Skip to content

fix(precompile-balances)!: refuse delegated, static and value calls; charge the dispatch weight up front and revert with a reason (0.2.0) - #170

Merged
nol4lej merged 1 commit into
mainfrom
fix/precompile-balances-guard-and-metering
Oct 18, 2026
Merged

nol4lej merged 1 commit into
mainfrom
fix/precompile-balances-guard-and-metering

Conversation

@nol4lej

@nol4lej nol4lej commented Oct 18, 2026

Copy link
Copy Markdown
Member

Summary

The Balances precompile (0x…0802) debits context.caller. Under DELEGATECALL that caller is whoever called the delegating contract, so any contract a user interacted with could move that user's ORB through the precompile. Value sent to it was stranded in its account.

This PR closes the hole and fixes how the precompile meters and fails. pallet-evm-precompile-balances 0.1.0 → 0.2.0 (breaking).

Security

  • Static calls, delegated calls (DELEGATECALL, CALLCODE) and calls carrying value are refused, with the same code_address == context.address rule as the ShieldedPool precompile.

Changed

  • Gas follows the dispatch weight. The call is built as pallet_balances::Call::{transfer_allow_death, transfer_keep_alive}. Its weight (as gas), proof size and new-account storage are recorded before it runs, so a failing transfer costs the same as a successful one. The flat 5,000 gas, which metered neither proof size nor storage, is gone. Proof size dominates on Orbinum: a direct transfer transaction uses about 60,000 gas.
  • Failures revert with a reason (Error(string)) and return unused gas, instead of an exceptional halt that burned the whole gas limit. A failed transfer reverts with transfer failed: <DispatchError>.
  • Calldata length is exact: selector + bytes32 + uint256 (68 bytes).
  • The crate is split into abi (decoding), guard (caller rules) and dispatch (charging and the transfer). Tests are split to match.
  • The runtime's PrecompileSet bounds now require RuntimeCall: From<pallet_balances::Call<R>> and drop the fungible::Mutate bound.

Consensus-relevant: it ships with the next spec_version (noted in RUNTIME_VERSIONS.md under "Next spec — pending").

Testing

  • cargo test -p pallet-evm-precompile-balances: 18 tests (ABI decoding and selector hashes, the three guards leave balances untouched and charge nothing, transfer and keep-alive semantics, up-front weight/PoV/storage charge, failing transfers still charged).
  • cargo clippy -D warnings, cargo fmt --check.
  • --dev node E2E with ethers + solc:
    • high-level Solidity call pays the payee;
    • delegatecall, staticcall and value calls refused, and the victim loses only gas;
    • revert reasons via eth_call (unknown selector, short and long calldata, insufficient balance);
    • successful and failing transfers both charged by weight;
    • a revert returns unused gas.

…charge the dispatch weight up front and revert with a reason (pallet-evm-precompile-balances 0.2.0)
@nol4lej
nol4lej merged commit f251dd1 into main Oct 18, 2026
5 checks passed
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