Docs
Contents

ZOL ledger program reference

zol-ledger is the Anchor program on Solana that records who is owed what for the ZEC held in each app's Zcash vault. Players deposit shielded ZEC into a per-app vault held by 2-of-3 operators. An attested credit then adds the deposit to a Balance on Solana, keyed by the player's session key. Apps are separate Anchor programs that call the ledger by CPI. They move balances into pots, delegate a pot to a MagicBlock rollup, move value between its seats there, and bring the pot back. The pot is then paid out to the players' balances. Players leave through exit requests, which become settlement records that the operators pay on Zcash. The ledger never calls app code. Value enters only through attested credits and leaves only through settlement records.

Source: programs/zol-ledger/src/. IDL: idl/zol_ledger.json. The Rust code is the source of truth; this page follows ledger version 0.3.0.

Contents

  1. Program ID and calling the ledger from an app
  2. Accounts
  3. Instructions
  4. Events
  5. Errors
  6. Invariants the ledger guarantees

1. Program ID and calling the ledger from an app

NetworkProgram ID
Solana devnet3qh6GRqjmVSeVctB6R7oYYsdG68oFyRDwnXGvKamib3Y

The ID is set with declare_id! in lib.rs and is zol_ledger::ID in Rust.

The app authority

Each app acts through one address: the PDA with seed ["zol-app"] under the app's own program ID. The constant is zol_ledger::state::seeds::APP_AUTHORITY. Only the app program can sign for this address, through invoke_signed. The ledger stores it as App.authority at registration, and copies it into every pot as Pot.app_authority.

These instructions require the app authority as a signer: register_app, open_pot, buy_in, delegate_pot, move_seat, close_pot. An app can therefore only call them by CPI from its own program.

Depending on the crate

Add the ledger as a dependency with the cpi feature. This enables no-entrypoint and generates zol_ledger::cpi::* (one function per instruction) and zol_ledger::cpi::accounts::* (one struct per accounts list). From programs/zol-coinflip/Cargo.toml:

[features]
idl-build = ["anchor-lang/idl-build", "zol-ledger/idl-build"]

[dependencies]
zol-ledger = { path = "../zol-ledger", features = ["cpi"] }

Account types, seeds and helpers are public in zol_ledger::state (Config, Params, App, Balance, Pot, PotReceipt, SeatStake, PotAction, ActionAccount, seeds, limits, bps). Apps can deserialize ledger accounts with them, for example to read Pot.amounts in the rollup.

CPI example

From zol-coinflip:

pub const APP_AUTHORITY_SEED: &[u8] = b"zol-app";

zol_ledger::cpi::move_seat(
    CpiContext::new_with_signer(
        zol_ledger::ID,
        zol_ledger::cpi::accounts::MoveSeat {
            authority: a.app_authority.to_account_info(),
            pot: a.pot.to_account_info(),
            app_authority: a.app_authority.to_account_info(),
        },
        &[&[APP_AUTHORITY_SEED, &[bump]]],
    ),
    loser,
    winner,
    amount,
)?;

Instructions that take a variable list of accounts (cash_out, refund_pot, settle, set_guardians, unfreeze_app) read them from the remaining accounts. Over CPI, pass them with CpiContext::with_remaining_accounts.

Where instructions run

Most instructions run on Solana. move_seat and close_pot run in the MagicBlock rollup, where the pot is delegated. The program is built with #[ephemeral], which also adds the SDK's process_undelegation instruction (see 3.6).


2. Accounts

All amounts are in zatoshi (1 ZEC = 100,000,000 zatoshi). "App PDA" below means the App account address, not the app's program ID.

PDA seeds

AccountSeedsOwner programDelegated?
Config["config"]ledgerNo
App["app", app_program_id]ledgerNo
Deposit["deposit", deposit_id]ledgerNo
Balance["balance", app_pda, session_key]ledgerNo
Pot["pot", app_pda, pot_id]ledgerYes: from delegate_pot until close_pot
PotReceipt["receipt", pot_pda]ledgerNo; never leaves Solana
Settlement["settlement", app_pda, sequence.to_le_bytes()] (sequence is a u64)ledgerNo
App authority["zol-app"]the app programn/a (not a ledger account)

Notes:

Config

One per deployment. Created by initialize.

FieldTypeMeaning
adminPubkeyCan call set_config, set_cap, activate_app.
attestorPubkeyFROST Ed25519 group key of the attestors. Signs credits, voids and paid records.
guardians[Pubkey; 7]Guardian set; the first guardian_count entries are used.
guardian_countu8Number of guardians.
guardian_thresholdu8Guardian signatures needed for set_guardians and unfreeze_app.
networku8Zcash network, as zol_wire::Network: 0 Mainnet, 1 Testnet, 2 Regtest. Bound into every attested digest.
pausedboolWhen true, every instruction that requires an active app fails with Paused.
paramsParamsSee below.
bumpu8PDA bump.

Params

Set at initialize and replaceable by set_config. Params::validate enforces the rules in the last column.

