Repository Public
1// XRP Ledger through the real Signer: passkey approval bound to the intent, plugin build + sign with the signer's key handle.2import { createHash } from 'node:crypto';3import { mkdtempSync } from 'node:fs';4import { tmpdir } from 'node:os';5import { join } from 'node:path';6import { describe, expect, it } from 'vitest';7import { decode, verifySignature, Wallet } from 'xrpl';8import { intentHash, NETWORKS, type Intent } from '@spw/core';9import { TestPasskey } from '../../../tests/helpers/passkey.ts';10import { deriveKeys } from '../src/keys.ts';11import { Signer } from '../src/signer.ts';12import { Store } from '../src/store.ts';1314// Public BIP-39 test vector mnemonic — never used outside tests.15const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';16const RP = 'wallet.test', ORIGIN = 'https://wallet.test';17const keys = deriveKeys(TEST_MNEMONIC);18const DEST = 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe';19const X = { sequence: '5', lastLedgerSequence: '108249120', fee: '12' };2021async function setup() {22 const dir = mkdtempSync(join(tmpdir(), 'spw-signer-xrpl-'));23 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));24 store.state.setupTokenHash = createHash('sha256').update('setup-token').digest('hex');25 const t = Math.floor(Date.now() / 1000);26 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t);27 const pk = new TestPasskey(RP, ORIGIN);28 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });29 return { signer, pk, t };30}31let seq = 0;32const intent = (t: number, over: Partial<Intent> = {}): Intent => ({ v: 1, proposalId: `7a1c1f0e-8f5f-4b1e-9c56-${String(++seq).padStart(12, '0')}`, keyId: keys.keyId, createdAt: t, expiresAt: t + 45,33 policyVersion: 3, network: 'xrpl', chainRef: NETWORKS.xrpl.chainRef, asset: 'native', symbol: 'XRP', decimals: 6, amount: '3000000', from: keys.addresses.xrpl!, to: DEST, maxFee: '12',34 memo: '20261110', x: X, ...over });3536describe('XRP Ledger through the signer', () => {37 it("derives the published m/44'/144'/0'/0/0 address of the test mnemonic", () => {38 expect(keys.addresses.xrpl).toBe('rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3');39 expect(keys.addresses.xrpl).toBe(Wallet.fromMnemonic(TEST_MNEMONIC, { derivationPath: "m/44'/144'/0'/0/0" }).classicAddress);40 });4142 it('signs an approved XRP payment that the official SDK decodes and verifies', async () => {43 const { signer, pk, t } = await setup();44 const i = intent(t);45 const r = await signer.sign(i, pk.assert(intentHash(i)));46 expect(verifySignature(r.raw)).toBe(true);47 const d = decode(r.raw) as Record<string, unknown>;48 expect(d).toMatchObject({ TransactionType: 'Payment', Account: keys.addresses.xrpl, Destination: DEST, Amount: '3000000', Fee: '12', Sequence: 5,49 LastLedgerSequence: 108249120, DestinationTag: 20261110, Flags: 0x80000000 });50 expect(Object.keys(d).length).toBe(11);51 const sdk = Wallet.fromMnemonic(TEST_MNEMONIC, { derivationPath: "m/44'/144'/0'/0/0" }).sign({ TransactionType: 'Payment', Account: i.from, Destination: DEST, Amount: '3000000', Fee: '12',52 Sequence: 5, LastLedgerSequence: 108249120, DestinationTag: 20261110, Flags: 0x80000000, SigningPubKey: String(d.SigningPubKey) });53 expect(r.raw).toBe(sdk.tx_blob);54 expect(r.txid).toBe(sdk.hash);55 });5657 it('refuses everything else', async () => {58 const { signer, pk, t } = await setup();59 const tryIt = (i: Intent) => signer.sign(i, pk.assert(intentHash(i)));60 const good = intent(t);61 const approval = pk.assert(intentHash(good));62 await expect(signer.sign({ ...good, amount: '3000001' }, approval)).rejects.toThrow(/approval/); // approval bound to other fields63 await expect(signer.sign({ ...good, memo: '1' }, approval)).rejects.toThrow(/approval/);64 await expect(signer.sign({ ...good, x: { ...X, sequence: '6' } }, approval)).rejects.toThrow(/approval/);65 await expect(tryIt(intent(t, { amount: '47000001' }))).rejects.toThrow(/per-transaction/);66 await expect(tryIt(intent(t, { maxFee: '100001', x: { ...X, fee: '100001' } }))).rejects.toThrow(/fee ceiling/);67 await expect(tryIt(intent(t, { x: { ...X, fee: '13' } }))).rejects.toThrow(/approved maximum/);68 await expect(tryIt(intent(t, { chainRef: NETWORKS.bitcoin.chainRef }))).rejects.toThrow(/chain/);69 await expect(tryIt(intent(t, { from: DEST, to: 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn' }))).rejects.toThrow(/source/);70 await expect(tryIt(intent(t, { to: keys.addresses.xrpl! }))).rejects.toThrow(/destination equals source/);71 await expect(tryIt(intent(t, { to: 'XVLhHMPHU98es4dbozjVtdWzVrDjtV5fdx1mHp98tDMoQXb' }))).rejects.toThrow(/X-addresses/);72 await expect(tryIt(intent(t, { asset: 'rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De' }))).rejects.toThrow(/only native XRP/);73 await expect(tryIt(intent(t, { memo: '4294967296' }))).rejects.toThrow(/destination tag/);74 await expect(tryIt(intent(t, { x: { ...X, flags: '131072' } }))).rejects.toThrow(/unknown or missing/);75 expect(() => tryIt(intent(t, { x: undefined }))).toThrow(/chain section/); // no approval can even be formed76 await expect(signer.sign(intent(t, { x: undefined }), approval)).rejects.toThrow();77 await expect(signer.sign(good, approval, { prevTx: {} })).rejects.toThrow(/helper data/);78 const r = await signer.sign(good, approval); // still usable, and only once79 expect(verifySignature(r.raw)).toBe(true);80 await expect(signer.sign(good, pk.assert(intentHash(good)))).rejects.toThrow(/already signed/);81 });82});83