Skip to main content

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

PropertyValue
Address0x0000000000000000000000000000000000000801
Index2049 (hash(2049))
Sourceframe/evm/precompile/shielded-pool/
Cratepallet-evm-precompile-shielded-pool

Function Selectors

Selectors are bytes4(keccak256("functionName(argTypes)")).

SelectorSolidity signatureOrigin
0x9feb22eashield(uint32,bytes32,bytes)payableprecompile address
0x66ed2cd4privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)unsigned
0x4e505348unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)unsigned
0x88d9debaclaimShieldedFees(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.

FunctionDispatch modeSubstrate origin
shieldfrom_selfThe precompile's own address, mapped via AddressMapping
privateTransferunsignedNoneensure_none in the pallet
unshieldunsignedNoneensure_none in the pallet
claimShieldedFeesfrom_callerThe 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.

Fee attribution

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;
ParameterTypeDescription
assetIduint32Registered asset identifier
commitmentbytes32Poseidon commitment: Poseidon4(value, assetId, ownerPk, blinding)
encryptedMemobytesEncrypted note data — exactly 180 bytes
The amount is msg.value

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

The memo is a fixed 180 bytes

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..]):

SlotTypeField
0..32uint32assetId
32..64bytes32commitment
64..96uint256offset → memo
at offsetbytesencryptedMemo

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;
ParameterTypeDescription
proofbytesGroth16 proof, max 512 bytes
rootbytes32Merkle root the proof was generated against
inputNullifiersbytes32[]Nullifiers of consumed notes (max 2)
outputCommitmentsbytes32[]Commitments of new output notes (max 2)
encryptedMemosbytes[]Encrypted data for each output note
assetIduint32Asset being transferred
feeuint256Gasless relay fee, deducted from the input sum
circuitVersionuint32Circuit 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.

The fee recipient is not in the calldata

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.

Sender-side recovery costs no calldata

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;
ParameterTypeDescription
proofbytesGroth16 unshield proof, max 512 bytes
rootbytes32Merkle root the proof was generated against
nullifierbytes32Nullifier of the consumed note
assetIduint32Asset to withdraw
amountuint256Amount released to recipient
recipientbytes32AccountId32 of the recipient
feeuint256Gasless relay fee
changeCommitmentbytes32bytes32(0) for a total unshield; the change note's commitment otherwise
changeEncryptedMemobytesEmpty for a total unshield; 180 bytes for a partial one
circuitVersionuint32Circuit 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 signalABI parameterExtrinsic fieldNotes
merkle_rootrootmerkle_root
nullifiernullifiernullifier
amountamountamount
recipientrecipientrecipient⚠️ Two different values — see below
asset_idassetIdasset_id
feefeefee
change_commitmentchangeCommitmentchange_commitment
changeEncryptedMemochange_encrypted_memoNot a circuit signal
circuitVersioncircuit_versionNot 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, recipient is the raw AccountId32 in a 32-byte slot.
  • In the circuit, recipient is 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;
ParameterTypeDescription
commitmentbytes32Commitment of the note being created
amountuint256Amount claimed, must not exceed pending fees
assetIduint32Asset of the claimed fees
memobytesEncrypted note data
proofbytesGroth16 value proof, max 512 bytes
publicSignalsbytes76 bytes: commitment[0..32] | value[32..40] | assetId[40..44] | ownerHash[44..76]
circuitVersionuint32Circuit 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:

Custody code — not publicly installable

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

ErrorCause
InvalidMemoSizeMemo is not exactly 180 bytes
InvalidAmountZero, or exceeds the circuit's u64 signal width
InvalidAssetIdAsset is not registered
AssetNotVerifiedAsset is registered but not yet verified — shielding is blocked
AssetIdMismatchAsset in the note does not match the one supplied
InvalidProofMalformed proof, or wrong length
ProofVerificationFailedWell-formed proof that does not verify — usually wrong circuitVersion, a stale root, or the recipient encoding trap above
InvalidPublicSignalsPublic signals do not match the extrinsic arguments
UnknownMerkleRootThe root is neither current nor a retained historic/sealed root — regenerate the proof
NullifierAlreadyUsedDouble-spend: the note is already spent
CommitmentAlreadyExistsThis commitment is already in the tree — reuse of a blinding factor
MerkleTreeFullGlobal forest capacity exhausted (u32 leaf index)
InsufficientPoolBalancePool cannot cover the withdrawal
TooManyInputsOrOutputsMore than 2 nullifiers or commitments
InvalidRecipientRecipient is the zero account
FeeTooLowFee below MinRelayFee
FeeRecipientUnavailableNeither a registered relayer nor a block author resolved
InsufficientPendingFeesclaimShieldedFees for more than the pending balance
CommitmentNotFoundReferenced commitment is not in the tree
MemoCommitmentMismatchMemo does not correspond to its commitment
EmptyBatch / AssetIdAlreadyExistsBatch and asset-registration paths

Events

Emitted by pallet-shielded-pool. Indexers and wallets need the first five to track their notes.

EventMeaning
ShieldedTokens deposited into the pool
CommitmentsInsertedNew commitments appended — this is how a wallet learns its note landed, and at which leaf index
MerkleRootUpdatedRoot advanced; carries old_root, new_root, tree_size
NullifiersSpentNotes consumed
UnshieldedWithdrawal executed; carries the optional change commitment and its leaf index
TreeSealedA tree filled and was sealed; its final root is now a permanent anchor
ValidatorFeesClaimedRelay fees converted into a private note
AssetRegistered / AssetVerified / AssetUnverifiedAsset lifecycle

Security Considerations

  • The precompile does not bypass ZK verification. Every proof is validated by pallet-zk-verifier against the VK of the supplied circuitVersion.
  • 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 privateTransfer and unshield is taken from the EVM caller, so it cannot be forged by a contract calling this precompile.