FieldTypeMeaningValidation
entry_fee_bpsu16Fee taken from each credited deposit, in basis points.<= MAX_FEE_BPS (100)
exit_fee_bpsu16Fee taken from each payout at settle, in basis points.<= MAX_FEE_BPS (100)
network_fee_allowanceu64Fixed amount deducted from each payout at settle for the Zcash network fee.<= MAX_NETWORK_FEE_ALLOWANCE (100,000)
min_exitu64Smallest balance that can request an exit or be settled.min_exit - floor(min_exit * exit_fee_bps / 10000) - network_fee_allowance > 0
max_seatsu8Most seats a pot can have.2 <= max_seats <= MAX_SEATS (16)
max_open_secsi64Latest open_deadline allowed, relative to open_pot time.> 0
min_session_secsi64Earliest session_deadline allowed, relative to delegate_pot time.> 0 and <= max_session_secs
max_session_secsi64Latest session_deadline allowed, relative to delegate_pot time.see above
refund_grace_secsi64Wait after session_deadline before a delegated pot that has not validly returned can be refunded. Must exceed MagicBlock's longest commit-to-return time.>= MIN_REFUND_GRACE_SECS
activation_delay_secsi64Delay between activate_app and the app becoming Active, so guardians can cancel.>= MIN_ACTIVATION_DELAY_SECS
registration_bondu64Lamports moved from the payer into the App account at registration.none
denominations[u64; 4]Permitted deposit values in zatoshi; 0 marks an unused slot. Apps may read it (coin-flip derives its stakes from it).At least one non-zero; no duplicates; each non-zero value >= min_exit

bps(amount, bps) is floor(amount * bps / 10_000), computed in u128.

limits constants

From zol_ledger::state::limits. The admin can only set parameters within these bounds.

ConstantValueMeaning
MAX_GUARDIANS7Size of the guardian set.
MAX_SEATS16Seats per pot (array size).
MAX_ACTION_ACCOUNTS16Accounts in a close_pot return action.
MAX_ACTION_DATA256Bytes of instruction data in a return action.
MAX_ACTION_COMPUTE_UNITS400,000Compute units of a return action.
MAX_PAYOUTS16Payouts per settlement (zol_wire::MAX_PAYOUTS_PER_SETTLEMENT).
MAX_FEE_BPS1001% cap on each of the entry and exit fee.
MAX_NETWORK_FEE_ALLOWANCE100,0000.001 ZEC cap on the per-payout network fee allowance.
DEV_TIMINGScfg!(feature = "dev-timings")True in a local development build.
MIN_ACTIVATION_DELAY_SECS3,600 (5 with dev-timings)Floor on activation_delay_secs.
MIN_REFUND_GRACE_SECS3,900 (10 with dev-timings)Floor on refund_grace_secs. A forced undelegation completes only after 9,000 slots (about 60 minutes).
MAX_APP_DATA329Bytes of app_data in a credit (zol_wire::memo::APP_DATA_MAX).
MAX_DENOMINATIONS4Slots in Params.denominations.

A dev-timings build refuses to initialize for Zcash mainnet.

App

One per app program. Created by register_app.

FieldTypeMeaning
program_idPubkeyThe app's program ID.
authorityPubkeyThe app's ["zol-app"] PDA; signs on the app's behalf.
registrantPubkeyPayer of register_app.
stateAppStatePending, Activating, Active or Frozen.
activates_ati64Unix time at which Activating becomes Active.
vault_address[u8; 43]Raw Orchard-format receiver of the app's Zcash vault.
genesis_head[u8; 32]Note commitment (cmx) of the vault's genesis head.
deposit_capu64Credits are refused once liability + unpaid_total would exceed it. Starts at 0.
liabilityu64sum(Balance.amount) + sum(live PotReceipt.total).
deposit_countu64Number of credited deposits; the next Deposit.index.
credited_totalu64Sum of Deposit.amount over credited deposits (gross, before the entry fee).
fee_poolu64Entry fees, exit fees and network fee allowances collected. No ledger instruction draws on it.
next_settlementu64Sequence of the next Settlement.
paid_countu64Number of settlements marked paid; also the next sequence mark_paid accepts.
unpaid_totalu64Sum of total over unpaid settlements.
bondu64Registration bond paid, in lamports. No ledger instruction returns it.
bumpu8PDA bump.

AppState:

VariantMeaning
PendingRegistered, no vault yet. Also the state after a guardian cancels an activation.
ActivatingVault recorded; becomes Active at activates_at unless a guardian cancels.
ActiveNormal operation.
FrozenFrozen by a guardian. Only a guardian threshold returns it to Active.

The move from Activating to Active is lazy. App::refresh applies it at the start of credit_deposit, open_pot, buy_in, delegate_pot, settle and freeze_app. A stored Activating with activates_at <= now is effectively Active. Off-chain readers should treat it so.

Deposit

One per deposit ID. Created by either credit_deposit or void_deposit, so each ID is credited or voided at most once.

