use crate::{
content::nft_ownership_verification::NftOwnershipVerificationContent as Ctnt,
proof::nft_ownership_verification::NftOwnershipVerificationProof as Prf,
statement::nft_ownership_verification::NftOwnershipVerificationStatement as Stmt,
types::{
defs::{Flow, Instructions, Issuer, Proof, Statement, StatementResponse, Subject},
enums::subject::{Pkh, Subjects},
error::FlowError,
},
};
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use reqwest::Client;
use schemars::schema_for;
use serde::{Deserialize, Serialize};
use tsify::Tsify;
use url::Url;
use wasm_bindgen::prelude::*;
#[derive(Clone, Debug, Deserialize, Serialize, Tsify)]
#[tsify(into_wasm_abi, from_wasm_abi)]
#[serde(untagged)]
pub enum NftOwnershipVerificationFlow {
Alchemy(Alchemy),
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl Flow<Ctnt, Stmt, Prf> for NftOwnershipVerificationFlow {
fn instructions(&self) -> Result<Instructions, FlowError> {
match self {
NftOwnershipVerificationFlow::Alchemy(x) => x.instructions(),
}
}
async fn statement<I: Issuer + Send + Clone>(
&self,
stmt: Stmt,
issuer: I,
) -> Result<StatementResponse, FlowError> {
match self {
NftOwnershipVerificationFlow::Alchemy(x) => x.statement(stmt, issuer).await,
}
}
async fn validate_proof<I: Issuer + Send>(
&self,
proof: Prf,
issuer: I,
) -> Result<Ctnt, FlowError> {
match self {
NftOwnershipVerificationFlow::Alchemy(x) => x.validate_proof(proof, issuer).await,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Tsify)]
pub struct Alchemy {
pub api_key: String,
pub challenge_delimiter: String,
pub max_elapsed_minutes: i64,
}
pub struct AlchemyPageResult {
next_page: Option<String>,
found: bool,
}
impl Alchemy {
pub fn sanity_check(&self, timestamp: &str) -> Result<(), FlowError> {
if self.max_elapsed_minutes <= 0 {
return Err(FlowError::Validation(
"Max elapsed minutes must be set to a number greater than 0".to_string(),
));
}
let now = Utc::now();
let then = DateTime::parse_from_rfc3339(timestamp)
.map_err(|e| FlowError::Validation(e.to_string()))?;
if then > now {
return Err(FlowError::Validation(
"Timestamp provided comes from the future".to_string(),
));
}
if now - Duration::minutes(self.max_elapsed_minutes) > then {
return Err(FlowError::Validation(
"Validation window has expired".to_string(),
));
};
Ok(())
}
pub async fn process_page(
&self,
client: &reqwest::Client,
contract_address: &str,
base_url: &str,
page_key: Option<String>,
) -> Result<AlchemyPageResult, FlowError> {
let u = match page_key {
None => Url::parse(base_url).map_err(|e| FlowError::BadLookup(e.to_string()))?,
Some(pk) => Url::parse(&format!("{}&pageKey={}", base_url, pk)).map_err(|_e| {
FlowError::BadLookup("Could not follow paginated results".to_string())
})?,
};
let res: AlchemyNftRes = client
.get(u)
.send()
.await
.map_err(|e| FlowError::BadLookup(e.to_string()))?
.json()
.await
.map_err(|e| FlowError::BadLookup(e.to_string()))?;
let mut result = AlchemyPageResult {
next_page: res.page_key.clone(),
found: false,
};
for nft in res.owned_nfts {
if nft.contract.address == contract_address {
result.found = true;
break;
}
}
Ok(result)
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl Flow<Ctnt, Stmt, Prf> for Alchemy {
fn instructions(&self) -> Result<Instructions, FlowError> {
Ok(Instructions {
statement: "Enter the contract address and network of asset".to_string(),
signature: "Sign a statement attesting to ownership of the asset".to_string(),
witness: "Send the attestation and the signature to the witness and issue a credential"
.to_string(),
statement_schema: schema_for!(Stmt),
witness_schema: schema_for!(Prf),
})
}
async fn statement<I: Issuer + Send + Clone>(
&self,
stmt: Stmt,
issuer: I,
) -> Result<StatementResponse, FlowError> {
self.sanity_check(&stmt.issued_at)?;
match stmt.subject {
Subjects::Pkh(Pkh::Eip155(_)) => {}
_ => {
return Err(FlowError::Validation(
"Currently only supports Ethereum NFTs".to_string(),
))
}
}
let s = stmt.generate_statement()?;
let f = issuer.sign(&s);
let sig = f.await?;
Ok(StatementResponse {
statement: format!("{}{}{}", s, self.challenge_delimiter, sig),
delimiter: None,
})
}
async fn validate_proof<I: Issuer + Send>(
&self,
proof: Prf,
issuer: I,
) -> Result<Ctnt, FlowError> {
self.sanity_check(&proof.statement.issued_at)?;
let base = format!(
"https://{}.g.alchemy.com/nft/v2/{}/getNFTs?owner={}&withMetadata=false",
proof.statement.network.to_string(),
self.api_key,
proof.statement.subject.display_id()?
);
let client = Client::new();
let f = self.process_page(&client, &proof.statement.contract_address, &base, None);
let mut res = f.await?;
if !res.found && res.next_page.is_some() {
loop {
let f = self.process_page(
&client,
&proof.statement.contract_address,
&base,
res.next_page,
);
res = f.await?;
if res.found || res.next_page.is_none() {
break;
}
}
}
if !res.found {
return Err(FlowError::BadLookup(format!(
"Found no owned NFTs from contract {}",
proof.statement.contract_address
)));
}
let s = proof.statement.generate_statement()?;
let f = issuer.sign(&s);
let sig = f.await?;
proof
.statement
.subject
.valid_signature(
&format!("{}{}{}", s, &self.challenge_delimiter, sig),
&proof.signature,
)
.await?;
Ok(proof.to_content(&s, &proof.signature)?)
}
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct AlchemyNftRes {
owned_nfts: Vec<AlchemyNftEntry>,
page_key: Option<String>,
total_count: i64,
block_hash: String,
}
#[derive(Clone, Deserialize, Serialize)]
struct AlchemyNftEntry {
contract: AlchemyNftContractEntry,
id: AlchemyTokenId,
balance: String,
}
#[derive(Clone, Deserialize, Serialize)]
struct AlchemyNftContractEntry {
address: String,
}
#[derive(Clone, Deserialize, Serialize)]
struct AlchemyTokenId {
#[serde(rename = "tokenId")]
token_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
test_util::util::{
test_eth_did, test_witness_signature, test_witness_statement, MockFlow, MockIssuer,
TestKey, TestWitness,
},
types::{
defs::{Issuer, Proof, Statement, Subject},
enums::subject::Subjects,
},
};
fn mock_proof(key: fn() -> Subjects, signature: String) -> Prf {
Prf {
statement: Stmt {
subject: key(),
contract_address: "0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85".to_owned(),
network: crate::types::defs::AlchemyNetworks::EthMainnet,
issued_at: "2023-09-27T16:23:00.447Z".to_string(),
},
signature,
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl Flow<Ctnt, Stmt, Prf> for MockFlow {
fn instructions(&self) -> Result<Instructions, FlowError> {
Ok(Instructions {
statement: "Unimplemented".to_string(),
statement_schema: schema_for!(Stmt),
signature: "Unimplemented".to_string(),
witness: "Unimplemented".to_string(),
witness_schema: schema_for!(Prf),
})
}
async fn statement<I: Issuer + Send + Clone>(
&self,
statement: Stmt,
_issuer: I,
) -> Result<StatementResponse, FlowError> {
Ok(StatementResponse {
statement: statement.generate_statement()?,
delimiter: Some("\n\n".to_string()),
})
}
async fn validate_proof<I: Issuer + Send>(
&self,
proof: Prf,
_issuer: I,
) -> Result<Ctnt, FlowError> {
proof
.statement
.subject
.valid_signature(&self.statement, &self.signature)
.await?;
Ok(proof
.to_content(&self.statement, &self.signature)
.map_err(FlowError::Proof)?)
}
}
#[tokio::test]
async fn mock_nft_ownership() {
let signature = test_witness_signature(TestWitness::NftOwnership, TestKey::Eth).unwrap();
let statement = test_witness_statement(TestWitness::NftOwnership, TestKey::Eth).unwrap();
let p = mock_proof(test_eth_did, signature.clone());
let flow = MockFlow {
statement,
signature,
};
let i = MockIssuer {};
flow.unsigned_credential(p, test_eth_did(), i)
.await
.unwrap();
}
}