lez-programs/docs/testnet-runbook.md

21 KiB
Raw Blame History

AMM on testnet — spel runbook

End-to-end steps to deploy the programs, initialize the AMM, and create a pool using spel, in the order they must happen. Follow top to bottom; nothing here can be skipped.

Golden rule: every time you recompile a guest, its ProgramId changes, and every PDA derived from that ProgramId changes too (config, pool, vaults, LP def, lp lock, current tick). If you rebuild the AMM, you must recompute all AMM PDAs and re-run initialize. Treat ProgramIds and PDAs as deployment-specific — re-derive them, never reuse old values.


Contents


0. Prerequisites

  • Docker running (guest builds cross-compile through it).
  • spel / wallet built from the refactor/lez-v020-compat branch (github.com/0x-r4bbit/spel). This is required for: the program-id command, the -- subcommand separator, the public-tx signing scheme, and the account_id argument fix.
  • Wallet home exported in every shell you use (deploy and spel must point at the same wallet/network):
    export LEE_WALLET_HOME_DIR="$HOME/.lee/wallet"
    
  • Wallet configured to reach your sequencer (wallet_config.json), with the accounts you need created (wallet account ...).
    wallet config set sequencer_addr https://testnet.lez.logos.co/
    

Argument formats (used throughout)

Kind Accepted forms
account id (PDAs, holdings, authority) base58 (e.g. 9qbX…) or 0x-prefixed 32-byte hex. No account_id( … ) wrapper.
program id 8 comma-separated u32 limbs, a bare 64-char ImageID hex, a 0x-prefixed ImageID hex, or a base58 ImageID. (spel program-id prints the limbs and the bare hex; base58 is computed by the *_pdas helpers / yourself.)

1. Build & deploy the programs

Note: Only do this if you want to deploy the protocols yourself!

