ShieldedPool Precompile
The ShieldedPool precompile lets an EVM account or contract deposit ORB into the shielded pool with
an ordinary Ethereum transaction. It exposes one function, shield, and dispatches it to the
ShieldedPool.shield extrinsic of the runtime. Spends do not go through the EVM; see
Other selectors.
Overview
| Property | Value |
|---|---|
| Address | 0x0000000000000000000000000000000000000801 |
| Index | 2049 |
| Function | shield(uint32,bytes32,bytes,bytes,uint32), payable |
| Selector | 0xf25897e0 |
| Dispatches | ShieldedPool.shield, signed by the precompile's own account |
| Amount | msg.value, in planck (18 decimals) |
| Gas | The weight of the dispatched call |
| Events | Substrate events only; no EVM logs |
The address holds a five-byte stub, 0x60006000fd, that never runs; it lets Solidity's high-level
calls reach the precompile. See Precompile Addresses.
shield
function shield(
uint32 assetId,
bytes32 commitment,
bytes encryptedMemo,
bytes proof,
uint32 circuitVersion
) external payable;
shield moves msg.value from the caller into the pool and appends commitment to the active tree
of the Merkle forest. The proof must show that the commitment opens to
exactly msg.value of assetId; it is verified before any funds move.
Arguments
| Argument | Encoding | Requirement |
|---|---|---|
assetId | uint32 | 0 (ORB). Any other value fails in the pallet. |
commitment | bytes32 | The note commitment Poseidon4(value, assetId, ownerPk, blinding) as a 32-byte little-endian field element. It must be canonical (below the BN254 scalar modulus), non-zero, and not already in the forest. |
encryptedMemo | bytes | Exactly 180 bytes: nonce(12) ‖ ciphertext(120) ‖ tag(16) ‖ ephPk(32). See The encrypted memo. |
proof | bytes | A 128-byte compressed Groth16 shield proof from @orbinum/proof-generator, over the public inputs commitment, value = msg.value and asset_id. |
circuitVersion | uint32 | The shield circuit's active version (circuit id 3). Read it with zkVerifier_getCircuitVersionInfo(3); on the testnet it is 1. |
msg.value | — | The deposit, in planck. Must be non-zero. There is no amount argument. |
A bytes32 commitment is the little-endian byte string of the field element, not the big-endian
uint256 form. commitmentHexOf(bigint) from @orbinum/protocol produces it.
ABI layout
Standard Solidity ABI encoding. After the 4-byte selector, the head is five 32-byte slots
(160 bytes); the two bytes values follow in the tail.
| Bytes (after selector) | Type | Field |
|---|---|---|
0..32 | uint32, right-aligned | assetId |
32..64 | bytes32 | commitment |
64..96 | uint256 | Offset of encryptedMemo |
96..128 | uint256 | Offset of proof |
128..160 | uint32, right-aligned | circuitVersion |
| tail | uint256 length + data, padded to 32 bytes | encryptedMemo, then proof |
A uint32 slot whose 32-byte word exceeds 2^32 − 1 is refused, not truncated. Every offset and
length is bounds-checked against the calldata.
Origin model
The precompile dispatches ShieldedPool.shield with a signed origin whose account is the
precompile's own mapped address, not the caller's:
0x0000000000000000000000000000000000000801 000000000000000000000000
The EVM moves msg.value from the caller to 0x…0801 before the precompile runs. The pallet then
transfers that amount from the precompile's account to the pool account, so the caller is debited
once.
| Consequence | Detail |
|---|---|
depositor in Shielded | The precompile's account above, for every EVM shield. It does not identify the user. |
| Who paid | The EVM transaction's from (or the calling contract) and its value, both public. |
STATICCALL | Reverts with static or delegated calls are not allowed. A static call may not change state. |
DELEGATECALL, CALLCODE | Revert with the same reason. Under delegation the value would sit at the delegating contract's address, not at 0x…0801. |
A shield is public: the sender, the amount and the commitment are all on-chain. Privacy starts with the notes it creates.
Dispatch flow
Gas
There is no flat cost. Before dispatching, the precompile charges the dispatch weight of
ShieldedPool.shield, which includes one proof verification, converted to gas by the runtime's
GasWeightMapping. The transaction pays that plus the usual intrinsic and calldata gas.
| Failure point | Gas consumed |
|---|---|
Decoding (any shield: … revert, bad ABI) | Only what was spent before the revert; the rest is returned |
| Inside the pallet (proof, commitment, asset) | The full dispatch weight, already charged; the rest is returned |
Estimate rather than hard-code a figure, and include value: msg.value = 0 reverts, so an
estimate without it always fails. With @orbinum/protocol,
client.precompiles.shieldedPool.estimateShieldGas(params, from) sends eth_estimateGas with the
calldata and the amount. from must be the real sender, whose balance funds msg.value.
Errors
Every failure is a revert with a Solidity Error(string) reason (0x08c379a0). The caller gets its
unused gas back, and eth_call / eth_estimateGas return the reason in the error message. There
are no custom errors; match on the string.
Precompile reverts
| Reason | Cause |
|---|---|
input too short: missing selector | Calldata shorter than 4 bytes |
static or delegated calls are not allowed | STATICCALL, DELEGATECALL or CALLCODE |
unknown selector | A selector the precompile does not define |
retired in spec 19: submit spends unsigned via Substrate | One of the other selectors |
shield: input too short | Fewer than 160 bytes after the selector |
uint32 slot exceeds u32 | assetId or circuitVersion word wider than 32 bits |
shield: amount must be non-zero | msg.value is 0 |
shield: msg.value overflow | msg.value does not fit in u128 |
shield: memo too long or wrong size | encryptedMemo longer than 180 bytes |
shield: proof too long | proof longer than 512 bytes |
shield: proof must be non-empty | proof is empty |
offset slot, length word, bytes data, bytes offset overflows, offset does not fit in usize, length does not fit in usize | A malformed bytes offset or length |
Pallet errors
When the dispatched call fails, the reason is the debug form of the dispatch error. It contains the
error's name, for example … message: Some("ProofVerificationFailed") …, so match by substring. In
the order the pallet checks them:
| Error | Cause |
|---|---|
InvalidAssetId | assetId is not registered |
AssetNotVerified | assetId is registered but not verified |
AssetNotSupported | assetId is not 0: only ORB moves through the pool |
InvalidMemoSize | encryptedMemo is shorter than 180 bytes |
InvalidPublicSignals | commitment is not a canonical little-endian field element, or is zero |
CommitmentAlreadyExists | commitment is already in the forest |
UnsupportedCircuitVersion | circuitVersion has no registered shield key, or is retired (from the ZK verifier) |
ProofVerificationFailed | The proof does not open commitment to msg.value of assetId, or is malformed |
MerkleTreeFull | The forest's u32 leaf-index space is exhausted |
The full list of pool errors is in Spend Validity.
Events
The precompile emits no EVM logs: the receipt's logs array is empty and eth_getLogs finds
nothing. A successful shield deposits Substrate events in the same extrinsic as the EVM transaction.
Read them with polkadot-api or @orbinum/protocol; see Indexing Events.
| Event | Fields |
|---|---|
ShieldedPool.MerkleRootUpdated | old_root, new_root, tree_size (total leaves across the whole forest) |
ShieldedPool.TreeSealed | tree_id, final_root, first_leaf_index, leaf_count; only when this leaf fills a tree |
ShieldedPool.Shielded | depositor, amount, commitment, encrypted_memo, leaf_index |
depositor is the 0x…0801 account, as described in Origin model. leaf_index
is the note's global position, which a wallet needs to build its Merkle path. The extrinsic also
carries the Balances.Transfer events for caller → 0x…0801 and 0x…0801 → pool.
Other selectors
Four further selectors are recognised only to be refused. Each reverts with the literal reason
retired in spec 19: submit spends unsigned via Substrate.
| Selector | Function | Instead |
|---|---|---|
0x63d0b9a0 | privateTransfer | Unsigned ShieldedPool.private_transfer extrinsic |
0x4e505348 | unshield | Unsigned ShieldedPool.unshield extrinsic |
0xc9b235ff | commitRelay | No equivalent |
0x2a3274dd | claimRelayFees | Signed ShieldedPool.claim_fees, by a validator; see Claiming |
Spends are unsigned Substrate extrinsics authorised by their zero-knowledge proof, so no account, signature or gas payment links an address to a spend. See Spend Validity.
Solidity interface
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.20;
/// Precompile at 0x0000000000000000000000000000000000000801.
interface IShieldedPool {
/// Deposits msg.value of `assetId` as the note `commitment`.
function shield(
uint32 assetId,
bytes32 commitment,
bytes calldata encryptedMemo,
bytes calldata proof,
uint32 circuitVersion
) external payable;
}
A high-level call IShieldedPool(0x…0801).shield{value: amount}(…) works, because the address holds
stub code. A low-level call lets the contract read the revert reason and handle a failed shield:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IShieldedPool} from "./IShieldedPool.sol";
/// Shields part of this contract's own balance. Only the owner can call it.
contract Shielder {
address constant SHIELDED_POOL = 0x0000000000000000000000000000000000000801;
address public immutable owner = msg.sender;
receive() external payable {}
function shield(
uint256 amount,
bytes32 commitment,
bytes calldata encryptedMemo,
bytes calldata proof,
uint32 circuitVersion
) external {
require(msg.sender == owner, "not owner");
(bool ok, bytes memory reason) = SHIELDED_POOL.call{value: amount}(
abi.encodeCall(IShieldedPool.shield, (0, commitment, encryptedMemo, proof, circuitVersion))
);
if (!ok) {
// Bubble up the precompile's Error(string).
assembly { revert(add(reason, 32), mload(reason)) }
}
}
}
The contract's balance funds the deposit. The note belongs to whoever holds the key inside the commitment, not to the contract.
Calling from TypeScript
With @orbinum/protocol and an ethers v6 signer. The package needs its peers polkadot-api and
@polkadot/util-crypto installed.
import { ethers } from 'ethers';
import { OrbinumClient, type ShieldParams } from '@orbinum/protocol';
/** The wallet-built parts of a shield. */
type PreparedNote = {
commitment: string; // 0x-prefixed 32-byte little-endian hex
encryptedMemo: Uint8Array; // exactly 180 bytes
proof: Uint8Array; // 128-byte compressed Groth16 shield proof
};
export async function shieldFromEvm(signer: ethers.Wallet, note: PreparedNote, amount: bigint) {
const client = await OrbinumClient.connect({
substrateWs: 'wss://rpc-1.testnet.orbinum.io',
evmRpc: 'https://rpc-1.testnet.orbinum.io',
});
try {
const shieldedPool = client.precompiles!.shieldedPool; // non-null: evmRpc is set
const circuit = await client.zkVerifier.getCircuitVersionInfo(3); // shield circuit
if (!circuit) throw new Error('shield circuit not registered');
const params: ShieldParams = {
assetId: 0,
amount,
...note,
circuitVersion: circuit.activeVersion,
};
const gasLimit = await shieldedPool.estimateShieldGas(params, signer.address);
const txHash = await shieldedPool.shield(params, async (tx) => {
const sent = await signer.sendTransaction({ ...tx, gasLimit });
return sent.hash;
});
const receipt = await signer.provider!.waitForTransaction(txHash);
if (receipt?.status !== 1) throw new Error(`shield reverted: ${txHash}`);
return txHash;
} finally {
client.destroy();
}
}
shield validates every field before encoding, sends the calldata to 0x…0801 with
value = amount, and returns the transaction hash. With ethers alone, the same call is:
const pool = new ethers.Contract(
'0x0000000000000000000000000000000000000801',
['function shield(uint32 assetId, bytes32 commitment, bytes encryptedMemo, bytes proof, uint32 circuitVersion) payable'],
signer,
);
const tx = await pool.shield(0, note.commitment, note.encryptedMemo, note.proof, 1, { value: amount });
await tx.wait();
For a privacy address, the commitment, the encrypted memo and the proof are produced by the wallet.
The public packages generate the shield proof (@orbinum/proof-generator) and encode a commitment
for the chain (commitmentHexOf in @orbinum/protocol); the commitment itself is a circomlib
Poseidon4, such as poseidon4 from poseidon-lite. They do not seal memos or parse orbpriv3
addresses. See the custody boundary.
Security considerations
- The proof is verified before any funds move. A proof for a different amount, asset or
commitment fails with
ProofVerificationFailedand nothing is transferred. - The memo is the only on-chain copy of the note's secrets. A memo that does not decrypt for the owner leaves the note unrecoverable from the chain, even though the deposit succeeds.
- The circuit does not check who owns the note. A commitment to a key nobody holds locks the deposit permanently.
- A shield links the sender's EVM address and the amount to the new commitment. The privacy of the note comes from the spends that follow, not from the deposit.
depositoris always the precompile's account for EVM shields. Attribute a deposit through its EVM transaction, not through that field.- Only a plain
CALLreachesshield. Static and delegated calls revert before decoding, so the deposited value is always the value sent to0x…0801.
Related
- Precompile Addresses — the full listing of registered precompiles
- Calling a Precompile — a first call from Solidity and ethers
- Balances Precompile — pay any
AccountId32from the EVM - Shield Circuit — what the shield proof binds
- Spend Validity — how spends are checked as unsigned extrinsics
- @orbinum/protocol — client, precompile bindings and proofs
- Indexing Events — reading
Shieldedand the other pool events