Working with EVM Chains
One base class
Ethereum and Base extend AbstractEVMBlockchain. Keys, addresses, validation, and message signing all live in the base, so a new EVM chain is two lines:
import { AbstractEVMBlockchain } from "@agntn/keys";
import { BIP44 } from "@agntn/keys/bip44";
export default class Ethereum extends AbstractEVMBlockchain {
override readonly name = "ethereum";
override readonly bip44 = BIP44.ETHEREUM;
}
Base looks identical apart from the name. It even keeps coin type 60, because that's what every wallet does for L2s.
Same key, same address everywhere
import { useBlockchain } from "@agntn/keys";
import Ethereum from "@agntn/keys/blockchains/ethereum";
import Base from "@agntn/keys/blockchains/base";
const ethereumChain = useBlockchain(new Ethereum());
const baseChain = useBlockchain(new Base());
const privateKey = ethereumChain.generateKeyPrivate();
const ethAddress = ethereumChain.getAddress(ethereumChain.getKeyPublic(privateKey));
const baseAddress = baseChain.getAddress(baseChain.getKeyPublic(privateKey));
ethAddress === baseAddress; // true
This is a feature of the EVM, not of the library. Address is a function of the public key alone, so one wallet serves every EVM chain. It also means sending to the wrong chain lands the funds at the right address on the wrong network, which is a much worse afternoon than a typo.
The address
Take the uncompressed public key without the leading 04, Keccak-256 it, keep the last 20 bytes, add 0x. Then EIP-55:
"0x7e5f4552091a69125d5dfcb7b8c2659029395bdf"; // valid, no checksum
"0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; // same address, checksummed
The checksum hides in the letter case. Hash the lowercase hex, and every character whose hash nibble is 8 or more gets uppercased. getAddress always returns the checksummed form.
Validation
ethereumChain.validateAddress("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); // true
ethereumChain.validateAddress("0x7e5f4552091a69125d5dfcb7b8c2659029395bdf"); // true, all lowercase
ethereumChain.validateAddress("0x7e5F4552091A69125d5DfCb7b8C2659029395Bdf"); // false, mixed case, wrong checksum
All lowercase and all uppercase pass, because those carry no checksum and plenty of tooling still emits them. Mixed case has to match EIP-55 exactly.
Signing
signMessage uses the personal_sign preamble, "\x19Ethereum Signed Message:\n" + length + message, hashed with Keccak-256 and signed with secp256k1. That's the hash MetaMask and ethers sign, so the same key gives the same r and s here and there. Default output is 64 bytes of r||s, which verifyMessage here takes as is.
Everything outside this package wants the v too. Ask for it:
ethereumChain.signMessage("hello", privateKey, { recovered: true });
// 65 bytes now, r||s||v with v as 27 or 28, byte for byte what ethers signMessage returns
Don't hand the 64-byte one to ethers and hope. ethers.verifyMessage reads 64 bytes as an EIP-2098 compact signature, where the top bit of s carries the parity, and low-s signatures always have that bit clear. So it doesn't throw, it just answers with the v = 27 address, wrong half the time. A silent wrong address is worse than an error, and that's the whole reason for the flag.
Who signed this?
Somebody hands you a signature and says the address is theirs? The 65 bytes give the key away. recoverMessageSigner reads it back for a personal_sign message:
const signer = ethereumChain.recoverMessageSigner("hello", "f16ea9a3...94504311c");
signer.publicKey; // 04..., uncompressed
ethereumChain.getAddress(signer.publicKey); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
That's viem's signature from Hardhat's account 0. Nobody's savings were harmed.
Careful, though. Feed it the wrong message and you still get a key, just somebody else's. No error. A key coming out proves nothing, the address match does.
EIP-712 and raw digests
Typed data doesn't sign the text. It signs a digest built from the types, the domain and the message. hashTypedData builds it from the eth_signTypedData_v4 JSON. recoverDigestSigner takes it from there:
import { hashTypedData } from "@agntn/keys";
const digest = hashTypedData(typedData); // { types, primaryType, domain, message }
ethereumChain.getAddress(ethereumChain.recoverDigestSigner(digest, signature).publicKey);
No EIP712Domain in types? It's built from the domain fields, the way ethers does it. A field that doesn't fit its type throws with its path, message.to.wallet and so on.
A contract that calls ecrecover itself skips the JSON. Hand recoverDigestSigner its 32 bytes. v can be 27 and 28, or 0 and 1.
Agents get all three through keys_message_recover: pass message, typedData or digest, plus the address to check.
Adding another EVM chain
Drop a file into src/blockchains/ and register it in the lazy loader:
// src/blockchains/polygon.ts
import { AbstractEVMBlockchain } from "../utils/evm.ts";
import { BIP44 } from "../utils/bip44/index.ts";
export default class Polygon extends AbstractEVMBlockchain {
override readonly name = "polygon";
override readonly bip44 = BIP44.ETHEREUM;
}
// src/_blockchains.ts
polygon: lazy("polygon", () => import("./blockchains/polygon.ts")),
Nothing else. The base class already knows how to do everything the chain needs.