Skip to content
Open
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
3 changes: 2 additions & 1 deletion contracts/privacy_pool/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ impl PrivacyPool {
pool_id: PoolId,
proof: Proof,
pub_inputs: PublicInputs,
recipient: Address,
) -> Result<bool, Error> {
withdraw::execute(env, pool_id, proof, pub_inputs)
withdraw::execute(env, pool_id, proof, pub_inputs, recipient)
}

// ──────────────────────────────────────────────────────────
Expand Down
10 changes: 9 additions & 1 deletion contracts/privacy_pool/src/core/withdraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub fn execute(
pool_id: PoolId,
proof: Proof,
pub_inputs: PublicInputs,
recipient: Address,
) -> Result<bool, Error> {
// Load and validate pool configuration
let pool_config = config::load_pool_config(&env, &pool_id)?;
Expand All @@ -38,6 +39,14 @@ pub fn execute(
return Err(Error::InvalidDenomination);
}

// Step 2.6: Validate recipient address binding (ZK-072)
// The proof is bound to the hash of the recipient address. We verify that
// the hashed recipient in the proof matches the concrete recipient provided.
let expected_recipient_field = address_decoder::encode_address_to_field(&env, &recipient);
if pub_inputs.recipient != expected_recipient_field {
return Err(Error::RecipientBindingMismatch);
}

// Step 3: Validate and decode fee
let fee = validation::decode_and_validate_fee(&pub_inputs.fee, denomination_amount)?;

Expand All @@ -52,7 +61,6 @@ pub fn execute(
nullifier::mark_spent(&env, &pool_id, &pub_inputs.nullifier_hash);

// Step 6: Decode addresses
let recipient = address_decoder::decode_address(&env, &pub_inputs.recipient);
let relayer_opt = address_decoder::decode_optional_relayer(&env, &pub_inputs.relayer);

// Step 7: Transfer funds
Expand Down
43 changes: 35 additions & 8 deletions contracts/privacy_pool/src/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ fn test_e2e_unknown_root_rejected() {
denomination: field(&env, 100),
};

let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs);
let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs, &alice);
assert!(result.is_err());
}

Expand Down Expand Up @@ -274,7 +274,7 @@ fn test_e2e_double_spend_rejected_after_manual_spend_mark() {
denomination: field(&env, 100),
};

let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs);
let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs, &alice);
assert!(result.is_err());
}

Expand Down Expand Up @@ -341,7 +341,7 @@ fn test_e2e_withdraw_rejects_wrong_pool_id_in_public_inputs() {
denomination: field(&env, 100),
};

let result = client.try_withdraw(&pool_a, &dummy_proof(&env), &pub_inputs);
let result = client.try_withdraw(&pool_a, &dummy_proof(&env), &pub_inputs, &alice);
assert!(result.is_err());
// Should fail with InvalidPoolId error due to pool_id mismatch
}
Expand All @@ -365,7 +365,7 @@ fn test_e2e_withdraw_rejects_wrong_denomination_in_public_inputs() {
denomination: field(&env, 200), // Wrong denomination
};

let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs);
let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs, &alice);
assert!(result.is_err());
// Should fail with InvalidDenomination error due to denomination mismatch
}
Expand All @@ -391,7 +391,7 @@ fn test_e2e_withdraw_accepts_correct_pool_id_and_denomination() {

// This should pass the pool_id and denomination validation
// (though it will fail later due to invalid proof, which is expected)
let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs);
let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs, &alice);
assert!(result.is_err());
// Should fail with InvalidProof, not InvalidPoolId or InvalidDenomination
}
Expand Down Expand Up @@ -426,7 +426,7 @@ fn test_e2e_vk_circuit_id_mismatch_rejected() {
denomination: field(&env, 100),
};

let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs);
let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs, &alice);
assert!(result.is_err());
// Should fail with CircuitIdMismatch before expensive pairing operations
}
Expand Down Expand Up @@ -457,7 +457,7 @@ fn test_e2e_vk_public_input_count_mismatch_rejected() {
denomination: field(&env, 100),
};

let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs);
let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs, &alice);
assert!(result.is_err());
// Should fail with PublicInputCountMismatch before expensive pairing operations
}
Expand Down Expand Up @@ -504,11 +504,38 @@ fn test_e2e_vk_gamma_abc_length_mismatch_rejected() {
denomination: field(&env, 100),
};

let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs);
let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs, &alice);
assert!(result.is_err());
// Should fail with MalformedVerifyingKey due to length mismatch
}

#[test]
fn test_e2e_withdraw_rejects_mismatched_recipient_binding() {
let (env, client, _token_id, _admin, alice, bob, pool_id) = setup();

// Deposit into pool
let (_, root) = client.deposit(&pool_id, &alice, &make_commit(&env, 1));

// Encode alice's address as the recipient bound in the proof
let alice_recipient_field = crate::utils::address_decoder::encode_address_to_field(&env, &alice);

// Try to withdraw with alice's bound recipient but bob's actual address
let pub_inputs = PublicInputs {
pool_id: pool_id.0.clone(),
root,
nullifier_hash: make_nullifier_hash(&env, 1),
recipient: alice_recipient_field, // Proof bound to Alice
amount: field(&env, 1),
relayer: BytesN::from_array(&env, &[0u8; 32]),
fee: BytesN::from_array(&env, &[0u8; 32]),
denomination: field(&env, 100),
};

let result = client.try_withdraw(&pool_id, &dummy_proof(&env), &pub_inputs, &bob);
assert!(result.is_err());
// Should fail with RecipientBindingMismatch because bob is not alice
}

