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
4 changes: 2 additions & 2 deletions .claude/rules/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

**Rust verification checklist:**
- `cargo fmt --check` - code formatting
- `cargo clippy` - lints (0 warnings required), including integration tests (all targets)
- `cargo test` - all tests pass
- `cargo clippy` - lints (0 warnings required), including integration tests, for all targets
- `cargo test` - all tests pass, for all targets

**TypeScript verification checklist:**
- `cd sdk && npm run format:check` - prettier formatting
Expand Down
69 changes: 38 additions & 31 deletions .claude/specs/discovery-service/06-api-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,57 +138,64 @@ A unified endpoint that discovers channels, subchannels, and notes in one call w

## 6.6 Outgoing Channel Sync Endpoint

Whenever a user wants to make a private transfer there are several things to determine:
Discovers all outgoing channels and subchannels for a sender. The server decrypts outgoing channel data using the sender's `viewing_key` to find recipients and their per-token subchannels. An optional `recipients` filter restricts discovery to specific recipients; recipients without an existing on-chain channel are returned with `precomputed: true`.

1. Is the sender registered in the pool?
2. Is the receiver registered in the pool?
3. Is there an existing outgoing channel for the destination address?
4. Is there an existing subchannel for the target token?
5. What is the latest note index in that subchannel (if it exists)?
Uses the same `block_ref`/`last_known_block` reorg-detection pattern and composite `DiscoveryCursor` as the incoming sync endpoint (§6.5).

The channel key is computed on the client using the viewing key. This avoids sending additional secrets to the service.

Typically users should have this information cached locally but in case there's a need to recover it, the following method would do all the checks and encrypted note discovery.

`POST /v1/discovery/outgoing/sync`
`POST /v1/sync/outgoing_state`

**Request:**

```json
{
"contract_address": "0x...",
"sender_addr": "0x...",
"recipient_addr": "0x...",
"channel_key": "0x...",
"token_address": "0x...",
"sender_address": "0x...",
"viewing_key": "0x...",
"last_known_block": "0x...",
"block_ref": "0x...",
"cursor": {
"start_note_index": 0
},
"max_reads": 1000
"cursor": { ... },
"recipients": ["0x...", "0x..."]
}
```

- `contract_address`: The privacy pool contract address.
- `sender_address`: The sender's on-chain address.
- `viewing_key`: The sender's private viewing key (used for server-side decryption of outgoing channel data).
- `last_known_block`: Optional. For reorg detection on first request of a new sync session.
- `block_ref`: Optional. Block hash for consistent reads across paginated requests.
- `cursor`: Composite `DiscoveryCursor` for pagination. Default (empty) on first request.
- `recipients`: Optional. When set, only return channels for these recipient addresses.

**Response:**

```json
{
"head": { "block_number": 123456, "block_hash": "0x..." },
"sender_registered": true,
"receiver_registered": true,
"channel_exists": true,
"subchannel_exists": true,
"total_n_notes": 57,
"cursor": {
"start_note_index": 57
},
"stats": { "reads_used": 100 }
"block_ref": "0x...",
"channels": [
{
"recipient_addr": "0x...",
"recipient_public_key": "0x...",
"channel_key": "0x...",
"precomputed": false
}
],
"subchannels": [
{
"recipient_addr": "0x...",
"token": "0x...",
"last_note_index": 0
}
],
"cursor": { ... }
}
```

**Completion detection:** Client computes done status: `cursor.start_note_index >= total_n_notes`.
- `block_ref`: Block hash pinning all reads. Pass back as `block_ref` in subsequent requests.
- `channels`: Discovered outgoing channels (one per recipient). `precomputed: true` for recipients requested via `recipients` filter that don't yet have an on-chain channel.
- `subchannels`: Discovered subchannels (one per recipient×token pair). `last_note_index` is the last note index in the subchannel, or `null` if no notes exist.
- `cursor`: Updated cursor for continuation.

**Current limitation:** This endpoint supports a single receiver/token pair per request. Future versions may support batch queries for multiple receivers to enable mass payout scenarios.
**Completion:** Check `cursor.is_complete()` — when all channel and subchannel discovery is done.

## 6.7 History Endpoint (Not Yet Specified)

Expand Down
137 changes: 116 additions & 21 deletions crates/discovery-service/src/api/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@ use axum::response::IntoResponse;
use axum::Json;
use discovery_core::io_budget::IoBudget;
use discovery_core::storage_backend::StorageBackend;
use starknet_core::types::BlockId;
use starknet_core::types::{BlockId, Felt};
use tracing::debug;

use discovery_core::privacy_pool::felt_hex;
use discovery_core::privacy_pool::types::SecretFelt;

use crate::api::types::{
ApiErrorResponse, HealthResponse, IncomingSyncRequest, IncomingSyncResponse,
OutgoingSyncRequest, OutgoingSyncResponse, SyncRequestBase,
};
use crate::api::validators::{validate_block_ref, validate_cursor};
use crate::api::validators::{validate_block_ref, validate_cursor, validate_recipients};
use crate::api::AppState;
use crate::chain_state::ChainState;
use discovery_core::discovery::CursorLimits;

/// Handler for GET /health.
pub async fn health_handler<B>(State(state): State<Arc<AppState<B>>>) -> impl IntoResponse
Expand Down Expand Up @@ -54,6 +57,47 @@ where
(status_code, Json(response))
}

/// Validated and resolved shared context for sync handlers.
struct SyncContext<S> {
block_ref: Felt,
viewing_key: SecretFelt,
snapshot: S,
budget: IoBudget,
cursor_limits: CursorLimits,
}

/// Validates the shared request fields and builds the context needed by
/// both incoming and outgoing sync handlers.
async fn prepare_sync_context<B>(
base: &SyncRequestBase,
state: &AppState<B>,
) -> Result<SyncContext<B::Snapshot>, (StatusCode, ApiErrorResponse)>
where
B: StorageBackend + ChainState + Clone + Send + Sync + 'static,
B::Snapshot: Clone + Send + Sync + 'static,
{
validate_cursor(&base.cursor, &state.validation_limits)?;
let block_ref =
validate_block_ref(base.last_known_block, base.block_ref, &state.backend).await?;
let viewing_key = SecretFelt::new(base.viewing_key);

let snapshot = state
.backend
.snapshot(base.contract_address, Some(BlockId::Hash(block_ref)))
.await;

let budget = IoBudget::new(state.validation_limits.server_budget);
let cursor_limits = state.validation_limits.cursor_limits;

Ok(SyncContext {
block_ref,
viewing_key,
snapshot,
budget,
cursor_limits,
})
}

/// Handler for POST /v1/sync/incoming_state.
pub async fn incoming_sync_handler<B>(
State(state): State<Arc<AppState<B>>>,
Expand All @@ -77,32 +121,21 @@ where
B: StorageBackend + ChainState + Clone + Send + Sync + 'static,
B::Snapshot: Clone + Send + Sync + 'static,
{
validate_cursor(&request.cursor, &state.validation_limits)?;
let block_ref =
validate_block_ref(request.last_known_block, request.block_ref, &state.backend).await?;
let viewing_key = SecretFelt::new(request.viewing_key);

let snapshot = state
.backend
.snapshot(request.contract_address, Some(BlockId::Hash(block_ref)))
.await;

let budget = IoBudget::new(state.validation_limits.server_budget);
let cursor_limits = state.validation_limits.cursor_limits;
let context = prepare_sync_context(&request.base, state).await?;

debug!(
recipient = %format!("{:#x}", request.recipient_address),
block = %block_ref,
block = %context.block_ref,
"incoming_sync request"
);

let discovery_output = discovery_core::sync::incoming_state::sync_incoming_state(
&snapshot,
&context.snapshot,
request.recipient_address,
&viewing_key,
request.cursor,
cursor_limits,
&budget,
&context.viewing_key,
request.base.cursor,
context.cursor_limits,
&context.budget,
)
.await
.map_err(crate::api::types::discovery_error_to_response)?;
Expand All @@ -116,10 +149,72 @@ where
);

Ok(IncomingSyncResponse {
block_ref,
block_ref: context.block_ref,
channels: discovery_output.channels,
subchannels: discovery_output.subchannels,
notes: discovery_output.notes,
cursor: discovery_output.cursor,
})
}

/// Handler for POST /v1/sync/outgoing_state.
pub async fn outgoing_sync_handler<B>(
State(state): State<Arc<AppState<B>>>,
Json(request): Json<OutgoingSyncRequest>,
) -> impl IntoResponse
where
B: StorageBackend + ChainState + Clone + Send + Sync + 'static,
B::Snapshot: Clone + Send + Sync + 'static,
{
match outgoing_sync_impl(&state, request).await {
Ok(response) => (StatusCode::OK, Json(response)).into_response(),
Err((status, error)) => (status, Json(error)).into_response(),
}
}

async fn outgoing_sync_impl<B>(
state: &AppState<B>,
request: OutgoingSyncRequest,
) -> Result<OutgoingSyncResponse, (StatusCode, ApiErrorResponse)>
where
B: StorageBackend + ChainState + Clone + Send + Sync + 'static,
B::Snapshot: Clone + Send + Sync + 'static,
{
if let Some(ref recipients) = request.recipients {
validate_recipients(recipients, &state.validation_limits)?;
}
let context = prepare_sync_context(&request.base, state).await?;

debug!(
sender = felt_hex(&request.sender_address),
recipients = ?request.recipients.as_ref().map(|r| r.len()),
block = %context.block_ref,
"outgoing_sync request"
);

let discovery_output = discovery_core::sync::outgoing_state::sync_outgoing_state(
&context.snapshot,
request.sender_address,
&context.viewing_key,
request.base.cursor,
context.cursor_limits,
&context.budget,
request.recipients.as_ref(),
)
.await
.map_err(crate::api::types::discovery_error_to_response)?;

debug!(
channels = discovery_output.channels.len(),
subchannels = discovery_output.subchannels.len(),
cursor_complete = discovery_output.cursor.is_complete(),
"outgoing_sync response"
);

Ok(OutgoingSyncResponse {
block_ref: context.block_ref,
channels: discovery_output.channels,
subchannels: discovery_output.subchannels,
cursor: discovery_output.cursor,
})
}
4 changes: 3 additions & 1 deletion crates/discovery-service/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ use tracing::info;
use crate::chain_state::ChainState;
use crate::config::{ApiServerConfig, ValidationLimits};

pub use handlers::{health_handler, incoming_sync_handler};
pub use handlers::{health_handler, incoming_sync_handler, outgoing_sync_handler};
pub use types::{
ApiErrorBody, ApiErrorResponse, HealthResponse, IncomingSyncRequest, IncomingSyncResponse,
OutgoingSyncRequest, OutgoingSyncResponse, SyncRequestBase,
};

/// API server for the discovery service.
Expand Down Expand Up @@ -70,6 +71,7 @@ where
let app = Router::new()
.route("/health", get(health_handler::<B>))
.route("/v1/sync/incoming_state", post(incoming_sync_handler::<B>))
.route("/v1/sync/outgoing_state", post(outgoing_sync_handler::<B>))
.with_state(app_state);

let listener = TcpListener::bind(&self.config.host)
Expand Down
Loading