How Qlaunch works
The complete protocol: what is signed, by which key, how anyone checks it, and what it does and doesn't protect. Everything here is implemented in src/lib/pq and cross-checked by an independent Python verifier, scripts/verify.py.
Overview#
Every coin launched here carries a post-quantum attestation: a hash-based signature, by the launcher, over the coin's mint address, name, ticker, image and description. The signature lives inside the coin's IPFS metadata, next to the fields it covers, so it travels with the coin wherever its metadata goes.
| Step | What happens |
|---|---|
| Derive | Your wallet signs a fixed message. That deterministic signature, optionally stretched with a passphrase, seeds 256 one-time keys, generated in your browser. |
| Register | The keys hash into a Merkle tree whose root is your post-quantum public key. Your wallet and key #0 sign one statement, binding the two. |
| Anchor | An SPL memo puts the root on Solana, a timestamp nobody can forge later. |
| Launch | A fresh one-time key signs the coin. The coin is created on pump.fun in one transaction, the attestation pinned in its metadata. |
| Verify | Anyone rebuilds the digest from public fields and walks the signature to the root. Only SHA-256 is involved. |
Threat model#
A Solana address is an ed25519 public key. Unlike a never-spent Bitcoin address, there is no hash in front of it to hide behind: a cryptographically relevant quantum computer (or a classical break of the curve) exposes every account at once. When that happens, an ed25519 signature stops proving anything: an attacker can produce one for any address.
What survives is anything resting on hash functions alone. Grover's algorithm speeds up preimage search quadratically, which takes SHA-256 from 256 to roughly 128 bits of security: still far out of reach. So we give every creator a hash-based identity now, while ed25519 can still vouch for it, and use it to sign every launch.
- Protects: proof of who launched a coin, after a curve break, for identities with a passphrase (see below); identity ownership, via a challenge-response that never touches ed25519.
- Does not protect: funds. SOL and tokens in ordinary Solana accounts are guarded by ed25519 and nothing else. Moving value under hash-based control needs an on-chain verifier program; that is on the roadmap.
- Wallet-only identities have no post-quantum value: the keys come from a wallet signature, so whoever recovers the wallet's ed25519 key after a break can re-derive them. Verifiers can't tell such identities apart. With a passphrase, an attacker also has to guess it, offline, at scrypt (2^17) cost per guess: use a long, random one.
Primitives#
One hash function, SHA-256, used as a tweakable hash in the style of SPHINCS+: every call is keyed by the identity's public seed and a 32-byte address naming its exact position in the structure. No two calls anywhere hash the same (seed, position), which is what defeats multi-target attacks.
F(pubSeed, ADRS, x) = SHA-256(pubSeed ‖ ADRS ‖ x) one chain step (96-byte input) H(pubSeed, ADRS, l, r) = SHA-256(pubSeed ‖ ADRS ‖ l ‖ r) Merkle node T(pubSeed, ADRS, pk) = SHA-256(pubSeed ‖ ADRS ‖ pk[0] ‖ … ‖ pk[66]) WOTS key compression PRF(skSeed, ADRS) = HMAC-SHA-256(skSeed, pubSeed ‖ ADRS) secret chain start ADRS (32 bytes, big-endian u32 words): type ‖ epoch ‖ leaf ‖ a ‖ b ‖ 0 ‖ 0 ‖ 0 type: 0 chain step · 1 key compression · 2 tree node · 3 secret derivation a, b: chain index and step (WOTS) · node height and index (tree)
WOTS+ one-time signatures#
A Winternitz key with w = 16 is 67 hash chains of length 15. The secret key is each chain's start, the public key each chain's end. The 32-byte message digest is split into 64 base-16 digits, followed by 3 checksum digits:
digits[0..63] = nibbles of digest, high first checksum = Σ (15 − digits[i]) ≤ 960 < 16³ digits[64..66] = nibbles of (checksum << 4) as two bytes, first three
Signing reveals position digits[i] of chain i. Verifying walks each chain the remaining 15 − digits[i] steps and must land on the public end. To forge, an attacker would need to raise some digit, which the checksum turns into lowering another, meaning walking a chain backwards: a SHA-256 preimage. A signature is 67 × 32 = 2,144 bytes.
One-time is literal: two signatures from one key reveal two positions per chain and let anyone sign messages in between. Every key here signs exactly once, and the database enforces it with a primary key on (identity, leaf).
Merkle identities#
256 one-time keys are the leaves of a complete binary tree of height 8. The root, together with the public seed, is your post-quantum public key: 64 bytes that commit to all 256 keys. A full signature is the leaf index, the WOTS signature and the 8 sibling hashes on the path to the root:
signature = u32 leaf ‖ WOTS signature (2,144 B) ‖ auth path (8 × 32 = 256 B) = 2,404 bytes
address = bech32m("pq", SHA-256("pqlaunch-v1/address" ‖ 0x00 ‖ height ‖ u32 epoch ‖ pubSeed ‖ root))The address keeps all 256 bits of the hash, so a second identity with the same address costs 2^128 work even for a quantum attacker. Key #0 is reserved for registration; keys #1 to #255 sign launches and proofs.
Key derivation#
Keys are derived, never stored: unlock again on any device with the same wallet (and passphrase) and you get the same tree.
walletSig = ed25519 signature by your wallet over a fixed message (RFC 8032: deterministic) stretched = scrypt(passphrase, "pqlaunch-v1/passphrase/<wallet>", N = 2^17, r = 8, p = 1) or empty seed = HKDF-SHA-256(walletSig ‖ stretched, salt "pqlaunch-v1", info "identity-seed") skSeed = HKDF(seed, info "wots-secret/<epoch>") pubSeed = HKDF(seed, info "public-seed/<epoch>") mint key = ed25519 keypair from HKDF(seed, info "mint/<epoch>/<leaf as 8 hex digits>")
The fixed message names no site and no domain on purpose: if it changed, every identity would change with it. It does say plainly, in the wallet prompt, that the signature is key material. The mint key is derived per leaf so that a launch interrupted at any point retries with the same mint and the same one-time key, and never wastes either.
Registration#
Your wallet (ed25519) and your key #0 (WOTS) both sign one statement naming the wallet, the address, the root and the public seed. The server checks both before recording the identity. The binding runs both ways: a wallet can't claim an identity it can't sign for, and an identity can't claim a wallet that didn't sign for it. Both signatures and the exact statement are public, so anyone can re-check them.
On-chain anchor#
Optional, and recommended: an SPL memo pqlaunch-v1:anchor:<pq address>:<root hex>, signed by your wallet. After a curve break anyone can forge a wallet signature, but nobody can rewrite a memo that is already in Solana's history. The anchor proves the root existed, and was claimed by that wallet, while ed25519 still meant something.
Launch attestation#
The launch digest covers the mint, the launching wallet, the identity and key, and the coin's name, ticker, description, image and links, each length-prefixed so no boundary can shift. Not signed: the dev buy (the site shows the amount read from the creation transaction) and the anchor (a separate on-chain fact).
digest = SHA-256("pqlaunch-v1/launch" ‖ 0x00 ‖ lp(mint) ‖ lp(creator) ‖ lp(pqAddress) ‖ lp(u32 leaf)
‖ lp(name) ‖ lp(symbol) ‖ lp(description) ‖ lp(image) ‖ lp(twitter) ‖ lp(telegram))
lp(x) = u32 byte length ‖ x (UTF-8)The server re-derives the digest from the submitted fields, verifies the signature against your registered root, spends the key, pins the metadata and builds pump.fun's create_v2 transaction. You sign it with your wallet and the derived mint key. The metadata pump.fun displays carries the attestation under pq:
{
"name": "Quantum Frog", "symbol": "QFROG", "description": "…", "image": "https://…/ipfs/…",
"website": "https://qlaunch.xyz/coin/<mint>",
"pq": {
"protocol": "pqlaunch-v1",
"scheme": "WOTS+(w=16, SHA-256) + Merkle(h=8)",
"mint": "<mint>", "creator": "<launching wallet>", "leaf": 7,
"identity": { "address": "pq1…", "root": "<hex>", "pubSeed": "<hex>", "height": 8, "epoch": 0 },
"anchor": "<memo tx signature or null>",
"digest": "<hex>", "signature": "<base64, 2404 bytes>"
}
}Creator fees#
pump.fun fixes a coin's fee recipient at creation, through the creator argument of create_v2. On Qlaunch, every coin sets it to the platform treasury: creator fees from trading go to the treasury, not to the launching wallet. The launching wallet signs and pays for the transaction and is the one named in the attestation. The launch form says this before you sign.
Treasury address: 81sbqcNia8tMcCo6F9Mjf2yURkUbt2PM9X14VoetrVxo
Proof of possession#
To prove you hold an identity, request a random 32-byte challenge and sign SHA-256("pqlaunch-v1/prove" ‖ 0x00 ‖ lp(address) ‖ lp(challenge) ‖ lp(u32 leaf) ‖ lp(note)) with your next one-time key. The server checks it against your registered root and spends the key the moment the signature verifies, then the challenge. No wallet and no elliptic curve are involved, so this is the login that keeps working after a break. Each proof gets a public page that re-verifies it.
Security analysis#
- Forgery requires a SHA-256 preimage or second preimage under a fixed, position-unique key: about 2^256 classically and 2^128 with Grover.
- Key reuse is the one way to break WOTS: two signatures from one key let anyone who sees both forge messages. Three layers prevent it. The browser journals every key it signs with (permanently, before the signature leaves the page) and never signs a different message with one, across tabs too. The server spends a key the moment it sees a valid signature, before anything else can fail. The database refuses a second use of (identity, leaf). A retried launch with identical details re-signs the identical digest on purpose: nothing new is revealed.
- The server never sees your seed, your secret keys or your mint key. It can refuse service; on its own it can't forge your signature or move funds, and the page checks the launch transaction before your wallet signs it (exact name, ticker, metadata, buy cap, no extra instructions). It does see each signature as you submit it, which is why reuse is prevented in the browser, not only on the server. You trust it to serve honest page code, as with any web wallet flow; the protocol checks don't depend on it and can be re-run independently.
- Keys in the browser stay in memory only and are dropped when the tab closes. They are never written to storage; only the list of keys already used (and what each signed) is kept, so they can't be reused.
- Exhaustion: an identity has 255 usable keys. Rotating to a fresh tree (a new epoch) certified by the last key is on the roadmap.
Parameters#
| Parameter | Value |
|---|---|
| Hash | SHA-256 (n = 32) |
| Winternitz parameter | w = 16 (64 + 3 = 67 chains) |
| Tree height | 8 (256 one-time keys) |
| WOTS signature | 2,144 B |
| Full signature | 2,404 B |
| Public key | 64 B (root + public seed) |
| Verification cost | ≤ 1,005 + 9 SHA-256 calls |
| Passphrase KDF | scrypt N = 2^17, r = 8, p = 1 |
| Protocol tag | pqlaunch-v1 |
Verify it yourself#
A standalone verifier, written separately from the site's TypeScript and using only Python's standard library. Given a mint, it reads the coin's metadata URI from Solana itself (not from this site), checks the on-chain name and ticker, requires the attestation to name this mint, rebuilds the digest, walks the chains and the tree, and checks the address. The test vectors it ships with are the same ones the site's tests use.
curl -O https://qlaunch.xyz/verify.py python3 verify.py <mint> # or a coin page link; --rpc <url> for another RPC
Or in TypeScript, against this repository's library:
import { launchDigest, pqAddress, verify, identityFromHex, signatureFromBase64 } from "@/lib/pq";
// metadataUri: read it from the mint's on-chain Token-2022 metadata, not from a website.
const meta = await fetch(metadataUri).then((r) => r.json());
const { pq } = meta;
const identity = identityFromHex(pq.identity)!;
if (pq.mint !== mint) throw new Error("attestation is for another coin");
if (pqAddress(identity) !== pq.identity.address) throw new Error("address doesn't match the root");
if (pq.leaf === 0) throw new Error("key 0 only signs registrations");
const digest = launchDigest({
mint: pq.mint, creator: pq.creator, pqAddress: pq.identity.address, leaf: pq.leaf,
name: meta.name, symbol: meta.symbol, description: meta.description ?? "", image: meta.image,
twitter: meta.twitter ?? "", telegram: meta.telegram ?? "",
});
const { valid } = verify(digest, signatureFromBase64(pq.signature)!, identity);