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
8 changes: 4 additions & 4 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions client/rpc/src/relay/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ impl RelayableOperation for UnshieldOp {
}
}

/// `privateTransfer(proof, root, nullifiers, commitments, memos, asset_id, fee,
/// circuit_version)` — `0x66ed2cd4`
/// `privateTransfer(proof, merkle_roots, nullifiers, commitments, memos, asset_id,
/// fee, circuit_version)` — `0x63d0b9a0`
///
/// Fee is in ABI slot 6: `calldata[196..228]`. The head is 8 slots (256 bytes)
/// plus the 4-byte selector = 260 minimum.
Expand Down
39 changes: 38 additions & 1 deletion client/rpc/src/relay/tests/guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ fn unshield(nullifier: [u8; 32]) -> Vec<u8> {
fn transfer(nullifiers: &[[u8; 32]]) -> Vec<u8> {
let mut data = SELECTOR_PRIVATE_TRANSFER.to_vec();
data.extend_from_slice(&word(256)); // proof offset
data.extend_from_slice(&[0x11; 32]); // root
data.extend_from_slice(&word(256 + 32 + 32 * nullifiers.len())); // roots offset
data.extend_from_slice(&word(256)); // nullifiers offset
data.resize(4 + 256, 0);
data.extend_from_slice(&word(nullifiers.len()));
for n in nullifiers {
data.extend_from_slice(n);
}
data.extend_from_slice(&word(2));
data.extend_from_slice(&[[0x11; 32], [0x12; 32]].concat());
data
}

Expand Down Expand Up @@ -87,6 +89,41 @@ fn refuses_calldata_that_names_no_note() {
assert_eq!(spend_nullifiers(&bad_offset), None, "offset past the input");
}

/// Offsets and lengths at the edge of `usize` read nothing and never overflow:
/// the guard runs on calldata from anyone, before the dry-run.
#[test]
fn hostile_offsets_and_lengths_read_nothing() {
let near_max = U256::from(usize::MAX - 16).to_big_endian();
let mut offset = transfer(&[[0x33; 32]]);
offset[4 + 64..4 + 96].copy_from_slice(&near_max);
assert_eq!(spend_nullifiers(&offset), None);

let mut length = transfer(&[[0x33; 32]]);
length[4 + 256..4 + 288].copy_from_slice(&near_max);
assert_eq!(spend_nullifiers(&length), None);

let mut short = transfer(&[[0x33; 32], [0x44; 32]]);
short.truncate(4 + 256 + 32 + 32);
assert_eq!(spend_nullifiers(&short), None, "second nullifier missing");
}

/// The nullifiers come from head slot 2 whatever slot 1 holds: in the
/// two-root layout slot 1 is the roots' offset, never a nullifier.
#[test]
fn the_roots_array_is_never_read_as_nullifiers() {
let mut data = transfer(&[[0x33; 32], [0x44; 32]]);
// Point the roots at the nullifiers and the nullifiers at the roots.
let roots = data[4 + 32..4 + 64].to_vec();
let nullifiers = data[4 + 64..4 + 96].to_vec();
data[4 + 32..4 + 64].copy_from_slice(&nullifiers);
data[4 + 64..4 + 96].copy_from_slice(&roots);
assert_eq!(
spend_nullifiers(&data),
Some(vec![[0x11; 32], [0x12; 32]]),
"slot 2 decides, as it does for the precompile"
);
}

// ── InFlight ─────────────────────────────────────────────────────────────────

const TTL: Duration = Duration::from_secs(120);
Expand Down
2 changes: 1 addition & 1 deletion client/rpc/src/relay/tests/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ fn rejects_calldata_that_ends_before_the_fee_slot() {
#[test]
fn selectors_are_keccak_of_the_abi_signatures() {
let pt = sp_io::hashing::keccak_256(
b"privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)",
b"privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)",
);
let un = sp_io::hashing::keccak_256(
b"unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)",
Expand Down
2 changes: 1 addition & 1 deletion client/rpc/src/relay/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub(crate) fn compute_effective_min_fee(min_fee_planck: u128, base_fee_wei: u128
/// ```text
/// bytes [0..4] selector
/// bytes [4..36] slot 0 — offset pointer for proof (bytes/dynamic)
/// bytes [36..68] slot 1 — bytes32 root
/// bytes [36..68] slot 1 — bytes32 root / bytes32[] roots offset
/// bytes [68..100] slot 2 — bytes32 nullifier / bytes32[] nullifiers offset
/// bytes [100..132] slot 3 — uint32 asset_id / bytes32[] commits offset
/// bytes [132..164] slot 4 — uint256 amount / bytes[] memos offset
Expand Down
9 changes: 9 additions & 0 deletions frame/evm/precompile/shielded-pool/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to `pallet-evm-precompile-shielded-pool` will be documented

## [Unreleased]

## [0.9.0] - 2026-10-08

### Changed

- **Breaking:** `privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)`,
selector `0x63d0b9a0`: slot 1 is the offset of `merkle_roots`, exactly two
roots in input order. The single-root selector `0x66ed2cd4` is removed. The fee
stays at slot 6, so the relay RPC's gate is unchanged.

## [0.8.0] - 2026-10-05

**Breaking** — the shield ABI changes. Requires the matching `pallet-shielded-pool`.
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.8.0"
version = "0.9.0"
authors = { workspace = true }
edition = "2021"
description = "EVM Precompile for Orbinum Shielded Pool Pallet."
Expand Down
31 changes: 19 additions & 12 deletions frame/evm/precompile/shielded-pool/src/calls/private_transfer.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
//! ABI decoding and call construction for
//! `privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)`.
//! `privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)`.
//!
//! ## Selector
//! `keccak256("privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)")[0..4]`
//! = `0x66ed2cd4`
//! `keccak256("privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)")[0..4]`
//! = `0x63d0b9a0`
//!
//! ## ABI layout (`input[4..]`)
//! | Slot (bytes) | Type | Field |
//! |--------------|-----------|-----------------------|
//! | 0..32 | `uint256` | offset → `proof` |
//! | 32..64 | `bytes32` | `merkle_root` |
//! | Slot (bytes) | Type | Field |
//! |--------------|-----------|------------------------|
//! | 0..32 | `uint256` | offset → `proof` |
//! | 32..64 | `uint256` | offset → `merkle_roots`|
//! | 64..96 | `uint256` | offset → `nullifiers` |
//! | 96..128 | `uint256` | offset → `commitments`|
//! | 128..160 | `uint256` | offset → `memos` |
Expand All @@ -18,7 +18,10 @@
//! | 224..256 | `uint32` | `circuit_version` |
//!
//! ## Notes
//! The three arrays are parallel: `commitments[i]` and `memos[i]` describe the
//! `merkle_roots` holds exactly two roots, one per input note in input order;
//! they differ when the notes come from different trees.
//!
//! The three other arrays are parallel: `commitments[i]` and `memos[i]` describe the
//! output note paid for by `nullifiers[i]`, so all three must have equal length.
//!
//! The relay fee recipient is not in the ABI: it is the relayer that recorded a
Expand All @@ -36,7 +39,7 @@ use super::{balance, params, proof};
use crate::{abi, revert};

/// Selector for the signature in this module's header.
pub const SELECTOR: [u8; 4] = [0x66, 0xed, 0x2c, 0xd4];
pub const SELECTOR: [u8; 4] = [0x63, 0xd0, 0xb9, 0xa0];

/// Maximum number of input nullifiers / output commitments in a single transfer.
pub const MAX_NOTES: u32 = 2;
Expand All @@ -61,8 +64,12 @@ where
"privateTransfer: proof must be non-empty",
)?;

// Step 3: merkle_root the proof is verified against.
let merkle_root: pallet_shielded_pool::Hash = abi::read_bytes32(params, 32)?;
// Step 3: one root per input note, in input order. The circuit always has two
// inputs (a dummy pads a one-note spend), so exactly two roots.
let merkle_roots: [pallet_shielded_pool::Hash; 2] =
abi::decode_bytes32_array_at_slot(params, 32, 2)?
.try_into()
.map_err(|_| revert("privateTransfer: exactly two merkle roots required"))?;

// Step 4: the three parallel arrays — nullifiers spent, commitments created,
// and the memo carrying each new note's secrets. The decoders bound count and
Expand Down Expand Up @@ -127,7 +134,7 @@ where

Ok(pallet_shielded_pool::Call::<T>::private_transfer {
proof,
merkle_root,
merkle_roots,
nullifiers,
commitments,
encrypted_memos,
Expand Down
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 @@ -44,7 +44,7 @@ pub use calls::private_transfer::MAX_NOTES as MAX_SPEND_INPUTS;
/// | Selector | Solidity signature |
/// |--------------|---------------------------------------------------------------------------------------|
/// | `0xf25897e0` | `shield(uint32,bytes32,bytes,bytes,uint32)` — payable, amount = `msg.value` |
/// | `0x66ed2cd4` | `privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)` |
/// | `0x63d0b9a0` | `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 |
/// | `0x2a3274dd` | `claimRelayFees(uint32,uint256)` — registered relayers |
Expand Down
38 changes: 33 additions & 5 deletions frame/evm/precompile/shielded-pool/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ fn encode_shield_with(
input
}

/// `privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)`,
/// selector `0x66ed2cd4`.
/// `privateTransfer` with both inputs proven against `merkle_root`, as a
/// same-tree spend submits it.
#[allow(clippy::too_many_arguments)]
fn encode_private_transfer(
proof: &[u8],
Expand All @@ -170,23 +170,50 @@ fn encode_private_transfer(
asset_id: u32,
fee: u128,
circuit_version: u32,
) -> Vec<u8> {
encode_private_transfer_roots(
proof,
&[merkle_root, merkle_root],
nullifiers,
commitments,
memos,
asset_id,
fee,
circuit_version,
)
}

/// `privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)`,
/// selector `0x63d0b9a0`, with any number of roots so malformed counts can be built.
#[allow(clippy::too_many_arguments)]
fn encode_private_transfer_roots(
proof: &[u8],
merkle_roots: &[[u8; 32]],
nullifiers: &[[u8; 32]],
commitments: &[[u8; 32]],
memos: &[Vec<u8>],
asset_id: u32,
fee: u128,
circuit_version: u32,
) -> Vec<u8> {
let proof_enc = encode_bytes(proof);
let roots_enc = encode_bytes32_array(merkle_roots);
let nullifiers_enc = encode_bytes32_array(nullifiers);
let commitments_enc = encode_bytes32_array(commitments);
let memos_enc = encode_bytes_array(memos);

// head: 8 slots × 32 = 256 bytes
let head_size = 256usize;
let off_proof = head_size;
let off_nullifiers = off_proof + proof_enc.len();
let off_roots = off_proof + proof_enc.len();
let off_nullifiers = off_roots + roots_enc.len();
let off_commitments = off_nullifiers + nullifiers_enc.len();
let off_memos = off_commitments + commitments_enc.len();

let mut input = vec![0x66, 0xed, 0x2c, 0xd4];
let mut input = crate::calls::private_transfer::SELECTOR.to_vec();
let mut head = vec![0u8; head_size];
head[0..32].copy_from_slice(&u256_word(off_proof));
head[32..64].copy_from_slice(&merkle_root);
head[32..64].copy_from_slice(&u256_word(off_roots));
head[64..96].copy_from_slice(&u256_word(off_nullifiers));
head[96..128].copy_from_slice(&u256_word(off_commitments));
head[128..160].copy_from_slice(&u256_word(off_memos));
Expand All @@ -196,6 +223,7 @@ fn encode_private_transfer(

input.extend_from_slice(&head);
input.extend_from_slice(&proof_enc);
input.extend_from_slice(&roots_enc);
input.extend_from_slice(&nullifiers_enc);
input.extend_from_slice(&commitments_enc);
input.extend_from_slice(&memos_enc);
Expand Down
Loading
Loading