Skip to main content

Calling a Precompile

A precompile is a contract at a fixed address that runs runtime code instead of EVM bytecode. Orbinum's two precompiles reach the shielded pool and native balances from any EVM wallet or contract. This page calls the Balances precompile from Solidity and from ethers v6; the same rules apply to ShieldedPool.


Orbinum precompiles​

AddressNameFunctionsReference
0x0000000000000000000000000000000000000801ShieldedPoolshield, payable: deposits ORB into the shielded poolShieldedPool Precompile
0x0000000000000000000000000000000000000802Balancestransfer, transferKeepAlive: pay ORB to any AccountId32Balances Precompile

No package publishes their interfaces; declare them in your project. Every registered address, including the cryptographic ones, is in Precompile Addresses.


The interface​

interface IBalances {
function transfer(bytes32 dest, uint256 value) external;
function transferKeepAlive(bytes32 dest, uint256 value) external;
}
ParameterMeaning
destThe recipient's 32-byte AccountId32: an sr25519 or ed25519 public key, or an EVM account's mapped form, the H160 followed by 12 zero bytes
valueAmount in planck (10¹⁸ per ORB); must fit in 128 bits

The precompile debits msg.sender: an externally owned account pays from its own balance, a contract from the contract's balance. transfer lets the transfer reap the sender and transferKeepAlive refuses to; with an existential deposit of 0 no account is ever reaped, so the two behave the same and either can take the sender's balance to zero.


From Solidity​

A contract that holds ORB and lets only its owner pay it out, to any account, including one with no EVM address:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IBalances {
function transfer(bytes32 dest, uint256 value) external;
function transferKeepAlive(bytes32 dest, uint256 value) external;
}

/// Holds ORB and lets its owner pay any AccountId32, including accounts with no EVM address.
contract Payouts {
address private constant BALANCES = 0x0000000000000000000000000000000000000802;

address public immutable owner;

error NotOwner();
error PayoutFailed();

event Paid(bytes32 indexed dest, uint256 amount);

constructor() {
owner = msg.sender;
}

/// Funds the contract: send ORB to its address.
receive() external payable {}

/// Pays `amount` of the contract's own ORB to `dest`.
function payout(bytes32 dest, uint256 amount) external {
if (msg.sender != owner) revert NotOwner();
// A low-level call: `ok` lets the contract handle a failed transfer, whose
// revert returns the unused gas.
(bool ok, ) = BALANCES.call(
abi.encodeCall(IBalances.transferKeepAlive, (dest, amount))
);
if (!ok) revert PayoutFailed();
emit Paid(dest, amount);
}
}
LineWhy
receive()Funds the contract with a plain ORB transfer to its address; the precompile pays from that balance
msg.sender != ownerWithout it, anyone could call payout and empty the contract
BALANCES.call(...)Returns ok instead of reverting the whole call. A high-level IBalances(BALANCES).transferKeepAlive(...) also reaches the precompile, since the address holds stub code, and bubbles up its revert reason
if (!ok) revertA failed transfer returns false; reverting keeps the contract's state consistent
emit PaidThe precompile emits no EVM log; the contract emits its own if indexers need one

The transfer itself appears as a Substrate Balances.Transfer event, not as an EVM log.


From ethers v6​

An externally owned account calls the precompile directly. addressToAccountIdHex from @orbinum/protocol turns an SS58 address, an AccountId32 hex or an H160 into the bytes32 dest:

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.ORBINUM_PRIVATE_KEY!, provider);

const balances = new ethers.Contract(
'0x0000000000000000000000000000000000000802',
[
'function transfer(bytes32 dest, uint256 value)',
'function transferKeepAlive(bytes32 dest, uint256 value)',
],
signer,
);

const dest = addressToAccountIdHex('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY');
if (!dest) throw new Error('not an SS58 address, AccountId32 or H160');

const tx = await balances.transferKeepAlive(dest, ethers.parseEther('1'));
const receipt = await tx.wait();
console.log('included in block', receipt?.blockNumber);

A top-level call has no extcodesize check, so ethers.Contract works as with any contract. An H160 passed to addressToAccountIdHex becomes its mapped AccountId32, the address followed by 12 zero bytes, not left-padded. The signer pays gas in ORB; test ORB comes from the faucet.


How failures look​

Both precompiles fail the same way: the call reverts with an Error(string) reason, decodable like a Solidity require message, and the unused gas is returned.

eth_call and eth_estimateGas report the reason, for example execution reverted: transfer failed: Token(FundsUnavailable), so estimating before sending shows why a call would fail. A contract calling the precompile receives ok == false and the encoded reason as return data. A mined transaction that fails shows status: 0 in its receipt.

The revert strings are listed in Balances Precompile and ShieldedPool Precompile.


Call rules​

RuleBalancesShieldedPool
STATICCALLRefusedRefused
DELEGATECALL and CALLCODERefusedRefused
Value (msg.value)RefusedRequired: it is the shielded amount
Callermsg.sender, mapped to its AccountId32The precompile's own mapped account dispatches shield

Refusing delegated calls means a contract moves only its own balance through Balances, never the balance of an account that called it. Proxies still work: an upgradeable proxy's implementation makes a normal CALL to the precompile, with the proxy as msg.sender.