Skip to content

fix(drive-abci)!: bill the contract fetch of a fee claim deterministically - #4954

Merged
QuantumExplorer merged 3 commits into
v4.2-devfrom
fix/contract-fee-claim-deterministic-fetch-fee
Sep 24, 2026
Merged

QuantumExplorer merged 3 commits into
v4.2-devfrom
fix/contract-fee-claim-deterministic-fetch-fee

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

The contract fee claim (state transition type 25, protocol version 14, unreleased) billed its contract read from the fee stored inside the cached DataContractFetchInfo:

let Some(contract_fetch_info) = platform.drive.get_contract_with_fetch_info_and_fee(..)?.1 else { .. };
if let Some(fee) = contract_fetch_info.fee.clone() {
    execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee));
}

That field is Some only when the cache entry was built with an epoch. The cache refresh after a contract create or update, and the getDocuments query handler, both cache the contract with fee: None, and the claim reads with add_to_cache_if_pulled = false, so a cache hit never writes the fee back. The same claim therefore cost:

  • nothing for the contract read on a node whose cache holds a fee-less entry (it executed the contract's create or update, or served a getDocuments for it), and
  • the full read on a node whose cache is cold (restart, eviction, late joiner).

Different balances mean different app hashes. Anyone can cause the split, because a getDocuments request sent to some nodes fills their cache with a fee-less entry.

What was done?

  • contract_fee_claim/state/v0 transform_into_action_v0 now bills the FeeResult that get_contract_with_fetch_info_and_fee returns (.0). That fee is computed the same way on every node, whether the contract came from disk or the cache, or does not exist. It is billed before anything else is checked, and a missing fee is a CorruptedCodeExecution error. This is the same pattern as contract_user_moderation/state/v0 and data_contract_update/state/v0.

  • Behaviour change beyond the fee: a claim on a contract that does not exist is now a paid refusal. It bumps the signer's contract nonce and charges the lookup, where before it was an unpaid refusal. Reasons:

    • Every other refusal of the claim is already paid.
    • The signer is authenticated and its nonce is valid at this point. The lookup has happened and is now billed, and an unpaid refusal would throw that bill away.
    • The other two transitions that act on an existing contract by id do the same: the contract update (live on mainnet) and the contract user moderation (PV14).

    The check tx outcome is unchanged: the claim is still refused with 10400.

  • The contract cache is emptied on the first block of a protocol change (new perform_events_on_first_block_of_protocol_change_v2, selected by protocol version 14's table). A cache hit bills the fee its entry carries when it has one, calculated under the fee schedule of the protocol version the contract was read in. A protocol change is the only time that schedule can change, but nothing emptied the contract cache then, so a future schedule that changes read costs would bill a node that stayed up the old fee and a node that restarted the new one, for every transition that reads a contract (fee claim, contract update, contract user moderation, key bounds). v2 clears the cache and then runs v1, whose system contract seeding must come after the clear. Upgrades into protocol versions 10 to 13 keep running v1 unchanged.

  • DataContractFetchInfo::fee is crate-private, with its doc corrected: it claimed the cache is cleared on a protocol change, which no code did until v2. No code outside rs-drive can bill it any more. Tests that need to know whether an entry carries a fee use has_fee_for_tests() (behind fixtures-and-mocks), which returns a bool, not a fee.

  • The book's fee claim table (book/src/data-model/contract-moderation.md) no longer lists the unknown-contract refusal as unpaid.

Before / after

A moderator claims the moderators pot of a contract they moderate. The pot holds 1,000 credits and the claim is processed in epoch 1.

                                                   storage_fee   processing_fee
Before, the cache holds a fee-less entry            16,470,000        1,635,640
Before, cold cache                                  16,470,000        1,697,780
After, fee-less entry, getDocuments-filled or cold  16,470,000        1,697,780

The 62,140 credits between the two "before" rows are the contract read.

A node that read a user contract under protocol version 13 and cached the fee of that read, at the first block of protocol version 14:

Before: the entry stays cached, and later reads of the contract bill the fee calculated under 13's schedule
After:  the contract cache is empty, and the next read is billed under 14's schedule, like on a node that restarted

A claim on a contract id that does not exist:

Before: UnpaidConsensusError(DataContractNotPresentError 10400), no nonce bump, nothing charged
After:  PaidConsensusError(DataContractNotPresentError 10400), contract nonce bumped, the lookup charged

In-place changes to shipped generations

  • contract_fee_claim/state/v0 transform_into_action_v0 (drive-abci): not shipped. ContractFeeClaim exists only from protocol version 14, which is unreleased.
  • DRIVE_ABCI_METHOD_VERSIONS_V10: not shipped. Only protocol version 14 uses it; its perform_events_on_first_block_of_protocol_change slot moves from 1 to the new 2. The shipped v0 and v1 are untouched.

How Has This Been Tested?

packages/rs-drive-abci/.../contract_fee_claim/tests.rs:

  • New should_bill_a_claim_the_same_whether_its_contract_is_cached_or_not. It processes one successful claim four times, each in a transaction that is then dropped, and asserts the four fee_results are equal. The four cache states:

    1. as the contract create's cache refresh left it, fee-less (asserted);
    2. after the block cache is merged and cleared and a real getDocuments request (Platform::query_documents, V0) has filled the committed cache, again fee-less (asserted);
    3. cached with the fee of its read in an earlier epoch, as a contract update's validation caches it (asserted);
    4. cold, after drive.cache.data_contracts.clear().

    Against the previous code this test fails on its first comparison: processing fee 1,635,640 as the create left the cache, against 1,697,780 cold.

  • should_refuse_a_claim_on_an_unknown_contract_unpaid becomes should_refuse_a_claim_on_an_unknown_contract_and_charge_for_the_lookup. It still checks check tx returns 10400. It now expects a paid 10400, checks the signer's balance dropped by exactly the gas charged, and checks the claimant's stored contract nonce for that contract id is the claim's nonce.

  • New should_empty_the_contract_cache_on_the_first_block_of_protocol_version_14 (protocol change dispatcher). On a chain born at 13, a user contract is read with an epoch at 13, which caches it with its fee. Running the protocol change events of v1 leaves that entry cached; running protocol version 14's (v2) leaves the contract in neither cache.

  • cargo test -p drive-abci --lib -- contract_fee_claim data_contract_update contract_user_moderation: 127 pass.

  • cargo test -p drive-abci --lib -- protocol_upgrade contract_fee_claim: 74 pass. cargo test -p drive --lib -- drive::contract cache::: 480 pass. cargo test -p platform-version: pass.

  • cargo clippy -p drive -p drive-abci -p platform-version --lib --tests -- -D warnings: clean.

  • Upgrade strategy tests (--test strategy_tests upgrade): run_chain_quick_version_upgrade and run_chain_v12_to_v13_locks_in_before_activation pass. The two that upgrade into 14 (run_chain_v13_to_v14_registers_the_app_connect_contract, run_chain_reopened_drive_at_epoch_boundary_locks_in_the_same_version_as_a_warm_node) fail on v4.2-dev itself: the structure conformance check finds the moderation charters contract's electedCharter vote poll trees carrying EpochOwned flags where the description expects none. They fail the same way with the cache clear removed.

  • CI's "Rust workspace tests" currently stops at moderation-charters-contract's should_load_the_schema_at_the_latest_platform_version, red on v4.2-dev since feat(platform)!: moderation charters system data contract #4898 and fixed by feat(platform)!: elected moderation teams moderate from their stored charter #4952 and feat(sdk): encryptedFor helpers and moderation charter readers #4953.

Breaking Changes

This is a consensus change inside protocol version 14, which is unreleased: the fee claim's fee and the payment of its unknown-contract refusal both change. The contract cache clear runs only on upgrades into protocol version 14 and later. Nothing that has shipped is affected.

DataContractFetchInfo::fee is no longer public. No code outside rs-drive read it after this PR's fee claim change.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed
  • If I added or changed GroveDB structure, I described it in the area's structure.rs, regenerated grovedb-structure.json, and checked the structure viewer link posted on this pull request

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Contract fee claims now use the correct fetch fee whether the contract is cached or not.
    • Claims for missing contracts now return a paid refusal and advance the claimant’s nonce.
    • Contract cache entries are cleared during protocol upgrades so reads use the current fee schedule.
  • Documentation
    • Clarified that refusals for missing contracts are paid.

…cally

The contract fee claim billed its contract read from the fee stored in
the cached DataContractFetchInfo. That fee is only there when the cache
entry was built with an epoch: the refresh after a contract create or
update and the getDocuments handler cache the contract without one. A
node holding such an entry billed nothing for the read, a cold node
billed it, and their balances and app hashes diverged. Sending
getDocuments to some nodes was enough to cause it.

Bill the FeeResult the fetch returns, before anything else is checked,
as the contract update and the contract user moderation do. A claim on
a contract that does not exist becomes a paid refusal that bumps the
contract nonce, like every other refusal of the claim.

ContractFeeClaim only exists from protocol version 14, which is
unreleased, so v0 is edited in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added this to the v4.2.0 milestone Sep 23, 2026
@github-actions github-actions Bot added the waiting-bots Waiting for the review bots to report on this head label Sep 23, 2026
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: dashpay/platform/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 0774a3dd-362f-4220-805f-3c599935b638

📥 Commits

Reviewing files that changed from the base of the PR and between 58bfae8 and 8b05c58.

📒 Files selected for processing (2)
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/v0/mod.rs
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Contract fee claim validation now records contract fetch fees for cached and absent contracts. Claims on absent contracts return a paid refusal with a nonce-bump action. Tests check the charge and compare billing across cache states.

Changes

Contract Fee Claim

Layer / File(s) Summary
Fetch fee and refusal behavior
packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/v0/mod.rs, packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs
Validation records the fetch fee separately from optional contract information. If the contract is absent, it returns a DataContractNotPresentError refusal with a nonce-bump action. Tests verify the paid lookup and compare fees when the contract is cached by creation, cached by a document query, or not cached.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 8b05c

Contract claims bill consistently across the tested cache states, and absent-contract refusals are charged. No identified issue blocks merge after normal checks.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: deterministic billing of the contract fetch for a fee claim. It is specific and consistent with the described implementation and objectives.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

@thepastaclaw

thepastaclaw commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

🕓 Queued for automated review — 10th in line, estimated start in ~1.9 h (commit 10b52d2)
Estimated review time once started: ~25 min (two-phase automated review; median of recent runs).

  • Request priority review — click to move this review to the front of the queue.

…tored fee

A cache hit billed the fee its entry carried when it had one. That fee
was calculated under the fee schedule active when the contract was
cached, and nothing clears the contract cache on a protocol change, so
a schedule that changes read costs would bill warm and cold nodes
differently. The cache hit now always calculates the fee from the
entry's read cost under the active schedule; the write-back that only
memoized it is gone.

This is consensus-neutral on every shipped version: every stored fee
is calculate_fee over the entry's own cost, a read's fee depends on the
epoch for nothing, and every shipped schedule (and FEE_VERSION3) shares
the storage and processing costs.

DataContractFetchInfo::fee becomes crate-private with a corrected doc,
so no caller outside Drive can bill it again; tests use
has_fee_for_tests. The fee claim tests gain a leg with a fee-carrying
cache entry and check the unknown-contract refusal's nonce bump, and
the book no longer calls that refusal unpaid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📖 Book Preview built successfully.

Download the preview from the workflow artifacts.
To view locally: download the artifact, unzip, and open index.html.

Updated at 2026-09-24T00:02:42.649Z

…tocol change

Replaces the previous commit's Drive change, which recalculated every
cache hit's fee in a read path all protocol versions share. The fee a
cache entry carries can only go stale when the fee schedule changes,
and that only happens at a protocol change, so emptying the cache there
is enough.

perform_events_on_first_block_of_protocol_change v2 clears the contract
cache and then runs v1, whose system contract seeding has to come after
the clear. Only protocol version 14's method table selects it, so
upgrades into 10 to 13 keep running v1. The rs-drive fetch code is back
to what it was; DataContractFetchInfo::fee stays crate-private, with a
doc that now says when the cache is cleared.

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

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed

@QuantumExplorer
QuantumExplorer merged commit 342b720 into v4.2-dev Sep 24, 2026
19 of 22 checks passed
@QuantumExplorer
QuantumExplorer deleted the fix/contract-fee-claim-deterministic-fetch-fee branch September 24, 2026 00:05
@github-actions github-actions Bot removed the waiting-bots Waiting for the review bots to report on this head label Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants