Skip to content

Storage-rent repairs (version-gated): 64-bit fee arithmetic + EIP-27 re-emission carve-out - #2438

Draft
arkadianet wants to merge 3 commits into
ergoplatform:masterfrom
arkadianet:fix/storage-rent-repairs
Draft

Storage-rent repairs (version-gated): 64-bit fee arithmetic + EIP-27 re-emission carve-out#2438
arkadianet wants to merge 3 commits into
ergoplatform:masterfrom
arkadianet:fix/storage-rent-repairs

Conversation

@arkadianet

@arkadianet arkadianet commented Jul 18, 2026

Copy link
Copy Markdown

Fixes #2251. Reference implementation for discussion — draft because the activation vehicle (which release bundles block version 5) is a maintainer decision; merged as-is this changes nothing on any network, since every new branch is gated on the activated block version and unreachable until a v5 vote passes.

Specification: EIP-0048 — ergoplatform/eips#105

Problem 1: storage fee wraps in Int (#2251)

storageFeeFactor * box.bytes.length wraps in 32-bit arithmetic. At the default factor: boxes ≥ ~1,718 bytes get a negative fee, making the recreate floor exceed the box's own value — effectively rent-immune; boxes ≥ ~3,436 bytes wrap back to a small wrong positive fee. Both regions are reachable within the 4,096-byte box cap, and the wrap points move with the voted factor.

#2375 (credit @a-shannon) has the same arithmetic fix — .toLong — but ungated. Ungated, the fix flips transaction verdicts between patched and unpatched nodes: the regression-test transaction in #2375 itself is invalid under 6.0.x rules (wrapped fee is negative, so the recreate floor is box.value + |fee|) and valid under the patch. The first large-box rent claim mined by a patched node would fork it from every unpatched node, and any historical wrapped-fee claim would break resync. This PR contributes the version gate that makes the same arithmetic deployable; #2375 could equally rebase onto the gate if that ordering is preferred.

Problem 2: EIP-27 re-emission tokens make boxes permanently rent-immune

checkExpiredBox's recreate branch requires the claimed output to preserve the box's tokens (R2); verifyReemissionSpending forbids any output from carrying the re-emission token. No transaction satisfies both, and the full-consume escape is unreachable (post-activation only emission-tx outputs can carry the token, and a reward box's value always far exceeds its storage fee). So every unclaimed post-777,217 reward box still holding re-emission tokens becomes permanently exempt from storage rent once aged past the storage period — defeating rent's purpose (recirculating lost coins) for exactly the class most likely to be lost.

This is live today, not theoretical. The first such box — fbf119cbeb73cadc5866f63931599c85e26ea603b9db6d4a97914b88f1f82cfb (63 ERG reward, created 777,693) — became rent-eligible at ~1,828,900, and a mainnet node rejects both possible claim shapes via POST /transactions/check:

  • token-preserving recreation → Transaction should conform EIP-27 rules
  • token dropped + 12 ERG paid to pay-to-reemission → input script verification fails (checkExpiredBox → false)

Measured at height ~1,831,846: 10,917 unspent sub-100K-ERG boxes carry the token (~192K ERG, mostly recent rewards pending routine sweeps); 17 boxes / ~1,026 ERG have sat unswept for 4+ years, and the eligibility window rolls forward every block until ~3,067,199.

The change (all in checkExpiredBox, gated on blockVersion >= 5)

  1. Fee in 64-bit: storageFeeFactor.toLong * box.bytes.length; legacy Int-wrap preserved verbatim below the gate (the wrap is consensus-observable — historical blocks must keep validating).
  2. Re-emission carve-out: when the box carries the chain's re-emission token, the recreated output must drop it (all other registers/tokens preserved as before), and the recreation floor drops by 1 nanoErg per token — exactly the EIP-27 burn obligation. verifyReemissionSpending is unchanged: it already requires that released value be paid to the pay-to-reemission contract, so the rent path now executes the burn instead of deadlocking against it. Worked example for the real box above: recreated 50.83125 ERG (same owner/script), 12 ERG to pay-to-reemission (and thence to future miner emissions), 0.16875 ERG rent fee.

ErgoInterpreter learns the token id via a new optional constructor argument, threaded from chainSettings.reemission at the three construction sites (candidate generation, block execution, mempool validation); networks without EIP-27 pass None and are byte-identical.

Related: EIP-0033 (eips#68) tightens the full-consume branch (burn seized tokens); orthogonal and composable — under this PR the re-emission token is dropped, not seized, in both branches, which is EIP-0033-consistent.

Test plan

  • testOnly org.ergoplatform.modifiers.mempool.ExpirationSpecification — 16/16: all 11 existing properties untouched and green (legacy semantics preserved), plus 5 new: first-wrap box uncollectable→fully-consumable, second-wrap box charged its true fee, token-dropped claim invalid→valid, token-kept claim valid→invalid, and an end-to-end property through ErgoState.execTransactions (production context→interpreter wiring, checkReemissionRules = true) where the repaired claim validates against the real pay-to-reemission contract and the same tx fails under legacy parameters.
  • ergoWallet/test — 57/57; ergoCore/testOnly org.ergoplatform.reemission.ReemissionRulesSpec — green.

An independent Rust-node implementation of the current rules' consequence (claim builders must skip re-emission-token boxes, with the live-node rejections captured as fixtures): arkadianet/ergo#225

🤖 Generated with Claude Code

https://claude.ai/code/session_01PYSS7hBNFeddkwE3RadSfw

arkadianet and others added 2 commits July 19, 2026 03:09
…e-out (block version 5)

Two storage-rent defects strand UTXOs from the mechanism built to
recirculate lost coins:

1. EIP-27 deadlock. checkExpiredBox's recreate branch requires the
   claimed output to preserve the box's tokens, while
   verifyReemissionSpending forbids any output from carrying the
   re-emission token. No transaction satisfies both, so any box still
   holding re-emission tokens (unclaimed post-777217 miner rewards)
   becomes permanently rent-immune once it ages past the storage
   period. The first such box (fbf119cb..., 63 ERG, created 777693)
   crossed the eligibility line at ~1828900; a live mainnet node
   rejects both possible claim shapes for it today.

2. Int-wrapping storage fee. storageFeeFactor * box.bytes.length wraps
   in Int: boxes >= ~1718 bytes (at the default factor) get a negative
   fee, making the recreate floor exceed the box value (rent-immune
   short of a subsidy); boxes >= ~3436 bytes wrap back positive and are
   claimable at a fee unrelated to their size.

Both fixes are gated on block version 5 (Header.Interpreter70Version /
Constants.StorageRentRepairsBlockVersion), because each changes which
transactions are valid: below the activation version the legacy
semantics (register preservation, Int wrap) are preserved exactly, so
historical blocks validate unchanged and the network switches together
on soft-fork activation, as with 5.0's reinterpretation of existing
scripts.

From block version 5, checkExpiredBox:
- computes the storage fee in 64-bit arithmetic (the product cannot
  overflow Long within parameter bounds);
- requires the recreated box to DROP the re-emission token (all other
  registers and tokens preserved as before), and releases 1 nanoErg
  per token from the recreation floor. verifyReemissionSpending is
  untouched: it already demands exactly that value be paid to the
  pay-to-reemission contract, so the rent path now executes the burn
  obligation instead of deadlocking against it. The freed value flows
  back through re-emission to future miners - restoring storage rent's
  purpose for exactly the boxes most likely to be abandoned.

The interpreter learns the re-emission token id via a new optional
constructor argument, threaded from chain settings at the three node
construction sites (candidate generation, block execution, mempool
validation); networks without EIP-27 pass None and are unaffected.

New ExpirationSpecification properties pin all four verdict flips
(first-wrap uncollectable->consumable, second-wrap true fee,
token-dropped claim valid, token-kept claim invalid) and the legacy
behaviors below the activation version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYSS7hBNFeddkwE3RadSfw
Review feedback (fork PR #1): the storage-rent-repairs properties drove
an independently-constructed verifier, leaving the production wiring
(ErgoStateContext.storageRentReemissionTokenId and the interpreter
construction sites) untested.

New property runs the full claim through ErgoState.execTransactions -
which builds its own interpreter from the state context - under a
block-version-5, checkReemissionRules=true context: the recreated box
drops the token, the UNCHANGED verifyReemissionSpending accepts the
1-nanoErg-per-token payment to the real pay-to-reemission contract, and
the claimer takes the fee. The same transaction through the same wiring
under legacy parameters fails, and the context helper is pinned to
expose the token id exactly when re-emission rules are active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYSS7hBNFeddkwE3RadSfw
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.

Storage rent with current parameters cause overflow error for big boxes

2 participants