PQC SCAN

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.

ClaimTodayAfter an EC break
Who launched the tokencreation tx signatureWOTS attestation + anchor
Metadata was not swappedon-chain name / symboldigest commits to all fields
Tokens in an ed25519 walletsafeexposed, needs on-chain PQ vault
Token value, liquidity, intentnot addressednot 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 root

The homepage terminal replays this exact computation, live, on a real mainnet proof.

The seven checks

CheckWhat it confirmsRequired
Mint LookupThe account exists on mainnet and is an initialized SPL Token or Token-2022 mint.yes
Metadata RetrievedThe URI resolves from the Token-2022 extension or Metaplex account, fetched with SSRF protection, a size limit and a timeout.yes
Metadata IntegrityOn-chain name and symbol equal the document. Also reports whether it is content-addressed (IPFS).yes
PQC Proof FormatA known format with every field valid, and a PQ address correctly derived from root and seed.yes
PQC SignatureThe hash chains and Merkle path recompute to the root. NIST schemes also check the WOTS certificate.yes
Creator BindingThe digest rebuilt from the mint, its creation-tx signer and the metadata matches the signed one.yes
On-chain AnchorAn 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

OutcomeWhen
VERIFIEDEvery required check is verified.
FAILEDAt least one required check definitively failed.
UNSUPPORTED FORMATA proof is present but cannot be parsed or verified by this build.
INCOMPLETEEvidence 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.

SchemeLibraryAudit
WOTS (w=16, SHA-256) + Merkleown code over @noble/hashesSHA-256 audited
ML-DSA-65 / 87@noble/post-quantum 0.7.1self-audited
SLH-DSA-SHA2-128s, SHAKE-128f@noble/post-quantum 0.7.1self-audited
Falcon-512 / 1024@noble/post-quantum 0.7.1self-audited
ed25519 + ML-DSA-65 hybrid@noble/post-quantum 0.7.1self-audited

NIST schemes are certified by a WOTS leaf. Other formats can be added as adapters without changing the UI.

Parameters

ParameterValue
hashSHA-256
n32 bytes
w16
len1 / len2 / len64 / 3 / 67
tree height8 (256 one-time leaves)
WOTS signature67 × 32 = 2,144 B
auth path8 × 32 = 256 B
worst-case verify67 × 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.

VariablePurpose
SOLANA_RPC_URLMainnet RPC, may include an API key. Defaults to the public endpoint.
DATABASE_URLOptional Postgres for the Explorer’s scan history.
IPFS_GATEWAY_URLOptional https gateway for ipfs:// URIs.
SCAN_CREATION_MAX_PAGESPages of 1,000 signatures searched for the creation tx (default 8).
SCAN_ANCHOR_MAX_PAGESPages searched for an anchor memo (default 3).
SCAN_METADATA_MAX_BYTESMetadata size limit (default 524288).
NEXT_PUBLIC_GITHUB_URLOptional 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.