diff --git a/Cargo.lock b/Cargo.lock index 5ce66da95..c64ae1630 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3501,7 +3501,10 @@ dependencies = [ "async-trait", "bb8", "borsh 0.10.3", + "bs58 0.4.0", + "light-sdk", "log", + "photon-api", "solana-banks-client", "solana-client", "solana-program", diff --git a/Cargo.toml b/Cargo.toml index c917d781e..3077eaeaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,9 +72,11 @@ thiserror = "1.0" # Light Protocol light-client = { path = "client", version = "0.8.0" } light-indexed-merkle-tree = { path = "merkle-tree/indexed", version = "1.0.0" } +light-sdk = { path = "sdk", version = "0.8.0" } photon-api = { path = "photon-api" } # Math and crypto +bs58 = "0.4.0" num-bigint = "0.4.6" [patch.crates-io] diff --git a/client/Cargo.toml b/client/Cargo.toml index e34da5213..a29b0d673 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -17,10 +17,17 @@ solana-transaction-status = { workspace = true } # Anchor compatibility borsh = { workspace = true } +# Light Protocol +light-sdk = { workspace = true } +photon-api = { workspace = true } + # Async ecosystem tokio = { workspace = true } async-trait = { workspace = true } bb8 = { workspace = true } +# Math and crypto +bs58 = { workspace = true } + log = { workspace = true } thiserror = { workspace = true } diff --git a/client/src/indexer/mod.rs b/client/src/indexer/mod.rs index 9499eca14..a6f062cb9 100644 --- a/client/src/indexer/mod.rs +++ b/client/src/indexer/mod.rs @@ -1 +1,76 @@ -pub trait Indexer: Sync + Send + Debug + 'static {} +use std::{fmt::Debug, future::Future}; + +use light_sdk::{ + address::AddressWithMerkleContext, + compressed_account::CompressedAccountWithMerkleContext, + proof::{MerkleProof, NewAddressProofWithContext, ProofRpcResult}, +}; +use solana_sdk::pubkey::Pubkey; +use thiserror::Error; + +pub mod photon; + +#[derive(Error, Debug)] +pub enum IndexerError { + // #[error("RPC Error: {0}")] + // RpcError(#[from] solana_client::client_error::ClientError), + // #[error("failed to deserialize account data")] + // DeserializeError(#[from] solana_sdk::program_error::ProgramError), + // #[error(transparent)] + // HashSetError(#[from] HashSetError), + // #[error(transparent)] + // PhotonApiError(PhotonApiErrorWrapper), + // #[error("error: {0:?}")] + // Custom(String), + #[error("unknown error")] + Unknown, + + #[error("indexer returned an empty result")] + EmptyResult, + + #[error("failed to hash a compressed account")] + AccountHash, +} + +/// Format of hashes. +/// +/// Depending on the context, it's better to treat hashes either as arrays or +/// as strings. +/// +/// Photon API takes hashes as strings. +/// +/// In Solana program tests it's more convenient to operate on arrays. The +/// `Array` variant is being converted to strings by indexer implementations, +/// so the conversion doesn't have to be done independently in tests. +/// +/// In forester, which only uses Photon, it makes more sense to just use +/// strings and avoid conversions. +#[derive(Debug)] +pub enum Hashes<'a> { + Array(&'a [[u8; 32]]), + String(&'a [String]), +} + +pub trait Indexer: Sync + Send + Debug + 'static { + fn get_compressed_accounts_by_owner( + &self, + owner: &Pubkey, + ) -> impl Future, IndexerError>> + Send + Sync; + + fn get_multiple_compressed_account_proofs<'a>( + &self, + hashes: Hashes<'a>, + ) -> impl Future, IndexerError>> + Send + Sync; + + fn get_multiple_new_address_proofs( + &self, + merkle_tree_pubkey: &Pubkey, + addresses: &[[u8; 32]], + ) -> impl Future, IndexerError>> + Send + Sync; + + fn get_validity_proof( + &self, + compressed_accounts: &[CompressedAccountWithMerkleContext], + new_addresses: &[AddressWithMerkleContext], + ) -> impl Future> + Send + Sync; +} diff --git a/client/src/indexer/photon.rs b/client/src/indexer/photon.rs new file mode 100644 index 000000000..3c6f45b83 --- /dev/null +++ b/client/src/indexer/photon.rs @@ -0,0 +1,266 @@ +use std::str::FromStr; + +use light_sdk::{ + address::AddressWithMerkleContext, + compressed_account::{ + CompressedAccount, CompressedAccountData, CompressedAccountWithMerkleContext, + }, + merkle_context::MerkleContext, + proof::{CompressedProof, MerkleProof, NewAddressProofWithContext, ProofRpcResult}, +}; +use photon_api::{ + apis::configuration::{ApiKey, Configuration}, + models::{ + AddressWithTree, GetCompressedAccountsByOwnerPostRequestParams, + GetMultipleCompressedAccountProofsPostRequest, GetMultipleNewAddressProofsV2PostRequest, + GetValidityProofPostRequest, GetValidityProofPostRequestParams, + }, +}; +use solana_sdk::pubkey::Pubkey; + +use crate::utils::decode_hash; + +use super::{Hashes, Indexer, IndexerError}; + +#[derive(Debug)] +pub struct PhotonIndexer { + configuration: Configuration, +} + +impl PhotonIndexer { + pub fn new(base_path: String, api_key: Option) -> Self { + let configuration = Configuration { + base_path, + api_key: api_key.map(|key| ApiKey { + prefix: Some("api-key".to_string()), + key, + }), + ..Default::default() + }; + Self { configuration } + } +} + +impl Indexer for PhotonIndexer { + async fn get_compressed_accounts_by_owner( + &self, + owner: &solana_sdk::pubkey::Pubkey, + ) -> Result, IndexerError> { + let request = photon_api::models::GetCompressedAccountsByOwnerPostRequest { + params: Box::from(GetCompressedAccountsByOwnerPostRequestParams { + cursor: None, + data_slice: None, + filters: None, + limit: None, + owner: owner.to_string(), + }), + ..Default::default() + }; + + let result = photon_api::apis::default_api::get_compressed_accounts_by_owner_post( + &self.configuration, + request, + ) + .await + .unwrap(); + let items = result.result.ok_or(IndexerError::EmptyResult)?.value.items; + + // PANICS: We assume correctness of data returned by Photon. + let compressed_accounts = items + .iter() + .map(|account| CompressedAccountWithMerkleContext { + compressed_account: CompressedAccount { + owner: Pubkey::from_str(&account.owner).unwrap(), + lamports: account.lamports as u64, + address: account + .address + .as_ref() + .map(|address| decode_hash(&address)), + data: account.data.as_ref().map(|data| CompressedAccountData { + discriminator: (data.discriminator as u64).to_le_bytes(), + data: bs58::decode(&data.data).into_vec().unwrap(), + data_hash: decode_hash(&data.data_hash), + }), + }, + merkle_context: MerkleContext { + merkle_tree_pubkey: Pubkey::from_str(&account.tree).unwrap(), + nullifier_queue_pubkey: Pubkey::new_unique(), + leaf_index: account.leaf_index as u32, + queue_index: None, + }, + }) + .collect::>(); + Ok(compressed_accounts) + } + + async fn get_multiple_compressed_account_proofs<'a>( + &self, + hashes: Hashes<'a>, + ) -> Result, IndexerError> { + let hashes = match hashes { + Hashes::Array(hashes) => hashes + .iter() + .map(|hash| bs58::encode(hash).into_string()) + .collect::>(), + Hashes::String(hashes) => hashes.to_vec(), + }; + + let request = GetMultipleCompressedAccountProofsPostRequest { + params: hashes, + ..Default::default() + }; + + let result = photon_api::apis::default_api::get_multiple_compressed_account_proofs_post( + &self.configuration, + request, + ) + .await + .unwrap(); + let items = result.result.ok_or(IndexerError::EmptyResult)?.value; + + let proofs = items + .iter() + .map(|proof| MerkleProof { + hash: proof.hash.clone(), + leaf_index: proof.leaf_index, + merkle_tree: proof.merkle_tree.clone(), + proof: Vec::new(), + root_seq: proof.root_seq, + }) + .collect::>(); + + Ok(proofs) + } + + async fn get_multiple_new_address_proofs( + &self, + merkle_tree_pubkey: &Pubkey, + addresses: &[[u8; 32]], + ) -> Result, IndexerError> { + let params: Vec = addresses + .iter() + .map(|x| AddressWithTree { + address: bs58::encode(x).into_string(), + tree: bs58::encode(&merkle_tree_pubkey).into_string(), + }) + .collect(); + let request = GetMultipleNewAddressProofsV2PostRequest { + params, + ..Default::default() + }; + + let result = photon_api::apis::default_api::get_multiple_new_address_proofs_v2_post( + &self.configuration, + request, + ) + .await + .unwrap(); + let items = result.result.ok_or(IndexerError::EmptyResult)?.value; + + let proofs = items + .iter() + .map(|proof| NewAddressProofWithContext { + merkle_tree: Pubkey::from_str(&proof.merkle_tree).unwrap(), + root: decode_hash(&proof.root), + root_seq: proof.root_seq, + low_address_index: proof.low_element_leaf_index as u64, + low_address_value: decode_hash(&proof.lower_range_address), + low_address_next_index: proof.next_index as u64, + low_address_next_value: decode_hash(&proof.higher_range_address), + low_address_proof: proof + .proof + .iter() + .take( + // Proof nodes without canopy + 16, + ) + .map(|proof| decode_hash(proof)) + .collect::>() + .try_into() + .unwrap(), + new_low_element: None, + new_element: None, + new_element_next_value: None, + }) + .collect::>(); + + Ok(proofs) + } + + async fn get_validity_proof( + &self, + compressed_accounts: &[CompressedAccountWithMerkleContext], + new_addresses: &[AddressWithMerkleContext], + ) -> Result { + let hashes = if compressed_accounts.is_empty() { + None + } else { + let mut hashes = Vec::with_capacity(compressed_accounts.len()); + for account in compressed_accounts.iter() { + let hash = account.hash().map_err(|_| IndexerError::AccountHash)?; + let hash = bs58::encode(hash).into_string(); + hashes.push(hash); + } + Some(hashes) + }; + let new_addresses_with_trees = if new_addresses.is_empty() { + None + } else { + Some( + new_addresses + .iter() + .map(|address| AddressWithTree { + address: bs58::encode(address.address).into_string(), + tree: bs58::encode( + address + .address_merkle_context + .address_merkle_tree_pubkey + .to_bytes(), + ) + .into_string(), + }) + .collect::>(), + ) + }; + + let request = GetValidityProofPostRequest { + params: Box::new(GetValidityProofPostRequestParams { + hashes, + new_addresses: None, + new_addresses_with_trees, + }), + ..Default::default() + }; + + let result = + photon_api::apis::default_api::get_validity_proof_post(&self.configuration, request) + .await + .unwrap(); + let value = result.result.ok_or(IndexerError::EmptyResult)?.value; + + println!("VALUE: {value:?}"); + + let proof = ProofRpcResult { + // FIXME + // proof: CompressedProof { + // a: value.compressed_proof.a, + // b: value.compressed_proof.b, + // c: value.compressed_proof.c, + // }, + proof: CompressedProof { + a: [0; 32], + b: [0; 64], + c: [0; 32], + }, + root_indices: value + .root_indices + .iter() + .map(|index| *index as u16) + .collect::>(), + // FIXME + address_root_indices: Vec::new(), + }; + + Ok(proof) + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs index c3b26435b..01af23f99 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,3 +1,5 @@ +pub mod indexer; pub mod rpc; pub mod rpc_pool; pub mod transaction_params; +pub mod utils; diff --git a/client/src/utils.rs b/client/src/utils.rs new file mode 100644 index 000000000..7ec96fca1 --- /dev/null +++ b/client/src/utils.rs @@ -0,0 +1,6 @@ +pub fn decode_hash(hash: &str) -> [u8; 32] { + let bytes = bs58::decode(hash).into_vec().unwrap(); + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr +} diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 951d2012d..a40a97d07 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -41,6 +41,7 @@ light-indexed-merkle-tree = { workspace = true } account-compression = { version = "1.0.0", path = "../programs/account-compression", features = ["cpi"] } light-system-program = { version = "1.0.0", path = "../programs/system", features = ["cpi"] } light-concurrent-merkle-tree = { path = "../merkle-tree/concurrent", version = "1.0.0" } +light-indexed-merkle-tree = { workspace = true } light-utils = { version = "1.0.0", path = "../utils" } groth16-solana = "0.0.3" light-verifier = { path = "../circuit-lib/verifier", version = "1.0.0", features = ["solana"] } diff --git a/sdk/src/address.rs b/sdk/src/address.rs index 772e195d8..62beaddb3 100644 --- a/sdk/src/address.rs +++ b/sdk/src/address.rs @@ -20,6 +20,11 @@ pub struct NewAddressParamsPacked { pub address_merkle_tree_root_index: u16, } +pub struct AddressWithMerkleContext { + pub address: [u8; 32], + pub address_merkle_context: AddressMerkleContext, +} + #[cfg(feature = "idl-build")] impl anchor_lang::IdlBuild for NewAddressParamsPacked {}