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
| Field | Type | Bytes | Meaning |
|---|---|---|---|
| (discriminator) | - | 8 | Anchor |
owner | Pubkey | 32 | The wallet this log belongs to |
authority | Pubkey | 32 | May call commit_root on the owner's behalf |
merkle_root | [u8; 32] | 32 | Rolling root over leaves 0..memory_count-1 |
memory_count | u64 | 8 | Leaves the root covers. Monotonic |
nonce | u64 | 8 | Incremented per successful commit |
last_updated | i64 | 8 | Unix timestamp of the last commit |
bump | u8 | 1 | PDA bump |
| 129 | Fixed. 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.
| Account | Constraint |
|---|---|
user_commit | init, seeds = ["user", owner], space = 129 |
owner | UncheckedAccount - only its key is stored, no signature required |
payer | Signer, 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.
init_userAnyone 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.
| Account | Constraint |
|---|---|
user_commit | mut, seeds = ["user", user_commit.owner], bump = user_commit.bump |
signer | Signer |
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_nonceis 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
| Error | Cause |
|---|---|
Unauthorized | Signer is neither owner nor authority |
NonceMismatch | expected_nonce != nonce. A concurrent commit landed first |
CountRegressed | new_count < memory_count |
NonceOverflow | nonce 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.
| Account | Constraint |
|---|---|
owner | UncheckedAccount - not a signer. Only its key seeds the PDA |
stake_authority | UncheckedAccount, seeds = ["stake", owner] |
stake_vault | mut, ATA of stake_authority for mint |
depositor | Signer - funds and authorizes the transfer |
depositor_token | mut, token account of mint |
mint | InterfaceAccount<Mint> |
token_program | Interface<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.
| Account | Constraint |
|---|---|
owner | Signer |
stake_authority | UncheckedAccount, seeds = ["stake", owner], bump |
stake_vault | mut, ATA of stake_authority for mint |
destination | mut, token account of mint |
mint | InterfaceAccount<Mint> |
token_program | Interface<TokenInterface> |
Requires amount > 0 (AmountZero) and stake_vault.amount >= amount (InsufficientStake). Signs the CPI with ["stake", owner, bump]. Emits Unstaked { owner, mint, amount }.
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
| Error | Cause |
|---|---|
AmountZero | amount == 0 |
InsufficientStake | amount > 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/.
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.