Guide

Working with Keys

Private and public keys. Compression and WIF. The one chain that speaks two curves.

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

convertSecp256k1PublicKey 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 { convertSecp256k1PublicKey } from "@agntn/keys";

convertSecp256k1PublicKey(publicKey, { compressed: false }); // 04..., 130 hex chars
convertSecp256k1PublicKey(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.

WIF

Wallet import format is the private key in base58check with a chain prefix and a compression flag. Bitcoin, Litecoin and Decred have one, and encodeWIF and decodeWIF speak all three.

import { encodeWIF, decodeWIF } from "@agntn/keys";

encodeWIF(privateKey, { chain: "bitcoin" }); // K... or L..., compressed
encodeWIF(privateKey, { chain: "bitcoin", compressed: false }); // 5...
encodeWIF(privateKey, { chain: "litecoin" }); // T...
encodeWIF(privateKey, { chain: "decred" }); // Pm...

decodeWIF("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn", { chain: "bitcoin" });
// { privateKey: '0000…0001', chain: 'bitcoin', network: 'mainnet', compressed: true }

decodeWIF wants to be told the chain and checks the prefix against it, no guessing from the first letter. That's on purpose: Bitcoin and Litecoin 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.

The shape you get back

interface Keys {
  keys: {
    private: string;
    public: string;
  };
}

Hex strings everywhere. Sizes by curve:

CurvePrivatePublic
secp256k164 hex chars66 hex chars compressed, 130 uncompressed
ed2551964 hex chars64 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 @noble/hashes. There's no second crypto implementation anywhere in the package, and adding one 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 and Litecoin do the same through AbstractBitcoinBlockchain.

@agntn/keys·MIT license· Keys never leave the browser.