diff --git a/.gitignore b/.gitignore index 5696b65..5e026f9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb .vscode +local_keys diff --git a/Cargo.toml b/Cargo.toml index 092ab44..0313273 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,9 @@ tokio = { version = "1.34", features = [ "macros", ] } tokio-stream = "0.1.14" +xml = "0.8.10" +fancy-regex = "0.13.0" +chrono = "0.4.33" [patch.crates-io] # see docs/serialization.md diff --git a/src/bob_request.rs b/src/bob_request.rs index 16d26b3..47f8eca 100644 --- a/src/bob_request.rs +++ b/src/bob_request.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, path::Path, str::FromStr, vec}; +use std::{collections::HashMap, path::Path, str::FromStr, sync::Arc, vec}; use anyhow::{bail, ensure, Context, Result}; use bitcoin::{ @@ -11,10 +11,12 @@ use serde::{Deserialize, Serialize}; use crate::{ circom_field_from_bytes, circom_field_to_bytes, + compliance::Compliance, constants::{ FEE_ZKBITCOIN_SAT, MINIMUM_CONFIRMATIONS, STATEFUL_ZKAPP_PUBLIC_INPUT_LEN, ZKBITCOIN_FEE_PUBKEY, ZKBITCOIN_PUBKEY, }, + get_network, json_rpc_stuff::{ createrawtransaction, fund_raw_transaction, get_transaction, json_rpc_request, TransactionOrHex, @@ -445,6 +447,23 @@ impl BobRequest { Ok(()) } + /// Check that the zkapp input transactions are compliant + pub async fn check_compliance(&self, compliance: Arc) -> Result<()> { + for zkapp_txin in &self.zkapp_tx.input { + let addr = Address::from_script( + &zkapp_txin.script_sig.clone().into_boxed_script(), + get_network(), + )?; + + ensure!( + !compliance.is_sanctioned(&addr).await, + "ZkApp input transaction is sanctioned" + ); + } + + Ok(()) + } + /// Validates a request received from Bob. pub async fn validate_request(&self) -> Result { // extract smart contract from tx diff --git a/src/committee/orchestrator.rs b/src/committee/orchestrator.rs index a8a81cc..fdae7c6 100644 --- a/src/committee/orchestrator.rs +++ b/src/committee/orchestrator.rs @@ -27,6 +27,7 @@ use tokio::time::sleep; use crate::{ bob_request::{BobRequest, BobResponse}, committee::node::Round1Response, + compliance::Compliance, constants::{KEEPALIVE_MAX_RETRIES, KEEPALIVE_WAIT_SECONDS, ZKBITCOIN_PUBKEY}, frost, json_rpc_stuff::{json_rpc_request, RpcCtx}, @@ -268,6 +269,7 @@ pub struct Orchestrator { pub pubkey_package: frost_secp256k1_tr::keys::PublicKeyPackage, pub committee_cfg: CommitteeConfig, pub member_status: Arc>, + compliance: Arc, } impl Orchestrator { @@ -275,17 +277,22 @@ impl Orchestrator { pubkey_package: frost_secp256k1_tr::keys::PublicKeyPackage, committee_cfg: CommitteeConfig, member_status: Arc>, + compliance: Arc, ) -> Self { Self { pubkey_package, committee_cfg, member_status, + compliance, } } /// Handles bob request from A to Z. pub async fn handle_request(&self, bob_request: &BobRequest) -> Result { // Validate transaction before forwarding it, and get smart contract + bob_request + .check_compliance(Arc::clone(&self.compliance)) + .await?; let smart_contract = bob_request.validate_request().await?; // TODO: we might want to check that the zkapp/UTXO is unspent here, but this requires us to have access to a bitcoin node, so for now we don't do it :o) @@ -567,15 +574,26 @@ pub async fn run_server( let address = address.unwrap_or("127.0.0.1:6666"); info!("- starting orchestrator at address http://{address}"); + let mut compliance = Compliance::new(); + // Orchestrator should sync the Sanction ist before doing anything else + compliance.sync().await.expect("sync sanction list"); + + // wrap in an Arc after the first sync so it can be used in multiple request contexts + let compliance: Arc = Arc::new(compliance); + let member_status_state = Arc::new(RwLock::new(MemberStatusState::new(&committee_cfg).await)); let mss_thread_copy = member_status_state.clone(); tokio::spawn(async move { MemberStatusState::keepalive_thread(mss_thread_copy).await }); - let ctx = Orchestrator { + let ctx = Orchestrator::new( pubkey_package, committee_cfg, - member_status: member_status_state, - }; + member_status_state, + Arc::clone(&compliance), + ); + + // Sync sanction list in a parallel thread + compliance.start(); let server = Server::builder() .build(address.parse::()?) diff --git a/src/compliance.rs b/src/compliance.rs new file mode 100644 index 0000000..1a7e986 --- /dev/null +++ b/src/compliance.rs @@ -0,0 +1,171 @@ +use anyhow::{Context, Result}; +use bitcoin::Address; +use chrono::prelude::*; +use fancy_regex::Regex; +use futures::StreamExt; +use log::{error, info}; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::{spawn, sync::RwLock, task::JoinHandle, time::interval}; +use xml::reader::{EventReader, XmlEvent}; + +pub struct Compliance { + sanctioned_addresses: Arc>>, + last_update: Arc>, +} + +impl Default for Compliance { + fn default() -> Self { + Self::new() + } +} + +impl Compliance { + const BTC_ID: &'static str = "344"; + const OFAC_URL: &'static str = + "https://www.treasury.gov/ofac/downloads/sanctions/1.0/sdn_advanced.xml"; + + pub fn new() -> Self { + Self { + sanctioned_addresses: Arc::new(RwLock::new(HashMap::new())), + last_update: Arc::new(RwLock::new(0)), + } + } + + fn extract_from_xml(str_value: &str, tag: &str) -> Result { + let re = Regex::new(&format!(r"(?<={}>)\s*(\w+)(?=<\/{})", tag, tag)).unwrap(); + let value = re.find(str_value)?.context("no regex result")?.as_str(); + + Ok(value.parse()?) + } + + /// read the first few bytes from the remote XML file and extract the last update date. + /// If there is no fresh data we can skip the parsing of XML which is slow. + async fn publish_date() -> Result { + let res = reqwest::get(Self::OFAC_URL).await?; + + let head = res + .bytes_stream() + .take(1) + .collect::>>() + .await + .into_iter() + .collect::>>()?; + + let str_value = String::from_utf8(head[0].to_vec())?; + let year = Self::extract_from_xml(&str_value, "Year")?; + let day = Self::extract_from_xml(&str_value, "Day")?; + let month = Self::extract_from_xml(&str_value, "Month")?; + let date = Utc + .with_ymd_and_hms(year as i32, month, day, 0, 0, 0) + .single() + .context("date parse error")? + .timestamp(); + + Ok(date) + } + + pub async fn sync(&mut self) -> Result<()> { + Self::sync_internal( + Arc::clone(&self.sanctioned_addresses), + Arc::clone(&self.last_update), + ) + .await?; + + Ok(()) + } + + /// Runs the Sanction list syncronization. Downloads the remote XML file and extracts the sanctioned addresses + async fn sync_internal( + sanctioned_addresses: Arc>>, + last_update: Arc>, + ) -> Result<()> { + let publish_date = Self::publish_date().await?; + + if *last_update.read().await >= publish_date { + info!("Sanction list is up-to-date"); + return Ok(()); + } + + let mut last_update = last_update.write().await; + *last_update = publish_date; + + info!("Syncing sanction list..."); + let start = Instant::now(); + let res = reqwest::get(Self::OFAC_URL).await?; + + let xml = res.text().await?; + let parser: EventReader<&[u8]> = EventReader::new(xml.as_bytes()); + let mut inside_feature_elem = false; + let mut inside_final_elem = false; + + let mut sanctioned_addresses = sanctioned_addresses.write().await; + for e in parser { + match e { + Ok(XmlEvent::StartElement { + name, attributes, .. + }) => { + if name.local_name == "Feature" { + if attributes.iter().any(|a| { + a.name.local_name == "FeatureTypeID" && a.value == Self::BTC_ID + }) { + inside_feature_elem = true; + } + } else if name.local_name == "VersionDetail" && inside_feature_elem { + inside_final_elem = true; + } + } + Ok(XmlEvent::Characters(value)) => { + if inside_final_elem { + sanctioned_addresses.insert(value, true); + } + } + Ok(XmlEvent::EndElement { name, .. }) => { + if name.local_name == "VersionDetail" && inside_feature_elem { + inside_feature_elem = false; + inside_final_elem = false; + } + } + Err(e) => { + error!("Error parsing xml: {e}"); + break; + } + _ => {} + } + } + + let duration = start.elapsed(); + info!("Sanction list synced in {:?}", duration); + + Ok(()) + } + + /// Periodically fetces the latest list from OFAC_URL and updates the local list + pub fn start(&self) -> JoinHandle<()> { + let sanctioned_addresses = Arc::clone(&self.sanctioned_addresses); + let last_update = Arc::clone(&self.last_update); + + spawn(async move { + let mut interval = interval(Duration::from_secs(600)); + + loop { + interval.tick().await; + if let Err(error) = + Self::sync_internal(Arc::clone(&sanctioned_addresses), Arc::clone(&last_update)) + .await + { + error!("Sanction list sync error: {}", error); + }; + } + }) + } + + /// Returns true if the given address is in the sanction list + pub async fn is_sanctioned(&self, address: &Address) -> bool { + let sanctioned_addresses = self.sanctioned_addresses.read().await; + sanctioned_addresses.contains_key(&address.to_string()) + } +} diff --git a/src/lib.rs b/src/lib.rs index f4d2cff..b4a60f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ use secp256k1::hashes::Hash; pub mod capped_hashmap; pub mod committee; +pub mod compliance; pub mod constants; pub mod frost; pub mod json_rpc_stuff;