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 { Address, beginCell, Cell, comment, loadMessage, loadMessageRelaxed } from '@ton/core';7import { signVerify } from '@ton/crypto';8import { WalletContractV4 } from '@ton/ton';9import { intentHash, NETWORKS, verifyAuditChain, type Intent } from '@spw/core';10import { TestPasskey } from '../../../tests/helpers/passkey.ts';11import { deriveKeys } from '../src/keys.ts';12import { Signer } from '../src/signer.ts';13import { Store } from '../src/store.ts';1415// Public BIP-39 test vector mnemonic — holds no funds, never used outside tests.16const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');17const RP = 'wallet.test', ORIGIN = 'https://wallet.test';18const PUB = Buffer.from(keys.handle(NETWORKS.ton).publicKey);19const wallet = WalletContractV4.create({ workchain: 0, publicKey: PUB });20const DEST = 'UQCD39VS5jcptHL8vMjEXrzGaRcCVYto7HUn4bpAOg8xqEBI'; // TON Foundation wallet, canonical (non-bounceable) form2122async function setup() {23 const dir = mkdtempSync(join(tmpdir(), 'spw-signer-ton-'));24 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));25 store.state.setupTokenHash = createHash('sha256').update('setup-token').digest('hex');26 const t = Math.floor(Date.now() / 1000);27 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t);28 const pk = new TestPasskey(RP, ORIGIN);29 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });30 return { store, signer, pk, t };31}32let n = 0;33const ton = (t: number, over: Partial<Intent> = {}): Intent => ({34 v: 1, proposalId: `6f1c1f0e-8f5f-4b1e-9c56-${String(++n).padStart(12, '0')}`, keyId: keys.keyId, createdAt: t, expiresAt: t + 120, policyVersion: 3,35 network: 'ton', chainRef: NETWORKS.ton.chainRef, asset: 'native', symbol: 'TON', decimals: 9, amount: '2500000000', from: keys.addresses.ton!, to: DEST, maxFee: '5000000',36 memo: 'order 4242', x: { seqno: '12', validUntil: String(t + 300), bounce: '1', sendMode: '3', stateInit: '0' }, ...over,37});38/** decode a signed external message with the official SDK */39function decode(raw: string) {40 const msg = loadMessage(Cell.fromBase64(raw).beginParse());41 if (msg.info.type !== 'external-in') throw new Error('not external-in');42 const s = msg.body.beginParse();43 const signature = s.loadBuffer(64), signingHash = beginCell().storeSlice(s).endCell().hash();44 const head = { subwallet: s.loadUint(32), validUntil: s.loadUint(32), seqno: s.loadUint(32), op: s.loadUint(8), mode: s.loadUint(8), refs: s.remainingRefs };45 const out = loadMessageRelaxed(s.loadRef().beginParse());46 if (out.info.type !== 'internal') throw new Error('not internal');47 return { dest: msg.info.dest, init: msg.init ?? null, signature, signingHash, head, out: out.info, body: out.body };48}4950describe('ton through the signer', () => {51 it('derives the v4R2 wallet of the mnemonic at m/44\'/607\'/0\' (same address as the official SDK)', () => {52 expect(keys.addresses.ton).toBe('UQAzWZa6nM5mJev91wGc7VCSfBoIsYRqKJpV78N8Add9-RKY');53 expect(keys.addresses.ton).toBe(wallet.address.toString({ bounceable: false }));54 });5556 it('signs an approved transfer; the official SDK decodes it and the signature verifies', async () => {57 const { signer, pk, t, store } = await setup();58 const i = ton(t);59 const r = await signer.sign(i, pk.assert(intentHash(i)));60 const d = decode(r.raw);61 expect(d.dest.equals(wallet.address)).toBe(true);62 expect(d.init).toBeNull();63 expect(d.head).toEqual({ subwallet: 698983191, validUntil: t + 300, seqno: 12, op: 0, mode: 3, refs: 1 });64 expect([d.out.dest.equals(Address.parse(DEST)), d.out.value.coins, d.out.bounce]).toEqual([true, 2_500_000_000n, true]);65 expect(d.body.equals(comment('order 4242'))).toBe(true);66 expect(signVerify(d.signingHash, d.signature, PUB)).toBe(true);67 expect(r.txid).toMatch(/^[0-9a-f]{64}$/);68 expect(verifyAuditChain(store.state.journal)).toBeNull();69 await expect(signer.sign(i, pk.assert(intentHash(i)))).rejects.toThrow(/already signed/);70 });7172 it('attaches the wallet StateInit only when the approved intent says so, and the bounce flag is part of the approval', async () => {73 const { signer, pk, t } = await setup();74 const { memo: _m, ...rest } = ton(t);75 const first: Intent = { ...rest, x: { ...rest.x!, seqno: '0', stateInit: '1', bounce: '0' } };76 const d = decode((await signer.sign(first, pk.assert(intentHash(first)))).raw);77 expect(d.init!.code!.equals(wallet.init.code) && d.init!.data!.equals(wallet.init.data)).toBe(true);78 expect([d.head.seqno, d.out.bounce, d.body.equals(Cell.EMPTY)]).toEqual([0, false, true]);79 // an approval given for a bounceable transfer cannot be reused for a non-bounceable one, nor for another seqno80 const a = ton(t);81 await expect(signer.sign({ ...a, x: { ...a.x!, bounce: '0' } }, pk.assert(intentHash(a)))).rejects.toThrow(/approval/);82 await expect(signer.sign({ ...a, x: { ...a.x!, seqno: '13' } }, pk.assert(intentHash(a)))).rejects.toThrow(/approval/);83 });8485 it('refuses unsupported or out-of-policy TON intents before looking at the approval', async () => {86 const { signer, pk, t } = await setup();87 const cases: [Partial<Intent>, RegExp][] = [88 [{ chainRef: '823f81f306ff02694f935cf5021548e3ce2b86b529812af6a12148879e95a128' }, /chain identity/], // testnet zero state89 [{ from: DEST }, /not controlled/],90 [{ to: keys.addresses.ton! }, /equals source/],91 [{ to: Address.parse(DEST).toString({ bounceable: true }) }, /canonical/],92 [{ to: Address.parse(DEST).toString({ bounceable: false, testOnly: true }) }, /testnet/],93 [{ asset: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs' }, /native/], // USDT jetton master94 [{ x: { ...ton(t).x!, sendMode: '128' } }, /sendMode/],95 [{ x: { ...ton(t).x!, stateInit: '1' } }, /first message/],96 [{ x: { ...ton(t).x!, extra: '1' } }, /unknown or missing/],97 [{ x: { ...ton(t).x!, validUntil: String(t + 3600) } }, /too far/],98 [{ amount: '45000000001' }, /per-transaction limit/], // default cap: 45 TON99 [{ maxFee: '50000001' }, /fee ceiling/],100 ];101 for (const [over, re] of cases) { const i = ton(t, over); await expect(signer.sign(i, pk.assert(intentHash(i))), JSON.stringify(over)).rejects.toThrow(re); }102 });103});104