// overview

A quantum-safe transaction layer on Solana

QuantumPOS lets anyone with a Phantom wallet hold, send and receive SOL through a Winternitz vault: a program-owned account that only moves funds when it sees a valid one-time hash-based signature. Ed25519, the scheme behind every normal Solana account, can be broken by a large enough quantum computer. A Winternitz signature can’t, because breaking it would mean reversing Keccak-256.

Each user gets one permanent vault. Each spend signs the exact transfer and the hash of the next key, and the program rotates to that key in the same instruction. Recipients are plain Solana addresses. If the recipient also has a vault, the SOL moves vault-to-vault and stays inside the quantum layer.

816 B

signature

34

hash chains

192-bit

classical preimage

96-bit

quantum (Grover)

// threat model

The quantum threat

Shor’s algorithm solves the discrete-log problem behind elliptic curves in polynomial time, so a quantum computer could compute an Ed25519 private key from its public key. A Solana address is its public key, so every wallet that has ever held funds is exposed. That makes “harvest now, decrypt later” a real risk for SOL held for the long term.

Grover’s algorithm is the best known generic quantum attack on a hash function, and it only square-roots the work. A preimage search over 192 bits therefore costs about 2⁹⁶ quantum operations. Hash-based signatures sidestep Shor completely, which is why NIST standardised them (SP 800-208, FIPS 205).

// cryptography

Winternitz one-time signatures (WOTS)

A Winternitz key is a set of hash chains. The secret is the start of each chain and the public key is the end, 255 steps later. To sign, you reveal each chain at the position given by one byte of the message digest. A verifier walks the rest of each chain and checks that the ends hash to the public key. Since nobody can walk a chain backwards, a signature reveals nothing that signs an earlier position.

N          = 24 bytes kept per hash          (192-bit classical, 96-bit quantum preimage)
MSG_CHUNKS = 32  (one per keccak256 digest byte)
SUM_CHUNKS = 2   (checksum)                   → CHUNKS = 34, SIG_LEN = 34 × 24 = 816 bytes

secret(seed, i) = keccak256("WNTR:SK" ‖ seed ‖ i)[:24]       // a key is one 32-byte seed
step(i, v)      = keccak256(i ‖ v)[:24]                      // chain index is domain-separated
pk_hash         = keccak256( ‖ᵢ walk(i, secretᵢ, 255) )
chunks(d)       = d[0..32] ‖ be16( Σ (255 − d[j]) )          // checksum
sign(d)ᵢ        = walk(i, secretᵢ, chunks(d)ᵢ)
verify          = keccak256( ‖ᵢ walk(i, sigᵢ, 255 − chunks(d)ᵢ) ) == pk_hash

Why the checksum matters. Without it, a forger could take a signature, raise one digest byte and walk that chain forward for free. Raising any message byte lowers the checksum, so a forgery would always need some chain walked backwards. The earlier lamports-only vault design (Dean Little) had no checksum. This program does.

One time only. A second signature with the same key reveals two points on each chain, and an attacker can then sign anything in between. That is why the key rotates on every spend.

# shared test vector (program/src/wots.rs ⇄ client/src/check-vector.ts ⇄ our backend/wntr.py)
seed      = 0x01 × 32,  message = "winternitz test vector"
pk_hash   = fe5020b437c6b20c5e7375bcd9a3c6918b6d7654d8f488ffd8e3975f9fe5ab2a   ✓
digest    = ee3ac08879e1c9ad49981a20b07abf2bd0171540013cb1b4c77ac074089da713   ✓
sig[0:24] = 4e7850f3be9f49450cdc8a56add99f02c60efdcad1ebc9d4                   ✓
sig_hash  = 02207cde031055670bd22c656eb8f6b1679600450dd1ab7cb3f1888cb504c6c3   ✓

// on-chain

The vault program

Program 13EtnfYGUH8NaGAnUpDTVgSsXoewNnULp7ESwHzQUANT is built from rabb757/winternitz-vault. It was written from the specification, not forked from the original vault, and its interface is different.

Vault account (106 bytes, owner = program)
  tag u8 = 1 | bump u8 | nonce u64 LE | vault_id [32] | pk_hash [32] | mint [32]
PDA = find_program_address(["vault", vault_id])      vault_id = first pk_hash, so the address is permanent
taginstructiondataaccounts
0initializepk_hash(32)payer(s,w) · vault(w) · mint · system
1spend: mintamount u64 · next_pk(32) · sig(816)vault(w) · mint(w) · dest(w) · token program
2spend: transferamount u64 · next_pk(32) · sig(816)vault(w) · source(w) · dest(w) · token program
3execute (any CPI)next_pk(32) · sig(816) · n · flags[n] · cpi_datavault(w) · target program · …n inner accounts
transfer digest = keccak256("WNTR:XFER" ‖ vault_id ‖ nonce ‖ source ‖ destination ‖ amount ‖ next_pk_hash)
execute  digest = keccak256("WNTR:EXEC" ‖ vault_id ‖ nonce ‖ target ‖ n ‖ (key ‖ flags)* ‖ cpi_data ‖ next_pk_hash)
on success: pk_hash ← next_pk_hash, nonce ← nonce + 1      (written before the CPI)

