Repository Public
1import { createHash } from 'node:crypto';2import { mkdtempSync } from 'node:fs';3import { tmpdir } from 'node:os';4import { join } from 'node:path';5import { describe, expect, it } from 'vitest';6import { Keypair, Networks, TransactionBuilder, type Transaction } from '@stellar/stellar-base';7import { intentHash, NETWORKS, verifyAuditChain, type Intent } from '@spw/core';8import { TestPasskey } from '../../../tests/helpers/passkey.ts';9import { deriveKeys } from '../src/keys.ts';10import { Signer } from '../src/signer.ts';11import { Store } from '../src/store.ts';1213// Public BIP-39 test vector mnemonic — holds no funds, never used outside tests.14const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');15const RP = 'wallet.test', ORIGIN = 'https://wallet.test';16const DEST = 'GDVSYYTUAJ3ACHTPQNSTQBDQ4LDHQCMNY4FCEQH5TJUMSSLWQSTG42MV'; // SEP-0005 test 5, account 11718async function setup() {19 const dir = mkdtempSync(join(tmpdir(), 'spw-signer-xlm-'));20 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));21 store.state.setupTokenHash = createHash('sha256').update('setup-token').digest('hex');22 const t = Math.floor(Date.now() / 1000);23 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t);24 const pk = new TestPasskey(RP, ORIGIN);25 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });26 return { store, signer, pk, t };27}28let n = 0;29const xlm = (t: number, over: Partial<Intent> = {}): Intent => ({30 v: 1, proposalId: `6f1c1f0e-8f5f-4b1e-9c56-${String(++n).padStart(12, '0')}`, keyId: keys.keyId, createdAt: t, expiresAt: t + 120, policyVersion: 3,31 network: 'stellar', chainRef: NETWORKS.stellar.chainRef, asset: 'native', symbol: 'XLM', decimals: 7, amount: '25000000', from: keys.addresses.stellar!, to: DEST, maxFee: '10112',32 memo: '4242', x: { op: 'payment', sequence: '2390162185806736', fee: '10112', maxTime: String(t + 180), memoType: 'id' }, ...over,33});3435describe('stellar through the signer', () => {36 it('derives the SEP-0005 account from the mnemonic (m/44\'/148\'/0\')', () => {37 expect(keys.addresses.stellar).toBe('GB3JDWCQJCWMJ3IILWIGDTQJJC5567PGVEVXSCVPEQOTDN64VJBDQBYX');38 });3940 it('signs an approved XLM payment; the official SDK decodes it and verifies the signature', async () => {41 const { signer, pk, t, store } = await setup();42 const i = xlm(t);43 const r = await signer.sign(i, pk.assert(intentHash(i)));44 const tx = TransactionBuilder.fromXDR(r.raw, Networks.PUBLIC) as Transaction;45 expect(tx.hash().toString('hex')).toBe(r.txid);46 expect([tx.source, tx.fee, tx.sequence, tx.timeBounds?.maxTime, tx.memo.type, tx.memo.value]).toEqual([i.from, '10112', i.x!.sequence, String(t + 180), 'id', '4242']);47 expect(tx.operations).toHaveLength(1);48 const op = tx.operations[0]!;49 expect(op.type === 'payment' && op.destination === DEST && op.asset.isNative() && op.amount === '2.5000000').toBe(true);50 expect(tx.signatures).toHaveLength(1);51 expect(Keypair.fromPublicKey(keys.addresses.stellar!).verify(tx.hash(), tx.signatures[0]!.signature())).toBe(true);52 expect(verifyAuditChain(store.state.journal)).toBeNull();53 await expect(signer.sign(i, pk.assert(intentHash(i)))).rejects.toThrow(/already signed/);54 });5556 it('signs a CreateAccount only when the approved intent says so', async () => {57 const { signer, pk, t } = await setup();58 const { memo: _m, ...rest } = xlm(t);59 const i: Intent = { ...rest, x: { ...rest.x!, op: 'createAccount', memoType: 'none' } };60 const r = await signer.sign(i, pk.assert(intentHash(i)));61 const op = (TransactionBuilder.fromXDR(r.raw, Networks.PUBLIC) as Transaction).operations[0]!;62 expect(op.type === 'createAccount' && op.destination === DEST && op.startingBalance === '2.5000000').toBe(true);63 // an approval given for a payment cannot be reused for the other operation64 const pay = xlm(t), swapped = { ...pay, x: { ...pay.x!, op: 'createAccount' } };65 await expect(signer.sign(swapped, pk.assert(intentHash(pay)))).rejects.toThrow(/approval/);66 });6768 it('refuses unsupported or out-of-policy Stellar intents before looking at the approval', async () => {69 const { signer, pk, t } = await setup();70 const cases: [Partial<Intent>, RegExp][] = [71 [{ chainRef: 'Test SDF Network ; September 2015' }, /chain identity/],72 [{ from: DEST }, /not controlled/],73 [{ to: keys.addresses.stellar! }, /equals source/],74 [{ to: DEST.toLowerCase() }, /Stellar address/],75 [{ asset: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' }, /native/],76 [{ x: { ...xlm(t).x!, fee: '10113' } }, /above approved maximum/],77 [{ x: { ...xlm(t).x!, memoType: 'text' } }, /memoType/],78 [{ x: { ...xlm(t).x!, extra: '1' } }, /unknown or missing/],79 [{ x: { ...xlm(t).x!, maxTime: String(t + 3600) } }, /too far/],80 [{ amount: '3500000001' }, /per-transaction limit/], // default cap: 350 XLM81 [{ maxFee: '1000001', x: { ...xlm(t).x!, fee: '1000001' } }, /fee ceiling/],82 ];83 for (const [over, re] of cases) { const i = xlm(t, over); await expect(signer.sign(i, pk.assert(intentHash(i))), JSON.stringify(over)).rejects.toThrow(re); }84 });85});86