Rotor documentation

Quantum-safe vaults on Solana, explained line by line.

Everything about how Rotor works, from the math to every instruction of the on-chain program. All code on this page is read straight from the source that’s deployed on mainnet.

#Introduction

Every normal Solana wallet is an ed25519 key pair, and your address is the public key. A large quantum computer running Shor’s algorithm could derive the private key from it. Rotor removes that dependency:

  • Funds live in a program-derived address (the vault authority). It has no private key at all.
  • The Rotor program only moves funds when it sees a valid Winternitz one-time signature (WOTS), which is built purely from SHA-256.
  • Every action rotates the vault to a fresh key. The previous key is retired on-chain forever.
Client24-word seed → WOTS keys
reserve · write_sig · action
Rotor programSHA-256 verification
invoke_signed
Vault authority PDAholds SOL & token accounts

#Quickstart

The TypeScript SDK wraps key derivation, signing, reservations and transaction building.

quickstart.tsTypeScript
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
import { connectionTransport, generateSeed, QVaultClient, VaultKeychain } from "@qvault/sdk";

const mnemonic = generateSeed();                       // 24 words. Store them safely.
const keychain = VaultKeychain.fromMnemonic(mnemonic); // vault id + every WOTS key
const gasWallet = Keypair.fromSeed(keychain.gasWalletSeed);

const client = new QVaultClient({
  transport: connectionTransport(new Connection("https://api.mainnet-beta.solana.com")),
  programId: new PublicKey("7g1WNYsZDPJK46dUrMg5cZVoTGSC9UF6YLto5t8W5RfP"),
  keychain,
  gasWallet,            // pays fees only
  store: durableStore,  // persists signatures: required to prevent key reuse
});

await client.initVault();                   // creates the vault at key #0
console.log(client.receiveAddress.toBase58()); // deposit SOL / tokens here

await client.sendSol(new PublicKey("…recipient…"), 1_000_000n);   // 0.001 SOL
await client.sendToken({ mint, to: recipient, amount: 5_000_000n }); // SPL or Token-2022

#Deployed program

NetworkSolana mainnet-beta
Program ID7g1WNYsZDPJK46dUrMg5cZVoTGSC9UF6YLto5t8W5RfP
FrameworkPinocchio 0.11 (no_std-style, zero-copy)
Binary SHA-256c17ceea114a84141600d9dd2aba2347a3b210ce3ab33f9aea9b0d35ce19e0d50
Solscansolscan.io ↗

#Winternitz one-time signatures

A WOTS key is 34 secret values. Each one is the start of a hash chain: hash it 255 times and you reach the chain’s end. The public key is the 34 chain ends, and the vault stores only a 32-byte commitment (hash) of them.

ParameterValueMeaning
n32 byteshash output size (SHA-256)
w256each chain encodes one byte (0–255)
len₁32message digits (one per digest byte)
len₂2checksum digits
Signature34 × 32 = 1,088 bytesone chain value per digit
program/src/wots.rsRust
pub const N: usize = 32;
pub const LEN1: usize = 32;
pub const LEN: usize = 34;
pub const SIG_LEN: usize = LEN * N; // 1088
const MAX_DIGIT: u8 = 255;

pub const TWEAK_DOMAIN: &[u8] = b"QV1-TW";
pub const PK_DOMAIN: &[u8] = b"QV1-PK";
pub const ID_DOMAIN: &[u8] = b"QV1-ID";
pub const MSG_DOMAIN: &[u8] = b"QVAULT_V1";

#Digits and the checksum

The 32-byte message digest becomes 32 digits. Two more digits encode a checksum of “how far each chain still has to go”. Without it, an attacker could take a signature and hash some chains further to sign a bigger digit. The checksum would then have to get smaller, which needs going backwards.

program/src/wots.rsRust
/// 32 message digits followed by a 2-digit big-endian checksum.
pub fn digits(digest: &[u8; 32]) -> [u8; LEN] {
    let mut d = [0u8; LEN];
    d[..LEN1].copy_from_slice(digest);
    let csum: u32 = digest.iter().map(|b| (MAX_DIGIT - b) as u32).sum();
    d[LEN1] = (csum >> 8) as u8;
    d[LEN1 + 1] = csum as u8;
    d
}

#Verifying: finish every chain

To sign digit d, the signer reveals the chain value after d steps. The verifier hashes it the remaining 255 − d steps and checks that all 34 ends hash to the stored commitment.

