Skip to main content

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​

PropertyValue
Address0x0000000000000000000000000000000000000801
Index2049
Functionshield(uint32,bytes32,bytes,bytes,uint32), payable
Selector0xf25897e0
DispatchesShieldedPool.shield, signed by the precompile's own account
Amountmsg.value, in planck (18 decimals)
GasThe weight of the dispatched call
EventsSubstrate 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​

ArgumentEncodingRequirement
assetIduint320 (ORB). Any other value fails in the pallet.
commitmentbytes32The 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.
encryptedMemobytesExactly 180 bytes: nonce(12) ‖ ciphertext(120) ‖ tag(16) ‖ ephPk(32). See The encrypted memo.
proofbytesA 128-byte compressed Groth16 shield proof from @orbinum/proof-generator, over the public inputs commitment, value = msg.value and asset_id.
circuitVersionuint32The 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)TypeField
0..32uint32, right-alignedassetId
32..64bytes32commitment
64..96uint256Offset of encryptedMemo
96..128uint256Offset of proof
128..160uint32, right-alignedcircuitVersion
tailuint256 length + data, padded to 32 bytesencryptedMemo, 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.

ConsequenceDetail
depositor in ShieldedThe precompile's account above, for every EVM shield. It does not identify the user.
Who paidThe EVM transaction's from (or the calling contract) and its value, both public.
STATICCALLReverts with static or delegated calls are not allowed. A static call may not change state.
DELEGATECALL, CALLCODERevert 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 pointGas 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​

ReasonCause
input too short: missing selectorCalldata shorter than 4 bytes
static or delegated calls are not allowedSTATICCALL, DELEGATECALL or CALLCODE
unknown selectorA selector the precompile does not define
retired in spec 19: submit spends unsigned via SubstrateOne of the other selectors
shield: input too shortFewer than 160 bytes after the selector
uint32 slot exceeds u32assetId or circuitVersion word wider than 32 bits
shield: amount must be non-zeromsg.value is 0
shield: msg.value overflowmsg.value does not fit in u128
shield: memo too long or wrong sizeencryptedMemo longer than 180 bytes
shield: proof too longproof longer than 512 bytes
shield: proof must be non-emptyproof is empty
offset slot, length word, bytes data, bytes offset overflows, offset does not fit in usize, length does not fit in usizeA 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:

ErrorCause
InvalidAssetIdassetId is not registered
AssetNotVerifiedassetId is registered but not verified
AssetNotSupportedassetId is not 0: only ORB moves through the pool
InvalidMemoSizeencryptedMemo is shorter than 180 bytes
InvalidPublicSignalscommitment is not a canonical little-endian field element, or is zero
CommitmentAlreadyExistscommitment is already in the forest
UnsupportedCircuitVersioncircuitVersion has no registered shield key, or is retired (from the ZK verifier)
ProofVerificationFailedThe proof does not open commitment to msg.value of assetId, or is malformed
MerkleTreeFullThe 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.

EventFields
ShieldedPool.MerkleRootUpdatedold_root, new_root, tree_size (total leaves across the whole forest)
ShieldedPool.TreeSealedtree_id, final_root, first_leaf_index, leaf_count; only when this leaf fills a tree
ShieldedPool.Shieldeddepositor, 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.

SelectorFunctionInstead
0x63d0b9a0privateTransferUnsigned ShieldedPool.private_transfer extrinsic
0x4e505348unshieldUnsigned ShieldedPool.unshield extrinsic
0xc9b235ffcommitRelayNo equivalent
0x2a3274ddclaimRelayFeesSigned 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();
Who builds the note

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 ProofVerificationFailed and 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.
  • depositor is always the precompile's account for EVM shields. Attribute a deposit through its EVM transaction, not through that field.
  • Only a plain CALL reaches shield. Static and delegated calls revert before decoding, so the deposited value is always the value sent to 0x…0801.