Skip to content

Commit

Permalink
Adds a prototype NFT Ownership flow that uses Alchemy + Eth implement…
Browse files Browse the repository at this point in the history
…ation. Lacks full documentation or appearance in demo dapp because breaking changes are expected. Is implemented in NPM lib and worker for use in seperate demo.t
  • Loading branch information
krhoda committed Feb 15, 2023
1 parent 33f5d71 commit 22545d2
Show file tree
Hide file tree
Showing 15 changed files with 453 additions and 8 deletions.
8 changes: 8 additions & 0 deletions demo/witness/secrets.md
Expand Up @@ -45,6 +45,14 @@ The `SENDGRID_MAX_ELAPSED_MINS` is a number set to something greater than 0. It
### GITHUB_USER_AGENT (GitHub Flow)
The `GITHUB_USER_AGENT` secret will be the user agent sent to GitHub when querying it's public API.

## NFT Ownership Flow

NOTE: The NFT Ownership route is not in a complete state and will encounter breaking changes before final release, use in production at your own peril!
### ALCHEMY_API_KEY (NFT Ownership Flow)
The `ALCHEMY_API_KEY` secret will be used to access the alchemy api for querying about NFT ownership. An API key can be gained by signing up [here](https://docs.alchemy.com/reference/api-overview).

### ALCHEMY_MAX_ELAPSED_MINS
The `ALCHEMY_MAX_ELAPSED_MINS` is a number set to something greater than 0. It represents how many minutes can ellapse from the email challenge being sent and the end-user pasting it back before the witness considers the challenge expired. If set to not a number, a negative number, or 0 it will error out.
## Twitter Flow
### TWITTER_BEARER_TOKEN (Twitter Flow)
The `TWITTER_BEARER_TOKEN` is the bearer token given from Twitter to the application developer using the [Twitter API](https://developer.twitter.com/en/docs/twitter-api) and will be used (as described [here](https://developer.twitter.com/en/docs/authentication/oauth-2-0/bearer-tokens)) when querying the API.
Expand Down
10 changes: 10 additions & 0 deletions demo/witness/worker/worker.js
Expand Up @@ -216,6 +216,16 @@ function witnessOpts() {
}
}

let useAlchemy = ALCHEMY_API_KEY
&& ALCHEMY_MAX_ELAPSED_MINS
&& !isNaN(parseInt(ALCHEMY_MAX_ELAPSED_MINS));
if (useAlchemy) {
o.nft_ownership = {
api_key: ALCHEMY_API_KEY,
max_elapsed_minutes: parseInt(ALCHEMY_MAX_ELAPSED_MINS)
}
}

return o
};

Expand Down
2 changes: 2 additions & 0 deletions rust/rebase/README.md
Expand Up @@ -27,6 +27,7 @@ Current credentials supported:
* same (links two Subject instances)
* soundcloud
* twitter
* nft_ownership (NOT PRODUCTION READY)

Current Witness flows:
* dns
Expand All @@ -36,6 +37,7 @@ Current Witness flows:
* same (links two Subject instances)
* soundcloud
* twitter
* nft_ownership (NOT PRODUCTION READY)

