Reward-pool NFT submission — fix proposals

BC-492 / BC-493 / BC-494 / BC-495 (SEC-F1…F4) — seven candidate solutions, compared.

Branch feature/polkadot-v1.6.0-vm · scanned revision 1c7d2ca · prepared 30 Jul 2026 · source of record: plans/look-at-this-issue-memoized-snowglobe.md

Internal review document. It describes the mechanism of an unfixed HIGH-severity finding. No exploit code is included, and the detail level matches the existing ClickUp tickets — but please keep the link inside the team until the cluster is fixed.

The four tickets

All four findings sit on the same code path — pallet_reward_pool's on_finalize ERC721 detector — and the scan report asks for them to be fixed as one cluster.

TicketAudit IDSevWhereDefect
BC-492SEC-F1HIGH reward-pool/src/lib.rs:728 ERC721 contracts validated by a forgeable 36-byte bytecode fingerprint
BC-493SEC-F2HIGH reward-pool/src/lib.rs:490 Reverted EVM transactions processed as valid deposits; duplicate claims steal a real NFT
BC-494SEC-F3MEDIUM reward-pool/src/lib.rs:467 Attacker-controlled calldata from used for identity and attribution
BC-495SEC-F4MEDIUM reward-pool/src/lib.rs:463 Spoofed from in reverted txs → victim's daily quota exhausted, counters inflated

Two defects, not one

The framing below decides which options are viable, so it is worth stating precisely. The findings describe one problem; the code contains two independent ones.

(A) Authenticity — the pallet cannot tell a real deposit from a fabricated one

IDSub-defectTicket
A1is_erc721_compatible scans bytecode with code.windows(4) for nine selectors — 36 bytes of pattern, embeddable as data constants in any contractBC-492
A2No receipt-status check, so a reverted transfer still creates a collectionBC-493
A3from read out of calldata bytes [16..36] — fully attacker-controlledBC-494 / BC-495
A4No (contract, token_id) uniqueness, so two collections can claim one real NFT and the second unlocker takes itBC-493 (variant 2)

(B) Valuation — an authenticated deposit of a worthless NFT still earns a claim

The payout is computed at unlock.rs:121:

let amount: T::Balance = calculate_unlock_amount(
    fractional_nft_count as u128,   // pieces in the collection
    active_referral_count as u128,  // unlocker's referrals
    reward_pool_balance,            // pool size
    &cfg,
);

The NFT's identity never enters the formula. Any account can deploy a fully compliant ERC721, mint unlimited tokens for free, and deposit them. Every semantic check passes and the pool drains at exactly the same rate.

Therefore: fixing all of (A) does not fix (B) — and the ticket's own second suggestion, verifying ownerOf(tokenId), proves only that a token moved, not that it was worth anything.

The drain loop

Severity comes from compounding, not from a single extraction. Two runtime constants close the loop on themselves:

1 · Deploy contract passes the fingerprint 2 · "Transfer" to pool may even revert 3 · Collection created enters unused pool 4 · Distributed as block reward 5 · unlock() ≥ 0.01% of pool leaves +100,000 reputation 6 · Now eligible 100 submissions / day dashed = the loop closes: each unlock funds and qualifies the next round
Each pass leaves ≥0.01% of the pool and mints exactly enough reputation to qualify the next account. The pool drains whether or not the attacker captures every piece — other recipients unlock the phantom collections too, diluting legitimate rewards.

The self-qualifying constant. NftReputationFactor = 100_000 and one unlocked NFT grants 1 × 100_000 reputation (runtime/impls/src/lib.rs:146-149) — which exactly equals MinReputationForNftSubmission = 100_000. The first unlock self-qualifies an account for 100 submissions a day, and every further unlock qualifies another sybil.

Verified evidence

Everything below was read in source, not inferred. These facts are what rule several of the options in or out.

