Skip to content

cluster-tool + flow-liq-syndication: drive the simple_swap liq attestations through the real liqsol_core paths - #98

Open
valthon wants to merge 5 commits into
masterfrom
simple_swap
Open

valthon wants to merge 5 commits into
masterfrom
simple_swap

Conversation

@valthon

@valthon valthon commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Harness half of the cross-repo simple_swap feature (wire-sysio #609 protocol types, wire-solana #523 outpost emitters, both merged). This replaces the earlier injection approach: the flow now drives the real liqsol_core paths and proves SYNDICATE_LIQ and LIQ_YIELD circulate from the Solana outpost to the depot.

Depends on wire-solana #534 (register_system_pda creating the pool record on a deployable build). Until it lands, the liqsol surface bootstrap fails at init-wire-config on any non-development wire-solana build; the flows below were run against next + #534.

Commits (4, on master 2d703304)

  1. cluster-tool: load every wire-solana program at genesis + stand up the liqsol surface. SolanaValidatorProcessSteps loads liqsol_core, liqsol_token, transfer_hook, validator_leaderboard at genesis (upgradeable, deployer as authority, ids from .keys/). A new SolanaLiqsolSurface phase, before the OPP outpost bootstrap, runs wire-solana's init scripts through SolanaAnchorScriptTool (anchor run --provider.cluster --provider.wallet, wire-solana's own node_modules/.bin on PATH, stdout/stderr tails in the Report) plus read-first harness Steps where a script cannot report failure (initialize, initialize_stake_controller_state, initialize_vault, treasury shortfall). Guards: verify-toolchain (solana/anchor versions equal the [toolchain] pins, so anchor-cli cannot reinstall a toolchain under a running validator), verify-program-ids, an epoch gate before init-pretoken-purchase-history (the instruction underflows in epoch 0 and writes the program's "uninitialized" sentinel in epoch 1; wire-solana's CI gates on epoch 2 for the same reason) and a read-back that fails on the sentinel. init-tranche-state is deliberately not run: a production build pins the mainnet Chainlink feed, which a test validator does not host. solanaSlotsPerEpoch becomes a persisted ClusterConfig field (--solana-slots-per-epoch, default 100) so create, run and start.sh cannot drift.
  2. cluster-tool: drive the liq-syndication surface through the real liqsol_core paths. SolanaLiqSyndicationTool: one Step per write (sol_to_liqsol, set_wire_state, synd, inject_bonus_synd_yield, report_liq_yield), pure account-map builders checked against the deployed IDL at bootstrap (verify-instruction-accounts) and against a committed fixture in unit tests, set_wire_state refusing illegal transitions before submitting, the deposit waiting out the EpochRewards window and retrying once on EpochRewardsActive in any of its three renderings. SolanaAddAttestationTool and SolanaYieldEmitterTool are deleted.
  3. flow-liq-syndication: prove the liq attestations end-to-end; drop the injectors. Phases: SnapshotDepotEpoch → Syndicate (pre-launch verify first, deposit for liqSOL, Launching, PostLaunch, synd, SYNDICATE_LIQ observed in an OUTPOST_SOLANA_DEPOT artifact) → Yield (0.1 SOL × k bonus donation, report_liq_yield, LIQ_YIELD decoded and its amount checked against the donation) → DepotAcceptsUnknownTypes. flow-yield-distribution loses its Solana leg (the outpost no longer emits STAKING_REWARD; it returns when the depot handles LIQ_YIELD).
  4. docs: genesis program set, the liqsol surface, the new flow.

Review history

Five adversarial rounds (Opus, then Fable) between the original injector approach and this series; round 5 found no blockers. Per-round reports are in the session, summarised in the commit bodies: ts-node resolved only through a host PATH (fixed), two init scripts that could not fail (replaced by harness Steps), the epoch-0/epoch-1 pretoken-history hazard (gated and read back), the toolchain reinstall hazard (asserted), the epoch-rewards deposit race (single retry, tested).

