Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions frame/evm/precompile/shielded-pool/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

All notable changes to `pallet-evm-precompile-shielded-pool` will be documented in this file.

## [Unreleased]

## [0.8.0] - 2026-10-05

**Breaking** — the shield ABI changes. Requires the matching `pallet-shielded-pool`.

### Changed

- `shield(uint32,bytes32,bytes,bytes,uint32)` — `0xf25897e0`, was
`shield(uint32,bytes32,bytes)` `0x9feb22ea`. Adds the shield `proof` and its
`circuitVersion`; the amount is still `msg.value`. Old three-slot calldata
reverts with `shield: input too short`, an empty proof with
`shield: proof must be non-empty`.
- The proof bound is `pallet_shielded_pool::Proof`; the precompile no longer
keeps its own copy of the 512-byte limit.

## [0.7.0] - 2026-09-29

**Breaking** — `claimShieldedFees` is gone. Requires the matching
Expand Down
2 changes: 1 addition & 1 deletion frame/evm/precompile/shielded-pool/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pallet-evm-precompile-shielded-pool"
version = "0.7.0"
version = "0.8.0"
authors = { workspace = true }
edition = "2021"
description = "EVM Precompile for Orbinum Shielded Pool Pallet."
Expand Down
9 changes: 1 addition & 8 deletions frame/evm/precompile/shielded-pool/src/calls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,11 @@ pub mod shield;
pub mod unshield;

use fp_evm::PrecompileFailure;
use frame_support::{traits::ConstU32, BoundedVec};
use pallet_shielded_pool::BalanceOf;
use pallet_shielded_pool::{BalanceOf, Proof};
use sp_core::U256;

use crate::{abi, revert};

/// Maximum byte length of a serialised Groth16 proof accepted by the pallet.
const MAX_PROOF_LEN: u32 = 512;

/// A spend's proof, bounded as the pallet call expects it.
type Proof = BoundedVec<u8, ConstU32<MAX_PROOF_LEN>>;

/// The parameters after the selector, once they hold the whole `head_len`-byte
/// head. Offsets in the head point into the tail, which each dynamic decoder
/// bounds-checks on its own.
Expand Down
44 changes: 30 additions & 14 deletions frame/evm/precompile/shielded-pool/src/calls/shield.rs
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
//! ABI decoding and call construction for `shield(uint32,bytes32,bytes)`.
//! ABI decoding and call construction for `shield(uint32,bytes32,bytes,bytes,uint32)`.
//!
//! ## Selector
//! `keccak256("shield(uint32,bytes32,bytes)")[0..4]` = `0x9feb22ea`
//! `keccak256("shield(uint32,bytes32,bytes,bytes,uint32)")[0..4]` = `0xf25897e0`
//!
//! ## ABI layout (`input[4..]`)
//! | Slot (bytes) | Type | Field |
//! |--------------|-----------|------------------|
//! | 0..32 | `uint32` | `asset_id` |
//! | 32..64 | `bytes32` | `commitment` |
//! | 64..96 | `uint256` | offset → memo |
//! | Slot (bytes) | Type | Field |
//! |--------------|-----------|-------------------|
//! | 0..32 | `uint32` | `asset_id` |
//! | 32..64 | `bytes32` | `commitment` |
//! | 64..96 | `uint256` | offset → memo |
//! | 96..128 | `uint256` | offset → proof |
//! | 128..160 | `uint32` | `circuit_version` |
//!
//! ## Notes
//! `amount` is not in the ABI: it is `msg.value`, which the EVM executor has
//! already transferred to the precompile's address before `execute` runs.
//! already transferred to the precompile's address before `execute` runs. The
//! proof binds the commitment to that amount.

use fp_evm::{PrecompileFailure, PrecompileHandle};

use super::{balance, params};
use super::{balance, params, proof};
use crate::{abi, revert};

/// Selector for the signature in this module's header.
pub const SELECTOR: [u8; 4] = [0x9f, 0xeb, 0x22, 0xea];
pub const SELECTOR: [u8; 4] = [0xf2, 0x58, 0x97, 0xe0];

