From 89a26bee76378b0e13ec9660c2685e087dafa8d4 Mon Sep 17 00:00:00 2001 From: baha Date: Tue, 30 Dec 2025 22:48:18 +0300 Subject: [PATCH] feat(signing): add Cosmos SIGN_MODE_DIRECT support Add SDK support for signing Cosmos SDK transactions using the SIGN_MODE_DIRECT protobuf encoding. - Add signCosmosDirect() API function - Add COSMOS encoding type (requires firmware v0.18.9+) - Add COSMOS_DERIVATION path (m/44'/118'/0'/0/0) - Add e2e tests for Cosmos signing --- package.json | 1 + .../e2e/signing/cosmos/cosmos.test.ts | 346 ++++++++++++++++++ src/api/signing.ts | 18 + src/constants.ts | 19 + src/protocol/latticeConstants.ts | 1 + src/types/firmware.ts | 1 + 6 files changed, 386 insertions(+) create mode 100644 src/__test__/e2e/signing/cosmos/cosmos.test.ts diff --git a/package.json b/package.json index 9a6bc8e0..8e25bd32 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "e2e-sign-evm-abi": "vitest run ./src/__test__/e2e/signing/evm-abi.test.ts", "e2e-sign-evm-tx": "vitest run ./src/__test__/e2e/signing/evm-tx.test.ts", "e2e-sign-solana": "vitest run ./src/__test__/e2e/signing/solana*", + "e2e-sign-cosmos": "vitest run ./src/__test__/e2e/signing/cosmos*", "e2e-sign-unformatted": "vitest run ./src/__test__/e2e/signing/unformatted.test.ts", "e2e-api": "vitest run ./src/__test__/e2e/api.test.ts", "e2e-sign-eip712": "vitest run ./src/__test__/e2e/signing/eip712-msg.test.ts" diff --git a/src/__test__/e2e/signing/cosmos/cosmos.test.ts b/src/__test__/e2e/signing/cosmos/cosmos.test.ts new file mode 100644 index 00000000..80d3ba42 --- /dev/null +++ b/src/__test__/e2e/signing/cosmos/cosmos.test.ts @@ -0,0 +1,346 @@ +/** + * REQUIRED TEST MNEMONIC: + * These tests require a SafeCard loaded with the standard test mnemonic: + * "test test test test test test test test test test test junk" + * + * Running with a different mnemonic will cause test failures due to + * incorrect key derivations and signature mismatches. + */ +import { createHash } from 'node:crypto'; +import { bech32 } from 'bech32'; +import { Constants, signCosmosDirect } from '../../../..'; +import { COSMOS_DERIVATION } from '../../../../constants'; +import { setupClient } from '../../../utils/setup'; +import { deriveSECP256K1Key, validateGenericSig } from '../../../utils/helpers'; +import { TEST_SEED } from '../../../utils/testConstants'; + +const encodeVarint = (value: number | bigint) => { + let v = BigInt(value); + const bytes: number[] = []; + while (v >= 0x80n) { + bytes.push(Number((v & 0x7fn) | 0x80n)); + v >>= 7n; + } + bytes.push(Number(v)); + return Buffer.from(bytes); +}; + +const encodeFieldKey = (tag: number, wireType: number) => + encodeVarint((tag << 3) | wireType); + +const encodeVarintField = (tag: number, value: number | bigint) => + Buffer.concat([encodeFieldKey(tag, 0), encodeVarint(value)]); + +const encodeBytesField = (tag: number, value: Buffer) => + Buffer.concat([encodeFieldKey(tag, 2), encodeVarint(value.length), value]); + +const encodeStringField = (tag: number, value: string) => + encodeBytesField(tag, Buffer.from(value, 'utf8')); + +const encodeAny = (typeUrl: string, value: Buffer) => + Buffer.concat([encodeStringField(1, typeUrl), encodeBytesField(2, value)]); + +const encodeCoin = (denom: string, amount: string) => + Buffer.concat([encodeStringField(1, denom), encodeStringField(2, amount)]); + +const buildMsgSend = (from: string, to: string, denom: string, amount: string) => + Buffer.concat([ + encodeStringField(1, from), + encodeStringField(2, to), + encodeBytesField(3, encodeCoin(denom, amount)), + ]); + +const buildMsgMultiSend = ( + inputs: Array<{ address: string; denom: string; amount: string }>, + outputs: Array<{ address: string; denom: string; amount: string }>, +) => { + const inputBufs = inputs.map((input) => + encodeBytesField( + 1, + Buffer.concat([ + encodeStringField(1, input.address), + encodeBytesField(2, encodeCoin(input.denom, input.amount)), + ]), + ), + ); + const outputBufs = outputs.map((output) => + encodeBytesField( + 2, + Buffer.concat([ + encodeStringField(1, output.address), + encodeBytesField(2, encodeCoin(output.denom, output.amount)), + ]), + ), + ); + return Buffer.concat([...inputBufs, ...outputBufs]); +}; + +const buildMsgDelegate = ( + delegator: string, + validator: string, + denom: string, + amount: string, +) => + Buffer.concat([ + encodeStringField(1, delegator), + encodeStringField(2, validator), + encodeBytesField(3, encodeCoin(denom, amount)), + ]); + +const buildMsgRedelegate = ( + delegator: string, + validatorSrc: string, + validatorDst: string, + denom: string, + amount: string, +) => + Buffer.concat([ + encodeStringField(1, delegator), + encodeStringField(2, validatorSrc), + encodeStringField(3, validatorDst), + encodeBytesField(4, encodeCoin(denom, amount)), + ]); + +const buildMsgExecuteContract = ( + sender: string, + contract: string, + msg: string, + denom: string, + amount: string, +) => + Buffer.concat([ + encodeStringField(1, sender), + encodeStringField(2, contract), + encodeBytesField(3, Buffer.from(msg, 'utf8')), + encodeBytesField(5, encodeCoin(denom, amount)), + ]); + +const buildMsgIbcTransfer = (params: { + port: string; + channel: string; + sender: string; + receiver: string; + denom: string; + amount: string; + timeoutTimestamp: number | bigint; + memo: string; +}) => { + const timeoutHeight = Buffer.concat([ + encodeVarintField(1, 1), + encodeVarintField(2, 123456), + ]); + return Buffer.concat([ + encodeStringField(1, params.port), + encodeStringField(2, params.channel), + encodeBytesField(3, encodeCoin(params.denom, params.amount)), + encodeStringField(4, params.sender), + encodeStringField(5, params.receiver), + encodeBytesField(6, timeoutHeight), + encodeVarintField(7, params.timeoutTimestamp), + encodeStringField(8, params.memo), + ]); +}; + +const buildPubKeyAny = (pubkey: Buffer) => { + const pubKeyMsg = encodeBytesField(1, pubkey); + return encodeAny('/cosmos.crypto.secp256k1.PubKey', pubKeyMsg); +}; + +const buildSignerInfo = (pubkey: Buffer, sequence: number) => { + const modeInfoSingle = encodeVarintField(1, 1); // SIGN_MODE_DIRECT + const modeInfo = encodeBytesField(1, modeInfoSingle); + return Buffer.concat([ + encodeBytesField(1, buildPubKeyAny(pubkey)), + encodeBytesField(2, modeInfo), + encodeVarintField(3, sequence), + ]); +}; + +const buildFee = (denom: string, amount: string, gasLimit: number) => + Buffer.concat([ + encodeBytesField(1, encodeCoin(denom, amount)), + encodeVarintField(2, gasLimit), + ]); + +const buildAuthInfo = ( + pubkey: Buffer, + sequence: number, + denom: string, + amount: string, + gasLimit: number, +) => + Buffer.concat([ + encodeBytesField(1, buildSignerInfo(pubkey, sequence)), + encodeBytesField(2, buildFee(denom, amount, gasLimit)), + ]); + +const buildTxBody = (msgAny: Buffer, memo: string) => + Buffer.concat([encodeBytesField(1, msgAny), encodeStringField(2, memo)]); + +const buildSignDoc = ( + bodyBytes: Buffer, + authInfoBytes: Buffer, + chainId: string, + accountNumber: number, +) => + Buffer.concat([ + encodeBytesField(1, bodyBytes), + encodeBytesField(2, authInfoBytes), + encodeStringField(3, chainId), + encodeVarintField(4, accountNumber), + ]); + +const sha256 = (buf: Buffer) => createHash('sha256').update(buf).digest(); +const ripemd160 = (buf: Buffer) => createHash('ripemd160').update(buf).digest(); + +const getBech32Address = (pubkey: Buffer, hrp: string) => { + const hash = ripemd160(sha256(pubkey)); + const words = bech32.toWords(hash); + return bech32.encode(hrp, words); +}; + +describe('[Cosmos]', () => { + const chainId = 'cosmoshub-4'; + const feeDenom = 'uatom'; + const feeAmount = '500'; + const feeGas = 200000; + let pub: Buffer; + let fromAddr: string; + let toAddr: string; + let altAddr: string; + let valoperA: string; + let valoperB: string; + + beforeAll(async () => { + await setupClient(); + ({ pub } = deriveSECP256K1Key(COSMOS_DERIVATION, TEST_SEED)); + fromAddr = getBech32Address(pub, 'cosmos'); + toAddr = 'cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnrql8a'; + + const altPath = [...COSMOS_DERIVATION]; + altPath[4] += 1; + const altPub = deriveSECP256K1Key(altPath, TEST_SEED).pub; + altAddr = getBech32Address(altPub, 'cosmos'); + valoperA = getBech32Address(pub, 'cosmosvaloper'); + valoperB = getBech32Address(altPub, 'cosmosvaloper'); + }); + + const buildSignDocForMsg = (msgAny: Buffer, memo: string) => { + const txBody = buildTxBody(msgAny, memo); + const authInfo = buildAuthInfo(pub, 0, feeDenom, feeAmount, feeGas); + return buildSignDoc(txBody, authInfo, chainId, 0); + }; + + const signAndValidate = async (signDoc: Buffer) => { + const resp = await signCosmosDirect(signDoc); + expect(resp.sig).toBeTruthy(); + + validateGenericSig(TEST_SEED, resp.sig, signDoc, { + signerPath: COSMOS_DERIVATION, + curveType: Constants.SIGNING.CURVES.SECP256K1, + hashType: Constants.SIGNING.HASHES.SHA256, + }); + }; + + it('Should sign a Cosmos MsgSend SignDoc (SIGN_MODE_DIRECT)', async () => { + const msgSend = buildMsgSend(fromAddr, toAddr, 'uatom', '1'); + const msgAny = encodeAny('/cosmos.bank.v1beta1.MsgSend', msgSend); + await signAndValidate(buildSignDocForMsg(msgAny, 'gridplus cosmos send')); + }); + + it('Should sign a Cosmos MsgMultiSend SignDoc (SIGN_MODE_DIRECT)', async () => { + const msgMultiSend = buildMsgMultiSend( + [ + { address: fromAddr, denom: 'uatom', amount: '3' }, + { address: altAddr, denom: 'uatom', amount: '7' }, + ], + [{ address: toAddr, denom: 'uatom', amount: '10' }], + ); + const msgAny = encodeAny('/cosmos.bank.v1beta1.MsgMultiSend', msgMultiSend); + await signAndValidate(buildSignDocForMsg(msgAny, 'gridplus cosmos multi')); + }); + + it('Should sign a Cosmos MsgTransfer (IBC) SignDoc (SIGN_MODE_DIRECT)', async () => { + const msgTransfer = buildMsgIbcTransfer({ + port: 'transfer', + channel: 'channel-0', + sender: fromAddr, + receiver: toAddr, + denom: 'uatom', + amount: '42', + timeoutTimestamp: 1690000000000, + memo: 'ibc memo', + }); + const msgAny = encodeAny( + '/ibc.applications.transfer.v1.MsgTransfer', + msgTransfer, + ); + await signAndValidate(buildSignDocForMsg(msgAny, 'gridplus cosmos ibc')); + }); + + it('Should sign a Cosmos MsgDelegate SignDoc (SIGN_MODE_DIRECT)', async () => { + const msgDelegate = buildMsgDelegate(fromAddr, valoperA, 'uatom', '25'); + const msgAny = encodeAny('/cosmos.staking.v1beta1.MsgDelegate', msgDelegate); + await signAndValidate(buildSignDocForMsg(msgAny, 'gridplus cosmos delegate')); + }); + + it('Should sign a Cosmos MsgUndelegate SignDoc (SIGN_MODE_DIRECT)', async () => { + const msgUndelegate = buildMsgDelegate(fromAddr, valoperA, 'uatom', '12'); + const msgAny = encodeAny( + '/cosmos.staking.v1beta1.MsgUndelegate', + msgUndelegate, + ); + await signAndValidate( + buildSignDocForMsg(msgAny, 'gridplus cosmos undelegate'), + ); + }); + + it('Should sign a Cosmos MsgBeginRedelegate SignDoc (SIGN_MODE_DIRECT)', async () => { + const msgRedelegate = buildMsgRedelegate( + fromAddr, + valoperA, + valoperB, + 'uatom', + '8', + ); + const msgAny = encodeAny( + '/cosmos.staking.v1beta1.MsgBeginRedelegate', + msgRedelegate, + ); + await signAndValidate( + buildSignDocForMsg(msgAny, 'gridplus cosmos redelegate'), + ); + }); + + it('Should sign a Cosmos MsgExecuteContract SignDoc (SIGN_MODE_DIRECT)', async () => { + const execMsg = '{"transfer":{"recipient":"cosmos1deadbeef","amount":"5"}}'; + const msgExecute = buildMsgExecuteContract( + fromAddr, + toAddr, + execMsg, + 'uatom', + '5', + ); + const msgAny = encodeAny( + '/cosmwasm.wasm.v1.MsgExecuteContract', + msgExecute, + ); + await signAndValidate(buildSignDocForMsg(msgAny, 'gridplus cosmos wasm')); + }); + + it('Should sign a Terra legacy MsgExecuteContract SignDoc (SIGN_MODE_DIRECT)', async () => { + const execMsg = '{"send":{"to":"cosmos1deadbeef","amount":"7"}}'; + const msgExecute = buildMsgExecuteContract( + fromAddr, + toAddr, + execMsg, + 'uatom', + '7', + ); + const msgAny = encodeAny( + '/terra.wasm.v1beta1.MsgExecuteContract', + msgExecute, + ); + await signAndValidate(buildSignDocForMsg(msgAny, 'gridplus terra wasm')); + }); +}); diff --git a/src/api/signing.ts b/src/api/signing.ts index 3b8fc54a..31801384 100644 --- a/src/api/signing.ts +++ b/src/api/signing.ts @@ -13,6 +13,7 @@ import { BTC_LEGACY_DERIVATION, BTC_SEGWIT_DERIVATION, BTC_WRAPPED_SEGWIT_DERIVATION, + COSMOS_DERIVATION, CURRENCIES, DEFAULT_ETH_DERIVATION, SOLANA_DERIVATION, @@ -282,3 +283,20 @@ export const signSolanaTx = async ( }; return queue((client) => client.sign(tx)); }; + +export const signCosmosDirect = async ( + payload: Buffer, + overrides?: SignRequestParams, +): Promise => { + const tx = { + data: { + signerPath: COSMOS_DERIVATION, + curveType: Constants.SIGNING.CURVES.SECP256K1, + hashType: Constants.SIGNING.HASHES.SHA256, + encodingType: Constants.SIGNING.ENCODINGS.COSMOS, + payload, + ...overrides, + }, + }; + return queue((client) => client.sign(tx)); +}; diff --git a/src/constants.ts b/src/constants.ts index f7679814..6ac9b26f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -40,6 +40,7 @@ export const EXTERNAL = { ENCODINGS: { NONE: LatticeSignEncoding.none, SOLANA: LatticeSignEncoding.solana, + COSMOS: LatticeSignEncoding.cosmos, EVM: LatticeSignEncoding.evm, ETH_DEPOSIT: LatticeSignEncoding.eth_deposit, EIP7702_AUTH: LatticeSignEncoding.eip7702_auth, @@ -461,6 +462,15 @@ function getFwVersionConst(v: Buffer): FirmwareConstants { }; } + // --- V0.18.9 --- + // V0.18.9 added Cosmos (SIGN_MODE_DIRECT) decoding for generic signing + if (!legacy && gte(v, [0, 18, 9])) { + c.genericSigning.encodingTypes = { + ...c.genericSigning.encodingTypes, + COSMOS: EXTERNAL.SIGNING.ENCODINGS.COSMOS, + }; + } + return c; } @@ -624,6 +634,15 @@ export const SOLANA_DERIVATION = [ HARDENED_OFFSET, ]; +/** @internal */ +export const COSMOS_DERIVATION = [ + HARDENED_OFFSET + 44, + HARDENED_OFFSET + 118, + HARDENED_OFFSET, + 0, + 0, +]; + /** @internal */ export const LEDGER_LIVE_DERIVATION = [ HARDENED_OFFSET + 49, diff --git a/src/protocol/latticeConstants.ts b/src/protocol/latticeConstants.ts index 041564aa..93993b75 100644 --- a/src/protocol/latticeConstants.ts +++ b/src/protocol/latticeConstants.ts @@ -74,6 +74,7 @@ export enum LatticeSignCurve { export enum LatticeSignEncoding { none = 1, solana = 2, + cosmos = 3, evm = 4, eth_deposit = 5, eip7702_auth = 6, diff --git a/src/types/firmware.ts b/src/types/firmware.ts index f00a4098..2a3d0520 100644 --- a/src/types/firmware.ts +++ b/src/types/firmware.ts @@ -21,6 +21,7 @@ export interface GenericSigningData { encodingTypes: { NONE: typeof EXTERNAL.SIGNING.ENCODINGS.NONE; SOLANA: typeof EXTERNAL.SIGNING.ENCODINGS.SOLANA; + COSMOS?: typeof EXTERNAL.SIGNING.ENCODINGS.COSMOS; EVM?: typeof EXTERNAL.SIGNING.ENCODINGS.EVM; }; }