Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions frame/evm/precompile/balances/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Changelog

All notable changes to `pallet-evm-precompile-balances` will be documented in this file.

## [Unreleased]

## [0.2.0] - 2026-10-16

**Breaking** — gas is charged by dispatch weight and every failure reverts with
a reason. Requires the next runtime `spec_version`.

### Security

- Static calls, delegated calls (DELEGATECALL, CALLCODE) and calls carrying
value are refused. A transfer debits `context.caller`, which under
DELEGATECALL is the delegating contract's caller: any contract a user called
could move that user's ORB through `0x…0802`. Value sent to the precompile
was stranded in its account.

### Changed

- **Gas follows the dispatch weight.** A call is charged the weight of
`transfer_allow_death` or `transfer_keep_alive`, its proof size and the
storage a new account takes, before the transfer 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. On Orbinum the proof size dominates: a
direct transfer transaction uses about 60,000 gas.
- **Failures revert with a reason.** Every failure reverts with
`Error(string)` and returns the unused gas, instead of an exceptional halt
that consumed all of it. A failed transfer reverts with
`transfer failed: <DispatchError>`.
- **Calldata length is exact.** Input must be the selector plus one `bytes32`
and one `uint256` (68 bytes); shorter or longer input reverts.
- The precompile is split into `abi` (decoding), `guard` (caller rules) and
`dispatch` (charging and the transfer).

## [0.1.0] - 2026-05-29

### Added

- Native `pallet_balances` transfers to any `AccountId32` from EVM contracts and
wallets, at `0x…0802`: `transfer(bytes32,uint256)` (`0x6a467394`) and
`transferKeepAlive(bytes32,uint256)` (`0xac6ac4c4`). The `bytes32` is the
recipient's raw `AccountId32`; a `uint256` above `u128::MAX` is rejected.
- A flat 5,000 gas per call.
4 changes: 3 additions & 1 deletion frame/evm/precompile/balances/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pallet-evm-precompile-balances"
version = "0.1.0"
version = "0.2.0"
authors = { workspace = true }
edition = "2021"
description = "EVM Precompile for native Balances transfers to arbitrary AccountId32 destinations."
Expand All @@ -20,6 +20,7 @@ pallet-balances = { workspace = true }
# Frontier EVM
fp-evm = { workspace = true }
pallet-evm = { workspace = true }
precompile-utils = { workspace = true }

[dev-dependencies]
frame-system = { workspace = true, features = ["default"] }
Expand All @@ -42,4 +43,5 @@ std = [
"fp-evm/std",
"pallet-evm/std",
"pallet-balances/std",
"precompile-utils/std",
]
57 changes: 57 additions & 0 deletions frame/evm/precompile/balances/src/abi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! Calldata decoding: the two selectors and their fixed-size arguments.

use crate::revert;
use fp_evm::PrecompileFailure;

// Verify with: cast sig "transfer(bytes32,uint256)"
// cast sig "transferKeepAlive(bytes32,uint256)"

/// `transfer(bytes32,uint256)` → 0x6a467394
pub const SEL_TRANSFER: [u8; 4] = [0x6a, 0x46, 0x73, 0x94];
/// `transferKeepAlive(bytes32,uint256)` → 0xac6ac4c4
pub const SEL_TRANSFER_KEEP_ALIVE: [u8; 4] = [0xac, 0x6a, 0xc4, 0xc4];

/// Selector + `bytes32` + `uint256`. Both functions take exactly this; longer
/// calldata is refused rather than silently ignored.
const INPUT_LEN: usize = 4 + 32 + 32;

/// A decoded transfer: who receives, how much, and whether the sender must stay
/// alive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TransferCall {
pub dest: [u8; 32],
pub value: u128,
pub keep_alive: bool,
}

pub(crate) fn decode(input: &[u8]) -> Result<TransferCall, PrecompileFailure> {
let selector: [u8; 4] = input
.get(..4)
.and_then(|s| s.try_into().ok())
.ok_or_else(|| revert("input too short: missing selector"))?;
let keep_alive = match selector {
SEL_TRANSFER => false,
SEL_TRANSFER_KEEP_ALIVE => true,
_ => return Err(revert("unknown selector")),
};
if input.len() != INPUT_LEN {
return Err(revert("calldata must be selector + bytes32 + uint256"));
}

let mut dest = [0u8; 32];
dest.copy_from_slice(&input[4..36]);

// uint256 big-endian; the high 16 bytes must be zero for it to fit in u128.
let word = &input[36..68];
if word[..16].iter().any(|&b| b != 0) {
return Err(revert("value overflows u128"));
}
let mut low = [0u8; 16];
low.copy_from_slice(&word[16..]);

Ok(TransferCall {
dest,
value: u128::from_be_bytes(low),
keep_alive,
})
}
63 changes: 63 additions & 0 deletions frame/evm/precompile/balances/src/dispatch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//! Runs a decoded transfer as a `pallet_balances` call from the EVM caller.

