From 70bf01c5b4b09c9497a23b42424d58d7113fbbd2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 6 Nov 2026 02:31:52 -0800 Subject: [PATCH 1/6] Note NavigationBase migration in Services Services is a service-level component that passes navigation to many children, so it keeps NavigationBase until the v7 migration. --- src/components/services/Services.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/services/Services.tsx b/src/components/services/Services.tsx index 8aad5aac860..fa185937624 100644 --- a/src/components/services/Services.tsx +++ b/src/components/services/Services.tsx @@ -49,6 +49,7 @@ import { WalletLifecycle } from './WalletLifecycle' import { WipeLogsService } from './WipeLogsService' interface Props { + // TODO: Remove NavigationBase dependency. Requires broader v7 navigation migration for service-level navigation. navigation: NavigationBase } From 9a6c9ed20b9cfd31febe7259aa385a3c0d063731 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 6 Nov 2026 13:06:47 -0800 Subject: [PATCH 2/6] Find bridged otherMethods in the wallet wait Bridged proxies define their methods as non-enumerable properties, so Object.keys(wallet.otherMethods) is always empty on the GUI side and waitForWalletOtherMethods waited out its full 10 minute timeout even when the engine methods were there. Probe the own property names for functions instead. --- .../util/waitForWalletOtherMethods.test.ts | 59 +++++++++++++++++++ src/util/waitForWalletOtherMethods.ts | 21 ++++++- 2 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/util/waitForWalletOtherMethods.test.ts diff --git a/src/__tests__/util/waitForWalletOtherMethods.test.ts b/src/__tests__/util/waitForWalletOtherMethods.test.ts new file mode 100644 index 00000000000..461a24537d9 --- /dev/null +++ b/src/__tests__/util/waitForWalletOtherMethods.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { EdgeCurrencyWallet } from 'edge-core-js' +import { bridgifyObject, makeLocalBridge, update, watchMethod } from 'yaob' + +import { waitForWalletOtherMethods } from '../../util/waitForWalletOtherMethods' + +interface FakeWallet { + id: string + otherMethods: object + watch: typeof watchMethod +} + +/** Sends a wallet-shaped object across a local bridge, as the core does. */ +function bridgeWallet(otherMethods: object): { + server: FakeWallet + wallet: EdgeCurrencyWallet +} { + const server: FakeWallet = bridgifyObject({ + id: 'wallet-id', + otherMethods: bridgifyObject(otherMethods), + watch: watchMethod + }) + const wallet = makeLocalBridge(server) as unknown as EdgeCurrencyWallet + return { server, wallet } +} + +describe('waitForWalletOtherMethods', () => { + it('resolves for methods that crossed the bridge', async () => { + const { wallet } = bridgeWallet({ sign: async () => 'signed' }) + + // The bridge hides methods from Object.keys: + expect(Object.keys(wallet.otherMethods)).toEqual([]) + await expect(waitForWalletOtherMethods(wallet, 1000)).resolves.toBe( + undefined + ) + }) + + it('resolves when the methods arrive later', async () => { + const { server, wallet } = bridgeWallet({}) + + const promise = waitForWalletOtherMethods(wallet, 1000) + server.otherMethods = bridgifyObject({ sign: async () => 'signed' }) + update(server) + // The bridge batches updates on a timer: + await jest.advanceTimersByTimeAsync(0) + await expect(promise).resolves.toBe(undefined) + }) + + it('times out for a wallet with no methods', async () => { + const { wallet } = bridgeWallet({}) + + const promise = waitForWalletOtherMethods(wallet, 10) + const assertion = expect(promise).rejects.toThrow( + 'Timed out waiting for wallet wallet-id engine methods' + ) + await jest.advanceTimersByTimeAsync(10) + await assertion + }) +}) diff --git a/src/util/waitForWalletOtherMethods.ts b/src/util/waitForWalletOtherMethods.ts index 0bd0c6832f0..13cd110c640 100644 --- a/src/util/waitForWalletOtherMethods.ts +++ b/src/util/waitForWalletOtherMethods.ts @@ -22,7 +22,7 @@ export async function waitForWalletOtherMethods( wallet: EdgeCurrencyWallet, timeoutMs: number = OTHER_METHODS_TIMEOUT_MS ): Promise { - if (Object.keys(wallet.otherMethods).length > 0) return + if (hasMethods(wallet.otherMethods)) return await new Promise((resolve, reject) => { const handleReady = (): void => { @@ -37,11 +37,26 @@ export async function waitForWalletOtherMethods( ) }, timeoutMs) const unsubscribe = wallet.watch('otherMethods', otherMethods => { - if (Object.keys(otherMethods).length > 0) handleReady() + if (hasMethods(otherMethods)) handleReady() }) // The methods may have arrived between the caller's check and the // subscription above, in which case the watcher never fires: - if (Object.keys(wallet.otherMethods).length > 0) handleReady() + if (hasMethods(wallet.otherMethods)) handleReady() }) } + +/** + * True once an `otherMethods` object carries at least one method. + * + * The bridge defines a proxy's methods as non-enumerable properties, so + * `Object.keys` finds none on the GUI side even when they exist. The + * own property names include them, along with the bridge's own magic + * property, which is not a function: + */ +function hasMethods(otherMethods: object): boolean { + const record = otherMethods as Record + return Object.getOwnPropertyNames(otherMethods).some( + name => typeof record[name] === 'function' + ) +} From 59c1f8e5d8702b955dff2f7c5229d21cb5529e6e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 6 Nov 2026 02:39:39 -0800 Subject: [PATCH 3/6] Enable the Stellar testnet currency plugin Load edge-currency-accountbased's stellartestnet plugin and give it a default wallet name and the same reserve warnings as mainnet Stellar, so testnet anchors such as testanchor.stellar.org can be exercised from the app. --- src/constants/WalletAndCurrencyConstants.ts | 13 +++++++++++++ src/locales/en_US.ts | 1 + src/locales/strings/enUS.json | 1 + src/util/corePlugins.ts | 1 + 4 files changed, 16 insertions(+) diff --git a/src/constants/WalletAndCurrencyConstants.ts b/src/constants/WalletAndCurrencyConstants.ts index 3e672a7c3cc..3917d7b792b 100644 --- a/src/constants/WalletAndCurrencyConstants.ts +++ b/src/constants/WalletAndCurrencyConstants.ts @@ -149,6 +149,7 @@ export const WALLET_TYPE_ORDER = [ 'wallet:ripple', 'wallet:rsk', 'wallet:stellar', + 'wallet:stellartestnet', 'wallet:dash', 'wallet:tezos', 'wallet:digibyte', @@ -427,6 +428,18 @@ export const SPECIAL_CURRENCY_INFO: Record = { isImportKeySupported: true, unstoppableDomainsTicker: 'XLM' }, + stellartestnet: { + initWalletName: lstrings.string_first_stellar_testnet_wallet_name, + dummyPublicAddress: + 'GBEVGJYAUKJ2TVPMC3GEPI2GGZQLMWZDRWJCVNBXCJ3ELYTDPHVQQM74', + minimumPopupModals: { + minimumNativeBalance: '10000000', + modalMessage: lstrings.request_xlm_minimum_notification_body, + alertMessage: lstrings.request_xlm_minimum_notification_alert_body + }, + displayBuyCrypto: false, + isImportKeySupported: true + }, ripple: { initWalletName: lstrings.string_first_ripple_wallet_name, showTokenNames: true, diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 4ba6461f43d..17370fedda7 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -820,6 +820,7 @@ const strings = { string_first_ravencoin_wallet_name: 'My Ravencoin', string_first_zcoin_wallet_name: 'My Firo', string_first_stellar_wallet_name: 'My Stellar', + string_first_stellar_testnet_wallet_name: 'My Stellar Testnet', string_first_tezos_wallet_name: 'My Tezos', string_first_axelar_wallet_name: 'My Axelar', string_first_coreum_wallet_name: 'My TX', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 2263dcb9d22..605ac13deb2 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -623,6 +623,7 @@ "string_first_ravencoin_wallet_name": "My Ravencoin", "string_first_zcoin_wallet_name": "My Firo", "string_first_stellar_wallet_name": "My Stellar", + "string_first_stellar_testnet_wallet_name": "My Stellar Testnet", "string_first_tezos_wallet_name": "My Tezos", "string_first_axelar_wallet_name": "My Axelar", "string_first_coreum_wallet_name": "My TX", diff --git a/src/util/corePlugins.ts b/src/util/corePlugins.ts index 68147194788..f51fb2fde5f 100644 --- a/src/util/corePlugins.ts +++ b/src/util/corePlugins.ts @@ -62,6 +62,7 @@ function buildAccountbasedPlugins(): EdgeCorePluginsInit { solana: coreInit('solana'), sonic: coreInit('sonic'), stellar: true, + stellartestnet: coreInit('stellartestnet'), sui: true, telos: true, tezos: true, From ab5b7ef5b73482f706e4adea7aace08b055f88b6 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 6 Nov 2026 02:40:27 -0800 Subject: [PATCH 4/6] Add a SEP-1, SEP-10 and SEP-24 anchor client Read an anchor's stellar.toml, log in with SEP-10 by having the Stellar engine validate and sign the challenge with the wallet key, and call the SEP-24 info, interactive and transaction endpoints. The JWT is cached per wallet and anchor and refreshed once when the anchor answers 401. client_domain is not sent yet. --- src/__tests__/plugins/ramps/moneygram.test.ts | 250 ++++++++++++++++++ src/__tests__/plugins/ramps/sep24Api.test.ts | 75 ++++++ src/plugins/ramps/moneygram/moneygramTypes.ts | 112 ++++++++ src/plugins/ramps/moneygram/sep1.ts | 60 +++++ src/plugins/ramps/moneygram/sep10.ts | 108 ++++++++ src/plugins/ramps/moneygram/sep24Api.ts | 188 +++++++++++++ 6 files changed, 793 insertions(+) create mode 100644 src/__tests__/plugins/ramps/moneygram.test.ts create mode 100644 src/__tests__/plugins/ramps/sep24Api.test.ts create mode 100644 src/plugins/ramps/moneygram/moneygramTypes.ts create mode 100644 src/plugins/ramps/moneygram/sep1.ts create mode 100644 src/plugins/ramps/moneygram/sep10.ts create mode 100644 src/plugins/ramps/moneygram/sep24Api.ts diff --git a/src/__tests__/plugins/ramps/moneygram.test.ts b/src/__tests__/plugins/ramps/moneygram.test.ts new file mode 100644 index 00000000000..93da0784c72 --- /dev/null +++ b/src/__tests__/plugins/ramps/moneygram.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals' +import type { EdgeCurrencyWallet } from 'edge-core-js' + +import type { AnchorEndpoints } from '../../../plugins/ramps/moneygram/moneygramTypes' +import { + asAnchorToml, + parseStellarToml +} from '../../../plugins/ramps/moneygram/sep1' +import { fetchWithSep10 } from '../../../plugins/ramps/moneygram/sep10' +import { isTerminalStatus } from '../../../plugins/ramps/moneygram/sep24Api' + +const TESTNET = 'Test SDF Network ; September 2015' +const ANCHOR: AnchorEndpoints = { + homeDomain: 'testanchor.stellar.org', + signingKey: 'GCHLHDBOKG2JWMJQBTLSL5XG6NO7ESXI2TAQKZXCXWXB5WI2X6W233PR', + transferServerSep24: 'https://testanchor.stellar.org/sep24', + webAuthEndpoint: 'https://testanchor.stellar.org/auth' +} +const ACCOUNT = 'GAMYLWB5BQWUZZ3LG4T34XWOEJ7JRUPOEW5UJSVHBMVOMTDEMOH4JA3W' + +const STELLAR_TOML = ` +# Test anchor +NETWORK_PASSPHRASE="${TESTNET}" +SIGNING_KEY="${ANCHOR.signingKey}" +WEB_AUTH_ENDPOINT="${ANCHOR.webAuthEndpoint}" +TRANSFER_SERVER_SEP0024='${ANCHOR.transferServerSep24}/' # trailing slash +ACCOUNTS=["${ACCOUNT}"] + +[DOCUMENTATION] +ORG_NAME="Stellar Development Foundation" +SIGNING_KEY="not a top-level key" +` + +const realFetch = global.fetch + +afterEach(() => { + global.fetch = realFetch +}) + +interface FakeResponse { + status?: number + json?: unknown + text?: string +} + +const response = ({ status = 200, json, text }: FakeResponse): Response => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => json, + text: async () => text ?? JSON.stringify(json) + } as unknown as Response) + +const makeWallet = ( + id: string, + pluginId = 'stellartestnet' +): { + wallet: EdgeCurrencyWallet + signSep10Challenge: jest.Mock<(...args: unknown[]) => Promise> +} => { + const signSep10Challenge = jest.fn( + async (...args: unknown[]) => `signed:${String(args[0])}` + ) + const wallet = { + id, + currencyInfo: { pluginId, currencyCode: 'XLM' }, + getAddresses: async () => [ + { addressType: 'publicAddress', publicAddress: ACCOUNT } + ], + otherMethods: { signSep10Challenge } + } as unknown as EdgeCurrencyWallet + return { wallet, signSep10Challenge } +} + +describe('SEP-1 stellar.toml', () => { + it('reads top-level keys and stops at the first table', () => { + const fields = parseStellarToml(STELLAR_TOML) + expect(fields.SIGNING_KEY).toBe(ANCHOR.signingKey) + expect(fields.NETWORK_PASSPHRASE).toBe(TESTNET) + expect(fields.TRANSFER_SERVER_SEP0024).toBe( + `${ANCHOR.transferServerSep24}/` + ) + // Arrays are not strings, and section keys are out of scope: + expect(fields.ACCOUNTS).toBeUndefined() + expect(fields.ORG_NAME).toBeUndefined() + }) + + it('picks the anchor endpoints without a trailing slash', () => { + const toml = asAnchorToml(parseStellarToml(STELLAR_TOML), ANCHOR.homeDomain) + expect(toml).toEqual({ + networkPassphrase: TESTNET, + signingKey: ANCHOR.signingKey, + transferServerSep24: ANCHOR.transferServerSep24, + webAuthEndpoint: ANCHOR.webAuthEndpoint + }) + }) + + it('rejects a stellar.toml without a SEP-10 signing key', () => { + const fields = parseStellarToml(STELLAR_TOML) + delete fields.SIGNING_KEY + expect(() => asAnchorToml(fields, ANCHOR.homeDomain)).toThrow( + 'testanchor.stellar.org stellar.toml has no SIGNING_KEY' + ) + }) +}) + +describe('SEP-10 fetchWithSep10', () => { + const challengeUrl = `${ANCHOR.webAuthEndpoint}?account=${ACCOUNT}&home_domain=${ANCHOR.homeDomain}` + const resourceUrl = `${ANCHOR.transferServerSep24}/transaction?id=abc` + + it('signs the challenge with the wallet and sends the JWT', async () => { + const { wallet, signSep10Challenge } = makeWallet('sep10-first') + const calls: Array<{ url: string; init?: RequestInit }> = [] + global.fetch = jest.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, init }) + if (url === challengeUrl) { + return response({ json: { transaction: 'challenge-xdr' } }) + } + if (url === ANCHOR.webAuthEndpoint) { + return response({ json: { token: 'jwt-1' } }) + } + return response({ json: { ok: true } }) + }) as unknown as typeof fetch + + const result = await fetchWithSep10(wallet, ANCHOR, resourceUrl) + expect(result.status).toBe(200) + + expect(signSep10Challenge).toHaveBeenCalledWith('challenge-xdr', { + homeDomain: ANCHOR.homeDomain, + webAuthDomain: 'testanchor.stellar.org', + serverSigningKey: ANCHOR.signingKey + }) + expect(JSON.parse(calls[1].init?.body as string)).toEqual({ + transaction: 'signed:challenge-xdr' + }) + expect(calls[2].url).toBe(resourceUrl) + expect(calls[2].init?.headers).toEqual({ Authorization: 'Bearer jwt-1' }) + + // A second request reuses the cached JWT: + await fetchWithSep10(wallet, ANCHOR, resourceUrl) + expect(signSep10Challenge).toHaveBeenCalledTimes(1) + expect(calls).toHaveLength(4) + }) + + it('logs in again once when the anchor answers 401', async () => { + const { wallet, signSep10Challenge } = makeWallet('sep10-expired') + let tokens = 0 + const authHeaders: string[] = [] + global.fetch = jest.fn(async (url: string, init?: RequestInit) => { + if (url === challengeUrl) { + return response({ json: { transaction: 'challenge-xdr' } }) + } + if (url === ANCHOR.webAuthEndpoint) { + return response({ json: { token: `jwt-${++tokens}` } }) + } + const auth = (init?.headers as Record).Authorization + authHeaders.push(auth) + return response({ status: auth === 'Bearer jwt-1' ? 401 : 200, json: {} }) + }) as unknown as typeof fetch + + const result = await fetchWithSep10(wallet, ANCHOR, resourceUrl) + expect(result.status).toBe(200) + expect(authHeaders).toEqual(['Bearer jwt-1', 'Bearer jwt-2']) + expect(signSep10Challenge).toHaveBeenCalledTimes(2) + }) + + it('does not cache a failed login', async () => { + const { wallet, signSep10Challenge } = makeWallet('sep10-failed') + let challengeStatus = 500 + global.fetch = jest.fn(async (url: string) => { + if (url === challengeUrl) { + return response({ + status: challengeStatus, + json: { transaction: 'challenge-xdr' } + }) + } + if (url === ANCHOR.webAuthEndpoint) { + return response({ json: { token: 'jwt-1' } }) + } + return response({ json: {} }) + }) as unknown as typeof fetch + + await expect(fetchWithSep10(wallet, ANCHOR, resourceUrl)).rejects.toThrow( + 'SEP-10 challenge failed: 500' + ) + challengeStatus = 200 + const result = await fetchWithSep10(wallet, ANCHOR, resourceUrl) + expect(result.status).toBe(200) + expect(signSep10Challenge).toHaveBeenCalledTimes(1) + }) + + it('keeps a separate JWT for each set of anchor endpoints', async () => { + const { wallet, signSep10Challenge } = makeWallet('sep10-endpoints') + const otherAnchor: AnchorEndpoints = { + ...ANCHOR, + transferServerSep24: 'https://other.example.com/sep24' + } + let tokens = 0 + const authHeaders: string[] = [] + global.fetch = jest.fn(async (url: string, init?: RequestInit) => { + if (url === challengeUrl) { + return response({ json: { transaction: 'challenge-xdr' } }) + } + if (url === ANCHOR.webAuthEndpoint) { + return response({ json: { token: `jwt-${++tokens}` } }) + } + authHeaders.push((init?.headers as Record).Authorization) + return response({ json: {} }) + }) as unknown as typeof fetch + + await fetchWithSep10(wallet, ANCHOR, resourceUrl) + await fetchWithSep10( + wallet, + otherAnchor, + `${otherAnchor.transferServerSep24}/transaction?id=abc` + ) + expect(authHeaders).toEqual(['Bearer jwt-1', 'Bearer jwt-2']) + expect(signSep10Challenge).toHaveBeenCalledTimes(2) + }) + + it('surfaces a challenge the engine refuses to sign', async () => { + const { wallet, signSep10Challenge } = makeWallet('sep10-refused') + signSep10Challenge.mockRejectedValue( + new Error('SEP-10 challenge is for a different account') + ) + global.fetch = jest.fn(async () => + response({ json: { transaction: 'challenge-xdr' } }) + ) as unknown as typeof fetch + + await expect(fetchWithSep10(wallet, ANCHOR, resourceUrl)).rejects.toThrow( + 'SEP-10 challenge is for a different account' + ) + }) +}) + +describe('SEP-24 statuses', () => { + it('treats only final anchor statuses as terminal', () => { + for (const status of ['completed', 'refunded', 'expired', 'error']) { + expect(isTerminalStatus(status)).toBe(true) + } + for (const status of [ + 'incomplete', + 'pending_user_transfer_start', + 'pending_anchor', + 'pending_stellar' + ]) { + expect(isTerminalStatus(status)).toBe(false) + } + }) +}) diff --git a/src/__tests__/plugins/ramps/sep24Api.test.ts b/src/__tests__/plugins/ramps/sep24Api.test.ts new file mode 100644 index 00000000000..2f0328dfabf --- /dev/null +++ b/src/__tests__/plugins/ramps/sep24Api.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from '@jest/globals' + +import type { AnchorEndpoints } from '../../../plugins/ramps/moneygram/moneygramTypes' +import { + anchorPageUrl, + checkInteractiveUrl, + isAnchorUrl +} from '../../../plugins/ramps/moneygram/sep24Api' + +const ANCHOR: AnchorEndpoints = { + homeDomain: 'testanchor.stellar.org', + signingKey: 'GCHLHDBOKG2JWMJQBTLSL5XG6NO7ESXI2TAQKZXCXWXB5WI2X6W233PR', + transferServerSep24: 'https://testanchor.stellar.org/sep24', + webAuthEndpoint: 'https://testanchor.stellar.org/auth' +} + +describe('SEP-24 interactive URLs', () => { + it('opens https pages on the anchor site', () => { + for (const url of [ + 'https://testanchor.stellar.org/sep24/transactions/deposit/webapp?token=t', + 'https://anchor-ref-ui-testanchor.stellar.org/?transaction_id=1&token=t' + ]) { + expect(() => { + checkInteractiveUrl(url, ANCHOR) + }).not.toThrow() + } + }) + + it('refuses other schemes and hosts', () => { + for (const url of [ + 'http://testanchor.stellar.org/sep24/webapp', + 'https://evil.example.com/sep24/webapp', + 'https://testanchor.stellar.org.evil.example.com/webapp', + 'https://evilstellar.org/webapp', + 'https://stellar.org/webapp', + 'https://evil.stellar.org/?token=t', + 'javascript:alert(1)' + ]) { + expect(() => { + checkInteractiveUrl(url, ANCHOR) + }).toThrow('SEP-24 interactive URL is not on the anchor') + } + }) +}) + +describe('SEP-24 anchor URLs', () => { + it('accepts hosts under the home domain', () => { + expect(isAnchorUrl('https://api.testanchor.stellar.org/x', ANCHOR)).toBe( + true + ) + }) + + it('does not lend one anchor the UI hosts of another', () => { + const other: AnchorEndpoints = { ...ANCHOR, homeDomain: 'example.com' } + expect( + isAnchorUrl('https://anchor-ref-ui-testanchor.stellar.org/', other) + ).toBe(false) + }) +}) + +describe('SEP-24 status pages', () => { + it('keeps a status page on the anchor site', () => { + const url = + 'https://testanchor.stellar.org/sep24/transaction/more_info?id=1' + expect(anchorPageUrl(url, ANCHOR)).toBe(url) + }) + + it('drops a missing or off-site status page', () => { + expect(anchorPageUrl(undefined, ANCHOR)).toBeUndefined() + expect(anchorPageUrl('edge://pay/bitcoin', ANCHOR)).toBeUndefined() + expect( + anchorPageUrl('https://evil.example.com/more_info', ANCHOR) + ).toBeUndefined() + }) +}) diff --git a/src/plugins/ramps/moneygram/moneygramTypes.ts b/src/plugins/ramps/moneygram/moneygramTypes.ts new file mode 100644 index 00000000000..58969715f8a --- /dev/null +++ b/src/plugins/ramps/moneygram/moneygramTypes.ts @@ -0,0 +1,112 @@ +import { + asBoolean, + asEither, + asMaybe, + asNull, + asNumber, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' + +export const asInitOptions = asObject({ + /** The anchor's home domain, where its stellar.toml lives. */ + homeDomain: asOptional(asString, 'testanchor.stellar.org') +}) + +/** The SEP-1 stellar.toml fields this client needs. */ +export interface AnchorToml { + networkPassphrase: string + signingKey: string + transferServerSep24: string + webAuthEndpoint: string +} + +/** Where to reach one anchor, as saved with each transfer. */ +export interface AnchorEndpoints { + homeDomain: string + signingKey: string + transferServerSep24: string + webAuthEndpoint: string +} + +// SEP-10: + +export const asSep10Challenge = asObject({ + transaction: asString, + network_passphrase: asOptional(asString) +}) + +export const asSep10Token = asObject({ + token: asString +}) + +// SEP-24: + +const asSep24AssetInfo = asObject({ + enabled: asBoolean, + min_amount: asOptional(asNumber), + max_amount: asOptional(asNumber) +}) +export type Sep24AssetInfo = ReturnType + +export const asSep24Info = asObject({ + deposit: asObject(asSep24AssetInfo), + withdraw: asObject(asSep24AssetInfo) +}) +export type Sep24Info = ReturnType + +export const asSep24Interactive = asObject({ + type: asString, + url: asString, + id: asString +}) + +export const asSep24Transaction = asObject({ + id: asString, + kind: asValue('deposit', 'withdrawal'), + status: asString, + amount_in: asMaybe(asString), + amount_out: asMaybe(asString), + message: asMaybe(asString), + more_info_url: asMaybe(asString), + stellar_transaction_id: asMaybe(asString), + withdraw_anchor_account: asMaybe(asString), + withdraw_memo: asMaybe(asString), + withdraw_memo_type: asMaybe(asValue('text', 'id', 'hash')) +}) +export type Sep24Transaction = ReturnType + +export const asSep24TransactionResponse = asObject({ + transaction: asSep24Transaction +}) + +export const asSep24Error = asObject({ + error: asString +}) + +// Saved transfers: + +export const asMoneygramTransfer = asObject({ + /** The anchor's SEP-24 transaction id */ + id: asString, + direction: asValue('buy', 'sell'), + walletId: asString, + tokenId: asEither(asString, asNull), + assetCode: asString, + fiatCurrencyCode: asString, + status: asString, + createdAt: asString, + /** The txid of the payment this wallet sent to the anchor */ + sendTxid: asOptional(asString), + /** The txid this wallet tagged as the anchor's deposit */ + taggedTxid: asOptional(asString), + anchor: asObject({ + homeDomain: asString, + signingKey: asString, + transferServerSep24: asString, + webAuthEndpoint: asString + }) +}) +export type MoneygramTransfer = ReturnType diff --git a/src/plugins/ramps/moneygram/sep1.ts b/src/plugins/ramps/moneygram/sep1.ts new file mode 100644 index 00000000000..60bb00c73fd --- /dev/null +++ b/src/plugins/ramps/moneygram/sep1.ts @@ -0,0 +1,60 @@ +import type { AnchorToml } from './moneygramTypes' + +/** + * Reads the top-level string keys from a stellar.toml file. + * Keys inside [SECTIONS] and [[TABLES]] are not needed by SEP-10 or SEP-24, + * so parsing stops at the first table header. + */ +export function parseStellarToml(text: string): Record { + const out: Record = {} + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim() + if (line === '' || line.startsWith('#')) continue + if (line.startsWith('[')) break + + const match = /^([A-Za-z0-9_]+)\s*=\s*(.*)$/.exec(line) + if (match == null) continue + const value = readTomlString(match[2]) + if (value != null) out[match[1]] = value + } + return out +} + +/** Reads a quoted TOML string, ignoring any trailing comment. */ +function readTomlString(text: string): string | undefined { + const quote = text[0] + if (quote !== '"' && quote !== "'") return + const end = text.indexOf(quote, 1) + if (end < 0) return + return text.slice(1, end) +} + +/** Picks and checks the SEP-10 / SEP-24 fields from parsed stellar.toml. */ +export function asAnchorToml( + fields: Record, + homeDomain: string +): AnchorToml { + const pick = (key: string): string => { + const value = fields[key] + if (value == null || value === '') { + throw new Error(`${homeDomain} stellar.toml has no ${key}`) + } + return value + } + return { + networkPassphrase: pick('NETWORK_PASSPHRASE'), + signingKey: pick('SIGNING_KEY'), + transferServerSep24: pick('TRANSFER_SERVER_SEP0024').replace(/\/+$/, ''), + webAuthEndpoint: pick('WEB_AUTH_ENDPOINT') + } +} + +export async function fetchAnchorToml(homeDomain: string): Promise { + const response = await fetch(`https://${homeDomain}/.well-known/stellar.toml`) + if (!response.ok) { + throw new Error( + `${homeDomain} stellar.toml request failed: ${response.status}` + ) + } + return asAnchorToml(parseStellarToml(await response.text()), homeDomain) +} diff --git a/src/plugins/ramps/moneygram/sep10.ts b/src/plugins/ramps/moneygram/sep10.ts new file mode 100644 index 00000000000..9150afb26fe --- /dev/null +++ b/src/plugins/ramps/moneygram/sep10.ts @@ -0,0 +1,108 @@ +import { asString } from 'cleaners' +import type { EdgeCurrencyWallet } from 'edge-core-js' +import URL from 'url-parse' + +import { + type AnchorEndpoints, + asSep10Challenge, + asSep10Token +} from './moneygramTypes' + +/** The wallet's Stellar account, which SEP-10 authenticates. */ +export async function getStellarAccount( + wallet: EdgeCurrencyWallet +): Promise { + const [address] = await wallet.getAddresses({ tokenId: null }) + if (address == null) throw new Error('Stellar wallet has no address') + return address.publicAddress +} + +/** + * Runs SEP-10 web authentication: fetch the anchor's challenge, have the + * Stellar engine validate and sign it with the wallet key, and trade the + * signed challenge for a JWT. + */ +export async function fetchSep10Token( + wallet: EdgeCurrencyWallet, + anchor: AnchorEndpoints +): Promise { + const { homeDomain, signingKey, webAuthEndpoint } = anchor + const account = await getStellarAccount(wallet) + + const query = `account=${encodeURIComponent( + account + )}&home_domain=${encodeURIComponent(homeDomain)}` + const challengeResponse = await fetch(`${webAuthEndpoint}?${query}`) + if (!challengeResponse.ok) { + throw new Error(`SEP-10 challenge failed: ${challengeResponse.status}`) + } + const challenge = asSep10Challenge(await challengeResponse.json()) + + const signed = asString( + await wallet.otherMethods.signSep10Challenge(challenge.transaction, { + homeDomain, + webAuthDomain: new URL(webAuthEndpoint).host, + serverSigningKey: signingKey + }) + ) + + const tokenResponse = await fetch(webAuthEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transaction: signed }) + }) + if (!tokenResponse.ok) { + throw new Error(`SEP-10 token request failed: ${tokenResponse.status}`) + } + return asSep10Token(await tokenResponse.json()).token +} + +// JWTs shared by every transfer for the same wallet and anchor: +const tokenCache = new Map>() + +/** + * Fetches with the anchor's JWT, authenticating on first use and once more + * if the anchor answers 401 (an expired or revoked token). + */ +export async function fetchWithSep10( + wallet: EdgeCurrencyWallet, + anchor: AnchorEndpoints, + url: string, + init: RequestInit = {} +): Promise { + // The key holds every endpoint and key the login trusted, so a token + // only ever goes back to the servers of the anchor that issued it: + const key = [ + wallet.id, + anchor.homeDomain, + anchor.webAuthEndpoint, + anchor.signingKey, + anchor.transferServerSep24 + ].join(' ') + + const getToken = async (refresh: boolean): Promise => { + let pending = tokenCache.get(key) + if (pending == null || refresh) { + pending = fetchSep10Token(wallet, anchor) + tokenCache.set(key, pending) + // A failed login must not stay cached: + pending.catch(() => { + if (tokenCache.get(key) === pending) tokenCache.delete(key) + }) + } + return await pending + } + + const send = async (token: string): Promise => + await fetch(url, { + ...init, + headers: { + ...(init.headers as Record | undefined), + Authorization: `Bearer ${token}` + } + }) + + const response = await send(await getToken(false)) + if (response.status !== 401) return response + return await send(await getToken(true)) +} diff --git a/src/plugins/ramps/moneygram/sep24Api.ts b/src/plugins/ramps/moneygram/sep24Api.ts new file mode 100644 index 00000000000..bde61e46b7b --- /dev/null +++ b/src/plugins/ramps/moneygram/sep24Api.ts @@ -0,0 +1,188 @@ +import { asMaybe, type Cleaner } from 'cleaners' +import type { EdgeCurrencyWallet } from 'edge-core-js' +import URL from 'url-parse' + +import { + type AnchorEndpoints, + asSep24Error, + asSep24Info, + asSep24Interactive, + asSep24TransactionResponse, + type Sep24Info, + type Sep24Transaction +} from './moneygramTypes' +import { fetchWithSep10 } from './sep10' + +/** Statuses after which the anchor will not change the transfer again. */ +const TERMINAL_STATUSES = new Set([ + 'completed', + 'refunded', + 'expired', + 'error', + 'no_market', + 'too_small', + 'too_large' +]) + +export const isTerminalStatus = (status: string): boolean => + TERMINAL_STATUSES.has(status) + +export interface Sep24StartParams { + kind: 'deposit' | 'withdraw' + assetCode: string + /** Pins the token's issuer, so the anchor cannot pick another one. */ + assetIssuer?: string + account: string + amount?: string + lang?: string +} + +export interface Sep24Api { + startInteractive: ( + params: Sep24StartParams + ) => Promise<{ id: string; url: string }> + getTransaction: (id: string) => Promise +} + +/** Reads the anchor's assets and limits, which needs no login. */ +export async function fetchSep24Info( + transferServer: string +): Promise { + const response = await fetch(`${transferServer}/info`) + return await readJson(response, asSep24Info, 'SEP-24 info') +} + +export function makeSep24Api( + wallet: EdgeCurrencyWallet, + anchor: AnchorEndpoints +): Sep24Api { + const baseUrl = anchor.transferServerSep24 + + return { + async startInteractive(params) { + const { + kind, + assetCode, + assetIssuer, + account, + amount, + lang = 'en' + } = params + const body: Record = { + asset_code: assetCode, + account, + lang + } + if (assetIssuer != null) body.asset_issuer = assetIssuer + if (amount != null) body.amount = amount + + const response = await fetchWithSep10( + wallet, + anchor, + `${baseUrl}/transactions/${kind}/interactive`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + } + ) + const { id, url } = await readJson( + response, + asSep24Interactive, + `SEP-24 ${kind}` + ) + checkInteractiveUrl(url, anchor) + return { id, url } + }, + + async getTransaction(id) { + const response = await fetchWithSep10( + wallet, + anchor, + `${baseUrl}/transaction?id=${encodeURIComponent(id)}` + ) + const { transaction } = await readJson( + response, + asSep24TransactionResponse, + 'SEP-24 transaction' + ) + return transaction + } + } +} + +/** + * Hosts that serve an anchor's interactive pages outside its home domain, + * keyed by home domain. Each one is listed by name, since trusting the + * whole parent zone would also trust every sibling host in it. + */ +const ANCHOR_UI_HOSTS: Record = { + 'testanchor.stellar.org': ['anchor-ref-ui-testanchor.stellar.org'] +} + +/** True for the home domain itself or any host under it. */ +export const isHomeDomainHost = ( + hostname: string, + homeDomain: string +): boolean => hostname === homeDomain || hostname.endsWith(`.${homeDomain}`) + +/** + * True for an https URL on the anchor's home domain, a host under it, or + * one of the anchor's listed UI hosts. + */ +export function isAnchorUrl(url: string, anchor: AnchorEndpoints): boolean { + const { hostname, protocol } = new URL(url) + if (protocol !== 'https:') return false + const uiHosts = ANCHOR_UI_HOSTS[anchor.homeDomain] ?? [] + return ( + isHomeDomainHost(hostname, anchor.homeDomain) || uiHosts.includes(hostname) + ) +} + +/** + * Refuses an interactive URL that is not an anchor URL, so a bad transfer + * server answer cannot open some other page in the in-app browser. + */ +export function checkInteractiveUrl( + url: string, + anchor: AnchorEndpoints +): void { + if (!isAnchorUrl(url, anchor)) { + throw new Error(`SEP-24 interactive URL is not on the anchor: ${url}`) + } +} + +/** + * The anchor's status page for a transfer, if it passes the same check as + * the interactive URL. The app saves it on the wallet transaction and opens + * it from the transaction details, so anything else is dropped. + */ +export function anchorPageUrl( + url: string | undefined, + anchor: AnchorEndpoints +): string | undefined { + if (url == null) return + try { + checkInteractiveUrl(url, anchor) + return url + } catch (error: unknown) { + console.warn('MoneyGram: dropping the anchor status page', error) + } +} + +async function readJson( + response: Response, + cleaner: Cleaner, + label: string +): Promise { + const json: unknown = await response.json().catch(() => undefined) + if (!response.ok) { + const anchorError = asMaybe(asSep24Error)(json)?.error + const reason = + anchorError == null + ? String(response.status) + : `${response.status} ${anchorError}` + throw new Error(`${label} request failed: ${reason}`) + } + return cleaner(json) +} From 44f3a3771fa00dceca4338ea69699bef411e2f59 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 6 Nov 2026 02:41:16 -0800 Subject: [PATCH 5/6] Add a MoneyGram ramp plugin over SEP-24 Offer MoneyGram buy and sell quotes for native XLM when the anchor's network matches the wallet, with limits from SEP-24 /info. Approving a quote starts the interactive deposit or withdraw, saves the transfer in the plugin store and opens the anchor's page. A poller follows the transfer: a withdraw waiting for funds opens a locked send scene with the anchor's address, amount and memo, and a completed deposit tags the incoming transaction as a MoneyGram buy. Unfinished transfers are resumed at login, and error, expired and refunded statuses are shown. --- src/__tests__/plugins/ramps/moneygram.test.ts | 212 +++++++- src/components/scenes/SendScene2.tsx | 7 +- src/components/services/Services.tsx | 31 +- src/locales/en_US.ts | 9 + src/locales/strings/enUS.json | 6 + src/plugins/ramps/allRampPlugins.ts | 2 + .../ramps/moneygram/moneygramRampPlugin.ts | 330 ++++++++++++ src/plugins/ramps/moneygram/moneygramStore.ts | 49 ++ .../ramps/moneygram/moneygramTransfer.ts | 489 ++++++++++++++++++ 9 files changed, 1131 insertions(+), 4 deletions(-) create mode 100644 src/plugins/ramps/moneygram/moneygramRampPlugin.ts create mode 100644 src/plugins/ramps/moneygram/moneygramStore.ts create mode 100644 src/plugins/ramps/moneygram/moneygramTransfer.ts diff --git a/src/__tests__/plugins/ramps/moneygram.test.ts b/src/__tests__/plugins/ramps/moneygram.test.ts index 93da0784c72..b5863f0c304 100644 --- a/src/__tests__/plugins/ramps/moneygram.test.ts +++ b/src/__tests__/plugins/ramps/moneygram.test.ts @@ -1,13 +1,32 @@ import { afterEach, describe, expect, it, jest } from '@jest/globals' import type { EdgeCurrencyWallet } from 'edge-core-js' -import type { AnchorEndpoints } from '../../../plugins/ramps/moneygram/moneygramTypes' +import { FiatProviderError } from '../../../plugins/gui/fiatProviderTypes' +import { + checkLimits, + moneygramRampPlugin, + pickAmount +} from '../../../plugins/ramps/moneygram/moneygramRampPlugin' +import { loadOpenTransfers } from '../../../plugins/ramps/moneygram/moneygramStore' +import { toEdgeMemo } from '../../../plugins/ramps/moneygram/moneygramTransfer' +import type { + AnchorEndpoints, + MoneygramTransfer +} from '../../../plugins/ramps/moneygram/moneygramTypes' import { asAnchorToml, parseStellarToml } from '../../../plugins/ramps/moneygram/sep1' import { fetchWithSep10 } from '../../../plugins/ramps/moneygram/sep10' import { isTerminalStatus } from '../../../plugins/ramps/moneygram/sep24Api' +import type { RampPluginConfig } from '../../../plugins/ramps/rampPluginTypes' +import type { RampPluginStore } from '../../../plugins/ramps/utils/createStore' + +jest.mock('../../../components/services/AirshipInstance', () => ({ + ...jest.requireActual('../../../components/services/AirshipInstance'), + showError: jest.fn(), + showToast: jest.fn() +})) const TESTNET = 'Test SDF Network ; September 2015' const ANCHOR: AnchorEndpoints = { @@ -72,6 +91,20 @@ const makeWallet = ( return { wallet, signSep10Challenge } } +const makeTransfer = (id: string, status: string): MoneygramTransfer => ({ + id, + direction: 'sell', + walletId: 'wallet-id', + tokenId: null, + assetCode: 'native', + fiatCurrencyCode: 'USD', + status, + createdAt: '2026-11-06T00:00:00.000Z', + sendTxid: undefined, + taggedTxid: undefined, + anchor: ANCHOR +}) + describe('SEP-1 stellar.toml', () => { it('reads top-level keys and stops at the first table', () => { const fields = parseStellarToml(STELLAR_TOML) @@ -248,3 +281,180 @@ describe('SEP-24 statuses', () => { } }) }) + +describe('MoneyGram withdraw memos', () => { + it('converts withdraw memos for the Stellar engine', () => { + expect(toEdgeMemo('12345', 'id')).toEqual({ + type: 'number', + memoName: 'memo_id', + value: '12345' + }) + expect(toEdgeMemo('cash out', 'text')).toEqual({ + type: 'text', + memoName: 'memo_text', + value: 'cash out' + }) + expect(toEdgeMemo('cash out', undefined).type).toBe('text') + // SEP-24 sends hash memos as base64: + expect(toEdgeMemo('3q2+7w==', 'hash')).toEqual({ + type: 'hex', + memoName: 'memo_hash', + value: 'deadbeef' + }) + }) +}) + +describe('MoneyGram amounts and limits', () => { + const limits = { enabled: true, min_amount: 1, max_amount: 10 } + + it('picks the amount to send the anchor', () => { + expect(pickAmount({ exchangeAmount: '2.5' }, limits)).toBe('2.5') + expect(pickAmount({ maxExchangeAmount: '4' }, limits)).toBe('4') + expect(pickAmount({ maxExchangeAmount: '25' }, limits)).toBe('10') + expect(pickAmount({ max: true }, limits)).toBe('10') + expect( + pickAmount( + { max: true }, + { enabled: true, min_amount: undefined, max_amount: 1e-7 } + ) + ).toBe('0.0000001') + expect(() => + pickAmount( + { max: true }, + { enabled: true, min_amount: undefined, max_amount: undefined } + ) + ).toThrow('MoneyGram has no maximum amount for this asset') + }) + + it('throws the quote limit errors from /info', () => { + const quoteError = (amount: string): unknown => { + try { + checkLimits(amount, limits, 'XLM') + } catch (error: unknown) { + return error instanceof FiatProviderError ? error.quoteError : error + } + } + expect(quoteError('0.5')).toEqual({ + providerId: 'moneygram', + errorType: 'underLimit', + errorAmount: 1, + displayCurrencyCode: 'XLM' + }) + expect(quoteError('10.0000001')).toEqual({ + providerId: 'moneygram', + errorType: 'overLimit', + errorAmount: 10, + displayCurrencyCode: 'XLM' + }) + expect(quoteError('1')).toBeUndefined() + expect(quoteError('10')).toBeUndefined() + }) +}) + +describe('MoneyGram transfer store', () => { + it('loads unfinished transfers and untagged deposits', async () => { + const items: Record = { + waiting: JSON.stringify( + makeTransfer('waiting', 'pending_user_transfer_start') + ), + done: JSON.stringify(makeTransfer('done', 'completed')), + refunded: JSON.stringify(makeTransfer('refunded', 'refunded')), + untagged: JSON.stringify({ + ...makeTransfer('untagged', 'completed'), + direction: 'buy' + }), + tagged: JSON.stringify({ + ...makeTransfer('tagged', 'completed'), + direction: 'buy', + taggedTxid: 'txid' + }), + garbled: '{not json', + wrongShape: JSON.stringify({ id: 'wrongShape' }) + } + const store: RampPluginStore = { + deleteItem: async () => {}, + listItemIds: async () => Object.keys(items), + getItem: async (id: string) => items[id], + setItem: async () => {} + } + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + const transfers = await loadOpenTransfers(store) + expect(transfers.map(transfer => transfer.id)).toEqual([ + 'waiting', + 'untagged' + ]) + expect(warn).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) +}) + +describe('moneygramRampPlugin checkSupport', () => { + const makePlugin = ( + withdrawEnabled: boolean + ): ReturnType => { + global.fetch = jest.fn(async (url: string) => { + if (url === 'https://testanchor.stellar.org/.well-known/stellar.toml') { + return response({ text: STELLAR_TOML }) + } + if (url === `${ANCHOR.transferServerSep24}/info`) { + return response({ + json: { + deposit: { + native: { enabled: true, min_amount: 1, max_amount: 10 } + }, + withdraw: { native: { enabled: withdrawEnabled } } + } + }) + } + return response({ status: 404, json: {} }) + }) as unknown as typeof fetch + + return moneygramRampPlugin({ + initOptions: {}, + store: {} as unknown as RampPluginStore, + account: {}, + navigation: {}, + onLogEvent: () => {}, + disklet: {} + } as unknown as RampPluginConfig) + } + + const request = ( + pluginId: string, + tokenId: string | null, + direction: 'buy' | 'sell' + ): Parameters['checkSupport']>[0] => ({ + direction, + regionCode: { countryCode: 'US' }, + fiatAsset: { currencyCode: 'USD' }, + cryptoAsset: { pluginId, tokenId } + }) + + it('supports native XLM on the network the anchor runs on', async () => { + const plugin = makePlugin(true) + expect( + await plugin.checkSupport(request('stellartestnet', null, 'buy')) + ).toEqual({ supported: true, supportedAmountTypes: ['crypto'] }) + expect( + await plugin.checkSupport(request('stellartestnet', null, 'sell')) + ).toEqual({ supported: true, supportedAmountTypes: ['crypto'] }) + }) + + it('rejects other networks, tokens, and disabled directions', async () => { + const plugin = makePlugin(false) + // The test anchor runs on testnet, so mainnet XLM is unsupported: + expect(await plugin.checkSupport(request('stellar', null, 'buy'))).toEqual({ + supported: false + }) + expect( + await plugin.checkSupport(request('stellartestnet', 'USDC-GA5Z', 'buy')) + ).toEqual({ supported: false }) + expect(await plugin.checkSupport(request('ethereum', null, 'buy'))).toEqual( + { supported: false } + ) + expect( + await plugin.checkSupport(request('stellartestnet', null, 'sell')) + ).toEqual({ supported: false }) + }) +}) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 16950034b4c..c5b66a6dbea 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -216,6 +216,8 @@ export interface SendScene2Params { address?: boolean amount?: boolean fee?: boolean + /** Keeps the memos from `spendInfo` as sent, such as a ramp's payout memo. */ + memo?: boolean wallet?: boolean } hiddenFeaturesMap?: { @@ -3000,12 +3002,13 @@ const SendComponent: React.FC = props => { }) } + const locked = lockTilesMap.memo === true return ( {value ?? addButtonText} diff --git a/src/components/services/Services.tsx b/src/components/services/Services.tsx index fa185937624..17fadfa999f 100644 --- a/src/components/services/Services.tsx +++ b/src/components/services/Services.tsx @@ -12,12 +12,17 @@ import { refreshAllFioAddresses } from '../../actions/FioAddressActions' import { updateGiftCardInfo } from '../../actions/GiftCardInfoActions' import { updateHoudiniChains } from '../../actions/HoudiniChainsActions' import { registerNotificationsV2 } from '../../actions/NotificationActions' -import { trackAppUsageAfterUpgrade } from '../../actions/RequestReviewActions' +import { + trackAppUsageAfterUpgrade, + updateFiatPurchaseCount +} from '../../actions/RequestReviewActions' import { checkCompromisedKeys } from '../../actions/WalletActions' import { CONFIG } from '../../config' import { useAsyncEffect } from '../../hooks/useAsyncEffect' import { useRefresher } from '../../hooks/useRefresher' import { lstrings } from '../../locales/strings' +import { pluginMaps } from '../../pluginMaps' +import { resumeMoneygramTransfers } from '../../plugins/ramps/moneygram/moneygramRampPlugin' import { defaultAccount } from '../../reducers/CoreReducer' import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' @@ -28,6 +33,7 @@ import { ratioVertical, width } from '../../util/scaling' +import { logEvent } from '../../util/tracking' import { snooze } from '../../util/utils' import { waitForWalletOtherMethods } from '../../util/waitForWalletOtherMethods' import { AlertDropdown } from '../navigation/AlertDropdown' @@ -95,6 +101,29 @@ export const Services: React.FC = props => { } }, [account, dispatch]) + // Pick up MoneyGram transfers left open by an earlier session: + useAsyncEffect( + async () => { + if (account == null) return + const initOptions: unknown = pluginMaps.rampPlugins.moneygram + if (initOptions == null || typeof initOptions !== 'object') return + await resumeMoneygramTransfers({ + account, + navigation, + onLogEvent: (event, values) => { + dispatch(logEvent(event, values)) + if (event === 'Buy_Success' || event === 'Sell_Success') { + dispatch(updateFiatPurchaseCount()).catch((error: unknown) => { + console.warn('MoneyGram: purchase count failed', error) + }) + } + } + }) + }, + [account], + 'Services MoneyGram' + ) + // Methods to call once all of the currency wallets have been loaded useAsyncEffect( async () => { diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 17370fedda7..22d15755c86 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -2006,6 +2006,15 @@ const strings = { fiat_plugin_sell_failed_try_again: 'Sell order failed. Please try again.', fiat_plugin_sell_failed_to_send_try_again: 'Failed to send funds for sell transaction. Please try again.', + moneygram_send_title: 'Send to MoneyGram', + moneygram_send_message_2s: + 'Your MoneyGram cash out is waiting for %1$s, paid to the MoneyGram account %2$s. Send it now to finish the transfer.', + moneygram_send_later: + 'Your MoneyGram transfer is saved. Edge will ask you to send it the next time you log in.', + moneygram_deposit_complete: 'MoneyGram deposit complete', + moneygram_withdraw_complete: 'MoneyGram cash out complete', + moneygram_transfer_failed_2s: + 'MoneyGram transfer ended with status %1$s. %2$s', fiat_plugin_cannot_continue_camera_permission: 'Cannot continue. Camera permission needed for ID verifications', fiat_plugin_purchase_limit_error_2s: diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 605ac13deb2..a0ec91e6fca 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1548,6 +1548,12 @@ "fiat_plugin_sell_complete_message_3": "A confirmation email has been sent to your registered email address.", "fiat_plugin_sell_failed_try_again": "Sell order failed. Please try again.", "fiat_plugin_sell_failed_to_send_try_again": "Failed to send funds for sell transaction. Please try again.", + "moneygram_send_title": "Send to MoneyGram", + "moneygram_send_message_2s": "Your MoneyGram cash out is waiting for %1$s, paid to the MoneyGram account %2$s. Send it now to finish the transfer.", + "moneygram_send_later": "Your MoneyGram transfer is saved. Edge will ask you to send it the next time you log in.", + "moneygram_deposit_complete": "MoneyGram deposit complete", + "moneygram_withdraw_complete": "MoneyGram cash out complete", + "moneygram_transfer_failed_2s": "MoneyGram transfer ended with status %1$s. %2$s", "fiat_plugin_cannot_continue_camera_permission": "Cannot continue. Camera permission needed for ID verifications", "fiat_plugin_purchase_limit_error_2s": "Please create a full account to increase the purchase limit above %1$s %2$s", "fiat_plugin_max_buy_quote_error": "Provider cannot create max buy quote", diff --git a/src/plugins/ramps/allRampPlugins.ts b/src/plugins/ramps/allRampPlugins.ts index 0555d3a1793..872a4658c16 100644 --- a/src/plugins/ramps/allRampPlugins.ts +++ b/src/plugins/ramps/allRampPlugins.ts @@ -2,6 +2,7 @@ import { banxaRampPlugin } from './banxa/banxaRampPlugin' import { bitsofgoldRampPlugin } from './bitsofgold/bitsofgoldRampPlugin' import { infiniteRampPlugin } from './infinite/infiniteRampPlugin' import { libertyxRampPlugin } from './libertyx/libertyxRampPlugin' +import { moneygramRampPlugin } from './moneygram/moneygramRampPlugin' import { moonpayRampPlugin } from './moonpay/moonpayRampPlugin' import { paybisRampPlugin } from './paybis/paybisRampPlugin' import type { RampPluginFactory } from './rampPluginTypes' @@ -13,6 +14,7 @@ export const pluginFactories: Record = { bitsofgold: bitsofgoldRampPlugin, infinite: infiniteRampPlugin, libertyx: libertyxRampPlugin, + moneygram: moneygramRampPlugin, moonpay: moonpayRampPlugin, paybis: paybisRampPlugin, revolut: revolutRampPlugin, diff --git a/src/plugins/ramps/moneygram/moneygramRampPlugin.ts b/src/plugins/ramps/moneygram/moneygramRampPlugin.ts new file mode 100644 index 00000000000..5279490869e --- /dev/null +++ b/src/plugins/ramps/moneygram/moneygramRampPlugin.ts @@ -0,0 +1,330 @@ +import { gt, lt } from 'biggystring' + +import { showError } from '../../../components/services/AirshipInstance' +import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' +import { lstrings } from '../../../locales/strings' +import { removeIsoPrefix } from '../../../util/utils' +import { FiatProviderError } from '../../gui/fiatProviderTypes' +import type { + RampApproveQuoteParams, + RampCheckSupportRequest, + RampPlugin, + RampPluginConfig, + RampPluginFactory, + RampQuote, + RampQuoteRequest, + RampSupportResult, + SettlementRange +} from '../rampPluginTypes' +import { createStore } from '../utils/createStore' +import { getRampPluginStoreId } from '../utils/rampStoreIds' +import { openEdgeWebView } from '../utils/webViewUtils' +import { loadOpenTransfers, saveTransfer } from './moneygramStore' +import { + MONEYGRAM_DISPLAY_NAME, + MONEYGRAM_PLUGIN_ID, + type TransferContext, + watchTransfer +} from './moneygramTransfer' +import { + type AnchorEndpoints, + type AnchorToml, + asInitOptions, + type MoneygramTransfer, + type Sep24AssetInfo, + type Sep24Info +} from './moneygramTypes' +import { fetchAnchorToml } from './sep1' +import { getStellarAccount } from './sep10' +import { + fetchSep24Info, + isAnchorUrl, + isTerminalStatus, + makeSep24Api +} from './sep24Api' + +const partnerIcon = `${EDGE_CONTENT_SERVER_URI}/moneygram.png` + +/** The network each supported wallet type signs for. */ +export const NETWORK_PASSPHRASES: Record = { + stellar: 'Public Global Stellar Network ; September 2015', + stellartestnet: 'Test SDF Network ; September 2015' +} + +// SEP-24 names the chain's own asset "native": +const NATIVE_ASSET_CODE = 'native' + +const SETTLEMENT_RANGE: SettlementRange = { + min: { value: 10, unit: 'minutes' }, + max: { value: 1, unit: 'days' } +} + +// An abandoned transfer stays "incomplete" until the anchor expires it, +// and a deposit's payout may never show up in the wallet's history, +// so resume stops checking on those after a day: +const STALE_RESUME_MS = 24 * 60 * 60 * 1000 + +export const moneygramRampPlugin: RampPluginFactory = config => { + const { homeDomain } = asInitOptions(config.initOptions) + const { account, navigation, onLogEvent, store } = config + const context: TransferContext = { account, navigation, onLogEvent, store } + + // The anchor's stellar.toml and /info, fetched once per plugin instance: + let tomlPromise: Promise | undefined + let infoPromise: Promise | undefined + + const getToml = async (): Promise => { + if (tomlPromise == null) { + const promise = fetchAnchorToml(homeDomain) + tomlPromise = promise + promise.catch(() => { + if (tomlPromise === promise) tomlPromise = undefined + }) + } + return await tomlPromise + } + + const getAnchor = async (): Promise => { + const { signingKey, transferServerSep24, webAuthEndpoint } = await getToml() + return { homeDomain, signingKey, transferServerSep24, webAuthEndpoint } + } + + const getInfo = async (): Promise => { + if (infoPromise == null) { + const promise = getToml().then( + async toml => await fetchSep24Info(toml.transferServerSep24) + ) + infoPromise = promise + promise.catch(() => { + if (infoPromise === promise) infoPromise = undefined + }) + } + return await infoPromise + } + + /** The anchor's limits for this asset, if the wallet and anchor match. */ + const getAssetInfo = async ( + pluginId: string, + tokenId: string | null, + direction: 'buy' | 'sell' + ): Promise => { + // Issued assets wait on Stellar token support in the wallet: + if (tokenId != null) return + const passphrase = NETWORK_PASSPHRASES[pluginId] + if (passphrase == null) return + + const toml = await getToml() + if (toml.networkPassphrase !== passphrase) return + + const info = await getInfo() + const assetInfo = + direction === 'buy' + ? info.deposit[NATIVE_ASSET_CODE] + : info.withdraw[NATIVE_ASSET_CODE] + if (!assetInfo?.enabled) return + return assetInfo + } + + const checkSupport = async ( + request: RampCheckSupportRequest + ): Promise => { + const { cryptoAsset, direction } = request + try { + const assetInfo = await getAssetInfo( + cryptoAsset.pluginId, + cryptoAsset.tokenId, + direction + ) + if (assetInfo == null) return { supported: false } + // The anchor prices the transfer on its own page, so the amount is + // always entered in crypto: + return { supported: true, supportedAmountTypes: ['crypto'] } + } catch (error: unknown) { + console.warn('MoneyGram: checkSupport failed', error) + return { supported: false } + } + } + + const fetchQuotes = async ( + request: RampQuoteRequest + ): Promise => { + const { + amountQuery, + amountType, + direction, + displayCurrencyCode, + regionCode, + tokenId, + wallet + } = request + if (amountType !== 'crypto') return [] + + const assetInfo = await getAssetInfo( + wallet.currencyInfo.pluginId, + tokenId, + direction + ) + if (assetInfo == null) return [] + + const cryptoAmount = pickAmount(amountQuery, assetInfo) + checkLimits(cryptoAmount, assetInfo, displayCurrencyCode) + const fiatCurrencyCode = removeIsoPrefix(request.fiatCurrencyCode) + + const approveQuote = async ( + params: RampApproveQuoteParams + ): Promise => { + const { coreWallet } = params + const anchor = await getAnchor() + const api = makeSep24Api(coreWallet, anchor) + const { id, url } = await api.startInteractive({ + kind: direction === 'buy' ? 'deposit' : 'withdraw', + assetCode: NATIVE_ASSET_CODE, + account: await getStellarAccount(coreWallet), + amount: cryptoAmount + }) + + const transfer: MoneygramTransfer = { + id, + direction, + walletId: coreWallet.id, + tokenId, + assetCode: NATIVE_ASSET_CODE, + fiatCurrencyCode, + status: 'incomplete', + createdAt: new Date().toISOString(), + sendTxid: undefined, + taggedTxid: undefined, + anchor + } + await saveTransfer(store, transfer) + + // The anchor's page collects the user's details, then the poller + // takes over from the status it reports: + let webViewOpen = true + openEdgeWebView({ + navigation, + url, + // Later page loads stay on the hosts the first URL had to pass: + onShouldStartLoad: pageUrl => isAnchorUrl(pageUrl, anchor), + onClose: () => { + webViewOpen = false + } + }) + watchTransfer(context, transfer, { + askBeforeSend: () => !webViewOpen, + closeWebView: () => { + if (!webViewOpen) return + webViewOpen = false + navigation.pop() + } + }).catch((error: unknown) => { + showError(error) + }) + } + + const quote: RampQuote = { + pluginId: MONEYGRAM_PLUGIN_ID, + partnerIcon, + pluginDisplayName: MONEYGRAM_DISPLAY_NAME, + displayCurrencyCode, + // MoneyGram shows the cash amount and fees on its own page. Both + // amounts stay '0' so ranking treats this as an unpriced placeholder + // rather than a zero rate (approveQuote still sends the real amount): + cryptoAmount: '0', + isEstimate: true, + fiatCurrencyCode, + fiatAmount: '0', + direction, + regionCode, + paymentType: 'cash', + settlementRange: SETTLEMENT_RANGE, + specialQuoteRateMessage: lstrings.tap_to_view_quote_amount_and_rate, + approveQuote, + closeQuote: async () => {} + } + return [quote] + } + + const plugin: RampPlugin = { + pluginId: MONEYGRAM_PLUGIN_ID, + rampInfo: { partnerIcon, pluginDisplayName: MONEYGRAM_DISPLAY_NAME }, + checkSupport, + fetchQuotes + } + return plugin +} + +/** Stellar amounts have 7 decimal places; avoids exponent notation. */ +const toDecimal = (amount: number): string => + amount.toFixed(7).replace(/\.?0+$/, '') + +/** Turns the amount request into the exact amount to send the anchor. */ +export function pickAmount( + amountQuery: RampQuoteRequest['amountQuery'], + assetInfo: Sep24AssetInfo +): string { + const max = + assetInfo.max_amount == null ? undefined : toDecimal(assetInfo.max_amount) + if ('exchangeAmount' in amountQuery) return amountQuery.exchangeAmount + if ('maxExchangeAmount' in amountQuery) { + const { maxExchangeAmount } = amountQuery + return max != null && gt(maxExchangeAmount, max) ? max : maxExchangeAmount + } + if (max == null) { + throw new Error('MoneyGram has no maximum amount for this asset') + } + return max +} + +/** Throws the quote scene's limit errors for amounts outside /info. */ +export function checkLimits( + amount: string, + assetInfo: Sep24AssetInfo, + displayCurrencyCode: string +): void { + const { min_amount: min, max_amount: max } = assetInfo + if (min != null && lt(amount, toDecimal(min))) { + throw new FiatProviderError({ + providerId: MONEYGRAM_PLUGIN_ID, + errorType: 'underLimit', + errorAmount: min, + displayCurrencyCode + }) + } + if (max != null && gt(amount, toDecimal(max))) { + throw new FiatProviderError({ + providerId: MONEYGRAM_PLUGIN_ID, + errorType: 'overLimit', + errorAmount: max, + displayCurrencyCode + }) + } +} + +/** + * Picks up saved MoneyGram transfers the anchor has not finished, such as a + * withdraw still waiting for its payment, and follows them to the end. + */ +export async function resumeMoneygramTransfers( + config: Pick +): Promise { + const { account, navigation, onLogEvent } = config + const store = createStore( + getRampPluginStoreId(MONEYGRAM_PLUGIN_ID), + account.dataStore + ) + const context: TransferContext = { account, navigation, onLogEvent, store } + + const transfers = await loadOpenTransfers(store) + for (const transfer of transfers) { + const age = Date.now() - new Date(transfer.createdAt).valueOf() + const mayBeStale = + transfer.status === 'incomplete' || isTerminalStatus(transfer.status) + if (mayBeStale && age > STALE_RESUME_MS) continue + watchTransfer(context, transfer, { askBeforeSend: () => true }).catch( + (error: unknown) => { + console.warn(`MoneyGram: resuming ${transfer.id} failed`, error) + } + ) + } +} diff --git a/src/plugins/ramps/moneygram/moneygramStore.ts b/src/plugins/ramps/moneygram/moneygramStore.ts new file mode 100644 index 00000000000..361ef306bc9 --- /dev/null +++ b/src/plugins/ramps/moneygram/moneygramStore.ts @@ -0,0 +1,49 @@ +import { asJSON } from 'cleaners' + +import type { RampPluginStore } from '../utils/createStore' +import { asMoneygramTransfer, type MoneygramTransfer } from './moneygramTypes' +import { isTerminalStatus } from './sep24Api' + +export async function saveTransfer( + store: RampPluginStore, + transfer: MoneygramTransfer +): Promise { + await store.setItem(transfer.id, JSON.stringify(transfer)) +} + +export async function updateTransfer( + store: RampPluginStore, + transfer: MoneygramTransfer, + changes: Partial +): Promise { + const next = { ...transfer, ...changes } + await saveTransfer(store, next) + return next +} + +/** + * True while the anchor has not finished a transfer, or while a finished + * deposit's payout still lacks its buy tag. + */ +export const isOpenTransfer = (transfer: MoneygramTransfer): boolean => + !isTerminalStatus(transfer.status) || + (transfer.direction === 'buy' && + transfer.status === 'completed' && + transfer.taggedTxid == null) + +/** Saved transfers that still need work, skipping unreadable items. */ +export async function loadOpenTransfers( + store: RampPluginStore +): Promise { + const ids = await store.listItemIds().catch((): string[] => []) + const out: MoneygramTransfer[] = [] + for (const id of ids) { + try { + const transfer = asJSON(asMoneygramTransfer)(await store.getItem(id)) + if (isOpenTransfer(transfer)) out.push(transfer) + } catch (error: unknown) { + console.warn(`MoneyGram: skipping unreadable transfer ${id}`, error) + } + } + return out +} diff --git a/src/plugins/ramps/moneygram/moneygramTransfer.ts b/src/plugins/ramps/moneygram/moneygramTransfer.ts new file mode 100644 index 00000000000..69b924f8bfa --- /dev/null +++ b/src/plugins/ramps/moneygram/moneygramTransfer.ts @@ -0,0 +1,489 @@ +import { mul } from 'biggystring' +import type { + EdgeAccount, + EdgeCurrencyWallet, + EdgeMemo, + EdgeSpendInfo, + EdgeTransaction, + EdgeTxActionFiat +} from 'edge-core-js' +import { base16, base64 } from 'rfc4648' +import { sprintf } from 'sprintf-js' + +import { showButtonsModal } from '../../../components/modals/ButtonsModal' +import { + showError, + showToast +} from '../../../components/services/AirshipInstance' +import { lstrings } from '../../../locales/strings' +import { getExchangeDenom } from '../../../selectors/DenominationSelectors' +import { CryptoAmount } from '../../../util/CryptoAmount' +import { snooze } from '../../../util/utils' +import { waitForWalletOtherMethods } from '../../../util/waitForWalletOtherMethods' +import { + SendErrorBackPressed, + SendErrorNoTransaction +} from '../../gui/fiatPlugin' +import { isWalletTestnet } from '../../gui/pluginUtils' +import type { RampPluginConfig } from '../rampPluginTypes' +import { updateTransfer } from './moneygramStore' +import type { MoneygramTransfer, Sep24Transaction } from './moneygramTypes' +import { getStellarAccount } from './sep10' +import { anchorPageUrl, isTerminalStatus, makeSep24Api } from './sep24Api' + +export const MONEYGRAM_PLUGIN_ID = 'moneygram' +export const MONEYGRAM_DISPLAY_NAME = 'MoneyGram' + +const POLL_MS = 5000 +const WATCH_LIMIT_MS = 2 * 60 * 60 * 1000 +const TAG_ATTEMPTS = 12 + +export type TransferContext = Pick< + RampPluginConfig, + 'account' | 'navigation' | 'onLogEvent' | 'store' +> + +export interface WatchOptions { + /** + * True when the send scene should wait for the user to agree, such as for + * a transfer resumed at login or one whose anchor page the user closed. + */ + askBeforeSend: () => boolean + /** Closes the anchor's interactive page, if it is still showing. */ + closeWebView?: () => void +} + +// One poller per anchor transaction, shared by the quote flow and resume. +// Each entry keeps the account that started it, so a poller left over from +// a logged-out account never blocks the next login's: +const activeWatches = new Map() + +// Withdraws take turns at the send prompt and scene, so transfers resumed +// together never stack modals or replace each other's send scene. Keyed by +// account, so a send left hanging at logout never blocks the next login: +const sendQueues = new WeakMap>() + +async function withSendTurn( + account: EdgeAccount, + task: () => Promise +): Promise { + const previous = sendQueues.get(account) ?? Promise.resolve() + const turn = previous.then(task, task) + sendQueues.set( + account, + turn.catch(() => {}) + ) + return await turn +} + +/** + * Follows one anchor transaction until it finishes, saving each new status. + * A withdraw that is waiting for funds opens the send scene, and a finished + * deposit tags the incoming transaction as a buy. + */ +export async function watchTransfer( + context: TransferContext, + initial: MoneygramTransfer, + options: WatchOptions +): Promise { + const { account } = context + const owner = activeWatches.get(initial.id) + if (owner?.loggedIn === true) return + activeWatches.set(initial.id, account) + try { + await runWatch(context, initial, options) + } finally { + if (activeWatches.get(initial.id) === account) { + activeWatches.delete(initial.id) + } + } +} + +async function runWatch( + context: TransferContext, + initial: MoneygramTransfer, + options: WatchOptions +): Promise { + const { account, store } = context + if (!account.activeWalletIds.includes(initial.walletId)) return + const wallet = await account.waitForCurrencyWallet(initial.walletId) + // SEP-10 signs with an engine method, which a cached wallet lacks at first: + await waitForWalletOtherMethods(wallet) + const api = makeSep24Api(wallet, initial.anchor) + + // A deposit resumed only for its buy tag already logged and showed its end: + const alreadyFinished = isTerminalStatus(initial.status) + let transfer = initial + let payoutWaits = 0 + // A send that failed to broadcast asks before it opens the send scene again: + let sendFailed = false + const deadline = Date.now() + WATCH_LIMIT_MS + // A logout stops the poller, and the next login resumes the transfer: + while (account.loggedIn && Date.now() < deadline) { + let anchorTx: Sep24Transaction + try { + anchorTx = await api.getTransaction(transfer.id) + } catch (error: unknown) { + console.warn(`MoneyGram: polling ${transfer.id} failed`, error) + await snooze(POLL_MS) + continue + } + if (!account.loggedIn) return + + if (anchorTx.status !== transfer.status) { + transfer = await updateTransfer(store, transfer, { + status: anchorTx.status + }) + } + + // The anchor can report the status a moment before it fills in where + // to pay, so a withdraw keeps polling until both are there: + if ( + transfer.direction === 'sell' && + anchorTx.status === 'pending_user_transfer_start' && + transfer.sendTxid == null && + hasPaymentInstructions(anchorTx) + ) { + // A payment that went out before its txid was saved is already in + // the wallet, and paying again would send the money twice: + const sentTxid = await findSentPayment(wallet, transfer) + if (!account.loggedIn) return + const sent = + sentTxid != null + ? { txid: sentTxid } + : await withSendTurn(account, async () => + // A logout while this waited its turn puts the send off: + account.loggedIn + ? await sendToAnchor(context, wallet, transfer, anchorTx, { + ...options, + askBeforeSend: () => sendFailed || options.askBeforeSend() + }) + : 'later' + ) + if (!account.loggedIn) return + // Put off by the user, so the next login asks again: + if (sent === 'later') return + if (sent === 'failed') { + sendFailed = true + await snooze(POLL_MS) + continue + } + transfer = await updateTransfer(store, transfer, { sendTxid: sent.txid }) + } + + if ( + transfer.direction === 'buy' && + anchorTx.status === 'completed' && + transfer.taggedTxid == null + ) { + // The anchor can mark a deposit complete a moment before it reports + // the payout transaction, so give it a few polls to fill that in: + if ( + anchorTx.stellar_transaction_id == null && + payoutWaits < TAG_ATTEMPTS + ) { + ++payoutWaits + await snooze(POLL_MS) + continue + } + const txid = await tagDeposit(account, wallet, transfer, anchorTx) + if (!account.loggedIn) return + if (txid != null) { + transfer = await updateTransfer(store, transfer, { taggedTxid: txid }) + } + if (!alreadyFinished) logBuy(context, wallet, transfer, anchorTx) + } + + if (isTerminalStatus(anchorTx.status)) { + if (!alreadyFinished) showOutcome(transfer, anchorTx) + return + } + await snooze(POLL_MS) + } +} + +type Sep24PaymentInstructions = Sep24Transaction & { + amount_in: string + withdraw_anchor_account: string + withdraw_memo: string +} + +/** + * True once the anchor says where and how much to pay. The memo is required + * too: anchors match payments to a shared account by memo, so a payment + * sent before the memo shows up could never be credited. + */ +export function hasPaymentInstructions( + anchorTx: Sep24Transaction +): anchorTx is Sep24PaymentInstructions { + return ( + anchorTx.amount_in != null && + anchorTx.withdraw_anchor_account != null && + anchorTx.withdraw_memo != null + ) +} + +/** + * The txid of a withdraw payment the wallet already broadcast, found by the + * order id the send saved on the transaction. + */ +export async function findSentPayment( + wallet: EdgeCurrencyWallet, + transfer: MoneygramTransfer +): Promise { + const txs = await wallet.getTransactions({ tokenId: transfer.tokenId }) + const sent = txs.find( + tx => + tx.savedAction?.actionType === 'fiat' && + tx.savedAction.fiatPlugin.providerId === MONEYGRAM_PLUGIN_ID && + tx.savedAction.orderId === transfer.id + ) + return sent?.txid +} + +/** + * How a withdraw payment ended: broadcast, put off by the user, or failed + * to go out. + */ +type SendResult = { txid: string } | 'later' | 'failed' + +/** + * Pays the anchor for a withdraw through the send scene, with the anchor's + * address, amount and memo locked in. + */ +async function sendToAnchor( + context: TransferContext, + wallet: EdgeCurrencyWallet, + transfer: MoneygramTransfer, + anchorTx: Sep24PaymentInstructions, + options: WatchOptions +): Promise { + const { navigation, onLogEvent } = context + const { tokenId } = transfer + const { + amount_in: amountIn, + withdraw_anchor_account: anchorAccount, + withdraw_memo: memo, + withdraw_memo_type: memoType + } = anchorTx + + const currencyCode = + tokenId == null + ? wallet.currencyInfo.currencyCode + : wallet.currencyConfig.allTokens[tokenId]?.currencyCode ?? '' + if (options.askBeforeSend()) { + const answer = await showButtonsModal({ + title: lstrings.moneygram_send_title, + message: sprintf( + lstrings.moneygram_send_message_2s, + `${amountIn} ${currencyCode}`, + anchorAccount + ), + buttons: { + send: { label: lstrings.fragment_send_subtitle }, + later: { label: lstrings.not_now_button } + } + }) + if (answer !== 'send') return 'later' + } + options.closeWebView?.() + + const { multiplier } = getExchangeDenom(wallet.currencyConfig, tokenId) + const nativeAmount = mul(amountIn, multiplier) + const savedAction: EdgeTxActionFiat = { + actionType: 'fiat', + orderId: transfer.id, + orderUri: anchorPageUrl(anchorTx.more_info_url, transfer.anchor), + isEstimate: true, + fiatPlugin: { + providerId: MONEYGRAM_PLUGIN_ID, + providerDisplayName: MONEYGRAM_DISPLAY_NAME + }, + payinAddress: anchorAccount, + cryptoAsset: { + pluginId: wallet.currencyInfo.pluginId, + tokenId, + nativeAmount + }, + fiatAsset: { + fiatCurrencyCode: transfer.fiatCurrencyCode, + fiatAmount: anchorTx.amount_out ?? '0' + } + } + const spendInfo: EdgeSpendInfo = { + tokenId, + assetAction: { assetActionType: 'sell' }, + savedAction, + spendTargets: [{ nativeAmount, publicAddress: anchorAccount }], + memos: [toEdgeMemo(memo, memoType)] + } + + try { + // The send scene closes itself before it reports the broadcast: + const tx = await new Promise((resolve, reject) => { + navigation.navigate('send2', { + walletId: wallet.id, + tokenId, + spendInfo, + lockTilesMap: { address: true, amount: true, memo: true, wallet: true }, + onDone: (error: Error | null, edgeTransaction?: EdgeTransaction) => { + if (error != null) reject(error) + else if (edgeTransaction != null) resolve(edgeTransaction) + else reject(new Error(SendErrorNoTransaction)) + }, + onBack: () => { + reject(new Error(SendErrorBackPressed)) + } + }) + }) + + if (!isWalletTestnet(wallet)) { + onLogEvent('Sell_Success', { + conversionValues: { + conversionType: 'sell', + destFiatAmount: anchorTx.amount_out ?? '0', + destFiatCurrencyCode: transfer.fiatCurrencyCode, + sourceAmount: new CryptoAmount({ + currencyConfig: wallet.currencyConfig, + tokenId, + exchangeAmount: amountIn + }), + fiatProviderId: MONEYGRAM_PLUGIN_ID, + orderId: transfer.id + } + }) + } + return { txid: tx.txid } + } catch (error: unknown) { + if (error instanceof Error && error.message === SendErrorBackPressed) { + showToast(lstrings.moneygram_send_later) + return 'later' + } + if (error instanceof Error && error.message === SendErrorNoTransaction) { + showToast(lstrings.fiat_plugin_sell_failed_to_send_try_again) + } else { + showError(error) + } + return 'failed' + } +} + +/** Converts a SEP-24 withdraw memo into the memo the Stellar engine sends. */ +export function toEdgeMemo( + memo: string, + memoType: Sep24Transaction['withdraw_memo_type'] +): EdgeMemo { + switch (memoType) { + case 'id': + return { type: 'number', memoName: 'memo_id', value: memo } + case 'hash': + // SEP-24 sends hash memos as base64; the engine takes hex: + return { + type: 'hex', + memoName: 'memo_hash', + value: base16.stringify(base64.parse(memo)).toLowerCase() + } + default: + return { type: 'text', memoName: 'memo_text', value: memo } + } +} + +/** + * Marks the anchor's payment to this wallet as a MoneyGram buy. The wallet + * may not have seen the payment yet, so this retries for a minute. + */ +async function tagDeposit( + account: EdgeAccount, + wallet: EdgeCurrencyWallet, + transfer: MoneygramTransfer, + anchorTx: Sep24Transaction +): Promise { + const txid = anchorTx.stellar_transaction_id + if (txid == null) return + const { tokenId } = transfer + + // A logout ends the retries, so no tag lands on the old account's wallet: + for (let attempt = 0; attempt < TAG_ATTEMPTS && account.loggedIn; ++attempt) { + const txs = await wallet.getTransactions({ tokenId }) + if (!account.loggedIn) return + const tx = txs.find(tx => tx.txid.toLowerCase() === txid.toLowerCase()) + if (tx != null) { + await wallet.saveTxAction({ + txid: tx.txid, + tokenId, + assetAction: { assetActionType: 'buy' }, + savedAction: { + actionType: 'fiat', + orderId: transfer.id, + orderUri: anchorPageUrl(anchorTx.more_info_url, transfer.anchor), + isEstimate: false, + fiatPlugin: { + providerId: MONEYGRAM_PLUGIN_ID, + providerDisplayName: MONEYGRAM_DISPLAY_NAME + }, + // An engine may leave the receive addresses empty, and a Stellar + // wallet only has the one account: + payoutAddress: + tx.ourReceiveAddresses[0] ?? (await getStellarAccount(wallet)), + cryptoAsset: { + pluginId: wallet.currencyInfo.pluginId, + tokenId, + nativeAmount: tx.nativeAmount + }, + fiatAsset: { + fiatCurrencyCode: transfer.fiatCurrencyCode, + fiatAmount: anchorTx.amount_in ?? '0' + } + } + }) + return tx.txid + } + await snooze(POLL_MS) + } +} + +function logBuy( + context: TransferContext, + wallet: EdgeCurrencyWallet, + transfer: MoneygramTransfer, + anchorTx: Sep24Transaction +): void { + if (isWalletTestnet(wallet) || anchorTx.amount_out == null) return + context.onLogEvent('Buy_Success', { + conversionValues: { + conversionType: 'buy', + sourceFiatAmount: anchorTx.amount_in ?? '0', + sourceFiatCurrencyCode: transfer.fiatCurrencyCode, + destAmount: new CryptoAmount({ + currencyConfig: wallet.currencyConfig, + tokenId: transfer.tokenId, + exchangeAmount: anchorTx.amount_out + }), + fiatProviderId: MONEYGRAM_PLUGIN_ID, + orderId: transfer.id + } + }) +} + +function showOutcome( + transfer: MoneygramTransfer, + anchorTx: Sep24Transaction +): void { + if (anchorTx.status === 'completed') { + showToast( + transfer.direction === 'buy' + ? lstrings.moneygram_deposit_complete + : lstrings.moneygram_withdraw_complete + ) + return + } + showError( + new Error( + sprintf( + lstrings.moneygram_transfer_failed_2s, + anchorTx.status, + anchorTx.message ?? '' + ) + ) + ) +} From 63b48828fcd0570e1c33f00634b473f0e31e42a7 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 6 Nov 2026 14:44:43 -0800 Subject: [PATCH 6/6] Add USDC to MoneyGram with trustline setup Read the [[CURRENCIES]] tables from the anchor's stellar.toml and offer a Stellar token only when its code and issuer match a listed currency, so a look-alike token from another issuer gets no quote. Before a token deposit, open the wallet's trustline through the engine's activation quote after the user agrees to the XLM fee, since the anchor cannot pay a token into an account that does not trust it. --- CHANGELOG.md | 2 + src/__tests__/plugins/ramps/moneygram.test.ts | 523 +++++++++++++++++- src/locales/en_US.ts | 3 + src/locales/strings/enUS.json | 2 + .../ramps/moneygram/moneygramRampPlugin.ts | 84 ++- .../ramps/moneygram/moneygramTrustline.ts | 64 +++ src/plugins/ramps/moneygram/moneygramTypes.ts | 8 + src/plugins/ramps/moneygram/sep1.ts | 78 ++- 8 files changed, 727 insertions(+), 37 deletions(-) create mode 100644 src/plugins/ramps/moneygram/moneygramTrustline.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea74eb21598..177bf0203ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- added: MoneyGram cash in and cash out for Stellar through SEP-24, with USDC trustline setup before a deposit (testnet prototype) +- fixed: FIO connected-wallet and name refreshes waiting 10 minutes after login on accounts with a FIO wallet - fixed: Exchange info, gift card info, Houdini chain, and Zcash migration status refreshes running twice per interval ## 4.53.0 (staging) diff --git a/src/__tests__/plugins/ramps/moneygram.test.ts b/src/__tests__/plugins/ramps/moneygram.test.ts index b5863f0c304..c6d407d0f54 100644 --- a/src/__tests__/plugins/ramps/moneygram.test.ts +++ b/src/__tests__/plugins/ramps/moneygram.test.ts @@ -1,26 +1,50 @@ import { afterEach, describe, expect, it, jest } from '@jest/globals' -import type { EdgeCurrencyWallet } from 'edge-core-js' +import type { + EdgeAccount, + EdgeActivationQuote, + EdgeCurrencyWallet, + EdgeTokenMap +} from 'edge-core-js' +import { showButtonsModal } from '../../../components/modals/ButtonsModal' import { FiatProviderError } from '../../../plugins/gui/fiatProviderTypes' import { checkLimits, + findAnchorAsset, moneygramRampPlugin, pickAmount } from '../../../plugins/ramps/moneygram/moneygramRampPlugin' import { loadOpenTransfers } from '../../../plugins/ramps/moneygram/moneygramStore' -import { toEdgeMemo } from '../../../plugins/ramps/moneygram/moneygramTransfer' +import { + findSentPayment, + hasPaymentInstructions, + toEdgeMemo, + type TransferContext, + watchTransfer +} from '../../../plugins/ramps/moneygram/moneygramTransfer' +import { activateTokenForDeposit } from '../../../plugins/ramps/moneygram/moneygramTrustline' import type { AnchorEndpoints, - MoneygramTransfer + MoneygramTransfer, + Sep24Transaction } from '../../../plugins/ramps/moneygram/moneygramTypes' import { asAnchorToml, - parseStellarToml + parseStellarToml, + parseTomlCurrencies } from '../../../plugins/ramps/moneygram/sep1' import { fetchWithSep10 } from '../../../plugins/ramps/moneygram/sep10' -import { isTerminalStatus } from '../../../plugins/ramps/moneygram/sep24Api' +import { + isTerminalStatus, + makeSep24Api +} from '../../../plugins/ramps/moneygram/sep24Api' import type { RampPluginConfig } from '../../../plugins/ramps/rampPluginTypes' import type { RampPluginStore } from '../../../plugins/ramps/utils/createStore' +import { rampQuoteHasAmounts } from '../../../plugins/ramps/utils/rampQuotePriority' + +jest.mock('../../../components/modals/ButtonsModal', () => ({ + showButtonsModal: jest.fn() +})) jest.mock('../../../components/services/AirshipInstance', () => ({ ...jest.requireActual('../../../components/services/AirshipInstance'), @@ -28,6 +52,22 @@ jest.mock('../../../components/services/AirshipInstance', () => ({ showToast: jest.fn() })) +jest.mock('../../../plugins/ramps/moneygram/sep24Api', () => { + const actual = jest.requireActual<{ makeSep24Api: typeof makeSep24Api }>( + '../../../plugins/ramps/moneygram/sep24Api' + ) + return { ...actual, makeSep24Api: jest.fn(actual.makeSep24Api) } +}) + +jest.mock('../../../util/utils', () => ({ + ...jest.requireActual('../../../util/utils'), + snooze: async () => {} +})) + +jest.mock('../../../util/waitForWalletOtherMethods', () => ({ + waitForWalletOtherMethods: async () => {} +})) + const TESTNET = 'Test SDF Network ; September 2015' const ANCHOR: AnchorEndpoints = { homeDomain: 'testanchor.stellar.org', @@ -36,6 +76,25 @@ const ANCHOR: AnchorEndpoints = { webAuthEndpoint: 'https://testanchor.stellar.org/auth' } const ACCOUNT = 'GAMYLWB5BQWUZZ3LG4T34XWOEJ7JRUPOEW5UJSVHBMVOMTDEMOH4JA3W' +const USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' +const OTHER_ISSUER = 'GCDNJUBQSX7AJWLJACMJ7I4BC3Z47BQUTMHEICZLE6MU4KQBRYG5JY6B' +const USDC_TOKEN_ID = `USDC-${USDC_ISSUER}` +const FAKE_USDC_TOKEN_ID = `USDC-${OTHER_ISSUER}` + +const TOKENS: EdgeTokenMap = { + [USDC_TOKEN_ID]: { + currencyCode: 'USDC', + displayName: 'USD Coin', + denominations: [{ name: 'USDC', multiplier: '10000000' }], + networkLocation: { code: 'USDC', issuer: USDC_ISSUER } + }, + [FAKE_USDC_TOKEN_ID]: { + currencyCode: 'USDC', + displayName: 'Look-alike USDC', + denominations: [{ name: 'USDC', multiplier: '10000000' }], + networkLocation: { code: 'USDC', issuer: OTHER_ISSUER } + } +} const STELLAR_TOML = ` # Test anchor @@ -48,6 +107,14 @@ ACCOUNTS=["${ACCOUNT}"] [DOCUMENTATION] ORG_NAME="Stellar Development Foundation" SIGNING_KEY="not a top-level key" + +[[CURRENCIES]] +code = "USDC" +issuer = "${USDC_ISSUER}" # Circle test USDC +status = "test" + +[[CURRENCIES]] +code = "native" ` const realFetch = global.fetch @@ -118,9 +185,24 @@ describe('SEP-1 stellar.toml', () => { expect(fields.ORG_NAME).toBeUndefined() }) + it('reads the code and issuer of each listed currency', () => { + expect(parseTomlCurrencies(STELLAR_TOML)).toEqual([ + { code: 'USDC', issuer: USDC_ISSUER }, + { code: 'native', issuer: undefined } + ]) + }) + it('picks the anchor endpoints without a trailing slash', () => { - const toml = asAnchorToml(parseStellarToml(STELLAR_TOML), ANCHOR.homeDomain) + const toml = asAnchorToml( + parseStellarToml(STELLAR_TOML), + parseTomlCurrencies(STELLAR_TOML), + ANCHOR.homeDomain + ) expect(toml).toEqual({ + currencies: [ + { code: 'USDC', issuer: USDC_ISSUER }, + { code: 'native', issuer: undefined } + ], networkPassphrase: TESTNET, signingKey: ANCHOR.signingKey, transferServerSep24: ANCHOR.transferServerSep24, @@ -128,10 +210,23 @@ describe('SEP-1 stellar.toml', () => { }) }) + it('rejects login or transfer servers off the home domain', () => { + for (const [key, value] of [ + ['TRANSFER_SERVER_SEP0024', 'https://evil.example.com/sep24'], + ['TRANSFER_SERVER_SEP0024', 'http://testanchor.stellar.org/sep24'], + ['WEB_AUTH_ENDPOINT', 'https://stellar.org/auth'] + ]) { + const fields = { ...parseStellarToml(STELLAR_TOML), [key]: value } + expect(() => asAnchorToml(fields, [], ANCHOR.homeDomain)).toThrow( + `testanchor.stellar.org stellar.toml ${key} is not on the domain` + ) + } + }) + it('rejects a stellar.toml without a SEP-10 signing key', () => { const fields = parseStellarToml(STELLAR_TOML) delete fields.SIGNING_KEY - expect(() => asAnchorToml(fields, ANCHOR.homeDomain)).toThrow( + expect(() => asAnchorToml(fields, [], ANCHOR.homeDomain)).toThrow( 'testanchor.stellar.org stellar.toml has no SIGNING_KEY' ) }) @@ -304,6 +399,253 @@ describe('MoneyGram withdraw memos', () => { }) }) +describe('MoneyGram withdraw payments', () => { + const pending: Sep24Transaction = { + id: 'withdraw-1', + kind: 'withdrawal', + status: 'pending_user_transfer_start', + amount_in: '5', + amount_out: undefined, + message: undefined, + more_info_url: undefined, + stellar_transaction_id: undefined, + withdraw_anchor_account: ACCOUNT, + withdraw_memo: '9191312236600194934', + withdraw_memo_type: 'id' + } + + it('waits for the amount, account and memo before paying', () => { + expect(hasPaymentInstructions(pending)).toBe(true) + expect( + hasPaymentInstructions({ ...pending, withdraw_memo: undefined }) + ).toBe(false) + expect( + hasPaymentInstructions({ ...pending, withdraw_anchor_account: undefined }) + ).toBe(false) + expect(hasPaymentInstructions({ ...pending, amount_in: undefined })).toBe( + false + ) + }) + + it('finds a payment the wallet already sent for the transfer', async () => { + const fiatAction = (orderId: string, providerId = 'moneygram'): object => ({ + actionType: 'fiat', + orderId, + fiatPlugin: { providerId, providerDisplayName: 'Some ramp' } + }) + const getTransactions = jest.fn(async () => [ + { txid: 'plain-send' }, + { txid: 'other-ramp', savedAction: fiatAction('withdraw-1', 'paybis') }, + { txid: 'other-order', savedAction: fiatAction('withdraw-0') }, + { txid: 'paid', savedAction: fiatAction('withdraw-1') } + ]) + const wallet = { getTransactions } as unknown as EdgeCurrencyWallet + + expect( + await findSentPayment(wallet, makeTransfer('withdraw-1', 'pending')) + ).toBe('paid') + expect( + await findSentPayment(wallet, makeTransfer('withdraw-2', 'pending')) + ).toBeUndefined() + expect(getTransactions).toHaveBeenCalledWith({ tokenId: null }) + }) +}) + +describe('MoneyGram watchTransfer', () => { + const pending: Sep24Transaction = { + id: 'watch-1', + kind: 'withdrawal', + status: 'pending_user_transfer_start', + amount_in: '5', + amount_out: '4.5', + message: undefined, + more_info_url: undefined, + stellar_transaction_id: undefined, + withdraw_anchor_account: ACCOUNT, + withdraw_memo: '9191312236600194934', + withdraw_memo_type: 'id' + } + + const setup = ( + statuses: Sep24Transaction[], + getTransactions: () => Promise = async () => [], + initial: MoneygramTransfer = makeTransfer( + 'watch-1', + 'pending_user_transfer_start' + ) + ): { + account: { loggedIn: boolean } + context: TransferContext + navigate: jest.Mock<(name: string, params: any) => void> + saveTxAction: jest.Mock<(action: unknown) => Promise> + saved: () => MoneygramTransfer + run: () => Promise + } => { + const getTransaction = jest.fn(async () => statuses.shift() ?? pending) + jest + .mocked(makeSep24Api) + .mockReturnValueOnce({ getTransaction } as unknown as ReturnType< + typeof makeSep24Api + >) + const saveTxAction = jest.fn(async (action: unknown) => {}) + const wallet = { + id: 'wallet-id', + currencyInfo: { pluginId: 'stellartestnet', currencyCode: 'XLM' }, + currencyConfig: { + allTokens: {}, + currencyInfo: { + pluginId: 'stellartestnet', + denominations: [{ name: 'XLM', multiplier: '10000000' }] + } + }, + getTransactions, + saveTxAction + } + const account = { + loggedIn: true, + activeWalletIds: ['wallet-id'], + waitForCurrencyWallet: async () => wallet + } + const setItem = jest.fn(async (id: string, value: string) => {}) + const navigate = jest.fn<(name: string, params: any) => void>() + const context = { + account: account as unknown as EdgeAccount, + navigation: { navigate } as unknown as RampPluginConfig['navigation'], + onLogEvent: jest.fn(), + store: { setItem } as unknown as RampPluginStore + } + return { + account, + context, + navigate, + saveTxAction, + saved: () => JSON.parse(String(setItem.mock.calls.at(-1)?.[1])), + run: async () => { + await watchTransfer(context, initial, { askBeforeSend: () => false }) + } + } + } + + afterEach(() => { + jest.mocked(showButtonsModal).mockReset() + }) + + it('asks again after a send that failed, then saves the txid', async () => { + const { navigate, run, saved } = setup([ + pending, + pending, + { ...pending, status: 'completed' } + ]) + navigate + .mockImplementationOnce((name, params) => params.onDone(null)) + .mockImplementationOnce((name, params) => + params.onDone(null, { txid: 'paid-txid' }) + ) + jest.mocked(showButtonsModal).mockResolvedValue('send') + + await run() + + expect(navigate).toHaveBeenCalledTimes(2) + expect(showButtonsModal).toHaveBeenCalledTimes(1) + expect(navigate.mock.calls[1][1].lockTilesMap).toEqual({ + address: true, + amount: true, + memo: true, + wallet: true + }) + expect(navigate.mock.calls[1][1].spendInfo.memos).toEqual([ + { type: 'number', memoName: 'memo_id', value: '9191312236600194934' } + ]) + expect(saved()).toMatchObject({ + sendTxid: 'paid-txid', + status: 'completed' + }) + }) + + it('lets withdraws resumed together take turns at the send scene', async () => { + const { context, navigate } = setup([ + pending, + { ...pending, status: 'completed' } + ]) + const second: Sep24Transaction[] = [ + { ...pending, id: 'watch-2' }, + { ...pending, id: 'watch-2', status: 'completed' } + ] + jest.mocked(makeSep24Api).mockReturnValueOnce({ + getTransaction: async () => second.shift() ?? second[0] + } as unknown as ReturnType) + // Timers are fake here, so let the watchers' promise chains settle: + const flush = async (): Promise => { + for (let i = 0; i < 100; ++i) await Promise.resolve() + } + const askBeforeSend = (): boolean => false + + const watches = Promise.all([ + watchTransfer( + context, + makeTransfer('watch-1', 'pending_user_transfer_start'), + { askBeforeSend } + ), + watchTransfer( + context, + makeTransfer('watch-2', 'pending_user_transfer_start'), + { askBeforeSend } + ) + ]) + await flush() + // The second send waits until the first one's scene reports back: + expect(navigate).toHaveBeenCalledTimes(1) + + navigate.mock.calls[0][1].onDone(null, { txid: 'paid-1' }) + await flush() + expect(navigate).toHaveBeenCalledTimes(2) + navigate.mock.calls[1][1].onDone(null, { txid: 'paid-2' }) + await watches + + expect( + navigate.mock.calls.map(call => call[1].spendInfo.savedAction.orderId) + ).toEqual(['watch-1', 'watch-2']) + }) + + it('opens nothing once the account logs out mid-poll', async () => { + const holder: { account?: { loggedIn: boolean } } = {} + const { account, navigate, run } = setup([pending], async () => { + if (holder.account != null) holder.account.loggedIn = false + return [] + }) + holder.account = account + + await run() + + expect(showButtonsModal).not.toHaveBeenCalled() + expect(navigate).not.toHaveBeenCalled() + }) + + it('tags no deposit once the account logs out mid-tag', async () => { + const holder: { account?: { loggedIn: boolean } } = {} + const { account, run, saveTxAction } = setup( + [ + { + ...pending, + kind: 'deposit', + status: 'completed', + stellar_transaction_id: 'deposit-txid' + } + ], + async () => { + if (holder.account != null) holder.account.loggedIn = false + return [{ txid: 'deposit-txid', ourReceiveAddresses: [] }] + }, + { ...makeTransfer('watch-1', 'completed'), direction: 'buy' } + ) + holder.account = account + + await run() + + expect(saveTxAction).not.toHaveBeenCalled() + }) +}) + describe('MoneyGram amounts and limits', () => { const limits = { enabled: true, min_amount: 1, max_amount: 10 } @@ -401,9 +743,13 @@ describe('moneygramRampPlugin checkSupport', () => { return response({ json: { deposit: { - native: { enabled: true, min_amount: 1, max_amount: 10 } + native: { enabled: true, min_amount: 1, max_amount: 10 }, + USDC: { enabled: true, min_amount: 1, max_amount: 10 } }, - withdraw: { native: { enabled: withdrawEnabled } } + withdraw: { + native: { enabled: withdrawEnabled }, + USDC: { enabled: withdrawEnabled } + } } }) } @@ -413,7 +759,7 @@ describe('moneygramRampPlugin checkSupport', () => { return moneygramRampPlugin({ initOptions: {}, store: {} as unknown as RampPluginStore, - account: {}, + account: { currencyConfig: { stellartestnet: { allTokens: TOKENS } } }, navigation: {}, onLogEvent: () => {}, disklet: {} @@ -441,6 +787,35 @@ describe('moneygramRampPlugin checkSupport', () => { ).toEqual({ supported: true, supportedAmountTypes: ['crypto'] }) }) + it('supports a token the anchor lists from the same issuer', async () => { + const plugin = makePlugin(true) + expect( + await plugin.checkSupport(request('stellartestnet', USDC_TOKEN_ID, 'buy')) + ).toEqual({ supported: true, supportedAmountTypes: ['crypto'] }) + expect( + await plugin.checkSupport( + request('stellartestnet', FAKE_USDC_TOKEN_ID, 'buy') + ) + ).toEqual({ supported: false }) + }) + + it('quotes as an unpriced placeholder so ranking never sees a zero rate', async () => { + const plugin = makePlugin(true) + const quotes = await plugin.fetchQuotes({ + wallet: { currencyInfo: { pluginId: 'stellartestnet' } }, + tokenId: null, + displayCurrencyCode: 'XLM', + amountQuery: { exchangeAmount: '2.5' }, + fiatCurrencyCode: 'iso:USD', + amountType: 'crypto', + direction: 'buy', + regionCode: { countryCode: 'US' } + } as unknown as Parameters[0]) + expect(quotes).toHaveLength(1) + expect(rampQuoteHasAmounts(quotes[0])).toBe(false) + expect(quotes[0].specialQuoteRateMessage).toBeDefined() + }) + it('rejects other networks, tokens, and disabled directions', async () => { const plugin = makePlugin(false) // The test anchor runs on testnet, so mainnet XLM is unsupported: @@ -458,3 +833,131 @@ describe('moneygramRampPlugin checkSupport', () => { ).toEqual({ supported: false }) }) }) + +describe('MoneyGram anchor assets', () => { + const currencies = parseTomlCurrencies(STELLAR_TOML) + + it('maps XLM and listed tokens to the anchor code and issuer', () => { + expect(findAnchorAsset(currencies, TOKENS, null)).toEqual({ + code: 'native' + }) + expect(findAnchorAsset(currencies, TOKENS, USDC_TOKEN_ID)).toEqual({ + code: 'USDC', + issuer: USDC_ISSUER + }) + }) + + it('skips tokens from another issuer or without a location', () => { + expect( + findAnchorAsset(currencies, TOKENS, FAKE_USDC_TOKEN_ID) + ).toBeUndefined() + expect(findAnchorAsset(currencies, TOKENS, 'missing')).toBeUndefined() + expect( + findAnchorAsset( + currencies, + { odd: { ...TOKENS[USDC_TOKEN_ID], networkLocation: undefined } }, + 'odd' + ) + ).toBeUndefined() + }) +}) + +describe('MoneyGram trustline activation', () => { + const showModal = showButtonsModal as jest.Mock< + (...args: unknown[]) => Promise + > + + const setup = ( + unactivatedTokenIds: string[] + ): { + account: EdgeAccount + wallet: EdgeCurrencyWallet + activateWallet: jest.Mock<(...args: unknown[]) => Promise> + approve: jest.Mock<(...args: unknown[]) => Promise> + close: jest.Mock<() => Promise> + } => { + const approve = jest.fn(async (...args: unknown[]) => ({ + transactions: [] + })) + const close = jest.fn(async () => {}) + const quote: Partial = { + networkFee: { + nativeAmount: '100', + tokenId: null, + currencyPluginId: 'stellartestnet' + }, + approve, + close + } + const activateWallet = jest.fn(async (...args: unknown[]) => quote) + const account = { activateWallet } as unknown as EdgeAccount + const wallet = { + id: 'wallet-id', + unactivatedTokenIds, + currencyInfo: { + pluginId: 'stellartestnet', + currencyCode: 'XLM', + denominations: [{ name: 'XLM', multiplier: '10000000' }] + }, + currencyConfig: { + allTokens: TOKENS, + currencyInfo: { + pluginId: 'stellartestnet', + currencyCode: 'XLM', + denominations: [{ name: 'XLM', multiplier: '10000000' }] + } + } + } as unknown as EdgeCurrencyWallet + return { account, wallet, activateWallet, approve, close } + } + + afterEach(() => { + showModal.mockReset() + }) + + it('skips a token the wallet already trusts', async () => { + const { account, wallet, activateWallet } = setup([]) + expect(await activateTokenForDeposit(account, wallet, USDC_TOKEN_ID)).toBe( + true + ) + expect(activateWallet).not.toHaveBeenCalled() + expect(showModal).not.toHaveBeenCalled() + }) + + it('opens the trustline once the user agrees to the fee', async () => { + const { account, wallet, activateWallet, approve } = setup([USDC_TOKEN_ID]) + showModal.mockResolvedValue('activate') + expect(await activateTokenForDeposit(account, wallet, USDC_TOKEN_ID)).toBe( + true + ) + expect(activateWallet).toHaveBeenCalledWith({ + activateWalletId: 'wallet-id', + activateTokenIds: [USDC_TOKEN_ID], + paymentInfo: { walletId: 'wallet-id', tokenId: null } + }) + expect(showModal.mock.calls[0][0]).toMatchObject({ + message: expect.stringContaining('0.00001 XLM') + }) + expect(approve).toHaveBeenCalledTimes(1) + }) + + it('closes the quote when the user declines', async () => { + const { account, wallet, approve, close } = setup([USDC_TOKEN_ID]) + showModal.mockResolvedValue('cancel') + expect(await activateTokenForDeposit(account, wallet, USDC_TOKEN_ID)).toBe( + false + ) + expect(approve).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledTimes(1) + }) + + it('closes the quote when the activation fails', async () => { + const { account, wallet, approve, close } = setup([USDC_TOKEN_ID]) + showModal.mockResolvedValue('activate') + approve.mockRejectedValueOnce(new Error('broadcast failed')) + await expect( + activateTokenForDeposit(account, wallet, USDC_TOKEN_ID) + ).rejects.toThrow('broadcast failed') + expect(close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 22d15755c86..6a9821d8b18 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -2006,6 +2006,9 @@ const strings = { fiat_plugin_sell_failed_try_again: 'Sell order failed. Please try again.', fiat_plugin_sell_failed_to_send_try_again: 'Failed to send funds for sell transaction. Please try again.', + moneygram_activate_message_2s: + 'MoneyGram pays out %1$s, which this wallet has not activated yet. Activating it costs %2$s in network fees.', + moneygram_activate_button: 'Activate', moneygram_send_title: 'Send to MoneyGram', moneygram_send_message_2s: 'Your MoneyGram cash out is waiting for %1$s, paid to the MoneyGram account %2$s. Send it now to finish the transfer.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index a0ec91e6fca..8a94ed71052 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1548,6 +1548,8 @@ "fiat_plugin_sell_complete_message_3": "A confirmation email has been sent to your registered email address.", "fiat_plugin_sell_failed_try_again": "Sell order failed. Please try again.", "fiat_plugin_sell_failed_to_send_try_again": "Failed to send funds for sell transaction. Please try again.", + "moneygram_activate_message_2s": "MoneyGram pays out %1$s, which this wallet has not activated yet. Activating it costs %2$s in network fees.", + "moneygram_activate_button": "Activate", "moneygram_send_title": "Send to MoneyGram", "moneygram_send_message_2s": "Your MoneyGram cash out is waiting for %1$s, paid to the MoneyGram account %2$s. Send it now to finish the transfer.", "moneygram_send_later": "Your MoneyGram transfer is saved. Edge will ask you to send it the next time you log in.", diff --git a/src/plugins/ramps/moneygram/moneygramRampPlugin.ts b/src/plugins/ramps/moneygram/moneygramRampPlugin.ts index 5279490869e..dad305f4d57 100644 --- a/src/plugins/ramps/moneygram/moneygramRampPlugin.ts +++ b/src/plugins/ramps/moneygram/moneygramRampPlugin.ts @@ -1,4 +1,6 @@ import { gt, lt } from 'biggystring' +import { asMaybe, asObject, asString } from 'cleaners' +import type { EdgeTokenId, EdgeTokenMap } from 'edge-core-js' import { showError } from '../../../components/services/AirshipInstance' import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' @@ -26,7 +28,9 @@ import { type TransferContext, watchTransfer } from './moneygramTransfer' +import { activateTokenForDeposit } from './moneygramTrustline' import { + type AnchorCurrency, type AnchorEndpoints, type AnchorToml, asInitOptions, @@ -54,6 +58,12 @@ export const NETWORK_PASSPHRASES: Record = { // SEP-24 names the chain's own asset "native": const NATIVE_ASSET_CODE = 'native' +// Where a Stellar token lives, as the wallet's token list describes it: +const asStellarTokenLocation = asObject({ + code: asString, + issuer: asString +}) + const SETTLEMENT_RANGE: SettlementRange = { min: { value: 10, unit: 'minutes' }, max: { value: 1, unit: 'days' } @@ -102,27 +112,32 @@ export const moneygramRampPlugin: RampPluginFactory = config => { return await infoPromise } - /** The anchor's limits for this asset, if the wallet and anchor match. */ - const getAssetInfo = async ( + /** + * The anchor's asset and limits for this wallet asset, if the wallet and + * anchor share the network and the anchor handles the asset. + */ + const getAnchorAsset = async ( pluginId: string, - tokenId: string | null, + tokenId: EdgeTokenId, direction: 'buy' | 'sell' - ): Promise => { - // Issued assets wait on Stellar token support in the wallet: - if (tokenId != null) return + ): Promise< + { asset: AnchorCurrency; assetInfo: Sep24AssetInfo } | undefined + > => { const passphrase = NETWORK_PASSPHRASES[pluginId] if (passphrase == null) return const toml = await getToml() if (toml.networkPassphrase !== passphrase) return + const allTokens = account.currencyConfig[pluginId]?.allTokens ?? {} + const asset = findAnchorAsset(toml.currencies, allTokens, tokenId) + if (asset == null) return + const info = await getInfo() const assetInfo = - direction === 'buy' - ? info.deposit[NATIVE_ASSET_CODE] - : info.withdraw[NATIVE_ASSET_CODE] + direction === 'buy' ? info.deposit[asset.code] : info.withdraw[asset.code] if (!assetInfo?.enabled) return - return assetInfo + return { asset, assetInfo } } const checkSupport = async ( @@ -130,12 +145,12 @@ export const moneygramRampPlugin: RampPluginFactory = config => { ): Promise => { const { cryptoAsset, direction } = request try { - const assetInfo = await getAssetInfo( + const asset = await getAnchorAsset( cryptoAsset.pluginId, cryptoAsset.tokenId, direction ) - if (assetInfo == null) return { supported: false } + if (asset == null) return { supported: false } // The anchor prices the transfer on its own page, so the amount is // always entered in crypto: return { supported: true, supportedAmountTypes: ['crypto'] } @@ -159,12 +174,14 @@ export const moneygramRampPlugin: RampPluginFactory = config => { } = request if (amountType !== 'crypto') return [] - const assetInfo = await getAssetInfo( + const anchorAsset = await getAnchorAsset( wallet.currencyInfo.pluginId, tokenId, direction ) - if (assetInfo == null) return [] + if (anchorAsset == null) return [] + const { asset, assetInfo } = anchorAsset + const { code: assetCode, issuer: assetIssuer } = asset const cryptoAmount = pickAmount(amountQuery, assetInfo) checkLimits(cryptoAmount, assetInfo, displayCurrencyCode) @@ -174,11 +191,23 @@ export const moneygramRampPlugin: RampPluginFactory = config => { params: RampApproveQuoteParams ): Promise => { const { coreWallet } = params + // A deposit of a token needs the wallet's trustline before the + // anchor can pay it: + if (direction === 'buy' && tokenId != null) { + const activated = await activateTokenForDeposit( + account, + coreWallet, + tokenId + ) + if (!activated) return + } + const anchor = await getAnchor() const api = makeSep24Api(coreWallet, anchor) const { id, url } = await api.startInteractive({ kind: direction === 'buy' ? 'deposit' : 'withdraw', - assetCode: NATIVE_ASSET_CODE, + assetCode, + assetIssuer, account: await getStellarAccount(coreWallet), amount: cryptoAmount }) @@ -188,7 +217,7 @@ export const moneygramRampPlugin: RampPluginFactory = config => { direction, walletId: coreWallet.id, tokenId, - assetCode: NATIVE_ASSET_CODE, + assetCode, fiatCurrencyCode, status: 'incomplete', createdAt: new Date().toISOString(), @@ -254,6 +283,29 @@ export const moneygramRampPlugin: RampPluginFactory = config => { return plugin } +/** + * The anchor's SEP-24 asset for a wallet asset. A token only matches when + * the anchor's stellar.toml lists the same code from the same issuer, and + * the issuer goes to the anchor with the code, so a look-alike asset from + * another issuer never reaches the anchor. + */ +export function findAnchorAsset( + currencies: AnchorCurrency[], + allTokens: EdgeTokenMap, + tokenId: EdgeTokenId +): AnchorCurrency | undefined { + if (tokenId == null) return { code: NATIVE_ASSET_CODE } + const location = asMaybe(asStellarTokenLocation)( + allTokens[tokenId]?.networkLocation + ) + if (location == null) return + const listed = currencies.some( + currency => + currency.code === location.code && currency.issuer === location.issuer + ) + return listed ? location : undefined +} + /** Stellar amounts have 7 decimal places; avoids exponent notation. */ const toDecimal = (amount: number): string => amount.toFixed(7).replace(/\.?0+$/, '') diff --git a/src/plugins/ramps/moneygram/moneygramTrustline.ts b/src/plugins/ramps/moneygram/moneygramTrustline.ts new file mode 100644 index 00000000000..33cdc38b523 --- /dev/null +++ b/src/plugins/ramps/moneygram/moneygramTrustline.ts @@ -0,0 +1,64 @@ +import { div, log10 } from 'biggystring' +import type { EdgeAccount, EdgeCurrencyWallet } from 'edge-core-js' +import { sprintf } from 'sprintf-js' + +import { showButtonsModal } from '../../../components/modals/ButtonsModal' +import { lstrings } from '../../../locales/strings' +import { getExchangeDenom } from '../../../selectors/DenominationSelectors' + +/** + * Opens the wallet's trustline for a Stellar token, so the anchor can pay + * the token into it. The same wallet pays the fee in XLM after the user + * agrees to it. Returns false if the user declines. + */ +export async function activateTokenForDeposit( + account: EdgeAccount, + wallet: EdgeCurrencyWallet, + tokenId: string +): Promise { + if (!wallet.unactivatedTokenIds.includes(tokenId)) return true + + const quote = await account.activateWallet({ + activateWalletId: wallet.id, + activateTokenIds: [tokenId], + paymentInfo: { walletId: wallet.id, tokenId: null } + }) + + const { nativeAmount, tokenId: feeTokenId } = quote.networkFee + const feeDenom = getExchangeDenom(wallet.currencyConfig, feeTokenId) + const fee = div(nativeAmount, feeDenom.multiplier, log10(feeDenom.multiplier)) + const currencyCode = + wallet.currencyConfig.allTokens[tokenId]?.currencyCode ?? tokenId + + const answer = await showButtonsModal({ + title: lstrings.activate_wallet_token_scene_title, + message: sprintf( + lstrings.moneygram_activate_message_2s, + currencyCode, + `${fee} ${feeDenom.name}` + ), + buttons: { + activate: { label: lstrings.moneygram_activate_button }, + cancel: { label: lstrings.string_cancel_cap } + } + }) + if (answer !== 'activate') { + await quote.close() + return false + } + + // The engine resolves once the ledger holds the trustline: + try { + await quote.approve({ + metadata: { + name: lstrings.activate_wallet_token_transaction_name_category_generic, + category: `Expense:${lstrings.activate_wallet_token_transaction_name_category_generic}`, + notes: lstrings.activate_wallet_token_transaction_notes_generic + } + }) + } catch (error: unknown) { + await quote.close().catch(() => {}) + throw error + } + return true +} diff --git a/src/plugins/ramps/moneygram/moneygramTypes.ts b/src/plugins/ramps/moneygram/moneygramTypes.ts index 58969715f8a..6616f9d4dbf 100644 --- a/src/plugins/ramps/moneygram/moneygramTypes.ts +++ b/src/plugins/ramps/moneygram/moneygramTypes.ts @@ -15,8 +15,16 @@ export const asInitOptions = asObject({ homeDomain: asOptional(asString, 'testanchor.stellar.org') }) +/** One asset from the anchor's stellar.toml [[CURRENCIES]] list. */ +export interface AnchorCurrency { + code: string + /** Missing for the chain's own asset. */ + issuer?: string +} + /** The SEP-1 stellar.toml fields this client needs. */ export interface AnchorToml { + currencies: AnchorCurrency[] networkPassphrase: string signingKey: string transferServerSep24: string diff --git a/src/plugins/ramps/moneygram/sep1.ts b/src/plugins/ramps/moneygram/sep1.ts index 60bb00c73fd..8582b42fcb8 100644 --- a/src/plugins/ramps/moneygram/sep1.ts +++ b/src/plugins/ramps/moneygram/sep1.ts @@ -1,4 +1,7 @@ -import type { AnchorToml } from './moneygramTypes' +import URL from 'url-parse' + +import type { AnchorCurrency, AnchorToml } from './moneygramTypes' +import { isHomeDomainHost } from './sep24Api' /** * Reads the top-level string keys from a stellar.toml file. @@ -7,19 +10,52 @@ import type { AnchorToml } from './moneygramTypes' */ export function parseStellarToml(text: string): Record { const out: Record = {} - for (const rawLine of text.split(/\r?\n/)) { - const line = rawLine.trim() - if (line === '' || line.startsWith('#')) continue + for (const line of readTomlLines(text)) { if (line.startsWith('[')) break + addTomlKey(out, line) + } + return out +} + +/** + * Reads the code and issuer of each [[CURRENCIES]] entry, which names the + * assets the anchor issues or pays out. + */ +export function parseTomlCurrencies(text: string): AnchorCurrency[] { + const tables: Array> = [] + let table: Record | undefined + for (const line of readTomlLines(text)) { + if (line.startsWith('[')) { + table = line === '[[CURRENCIES]]' ? {} : undefined + if (table != null) tables.push(table) + continue + } + if (table != null) addTomlKey(table, line) + } - const match = /^([A-Za-z0-9_]+)\s*=\s*(.*)$/.exec(line) - if (match == null) continue - const value = readTomlString(match[2]) - if (value != null) out[match[1]] = value + const out: AnchorCurrency[] = [] + for (const { code, issuer } of tables) { + if (code != null) out.push({ code, issuer }) } return out } +/** The trimmed lines of a TOML file, without blanks or comments. */ +function readTomlLines(text: string): string[] { + return text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line !== '' && !line.startsWith('#')) +} + +/** Adds a `KEY = "value"` line to the table, if it holds a string. */ +function addTomlKey(table: Record, line: string): void { + const match = /^([A-Za-z0-9_]+)\s*=\s*(.*)$/.exec(line) + if (match == null) return + const value = readTomlString(match[2]) + if (value != null) table[match[1]] = value +} + /** Reads a quoted TOML string, ignoring any trailing comment. */ function readTomlString(text: string): string | undefined { const quote = text[0] @@ -32,6 +68,7 @@ function readTomlString(text: string): string | undefined { /** Picks and checks the SEP-10 / SEP-24 fields from parsed stellar.toml. */ export function asAnchorToml( fields: Record, + currencies: AnchorCurrency[], homeDomain: string ): AnchorToml { const pick = (key: string): string => { @@ -41,11 +78,25 @@ export function asAnchorToml( } return value } + // The login and transfer servers get the wallet's SEP-10 token and + // choose where withdrawals go, so they must sit on the home domain: + const pickEndpoint = (key: string): string => { + const value = pick(key) + const { hostname, protocol } = new URL(value) + if (protocol !== 'https:' || !isHomeDomainHost(hostname, homeDomain)) { + throw new Error(`${homeDomain} stellar.toml ${key} is not on the domain`) + } + return value + } return { + currencies, networkPassphrase: pick('NETWORK_PASSPHRASE'), signingKey: pick('SIGNING_KEY'), - transferServerSep24: pick('TRANSFER_SERVER_SEP0024').replace(/\/+$/, ''), - webAuthEndpoint: pick('WEB_AUTH_ENDPOINT') + transferServerSep24: pickEndpoint('TRANSFER_SERVER_SEP0024').replace( + /\/+$/, + '' + ), + webAuthEndpoint: pickEndpoint('WEB_AUTH_ENDPOINT') } } @@ -56,5 +107,10 @@ export async function fetchAnchorToml(homeDomain: string): Promise { `${homeDomain} stellar.toml request failed: ${response.status}` ) } - return asAnchorToml(parseStellarToml(await response.text()), homeDomain) + const text = await response.text() + return asAnchorToml( + parseStellarToml(text), + parseTomlCurrencies(text), + homeDomain + ) }