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
11 changes: 11 additions & 0 deletions .claude/skills/pr-splitter/partial-file-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ git diff HEAD -- path/to/file
# Every changed line here must also appear in /tmp/file.patch
```

## Copy-and-Fixup for Small Surgical Differences

When a file's final state has only a few small differences from what this slice
needs (e.g., a type, import, or signature introduced in a later slice), copy the
final file from the worktree and apply surgical fixups rather than reverting and
re-patching from scratch. This is faster and less error-prone than hunk filtering
when the delta is small and spread across many locations.

Record each fixup in the plan's Issues section so the later slice knows what to
undo.

## When to Avoid Splitting

If more than 3 hunks need surgical extraction from one file, consider putting
Expand Down
207 changes: 197 additions & 10 deletions crates/discovery-core/src/privacy_pool/hashes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,29 @@ static NOTE_ID_TAG: LazyLock<Felt> = LazyLock::new(|| short_string_to_felt("NOTE
/// Domain separation tag for encrypted amount.
static ENC_AMOUNT_TAG: LazyLock<Felt> = LazyLock::new(|| short_string_to_felt("ENC_AMOUNT_TAG:V1"));

/// Domain separation tag for nullifier derivation.
static NULLIFIER_TAG: LazyLock<Felt> = LazyLock::new(|| short_string_to_felt("NULLIFIER_TAG:V1"));

/// Domain separation tag for channel key derivation.
static CHANNEL_KEY_TAG: LazyLock<Felt> =
LazyLock::new(|| short_string_to_felt("CHANNEL_KEY_TAG:V1"));

/// Domain separation tag for channel marker derivation.
static CHANNEL_MARKER_TAG: LazyLock<Felt> =
LazyLock::new(|| short_string_to_felt("CHANNEL_MARKER_TAG:V1"));

/// Domain separation tag for subchannel marker derivation.
static SUBCHANNEL_MARKER_TAG: LazyLock<Felt> =
LazyLock::new(|| short_string_to_felt("SUBCHANNEL_MARKER_TAG:V1"));

/// Domain separation tag for outgoing channel id derivation.
static OUTGOING_CHANNEL_ID_TAG: LazyLock<Felt> =
LazyLock::new(|| short_string_to_felt("OUTGOING_CHANNEL_ID_TAG:V1"));

/// Domain separation tag for encrypted recipient address.
static ENC_RECIPIENT_ADDR_TAG: LazyLock<Felt> =
LazyLock::new(|| short_string_to_felt("ENC_RECIPIENT_ADDR_TAG:V1"));

/// Converts a short string (up to 31 ASCII chars) to Felt.
fn short_string_to_felt(s: &str) -> Felt {
assert!(
Expand All @@ -37,8 +60,8 @@ fn short_string_to_felt(s: &str) -> Felt {
}

/// Cryptographic hash function.
pub fn hash(data: &[Felt]) -> Felt {
let inner = poseidon_hash_many(data.iter());
pub fn hash(elements: &[Felt]) -> Felt {
let inner = poseidon_hash_many(elements.iter());
let mut hasher = PoseidonHasher::new();
hasher.update(inner);
hasher.finalize()
Expand All @@ -55,8 +78,6 @@ pub fn compute_enc_sender_addr_hash(shared_x: Felt) -> Felt {
}

/// Computes the subchannel key from channel key and index.
///
/// `subchannel_id = hash(SUBCHANNEL_ID_TAG, channel_key, index, 0)`
pub fn compute_subchannel_id(channel_key: Felt, index: u64) -> Felt {
hash(&[
*SUBCHANNEL_ID_TAG,
Expand All @@ -67,8 +88,6 @@ pub fn compute_subchannel_id(channel_key: Felt, index: u64) -> Felt {
}

/// Computes the encryption mask for token encryption.
///
/// `enc_token_hash = hash(ENC_TOKEN_TAG, channel_key, index, 0, salt)`
pub fn compute_enc_token_hash(channel_key: Felt, index: u64, salt: Felt) -> Felt {
hash(&[
*ENC_TOKEN_TAG,
Expand All @@ -80,8 +99,6 @@ pub fn compute_enc_token_hash(channel_key: Felt, index: u64, salt: Felt) -> Felt
}

/// Computes the note ID from channel key, token, and note index.
///
/// `note_id = hash(NOTE_ID_TAG, channel_key, token, index, 0)`
pub fn compute_note_id(channel_key: Felt, token: Felt, index: u64) -> Felt {
hash(&[
*NOTE_ID_TAG,
Expand All @@ -93,8 +110,6 @@ pub fn compute_note_id(channel_key: Felt, token: Felt, index: u64) -> Felt {
}

/// Computes the encryption mask for note amount.
///
/// `enc_amount_hash = hash(ENC_AMOUNT_TAG, channel_key, token, index, 0, salt)`
pub fn compute_enc_amount_hash(channel_key: Felt, token: Felt, index: u64, salt: u128) -> Felt {
hash(&[
*ENC_AMOUNT_TAG,
Expand All @@ -106,6 +121,99 @@ pub fn compute_enc_amount_hash(channel_key: Felt, token: Felt, index: u64, salt:
])
}

/// Computes the nullifier for a note.
pub fn compute_nullifier(
channel_key: Felt,
token: Felt,
index: u64,
decryption_key: &Felt,
) -> Felt {
hash(&[
*NULLIFIER_TAG,
channel_key,
token,
Felt::from(index),
Felt::ZERO,
*decryption_key,
])
}

/// Computes the channel key from sender credentials and recipient identity.
pub fn compute_channel_key(
sender_addr: Felt,
decryption_key: &Felt,
recipient_addr: Felt,
recipient_public_key: Felt,
) -> Felt {
hash(&[
*CHANNEL_KEY_TAG,
sender_addr,
*decryption_key,
recipient_addr,
recipient_public_key,
])
}

/// Computes the channel marker for `channel_exists` storage lookup.
pub fn compute_channel_marker(
channel_key: Felt,
sender_addr: Felt,
recipient_addr: Felt,
recipient_public_key: Felt,
) -> Felt {
hash(&[
*CHANNEL_MARKER_TAG,
channel_key,
sender_addr,
recipient_addr,
recipient_public_key,
])
}

/// Computes the subchannel marker for `subchannel_exists` storage lookup.
pub fn compute_subchannel_marker(
channel_key: Felt,
recipient_addr: Felt,
recipient_public_key: Felt,
token: Felt,
) -> Felt {
hash(&[
*SUBCHANNEL_MARKER_TAG,
channel_key,
recipient_addr,
recipient_public_key,
token,
])
}

/// Computes the outgoing channel id for storage lookup.
pub fn compute_outgoing_channel_id(sender_addr: Felt, decryption_key: &Felt, index: u64) -> Felt {
hash(&[
*OUTGOING_CHANNEL_ID_TAG,
sender_addr,
*decryption_key,
Felt::from(index),
Felt::ZERO,
])
}

/// Computes the encryption mask for the outgoing recipient address.
pub fn compute_enc_recipient_addr_hash(
sender_addr: Felt,
decryption_key: &Felt,
index: u64,
salt: Felt,
) -> Felt {
hash(&[
*ENC_RECIPIENT_ADDR_TAG,
sender_addr,
*decryption_key,
Felt::from(index),
Felt::ZERO,
salt,
])
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -157,6 +265,22 @@ mod tests {
);
}

#[test]
fn test_compute_nullifier() {
let f = load_cairo_ref_fixture();
// Reference data uses sender_private_key as the owner (note owner
// in the reference scenario is the sender).
assert_eq!(
compute_nullifier(
f.inputs.channel_key,
f.inputs.token,
f.inputs.index,
&f.inputs.sender_private_key,
),
f.outputs.nullifier
);
}

#[test]
fn test_compute_enc_amount_hash() {
use crate::privacy_pool::types::felt_low_u128;
Expand All @@ -168,4 +292,67 @@ mod tests {
f.outputs.enc_amount_hash
);
}

#[test]
fn test_compute_channel_key() {
let f = load_cairo_ref_fixture();
let key = f.inputs.sender_private_key;
assert_eq!(
compute_channel_key(
f.inputs.sender,
&key,
f.inputs.recipient,
f.inputs.recipient_public_key,
),
f.outputs.channel_key
);
}

#[test]
fn test_compute_outgoing_channel_id() {
let f = load_cairo_ref_fixture();
let key = f.inputs.sender_private_key;
assert_eq!(
compute_outgoing_channel_id(f.inputs.sender, &key, f.inputs.index),
f.outputs.outgoing_channel_id
);
}

#[test]
fn test_compute_enc_recipient_addr_hash() {
let f = load_cairo_ref_fixture();
let key = f.inputs.sender_private_key;
assert_eq!(
compute_enc_recipient_addr_hash(f.inputs.sender, &key, f.inputs.index, f.inputs.salt),
f.outputs.enc_recipient_addr_hash
);
}

#[test]
fn test_compute_channel_marker() {
let f = load_cairo_ref_fixture();
assert_eq!(
compute_channel_marker(
f.inputs.channel_key,
f.inputs.sender,
f.inputs.recipient,
f.inputs.recipient_public_key,
),
f.outputs.channel_marker
);
}

#[test]
fn test_compute_subchannel_marker() {
let f = load_cairo_ref_fixture();
assert_eq!(
compute_subchannel_marker(
f.inputs.channel_key,
f.inputs.recipient,
f.inputs.recipient_public_key,
f.inputs.token,
),
f.outputs.subchannel_marker
);
}
}
10 changes: 10 additions & 0 deletions crates/discovery-core/src/privacy_pool/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
//! Privacy pool crypto primitives and storage slot computation.

use starknet_types_core::felt::Felt;

pub mod decryption;
pub mod hashes;
pub mod storage_slots;
pub mod types;
pub mod views;

/// Formats a `Felt` as a 0x-prefixed, zero-padded 64-char hex string.
///
/// Useful for logging — `Display`/`%` on `Felt` uses decimal, which is unreadable
/// for addresses and keys.
pub fn felt_hex(f: &Felt) -> String {
format!("{:#066x}", f)
}
18 changes: 18 additions & 0 deletions crates/discovery-core/src/privacy_pool/storage_slots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ pub struct EncSubchannelInfoSlots {
pub enc_token: Felt,
}

/// Storage slots for encrypted outgoing channel info (2 consecutive slots).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EncOutgoingChannelInfoSlots {
pub salt: Felt,
pub enc_recipient_addr: Felt,
}

/// Computes a storage variable address.
///
/// Wraps `get_storage_var_address`, which only fails if the variable name
Expand Down Expand Up @@ -99,6 +106,17 @@ pub fn subchannel_tokens(subchannel_id: Felt) -> EncSubchannelInfoSlots {
}
}

/// Storage slots for encrypted outgoing channel info.
/// Cairo: `outgoing_channels: Map<felt252, EncOutgoingChannelInfo>`
/// EncOutgoingChannelInfo has 2 fields: salt and enc_recipient_addr.
pub fn outgoing_channels(outgoing_channel_id: Felt) -> EncOutgoingChannelInfoSlots {
let base = slot("outgoing_channels", &[outgoing_channel_id]);
EncOutgoingChannelInfoSlots {
salt: base,
enc_recipient_addr: base + Felt::ONE,
}
}

/// Storage slot for a note's existence.
/// Cairo: `notes: LegacyMap<NoteId, bool>`
pub fn notes(note_id: Felt) -> Felt {
Expand Down
31 changes: 27 additions & 4 deletions crates/discovery-service/tests/common/indexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,42 @@ impl IndexerClient {

/// Wait for a specific log message.
pub async fn wait_for_log(&mut self, pattern: &str, timeout: Duration) -> Result<String> {
self.wait_for_logs(&[pattern], timeout)
.await
.map(|v| v.into_values().next().unwrap())
}

/// Wait for all given patterns to appear in logs (in any order).
pub async fn wait_for_logs(
&mut self,
patterns: &[&str],
timeout: Duration,
) -> Result<std::collections::HashMap<String, String>> {
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
let mut found: std::collections::HashMap<String, String> = std::collections::HashMap::new();
while found.len() < patterns.len() {
match tokio::time::timeout_at(deadline, self.log_rx.recv()).await {
Ok(Some(line)) => {
eprintln!("LOG: {}", line);
if line.contains(pattern) {
return Ok(line);
for &p in patterns {
if !found.contains_key(p) && line.contains(p) {
found.insert(p.to_string(), line.clone());
}
}
}
_ => break,
}
}
Err(anyhow!("Timeout waiting for: {}", pattern))
if found.len() == patterns.len() {
Ok(found)
} else {
let missing: Vec<&str> = patterns
.iter()
.copied()
.filter(|p| !found.contains_key(*p))
.collect();
Err(anyhow!("Timeout waiting for: {}", missing.join(", ")))
}
}

/// Send SIGINT for graceful shutdown.
Expand Down
Loading