Since block 4,951,111, at 08:37:11 UTC on 6 October 2026, KXCO's chain has verified ML-DSA-87 signatures in consensus, per the chain's public record. The verifier is a precompiled contract, a function built into the chain. Anyone can call it through the public endpoint with no account and no licence key.
This note is the code. Sign at category 5 with the npm package, ask the chain for a verdict, keep the key behind an HSM interface, then write as an institution. Every block below ran on 6 October against the published packages and the live chain.
npm install kxco-post-quantum kxco-pq-hsm kxco-pq-chain
01Sign at category 5
ML-DSA-87 is the category 5 parameter set of FIPS 204. The NSA's CNSA 2.0 FAQ specifies "ML-DSA-87 for all classification levels", for signatures "in any use case, including signing firmware and software". So the example signs a firmware release.
import { mlDsa87 } from 'kxco-post-quantum'
const master = crypto.getRandomValues(new Uint8Array(32)) // in production: from your KMS or HSM
const key = mlDsa87.keypairFromMaster(master, 'release-signing-87-v1')
const message = new TextEncoder().encode('firmware 4.2.0, build 1187')
const sig = mlDsa87.sign(key.secretKey, message) // hex, 4,627 bytes
mlDsa87.verify(key.publicKey, message, sig) // true
The pair is derived from the master under a label, so the master stays in your key store and the label versions the key. Give the ML-DSA-87 key a label of its own: one master under one label yields one seed, whichever parameter set you ask for.
Plan for the size. An ML-DSA-87 public key is 2,592 bytes and a signature 4,627 bytes, against 1,952 and 3,309 for ML-DSA-65, per FIPS 204 Table 2. The test file asserts both, and every fixed-width column, header and message field that holds a key or a signature needs checking before the first one arrives.
02Ask the chain
The ML-DSA-87 precompile lives at 0x4b58434f00000000000000000000000000000087. The first four bytes spell KXCO in ASCII, and the last byte names the parameter set. That keeps it clear of the low addresses Ethereum uses and reserves, where EIP-8051 proposes ML-DSA at 0x12 and 0x13.
The input is the public key, the signature and the message, concatenated raw: no function selector, no ABI encoding, no length prefixes. The output is one 32-byte word ending in 01 for valid and 00 for anything else.
// Ask the chain. The ML-DSA-87 precompile takes publicKey || signature || message, raw. const PRECOMPILE_87 = '0x4b58434f00000000000000000000000000000087' const hex = b => Buffer.from(b).toString('hex') const ask = async (to, data) => (await (await fetch('https://chain.kxco.ai/rpc', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to, data }, 'latest'] }), })).json()).result const input = '0x' + hex(key.publicKey) + sig + hex(message) console.log('valid ', await ask(PRECOMPILE_87, input)) const tampered = message.slice(); tampered[9] ^= 1 // firmware 4.2.0 becomes 5.2.0 console.log('tampered ', await ask(PRECOMPILE_87, '0x' + hex(key.publicKey) + sig + hex(tampered))) console.log('at 0x0b ', await ask('0x000000000000000000000000000000000000000b', input)) // the ML-DSA-65 precompile
public key 2592 bytes, signature 4627 bytes valid 0x0000000000000000000000000000000000000000000000000000000000000001 tampered 0x0000000000000000000000000000000000000000000000000000000000000000 at 0x0b 0x0000000000000000000000000000000000000000000000000000000000000000
One bit turns version 4.2.0 into 5.2.0 and the verdict goes to 00. The third call is the control in the other direction: the ML-DSA-65 precompile at 0x0b refuses an ML-DSA-87 input, so the two levels never cross. An empty input, a single byte and 7,219 bytes of noise all came back 00 as well, measured on the public endpoint on 6 October. A malformed call is an invalid signature, never an error.
Calling it with eth_call costs nothing and changes nothing. The same precompile runs inside every transaction that verifies an ML-DSA-87 signature, and each validator re-executes it.
03Keep the key behind an HSM
kxco-pq-hsm 1.5.0 takes the parameter set per key. The memory backend below is for development, and the PKCS#11 backend is the same API on a hardware token.
// The same key type held behind the HSM interface. MemoryBackend here; Pkcs11Backend on a token.
const hsm = new PqHsm(new MemoryBackend())
const { publicKey } = await hsm.keygen('release-signing', 'ml-dsa-87')
const hsmSig = await hsm.sign('release-signing', message)
console.log('hsm key ', await ask(PRECOMPILE_87, '0x' + hex(publicKey) + hex(hsmSig) + hex(message)))
hsm key 0x0000000000000000000000000000000000000000000000000000000000000001
On a token that generates ML-DSA keys, keygen creates the pair on the token and writes CKP_ML_DSA_87 to CKA_PARAMETER_SET. The private object is marked CKA_EXTRACTABLE=false and CKA_SENSITIVE=true, so the process never receives the private bytes. A stored key whose size belongs to the other parameter set is refused rather than used.
04Write as an institution
kxco-pq-chain 2.3.1 writes through the verified path, where the chain itself checks your signature. An ML-DSA-87 identity carries its algorithm and its public key:
const identity = {
kid: id.kid,
publicKeyHex: id.publicKeyHex,
alg: 'ML-DSA-87',
sign: async message => new Uint8Array(Buffer.from(mlDsa87.sign(secretKey, message), 'hex')),
}
These lines come from the script that made the first live ML-DSA-87 writes on 6 October. It ran beside the relay, where a customer passes a licence key instead:
const chain = new KxcoChain({ relay: RELAY, identity, requireLicence: false })
const reg = await chain.registerInstitution({ publicKeyHex: id.publicKeyHex, metadataUrl: '' })
const anc = await chain.anchorAttestation({ payloadHash, purpose: 'system-test' })
What the client signs is the part worth knowing. The authorising message binds the verifying contract, the chain, the operation, your key id and a sequential nonce, then the operation's arguments. An ML-DSA-87 key adds one word after the nonce, keccak256("ML-DSA-87"), so the level is inside the signature. The contract reads the level from the length of the key you send, 1,952 or 2,592 bytes per FIPS 204, never from a field you set. The relay checks your declared alg against the key and stops a mismatch before any gas is spent. Registration carries proof of possession: the new key signs its own registration.
Exhibit 1 maps the path, the check at each step and the evidence for it.
On 6 October 2026 the first registration landed in block 4,951,125 with 7,460 bytes of signed calldata and used 353,166 gas, and the first anchor landed in block 4,951,127 and used 387,954, per their receipts on chain. KXCO pays the gas. You never hold a token or run a node.
05Check it yourself
The relay tells you which levels the chain verifies at the current block, and the registry tells you whether a key id is live:
$ curl -s https://relay.kxco.ai/intents/v2/params
{"ok":true,"verifyingRelay":"0x941217082350AC45Ab9AA035687ce16c6822b657","chainId":1111111,"legacyRelay":"0x0000000000000000000000000000000000000000","algorithms":["ML-DSA-65","ML-DSA-87"]}
$ curl -s https://chain.kxco.ai/kids/ded6ea78ab462e53
{"kid":"ded6ea78ab462e53","status":"active","rotatedTo":null,"chainId":1111111,"asOfBlock":4951826,"kind":"institution"}
Read verifyingRelay again at least every five minutes rather than holding it for the life of a process, which kxco-pq-chain 2.3.1 does for you. An ML-DSA-87 identity takes the verified path once algorithms lists it, and today it does.
06What to watch
1 January 2027, when CNSA 2.0 applies to new acquisitions for US national security systems. The algorithm it specifies for signatures is verifiable on KXCO's chain now, from the code above.
The packages are on npm: kxco-post-quantum, kxco-pq-hsm and kxco-pq-chain. For an institutional deployment, request a briefing or write to [email protected].