docs / verifier v1
How PQC Scan verifies
A specification of how PQC Scan checks post-quantum provenance for a Solana token. Every value is recomputed from public data on mainnet and the token's metadata. The verifier lives in lib/pqc and the same code runs in the browser and on the server.
Overview
On Solana, every account address is an ed25519 public key. A large enough quantum computer running Shor's algorithm could forge ed25519 signatures, which would make it impossible to tell after the fact who really created a token.
Post-quantum provenance is a second, independent signature over a token's identity (mint, creator, name, symbol and image) made with a scheme that does not rely on elliptic curves. If it verifies, the holder of a specific post-quantum identity attested to this exact launch. Hash-based schemes like WOTS rely only on the security of SHA-256.
PQC Scan is read-only. It never asks you to connect a wallet, sign a message or share a seed phrase, and it reports exactly what it could and could not confirm.
Threat model
We assume an adversary who can eventually forge ed25519 signatures. A verified provenance proof answers one question, who attested to this launch, and it keeps answering it after that break. It does not protect anything.
| Claim | Today | After an EC break |
|---|---|---|
| Who launched the token | creation tx signature | WOTS attestation + anchor |
| Metadata was not swapped | on-chain name / symbol | digest commits to all fields |
| Tokens in an ed25519 wallet | safe | exposed, needs on-chain PQ vault |
| Token value, liquidity, intent | not addressed | not addressed |
Solana validators do not check these post-quantum signatures. They are an off-chain record. Holding value under hash-based keys requires an on-chain verifier program, which is outside PQC Scan's scope.
Verification
For the WOTS + Merkle scheme, an identity is a Merkle tree of 256 one-time keys and the tree root is the public key. A signature reveals one value per hash chain plus the sibling nodes needed to climb from that leaf to the root. Every hash call is domain-separated by a public seed and a 32-byte address.
ADRS = u32be(type) || u32be(a) || u32be(b) || u32be(c) || 0^16
F(seed, ADRS, x) = SHA-256(seed || ADRS || x)Launch digest
The signed digest is rebuilt from public fields, never trusted as published:
digest = SHA-256("pqc.market/launch/v1\n" ||
"creator=<wallet>\nimage=<url>\nleaf=<n>\nmint=<mint>\nname=<name>\nsymbol=<symbol>")PQC Scan verifies the signature over the published digest, then separately rebuilds the digest from the mint being scanned, the wallet that signed its creation transaction and the current metadata. Both must hold.
WOTS chains
The 32-byte digest is split into 64 base-16 digits, followed by a 3-digit checksum, giving 67 digits d[i]. Each revealed value is hashed forward to the end of its chain:
pk[i] = H^(15 - d[i])(σ[i]) chain step s uses ADRS(0, leaf, i, s)
ℓ = H(pk[0] || … || pk[66]) ADRS(1, leaf)The checksum makes it impossible to raise any digit without lowering another, so a signature for one message cannot be stretched to a different one.
Merkle climb
Starting from the leaf, the verifier hashes upward with the authentication path, 8 levels, to the root:
node(h, j) = H(left || right) ADRS(2, h, j)
valid iff node(8, 0) = published rootThe homepage terminal replays this exact computation, live, on a real mainnet proof.
The seven checks
| Check | What it confirms | Required |
|---|---|---|
| Mint Lookup | The account exists on mainnet and is an initialized SPL Token or Token-2022 mint. | yes |
| Metadata Retrieved | The URI resolves from the Token-2022 extension or Metaplex account, fetched with SSRF protection, a size limit and a timeout. | yes |
| Metadata Integrity | On-chain name and symbol equal the document. Also reports whether it is content-addressed (IPFS). | yes |
| PQC Proof Format | A known format with every field valid, and a PQ address correctly derived from root and seed. | yes |
| PQC Signature | The hash chains and Merkle path recompute to the root. NIST schemes also check the WOTS certificate. | yes |
| Creator Binding | The digest rebuilt from the mint, its creation-tx signer and the metadata matches the signed one. | yes |
| On-chain Anchor | An SPL Memo signed by the creator wallet publishes the identity root. | optional |
Statuses
- VerifiedThe check ran and the evidence satisfied it.
- FailedThe check ran and the evidence definitively contradicts the claim.
- Not FoundThe data the check needs does not exist (no account, no proof, no anchor).
- UnsupportedA proof exists but uses a version or scheme this verifier cannot evaluate.
- UnavailableA dependency (RPC, metadata host) failed or timed out. Retrying may help.
- Not CheckedSkipped because an earlier step it depends on did not complete.
Outcomes
| Outcome | When |
|---|---|
| VERIFIED | Every required check is verified. |
| FAILED | At least one required check definitively failed. |
| UNSUPPORTED FORMAT | A proof is present but cannot be parsed or verified by this build. |
| INCOMPLETE | Evidence is missing, for example no proof exists or a dependency was unavailable. |
Most Solana tokens have no PQC proof at all. That is normal and not a sign of wrongdoing.
Supported formats
pqc.market metadata attestation v1, implemented independently from its public specification. PQC Scan is not affiliated with pqc.market. The adapter reads the pqc object in the token's metadata JSON.
| Scheme | Library | Audit |
|---|---|---|
| WOTS (w=16, SHA-256) + Merkle | own code over @noble/hashes | SHA-256 audited |
| ML-DSA-65 / 87 | @noble/post-quantum 0.7.1 | self-audited |
| SLH-DSA-SHA2-128s, SHAKE-128f | @noble/post-quantum 0.7.1 | self-audited |
| Falcon-512 / 1024 | @noble/post-quantum 0.7.1 | self-audited |
| ed25519 + ML-DSA-65 hybrid | @noble/post-quantum 0.7.1 | self-audited |
NIST schemes are certified by a WOTS leaf. Other formats can be added as adapters without changing the UI.
Parameters
| Parameter | Value |
|---|---|
| hash | SHA-256 |
| n | 32 bytes |
| w | 16 |
| len1 / len2 / len | 64 / 3 / 67 |
| tree height | 8 (256 one-time leaves) |
| WOTS signature | 67 × 32 = 2,144 B |
| auth path | 8 × 32 = 256 B |
| worst-case verify | 67 × 15 + 1 + 8 = 1,014 hashes |
Limitations
- Creator binding needs the mint's creation transaction. For very active tokens it may be beyond the history window, and the check reports Unavailable instead of guessing.
- The anchor search covers a bounded window of the creator wallet's recent history.
- Without an anchor, the link between the identity root and the wallet rests on the wallet's ed25519 signature of the creation transaction. That is sound today but not after an elliptic-curve break.
- The public mainnet RPC is rate limited. Configure a dedicated endpoint for reliable results.
Configuration
All configuration is server-side. Secrets are never sent to the browser.
| Variable | Purpose |
|---|---|
| SOLANA_RPC_URL | Mainnet RPC, may include an API key. Defaults to the public endpoint. |
| DATABASE_URL | Optional Postgres for the Explorer’s scan history. |
| IPFS_GATEWAY_URL | Optional https gateway for ipfs:// URIs. |
| SCAN_CREATION_MAX_PAGES | Pages of 1,000 signatures searched for the creation tx (default 8). |
| SCAN_ANCHOR_MAX_PAGES | Pages searched for an anchor memo (default 3). |
| SCAN_METADATA_MAX_BYTES | Metadata size limit (default 524288). |
| NEXT_PUBLIC_GITHUB_URL | Optional repository link in the footer. |
Verify it yourself
You do not need to trust PQC Scan. The whole WOTS check fits in a few lines over any SHA-256 implementation. Read the pqc object from the token's metadata and run:
import { sha256 } from '@noble/hashes/sha2.js'
import { concatBytes, hexToBytes, bytesToHex } from '@noble/hashes/utils.js'
const adrs = (t, a, b = 0, c = 0) => {
const o = new Uint8Array(32), v = new DataView(o.buffer)
v.setUint32(0, t); v.setUint32(4, a); v.setUint32(8, b); v.setUint32(12, c)
return o
}
const H = (seed, ad, ...x) => sha256(concatBytes(seed, ad, ...x))
function verify({ messageHash, signature: { leaf, wots, auth }, root, pubSeed }) {
const seed = hexToBytes(pubSeed), msg = hexToBytes(messageHash), sig = hexToBytes(wots)
const d = [...msg].flatMap((b) => [b >> 4, b & 15])
const cs = d.reduce((s, v) => s + 15 - v, 0)
d.push((cs >> 8) & 15, (cs >> 4) & 15, cs & 15)
const pk = d.map((di, i) => {
let x = sig.slice(i * 32, i * 32 + 32)
for (let s = di; s < 15; s++) x = H(seed, adrs(0, leaf, i, s), x)
return x
})
let node = H(seed, adrs(1, leaf), ...pk), j = leaf
auth.forEach((a, h) => {
const sib = hexToBytes(a)
node = j % 2 === 0 ? H(seed, adrs(2, h + 1, j >> 1), node, sib)
: H(seed, adrs(2, h + 1, j >> 1), sib, node)
j >>= 1
})
return bytesToHex(node) === root.toLowerCase()
}Or call the scanner directly. GET /api/scan?mint=<address> returns the full result as JSON, including every piece of evidence shown in the UI.
