ShieldedPool Precompile
Address: 0x0000000000000000000000000000000000000801
The ShieldedPool precompile exposes four operations of pallet-shielded-pool to EVM clients. It
lets MetaMask users and Solidity contracts deposit tokens into the shielded pool, execute private
transfers, withdraw tokens, and — for validators — claim accumulated relay fees, all using standard
Ethereum transactions.
Overview
| Property | Value |
|---|---|
| Address | 0x0000000000000000000000000000000000000801 |
| Index | 2049 (hash(2049)) |
| Source | frame/evm/precompile/shielded-pool/ |
| Crate | pallet-evm-precompile-shielded-pool |
Function Selectors
Selectors are bytes4(keccak256("functionName(argTypes)")).
| Selector | Solidity signature | Origin |
|---|---|---|
0x9feb22ea | shield(uint32,bytes32,bytes) — payable | precompile address |
0x66ed2cd4 | privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32) | unsigned |
0x4e505348 | unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32) | unsigned |
0x88d9deba | claimShieldedFees(bytes32,uint256,uint32,bytes,bytes,bytes,uint32) | EVM caller |
Verify any of them with:
node -e "const {ethers}=require('ethers'); console.log(ethers.id('shield(uint32,bytes32,bytes)').slice(0,10))"
Origin model
Unlike most precompiles, ShieldedPool does not use a single caller-derived origin. Each
function dispatches under a different origin, because each is authenticated differently.
| Function | Dispatch mode | Substrate origin |
|---|---|---|
shield | from_self | The precompile's own address, mapped via AddressMapping |
privateTransfer | unsigned | None — ensure_none in the pallet |
unshield | unsigned | None — ensure_none in the pallet |
claimShieldedFees | from_caller | The EVM caller's H160, mapped via AddressMapping |
Why shield uses the precompile's own address: the function is payable, so the EVM executor has
already moved msg.value from the caller to the precompile address before execute runs. The
pallet then moves those funds from the precompile account into the pool, rather than debiting the
caller a second time.
Why privateTransfer and unshield are unsigned: the ZK proof is the authenticator. There is no
transaction signer to check, which is what makes gasless relaying possible.
For privateTransfer and unshield, the fee recipient is the dispatch origin, which this
precompile builds from handle.context().caller — the EVM address that sent the transaction and
paid its gas. It is not an ABI parameter and there is no extrinsic field for it, so it cannot be
spoofed. pallet-relayer resolves that address to the registered Substrate account it pays.
Functions
shield
Deposits public tokens into the shielded pool and appends a commitment to the active tree of the Merkle forest.
function shield(
uint32 assetId,
bytes32 commitment,
bytes calldata encryptedMemo
) external payable;
| Parameter | Type | Description |
|---|---|---|
assetId | uint32 | Registered asset identifier |
commitment | bytes32 | Poseidon commitment: Poseidon4(value, assetId, ownerPk, blinding) |
encryptedMemo | bytes | Encrypted note data — exactly 180 bytes |
msg.valueThere is no amount parameter. The deposited amount is taken from msg.value, which is why the
function must be declared payable. A non-payable call, or a call with msg.value == 0, is
rejected at the precompile boundary.
shield and privateTransfer reject any memo that is not exactly 180 bytes with InvalidMemoSize.
The layout is:
nonce(12) | ciphertext(120) | MAC(16) | ephPk(32) = 180
Build it with EncryptedMemo from the wallet SDK rather than by hand — the memo is what lets the recipient
find and decrypt the note, so a malformed one loses access to the funds it describes.
ABI layout (input[4..]):
| Slot | Type | Field |
|---|---|---|
0..32 | uint32 | assetId |
32..64 | bytes32 | commitment |
64..96 | uint256 | offset → memo |
| at offset | bytes | encryptedMemo |
privateTransfer
Executes a private transfer between shielded notes. Requires a valid Groth16 proof.
function privateTransfer(
bytes calldata proof,
bytes32 root,
bytes32[] calldata inputNullifiers,
bytes32[] calldata outputCommitments,
bytes[] calldata encryptedMemos,
uint32 assetId,
uint256 fee,
uint32 circuitVersion
) external;
| Parameter | Type | Description |
|---|---|---|
proof | bytes | Groth16 proof, max 512 bytes |
root | bytes32 | Merkle root the proof was generated against |
inputNullifiers | bytes32[] | Nullifiers of consumed notes (max 2) |
outputCommitments | bytes32[] | Commitments of new output notes (max 2) |
encryptedMemos | bytes[] | Encrypted data for each output note |
assetId | uint32 | Asset being transferred |
fee | uint256 | Gasless relay fee, deducted from the input sum |
circuitVersion | uint32 | Circuit version the spent notes were created under |
circuitVersion is what allows notes to remain spendable across verification-key rotations: the
proof is verified against that version's VK, not merely the currently active one. See
On-Chain Verification.
There is no relayer argument. The chain credits the fee to whoever signed this EVM
transaction — the same address that paid its gas — taken from the dispatch origin, which calldata
cannot influence. See
who receives the fee.
Nothing here carries a key for the sender's own benefit. A sender recognises the notes it sent by deriving their ephemeral keys from its own outgoing viewing key, reading data the memo already carries — see outgoing viewing keys.
The selector is computed over these eight arguments. A caller passing any other count computes a different selector and is rejected as unsupported.
unshield
Withdraws tokens from the shielded pool to a public account. Requires a valid Groth16 proof. Supports partial withdrawal with a change note.
function unshield(
bytes calldata proof,
bytes32 root,
bytes32 nullifier,
uint32 assetId,
uint256 amount,
bytes32 recipient,
uint256 fee,
bytes32 changeCommitment,
bytes calldata changeEncryptedMemo,
uint32 circuitVersion
) external;
| Parameter | Type | Description |
|---|---|---|
proof | bytes | Groth16 unshield proof, max 512 bytes |
root | bytes32 | Merkle root the proof was generated against |
nullifier | bytes32 | Nullifier of the consumed note |
assetId | uint32 | Asset to withdraw |
amount | uint256 | Amount released to recipient |
recipient | bytes32 | AccountId32 of the recipient |
fee | uint256 | Gasless relay fee |
changeCommitment | bytes32 | bytes32(0) for a total unshield; the change note's commitment otherwise |
changeEncryptedMemo | bytes | Empty for a total unshield; 180 bytes for a partial one |
circuitVersion | uint32 | Circuit version the spent note was created under |
recipient is an AccountId32 in a 32-byte slot. It can be a Substrate-native account or the
AccountId32 derived from an H160 (H160 ++ [0x00; 12]).
For a partial unshield the circuit enforces note_value == amount + fee + change_value, and
changeCommitment must equal Poseidon4(change_value, assetId, changeOwnerPk, changeBlinding).
Circuit signal → ABI parameter → extrinsic field
The same value carries three names across the three layers, and in one case two different values
share a name. This table reconciles them for unshield:
| Circuit signal | ABI parameter | Extrinsic field | Notes |
|---|---|---|---|
merkle_root | root | merkle_root | |
nullifier | nullifier | nullifier | |
amount | amount | amount | |
recipient | recipient | recipient | ⚠️ Two different values — see below |
asset_id | assetId | asset_id | |
fee | fee | fee | |
change_commitment | changeCommitment | change_commitment | |
| — | changeEncryptedMemo | change_encrypted_memo | Not a circuit signal |
| — | circuitVersion | circuit_version | Not a circuit signal |
| — | (derived from EVM caller) | (dispatch origin) | Named nowhere — not in the ABI, not an extrinsic argument |
recipient means two different things- In the ABI and the extrinsic,
recipientis the rawAccountId32in a 32-byte slot. - In the circuit,
recipientis that account hashed into a BN254 field element:Poseidon(le32(accountId32)).
Pass the raw account to the precompile, but the hashed field element when generating the proof.
Getting this backwards produces a proof that fails verification with no diagnostic beyond
InvalidProof.
The wallet SDK's generateUnshieldProof expects the field element form.
claimShieldedFees
Lets a validator convert accumulated relay fees into a shielded note. Requires a value proof (circuit ID 6).
function claimShieldedFees(
bytes32 commitment,
uint256 amount,
uint32 assetId,
bytes calldata memo,
bytes calldata proof,
bytes calldata publicSignals,
uint32 circuitVersion
) external;
| Parameter | Type | Description |
|---|---|---|
commitment | bytes32 | Commitment of the note being created |
amount | uint256 | Amount claimed, must not exceed pending fees |
assetId | uint32 | Asset of the claimed fees |
memo | bytes | Encrypted note data |
proof | bytes | Groth16 value proof, max 512 bytes |
publicSignals | bytes | 76 bytes: commitment[0..32] | value[32..40] | assetId[40..44] | ownerHash[44..76] |
circuitVersion | uint32 | Circuit version used to generate the proof |
The validator's identity comes from the EVM caller address, which must match the address registered
in pallet-relayer holding the pending fees. It is not an ABI parameter.
Solidity Interface
// SPDX-License-Identifier: Apache-2.0
pragma solidity >=0.8.0;
/// @dev Precompiled contract at address 0x...0801.
interface IShieldedPool {
/// @dev Payable — the deposited amount travels in `msg.value`, not in an argument.
function shield(
uint32 assetId,
bytes32 commitment,
bytes calldata encryptedMemo
) external payable;
function privateTransfer(
bytes calldata proof,
bytes32 root,
bytes32[] calldata inputNullifiers,
bytes32[] calldata outputCommitments,
bytes[] calldata encryptedMemos,
uint32 assetId,
uint256 fee,
uint32 circuitVersion
) external;
function unshield(
bytes calldata proof,
bytes32 root,
bytes32 nullifier,
uint32 assetId,
uint256 amount,
bytes32 recipient,
uint256 fee,
bytes32 changeCommitment,
bytes calldata changeEncryptedMemo,
uint32 circuitVersion
) external;
function claimShieldedFees(
bytes32 commitment,
uint256 amount,
uint32 assetId,
bytes calldata memo,
bytes calldata proof,
bytes calldata publicSignals,
uint32 circuitVersion
) external;
}
Usage Example
Shielding is the only operation that needs no ZK proof, so it is the one you can call directly:
import { ethers } from 'ethers';
const SHIELDED_POOL = '0x0000000000000000000000000000000000000801';
const abi = [
'function shield(uint32 assetId, bytes32 commitment, bytes encryptedMemo) payable',
];
const pool = new ethers.Contract(SHIELDED_POOL, abi, signer);
// `commitment` and `encryptedMemo` come from a wallet — see below.
await pool.shield(0, commitment, encryptedMemo, {
value: ethers.parseEther('1'),
});
Chain access, address handling, and calldata decoding come from the public package:
import { OrbinumClient, decodePrecompileCalldata } from '@orbinum/protocol';
Building the commitment and memo by hand is error-prone, but doing it for you requires a spending key, so it is custody-side rather than part of the public package:
@orbinum/wallet-sdk is a private package. This snippet shows the shape of the inputs, not
something you can install and run. Reaching this API requires
wallet SDK access.
import { computeNoteCommitment, EncryptedMemo } from '@orbinum/wallet-sdk';
privateTransfer and unshield additionally need a Groth16 proof, which is generated the same
way — see the wallet SDK for where that sits, and the
public SDK reference for everything you can install.
Dispatch Flow
EVM Transaction (H160 sender)
└── precompile address: 0x…0801
└── ShieldedPoolPrecompile::execute(handle)
├── decode selector (4 bytes)
├── decode calldata (ABI head/tail)
└── dispatch by origin mode:
├── shield → from_self (precompile address)
├── privateTransfer → unsigned (None)
├── unshield → unsigned (None)
└── claimShieldedFees → from_caller (EVM caller)
└── pallet-zk-verifier verifies the proof
(all except shield)
Gas is charged up front from the dispatch weight of the resulting call, via GasWeightMapping.
Errors
Pallet errors surface through the EVM as ExitError::Other("<Debug of the error>"). There are no
Solidity custom errors, so you cannot catch them by type — match on the string, or use this table
to interpret a revert.
| Error | Cause |
|---|---|
InvalidMemoSize | Memo is not exactly 180 bytes |
InvalidAmount | Zero, or exceeds the circuit's u64 signal width |
InvalidAssetId | Asset is not registered |
AssetNotVerified | Asset is registered but not yet verified — shielding is blocked |
AssetIdMismatch | Asset in the note does not match the one supplied |
InvalidProof | Malformed proof, or wrong length |
ProofVerificationFailed | Well-formed proof that does not verify — usually wrong circuitVersion, a stale root, or the recipient encoding trap above |
InvalidPublicSignals | Public signals do not match the extrinsic arguments |
UnknownMerkleRoot | The root is neither current nor a retained historic/sealed root — regenerate the proof |
NullifierAlreadyUsed | Double-spend: the note is already spent |
CommitmentAlreadyExists | This commitment is already in the tree — reuse of a blinding factor |
MerkleTreeFull | Global forest capacity exhausted (u32 leaf index) |
InsufficientPoolBalance | Pool cannot cover the withdrawal |
TooManyInputsOrOutputs | More than 2 nullifiers or commitments |
InvalidRecipient | Recipient is the zero account |
FeeTooLow | Fee below MinRelayFee |
FeeRecipientUnavailable | Neither a registered relayer nor a block author resolved |
InsufficientPendingFees | claimShieldedFees for more than the pending balance |
CommitmentNotFound | Referenced commitment is not in the tree |
MemoCommitmentMismatch | Memo does not correspond to its commitment |
EmptyBatch / AssetIdAlreadyExists | Batch and asset-registration paths |
Events
Emitted by pallet-shielded-pool. Indexers and wallets need the first five to track their notes.
| Event | Meaning |
|---|---|
Shielded | Tokens deposited into the pool |
CommitmentsInserted | New commitments appended — this is how a wallet learns its note landed, and at which leaf index |
MerkleRootUpdated | Root advanced; carries old_root, new_root, tree_size |
NullifiersSpent | Notes consumed |
Unshielded | Withdrawal executed; carries the optional change commitment and its leaf index |
TreeSealed | A tree filled and was sealed; its final root is now a permanent anchor |
ValidatorFeesClaimed | Relay fees converted into a private note |
AssetRegistered / AssetVerified / AssetUnverified | Asset lifecycle |
Security Considerations
- The precompile does not bypass ZK verification. Every proof is validated by
pallet-zk-verifieragainst the VK of the suppliedcircuitVersion. - Double-spend prevention is enforced by the nullifier set in
pallet-shielded-pool. - Sealed Merkle roots are permanent anchors, so a proof generated against an older tree stays valid indefinitely. See Privacy Architecture.
- The relayer attribution for
privateTransferandunshieldis taken from the EVM caller, so it cannot be forged by a contract calling this precompile.
Related
- Balances Precompile (
0x…0802) — pay Substrate-native accounts - Precompiles Overview — full precompile listing
- Privacy Architecture — ZK proof system overview