Current Subjects:
* ethereum
Expand Down
1 change: 1 addition & 0 deletions rust/rebase/src/content/mod.rs
Expand Up @@ -3,6 +3,7 @@ pub mod basic_profile;
pub mod dns;
pub mod email;
pub mod github;
pub mod nft_ownership;
pub mod reddit;
pub mod same;
pub mod soundcloud;
Expand Down
72 changes: 72 additions & 0 deletions rust/rebase/src/content/nft_ownership.rs
@@ -0,0 +1,72 @@
use crate::types::{
enums::subject::Subjects,
error::ContentError,
types::{Content, Subject},
};
use chrono::{SecondsFormat, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
use ssi::{one_or_many::OneOrMany, vc::Evidence};

#[derive(Clone, Deserialize, JsonSchema, Serialize)]
pub struct NftOwnership {
pub contract_address: String,
pub subject: Subjects,
pub statement: String,
pub signature: String,
}

impl Content for NftOwnership {
fn context(&self) -> Result<serde_json::Value, ContentError> {
Ok(json!([
"https://www.w3.org/2018/credentials/v1",
"https://spec.rebase.xyz/contexts/v1"
]))
}

fn evidence(&self) -> Result<Option<OneOrMany<Evidence>>, ContentError> {
let mut evidence_map = std::collections::HashMap::new();
evidence_map.insert(
"contract_address".to_string(),
serde_json::Value::String(self.contract_address.clone()),
);

evidence_map.insert(
"statement".to_string(),
serde_json::Value::String(self.contract_address.clone()),
);

evidence_map.insert(
"signature".to_string(),
serde_json::Value::String(self.contract_address.clone()),
);

evidence_map.insert(
"timestamp".to_string(),
serde_json::Value::String(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)),
);

let evidence = Evidence {
id: None,
type_: vec!["NftOwnershipMessage".to_string()],
property_set: Some(evidence_map),
};

Ok(Some(OneOrMany::One(evidence)))
}

fn subject(&self) -> Result<serde_json::Value, ContentError> {
Ok(json!({
"id": self.subject.did()?,
"owns_asset_from": self.contract_address.clone(),
}))
}

fn types(&self) -> Result<Vec<String>, ContentError> {
Ok(vec![
"VerifiableCredential".to_owned(),
"NftOwnershipVerification".to_owned(),
])
}
}
3 changes: 3 additions & 0 deletions rust/rebase/src/flow/email.rs
Expand Up @@ -19,6 +19,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use url::Url;

// TODO: When revamping for publication, add challenge-parsing delimitor to config handle all on this side.
#[derive(Clone, Deserialize, Serialize)]
pub struct SendGridBasic {
api_key: String,
Expand Down Expand Up @@ -142,6 +143,8 @@ impl Flow<Ctnt, Stmt, Prf> for SendGridBasic {
)));
}

// TODO: Fix this part to have the delimitor part of the config.
// TODO: Add parsing logic based on that config.
let t = format!(
"{}:::{}",
proof.statement.generate_statement()?,
Expand Down
1 change: 1 addition & 0 deletions rust/rebase/src/flow/mod.rs
@@ -1,6 +1,7 @@
pub mod dns;
pub mod email;
pub mod github;
pub mod nft_ownership;
pub mod reddit;
pub mod same;
pub mod soundcloud;
Expand Down
238 changes: 238 additions & 0 deletions rust/rebase/src/flow/nft_ownership.rs
@@ -0,0 +1,238 @@
use crate::{
content::nft_ownership::NftOwnership as Ctnt,
proof::nft_ownership::NftOwnership as Prf,
statement::nft_ownership::NftOwnership as Stmt,
types::{
enums::subject::{Pkh, Subjects},
error::FlowError,
types::{Flow, FlowResponse, Instructions, Issuer, Proof, Statement, Subject},
},
};

use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use reqwest::Client;
use schemars::schema_for;
use serde::{Deserialize, Serialize};
use url::Url;

// TODO: Make this an Enum of which Alchemy is the only impl.
// TODO: Make Alchemy variant be configurable by chain + per-chain configs.
// NOTE: For now, this is just a wrapper around the alchemy API.
#[derive(Clone, Deserialize, Serialize)]
pub struct NftOwnership {
api_key: String,
// The amount of time that can pass before the witness
// wants a new flow initiated. In demo, set to 15 mins.
// This is checked for a negative value or 0 and errs if one is found
// Alternative is casting u64 to i64 and risking UB.
max_elapsed_minutes: i64,
}

pub struct PageResult {
next_page: Option<String>,
found: bool,
// If more data is needed about the NFT pass this structure around.
// res: AlchemyNftRes,
}

impl NftOwnership {
// NOTE: This method would be vulnerable to someone foward-dating signatures.
// It likely wouldn't occur, but could be mitigated by doing a Challenge { challenge: string, timestamp: string}
// and attaching it to FlowResponse with a challenge: Option<Challenge> field.
// Then, we generate the TS here, like in email, but send it back over the wire as part of the statement.
// That said, there's no motivation for commiting that style of attack in the current NFT gating demo situation.
// NOTE: People with clocks that are off might mess this up too.
// TODO: When moving to post-demo impl, rework this to use the above strategy
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(format!(
"Timestamp provided comes from the future"
)));
}

