Core logic for generating one key pair and using it to sign transactions on both Cosmos and EVM.
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)
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
};
}
}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()
};
}
}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
);{
"@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"
}One private key from mnemonic used for both chains:
- Key Generation: BIP39 mnemonic → BIP32 HD derivation (m/44'/60'/0'/0/0) → secp256k1 private key
- Address Derivation:
- EVM: Keccak-256 hash of uncompressed public key
- Cosmos: Bech32 encoding of EVM address bytes
- Cosmos Signing:
DirectSecp256k1Wallet.fromKey()with raw bytes - EVM Signing:
ethers.Walletwith hex
Both wallets use the same underlying key.