Skip to content

LIQ yield flow: sysio.liq, msgch handlers, depot cranks, Solana relay shape - #634

Merged
heifner merged 39 commits into
masterfrom
feature/liq-yield-claiming
Sep 24, 2026
Merged

heifner merged 39 commits into
masterfrom
feature/liq-yield-claiming

Conversation

@heifner

@heifner heifner commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

The LIQ reward flow end to end on the WIRE side, stacked on the swap contract from #611 and merged with current master (through #594 / WIRE-352). This PR supersedes #611: the swap contract itself is untouched except the uint128 index widening, and its open review items stay Chuy's after landing.

The flow

  • A position exists. SYNDICATE_LIQ lands; sysio.msgch resolves the user's pubkey through sysio.authex. A linked user is credited (sysio.liq::mintsynd); an unlinked pubkey is parked (park) and delivered on link — sysio.authex::createlink sends sysio.liq::linkswept inline, best-effort, once sysio.liq is deployed and privileged — or by the permissionless sweep. Pre-launch positions and the LCO liq arrive from the bootstrap config the same way (importsynd, regliqpool), inside the epoch-0 window.
  • Attestation landing. LIQ_YIELD, the outpost's claimed yield since its last report, is sequence-deduped and lands in liqpending, outside supply.
  • Token minted. The permissionless queueyield mints the pending amount as protocol-owned shadow and hands it to the swap's reservoir (fundyield) in one transaction.
  • Swapped to WIRE. sysio.swap::tickyield sells a time-share clip of the reservoir into the pool; the WIRE proceeds reach the shadow contract through addyield.
  • Distributed / claimable. addyield bumps a uint128 cumulative index for every holder, with a 2% T5 kicker drawn through fundclaim(recipient, amount); claim pays exactly owed(row, index); close refuses while anything is owed.
  • Exit. desyndicate settles, burns and queues DESYNDICATE_LIQ; the Solana relay carries the handler's remaining accounts so the outpost pays the user's liqSOL inline. The other exit is selling shadow into the yield pool for WIRE on the depot.

Depot

  • contracts/sysio.liq: the shadow token, the parked ledger, the inbound cursors, the kicker, the ingestion actions, and its suite, which includes a replay of the checked-in dev config.
  • sysio.msgch: SYNDICATE_LIQ / LIQ_YIELD routed behind an active-liq-token gate on sysio.tokens; every refusal is a drop, never an abort. sysio.liq joins the queueout allow-list.
  • sysio.authex: createlink sweeps a parked syndication beside the linked-rewards sweep (the hook is in docs/contract-upgrade-order.md; no ordering constraint). The header's public_key_from_op_address returns an optional for only the key shapes a link can hold: a malformed op_address used to abort the whole envelope inside pubkey_to_checksum256, on the OPERATOR_ACTION path too; regression test added.
  • sysio.system: fundclaim(name recipient, int64 amount), recipients sysio.dclaim and sysio.liq.
  • bootstrap.proto: LiqPoolSpec and SyndicationSpec, validator rules V10 to V13, the dev config's liq pools and syndications.
  • Tests: the shadowtoken stand-in under test_contracts is gone. The swap suite deploys the real sysio.liq (bound through the chain and token registries, minted into by sysio.msgch), and its yield tests run on a fixture whose system token is 9,WIRE — the real contract takes its yield in WIRE and pays claims in WIRE, and a pair's second leg is the system token — so SHEO is SHD/WIRE at the precision inittoken derives. The AMM tests keep upstream's EOS fixture.

Node

  • batch_operator_plugin: sysio.swap::tickyield per yield pool with a queued reservoir, spaced per pool by --batch-yield-tick-interval-ms, and sysio.liq::queueyield per shadow with pending yield. Both idle until sysio.swap and sysio.liq run code, so an undeployed contract never logs a failed table read every poll.
  • outpost_solana_client: effect_shape::desyndicate_liq, in lock-step with handle_desyndicate_liq on wire-solana next, and a once-per-epoch report_liq_yield crank, PostLaunch only, through the new outpost_client::crank_outpost.
  • outpost_ethereum_client: the same hook cranks SyndicationPool.realizeYield() once per epoch, on the pool discovered from the outpost's DESYNDICATE_LIQ handler registration. Boot checks for DistributionState and GlobalState where the IDL declares them.

Companion PRs

Not in this PR

  • wire-solana: total_staked_liqsol must fold reported yield before yield-shadow circulates (David).

Review (Huang-Ming, 2026-09-22)

  • f0430969358c: addyield requests the T5 kicker only on the swap's intake; a donation distributes itself alone.
  • 16457e738df4: supply plus pending yield never exceeds the asset range — one headroom behind every mint and every intake, mintyield drops past it before consuming the sequence, queueyield can no longer fail.
  • 8803bfaaa38e: dispatch_syndicate_liq drops a user whose key family is not the proven outpost's, before the AuthX lookup.
  • fea844900990: the Ethereum crank. outpost_ethereum_client::crank_outpost sends SyndicationPool.realizeYield() on the pool the outpost registers as its DESYNDICATE_LIQ handler (OPPInbound.attestationHandlers), so nothing has to name the pool; idle until Wire-Network/wire-ethereum#207 deploys one, and the pool's own refusals (no yield, below the deadband, underbacked) are outcomes rather than failed cranks. The relay's signer needs the pool's yield_operator role.
  • The Solana counter fold is David's (wire-solana); the Ethereum handlers are Wire-Network/wire-ethereum#207 (Josh).

Verification

