SPB Git

Repository Public

cosmos.test.ts 25.3 KB 328 lines typescript

1// Cosmos SDK plugin (Cosmos Hub), proven against the official JavaScript SDK: @cosmjs/proto-signing + @cosmjs/stargate 0.39.02// (protobuf codecs from cosmjs-types 0.11.0).3import { describe, expect, it } from 'vitest';4import fc from 'fast-check';5import { secp256k1 } from '@noble/curves/secp256k1.js';6import { sha256 } from '@noble/hashes/sha2.js';7import { HDKey } from '@scure/bip32';8import { mnemonicToSeedSync } from '@scure/bip39';9import { decodePubkey, decodeTxRaw, DirectSecp256k1HdWallet, DirectSecp256k1Wallet, encodePubkey, makeAuthInfoBytes, makeSignBytes, makeSignDoc, Registry } from '@cosmjs/proto-signing';10import { defaultRegistryTypes } from '@cosmjs/stargate';11import { SignMode } from 'cosmjs-types/cosmos/tx/signing/v1beta1/signing';12import { TxRaw } from 'cosmjs-types/cosmos/tx/v1beta1/tx';13import {14  canonicalBytes, COSMOS_MAX_GAS, cosmosAddressBytes, cosmosAddressFromPublicKey, cosmosMemo, cosmosPlugin, cosmosSignDoc, cosmosSimulationTx, cosmosTxid,15  DEFAULT_POLICY, intentHash, isValidAddress, NETWORKS, normalizeAddress, pluginFor, toHex, type Intent, type KeyHandle, type NetworkDef,16} from '../src/index.ts';1718// Public BIP-39 test vector mnemonic — holds no funds of ours, never used outside tests.19const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';20/** Address of that mnemonic at m/44'/118'/0'/0/0 as published by the usual tools; it exists on cosmoshub-4 (account 1425847) with the public key below. */21const VECTOR_ADDRESS = 'cosmos19rl4cm2hmr8afy4kldpxz3fka4jguq0auqdal4', VECTOR_PUBKEY_B64 = 'Ak9OKtmcNNYLm6YoPJQxqEGK+GcyEpYfl6d7Y3f80Fti';22const net = NETWORKS.cosmos;23const node = HDKey.fromMasterSeed(mnemonicToSeedSync(TEST_MNEMONIC)).derive(cosmosPlugin.path(net));24const priv = node.privateKey!, pub = node.publicKey!;25/** Same shape as the signer's key handle (services/isolated-signer/src/keys.ts). */26const handleOf = (sk: Uint8Array): KeyHandle => ({ curve: 'secp256k1', publicKey: secp256k1.getPublicKey(sk, true), signDigest: (digest) => {27  const sig = secp256k1.sign(digest, sk, { prehash: false, lowS: true, format: 'recovered' });28  const compact = sig.slice(1);29  return { compact, recovery: sig[0]!, der: secp256k1.Signature.fromBytes(compact, 'compact').toBytes('der') };30} });31const key = handleOf(priv);32const sdkWallet = await DirectSecp256k1HdWallet.fromMnemonic(TEST_MNEMONIC, { prefix: 'cosmos' }); // cosmjs default path = m/44'/118'/0'/0/033const [sdkAccount] = await sdkWallet.getAccounts();34const registry = new Registry(defaultRegistryTypes);3536const DEST = 'cosmos1wnacchusmacys78rxcr640gjpz000f87gnz6zf', DEST2 = 'cosmos1dkaw0wlutmwqfw7dyn4s5mlv7nj6xjax2a73q7';37const now = 1_800_000_000;38const X = { account_number: '1425847', sequence: '7', gas_limit: '200000', fee_amount: '1500', timeout_height: '33821256' };39const base: Intent = { v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', createdAt: now, expiresAt: now + 90, policyVersion: 3,40  network: 'cosmos', chainRef: net.chainRef, asset: 'native', symbol: 'ATOM', decimals: 6, amount: '2500000', from: VECTOR_ADDRESS, to: DEST, maxFee: '1500', x: X };41const withMemo: Intent = { ...base, memo: '104588310' };42const B64 = (b: Uint8Array) => Buffer.from(b).toString('base64');43const bytes = (b64: string) => Uint8Array.from(Buffer.from(b64, 'base64'));4445/** The same transfer as cosmjs builds it: TxBody, AuthInfo and SignDoc bytes. */46function sdkDoc(i: Intent, n: NetworkDef = net, publicKey: Uint8Array = pub) {47  const bodyBytes = registry.encodeTxBody({48    messages: [{ typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: { fromAddress: i.from, toAddress: i.to, amount: [{ denom: n.cosmos!.denom, amount: i.amount }] } }],49    memo: i.memo ?? '', timeoutHeight: BigInt(i.x!.timeout_height!) });50  const authInfoBytes = makeAuthInfoBytes([{ pubkey: encodePubkey({ type: 'tendermint/PubKeySecp256k1', value: B64(publicKey) }), sequence: BigInt(i.x!.sequence!) }],51    [{ denom: n.cosmos!.denom, amount: i.x!.fee_amount! }], Number(i.x!.gas_limit), undefined, undefined, SignMode.SIGN_MODE_DIRECT);52  const signDoc = makeSignDoc(bodyBytes, authInfoBytes, n.chainRef, Number(i.x!.account_number));53  return { bodyBytes, authInfoBytes, signDoc, signBytes: makeSignBytes(signDoc) };54}55/** cosmjs signs the SignDoc and assembles TxRaw. */56async function sdkSign(i: Intent, wallet: { signDirect: DirectSecp256k1HdWallet['signDirect'] } = sdkWallet, n: NetworkDef = net, publicKey: Uint8Array = pub) {57  const d = sdkDoc(i, n, publicKey);58  const { signature, signed } = await wallet.signDirect(i.from, d.signDoc);59  const raw = TxRaw.encode({ bodyBytes: signed.bodyBytes, authInfoBytes: signed.authInfoBytes, signatures: [bytes(signature.signature)] }).finish();60  return { raw: B64(raw), txid: toHex(sha256(raw)).toUpperCase(), signature: bytes(signature.signature) };61}6263describe('cosmos: registration', () => {64  it('is registered as a plugin family with the pinned network definition and policy', () => {65    expect(pluginFor(net)).toBe(cosmosPlugin);66    expect(net).toMatchObject({ id: 'cosmos', family: 'cosmos', name: 'Cosmos Hub', chainRef: 'cosmoshub-4', coinType: 118, memo: 'text', cosmos: { bech32: 'cosmos', denom: 'uatom' } });67    expect(net.activation).toBeUndefined();68    expect(net.native).toEqual({ id: 'native', symbol: 'ATOM', name: 'Cosmos', decimals: 6 });69    expect(net.tokens).toEqual([]);70    expect(net.rpc.length).toBe(2);71    expect(new Set(net.rpc.map((u) => new URL(u).host.split('.').slice(-2).join('.'))).size).toBe(2);72    expect(DEFAULT_POLICY.perTx['cosmos:ATOM']).toBe('36000000');73    expect(DEFAULT_POLICY.daily['cosmos:ATOM']).toBe('72000000');74    expect(DEFAULT_POLICY.maxFee.cosmos).toBe('50000');75    expect(Object.values(NETWORKS).filter((n) => n.family === 'cosmos').map((n) => n.id)).toEqual(['cosmos']); // only Cosmos Hub is registered76  });77});7879describe('cosmos: keys and addresses', () => {80  it("derives m/44'/118'/0'/0/0 of the public test mnemonic to the published address (and to what cosmjs derives)", () => {81    expect(cosmosPlugin.curve).toBe('secp256k1');82    expect(cosmosPlugin.path(net)).toBe("m/44'/118'/0'/0/0");83    expect(sdkAccount!.address).toBe(VECTOR_ADDRESS); // the published vector, confirmed by the official SDK84    expect(cosmosPlugin.address(net, pub)).toBe(VECTOR_ADDRESS);85    expect(B64(pub)).toBe(VECTOR_PUBKEY_B64);86    expect(B64(sdkAccount!.pubkey)).toBe(VECTOR_PUBKEY_B64);87    expect(cosmosAddressFromPublicKey('cosmos', pub)).toBe(VECTOR_ADDRESS);88  });89  it('matches cosmjs for random keys and prefixes (property)', async () => {90    await fc.assert(fc.asyncProperty(fc.uint8Array({ minLength: 32, maxLength: 32 }), fc.constantFrom('cosmos', 'osmo', 'celestia'), async (seed, prefix) => {91      fc.pre(secp256k1.utils.isValidSecretKey(seed));92      const [a] = await (await DirectSecp256k1Wallet.fromKey(seed, prefix)).getAccounts();93      const mine = cosmosAddressFromPublicKey(prefix, secp256k1.getPublicKey(seed, true));94      return mine === a!.address && toHex(cosmosAddressBytes(prefix, mine)).length === 40;95    }), { numRuns: 40 });96  });97  it('accepts canonical account addresses of this chain only', () => {98    expect(normalizeAddress('cosmos', DEST)).toBe(DEST);99    expect(normalizeAddress('cosmos', ` ${DEST} `)).toBe(DEST); // the shared entry point trims100    expect(cosmosPlugin.normalizeAddress(net, VECTOR_ADDRESS)).toBe(VECTOR_ADDRESS);101    expect(isValidAddress('cosmos', DEST2)).toBe(true);102    expect(() => cosmosPlugin.normalizeAddress(net, ` ${DEST}`)).toThrow();103    expect(() => normalizeAddress('cosmos', DEST.toUpperCase())).toThrow(); // valid bech32, but not the normalized form104    expect(() => normalizeAddress('cosmos', DEST.slice(0, -1) + (DEST.endsWith('f') ? 'g' : 'f'))).toThrow(/checksum/);105    expect(() => normalizeAddress('cosmos', 'cosmos1Wnacchusmacys78rxcr640gjpz000f87gnz6zf')).toThrow();106    expect(() => normalizeAddress('cosmos', cosmosAddressFromPublicKey('osmo', pub))).toThrow(/cosmos1/); // same key, another chain's prefix107    expect(() => normalizeAddress('cosmos', cosmosAddressFromPublicKey('cosmosvaloper', pub))).toThrow(); // validator operator address108    expect(() => normalizeAddress('cosmos', 'cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsgcqu0')).toThrow(); // 32-byte (module / interchain account) address109    expect(() => normalizeAddress('cosmos', 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toThrow();110    expect(() => normalizeAddress('cosmos', '0x70997970C51812dc3A010C7d01b50e0d17dc79C8')).toThrow();111    expect(() => normalizeAddress('cosmos', '')).toThrow();112    expect(() => normalizeAddress('bitcoin', DEST)).toThrow();113    expect(() => cosmosPlugin.address(net, pub.slice(1))).toThrow(/33-byte/);114    expect(() => cosmosPlugin.address(NETWORKS.bitcoin, pub)).toThrow(/not a Cosmos/);115    expect(() => cosmosPlugin.path(NETWORKS.xrpl)).toThrow(/not a Cosmos/);116  });117});118119describe('cosmos: transaction bytes against cosmjs', () => {120  const cases: Intent[] = [base, withMemo, { ...base, memo: 'Dépôt №7 — ok ✅' },121    { ...base, x: { ...X, account_number: '0', sequence: '0' } }, // proto3 omits zero-valued fields122    { ...base, amount: '9007199254740993000000', maxFee: '50000', x: { account_number: '9223372036854775807', sequence: '9223372036854775807', gas_limit: COSMOS_MAX_GAS.toString(), fee_amount: '50000', timeout_height: '9223372036854775807' } }];123  it('SignDoc bytes are byte-identical to makeSignBytes(makeSignDoc(...)) of cosmjs', () => {124    for (const i of cases) {125      const mine = cosmosSignDoc(i, net, pub);126      const sdk = i.x!.account_number!.length > 15 ? null : sdkDoc(i); // cosmjs takes the account number as a JS number127      if (sdk) expect(toHex(mine)).toBe(toHex(sdk.signBytes));128      // build() with the (verified) public key returns exactly those bytes; without it, the same document minus the key129      expect(toHex(cosmosPlugin.build(i, net, { publicKey: toHex(pub) }).signDocs[0]!)).toBe(toHex(mine));130      expect(cosmosPlugin.build(i, net, { publicKey: toHex(pub) }).signDocs.length).toBe(1);131      expect(toHex(cosmosPlugin.build(i, net).signDocs[0]!)).not.toBe(toHex(mine));132    }133  });134  it('the signed TxRaw is byte-identical to cosmjs signDirect + TxRaw, decodes to the intent fields, and the signature verifies', async () => {135    for (const i of cases.slice(0, 4)) {136      const mine = cosmosPlugin.sign(i, net, key);137      const sdk = await sdkSign(i);138      expect(mine.raw).toBe(sdk.raw);139      expect(mine.txid).toBe(sdk.txid);140      expect(mine.txid).toMatch(/^[0-9A-F]{64}$/);141      expect(cosmosTxid(mine.raw)).toBe(mine.txid);142143      const raw = bytes(mine.raw), d = decodeTxRaw(raw);144      expect(d.body.messages.length).toBe(1);145      expect(d.body.messages[0]!.typeUrl).toBe('/cosmos.bank.v1beta1.MsgSend');146      expect(registry.decode(d.body.messages[0]!)).toEqual({ fromAddress: VECTOR_ADDRESS, toAddress: DEST, amount: [{ denom: 'uatom', amount: i.amount }] });147      expect(d.body.memo).toBe(i.memo ?? '');148      expect(d.body.timeoutHeight).toBe(BigInt(i.x!.timeout_height!));149      expect([d.body.extensionOptions, d.body.nonCriticalExtensionOptions]).toEqual([[], []]);150      expect(d.authInfo.signerInfos.length).toBe(1);151      const s = d.authInfo.signerInfos[0]!;152      expect(decodePubkey(s.publicKey!)).toEqual({ type: 'tendermint/PubKeySecp256k1', value: VECTOR_PUBKEY_B64 });153      expect(s.modeInfo).toEqual({ single: { mode: SignMode.SIGN_MODE_DIRECT }, multi: undefined });154      expect(s.sequence).toBe(BigInt(i.x!.sequence!));155      expect(d.authInfo.fee).toMatchObject({ amount: [{ denom: 'uatom', amount: i.x!.fee_amount }], gasLimit: BigInt(i.x!.gas_limit!), payer: '', granter: '' });156      expect(d.authInfo.tip).toBeUndefined();157      // canonical for the official codec: decoding and re-encoding every layer gives the same bytes158      const t = TxRaw.decode(raw);159      expect(B64(TxRaw.encode(t).finish())).toBe(mine.raw);160      expect(toHex(registry.encodeTxBody({ messages: [{ typeUrl: d.body.messages[0]!.typeUrl, value: registry.decode(d.body.messages[0]!) }], memo: d.body.memo, timeoutHeight: d.body.timeoutHeight }))).toBe(toHex(t.bodyBytes));161      // signature: 64 bytes r||s, low-S, over sha256(SignDoc), verified with noble against the public key and equal to the SDK's162      expect(d.signatures.length).toBe(1);163      const sig = d.signatures[0]!, digest = sha256(cosmosSignDoc(i, net, pub));164      expect(sig.length).toBe(64);165      expect(toHex(sig)).toBe(toHex(sdk.signature));166      expect(secp256k1.verify(sig, digest, pub, { prehash: false, lowS: true })).toBe(true);167      expect(toHex(digest)).toBe(toHex(sha256(sdkDoc(i).signBytes)));168    }169  });170  it('matches cosmjs for arbitrary transfers (property)', async () => {171    const u53 = fc.bigInt({ min: 0n, max: 9_007_199_254_740_991n });172    const memo = fc.option(fc.string({ unit: 'grapheme', minLength: 1, maxLength: 40 }).filter((m) => { try { cosmosMemo(m); return true; } catch { return false; } }), { nil: undefined });173    await fc.assert(fc.asyncProperty(fc.bigInt({ min: 1n, max: 10n ** 30n }), fc.bigInt({ min: 1n, max: 50_000n }), u53, u53, fc.bigInt({ min: 1n, max: COSMOS_MAX_GAS }), fc.bigInt({ min: 1n, max: 1n << 62n }), memo,174      fc.uint8Array({ minLength: 32, maxLength: 32 }), async (amount, fee, accountNumber, sequence, gas, timeout, m, destSeed) => {175        fc.pre(secp256k1.utils.isValidSecretKey(destSeed));176        const to = cosmosAddressFromPublicKey('cosmos', secp256k1.getPublicKey(destSeed, true));177        const i: Intent = { ...base, amount: amount.toString(), to, maxFee: fee.toString(), ...(m !== undefined ? { memo: m } : {}),178          x: { account_number: accountNumber.toString(), sequence: sequence.toString(), gas_limit: gas.toString(), fee_amount: fee.toString(), timeout_height: timeout.toString() } };179        const mine = cosmosPlugin.sign(i, net, key), sdk = await sdkSign(i);180        return toHex(cosmosSignDoc(i, net, pub)) === toHex(sdkDoc(i).signBytes) && mine.raw === sdk.raw && mine.txid === sdk.txid;181      }), { numRuns: 60 });182  });183  it('the simulation form is the same transfer without public key and with an empty signature', () => {184    for (const i of [base, withMemo]) {185      const sim = decodeTxRaw(bytes(cosmosSimulationTx(i, net))), signed = decodeTxRaw(bytes(cosmosPlugin.sign(i, net, key).raw));186      expect(sim.body).toEqual(signed.body);187      expect(sim.authInfo.fee).toEqual(signed.authInfo.fee);188      expect(sim.authInfo.signerInfos.length).toBe(1);189      expect(sim.authInfo.signerInfos[0]!.publicKey).toBeUndefined();190      expect(sim.authInfo.signerInfos[0]!.sequence).toBe(7n);191      expect(sim.signatures.map((s) => s.length)).toEqual([0]);192    }193    expect(() => cosmosSimulationTx({ ...base, amount: '0' }, net)).toThrow(/positive/);194    expect(() => cosmosSimulationTx({ ...base, maxFee: '1' }, net)).toThrow(/approved maximum/);195  });196  it('chain parameters come from the network definition: another Cosmos SDK chain is a registry entry', async () => {197    // not registered anywhere: only shows that chain id, prefix and denom are data, with the same proof against cosmjs198    const osmo: NetworkDef = { ...net, id: 'osmosis', name: 'Osmosis', chainRef: 'osmosis-1', native: { id: 'native', symbol: 'OSMO', name: 'Osmosis', decimals: 6 }, cosmos: { bech32: 'osmo', denom: 'uosmo' } };199    const from = cosmosPlugin.address(osmo, pub), to = cosmosAddressFromPublicKey('osmo', secp256k1.getPublicKey(new Uint8Array(32).fill(9), true));200    expect(from).toBe((await (await DirectSecp256k1HdWallet.fromMnemonic(TEST_MNEMONIC, { prefix: 'osmo' })).getAccounts())[0]!.address);201    const i: Intent = { ...withMemo, network: 'osmosis', chainRef: 'osmosis-1', symbol: 'OSMO', from, to };202    const w = await DirectSecp256k1Wallet.fromKey(priv, 'osmo');203    expect(toHex(cosmosSignDoc(i, osmo, pub))).toBe(toHex(sdkDoc(i, osmo).signBytes));204    expect(cosmosPlugin.sign(i, osmo, key).raw).toBe((await sdkSign(i, w, osmo)).raw);205    // the same intent fields on the two chains never share a signature: chain id, denom and addresses differ206    expect(() => cosmosPlugin.sign({ ...i, from: VECTOR_ADDRESS }, osmo, key)).toThrow(/osmo1/);207    expect(() => cosmosPlugin.sign(base, osmo, key)).toThrow(/not for this network/);208    expect(() => cosmosPlugin.build(i, { ...osmo, cosmos: undefined })).toThrow(/not a Cosmos/);209    expect(() => cosmosPlugin.build(i, { ...osmo, cosmos: { bech32: 'Osmo', denom: 'uosmo' } })).toThrow(/invalid Cosmos SDK network/);210  });211  it('is deterministic and the intent hash covers the chain parameters and the memo', () => {212    expect(cosmosPlugin.sign(base, net, key)).toEqual(cosmosPlugin.sign(base, net, key));213    const h = toHex(intentHash(base));214    for (const m of [{ memo: '1' }, { x: { ...X, sequence: '8' } }, { x: { ...X, account_number: '1425848' } }, { x: { ...X, timeout_height: '33821257' } }, { x: { ...X, gas_limit: '200001' } },215      { x: { ...X, fee_amount: '1499' } }, { amount: '2500001' }, { to: DEST2 }])216      expect(toHex(intentHash({ ...base, ...m }))).not.toBe(h);217    expect(canonicalBytes(base).length).toBeGreaterThan(0);218    expect(() => canonicalBytes({ ...base, x: undefined })).toThrow(/chain section/);219  });220});221222describe('cosmos: fail closed', () => {223  const bad = (m: Partial<Intent>, aux?: Record<string, unknown>) => () => cosmosPlugin.build({ ...base, ...m }, net, aux);224  it('refuses another chain, another family, foreign sections', () => {225    expect(bad({ chainRef: 'cosmoshub-3' })).toThrow(/wrong chain/);226    expect(bad({ chainRef: 'theta-testnet-001' })).toThrow(/wrong chain/);227    expect(bad({ chainRef: NETWORKS.bitcoin.chainRef })).toThrow(/wrong chain/);228    expect(bad({ network: 'solana' })).toThrow(/not for this/);229    expect(() => cosmosPlugin.build(base, NETWORKS.bitcoin)).toThrow(/not a Cosmos/);230    expect(() => cosmosPlugin.build(base, NETWORKS.xrpl)).toThrow(/not a Cosmos/);231    expect(() => cosmosPlugin.build(base, { ...net, chainRef: 'osmosis-1' })).toThrow(/wrong chain/);232    expect(bad({ evm: { nonce: 0, gasLimit: '21000', maxFeePerGas: '1', maxPriorityFeePerGas: '1' } })).toThrow(/foreign/);233    expect(bad({ btc: { inputs: [{ txid: 'aa'.repeat(32), vout: 0, value: '1' }], change: '0', fee: '1' } })).toThrow(/foreign/);234  });235  it('refuses anything but the native coin', () => {236    expect(bad({ asset: 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2' })).toThrow(/only native ATOM/);237    expect(bad({ asset: 'uatom' })).toThrow(/only native ATOM/);238    expect(bad({ symbol: 'OSMO' })).toThrow(/only native ATOM/);239    expect(bad({ decimals: 18 })).toThrow(/only native ATOM/);240  });241  it('refuses invalid, non-normalized and foreign addresses and a self-send', () => {242    expect(bad({ to: ` ${DEST}` })).toThrow(/invalid address/);243    expect(bad({ to: DEST.toUpperCase() })).toThrow(/invalid address/);244    expect(bad({ to: DEST.slice(0, -1) + 'q' })).toThrow(/checksum/);245    expect(bad({ from: VECTOR_ADDRESS.slice(0, -2) + 'qq' })).toThrow(/checksum/);246    expect(bad({ to: cosmosAddressFromPublicKey('osmo', pub) })).toThrow(/invalid address/);247    expect(bad({ to: cosmosAddressFromPublicKey('cosmosvaloper', pub) })).toThrow(/invalid address/);248    expect(bad({ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' })).toThrow(/invalid address/);249    expect(bad({ to: VECTOR_ADDRESS })).toThrow(/destination equals source/);250  });251  it('refuses bad amounts, fees and gas', () => {252    expect(bad({ amount: '0' })).toThrow(/amount must be positive/);253    expect(bad({ amount: '-1' })).toThrow();254    expect(bad({ amount: '1.5' })).toThrow();255    expect(bad({ amount: '01' })).toThrow();256    expect(bad({ maxFee: '1499' })).toThrow(/fee above the approved maximum/);257    expect(bad({ x: { ...X, fee_amount: '1501' } })).toThrow(/fee above the approved maximum/);258    expect(bad({ x: { ...X, fee_amount: '0' } })).toThrow(/fee must be positive/);259    expect(bad({ x: { ...X, fee_amount: '01500' } })).toThrow(/invalid fee_amount/);260    expect(bad({ x: { ...X, gas_limit: '0' } })).toThrow(/gas_limit out of range/);261    expect(bad({ x: { ...X, gas_limit: (COSMOS_MAX_GAS + 1n).toString() } })).toThrow(/gas_limit out of range/);262    expect(bad({ x: { ...X, timeout_height: '0' } })).toThrow(/timeout_height is required/);263  });264  it('refuses unknown, missing or malformed chain parameters and memos', () => {265    expect(bad({ x: undefined })).toThrow(/missing/);266    expect(bad({ x: { ...X, granter: DEST2 } })).toThrow(/unknown or missing/); // e.g. an attempt to make someone else pay the fee267    expect(bad({ x: { ...X, fee_denom: 'ibc/ABC' } })).toThrow(/unknown or missing/);268    expect(bad({ x: { ...X, unordered: '1' } })).toThrow(/unknown or missing/);269    const { timeout_height: _t, ...noTimeout } = X;270    expect(bad({ x: noTimeout })).toThrow(/unknown or missing/);271    expect(bad({ x: { ...noTimeout, timeoutHeight: '33821256' } })).toThrow(/unknown or missing/);272    for (const k of Object.keys(X) as (keyof typeof X)[])273      for (const v of ['-1', '9223372036854775808', '1e3', '0x10', '', ' 5', '05', '1.0', '18446744073709551616'])274        expect(bad({ x: { ...X, [k]: v } }), `${k} ${JSON.stringify(v)}`).toThrow();275    for (const v of ['', ' 104588310', '104588310 ', 'a\nb', 'tab\there', '\u0000', 'x'.repeat(257), '\ud800'])276      expect(bad({ memo: v }), `memo ${JSON.stringify(v)}`).toThrow(/memo/);277    expect(bad({ memo: 'é'.repeat(128) })).not.toThrow(); // 256 bytes278    expect(bad({ memo: 'é'.repeat(129) })).toThrow(/too long/);279    expect(cosmosMemo(undefined)).toBe('');280    expect(cosmosMemo('104588310')).toBe('104588310');281  });282  it('accepts helper data only when it is the matching public key', () => {283    expect(bad({}, { publicKey: toHex(pub) })).not.toThrow();284    expect(bad({}, {})).not.toThrow();285    expect(bad({}, { prevTx: {} })).toThrow(/unknown helper/);286    expect(bad({}, { publicKey: toHex(pub), extra: 1 })).toThrow(/unknown helper/);287    expect(bad({}, { publicKey: 'zz' })).toThrow(/unknown helper/);288    expect(bad({}, { publicKey: toHex(secp256k1.getPublicKey(new Uint8Array(32).fill(7), true)) })).toThrow(/does not match/);289    expect(() => cosmosSignDoc(base, net, secp256k1.getPublicKey(new Uint8Array(32).fill(7), true))).toThrow(/does not match/);290    expect(() => cosmosPlugin.sign(base, net, key, { prevTx: {} })).toThrow(/unknown helper/);291  });292  it('sign() refuses a key that is not the source account, a wrong curve, and a bad signature from the key module', () => {293    const otherPriv = new Uint8Array(32).fill(7);294    expect(() => cosmosPlugin.sign(base, net, handleOf(otherPriv))).toThrow(/not controlled/);295    expect(() => cosmosPlugin.sign(base, net, { curve: 'ed25519', publicKey: pub.slice(1), signMessage: () => new Uint8Array(64) })).toThrow(/secp256k1/);296    expect(() => cosmosPlugin.sign(base, net, { ...key, signDigest: undefined })).toThrow(/secp256k1/);297    expect(() => cosmosPlugin.sign(base, net, { ...key, signDigest: handleOf(otherPriv).signDigest })).toThrow(/self-check/); // signs with another private key298    const highS: KeyHandle = { ...key, signDigest: (d) => { // non-canonical (high-S) signature: the chain rejects it299      const s = secp256k1.Signature.fromBytes(key.signDigest!(d).compact, 'compact');300      const flipped = new secp256k1.Signature(s.r, secp256k1.Point.CURVE().n - s.s);301      return { compact: flipped.toBytes('compact'), recovery: 0, der: flipped.toBytes('der') }; } };302    expect(() => cosmosPlugin.sign(base, net, highS)).toThrow(/self-check/);303    expect(() => cosmosPlugin.sign(base, net, { ...key, signDigest: (d) => ({ ...key.signDigest!(d), compact: key.signDigest!(d).der }) })).toThrow(/self-check/); // DER instead of r||s304    expect(() => cosmosPlugin.sign({ ...base, amount: '0' }, net, key)).toThrow(/positive/);305    expect(() => cosmosPlugin.sign({ ...base, maxFee: '1' }, net, key)).toThrow(/approved maximum/);306    expect(() => cosmosTxid('not base64!')).toThrow(/invalid transaction payload/);307    expect(() => cosmosTxid('')).toThrow(/invalid transaction payload/);308  });309  it('changing any intent field changes the signed bytes or throws', () => {310    const ref = cosmosPlugin.sign(base, net, key);311    const changes: Partial<Intent>[] = [{ amount: '2500001' }, { to: DEST2 }, { memo: '7' }, { memo: '07' }, { x: { ...X, sequence: '8' } }, { x: { ...X, account_number: '1425848' } },312      { x: { ...X, timeout_height: '33821257' } }, { x: { ...X, gas_limit: '200001' } }, { x: { ...X, fee_amount: '1499' } }, { maxFee: '1501', x: { ...X, fee_amount: '1501' } }];313    const seen = new Set([ref.raw]), docs = new Set([toHex(cosmosPlugin.build(base, net).signDocs[0]!)]);314    for (const c of changes) {315      const r = cosmosPlugin.sign({ ...base, ...c }, net, key), doc = toHex(cosmosPlugin.build({ ...base, ...c }, net).signDocs[0]!);316      expect(seen.has(r.raw), JSON.stringify(c)).toBe(false);317      expect(docs.has(doc), JSON.stringify(c)).toBe(false);318      expect(r.txid).not.toBe(ref.txid);319      seen.add(r.raw); docs.add(doc);320    }321    for (const c of [{ from: DEST2 }, { chainRef: 'cosmoshub-5' }, { asset: 'uatom' }, { symbol: 'XLM' }, { decimals: 7 }, { network: 'bitcoin' }, { maxFee: '1499' }] as Partial<Intent>[])322      expect(() => cosmosPlugin.sign({ ...base, ...c }, net, key), JSON.stringify(c)).toThrow();323    // fields that are not part of the chain transaction are still bound by the approval (intent hash)324    for (const c of [{ proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a12' }, { keyId: 'spw-fedcba9876543210' }, { expiresAt: now + 46 }, { createdAt: now + 1 }, { policyVersion: 4 }, { maxFee: '1501' }] as Partial<Intent>[])325      expect(toHex(intentHash({ ...base, ...c }))).not.toBe(toHex(intentHash(base)));326  });327});328
Files main
Menu

Theme