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).
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

Add to pubspec.yaml:
dependencies:
  pointycastle: ^3.9.1   # SHA-256, RIPEMD-160, Keccak-256
  bech32: ^0.2.2         # Bech32 encoding
  http: ^1.2.2           # HTTP client

Derive Address

Recover the compressed secp256k1 public key from the EVM wallet, then compute RIPEMD-160(SHA-256(pubkey)) and bech32-encode. Public key recovery is done once per session using EIP-191 signing:
import 'dart:typed_data';
import 'package:pointycastle/pointycastle.dart';
import 'package:bech32/bech32.dart';

/// cosmos1... = bech32( RIPEMD-160( SHA-256(compressedPubkey) ) )
String cosmosAddressFromPubkey(Uint8List compressedPubkey) {
  final sha256Hash = Digest('SHA-256').process(compressedPubkey);
  final hash160 = Digest('RIPEMD-160').process(sha256Hash);
  final words = _convertBits(hash160, 8, 5, true);
  return Bech32Codec().encode(Bech32('cosmos', words));
}

List<int> _convertBits(Uint8List data, int fromBits, int toBits, bool pad) {
  var acc = 0;
  var bits = 0;
  final result = <int>[];
  final maxv = (1 << toBits) - 1;
  for (final value in data) {
    acc = (acc << fromBits) | value;
    bits += fromBits;
    while (bits >= toBits) {
      bits -= toBits;
      result.add((acc >> bits) & maxv);
    }
  }
  if (pad && bits > 0) result.add((acc << (toBits - bits)) & maxv);
  return result;
}

/// Derive the Cosmos address and recover the public key.
/// Returns {address, publicKey} where publicKey is the compressed pubkey hex.
/// Uses 'COSMOS_PUBKEY_RECOVERY' as the recovery message.
Future<Map<String, String>> deriveCosmosPubkey({
  required SignEip191 signEip191,
  required String evmAddress,
}) async {
  final compressedPubkey = await recoverEvmPublicKey(
    recoveryMessage: 'COSMOS_PUBKEY_RECOVERY',
    signEip191: signEip191,
    evmAddress: evmAddress,
  );
  return {
    'address': cosmosAddressFromPubkey(compressedPubkey),
    'publicKey': bytesToHex(compressedPubkey),
  };
}
Cache the compressedPubkey — key recovery only needs to happen once per session.

Sign a Message

Cosmos message signing uses the Amino sign/MsgSignData format:
import 'dart:convert';
import 'dart:typed_data';
import 'package:pointycastle/pointycastle.dart';

String _sortedJsonStringify(dynamic obj) {
  if (obj is! Map && obj is! List) return json.encode(obj);
  if (obj is List) {
    return '[${obj.map(_sortedJsonStringify).join(',')}]';
  }
  final map = obj as Map<String, dynamic>;
  final keys = map.keys.toList()..sort();
  return '{${keys.map((k) => '${json.encode(k)}:${_sortedJsonStringify(map[k])}').join(',')}}';
}

/// Compute the Cosmos message digest using Amino sign/MsgSignData format.
String cosmosMessageDigest(String message) {
  final signDocAmino = {
    'chain_id': '',
    'account_number': '0',
    'sequence': '0',
    'fee': {'gas': '0', 'amount': <dynamic>[]},
    'msgs': [
      {
        'type': 'sign/MsgSignData',
        'value': {
          'signer': '',
          'data': base64.encode(utf8.encode(message)),
        },
      }
    ],
    'memo': '',
  };
  final bytes = utf8.encode(_sortedJsonStringify(signDocAmino));
  return bytesToHex(Digest('SHA-256').process(Uint8List.fromList(bytes)));
}

