Skip to content

Instantly share code, notes, and snippets.

@Cordtus
Created November 16, 2025 00:24
Show Gist options
  • Select an option

  • Save Cordtus/19113817a15e2a26fa65e3452cd92d17 to your computer and use it in GitHub Desktop.

Select an option

Save Cordtus/19113817a15e2a26fa65e3452cd92d17 to your computer and use it in GitHub Desktop.
Key Generation & Transaction Signing - Unified Key for Cosmos + EVM

Key Generation & Transaction Signing

Unified Key for Cosmos + EVM

Core logic for generating one key pair and using it to sign transactions on both Cosmos and EVM.


Key Generation from Mnemonic

From src/SecureKeyManager.js:

import { keccak_256 } from '@noble/hashes/sha3';
import { secp256k1 } from '@noble/curves/secp256k1';
import { mnemonicToSeedSync, validateMnemonic } from 'bip39';
import { BIP32Factory } from 'bip32';
import * as ecc from 'tiny-secp256k1';
import { bech32 } from 'bech32';

const bip32 = BIP32Factory(ecc);

class SecureKeyManager {
  async initialize() {
    const mnemonic = process.env.MNEMONIC;
    if (!validateMnemonic(mnemonic)) {
      throw new Error('Invalid mnemonic');
    }

    // Derive private key
    const seed = mnemonicToSeedSync(mnemonic);
    const root = bip32.fromSeed(seed);
    const node = root.derivePath("m/44'/60'/0'/0/0");
    const privateKeyBytes = node.privateKey;

    // Generate public keys
    const publicKeyBytes = secp256k1.getPublicKey(privateKeyBytes, false);
    const publicKeyBytesCompressed = secp256k1.getPublicKey(privateKeyBytes, true);

    // Derive both addresses from same key
    const evmAddress = this._deriveEvmAddress(publicKeyBytes);
    const cosmosAddress = this._deriveCosmosAddress(evmAddress);

    // Store keys
    this._keys.set('privateKey', privateKeyBytes);
    this._keys.set('publicKey', publicKeyBytesCompressed);

    this._addressCache = {
      evm: { address: evmAddress, publicKey: '0x' + Buffer.from(publicKeyBytesCompressed).toString('hex') },
      cosmos: { address: cosmosAddress, publicKey: Buffer.from(publicKeyBytesCompressed).toString('hex') }
    };
  }

  _deriveEvmAddress(publicKeyBytes) {
    // Remove 0x04 prefix, hash with Keccak-256, take last 20 bytes
    const publicKeyWithoutPrefix = publicKeyBytes.slice(1);
    const addressBytes = keccak_256(publicKeyWithoutPrefix).slice(-20);
    return '0x' + Buffer.from(addressBytes).toString('hex');
  }

  _deriveCosmosAddress(evmAddressHex) {
    // Bech32 encode EVM address bytes
    const addressBytes = Buffer.from(evmAddressHex.replace('0x', ''), 'hex');
    const words = bech32.toWords(addressBytes);
    return bech32.encode('cosmos', words);
  }

  getPrivateKeyHex() {
    return '0x' + Buffer.from(this._keys.get('privateKey')).toString('hex');
  }

  getPrivateKeyBytes() {
    return this._keys.get('privateKey');
  }
}

Derivation flow:

