Skip to content

fix(stablecoin): order guest instructions to match the enum - #421

Closed
gravityblast wants to merge 12 commits into
refactor/lez-v0.2.5from
fix/stablecoin-instruction-order
Closed

gravityblast wants to merge 12 commits into
refactor/lez-v0.2.5from
fix/stablecoin-instruction-order

Conversation

@gravityblast

Copy link
Copy Markdown
Collaborator

Reorders the stablecoin #[instruction] functions to match stablecoin_core::Instruction and regenerates the IDL.

spel uses the IDL position as the Borsh tag (logos-co/spel#288), so from index 8 on, spel calls decoded as the wrong variant (e.g. set-controller-gains → SetAdmin). Same fix #385 applied to token.

0x-r4bbit and others added 12 commits September 29, 2026 12:21
Point every crate at logos-execution-zone v0.2.5-rc2 and at the matching
spel framework revision.

The pin has to move everywhere at once: lee_core types cross every layer --
the program crates, their *_core crates, the guests, the host method crates
and the FFI modules -- so there is no partial bump that builds. The commits
that follow port one program each onto the new interfaces, and only the last
one leaves the workspace green.
Every entry point returns Vec<AccountStateDiff> instead of
Vec<AccountPostState>. A diff carries a change rather than a whole account -- a
BalanceDiff plus post_data, normalized to None when the data is untouched --
and ownership is not part of it at all: the runtime derives the post owner
itself. ProgramId is gone as a distinct type, since a program is now addressed
by its deployed header account, so ctx.self_program_id became
ctx.self_account_id. The program still needs it to check that a definition
account is owned by us; it no longer needs it to say who should own an account
afterwards.

Claim went with it. `new_claimed_if_default(post, Claim::Authorized)` in
transfer and mint is a plain diff now: v0.2.5 acquires ownership implicitly on
the first data write to a default-owned account, and there is no authorization
variant to ask for. Both sites carry a comment on why no is_authorized assert
replaces the old claim -- crediting an uninitialized recipient is a supported
flow, since the private foreign-init path knows the recipient only by npk/vpk,
and a guard here cannot tell that apart from an unauthorized public recipient.

token_core::Instruction gains Borsh derives: v0.2.5 decodes instruction data as
Borsh rather than through RISC Zero's serde codec. serde stays for tooling and
the IDL. Borsh tags variants positionally, so the enum is append-only from here
-- noted on the type.

StateDiffExt is a #[cfg(test)] extension trait reconstructing the resulting
account from a diff, delegating to lee_core's own post_state, so the existing
assertions keep reading in terms of post-state accounts.

artifacts/token-idl.json picks up a NewTokenDefinition type definition that the
regenerated IDL now emits.
Same shape as the token port: handlers return Vec<AccountStateDiff>,
ctx.self_program_id became ctx.self_account_id, Instruction gains Borsh derives
for the new instruction wire format, and a #[cfg(test)] StateDiffExt
reconstructs post-state accounts so the existing assertions keep reading the
way they did.

PDA derivation moves off ProgramId: compute_price_observations_pda,
compute_oracle_price_account_pda and compute_current_tick_account_pda take the
oracle's AccountId now, since a program is addressed by its deployed
ProgramHeader account. The *_pda_seed helpers are unchanged and still feed
AccountId::for_public_pda; they are just no longer needed at the call sites.

Claim::Pda is gone, and with it the runtime check that the claimed address
matched the seed the program presented. All three creating handlers already
assert their account id against the same derivation before writing, so that
assert is now the only thing binding these accounts to their PDAs -- worth
knowing before touching them. Writing the data is the claim: v0.2.5 makes the
program that writes to a default-owned account its owner.

examples/twap_oracle_pdas.rs takes a base58 account id for the oracle instead
of a ProgramId, dropping the limb/hex/base58 ImageID parsing that existed only
to reach the old type.
Handlers return Vec<AccountStateDiff>, ctx.self_program_id became
ctx.self_account_id, and Instruction gains Borsh derives for the new instruction
wire format.

Unlike token and twap_oracle, the ATA program takes another program's id as an
explicit instruction argument, so this changes the caller-facing interface:
Create, Transfer and Burn all carry token_program_id, now an AccountId rather
than a ProgramId. artifacts/ata-idl.json retypes those three args from
"program_id" to "account_id". Anything building ATA instructions -- scripts, the
FFI modules, wallet invocations -- has to pass the new form.

compute_ata_seed hashes token_program_id.to_bytes() instead of expanding eight
u32 limbs little-endian by hand. Those are the same 32 bytes, so a given token
program id yields the same ATA address it did before; what changes an ATA
address is the token program itself being deployed at a different id.

get_associated_token_account_id and verify_ata_and_get_seed take AccountId for
both the token and ATA program ids, for the same reason.
…h holdings

faucet_mint returns Vec<AccountStateDiff>, and its chained
Token::MintWithAuthority names accounts by id -- so the account ids are captured
before the pre-states are moved into the diffs. Instruction gains Borsh derives,
and ctx.self_program_id became ctx.self_account_id.

verify_mint_allowance_and_get_seed is now called only for its address assertion;
the seed it returns is unused, because ownership of the allowance PDA comes from
writing its data rather than from presenting a claim.

The faucet's split -- permissive for existing holdings, consent required for
fresh ones -- used to be enforced downstream rather than here. v0.2.4's token
program claimed a fresh holding via Claim::Authorized, which failed unless the
recipient had authorized it, so faucet_mint deliberately did not check
user_holding.is_authorized itself. v0.2.5 removed claims:
acquire_ownership_on_data_write fires on any data write to a default-owned
account with no authorization check. Without a local guard the chained mint
would materialize a holding at any unowned address the caller names, making it
token-program-owned permanently and denying it to every other program -- and the
24h cooldown does not contain that, since it keys on (recipient, definition) and
a fresh keypair resets it.

So the rule is now enforced where it is relied upon: an uninitialized
user_holding must carry is_authorized. Topping up an existing holding is
unchanged and still open to anyone. Covered by
rejects_creating_a_holding_the_caller_cannot_sign_for.
Handlers return Vec<AccountStateDiff>, Instruction gains Borsh derives, and
ctx.self_program_id became ctx.self_account_id. The IDL retypes
token_program_id and twap_oracle_program_id on Initialize and AmmConfig from
"program_id" to "account_id" -- caller-facing, like ata.

Chained calls now name accounts by id. ChainedCall carries pre_state_ids rather
than whole pre-states, and the runtime resolves each id against the
transaction's accumulated diff before falling back to global state. That
deletes every hand-built synthetic post-state this program used to construct to
show a callee an account it had just written: post_init_vault,
pool_lp_after_lock, pool_price_source, and swap's hand-recomputed second-touch
vault balance. The callee now sees the real applied diff, so these cannot drift
from what the program actually wrote.

Authority over a chained call's accounts moves to pda_seeds:
with_pda_seeds(...) feeds compute_public_authorized_pdas, which sets
is_authorized for the callee. There is no pre-state flag to set by hand any
more -- presenting the seed is the authority.
Handlers return Vec<AccountStateDiff>, Instruction gains Borsh derives, and
ctx.self_program_id became ctx.self_account_id. Every ProgramId parameter --
stablecoin_program_id and the token/oracle ids threaded through the core
modules -- becomes an AccountId, including the [Nu32; 8] test fixtures in
protocol_parameters, redemption_price_state and stability_fee_accumulator,
which are now AccountId::new([Nu8; 32]).

Chained calls name accounts by id, so open_position, withdraw_collateral and
initialize_program capture the ids they need before the pre-states are moved
into the diffs, and carry authority as pda_seeds: the position vault seed for
the chained InitializeAccount, and the stablecoin definition plus master
holding seeds for the initialize_program chain.

Claim::Pda is gone from every PDA-creating handler. verify_position_and_get_seed
is now called for its address assertion alone -- its seed is no longer carried
anywhere, because writing the position data is itself the ownership claim.
verify_position_vault_and_get_seed still returns a seed that is used, but as the
chained call's authority rather than as a claim.

open_position keeps its user_holding.is_authorized assert, which it always had:
unlike the faucet, this program never delegated that check downstream.
Deployment goes through with_programs instead of ProgramDeploymentTransaction,
which v0.2.5 deleted. The five deploy_* helpers seed each program in the shape
the program_loader flow produces -- a loader-owned header plus its segment -- so
call sites are unchanged. Program ids become AccountId::from(<ELF>_ID): the
harness seeds the header at the image-id bijection address, which is where a
program lives here.

The privacy helpers follow the collapsed witness API. The three
InputAccountIdentity::Private* variants became one Private(PrivateWitness), so
private_authorized_init_identity and private_authorized_update_identity now take
an AuthorizationSecretKey rather than a nullifier secret key, deriving the nsk
from it; GroupOwner::admit_member returns that key. private_foreign_init_identity
keeps its signature but now pairs with is_authorized: false, because the circuit
asserts a Regular witness's authorization matches whether a credential was
supplied.

Transactions carry instruction data as Borsh, and chained calls name accounts by
id, so the hand-built pre-states the old tests passed to callees are gone.
Two changes the release forces, entangled across the same files, and the spel
bump that carries them: 1ba0b6b7 moves spel-cli to Borsh encoding and drops the
serde derives from the Instruction enum spel-framework-macros generates. This
is the repo-side half of that flip.

Program addressing: lee_core::program::ProgramId -- the [u32; 8] ImageID -- is
gone. A program now lives at the account its `program_loader` header was
deployed to, so every ProgramId in the module FFIs becomes an AccountId. The
wire format is unchanged (callers still pass 64 hex characters), and a program
deployed at the image-id bijection address keeps the id it had.

Instruction encoding: the wire format is Borsh, but the FFI plan ops still
built their instructions with risc0_zkvm::serde::to_vec, which kept compiling
and kept producing bytes the runtime rejects; every encode site moves to
borsh::to_vec. Their C++ consumers then decoded each element of the resulting
byte array as a u32 word and emitted four little-endian bytes, quadrupling
every instruction -- so the guest's Borsh decode failed, the program panicked,
and the transaction was charged its full gas budget, bumped the nonce, wrote
nothing, and was never logged. All three module decoders now pass bytes
through, and their unit tests assert that contract instead of the old word
expansion.

The programs' Instruction enums drop their serde derives to match, so this
cannot recur: with Borsh the only encoding they implement, reaching for a serde
encoder is a compile error rather than silently wrong bytes.
Until v0.2.5 a program was addressed by its ImageID, so the module could
derive its own address from the binary at AMM_PROGRAM_BIN. v0.2.5 addresses a
program by the account its `program_loader` header was deployed to, chosen at
deploy time and not recoverable from the ELF, so that fallback now yields a
well-formed id that simply is not where the program lives -- and every PDA
derived from it is wrong too. Read the deployed id from AMM_PROGRAM_ID
instead, and drop the binary fallback along with loadAmmElf() and toHex().

An app-selected id (setAmmProgramId, i.e. the registry) still takes
precedence; AMM_PROGRAM_ID is the fallback for local and headless use. The UI
gains a config_read_failed string, and no_program_bin is reworded for the new
source.

RegistryLoader also warns when AMM_REGISTRY_URL is set but ignored because a
local token or pool list takes precedence, which otherwise looks like the
registry silently failing to load.
The end-to-end setup needed several things the release changed:

- Account mentions must be qualified (Public/<id>); a bare base58 id falls
  through to the label branch and the deploy fails.
- Every spel submission needs --fee-payer, or the sequencer answers
  "Incorrect fee".
- The accounts that sign from the UI need a native balance of their own,
  because the modules submit with the first signing account as payer. A new
  funding step tops up the six of them from the deploy payer, idempotently.
- run_tx now accepts the wallet's confirmation marker ("Transaction is
  included in block N") as well as spel's, so funding transfers stopped
  being reported as unconfirmed; deploys and funding are strict again.
- The faucet manifest carries feePayer and fundAmount so faucet-swap.mjs can
  do the same without hardcoding them.

Diagnostics move to stderr so a function can return a value on stdout without
its progress output being captured with it, and transaction-data dumps are
suppressed unless SHOW_TX_DATA=1. The recovery note is corrected: segments are
write-once and headers deployed --immutable cannot be reclaimed, so
FORCE_BOOTSTRAP does not help -- only a chain reset does.

faucet-swap.mjs funds holder2's holding itself, between initialize_account and
the swap: setup cannot, because the holding does not exist until the test
creates it, and initialize_account asserts its target is still
Account::default() -- balance and nonce zero -- so the funding has to land
after it.
spel encodes the IDL position as the Borsh tag, so the IDL must follow
stablecoin_core::Instruction order.
@0x-r4bbit
0x-r4bbit force-pushed the refactor/lez-v0.2.5 branch 4 times, most recently from 05282f1 to 38bd3c9 Compare October 20, 2026 09:38
@0x-r4bbit

Copy link
Copy Markdown
Collaborator

This will be fixed via SPEL in #385

@0x-r4bbit 0x-r4bbit closed this Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants