Repository Public
1// Cosmos SDK family: one native-coin bank MsgSend, signed in SIGN_MODE_DIRECT (no IBC, no staking, no other denom, no2// multi-message transactions, no fee payer / granter, no extension options).3// Pure module. The chain parameters (chain id = chainRef, bech32 prefix, fee/transfer denom) come from the network4// definition, so another Cosmos SDK chain is a registry entry; only Cosmos Hub is registered today.5// The protobuf below is the canonical (ADR-027) encoding of the five messages involved, written by hand (~40 lines) and6// proven byte-for-byte against @cosmjs/proto-signing + @cosmjs/stargate in packages/core/test/cosmos.test.ts; cosmjs is a7// test-only dependency, the signer's runtime stays on @noble / @scure.8//9// Intent parameters (`x`, all decimal strings, all required):10// account_number the account's number on this chain (part of the signed document)11// sequence the account's next sequence (replay protection)12// gas_limit gas the transaction may use13// fee_amount fee in base units of the native denom, paid in full whatever gas is used; must be <= intent.maxFee14// timeout_height last block height at which the transaction may be included (always set: an approval never stays valid forever)15// `memo` is free text (exchanges identify deposits by it); absent = empty memo.16import { secp256k1 } from '@noble/curves/secp256k1.js';17import { ripemd160 } from '@noble/hashes/legacy.js';18import { sha256 } from '@noble/hashes/sha2.js';19import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';20import { bech32 } from '@scure/base';21import { baseUnits } from '../amounts.ts';22import type { FamilyPlugin, KeyHandle, Signed } from '../families.ts';23import type { Intent } from '../intent.ts';24import type { NetworkDef } from '../networks.ts';2526const X_KEYS = ['account_number', 'fee_amount', 'gas_limit', 'sequence', 'timeout_height'];27const UINT = /^(0|[1-9]\d{0,18})$/;28/** protobuf uint64 fields are kept below 2^63 so that every implementation (Go int64 included) reads the same number */29const INT64_MAX = (1n << 63n) - 1n;30/** Upper bound on gas_limit accepted by this builder: a plain send uses about 0.1 M gas; nothing legitimate needs more than this. */31export const COSMOS_MAX_GAS = 2_000_000n;32/** SDK default `max_memo_characters` (Cosmos Hub allows 512); counted in bytes, as the chain does. */33export const COSMOS_MAX_MEMO_BYTES = 256;34const MSG_SEND = '/cosmos.bank.v1beta1.MsgSend', PUBKEY = '/cosmos.crypto.secp256k1.PubKey';35/** ModeInfo { single { mode: SIGN_MODE_DIRECT (1) } } */36const MODE_DIRECT = Uint8Array.of(0x0a, 0x02, 0x08, 0x01);37const enc = new TextEncoder();3839const cat = (...parts: (Uint8Array | number[])[]): Uint8Array => {40 const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));41 let o = 0;42 for (const p of parts) { out.set(p, o); o += p.length; }43 return out;44};45function varint(v: bigint): number[] {46 if (v < 0n || v > INT64_MAX) throw new Error('integer out of range');47 const out: number[] = [];48 while (v > 0x7fn) { out.push(Number(v & 0x7fn) | 0x80); v >>= 7n; }49 out.push(Number(v));50 return out;51}52/** Length-delimited field (wire type 2). Always written, even when empty (callers omit default values themselves). */53const bytesField = (field: number, b: Uint8Array): Uint8Array => cat([(field << 3) | 2], varint(BigInt(b.length)), b);54const stringField = (field: number, s: string): Uint8Array => bytesField(field, enc.encode(s));55/** Varint field (wire type 0); proto3 omits the default value 0. */56const uintField = (field: number, v: bigint): Uint8Array => (v === 0n ? new Uint8Array(0) : cat([field << 3], varint(v)));57const coin = (denom: string, amount: bigint): Uint8Array => cat(stringField(1, denom), stringField(2, amount.toString()));58const any = (typeUrl: string, value: Uint8Array): Uint8Array => cat(stringField(1, typeUrl), bytesField(2, value));5960interface Params { chainId: string; prefix: string; denom: string }61function paramsOf(net: NetworkDef): Params {62 const c = net.cosmos;63 if (net.family !== 'cosmos' || !c) throw new Error('not a Cosmos SDK network');64 if (!/^[a-z][a-z0-9]{0,19}$/.test(c.bech32) || !/^[a-zA-Z][a-zA-Z0-9/:._-]{2,127}$/.test(c.denom) || !/^[a-z0-9][a-z0-9._-]{2,49}$/.test(net.chainRef)) throw new Error('invalid Cosmos SDK network definition');65 return { chainId: net.chainRef, prefix: c.bech32, denom: c.denom };66}6768/**69 * 20-byte account id of a bech32 address with this prefix. Only the canonical lower-case form is accepted; other prefixes70 * (another chain's address, validator `…valoper` addresses), bech32m, bad checksums and 32-byte (module / interchain71 * account) addresses throw.72 */73export function cosmosAddressBytes(prefix: string, addr: string): Uint8Array {74 if (typeof addr !== 'string' || !addr.startsWith(prefix + '1') || !/^[a-z0-9]+1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38}$/.test(addr)) throw new Error(`invalid address: expected a ${prefix}1… account address`);75 let d: { prefix: string; bytes: Uint8Array };76 try { d = bech32.decodeToBytes(addr); } catch { throw new Error('invalid address: bad bech32 checksum'); }77 if (d.prefix !== prefix || d.bytes.length !== 20) throw new Error(`invalid address: expected a ${prefix}1… account address`);78 if (bech32.encode(prefix, bech32.toWords(d.bytes)) !== addr) throw new Error('non-canonical address');79 return d.bytes;80}81export function cosmosAddressFromPublicKey(prefix: string, publicKey: Uint8Array): string {82 if (publicKey.length !== 33 || (publicKey[0] !== 0x02 && publicKey[0] !== 0x03)) throw new Error('expected a 33-byte compressed secp256k1 public key');83 secp256k1.Point.fromBytes(publicKey); // throws when the point is not on the curve84 return bech32.encode(prefix, bech32.toWords(ripemd160(sha256(publicKey))));85}8687/**88 * The single memo rule, used by the API and re-checked by the signer. The memo is sent exactly as approved, so anything89 * that would silently differ from what an exchange expects is refused instead: empty text, surrounding white space,90 * control characters, ill-formed Unicode, more than 256 bytes.91 */92export function cosmosMemo(memo: string | undefined): string {93 if (memo === undefined) return '';94 if (memo.length === 0) throw new Error('empty memo');95 const bytes = enc.encode(memo);96 if (new TextDecoder('utf-8', { fatal: true }).decode(bytes) !== memo) throw new Error('memo is not valid Unicode text');97 if (memo !== memo.trim()) throw new Error('memo starts or ends with white space');98 // eslint-disable-next-line no-control-regex99 if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(memo)) throw new Error('memo contains control characters');100 if (bytes.length > COSMOS_MAX_MEMO_BYTES) throw new Error(`memo too long: at most ${COSMOS_MAX_MEMO_BYTES} bytes`);101 return memo;102}103104function uint(s: string | undefined, what: string): bigint {105 if (s === undefined || !UINT.test(s)) throw new Error(`invalid ${what}`);106 const v = BigInt(s);107 if (v > INT64_MAX) throw new Error(`${what} out of range`);108 return v;109}110111interface Transfer extends Params { from: string; to: string; amount: bigint; memo: string; accountNumber: bigint; sequence: bigint; gasLimit: bigint; fee: bigint; timeoutHeight: bigint }112113/** Everything the signer accepts, checked against the intent alone. */114function transferOf(i: Intent, net: NetworkDef): Transfer {115 const p = paramsOf(net);116 if (i.network !== net.id) throw new Error('intent is not for this network');117 if (i.chainRef !== net.chainRef) throw new Error('wrong chain: chain id mismatch');118 if (i.evm || i.btc || i.sol) throw new Error('foreign chain section in a Cosmos intent');119 if (i.asset !== 'native' || i.symbol !== net.native.symbol || i.decimals !== net.native.decimals) throw new Error(`only native ${net.native.symbol} can be sent on ${net.name}`);120 const x = i.x;121 if (!x) throw new Error('missing Cosmos parameters');122 const keys = Object.keys(x).sort();123 if (keys.length !== X_KEYS.length || keys.some((k, n) => k !== X_KEYS[n])) throw new Error('unknown or missing Cosmos parameters (expected account_number, fee_amount, gas_limit, sequence, timeout_height)');124 const accountNumber = uint(x.account_number, 'account_number'), sequence = uint(x.sequence, 'sequence');125 const gasLimit = uint(x.gas_limit, 'gas_limit'), timeoutHeight = uint(x.timeout_height, 'timeout_height');126 if (gasLimit < 1n || gasLimit > COSMOS_MAX_GAS) throw new Error('gas_limit out of range');127 if (timeoutHeight < 1n) throw new Error('timeout_height is required');128 const fee = uint(x.fee_amount, 'fee_amount'), amount = baseUnits(i.amount), maxFee = baseUnits(i.maxFee);129 if (fee <= 0n) throw new Error('fee must be positive');130 if (fee > maxFee) throw new Error('fee above the approved maximum');131 if (amount <= 0n) throw new Error('amount must be positive');132 cosmosAddressBytes(p.prefix, i.from);133 cosmosAddressBytes(p.prefix, i.to);134 if (i.from === i.to) throw new Error('destination equals source');135 return { ...p, from: i.from, to: i.to, amount, memo: cosmosMemo(i.memo), accountNumber, sequence, gasLimit, fee, timeoutHeight };136}137138/** TxBody { messages: [Any(MsgSend { from_address, to_address, amount: [Coin] })], memo, timeout_height } */139function txBody(t: Transfer): Uint8Array {140 const msg = cat(stringField(1, t.from), stringField(2, t.to), bytesField(3, coin(t.denom, t.amount)));141 return cat(bytesField(1, any(MSG_SEND, msg)), t.memo === '' ? [] : stringField(2, t.memo), uintField(3, t.timeoutHeight));142}143/** AuthInfo { signer_infos: [SignerInfo { public_key: Any(PubKey { key }), mode_info: DIRECT, sequence }], fee: Fee { amount: [Coin], gas_limit } } */144function authInfo(t: Transfer, publicKey: Uint8Array | undefined): Uint8Array {145 const signer = cat(publicKey ? bytesField(1, any(PUBKEY, bytesField(1, publicKey))) : [], bytesField(2, MODE_DIRECT), uintField(3, t.sequence));146 return cat(bytesField(1, signer), bytesField(2, cat(bytesField(1, coin(t.denom, t.fee)), uintField(2, t.gasLimit))));147}148/** SignDoc { body_bytes, auth_info_bytes, chain_id, account_number }: the bytes whose SHA-256 is signed. */149const signDoc = (t: Transfer, body: Uint8Array, auth: Uint8Array): Uint8Array => cat(bytesField(1, body), bytesField(2, auth), stringField(3, t.chainId), uintField(4, t.accountNumber));150/** TxRaw { body_bytes, auth_info_bytes, signatures: [sig] } */151const txRaw = (body: Uint8Array, auth: Uint8Array, signature: Uint8Array): Uint8Array => cat(bytesField(1, body), bytesField(2, auth), bytesField(3, signature));152153function describe(t: Transfer): Record<string, string> {154 return { type: 'MsgSend', chainId: t.chainId, from: t.from, to: t.to, amount: t.amount.toString(), denom: t.denom, fee: t.fee.toString(), gasLimit: t.gasLimit.toString(),155 accountNumber: t.accountNumber.toString(), sequence: t.sequence.toString(), timeoutHeight: t.timeoutHeight.toString(), memo: t.memo, signMode: 'SIGN_MODE_DIRECT' };156}157158/** `aux` may only carry the wallet's public key, and only if it hashes to the intent's source address. */159function auxKey(t: Transfer, aux?: Record<string, unknown>): Uint8Array | undefined {160 if (!aux) return undefined;161 const keys = Object.keys(aux);162 if (keys.length === 0) return undefined;163 if (keys.length !== 1 || keys[0] !== 'publicKey' || typeof aux.publicKey !== 'string' || !/^0[23][0-9a-fA-F]{64}$/.test(aux.publicKey)) throw new Error('unknown helper data for a Cosmos transfer');164 const pk = hexToBytes(aux.publicKey);165 if (cosmosAddressFromPublicKey(t.prefix, pk) !== t.from) throw new Error('public key does not match the source address');166 return pk;167}168169/** The exact SignDoc bytes for this intent and key (their SHA-256 is what gets signed). Refuses a key that is not `from`. */170export function cosmosSignDoc(i: Intent, net: NetworkDef, publicKey: Uint8Array): Uint8Array {171 const t = transferOf(i, net);172 if (cosmosAddressFromPublicKey(t.prefix, publicKey) !== t.from) throw new Error('public key does not match the source address');173 return signDoc(t, txBody(t), authInfo(t, publicKey));174}175176/**177 * Base64 TxRaw of the same transfer with no public key and an empty signature: the form `/cosmos/tx/v1beta1/simulate`178 * accepts. It can never be broadcast successfully (no signature). Goes through the same checks as build().179 */180export function cosmosSimulationTx(i: Intent, net: NetworkDef): string {181 const t = transferOf(i, net);182 return Buffer.from(txRaw(txBody(t), authInfo(t, undefined), new Uint8Array(0))).toString('base64');183}184185/** Transaction hash of a broadcast-ready payload (base64 TxRaw): SHA-256, upper-case hex as nodes and explorers print it. */186export function cosmosTxid(rawBase64: string): string {187 if (typeof rawBase64 !== 'string' || rawBase64.length === 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(rawBase64)) throw new Error('invalid transaction payload');188 return bytesToHex(sha256(Uint8Array.from(Buffer.from(rawBase64, 'base64')))).toUpperCase();189}190191export const cosmosPlugin: FamilyPlugin = {192 family: 'cosmos',193 curve: 'secp256k1',194 path(net) {195 paramsOf(net);196 if (net.coinType === undefined) throw new Error('not a Cosmos SDK network');197 return `m/44'/${net.coinType}'/0'/0/0`;198 },199 address(net, publicKey) { return cosmosAddressFromPublicKey(paramsOf(net).prefix, publicKey); },200 normalizeAddress(net, addr) {201 cosmosAddressBytes(paramsOf(net).prefix, addr);202 return addr;203 },204 /**205 * AuthInfo (and therefore the SignDoc) contains the signer's public key, which this method is not given: `from` (its206 * hash) commits to it instead. With `aux.publicKey` (verified against `from`) the result is the exact SignDoc; without207 * it, the same SignDoc with the public key left out of SignerInfo. sign() always uses the real key and refuses a key208 * that does not hash to `from`.209 */210 build(i, net, aux) {211 const t = transferOf(i, net);212 const pk = auxKey(t, aux);213 return { signDocs: [signDoc(t, txBody(t), authInfo(t, pk))], describe: { ...describe(t), publicKey: pk ? bytesToHex(pk) : '' } };214 },215 sign(i: Intent, net: NetworkDef, key: KeyHandle, aux?: Record<string, unknown>): Signed {216 const t = transferOf(i, net);217 auxKey(t, aux);218 if (key.curve !== 'secp256k1' || !key.signDigest) throw new Error('Cosmos signing needs a secp256k1 key');219 if (cosmosAddressFromPublicKey(t.prefix, key.publicKey) !== t.from) throw new Error('source address is not controlled by this key');220 const body = txBody(t), auth = authInfo(t, key.publicKey);221 const digest = sha256(signDoc(t, body, auth));222 const sig = key.signDigest(digest).compact;223 // never release a signature that the chain would not accept: 64 bytes r||s, low-S, verifying against the key224 if (sig.length !== 64 || !secp256k1.verify(sig, digest, key.publicKey, { prehash: false, lowS: true })) throw new Error('signature self-check failed');225 const raw = Buffer.from(txRaw(body, auth, sig)).toString('base64');226 return { raw, txid: cosmosTxid(raw) };227 },228};229