Suite Result
contracts_unit_test -- --sys-vm (full) green — 874 cases, review head 8803bfaaa38e (2026-09-23)
plugin_test green — 297 cases
test_batch_operator_plugin green (48)
test_outpost_solana_client_plugin green (95)
libraries/opp bootstrap-config validator green
e2e gate (wire-platform-build-system run 35772325409, 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 BRANCH_WIRE_SOLANA=codex/merge-develop-into-next-20260922) green — all 17 flows passed, incl. liq-yield (792 s) and liq-syndication (561 s), 2026-09-22; re-run on the review head 8803bfaaa38e: run 35877211864 green, all 17 flows, 107 min (2026-09-23); re-run on the crank head fea844900990: run 35890209048: 16 of 17 flows green; flow-reserve-lifecycle failed in its Solana bootstrap step init-token-mint (anchor run init-token-mint: ws error: connect ECONNREFUSED 127.0.0.1:39806, the validator's own pubsub port, before any batch operator ran; the validator log shows it alive and producing slots). Resolved refs identical to 35877211864 except the wire-sysio head, and the failing step never touches the changed plugin; re-dispatched as run 35905786811: green, all 17 flows, 122 min (2026-09-23)

qhool and others added 30 commits September 16, 2026 20:12
….swap

Byte-identical snapshot of https://github.com/EOSArgentina/evolutiondex at
066feb3ccd8d406ba2e095ef9978f6859a89bb03 (master, 2020-11-10, MIT — LICENSE
kept verbatim), laid out in the house contract shape and renamed to
sysio.swap:

  evolutiondex.{hpp,cpp}      -> include/sysio.swap/sysio.swap.hpp, sysio.swap.cpp
  utils.hpp, safe.hpp         -> include/sysio.swap/
  token_functions.cpp         -> token_functions.cpp
  evolutiondex.{contracts,clauses}.md -> sysio.swap.{contracts,clauses}.md
  tests/, wevotethefee/       -> kept alongside for the port

No source edits, no CMake wiring — nothing compiles yet; the port to the
sysio CDT idioms lands on top of this so every change diffs cleanly
against upstream.

Also narrows .gitignore's vim-swap pattern from *.sw* to *.sw?: the old
glob matched the directory name sysio.swap and silently ignored the whole
contract tree. contracts_unit_test: No errors detected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: Icd32083b8c506efe914f70a7a89e73a840f01d77
…o contracts_unit_test

Minimal port of the pristine import so sysio.swap builds, deploys, and
passes every upstream test case on WIRE:

- eosio -> sysio throughout (headers, attributes, sysio::multi_index — a
  drop-in over KV, so all four tables and both secondary indices are
  unchanged); namespace evolution -> sysio, class evolutiondex -> swap,
  contract name "sysio.swap". Logic untouched.
- CMake: contracts/sysio.swap registered as a two-TU add_contract; built
  wasm/abi copied into the source tree per house convention.
- Ricardian .md files moved under ricardian/: the CDT's add_contract macro
  passes its `${contracts}` glob unquoted to set_source_files_properties,
  which fails whenever a directory carries BOTH <name>.contracts.md and
  <name>.clauses.md. No house contract ships those in-dir, so the bug was
  latent; needs a one-line quoting fix in wire-cdt's CDTMacros.cmake.in.

Tests: all 7 upstream cases move to contracts/tests/sysio.swap_tests.cpp,
with badtoken and wevotethefee as contracts/test_contracts/. Fixture
changes forced by WIRE semantics, not by the contract:

- sysio.token hard-codes ram_payer = "sysio", which only a privileged
  contract may bill: every account hosting the token WASM (sysio.token,
  anothertoken, carol) is set_privileged, as sysio.token_tests does.
- WIRE requires {payer, sysio.payer} in the action authorization when a
  contract bills RAM to a user; inittoken's helper (the one place upstream
  pushed a bare actor list) now carries it explicitly.
- Every setup step asserts success(). Upstream ignored those results, which
  hid a real bug: memoexchange_test sends 0.0001 VOICE bob -> alice first,
  so many_transfer's 0.0903 VOICE deposit silently overdrew; it is 0.0902.
- indextable's hard-coded id_256 embeds the token account name; the
  eosio.token word is recomputed for sysio.token (verified independently).
- Tester API deltas: push_action takes no delay_sec; boost int256_t alias
  collided with a chain type; edump((ex)) has no fmt formatter.

contracts_unit_test (full binary, --sys-vm): No errors detected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: Ia097ef394c0b4312c03e1d1bd3ff4499f74d86cc
…ized-invariant tests

changefee accepted any int. A negative fee makes compute() subtract the fee
(the pool pays out more than constant product allows), and a fee at or above
100% can push compute()'s result past the int64 range it checks BEFORE the
fee is added. It now requires 0 <= fee <= MAX_FEE (FEE_DENOMINATOR - 1 =
9999); the 10000/9999 literals in compute() are the named FEE_DENOMINATOR.

Three test cases close the gaps the upstream suite left on the swap math:

- changefee_bounds: -1, 10000, INT_MIN, INT_MAX are rejected with state
  unchanged; 0, 9999 and every wevotethefee fee-vector value are accepted;
  at 9999 a swap still settles at exactly the spec quote.
- compute_rounding_table: at each of the 15 fee-vector values and 7 amounts
  (1 unit to 1,000,000 units), both swap directions and exact-output
  withdrawals are pinned to the unit against a reference written from the
  spec (pay rounds up, receive rounds down, fee rounds up): one unit past
  the quote is refused, the quote lands, pool and user deltas match. Unit-
  share add/remove rows cover the liquidity path the same way.
- invariants_under_random_sequences: a fixed-seed sequence of 400 ops over
  both pools and both users (swaps both ways, exact-output, add, remove,
  fee changes; log-uniform sizes from dust to full balance) asserts after
  every op that tokens are conserved and pool value per share never
  decreases, that any failure is one of the contract's own guards, and
  minimum success counts per op kind so the run cannot pass vacuously.

The three cases run in ~4 s combined. contracts_unit_test (full binary,
--sys-vm): No errors detected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: Ibb536d371e26f67b4a6377338b51f451d489436a
exchange now accepts exact-input swaps only: ext_asset_in must be
positive and min_expected nonnegative. The implied-argument mode that
let a negative ext_asset_in request an exact output amount is removed
along with its documentation in Commands.md and the ricardian clause.

Tests: the former exact-output success paths now assert rejection with
unchanged state, the rounding table drops its exact-output block, and
the randomized-invariant test replaces the exact-output op with a
negative-input rejection op (>=40 rejections per run).

Full contracts_unit_test green before commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: I4e46020619d0bc55d2e262dff755531e4fd92550
process_exch now takes its gross output from the equal-weight
constant-product path of sysio.opp.common/amm_math.hpp instead of the
negative branch of compute(). The pair fee is unchanged (rounded up,
via the new shared ceil_fee helper that compute() also uses), so every
quote is bit-identical to before; the guards on a zero input or an
empty pool keep their "invalid parameters" message.

