From bf4d1eabf8756abc833c8eb9fe470ed4b8263930 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 24 Jun 2026 16:10:39 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + src/__tests__/DeepLink.test.ts | 59 +++++++++++++++++++++++++++ src/actions/DeepLinkingActions.tsx | 63 +++++++++++++++++++++++++++++ src/plugins/gui/providers/common.ts | 18 +++++++-- src/types/DeepLinkTypes.ts | 20 +++++++++ src/util/DeepLinkParser.ts | 58 ++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fe47fb7f1..1821e2549e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased (develop) - added: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze. +- 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. ## 4.49.0 (staging) diff --git a/src/__tests__/DeepLink.test.ts b/src/__tests__/DeepLink.test.ts index ffc58508b03..27dc3d0c8db 100644 --- a/src/__tests__/DeepLink.test.ts +++ b/src/__tests__/DeepLink.test.ts @@ -189,6 +189,65 @@ 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 + }, + // Missing the deposit address on the edge.app host falls back to a browser: + 'https://edge.app/redirect/payment/?baseCurrencyCode=btc': { + type: 'other', + protocol: 'https', + uri: 'https://edge.app/redirect/payment/?baseCurrencyCode=btc' + }, + // Missing params on the edge:// (deep.edge.app) path degrade to a no-op + // instead of an "Unknown deep link format" error: + '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/cancel/': { type: 'noop' } + }) + }) + describe('plugin', function () { makeLinkTests({ 'edge://plugin/simplex/rabbit/hole?param=alice': { diff --git a/src/actions/DeepLinkingActions.tsx b/src/actions/DeepLinkingActions.tsx index 1a2661da9ed..c40a55872aa 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,67 @@ 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. + 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 { builtinTokens } = currencyConfig + for (const tokenId of Object.keys(builtinTokens)) { + if (builtinTokens[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 + + 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/common.ts b/src/plugins/gui/providers/common.ts index 89f5d64de2d..75f92f9f0da 100644 --- a/src/plugins/gui/providers/common.ts +++ b/src/plugins/gui/providers/common.ts @@ -7,10 +7,20 @@ 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/' +// 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 a host the app +// actually claims as a universal link. `deep.edge.app` is claimed on iOS and +// Android (the apex `edge.app` is not), matching the buy redirects, so 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). In-app +// interception is host-agnostic: Paybis/MoonPay use `uri.startsWith(...)` and +// Banxa an exact `===` against the same constant, so swapping the host is +// symmetric and does not change the in-webview behavior. +export const RETURN_URL_SUCCESS = 'https://deep.edge.app/redirect/success/' +export const RETURN_URL_FAIL = 'https://deep.edge.app/redirect/fail/' +export const RETURN_URL_CANCEL = 'https://deep.edge.app/redirect/cancel/' +export const RETURN_URL_PAYMENT = 'https://deep.edge.app/redirect/payment/' export const NOT_SUCCESS_TOAST_HIDE_MS = 5000 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..6904d09dc35 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,24 @@ 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. A `/redirect/payment` resolves to a + // pre-filled Send scene; the terminal states (`success`/`fail`/`cancel`) + // carry no actionable payload, so an externally-tapped link just opens + // the app via a no-op. A malformed or param-less payment URL degrades to + // a no-op too, rather than surfacing an "Unknown deep link format" error. + const [section] = pathParts + if (section === 'payment') { + return parsePaymentRedirect(parseQuery(url.query)) ?? { type: 'noop' } + } + if (section === 'success' || section === 'fail' || section === 'cancel') { + return { type: 'noop' } + } + break + } + case 'recovery': { // The new & improved format stores the token as a fragment: if (url.hash != null && url.hash !== '') { @@ -361,6 +380,24 @@ 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/... + // A `/redirect/payment` carries the deposit details for a pending sell order; + // the terminal states (`success`/`fail`/`cancel`) resolve to a no-op. + if (firstPath === 'redirect') { + const section = pathParts[1] + if (section === 'payment') { + const link = parsePaymentRedirect(query) + if (link != null) return link + } else if ( + section === 'success' || + section === 'fail' || + section === 'cancel' + ) { + return { type: 'noop' } + } + } + // No special handling supported. Open in browser. return { type: 'other', @@ -369,6 +406,27 @@ function parseEdgeAppLink(url: URL): DeepLink { } } +/** + * 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 { + const baseCurrencyCode = query.baseCurrencyCode ?? undefined + const depositWalletAddress = query.depositWalletAddress ?? undefined + if (baseCurrencyCode == null || depositWalletAddress == null) return null + + return { + type: 'paymentRedirect', + currencyCode: baseCurrencyCode, + depositAddress: depositWalletAddress, + amount: query.baseCurrencyAmount ?? undefined, + addressTag: query.depositWalletAddressTag ?? undefined + } +} + /** * Parse a request for address link. */