FieldTypeMeaning
id[u8; 32]The deposit ID (also the seed).
stateDepositStateCredited or Voided.
appPubkeyApp PDA.
indexu64Position among this app's credited deposits; meaningless when voided.
amountu64Full note value (gross). Unset when voided.
session_keyPubkeySession key credited. Unset when voided.
payout_commitment[u8; 32]Commitment to the player's payout address. Unset when voided.
pot_id[u8; 32]Pot ID from the deposit memo. Stored only; the ledger does not act on it. Unset when voided.
digest[u8; 32]The digest the attestor signed (credit or void).
bumpu8PDA bump.

Balance

One per (app, session key). Created by the first credit_deposit for that session key.

FieldTypeMeaning
appPubkeyApp PDA.
session_keyPubkeyPlayer's session key. Signs buy_in, leave_pot, request_exit.
amountu64Spendable balance.
payout_commitment[u8; 32]Set by the first credit. Later credits must carry the same value. Copied into settlement payouts.
exitingboolMarked for exit. Blocks buy_in. Cleared by settle.
auto_exitboolFrom the deposit flags (FLAG_AUTO_EXIT = 0x01), overwritten by each credit. When set, cash_out marks the balance exiting once it reaches min_exit.
bumpu8PDA bump.

Pot

The part of a pot that goes to the rollup. Holds amounts per seat, never player keys. Created by open_pot.

FieldTypeMeaning
appPubkeyApp PDA.
app_authorityPubkeyCopy of App.authority, so rollup instructions need no undelegated account.
pot_id[u8; 32]App-chosen ID (also a seed).
seat_countu8Seats in use.
amounts[u64; 16]Amount per seat, in receipt order.
delegatedboolSet by delegate_pot, cleared by close_pot. Seats may move only while set.
bumpu8PDA bump.

Delegable: yes. delegate_pot delegates it to the named validator; close_pot commits and undelegates it.

PotReceipt

The Solana-side record of a pot: who sits where and what they paid. Never delegated. Created by open_pot.

FieldTypeMeaning
appPubkeyApp PDA.
potPubkeyPot PDA.
pot_id[u8; 32]Pot ID.
statePotStateOpen, Delegated, CashedOut or Refunded.
seat_countu8Seats in use.
players[Pubkey; 16]Session key per seat.
buy_ins[u64; 16]Total bought in per seat.
totalu64Sum of buy_ins.
open_deadlinei64Buy-ins and delegation must happen before it. After it, an Open pot can be refunded.
session_deadlinei64Set at delegation. A pot not validly returned by session_deadline + refund_grace_secs can be refunded.
validatorPubkeyRollup validator named at delegation. The delegation program accepts commits only from it, so it is trusted for this pot's outcome.
bumpu8PDA bump.

PotState transitions:

open_pot ──> Open ──delegate_pot──> Delegated ──cash_out──> CashedOut
              │                        │
              └──refund_pot──> Refunded <──refund_pot──┘

A receipt is "live" in Open and Delegated.

Settlement

One per settle call, numbered per app from 0. Created by settle.

FieldTypeMeaning
appPubkeyApp PDA.
sequenceu64Position in the app's settlements (also a seed).
stateSettlementStateUnpaid or Paid.
countu8Payouts in use (1 to 16).
payouts[PayoutEntry; 16]First count entries are the payouts; the rest are zero.
totalu64Sum of payout amounts.
bumpu8PDA bump.

Plain structs

PayoutEntry:

FieldTypeMeaning
commitment[u8; 32]The balance's payout_commitment.
amountu64Net payout: balance minus exit fee minus network fee allowance.

SeatStake (argument of delegate_pot):

FieldTypeMeaning
playerPubkeyExpected session key at this seat.
amountu64Expected buy-in at this seat.

PotAction (argument of close_pot): an instruction the delegation program runs on Solana right after the pot returns, signed only by the app's escrow (its fee payer). It may call only the pot's own app program. The ledger never signs for it.

FieldTypeMeaning
programPubkeyProgram to call. Must be the pot's app program.
accountsVec<ActionAccount>Accounts of the instruction, in order. At most 16.
dataVec<u8>Instruction data. At most 256 bytes.
compute_unitsu32Compute budget for the action. 1 to 400,000.

ActionAccount:

FieldTypeMeaning
pubkeyPubkeyAccount address.
is_writableboolWritable flag. Action accounts carry no signer flag.

Instruction argument structs (InitializeArgs, SetConfigArgs, CreditArgs) are described with their instructions.


3. Instructions

Account tables list accounts in the order of the IDL. W = writable, S = signer. Anchor's own account checks (wrong PDA, wrong owner, failed deserialization, account already initialized) fail with Anchor or System Program errors, not with the ledger errors listed per instruction.

Which instructions an app's state blocks

App::require_active fails with Paused if Config.paused, AppFrozen if the app is Frozen, and AppNotActive otherwise unless Active. It is called by:

Requires an active app and unpaused ledgerWorks in any app state and while paused
credit_deposit, open_pot, buy_in, delegate_pot, settlevoid_deposit, leave_pot, move_seat, close_pot, cash_out, refund_pot, request_exit, mark_paid

