Skip to main content

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).
PropertyValue
Curvesecp256k1
Root WalletEVM
Address Formatbech32 (prefix varies by chain)
HashingSHA-256 + RIPEMD-160
SerializationAmino JSON (sign) / Protobuf (broadcast)
Smallest UnitVaries (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 { sha256 } from "@noble/hashes/sha2";
import { ripemd160 } from "@noble/hashes/legacy";
import { bech32 } from "bech32";

function addressFromPubkey(compressedPubkey: Uint8Array, prefix: string): string {
  const hash160 = ripemd160(sha256(compressedPubkey));
  return bech32.encode(prefix, bech32.toWords(hash160));
}

async function deriveCosmosAddress(bech32Prefix: string = "cosmos"): Promise<{
  address: string;
  publicKey: string;
}> {
  const compressedPubkey = await recoverEvmPubkey("COSMOS_PUBKEY_RECOVERY");
  return {
    address: addressFromPubkey(compressedPubkey, bech32Prefix),
    publicKey: bytesToHex(compressedPubkey),
  };
}
Cache the compressedPubkey — key recovery only needs to happen once per session.

Sign a Message

import { sha256 } from "@noble/hashes/sha2";

async function signCosmosMessage(
  message: string,
  displayDenom: string = "ATOM",
): Promise<string> {
  const wallet = dynamicClient.wallets.userWallets.find(w => w.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 dynamicClient.wallets.waas.signRawMessage(wallet.id, {
    accountAddress: wallet.address,
    message: bytesToHex(digest),
  });

  return signature;
}

Sign a Transaction

Build an Amino JSON sign document, sign the SHA-256 hash, normalize to low-S, then encode in Protobuf for broadcasting:
import { sha256 } from "@noble/hashes/sha2";

async function signCosmosTransaction(signDoc: object): Promise<Uint8Array> {
  const wallet = dynamicClient.wallets.userWallets.find(w => w.chain === "EVM")!;

  const signDocBytes = new TextEncoder().encode(JSON.stringify(signDoc));
  const digest = sha256(signDocBytes);

  const signature = await dynamicClient.wallets.waas.signRawMessage(wallet.id, {
    accountAddress: wallet.address,
    message: bytesToHex(digest),
  });

  // Normalize to low-S (required by Cosmos SDK)
  const sigRaw = hexToBytes(signature.replace(/^0x/, ""));
  const SECP256K1_N = BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141");
  const HALF_N = SECP256K1_N / BigInt(2);
  const s = BigInt("0x" + bytesToHex(sigRaw.slice(32, 64)));
  const sNorm = s > HALF_N ? SECP256K1_N - s : s;

  const result = new Uint8Array(64);
  result.set(sigRaw.slice(0, 32));
  const sBytes = hexToBytes(sNorm.toString(16).padStart(64, "0"));
  result.set(sBytes, 32);
  return result;
}
For full transaction serialization (Protobuf encoding, broadcasting) see the JavaScript Cosmos guide. The only difference in React Native is the signing call.

Verify a Signature

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(signature.replace(/^0x/, ""));
  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);
    return sig.recoverPublicKey(digest).toHex(true) === publicKey.toLowerCase();
  } catch {
    return false;
  }
}

Check Balance

async function getCosmosBalance(address: string, lcdUrl: string, denom: string): Promise<string> {
  const res = await fetch(`${lcdUrl}/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 === denom);
  if (!coin) return "0";
  return (Number(BigInt(coin.amount)) / 1e6).toString();
}