Skip to content

Commit

Permalink
feat: Add Photon indexer
Browse files Browse the repository at this point in the history
Add a new, tiny implementation of Photon client, which can be used in
third-party program tests.

Differences between the old and new implementation:

- Lack of dependency on Light Protocol program crates.
- Support of:
  - `get_compressed_accounts_by_owner` - which returns the whole
    accounts, not just hashes
  - `get_validity_proof` - which is essential for getting proofs for
    new compressed accounts
  • Loading branch information
vadorovsky committed Sep 16, 2024
1 parent 49560a7 commit 5b0f34a
Show file tree
Hide file tree
Showing 9 changed files with 368 additions and 1 deletion.
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 7 additions & 0 deletions client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
77 changes: 76 additions & 1 deletion client/src/indexer/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Output = Result<Vec<CompressedAccountWithMerkleContext>, IndexerError>> + Send + Sync;

fn get_multiple_compressed_account_proofs<'a>(
&self,
hashes: Hashes<'a>,
) -> impl Future<Output = Result<Vec<MerkleProof>, IndexerError>> + Send + Sync;

fn get_multiple_new_address_proofs(
&self,
merkle_tree_pubkey: &Pubkey,
addresses: &[[u8; 32]],
) -> impl Future<Output = Result<Vec<NewAddressProofWithContext>, IndexerError>> + Send + Sync;

fn get_validity_proof(
&self,
compressed_accounts: &[CompressedAccountWithMerkleContext],
new_addresses: &[AddressWithMerkleContext],
) -> impl Future<Output = Result<ProofRpcResult, IndexerError>> + Send + Sync;
}
266 changes: 266 additions & 0 deletions client/src/indexer/photon.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> 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<Vec<CompressedAccountWithMerkleContext>, 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::<Vec<_>>();
Ok(compressed_accounts)
}

async fn get_multiple_compressed_account_proofs<'a>(
&self,
hashes: Hashes<'a>,
) -> Result<Vec<MerkleProof>, IndexerError> {
let hashes = match hashes {
Hashes::Array(hashes) => hashes
.iter()
.map(|hash| bs58::encode(hash).into_string())
.collect::<Vec<_>>(),
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::<Vec<_>>();

Ok(proofs)
}

async fn get_multiple_new_address_proofs(
&self,
merkle_tree_pubkey: &Pubkey,
addresses: &[[u8; 32]],
) -> Result<Vec<light_sdk::proof::NewAddressProofWithContext>, IndexerError> {
let params: Vec<AddressWithTree> = 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::<Vec<_>>()
.try_into()
.unwrap(),
new_low_element: None,
new_element: None,
new_element_next_value: None,
})
.collect::<Vec<_>>();

Ok(proofs)
}

async fn get_validity_proof(
&self,
compressed_accounts: &[CompressedAccountWithMerkleContext],
new_addresses: &[AddressWithMerkleContext],
) -> Result<ProofRpcResult, IndexerError> {
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::<Vec<_>>(),
)
};

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::<Vec<_>>(),
// FIXME
address_root_indices: Vec::new(),
};

Ok(proof)
}
}
2 changes: 2 additions & 0 deletions client/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub mod indexer;
pub mod rpc;
pub mod rpc_pool;
pub mod transaction_params;
pub mod utils;
6 changes: 6 additions & 0 deletions client/src/utils.rs
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 5b0f34a

Please sign in to comment.