if now - Duration::minutes(self.max_elapsed_minutes) > then {
return Err(FlowError::Validation(format!(
"Validation window has expired"
)));
};
Ok(())
}

pub async fn process_page(
&self,
client: &reqwest::Client,
u: url::Url,
contract_address: &str,
) -> Result<PageResult, FlowError> {
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: PageResult = PageResult {
next_page: res.page_key.clone(),
found: false,
// res: res.clone(),
};

for nft in res.owned_nfts {
if nft.contract.address == contract_address {
result.found = true;
break;
}
}

Ok(result)
}
}

#[async_trait(?Send)]
impl Flow<Ctnt, Stmt, Prf> for NftOwnership {
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>(
&self,
stmt: &Stmt,
_issuer: &I,
) -> Result<FlowResponse, FlowError> {
self.sanity_check(&stmt.issued_at)?;

// TODO: Adjust this when adding additional Alchemy flows.
match stmt.subject {
Subjects::Pkh(Pkh::Eip155(_)) => {}
_ => {
return Err(FlowError::Validation(
"Currently only supports Ethereum NFTs".to_string(),
))
}
}

Ok(FlowResponse {
statement: stmt.generate_statement()?,
delimitor: None,
})
}

async fn validate_proof<I: Issuer>(&self, proof: &Prf, _issuer: &I) -> Result<Ctnt, FlowError> {
self.sanity_check(&proof.statement.issued_at)?;

let u = Url::parse(&format!(
"https://{}-{}.g.alchemy.com/nft/v2/{}/getNFTs?owner={}&withMetadata=false",
// TODO: Replace with enum.
"eth".to_string(),
// TODO: Replace with enum.
proof.statement.network,
self.api_key,
proof.statement.subject.display_id()?
))
.map_err(|e| FlowError::BadLookup(e.to_string()))?;

let client = Client::new();
// NOTE: Intentionally re-assigning before read, thus using: #[allow(unused_assignments)]
#[allow(unused_assignments)]
let mut page_key: Option<String> = None;
#[allow(unused_assignments)]
let mut res: PageResult = PageResult {
next_page: None,
found: false,
// res: AlchemyNftRes {
// owned_nfts: Vec::new(),
// page_key: None,
// total_count: 0,
// block_hash: "initial value".to_string(),
// },
};

let mut next_u: url::Url = u.clone();

loop {
res = self
.process_page(&client, next_u.clone(), &proof.statement.contract_address)
.await?;

if res.found {
break;
}

page_key = res.next_page.clone();
match page_key {
None => break,
Some(s) => {
next_u = u.join(&format!("&pageKey={}", s)).map_err(|_e| {
FlowError::BadLookup("Could not follow paginated results".to_string())
})?;
}
}
}

if !res.found {
return Err(FlowError::BadLookup(format!(
"Found no owned NFTs from contract {}",
proof.statement.contract_address
)));
}

let s = proof.statement.generate_statement()?;
// NOTE: We would generate and append the challenge
// here if using that scheme.
proof
.statement
.subject
.valid_signature(&s, &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,
// NOTE: Balance always seems to be a number
balance: String,
}

#[derive(Clone, Deserialize, Serialize)]
struct AlchemyNftContractEntry {
address: String,
}

#[derive(Clone, Deserialize, Serialize)]
struct AlchemyTokenId {
#[serde(rename = "tokenId")]
token_id: String,
}
2 changes: 1 addition & 1 deletion rust/rebase/src/lib.rs
@@ -1,4 +1,4 @@
//! For more information please see the documentation at https://spurceid.dev/rebase
//! For more information please see the documentation at https://spruceid.dev/rebase/rebase
pub mod content;
pub mod flow;
pub mod issuer;
Expand Down

0 comments on commit 22545d2

Please sign in to comment.