Characterization coverage added for the substitution, all written
against the spec or the host-side amm_math model rather than the
contract's arithmetic: differential agreement with the model across
both pools, both directions, five fees and six amounts; fee-zero output
equals the bare kernel and is the largest integer that keeps x*y from
falling; round trips never profit; precision extremes with a one-unit
dust pool, a whale pool grown past the init ceiling and whole-balance
trades landing on the int64 asset ceiling; guard semantics through the
memo path with badtoken's zero-amount notifications; the ABI surface
pinned. sysio_swap_tests: 10 -> 16 cases.

Full contracts_unit_test green before commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: I8e9e53f80f4234a43e9f6136c2bdc5efeff0042a
The swap fee now comes from the depot-wide fee decomposition in
sysio.opp.common/amm_math.hpp: split_wire_fee(gross, fee, 0).net, with
no underwriter share because the fee stays in the pool for the
liquidity providers. That convention rounds the fee DOWN, where the
evolutiondex math rounded it up, so a quote nets one unit more whenever
gross*fee is not a multiple of 10000 -- and a one-unit quote is no
longer eaten by its own fee. The curve output is still floored, so the
pool product never falls. Liquidity pricing (compute, ceil + 0.01%)
is unchanged.

MAX_FEE keeps its 9999 bound with the new rationale (below 100% a
positive quote always nets a unit; split_wire_fee reports net 0 at
100%). The ricardian exchange clause and the README now state the
floor-fee rule; the clause had described the result as x + y.

Tests: the reference model splits into a floored swap fee and a
ceiled liquidity fee, the host model composes out_given_in with
split_wire_fee, and the pinned quotes in exchange_action,
increasing_poolvalue and memoexchange_test move by one to three units
(re-derived with an independent replay). The precision-extremes case
now pins the one-unit dust quote reaching the trader.

Full contracts_unit_test green before commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: I2bfa45ca5d12cd62312c018b86b6c1aa33e6e006
split_wire_fee rounds the fee down, which leaves any quote below
FEE_DENOMINATOR/fee units fee-free; at the 10 bps floor that is a
thousand units, and "units" is precision-relative, so a zero-decimal
token could trade a thousand whole tokens per swap without paying the
pool. Trades that also divide the curve exactly then leave x*y
untouched, and those amounts can be chosen deliberately.

process_exch now takes max(floored fee, MIN_SWAP_FEE) whenever both the
pair's rate and the gross quote are nonzero; a zero rate still charges
nothing. Every fee-bearing trade grows x*y again. Reference and model
in the tests carry the same rule, the pinned quotes move by one unit
where a swap's floored fee was zero, and a new case walks the boundary:
a 999-unit quote pays one unit, a 19956-unit quote is unaffected, a zero
rate charges nothing, and x*y strictly grows on the smallest trades.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: If2b6c93496278f3f25b55a335dab050effa03248
Every pair now keeps two 256-bit accumulators in a `priceaccum` row:
the running sum of each side's Q64.64 spot price (the other side's
balance over its own) times the microseconds that price held. They
advance at the spot price in force since `last_update` immediately
before the pools change -- swaps, adding and removing liquidity -- and
on a new permissionless `sync` action, so a reader can bring a snapshot
up to date without trading. A reader records the row at t0 and again at
t; (r - r0) / (t - t0) is the time-weighted average price over the
window. A trade that moves the spot price inside a block contributes
nothing until time passes at the moved price.

The kernel lives in sysio.opp.common/twap.hpp: integer only, host-
compilable, with the 256-bit add (both carries), modular difference,
and the 256-by-64 division that recovers the average. A Q64.64 price of
int64 balances is below 2^126 and elapsed fits 64 bits, so the sum
cannot wrap within the life of any chain. The row's limb type is spelled
`uint128_t` so the ABI generator emits the `uint128` builtin.

Tests: a host suite (twap_tests) checks the kernel against boost 256-bit
arithmetic, including every carry and borrow path and 200 random
multi-step windows; contract cases follow the accumulators through
swap, add, remove and sync and recover the reader's average, show two
trades in one transaction add nothing for the second, and hold the
steepest int64 price for a week past 128 bits. The ABI pin gains the
`sync` action, the `priceaccum` table and the two new structs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: If0889e91e920c6a8e6ff305bfe78da1a31ea8891
The five tables leave the multi_index compatibility wrapper for the
native KV API, with explicit key structs and payer-first mutations:

  accounts    scoped_table by owner, key = symbol code (bytes unchanged,
              so wevotethefee's foreign multi_index read still works)
  evodexacnts scoped_table by user, keyed by the deposited token's
              extended symbol -- the surrogate id, available_primary_key,
              make128key and the uint128 secondary index all go; a
              deposit lookup is one primary get
  stat        unscoped table keyed by the pair's symbol code (the old
              scope duplicated the primary key)
  evoindex    unscoped table keyed by the pair identity itself, the two
              legs in canonical (lower contract, symbol first) order --
              the checksum256 hash, make256key and its secondary index
              all go; a duplicate pair is a plain emplace conflict
  priceaccum  unscoped table keyed by the pair's symbol code; the
              redundant `pair` field leaves the row

Table entries in the ABI now carry key_names/key_types/table_id, so an
explorer can decode the composite keys as named fields. The test
fixture reads deposits by extended symbol through a raw KV reader for
composite keys, the indextable case pins the canonical four-word key
(and the absence of the reversed one), and the ABI pin covers every
table's row type and key layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: Ibce6a77e0da625217537923ab2fe23a7a8f42b4f
…thority

The account allowed to change a pair's fee was wired to the upstream
wevotethefee voting contract: inittoken accepted no other name and no
initial fee but wevotethefee's 10 bps floor, and every LP-token
transfer, add and remove notified it so it could weigh votes by stake.
WIRE governance executes approved proposals as sysio, so the fee
authority is now a plain permission holder:

  * a `swapconfig` singleton, set at deployment by `setconfig` under
    the contract's own authority, names the contract-wide fee
    authority (sysio);
  * inittoken takes `fee_authority`: an empty name adopts the
    configured one, any other name becomes that pair's own -- pair
    creation already needs the contract's authority, so an override
    cannot be planted by an arbitrary caller;
  * `initial_fee` may be anything in [0, MAX_FEE];
  * changefee requires the pair's stored authority, as before;
  * the pair row's `fee_contract` is renamed `fee_authority`, and the
    require_recipient notifications go with the voter they served.