Build and deploy token, twap_oracle, and amm (order doesn't matter for deploy, but you need all three before initializing the AMM):

# repeat for: token, twap_oracle, amm
cargo risczero build --manifest-path programs/<prog>/methods/guest/Cargo.toml
wallet deploy-program programs/<prog>/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/<prog>.bin

Binary path convention: programs/<prog>/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/<prog>.bin

Record the ProgramIds

spel -- program-id programs/<prog>/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/<prog>.bin

Record each ProgramId in whatever form you'll pass it — the forms are interchangeable (see the arg-format table above). spel program-id prints both the decimal limbs and the 64-char ImageID hex; both are accepted by spel and the *_pdas helpers, and the ImageID hex is usually the easiest to copy. base58 is also accepted as input, but nothing in the toolchain prints it — base58-encode the 32 ImageID bytes yourself if you want that form. Fill in:

program ProgramId (limbs, ImageID hex, or base58)
token <TOKEN_PROGRAM_ID>
twap_oracle <TWAP_PROGRAM_ID>
amm <AMM_PROGRAM_ID>

2. Wallet CLI basics

The wallet CLI manages the accounts you'll pass to spel (definition targets, holdings, authority, LP holding). Every command needs LEE_WALLET_HOME_DIR set (see Prerequisites).

Create a public account, giving it a --label you can recognize later:

wallet account new public --label token-a-def

List the accounts the wallet owns (ls is an alias; add -l/--long for full details):

wallet account list

The listing shows each account's label and base58 id. Use those ids as the <DEF_*>, <HOLDING_*>, <AUTHORITY>, and <USER_HOLDING_LP> arguments in the steps below.

3. Create two token definitions

You need two different fungible tokens. For each, create two wallet accounts:

wallet account new public --label "Token A Definition"
wallet account new public --label "Token A Holding"
wallet account new public --label "Token B Definition"
wallet account new public --label "Token B Holding"

Confirm with (your addresses will be different):

wallet account list

/1 Public/4T69U868K6UzX8zbesU5wyr36gxaU7wb91Q45yedP4Rb [Token A Holding]
/0 Public/CER21z16YgmWr3aN8FEHsrmfm2iRfQiwZTac3FQa21US [Token A Definition]
/0/0 Public/EW2eoxcQyRDffrr94LkmuyByhaXx8emNzfDSAp9q29m5 [Token B Definition]
/2 Public/EcWWrBekMaER4JRAzzP4rpB8TFD2eHvyYJxScQPwzmpE [Token B Holding]

Next, create the token definitions and holdings:

spel --idl artifacts/token-idl.json \
     --program programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin \
     -- new-fungible-definition \
     --name "TOKEN A" --total-supply 1000000000000000000000 \
     --definition-target-account <DEF_A> \
     --holding-target-account <HOLDING_A>
  • <DEF_A> becomes the token-definition account; <HOLDING_A> receives total_supply.
  • Repeat for token B (<DEF_B>, <HOLDING_B>).
  • These <DEF_*> ids are what feed all the AMM pool PDAs.

Inspect to confirm / find a holding's definition id:

spel --idl artifacts/token-idl.json inspect <HOLDING_A> --type TokenHolding
spel --idl artifacts/token-idl.json inspect <DEF_A>     --type TokenDefinition

(Optional) Transfer tokens

Move fungible tokens between holdings with the token program's transfer. Standalone — usable any time, independent of the AMM. spel signs the sender (you must own its key) and credits the recipient; both must be holdings of the same token definition.

The recipient must be an already-initialized holding of that token. (transfer can credit an empty account by claiming a fresh holding, but that claim needs the recipient's signature, which transfer doesn't collect — so initialize the holding first.) Create one with initialize-account (the new holding is a signer — your wallet must hold its key):

spel --idl artifacts/token-idl.json \
     --program programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin \
     -- initialize-account \
     --definition-account <DEF> \
     --account-to-initialize <NEW_HOLDING>

Then transfer (amount-to-transfer is in base units — e.g. 1000000000000000000 = one whole token at 18 decimals):

spel --idl artifacts/token-idl.json \
     --program programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin \
     -- transfer \
     --sender <SENDER_HOLDING> \
     --recipient <RECIPIENT_HOLDING> \
     --amount-to-transfer <AMOUNT>
  • <SENDER_HOLDING> — a holding you own (spel signs it; balance must be ≥ <AMOUNT>).
  • <RECIPIENT_HOLDING> — an existing holding of the same <DEF> (initialize it first, above).
  • Both holdings must share the same definition, or the transfer aborts with Sender and recipient definition id mismatch.

Verify the recipient's new balance:

spel --idl artifacts/token-idl.json inspect <RECIPIENT_HOLDING> --type TokenHolding

4. Derive the AMM PDAs

Program PDAs use a SHA-256 seed scheme (in each *_core), which is not what spel pda computes (that pads raw bytes) — so spel pda will give wrong addresses. Each program ships a committed examples/*_pdas helper that derives its PDAs with its own *_core functions, guaranteeing they match the guest. They take ProgramIds as comma- separated u32 limbs, a 64-char ImageID hex, or a base58 ImageID (any form spel program-id prints, plus base58), and account ids as base58.

# config only (for step 5):
cargo run -q -p amm_program --example amm_pdas -- "<AMM_PROGRAM_ID>"

# config + all pool PDAs (for step 6):
cargo run -q -p amm_program --example amm_pdas -- \
  "<AMM_PROGRAM_ID>" "<TWAP_PROGRAM_ID>" "<DEF_A>" "<DEF_B>"

The fixed clock account never changes: 4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU (/LEZ/ClockProgramAccount/0000001).

All PDA helpers

program command prints
amm cargo run -q -p amm_program --example amm_pdas -- <amm_pid> [<twap_pid> <defA> <defB>] config; + pool, vault_a/b, pool_definition_lp, lp_lock_holding, current_tick_account
twap_oracle cargo run -q -p twap_oracle_program --example twap_oracle_pdas -- <oracle_pid> <price_source> [<window_duration>] current_tick_account; + price_observations, oracle_price_account (with a window)
ata cargo run -q -p ata_program --example ata_pdas -- <ata_pid> <token_pid> <owner> <definition> the ATA address
stablecoin cargo run -q -p stablecoin_program --example stablecoin_pdas -- <stablecoin_pid> <owner> <collateral_definition> position, position_vault

(The token program has no PDAs.)

5. Initialize the AMM

Pick an <AUTHORITY> account you control (the admin who may later update_config). config is the PDA from step 4; token/twap are the ProgramIds from step 1.

spel --idl artifacts/amm-idl.json \
     --program programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
     -- initialize \
     --config <CONFIG_PDA> \
     --token-program-id <TOKEN_PROGRAM_ID> \
     --twap-oracle-program-id <TWAP_PROGRAM_ID> \
     --authority <AUTHORITY>

config is init but not a signer — spel just lists it; the guest claims it as a PDA. Run once per deployment. (Add --dry-run to preview.)

6. Create a pool (new-definition)

Provide three holding accounts you own:

  • <USER_HOLDING_A> / <USER_HOLDING_B> — your holdings of token A / B (signers; must hold ≥ the deposit amounts).
  • <USER_HOLDING_LP> — a fresh wallet account whose key you hold (now a signer; it must be a normal keypair account, not an ATA). Receives the LP tokens.

Amounts must satisfy isqrt(token_a_amount * token_b_amount) > 1000 (MINIMUM_LIQUIDITY). fees ∈ {1, 5, 30, 100} (bps). deadline is a future ms timestamp (or u64::MAX = 18446744073709551615 to ignore).

spel --idl artifacts/amm-idl.json \
     --program programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
     -- new-definition \
     --config <CONFIG_PDA> \
     --pool <POOL_PDA> \
     --vault-a <VAULT_A_PDA> \
     --vault-b <VAULT_B_PDA> \
     --pool-definition-lp <POOL_DEFINITION_LP_PDA> \
     --lp-lock-holding <LP_LOCK_HOLDING_PDA> \
     --user-holding-a <USER_HOLDING_A> \
     --user-holding-b <USER_HOLDING_B> \
     --user-holding-lp <USER_HOLDING_LP> \
     --current-tick-account <CURRENT_TICK_PDA> \
     --clock 4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU \
     --token-a-amount <AMOUNT_A> \
     --token-b-amount <AMOUNT_B> \
     --fees 1 \
     --deadline 18446744073709551615

spel signs user_holding_a, user_holding_b, and user_holding_lp (the IDL signer accounts) — your wallet must hold all three keys. No --bin-* is needed: the chained token/oracle calls are resolved on-chain from the ProgramIds stored in config. Pair <DEF_A> with <USER_HOLDING_A>/<AMOUNT_A> consistently (and B with B).

7. Verify

spel --idl artifacts/amm-idl.json inspect <POOL_PDA> --type PoolDefinition

Check reserve_a/reserve_b, liquidity_pool_supply, and fees.

8. (Optional) Create a TWAP price-observations account

A TWAP feed for the pool over a time window. window_duration is in milliseconds (e.g. 24h = 86400000). The observations account is a PDA of (twap_pid, pool, window), so each window has its own account.

Derive it (the current_tick_account is the same one from the pool):

cargo run -q -p twap_oracle_program --example twap_oracle_pdas -- \
  "<TWAP_PROGRAM_ID>" <POOL_PDA> <WINDOW_DURATION>
# prints current_tick_account, price_observations, oracle_price_account
spel --idl artifacts/amm-idl.json \
     --program programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
     -- create-price-observations \
     --config <CONFIG_PDA> \
     --pool <POOL_PDA> \
     --current-tick-account <CURRENT_TICK_PDA> \
     --price-observations <PRICE_OBSERVATIONS_PDA> \
     --clock 4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU \
     --window-duration <WINDOW_DURATION>

No signers — this instruction is permissionless: the AMM authorizes the new observations PDA via the pool's PDA seed in a chained call to the TWAP oracle, so spel submits an empty witness set. Prereqs: the pool and its current_tick_account must already exist (the latter was created during new_definition); the oracle seeds the first observation from current_tick_account, so the price can't be forged. No --bin-* needed (the TWAP ProgramId is read from config). The price_observations PDA must not already exist (init).

Verify:

spel --idl artifacts/twap_oracle-idl.json inspect <PRICE_OBSERVATIONS_PDA> --type PriceObservations

9. Swap

Swap exact input → output. Uses the pool PDAs from step 4 plus your two token holdings. --token-definition-id-in picks the direction (pass the definition id of the token you're spending).

spel --idl artifacts/amm-idl.json \
     --program programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
     -- swap-exact-input \
     --config <CONFIG_PDA> \
     --pool <POOL_PDA> \
     --vault-a <VAULT_A_PDA> \
     --vault-b <VAULT_B_PDA> \
     --user-holding-a <USER_HOLDING_A> \
     --user-holding-b <USER_HOLDING_B> \
     --current-tick-account <CURRENT_TICK_PDA> \
     --clock 4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU \
     --swap-amount-in <AMOUNT_IN> \
     --min-amount-out <MIN_OUT> \
     --token-definition-id-in <DEF_OF_INPUT_TOKEN> \
     --deadline 18446744073709551615
  • --token-definition-id-in: <DEF_A> ⇒ A→B; <DEF_B> ⇒ B→A.
  • --swap-amount-in must be ≤ the input holding's balance; --min-amount-out is the slippage floor (1 accepts any nonzero output).
  • spel signs both user_holding_a and user_holding_b (both are signer in the IDL — the input side is dynamic, so both are marked; see the gotcha below). Pass both even though only the input side is debited.
  • No --bin-* needed (token program id read from config). swap-exact-output is the same account set with --exact-amount-out / --max-amount-in instead.

Verify (input reserve up by the input, output reserve down by the output):

spel --idl artifacts/amm-idl.json inspect <POOL_PDA> --type PoolDefinition

10. Record a tick

record_tick appends an observation to a window's price_observations account from the current tick. It's a direct TWAP oracle call (not AMM-chained), so it uses the twap_oracle binary + IDL. No signers — it's permissionless: it reads current_tick_account + clock and writes price_observations.

Run a swap (step 9) first so current_tick_account holds a fresh tick, then:

spel --idl artifacts/twap_oracle-idl.json \
     --program programs/twap_oracle/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/twap_oracle.bin \
     -- record-tick \
     --price-observations <PRICE_OBSERVATIONS_PDA> \
     --current-tick-account <CURRENT_TICK_PDA> \
     --clock 4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU \
     --price-source-id <POOL_PDA> \
     --window-duration <WINDOW_DURATION>
  • --price-source-id is the pool (an account_id arg, not a listed account).
  • --window-duration must match the value the price_observations account was created with (step 8) — it's part of that PDA's seeds, so a mismatch points at a different/missing account.
  • Prereqs: the observations account exists (step 8) and current_tick_account holds a fresh tick (run step 9 first).

Verify:

spel --idl artifacts/twap_oracle-idl.json inspect <PRICE_OBSERVATIONS_PDA> --type PriceObservations

11. Create the oracle price account

publish_price (step 12) writes into an oracle_price_account that must already exist, so create it first. The oracle's own create_oracle_price_account requires the price_source (the pool) to sign, which a PDA can't do — so the AMM creates it on the pool's behalf via a chained, PDA-authorized call. Permissionless (no signers) and init, so run it once per (pool, window).

Derive oracle_price_account (same twap helper as step 8, with the window):

cargo run -q -p twap_oracle_program --example twap_oracle_pdas -- \
  "<TWAP_PROGRAM_ID>" <POOL_PDA> <WINDOW_DURATION>
# prints current_tick_account, price_observations, oracle_price_account
spel --idl artifacts/amm-idl.json \
     --program programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
     -- create-oracle-price-account \
     --config <CONFIG_PDA> \
     --pool <POOL_PDA> \
     --oracle-price-account <ORACLE_PRICE_ACCOUNT_PDA> \
     --clock 4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU \
     --window-duration <WINDOW_DURATION>

--window-duration must match the observations account's window (step 8) — it seeds the oracle_price_account PDA. init, so skip this step if the account already exists.

12. Publish a price

Computes the TWAP from the observations ring buffer (extended to now using the current tick) and writes it to the oracle_price_account. Direct TWAP oracle call, permissionless.

Needs at least two observations. With fewer than two, publish_price is a silent no-op — it returns every account unchanged and writes nothing. The first observation is seeded when you create the observations account (step 8); the second comes from record_tick (step 10). That's why we swap (step 9) to move the tick and then record it before publishing.

spel --idl artifacts/twap_oracle-idl.json \
     --program programs/twap_oracle/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/twap_oracle.bin \
     -- publish-price \
     --price-observations <PRICE_OBSERVATIONS_PDA> \
     --oracle-price-account <ORACLE_PRICE_ACCOUNT_PDA> \
     --current-tick-account <CURRENT_TICK_PDA> \
     --clock 4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU \
     --price-source-id <POOL_PDA> \
     --window-duration <WINDOW_DURATION>
  • --price-source-id is the pool (an account_id arg, not a listed account).
  • Re-runnable any time to refresh the published TWAP (unlike step 11's one-time init).
  • --window-duration must match steps 8 and 11 — it seeds all three related PDAs.

Verify (check price and the publication timestamp):

spel --idl artifacts/twap_oracle-idl.json inspect <ORACLE_PRICE_ACCOUNT_PDA> --type OraclePriceAccount

Gotchas we hit (and how to avoid them)

  • Recompile ⇒ new ProgramId ⇒ new PDAs. Redo steps 46 after any AMM rebuild. The config/pool addresses are functions of the AMM ProgramId.
  • account_id args must be bare base58/0x-hex — strip the wallet's account_id( … ) display wrapper.
  • spel pda does NOT match these PDAs. It uses a padded-bytes seed scheme; the *_core crates hash with SHA-256. Always use the committed examples/*_pdas helpers (step 4).
  • user_holding_lp is a signer for new_definition (its LP-token definition is created in the same tx, so the holding must be a fresh signed keypair, not an ATA). For add_liquidity the LP definition already exists, so an existing/pre-created holding works without a signature.
  • Both swap holdings are signers. swap_exact_input/swap_exact_output mark both user_holding_a and user_holding_b signer, because the input side (a token Transfer sender, which must be authorized) is chosen at runtime via --token-definition-id-in. So pass and sign both even though only the input is debited. (Re-creating a pool also needs a fresh user_holding_lp — the previous one is bound to the old pool's LP token.)
  • Same LEE_WALLET_HOME_DIR everywhere. Deploying with one wallet home and running spel with another points them at different networks/keys.