Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
101 changes: 101 additions & 0 deletions src/__tests__/ctxSpendAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
} => {
const items = new Map<string, string>()
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)
})
})
118 changes: 118 additions & 0 deletions src/__tests__/ctxSpendCrypto.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
120 changes: 120 additions & 0 deletions src/__tests__/ctxSpendPurchase.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
): 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
)
})
})
Loading
Loading