diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8806f3941..89ead7228cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/eslint.config.mjs b/eslint.config.mjs index 7dc53841b8d..988cc271d09 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/src/__tests__/DeepLink.test.ts b/src/__tests__/DeepLink.test.ts index ffc58508b03..94591073bd8 100644 --- a/src/__tests__/DeepLink.test.ts +++ b/src/__tests__/DeepLink.test.ts @@ -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': { diff --git a/src/__tests__/plugins/gui/providers/common.test.ts b/src/__tests__/plugins/gui/providers/common.test.ts new file mode 100644 index 00000000000..c8c3d94ade5 --- /dev/null +++ b/src/__tests__/plugins/gui/providers/common.test.ts @@ -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) + }) +}) diff --git a/src/actions/DeepLinkingActions.tsx b/src/actions/DeepLinkingActions.tsx index 1a2661da9ed..c196bd9914b 100644 --- a/src/actions/DeepLinkingActions.tsx +++ b/src/actions/DeepLinkingActions.tsx @@ -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' @@ -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' @@ -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() + const assets: EdgeAsset[] = [] + for (const pluginId of Object.keys(account.currencyConfig)) { + 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 + ) + : 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 = diff --git a/src/plugins/gui/providers/banxaProvider.ts b/src/plugins/gui/providers/banxaProvider.ts index 01874b30b63..37e3027a855 100644 --- a/src/plugins/gui/providers/banxaProvider.ts +++ b/src/plugins/gui/providers/banxaProvider.ts @@ -38,6 +38,7 @@ import { addTokenToArray } from '../util/providerUtils' import { addExactRegion, isDailyCheckDue, + isReturnUrl, NOT_SUCCESS_TOAST_HIDE_MS, RETURN_URL_CANCEL, RETURN_URL_FAIL, @@ -849,18 +850,18 @@ export const banxaProvider: FiatProviderFactory = { changeUrl: string ): Promise => { 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, diff --git a/src/plugins/gui/providers/common.ts b/src/plugins/gui/providers/common.ts index 89f5d64de2d..e1c7c234de4 100644 --- a/src/plugins/gui/providers/common.ts +++ b/src/plugins/gui/providers/common.ts @@ -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 diff --git a/src/plugins/gui/providers/moonpayProvider.ts b/src/plugins/gui/providers/moonpayProvider.ts index 70c064cb801..5dbc8e67baa 100644 --- a/src/plugins/gui/providers/moonpayProvider.ts +++ b/src/plugins/gui/providers/moonpayProvider.ts @@ -47,6 +47,7 @@ import { addTokenToArray } from '../util/providerUtils' import { addExactRegion, isDailyCheckDue, + isReturnUrl, NOT_SUCCESS_TOAST_HIDE_MS, RETURN_URL_PAYMENT, validateExactRegion @@ -725,7 +726,7 @@ export const moonpayProvider: FiatProviderFactory = { const onUrlChangeAsync = async (uri: string): Promise => { 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 diff --git a/src/plugins/gui/providers/paybisProvider.ts b/src/plugins/gui/providers/paybisProvider.ts index e7981680d37..47aac9f80b8 100644 --- a/src/plugins/gui/providers/paybisProvider.ts +++ b/src/plugins/gui/providers/paybisProvider.ts @@ -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, @@ -911,13 +912,13 @@ export const paybisProvider: FiatProviderFactory = { newUrl: string ): Promise => { 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 { diff --git a/src/plugins/ramps/banxa/banxaRampPlugin.ts b/src/plugins/ramps/banxa/banxaRampPlugin.ts index 4f97f458f44..cf07895c99a 100644 --- a/src/plugins/ramps/banxa/banxaRampPlugin.ts +++ b/src/plugins/ramps/banxa/banxaRampPlugin.ts @@ -43,6 +43,7 @@ import type { import { FiatProviderError } from '../../gui/fiatProviderTypes' import { addExactRegion, + isReturnUrl, NOT_SUCCESS_TOAST_HIDE_MS, RETURN_URL_CANCEL, RETURN_URL_FAIL, @@ -1378,17 +1379,17 @@ export const banxaRampPlugin: RampPluginFactory = ( }, onUrlChange: async (changeUrl: string): Promise => { console.log(`onUrlChange url=${changeUrl}`) - if (changeUrl === RETURN_URL_SUCCESS) { + if (isReturnUrl(changeUrl, 'success')) { clearInterval(interval) navigation.pop() - } else if (changeUrl === RETURN_URL_CANCEL) { + } else if (isReturnUrl(changeUrl, 'cancel')) { clearInterval(interval) showToast( lstrings.fiat_plugin_sell_cancelled, NOT_SUCCESS_TOAST_HIDE_MS ) navigation.pop() - } else if (changeUrl === RETURN_URL_FAIL) { + } else if (isReturnUrl(changeUrl, 'fail')) { clearInterval(interval) showToast( lstrings.fiat_plugin_sell_failed_try_again, diff --git a/src/plugins/ramps/moonpay/moonpayRampPlugin.ts b/src/plugins/ramps/moonpay/moonpayRampPlugin.ts index 905012a0d1f..55bfe71c651 100644 --- a/src/plugins/ramps/moonpay/moonpayRampPlugin.ts +++ b/src/plugins/ramps/moonpay/moonpayRampPlugin.ts @@ -40,6 +40,7 @@ import { } from '../../gui/fiatProviderTypes' import { addExactRegion, + isReturnUrl, NOT_SUCCESS_TOAST_HIDE_MS, RETURN_URL_PAYMENT, validateExactRegion @@ -1006,7 +1007,7 @@ export const moonpayRampPlugin: RampPluginFactory = ( onUrlChange: async (uri: string): Promise => { 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 diff --git a/src/plugins/ramps/paybis/paybisRampPlugin.ts b/src/plugins/ramps/paybis/paybisRampPlugin.ts index 208a75e5e14..ab829592142 100644 --- a/src/plugins/ramps/paybis/paybisRampPlugin.ts +++ b/src/plugins/ramps/paybis/paybisRampPlugin.ts @@ -60,6 +60,7 @@ import type { import { FiatProviderError } from '../../gui/fiatProviderTypes' import { assert, isWalletTestnet } from '../../gui/pluginUtils' import { + isReturnUrl, NOT_SUCCESS_TOAST_HIDE_MS, RETURN_URL_FAIL, RETURN_URL_PAYMENT, @@ -1259,13 +1260,13 @@ export const paybisRampPlugin: RampPluginFactory = ( }) async function handleUrlChange(newUrl: string): Promise { console.log(`*** onUrlChange: ${newUrl}`) - if (newUrl.startsWith(RETURN_URL_FAIL)) { + if (isReturnUrl(newUrl, 'fail')) { navigation.pop() 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 { diff --git a/src/types/DeepLinkTypes.ts b/src/types/DeepLinkTypes.ts index 0d778106d19..550540834c9 100644 --- a/src/types/DeepLinkTypes.ts +++ b/src/types/DeepLinkTypes.ts @@ -56,6 +56,25 @@ export interface PaymentProtoLink { uri: string } +/** + * A provider sell-completion redirect (e.g. MoonPay's "Send with Edge" button). + * Carries everything needed to open the Send scene so the user can finish + * depositing crypto for a pending sell order: + * + * https://edge.app/redirect/payment/?baseCurrencyCode=btc&baseCurrencyAmount=0.001&depositWalletAddress=...&depositWalletAddressTag=... + * + * `currencyCode` is the provider's base currency code (resolved to a wallet at + * handle time), `addressTag` is the destination tag / memo (required for chains + * like XRP), and `amount` is in whole units of the base currency. + */ +export interface PaymentRedirectLink { + type: 'paymentRedirect' + currencyCode: string + depositAddress: string + amount?: string + addressTag?: string +} + export interface EdgeLoginLink { type: 'edgeLogin' lobbyId: string @@ -169,6 +188,7 @@ export type DeepLink = | NoopLink | PasswordRecoveryLink | PaymentProtoLink + | PaymentRedirectLink | PluginLink | PriceChangeLink | PromotionLink diff --git a/src/util/DeepLinkParser.ts b/src/util/DeepLinkParser.ts index 71bf278d802..fc254a7f4c9 100644 --- a/src/util/DeepLinkParser.ts +++ b/src/util/DeepLinkParser.ts @@ -14,6 +14,7 @@ import { type PromotionLink } from '../types/DeepLinkTypes' import type { AppParamList } from '../types/routerTypes' +import type { UriQueryMap } from '../types/WebTypes' import { parseQuery, stringifyQuery } from './WebUtils' /** @@ -244,6 +245,17 @@ function parseEdgeProtocol(url: URL): DeepLink { } } + case 'redirect': { + // Provider ramp redirect (e.g. MoonPay "Send with Edge"). All ramp + // redirect URLs now live on the claimed `deep.edge.app` host, which + // normalizes to this `edge://` path. parseRedirectSection resolves each + // section (payment -> pre-filled Send scene; terminal states and any + // malformed payment link -> no-op) identically for the apex host below. + const link = parseRedirectSection(pathParts[0], parseQuery(url.query)) + if (link != null) return link + break + } + case 'recovery': { // The new & improved format stores the token as a fragment: if (url.hash != null && url.hash !== '') { @@ -361,6 +373,16 @@ function parseEdgeAppLink(url: URL): DeepLink { } } + // Handle provider ramp redirects (e.g. MoonPay "Send with Edge"), which + // legacy orders may still point at the apex https://edge.app/redirect/... + // Route them through the same parseRedirectSection as the `edge://` scheme so + // both hosts stay in sync: a malformed apex payment link resolves to a no-op + // instead of falling through to a browser-opened dead apex page. + if (firstPath === 'redirect') { + const link = parseRedirectSection(pathParts[1], query) + if (link != null) return link + } + // No special handling supported. Open in browser. return { type: 'other', @@ -369,6 +391,76 @@ function parseEdgeAppLink(url: URL): DeepLink { } } +/** + * Resolve a provider ramp redirect `/redirect/
/`, shared by the + * `edge://` scheme (parseEdgeProtocol) and the legacy apex `https://edge.app` + * host (parseEdgeAppLink) so the two hosts can never drift. `payment` carries a + * pending sell order's deposit details and opens the pre-filled Send scene; a + * malformed or param-less payment link degrades to a no-op rather than + * surfacing an "Unknown deep link format" error or opening a dead apex page. + * The terminal states (`success`/`fail`/`cancel`) carry no payload, so an + * externally-tapped link just opens the app via a no-op. Returns null for any + * other section so the caller applies its own default (browser) handling. + */ +function parseRedirectSection( + section: string | undefined, + query: UriQueryMap +): DeepLink | null { + if (section === 'payment') { + return parsePaymentRedirect(query) ?? { type: 'noop' } + } + if (section === 'success' || section === 'fail' || section === 'cancel') { + return { type: 'noop' } + } + return null +} + +/** + * Parse a provider sell-completion redirect such as MoonPay's "Send with Edge" + * button, which sends the user to a `/redirect/payment/` URL carrying the + * deposit details for a pending sell order. Returns null when the required + * deposit parameters are missing so the caller can fall back to its default + * handling (e.g. opening the link in a browser). + */ +function parsePaymentRedirect(query: UriQueryMap): DeepLink | null { + // Treat a present-but-blank required param the same as an absent one: an empty + // `depositWalletAddress=` or `baseCurrencyCode=` would otherwise open the Send + // flow with an empty address/asset instead of degrading to the no-op the + // caller uses when the parameter is missing. + const baseCurrencyCode = query.baseCurrencyCode ?? undefined + const depositWalletAddress = query.depositWalletAddress ?? undefined + if ( + baseCurrencyCode == null || + baseCurrencyCode.trim() === '' || + depositWalletAddress == null || + depositWalletAddress.trim() === '' + ) + return null + + // Only carry an amount when it is a positive finite number. An empty + // `baseCurrencyAmount=` would otherwise pre-fill a zero send (mul('', m) === + // '0'), and a non-numeric value would throw from biggystring AFTER the user + // finished the wallet picker; drop it here so the Send scene opens without a + // bogus amount instead. + const rawAmount = query.baseCurrencyAmount ?? undefined + const amountNum = rawAmount != null ? Number(rawAmount) : Number.NaN + const amount = + Number.isFinite(amountNum) && amountNum > 0 ? rawAmount : undefined + + // Treat a present-but-blank destination tag/memo as absent, so the Send scene + // gets `undefined` (no memo) rather than an empty-string uniqueIdentifier. + const rawTag = query.depositWalletAddressTag ?? undefined + const addressTag = rawTag != null && rawTag.trim() !== '' ? rawTag : undefined + + return { + type: 'paymentRedirect', + currencyCode: baseCurrencyCode, + depositAddress: depositWalletAddress, + amount, + addressTag + } +} + /** * Parse a request for address link. */