0.01% floor, 100 submissions/day RewardMinParts = 100 (floor), RewardCapParts = 5000 (0.5% cap), MaxDailyNftSubmissions = 100 per account per day. runtime/src/lib.rs:1976-1985
Reverted frames discard their logs exit_revert omits the self.logs.append that exit_commit performs. So ERC721 event logs are inherently revert-safe, unlike calldata. evm-0.41.1/src/executor/stack/memory.rs:159 vs :134
frontier/frame/evm/src/runner/stack.rs:614-621
Logs are already in hand store_block writes CurrentBlock, CurrentReceipts and CurrentTransactionStatuses together, and TransactionStatus.logs carries the logs. The hook already reads that map (lib.rs:391) — log-based detection needs no new storage read. frontier/frame/ethereum/src/lib.rs:451-453, :582
The "reverts can't reach us" comment is wrong status_code = 1 only on ExitReason::Succeed, and transactions are appended to Pending regardless of outcome. The comment at lib.rs:566-570 asserting reverted safeTransferFrom cannot reach the handler does not hold — BC-493 is real. frontier/frame/ethereum/src/lib.rs:641-644, :678
No precedent for an EVM call in a hook The only Runner::call sites are unlock.rs:180 and the eth_call runtime API. Runner::call commits state and increments the source nonce — and would execute attacker bytecode inside an unmetered hook. unlock.rs:180 · runtime/src/lib.rs:2753
The current check is also a weight problem get_contract_code reads the entire contract bytecode from AccountCodes for every EVM transaction with a to address, then scans it nine times. Unbounded, unmetered work in on_finalize. reward-pool/src/lib.rs:718, :728
A kill switch already exists — zero code RewardPool::unlock is pausable via TxPause::pause under EnsureRootOrGeneralAdmin; TxPauseWhitelistedCalls does not exempt RewardPool. runtime/src/lib.rs:322-332, :335-341
No registry exists today pallet_reward_pool has no allowlist and no governance-gated extrinsic. It already depends on ethabi, ethereum-types, pallet-evm, pallet-ethereum, and the runtime already defines EnsureRootOrHalfAndMinTechnical. reward-pool/Cargo.toml · runtime/src/lib.rs:1053
Tests will need reworking for any log-based option Existing tests inject bytecode straight into pallet_evm::AccountCodes and build calldata from the erc721_nft_transfer_input_bytecode.txt fixture; dummy_status sets logs: vec![]. pallets/reward-pool/tests/tests.rs:59-62, :300

Coverage matrix

Which option closes which defect. closes it, ~ partial or neutralised-but-present, leaves it open.

Option A1
fingerprint
A2
reverts
A3
spoofed from
A4
theft
B
valuation
Size Product impact
1 Registry + log detection rec L Submission becomes permissioned
2 Minimal-diff hardening ~ S None — but breaks operator transfers
3 Precompile records deposits L transferFrom deposits stop working
4 Economic bonding / priced submissions M Deters legitimate depositors too
5 Outflow cap + kill switch XS Bounds every defect without closing any
6 Decouple payout from submissions ~~ ~ XL Whitepaper-level economics change
7 Opcode-aware fingerprint not viable M None — and cannot work

Note on A4. The (contract, token_id) dedup index is additive and can be bolted onto any option. Options 1–3 as described include it; 4–7 do not. A4 is the one defect where a real NFT is stolen from a legitimate depositor, so whatever else is chosen, the index should ship.

The seven options

Option 1 — Governance-approved contract registry + log-based detection

recommendedsize L

Replace the fingerprint with a governance-gated ApprovedNftContracts map; move detection from calldata parsing to ERC721 Transfer event logs; add the (contract, token_id) dedup index; extend the RewardPoolReceiver precompile to reject unapproved contracts so users keep custody.

Covers A1, A2, A3, A4 and B.

statuses[i].logs already read topics.len()==4 + data empty → ERC721 topics[2] == pool O(1), in memory registry lookup memoized per block dedup index then mint collection Cheapest filters first; the only storage read happens for logs that already name the pool as destination.
The replacement detection path. No bytecode read, no EVM call, no calldata parsing.

