Protocol
The lock
The Winternitz one-time signature over SHA-256: keys, commitment, signing, verification, the checksum.
The lock is a Winternitz one-time signature over SHA-256, implemented in hashlock.rs. Write for SHA-256 and for applications of it:
Parameters
| Name | Value | In the source |
|---|---|---|
| Winternitz parameter | 256 | WINTERNITZ_W |
| Message chains | 32 | MESSAGE_CHAINS |
| Checksum chains | 2 | CHECKSUM_CHAINS |
| Total chains | 34 | TOTAL_CHAINS |
| Chain length | 255 | CHAIN_LENGTH |
| Signature size | 34 × 32 = 1,088 bytes | SIGNATURE_BYTES |
| Commitment size | 32 bytes | Vault.commitment |
| Chains per push | 8 | CHAINS_PER_PUSH |
means one chain per byte of a 32-byte digest. The choice is forced by Solana’s 1,232-byte transaction limit, not by cryptography. A Lamport signature over 256 bits is 256 × 32 = 8,192 bytes. At there are 64 message chains and 3 checksum chains, 2,144 bytes, still too large. At the signature is 1,088 bytes and fits, with the verification spread over several transactions.
Keys and the commitment
A private key is 34 random 32-byte values, one per chain, derived off chain from the seed. The public key is each value hashed 255 times.
The vault does not store the 1,088-byte public key. It stores one hash of it, under a domain tag, so a vault costs 32 bytes rather than 1,088. This is the commitment.
commitment_of."metaspace:commitment:v1" || pk[0] || pk[1] || ... || pk[33]
commitment = SHA-256(preimage)Encoding a digest
The thing signed is a 32-byte digest , one byte per message chain. The first 32 chain lengths are the bytes themselves.
The checksum is a plain subtraction, so the argument below can be checked by eye.
chain_steps.It is split big-endian across the two checksum chains.
Signing
To sign, release each chain value hashed times. A byte of 0 releases the private value itself. A byte of 255 releases the public value, which reveals nothing further.
The signature is 34 values of 32 bytes, 1,088 bytes. Signing costs hashes.
Verification
The verifier recomputes the chain lengths from the digest, walks each released value the remaining distance to its endpoint, and hashes the endpoints together.
Vault.commitment in finalize_withdrawal.A genuine signature verifies because hashing composes:
Verification costs hashes. Signer and verifier together always do exactly . For a uniformly random digest the verifier’s share is about 4,300. Solana’s compute limit is 1.4 million units per transaction, which is why the verifier’s walk is split across several transactions. One push of 8 chains is at most hashes.
Why the checksum stops forgery
An attacker holds a valid signature on and wants a signature on some other digest . For a single chain, moving forward is free and moving backward is not:
So a forgery is only cheap if every chain moves forward or stays. Suppose the message chains all satisfy and at least one is strictly larger. Then the sum rises, so the checksum falls:
Because is split big-endian, means either , or and . Either way a checksum chain must move backward. There is no in which every one of the 34 chains moves forward or stays still. Every forgery is therefore a preimage of SHA-256, which is the search in Threat model.
This is the entire argument, and it is why the checksum chains are not optional.
The digest and what it binds
The digest is not chosen freely. It is the hash of the withdrawal request, as written on chain before any chain value is revealed. Everything that could be substituted is in it.
WithdrawalRequest::digest. Integers are little-endian 64-bit."metaspace:withdraw:v1" // 21 bytes, ASCII
|| vault // 32 bytes, the vault's address
|| nonce // 8 bytes, u64 little-endian
|| amount // 8 bytes, u64 little-endian, lamports
|| destination // 32 bytes
|| next_commitment // 32 bytes
digest = SHA-256(preimage)| Field | Bytes | What it stops |
|---|---|---|
| metaspace:withdraw:v1 | 21 | A signature for one message format being read as another. |
| vault | 32 | Replaying a signature against a different vault. |
| nonce | 8 | Replaying a signature against a later state of the same vault. |
| amount | 8 | Changing how much is paid. |
| destination | 32 | Changing who is paid. The finaliser cannot choose this. |
| next_commitment | 32 | Substituting the lock the vault rotates to. |
What is absent on purpose: the submitter. Binding the finaliser’s key would mean only one party could land the withdrawal. That turns a failed transaction into a stuck vault and buys nothing, because the destination is already fixed.