Skip to main content

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​

PropertyValue
Address0x0000000000000000000000000000000000000802
Index2050
Functionstransfer(bytes32,uint256), transferKeepAlive(bytes32,uint256)
Debitsmsg.sender, mapped to its AccountId32
CreditsAny 32-byte account
GasThe transfer's dispatch weight, charged before it runs
PayableNo
EventsSubstrate 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​

FunctionSelectorPreservation
transfer(bytes32 dest, uint256 value)0x6a467394Expendable: the balance may reach 0
transferKeepAlive(bytes32 dest, uint256 value)0xac6ac4c4Preserve: 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.

CallerAccount debited
An EOA, calling 0x…0802 directlyThe EOA's account
A contract, using CALLThe contract's own account
STATICCALLRefused
DELEGATECALL or CALLCODERefused
Any call with value > 0Refused

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.

BytesTypeField
0..4—Selector 0x6a467394 or 0xac6ac4c4
4..36bytes32dest: the recipient's raw AccountId32
36..68uint256, big-endianvalue: 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.

Recipientdest
sr25519 account 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d
EVM account 0x00000000000000000000000000000000000abc010x00000000000000000000000000000000000abc01000000000000000000000000

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>.

ReasonCause
static calls are not allowedThe precompile was reached by STATICCALL
delegated calls are not allowedThe precompile was reached by DELEGATECALL or CALLCODE
not payableThe call carried value
input too short: missing selectorCalldata shorter than 4 bytes
unknown selectorA selector other than the two above
calldata must be selector + bytes32 + uint256Calldata other than 68 bytes
value overflows u128value 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
InputOutput
SS58 address, any network prefixThe decoded 32-byte account as 0x-prefixed hex
EVM address (H160)H160 ‖ 0x00 × 12 as hex
0x + 64 hex charactersThe same value, lowercased
Anything elsenull

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