So players can always leave an open pot, pots in the rollup can always come home and be paid out or refunded, and exits can be requested. Settling needs the app active again.

Attestations

credit_deposit, void_deposit and mark_paid require that the instruction immediately before them in the transaction is an Ed25519 precompile (Ed25519SigVerify111111111111111111111111111) instruction that:

Otherwise the instruction fails with BadAttestation. The digests are defined in crates/zol-wire/src/digest.rs. Each is a SHA-256 over a 16-byte domain tag and the network byte, then:

InstructionDigestHashed after tag and network
credit_depositCreditAttestation::digest (tag ZOL/credit/v1)app program ID, deposit_id, amount (u64 LE), session_key, payout_commitment, flags, pot_id, app_data length (u16 LE), app_data
void_depositvoid_digest (tag ZOL/void/v1)app program ID, deposit_id
mark_paidpaid_digest (tag ZOL/paid/v1)app program ID, sequence (u64 LE)

3.1 Admin and guardians

initialize

Runs on Solana. Creates Config. Only the program's upgrade authority can call it, once.

#AccountWSDescription
0payerWSMust be the program's upgrade authority. Pays rent.
1configWConfig PDA, created.
2programThis program; its programdata_address must be program_data.
3program_dataThe program's ProgramData account.
4system_programSystem Program.

Args: args: InitializeArgs

FieldTypeMeaning
adminPubkeyInitial admin.
attestorPubkeyAttestor group key.
guardiansVec<Pubkey>Guardian set.
guardian_thresholdu8Guardian threshold.
networku8Zcash network (0, 1 or 2).
paramsParamsInitial parameters.

Checks: params pass Params::validate. Guardians are 1 to 7, distinct, none the default key, and 1 <= threshold <= len. network is a known value. A dev-timings build rejects mainnet.

Effect: writes all fields; paused = false.

Errors: NotUpgradeAuthority, FeeTooHigh, BadParams, BadGuardians.

set_config

Runs on Solana. Signer: admin. The admin can change parameters within the program's caps, rotate the attestor key, pause, and hand over the admin role. It cannot change guardians and cannot unfreeze an app.

#AccountWSDescription
0adminSMust equal Config.admin.
1configWConfig PDA.

Args: args: SetConfigArgs. Each None field is left unchanged.

FieldTypeMeaning
new_adminOption<Pubkey>New admin.
attestorOption<Pubkey>New attestor key.
paramsOption<Params>New parameters; validated with Params::validate.
pausedOption<bool>Pause or unpause.

Emits ConfigChanged. Errors: FeeTooHigh, BadParams; has_one = admin failure is an Anchor error.

set_guardians

Runs on Solana. Replaces the guardian set. Signers: at least guardian_threshold distinct current guardians.

#AccountWSDescription
0configWConfig PDA.
restguardiansSCurrent guardians, as remaining accounts. Non-guardians, non-signers and duplicates are not counted.

Args: guardians: Vec<Pubkey>, threshold: u8. Same rules as at initialize.

Errors: GuardianThreshold, BadGuardians. No event.

set_cap

Runs on Solana. Signer: admin. Sets App.deposit_cap to any value. Works in any app state.

#AccountWSDescription
0adminSMust equal Config.admin.
1configConfig PDA.
2appWApp PDA.

Args: cap: u64. No checks beyond the admin. No event.

A new app's cap is 0, so it accepts no credits until the admin sets a cap.

freeze_app

Runs on Solana. Signer: any one guardian.

#AccountWSDescription
0guardianSMust be in Config.guardians.
1configConfig PDA.
2appWApp PDA.

Args: none.

Effect, after App::refresh:

Errors: NotGuardian, BadAppState.

unfreeze_app

Runs on Solana. Returns a frozen app to Active. Signers: at least guardian_threshold distinct guardians. The admin cannot do this.

#AccountWSDescription
0configConfig PDA.
1appWApp PDA.
restguardiansSGuardians, as remaining accounts.

Args: none. Emits AppUnfrozen. Errors: GuardianThreshold, BadAppState (app not Frozen).

3.2 App registration and activation

register_app

Runs on Solana. Creates the App account in state Pending. The app must call it by CPI, signing for its ["zol-app"] PDA; this is the app's consent. Anyone may pay.

#AccountWSDescription
0payerWSPays rent and the registration bond. Recorded as registrant.
1configConfig PDA.
2app_programThe app program. Must be executable.
3app_authoritySMust be find_program_address(["zol-app"], app_program).
4appWApp PDA ["app", app_program], created.
5system_programSystem Program.

Args: none.

Effect: if registration_bond > 0, transfers that many lamports from payer to the App account. Sets program_id, authority, registrant, state = Pending, bond. All counters and deposit_cap start at 0. Emits AppRegistered.

Errors: NotAProgram, BadAppAuthority.

activate_app

