Skip to main content

Program Reference

Two Anchor programs. toda-commit is the protocol. toda-staking is dormant.


toda-commit

Program ID: 5SwaBH3pXzEKhmL6pbqZ1JgeDiGaf6i5oVj8951RX9sm
Cluster: devnet
PDA seeds: ["user", owner_pubkey]

One account per user, holding a rolling Merkle root over that user's entire memory log. The program stores and gates the root. It never recomputes it - the root's byte format is defined by @toda/crypto.

Account: UserCommit

FieldTypeBytesMeaning
(discriminator)-8Anchor
ownerPubkey32The wallet this log belongs to
authorityPubkey32May call commit_root on the owner's behalf
merkle_root[u8; 32]32Rolling root over leaves 0..memory_count-1
memory_countu648Leaves the root covers. Monotonic
nonceu648Incremented per successful commit
last_updatedi648Unix timestamp of the last commit
bumpu81PDA bump
129Fixed. Does not grow with the log

Integers are little-endian (Borsh).

init_user(authority: Pubkey)

Creates the commitment PDA. Idempotent per owner - the PDA can exist only once.

AccountConstraint
user_commitinit, seeds = ["user", owner], space = 129
ownerUncheckedAccount - only its key is stored, no signature required
payerSigner, mut - funds rent
system_program

Initializes merkle_root to 32 zero bytes, memory_count and nonce to 0. The authority argument names who may commit; pass the owner's own key for a self-anchoring user.

The owner does not sign init_user

Anyone can create anyone's commitment account. This is safe: the PDA is derived from the owner's key, the account records that key as owner, and only owner or the named authority can ever write a root into it. Creating one for a stranger costs you rent and grants you nothing.

The authority argument, though, is set by whoever calls init_user first. A deployment where users bring their own accounts must ensure they set themselves as authority.

commit_root(new_root: [u8; 32], new_count: u64, expected_nonce: u64)

Pushes a new rolling root.

AccountConstraint
user_commitmut, seeds = ["user", user_commit.owner], bump = user_commit.bump
signerSigner

Checks, in order:

require!(signer == uc.owner || signer == uc.authority, Unauthorized);
require!(expected_nonce == uc.nonce, NonceMismatch);
require!(new_count >= uc.memory_count, CountRegressed);

Then writes merkle_root, memory_count, last_updated, increments nonce (checked), and emits RootCommitted.

Three properties fall out:

  • Authorization - no third party can write a root.
  • Ordering - expected_nonce is optimistic concurrency control. Concurrent commits fail loudly instead of interleaving silently.
  • Monotonicity - the log can never shrink. This is what makes deletion detectable. See the monotonicity rule.

Event: RootCommitted

pub struct RootCommitted {
pub owner: Pubkey,
pub merkle_root: [u8; 32],
pub memory_count: u64,
pub nonce: u64, // the nonce AFTER incrementing
}

Errors

ErrorCause
UnauthorizedSigner is neither owner nor authority
NonceMismatchexpected_nonce != nonce. A concurrent commit landed first
CountRegressednew_count < memory_count
NonceOverflownonce would exceed u64::MAX

toda-staking

Program ID: DPV1ytT8jGMwUx4FKZJRXs6MTCsxmTA8nQfkEtAM2kae
Cluster: not deployed
PDA seeds: ["stake", owner_pubkey]

Pure self-custody vaults. No admin account, no config account, no privileged signer. The stake authority PDA owns nothing but its associated token account - the vault. See Staking.

Mint-agnostic by design: every transfer uses transfer_checked against the mint's own decimals, so classic SPL and Token-2022 mints both work. The vault ATA is created client-side with an idempotent instruction, so the program never allocates.

stake(amount: u64)

Moves amount base units from the depositor's token account into the owner's vault.

AccountConstraint
ownerUncheckedAccount - not a signer. Only its key seeds the PDA
stake_authorityUncheckedAccount, seeds = ["stake", owner]
stake_vaultmut, ATA of stake_authority for mint
depositorSigner - funds and authorizes the transfer
depositor_tokenmut, token account of mint
mintInterfaceAccount<Mint>
token_programInterface<TokenInterface>

Requires amount > 0 (AmountZero). Emits Staked { owner, mint, amount }.

Anyone may top up any vault. The depositor signs, the owner does not. Depositing into someone else's vault is a gift, and the program permits it.

unstake(amount: u64)

Moves amount base units from the owner's vault to any token account of the same mint.

AccountConstraint
ownerSigner
stake_authorityUncheckedAccount, seeds = ["stake", owner], bump
stake_vaultmut, ATA of stake_authority for mint
destinationmut, token account of mint
mintInterfaceAccount<Mint>
token_programInterface<TokenInterface>

Requires amount > 0 (AmountZero) and stake_vault.amount >= amount (InsufficientStake). Signs the CPI with ["stake", owner, bump]. Emits Unstaked { owner, mint, amount }.

The self-custody guarantee, stated exactly

The vault authority PDA is derived from owner.key(), and in Unstake, owner is a Signer. There is no code path that derives a vault authority from any key other than a signing owner. No server key, no admin key, and no other user's signature can reach your vault - not because the program refuses, but because the address it would need does not derive.

Errors

ErrorCause
AmountZeroamount == 0
InsufficientStakeamount > stake_vault.amount

Typed clients

@toda/solana wraps both programs.

// Commitment
deriveUserCommitPda(owner, programId): [PublicKey, number]
initUser(connection, authority, owner, commitAuthority?): Promise<string>
commitRoot({ connection, authority, owner, newRoot, newCount, expectedNonce }): Promise<string>
readUserCommit(connection, owner): Promise<OnchainCommit | null>

// Staking - build only. Never signs.
deriveStakeAuthorityPda(owner, programId): [PublicKey, number]
getStakeMintInfo(connection, mint): Promise<StakeMintInfo> // reads decimals + token program
stakeVaultAddress(owner, mintInfo, programId): PublicKey
buildStakeTx({ connection, owner, mintInfo, amount, programId }): Promise<{ tx, blockhash, lastValidBlockHeight }>
buildUnstakeTx({}): Promise<{ tx, blockhash, lastValidBlockHeight }>
readStakedBalance(connection, owner, mintInfo, programId): Promise<bigint>
readStakeChunks(connection, vault): Promise<StakeChunk[] | null> // null means UNKNOWN, not empty

commitRoot rejects a newRoot that is not exactly 32 bytes before it reaches the chain.

readTokenBalance returns 0n only when the token account genuinely does not exist. Every other failure throws. Callers must never coerce a failed read into a zero. See reconciliation.

IDLs

anchor build emits target/idl/*.json and target/types/*.ts. @toda/solana consumes committed copies in packages/solana/src/idl/.

Refresh the committed IDLs after any program change

The typed client reads the committed copy, not target/. An IDL that has drifted from the deployed program produces instructions the chain rejects - or, worse, accepts with the wrong account order.