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 { Account, Deserializer, SignedTransaction, TransactionAuthenticatorEd25519, TransactionPayloadEntryFunction, generateSigningMessage } from '@aptos-labs/ts-sdk';7import { aptosRawBytes, aptosTxid, 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 of ours, never used outside tests.14const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';15const keys = deriveKeys(MNEMONIC);16const RP = 'wallet.test', ORIGIN = 'https://wallet.test';17const DEST = Account.fromDerivationPath({ path: "m/44'/637'/1'/0'/0'", mnemonic: MNEMONIC, legacy: true }).accountAddress.toStringLong();1819async function setup() {20 const dir = mkdtempSync(join(tmpdir(), 'spw-signer-apt-'));21 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));22 store.state.setupTokenHash = createHash('sha256').update('setup-token').digest('hex');23 const t = Math.floor(Date.now() / 1000);24 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t);25 const pk = new TestPasskey(RP, ORIGIN);26 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });27 return { store, signer, pk, t };28}29let n = 0;30const apt = (t: number, over: Partial<Intent> = {}): Intent => ({31 v: 1, proposalId: `6f1c1f0e-8f5f-4b1e-9c56-${String(++n).padStart(12, '0')}`, keyId: keys.keyId, createdAt: t, expiresAt: t + 120, policyVersion: 3,32 network: 'aptos', chainRef: NETWORKS.aptos.chainRef, asset: 'native', symbol: 'APT', decimals: 8, amount: '250000000', from: keys.addresses.aptos!, to: DEST, maxFee: '812200',33 x: { sequence_number: '7', max_gas_amount: '8122', gas_unit_price: '100', expiration_timestamp_secs: String(t + 180) }, ...over,34});3536describe('aptos through the signer', () => {37 it("derives the Petra / official SDK account from the mnemonic (m/44'/637'/0'/0'/0')", () => {38 expect(keys.addresses.aptos).toBe('0xeb663b681209e7087d681c5d3eed12aaa8e1915e7c87794542c3f96e94b3d3bf');39 expect(keys.addresses.aptos).toBe(Account.fromDerivationPath({ path: "m/44'/637'/0'/0'/0'", mnemonic: MNEMONIC, legacy: true }).accountAddress.toStringLong());40 });4142 it('signs an approved APT transfer; the official SDK decodes it and verifies the signature', async () => {43 const { signer, pk, t, store } = await setup();44 const i = apt(t);45 const r = await signer.sign(i, pk.assert(intentHash(i)));46 const bytes = aptosRawBytes(r.raw);47 expect(r.txid).toBe(aptosTxid(bytes));48 const d = new Deserializer(bytes), tx = SignedTransaction.deserialize(d);49 expect(d.remaining()).toBe(0);50 const raw = tx.raw_txn, ef = (raw.payload as TransactionPayloadEntryFunction).entryFunction;51 expect([raw.sender.toStringLong(), raw.sequence_number, raw.max_gas_amount, raw.gas_unit_price, raw.expiration_timestamp_secs, raw.chain_id.chainId]).toEqual([i.from, 7n, 8122n, 100n, BigInt(t + 180), 1]);52 expect([ef.module_name.address.toString(), ef.module_name.name.identifier, ef.function_name.identifier, ef.type_args.length, ef.args.length]).toEqual(['0x1', 'aptos_account', 'transfer', 0, 2]);53 expect('0x' + Buffer.from(ef.args[0]!.bcsToBytes()).toString('hex')).toBe(DEST);54 expect(Buffer.from(ef.args[1]!.bcsToBytes()).readBigUInt64LE()).toBe(250_000_000n);55 const auth = tx.authenticator as TransactionAuthenticatorEd25519;56 expect(tx.authenticator).toBeInstanceOf(TransactionAuthenticatorEd25519);57 expect(auth.public_key.authKey().derivedAddress().toStringLong()).toBe(keys.addresses.aptos);58 expect(auth.public_key.verifySignature({ message: generateSigningMessage(raw.bcsToBytes(), 'APTOS::RawTransaction'), signature: auth.signature })).toBe(true);59 expect(verifyAuditChain(store.state.journal)).toBeNull();60 await expect(signer.sign(i, pk.assert(intentHash(i)))).rejects.toThrow(/already signed/);61 });6263 it('an approval cannot be reused for other transaction parameters', async () => {64 const { signer, pk, t } = await setup();65 const ok = apt(t);66 for (const x of [{ sequence_number: '8' }, { max_gas_amount: '8121' }, { max_gas_amount: '8000', gas_unit_price: '101' }, { expiration_timestamp_secs: String(t + 181) }]) {67 const swapped = { ...ok, x: { ...ok.x!, ...x } };68 await expect(signer.sign(swapped, pk.assert(intentHash(ok))), JSON.stringify(x)).rejects.toThrow(/approval/);69 }70 });7172 it('refuses unsupported or out-of-policy Aptos intents before looking at the approval', async () => {73 const { signer, pk, t } = await setup();74 const cases: [Partial<Intent>, RegExp][] = [75 [{ chainRef: '2' }, /chain identity/],76 [{ from: DEST }, /not controlled/],77 [{ to: keys.addresses.aptos! }, /equals source/],78 [{ to: '0x' + DEST.slice(2).toUpperCase() }, /Aptos address/],79 [{ to: '0x' + '0'.repeat(63) + '1' }, /reserved/],80 [{ asset: '0x1::aptos_coin::AptosCoin' }, /native/],81 [{ memo: 'hello' }, /no memo/],82 [{ x: { ...apt(t).x!, max_gas_amount: '8123' } }, /above approved maximum/],83 [{ x: { ...apt(t).x!, gas_unit_price: '99' } }, /range/],84 [{ x: { ...apt(t).x!, function: '0x1::coin::transfer' } }, /unknown or missing/],85 [{ x: { ...apt(t).x!, expiration_timestamp_secs: String(t + 3600) } }, /too far/],86 [{ amount: '8800000001' }, /per-transaction limit/], // default cap: 88 APT87 [{ maxFee: '10000100', x: { ...apt(t).x!, max_gas_amount: '100001' } }, /fee ceiling/],88 ];89 for (const [over, re] of cases) { const i = apt(t, over); await expect(signer.sign(i, pk.assert(intentHash(i))), JSON.stringify(over)).rejects.toThrow(re); }90 });91});92