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 { bcs } from '@mysten/sui/bcs';7import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';8import { Transaction } from '@mysten/sui/transactions';9import { verifyTransactionSignature } from '@mysten/sui/verify';10import { intentHash, NETWORKS, suiParseRaw, verifyAuditChain, type Intent } from '@spw/core';11import { TestPasskey } from '../../../tests/helpers/passkey.ts';12import { deriveKeys } from '../src/keys.ts';13import { Signer } from '../src/signer.ts';14import { Store } from '../src/store.ts';1516// Public BIP-39 test vector mnemonic — holds no funds, never used outside tests.17const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';18const keys = deriveKeys(MNEMONIC);19const RP = 'wallet.test', ORIGIN = 'https://wallet.test';20const DEST = Ed25519Keypair.deriveKeypair(MNEMONIC, "m/44'/784'/1'/0'/0'").toSuiAddress();21const COIN = `0x${'1a'.repeat(32)}:1065912677:D5PWuxMQiHxxTp77DehNKAn4mNsUw8ifDwA7Efd5GVVg`;2223async function setup() {24 const dir = mkdtempSync(join(tmpdir(), 'spw-signer-sui-'));25 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));26 store.state.setupTokenHash = createHash('sha256').update('setup-token').digest('hex');27 const t = Math.floor(Date.now() / 1000);28 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t);29 const pk = new TestPasskey(RP, ORIGIN);30 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });31 return { store, signer, pk, t };32}33let n = 0;34const sui = (t: number, over: Partial<Intent> = {}): Intent => ({35 v: 1, proposalId: `6f1c1f0e-8f5f-4b1e-9c56-${String(++n).padStart(12, '0')}`, keyId: keys.keyId, createdAt: t, expiresAt: t + 120, policyVersion: 3,36 network: 'sui', chainRef: NETWORKS.sui.chainRef, asset: 'native', symbol: 'SUI', decimals: 9, amount: '2500000000', from: keys.addresses.sui!, to: DEST, maxFee: '4152000',37 x: { payment: COIN, gasPrice: '100', gasBudget: '4152000', expiration: '1308' }, ...over,38});3940describe('sui through the signer', () => {41 it("derives the address of the official SDK from the mnemonic (m/44'/784'/0'/0'/0')", () => {42 expect(keys.addresses.sui).toBe(Ed25519Keypair.deriveKeypair(MNEMONIC).toSuiAddress());43 expect(keys.addresses.sui).toBe('0x5e93a736d04fbb25737aa40bee40171ef79f65fae833749e3c089fe7cc2161f1');44 });4546 it('signs an approved SUI payment; the official SDK decodes it and verifies the signature', async () => {47 const { signer, pk, t, store } = await setup();48 const i = sui(t);49 const r = await signer.sign(i, pk.assert(intentHash(i)));50 const { tx, signature, txid } = suiParseRaw(r.raw);51 expect(txid).toBe(r.txid);52 expect(await Transaction.from(tx).getDigest()).toBe(r.txid);53 const v1 = bcs.TransactionData.parse(tx).V1!;54 expect([v1.sender, v1.gasData.owner, v1.gasData.price, v1.gasData.budget, v1.expiration.Epoch]).toEqual([i.from, i.from, '100', '4152000', 1308]);55 expect(v1.gasData.payment).toEqual([{ objectId: '0x' + '1a'.repeat(32), version: '1065912677', digest: 'D5PWuxMQiHxxTp77DehNKAn4mNsUw8ifDwA7Efd5GVVg' }]);56 const ptb = v1.kind.ProgrammableTransaction!;57 expect(ptb.inputs.map((x) => x.Pure?.bytes)).toEqual([Buffer.from(bcs.u64().serialize(2_500_000_000n).toBytes()).toString('base64'), Buffer.from(bcs.Address.serialize(DEST).toBytes()).toString('base64')]);58 expect(ptb.commands.map((c) => c.$kind)).toEqual(['SplitCoins', 'TransferObjects']);59 const who = await verifyTransactionSignature(tx, Buffer.from(signature).toString('base64'), { address: keys.addresses.sui! });60 expect(who.toSuiAddress()).toBe(i.from);61 // identical to the SDK signing the same bytes with the same mnemonic62 expect((await Ed25519Keypair.deriveKeypair(MNEMONIC).signTransaction(tx)).signature).toBe(Buffer.from(signature).toString('base64'));63 expect(verifyAuditChain(store.state.journal)).toBeNull();64 await expect(signer.sign(i, pk.assert(intentHash(i)))).rejects.toThrow(/already signed/);65 });6667 it('an approval cannot be reused for other gas coins, another budget or another epoch', async () => {68 const { signer, pk, t } = await setup();69 const i = sui(t);70 for (const x of [{ payment: COIN.replace(':1065912677:', ':1065912678:') }, { gasBudget: '4151999' }, { expiration: '1309' }, { gasPrice: '101' }])71 await expect(signer.sign({ ...i, x: { ...i.x!, ...x } }, pk.assert(intentHash(i)))).rejects.toThrow(/approval/);72 });7374 it('refuses unsupported or out-of-policy Sui intents before looking at the approval', async () => {75 const { signer, pk, t } = await setup();76 const cases: [Partial<Intent>, RegExp][] = [77 [{ chainRef: '69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD' }, /chain identity/],78 [{ from: DEST }, /not controlled/],79 [{ to: keys.addresses.sui! }, /equals source/],80 [{ to: '0x' + DEST.slice(2).toUpperCase() }, /Sui address/],81 [{ to: '0x' + '0'.repeat(63) + '5' }, /reserved/],82 [{ asset: '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' }, /native/],83 [{ memo: '42' }, /memo/],84 [{ x: { ...sui(t).x!, gasBudget: '4152001' } }, /above approved maximum/],85 [{ x: { ...sui(t).x!, sponsor: DEST } }, /unknown or missing/],86 [{ x: { ...sui(t).x!, payment: `${COIN},${COIN}` } }, /duplicate/],87 [{ amount: '60000000001' }, /per-transaction limit/], // default cap: 60 SUI88 [{ maxFee: '50000001', x: { ...sui(t).x!, gasBudget: '50000001' } }, /fee ceiling/],89 ];90 for (const [over, re] of cases) { const i = sui(t, over); await expect(signer.sign(i, pk.assert(intentHash(i))), JSON.stringify(over)).rejects.toThrow(re); }91 });92});93