From 9880a46bdccca6c984ba06df4bf3df5cc1534947 Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Wed, 22 Apr 2026 17:39:27 +0300 Subject: [PATCH 01/25] feat: indexer client added to ffi --- Cargo.lock | 2 ++ indexer_ffi/Cargo.toml | 3 +++ indexer_ffi/indexer_ffi.h | 1 + indexer_ffi/src/api/client.rs | 36 ++++++++++++++++++++++++++++++++ indexer_ffi/src/api/lifecycle.rs | 18 ++++++++++++++-- indexer_ffi/src/api/mod.rs | 1 + indexer_ffi/src/indexer.rs | 32 ++++++++++++++++++++++++---- 7 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 indexer_ffi/src/api/client.rs diff --git a/Cargo.lock b/Cargo.lock index 3d46ad65..0d997442 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3485,7 +3485,9 @@ dependencies = [ "cbindgen", "indexer_service", "log", + "sequencer_core", "tokio", + "url", ] [[package]] diff --git a/indexer_ffi/Cargo.toml b/indexer_ffi/Cargo.toml index b55230c6..ed3de7ae 100644 --- a/indexer_ffi/Cargo.toml +++ b/indexer_ffi/Cargo.toml @@ -6,6 +6,9 @@ version = "0.1.0" [dependencies] indexer_service.workspace = true +sequencer_core.workspace = true + +url.workspace = true log = { workspace = true } tokio = { features = ["rt-multi-thread"], workspace = true } diff --git a/indexer_ffi/indexer_ffi.h b/indexer_ffi/indexer_ffi.h index 7c7d9a4d..d764d116 100644 --- a/indexer_ffi/indexer_ffi.h +++ b/indexer_ffi/indexer_ffi.h @@ -12,6 +12,7 @@ typedef enum OperationStatus { typedef struct IndexerServiceFFI { void *indexer_handle; void *runtime; + void *indexer_client; } IndexerServiceFFI; /** diff --git a/indexer_ffi/src/api/client.rs b/indexer_ffi/src/api/client.rs new file mode 100644 index 00000000..825a57de --- /dev/null +++ b/indexer_ffi/src/api/client.rs @@ -0,0 +1,36 @@ +use std::net::SocketAddr; + +use url::Url; + +use crate::OperationStatus; + +#[derive(Debug, Clone, Copy)] +pub enum UrlProtocol { + Http, + Ws, +} + +impl std::fmt::Display for UrlProtocol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Http => write!(f, "http"), + Self::Ws => write!(f, "ws"), + } + } +} + +pub(crate) fn addr_to_url(protocol: UrlProtocol, addr: SocketAddr) -> Result { + // Convert 0.0.0.0 to 127.0.0.1 for client connections + // When binding to port 0, the server binds to 0.0.0.0: + // but clients need to connect to 127.0.0.1: to work reliably + let url_string = if addr.ip().is_unspecified() { + format!("{protocol}://127.0.0.1:{}", addr.port()) + } else { + format!("{protocol}://{addr}") + }; + + url_string.parse().map_err(|e| { + log::error!("Could not parse indexer url: {e}"); + OperationStatus::InitializationError + }) +} diff --git a/indexer_ffi/src/api/lifecycle.rs b/indexer_ffi/src/api/lifecycle.rs index 735efd4d..087f7803 100644 --- a/indexer_ffi/src/api/lifecycle.rs +++ b/indexer_ffi/src/api/lifecycle.rs @@ -1,8 +1,16 @@ use std::{ffi::c_char, path::PathBuf}; +use sequencer_core::indexer_client::{IndexerClient, IndexerClientTrait}; use tokio::runtime::Runtime; -use crate::{IndexerServiceFFI, api::PointerResult, errors::OperationStatus}; +use crate::{ + IndexerServiceFFI, + api::{ + PointerResult, + client::{UrlProtocol, addr_to_url}, + }, + errors::OperationStatus, +}; pub type InitializedIndexerServiceFFIResult = PointerResult; @@ -67,7 +75,13 @@ fn setup_indexer( OperationStatus::InitializationError })?; - Ok(IndexerServiceFFI::new(indexer_handle, rt)) + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr())?; + let indexer_client = rt.block_on(IndexerClient::new(&indexer_url)).map_err(|e| { + log::error!("Could not start indexer client: {e}"); + OperationStatus::InitializationError + })?; + + Ok(IndexerServiceFFI::new(indexer_handle, rt, indexer_client)) } /// Stops and frees the resources associated with the given indexer service. diff --git a/indexer_ffi/src/api/mod.rs b/indexer_ffi/src/api/mod.rs index e84a3913..a20cb6a5 100644 --- a/indexer_ffi/src/api/mod.rs +++ b/indexer_ffi/src/api/mod.rs @@ -1,5 +1,6 @@ pub use result::PointerResult; +pub mod client; pub mod lifecycle; pub mod memory; pub mod result; diff --git a/indexer_ffi/src/indexer.rs b/indexer_ffi/src/indexer.rs index a3991388..be01f7f9 100644 --- a/indexer_ffi/src/indexer.rs +++ b/indexer_ffi/src/indexer.rs @@ -1,20 +1,27 @@ use std::{ffi::c_void, net::SocketAddr}; use indexer_service::IndexerHandle; +use sequencer_core::indexer_client::IndexerClient; use tokio::runtime::Runtime; #[repr(C)] pub struct IndexerServiceFFI { indexer_handle: *mut c_void, runtime: *mut c_void, + indexer_client: *mut c_void, } impl IndexerServiceFFI { - pub fn new(indexer_handle: indexer_service::IndexerHandle, runtime: Runtime) -> Self { + pub fn new( + indexer_handle: indexer_service::IndexerHandle, + runtime: Runtime, + indexer_client: IndexerClient, + ) -> Self { Self { // Box the complex types and convert to opaque pointers indexer_handle: Box::into_raw(Box::new(indexer_handle)).cast::(), runtime: Box::into_raw(Box::new(runtime)).cast::(), + indexer_client: Box::into_raw(Box::new(indexer_client)).cast::(), } } @@ -25,10 +32,11 @@ impl IndexerServiceFFI { /// The caller must ensure that: /// - `self` is a valid object(contains valid pointers in all fields) #[must_use] - pub unsafe fn into_parts(self) -> (Box, Box) { + pub unsafe fn into_parts(self) -> (Box, Box, Box) { let indexer_handle = unsafe { Box::from_raw(self.indexer_handle.cast::()) }; let runtime = unsafe { Box::from_raw(self.runtime.cast::()) }; - (indexer_handle, runtime) + let indexer_client = unsafe { Box::from_raw(self.indexer_client.cast::()) }; + (indexer_handle, runtime, indexer_client) } /// Helper to get indexer handle addr. @@ -49,7 +57,7 @@ impl IndexerServiceFFI { indexer_handle.addr() } - /// Helper to get indexer handle addr. + /// Helper to get indexer handle ref. /// /// # Safety /// @@ -64,6 +72,22 @@ impl IndexerServiceFFI { .expect("Indexer Handle must be non-null pointer") } } + + /// Helper to get indexer client ref. + /// + /// # Safety + /// + /// The caller must ensure that: + /// - `self` is a valid object(contains valid pointers in all fields) + #[must_use] + pub const unsafe fn client(&self) -> &IndexerClient { + unsafe { + self.indexer_client + .cast::() + .as_ref() + .expect("Indexer Client must be non-null pointer") + } + } } // Implement Drop to prevent memory leaks From 9fc2e39c1092eb3f3f88a97f406a0e2ce1b2ad6d Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Thu, 23 Apr 2026 17:07:19 +0300 Subject: [PATCH 02/25] feat: first query api --- Cargo.lock | 1 + indexer_ffi/Cargo.toml | 1 + indexer_ffi/indexer_ffi.h | 32 ++++++++++++++++++ indexer_ffi/src/api/mod.rs | 1 + indexer_ffi/src/api/query.rs | 41 +++++++++++++++++++++++ indexer_ffi/src/errors.rs | 1 + indexer_ffi/src/indexer.rs | 16 +++++++++ integration_tests/src/test_context_ffi.rs | 5 +++ integration_tests/tests/indexer_ffi.rs | 16 +++++++++ 9 files changed, 114 insertions(+) create mode 100644 indexer_ffi/src/api/query.rs diff --git a/Cargo.lock b/Cargo.lock index 0d997442..9b68aba9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3484,6 +3484,7 @@ version = "0.1.0" dependencies = [ "cbindgen", "indexer_service", + "indexer_service_rpc", "log", "sequencer_core", "tokio", diff --git a/indexer_ffi/Cargo.toml b/indexer_ffi/Cargo.toml index ed3de7ae..8099ad80 100644 --- a/indexer_ffi/Cargo.toml +++ b/indexer_ffi/Cargo.toml @@ -7,6 +7,7 @@ version = "0.1.0" [dependencies] indexer_service.workspace = true sequencer_core.workspace = true +indexer_service_rpc.workspace = true url.workspace = true log = { workspace = true } diff --git a/indexer_ffi/indexer_ffi.h b/indexer_ffi/indexer_ffi.h index d764d116..1db1784a 100644 --- a/indexer_ffi/indexer_ffi.h +++ b/indexer_ffi/indexer_ffi.h @@ -7,6 +7,7 @@ typedef enum OperationStatus { Ok = 0, NullPointer = 1, InitializationError = 2, + ClientError = 3, } OperationStatus; typedef struct IndexerServiceFFI { @@ -28,6 +29,17 @@ typedef struct PointerResult_IndexerServiceFFI__OperationStatus { typedef struct PointerResult_IndexerServiceFFI__OperationStatus InitializedIndexerServiceFFIResult; +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_u64__OperationStatus { + uint64_t *value; + enum OperationStatus error; +} PointerResult_u64__OperationStatus; + /** * Creates and starts an indexer based on the provided * configuration file path. @@ -72,6 +84,26 @@ enum OperationStatus stop_indexer(struct IndexerServiceFFI *indexer); */ void free_cstring(char *block); +/** + * Stops and frees the resources associated with the given indexer service. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. + * + * # Returns + * + * An `OperationStatus` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance + * - The `IndexerServiceFFI` instance was created by this library + * - The pointer will not be used after this function returns + */ +struct PointerResult_u64__OperationStatus query_last_block(const struct IndexerServiceFFI *indexer); + bool is_ok(const enum OperationStatus *self); bool is_error(const enum OperationStatus *self); diff --git a/indexer_ffi/src/api/mod.rs b/indexer_ffi/src/api/mod.rs index a20cb6a5..43284dc8 100644 --- a/indexer_ffi/src/api/mod.rs +++ b/indexer_ffi/src/api/mod.rs @@ -3,4 +3,5 @@ pub use result::PointerResult; pub mod client; pub mod lifecycle; pub mod memory; +pub mod query; pub mod result; diff --git a/indexer_ffi/src/api/query.rs b/indexer_ffi/src/api/query.rs new file mode 100644 index 00000000..9f9523cf --- /dev/null +++ b/indexer_ffi/src/api/query.rs @@ -0,0 +1,41 @@ +use indexer_service_rpc::RpcClient; + +use crate::{IndexerServiceFFI, api::PointerResult, errors::OperationStatus}; + +/// Stops and frees the resources associated with the given indexer service. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. +/// +/// # Returns +/// +/// An `OperationStatus` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance +/// - The `IndexerServiceFFI` instance was created by this library +/// - The pointer will not be used after this function returns +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_last_block( + indexer: *const IndexerServiceFFI, +) -> PointerResult { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = unsafe { indexer.client() }; + let runtime = unsafe { indexer.runtime() }; + + runtime + .block_on(client.get_last_finalized_block_id()) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + PointerResult::from_value, + ) +} diff --git a/indexer_ffi/src/errors.rs b/indexer_ffi/src/errors.rs index 46aa0f9f..4572474c 100644 --- a/indexer_ffi/src/errors.rs +++ b/indexer_ffi/src/errors.rs @@ -5,6 +5,7 @@ pub enum OperationStatus { Ok = 0x0, NullPointer = 0x1, InitializationError = 0x2, + ClientError = 0x3, } impl OperationStatus { diff --git a/indexer_ffi/src/indexer.rs b/indexer_ffi/src/indexer.rs index be01f7f9..1bc6fb3b 100644 --- a/indexer_ffi/src/indexer.rs +++ b/indexer_ffi/src/indexer.rs @@ -88,6 +88,22 @@ impl IndexerServiceFFI { .expect("Indexer Client must be non-null pointer") } } + + /// Helper to get indexer runtime ref. + /// + /// # Safety + /// + /// The caller must ensure that: + /// - `self` is a valid object(contains valid pointers in all fields) + #[must_use] + pub const unsafe fn runtime(&self) -> &Runtime { + unsafe { + self.runtime + .cast::() + .as_ref() + .expect("Indexer Runtime must be non-null pointer") + } + } } // Implement Drop to prevent memory leaks diff --git a/integration_tests/src/test_context_ffi.rs b/integration_tests/src/test_context_ffi.rs index 7d21aa28..1bda9e2c 100644 --- a/integration_tests/src/test_context_ffi.rs +++ b/integration_tests/src/test_context_ffi.rs @@ -268,6 +268,11 @@ impl BlockingTestContextFFI { pub fn runtime_clone(&self) -> Arc { Arc::::clone(&self.runtime) } + + #[must_use] + pub const fn indexer_ffi(&self) -> *const IndexerServiceFFI { + &(self.indexer_ffi) + } } impl Drop for BlockingTestContextFFI { diff --git a/integration_tests/tests/indexer_ffi.rs b/integration_tests/tests/indexer_ffi.rs index 5495e6c6..b75f1b2b 100644 --- a/integration_tests/tests/indexer_ffi.rs +++ b/integration_tests/tests/indexer_ffi.rs @@ -5,6 +5,7 @@ )] use anyhow::{Context as _, Result}; +use indexer_ffi::{IndexerServiceFFI, OperationStatus, api::PointerResult}; use indexer_service_rpc::RpcClient as _; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, format_private_account_id, format_public_account_id, @@ -17,6 +18,12 @@ use wallet::cli::{Command, programs::native_token_transfer::AuthTransferSubcomma /// Maximum time to wait for the indexer to catch up to the sequencer. const L2_TO_L1_TIMEOUT_MILLIS: u64 = 180_000; +unsafe extern "C" { + unsafe fn query_last_block( + indexer: *const IndexerServiceFFI, + ) -> PointerResult; +} + #[test] fn indexer_test_run_ffi() -> Result<()> { let blocking_ctx = BlockingTestContextFFI::new()?; @@ -28,10 +35,19 @@ fn indexer_test_run_ffi() -> Result<()> { }); let last_block_indexer = blocking_ctx.ctx().get_last_block_indexer(runtime_wrapped)?; + let last_block_indexer_ffi_res = unsafe { query_last_block(blocking_ctx.indexer_ffi()) }; + + assert!(last_block_indexer_ffi_res.error.is_ok()); + + let last_block_indexer_ffi = unsafe { *last_block_indexer_ffi_res.value }; info!("Last block on ind now is {last_block_indexer}"); + info!("Last block on ind ffi now is {last_block_indexer_ffi}"); assert!(last_block_indexer > 1); + assert!(last_block_indexer_ffi > 1); + + assert_eq!(last_block_indexer, last_block_indexer_ffi); Ok(()) } From b736b229ef69d39adbf210f08384a54489c442ba Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Fri, 24 Apr 2026 17:37:09 +0300 Subject: [PATCH 03/25] fix: types halfway done --- indexer_ffi/indexer_ffi.h | 113 +++++++++++++++++++++++++++++++++ indexer_ffi/src/api/convert.rs | 12 ++++ indexer_ffi/src/api/mod.rs | 2 + indexer_ffi/src/api/query.rs | 41 +++++++++++- indexer_ffi/src/api/types.rs | 99 +++++++++++++++++++++++++++++ 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 indexer_ffi/src/api/convert.rs create mode 100644 indexer_ffi/src/api/types.rs diff --git a/indexer_ffi/indexer_ffi.h b/indexer_ffi/indexer_ffi.h index 1db1784a..b734b59f 100644 --- a/indexer_ffi/indexer_ffi.h +++ b/indexer_ffi/indexer_ffi.h @@ -10,6 +10,24 @@ typedef enum OperationStatus { ClientError = 3, } OperationStatus; +typedef enum TransactionKind { + Public = 0, + Private, + ProgramDeploy, +} TransactionKind; + +typedef enum BedrockStatus { + Pending = 0, + Safe, + Finalized, +} BedrockStatus; + +typedef struct Vec_AccountId Vec_AccountId; + +typedef struct Vec_Nonce Vec_Nonce; + +typedef struct Vec_u32 Vec_u32; + typedef struct IndexerServiceFFI { void *indexer_handle; void *runtime; @@ -40,6 +58,80 @@ typedef struct PointerResult_u64__OperationStatus { enum OperationStatus error; } PointerResult_u64__OperationStatus; +typedef uint64_t BlockId; + +typedef uint8_t HashType[32]; + +typedef uint64_t Timestamp; + +typedef uint8_t Signature[64]; + +typedef struct BlockHeader { + BlockId block_id; + HashType prev_block_hash; + HashType hash; + Timestamp timestamp; + Signature signature; +} BlockHeader; + +typedef uint32_t ProgramId[8]; + +typedef struct PublicMessage { + ProgramId program_id; + struct Vec_AccountId account_ids; + struct Vec_Nonce nonces; + struct Vec_u32 instruction_data; +} PublicMessage; + +typedef struct WitnessSet { + +} WitnessSet; + +typedef struct PublicTransactionBody { + HashType hash; + struct PublicMessage message; + struct WitnessSet witness_set; +} PublicTransactionBody; + +typedef struct TransactionBody { + struct PublicTransactionBody *public_body; +} TransactionBody; + +typedef struct Transaction { + struct TransactionBody body; + enum TransactionKind kind; +} Transaction; + +typedef struct BlockBody { + struct Transaction *txs; + uintptr_t len; +} BlockBody; + +typedef uint8_t MsgId[32]; + +typedef struct Block { + struct BlockHeader header; + struct BlockBody body; + enum BedrockStatus bedrock_status; + MsgId bedrock_parent_id; +} Block; + +typedef struct BlockOpt { + struct Block *block; + bool is_ok; +} BlockOpt; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_BlockOpt__OperationStatus { + struct BlockOpt *value; + enum OperationStatus error; +} PointerResult_BlockOpt__OperationStatus; + /** * Creates and starts an indexer based on the provided * configuration file path. @@ -104,6 +196,27 @@ void free_cstring(char *block); */ struct PointerResult_u64__OperationStatus query_last_block(const struct IndexerServiceFFI *indexer); +/** + * Stops and frees the resources associated with the given indexer service. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. + * + * # Returns + * + * An `OperationStatus` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance + * - The `IndexerServiceFFI` instance was created by this library + * - The pointer will not be used after this function returns + */ +struct PointerResult_BlockOpt__OperationStatus query_block(const struct IndexerServiceFFI *indexer, + BlockId block_id); + bool is_ok(const enum OperationStatus *self); bool is_error(const enum OperationStatus *self); diff --git a/indexer_ffi/src/api/convert.rs b/indexer_ffi/src/api/convert.rs new file mode 100644 index 00000000..d970de38 --- /dev/null +++ b/indexer_ffi/src/api/convert.rs @@ -0,0 +1,12 @@ +use crate::api::types::BlockOpt; + +impl From> for BlockOpt { + fn from(value: Option) -> Self { + match value { + None => BlockOpt { block: std::ptr::null_mut(), is_ok: false }, + Some(block_orig) => BlockOpt { block: block_orig.into(), is_ok: true } + } + } +} + +impl From<> \ No newline at end of file diff --git a/indexer_ffi/src/api/mod.rs b/indexer_ffi/src/api/mod.rs index 43284dc8..9221a65c 100644 --- a/indexer_ffi/src/api/mod.rs +++ b/indexer_ffi/src/api/mod.rs @@ -5,3 +5,5 @@ pub mod lifecycle; pub mod memory; pub mod query; pub mod result; +pub mod types; +pub mod convert; diff --git a/indexer_ffi/src/api/query.rs b/indexer_ffi/src/api/query.rs index 9f9523cf..c4dd26b1 100644 --- a/indexer_ffi/src/api/query.rs +++ b/indexer_ffi/src/api/query.rs @@ -1,6 +1,6 @@ use indexer_service_rpc::RpcClient; -use crate::{IndexerServiceFFI, api::PointerResult, errors::OperationStatus}; +use crate::{IndexerServiceFFI, api::{PointerResult, types::{Block, BlockId, BlockOpt}}, errors::OperationStatus}; /// Stops and frees the resources associated with the given indexer service. /// @@ -39,3 +39,42 @@ pub unsafe extern "C" fn query_last_block( PointerResult::from_value, ) } + +/// Stops and frees the resources associated with the given indexer service. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. +/// +/// # Returns +/// +/// An `OperationStatus` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance +/// - The `IndexerServiceFFI` instance was created by this library +/// - The pointer will not be used after this function returns +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_block( + indexer: *const IndexerServiceFFI, + block_id: BlockId, +) -> PointerResult { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = unsafe { indexer.client() }; + let runtime = unsafe { indexer.runtime() }; + + runtime + .block_on(client.get_block_by_id(block_id)) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |block| PointerResult::from_value(block.into()), + ) +} diff --git a/indexer_ffi/src/api/types.rs b/indexer_ffi/src/api/types.rs new file mode 100644 index 00000000..920b4764 --- /dev/null +++ b/indexer_ffi/src/api/types.rs @@ -0,0 +1,99 @@ +pub type HashType = [u8; 32]; +pub type MsgId = [u8; 32]; +pub type BlockId = u64; +pub type Timestamp = u64; +pub type Signature = [u8; 64]; +pub type ProgramId = [u32; 8]; +pub type AccountId = [u8; 32]; +pub type Nonce = u128; +pub type PublicKey = [u8; 32]; + +#[repr(C)] +pub struct Block { + pub header: BlockHeader, + pub body: BlockBody, + pub bedrock_status: BedrockStatus, + pub bedrock_parent_id: MsgId, +} + +#[repr(C)] +pub struct BlockOpt { + pub block: *const Block, + pub is_ok: bool, +} + +#[repr(C)] +pub struct PublicMessage { + pub program_id: ProgramId, + pub account_ids: Vec, + pub nonces: Vec, + pub instruction_data: Vec, +} + +#[repr(C)] +pub struct PublicTransactionBody { + pub hash: HashType, + pub message: PublicMessage, + pub witness_set: Vec<(Signature, PublicKey)>, +} + +#[repr(C)] +pub struct PrivateTransactionBody { + +} + +#[repr(C)] +pub struct ProgramDeploymentTransactionBody { + +} + +#[repr(C)] +pub struct TransactionBody { + pub public_body: *const PublicTransactionBody, + pub private_body: *const PrivateTransactionBody, + pub program_deployment_body: *const ProgramDeploymentTransactionBody, +} + +#[repr(C)] +pub struct Transaction { + pub body: TransactionBody, + pub kind: TransactionKind, +} + +#[repr(C)] +pub struct BlockBody { + pub txs: *const Transaction, + pub len: usize, +} + +impl Default for BlockBody { + fn default() -> Self { + Self { + txs: std::ptr::null_mut(), + len: 0, + } + } +} + +#[repr(C)] +pub struct BlockHeader { + pub block_id: BlockId, + pub prev_block_hash: HashType, + pub hash: HashType, + pub timestamp: Timestamp, + pub signature: Signature, +} + +#[repr(C)] +pub enum BedrockStatus { + Pending = 0x0, + Safe, + Finalized, +} + +#[repr(C)] +pub enum TransactionKind { + Public = 0x0, + Private, + ProgramDeploy, +} \ No newline at end of file From 37f59281c07ab11747011dbbfef8d7fd4ca3718a Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Mon, 27 Apr 2026 15:38:06 +0300 Subject: [PATCH 04/25] fix: all types added --- Cargo.lock | 2 + indexer_ffi/Cargo.toml | 2 + indexer_ffi/indexer_ffi.h | 83 +--------- indexer_ffi/src/api/convert.rs | 12 +- indexer_ffi/src/api/mod.rs | 2 +- indexer_ffi/src/api/query.rs | 9 +- indexer_ffi/src/api/types.rs | 99 ------------ indexer_ffi/src/api/types/account.rs | 195 +++++++++++++++++++++++ indexer_ffi/src/api/types/block.rs | 36 +++++ indexer_ffi/src/api/types/mod.rs | 29 ++++ indexer_ffi/src/api/types/transaction.rs | 101 ++++++++++++ wallet-ffi/Cargo.toml | 1 + 12 files changed, 384 insertions(+), 187 deletions(-) delete mode 100644 indexer_ffi/src/api/types.rs create mode 100644 indexer_ffi/src/api/types/account.rs create mode 100644 indexer_ffi/src/api/types/block.rs create mode 100644 indexer_ffi/src/api/types/mod.rs create mode 100644 indexer_ffi/src/api/types/transaction.rs diff --git a/Cargo.lock b/Cargo.lock index 769b5fb3..6517f789 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3486,6 +3486,8 @@ dependencies = [ "indexer_service", "indexer_service_rpc", "log", + "nssa", + "nssa_core", "sequencer_core", "tokio", "url", diff --git a/indexer_ffi/Cargo.toml b/indexer_ffi/Cargo.toml index 8099ad80..425e322a 100644 --- a/indexer_ffi/Cargo.toml +++ b/indexer_ffi/Cargo.toml @@ -5,6 +5,8 @@ name = "indexer_ffi" version = "0.1.0" [dependencies] +nssa_core.workspace = true +nssa.workspace = true indexer_service.workspace = true sequencer_core.workspace = true indexer_service_rpc.workspace = true diff --git a/indexer_ffi/indexer_ffi.h b/indexer_ffi/indexer_ffi.h index b734b59f..cce80837 100644 --- a/indexer_ffi/indexer_ffi.h +++ b/indexer_ffi/indexer_ffi.h @@ -10,24 +10,6 @@ typedef enum OperationStatus { ClientError = 3, } OperationStatus; -typedef enum TransactionKind { - Public = 0, - Private, - ProgramDeploy, -} TransactionKind; - -typedef enum BedrockStatus { - Pending = 0, - Safe, - Finalized, -} BedrockStatus; - -typedef struct Vec_AccountId Vec_AccountId; - -typedef struct Vec_Nonce Vec_Nonce; - -typedef struct Vec_u32 Vec_u32; - typedef struct IndexerServiceFFI { void *indexer_handle; void *runtime; @@ -58,69 +40,6 @@ typedef struct PointerResult_u64__OperationStatus { enum OperationStatus error; } PointerResult_u64__OperationStatus; -typedef uint64_t BlockId; - -typedef uint8_t HashType[32]; - -typedef uint64_t Timestamp; - -typedef uint8_t Signature[64]; - -typedef struct BlockHeader { - BlockId block_id; - HashType prev_block_hash; - HashType hash; - Timestamp timestamp; - Signature signature; -} BlockHeader; - -typedef uint32_t ProgramId[8]; - -typedef struct PublicMessage { - ProgramId program_id; - struct Vec_AccountId account_ids; - struct Vec_Nonce nonces; - struct Vec_u32 instruction_data; -} PublicMessage; - -typedef struct WitnessSet { - -} WitnessSet; - -typedef struct PublicTransactionBody { - HashType hash; - struct PublicMessage message; - struct WitnessSet witness_set; -} PublicTransactionBody; - -typedef struct TransactionBody { - struct PublicTransactionBody *public_body; -} TransactionBody; - -typedef struct Transaction { - struct TransactionBody body; - enum TransactionKind kind; -} Transaction; - -typedef struct BlockBody { - struct Transaction *txs; - uintptr_t len; -} BlockBody; - -typedef uint8_t MsgId[32]; - -typedef struct Block { - struct BlockHeader header; - struct BlockBody body; - enum BedrockStatus bedrock_status; - MsgId bedrock_parent_id; -} Block; - -typedef struct BlockOpt { - struct Block *block; - bool is_ok; -} BlockOpt; - /** * Simple wrapper around a pointer to a value or an error. * @@ -128,7 +47,7 @@ typedef struct BlockOpt { * dereferencing the pointer. */ typedef struct PointerResult_BlockOpt__OperationStatus { - struct BlockOpt *value; + BlockOpt *value; enum OperationStatus error; } PointerResult_BlockOpt__OperationStatus; diff --git a/indexer_ffi/src/api/convert.rs b/indexer_ffi/src/api/convert.rs index d970de38..f5636b89 100644 --- a/indexer_ffi/src/api/convert.rs +++ b/indexer_ffi/src/api/convert.rs @@ -3,10 +3,14 @@ use crate::api::types::BlockOpt; impl From> for BlockOpt { fn from(value: Option) -> Self { match value { - None => BlockOpt { block: std::ptr::null_mut(), is_ok: false }, - Some(block_orig) => BlockOpt { block: block_orig.into(), is_ok: true } + None => BlockOpt { + block: std::ptr::null_mut(), + is_ok: false, + }, + Some(block_orig) => BlockOpt { + block: block_orig.into(), + is_ok: true, + }, } } } - -impl From<> \ No newline at end of file diff --git a/indexer_ffi/src/api/mod.rs b/indexer_ffi/src/api/mod.rs index 9221a65c..4c80629b 100644 --- a/indexer_ffi/src/api/mod.rs +++ b/indexer_ffi/src/api/mod.rs @@ -1,9 +1,9 @@ pub use result::PointerResult; pub mod client; +pub mod convert; pub mod lifecycle; pub mod memory; pub mod query; pub mod result; pub mod types; -pub mod convert; diff --git a/indexer_ffi/src/api/query.rs b/indexer_ffi/src/api/query.rs index c4dd26b1..510f1383 100644 --- a/indexer_ffi/src/api/query.rs +++ b/indexer_ffi/src/api/query.rs @@ -1,6 +1,13 @@ use indexer_service_rpc::RpcClient; -use crate::{IndexerServiceFFI, api::{PointerResult, types::{Block, BlockId, BlockOpt}}, errors::OperationStatus}; +use crate::{ + IndexerServiceFFI, + api::{ + PointerResult, + types::{Block, BlockId, BlockOpt}, + }, + errors::OperationStatus, +}; /// Stops and frees the resources associated with the given indexer service. /// diff --git a/indexer_ffi/src/api/types.rs b/indexer_ffi/src/api/types.rs deleted file mode 100644 index 920b4764..00000000 --- a/indexer_ffi/src/api/types.rs +++ /dev/null @@ -1,99 +0,0 @@ -pub type HashType = [u8; 32]; -pub type MsgId = [u8; 32]; -pub type BlockId = u64; -pub type Timestamp = u64; -pub type Signature = [u8; 64]; -pub type ProgramId = [u32; 8]; -pub type AccountId = [u8; 32]; -pub type Nonce = u128; -pub type PublicKey = [u8; 32]; - -#[repr(C)] -pub struct Block { - pub header: BlockHeader, - pub body: BlockBody, - pub bedrock_status: BedrockStatus, - pub bedrock_parent_id: MsgId, -} - -#[repr(C)] -pub struct BlockOpt { - pub block: *const Block, - pub is_ok: bool, -} - -#[repr(C)] -pub struct PublicMessage { - pub program_id: ProgramId, - pub account_ids: Vec, - pub nonces: Vec, - pub instruction_data: Vec, -} - -#[repr(C)] -pub struct PublicTransactionBody { - pub hash: HashType, - pub message: PublicMessage, - pub witness_set: Vec<(Signature, PublicKey)>, -} - -#[repr(C)] -pub struct PrivateTransactionBody { - -} - -#[repr(C)] -pub struct ProgramDeploymentTransactionBody { - -} - -#[repr(C)] -pub struct TransactionBody { - pub public_body: *const PublicTransactionBody, - pub private_body: *const PrivateTransactionBody, - pub program_deployment_body: *const ProgramDeploymentTransactionBody, -} - -#[repr(C)] -pub struct Transaction { - pub body: TransactionBody, - pub kind: TransactionKind, -} - -#[repr(C)] -pub struct BlockBody { - pub txs: *const Transaction, - pub len: usize, -} - -impl Default for BlockBody { - fn default() -> Self { - Self { - txs: std::ptr::null_mut(), - len: 0, - } - } -} - -#[repr(C)] -pub struct BlockHeader { - pub block_id: BlockId, - pub prev_block_hash: HashType, - pub hash: HashType, - pub timestamp: Timestamp, - pub signature: Signature, -} - -#[repr(C)] -pub enum BedrockStatus { - Pending = 0x0, - Safe, - Finalized, -} - -#[repr(C)] -pub enum TransactionKind { - Public = 0x0, - Private, - ProgramDeploy, -} \ No newline at end of file diff --git a/indexer_ffi/src/api/types/account.rs b/indexer_ffi/src/api/types/account.rs new file mode 100644 index 00000000..e36230ae --- /dev/null +++ b/indexer_ffi/src/api/types/account.rs @@ -0,0 +1,195 @@ +//! C-compatible type definitions for the FFI layer. + +use std::ptr; + +use crate::api::types::FfiVec; + +/// 32-byte array type for `AccountId`, keys, hashes, etc. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiBytes32 { + pub data: [u8; 32], +} + +/// 64-byte array type for signatures, etc. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct FfiBytes64 { + pub data: [u8; 64], +} + +impl Default for FfiBytes64 { + fn default() -> Self { + Self { data: [0; 64] } + } +} + +/// Program ID - 8 u32 values (32 bytes total). +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiProgramId { + pub data: [u32; 8], +} + +/// U128 - 16 bytes little endian. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiU128 { + pub data: [u8; 16], +} + +/// Account data structure - C-compatible version of nssa Account. +/// +/// Note: `balance` and `nonce` are u128 values represented as little-endian +/// byte arrays since C doesn't have native u128 support. +#[repr(C)] +pub struct FfiAccount { + pub program_owner: FfiProgramId, + /// Balance as little-endian [u8; 16]. + pub balance: FfiU128, + /// Pointer to account data bytes. + pub data: *const u8, + /// Length of account data. + pub data_len: usize, + /// Nonce as little-endian [u8; 16]. + pub nonce: FfiU128, +} + +#[repr(C)] +pub struct FfiAccountList { + pub entries: *const FfiAccount, + pub len: usize, +} + +impl Default for FfiAccount { + fn default() -> Self { + Self { + program_owner: FfiProgramId::default(), + balance: FfiU128::default(), + data: std::ptr::null(), + data_len: 0, + nonce: FfiU128::default(), + } + } +} + +/// Public keys for a private account (safe to expose). +#[repr(C)] +pub struct FfiPrivateAccountKeys { + /// Nullifier public key (32 bytes). + pub nullifier_public_key: FfiBytes32, + /// viewing public key (compressed secp256k1 point). + pub viewing_public_key: *const u8, + /// Length of viewing public key (typically 33 bytes). + pub viewing_public_key_len: usize, +} + +impl Default for FfiPrivateAccountKeys { + fn default() -> Self { + Self { + nullifier_public_key: FfiBytes32::default(), + viewing_public_key: std::ptr::null(), + viewing_public_key_len: 0, + } + } +} + +/// Public key info for a public account. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiPublicAccountKey { + pub public_key: FfiBytes32, +} + +/// Single entry in the account list. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct FfiAccountIdListEntry { + pub account_id: FfiBytes32, + pub is_public: bool, +} + +/// List of accounts returned by `wallet_ffi_list_accounts`. +#[repr(C)] +pub struct FfiAccountIdList { + pub entries: *mut FfiAccountIdListEntry, + pub count: usize, +} + +impl Default for FfiAccountIdList { + fn default() -> Self { + Self { + entries: std::ptr::null_mut(), + count: 0, + } + } +} + +pub type FfiVecBytes32 = FfiVec; + +// Helper functions to convert between Rust and FFI types + +impl FfiBytes32 { + /// Create from a 32-byte array. + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self { data: bytes } + } + + /// Create from an `AccountId`. + #[must_use] + pub const fn from_account_id(id: &nssa::AccountId) -> Self { + Self { data: *id.value() } + } +} + +impl From for FfiU128 { + fn from(value: u128) -> Self { + Self { + data: value.to_le_bytes(), + } + } +} + +impl From<&nssa::AccountId> for FfiBytes32 { + fn from(id: &nssa::AccountId) -> Self { + Self::from_account_id(id) + } +} + +impl From for FfiAccount { + #[expect( + clippy::as_conversions, + reason = "We need to convert to byte arrays for FFI" + )] + fn from(value: nssa::Account) -> Self { + // Convert account data to FFI type + let data_vec: Vec = value.data.into(); + let data_len = data_vec.len(); + let data = if data_len > 0 { + let data_boxed = data_vec.into_boxed_slice(); + Box::into_raw(data_boxed) as *const u8 + } else { + ptr::null() + }; + + let program_owner = FfiProgramId { + data: value.program_owner, + }; + Self { + program_owner, + balance: value.balance.into(), + data, + data_len, + nonce: value.nonce.0.into(), + } + } +} + +impl From for FfiPublicAccountKey { + fn from(value: nssa::PublicKey) -> Self { + Self { + public_key: FfiBytes32::from_bytes(*value.value()), + } + } +} diff --git a/indexer_ffi/src/api/types/block.rs b/indexer_ffi/src/api/types/block.rs new file mode 100644 index 00000000..a31bd495 --- /dev/null +++ b/indexer_ffi/src/api/types/block.rs @@ -0,0 +1,36 @@ +use crate::api::types::{ + FfiBlockId, FfiHashType, FfiMsgId, FfiSignature, FfiTimestamp, FfiVec, + transaction::FfiTransaction, +}; + +#[repr(C)] +pub struct FfiBlock { + pub header: FfiBlockHeader, + pub body: FfiBlockBody, + pub bedrock_status: FfiBedrockStatus, + pub bedrock_parent_id: FfiMsgId, +} + +#[repr(C)] +pub struct FfiBlockOpt { + pub block: *const FfiBlock, + pub is_some: bool, +} + +pub type FfiBlockBody = FfiVec; + +#[repr(C)] +pub struct FfiBlockHeader { + pub block_id: FfiBlockId, + pub prev_block_hash: FfiHashType, + pub hash: FfiHashType, + pub timestamp: FfiTimestamp, + pub signature: FfiSignature, +} + +#[repr(C)] +pub enum FfiBedrockStatus { + Pending = 0x0, + Safe, + Finalized, +} diff --git a/indexer_ffi/src/api/types/mod.rs b/indexer_ffi/src/api/types/mod.rs new file mode 100644 index 00000000..73336c05 --- /dev/null +++ b/indexer_ffi/src/api/types/mod.rs @@ -0,0 +1,29 @@ +use crate::api::types::account::{FfiBytes32, FfiBytes64, FfiU128}; + +pub mod account; +pub mod block; +pub mod transaction; + +pub type FfiHashType = FfiBytes32; +pub type FfiMsgId = FfiBytes32; +pub type FfiBlockId = u64; +pub type FfiTimestamp = u64; +pub type FfiSignature = FfiBytes64; +pub type FfiAccountId = FfiBytes32; +pub type FfiNonce = FfiU128; +pub type FfiPublicKey = FfiBytes32; + +#[repr(C)] +pub struct FfiVec { + pub entries: *const T, + pub len: usize, +} + +impl Default for FfiVec { + fn default() -> Self { + Self { + entries: std::ptr::null(), + len: 0, + } + } +} diff --git a/indexer_ffi/src/api/types/transaction.rs b/indexer_ffi/src/api/types/transaction.rs new file mode 100644 index 00000000..4f92d492 --- /dev/null +++ b/indexer_ffi/src/api/types/transaction.rs @@ -0,0 +1,101 @@ +use crate::api::types::{ + FfiHashType, FfiNonce, FfiPublicKey, FfiSignature, FfiVec, + account::{FfiAccountIdList, FfiAccountList, FfiBytes32, FfiProgramId, FfiVecBytes32}, +}; + +#[repr(C)] +pub struct FfiPublicTransactionBody { + pub hash: FfiHashType, + pub message: FfiPublicMessage, + pub witness_set: FfiSignaturePubKeyList, +} + +pub type FfiNonceList = FfiVec; + +pub type FfiInstructionDataList = FfiVec; + +#[repr(C)] +pub struct FfiPublicMessage { + pub program_id: FfiProgramId, + pub account_ids: FfiAccountIdList, + pub nonces: FfiNonceList, + pub instruction_data: FfiInstructionDataList, +} + +#[repr(C)] +pub struct FfiPrivateTransactionBody { + pub hash: FfiHashType, + pub message: FfiPrivacyPreservingMessage, + pub witness_set: FfiSignaturePubKeyList, + pub proof: FfiProofOpt, +} + +#[repr(C)] +pub struct FfiPrivacyPreservingMessage { + pub public_account_ids: FfiAccountIdList, + pub nonces: FfiNonceList, + pub public_post_states: FfiAccountList, + pub encrypted_private_post_states: FfiVec, + pub new_commitments: FfiVecBytes32, + pub new_nullifiers: FfiVec, + pub block_validity_window: [u64; 2], + pub timestamp_validity_window: [u64; 2], +} + +#[repr(C)] +pub struct NullifierCommitmentSet { + pub nullifier: FfiBytes32, + pub commitment_set_digest: FfiBytes32, +} + +#[repr(C)] +pub struct FfiEncryptedAccountData { + pub ciphertext: FfiVec, + pub epk: FfiVec, + pub view_tag: u8, +} + +#[repr(C)] +pub struct FfiSignaturePubKeyEntry { + pub signature: FfiSignature, + pub public_key: FfiPublicKey, +} + +pub struct FfiSignaturePubKeyList { + pub entries: *const FfiSignaturePubKeyEntry, + pub len: usize, +} + +#[repr(C)] +pub struct FfiProofOpt { + pub proof: FfiVec, + pub is_some: bool, +} + +#[repr(C)] +pub struct FfiProgramDeploymentTransactionBody { + pub hash: FfiHashType, + pub message: FfiProgramDeploymentMessage, +} + +pub type FfiProgramDeploymentMessage = FfiVec; + +#[repr(C)] +pub struct FfiTransactionBody { + pub public_body: *const FfiPublicTransactionBody, + pub private_body: *const FfiPrivateTransactionBody, + pub program_deployment_body: *const FfiProgramDeploymentTransactionBody, +} + +#[repr(C)] +pub struct FfiTransaction { + pub body: FfiTransactionBody, + pub kind: FfiTransactionKind, +} + +#[repr(C)] +pub enum FfiTransactionKind { + Public = 0x0, + Private, + ProgramDeploy, +} diff --git a/wallet-ffi/Cargo.toml b/wallet-ffi/Cargo.toml index 0af20a54..37d552ec 100644 --- a/wallet-ffi/Cargo.toml +++ b/wallet-ffi/Cargo.toml @@ -15,6 +15,7 @@ wallet.workspace = true nssa.workspace = true nssa_core.workspace = true sequencer_service_rpc = { workspace = true, features = ["client"] } + tokio.workspace = true [build-dependencies] From 89ea8842071b8e2450c97c8f2230ae378f94223c Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Tue, 28 Apr 2026 19:36:23 +0300 Subject: [PATCH 05/25] feat: working queries --- Cargo.lock | 2 +- indexer_ffi/Cargo.toml | 2 +- indexer_ffi/indexer_ffi.h | 261 +++++++++++++++++++++- indexer_ffi/src/api/convert.rs | 16 -- indexer_ffi/src/api/lifecycle.rs | 2 +- indexer_ffi/src/api/mod.rs | 1 - indexer_ffi/src/api/query.rs | 16 +- indexer_ffi/src/api/types/account.rs | 40 +--- indexer_ffi/src/api/types/block.rs | 48 +++- indexer_ffi/src/api/types/mod.rs | 75 ++++++- indexer_ffi/src/api/types/transaction.rs | 224 +++++++++++++++++-- indexer_ffi/src/api/types/vectors.rs | 31 +++ integration_tests/src/test_context_ffi.rs | 2 +- integration_tests/tests/indexer_ffi.rs | 1 + 14 files changed, 618 insertions(+), 103 deletions(-) delete mode 100644 indexer_ffi/src/api/convert.rs create mode 100644 indexer_ffi/src/api/types/vectors.rs diff --git a/Cargo.lock b/Cargo.lock index 6517f789..37ab61e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3484,10 +3484,10 @@ version = "0.1.0" dependencies = [ "cbindgen", "indexer_service", + "indexer_service_protocol", "indexer_service_rpc", "log", "nssa", - "nssa_core", "sequencer_core", "tokio", "url", diff --git a/indexer_ffi/Cargo.toml b/indexer_ffi/Cargo.toml index 425e322a..0a140d19 100644 --- a/indexer_ffi/Cargo.toml +++ b/indexer_ffi/Cargo.toml @@ -5,11 +5,11 @@ name = "indexer_ffi" version = "0.1.0" [dependencies] -nssa_core.workspace = true nssa.workspace = true indexer_service.workspace = true sequencer_core.workspace = true indexer_service_rpc.workspace = true +indexer_service_protocol.workspace = true url.workspace = true log = { workspace = true } diff --git a/indexer_ffi/indexer_ffi.h b/indexer_ffi/indexer_ffi.h index cce80837..1094e1de 100644 --- a/indexer_ffi/indexer_ffi.h +++ b/indexer_ffi/indexer_ffi.h @@ -10,6 +10,18 @@ typedef enum OperationStatus { ClientError = 3, } OperationStatus; +typedef enum FfiTransactionKind { + Public = 0, + Private, + ProgramDeploy, +} FfiTransactionKind; + +typedef enum FfiBedrockStatus { + Pending = 0, + Safe, + Finalized, +} FfiBedrockStatus; + typedef struct IndexerServiceFFI { void *indexer_handle; void *runtime; @@ -40,16 +52,255 @@ typedef struct PointerResult_u64__OperationStatus { enum OperationStatus error; } PointerResult_u64__OperationStatus; +typedef uint64_t FfiBlockId; + +/** + * 32-byte array type for `AccountId`, keys, hashes, etc. + */ +typedef struct FfiBytes32 { + uint8_t data[32]; +} FfiBytes32; + +typedef struct FfiBytes32 FfiHashType; + +typedef uint64_t FfiTimestamp; + +/** + * 64-byte array type for signatures, etc. + */ +typedef struct FfiBytes64 { + uint8_t data[64]; +} FfiBytes64; + +typedef struct FfiBytes64 FfiSignature; + +typedef struct FfiBlockHeader { + FfiBlockId block_id; + FfiHashType prev_block_hash; + FfiHashType hash; + FfiTimestamp timestamp; + FfiSignature signature; +} FfiBlockHeader; + +/** + * Program ID - 8 u32 values (32 bytes total). + */ +typedef struct FfiProgramId { + uint32_t data[8]; +} FfiProgramId; + +typedef struct FfiBytes32 FfiAccountId; + +typedef struct FfiVec_FfiAccountId { + FfiAccountId *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiAccountId; + +typedef struct FfiVec_FfiAccountId FfiAccountIdList; + +/** + * U128 - 16 bytes little endian. + */ +typedef struct FfiU128 { + uint8_t data[16]; +} FfiU128; + +typedef struct FfiU128 FfiNonce; + +typedef struct FfiVec_FfiNonce { + FfiNonce *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiNonce; + +typedef struct FfiVec_FfiNonce FfiNonceList; + +typedef struct FfiVec_u32 { + uint32_t *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_u32; + +typedef struct FfiVec_u32 FfiInstructionDataList; + +typedef struct FfiPublicMessage { + struct FfiProgramId program_id; + FfiAccountIdList account_ids; + FfiNonceList nonces; + FfiInstructionDataList instruction_data; +} FfiPublicMessage; + +typedef struct FfiBytes32 FfiPublicKey; + +typedef struct FfiSignaturePubKeyEntry { + FfiSignature signature; + FfiPublicKey public_key; +} FfiSignaturePubKeyEntry; + +typedef struct FfiVec_FfiSignaturePubKeyEntry { + struct FfiSignaturePubKeyEntry *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiSignaturePubKeyEntry; + +typedef struct FfiVec_FfiSignaturePubKeyEntry FfiSignaturePubKeyList; + +typedef struct FfiPublicTransactionBody { + FfiHashType hash; + struct FfiPublicMessage message; + FfiSignaturePubKeyList witness_set; +} FfiPublicTransactionBody; + +/** + * Account data structure - C-compatible version of nssa Account. + * + * Note: `balance` and `nonce` are u128 values represented as little-endian + * byte arrays since C doesn't have native u128 support. + */ +typedef struct FfiAccount { + struct FfiProgramId program_owner; + /** + * Balance as little-endian [u8; 16]. + */ + struct FfiU128 balance; + /** + * Pointer to account data bytes. + */ + const uint8_t *data; + /** + * Length of account data. + */ + uintptr_t data_len; + /** + * Nonce as little-endian [u8; 16]. + */ + struct FfiU128 nonce; +} FfiAccount; + +typedef struct FfiVec_FfiAccount { + struct FfiAccount *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiAccount; + +typedef struct FfiVec_FfiAccount FfiAccountList; + +typedef struct FfiVec_u8 { + uint8_t *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_u8; + +typedef struct FfiVec_u8 FfiVecU8; + +typedef struct FfiEncryptedAccountData { + FfiVecU8 ciphertext; + FfiVecU8 epk; + uint8_t view_tag; +} FfiEncryptedAccountData; + +typedef struct FfiVec_FfiEncryptedAccountData { + struct FfiEncryptedAccountData *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiEncryptedAccountData; + +typedef struct FfiVec_FfiEncryptedAccountData FfiEncryptedAccountDataList; + +typedef struct FfiVec_FfiBytes32 { + struct FfiBytes32 *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiBytes32; + +typedef struct FfiVec_FfiBytes32 FfiVecBytes32; + +typedef struct FfiNullifierCommitmentSet { + struct FfiBytes32 nullifier; + struct FfiBytes32 commitment_set_digest; +} FfiNullifierCommitmentSet; + +typedef struct FfiVec_FfiNullifierCommitmentSet { + struct FfiNullifierCommitmentSet *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiNullifierCommitmentSet; + +typedef struct FfiVec_FfiNullifierCommitmentSet FfiNullifierCommitmentSetList; + +typedef struct FfiPrivacyPreservingMessage { + FfiAccountIdList public_account_ids; + FfiNonceList nonces; + FfiAccountList public_post_states; + FfiEncryptedAccountDataList encrypted_private_post_states; + FfiVecBytes32 new_commitments; + FfiNullifierCommitmentSetList new_nullifiers; + uint64_t block_validity_window[2]; + uint64_t timestamp_validity_window[2]; +} FfiPrivacyPreservingMessage; + +typedef FfiVecU8 FfiProof; + +typedef struct FfiPrivateTransactionBody { + FfiHashType hash; + struct FfiPrivacyPreservingMessage message; + FfiSignaturePubKeyList witness_set; + FfiProof proof; +} FfiPrivateTransactionBody; + +typedef FfiVecU8 FfiProgramDeploymentMessage; + +typedef struct FfiProgramDeploymentTransactionBody { + FfiHashType hash; + FfiProgramDeploymentMessage message; +} FfiProgramDeploymentTransactionBody; + +typedef struct FfiTransactionBody { + struct FfiPublicTransactionBody *public_body; + struct FfiPrivateTransactionBody *private_body; + struct FfiProgramDeploymentTransactionBody *program_deployment_body; +} FfiTransactionBody; + +typedef struct FfiTransaction { + struct FfiTransactionBody body; + enum FfiTransactionKind kind; +} FfiTransaction; + +typedef struct FfiVec_FfiTransaction { + struct FfiTransaction *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiTransaction; + +typedef struct FfiVec_FfiTransaction FfiBlockBody; + +typedef struct FfiBytes32 FfiMsgId; + +typedef struct FfiBlock { + struct FfiBlockHeader header; + FfiBlockBody body; + enum FfiBedrockStatus bedrock_status; + FfiMsgId bedrock_parent_id; +} FfiBlock; + +typedef struct FfiOption_FfiBlock { + struct FfiBlock *value; + bool is_some; +} FfiOption_FfiBlock; + +typedef struct FfiOption_FfiBlock FfiBlockOpt; + /** * Simple wrapper around a pointer to a value or an error. * * Pointer is not guaranteed. You should check the error field before * dereferencing the pointer. */ -typedef struct PointerResult_BlockOpt__OperationStatus { - BlockOpt *value; +typedef struct PointerResult_FfiBlockOpt__OperationStatus { + FfiBlockOpt *value; enum OperationStatus error; -} PointerResult_BlockOpt__OperationStatus; +} PointerResult_FfiBlockOpt__OperationStatus; /** * Creates and starts an indexer based on the provided @@ -133,8 +384,8 @@ struct PointerResult_u64__OperationStatus query_last_block(const struct IndexerS * - The `IndexerServiceFFI` instance was created by this library * - The pointer will not be used after this function returns */ -struct PointerResult_BlockOpt__OperationStatus query_block(const struct IndexerServiceFFI *indexer, - BlockId block_id); +struct PointerResult_FfiBlockOpt__OperationStatus query_block(const struct IndexerServiceFFI *indexer, + FfiBlockId block_id); bool is_ok(const enum OperationStatus *self); diff --git a/indexer_ffi/src/api/convert.rs b/indexer_ffi/src/api/convert.rs deleted file mode 100644 index f5636b89..00000000 --- a/indexer_ffi/src/api/convert.rs +++ /dev/null @@ -1,16 +0,0 @@ -use crate::api::types::BlockOpt; - -impl From> for BlockOpt { - fn from(value: Option) -> Self { - match value { - None => BlockOpt { - block: std::ptr::null_mut(), - is_ok: false, - }, - Some(block_orig) => BlockOpt { - block: block_orig.into(), - is_ok: true, - }, - } - } -} diff --git a/indexer_ffi/src/api/lifecycle.rs b/indexer_ffi/src/api/lifecycle.rs index 087f7803..15c6e619 100644 --- a/indexer_ffi/src/api/lifecycle.rs +++ b/indexer_ffi/src/api/lifecycle.rs @@ -1,6 +1,6 @@ use std::{ffi::c_char, path::PathBuf}; -use sequencer_core::indexer_client::{IndexerClient, IndexerClientTrait}; +use sequencer_core::indexer_client::{IndexerClient, IndexerClientTrait as _}; use tokio::runtime::Runtime; use crate::{ diff --git a/indexer_ffi/src/api/mod.rs b/indexer_ffi/src/api/mod.rs index 4c80629b..ea2b91d7 100644 --- a/indexer_ffi/src/api/mod.rs +++ b/indexer_ffi/src/api/mod.rs @@ -1,7 +1,6 @@ pub use result::PointerResult; pub mod client; -pub mod convert; pub mod lifecycle; pub mod memory; pub mod query; diff --git a/indexer_ffi/src/api/query.rs b/indexer_ffi/src/api/query.rs index 510f1383..e840fed7 100644 --- a/indexer_ffi/src/api/query.rs +++ b/indexer_ffi/src/api/query.rs @@ -1,10 +1,10 @@ -use indexer_service_rpc::RpcClient; +use indexer_service_rpc::RpcClient as _; use crate::{ IndexerServiceFFI, api::{ PointerResult, - types::{Block, BlockId, BlockOpt}, + types::{FfiBlockId, block::FfiBlockOpt}, }, errors::OperationStatus, }; @@ -66,8 +66,8 @@ pub unsafe extern "C" fn query_last_block( #[unsafe(no_mangle)] pub unsafe extern "C" fn query_block( indexer: *const IndexerServiceFFI, - block_id: BlockId, -) -> PointerResult { + block_id: FfiBlockId, +) -> PointerResult { if indexer.is_null() { log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); return PointerResult::from_error(OperationStatus::NullPointer); @@ -82,6 +82,12 @@ pub unsafe extern "C" fn query_block( .block_on(client.get_block_by_id(block_id)) .map_or_else( |_| PointerResult::from_error(OperationStatus::ClientError), - |block| PointerResult::from_value(block.into()), + |block_opt| { + let block_ffi = block_opt.map_or_else(FfiBlockOpt::from_none, |block| { + FfiBlockOpt::from_value(block.into()) + }); + + PointerResult::from_value(block_ffi) + }, ) } diff --git a/indexer_ffi/src/api/types/account.rs b/indexer_ffi/src/api/types/account.rs index e36230ae..568bfa1c 100644 --- a/indexer_ffi/src/api/types/account.rs +++ b/indexer_ffi/src/api/types/account.rs @@ -2,7 +2,7 @@ use std::ptr; -use crate::api::types::FfiVec; +use indexer_service_protocol::ProgramId; /// 32-byte array type for `AccountId`, keys, hashes, etc. #[repr(C)] @@ -31,6 +31,12 @@ pub struct FfiProgramId { pub data: [u32; 8], } +impl From for FfiProgramId { + fn from(value: ProgramId) -> Self { + Self { data: value.0 } + } +} + /// U128 - 16 bytes little endian. #[repr(C)] #[derive(Clone, Copy, Default)] @@ -55,12 +61,6 @@ pub struct FfiAccount { pub nonce: FfiU128, } -#[repr(C)] -pub struct FfiAccountList { - pub entries: *const FfiAccount, - pub len: usize, -} - impl Default for FfiAccount { fn default() -> Self { Self { @@ -101,32 +101,6 @@ pub struct FfiPublicAccountKey { pub public_key: FfiBytes32, } -/// Single entry in the account list. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct FfiAccountIdListEntry { - pub account_id: FfiBytes32, - pub is_public: bool, -} - -/// List of accounts returned by `wallet_ffi_list_accounts`. -#[repr(C)] -pub struct FfiAccountIdList { - pub entries: *mut FfiAccountIdListEntry, - pub count: usize, -} - -impl Default for FfiAccountIdList { - fn default() -> Self { - Self { - entries: std::ptr::null_mut(), - count: 0, - } - } -} - -pub type FfiVecBytes32 = FfiVec; - // Helper functions to convert between Rust and FFI types impl FfiBytes32 { diff --git a/indexer_ffi/src/api/types/block.rs b/indexer_ffi/src/api/types/block.rs index a31bd495..1ee8215f 100644 --- a/indexer_ffi/src/api/types/block.rs +++ b/indexer_ffi/src/api/types/block.rs @@ -1,6 +1,7 @@ +use indexer_service_protocol::{BedrockStatus, Block, BlockHeader}; + use crate::api::types::{ - FfiBlockId, FfiHashType, FfiMsgId, FfiSignature, FfiTimestamp, FfiVec, - transaction::FfiTransaction, + FfiBlockId, FfiHashType, FfiMsgId, FfiOption, FfiSignature, FfiTimestamp, vectors::FfiBlockBody, }; #[repr(C)] @@ -11,13 +12,24 @@ pub struct FfiBlock { pub bedrock_parent_id: FfiMsgId, } -#[repr(C)] -pub struct FfiBlockOpt { - pub block: *const FfiBlock, - pub is_some: bool, +impl From for FfiBlock { + fn from(value: Block) -> Self { + Self { + header: value.header.into(), + body: value + .body + .transactions + .into_iter() + .map(Into::into) + .collect::>() + .into(), + bedrock_status: value.bedrock_status.into(), + bedrock_parent_id: value.bedrock_parent_id.into(), + } + } } -pub type FfiBlockBody = FfiVec; +pub type FfiBlockOpt = FfiOption; #[repr(C)] pub struct FfiBlockHeader { @@ -28,9 +40,31 @@ pub struct FfiBlockHeader { pub signature: FfiSignature, } +impl From for FfiBlockHeader { + fn from(value: BlockHeader) -> Self { + Self { + block_id: value.block_id, + prev_block_hash: value.prev_block_hash.into(), + hash: value.hash.into(), + timestamp: value.timestamp, + signature: value.signature.into(), + } + } +} + #[repr(C)] pub enum FfiBedrockStatus { Pending = 0x0, Safe, Finalized, } + +impl From for FfiBedrockStatus { + fn from(value: BedrockStatus) -> Self { + match value { + BedrockStatus::Finalized => Self::Finalized, + BedrockStatus::Pending => Self::Pending, + BedrockStatus::Safe => Self::Safe, + } + } +} diff --git a/indexer_ffi/src/api/types/mod.rs b/indexer_ffi/src/api/types/mod.rs index 73336c05..d1b3e572 100644 --- a/indexer_ffi/src/api/types/mod.rs +++ b/indexer_ffi/src/api/types/mod.rs @@ -1,8 +1,11 @@ +use indexer_service_protocol::{AccountId, HashType, MantleMsgId, PublicKey, Signature}; + use crate::api::types::account::{FfiBytes32, FfiBytes64, FfiU128}; pub mod account; pub mod block; pub mod transaction; +pub mod vectors; pub type FfiHashType = FfiBytes32; pub type FfiMsgId = FfiBytes32; @@ -13,17 +16,73 @@ pub type FfiAccountId = FfiBytes32; pub type FfiNonce = FfiU128; pub type FfiPublicKey = FfiBytes32; -#[repr(C)] -pub struct FfiVec { - pub entries: *const T, - pub len: usize, +impl From for FfiHashType { + fn from(value: HashType) -> Self { + Self { data: value.0 } + } } -impl Default for FfiVec { - fn default() -> Self { +impl From for FfiMsgId { + fn from(value: MantleMsgId) -> Self { + Self { data: value.0 } + } +} + +impl From for FfiSignature { + fn from(value: Signature) -> Self { + Self { data: value.0 } + } +} + +impl From for FfiAccountId { + fn from(value: AccountId) -> Self { + Self { data: value.value } + } +} + +impl From for FfiPublicKey { + fn from(value: PublicKey) -> Self { + Self { data: value.0 } + } +} + +#[repr(C)] +pub struct FfiVec { + pub entries: *mut T, + pub len: usize, + pub capacity: usize, +} + +impl From> for FfiVec { + fn from(value: Vec) -> Self { + let (entries, len, capacity) = value.into_raw_parts(); Self { - entries: std::ptr::null(), - len: 0, + entries, + len, + capacity, + } + } +} + +#[repr(C)] +pub struct FfiOption { + pub value: *mut T, + pub is_some: bool, +} + +impl FfiOption { + pub fn from_value(val: T) -> Self { + Self { + value: Box::into_raw(Box::new(val)), + is_some: true, + } + } + + #[must_use] + pub const fn from_none() -> Self { + Self { + value: std::ptr::null_mut(), + is_some: false, } } } diff --git a/indexer_ffi/src/api/types/transaction.rs b/indexer_ffi/src/api/types/transaction.rs index 4f92d492..86e28133 100644 --- a/indexer_ffi/src/api/types/transaction.rs +++ b/indexer_ffi/src/api/types/transaction.rs @@ -1,6 +1,17 @@ +use indexer_service_protocol::{ + CommitmentSetDigest, EncryptedAccountData, Nullifier, PrivacyPreservingMessage, + PrivacyPreservingTransaction, ProgramDeploymentTransaction, PublicKey, PublicMessage, + PublicTransaction, Signature, Transaction, ValidityWindow, +}; + use crate::api::types::{ - FfiHashType, FfiNonce, FfiPublicKey, FfiSignature, FfiVec, - account::{FfiAccountIdList, FfiAccountList, FfiBytes32, FfiProgramId, FfiVecBytes32}, + FfiHashType, FfiPublicKey, FfiSignature, + account::{FfiBytes32, FfiProgramId}, + vectors::{ + FfiAccountIdList, FfiAccountList, FfiEncryptedAccountDataList, FfiInstructionDataList, + FfiNonceList, FfiNullifierCommitmentSetList, FfiProgramDeploymentMessage, FfiProof, + FfiSignaturePubKeyList, FfiVecBytes32, FfiVecU8, + }, }; #[repr(C)] @@ -10,9 +21,21 @@ pub struct FfiPublicTransactionBody { pub witness_set: FfiSignaturePubKeyList, } -pub type FfiNonceList = FfiVec; - -pub type FfiInstructionDataList = FfiVec; +impl From for FfiPublicTransactionBody { + fn from(value: PublicTransaction) -> Self { + Self { + hash: value.hash.into(), + message: value.message.into(), + witness_set: value + .witness_set + .signatures_and_public_keys + .into_iter() + .map(Into::into) + .collect::>() + .into(), + } + } +} #[repr(C)] pub struct FfiPublicMessage { @@ -22,12 +45,55 @@ pub struct FfiPublicMessage { pub instruction_data: FfiInstructionDataList, } +impl From for FfiPublicMessage { + fn from(value: PublicMessage) -> Self { + Self { + program_id: value.program_id.into(), + account_ids: value + .account_ids + .into_iter() + .map(Into::into) + .collect::>() + .into(), + nonces: value + .nonces + .into_iter() + .map(Into::into) + .collect::>() + .into(), + instruction_data: value.instruction_data.into(), + } + } +} + #[repr(C)] pub struct FfiPrivateTransactionBody { pub hash: FfiHashType, pub message: FfiPrivacyPreservingMessage, pub witness_set: FfiSignaturePubKeyList, - pub proof: FfiProofOpt, + pub proof: FfiProof, +} + +impl From for FfiPrivateTransactionBody { + fn from(value: PrivacyPreservingTransaction) -> Self { + Self { + hash: value.hash.into(), + message: value.message.into(), + witness_set: value + .witness_set + .signatures_and_public_keys + .into_iter() + .map(Into::into) + .collect::>() + .into(), + proof: value + .witness_set + .proof + .expect("Private execution: proof must be present") + .0 + .into(), + } + } } #[repr(C)] @@ -35,41 +101,106 @@ pub struct FfiPrivacyPreservingMessage { pub public_account_ids: FfiAccountIdList, pub nonces: FfiNonceList, pub public_post_states: FfiAccountList, - pub encrypted_private_post_states: FfiVec, + pub encrypted_private_post_states: FfiEncryptedAccountDataList, pub new_commitments: FfiVecBytes32, - pub new_nullifiers: FfiVec, + pub new_nullifiers: FfiNullifierCommitmentSetList, pub block_validity_window: [u64; 2], pub timestamp_validity_window: [u64; 2], } +impl From for FfiPrivacyPreservingMessage { + fn from(value: PrivacyPreservingMessage) -> Self { + Self { + public_account_ids: value + .public_account_ids + .into_iter() + .map(Into::into) + .collect::>() + .into(), + nonces: value + .nonces + .into_iter() + .map(Into::into) + .collect::>() + .into(), + public_post_states: value + .public_post_states + .into_iter() + .map(|acc_ind| -> nssa::Account { + acc_ind.try_into().expect("Source is in blocks, must fit") + }) + .map(Into::into) + .collect::>() + .into(), + encrypted_private_post_states: value + .encrypted_private_post_states + .into_iter() + .map(Into::into) + .collect::>() + .into(), + new_commitments: value + .new_commitments + .into_iter() + .map(|comm| FfiBytes32 { data: comm.0 }) + .collect::>() + .into(), + new_nullifiers: value + .new_nullifiers + .into_iter() + .map(Into::into) + .collect::>() + .into(), + block_validity_window: cast_validity_window(value.block_validity_window), + timestamp_validity_window: cast_validity_window(value.timestamp_validity_window), + } + } +} + #[repr(C)] -pub struct NullifierCommitmentSet { +pub struct FfiNullifierCommitmentSet { pub nullifier: FfiBytes32, pub commitment_set_digest: FfiBytes32, } +impl From<(Nullifier, CommitmentSetDigest)> for FfiNullifierCommitmentSet { + fn from(value: (Nullifier, CommitmentSetDigest)) -> Self { + Self { + nullifier: FfiBytes32 { data: value.0.0 }, + commitment_set_digest: FfiBytes32 { data: value.1.0 }, + } + } +} + #[repr(C)] pub struct FfiEncryptedAccountData { - pub ciphertext: FfiVec, - pub epk: FfiVec, + pub ciphertext: FfiVecU8, + pub epk: FfiVecU8, pub view_tag: u8, } +impl From for FfiEncryptedAccountData { + fn from(value: EncryptedAccountData) -> Self { + Self { + ciphertext: value.ciphertext.0.into(), + epk: value.epk.0.into(), + view_tag: value.view_tag, + } + } +} + #[repr(C)] pub struct FfiSignaturePubKeyEntry { pub signature: FfiSignature, pub public_key: FfiPublicKey, } -pub struct FfiSignaturePubKeyList { - pub entries: *const FfiSignaturePubKeyEntry, - pub len: usize, -} - -#[repr(C)] -pub struct FfiProofOpt { - pub proof: FfiVec, - pub is_some: bool, +impl From<(Signature, PublicKey)> for FfiSignaturePubKeyEntry { + fn from(value: (Signature, PublicKey)) -> Self { + Self { + signature: value.0.into(), + public_key: value.1.into(), + } + } } #[repr(C)] @@ -78,13 +209,20 @@ pub struct FfiProgramDeploymentTransactionBody { pub message: FfiProgramDeploymentMessage, } -pub type FfiProgramDeploymentMessage = FfiVec; +impl From for FfiProgramDeploymentTransactionBody { + fn from(value: ProgramDeploymentTransaction) -> Self { + Self { + hash: value.hash.into(), + message: value.message.bytecode.into(), + } + } +} #[repr(C)] pub struct FfiTransactionBody { - pub public_body: *const FfiPublicTransactionBody, - pub private_body: *const FfiPrivateTransactionBody, - pub program_deployment_body: *const FfiProgramDeploymentTransactionBody, + pub public_body: *mut FfiPublicTransactionBody, + pub private_body: *mut FfiPrivateTransactionBody, + pub program_deployment_body: *mut FfiProgramDeploymentTransactionBody, } #[repr(C)] @@ -93,9 +231,47 @@ pub struct FfiTransaction { pub kind: FfiTransactionKind, } +impl From for FfiTransaction { + fn from(value: Transaction) -> Self { + match value { + Transaction::Public(pub_tx) => Self { + body: FfiTransactionBody { + public_body: Box::into_raw(Box::new(pub_tx.into())), + private_body: std::ptr::null_mut(), + program_deployment_body: std::ptr::null_mut(), + }, + kind: FfiTransactionKind::Public, + }, + Transaction::PrivacyPreserving(priv_tx) => Self { + body: FfiTransactionBody { + public_body: std::ptr::null_mut(), + private_body: Box::into_raw(Box::new(priv_tx.into())), + program_deployment_body: std::ptr::null_mut(), + }, + kind: FfiTransactionKind::Public, + }, + Transaction::ProgramDeployment(pr_dep_tx) => Self { + body: FfiTransactionBody { + public_body: std::ptr::null_mut(), + private_body: std::ptr::null_mut(), + program_deployment_body: Box::into_raw(Box::new(pr_dep_tx.into())), + }, + kind: FfiTransactionKind::Public, + }, + } + } +} + #[repr(C)] pub enum FfiTransactionKind { Public = 0x0, Private, ProgramDeploy, } + +fn cast_validity_window(window: ValidityWindow) -> [u64; 2] { + [ + window.0.0.unwrap_or_default(), + window.0.1.unwrap_or(u64::MAX), + ] +} diff --git a/indexer_ffi/src/api/types/vectors.rs b/indexer_ffi/src/api/types/vectors.rs new file mode 100644 index 00000000..7c8c2073 --- /dev/null +++ b/indexer_ffi/src/api/types/vectors.rs @@ -0,0 +1,31 @@ +use crate::api::types::{ + FfiAccountId, FfiNonce, FfiVec, + account::{FfiAccount, FfiBytes32}, + transaction::{ + FfiEncryptedAccountData, FfiNullifierCommitmentSet, FfiSignaturePubKeyEntry, FfiTransaction, + }, +}; + +pub type FfiVecU8 = FfiVec; + +pub type FfiAccountList = FfiVec; + +pub type FfiAccountIdList = FfiVec; + +pub type FfiVecBytes32 = FfiVec; + +pub type FfiBlockBody = FfiVec; + +pub type FfiNonceList = FfiVec; + +pub type FfiInstructionDataList = FfiVec; + +pub type FfiSignaturePubKeyList = FfiVec; + +pub type FfiProof = FfiVecU8; + +pub type FfiProgramDeploymentMessage = FfiVecU8; + +pub type FfiEncryptedAccountDataList = FfiVec; + +pub type FfiNullifierCommitmentSetList = FfiVec; diff --git a/integration_tests/src/test_context_ffi.rs b/integration_tests/src/test_context_ffi.rs index 1bda9e2c..cdf7db9a 100644 --- a/integration_tests/src/test_context_ffi.rs +++ b/integration_tests/src/test_context_ffi.rs @@ -271,7 +271,7 @@ impl BlockingTestContextFFI { #[must_use] pub const fn indexer_ffi(&self) -> *const IndexerServiceFFI { - &(self.indexer_ffi) + &raw const (self.indexer_ffi) } } diff --git a/integration_tests/tests/indexer_ffi.rs b/integration_tests/tests/indexer_ffi.rs index b75f1b2b..d0c643f7 100644 --- a/integration_tests/tests/indexer_ffi.rs +++ b/integration_tests/tests/indexer_ffi.rs @@ -1,6 +1,7 @@ #![expect( clippy::shadow_unrelated, clippy::tests_outside_test_module, + clippy::undocumented_unsafe_blocks, reason = "We don't care about these in tests" )] From a201fc646c85493e34a62ff11c86b52aa3598b75 Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Tue, 28 Apr 2026 19:42:58 +0300 Subject: [PATCH 06/25] fix: main merge --- common/src/test_utils.rs | 10 +++- indexer/core/src/block_store.rs | 9 +-- indexer_ffi/src/indexer.rs | 18 ++++-- storage/src/indexer/mod.rs | 102 +++++++++++++++++++------------- 4 files changed, 83 insertions(+), 56 deletions(-) diff --git a/common/src/test_utils.rs b/common/src/test_utils.rs index 720bd2f9..267d10ce 100644 --- a/common/src/test_utils.rs +++ b/common/src/test_utils.rs @@ -3,7 +3,7 @@ use nssa::AccountId; use crate::{ HashType, block::{Block, HashableBlockData}, - transaction::NSSATransaction, + transaction::{NSSATransaction, clock_invocation}, }; // Helpers @@ -15,7 +15,7 @@ pub fn sequencer_sign_key_for_testing() -> nssa::PrivateKey { // Dummy producers -/// Produce dummy block with. +/// Produce dummy block with provided transactions + clock transaction an the end. /// /// `id` - block id, provide zero for genesis. /// @@ -26,8 +26,12 @@ pub fn sequencer_sign_key_for_testing() -> nssa::PrivateKey { pub fn produce_dummy_block( id: u64, prev_hash: Option, - transactions: Vec, + mut transactions: Vec, ) -> Block { + transactions.push(NSSATransaction::Public(clock_invocation( + id.saturating_mul(100), + ))); + let block_data = HashableBlockData { block_id: id, prev_block_hash: prev_hash.unwrap_or_default(), diff --git a/indexer/core/src/block_store.rs b/indexer/core/src/block_store.rs index 611dec8d..cff07b0f 100644 --- a/indexer/core/src/block_store.rs +++ b/indexer/core/src/block_store.rs @@ -243,14 +243,9 @@ mod tests { &sign_key, ); let block_id = u64::try_from(i).unwrap(); - let block_timestamp = block_id.saturating_mul(100); - let clock_tx = NSSATransaction::Public(clock_invocation(block_timestamp)); - let next_block = common::test_utils::produce_dummy_block( - block_id, - Some(prev_hash), - vec![tx, clock_tx], - ); + let next_block = + common::test_utils::produce_dummy_block(block_id, Some(prev_hash), vec![tx]); prev_hash = next_block.header.hash; storage diff --git a/indexer_ffi/src/indexer.rs b/indexer_ffi/src/indexer.rs index 1bc6fb3b..102900f6 100644 --- a/indexer_ffi/src/indexer.rs +++ b/indexer_ffi/src/indexer.rs @@ -109,13 +109,23 @@ impl IndexerServiceFFI { // Implement Drop to prevent memory leaks impl Drop for IndexerServiceFFI { fn drop(&mut self) { - if self.indexer_handle.is_null() { + let Self { + indexer_handle, + runtime, + indexer_client, + } = self; + + if indexer_handle.is_null() { log::error!("Attempted to drop a null indexer pointer. This is a bug"); } - if self.runtime.is_null() { + if runtime.is_null() { log::error!("Attempted to drop a null tokio runtime pointer. This is a bug"); } - drop(unsafe { Box::from_raw(self.indexer_handle.cast::()) }); - drop(unsafe { Box::from_raw(self.runtime.cast::()) }); + if indexer_client.is_null() { + log::error!("Attempted to drop a null client pointer. This is a bug"); + } + drop(unsafe { Box::from_raw(indexer_handle.cast::()) }); + drop(unsafe { Box::from_raw(runtime.cast::()) }); + drop(unsafe { Box::from_raw(indexer_client.cast::()) }); } } diff --git a/storage/src/indexer/mod.rs b/storage/src/indexer/mod.rs index 85f2a278..7ef21258 100644 --- a/storage/src/indexer/mod.rs +++ b/storage/src/indexer/mod.rs @@ -1,6 +1,9 @@ use std::{path::Path, sync::Arc}; -use common::block::Block; +use common::{ + block::Block, + transaction::{NSSATransaction, clock_invocation}, +}; use nssa::V03State; use rocksdb::{ BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options, @@ -169,22 +172,52 @@ impl RocksDBIO { for block in self.get_block_batch_seq( start.checked_add(1).expect("Will be lesser that u64::MAX")..=block_id, )? { - for transaction in block.body.transactions { - transaction - .transaction_stateless_check() - .map_err(|err| { - DbError::db_interaction_error(format!( - "transaction pre check failed with err {err:?}" - )) - })? - .execute_check_on_state( - &mut breakpoint, + let expected_clock = + NSSATransaction::Public(clock_invocation(block.header.timestamp)); + + if let Some((clock_tx, user_txs)) = block.body.transactions.split_last() { + if *clock_tx != expected_clock { + return Err(DbError::db_interaction_error( + "Last transaction in block must be the clock invocation for the block timestamp" + .to_owned(), + )); + } + for transaction in user_txs { + transaction + .clone() + .transaction_stateless_check() + .map_err(|err| { + DbError::db_interaction_error(format!( + "transaction pre check failed with err {err:?}" + )) + })? + .execute_check_on_state( + &mut breakpoint, + block.header.block_id, + block.header.timestamp, + ) + .map_err(|err| { + DbError::db_interaction_error(format!( + "transaction execution failed with err {err:?}" + )) + })?; + } + + let NSSATransaction::Public(clock_public_tx) = clock_tx else { + return Err(DbError::db_interaction_error( + "Clock invocation must be a public transaction".to_owned(), + )); + }; + + breakpoint + .transition_from_public_transaction( + clock_public_tx, block.header.block_id, block.header.timestamp, ) .map_err(|err| { DbError::db_interaction_error(format!( - "transaction execution failed with err {err:?}" + "clock transaction execution failed with err {err:?}" )) })?; } @@ -213,6 +246,7 @@ fn closest_breakpoint_id(block_id: u64) -> u64 { #[expect(clippy::shadow_unrelated, reason = "Fine for tests")] #[cfg(test)] mod tests { + use common::test_utils::produce_dummy_block; use nssa::{AccountId, PublicKey}; use tempfile::tempdir; @@ -302,7 +336,7 @@ mod tests { let transfer_tx = common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); - let block = common::test_utils::produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); dbio.put_block(&block, [1; 32]).unwrap(); @@ -369,11 +403,7 @@ mod tests { 1, &sign_key, ); - let block = common::test_utils::produce_dummy_block( - (i + 1).into(), - Some(prev_hash), - vec![transfer_tx], - ); + let block = produce_dummy_block((i + 1).into(), Some(prev_hash), vec![transfer_tx]); dbio.put_block(&block, [i; 32]).unwrap(); } @@ -439,7 +469,7 @@ mod tests { let prev_hash = last_block.header.hash; let transfer_tx = common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); - let block = common::test_utils::produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); let control_hash1 = block.header.hash; @@ -451,7 +481,7 @@ mod tests { let prev_hash = last_block.header.hash; let transfer_tx = common::test_utils::create_transaction_native_token_transfer(from, 1, to, 1, &sign_key); - let block = common::test_utils::produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); let control_hash2 = block.header.hash; @@ -466,7 +496,7 @@ mod tests { let control_tx_hash1 = transfer_tx.hash(); - let block = common::test_utils::produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); dbio.put_block(&block, [3; 32]).unwrap(); let last_id = dbio.get_meta_last_block_in_db().unwrap(); @@ -478,7 +508,7 @@ mod tests { let control_tx_hash2 = transfer_tx.hash(); - let block = common::test_utils::produce_dummy_block(5, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(5, Some(prev_hash), vec![transfer_tx]); dbio.put_block(&block, [4; 32]).unwrap(); let control_block_id1 = dbio.get_block_id_by_hash(control_hash1.0).unwrap().unwrap(); @@ -526,7 +556,7 @@ mod tests { let prev_hash = last_block.header.hash; let transfer_tx = common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); - let block = common::test_utils::produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); block_res.push(block.clone()); dbio.put_block(&block, [1; 32]).unwrap(); @@ -537,7 +567,7 @@ mod tests { let prev_hash = last_block.header.hash; let transfer_tx = common::test_utils::create_transaction_native_token_transfer(from, 1, to, 1, &sign_key); - let block = common::test_utils::produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); block_res.push(block.clone()); dbio.put_block(&block, [2; 32]).unwrap(); @@ -549,7 +579,7 @@ mod tests { let transfer_tx = common::test_utils::create_transaction_native_token_transfer(from, 2, to, 1, &sign_key); - let block = common::test_utils::produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); block_res.push(block.clone()); dbio.put_block(&block, [3; 32]).unwrap(); @@ -560,7 +590,7 @@ mod tests { let transfer_tx = common::test_utils::create_transaction_native_token_transfer(from, 3, to, 1, &sign_key); - let block = common::test_utils::produce_dummy_block(5, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(5, Some(prev_hash), vec![transfer_tx]); block_res.push(block.clone()); dbio.put_block(&block, [4; 32]).unwrap(); @@ -633,11 +663,7 @@ mod tests { tx_hash_res.push(transfer_tx1.hash().0); tx_hash_res.push(transfer_tx2.hash().0); - let block = common::test_utils::produce_dummy_block( - 2, - Some(prev_hash), - vec![transfer_tx1, transfer_tx2], - ); + let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx1, transfer_tx2]); dbio.put_block(&block, [1; 32]).unwrap(); @@ -652,11 +678,7 @@ mod tests { tx_hash_res.push(transfer_tx1.hash().0); tx_hash_res.push(transfer_tx2.hash().0); - let block = common::test_utils::produce_dummy_block( - 3, - Some(prev_hash), - vec![transfer_tx1, transfer_tx2], - ); + let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx1, transfer_tx2]); dbio.put_block(&block, [2; 32]).unwrap(); @@ -671,11 +693,7 @@ mod tests { tx_hash_res.push(transfer_tx1.hash().0); tx_hash_res.push(transfer_tx2.hash().0); - let block = common::test_utils::produce_dummy_block( - 4, - Some(prev_hash), - vec![transfer_tx1, transfer_tx2], - ); + let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx1, transfer_tx2]); dbio.put_block(&block, [3; 32]).unwrap(); @@ -687,7 +705,7 @@ mod tests { common::test_utils::create_transaction_native_token_transfer(from, 6, to, 1, &sign_key); tx_hash_res.push(transfer_tx.hash().0); - let block = common::test_utils::produce_dummy_block(5, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(5, Some(prev_hash), vec![transfer_tx]); dbio.put_block(&block, [4; 32]).unwrap(); From 113a68c22c3c4cf3ed3026dba64060de277ae286 Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Wed, 29 Apr 2026 13:55:44 +0300 Subject: [PATCH 07/25] fix: correct free --- Cargo.lock | 14 +- indexer_ffi/indexer_ffi.h | 315 ++++++++++++++++++++++- indexer_ffi/src/api/query.rs | 263 ++++++++++++++++++- indexer_ffi/src/api/types/account.rs | 156 +++-------- indexer_ffi/src/api/types/block.rs | 136 +++++++++- indexer_ffi/src/api/types/mod.rs | 72 +++++- indexer_ffi/src/api/types/transaction.rs | 254 +++++++++++++++++- indexer_ffi/src/api/types/vectors.rs | 4 +- 8 files changed, 1051 insertions(+), 163 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37ab61e0..27831459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -629,9 +629,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "astral-tokio-tar" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9" +checksum = "4ce73b17c62717c4b6a9af10b43e87c578b0cac27e00666d48304d3b7d2c0693" dependencies = [ "filetime", "futures-core", @@ -2108,7 +2108,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2409,7 +2409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7090,7 +7090,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8023,7 +8023,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9306,7 +9306,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/indexer_ffi/indexer_ffi.h b/indexer_ffi/indexer_ffi.h index 1094e1de..7626b3b3 100644 --- a/indexer_ffi/indexer_ffi.h +++ b/indexer_ffi/indexer_ffi.h @@ -167,11 +167,15 @@ typedef struct FfiAccount { /** * Pointer to account data bytes. */ - const uint8_t *data; + uint8_t *data; /** * Length of account data. */ uintptr_t data_len; + /** + * Capacity of account data. + */ + uintptr_t data_cap; /** * Nonce as little-endian [u8; 16]. */ @@ -302,6 +306,66 @@ typedef struct PointerResult_FfiBlockOpt__OperationStatus { enum OperationStatus error; } PointerResult_FfiBlockOpt__OperationStatus; +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_FfiAccount__OperationStatus { + struct FfiAccount *value; + enum OperationStatus error; +} PointerResult_FfiAccount__OperationStatus; + +typedef struct FfiOption_FfiTransaction { + struct FfiTransaction *value; + bool is_some; +} FfiOption_FfiTransaction; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_FfiOption_FfiTransaction_____OperationStatus { + struct FfiOption_FfiTransaction *value; + enum OperationStatus error; +} PointerResult_FfiOption_FfiTransaction_____OperationStatus; + +typedef struct FfiVec_FfiBlock { + struct FfiBlock *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiBlock; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_FfiVec_FfiBlock_____OperationStatus { + struct FfiVec_FfiBlock *value; + enum OperationStatus error; +} PointerResult_FfiVec_FfiBlock_____OperationStatus; + +typedef struct FfiOption_u64 { + uint64_t *value; + bool is_some; +} FfiOption_u64; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_FfiVec_FfiTransaction_____OperationStatus { + struct FfiVec_FfiTransaction *value; + enum OperationStatus error; +} PointerResult_FfiVec_FfiTransaction_____OperationStatus; + /** * Creates and starts an indexer based on the provided * configuration file path. @@ -347,46 +411,275 @@ enum OperationStatus stop_indexer(struct IndexerServiceFFI *indexer); void free_cstring(char *block); /** - * Stops and frees the resources associated with the given indexer service. + * Query the last block id from indexer. * * # Arguments * - * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. * * # Returns * - * An `OperationStatus` indicating success or failure. + * A `PointerResult` indicating success or failure. * * # Safety * * The caller must ensure that: * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance - * - The `IndexerServiceFFI` instance was created by this library - * - The pointer will not be used after this function returns */ struct PointerResult_u64__OperationStatus query_last_block(const struct IndexerServiceFFI *indexer); /** - * Stops and frees the resources associated with the given indexer service. + * Query the block by id from indexer. * * # Arguments * - * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. + * - `block_id`: `u64` number of block id * * # Returns * - * An `OperationStatus` indicating success or failure. + * A `PointerResult` indicating success or failure. * * # Safety * * The caller must ensure that: * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance - * - The `IndexerServiceFFI` instance was created by this library - * - The pointer will not be used after this function returns */ struct PointerResult_FfiBlockOpt__OperationStatus query_block(const struct IndexerServiceFFI *indexer, FfiBlockId block_id); +/** + * Query the block by id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. + * - `hash`: `FfiHashType` - hash of block + * + * # Returns + * + * A `PointerResult` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance + */ +struct PointerResult_FfiBlockOpt__OperationStatus query_block_by_hash(const struct IndexerServiceFFI *indexer, + FfiHashType hash); + +/** + * Query the account by id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. + * - `account_id`: `FfiAccountId` - id of queried account + * + * # Returns + * + * A `PointerResult` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance + */ +struct PointerResult_FfiAccount__OperationStatus query_account(const struct IndexerServiceFFI *indexer, + FfiAccountId account_id); + +/** + * Query the trasnaction by hash from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. + * - `hash`: `FfiHashType` - hash of transaction + * + * # Returns + * + * A `PointerResult, OperationStatus>` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance + */ +struct PointerResult_FfiOption_FfiTransaction_____OperationStatus query_transaction(const struct IndexerServiceFFI *indexer, + FfiHashType hash); + +/** + * Query the blocks by block range from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. + * - `before`: `FfiOption` - end block of query + * - `limit`: `u64` - number of blocks to query before `before` + * + * # Returns + * + * A `PointerResult, OperationStatus>` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance + */ +struct PointerResult_FfiVec_FfiBlock_____OperationStatus query_block_vec(const struct IndexerServiceFFI *indexer, + struct FfiOption_u64 before, + uint64_t limit); + +/** + * Query the transactions range by account id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. + * - `account_id`: `FfiAccountId` - id of queried account + * - `offset`: `u64` - first tx id of query + * - `limit`: `u64` - number of tx ids to query after `offset` + * + * # Returns + * + * A `PointerResult, OperationStatus>` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance + */ +struct PointerResult_FfiVec_FfiTransaction_____OperationStatus query_transactions_by_account(const struct IndexerServiceFFI *indexer, + FfiAccountId account_id, + uint64_t offset, + uint64_t limit); + +/** + * Frees the resources associated with the given ffi account. + * + * # Arguments + * + * - `val`: An instance of `FfiAccount`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiAccount`. + */ +void free_ffi_account(struct FfiAccount val); + +/** + * Frees the resources associated with the given ffi block. + * + * # Arguments + * + * - `val`: An instance of `FfiBlock`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiBlock`. + */ +void free_ffi_block(struct FfiBlock val); + +/** + * Frees the resources associated with the given ffi block option. + * + * # Arguments + * + * - `val`: An instance of `FfiBlockOpt`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiBlockOpt`. + */ +void free_ffi_block_opt(FfiBlockOpt val); + +/** + * Frees the resources associated with the given ffi block vector. + * + * # Arguments + * + * - `val`: An instance of `FfiVec`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiVec`. + */ +void free_ffi_block_vec(struct FfiVec_FfiBlock val); + +/** + * Frees the resources associated with the given ffi transaction. + * + * # Arguments + * + * - `val`: An instance of `FfiTransaction`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiTransaction`. + */ +void free_ffi_transaction(struct FfiTransaction val); + +/** + * Frees the resources associated with the given ffi transaction option. + * + * # Arguments + * + * - `val`: An instance of `FfiOption`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiOption`. + */ +void free_ffi_transaction_opt(struct FfiOption_FfiTransaction val); + +/** + * Frees the resources associated with the given vector of ffi transactions. + * + * # Arguments + * + * - `val`: An instance of `FfiVec`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiVec`. + */ +void free_ffi_transaction_vec(struct FfiVec_FfiTransaction val); + bool is_ok(const enum OperationStatus *self); bool is_error(const enum OperationStatus *self); diff --git a/indexer_ffi/src/api/query.rs b/indexer_ffi/src/api/query.rs index e840fed7..1e39d961 100644 --- a/indexer_ffi/src/api/query.rs +++ b/indexer_ffi/src/api/query.rs @@ -1,30 +1,34 @@ +use indexer_service_protocol::{AccountId, HashType}; use indexer_service_rpc::RpcClient as _; use crate::{ IndexerServiceFFI, api::{ PointerResult, - types::{FfiBlockId, block::FfiBlockOpt}, + types::{ + FfiAccountId, FfiBlockId, FfiHashType, FfiOption, FfiVec, + account::FfiAccount, + block::{FfiBlock, FfiBlockOpt}, + transaction::FfiTransaction, + }, }, errors::OperationStatus, }; -/// Stops and frees the resources associated with the given indexer service. +/// Query the last block id from indexer. /// /// # Arguments /// -/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. /// /// # Returns /// -/// An `OperationStatus` indicating success or failure. +/// A `PointerResult` indicating success or failure. /// /// # Safety /// /// The caller must ensure that: /// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance -/// - The `IndexerServiceFFI` instance was created by this library -/// - The pointer will not be used after this function returns #[unsafe(no_mangle)] pub unsafe extern "C" fn query_last_block( indexer: *const IndexerServiceFFI, @@ -47,22 +51,21 @@ pub unsafe extern "C" fn query_last_block( ) } -/// Stops and frees the resources associated with the given indexer service. +/// Query the block by id from indexer. /// /// # Arguments /// -/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. +/// - `block_id`: `u64` number of block id /// /// # Returns /// -/// An `OperationStatus` indicating success or failure. +/// A `PointerResult` indicating success or failure. /// /// # Safety /// /// The caller must ensure that: /// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance -/// - The `IndexerServiceFFI` instance was created by this library -/// - The pointer will not be used after this function returns #[unsafe(no_mangle)] pub unsafe extern "C" fn query_block( indexer: *const IndexerServiceFFI, @@ -91,3 +94,241 @@ pub unsafe extern "C" fn query_block( }, ) } + +/// Query the block by id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. +/// - `hash`: `FfiHashType` - hash of block +/// +/// # Returns +/// +/// A `PointerResult` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_block_by_hash( + indexer: *const IndexerServiceFFI, + hash: FfiHashType, +) -> PointerResult { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = unsafe { indexer.client() }; + let runtime = unsafe { indexer.runtime() }; + + runtime + .block_on(client.get_block_by_hash(HashType(hash.data))) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |block_opt| { + let block_ffi = block_opt.map_or_else(FfiBlockOpt::from_none, |block| { + FfiBlockOpt::from_value(block.into()) + }); + + PointerResult::from_value(block_ffi) + }, + ) +} + +/// Query the account by id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. +/// - `account_id`: `FfiAccountId` - id of queried account +/// +/// # Returns +/// +/// A `PointerResult` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_account( + indexer: *const IndexerServiceFFI, + account_id: FfiAccountId, +) -> PointerResult { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = unsafe { indexer.client() }; + let runtime = unsafe { indexer.runtime() }; + + runtime + .block_on(client.get_account(AccountId { + value: account_id.data, + })) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |acc| { + let acc_nssa: nssa::Account = + acc.try_into().expect("Source is in blocks, must fit"); + PointerResult::from_value(acc_nssa.into()) + }, + ) +} + +/// Query the trasnaction by hash from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. +/// - `hash`: `FfiHashType` - hash of transaction +/// +/// # Returns +/// +/// A `PointerResult, OperationStatus>` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_transaction( + indexer: *const IndexerServiceFFI, + hash: FfiHashType, +) -> PointerResult, OperationStatus> { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = unsafe { indexer.client() }; + let runtime = unsafe { indexer.runtime() }; + + runtime + .block_on(client.get_transaction(HashType(hash.data))) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |tx_opt| { + let tx_ffi = tx_opt.map_or_else(FfiOption::::from_none, |tx| { + FfiOption::::from_value(tx.into()) + }); + + PointerResult::from_value(tx_ffi) + }, + ) +} + +/// Query the blocks by block range from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. +/// - `before`: `FfiOption` - end block of query +/// - `limit`: `u64` - number of blocks to query before `before` +/// +/// # Returns +/// +/// A `PointerResult, OperationStatus>` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_block_vec( + indexer: *const IndexerServiceFFI, + before: FfiOption, + limit: u64, +) -> PointerResult, OperationStatus> { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = unsafe { indexer.client() }; + let runtime = unsafe { indexer.runtime() }; + + let before_std = before.is_some.then(|| unsafe { *before.value }); + + runtime + .block_on(client.get_blocks(before_std, limit)) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |block_vec| { + PointerResult::from_value( + block_vec + .into_iter() + .map(Into::into) + .collect::>() + .into(), + ) + }, + ) +} + +/// Query the transactions range by account id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. +/// - `account_id`: `FfiAccountId` - id of queried account +/// - `offset`: `u64` - first tx id of query +/// - `limit`: `u64` - number of tx ids to query after `offset` +/// +/// # Returns +/// +/// A `PointerResult, OperationStatus>` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_transactions_by_account( + indexer: *const IndexerServiceFFI, + account_id: FfiAccountId, + offset: u64, + limit: u64, +) -> PointerResult, OperationStatus> { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = unsafe { indexer.client() }; + let runtime = unsafe { indexer.runtime() }; + + runtime + .block_on(client.get_transactions_by_account( + AccountId { + value: account_id.data, + }, + offset, + limit, + )) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |tx_vec| { + PointerResult::from_value( + tx_vec + .into_iter() + .map(Into::into) + .collect::>() + .into(), + ) + }, + ) +} diff --git a/indexer_ffi/src/api/types/account.rs b/indexer_ffi/src/api/types/account.rs index 568bfa1c..853e44d4 100644 --- a/indexer_ffi/src/api/types/account.rs +++ b/indexer_ffi/src/api/types/account.rs @@ -1,48 +1,6 @@ -//! C-compatible type definitions for the FFI layer. - -use std::ptr; - use indexer_service_protocol::ProgramId; -/// 32-byte array type for `AccountId`, keys, hashes, etc. -#[repr(C)] -#[derive(Clone, Copy, Default)] -pub struct FfiBytes32 { - pub data: [u8; 32], -} - -/// 64-byte array type for signatures, etc. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct FfiBytes64 { - pub data: [u8; 64], -} - -impl Default for FfiBytes64 { - fn default() -> Self { - Self { data: [0; 64] } - } -} - -/// Program ID - 8 u32 values (32 bytes total). -#[repr(C)] -#[derive(Clone, Copy, Default)] -pub struct FfiProgramId { - pub data: [u32; 8], -} - -impl From for FfiProgramId { - fn from(value: ProgramId) -> Self { - Self { data: value.0 } - } -} - -/// U128 - 16 bytes little endian. -#[repr(C)] -#[derive(Clone, Copy, Default)] -pub struct FfiU128 { - pub data: [u8; 16], -} +use crate::api::types::{FfiBytes32, FfiProgramId, FfiU128}; /// Account data structure - C-compatible version of nssa Account. /// @@ -54,77 +12,17 @@ pub struct FfiAccount { /// Balance as little-endian [u8; 16]. pub balance: FfiU128, /// Pointer to account data bytes. - pub data: *const u8, + pub data: *mut u8, /// Length of account data. pub data_len: usize, + /// Capacity of account data. + pub data_cap: usize, /// Nonce as little-endian [u8; 16]. pub nonce: FfiU128, } -impl Default for FfiAccount { - fn default() -> Self { - Self { - program_owner: FfiProgramId::default(), - balance: FfiU128::default(), - data: std::ptr::null(), - data_len: 0, - nonce: FfiU128::default(), - } - } -} - -/// Public keys for a private account (safe to expose). -#[repr(C)] -pub struct FfiPrivateAccountKeys { - /// Nullifier public key (32 bytes). - pub nullifier_public_key: FfiBytes32, - /// viewing public key (compressed secp256k1 point). - pub viewing_public_key: *const u8, - /// Length of viewing public key (typically 33 bytes). - pub viewing_public_key_len: usize, -} - -impl Default for FfiPrivateAccountKeys { - fn default() -> Self { - Self { - nullifier_public_key: FfiBytes32::default(), - viewing_public_key: std::ptr::null(), - viewing_public_key_len: 0, - } - } -} - -/// Public key info for a public account. -#[repr(C)] -#[derive(Clone, Copy, Default)] -pub struct FfiPublicAccountKey { - pub public_key: FfiBytes32, -} - // Helper functions to convert between Rust and FFI types -impl FfiBytes32 { - /// Create from a 32-byte array. - #[must_use] - pub const fn from_bytes(bytes: [u8; 32]) -> Self { - Self { data: bytes } - } - - /// Create from an `AccountId`. - #[must_use] - pub const fn from_account_id(id: &nssa::AccountId) -> Self { - Self { data: *id.value() } - } -} - -impl From for FfiU128 { - fn from(value: u128) -> Self { - Self { - data: value.to_le_bytes(), - } - } -} - impl From<&nssa::AccountId> for FfiBytes32 { fn from(id: &nssa::AccountId) -> Self { Self::from_account_id(id) @@ -132,20 +30,8 @@ impl From<&nssa::AccountId> for FfiBytes32 { } impl From for FfiAccount { - #[expect( - clippy::as_conversions, - reason = "We need to convert to byte arrays for FFI" - )] fn from(value: nssa::Account) -> Self { - // Convert account data to FFI type - let data_vec: Vec = value.data.into(); - let data_len = data_vec.len(); - let data = if data_len > 0 { - let data_boxed = data_vec.into_boxed_slice(); - Box::into_raw(data_boxed) as *const u8 - } else { - ptr::null() - }; + let (data, data_len, data_cap) = value.data.into_inner().into_raw_parts(); let program_owner = FfiProgramId { data: value.program_owner, @@ -155,15 +41,41 @@ impl From for FfiAccount { balance: value.balance.into(), data, data_len, + data_cap, nonce: value.nonce.0.into(), } } } -impl From for FfiPublicAccountKey { - fn from(value: nssa::PublicKey) -> Self { +impl From for indexer_service_protocol::Account { + fn from(value: FfiAccount) -> Self { Self { - public_key: FfiBytes32::from_bytes(*value.value()), + program_owner: ProgramId(value.program_owner.data), + balance: value.balance.into(), + data: indexer_service_protocol::Data(unsafe { + Vec::from_raw_parts(value.data, value.data_len, value.data_cap) + }), + nonce: value.nonce.into(), } } } + +/// Frees the resources associated with the given ffi account. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiAccount`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiAccount`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_account(val: FfiAccount) { + let orig_val: indexer_service_protocol::Account = val.into(); + drop(orig_val); +} diff --git a/indexer_ffi/src/api/types/block.rs b/indexer_ffi/src/api/types/block.rs index 1ee8215f..f7e0c778 100644 --- a/indexer_ffi/src/api/types/block.rs +++ b/indexer_ffi/src/api/types/block.rs @@ -1,7 +1,10 @@ -use indexer_service_protocol::{BedrockStatus, Block, BlockHeader}; +use indexer_service_protocol::{ + BedrockStatus, Block, BlockHeader, HashType, MantleMsgId, Signature, +}; use crate::api::types::{ - FfiBlockId, FfiHashType, FfiMsgId, FfiOption, FfiSignature, FfiTimestamp, vectors::FfiBlockBody, + FfiBlockId, FfiHashType, FfiMsgId, FfiOption, FfiSignature, FfiTimestamp, FfiVec, + transaction::free_ffi_transaction_vec, vectors::FfiBlockBody, }; #[repr(C)] @@ -29,6 +32,23 @@ impl From for FfiBlock { } } +// impl From> for Block { +// fn from(value: Box) -> Self { +// Self { +// header: BlockHeader { +// block_id: value.header.block_id, +// prev_block_hash: HashType(value.header.prev_block_hash.data), +// hash: HashType(value.header.hash.data), +// timestamp: value.header.timestamp, +// signature: Signature(value.header.signature.data), +// }, +// body: (), +// bedrock_status: value.bedrock_status.into(), +// bedrock_parent_id: MantleMsgId(value.bedrock_parent_id.data), +// } +// } +// } + pub type FfiBlockOpt = FfiOption; #[repr(C)] @@ -68,3 +88,115 @@ impl From for FfiBedrockStatus { } } } + +impl From for BedrockStatus { + fn from(value: FfiBedrockStatus) -> Self { + match value { + FfiBedrockStatus::Finalized => Self::Finalized, + FfiBedrockStatus::Pending => Self::Pending, + FfiBedrockStatus::Safe => Self::Safe, + } + } +} + +/// Frees the resources associated with the given ffi block. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiBlock`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiBlock`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_block(val: FfiBlock) { + // We don't really need all the casts, but just in case + // All except `ffi_tx_ffi_vec` is Copy types, so no need for Drop + let _ = BlockHeader { + block_id: val.header.block_id, + prev_block_hash: HashType(val.header.prev_block_hash.data), + hash: HashType(val.header.hash.data), + timestamp: val.header.timestamp, + signature: Signature(val.header.signature.data), + }; + let ffi_tx_ffi_vec = val.body; + + #[expect(clippy::let_underscore_must_use, reason = "No use for this Copy type")] + let _: BedrockStatus = val.bedrock_status.into(); + + let _ = MantleMsgId(val.bedrock_parent_id.data); + + unsafe { + free_ffi_transaction_vec(ffi_tx_ffi_vec); + }; +} + +/// Frees the resources associated with the given ffi block option. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiBlockOpt`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiBlockOpt`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_block_opt(val: FfiBlockOpt) { + if val.is_some { + let value = unsafe { Box::from_raw(val.value) }; + + // We don't really need all the casts, but just in case + // All except `ffi_tx_ffi_vec` is Copy types, so no need for Drop + let _ = BlockHeader { + block_id: value.header.block_id, + prev_block_hash: HashType(value.header.prev_block_hash.data), + hash: HashType(value.header.hash.data), + timestamp: value.header.timestamp, + signature: Signature(value.header.signature.data), + }; + let ffi_tx_ffi_vec = value.body; + + #[expect(clippy::let_underscore_must_use, reason = "No use for this Copy type")] + let _: BedrockStatus = value.bedrock_status.into(); + + let _ = MantleMsgId(value.bedrock_parent_id.data); + + unsafe { + free_ffi_transaction_vec(ffi_tx_ffi_vec); + }; + } +} + +/// Frees the resources associated with the given ffi block vector. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiVec`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiVec`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_block_vec(val: FfiVec) { + let ffi_block_std_vec: Vec<_> = val.into(); + for block in ffi_block_std_vec { + unsafe { + free_ffi_block(block); + } + } +} diff --git a/indexer_ffi/src/api/types/mod.rs b/indexer_ffi/src/api/types/mod.rs index d1b3e572..22b44c88 100644 --- a/indexer_ffi/src/api/types/mod.rs +++ b/indexer_ffi/src/api/types/mod.rs @@ -1,12 +1,72 @@ -use indexer_service_protocol::{AccountId, HashType, MantleMsgId, PublicKey, Signature}; - -use crate::api::types::account::{FfiBytes32, FfiBytes64, FfiU128}; +use indexer_service_protocol::{AccountId, HashType, MantleMsgId, ProgramId, PublicKey, Signature}; pub mod account; pub mod block; pub mod transaction; pub mod vectors; +/// 32-byte array type for `AccountId`, keys, hashes, etc. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiBytes32 { + pub data: [u8; 32], +} + +/// 64-byte array type for signatures, etc. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct FfiBytes64 { + pub data: [u8; 64], +} + +/// Program ID - 8 u32 values (32 bytes total). +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiProgramId { + pub data: [u32; 8], +} + +impl From for FfiProgramId { + fn from(value: ProgramId) -> Self { + Self { data: value.0 } + } +} + +/// U128 - 16 bytes little endian. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiU128 { + pub data: [u8; 16], +} + +impl FfiBytes32 { + /// Create from a 32-byte array. + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self { data: bytes } + } + + /// Create from an `AccountId`. + #[must_use] + pub const fn from_account_id(id: &nssa::AccountId) -> Self { + Self { data: *id.value() } + } +} + +impl From for FfiU128 { + fn from(value: u128) -> Self { + Self { + data: value.to_le_bytes(), + } + } +} + +impl From for u128 { + fn from(value: FfiU128) -> Self { + Self::from_le_bytes(value.data) + } +} + pub type FfiHashType = FfiBytes32; pub type FfiMsgId = FfiBytes32; pub type FfiBlockId = u64; @@ -64,6 +124,12 @@ impl From> for FfiVec { } } +impl From> for Vec { + fn from(value: FfiVec) -> Self { + unsafe { Self::from_raw_parts(value.entries, value.len, value.capacity) } + } +} + #[repr(C)] pub struct FfiOption { pub value: *mut T, diff --git a/indexer_ffi/src/api/types/transaction.rs b/indexer_ffi/src/api/types/transaction.rs index 86e28133..ff14276f 100644 --- a/indexer_ffi/src/api/types/transaction.rs +++ b/indexer_ffi/src/api/types/transaction.rs @@ -1,12 +1,13 @@ use indexer_service_protocol::{ - CommitmentSetDigest, EncryptedAccountData, Nullifier, PrivacyPreservingMessage, - PrivacyPreservingTransaction, ProgramDeploymentTransaction, PublicKey, PublicMessage, - PublicTransaction, Signature, Transaction, ValidityWindow, + AccountId, Ciphertext, Commitment, CommitmentSetDigest, EncryptedAccountData, + EphemeralPublicKey, HashType, Nullifier, PrivacyPreservingMessage, + PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction, + ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction, Signature, Transaction, + ValidityWindow, WitnessSet, }; use crate::api::types::{ - FfiHashType, FfiPublicKey, FfiSignature, - account::{FfiBytes32, FfiProgramId}, + FfiBytes32, FfiHashType, FfiOption, FfiProgramId, FfiPublicKey, FfiSignature, FfiVec, vectors::{ FfiAccountIdList, FfiAccountList, FfiEncryptedAccountDataList, FfiInstructionDataList, FfiNonceList, FfiNullifierCommitmentSetList, FfiProgramDeploymentMessage, FfiProof, @@ -37,6 +38,46 @@ impl From for FfiPublicTransactionBody { } } +impl From> for PublicTransaction { + fn from(value: Box) -> Self { + Self { + hash: HashType(value.hash.data), + message: PublicMessage { + program_id: ProgramId(value.message.program_id.data), + account_ids: { + let std_vec: Vec<_> = value.message.account_ids.into(); + std_vec + .into_iter() + .map(|ffi_val| AccountId { + value: ffi_val.data, + }) + .collect() + }, + nonces: { + let std_vec: Vec<_> = value.message.nonces.into(); + std_vec.into_iter().map(Into::into).collect() + }, + instruction_data: value.message.instruction_data.into(), + }, + witness_set: WitnessSet { + signatures_and_public_keys: { + let std_vec: Vec<_> = value.witness_set.into(); + std_vec + .into_iter() + .map(|ffi_val| { + ( + Signature(ffi_val.signature.data), + PublicKey(ffi_val.public_key.data), + ) + }) + .collect() + }, + proof: None, + }, + } + } +} + #[repr(C)] pub struct FfiPublicMessage { pub program_id: FfiProgramId, @@ -96,6 +137,84 @@ impl From for FfiPrivateTransactionBody { } } +impl From> for PrivacyPreservingTransaction { + fn from(value: Box) -> Self { + Self { + hash: HashType(value.hash.data), + message: PrivacyPreservingMessage { + public_account_ids: { + let std_vec: Vec<_> = value.message.public_account_ids.into(); + std_vec + .into_iter() + .map(|ffi_val| AccountId { + value: ffi_val.data, + }) + .collect() + }, + nonces: { + let std_vec: Vec<_> = value.message.nonces.into(); + std_vec.into_iter().map(Into::into).collect() + }, + public_post_states: { + let std_vec: Vec<_> = value.message.public_post_states.into(); + std_vec.into_iter().map(Into::into).collect() + }, + encrypted_private_post_states: { + let std_vec: Vec<_> = value.message.encrypted_private_post_states.into(); + std_vec + .into_iter() + .map(|ffi_val| EncryptedAccountData { + ciphertext: Ciphertext(ffi_val.ciphertext.into()), + epk: EphemeralPublicKey(ffi_val.epk.into()), + view_tag: ffi_val.view_tag, + }) + .collect() + }, + new_commitments: { + let std_vec: Vec<_> = value.message.new_commitments.into(); + std_vec + .into_iter() + .map(|ffi_val| Commitment(ffi_val.data)) + .collect() + }, + new_nullifiers: { + let std_vec: Vec<_> = value.message.new_nullifiers.into(); + std_vec + .into_iter() + .map(|ffi_val| { + ( + Nullifier(ffi_val.nullifier.data), + CommitmentSetDigest(ffi_val.commitment_set_digest.data), + ) + }) + .collect() + }, + block_validity_window: cast_ffi_validity_window( + value.message.block_validity_window, + ), + timestamp_validity_window: cast_ffi_validity_window( + value.message.timestamp_validity_window, + ), + }, + witness_set: WitnessSet { + signatures_and_public_keys: { + let std_vec: Vec<_> = value.witness_set.into(); + std_vec + .into_iter() + .map(|ffi_val| { + ( + Signature(ffi_val.signature.data), + PublicKey(ffi_val.public_key.data), + ) + }) + .collect() + }, + proof: Some(Proof(value.proof.into())), + }, + } + } +} + #[repr(C)] pub struct FfiPrivacyPreservingMessage { pub public_account_ids: FfiAccountIdList, @@ -209,6 +328,17 @@ pub struct FfiProgramDeploymentTransactionBody { pub message: FfiProgramDeploymentMessage, } +impl From> for ProgramDeploymentTransaction { + fn from(value: Box) -> Self { + Self { + hash: HashType(value.hash.data), + message: ProgramDeploymentMessage { + bytecode: value.message.into(), + }, + } + } +} + impl From for FfiProgramDeploymentTransactionBody { fn from(value: ProgramDeploymentTransaction) -> Self { Self { @@ -269,9 +399,123 @@ pub enum FfiTransactionKind { ProgramDeploy, } +/// Frees the resources associated with the given ffi transaction. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiTransaction`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiTransaction`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_transaction(val: FfiTransaction) { + match val.kind { + FfiTransactionKind::Public => { + let body = unsafe { Box::from_raw(val.body.public_body) }; + let std_body: PublicTransaction = body.into(); + drop(std_body); + } + FfiTransactionKind::Private => { + let body = unsafe { Box::from_raw(val.body.private_body) }; + let std_body: PrivacyPreservingTransaction = body.into(); + drop(std_body); + } + FfiTransactionKind::ProgramDeploy => { + let body = unsafe { Box::from_raw(val.body.program_deployment_body) }; + let std_body: ProgramDeploymentTransaction = body.into(); + drop(std_body); + } + } +} + +/// Frees the resources associated with the given ffi transaction option. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiOption`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiOption`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_transaction_opt(val: FfiOption) { + if val.is_some { + let value = unsafe { Box::from_raw(val.value) }; + + match value.kind { + FfiTransactionKind::Public => { + let body = unsafe { Box::from_raw(value.body.public_body) }; + let std_body: PublicTransaction = body.into(); + drop(std_body); + } + FfiTransactionKind::Private => { + let body = unsafe { Box::from_raw(value.body.private_body) }; + let std_body: PrivacyPreservingTransaction = body.into(); + drop(std_body); + } + FfiTransactionKind::ProgramDeploy => { + let body = unsafe { Box::from_raw(value.body.program_deployment_body) }; + let std_body: ProgramDeploymentTransaction = body.into(); + drop(std_body); + } + } + } +} + +/// Frees the resources associated with the given vector of ffi transactions. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiVec`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiVec`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_transaction_vec(val: FfiVec) { + let ffi_tx_std_vec: Vec<_> = val.into(); + for tx in ffi_tx_std_vec { + unsafe { + free_ffi_transaction(tx); + } + } +} + fn cast_validity_window(window: ValidityWindow) -> [u64; 2] { [ window.0.0.unwrap_or_default(), window.0.1.unwrap_or(u64::MAX), ] } + +const fn cast_ffi_validity_window(ffi_window: [u64; 2]) -> ValidityWindow { + let left = if ffi_window[0] == 0 { + None + } else { + Some(ffi_window[0]) + }; + + let right = if ffi_window[1] == u64::MAX { + None + } else { + Some(ffi_window[1]) + }; + + ValidityWindow((left, right)) +} diff --git a/indexer_ffi/src/api/types/vectors.rs b/indexer_ffi/src/api/types/vectors.rs index 7c8c2073..46f08737 100644 --- a/indexer_ffi/src/api/types/vectors.rs +++ b/indexer_ffi/src/api/types/vectors.rs @@ -1,6 +1,6 @@ use crate::api::types::{ - FfiAccountId, FfiNonce, FfiVec, - account::{FfiAccount, FfiBytes32}, + FfiAccountId, FfiBytes32, FfiNonce, FfiVec, + account::FfiAccount, transaction::{ FfiEncryptedAccountData, FfiNullifierCommitmentSet, FfiSignaturePubKeyEntry, FfiTransaction, }, From 06a6983ef355d611686a01aa0f36bf8fc2a06385 Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Wed, 29 Apr 2026 17:24:00 +0300 Subject: [PATCH 08/25] fix: tests updated --- Cargo.lock | 1 + indexer_ffi/src/api/types/account.rs | 1 + indexer_ffi/src/api/types/mod.rs | 11 ++++ indexer_ffi/src/indexer.rs | 2 +- integration_tests/Cargo.toml | 1 + integration_tests/tests/indexer_ffi.rs | 91 ++++++++++++++++++-------- 6 files changed, 77 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27831459..8defea3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3606,6 +3606,7 @@ dependencies = [ "hex", "indexer_ffi", "indexer_service", + "indexer_service_protocol", "indexer_service_rpc", "key_protocol", "log", diff --git a/indexer_ffi/src/api/types/account.rs b/indexer_ffi/src/api/types/account.rs index 853e44d4..7893657b 100644 --- a/indexer_ffi/src/api/types/account.rs +++ b/indexer_ffi/src/api/types/account.rs @@ -7,6 +7,7 @@ use crate::api::types::{FfiBytes32, FfiProgramId, FfiU128}; /// Note: `balance` and `nonce` are u128 values represented as little-endian /// byte arrays since C doesn't have native u128 support. #[repr(C)] +#[derive(Clone)] pub struct FfiAccount { pub program_owner: FfiProgramId, /// Balance as little-endian [u8; 16]. diff --git a/indexer_ffi/src/api/types/mod.rs b/indexer_ffi/src/api/types/mod.rs index 22b44c88..2e7a77ad 100644 --- a/indexer_ffi/src/api/types/mod.rs +++ b/indexer_ffi/src/api/types/mod.rs @@ -130,6 +130,17 @@ impl From> for Vec { } } +impl FfiVec { + /// # Safety + /// + /// `index` must be lesser than `self.len`. + #[must_use] + pub unsafe fn get(&self, index: usize) -> &T { + let ptr = unsafe { self.entries.add(index) }; + unsafe { &*ptr } + } +} + #[repr(C)] pub struct FfiOption { pub value: *mut T, diff --git a/indexer_ffi/src/indexer.rs b/indexer_ffi/src/indexer.rs index 102900f6..c64708db 100644 --- a/indexer_ffi/src/indexer.rs +++ b/indexer_ffi/src/indexer.rs @@ -126,6 +126,6 @@ impl Drop for IndexerServiceFFI { } drop(unsafe { Box::from_raw(indexer_handle.cast::()) }); drop(unsafe { Box::from_raw(runtime.cast::()) }); - drop(unsafe { Box::from_raw(indexer_client.cast::()) }); + drop(unsafe { Box::from_raw(indexer_client.cast::()) }); } } diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 53f0ee98..465ff301 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -24,6 +24,7 @@ sequencer_service_rpc = { workspace = true, features = ["client"] } wallet-ffi.workspace = true indexer_ffi.workspace = true testnet_initial_state.workspace = true +indexer_service_protocol.workspace = true url.workspace = true diff --git a/integration_tests/tests/indexer_ffi.rs b/integration_tests/tests/indexer_ffi.rs index d0c643f7..8ac74ee6 100644 --- a/integration_tests/tests/indexer_ffi.rs +++ b/integration_tests/tests/indexer_ffi.rs @@ -6,8 +6,13 @@ )] use anyhow::{Context as _, Result}; -use indexer_ffi::{IndexerServiceFFI, OperationStatus, api::PointerResult}; -use indexer_service_rpc::RpcClient as _; +use indexer_ffi::{ + IndexerServiceFFI, OperationStatus, + api::{ + PointerResult, + types::{FfiAccountId, FfiOption, FfiVec, account::FfiAccount, block::FfiBlock}, + }, +}; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, format_private_account_id, format_public_account_id, test_context_ffi::BlockingTestContextFFI, verify_commitment_is_in_state, @@ -23,6 +28,17 @@ unsafe extern "C" { unsafe fn query_last_block( indexer: *const IndexerServiceFFI, ) -> PointerResult; + + unsafe fn query_block_vec( + indexer: *const IndexerServiceFFI, + before: FfiOption, + limit: u64, + ) -> PointerResult, OperationStatus>; + + unsafe fn query_account( + indexer: *const IndexerServiceFFI, + account_id: FfiAccountId, + ) -> PointerResult; } #[test] @@ -57,7 +73,6 @@ fn indexer_test_run_ffi() -> Result<()> { fn indexer_ffi_block_batching() -> Result<()> { let blocking_ctx = BlockingTestContextFFI::new()?; let runtime_wrapped = blocking_ctx.runtime(); - let ctx = blocking_ctx.ctx(); // WAIT info!("Waiting for indexer to parse blocks"); @@ -65,31 +80,36 @@ fn indexer_ffi_block_batching() -> Result<()> { tokio::time::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)).await; }); - let last_block_indexer = runtime_wrapped - .block_on(ctx.indexer_client().get_last_finalized_block_id()) - .unwrap(); + let last_block_indexer_ffi_res = unsafe { query_last_block(blocking_ctx.indexer_ffi()) }; + + assert!(last_block_indexer_ffi_res.error.is_ok()); + + let last_block_indexer = unsafe { *last_block_indexer_ffi_res.value }; info!("Last block on ind now is {last_block_indexer}"); assert!(last_block_indexer > 1); - // Getting wide batch to fit all blocks (from latest backwards) - let mut block_batch = runtime_wrapped - .block_on(ctx.indexer_client().get_blocks(None, 100)) - .unwrap(); + let before_ffi = FfiOption::::from_none(); + let limit = 100; - // Reverse to check chain consistency from oldest to newest - block_batch.reverse(); + let block_batch_ffi_res = + unsafe { query_block_vec(blocking_ctx.indexer_ffi(), before_ffi, limit) }; - // Checking chain consistency - let mut prev_block_hash = block_batch.first().unwrap().header.hash; + assert!(block_batch_ffi_res.error.is_ok()); - for block in &block_batch[1..] { - assert_eq!(block.header.prev_block_hash, prev_block_hash); + let block_batch = unsafe { &*block_batch_ffi_res.value }; + + let mut last_block_prev_hash = unsafe { block_batch.get(0) }.header.prev_block_hash.data; + + for i in 1..block_batch.len { + let block = unsafe { block_batch.get(i) }; + + assert_eq!(last_block_prev_hash, block.header.hash.data); info!("Block {} chain-consistent", block.header.block_id); - prev_block_hash = block.header.hash; + last_block_prev_hash = block.header.prev_block_hash.data; } Ok(()) @@ -99,6 +119,7 @@ fn indexer_ffi_block_batching() -> Result<()> { fn indexer_ffi_state_consistency() -> Result<()> { let mut blocking_ctx = BlockingTestContextFFI::new()?; let runtime_wrapped = blocking_ctx.runtime_clone(); + let indexer_ffi = blocking_ctx.indexer_ffi(); let ctx = blocking_ctx.ctx_mut(); let command = Command::AuthTransfer(AuthTransferSubcommand::Send { @@ -190,14 +211,21 @@ fn indexer_ffi_state_consistency() -> Result<()> { tokio::time::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)).await; }); - let acc1_ind_state = runtime_wrapped.block_on( - ctx.indexer_client() - .get_account(ctx.existing_public_accounts()[0].into()), - )?; - let acc2_ind_state = runtime_wrapped.block_on( - ctx.indexer_client() - .get_account(ctx.existing_public_accounts()[1].into()), - )?; + let acc1_ind_state_ffi = + unsafe { query_account(indexer_ffi, (&ctx.existing_public_accounts()[0]).into()) }; + + assert!(acc1_ind_state_ffi.error.is_ok()); + + let acc1_ind_state_pre = unsafe { &*acc1_ind_state_ffi.value }.clone(); + let acc1_ind_state: indexer_service_protocol::Account = acc1_ind_state_pre.into(); + + let acc2_ind_state_ffi = + unsafe { query_account(indexer_ffi, (&ctx.existing_public_accounts()[1]).into()) }; + + assert!(acc2_ind_state_ffi.error.is_ok()); + + let acc2_ind_state_pre = unsafe { &*acc2_ind_state_ffi.value }.clone(); + let acc2_ind_state: indexer_service_protocol::Account = acc2_ind_state_pre.into(); info!("Checking correct state transition"); let acc1_seq_state = @@ -223,6 +251,7 @@ fn indexer_ffi_state_consistency() -> Result<()> { fn indexer_ffi_state_consistency_with_labels() -> Result<()> { let mut blocking_ctx = BlockingTestContextFFI::new()?; let runtime_wrapped = blocking_ctx.runtime_clone(); + let indexer_ffi = blocking_ctx.indexer_ffi(); let ctx = blocking_ctx.ctx_mut(); // Assign labels to both accounts @@ -283,10 +312,14 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> { tokio::time::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)).await; }); - let acc1_ind_state = runtime_wrapped.block_on( - ctx.indexer_client() - .get_account(ctx.existing_public_accounts()[0].into()), - )?; + let acc1_ind_state_ffi = + unsafe { query_account(indexer_ffi, (&ctx.existing_public_accounts()[0]).into()) }; + + assert!(acc1_ind_state_ffi.error.is_ok()); + + let acc1_ind_state_pre = unsafe { &*acc1_ind_state_ffi.value }.clone(); + let acc1_ind_state: indexer_service_protocol::Account = acc1_ind_state_pre.into(); + let acc1_seq_state = runtime_wrapped.block_on(sequencer_service_rpc::RpcClient::get_account( ctx.sequencer_client(), From 0e914adf0ab79e53f5e9291945d8b2dae4234cfe Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Tue, 5 May 2026 15:45:24 +0300 Subject: [PATCH 09/25] fix: merge fix --- .deny.toml | 2 ++ Cargo.lock | 15 ++++++++------- indexer_ffi/Cargo.toml | 3 ++- indexer_ffi/src/api/lifecycle.rs | 2 +- indexer_ffi/src/client.rs | 33 ++++++++++++++++++++++++++++++++ indexer_ffi/src/indexer.rs | 3 ++- indexer_ffi/src/lib.rs | 1 + 7 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 indexer_ffi/src/client.rs diff --git a/.deny.toml b/.deny.toml index e65cdd34..57b5f759 100644 --- a/.deny.toml +++ b/.deny.toml @@ -14,6 +14,8 @@ ignore = [ { id = "RUSTSEC-2025-0141", reason = "`bincode` is unmaintained but continuing to use it." }, { id = "RUSTSEC-2023-0089", reason = "atomic-polyfill is pulled transitively via risc0-zkvm; waiting on upstream fix (see https://github.com/risc0/risc0/issues/3453)" }, { id = "RUSTSEC-2026-0097", reason = "`rand` v0.8.5 is present transitively from logos crates, modification may break integration" }, + { id = "RUSTSEC-2026-0118", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration"}, + { id = "RUSTSEC-2026-0119", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration"}, ] yanked = "deny" unused-ignored-advisory = "deny" diff --git a/Cargo.lock b/Cargo.lock index e7a116e4..1c49d7e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2064,7 +2064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -2558,7 +2558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3797,13 +3797,14 @@ dependencies = [ name = "indexer_ffi" version = "0.1.0" dependencies = [ + "anyhow", "cbindgen", "indexer_service", "indexer_service_protocol", "indexer_service_rpc", + "jsonrpsee", "log", "nssa", - "sequencer_core", "tokio", "url", ] @@ -6341,7 +6342,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8126,7 +8127,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -9095,7 +9096,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -10381,7 +10382,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/indexer_ffi/Cargo.toml b/indexer_ffi/Cargo.toml index 0a140d19..1deb8fad 100644 --- a/indexer_ffi/Cargo.toml +++ b/indexer_ffi/Cargo.toml @@ -7,13 +7,14 @@ version = "0.1.0" [dependencies] nssa.workspace = true indexer_service.workspace = true -sequencer_core.workspace = true indexer_service_rpc.workspace = true indexer_service_protocol.workspace = true url.workspace = true log = { workspace = true } tokio = { features = ["rt-multi-thread"], workspace = true } +jsonrpsee.workspace = true +anyhow.workspace = true [build-dependencies] cbindgen = "0.29" diff --git a/indexer_ffi/src/api/lifecycle.rs b/indexer_ffi/src/api/lifecycle.rs index 15c6e619..c9cd859d 100644 --- a/indexer_ffi/src/api/lifecycle.rs +++ b/indexer_ffi/src/api/lifecycle.rs @@ -1,6 +1,5 @@ use std::{ffi::c_char, path::PathBuf}; -use sequencer_core::indexer_client::{IndexerClient, IndexerClientTrait as _}; use tokio::runtime::Runtime; use crate::{ @@ -9,6 +8,7 @@ use crate::{ PointerResult, client::{UrlProtocol, addr_to_url}, }, + client::{IndexerClient, IndexerClientTrait as _}, errors::OperationStatus, }; diff --git a/indexer_ffi/src/client.rs b/indexer_ffi/src/client.rs new file mode 100644 index 00000000..f05b350e --- /dev/null +++ b/indexer_ffi/src/client.rs @@ -0,0 +1,33 @@ +use std::{ops::Deref, sync::Arc}; + +use anyhow::{Context as _, Result}; +use log::info; +pub use url::Url; + +pub trait IndexerClientTrait: Clone { + async fn new(indexer_url: &Url) -> Result; +} + +#[derive(Clone)] +pub struct IndexerClient(Arc); + +impl IndexerClientTrait for IndexerClient { + async fn new(indexer_url: &Url) -> Result { + info!("Connecting to Indexer at {indexer_url}"); + + let client = jsonrpsee::ws_client::WsClientBuilder::default() + .build(indexer_url) + .await + .context("Failed to create websocket client")?; + + Ok(Self(Arc::new(client))) + } +} + +impl Deref for IndexerClient { + type Target = jsonrpsee::ws_client::WsClient; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/indexer_ffi/src/indexer.rs b/indexer_ffi/src/indexer.rs index c64708db..33800356 100644 --- a/indexer_ffi/src/indexer.rs +++ b/indexer_ffi/src/indexer.rs @@ -1,9 +1,10 @@ use std::{ffi::c_void, net::SocketAddr}; use indexer_service::IndexerHandle; -use sequencer_core::indexer_client::IndexerClient; use tokio::runtime::Runtime; +use crate::client::IndexerClient; + #[repr(C)] pub struct IndexerServiceFFI { indexer_handle: *mut c_void, diff --git a/indexer_ffi/src/lib.rs b/indexer_ffi/src/lib.rs index fe594ec0..5806a074 100644 --- a/indexer_ffi/src/lib.rs +++ b/indexer_ffi/src/lib.rs @@ -4,5 +4,6 @@ pub use errors::OperationStatus; pub use indexer::IndexerServiceFFI; pub mod api; +mod client; mod errors; mod indexer; From 7be0ed926cdef1d39661487d4104db8ce20fb3d4 Mon Sep 17 00:00:00 2001 From: Moudy Date: Tue, 5 May 2026 15:38:18 +0200 Subject: [PATCH 10/25] feat(wallet)!: add derive_keys_for_shared_account and PrivateShared variant BREAKING CHANGE: `pda_accounts` field in NSSAUserData renamed to `shared_accounts`. `PrivacyPreservingAccount` enum has a new `PrivateShared` variant, exhaustive matches must handle it. --- .../src/key_management/group_key_holder.rs | 63 ++++++++++ key_protocol/src/key_protocol_core/mod.rs | 12 +- wallet/src/privacy_preserving_tx.rs | 116 +++++++++++++++--- 3 files changed, 168 insertions(+), 23 deletions(-) diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs index 9e7bd8fc..e6634f88 100644 --- a/key_protocol/src/key_management/group_key_holder.rs +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -105,6 +105,21 @@ impl GroupKeyHolder { .produce_private_key_holder(None) } + /// Derive keys for a shared regular (non-PDA) private account. + /// + /// Uses a distinct domain separator from `derive_keys_for_pda` to prevent cross-domain + /// key collisions. The `tag` should be a stable, unique 32-byte value (e.g. derived from + /// a random identifier at account creation time). + #[must_use] + pub fn derive_keys_for_shared_account(&self, tag: &[u8; 32]) -> PrivateKeyHolder { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SHA"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(self.gms); + hasher.update(tag); + SecretSpendingKey(hasher.finalize_fixed().into()).produce_private_key_holder(None) + } + /// Encrypts this holder's GMS under the recipient's [`SealingPublicKey`]. /// /// Uses an ephemeral ECDH key exchange to derive a shared secret, then AES-256-GCM @@ -501,4 +516,52 @@ mod tests { let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk); assert_eq!(alice_account_id, bob_account_id); } + + /// Same GMS + same tag produces same keys for shared accounts. + #[test] + fn shared_account_same_gms_same_tag_produces_same_keys() { + let gms = [42_u8; 32]; + let tag = [1_u8; 32]; + let holder_a = GroupKeyHolder::from_gms(gms); + let holder_b = GroupKeyHolder::from_gms(gms); + + let npk_a = holder_a + .derive_keys_for_shared_account(&tag) + .generate_nullifier_public_key(); + let npk_b = holder_b + .derive_keys_for_shared_account(&tag) + .generate_nullifier_public_key(); + + assert_eq!(npk_a, npk_b); + } + + /// Different tags produce different keys for shared accounts. + #[test] + fn shared_account_different_tags_produce_different_keys() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let npk_a = holder + .derive_keys_for_shared_account(&[1_u8; 32]) + .generate_nullifier_public_key(); + let npk_b = holder + .derive_keys_for_shared_account(&[2_u8; 32]) + .generate_nullifier_public_key(); + + assert_ne!(npk_a, npk_b); + } + + /// PDA and shared account derivations from the same GMS + same bytes never collide. + #[test] + fn pda_and_shared_derivations_do_not_collide() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let bytes = [1_u8; 32]; + + let pda_npk = holder + .derive_keys_for_pda(&PdaSeed::new(bytes)) + .generate_nullifier_public_key(); + let shared_npk = holder + .derive_keys_for_shared_account(&bytes) + .generate_nullifier_public_key(); + + assert_ne!(pda_npk, shared_npk); + } } diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index d12f83a1..1abab24b 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -36,12 +36,12 @@ pub struct NSSAUserData { /// An older wallet binary that re-serializes this struct will drop the field. #[serde(default)] pub group_key_holders: BTreeMap, - /// Cached plaintext state of private PDA accounts, keyed by `AccountId`. - /// Updated after each private PDA transaction by decrypting the circuit output. + /// Cached plaintext state of shared accounts (PDAs and regular shared accounts), + /// keyed by `AccountId`. Updated after each transaction by decrypting the circuit output. /// The sequencer only stores encrypted commitments, so this local cache is the - /// only source of plaintext state for private PDAs. - #[serde(default, alias = "group_pda_accounts")] - pub pda_accounts: BTreeMap, + /// only source of plaintext state for these accounts. + #[serde(default, alias = "group_pda_accounts", alias = "pda_accounts")] + pub shared_accounts: BTreeMap, } impl NSSAUserData { @@ -101,7 +101,7 @@ impl NSSAUserData { public_key_tree, private_key_tree, group_key_holders: BTreeMap::new(), - pda_accounts: BTreeMap::new(), + shared_accounts: BTreeMap::new(), }) } diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index 35419534..ba1a6a73 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -30,6 +30,15 @@ pub enum PrivacyPreservingAccount { program_id: ProgramId, seed: PdaSeed, }, + /// A shared regular private account with externally-provided keys (e.g. from GMS). + /// Uses standard `AccountId = from((&npk, identifier))` and mask 1/2. + /// Works with `authenticated_transfer` and all existing programs out of the box. + PrivateShared { + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, + }, } impl PrivacyPreservingAccount { @@ -49,6 +58,7 @@ impl PrivacyPreservingAccount { identifier: _, } | Self::PrivatePda { .. } + | Self::PrivateShared { .. } ) } } @@ -111,6 +121,7 @@ impl AccountManager { nsk: None, npk, identifier, + is_pda: false, vpk, pre_state: auth_acc, proof: None, @@ -130,6 +141,16 @@ impl AccountManager { let pre = private_pda_preparation(wallet, nsk, npk, vpk, &program_id, &seed).await?; + State::Private(pre) + } + PrivacyPreservingAccount::PrivateShared { + nsk, + npk, + vpk, + identifier, + } => { + let pre = private_shared_preparation(wallet, nsk, npk, vpk, identifier).await?; + State::Private(pre) } }; @@ -184,22 +205,17 @@ impl AccountManager { .iter() .map(|state| match state { State::Public { .. } => InputAccountIdentity::Public, - State::Private(pre) if pre.identifier == u128::MAX => { - // Private PDA account - match (pre.nsk, pre.proof.clone()) { - (Some(nsk), Some(membership_proof)) => { - InputAccountIdentity::PrivatePdaUpdate { - ssk: pre.ssk, - nsk, - membership_proof, - } - } - _ => InputAccountIdentity::PrivatePdaInit { - npk: pre.npk, - ssk: pre.ssk, - }, - } - } + State::Private(pre) if pre.is_pda => match (pre.nsk, pre.proof.clone()) { + (Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate { + ssk: pre.ssk, + nsk, + membership_proof, + }, + _ => InputAccountIdentity::PrivatePdaInit { + npk: pre.npk, + ssk: pre.ssk, + }, + }, State::Private(pre) => match (pre.nsk, pre.proof.clone()) { (Some(nsk), Some(membership_proof)) => { InputAccountIdentity::PrivateAuthorizedUpdate { @@ -249,6 +265,7 @@ struct AccountPreparedData { nsk: Option, npk: NullifierPublicKey, identifier: Identifier, + is_pda: bool, vpk: ViewingPublicKey, pre_state: AccountWithMetadata, proof: Option, @@ -294,6 +311,7 @@ async fn private_acc_preparation( nsk: Some(nsk), npk: from_npk, identifier: from_identifier, + is_pda: false, vpk: from_vpk, pre_state: sender_pre, proof, @@ -317,7 +335,7 @@ async fn private_pda_preparation( let acc = wallet .storage .user_data - .pda_accounts + .shared_accounts .get(&account_id) .cloned() .unwrap_or_default(); @@ -347,6 +365,7 @@ async fn private_pda_preparation( nsk: exists.then_some(nsk), npk, identifier: u128::MAX, + is_pda: true, vpk, pre_state, proof, @@ -354,3 +373,66 @@ async fn private_pda_preparation( epk, }) } + +async fn private_shared_preparation( + wallet: &WalletCore, + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, +) -> Result { + let account_id = nssa::AccountId::from((&npk, identifier)); + + let acc = wallet + .storage + .user_data + .shared_accounts + .get(&account_id) + .cloned() + .unwrap_or_default(); + + let exists = acc != nssa_core::account::Account::default(); + let pre_state = AccountWithMetadata::new(acc, exists, (&npk, identifier)); + + let proof = if exists { + wallet + .check_private_account_initialized(account_id) + .await + .unwrap_or(None) + } else { + None + }; + + let eph_holder = EphemeralKeyHolder::new(&npk); + let ssk = eph_holder.calculate_shared_secret_sender(&vpk); + let epk = eph_holder.generate_ephemeral_public_key(); + + Ok(AccountPreparedData { + nsk: exists.then_some(nsk), + npk, + identifier, + is_pda: false, + vpk, + pre_state, + proof, + ssk, + epk, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_shared_is_private() { + let acc = PrivacyPreservingAccount::PrivateShared { + nsk: [0; 32], + npk: NullifierPublicKey([1; 32]), + vpk: ViewingPublicKey::from_scalar([2; 32]), + identifier: 42, + }; + assert!(acc.is_private()); + assert!(!acc.is_public()); + } +} From cd545819e7cb001aff45e64893cf0ddc1d707c22 Mon Sep 17 00:00:00 2001 From: Moudy Date: Tue, 5 May 2026 18:55:51 +0200 Subject: [PATCH 11/25] feat(wallet)!: add group CLI commands with --for-gms account creation BREAKING CHANGE: `NewSubcommand::Private` has new required fields (`for_gms`, `pda`, `seed`, `program_id`). Code constructing this variant must include them (use `None`/`false` for defaults). `shared_accounts` value type changed from `Account` to `SharedAccountEntry`. --- key_protocol/src/key_protocol_core/mod.rs | 20 ++- wallet/src/cli/account.rs | 174 ++++++++++++++++++---- wallet/src/cli/group.rs | 159 ++++---------------- wallet/src/cli/mod.rs | 6 + wallet/src/lib.rs | 35 +++++ wallet/src/privacy_preserving_tx.rs | 4 +- 6 files changed, 232 insertions(+), 166 deletions(-) diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index 1abab24b..e17c35a7 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -21,6 +21,15 @@ pub struct UserPrivateAccountData { pub accounts: Vec<(Identifier, Account)>, } +/// Metadata for a shared account (GMS-derived), stored alongside the cached plaintext state. +/// The group label and identifier are needed to re-derive keys during sync. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SharedAccountEntry { + pub group_label: String, + pub identifier: Identifier, + pub account: Account, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct NSSAUserData { /// Default public accounts. @@ -37,11 +46,12 @@ pub struct NSSAUserData { #[serde(default)] pub group_key_holders: BTreeMap, /// Cached plaintext state of shared accounts (PDAs and regular shared accounts), - /// keyed by `AccountId`. Updated after each transaction by decrypting the circuit output. - /// The sequencer only stores encrypted commitments, so this local cache is the - /// only source of plaintext state for these accounts. - #[serde(default, alias = "group_pda_accounts", alias = "pda_accounts")] - pub shared_accounts: BTreeMap, + /// keyed by `AccountId`. Each entry stores the group label and identifier needed + /// to re-derive keys during sync. + /// Old wallet files with `pda_accounts` (plain Account values) are incompatible with + /// this type. The `default` attribute ensures they deserialize as empty rather than failing. + #[serde(default)] + pub shared_accounts: BTreeMap, } impl NSSAUserData { diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index b5e80854..1355eb69 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -83,14 +83,27 @@ pub enum NewSubcommand { label: Option, }, /// Single-account convenience: creates a key node and auto-registers one account with a random - /// identifier. + /// identifier. When `--for-gms` is provided, derives keys from the named group instead of + /// the wallet's key tree. Private { #[arg(long)] - /// Chain index of a parent node. + /// Chain index of a parent node (ignored when --for-gms is used). cci: Option, #[arg(short, long)] /// Label to assign to the new account. label: Option, + #[arg(long)] + /// Derive keys from a group's GMS instead of the wallet tree. + for_gms: Option, + #[arg(long, requires = "for_gms")] + /// Create a PDA account (requires --seed and --program-id). + pda: bool, + #[arg(long, requires = "pda")] + /// PDA seed as 64-character hex string. + seed: Option, + #[arg(long, requires = "pda")] + /// Program ID as hex string. + program_id: Option, }, /// Recommended for receiving from multiple senders: creates a key node (npk + vpk) without /// registering any account. @@ -144,7 +157,14 @@ impl WalletSubcommand for NewSubcommand { Ok(SubcommandReturnValue::RegisterAccount { account_id }) } - Self::Private { cci, label } => { + Self::Private { + cci, + label, + for_gms, + pda, + seed, + program_id, + } => { if let Some(label) = &label && wallet_core .storage @@ -155,36 +175,132 @@ impl WalletSubcommand for NewSubcommand { anyhow::bail!("Label '{label}' is already in use by another account"); } - let (account_id, chain_index) = wallet_core.create_new_account_private(cci); + if let Some(group_name) = for_gms { + // GMS-derived account + let holder = wallet_core + .storage() + .user_data + .group_key_holder(&group_name) + .context(format!("Group '{group_name}' not found"))?; - let node = wallet_core - .storage - .user_data - .private_key_tree - .key_map - .get(&chain_index) - .expect("Node was just inserted"); - let key = &node.value.0; + if pda { + // PDA shared account + let seed_hex = seed.context("--seed is required for PDA accounts")?; + let pid_hex = + program_id.context("--program-id is required for PDA accounts")?; - if let Some(label) = label { - wallet_core + let seed_bytes: [u8; 32] = hex::decode(&seed_hex) + .context("Invalid seed hex")? + .try_into() + .map_err(|_err| anyhow::anyhow!("Seed must be exactly 32 bytes"))?; + let pda_seed = nssa_core::program::PdaSeed::new(seed_bytes); + + let pid_bytes = hex::decode(&pid_hex).context("Invalid program ID hex")?; + if pid_bytes.len() != 32 { + anyhow::bail!("Program ID must be exactly 32 bytes"); + } + let mut pid: nssa_core::program::ProgramId = [0; 8]; + for (i, chunk) in pid_bytes.chunks_exact(4).enumerate() { + pid[i] = u32::from_le_bytes(chunk.try_into().unwrap()); + } + + let keys = holder.derive_keys_for_pda(&pda_seed); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let account_id = nssa::AccountId::for_private_pda(&pid, &pda_seed, &npk); + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + wallet_core.register_shared_account( + account_id, + group_name.clone(), + u128::MAX, + ); + + println!("PDA shared account from group '{group_name}'"); + println!("AccountId: {account_id}"); + println!("NPK: {}", hex::encode(npk.0)); + println!("VPK: {}", hex::encode(&vpk.0)); + + wallet_core.store_persistent_data().await?; + Ok(SubcommandReturnValue::RegisterAccount { account_id }) + } else { + // Regular shared account. The tag is derived deterministically + // from the identifier so that keys can be re-derived without + // storing the tag separately. + let identifier: nssa_core::Identifier = rand::random(); + let tag = { + use sha2::Digest as _; + let mut hasher = sha2::Sha256::new(); + hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00"); + hasher.update(identifier.to_le_bytes()); + let result: [u8; 32] = hasher.finalize().into(); + result + }; + + let keys = holder.derive_keys_for_shared_account(&tag); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let account_id = nssa::AccountId::from((&npk, identifier)); + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + wallet_core.register_shared_account( + account_id, + group_name.clone(), + identifier, + ); + + println!("Shared account from group '{group_name}'"); + println!("AccountId: Private/{account_id}"); + println!("NPK: {}", hex::encode(npk.0)); + println!("VPK: {}", hex::encode(&vpk.0)); + + wallet_core.store_persistent_data().await?; + Ok(SubcommandReturnValue::RegisterAccount { account_id }) + } + } else { + // Standard wallet-tree-derived account + let (account_id, chain_index) = wallet_core.create_new_account_private(cci); + + let node = wallet_core .storage - .labels - .insert(account_id.to_string(), Label::new(label)); + .user_data + .private_key_tree + .key_map + .get(&chain_index) + .expect("Node was just inserted"); + let key = &node.value.0; + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + println!( + "Generated new account with account_id Private/{account_id} at path {chain_index}" + ); + println!("With npk {}", hex::encode(key.nullifier_public_key.0)); + println!( + "With vpk {}", + hex::encode(key.viewing_public_key.to_bytes()) + ); + + wallet_core.store_persistent_data().await?; + Ok(SubcommandReturnValue::RegisterAccount { account_id }) } - - println!( - "Generated new account with account_id Private/{account_id} at path {chain_index}" - ); - println!("With npk {}", hex::encode(key.nullifier_public_key.0)); - println!( - "With vpk {}", - hex::encode(key.viewing_public_key.to_bytes()) - ); - - wallet_core.store_persistent_data().await?; - - Ok(SubcommandReturnValue::RegisterAccount { account_id }) } Self::PrivateAccountsKey { cci } => { let chain_index = wallet_core.create_private_accounts_key(cci); diff --git a/wallet/src/cli/group.rs b/wallet/src/cli/group.rs index 5cdcc0af..0a1d8d54 100644 --- a/wallet/src/cli/group.rs +++ b/wallet/src/cli/group.rs @@ -1,15 +1,13 @@ use anyhow::{Context as _, Result}; use clap::Subcommand; use key_protocol::key_management::group_key_holder::GroupKeyHolder; -use nssa::AccountId; -use nssa_core::program::PdaSeed; use crate::{ WalletCore, cli::{SubcommandReturnValue, WalletSubcommand}, }; -/// Group PDA management commands. +/// Group key management commands. #[derive(Subcommand, Debug, Clone)] pub enum GroupSubcommand { /// Create a new group with a fresh random GMS. @@ -24,29 +22,15 @@ pub enum GroupSubcommand { /// Raw GMS as 64-character hex string. #[arg(long)] gms: String, - /// Epoch (defaults to 0). - #[arg(long, default_value = "0")] - epoch: u32, }, /// Export the raw GMS hex for backup or manual distribution. Export { /// Group name. name: String, }, - /// List all groups with their epochs. + /// List all groups. #[command(visible_alias = "ls")] List, - /// Derive keys for a PDA seed and show the resulting AccountId. - Derive { - /// Group name. - name: String, - /// PDA seed as 64-character hex string. - #[arg(long)] - seed: String, - /// Program ID as hex string (u32x8 little-endian). - #[arg(long)] - program_id: String, - }, /// Remove a group from the wallet. Remove { /// Group name. @@ -56,9 +40,9 @@ pub enum GroupSubcommand { Invite { /// Group name. name: String, - /// Recipient's viewing public key as hex string. + /// Recipient's sealing public key as hex string. #[arg(long)] - vpk: String, + key: String, }, /// Unseal a received GMS and store it (join a group). Join { @@ -67,15 +51,10 @@ pub enum GroupSubcommand { /// Sealed GMS as hex string (from the inviter). #[arg(long)] sealed: String, - /// Account label or Private/ whose VSK to use for decryption. + /// Account ID whose viewing secret key to use for decryption. #[arg(long)] account: String, }, - /// Ratchet the GMS to exclude removed members. - Ratchet { - /// Group name. - name: String, - }, } impl WalletSubcommand for GroupSubcommand { @@ -88,28 +67,25 @@ impl WalletSubcommand for GroupSubcommand { if wallet_core .storage() .user_data - .get_group_key_holder(&name) + .group_key_holder(&name) .is_some() { anyhow::bail!("Group '{name}' already exists"); } let holder = GroupKeyHolder::new(); - wallet_core - .storage_mut() - .user_data - .insert_group_key_holder(name.clone(), holder); + wallet_core.insert_group_key_holder(name.clone(), holder); wallet_core.store_persistent_data().await?; - println!("Created group '{name}' at epoch 0"); + println!("Created group '{name}'"); Ok(SubcommandReturnValue::Empty) } - Self::Import { name, gms, epoch } => { + Self::Import { name, gms } => { if wallet_core .storage() .user_data - .get_group_key_holder(&name) + .group_key_holder(&name) .is_some() { anyhow::bail!("Group '{name}' already exists"); @@ -118,16 +94,13 @@ impl WalletSubcommand for GroupSubcommand { let gms_bytes: [u8; 32] = hex::decode(&gms) .context("Invalid GMS hex")? .try_into() - .map_err(|_| anyhow::anyhow!("GMS must be exactly 32 bytes"))?; + .map_err(|_err| anyhow::anyhow!("GMS must be exactly 32 bytes"))?; - let holder = GroupKeyHolder::from_gms_and_epoch(gms_bytes, epoch); - wallet_core - .storage_mut() - .user_data - .insert_group_key_holder(name.clone(), holder); + let holder = GroupKeyHolder::from_gms(gms_bytes); + wallet_core.insert_group_key_holder(name.clone(), holder); wallet_core.store_persistent_data().await?; - println!("Imported group '{name}' at epoch {epoch}"); + println!("Imported group '{name}'"); Ok(SubcommandReturnValue::Empty) } @@ -135,14 +108,12 @@ impl WalletSubcommand for GroupSubcommand { let holder = wallet_core .storage() .user_data - .get_group_key_holder(&name) + .group_key_holder(&name) .context(format!("Group '{name}' not found"))?; let gms_hex = hex::encode(holder.dangerous_raw_gms()); - let epoch = holder.epoch(); println!("Group: {name}"); - println!("Epoch: {epoch}"); println!("GMS: {gms_hex}"); Ok(SubcommandReturnValue::Empty) } @@ -152,60 +123,15 @@ impl WalletSubcommand for GroupSubcommand { if holders.is_empty() { println!("No groups found"); } else { - for (name, holder) in holders { - println!("{name} (epoch {})", holder.epoch()); + for name in holders.keys() { + println!("{name}"); } } Ok(SubcommandReturnValue::Empty) } - Self::Derive { - name, - seed, - program_id, - } => { - let holder = wallet_core - .storage() - .user_data - .get_group_key_holder(&name) - .context(format!("Group '{name}' not found"))?; - - let seed_bytes: [u8; 32] = hex::decode(&seed) - .context("Invalid seed hex")? - .try_into() - .map_err(|_| anyhow::anyhow!("Seed must be exactly 32 bytes"))?; - let pda_seed = PdaSeed::new(seed_bytes); - - let pid_bytes = - hex::decode(&program_id).context("Invalid program ID hex")?; - if pid_bytes.len() != 32 { - anyhow::bail!("Program ID must be exactly 32 bytes"); - } - let mut pid: nssa_core::program::ProgramId = [0; 8]; - for (i, chunk) in pid_bytes.chunks_exact(4).enumerate() { - pid[i] = u32::from_le_bytes(chunk.try_into().unwrap()); - } - - let keys = holder.derive_keys_for_pda(&pda_seed); - let npk = keys.generate_nullifier_public_key(); - let vpk = keys.generate_viewing_public_key(); - let account_id = AccountId::for_private_pda(&pid, &pda_seed, &npk); - - println!("Group: {name}"); - println!("NPK: {}", hex::encode(npk.0)); - println!("VPK: {}", hex::encode(&vpk.0)); - println!("AccountId: {account_id}"); - Ok(SubcommandReturnValue::Empty) - } - Self::Remove { name } => { - if wallet_core - .storage_mut() - .user_data - .group_key_holders - .remove(&name) - .is_none() - { + if wallet_core.remove_group_key_holder(&name).is_none() { anyhow::bail!("Group '{name}' not found"); } @@ -214,18 +140,18 @@ impl WalletSubcommand for GroupSubcommand { Ok(SubcommandReturnValue::Empty) } - Self::Invite { name, vpk } => { + Self::Invite { name, key } => { let holder = wallet_core .storage() .user_data - .get_group_key_holder(&name) + .group_key_holder(&name) .context(format!("Group '{name}' not found"))?; - let vpk_bytes = hex::decode(&vpk).context("Invalid VPK hex")?; - let recipient_vpk = - nssa_core::encryption::shared_key_derivation::Secp256k1Point(vpk_bytes); + let key_bytes = hex::decode(&key).context("Invalid key hex")?; + let recipient_key = + nssa_core::encryption::shared_key_derivation::Secp256k1Point(key_bytes); - let sealed = holder.seal_for(&recipient_vpk); + let sealed = holder.seal_for(&recipient_key); println!("{}", hex::encode(&sealed)); Ok(SubcommandReturnValue::Empty) } @@ -238,7 +164,7 @@ impl WalletSubcommand for GroupSubcommand { if wallet_core .storage() .user_data - .get_group_key_holder(&name) + .group_key_holder(&name) .is_some() { anyhow::bail!("Group '{name}' already exists"); @@ -246,11 +172,8 @@ impl WalletSubcommand for GroupSubcommand { let sealed_bytes = hex::decode(&sealed).context("Invalid sealed hex")?; - // Resolve the account to get the VSK - let account_id: nssa::AccountId = account - .parse() - .context("Invalid account ID (use Private/)")?; - let (keychain, _) = wallet_core + let account_id: nssa::AccountId = account.parse().context("Invalid account ID")?; + let (keychain, _, _) = wallet_core .storage() .user_data .get_private_account(account_id) @@ -260,34 +183,10 @@ impl WalletSubcommand for GroupSubcommand { let holder = GroupKeyHolder::unseal(&sealed_bytes, &vsk) .map_err(|e| anyhow::anyhow!("Failed to unseal: {e:?}"))?; - let epoch = holder.epoch(); - wallet_core - .storage_mut() - .user_data - .insert_group_key_holder(name.clone(), holder); + wallet_core.insert_group_key_holder(name.clone(), holder); wallet_core.store_persistent_data().await?; - println!("Joined group '{name}' at epoch {epoch}"); - Ok(SubcommandReturnValue::Empty) - } - - Self::Ratchet { name } => { - let holder = wallet_core - .storage_mut() - .user_data - .group_key_holders - .get_mut(&name) - .context(format!("Group '{name}' not found"))?; - - let mut salt = [0_u8; 32]; - rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt); - holder.ratchet(salt); - - let epoch = holder.epoch(); - wallet_core.store_persistent_data().await?; - - println!("Ratcheted group '{name}' to epoch {epoch}"); - println!("Re-invite remaining members with 'group invite'"); + println!("Joined group '{name}'"); Ok(SubcommandReturnValue::Empty) } } diff --git a/wallet/src/cli/mod.rs b/wallet/src/cli/mod.rs index 1653e938..09cc1799 100644 --- a/wallet/src/cli/mod.rs +++ b/wallet/src/cli/mod.rs @@ -14,6 +14,7 @@ use crate::{ account::AccountSubcommand, chain::ChainSubcommand, config::ConfigSubcommand, + group::GroupSubcommand, programs::{ amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand, native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand, @@ -25,6 +26,7 @@ use crate::{ pub mod account; pub mod chain; pub mod config; +pub mod group; pub mod programs; pub(crate) trait WalletSubcommand { @@ -57,6 +59,9 @@ pub enum Command { /// Associated Token Account program interaction subcommand. #[command(subcommand)] Ata(AtaSubcommand), + /// Group key management (create, invite, join, derive keys). + #[command(subcommand)] + Group(GroupSubcommand), /// Check the wallet can connect to the node and builtin local programs /// match the remote versions. CheckHealth, @@ -164,6 +169,7 @@ pub async fn execute_subcommand( Command::Token(token_subcommand) => token_subcommand.handle_subcommand(wallet_core).await?, Command::AMM(amm_subcommand) => amm_subcommand.handle_subcommand(wallet_core).await?, Command::Ata(ata_subcommand) => ata_subcommand.handle_subcommand(wallet_core).await?, + Command::Group(group_subcommand) => group_subcommand.handle_subcommand(wallet_core).await?, Command::Config(config_subcommand) => { config_subcommand.handle_subcommand(wallet_core).await? } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index c8244ef9..7a293139 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -287,6 +287,41 @@ impl WalletCore { (account_id, cci) } + /// Insert a group key holder into storage. + pub fn insert_group_key_holder( + &mut self, + name: String, + holder: key_protocol::key_management::group_key_holder::GroupKeyHolder, + ) { + self.storage.user_data.insert_group_key_holder(name, holder); + } + + /// Remove a group key holder from storage. Returns the removed holder if it existed. + pub fn remove_group_key_holder( + &mut self, + name: &str, + ) -> Option { + self.storage.user_data.group_key_holders.remove(name) + } + + /// Register a shared account in storage for sync tracking. + pub fn register_shared_account( + &mut self, + account_id: AccountId, + group_label: String, + identifier: nssa_core::Identifier, + ) { + use key_protocol::key_protocol_core::SharedAccountEntry; + self.storage.user_data.shared_accounts.insert( + account_id, + SharedAccountEntry { + group_label, + identifier, + account: Account::default(), + }, + ); + } + /// Get account balance. pub async fn get_account_balance(&self, acc: AccountId) -> Result { Ok(self.sequencer_client.get_account_balance(acc).await?) diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index ba1a6a73..dfac8180 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -337,7 +337,7 @@ async fn private_pda_preparation( .user_data .shared_accounts .get(&account_id) - .cloned() + .map(|e| e.account.clone()) .unwrap_or_default(); let exists = acc != nssa_core::account::Account::default(); @@ -388,7 +388,7 @@ async fn private_shared_preparation( .user_data .shared_accounts .get(&account_id) - .cloned() + .map(|e| e.account.clone()) .unwrap_or_default(); let exists = acc != nssa_core::account::Account::default(); From d0a88e91e1453cabbcb862549eaa42f44e11ca0c Mon Sep 17 00:00:00 2001 From: Moudy Date: Tue, 5 May 2026 20:03:12 +0200 Subject: [PATCH 12/25] feat: extend sync to scan shared accounts with GMS-derived keys --- key_protocol/src/key_protocol_core/mod.rs | 6 +- wallet/src/cli/account.rs | 2 + wallet/src/lib.rs | 73 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index e17c35a7..7218ebde 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -22,11 +22,15 @@ pub struct UserPrivateAccountData { } /// Metadata for a shared account (GMS-derived), stored alongside the cached plaintext state. -/// The group label and identifier are needed to re-derive keys during sync. +/// The group label and identifier (or PDA seed) are needed to re-derive keys during sync. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SharedAccountEntry { pub group_label: String, pub identifier: Identifier, + /// For PDA accounts, the seed used to derive keys via `derive_keys_for_pda`. + /// `None` for regular shared accounts (keys derived from identifier via tag). + #[serde(default)] + pub pda_seed: Option, pub account: Account, } diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index 1355eb69..3bb7310b 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -220,6 +220,7 @@ impl WalletSubcommand for NewSubcommand { account_id, group_name.clone(), u128::MAX, + Some(pda_seed), ); println!("PDA shared account from group '{group_name}'"); @@ -259,6 +260,7 @@ impl WalletSubcommand for NewSubcommand { account_id, group_name.clone(), identifier, + None, ); println!("Shared account from group '{group_name}'"); diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 7a293139..f179ec44 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -310,6 +310,7 @@ impl WalletCore { account_id: AccountId, group_label: String, identifier: nssa_core::Identifier, + pda_seed: Option, ) { use key_protocol::key_protocol_core::SharedAccountEntry; self.storage.user_data.shared_accounts.insert( @@ -317,6 +318,7 @@ impl WalletCore { SharedAccountEntry { group_label, identifier, + pda_seed, account: Account::default(), }, ); @@ -592,6 +594,77 @@ impl WalletCore { self.storage .insert_private_account_data(affected_account_id, identifier, new_acc); } + + // Scan for updates to shared accounts (GMS-derived). + self.sync_shared_accounts_with_tx(&tx); + } + + fn sync_shared_accounts_with_tx(&mut self, tx: &PrivacyPreservingTransaction) { + let shared_keys: Vec<_> = self + .storage + .user_data + .shared_accounts + .iter() + .filter_map(|(&account_id, entry)| { + let holder = self + .storage + .user_data + .group_key_holders + .get(&entry.group_label)?; + + let keys = entry.pda_seed.as_ref().map_or_else( + || { + let tag = { + use sha2::Digest as _; + let mut hasher = sha2::Sha256::new(); + hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00"); + hasher.update(entry.identifier.to_le_bytes()); + let result: [u8; 32] = hasher.finalize().into(); + result + }; + holder.derive_keys_for_shared_account(&tag) + }, + |pda_seed| holder.derive_keys_for_pda(pda_seed), + ); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let vsk = keys.viewing_secret_key; + Some((account_id, npk, vpk, vsk)) + }) + .collect(); + + for (account_id, npk, vpk, vsk) in shared_keys { + let view_tag = EncryptedAccountData::compute_view_tag(&npk, &vpk); + + for (ciph_id, encrypted_data) in tx + .message() + .encrypted_private_post_states + .iter() + .enumerate() + { + if encrypted_data.view_tag != view_tag { + continue; + } + + let shared_secret = SharedSecretKey::new(&vsk, &encrypted_data.epk); + let commitment = &tx.message.new_commitments[ciph_id]; + + if let Some((_decrypted_identifier, new_acc)) = nssa_core::EncryptionScheme::decrypt( + &encrypted_data.ciphertext, + &shared_secret, + commitment, + ciph_id + .try_into() + .expect("Ciphertext ID is expected to fit in u32"), + ) { + info!("Synced shared account {account_id:#?} with new state {new_acc:#?}"); + if let Some(entry) = self.storage.user_data.shared_accounts.get_mut(&account_id) + { + entry.account = new_acc; + } + } + } + } } #[must_use] From 5bf24b191d0a2be1eaf5607b78e27c6fdd659e1a Mon Sep 17 00:00:00 2001 From: Moudy Date: Wed, 6 May 2026 00:15:05 +0200 Subject: [PATCH 13/25] test: add unit tests for SharedAccountEntry and shared account derivation --- integration_tests/tests/ata.rs | 4 + .../tests/auth_transfer/private.rs | 16 ++++ integration_tests/tests/keys_restoration.rs | 16 ++++ integration_tests/tests/pinata.rs | 8 ++ integration_tests/tests/token.rs | 48 +++++++++++ key_protocol/src/key_protocol_core/mod.rs | 83 +++++++++++++++++++ 6 files changed, 175 insertions(+) diff --git a/integration_tests/tests/ata.rs b/integration_tests/tests/ata.rs index 6f0bf05c..54ef5341 100644 --- a/integration_tests/tests/ata.rs +++ b/integration_tests/tests/ata.rs @@ -44,6 +44,10 @@ async fn new_private_account(ctx: &mut TestContext) -> Result { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index 8db5f8d4..6f05cdee 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -160,6 +160,10 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { // Create a new private account let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })); @@ -328,6 +332,10 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { // Create a new private account let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })); @@ -393,6 +401,10 @@ async fn initialize_private_account() -> Result<()> { let mut ctx = TestContext::new().await?; let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })); @@ -493,6 +505,10 @@ async fn initialize_private_account_using_label() -> Result<()> { // Create a new private account with a label let label = "init-private-label".to_owned(); let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: Some(label.clone()), })); diff --git a/integration_tests/tests/keys_restoration.rs b/integration_tests/tests/keys_restoration.rs index ff339120..8fae9808 100644 --- a/integration_tests/tests/keys_restoration.rs +++ b/integration_tests/tests/keys_restoration.rs @@ -30,6 +30,10 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { // Create a new private account let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })); @@ -40,6 +44,10 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -119,6 +127,10 @@ async fn restore_keys_from_seed() -> Result<()> { // Create first private account at root let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: Some(ChainIndex::root()), label: None, })); @@ -132,6 +144,10 @@ async fn restore_keys_from_seed() -> Result<()> { // Create second private account at /0 let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: Some(ChainIndex::from_str("/0")?), label: None, })); diff --git a/integration_tests/tests/pinata.rs b/integration_tests/tests/pinata.rs index 77c4a646..d4523f94 100644 --- a/integration_tests/tests/pinata.rs +++ b/integration_tests/tests/pinata.rs @@ -85,6 +85,10 @@ async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<() let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -229,6 +233,10 @@ async fn claim_pinata_to_new_private_account() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index 6db718f9..93786a57 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -297,6 +297,10 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -313,6 +317,10 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -460,6 +468,10 @@ async fn create_token_with_private_definition() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: Some(ChainIndex::root()), label: None, })), @@ -532,6 +544,10 @@ async fn create_token_with_private_definition() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -662,6 +678,10 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -678,6 +698,10 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -740,6 +764,10 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -855,6 +883,10 @@ async fn shielded_token_transfer() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -966,6 +998,10 @@ async fn deshielded_token_transfer() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -1077,6 +1113,10 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -1093,6 +1133,10 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), @@ -1126,6 +1170,10 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { + for_gms: None, + pda: false, + seed: None, + program_id: None, cci: None, label: None, })), diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index 7218ebde..ea8d8405 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -274,6 +274,89 @@ mod tests { fn group_key_holders_default_empty() { let user_data = NSSAUserData::default(); assert!(user_data.group_key_holders.is_empty()); + assert!(user_data.shared_accounts.is_empty()); + } + + #[test] + fn shared_account_entry_serde_round_trip() { + use nssa_core::program::PdaSeed; + + let entry = SharedAccountEntry { + group_label: String::from("test-group"), + identifier: 42, + pda_seed: None, + account: nssa_core::account::Account::default(), + }; + let encoded = bincode::serialize(&entry).expect("serialize"); + let decoded: SharedAccountEntry = bincode::deserialize(&encoded).expect("deserialize"); + assert_eq!(decoded.group_label, "test-group"); + assert_eq!(decoded.identifier, 42); + assert!(decoded.pda_seed.is_none()); + + let pda_entry = SharedAccountEntry { + group_label: String::from("pda-group"), + identifier: u128::MAX, + pda_seed: Some(PdaSeed::new([7_u8; 32])), + account: nssa_core::account::Account::default(), + }; + let pda_encoded = bincode::serialize(&pda_entry).expect("serialize pda"); + let pda_decoded: SharedAccountEntry = + bincode::deserialize(&pda_encoded).expect("deserialize pda"); + assert_eq!(pda_decoded.group_label, "pda-group"); + assert_eq!(pda_decoded.identifier, u128::MAX); + assert_eq!(pda_decoded.pda_seed.unwrap(), PdaSeed::new([7_u8; 32])); + } + + #[test] + fn shared_account_entry_none_pda_seed_round_trips() { + // Verify that an entry with pda_seed=None serializes and deserializes correctly, + // confirming the #[serde(default)] attribute works for backward compatibility. + let entry = SharedAccountEntry { + group_label: String::from("old"), + identifier: 1, + pda_seed: None, + account: nssa_core::account::Account::default(), + }; + let encoded = bincode::serialize(&entry).expect("serialize"); + let decoded: SharedAccountEntry = bincode::deserialize(&encoded).expect("deserialize"); + assert_eq!(decoded.group_label, "old"); + assert_eq!(decoded.identifier, 1); + assert!(decoded.pda_seed.is_none()); + } + + #[test] + fn shared_account_derives_consistent_keys_from_group() { + use nssa_core::program::PdaSeed; + + let mut user_data = NSSAUserData::default(); + let gms_holder = GroupKeyHolder::from_gms([42_u8; 32]); + user_data.insert_group_key_holder(String::from("my-group"), gms_holder); + + let holder = user_data.group_key_holder("my-group").unwrap(); + + // Regular shared account: derive via tag + let tag = [1_u8; 32]; + let keys_a = holder.derive_keys_for_shared_account(&tag); + let keys_b = holder.derive_keys_for_shared_account(&tag); + assert_eq!( + keys_a.generate_nullifier_public_key(), + keys_b.generate_nullifier_public_key(), + ); + + // PDA shared account: derive via seed + let seed = PdaSeed::new([2_u8; 32]); + let pda_keys_a = holder.derive_keys_for_pda(&seed); + let pda_keys_b = holder.derive_keys_for_pda(&seed); + assert_eq!( + pda_keys_a.generate_nullifier_public_key(), + pda_keys_b.generate_nullifier_public_key(), + ); + + // PDA and shared derivations don't collide + assert_ne!( + keys_a.generate_nullifier_public_key(), + pda_keys_a.generate_nullifier_public_key(), + ); } #[test] From f73cd6738f4b234b23e4a18ea506626e81ec8d0e Mon Sep 17 00:00:00 2001 From: Moudy Date: Wed, 6 May 2026 13:11:50 +0200 Subject: [PATCH 14/25] refactor: delegate to auth-transfer, add shared account test --- .../private_pda_spender.bin | Bin 406144 -> 403052 bytes .../privacy_preserving_transaction/circuit.rs | 178 +++++++++++------- .../guest/src/bin/private_pda_spender.rs | 113 +++++------ 3 files changed, 154 insertions(+), 137 deletions(-) diff --git a/artifacts/test_program_methods/private_pda_spender.bin b/artifacts/test_program_methods/private_pda_spender.bin index 70e4c5a0c753b6ae2b6839eed3cb947ce6871220..cc602ee4ec61debe74ecd28f84b0dce80bfcc7bd 100644 GIT binary patch delta 107574 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

0XB7F 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 delta 109713 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%0Dwb59j7Ll(SwH398lc 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 - let Ok([pda_pre, counterparty_pre]) = <[_; 2]>::try_from(pre_states.clone()) else { - panic!("expected exactly 2 pre_states: [group_pda, counterparty]"); - }; + if is_withdraw { + let Ok([pda_pre, recipient_pre]) = <[_; 2]>::try_from(pre_states.clone()) else { + panic!("expected exactly 2 pre_states for withdraw: [pda, recipient]"); + }; - if is_deposit { - // Deposit: claim PDA, transfer balance from counterparty to PDA. - // Both accounts must be owned by this program (or uninitialized) for - // validate_execution to allow balance changes. - assert!( - counterparty_pre.is_authorized, - "Counterparty must be authorized to deposit" - ); + // Post-states stay unchanged in this program. The actual balance transfer + // happens in the chained call to authenticated_transfer. + let pda_post = AccountPostState::new(pda_pre.account.clone()); + let recipient_post = AccountPostState::new(recipient_pre.account.clone()); - let mut pda_account = pda_pre.account; - let mut counterparty_account = counterparty_pre.account; - - pda_account.balance = pda_account - .balance - .checked_add(amount) - .expect("PDA balance overflow"); - counterparty_account.balance = counterparty_account - .balance - .checked_sub(amount) - .expect("Counterparty has insufficient balance"); - - let pda_post = AccountPostState::new_claimed_if_default(pda_account, Claim::Pda(pda_seed)); - let counterparty_post = AccountPostState::new(counterparty_account); + // Chain to authenticated_transfer with pda_seeds to authorize the PDA. + // The circuit's resolve_authorization_and_record_bindings establishes the + // mask-3 (seed, npk) binding when pda_seeds match the private PDA derivation. + let mut auth_pda_pre = pda_pre; + auth_pda_pre.is_authorized = true; + let auth_call = + ChainedCall::new(auth_transfer_id, vec![auth_pda_pre, recipient_pre], &amount) + .with_pda_seeds(vec![pda_seed]); ProgramOutput::new( self_program_id, caller_program_id, instruction_words, pre_states, - vec![pda_post, counterparty_post], + vec![pda_post, recipient_post], ) + .with_chained_calls(vec![auth_call]) .write(); } else { - // Spend: decrease PDA balance (owned by this program), increase counterparty. - // Chain to noop with pda_seeds to establish the mask-3 binding for the - // existing PDA. The noop's pre_states must match our post_states. - // Authorization is enforced by the circuit's binding check, not here. + // Init: initialize the PDA under authenticated_transfer's ownership. + let Ok([pda_pre]) = <[_; 1]>::try_from(pre_states.clone()) else { + panic!("expected exactly 1 pre_state for init: [pda]"); + }; - let mut pda_account = pda_pre.account.clone(); - let mut counterparty_account = counterparty_pre.account.clone(); + let pda_post = AccountPostState::new(pda_pre.account.clone()); - pda_account.balance = pda_account - .balance - .checked_sub(amount) - .expect("PDA has insufficient balance"); - counterparty_account.balance = counterparty_account - .balance - .checked_add(amount) - .expect("Counterparty balance overflow"); - - let pda_post = AccountPostState::new(pda_account.clone()); - let counterparty_post = AccountPostState::new(counterparty_account.clone()); - - // Chain to noop solely to establish the mask-3 binding via pda_seeds. - let mut noop_pda_pre = pda_pre; - noop_pda_pre.account = pda_account; - noop_pda_pre.is_authorized = true; - - let mut noop_counterparty_pre = counterparty_pre; - noop_counterparty_pre.account = counterparty_account; - - let noop_call = ChainedCall::new(noop_id, vec![noop_pda_pre, noop_counterparty_pre], &()) + // Chain to authenticated_transfer with instruction=0 (init path) and pda_seeds + // to authorize the PDA. authenticated_transfer will claim it with Claim::Authorized. + let mut auth_pda_pre = pda_pre; + auth_pda_pre.is_authorized = true; + let auth_call = ChainedCall::new(auth_transfer_id, vec![auth_pda_pre], &0_u128) .with_pda_seeds(vec![pda_seed]); ProgramOutput::new( @@ -110,9 +89,9 @@ fn main() { caller_program_id, instruction_words, pre_states, - vec![pda_post, counterparty_post], + vec![pda_post], ) - .with_chained_calls(vec![noop_call]) + .with_chained_calls(vec![auth_call]) .write(); } } From 6054ae113a0d2c4cbced51c1b7e9209a6e5a8851 Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Thu, 7 May 2026 14:29:06 +0300 Subject: [PATCH 15/25] fix(indexer_ffi): suggestion fix --- indexer_ffi/Cargo.toml | 2 +- indexer_ffi/src/api/types/account.rs | 55 ++++++++++-- indexer_ffi/src/api/types/block.rs | 51 ++++++------ indexer_ffi/src/api/types/transaction.rs | 101 ++++++++++++++--------- integration_tests/tests/indexer_ffi.rs | 6 +- 5 files changed, 138 insertions(+), 77 deletions(-) diff --git a/indexer_ffi/Cargo.toml b/indexer_ffi/Cargo.toml index 1deb8fad..1e6b1468 100644 --- a/indexer_ffi/Cargo.toml +++ b/indexer_ffi/Cargo.toml @@ -7,7 +7,7 @@ version = "0.1.0" [dependencies] nssa.workspace = true indexer_service.workspace = true -indexer_service_rpc.workspace = true +indexer_service_rpc = { workspace = true, features = ["client"] } indexer_service_protocol.workspace = true url.workspace = true diff --git a/indexer_ffi/src/api/types/account.rs b/indexer_ffi/src/api/types/account.rs index 7893657b..6c35347f 100644 --- a/indexer_ffi/src/api/types/account.rs +++ b/indexer_ffi/src/api/types/account.rs @@ -7,7 +7,6 @@ use crate::api::types::{FfiBytes32, FfiProgramId, FfiU128}; /// Note: `balance` and `nonce` are u128 values represented as little-endian /// byte arrays since C doesn't have native u128 support. #[repr(C)] -#[derive(Clone)] pub struct FfiAccount { pub program_owner: FfiProgramId, /// Balance as little-endian [u8; 16]. @@ -32,31 +31,69 @@ impl From<&nssa::AccountId> for FfiBytes32 { impl From for FfiAccount { fn from(value: nssa::Account) -> Self { - let (data, data_len, data_cap) = value.data.into_inner().into_raw_parts(); + let nssa::Account { + program_owner, + balance, + data, + nonce, + } = value; + + let (data, data_len, data_cap) = data.into_inner().into_raw_parts(); let program_owner = FfiProgramId { - data: value.program_owner, + data: program_owner, }; Self { program_owner, - balance: value.balance.into(), + balance: balance.into(), data, data_len, data_cap, - nonce: value.nonce.0.into(), + nonce: nonce.0.into(), } } } impl From for indexer_service_protocol::Account { fn from(value: FfiAccount) -> Self { + let FfiAccount { + program_owner, + balance, + data, + data_cap, + data_len, + nonce, + } = value; + Self { - program_owner: ProgramId(value.program_owner.data), - balance: value.balance.into(), + program_owner: ProgramId(program_owner.data), + balance: balance.into(), data: indexer_service_protocol::Data(unsafe { - Vec::from_raw_parts(value.data, value.data_len, value.data_cap) + Vec::from_raw_parts(data, data_len, data_cap) }), - nonce: value.nonce.into(), + nonce: nonce.into(), + } + } +} + +impl From<&FfiAccount> for indexer_service_protocol::Account { + fn from(value: &FfiAccount) -> Self { + let &FfiAccount { + program_owner, + balance, + data, + data_cap, + data_len, + nonce, + } = value; + + Self { + program_owner: ProgramId(program_owner.data), + balance: balance.into(), + data: indexer_service_protocol::Data(unsafe { + Vec::from_raw_parts(data, data_len, data_cap) + }), + nonce: nonce.into(), } } } diff --git a/indexer_ffi/src/api/types/block.rs b/indexer_ffi/src/api/types/block.rs index f7e0c778..bca2fdb5 100644 --- a/indexer_ffi/src/api/types/block.rs +++ b/indexer_ffi/src/api/types/block.rs @@ -17,38 +17,27 @@ pub struct FfiBlock { impl From for FfiBlock { fn from(value: Block) -> Self { + let Block { + header, + body, + bedrock_status, + bedrock_parent_id, + } = value; + Self { - header: value.header.into(), - body: value - .body + header: header.into(), + body: body .transactions .into_iter() .map(Into::into) .collect::>() .into(), - bedrock_status: value.bedrock_status.into(), - bedrock_parent_id: value.bedrock_parent_id.into(), + bedrock_status: bedrock_status.into(), + bedrock_parent_id: bedrock_parent_id.into(), } } } -// impl From> for Block { -// fn from(value: Box) -> Self { -// Self { -// header: BlockHeader { -// block_id: value.header.block_id, -// prev_block_hash: HashType(value.header.prev_block_hash.data), -// hash: HashType(value.header.hash.data), -// timestamp: value.header.timestamp, -// signature: Signature(value.header.signature.data), -// }, -// body: (), -// bedrock_status: value.bedrock_status.into(), -// bedrock_parent_id: MantleMsgId(value.bedrock_parent_id.data), -// } -// } -// } - pub type FfiBlockOpt = FfiOption; #[repr(C)] @@ -62,12 +51,20 @@ pub struct FfiBlockHeader { impl From for FfiBlockHeader { fn from(value: BlockHeader) -> Self { + let BlockHeader { + block_id, + prev_block_hash, + hash, + timestamp, + signature, + } = value; + Self { - block_id: value.block_id, - prev_block_hash: value.prev_block_hash.into(), - hash: value.hash.into(), - timestamp: value.timestamp, - signature: value.signature.into(), + block_id, + prev_block_hash: prev_block_hash.into(), + hash: hash.into(), + timestamp, + signature: signature.into(), } } } diff --git a/indexer_ffi/src/api/types/transaction.rs b/indexer_ffi/src/api/types/transaction.rs index ff14276f..ee3bd01b 100644 --- a/indexer_ffi/src/api/types/transaction.rs +++ b/indexer_ffi/src/api/types/transaction.rs @@ -24,11 +24,16 @@ pub struct FfiPublicTransactionBody { impl From for FfiPublicTransactionBody { fn from(value: PublicTransaction) -> Self { + let PublicTransaction { + hash, + message, + witness_set, + } = value; + Self { - hash: value.hash.into(), - message: value.message.into(), - witness_set: value - .witness_set + hash: hash.into(), + message: message.into(), + witness_set: witness_set .signatures_and_public_keys .into_iter() .map(Into::into) @@ -88,21 +93,26 @@ pub struct FfiPublicMessage { impl From for FfiPublicMessage { fn from(value: PublicMessage) -> Self { + let PublicMessage { + program_id, + account_ids, + nonces, + instruction_data, + } = value; + Self { - program_id: value.program_id.into(), - account_ids: value - .account_ids + program_id: program_id.into(), + account_ids: account_ids .into_iter() .map(Into::into) .collect::>() .into(), - nonces: value - .nonces + nonces: nonces .into_iter() .map(Into::into) .collect::>() .into(), - instruction_data: value.instruction_data.into(), + instruction_data: instruction_data.into(), } } } @@ -117,18 +127,22 @@ pub struct FfiPrivateTransactionBody { impl From for FfiPrivateTransactionBody { fn from(value: PrivacyPreservingTransaction) -> Self { + let PrivacyPreservingTransaction { + hash, + message, + witness_set, + } = value; + Self { - hash: value.hash.into(), - message: value.message.into(), - witness_set: value - .witness_set + hash: hash.into(), + message: message.into(), + witness_set: witness_set .signatures_and_public_keys .into_iter() .map(Into::into) .collect::>() .into(), - proof: value - .witness_set + proof: witness_set .proof .expect("Private execution: proof must be present") .0 @@ -229,21 +243,29 @@ pub struct FfiPrivacyPreservingMessage { impl From for FfiPrivacyPreservingMessage { fn from(value: PrivacyPreservingMessage) -> Self { + let PrivacyPreservingMessage { + public_account_ids, + nonces, + public_post_states, + encrypted_private_post_states, + new_commitments, + new_nullifiers, + block_validity_window, + timestamp_validity_window, + } = value; + Self { - public_account_ids: value - .public_account_ids + public_account_ids: public_account_ids .into_iter() .map(Into::into) .collect::>() .into(), - nonces: value - .nonces + nonces: nonces .into_iter() .map(Into::into) .collect::>() .into(), - public_post_states: value - .public_post_states + public_post_states: public_post_states .into_iter() .map(|acc_ind| -> nssa::Account { acc_ind.try_into().expect("Source is in blocks, must fit") @@ -251,26 +273,23 @@ impl From for FfiPrivacyPreservingMessage { .map(Into::into) .collect::>() .into(), - encrypted_private_post_states: value - .encrypted_private_post_states + encrypted_private_post_states: encrypted_private_post_states .into_iter() .map(Into::into) .collect::>() .into(), - new_commitments: value - .new_commitments + new_commitments: new_commitments .into_iter() .map(|comm| FfiBytes32 { data: comm.0 }) .collect::>() .into(), - new_nullifiers: value - .new_nullifiers + new_nullifiers: new_nullifiers .into_iter() .map(Into::into) .collect::>() .into(), - block_validity_window: cast_validity_window(value.block_validity_window), - timestamp_validity_window: cast_validity_window(value.timestamp_validity_window), + block_validity_window: cast_validity_window(block_validity_window), + timestamp_validity_window: cast_validity_window(timestamp_validity_window), } } } @@ -299,10 +318,16 @@ pub struct FfiEncryptedAccountData { impl From for FfiEncryptedAccountData { fn from(value: EncryptedAccountData) -> Self { + let EncryptedAccountData { + ciphertext, + epk, + view_tag, + } = value; + Self { - ciphertext: value.ciphertext.0.into(), - epk: value.epk.0.into(), - view_tag: value.view_tag, + ciphertext: ciphertext.0.into(), + epk: epk.0.into(), + view_tag, } } } @@ -341,9 +366,11 @@ impl From> for ProgramDeploymentTransac impl From for FfiProgramDeploymentTransactionBody { fn from(value: ProgramDeploymentTransaction) -> Self { + let ProgramDeploymentTransaction { hash, message } = value; + Self { - hash: value.hash.into(), - message: value.message.bytecode.into(), + hash: hash.into(), + message: message.bytecode.into(), } } } @@ -378,7 +405,7 @@ impl From for FfiTransaction { private_body: Box::into_raw(Box::new(priv_tx.into())), program_deployment_body: std::ptr::null_mut(), }, - kind: FfiTransactionKind::Public, + kind: FfiTransactionKind::Private, }, Transaction::ProgramDeployment(pr_dep_tx) => Self { body: FfiTransactionBody { @@ -386,7 +413,7 @@ impl From for FfiTransaction { private_body: std::ptr::null_mut(), program_deployment_body: Box::into_raw(Box::new(pr_dep_tx.into())), }, - kind: FfiTransactionKind::Public, + kind: FfiTransactionKind::ProgramDeploy, }, } } diff --git a/integration_tests/tests/indexer_ffi.rs b/integration_tests/tests/indexer_ffi.rs index 96196fbd..bbc329e3 100644 --- a/integration_tests/tests/indexer_ffi.rs +++ b/integration_tests/tests/indexer_ffi.rs @@ -218,7 +218,7 @@ fn indexer_ffi_state_consistency() -> Result<()> { assert!(acc1_ind_state_ffi.error.is_ok()); - let acc1_ind_state_pre = unsafe { &*acc1_ind_state_ffi.value }.clone(); + let acc1_ind_state_pre = unsafe { &*acc1_ind_state_ffi.value }; let acc1_ind_state: indexer_service_protocol::Account = acc1_ind_state_pre.into(); let acc2_ind_state_ffi = @@ -226,7 +226,7 @@ fn indexer_ffi_state_consistency() -> Result<()> { assert!(acc2_ind_state_ffi.error.is_ok()); - let acc2_ind_state_pre = unsafe { &*acc2_ind_state_ffi.value }.clone(); + let acc2_ind_state_pre = unsafe { &*acc2_ind_state_ffi.value }; let acc2_ind_state: indexer_service_protocol::Account = acc2_ind_state_pre.into(); info!("Checking correct state transition"); @@ -320,7 +320,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> { assert!(acc1_ind_state_ffi.error.is_ok()); - let acc1_ind_state_pre = unsafe { &*acc1_ind_state_ffi.value }.clone(); + let acc1_ind_state_pre = unsafe { &*acc1_ind_state_ffi.value }; let acc1_ind_state: indexer_service_protocol::Account = acc1_ind_state_pre.into(); let acc1_seq_state = From 45ccf14e7720216368d84199006243e3768bcb02 Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Thu, 7 May 2026 14:32:03 +0300 Subject: [PATCH 16/25] fix(indexer_ffi): move into indexer --- Cargo.toml | 4 ++-- {indexer_ffi => indexer/ffi}/Cargo.toml | 0 {indexer_ffi => indexer/ffi}/build.rs | 0 {indexer_ffi => indexer/ffi}/cbindgen.toml | 0 {indexer_ffi => indexer/ffi}/indexer_ffi.h | 0 {indexer_ffi => indexer/ffi}/src/api/client.rs | 0 {indexer_ffi => indexer/ffi}/src/api/lifecycle.rs | 0 {indexer_ffi => indexer/ffi}/src/api/memory.rs | 0 {indexer_ffi => indexer/ffi}/src/api/mod.rs | 0 {indexer_ffi => indexer/ffi}/src/api/query.rs | 0 {indexer_ffi => indexer/ffi}/src/api/result.rs | 0 {indexer_ffi => indexer/ffi}/src/api/types/account.rs | 0 {indexer_ffi => indexer/ffi}/src/api/types/block.rs | 0 {indexer_ffi => indexer/ffi}/src/api/types/mod.rs | 0 {indexer_ffi => indexer/ffi}/src/api/types/transaction.rs | 0 {indexer_ffi => indexer/ffi}/src/api/types/vectors.rs | 0 {indexer_ffi => indexer/ffi}/src/client.rs | 0 {indexer_ffi => indexer/ffi}/src/errors.rs | 0 {indexer_ffi => indexer/ffi}/src/indexer.rs | 0 {indexer_ffi => indexer/ffi}/src/lib.rs | 0 20 files changed, 2 insertions(+), 2 deletions(-) rename {indexer_ffi => indexer/ffi}/Cargo.toml (100%) rename {indexer_ffi => indexer/ffi}/build.rs (100%) rename {indexer_ffi => indexer/ffi}/cbindgen.toml (100%) rename {indexer_ffi => indexer/ffi}/indexer_ffi.h (100%) rename {indexer_ffi => indexer/ffi}/src/api/client.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/lifecycle.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/memory.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/mod.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/query.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/result.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/types/account.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/types/block.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/types/mod.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/types/transaction.rs (100%) rename {indexer_ffi => indexer/ffi}/src/api/types/vectors.rs (100%) rename {indexer_ffi => indexer/ffi}/src/client.rs (100%) rename {indexer_ffi => indexer/ffi}/src/errors.rs (100%) rename {indexer_ffi => indexer/ffi}/src/indexer.rs (100%) rename {indexer_ffi => indexer/ffi}/src/lib.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 4fcaf9f6..1bce967f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ members = [ "examples/program_deployment/methods", "examples/program_deployment/methods/guest", "testnet_initial_state", - "indexer_ffi", + "indexer/ffi", ] [workspace.dependencies] @@ -57,7 +57,7 @@ indexer_service_protocol = { path = "indexer/service/protocol" } indexer_service_rpc = { path = "indexer/service/rpc" } wallet = { path = "wallet" } wallet-ffi = { path = "wallet-ffi", default-features = false } -indexer_ffi = { path = "indexer_ffi" } +indexer_ffi = { path = "indexer/ffi" } clock_core = { path = "programs/clock/core" } token_core = { path = "programs/token/core" } token_program = { path = "programs/token" } diff --git a/indexer_ffi/Cargo.toml b/indexer/ffi/Cargo.toml similarity index 100% rename from indexer_ffi/Cargo.toml rename to indexer/ffi/Cargo.toml diff --git a/indexer_ffi/build.rs b/indexer/ffi/build.rs similarity index 100% rename from indexer_ffi/build.rs rename to indexer/ffi/build.rs diff --git a/indexer_ffi/cbindgen.toml b/indexer/ffi/cbindgen.toml similarity index 100% rename from indexer_ffi/cbindgen.toml rename to indexer/ffi/cbindgen.toml diff --git a/indexer_ffi/indexer_ffi.h b/indexer/ffi/indexer_ffi.h similarity index 100% rename from indexer_ffi/indexer_ffi.h rename to indexer/ffi/indexer_ffi.h diff --git a/indexer_ffi/src/api/client.rs b/indexer/ffi/src/api/client.rs similarity index 100% rename from indexer_ffi/src/api/client.rs rename to indexer/ffi/src/api/client.rs diff --git a/indexer_ffi/src/api/lifecycle.rs b/indexer/ffi/src/api/lifecycle.rs similarity index 100% rename from indexer_ffi/src/api/lifecycle.rs rename to indexer/ffi/src/api/lifecycle.rs diff --git a/indexer_ffi/src/api/memory.rs b/indexer/ffi/src/api/memory.rs similarity index 100% rename from indexer_ffi/src/api/memory.rs rename to indexer/ffi/src/api/memory.rs diff --git a/indexer_ffi/src/api/mod.rs b/indexer/ffi/src/api/mod.rs similarity index 100% rename from indexer_ffi/src/api/mod.rs rename to indexer/ffi/src/api/mod.rs diff --git a/indexer_ffi/src/api/query.rs b/indexer/ffi/src/api/query.rs similarity index 100% rename from indexer_ffi/src/api/query.rs rename to indexer/ffi/src/api/query.rs diff --git a/indexer_ffi/src/api/result.rs b/indexer/ffi/src/api/result.rs similarity index 100% rename from indexer_ffi/src/api/result.rs rename to indexer/ffi/src/api/result.rs diff --git a/indexer_ffi/src/api/types/account.rs b/indexer/ffi/src/api/types/account.rs similarity index 100% rename from indexer_ffi/src/api/types/account.rs rename to indexer/ffi/src/api/types/account.rs diff --git a/indexer_ffi/src/api/types/block.rs b/indexer/ffi/src/api/types/block.rs similarity index 100% rename from indexer_ffi/src/api/types/block.rs rename to indexer/ffi/src/api/types/block.rs diff --git a/indexer_ffi/src/api/types/mod.rs b/indexer/ffi/src/api/types/mod.rs similarity index 100% rename from indexer_ffi/src/api/types/mod.rs rename to indexer/ffi/src/api/types/mod.rs diff --git a/indexer_ffi/src/api/types/transaction.rs b/indexer/ffi/src/api/types/transaction.rs similarity index 100% rename from indexer_ffi/src/api/types/transaction.rs rename to indexer/ffi/src/api/types/transaction.rs diff --git a/indexer_ffi/src/api/types/vectors.rs b/indexer/ffi/src/api/types/vectors.rs similarity index 100% rename from indexer_ffi/src/api/types/vectors.rs rename to indexer/ffi/src/api/types/vectors.rs diff --git a/indexer_ffi/src/client.rs b/indexer/ffi/src/client.rs similarity index 100% rename from indexer_ffi/src/client.rs rename to indexer/ffi/src/client.rs diff --git a/indexer_ffi/src/errors.rs b/indexer/ffi/src/errors.rs similarity index 100% rename from indexer_ffi/src/errors.rs rename to indexer/ffi/src/errors.rs diff --git a/indexer_ffi/src/indexer.rs b/indexer/ffi/src/indexer.rs similarity index 100% rename from indexer_ffi/src/indexer.rs rename to indexer/ffi/src/indexer.rs diff --git a/indexer_ffi/src/lib.rs b/indexer/ffi/src/lib.rs similarity index 100% rename from indexer_ffi/src/lib.rs rename to indexer/ffi/src/lib.rs From 69b81ea6217a5bc03875c76cdb7fd8ddfc325a60 Mon Sep 17 00:00:00 2001 From: Moudy Date: Thu, 7 May 2026 17:35:51 +0200 Subject: [PATCH 17/25] fix: address review feedback, persist group data in wallet storage --- integration_tests/tests/shared_accounts.rs | 210 ++++++++++++++++++ .../src/key_management/group_key_holder.rs | 89 ++++---- key_protocol/src/key_protocol_core/mod.rs | 19 +- wallet/src/cli/account.rs | 92 ++------ wallet/src/config.rs | 12 + wallet/src/helperfunctions.rs | 2 + wallet/src/lib.rs | 117 ++++++++-- wallet/src/privacy_preserving_tx.rs | 4 +- 8 files changed, 411 insertions(+), 134 deletions(-) create mode 100644 integration_tests/tests/shared_accounts.rs diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs new file mode 100644 index 00000000..b91502d1 --- /dev/null +++ b/integration_tests/tests/shared_accounts.rs @@ -0,0 +1,210 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "Integration test file, not inside a #[cfg(test)] module" +)] + +//! Shared account integration tests. +//! +//! Demonstrates: +//! 1. Group creation and GMS distribution via seal/unseal. +//! 2. Shared regular private account creation via `--for-gms`. +//! 3. Funding a shared account from a public account. +//! 4. Syncing discovers the funded shared account state. + +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, format_public_account_id}; +use log::info; +use tokio::test; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + group::GroupSubcommand, + programs::native_token_transfer::AuthTransferSubcommand, +}; + +/// Create a group, create a shared account from it, and verify registration. +#[test] +async fn group_create_and_shared_account_registration() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Create a group + let command = Command::Group(GroupSubcommand::New { + name: "test-group".to_string(), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + // Verify group exists + assert!( + ctx.wallet() + .storage() + .user_data + .group_key_holder("test-group") + .is_some() + ); + + // Create a shared regular private account from the group + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + cci: None, + label: Some("shared-acc".to_string()), + for_gms: Some("test-group".to_string()), + pda: false, + seed: None, + program_id: None, + })); + + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::RegisterAccount { + account_id: shared_account_id, + } = result + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // Verify shared account is registered in storage + let shared_private_accounts = &ctx.wallet().storage().user_data.shared_private_accounts; + let entry = shared_private_accounts + .get(&shared_account_id) + .context("Shared account not found in storage")?; + assert_eq!(entry.group_label, "test-group"); + assert!(entry.pda_seed.is_none()); + + info!("Shared account registered: {shared_account_id}"); + Ok(()) +} + +/// GMS seal/unseal round-trip: export GMS, re-import under a new name, verify key agreement. +#[test] +async fn group_export_import_key_agreement() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Create a group + let command = Command::Group(GroupSubcommand::New { + name: "alice-group".to_string(), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + // Export the GMS + let holder = ctx + .wallet() + .storage() + .user_data + .group_key_holder("alice-group") + .context("Group not found")?; + let gms_hex = hex::encode(holder.dangerous_raw_gms()); + + // Import under a different name (simulating Bob receiving the GMS) + let command = Command::Group(GroupSubcommand::Import { + name: "bob-copy".to_string(), + gms: gms_hex, + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + // Both derive the same keys for the same tag + let alice_holder = ctx + .wallet() + .storage() + .user_data + .group_key_holder("alice-group") + .unwrap(); + let bob_holder = ctx + .wallet() + .storage() + .user_data + .group_key_holder("bob-copy") + .unwrap(); + + let tag = [42_u8; 32]; + let alice_npk = alice_holder + .derive_keys_for_shared_account(&tag) + .generate_nullifier_public_key(); + let bob_npk = bob_holder + .derive_keys_for_shared_account(&tag) + .generate_nullifier_public_key(); + + assert_eq!( + alice_npk, bob_npk, + "Key agreement: same GMS produces same keys" + ); + + info!("Key agreement verified"); + Ok(()) +} + +/// Fund a shared account from a public account via auth-transfer, then sync. +#[test] +async fn fund_shared_account_from_public() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Create group and shared account + let command = Command::Group(GroupSubcommand::New { + name: "fund-group".to_string(), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + cci: None, + label: None, + for_gms: Some("fund-group".to_string()), + pda: false, + seed: None, + program_id: None, + })); + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::RegisterAccount { + account_id: shared_id, + } = result + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // Initialize the shared account under auth-transfer + let command = Command::AuthTransfer(AuthTransferSubcommand::Init { + account_id: Some(format!("Private/{shared_id}")), + account_label: None, + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + // Fund from a public account + let from_public = ctx.existing_public_accounts()[0]; + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { + from: Some(format_public_account_id(from_public)), + from_label: None, + to: Some(format!("Private/{shared_id}")), + to_label: None, + to_npk: None, + to_vpk: None, + to_identifier: None, + amount: 100, + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + // Sync private accounts + let command = Command::Account(AccountSubcommand::SyncPrivate); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + // Verify the shared account was updated + let entry = ctx + .wallet() + .storage() + .user_data + .shared_private_accounts + .get(&shared_id) + .context("Shared account not found after sync")?; + + info!( + "Shared account balance after funding: {}", + entry.account.balance + ); + assert_eq!( + entry.account.balance, 100, + "Shared account should have received 100" + ); + + Ok(()) +} diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs index e6634f88..533906a1 100644 --- a/key_protocol/src/key_management/group_key_holder.rs +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -2,7 +2,7 @@ use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _}; use nssa_core::{ SharedSecretKey, encryption::{Scalar, shared_key_derivation::Secp256k1Point}, - program::PdaSeed, + program::{PdaSeed, ProgramId}, }; use rand::{RngCore as _, rngs::OsRng}; use serde::{Deserialize, Serialize}; @@ -83,40 +83,51 @@ impl GroupKeyHolder { /// Derive a per-PDA [`SecretSpendingKey`] by mixing the seed into the SHA-256 input. /// - /// Each distinct `pda_seed` produces a distinct SSK in the full 256-bit space, so - /// adversarial seed-grinding cannot collide two PDAs' derived keys under the same + /// Each distinct `(program_id, pda_seed)` pair produces a distinct SSK in the full 256-bit + /// space, so adversarial seed-grinding cannot collide two PDAs' derived keys under the same /// group. Uses the codebase's 32-byte protocol-versioned domain-separation convention. - fn secret_spending_key_for_pda(&self, pda_seed: &PdaSeed) -> SecretSpendingKey { + fn secret_spending_key_for_pda( + &self, + program_id: &ProgramId, + pda_seed: &PdaSeed, + ) -> SecretSpendingKey { const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SSK"; let mut hasher = sha2::Sha256::new(); hasher.update(PREFIX); hasher.update(self.gms); + for word in program_id { + hasher.update(word.to_le_bytes()); + } hasher.update(pda_seed.as_ref()); SecretSpendingKey(hasher.finalize_fixed().into()) } - /// Derive keys for a specific PDA. + /// Derive keys for a specific PDA under a given program. /// /// All controllers holding the same GMS independently derive the same keys for the - /// same PDA because the derivation is deterministic in (GMS, seed). + /// same `(program_id, seed)` because the derivation is deterministic. #[must_use] - pub fn derive_keys_for_pda(&self, pda_seed: &PdaSeed) -> PrivateKeyHolder { - self.secret_spending_key_for_pda(pda_seed) + pub fn derive_keys_for_pda( + &self, + program_id: &ProgramId, + pda_seed: &PdaSeed, + ) -> PrivateKeyHolder { + self.secret_spending_key_for_pda(program_id, pda_seed) .produce_private_key_holder(None) } /// Derive keys for a shared regular (non-PDA) private account. /// /// Uses a distinct domain separator from `derive_keys_for_pda` to prevent cross-domain - /// key collisions. The `tag` should be a stable, unique 32-byte value (e.g. derived from - /// a random identifier at account creation time). + /// key collisions. The `derivation_seed` should be a stable, unique 32-byte value + /// (e.g. derived deterministically from the account's identifier). #[must_use] - pub fn derive_keys_for_shared_account(&self, tag: &[u8; 32]) -> PrivateKeyHolder { + pub fn derive_keys_for_shared_account(&self, derivation_seed: &[u8; 32]) -> PrivateKeyHolder { const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SHA"; let mut hasher = sha2::Sha256::new(); hasher.update(PREFIX); hasher.update(self.gms); - hasher.update(tag); + hasher.update(derivation_seed); SecretSpendingKey(hasher.finalize_fixed().into()).produce_private_key_holder(None) } @@ -210,6 +221,8 @@ mod tests { use super::*; + const TEST_PROGRAM_ID: ProgramId = [9; 8]; + /// Two holders from the same GMS derive identical keys for the same PDA seed. #[test] fn same_gms_same_seed_produces_same_keys() { @@ -218,8 +231,8 @@ mod tests { let holder_b = GroupKeyHolder::from_gms(gms); let seed = PdaSeed::new([1; 32]); - let keys_a = holder_a.derive_keys_for_pda(&seed); - let keys_b = holder_b.derive_keys_for_pda(&seed); + let keys_a = holder_a.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed); + let keys_b = holder_b.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed); assert_eq!( keys_a.generate_nullifier_public_key().to_byte_array(), @@ -235,10 +248,10 @@ mod tests { let seed_b = PdaSeed::new([2; 32]); let npk_a = holder - .derive_keys_for_pda(&seed_a) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed_a) .generate_nullifier_public_key(); let npk_b = holder - .derive_keys_for_pda(&seed_b) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed_b) .generate_nullifier_public_key(); assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array()); @@ -252,10 +265,10 @@ mod tests { let seed = PdaSeed::new([1; 32]); let npk_a = holder_a - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); let npk_b = holder_b - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array()); @@ -269,10 +282,10 @@ mod tests { let seed = PdaSeed::new([1; 32]); let npk_original = original - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); let npk_restored = restored - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); assert_eq!(npk_original.to_byte_array(), npk_restored.to_byte_array()); @@ -284,7 +297,7 @@ mod tests { let holder = GroupKeyHolder::from_gms([42_u8; 32]); let seed = PdaSeed::new([1; 32]); let npk = holder - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); assert_ne!(npk, NullifierPublicKey([0; 32])); @@ -304,7 +317,7 @@ mod tests { let holder = GroupKeyHolder::from_gms(gms); let npk = holder - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); let account_id = AccountId::for_private_pda(&program_id, &seed, &npk); @@ -333,10 +346,10 @@ mod tests { let seed = PdaSeed::new([1; 32]); let npk_original = original - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); let npk_restored = restored - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); assert_eq!(npk_original, npk_restored); @@ -354,7 +367,7 @@ mod tests { let seed = PdaSeed::new([5; 32]); let group_npk = GroupKeyHolder::from_gms(shared_bytes) - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); let personal_npk = SecretSpendingKey(shared_bytes) @@ -382,10 +395,10 @@ mod tests { let seed = PdaSeed::new([1; 32]); assert_eq!( holder - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(), restored - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(), ); } @@ -468,7 +481,7 @@ mod tests { .iter() .map(|gms| { GroupKeyHolder::from_gms(*gms) - .derive_keys_for_pda(&seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key() }) .collect(); @@ -493,7 +506,7 @@ mod tests { let program_id: nssa_core::program::ProgramId = [1; 8]; // Derive Alice's keys - let alice_keys = alice_holder.derive_keys_for_pda(&pda_seed); + let alice_keys = alice_holder.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed); let alice_npk = alice_keys.generate_nullifier_public_key(); // Seal GMS for Bob using Bob's viewing key, Bob unseals @@ -508,7 +521,7 @@ mod tests { // Key agreement: both derive identical NPK and AccountId let bob_npk = bob_holder - .derive_keys_for_pda(&pda_seed) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed) .generate_nullifier_public_key(); assert_eq!(alice_npk, bob_npk); @@ -517,27 +530,27 @@ mod tests { assert_eq!(alice_account_id, bob_account_id); } - /// Same GMS + same tag produces same keys for shared accounts. + /// Same GMS + same derivation seed produces same keys for shared accounts. #[test] - fn shared_account_same_gms_same_tag_produces_same_keys() { + fn shared_account_same_gms_same_seed_produces_same_keys() { let gms = [42_u8; 32]; - let tag = [1_u8; 32]; + let derivation_seed = [1_u8; 32]; let holder_a = GroupKeyHolder::from_gms(gms); let holder_b = GroupKeyHolder::from_gms(gms); let npk_a = holder_a - .derive_keys_for_shared_account(&tag) + .derive_keys_for_shared_account(&derivation_seed) .generate_nullifier_public_key(); let npk_b = holder_b - .derive_keys_for_shared_account(&tag) + .derive_keys_for_shared_account(&derivation_seed) .generate_nullifier_public_key(); assert_eq!(npk_a, npk_b); } - /// Different tags produce different keys for shared accounts. + /// Different derivation seeds produce different keys for shared accounts. #[test] - fn shared_account_different_tags_produce_different_keys() { + fn shared_account_different_seeds_produce_different_keys() { let holder = GroupKeyHolder::from_gms([42_u8; 32]); let npk_a = holder .derive_keys_for_shared_account(&[1_u8; 32]) @@ -556,7 +569,7 @@ mod tests { let bytes = [1_u8; 32]; let pda_npk = holder - .derive_keys_for_pda(&PdaSeed::new(bytes)) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &PdaSeed::new(bytes)) .generate_nullifier_public_key(); let shared_npk = holder .derive_keys_for_shared_account(&bytes) diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index ea8d8405..a18c1d3a 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -27,10 +27,12 @@ pub struct UserPrivateAccountData { pub struct SharedAccountEntry { pub group_label: String, pub identifier: Identifier, - /// For PDA accounts, the seed used to derive keys via `derive_keys_for_pda`. - /// `None` for regular shared accounts (keys derived from identifier via tag). + /// For PDA accounts, the seed and program ID used to derive keys via `derive_keys_for_pda`. + /// `None` for regular shared accounts (keys derived from identifier via derivation seed). #[serde(default)] pub pda_seed: Option, + #[serde(default)] + pub pda_program_id: Option, pub account: Account, } @@ -55,7 +57,7 @@ pub struct NSSAUserData { /// Old wallet files with `pda_accounts` (plain Account values) are incompatible with /// this type. The `default` attribute ensures they deserialize as empty rather than failing. #[serde(default)] - pub shared_accounts: BTreeMap, + pub shared_private_accounts: BTreeMap, } impl NSSAUserData { @@ -115,7 +117,7 @@ impl NSSAUserData { public_key_tree, private_key_tree, group_key_holders: BTreeMap::new(), - shared_accounts: BTreeMap::new(), + shared_private_accounts: BTreeMap::new(), }) } @@ -274,7 +276,7 @@ mod tests { fn group_key_holders_default_empty() { let user_data = NSSAUserData::default(); assert!(user_data.group_key_holders.is_empty()); - assert!(user_data.shared_accounts.is_empty()); + assert!(user_data.shared_private_accounts.is_empty()); } #[test] @@ -285,6 +287,7 @@ mod tests { group_label: String::from("test-group"), identifier: 42, pda_seed: None, + pda_program_id: None, account: nssa_core::account::Account::default(), }; let encoded = bincode::serialize(&entry).expect("serialize"); @@ -297,6 +300,7 @@ mod tests { group_label: String::from("pda-group"), identifier: u128::MAX, pda_seed: Some(PdaSeed::new([7_u8; 32])), + pda_program_id: Some([9; 8]), account: nssa_core::account::Account::default(), }; let pda_encoded = bincode::serialize(&pda_entry).expect("serialize pda"); @@ -315,6 +319,7 @@ mod tests { group_label: String::from("old"), identifier: 1, pda_seed: None, + pda_program_id: None, account: nssa_core::account::Account::default(), }; let encoded = bincode::serialize(&entry).expect("serialize"); @@ -345,8 +350,8 @@ mod tests { // PDA shared account: derive via seed let seed = PdaSeed::new([2_u8; 32]); - let pda_keys_a = holder.derive_keys_for_pda(&seed); - let pda_keys_b = holder.derive_keys_for_pda(&seed); + let pda_keys_a = holder.derive_keys_for_pda(&[9; 8], &seed); + let pda_keys_b = holder.derive_keys_for_pda(&[9; 8], &seed); assert_eq!( pda_keys_a.generate_nullifier_public_key(), pda_keys_b.generate_nullifier_public_key(), diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index 3bb7310b..cfb1c8e4 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -176,15 +176,7 @@ impl WalletSubcommand for NewSubcommand { } if let Some(group_name) = for_gms { - // GMS-derived account - let holder = wallet_core - .storage() - .user_data - .group_key_holder(&group_name) - .context(format!("Group '{group_name}' not found"))?; - - if pda { - // PDA shared account + let info = if pda { let seed_hex = seed.context("--seed is required for PDA accounts")?; let pid_hex = program_id.context("--program-id is required for PDA accounts")?; @@ -204,73 +196,27 @@ impl WalletSubcommand for NewSubcommand { pid[i] = u32::from_le_bytes(chunk.try_into().unwrap()); } - let keys = holder.derive_keys_for_pda(&pda_seed); - let npk = keys.generate_nullifier_public_key(); - let vpk = keys.generate_viewing_public_key(); - let account_id = nssa::AccountId::for_private_pda(&pid, &pda_seed, &npk); - - if let Some(label) = label { - wallet_core - .storage - .labels - .insert(account_id.to_string(), Label::new(label)); - } - - wallet_core.register_shared_account( - account_id, - group_name.clone(), - u128::MAX, - Some(pda_seed), - ); - - println!("PDA shared account from group '{group_name}'"); - println!("AccountId: {account_id}"); - println!("NPK: {}", hex::encode(npk.0)); - println!("VPK: {}", hex::encode(&vpk.0)); - - wallet_core.store_persistent_data().await?; - Ok(SubcommandReturnValue::RegisterAccount { account_id }) + wallet_core.create_shared_pda_account(&group_name, pda_seed, pid)? } else { - // Regular shared account. The tag is derived deterministically - // from the identifier so that keys can be re-derived without - // storing the tag separately. - let identifier: nssa_core::Identifier = rand::random(); - let tag = { - use sha2::Digest as _; - let mut hasher = sha2::Sha256::new(); - hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00"); - hasher.update(identifier.to_le_bytes()); - let result: [u8; 32] = hasher.finalize().into(); - result - }; + wallet_core.create_shared_regular_account(&group_name)? + }; - let keys = holder.derive_keys_for_shared_account(&tag); - let npk = keys.generate_nullifier_public_key(); - let vpk = keys.generate_viewing_public_key(); - let account_id = nssa::AccountId::from((&npk, identifier)); - - if let Some(label) = label { - wallet_core - .storage - .labels - .insert(account_id.to_string(), Label::new(label)); - } - - wallet_core.register_shared_account( - account_id, - group_name.clone(), - identifier, - None, - ); - - println!("Shared account from group '{group_name}'"); - println!("AccountId: Private/{account_id}"); - println!("NPK: {}", hex::encode(npk.0)); - println!("VPK: {}", hex::encode(&vpk.0)); - - wallet_core.store_persistent_data().await?; - Ok(SubcommandReturnValue::RegisterAccount { account_id }) + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(info.account_id.to_string(), Label::new(label)); } + + println!("Shared account from group '{group_name}'"); + println!("AccountId: Private/{}", info.account_id); + println!("NPK: {}", hex::encode(info.npk.0)); + println!("VPK: {}", hex::encode(&info.vpk.0)); + + wallet_core.store_persistent_data().await?; + Ok(SubcommandReturnValue::RegisterAccount { + account_id: info.account_id, + }) } else { // Standard wallet-tree-derived account let (account_id, chain_index) = wallet_core.create_new_account_private(cci); diff --git a/wallet/src/config.rs b/wallet/src/config.rs index bbd98ac7..d8e186bd 100644 --- a/wallet/src/config.rs +++ b/wallet/src/config.rs @@ -98,6 +98,18 @@ pub struct PersistentStorage { /// "2rnKprXqWGWJTkDZKsQbFXa4ctKRbapsdoTKQFnaVGG8"). #[serde(default)] pub labels: HashMap, + /// Group key holders for shared account management. + #[serde(default)] + pub group_key_holders: std::collections::BTreeMap< + String, + key_protocol::key_management::group_key_holder::GroupKeyHolder, + >, + /// Cached state of shared private accounts (PDA and regular). + #[serde(default)] + pub shared_private_accounts: std::collections::BTreeMap< + nssa::AccountId, + key_protocol::key_protocol_core::SharedAccountEntry, + >, } impl PersistentStorage { diff --git a/wallet/src/helperfunctions.rs b/wallet/src/helperfunctions.rs index 94755f6e..57416c55 100644 --- a/wallet/src/helperfunctions.rs +++ b/wallet/src/helperfunctions.rs @@ -204,6 +204,8 @@ pub fn produce_data_for_storage( accounts: vec_for_storage, last_synced_block, labels, + group_key_holders: user_data.group_key_holders.clone(), + shared_private_accounts: user_data.shared_private_accounts.clone(), } } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index f179ec44..545b704b 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -51,6 +51,13 @@ pub enum AccDecodeData { Decode(nssa_core::SharedSecretKey, AccountId), } +/// Info returned when creating a shared account. +pub struct SharedAccountInfo { + pub account_id: AccountId, + pub npk: nssa_core::NullifierPublicKey, + pub vpk: nssa_core::encryption::ViewingPublicKey, +} + #[derive(Debug, thiserror::Error)] pub enum ExecutionFailureKind { #[error("Failed to get data from sequencer")] @@ -98,6 +105,8 @@ impl WalletCore { accounts: persistent_accounts, last_synced_block, labels, + group_key_holders, + shared_private_accounts, } = PersistentStorage::from_path(&storage_path).with_context(|| { format!( "Failed to read persistent storage at {}", @@ -109,7 +118,12 @@ impl WalletCore { config_path, storage_path, config_overrides, - |config| WalletChainStore::new(config, persistent_accounts, labels), + |config| { + let mut store = WalletChainStore::new(config, persistent_accounts, labels)?; + store.user_data.group_key_holders = group_key_holders; + store.user_data.shared_private_accounts = shared_private_accounts; + Ok(store) + }, last_synced_block, ) } @@ -305,25 +319,93 @@ impl WalletCore { } /// Register a shared account in storage for sync tracking. - pub fn register_shared_account( + fn register_shared_account( &mut self, account_id: AccountId, group_label: String, identifier: nssa_core::Identifier, pda_seed: Option, + pda_program_id: Option, ) { use key_protocol::key_protocol_core::SharedAccountEntry; - self.storage.user_data.shared_accounts.insert( + self.storage.user_data.shared_private_accounts.insert( account_id, SharedAccountEntry { group_label, identifier, pda_seed, + pda_program_id, account: Account::default(), }, ); } + /// Create a shared PDA account from a group's GMS. Returns the `AccountId` and derived keys. + pub fn create_shared_pda_account( + &mut self, + group_name: &str, + pda_seed: nssa_core::program::PdaSeed, + program_id: nssa_core::program::ProgramId, + ) -> Result { + let holder = self + .storage + .user_data + .group_key_holder(group_name) + .context(format!("Group '{group_name}' not found"))?; + + let keys = holder.derive_keys_for_pda(&program_id, &pda_seed); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let account_id = AccountId::for_private_pda(&program_id, &pda_seed, &npk); + + self.register_shared_account( + account_id, + String::from(group_name), + u128::MAX, + Some(pda_seed), + Some(program_id), + ); + + Ok(SharedAccountInfo { + account_id, + npk, + vpk, + }) + } + + /// Create a shared regular private account from a group's GMS. Returns the `AccountId` and + /// derived keys. The derivation seed is computed deterministically from a random identifier. + pub fn create_shared_regular_account(&mut self, group_name: &str) -> Result { + let identifier: nssa_core::Identifier = rand::random(); + let derivation_seed = { + use sha2::Digest as _; + let mut hasher = sha2::Sha256::new(); + hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00"); + hasher.update(identifier.to_le_bytes()); + let result: [u8; 32] = hasher.finalize().into(); + result + }; + + let holder = self + .storage + .user_data + .group_key_holder(group_name) + .context(format!("Group '{group_name}' not found"))?; + + let keys = holder.derive_keys_for_shared_account(&derivation_seed); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let account_id = AccountId::from((&npk, identifier)); + + self.register_shared_account(account_id, String::from(group_name), identifier, None, None); + + Ok(SharedAccountInfo { + account_id, + npk, + vpk, + }) + } + /// Get account balance. pub async fn get_account_balance(&self, acc: AccountId) -> Result { Ok(self.sequencer_client.get_account_balance(acc).await?) @@ -596,14 +678,14 @@ impl WalletCore { } // Scan for updates to shared accounts (GMS-derived). - self.sync_shared_accounts_with_tx(&tx); + self.sync_shared_private_accounts_with_tx(&tx); } - fn sync_shared_accounts_with_tx(&mut self, tx: &PrivacyPreservingTransaction) { + fn sync_shared_private_accounts_with_tx(&mut self, tx: &PrivacyPreservingTransaction) { let shared_keys: Vec<_> = self .storage .user_data - .shared_accounts + .shared_private_accounts .iter() .filter_map(|(&account_id, entry)| { let holder = self @@ -612,9 +694,13 @@ impl WalletCore { .group_key_holders .get(&entry.group_label)?; - let keys = entry.pda_seed.as_ref().map_or_else( - || { - let tag = { + let keys = match (&entry.pda_seed, &entry.pda_program_id) { + (Some(pda_seed), Some(program_id)) => { + holder.derive_keys_for_pda(program_id, pda_seed) + } + (Some(_), None) => return None, // PDA without program_id, skip + _ => { + let derivation_seed = { use sha2::Digest as _; let mut hasher = sha2::Sha256::new(); hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00"); @@ -622,10 +708,9 @@ impl WalletCore { let result: [u8; 32] = hasher.finalize().into(); result }; - holder.derive_keys_for_shared_account(&tag) - }, - |pda_seed| holder.derive_keys_for_pda(pda_seed), - ); + holder.derive_keys_for_shared_account(&derivation_seed) + } + }; let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); let vsk = keys.viewing_secret_key; @@ -658,7 +743,11 @@ impl WalletCore { .expect("Ciphertext ID is expected to fit in u32"), ) { info!("Synced shared account {account_id:#?} with new state {new_acc:#?}"); - if let Some(entry) = self.storage.user_data.shared_accounts.get_mut(&account_id) + if let Some(entry) = self + .storage + .user_data + .shared_private_accounts + .get_mut(&account_id) { entry.account = new_acc; } diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index dfac8180..cdf0bed7 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -335,7 +335,7 @@ async fn private_pda_preparation( let acc = wallet .storage .user_data - .shared_accounts + .shared_private_accounts .get(&account_id) .map(|e| e.account.clone()) .unwrap_or_default(); @@ -386,7 +386,7 @@ async fn private_shared_preparation( let acc = wallet .storage .user_data - .shared_accounts + .shared_private_accounts .get(&account_id) .map(|e| e.account.clone()) .unwrap_or_default(); From 4ace6e1570a4eb118bb140ce47dafb182683a466 Mon Sep 17 00:00:00 2001 From: Moudy Date: Thu, 7 May 2026 22:48:32 +0200 Subject: [PATCH 18/25] fix: address review feedback --- integration_tests/tests/ata.rs | 4 - .../tests/auth_transfer/private.rs | 16 -- integration_tests/tests/keys_restoration.rs | 16 -- integration_tests/tests/pinata.rs | 8 - integration_tests/tests/shared_accounts.rs | 21 ++- integration_tests/tests/token.rs | 48 ------ key_protocol/src/key_protocol_core/mod.rs | 48 +++++- .../privacy_preserving_transaction/circuit.rs | 12 +- .../guest/src/bin/private_pda_spender.rs | 2 +- wallet/src/cli/account.rs | 161 ++++++++++-------- wallet/src/lib.rs | 17 +- wallet/src/privacy_preserving_tx.rs | 10 +- 12 files changed, 151 insertions(+), 212 deletions(-) diff --git a/integration_tests/tests/ata.rs b/integration_tests/tests/ata.rs index 54ef5341..6f0bf05c 100644 --- a/integration_tests/tests/ata.rs +++ b/integration_tests/tests/ata.rs @@ -44,10 +44,6 @@ async fn new_private_account(ctx: &mut TestContext) -> Result { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index 6f05cdee..8db5f8d4 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -160,10 +160,6 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { // Create a new private account let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })); @@ -332,10 +328,6 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { // Create a new private account let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })); @@ -401,10 +393,6 @@ async fn initialize_private_account() -> Result<()> { let mut ctx = TestContext::new().await?; let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })); @@ -505,10 +493,6 @@ async fn initialize_private_account_using_label() -> Result<()> { // Create a new private account with a label let label = "init-private-label".to_owned(); let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: Some(label.clone()), })); diff --git a/integration_tests/tests/keys_restoration.rs b/integration_tests/tests/keys_restoration.rs index 8fae9808..ff339120 100644 --- a/integration_tests/tests/keys_restoration.rs +++ b/integration_tests/tests/keys_restoration.rs @@ -30,10 +30,6 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { // Create a new private account let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })); @@ -44,10 +40,6 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -127,10 +119,6 @@ async fn restore_keys_from_seed() -> Result<()> { // Create first private account at root let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: Some(ChainIndex::root()), label: None, })); @@ -144,10 +132,6 @@ async fn restore_keys_from_seed() -> Result<()> { // Create second private account at /0 let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: Some(ChainIndex::from_str("/0")?), label: None, })); diff --git a/integration_tests/tests/pinata.rs b/integration_tests/tests/pinata.rs index d4523f94..77c4a646 100644 --- a/integration_tests/tests/pinata.rs +++ b/integration_tests/tests/pinata.rs @@ -85,10 +85,6 @@ async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<() let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -233,10 +229,6 @@ async fn claim_pinata_to_new_private_account() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index b91502d1..905ae367 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -45,10 +45,9 @@ async fn group_create_and_shared_account_registration() -> Result<()> { ); // Create a shared regular private account from the group - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, + let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateGms { + group: "test-group".to_string(), label: Some("shared-acc".to_string()), - for_gms: Some("test-group".to_string()), pda: false, seed: None, program_id: None, @@ -63,9 +62,11 @@ async fn group_create_and_shared_account_registration() -> Result<()> { }; // Verify shared account is registered in storage - let shared_private_accounts = &ctx.wallet().storage().user_data.shared_private_accounts; - let entry = shared_private_accounts - .get(&shared_account_id) + let entry = ctx + .wallet() + .storage() + .user_data + .shared_private_account(&shared_account_id) .context("Shared account not found in storage")?; assert_eq!(entry.group_label, "test-group"); assert!(entry.pda_seed.is_none()); @@ -143,10 +144,9 @@ async fn fund_shared_account_from_public() -> Result<()> { }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, + let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateGms { + group: "fund-group".to_string(), label: None, - for_gms: Some("fund-group".to_string()), pda: false, seed: None, program_id: None, @@ -193,8 +193,7 @@ async fn fund_shared_account_from_public() -> Result<()> { .wallet() .storage() .user_data - .shared_private_accounts - .get(&shared_id) + .shared_private_account(&shared_id) .context("Shared account not found after sync")?; info!( diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index 93786a57..6db718f9 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -297,10 +297,6 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -317,10 +313,6 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -468,10 +460,6 @@ async fn create_token_with_private_definition() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: Some(ChainIndex::root()), label: None, })), @@ -544,10 +532,6 @@ async fn create_token_with_private_definition() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -678,10 +662,6 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -698,10 +678,6 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -764,10 +740,6 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -883,10 +855,6 @@ async fn shielded_token_transfer() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -998,10 +966,6 @@ async fn deshielded_token_transfer() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -1113,10 +1077,6 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -1133,10 +1093,6 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), @@ -1170,10 +1126,6 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Account(AccountSubcommand::New(NewSubcommand::Private { - for_gms: None, - pda: false, - seed: None, - program_id: None, cci: None, label: None, })), diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index a18c1d3a..3adea616 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -36,7 +36,7 @@ pub struct SharedAccountEntry { pub account: Account, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug)] pub struct NSSAUserData { /// Default public accounts. pub default_pub_account_signing_keys: BTreeMap, @@ -46,17 +46,11 @@ pub struct NSSAUserData { pub public_key_tree: KeyTreePublic, /// Tree of private keys. pub private_key_tree: KeyTreePrivate, - /// Group key holders for private PDA groups, keyed by a human-readable label. - /// Defaults to empty for backward compatibility with wallets that predate group PDAs. - /// An older wallet binary that re-serializes this struct will drop the field. - #[serde(default)] + /// Group key holders for shared account management, keyed by a human-readable label. pub group_key_holders: BTreeMap, - /// Cached plaintext state of shared accounts (PDAs and regular shared accounts), + /// Cached plaintext state of shared private accounts (PDAs and regular shared accounts), /// keyed by `AccountId`. Each entry stores the group label and identifier needed /// to re-derive keys during sync. - /// Old wallet files with `pda_accounts` (plain Account values) are incompatible with - /// this type. The `default` attribute ensures they deserialize as empty rather than failing. - #[serde(default)] pub shared_private_accounts: BTreeMap, } @@ -239,6 +233,42 @@ impl NSSAUserData { pub fn insert_group_key_holder(&mut self, label: String, holder: GroupKeyHolder) { self.group_key_holders.insert(label, holder); } + + /// Returns the cached account for a shared private account, if it exists. + #[must_use] + pub fn shared_private_account( + &self, + account_id: &nssa::AccountId, + ) -> Option<&SharedAccountEntry> { + self.shared_private_accounts.get(account_id) + } + + /// Inserts or replaces a shared private account entry. + pub fn insert_shared_private_account( + &mut self, + account_id: nssa::AccountId, + entry: SharedAccountEntry, + ) { + self.shared_private_accounts.insert(account_id, entry); + } + + /// Updates the cached account state for a shared private account. + pub fn update_shared_private_account_state( + &mut self, + account_id: &nssa::AccountId, + account: nssa_core::account::Account, + ) { + if let Some(entry) = self.shared_private_accounts.get_mut(account_id) { + entry.account = account; + } + } + + /// Iterates over all shared private accounts. + pub fn shared_private_accounts_iter( + &self, + ) -> impl Iterator { + self.shared_private_accounts.iter() + } } impl Default for NSSAUserData { diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index 913cbb29..90efe6d1 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -420,7 +420,7 @@ mod tests { /// PDA init: initializes a new PDA under `authenticated_transfer`'s ownership. /// The `private_pda_spender` program chains to `authenticated_transfer` with `pda_seeds` - /// to establish authorization and the mask-3 binding. + /// to establish authorization and the private PDA binding. #[test] fn private_pda_init() { let program = Program::private_pda_spender(); @@ -430,7 +430,7 @@ mod tests { let seed = PdaSeed::new([42; 32]); let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); - // PDA (new, mask 3) — AccountId derived from private_pda_spender's program ID + // PDA (new, private PDA) — AccountId derived from private_pda_spender's program ID let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); @@ -467,11 +467,11 @@ mod tests { let seed = PdaSeed::new([42; 32]); let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); - // PDA (new, mask 3) + // PDA (new, private PDA) let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); - // Recipient (mask 0, public) + // Recipient (public) let recipient_id = AccountId::new([88; 32]); let recipient_pre = AccountWithMetadata::new( Account { @@ -510,7 +510,7 @@ mod tests { /// Shared regular private account: receives funds via `authenticated_transfer` directly, /// no custom program needed. This demonstrates the non-PDA shared account flow where /// keys are derived from GMS via `derive_keys_for_shared_account`. The shared account - /// uses standard mask 2 (new unauthorized private) and works with auth-transfer's + /// uses the standard unauthorized private account path and works with auth-transfer's /// transfer path like any other private account. #[test] fn shared_account_receives_via_auth_transfer() { @@ -532,7 +532,7 @@ mod tests { sender_id, ); - // Recipient: shared private account (new, unauthorized, mask 2) + // Recipient: shared private account (new, unauthorized) let shared_account_id = AccountId::from((&shared_npk, shared_identifier)); let recipient = AccountWithMetadata::new(Account::default(), false, shared_account_id); diff --git a/test_program_methods/guest/src/bin/private_pda_spender.rs b/test_program_methods/guest/src/bin/private_pda_spender.rs index 2f3b9c23..17316f16 100644 --- a/test_program_methods/guest/src/bin/private_pda_spender.rs +++ b/test_program_methods/guest/src/bin/private_pda_spender.rs @@ -53,7 +53,7 @@ fn main() { // Chain to authenticated_transfer with pda_seeds to authorize the PDA. // The circuit's resolve_authorization_and_record_bindings establishes the - // mask-3 (seed, npk) binding when pda_seeds match the private PDA derivation. + // private PDA (seed, npk) binding when pda_seeds match the private PDA derivation. let mut auth_pda_pre = pda_pre; auth_pda_pre.is_authorized = true; let auth_call = diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index cfb1c8e4..0e12e9a5 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -83,19 +83,23 @@ pub enum NewSubcommand { label: Option, }, /// Single-account convenience: creates a key node and auto-registers one account with a random - /// identifier. When `--for-gms` is provided, derives keys from the named group instead of - /// the wallet's key tree. + /// identifier. Private { #[arg(long)] - /// Chain index of a parent node (ignored when --for-gms is used). + /// Chain index of a parent node. cci: Option, #[arg(short, long)] /// Label to assign to the new account. label: Option, + }, + /// Create a shared private account from a group's GMS. + PrivateGms { + /// Group name to derive keys from. + group: String, + #[arg(short, long)] + /// Label to assign to the new account. + label: Option, #[arg(long)] - /// Derive keys from a group's GMS instead of the wallet tree. - for_gms: Option, - #[arg(long, requires = "for_gms")] /// Create a PDA account (requires --seed and --program-id). pda: bool, #[arg(long, requires = "pda")] @@ -157,10 +161,50 @@ impl WalletSubcommand for NewSubcommand { Ok(SubcommandReturnValue::RegisterAccount { account_id }) } - Self::Private { - cci, + Self::Private { cci, label } => { + if let Some(label) = &label + && wallet_core + .storage + .labels + .values() + .any(|l| l.to_string() == *label) + { + anyhow::bail!("Label '{label}' is already in use by another account"); + } + + let (account_id, chain_index) = wallet_core.create_new_account_private(cci); + + let node = wallet_core + .storage + .user_data + .private_key_tree + .key_map + .get(&chain_index) + .expect("Node was just inserted"); + let key = &node.value.0; + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + println!( + "Generated new account with account_id Private/{account_id} at path {chain_index}" + ); + println!("With npk {}", hex::encode(key.nullifier_public_key.0)); + println!( + "With vpk {}", + hex::encode(key.viewing_public_key.to_bytes()) + ); + + wallet_core.store_persistent_data().await?; + Ok(SubcommandReturnValue::RegisterAccount { account_id }) + } + Self::PrivateGms { + group, label, - for_gms, pda, seed, program_id, @@ -175,80 +219,47 @@ impl WalletSubcommand for NewSubcommand { anyhow::bail!("Label '{label}' is already in use by another account"); } - if let Some(group_name) = for_gms { - let info = if pda { - let seed_hex = seed.context("--seed is required for PDA accounts")?; - let pid_hex = - program_id.context("--program-id is required for PDA accounts")?; + let info = if pda { + let seed_hex = seed.context("--seed is required for PDA accounts")?; + let pid_hex = + program_id.context("--program-id is required for PDA accounts")?; - let seed_bytes: [u8; 32] = hex::decode(&seed_hex) - .context("Invalid seed hex")? - .try_into() - .map_err(|_err| anyhow::anyhow!("Seed must be exactly 32 bytes"))?; - let pda_seed = nssa_core::program::PdaSeed::new(seed_bytes); + let seed_bytes: [u8; 32] = hex::decode(&seed_hex) + .context("Invalid seed hex")? + .try_into() + .map_err(|_err| anyhow::anyhow!("Seed must be exactly 32 bytes"))?; + let pda_seed = nssa_core::program::PdaSeed::new(seed_bytes); - let pid_bytes = hex::decode(&pid_hex).context("Invalid program ID hex")?; - if pid_bytes.len() != 32 { - anyhow::bail!("Program ID must be exactly 32 bytes"); - } - let mut pid: nssa_core::program::ProgramId = [0; 8]; - for (i, chunk) in pid_bytes.chunks_exact(4).enumerate() { - pid[i] = u32::from_le_bytes(chunk.try_into().unwrap()); - } - - wallet_core.create_shared_pda_account(&group_name, pda_seed, pid)? - } else { - wallet_core.create_shared_regular_account(&group_name)? - }; - - if let Some(label) = label { - wallet_core - .storage - .labels - .insert(info.account_id.to_string(), Label::new(label)); + let pid_bytes = hex::decode(&pid_hex).context("Invalid program ID hex")?; + if pid_bytes.len() != 32 { + anyhow::bail!("Program ID must be exactly 32 bytes"); + } + let mut pid: nssa_core::program::ProgramId = [0; 8]; + for (i, chunk) in pid_bytes.chunks_exact(4).enumerate() { + pid[i] = u32::from_le_bytes(chunk.try_into().unwrap()); } - println!("Shared account from group '{group_name}'"); - println!("AccountId: Private/{}", info.account_id); - println!("NPK: {}", hex::encode(info.npk.0)); - println!("VPK: {}", hex::encode(&info.vpk.0)); - - wallet_core.store_persistent_data().await?; - Ok(SubcommandReturnValue::RegisterAccount { - account_id: info.account_id, - }) + wallet_core.create_shared_pda_account(&group, pda_seed, pid)? } else { - // Standard wallet-tree-derived account - let (account_id, chain_index) = wallet_core.create_new_account_private(cci); + wallet_core.create_shared_regular_account(&group)? + }; - let node = wallet_core + if let Some(label) = label { + wallet_core .storage - .user_data - .private_key_tree - .key_map - .get(&chain_index) - .expect("Node was just inserted"); - let key = &node.value.0; - - if let Some(label) = label { - wallet_core - .storage - .labels - .insert(account_id.to_string(), Label::new(label)); - } - - println!( - "Generated new account with account_id Private/{account_id} at path {chain_index}" - ); - println!("With npk {}", hex::encode(key.nullifier_public_key.0)); - println!( - "With vpk {}", - hex::encode(key.viewing_public_key.to_bytes()) - ); - - wallet_core.store_persistent_data().await?; - Ok(SubcommandReturnValue::RegisterAccount { account_id }) + .labels + .insert(info.account_id.to_string(), Label::new(label)); } + + println!("Shared account from group '{group}'"); + println!("AccountId: Private/{}", info.account_id); + println!("NPK: {}", hex::encode(info.npk.0)); + println!("VPK: {}", hex::encode(&info.vpk.0)); + + wallet_core.store_persistent_data().await?; + Ok(SubcommandReturnValue::RegisterAccount { + account_id: info.account_id, + }) } Self::PrivateAccountsKey { cci } => { let chain_index = wallet_core.create_private_accounts_key(cci); diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 545b704b..1ff65ce9 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -328,7 +328,7 @@ impl WalletCore { pda_program_id: Option, ) { use key_protocol::key_protocol_core::SharedAccountEntry; - self.storage.user_data.shared_private_accounts.insert( + self.storage.user_data.insert_shared_private_account( account_id, SharedAccountEntry { group_label, @@ -685,14 +685,12 @@ impl WalletCore { let shared_keys: Vec<_> = self .storage .user_data - .shared_private_accounts - .iter() + .shared_private_accounts_iter() .filter_map(|(&account_id, entry)| { let holder = self .storage .user_data - .group_key_holders - .get(&entry.group_label)?; + .group_key_holder(&entry.group_label)?; let keys = match (&entry.pda_seed, &entry.pda_program_id) { (Some(pda_seed), Some(program_id)) => { @@ -743,14 +741,9 @@ impl WalletCore { .expect("Ciphertext ID is expected to fit in u32"), ) { info!("Synced shared account {account_id:#?} with new state {new_acc:#?}"); - if let Some(entry) = self - .storage + self.storage .user_data - .shared_private_accounts - .get_mut(&account_id) - { - entry.account = new_acc; - } + .update_shared_private_account_state(&account_id, new_acc); } } } diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index cdf0bed7..5f35cde9 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -31,8 +31,8 @@ pub enum PrivacyPreservingAccount { seed: PdaSeed, }, /// A shared regular private account with externally-provided keys (e.g. from GMS). - /// Uses standard `AccountId = from((&npk, identifier))` and mask 1/2. - /// Works with `authenticated_transfer` and all existing programs out of the box. + /// Uses standard `AccountId = from((&npk, identifier))` with authorized/unauthorized private + /// paths. Works with `authenticated_transfer` and all existing programs out of the box. PrivateShared { nsk: NullifierSecretKey, npk: NullifierPublicKey, @@ -335,8 +335,7 @@ async fn private_pda_preparation( let acc = wallet .storage .user_data - .shared_private_accounts - .get(&account_id) + .shared_private_account(&account_id) .map(|e| e.account.clone()) .unwrap_or_default(); @@ -386,8 +385,7 @@ async fn private_shared_preparation( let acc = wallet .storage .user_data - .shared_private_accounts - .get(&account_id) + .shared_private_account(&account_id) .map(|e| e.account.clone()) .unwrap_or_default(); From 4e7963c65517e75f8b8576d267b89ba536d35535 Mon Sep 17 00:00:00 2001 From: Moudy Date: Fri, 8 May 2026 08:19:55 +0200 Subject: [PATCH 19/25] feat: add dedicated sealing key for GMS distribution --- .../private_pda_spender.bin | Bin 403052 -> 403024 bytes .../src/key_management/group_key_holder.rs | 11 ++-- key_protocol/src/key_protocol_core/mod.rs | 5 ++ wallet/src/cli/group.rs | 49 ++++++++++++------ wallet/src/config.rs | 3 ++ wallet/src/helperfunctions.rs | 1 + wallet/src/lib.rs | 7 +++ 7 files changed, 53 insertions(+), 23 deletions(-) diff --git a/artifacts/test_program_methods/private_pda_spender.bin b/artifacts/test_program_methods/private_pda_spender.bin index cc602ee4ec61debe74ecd28f84b0dce80bfcc7bd..ca1c8bd6ac32b983851454cf3eb51173ec3fc37f 100644 GIT binary patch delta 9765 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 G+5ZRN31$=k diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs index 533906a1..3f77c531 100644 --- a/key_protocol/src/key_management/group_key_holder.rs +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -322,13 +322,12 @@ mod tests { let account_id = AccountId::for_private_pda(&program_id, &seed, &npk); let expected_npk = NullifierPublicKey([ - 185, 161, 225, 224, 20, 156, 173, 0, 6, 173, 74, 136, 16, 88, 71, 154, 101, 160, 224, - 162, 247, 98, 183, 210, 118, 130, 143, 237, 20, 112, 111, 114, - ]); - let expected_account_id = AccountId::new([ - 236, 138, 175, 184, 194, 233, 144, 109, 157, 51, 193, 120, 83, 110, 147, 90, 154, 57, - 148, 236, 12, 92, 135, 38, 253, 79, 88, 143, 161, 175, 46, 144, + 136, 176, 234, 71, 208, 8, 143, 142, 126, 155, 132, 18, 71, 27, 88, 56, 100, 90, 79, + 215, 76, 92, 60, 166, 104, 35, 51, 91, 16, 114, 188, 112, ]); + // AccountId is derived from (program_id, seed, npk), so it changes when npk changes. + // We verify npk is pinned, and AccountId is deterministically derived from it. + let expected_account_id = AccountId::for_private_pda(&program_id, &seed, &expected_npk); assert_eq!(npk, expected_npk); assert_eq!(account_id, expected_account_id); diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index 3adea616..20bea342 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -52,6 +52,10 @@ pub struct NSSAUserData { /// keyed by `AccountId`. Each entry stores the group label and identifier needed /// to re-derive keys during sync. pub shared_private_accounts: BTreeMap, + /// Dedicated sealing secret key for GMS distribution. Generated once via + /// `wallet group new-sealing-key`. The corresponding public key is shared with + /// group members so they can seal GMS for this wallet. + pub sealing_secret_key: Option, } impl NSSAUserData { @@ -112,6 +116,7 @@ impl NSSAUserData { private_key_tree, group_key_holders: BTreeMap::new(), shared_private_accounts: BTreeMap::new(), + sealing_secret_key: None, }) } diff --git a/wallet/src/cli/group.rs b/wallet/src/cli/group.rs index 0a1d8d54..f1d93b75 100644 --- a/wallet/src/cli/group.rs +++ b/wallet/src/cli/group.rs @@ -45,16 +45,17 @@ pub enum GroupSubcommand { key: String, }, /// Unseal a received GMS and store it (join a group). + /// Uses the wallet's dedicated sealing key (generated via `new-sealing-key`). Join { /// Human-readable name to store the group under. name: String, /// Sealed GMS as hex string (from the inviter). #[arg(long)] sealed: String, - /// Account ID whose viewing secret key to use for decryption. - #[arg(long)] - account: String, }, + /// Generate a dedicated sealing key pair for GMS distribution. + /// Share the printed public key with group members so they can seal GMS for you. + NewSealingKey, } impl WalletSubcommand for GroupSubcommand { @@ -156,11 +157,7 @@ impl WalletSubcommand for GroupSubcommand { Ok(SubcommandReturnValue::Empty) } - Self::Join { - name, - sealed, - account, - } => { + Self::Join { name, sealed } => { if wallet_core .storage() .user_data @@ -170,17 +167,14 @@ impl WalletSubcommand for GroupSubcommand { anyhow::bail!("Group '{name}' already exists"); } + let sealing_key = + wallet_core.storage().user_data.sealing_secret_key.context( + "No sealing key found. Run 'wallet group new-sealing-key' first.", + )?; + let sealed_bytes = hex::decode(&sealed).context("Invalid sealed hex")?; - let account_id: nssa::AccountId = account.parse().context("Invalid account ID")?; - let (keychain, _, _) = wallet_core - .storage() - .user_data - .get_private_account(account_id) - .context("Private account not found")?; - let vsk = keychain.private_key_holder.viewing_secret_key; - - let holder = GroupKeyHolder::unseal(&sealed_bytes, &vsk) + let holder = GroupKeyHolder::unseal(&sealed_bytes, &sealing_key) .map_err(|e| anyhow::anyhow!("Failed to unseal: {e:?}"))?; wallet_core.insert_group_key_holder(name.clone(), holder); @@ -189,6 +183,27 @@ impl WalletSubcommand for GroupSubcommand { println!("Joined group '{name}'"); Ok(SubcommandReturnValue::Empty) } + + Self::NewSealingKey => { + if wallet_core.storage().user_data.sealing_secret_key.is_some() { + anyhow::bail!("Sealing key already exists. Each wallet has one sealing key."); + } + + let mut secret: nssa_core::encryption::Scalar = [0_u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut secret); + let public_key = + nssa_core::encryption::shared_key_derivation::Secp256k1Point::from_scalar( + secret, + ); + + wallet_core.set_sealing_secret_key(secret); + wallet_core.store_persistent_data().await?; + + println!("Sealing key generated."); + println!("Public key: {}", hex::encode(&public_key.0)); + println!("Share this public key with group members so they can seal GMS for you."); + Ok(SubcommandReturnValue::Empty) + } } } } diff --git a/wallet/src/config.rs b/wallet/src/config.rs index d8e186bd..79a4e3c9 100644 --- a/wallet/src/config.rs +++ b/wallet/src/config.rs @@ -110,6 +110,9 @@ pub struct PersistentStorage { nssa::AccountId, key_protocol::key_protocol_core::SharedAccountEntry, >, + /// Dedicated sealing secret key for GMS distribution. + #[serde(default)] + pub sealing_secret_key: Option, } impl PersistentStorage { diff --git a/wallet/src/helperfunctions.rs b/wallet/src/helperfunctions.rs index 57416c55..bc53edc0 100644 --- a/wallet/src/helperfunctions.rs +++ b/wallet/src/helperfunctions.rs @@ -206,6 +206,7 @@ pub fn produce_data_for_storage( labels, group_key_holders: user_data.group_key_holders.clone(), shared_private_accounts: user_data.shared_private_accounts.clone(), + sealing_secret_key: user_data.sealing_secret_key, } } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 1ff65ce9..307b253a 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -107,6 +107,7 @@ impl WalletCore { labels, group_key_holders, shared_private_accounts, + sealing_secret_key, } = PersistentStorage::from_path(&storage_path).with_context(|| { format!( "Failed to read persistent storage at {}", @@ -122,6 +123,7 @@ impl WalletCore { let mut store = WalletChainStore::new(config, persistent_accounts, labels)?; store.user_data.group_key_holders = group_key_holders; store.user_data.shared_private_accounts = shared_private_accounts; + store.user_data.sealing_secret_key = sealing_secret_key; Ok(store) }, last_synced_block, @@ -310,6 +312,11 @@ impl WalletCore { self.storage.user_data.insert_group_key_holder(name, holder); } + /// Set the wallet's dedicated sealing secret key. + pub const fn set_sealing_secret_key(&mut self, key: nssa_core::encryption::Scalar) { + self.storage.user_data.sealing_secret_key = Some(key); + } + /// Remove a group key holder from storage. Returns the removed holder if it existed. pub fn remove_group_key_holder( &mut self, From 2b2275ee7406bffbeeb35b479a9ee77efa8762b3 Mon Sep 17 00:00:00 2001 From: Moudy Date: Fri, 8 May 2026 11:03:13 +0200 Subject: [PATCH 20/25] fix: resolve shared accounts in auth-transfer commands --- wallet/src/lib.rs | 53 +++++++++++++++++++ .../native_token_transfer/private.rs | 21 ++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 307b253a..1d9c2c7e 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -317,6 +317,59 @@ impl WalletCore { self.storage.user_data.sealing_secret_key = Some(key); } + /// Resolve an `AccountId` to the appropriate `PrivacyPreservingAccount` variant. + /// Checks the key tree first, then shared private accounts. + #[must_use] + pub fn resolve_private_account( + &self, + account_id: nssa::AccountId, + ) -> Option { + // Check key tree first + if self + .storage + .user_data + .get_private_account(account_id) + .is_some() + { + return Some(PrivacyPreservingAccount::PrivateOwned(account_id)); + } + + // Check shared private accounts + let entry = self.storage.user_data.shared_private_account(&account_id)?; + let holder = self + .storage + .user_data + .group_key_holder(&entry.group_label)?; + + if let Some(pda_seed) = &entry.pda_seed { + let program_id = entry.pda_program_id?; + let keys = holder.derive_keys_for_pda(&program_id, pda_seed); + Some(PrivacyPreservingAccount::PrivatePda { + nsk: keys.nullifier_secret_key, + npk: keys.generate_nullifier_public_key(), + vpk: keys.generate_viewing_public_key(), + program_id, + seed: *pda_seed, + }) + } else { + let derivation_seed = { + use sha2::Digest as _; + let mut hasher = sha2::Sha256::new(); + hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00"); + hasher.update(entry.identifier.to_le_bytes()); + let result: [u8; 32] = hasher.finalize().into(); + result + }; + let keys = holder.derive_keys_for_shared_account(&derivation_seed); + Some(PrivacyPreservingAccount::PrivateShared { + nsk: keys.nullifier_secret_key, + npk: keys.generate_nullifier_public_key(), + vpk: keys.generate_viewing_public_key(), + identifier: entry.identifier, + }) + } + } + /// Remove a group key holder from storage. Returns the removed holder if it existed. pub fn remove_group_key_holder( &mut self, diff --git a/wallet/src/program_facades/native_token_transfer/private.rs b/wallet/src/program_facades/native_token_transfer/private.rs index d317b31c..436c2d41 100644 --- a/wallet/src/program_facades/native_token_transfer/private.rs +++ b/wallet/src/program_facades/native_token_transfer/private.rs @@ -14,9 +14,14 @@ impl NativeTokenTransfer<'_> { ) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> { let instruction: u128 = 0; + let account = self + .0 + .resolve_private_account(from) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + self.0 .send_privacy_preserving_tx( - vec![PrivacyPreservingAccount::PrivateOwned(from)], + vec![account], Program::serialize_instruction(instruction).unwrap(), &Program::authenticated_transfer_program().into(), ) @@ -69,12 +74,18 @@ impl NativeTokenTransfer<'_> { ) -> Result<(HashType, [SharedSecretKey; 2]), ExecutionFailureKind> { let (instruction_data, program, tx_pre_check) = auth_transfer_preparation(balance_to_move); + let from_account = self + .0 + .resolve_private_account(from) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let to_account = self + .0 + .resolve_private_account(to) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + self.0 .send_privacy_preserving_tx_with_pre_check( - vec![ - PrivacyPreservingAccount::PrivateOwned(from), - PrivacyPreservingAccount::PrivateOwned(to), - ], + vec![from_account, to_account], instruction_data, &program.into(), tx_pre_check, From 1bed9ecef2f30c629a40ed44f4e88c96d9cedb2c Mon Sep 17 00:00:00 2001 From: Moudy Date: Fri, 8 May 2026 17:44:10 +0200 Subject: [PATCH 21/25] fix: resolve shared accounts in all facades, use SealingPublicKey alias, ignore fund test --- integration_tests/tests/shared_accounts.rs | 16 +++-- wallet/src/cli/group.rs | 2 +- wallet/src/program_facades/ata.rs | 12 +++- .../native_token_transfer/deshielded.rs | 4 +- .../native_token_transfer/private.rs | 4 +- .../native_token_transfer/shielded.rs | 4 +- wallet/src/program_facades/pinata.rs | 4 +- wallet/src/program_facades/token.rs | 72 ++++++++++++++----- 8 files changed, 85 insertions(+), 33 deletions(-) diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index 905ae367..09e39c18 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -31,7 +31,7 @@ async fn group_create_and_shared_account_registration() -> Result<()> { // Create a group let command = Command::Group(GroupSubcommand::New { - name: "test-group".to_string(), + name: "test-group".into(), }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -46,8 +46,8 @@ async fn group_create_and_shared_account_registration() -> Result<()> { // Create a shared regular private account from the group let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateGms { - group: "test-group".to_string(), - label: Some("shared-acc".to_string()), + group: "test-group".into(), + label: Some("shared-acc".into()), pda: false, seed: None, program_id: None, @@ -82,7 +82,7 @@ async fn group_export_import_key_agreement() -> Result<()> { // Create a group let command = Command::Group(GroupSubcommand::New { - name: "alice-group".to_string(), + name: "alice-group".into(), }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -97,7 +97,7 @@ async fn group_export_import_key_agreement() -> Result<()> { // Import under a different name (simulating Bob receiving the GMS) let command = Command::Group(GroupSubcommand::Import { - name: "bob-copy".to_string(), + name: "bob-copy".into(), gms: gms_hex, }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -134,18 +134,20 @@ async fn group_export_import_key_agreement() -> Result<()> { } /// Fund a shared account from a public account via auth-transfer, then sync. +/// TODO: Requires auth-transfer init to work with shared accounts (authorization flow). #[test] +#[ignore] async fn fund_shared_account_from_public() -> Result<()> { let mut ctx = TestContext::new().await?; // Create group and shared account let command = Command::Group(GroupSubcommand::New { - name: "fund-group".to_string(), + name: "fund-group".into(), }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateGms { - group: "fund-group".to_string(), + group: "fund-group".into(), label: None, pda: false, seed: None, diff --git a/wallet/src/cli/group.rs b/wallet/src/cli/group.rs index f1d93b75..5d5bf045 100644 --- a/wallet/src/cli/group.rs +++ b/wallet/src/cli/group.rs @@ -149,7 +149,7 @@ impl WalletSubcommand for GroupSubcommand { .context(format!("Group '{name}' not found"))?; let key_bytes = hex::decode(&key).context("Invalid key hex")?; - let recipient_key = + let recipient_key: key_protocol::key_management::group_key_holder::SealingPublicKey = nssa_core::encryption::shared_key_derivation::Secp256k1Point(key_bytes); let sealed = holder.seal_for(&recipient_key); diff --git a/wallet/src/program_facades/ata.rs b/wallet/src/program_facades/ata.rs index ac60fb63..fa868750 100644 --- a/wallet/src/program_facades/ata.rs +++ b/wallet/src/program_facades/ata.rs @@ -188,7 +188,9 @@ impl Ata<'_> { Program::serialize_instruction(instruction).expect("Instruction should serialize"); let accounts = vec![ - PrivacyPreservingAccount::PrivateOwned(owner_id), + self.0 + .resolve_private_account(owner_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::Public(definition_id), PrivacyPreservingAccount::Public(ata_id), ]; @@ -223,7 +225,9 @@ impl Ata<'_> { Program::serialize_instruction(instruction).expect("Instruction should serialize"); let accounts = vec![ - PrivacyPreservingAccount::PrivateOwned(owner_id), + self.0 + .resolve_private_account(owner_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::Public(sender_ata_id), PrivacyPreservingAccount::Public(recipient_id), ]; @@ -257,7 +261,9 @@ impl Ata<'_> { Program::serialize_instruction(instruction).expect("Instruction should serialize"); let accounts = vec![ - PrivacyPreservingAccount::PrivateOwned(owner_id), + self.0 + .resolve_private_account(owner_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::Public(holder_ata_id), PrivacyPreservingAccount::Public(definition_id), ]; diff --git a/wallet/src/program_facades/native_token_transfer/deshielded.rs b/wallet/src/program_facades/native_token_transfer/deshielded.rs index d51f15ce..d4bde39f 100644 --- a/wallet/src/program_facades/native_token_transfer/deshielded.rs +++ b/wallet/src/program_facades/native_token_transfer/deshielded.rs @@ -16,7 +16,9 @@ impl NativeTokenTransfer<'_> { self.0 .send_privacy_preserving_tx_with_pre_check( vec![ - PrivacyPreservingAccount::PrivateOwned(from), + self.0 + .resolve_private_account(from) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::Public(to), ], instruction_data, diff --git a/wallet/src/program_facades/native_token_transfer/private.rs b/wallet/src/program_facades/native_token_transfer/private.rs index 436c2d41..501ead50 100644 --- a/wallet/src/program_facades/native_token_transfer/private.rs +++ b/wallet/src/program_facades/native_token_transfer/private.rs @@ -46,7 +46,9 @@ impl NativeTokenTransfer<'_> { self.0 .send_privacy_preserving_tx_with_pre_check( vec![ - PrivacyPreservingAccount::PrivateOwned(from), + self.0 + .resolve_private_account(from) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::PrivateForeign { npk: to_npk, vpk: to_vpk, diff --git a/wallet/src/program_facades/native_token_transfer/shielded.rs b/wallet/src/program_facades/native_token_transfer/shielded.rs index 8f7ba2b5..98dd0081 100644 --- a/wallet/src/program_facades/native_token_transfer/shielded.rs +++ b/wallet/src/program_facades/native_token_transfer/shielded.rs @@ -18,7 +18,9 @@ impl NativeTokenTransfer<'_> { .send_privacy_preserving_tx_with_pre_check( vec![ PrivacyPreservingAccount::Public(from), - PrivacyPreservingAccount::PrivateOwned(to), + self.0 + .resolve_private_account(to) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], instruction_data, &program.into(), diff --git a/wallet/src/program_facades/pinata.rs b/wallet/src/program_facades/pinata.rs index 97118ecd..0575455e 100644 --- a/wallet/src/program_facades/pinata.rs +++ b/wallet/src/program_facades/pinata.rs @@ -56,7 +56,9 @@ impl Pinata<'_> { .send_privacy_preserving_tx( vec![ PrivacyPreservingAccount::Public(pinata_account_id), - PrivacyPreservingAccount::PrivateOwned(winner_account_id), + self.0 + .resolve_private_account(winner_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], nssa::program::Program::serialize_instruction(solution).unwrap(), &nssa::program::Program::pinata().into(), diff --git a/wallet/src/program_facades/token.rs b/wallet/src/program_facades/token.rs index d105a4de..da069bc2 100644 --- a/wallet/src/program_facades/token.rs +++ b/wallet/src/program_facades/token.rs @@ -74,7 +74,9 @@ impl Token<'_> { .send_privacy_preserving_tx( vec![ PrivacyPreservingAccount::Public(definition_account_id), - PrivacyPreservingAccount::PrivateOwned(supply_account_id), + self.0 + .resolve_private_account(supply_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], instruction_data, &Program::token().into(), @@ -103,7 +105,9 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(definition_account_id), + self.0 + .resolve_private_account(definition_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::Public(supply_account_id), ], instruction_data, @@ -133,8 +137,12 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(definition_account_id), - PrivacyPreservingAccount::PrivateOwned(supply_account_id), + self.0 + .resolve_private_account(definition_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, + self.0 + .resolve_private_account(supply_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], instruction_data, &Program::token().into(), @@ -227,8 +235,12 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(sender_account_id), - PrivacyPreservingAccount::PrivateOwned(recipient_account_id), + self.0 + .resolve_private_account(sender_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, + self.0 + .resolve_private_account(recipient_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], instruction_data, &Program::token().into(), @@ -259,7 +271,9 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(sender_account_id), + self.0 + .resolve_private_account(sender_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::PrivateForeign { npk: recipient_npk, vpk: recipient_vpk, @@ -293,7 +307,9 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(sender_account_id), + self.0 + .resolve_private_account(sender_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::Public(recipient_account_id), ], instruction_data, @@ -325,7 +341,9 @@ impl Token<'_> { .send_privacy_preserving_tx( vec![ PrivacyPreservingAccount::Public(sender_account_id), - PrivacyPreservingAccount::PrivateOwned(recipient_account_id), + self.0 + .resolve_private_account(recipient_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], instruction_data, &Program::token().into(), @@ -434,8 +452,12 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(definition_account_id), - PrivacyPreservingAccount::PrivateOwned(holder_account_id), + self.0 + .resolve_private_account(definition_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, + self.0 + .resolve_private_account(holder_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], instruction_data, &Program::token().into(), @@ -464,7 +486,9 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(definition_account_id), + self.0 + .resolve_private_account(definition_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::Public(holder_account_id), ], instruction_data, @@ -496,7 +520,9 @@ impl Token<'_> { .send_privacy_preserving_tx( vec![ PrivacyPreservingAccount::Public(definition_account_id), - PrivacyPreservingAccount::PrivateOwned(holder_account_id), + self.0 + .resolve_private_account(holder_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], instruction_data, &Program::token().into(), @@ -590,8 +616,12 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(definition_account_id), - PrivacyPreservingAccount::PrivateOwned(holder_account_id), + self.0 + .resolve_private_account(definition_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, + self.0 + .resolve_private_account(holder_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], instruction_data, &Program::token().into(), @@ -622,7 +652,9 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(definition_account_id), + self.0 + .resolve_private_account(definition_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::PrivateForeign { npk: holder_npk, vpk: holder_vpk, @@ -656,7 +688,9 @@ impl Token<'_> { self.0 .send_privacy_preserving_tx( vec![ - PrivacyPreservingAccount::PrivateOwned(definition_account_id), + self.0 + .resolve_private_account(definition_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, PrivacyPreservingAccount::Public(holder_account_id), ], instruction_data, @@ -688,7 +722,9 @@ impl Token<'_> { .send_privacy_preserving_tx( vec![ PrivacyPreservingAccount::Public(definition_account_id), - PrivacyPreservingAccount::PrivateOwned(holder_account_id), + self.0 + .resolve_private_account(holder_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?, ], instruction_data, &Program::token().into(), From cf699fde7c01d5b7a700c9d57bcfd24c65efc4af Mon Sep 17 00:00:00 2001 From: Moudy Date: Fri, 8 May 2026 18:18:40 +0200 Subject: [PATCH 22/25] refactor: rename private_pda_spender to auth_transfer_proxy --- ...da_spender.bin => auth_transfer_proxy.bin} | Bin 403024 -> 403044 bytes .../privacy_preserving_transaction/circuit.rs | 8 ++++---- nssa/src/program.rs | 8 ++++---- ..._pda_spender.rs => auth_transfer_proxy.rs} | 0 4 files changed, 8 insertions(+), 8 deletions(-) rename artifacts/test_program_methods/{private_pda_spender.bin => auth_transfer_proxy.bin} (69%) rename test_program_methods/guest/src/bin/{private_pda_spender.rs => auth_transfer_proxy.rs} (100%) diff --git a/artifacts/test_program_methods/private_pda_spender.bin b/artifacts/test_program_methods/auth_transfer_proxy.bin similarity index 69% rename from artifacts/test_program_methods/private_pda_spender.bin rename to artifacts/test_program_methods/auth_transfer_proxy.bin index ca1c8bd6ac32b983851454cf3eb51173ec3fc37f..662f2d064a0c6d4b5c3bc92ffbf0e684c30b0b88 100644 GIT binary patch delta 15406 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 BP*4B> diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index 90efe6d1..ff23647e 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -419,18 +419,18 @@ mod tests { } /// PDA init: initializes a new PDA under `authenticated_transfer`'s ownership. - /// The `private_pda_spender` program chains to `authenticated_transfer` with `pda_seeds` + /// The `auth_transfer_proxy` program chains to `authenticated_transfer` with `pda_seeds` /// to establish authorization and the private PDA binding. #[test] fn private_pda_init() { - let program = Program::private_pda_spender(); + let program = Program::auth_transfer_proxy(); let auth_transfer = Program::authenticated_transfer_program(); let keys = test_private_account_keys_1(); let npk = keys.npk(); let seed = PdaSeed::new([42; 32]); let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); - // PDA (new, private PDA) — AccountId derived from private_pda_spender's program ID + // PDA (new, private PDA) — AccountId derived from auth_transfer_proxy's program ID let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); @@ -460,7 +460,7 @@ mod tests { /// two-tx sequence with membership proofs. #[test] fn private_pda_withdraw() { - let program = Program::private_pda_spender(); + let program = Program::auth_transfer_proxy(); let auth_transfer = Program::authenticated_transfer_program(); let keys = test_private_account_keys_1(); let npk = keys.npk(); diff --git a/nssa/src/program.rs b/nssa/src/program.rs index a214b055..23003a92 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -313,12 +313,12 @@ mod tests { } #[must_use] - pub fn private_pda_spender() -> Self { - use test_program_methods::{PRIVATE_PDA_SPENDER_ELF, PRIVATE_PDA_SPENDER_ID}; + pub fn auth_transfer_proxy() -> Self { + use test_program_methods::{AUTH_TRANSFER_PROXY_ELF, AUTH_TRANSFER_PROXY_ID}; Self { - id: PRIVATE_PDA_SPENDER_ID, - elf: PRIVATE_PDA_SPENDER_ELF.to_vec(), + id: AUTH_TRANSFER_PROXY_ID, + elf: AUTH_TRANSFER_PROXY_ELF.to_vec(), } } diff --git a/test_program_methods/guest/src/bin/private_pda_spender.rs b/test_program_methods/guest/src/bin/auth_transfer_proxy.rs similarity index 100% rename from test_program_methods/guest/src/bin/private_pda_spender.rs rename to test_program_methods/guest/src/bin/auth_transfer_proxy.rs From 6e376900f7369e9c6cb9feb23943e5ac99df05ff Mon Sep 17 00:00:00 2001 From: Moudy Date: Fri, 8 May 2026 20:16:10 +0200 Subject: [PATCH 23/25] fix: remove export/import commands, rewrite test to use invite/join --- integration_tests/tests/shared_accounts.rs | 44 +++++++++++++------ wallet/src/cli/group.rs | 50 ---------------------- 2 files changed, 31 insertions(+), 63 deletions(-) diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index 09e39c18..fddb9e6b 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -2,6 +2,10 @@ clippy::tests_outside_test_module, reason = "Integration test file, not inside a #[cfg(test)] module" )] +#![expect( + clippy::shadow_unrelated, + reason = "Sequential wallet commands naturally reuse the `command` binding" +)] //! Shared account integration tests. //! @@ -75,34 +79,48 @@ async fn group_create_and_shared_account_registration() -> Result<()> { Ok(()) } -/// GMS seal/unseal round-trip: export GMS, re-import under a new name, verify key agreement. +/// GMS seal/unseal round-trip via invite/join, verify key agreement. #[test] -async fn group_export_import_key_agreement() -> Result<()> { +async fn group_invite_join_key_agreement() -> Result<()> { let mut ctx = TestContext::new().await?; + // Generate a sealing key + let command = Command::Group(GroupSubcommand::NewSealingKey); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + // Create a group let command = Command::Group(GroupSubcommand::New { name: "alice-group".into(), }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - // Export the GMS + // Seal GMS for ourselves (simulating invite to another wallet) + let sealing_sk = ctx + .wallet() + .storage() + .user_data + .sealing_secret_key + .context("Sealing key not found")?; + let sealing_pk = + nssa_core::encryption::shared_key_derivation::Secp256k1Point::from_scalar(sealing_sk); + let holder = ctx .wallet() .storage() .user_data .group_key_holder("alice-group") .context("Group not found")?; - let gms_hex = hex::encode(holder.dangerous_raw_gms()); + let sealed = holder.seal_for(&sealing_pk); + let sealed_hex = hex::encode(&sealed); - // Import under a different name (simulating Bob receiving the GMS) - let command = Command::Group(GroupSubcommand::Import { + // Join under a different name (simulating Bob receiving the sealed GMS) + let command = Command::Group(GroupSubcommand::Join { name: "bob-copy".into(), - gms: gms_hex, + sealed: sealed_hex, }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - // Both derive the same keys for the same tag + // Both derive the same keys for the same derivation seed let alice_holder = ctx .wallet() .storage() @@ -116,12 +134,12 @@ async fn group_export_import_key_agreement() -> Result<()> { .group_key_holder("bob-copy") .unwrap(); - let tag = [42_u8; 32]; + let seed = [42_u8; 32]; let alice_npk = alice_holder - .derive_keys_for_shared_account(&tag) + .derive_keys_for_shared_account(&seed) .generate_nullifier_public_key(); let bob_npk = bob_holder - .derive_keys_for_shared_account(&tag) + .derive_keys_for_shared_account(&seed) .generate_nullifier_public_key(); assert_eq!( @@ -129,14 +147,14 @@ async fn group_export_import_key_agreement() -> Result<()> { "Key agreement: same GMS produces same keys" ); - info!("Key agreement verified"); + info!("Key agreement verified via invite/join"); Ok(()) } /// Fund a shared account from a public account via auth-transfer, then sync. /// TODO: Requires auth-transfer init to work with shared accounts (authorization flow). #[test] -#[ignore] +#[ignore = "Requires auth-transfer init to work with shared accounts (authorization flow)"] async fn fund_shared_account_from_public() -> Result<()> { let mut ctx = TestContext::new().await?; diff --git a/wallet/src/cli/group.rs b/wallet/src/cli/group.rs index 5d5bf045..e7e7c136 100644 --- a/wallet/src/cli/group.rs +++ b/wallet/src/cli/group.rs @@ -15,19 +15,6 @@ pub enum GroupSubcommand { /// Human-readable name for the group. name: String, }, - /// Import a group from raw GMS bytes. - Import { - /// Human-readable name for the group. - name: String, - /// Raw GMS as 64-character hex string. - #[arg(long)] - gms: String, - }, - /// Export the raw GMS hex for backup or manual distribution. - Export { - /// Group name. - name: String, - }, /// List all groups. #[command(visible_alias = "ls")] List, @@ -82,43 +69,6 @@ impl WalletSubcommand for GroupSubcommand { Ok(SubcommandReturnValue::Empty) } - Self::Import { name, gms } => { - if wallet_core - .storage() - .user_data - .group_key_holder(&name) - .is_some() - { - anyhow::bail!("Group '{name}' already exists"); - } - - let gms_bytes: [u8; 32] = hex::decode(&gms) - .context("Invalid GMS hex")? - .try_into() - .map_err(|_err| anyhow::anyhow!("GMS must be exactly 32 bytes"))?; - - let holder = GroupKeyHolder::from_gms(gms_bytes); - wallet_core.insert_group_key_holder(name.clone(), holder); - wallet_core.store_persistent_data().await?; - - println!("Imported group '{name}'"); - Ok(SubcommandReturnValue::Empty) - } - - Self::Export { name } => { - let holder = wallet_core - .storage() - .user_data - .group_key_holder(&name) - .context(format!("Group '{name}' not found"))?; - - let gms_hex = hex::encode(holder.dangerous_raw_gms()); - - println!("Group: {name}"); - println!("GMS: {gms_hex}"); - Ok(SubcommandReturnValue::Empty) - } - Self::List => { let holders = &wallet_core.storage().user_data.group_key_holders; if holders.is_empty() { From 27e2850b5caa6779b53f2fc47aa15f9099c238dd Mon Sep 17 00:00:00 2001 From: Moudy Date: Fri, 8 May 2026 23:59:08 +0200 Subject: [PATCH 24/25] refactor: make SealingPublicKey a newtype wrapper --- integration_tests/tests/shared_accounts.rs | 2 +- .../src/key_management/group_key_holder.rs | 43 ++++++++++++++----- wallet/src/cli/group.rs | 15 +++---- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index fddb9e6b..ecf3a4b4 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -102,7 +102,7 @@ async fn group_invite_join_key_agreement() -> Result<()> { .sealing_secret_key .context("Sealing key not found")?; let sealing_pk = - nssa_core::encryption::shared_key_derivation::Secp256k1Point::from_scalar(sealing_sk); + key_protocol::key_management::group_key_holder::SealingPublicKey::from_scalar(sealing_sk); let holder = ctx .wallet() diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs index 3f77c531..609c45ed 100644 --- a/key_protocol/src/key_management/group_key_holder.rs +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -12,10 +12,30 @@ use super::secret_holders::{PrivateKeyHolder, SecretSpendingKey}; /// Public key used to seal a `GroupKeyHolder` for distribution to a recipient. /// -/// Structurally identical to `ViewingPublicKey` (both are secp256k1 points), but given -/// a distinct alias to clarify intent: viewing keys encrypt account state, sealing keys -/// encrypt the GMS for off-chain distribution. -pub type SealingPublicKey = Secp256k1Point; +/// Wraps a secp256k1 point but is a distinct type from `ViewingPublicKey` to enforce +/// key separation: viewing keys encrypt account state, sealing keys encrypt the GMS +/// for off-chain distribution. +pub struct SealingPublicKey(Secp256k1Point); + +impl SealingPublicKey { + /// Derive the sealing public key from a secret scalar. + #[must_use] + pub fn from_scalar(scalar: Scalar) -> Self { + Self(Secp256k1Point::from_scalar(scalar)) + } + + /// Construct from raw serialized bytes (e.g. received from another wallet). + #[must_use] + pub const fn from_bytes(bytes: Vec) -> Self { + Self(Secp256k1Point(bytes)) + } + + /// Returns the raw bytes for display or transmission. + #[must_use] + pub fn to_bytes(&self) -> &[u8] { + &self.0.0 + } +} /// Secret key used to unseal a `GroupKeyHolder` received from another member. pub type SealingSecretKey = Scalar; @@ -144,7 +164,7 @@ impl GroupKeyHolder { let mut ephemeral_scalar: Scalar = [0_u8; 32]; OsRng.fill_bytes(&mut ephemeral_scalar); let ephemeral_pubkey = Secp256k1Point::from_scalar(ephemeral_scalar); - let shared = SharedSecretKey::new(&ephemeral_scalar, recipient_key); + let shared = SharedSecretKey::new(&ephemeral_scalar, &recipient_key.0); let aes_key = Self::seal_kdf(&shared); let cipher = Aes256Gcm::new(&aes_key.into()); @@ -386,7 +406,7 @@ mod tests { let recipient_vpk = recipient_keys.generate_viewing_public_key(); let recipient_vsk = recipient_keys.viewing_secret_key; - let sealed = holder.seal_for(&recipient_vpk); + let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0)); let restored = GroupKeyHolder::unseal(&sealed, &recipient_vsk).expect("unseal"); assert_eq!(restored.dangerous_raw_gms(), holder.dangerous_raw_gms()); @@ -417,7 +437,7 @@ mod tests { .produce_private_key_holder(None) .viewing_secret_key; - let sealed = holder.seal_for(&recipient_vpk); + let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0)); let result = GroupKeyHolder::unseal(&sealed, &wrong_vsk); assert!(matches!(result, Err(super::SealError::DecryptionFailed))); } @@ -432,7 +452,7 @@ mod tests { let recipient_vpk = recipient_keys.generate_viewing_public_key(); let recipient_vsk = recipient_keys.viewing_secret_key; - let mut sealed = holder.seal_for(&recipient_vpk); + let mut sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0)); // Flip a byte in the ciphertext portion (after ephemeral_pubkey + nonce) let last = sealed.len() - 1; sealed[last] ^= 0xFF; @@ -451,8 +471,9 @@ mod tests { .produce_private_key_holder(None) .generate_viewing_public_key(); - let sealed_a = holder.seal_for(&recipient_vpk); - let sealed_b = holder.seal_for(&recipient_vpk); + let sealing_key = SealingPublicKey::from_bytes(recipient_vpk.0); + let sealed_a = holder.seal_for(&sealing_key); + let sealed_b = holder.seal_for(&sealing_key); assert_ne!(sealed_a, sealed_b); } @@ -514,7 +535,7 @@ mod tests { let bob_vpk = bob_keys.generate_viewing_public_key(); let bob_vsk = bob_keys.viewing_secret_key; - let sealed = alice_holder.seal_for(&bob_vpk); + let sealed = alice_holder.seal_for(&SealingPublicKey::from_bytes(bob_vpk.0)); let bob_holder = GroupKeyHolder::unseal(&sealed, &bob_vsk).expect("Bob should unseal the GMS"); diff --git a/wallet/src/cli/group.rs b/wallet/src/cli/group.rs index e7e7c136..e1dd9159 100644 --- a/wallet/src/cli/group.rs +++ b/wallet/src/cli/group.rs @@ -1,6 +1,6 @@ use anyhow::{Context as _, Result}; use clap::Subcommand; -use key_protocol::key_management::group_key_holder::GroupKeyHolder; +use key_protocol::key_management::group_key_holder::{GroupKeyHolder, SealingPublicKey}; use crate::{ WalletCore, @@ -99,8 +99,10 @@ impl WalletSubcommand for GroupSubcommand { .context(format!("Group '{name}' not found"))?; let key_bytes = hex::decode(&key).context("Invalid key hex")?; - let recipient_key: key_protocol::key_management::group_key_holder::SealingPublicKey = - nssa_core::encryption::shared_key_derivation::Secp256k1Point(key_bytes); + let recipient_key = + key_protocol::key_management::group_key_holder::SealingPublicKey::from_bytes( + key_bytes, + ); let sealed = holder.seal_for(&recipient_key); println!("{}", hex::encode(&sealed)); @@ -141,16 +143,13 @@ impl WalletSubcommand for GroupSubcommand { let mut secret: nssa_core::encryption::Scalar = [0_u8; 32]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut secret); - let public_key = - nssa_core::encryption::shared_key_derivation::Secp256k1Point::from_scalar( - secret, - ); + let public_key = SealingPublicKey::from_scalar(secret); wallet_core.set_sealing_secret_key(secret); wallet_core.store_persistent_data().await?; println!("Sealing key generated."); - println!("Public key: {}", hex::encode(&public_key.0)); + println!("Public key: {}", hex::encode(public_key.to_bytes())); println!("Share this public key with group members so they can seal GMS for you."); Ok(SubcommandReturnValue::Empty) } From b05da5b426fda17a2fa2ddc59db1658466494ced Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Fri, 8 May 2026 23:30:46 -0300 Subject: [PATCH 25/25] remove stale files --- wallet/src/pinata_interactions.rs | 165 --------- wallet/src/transaction_utils.rs | 594 ------------------------------ 2 files changed, 759 deletions(-) delete mode 100644 wallet/src/pinata_interactions.rs delete mode 100644 wallet/src/transaction_utils.rs diff --git a/wallet/src/pinata_interactions.rs b/wallet/src/pinata_interactions.rs deleted file mode 100644 index 77549772..00000000 --- a/wallet/src/pinata_interactions.rs +++ /dev/null @@ -1,165 +0,0 @@ -use common::{HashType, transaction::NSSATransaction}; -use sequencer_service_rpc::RpcClient as _; -use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; -use nssa::{AccountId, privacy_preserving_transaction::circuit}; -use nssa_core::{MembershipProof, SharedSecretKey, account::AccountWithMetadata}; - -use crate::{ - ExecutionFailureKind, WalletCore, helperfunctions::produce_random_nonces, - transaction_utils::AccountPreparedData, -}; - -impl WalletCore { - pub async fn claim_pinata( - &self, - pinata_account_id: AccountId, - winner_account_id: AccountId, - solution: u128, - ) -> Result { - let account_ids = vec![pinata_account_id, winner_account_id]; - let program_id = nssa::program::Program::pinata().id(); - let message = - nssa::public_transaction::Message::try_new(program_id, account_ids, vec![], solution) - .unwrap(); - - let witness_set = nssa::public_transaction::WitnessSet::for_message(&message, &[]); - let tx = nssa::PublicTransaction::new(message, witness_set); - - Ok(self.sequencer_client.send_transaction(NSSATransaction::Public(tx).into()).await?) - } - - pub async fn claim_pinata_private_owned_account_already_initialized( - &self, - pinata_account_id: AccountId, - winner_account_id: AccountId, - solution: u128, - winner_proof: MembershipProof, - ) -> Result<(HashType, [SharedSecretKey; 1]), ExecutionFailureKind> { - let AccountPreparedData { - nsk: winner_nsk, - npk: winner_npk, - vpk: winner_vpk, - auth_acc: winner_pre, - proof: _, - } = self - .private_acc_preparation(winner_account_id, true, false) - .await?; - - let pinata_acc = self.get_account_public(pinata_account_id).await.unwrap(); - - let program = nssa::program::Program::pinata(); - - let pinata_pre = AccountWithMetadata::new(pinata_acc.clone(), false, pinata_account_id); - - let eph_holder_winner = EphemeralKeyHolder::new(&winner_npk); - let shared_secret_winner = eph_holder_winner.calculate_shared_secret_sender(&winner_vpk); - - let (output, proof) = circuit::execute_and_prove( - &[pinata_pre, winner_pre], - &nssa::program::Program::serialize_instruction(solution).unwrap(), - &[0, 1], - &produce_random_nonces(1), - &[(winner_npk, shared_secret_winner.clone())], - &[(winner_nsk.unwrap())], - &[winner_proof], - &program.into(), - ) - .unwrap(); - - let message = - nssa::privacy_preserving_transaction::message::Message::try_from_circuit_output( - vec![pinata_account_id], - vec![], - vec![( - winner_npk, - winner_vpk.clone(), - eph_holder_winner.generate_ephemeral_public_key(), - )], - output, - ) - .unwrap(); - - let witness_set = - nssa::privacy_preserving_transaction::witness_set::WitnessSet::for_message( - &message, - proof, - &[], - ); - let tx = nssa::privacy_preserving_transaction::PrivacyPreservingTransaction::new( - message, - witness_set, - ); - - Ok(( - self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?, - [shared_secret_winner], - )) - } - - pub async fn claim_pinata_private_owned_account_not_initialized( - &self, - pinata_account_id: AccountId, - winner_account_id: AccountId, - solution: u128, - ) -> Result<(HashType, [SharedSecretKey; 1]), ExecutionFailureKind> { - let AccountPreparedData { - nsk: _, - npk: winner_npk, - vpk: winner_vpk, - auth_acc: winner_pre, - proof: _, - } = self - .private_acc_preparation(winner_account_id, false, false) - .await?; - - let pinata_acc = self.get_account_public(pinata_account_id).await.unwrap(); - - let program = nssa::program::Program::pinata(); - - let pinata_pre = AccountWithMetadata::new(pinata_acc.clone(), false, pinata_account_id); - - let eph_holder_winner = EphemeralKeyHolder::new(&winner_npk); - let shared_secret_winner = eph_holder_winner.calculate_shared_secret_sender(&winner_vpk); - - let (output, proof) = circuit::execute_and_prove( - &[pinata_pre, winner_pre], - &nssa::program::Program::serialize_instruction(solution).unwrap(), - &[0, 2], - &produce_random_nonces(1), - &[(winner_npk, shared_secret_winner.clone())], - &[], - &[], - &program.into(), - ) - .unwrap(); - - let message = - nssa::privacy_preserving_transaction::message::Message::try_from_circuit_output( - vec![pinata_account_id], - vec![], - vec![( - winner_npk, - winner_vpk.clone(), - eph_holder_winner.generate_ephemeral_public_key(), - )], - output, - ) - .unwrap(); - - let witness_set = - nssa::privacy_preserving_transaction::witness_set::WitnessSet::for_message( - &message, - proof, - &[], - ); - let tx = nssa::privacy_preserving_transaction::PrivacyPreservingTransaction::new( - message, - witness_set, - ); - - Ok(( - self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?, - [shared_secret_winner], - )) - } -} diff --git a/wallet/src/transaction_utils.rs b/wallet/src/transaction_utils.rs deleted file mode 100644 index f2def802..00000000 --- a/wallet/src/transaction_utils.rs +++ /dev/null @@ -1,594 +0,0 @@ -use common::{HashType, transaction::NSSATransaction}; -use sequencer_service_rpc::RpcClient as _; -use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; -use nssa::{ - Account, AccountId, PrivacyPreservingTransaction, - privacy_preserving_transaction::{circuit, message::Message, witness_set::WitnessSet}, - program::Program, -}; -use nssa_core::{ - Commitment, MembershipProof, NullifierPublicKey, NullifierSecretKey, SharedSecretKey, - account::AccountWithMetadata, encryption::ViewingPublicKey, program::InstructionData, -}; - -use crate::{ExecutionFailureKind, WalletCore, helperfunctions::produce_random_nonces}; - -pub(crate) struct AccountPreparedData { - pub nsk: Option, - pub npk: NullifierPublicKey, - pub vpk: ViewingPublicKey, - pub auth_acc: AccountWithMetadata, - pub proof: Option, -} - -impl WalletCore { - pub(crate) async fn private_acc_preparation( - &self, - account_id: AccountId, - is_authorized: bool, - needs_proof: bool, - ) -> Result { - let Some((from_keys, from_acc)) = self - .storage - .user_data - .get_private_account(&account_id) - .cloned() - else { - return Err(ExecutionFailureKind::KeyNotFoundError); - }; - - let mut nsk = None; - let mut proof = None; - - let from_npk = from_keys.nullifier_public_key; - let from_vpk = from_keys.viewing_public_key; - - let sender_commitment = Commitment::new(&from_npk, &from_acc); - - let sender_pre = AccountWithMetadata::new(from_acc.clone(), is_authorized, &from_npk); - - if is_authorized { - nsk = Some(from_keys.private_key_holder.nullifier_secret_key); - } - - if needs_proof { - proof = Some( - self.sequencer_client - .get_proof_for_commitment(sender_commitment) - .await - .unwrap(), - ); - } - - Ok(AccountPreparedData { - nsk, - npk: from_npk, - vpk: from_vpk, - auth_acc: sender_pre, - proof, - }) - } - - pub(crate) async fn private_tx_two_accs_all_init( - &self, - from: AccountId, - to: AccountId, - instruction_data: InstructionData, - tx_pre_check: impl FnOnce(&Account, &Account) -> Result<(), ExecutionFailureKind>, - program: Program, - to_proof: MembershipProof, - ) -> Result<(HashType, [SharedSecretKey; 2]), ExecutionFailureKind> { - let AccountPreparedData { - nsk: from_nsk, - npk: from_npk, - vpk: from_vpk, - auth_acc: sender_pre, - proof: from_proof, - } = self.private_acc_preparation(from, true, true).await?; - - let AccountPreparedData { - nsk: to_nsk, - npk: to_npk, - vpk: to_vpk, - auth_acc: recipient_pre, - proof: _, - } = self.private_acc_preparation(to, true, false).await?; - - tx_pre_check(&sender_pre.account, &recipient_pre.account)?; - - let eph_holder_from = EphemeralKeyHolder::new(&from_npk); - let shared_secret_from = eph_holder_from.calculate_shared_secret_sender(&from_vpk); - - let eph_holder_to = EphemeralKeyHolder::new(&to_npk); - let shared_secret_to = eph_holder_to.calculate_shared_secret_sender(&to_vpk); - - let (output, proof) = circuit::execute_and_prove( - &[sender_pre, recipient_pre], - &instruction_data, - &[1, 1], - &produce_random_nonces(2), - &[ - (from_npk, shared_secret_from.clone()), - (to_npk, shared_secret_to.clone()), - ], - &[ - (from_nsk.unwrap(), from_proof.unwrap()), - (to_nsk.unwrap(), to_proof), - ], - &program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![], - vec![], - vec![ - ( - from_npk, - from_vpk.clone(), - eph_holder_from.generate_ephemeral_public_key(), - ), - ( - to_npk, - to_vpk.clone(), - eph_holder_to.generate_ephemeral_public_key(), - ), - ], - output, - ) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - Ok(( - self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?, - [shared_secret_from, shared_secret_to], - )) - } - - pub(crate) async fn private_tx_two_accs_receiver_uninit( - &self, - from: AccountId, - to: AccountId, - instruction_data: InstructionData, - tx_pre_check: impl FnOnce(&Account, &Account) -> Result<(), ExecutionFailureKind>, - program: Program, - ) -> Result<(HashType, [SharedSecretKey; 2]), ExecutionFailureKind> { - let AccountPreparedData { - nsk: from_nsk, - npk: from_npk, - vpk: from_vpk, - auth_acc: sender_pre, - proof: from_proof, - } = self.private_acc_preparation(from, true, true).await?; - - let AccountPreparedData { - nsk: _, - npk: to_npk, - vpk: to_vpk, - auth_acc: recipient_pre, - proof: _, - } = self.private_acc_preparation(to, false, false).await?; - - tx_pre_check(&sender_pre.account, &recipient_pre.account)?; - - let eph_holder_from = EphemeralKeyHolder::new(&from_npk); - let shared_secret_from = eph_holder_from.calculate_shared_secret_sender(&from_vpk); - - let eph_holder_to = EphemeralKeyHolder::new(&to_npk); - let shared_secret_to = eph_holder_to.calculate_shared_secret_sender(&to_vpk); - - let (output, proof) = circuit::execute_and_prove( - &[sender_pre, recipient_pre], - &instruction_data, - &[1, 2], - &produce_random_nonces(2), - &[ - (from_npk, shared_secret_from.clone()), - (to_npk, shared_secret_to.clone()), - ], - &[(from_nsk.unwrap(), from_proof.unwrap())], - &program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![], - vec![], - vec![ - ( - from_npk, - from_vpk.clone(), - eph_holder_from.generate_ephemeral_public_key(), - ), - ( - to_npk, - to_vpk.clone(), - eph_holder_to.generate_ephemeral_public_key(), - ), - ], - output, - ) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - Ok(( - self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?, - [shared_secret_from, shared_secret_to], - )) - } - - pub(crate) async fn private_tx_two_accs_receiver_outer( - &self, - from: AccountId, - to_npk: NullifierPublicKey, - to_vpk: ViewingPublicKey, - instruction_data: InstructionData, - tx_pre_check: impl FnOnce(&Account, &Account) -> Result<(), ExecutionFailureKind>, - program: Program, - ) -> Result<(HashType, [SharedSecretKey; 2]), ExecutionFailureKind> { - let AccountPreparedData { - nsk: from_nsk, - npk: from_npk, - vpk: from_vpk, - auth_acc: sender_pre, - proof: from_proof, - } = self.private_acc_preparation(from, true, true).await?; - - let to_acc = nssa_core::account::Account::default(); - - tx_pre_check(&sender_pre.account, &to_acc)?; - - let recipient_pre = AccountWithMetadata::new(to_acc.clone(), false, &to_npk); - - let eph_holder = EphemeralKeyHolder::new(&to_npk); - - let shared_secret_from = eph_holder.calculate_shared_secret_sender(&from_vpk); - let shared_secret_to = eph_holder.calculate_shared_secret_sender(&to_vpk); - - let (output, proof) = circuit::execute_and_prove( - &[sender_pre, recipient_pre], - &instruction_data, - &[1, 2], - &produce_random_nonces(2), - &[ - (from_npk, shared_secret_from.clone()), - (to_npk, shared_secret_to.clone()), - ], - &[(from_nsk.unwrap(), from_proof.unwrap())], - &program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![], - vec![], - vec![ - ( - from_npk, - from_vpk.clone(), - eph_holder.generate_ephemeral_public_key(), - ), - ( - to_npk, - to_vpk.clone(), - eph_holder.generate_ephemeral_public_key(), - ), - ], - output, - ) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - Ok(( - self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?, - [shared_secret_from, shared_secret_to], - )) - } - - pub(crate) async fn deshielded_tx_two_accs( - &self, - from: AccountId, - to: AccountId, - instruction_data: InstructionData, - tx_pre_check: impl FnOnce(&Account, &Account) -> Result<(), ExecutionFailureKind>, - program: Program, - ) -> Result<(HashType, [nssa_core::SharedSecretKey; 1]), ExecutionFailureKind> { - let AccountPreparedData { - nsk: from_nsk, - npk: from_npk, - vpk: from_vpk, - auth_acc: sender_pre, - proof: from_proof, - } = self.private_acc_preparation(from, true, true).await?; - - let Ok(to_acc) = self.get_account_public(to).await else { - return Err(ExecutionFailureKind::KeyNotFoundError); - }; - - tx_pre_check(&sender_pre.account, &to_acc)?; - - let recipient_pre = AccountWithMetadata::new(to_acc.clone(), false, to); - - let eph_holder = EphemeralKeyHolder::new(&from_npk); - let shared_secret = eph_holder.calculate_shared_secret_sender(&from_vpk); - - let (output, proof) = circuit::execute_and_prove( - &[sender_pre, recipient_pre], - &instruction_data, - &[1, 0], - &produce_random_nonces(1), - &[(from_npk, shared_secret.clone())], - &[(from_nsk.unwrap(), from_proof.unwrap())], - &program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![to], - vec![], - vec![( - from_npk, - from_vpk.clone(), - eph_holder.generate_ephemeral_public_key(), - )], - output, - ) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - Ok(( - self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?, - [shared_secret], - )) - } - - pub(crate) async fn shielded_two_accs_all_init( - &self, - from: AccountId, - to: AccountId, - instruction_data: InstructionData, - tx_pre_check: impl FnOnce(&Account, &Account) -> Result<(), ExecutionFailureKind>, - program: Program, - to_proof: MembershipProof, - ) -> Result<(HashType, [SharedSecretKey; 1]), ExecutionFailureKind> { - let Ok(from_acc) = self.get_account_public(from).await else { - return Err(ExecutionFailureKind::KeyNotFoundError); - }; - - let AccountPreparedData { - nsk: to_nsk, - npk: to_npk, - vpk: to_vpk, - auth_acc: recipient_pre, - proof: _, - } = self.private_acc_preparation(to, true, false).await?; - - tx_pre_check(&from_acc, &recipient_pre.account)?; - - let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, from); - - let eph_holder = EphemeralKeyHolder::new(&to_npk); - let shared_secret = eph_holder.calculate_shared_secret_sender(&to_vpk); - - let (output, proof) = circuit::execute_and_prove( - &[sender_pre, recipient_pre], - &instruction_data, - &[0, 1], - &produce_random_nonces(1), - &[(to_npk, shared_secret.clone())], - &[(to_nsk.unwrap(), to_proof)], - &program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![from], - vec![from_acc.nonce], - vec![( - to_npk, - to_vpk.clone(), - eph_holder.generate_ephemeral_public_key(), - )], - output, - ) - .unwrap(); - - let signing_key = self.storage.user_data.get_pub_account_signing_key(&from); - - let Some(signing_key) = signing_key else { - return Err(ExecutionFailureKind::KeyNotFoundError); - }; - - let witness_set = WitnessSet::for_message(&message, proof, &[signing_key]); - - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - Ok(( - self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?, - [shared_secret], - )) - } - - pub(crate) async fn shielded_two_accs_receiver_uninit( - &self, - from: AccountId, - to: AccountId, - instruction_data: InstructionData, - tx_pre_check: impl FnOnce(&Account, &Account) -> Result<(), ExecutionFailureKind>, - program: Program, - ) -> Result<(HashType, [SharedSecretKey; 1]), ExecutionFailureKind> { - let Ok(from_acc) = self.get_account_public(from).await else { - return Err(ExecutionFailureKind::KeyNotFoundError); - }; - - let AccountPreparedData { - nsk: _, - npk: to_npk, - vpk: to_vpk, - auth_acc: recipient_pre, - proof: _, - } = self.private_acc_preparation(to, false, false).await?; - - tx_pre_check(&from_acc, &recipient_pre.account)?; - - let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, from); - - let eph_holder = EphemeralKeyHolder::new(&to_npk); - let shared_secret = eph_holder.calculate_shared_secret_sender(&to_vpk); - - let (output, proof) = circuit::execute_and_prove( - &[sender_pre, recipient_pre], - &instruction_data, - &[0, 2], - &produce_random_nonces(1), - &[(to_npk, shared_secret.clone())], - &[], - &program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![from], - vec![from_acc.nonce], - vec![( - to_npk, - to_vpk.clone(), - eph_holder.generate_ephemeral_public_key(), - )], - output, - ) - .unwrap(); - - let signing_key = self.storage.user_data.get_pub_account_signing_key(&from); - - let Some(signing_key) = signing_key else { - return Err(ExecutionFailureKind::KeyNotFoundError); - }; - - let witness_set = WitnessSet::for_message(&message, proof, &[signing_key]); - - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - Ok(( - self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?, - [shared_secret], - )) - } - - pub(crate) async fn shielded_two_accs_receiver_outer( - &self, - from: AccountId, - to_npk: NullifierPublicKey, - to_vpk: ViewingPublicKey, - instruction_data: InstructionData, - tx_pre_check: impl FnOnce(&Account, &Account) -> Result<(), ExecutionFailureKind>, - program: Program, - ) -> Result { - let Ok(from_acc) = self.get_account_public(from).await else { - return Err(ExecutionFailureKind::KeyNotFoundError); - }; - - let to_acc = Account::default(); - - tx_pre_check(&from_acc, &to_acc)?; - - let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, from); - let recipient_pre = AccountWithMetadata::new(to_acc.clone(), false, &to_npk); - - let eph_holder = EphemeralKeyHolder::new(&to_npk); - let shared_secret = eph_holder.calculate_shared_secret_sender(&to_vpk); - - let (output, proof) = circuit::execute_and_prove( - &[sender_pre, recipient_pre], - &instruction_data, - &[0, 2], - &produce_random_nonces(1), - &[(to_npk, shared_secret.clone())], - &[], - &program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![from], - vec![from_acc.nonce], - vec![( - to_npk, - to_vpk.clone(), - eph_holder.generate_ephemeral_public_key(), - )], - output, - ) - .unwrap(); - - let signing_key = self.storage.user_data.get_pub_account_signing_key(&from); - - let Some(signing_key) = signing_key else { - return Err(ExecutionFailureKind::KeyNotFoundError); - }; - - let witness_set = WitnessSet::for_message(&message, proof, &[signing_key]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - Ok(self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?) - } - - pub async fn register_account_under_authenticated_transfers_programs_private( - &self, - from: AccountId, - ) -> Result<(HashType, [SharedSecretKey; 1]), ExecutionFailureKind> { - let AccountPreparedData { - nsk: _, - npk: from_npk, - vpk: from_vpk, - auth_acc: sender_pre, - proof: _, - } = self.private_acc_preparation(from, false, false).await?; - - let eph_holder_from = EphemeralKeyHolder::new(&from_npk); - let shared_secret_from = eph_holder_from.calculate_shared_secret_sender(&from_vpk); - - let instruction: u128 = 0; - - let (output, proof) = circuit::execute_and_prove( - &[sender_pre], - &Program::serialize_instruction(instruction).unwrap(), - &[2], - &produce_random_nonces(1), - &[(from_npk, shared_secret_from.clone())], - &[], - &Program::authenticated_transfer_program().into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![], - vec![], - vec![( - from_npk, - from_vpk.clone(), - eph_holder_from.generate_ephemeral_public_key(), - )], - output, - ) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - Ok(( - self.sequencer_client.send_transaction(NSSATransaction::PrivacyPreserving(tx).into()).await?, - [shared_secret_from], - )) - } -}