program/src/wots.rsRust
/// Walk every chain from the signature to its end and return the key commitment
/// those chain ends hash to. A signature is valid iff this equals the stored commitment.
pub fn recover_commitment(
    pub_seed: &[u8; 32],
    index: u64,
    digest: &[u8; 32],
    sig: &[u8],
) -> [u8; 32] {
    debug_assert_eq!(sig.len(), SIG_LEN);
    let prefix = tweak_prefix(pub_seed, index);
    let d = digits(digest);

    let mut pk = [0u8; SIG_LEN];
    // F input: P_k(32) || chain i (1) || step j (1) || x (32)
    let mut buf = [0u8; 66];
    buf[..32].copy_from_slice(&prefix);

    for i in 0..LEN {
        buf[32] = i as u8;
        buf[34..].copy_from_slice(&sig[i * N..(i + 1) * N]);
        for j in d[i]..MAX_DIGIT {
            buf[33] = j;
            let h = sha256(&[&buf]);
            buf[34..].copy_from_slice(&h);
        }
        pk[i * N..(i + 1) * N].copy_from_slice(&buf[34..]);
    }

    sha256(&[PK_DOMAIN, &prefix, &pk])
}
  1. 1
    tweak_prefix mixes the public seed and the key index into every hash (WOTS+-style), so chains from different keys or vaults can never be swapped or attacked together.
  2. 2
    The 66-byte buffer is P_k ‖ chain i ‖ step j ‖ x. Including i and j makes every single hash in every chain unique.
  3. 3
    For chain i, start from the signature value and hash from position d[i] up to 255. That’s exactly the work the signer skipped.
  4. 4
    Concatenate the 34 chain ends and hash once more with the QV1-PK domain. A valid signature reproduces the commitment stored in the vault, and nothing else can.
program/src/wots.rsRust
/// Per-key public tweak prefix `P_k`.
#[inline(always)]
pub fn tweak_prefix(pub_seed: &[u8; 32], index: u64) -> [u8; 32] {
    sha256(&[TWEAK_DOMAIN, pub_seed, &index.to_le_bytes()])
}
program/src/hash.rsRust
/// SHA-256 over the concatenation of `parts`.
///
/// On-chain this is one `sol_sha256` syscall (85 CU + 1 CU per 2 bytes per part).
#[inline(always)]
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
    #[cfg(target_os = "solana")]
    {
        let mut out = [0u8; 32];
        // SAFETY: `&[u8]` is laid out as (ptr, len), which is the `SolBytes` layout the
        // syscall expects; `out` is 32 writable bytes.
        unsafe {
            pinocchio::syscalls::sol_sha256(
                parts.as_ptr() as *const u8,
                parts.len() as u64,
                out.as_mut_ptr(),
            );
        }
        out
    }
    #[cfg(not(target_os = "solana"))]
    {
        use sha2::{Digest, Sha256};
        let mut h = Sha256::new();
        for p in parts {
            h.update(p);
        }
        h.finalize().into()
    }
}

On-chain, each hash is a single sol_sha256 syscall over one contiguous 66-byte slice, about 145 CU per step including loop overhead.

#Key derivation

Everything comes from the BIP-39 seed: the WOTS secrets, the public seed, the gas wallet, and the vault id. Every derivation uses its own HMAC domain, so learning one (e.g. the gas wallet key) reveals nothing about the others.

sdk/src/keys.tsTypeScript
constructor(seed: Uint8Array, account = 0) {
  this.account = account;
  const master = hmac(sha256, MASTER_KEY, seed);
  this.skSeed = hmac(sha256, master, concatBytes(utf8ToBytes("sk"), u32le(account)));
  this.pubSeed = hmac(sha256, master, concatBytes(utf8ToBytes("pub"), u32le(account)));
  this.gasWalletSeed = hmac(sha256, master, concatBytes(utf8ToBytes("gas"), u32le(account)));
  this.vaultId = vaultIdFrom(this.pubSeed, this.commitment(0n));
}
sdk/src/keys.tsTypeScript
/** Derive the secret key at `index`. */
key(index: bigint): WotsKey {
  const sk: Uint8Array[] = [];
  for (let i = 0; i < LEN; i++) {
    sk.push(hmac(sha256, this.skSeed, concatBytes(utf8ToBytes("wots"), u64le(index), Uint8Array.of(i))));
  }
  return { index, pubSeed: this.pubSeed, sk };
}

#The signed message

A key signs the SHA-256 digest of this exact byte string. It binds the program, the vault, the key index (so it can’t be replayed), the action and its parameters (so they can’t be swapped), and the next key’s commitment (so a relayer can’t substitute its own key).

DESIGN.md §2Text
msg = "QVAULT_V1" || program_id || vault_id || index(u64 LE) || action(u8) || params || next_commitment
digest = SHA-256(msg)

send_sol             (1): amount(u64) || recipient
send_token           (2): token_program || mint || source || destination || amount(u64) || decimals(u8)
close_token_account  (3): token_program || token_account
sdk/src/message.tsTypeScript
export function buildMessage(p: MessageParams): Uint8Array {
  if (p.vaultId.length !== 32) throw new Error("vaultId must be 32 bytes");
  if (p.nextCommitment.length !== 32) throw new Error("nextCommitment must be 32 bytes");
  return concatBytes(
    MSG_DOMAIN,
    addrBytes(p.programId),
    p.vaultId,
    u64le(p.index),
    actionBytes(p.action),
    p.nextCommitment,
  );
}

#Key rotation

