Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- fixed: Android app icon showing as a blank dark circle when drawn small, such as on Wear OS watch notifications
- fixed: Stealth Swap transactions titled "Swap Funds" instead of "Stealth Swap"
- fixed: Runaway log requests in dev builds when the `LOG_SERVER` listener is down
- fixed: Missing gateway and legacy address warnings for Stealth Send addresses on another network

## 4.52.1 (2026-10-23)

Expand Down
120 changes: 117 additions & 3 deletions src/__tests__/components/AddressTile2.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { describe, expect, it, jest } from '@jest/globals'
import { fireEvent, render, waitFor } from '@testing-library/react-native'
import type { EdgeAddress, EdgeCurrencyWallet } from 'edge-core-js'
import { act, fireEvent, render, waitFor } from '@testing-library/react-native'
import type {
EdgeAddress,
EdgeCurrencyWallet,
EdgeMetadata
} from 'edge-core-js'
import * as React from 'react'
import { sprintf } from 'sprintf-js'

import { Airship } from '../../components/services/AirshipInstance'
import { AddressTile2 } from '../../components/tiles/AddressTile2'
import {
AddressTile2,
type AddressTileRef,
type ChangeAddressResult
} from '../../components/tiles/AddressTile2'
import { lstrings } from '../../locales/strings'
import type { EdgeAsset } from '../../types/types'
import { makeFakeCurrencyConfig } from '../../util/fake/fakeCurrencyConfig'
import { ethCurrencyInfo } from '../../util/fake/fakeEthInfo'
Expand Down Expand Up @@ -97,3 +107,107 @@ describe('AddressTile2 "Myself" picker', () => {
rendered.unmount()
})
})