/// Decodes `input` into a ready-to-dispatch `shield` call.
///
/// `handle` supplies the amount via `apparent_value` (`msg.value`); the calldata
/// carries only the asset, the commitment, and the memo.
/// carries the asset, the commitment, the memo and the shield proof.
pub fn decode<T>(
handle: &impl PrecompileHandle,
input: &[u8],
Expand All @@ -34,9 +37,9 @@ where
T: pallet_shielded_pool::Config,
pallet_shielded_pool::BalanceOf<T>: TryFrom<u128>,
{
// Step 1: require the three-slot head. The memo offset it carries is bounds
// checked by the tail decoder in step 5.
let params = params(input, 96, "shield: input too short")?;
// Step 1: require the five-slot head. The memo and proof offsets it carries
// are bounds checked by their tail decoders.
let params = params(input, 160, "shield: input too short")?;

// Step 2: asset_id.
let asset_id = abi::read_u32(params, 0)?;
Expand All @@ -63,10 +66,23 @@ where
let encrypted_memo = pallet_shielded_pool::FrameEncryptedMemo::new(memo_bytes)
.map_err(|_| revert("shield: memo too long or wrong size"))?;

// Step 6: proof — dynamic, offset at slot 96.
let proof = proof(
params,
96,
"shield: proof too long",
"shield: proof must be non-empty",
)?;

// Step 7: circuit_version.
let circuit_version = abi::read_u32(params, 128)?;

Ok(pallet_shielded_pool::Call::<T>::shield {
asset_id,
amount,
commitment,
encrypted_memo,
proof,
circuit_version,
})
}
2 changes: 1 addition & 1 deletion frame/evm/precompile/shielded-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub use calls::private_transfer::MAX_NOTES as MAX_SPEND_INPUTS;
///
/// | Selector | Solidity signature |
/// |--------------|---------------------------------------------------------------------------------------|
/// | `0x9feb22ea` | `shield(uint32,bytes32,bytes)` — payable, amount = `msg.value` |
/// | `0xf25897e0` | `shield(uint32,bytes32,bytes,bytes,uint32)` — payable, amount = `msg.value` |
/// | `0x66ed2cd4` | `privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)` |
/// | `0x4e505348` | `unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)` |
/// | `0xc9b235ff` | `commitRelay(bytes32[])` — registered relayers |
Expand Down
10 changes: 9 additions & 1 deletion frame/evm/precompile/shielded-pool/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use frame_support::{derive_impl, parameter_types, traits::Get, weights::Weight,
use pallet_evm::{
AddressMapping, Context, EnsureAddressNever, EnsureAddressRoot, FeeCalculator, PrecompileHandle,
};
use pallet_zk_verifier::{TransferStatement, UnshieldStatement, ZkVerifierPort};
use pallet_zk_verifier::{ShieldStatement, TransferStatement, UnshieldStatement, ZkVerifierPort};
use sp_core::{H160, H256, U256};
use sp_runtime::{
traits::{BlakeTwo256, IdentityLookup},
Expand Down Expand Up @@ -145,6 +145,14 @@ impl ZkVerifierPort for MockZkVerifier {
non_empty(proof)
}

fn verify_shield_proof(
proof: &[u8],
_statement: &ShieldStatement,
_version: Option<u32>,
) -> Result<bool, sp_runtime::DispatchError> {
non_empty(proof)
}

fn is_supported_version(_circuit_id: u32, version: u32) -> bool {
version != 0
}
Expand Down
2 changes: 1 addition & 1 deletion frame/evm/precompile/shielded-pool/src/tests/abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ fn router_rejects_empty_input() {
#[test]
fn router_rejects_3_byte_selector() {
new_test_ext().execute_with(|| {
let mut h = MockHandle::new(vec![0x9f, 0xeb, 0x22]);
let mut h = MockHandle::new(crate::calls::shield::SELECTOR[..3].to_vec());
expect_error(ShieldedPoolPrecompile::<Test>::execute(&mut h));
});
}
Expand Down
27 changes: 22 additions & 5 deletions frame/evm/precompile/shielded-pool/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,32 @@ fn encode_bytes_array(items: &[Vec<u8>]) -> Vec<u8> {

// ─── Call encoders ───────────────────────────────────────────────────────────

/// `shield(uint32,bytes32,bytes)`, selector `0x9feb22ea`.
/// `shield(uint32,bytes32,bytes,bytes,uint32)` with a well-formed proof at version 1.
fn encode_shield(asset_id: u32, commitment: [u8; 32], memo: &[u8]) -> Vec<u8> {
let mut input = vec![0x9f, 0xeb, 0x22, 0xea];
let mut head = vec![0u8; 96];
encode_shield_with(asset_id, commitment, memo, &[0x01; 128], 1)
}

/// `shield(uint32,bytes32,bytes,bytes,uint32)`, selector `0xf25897e0`.
fn encode_shield_with(
asset_id: u32,
commitment: [u8; 32],
memo: &[u8],
proof: &[u8],
circuit_version: u32,
) -> Vec<u8> {
let memo_enc = encode_bytes(memo);
let head_size = 160usize;

let mut input = crate::calls::shield::SELECTOR.to_vec();
let mut head = vec![0u8; head_size];
head[28..32].copy_from_slice(&asset_id.to_be_bytes());
head[32..64].copy_from_slice(&commitment);
head[64..96].copy_from_slice(&u256_word(96));
head[64..96].copy_from_slice(&u256_word(head_size));
head[96..128].copy_from_slice(&u256_word(head_size + memo_enc.len()));
head[156..160].copy_from_slice(&circuit_version.to_be_bytes());
input.extend_from_slice(&head);
input.extend_from_slice(&encode_bytes(memo));
input.extend_from_slice(&memo_enc);
input.extend_from_slice(&encode_bytes(proof));
input
}

Expand Down
62 changes: 62 additions & 0 deletions frame/evm/precompile/shielded-pool/src/tests/shield.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,68 @@ fn shield_rejects_truncated_input() {
});
}

/// The pre-proof ABI (`asset_id, commitment, memo`) is three slots: too short now,
/// so an outdated client fails loudly instead of shielding without a proof.
#[test]
fn shield_rejects_the_old_three_slot_head() {
new_test_ext().execute_with(|| {
let mut input = crate::calls::shield::SELECTOR.to_vec();
input.extend_from_slice(&[0u8; 96]);
let mut h = MockHandle::with_value(input, 1_000);
expect_error_msg(
ShieldedPoolPrecompile::<Test>::execute(&mut h),
"shield: input too short",
);
});
}

#[test]
fn shield_rejects_an_empty_proof() {
new_test_ext().execute_with(|| {
let input = encode_shield_with(0, canon(0x12), &[0xAB; 180], &[], 1);
let mut h = MockHandle::with_value(input, 1_000);
expect_error_msg(
ShieldedPoolPrecompile::<Test>::execute(&mut h),
"shield: proof must be non-empty",
);
assert_eq!(pallet_shielded_pool::MerkleTreeSize::<Test>::get(), 0);
});
}

/// The amount reaches the pallet from `msg.value`, and the proof and version
/// from the calldata, unchanged.
#[test]
fn shield_decodes_proof_and_version_into_the_call() {
new_test_ext().execute_with(|| {
let proof = [0x5Au8; 96];
let input = encode_shield_with(3, canon(0x13), &[0xAB; 180], &proof, 7);
let h = MockHandle::with_value(input.clone(), 1_000);
let call = crate::calls::shield::decode::<Test>(&h, &input)
.ok()
.unwrap();
match call {
pallet_shielded_pool::Call::shield {
asset_id,
amount,
commitment,
proof: decoded,
circuit_version,
..
} => {
assert_eq!(asset_id, 3);
assert_eq!(amount, 1_000);
assert_eq!(
commitment,
pallet_shielded_pool::Commitment::from(canon(0x13))
);
assert_eq!(decoded.into_inner(), proof.to_vec());
assert_eq!(circuit_version, 7);
}
other => panic!("decoded to {other:?}"),
}
});
}

#[test]
fn shield_accepts_smallest_non_zero_amount() {
// There is no minimum shield amount: msg.value = 1 must go through.
Expand Down
35 changes: 35 additions & 0 deletions frame/shielded-pool/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,41 @@

All notable changes to `pallet-shielded-pool` will be documented in this file.

## [Unreleased]

## [0.21.0] - 2026-10-05

A shield must prove its note is worth exactly the deposit. Closes AL-1: a deposit
of 1 could insert a note worth 1000. **Breaking** — `shield` and `shield_batch`
change signature; `transaction_version` must move.

### Changed

- **`shield`** takes `proof: Proof` and `circuit_version: u32`. The
commitment must open to `amount` of `asset_id` under circuit 3, checked through
`ZkVerifierPort::verify_shield_proof` before any funds move;
`ProofVerificationFailed` otherwise.
- **`shield_batch`** entries are `ShieldBatchItem<T>`: `shield`'s arguments,
proof and version included. One bad proof reverts the whole batch.
- Both weights add one proof verification per deposit (`verification_weight`).
- **Only the native asset moves.** `shield`, `unshield`, `private_transfer` and
`claim_relay_fees` refuse a verified non-native asset with the new
`AssetNotSupported` (appended to `Error`, no index shift): every transfer in
and out of the pool moves `T::Currency`, so a note of another asset was backed
by native funds. Checked through `AssetOperation::ensure_movable`, after
`InvalidAssetId` / `AssetNotVerified`. `NATIVE_ASSET_ID` (0) names the genesis
asset.
- `ShieldOperation::execute(depositor, &proof, ShieldRequest)`, shaped like the
unshield and transfer operations; `ShieldRequest::from_batch_item` splits a
batch entry. Everything checkable without the proof runs first, so a bad
request costs no verification.

### Added

- `Proof` and `MAX_PROOF_SIZE` (512): the one proof bound for every call, shared
with the EVM precompile. `shield`, `shield_batch`, `unshield` and
`private_transfer` take it; the encoding is unchanged.

## [0.20.0] - 2026-09-29

Relay fees follow the relay commit, and are claimed publicly. Closes RL-6: copying
Expand Down
2 changes: 1 addition & 1 deletion frame/shielded-pool/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pallet-shielded-pool"
version = "0.20.0"
version = "0.21.0"
description = "Shielded pool pallet for private transactions using ZK proofs"
authors = ["Orbinum Team"]
license = "GPL-3.0-or-later"
Expand Down
Loading
Loading