Gate (idle host)

Check Result
pnpm build, pnpm run lint clean
pnpm test at the tip 233 suites, 2182 tests, all passing
pnpm build per commit all four clean
flow-liq-syndication (canonical run-flow.mjs + heartbeat), --solana-path = wire-solana next + #534, deployable build SUCCEEDED; epoch gate blocked 21.5 s, history read back with starting_epoch = 1, Registered liqSOL pool user record. captured from init-wire-config
flow-yield-distribution, same build SUCCEEDED

Known, out of scope: the bind-registry lock compromise that flakes the full jest suite under load has its own fix on fix/bind-registry-lock-compromise.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CNDguHHWTwn1UBejDiTAGa

@valthon

valthon commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed as 3c8de861 (still one commit) to track the wire-sysio #609 review: the three messages are now SyndicateLIQ / LIQYield / DesyndicateLIQ and every amount is the generated TokenAmount (tokenCode = SlugName.from("LIQSOL"), the bootstrap registry's liq token). Harness identifiers follow the same casing (encodeSyndicateLIQ, containsLIQYield, LIQSyndicationScenario, …). Gate: build, lint, 232 suites / 2098 tests, run twice. CI stays red on the published models package until #609 merges and the bundles publish, as before.

@valthon valthon changed the title cluster-tool + flow-liq-syndication: drive the simple_swap liq attestations through the Solana outpost cluster-tool + flow-liq-syndication: drive the simple_swap liq attestations through the real liqsol_core paths Sep 17, 2026
@valthon
valthon force-pushed the simple_swap branch 2 times, most recently from b5b7c45 to 14de67e Compare September 17, 2026 07:59
@heifner

heifner commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

e2e: three runs, all green, and they cover this branch. #104 stacks on simple_swap, and every run resolved BRANCH_WIRE_TOOLS_TS=feature/liq-yield-flow to f22987b3, whose history contains this PR's head 14de67e0.

Run Started Result
35905786811 2026-09-23 18:55Z 17 of 17 flows, 0 failed, 122 min
35877211864 2026-09-23 14:51Z 17 of 17 flows, 0 failed, 107 min
35772325409 2026-09-22 19:12Z 17 of 17 flows, 0 failed

