Repository Public
1// Cosmos Hub 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 { secp256k1 } from '@noble/curves/secp256k1.js';8import { decodePubkey, decodeTxRaw, DirectSecp256k1HdWallet, makeAuthInfoBytes, makeSignBytes, makeSignDoc, Registry } from '@cosmjs/proto-signing';9import { defaultRegistryTypes } from '@cosmjs/stargate';10import { TxRaw } from 'cosmjs-types/cosmos/tx/v1beta1/tx';11import { intentHash, NETWORKS, type Intent } from '@spw/core';12import { TestPasskey } from '../../../tests/helpers/passkey.ts';13import { deriveKeys } from '../src/keys.ts';14import { Signer } from '../src/signer.ts';15import { Store } from '../src/store.ts';1617// Public BIP-39 test vector mnemonic — never used outside tests.18const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';19const RP = 'wallet.test', ORIGIN = 'https://wallet.test';20const keys = deriveKeys(TEST_MNEMONIC);21const DEST = 'cosmos1wnacchusmacys78rxcr640gjpz000f87gnz6zf';22const X = { account_number: '1425847', sequence: '7', gas_limit: '200000', fee_amount: '1500', timeout_height: '33821256' };23const sdkWallet = await DirectSecp256k1HdWallet.fromMnemonic(TEST_MNEMONIC, { prefix: 'cosmos' });24const [sdkAccount] = await sdkWallet.getAccounts();25const registry = new Registry(defaultRegistryTypes);2627async function setup() {28 const dir = mkdtempSync(join(tmpdir(), 'spw-signer-cosmos-'));29 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));30 store.state.setupTokenHash = createHash('sha256').update('setup-token').digest('hex');31 const t = Math.floor(Date.now() / 1000);32 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t);33 const pk = new TestPasskey(RP, ORIGIN);34 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });35 return { signer, pk, t };36}37let seq = 0;38const 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 + 90,39 policyVersion: 3, network: 'cosmos', chainRef: NETWORKS.cosmos.chainRef, asset: 'native', symbol: 'ATOM', decimals: 6, amount: '3000000', from: keys.addresses.cosmos!, to: DEST, maxFee: '1500',40 memo: '104588310', x: X, ...over });4142describe('Cosmos Hub through the signer', () => {43 it("derives the published m/44'/118'/0'/0/0 address of the test mnemonic", () => {44 expect(keys.addresses.cosmos).toBe('cosmos19rl4cm2hmr8afy4kldpxz3fka4jguq0auqdal4');45 expect(keys.addresses.cosmos).toBe(sdkAccount!.address);46 });4748 it('signs an approved ATOM transfer that the official SDK decodes, reproduces byte for byte, and whose signature verifies', async () => {49 const { signer, pk, t } = await setup();50 const i = intent(t);51 const r = await signer.sign(i, pk.assert(intentHash(i)));52 const d = decodeTxRaw(Uint8Array.from(Buffer.from(r.raw, 'base64')));53 expect(d.body.messages.length).toBe(1);54 expect(d.body.messages[0]!.typeUrl).toBe('/cosmos.bank.v1beta1.MsgSend');55 expect(registry.decode(d.body.messages[0]!)).toEqual({ fromAddress: keys.addresses.cosmos, toAddress: DEST, amount: [{ denom: 'uatom', amount: '3000000' }] });56 expect(d.body).toMatchObject({ memo: '104588310', timeoutHeight: 33821256n, extensionOptions: [], nonCriticalExtensionOptions: [] });57 expect(d.authInfo.fee).toMatchObject({ amount: [{ denom: 'uatom', amount: '1500' }], gasLimit: 200000n, payer: '', granter: '' });58 expect(d.authInfo.signerInfos.length).toBe(1);59 expect(d.authInfo.signerInfos[0]!.sequence).toBe(7n);60 expect(decodePubkey(d.authInfo.signerInfos[0]!.publicKey!)).toEqual({ type: 'tendermint/PubKeySecp256k1', value: Buffer.from(sdkAccount!.pubkey).toString('base64') });6162 // the same transfer built and signed by cosmjs63 const bodyBytes = registry.encodeTxBody({ messages: [{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { fromAddress: i.from, toAddress: DEST, amount: [{ denom: 'uatom', amount: '3000000' }] } }],64 memo: '104588310', timeoutHeight: 33821256n });65 const authInfoBytes = makeAuthInfoBytes([{ pubkey: d.authInfo.signerInfos[0]!.publicKey!, sequence: 7 }], [{ denom: 'uatom', amount: '1500' }], 200000, undefined, undefined);66 const signDoc = makeSignDoc(bodyBytes, authInfoBytes, 'cosmoshub-4', 1425847);67 const { signature } = await sdkWallet.signDirect(i.from, signDoc);68 const sdkRaw = TxRaw.encode({ bodyBytes, authInfoBytes, signatures: [Uint8Array.from(Buffer.from(signature.signature, 'base64'))] }).finish();69 expect(r.raw).toBe(Buffer.from(sdkRaw).toString('base64'));70 expect(r.txid).toBe(createHash('sha256').update(sdkRaw).digest('hex').toUpperCase());71 expect(secp256k1.verify(d.signatures[0]!, createHash('sha256').update(makeSignBytes(signDoc)).digest(), sdkAccount!.pubkey, { prehash: false, lowS: true })).toBe(true);72 });7374 it('refuses everything else', async () => {75 const { signer, pk, t } = await setup();76 const tryIt = (i: Intent) => signer.sign(i, pk.assert(intentHash(i)));77 const good = intent(t);78 const approval = pk.assert(intentHash(good));79 await expect(signer.sign({ ...good, amount: '3000001' }, approval)).rejects.toThrow(/approval/); // approval bound to other fields80 await expect(signer.sign({ ...good, memo: '104588311' }, approval)).rejects.toThrow(/approval/);81 await expect(signer.sign({ ...good, memo: undefined }, approval)).rejects.toThrow(/approval/);82 await expect(signer.sign({ ...good, x: { ...X, sequence: '8' } }, approval)).rejects.toThrow(/approval/);83 await expect(signer.sign({ ...good, x: { ...X, timeout_height: '99999999' } }, approval)).rejects.toThrow(/approval/);84 await expect(tryIt(intent(t, { amount: '36000001' }))).rejects.toThrow(/per-transaction/);85 await expect(tryIt(intent(t, { maxFee: '50001', x: { ...X, fee_amount: '50001' } }))).rejects.toThrow(/fee ceiling/);86 await expect(tryIt(intent(t, { x: { ...X, fee_amount: '1501' } }))).rejects.toThrow(/approved maximum/);87 await expect(tryIt(intent(t, { chainRef: 'theta-testnet-001' }))).rejects.toThrow(/chain/);88 await expect(tryIt(intent(t, { from: DEST, to: 'cosmos1dkaw0wlutmwqfw7dyn4s5mlv7nj6xjax2a73q7' }))).rejects.toThrow(/source/);89 await expect(tryIt(intent(t, { to: keys.addresses.cosmos! }))).rejects.toThrow(/destination equals source/);90 await expect(tryIt(intent(t, { to: 'osmo19rl4cm2hmr8afy4kldpxz3fka4jguq0a5m7df8' }))).rejects.toThrow(/address/);91 await expect(tryIt(intent(t, { asset: 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2' }))).rejects.toThrow(/only native ATOM/);92 await expect(tryIt(intent(t, { memo: ' 104588310' }))).rejects.toThrow(/memo/);93 await expect(tryIt(intent(t, { x: { ...X, granter: DEST } }))).rejects.toThrow(/unknown or missing/);94 expect(() => tryIt(intent(t, { x: undefined }))).toThrow(/chain section/); // no approval can even be formed95 await expect(signer.sign(intent(t, { x: undefined }), approval)).rejects.toThrow();96 await expect(signer.sign(good, approval, { prevTx: {} })).rejects.toThrow(/helper data/);97 const r = await signer.sign(good, approval); // still usable, and only once98 expect(decodeTxRaw(Uint8Array.from(Buffer.from(r.raw, 'base64'))).signatures[0]!.length).toBe(64);99 await expect(signer.sign(good, pk.assert(intentHash(good)))).rejects.toThrow(/already signed/);100 });101});102