#[test]
fn test_e2e_vk_metadata_allows_upgrade_auditability() {
let (env, client, _token_id, admin, _alice, _bob, pool_id) = setup();
Expand Down
2 changes: 2 additions & 0 deletions contracts/privacy_pool/src/types/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ pub enum Error {
InvalidPoolId = 46,
/// Denomination in public inputs does not match the pool denomination
InvalidDenomination = 47,
/// Recipient provided does not match the recipient bound in the proof
RecipientBindingMismatch = 48,

// ── Verifying Key ──────────────────────────────────
/// Verifying key has not been set
Expand Down
63 changes: 62 additions & 1 deletion contracts/privacy_pool/src/utils/address_decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// Decodes addresses from 32-byte field elements in public inputs.
// ============================================================

use soroban_sdk::{Address, BytesN, Env};
use soroban_sdk::{Address, Bytes, BytesN, Env, String};

/// Decode a Stellar address from a 32-byte field element.
///
Expand All @@ -14,6 +14,67 @@ pub fn decode_address(env: &Env, address_bytes: &BytesN<32>) -> Address {
Address::from_string_bytes(&soroban_sdk::Bytes::from_slice(env, &bytes_array))
}

/// Encode a Stellar address into a 32-byte field element matching the SDK's SHA-256 hash logic.
///
/// ZK-072: Replaces lossy recipient decoding with a verifiable address-binding strategy.
pub fn encode_address_to_field(env: &Env, address: &Address) -> BytesN<32> {
let address_text: String = address.to_string();
let address_bytes = address_text.into_bytes();
let digest = env.crypto().sha256(&address_bytes);

// Reducing modulo BN254 field prime (approx 2^254.4)
// SHA-256 is 2^256, so reduction is needed if precision matters,
// but the SDK uses BigInt(0x...) % FIELD_MODULUS.
// In Soroban, we'd need to do big-int modular reduction if it can exceed r.
// However, the SDK logic is simply BigInt('0x' + digest) % FIELD_MODULUS.

let mut bytes = digest.to_array();

// Manual reduction for BN254 prime:
// 21888242871839275222246405745257275088548364400416034343698204186575808495617
// Hex: 30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001

// For now, simplicity: if it's over the prime, we'd need big-int math.
// But most SHA-256 digests fit if we treat them carefully.
// The proper way in Soroban without a big-int library is to use the SDK's logic
// if we want to be 100% compliant.

// Let's use the actual BN254 prime check:
let prime: [u8; 32] = [
0x30, 0x64, 0x4e, 0x72, 0xe1, 0x31, 0xa0, 0x29,
0xb8, 0x50, 0x45, 0xb6, 0x81, 0x81, 0x58, 0x5d,
0x28, 0x33, 0xe8, 0x48, 0x79, 0xb9, 0x70, 0x91,
0x43, 0xe1, 0xf5, 0x93, 0xf0, 0x00, 0x00, 0x01
];

let mut is_greater = false;
for i in 0..32 {
if bytes[i] > prime[i] {
is_greater = true;
break;
} else if bytes[i] < prime[i] {
break;
}
}

if is_greater {
// Simple big-int subtraction (digest - prime) since SHA-256 < 2*prime
let mut borrow = 0i16;
for i in (0..32).rev() {
let res = bytes[i] as i16 - prime[i] as i16 - borrow;
if res < 0 {
bytes[i] = (res + 256) as u8;
borrow = 1;
} else {
bytes[i] = res as u8;
borrow = 0;
}
}
}

BytesN::from_array(env, &bytes)
}

/// Decode an optional relayer address (ZK-104 sentinel policy).
///
/// Returns `Some(Address)` if the relayer field is non-zero, `None` if it is
Expand Down
8 changes: 5 additions & 3 deletions sdk/src/public_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,11 @@ export function encodeSecret(secret: Buffer): string {
/**
* Encodes a Stellar public key (G… Strkey address) as a circuit field element.
*
* The Stellar address is hashed with SHA-256 and the digest is reduced modulo
* the BN254 field prime, producing a deterministic field-sized value. This
* mirrors the on-chain address_decoder used in the Soroban contract.
* ZK-072: Replaces lossy recipient decoding with a verifiable address-binding
* strategy. The recipient address is hashed with SHA-256 and the digest is
* reduced modulo the BN254 field prime. The contract no longer attempts to
* reverse this hash; instead, it verifies that the hashed recipient provided
* in the proof matches the hash of the concrete recipient address argument.
*/
export function encodeStellarAddress(address: string): string {
if (!StrKey.isValidEd25519PublicKey(address)) {
Expand Down