SPB Git

Repository Public

cosmos.test.ts 7.5 KB 102 lines typescript

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

Theme