Skip to content

fix(#420): creator fees accrue and are claimed PER ASSET - #463

Merged
dcccrypto merged 1 commit into
mainfrom
fix/420-per-asset-creator-fee
Sep 4, 2026
Merged

fix(#420): creator fees accrue and are claimed PER ASSET#463
dcccrypto merged 1 commit into
mainfrom
fix/420-per-asset-creator-fee

Conversation

@dcccrypto

Copy link
Copy Markdown
Owner

Decision A — route per-asset; each asset's creator gets its own fees.

The bug

Every asset's creator cut accumulated into one market-wide counter (WrapperConfigV16::creator_fee_claimable_atoms) while WithdrawCreatorFee authorised against asset 0's asset_admin only. In a multi-asset market the base deployer could withdraw fees earned on assets 1..N, and those assets' creators could never claim their own.

What changed

AssetOracleProfileV16 gains creator_fee_claimable_atoms. Both accrual paths credit the leg's own asset, and tag 90 takes an asset_index, authorises against that asset's asset_admin, and debits that asset's counter.

No layout cost, and not by luck. ASSET_ORACLE_PROFILE_LEN grows 400 → 408, but the profile sits inside a fixed 512-byte slot with 112 bytes spare — MARKET_ASSET_SLOT_LEN is unchanged and no offset moves. Deployed markets have those bytes zeroed (slot padding), so each asset reads 0 and accrues fresh.

That's the opposite of the config counter's situation, where WRAPPER_CONFIG_LEN is exactly 576 and growing it would shift MARKET_GROUP_OFF and brick every deployed market.

Nothing already earned is stranded. The pre-existing global balance stays in cfg.creator_fee_claimable_atoms, still claimable by asset 0's admin — the only party who could ever claim it. A claim debits the per-asset counter first and falls back to the legacy pot, so a migrated market stops touching the old value and it can only shrink.

ABI: tag 90 goes 17 → 19 bytes, asset_index appended after amount so the tag and u128 keep their offsets and an old 17-byte caller fails to decode rather than being read as asset 0. Rides the next deploy alongside #412 and #451.

Two bugs the tests caught in my own change

  1. The first version of the single-trade site did its own profile read/write — clobbered a few lines later by the function's existing write-back of a profile read earlier. The accrual silently vanished. It now mutates the in-scope profile, as the batch path does.
  2. The batch site initially removed the config accrual while the per-leg credit did not yet exist, which would have dropped batch creator fees entirely.

Neither would have been obvious from reading the diff. Both were caught by ..._creator_fee_accrual_is_written_back_to_the_account_... and the batch multi-leg test — which is what those tests are for.

I also removed a bounds check I'd added against config.max_market_slots: a different basis from the one read_asset_oracle_profile already enforces, and it rejected a legitimate asset 1 as InvalidInstruction. One invariant, one owner.

Verified

check result
new test asset 0's admin refused against asset 1 with the market left byte-identical; asset 1's own admin can claim (positive control)
negative control authority check reverted to asset 0 → the test fails, i.e. the theft succeeds; restored → passes
batch test rewritten to assert each asset receives its leg's cut, keeping the original conservation assertion — the per-asset credits still sum to every leg's cut
wire test updated for the deliberate break, and now pins that the old 17-byte payload is refused rather than read as asset 0
full CI suite 604 passed / 21 failed"OK: failing set matches the allowlist exactly"

Upstream

creator_fee_claimable_atoms doesn't exist at aeyakovenko/percolator-prog — the creator-fee claim is ours alone. Nothing to coordinate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NgoNgagkvw7i5SSRC3FJ8D

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 8f63dbcc-7f90-47f8-9511-f21f1e6e3245


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Every asset's creator cut accumulated into one market-wide counter
(`WrapperConfigV16::creator_fee_claimable_atoms`) while `WithdrawCreatorFee`
authorised against ASSET 0's `asset_admin` only. In a multi-asset market the base
deployer could withdraw fees earned on assets 1..N, and those assets' creators
could never claim their own.

WHAT CHANGED

`AssetOracleProfileV16` gains `creator_fee_claimable_atoms`. Both accrual paths
credit the LEG'S OWN asset, and tag 90 takes an `asset_index`, authorises against
that asset's `asset_admin`, and debits that asset's counter.

THERE IS NO LAYOUT COST, and that is not luck. `ASSET_ORACLE_PROFILE_LEN` grows
400 -> 408, but the profile sits inside a fixed 512-byte `ASSET_ORACLE_WRAPPER_LEN`
slot with 112 bytes spare, so `MARKET_ASSET_SLOT_LEN` is unchanged and NO offset
moves. Deployed markets have those bytes zeroed (they were slot padding), so each
asset reads 0 and accrues fresh. This is the opposite of the config counter's
situation, where `WRAPPER_CONFIG_LEN` is exactly 576 and growing it would shift
`MARKET_GROUP_OFF` and brick every deployed market.

NOTHING ALREADY EARNED IS STRANDED. The pre-existing global balance stays in
`cfg.creator_fee_claimable_atoms` and remains claimable by asset 0's admin — the
only party who could ever claim it. A claim debits the per-asset counter first and
falls back to the legacy pot, so a migrated market stops touching the old value and
that value can only shrink.

ABI: tag 90 goes 17 -> 19 bytes, `asset_index` appended AFTER `amount` so the tag
and the u128 keep their offsets and an old 17-byte caller fails to DECODE rather
than being silently read as asset 0. Rides the next deploy alongside #412 and #451.

TWO BUGS THE TESTS CAUGHT IN MY OWN CHANGE

1. The first version of the single-trade site did its own profile read/write. That
   write was CLOBBERED a few lines later by the function's existing write-back of
   an `oracle_profile` read earlier — the accrual silently vanished. Now it mutates
   the in-scope profile, as the batch path does.
2. The batch site initially removed the config accrual while the per-leg credit did
   not yet exist, which would have DROPPED batch creator fees entirely.

Neither would have been obvious by reading the diff. Both were caught by
`..._creator_fee_accrual_is_written_back_to_the_account_...` and the batch
multi-leg test, which is what those tests are for.

Also removed a bounds check I had added against `config.max_market_slots`: that is
a different basis from the one `read_asset_oracle_profile` already enforces, and it
rejected a legitimate asset 1 as InvalidInstruction. One invariant, one owner.

VERIFIED
  new test          `asset0_admin_cannot_drain_asset1_creator_fees` — asset 0's
                    admin is REFUSED against asset 1 and the market is left
                    byte-identical, and asset 1's own admin CAN claim (positive
                    control, without which the refusal proves nothing)
  NEGATIVE CONTROL  with the authority check reverted to asset 0, that test FAILS
                    — the theft succeeds; restored, it passes
  batch test        rewritten to assert each asset receives ITS leg's cut, and
                    keeps the original conservation assertion: the per-asset
                    credits still sum to every leg's creator cut
  wire test         updated for the deliberate break, and now also pins that the
                    OLD 17-byte payload is REFUSED rather than read as asset 0
  full CI suite     604 passed / 21 failed — "OK: failing set matches the
                    allowlist exactly"

UPSTREAM: `creator_fee_claimable_atoms` does not exist at
aeyakovenko/percolator-prog — the creator-fee claim is ours alone. Nothing to
coordinate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgoNgagkvw7i5SSRC3FJ8D
@dcccrypto
dcccrypto force-pushed the fix/420-per-asset-creator-fee branch from c3b6657 to 6cb3c70 Compare September 4, 2026 00:24
@dcccrypto
dcccrypto merged commit a327b4b into main Sep 4, 2026
3 checks passed
@dcccrypto
dcccrypto deleted the fix/420-per-asset-creator-fee branch September 4, 2026 00:29
dcccrypto added a commit that referenced this pull request Sep 4, 2026
`last_fee_slot` is anchored at ACCOUNT CREATION and advanced only by
SyncMaintenanceFee. While the rate is 0 nobody cranks, so it stays pinned at
creation for the market's whole life — and the moment `marketauth` raises the rate,
the next PERMISSIONLESS crank bills `new_rate x (the account's entire age)`,
including every slot the rate was 0, capped only by capital. A small-looking rate
takes up to 100% of a flat account, and `UpdateMaintenanceFeePerSlot` +
`SyncMaintenanceFee` bundle atomically so the victim never gets a turn.

That defeats #428's safety argument, which Live-gated the rate change on the
assumption a user can withdraw before a hostile fee bites. They cannot: the charge
is retroactive AND instantaneous.

── WHY THIS IS IN THE WRAPPER, AFTER A FIRST ATTEMPT IN THE ENGINE FAILED ──────

My first attempt capped `dt` inside `sync_account_fee_to_slot_not_atomic` and broke
`v16_spec_tests.rs`. spec.md §981 fixes what the ENGINE does with a given anchor:
it "charges recurring wrapper-owned fees exactly once over
`[last_fee_slot_i, anchor]`" and advances `last_fee_slot_i = anchor`. The
retroactivity is SPECIFIED.

But CHOOSING the anchor is the wrapper's job, and §981's only constraint on it is
"Live anchors must be `<= current_slot`". A smaller anchor is fully compliant. So
the fix is two spec-compliant engine calls instead of one:

  [last_fee_slot, checkpoint]  at the PREVIOUS rate   <- what was actually owed
  [checkpoint,    now]         at the CURRENT rate    <- ordinary accrual

`UpdateMaintenanceFeePerSlot` records `(slot, previous_rate)` when — and only when
— the rate actually changes, so a no-op re-assert cannot move the checkpoint and
silently forgive owed fees.

STATE: two fields on `AssetOracleProfileV16`, asset 0's copy holding the
market-wide value, following the existing precedent that asset 0's profile carries
market-wide oracle state the config mirrors. `ASSET_ORACLE_PROFILE_LEN` 408 -> 432,
still inside the fixed 512-byte slot (80 spare), so `MARKET_ASSET_SLOT_LEN` is
unchanged and NO offset moves. This is why it is not in `WrapperConfigV16`:
`WRAPPER_CONFIG_LEN` is exactly 576 and growing it shifts `MARKET_GROUP_OFF`,
bricking every deployed market. Zero means "never changed", which is what a
deployed market reads after an in-place upgrade — correct, since a market whose
rate never changed has no retroactive window.

── (B) HAS NO SAFE PLACE TO SIT, AND THAT IS A FINDING ─────────────────────────

The decision asked for a `max_accrual_dt_slots` per-crank bound as belt and braces.
I implemented it. It cannot go anywhere useful once (A) exists:

  * on the POST-checkpoint leg it under-collects ordinary accrual from any account
    that simply has not been cranked lately — legitimately owed fees, not hostile
    ones. Six existing tests assert those exact amounts and were right to; all six
    failed with the cap in place.
  * on the PRE-checkpoint leg it is WORSE than useless: the leg stops short of the
    checkpoint, and the second call then bills the remaining pre-change window at
    the NEW rate — re-creating the exact confiscation (A) prevents. The new test
    fails with the account drained to zero.

With (A) there is no unbounded HOSTILE window left. A cap would only defer
legitimately-owed fees, so it is deliberately absent and the reasoning is in-source.

VERIFIED
  new test          `..._raising_the_maintenance_rate_is_not_retroactive` — rate 0
                    for ~900 slots, then raised and cranked immediately (the bundled
                    attack). Only the 10 post-raise slots are billed: capital 950.
  NEGATIVE CONTROL  with the checkpoint leg disabled, that test FAILS with capital
                    0 — the account fully drained. Restored, 950.
  full CI suite     605 passed / 21 failed — "OK: failing set matches the allowlist
                    exactly". The six accrual tests pass UNTOUCHED.

STACKED ON #420 (prog#463): both grow `AssetOracleProfileV16`, so this branch is
based on that one to avoid a conflict. Merge #463 first.

UPSTREAM: `sync_account_fee_to_slot_not_atomic` exists at `av` (tip 8eb7142a) with
the same retroactive shape, so this is not a divergence we introduced — and it is
worth filing upstream, since spec.md is Toly's and upstream's own engine has the
same exposure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgoNgagkvw7i5SSRC3FJ8D
dcccrypto added a commit that referenced this pull request Sep 4, 2026
`last_fee_slot` is anchored at ACCOUNT CREATION and advanced only by
SyncMaintenanceFee. While the rate is 0 nobody cranks, so it stays pinned at
creation for the market's whole life — and the moment `marketauth` raises the rate,
the next PERMISSIONLESS crank bills `new_rate x (the account's entire age)`,
including every slot the rate was 0, capped only by capital. A small-looking rate
takes up to 100% of a flat account, and `UpdateMaintenanceFeePerSlot` +
`SyncMaintenanceFee` bundle atomically so the victim never gets a turn.

That defeats #428's safety argument, which Live-gated the rate change on the
assumption a user can withdraw before a hostile fee bites. They cannot: the charge
is retroactive AND instantaneous.

── WHY THIS IS IN THE WRAPPER, AFTER A FIRST ATTEMPT IN THE ENGINE FAILED ──────

My first attempt capped `dt` inside `sync_account_fee_to_slot_not_atomic` and broke
`v16_spec_tests.rs`. spec.md §981 fixes what the ENGINE does with a given anchor:
it "charges recurring wrapper-owned fees exactly once over
`[last_fee_slot_i, anchor]`" and advances `last_fee_slot_i = anchor`. The
retroactivity is SPECIFIED.

But CHOOSING the anchor is the wrapper's job, and §981's only constraint on it is
"Live anchors must be `<= current_slot`". A smaller anchor is fully compliant. So
the fix is two spec-compliant engine calls instead of one:

  [last_fee_slot, checkpoint]  at the PREVIOUS rate   <- what was actually owed
  [checkpoint,    now]         at the CURRENT rate    <- ordinary accrual

`UpdateMaintenanceFeePerSlot` records `(slot, previous_rate)` when — and only when
— the rate actually changes, so a no-op re-assert cannot move the checkpoint and
silently forgive owed fees.

STATE: two fields on `AssetOracleProfileV16`, asset 0's copy holding the
market-wide value, following the existing precedent that asset 0's profile carries
market-wide oracle state the config mirrors. `ASSET_ORACLE_PROFILE_LEN` 408 -> 432,
still inside the fixed 512-byte slot (80 spare), so `MARKET_ASSET_SLOT_LEN` is
unchanged and NO offset moves. This is why it is not in `WrapperConfigV16`:
`WRAPPER_CONFIG_LEN` is exactly 576 and growing it shifts `MARKET_GROUP_OFF`,
bricking every deployed market. Zero means "never changed", which is what a
deployed market reads after an in-place upgrade — correct, since a market whose
rate never changed has no retroactive window.

── (B) HAS NO SAFE PLACE TO SIT, AND THAT IS A FINDING ─────────────────────────

The decision asked for a `max_accrual_dt_slots` per-crank bound as belt and braces.
I implemented it. It cannot go anywhere useful once (A) exists:

  * on the POST-checkpoint leg it under-collects ordinary accrual from any account
    that simply has not been cranked lately — legitimately owed fees, not hostile
    ones. Six existing tests assert those exact amounts and were right to; all six
    failed with the cap in place.
  * on the PRE-checkpoint leg it is WORSE than useless: the leg stops short of the
    checkpoint, and the second call then bills the remaining pre-change window at
    the NEW rate — re-creating the exact confiscation (A) prevents. The new test
    fails with the account drained to zero.

With (A) there is no unbounded HOSTILE window left. A cap would only defer
legitimately-owed fees, so it is deliberately absent and the reasoning is in-source.

VERIFIED
  new test          `..._raising_the_maintenance_rate_is_not_retroactive` — rate 0
                    for ~900 slots, then raised and cranked immediately (the bundled
                    attack). Only the 10 post-raise slots are billed: capital 950.
  NEGATIVE CONTROL  with the checkpoint leg disabled, that test FAILS with capital
                    0 — the account fully drained. Restored, 950.
  full CI suite     605 passed / 21 failed — "OK: failing set matches the allowlist
                    exactly". The six accrual tests pass UNTOUCHED.

STACKED ON #420 (prog#463): both grow `AssetOracleProfileV16`, so this branch is
based on that one to avoid a conflict. Merge #463 first.

UPSTREAM: `sync_account_fee_to_slot_not_atomic` exists at `av` (tip 8eb7142a) with
the same retroactive shape, so this is not a divergence we introduced — and it is
worth filing upstream, since spec.md is Toly's and upstream's own engine has the
same exposure.


Claude-Session: https://claude.ai/code/session_01NgoNgagkvw7i5SSRC3FJ8D

Co-authored-by: dcccrypto <dcccrypto@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant