Balances Precompile
Address: 0x0000000000000000000000000000000000000802
The Balances precompile exposes native pallet_balances transfers to EVM clients. Unlike a
plain ETH transfer, it accepts a raw 32-byte AccountId32 as destination, which means an EVM
wallet can send tokens to any Substrate account — including Sr25519 and Ed25519 accounts that
do not have an EVM address.
Overview
| Property | Value |
|---|---|
| Address | 0x0000000000000000000000000000000000000802 |
| Index | 2050 (0x802) |
| Source | frame/evm/precompile/balances/ |
| Crate | pallet-evm-precompile-balances |
| Status | Implemented (MVP) |
Why This Precompile Exists
On Orbinum, EVM accounts are embedded in AccountId32 as [H160 | 0x00 × 12]. A plain ETH
transfer only reaches the 20-byte EVM namespace. Native Substrate accounts — for example, a
Sr25519 keypair used in Polkadot.js or Talisman — occupy an arbitrary 32-byte AccountId32 that
does not correspond to any EVM address. Without this precompile, there is no way to send tokens to
such an account from an EVM transaction.
The Balances precompile bridges this gap by routing the transfer through
pallet_balances::Mutate::transfer, which operates on the full AccountId32 space.
EVM tx (MetaMask)
└── call 0x0802 with transfer(dest_bytes32, amount)
└── pallet_balances: caller AccountId32 → dest AccountId32
Caller Identity
The caller's AccountId32 is derived from the EVM H160 sender via AddressMapping:
AccountId32 = H160 ++ [0x00; 12]
Authentication is provided by the EVM transaction signature — the precompile does not perform additional signature verification.
Function Reference
transfer
Transfers tokens from the caller to dest. The caller account may be reaped (destroyed) if
its balance drops below the existential deposit.
function transfer(bytes32 dest, uint256 value) external;
| Parameter | Type | Description |
|---|---|---|
dest | bytes32 | Raw 32-byte AccountId32 of the recipient |
value | uint256 | Amount to transfer in the chain's native denomination. Must fit in u128. |
Selector: 0x6a467394
This corresponds to pallet_balances::transfer_allow_death. The sender account may be reaped
if its balance reaches zero.
transferKeepAlive
Transfers tokens from the caller to dest, but guarantees that the caller account is not
reaped. The call reverts if the remaining balance would fall below the existential deposit.
function transferKeepAlive(bytes32 dest, uint256 value) external;
| Parameter | Type | Description |
|---|---|---|
dest | bytes32 | Raw 32-byte AccountId32 of the recipient |
value | uint256 | Amount to transfer. Must fit in u128. |
Selector: 0xac6ac4c4
This corresponds to pallet_balances::transfer_keep_alive. Prefer this variant for faucets and
smart contracts where the sender should remain funded.
ABI Encoding
Both functions take (bytes32, uint256). The encoding is straightforward:
| Bytes | Field |
|---|---|
[0..3] | 4-byte selector (0x6a467394 or 0xac6ac4c4) |
[4..35] | bytes32 — raw AccountId32 (big-endian, zero-padded left if shorter) |
[36..67] | uint256 — amount in native denomination, big-endian, must fit in u128 |
Total calldata length: 68 bytes. Shorter inputs are rejected with an error.
Values exceeding u128::MAX (2^128 − 1) are rejected.
Gas
A flat base cost of 5 000 gas is charged per call, deducted before any argument decoding. This
covers the storage reads and writes performed by pallet_balances and prevents free DoS attempts.
Usage Examples
Solidity
interface IBalances {
function transfer(bytes32 dest, uint256 value) external;
function transferKeepAlive(bytes32 dest, uint256 value) external;
}
IBalances balances = IBalances(0x0000000000000000000000000000000000000802);
// Send 10 ORB to a Sr25519 account (Alice's AccountId32)
bytes32 alice = 0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d;
balances.transferKeepAlive(alice, 10e18);
ethers.js / viem
import { ethers } from "ethers";
const ABI = [
"function transfer(bytes32 dest, uint256 value) external",
"function transferKeepAlive(bytes32 dest, uint256 value) external",
];
const balances = new ethers.Contract(
"0x0000000000000000000000000000000000000802",
ABI,
signer
);
// Alice's raw AccountId32 (Sr25519 dev account)
const alice = "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d";
await balances.transferKeepAlive(alice, ethers.parseEther("10"));
Raw calldata (manual)
Selector: 6a467394
Dest: d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d
Value: 0000000000000000000000000000000000000000000000008ac7230489e80000
└── 10 ORB = 10 × 10¹⁸ = 0x8AC7230489E80000
Encoding an SS58 Address
If you have a Substrate address in SS58 format (e.g. from Polkadot.js or Talisman), decode it to
its raw 32-byte AccountId32 before passing it to the precompile.
import { decodeAddress } from "@polkadot/util-crypto";
// "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" → Uint8Array(32)
const accountId32 = decodeAddress("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY");
const dest = "0x" + Buffer.from(accountId32).toString("hex");
// → "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"
Compatible Wallets
The precompile broadens token reachability across the full account space. The table below shows
which address format each wallet produces and how it maps to the bytes32 dest parameter.
Recipient wallets (destination address)
| Wallet | Address format | Key type | Notes |
|---|---|---|---|
| MetaMask | 0x… (EVM) | secp256k1 | Encode as AccountId32 = [H160 | 0x00×12] |
| Rainbow | 0x… (EVM) | secp256k1 | Same as MetaMask |
| Coinbase Wallet | 0x… (EVM) | secp256k1 | Same as MetaMask |
| Rabby | 0x… (EVM) | secp256k1 | Same as MetaMask |
| Polkadot.js Extension | SS58 (5…) | Sr25519 / Ed25519 / ECDSA | Decode SS58 → raw 32 bytes |
| Talisman | SS58 (5…) | Sr25519 | Decode SS58 → raw 32 bytes |
| SubWallet | SS58 (5…) | Sr25519 | Decode SS58 → raw 32 bytes |
| Nova Wallet | SS58 (5…) | Sr25519 | Decode SS58 → raw 32 bytes |
| Ledger (Substrate app) | SS58 (5…) | Ed25519 | Decode SS58 → raw 32 bytes |
EVM addresses can be passed directly to the precompile as bytes32 by left-padding the 20-byte
H160 to 32 bytes ([H160 | 0x00 × 12]). SS58 addresses must first be decoded to their raw
32-byte AccountId32 — see Encoding an SS58 Address above.
Caller wallets (transaction sender)
The precompile is called via a standard EVM transaction, so the sender must always be an EVM
wallet. The precompile itself handles delivery to any AccountId32 recipient.
| Wallet | Supported as caller |
|---|---|
| MetaMask | ✅ |
| Rainbow | ✅ |
| Coinbase Wallet | ✅ |
| Rabby | ✅ |
| Any EVM-compatible wallet | ✅ |
| Polkadot.js / Talisman (Substrate-only mode) | ❌ — must go through an EVM transaction |
Limitations
- The
valueparameter must fit in au128. Values exceedingu128::MAXare rejected. In practice this is not a constraint — the maximum representable amount is approximately3.4 × 10³⁸, far above any realistic balance. - The precompile does not check whether
destcorresponds to a real account. Tokens sent to an unregisteredAccountId32are credited to that account and can be claimed later by whoever controls the key. - No cross-asset support — only the chain's native token is transferred.