The vault holds data, so the System program can’t debit it. SOL that lands directly on the PDA can never be moved. Funds therefore live as wrapped SOL in the vault’s associated token account, and the vault PDA is the token authority.

// architecture

How the transaction layer uses it

actionroutetransactions (one Phantom approval)
Open vault—initialize + create wSOL ATA (+ optional transfer & SyncNative)
Deposit / pay link—create ATA (idempotent) · system transfer → ATA · SyncNative
Send to vault uservault① ensure recipient ATA ② SyncNative + WOTS transfer(vault ATA → recipient vault ATA)
Send to plain walletwallet① ensure sender wSOL ATA ② WOTS transfer → it ③ CloseAccount (unwrap) + system transfer
Withdraw everythingcloseSyncNative + WOTS execute(CloseAccount(vault ATA → Phantom))
  • Same signature on every retry. The signed message has no blockhash, so resuming a stuck transfer resubmits the identical 816 bytes and reveals nothing new about the key.
  • Exact confirmation. The server reads the on-chain vault and finalises only when nonce == n + 1 and pk_hash == next_pk. A lagging RPC node can’t trigger a false rotation.
  • Strict ordering. Steps are signed together but broadcast one at a time. Each is confirmed before the next, so a spend never runs ahead of its destination account.
  • Measured cost. The spend transaction is 1,204 bytes plus the priority-fee instruction (limit 1,232). Verifying uses about 590k–660k compute units, and we request 1.4M for the worst case (8,670 keccaks).
  • Phantom pays the fees. About 0.00001 SOL per transaction. At current mainnet rent, opening a vault costs about 0.0027 SOL (vault 0.00119 + token account 0.00149). Creating a missing destination token account costs about 0.0015 SOL, and that rent is returned when the account is closed. The app reads live rent from the cluster.

// how it works

From Phantom to a quantum vault and back

Phantom

Pays fees

Your vault

Wrapped SOL, PDA

WOTS spend

816-byte signature

Recipient vault

or native wallet

nonce n → sign(keccak(“WNTR:XFER” | vault | n | src | dst | amt | pkₙ₊₁))

verify: 34 chains walked, hash == pkₙ

rotate: pk ← pkₙ₊₁, nonce ← n+1

01

Open your vault

We generate a Winternitz key and derive a permanent vault address from its hash. Phantom pays a one-time rent of under 0.003 SOL to create it on mainnet.

02

Receive into it

Anyone can pay you through your link or QR code. The SOL is wrapped into the vault's token account, and only a Winternitz signature can ever move it.

03

Sign once

To send, a one-time signature covers the nonce, source, destination, amount and the hash of the next key. Change any byte and the program rejects it.

04

Key rotates in place

The vault swaps to the next key in the same instruction. Your address never changes, and every old signature stops working for good.

05

Deliver

Send to another vault and the SOL stays quantum-protected. Send to a plain wallet and it is unwrapped and delivered as native SOL.

// verification

What we verified

  • Our Python signer (backend/wntr.py) reproduces all four values of the program's published test vector, byte for byte.
  • We pulled the live program bytecode from its programdata account (8fqkcU…MZWu, 117,616 bytes) and ran it locally in LiteSVM.
  • 28/28 end-to-end checks passed with the real API against that bytecode: open + deposit, vault-to-vault, native delivery to an empty wallet, partial withdraw, full withdraw, re-deposit after close, retry with the identical signature, the discard warning, and outside-deposit detection.
  • Negative tests: a changed amount, a replayed signature and a stale pre-rotation signature were all rejected on-chain with "winternitz signature rejected".
  • An earlier build targeted the original Open/Split/Close layout. Mainnet simulation showed this program rejects it (NotEnoughAccountKeys), which is why the layer was rebuilt on the real interface.

// honest scope

Security model & limitations

Server-held keys

One-time seeds are generated and stored on our server, encrypted with Fernet (AES-128-CBC + HMAC). Phantom can't hold Winternitz keys. This trades self-custody for usability: whoever controls the server and its encryption key controls the vaults.

Upgradeable program

The program's upgrade authority (EFQJ3s…aeU) is an ordinary Ed25519 key. Whoever holds it could change the program's rules.

Ed25519 fee payer

Phantom signs and pays fees with Ed25519. A quantum attacker who broke your Phantom key could take the SOL in Phantom, but not the funds behind your vault's Winternitz key.

Native-SOL delivery

Sending to a plain wallet routes the SOL briefly through your own wSOL account before it's unwrapped. At that point it has left the quantum layer, by design.

Discarded transfers

If a signed transfer is discarded, its signature may already be public. Signing a different message with the same key weakens it, so the app asks for explicit confirmation and tells you to move funds.

Don't fund the PDA directly

SOL sent straight to the vault PDA is stuck permanently. Always use the pay link, which deposits into the vault's token account.

Research-grade

The program is new, and the vault account's rent (~0.0012 SOL) can't be reclaimed. Use amounts you can afford to lose.

// research

Research & references