Advantages

  • The only option that closes all four tickets and defect (B).
  • Net simplification: deletes is_erc721_compatible, get_contract_code, is_sent_to_reward_pool_address, extract_token_id_param, extract_from_param — all five hand-rolled offset parsers, i.e. the exact class of bug that produced F3/F4.
  • Strictly cheaper on_finalize: removes the full-bytecode read (up to 24 KB of PoV per EVM tx) and the 9×O(code_len) scan; adds one O(1) registry read per candidate log, memoizable with the same BTreeMap pattern already used for the reputation cache at lib.rs:397.
  • Revert-safety is free and structural — reverted frames have no logs.
  • The log's from topic is both unspoofable and the true prior owner, so it preserves the approved-operator semantics the comment at lib.rs:452-462 deliberately defends. Strictly better than the ticket's transaction_status.from suggestion, which would credit marketplaces and relayers instead of owners.
  • Detects nested/internal transfers to the pool that the top-level calldata check misses today — those NFTs are currently orphaned.
  • No EVM call in on_finalize.
  • Reuses the existing EnsureRootOrHalfAndMinTechnical origin.

Disadvantages

  • Makes NFT submission permissioned. If permissionless submission is the product intent, this is a product decision, not a bug fix — needs a call before implementation.
  • Governance operational burden and onboarding latency for every new collection.
  • The registry is a trust point: approving one contract with an owner-callable mint() re-opens (B) completely. Approval review quality becomes a security control.
  • Largest diff of the detection options; rewrites process_eth_transaction and reworks the test fixtures (AccountCodes injection → TransactionStatus.logs construction).
  • Needs genesis seeding for dev/testnet/mainnet specs and eth-tests, and possibly a storage migration to backfill the dedup index from pallet_fractional_nft::CollectionIdNftInfo.
  • Broader detection (internal transfers) is a behavioural change needing its own tests.

Option 2 — Minimal-diff hardening (the tickets exactly as written)

size S

Read CurrentReceipts and skip status_code != 1; attribute to transaction_status.from; add the (contract, token_id) dedup index. Keep the fingerprint and the calldata parsing.

Covers A2, A3, A4. Does not cover A1 or B.

Advantages

  • Smallest diff, fastest to ship and review.
  • No product change, no governance process, no genesis seeding, no migration.
  • Existing test fixtures stay valid; only new tests to add.
  • Closes the sharpest edge — the A4 theft path where a legitimate depositor loses their NFT.
  • Fully closes BC-494 and BC-495 (daily-limit griefing and counter inflation).

Disadvantages

  • Does not fix BC-492 at all — the headline HIGH finding. The fingerprint stays forgeable and the pool drain stays open.
  • Does not fix (B).
  • Attributing to transaction_status.from breaks approved-operator transfers (marketplace, relayer, custodial wallet). It contradicts the design comment at lib.rs:452-462 and disagrees with the precompile's onERC721Received gate, which uses the ABI from — so a user whose operator has low reputation gets their NFT orphaned at the pool.
  • Keeps the unmetered full-bytecode scan in on_finalize.
  • Adds a third full-block-sized decode per block (CurrentBlock + CurrentTransactionStatuses + CurrentReceipts).
  • Leaves all five hand-rolled ABI parsers in place.

Option 3 — Precompile records deposits (drop on_finalize scanning entirely)

size L

RewardPoolReceiver records the submission synchronously during onERC721Received, validating handle.context().caller — the calling ERC721 contract — against the registry. The whole on_finalize hook and its five helpers are deleted.

Covers A1, A2, A3 and B structurally, plus A4 with the index.

Advantages

  • Synchronous and gas-metered — the submitter pays for the work; nothing unmetered.
  • handle.context().caller is the calling ERC721 contract, so contract identity cannot be spoofed by calldata at all.
  • Revert-safety is structural: sp_io::storage::rollback_transaction() in frontier/frame/evm/src/runner/stack.rs:619 rolls back precompile storage writes together with the frame.
  • Deletes the BC-483 hook-ordering invariant and its guard tests (runtime/tests/reward_pool_hook_ordering_tests.rs) — a large net simplification.
  • The from argument comes from the ERC721 contract itself, the same authoritative property as the log.