The wevotethefee test contract, its CMake and fixture hooks, its test
case and its fixture helpers are removed; badtoken stays for the guard
tests. New cases cover the unconfigured state (pair creation needs
either a configured or a named authority), per-pair authority
isolation, reconfiguration binding only pairs created afterwards, and
the open fee range at creation. The ABI pin gains setconfig, the
swapconfig singleton and the renamed field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: I495df05e030b7fe0b5b349e48bedc31215da307b
…share lock

inittoken minted sqrt(pool1 * pool2) through the floating sqrt, which
the consensus rules forbid: its rounding is platform-dependent. The
seed supply now comes from amm::geometric_mean, an integer Newton
floor(sqrt(x*y)) added to sysio.opp.common/amm_math.hpp, so every node
mints the same share count on every input.

inittoken also takes `locked_shares`: part of the minted supply that
is credited to no account and can never be redeemed, the Uniswap v2
minimum-liquidity idea with the size left to the pair's creator. The
lock keeps the pool from ever being emptied and re-seeded at another
unit, and bounds how far one share's value can be pushed. Seed-time
attacks only victimise the creator, so the size is the creator's
decision and zero is allowed; the creator must keep at least one
share. The pair row records the lock, and remliquidity refuses to take
supply below it (ownership already makes that unreachable; the check
is defense in depth beside "the pool cannot be left empty").

Tests: host cases pin isqrt across the int128 range (exact around
20000 random perfect squares and at the top of the range) and the
geometric mean, the fixture pins the exact integer seed supply of both
standard pools, and a contract case walks the lock: the three
validation failures, the creator holding minted-less-locked, removing
every creator share leaving the locked slice of the pools behind, and
the pool trading and growing again from that floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: I6edefc852f4653bacbbd50d728e8f5399693b92c
indexpair back-filled the pair-uniqueness index for pairs that upstream
had created before the index existed. Every pair here is indexed by
inittoken at creation and there are no pre-index pairs to migrate, so
the action could only ever fail with "the pool is already indexed". The
evoindex table and the placeindex helper stay: they are what enforce
one pool per token pair.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: Ib1ade4a5ac33c23c5f6da56a2777e2fa5fca1127
safe.hpp was evolutiondex's copy of the old fc::safe<T> checked-integer
wrapper, 221 lines whose only consumer was the digit accumulation in
asset_from_string, the parser for the min_expected asset of an
"exchange:" memo. The parser now guards the same three arithmetic steps
with __builtin_mul_overflow / __builtin_add_overflow (deterministic on
WASM) and the header is gone. The parse also loses a dead branch: it
negated the fraction on a leading '-', but the digit loop rejects a
sign before it could run, so memo amounts have always been unsigned
magnitudes, which they should be.

Tests pin the overflow guard on the memo path: a digit string past
int64, the last digit that overflows, a scaled integer part that
overflows, one that fits int64 but not an asset (the asset's own
magnitude refusal), more than 18 decimals, a sign, a bare decimal
point and a missing separator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: I617c96039bc40d06a1f7cb299f75be8f616527c8
A pair may now name one of its legs as its shadow token, which makes it
a yield pool; an empty yield leg is a plain pool with unchanged
behaviour. A `yieldpairs` table keyed by the shadow's extended symbol
enforces at most one yield pool per shadow symbol and doubles as the
symbol-to-pair lookup the yield paths will use. The pair row gains the
tick parameters (`conversion_horizon_sec`, `depth_cap_bps`, `last_tick`),
set by the pair's fee authority through the new `setyield` action; both
must be nonzero before a tick can run.

