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 new file mode 100644 index 00000000000..c6d407d0f54 --- /dev/null +++ b/src/__tests__/plugins/ramps/moneygram.test.ts @@ -0,0 +1,963 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals' +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 { + findSentPayment, + hasPaymentInstructions, + toEdgeMemo, + type TransferContext, + watchTransfer +} from '../../../plugins/ramps/moneygram/moneygramTransfer' +import { activateTokenForDeposit } from '../../../plugins/ramps/moneygram/moneygramTrustline' +import type { + AnchorEndpoints, + MoneygramTransfer, + Sep24Transaction +} from '../../../plugins/ramps/moneygram/moneygramTypes' +import { + asAnchorToml, + parseStellarToml, + parseTomlCurrencies +} from '../../../plugins/ramps/moneygram/sep1' +import { fetchWithSep10 } from '../../../plugins/ramps/moneygram/sep10' +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'), + showError: jest.fn(), + 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', + signingKey: 'GCHLHDBOKG2JWMJQBTLSL5XG6NO7ESXI2TAQKZXCXWXB5WI2X6W233PR', + transferServerSep24: 'https://testanchor.stellar.org/sep24', + 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 +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" + +[[CURRENCIES]] +code = "USDC" +issuer = "${USDC_ISSUER}" # Circle test USDC +status = "test" + +[[CURRENCIES]] +code = "native" +` + +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 } +} + +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) + 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('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), + 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, + webAuthEndpoint: ANCHOR.webAuthEndpoint + }) + }) + + 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( + '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) + } + }) +}) + +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 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 } + + 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 }, + USDC: { enabled: true, min_amount: 1, max_amount: 10 } + }, + withdraw: { + native: { enabled: withdrawEnabled }, + USDC: { enabled: withdrawEnabled } + } + } + }) + } + return response({ status: 404, json: {} }) + }) as unknown as typeof fetch + + return moneygramRampPlugin({ + initOptions: {}, + store: {} as unknown as RampPluginStore, + account: { currencyConfig: { stellartestnet: { allTokens: TOKENS } } }, + 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('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: + 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 }) + }) +}) + +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/__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/__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/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 8aad5aac860..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' @@ -49,6 +55,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 } @@ -94,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/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..6a9821d8b18 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', @@ -2005,6 +2006,18 @@ 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.', + 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 2263dcb9d22..8a94ed71052 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", @@ -1547,6 +1548,14 @@ "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.", + "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..dad305f4d57 --- /dev/null +++ b/src/plugins/ramps/moneygram/moneygramRampPlugin.ts @@ -0,0 +1,382 @@ +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' +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 { activateTokenForDeposit } from './moneygramTrustline' +import { + type AnchorCurrency, + 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' + +// 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' } +} + +// 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 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: EdgeTokenId, + direction: 'buy' | 'sell' + ): 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[asset.code] : info.withdraw[asset.code] + if (!assetInfo?.enabled) return + return { asset, assetInfo } + } + + const checkSupport = async ( + request: RampCheckSupportRequest + ): Promise => { + const { cryptoAsset, direction } = request + try { + const asset = await getAnchorAsset( + cryptoAsset.pluginId, + cryptoAsset.tokenId, + direction + ) + 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'] } + } 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 anchorAsset = await getAnchorAsset( + wallet.currencyInfo.pluginId, + tokenId, + direction + ) + if (anchorAsset == null) return [] + const { asset, assetInfo } = anchorAsset + const { code: assetCode, issuer: assetIssuer } = asset + + const cryptoAmount = pickAmount(amountQuery, assetInfo) + checkLimits(cryptoAmount, assetInfo, displayCurrencyCode) + const fiatCurrencyCode = removeIsoPrefix(request.fiatCurrencyCode) + + const approveQuote = async ( + 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, + assetIssuer, + account: await getStellarAccount(coreWallet), + amount: cryptoAmount + }) + + const transfer: MoneygramTransfer = { + id, + direction, + walletId: coreWallet.id, + tokenId, + assetCode, + 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 +} + +/** + * 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+$/, '') + +/** 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 ?? '' + ) + ) + ) +} 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 new file mode 100644 index 00000000000..6616f9d4dbf --- /dev/null +++ b/src/plugins/ramps/moneygram/moneygramTypes.ts @@ -0,0 +1,120 @@ +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') +}) + +/** 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 + 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..8582b42fcb8 --- /dev/null +++ b/src/plugins/ramps/moneygram/sep1.ts @@ -0,0 +1,116 @@ +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. + * 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 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 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] + 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, + currencies: AnchorCurrency[], + 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 + } + // 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: pickEndpoint('TRANSFER_SERVER_SEP0024').replace( + /\/+$/, + '' + ), + webAuthEndpoint: pickEndpoint('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}` + ) + } + const text = await response.text() + return asAnchorToml( + parseStellarToml(text), + parseTomlCurrencies(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) +} 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, 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' + ) +}