Skip to content

Commit 88e8509

Browse files
committed
Fix MoonPay "Send with Edge" sell link
MoonPay surfaces a "Send with Edge" button for pending sell orders (in their email and order-history page) that reflects back the deposit redirect URL our app hands them. That URL was RETURN_URL_PAYMENT = https://edge.app/redirect/payment/, but the apex edge.app host is NOT claimed as a universal link on iOS or Android (only deep.edge.app, dl, and return are), so the button dead-ended on a static redirect page and the user could not complete the deposit. The buy redirects already use deep.edge.app; only the sell payment redirect used the unclaimed apex. Point RETURN_URL_PAYMENT at the claimed deep.edge.app host (in-app webview interception is host-agnostic via startsWith, so it is unaffected) and parse the redirect/payment deep link into a new paymentRedirect link that opens the Send scene, resolving the wallet from the base currency code and pre-filling the deposit address, amount, and destination tag (uniqueIdentifier). Give the sibling ramp redirect URLs the same treatment: RETURN_URL_SUCCESS, RETURN_URL_FAIL, and RETURN_URL_CANCEL now also live on the claimed deep.edge.app host (was the apex edge.app), so an externally-reflected terminal redirect opens the app instead of dead-ending. The host swap is symmetric for the in-webview interception (Banxa matches by exact ===, Paybis/MoonPay by startsWith), so no ramp flow changes behavior. These terminal states carry no actionable payload, so the deep-link parser resolves edge://redirect/{success,fail,cancel} to a no-op rather than the former "Unknown deep link format" error, on the edge://, deep.edge.app, and legacy apex edge.app hosts.
1 parent 90034ca commit 88e8509

14 files changed

Lines changed: 314 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased (develop)
44

5+
- 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 universal-link host (was the unclaimed edge.app apex), and the deep-link parser routes the payment redirect to Send with the deposit address, amount, and destination tag while the terminal redirects open the app via a no-op. The in-WebView ramp interceptors match both the new and legacy hosts, so an order created before the switch is still handled after an app update.
6+
57
## 4.49.0 (staging)
68