Each successful action replaces the stored commitment with next_commitment and increments the index. Your receive address never changes, because it’s derived from the vault id, not from any key.

program/src/state.rsRust
/// Replace the commitment and advance the index. The old key is gone for good.
pub fn rotate(&mut self, next_commitment: &[u8; 32]) -> Option<()> {
    let next = self.index().checked_add(1)?;
    self.0[OFF_INDEX..OFF_INDEX + 8].copy_from_slice(&next.to_le_bytes());
    self.0[OFF_COMMITMENT..OFF_COMMITMENT + 32].copy_from_slice(next_commitment);
    Some(())
}

#Key reservations

A one-time key must never sign two different messages. With several devices on one vault, two could both read “current key = #5” and each publish a different signature. Reservations close that gap on-chain:

  1. 1
    Before anything is signed, the client calls reserve(digest). It records (current index, digest) in the signature buffer.
  2. 2
    A second reserve for the same key with a different digest fails with AlreadyReserved. The losing device stops before signing, so nothing leaks.
  3. 3
    Only after its reservation is confirmed does the client sign and upload the signature.
  4. 4
    The action checks the buffer is reserved for exactly its own digest at the current index (ReservationMismatch otherwise).

#Entrypoint & dispatch

The first byte of instruction data selects the handler. Everything else is fixed-layout little-endian bytes. There’s no Borsh and no allocator-heavy deserialisation.

program/src/lib.rsRust
#[cfg(target_os = "solana")]
mod entry {
    use pinocchio::{entrypoint, AccountView, Address, ProgramResult};

    entrypoint!(process_instruction);