Disadvantages

  • Plain transferFrom deposits stop being detected entirely. transferFrom does not invoke onERC721Received, every wallet exposes it, and users would silently lose NFTs to the pool with no claim at all. Today they at least get a collection. A serious asset-loss regression unless paired with a rescue extrinsic.
  • Precompiles mutating pallet storage is a Moonbeam-style pattern with no precedent in this repo — new audit surface, and the precompile becomes reachable by any contract with fabricated onERC721Received calldata, making the registry check on caller the only defence.
  • Gas pricing becomes safety-critical: the precompile must record_cost for collection creation, N item mints and unused-pool insertion. Today it charges only REPUTATION_READ_GAS = 25_000 for reads. Mispricing is a DoS.
  • Calls deep into pallet_fractional_nft from inside an EVM frame — weight is not accounted in the block's dispatch budget.
  • Biggest behavioural change of the authenticity options.

Option 4 — Economic bonding / priced submissions (permissionless, no registry)

size M

Keep submission open to any contract, but make creating a claim cost something: reserve a slashable bond, or charge a non-refundable submission fee into the pool sized against the expected claim.

Targets B directly. Does not cover A1–A4.

Advantages

  • Preserves permissionless submission — no gatekeeping, no onboarding latency, no trust point.
  • Attacks (B), the valuation defect, head-on rather than by proxy.
  • Self-tuning if priced as a fraction of the pool balance.
  • Fully orthogonal — composes with any detection mechanism, including Option 1 or 2.
  • Cheap to implement via Currency::reserve or transfer-to-pool.

Disadvantages

  • Damages the legitimate incentive too: a genuine depositor already gives up a valuable NFT; charging a fee comparable to the claim removes the reason to participate.
  • Pricing is guesswork — the expected claim value depends on whether the submitter's own collection is distributed back to them, which depends on block authorship.
  • Fixes none of A1–A4: reverts, from spoofing and the duplicate-claim theft path all remain.
  • A fee cannot be charged reliably from on_finalize — the submitter may lack balance, and failing there leaves the NFT orphaned.
  • A well-capitalised attacker still profits if the fee is priced below the expected claim.
  • Largest economics change; needs modelling, not just code.

Option 5 — Cap the blast radius (global outflow limit + existing kill switch)

size XSmitigation, not a fix

Do not authenticate anything. Bound the pool's outflow per period as a fraction of its balance, and keep TxPause on RewardPool::unlock ready as an incident control.

Covers no ticket — but bounds all of them.

Advantages

  • Deployable now with essentially no code — the kill switch already works: TxPause::pause(("RewardPool","unlock")) under EnsureRootOrGeneralAdmin. Buys time on an open HIGH while the real fix is designed and reviewed.
  • Bounds damage from any flaw in the submission path, including ones not yet found. The four tickets are instances of "a bogus claim got created"; a cap covers the whole class.
  • Directly blunts the compounding dynamic, which is where the severity actually comes from.
  • Very small, very safe diff; independent of every other option.

Disadvantages

  • Mitigation, not a fix. Value still leaks, just more slowly. BC-492/493/494/495 stay open in ClickUp.
  • A global cap is itself a griefing vector: an attacker exhausting the period's cap early blocks legitimate unlocks — the BC-495 daily-limit DoS promoted to global scope. Needs a fair ordering/queuing policy to be acceptable, which is real design work.
  • Does nothing about the A4 NFT-theft path — that is not a rate problem; one duplicate claim steals one NFT.
  • Adds a new failure mode: unlock failing for reasons unrelated to the caller.

Option 6 — Decouple the payout from submission volume (economic root cause)

size XL

Today each submission mints a fresh claim, so submission volume drives pool outflow. Instead pay a bounded per-era budget split among that era's unlockers, and treat the deposited NFT purely as the collectible prize.

Targets B. Makes A1–A3 economically toothless. Still needs A4 separately.

Advantages

  • Attacks the actual root cause: phantom submissions can no longer inflate total outflow, only dilute each other's share — a phantom-NFT attacker dilutes their own return.
  • Degrades the authenticity defects from theft to spam.
  • Makes emissions predictable and modellable, which matters given BurnablePoolBalance and the pallet_potential cycle burn.
  • Removes the compounding dynamic entirely.
  • Keeps submission permissionless — the registry becomes optional rather than load-bearing.