use crate::{abi::TransferCall, revert};
use alloc::format;
use fp_evm::{ExitSucceed, PrecompileHandle, PrecompileOutput, PrecompileResult};
use frame_support::dispatch::{GetDispatchInfo, PostDispatchInfo};
use pallet_evm::{AddressMapping, GasWeightMapping};
use sp_runtime::traits::{Dispatchable, StaticLookup};

/// Storage a transfer may add: a new `System::Account` entry (key and value),
/// the same figure Frontier's balance precompiles charge.
pub const NEW_ACCOUNT_STORAGE_GROWTH: u64 = 148;

/// Dispatches `transfer_allow_death` / `transfer_keep_alive` signed by the caller.
///
/// The call's weight (as gas), proof size and storage growth are recorded BEFORE
/// it runs: charging after a successful dispatch only would make every failing
/// transfer free, an unmetered loop for a spammer.
pub(crate) fn transfer<T>(
handle: &mut impl PrecompileHandle,
call: TransferCall,
) -> PrecompileResult
where
T: pallet_evm::Config + pallet_balances::Config,
T::AccountId: From<[u8; 32]>,
<T as pallet_balances::Config>::Balance: TryFrom<u128>,
pallet_evm::AccountIdOf<T>: Into<<T as frame_system::Config>::AccountId>,
<T as frame_system::Config>::RuntimeCall: Dispatchable<PostInfo = PostDispatchInfo>
+ GetDispatchInfo
+ From<pallet_balances::Call<T>>,
<<T as frame_system::Config>::RuntimeCall as Dispatchable>::RuntimeOrigin:
From<Option<<T as frame_system::Config>::AccountId>>,
{
let value: <T as pallet_balances::Config>::Balance = call
.value
.try_into()
.map_err(|_| revert("value does not fit the balance type"))?;
let dest = <T as frame_system::Config>::Lookup::unlookup(T::AccountId::from(call.dest));
let balances_call = if call.keep_alive {
pallet_balances::Call::<T>::transfer_keep_alive { dest, value }
} else {
pallet_balances::Call::<T>::transfer_allow_death { dest, value }
};
let runtime_call = <T as frame_system::Config>::RuntimeCall::from(balances_call);

let weight = runtime_call.get_dispatch_info().total_weight();
handle.record_cost(T::GasWeightMapping::weight_to_gas(weight))?;
handle.record_external_cost(
None,
Some(weight.proof_size()),
Some(NEW_ACCOUNT_STORAGE_GROWTH),
)?;

let caller: <T as frame_system::Config>::AccountId =
T::AddressMapping::into_account_id(handle.context().caller).into();
match runtime_call.dispatch(Some(caller).into()) {
Ok(_) => Ok(PrecompileOutput {
exit_status: ExitSucceed::Returned,
output: Default::default(),
}),
Err(e) => Err(revert(&format!("transfer failed: {:?}", e.error))),
}
}
21 changes: 21 additions & 0 deletions frame/evm/precompile/balances/src/guard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//! The only way to reach this precompile: a direct, non-static, valueless call.

use crate::revert;
use fp_evm::{PrecompileFailure, PrecompileHandle};

/// A transfer debits `context.caller`. Under DELEGATECALL or CALLCODE that is the
/// delegating contract's caller, so any contract a user called could spend the
/// user's ORB; under STATICCALL nothing may change state; value sent here would
/// be stranded in this precompile's account. Each is refused.
pub(crate) fn ensure_direct_call(handle: &impl PrecompileHandle) -> Result<(), PrecompileFailure> {
if handle.is_static() {
return Err(revert("static calls are not allowed"));
}
if handle.code_address() != handle.context().address {
return Err(revert("delegated calls are not allowed"));
}
if !handle.context().apparent_value.is_zero() {
return Err(revert("not payable"));
}
Ok(())
}
Loading
Loading