Tests cover leg validation (must be one of the pair's legs, contract
included), the uniqueness rule against a second pool over the same
shadow, a plain pool alongside, and setyield's authority, target and
range checks. The ABI pin gains the new fields, action and table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: Idefdd2d7a469a6da7366f1c4f905af8fc8285df5
A yield pool's shadow token pays WIRE yield to its holders through a
cumulative index, and the contract, holding the pool's shadow, is such a
holder. `sysio.opp.common/shadow_yield.hpp` is the read contract between
the token and its holders: table names, row layouts, the index scale, the
`owed` formula, and the two typed actions a holder calls (`claim`,
`addyield`). The swap reads the token's tables in place through aliases
local to its implementation file (an alias inside the contract class makes
the ABI generator list the table as the contract's own).

`accrue` computes what the contract is owed from that public state,
credits it to the pool's other leg with no shares minted, records a
receipt keyed by the shadow contract, and calls the token's `claim`
inline; `ontransfer` matches the delivering transfer against the receipt
and retires it, failing on any other amount. A receipt that outlives its
transaction blocks further accrual through that contract. Accrual runs
before every mint and burn (`add_signed_liq`), so yield always belongs to
the shares that existed when it was earned, and the new `accrueyield`
action lets anyone settle in between. A plain token in a yield leg has no
index row and reads as owing nothing. Deposit accounts are not
yield-bearing: the contract's whole holding earns for the pool.

`contracts/test_contracts/shadowtoken` is the mock shadow token the suite
drives: sysio.token's shape plus the distribution, settling every balance
move, stamping new holders at the current index, and carrying the
truncation remainder. Tests cover a single and a chained distribution
against a hand-written reference, exactness of the delivered payout, the
mint and burn pricing against the accrued pool, refusals, and the payout
route being live only while a receipt exists. Docs and the ABI pin gain
the yield leg, setyield and accrueyield.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: Ic18f2d01bd283719a4c041bef139c8bd6cb90d3b
A yield pool gains a reservoir (`reservoirs` table, keyed by the pair,
created at inittoken): shadow queued to be sold through the pool, held by
the contract but in no pool and no deposit. It is its own row rather than
a deposit row under a synthetic owner, so no account name can alias it.

`fundyield(from, pair_token, quantity)` is the typed, memo-free
announcement: `from` names the pair and the exact amount of its shadow it
will transfer, and the transfer that matches (same contract, symbol and
amount) fills the reservoir instead of `from`'s deposit, in the same
transaction or a later one. One announcement per account is pending at a
time, a new one replaces it, and while one is pending any other transfer
from that account is refused. Both receipt routes in `ontransfer` now
compare field-wise: asset's own == asserts on a symbol mismatch, which
here must be an ordinary "does not match" failure.

Tests cover the refusals, the pending state across transactions, the
replacement, the one-transaction shape a contract would use inline, and
that the reservoir earns yield for the pool like the rest of the
contract's holding while accrual leaves the queue alone. Docs and the ABI
pin gain fundyield, the receipt table and the reservoir.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: Ia754e0e2fbd8cb36c10d6cfd2a9430ae90a06ea0
`tickyield(pair_token)` sells one clip of a yield pool's reservoir through
the pool and hands the proceeds to the shadow's holders. The clip is the
reservoir's share of the horizon elapsed since the pair's clock last
advanced, rounded up so a nonempty reservoir always drains, capped by
`depth_cap_bps` of the pool's shadow side and by what is queued; it is
sold at the pool's own curve and fee (the fee stays with the providers)
after the pool's owed yield has been settled, and the proceeds go out
through the token's `addyield`, so the pool, a holder, takes its share
back on the next accrual. An empty reservoir, no elapsed time, or a clip
that rounds to nothing is a no-op. Ticking more often does not sell
faster: a second tick in the same block does nothing.

The clock (`last_tick`) advances on every tick that sells, restarts on
`setyield`, and restarts when a funding fills a reservoir that was empty,
so a queue sells over a fresh horizon from the moment it is funded rather
than dumping against a stale timestamp. The token moves the proceeds out
of the contract under the contract's authority, so deployment grants the
shadow token's sysio.code seat on the contract's active permission; the
fixture does the same (keeping the contract's own seat).

The test drives the refusals, the empty no-op, the clock restarts, one
clip against the hand-written pacing and curve references with the index
advancing by the proceeds, the fee retained, the proceeds returning on the
next accrual, same-block pounding, the cap after a long gap, a full drain
with the cap lifted, and accrual-before-trade ordering. Docs cover the
tick and the deployment steps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: I0dbbca1bfbf5745337494583c7f6216c01416dee
…r is pinned

`setconfig` now also names the system token (WIRE), and `inittoken`
requires every pair's second leg to be it; the first leg is the pair's own
token, and since pairs are unique there is exactly one pair per token. That
orientation lets `ontransfer` recognise a transfer without a table of its
own: a token is accepted if it is the system token, or if the pair it
forms with the system token exists, one lookup in the pair index. Anything
else is refused, a look-alike symbol from another contract included, which
is what the evolutiondex `badtoken` surface was about. A first leg's seed
lands before its pair exists, so a transfer carrying the contract's own
authority, the authority that creates pairs, is accepted regardless.
Nothing works before `setconfig`: no deposit, no pair.

A yield leg must now be the first leg, and the `yieldpairs` table is gone:
one pair per first leg already makes one yield pool per shadow, and the
pair index is the lookup.

The tests configure EOS on sysio.token as the system token, so every
upstream pair flips to (token, EOS) and the seed deposits of VOICE, TUSD,
SHD and badtoken's EOS carry the contract's authority; pool-index
expectations and the accumulator direction checks follow. New coverage:
the refusal of an unpaired token with and without the authority, the
second-leg rule in both orders and against a look-alike contract, the
pair token itself being undepositable, and the unconfigured contract
refusing everything until setconfig runs. Docs and the ABI pin follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: I51e3337c65026a8977fa96a97e26329af8076192
…contract's authority

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Change-Id: If0417cbd720c561cf628797dfa85206b873b2b81
`sysio.swap::compute` held the last piece of the pool model that had not
reached the shared kernel. The curve (`out_given_in`) and the seed
(`geometric_mean`) were already there; what a share is worth against a
pool side was not, because the header's original consumers, sysio.reserv
and sysio.uwrit, have no tokenized pool share and never needed it.

`in_given_shares` and `out_given_shares` are that slice, named off
`out_given_in` and differing only in rounding: minting rounds up, burning
rounds down, both the pool's way, which is why a mint-then-burn round
trip cannot profit. They return `u128` because the value genuinely
exceeds 64 bits (two 62-bit balances over a supply of one reaches about
2^124), so bounding it is the caller's job. The unsigned ceiling is
written `(prod + supply - 1) / supply` rather than the contract's
`1 + (prod - 1) / z`, which would wrap on a zero product.

`compute` stays where it is and keeps everything the kernel deliberately
does not do: the input guard, the sign that selects direction, the
overflow and underflow bounds, and the liquidity fee. The bounds still
run before the fee is added, as before. The kernel stays free of contract
intrinsics and host-testable, which is the property that would have been
lost by moving the `check()` calls with it.

Behaviour is unchanged, and the swap suite's rounding table is what pins
that: it computes every expectation from the hand-written spec rather
than the contract. The ABI does not move. Four host cases cover the
rounding directions, the exact-division agreement, dust in both
directions, degenerate inputs, the result past 64 bits, and a random grid
asserting each side is tight against the true quotient.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: Ibb36013965b1d7aa86bc572a695a01f6a623f0a6
The swap suite's two test contracts compiled only under
BUILD_SYSTEM_CONTRACTS and committed nothing, so their wasm existed
only on a machine that had built them. CI sets that flag OFF for every
build that is not a version tag and compiles no contract at all, which
left `contracts.hpp.in`'s embed with nothing to read: all 28 swap cases
died in the fixture constructor on "wasm file cannot be found", on both
the asan and asserton jobs.

Every other contract here already handles this through
`bootstrap_contract`, which copies the committed artifact into the build
tree and, when contracts ARE being built, leaves the freshly compiled
one alone. Both test contracts now use it, with their wasm and abi
committed alongside, matching what sysio.swap and the rest do. Local
behaviour is unchanged: add_contract still compiles both as a
build-time check and overwrites the copy, so a change to either source
means rebuilding and re-committing the artifact, which the comment on
each file now says.

Verified by configuring the contracts tree standalone with
BUILD_SYSTEM_CONTRACTS=OFF, as a PR build does: all four artifacts land
in the build tree, where before the change those CMakeLists were inert
and produced nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I93891438d64d85ee05a88e11156f3d6a2b704855
The tick rounded the clip UP so a nonempty reservoir always drained. That
guaranteed a sale every tick, which had two consequences. A one-unit clip
returns nothing on the curve whenever the WIRE side does not exceed the
shadow side, and MIN_SWAP_FEE takes the rest up to an output of two, so
across the whole below-parity range the clip moved into the pool and paid
its holders zero: the reservoir drained to the liquidity providers a unit
per block. It also made the elapsed-scaling unreachable, since the clock
advanced on every tick and the window reset to one block forever, which
is the opposite of the design's "pounding it yields zero-size no-ops".

The clip is now floored, and `setyield` takes a third parameter: the
least a clip may be. A clip short of `min(clip_floor, queued)` sells
nothing and, the part that matters, leaves `last_tick` alone, so the
interval is banked and the next tick offers a proportionally larger clip.
Throughput is unchanged, because waiting N times as long sells N times as
much; only the granularity is coarser. Yielding to `queued` keeps a
remainder below the floor from stranding: it leaves as one sale once the
time share reaches the whole queue, exactly one horizon later.

The floor is in units of the shadow, so it is precision-relative and
cannot be a constant. Size it where the pair's fee on a clip's output
reaches a whole unit on its own, an output of FEE_DENOMINATOR/fee, since
below that MIN_SWAP_FEE is the binding fee and a clip pays far above the
pair's rate. Erring high is free; erring above the depth cap stops the
pair selling, which is the one combination with no way out and is
documented as such.

A new case pins all of it: a floor above a block's share makes ten
consecutive cranks no-ops that move nothing and hold the clock, the
banked time then sells in one piece that actually pays, dust clears one
horizon after funding, and a cap under the floor stalls until setyield
widens one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I2b9ad271a61074acebc236cb0eb21061f97546a4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I30e4047742f37e623d7ecefe1afd5eb78451c18f
process_exch priced against the stored pool while accrueyield is
permissionless, so one transaction could buy the shadow, settle, and sell
it back, taking w·Y/(W+w) of the pending yield off the liquidity
providers. It clears the round-trip fee once the pending yield passes
about twice the fee rate against the WIRE side, which a pair reaches
between ticks.

The accrual now happens in process_exch rather than in exchange, so the
memo route is covered by the same change, and a trade is always priced
against a settled pool.

tickyield no longer accrues for itself, because accrue must run at most
once per action: its inline claim settles the token's row only after it
returns, so a second call reads the same owed amount and collides with
the receipt the first one left. That constraint is now on accrue's
declaration. Nothing tickyield reads moves under accrual, which credits
the other leg while the clip is measured against the shadow side.

Covered by a new case, the first to swap against a yield pool at all: a
buy prices against the pool including the owed yield and is refused at
the stale quote, and the buy, settle, sell round trip returns less WIRE
than it spent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I34195d121b9c9c557872a0d33b57c91d88931367
The cap was taken against the shadow side as it stood, which a caller
sets inside the same transaction by selling shadow into the pool. That is
the same move that makes a clip worth sandwiching, so the bound scaled
with the attack it exists to bound: the clip grew with the injection, the
ratio the cap fixes stayed put, and sandwich profit went from
self-limiting to linear in the attacker's position.

The pair now records its shadow side at each setyield and each selling
tick, and the cap is taken against the smaller of that and the current
side. In-transaction inflation cannot widen it, a genuine shrink tightens
it at once, and genuine growth takes effect one tick later. setyield
always precedes a tick, so the recorded depth is never zero when it is
read.

The cap's sizing rule is now on setyield: keep depth_cap_bps below about
twice the pair's fee, which is where a clip's price impact stops covering
the round-trip fee a sandwicher pays on their own position. That is what
makes the cap self-enforcing and is the argument the plan relies on in
§09 when it leaves the band guard optional.

The new case doubles the shadow side, then ticks, and pins the clip to
the cap implied by the honest depth, which is half what the inflated side
would have allowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I2e870488d98e0597d0cb076bdff41fb5ceaed2f8
fundyield billed the pending row to the contract and only the exact
matching transfer erased it, so an announcement nobody delivers sat on
sysio.swap's RAM forever, one per account that ever called the action and
no way to reclaim any of them. The row is now billed to `from`, which
puts the cost on whoever creates it and makes the accumulation
self-limiting; the action therefore carries `{from, sysio.payer}`
alongside its active permission, as inittoken already does.

`cancelyield(from)` drops a pending announcement and refunds the row.
That also fixes the state it left a funder in: while an announcement is
pending every other transfer from them is refused, so a funder who
changed their mind or named an amount they cannot send had no way back
except delivering it.

Covered by a case that watches both RAM balances across the
announcement, refuses a cancel signed by anyone else, refuses a cancel
with nothing pending, and checks ordinary deposits work again afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: Id0c32ce6e36a7cd580d99396d95cba8969cf5c49
Declared on the contract class and never defined or called, carried over
from upstream. The memo path parses through utils.hpp's
asset_from_string. Sweeping the rest of the class turned up no other
declaration without a definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I13fe1db5c43318cba72926c13f38d84b574a3643
The clip floor is in shadow units, so the condition it stands in for --
that a clip's output is large enough for the pair's proportional fee to
reach a whole unit -- it reaches only through the pool price and the
precision gap between the two legs. Both move: the price with every
trade, and the fee itself with changefee, after setyield has already
fixed the floor. Below FEE_DENOMINATOR/fee units of output the
proportional fee floors to zero and MIN_SWAP_FEE binds instead, so the
clip pays far above the pair's rate -- an output of 2 pays half of itself
-- out of the holders' distribution.

tickyield now quotes the clip and declines it when the output falls short
of that threshold, which is stated in output units, the one place the
condition does not move with the price. The quote is taken against the
pool before accrual, and accrual only ever raises the other leg, so a
clip that clears it here clears it on the sale.

Declining banks the elapsed time exactly as the clip floor's own skip
does, so throughput is unchanged and a crank that finds nothing to do
pays nothing for the attempt. Both gates return rather than assert,
because a tick that asserted every block would accrue subjective CPU
against the crank until its node stopped accepting the ticks that do
clear; a pair that has stopped selling shows instead as a non-empty
reservoir whose last_tick is not advancing. The remainder drain is
exempt: when the whole queue is under the floor it is dust by
construction, and stranding it forever is worse than selling it at a poor
rate.

process_exch's quote math moves to a pure quote_out so the tick can ask
what a clip would fetch without moving anything, and clip_floor's
documentation now describes it as the sale granularity it is rather than
the fee-rate guarantee it cannot be.

The new case drops the fee to 0.01%, which puts the fee-bearing output an
order of magnitude above the clip floor, and pins the tick declining
while the clip clears the floor alone, then selling in one piece measured
from the original clock once the banked time carries the output over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I78597c27d6215ca27f8dc7427a5dc6dc4761749c
A uint64 index wraps once cumulative yield per shadow subunit passes ~1.8e7,
which a thinly held symbol reaches on one add. shadow_yield.hpp now carries
index and index_checkpoint as the ABI builtin uint128, owed() computes in 128
bits and checks the asset range, and the swap suite pins a supply-of-one add
past 2^64. shadowtoken and sysio.swap rebuilt against the header.

Change-Id: I167048937af536c89b2f6551682b02457a620a1d
sysio.liq shadows each outpost's liq token 1:1. SYNDICATE_LIQ credits a
linked user or parks an unlinked pubkey; LIQ_YIELD lands in a pending
balance that queueyield hands to the swap's reservoir; the WIRE that
tickyield sells it for distributes through a uint128 cumulative index,
with a T5 kicker drawn through fundclaim(recipient, amount); claim pays
owed(row, index); desyndicate burns and queues DESYNDICATE_LIQ.

sysio.msgch routes both inbound types behind the active-liq-token gate
and never aborts an envelope: a malformed op_address now misses the
authex lookup instead of aborting inside pubkey_to_checksum256, which
the OPERATOR_ACTION path was exposed to as well.

The bootstrap config gains liq_pools and syndications, replayed by
regliqpool and importsynd inside the epoch-0 window; the liq suite
replays the dev config end to end.

Change-Id: I74b2101f74fdfebfef5f324eaaaa8e5acef114a7
batch_operator_plugin pushes sysio.swap::tickyield per yield pool with
a queued reservoir, spaced per pool by --batch-yield-tick-interval-ms,
and sysio.liq::queueyield per shadow with pending yield; both stay idle
until sysio.swap and sysio.liq are deployed, so an undeployed contract
never logs a failed table read every poll.

outpost_solana_client carries the DESYNDICATE_LIQ effect shape, the
handler's remaining accounts derived from the user's pubkey, the pool
PDAs and the mint on DistributionState, and cranks report_liq_yield
once per epoch after the elected operator's delivery, PostLaunch only.
outpost_client gains crank_outpost for that; the Ethereum relay keeps
the no-op default.

Change-Id: If43f669eeb6b16c57a0a627d9e5994fafabdc9b6
The other exit: a holder deposits sysio.liq shadow on the swap, exchanges
it for WIRE and withdraws it to their wallet, with both legs conserved
exactly. The test support header gains payer_authorization for actions an
unprivileged contract bills to the user.

Change-Id: I3f0ac133a4641eb04ee6a698151b118e46c0a0b7
…laiming

Change-Id: Iebec3be70b839c09208eb29510ace6c0e2dde7f6

# Conflicts:
#	contracts/sysio.msgch/sysio.msgch.wasm
#	contracts/sysio.system/sysio.system.wasm
#	contracts/tests/sysio.dispatch_tests.cpp
…laiming

Change-Id: I01810fd22f108281fa3ea44791342b380a3e7e81

# Conflicts:
#	contracts/sysio.dclaim/sysio.dclaim.wasm
#	contracts/sysio.msgch/sysio.msgch.wasm
#	contracts/tests/sysio.dispatch_tests.cpp
….liq

createlink sends sysio.liq::linkswept inline once sysio.liq is deployed and
privileged, so a syndication parked against an unlinked key is delivered the
moment the key links (the hook deferred until #594 landed).

The shadowtoken stand-in is gone: the swap suite deploys the real sysio.liq,
and its yield tests run on a fixture whose system token is 9,WIRE (the contract
takes yield and pays claims in WIRE, and a pair's second leg is the system
token), so SHEO is SHD/WIRE at precision 6. The AMM tests keep upstream's EOS
fixture. recordlink carries native_address (#594) in the liq tests.

Change-Id: Ie3dbb061257a402a2ee0ef475131728baedd7b34
std::string{ CLAIM_MEMO })).send();
}

void liq::addyield(name from, asset quantity, symbol_code target) {

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.

[P1] Restrict the kicker-bearing intake to a trusted source. This action only requires from, but every caller can make sysio.liq invoke fundclaim against T5. If the caller owns all of the target supply, donating Q credits that caller Q plus the 2% kicker; a subsequent claim returns the donation and captures 0.02Q from the treasury. Please require sysio.swap as the source for this path, or split permissionless donations into an action that does not request a kicker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gated in f043096: addyield requests the kicker only when from is sysio.swap — the one intake that is yield (tickyield's proceeds). Donations stay permissionless and distribute themselves alone, so the treasury is never drawn on a self-donation. The kicker unit test now donates as the swap and pins that a third-party donation carries no fundclaim; README, header doc and EMISSIONS.md say the same.


// Minted to this contract and handed on in the same transaction, so its row is
// settled at one index and accrues nothing on the way through.
check(mint(sym, static_cast<uint64_t>(quantity.amount)), "supply exceeds the asset range");

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.

[P1] Keep reported yield out of circulation until Solana accounts for it as redeemable stake. On the current companion SHA (11c88c0), apply_yield raises yield_accumulated_liqsol and expected_pool_balance but not total_staked_liqsol; handle_desyndicate_liq refuses a payout once that principal counter is below the requested amount. Minting the reported yield here therefore creates transferable shadow that cannot all be redeemed: after aggregate exits consume the original principal, the remaining yield-backed shadow burns on WIRE and is logged unpaid even though the pool holds the yield. The PR body acknowledges the companion gap, but this path has no activation guard. Please land the Solana counter update first or gate queueyield until it is active.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The fix is on the outpost: when report_liq_yield advances its watermark, the reported delta is added to total_staked_liqsol, so the outpost's redeemable total tracks the shadow supply the depot mints for it. That lands on the wire-solana companion (@valthon). The depot mints exactly what the outpost reports and stays as is.

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.

Rechecked against current wire-solana/next at cbf721887b1e (fetched 2026-09-23). GlobalState::apply_yield still updates the index, yield_accumulated_liqsol, and expected_pool_balance without incrementing total_staked_liqsol; the desyndication gate still refuses once that principal counter is exhausted. I also could not find an open companion PR containing the fold. This remains unresolved for this review head. Please link and land the companion fix before activation, or add a depot-side gate and an explicit hard deployment precondition.

"connector_weight_bps": 5000, "is_private": false, "owner": "" }
],
"liq_pools": [
{ "chain_code": "ETHEREUM", "token_code": "LIQETH", "pair_symbol": "LIQETHP",

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.

[P1] Do not bootstrap LIQETH before Ethereum has LIQ handlers. The current e2e Ethereum branch (16e5e61) has no DESYNDICATE_LIQ handler or LIQ yield reporter, and OPPInbound irreversibly skips unregistered attestation types. Because sysio.liq::desyndicate burns before queuing the effect, every LIQETH outpost exit is dropped and requires governance recredit; the base outpost crank is also a no-op, so LIQETH never reports yield. Please remove or gate this LIQETH activation until the Ethereum handler and reporting path are deployed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The Ethereum side is wire-ethereum #207 (@qhool): SyndicationPool emits SYNDICATE_LIQ and LIQ_YIELD and registers an inline, never-reverting DESYNDICATE_LIQ handler with once-only settlement. The e2e ran on wne-41_next-fix only because tools master needs #205, and that branch predates #207. The dev config stays as it is: it is the dev cluster's, and the launch template carries no liq rows. One piece is in neither PR: realizeYield is access-restricted, so the batch operators will crank it through outpost_ethereum_client::crank_outpost, the counterpart of the Solana client's report_liq_yield crank, as a follow-up to this PR.

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.

Rechecked the cited Ethereum work. wire-ethereum #207 is still a draft at 2cd51ab, its body leaves live upgrade/deploy wiring out of scope, and this thread confirms the access-restricted realizeYield node crank is still a later follow-up. The current e2e branch predates #207, while this PR still bootstraps LIQETH in the dev config. Please remove/gate that activation or make the handler, upgrade wiring, and crank concrete prerequisites before this flow is enabled. I am keeping this finding open.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The crank is in this PR after all, in fea8449: outpost_ethereum_client::crank_outpost sends realizeYield on the pool the outpost registers as its DESYNDICATE_LIQ handler (OPPInbound.attestationHandlers), so nothing has to name the pool; it stays idle until #207 deploys one and grants the relay's signer yield_operator.

Comment thread contracts/sysio.liq/src/sysio.liq.cpp Outdated
drop(path, "amount out of range");
return std::nullopt;
}
if (amount > static_cast<uint64_t>(asset::max_amount - st->supply.amount)) {

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.

[P2] Include existing pending yield in the supply-headroom invariant. This per-report check ignores liqpending, so individually valid reports can accumulate beyond the remaining mintable supply. A focused reproduction with supply at asset::max_amount - 100 accepts two reports of 60, leaves 120 pending, and then every queueyield attempt aborts while transaction rollback restores the same pending row. The saturation at lines 189-193 can also consume a sequence while discarding excess reported value. Please reserve pending amounts in every supply-growing path, or let queueyield mint available headroom and retain the remainder.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 16457e7: one headroom, the asset range net of supply and of what is pending, bounds every supply-growing path (mint and resolve_inbound), so queueyield cannot fail once the row is erased first. mintyield no longer saturates: a report past the headroom is dropped before its sequence is consumed, so a later report still admits. Pinned by pending_yield_is_reserved_against_the_asset_range: your two-reports-of-60 shape with the second dropped and the cursor unchanged, mintsynd and recredit refused while pending fills the range, and queueyield minting to exactly the maximum.

return;
}
const uint64_t sequence = synd.sequence;
const name account = resolve_account_from_op_address(synd.user);

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.

[P2] Validate synd.user.kind against the proven source chain before resolving AuthX. A valid wrong-family key that is already linked resolves an account here and proceeds through mintsynd, which receives no ChainKind and credits it. The same payload is rejected only when the key is unlinked, because that branch reaches park and its chain-kind check. Please compare the user kind with the registered kind of chain_code_slug before this lookup, or pass the kind through to mintsynd and enforce it there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8803bfa: dispatch_syndicate_liq now drops a user whose key family is not the proven outpost's registered kind, before the AuthX lookup, so a linked key of another family is refused exactly like an unlinked one. Same never-throw shape as the chain-binding check beside it. Pinned by syndicate_liq_refuses_a_key_of_another_chain_family: a linked and an unlinked Solana key in an Ethereum envelope are both dropped with nothing parked and no sequence consumed, and the EVM credit beside them lands.

addyield is permissionless, and the kicker rode on every intake: a holder of
most of a shadow's supply could donate, claim it back with the treasury's 2% on
top, and repeat. Only the swap's intake (tickyield's proceeds) is yield, so only
it requests fundclaim now; a donation distributes itself alone. Review P1 on

Change-Id: Ia0de4e049188ece04fb1c6ed96bbfb9a48f18a4c
#634.
The range check on every supply-growing path read supply alone, so reports
that each fit could together exceed what queueyield can mint, and its
assertion then wedged the crank for that symbol for good. One headroom, the
range net of supply and of what is pending, now bounds every mint and every
intake; mintyield drops a report past it before consuming the sequence
instead of clipping it after. Review P2 on #634.

Change-Id: I5bdcabb7b57c2920004391bfa75e1d720225612d
The dispatch checked the payload's chain against the proven outpost and the
token, then resolved the user's key through AuthX without comparing its
family with the outpost's: a linked key of another family was credited by
mintsynd while an unlinked one was refused by park. The family is now part
of the provenance check, before the lookup. Review P2 on #634.

Change-Id: If76a162d3144850cccabc609a3463b34b2d979cd
The Ethereum counterpart of the Solana relay's report_liq_yield crank. Once
per epoch, after the delivery lands, the relay reads the pool's address off
the outpost itself, OPPInbound.attestationHandlers(DESYNDICATE_LIQ), which
wire-ethereum #207's SyndicationPool registers itself under, binds a wrapper
to it, and sends realizeYield. No handler, or an ABI set without the pool,
leaves the crank idle; the pool's own refusals (no yield, below the deadband,
underbacked) are outcomes, anything else is a failed crank for the job to log.

Change-Id: Iea3551beab38bf84f6d4cf5fcb4b5e4dc47bcec1

@huangminghuang huangminghuang 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.

Reviewed current head fea8449. The earlier sysio findings are addressed, including the Ethereum realizeYield crank. The remaining Solana accounting change and Ethereum contract/deployment work are companion-repository activation dependencies; this head remains backward-compatible and idles when the pool ABI or handler is absent. No blocking sysio findings.

@heifner
heifner merged commit 4a20f64 into master Sep 24, 2026
14 checks passed
@heifner
heifner deleted the feature/liq-yield-claiming branch September 24, 2026 12:36
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