diff --git a/modules/cosmos/.mocharc.json b/modules/cosmos/.mocharc.json index 4a80d02..88cbff7 100644 --- a/modules/cosmos/.mocharc.json +++ b/modules/cosmos/.mocharc.json @@ -1,5 +1,5 @@ { "require": ["ts-node/register"], "spec": "test/**/*.test.ts", - "timeout": 15000 + "timeout": 1000000 } diff --git a/modules/cosmos/configs/devnet.module.config.json b/modules/cosmos/configs/devnet.module.config.json index bb8e41c..fb66e12 100644 --- a/modules/cosmos/configs/devnet.module.config.json +++ b/modules/cosmos/configs/devnet.module.config.json @@ -11,7 +11,7 @@ "decimals": 18 }, "urls": { - "rpc": "http://cosmos.devnet.xrplevm.org:26657" + "rpc": "https://cosmos-rpc.devnet.xrplevm.org" } }, "slashing": { @@ -24,6 +24,51 @@ "bank": { "account": "ethm1dakgyqjulg29m5fmv992g2y66m9g2mjn6hahwg" }, + "ibc": { + "chains": [ + { + "dstChain": { + "account": { + "mnemonic": "harsh harsh mean pool tell oval cancel deal unit strategy deny pool", + "address": "ethm1dny09udcpxg2j6440fuk2m6wajz6249tgu2znt" + }, + "evm": true, + "chainId": "xrplevm_1449000-1", + "rpcUrl": "https://cosmos-rpc.testnet.xrplevm.org", + "prefix": "ethm", + "denom": "axrp", + "amount": "5000", + "channel": "channel-2", + "gas": { + "amount": "40000000000000000", + "gas": "200000" + } + }, + "srcChain": { + "account": { + "mnemonic": "harsh harsh mean pool tell oval cancel deal unit strategy deny pool", + "address": "cosmos1dakgyqjulg29m5fmv992g2y66m9g2mjn6hahwg" + }, + "evm": false, + "chainId": "osmo-test-5", + "rpcUrl": "https://rpc.testnet.osmosis.zone", + "prefix": "osmo", + "denom": "uosmo", + "amount": "5000", + "gas": { + "amount": "200000", + "gas": "200000" + }, + "channel": "channel-10361" + }, + "port": "transfer" + } + ], + "heightBuffer": 1000, + "timeoutMinutes": 10, + "maxIterations": 12, + "delay": 10000 + }, "evm": { "v1": { "accounts": [ @@ -65,15 +110,15 @@ }, "v2": { "accounts": [{ - "address": "", - "code": "", - "codeHash": "" + "address": "0xe432150cce91c13a887f7D836923d5597adD8E31", + "code": null, + "codeHash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" }], "params": { - "evmDenom": "", - "allowUnprotectedTxs": false, - "extraEips": [], + "evmDenom": "axrp", + "allowUnprotectedTxs": true, "evmChannels": [], + "extraEips": [], "accessControl": { "create": { "accessType": 0, @@ -84,7 +129,12 @@ "accessControlList": [] } }, - "activeStaticPrecompiles": [] + "activeStaticPrecompiles": [ + "0x0000000000000000000000000000000000000100", + "0x0000000000000000000000000000000000000400", + "0x0000000000000000000000000000000000000804", + "0x0000000000000000000000000000000000000805" + ] } } }, @@ -95,23 +145,23 @@ "baseFeeChangeDenominator": 8, "elasticityMultiplier": 4, "enableHeight": 0, - "baseFee": "800000000000", - "minGasPrice": "800000000000000000000000000000", + "baseFee": "200000000000", + "minGasPrice": "200000000000000000000000000000", "minGasMultiplier": "500000000000000000" }, - "baseFee": "800000000000" + "baseFee": "200000000000" }, "v2": { "params": { "noBaseFee": false, - "baseFeeChangeDenominator": 1, - "elasticityMultiplier": 1, - "enableHeight": "", - "baseFee": "", - "minGasPrice": "", - "minGasMultiplier": "" + "baseFeeChangeDenominator": 8, + "elasticityMultiplier": 4, + "enableHeight": 0, + "baseFee": "200000000000000000000000000000", + "minGasPrice": "200000000000000000000000000000", + "minGasMultiplier": "500000000000000000" }, - "baseFee": "" + "baseFee": "200000000000000000000000000000" } } } diff --git a/modules/cosmos/configs/mainnet.module.config.json b/modules/cosmos/configs/mainnet.module.config.json index b9b5660..d5fe0d0 100644 --- a/modules/cosmos/configs/mainnet.module.config.json +++ b/modules/cosmos/configs/mainnet.module.config.json @@ -24,6 +24,49 @@ "bank": { "account": "ethm1dakgyqjulg29m5fmv992g2y66m9g2mjn6hahwg" }, + "ibc": { + "chains": [ + { + "dstChain": { + "account": { + "mnemonic": "" + }, + "evm": true, + "chainId": "xrplevm_1440000-1", + "rpcUrl": "https://cosmos-rpc.xrplevm.org", + "prefix": "ethm", + "denom": "axrp", + "amount": "5000", + "channel": "", + "gas": { + "amount": "40000000000000000", + "gas": "200000" + } + }, + "srcChain": { + "account": { + "mnemonic": "" + }, + "evm": false, + "chainId": "osmo-mainnet-1", + "rpcUrl": "https://rpc.osmosis.zone", + "prefix": "osmo", + "denom": "uosmo", + "amount": "5000", + "gas": { + "amount": "200000", + "gas": "200000" + }, + "channel": "" + }, + "port": "transfer" + } + ], + "heightBuffer": 1000, + "timeoutMinutes": 10, + "maxIterations": 12, + "delay": 10000 + }, "evm": { "v1": { "accounts": [ @@ -65,15 +108,15 @@ }, "v2": { "accounts": [{ - "address": "", - "code": "", - "codeHash": "" + "address": "0xe432150cce91c13a887f7D836923d5597adD8E31", + "code": "6080604052600436106100385760003560e01c80633e35487c146100915780635c60da1b146100d85780639ded06df1461012c5761003f565b3661003f57005b60006100697f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5490565b90503660008037600080366000845af43d6000803e80801561008a573d6000f35b3d6000fd5b005b34801561009d57600080fd5b506100c57fd10cce6f0e3f6dc9eb8280c32952dc6e4759d0519c550c55d0f8747d13a64bfd81565b6040519081526020015b60405180910390f35b3480156100e457600080fd5b507f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5460405173ffffffffffffffffffffffffffffffffffffffff90911681526020016100cf565b34801561013857600080fd5b5061008f61014736600461014b565b5050565b6000806020838503121561015e57600080fd5b823567ffffffffffffffff8082111561017657600080fd5b818501915085601f83011261018a57600080fd5b81358181111561019957600080fd5b8660208285010111156101ab57600080fd5b6020929092019691955090935050505056fea264697066735822122048cf09e223a1bd27520b20e79507d7de6a036b11d95e1368da36c89f0c67f6a764736f6c63430008170033", + "codeHash": "0xb517e33d0b0a195c9689f81879dc551f8cc0d09ace8e2ed2a9377208527ede18" }], "params": { - "evmDenom": "", - "allowUnprotectedTxs": false, + "evmDenom": "axrp", + "allowUnprotectedTxs": true, "evmChannels": [], - "extraEips": [], + "extraEips": ["3855"], "accessControl": { "create": { "accessType": 0, @@ -84,7 +127,12 @@ "accessControlList": [] } }, - "activeStaticPrecompiles": [] + "activeStaticPrecompiles": [ + "0x0000000000000000000000000000000000000100", + "0x0000000000000000000000000000000000000400", + "0x0000000000000000000000000000000000000804", + "0x0000000000000000000000000000000000000805" + ] } } }, @@ -104,14 +152,14 @@ "v2": { "params": { "noBaseFee": false, - "baseFeeChangeDenominator": 1, - "elasticityMultiplier": 1, - "enableHeight": "", - "baseFee": "", - "minGasPrice": "", - "minGasMultiplier": "" + "baseFeeChangeDenominator": 8, + "elasticityMultiplier": 4, + "enableHeight": 0, + "baseFee": "100000000000000000000000000000", + "minGasPrice": "100000000000000000000000000000", + "minGasMultiplier": "500000000000000000" }, - "baseFee": "" + "baseFee": "100000000000000000000000000000" } } } diff --git a/modules/cosmos/configs/testnet.module.config.json b/modules/cosmos/configs/testnet.module.config.json index 077ac25..674a300 100644 --- a/modules/cosmos/configs/testnet.module.config.json +++ b/modules/cosmos/configs/testnet.module.config.json @@ -11,7 +11,7 @@ "decimals": 18 }, "urls": { - "rpc": "http://cosmos.testnet.xrplevm.org:26657" + "rpc": "https://cosmos-rpc.testnet.xrplevm.org" } }, "slashing": { @@ -24,6 +24,51 @@ "bank": { "account": "ethm1dakgyqjulg29m5fmv992g2y66m9g2mjn6hahwg" }, + "ibc": { + "chains": [ + { + "dstChain": { + "account": { + "mnemonic": "harsh harsh mean pool tell oval cancel deal unit strategy deny pool", + "address": "ethm1dny09udcpxg2j6440fuk2m6wajz6249tgu2znt" + }, + "evm": true, + "chainId": "xrplevm_1449000-1", + "rpcUrl": "https://cosmos-rpc.testnet.xrplevm.org", + "prefix": "ethm", + "denom": "axrp", + "amount": "5000", + "channel": "channel-2", + "gas": { + "amount": "40000000000000000", + "gas": "200000" + } + }, + "srcChain": { + "account": { + "mnemonic": "harsh harsh mean pool tell oval cancel deal unit strategy deny pool", + "address": "cosmos1dakgyqjulg29m5fmv992g2y66m9g2mjn6hahwg" + }, + "evm": false, + "chainId": "osmo-test-5", + "rpcUrl": "https://rpc.testnet.osmosis.zone", + "prefix": "osmo", + "denom": "uosmo", + "amount": "5000", + "gas": { + "amount": "200000", + "gas": "200000" + }, + "channel": "channel-10361" + }, + "port": "transfer" + } + ], + "heightBuffer": 1000, + "timeoutMinutes": 10, + "maxIterations": 12, + "delay": 10000 + }, "evm": { "v1": { "accounts": [ @@ -65,15 +110,15 @@ }, "v2": { "accounts": [{ - "address": "", - "code": "", - "codeHash": "" + "address": "0xe432150cce91c13a887f7D836923d5597adD8E31", + "code": "6080604052600436106100385760003560e01c80633e35487c146100915780635c60da1b146100d85780639ded06df1461012c5761003f565b3661003f57005b60006100697f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5490565b90503660008037600080366000845af43d6000803e80801561008a573d6000f35b3d6000fd5b005b34801561009d57600080fd5b506100c57fd10cce6f0e3f6dc9eb8280c32952dc6e4759d0519c550c55d0f8747d13a64bfd81565b6040519081526020015b60405180910390f35b3480156100e457600080fd5b507f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5460405173ffffffffffffffffffffffffffffffffffffffff90911681526020016100cf565b34801561013857600080fd5b5061008f61014736600461014b565b5050565b6000806020838503121561015e57600080fd5b823567ffffffffffffffff8082111561017657600080fd5b818501915085601f83011261018a57600080fd5b81358181111561019957600080fd5b8660208285010111156101ab57600080fd5b6020929092019691955090935050505056fea264697066735822122048cf09e223a1bd27520b20e79507d7de6a036b11d95e1368da36c89f0c67f6a764736f6c63430008170033", + "codeHash": "0xb517e33d0b0a195c9689f81879dc551f8cc0d09ace8e2ed2a9377208527ede18" }], "params": { - "evmDenom": "", + "evmDenom": "axrp", "allowUnprotectedTxs": false, "evmChannels": [], - "extraEips": [], + "extraEips": ["3855"], "accessControl": { "create": { "accessType": 0, @@ -84,7 +129,12 @@ "accessControlList": [] } }, - "activeStaticPrecompiles": [] + "activeStaticPrecompiles": [ + "0x0000000000000000000000000000000000000100", + "0x0000000000000000000000000000000000000400", + "0x0000000000000000000000000000000000000804", + "0x0000000000000000000000000000000000000805" + ] } } }, @@ -104,14 +154,14 @@ "v2": { "params": { "noBaseFee": false, - "baseFeeChangeDenominator": 1, - "elasticityMultiplier": 1, - "enableHeight": "", - "baseFee": "", - "minGasPrice": "", - "minGasMultiplier": "" + "baseFeeChangeDenominator": 8, + "elasticityMultiplier": 4, + "enableHeight": 0, + "baseFee": "200000000000000000000000000000", + "minGasPrice": "200000000000000000000000000000", + "minGasMultiplier": "500000000000000000" }, - "baseFee": "" + "baseFee": "200000000000000000000000000000" } } } diff --git a/modules/cosmos/module.config.json b/modules/cosmos/module.config.json index b9b5660..fb66e12 100644 --- a/modules/cosmos/module.config.json +++ b/modules/cosmos/module.config.json @@ -1,9 +1,9 @@ { "network": { - "id": "xrplevm_mainnet", - "name": "xrplevm_mainnet", + "id": "xrplevm_devnet", + "name": "xrplevm_devnet", "symbol": "XRP", - "env": "mainnet", + "env": "devnet", "type": "cosmos", "nativeToken": { "name": "XRP", @@ -11,7 +11,7 @@ "decimals": 18 }, "urls": { - "rpc": "https://cosmos-rpc.xrplevm.org" + "rpc": "https://cosmos-rpc.devnet.xrplevm.org" } }, "slashing": { @@ -24,6 +24,51 @@ "bank": { "account": "ethm1dakgyqjulg29m5fmv992g2y66m9g2mjn6hahwg" }, + "ibc": { + "chains": [ + { + "dstChain": { + "account": { + "mnemonic": "harsh harsh mean pool tell oval cancel deal unit strategy deny pool", + "address": "ethm1dny09udcpxg2j6440fuk2m6wajz6249tgu2znt" + }, + "evm": true, + "chainId": "xrplevm_1449000-1", + "rpcUrl": "https://cosmos-rpc.testnet.xrplevm.org", + "prefix": "ethm", + "denom": "axrp", + "amount": "5000", + "channel": "channel-2", + "gas": { + "amount": "40000000000000000", + "gas": "200000" + } + }, + "srcChain": { + "account": { + "mnemonic": "harsh harsh mean pool tell oval cancel deal unit strategy deny pool", + "address": "cosmos1dakgyqjulg29m5fmv992g2y66m9g2mjn6hahwg" + }, + "evm": false, + "chainId": "osmo-test-5", + "rpcUrl": "https://rpc.testnet.osmosis.zone", + "prefix": "osmo", + "denom": "uosmo", + "amount": "5000", + "gas": { + "amount": "200000", + "gas": "200000" + }, + "channel": "channel-10361" + }, + "port": "transfer" + } + ], + "heightBuffer": 1000, + "timeoutMinutes": 10, + "maxIterations": 12, + "delay": 10000 + }, "evm": { "v1": { "accounts": [ @@ -65,13 +110,13 @@ }, "v2": { "accounts": [{ - "address": "", - "code": "", - "codeHash": "" + "address": "0xe432150cce91c13a887f7D836923d5597adD8E31", + "code": null, + "codeHash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" }], "params": { - "evmDenom": "", - "allowUnprotectedTxs": false, + "evmDenom": "axrp", + "allowUnprotectedTxs": true, "evmChannels": [], "extraEips": [], "accessControl": { @@ -84,7 +129,12 @@ "accessControlList": [] } }, - "activeStaticPrecompiles": [] + "activeStaticPrecompiles": [ + "0x0000000000000000000000000000000000000100", + "0x0000000000000000000000000000000000000400", + "0x0000000000000000000000000000000000000804", + "0x0000000000000000000000000000000000000805" + ] } } }, @@ -104,14 +154,14 @@ "v2": { "params": { "noBaseFee": false, - "baseFeeChangeDenominator": 1, - "elasticityMultiplier": 1, - "enableHeight": "", - "baseFee": "", - "minGasPrice": "", - "minGasMultiplier": "" + "baseFeeChangeDenominator": 8, + "elasticityMultiplier": 4, + "enableHeight": 0, + "baseFee": "200000000000000000000000000000", + "minGasPrice": "200000000000000000000000000000", + "minGasMultiplier": "500000000000000000" }, - "baseFee": "" + "baseFee": "200000000000000000000000000000" } } } diff --git a/modules/cosmos/package.json b/modules/cosmos/package.json index 284a134..bb13b01 100644 --- a/modules/cosmos/package.json +++ b/modules/cosmos/package.json @@ -13,11 +13,16 @@ }, "license": "ISC", "dependencies": { + "@cosmjs/amino": "^0.33.1", + "@cosmjs/crypto": "^0.33.1", "@cosmjs/encoding": "^0.33.1", + "@cosmjs/math": "^0.33.1", + "@cosmjs/proto-signing": "^0.33.1", "@cosmjs/stargate": "^0.33.0", "@cosmjs/tendermint-rpc": "^0.33.1", "@firewatch/proto-evmos": "workspace:*", "@firewatch/proto-evm": "workspace:*", + "@cosmjs/utils": "^0.33.1", "@types/chai": "^5.0.1", "cosmjs-types": "^0.9.0", "mocha": "^11.1.0", @@ -27,7 +32,9 @@ "@firewatch/core": "workspace:*", "@shared/eslint": "workspace:*", "@shared/tsconfig": "workspace:*", + "@shared/utils": "workspace:*", "@testing/mocha": "workspace:*", - "@types/mocha": "^10.0.10" + "@types/mocha": "^10.0.10", + "@types/node": "^24.0.10" } } diff --git a/modules/cosmos/src/modules/ibc/account-parser.ts b/modules/cosmos/src/modules/ibc/account-parser.ts new file mode 100644 index 0000000..f308d25 --- /dev/null +++ b/modules/cosmos/src/modules/ibc/account-parser.ts @@ -0,0 +1,61 @@ +import { Account, accountFromAny } from "@cosmjs/stargate"; +import { Any } from "cosmjs-types/google/protobuf/any"; +import { parseEthAccount } from "./parser"; + +/** + * Custom account parser that extends accountFromAny to support EthAccount format. + * This parser handles both standard Cosmos accounts and Ethermint EthAccount types. + * + * Supports the EthAccount format as defined in ethermint.types.v1: + * ```proto + * message EthAccount { + * cosmos.auth.v1beta1.BaseAccount base_account = 1; + * string code_hash = 2; + * } + * ``` + * @param input The Any message containing the account. + * @returns The parsed account. + * @throws Error if parsing fails for both EthAccount and standard account formats. + */ +export function ethermintAccountParser(input: Any): Account { + try { + // Handle EthAccount specifically + if (input.typeUrl === "/ethermint.types.v1.EthAccount") { + const ethAccount = parseEthAccount(input); + if (ethAccount?.baseAccount) { + return { + address: ethAccount.baseAccount.address, + accountNumber: Number(ethAccount.baseAccount.accountNumber), + sequence: Number(ethAccount.baseAccount.sequence), + pubkey: null, // EthAccount doesn't store pubkey in the account + } as Account; + } + // If EthAccount parsing fails, fall through to standard parsing + } + + // For all other account types or if EthAccount parsing failed, use standard parsing + return accountFromAny(input); + } catch (error) { + console.error("Failed to parse account with ethermintAccountParser:", error); + // Final fallback to standard parsing, let it throw if it fails + return accountFromAny(input); + } +} + +/** + * Check if an account type URL represents an EthAccount. + * @param typeUrl The type URL to check. + * @returns True if the type URL is for an EthAccount. + */ +export function isEthAccount(typeUrl: string): boolean { + return typeUrl === "/ethermint.types.v1.EthAccount"; +} + +/** + * Create an account parser specifically for Ethermint/Evmos chains. + * This is a factory function that returns a configured account parser. + * @returns The account parser function. + */ +export function createEthermintAccountParser() { + return (input: Any): Account => ethermintAccountParser(input); +} diff --git a/modules/cosmos/src/modules/ibc/client.ts b/modules/cosmos/src/modules/ibc/client.ts new file mode 100644 index 0000000..fd70bde --- /dev/null +++ b/modules/cosmos/src/modules/ibc/client.ts @@ -0,0 +1,232 @@ +import { + DeliverTxResponse, + StdFee, + SignerData, + HttpEndpoint, + SigningStargateClientOptions, + accountFromAny, + Account, + defaultRegistryTypes, +} from "@cosmjs/stargate"; +import { Coin } from "cosmjs-types/cosmos/base/v1beta1/coin"; +import { Height } from "cosmjs-types/ibc/core/client/v1/client"; +import { MsgTransfer } from "cosmjs-types/ibc/applications/transfer/v1/tx"; +import { EncodeObject, OfflineSigner, Registry } from "@cosmjs/proto-signing"; +import { TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; +import { CometClient, connectComet } from "@cosmjs/tendermint-rpc"; +import { parseEthAccount } from "./parser"; +import { SigningStargateClient } from "./signingstartgateclient"; +import { Any } from "cosmjs-types/google/protobuf/any"; + +/** + * Create Ethermint-compatible registry with correct public key types. + * @returns Registry configured for Ethermint chains. + */ +function createEthermintRegistry(): Registry { + const registry = new Registry(defaultRegistryTypes); + + // Register Ethermint-specific amino types + registry.register("/ethermint.crypto.v1.ethsecp256k1.PubKey", {} as any); + registry.register("/ethermint.types.v1.EthAccount", {} as any); + + return registry; +} + +/** + * Custom account parser that extends accountFromAny to support EthAccount. + * @param input The Any message containing the account. + * @returns The parsed account. + * @throws Error if parsing fails. + */ +export function ethermintAccountParser(input: Any): Account { + try { + // First try standard Cosmos account parsing for non-EthAccount types + if (input.typeUrl !== "/ethermint.types.v1.EthAccount") { + return accountFromAny(input); + } + + // Handle EthAccount specifically + const ethAccount = parseEthAccount(input); + if (ethAccount?.baseAccount) { + return { + address: ethAccount.baseAccount.address, + accountNumber: Number(ethAccount.baseAccount.accountNumber), + sequence: Number(ethAccount.baseAccount.sequence), + pubkey: null, // EthAccount doesn't store pubkey in the account + } as Account; + } + + // If EthAccount parsing fails, try fallback to standard parsing + return accountFromAny(input); + } catch (error) { + console.error("Failed to parse account:", error); + // Final fallback to standard parsing, let it throw if it fails + return accountFromAny(input); + } +} + +/** + * Create an account parser configured for a specific address prefix. + * @returns A configured account parser function. + */ +export function createEthermintAccountParser() { + return (input: Any): Account => ethermintAccountParser(input); +} + +export class IBCEvmSignerClient extends SigningStargateClient { + constructor(cometClient: CometClient, signer: OfflineSigner, options: SigningStargateClientOptions) { + super(cometClient, signer, options); + } + + /** + * Get the account. + * @param searchAddress The address to search for. + * @returns The account. + */ + async getAccount(searchAddress: string): Promise { + try { + const accountAny = await this.forceGetQueryClient().auth.account(searchAddress); + + if (!accountAny) { + return null; + } + + // Use the custom account parser + return ethermintAccountParser(accountAny); + } catch (error) { + console.error("Failed to get account:", error); + return null; + } + } + + /** + * Create a client with a signer. + * @param cometClient The comet client. + * @param signer The signer. + * @param options The options. + * @returns The client. + */ + static async createWithSigner( + cometClient: CometClient, + signer: OfflineSigner, + options?: SigningStargateClientOptions, + ): Promise { + const defaultOptions: SigningStargateClientOptions = { + accountParser: createEthermintAccountParser(), // Use our custom account parser + registry: createEthermintRegistry(), + aminoTypes: undefined, // Use default amino types + ...options, + }; + return new IBCEvmSignerClient(cometClient, signer, defaultOptions); + } + + /** + * Connect with a signer. + * @param endpoint The endpoint. + * @param signer The signer. + * @param options The options. + * @returns The client. + */ + static async connectWithSigner( + endpoint: string | HttpEndpoint, + signer: OfflineSigner, + options?: SigningStargateClientOptions, + ): Promise { + const cometClient = await connectComet(endpoint); + return await IBCEvmSignerClient.createWithSigner(cometClient, signer, options); + } + + /** + * Send IBC tokens from one chain to another. + * @param senderAddress The address of the sender. + * @param recipientAddress The address of the recipient. + * @param transferAmount The amount of tokens to transfer. + * @param sourcePort The source port. + * @param sourceChannel The source channel. + * @param timeoutHeight The timeout height. + * @param timeoutTimestamp The timeout timestamp (in milliseconds if number, or nanoseconds if bigint). + * @param fee The fee. + * @param memo The memo. + * @returns The deliver tx response. + */ + async sendIbcTokens( + senderAddress: string, + recipientAddress: string, + transferAmount: Coin, + sourcePort: string, + sourceChannel: string, + timeoutHeight: Height | undefined, + timeoutTimestamp: number | bigint | undefined, + fee: StdFee, + memo?: string, + ): Promise { + let timeoutTimestampNanos: bigint | undefined; + + if (timeoutTimestamp !== undefined) { + if (typeof timeoutTimestamp === "bigint") { + // Already in nanoseconds (from calculateTimeoutTimestamp utility) + timeoutTimestampNanos = timeoutTimestamp; + } else { + // Convert from milliseconds to nanoseconds + timeoutTimestampNanos = BigInt(timeoutTimestamp) * BigInt(1000000); + } + } + + const transferMsg = { + typeUrl: "/ibc.applications.transfer.v1.MsgTransfer", + value: MsgTransfer.fromPartial({ + sourcePort: sourcePort, + sourceChannel: sourceChannel, + sender: senderAddress, + receiver: recipientAddress, + token: transferAmount, + timeoutHeight: timeoutHeight, + timeoutTimestamp: timeoutTimestampNanos, + }), + }; + + return this.signAndBroadcast(senderAddress, [transferMsg], fee, memo); + } + + /** + * Sign and broadcast a transaction. + * @param signerAddress The address of the signer. + * @param messages The messages to sign and broadcast. + * @param fee The fee. + * @param memo The memo. + * @param timeoutHeight The timeout height. + * @returns The deliver tx response. + */ + async signAndBroadcast( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee, + memo: string = "", + timeoutHeight?: bigint, + ): Promise { + const txRaw = await this.sign(signerAddress, messages, fee, memo, undefined, timeoutHeight); + const txBytes = TxRaw.encode(txRaw).finish(); + return this.broadcastTx(txBytes, this.broadcastTimeoutMs, this.broadcastPollIntervalMs); + } + + /** + * Sign a transaction. + * @param signerAddress The address of the signer. + * @param messages The messages to sign. + * @param fee The fee. + * @param memo The memo. + * @param explicitSignerData Explicit signer data. + * @param timeoutHeight The timeout height. + * @returns The signed transaction. + */ + async sign( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee, + memo: string, + explicitSignerData?: SignerData, + timeoutHeight?: bigint, + ): Promise { + return super.sign(signerAddress, messages, fee, memo, explicitSignerData as any, timeoutHeight); + } +} diff --git a/modules/cosmos/src/modules/ibc/config.ts b/modules/cosmos/src/modules/ibc/config.ts new file mode 100644 index 0000000..cdc42f6 --- /dev/null +++ b/modules/cosmos/src/modules/ibc/config.ts @@ -0,0 +1,35 @@ +export interface IBCAccount { + mnemonic: string; +} + +export interface IBCGas { + amount: string; + gas: string; +} + +export interface IBCChain { + account: IBCAccount; + evm: boolean; + chainId: string; + rpcUrl: string; + prefix: string; + denom: string; + amount: string; + gas: IBCGas; + channel: string; +} + +export interface IBCChainPair { + srcChain: IBCChain; + dstChain: IBCChain; + port: string; + roundtrip?: boolean; +} + +export interface IBCModuleConfig { + chains: IBCChainPair[]; + heightBuffer: number; + timeoutMinutes: number; + maxIterations: number; + delay: number; +} diff --git a/modules/cosmos/src/modules/ibc/parser.ts b/modules/cosmos/src/modules/ibc/parser.ts new file mode 100644 index 0000000..6bd23f4 --- /dev/null +++ b/modules/cosmos/src/modules/ibc/parser.ts @@ -0,0 +1,208 @@ +import { BaseAccount } from "cosmjs-types/cosmos/auth/v1beta1/auth"; +import { Any } from "cosmjs-types/google/protobuf/any"; + +/** + * EthAccount message structure from ethermint.types.v1. + */ +export interface EthAccount { + baseAccount?: BaseAccount; + codeHash: string; +} + +/** + * Parse EthAccount from protobuf Any message. + * @param accountAny The Any message containing EthAccount. + * @returns Parsed EthAccount or null if parsing fails. + */ +export function parseEthAccount(accountAny: Any): EthAccount | null { + try { + if (accountAny.typeUrl !== "/ethermint.types.v1.EthAccount") { + return null; + } + + const reader = new Uint8Array(accountAny.value); + let position = 0; + + let baseAccount: BaseAccount | undefined; + let codeHash = ""; + + while (position < reader.length) { + const tag = reader[position++]; + const fieldNumber = tag >> 3; + const wireType = tag & 0x07; + + switch (fieldNumber) { + case 1: // base_account + if (wireType === 2) { + // Length-delimited + const length = readVarint(reader, position); + position = skipVarint(reader, position); + const baseAccountBytes = reader.slice(position, position + length); + position += length; + + // Parse BaseAccount with custom parser + baseAccount = parseBaseAccount(baseAccountBytes); + } + break; + case 2: // code_hash + if (wireType === 2) { + // Length-delimited + const length = readVarint(reader, position); + position = skipVarint(reader, position); + const codeHashBytes = reader.slice(position, position + length); + position += length; + codeHash = new TextDecoder().decode(codeHashBytes); + } + break; + default: + // Skip unknown fields + if (wireType === 0) { + // Varint + position = skipVarint(reader, position); + } else if (wireType === 2) { + // Length-delimited + const length = readVarint(reader, position); + position = skipVarint(reader, position); + position += length; + } else { + // For other wire types, try to skip safely + console.warn(`Unknown wire type ${wireType} for field ${fieldNumber}, skipping`); + break; + } + break; + } + } + + return { baseAccount, codeHash }; + } catch (error) { + console.error("Failed to parse EthAccount:", error); + return null; + } +} + +/** + * Parse BaseAccount from protobuf bytes. + * @param bytes The protobuf bytes. + * @returns Parsed BaseAccount. + */ +function parseBaseAccount(bytes: Uint8Array): BaseAccount { + let position = 0; + let address = ""; + let accountNumber = 0n; + let sequence = 0n; + const pubKey: any = null; + + while (position < bytes.length) { + if (position >= bytes.length) break; + + const tag = bytes[position++]; + const fieldNumber = tag >> 3; + const wireType = tag & 0x07; + + switch (fieldNumber) { + case 1: // address + if (wireType === 2) { + const length = readVarint(bytes, position); + position = skipVarint(bytes, position); + + // Address is stored as bech32 string in BaseAccount + const addressBytes = bytes.slice(position, position + length); + address = new TextDecoder().decode(addressBytes); + position += length; + } + break; + case 2: // pub_key + if (wireType === 2) { + const length = readVarint(bytes, position); + position = skipVarint(bytes, position); + // Skip pubkey for now - it's complex to parse + position += length; + } + break; + case 3: // account_number + if (wireType === 0) { + accountNumber = BigInt(readVarint64(bytes, position)); + position = skipVarint(bytes, position); + } + break; + case 4: // sequence + if (wireType === 0) { + sequence = BigInt(readVarint64(bytes, position)); + position = skipVarint(bytes, position); + } + break; + default: + // Skip unknown fields safely + if (wireType === 0) { + position = skipVarint(bytes, position); + } else if (wireType === 2) { + const length = readVarint(bytes, position); + position = skipVarint(bytes, position); + position += length; + } else { + // For other wire types, just skip one byte to avoid infinite loop + console.warn(`Unknown wire type ${wireType} for field ${fieldNumber} in BaseAccount`); + position++; + } + break; + } + } + + return { + address, + pubKey, + accountNumber, + sequence, + } as BaseAccount; +} + +/** + * Read varint from bytes (32-bit). + * @param bytes The bytes array. + * @param position The starting position. + * @returns The varint value. + */ +function readVarint(bytes: Uint8Array, position: number): number { + let result = 0; + let shift = 0; + while (position < bytes.length && shift < 32) { + const byte = bytes[position]; + result |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) break; + shift += 7; + position++; + } + return result; +} + +/** + * Read varint from bytes (64-bit). + * @param bytes The bytes array. + * @param position The starting position. + * @returns The varint value. + */ +function readVarint64(bytes: Uint8Array, position: number): number { + let result = 0; + let shift = 0; + while (position < bytes.length && shift < 64) { + const byte = bytes[position]; + result += (byte & 0x7f) * Math.pow(2, shift); + if ((byte & 0x80) === 0) break; + shift += 7; + position++; + } + return result; +} + +/** + * Skip varint and return the new position. + * @param bytes The bytes array. + * @param position The starting position. + * @returns The position after the varint. + */ +function skipVarint(bytes: Uint8Array, position: number): number { + while (position < bytes.length && (bytes[position] & 0x80) !== 0) { + position++; + } + return position < bytes.length ? position + 1 : position; +} diff --git a/modules/cosmos/src/modules/ibc/pubkey.ts b/modules/cosmos/src/modules/ibc/pubkey.ts new file mode 100644 index 0000000..b65431a --- /dev/null +++ b/modules/cosmos/src/modules/ibc/pubkey.ts @@ -0,0 +1,52 @@ +/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { isEd25519Pubkey, isMultisigThresholdPubkey, isSecp256k1Pubkey, Pubkey } from "@cosmjs/amino"; +import { fromBase64 } from "@cosmjs/encoding"; +import { Uint53 } from "@cosmjs/math"; +import { PubKey as CosmosCryptoEd25519Pubkey } from "cosmjs-types/cosmos/crypto/ed25519/keys"; +import { LegacyAminoPubKey } from "cosmjs-types/cosmos/crypto/multisig/keys"; +import { PubKey as CosmosCryptoSecp256k1Pubkey } from "cosmjs-types/cosmos/crypto/secp256k1/keys"; +import { Any } from "cosmjs-types/google/protobuf/any"; + +const isEvm256k1Pubkey = (pubkey: Pubkey): boolean => { + return pubkey.type === "ethermint.crypto.v1.ethsecp256k1.PubKey"; +}; + +export function encodePubkey(pubkey: Pubkey): Any { + if (isSecp256k1Pubkey(pubkey)) { + const pubkeyProto = CosmosCryptoSecp256k1Pubkey.fromPartial({ + key: fromBase64(pubkey.value), + }); + return Any.fromPartial({ + typeUrl: "/cosmos.crypto.secp256k1.PubKey", + value: Uint8Array.from(CosmosCryptoSecp256k1Pubkey.encode(pubkeyProto).finish()), + }); + } else if (isEvm256k1Pubkey(pubkey)) { + const pubkeyProto = CosmosCryptoSecp256k1Pubkey.fromPartial({ + key: fromBase64(pubkey.value), + }); + return Any.fromPartial({ + typeUrl: "/ethermint.crypto.v1.ethsecp256k1.PubKey", + value: Uint8Array.from(CosmosCryptoSecp256k1Pubkey.encode(pubkeyProto).finish()), + }); + } else if (isEd25519Pubkey(pubkey)) { + const pubkeyProto = CosmosCryptoEd25519Pubkey.fromPartial({ + key: fromBase64(pubkey.value), + }); + return Any.fromPartial({ + typeUrl: "/cosmos.crypto.ed25519.PubKey", + value: Uint8Array.from(CosmosCryptoEd25519Pubkey.encode(pubkeyProto).finish()), + }); + } else if (isMultisigThresholdPubkey(pubkey)) { + const pubkeyProto = LegacyAminoPubKey.fromPartial({ + threshold: Uint53.fromString(pubkey.value.threshold).toNumber(), + publicKeys: pubkey.value.pubkeys.map(encodePubkey), + }); + return Any.fromPartial({ + typeUrl: "/cosmos.crypto.multisig.LegacyAminoPubKey", + value: Uint8Array.from(LegacyAminoPubKey.encode(pubkeyProto).finish()), + }); + } else { + throw new Error(`Pubkey type ${pubkey.type} not recognized`); + } +} diff --git a/modules/cosmos/src/modules/ibc/secp256k1.ts b/modules/cosmos/src/modules/ibc/secp256k1.ts new file mode 100644 index 0000000..da2d910 --- /dev/null +++ b/modules/cosmos/src/modules/ibc/secp256k1.ts @@ -0,0 +1,38 @@ +import { StdSignature } from "@cosmjs/amino"; +import { toBase64 } from "@cosmjs/encoding"; + +/** + * Takes a binary pubkey and signature to create a signature object. + * @param pubkey A compressed secp256k1 public key. + * @param signature A 64 byte fixed length representation of secp256k1 signature components r and s. + * @param isEthermint Whether to use Ethermint pubkey type or standard Cosmos pubkey type. + * @returns A StdSignature containing the encoded public key and base64 signature. + */ +export function encodeSecp256k1Signature(pubkey: Uint8Array, signature: Uint8Array, isEthermint = false): StdSignature { + if (signature.length !== 64) { + throw new Error( + "Signature must be 64 bytes long. Cosmos SDK uses a 2x32 byte fixed length encoding for the secp256k1 signature integers r and s.", + ); + } + + return { + pub_key: encodeSecp256k1Pubkey(pubkey, isEthermint), + signature: toBase64(signature), + }; +} + +/** + * Takes a Secp256k1 public key as raw bytes and returns the Amino JSON representation of it (the type/value wrapper object). + * @param pubkey A compressed secp256k1 public key. + * @param isEthermint Whether to use Ethermint pubkey type or standard Cosmos pubkey type. + * @returns A Secp256k1Pubkey containing the encoded public key. + */ +export function encodeSecp256k1Pubkey(pubkey: Uint8Array, isEthermint = false) { + if (pubkey.length !== 33 || (pubkey[0] !== 0x02 && pubkey[0] !== 0x03)) { + throw new Error("Public key must be compressed secp256k1, i.e. 33 bytes starting with 0x02 or 0x03"); + } + return { + type: isEthermint ? "ethermint.crypto.v1.ethsecp256k1.PubKey" : "cosmos.crypto.secp256k1.PubKey", + value: toBase64(pubkey), + }; +} diff --git a/modules/cosmos/src/modules/ibc/signer.ts b/modules/cosmos/src/modules/ibc/signer.ts new file mode 100644 index 0000000..b9cf7fa --- /dev/null +++ b/modules/cosmos/src/modules/ibc/signer.ts @@ -0,0 +1,364 @@ +/* eslint-disable jsdoc/check-param-names */ +/* eslint-disable jsdoc/require-param */ +/* eslint-disable jsdoc/tag-lines */ +/* eslint-disable jsdoc/require-returns */ +/* eslint-disable jsdoc/require-jsdoc */ +import { makeCosmoshubPath } from "@cosmjs/amino"; +import { + Bip39, + EnglishMnemonic, + HdPath, + pathToString, + Random, + Secp256k1, + Secp256k1Keypair, + sha256, + Slip10, + Slip10Curve, + stringToPath, + keccak256, +} from "@cosmjs/crypto"; +import { fromBase64, fromUtf8, toBase64, toBech32, toUtf8 } from "@cosmjs/encoding"; +import { AccountData, DirectSignResponse, KdfConfiguration, makeSignBytes, OfflineDirectSigner } from "@cosmjs/proto-signing"; +import { decrypt, encrypt, EncryptionConfiguration, executeKdf, supportedAlgorithms } from "@cosmjs/proto-signing/build/wallet"; +import { assert, isNonNullObject } from "@cosmjs/utils"; +import { SignDoc } from "cosmjs-types/cosmos/tx/v1beta1/tx"; +import { encodeSecp256k1Signature } from "./secp256k1"; + +interface AccountDataWithPrivkey extends AccountData { + readonly privkey: Uint8Array; +} + +const serializationTypeV1 = "directsecp256k1hdwallet-v1"; + +/** + * A KDF configuration that is not very strong but can be used on the main thread. + * It takes about 1 second in Node.js 16.0.0 and should have similar runtimes in other modern Wasm hosts. + */ +const basicPasswordHashingOptions: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 24, + memLimitKib: 12 * 1024, + }, +}; + +/** + * This interface describes a JSON object holding the encrypted wallet and the meta data. + * All fields in here must be JSON types. + */ +export interface DirectSecp256k1HdWalletSerialization { + /** A format+version identifier for this serialization format */ + readonly type: string; + /** Information about the key derivation function (i.e. password to encryption key) */ + readonly kdf: KdfConfiguration; + /** Information about the symmetric encryption */ + readonly encryption: EncryptionConfiguration; + /** An instance of Secp256k1HdWalletData, which is stringified, encrypted and base64 encoded. */ + readonly data: string; +} + +/** + * Derivation information required to derive a keypair and an address from a mnemonic. + */ +interface Secp256k1Derivation { + readonly hdPath: HdPath; + readonly prefix: string; +} + +/** + * Derivation information required to derive a keypair and an address from a mnemonic. + * All fields in here must be JSON types. + */ +interface DerivationInfoJson { + readonly hdPath: string; + readonly prefix: string; +} + +function isDerivationJson(thing: unknown): thing is DerivationInfoJson { + if (!isNonNullObject(thing)) return false; + if (typeof (thing as DerivationInfoJson).hdPath !== "string") return false; + if (typeof (thing as DerivationInfoJson).prefix !== "string") return false; + return true; +} + +/** + * The data of a wallet serialization that is encrypted. + * All fields in here must be JSON types. + */ +interface DirectSecp256k1HdWalletData { + readonly mnemonic: string; + readonly accounts: readonly DerivationInfoJson[]; +} + +function extractKdfConfigurationV1(doc: any): KdfConfiguration { + return doc.kdf; +} + +export function extractKdfConfiguration(serialization: string): KdfConfiguration { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + + switch ((root as any).type) { + case serializationTypeV1: + return extractKdfConfigurationV1(root); + default: + throw new Error("Unsupported serialization type"); + } +} + +export interface DirectSecp256k1HdWalletOptions { + /** The password to use when deriving a BIP39 seed from a mnemonic. */ + readonly bip39Password: string; + /** The BIP-32/SLIP-10 derivation paths. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`. */ + readonly hdPaths: readonly HdPath[]; + /** The bech32 address prefix (human readable part). Defaults to "cosmos". */ + readonly prefix: string; +} + +interface DirectSecp256k1HdWalletConstructorOptions extends Partial { + readonly seed: Uint8Array; +} + +const defaultOptions: DirectSecp256k1HdWalletOptions = { + bip39Password: "", + hdPaths: [makeCosmoshubPath(0)], + prefix: "cosmos", +}; + +/** + * Derive Ethereum-style address from secp256k1 public key for Ethermint chains. + * @param pubkey A compressed secp256k1 public key. + * @returns The raw address bytes (20 bytes). + */ +function ethermintPubkeyToRawAddress(pubkey: Uint8Array): Uint8Array { + if (pubkey.length !== 33) { + throw new Error("Public key must be compressed secp256k1 (33 bytes)"); + } + + // Convert compressed pubkey to uncompressed + const uncompressed = Secp256k1.uncompressPubkey(pubkey); + + // Remove the 0x04 prefix byte, keep only the 64-byte coordinates + const pubkeyWithoutPrefix = uncompressed.slice(1); + + // Apply keccak256 hash and take the last 20 bytes for Ethereum address + const hash = keccak256(pubkeyWithoutPrefix); + return hash.slice(-20); +} + +/** A wallet for protobuf based signing using SIGN_MODE_DIRECT */ +export class DirectSecp256k1HdWallet implements OfflineDirectSigner { + /** + * Restores a wallet from the given BIP39 mnemonic. + * + * @param mnemonic Any valid English mnemonic. + * @param options An optional `DirectSecp256k1HdWalletOptions` object optionally containing a bip39Password, hdPaths, and prefix. + */ + static async fromMnemonic(mnemonic: string, options: Partial = {}): Promise { + const mnemonicChecked = new EnglishMnemonic(mnemonic); + const seed = await Bip39.mnemonicToSeed(mnemonicChecked, options.bip39Password); + return new DirectSecp256k1HdWallet(mnemonicChecked, { + ...options, + seed: seed, + }); + } + + /** + * Generates a new wallet with a BIP39 mnemonic of the given length. + * + * @param length The number of words in the mnemonic (12, 15, 18, 21 or 24). + * @param options An optional `DirectSecp256k1HdWalletOptions` object optionally containing a bip39Password, hdPaths, and prefix. + */ + static async generate( + length: 12 | 15 | 18 | 21 | 24 = 12, + options: Partial = {}, + ): Promise { + const entropyLength = 4 * Math.floor((11 * length) / 33); + const entropy = Random.getBytes(entropyLength); + const mnemonic = Bip39.encode(entropy); + return DirectSecp256k1HdWallet.fromMnemonic(mnemonic.toString(), options); + } + + /** + * Restores a wallet from an encrypted serialization. + * + * @param password The user provided password used to generate an encryption key via a KDF. + * This is not normalized internally (see "Unicode normalization" to learn more). + */ + static async deserialize(serialization: string, password: string): Promise { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + switch ((root as any).type) { + case serializationTypeV1: + return DirectSecp256k1HdWallet.deserializeTypeV1(serialization, password); + default: + throw new Error("Unsupported serialization type"); + } + } + /** + * Restores a wallet from an encrypted serialization. + * + * This is an advanced alternative to calling `deserialize(serialization, password)` directly, which allows + * you to offload the KDF execution to a non-UI thread (e.g. in a WebWorker). + * + * The caller is responsible for ensuring the key was derived with the given KDF configuration. This can be + * done using `extractKdfConfiguration(serialization)` and `executeKdf(password, kdfConfiguration)` from this package. + */ + static async deserializeWithEncryptionKey(serialization: string, encryptionKey: Uint8Array): Promise { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + const untypedRoot: any = root; + switch (untypedRoot.type) { + case serializationTypeV1: { + const decryptedBytes = await decrypt(fromBase64(untypedRoot.data), encryptionKey, untypedRoot.encryption); + const decryptedDocument = JSON.parse(fromUtf8(decryptedBytes)); + const { mnemonic, accounts } = decryptedDocument; + assert(typeof mnemonic === "string"); + if (!Array.isArray(accounts)) throw new Error("Property 'accounts' is not an array"); + if (!accounts.every((account) => isDerivationJson(account))) { + throw new Error("Account is not in the correct format."); + } + const firstPrefix = accounts[0].prefix; + if (!accounts.every(({ prefix }) => prefix === firstPrefix)) { + throw new Error("Accounts do not all have the same prefix"); + } + const hdPaths = accounts.map(({ hdPath }) => stringToPath(hdPath)); + return DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { + hdPaths: hdPaths, + prefix: firstPrefix, + }); + } + default: + throw new Error("Unsupported serialization type"); + } + } + + private static async deserializeTypeV1(serialization: string, password: string): Promise { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + const encryptionKey = await executeKdf(password, (root as any).kdf); + return DirectSecp256k1HdWallet.deserializeWithEncryptionKey(serialization, encryptionKey); + } + + /** Base secret */ + private readonly secret: EnglishMnemonic; + /** BIP39 seed */ + private readonly seed: Uint8Array; + /** Derivation instructions */ + private readonly accounts: readonly Secp256k1Derivation[]; + + protected constructor(mnemonic: EnglishMnemonic, options: DirectSecp256k1HdWalletConstructorOptions) { + const prefix = options.prefix ?? defaultOptions.prefix; + const hdPaths = options.hdPaths ?? defaultOptions.hdPaths; + this.secret = mnemonic; + this.seed = options.seed; + this.accounts = hdPaths.map((hdPath) => ({ + hdPath: hdPath, + prefix: prefix, + })); + } + + get mnemonic(): string { + return this.secret.toString(); + } + + async getAccounts(): Promise { + const accountsWithPrivkeys = await this.getAccountsWithPrivkeys(); + return accountsWithPrivkeys.map(({ algo, pubkey, address }) => ({ + algo: algo, + pubkey: pubkey, + address: address, + })); + } + + async signDirect(signerAddress: string, signDoc: SignDoc): Promise { + const accounts = await this.getAccountsWithPrivkeys(); + const account = accounts.find(({ address }) => address === signerAddress); + if (account === undefined) { + throw new Error(`Address ${signerAddress} not found in wallet`); + } + const { privkey, pubkey } = account; + const signBytes = makeSignBytes(signDoc); + const hashedMessage = sha256(signBytes); + const signature = await Secp256k1.createSignature(hashedMessage, privkey); + const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]); + const stdSignature = encodeSecp256k1Signature(pubkey, signatureBytes, true); // true for Ethermint + return { + signed: signDoc, + signature: stdSignature, + }; + } + + /** + * Generates an encrypted serialization of this wallet. + * + * @param password The user provided password used to generate an encryption key via a KDF. + * This is not normalized internally (see "Unicode normalization" to learn more). + */ + async serialize(password: string): Promise { + const kdfConfiguration = basicPasswordHashingOptions; + const encryptionKey = await executeKdf(password, kdfConfiguration); + return this.serializeWithEncryptionKey(encryptionKey, kdfConfiguration); + } + + /** + * Generates an encrypted serialization of this wallet. + * + * This is an advanced alternative to calling `serialize(password)` directly, which allows you to + * offload the KDF execution to a non-UI thread (e.g. in a WebWorker). + * + * The caller is responsible for ensuring the key was derived with the given KDF options. If this + * is not the case, the wallet cannot be restored with the original password. + */ + async serializeWithEncryptionKey(encryptionKey: Uint8Array, kdfConfiguration: KdfConfiguration): Promise { + const dataToEncrypt: DirectSecp256k1HdWalletData = { + mnemonic: this.mnemonic, + accounts: this.accounts.map(({ hdPath, prefix }) => ({ + hdPath: pathToString(hdPath), + prefix: prefix, + })), + }; + const dataToEncryptRaw = toUtf8(JSON.stringify(dataToEncrypt)); + + const encryptionConfiguration: EncryptionConfiguration = { + algorithm: supportedAlgorithms.xchacha20poly1305Ietf, + }; + const encryptedData = await encrypt(dataToEncryptRaw, encryptionKey, encryptionConfiguration); + + const out: DirectSecp256k1HdWalletSerialization = { + type: serializationTypeV1, + kdf: kdfConfiguration, + encryption: encryptionConfiguration, + data: toBase64(encryptedData), + }; + return JSON.stringify(out); + } + + private async getKeyPair(hdPath: HdPath): Promise { + const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, this.seed, hdPath); + const { pubkey } = await Secp256k1.makeKeypair(privkey); + return { + privkey: privkey, + pubkey: Secp256k1.compressPubkey(pubkey), + }; + } + + private async getAccountsWithPrivkeys(): Promise { + return Promise.all( + this.accounts.map(async ({ hdPath, prefix }) => { + const { privkey, pubkey } = await this.getKeyPair(hdPath); + // Use Ethereum-style address derivation for Ethermint chains + const addressBytes = ethermintPubkeyToRawAddress(pubkey); + const address = toBech32(prefix, addressBytes); + return { + algo: "secp256k1" as const, + privkey: privkey, + pubkey: pubkey, + address: address, + }; + }), + ); + } +} diff --git a/modules/cosmos/src/modules/ibc/signingstartgateclient.ts b/modules/cosmos/src/modules/ibc/signingstartgateclient.ts new file mode 100644 index 0000000..e7d342b --- /dev/null +++ b/modules/cosmos/src/modules/ibc/signingstartgateclient.ts @@ -0,0 +1,411 @@ +/* eslint-disable jsdoc/require-returns */ +/* eslint-disable jsdoc/require-jsdoc */ +import { makeSignDoc as makeSignDocAmino, StdFee } from "@cosmjs/amino"; +import { fromBase64 } from "@cosmjs/encoding"; +import { Int53, Uint53 } from "@cosmjs/math"; +import { + EncodeObject, + GeneratedType, + isOfflineDirectSigner, + makeAuthInfoBytes, + makeSignDoc, + OfflineSigner, + Registry, + TxBodyEncodeObject, +} from "@cosmjs/proto-signing"; +import { + StargateClientOptions, + AminoTypes, + GasPrice, + AminoConverters, + createAuthzAminoConverters, + createBankAminoConverters, + createDistributionAminoConverters, + createGovAminoConverters, + createStakingAminoConverters, + createIbcAminoConverters, + createFeegrantAminoConverters, + createVestingAminoConverters, + StargateClient, + DeliverTxResponse, + MsgSendEncodeObject, + MsgDelegateEncodeObject, + MsgUndelegateEncodeObject, + MsgWithdrawDelegatorRewardEncodeObject, + MsgTransferEncodeObject, + calculateFee, +} from "@cosmjs/stargate"; +import { + authzTypes, + bankTypes, + distributionTypes, + feegrantTypes, + govTypes, + groupTypes, + stakingTypes, + ibcTypes, + vestingTypes, +} from "@cosmjs/stargate/build/modules"; +import { CometClient, connectComet, HttpEndpoint } from "@cosmjs/tendermint-rpc"; +import { assert, assertDefined } from "@cosmjs/utils"; +import { Coin } from "cosmjs-types/cosmos/base/v1beta1/coin"; +import { MsgWithdrawDelegatorReward } from "cosmjs-types/cosmos/distribution/v1beta1/tx"; +import { MsgDelegate, MsgUndelegate } from "cosmjs-types/cosmos/staking/v1beta1/tx"; +import { SignMode } from "cosmjs-types/cosmos/tx/signing/v1beta1/signing"; +import { TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; +import { MsgTransfer } from "cosmjs-types/ibc/applications/transfer/v1/tx"; +import { Height } from "cosmjs-types/ibc/core/client/v1/client"; +import { encodeSecp256k1Pubkey } from "./secp256k1"; +import { encodePubkey } from "./pubkey"; + +export const defaultRegistryTypes: ReadonlyArray<[string, GeneratedType]> = [ + ["/cosmos.base.v1beta1.Coin", Coin], + ...authzTypes, + ...bankTypes, + ...distributionTypes, + ...feegrantTypes, + ...govTypes, + ...groupTypes, + ...stakingTypes, + ...ibcTypes, + ...vestingTypes, +]; + +/** + * Signing information for a single signer that is not included in the transaction. + * @see https://github.com/cosmos/cosmos-sdk/blob/v0.42.2/x/auth/signing/sign_mode_handler.go#L23-L37 + */ +export interface SignerData { + readonly accountNumber: number; + readonly sequence: number; + readonly chainId: string; +} + +/** Use for testing only */ +export interface PrivateSigningStargateClient { + readonly registry: Registry; +} + +export interface SigningStargateClientOptions extends StargateClientOptions { + readonly registry?: Registry; + readonly aminoTypes?: AminoTypes; + readonly broadcastTimeoutMs?: number; + readonly broadcastPollIntervalMs?: number; + readonly gasPrice?: GasPrice; +} + +export function createDefaultAminoConverters(): AminoConverters { + return { + ...createAuthzAminoConverters(), + ...createBankAminoConverters(), + ...createDistributionAminoConverters(), + ...createGovAminoConverters(), + ...createStakingAminoConverters(), + ...createIbcAminoConverters(), + ...createFeegrantAminoConverters(), + ...createVestingAminoConverters(), + }; +} + +export class SigningStargateClient extends StargateClient { + readonly registry: Registry; + readonly broadcastTimeoutMs: number | undefined; + readonly broadcastPollIntervalMs: number | undefined; + + private readonly signer: OfflineSigner; + private readonly aminoTypes: AminoTypes; + private readonly gasPrice: GasPrice | undefined; + // Starting with Cosmos SDK 0.47, we see many cases in which 1.3 is not enough anymore + // E.g. https://github.com/cosmos/cosmos-sdk/issues/16020 + private readonly defaultGasMultiplier = 1.4; + + static async connectWithSigner( + endpoint: string | HttpEndpoint, + signer: OfflineSigner, + options: SigningStargateClientOptions = {}, + ): Promise { + const cometClient = await connectComet(endpoint); + return SigningStargateClient.createWithSigner(cometClient, signer, options); + } + + static async createWithSigner( + cometClient: CometClient, + signer: OfflineSigner, + options: SigningStargateClientOptions = {}, + ): Promise { + return new SigningStargateClient(cometClient, signer, options); + } + + static async offline(signer: OfflineSigner, options: SigningStargateClientOptions = {}): Promise { + return new SigningStargateClient(undefined, signer, options); + } + + protected constructor(cometClient: CometClient | undefined, signer: OfflineSigner, options: SigningStargateClientOptions) { + super(cometClient, options); + const { registry = new Registry(defaultRegistryTypes), aminoTypes = new AminoTypes(createDefaultAminoConverters()) } = options; + this.registry = registry; + this.aminoTypes = aminoTypes; + this.signer = signer; + this.broadcastTimeoutMs = options.broadcastTimeoutMs; + this.broadcastPollIntervalMs = options.broadcastPollIntervalMs; + this.gasPrice = options.gasPrice; + } + + async simulate(signerAddress: string, messages: readonly EncodeObject[], memo: string | undefined): Promise { + const anyMsgs = messages.map((m) => this.registry.encodeAsAny(m)); + const accountFromSigner = (await this.signer.getAccounts()).find((account) => account.address === signerAddress); + if (!accountFromSigner) { + throw new Error("Failed to retrieve account from signer"); + } + const pubkey = encodeSecp256k1Pubkey(accountFromSigner.pubkey, true); // true for Ethermint + const { sequence } = await this.getSequence(signerAddress); + const { gasInfo } = await this.forceGetQueryClient().tx.simulate(anyMsgs, memo, pubkey, sequence); + assertDefined(gasInfo); + return Uint53.fromString(gasInfo.gasUsed.toString()).toNumber(); + } + + async sendTokens( + senderAddress: string, + recipientAddress: string, + amount: readonly Coin[], + fee: StdFee | "auto" | number, + memo = "", + ): Promise { + const sendMsg: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: { + fromAddress: senderAddress, + toAddress: recipientAddress, + amount: [...amount], + }, + }; + return this.signAndBroadcast(senderAddress, [sendMsg], fee, memo); + } + + async delegateTokens( + delegatorAddress: string, + validatorAddress: string, + amount: Coin, + fee: StdFee | "auto" | number, + memo = "", + ): Promise { + const delegateMsg: MsgDelegateEncodeObject = { + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: MsgDelegate.fromPartial({ + delegatorAddress: delegatorAddress, + validatorAddress: validatorAddress, + amount: amount, + }), + }; + return this.signAndBroadcast(delegatorAddress, [delegateMsg], fee, memo); + } + + async undelegateTokens( + delegatorAddress: string, + validatorAddress: string, + amount: Coin, + fee: StdFee | "auto" | number, + memo = "", + ): Promise { + const undelegateMsg: MsgUndelegateEncodeObject = { + typeUrl: "/cosmos.staking.v1beta1.MsgUndelegate", + value: MsgUndelegate.fromPartial({ + delegatorAddress: delegatorAddress, + validatorAddress: validatorAddress, + amount: amount, + }), + }; + return this.signAndBroadcast(delegatorAddress, [undelegateMsg], fee, memo); + } + + async withdrawRewards( + delegatorAddress: string, + validatorAddress: string, + fee: StdFee | "auto" | number, + memo = "", + ): Promise { + const withdrawMsg: MsgWithdrawDelegatorRewardEncodeObject = { + typeUrl: "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", + value: MsgWithdrawDelegatorReward.fromPartial({ + delegatorAddress: delegatorAddress, + validatorAddress: validatorAddress, + }), + }; + return this.signAndBroadcast(delegatorAddress, [withdrawMsg], fee, memo); + } + + async sendIbcTokens( + senderAddress: string, + recipientAddress: string, + transferAmount: Coin, + sourcePort: string, + sourceChannel: string, + timeoutHeight: Height | undefined, + /** timeout in seconds */ + timeoutTimestamp: number | undefined, + fee: StdFee | "auto" | number, + memo = "", + ): Promise { + const timeoutTimestampNanoseconds = timeoutTimestamp ? BigInt(timeoutTimestamp) * BigInt(1_000_000_000) : undefined; + const transferMsg: MsgTransferEncodeObject = { + typeUrl: "/ibc.applications.transfer.v1.MsgTransfer", + value: MsgTransfer.fromPartial({ + sourcePort: sourcePort, + sourceChannel: sourceChannel, + sender: senderAddress, + receiver: recipientAddress, + token: transferAmount, + timeoutHeight: timeoutHeight, + timeoutTimestamp: timeoutTimestampNanoseconds, + }), + }; + return this.signAndBroadcast(senderAddress, [transferMsg], fee, memo); + } + + async signAndBroadcast( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee | "auto" | number, + memo = "", + timeoutHeight?: bigint, + ): Promise { + let usedFee: StdFee; + if (fee == "auto" || typeof fee === "number") { + assertDefined(this.gasPrice, "Gas price must be set in the client options when auto gas is used."); + const gasEstimation = await this.simulate(signerAddress, messages, memo); + const multiplier = typeof fee === "number" ? fee : this.defaultGasMultiplier; + usedFee = calculateFee(Math.round(gasEstimation * multiplier), this.gasPrice); + } else { + usedFee = fee; + } + const txRaw = await this.sign(signerAddress, messages, usedFee, memo, undefined, timeoutHeight); + const txBytes = TxRaw.encode(txRaw).finish(); + return this.broadcastTx(txBytes, this.broadcastTimeoutMs, this.broadcastPollIntervalMs); + } + + async signAndBroadcastSync( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee | "auto" | number, + memo = "", + timeoutHeight?: bigint, + ): Promise { + let usedFee: StdFee; + if (fee == "auto" || typeof fee === "number") { + assertDefined(this.gasPrice, "Gas price must be set in the client options when auto gas is used."); + const gasEstimation = await this.simulate(signerAddress, messages, memo); + const multiplier = typeof fee === "number" ? fee : this.defaultGasMultiplier; + usedFee = calculateFee(Math.round(gasEstimation * multiplier), this.gasPrice); + } else { + usedFee = fee; + } + const txRaw = await this.sign(signerAddress, messages, usedFee, memo, undefined, timeoutHeight); + const txBytes = TxRaw.encode(txRaw).finish(); + return this.broadcastTxSync(txBytes); + } + + async sign( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee, + memo: string, + explicitSignerData?: SignerData, + timeoutHeight?: bigint, + ): Promise { + let signerData: SignerData; + if (explicitSignerData) { + signerData = explicitSignerData; + } else { + const { accountNumber, sequence } = await this.getSequence(signerAddress); + const chainId = await this.getChainId(); + signerData = { + accountNumber: accountNumber, + sequence: sequence, + chainId: chainId, + }; + } + + return isOfflineDirectSigner(this.signer) + ? this.signDirect(signerAddress, messages, fee, memo, signerData, timeoutHeight) + : this.signAmino(signerAddress, messages, fee, memo, signerData, timeoutHeight); + } + + private async signAmino( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee, + memo: string, + { accountNumber, sequence, chainId }: SignerData, + timeoutHeight?: bigint, + ): Promise { + assert(!isOfflineDirectSigner(this.signer)); + const accountFromSigner = (await this.signer.getAccounts()).find((account) => account.address === signerAddress); + if (!accountFromSigner) { + throw new Error("Failed to retrieve account from signer"); + } + const pubkey = encodePubkey(encodeSecp256k1Pubkey(accountFromSigner.pubkey, true)); // true for Ethermint + const signMode = SignMode.SIGN_MODE_LEGACY_AMINO_JSON; + const msgs = messages.map((msg) => this.aminoTypes.toAmino(msg)); + const signDoc = makeSignDocAmino(msgs, fee, chainId, memo, accountNumber, sequence, timeoutHeight); + const { signature, signed } = await this.signer.signAmino(signerAddress, signDoc); + const signedTxBody = { + messages: signed.msgs.map((msg) => this.aminoTypes.fromAmino(msg)), + memo: signed.memo, + timeoutHeight: timeoutHeight, + }; + const signedTxBodyEncodeObject: TxBodyEncodeObject = { + typeUrl: "/cosmos.tx.v1beta1.TxBody", + value: signedTxBody, + }; + const signedTxBodyBytes = this.registry.encode(signedTxBodyEncodeObject); + const signedGasLimit = Int53.fromString(signed.fee.gas).toNumber(); + const signedSequence = Int53.fromString(signed.sequence).toNumber(); + const signedAuthInfoBytes = makeAuthInfoBytes( + [{ pubkey, sequence: signedSequence }], + signed.fee.amount, + signedGasLimit, + signed.fee.granter, + signed.fee.payer, + signMode, + ); + return TxRaw.fromPartial({ + bodyBytes: signedTxBodyBytes, + authInfoBytes: signedAuthInfoBytes, + signatures: [fromBase64(signature.signature)], + }); + } + + private async signDirect( + signerAddress: string, + messages: readonly EncodeObject[], + fee: StdFee, + memo: string, + { accountNumber, sequence, chainId }: SignerData, + timeoutHeight?: bigint, + ): Promise { + assert(isOfflineDirectSigner(this.signer)); + const accountFromSigner = (await this.signer.getAccounts()).find((account) => account.address === signerAddress); + if (!accountFromSigner) { + throw new Error("Failed to retrieve account from signer"); + } + const pubkey = encodePubkey(encodeSecp256k1Pubkey(accountFromSigner.pubkey, true)); // true for Ethermint + const txBodyEncodeObject: TxBodyEncodeObject = { + typeUrl: "/cosmos.tx.v1beta1.TxBody", + value: { + messages: messages, + memo: memo, + timeoutHeight: timeoutHeight, + }, + }; + const txBodyBytes = this.registry.encode(txBodyEncodeObject); + const gasLimit = Int53.fromString(fee.gas).toNumber(); + const authInfoBytes = makeAuthInfoBytes([{ pubkey, sequence }], fee.amount, gasLimit, fee.granter, fee.payer); + const signDoc = makeSignDoc(txBodyBytes, authInfoBytes, chainId, accountNumber); + const { signature, signed } = await this.signer.signDirect(signerAddress, signDoc); + return TxRaw.fromPartial({ + bodyBytes: signed.bodyBytes, + authInfoBytes: signed.authInfoBytes, + signatures: [fromBase64(signature.signature)], + }); + } +} diff --git a/modules/cosmos/src/modules/ibc/utils.ts b/modules/cosmos/src/modules/ibc/utils.ts new file mode 100644 index 0000000..dc59c29 --- /dev/null +++ b/modules/cosmos/src/modules/ibc/utils.ts @@ -0,0 +1,92 @@ +import { SigningStargateClient, StargateClient } from "@cosmjs/stargate"; +import { IBCChain, IBCChainPair } from "./config"; +import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing"; +import { IBCEvmSignerClient } from "./client"; +import { DirectSecp256k1HdWallet as EvmDirectSecp256k1HdWallet } from "./signer"; +import { makeCosmoshubPath } from "@cosmjs/amino"; +import { stringToPath } from "@cosmjs/crypto"; + +/** + * Format the test name for IBC transfer. + * @param chainPair The chain pair configuration. + * @returns The formatted test name. + */ +export function formatIBCTestname(chainPair: IBCChainPair): string { + const direction = chainPair.roundtrip ? "<->" : "->"; + return `${chainPair.srcChain.chainId} ${direction} ${chainPair.dstChain.chainId}`; +} + +/** + * Extract the packet sequence from the logs. + * @param events The events to extract the packet sequence from. + * @returns The packet sequence. + */ +export function extractPacketSequenceFromLogs(events: readonly any[]): string | undefined { + const event = events.find((event) => event.type === "send_packet"); + const attr = event?.attributes.find((attr: any) => attr.key === "packet_sequence"); + return attr?.value; +} + +/** + * Verify the IBC packet acknowledgement. + * @param client The client. + * @param channelId The channel ID. + * @param sequence The packet sequence. + * @returns True if the packet was received, false otherwise. + */ +export async function verifyIbcPacketAcknowledgement(client: StargateClient, channelId: string, sequence: string) { + const searchKey = `recv_packet.packet_sequence='${sequence}' AND recv_packet.packet_dst_channel='${channelId}'`; + + const results = await client.searchTx(searchKey); + + return results.length > 0; +} + +/** + * Load the IBC chain client and sender. + * @param chain The chain configuration. + * @returns The client and sender. + */ +export async function loadIbcChain(chain: IBCChain) { + const wallet = chain.evm + ? await EvmDirectSecp256k1HdWallet.fromMnemonic(chain.account.mnemonic, { + prefix: chain.prefix, + hdPaths: [stringToPath("m/44'/60'/0'/0/0")], // Ethereum derivation path + }) + : await DirectSecp256k1HdWallet.fromMnemonic(chain.account.mnemonic, { + prefix: chain.prefix, + hdPaths: [makeCosmoshubPath(0)], // Standard Cosmos derivation path + }); + const client = chain.evm + ? await IBCEvmSignerClient.connectWithSigner(chain.rpcUrl, wallet) + : await SigningStargateClient.connectWithSigner(chain.rpcUrl, wallet); + + const accounts = await wallet.getAccounts(); + const sender = accounts[0].address; + + return { client, sender, accounts }; +} + +/** + * Calculate timeout height for IBC transfer. + * @param client The destination chain client. + * @param heightBuffer The number of blocks to add as buffer (default: 1000). + * @returns The timeout height object. + */ +export async function calculateTimeoutHeight(client: StargateClient, heightBuffer: number = 1000) { + const currentHeight = await client.getHeight(); + return { + revisionNumber: 1, + revisionHeight: currentHeight + heightBuffer, + }; +} + +/** + * Calculate timeout timestamp for IBC transfer. + * @param timeoutMinutes The number of minutes from now for timeout (default: 10). + * @returns The timeout timestamp in nanoseconds. + */ +export function calculateTimeoutTimestamp(timeoutMinutes: number = 10): bigint { + const timeoutMs = Date.now() + timeoutMinutes * 60 * 1000; + return BigInt(timeoutMs) * BigInt(1000000); // Convert to nanoseconds +} diff --git a/modules/cosmos/test/modules/evm/index.test.ts b/modules/cosmos/test/modules/evm/index.test.ts index 4e1500d..03c99c1 100644 --- a/modules/cosmos/test/modules/evm/index.test.ts +++ b/modules/cosmos/test/modules/evm/index.test.ts @@ -17,7 +17,7 @@ describeOrSkip( () => { describeOrSkip( "v1 (evmos)", - () => isChainEnvironment(["devnet", "testnet", "mainnet"], moduleConfig.network as unknown as Chain), + () => isChainEnvironment([], moduleConfig.network as unknown as Chain), () => { let evmClientV1: EvmClientV1; const { @@ -85,7 +85,7 @@ describeOrSkip( describeOrSkip( "v2 (cosmos/evm)", - () => isChainEnvironment(["localnet"], moduleConfig.network as unknown as Chain), + () => isChainEnvironment(["mainnet", "testnet", "devnet", "localnet"], moduleConfig.network as unknown as Chain), () => { let evmClientV2: EvmClientV2; const { diff --git a/modules/cosmos/test/modules/feemarket/index.test.ts b/modules/cosmos/test/modules/feemarket/index.test.ts index 4967a35..5d27a8f 100644 --- a/modules/cosmos/test/modules/feemarket/index.test.ts +++ b/modules/cosmos/test/modules/feemarket/index.test.ts @@ -19,7 +19,7 @@ describeOrSkip( () => { describeOrSkip( "v1 (evmos)", - () => isChainEnvironment(["devnet", "testnet", "mainnet"], moduleConfig.network as unknown as Chain), + () => isChainEnvironment([], moduleConfig.network as unknown as Chain), () => { let feemarketClientV1: FeemarketClientV1; let params: QueryParamsResponseV1; @@ -86,7 +86,7 @@ describeOrSkip( describeOrSkip( "v2 (cosmos/evm)", - () => isChainEnvironment(["localnet"], moduleConfig.network as unknown as Chain), + () => isChainEnvironment(["mainnet", "testnet", "devnet", "localnet"], moduleConfig.network as unknown as Chain), () => { let feemarketClientV2: FeemarketClientV2; let params: QueryParamsResponse; diff --git a/modules/cosmos/test/modules/ibc/index.test.ts b/modules/cosmos/test/modules/ibc/index.test.ts new file mode 100644 index 0000000..e48a099 --- /dev/null +++ b/modules/cosmos/test/modules/ibc/index.test.ts @@ -0,0 +1,131 @@ +import { coins } from "@cosmjs/proto-signing"; +import { assertIsDeliverTxSuccess } from "@cosmjs/stargate"; +import config from "../../../module.config.json"; +import { + extractPacketSequenceFromLogs, + formatIBCTestname, + loadIbcChain, + verifyIbcPacketAcknowledgement, + calculateTimeoutHeight, + calculateTimeoutTimestamp, +} from "../../../src/modules/ibc/utils"; +import { expect } from "chai"; +import { polling } from "@shared/utils"; +import { describeOrSkip, itOrSkip } from "@testing/mocha/utils"; +import { isChainEnvironment, isChainType } from "@testing/mocha/assertions"; +import { Chain } from "@firewatch/core/chain"; + +// TODO: Fix this tests to be executed in mainnet and testnet +describeOrSkip( + "IBCModule", + () => { + return isChainType(["cosmos"], config.network as unknown as Chain) && isChainEnvironment([], config.network as unknown as Chain); + }, + () => { + const { ibc: ibcConfig } = config; + + for (const chainPair of ibcConfig.chains) { + describe(formatIBCTestname(chainPair), () => { + const { srcChain, dstChain } = chainPair; + + let srcClient: any; + let srcSender: string; + let dstClient: any; + let dstSender: string; + + before(async () => { + const srcChainData = await loadIbcChain(srcChain); + const dstChainData = await loadIbcChain(dstChain); + + srcClient = srcChainData.client; + srcSender = srcChainData.sender; + dstClient = dstChainData.client; + dstSender = dstChainData.sender; + }); + + it(`should transfer ${srcChain.amount} ${srcChain.denom} from ${srcChain.chainId} to ${dstChain.chainId}`, async () => { + // Calculate dynamic timeout height and timestamp + const timeoutHeight = await calculateTimeoutHeight(dstClient, ibcConfig.heightBuffer); + const timeoutTimestamp = calculateTimeoutTimestamp(ibcConfig.timeoutMinutes); // 10 minutes + + const result = await srcClient.sendIbcTokens( + srcSender, + dstSender, + { + denom: srcChain.denom, + amount: srcChain.amount, + }, + "transfer", + srcChain.channel, + timeoutHeight, + timeoutTimestamp, + { + amount: coins(srcChain.gas.amount, srcChain.denom), + gas: srcChain.gas.gas, + }, + ); + + assertIsDeliverTxSuccess(result); + + const txDetails = await srcClient.getTx(result.transactionHash); + const sequence = extractPacketSequenceFromLogs(txDetails.events); + + expect(sequence).to.not.equal(undefined); + + const isPacketReceived = await polling( + async () => await verifyIbcPacketAcknowledgement(dstClient, dstChain.channel, sequence!), + (res) => !res, + { + delay: ibcConfig.delay, + maxIterations: ibcConfig.maxIterations, + }, + ); + expect(isPacketReceived).to.equal(true); + }); + + itOrSkip( + `should transfer ${dstChain.amount} ${dstChain.denom} from ${dstChain.chainId} to ${srcChain.chainId}`, + !dstChain.evm, + async () => { + const timeoutHeight = await calculateTimeoutHeight(srcClient, ibcConfig.heightBuffer); + const timeoutTimestamp = calculateTimeoutTimestamp(ibcConfig.timeoutMinutes); // 10 minutes + + const result = await dstClient.sendIbcTokens( + dstSender, + srcSender, + { + denom: dstChain.denom, + amount: dstChain.amount, + }, + "transfer", + dstChain.channel, + timeoutHeight, + timeoutTimestamp, + { + amount: coins(dstChain.gas.amount, dstChain.denom), + gas: dstChain.gas.gas, + }, + ); + + assertIsDeliverTxSuccess(result); + + const txDetails = await dstClient.getTx(result.transactionHash); + const sequence = extractPacketSequenceFromLogs(txDetails.events); + + expect(sequence).to.not.equal(undefined); + + const isPacketReceived = await polling( + async () => await verifyIbcPacketAcknowledgement(srcClient, srcChain.channel, sequence!), + (res) => !res, + { + delay: ibcConfig.delay, + maxIterations: ibcConfig.maxIterations, + }, + ); + expect(isPacketReceived).to.equal(true); + }, + ); + }); + } + }, +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48c0018..a0d389b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,15 +93,30 @@ importers: modules/cosmos: dependencies: + '@cosmjs/amino': + specifier: ^0.33.1 + version: 0.33.1 + '@cosmjs/crypto': + specifier: ^0.33.1 + version: 0.33.1 '@cosmjs/encoding': specifier: ^0.33.1 version: 0.33.1 + '@cosmjs/math': + specifier: ^0.33.1 + version: 0.33.1 + '@cosmjs/proto-signing': + specifier: ^0.33.1 + version: 0.33.1 '@cosmjs/stargate': specifier: ^0.33.0 version: 0.33.0(bufferutil@4.0.5)(utf-8-validate@5.0.7) '@cosmjs/tendermint-rpc': specifier: ^0.33.1 version: 0.33.1(bufferutil@4.0.5)(utf-8-validate@5.0.7) + '@cosmjs/utils': + specifier: ^0.33.1 + version: 0.33.1 '@firewatch/proto-evm': specifier: workspace:* version: link:../../packages/proto/evm @@ -130,12 +145,18 @@ importers: '@shared/tsconfig': specifier: workspace:* version: link:../../packages/shared/tsconfig + '@shared/utils': + specifier: workspace:* + version: link:../../packages/shared/utils '@testing/mocha': specifier: workspace:* version: link:../../packages/testing/mocha '@types/mocha': specifier: ^10.0.10 version: 10.0.10 + '@types/node': + specifier: ^24.0.10 + version: 24.10.14 modules/evm: dependencies: @@ -236,10 +257,10 @@ importers: version: 6.13.5(bufferutil@4.0.5)(utf-8-validate@5.0.7) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.0.9)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) + version: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.2.5(@babel/core@7.26.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@25.0.9)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.2.5(@babel/core@7.26.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: latest version: 5.9.3 @@ -1103,17 +1124,13 @@ packages: '@cosmjs/amino@0.31.3': resolution: {integrity: sha512-36emtUq895sPRX8PTSOnG+lhJDCVyIcE0Tr5ct59sUbgQiI14y43vj/4WAlJ/utSOxy+Zhj9wxcs4AZfu0BHsw==} - '@cosmjs/amino@0.33.0': - resolution: {integrity: sha512-a4qnWGzuM2IrlkDTFQmU7bDd+wNIzyvfcRIZ43i00ZHvTEtrCcWopT94rIv/Zy6fdgkhQ3HWrsGVlIPDT/ibRw==} + '@cosmjs/amino@0.33.1': + resolution: {integrity: sha512-WfWiBf2EbIWpwKG9AOcsIIkR717SY+JdlXM/SL/bI66BdrhniAF+/ZNis9Vo9HF6lP2UU5XrSmFA4snAvEgdrg==} '@cosmjs/crypto@0.31.3': resolution: {integrity: sha512-vRbvM9ZKR2017TO73dtJ50KxoGcFzKtKI7C8iO302BQ5p+DuB+AirUg1952UpSoLfv5ki9O416MFANNg8UN/EQ==} deprecated: This uses elliptic for cryptographic operations, which contains several security-relevant bugs. To what degree this affects your application is something you need to carefully investigate. See https://github.com/cosmos/cosmjs/issues/1708 for further pointers. Starting with version 0.34.0 the cryptographic library has been replaced. However, private keys might still be at risk. - '@cosmjs/crypto@0.33.0': - resolution: {integrity: sha512-kkt06t+cFW2XRGDGUZ0cVf5yoQ2OhZnubwbYbz3QXdyhf1qOXYVPRThfFPsko7dssr+e8Yy4OJKlh5SLA8DXTQ==} - deprecated: This uses elliptic for cryptographic operations, which contains several security-relevant bugs. To what degree this affects your application is something you need to carefully investigate. See https://github.com/cosmos/cosmjs/issues/1708 for further pointers. Starting with version 0.34.0 the cryptographic library has been replaced. However, private keys might still be at risk. - '@cosmjs/crypto@0.33.1': resolution: {integrity: sha512-U4kGIj/SNBzlb2FGgA0sMR0MapVgJUg8N+oIAiN5+vl4GZ3aefmoL1RDyTrFS/7HrB+M+MtHsxC0tvEu4ic/zA==} deprecated: This uses elliptic for cryptographic operations, which contains several security-relevant bugs. To what degree this affects your application is something you need to carefully investigate. See https://github.com/cosmos/cosmjs/issues/1708 for further pointers. Starting with version 0.34.0 the cryptographic library has been replaced. However, private keys might still be at risk. @@ -1136,9 +1153,6 @@ packages: '@cosmjs/math@0.31.3': resolution: {integrity: sha512-kZ2C6glA5HDb9hLz1WrftAjqdTBb3fWQsRR+Us2HsjAYdeE6M3VdXMsYCP5M3yiihal1WDwAY2U7HmfJw7Uh4A==} - '@cosmjs/math@0.33.0': - resolution: {integrity: sha512-B2uOgM12iuIhJWzGuAxGwO6zO+cI8Q4z7mVu7HgFrGJJTM1HtPTYgb55oMOuUN0OZ352MEEm5uAt8sA9jZQqbA==} - '@cosmjs/math@0.33.1': resolution: {integrity: sha512-ytGkWdKFCPiiBU5eqjHNd59djPpIsOjbr2CkNjlnI1Zmdj+HDkSoD9MUGpz9/RJvRir5IvsXqdE05x8EtoQkJA==} @@ -1148,6 +1162,9 @@ packages: '@cosmjs/proto-signing@0.33.0': resolution: {integrity: sha512-UHA92d/Siy3wnce/xhU4iagKrs6r8Ruacc0qeHj3mNrtuUH8f70cD7lzzClzI7wvRLcPprOY0YTeEzqGbPeBFw==} + '@cosmjs/proto-signing@0.33.1': + resolution: {integrity: sha512-Sv4W+MxX+0LVnd+2rU4Fw1HRsmMwSVSYULj7pRkij3wnPwUlTVoJjmKFgKz13ooIlfzPrz/dnNjGp/xnmXChFQ==} + '@cosmjs/socket@0.31.3': resolution: {integrity: sha512-aqrDGGi7os/hsz5p++avI4L0ZushJ+ItnzbqA7C6hamFSCJwgOkXaOUs+K9hXZdX4rhY7rXO4PH9IH8q09JkTw==} @@ -1181,9 +1198,6 @@ packages: '@cosmjs/utils@0.31.3': resolution: {integrity: sha512-VBhAgzrrYdIe0O5IbKRqwszbQa7ZyQLx9nEQuHQ3HUplQW7P44COG/ye2n6AzCudtqxmwdX7nyX8ta1J07GoqA==} - '@cosmjs/utils@0.33.0': - resolution: {integrity: sha512-Y6glwHNlNjcOgwPg8YmNr1PSrNm307EhJVytFt8HmA/G7MRcIA+jIzCL0VlOrWGU4TrAOXvshM+oJZbTIldFRA==} - '@cosmjs/utils@0.33.1': resolution: {integrity: sha512-UnLHDY6KMmC+UXf3Ufyh+onE19xzEXjT4VZ504Acmk4PXxqyvG4cCPprlKUFnGUX7f0z8Or9MAOHXBx41uHBcg==} @@ -2659,6 +2673,9 @@ packages: '@types/node@22.7.5': resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/node@24.10.14': + resolution: {integrity: sha512-OowOUbD1lBCOFIPOZ8xnMIhgqA4sCutMiYOmPHL1PTLt5+y1XA+g2+yC9OOyz8p+deMZqPZLxfMjYIfrKsPeFg==} + '@types/node@25.0.9': resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==} @@ -3436,9 +3453,6 @@ packages: bn.js@4.12.1: resolution: {integrity: sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==} - bn.js@5.2.1: - resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==} - bn.js@5.2.2: resolution: {integrity: sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==} @@ -4621,8 +4635,8 @@ packages: resolution: {integrity: sha512-Tx0r/iXI6r+lRsdvkFDlut0N08jWMnKRZ6Gkq+Nmw75lZe4e6o3EkSnkaBP5NF6+m5PTGAr9JP43N3LyeoglsA==} deprecated: This library has been deprecated and usage is discouraged. - ethereumjs-abi@git+https://git@github.com:ethereumjs/ethereumjs-abi.git#ee3994657fa7a427238e6ba92a84d0b529bbcde0: - resolution: {commit: ee3994657fa7a427238e6ba92a84d0b529bbcde0, repo: git@github.com:ethereumjs/ethereumjs-abi.git, type: git} + ethereumjs-abi@https://codeload.github.com/ethereumjs/ethereumjs-abi/tar.gz/ee3994657fa7a427238e6ba92a84d0b529bbcde0: + resolution: {tarball: https://codeload.github.com/ethereumjs/ethereumjs-abi/tar.gz/ee3994657fa7a427238e6ba92a84d0b529bbcde0} version: 0.6.8 ethereumjs-account@2.0.5: @@ -9286,12 +9300,12 @@ snapshots: '@cosmjs/math': 0.31.3 '@cosmjs/utils': 0.31.3 - '@cosmjs/amino@0.33.0': + '@cosmjs/amino@0.33.1': dependencies: - '@cosmjs/crypto': 0.33.0 + '@cosmjs/crypto': 0.33.1 '@cosmjs/encoding': 0.33.1 - '@cosmjs/math': 0.33.0 - '@cosmjs/utils': 0.33.0 + '@cosmjs/math': 0.33.1 + '@cosmjs/utils': 0.33.1 '@cosmjs/crypto@0.31.3': dependencies: @@ -9299,17 +9313,7 @@ snapshots: '@cosmjs/math': 0.31.3 '@cosmjs/utils': 0.31.3 '@noble/hashes': 1.7.1 - bn.js: 5.2.1 - elliptic: 6.6.1 - libsodium-wrappers-sumo: 0.7.15 - - '@cosmjs/crypto@0.33.0': - dependencies: - '@cosmjs/encoding': 0.33.1 - '@cosmjs/math': 0.33.0 - '@cosmjs/utils': 0.33.0 - '@noble/hashes': 1.7.1 - bn.js: 5.2.1 + bn.js: 5.2.2 elliptic: 6.6.1 libsodium-wrappers-sumo: 0.7.15 @@ -9319,7 +9323,7 @@ snapshots: '@cosmjs/math': 0.33.1 '@cosmjs/utils': 0.33.1 '@noble/hashes': 1.7.1 - bn.js: 5.2.1 + bn.js: 5.2.2 elliptic: 6.6.1 libsodium-wrappers-sumo: 0.7.15 @@ -9352,15 +9356,11 @@ snapshots: '@cosmjs/math@0.31.3': dependencies: - bn.js: 5.2.1 - - '@cosmjs/math@0.33.0': - dependencies: - bn.js: 5.2.1 + bn.js: 5.2.2 '@cosmjs/math@0.33.1': dependencies: - bn.js: 5.2.1 + bn.js: 5.2.2 '@cosmjs/proto-signing@0.31.3': dependencies: @@ -9374,7 +9374,16 @@ snapshots: '@cosmjs/proto-signing@0.33.0': dependencies: - '@cosmjs/amino': 0.33.0 + '@cosmjs/amino': 0.33.1 + '@cosmjs/crypto': 0.33.1 + '@cosmjs/encoding': 0.33.1 + '@cosmjs/math': 0.33.1 + '@cosmjs/utils': 0.33.1 + cosmjs-types: 0.9.0 + + '@cosmjs/proto-signing@0.33.1': + dependencies: + '@cosmjs/amino': 0.33.1 '@cosmjs/crypto': 0.33.1 '@cosmjs/encoding': 0.33.1 '@cosmjs/math': 0.33.1 @@ -9422,13 +9431,13 @@ snapshots: '@cosmjs/stargate@0.33.0(bufferutil@4.0.5)(utf-8-validate@5.0.7)': dependencies: - '@cosmjs/amino': 0.33.0 + '@cosmjs/amino': 0.33.1 '@cosmjs/encoding': 0.33.1 - '@cosmjs/math': 0.33.0 - '@cosmjs/proto-signing': 0.33.0 + '@cosmjs/math': 0.33.1 + '@cosmjs/proto-signing': 0.33.1 '@cosmjs/stream': 0.33.0 '@cosmjs/tendermint-rpc': 0.33.1(bufferutil@4.0.5)(utf-8-validate@5.0.7) - '@cosmjs/utils': 0.33.0 + '@cosmjs/utils': 0.33.1 cosmjs-types: 0.9.0 transitivePeerDependencies: - bufferutil @@ -9487,8 +9496,6 @@ snapshots: '@cosmjs/utils@0.31.3': {} - '@cosmjs/utils@0.33.0': {} - '@cosmjs/utils@0.33.1': {} '@cspotcode/source-map-support@0.8.1': @@ -9901,13 +9908,13 @@ snapshots: dependencies: '@ethersproject/bytes': 5.8.0 '@ethersproject/logger': 5.8.0 - bn.js: 5.2.1 + bn.js: 5.2.2 '@ethersproject/bignumber@5.8.0': dependencies: '@ethersproject/bytes': 5.8.0 '@ethersproject/logger': 5.8.0 - bn.js: 5.2.1 + bn.js: 5.2.2 '@ethersproject/bytes@5.7.0': dependencies: @@ -10117,7 +10124,7 @@ snapshots: '@ethersproject/bytes': 5.8.0 '@ethersproject/logger': 5.8.0 '@ethersproject/properties': 5.8.0 - bn.js: 5.2.1 + bn.js: 5.2.2 elliptic: 6.5.4 hash.js: 1.1.7 @@ -10126,7 +10133,7 @@ snapshots: '@ethersproject/bytes': 5.8.0 '@ethersproject/logger': 5.8.0 '@ethersproject/properties': 5.8.0 - bn.js: 5.2.1 + bn.js: 5.2.2 elliptic: 6.6.1 hash.js: 1.1.7 @@ -11616,6 +11623,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@24.10.14': + dependencies: + undici-types: 7.16.0 + '@types/node@25.0.9': dependencies: undici-types: 7.16.0 @@ -12760,8 +12771,6 @@ snapshots: bn.js@4.12.1: {} - bn.js@5.2.1: {} - bn.js@5.2.2: {} body-parser@1.20.3: @@ -14307,7 +14316,7 @@ snapshots: eth-sig-util@1.4.2: dependencies: - ethereumjs-abi: git+https://git@github.com:ethereumjs/ethereumjs-abi.git#ee3994657fa7a427238e6ba92a84d0b529bbcde0 + ethereumjs-abi: https://codeload.github.com/ethereumjs/ethereumjs-abi/tar.gz/ee3994657fa7a427238e6ba92a84d0b529bbcde0 ethereumjs-util: 5.2.1 eth-sig-util@3.0.0: @@ -14422,7 +14431,7 @@ snapshots: bn.js: 4.12.1 ethereumjs-util: 6.2.1 - ethereumjs-abi@git+https://git@github.com:ethereumjs/ethereumjs-abi.git#ee3994657fa7a427238e6ba92a84d0b529bbcde0: + ethereumjs-abi@https://codeload.github.com/ethereumjs/ethereumjs-abi/tar.gz/ee3994657fa7a427238e6ba92a84d0b529bbcde0: dependencies: bn.js: 4.12.1 ethereumjs-util: 6.2.1 @@ -14519,7 +14528,7 @@ snapshots: ethereumjs-util@7.1.5: dependencies: '@types/bn.js': 5.1.6 - bn.js: 5.2.1 + bn.js: 5.2.2 create-hash: 1.2.0 ethereum-cryptography: 0.1.3 rlp: 2.2.7 @@ -17948,7 +17957,7 @@ snapshots: ripple-keypairs@1.3.1: dependencies: - bn.js: 5.2.1 + bn.js: 5.2.2 brorand: 1.1.0 elliptic: 6.6.1 hash.js: 1.1.7 @@ -17969,7 +17978,7 @@ snapshots: rlp@2.2.7: dependencies: - bn.js: 5.2.1 + bn.js: 5.2.2 rollup@4.34.8: dependencies: @@ -18949,26 +18958,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.26.9) esbuild: 0.24.2 - ts-jest@29.2.5(@babel/core@7.26.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@25.0.9)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - ejs: 3.1.10 - fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@25.0.9)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) - jest-util: 29.7.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.1 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.26.9 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9) - esbuild: 0.24.2 - ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -19577,7 +19566,7 @@ snapshots: web3-utils@1.10.4: dependencies: '@ethereumjs/util': 8.1.0 - bn.js: 5.2.1 + bn.js: 5.2.2 ethereum-bloom-filters: 1.2.0 ethereum-cryptography: 2.2.1 ethjs-unit: 0.1.6