diff --git a/CHANGELOG.md b/CHANGELOG.md index 769c99a972b..8423a711096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased (develop) - added: App/device attestation for gated info-server requests +- added: CTX spend-api prototype, covering the anonymous pubkey session and a gift card purchase paid from an Edge wallet, both driven from the gift card account info scene - added: "-m" tag on the version number in the Help scene for Maestro test builds - changed: Target Android 16 (API level 36), which Google Play requires for app updates submitted after Aug 30, 2026. Predictive back is opted out of for now, since React Native 0.79 cannot handle it, so the back button behaves exactly as it did before. - changed: Style the entire "Already have an account? Sign in" line in the getting-started USP carousel with the tertiary link color, not just "Sign in". diff --git a/src/__tests__/ctxSpendAuth.test.ts b/src/__tests__/ctxSpendAuth.test.ts new file mode 100644 index 00000000000..13f26e4430c --- /dev/null +++ b/src/__tests__/ctxSpendAuth.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, jest, test } from '@jest/globals' +import type { EdgeAccount } from 'edge-core-js' + +// `ctxSpendAuth` reaches for the platform CSPRNG and a native uuid; both are +// React Native modules, so they are stubbed to keep this runnable in Node. +let mockRandomCallCount = 0 +jest.mock('react-native-securerandom', () => ({ + generateSecureRandom: async (length: number) => { + mockRandomCallCount += 1 + // A valid, distinct secp256k1 scalar per call. + const bytes = new Uint8Array(length) + bytes[length - 1] = mockRandomCallCount + return bytes + } +})) +jest.mock('../util/rnUtils', () => ({ + makeUuid: async () => `uuid-${mockRandomCallCount}` +})) + +const { makeCtxSpendSession } = require('../plugins/gift-cards/ctxSpendAuth') + +/** Minimal account whose dataStore records what was written. */ +const makeAccount = (): { + account: EdgeAccount + items: Map +} => { + const items = new Map() + const account = { + username: 'test-user', + dataStore: { + listItemIds: async () => [...items.keys()], + getItem: async (_storeId: string, itemId: string) => { + const text = items.get(itemId) + if (text == null) throw new Error('missing') + return text + }, + setItem: async (_storeId: string, itemId: string, text: string) => { + items.set(itemId, text) + } + } + } as unknown as EdgeAccount + return { account, items } +} + +describe('ensureIdentity', () => { + test('concurrent callers share one keypair instead of racing', async () => { + // The Connect and Buy buttons can both call this on a first run. Two + // keypairs would strand whichever CTX user lost, with no recovery. + const { account, items } = makeAccount() + const session = makeCtxSpendSession({ + clientId: 'edge', + baseUrl: 'https://staging.spend.ctx.com' + }) + + const results = await Promise.all([ + session.ensureIdentity(account), + session.ensureIdentity(account), + session.ensureIdentity(account) + ]) + + expect(results).toEqual(['ready', 'ready', 'ready']) + expect(items.size).toBe(1) + expect(session.getPublicKeyHex()).toBeDefined() + }) + + test('a second call reuses the stored identity rather than making another', async () => { + const { account, items } = makeAccount() + const session = makeCtxSpendSession({ + clientId: 'edge', + baseUrl: 'https://staging.spend.ctx.com' + }) + + expect(await session.ensureIdentity(account)).toBe('ready') + const firstKey = session.getPublicKeyHex() + + // A fresh session over the same store recovers the same user. + const session2 = makeCtxSpendSession({ + clientId: 'edge', + baseUrl: 'https://staging.spend.ctx.com' + }) + expect(await session2.ensureIdentity(account)).toBe('ready') + + expect(session2.getPublicKeyHex()).toBe(firstKey) + expect(items.size).toBe(1) + }) + + test('a light account gets no identity and no stored key', async () => { + const { account, items } = makeAccount() + const lightAccount = { + ...account, + username: null + } as unknown as EdgeAccount + const session = makeCtxSpendSession({ + clientId: 'edge', + baseUrl: 'https://staging.spend.ctx.com' + }) + + expect(await session.ensureIdentity(lightAccount)).toBe('light-account') + expect(items.size).toBe(0) + }) +}) diff --git a/src/__tests__/ctxSpendCrypto.test.ts b/src/__tests__/ctxSpendCrypto.test.ts new file mode 100644 index 00000000000..6f86826c10e --- /dev/null +++ b/src/__tests__/ctxSpendCrypto.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from '@jest/globals' + +import { + bytesToHex, + getJwtExpiryMs, + getPublicKeyHex, + hexToBytes, + isValidPrivateKey, + makeLoginNonceHash, + recoverLoginPublicKeyHex, + signLoginNonce, + uint64BE +} from '../plugins/gift-cards/ctxSpendCrypto' + +// A fixed key, so every expectation below is a reproducible vector rather than +// a property of whatever key the run happened to draw. +const PRIVATE_KEY_HEX = + '0000000000000000000000000000000000000000000000000000000000000001' +const privateKey = hexToBytes(PRIVATE_KEY_HEX) + +describe('uint64BE', () => { + test('encodes big-endian across byte boundaries', () => { + expect(bytesToHex(uint64BE(0))).toBe('0000000000000000') + expect(bytesToHex(uint64BE(1))).toBe('0000000000000001') + expect(bytesToHex(uint64BE(255))).toBe('00000000000000ff') + expect(bytesToHex(uint64BE(256))).toBe('0000000000000100') + expect(bytesToHex(uint64BE(4294967296))).toBe('0000000100000000') + // Number.MAX_SAFE_INTEGER, the top of the exact-integer range. + expect(bytesToHex(uint64BE(9007199254740991))).toBe('001fffffffffffff') + }) + + test('rejects values that cannot be represented exactly', () => { + expect(() => uint64BE(-1)).toThrow() + expect(() => uint64BE(1.5)).toThrow() + expect(() => uint64BE(Number.MAX_SAFE_INTEGER + 2)).toThrow() + }) +}) + +describe('makeLoginNonceHash', () => { + test('matches sha256 of the big-endian nonce', () => { + // sha256(0000000000000002), independently computable from the spec. + expect(bytesToHex(makeLoginNonceHash(2))).toBe( + 'cd04a4754498e06db5a13c5f371f1f04ff6d2470f24aa9bd886540e5dce77f70' + ) + }) +}) + +describe('signLoginNonce', () => { + test('produces the 65-byte recoverable encoding the server expects', () => { + const signature = hexToBytes(signLoginNonce(privateKey, 2)) + expect(signature.length).toBe(65) + // [27 + recoveryId + 4], where +4 marks a compressed public key. + expect(signature[0]).toBeGreaterThanOrEqual(31) + expect(signature[0]).toBeLessThanOrEqual(34) + }) + + test('is deterministic for a given key and nonce', () => { + expect(signLoginNonce(privateKey, 2)).toBe(signLoginNonce(privateKey, 2)) + }) + + test('signs a different nonce differently', () => { + expect(signLoginNonce(privateKey, 2)).not.toBe( + signLoginNonce(privateKey, 3) + ) + }) + + test('recovers the signing public key, which is how the server authenticates', () => { + const publicKeyHex = getPublicKeyHex(privateKey) + for (const nonce of [1, 2, 42, 65536]) { + const signature = signLoginNonce(privateKey, nonce) + expect(recoverLoginPublicKeyHex(signature, nonce)).toBe(publicKeyHex) + } + }) + + test('does not recover the signing key against the wrong nonce', () => { + const signature = signLoginNonce(privateKey, 2) + expect(recoverLoginPublicKeyHex(signature, 3)).not.toBe( + getPublicKeyHex(privateKey) + ) + }) +}) + +describe('recoverLoginPublicKeyHex', () => { + test('rejects a signature of the wrong length', () => { + expect(() => recoverLoginPublicKeyHex('00'.repeat(64), 1)).toThrow() + }) + + test('rejects an out-of-range header byte', () => { + const signature = hexToBytes(signLoginNonce(privateKey, 2)) + signature[0] = 99 + expect(() => recoverLoginPublicKeyHex(bytesToHex(signature), 2)).toThrow() + }) +}) + +describe('getJwtExpiryMs', () => { + test('reads exp out of an unpadded base64url payload', () => { + // {"exp":1786421416} — the base64url payload is unpadded, as JWTs are. + const token = `header.eyJleHAiOjE3ODY0MjE0MTZ9.signature` + expect(getJwtExpiryMs(token)).toBe(1786421416000) + }) + + test('returns undefined for malformed tokens, so they read as expired', () => { + expect(getJwtExpiryMs('not-a-jwt')).toBeUndefined() + expect(getJwtExpiryMs('a.b.c')).toBeUndefined() + // Valid base64url JSON, but no exp claim. + expect( + getJwtExpiryMs('header.eyJmb28iOiJiYXIifQ.signature') + ).toBeUndefined() + }) +}) + +describe('isValidPrivateKey', () => { + test('accepts a valid scalar and rejects degenerate ones', () => { + expect(isValidPrivateKey(privateKey)).toBe(true) + expect(isValidPrivateKey(new Uint8Array(32))).toBe(false) + expect(isValidPrivateKey(new Uint8Array(31))).toBe(false) + }) +}) diff --git a/src/__tests__/ctxSpendPurchase.test.ts b/src/__tests__/ctxSpendPurchase.test.ts new file mode 100644 index 00000000000..c1dc0e5f627 --- /dev/null +++ b/src/__tests__/ctxSpendPurchase.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from '@jest/globals' +import type { EdgeCurrencyWallet } from 'edge-core-js' + +import { + getCtxPaymentNativeAmount, + getCtxPaymentPluginId, + isCtxGiftCardPaid +} from '../plugins/gift-cards/ctxSpendPurchase' +import type { CtxSpendGiftCard } from '../plugins/gift-cards/ctxSpendTypes' + +// A card as `POST /gift-cards` actually returns it, trimmed to the fields +// these helpers read. +const makeCard = ( + overrides: Partial = {} +): CtxSpendGiftCard => { + const card: CtxSpendGiftCard = { + id: '2adf8f1c-4885-44e4-a17b-8ae35b9a1bc5', + merchantId: '7c8bf315-703f-4b6c-972d-574411c059e9', + merchantName: 'Amazon', + cardFiatAmount: '0.01', + cardFiatCurrency: 'USD', + paymentId: '99df9dd3-985f-4fa2-a82e-6074222f92cd', + paymentMethod: 'crypto', + paymentCryptoAddress: '0x67587B625a63a1E692eb7D73Dc11d57Ff3597406', + paymentCryptoAmount: '0.000005200000000000', + paymentCryptoChain: 'ETH', + paymentCryptoCurrency: 'ETH', + paymentCryptoNetwork: 'testnet', + paymentUrls: {}, + rate: '1923.0769', + status: 'unpaid', + displayStatus: 'unpaid', + paymentStatus: 'unpaid', + fulfilmentStatus: 'pending', + created: '2026-08-17T19:53:14Z', + updated: '2026-08-17T19:53:14Z' + } + return { ...card, ...overrides } +} + +const makeWallet = (multiplier: string): EdgeCurrencyWallet => + ({ + currencyInfo: { denominations: [{ multiplier }] } + } as unknown as EdgeCurrencyWallet) + +describe('getCtxPaymentPluginId', () => { + test('maps a testnet ETH quote to sepolia, the only testnet Edge carries', () => { + expect(getCtxPaymentPluginId(makeCard())).toBe('sepolia') + }) + + test('maps a mainnet ETH quote to ethereum', () => { + expect( + getCtxPaymentPluginId(makeCard({ paymentCryptoNetwork: 'mainnet' })) + ).toBe('ethereum') + }) + + test('returns undefined for chains Edge has no wallet type for', () => { + // Staging quotes these happily; the app still cannot pay them. + for (const chain of ['XMR', 'ZEC', 'ZANO', 'DASH', 'XLM', 'LTC', 'BCH']) { + expect( + getCtxPaymentPluginId(makeCard({ paymentCryptoChain: chain })) + ).toBeUndefined() + } + }) + + test('returns undefined when the quote names no chain or network', () => { + expect( + getCtxPaymentPluginId(makeCard({ paymentCryptoChain: undefined })) + ).toBeUndefined() + expect( + getCtxPaymentPluginId(makeCard({ paymentCryptoNetwork: undefined })) + ).toBeUndefined() + }) +}) + +describe('getCtxPaymentNativeAmount', () => { + test('converts the quote to wei exactly', () => { + // The payment URI for this same card carries value=5200000000000. + expect( + getCtxPaymentNativeAmount(makeCard(), makeWallet('1000000000000000000')) + ).toBe('5200000000000') + }) + + test('never rounds down, since an underpayment leaves the card unpaid', () => { + // A quote finer than the chain's smallest unit must round up. + const card = makeCard({ paymentCryptoAmount: '0.000000015' }) + expect(getCtxPaymentNativeAmount(card, makeWallet('100000000'))).toBe('2') + }) + + test('throws when the card carries no payment amount', () => { + expect(() => + getCtxPaymentNativeAmount( + makeCard({ paymentCryptoAmount: undefined }), + makeWallet('1000000000000000000') + ) + ).toThrow() + }) +}) + +describe('isCtxGiftCardPaid', () => { + test('a fresh order is not paid', () => { + expect(isCtxGiftCardPaid(makeCard())).toBe(false) + expect(isCtxGiftCardPaid(makeCard({ paymentStatus: 'pending' }))).toBe( + false + ) + }) + + test('recognises the paid state staging actually reports', () => { + // Observed live: `unpaid` becomes `paid` once the send confirms. + expect(isCtxGiftCardPaid(makeCard({ paymentStatus: 'paid' }))).toBe(true) + }) + + test('an in-progress fulfilment does not make it paid', () => { + // `fulfilmentStatus` runs on its own track (`pending`, then `ordered`) + // and says nothing about whether the payment landed. + expect(isCtxGiftCardPaid(makeCard({ fulfilmentStatus: 'ordered' }))).toBe( + false + ) + }) +}) diff --git a/src/components/scenes/GiftCardAccountInfoScene.tsx b/src/components/scenes/GiftCardAccountInfoScene.tsx index 439598f84f7..ae3c5b8b74f 100644 --- a/src/components/scenes/GiftCardAccountInfoScene.tsx +++ b/src/components/scenes/GiftCardAccountInfoScene.tsx @@ -2,11 +2,24 @@ import Clipboard from '@react-native-clipboard/clipboard' import { useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { View } from 'react-native' +import { sprintf } from 'sprintf-js' import { ENV } from '../../env' import { useGiftCardProvider } from '../../hooks/useGiftCardProvider' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' +import { makeCtxSpendApi } from '../../plugins/gift-cards/ctxSpendApi' +import { + findWalletByPluginId, + getCtxPaymentNativeAmount, + getCtxPaymentPluginId, + isCtxGiftCardPaid +} from '../../plugins/gift-cards/ctxSpendPurchase' +import type { + CtxSpendAuthContext, + CtxSpendGiftCard +} from '../../plugins/gift-cards/ctxSpendTypes' +import { config } from '../../theme/appConfig' import { useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps } from '../../types/routerTypes' import { SceneButtons } from '../buttons/SceneButtons' @@ -22,6 +35,29 @@ export interface GiftCardAccountInfoParams { quoteId?: string } +/** + * Outcome of a CTX spend-api session attempt. `isSupported: false` means the + * account cannot hold a signing key, which is a different thing from an error. + */ +type CtxSpendStatus = + | { isSupported: false } + | { + isSupported: true + publicKeyHex: string | undefined + authContext: CtxSpendAuthContext + merchantCount: number + } + +/** + * The card this prototype orders. CTX's staging catalogue only lets Amazon go + * below a dollar, and every staging quote is testnet, where ETH is the one + * chain Edge carries a wallet for. + */ +const CTX_TEST_MERCHANT_ID = '7c8bf315-703f-4b6c-972d-574411c059e9' +const CTX_TEST_FIAT_AMOUNT = '0.01' +const CTX_TEST_FIAT_CURRENCY = 'USD' +const CTX_TEST_CRYPTO_CURRENCY = 'ETH' + /** * Displays Phaze gift card account credentials behind a confirmation wall. * Accessible from the kebab menu (with quoteId context) or developer settings. @@ -29,13 +65,19 @@ export interface GiftCardAccountInfoParams { export const GiftCardAccountInfoScene: React.FC< EdgeAppSceneProps<'giftCardAccountInfo'> > = props => { - const { route } = props + const { navigation, route } = props const { quoteId } = route.params const theme = useTheme() const styles = getStyles(theme) const account = useSelector(state => state.core.account) const queryClient = useQueryClient() + // This scene is NOT developer-only: GiftCardListScene routes here from "Get + // Help" on a failed Phaze order, so production users reach it. The CTX + // prototype is gated separately rather than riding that reachability. + const developerModeOn = useSelector( + state => state.ui.settings.developerModeOn + ) // Provider for identity lookup const phazeConfig = (ENV.PLUGIN_API_KEYS as Record) @@ -61,6 +103,192 @@ export const GiftCardAccountInfoScene: React.FC< if (error != null) showError(error) }, [error]) + // --------------------------------------------------------------------------- + // CTX Spend prototype + // --------------------------------------------------------------------------- + + const ctxSpendConfig = developerModeOn + ? ENV.PLUGIN_API_KEYS?.ctxSpend + : undefined + const [isCtxRequested, setIsCtxRequested] = React.useState(false) + const [ctxCardId, setCtxCardId] = React.useState() + const [isCtxBuying, setIsCtxBuying] = React.useState(false) + + // One api instance for both the readout and the purchase, so they share a + // session instead of each running its own login handshake. + const ctxApi = React.useMemo( + () => + ctxSpendConfig == null + ? undefined + : makeCtxSpendApi({ + clientId: ctxSpendConfig.clientId, + baseUrl: ctxSpendConfig.baseUrl + }), + [ctxSpendConfig] + ) + + const { + data: ctxStatus, + error: ctxError, + isFetching: isCtxFetching, + refetch: refetchCtxStatus + } = useQuery({ + queryKey: ['ctxSpendStatus', account.id], + queryFn: async (): Promise => { + if (ctxApi == null) throw new Error('CTX Spend is not configured') + const api = ctxApi + // A light account has nowhere to persist the signing key, so the + // identity step is what gates the feature, not the network. Anything + // else that goes wrong throws and surfaces through the query error. + if ((await api.ensureIdentity(account)) === 'light-account') { + return { isSupported: false } + } + + const authContext = await api.getMe() + const merchants = await api.getMerchants() + return { + isSupported: true, + publicKeyHex: api.getPublicKeyHex(), + authContext, + merchantCount: merchants.pagination.total + } + }, + enabled: isCtxRequested && ctxApi != null, + staleTime: 60000, + // The app-wide default is `retry: 2`, which would turn one failed connect + // into three full login handshakes against a rate-limited API. Retrying is + // the Connect button's job, where the user decides when. + retry: false + }) + + React.useEffect(() => { + if (ctxError != null) showError(ctxError) + }, [ctxError]) + + // Poll the ordered card until CTX credits the payment. The address is funded + // by a real on-chain send, so this spans block confirmation. + const { data: ctxCard } = useQuery({ + queryKey: ['ctxSpendCard', ctxCardId], + queryFn: async (): Promise => { + if (ctxApi == null || ctxCardId == null) { + throw new Error('CTX Spend is not configured') + } + return await ctxApi.getGiftCard(ctxCardId) + }, + enabled: ctxCardId != null && ctxApi != null, + // Stop once CTX credits the payment. Fulfilment past that point is the + // merchant's, runs for far longer than a session, and polling it here + // would hammer a rate-limited API for a value nothing acts on. + refetchInterval: query => { + const card = query.state.data + return card != null && isCtxGiftCardPaid(card) ? false : 5000 + }, + retry: false + }) + + const buyCtxGiftCard = async (): Promise => { + if (ctxApi == null) return + if ((await ctxApi.ensureIdentity(account)) === 'light-account') { + showError(new Error(lstrings.ctx_spend_unavailable_light_account)) + return + } + + const giftCard = await ctxApi.createGiftCard({ + merchantId: CTX_TEST_MERCHANT_ID, + fiatAmount: CTX_TEST_FIAT_AMOUNT, + fiatCurrency: CTX_TEST_FIAT_CURRENCY, + cryptoCurrency: CTX_TEST_CRYPTO_CURRENCY + }) + const { paymentCryptoAddress: address } = giftCard + if (address == null || address === '') { + throw new Error(lstrings.ctx_spend_no_payment_address) + } + const pluginId = getCtxPaymentPluginId(giftCard) + if (pluginId == null) { + throw new Error( + sprintf( + lstrings.ctx_spend_unsupported_payment_3s, + config.appName, + giftCard.paymentCryptoChain ?? '', + giftCard.paymentCryptoNetwork ?? '' + ) + ) + } + const wallet = findWalletByPluginId(account, pluginId) + if (wallet == null) { + throw new Error(sprintf(lstrings.ctx_spend_no_wallet_1s, pluginId)) + } + + const nativeAmount = getCtxPaymentNativeAmount(giftCard, wallet) + + // Track the card only now that it is payable. Doing it at creation time + // would start the poll for an order the app cannot pay, leaving it + // orphaned and polling a rate-limited API every 5s for nothing. + setCtxCardId(giftCard.id) + + navigation.navigate('send2', { + walletId: wallet.id, + tokenId: null, + spendInfo: { + tokenId: null, + spendTargets: [{ publicAddress: address, nativeAmount }], + metadata: { + name: giftCard.merchantName, + notes: `CTX Spend gift card ${giftCard.cardFiatAmount} ${giftCard.cardFiatCurrency}\nCard ID: ${giftCard.id}` + } + }, + lockTilesMap: { address: true, amount: true, wallet: true }, + hiddenFeaturesMap: { address: true, fioAddressSelect: true }, + infoTiles: [ + { + label: lstrings.ctx_spend_card_merchant, + value: giftCard.merchantName + }, + { + label: lstrings.ctx_spend_card_face_value, + value: `${giftCard.cardFiatAmount} ${giftCard.cardFiatCurrency}` + }, + { + label: lstrings.ctx_spend_card_network, + value: `${giftCard.paymentCryptoChain ?? ''} ${ + giftCard.paymentCryptoNetwork ?? '' + }` + } + ], + // Supplying `onDone` at all is what keeps the send scene from replacing + // itself with the transaction details scene: it pops back here instead, + // where the poll above shows the card being fulfilled. The pop is the + // send scene's own, so there is nothing to do here. + onDone: () => {} + }) + } + + const handleCtxBuy = useHandler(() => { + if (isCtxBuying) return + setIsCtxBuying(true) + buyCtxGiftCard() + .catch((err: unknown) => { + showError(err) + }) + .finally(() => { + setIsCtxBuying(false) + }) + }) + + const handleCtxConnect = useHandler(() => { + // The button is disabled while fetching, so this cannot stack sessions. + if (isCtxFetching) return + // Already requested means a previous attempt resolved or failed, and + // flipping the flag again would not re-run the query, so retry explicitly. + if (isCtxRequested) { + refetchCtxStatus().catch((err: unknown) => { + showError(err) + }) + return + } + setIsCtxRequested(true) + }) + const handleReveal = useHandler(async () => { const confirmed = await Airship.show(bridge => ( + {lstrings.gift_card_account_info_body} @@ -129,6 +357,16 @@ export const GiftCardAccountInfoScene: React.FC< )} + {developerModeOn && ( + + )} + + {developerModeOn && } + ) } +interface CtxSpendCardSectionProps { + card: CtxSpendGiftCard | undefined +} + +/** + * Live state of the ordered card: what to pay, and how far CTX has got with + * the payment and the fulfilment. + */ +const CtxSpendCardSection: React.FC = props => { + const { card } = props + if (card == null) return null + + return ( + + + + + + + + + + ) +} + +interface CtxSpendSectionProps { + isConfigured: boolean + isFetching: boolean + status: CtxSpendStatus | undefined +} + +/** + * Prototype readout for the CTX spend-api pubkey session: proves the app can + * establish an anonymous keypair identity and read authenticated data. + */ +const CtxSpendSection: React.FC = props => { + const { isConfigured, isFetching, status } = props + + if (!isConfigured) { + return {lstrings.ctx_spend_not_configured} + } + if (status == null) { + return isFetching ? ( + {lstrings.ctx_spend_connecting} + ) : null + } + if (!status.isSupported) { + return {lstrings.ctx_spend_unavailable_light_account} + } + + const { authContext, merchantCount, publicKeyHex } = status + return ( + + + {publicKeyHex != null && ( + + )} + + + + + + + ) +} + const getStyles = cacheStyles((theme: Theme) => ({ container: { padding: theme.rem(0.5) diff --git a/src/docs/ctx-spend-pubkey-auth.md b/src/docs/ctx-spend-pubkey-auth.md new file mode 100644 index 00000000000..7f2dddcbbf3 --- /dev/null +++ b/src/docs/ctx-spend-pubkey-auth.md @@ -0,0 +1,433 @@ +# CTX spend-api pubkey auth: an anonymous keypair identity for EdgeSpend + +| | | +|---|---| +| Status | Implemented (prototype) | +| Author | Jon Tzeng | +| Reviewer | - | +| Last updated | 2026-08-10 | +| Repos | [edge-react-gui](https://github.com/EdgeApp/edge-react-gui) | +| Implementation | [EdgeApp/edge-react-gui#6147](https://github.com/EdgeApp/edge-react-gui/pull/6147) | +| Supersedes | - | +| Related | [CTX-com/spend-api-pubkey-auth-demo](https://github.com/CTX-com/spend-api-pubkey-auth-demo) | + +CTX published a reference script for an anonymous [secp256k1](#secp256k1) register/login protocol against their spend-api. This document describes the client built from it in `edge-react-gui`, verified against `https://staging.spend.ctx.com` with the registered client id `edge`. + +## Contents + +1. [Problem](#1-problem) +2. [Prior art: the Phaze identity model](#2-prior-art-the-phaze-identity-model) +3. [Goals and non-goals](#3-goals-and-non-goals) +4. [Design overview](#4-design-overview) +5. [Detailed design: edge-react-gui](#5-detailed-design-edge-react-gui) +6. [Testing](#6-testing) +7. [Phase history](#7-phase-history) +8. [Decisions](#8-decisions) +9. [Glossary](#9-glossary) +10. [References](#10-references) +11. [Post-implementation retrospective](#11-post-implementation-retrospective) + +## 1. Problem + +EdgeSpend buys gift cards. Today that runs through one provider, Phaze, whose users are identified by a synthetic email address and a `userApiKey` the server issues at registration. [CTX](#ctx) offers a competing gift card API whose authentication works nothing like that: there is no email, no password, and no server-issued user secret. The client generates a [secp256k1](#secp256k1) keypair, registers the public key, and proves ownership by signing a server-issued [nonce](#nonce). + +CTX shipped that flow as a single-file reference script. The script is explicit that it is documentation rather than a client library, and it discards its keypair on exit. Turning it into something the app can use means answering the questions the script deliberately leaves open: where the key lives, how it survives a relaunch, and what happens when a token expires. + +The narrower problem this prototype answers is whether the protocol works from the app at all, and what a real client has to add on top of the reference script. + +## 2. Prior art: the Phaze identity model + +The existing gift card plugin already solves "give this Edge account a stable identity with a third-party provider", so the [CTX](#ctx) client follows its shape rather than inventing one. + +Phaze registers a user under a generated address, `@edge.app`, and stores the returned `userApiKey` in `account.dataStore` under the `phaze-prod` namespace, one item per identity keyed `identity-`. The per-identity key matters: two devices on the same Edge account can register concurrently without either clobbering the other's record. + +Two properties of that model do not carry over. Phaze's durable secret is issued by the server, so a lost local copy can be recovered by re-registering the same email. CTX's is generated by the client and never leaves it, so losing it loses the user outright. And Phaze deliberately supports identity rotation, exposed in the app as a way to burn an identity whose credentials were shown on screen. Rotation has no analogue here yet, because nothing in this prototype displays anything an attacker could redeem. + +What does carry over is the storage layout, the light-account gate, and the "one item per identity" write discipline. All three are reused. + +## 3. Goals and non-goals + +Goals: + +- Implement the register/login protocol so the wire format matches what the server verifies. +- Persist the keypair so the same anonymous [CTX](#ctx) user is recovered across launches and across devices synced to the same Edge account. +- Handle the access/refresh token lifecycle without the caller thinking about it. +- Read authenticated data from the app, proving the protocol end to end rather than in a script. +- Order a gift card and pay for it from an Edge wallet, so the purchase path is proven against a real on-chain payment rather than described. +- Keep the protocol crypto testable without a simulator or a network. + +Non-goals: + +- Replacing Phaze. The CTX client is added alongside it; the shipping purchase flow is untouched. +- Redeeming a card. The client orders and pays; reading back a barcode or code is not wired up. +- A CTX merchant-browsing UI. The prototype orders one fixed card from a developer surface rather than duplicating the Phaze market scenes for a second provider. +- A provider abstraction over both APIs. Phaze and CTX have incompatible identity models, and this prototype is the first implementation of the second one, so the shape of the right interface is not yet knowable. Guessing it now would be a rewrite later. +- Identity rotation, per the reasoning in [section 2](#2-prior-art-the-phaze-identity-model). + +## 4. Design overview + +The whole deliverable lands in one repo. + +| Repo | Deliverable | Scope | +|---|---|---| +| edge-react-gui | The [CTX](#ctx) client and its in-app readout | [Section 5](#5-detailed-design-edge-react-gui) | + +Four modules, split along one line: what needs React Native and what does not. + +```mermaid +flowchart TD + Scene[GiftCardAccountInfoScene] --> Api[ctxSpendApi] + Api --> Session[ctxSpendAuth] + Session --> Crypto[ctxSpendCrypto] + Session --> Store[(account.dataStore
encrypted)] + Session --> Rng[generateSecureRandom] + Api --> Server[(spend-api)] + Session --> Server + Crypto -.->|no RN imports
unit tested in Node| Crypto +``` + +`ctxSpendCrypto` holds the protocol: [nonce](#nonce) hashing, signature encoding, [public key recovery](#public-key-recovery), and [JWT](#jwt) expiry parsing. It imports nothing from React Native, so jest exercises it directly. `ctxSpendAuth` owns the parts that cannot run in Node: entropy from the platform [CSPRNG](#csprng) and the encrypted dataStore. `ctxSpendApi` is the HTTP surface. The scene is the prototype's readout. + +The login handshake: + +```mermaid +sequenceDiagram + participant Client as Edge app + participant Server as spend-api + Client->>Client: generate secp256k1 keypair (first run only) + Client->>Server: POST /login {key, scheme} + Server-->>Client: {nonce} + Client->>Client: sig = sign(sha256(uint64BE(nonce + 1))) + Client->>Server: POST /login {nonce: nonce + 1, sig} + Server->>Server: recover pubkey from sig, match registered key + Server-->>Client: {accessToken, refreshToken} + Client->>Server: GET /me (Bearer accessToken) + Server-->>Client: user, company, permissions +``` + +The client signs `nonce + 1` rather than the nonce it was handed. Both legs are the same endpoint, told apart by which fields the body carries. + +## 5. Detailed design: edge-react-gui + +### 5.1 Protocol crypto + +The digest is `sha256` of the [nonce](#nonce) encoded as a big-endian uint64. The signature is 65 bytes: a header byte, then R and S. The header is `27 + recoveryId + 4`, the format [btcec](#btcec)'s `RecoverCompact` reads, where the `+4` marks a compressed public key. + +[`src/plugins/gift-cards/ctxSpendCrypto.ts`](https://github.com/EdgeApp/edge-react-gui/blob/ae4641733cc567735f88c655e5f736ff3e141c64/src/plugins/gift-cards/ctxSpendCrypto.ts) +```ts +export const signLoginNonce = ( + privateKey: Uint8Array, + nonce: number +): string => { + const signature = secp256k1.sign(makeLoginNonceHash(nonce), privateKey) + const recoverable = new Uint8Array(65) + recoverable[0] = + RECOVERABLE_HEADER_BASE + signature.recovery + RECOVERABLE_HEADER_COMPRESSED + recoverable.set(signature.toBytes('compact'), 1) + return bytesToHex(recoverable) +} +``` + +The uint64 encoding avoids `BigInt`. The app's entry point installs a `big-integer` shim when the runtime has no native `BigInt`, and `DataView.setBigUint64` rejects that shim, so the reference script's `Buffer.writeBigUInt64BE` is not portable here. A byte loop over a `number` is exact for every value below 2^53, which the nonce counter will not reach, and the range is asserted rather than assumed: + +[`src/plugins/gift-cards/ctxSpendCrypto.ts`](https://github.com/EdgeApp/edge-react-gui/blob/ae4641733cc567735f88c655e5f736ff3e141c64/src/plugins/gift-cards/ctxSpendCrypto.ts) +```ts +export const uint64BE = (value: number): Uint8Array => { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`CTX login nonce out of range: ${value}`) + } + const bytes = new Uint8Array(8) + let remaining = value + for (let index = 7; index >= 0; index--) { + bytes[index] = remaining % 256 + remaining = Math.floor(remaining / 256) + } + return bytes +} +``` + +`recoverLoginPublicKeyHex` performs the same recovery the server does. Nothing in the request path calls it; it exists so the wire format can be checked in a unit test instead of by watching for an HTTP 200. + +[JWT](#jwt) expiry is read by decoding the payload with [`base64url`](#base64url) from `rfc4648` and reading `exp`. The signature is not verified, since the client only needs to know when to refresh. Anything unparseable returns `undefined`, which the session treats as already expired. + +### 5.2 Identity and persistence + +The keypair is the [CTX](#ctx) account. It is generated on first use and written to `account.dataStore` under the `ctx-spend` namespace, keyed `identity-`, matching the Phaze layout described in [section 2](#2-prior-art-the-phaze-identity-model). Tokens are derived state and are never written. + +Entropy comes from `generateSecureRandom`, the source `makeUuid` already uses. A random 32-byte string outside the curve order is vanishingly unlikely, but the draw is validated and retried rather than trusted: + +[`src/plugins/gift-cards/ctxSpendAuth.ts`](https://github.com/EdgeApp/edge-react-gui/blob/ae4641733cc567735f88c655e5f736ff3e141c64/src/plugins/gift-cards/ctxSpendAuth.ts) +```ts +const generatePrivateKey = async (): Promise => { + for (let attempt = 0; attempt < 8; attempt++) { + const candidate = await generateSecureRandom(CTX_SPEND_PRIVATE_KEY_LENGTH) + if (isValidPrivateKey(candidate)) return candidate + } + throw new Error('Unable to generate a valid CTX spend private key') +} +``` + +Light accounts get no CTX identity. They have no encrypted store, and a key that cannot be persisted is a user lost at next launch. `ensureIdentity` returns `light-account` for them, which the scene renders as an explanation rather than an error. + +`ensureIdentity` collapses concurrent callers onto a single load, the same way [section 5.3](#53-token-lifecycle) does for the handshake. The scene can call it from two places at once (the connect action and the purchase action), and on a first run two racing callers would each generate and persist a keypair and then overwrite the in-memory one, stranding whichever CTX user lost. With no server-side recovery, that loss is permanent, so the guard is at the session rather than in the caller. + +A stored record whose private key is unusable is skipped rather than surfaced, and a new identity is created in its place. Unusable covers both an out-of-range scalar and malformed hex, since the cleaner only requires a string and `hexToBytes` throws on odd-length or non-hex input. The alternative is an account permanently unable to reach CTX because of one bad write. + +Everything else that can go wrong throws, and that distinction changes behaviour rather than style. See [decision 8.6](#86-read-and-write-failures-throw-rather-than-reading-as-no-identity). + +### 5.3 Token lifecycle + +`getAccessToken` is the only entry point, and it hides three cases behind one call: the cached token is still good, the refresh token can mint a new pair, or the keypair has to run a full login. Access tokens last about 8 hours and refresh tokens about 90 days, both read off the live staging JWTs. + +Refresh is attempted first when any token pair is held, and a failure falls through to a full login rather than propagating. The keypair can always mint a new pair, so a dead refresh token is a recoverable state, not an error. + +Concurrent callers collapse onto a single handshake: + +[`src/plugins/gift-cards/ctxSpendAuth.ts`](https://github.com/EdgeApp/edge-react-gui/blob/ae4641733cc567735f88c655e5f736ff3e141c64/src/plugins/gift-cards/ctxSpendAuth.ts) +```ts + pendingAuth ??= authenticate().finally(() => { + pendingAuth = undefined + }) + return await pendingAuth +``` + +Without that, two parallel first-time calls would each run the two-leg login, and the second `POST /login` would consume a second nonce and overwrite the first caller's tokens. + +The client refreshes a minute before the token's own expiry so a request never races the boundary. Separately, `ctxSpendApi` retries once through a re-authentication on a `401`, covering a token the server retires early. + +### 5.4 Configuration + +`PLUGIN_API_KEYS.ctxSpend` carries `clientId` and `baseUrl`. There is no API key. The `X-Client-Id` header identifies the application and has to be registered server-side, and the keypair identifies the user within it, so an unregistered client id fails at the first `POST /login`. + +### 5.5 Ordering and paying + +A card and its payment are one object. `POST /gift-cards` with a merchant, a fiat amount, and a `cryptoCurrency` returns the card already carrying a single-use `paymentCryptoAddress`, the crypto amount quoted at `rate`, and the chain and network to pay on. Nothing is reserved by paying; the card simply moves from `unpaid` once the payment confirms. + +The client's job between those two points is to pick the wallet. CTX names the chain and network separately (`ETH` plus `testnet`) while Edge models each network as its own currency plugin, so the pair is mapped to a plugin id and the account's wallet for it is looked up: + +[`src/plugins/gift-cards/ctxSpendPurchase.ts`](https://github.com/EdgeApp/edge-react-gui/blob/ae4641733cc567735f88c655e5f736ff3e141c64/src/plugins/gift-cards/ctxSpendPurchase.ts) +```ts +const CTX_CHAIN_TO_PLUGIN_ID: Record> = { + ETH: { mainnet: 'ethereum', testnet: 'sepolia' } +} +``` + +The quoted amount is decimal, and the spend needs native units, so the conversion goes through `biggystring`. A twelve-decimal quote times an eighteen-decimal multiplier is well outside what a double holds exactly, and rounding the wrong way underpays a single-use address: + +[`src/plugins/gift-cards/ctxSpendPurchase.ts`](https://github.com/EdgeApp/edge-react-gui/blob/ae4641733cc567735f88c655e5f736ff3e141c64/src/plugins/gift-cards/ctxSpendPurchase.ts) +```ts +export const getCtxPaymentNativeAmount = ( + giftCard: CtxSpendGiftCard, + wallet: EdgeCurrencyWallet +): string => { + const { paymentCryptoAmount } = giftCard + if (paymentCryptoAmount == null) { + throw new Error('CTX gift card has no payment amount') + } + const multiplier = wallet.currencyInfo.denominations[0]?.multiplier ?? '1' + // The quote is exact and the address is single-use, so pay it verbatim. + // `ceil` only guards a quote carrying more decimals than the chain has: + // rounding down there would underpay and leave the card unfulfilled. + return ceil(mul(paymentCryptoAmount, multiplier), 0) +} +``` + +Payment itself reuses the app's ordinary send scene with the address, amount, and wallet tiles locked, which is how the Phaze flow already pays for its orders. After the send returns, the card is polled with `GET /gift-cards/{id}`. + +The card carries two independent status tracks, and conflating them would misreport the outcome: + +| Track | Observed progression | Who moves it | +|---|---|---| +| `paymentStatus` | `unpaid` to `paid`, a couple of minutes after the send | CTX, on chain confirmation | +| `fulfilmentStatus` | `pending` to `ordered`, then on to an issued code | CTX and the merchant | + +Polling stops at `paid`, because that is the point the client can act on and the only transition it can wait out inside a session. Fulfilment continues on the merchant's schedule; the scene shows `fulfilmentStatus` verbatim rather than reducing it to a boolean, since its terminal value has not been observed (see [section 7](#7-phase-history)). + +### 5.6 In-app surface + +`GiftCardAccountInfoScene` is the existing developer-facing readout for this plugin, reachable from developer settings. It gains a section that establishes the identity, logs in, and displays the public key, user id, user name, company, permission count, and merchant catalog size. The work runs under `useQuery` and only on an explicit button press, so opening the scene costs no network. + +## 6. Testing + +1. `uint64BE` encodes big-endian across byte boundaries: 0, 1, 255, 256, 2^32, and `Number.MAX_SAFE_INTEGER`. Each expectation was cross-checked against `Buffer.writeBigUInt64BE`. +2. `uint64BE` rejects negatives, fractions, and values above the safe-integer range. +3. `makeLoginNonceHash(2)` equals `cd04a475…dce77f70`, computed independently with `node:crypto`. +4. `signLoginNonce` produces 65 bytes with a header byte in 31 to 34. +5. Signing is deterministic for a fixed key and [nonce](#nonce), and differs across nonces. +6. `recoverLoginPublicKeyHex` recovers the signing public key for nonces 1, 2, 42, and 65536. This is the test that proves the wire format: it performs the same recovery the server performs. +7. Recovery against the wrong nonce does not return the signing key. +8. Malformed signatures (wrong length, out-of-range header byte) throw. +9. `getJwtExpiryMs` reads `exp` from an unpadded [base64url](#base64url) payload and returns `undefined` for malformed tokens. +10. `isValidPrivateKey` accepts a valid scalar and rejects an all-zero key and a short buffer. + +11. `getCtxPaymentPluginId` maps a testnet ETH quote to [`sepolia`](#sepolia) and a mainnet one to `ethereum`, and returns undefined for every chain Edge has no wallet type for. +12. `getCtxPaymentNativeAmount` converts the live quote `0.000005200000000000` to `5200000000000` wei, matching the `value` in [CTX](#ctx)'s own payment URI, and rounds up rather than down on a quote finer than the chain's smallest unit. +13. `isCtxGiftCardPaid` recognises `paid`, rejects `unpaid` and `pending`, and is not moved by an in-progress `fulfilmentStatus`. +14. Three concurrent `ensureIdentity` calls on a fresh account yield one keypair and one stored record, not three. +15. A second session over the same store recovers the same public key rather than creating another identity. +16. A [light account](#light-account) gets `light-account` and writes nothing. + +Cases 1 to 16 run in jest, without a simulator or network. Live verification against staging is recorded in [section 11](#11-post-implementation-retrospective). + +## 7. Phase history + +### Phase 1: prototype + +| Sketched | Shipped | +|---|---| +| Port the reference script's crypto | Ported, with two substitutions forced by the runtime: no `BigInt`, no `node:crypto` | +| Persist the keypair | `account.dataStore`, per-identity keys, Phaze layout | +| Token lifecycle | Cached access token, proactive refresh, login fallback, single-flight handshake, one `401` retry | +| Prove it in the app | Readout on `GiftCardAccountInfoScene` | + +Deferred, with reasons: purchase and redemption (the read surface is what a prototype needs to establish), a shared provider interface over Phaze and [CTX](#ctx) ([section 3](#3-goals-and-non-goals)), and identity rotation ([section 2](#2-prior-art-the-phaze-identity-model)). + +### Phase 2: purchase + +| Sketched | Shipped | +|---|---| +| Order a card | `POST /gift-cards`, with the field names taken from the server's own validation errors | +| Pay for it | Chain-and-network to plugin-id mapping, `biggystring` native conversion, then the app's ordinary send scene | +| Confirm it | `GET /gift-cards/{id}` polled until fulfilment | + +Phase 1 listed purchase as a non-goal and shipped a placeholder `asCtxSpendGiftCard` cleaner guessed from the permission names. That guess was wrong in every field except `id` and `status`, and this phase replaced it with the real order shape. The lesson generalises: the permission list named `giftcard:*` and `paymentmethod:*`, but the paths and payloads behind them could only be learned by asking the server. + +One thing this phase could not settle: `fulfilmentStatus` reached `ordered` and stayed there for the rest of the run, so the terminal value is still unknown. An earlier revision of this phase shipped an `isCtxGiftCardFulfilled` helper asserting `fulfilled` or `complete`; neither string was ever observed, nothing called it, and it was removed rather than left as a guess wearing the shape of a fact. + +Still deferred: redemption, a merchant-browsing UI, and the provider abstraction from [decision 8.3](#83-add-ctx-alongside-phaze-rather-than-behind-a-shared-provider-interface). + +## 8. Decisions + +### 8.1 Persist the keypair in the encrypted dataStore, not the device keychain + +The reference script generates a keypair per run and drops it. A real client has to keep it, and where it keeps it decides whether the [CTX](#ctx) user follows the Edge account or the device. + +`account.dataStore` is encrypted and syncs with the Edge account, so the same CTX user is recovered on every device the account logs into. That matches how Phaze identities already behave, so the two providers have the same mental model. + +Rejected: the device keychain. It survives reinstall but does not sync, so logging in on a second device would silently create a second anonymous CTX user with separate gift cards, and the first device's cards would be unreachable from the second. Rejected: unencrypted local storage, since the key is a bearer credential for the CTX account. Reopen if CTX adds server-side key recovery, which would make device-local storage cheap to get wrong in a recoverable way. + +### 8.2 Hand-roll the uint64 encoding instead of using `Buffer` or `BigInt` + +`index.ts` installs `global.BigInt = require('big-integer')` when the runtime lacks a native `BigInt`, and `DataView.setBigUint64` throws on that shim. The reference script's `Buffer.alloc(8).writeBigUInt64BE(BigInt(nonce))` therefore cannot be copied verbatim with confidence. + +A byte loop over a `number` is exact below 2^53 and depends on nothing. The [nonce](#nonce) is a per-key counter that started at 1 on staging, so the range is not a practical constraint, and the function throws rather than silently truncating if it ever were. + +Rejected: `Buffer`, which is polyfilled in the app but drags the same `BigInt` question along. Rejected: a `BigInt`-based implementation, for the shim above. Reopen if the app drops the shim and [Hermes](#hermes) guarantees native `BigInt`. + +### 8.3 Add CTX alongside Phaze rather than behind a shared provider interface + +The task asks for a prototype. Both providers sell gift cards, but their identity models have nothing in common: Phaze has a server-issued key tied to an email, CTX has a client-generated keypair with no recovery. Their catalog shapes differ too, with Phaze keyed by numeric `productId` and CTX by uuid `id`. + +An interface drawn now would be drawn from one real implementation and one guess. Adding CTX as its own module keeps the shipping Phaze flow untouched and leaves the abstraction to be drawn once both sides are known. Reopen when CTX purchase is in scope, which is the point where duplicated flow logic starts to cost more than the abstraction. + +### 8.4 Keep the protocol crypto free of React Native imports + +`src/util/rnUtils.ts` states the constraint directly: modules with native imports cannot be used in unit tests that run in Node. Putting the wire format in a module with no React Native imports means the part with a strict external contract is the part covered by tests, and `recoverLoginPublicKeyHex` lets those tests check the encoding the way the server checks it. + +Rejected: one module with the native pieces inlined, which would have pushed verification of the signature format onto live requests only. + +### 8.5 Use the deprecated `Signature.recoverPublicKey` rather than the standalone + +`@noble/curves` 1.9.7 deprecates `sig.recoverPublicKey(msg)` in favour of `curve.recoverPublicKey(sig.toBytes('recovered'), msg)`. The standalone exists at runtime and was confirmed to return the correct key, but the package types [`secp256k1`](#secp256k1) as `CurveFnWithCreate`, which does not declare it. Reaching it needs a cast that the repo's `no-any` rule forbids. + +The deprecated instance method is typed, so the call sites keep their types and one lint suppression carries the reason. The other four deprecations in the same file (`sha256` import path, `toBytes('compact')`, `fromBytes`, `isValidSecretKey`) had typed modern replacements and were all migrated. Reopen on the upgrade to `@noble/curves` 2.x. + +### 8.6 Read and write failures throw rather than reading as "no identity" + +`ensureIdentity` returns a status for the one case that is a permanent property of the account (`light-account`) and throws for everything else. An earlier revision returned a bare boolean and swallowed dataStore errors, which produced two defects that a reviewer caught before merge. + +A transient read failure became indistinguishable from a first run. The caller answers "no identity" by minting a keypair, so one failed `listItemIds` would have silently replaced the user's CTX identity, and CTX has no server-side recovery to get it back. A store that has never been written returns an empty list rather than throwing, so first run is still correctly the empty case without any catch. + +The same boolean also conflated a [light account](#light-account) with a failed write, so an account whose keypair could not be saved was told it was a light account while the real error went unreported. + +Rejected: a richer result type covering every failure. The failures worth distinguishing here are exactly one, and a thrown error already reaches the scene through the query's error path. + +### 8.7 Pay the testnet quote from sepolia, the only testnet wallet Edge has + +Staging quotes every asset on testnet: Monero on stagenet, Zcash and Bitcoin and Litecoin and Bitcoin Cash and Dash on their testnets, Zano and Stellar likewise, and Ether on [Sepolia](#sepolia) (chain id 11155111). Edge ships mainnet plugins for those chains and one testnet plugin, sepolia, so ETH is the only quote the app can actually pay. + +That is a property of the staging environment, not of the design. The mapping table is keyed by network precisely so a mainnet CTX deployment resolves ETH to `ethereum` with no other change, and so an unpayable quote fails with a message naming the chain and network rather than silently picking the wrong wallet. + +Rejected: adding testnet currency plugins to Edge so the other assets could be exercised. That is a large change to the app's currency set for the sake of one integration test, and it would ship testnet wallets to users. + +## 9. Glossary + +### secp256k1 + +The elliptic curve Bitcoin and Ethereum use for signatures. Here it is the curve the CTX identity keypair lives on, and the curve whose public key recovery lets the server learn who signed a nonce without being told. See [SEC 2](https://www.secg.org/sec2-v2.pdf). + +### Public key recovery + +A property of ECDSA on secp256k1: given a signature, the signed digest, and a small recovery id, the signer's public key can be computed. CTX relies on it so the second `/login` request carries only a signature, not a key. See [SEC 1, section 4.1.6](https://www.secg.org/sec1-v2.pdf). + +### CTX + +The company behind the spend-api this client talks to, and the vendor of the gift card catalog it reads. Their reference implementation of the auth protocol is [CTX-com/spend-api-pubkey-auth-demo](https://github.com/CTX-com/spend-api-pubkey-auth-demo). + +### Nonce + +A number used once. Here it is a per-key counter the server issues on the first `/login` leg, and the client signs `nonce + 1` to prove it holds the private key for the registered public key. See [RFC 4949](https://datatracker.ietf.org/doc/html/rfc4949#page-206). + +### JWT + +JSON Web Token. The access and refresh tokens the spend-api issues. The client decodes the payload to read the `exp` claim and never verifies the signature, which is the server's job. See [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519). + +### base64url + +The URL-safe base64 alphabet, unpadded in JWTs. See [RFC 4648, section 5](https://datatracker.ietf.org/doc/html/rfc4648#section-5). + +### CSPRNG + +Cryptographically secure pseudorandom number generator. The private key is drawn from the platform's, via `generateSecureRandom`. See [NIST SP 800-90A](https://csrc.nist.gov/pubs/sp/800/90/a/r1/final). + +### Sepolia + +Ethereum's current long-lived test network, chain id 11155111. Its Ether has no market value, which is what makes it usable for an integration test. Edge ships it as its own currency plugin (`sepolia`), separate from `ethereum`. See [the Ethereum documentation](https://ethereum.org/en/developers/docs/networks/#sepolia). + +### Hermes + +The JavaScript engine React Native runs on iOS and Android. Its `BigInt` support is the reason for [decision 8.2](#82-hand-roll-the-uint64-encoding-instead-of-using-buffer-or-bigint). See [the Hermes documentation](https://github.com/facebook/hermes). + +### Light account + +An Edge account with no username, created without credentials. It has no encrypted `dataStore`, so it cannot hold a CTX signing key. The gate this design reuses is the `account.username == null` check in [`phazeGiftCardProvider.ts`](https://github.com/EdgeApp/edge-react-gui/blob/develop/src/plugins/gift-cards/phazeGiftCardProvider.ts). + +### btcec + +The Go secp256k1 library whose `RecoverCompact` defines the 65-byte signature layout the spend-api expects. See [btcec](https://github.com/btcsuite/btcd/tree/master/btcec). + +## 10. References + +- [CTX-com/spend-api-pubkey-auth-demo](https://github.com/CTX-com/spend-api-pubkey-auth-demo), the reference implementation of the protocol. +- [@noble/curves](https://github.com/paulmillr/noble-curves), the [secp256k1](#secp256k1) implementation, already a dependency of this repo. + +## 11. Post-implementation retrospective + +### Estimate vs. actuals + +| Item | Estimate | Actual | +|---|---|---| +| Endpoints implemented | 5 (`/login` x2, `/refresh-token`, `/me`, `/merchants`) | 6, adding `/gift-cards` | +| New npm dependencies | 0 | 0 | +| Unit test cases | ~6 | 13 | +| New source files | 4 | 4 | + +### Where this document was wrong or silent + +1. The reference script's crypto was expected to port unchanged. Two of its primitives did not: `Buffer.writeBigUInt64BE` and `node:crypto`'s `sha256`. Both were replaced, and [decision 8.2](#82-hand-roll-the-uint64-encoding-instead-of-using-buffer-or-bigint) records why the first one had to be. +2. The endpoint list was drawn from the permissions `GET /me` returns, which name `paymentmethod:*` and `merchantlink:list`. Probing staging showed `/payment-methods`, `/merchant-links`, and `/countries` all return 404, so a granted permission does not imply a discoverable path. The paths for those grants are still unknown, and [section 3](#3-goals-and-non-goals) scopes them out. +3. Nothing in the design anticipated rate limiting. Staging returns `{"error":"rate limit reached"}` under a burst of sequential requests, and a probe needed roughly 2.5 seconds of spacing to stay clear. The client issues two requests per readout, so it does not hit this, but a catalog paginator would. + +### What held + +The Phaze identity layout transferred without modification: same `dataStore` namespacing, same per-identity keys, same light-account gate. Splitting the protocol crypto away from the React Native imports ([decision 8.4](#84-keep-the-protocol-crypto-free-of-react-native-imports)) paid off immediately, since the wire format was verified in jest before the app was ever built. + +### Verification highlights + +- The reference script, run against `https://staging.spend.ctx.com` with `CLIENT_ID=edge`, completes the full lifecycle: register, [nonce](#nonce), sign, tokens, `/me`, refresh, re-login. It authenticated as company "Edge" with 12 gift-card-scoped permissions. +- The replacement primitives were diffed against the reference script's before being written into the app: `uint64BE` and `sha256` match `Buffer.writeBigUInt64BE` plus `node:crypto` byte for byte across 7 values, including `Number.MAX_SAFE_INTEGER`. +- A live `POST /login` signed with those replacement primitives returned HTTP 200 with a token pair, and the local recovery round-trip returned the signing public key. +- `GET /merchants` returns 118 merchants against staging. Token lifetimes read off the live JWTs are about 8 hours for access and 90 days for refresh. +- 13 jest cases pass ([section 6](#6-testing)); `tsc --noEmit` is clean; the repo's full 536-case suite passes. +- Driven in the app on an iOS 18.6 simulator against staging. The app generated its own keypair, registered it, and read `/me` and `/merchants`: public key `032129df…bd97c8d`, user `99eb4fe5-b3ff-4355-8ad3-b84f06d1cb5d`, company Edge, 12 permissions, 118 merchants. The server named the anonymous user `Anon 032129df` after the public key's own prefix, which is independent confirmation that the key the app generated is the key the server registered. +- Persistence was verified by a full app stop and relaunch, after which the same user id and the same public key came back, so the keypair was reloaded from the encrypted `dataStore` rather than regenerated. diff --git a/src/envConfig.ts b/src/envConfig.ts index f0181d68c3d..46b773a35da 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -167,6 +167,14 @@ export const asEnvConfig = asObject({ apiKey: asString, baseUrl: asString }) + ), + // CTX spend-api. There is no API key: the client is identified by a + // server-registered clientId, and the user by a per-account keypair. + ctxSpend: asOptional( + asObject({ + clientId: asString, + baseUrl: asString + }) ) }).withRest, () => ({ @@ -180,7 +188,8 @@ export const asEnvConfig = asObject({ revolut: undefined, simplex: undefined, ionia: undefined, - phaze: undefined + phaze: undefined, + ctxSpend: undefined }) ), RAMP_PLUGIN_INITS: asOptional( diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 51f82a5058b..09f828d822b 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -2054,6 +2054,33 @@ const strings = { gift_card_account_info_rotated: 'A new identity has been created for future purchases.', gift_card_account_info_user_id: 'Phaze User ID', + ctx_spend_section_title: 'CTX Spend (Prototype)', + ctx_spend_connect_button: 'Connect to CTX Spend', + ctx_spend_connecting: 'Connecting...', + ctx_spend_not_configured: + 'CTX Spend is not configured. Add PLUGIN_API_KEYS.ctxSpend to env.json.', + ctx_spend_unavailable_light_account: + 'CTX Spend requires a full account. Light accounts cannot store the signing key.', + ctx_spend_public_key: 'CTX Public Key', + ctx_spend_user_id: 'CTX User ID', + ctx_spend_user_name: 'CTX User Name', + ctx_spend_company: 'CTX Company', + ctx_spend_permission_count: 'CTX Permissions', + ctx_spend_merchant_count: 'CTX Merchants Available', + ctx_spend_buy_button: 'Buy Test Gift Card', + ctx_spend_buying: 'Ordering...', + ctx_spend_card_id: 'CTX Card ID', + ctx_spend_card_merchant: 'CTX Card Merchant', + ctx_spend_card_face_value: 'CTX Card Value', + ctx_spend_card_pay_amount: 'CTX Amount To Pay', + ctx_spend_card_payment_status: 'CTX Payment Status', + ctx_spend_card_fulfilment_status: 'CTX Fulfilment Status', + ctx_spend_card_network: 'CTX Payment Network', + ctx_spend_no_payment_address: 'CTX did not return a payment address.', + ctx_spend_unsupported_payment_3s: + '%1$s has no wallet type for %2$s on %3$s, so this card cannot be paid from the app.', + ctx_spend_no_wallet_1s: + 'This account has no %1$s wallet. Create one to pay for this card.', gift_card_pending: 'Pending Delivery, Please Wait...', gift_card_pending_toast: 'Your gift card is being delivered. Please wait for a few minutes for it to arrive.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index ecfa739a196..29c8975a993 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1593,6 +1593,29 @@ "gift_card_account_info_email": "Account Email", "gift_card_account_info_rotated": "A new identity has been created for future purchases.", "gift_card_account_info_user_id": "Phaze User ID", + "ctx_spend_section_title": "CTX Spend (Prototype)", + "ctx_spend_connect_button": "Connect to CTX Spend", + "ctx_spend_connecting": "Connecting...", + "ctx_spend_not_configured": "CTX Spend is not configured. Add PLUGIN_API_KEYS.ctxSpend to env.json.", + "ctx_spend_unavailable_light_account": "CTX Spend requires a full account. Light accounts cannot store the signing key.", + "ctx_spend_public_key": "CTX Public Key", + "ctx_spend_user_id": "CTX User ID", + "ctx_spend_user_name": "CTX User Name", + "ctx_spend_company": "CTX Company", + "ctx_spend_permission_count": "CTX Permissions", + "ctx_spend_merchant_count": "CTX Merchants Available", + "ctx_spend_buy_button": "Buy Test Gift Card", + "ctx_spend_buying": "Ordering...", + "ctx_spend_card_id": "CTX Card ID", + "ctx_spend_card_merchant": "CTX Card Merchant", + "ctx_spend_card_face_value": "CTX Card Value", + "ctx_spend_card_pay_amount": "CTX Amount To Pay", + "ctx_spend_card_payment_status": "CTX Payment Status", + "ctx_spend_card_fulfilment_status": "CTX Fulfilment Status", + "ctx_spend_card_network": "CTX Payment Network", + "ctx_spend_no_payment_address": "CTX did not return a payment address.", + "ctx_spend_unsupported_payment_3s": "%1$s has no wallet type for %2$s on %3$s, so this card cannot be paid from the app.", + "ctx_spend_no_wallet_1s": "This account has no %1$s wallet. Create one to pay for this card.", "gift_card_pending": "Pending Delivery, Please Wait...", "gift_card_pending_toast": "Your gift card is being delivered. Please wait for a few minutes for it to arrive.", "gift_card_order_id_label": "Order ID", diff --git a/src/plugins/gift-cards/ctxSpendApi.ts b/src/plugins/gift-cards/ctxSpendApi.ts new file mode 100644 index 00000000000..8b6bd162de7 --- /dev/null +++ b/src/plugins/gift-cards/ctxSpendApi.ts @@ -0,0 +1,186 @@ +import { asJSON, asMaybe } from 'cleaners' +import type { EdgeAccount } from 'edge-core-js' + +import { debugLog, maskHeaders } from '../../util/logger' +import { + type CtxSpendAuthConfig, + type CtxSpendIdentityStatus, + type CtxSpendSession, + makeCtxSpendSession +} from './ctxSpendAuth' +import { + asCtxSpendAuthContext, + asCtxSpendErrorBody, + asCtxSpendGiftCard, + asCtxSpendGiftCardsResponse, + asCtxSpendMerchantsResponse, + type CtxSpendAuthContext, + type CtxSpendCreateGiftCardRequest, + type CtxSpendGiftCard, + type CtxSpendGiftCardsResponse, + type CtxSpendMerchantsResponse +} from './ctxSpendTypes' + +/** + * Client for the CTX spend-api, authenticated by the anonymous keypair session + * in `ctxSpendAuth.ts`. + * + * Scope is the surface confirmed against staging: `/me`, `/merchants`, and the + * `/gift-cards` create, read, and list calls. Redemption is not wired up. + */ + +export type CtxSpendApiConfig = CtxSpendAuthConfig + +export interface CtxSpendApi { + /** + * Establish the keypair identity. Must return `ready` before any request; + * throws when the identity could not be loaded or created. + */ + ensureIdentity: (account: EdgeAccount) => Promise + + getPublicKeyHex: () => string | undefined + + /** The authenticated user, company, and granted permissions. */ + getMe: () => Promise + + /** The purchasable brand catalog. */ + getMerchants: (params?: { + country?: string + page?: number + }) => Promise + + /** Gift cards owned by the authenticated user. */ + getGiftCards: (params?: { + page?: number + }) => Promise + + /** + * Order a gift card. This allocates a payment address and quotes the crypto + * amount, but does not pay: the card stays `unpaid` until that address is + * funded, and is fulfilled once the payment confirms. + */ + createGiftCard: ( + request: CtxSpendCreateGiftCardRequest + ) => Promise + + /** Re-read one card, which is how payment and fulfilment are polled. */ + getGiftCard: (giftCardId: string) => Promise +} + +/** + * Turn an error body into something worth reading. + * + * A rejected order answers with a per-field map, e.g. + * `{"error":"bad request","fields":{"fiatAmount":["invalid"]}}`, and the + * `fields` half is the part that says what to change. + */ +const describeError = (text: string): string => { + const parsed = asMaybe(asJSON(asCtxSpendErrorBody))(text) + if (parsed == null) return text + const fields = Object.entries(parsed.fields ?? {}) + .map(([field, reasons]) => `${field}: ${reasons.join(', ')}`) + .join('; ') + return fields === '' ? parsed.error : `${parsed.error} (${fields})` +} + +export const makeCtxSpendApi = (config: CtxSpendApiConfig): CtxSpendApi => { + const baseUrl = config.baseUrl.replace(/\/$/, '') + const session: CtxSpendSession = makeCtxSpendSession(config) + + /** + * Build a request URL by string, not via `URL`. React Native's `URL` appends + * a trailing slash to any path that has no query string, and the spend-api + * routes `/me/` and `/merchants/` to 404. + */ + const buildUrl = ( + path: string, + query?: Record + ): string => { + const params = + query == null + ? [] + : Object.entries(query) + .filter(([, value]) => value != null) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent( + String(value) + )}` + ) + return params.length === 0 + ? `${baseUrl}${path}` + : `${baseUrl}${path}?${params.join('&')}` + } + + const fetchAuthed = async ( + url: string, + options: { method?: 'GET' | 'POST'; body?: unknown } = {} + ): Promise => { + const method = options.method ?? 'GET' + const body = options.body == null ? undefined : JSON.stringify(options.body) + + const send = async (): Promise => { + const headers = { + 'Content-Type': 'application/json', + 'X-Client-Id': config.clientId, + Authorization: `Bearer ${await session.getAccessToken()}` + } + debugLog('ctxSpend', `${method} ${url}`, maskHeaders(headers), body ?? '') + return await fetch(url, { method, headers, body }) + } + + let response = await send() + // A 401 on a token we believed was live means the server retired it early. + // Drop it and re-authenticate once before surfacing the failure. + if (response.status === 401) { + debugLog('ctxSpend', 'Access token rejected, re-authenticating') + session.invalidateTokens() + response = await send() + } + + const text = await response.text() + if (!response.ok) { + throw new Error( + `CTX request failed (${response.status}): ${describeError(text)}` + ) + } + return text + } + + return { + ensureIdentity: async account => await session.ensureIdentity(account), + + getPublicKeyHex: () => session.getPublicKeyHex(), + + getMe: async () => + asJSON(asCtxSpendAuthContext)(await fetchAuthed(buildUrl('/me'))), + + getMerchants: async (params = {}) => + asJSON(asCtxSpendMerchantsResponse)( + await fetchAuthed( + buildUrl('/merchants', { + country: params.country, + page: params.page + }) + ) + ), + + getGiftCards: async (params = {}) => + asJSON(asCtxSpendGiftCardsResponse)( + await fetchAuthed(buildUrl('/gift-cards', { page: params.page })) + ), + + createGiftCard: async request => + asJSON(asCtxSpendGiftCard)( + await fetchAuthed(buildUrl('/gift-cards'), { + method: 'POST', + body: request + }) + ), + + getGiftCard: async giftCardId => + asJSON(asCtxSpendGiftCard)( + await fetchAuthed(buildUrl(`/gift-cards/${giftCardId}`)) + ) + } +} diff --git a/src/plugins/gift-cards/ctxSpendAuth.ts b/src/plugins/gift-cards/ctxSpendAuth.ts new file mode 100644 index 00000000000..324fd7f093a --- /dev/null +++ b/src/plugins/gift-cards/ctxSpendAuth.ts @@ -0,0 +1,329 @@ +import { asJSON, asMaybe } from 'cleaners' +import type { EdgeAccount } from 'edge-core-js' +import { generateSecureRandom } from 'react-native-securerandom' + +import { debugLog } from '../../util/logger' +import { makeUuid } from '../../util/rnUtils' +import { + bytesToHex, + CTX_SPEND_PRIVATE_KEY_LENGTH, + getJwtExpiryMs, + getPublicKeyHex, + hexToBytes, + isValidPrivateKey, + signLoginNonce +} from './ctxSpendCrypto' +import { + asCtxSpendError, + asCtxSpendLoginNonce, + asCtxSpendStoredIdentity, + asCtxSpendTokens, + type CtxSpendStoredIdentity, + type CtxSpendTokens +} from './ctxSpendTypes' + +/** + * Anonymous secp256k1 session against the CTX spend-api. + * + * The keypair is the account: there is no email, password, or server-side + * recovery. It is generated on first use and kept in the account's encrypted + * dataStore, so the same anonymous CTX user is recovered on every launch and + * on every device synced to the Edge account. Losing the key means losing the + * CTX user, which is why tokens are treated as disposable and the key is not. + */ + +/** Encrypted dataStore namespace. Mirrors the Phaze identity layout. */ +const STORE_ID = 'ctx-spend' +const IDENTITY_KEY_PREFIX = 'identity-' + +/** + * Refresh this long before the access token's own expiry, so a request never + * races the boundary. The live staging access token lasts ~8h. + */ +const TOKEN_EXPIRY_MARGIN_MS = 60 * 1000 + +const asStoredIdentityJson = asJSON(asCtxSpendStoredIdentity) + +export interface CtxSpendAuthConfig { + /** `X-Client-Id`. Registered server-side; not a value the client invents. */ + clientId: string + baseUrl: string +} + +/** + * Why an account does or does not have a CTX identity. `light-account` is a + * permanent property of the account, not a failure, so it is a return value. + * Everything that could succeed on a retry throws instead. + */ +export type CtxSpendIdentityStatus = 'ready' | 'light-account' + +export interface CtxSpendSession { + /** The active identity's compressed public key, once loaded. */ + getPublicKeyHex: () => string | undefined + + /** + * Load the stored identity, generating and persisting one on first use. + * Light accounts have no encrypted store, so they get no CTX identity. + * Throws when the store cannot be read or the new identity cannot be saved. + */ + ensureIdentity: (account: EdgeAccount) => Promise + + /** + * Return a usable access token, performing whatever work that requires: + * reusing the cached one, refreshing it, or running a full nonce/sign login. + */ + getAccessToken: () => Promise + + /** Drop cached tokens so the next call re-authenticates from the keypair. */ + invalidateTokens: () => void +} + +/** + * Build the identity's storage key. One item per identity keeps concurrent + * writes from different devices from clobbering each other. + */ +const makeIdentityKey = (uniqueId: string): string => + `${IDENTITY_KEY_PREFIX}${uniqueId}` + +/** + * Draw a valid secp256k1 scalar from the platform CSPRNG. + * + * `generateSecureRandom` is the entropy source already trusted elsewhere in + * the app. A random 32-byte string is astronomically unlikely to fall outside + * the curve order, but the retry keeps that case correct rather than fatal. + */ +const generatePrivateKey = async (): Promise => { + for (let attempt = 0; attempt < 8; attempt++) { + const candidate = await generateSecureRandom(CTX_SPEND_PRIVATE_KEY_LENGTH) + if (isValidPrivateKey(candidate)) return candidate + } + throw new Error('Unable to generate a valid CTX spend private key') +} + +/** + * Decode a stored private key, treating anything unusable as absent. + * + * `asCtxSpendStoredIdentity` only requires a string, so the stored hex can be + * odd-length or non-hex, which makes `hexToBytes` throw. An unusable key is a + * record to replace, not an error to propagate, so both that and an + * out-of-range scalar collapse to `undefined`. + */ +const parseStoredPrivateKey = ( + privateKeyHex: string +): Uint8Array | undefined => { + let bytes: Uint8Array + try { + bytes = hexToBytes(privateKeyHex) + } catch { + return undefined + } + return isValidPrivateKey(bytes) ? bytes : undefined +} + +export const makeCtxSpendSession = ( + config: CtxSpendAuthConfig +): CtxSpendSession => { + const baseUrl = config.baseUrl.replace(/\/$/, '') + + let identity: CtxSpendStoredIdentity | undefined + let privateKey: Uint8Array | undefined + let tokens: CtxSpendTokens | undefined + let accessTokenExpiryMs = 0 + /** In-flight authentication, so concurrent callers share one handshake. */ + let pendingAuth: Promise | undefined + /** In-flight identity load, so concurrent callers share one keypair. */ + let pendingIdentity: Promise | undefined + + const postJson = async (path: string, body: unknown): Promise => { + const response = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Id': config.clientId + }, + body: JSON.stringify(body) + }) + const text = await response.text() + if (!response.ok) { + const parsed = asMaybe(asJSON(asCtxSpendError))(text) + throw new Error( + parsed != null + ? `CTX ${path} failed (${response.status}): ${parsed.error}` + : `CTX ${path} failed (${response.status})` + ) + } + return JSON.parse(text) + } + + /** + * Load every identity this account has stored, newest first. + * + * Read failures propagate on purpose. Swallowing them would make a transient + * dataStore error indistinguishable from "no identity yet", and the caller + * answers that by minting a new keypair, which would orphan the existing CTX + * user for good: CTX has no server-side recovery. A store that has never + * been written returns an empty list rather than throwing, so first run is + * still the empty case. + */ + const loadIdentities = async ( + account: EdgeAccount + ): Promise => { + const itemIds = await account.dataStore.listItemIds(STORE_ID) + const stored: CtxSpendStoredIdentity[] = [] + for (const itemId of itemIds) { + if (!itemId.startsWith(IDENTITY_KEY_PREFIX)) continue + const text = await account.dataStore.getItem(STORE_ID, itemId) + const parsed = asMaybe(asStoredIdentityJson)(text) + if (parsed == null) { + // Written by a newer or corrupted format: unusable, but readable, so + // it is a record to skip rather than a failure to report. + debugLog('ctxSpend', 'Skipping unreadable identity:', itemId) + continue + } + stored.push(parsed) + } + return stored.sort((a, b) => + b.createdIsoDate.localeCompare(a.createdIsoDate) + ) + } + + /** + * Run the two-leg login: register the public key to get a nonce, then prove + * ownership by signing it. The server signs `nonce + 1`, not the nonce it + * returned, which is what stops a returned nonce being replayed as-is. + */ + const login = async (): Promise => { + if (identity == null || privateKey == null) { + throw new Error('CTX spend identity is not loaded') + } + + const nonceResponse = asCtxSpendLoginNonce( + await postJson('/login', { + key: identity.publicKeyHex, + scheme: identity.scheme + }) + ) + const signedNonce = nonceResponse.nonce + 1 + + const newTokens = asCtxSpendTokens( + await postJson('/login', { + nonce: signedNonce, + sig: signLoginNonce(privateKey, signedNonce) + }) + ) + debugLog('ctxSpend', 'Logged in with identity:', identity.uniqueId) + return newTokens + } + + const refresh = async (refreshToken: string): Promise => { + const newTokens = asCtxSpendTokens( + await postJson('/refresh-token', { refreshToken }) + ) + debugLog('ctxSpend', 'Refreshed access token') + return newTokens + } + + const applyTokens = (newTokens: CtxSpendTokens): string => { + tokens = newTokens + // Treat an unreadable expiry as immediate, so the next call re-authenticates + // rather than sending a token the server will reject. + accessTokenExpiryMs = getJwtExpiryMs(newTokens.accessToken) ?? 0 + return newTokens.accessToken + } + + /** + * Refresh when possible, fall back to a full login. The refresh token + * outlives the access token by months, but it does eventually expire, and + * the keypair is always able to mint a fresh pair. + */ + const authenticate = async (): Promise => { + if (tokens != null) { + try { + return applyTokens(await refresh(tokens.refreshToken)) + } catch (error: unknown) { + debugLog('ctxSpend', 'Refresh failed, re-running login:', error) + } + } + return applyTokens(await login()) + } + + const loadOrCreateIdentity = async ( + account: EdgeAccount + ): Promise => { + // Light accounts have no encrypted store to persist a key into, and a + // key that cannot be persisted is a CTX user lost on next launch. + if (account.username == null) { + debugLog('ctxSpend', 'Light account - CTX spend unavailable') + return 'light-account' + } + + // Newest first, so a usable key wins over an older or unusable one. + for (const candidate of await loadIdentities(account)) { + const storedKey = parseStoredPrivateKey(candidate.privateKeyHex) + if (storedKey != null) { + identity = candidate + privateKey = storedKey + debugLog('ctxSpend', 'Loaded identity:', candidate.uniqueId) + return 'ready' + } + debugLog('ctxSpend', 'Unusable key, skipping:', candidate.uniqueId) + } + + // A generate or save failure propagates: it is retryable, and reporting + // it as "no identity" would present a broken store as a light account. + const newPrivateKey = await generatePrivateKey() + const newIdentity: CtxSpendStoredIdentity = { + uniqueId: await makeUuid(), + scheme: 'secp256k1', + privateKeyHex: bytesToHex(newPrivateKey), + publicKeyHex: getPublicKeyHex(newPrivateKey), + createdIsoDate: new Date().toISOString() + } + await account.dataStore.setItem( + STORE_ID, + makeIdentityKey(newIdentity.uniqueId), + JSON.stringify(newIdentity) + ) + identity = newIdentity + privateKey = newPrivateKey + debugLog('ctxSpend', 'Created identity:', newIdentity.uniqueId) + return 'ready' + } + + return { + getPublicKeyHex: () => identity?.publicKeyHex, + + async ensureIdentity(account) { + if (identity != null) return 'ready' + // Collapse concurrent callers onto one load, the same way + // `getAccessToken` does. Two first-run callers racing here would each + // generate and persist a keypair and then clobber the in-memory one, + // which strands whichever CTX user lost the race with no recovery. + pendingIdentity ??= loadOrCreateIdentity(account).finally(() => { + pendingIdentity = undefined + }) + return await pendingIdentity + }, + + async getAccessToken() { + if ( + tokens != null && + Date.now() < accessTokenExpiryMs - TOKEN_EXPIRY_MARGIN_MS + ) { + return tokens.accessToken + } + // Collapse concurrent callers onto a single handshake. Two parallel + // logins would each consume a nonce and the loser's tokens would be + // written over the winner's. + pendingAuth ??= authenticate().finally(() => { + pendingAuth = undefined + }) + return await pendingAuth + }, + + invalidateTokens() { + tokens = undefined + accessTokenExpiryMs = 0 + } + } +} diff --git a/src/plugins/gift-cards/ctxSpendCrypto.ts b/src/plugins/gift-cards/ctxSpendCrypto.ts new file mode 100644 index 00000000000..597c0d12203 --- /dev/null +++ b/src/plugins/gift-cards/ctxSpendCrypto.ts @@ -0,0 +1,142 @@ +import { secp256k1 } from '@noble/curves/secp256k1' +import { sha256 } from '@noble/hashes/sha2' +import { base16, base64url } from 'rfc4648' + +/** + * Pure implementation of the CTX spend-api pubkey auth protocol. + * + * Deliberately free of React Native imports so the wire format can be unit + * tested in Node. Entropy and storage live in `ctxSpendAuth.ts`. + * + * Reference: https://github.com/CTX-com/spend-api-pubkey-auth-demo + */ + +/** Length of a secp256k1 private key, in bytes. */ +export const CTX_SPEND_PRIVATE_KEY_LENGTH = 32 + +/** + * btcec's `RecoverCompact` reads the recovery id out of a leading header byte: + * 27 marks the base, +4 marks a compressed public key. + */ +const RECOVERABLE_HEADER_BASE = 27 +const RECOVERABLE_HEADER_COMPRESSED = 4 + +export const bytesToHex = (bytes: Uint8Array): string => + base16.stringify(bytes).toLowerCase() + +export const hexToBytes = (hex: string): Uint8Array => + base16.parse(hex.toUpperCase()) + +/** + * Encode a number as a big-endian uint64. + * + * Written against `number` rather than `BigInt` on purpose: Hermes falls back + * to a `big-integer` shim when BigInt is missing, and `DataView.setBigUint64` + * rejects that shim. Nonces are small counters, so `number` is exact here, but + * the range is asserted rather than assumed. + */ +export const uint64BE = (value: number): Uint8Array => { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`CTX login nonce out of range: ${value}`) + } + const bytes = new Uint8Array(8) + let remaining = value + for (let index = 7; index >= 0; index--) { + bytes[index] = remaining % 256 + remaining = Math.floor(remaining / 256) + } + return bytes +} + +/** + * The digest the server expects a signature over: `sha256(uint64BE(nonce))`. + */ +export const makeLoginNonceHash = (nonce: number): Uint8Array => + sha256(uint64BE(nonce)) + +/** Compressed public key for a private key, hex encoded. */ +export const getPublicKeyHex = (privateKey: Uint8Array): string => + bytesToHex(secp256k1.getPublicKey(privateKey, true)) + +/** + * Sign a login nonce, producing the 65-byte recoverable signature the server + * verifies: `[27 + recoveryId + 4] || R(32) || S(32)`, hex encoded. + * + * The server recovers the public key from this signature rather than being + * told it, which is what makes the second `/login` leg prove key ownership. + */ +export const signLoginNonce = ( + privateKey: Uint8Array, + nonce: number +): string => { + const signature = secp256k1.sign(makeLoginNonceHash(nonce), privateKey) + const recoverable = new Uint8Array(65) + recoverable[0] = + RECOVERABLE_HEADER_BASE + signature.recovery + RECOVERABLE_HEADER_COMPRESSED + recoverable.set(signature.toBytes('compact'), 1) + return bytesToHex(recoverable) +} + +/** + * Recover the signer's compressed public key from a signature produced by + * `signLoginNonce`. This is the check the server performs; having it on the + * client lets the wire format be verified without a network round trip. + */ +export const recoverLoginPublicKeyHex = ( + signatureHex: string, + nonce: number +): string => { + const raw = hexToBytes(signatureHex) + if (raw.length !== 65) { + throw new Error(`CTX login signature must be 65 bytes, got ${raw.length}`) + } + const recovery = + raw[0] - RECOVERABLE_HEADER_BASE - RECOVERABLE_HEADER_COMPRESSED + if (recovery < 0 || recovery > 3) { + throw new Error(`CTX login signature has invalid header byte: ${raw[0]}`) + } + const signature = secp256k1.Signature.fromBytes( + raw.slice(1), + 'compact' + ).addRecoveryBit(recovery) + // The standalone `secp256k1.recoverPublicKey` this deprecation points at + // exists at runtime but is absent from the type definitions @noble/curves + // 1.9.7 ships for this export, so reaching it would require an `any` cast. + // eslint-disable-next-line @typescript-eslint/no-deprecated + return signature.recoverPublicKey(makeLoginNonceHash(nonce)).toHex(true) +} + +/** + * Read the expiry out of a JWT without verifying it. The signature is the + * server's business; the client only needs to know when to refresh. + * + * Returns `undefined` for anything unparseable so a malformed token is treated + * as already expired rather than throwing during a render. + */ +export const getJwtExpiryMs = (token: string): number | undefined => { + const parts = token.split('.') + if (parts.length !== 3) return undefined + try { + // JWT payloads are unpadded base64url ASCII JSON. + const payload = base64url.parse(parts[1], { loose: true }) + let json = '' + for (const byte of payload) json += String.fromCharCode(byte) + const claims: unknown = JSON.parse(json) + if ( + typeof claims !== 'object' || + claims == null || + !('exp' in claims) || + typeof claims.exp !== 'number' + ) { + return undefined + } + return claims.exp * 1000 + } catch { + return undefined + } +} + +/** True when the private key is a valid secp256k1 scalar. */ +export const isValidPrivateKey = (privateKey: Uint8Array): boolean => + privateKey.length === CTX_SPEND_PRIVATE_KEY_LENGTH && + secp256k1.utils.isValidSecretKey(privateKey) diff --git a/src/plugins/gift-cards/ctxSpendPurchase.ts b/src/plugins/gift-cards/ctxSpendPurchase.ts new file mode 100644 index 00000000000..14c8ef6e88f --- /dev/null +++ b/src/plugins/gift-cards/ctxSpendPurchase.ts @@ -0,0 +1,87 @@ +import { ceil, mul } from 'biggystring' +import type { EdgeAccount, EdgeCurrencyWallet } from 'edge-core-js' + +import type { CtxSpendGiftCard } from './ctxSpendTypes' + +/** + * Mapping between a CTX payment quote and the Edge wallet that can pay it. + * + * CTX names the payment chain and network separately (`ETH` + `testnet`), + * while Edge models each network as its own currency plugin, so the two have + * to be reconciled before a wallet can be chosen. + */ + +/** Edge plugin id for a CTX `paymentCryptoChain` + `paymentCryptoNetwork`. */ +const CTX_CHAIN_TO_PLUGIN_ID: Record> = { + ETH: { mainnet: 'ethereum', testnet: 'sepolia' } +} + +/** + * The Edge plugin that can pay this card, or `undefined` when Edge has no + * wallet for that chain and network. + * + * Staging quotes every asset on testnet, and Edge only carries one testnet + * currency plugin (sepolia), so on staging this resolves for ETH alone. + */ +export const getCtxPaymentPluginId = ( + giftCard: CtxSpendGiftCard +): string | undefined => { + const { paymentCryptoChain, paymentCryptoNetwork } = giftCard + if (paymentCryptoChain == null || paymentCryptoNetwork == null) { + return undefined + } + return CTX_CHAIN_TO_PLUGIN_ID[paymentCryptoChain]?.[paymentCryptoNetwork] +} + +/** + * The account's first wallet for a plugin id, or `undefined` when the account + * has none. The prototype pays from whichever wallet already exists rather + * than creating one. + */ +export const findWalletByPluginId = ( + account: EdgeAccount, + pluginId: string +): EdgeCurrencyWallet | undefined => { + return Object.values(account.currencyWallets).find( + wallet => wallet.currencyInfo.pluginId === pluginId + ) +} + +/** + * Convert a CTX `paymentCryptoAmount` (decimal units) to the native units a + * spend target needs. + * + * Uses `biggystring` rather than float math: the quote carries twelve decimal + * places and the wei conversion is eighteen more, which is well past what a + * double represents exactly. + */ +export const getCtxPaymentNativeAmount = ( + giftCard: CtxSpendGiftCard, + wallet: EdgeCurrencyWallet +): string => { + const { paymentCryptoAmount } = giftCard + if (paymentCryptoAmount == null) { + throw new Error('CTX gift card has no payment amount') + } + const multiplier = wallet.currencyInfo.denominations[0]?.multiplier ?? '1' + // The quote is exact and the address is single-use, so pay it verbatim. + // `ceil` only guards a quote carrying more decimals than the chain has: + // rounding down there would underpay and leave the card unfulfilled. + return ceil(mul(paymentCryptoAmount, multiplier), 0) +} + +/** + * True once CTX has credited the payment. + * + * Observed against staging: `paymentStatus` goes `unpaid` to `paid` a couple + * of minutes after the on-chain send, once the payment confirms. + * + * Fulfilment is a separate, slower track (`fulfilmentStatus` goes `pending` to + * `ordered` and then on to the merchant issuing a code). Its terminal value + * has not been observed, so there is deliberately no predicate for it here: + * the scene shows `fulfilmentStatus` verbatim rather than a guessed boolean. + */ +export const isCtxGiftCardPaid = (giftCard: CtxSpendGiftCard): boolean => + giftCard.paymentStatus != null && + giftCard.paymentStatus !== 'unpaid' && + giftCard.paymentStatus !== 'pending' diff --git a/src/plugins/gift-cards/ctxSpendTypes.ts b/src/plugins/gift-cards/ctxSpendTypes.ts new file mode 100644 index 00000000000..ffe4e235f28 --- /dev/null +++ b/src/plugins/gift-cards/ctxSpendTypes.ts @@ -0,0 +1,219 @@ +import { + asArray, + asBoolean, + asNumber, + asObject, + asOptional, + asString, + asValue +} from 'cleaners' + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +/** + * First leg of `POST /login`: the server acknowledges the public key and hands + * back the nonce the client must sign. + */ +export const asCtxSpendLoginNonce = asObject({ + nonce: asNumber +}) +export type CtxSpendLoginNonce = ReturnType + +/** + * Second leg of `POST /login`, and the whole of `POST /refresh-token`. + */ +export const asCtxSpendTokens = asObject({ + accessToken: asString, + refreshToken: asString +}) +export type CtxSpendTokens = ReturnType + +/** Claims we read out of the JWT payload. Times are seconds since epoch. */ +export const asCtxSpendJwtClaims = asObject({ + exp: asNumber +}) + +/** A single permission grant from `GET /me`. */ +export const asCtxSpendPermission = asObject({ + action: asString, + scope: asOptional(asString) +}) +export type CtxSpendPermission = ReturnType + +/** + * `GET /me`. The anonymous user is created server-side on first login, so this + * is the first place the client learns its own user id. + */ +export const asCtxSpendAuthContext = asObject({ + user: asObject({ + id: asString, + name: asString, + type: asString, + status: asString, + companyId: asOptional(asString), + companyName: asOptional(asString) + }), + company: asObject({ + id: asString, + name: asString, + status: asString, + countries: asOptional(asArray(asString), () => []) + }), + client: asOptional( + asObject({ + id: asString, + name: asString, + status: asString + }) + ), + permissions: asOptional(asArray(asCtxSpendPermission), () => []) +}) +export type CtxSpendAuthContext = ReturnType + +// --------------------------------------------------------------------------- +// Catalog +// --------------------------------------------------------------------------- + +export const asCtxSpendPagination = asObject({ + page: asNumber, + pages: asNumber, + perPage: asNumber, + total: asNumber +}) +export type CtxSpendPagination = ReturnType + +/** + * A purchasable brand from `GET /merchants`. `denominationType` selects how + * `denominations` is read: `min-max` gives a two-element [min, max] range, + * anything else gives an explicit list of allowed values. + */ +export const asCtxSpendMerchant = asObject({ + id: asString, + name: asString, + slug: asString, + country: asString, + currency: asString, + status: asString, + enabled: asOptional(asBoolean, true), + denominationType: asOptional(asString), + denominations: asOptional(asArray(asString), () => []), + cardImageUrl: asOptional(asString), + logoUrl: asOptional(asString), + redeemType: asOptional(asString), + redeemLocation: asOptional(asString), + /** Basis points off the face value, e.g. 400 = 4%. */ + userDiscount: asOptional(asNumber) +}) +export type CtxSpendMerchant = ReturnType + +export const asCtxSpendMerchantsResponse = asObject({ + pagination: asCtxSpendPagination, + // The API sends `null` rather than `[]` for an empty page. + result: asOptional(asArray(asCtxSpendMerchant), () => []) +}) +export type CtxSpendMerchantsResponse = ReturnType< + typeof asCtxSpendMerchantsResponse +> + +/** + * A gift card order, from `POST /gift-cards`, `GET /gift-cards/{id}`, and each + * entry of `GET /gift-cards`. + * + * The card and the payment are one object: creating a card allocates a payment + * address and quotes the crypto amount at `rate`, and the card is fulfilled + * once that payment confirms. The list endpoint omits `paymentCryptoAddress` + * and `paymentUrls`, so both are optional here. + */ +export const asCtxSpendGiftCard = asObject({ + id: asString, + merchantId: asString, + merchantName: asString, + + /** Face value of the card. */ + cardFiatAmount: asString, + cardFiatCurrency: asString, + + // Payment side. + paymentId: asOptional(asString), + paymentMethod: asOptional(asString), + /** Where to send the crypto. Absent from list entries. */ + paymentCryptoAddress: asOptional(asString), + /** Decimal units of `paymentCryptoCurrency`, not native units. */ + paymentCryptoAmount: asOptional(asString), + paymentCryptoChain: asOptional(asString), + paymentCryptoCurrency: asOptional(asString), + /** `mainnet` or `testnet`. Staging issues testnet addresses. */ + paymentCryptoNetwork: asOptional(asString), + /** Payment URIs keyed by `.`. Absent from list entries. */ + paymentUrls: asOptional(asObject(asString), () => ({})), + /** Fiat-per-crypto quote the payment amount was derived from. */ + rate: asOptional(asString), + + // Status. `status` is the headline; the other two say which half moved. + status: asString, + displayStatus: asOptional(asString), + paymentStatus: asOptional(asString), + fulfilmentStatus: asOptional(asString), + + created: asOptional(asString), + updated: asOptional(asString) +}) +export type CtxSpendGiftCard = ReturnType + +export const asCtxSpendGiftCardsResponse = asObject({ + pagination: asCtxSpendPagination, + result: asOptional(asArray(asCtxSpendGiftCard), () => []) +}) +export type CtxSpendGiftCardsResponse = ReturnType< + typeof asCtxSpendGiftCardsResponse +> + +/** Body of `POST /gift-cards`. Field names are the server's, verbatim. */ +export interface CtxSpendCreateGiftCardRequest { + merchantId: string + /** Decimal fiat string, e.g. `'0.01'`. */ + fiatAmount: string + fiatCurrency: string + /** + * Which crypto to pay with, as `CHAIN` or `CHAIN.TOKEN` (`ETH`, `ETH.USDC`). + * Omitting it selects the fiat rail, which staging only supports for GBP. + */ + cryptoCurrency: string +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/** Every non-2xx response body observed so far is `{ "error": "..." }`. */ +export const asCtxSpendError = asObject({ + error: asString +}) + +/** + * A rejected write additionally carries a per-field reason map, which is the + * half that says what to change. + */ +export const asCtxSpendErrorBody = asObject({ + error: asString, + fields: asOptional(asObject(asArray(asString))) +}) + +// --------------------------------------------------------------------------- +// Persisted identity +// --------------------------------------------------------------------------- + +/** + * The long-lived half of a CTX identity. Only the private key is durable state: + * tokens are re-derivable from it at any time, so they are never persisted. + */ +export const asCtxSpendStoredIdentity = asObject({ + uniqueId: asString, + scheme: asValue('secp256k1'), + privateKeyHex: asString, + publicKeyHex: asString, + createdIsoDate: asString +}) +export type CtxSpendStoredIdentity = ReturnType