Overrides on all three: BRANCH_WIRE_SYSIO=feature/liq-yield-claiming, BRANCH_WIRE_LIBRARIES_TS=feature/liq-yield-contract-types, BRANCH_WIRE_TOOLS_TS=feature/liq-yield-flow, BRANCH_WIRE_ETHEREUM=wne-41_next-fix (Wire-Network/wire-ethereum#205), BRANCH_WIRE_SOLANA=codex/merge-develop-into-next-20260922.

The 17 are master's 15 plus liq-yield and liq-syndication, both of which come from #104. The five swap flows that exercise this branch's sysio.swap work are in the 15.

Build and test is red here by construction, not from a defect in the diff: a standalone CI clone resolves @wireio/sdk-core and @wireio/opp-typescript-models from npm, and neither yet carries Wire-Network/wire-libraries-ts#84's regenerated types or Wire-Network/wire-sysio#634's attestations. It clears once #84 releases and the package.json points at that version.

@jglanz jglanz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you choose to merge, make sure to rebase first. Otherwise @jglanz will merge when implementing new flow scenarios over the coming days

valthon and others added 5 commits September 24, 2026 15:56
…e liqsol surface

The Solana outpost bootstrap only ever loaded `liqsol_core`, so the liqsol
staking + syndication surface it hosts had no accounts behind it: the liqSOL
mint, its Token-2022 transfer hook, the distribution / stake / withdraw state,
the leaderboard, the wire `GlobalState` and the reserve pool simply did not
exist on a flow cluster. Anything that drives a REAL `synd` or
`report_liq_yield` was therefore unreachable.

Load all four programs at genesis and run wire-solana's own `init-*` scripts:

- `SolanaOutpostProgramTool` generalizes over the program's CRATE name
  (`.keys/<name>-keypair.json`, `target/deploy/<name>.so`,
  `target/idl/<name>.json`) and gains `assertIdlProgramId`, so the id a script
  resolves from the IDL can be compared to the id the validator loaded.
  `liqsol_core` stays the default, so every existing caller is unchanged.
- `SolanaValidatorProcessSteps.resolvePrograms` returns all four programs
  (`GenesisAnchorPrograms`), each upgradeable under the ONE per-cluster
  deployer — the same identity `initialize_global_config` proves against
  `ProgramData`.
- `SolanaAnchorScriptTool` runs ONE `Anchor.toml` `[scripts]` entry per Step
  (`anchor run <script> --provider.cluster … --provider.wallet …`), the Solana
  analogue of the hardhat shell-out in `EthereumOutpostBootstrapper`. It
  prepends wire-solana's own `node_modules/.bin` to the subprocess `PATH`:
  every `[scripts]` entry is a bare `ts-node`, which anchor-cli hands to
  `bash -c` WITHOUT adding the workspace bin dir, so on a machine with no global
  `ts-node` — a CI runner — every script would die `command not found`.
  Prepending it also means the interpreter is the one that repo's lockfile pins
  rather than whatever a global shim happens to be.
- `SolanaLiqsolSurfaceSteps.planLiqsolSurface` composes the phase: assert the
  toolchain and the program ids, airdrop the deployer, run the sixteen init
  scripts in `bash-scripts/reset-local-cluster.sh`'s order — with harness Steps
  spliced in at the three scripts that need them — then top the rent treasury up
  to its floor.

  `init-distro` and `init-controller` are NOT among the scripts. Both write
  unconditionally and both end in `main().catch(console.error)`, so they exit 0
  on a failed write, and `runScript` sees only the exit code: their Report rows
  could not go red, and a failed init would surface at whichever later step
  first read `distribution_state` or `controllerState`, under the wrong name.
  The three writes they carry are three harness Steps instead (`initialize`,
  `initialize_stake_controller_state`, `initialize_vault`), each reading its
  target account first, at the position the scripts ran — which is not one
  position: `init-distro` runs immediately BEFORE `init-wire-config`, whose
  instruction reads the `distribution_state` it creates, and `init-controller`
  immediately before `init-global-config`. The Steps are keyed to those script
  NAMES rather than to an index, so inserting an init script cannot move them.

  `init-pretoken-purchase-history` gets a gate and a read-back, and this one is
  not hygiene either. The instruction stores `starting_epoch = current_epoch -
  1`, and `PretokenPurchaseHistory::is_initialized()` IS `starting_epoch != 0`.
  In Solana epoch 0 that underflows and the script exits 1 — every local-mode
  bootstrap dies. In epoch 1 it SUCCEEDS and stores 0, the program's own
  "never initialized" sentinel, after which every pre-launch syndication,
  pretoken and refund path on that cluster fails `InvalidWireState` for the rest
  of its life. Measured on this branch's own runs the step landed 1.4 s into
  epoch 1 — the sentinel, every time, invisible only because PostLaunch `synd`
  never reads the history. So the phase waits for epoch
  `MinimumPretokenHistoryEpoch` (2, wire-solana's own `MIN_EPOCH` in
  `bash-scripts/wait-for-validators.sh`, for exactly this reason) immediately
  before the script, and reads the account back immediately after so a
  regression in the gate cannot pass silently.

  The wait's budget comes from `getEpochInfo()` — the slot time actually
  REMAINING (`slotsInEpoch - slotIndex`, plus whole epochs after it) times a
  slack factor — not from a whole number of epochs counted at the step. By the
  time this runs the scripts ahead of it have already spent most of the wait, so
  a step-relative budget would be mostly slack in the normal case and still too
  tight for a slow validator in the bad one. The read-back polls for a few slots
  before decoding, because the script confirms its `.rpc()` at `processed` while
  the harness reads at `confirmed`. This is also what makes the
  100-slot epoch schedule load-bearing rather than cosmetic: at agave's default
  the wait would be days.

  The toolchain assertion is not hygiene. Since anchor-cli 0.30, `Anchor.toml`'s
  `[toolchain]` is applied on EVERY `anchor` invocation, so a `solana` or
  `anchor` that differs from the pins makes each script Step run
  `agave-install init <pinned>` first — a network download that repoints
  `~/.local/share/solana/install/active_release` while THIS cluster's
  `solana-test-validator` is running out of it. Refusing up front costs one
  `--version` call per binary; discovering it the other way is a validator that
  misbehaves mid-bootstrap, blamed on whichever step was unlucky. `reset-local-cluster.sh` itself is NOT used — its
  `anchor deploy` would fight the genesis load with a different upgrade
  authority. The treasury is NOT one of the scripts: `fund-treasury` transfers
  a fixed amount every time it runs, so on a re-run it would keep paying. A
  script Step is only sound when the script is idempotent, so the treasury gets
  a harness Step instead — `planFundTreasury` reads the PDA's balance and
  transfers only the shortfall, the shape `planKeypairAirdrop` already uses.
  `init-tranche-state` is left out for a harder reason: outside
  `--features development`, `initialize_tranche_state` pins its `chainlink_feed`
  and `chainlink_program` accounts to the real MAINNET Chainlink SOL/USD feed
  and program by address, and a test validator hosts neither — so running it
  would make a development build a prerequisite of the bootstrap. Nothing on the
  syndication path reads the tranche state, and neither does any later script
  here, so the surface is complete without it.
- `SolanaAnchorScriptTool` records each script's stdout/stderr TAILS into the
  step's Report `extra`. A script Step's evidence is its output, and the tails
  are logged at `debug` (twenty scripts x 4 000 chars would flood the aggregate
  log the heartbeat greps); without the Report entry a PASSING step would leave
  nothing behind but the argv that launched it, so `init-wire-config` standing up
  the wire `GlobalState` and the liqSOL pool's accounting would be unverifiable
  after the run.
- `SolanaFundingTool` gains handle-addressed per-cluster keypairs
  (`sol-<name>-keypair.json`, the deployer being one of them) and
  `planKeypairAirdrop`, the operator-free counterpart of `planAirdrop`: the
  deployer and flow-owned wallets have no `ctx.keyStore` operator identity.
- `SolanaValidatorProcess` now passes `--slots-per-epoch`, resolved from the
  PERSISTED `ClusterConfig` so `create`, `run` and `start.sh` cannot drift from
  one another. The field is REQUIRED and carries no schema default:
  `ClusterConfigProvider.resolve` always writes it (from
  `--solana-slots-per-epoch`, defaulting to 100), so a config without it is
  malformed rather than old.
  agave's default keeps a fresh validator at Solana epoch 0 for ~2 days of slot
  time, and `initialize_pretoken_purchase_history_handler` seeds its history
  from `current_epoch.checked_sub(1)` — which underflows (`Underflow`, 7418)
  for as long as the epoch is 0, so the liqsol surface could not be
  initialized at all. This is the same reason wire-solana's own
  `run-wire-postlaunch-local.sh` passes the flag. No harness or flow code
  reads the Solana epoch (the OPP consensus boundary is the DEPOT's epoch), so
  shortening it changes no behavior under test.

The phase is sequenced BEFORE the OPP outpost deploy because that is where
`init-global-config` creates the `global_config` every OPP admin op is gated
on; `SolanaOutpostBootstrapper.ensureGlobalConfig` then finds it already there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNDguHHWTwn1UBejDiTAGa
…ol_core paths

`SolanaLiqSyndicationTool` is the harness's Step palette for the `liqsol_core`
instructions a user, an admin and a permissionless cranker actually call —
`sol_to_liqsol`, `set_wire_state`, `set_token_address`, `synd`,
`inject_bonus_synd_yield` and `report_liq_yield`. Every one is its own Step, so
the Report records each write; `SYNDICATE_LIQ` and `LIQ_YIELD` are queued by the
program itself, never by the harness.

- PDA derivation is a pure value helper (`deriveBasePdas` / `deriveUserPdas`)
  over `LiqsolPdaSeed` — the seed registry the bootstrap phase that CREATES
  these accounts already derives from, aliased here as `PdaSeed` so every call
  site reads the same. The seeds are cross-checked against
  `liqsol-core`, `liqsol-token` (the mint + its authority) and `transfer-hook`
  (the `ExtraAccountMetaList`). The mint's token accounts are Token-2022 ATAs.
- `readLiqYieldState` and `readWireState` decode `GlobalState` through the
  program's OWN Anchor coder, reached via a connection-only
  `SolanaOutpostProgramTool.loadReadOnlyProgram` — a read needs the IDL's
  layouts and an RPC, never a wallet or a fabricated keypair. It has to be a
  `Program` and not a bare `anchor.BorshCoder`: `Program` is what camelCases the
  IDL, so its coder keys `globalState` and `liqYieldReported` where a raw-IDL
  coder keys `GlobalState` and `liq_yield_reported`, and Anchor exports no
  converter to bridge them. A committed `GlobalState` fixture pins that, because
  the wrong spelling costs a four-minute cluster to discover. The crank's
  reported amount IS the delta between its watermark before and after.
- `set_token_address` is exposed as a Step because it is a hard precondition,
  not a convenience: `synd` and `report_liq_yield` resolve their attestation's
  depot token code via `OutpostConfig::token_code_for_mint` and refuse with
  `LiqTokenNotMapped` when the liqSOL mint is unmapped.
- `SolanaAnchorEnumTool` gains the `WireState` identity enum, its variant tag
  and the program's OWN transition table, alongside the existing proto-enum
  variant helpers. `runSetWireState` READS `GlobalState.wireState` and refuses
  before it submits — `set_wire_state` is a one-way ratchet in `liqsol_core`,
  so a re-run or a wrong target would otherwise spend a transaction to earn an
  opaque on-chain revert.
- Every instruction's account map is an exported pure builder, and one test per
  instruction feeds the runner's OWN map to `program.methods.<ix>().accounts()`
  against a trimmed, committed copy of the real `liqsol_core` IDL, asserting
  the resolved keys, their order and their writable/signer flags. wire-solana
  sets `resolution = false`, so Anchor fills in NOTHING: a renamed key would
  otherwise surface only as a run-time failure deep inside a flow, and an extra
  key would be silently dropped. The same comparison runs at BOOTSTRAP, against
  the IDL actually on disk (`verify-instruction-accounts`), because the
  committed fixture can only prove the maps were right when it was generated —
  and it names the instruction and the drifted keys rather than reverting later.

The deposit Step stands off Solana's epoch-rewards window, and retries once if
it lands in it anyway.
The runtime distributes an epoch's staking rewards over the first blocks of the
next one (SIMD-118) and refuses every stake instruction while it does;
`liqsol_core` reads the same `EpochRewards` sysvar and fails `deposit_to_reserve`
with `EpochRewardsActive`. At the harness's 100-slot epochs that window recurs
every ~40 seconds, so `runDepositForLiqsol` polls the sysvar and waits it out —
otherwise the flow is a coin flip, which is exactly how it was observed failing.

Polling narrows the window to the gap between the last read and the transaction
landing; it cannot close it. So the submission runs through
`submitWithEpochRewardsRetry`, which on that one error waits the window out and
re-sends EXACTLY once — safe in both shapes it arrives in, because a preflight
refusal sent nothing and an on-chain failure is atomic. All three renderings are
matched, derived from the single `EpochRewardsActiveErrorCode`: the AnchorError
name, `custom program error: 0x1dc5` from a refused preflight, and
`{"InstructionError":[1,{"Custom":7621}]}` from a transaction that PASSED
preflight and executed inside the window — the last being the one that actually
happens to the ~1 % of deposits the poll cannot protect. The retry takes its
`send` as a parameter so the branch is exercised without a validator.

`oppEnvelopeScan.readEnvelopeAttestations` returns the raw attestation payloads
of one type on one edge, so a scenario can assert on an attestation's CONTENT
instead of only its type tag. It delegates to `@wireio/debugging-shared`'s
`readEnvelopeRecordsFromDir` rather than re-walking the directory and re-parsing
the envelopes: that reader already owns the `.data`/`.metadata` pairing, the
filename grammar and the torn-read handling. Decoding the payload stays at the
call site, with the generated message class, so this module re-declares no proto
shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNDguHHWTwn1UBejDiTAGa
… injectors

`flow-liq-syndication` now drives the outpost's own instructions instead of
enqueueing attestations behind the program's back. A verify step reads
`GlobalState.wireState` and asserts the outpost is still PreLaunch — FIRST,
because every step after it writes — then a user is funded and deposits SOL for
liqSOL, the admin flips the outpost Launching -> PostLaunch, the user
syndicates — and `synd` queues `SYNDICATE_LIQ` itself. A permissionless donation
then credits the syndicated pool and a NON-ADMIN crank of `report_liq_yield`
queues `LIQ_YIELD`. The circulated `LIQ_YIELD` is DECODED and must carry exactly
the delta the crank advanced its watermark by, on exactly the sequence it drew,
under the outpost's chain code and the depot's liqSOL token code.

Because `set_wire_state` is a one-way ratchet, the scenario is single-shot per
cluster — the flow's README says so, and a second run against the same cluster
fails at that verify step with the state it found rather than at an opaque
on-chain revert several steps later. The step leads the phase so a refused run
spends nothing: behind it sits a real 5 SOL `sol_to_liqsol`, which would
otherwise land before the refusal.

The scenario binds the REAL liqSOL mint to the depot's liqSOL token code as its
first step rather than in the shared bootstrap: the bootstrap binds that code to
the mock SPL mint the swap flows hold reserves in, and only this flow's cluster
should see the change (`wire-outpost-repos-…-belong-in-before-all.md`).

`flow-yield-distribution` loses its Solana leg. The SOL staking surface is a
separate developer track and `liqsol_core` emits no `STAKING_REWARD`, so the leg
could only ever have been an injection; the flow is Ethereum-only until the
outpost produces the attestation itself.

With both consumers gone, the injection surface goes with them:
`SolanaYieldEmitterTool` (and, from the superseded revision of this branch,
`SolanaAddAttestationTool` / `SolanaSyndicationTool` and the flow's own emit
steps) are deleted. Nothing in the harness writes to a program's outbound
message buffer any more.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNDguHHWTwn1UBejDiTAGa
…he new flow

The root README's outpost summary and `--solana-path` row said the harness
bootstraps `opp-outpost`; it now loads all four wire-solana Anchor programs at
genesis and runs their `init-*` scripts before the OPP deploy. CLAUDE.md's
orchestration map names `SolanaLiqsolSurfaceSteps` and why it is sequenced ahead
of the outpost bootstrap, and the flow table rows say what the two touched flows
actually assert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNDguHHWTwn1UBejDiTAGa
sdk-core and shared to 1.0.92, the wire-libraries-ts release carrying
the regenerated SysioContractTypes for sysio.liq and sysio.swap
(wire-libraries-ts#84). Produced by scripts/update-wireio-deps.mjs.

Change-Id: I2ccd90c14dd35080a9961f615041c70ff86a0e52

This branch has not been deployed

No deployments
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.

3 participants