Repository Public
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