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
2 changes: 1 addition & 1 deletion Cargo.lock

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

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

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

## [0.17.0] - 2026-08-07

### Security

- **`private_transfer` rejects two equal non-dummy nullifiers.** Both slots
holding the same real nullifier would spend one input twice within a single
transfer: each clears the used-set check (neither is marked yet), and the
second `mark_as_used` is idempotent, so nothing downstream caught it. Pool
admission does not catch it either — `validate_unsigned` collapses equal
nullifiers into one `provides` tag rather than flagging the duplicate. The
proof is expected to bind the two inputs distinct, but the chain no longer
relies on that: `execute` now compares the non-dummy nullifiers directly (at
most two inputs) and refuses a duplicate with `NullifierAlreadyUsed`.

## [0.16.0] - 2026-08-07

### Security
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.16.0"
version = "0.17.0"
description = "Shielded pool pallet for private transactions using ZK proofs"
authors = ["Orbinum Team"]
license = "GPL-3.0-or-later"
Expand Down
33 changes: 33 additions & 0 deletions frame/shielded-pool/src/operations/private_transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ impl PrivateTransferOperation {
);
}

let non_dummy: sp_std::vec::Vec<&Nullifier> =
nullifiers.iter().filter(|n| n.0 != [0u8; 32]).collect();
if non_dummy.len() == 2 {
ensure!(
non_dummy[0] != non_dummy[1],
Error::<T>::NullifierAlreadyUsed
);
}

for commitment in commitments.iter() {
ensure!(commitment.is_canonical(), Error::<T>::InvalidPublicSignals);
ensure!(commitment.is_valid(), Error::<T>::InvalidPublicSignals);
Expand Down Expand Up @@ -301,6 +310,30 @@ mod tests {
});
}

#[test]
fn execute_two_equal_nullifiers_fails() {
new_test_ext().execute_with(|| {
MerkleRepository::add_historic_poseidon_root::<Test>(KNOWN_ROOT);

// The same non-dummy nullifier in both slots would spend one input
// twice: neither is in the used set yet, so both clear that check.
assert_noop!(
PrivateTransferOperation::execute::<Test>(
proof(),
KNOWN_ROOT,
nullifiers_of(&[0x20, 0x20]),
commitments_of(&[0x30, 0x31]),
memos_of(2),
0u32,
0u128,
None,
1,
),
crate::pallet::Error::<Test>::NullifierAlreadyUsed
);
});
}

#[test]
fn execute_memo_commitment_mismatch_fails() {
new_test_ext().execute_with(|| {
Expand Down
Loading