Overview
Crypto wallet engineering spans key derivation, transaction construction, signing, and multi-chain broadcasting. This skill covers production-grade wallet implementation using HD derivation (BIP-32/39/44), EIP-1559 transaction building, WalletConnect v2 integration, hardware wallet support, and cross-chain RPC management.
Core Concepts
A non-custodial wallet generates and stores private keys client-side. Three wallet tiers:
| Tier | Example | Key Storage | Use Case |
|---|---|---|---|
| Hot wallet | Browser extension, mobile app | Encrypted keystore / OS keychain | Daily trading, DeFi interactions |
| Warm wallet | Hardware wallet (Ledger/Trezor) | Secure element, never exposed | Medium-value holdings, frequent use |
| Cold wallet | Air-gapped machine, paper wallet | Offline, manual signing | Long-term storage, large holdings |
When to Use
Trigger phrases:
- "crypto wallet", "Generate wallet from mnemonic"
- "Sign transaction / sign message", "WalletConnect integration"
- "Hardware wallet support", "Multi-chain portfolio manager"
- "DeFi dashboard integration"
Situations:
- Building a non-custodial wallet UI (browser extension, mobile, desktop)
- Integrating wallet connection into a dApp (RainbowKit, Web3Modal, ConnectKit)
- Implementing backend transaction broadcasting or fee estimation
- Supporting EIP-712 typed data signing for gasless permits or off-chain orders
When NOT to Use
- Task is about portfolio management analytics (use portfolio-manager)
- Task is about trading strategy execution (use trading-strategist)
- Task is about DeFi protocol analysis (use defi-protocols)
- You need regulatory compliance or custody infrastructure — custodial wallet management requires separate security patterns
- You only need read-only balances or transaction history without signing (use block explorer APIs instead)
HD Wallet Derivation (BIP-32 / BIP-39 / BIP-44)
BIP-39: Mnemonic Generation
A mnemonic encodes 128-256 bits of entropy into 12-24 words from a 2048-word list. Each word represents 11 bits plus a 4-8 bit checksum.
// ethers.js v6
import { entropyToMnemonic, mnemonicToEntropy, HDNodeWallet, randomBytes } from "ethers";
const entropy = randomBytes(16); // 128 bits → 12 words
const mnemonic = entropyToMnemonic(entropy);
console.log("Mnemonic:", mnemonic);
// Validate
const recovered = mnemonicToEntropy(mnemonic);
console.assert(recovered === entropy);
# web3.py
from eth_account import Account
import os
entropy = os.urandom(16)
account = Account.create_with_mnemonic(extra_entropy=entropy)
mnemonic = account[0]
# Validate
from eth_account.mnemonic import Mnemonic
assert Mnemonic("english").check(mnemonic)
| Entropy (bits) | Checksum (bits) | Words |
|---|---|---|
| 128 | 4 | 12 |
| 192 | 6 | 18 |
| 256 | 8 | 24 |
Passphrase support: An optional passphrase ("25th word") produces a completely different wallet from the same mnemonic.
// Different passphrase = different wallet
const walletNoPass = HDNodeWallet.fromMnemonic(
HDNodeWallet.mnemonicFrom(mnemonic), "m/44'/60'/0'/0/0"
);
const walletWithPass = HDNodeWallet.fromMnemonic(
HDNodeWallet.mnemonicFrom(mnemonic, "my-passphrase"), "m/44'/60'/0'/0/0"
);
// walletNoPass.address !== walletWithPass.address
BIP-32: Hierarchical Deterministic Derivation
A single master seed derives an unlimited tree of keypairs. Hardened paths (apostrophe ') require parent private key; non-hardened paths can derive children from extended public key (xpub).
// ethers.js v6 — derive addresses at any depth
const mnemonic = HDNodeWallet.createRandom().mnemonic;
const derive = (path) => HDNodeWallet.fromMnemonic(mnemonic, path);
const path = "m/44'/60'/0'/0/0"; // Ethereum first address
const wallet = derive(path);
console.log("Address:", wallet.address);
// Iterate addresses
for (let i = 0; i < 5; i++) {
console.log(`Address ${i}:`, derive(`m/44'/60'/0'/0/${i}`).address);
}
// ethers-rs
use ethers::signers::{MnemonicBuilder, coins_bip39::English};
let wallet = MnemonicBuilder::<English>::default()
.phrase(phrase)
.derivation_path("m/44'/60'/0'/0/0")?
.build()?;
println!("{:?}", wallet.address());
BIP-44: Multi-Account Hierarchy
Standardized path: m / purpose' / coin_type' / account' / change / address_index
| Coin | coin_type' | Path Example |
|---|---|---|
| Bitcoin | 0' | m/44'/0'/0'/0/0 |
| Ethereum | 60' | m/44'/60'/0'/0/0 |
| Solana | 501' | m/44'/501'/0'/0' |
| Binance Chain | 714' | m/44'/714'/0'/0/0 |
| Cosmos | 118' | m/44'/118'/0'/0/0 |
| Polkadot | 354' | m/44'/354'/0'/0/0 |
| Litecoin | 2' | m/44'/2'/0'/0/0 |
Address Derivation
Ethereum (EIP-55 Checksummed)
Derivation: keccak256(publicKey) last 20 bytes, with mixed-case checksum.
import { Wallet, getAddress } from "ethers";
const wallet = new Wallet(privateKey);
console.log("Address:", wallet.address); // EIP-55 checksummed
// Validate
console.assert(getAddress(rawAddr) === rawAddr); // throws if invalid checksum
Bitcoin Addresses (P2PKH, P2SH, Bech32)
import * as bitcoin from "bitcoinjs-lib";
import { BIP32Factory } from "bip32";
import * as ecc from "tiny-secp256k1";
const bip32 = BIP32Factory(ecc);
const seed = bip32.fromSeed(Buffer.from(mnemonicToSeed(mnemonic), "hex"));
// P2PKH (legacy, starts with 1)
const p2pkh = bitcoin.payments.p2pkh({
pubkey: seed.derivePath("m/44'/0'/0'/0/0").publicKey,
network: bitcoin.networks.bitcoin,
});
// P2SH-P2WPKH (nested SegWit, starts with 3)
const p2sh = bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2wpkh({
pubkey: seed.derivePath("m/49'/0'/0'/0/0").publicKey,
network: bitcoin.networks.bitcoin,
}),
});
// Bech32 (native SegWit, starts with bc1)
const bech32 = bitcoin.payments.p2wpkh({
pubkey: seed.derivePath("m/84'/0'/0'/0/0").publicKey,
network: bitcoin.networks.bitcoin,
});
EVM Chains
All EVM chains use the same address format — keccak256(pubkey)[12..32] with EIP-55. One address works on Polygon, Arbitrum, Optimism, BSC, Avalanche, Base, and every EVM L2.
import { isAddress } from "viem";
if (!isAddress(input)) throw new Error("Invalid address");
Transaction Building
EIP-1559 (Type 2)
Post-London fork. Uses base fee (burned) + priority fee (tip to validator).
// ethers.js v6
const tx = await wallet.sendTransaction({
to: "0xRecipient",
value: ethers.parseEther("0.1"),
maxFeePerGas: ethers.parseGwei("50"),
maxPriorityFeePerGas: ethers.parseGwei("2"),
gasLimit: 21000n,
chainId: 1,
nonce: await wallet.getNonce(),
type: 2,
});
await tx.wait();
// viem
import { createWalletClient, http, parseEther, parseGwei } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
const account = privateKeyToAccount("0x...");
const client = createWalletClient({ account, chain: mainnet, transport: http() });
const hash = await client.sendTransaction({
to: "0xRecipient", value: parseEther("0.1"),
maxFeePerGas: parseGwei("50"), maxPriorityFeePerGas: parseGwei("2"),
});
await client.waitForTransactionReceipt({ hash });
# web3.py
from web3 import Web3
w3 = Web3(Web3.HTTPProvider("https://eth-mainnet.g.alchemy.com/v2/KEY"))
signed = account.sign_transaction({
"to": "0xRecipient", "value": w3.to_wei(0.1, "ether"),
"gas": 21000, "chainId": 1,
"maxFeePerGas": w3.to_wei(50, "gwei"),
"maxPriorityFeePerGas": w3.to_wei(2, "gwei"),
"nonce": w3.eth.get_transaction_count(account.address),
})
tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction)
Legacy (Type 0) — Pre-EIP-1559
const tx = await wallet.sendTransaction({
to: "0xRecipient", value: ethers.parseEther("0.1"),
gasPrice: ethers.parseGwei("10"),
gasLimit: 21000n, chainId: 1, nonce: nonce, type: 0,
});
EIP-2930 (Type 1) — Access Lists
Pre-declare contract storage slots to reduce gas costs in multi-contract interactions.
const tx = {
to: "0xContract", data: "0x...",
accessList: [{
address: "0xContractAddress",
storageKeys: ["0x00...01", "0x00...02"],
}],
maxFeePerGas: ethers.parseGwei("50"),
maxPriorityFeePerGas: ethers.parseGwei("2"),
chainId: 1, type: 1,
};
EIP-4844 (Type 3) — Blob Transactions
Used for L2 data availability. Requires blob-carrying transaction support.
// viem
const txHash = await walletClient.sendTransaction({
blobs: toBlobSidecar({ data: "0x..." }).blobs,
maxFeePerBlobGas: parseGwei("1"),
account: wallet.account, chain: mainnet,
});
Signing
ECDSA (secp256k1)
Every private key is a random 256-bit scalar on secp256k1.
const wallet = new Wallet(privateKey);
const sig = await wallet.signMessage("Hello");
// 0x... (65 bytes: r,s,v)
personal_sign
Prepends \x19Ethereum Signed Message:\n<len> before hashing.
// viem — verify
import { verifyMessage } from "viem";
const valid = await verifyMessage({
address: userAddress, message: "Login to ExampleApp", signature: sig,
});
EIP-712 (Typed Structured Data)
Used for gasless permits, Uniswap orders, ERC-2612, and typed auth.
// ethers.js v6
const domain = { name: "Uniswap V2", version: "1", chainId: 1, verifyingContract: "0x..." };
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const value = { owner: "0xOwner", spender: "0xSpender", value: ethers.parseUnits("1000", 18) };
const sig = await wallet.signTypedData(domain, types, value);
# web3.py
from eth_account.messages import encode_typed_data
encoded = encode_typed_data(domain, types, value)
signed = account.sign_message(encoded)
ERC-1271 (Contract Signature Validation)
Smart contract wallets need on-chain validation for off-chain signatures.
function verify(address signer, bytes32 hash, bytes calldata sig) internal view returns (bool) {
if (signer.code.length > 0) {
try IERC1271(signer).isValidSignature(hash, sig) returns (bytes4 result) {
return result == 0x1626ba7e;
} catch { return false; }
} else {
return ecrecover(hash, ...) == signer;
}
}
WalletConnect v2
Symmetric key exchange over a relay network for chain-agnostic, session-based communication.
// Wallet-side (in-webapp wallet, @walletconnect/web3wallet)
import { Web3Wallet } from "@walletconnect/web3wallet";
const web3wallet = await Web3Wallet.init({
projectId: "YOUR_PROJECT_ID",
metadata: { name: "MyWallet", description: "...", url: "...", icons: ["..."] },
});
// Handle session proposals
web3wallet.on("session_proposal", async (proposal) => {
const { id, params } = proposal;
// Approve with namespace-scoped methods
await web3wallet.approveSession({
id,
namespaces: {
eip155: {
accounts: [`eip155:1:${address}`, `eip155:137:${address}`],
methods: ["eth_sendTransaction", "personal_sign", "eth_signTypedData"],
events: ["chainChanged", "accountsChanged"],
},
},
});
});
// Handle signing requests
web3wallet.on("session_request", async (event) => {
const { request } = event.params;
if (request.method === "eth_sendTransaction") {
const [tx] = request.params;
const signedTx = await wallet.signTransaction(tx);
await web3wallet.respondSessionRequest({
topic: event.topic,
response: { id: event.id, jsonrpc: "2.0", result: signedTx },
});
}
});
Required namespaces define mandatory chains, methods, and events. Optional namespaces let the wallet add chains without re-approval:
const requiredNamespaces = {
eip155: {
chains: ["eip155:1"], // Ethereum mainnet
methods: ["eth_sendTransaction", "personal_sign"],
events: ["chainChanged", "accountsChanged"],
},
};
const optionalNamespaces = {
eip155: {
chains: ["eip155:137", "eip155:10"], // Polygon, Optimism
methods: ["eth_sendTransaction"],
events: ["chainChanged"],
},
};
Hardware Wallet Integration
Ledger
// @ethers-ext/signer-ledger
import { LedgerSigner } from "@ethers-ext/signer-ledger";
const signer = new LedgerSigner(provider, "44'/60'/0'/0/0");
const addr = await signer.getAddress();
const tx = await signer.sendTransaction({ to: "...", value: ethers.parseEther("0.01") });
await tx.wait();
// viem + @shutter-network/viem-account-ledger
const client = createWalletClient({
account: await ledgerWallet({ path: "m/44'/60'/0'/0/0" }),
chain: mainnet, transport: http(),
});
const hash = await client.sendTransaction({ to: "0x...", value: parseEther("0.01") });
Trezor
import TrezorConnect from "@trezor/connect";
TrezorConnect.init({ manifest: { email: "dev@example.com", appUrl: "https://app.com" } });
const result = await TrezorConnect.ethereumSignTransaction({
path: "m/44'/60'/0'/0/0",
transaction: {
to: "0xRecipient", value: "0x2386f26fc10000",
gasLimit: "0x5208", nonce: "0x0", chainId: 1,
maxFeePerGas: "0xBA43B7400", maxPriorityFeePerGas: "0x77359400",
},
});
Multi-Chain Management
Chain ID Registry
const CHAINS = {
1: { name: "Ethereum", rpc: "https://eth-mainnet.g.alchemy.com/v2/KEY" },
137: { name: "Polygon", rpc: "https://polygon-mainnet.g.alchemy.com/v2/KEY" },
10: { name: "Optimism", rpc: "https://opt-mainnet.g.alchemy.com/v2/KEY" },
42161:{ name: "Arbitrum", rpc: "https://arb-mainnet.g.alchemy.com/v2/KEY" },
8453: { name: "Base", rpc: "https://base-mainnet.g.alchemy.com/v2/KEY" },
};
function getProvider(chainId) {
return new ethers.JsonRpcProvider(CHAINS[chainId].rpc, chainId, { staticNetwork: true });
}
RPC Failover Pattern
// viem fallback transport
import { fallback, http } from "viem";
const transport = fallback([
http("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
http("https://mainnet.infura.io/v3/KEY2"),
http("https://rpc.ankr.com/eth"),
]);
// Manual failover
async function failoverSend(method, params) {
for (const rpc of ["https://.../v2/KEY1", "https://.../v3/KEY2"]) {
try {
const resp = await fetch(rpc, { method: "POST", body: JSON.stringify({ method, params }) });
const json = await resp.json();
if (json.error) throw new Error(json.error.message);
return json.result;
} catch (e) { console.warn("RPC failed:", e.message); }
}
throw new Error("All RPCs failed");
}
Non-EVM Chains
// Solana — Ed25519 (not secp256k1)
import { Keypair, SystemProgram, Transaction, LAMPORTS_PER_SOL, Connection } from "@solana/web3.js";
import { derivePath } from "ed25519-hd-key";
const seed = ethers.utils.mnemonicToSeed(mnemonic);
const derived = derivePath("m/44'/501'/0'/0'", seed.toString("hex"));
const keypair = Keypair.fromSeed(derived.key);
const tx = new Transaction().add(SystemProgram.transfer({
fromPubkey: keypair.publicKey, toPubkey: new PublicKey("recipient"),
lamports: 0.01 * LAMPORTS_PER_SOL,
}));
await connection.sendTransaction(tx, [keypair]);
// Bitcoin — PSBT with bitcoinjs-lib
import * as bitcoin from "bitcoinjs-lib";
import { ECPairFactory } from "ecpair";
const ECPair = ECPairFactory(ecc);
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });
psbt.addInput({ hash: "prevTxId", index: 0 });
psbt.addOutput({ address: "bc1q...", value: 100000 });
psbt.signInput(0, ECPair.fromWIF("..."));
psbt.finalizeInput(0);
const txHex = psbt.extractTransaction().toHex();
await fetch("https://mempool.space/api/tx", { method: "POST", body: txHex });
Gas Estimation
eth_estimateGas vs Simulation
// Estimate with 20% buffer
const estimate = await provider.estimateGas({ from, to, data });
const safeGas = estimate * 120n / 100n;
// Simulate first (catches reverts)
try {
await provider.call({ from, to, data });
} catch (err) {
console.error("Would revert:", err.reason); // Don't send this tx
}
Fee Oracle (EIP-1559)
// ethers.js
const feeData = await provider.getFeeData();
console.log({
maxFee: ethers.formatUnits(feeData.maxFeePerGas, "gwei"),
priority: ethers.formatUnits(feeData.maxPriorityFeePerGas, "gwei"),
});
// Custom: median base fee of last N blocks
async function customOracle(provider, n = 10) {
const fees = [];
for (let i = 0; i < n; i++) {
const block = await provider.getBlock(await provider.getBlockNumber() - i);
if (block.baseFeePerGas) fees.push(Number(block.baseFeePerGas));
}
fees.sort((a, b) => a - b);
const median = fees[Math.floor(fees.length / 2)];
return {
maxFeePerGas: BigInt(Math.floor(median * 2)),
maxPriorityFeePerGas: ethers.parseGwei("2"),
};
}
Per-Chain Gas Profile
| Chain | Gas Model | Block Time | Priority Fee |
|---|---|---|---|
| Ethereum | EIP-1559 | 12s | 1-2 gwei |
| Polygon | EIP-1559 | 2s | 30-50 gwei |
| Arbitrum | L2 rollup | 0.25s | 0.1 gwei |
| Optimism | L2 rollup | 2s | 0.001 gwei |
| BNB Chain | Legacy | 3s | 3 gwei (gasPrice) |
| Avanlanche | EIP-1559 | 2s | 25 nAVAX |
Security
Never Log Private Keys
// WRONG
console.log("Private key:", privateKey); // DANGER
// CORRECT
console.log("Wallet address:", wallet.address);
// For debugging: one-way hash
console.log("PK hash:", ethers.keccak256(privateKey));
Encrypted Storage
// Browser — AES-GCM with PBKDF2
async function encryptAndStore(pk, password) {
const salt = crypto.getRandomValues(new Uint8Array(32));
const key = await crypto.subtle.importKey("raw",
await deriveKey(password, salt), { name: "AES-GCM" }, false, ["encrypt"]);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(pk));
localStorage.setItem("wallet", JSON.stringify({
encrypted: btoa(String.fromCharCode(...new Uint8Array(encrypted))),
iv: btoa(String.fromCharCode(...iv)), salt: btoa(String.fromCharCode(...salt)),
}));
}
// Node.js — OS keychain
import keytar from "keytar";
await keytar.setPassword("MyApp", `wallet-${address}`, privateKey);
Mnemonic Backup Verification
function verifyMnemonic(mnemonic) {
const words = mnemonic.split(" ");
if (![12, 15, 18, 21, 24].includes(words.length)) return false;
// Accept check: user re-enters words at positions 2, 5, 8
const confirmWords = {}; // { 2: "word", 5: "word", 8: "word" }
for (const [idx, word] of Object.entries(confirmWords)) {
if (word !== words[idx]) return false;
}
return Mnemonic("english").check(mnemonic);
}
WalletConnect Session Cleanup
window.addEventListener("beforeunload", () => {
Object.values(web3wallet?.getActiveSessions() || {}).forEach(s =>
web3wallet.disconnectSession({ topic: s.topic }));
});
Rate Limits
RPC providers enforce strict limits. Exceeding them causes 429s or dropped connections.
// Token bucket limiter
class RpcLimiter {
constructor(rps = 10) {
this.tokens = rps; this.max = rps;
this.interval = 1000 / rps; this.lastRefill = Date.now();
}
async acquire() {
this.refill();
if (this.tokens < 1) {
await new Promise(r => setTimeout(r, this.interval));
this.refill();
}
this.tokens--;
}
refill() {
const now = Date.now();
this.tokens = Math.min(this.max, this.tokens + (now - this.lastRefill) / this.interval);
this.lastRefill = now;
}
}
// Batch requests
const batch = new ethers.BatchProvider(provider);
const [b1, b2, b3] = await Promise.all([
batch.getBalance(a1), batch.getBalance(a2), batch.getBalance(a3),
]);
| Provider | Free Tier | Rate Limit |
|---|---|---|
| Alchemy | 300M CU/mo | 330 CU/s |
| Infura | 100K req/day | 100 req/s |
| QuickNode | 250K req/mo | 25 req/s |
| Ankr | 1M req/day | 100 req/s |
| Self-hosted | Unlimited | hardware |
Common Patterns
Nonce Management
class NonceManager {
constructor(address, provider) {
this.address = address; this.provider = provider; this.pending = null;
}
async nextNonce() {
const chain = await this.provider.getTransactionCount(this.address);
if (this.pending === null || chain > this.pending) this.pending = chain;
return this.pending++;
}
async send(tx) { return this.provider.getSigner().sendTransaction({ ...tx, nonce: await this.nextNonce() }); }
}
Fee Bumping (Replace stuck tx)
async function bumpFee(txHash, wallet, increasePct = 20n) {
const tx = await provider.getTransaction(txHash);
if (!tx || tx.confirmations > 0) throw new Error("Already confirmed");
const increase = (100n + increasePct) * tx.maxFeePerGas / 100n;
return wallet.sendTransaction({
to: tx.to, value: tx.value, data: tx.data, nonce: tx.nonce,
maxFeePerGas: increase,
maxPriorityFeePerGas: (100n + increasePct) * tx.maxPriorityFeePerGas / 100n,
gasLimit: tx.gasLimit, chainId: tx.chainId,
});
}
Address Confirmation
function formatAddress(addr) { return `${addr.slice(0, 6)}...${addr.slice(-4)}`; }
// Always display recipient on screen before signing
Red Flags
| Red Flag | Risk | Mitigation |
|---|---|---|
| Private key logged to console | Complete loss of funds | Use structured logging with address only |
| Mnemonic stored in plaintext | Theft on disk compromise | Encrypt with AES-GCM + OS keychain |
| No backup verification | User locks themselves out | Require 3-word confirmation during setup |
| Hardcoded gas prices | Failed or overpriced txns | Use fee oracle + configurable multiplier |
| No nonce tracking | Stuck/collided txns | Use NonceManager pattern |
| Single RPC endpoint | Downtime on provider outage | Use fallback transport |
| Missing chain ID | Replay across chains | Always set chainId in every tx |
| No gas estimation | Wasted gas on revert | Simulate via eth_call first |
| HW path hardcoded | Wrong derivation | Make derivation path configurable |
| Session persists after logout | Session hijacking | Disconnect all sessions on logout |
Verification
- All wallet operations verified end-to-end on testnet before mainnet deployment
- Private keys never exposed in logs, error messages, or debugging output
- Encrypted storage uses AES-256-GCM with PBKDF2; keys managed via OS keychain
- Mnemonic backup requires user confirmation of 3+ random words
- WalletConnect sessions disconnect cleanly on app close
Verification Checklist
- Mnemonic generates valid entropy (12/15/18/21/24 words)
- BIP-44 derivation produces correct address for 3+ coin types
- Passphrase changes produce different wallet addresses
- EIP-55 checksum verification passes on all EVM chains
Transaction Signing
- EIP-1559 transaction sends with maxFeePerGas and maxPriorityFeePerGas
- Legacy transaction sends with gasPrice
- EIP-712 typed data signature verifies correctly with ecrecover
- personal_sign produces recoverable signature
- ERC-1271 contract validation returns correct magic value
- Insufficient balance fails gracefully with error message
- Fee bumping replaces unconfirmed tx within 6 blocks
Multi-Chain
- Same address works on 3+ EVM chains
- Chain ID validation prevents cross-chain replay
- RPC failover switches provider within 5 seconds on timeout
- Gas estimation returns values within 20% of actual cost
Hardware Wallet
- Ledger/Trezor path produces correct address (compare with vendor app)
- Transaction signing works with device
- Device disconnect/reconnect returns correct address
Security
- Private keys never logged, stored, or exposed in error messages
- Encrypted storage uses AES-256-GCM with PBKDF2
- WalletConnect sessions disconnect on app close
- Mnemonic backup requires re-entry of 3+ random words
Anti-Rationalization Table
| Rationalization | Reality |
|---|---|
| "I'll store the mnemonic in app config, it's private" | Configs end up in git, CI logs, or cloud backups. Mnemonics belong on paper or in a hardware wallet. |
| "Custodial is fine for small balances — I'll self-custody later" | Later never comes. Self-custody from the first sat. |
| "Hot wallet is convenient, cold storage is too slow" | A hardware wallet adds 5 seconds per tx for 100x better protection. |
| "I only need one RPC — redundancy is overkill" | One provider downtime during a trade costs more than 3 API keys. |
| "EIP-1559 is complicated, I'll use legacy gasPrice" | Legacy overpays in quiet blocks and fails in congested ones. EIP-1559 saves 10-30%. |
| "I don't need nonce management for simple code" | One double-send or stuck tx from parallel calls wipes months of fee savings. |
| "Hardware wallet support is a future feature" | Adding it post-launch means re-architecting the signing layer. Add the interface day 1. |
| "Backup verification is unnecessary — users will test" | Users who write down the wrong word lose everything. Verify or ship a support nightmare. |
| "My private key is safe in an env file" | process.env leaks through error handlers, logging, and child processes. Encrypt with a password. |
Process
- Determine wallet scope — hot/cold/custodial, single-chain vs multi-chain, hardware support needed
- Select derivation scheme — BIP-44 path per coin type, passphrase policy, account iteration range
- Generate mnemonic — cryptographically secure entropy, 12 or 24 words depending on security requirements
- Implement address derivation — EIP-55 for EVM, Bech32/P2PKH for Bitcoin, Ed25519 for Solana
- Build transaction signing — EIP-1559 with fee estimation, legacy fallback, EIP-712 typed data
- Add multi-chain support — chain ID registry, RPC failover, gas estimation per chain
- Integrate WalletConnect — session proposals, namespace scoping, request handling
- Add hardware wallet — Ledger/Trezor derivation path config, device disconnect handling
- Apply security hardening — encrypted storage, mnemonic backup verification, session cleanup
- Test end-to-end — derive addresses, sign transactions, broadcast on testnet, verify receipts