Skip to content

Route MoonPay iOS buys through a Private Relay interstitial check - #6151

Open
j0ntz wants to merge 1 commit into
developfrom
jon/moonpay-relay-interstitial
Open

Route MoonPay iOS buys through a Private Relay interstitial check#6151
j0ntz wants to merge 1 commit into
developfrom
jon/moonpay-relay-interstitial

Conversation

@j0ntz

@j0ntz j0ntz commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Technical Design Document

moonpay-private-relay-interstitial.md

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

EdgeApp/edge-info-server#160 (soft dependency: without it the app falls back to today's bound flow, so this PR is safe to land first)

Requirements

If you have made any visual changes to the GUI. Make sure you have:

  • Tested on iOS device
  • Tested on Android device
  • Tested on small-screen device (iPod Touch)
  • Tested on large-screen device (tablet)

No visual changes: only the URL handed to the external Safari view differs.

Description

Asana task

iOS buys open the MoonPay widget through the server's Private Relay relay-check interstitial instead of a directly IP-bound URL, so the server can compare the app-fetch and Safari-view egress addresses and sign the widget URL with or without the binding. Any interstitial failure falls back to today's bound flow, so buys cannot regress; sell and Android are untouched. MOONPAY_RELAY_CHECK_SIGN_PROXY (dev builds only) makes the divergence branch reproducible on a simulator.

The TDD linked above carries the full design for both repos. The physical-device Private Relay pass is the outstanding manual item; MoonPay enforcement stays off until it completes.


Note

Medium Risk
Changes the critical fiat on-ramp URL handoff for iOS buys and depends on info-server relay-check behavior, but failures degrade to the prior bound flow.

Overview
iOS MoonPay buys no longer open a directly IP-bound widget URL in SFSafariViewController. They first fetch a relay-check interstitial from the info server (fetchMoonpayInterstitialUrl with relayCheck: true); the Safari view loads that URL and the server redirects to a signed widget URL with or without IP binding after comparing app vs Safari egress. Sell and Android buy still use the existing bound signMoonpayUrl path.

Resilience: If the interstitial request fails (old server, network, empty URL), the ramp plugin falls back to today's bound signing so buys are not blocked on partial rollout.

Dev tooling: MOONPAY_RELAY_CHECK_SIGN_PROXY in env config (dev-only) can reroute the relay-check POST through alternate egress for simulator testing; signing helpers are consolidated behind postSignUrl.

A TDD (moonpay-private-relay-interstitial.md) and CHANGELOG entry document the cross-repo flow and outcomes.

Reviewed by Cursor Bugbot for commit d16b014. Bugbot is set up for automated code reviews on this repo. Configure here.

@j0ntz

j0ntz commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence

moonpay buy quotes

moonpay buy quotes

safari widget fallback bound

safari widget fallback bound

safari interstitial unbound

safari interstitial unbound

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/moonpay-relay-interstitial branch 2 times, most recently from 1c042b5 to 4cf563e Compare August 13, 2026 20:19
@j0ntz
j0ntz marked this pull request as ready for review August 13, 2026 20:21
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@j0ntz
j0ntz force-pushed the jon/moonpay-relay-interstitial branch 6 times, most recently from 18a9532 to aab11af Compare August 14, 2026 07:22

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

@j0ntz
j0ntz force-pushed the jon/moonpay-relay-interstitial branch 2 times, most recently from 51ea824 to 0c8bd6d Compare August 17, 2026 22:02
iOS buy opens the MoonPay widget in an SFSafariViewController whose
traffic can egress through iCloud Private Relay, so a widget URL bound
to the app-fetch IP mismatches what MoonPay observes for relay users
and will fail their buys once IP-match enforcement turns on.

The buy path now asks the info server for a relay-check interstitial
URL and opens that in the Safari view: the server observes the Safari
view's own egress and 302s to the widget URL signed with the IP binding
when the addresses agree, or without it when they diverge. Any
interstitial failure falls back to today's bound flow, so the change
cannot regress buys even against servers without relay-check support.
Sell and Android are untouched.

MOONPAY_RELAY_CHECK_SIGN_PROXY (dev builds only) reroutes the one
relay-check POST through an alternate egress so the unbound branch is
reproducible on a simulator. The TDD at
src/docs/moonpay-private-relay-interstitial.md documents both repos'
changes.
@j0ntz
j0ntz force-pushed the jon/moonpay-relay-interstitial branch from 0c8bd6d to d16b014 Compare August 17, 2026 22:12

@paullinator paullinator left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Findings

  • suggestion: PR Requirements leaves all device-test boxes unchecked with "No visual changes", while the Description/TDD correctly call out that the physical-device Private Relay pass is still outstanding and that MoonPay enforcement must stay off until it completes.
    • Keep the no-visual-changes note, but add an explicit checklist item for the iCloud+ Private Relay on-device buy pass, and leave it unchecked until done.