Future<String> signCosmosMessage(String message) async {
  final evmWallet = DynamicSDK.instance.wallets.userWallets
      .firstWhere((w) => w.chain == 'EVM');

  final hexDigest = cosmosMessageDigest(message);

  final signature = await DynamicSDK.instance.wallets.signRawMessage(
    walletId: evmWallet.id,
    accountAddress: evmWallet.address,
    message: hexDigest,
  );

  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 'dart:convert';
import 'dart:typed_data';
import 'package:pointycastle/pointycastle.dart';
import 'package:http/http.dart' as http;

const _lcd = 'https://rest.provider-sentry-01.hub-testnet.polypore.xyz'; // Cosmos Hub provider testnet
const _chainId = 'provider';
const _denom = 'uatom';
const _gasLimit = '200000';
const _feeAmount = '5000';

Future<String> sendCosmosTransfer({
  required String to,
  required double amount,
  required String fromAddress,
  required String pubkeyHex,     // hex-encoded compressed secp256k1 pubkey
  required SignWithEvm signRaw,  // callback: (Uint8List digest) → Future<String>
}) async {
  final microAmount = (amount * 1000000).round().toString();
  final pubkeyBytes = hexToBytes(pubkeyHex);

  // Fetch account info and chain ID in parallel
  final results = await Future.wait([
    _lcdGet('/cosmos/auth/v1beta1/accounts/$fromAddress'),
    _lcdGet('/cosmos/base/tendermint/v1beta1/node_info'),
  ]);

  final account = (results[0]['account'] ?? {}) as Map<String, dynamic>;
  final accountNumber = (account['account_number'] ?? '0').toString();
  final sequence = (account['sequence'] ?? '0').toString();
  final nodeInfo = (results[1]['default_node_info'] ?? {}) as Map<String, dynamic>;
  final chainId = (nodeInfo['network'] ?? _chainId).toString();

  // Build Amino sign doc
  final signDoc = {
    'account_number': accountNumber,
    'chain_id': chainId,
    'fee': {
      'amount': [{'amount': _feeAmount, 'denom': _denom}],
      'gas': _gasLimit,
    },
    'memo': '',
    'msgs': [
      {
        'type': 'cosmos-sdk/MsgSend',
        'value': {
          'amount': [{'amount': microAmount, 'denom': _denom}],
          'from_address': fromAddress,
          'to_address': to,
        },
      },
    ],
    'sequence': sequence,
  };

  final digest = Digest('SHA-256').process(
    Uint8List.fromList(utf8.encode(_sortedJsonStringify(signDoc))),
  );
  final signature = await signRaw(digest);

  // Normalize to low-S (required by Cosmos SDK)
  final sigRaw = hexToBytes(strip0x(signature));
  final sNorm = normalizeLowS(
    BigInt.parse(bytesToHex(sigRaw.sublist(32, 64)), radix: 16),
  );
  final compactSig = hexToBytes(
    bytesToHex(sigRaw.sublist(0, 32)) + sNorm.toRadixString(16).padLeft(64, '0'),
  );

  // Encode as Protobuf TxRaw and broadcast
  // (See flutter-multichain-demo/lib/chains/cosmos.dart for full Protobuf encoding)
  return _broadcastTx(compactSig, pubkeyBytes, signDoc);
}

Future<Map<String, dynamic>> _lcdGet(String path) async {
  final res = await http.get(Uri.parse('$_lcd$path'));
  if (res.statusCode != 200) throw Exception('Cosmos LCD error: $path');
  return json.decode(res.body) as Map<String, dynamic>;
}
For full Protobuf transaction serialization and broadcasting, see the flutter-multichain-demo cosmos.dart.

Check Balance

import 'dart:convert';
import 'package:http/http.dart' as http;

Future<String> getCosmosBalance(String address) async {
  try {
    final data = await _lcdGet('/cosmos/bank/v1beta1/balances/$address');
    final balances = data['balances'] as List?;
    if (balances == null) return '0';
    final coin = balances
        .cast<Map<String, dynamic>>()
        .firstWhere((b) => b['denom'] == _denom, orElse: () => {});
    if (coin.isEmpty) return '0';
    return (BigInt.parse(coin['amount']) / BigInt.from(1000000)).toString();
  } catch (_) {
    return '0';
  }
}