sr25519
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.
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
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
| Function | Starts from | Gives |
|---|---|---|
deriveHard(secret, chainCode) | the secret | a //hard child secret |
deriveSoft(secret, chainCode) | the secret | a /soft child secret |
derivePublic(publicKey, chainCode) | the public key | the /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
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.
{"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.