Balances Precompile
The Balances precompile sends native ORB from the calling EVM account to any AccountId32. A plain
EVM value transfer can only reach accounts that have an H160; this precompile also reaches sr25519
and ed25519 accounts, such as those created in Polkadot wallets, which have none.
Overview
| Property | Value |
|---|---|
| Address | 0x0000000000000000000000000000000000000802 |
| Index | 2050 |
| Functions | transfer(bytes32,uint256), transferKeepAlive(bytes32,uint256) |
| Debits | msg.sender, mapped to its AccountId32 |
| Credits | Any 32-byte account |
| Gas | The transfer's dispatch weight, charged before it runs |
| Payable | No |
| Events | Substrate Balances.Transfer; no EVM logs |
Every EVM account already has an AccountId32: its H160 followed by 12 zero bytes. The reverse is
not true. An sr25519 account's 32 bytes are a public key with no H160 inside, so no EVM address
reaches it. The precompile takes the full 32 bytes as the destination and moves the balance with
pallet_balances. See EVM and Substrate accounts for the mapping.
Functions
| Function | Selector | Preservation |
|---|---|---|
transfer(bytes32 dest, uint256 value) | 0x6a467394 | Expendable: the balance may reach 0 |
transferKeepAlive(bytes32 dest, uint256 value) | 0xac6ac4c4 | Preserve: keeps the existential deposit back |
Both move value planck (18 decimals) of ORB from the caller to dest, and return no data. The
existential deposit on Orbinum is 0, so Preserve keeps nothing back: both functions can send the
caller's whole transferable balance and behave the same. Neither function checks that dest
belongs to anyone.
Caller identity
The precompile debits msg.sender of the call that reaches it, mapped to
AccountId32 = H160 ‖ 0x00 × 12. No other signature is checked: the EVM transaction's own signature
authorises the transfer.
| Caller | Account debited |
|---|---|
An EOA, calling 0x…0802 directly | The EOA's account |
A contract, using CALL | The contract's own account |
STATICCALL | Refused |
DELEGATECALL or CALLCODE | Refused |
Any call with value > 0 | Refused |
Under DELEGATECALL or CALLCODE the precompile would see the delegating contract's caller as
msg.sender, so any contract a user called could spend the user's ORB. Refusing both means a
contract can move only its own balance through the precompile, never its caller's. Value is refused
because the precompile does not use it: it would be left in the precompile's account.
ABI encoding
Calldata is exactly 68 bytes: the selector and two 32-byte words. Shorter or longer input reverts.
| Bytes | Type | Field |
|---|---|---|
0..4 | — | Selector 0x6a467394 or 0xac6ac4c4 |
4..36 | bytes32 | dest: the recipient's raw AccountId32 |
36..68 | uint256, big-endian | value: must fit in u128 (the high 16 bytes are zero) |
The bytes32 for an EVM account is its H160 followed by 12 zero bytes: the zeros are a suffix,
not left padding. In Solidity that is bytes32(bytes20(addr)); in ethers v6,
ethers.zeroPadBytes(addr, 32). A left-padded address, as bytes32(uint256(uint160(addr))) produces, is a
different account that nobody controls.
| Recipient | dest |
|---|---|
sr25519 account 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY | 0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d |
EVM account 0x00000000000000000000000000000000000abc01 | 0x00000000000000000000000000000000000abc01000000000000000000000000 |
Gas
After the caller checks and decoding pass, the precompile charges the dispatch weight of
transfer_allow_death or transfer_keep_alive, converted to gas, together with its proof size and
the storage a new account takes. The charge comes before the transfer runs, so a transfer that
pallet_balances refuses costs the same as one that succeeds. A call refused by the caller checks
or by decoding charges nothing beyond the CALL itself.
Proof size dominates the cost: a transaction that calls the precompile directly uses about 60,000
gas. Use eth_estimateGas rather than a hard-coded limit; the EVM gas rules
explain the proof-size component.
Every failure reverts with an Error(string) reason and returns the unused gas, so a calling
contract can check success without bounding the gas it forwards.
Errors
Every failure reverts with Error(string), as a Solidity require does. A calling contract
receives the ABI-encoded reason as return data, and eth_call and eth_estimateGas report it as
execution reverted: <reason>.
| Reason | Cause |
|---|---|
static calls are not allowed | The precompile was reached by STATICCALL |
delegated calls are not allowed | The precompile was reached by DELEGATECALL or CALLCODE |
not payable | The call carried value |
input too short: missing selector | Calldata shorter than 4 bytes |
unknown selector | A selector other than the two above |
calldata must be selector + bytes32 + uint256 | Calldata other than 68 bytes |
value overflows u128 | value is 2^128 or more |
transfer failed: <error> | pallet_balances refused the transfer, for example transfer failed: Token(FundsUnavailable) when the caller's transferable balance is below value |
Events
A successful transfer emits the Substrate event Balances.Transfer { from, to, amount } in the
extrinsic that carries the EVM transaction. The precompile emits no EVM logs, so eth_getLogs does
not see it. See Indexing Events.
Usage
Solidity
A high-level call such as IBalances(0x…0802).transfer(…) works: the address holds stub code,
which passes Solidity's code-size check, and the precompile runs instead of it, bubbling up the
revert reason on failure. A low-level call lets the contract handle a failed transfer itself:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IBalances {
function transfer(bytes32 dest, uint256 value) external;
function transferKeepAlive(bytes32 dest, uint256 value) external;
}
/// Pays any AccountId32 out of this contract's own balance.
contract Payout {
address constant BALANCES = 0x0000000000000000000000000000000000000802;
address public immutable owner = msg.sender;
receive() external payable {}
function pay(bytes32 dest, uint256 value) external {
require(msg.sender == owner, "not owner");
(bool ok, ) = BALANCES.call(
abi.encodeCall(IBalances.transfer, (dest, value))
);
require(ok, "transfer failed");
}
/// An EVM account's AccountId32: the H160 followed by 12 zero bytes.
function accountIdOf(address evm) public pure returns (bytes32) {
return bytes32(bytes20(evm));
}
}
Fund the contract with a plain value transfer to its address; pay then sends from that balance.
Calling a Precompile walks through deploying and calling it.
ethers v6
A direct call from an EOA. @orbinum/protocol needs its peers polkadot-api and
@polkadot/util-crypto installed.
import { ethers } from 'ethers';
import { addressToAccountIdHex } from '@orbinum/protocol';
const provider = new ethers.JsonRpcProvider('https://rpc-1.testnet.orbinum.io');
const signer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
const balances = new ethers.Contract(
'0x0000000000000000000000000000000000000802',
[
'function transfer(bytes32 dest, uint256 value)',
'function transferKeepAlive(bytes32 dest, uint256 value)',
],
signer,
);
// SS58, H160 or 0x-prefixed AccountId32 hex.
const dest = addressToAccountIdHex('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY');
if (!dest) throw new Error('not an address');
const tx = await balances.transfer(dest, ethers.parseEther('1'));
await tx.wait(); // throws if the transaction failed
ethers estimates gas before sending, so a transfer that would fail throws at that step with the precompile's revert reason, and nothing is sent.
Encoding an SS58 address
dest takes the raw 32 bytes, not an SS58 string. addressToAccountIdHex from @orbinum/protocol
converts any address form:
addressToAccountIdHex(addr: string): string | null
| Input | Output |
|---|---|
| SS58 address, any network prefix | The decoded 32-byte account as 0x-prefixed hex |
| EVM address (H160) | H160 ‖ 0x00 × 12 as hex |
0x + 64 hex characters | The same value, lowercased |
| Anything else | null |
An SS58 address is the base58 encoding of prefix ‖ account ‖ checksum. On Orbinum the prefix is
42, which takes one byte; account is the 32 bytes to pass as dest; the checksum is the first
2 bytes of blake2b-512("SS58PRE" ‖ prefix ‖ account). decodeAddress from
@polkadot/util-crypto performs the same decoding and verifies the checksum:
import { decodeAddress } from '@polkadot/util-crypto';
import { hexlify } from 'ethers';
const dest = hexlify(decodeAddress('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'));
// 0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d
Related
- Calling a Precompile — a first call from Solidity and ethers
- Precompile Addresses — the full listing of registered precompiles
- EVM and Substrate accounts — how H160 and
AccountId32map - ShieldedPool Precompile — deposit into the shielded pool
- EVM Compatibility — how Orbinum's EVM differs from Ethereum
- Indexing Events — reading
Balances.Transferand other events