Runs on Solana. Signer: admin. Records the vault and starts the activation delay. The vault address and genesis head never change once the app is active. Only a guardian cancel during the delay clears them.

#AccountWSDescription
0adminSMust equal Config.admin.
1configConfig PDA.
2appWApp PDA.

Args: vault_address: [u8; 43], genesis_head: [u8; 32].

Checks: app is Pending; neither argument is all zeros.

Effect: state Activating; activates_at = now + activation_delay_secs. Emits AppActivating.

Errors: BadAppState, BadParams, Overflow.

3.3 Deposits

credit_deposit

Runs on Solana. Credits an attested deposit to the player's balance. Any account may relay it; the attestation is the authorization.

#AccountWSDescription
0relayerWSPays rent for deposit and a new balance.
1configConfig PDA.
2appWApp PDA.
3depositWDeposit PDA ["deposit", args.deposit_id], created.
4balanceWBalance PDA ["balance", app, args.session_key], created if needed.
5instructionsInstructions sysvar.
6system_programSystem Program.

Args: args: CreditArgs

FieldTypeMeaning
deposit_id[u8; 32]Deposit ID.
amountu64Full note value in zatoshi.
session_keyPubkeyPlayer's session key.
payout_commitment[u8; 32]Commitment to the player's payout address.
flagsu8Deposit flags. Only FLAG_AUTO_EXIT (0x01) is known.
pot_id[u8; 32]Pot ID from the memo. Stored in Deposit; not acted on.
app_dataVec<u8>App data from the memo, at most 329 bytes. Part of the digest; not stored.

Checks, in order: app active and ledger unpaused; amount > 0; amount is a permitted denomination; no unknown flag bits; app_data within the limit; the preceding instruction attests the credit digest; liability + unpaid_total + net <= deposit_cap; for an existing balance, the same payout_commitment.

Effect:

A second credit or void of the same deposit_id fails because the Deposit account already exists.

Errors: Paused, AppFrozen, AppNotActive, ZeroAmount, BadDenomination, BadFlags, AppDataTooLong, BadAttestation, OverCap, PayoutCommitmentMismatch, Overflow.

void_deposit

Runs on Solana. Records an attested void, so the deposit can never be credited, and the operators can refund it on Zcash. Works in any app state, including frozen, paused or cancelled. Credits nothing.

#AccountWSDescription
0relayerWSPays rent for deposit.
1configConfig PDA.
2appApp PDA.
3depositWDeposit PDA ["deposit", deposit_id], created.
4instructionsInstructions sysvar.
5system_programSystem Program.

Args: deposit_id: [u8; 32].

Effect: creates the Deposit with state Voided, app, id and digest. Emits DepositVoided.

Errors: BadAttestation.

3.4 Pots

A pot's life:

  1. open_pot (app) creates Pot and PotReceipt in state Open.
  2. buy_in (app and player) moves balance into a seat. leave_pot (player alone) moves it back.
  3. delegate_pot (app) checks the seats, fixes session_deadline and the validator, and delegates Pot to the rollup.
  4. move_seat (app, in the rollup) moves value between seats.
  5. close_pot (app, in the rollup) commits the pot and returns it to Solana, optionally with a return action.
  6. cash_out (anyone) pays the returned seat amounts to the players' balances.

refund_pot (anyone) returns every buy-in if the pot never started in time, or never validly returned.

open_pot

Runs on Solana. Signer: app authority.

#AccountWSDescription
0payerWSPays rent for pot and receipt.
1configConfig PDA.
2appWApp PDA.
3authoritySMust equal App.authority.
4potWPot PDA ["pot", app, pot_id], created.
5receiptWPotReceipt PDA ["receipt", pot], created.
6system_programSystem Program.

Args: pot_id: [u8; 32], open_deadline: i64.

Checks: app active and ledger unpaused; now < open_deadline <= now + max_open_secs. pot_id must be unused for this app.

Effect: Pot with app, app_authority, pot_id, no seats. PotReceipt in state Open with open_deadline. Emits PotOpened.

Errors: BadAppAuthority, Paused, AppFrozen, AppNotActive, BadDeadline, Overflow.

buy_in

Runs on Solana. Signers: app authority and the player's session key. Nobody enters a pot except through the app.

#AccountWSDescription
0configConfig PDA.
1appWApp PDA.
2authoritySMust equal App.authority.
3session_keySThe player.
4balanceWBalance PDA ["balance", app, session_key].
5potWPot PDA ["pot", app, pot.pot_id].
6receiptWPotReceipt PDA ["receipt", pot].

Args: amount: u64.

Checks: app active and ledger unpaused; receipt Open; now < open_deadline; amount > 0; balance not exiting; balance.amount >= amount. A new player needs a free seat: seat_count < max_seats.

Effect: balance.amount -= amount. A player already seated adds to their seat; a new player takes the next seat. receipt.buy_ins[seat] and receipt.total grow by amount. pot.amounts[seat] is set to the seat's buy-in and pot.seat_count to the receipt's. Emits BoughtIn.

