metaspace
Protocol / The lock

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 HH for SHA-256 and HkH^{k} for kk applications of it:

H0(x)=x,Hk(x)=H ⁣(Hk−1(x)).H^{0}(x) = x, \qquad H^{k}(x) = H\!\left(H^{k-1}(x)\right).

Parameters

NameValueIn the source
Winternitz parameter ww256WINTERNITZ_W
Message chains ℓ1\ell_132MESSAGE_CHAINS
Checksum chains ℓ2\ell_22CHECKSUM_CHAINS
Total chains ℓ\ell34TOTAL_CHAINS
Chain length w−1w - 1255CHAIN_LENGTH
Signature size34 × 32 = 1,088 bytesSIGNATURE_BYTES
Commitment size32 bytesVault.commitment
Chains per push8CHAINS_PER_PUSH

w=256w = 256 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 w=16w = 16 there are 64 message chains and 3 checksum chains, 2,144 bytes, still too large. At w=256w = 256 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.

sk=(x1,…,x34),xi∈{0,1}256\mathit{sk} = (x_1, \ldots, x_{34}), \qquad x_i \in \{0,1\}^{256}
pki=H255(xi),i=1,…,34\mathit{pk}_i = H^{255}(x_i), \qquad i = 1, \ldots, 34

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.

C=H ⁣(metaspace:commitment:v1 ∥ pk1 ∥ ⋯ ∥ pk34)C = H\!\left(\texttt{metaspace:commitment:v1} \,\|\, \mathit{pk}_1 \,\|\, \cdots \,\|\, \mathit{pk}_{34}\right)
The tag is the 23-byte ASCII string. ∥\| is concatenation. Computed by commitment_of.
Commitment preimage, 23 + 34 × 32 = 1,111 bytes
"metaspace:commitment:v1" || pk[0] || pk[1] || ... || pk[33]
commitment = SHA-256(preimage)

Encoding a digest

The thing signed is a 32-byte digest d=(d1,…,d32)d = (d_1, \ldots, d_{32}), one byte per message chain. The first 32 chain lengths are the bytes themselves.

mi=di,0≤mi≤255,i=1,…,32m_i = d_i, \qquad 0 \le m_i \le 255, \qquad i = 1, \ldots, 32

The checksum is a plain subtraction, so the argument below can be checked by eye.

c=32⋅255−∑i=132mi=8160−∑i=132mic = 32 \cdot 255 - \sum_{i=1}^{32} m_i = 8160 - \sum_{i=1}^{32} m_i
cc ranges from 0 to 8,160. Computed by chain_steps.

It is split big-endian across the two checksum chains.

m33=⌊c/256⌋,m34=c mod 256m_{33} = \left\lfloor c / 256 \right\rfloor, \qquad m_{34} = c \bmod 256
Since 8160 = 31 · 256 + 224, m33m_{33} never exceeds 31.

Signing

To sign, release each chain value hashed mim_i times. A byte of 0 releases the private value itself. A byte of 255 releases the public value, which reveals nothing further.

σi=Hmi(xi),σ=(σ1,…,σ34)\sigma_i = H^{m_i}(x_i), \qquad \sigma = (\sigma_1, \ldots, \sigma_{34})

The signature is 34 values of 32 bytes, 1,088 bytes. Signing costs ∑imi\sum_i m_i 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.

ei=H255−mi(σi),i=1,…,34e_i = H^{255 - m_i}(\sigma_i), \qquad i = 1, \ldots, 34
accept  ⟺  H ⁣(metaspace:commitment:v1 ∥ e1 ∥ ⋯ ∥ e34)=C\text{accept} \iff H\!\left(\texttt{metaspace:commitment:v1} \,\|\, e_1 \,\|\, \cdots \,\|\, e_{34}\right) = C
Compared against Vault.commitment in finalize_withdrawal.

A genuine signature verifies because hashing composes:

H255−mi ⁣(Hmi(xi))=H255(xi)=pki.H^{255 - m_i}\!\left(H^{m_i}(x_i)\right) = H^{255}(x_i) = \mathit{pk}_i.

Verification costs ∑i(255−mi)\sum_i (255 - m_i) hashes. Signer and verifier together always do exactly 34⋅255=867034 \cdot 255 = 8670. 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 8⋅255=20408 \cdot 255 = 2040 hashes.

Why the checksum stops forgery

An attacker holds a valid signature σ\sigma on mm and wants a signature on some other digest m′m'. For a single chain, moving forward is free and moving backward is not:

mi′≥mi:σi′=H mi′−mi(σi)(computable)m'_i \ge m_i: \quad \sigma'_i = H^{\,m'_i - m_i}(\sigma_i) \quad\text{(computable)}
mi′<mi:σi′ is a preimage of σi under H mi−mi′(inverting SHA-256)m'_i < m_i: \quad \sigma'_i \text{ is a preimage of } \sigma_i \text{ under } H^{\,m_i - m'_i} \quad\text{(inverting SHA-256)}

So a forgery is only cheap if every chain moves forward or stays. Suppose the message chains all satisfy mi′≥mim'_i \ge m_i and at least one is strictly larger. Then the sum rises, so the checksum falls:

∑i=132mi′>∑i=132mi  ⟹  c′<c.\sum_{i=1}^{32} m'_i > \sum_{i=1}^{32} m_i \;\Longrightarrow\; c' < c.

Because cc is split big-endian, c′<cc' < c means either m33′<m33m'_{33} < m_{33}, or m33′=m33m'_{33} = m_{33} and m34′<m34m'_{34} < m_{34}. Either way a checksum chain must move backward. There is no m′≠mm' \ne m 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 21282^{128} 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.

d=H ⁣(metaspace:withdraw:v1 ∥ vault ∥ nonce ∥ amount ∥ dest ∥ Cnext)d = H\!\left(\texttt{metaspace:withdraw:v1} \,\|\, \mathit{vault} \,\|\, \mathit{nonce} \,\|\, \mathit{amount} \,\|\, \mathit{dest} \,\|\, C_{\mathrm{next}}\right)
Computed by WithdrawalRequest::digest. Integers are little-endian 64-bit.
Digest preimage, 133 bytes
"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)
FieldBytesWhat it stops
metaspace:withdraw:v121A signature for one message format being read as another.
vault32Replaying a signature against a different vault.
nonce8Replaying a signature against a later state of the same vault.
amount8Changing how much is paid.
destination32Changing who is paid. The finaliser cannot choose this.
next_commitment32Substituting 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.