SPB Git

Repository Public

ton.test.ts 6.4 KB 104 lines typescript

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
Files main
Menu

Theme