Working with Keys
A private key
Every chain uses the same generator. You get 32 bytes of randomness as a 64 character hex string, nothing more.
import { useBlockchain } from "@agntn/keys";
import Bitcoin from "@agntn/keys/blockchains/bitcoin";
const chain = useBlockchain(new Bitcoin());
const privateKey = chain.generateKeyPrivate();
console.log(privateKey); // '7f9e5b9e3bbed34a4c28c8c1665525fc2cd7afb4fdc7edca3eb93ddf8a31ef56'
secp256k1 chains ask @noble/curves for a scalar that sits inside the curve order, ed25519 chains ask the same library for a seed. There's no node:crypto anywhere, noble reads globalThis.crypto on its own, which is why the Keyspace explorer can run the whole thing in a tab.
The public key
const publicKey = chain.getKeyPublic(privateKey);
console.log(publicKey); // '02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc'
On secp256k1 chains this is the compressed form: 33 bytes, starting with 02 or 03. That's what Bitcoin, Ethereum, and TRON expect today. If you need the uncompressed 65 byte key, ask for it:
const uncompressed = chain.getKeyPublic(privateKey, { compressed: false });
// '04a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc7513...'
ed25519 chains have no compression flag. The public key is always 32 bytes.
Both at once
const keyPair = chain.generateKeys();
keyPair.keys.private;
keyPair.keys.public;
The same options apply, so chain.generateKeys({ compressed: false }) gives you an uncompressed pair.
Flipping a public key you already have
convertPublicKey from @agntn/keys/secp256k1 goes between the two encodings without a private key. It parses the point first, so a string that only looks like a key is rejected instead of encoded again.
import { convertPublicKey } from "@agntn/keys/secp256k1";
convertPublicKey(publicKey, { compressed: false }); // 04..., 130 hex chars
convertPublicKey(uncompressed); // back to 02... or 03..., the default
Mind what that does to addresses. A legacy Bitcoin address hashes the bytes you give it, so the compressed and the uncompressed encoding of one key are two different addresses. SegWit v0 and the P2SH form take the compressed key only, as BIP143 requires, so an uncompressed key there throws, while taproot keeps just the x coordinate and takes either. Keep the compressed flag next to the key, the way WIF does.
Math on the curve
Got two public keys and want their sum? That's a split key vanity address. addPoints does it without either private key. The rest of @agntn/keys/secp256k1 is the same kind of toolkit:
import { addPoints, isOnCurve, liftX, multiplyPoint, subtractPoints } from "@agntn/keys/secp256k1";
const G = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
addPoints(G, G); // '02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5', that's 2G
subtractPoints(multiplyPoint(G, "3"), G); // 2G again
liftX(G.slice(2)); // { even: '0279be66…', odd: '0379be66…' }
isOnCurve(`02${"5".padStart(64, "0")}`); // false, nothing sits above x = 5
Points go in as SEC1, compressed or uncompressed. They come out compressed unless you pass { compressed: false }. subtractPoints gives the offset between two known keys. multiplyPoint takes a scalar as hex or a bigint, and multiplyGenerator is the same with G built in. What's P minus P? The point at infinity. It's no public key, so you get a RangeError instead of something that looks like one.
Where's the y of an x-only key?
Taproot keeps only x. liftX hands back both points above it, and the even one is the key BIP340 means. A compressed key whose x has no point fails isOnCurve. A string that isn't SEC1 at all throws, because there's nothing to check.
Scalars, mod n
addScalars, subtractScalars, multiplyScalars and invertScalar do arithmetic mod the curve order and answer with 64 hex digits. A value at or above n is refused, not reduced. A typo shouldn't quietly become another key.
None of this math is ours anymore. Both curve subpaths re-export @agntn/curves, and the agent tool moved out with it. curves_secp256k1_compute adds, subtracts, negates, multiplies, lifts and checks. The scalar lands in the transcript like any argument. Multiply G by a private key there and you've published it, so keep that tool for public tweaks and burner keys. Scalar math stays in the library.
A curve of your own
Not every puzzle sits on secp256k1. Some hand you y² = x³ + 2x + 2 over F17 and a point. What's it worth? @agntn/keys/curve does the textbook math on any short Weierstrass curve you define:
import { defineCurve, discreteLog, doublePoint, listPoints, pointOrder } from "@agntn/keys/curve";
const curve = defineCurve({ a: 2n, b: 2n, p: 17n });
const P = { x: 5n, y: 1n };
doublePoint(curve, P); // { x: 6n, y: 3n }
pointOrder(curve, P); // 19n
discreteLog(curve, P, { x: 16n, y: 4n }); // 13n, so 13P
listPoints(curve).length; // 18, plus the point at infinity
Everything is a bigint. The point at infinity is null. a and b get reduced mod p, so -3n is fine. A p that isn't prime gets refused. So does a singular curve. addPoints, negatePoint, multiplyPoint and isOnCurve do the rest. A negative scalar multiplies the negation.
How small is small?
countPoints and listPoints walk one x at a time, up to p = 2^20. Points of one order cost a multiplication each, so that filter stops at 2^16. pointOrder does baby steps and giant steps over the Hasse interval. That's quick up to p = 2^48. discreteLog takes a base of order up to 2^36. Past that you're not solving a puzzle anymore. You're breaking elliptic curves, so write the paper first. The limits are exported as constants, if you'd rather check before you call.
Agents get curves_compute from the same package. Numbers go in as strings, decimal or 0x hex, and come back decimal. Infinity comes back as "infinity". A log with no answer says scalar: null instead of guessing.
WIF
Wallet import format is the private key in base58check with a chain prefix and a compression flag. Bitcoin, Litecoin, Dash, Decred and Dogecoin each have one, and encode and decode from @agntn/keys/wif speak all five.
import { encode, decode } from "@agntn/keys/wif";
encode(privateKey, { chain: "bitcoin" }); // K... or L..., compressed
encode(privateKey, { chain: "bitcoin", compressed: false }); // 5...
encode(privateKey, { chain: "litecoin" }); // T...
encode(privateKey, { chain: "dash" }); // X...
encode(privateKey, { chain: "dogecoin" }); // Q...
encode(privateKey, { chain: "decred" }); // Pm...
decode("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn", { chain: "bitcoin" });
// { privateKey: '0000…0001', chain: 'bitcoin', network: 'mainnet', compressed: true }
decode wants to be told the chain and checks the prefix against it, no guessing from the first letter. That's on purpose: Bitcoin, Litecoin and Dash share the testnet prefix, so a guess would be a coin flip there. Decred has its own layout, a scheme byte after the prefix and a single BLAKE-256 checksum, and only the compressed form exists, so compressed: false on Decred throws. What you get back is the hex key plus the flags that were encoded with it, not proof that the key belongs to that chain.
BIP38
A 6P... string is a private key locked with a passphrase. The lock covers the key, not the header, so a fair bit can be read without the passphrase. inspect from @agntn/keys/bip38 reads it:
import { inspect } from "@agntn/keys/bip38";
inspect("6PgNBNNzDkKdhkT6uJntUXwwzQV8Rr2tZcbkDcuC9DZRsS6AtHts4Ypo1j", {
address: "1Jscj8ALrYu2y9TD8NrpvDBugPedmbj4Yh",
});
// { mode: 'ec-multiply', flagByte: 4, compressed: false, hasLotSequence: true,
// addressHash: 'bb458cef', ownerEntropy: '4fca5a974040f001', ownerSalt: '4fca5a97',
// lot: 263183, sequence: 1, addressMatches: true }
That's a test vector from the BIP, lot 263183 and sequence 1 like the spec says. non-ec means someone encrypted a key they already had. ec-multiply means a generator built the key from the owner's passphrase code, and only then do you get owner entropy. With the 0x04 flag bit it also carries a lot and a sequence number.
addressHash is the first four bytes of a double SHA-256 over the address text. Pass an address and addressMatches says whether it hashes to the same four bytes. That's a good sign, not proof: four bytes leave one chance in about four billion for a coincidence. A flag byte with a reserved bit set throws, because this version of the spec wants those bits at zero.
inspect decrypts nothing. That's decrypt, and it wants the passphrase:
import { decrypt } from "@agntn/keys/bip38";
decrypt("6PgNBNNzDkKdhkT6uJntUXwwzQV8Rr2tZcbkDcuC9DZRsS6AtHts4Ypo1j", "MOLON LABE");
// { privateKey: Uint8Array(32), compressed: false,
// wif: '5JLdxTtcTHcfYcmJsNVy1v2PMDx432JPoYcBTVVRHpPaxUrdtf8',
// address: '1Jscj8ALrYu2y9TD8NrpvDBugPedmbj4Yh' }
Both modes open, lot and sequence included. The passphrase goes through NFC first, like the spec says. So an upsilon with hook and an acute typed as two code points and the same letter as one give the same key. No surprise there, the spec has a test vector for it.
How does it know the passphrase is wrong? It doesn't, not directly. It builds the address and hashes it. Miss the stored hash and you get BIP38PassphraseError. A hit is the same four bytes as above. So one wrong passphrase in about four billion slips through.
Every try costs scrypt with N 16384, r 8 and p 8. That's about a third of a second on one core. A million guesses take close to four days. Annoying on purpose.
MCP and Pi call it keys_bip38_decrypt. You get the mode, whether the passphrase fits and, if it does, the public key and the address. The WIF only with revealKey: true, because then it lands in the transcript. The passphrase lands there anyway, so keep real ones out.
Keystore files
Remember the UTC--2017-... file MyEtherWallet made you download? That's a keystore. Geth, ethers and MyEtherWallet all write the same JSON, Web3 Secret Storage version 3. The password goes through scrypt or PBKDF2. AES-128-CTR locks the key. A Keccak MAC tells a wrong password from a right one. And it's not dead. Geth and Foundry still write it, MetaMask still imports it.
@agntn/keys/store opens one:
import { decrypt, inspect } from "@agntn/keys/store";
import Ethereum from "@agntn/keys/blockchains/ethereum";
const file = {
version: 3,
id: "fecfc4ce-e956-48fd-953b-30f8b52ed66c",
crypto: {
cipher: "aes-128-ctr",
cipherparams: { iv: "e0c41130a323adc1446fc82f724bca2f" },
ciphertext: "9517cd5bdbe69076f9bf5057248c6c050141e970efa36ce53692d5d59a3984",
kdf: "scrypt",
kdfparams: {
dklen: 32,
n: 2,
r: 8,
p: 1,
salt: "711f816911c92d649fb4c84b047915679933555030b3552c1212609b38208c63",
},
mac: "d5e116151c6aa71470e67a7d42c9620c75c4d23229847dcc127794f0732b0db5",
},
};
inspect(file).kdf; // 'scrypt'
const key = decrypt(file, "foo");
const wallet = new Ethereum().deriveWallet(key.toHex());
wallet.address;
That's 0xd1E64E5480bFaf733Ba7D48712DEcb8227797a4e. The file comes from geth's test data. Look at the ciphertext: 31 bytes, not 32. Early geth dropped leading zero bytes of the key. decrypt pads them back, like geth does.
A wrong password throws KeystorePasswordError. A key that doesn't match the stored address throws too. Crypto with a capital C, the way ethers writes it, reads fine. inspect gives the KDF, its costs and the stored address without any password.
encrypt(key, password) goes the other way. By default it uses geth's cost, scrypt with n: 262144, r: 8 and p: 1. That's about a second. Pass kdf: { kdf: "pbkdf2", c: 262144 } for PBKDF2. The password goes in as UTF-8, exactly as typed, like geth. ethers runs NFKC over it first. Plain text is the same either way, a ligature is not.
Version 1 and 2 files and the presale wallet don't open here. An old scrypt file with r: 1 and n of 65536 or more doesn't either. @agntn/hashes keeps scrypt inside RFC 7914 and refuses it.
MCP and Pi call it keys_store_decrypt. You get the KDF, the stored address and whether the password fits. If it does, the public key and the address. Never the private key. A file that wants more scrypt memory than geth's standard gets refused before the KDF runs. The password is still in the transcript, so keep real ones out.
The shape you get back
interface Keys {
keys: {
private: string;
public: string;
};
}
Hex strings everywhere. Sizes by curve:
| Curve | Private | Public |
|---|---|---|
| secp256k1 | 64 hex chars | 66 hex chars compressed, 130 uncompressed |
| ed25519 | 64 hex chars | 64 hex chars |
Sui speaks two curves
Sui accepts ed25519 and secp256k1 keys on the same chain. The private key is the same 32 bytes either way. The scheme option decides which curve turns it into a public key:
import Sui from "@agntn/keys/blockchains/sui";
const suiChain = useBlockchain(new Sui());
const ed25519PublicKey = suiChain.getKeyPublic(privateKey); // default
const secp256k1PublicKey = suiChain.getKeyPublic(privateKey, { scheme: "secp256k1" });
Pick the scheme once and carry it through to getAddress, otherwise the address won't match the key. The Sui page shows the full round trip.
Under the hood
Curves come from @noble/curves, hashes from @agntn/hashes. The package itself doesn't implement any crypto, and adding some is the first thing a review would reject. EVM chains share everything through AbstractEVMBlockchain, so Ethereum and Base are the same code with a different name and coin type. Bitcoin, Litecoin and Bitcoin Gold do the same through AbstractBitcoinBlockchain.