Overview
Cosmos SDK chains use the secp256k1 elliptic curve — the same as Ethereum. You recover the compressed public key from the EVM wallet, then derive bech32-encoded addresses. The same key works across all Cosmos chains by changing the bech32 prefix (e.g., cosmos for Cosmos Hub, osmo for Osmosis). Transactions use Amino JSON for signing and Protobuf for broadcasting.
| Property | Value |
|---|
| Curve | secp256k1 |
| Root Wallet | EVM |
| Address Format | bech32 (prefix varies by chain) |
| Hashing | SHA-256 + RIPEMD-160 |
| Serialization | Amino JSON (sign) / Protobuf (broadcast) |
| Smallest Unit | Varies (e.g., uatom for Cosmos Hub) |
Dependencies
npm install @noble/curves @noble/hashes bech32
Derive Address
Recover the compressed secp256k1 public key from the EVM wallet, then compute RIPEMD-160(SHA-256(pubkey)) and bech32-encode:
import { secp256k1 } from "@noble/curves/secp256k1";
import { sha256 } from "@noble/hashes/sha2";
import { ripemd160 } from "@noble/hashes/legacy";
import { bech32 } from "bech32";
import { createWaasProvider } from "@dynamic-labs-sdk/client/waas/core";
function addressFromPubkey(compressedPubkey: Uint8Array, prefix: string): string {
const sha256Hash = sha256(compressedPubkey);
const hash160 = ripemd160(sha256Hash);
return bech32.encode(prefix, bech32.toWords(hash160));
}
async function deriveCosmosAddress(
evmWallet: WalletAccount,
dynamicClient: DynamicClient,
bech32Prefix: string = "cosmos",
): Promise<{ address: string; publicKey: string }> {
const compressedPubkey = await recoverEvmPublicKey(
"COSMOS_PUBKEY_RECOVERY",
evmWallet,
dynamicClient,
);
return {
address: addressFromPubkey(compressedPubkey, bech32Prefix),
publicKey: bytesToHex(compressedPubkey),
};
}
Multi-Chain Derivation
Derive addresses for multiple Cosmos chains from a single key recovery:
interface CosmosChainConfig {
id: string;
bech32Prefix: string;
denom: string;
displayDenom: string;
decimals: number;
gasLimit: string;
feeAmount: string;
lcdProxy: string;
}
const COSMOS_HUB: CosmosChainConfig = {
id: "cosmos",
bech32Prefix: "cosmos",
denom: "uatom",
displayDenom: "ATOM",
decimals: 6,
gasLimit: "200000",
feeAmount: "5000",
lcdProxy: "/api/cosmos-rpc?chain=cosmos",
};
const OSMOSIS: CosmosChainConfig = {
id: "osmosis",
bech32Prefix: "osmo",
denom: "uosmo",
displayDenom: "OSMO",
decimals: 6,
gasLimit: "250000",
feeAmount: "7500",
lcdProxy: "/api/cosmos-rpc?chain=osmosis",
};
async function deriveCosmosAddresses(
evmWallet: WalletAccount,
dynamicClient: DynamicClient,
configs: CosmosChainConfig[],
): Promise<Record<string, { address: string; publicKey: string }>> {
const compressedPubkey = await recoverEvmPublicKey(
"COSMOS_PUBKEY_RECOVERY", evmWallet, dynamicClient,
);
const publicKeyHex = bytesToHex(compressedPubkey);
const result: Record<string, { address: string; publicKey: string }> = {};
for (const config of configs) {
result[config.id] = {
address: addressFromPubkey(compressedPubkey, config.bech32Prefix),
publicKey: publicKeyHex,
};
}
return result;
}
Sign a Message
Sign using a chain-specific message prefix via the EVM WaaS provider:
import { sha256 } from "@noble/hashes/sha2";
import { createWaasProvider } from "@dynamic-labs-sdk/client/waas/core";
async function signCosmosMessage(
message: string,
evmWallet: WalletAccount,
dynamicClient: DynamicClient,
displayDenom: string = "ATOM",
): Promise<string> {
const provider = createWaasProvider({
sdkClient: dynamicClient,
chain: "EVM",
});
const messageBytes = new TextEncoder().encode(message);
const prefix = new TextEncoder().encode(`\x19${displayDenom} Signed Message:\n`);
const lengthStr = new TextEncoder().encode(String(messageBytes.length));
const digest = sha256(concatBytes(prefix, lengthStr, messageBytes));
const { signature } = await provider.signRawMessage({
message: bytesToHex(digest),
walletAccount: evmWallet,
});
return signature;
}
Verify a Signature
Recover the public key from the signature and compare it to the stored key:
import { secp256k1 } from "@noble/curves/secp256k1";
import { sha256 } from "@noble/hashes/sha2";
function verifyCosmosSignature(
message: string,
signature: string,
publicKey: string,
displayDenom: string = "ATOM",
): boolean {
const messageBytes = new TextEncoder().encode(message);
const prefix = new TextEncoder().encode(`\x19${displayDenom} Signed Message:\n`);
const lengthStr = new TextEncoder().encode(String(messageBytes.length));
const digest = sha256(concatBytes(prefix, lengthStr, messageBytes));
const sigBytes = hexToBytes(strip0x(signature));
const rsHex = bytesToHex(sigBytes.slice(0, 64));
let v = sigBytes[64];
if (v >= 27) v -= 27;
try {
const sig = secp256k1.Signature.fromHex(rsHex).addRecoveryBit(v);
const recovered = sig.recoverPublicKey(digest);
return recovered.toHex(true) === publicKey.toLowerCase();
} catch {
return false;
}
}
Check Balance
Query the balance via the Cosmos LCD REST API:
async function getCosmosBalance(
address: string,
config: CosmosChainConfig,
): Promise<string> {
const res = await fetch(
`${config.lcdProxy}/cosmos/bank/v1beta1/balances/${address}`,
);
if (!res.ok) return "0";
const data = await res.json();
const coin = (data.balances || []).find(
(b: { denom: string }) => b.denom === config.denom,
);
if (!coin) return "0";
return (Number(BigInt(coin.amount)) / 10 ** config.decimals).toString();
}
Send a Transfer
Build an Amino JSON sign document, sign the SHA-256 hash, normalize to low-S, then encode the transaction in Protobuf for broadcasting.
Low-S Normalization
Cosmos SDK requires canonical low-S signatures:
const SECP256K1_N = BigInt(
"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141",
);
const SECP256K1_HALF_N = SECP256K1_N / BigInt(2);
function normalizeLowS(s: bigint): bigint {
return s > SECP256K1_HALF_N ? SECP256K1_N - s : s;
}
Protobuf Encoding Helpers
function pbVarint(n: number | bigint): Uint8Array {
const num = typeof n === "bigint" ? Number(n) : n;
const bytes: number[] = [];
let val = num >>> 0;
while (val > 0x7f) {
bytes.push((val & 0x7f) | 0x80);
val >>>= 7;
}
bytes.push(val);
return new Uint8Array(bytes);
}
function pbLengthDelimited(fieldNumber: number, data: Uint8Array): Uint8Array {
const tag = pbVarint((fieldNumber << 3) | 2);
const len = pbVarint(data.length);
return concatBytes(tag, len, data);
}
function pbString(fieldNumber: number, s: string): Uint8Array {
return pbLengthDelimited(fieldNumber, new TextEncoder().encode(s));
}
function pbVarintField(fieldNumber: number, value: number | bigint): Uint8Array {
const tag = pbVarint((fieldNumber << 3) | 0);
return concatBytes(tag, pbVarint(value));
}
Full Transfer Implementation
async function sendCosmosTransfer(
to: string,
amount: number,
fromAddress: string,
publicKey: string,
evmWallet: WalletAccount,
dynamicClient: DynamicClient,
config: CosmosChainConfig,
): Promise<string> {
const provider = createWaasProvider({
sdkClient: dynamicClient,
chain: "EVM",
});
const microAmount = BigInt(Math.round(amount * 10 ** config.decimals)).toString();
// Fetch account info and chain ID
const [accountData, nodeData] = await Promise.all([
cosmosLcd(config, `/cosmos/auth/v1beta1/accounts/${fromAddress}`),
cosmosLcd(config, `/cosmos/base/tendermint/v1beta1/node_info`),
]);
const account = accountData.account || {};
const accountNumber = account.account_number || "0";
const sequence = account.sequence || "0";
const chainId = nodeData.default_node_info?.network || config.id;
// Build Amino JSON sign doc (keys must be sorted)
const signDoc = {
account_number: accountNumber,
chain_id: chainId,
fee: {
amount: [{ amount: config.feeAmount, denom: config.denom }],
gas: config.gasLimit,
},
memo: "",
msgs: [{
type: "cosmos-sdk/MsgSend",
value: {
amount: [{ amount: microAmount, denom: config.denom }],
from_address: fromAddress,
to_address: to,
},
}],
sequence: sequence,
};
// Sort keys and hash
const signDocBytes = new TextEncoder().encode(sortedJsonStringify(signDoc));
const digest = sha256(signDocBytes);
// Sign
const { signature } = await provider.signRawMessage({
message: bytesToHex(digest),
walletAccount: evmWallet,
});
// Normalize to low-S
const sigRaw = hexToBytes(strip0x(signature));
const rHex = bytesToHex(sigRaw.slice(0, 32));
const sNormalized = normalizeLowS(BigInt("0x" + bytesToHex(sigRaw.slice(32, 64))));
const sHex = sNormalized.toString(16).padStart(64, "0");
const compactSig = hexToBytes(rHex + sHex);
// Encode as Protobuf TxRaw and broadcast
const txRaw = buildProtobufTxRaw(
fromAddress, to, config, microAmount, publicKey, compactSig, sequence,
);
const txBase64 = btoa(String.fromCharCode(...txRaw));
const result = await cosmosLcd(config, "/cosmos/tx/v1beta1/txs", "POST", {
tx_bytes: txBase64,
mode: "BROADCAST_MODE_SYNC",
});
return result.tx_response?.txhash || "";
}
The Amino JSON sign document requires keys to be sorted recursively. Use a canonical JSON serializer that sorts keys at every level.