Skip to content

Reconcile treasury fee totals and expose reconciliation read APIs - #1096

Merged
IbrahimIjai merged 18 commits into
SO4-Markets:mainfrom
toluwalasedaniel459-cmyk:feat/treasury-fee-reconciliation
Oct 21, 2026
Merged

IbrahimIjai merged 18 commits into
SO4-Markets:mainfrom
toluwalasedaniel459-cmyk:feat/treasury-fee-reconciliation

Conversation

@toluwalasedaniel459-cmyk

@toluwalasedaniel459-cmyk toluwalasedaniel459-cmyk commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #971

Stacked on #1093 (#970) → #1092 (#969). This branch builds on feat/per-market-fee-attribution, so its diff against main includes those PRs' commits. The new commits here are 7670d11, 97129dd, 11afb6a and 094fe9f, plus 2834081 (merge of the main sync from #1092/#1093). Please merge #1092 and #1093 first.

⚠️ Merging this PR restores the codebase #1090 deleted. #1090 (280f268) removed 765 files from main, and #1083, #1089 and #1084 were merged on top of that tree. To make these PRs mergeable, main was merged into #1092's branch (and from there into #1093's and this one), keeping the full tree. This PR therefore re-adds every file #1090 deleted (about 1.17M lines, identical to main at 5436392) and keeps the 6 files main added since:
libs/clob_settlement_core/src/lib.rs, contracts/reader/src/clob_reads.rs, tests/reentrancy_token.rs, docs/REENTRANCY_ANALYSIS.md, docs/clob-read-api-specification.md and docs/market-data-streaming.md. None of these 6 are compiled yet: there is no Cargo.toml, mod declaration or test registration for them.

To review only this PR's own changes, diff against 5436392, the last main commit before #1090.

CI: main's .github/workflows were also deleted by #1090, so no checks run on this PR upstream. They were run on the fork instead (see Verification).

Summary

Adds a global per-token fee ledger, total_claimable_fee_amount(token), kept equal to the sum of the per-market sub-ledgers claimable_fee_amount(market, token). Also adds read APIs for treasury reconciliation.

