Skip to content
Merged
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 @@ -6,6 +6,7 @@
- changed: Route maestro test builds to a dedicated Zealot channel so they no longer appear in the production release list.
- changed: Hide the wallet "Get Raw Keys" option behind Developer Mode, while still showing it for wallets that fail to load.
- fixed: Share button referral link now uses the dl.edge.app deep-link domain so appreferred attribution is tracked
- fixed: MoonPay "Send with Edge" sell link now opens the app to a pre-filled Send scene. All ramp redirect URLs (payment, success, fail, cancel) point at the claimed deep.edge.app.
- removed: SideShift `privateKey` from env config; the swap integration no longer sends the affiliate secret header.

## 4.49.0 (staging)
Expand Down
1 change: 0 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,6 @@ export default [

'src/plugins/gui/providers/bityProvider.ts',

'src/plugins/gui/providers/moonpayProvider.ts',
'src/plugins/gui/providers/mtpelerinProvider.ts',

'src/plugins/gui/providers/revolutProvider.ts',
Expand Down
94 changes: 94 additions & 0 deletions src/__tests__/DeepLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,100 @@ describe('parseDeepLink', function () {
})
})

describe('paymentRedirect', () => {
makeLinkTests({
// Real-world MoonPay "Send with Edge" sell link (extra params ignored):
'https://edge.app/redirect/payment/?transactionId=6ae325aa-d930-47cd-9ef0-d26e03b68f3c&baseCurrencyCode=btc&baseCurrencyAmount=0.00212&depositWalletAddress=bc1qqp44yqt9nzrca7cw4hrl2hu5nmpw5fg0r32z62&paymentMethod=moonpay_balance':
{
type: 'paymentRedirect',
currencyCode: 'btc',
depositAddress: 'bc1qqp44yqt9nzrca7cw4hrl2hu5nmpw5fg0r32z62',
amount: '0.00212',
addressTag: undefined
},
// XRP needs a destination tag, carried as depositWalletAddressTag:
'edge://redirect/payment/?baseCurrencyCode=xrp&baseCurrencyAmount=10&depositWalletAddress=rEXAMPLExrpADDRESS&depositWalletAddressTag=123456':
{
type: 'paymentRedirect',
currencyCode: 'xrp',
depositAddress: 'rEXAMPLExrpADDRESS',
amount: '10',
addressTag: '123456'
},
// deep.edge.app is an already-claimed universal-link host:
'https://deep.edge.app/redirect/payment/?baseCurrencyCode=ltc&baseCurrencyAmount=1.5&depositWalletAddress=ltc1qexample':
{
type: 'paymentRedirect',
currencyCode: 'ltc',
depositAddress: 'ltc1qexample',
amount: '1.5',
addressTag: undefined
},
// A non-numeric or empty baseCurrencyAmount is dropped (left undefined) so
// the Send scene opens without a bogus/zero amount, instead of pre-filling
// '0' or throwing from biggystring after the wallet picker:
'edge://redirect/payment/?baseCurrencyCode=btc&depositWalletAddress=bc1qexample&baseCurrencyAmount=':
{
type: 'paymentRedirect',
currencyCode: 'btc',
depositAddress: 'bc1qexample',
amount: undefined,
addressTag: undefined
},
'edge://redirect/payment/?baseCurrencyCode=btc&depositWalletAddress=bc1qexample&baseCurrencyAmount=notanumber':
{
type: 'paymentRedirect',
currencyCode: 'btc',
depositAddress: 'bc1qexample',
amount: undefined,
addressTag: undefined
},
// A present-but-blank destination tag is treated as absent (no memo
// forwarded to the Send scene), not an empty-string uniqueIdentifier:
'edge://redirect/payment/?baseCurrencyCode=xrp&depositWalletAddress=rEXAMPLExrpADDRESS&depositWalletAddressTag=':
{
type: 'paymentRedirect',
currencyCode: 'xrp',
depositAddress: 'rEXAMPLExrpADDRESS',
amount: undefined,
addressTag: undefined
},
// A present-but-blank required param degrades to a no-op, same as when it
// is absent (a blank address/asset must not open the Send flow):
'edge://redirect/payment/?baseCurrencyCode=btc&depositWalletAddress=': {
type: 'noop'
},
'edge://redirect/payment/?baseCurrencyCode=&depositWalletAddress=bc1qexample':
{ type: 'noop' },
// Missing params degrade to a no-op instead of an "Unknown deep link
// format" error (edge://) or a browser-opened dead apex page. Both the
// claimed and legacy apex hosts behave identically:
'https://edge.app/redirect/payment/?baseCurrencyCode=btc': {
type: 'noop'
},
'edge://redirect/payment/?baseCurrencyCode=btc': { type: 'noop' },
'https://deep.edge.app/redirect/payment/': { type: 'noop' }
})
})

describe('redirect terminal states', () => {
makeLinkTests({
// The provider terminal redirects (success/fail/cancel) carry no
// actionable payload, so an externally-tapped link opens the app via a
// no-op on every host: edge://, the claimed deep.edge.app, and the legacy
// apex edge.app (old orders may still point there).
'edge://redirect/success/': { type: 'noop' },
'edge://redirect/fail/': { type: 'noop' },
'edge://redirect/cancel/': { type: 'noop' },
'https://deep.edge.app/redirect/success/': { type: 'noop' },
'https://deep.edge.app/redirect/fail/': { type: 'noop' },
'https://deep.edge.app/redirect/cancel/': { type: 'noop' },
'https://edge.app/redirect/success/': { type: 'noop' },
'https://edge.app/redirect/fail/': { type: 'noop' },
'https://edge.app/redirect/cancel/': { type: 'noop' }
})
})

describe('plugin', function () {
makeLinkTests({
'edge://plugin/simplex/rabbit/hole?param=alice': {
Expand Down
66 changes: 66 additions & 0 deletions src/__tests__/plugins/gui/providers/common.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from '@jest/globals'

import { isReturnUrl } from '../../../../plugins/gui/providers/common'

describe('isReturnUrl', () => {
it('matches the claimed deep.edge.app host per kind', () => {
expect(
isReturnUrl('https://deep.edge.app/redirect/payment/', 'payment')
).toBe(true)
expect(
isReturnUrl('https://deep.edge.app/redirect/success/', 'success')
).toBe(true)
expect(isReturnUrl('https://deep.edge.app/redirect/fail/', 'fail')).toBe(
true
)
expect(
isReturnUrl('https://deep.edge.app/redirect/cancel/', 'cancel')
).toBe(true)
})

it('also matches the legacy apex edge.app host so pre-switch orders still intercept', () => {
expect(isReturnUrl('https://edge.app/redirect/payment/', 'payment')).toBe(
true
)
expect(isReturnUrl('https://edge.app/redirect/success/', 'success')).toBe(
true
)
expect(isReturnUrl('https://edge.app/redirect/fail/', 'fail')).toBe(true)
expect(isReturnUrl('https://edge.app/redirect/cancel/', 'cancel')).toBe(
true
)
})

it('matches when the provider appends query params or path suffix (startsWith)', () => {
expect(
isReturnUrl(
'https://deep.edge.app/redirect/payment/?orderId=abc',
'payment'
)
).toBe(true)
expect(
isReturnUrl('https://edge.app/redirect/success/?status=ok', 'success')
).toBe(true)
})

it('does not cross-match different kinds', () => {
expect(
isReturnUrl('https://deep.edge.app/redirect/success/', 'cancel')
).toBe(false)
expect(
isReturnUrl('https://deep.edge.app/redirect/cancel/', 'success')
).toBe(false)
})

it('rejects unrelated or look-alike hosts', () => {
expect(
isReturnUrl('https://evil.edge.app/redirect/payment/', 'payment')
).toBe(false)
expect(
isReturnUrl('https://deep.edge.app.evil.com/redirect/payment/', 'payment')
).toBe(false)
expect(
isReturnUrl('https://example.com/redirect/payment/', 'payment')
).toBe(false)
})
})
80 changes: 80 additions & 0 deletions src/actions/DeepLinkingActions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mul } from 'biggystring'
import type { EdgeParsedUri, EdgeTokenId } from 'edge-core-js'
import * as React from 'react'
import { Linking } from 'react-native'
Expand All @@ -21,6 +22,7 @@ import {
fiatProviderDeeplinkHandler
} from '../plugins/gui/fiatPlugin'
import { rampDeeplinkManager } from '../plugins/ramps/rampDeeplinkHandler'
import { getExchangeDenom } from '../selectors/DenominationSelectors'
import { config } from '../theme/appConfig'
import type { DeepLink } from '../types/DeepLinkTypes'
import type { Dispatch, RootState, ThunkAction } from '../types/reduxTypes'
Expand Down Expand Up @@ -233,6 +235,84 @@ async function handleLink(
})
break

case 'paymentRedirect': {
// A provider sell-completion redirect (e.g. MoonPay "Send with Edge").
// Resolve the provider's base currency code to candidate assets, then
// open the Send scene pre-filled with the deposit address, amount, and
// destination tag / memo so the user can finish the sell order.
const { currencyCode, depositAddress, amount, addressTag } = link

// Collect every native AND token asset that shares the symbol, and let
// the user disambiguate via the wallet picker. A provider sell can be a
// token whose ticker collides with another chain's native asset (the
// provider disambiguates by network metadata we do not get here), so we
// must not exclude token matches when a native one also matches. Iterate
// `allTokens` (builtin + user-added custom tokens) rather than
// `builtinTokens`: pickWallet matches wallets by their enabled token ids,
// which include custom tokens, so a sell of a custom token would
// otherwise resolve zero assets and wrongly report "no wallet".
const symbol = currencyCode.split('_')[0].toUpperCase()
Comment thread
j0ntz marked this conversation as resolved.
const assets: EdgeAsset[] = []
for (const pluginId of Object.keys(account.currencyConfig)) {
Comment thread
j0ntz marked this conversation as resolved.
const currencyConfig = account.currencyConfig[pluginId]
if (currencyConfig.currencyInfo.currencyCode.toUpperCase() === symbol) {
assets.push({ pluginId, tokenId: null })
}
const { allTokens } = currencyConfig
for (const tokenId of Object.keys(allTokens)) {
if (allTokens[tokenId].currencyCode.toUpperCase() === symbol) {
assets.push({ pluginId, tokenId })
}
}
}

if (assets.length === 0) {
showToast(lstrings.alert_deep_link_no_wallet_for_uri)
break
}

const result = await pickWallet({
account,
assets,
navigation,
showCreateWallet: true
})
if (result?.type !== 'wallet') break
const { walletId, tokenId } = result
const wallet = account.currencyWallets[walletId]
if (wallet == null) break

// A token-metadata refresh race could leave the picked tokenId
// unresolvable, in which case getExchangeDenom silently returns a
// multiplier of '1' and mul() would treat a decimal amount as already
// native (a wildly wrong send amount). Abort with a toast rather than
// pre-filling a wrong amount.
if (
amount != null &&
tokenId != null &&
wallet.currencyConfig.allTokens[tokenId] == null
) {
showToast(lstrings.alert_deep_link_no_wallet_for_uri)
break
}
const nativeAmount =
amount != null
? mul(
amount,
getExchangeDenom(wallet.currencyConfig, tokenId).multiplier
Comment thread
j0ntz marked this conversation as resolved.
)
: undefined

const parsedUri: EdgeParsedUri = {
publicAddress: depositAddress,
nativeAmount,
uniqueIdentifier: addressTag,
tokenId
}
await dispatch(handleWalletUris(navigation, wallet, parsedUri))
break
}

case 'price-change': {
const { pluginId, body } = link
const currencyCode =
Expand Down
7 changes: 4 additions & 3 deletions src/plugins/gui/providers/banxaProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { addTokenToArray } from '../util/providerUtils'
import {
addExactRegion,
isDailyCheckDue,
isReturnUrl,
NOT_SUCCESS_TOAST_HIDE_MS,
RETURN_URL_CANCEL,
RETURN_URL_FAIL,
Expand Down Expand Up @@ -849,18 +850,18 @@ export const banxaProvider: FiatProviderFactory = {
changeUrl: string
): Promise<void> => {
console.log(`onUrlChange url=${changeUrl}`)
if (changeUrl === RETURN_URL_SUCCESS) {
if (isReturnUrl(changeUrl, 'success')) {
clearInterval(interval)

await showUi.exitScene()
} else if (changeUrl === RETURN_URL_CANCEL) {
} else if (isReturnUrl(changeUrl, 'cancel')) {
clearInterval(interval)
await showUi.showToast(
lstrings.fiat_plugin_sell_cancelled,
NOT_SUCCESS_TOAST_HIDE_MS
)
await showUi.exitScene()
} else if (changeUrl === RETURN_URL_FAIL) {
} else if (isReturnUrl(changeUrl, 'fail')) {
clearInterval(interval)
await showUi.showToast(
lstrings.fiat_plugin_sell_failed_try_again,
Expand Down
36 changes: 32 additions & 4 deletions src/plugins/gui/providers/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,38 @@ import {
type FiatProviderSupportedRegions
} from '../fiatProviderTypes'

export const RETURN_URL_SUCCESS = 'https://edge.app/redirect/success/'
export const RETURN_URL_FAIL = 'https://edge.app/redirect/fail/'
export const RETURN_URL_CANCEL = 'https://edge.app/redirect/cancel/'
export const RETURN_URL_PAYMENT = 'https://edge.app/redirect/payment/'
// Ramp redirect base hosts. Derive both the outbound `RETURN_URL_*` constants
// and the inbound `isReturnUrl` matcher from these two so a host migration is a
// single edit that can never leave the hand-off and match sides out of sync.
// `deep.edge.app` is claimed as a universal link on iOS and Android (the apex
// `edge.app` is NOT), matching the buy redirects. The legacy apex host lingers
// only on orders created before the switch.
const CLAIMED_REDIRECT_HOST = 'https://deep.edge.app/redirect/'
const LEGACY_REDIRECT_HOST = 'https://edge.app/redirect/'

// These URLs are handed to the ramp providers as sell redirect / callback URLs
// and can be reflected back to the user outside the app (provider email or
// order-history "complete payment" button), so they must live on the claimed
// host: an external click opens the app and the deep-link parser routes it
// (PAYMENT to the pre-filled Send scene; the terminal states to a harmless
// no-op). We always hand providers the claimed-host URL going forward.
export const RETURN_URL_SUCCESS = `${CLAIMED_REDIRECT_HOST}success/`
export const RETURN_URL_FAIL = `${CLAIMED_REDIRECT_HOST}fail/`
export const RETURN_URL_CANCEL = `${CLAIMED_REDIRECT_HOST}cancel/`
export const RETURN_URL_PAYMENT = `${CLAIMED_REDIRECT_HOST}payment/`

// Match a ramp redirect URL as it appears inside the provider WebView. Orders
// created before the host switch still carry the legacy apex `edge.app` host —
// MoonPay persists the payment redirect server-side and can resurface it days
// later — so the in-WebView interceptors match BOTH the claimed and legacy hosts
// to keep catching a legacy order resumed after an app update. Use this in place
// of a raw `startsWith`/`===` against a single constant.
const RETURN_URL_REDIRECT_HOSTS = [CLAIMED_REDIRECT_HOST, LEGACY_REDIRECT_HOST]
export const isReturnUrl = (
uri: string,
kind: 'payment' | 'success' | 'fail' | 'cancel'
): boolean =>
RETURN_URL_REDIRECT_HOSTS.some(host => uri.startsWith(`${host}${kind}/`))

export const NOT_SUCCESS_TOAST_HIDE_MS = 5000

Expand Down
3 changes: 2 additions & 1 deletion src/plugins/gui/providers/moonpayProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { addTokenToArray } from '../util/providerUtils'
import {
addExactRegion,
isDailyCheckDue,
isReturnUrl,
NOT_SUCCESS_TOAST_HIDE_MS,
RETURN_URL_PAYMENT,
validateExactRegion
Expand Down Expand Up @@ -725,7 +726,7 @@ export const moonpayProvider: FiatProviderFactory = {
const onUrlChangeAsync = async (uri: string): Promise<void> => {
console.log('Moonpay WebView url change: ' + uri)

if (uri.startsWith(RETURN_URL_PAYMENT)) {
if (isReturnUrl(uri, 'payment')) {
console.log('Moonpay WebView launch payment: ' + uri)
const urlObj = new URL(uri, true)
const { query } = urlObj
Expand Down
5 changes: 3 additions & 2 deletions src/plugins/gui/providers/paybisProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import { assert, isWalletTestnet } from '../pluginUtils'
import { addTokenToArray } from '../util/providerUtils'
import {
isReturnUrl,
NOT_SUCCESS_TOAST_HIDE_MS,
RETURN_URL_FAIL,
RETURN_URL_PAYMENT,
Expand Down Expand Up @@ -911,13 +912,13 @@ export const paybisProvider: FiatProviderFactory = {
newUrl: string
): Promise<void> => {
console.log(`*** onUrlChange: ${newUrl}`)
if (newUrl.startsWith(RETURN_URL_FAIL)) {
if (isReturnUrl(newUrl, 'fail')) {
await showUi.exitScene()
await showUi.showToast(
lstrings.fiat_plugin_sell_failed_try_again,
NOT_SUCCESS_TOAST_HIDE_MS
)
} else if (newUrl.startsWith(RETURN_URL_PAYMENT)) {
} else if (isReturnUrl(newUrl, 'payment')) {
if (inPayment) return
inPayment = true
try {
Expand Down
Loading
Loading