Disadvantages

  • Far larger than the security fix asked for — touches reward_calculator.rs, unlock.rs, runtime constants and whitepaper-level economics. Not a sprint-sized security patch.
  • Needs product and economics sign-off, not just engineering review.
  • Changes payouts for existing users → migration and comms concerns.
  • Does not remove the unmetered bytecode scan or the hand-rolled ABI parsing.
  • Highest risk of introducing fresh economic bugs.

Option 7 — Harden the fingerprint (opcode-aware detection + EIP-165)

not viablesize M

Parse the dispatcher instead of scanning raw bytes: accept a selector only as PUSH4 <selector> followed by comparison/jump patterns, and probe supportsInterface(0x80ac58cd). Listed for completeness because it is the intuitive first idea.

Advantages

  • Preserves the existing architecture and product model exactly: permissionless, no governance, no genesis seeding, no migration.
  • Smallest conceptual change for reviewers already familiar with the code.
  • Defeats the trivial forgery — 36 bytes of data constants.

Disadvantages

  • Cannot work. Bytecode shape is not semantics. An attacker can write real Solidity with genuine PUSH4 dispatch arms for all nine selectors and arbitrary bodies. And no forgery is needed at all: a fully compliant ERC721 with a public mint() passes trivially. This defends against an accident, not an adversary.
  • Does nothing for (B), A2, A3 or A4.
  • Increases unmetered on_finalize cost — opcode walking is strictly more work than window scanning, over the same full-bytecode read.
  • The EIP-165 probe means an EVM call in on_finalize, with the hazards noted in the evidence section.
  • A bytecode parser in consensus code is a bug farm; worst complexity-to-benefit ratio here.

Recommendation

These are not mutually exclusive. The staged answer:

1. Now — no code. Option 5's kill switch: be ready to TxPause RewardPool::unlock, and decide whether to pause pre-emptively on any live network. This is an open HIGH.

2. This sprint — Option 1. One branch, separate commits per logical change (registry + governance extrinsics + genesis · log-based detection · dedup index · precompile gate · tests). It is the only option that closes all four tickets and defect (B) together, and it removes more code than it adds.

3. Follow-up ticket — Option 6. File it regardless of what is chosen now. The registry is a governance-heavy answer to an economics problem; Option 6 is the structural one, and it is the path back to permissionless submission.

Option 2 alone should be rejected as the cluster fix. It leaves BC-492 open by construction, and its attribution change would break approved-operator transfers — regressing a behaviour the code deliberately supports today.

Decisions needed

Four open questions block implementation. Answer below and hit copy — it produces a paste-ready comment for the ClickUp ticket or the MR thread.

Q1 · Is permissionless NFT submission a product requirement?

The load-bearing question. If yes, Option 1's registry is the wrong shape and Option 4 or 6 becomes primary instead.

Q2 · Scope
Q3 · Is there live chain state with existing reward-pool collections?

Determines whether a storage migration is needed to backfill the dedup index and seed the registry.

Q4 · Orphaned NFTs

With a registry, an unapproved NFT sent by plain transferFrom is stuck at the pool — EVM-layer gating is impossible for transferFrom.

Preferred option
Reviewer & notes

Verification (applies to whichever option is chosen)

cargo test --release -p pallet-reward-pool
cargo test --release -p pallet-fractional-nft
cargo test --release -p totochain-runtime   # incl. reward_pool_hook_ordering_tests.rs
NODE_BINARY=./target/release/totochain eth-tests/run-tests.sh
cargo fmt -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings

New negative tests the fix must carry, whichever option lands:

  • A contract with forged selector patterns is rejected — no collection created.
  • A reverted safeTransferFrom creates no collection.
  • A duplicate (contract, token_id) claim is rejected.
  • A spoofed calldata from does not consume the victim's daily quota.
  • An approved-operator transfer still credits the token owner, not the operator (regression guard for the behaviour Option 2 would break).

eth-tests/test/reward-pool-reputation.test.js exercises the real EVM path and will need genesis-approved contracts under Option 1 or 3.