Mnemonic → Seed → HD Root → HD Node (m/44'/60'/0'/0/0) → Private Key
                                                            ↓
                                                      Public Key (secp256k1)
                                                            ↓
                                    ┌───────────────────────┴───────────────────────┐
                                    ↓                                               ↓
                        EVM Address (Keccak-256)                    Cosmos Address (Bech32)

Cosmos Wallet Init & Signing

From src/services/CosmosService.js:

import { DirectSecp256k1Wallet } from "@cosmjs/proto-signing";
import { SigningStargateClient } from "@cosmjs/stargate";
import { MsgSend } from "cosmjs-types/cosmos/bank/v1beta1/tx.js";

class CosmosService {
  async initialize() {
    const privateKeyBytes = this.keyManager.getPrivateKeyBytes();
    this.wallet = await DirectSecp256k1Wallet.fromKey(privateKeyBytes, 'cosmos');

    this.client = await SigningStargateClient.connectWithSigner(
      this.config.network.endpoints.cosmos.rpc,
      this.wallet
    );
  }

  async sendTokens(recipientAddress, amounts) {
    const accounts = await this.wallet.getAccounts();
    const senderAddress = accounts[0].address;

    const msg = {
      typeUrl: "/cosmos.bank.v1beta1.MsgSend",
      value: MsgSend.fromPartial({
        fromAddress: senderAddress,
        toAddress: recipientAddress,
        amount: amounts  // [{ denom: "uatom", amount: "1000000" }]
      })
    };

    const result = await this.client.signAndBroadcast(
      senderAddress,
      [msg],
      { amount: [{ amount: "5000", denom: "uatom" }], gas: "200000" },
      "Faucet distribution"
    );

    return {
      txHash: result.transactionHash,
      height: result.height,
      gasUsed: result.gasUsed
    };
  }
}

EVM Wallet Init & Signing

From src/services/EVMService.js:

import { Wallet, JsonRpcProvider, Contract, parseEther, parseUnits } from 'ethers';

class EVMService {
  async initialize() {
    this.provider = new JsonRpcProvider(this.config.network.endpoints.evm.rpc);
    const privateKey = this.keyManager.getPrivateKeyHex();
    this.wallet = new Wallet(privateKey, this.provider);
  }

  async sendNativeTokens(recipientAddress, amount) {
    const tx = await this.wallet.sendTransaction({
      to: recipientAddress,
      value: parseEther(amount.toString()),
      gasLimit: 21000
    });

    const receipt = await tx.wait();
    return {
      txHash: receipt.hash,
      blockNumber: receipt.blockNumber,
      gasUsed: receipt.gasUsed.toString()
    };
  }

  async sendERC20Token(tokenAddress, recipientAddress, amount, decimals = 18) {
    const abi = ['function transfer(address to, uint256 amount) returns (bool)'];
    const contract = new Contract(tokenAddress, abi, this.wallet);

    const parsedAmount = parseUnits(amount.toString(), decimals);
    const tx = await contract.transfer(recipientAddress, parsedAmount, { gasLimit: 100000 });

    const receipt = await tx.wait();
    return {
      txHash: receipt.hash,
      blockNumber: receipt.blockNumber,
      gasUsed: receipt.gasUsed.toString()
    };
  }
}

Alternative: Direct Init from Mnemonic

From src/server/faucet.js:

import { ethers } from 'ethers';
import { DirectSecp256k1Wallet } from "@cosmjs/proto-signing";
import { SigningStargateClient } from "@cosmjs/stargate";

const MNEMONIC = process.env.MNEMONIC;

// EVM wallet
const evmWallet = ethers.Wallet.fromPhrase(MNEMONIC);
const evmProvider = new ethers.JsonRpcProvider(rpcUrl);
const connectedWallet = evmWallet.connect(evmProvider);

// Cosmos wallet - same HD path as EVM
const cosmosWallet = await DirectSecp256k1Wallet.fromMnemonic(MNEMONIC, {
  hdPaths: ["m/44'/60'/0'/0/0"],
  prefix: "cosmos"
});

const cosmosClient = await SigningStargateClient.connectWithSigner(
  cosmosRpcUrl,
  cosmosWallet
);

Dependencies

{
  "@noble/curves": "secp256k1 operations",
  "@noble/hashes": "Keccak-256 hashing",
  "bip39": "Mnemonic validation",
  "bip32": "HD wallet derivation",
  "tiny-secp256k1": "ECC operations",
  "bech32": "Cosmos address encoding",
  "ethers": "EVM wallet + signing",
  "@cosmjs/proto-signing": "Cosmos wallet creation",
  "@cosmjs/stargate": "Cosmos signing",
  "cosmjs-types": "Cosmos message types"
}

Summary

One private key from mnemonic used for both chains:

  1. Key Generation: BIP39 mnemonic → BIP32 HD derivation (m/44'/60'/0'/0/0) → secp256k1 private key
  2. Address Derivation:
    • EVM: Keccak-256 hash of uncompressed public key
    • Cosmos: Bech32 encoding of EVM address bytes
  3. Cosmos Signing: DirectSecp256k1Wallet.fromKey() with raw bytes
  4. EVM Signing: ethers.Wallet with hex

Both wallets use the same underlying key.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment