Skip to content

Commit 5c83012

Browse files
authored
Merge pull request #6056 from EdgeApp/jon/moonpay-payment-deeplink
Fix MoonPay "Send with Edge" sell link
2 parents 659e056 + 77117ab commit 5c83012

14 files changed

Lines changed: 403 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- changed: Route maestro test builds to a dedicated Zealot channel so they no longer appear in the production release list.
77
- changed: Hide the wallet "Get Raw Keys" option behind Developer Mode, while still showing it for wallets that fail to load.
88
- fixed: Share button referral link now uses the dl.edge.app deep-link domain so appreferred attribution is tracked
9+
- 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.
910
- removed: SideShift `privateKey` from env config; the swap integration no longer sends the affiliate secret header.
1011

1112
## 4.49.0 (staging)

eslint.config.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,6 @@ export default [
455455

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

458-
'src/plugins/gui/providers/moonpayProvider.ts',
459458
'src/plugins/gui/providers/mtpelerinProvider.ts',
460459

461460
'src/plugins/gui/providers/revolutProvider.ts',

src/__tests__/DeepLink.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,100 @@ describe('parseDeepLink', function () {
189189
})
190190
})
191191

192+
describe('paymentRedirect', () => {
193+
makeLinkTests({
194+
// Real-world MoonPay "Send with Edge" sell link (extra params ignored):
195+
'https://edge.app/redirect/payment/?transactionId=6ae325aa-d930-47cd-9ef0-d26e03b68f3c&baseCurrencyCode=btc&baseCurrencyAmount=0.00212&depositWalletAddress=bc1qqp44yqt9nzrca7cw4hrl2hu5nmpw5fg0r32z62&paymentMethod=moonpay_balance':
196+
{
197+
type: 'paymentRedirect',
198+
currencyCode: 'btc',
199+
depositAddress: 'bc1qqp44yqt9nzrca7cw4hrl2hu5nmpw5fg0r32z62',
200+
amount: '0.00212',
201+
addressTag: undefined
202+
},
203+
// XRP needs a destination tag, carried as depositWalletAddressTag:
204+
'edge://redirect/payment/?baseCurrencyCode=xrp&baseCurrencyAmount=10&depositWalletAddress=rEXAMPLExrpADDRESS&depositWalletAddressTag=123456':
205+
{
206+
type: 'paymentRedirect',
207+
currencyCode: 'xrp',
208+
depositAddress: 'rEXAMPLExrpADDRESS',
209+
amount: '10',
210+
addressTag: '123456'
211+
},
212+
// deep.edge.app is an already-claimed universal-link host:
213+
'https://deep.edge.app/redirect/payment/?baseCurrencyCode=ltc&baseCurrencyAmount=1.5&depositWalletAddress=ltc1qexample':
214+
{
215+
type: 'paymentRedirect',
216+
currencyCode: 'ltc',
217+
depositAddress: 'ltc1qexample',
218+
amount: '1.5',
219+
addressTag: undefined
220+
},
221+
// A non-numeric or empty baseCurrencyAmount is dropped (left undefined) so
222+
// the Send scene opens without a bogus/zero amount, instead of pre-filling
223+
// '0' or throwing from biggystring after the wallet picker:
224+
'edge://redirect/payment/?baseCurrencyCode=btc&depositWalletAddress=bc1qexample&baseCurrencyAmount=':
225+
{
226+
type: 'paymentRedirect',
227+
currencyCode: 'btc',
228+
depositAddress: 'bc1qexample',
229+
amount: undefined,
230+
addressTag: undefined
231+
},
232+
'edge://redirect/payment/?baseCurrencyCode=btc&depositWalletAddress=bc1qexample&baseCurrencyAmount=notanumber':
233+
{
234+
type: 'paymentRedirect',
235+
currencyCode: 'btc',
236+
depositAddress: 'bc1qexample',
237+
amount: undefined,
238+
addressTag: undefined
239+
},
240+
// A present-but-blank destination tag is treated as absent (no memo
241+
// forwarded to the Send scene), not an empty-string uniqueIdentifier:
242+
'edge://redirect/payment/?baseCurrencyCode=xrp&depositWalletAddress=rEXAMPLExrpADDRESS&depositWalletAddressTag=':
243+
{
244+
type: 'paymentRedirect',
245+
currencyCode: 'xrp',
246+
depositAddress: 'rEXAMPLExrpADDRESS',
247+
amount: undefined,
248+
addressTag: undefined
249+
},
250+
// A present-but-blank required param degrades to a no-op, same as when it
251+
// is absent (a blank address/asset must not open the Send flow):
252+
'edge://redirect/payment/?baseCurrencyCode=btc&depositWalletAddress=': {
253+
type: 'noop'
254+
},
255+
'edge://redirect/payment/?baseCurrencyCode=&depositWalletAddress=bc1qexample':
256+
{ type: 'noop' },
257+
// Missing params degrade to a no-op instead of an "Unknown deep link
258+
// format" error (edge://) or a browser-opened dead apex page. Both the
259+
// claimed and legacy apex hosts behave identically:
260+
'https://edge.app/redirect/payment/?baseCurrencyCode=btc': {
261+
type: 'noop'
262+
},
263+
'edge://redirect/payment/?baseCurrencyCode=btc': { type: 'noop' },
264+
'https://deep.edge.app/redirect/payment/': { type: 'noop' }
265+
})
266+
})
267+
268+
describe('redirect terminal states', () => {
269+
makeLinkTests({
270+
// The provider terminal redirects (success/fail/cancel) carry no
271+
// actionable payload, so an externally-tapped link opens the app via a
272+
// no-op on every host: edge://, the claimed deep.edge.app, and the legacy
273+
// apex edge.app (old orders may still point there).
274+
'edge://redirect/success/': { type: 'noop' },
275+
'edge://redirect/fail/': { type: 'noop' },
276+
'edge://redirect/cancel/': { type: 'noop' },
277+
'https://deep.edge.app/redirect/success/': { type: 'noop' },
278+
'https://deep.edge.app/redirect/fail/': { type: 'noop' },
279+
'https://deep.edge.app/redirect/cancel/': { type: 'noop' },
280+
'https://edge.app/redirect/success/': { type: 'noop' },
281+
'https://edge.app/redirect/fail/': { type: 'noop' },
282+
'https://edge.app/redirect/cancel/': { type: 'noop' }
283+
})
284+
})
285+
192286
describe('plugin', function () {
193287
makeLinkTests({
194288
'edge://plugin/simplex/rabbit/hole?param=alice': {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it } from '@jest/globals'
2+
3+
import { isReturnUrl } from '../../../../plugins/gui/providers/common'
4+
5+
describe('isReturnUrl', () => {
6+
it('matches the claimed deep.edge.app host per kind', () => {
7+
expect(
8+
isReturnUrl('https://deep.edge.app/redirect/payment/', 'payment')
9+
).toBe(true)
10+
expect(
11+
isReturnUrl('https://deep.edge.app/redirect/success/', 'success')
12+
).toBe(true)
13+
expect(isReturnUrl('https://deep.edge.app/redirect/fail/', 'fail')).toBe(
14+
true
15+
)
16+
expect(
17+
isReturnUrl('https://deep.edge.app/redirect/cancel/', 'cancel')
18+
).toBe(true)
19+
})
20+
21+
it('also matches the legacy apex edge.app host so pre-switch orders still intercept', () => {
22+
expect(isReturnUrl('https://edge.app/redirect/payment/', 'payment')).toBe(
23+
true
24+
)
25+
expect(isReturnUrl('https://edge.app/redirect/success/', 'success')).toBe(
26+
true
27+
)
28+
expect(isReturnUrl('https://edge.app/redirect/fail/', 'fail')).toBe(true)
29+
expect(isReturnUrl('https://edge.app/redirect/cancel/', 'cancel')).toBe(
30+
true
31+
)
32+
})
33+
34+
it('matches when the provider appends query params or path suffix (startsWith)', () => {
35+
expect(
36+
isReturnUrl(
37+
'https://deep.edge.app/redirect/payment/?orderId=abc',
38+
'payment'
39+
)
40+
).toBe(true)
41+
expect(
42+
isReturnUrl('https://edge.app/redirect/success/?status=ok', 'success')
43+
).toBe(true)
44+
})
45+
46+
it('does not cross-match different kinds', () => {
47+
expect(
48+
isReturnUrl('https://deep.edge.app/redirect/success/', 'cancel')
49+
).toBe(false)
50+
expect(
51+
isReturnUrl('https://deep.edge.app/redirect/cancel/', 'success')
52+
).toBe(false)
53+
})
54+
55+
it('rejects unrelated or look-alike hosts', () => {
56+
expect(
57+
isReturnUrl('https://evil.edge.app/redirect/payment/', 'payment')
58+
).toBe(false)
59+
expect(
60+
isReturnUrl('https://deep.edge.app.evil.com/redirect/payment/', 'payment')
61+
).toBe(false)
62+
expect(
63+
isReturnUrl('https://example.com/redirect/payment/', 'payment')
64+
).toBe(false)
65+
})
66+
})

src/actions/DeepLinkingActions.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { mul } from 'biggystring'
12
import type { EdgeParsedUri, EdgeTokenId } from 'edge-core-js'
23
import * as React from 'react'
34
import { Linking } from 'react-native'
@@ -21,6 +22,7 @@ import {
2122
fiatProviderDeeplinkHandler
2223
} from '../plugins/gui/fiatPlugin'
2324
import { rampDeeplinkManager } from '../plugins/ramps/rampDeeplinkHandler'
25+
import { getExchangeDenom } from '../selectors/DenominationSelectors'
2426
import { config } from '../theme/appConfig'
2527
import type { DeepLink } from '../types/DeepLinkTypes'
2628
import type { Dispatch, RootState, ThunkAction } from '../types/reduxTypes'
@@ -233,6 +235,84 @@ async function handleLink(
233235
})
234236
break
235237

238+
case 'paymentRedirect': {
239+
// A provider sell-completion redirect (e.g. MoonPay "Send with Edge").
240+
// Resolve the provider's base currency code to candidate assets, then
241+
// open the Send scene pre-filled with the deposit address, amount, and
242+
// destination tag / memo so the user can finish the sell order.
243+
const { currencyCode, depositAddress, amount, addressTag } = link
244+
245+
// Collect every native AND token asset that shares the symbol, and let
246+
// the user disambiguate via the wallet picker. A provider sell can be a
247+
// token whose ticker collides with another chain's native asset (the
248+
// provider disambiguates by network metadata we do not get here), so we
249+
// must not exclude token matches when a native one also matches. Iterate
250+
// `allTokens` (builtin + user-added custom tokens) rather than
251+
// `builtinTokens`: pickWallet matches wallets by their enabled token ids,
252+
// which include custom tokens, so a sell of a custom token would
253+
// otherwise resolve zero assets and wrongly report "no wallet".
254+
const symbol = currencyCode.split('_')[0].toUpperCase()
255+
const assets: EdgeAsset[] = []
256+
for (const pluginId of Object.keys(account.currencyConfig)) {
257+
const currencyConfig = account.currencyConfig[pluginId]
258+
if (currencyConfig.currencyInfo.currencyCode.toUpperCase() === symbol) {
259+
assets.push({ pluginId, tokenId: null })
260+
}
261+
const { allTokens } = currencyConfig
262+
for (const tokenId of Object.keys(allTokens)) {
263+
if (allTokens[tokenId].currencyCode.toUpperCase() === symbol) {
264+
assets.push({ pluginId, tokenId })
265+
}
266+
}
267+
}
268+
269+
if (assets.length === 0) {
270+
showToast(lstrings.alert_deep_link_no_wallet_for_uri)
271+
break
272+
}
273+
274+
const result = await pickWallet({
275+
account,
276+
assets,
277+
navigation,
278+
showCreateWallet: true
279+
})
280+
if (result?.type !== 'wallet') break
281+
const { walletId, tokenId } = result
282+
const wallet = account.currencyWallets[walletId]
283+
if (wallet == null) break
284+
285+
// A token-metadata refresh race could leave the picked tokenId
286+
// unresolvable, in which case getExchangeDenom silently returns a
287+
// multiplier of '1' and mul() would treat a decimal amount as already
288+
// native (a wildly wrong send amount). Abort with a toast rather than
289+
// pre-filling a wrong amount.
290+
if (
291+
amount != null &&
292+
tokenId != null &&
293+
wallet.currencyConfig.allTokens[tokenId] == null
294+
) {
295+
showToast(lstrings.alert_deep_link_no_wallet_for_uri)
296+
break
297+
}
298+
const nativeAmount =
299+
amount != null
300+
? mul(
301+
amount,
302+
getExchangeDenom(wallet.currencyConfig, tokenId).multiplier
303+
)
304+
: undefined
305+
306+
const parsedUri: EdgeParsedUri = {
307+
publicAddress: depositAddress,
308+
nativeAmount,
309+
uniqueIdentifier: addressTag,
310+
tokenId
311+
}
312+
await dispatch(handleWalletUris(navigation, wallet, parsedUri))
313+
break
314+
}
315+
236316
case 'price-change': {
237317
const { pluginId, body } = link
238318
const currencyCode =

src/plugins/gui/providers/banxaProvider.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { addTokenToArray } from '../util/providerUtils'
3838
import {
3939
addExactRegion,
4040
isDailyCheckDue,
41+
isReturnUrl,
4142
NOT_SUCCESS_TOAST_HIDE_MS,
4243
RETURN_URL_CANCEL,
4344
RETURN_URL_FAIL,
@@ -849,18 +850,18 @@ export const banxaProvider: FiatProviderFactory = {
849850
changeUrl: string
850851
): Promise<void> => {
851852
console.log(`onUrlChange url=${changeUrl}`)
852-
if (changeUrl === RETURN_URL_SUCCESS) {
853+
if (isReturnUrl(changeUrl, 'success')) {
853854
clearInterval(interval)
854855

855856
await showUi.exitScene()
856-
} else if (changeUrl === RETURN_URL_CANCEL) {
857+
} else if (isReturnUrl(changeUrl, 'cancel')) {
857858
clearInterval(interval)
858859
await showUi.showToast(
859860
lstrings.fiat_plugin_sell_cancelled,
860861
NOT_SUCCESS_TOAST_HIDE_MS
861862
)
862863
await showUi.exitScene()
863-
} else if (changeUrl === RETURN_URL_FAIL) {
864+
} else if (isReturnUrl(changeUrl, 'fail')) {
864865
clearInterval(interval)
865866
await showUi.showToast(
866867
lstrings.fiat_plugin_sell_failed_try_again,

src/plugins/gui/providers/common.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,38 @@ import {
77
type FiatProviderSupportedRegions
88
} from '../fiatProviderTypes'
99

10-
export const RETURN_URL_SUCCESS = 'https://edge.app/redirect/success/'
11-
export const RETURN_URL_FAIL = 'https://edge.app/redirect/fail/'
12-
export const RETURN_URL_CANCEL = 'https://edge.app/redirect/cancel/'
13-
export const RETURN_URL_PAYMENT = 'https://edge.app/redirect/payment/'
10+
// Ramp redirect base hosts. Derive both the outbound `RETURN_URL_*` constants
11+
// and the inbound `isReturnUrl` matcher from these two so a host migration is a
12+
// single edit that can never leave the hand-off and match sides out of sync.
13+
// `deep.edge.app` is claimed as a universal link on iOS and Android (the apex
14+
// `edge.app` is NOT), matching the buy redirects. The legacy apex host lingers
15+
// only on orders created before the switch.
16+
const CLAIMED_REDIRECT_HOST = 'https://deep.edge.app/redirect/'
17+
const LEGACY_REDIRECT_HOST = 'https://edge.app/redirect/'
18+
19+
// These URLs are handed to the ramp providers as sell redirect / callback URLs
20+
// and can be reflected back to the user outside the app (provider email or
21+
// order-history "complete payment" button), so they must live on the claimed
22+
// host: an external click opens the app and the deep-link parser routes it
23+
// (PAYMENT to the pre-filled Send scene; the terminal states to a harmless
24+
// no-op). We always hand providers the claimed-host URL going forward.
25+
export const RETURN_URL_SUCCESS = `${CLAIMED_REDIRECT_HOST}success/`
26+
export const RETURN_URL_FAIL = `${CLAIMED_REDIRECT_HOST}fail/`
27+
export const RETURN_URL_CANCEL = `${CLAIMED_REDIRECT_HOST}cancel/`
28+
export const RETURN_URL_PAYMENT = `${CLAIMED_REDIRECT_HOST}payment/`
29+
30+
// Match a ramp redirect URL as it appears inside the provider WebView. Orders
31+
// created before the host switch still carry the legacy apex `edge.app` host —
32+
// MoonPay persists the payment redirect server-side and can resurface it days
33+
// later — so the in-WebView interceptors match BOTH the claimed and legacy hosts
34+
// to keep catching a legacy order resumed after an app update. Use this in place
35+
// of a raw `startsWith`/`===` against a single constant.
36+
const RETURN_URL_REDIRECT_HOSTS = [CLAIMED_REDIRECT_HOST, LEGACY_REDIRECT_HOST]
37+
export const isReturnUrl = (
38+
uri: string,
39+
kind: 'payment' | 'success' | 'fail' | 'cancel'
40+
): boolean =>
41+
RETURN_URL_REDIRECT_HOSTS.some(host => uri.startsWith(`${host}${kind}/`))
1442

1543
export const NOT_SUCCESS_TOAST_HIDE_MS = 5000
1644

src/plugins/gui/providers/moonpayProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { addTokenToArray } from '../util/providerUtils'
4747
import {
4848
addExactRegion,
4949
isDailyCheckDue,
50+
isReturnUrl,
5051
NOT_SUCCESS_TOAST_HIDE_MS,
5152
RETURN_URL_PAYMENT,
5253
validateExactRegion
@@ -725,7 +726,7 @@ export const moonpayProvider: FiatProviderFactory = {
725726
const onUrlChangeAsync = async (uri: string): Promise<void> => {
726727
console.log('Moonpay WebView url change: ' + uri)
727728

728-
if (uri.startsWith(RETURN_URL_PAYMENT)) {
729+
if (isReturnUrl(uri, 'payment')) {
729730
console.log('Moonpay WebView launch payment: ' + uri)
730731
const urlObj = new URL(uri, true)
731732
const { query } = urlObj

src/plugins/gui/providers/paybisProvider.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
import { assert, isWalletTestnet } from '../pluginUtils'
4949
import { addTokenToArray } from '../util/providerUtils'
5050
import {
51+
isReturnUrl,
5152
NOT_SUCCESS_TOAST_HIDE_MS,
5253
RETURN_URL_FAIL,
5354
RETURN_URL_PAYMENT,
@@ -911,13 +912,13 @@ export const paybisProvider: FiatProviderFactory = {
911912
newUrl: string
912913
): Promise<void> => {
913914
console.log(`*** onUrlChange: ${newUrl}`)
914-
if (newUrl.startsWith(RETURN_URL_FAIL)) {
915+
if (isReturnUrl(newUrl, 'fail')) {
915916
await showUi.exitScene()
916917
await showUi.showToast(
917918
lstrings.fiat_plugin_sell_failed_try_again,
918919
NOT_SUCCESS_TOAST_HIDE_MS
919920
)
920-
} else if (newUrl.startsWith(RETURN_URL_PAYMENT)) {
921+
} else if (isReturnUrl(newUrl, 'payment')) {
921922
if (inPayment) return
922923
inPayment = true
923924
try {

0 commit comments

Comments
 (0)