Errors: BadAppAuthority, Paused, AppFrozen, AppNotActive, BadPotState, OpenDeadlinePassed, ZeroAmount, BalanceExiting, InsufficientBalance, PotFull, Overflow.

leave_pot

Runs on Solana. Signer: the player's session key alone. While the pot is Open, a player can always take their whole buy-in back, so an app cannot trap anyone in a pot that has not started. Works in any app state and while paused.

#AccountWSDescription
0session_keySThe player.
1balanceWBalance PDA ["balance", receipt.app, session_key].
2potWPot PDA ["pot", receipt.app, receipt.pot_id].
3receiptWPotReceipt PDA ["receipt", pot].

Args: none.

Checks: receipt Open; the player has a seat.

Effect: removes the seat and shifts later seats down by one in both receipt and pot, so they stay aligned and ordered. receipt.total and seat_count decrease. The buy-in returns to balance.amount. Emits LeftPot.

Because seats shift, an app must not assume a player's seat index is fixed before delegation. delegate_pot takes the expected seating for this reason.

Errors: BadPotState, NoSeat, Overflow.

delegate_pot

Runs on Solana. Signer: app authority. Starts the pot: fixes the seating, the session deadline and the rollup validator, then delegates Pot to the MagicBlock delegation program.

#AccountWSDescription
0payerWSPays for the delegation accounts.
1configConfig PDA.
2appWApp PDA.
3authoritySMust equal App.authority.
4receiptWPotReceipt PDA ["receipt", pot]; receipt.app must be app.
5buffer_potWDelegation buffer, PDA ["buffer", pot] under the ledger program.
6delegation_record_potWPDA ["delegation", pot] under the delegation program.
7delegation_metadata_potWPDA ["delegation-metadata", pot] under the delegation program.
8potWThe pot; must equal receipt.pot and be owned by the ledger.
9owner_programThe ledger program, 3qh6GRqjmVSeVctB6R7oYYsdG68oFyRDwnXGvKamib3Y.
10delegation_programDELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh.
11system_programSystem Program.

Accounts 5–7 and 9–11 are added by the ephemeral-rollups SDK's #[delegate] macro.

Args:

ArgTypeMeaning
expectedVec<SeatStake>The seating the app expects, in seat order. Players can leave until the start, so the app must state who it expects.
session_deadlinei64When the session should end.
validatorPubkeyRollup validator to delegate to. Must not be the default key: without an explicit validator the delegation program picks its default one, and the pot never appears in the app's rollup.

Checks: validator non-default; app active and ledger unpaused; receipt Open; now < open_deadline; seat_count > 0; expected matches players and buy_ins seat by seat and has the same length; now + min_session_secs <= session_deadline <= now + max_session_secs; pot owned by the ledger with the receipt's seat_count.

Effect: receipt state Delegated, session_deadline and validator recorded; pot.delegated = true; pot delegated to validator. Emits PotDelegated.

Errors: BadParams, BadAppAuthority, BadAccount, Paused, AppFrozen, AppNotActive, BadPotState, OpenDeadlinePassed, ReceiptMismatch, BadDeadline, PotInvalid, Overflow.

move_seat

Runs in the rollup. Signer: app authority. Moves value between two seats of one delegated pot. It cannot create value, add a player or touch another pot. It reads no Config or App, so app freezes and the ledger pause do not stop it.

#AccountWSDescription
0authoritySThe app's ["zol-app"] PDA.
1potWDelegated Pot PDA ["pot", pot.app, pot.pot_id].
2app_authorityMust equal pot.app_authority and authority. Pass the app authority again.

Args: from: u8, to: u8, amount: u64.

Checks: pot.delegated; from < seat_count, to < seat_count, from != to; amounts[from] >= amount. A zero amount is not rejected.

Effect: amounts[from] -= amount, amounts[to] += amount. No event.

Errors: BadAppAuthority, BadPotState, BadSeat, InsufficientBalance, Overflow.

close_pot

Runs in the rollup. Signer: app authority (and the payer). Commits the pot and returns it to Solana. With action, the delegation program then runs that instruction on Solana right after the pot is back, paid from the app's escrow. An app can use this to pay its players out without a keeper. close_pot checks no deadline; the app decides when to close.

#AccountWSDescription
0payerWSPays the commit. May be a delegated account signing by seeds (for example an app PDA in a scheduled task); then pass magic_fee_vault.
1authoritySMust equal pot.app_authority.
2potWThe delegated Pot. Must be owned by the ledger (in the rollup) and be the PDA ["pot", pot.app, pot.pot_id].
3magic_fee_vaultWOptional. MagicBlock's fee vault for the validator; the magic program checks it. Needed when the payer is itself a delegated account. zol-coinflip derives it as ["magic-fee-vault", validator] under ephemeral_rollups_sdk::id().
4escrow_authoritySOptional. The app's escrow authority for action (the app signs for its PDA). Required when action is set.
5magic_programMagic11111111111111111111111111111111111111.
6magic_contextWMagicContext1111111111111111111111111111111.

