Math

sr25519

Polkadot keys and signatures. Schnorr over ristretto255 with Merlin transcripts and Substrate HDKD. Written from the specs

Your Polkadot account isn't on secp256k1

Polkadot wallets make new accounts on sr25519. That's Schnorr signatures over ristretto255, a group built on edwards25519. Same words, other curve, other address. The fun one, too.

ts
import { deriveHard, expandSeed, getPublicKey } from "@agntn/curves/sr25519";

const root = expandSeed("fac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e");
getPublicKey(root); // "46ebddef8cd9bb16…b47a"

const alice = deriveHard(root, "14416c696365".padEnd(64, "0"));
getPublicKey(alice); // "d43593c715fdd31c…a27d"

Seen d43593c7… before? It's //Alice, the account every Substrate dev chain funds. The seed is the mini secret of the dev phrase. Turning a phrase into that seed happens elsewhere, not here.

A seed is 32 bytes. expandSeed turns it into a 64-byte secret the way Substrate does: the key as schnorrkel writes it for ed25519, then a nonce. Everything is hex without 0x.

Sign twice, get two signatures

ts
import { sign, verify } from "@agntn/curves/sr25519";

const message = new TextEncoder().encode("hello");
const signature = sign(alice, message);
verify(signature, message, getPublicKey(alice)); // true

Sign the same message again and the bytes change. Both still verify. The nonce mixes the secret with fresh random bytes, as schnorrkel does. Writing a test? Pass { random } with 64 hex digits and the signature holds still.

Signatures go under the substrate signing context, the one Polkadot signs with. Some other app uses its own? Give { context } to both sign and verify.

verify says false when the math fails. Also for a signature without schnorrkel's marker bit, or with s at or above l. It throws only on input of the wrong shape: a signature that isn't 128 hex digits, a public key that's no ristretto255 point.

Hard and soft

FunctionStarts fromGives
deriveHard(secret, chainCode)the secreta //hard child secret
deriveSoft(secret, chainCode)the secreta /soft child secret
derivePublic(publicKey, chainCode)the public keythe /soft child's public key

The chain code is what Substrate makes of a junction: its SCALE encoding, padded to 32 bytes. Alice becomes 14416c696365 and zeros. A hard child needs the secret. A soft one doesn't, so a watch-only setup can follow it from the public key alone. Both ways land on the same key, the tests check that.

A soft child secret gets a fresh nonce too, so deriveSoft takes { random } like sign.

ristretto255 underneath

ts
import { addPoints, multiplyGenerator } from "@agntn/curves/ristretto255";

multiplyGenerator(1n); // "e2f2ae0a6abc4e71…2d76"

edwards25519 has a cofactor of 8. A classic way to shoot yourself in the foot. ristretto255 from RFC 9496 hides it: one element, one 32-byte encoding, everything else refused. The identity is 32 zero bytes, and it's a valid point here.

Scalars are bigints from 0 to l - 1, never hex. Why? ed25519 reads bytes little-endian, so a hex scalar would leave you guessing which number it is.

Written from the specs, again

ristretto255 follows RFC 9496 and passes its test vectors. Merlin and STROBE-128 run on a Keccak-f1600 written here. SHA-512 comes from @agntn/hashes. The tests hold keys, signatures and both derivations to @scure/sr25519 byte for byte, given the same random bytes.

About 4 ms to sign and 3 to verify. Slow next to a Rust wallet, fine for a puzzle. Not constant time either, so keep real funds out of it.

Over MCP

curves_sr25519_compute does keypair, sign, verify and derive. A message is text unless encoding is hex.

text
{"operation":"keypair","secret":"28b0ae221c6bb06856b287f60d7ea0d98552ea5a16db16956849aa371db3eb51fd190cce74df356432b410bd64682309d6dedb27c76845daf388557cbac3ca34","publicKey":"46ebddef8cd9bb167dc30878d7113b7e168e6f0646beffd77d69d39bad76b47a"}

That's keypair with the dev seed. Yes, the secret comes back in the answer. Dev accounts and burner keys only.