diff --git a/Cargo.lock b/Cargo.lock index c63b5805..1c49d7e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3797,10 +3797,16 @@ dependencies = [ name = "indexer_ffi" version = "0.1.0" dependencies = [ + "anyhow", "cbindgen", "indexer_service", + "indexer_service_protocol", + "indexer_service_rpc", + "jsonrpsee", "log", + "nssa", "tokio", + "url", ] [[package]] @@ -3916,6 +3922,7 @@ dependencies = [ "hex", "indexer_ffi", "indexer_service", + "indexer_service_protocol", "indexer_service_rpc", "jsonrpsee", "key_protocol", 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/artifacts/test_program_methods/auth_transfer_pda_proxy.bin b/artifacts/test_program_methods/auth_transfer_pda_proxy.bin new file mode 100644 index 00000000..2f09d761 Binary files /dev/null and b/artifacts/test_program_methods/auth_transfer_pda_proxy.bin differ diff --git a/artifacts/test_program_methods/auth_transfer_proxy.bin b/artifacts/test_program_methods/auth_transfer_proxy.bin new file mode 100644 index 00000000..662f2d06 Binary files /dev/null and b/artifacts/test_program_methods/auth_transfer_proxy.bin differ diff --git a/indexer_ffi/Cargo.toml b/indexer/ffi/Cargo.toml similarity index 71% rename from indexer_ffi/Cargo.toml rename to indexer/ffi/Cargo.toml index b55230c6..1e6b1468 100644 --- a/indexer_ffi/Cargo.toml +++ b/indexer/ffi/Cargo.toml @@ -5,9 +5,16 @@ name = "indexer_ffi" version = "0.1.0" [dependencies] +nssa.workspace = true indexer_service.workspace = true +indexer_service_rpc = { workspace = true, features = ["client"] } +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/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 new file mode 100644 index 00000000..7626b3b3 --- /dev/null +++ b/indexer/ffi/indexer_ffi.h @@ -0,0 +1,685 @@ +#include +#include +#include +#include + +typedef enum OperationStatus { + Ok = 0, + NullPointer = 1, + InitializationError = 2, + 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; + void *indexer_client; +} IndexerServiceFFI; + +/** + * 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_IndexerServiceFFI__OperationStatus { + struct IndexerServiceFFI *value; + enum OperationStatus error; +} 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; + +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. + */ + 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]. + */ + 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_FfiBlockOpt__OperationStatus { + FfiBlockOpt *value; + 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. + * + * # Arguments + * + * - `config_path`: A pointer to a string representing the path to the configuration file. + * - `port`: Number representing a port, on which indexers RPC will start. + * + * # Returns + * + * An `InitializedIndexerServiceFFIResult` containing either a pointer to the + * initialized `IndexerServiceFFI` or an error code. + */ +InitializedIndexerServiceFFIResult start_indexer(const char *config_path, uint16_t port); + +/** + * 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 + */ +enum OperationStatus stop_indexer(struct IndexerServiceFFI *indexer); + +/** + * # Safety + * It's up to the caller to pass a proper pointer, if somehow from c/c++ side + * this is called with a type which doesn't come from a returned `CString` it + * will cause a segfault. + */ +void free_cstring(char *block); + +/** + * Query the last block id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. + * + * # Returns + * + * A `PointerResult` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance + */ +struct PointerResult_u64__OperationStatus query_last_block(const struct IndexerServiceFFI *indexer); + +/** + * Query the block by id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. + * - `block_id`: `u64` number of block id + * + * # 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(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/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 similarity index 84% rename from indexer_ffi/src/api/lifecycle.rs rename to indexer/ffi/src/api/lifecycle.rs index 735efd4d..c9cd859d 100644 --- a/indexer_ffi/src/api/lifecycle.rs +++ b/indexer/ffi/src/api/lifecycle.rs @@ -2,7 +2,15 @@ use std::{ffi::c_char, path::PathBuf}; use tokio::runtime::Runtime; -use crate::{IndexerServiceFFI, api::PointerResult, errors::OperationStatus}; +use crate::{ + IndexerServiceFFI, + api::{ + PointerResult, + client::{UrlProtocol, addr_to_url}, + }, + client::{IndexerClient, IndexerClientTrait as _}, + 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/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 64% rename from indexer_ffi/src/api/mod.rs rename to indexer/ffi/src/api/mod.rs index e84a3913..ea2b91d7 100644 --- a/indexer_ffi/src/api/mod.rs +++ b/indexer/ffi/src/api/mod.rs @@ -1,5 +1,8 @@ pub use result::PointerResult; +pub mod client; pub mod lifecycle; pub mod memory; +pub mod query; pub mod result; +pub mod types; diff --git a/indexer/ffi/src/api/query.rs b/indexer/ffi/src/api/query.rs new file mode 100644 index 00000000..1e39d961 --- /dev/null +++ b/indexer/ffi/src/api/query.rs @@ -0,0 +1,334 @@ +use indexer_service_protocol::{AccountId, HashType}; +use indexer_service_rpc::RpcClient as _; + +use crate::{ + IndexerServiceFFI, + api::{ + PointerResult, + types::{ + FfiAccountId, FfiBlockId, FfiHashType, FfiOption, FfiVec, + account::FfiAccount, + block::{FfiBlock, FfiBlockOpt}, + transaction::FfiTransaction, + }, + }, + errors::OperationStatus, +}; + +/// Query the last block id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. +/// +/// # 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_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, + ) +} + +/// Query the block by id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be queried. +/// - `block_id`: `u64` number of block id +/// +/// # 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( + indexer: *const IndexerServiceFFI, + 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); + } + + 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_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 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/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 new file mode 100644 index 00000000..6c35347f --- /dev/null +++ b/indexer/ffi/src/api/types/account.rs @@ -0,0 +1,119 @@ +use indexer_service_protocol::ProgramId; + +use crate::api::types::{FfiBytes32, FfiProgramId, FfiU128}; + +/// 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: *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, +} + +// Helper functions to convert between Rust and FFI types + +impl From<&nssa::AccountId> for FfiBytes32 { + fn from(id: &nssa::AccountId) -> Self { + Self::from_account_id(id) + } +} + +impl From for FfiAccount { + fn from(value: nssa::Account) -> Self { + 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: program_owner, + }; + Self { + program_owner, + balance: balance.into(), + data, + data_len, + data_cap, + 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(program_owner.data), + balance: balance.into(), + data: indexer_service_protocol::Data(unsafe { + Vec::from_raw_parts(data, data_len, data_cap) + }), + 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(), + } + } +} + +/// 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 new file mode 100644 index 00000000..bca2fdb5 --- /dev/null +++ b/indexer/ffi/src/api/types/block.rs @@ -0,0 +1,199 @@ +use indexer_service_protocol::{ + BedrockStatus, Block, BlockHeader, HashType, MantleMsgId, Signature, +}; + +use crate::api::types::{ + FfiBlockId, FfiHashType, FfiMsgId, FfiOption, FfiSignature, FfiTimestamp, FfiVec, + transaction::free_ffi_transaction_vec, vectors::FfiBlockBody, +}; + +#[repr(C)] +pub struct FfiBlock { + pub header: FfiBlockHeader, + pub body: FfiBlockBody, + pub bedrock_status: FfiBedrockStatus, + pub bedrock_parent_id: FfiMsgId, +} + +impl From for FfiBlock { + fn from(value: Block) -> Self { + let Block { + header, + body, + bedrock_status, + bedrock_parent_id, + } = value; + + Self { + header: header.into(), + body: body + .transactions + .into_iter() + .map(Into::into) + .collect::>() + .into(), + bedrock_status: bedrock_status.into(), + bedrock_parent_id: bedrock_parent_id.into(), + } + } +} + +pub type FfiBlockOpt = FfiOption; + +#[repr(C)] +pub struct FfiBlockHeader { + pub block_id: FfiBlockId, + pub prev_block_hash: FfiHashType, + pub hash: FfiHashType, + pub timestamp: FfiTimestamp, + pub signature: FfiSignature, +} + +impl From for FfiBlockHeader { + fn from(value: BlockHeader) -> Self { + let BlockHeader { + block_id, + prev_block_hash, + hash, + timestamp, + signature, + } = value; + + Self { + block_id, + prev_block_hash: prev_block_hash.into(), + hash: hash.into(), + timestamp, + signature: 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, + } + } +} + +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 new file mode 100644 index 00000000..2e7a77ad --- /dev/null +++ b/indexer/ffi/src/api/types/mod.rs @@ -0,0 +1,165 @@ +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; +pub type FfiTimestamp = u64; +pub type FfiSignature = FfiBytes64; +pub type FfiAccountId = FfiBytes32; +pub type FfiNonce = FfiU128; +pub type FfiPublicKey = FfiBytes32; + +impl From for FfiHashType { + fn from(value: HashType) -> Self { + Self { data: value.0 } + } +} + +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, + len, + capacity, + } + } +} + +impl From> for Vec { + fn from(value: FfiVec) -> Self { + unsafe { Self::from_raw_parts(value.entries, value.len, value.capacity) } + } +} + +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, + 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 new file mode 100644 index 00000000..ee3bd01b --- /dev/null +++ b/indexer/ffi/src/api/types/transaction.rs @@ -0,0 +1,548 @@ +use indexer_service_protocol::{ + 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::{ + FfiBytes32, FfiHashType, FfiOption, FfiProgramId, FfiPublicKey, FfiSignature, FfiVec, + vectors::{ + FfiAccountIdList, FfiAccountList, FfiEncryptedAccountDataList, FfiInstructionDataList, + FfiNonceList, FfiNullifierCommitmentSetList, FfiProgramDeploymentMessage, FfiProof, + FfiSignaturePubKeyList, FfiVecBytes32, FfiVecU8, + }, +}; + +#[repr(C)] +pub struct FfiPublicTransactionBody { + pub hash: FfiHashType, + pub message: FfiPublicMessage, + pub witness_set: FfiSignaturePubKeyList, +} + +impl From for FfiPublicTransactionBody { + fn from(value: PublicTransaction) -> Self { + let PublicTransaction { + hash, + message, + witness_set, + } = value; + + Self { + hash: hash.into(), + message: message.into(), + witness_set: witness_set + .signatures_and_public_keys + .into_iter() + .map(Into::into) + .collect::>() + .into(), + } + } +} + +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, + pub account_ids: FfiAccountIdList, + pub nonces: FfiNonceList, + pub instruction_data: FfiInstructionDataList, +} + +impl From for FfiPublicMessage { + fn from(value: PublicMessage) -> Self { + let PublicMessage { + program_id, + account_ids, + nonces, + instruction_data, + } = value; + + Self { + program_id: program_id.into(), + account_ids: account_ids + .into_iter() + .map(Into::into) + .collect::>() + .into(), + nonces: nonces + .into_iter() + .map(Into::into) + .collect::>() + .into(), + instruction_data: instruction_data.into(), + } + } +} + +#[repr(C)] +pub struct FfiPrivateTransactionBody { + pub hash: FfiHashType, + pub message: FfiPrivacyPreservingMessage, + pub witness_set: FfiSignaturePubKeyList, + pub proof: FfiProof, +} + +impl From for FfiPrivateTransactionBody { + fn from(value: PrivacyPreservingTransaction) -> Self { + let PrivacyPreservingTransaction { + hash, + message, + witness_set, + } = value; + + Self { + hash: hash.into(), + message: message.into(), + witness_set: witness_set + .signatures_and_public_keys + .into_iter() + .map(Into::into) + .collect::>() + .into(), + proof: witness_set + .proof + .expect("Private execution: proof must be present") + .0 + .into(), + } + } +} + +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, + pub nonces: FfiNonceList, + pub public_post_states: FfiAccountList, + pub encrypted_private_post_states: FfiEncryptedAccountDataList, + pub new_commitments: FfiVecBytes32, + 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 { + 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: public_account_ids + .into_iter() + .map(Into::into) + .collect::>() + .into(), + nonces: nonces + .into_iter() + .map(Into::into) + .collect::>() + .into(), + public_post_states: 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: encrypted_private_post_states + .into_iter() + .map(Into::into) + .collect::>() + .into(), + new_commitments: new_commitments + .into_iter() + .map(|comm| FfiBytes32 { data: comm.0 }) + .collect::>() + .into(), + new_nullifiers: new_nullifiers + .into_iter() + .map(Into::into) + .collect::>() + .into(), + block_validity_window: cast_validity_window(block_validity_window), + timestamp_validity_window: cast_validity_window(timestamp_validity_window), + } + } +} + +#[repr(C)] +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: FfiVecU8, + pub epk: FfiVecU8, + pub view_tag: u8, +} + +impl From for FfiEncryptedAccountData { + fn from(value: EncryptedAccountData) -> Self { + let EncryptedAccountData { + ciphertext, + epk, + view_tag, + } = value; + + Self { + ciphertext: ciphertext.0.into(), + epk: epk.0.into(), + view_tag, + } + } +} + +#[repr(C)] +pub struct FfiSignaturePubKeyEntry { + pub signature: FfiSignature, + pub public_key: FfiPublicKey, +} + +impl From<(Signature, PublicKey)> for FfiSignaturePubKeyEntry { + fn from(value: (Signature, PublicKey)) -> Self { + Self { + signature: value.0.into(), + public_key: value.1.into(), + } + } +} + +#[repr(C)] +pub struct FfiProgramDeploymentTransactionBody { + pub hash: FfiHashType, + 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 { + let ProgramDeploymentTransaction { hash, message } = value; + + Self { + hash: hash.into(), + message: message.bytecode.into(), + } + } +} + +#[repr(C)] +pub struct FfiTransactionBody { + pub public_body: *mut FfiPublicTransactionBody, + pub private_body: *mut FfiPrivateTransactionBody, + pub program_deployment_body: *mut FfiProgramDeploymentTransactionBody, +} + +#[repr(C)] +pub struct FfiTransaction { + pub body: FfiTransactionBody, + 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::Private, + }, + 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::ProgramDeploy, + }, + } + } +} + +#[repr(C)] +pub enum FfiTransactionKind { + Public = 0x0, + Private, + 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 new file mode 100644 index 00000000..46f08737 --- /dev/null +++ b/indexer/ffi/src/api/types/vectors.rs @@ -0,0 +1,31 @@ +use crate::api::types::{ + FfiAccountId, FfiBytes32, FfiNonce, FfiVec, + account::FfiAccount, + 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/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/errors.rs b/indexer/ffi/src/errors.rs similarity index 94% rename from indexer_ffi/src/errors.rs rename to 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 similarity index 59% rename from indexer_ffi/src/indexer.rs rename to indexer/ffi/src/indexer.rs index c110b183..33800356 100644 --- a/indexer_ffi/src/indexer.rs +++ b/indexer/ffi/src/indexer.rs @@ -3,18 +3,26 @@ use std::{ffi::c_void, net::SocketAddr}; use indexer_service::IndexerHandle; use tokio::runtime::Runtime; +use crate::client::IndexerClient; + #[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 +33,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 +58,7 @@ impl IndexerServiceFFI { indexer_handle.addr() } - /// Helper to get indexer handle addr. + /// Helper to get indexer handle ref. /// /// # Safety /// @@ -64,6 +73,38 @@ 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") + } + } + + /// 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 @@ -72,6 +113,7 @@ impl Drop for IndexerServiceFFI { let Self { indexer_handle, runtime, + indexer_client, } = self; if indexer_handle.is_null() { @@ -80,7 +122,11 @@ impl Drop for IndexerServiceFFI { if runtime.is_null() { log::error!("Attempted to drop a null tokio runtime pointer. This is a bug"); } + 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/indexer_ffi/src/lib.rs b/indexer/ffi/src/lib.rs similarity index 93% rename from indexer_ffi/src/lib.rs rename to 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; diff --git a/indexer_ffi/indexer_ffi.h b/indexer_ffi/indexer_ffi.h deleted file mode 100644 index 7c7d9a4d..00000000 --- a/indexer_ffi/indexer_ffi.h +++ /dev/null @@ -1,76 +0,0 @@ -#include -#include -#include -#include - -typedef enum OperationStatus { - Ok = 0, - NullPointer = 1, - InitializationError = 2, -} OperationStatus; - -typedef struct IndexerServiceFFI { - void *indexer_handle; - void *runtime; -} IndexerServiceFFI; - -/** - * 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_IndexerServiceFFI__OperationStatus { - struct IndexerServiceFFI *value; - enum OperationStatus error; -} PointerResult_IndexerServiceFFI__OperationStatus; - -typedef struct PointerResult_IndexerServiceFFI__OperationStatus InitializedIndexerServiceFFIResult; - -/** - * Creates and starts an indexer based on the provided - * configuration file path. - * - * # Arguments - * - * - `config_path`: A pointer to a string representing the path to the configuration file. - * - `port`: Number representing a port, on which indexers RPC will start. - * - * # Returns - * - * An `InitializedIndexerServiceFFIResult` containing either a pointer to the - * initialized `IndexerServiceFFI` or an error code. - */ -InitializedIndexerServiceFFIResult start_indexer(const char *config_path, uint16_t port); - -/** - * 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 - */ -enum OperationStatus stop_indexer(struct IndexerServiceFFI *indexer); - -/** - * # Safety - * It's up to the caller to pass a proper pointer, if somehow from c/c++ side - * this is called with a type which doesn't come from a returned `CString` it - * will cause a segfault. - */ -void free_cstring(char *block); - -bool is_ok(const enum OperationStatus *self); - -bool is_error(const enum OperationStatus *self); diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index a1704cdf..5f1f1037 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -25,6 +25,7 @@ jsonrpsee = { workspace = true, features = ["ws-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/src/test_context_ffi.rs b/integration_tests/src/test_context_ffi.rs index b29ace7c..d03a4e00 100644 --- a/integration_tests/src/test_context_ffi.rs +++ b/integration_tests/src/test_context_ffi.rs @@ -266,6 +266,11 @@ impl BlockingTestContextFFI { pub fn runtime_clone(&self) -> Arc { Arc::::clone(&self.runtime) } + + #[must_use] + pub const fn indexer_ffi(&self) -> *const IndexerServiceFFI { + &raw const (self.indexer_ffi) + } } impl Drop for BlockingTestContextFFI { diff --git a/integration_tests/tests/indexer_ffi.rs b/integration_tests/tests/indexer_ffi.rs index ac1dab4c..bbc329e3 100644 --- a/integration_tests/tests/indexer_ffi.rs +++ b/integration_tests/tests/indexer_ffi.rs @@ -1,11 +1,18 @@ #![expect( clippy::shadow_unrelated, clippy::tests_outside_test_module, + clippy::undocumented_unsafe_blocks, reason = "We don't care about these in tests" )] use anyhow::{Context as _, Result}; -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, @@ -17,6 +24,23 @@ 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; + + 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] fn indexer_test_run_ffi() -> Result<()> { let blocking_ctx = BlockingTestContextFFI::new()?; @@ -28,10 +52,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(()) } @@ -40,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"); @@ -48,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(()) @@ -82,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 { @@ -175,14 +213,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 }; + 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 }; + let acc2_ind_state: indexer_service_protocol::Account = acc2_ind_state_pre.into(); info!("Checking correct state transition"); let acc1_seq_state = @@ -208,6 +253,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 @@ -269,10 +315,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 }; + 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(), diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs new file mode 100644 index 00000000..ecf3a4b4 --- /dev/null +++ b/integration_tests/tests/shared_accounts.rs @@ -0,0 +1,229 @@ +#![expect( + 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. +//! +//! 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".into(), + }); + 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::PrivateGms { + group: "test-group".into(), + label: Some("shared-acc".into()), + 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 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()); + + info!("Shared account registered: {shared_account_id}"); + Ok(()) +} + +/// GMS seal/unseal round-trip via invite/join, verify key agreement. +#[test] +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?; + + // 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 = + key_protocol::key_management::group_key_holder::SealingPublicKey::from_scalar(sealing_sk); + + let holder = ctx + .wallet() + .storage() + .user_data + .group_key_holder("alice-group") + .context("Group not found")?; + let sealed = holder.seal_for(&sealing_pk); + let sealed_hex = hex::encode(&sealed); + + // Join under a different name (simulating Bob receiving the sealed GMS) + let command = Command::Group(GroupSubcommand::Join { + name: "bob-copy".into(), + sealed: sealed_hex, + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + // Both derive the same keys for the same derivation seed + 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 seed = [42_u8; 32]; + let alice_npk = alice_holder + .derive_keys_for_shared_account(&seed) + .generate_nullifier_public_key(); + let bob_npk = bob_holder + .derive_keys_for_shared_account(&seed) + .generate_nullifier_public_key(); + + assert_eq!( + alice_npk, bob_npk, + "Key agreement: same GMS produces same keys" + ); + + 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 = "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?; + + // Create group and shared account + let command = Command::Group(GroupSubcommand::New { + name: "fund-group".into(), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateGms { + group: "fund-group".into(), + label: None, + 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_account(&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 1e7bf4e4..b07ab93c 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}; @@ -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; @@ -83,28 +103,54 @@ 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 `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, 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(derivation_seed); + 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 @@ -118,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()); @@ -195,6 +241,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() { @@ -203,8 +251,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(), @@ -220,10 +268,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()); @@ -237,10 +285,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()); @@ -254,10 +302,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()); @@ -269,7 +317,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])); @@ -289,19 +337,17 @@ 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, u128::MAX); 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([ - 251, 228, 245, 3, 160, 134, 97, 69, 187, 157, 170, 192, 165, 216, 166, 79, 179, 187, - 125, 146, 36, 192, 232, 110, 198, 47, 24, 10, 223, 25, 108, 5, + 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, u128::MAX); assert_eq!(npk, expected_npk); assert_eq!(account_id, expected_account_id); @@ -319,10 +365,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); @@ -340,7 +386,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) @@ -360,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()); @@ -368,10 +414,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(), ); } @@ -391,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))); } @@ -406,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; @@ -425,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); } @@ -454,7 +501,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(); @@ -479,7 +526,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 @@ -488,13 +535,13 @@ 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"); // 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); @@ -502,4 +549,52 @@ mod tests { let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk, 0); assert_eq!(alice_account_id, bob_account_id); } + + /// Same GMS + same derivation seed produces same keys for shared accounts. + #[test] + fn shared_account_same_gms_same_seed_produces_same_keys() { + let gms = [42_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(&derivation_seed) + .generate_nullifier_public_key(); + let npk_b = holder_b + .derive_keys_for_shared_account(&derivation_seed) + .generate_nullifier_public_key(); + + assert_eq!(npk_a, npk_b); + } + + /// Different derivation seeds produce different keys for shared accounts. + #[test] + 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]) + .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(&TEST_PROGRAM_ID, &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 082ad551..e973655e 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -21,7 +21,22 @@ pub struct UserPrivateAccountData { pub accounts: Vec<(PrivateAccountKind, Account)>, } +/// Metadata for a shared account (GMS-derived), stored alongside the cached plaintext state. +/// 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 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, +} + +#[derive(Clone, Debug)] pub struct NSSAUserData { /// Default public accounts. pub default_pub_account_signing_keys: BTreeMap, @@ -31,17 +46,16 @@ 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 private PDA accounts, keyed by `AccountId`. - /// Updated after each private PDA 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, + /// 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. + 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 { @@ -102,7 +116,8 @@ impl NSSAUserData { public_key_tree, private_key_tree, group_key_holders: BTreeMap::new(), - pda_accounts: BTreeMap::new(), + shared_private_accounts: BTreeMap::new(), + sealing_secret_key: None, }) } @@ -227,6 +242,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 { @@ -264,6 +315,92 @@ 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_private_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, + pda_program_id: 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])), + pda_program_id: Some([9; 8]), + 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, + pda_program_id: 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(&[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(), + ); + + // PDA and shared derivations don't collide + assert_ne!( + keys_a.generate_nullifier_public_key(), + pda_keys_a.generate_nullifier_public_key(), + ); } #[test] diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index e1a3350b..fe306caa 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -470,12 +470,13 @@ mod tests { ); } - /// Group PDA deposit: creates a new PDA and transfers balance from the - /// counterparty. Both accounts owned by `private_pda_spender`. + /// PDA init: initializes a new PDA under `authenticated_transfer`'s ownership. + /// The `auth_transfer_proxy` program chains to `authenticated_transfer` with `pda_seeds` + /// to establish authorization and the private PDA binding. #[test] - fn group_pda_deposit() { - let program = Program::private_pda_spender(); - let noop = Program::noop(); + fn private_pda_init() { + 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]); @@ -485,92 +486,129 @@ mod tests { let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0); let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); - // Sender (mask 0, public, owned by this program, has balance) + let auth_id = auth_transfer.id(); + let program_with_deps = + ProgramWithDependencies::new(program, [(auth_id, auth_transfer)].into()); + + // is_withdraw=false triggers init path (1 pre-state) + let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, false)).unwrap(); + + let result = execute_and_prove( + vec![pda_pre], + instruction, + vec![InputAccountIdentity::PrivatePdaInit { + npk, + ssk: shared_secret_pda, + identifier: 0, + }], + &program_with_deps, + ); + + let (output, _proof) = result.expect("PDA init should succeed"); + assert_eq!(output.new_commitments.len(), 1); + } + + /// PDA withdraw: chains to `authenticated_transfer` to move balance from PDA to recipient. + /// Uses a default PDA (amount=0) because testing with a pre-funded PDA requires a + /// two-tx sequence with membership proofs. + #[test] + fn private_pda_withdraw() { + 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) + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0); + let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); + + // Recipient (public) + let recipient_id = AccountId::new([88; 32]); + let recipient_pre = AccountWithMetadata::new( + Account { + program_owner: auth_transfer.id(), + balance: 10000, + ..Account::default() + }, + true, + recipient_id, + ); + + let auth_id = auth_transfer.id(); + let program_with_deps = + ProgramWithDependencies::new(program, [(auth_id, auth_transfer)].into()); + + // is_withdraw=true, amount=0 (PDA has no balance yet) + let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, true)).unwrap(); + + let result = execute_and_prove( + vec![pda_pre, recipient_pre], + instruction, + vec![ + InputAccountIdentity::PrivatePdaInit { + npk, + ssk: shared_secret_pda, + identifier: 0, + }, + InputAccountIdentity::Public, + ], + &program_with_deps, + ); + + let (output, _proof) = result.expect("PDA withdraw should succeed"); + assert_eq!(output.new_commitments.len(), 1); + } + + /// 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 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() { + let program = Program::authenticated_transfer_program(); + let shared_keys = test_private_account_keys_1(); + let shared_npk = shared_keys.npk(); + let shared_identifier: u128 = 42; + let shared_secret = SharedSecretKey::new(&[55; 32], &shared_keys.vpk()); + + // Sender: public account with balance, owned by auth-transfer let sender_id = AccountId::new([99; 32]); - let sender_pre = AccountWithMetadata::new( + let sender = AccountWithMetadata::new( Account { program_owner: program.id(), - balance: 10000, + balance: 1000, ..Account::default() }, true, sender_id, ); - let noop_id = noop.id(); - let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into()); + // 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); - let instruction = Program::serialize_instruction((seed, noop_id, 500_u128, true)).unwrap(); - - // PDA is mask 3 (private PDA), sender is mask 0 (public). - // The noop chained call is required to establish the mask-3 (seed, npk) binding - // that the circuit enforces for private PDAs. Without a caller providing pda_seeds, - // the circuit's binding check rejects the account. - let result = execute_and_prove( - vec![pda_pre, sender_pre], - instruction, - vec![ - InputAccountIdentity::PrivatePdaInit { - npk, - ssk: shared_secret_pda, - identifier: 0, - }, - InputAccountIdentity::Public, - ], - &program_with_deps, - ); - - let (output, _proof) = result.expect("group PDA deposit should succeed"); - // Only PDA (mask 3) produces a commitment; sender (mask 0) is public. - assert_eq!(output.new_commitments.len(), 1); - } - - /// Group PDA spend binding: the noop chained call with `pda_seeds` establishes - /// the mask-3 binding for an existing-but-default PDA. Uses amount=0 because - /// testing with a pre-funded PDA requires a two-tx sequence with membership proofs. - #[test] - fn group_pda_spend_binding() { - let program = Program::private_pda_spender(); - let noop = Program::noop(); - 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()); - - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0); - let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); - - let bob_id = AccountId::new([88; 32]); - let bob_pre = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 10000, - ..Account::default() - }, - true, - bob_id, - ); - - let noop_id = noop.id(); - let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into()); - - let instruction = Program::serialize_instruction((seed, noop_id, 0_u128, false)).unwrap(); + let balance_to_move: u128 = 100; + let instruction = Program::serialize_instruction(balance_to_move).unwrap(); let result = execute_and_prove( - vec![pda_pre, bob_pre], + vec![sender, recipient], instruction, vec![ - InputAccountIdentity::PrivatePdaInit { - npk, - ssk: shared_secret_pda, - identifier: 0, - }, InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + npk: shared_npk, + ssk: shared_secret, + identifier: shared_identifier, + }, ], - &program_with_deps, + &program.into(), ); - let (output, _proof) = result.expect("group PDA spend binding should succeed"); + let (output, _proof) = result.expect("shared account receive should succeed"); + // Sender is public (no commitment), recipient is private (1 commitment) assert_eq!(output.new_commitments.len(), 1); } diff --git a/nssa/src/program.rs b/nssa/src/program.rs index 3f67ef57..9c94cdd5 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/auth_transfer_proxy.rs b/test_program_methods/guest/src/bin/auth_transfer_proxy.rs new file mode 100644 index 00000000..17316f16 --- /dev/null +++ b/test_program_methods/guest/src/bin/auth_transfer_proxy.rs @@ -0,0 +1,97 @@ +use nssa_core::program::{ + AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, +}; + +/// PDA authorization program that delegates balance operations to `authenticated_transfer`. +/// +/// The PDA is owned by `authenticated_transfer`, not by this program. This program's role +/// is solely to provide PDA authorization via `pda_seeds` in chained calls. +/// +/// Instruction: `(pda_seed, auth_transfer_id, amount, is_withdraw)`. +/// +/// **Init** (`is_withdraw = false`, 1 pre-state `[pda]`): +/// Chains to `authenticated_transfer` with `instruction=0` (init path) and `pda_seeds=[seed]` +/// to initialize the PDA under `authenticated_transfer`'s ownership. +/// +/// **Withdraw** (`is_withdraw = true`, 2 pre-states `[pda, recipient]`): +/// Chains to `authenticated_transfer` with the amount and `pda_seeds=[seed]` to authorize +/// the PDA for a balance transfer. The actual balance modification happens in +/// `authenticated_transfer`, not here. +/// +/// **Deposit**: done directly via `authenticated_transfer` (no need for this program). +type Instruction = (PdaSeed, ProgramId, u128, bool); + +#[expect( + clippy::allow_attributes, + reason = "allow is needed because the clones are only redundant in test compilation" +)] +#[allow( + clippy::redundant_clone, + reason = "clones needed in non-test compilation" +)] +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (pda_seed, auth_transfer_id, amount, is_withdraw), + }, + instruction_words, + ) = read_nssa_inputs::(); + + 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]"); + }; + + // 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()); + + // Chain to authenticated_transfer with pda_seeds to authorize the PDA. + // The circuit's resolve_authorization_and_record_bindings establishes the + // 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 = + 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, recipient_post], + ) + .with_chained_calls(vec![auth_call]) + .write(); + } else { + // 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 pda_post = AccountPostState::new(pda_pre.account.clone()); + + // 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( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![pda_post], + ) + .with_chained_calls(vec![auth_call]) + .write(); + } +} diff --git a/test_program_methods/guest/src/bin/private_pda_spender.rs b/test_program_methods/guest/src/bin/private_pda_spender.rs deleted file mode 100644 index 04ef91a4..00000000 --- a/test_program_methods/guest/src/bin/private_pda_spender.rs +++ /dev/null @@ -1,118 +0,0 @@ -use nssa_core::program::{ - AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId, ProgramInput, ProgramOutput, - read_nssa_inputs, -}; - -/// Single program for group PDA operations. Owns and operates the PDA directly. -/// -/// Instruction: `(pda_seed, noop_program_id, amount, is_deposit)`. -/// Pre-states: `[group_pda, counterparty]`. -/// -/// **Deposit** (`is_deposit = true`, new PDA): -/// Claims PDA via `Claim::Pda(seed)`, increases PDA balance, decreases counterparty. -/// Counterparty must be authorized and owned by this program (or uninitialized). -/// -/// **Spend** (`is_deposit = false`, existing PDA): -/// Decreases PDA balance (this program owns it), increases counterparty. -/// Chains to a noop callee with `pda_seeds` to establish the mask-3 binding -/// that the circuit requires for existing private PDAs. -type Instruction = (PdaSeed, ProgramId, u128, bool); - -#[expect( - clippy::allow_attributes, - reason = "allow is needed because the clones are only redundant in test compilation" -)] -#[allow( - clippy::redundant_clone, - reason = "clones needed in non-test compilation" -)] -fn main() { - let ( - ProgramInput { - self_program_id, - caller_program_id, - pre_states, - instruction: (pda_seed, noop_id, amount, is_deposit), - }, - instruction_words, - ) = read_nssa_inputs::(); - - let Ok([pda_pre, counterparty_pre]) = <[_; 2]>::try_from(pre_states.clone()) else { - panic!("expected exactly 2 pre_states: [group_pda, counterparty]"); - }; - - 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" - ); - - 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); - - ProgramOutput::new( - self_program_id, - caller_program_id, - instruction_words, - pre_states, - vec![pda_post, counterparty_post], - ) - .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. - - let mut pda_account = pda_pre.account.clone(); - let mut counterparty_account = counterparty_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], &()) - .with_pda_seeds(vec![pda_seed]); - - ProgramOutput::new( - self_program_id, - caller_program_id, - instruction_words, - pre_states, - vec![pda_post, counterparty_post], - ) - .with_chained_calls(vec![noop_call]) - .write(); - } -} 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] diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index b5e80854..0e12e9a5 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -92,6 +92,23 @@ pub enum NewSubcommand { /// 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)] + /// 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. PrivateAccountsKey { @@ -183,9 +200,67 @@ impl WalletSubcommand for NewSubcommand { ); wallet_core.store_persistent_data().await?; - Ok(SubcommandReturnValue::RegisterAccount { account_id }) } + Self::PrivateGms { + group, + label, + pda, + seed, + program_id, + } => { + 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 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 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, pda_seed, pid)? + } else { + wallet_core.create_shared_regular_account(&group)? + }; + + if let Some(label) = label { + wallet_core + .storage + .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/cli/group.rs b/wallet/src/cli/group.rs index 5cdcc0af..e1dd9159 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 key_protocol::key_management::group_key_holder::{GroupKeyHolder, SealingPublicKey}; 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. @@ -17,36 +15,9 @@ 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, - /// 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,26 +27,22 @@ 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). + /// 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 label or Private/ whose VSK to use for decryption. - #[arg(long)] - account: String, - }, - /// Ratchet the GMS to exclude removed members. - Ratchet { - /// Group name. - name: 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 { @@ -88,62 +55,17 @@ 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"); - Ok(SubcommandReturnValue::Empty) - } - - Self::Import { name, gms, epoch } => { - if wallet_core - .storage() - .user_data - .get_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(|_| 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); - wallet_core.store_persistent_data().await?; - - println!("Imported group '{name}' at epoch {epoch}"); - Ok(SubcommandReturnValue::Empty) - } - - Self::Export { name } => { - let holder = wallet_core - .storage() - .user_data - .get_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}"); + println!("Created group '{name}'"); Ok(SubcommandReturnValue::Empty) } @@ -152,60 +74,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,80 +91,66 @@ 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 = + key_protocol::key_management::group_key_holder::SealingPublicKey::from_bytes( + key_bytes, + ); - let sealed = holder.seal_for(&recipient_vpk); + let sealed = holder.seal_for(&recipient_key); println!("{}", hex::encode(&sealed)); Ok(SubcommandReturnValue::Empty) } - Self::Join { - name, - sealed, - account, - } => { + Self::Join { name, sealed } => { if wallet_core .storage() .user_data - .get_group_key_holder(&name) + .group_key_holder(&name) .is_some() { 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")?; - // Resolve the account to get the VSK - let account_id: nssa::AccountId = account - .parse() - .context("Invalid account ID (use Private/)")?; - 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:?}"))?; - 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}"); + println!("Joined group '{name}'"); 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"))?; + 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 salt = [0_u8; 32]; - rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt); - holder.ratchet(salt); + let mut secret: nssa_core::encryption::Scalar = [0_u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut secret); + let public_key = SealingPublicKey::from_scalar(secret); - let epoch = holder.epoch(); + wallet_core.set_sealing_secret_key(secret); wallet_core.store_persistent_data().await?; - println!("Ratcheted group '{name}' to epoch {epoch}"); - println!("Re-invite remaining members with 'group invite'"); + println!("Sealing key generated."); + 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) } } 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/config.rs b/wallet/src/config.rs index e2905103..e5d43024 100644 --- a/wallet/src/config.rs +++ b/wallet/src/config.rs @@ -98,6 +98,21 @@ 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, + >, + /// 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 04f780dd..6b7cfa00 100644 --- a/wallet/src/helperfunctions.rs +++ b/wallet/src/helperfunctions.rs @@ -204,6 +204,9 @@ 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(), + sealing_secret_key: user_data.sealing_secret_key, } } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 2a31ea4b..dbcacaaf 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -52,6 +52,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")] @@ -99,6 +106,9 @@ impl WalletCore { accounts: persistent_accounts, last_synced_block, labels, + group_key_holders, + shared_private_accounts, + sealing_secret_key, } = PersistentStorage::from_path(&storage_path).with_context(|| { format!( "Failed to read persistent storage at {}", @@ -110,7 +120,13 @@ 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; + store.user_data.sealing_secret_key = sealing_secret_key; + Ok(store) + }, last_synced_block, ) } @@ -291,6 +307,161 @@ 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); + } + + /// 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); + } + + /// 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 entry.pda_seed.is_some() { + Some(PrivacyPreservingAccount::PrivatePdaOwned(account_id)) + } 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, + name: &str, + ) -> Option { + self.storage.user_data.group_key_holders.remove(name) + } + + /// Register a shared account in storage for sync tracking. + 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.insert_shared_private_account( + 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, u128::MAX); + + 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?) @@ -559,6 +730,77 @@ impl WalletCore { self.storage .insert_private_account_data(affected_account_id, &kind, new_acc); } + + // Scan for updates to shared accounts (GMS-derived). + self.sync_shared_private_accounts_with_tx(&tx); + } + + fn sync_shared_private_accounts_with_tx(&mut self, tx: &PrivacyPreservingTransaction) { + let shared_keys: Vec<_> = self + .storage + .user_data + .shared_private_accounts_iter() + .filter_map(|(&account_id, entry)| { + let holder = self + .storage + .user_data + .group_key_holder(&entry.group_label)?; + + 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"); + hasher.update(entry.identifier.to_le_bytes()); + let result: [u8; 32] = hasher.finalize().into(); + result + }; + 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; + 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:#?}"); + self.storage + .user_data + .update_shared_private_account_state(&account_id, new_acc); + } + } + } } #[must_use] 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/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index c37017c3..89b5383d 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -30,6 +30,15 @@ pub enum PrivacyPreservingAccount { vpk: ViewingPublicKey, identifier: Identifier, }, + /// A shared regular private account with externally-provided keys (e.g. from GMS). + /// 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, + vpk: ViewingPublicKey, + identifier: Identifier, + }, } impl PrivacyPreservingAccount { @@ -46,6 +55,7 @@ impl PrivacyPreservingAccount { | Self::PrivateForeign { .. } | Self::PrivatePdaOwned(_) | Self::PrivatePdaForeign { .. } + | Self::PrivateShared { .. } ) } } @@ -146,6 +156,16 @@ impl AccountManager { }; State::Private(pre) } + PrivacyPreservingAccount::PrivateShared { + nsk, + npk, + vpk, + identifier, + } => { + let pre = private_shared_preparation(wallet, nsk, npk, vpk, identifier).await?; + + State::Private(pre) + } }; states.push(state); @@ -280,40 +300,150 @@ async fn private_acc_preparation( account_id: AccountId, is_pda: bool, ) -> Result { - let Some((from_keys, from_acc, from_identifier)) = + if let Some((from_keys, from_acc, from_identifier)) = wallet.storage.user_data.get_private_account(account_id) - else { - return Err(ExecutionFailureKind::KeyNotFoundError); + { + let nsk = from_keys.private_key_holder.nullifier_secret_key; + let from_npk = from_keys.nullifier_public_key; + let from_vpk = from_keys.viewing_public_key; + + // TODO: Remove this unwrap, error types must be compatible + let proof = wallet + .check_private_account_initialized(account_id) + .await + .unwrap(); + + // TODO: Technically we could allow unauthorized owned accounts, but currently we don't have + // support from that in the wallet. + let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, account_id); + + let eph_holder = EphemeralKeyHolder::new(&from_npk); + let ssk = eph_holder.calculate_shared_secret_sender(&from_vpk); + let epk = eph_holder.generate_ephemeral_public_key(); + + return Ok(AccountPreparedData { + nsk: Some(nsk), + npk: from_npk, + identifier: from_identifier, + vpk: from_vpk, + pre_state: sender_pre, + proof, + ssk, + epk, + is_pda, + }); + } + + // Fallback: check shared storage for group PDAs + let entry = wallet + .storage + .user_data + .shared_private_account(&account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + + let pda_seed = entry.pda_seed.ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let program_id = entry.pda_program_id.ok_or(ExecutionFailureKind::KeyNotFoundError)?; + + let holder = wallet + .storage + .user_data + .group_key_holder(&entry.group_label) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + + let keys = holder.derive_keys_for_pda(&program_id, &pda_seed); + let nsk = keys.nullifier_secret_key; + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let identifier = entry.identifier; + let acc = entry.account.clone(); + + let exists = acc != nssa_core::account::Account::default(); + let pre_state = AccountWithMetadata::new(acc, exists, account_id); + + let proof = if exists { + wallet + .check_private_account_initialized(account_id) + .await + .unwrap_or(None) + } else { + None }; - let nsk = from_keys.private_key_holder.nullifier_secret_key; - - let from_npk = from_keys.nullifier_public_key; - let from_vpk = from_keys.viewing_public_key; - - // TODO: Remove this unwrap, error types must be compatible - let proof = wallet - .check_private_account_initialized(account_id) - .await - .unwrap(); - - // TODO: Technically we could allow unauthorized owned accounts, but currently we don't have - // support from that in the wallet. - let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, account_id); - - let eph_holder = EphemeralKeyHolder::new(&from_npk); - let ssk = eph_holder.calculate_shared_secret_sender(&from_vpk); + 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: Some(nsk), - npk: from_npk, - identifier: from_identifier, - vpk: from_vpk, - pre_state: sender_pre, + nsk: exists.then_some(nsk), + npk, + identifier, + vpk, + pre_state, proof, ssk, epk, - is_pda, + is_pda: true, }) } + +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_private_account(&account_id) + .map(|e| e.account.clone()) + .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()); + } +} 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 d317b31c..501ead50 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(), ) @@ -41,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, @@ -69,12 +76,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, 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(), 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], - )) - } -}