Accounts 5–6 are added by the SDK's #[commit] macro. In Anchor, omit an optional account by passing the program ID in its place; zol_ledger::cpi::accounts::ClosePot takes Option<AccountInfo> for both.

Args: action: Option<PotAction>.

Checks: pot owned by the ledger and at the right address; authority == pot.app_authority; pot.delegated. With an action:

The ledger does not check the address of escrow_authority. Action accounts carry no signer flag; the action runs signed only by the escrow.

Effect: pot.delegated = false is written, then a MagicBlock intent bundle commits and undelegates the pot, with magic_fee_vault if given. With an action, the bundle adds it as a post-undelegate action (CallHandler) with destination_program = action.program, the action's accounts, data and compute units, and escrow_authority. The return to Solana is asynchronous. Until it lands, cash_out fails with PotNotReturned. An app's action handler should expect the pot may not be back yet; zol-coinflip's cash_out_table then does nothing. zol-coinflip also notes that the action receives extra trailing accounts (the escrow), which its handler ignores. No event.

Errors: BadAccount, BadAppAuthority, BadPotState, BadAction.

cash_out

Runs on Solana. Anyone may call it, including an app's return action. Pays the returned seat amounts to the players' balances. Works in any app state and while paused, so pots in the rollup can still come home.

#AccountWSDescription
0configConfig PDA.
1appApp PDA.
2receiptWPotReceipt PDA ["receipt", pot]; receipt.app must be app.
3potPot PDA ["pot", app, receipt.pot_id]. Owner and contents checked by hand.
restbalancesWOne Balance per seat, in receipt seat order, as remaining accounts.

Args: none.

Checks: receipt Delegated; pot owned by the ledger again (otherwise PotNotReturned). The pot must have validly returned: seat_count, app and pot_id match the receipt, the seat amounts sum to receipt.total, and unused seats are zero (otherwise PotInvalid). Exactly seat_count balances, no duplicates, each with app == app and session_key == players[i].

Effect: balances[i].amount += pot.amounts[i]. If a balance has auto_exit and now holds at least min_exit, it is marked exiting. Receipt state CashedOut. Emits PotCashedOut. App.liability does not change (value moves from a live receipt to balances).

Errors: BadAccount, BadPotState, PotNotReturned, PotInvalid, DuplicateAccount, Overflow.

refund_pot

Runs on Solana. Anyone. Returns every buy-in from the receipt. Works in any app state and while paused. Same accounts as cash_out.

#AccountWSDescription
0configConfig PDA.
1appApp PDA.
2receiptWPotReceipt PDA ["receipt", pot]; receipt.app must be app.
3potPot PDA ["pot", app, receipt.pot_id].
restbalancesWOne Balance per seat, in receipt seat order.

Args: none.

Allowed when:

Effect: balances[i].amount += receipt.buy_ins[i]; auto_exit is not applied. Receipt state Refunded. Emits PotRefunded.

Errors: BadAccount, RefundNotAllowed, DuplicateAccount, Overflow.

3.5 Exits and settlements

request_exit

Runs on Solana. Signer: the player's session key. Marks the balance for payout. The whole balance at settle time is paid, and buy-ins are refused while it is marked. Works in any app state and while paused. There is no instruction to unmark.

#AccountWSDescription
0session_keySThe player.
1configConfig PDA.
2balanceWBalance PDA ["balance", balance.app, session_key].

Args: none. Checks: balance.amount >= min_exit. Effect: exiting = true. Emits ExitRequested.

Errors: BelowMinExit.

settle

Runs on Solana. Anyone. Turns 1 to 16 exiting balances into the app's next settlement record, which the operators pay on Zcash.

#AccountWSDescription
0payerWSPays rent for settlement.
1configConfig PDA.
2appWApp PDA.
3settlementWSettlement PDA ["settlement", app, app.next_settlement], created.
4system_programSystem Program.
restbalancesW1 to 16 Balance accounts of this app, as remaining accounts.

Args: none.

Checks: app active and ledger unpaused; 1 to 16 balances; no duplicates; each belongs to app, is exiting, and holds at least min_exit.

Effect, per balance: fee = bps(amount, exit_fee_bps) + network_fee_allowance; payouts[i] = { commitment: payout_commitment, amount: amount - fee }; App.liability -= amount; App.fee_pool += fee; the balance's amount = 0 and exiting = false. Then Settlement is written as Unpaid with sequence = next_settlement, count and total; next_settlement += 1; unpaid_total += total. Emits Settled.

Errors: Paused, AppFrozen, AppNotActive, BadPayoutCount, DuplicateAccount, BadAccount, NotExiting, BelowMinExit, Overflow.

mark_paid

Runs on Solana. Any relayer, with an attestation. A public record for auditors, posted after the payout reaches depth on Zcash. The code notes that signers never rely on it. Works in any app state and while paused.

#AccountWSDescription
0relayerSAny signer.
1configConfig PDA.
2appWApp PDA.
3settlementWSettlement PDA ["settlement", app, settlement.sequence].
4instructionsInstructions sysvar.

