21 KiB
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
- 1. Build & deploy the programs
- 2. Wallet CLI basics
- 3. Create two token definitions
- 4. Derive the AMM PDAs
- 5. Initialize the AMM
- 6. Create a pool (new-definition)
- 7. Verify
- 8. (Optional) Create a TWAP price-observations account
- 9. Swap
- 10. Record a tick
- 11. Create the oracle price account
- 12. Publish a price
- Gotchas
0. Prerequisites
- Docker running (guest builds cross-compile through it).
spel/walletbuilt from therefactor/lez-v020-compatbranch (github.com/0x-r4bbit/spel). This is required for: theprogram-idcommand, the--subcommand separator, the public-tx signing scheme, and theaccount_idargument 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>receivestotal_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>
configisinitbut not a signer — spel just lists it; the guest claims it as a PDA. Run once per deployment. (Add--dry-runto 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, anduser_holding_lp(the IDLsigneraccounts) — your wallet must hold all three keys. No--bin-*is needed: the chained token/oracle calls are resolved on-chain from the ProgramIds stored inconfig. 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_accountmust already exist (the latter was created duringnew_definition); the oracle seeds the first observation fromcurrent_tick_account, so the price can't be forged. No--bin-*needed (the TWAP ProgramId is read fromconfig). Theprice_observationsPDA 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-inmust be ≤ the input holding's balance;--min-amount-outis the slippage floor (1accepts any nonzero output).- spel signs both
user_holding_aanduser_holding_b(both aresignerin 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 fromconfig).swap-exact-outputis the same account set with--exact-amount-out/--max-amount-ininstead.
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-idis the pool (anaccount_idarg, not a listed account).--window-durationmust match the value theprice_observationsaccount 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_accountholds 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-durationmust match the observations account's window (step 8) — it seeds theoracle_price_accountPDA.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_priceis 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 fromrecord_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-idis the pool (anaccount_idarg, not a listed account).- Re-runnable any time to refresh the published TWAP (unlike step 11's one-time
init). --window-durationmust 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 4–6 after any AMM rebuild. The config/pool addresses are functions of the AMM ProgramId.
account_idargs must be bare base58/0x-hex — strip the wallet'saccount_id( … )display wrapper.spel pdadoes NOT match these PDAs. It uses a padded-bytes seed scheme; the*_corecrates hash with SHA-256. Always use the committedexamples/*_pdashelpers (step 4).user_holding_lpis a signer fornew_definition(its LP-token definition is created in the same tx, so the holding must be a fresh signed keypair, not an ATA). Foradd_liquiditythe LP definition already exists, so an existing/pre-created holding works without a signature.- Both swap holdings are signers.
swap_exact_input/swap_exact_outputmark bothuser_holding_aanduser_holding_bsigner, because the input side (a tokenTransfersender, 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 freshuser_holding_lp— the previous one is bound to the old pool's LP token.) - Same
LEE_WALLET_HOME_DIReverywhere. Deploying with one wallet home and running spel with another points them at different networks/keys.