diff --git a/src/lib/moneygram-tempo.ts b/src/lib/moneygram-tempo.ts new file mode 100644 index 00000000..5c078efb --- /dev/null +++ b/src/lib/moneygram-tempo.ts @@ -0,0 +1,310 @@ +import type { Account, Address, Chain, Client, Hash, Transport } from 'viem' +import { formatUnits, getAddress, isAddress, parseUnits } from 'viem' +import { Actions } from 'viem/tempo' + +export type MoneyGramRampMode = 'off-ramp' | 'on-ramp' + +export interface MoneyGramSession { + sessionId: string + sessionToken: string + widgetUrl: string +} + +export interface MoneyGramCustomer { + firstName?: string + middleName?: string + lastName?: string + secondLastName?: string + dateOfBirth?: string + email?: string + phone?: string + addressLine1?: string + city?: string + postalCode?: string + countryCode?: string + countrySubdivisionCode?: string + birthCountryCode?: string + citizenshipCountryCode?: string + idType?: 'PAS' | 'DRV' | 'STA' | 'GOV' + idNumber?: string + idIssueCountry?: string + idCountrySubdivisionCode?: string +} + +export interface MoneyGramTransaction { + id: string + referenceNumber: string + amount: string + asset: 'USDC' + status: string + createdAt: number +} + +type TempoClient = Client + +export interface MoneyGramTempoOptions { + client: TempoClient + container: HTMLElement + sessionUrl: string + walletAddress: Address + mode?: MoneyGramRampMode + amount?: string + customer?: MoneyGramCustomer + destinationCountry?: string + destinationSubdivision?: string + viewTransactionId?: string + theme?: 'light' | 'dark' + apiBaseUrl?: string + /** Allow only tokens provisioned by MoneyGram for this environment. */ + allowedTokens: readonly Address[] + onClose?: () => void + onTransaction?: (transaction: MoneyGramTransaction) => void +} + +interface BridgeMessage { + type?: string + payload?: Record +} + +interface SignPayload { + chain: string + requiredNetwork: string + chainId: number + to: string + amount: string + asset: string + tokenAddress: string + tokenDecimals: number +} + +function cleanCustomer(customer?: MoneyGramCustomer) { + if (!customer) return undefined + const values = Object.entries(customer).flatMap(([key, value]) => { + const cleaned = value?.trim() + return cleaned ? [[key, cleaned]] : [] + }) + return values.length ? Object.fromEntries(values) : undefined +} + +function readSignPayload(payload: Record | undefined): SignPayload { + const value = payload ?? {} + const parsed: SignPayload = { + chain: String(value.chain ?? ''), + requiredNetwork: String(value.requiredNetwork ?? ''), + chainId: Number(value.chainId), + to: String(value.to ?? ''), + amount: String(value.amount ?? ''), + asset: String(value.asset ?? ''), + tokenAddress: String(value.tokenAddress ?? ''), + tokenDecimals: Number(value.tokenDecimals), + } + + if (parsed.chain !== 'tempo') throw new Error('MoneyGram requested a non-Tempo transfer') + if (!Number.isSafeInteger(parsed.chainId)) + throw new Error('MoneyGram omitted a valid Tempo chain ID') + if (!isAddress(parsed.to)) throw new Error('MoneyGram returned an invalid recipient address') + if (!isAddress(parsed.tokenAddress)) + throw new Error('MoneyGram returned an invalid token address') + if ( + !Number.isInteger(parsed.tokenDecimals) || + parsed.tokenDecimals < 0 || + parsed.tokenDecimals > 255 + ) + throw new Error('MoneyGram returned invalid token decimals') + if (parsed.asset !== 'USDC') throw new Error('MoneyGram requested an unsupported asset') + parseUnits(parsed.amount, parsed.tokenDecimals) + return parsed +} + +export class MoneyGramTempo { + readonly #options: MoneyGramTempoOptions + #frame?: HTMLIFrameElement + #session?: MoneyGramSession + #widgetOrigin?: string + #pendingAmount = '' + #onMessage = (event: MessageEvent) => void this.#handleMessage(event) + + constructor(options: MoneyGramTempoOptions) { + this.#options = options + } + + async mount() { + if (this.#frame) throw new Error('MoneyGram Tempo is already mounted') + + const response = await fetch(this.#options.sessionUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + credentials: 'include', + }) + if (!response.ok) throw new Error(`MoneyGram session creation failed (${response.status})`) + + this.#session = (await response.json()) as MoneyGramSession + const widgetUrl = new URL(this.#session.widgetUrl) + this.#widgetOrigin = widgetUrl.origin + widgetUrl.searchParams.set('mode', this.#options.mode ?? 'off-ramp') + widgetUrl.searchParams.set('_t', String(Date.now())) + if (this.#options.viewTransactionId) + widgetUrl.searchParams.set('transactionId', this.#options.viewTransactionId) + + const frame = document.createElement('iframe') + frame.src = widgetUrl.toString() + frame.title = 'MoneyGram cash ramp' + frame.allow = 'camera; geolocation' + frame.style.border = '0' + frame.style.width = '100%' + frame.style.height = '100%' + this.#frame = frame + window.addEventListener('message', this.#onMessage) + this.#options.container.replaceChildren(frame) + } + + destroy() { + window.removeEventListener('message', this.#onMessage) + this.#frame?.remove() + this.#frame = undefined + this.#session = undefined + this.#widgetOrigin = undefined + } + + #post(type: string, payload?: Record) { + if (!this.#frame?.contentWindow || !this.#widgetOrigin) return + this.#frame.contentWindow.postMessage( + payload ? { type, payload } : { type }, + this.#widgetOrigin, + ) + } + + async #handleMessage(event: MessageEvent) { + if ( + event.origin !== this.#widgetOrigin || + event.source !== this.#frame?.contentWindow || + !event.data || + typeof event.data !== 'object' + ) + return + + const { type, payload } = event.data as BridgeMessage + switch (type) { + case 'RAMPS_READY': { + const customer = cleanCustomer(this.#options.customer) + const mode = this.#options.mode ?? 'off-ramp' + this.#post('RAMPS_CONFIG', { + sessionToken: this.#session?.sessionToken, + wallet: { + address: this.#options.walletAddress, + chain: 'tempo', + asset: 'USDC', + walletType: 'non-custodial', + }, + ...(this.#options.apiBaseUrl + ? { devConfig: { mockMode: false, apiBaseUrl: this.#options.apiBaseUrl } } + : {}), + theme: this.#options.theme ?? 'dark', + ...(customer ? { customer } : {}), + ...(!this.#options.viewTransactionId + ? { + transaction: { + type: mode, + asset: 'USDC', + ...(this.#options.amount ? { amount: this.#options.amount } : {}), + ...(mode === 'off-ramp' && this.#options.destinationCountry + ? { destinationCountry: this.#options.destinationCountry } + : {}), + ...(mode === 'off-ramp' && this.#options.destinationSubdivision + ? { destinationSubdivision: this.#options.destinationSubdivision } + : {}), + }, + } + : { mode: 'view', transactionId: this.#options.viewTransactionId }), + }) + break + } + + case 'RAMPS_CHECK_BALANCE': { + try { + const sign = readSignPayload(payload) + this.#validateRequest(sign) + const balance = await Actions.token.getBalance(this.#options.client, { + account: this.#options.walletAddress, + token: getAddress(sign.tokenAddress), + }) + const requested = parseUnits(sign.amount, sign.tokenDecimals) + this.#post('RAMPS_BALANCE_RESULT', { + walletAddress: this.#options.walletAddress, + balance: formatUnits(balance.amount, sign.tokenDecimals), + asset: 'USDC', + blockchainNetwork: sign.requiredNetwork, + sufficient: balance.amount >= requested, + }) + } catch (error) { + this.#post('RAMPS_BALANCE_RESULT', { + walletAddress: this.#options.walletAddress, + balance: '0', + asset: 'USDC', + sufficient: false, + error: error instanceof Error ? error.message : 'Balance check failed', + }) + } + break + } + + case 'RAMPS_SIGN_TRANSACTION': { + try { + const sign = readSignPayload(payload) + this.#validateRequest(sign) + this.#pendingAmount = sign.amount + const { receipt } = await Actions.token.transferSync(this.#options.client, { + account: this.#options.walletAddress, + amount: parseUnits(sign.amount, sign.tokenDecimals), + feeToken: getAddress(sign.tokenAddress), + to: getAddress(sign.to), + token: getAddress(sign.tokenAddress), + }) + this.#post('RAMPS_SIGN_SUCCESS', { + txHash: receipt.transactionHash satisfies Hash, + walletAddress: this.#options.walletAddress, + }) + } catch (error) { + this.#post('RAMPS_SIGN_ERROR', { + error: error instanceof Error ? error.message : 'Tempo transfer failed', + }) + } + break + } + + case 'RAMPS_TRANSACTION_COMPLETE': { + const transaction: MoneyGramTransaction = { + id: String(payload?.id ?? ''), + referenceNumber: String(payload?.referenceNumber ?? ''), + amount: String(payload?.amount ?? this.#pendingAmount), + asset: 'USDC', + status: String(payload?.status ?? 'completed'), + createdAt: Date.now(), + } + this.#options.onTransaction?.(transaction) + break + } + + case 'RAMPS_CLOSE': + this.#options.onClose?.() + break + + case 'RAMPS_OPEN_URL': { + const url = String(payload?.url ?? '') + if (url.startsWith('https://')) window.open(url, '_blank', 'noopener,noreferrer') + break + } + } + } + + #validateRequest(sign: SignPayload) { + if (sign.chainId !== this.#options.client.chain?.id) + throw new Error( + `MoneyGram requested chain ${sign.chainId}; wallet is on ${this.#options.client.chain?.id}`, + ) + const allowed = new Set(this.#options.allowedTokens.map((token) => token.toLowerCase())) + if (!allowed.has(sign.tokenAddress.toLowerCase())) + throw new Error('MoneyGram requested an unapproved token') + } +} diff --git a/src/pages/docs/guide/moneygram-ramps.mdx b/src/pages/docs/guide/moneygram-ramps.mdx new file mode 100644 index 00000000..c8180303 --- /dev/null +++ b/src/pages/docs/guide/moneygram-ramps.mdx @@ -0,0 +1,189 @@ +--- +title: Integrate MoneyGram cash ramps +seoTitle: MoneyGram USDC cash ramps on Tempo | Docs +description: Embed MoneyGram cash-in and cash-out for USDC on Tempo in a web application. +--- + +import { Callout } from 'vocs' + +# Integrate MoneyGram cash ramps + +Embed MoneyGram's web widget to let users deposit cash for USDC or withdraw USDC for cash on Tempo. The integration uses MoneyGram's session API and message protocol with Tempo's Viem extension. + + + +MoneyGram must provision Tempo, its chain IDs, and the USDC TIP-20 addresses in your sandbox and production API keys before this flow can move funds. Do not substitute a test token or hardcode an unconfirmed token address. + + + +## Tempo integration contract + +MoneyGram keeps the same session, widget, KYC, cash pickup, and transaction history flow used by its Solana integration. Tempo changes the wallet and transfer fields below. + +| Field | Tempo value | +| --- | --- | +| `wallet.chain` | `tempo` | +| Wallet and recipient addresses | Checksummed or lowercase 20-byte EVM addresses | +| Asset | `USDC` as a TIP-20 token | +| Token amount | Decimal string; never a JavaScript floating-point number | +| Fee asset | A TIP-20 stablecoin; Tempo has no native gas token | +| Transaction result | Tempo transaction hash in `RAMPS_SIGN_SUCCESS.txHash` | + +MoneyGram must include these fields in both `RAMPS_CHECK_BALANCE` and `RAMPS_SIGN_TRANSACTION`: + +```ts +interface TempoTransferRequest { + chain: 'tempo' + requiredNetwork: 'tempo-mainnet' | 'tempo-moderato' + chainId: 4217 | 42431 + to: `0x${string}` + amount: string + asset: 'USDC' + tokenAddress: `0x${string}` + tokenDecimals: number +} +``` + +Use chain ID `4217` for Tempo mainnet and `42431` for Tempo Testnet (Moderato). + +The app must reject a request when the chain ID does not match the connected wallet, the token is not in the partner's environment-specific allowlist, or any address or decimal value is malformed. The session token, not the browser, determines which network and token MoneyGram requests. + +## Cash-out and cash-in sequences + +For cash-out, the widget requests a Tempo transfer after the user accepts the quote and completes KYC: + +```text +MoneyGram widget Partner web app + | | + |---- RAMPS_READY --------------------->| + |<--- RAMPS_CONFIG ---------------------| + |---- RAMPS_CHECK_BALANCE ------------->| + |<--- RAMPS_BALANCE_RESULT -------------| + | | + | User completes quote + KYC | + | | + |---- RAMPS_SIGN_TRANSACTION ---------->| + | TIP-20 transfer on Tempo | + |<--- RAMPS_SIGN_SUCCESS { txHash } -----| + |---- RAMPS_TRANSACTION_COMPLETE ------>| +``` + +For cash-in, MoneyGram accepts cash at an agent location and sends USDC to the configured Tempo address. The standard cash-in path does not send `RAMPS_SIGN_TRANSACTION` to the app. + +## Create MoneyGram sessions on the server + +Keep `MONEYGRAM_SK` on the server. Create a new session every time the widget opens because MoneyGram session tokens expire after one hour. + +```ts +export async function POST() { + const response = await fetch( + 'https://playground.xramps.moneygram.com/api/v1/sessions', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': process.env.MONEYGRAM_SK!, + }, + body: '{}', + }, + ) + + const session = await response.json() + if (!response.ok) + return Response.json(session, { status: response.status }) + + return Response.json({ + sessionToken: session.sessionToken, + sessionId: session.sessionId, + widgetUrl: session.widgetUrl, + }) +} +``` + +Restrict CORS to the web application's exact origins and rate-limit this endpoint. Never return the MoneyGram secret key to the browser. + +## Install the Tempo web SDK dependencies + +```bash +npm install viem +``` + +The MoneyGram Tempo adapter uses `viem/tempo` for TIP-20 balance reads and synchronous transfers. Copy `src/lib/moneygram-tempo.ts` from this repository into the application until the adapter is published as a standalone package. + +## Mount the MoneyGram widget + +Create a Tempo client backed by the connected browser wallet, then mount the adapter into a fixed-size container. + +```ts +import { custom } from 'viem' +import { createClient } from 'viem/tempo' +import { tempo } from 'viem/chains' +import { MoneyGramTempo } from './moneygram-tempo' + +const [walletAddress] = await window.ethereum.request({ + method: 'eth_requestAccounts', +}) + +const client = createClient({ + account: walletAddress, + chain: tempo, + transport: custom(window.ethereum), +}) + +const ramps = new MoneyGramTempo({ + client, + container: document.querySelector('#moneygram-ramps')!, + sessionUrl: '/api/moneygram-session', + walletAddress, + mode: 'off-ramp', + // Populate this only with the USDC address MoneyGram confirms for Tempo mainnet. + allowedTokens: [import.meta.env.VITE_TEMPO_USDC_ADDRESS], + onTransaction(transaction) { + localStorage.setItem( + `moneygram:${transaction.id}:${transaction.createdAt}`, + JSON.stringify(transaction), + ) + }, + onClose() { + ramps.destroy() + }, +}) + +await ramps.mount() +``` + +```html +
+``` + +Use `mode: 'on-ramp'` for cash-in. To reopen a transaction, pass its MoneyGram ID as `viewTransactionId`. + +## Stablecoin transaction fees + +Tempo charges transaction fees in TIP-20 stablecoins rather than a native gas token. The adapter sets USDC as the fee token for cash-out, so a user without a native token can complete the transfer. The wallet still needs enough balance for both the cash-out amount and its transaction fee. + +For a consumer flow, sponsor transaction fees with the [hosted fee payer](/docs/guide/payments/sponsor-user-fees). Sponsorship avoids an amount-plus-fee race when the user cashes out nearly their full balance. The MoneyGram bridge remains unchanged: after the sponsor submits the transfer, return the confirmed Tempo transaction hash through `RAMPS_SIGN_SUCCESS`. + +## Browser security requirements + +- Compare `message.origin` with the origin parsed from the session's `widgetUrl`. +- Compare `message.source` with the mounted iframe's `contentWindow`. +- Pass the exact widget origin as the `postMessage` target; never use `*`. +- Validate the MoneyGram-provided chain ID, token address, recipient, decimals, and amount before displaying a wallet signature request. +- Allowlist production and sandbox token addresses separately. +- Open only `https://` URLs from `RAMPS_OPEN_URL` with `noopener,noreferrer`. +- Destroy the adapter when its modal unmounts so the global message listener is removed. + +## MoneyGram provisioning checklist + +Before an end-to-end sandbox test, MoneyGram must confirm: + +- `tempo` is accepted in `wallet.chain` and transfer payloads. +- Tempo testnet and mainnet chain IDs map to the correct RPC environments. +- Each API-key environment resolves to one approved USDC TIP-20 address and decimal count. +- Deposit addresses returned in `RAMPS_SIGN_TRANSACTION.to` are valid Tempo addresses. +- Cash-in sends USDC to the EVM address supplied in `RAMPS_CONFIG.wallet.address`. +- Transaction monitoring recognizes Tempo hashes and waits for the agreed confirmation state. +- Refunds return USDC to the original Tempo wallet. + +Do not enable a production API key until MoneyGram verifies the cash-out, cash-in, view, expiry, rejection, and refund paths on Tempo testnet. diff --git a/vocs.config.ts b/vocs.config.ts index f0f618b8..c45632c4 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -618,6 +618,10 @@ export default defineConfig({ text: 'Wallet Developers', link: '/docs/quickstart/wallet-developers', }, + { + text: 'MoneyGram Cash Ramps', + link: '/docs/guide/moneygram-ramps', + }, { text: 'Contract Verification', link: '/docs/quickstart/verify-contracts',