    #[inline(never)]
    fn process_instruction(
        program_id: &Address,
        accounts: &mut [AccountView],
        data: &[u8],
    ) -> ProgramResult {
        crate::processor::process(program_id, accounts, data)
    }
}
program/src/processor.rsRust
pub fn process(program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult {
    let (&tag, rest) = data
        .split_first()
        .ok_or(ProgramError::InvalidInstructionData)?;
    match tag {
        ix::INIT_VAULT => init_vault(program_id, accounts, rest),
        ix::WRITE_SIG => write_sig(program_id, accounts, rest),
        ix::SEND_SOL => send_sol(program_id, accounts, rest),
        ix::SEND_TOKEN => send_token(program_id, accounts, rest),
        ix::CLOSE_TOKEN_ACCOUNT => close_token_account(program_id, accounts, rest),
        ix::CLOSE_SIG_BUFFER => close_sig_buffer(program_id, accounts),
        ix::RESERVE => reserve(program_id, accounts, rest),
        _ => Err(ProgramError::InvalidInstructionData),
    }
}

#Accounts

#Vault state

PDA ["vault", vault_id], owned by the program, 112 bytes, zero-copy:

OffsetFieldType
0discriminator (= 1)u8
1version (= 1)u8
2bumpu8
3authority_bumpu8
8indexu64
16vault_id[u8; 32]
48pub_seed[u8; 32]
80key_commitment[u8; 32]

#Vault authority

PDA ["authority", vault_id], system-owned. This is the address users send funds to. It holds SOL directly and owns every token account. The program signs for it with invoke_signed.

#Signature buffer

PDA ["sigbuf", vault_id, payer], 1,192 bytes. A WOTS signature (1,088 bytes) doesn’t fit in one transaction next to the action’s accounts, so it’s uploaded here first and closed (rent refunded) by the action.

program/src/state.rsRust
/// Signature buffer (v2): `vault_id[32] || payer[32] || index u64 || digest[32] || signature[1088]`.
///
/// `index` + `digest` are a *reservation*: before any signature bytes exist, the client reserves
/// the vault's current key for exactly one message. A second, different message for the same key
/// is rejected on-chain, so two devices can never both publish a signature with one key.
pub const SIGBUF_OFF_INDEX: usize = 64;
pub const SIGBUF_OFF_DIGEST: usize = 72;
pub const SIGBUF_HEADER: usize = 104;
pub const SIGBUF_LEN: usize = SIGBUF_HEADER + SIG_LEN; // 1192
/// v1 buffers (no reservation) may still exist; they can only be closed.
pub const LEGACY_SIGBUF_LEN: usize = 64 + SIG_LEN; // 1152

#Instructions

TagInstructionDataAccounts
0init_vaultpub_seed[32] commitment[32]payer(s,w), vault_state(w), system
1write_sigvault_id[32] offset(u16) bytes…payer(s,w), sig_buffer(w), system
2send_solamount(u64) next[32]payer, vault_state, authority, sig_buffer, recipient, system
3send_tokenamount(u64) decimals(u8) next[32]payer, vault_state, authority, sig_buffer, source, mint, destination, token_program, …hooks
4close_token_accountnext[32]payer, vault_state, authority, sig_buffer, token_account, token_program
5close_sig_buffer(none)payer(s,w), sig_buffer(w)
6reservedigest[32]payer(s,w), vault_state, sig_buffer(w), system

#init_vault

program/src/processor.rsRust
// ---------------------------------------------------------------------------
// init_vault: pub_seed[32] commitment[32]
// accounts: payer(s,w), vault_state(w), system_program
// ---------------------------------------------------------------------------
fn init_vault(program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult {
    let [payer, vault_state, system_program, ..] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    require_signer(payer)?;
    require_system_program(system_program)?;
    let pub_seed = read32(data, 0)?;
    let commitment = read32(data, 32)?;

    // vault_id binds the first commitment, so init cannot be front-run with another key.
    let vault_id = wots::vault_id(&pub_seed, &commitment);
    let (expected, bump) = Address::find_program_address(&[VAULT_SEED, &vault_id], program_id);
    if vault_state.address() != &expected {
        return Err(VaultError::InvalidVault.into());
    }
    // Canonical bump only, so the receive address is unambiguous.
    let (_, auth_bump) = Address::find_program_address(&[AUTHORITY_SEED, &vault_id], program_id);

    let bump_seed = [bump];
    let seeds = [
        Seed::from(VAULT_SEED),
        Seed::from(&vault_id),
        Seed::from(&bump_seed),
    ];
    // Handles a pre-funded PDA (griefing) and fails if already initialised.
    create_program_account_with_minimum_balance_signed(
        vault_state,
        VAULT_LEN,
        program_id,
        payer,
        None,
        &[Signer::from(&seeds)],
    )?;

    let mut data = vault_state.try_borrow_mut()?;
    Vault::init(&mut data, bump, auth_bump, &vault_id, &pub_seed, &commitment);
    Ok(())
}
  1. 1
    vault_id is derived from the first commitment. Someone who sees your init transaction can’t front-run it with the same vault id and their own key.
  2. 2
    The vault PDA must match the canonical address for that id.
  3. 3
    The authority bump is found canonically too, so the receive address is unambiguous: no one can pick a different bump and split your deposits.
  4. 4
    create_program_account_with_minimum_balance_signed also handles a PDA that someone pre-funded to grief creation, and refuses an already-initialised vault.

#reserve

program/src/processor.rsRust
// ---------------------------------------------------------------------------
// reserve: digest[32]
// accounts: payer(s,w), vault_state, sig_buffer(w), system_program
//
// Reserve the vault's *current* key for exactly one message digest, before any signature bytes
// exist. Only the first reservation per key wins: a different digest for the same key is
// rejected, so a second device never gets to publish a signature (the client only signs after its
// reservation is confirmed). Re-reserving the same digest is a no-op (safe retries).
// ---------------------------------------------------------------------------
fn reserve(program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult {
    let [payer, vault_state, sig_buffer, system_program, ..] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    require_signer(payer)?;
    require_system_program(system_program)?;
    let digest = read32(data, 0)?;
    let (vault_id, index) = read_vault(program_id, vault_state)?;
    let payer_key = *payer.address();

    if !sig_buffer.owned_by(program_id) {
        // First use: create the payer's buffer at its PDA (handles a pre-funded address).
        let (expected, bump) = Address::find_program_address(
            &[SIGBUF_SEED, &vault_id, payer_key.as_ref()],
            program_id,
        );
        if sig_buffer.address() != &expected {
            return Err(VaultError::InvalidSigBuffer.into());
        }
        let bump_seed = [bump];
        let seeds = [
            Seed::from(SIGBUF_SEED),
            Seed::from(&vault_id),
            Seed::from(payer_key.as_array()),
            Seed::from(&bump_seed),
        ];
        create_program_account_with_minimum_balance_signed(
            sig_buffer,
            SIGBUF_LEN,
            program_id,
            payer,
            None,
            &[Signer::from(&seeds)],
        )?;
        let mut buf = sig_buffer.try_borrow_mut()?;
        buf[..32].copy_from_slice(&vault_id);
        buf[32..SIGBUF_OFF_INDEX].copy_from_slice(payer_key.as_ref());
        buf[SIGBUF_OFF_INDEX..SIGBUF_OFF_DIGEST].copy_from_slice(&index.to_le_bytes());
        buf[SIGBUF_OFF_DIGEST..SIGBUF_HEADER].copy_from_slice(&digest);
        return Ok(());
    }

    // Existing buffer (a legacy v1 buffer must be closed first).
    if sig_buffer.data_len() != SIGBUF_LEN {
        return Err(VaultError::InvalidSigBuffer.into());
    }
    let mut buf = sig_buffer.try_borrow_mut()?;
    if buf[..32] != vault_id || buf[32..SIGBUF_OFF_INDEX] != *payer_key.as_ref() {
        return Err(VaultError::InvalidSigBuffer.into());
    }
    let held = u64::from_le_bytes(buf[SIGBUF_OFF_INDEX..SIGBUF_OFF_DIGEST].try_into().unwrap());
    if held == index {
        if buf[SIGBUF_OFF_DIGEST..SIGBUF_HEADER] == digest {
            return Ok(()); // same message again: idempotent
        }
        return Err(VaultError::AlreadyReserved.into());
    }
    if held > index {
        return Err(VaultError::InvalidSigBuffer.into());
    }
    // Reservation for an already-retired key: nothing it held can be used any more.
    buf[SIGBUF_OFF_INDEX..SIGBUF_OFF_DIGEST].copy_from_slice(&index.to_le_bytes());
    buf[SIGBUF_OFF_DIGEST..SIGBUF_HEADER].copy_from_slice(&digest);
    buf[SIGBUF_HEADER..].fill(0);
    Ok(())
}
  1. 1
    read_vault validates the vault (owner, layout, PDA) and returns the current index. Callers can’t pick the index.
  2. 2
    First use: create the payer’s buffer at ["sigbuf", vault_id, payer] and write the reservation header. Only that payer’s signature can create or use it.
  3. 3
    Existing buffer at the same index: the same digest is a no-op (safe retries), a different digest is rejected.
  4. 4
    A reservation left for an already-retired key is reset; its signature bytes can no longer be used for anything.

#write_sig

program/src/processor.rsRust
// ---------------------------------------------------------------------------
// write_sig: vault_id[32] offset u16 bytes...
// accounts: payer(s,w), sig_buffer(w), system_program
// Requires a reservation (see `reserve`); only the reserving payer can write.
// ---------------------------------------------------------------------------
fn write_sig(program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult {
    let [payer, sig_buffer, system_program, ..] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    require_signer(payer)?;
    require_system_program(system_program)?;
    let vault_id = read32(data, 0)?;
    let offset = read_u16(data, 32)? as usize;
    let bytes = &data[34..];
    if offset + bytes.len() > SIG_LEN {
        return Err(VaultError::InvalidSigBuffer.into());
    }
    if !sig_buffer.owned_by(program_id) || sig_buffer.data_len() != SIGBUF_LEN {
        return Err(VaultError::InvalidSigBuffer.into()); // not reserved
    }
    let payer_key = *payer.address();
    let mut buf = sig_buffer.try_borrow_mut()?;
    if buf[..32] != vault_id || buf[32..SIGBUF_OFF_INDEX] != *payer_key.as_ref() {
        return Err(VaultError::InvalidSigBuffer.into());
    }
    let start = SIGBUF_HEADER + offset;
    buf[start..start + bytes.len()].copy_from_slice(bytes);
    Ok(())
}

Chunks are written at any offset (bounds-checked), so the client can split the 1,088 bytes across transactions. The SDK puts as many bytes as fit into the action transaction itself and uploads the rest beforehand.

#send_sol

program/src/processor.rsRust
// ---------------------------------------------------------------------------
// send_sol: amount u64 next_commitment[32]
// accounts: payer(s,w), vault_state(w), vault_authority(w), sig_buffer(w), recipient(w),
//           system_program
// ---------------------------------------------------------------------------
fn send_sol(program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult {
    let [payer, vault_state, authority, sig_buffer, recipient, system_program, ..] = accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    require_system_program(system_program)?;
    let amount = read_u64(data, 0)?;
    let next = read32(data, 8)?;

    let v = verify_and_rotate(
        program_id,
        payer,
        vault_state,
        sig_buffer,
        action::SEND_SOL,
        &[&amount.to_le_bytes(), recipient.address().as_ref()],
        &next,
    )?;
    v.check_authority(program_id, authority)?;

    let bump = [v.auth_bump];
    let seeds = v.authority_seeds(&bump);
    Transfer {
        from: authority,
        to: recipient,
        lamports: amount,
    }
    .invoke_signed(&[Signer::from(&seeds)])?;

    close_buffer(sig_buffer, payer)
}
  1. 1
    The message params are built from the actual accounts and data of this instruction. If anyone swaps the recipient or amount, the digest changes and verification fails.
  2. 2
    verify_and_rotate checks the reservation, verifies the WOTS signature and rotates the key, all before any funds move.
  3. 3
    The authority PDA is re-derived from the stored bump and must match the account passed in.
  4. 4
    A system transfer signed by the PDA moves the SOL, then the buffer is closed and its rent refunded to the payer.

#send_token

program/src/processor.rsRust
// ---------------------------------------------------------------------------
// send_token: amount u64 decimals u8 next_commitment[32]
// accounts: payer(s,w), vault_state(w), vault_authority, sig_buffer(w), source(w), mint,
//           destination(w), token_program, ...extra (forwarded to transfer_checked, e.g.
//           transfer-hook accounts)
// ---------------------------------------------------------------------------
fn send_token(program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult {
    let [payer, vault_state, authority, sig_buffer, source, mint, destination, token_program, extra @ ..] =
        accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    let token_program_id = require_token_program(token_program)?;
    let amount = read_u64(data, 0)?;
    let decimals = *data.get(8).ok_or(ProgramError::InvalidInstructionData)?;
    let next = read32(data, 9)?;

    let v = verify_and_rotate(
        program_id,
        payer,
        vault_state,
        sig_buffer,
        action::SEND_TOKEN,
        &[
            token_program_id.as_ref(),
            mint.address().as_ref(),
            source.address().as_ref(),
            destination.address().as_ref(),
            &amount.to_le_bytes(),
            &[decimals],
        ],
        &next,
    )?;
    v.check_authority(program_id, authority)?;

    let mut ix_data = [0u8; 10];
    ix_data[0] = TOKEN_IX_TRANSFER_CHECKED;
    ix_data[1..9].copy_from_slice(&amount.to_le_bytes());
    ix_data[9] = decimals;

    let mut metas = Vec::with_capacity(4 + extra.len());
    metas.push(InstructionAccount::writable(source.address()));
    metas.push(InstructionAccount::readonly(mint.address()));
    metas.push(InstructionAccount::writable(destination.address()));
    metas.push(InstructionAccount::readonly_signer(authority.address()));
    let mut views: Vec<&AccountView> = Vec::with_capacity(4 + extra.len());
    views.extend([&*source, &*mint, &*destination, &*authority]);
    for a in extra.iter() {
        metas.push(InstructionAccount::new(a.address(), a.is_writable(), false));
        views.push(a);
    }

    let bump = [v.auth_bump];
    let seeds = v.authority_seeds(&bump);
    invoke_signed_with_slice(
        &InstructionView {
            program_id: &token_program_id,
            data: &ix_data,
            accounts: &metas,
        },
        &views,
        &[Signer::from(&seeds)],
    )?;
    drop(views);

    close_buffer(sig_buffer, payer)
}
  1. 1
    The token program must be SPL Token or Token-2022. Its id is part of the signed message.
  2. 2
    Uses TransferChecked (mint + decimals), so the amount can’t be misinterpreted, and source, destination and decimals are all bound by the signature.
  3. 3
    Any extra accounts are forwarded untouched, which is how Token-2022 transfer hooks get their accounts.

#close_token_account & close_sig_buffer

program/src/processor.rsRust
// ---------------------------------------------------------------------------
// close_token_account: next_commitment[32]
// accounts: payer(s,w), vault_state(w), vault_authority(w), sig_buffer(w), token_account(w),
//           token_program
// Rent goes back to the vault authority.
// ---------------------------------------------------------------------------
fn close_token_account(
    program_id: &Address,
    accounts: &mut [AccountView],
    data: &[u8],
) -> ProgramResult {
    let [payer, vault_state, authority, sig_buffer, token_account, token_program, ..] = accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    let token_program_id = require_token_program(token_program)?;
    let next = read32(data, 0)?;

    let v = verify_and_rotate(
        program_id,
        payer,
        vault_state,
        sig_buffer,
        action::CLOSE_TOKEN_ACCOUNT,
        &[token_program_id.as_ref(), token_account.address().as_ref()],
        &next,
    )?;
    v.check_authority(program_id, authority)?;

    let bump = [v.auth_bump];
    let seeds = v.authority_seeds(&bump);
    invoke_signed(
        &InstructionView {
            program_id: &token_program_id,
            data: &[TOKEN_IX_CLOSE_ACCOUNT],
            accounts: &[
                InstructionAccount::writable(token_account.address()),
                InstructionAccount::writable(authority.address()),
                InstructionAccount::readonly_signer(authority.address()),
            ],
        },
        &[&*token_account, &*authority, &*authority],
        &[Signer::from(&seeds)],
    )?;

    close_buffer(sig_buffer, payer)
}
program/src/processor.rsRust
// ---------------------------------------------------------------------------
// close_sig_buffer: (no data) — reclaim rent from an abandoned buffer.
// accounts: payer(s,w), sig_buffer(w)
// ---------------------------------------------------------------------------
fn close_sig_buffer(program_id: &Address, accounts: &mut [AccountView]) -> ProgramResult {
    let [payer, sig_buffer, ..] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    require_signer(payer)?;
    let len = sig_buffer.data_len();
    if !sig_buffer.owned_by(program_id) || (len != SIGBUF_LEN && len != LEGACY_SIGBUF_LEN) {
        return Err(VaultError::InvalidSigBuffer.into());
    }
    if sig_buffer.try_borrow()?[32..64] != *payer.address().as_ref() {
        return Err(VaultError::InvalidSigBuffer.into());
    }
    close_buffer(sig_buffer, payer)
}

#verify_and_rotate (the core)

Every action goes through this one function. If it returns, the signature was valid for exactly this action and the key has already been rotated.

program/src/processor.rsRust
/// Verify the WOTS signature in `sig_buffer` over the action message, then rotate the key.
///
/// `params` are the action-specific message fields (DESIGN.md §2), built from the accounts
/// and data actually used by the instruction, so the signature binds exactly what executes.
fn verify_and_rotate(
    program_id: &Address,
    payer: &AccountView,
    vault_state: &mut AccountView,
    sig_buffer: &AccountView,
    action: u8,
    params: &[&[u8]],
    next_commitment: &[u8; 32],
) -> Result<Verified, ProgramError> {
    require_signer(payer)?;
    if !vault_state.owned_by(program_id) || !vault_state.is_writable() {
        return Err(VaultError::InvalidVault.into());
    }
    if !sig_buffer.owned_by(program_id)
        || !sig_buffer.is_writable()
        || sig_buffer.data_len() != SIGBUF_LEN
    {
        return Err(VaultError::InvalidSigBuffer.into());
    }

    let vault_addr = *vault_state.address();
    let mut data = vault_state.try_borrow_mut()?;
    if !Vault::is_valid_layout(&data) {
        return Err(VaultError::InvalidVault.into());
    }
    let mut vault = Vault(&mut data);
    let vault_id = vault.vault_id();
    if Address::derive_address(&[VAULT_SEED, &vault_id], Some(vault.bump()), program_id)
        != vault_addr
    {
        return Err(VaultError::InvalidVault.into());
    }

    let buf = sig_buffer.try_borrow()?;
    if buf[..32] != vault_id || buf[32..64] != *payer.address().as_ref() {
        return Err(VaultError::InvalidSigBuffer.into());
    }

    let index = vault.index();
    let index_bytes = index.to_le_bytes();
    let action_byte = [action];
    let mut parts: Vec<&[u8]> = Vec::with_capacity(6 + params.len());
    parts.extend([
        MSG_DOMAIN,
        program_id.as_ref(),
        &vault_id[..],
        &index_bytes[..],
        &action_byte[..],
    ]);
    parts.extend_from_slice(params);
    parts.push(next_commitment);
    let digest = sha256(&parts);

    // The buffer must be reserved for exactly this message at the current key.
    if u64::from_le_bytes(buf[SIGBUF_OFF_INDEX..SIGBUF_OFF_DIGEST].try_into().unwrap()) != index
        || buf[SIGBUF_OFF_DIGEST..SIGBUF_HEADER] != digest
    {
        return Err(VaultError::ReservationMismatch.into());
    }

    let recovered =
        wots::recover_commitment(&vault.pub_seed(), index, &digest, &buf[SIGBUF_HEADER..]);
    if recovered != vault.commitment() {
        return Err(VaultError::InvalidSignature.into());
    }

    vault
        .rotate(next_commitment)
        .ok_or(ProgramError::ArithmeticOverflow)?;

    Ok(Verified {
        vault_id,
        auth_bump: vault.auth_bump(),
    })
}
  1. 1
    Account checks: payer signed, the vault is ours and writable, and the buffer is ours, writable and the right size.
  2. 2
    The vault PDA is re-derived from its stored id and bump. A look-alike account can’t pass.
  3. 3
    The buffer header must name this vault and this payer, so one vault’s signature can’t be replayed on another.
  4. 4
    The digest is computed from domain, program id, vault id, current index, action, params and next commitment, then checked against the reservation before the expensive WOTS work.
  5. 5
    recover_commitment must reproduce the stored commitment. Then rotate installs the next key in the same instruction, so the old key is gone forever.

#Errors

program/src/error.rsRust
/// Custom error codes (surface as `custom program error: 0x..`).
#[repr(u32)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VaultError {
    /// WOTS signature does not recover to the stored key commitment.
    InvalidSignature = 1,
    /// Vault state account is not the expected PDA / wrong owner / bad layout.
    InvalidVault = 2,
    /// Signature buffer is missing, foreign, or out of bounds.
    InvalidSigBuffer = 3,
    /// Token program is neither SPL Token nor Token-2022.
    InvalidTokenProgram = 4,
    /// Vault authority account is not the vault's authority PDA.
    InvalidAuthority = 5,
    /// Wrong system program account.
    InvalidSystemProgram = 6,
    /// The current key is already reserved for a different message (another device is sending).
    AlreadyReserved = 7,
    /// The signature buffer's reservation doesn't match this action at the current key.
    ReservationMismatch = 8,
}

They surface as custom program error: 0x1 … 0x8. Standard errors (missing signature, invalid instruction data, already initialised) come from the runtime.

#Send flow

sdk/src/client.tsTypeScript
private async execute(action: Action, buildIxs: (next: Uint8Array) => TransactionInstruction[]): Promise<SendResult> {
  const vid = this.vaultIdHex;
  const state = await this.requireState();
  const index = state.index;

  // Index sync: never sign against an index we cannot account for.
  const known = await this.store.getKnownIndex(vid);
  if (known === undefined) {
    throw new IndexMismatchError("no local index for this vault; call syncIndex() after restoring");
  }
  if (index !== known) {
    throw new IndexMismatchError(
      `on-chain index ${index} != local index ${known}. ` +
        (index < known
          ? "RPC is behind or the chain rolled back; wait and retry."
          : "The vault advanced elsewhere. Make sure no other device has a pending send, then call syncIndex()."),
    );
  }
  // The seed must actually control this vault at this index.
  if (!equalBytes(state.keyCommitment, this.keychain.commitment(index))) {
    throw new Error("on-chain key commitment does not match this seed at the current index");
  }

  const next = this.keychain.commitment(index + 1n);
  const digest = messageDigest({
    programId: this.programId,
    vaultId: this.vaultId,
    index,
    action,
    nextCommitment: next,
  });

  let rec = await this.store.getSigned(vid, index);
  if (rec && !equalBytes(rec.digest, digest)) {
    throw new KeyReuseError(
      `key #${index} already signed a different action that has not landed yet. ` +
        "Re-submit that exact action; signing anything else with this key would leak it.",
    );
  }

  // On-chain reservation: the key must be reserved for *this* message before anything is
  // signed. If another device reserved it for a different message, stop without signing.
  await this.ensureReserved(index, digest, !!rec);

  if (!rec) {
    const signature = this.keychain.signDigest(index, digest);
    if (!wotsVerify(this.keychain.pubSeed, index, state.keyCommitment, digest, signature)) {
      throw new Error("self-check failed: produced signature does not verify");
    }
    rec = { index, digest, signature };
    // Persist BEFORE broadcasting anything.
    await this.store.putSigned(vid, rec);
  }

  const res = await this.submit(rec.signature, buildIxs(next));
  await this.store.setKnownIndex(vid, index + 1n);
  return res;
}
  1. 1
    Index sync. Read the on-chain index and refuse if it differs from what this device last saw, or if the on-chain commitment isn’t this seed’s key at that index.
  2. 2
    Key-reuse guard. If this device already signed something at this index, only that exact message may be sent.
  3. 3
    Reserve the key on-chain for this digest and wait for confirmation (see below).
  4. 4
    Sign (deterministically), self-verify, and persist the signature before broadcasting a single byte.
  5. 5
    Submit: upload the signature and execute the action, sized to fit Solana’s 1,232-byte transaction limit.
sdk/src/client.tsTypeScript
/**
 * Make sure the vault's current key is reserved on-chain for `digest`, reserving it if needed.
 * Throws PendingSignatureError (without anything having been signed) when the key is already
 * reserved for a different message, e.g. a send started on another device.
 */
private async ensureReserved(index: bigint, digest: Uint8Array, haveLocalRecord: boolean): Promise<void> {
  const conflict = () =>
    new PendingSignatureError(
      `key #${index} is reserved for a different send (probably started on another device). ` +
        "Nothing was signed. Finish that send on the device that started it, then try again.",
    );
  const read = async () => {
    const acc = await this.transport.getAccount(this.sigBufferAddress);
    if (!acc || acc.data.length === 0) return { kind: "none" as const };
    if (acc.data.length === LEGACY_SIGBUF_LEN) return { kind: "legacy" as const };
    const r = decodeSigBuffer(acc.data);
    if (!r || r.index !== index) return { kind: "stale" as const }; // reserved for a retired key
    return equalBytes(r.digest, digest) ? { kind: "ours" as const } : { kind: "other" as const };
  };

  let cur = await read();
  if (cur.kind === "other") throw conflict();
  if (cur.kind === "ours") return;
  if (cur.kind === "legacy") {
    // A pre-reservation buffer may hold part of a signature for this key. Only the device that
    // made it (it has the local record) may clear it; anyone else must not sign.
    if (!haveLocalRecord) throw conflict();
    await this.closeSigBuffer();
  }
  try {
    await this.transport.send(
      [reserveIx({ programId: this.programId, payer: this.gasWallet.publicKey, vaultId: this.vaultId, digest })],
      this.gasWallet,
    );
  } catch (e) {
    // Lost a race with another device? Then don't sign.
    cur = await read();
    if (cur.kind === "other") throw conflict();
    if (cur.kind !== "ours") throw e;
  }
}

#Compute & costs

ActionCompute unitsTransactions
init_vault~6–12k1
send_sol~560k–710k (worst case ~1.23M)3: reserve, upload, execute
send_token~580k–750k3
Signature buffer rent~0.0092 SOL, refunded in the same send

Verifier work is Σ(255 − digit) hashes over 34 chains. With w = 256 that’s always exactly 255 × (checksum_hi + 2): 4,335 hashes on average and 8,415 at worst, safely under Solana’s 1.4M CU limit.

#Security model

ThreatStatus
Quantum attack on ed25519 (Shor)✅ Vault funds never depend on ed25519
Forging a WOTS signature✅ Requires SHA-256 preimages (≈2¹²⁸ quantum work)
Replaying an old signature✅ Index is in the message; keys rotate
Swapping recipient / amount / next key✅ All bound in the signed digest
Two devices reusing one key✅ On-chain reservations + local signature store
Front-running vault creation✅ vault_id commits to the first key
Gas wallet compromise⚠️ Loses fee money only; no vault authority
Upgrade authority⚠️ Currently a single key; move to multisig/timelock or freeze
Solana consensus (validators use ed25519)⚠️ Network-level; outside any wallet’s control