The total always equals the sum of the sub-ledgers:

  • Accrual: all three settlement paths (increase position, decrease position, swap) accrue through one helper, gmx_market_utils::apply_delta_to_claimable_fee_amount. It moves the per-market entry and the global total in the same call. fee_handler::record_fee_accrual does the same.
  • Claims: claim_fees and claim_token_fees release the transferred amount from the global total in the same call as the per-market entry. A claim the global ledger does not cover reverts with FeeLedgerMismatch (#12 — Add upgrade entrypoint to adl_handler #12).

Aggregate withdrawal cannot exceed summed accrued balances:

  • Each per-market claim is bounded by its own ledger.
  • claim_token_fees also checks its total against the listed markets' summed accrued balances and against the global total, reverting with ExceedsAccruedFees (#13 — Add upgrade entrypoint to fee_handler #13).

Read APIs (fee_handler):

Entrypoint Auth Returns
total_claimable_fees(token) view The global total
reconcile_token_fees(token, markets) view FeeReconciliation (see below)

FeeReconciliation has the fields token, ledger_total, markets_total, reconciled and markets. Each markets entry is a MarketFeeReconciliation { market, claimable, pool_amount, custody, backed }.

  • backed means claimable <= pool_amount and claimable <= custody.
  • reconciled means every market is backed and markets_total == ledger_total.
  • A market missing from the list shows up as ledger_total > markets_total.

Upgrade migration:

  • backfill_fee_total(token, markets) (admin only) adds pre-upgrade per-market ledgers into the global total, at most once per (market, token), and emits FeeTotalBackfilled.
  • docs/deployment.md gives the order:
    1. Upgrade fee_handler.
    2. Backfill.
    3. Upgrade order_handler and liquidation_handler. adl_handler is not upgradeable and must be redeployed.
    4. Check reconcile_token_fees reports reconciled: true.
  • Until backfill runs, claims of pre-upgrade fees revert. That is fail-safe: nothing can be over-withdrawn.

All new calls reject duplicate markets and are bounded by MAX_TOKEN_FEE_MARKETS = 20.

Tests

  • fee_handler unit tests:
    • The global total reconciles with the sub-ledgers through accrual, claim_fees and claim_token_fees.
    • A missing market is reported as unreconciled.
    • A ledger the global total does not cover cannot be claimed, and nothing moves.
    • The backfill adds each ledger once, with exact events, and requires admin auth.
    • An unbacked market is flagged.
    • record_fee_accrual moves the total.
    • Bounds and duplicate rejection hold.
  • Test fixtures: fixtures that seed fee ledgers directly now also seed the global total, as real accrual does. This applies to fee_handler, fee_batch_sweeper and resource_bench.
  • tests/shared_quote_token_fees.rs (e2e): two markets settle shorts in a shared quote token. The global total equals the per-market sum, reconciliation passes, omitting a market shows the shortfall, and after the token-level sweep both totals are 0.

Verification

Check Result
CI (fork, workflow_dispatch): rustfmt, clippy --all-targets -D warnings, WASM build ✅
CI cargo test ❌ only the pre-existing tests/funding_rate.rs failures (see #1092)
fee_handler unit tests (CI) ✅ 48 passed
Full integration suite, locally, --no-fail-fast ✅ every test file passes except funding_rate, including the binaries CI never reaches
Resource benchmarks (CI run 39361305731) ✅ gate passes

Benchmark changes: only fees/claim_fees and fees/claim_token_fees changed, by +1 read and +1 write each for the global total. fees/reconcile_token_fees is a new scenario. No lifecycle scenario changed.

After the merge with main (2834081, fork CI runs 39450588863 and 39450596402): rustfmt, clippy, the WASM build and Resource Benchmarks pass. cargo test fails only on the 6 known funding_rate tests.

Docs and types

  • docs/security.md: reconciliation model, errors and entrypoints.
  • docs/deployment.md: upgrade ordering.
  • docs/auth-audit.md: regenerated. backfill_fee_total is auth-bearing (PASS).
  • bindings/fee-handler: regenerated.

🤖 Generated with Claude Code



main did not compile, so no gate step could run:
- tests/Cargo.toml: two [[test]] headers were missing (duplicate keys)
- settlement_invariants/rollback_harness: depended on nonexistent
  `gmx_types`/`gmx_math` package names; fixed unused-arg/while-let/clippy
  issues, a nonexistent `soroban_sdk::Status`, a u32/u64 mismatch, an
  invalid `Env::new()` call, and a test asserting on a nonexistent field
- gmx-types event_schema: `[u8; 32]` is not a contracttype; use BytesN<32>
- order_cleanup: u32/usize comparison in the batch limit check
- cargo fmt on the touched modules

Compile fixes only; no behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Protocol fees could be paid to any address the FEE_KEEPER passed to
claim_fees / claim_all_fees. The destination is now trusted state only.

- Protocol recipient (instance storage) set by `set_fee_receiver`, and an
  optional per-market override (persistent storage) set/cleared by
  `set_market_fee_receiver`; both stored-admin only.
- Every change emits `FeeReceiverSet { market, previous, receiver }`
  (topic `fee_rcv`) so the full history is auditable.
- BREAKING: `claim_fees(keeper, market, token)` and
  `claim_all_fees(fee_handler, keeper, markets, tokens)` drop the
  `receiver` argument; `BatchFeesClaimed` drops its `receiver` field.
- Reverts with FeeReceiverNotSet (SO4-Markets#7) when nothing is configured, and
  InvalidFeeReceiver (SO4-Markets#8) for the market pool or the handler itself.
- New views: get_fee_receiver, get_protocol_fee_receiver,
  get_market_fee_receiver. v1 is single-recipient; no splits.
- Accrual paths are unchanged: settlement credits
  claimable_fee_amount(market, token); no order parameter is consulted.

Tests: unit tests for routing, fallback, admin auth (positive/negative),
invalid recipients, events; sweeper per-market routing; e2e test showing
an order's `receiver` cannot redirect settled fees; fee scenarios added
to the resource bench. Docs: security.md, deployment.md, auth-audit.md;
deploy.sh sets the protocol recipient after initialize.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pre-existing test code that never compiled or that current stable clippy
(what CI installs) rejects; no production behaviour changes:
- order_handler tests from SO4-Markets#1086 (rollback_atomicity, settlement_invariants):
  add the missing settlement_invariants/rollback_harness dev-dependencies,
  drop an extra `initialize` argument, fix a u32/u64 comparison and a
  use-after-move, remove unused imports, rustfmt
- tests/clob_model.rs (SO4-Markets#1082): type alias for a complex return type,
  needless borrows, `div_ceil`
- oracle: turn an assertion on a constant into a compile-time check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up the new fee-recipient entrypoints, the `receiver`-less
claim_fees / claim_all_fees signatures, and errors SO4-Markets#7/SO4-Markets#8.

Generated with stellar-cli 28.1.0. The package.json bump to
@stellar/stellar-sdk ^16 that this CLI version emits was left out on
purpose (major frontend upgrade, out of scope); other contracts' bindings
are stale only because of SO4-Markets#1085's gmx-types additions and are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
execute_{deposit,withdrawal}_reverts_on_stale_price advanced the ledger by
one and expected a panic, but oracle#1205 (53abcfb) deliberately accepts
prices for PRICE_FRESHNESS_WINDOW (20) ledgers, so the calls succeed and
the #[should_panic] tests fail. Step past the window so the tests check
what they describe again. Test-only change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds fees/set_fee_receiver, fees/set_market_fee_receiver and
fees/claim_fees. Every existing scenario is unchanged versus the previous
baseline. Recorded from CI run 39335378079 (ubuntu, soroban-sdk 25.3.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`cargo build --target wasm32v1-none --release` with no -p list builds every
workspace member, including test-only crates that enable soroban-sdk
`testutils`, which does not compile for wasm. Use `make build-wasm` (the
$(CONTRACTS) list), as ci.yml already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The FIFO property test picks resting orders at random to cancel or reduce,
and reduces to rng.range(1, qty - 1). Once an order has been reduced to 1,
choosing it again calls range(1, 0) and underflows ("attempt to subtract
with overflow"). Cancel it instead in that case. Generator bug in the test
only; this test never ran before because tests/Cargo.toml was missing its
[[test]] header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fees stay market-scoped in both custody (each market_token pool) and
ledger (claimable_fee_amount(market, token) backed by
pool_amount(market, token)), so markets sharing a quote token never
overwrite each other and no token-level ledger records the same tokens
twice. Token-level reads and withdrawals aggregate those entries:

- fee_handler.claimable_fees_by_token(token, markets) -> TokenFeeAttribution
  { token, total, markets: [MarketFeeAmount { market, amount }] }
- fee_handler.claim_token_fees(keeper, token, markets): FEE_KEEPER; claims
  each market exactly as claim_fees (own pool, own pool_amount, own
  recipient, own FeeClaimed), then emits TokenFeesClaimed. Atomic.
- Both reject duplicate markets (DuplicateMarket SO4-Markets#10), are bounded by
  MAX_TOKEN_FEE_MARKETS = 20 (TooManyMarkets SO4-Markets#9) and use checked sums
  (FeeOverflow SO4-Markets#11).
- reader.get_protocol_stats rejects duplicate markets (DuplicateMarket SO4-Markets#6),
  which previously counted a market's fees, pool value and OI twice.

claim_fees now shares the per-market claim path; its behaviour and
resource cost are unchanged (bench baseline identical).

Tests: fee_handler unit tests (separate ledgers, attribution, per-market
recipients and events, duplicates, bounds, auth, atomic revert, overflow),
reader duplicate test, and tests/shared_quote_token_fees.rs e2e with two
markets settling shorts in the same quote token. Bench scenario
fees/claim_token_fees added; docs/security.md, auth audit and fee-handler
bindings updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kets#971)

Add a global per-token fee ledger, total_claimable_fee_amount(token), that
always equals the sum of the per-market claimable_fee_amount(market, token)
sub-ledgers:

- Accrual: increase/decrease position and swap settlement now accrue through
  one helper, gmx_market_utils::apply_delta_to_claimable_fee_amount, which
  moves the per-market entry and the global total in the same call;
  fee_handler.record_fee_accrual does the same.
- Claims: claim_fees / claim_token_fees release the transferred amount from
  the global total; a claim the global ledger does not cover reverts with
  FeeLedgerMismatch (SO4-Markets#12).
- claim_token_fees checks its aggregate withdrawal against the listed
  markets' summed accrued balances and the global total
  (ExceedsAccruedFees, SO4-Markets#13).

Read APIs: total_claimable_fees(token) and reconcile_token_fees(token,
markets) -> FeeReconciliation { ledger_total, markets_total, reconciled,
per-market claimable / pool_amount / custody / backed }.

Upgrade migration: admin backfill_fee_total(token, markets) adds each
pre-upgrade (market, token) ledger to the global total at most once
(FeeTotalBackfilled event). Ordering is documented in docs/deployment.md.
All new calls reject duplicate markets and are bounded by
MAX_TOKEN_FEE_MARKETS.

Tests: fee_handler unit tests (reconciliation through accrual and claims,
uncovered ledger rejected, backfill once-only + admin auth, unbacked market
flagged, record_fee_accrual, bounds/duplicates); test fixtures that seed fee
ledgers directly now also seed the global total; e2e reconciliation through
real settlement in tests/shared_quote_token_fees.rs; bench scenario
fees/reconcile_token_fees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Markets#1090 deleted

SO4-Markets#1090 (280f268) deleted 765 files from main, leaving only
libs/clob_settlement_core. This merge keeps this branch's full tree, which
restores every file SO4-Markets#1090 deleted, and takes the files main added since
5436392 (SO4-Markets#1090, SO4-Markets#1083, SO4-Markets#1089, SO4-Markets#1084):

- libs/clob_settlement_core/src/lib.rs
- contracts/reader/src/clob_reads.rs
- docs/REENTRANCY_ANALYSIS.md, docs/clob-read-api-specification.md,
  docs/market-data-streaming.md
- tests/reentrancy_token.rs

None of these are compiled yet: clob_settlement_core has no Cargo.toml,
clob_reads.rs is not declared as a module, and reentrancy_token.rs is not
registered in tests/Cargo.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Markets#1090 deleted)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Markets#1090 deleted)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@drips-wave

drips-wave Bot commented Oct 20, 2026

Copy link
Copy Markdown

@toluwalasedaniel459-cmyk Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

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.

[CLOB-095] Reconcile treasury totals and expose reconciliation read APIs

2 participants