Comment on lines +69 to +81
export const fetchMoonpayInterstitialUrl = async (
url: string
): Promise<string> => {
const signProxy = __DEV__ ? ENV.MOONPAY_RELAY_CHECK_SIGN_PROXY : undefined
const reply = await postSignUrl({ url, relayCheck: true }, signProxy)
const { interstitialUrl } = asMoonpayRelayCheckResponse(reply)
// An empty URL would open a blank Safari view with no error; throwing here
// routes the caller onto its bound-URL fallback instead.
if (interstitialUrl === '') {
throw new Error('Moonpay relay check returned an empty interstitial URL')
}
return interstitialUrl
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: No new unit tests cover fetchMoonpayInterstitialUrl, postSignUrl's MOONPAY_RELAY_CHECK_SIGN_PROXY / fetchWaterfall branch, the empty-interstitialUrl throw, or the iOS Platform.OS fallback in moonpayRampPlugin. The TDD (§8) only lists server mocha tests and Maestro/manual device passes for the GUI side, so the new client helpers ship without automated regression coverage despite being easily mockable (fetchInfo/fetchWaterfall).

Recommendation: Add Jest tests: mock fetchInfo/fetchWaterfall to assert relayCheck:true body + interstitialUrl parsing, empty-URL throw, non-OK status, and that a non-empty signProxy calls fetchWaterfall on the trimmed base. Optionally extract the iOS-vs-Android URL resolution so the Platform branch and .catch → signMoonpayUrl fallback can be asserted without mounting the full ramp plugin.

Comment on lines +20 to +41
const postSignUrl = async (
body: object,
signProxy?: string
): Promise<unknown> => {
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}
const response =
signProxy != null && signProxy !== ''
? await fetchWaterfall(
[signProxy.replace(/\/+$/, '')],
SIGN_URL_PATH,
options,
SIGN_URL_TIMEOUT_MS
)
: await fetchInfo(SIGN_URL_PATH, options, SIGN_URL_TIMEOUT_MS)
if (!response.ok) {
throw new Error(`Moonpay URL signing failed: ${response.status}`)
}
return await response.json()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: postSignUrl is a good shared helper, but body is typed as object and HTTP failures always throw "Moonpay URL signing failed", including relayCheck requests. That weakens call-site type safety and makes interstitial fallback logs misleading.

Recommendation: Type the body as { url: string; relayCheck?: boolean } and use a mode-aware error message.

Comment on lines +855 to +871
let webViewUrl: string
if (Platform.OS === 'ios') {
webViewUrl = await fetchMoonpayInterstitialUrl(
urlObj.href
).catch(async (error: unknown) => {
console.log(
'Moonpay relay check unavailable, using bound URL: ' +
String(error)
)
return await signMoonpayUrl(urlObj.href)
})
} else {
webViewUrl = await signMoonpayUrl(urlObj.href)
}

deeplinkToken = await openExternalWebView({
url: signedUrl,
url: webViewUrl,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: webViewUrl is opened via openExternalWebView (SafariView / Custom Tabs), not a WebView; after the interstitial change the name no longer describes the value well.

Recommendation: Rename to openUrl or widgetUrl for clarity at the openExternalWebView call site.

Comment on lines +859 to +865
).catch(async (error: unknown) => {
console.log(
'Moonpay relay check unavailable, using bound URL: ' +
String(error)
)
return await signMoonpayUrl(urlObj.href)
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The .catch handler is marked async only to return await signMoonpayUrl(...); the extra async/await is unnecessary.

Recommendation: Use .catch((error: unknown) => { console.log(...); return signMoonpayUrl(urlObj.href) }).


One consequence of the fallback: when the relay-check POST fails and the plain retry succeeds against an upgraded server, a relaying v2 user is indistinguishable from the live app and lands on the update page. That session was already degraded (the primary path failed), and the page's advice is merely unnecessary rather than wrong.

The dev flag: `ENV.MOONPAY_RELAY_CHECK_SIGN_PROXY` (declared in `src/envConfig.ts`, empty by default) reroutes this one POST through an alternate egress, so the server observes divergent A1/A2 on a simulator and the unbound branch is reproducible. The flag is read behind `__DEV__`, so release builds cannot reach it ([decision 3](#103-divergence-testing-flag-lives-client-side)).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: TDD says ENV.MOONPAY_RELAY_CHECK_SIGN_PROXY is "empty by default", but envConfig declares it as asOptional(asString) with no default, so an absent key is undefined (not "").

Recommendation: Rephrase to "absent/undefined by default (and ignored when empty)" to match asOptional(asString) and the signProxy guard.

Comment on lines +853 to +854
// to today's bound flow. Android (Custom Tabs, app network
// stack) keeps the fully bound flow.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Inline comment calls Android Custom Tabs the "app network stack". Sell's guiPluginWebView is the app stack; Custom Tabs use the device/browser stack (same egress as app fetch, but not the app WebView stack).

Recommendation: Align with the TDD: e.g. "Android (Custom Tabs, device network stack, same egress as app fetch) keeps the fully bound flow."

Comment on lines 44 to 48
/**
* 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: signMoonpayUrl's JSDoc still says every buy/sell widget URL must be routed through that function before opening. After this PR, the iOS buy happy path uses fetchMoonpayInterstitialUrl instead.

Recommendation: Update the JSDoc to say direct IP-bound signing (sell, Android buy, and iOS buy fallback); point iOS buy's primary path at fetchMoonpayInterstitialUrl.

Comment thread CHANGELOG.md

## Unreleased (develop)

- added: MoonPay iOS buys open through a Private Relay interstitial check, so IP-match enforcement cannot lock out iCloud Private Relay users; any interstitial failure falls back to the existing bound flow.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: CHANGELOG uses "added:" for a change to the existing MoonPay IP-bound buy handoff. Nearby history already has "changed:" for signing/binding.

Recommendation: Prefer "changed:" (keep the same explanation about interstitial + fallback).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants