From e3df5715bf6824e09d2ed735b6456fbcda84f0b7 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 10 Jul 2026 14:19:38 -0700 Subject: [PATCH 1/2] Sign MoonPay widget URLs with IP binding MoonPay's on-ramp security upgrade refuses to load widget URLs that are not signed and bound to a hash of the customer's device IP. Route every MoonPay buy/sell widget URL through the info server's /v1/moonpay/signUrl endpoint (which captures the public IP and applies the allowedIpAddress + signature params) before opening it, in both the new ramps plugin and the still-registered legacy fiat provider. --- CHANGELOG.md | 2 ++ src/plugins/gui/providers/moonpayProvider.ts | 7 +++-- src/plugins/gui/providers/moonpaySign.ts | 28 +++++++++++++++++++ .../ramps/moonpay/moonpayRampPlugin.ts | 9 ++++-- 4 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 src/plugins/gui/providers/moonpaySign.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a81bda8397..4e81d870504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- changed: Sign MoonPay buy/sell widget URLs and bind them to the customer's IP via the info server, for MoonPay's on-ramp IP-matching security upgrade. + ## 4.50.0 (2026-07-21) - added: Changelly swap provider diff --git a/src/plugins/gui/providers/moonpayProvider.ts b/src/plugins/gui/providers/moonpayProvider.ts index 5dbc8e67baa..87bbd5fdfcf 100644 --- a/src/plugins/gui/providers/moonpayProvider.ts +++ b/src/plugins/gui/providers/moonpayProvider.ts @@ -52,6 +52,7 @@ import { RETURN_URL_PAYMENT, validateExactRegion } from './common' +import { signMoonpayUrl } from './moonpaySign' const providerId = 'moonpay' const storeId = 'com.moonpay' const partnerIcon = 'moonpay_symbol_prp.png' @@ -644,6 +645,7 @@ export const moonpayProvider: FiatProviderFactory = { } urlObj.set('query', queryObj) console.log('Approving moonpay buy quote url=' + urlObj.href) + const signedUrl = await signMoonpayUrl(urlObj.href) const handleBuyDeeplinkAsync = async ( link: unknown ): Promise => { @@ -696,7 +698,7 @@ export const moonpayProvider: FiatProviderFactory = { handleBuyDeeplinkAsync(link).catch(() => {}) } await showUi.openExternalWebView({ - url: urlObj.href, + url: signedUrl, providerId, deeplinkHandler: handleBuyDeeplink }) @@ -719,6 +721,7 @@ export const moonpayProvider: FiatProviderFactory = { } urlObj.set('query', queryObj) console.log('Approving moonpay sell quote url=' + urlObj.href) + const signedUrl = await signMoonpayUrl(urlObj.href) let inPayment = false @@ -868,7 +871,7 @@ export const moonpayProvider: FiatProviderFactory = { onUrlChangeAsync(uri).catch(() => {}) } await showUi.openWebView({ - url: urlObj.href, + url: signedUrl, onUrlChange, onClose: () => {} }) diff --git a/src/plugins/gui/providers/moonpaySign.ts b/src/plugins/gui/providers/moonpaySign.ts new file mode 100644 index 00000000000..4715200037f --- /dev/null +++ b/src/plugins/gui/providers/moonpaySign.ts @@ -0,0 +1,28 @@ +import { asObject, asString } from 'cleaners' + +import { fetchInfo } from '../../../util/network' + +const asMoonpaySignResponse = asObject({ signedUrl: asString }) + +/** + * Ask the info server to bind a Moonpay widget URL to the caller's public IP + * and sign it. Moonpay's on-ramp security upgrade refuses to load widget URLs + * that are not signed and IP-bound, so every buy/sell widget URL must be routed + * through here before it is opened. + */ +export const signMoonpayUrl = async (url: string): Promise => { + const response = await fetchInfo( + 'v1/moonpay/signUrl', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }, + 10000 + ) + if (!response.ok) { + throw new Error(`Moonpay URL signing failed: ${response.status}`) + } + const reply = await response.json() + return asMoonpaySignResponse(reply).signedUrl +} diff --git a/src/plugins/ramps/moonpay/moonpayRampPlugin.ts b/src/plugins/ramps/moonpay/moonpayRampPlugin.ts index 55bfe71c651..09983f00573 100644 --- a/src/plugins/ramps/moonpay/moonpayRampPlugin.ts +++ b/src/plugins/ramps/moonpay/moonpayRampPlugin.ts @@ -45,6 +45,7 @@ import { RETURN_URL_PAYMENT, validateExactRegion } from '../../gui/providers/common' +import { signMoonpayUrl } from '../../gui/providers/moonpaySign' import { addTokenToArray } from '../../gui/util/providerUtils' import { rampDeeplinkManager } from '../rampDeeplinkHandler' import type { @@ -834,9 +835,10 @@ export const moonpayRampPlugin: RampPluginFactory = ( } urlObj.set('query', queryObj) console.log('Approving moonpay buy quote url=' + urlObj.href) + const signedUrl = await signMoonpayUrl(urlObj.href) deeplinkToken = await openExternalWebView({ - url: urlObj.href, + url: signedUrl, deeplink: { direction: 'buy', providerId: pluginId, @@ -998,9 +1000,12 @@ export const moonpayRampPlugin: RampPluginFactory = ( let inPayment = false const openWebView = async (): Promise => { + // Re-sign on every open: IP-bound signatures must be fresh, and + // this path re-opens the widget on a failed/cancelled send. + const signedUrl = await signMoonpayUrl(urlObj.href) await new Promise((resolve, reject) => { navigation.navigate('guiPluginWebView', { - url: urlObj.href, + url: signedUrl, onClose: () => { resolve() }, From 94420c11aa1a159bc4170fdc863e4f62f3d75c5e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 20 Jul 2026 15:52:53 -0700 Subject: [PATCH 2/2] test: add testIDs for maestro ramp selectors --- src/components/buttons/PillButton.tsx | 3 +++ src/components/cards/PaymentOptionCard.tsx | 3 +++ src/components/scenes/RampCreateScene.tsx | 5 ++++- src/components/scenes/RampSelectOptionScene.tsx | 1 + 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/buttons/PillButton.tsx b/src/components/buttons/PillButton.tsx index 29c38e64d54..33bbc421128 100644 --- a/src/components/buttons/PillButton.tsx +++ b/src/components/buttons/PillButton.tsx @@ -19,6 +19,7 @@ export interface PillButtonProps extends LayoutStyleProps { children?: React.ReactNode chevronDown?: boolean chevronRight?: boolean + testID?: string } export const PillButton: React.FC = ( @@ -32,6 +33,7 @@ export const PillButton: React.FC = ( children, chevronDown = false, chevronRight = false, + testID, ...marginProps } = props const marginStyle = useLayoutStyle(marginProps) @@ -45,6 +47,7 @@ export const PillButton: React.FC = ( disabled={disabled} hitSlop={theme.rem(0.5)} onPress={onPress} + testID={testID} > Promise | void @@ -72,6 +74,7 @@ export const PaymentOptionCard: React.FC = (props: Props) => { label={props.partner?.displayName ?? ''} onPress={props.onProviderPress} chevronDown + testID={props.providerTestID} /> )} diff --git a/src/components/scenes/RampCreateScene.tsx b/src/components/scenes/RampCreateScene.tsx index 82bb7394f08..5b8ee952843 100644 --- a/src/components/scenes/RampCreateScene.tsx +++ b/src/components/scenes/RampCreateScene.tsx @@ -864,7 +864,10 @@ export const RampCreateScene: React.FC = (props: Props) => { /> ) : ( <> - + {isLoadingPersistedCryptoSelection ? ( ) : selectedCrypto == null || selectedWallet == null ? null : ( diff --git a/src/components/scenes/RampSelectOptionScene.tsx b/src/components/scenes/RampSelectOptionScene.tsx index c55eaa066dd..6af85b5d994 100644 --- a/src/components/scenes/RampSelectOptionScene.tsx +++ b/src/components/scenes/RampSelectOptionScene.tsx @@ -405,6 +405,7 @@ const QuoteResult: React.FC<{ icon: { uri: providerQuote.partnerIcon } }} isBestOption={isBestOption} + providerTestID={`providerPill_${providerQuote.paymentType}`} onPress={handlePress} onProviderPress={handleProviderPress} />