describe('AddressTile2 cross-chain address', () => {
const PAYEE = '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e'
// Parsers flag a gateway code on the metadata, outside the core type:
const gatewayMetadata: EdgeMetadata & { gateway: boolean } = {
gateway: true
}

const renderCrossChainTile = (
onChangeAddress: (result: ChangeAddressResult) => Promise<void>
): {
ref: React.RefObject<AddressTileRef | null>
unmount: () => void
} => {
const wallet = makeWallet('source', async () => [])
const ref = React.createRef<AddressTileRef>()
const rootState: FakeState = {
core: {
account: {
currencyConfig: { ethereum: ethConfig },
currencyWallets: { source: wallet },
watch: () => () => {}
}
}
}
const rendered = render(
<FakeProviders initialState={rootState}>
<AddressTile2
ref={ref}
coreWallet={wallet}
tokenId={null}
title="Send to"
recipientAddress=""
onChangeAddress={onChangeAddress}
resetSendTransaction={() => {}}
isCameraOpen={false}
navigation={fakeNavigation}
parseCrossChainAddress={async () => ({
publicAddress: PAYEE,
currencyCode: 'USDT',
metadata: gatewayMetadata
})}
/>
</FakeProviders>
)
return { ref, unmount: rendered.unmount }
}

const answerModals = (
answer: boolean
): { titles: unknown[]; restore: () => void } => {
const titles: unknown[] = []
const spy = jest
.spyOn(Airship, 'show')
.mockImplementation(
async (renderModal: (bridge: never) => React.ReactNode) => {
const modal = renderModal(undefined as never) as React.ReactElement<{
title?: string
}>
titles.push(modal.props.title)
return answer
}
)
return {
titles,
restore: () => {
spy.mockRestore()
}
}
}

it("keeps a gateway code out when the payout's warning is declined", async () => {
const onChangeAddress = jest.fn(async (result: ChangeAddressResult) => {})
const modals = answerModals(false)
const tile = renderCrossChainTile(onChangeAddress)

await act(async () => {
await tile.ref.current?.onChangeAddress(`ethereum://${PAYEE}`)
})
expect(modals.titles).toEqual([
sprintf(lstrings.gateway_agreement_modal_title, 'USDT')
])
expect(onChangeAddress).not.toHaveBeenCalled()

modals.restore()
tile.unmount()
})

it('takes a gateway code once the warning is accepted', async () => {
const onChangeAddress = jest.fn(async (result: ChangeAddressResult) => {})
const modals = answerModals(true)
const tile = renderCrossChainTile(onChangeAddress)

await act(async () => {
await tile.ref.current?.onChangeAddress(`ethereum://${PAYEE}`)
})
expect(onChangeAddress).toHaveBeenCalledWith(
expect.objectContaining({ parsedUri: { publicAddress: PAYEE } })
)

modals.restore()
tile.unmount()
})
})
56 changes: 44 additions & 12 deletions src/__tests__/scenes/SendScene2.recipientNetwork.ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ const ZCASH_TRANSPARENT_WARNING = 'Transparent Zcash payout'
* native units, plus an XRP-style `dt` destination tag. Like the real parsers
* it names no asset for either, so the wallet stamps the asset it was asked
* about on both. An EIP-681 token `transfer` reads only when asked with a
* token's code, as an EVM parser refuses it asked with the coin's, and a
* `gateway=1` parameter marks it a gateway code. Each read is logged in
* token's code, as an EVM parser refuses it asked with the coin's. A
* `gateway=1` parameter on any code marks it a gateway code. Each read is logged in
* `parseCalls`.
*/
interface GatewayMetadata extends EdgeMetadata {
Expand All @@ -148,6 +148,11 @@ const makeParsingPlugin = (
): Promise<EdgeParsedUri> => {
parseCalls.push({ text: uri, currencyCode })
const [target, query = ''] = uri.replace(/^[a-z]+:/, '').split('?')
const gateway = /(?:^|&)gateway=1(?:&|$)/.test(query)
// EVM parsers flag a gateway code on the metadata, outside the core
// type, which is how the scan warning reads it:
const gatewayMetadata: GatewayMetadata = { gateway }
const metadata = gateway ? gatewayMetadata : undefined
if (target.endsWith('/transfer')) {
if (
currencyCode == null ||
Expand All @@ -160,23 +165,20 @@ const makeParsingPlugin = (
throw new Error('InvalidPublicAddressError')
}
const amount = /(?:^|&)uint256=(\d+)/.exec(query)?.[1]
const gateway = /(?:^|&)gateway=1(?:&|$)/.test(query)
// EVM parsers flag a gateway code on the metadata, outside the core
// type, which is how the scan warning reads it:
const metadata: GatewayMetadata = { gateway }
return {
publicAddress: payee,
nativeAmount: amount,
metadata: gateway ? metadata : undefined
}
return { publicAddress: payee, nativeAmount: amount, metadata }
}
const publicAddress = target.split('@')[0]
if (!addressFormat.test(publicAddress)) {
throw new Error('InvalidPublicAddressError')
}
const amount = /(?:^|&)(?:amount|value)=(\d+)/.exec(query)?.[1]
const tag = /(?:^|&)dt=(\d+)/.exec(query)?.[1]
return { publicAddress, nativeAmount: amount, uniqueIdentifier: tag }
return {
publicAddress,
nativeAmount: amount,
uniqueIdentifier: tag,
metadata
}
}
return Object.assign(tools, { parseUri })
}
Expand Down Expand Up @@ -710,6 +712,36 @@ describe('SendScene2 recipient network', () => {
scene.unmount()
})

it("keeps another network's gateway code out when its warning is declined", async () => {
const scene = renderBitcoinSend()
const titles: unknown[] = []
const show = jest
.spyOn(Airship, 'show')
.mockImplementation(
async (renderModal: (bridge: never) => React.ReactNode) => {
const modal = renderModal(undefined as never) as React.ReactElement<{
title?: string
}>
titles.push(modal.props.title)
return false
}
)

// Detection reads the code with the matched chain's parser, which flags
// the gateway the same as the wallet's own parser would:
expect(
await scene.enterUnparsedAddress(`ethereum:${EVM_ADDRESS}@1?gateway=1`)
).toBe(true)
expect(titles).toEqual([
sprintf(lstrings.gateway_agreement_modal_title, 'ETH')
])
expect(scene.recipientAddress()).toBe('')
expect(mockShowToast).not.toHaveBeenCalled()

show.mockRestore()
scene.unmount()
})

it("switches back for an address on the wallet's own network", async () => {
const scene = renderBitcoinSend()
await scene.enterUnparsedAddress(`ethereum:${EVM_ADDRESS}@1`)
Expand Down
56 changes: 49 additions & 7 deletions src/__tests__/util/paymentUri.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from '@jest/globals'
import type { EdgeCurrencyConfig, EdgeParsedUri } from 'edge-core-js'
import type {
EdgeCurrencyConfig,
EdgeMetadata,
EdgeParsedUri
} from 'edge-core-js'

import { makeFakeCurrencyConfig } from '../../util/fake/fakeCurrencyConfig'
import {
Expand Down Expand Up @@ -133,11 +137,33 @@ describe('parseCrossChainPayment', () => {
)
).toEqual({
publicAddress: 'rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh',
currencyCode: 'XRP',
nativeAmount: '20000000',
memo: '987654'
})
})

it("carries the parser's gateway flag and legacy address for the warnings", async () => {
// Parsers flag a gateway code on the metadata, outside the core type:
const metadata: EdgeMetadata & { gateway: boolean } = { gateway: true }
const config = makeConfig(async () => ({
publicAddress: 'rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh',
legacyAddress: 'rLegacy',
metadata
}))
expect(
await parseCrossChainPayment(
config,
'ripple://rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh'
)
).toEqual({
publicAddress: 'rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh',
currencyCode: 'XRP',
legacyAddress: 'rLegacy',
metadata: { gateway: true }
})
})

it("asks for the chain's own coin", async () => {
let asked: string | undefined
const config = makeConfig(async (uri, currencyCode) => {
Expand Down Expand Up @@ -178,7 +204,11 @@ describe('parseCrossChainPayment', () => {
}))
expect(
await parseCrossChainPayment(config, 'ethereum:0x', USDC_TOKEN_ID)
).toEqual({ publicAddress: PAYEE, nativeAmount: '1000000' })
).toEqual({
publicAddress: PAYEE,
currencyCode: 'XRP',
nativeAmount: '1000000'
})
})

it("rejects another token's code on a token payout", async () => {
Expand All @@ -200,7 +230,7 @@ describe('parseCrossChainPayment', () => {
}))
expect(
await parseCrossChainPayment(config, 'ethereum:0x', USDC_TOKEN_ID)
).toEqual({ publicAddress: PAYEE })
).toEqual({ publicAddress: PAYEE, currencyCode: 'XRP' })
})

