#![feature(try_blocks)] mod blockchain; mod indexer; mod response; use argp::FromArgs; use log::error; use serde::{Deserialize, Serialize}; use sp_core::{ Pair as _, crypto::{Ss58AddressFormat, Ss58Codec, set_default_ss58_version}, }; use std::{ collections::{HashMap, hash_map::Entry}, io::{BufRead, Read}, str::FromStr, sync::Arc, }; use subxt::{ext::sp_core::ed25519::Pair, utils::AccountId32}; use tiny_http::{Response, ResponseBox, Server}; use tokio::sync::mpsc::{Sender, channel}; use typed_sled::Tree; /// Game to Blockchain const TREE_DEBT_G2B: &'static str = "debt_g2b"; /// Blob #[derive(FromArgs)] struct Opt { /// path to the database #[argp(option, short = 'd')] db: String, /// Duniter indexer URL #[argp( option, short = 'i', default = "String::from(\"https://gt-squid.axiom-team.fr/v1/graphql\")" )] indexer: String, /// secret key is legacy (two passwords on the two first lines) #[argp(switch, short = 'l')] legacy: bool, /// path to the file containing the secret key in Substrate format #[argp(option, short = 's')] secret_key: String, /// duniter node URL (default: ws://127.0.0.1:9944) #[argp(option, short = 'u', default = "String::from(\"ws://127.0.0.1:9944\")")] url: String, } #[derive(Deserialize, Serialize)] struct DebtG2B { address: AccountId32, amount: u64, } #[tokio::main] async fn main() { let opt: Opt = argp::parse_args_or_exit(argp::DEFAULT); let mut sk_file = std::fs::File::open(opt.secret_key).expect("Cannot open secret key file"); let pair: Pair = if opt.legacy { let sk_reader = std::io::BufReader::new(sk_file); let mut sk_lines = sk_reader.lines(); let psw1 = sk_lines .next() .expect("Secret key file should contain at least 2 lines for the 2 passwords") .unwrap(); let psw2 = sk_lines .next() .expect("Secret key file should contain at least 2 lines for the 2 passwords") .unwrap(); let params = scrypt::Params::new(12, 16, 1, 32).unwrap(); let mut seed = [0u8; 32]; scrypt::scrypt( psw2.trim().as_bytes(), psw1.trim().as_bytes(), ¶ms, &mut seed, ) .unwrap(); Pair::from_seed(&seed) } else { let mut sk_content = String::new(); sk_file .read_to_string(&mut sk_content) .expect("Cannot read secret key file"); Pair::from_string(sk_content.trim(), None).expect("Cannot decode secret key") }; set_default_ss58_version(Ss58AddressFormat::custom(4450)); let pk: AccountId32 = pair.public().into(); let pk: sp_core::crypto::AccountId32 = pk.0.into(); let pk_str = dbg!(pk.to_ss58check_with_version(Ss58AddressFormat::custom(4450))); simplelog::SimpleLogger::init(log::LevelFilter::Debug, simplelog::Config::default()).unwrap(); let db = typed_sled::open(opt.db).expect("Cannot open database"); let tree_debt_g2b = Tree::::open(&db, TREE_DEBT_G2B); let client = reqwest::Client::new(); let (bc_send, bc_recv) = channel(128); tokio::spawn(blockchain::run( opt.url, pair, bc_recv, tree_debt_g2b.clone(), )); let server = Server::http("127.0.0.1:30061").expect("Cannot start server"); struct Locals { tree_debt_g2b: Tree, bc_send: Sender, indexer: String, pk_str: String, } tokio::task_local! { static LOCALS: Arc; }; LOCALS.scope( Arc::new(Locals { tree_debt_g2b, bc_send, indexer: opt.indexer, pk_str: pk_str, }), async move { for request in server.incoming_requests() { let locals = LOCALS.get(); let client = client.clone(); tokio::spawn(async move { let url = request.url(); let mut url_items = url.split('/').filter(|item| !item.is_empty()); let resp: Option = try { match url_items.next() { Some("send") => { let address = AccountId32::from_str(url_items.next()?).ok()?; let amount = url_items.next()?.parse::().ok()?; let debt = DebtG2B { address, amount }; locals.tree_debt_g2b.insert(&debt, &()).ok()?; locals.bc_send.send(debt).await.ok()?; Response::empty(200).boxed() } Some("recv") => { let block_number = url_items.next()?.parse::().ok()?; let req = client.post(&locals.indexer).body(dbg!(format!( r#"[{{"query":"{{transfer(where:{{blockNumber:{{_gt:{}}},to:{{id:{{_eq:\"{}\"}}}}}}){{amount comment{{remark}}}}block(limit:1,orderBy:{{height:DESC}}){{height hash}}}}"}}]"#, block_number, locals.pk_str))).build().unwrap(); let Ok(res) = client.execute(req).await else { request.respond(Response::empty(500)).ok(); error!("Error from indexer"); return; }; let Ok(res) = res.json::().await else { request.respond(Response::empty(500)).ok(); error!("Bad JSON from indexer"); return; }; let Some(data) = res.first() else { request.respond(Response::empty(500)).ok(); error!("No data from indexer"); return; }; let Some(block) = data.data.block.first() else { request.respond(Response::empty(500)).ok(); error!("No block from indexer"); return; }; let mut debts = HashMap::::new(); for transfer in data.data.transfer.iter() { if let Some(comment) = &transfer.comment { match debts.entry(comment.remark.clone()) { Entry::Occupied(mut debt) => { *debt.get_mut() = debt.get().saturating_add(transfer.amount); } Entry::Vacant(debt) => { debt.insert(transfer.amount); } } } } Response::from_string(serde_json::to_string(&response::Response { block: block.height, debts: debts.into_iter().map(|(name, amount)| response::Debt { name, amount, }).collect(), }).unwrap()).with_status_code(200).boxed() } _ => None?, } }; if let Some(resp) = resp { request.respond(resp).ok(); } else { request.respond(Response::empty(400)).ok(); } }); } }, ).await; }