diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7d8fa1261..a9d07fd08d6 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.49.0 (staging) - added: Monero wallet import support 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/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 fed4a86f85e..5b8ee952843 100644 --- a/src/components/scenes/RampCreateScene.tsx +++ b/src/components/scenes/RampCreateScene.tsx @@ -811,7 +811,10 @@ export const RampCreateScene: React.FC = (props: Props) => { {/* Amount Inputs */} {/* Top Input (Fiat) */} - + {selectedFiatFlagUri !== '' ? ( = (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} /> diff --git a/src/plugins/gui/providers/moonpayProvider.ts b/src/plugins/gui/providers/moonpayProvider.ts index 70c064cb801..dc73ddcb199 100644 --- a/src/plugins/gui/providers/moonpayProvider.ts +++ b/src/plugins/gui/providers/moonpayProvider.ts @@ -51,6 +51,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' @@ -643,6 +644,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 => { @@ -695,7 +697,7 @@ export const moonpayProvider: FiatProviderFactory = { handleBuyDeeplinkAsync(link).catch(() => {}) } await showUi.openExternalWebView({ - url: urlObj.href, + url: signedUrl, providerId, deeplinkHandler: handleBuyDeeplink }) @@ -718,6 +720,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 @@ -867,7 +870,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 905012a0d1f..b51d5e60146 100644 --- a/src/plugins/ramps/moonpay/moonpayRampPlugin.ts +++ b/src/plugins/ramps/moonpay/moonpayRampPlugin.ts @@ -44,6 +44,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 { @@ -833,9 +834,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, @@ -997,9 +999,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() },