it('rejects a result with no address', async () => {
Expand Down Expand Up @@ -241,7 +271,11 @@ describe('parseCrossChainPayment', () => {
it('accepts a token invoice the parser only reads as that token', async () => {
expect(
await parseCrossChainPayment(makeEvmConfig(), USDT_INVOICE, USDT_TOKEN_ID)
).toEqual({ publicAddress: PAYEE, nativeAmount: '25000000' })
).toEqual({
publicAddress: PAYEE,
currencyCode: 'USDT',
nativeAmount: '25000000'
})
})

it('still reads a coin code as the coin on a token payout', async () => {
Expand All @@ -251,7 +285,7 @@ describe('parseCrossChainPayment', () => {
`ethereum:${PAYEE}?value=2000000000000000`,
USDT_TOKEN_ID
)
).toEqual({ publicAddress: PAYEE })
).toEqual({ publicAddress: PAYEE, currencyCode: 'USDT' })
})

it('rejects a token invoice on a coin payout', async () => {
Expand Down Expand Up @@ -568,11 +602,19 @@ describe('findPayableChains', () => {
).toEqual([
{
chain: 'alsoAccepts',
payment: { publicAddress: '0xabc', nativeAmount: '5' }
payment: {
publicAddress: '0xabc',
currencyCode: 'POL',
nativeAmount: '5'
}
},
{
chain: 'accepts',
payment: { publicAddress: '0xabc', nativeAmount: '5' }
payment: {
publicAddress: '0xabc',
currencyCode: 'POL',
nativeAmount: '5'
}
}
])
})
Expand Down
3 changes: 3 additions & 0 deletions src/components/scenes/SendScene2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,9 @@ const SendComponent: React.FC<Props> = props => {
chain = picked.chain
payment = picked.payment
}
// A gateway or legacy-address warning the user declines is a cancel,
// so the address is acknowledged without being adopted:
if (!(await addressWarnings(payment, payment.currencyCode))) return true
const { publicAddress, nativeAmount, memo } = payment

// A scanned exchange deposit code carries the tag that credits the
Expand Down
3 changes: 3 additions & 0 deletions src/components/tiles/AddressTile2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ export const AddressTile2 = React.forwardRef(
await rejectUnparsedAddress(address, addressEntryMethod)
return
}
// Declining a gateway or legacy-address warning is a cancel, not an
// invalid address:
if (!(await addressWarnings(payment, payment.currencyCode))) return
await onChangeAddress({
parsedUri: { publicAddress: payment.publicAddress },
addressEntryMethod,
Expand Down
Loading
Loading