79
- added: Monero wallet import support

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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,65 @@ 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+
// Missing the deposit address on the edge.app host falls back to a browser:
222+
'https://edge.app/redirect/payment/?baseCurrencyCode=btc': {
223+
type: 'other',
224+
protocol: 'https',
225+
uri: 'https://edge.app/redirect/payment/?baseCurrencyCode=btc'
226+
},
227+
// Missing params on the edge:// (deep.edge.app) path degrade to a no-op
228+
// instead of an "Unknown deep link format" error:
229+
'edge://redirect/payment/?baseCurrencyCode=btc': { type: 'noop' },
230+
'https://deep.edge.app/redirect/payment/': { type: 'noop' }
231+
})
232+
})
233+
234+
describe('redirect terminal states', () => {
235+
makeLinkTests({
236+
// The provider terminal redirects (success/fail/cancel) carry no
237+
// actionable payload, so an externally-tapped link opens the app via a
238+
// no-op on every host: edge://, the claimed deep.edge.app, and the legacy
239+
// apex edge.app (old orders may still point there).
240+
'edge://redirect/success/': { type: 'noop' },
241+
'edge://redirect/fail/': { type: 'noop' },
242+
'edge://redirect/cancel/': { type: 'noop' },
243+
'https://deep.edge.app/redirect/success/': { type: 'noop' },
244+
'https://deep.edge.app/redirect/fail/': { type: 'noop' },
245+
'https://deep.edge.app/redirect/cancel/': { type: 'noop' },
246+
'https://edge.app/redirect/success/': { type: 'noop' },
247+
'https://edge.app/redirect/cancel/': { type: 'noop' }
248+
})
249+
})
250+
192251
describe('plugin', function () {
193252
makeLinkTests({
194253
'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: 63 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,67 @@ 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.
250+
const symbol = currencyCode.split('_')[0].toUpperCase()
251+
const assets: EdgeAsset[] = []
252+
for (const pluginId of Object.keys(account.currencyConfig)) {
253+
const currencyConfig = account.currencyConfig[pluginId]
254+
if (currencyConfig.currencyInfo.currencyCode.toUpperCase() === symbol) {
255+
assets.push({ pluginId, tokenId: null })
256+
}
257+
const { builtinTokens } = currencyConfig
258+
for (const tokenId of Object.keys(builtinTokens)) {
259+
if (builtinTokens[tokenId].currencyCode.toUpperCase() === symbol) {
260+
assets.push({ pluginId, tokenId })
261+
}
262+
}
263+
}
264+
265+
if (assets.length === 0) {
266+
showToast(lstrings.alert_deep_link_no_wallet_for_uri)
267+
break
268+
}
269+
270+
const result = await pickWallet({
271+
account,
272+
assets,
273+
navigation,
274+
showCreateWallet: true
275+
})
276+
if (result?.type !== 'wallet') break
277+
const { walletId, tokenId } = result
278+
const wallet = account.currencyWallets[walletId]
279+
if (wallet == null) break
280+
281+
const nativeAmount =
282+
amount != null
283+
? mul(
284+
amount,
285+
getExchangeDenom(wallet.currencyConfig, tokenId).multiplier
286+
)
287+
: undefined
288+
289+
const parsedUri: EdgeParsedUri = {
290+
publicAddress: depositAddress,
291+
nativeAmount,
292+
uniqueIdentifier: addressTag,
293+
tokenId
294+
}
295+
await dispatch(handleWalletUris(navigation, wallet, parsedUri))
296+
break
297+
}
298+
236299
case 'price-change': {
237300
const { pluginId, body } = link
238301
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: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,34 @@ 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+
// These URLs are handed to the ramp providers as sell redirect / callback URLs
11+
// and can be reflected back to the user outside the app (provider email or
12+
// order-history "complete payment" button), so they must live on a host the app
13+
// actually claims as a universal link. `deep.edge.app` is claimed on iOS and
14+
// Android (the apex `edge.app` is not), matching the buy redirects, so an
15+
// external click opens the app and the deep-link parser routes it (PAYMENT to
16+
// the pre-filled Send scene; the terminal states to a harmless no-op). We always
17+
// hand providers the claimed-host URL going forward.
18+
export const RETURN_URL_SUCCESS = 'https://deep.edge.app/redirect/success/'
19+
export const RETURN_URL_FAIL = 'https://deep.edge.app/redirect/fail/'
20+
export const RETURN_URL_CANCEL = 'https://deep.edge.app/redirect/cancel/'
21+
export const RETURN_URL_PAYMENT = 'https://deep.edge.app/redirect/payment/'
22+
23+
// Match a ramp redirect URL as it appears inside the provider WebView. Orders
24+
// created before the host switch still carry the legacy apex `edge.app` host —
25+
// MoonPay persists the payment redirect server-side and can resurface it days
26+
// later — so the in-WebView interceptors match BOTH the claimed and legacy hosts
27+
// to keep catching a legacy order resumed after an app update. Use this in place
28+
// of a raw `startsWith`/`===` against a single constant.
29+
const RETURN_URL_REDIRECT_HOSTS = [
30+
'https://deep.edge.app/redirect/',
31+
'https://edge.app/redirect/'
32+
]
33+
export const isReturnUrl = (
34+
uri: string,
35+
kind: 'payment' | 'success' | 'fail' | 'cancel'
36+
): boolean =>
37+
RETURN_URL_REDIRECT_HOSTS.some(host => uri.startsWith(`${host}${kind}/`))
1438

1539
export const NOT_SUCCESS_TOAST_HIDE_MS = 5000
1640

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 {

src/plugins/ramps/banxa/banxaRampPlugin.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import type {
4343
import { FiatProviderError } from '../../gui/fiatProviderTypes'
4444
import {
4545
addExactRegion,
46+
isReturnUrl,
4647
NOT_SUCCESS_TOAST_HIDE_MS,
4748
RETURN_URL_CANCEL,
4849
RETURN_URL_FAIL,
@@ -1378,17 +1379,17 @@ export const banxaRampPlugin: RampPluginFactory = (
13781379
},
13791380
onUrlChange: async (changeUrl: string): Promise<void> => {
13801381
console.log(`onUrlChange url=${changeUrl}`)
1381-
if (changeUrl === RETURN_URL_SUCCESS) {
1382+
if (isReturnUrl(changeUrl, 'success')) {
13821383
clearInterval(interval)
13831384
navigation.pop()
1384-
} else if (changeUrl === RETURN_URL_CANCEL) {
1385+
} else if (isReturnUrl(changeUrl, 'cancel')) {
13851386
clearInterval(interval)
13861387
showToast(
13871388
lstrings.fiat_plugin_sell_cancelled,
13881389
NOT_SUCCESS_TOAST_HIDE_MS
13891390
)
13901391
navigation.pop()
1391-
} else if (changeUrl === RETURN_URL_FAIL) {
1392+
} else if (isReturnUrl(changeUrl, 'fail')) {
13921393
clearInterval(interval)
13931394
showToast(
13941395
lstrings.fiat_plugin_sell_failed_try_again,

0 commit comments

Comments
 (0)