Args: none.

Checks: settlement Unpaid; settlement.sequence == app.paid_count (settlements are marked paid in order); the preceding instruction attests the paid digest.

Effect: settlement Paid; paid_count += 1; unpaid_total -= total. Emits SettlementPaid.

Errors: AlreadyPaid, OutOfOrder, BadAttestation, Overflow.

3.6 SDK-generated: process_undelegation

Added by the ephemeral-rollups SDK's #[ephemeral] attribute. The delegation program calls it to hand an undelegated account (the pot) back to the ledger. Apps do not call it. Accounts in IDL order: base_account (W), buffer (PDA ["undelegate-buffer", base_account] under the delegation program), payer (W), system_program. Args: account_seeds: Vec<Vec<u8>>. Its behavior is defined by the SDK, not by ledger code.


4. Events

Emitted with Anchor emit! (program log). In most events app is the app's program ID. In ExitRequested it is the App PDA, because that event takes Balance.app.

EventFieldsEmitted by
ConfigChangedadmin: Pubkey, attestor: Pubkey, paused: bool (values after the change)set_config
AppRegisteredapp: Pubkey (program ID), authority: Pubkeyregister_app
AppActivatingapp: Pubkey, vault_address: [u8; 43], genesis_head: [u8; 32], activates_at: i64activate_app
ActivationCancelledapp: Pubkey, guardian: Pubkeyfreeze_app on an Activating app
AppFrozenapp: Pubkey, guardian: Pubkeyfreeze_app on an Active app
AppUnfrozenapp: Pubkeyunfreeze_app
DepositCreditedapp: Pubkey, deposit_id: [u8; 32], index: u64, amount: u64 (gross), fee: u64, session_key: Pubkeycredit_deposit
DepositVoidedapp: Pubkey, deposit_id: [u8; 32]void_deposit
PotOpenedapp: Pubkey, pot: Pubkey, open_deadline: i64open_pot
BoughtInpot: Pubkey, player: Pubkey, seat: u8, amount: u64 (this buy-in only)buy_in
LeftPotpot: Pubkey, player: Pubkey, amount: u64leave_pot
PotDelegatedpot: Pubkey, session_deadline: i64, total: u64delegate_pot
PotCashedOutpot: Pubkey, total: u64cash_out
PotRefundedpot: Pubkey, total: u64refund_pot
ExitRequestedapp: Pubkey (App PDA), session_key: Pubkey, amount: u64 (balance at request time)request_exit
Settledapp: Pubkey, sequence: u64, count: u8, total: u64settle
SettlementPaidapp: Pubkey, sequence: u64mark_paid

initialize, set_guardians, set_cap, move_seat and close_pot emit no event.


5. Errors

From error.rs, enum ZolError. Code is 6000 + index.

CodeNameMessage
6000FeeTooHighFee rate or allowance above the program's constant cap
6001BadParamsParameters out of range
6002BadGuardiansGuardian set or threshold invalid
6003GuardianThresholdNot enough guardian signatures
6004NotGuardianSigner is not a guardian
6005NotUpgradeAuthorityOnly the program upgrade authority can initialize
6006PausedLedger is paused
6007AppFrozenApp is frozen
6008AppNotActiveApp is not active
6009BadAppStateApp is in the wrong state for this instruction
6010NotAProgramAccount is not an executable program
6011BadAppAuthorityApp authority is not the app program's zol-app address
6012BadAttestationPrevious instruction is not a matching Ed25519 attestation
6013OverCapDeposit would exceed the app's cap
6014PayoutCommitmentMismatchPayout commitment differs from this balance's
6015ZeroAmountAmount must be positive
6016OverflowArithmetic overflow
6017InsufficientBalanceInsufficient balance
6018BalanceExitingBalance is marked for exit
6019BadPotStatePot is in the wrong state
6020BadDeadlineDeadline out of range
6021OpenDeadlinePassedOpen deadline has passed
6022PotFullPot is full
6023NoSeatPlayer has no seat in this pot
6024ReceiptMismatchExpected players and buy-ins differ from the receipt
6025PotNotReturnedPot has not returned from the rollup
6026PotInvalidPot does not match its receipt
6027BadSeatSeat index out of range
6028RefundNotAllowedRefund conditions not met
6029BadAccountWrong account passed
6030DuplicateAccountDuplicate account
6031BelowMinExitBalance below the minimum exit
6032NotExitingBalance is not marked for exit
6033BadPayoutCountNothing to settle, or too many payouts
6034OutOfOrderSettlement is not the next unpaid one
6035AlreadyPaidSettlement already paid
6036AppDataTooLongApp data too long
6037BadFlagsUnknown deposit flags
6038BadDenominationDeposit amount is not a permitted denomination
6039BadActionA pot's return action must call its own app, within the limits, with a signing escrow authority

6. Invariants the ledger guarantees

These follow from the code and its doc comments. They hold for any app program, since apps are untrusted callers.

Value

Who can move what

Liveness and deadlines

Governance limits