diff --git a/.gitignore b/.gitignore index 8a1c25b..f7a9933 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # will have compiled files and executables debug/ target/ +keys/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html diff --git a/crates/ns-indexer/Cargo.toml b/crates/ns-indexer/Cargo.toml index dc4745b..bdd7dc0 100644 --- a/crates/ns-indexer/Cargo.toml +++ b/crates/ns-indexer/Cargo.toml @@ -40,7 +40,7 @@ reqwest = { version = "0.11", features = [ ], default-features = false } bitcoin = { version = "0.31", features = ["serde", "base64", "rand"] } dotenvy = "0.15" -faster-hex = "0.9" +hex = "0.4" bitcoincore-rpc-json = "0.18.0" scylla = "0.10" tower = "0.4" diff --git a/crates/ns-indexer/README.md b/crates/ns-indexer/README.md index 9a26faf..70517e8 100644 --- a/crates/ns-indexer/README.md +++ b/crates/ns-indexer/README.md @@ -6,4 +6,8 @@ More information about the protocol can be found in the [protocol documentation] ```sh cargo run --package ns-indexer --bin ns-indexer +``` + +```sh +cargo build --release --target x86_64-unknown-linux-musl --package ns-indexer --bin ns-indexer ``` \ No newline at end of file diff --git a/crates/ns-indexer/cql/schema.cql b/crates/ns-indexer/cql/schema.cql index c89d92a..89c1dd0 100644 --- a/crates/ns-indexer/cql/schema.cql +++ b/crates/ns-indexer/cql/schema.cql @@ -123,3 +123,18 @@ CREATE TABLE IF NOT EXISTS checkpoint ( AND compaction = {'class': 'SizeTieredCompactionStrategy'} AND compression = {'sstable_compression': 'LZ4Compressor'} AND default_time_to_live = 0; + +CREATE TABLE IF NOT EXISTS utxo ( + txid BLOB, -- transaction id that contains this inscription + vout INT, -- output index in transaction + amount BIGINT, -- unspend amount in satoshi + address BLOB, -- p2tr address + PRIMARY KEY (txid, vout) +) WITH CLUSTERING ORDER BY (vout ASC) + AND caching = {'enabled': 'true'} + AND comment = 'unspent TX outputs' + AND compaction = {'class': 'SizeTieredCompactionStrategy'} + AND compression = {'sstable_compression': 'LZ4Compressor'} + AND default_time_to_live = 0; + +CREATE INDEX utxo_address ON utxo (address); \ No newline at end of file diff --git a/.env.sample b/crates/ns-indexer/sample.env similarity index 75% rename from .env.sample rename to crates/ns-indexer/sample.env index 4da167f..4195dad 100644 --- a/.env.sample +++ b/crates/ns-indexer/sample.env @@ -1,17 +1,16 @@ -BITCOIN_RPC_URL=http://127.0.0.1:18443 -BITCOIN_RPC_USER=test -BITCOIN_RPC_PASSWORD=123456 - -# ----- ns-indexer env ----- LOG_LEVEL=info + INDEXER_SERVER_WORKER_THREADS=0 # defaults to the number of cpus on the system INDEXER_SERVER_ADDR=0.0.0.0:8080 INDEXER_SERVER_NOSCAN=false # run as API server +INDEXER_UTXO=false # index UTXO when scanning INDEXER_START_HEIGHT=0 -# more nodes split by comma -SCYLLA_NODES=127.0.0.1:9042 + +SCYLLA_NODES=127.0.0.1:9042 # more nodes split by comma SCYLLA_USERNAME="" SCYLLA_PASSWORD="" SCYLLA_KEYSPACE=ns_indexer -# ----- ns-inscriber env ----- \ No newline at end of file +BITCOIN_RPC_URL=http://127.0.0.1:18443 +BITCOIN_RPC_USER=test +BITCOIN_RPC_PASSWORD=123456 diff --git a/crates/ns-indexer/src/api/inscription.rs b/crates/ns-indexer/src/api/inscription.rs index 061f24c..b736c6d 100644 --- a/crates/ns-indexer/src/api/inscription.rs +++ b/crates/ns-indexer/src/api/inscription.rs @@ -42,7 +42,7 @@ impl InscriptionAPI { ctx.set("action", "get_best_inscription".into()).await; let best_inscriptions_state = api.state.best_inscriptions.read().await; - let mut inscription = best_inscriptions_state.last().cloned(); + let mut inscription = best_inscriptions_state.back().cloned(); if inscription.is_none() { let last_accepted_state = api.state.last_accepted.read().await; inscription = last_accepted_state.clone(); @@ -107,7 +107,9 @@ impl InscriptionAPI { ) -> Result>>, HTTPError> { ctx.set("action", "list_best_inscriptions".into()).await; let best_inscriptions_state = api.state.best_inscriptions.read().await; - Ok(to.with(SuccessResponse::new(best_inscriptions_state.clone()))) + Ok(to.with(SuccessResponse::new( + best_inscriptions_state.iter().cloned().collect(), + ))) } pub async fn list_by_block_height( diff --git a/crates/ns-indexer/src/api/mod.rs b/crates/ns-indexer/src/api/mod.rs index 51c56f2..fe78ff0 100644 --- a/crates/ns-indexer/src/api/mod.rs +++ b/crates/ns-indexer/src/api/mod.rs @@ -13,10 +13,12 @@ use crate::indexer::{Indexer, IndexerState}; mod inscription; mod name; mod service; +mod utxo; pub use inscription::InscriptionAPI; pub use name::NameAPI; pub use service::ServiceAPI; +pub use utxo::UtxoAPI; #[derive(Serialize, Deserialize)] pub struct AppVersion { @@ -99,6 +101,11 @@ pub struct QueryPubkey { pub pubkey: String, } +#[derive(Debug, Deserialize, Validate)] +pub struct QueryAddress { + pub address: String, +} + fn validate_name(name: &str) -> Result<(), ValidationError> { if !ns::valid_name(name) { return Err(ValidationError::new("invalid name")); diff --git a/crates/ns-indexer/src/api/name.rs b/crates/ns-indexer/src/api/name.rs index 0131a33..dd5450b 100644 --- a/crates/ns-indexer/src/api/name.rs +++ b/crates/ns-indexer/src/api/name.rs @@ -67,8 +67,12 @@ impl NameAPI { ) -> Result>>, HTTPError> { input.validate()?; - let mut pubkey = [0u8; 32]; - faster_hex::hex_decode(input.pubkey.as_bytes(), &mut pubkey) + let key = if input.pubkey.starts_with("0x") { + &input.pubkey[2..] + } else { + input.pubkey.as_str() + }; + let pubkey = hex::decode(key) .map_err(|_| HTTPError::new(400, format!("Invalid pubkey: {}", input.pubkey)))?; ctx.set_kvs(vec![("action", "list_names_by_pubkey".into())]) .await; diff --git a/crates/ns-indexer/src/api/utxo.rs b/crates/ns-indexer/src/api/utxo.rs new file mode 100644 index 0000000..4938ddb --- /dev/null +++ b/crates/ns-indexer/src/api/utxo.rs @@ -0,0 +1,68 @@ +use axum::{ + extract::{Query, State}, + Extension, +}; +use bitcoin::{Address, AddressType}; +use std::{collections::BTreeMap, str::FromStr, sync::Arc}; +use validator::Validate; + +use axum_web::{ + context::ReqContext, + erring::{HTTPError, SuccessResponse}, + object::PackObject, +}; + +use crate::api::{IndexerAPI, QueryAddress}; +use crate::db; +use crate::utxo::UTXO; + +pub struct UtxoAPI; + +impl UtxoAPI { + pub async fn list( + State(app): State>, + Extension(ctx): Extension>, + to: PackObject<()>, + input: Query, + ) -> Result>>, HTTPError> { + input.validate()?; + + let address = Address::from_str(input.address.as_str()) + .map_err(|_| HTTPError::new(400, format!("invalid address: {}", input.address)))? + .assume_checked(); + + match address.address_type() { + Some(AddressType::P2tr) | Some(AddressType::P2wpkh) => {} + other => { + return Err(HTTPError::new( + 400, + format!("only support p2tr address, got: {:?}", other), + )); + } + } + + ctx.set_kvs(vec![("action", "list_utxos_by_address".into())]) + .await; + + let address = address.script_pubkey().as_bytes().to_vec(); + let utxos = db::Utxo::list(&app.scylla, &address).await?; + let mut utxos: BTreeMap<(&Vec, u32), UTXO> = utxos + .iter() + .map(|utxo| ((&utxo.txid, utxo.vout as u32), utxo.to_utxo())) + .collect(); + + let confirming_utxos = app.state.confirming_utxos.read().await; + for utxo in confirming_utxos.iter() { + for spent in &utxo.1 { + utxos.remove(&(&spent.txid, spent.vout)); + } + for (_, unspent) in &utxo.2 { + utxos.insert((&unspent.txid, unspent.vout), unspent.clone()); + } + } + + let mut utxos = utxos.into_values().collect::>(); + utxos.sort_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()); + Ok(to.with(SuccessResponse::new(utxos))) + } +} diff --git a/crates/ns-indexer/src/bin/main.rs b/crates/ns-indexer/src/bin/main.rs index ecb9e6d..aa3f290 100644 --- a/crates/ns-indexer/src/bin/main.rs +++ b/crates/ns-indexer/src/bin/main.rs @@ -57,7 +57,16 @@ fn main() -> anyhow::Result<()> { rpcpassword, }) .await?; - let indexer = Indexer::new(&IndexerOptions { scylla }).await?; + + let indexer = Indexer::new(&IndexerOptions { + scylla, + index_utxo: std::env::var("INDEXER_UTXO") + .unwrap_or("false".to_string()) + .parse::() + .unwrap(), + }) + .await?; + let last_accepted_height = indexer.initialize().await?; let start_height = if last_accepted_height > 0 { last_accepted_height + 1 diff --git a/crates/ns-indexer/src/db/mod.rs b/crates/ns-indexer/src/db/mod.rs index c586b46..1d3fed0 100644 --- a/crates/ns-indexer/src/db/mod.rs +++ b/crates/ns-indexer/src/db/mod.rs @@ -2,9 +2,11 @@ mod model_inscription; mod model_name_state; mod model_service_protocol; mod model_service_state; +mod model_utxo; pub mod scylladb; pub use model_inscription::{Checkpoint, Inscription, InvalidInscription}; pub use model_name_state::NameState; pub use model_service_protocol::ServiceProtocol; pub use model_service_state::ServiceState; +pub use model_utxo::Utxo; diff --git a/crates/ns-indexer/src/db/model_inscription.rs b/crates/ns-indexer/src/db/model_inscription.rs index c825bec..2f653a6 100644 --- a/crates/ns-indexer/src/db/model_inscription.rs +++ b/crates/ns-indexer/src/db/model_inscription.rs @@ -386,6 +386,16 @@ impl Inscription { statements.push(checkpoint_query.as_str()); values.push(checkpoint_params); + if statements.len() > 500 { + log::info!(target: "ns-indexer", + action = "save_checkpoint", + statements = statements.len(), + block_height = checkpoint.block_height, + height = checkpoint.height; + "", + ); + } + let _ = db.batch(statements, values).await?; Ok(()) } diff --git a/crates/ns-indexer/src/db/model_utxo.rs b/crates/ns-indexer/src/db/model_utxo.rs new file mode 100644 index 0000000..cc5dda4 --- /dev/null +++ b/crates/ns-indexer/src/db/model_utxo.rs @@ -0,0 +1,131 @@ +use std::vec; + +use scylla_orm::{ColumnsMap, CqlValue, ToCqlVal}; +use scylla_orm_macros::CqlOrm; + +use crate::db::scylladb; +use crate::utxo; + +#[derive(Debug, Default, Clone, CqlOrm, PartialEq)] +pub struct Utxo { + pub txid: Vec, + pub vout: i32, + pub amount: i64, + pub address: Vec, + + pub _fields: Vec, // selected fields,field with `_` will be ignored by CqlOrm +} + +impl Utxo { + pub fn from_utxo(address: Vec, value: &utxo::UTXO) -> Self { + Self { + txid: value.txid.clone(), + vout: value.vout as i32, + amount: value.amount as i64, + address, + _fields: vec![], + } + } + + pub fn to_utxo(&self) -> utxo::UTXO { + utxo::UTXO { + txid: self.txid.clone(), + vout: self.vout as u32, + amount: self.amount as u64, + } + } + + pub async fn handle_utxo( + db: &scylladb::ScyllaDB, + spent: &Vec, + unspent: &Vec<(Vec, utxo::UTXO)>, + ) -> anyhow::Result<()> { + // delete spent utxos + let mut start = 0; + while start < spent.len() { + let end = if start + 1000 > spent.len() { + spent.len() + } else { + start + 1000 + }; + let mut statements: Vec<&str> = Vec::with_capacity(end - start); + let mut values: Vec> = Vec::with_capacity(end - start); + let query = "DELETE FROM utxo WHERE txid=? AND vout=?"; + + for tx in &spent[start..end] { + statements.push(query); + values.push(vec![tx.txid.to_cql(), (tx.vout as i32).to_cql()]); + } + + if statements.len() > 500 { + log::info!(target: "ns-indexer", + action = "handle_spent_utxos", + statements = statements.len(); + "", + ); + } + + let _ = db.batch(statements, values).await?; + start = end; + } + + let mut start = 0; + while start < unspent.len() { + let end = if start + 1000 > unspent.len() { + unspent.len() + } else { + start + 1000 + }; + let mut statements: Vec<&str> = Vec::with_capacity(unspent.len()); + let mut values: Vec> = Vec::with_capacity(unspent.len()); + let query = "INSERT INTO utxo (txid,vout,amount,address) VALUES (?,?,?,?)"; + + for tx in &unspent[start..end] { + statements.push(query); + let tx = Self::from_utxo(tx.0.clone(), &tx.1); + values.push(vec![ + tx.txid.to_cql(), + tx.vout.to_cql(), + tx.amount.to_cql(), + tx.address.to_cql(), + ]); + } + + if statements.len() > 500 { + log::info!(target: "ns-indexer", + action = "handle_unspent_utxos", + statements = statements.len(); + "", + ); + } + + let _ = db.batch(statements, values).await?; + start = end; + } + + Ok(()) + } + + pub async fn list(db: &scylladb::ScyllaDB, address: &Vec) -> anyhow::Result> { + let fields = Self::fields(); + + let query = format!( + "SELECT {} FROM utxo WHERE address=? USING TIMEOUT 3s", + fields.clone().join(",") + ); + let params = (address.to_cql(),); + let rows = db.execute_iter(query, params).await?; + + let mut res: Vec = Vec::with_capacity(rows.len()); + for row in rows { + let mut doc = Self::default(); + let mut cols = ColumnsMap::with_capacity(fields.len()); + cols.fill(row, &fields)?; + doc.fill(&cols); + doc._fields = fields.clone(); + res.push(doc); + } + + Ok(res) + } +} diff --git a/crates/ns-indexer/src/db/scylladb.rs b/crates/ns-indexer/src/db/scylladb.rs index d0bd146..39d52a2 100644 --- a/crates/ns-indexer/src/db/scylladb.rs +++ b/crates/ns-indexer/src/db/scylladb.rs @@ -186,7 +186,7 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn exec_cqls_works() { - dotenvy::from_filename(".env.sample").expect(".env file not found"); + dotenvy::from_filename("sample.env").expect(".env file not found"); let db = get_db().await; let schema = std::include_str!("../../cql/schema.cql"); diff --git a/crates/ns-indexer/src/indexer.rs b/crates/ns-indexer/src/indexer.rs index bbe2072..c6a1992 100644 --- a/crates/ns-indexer/src/indexer.rs +++ b/crates/ns-indexer/src/indexer.rs @@ -1,6 +1,6 @@ -use bitcoin::{hashes::Hash, BlockHash}; +use bitcoin::{hashes::Hash, BlockHash, Transaction}; use std::{ - collections::{BTreeMap, HashSet}, + collections::{BTreeMap, HashSet, VecDeque}, sync::Arc, }; use tokio::sync::RwLock; @@ -15,27 +15,33 @@ use crate::db::{ scylladb::{ScyllaDB, ScyllaDBOptions}, }; use crate::envelope::Envelope; +use crate::utxo::UTXO; const ACCEPTED_DISTANCE: u64 = 6; // 6 blocks before the best block pub struct IndexerOptions { pub scylla: ScyllaDBOptions, + pub index_utxo: bool, } #[derive(Clone)] pub struct Indexer { pub(crate) scylla: Arc, pub(crate) state: Arc, + index_utxo: bool, } -pub type InscriptionState = (NameState, ServiceState, Option); +type InscriptionState = (NameState, ServiceState, Option); +// (block_height, spents, unspents) +type UTXOState = (u64, Vec, Vec<(Vec, UTXO)>); pub struct IndexerState { pub(crate) last_accepted_height: RwLock, pub(crate) last_accepted: RwLock>, - pub(crate) best_inscriptions: RwLock>, + pub(crate) best_inscriptions: RwLock>, // protocols: RwLock>, - pub(crate) confirming_names: RwLock>>, + pub(crate) confirming_names: RwLock>>, + pub(crate) confirming_utxos: RwLock>, } impl Indexer { @@ -46,10 +52,12 @@ impl Indexer { state: Arc::new(IndexerState { last_accepted_height: RwLock::new(0), last_accepted: RwLock::new(None), - best_inscriptions: RwLock::new(Vec::with_capacity(1024)), + best_inscriptions: RwLock::new(VecDeque::with_capacity(1024)), // protocols: RwLock::new(BTreeMap::new()), confirming_names: RwLock::new(BTreeMap::new()), + confirming_utxos: RwLock::new(VecDeque::with_capacity(1024)), }), + index_utxo: opts.index_utxo, }) } @@ -88,7 +96,7 @@ impl Indexer { block_hash: &BlockHash, block_height: u64, block_time: u64, - envelopes: Vec, + tx: Transaction, ) -> anyhow::Result<()> { let accepted_height = { let last_accepted_height_state = self.state.last_accepted_height.read().await; @@ -103,7 +111,7 @@ impl Indexer { self.save_accepted(accepted_height).await?; } - for envelope in envelopes { + for envelope in Envelope::from_transaction(&tx) { for name in envelope.payload { match self.index_name(block_height, block_time, &name).await { Err(err) => { @@ -147,7 +155,7 @@ impl Indexer { let mut best_inscriptions_state = self.state.best_inscriptions.write().await; - match best_inscriptions_state.last() { + match best_inscriptions_state.back() { Some(prev_best_inscription) => { inscription.height = prev_best_inscription.height + 1; inscription.previous_hash = prev_best_inscription @@ -168,13 +176,19 @@ impl Indexer { }, } - best_inscriptions_state.push(inscription); + best_inscriptions_state.push_back(inscription); } } } } } + if self.index_utxo { + let (spent, unspent) = UTXO::from_transaction(&tx); + let mut confirming_utxos = self.state.confirming_utxos.write().await; + confirming_utxos.push_back((block_height, spent, unspent)); + } + Ok(()) } @@ -224,7 +238,7 @@ impl Indexer { let mut prev_state: (Option, Option) = { let confirming_names = self.state.confirming_names.read().await; if let Some(names) = confirming_names.get(&name.name) { - let prev_name_state = names.last().map(|(name_state, _, _)| name_state); + let prev_name_state = names.back().map(|(name_state, _, _)| name_state); let prev_service_state = names .iter() .filter_map(|(_, service_state, _)| { @@ -279,7 +293,7 @@ impl Indexer { let mut confirming_names = self.state.confirming_names.write().await; if !confirming_names.contains_key(&name.name) { - confirming_names.insert(name.name.clone(), vec![]); + confirming_names.insert(name.name.clone(), VecDeque::new()); } let names = confirming_names @@ -288,7 +302,7 @@ impl Indexer { let name_state_hash = name_state.hash()?; let service_state_hash = service_state.hash()?; - names.push((name_state, service_state, None)); + names.push_back((name_state, service_state, None)); return Ok((name_state_hash, service_state_hash, None)); } @@ -343,7 +357,10 @@ impl Indexer { let name_state_hash = name_state.hash()?; let service_state_hash = service_state.hash()?; let mut confirming_names = self.state.confirming_names.write().await; - confirming_names.insert(name.name.clone(), vec![(name_state, service_state, None)]); + confirming_names.insert( + name.name.clone(), + VecDeque::from([(name_state, service_state, None)]), + ); Ok((name_state_hash, service_state_hash, None)) } @@ -359,16 +376,17 @@ impl Indexer { let mut empty_names: Vec = Vec::new(); for (name, names) in confirming_names.iter_mut() { - while let Some(head) = names.first() { + while let Some(head) = names.front() { if head.0.block_height > accepted_height { break; } - let (name_state, service_state, protocol_state) = names.remove(0); - name_states.push(name_state); - service_states.push(service_state); - if let Some(protocol_state) = protocol_state { - protocol_states.push(protocol_state); + if let Some((name_state, service_state, protocol_state)) = names.pop_front() { + name_states.push(name_state); + service_states.push(service_state); + if let Some(protocol_state) = protocol_state { + protocol_states.push(protocol_state); + } } } @@ -386,11 +404,13 @@ impl Indexer { { let mut best_inscriptions_state = self.state.best_inscriptions.write().await; - while let Some(inscription) = best_inscriptions_state.first() { + while let Some(inscription) = best_inscriptions_state.front() { if inscription.block_height > accepted_height { break; } - inscriptions.push(best_inscriptions_state.remove(0)); + if let Some(inscription) = best_inscriptions_state.pop_front() { + inscriptions.push(inscription); + } } } @@ -479,6 +499,25 @@ impl Indexer { db::NameState::batch_add_pubkey_names(&self.scylla, fresh_pubkey_names).await?; } + let mut utxos: Vec = Vec::new(); + if self.index_utxo { + let mut confirming_utxos = self.state.confirming_utxos.write().await; + + while let Some(utxo) = confirming_utxos.front() { + if utxo.0 > accepted_height { + break; + } + if let Some(utxo) = confirming_utxos.pop_front() { + utxos.push(utxo); + } + } + } + if !utxos.is_empty() { + for utxo in utxos { + db::Utxo::handle_utxo(&self.scylla, &utxo.1, &utxo.2).await?; + } + } + Ok(()) } } diff --git a/crates/ns-indexer/src/lib.rs b/crates/ns-indexer/src/lib.rs index 221c0b7..391d5f6 100644 --- a/crates/ns-indexer/src/lib.rs +++ b/crates/ns-indexer/src/lib.rs @@ -5,6 +5,7 @@ pub mod envelope; pub mod indexer; pub mod router; pub mod scanner; +pub mod utxo; pub const APP_NAME: &str = env!("CARGO_PKG_NAME"); pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/ns-indexer/src/router.rs b/crates/ns-indexer/src/router.rs index 620d4eb..8198464 100644 --- a/crates/ns-indexer/src/router.rs +++ b/crates/ns-indexer/src/router.rs @@ -20,6 +20,7 @@ pub fn new(state: Arc) -> Router { Router::new() .route("/", routing::get(api::version)) .route("/healthz", routing::get(api::healthz)) + .route("/best/utxo/list", routing::get(api::UtxoAPI::list)) .nest( "/v1/name", Router::new() diff --git a/crates/ns-indexer/src/scanner.rs b/crates/ns-indexer/src/scanner.rs index 819e3f5..48d1cdb 100644 --- a/crates/ns-indexer/src/scanner.rs +++ b/crates/ns-indexer/src/scanner.rs @@ -4,7 +4,6 @@ use std::{future::Future, sync::Arc}; use tokio::time::{sleep, Duration}; use crate::bitcoin::BitcoinRPC; -use crate::envelope; use crate::indexer::Indexer; pub struct Scanner { @@ -83,9 +82,8 @@ impl Scanner { ); for tx in block.txdata { - let envelopes = envelope::Envelope::from_transaction(&tx); self.indexer - .index(blockhash, block_height, block.header.time as u64, envelopes) + .index(blockhash, block_height, block.header.time as u64, tx) .await?; } Ok(()) diff --git a/crates/ns-indexer/src/utxo.rs b/crates/ns-indexer/src/utxo.rs new file mode 100644 index 0000000..a1c514b --- /dev/null +++ b/crates/ns-indexer/src/utxo.rs @@ -0,0 +1,42 @@ +use bitcoin::{hashes::Hash, Transaction}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)] +pub struct UTXO { + pub txid: Vec, + pub vout: u32, + pub amount: u64, // 0 means the UTXO is spent +} + +impl UTXO { + // return (spent, unspent) + pub fn from_transaction(tx: &Transaction) -> (Vec, Vec<(Vec, Self)>) { + let txid = tx.txid().to_byte_array().to_vec(); + + let spent: Vec = tx + .input + .iter() + .map(|txin| UTXO { + txid: txin.previous_output.txid.to_byte_array().to_vec(), + vout: txin.previous_output.vout, + amount: 0, + }) + .collect(); + + let mut unspent: Vec<(Vec, Self)> = Vec::new(); + for (i, txout) in tx.output.iter().enumerate() { + if txout.script_pubkey.is_p2tr() || txout.script_pubkey.is_p2wpkh() { + unspent.push(( + txout.script_pubkey.to_bytes(), + UTXO { + txid: txid.clone(), + vout: i as u32, + amount: txout.value.to_sat(), + }, + )); + } + } + + (spent, unspent) + } +} diff --git a/crates/ns-inscriber/Cargo.toml b/crates/ns-inscriber/Cargo.toml index f7be9dd..7028ab9 100644 --- a/crates/ns-inscriber/Cargo.toml +++ b/crates/ns-inscriber/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ns-inscriber" -version = "0.1.0" +version = "0.2.0" edition = "2021" rust-version = "1.64" description = "Name & Service Protocol inscriber service in Rust" @@ -27,6 +27,7 @@ tokio = { workspace = true } serde_json = { workspace = true } log = { workspace = true } structured-logger = { workspace = true } +ed25519-dalek = { workspace = true } futures = "0.3" reqwest = { version = "0.11", features = [ "rustls-tls", @@ -38,6 +39,16 @@ reqwest = { version = "0.11", features = [ bitcoin = { version = "0.31", features = ["serde", "base64", "rand"] } dotenvy = "0.15" bitcoincore-rpc-json = "0.18.0" +clap = { version = "=4.4.11", features = ["derive"] } +terminal-prompt = { version = "=0.2.3" } +coset = { version = "0.3" } +sys-locale = "0.3" +aes-gcm = "0.10" +rand_core = { version = "0.6", features = ["getrandom", "alloc"] } +chrono = { version = "0.4" } +sha3 = "0.10" +hex = "0.4" +slip10_ed25519 = "0.1.3" [dev-dependencies] hex-literal = "0.4" diff --git a/crates/ns-inscriber/README.md b/crates/ns-inscriber/README.md index fdb9c73..0a616c0 100644 --- a/crates/ns-inscriber/README.md +++ b/crates/ns-inscriber/README.md @@ -1,3 +1,33 @@ # ns-inscriber -More information about the protocol can be found in the [protocol documentation](https://github.com/ldclabs/ns-protocol) \ No newline at end of file +More information about the protocol can be found in the [protocol documentation](https://github.com/ldclabs/ns-protocol) + +## Development + +```sh +cargo run --package ns-inscriber --bin ns-inscriber +``` + +```sh +cargo build --release --package ns-inscriber --bin ns-inscriber +``` + +## Usage + +Rename `example.env` to `.env` and fill in the values, run ns-inscriber with `.env`: +```sh +./target/release/ns-inscriber help +./target/release/ns-inscriber list-keys +``` + +run ns-inscriber with `my.env` +```sh +./target/release/ns-inscriber -c my.env list-keys +``` + +This is the first transaction inscribed NS inscriptions on mainnet: +https://mempool.space/tx/8e9d3e0d762c1d2348a2ca046b36f8de001f740c976b09c046ee1f09a8680131 + +```sh +ns-inscriber -c my.env inscribe --txid 1d6166ed74982ffd757d3da4082fa18a61094785a3338d6caf3c50190f3e14d7 --addr bc1q6dukpvmcxae0pdh95zgh793l5ept8fluhqqnyc --fee 200 --key 0x31d6ec328b42051a63c1619afad4e60b78f4991e62337918fe2d2e694a4f88f7 --names 0,1,2,3,4,5,6,7,8,9,a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z +``` diff --git a/crates/ns-inscriber/sample.env b/crates/ns-inscriber/sample.env new file mode 100644 index 0000000..01d16a2 --- /dev/null +++ b/crates/ns-inscriber/sample.env @@ -0,0 +1,7 @@ +INSCRIBER_KEK="" +INSCRIBER_KEYS_DIR="./keys" + +BITCOIN_NETWORK=regtest # Allowed values: main, test, signet, regtest +BITCOIN_RPC_URL=http://127.0.0.1:18443 +BITCOIN_RPC_USER=test +BITCOIN_RPC_PASSWORD=123456 \ No newline at end of file diff --git a/crates/ns-inscriber/src/bin/main.rs b/crates/ns-inscriber/src/bin/main.rs index 3302285..14d8457 100644 --- a/crates/ns-inscriber/src/bin/main.rs +++ b/crates/ns-inscriber/src/bin/main.rs @@ -1,46 +1,652 @@ -use bitcoin::{ - address::NetworkChecked, - secp256k1::{rand, Keypair, Secp256k1}, - Address, Network, ScriptBuf, +use anyhow::anyhow; +use bitcoin::{address::NetworkChecked, Address, Amount, Network, PublicKey, ScriptBuf, Txid}; +use chrono::{Local, SecondsFormat}; +use clap::{Parser, Subcommand}; +use ns_protocol::ns::valid_name; +use std::{ + path::{Path, PathBuf}, + str::FromStr, }; -use dotenvy::dotenv; -use structured_logger::{async_json::new_writer, Builder}; +// use sys_locale::get_locale; +use coset::{CoseEncrypt0, TaggedCborSerializable}; +use terminal_prompt::Terminal; -use ns_inscriber::bitcoin::BitCoinRPCOptions; -use ns_inscriber::inscriber::{Inscriber, InscriberOptions}; +use ns_inscriber::{ + bitcoin::BitCoinRPCOptions, + inscriber::{Inscriber, InscriberOptions, UnspentTxOut}, + wallet::{ + base64_decode, base64_encode, base64url_decode, base64url_encode, ed25519, hash_256, iana, + secp256k1, unwrap_cbor_tag, wrap_cbor_tag, DerivationPath, Encrypt0, Key, + }, +}; +use ns_protocol::ns::{Name, Operation, PublicKeyParams, Service, ThresholdLevel, Value}; + +const AAD: &[u8; 12] = b"ns-inscriber"; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + /// Run with a env config file + #[arg(short, long, value_name = "FILE", default_value = ".env")] + config: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +pub enum Commands { + /// generate a new KEK used to protect other keys + NewKEK { + /// alias for the new KEK key + #[arg(short, long)] + alias: String, + }, + /// generate a new Secp256k1 seed key that protected by KEK + Secp256k1Seed {}, + /// generate a new Ed25519 seed key that protected by KEK + Ed25519Seed {}, + /// Derive a Secp256k1 key from seed, it is protected by KEK. + /// Options Will be combined to "m/44'/0'/{acc}'/1'/{idx}" (BIP-32/44) + Secp256k1Derive { + #[arg(long, default_value_t = 0)] + acc: u32, + #[arg(long, default_value_t = 0)] + idx: u32, + }, + /// Derive a Ed25519 key from seed, it is protected by KEK. + /// Options Will be combined to "m/42'/0'/{acc}'/1'/{idx}" (BIP-32/44) + Ed25519Derive { + #[arg(long, default_value_t = 0)] + acc: u32, + #[arg(long, default_value_t = 0)] + idx: u32, + }, + /// List keys in keys dir. + ListKeys {}, + /// Display secp256k1 addresses + Secp256k1Address { + /// secp256k key file name, will be combined to "{key}.cose.key" to read secp256k key + #[arg(long, value_name = "FILE")] + key: String, + }, + SignMessage { + /// Ed25519 or Secp256k1 key file name, will be combined to "{key}.cose.key" to read key + #[arg(long, value_name = "FILE")] + key: String, + /// message to sign + #[arg(long)] + msg: String, + /// output encoding format, default is base64, can be "hex" + #[arg(long, default_value = "base64")] + enc: String, + }, + VerifyMessage { + /// Ed25519 or Secp256k1 key file name, will be combined to "{key}.cose.key" to read key + #[arg(long, value_name = "FILE")] + key: String, + /// message to verify + #[arg(long)] + msg: String, + /// signature to verify + #[arg(long)] + sig: String, + /// signature encoding format, default is base64, can be "hex" + #[arg(long, default_value = "base64")] + enc: String, + }, + /// Send sats from tx to a bitcoin address + SendSats { + /// Unspent transaction id to spend + #[arg(long)] + txid: String, + /// Bitcoin address on tx to spend, will be combined to "{addr}.cose.key" to read secp256k key + #[arg(long, value_name = "FILE")] + addr: String, + /// Bitcoin address to receive sats + #[arg(long)] + to: String, + /// Amount of satoshis to send + #[arg(long)] + amount: u64, + /// fee rate in sat/vbyte + #[arg(long)] + fee: u64, + }, + /// Preview inscription transactions and min cost + Preview { + /// fee rate in sat/vbyte + #[arg(long)] + fee: u64, + /// ed25519 key file name, will be combined to "{key}.cose.key" to read ed25519 key + #[arg(long, value_name = "FILE")] + key: String, + /// names to inscribe, separated by comma + #[arg(long)] + names: String, + }, + /// Inscribe names to a transaction + Inscribe { + /// Unspent transaction id to spend + #[arg(long)] + txid: String, + /// Bitcoin address on tx to operate on, will be combined to "{addr}.cose.key" to read secp256k key + #[arg(long, value_name = "FILE")] + addr: String, + /// fee rate in sat/vbyte + #[arg(long)] + fee: u64, + /// ed25519 key file name, will be combined to "{key}.cose.key" to read ed25519 key + #[arg(long, value_name = "FILE")] + key: String, + /// names to inscribe, separated by comma + #[arg(long)] + names: String, + }, +} -#[tokio::main(flavor = "multi_thread", worker_threads = 1)] +#[tokio::main] async fn main() -> anyhow::Result<()> { - dotenv().expect(".env file not found"); + // let locale = get_locale().unwrap_or_else(|| String::from("en-US")); + // println!("The current locale is {}", locale); + + let cli = Cli::parse(); + + let config_path = cli.config.as_deref().expect("config path not found"); + dotenvy::from_filename(config_path).expect(".env file not found"); + + let keys_path = std::env::var("INSCRIBER_KEYS_DIR").unwrap_or("./keys".to_string()); + let keys_path = Path::new(&keys_path); + if std::fs::read_dir(keys_path).is_err() { + std::fs::create_dir_all(keys_path)?; + } + + let network = Network::from_core_arg(&std::env::var("BITCOIN_NETWORK").unwrap_or_default()) + .unwrap_or(Network::Regtest); + + println!("Bitcoin network: {}", network); + + match &cli.command { + Some(Commands::NewKEK { alias }) => { + let mut terminal = Terminal::open()?; + let password = terminal.prompt_sensitive("Enter a password to protect KEK")?; + let mkek = hash_256(password.as_bytes()); + let kid = if alias.is_empty() { + Local::now().to_rfc3339_opts(SecondsFormat::Secs, true) + } else { + alias.to_owned() + }; + let encryptor = Encrypt0::new(mkek); + let kek = Key::new_sym(iana::Algorithm::A256GCM, kid.as_bytes())?; + let data = encryptor.encrypt(&kek.to_vec()?, AAD, kid.as_bytes())?; + let data = wrap_cbor_tag(&data); + println!( + "Put this new KEK as INSCRIBER_KEK on config file:\n{}", + base64url_encode(&data) + ); + } + + Some(Commands::Secp256k1Seed {}) => { + let file = keys_path.join("secp256k1-seed.cose.key"); + if KekEncryptor::key_exists(&file) { + println!("{} exists, skipping key generation", file.display()); + return Ok(()); + } + + let kek = KekEncryptor::open()?; + let secp = secp256k1::Secp256k1::new(); + let keypair = secp256k1::new_secp256k1(&secp); + let (public_key, _parity) = keypair.x_only_public_key(); + let script_pubkey = ScriptBuf::new_p2tr(&secp, public_key, None); + let address: Address = + Address::from_script(&script_pubkey, network).unwrap(); + let key = Key::secp256k1_from_keypair(&keypair, address.to_string().as_bytes())?; + kek.save_key(&file, key)?; + println!("key: {}, address: {}", file.display(), address); + return Ok(()); + } + + Some(Commands::Secp256k1Derive { acc, idx }) => { + let kek = KekEncryptor::open()?; + let seed_key = kek.read_key(&keys_path.join("secp256k1-seed.cose.key"))?; + let kid = format!("m/44'/0'/{acc}'/1'/{idx}'"); + let path: DerivationPath = kid.parse()?; + let secp = secp256k1::Secp256k1::new(); + let keypair = + secp256k1::derive_secp256k1(&secp, network, &seed_key.secret_key()?, &path)?; + let address = Address::p2wpkh( + &PublicKey { + compressed: true, + inner: keypair.public_key(), + }, + network, + )?; + let key = Key::secp256k1_from_keypair(&keypair, kid.as_bytes())?; + let file = keys_path.join(format!("{}.cose.key", address)); + kek.save_key(&file, key)?; + println!("key: {}, address: {}", file.display(), address); + return Ok(()); + } + + Some(Commands::Ed25519Seed {}) => { + let file = keys_path.join("ed25519-seed.cose.key"); + if KekEncryptor::key_exists(&file) { + println!("{} exists, skipping key generation", file.display()); + return Ok(()); + } + + let kek = KekEncryptor::open()?; + let signing_key = ed25519::new_ed25519(); + let address = format!("0x{}", hex::encode(signing_key.verifying_key().to_bytes())); + let key = Key::ed25519_from_secret(signing_key.as_bytes(), address.as_bytes())?; + kek.save_key(&file, key)?; + println!("key: {}, public key: {}", file.display(), address); + return Ok(()); + } + + Some(Commands::Ed25519Derive { acc, idx }) => { + let kek = KekEncryptor::open()?; + let seed_key = kek.read_key(&keys_path.join("ed25519-seed.cose.key"))?; + let kid = format!("m/42'/0'/{acc}'/1'/{idx}'"); + let path: DerivationPath = kid.parse()?; + let signing_key = ed25519::derive_ed25519(&seed_key.secret_key()?, &path); + let address = format!("0x{}", hex::encode(signing_key.verifying_key().to_bytes())); + let key = Key::ed25519_from_secret(signing_key.as_bytes(), kid.as_bytes())?; + let file = keys_path.join(format!("{}.cose.key", address)); + kek.save_key(&file, key)?; + println!("key: {}, public key: {}", file.display(), address); + return Ok(()); + } + + Some(Commands::ListKeys {}) => { + for entry in std::fs::read_dir(keys_path)? { + let path = entry?.path(); + if path.is_file() { + let data = std::fs::read(&path)?; + let filename = &path + .file_name() + .expect("should get file name") + .to_string_lossy(); + let data = unwrap_cbor_tag(&data); + let e0 = CoseEncrypt0::from_tagged_slice(data).map_err(anyhow::Error::msg)?; + let key_id = String::from_utf8_lossy(&e0.unprotected.key_id); + if key_id.starts_with("m/") { + println!("\nkey file: {}\nkey derived path: {}", filename, key_id); + } else { + println!("\nkey file: {}\nkey id: {}", filename, key_id); + } + } + } + + return Ok(()); + } + + Some(Commands::Secp256k1Address { key }) => { + let kek = KekEncryptor::open()?; + let secp256k1_key = kek.read_key(&keys_path.join(format!("{key}.cose.key")))?; + if !secp256k1_key.is_crv(iana::EllipticCurve::Secp256k1) { + anyhow::bail!("{} is not a secp256k1 key", key); + } + let secp = secp256k1::Secp256k1::new(); + let keypair = + secp256k1::Keypair::from_seckey_slice(&secp, &secp256k1_key.secret_key()?)?; + let (public_key, _parity) = keypair.x_only_public_key(); + let script_pubkey = ScriptBuf::new_p2tr(&secp, public_key, None); + let address: Address = + Address::from_script(&script_pubkey, network).unwrap(); + println!("p2tr address: {}", address); + + let address = Address::p2pkh( + &PublicKey { + compressed: true, + inner: keypair.public_key(), + }, + network, + ); + println!("p2pkh address: {}", address); + + let address = Address::p2wpkh( + &PublicKey { + compressed: true, + inner: keypair.public_key(), + }, + network, + )?; + println!("p2wpkh address: {}", address); + return Ok(()); + } + + Some(Commands::SignMessage { key, msg, enc }) => { + let kek = KekEncryptor::open()?; + let cose_key = kek.read_key(&keys_path.join(format!("{key}.cose.key")))?; + println!("message: {}", msg); + + if cose_key.is_crv(iana::EllipticCurve::Secp256k1) { + let secp = secp256k1::Secp256k1::new(); + let keypair = + secp256k1::Keypair::from_seckey_slice(&secp, &cose_key.secret_key()?)?; + + let sig = secp256k1::sign_message(&secp, &keypair.secret_key(), msg); + if enc == "hex" { + println!("signature:\n{}", hex::encode(sig.serialize())); + } else { + println!("signature:\n{}", base64_encode(&sig.serialize())); + } + } else if cose_key.is_crv(iana::EllipticCurve::Ed25519) { + let signing_key = ed25519::SigningKey::from_bytes(&cose_key.secret_key()?); + let sig = ed25519::sign_message(&signing_key, msg); + if enc == "hex" { + println!("signature:\n{}", hex::encode(sig.to_bytes())); + } else { + println!("signature:\n{}", base64_encode(&sig.to_bytes())); + } + } else { + println!("unsupported key type"); + } + return Ok(()); + } + + Some(Commands::VerifyMessage { key, msg, sig, enc }) => { + let kek = KekEncryptor::open()?; + let cose_key = kek.read_key(&keys_path.join(format!("{key}.cose.key")))?; + let sig = if enc == "hex" { + hex::decode(sig)? + } else { + base64_decode(sig)? + }; + if cose_key.is_crv(iana::EllipticCurve::Secp256k1) { + let secp = secp256k1::Secp256k1::new(); + + let keypair = + secp256k1::Keypair::from_seckey_slice(&secp, &cose_key.secret_key()?)?; + secp256k1::verify_message(&secp, &keypair.public_key(), msg, &sig)?; + + println!("signature is valid"); + } else if cose_key.is_crv(iana::EllipticCurve::Ed25519) { + let signing_key = ed25519::SigningKey::from_bytes(&cose_key.secret_key()?); + ed25519::verify_message(&signing_key.verifying_key(), msg, &sig)?; - Builder::with_level("info") - .with_target_writer("*", new_writer(tokio::io::stdout())) - .init(); + println!("signature is valid"); + } else { + println!("unsupported key type"); + } + return Ok(()); + } + Some(Commands::SendSats { + txid, + addr, + to, + amount, + fee, + }) => { + let txid: Txid = txid.parse()?; + let to = Address::from_str(to)?.require_network(network)?; + let amount = Amount::from_sat(*amount); + let fee_rate = Amount::from_sat(*fee); + + let kek = KekEncryptor::open()?; + let secp256k1_key = kek.read_key(&keys_path.join(format!("{addr}.cose.key")))?; + if !secp256k1_key.is_crv(iana::EllipticCurve::Secp256k1) { + anyhow::bail!("{} is not a secp256k1 key", addr); + } + let secp = secp256k1::Secp256k1::new(); + let keypair = + secp256k1::Keypair::from_seckey_slice(&secp, &secp256k1_key.secret_key()?)?; + let (p2wpkh_pubkey, p2tr_pubkey) = secp256k1::as_script_pubkey(&secp, &keypair); + + let inscriber = get_inscriber(network).await?; + let tx = inscriber.bitcoin.get_transaction(&txid).await?; + let (vout, txout) = tx + .output + .iter() + .enumerate() + .find(|(_, o)| { + o.value > amount + && (o.script_pubkey == p2wpkh_pubkey || o.script_pubkey == p2tr_pubkey) + }) + .ok_or(anyhow!("no matched transaction out to spend"))?; + let txid = inscriber + .send_sats( + fee_rate, + &keypair.secret_key(), + &UnspentTxOut { + txid, + vout: vout as u32, + amount: txout.value, + script_pubkey: txout.script_pubkey.clone(), + }, + &to, + amount, + ) + .await?; + + println!("txid: {}", txid); + return Ok(()); + } + + Some(Commands::Preview { fee, key, names }) => { + let fee_rate = Amount::from_sat(*fee); + let names: Vec = names.split(',').map(|n| n.trim().to_string()).collect(); + for name in &names { + if !valid_name(name) { + return Err(anyhow!("invalid name to inscribe: {}", name)); + } + } + if names.is_empty() { + return Err(anyhow!("no names to inscribe")); + } + + let kek = KekEncryptor::open()?; + let ed25519_key = kek.read_key(&keys_path.join(format!("{key}.cose.key")))?; + if !ed25519_key.is_crv(iana::EllipticCurve::Ed25519) { + anyhow::bail!("{} is not a ed25519 key", key); + } + let signing_key = ed25519::SigningKey::from_bytes(&ed25519_key.secret_key()?); + let params = PublicKeyParams { + public_keys: vec![signing_key.verifying_key().to_bytes().to_vec()], + threshold: None, + kind: None, + }; + + let mut ns: Vec = Vec::with_capacity(names.len()); + for name in &names { + let mut n = Name { + name: name.clone(), + sequence: 0, + payload: Service { + code: 0, + operations: vec![Operation { + subcode: 1, + params: Value::from(¶ms), + }], + approver: None, + }, + signatures: vec![], + }; + n.sign( + ¶ms, + ThresholdLevel::All, + &[signing_key.as_bytes().to_vec()], + )?; + n.validate()?; + ns.push(n); + } + + let res = Inscriber::preview_inscription_transactions(&ns, fee_rate)?; + + println!( + "commit_tx: {} bytes, {} vBytes", + res.0.total_size(), + res.0.vsize() + ); + println!( + "reveal_tx: {} bytes, {} vBytes", + res.1.total_size(), + res.1.vsize() + ); + println!("total bytes: {}", res.0.total_size() + res.1.total_size()); + println!("min cost: {}", res.2); + return Ok(()); + } + + Some(Commands::Inscribe { + txid, + addr, + fee, + key, + names, + }) => { + let txid: Txid = txid.parse()?; + let fee_rate = Amount::from_sat(*fee); + let names: Vec = names.split(',').map(|n| n.trim().to_string()).collect(); + for name in &names { + if !valid_name(name) { + return Err(anyhow!("invalid name to inscribe: {}", name)); + } + } + if names.is_empty() { + return Err(anyhow!("no names to inscribe")); + } + + let kek = KekEncryptor::open()?; + let ed25519_key = kek.read_key(&keys_path.join(format!("{key}.cose.key")))?; + if !ed25519_key.is_crv(iana::EllipticCurve::Ed25519) { + anyhow::bail!("{} is not a ed25519 key", key); + } + let signing_key = ed25519::SigningKey::from_bytes(&ed25519_key.secret_key()?); + let params = PublicKeyParams { + public_keys: vec![signing_key.verifying_key().to_bytes().to_vec()], + threshold: None, + kind: None, + }; + + let mut ns: Vec = Vec::with_capacity(names.len()); + for name in &names { + let mut n = Name { + name: name.clone(), + sequence: 0, + payload: Service { + code: 0, + operations: vec![Operation { + subcode: 1, + params: Value::from(¶ms), + }], + approver: None, + }, + signatures: vec![], + }; + n.sign( + ¶ms, + ThresholdLevel::All, + &[signing_key.as_bytes().to_vec()], + )?; + n.validate()?; + ns.push(n); + } + + let secp256k1_key = kek.read_key(&keys_path.join(format!("{addr}.cose.key")))?; + if !secp256k1_key.is_crv(iana::EllipticCurve::Secp256k1) { + anyhow::bail!("{} is not a secp256k1 key", key); + } + let secp = secp256k1::Secp256k1::new(); + let keypair = + secp256k1::Keypair::from_seckey_slice(&secp, &secp256k1_key.secret_key()?)?; + let (p2wpkh_pubkey, p2tr_pubkey) = secp256k1::as_script_pubkey(&secp, &keypair); + + let inscriber = get_inscriber(network).await?; + let tx = inscriber.bitcoin.get_transaction(&txid).await?; + let (vout, txout) = tx + .output + .iter() + .enumerate() + .find(|(_, o)| o.script_pubkey == p2wpkh_pubkey || o.script_pubkey == p2tr_pubkey) + .ok_or(anyhow!("no matched transaction out to spend"))?; + let txid = inscriber + .inscribe( + &ns, + fee_rate, + &keypair.secret_key(), + &vec![UnspentTxOut { + txid, + vout: vout as u32, + amount: txout.value, + script_pubkey: txout.script_pubkey.clone(), + }], + None, + ) + .await?; + + println!("txid: {}", txid); + return Ok(()); + } + + None => {} + } + + Ok(()) +} + +struct KekEncryptor { + encryptor: Encrypt0, +} + +impl KekEncryptor { + fn open() -> anyhow::Result { + let kek_str = std::env::var("INSCRIBER_KEK").unwrap_or_default(); + if kek_str.is_empty() { + println!("INSCRIBER_KEK not found"); + println!("KEK is used to protect other keys"); + println!("Run `ns-inscriber new-kek --alias=mykek` to generate a new KEK`"); + return Err(anyhow::Error::msg("INSCRIBER_KEK not found")); + } + + let mut terminal = Terminal::open()?; + let password = terminal.prompt_sensitive("Enter a password to protect KEK: ")?; + let mkek = hash_256(password.as_bytes()); + let decryptor = Encrypt0::new(mkek); + let ciphertext = base64url_decode(kek_str.trim())?; + let key = decryptor.decrypt(unwrap_cbor_tag(&ciphertext), AAD)?; + let key = Key::from_slice(&key)?; + Ok(KekEncryptor { + encryptor: Encrypt0::new(key.secret_key()?), + }) + } + + fn key_exists(file: &Path) -> bool { + std::fs::read(file).is_ok() + } + + fn read_key(&self, file: &Path) -> anyhow::Result { + let data = std::fs::read(file)?; + let key = self.encryptor.decrypt(unwrap_cbor_tag(&data), AAD)?; + Key::from_slice(&key) + } + + fn save_key(&self, file: &Path, key: Key) -> anyhow::Result<()> { + let kid = key.key_id(); + let data = self + .encryptor + .encrypt(key.to_vec()?.as_slice(), AAD, &kid)?; + std::fs::write(file, wrap_cbor_tag(&data))?; + Ok(()) + } +} + +async fn get_inscriber(network: Network) -> anyhow::Result { let rpcurl = std::env::var("BITCOIN_RPC_URL").unwrap(); let rpcuser = std::env::var("BITCOIN_RPC_USER").unwrap(); let rpcpassword = std::env::var("BITCOIN_RPC_PASSWORD").unwrap(); - let secp = Secp256k1::new(); - let key_pair = Keypair::new(&secp, &mut rand::thread_rng()); - let (public_key, _parity) = key_pair.x_only_public_key(); - let script_pubkey = ScriptBuf::new_p2tr(&secp, public_key, None); - let address: Address = - Address::from_script(&script_pubkey, Network::Regtest).unwrap(); - - println!("address: {}", address); - let inscriber = Inscriber::new(&InscriberOptions { bitcoin: BitCoinRPCOptions { rpcurl, rpcuser, rpcpassword, - network: Network::Regtest, + network, }, - }) - .unwrap(); + })?; - inscriber.bitcoin.ping().await.unwrap(); - // ToDO - Ok(()) + inscriber.bitcoin.ping().await?; + Ok(inscriber) } diff --git a/crates/ns-inscriber/src/bitcoin.rs b/crates/ns-inscriber/src/bitcoin.rs index 815c673..9c7047b 100644 --- a/crates/ns-inscriber/src/bitcoin.rs +++ b/crates/ns-inscriber/src/bitcoin.rs @@ -269,7 +269,6 @@ pub fn decode_hex(hex: &str) -> anyhow::Result { #[cfg(test)] mod tests { use super::*; - use dotenvy::dotenv; #[test] fn decode_hex_works() { @@ -290,7 +289,7 @@ mod tests { #[tokio::test(flavor = "current_thread")] #[ignore] async fn rpc_works() { - dotenv().expect(".env file not found"); + dotenvy::from_filename("sample.env").expect(".env file not found"); let rpcurl = std::env::var("BITCOIN_RPC_URL").unwrap(); let rpcuser = std::env::var("BITCOIN_RPC_USER").unwrap(); diff --git a/crates/ns-inscriber/src/inscriber.rs b/crates/ns-inscriber/src/inscriber.rs index f37521e..88f24ea 100644 --- a/crates/ns-inscriber/src/inscriber.rs +++ b/crates/ns-inscriber/src/inscriber.rs @@ -1,6 +1,7 @@ use bitcoin::{ address::NetworkChecked, blockdata::{opcodes, script::PushBytesBuf}, + ecdsa, hashes::Hash, key::{ constants::{SCHNORR_PUBLIC_KEY_SIZE, SCHNORR_SIGNATURE_SIZE}, @@ -8,15 +9,16 @@ use bitcoin::{ }, locktime::absolute::LockTime, secp256k1::{rand, All, Keypair, Message, Secp256k1, SecretKey}, - sighash::{Prevouts, SighashCache, TapSighashType}, + sighash::{EcdsaSighashType, Prevouts, SighashCache, TapSighashType}, taproot::{ - LeafVersion, Signature, TapLeafHash, TaprootBuilder, TAPROOT_CONTROL_BASE_SIZE, + self, LeafVersion, TapLeafHash, TaprootBuilder, TAPROOT_CONTROL_BASE_SIZE, TAPROOT_CONTROL_NODE_SIZE, }, transaction::Version, Address, Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, }; +use std::collections::HashSet; use ns_protocol::ns::{Name, MAX_NAME_BYTES}; @@ -37,6 +39,7 @@ pub struct UnspentTxOut { pub txid: Txid, pub vout: u32, pub amount: Amount, + pub script_pubkey: ScriptBuf, } impl Inscriber { @@ -56,28 +59,27 @@ impl Inscriber { fee_rate: Amount, secret: &SecretKey, unspent_txouts: &Vec, - inscription_key_pair: Option, // safe to use one-time KeyPair + inscription_keypair: Option, // safe to use one-time KeyPair ) -> anyhow::Result { let keypair = Keypair::from_secret_key(&self.secp, secret); - let (internal_key, _parity) = keypair.x_only_public_key(); - let script_pubkey = ScriptBuf::new_p2tr(&self.secp, internal_key, None); - let address: Address = Address::from_script(&script_pubkey, self.network)?; - let (input, unsigned_commit_tx, signed_reveal_tx) = self + let (unspent_txout, unsigned_commit_tx, signed_reveal_tx) = self .build_inscription_transactions( names, fee_rate, - address, unspent_txouts, - inscription_key_pair, + Some(inscription_keypair.unwrap_or(keypair)), ) .await?; let mut signed_commit_tx = unsigned_commit_tx; // sigh commit_tx - { - let prevouts = vec![input]; + if unspent_txout.script_pubkey.is_p2tr() { let mut sighasher = SighashCache::new(&mut signed_commit_tx); + let prevouts = vec![TxOut { + value: unspent_txout.amount, + script_pubkey: unspent_txout.script_pubkey.clone(), + }]; let sighash = sighasher .taproot_key_spend_signature_hash( 0, @@ -92,22 +94,42 @@ impl Inscriber { &tweaked.to_inner(), ); + let signature = taproot::Signature { + sig, + hash_ty: TapSighashType::Default, + }; + sighasher .witness_mut(0) .expect("getting mutable witness reference should work") - .push( - &Signature { - sig, - hash_ty: TapSighashType::Default, - } - .to_vec(), - ); + .push(&signature.to_vec()); + } else if unspent_txout.script_pubkey.is_p2wpkh() { + let mut sighasher = SighashCache::new(&mut signed_commit_tx); + let sighash = sighasher + .p2wpkh_signature_hash( + 0, + &unspent_txout.script_pubkey, + unspent_txout.amount, + EcdsaSighashType::All, + ) + .expect("failed to create sighash"); + + let sig = self + .secp + .sign_ecdsa(&Message::from(sighash), &keypair.secret_key()); + let signature = ecdsa::Signature { + sig, + hash_ty: EcdsaSighashType::All, + }; + signed_commit_tx.input[0].witness = Witness::p2wpkh(&signature, &keypair.public_key()); + } else { + anyhow::bail!("unsupported script_pubkey"); } + let test_txs = self .bitcoin .test_mempool_accept(&[&signed_commit_tx, &signed_reveal_tx]) .await?; - for r in &test_txs { if !r.allowed { anyhow::bail!("failed to accept transaction: {:?}", &test_txs); @@ -122,28 +144,144 @@ impl Inscriber { Ok(reveal) } + pub async fn send_sats( + &self, + fee_rate: Amount, + secret: &SecretKey, + unspent_txout: &UnspentTxOut, + to: &Address, + amount: Amount, + ) -> anyhow::Result { + let keypair = Keypair::from_secret_key(&self.secp, secret); + + let mut tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: unspent_txout.txid, + vout: unspent_txout.vout, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![ + TxOut { + value: amount, + script_pubkey: to.script_pubkey(), + }, + TxOut { + // change + value: unspent_txout.amount, // will update later + script_pubkey: unspent_txout.script_pubkey.clone(), + }, + ], + }; + + let fee = { + let mut v_tx = tx.clone(); + v_tx.input[0].witness = Witness::from_slice(&[&[0; SCHNORR_SIGNATURE_SIZE]]); + fee_rate + .checked_mul(v_tx.vsize() as u64) + .expect("should compute commit_tx fee") + }; + + let change_value = unspent_txout + .amount + .checked_sub(amount) + .ok_or_else(|| anyhow::anyhow!("should compute amount"))?; + if change_value > fee { + tx.output[1].value = change_value - fee; + } else { + tx.output.pop(); // no change + } + + if unspent_txout.script_pubkey.is_p2tr() { + let mut sighasher = SighashCache::new(&mut tx); + let prevouts = vec![TxOut { + value: unspent_txout.amount, + script_pubkey: unspent_txout.script_pubkey.clone(), + }]; + let sighash = sighasher + .taproot_key_spend_signature_hash( + 0, + &Prevouts::All(&prevouts), + TapSighashType::Default, + ) + .expect("failed to construct sighash"); + + let tweaked: TweakedKeypair = keypair.tap_tweak(&self.secp, None); + let sig = self.secp.sign_schnorr( + &Message::from_digest(sighash.to_byte_array()), + &tweaked.to_inner(), + ); + + let signature = taproot::Signature { + sig, + hash_ty: TapSighashType::Default, + }; + + sighasher + .witness_mut(0) + .expect("getting mutable witness reference should work") + .push(&signature.to_vec()); + } else if unspent_txout.script_pubkey.is_p2wpkh() { + let mut sighasher = SighashCache::new(&mut tx); + let sighash = sighasher + .p2wpkh_signature_hash( + 0, + &unspent_txout.script_pubkey, + unspent_txout.amount, + EcdsaSighashType::All, + ) + .expect("failed to create sighash"); + + let sig = self + .secp + .sign_ecdsa(&Message::from(sighash), &keypair.secret_key()); + let signature = ecdsa::Signature { + sig, + hash_ty: EcdsaSighashType::All, + }; + tx.input[0].witness = Witness::p2wpkh(&signature, &keypair.public_key()); + } else { + anyhow::bail!("unsupported script_pubkey"); + } + + let test_txs = self.bitcoin.test_mempool_accept(&[&tx]).await?; + for r in &test_txs { + if !r.allowed { + anyhow::bail!("failed to accept transaction: {:?}", &test_txs); + } + } + + let txid = self.bitcoin.send_transaction(&tx).await?; + Ok(txid) + } + // return (to_spent_tx_out, unsigned_commit_tx, signed_reveal_tx) pub async fn build_inscription_transactions( &self, names: &Vec, fee_rate: Amount, - unspent_address: Address, unspent_txouts: &Vec, - inscription_key_pair: Option, - ) -> anyhow::Result<(TxOut, Transaction, Transaction)> { + inscription_keypair: Option, + ) -> anyhow::Result<(UnspentTxOut, Transaction, Transaction)> { if names.is_empty() { anyhow::bail!("no names to inscribe"); } if fee_rate.to_sat() == 0 { anyhow::bail!("fee rate cannot be zero"); } - if unspent_address.network() != &self.network { - anyhow::bail!("unspent address is not on the same network as the inscriber"); - } if unspent_txouts.is_empty() { anyhow::bail!("no unspent transaction out"); } + if let Some(name) = check_duplicate(names) { + anyhow::bail!("duplicate name {}", name); + } + for name in names { if let Err(err) = name.validate() { anyhow::bail!("invalid name {}: {}", name.name, err); @@ -159,28 +297,22 @@ impl Inscriber { } } - let input = TxOut { - value: unspent_tx.amount, - script_pubkey: unspent_address.script_pubkey(), - }; + let keypair = inscription_keypair + .unwrap_or_else(|| Keypair::new(&self.secp, &mut rand::thread_rng())); - let (unsigned_commit_tx, signed_reveal_tx) = self.create_inscription_transactions( - names, - fee_rate, - input.clone(), - OutPoint { - txid: unspent_tx.txid, - vout: unspent_tx.vout, - }, - inscription_key_pair, - )?; - Ok((input, unsigned_commit_tx, signed_reveal_tx)) + let (unsigned_commit_tx, signed_reveal_tx) = + self.create_inscription_transactions(names, fee_rate, unspent_tx, &keypair)?; + Ok((unspent_tx.to_owned(), unsigned_commit_tx, signed_reveal_tx)) } pub fn preview_inscription_transactions( names: &Vec, fee_rate: Amount, ) -> anyhow::Result<(Transaction, Transaction, Amount)> { + if let Some(name) = check_duplicate(names) { + anyhow::bail!("duplicate name {}", name); + } + let mut reveal_script = ScriptBuf::builder() .push_slice([0; SCHNORR_PUBLIC_KEY_SIZE]) .push_opcode(opcodes::all::OP_CHECKSIG) @@ -206,7 +338,7 @@ impl Inscriber { let mut witness = Witness::default(); witness.push( - Signature::from_slice(&[0; SCHNORR_SIGNATURE_SIZE]) + taproot::Signature::from_slice(&[0; SCHNORR_SIGNATURE_SIZE]) .unwrap() .to_vec(), ); @@ -263,14 +395,12 @@ impl Inscriber { &self, names: &Vec, fee_rate: Amount, - input: TxOut, - input_point: OutPoint, - inscription_key_pair: Option, + unspent_txout: &UnspentTxOut, + keypair: &Keypair, ) -> anyhow::Result<(Transaction, Transaction)> { // or use one-time KeyPair - let key_pair = inscription_key_pair - .unwrap_or_else(|| Keypair::new(&self.secp, &mut rand::thread_rng())); - let (public_key, _parity) = key_pair.x_only_public_key(); + + let (public_key, _parity) = keypair.x_only_public_key(); let mut reveal_script = ScriptBuf::builder() .push_slice(public_key.serialize()) @@ -314,8 +444,8 @@ impl Inscriber { witness: Witness::default(), // Filled in after signing. }], output: vec![TxOut { - value: input.value, - script_pubkey: input.script_pubkey.clone(), + value: unspent_txout.amount, + script_pubkey: unspent_txout.script_pubkey.clone(), }], }; @@ -323,7 +453,7 @@ impl Inscriber { let mut v_reveal_tx = reveal_tx.clone(); let mut witness = Witness::default(); witness.push( - Signature::from_slice(&[0; SCHNORR_SIGNATURE_SIZE]) + taproot::Signature::from_slice(&[0; SCHNORR_SIGNATURE_SIZE]) .unwrap() .to_vec(), ); @@ -339,13 +469,16 @@ impl Inscriber { version: Version::TWO, lock_time: LockTime::ZERO, input: vec![TxIn { - previous_output: input_point, + previous_output: OutPoint { + txid: unspent_txout.txid, + vout: unspent_txout.vout, + }, script_sig: ScriptBuf::new(), sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, witness: Witness::new(), }], output: vec![TxOut { - value: input.value, + value: unspent_txout.amount, script_pubkey: commit_tx_address.script_pubkey(), }], }; @@ -357,15 +490,14 @@ impl Inscriber { .checked_mul(v_commit_tx.vsize() as u64) .expect("should compute commit_tx fee") }; - let change_value = input - .value + let change_value = unspent_txout + .amount .checked_sub(commit_tx_fee) .ok_or_else(|| anyhow::anyhow!("should compute commit_tx fee"))?; commit_tx.output[0].value = change_value; - let change_value = input - .value + let change_value = change_value .checked_sub(reveal_tx_fee) .ok_or_else(|| anyhow::anyhow!("should compute commit_tx fee"))?; if change_value <= dust_value { @@ -393,13 +525,13 @@ impl Inscriber { let sig = self .secp - .sign_schnorr(&Message::from_digest(sighash.to_byte_array()), &key_pair); + .sign_schnorr(&Message::from_digest(sighash.to_byte_array()), keypair); let witness = sighasher .witness_mut(0) .expect("getting mutable witness reference should work"); witness.push( - Signature { + taproot::Signature { sig, hash_ty: TapSighashType::Default, } @@ -413,6 +545,17 @@ impl Inscriber { } } +pub fn check_duplicate(names: &Vec) -> Option { + let mut set: HashSet = HashSet::new(); + for name in names { + if set.contains(&name.name) { + return Some(name.name.clone()); + } + set.insert(name.name.clone()); + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -475,19 +618,22 @@ mod tests { #[tokio::test(flavor = "current_thread")] #[ignore] async fn inscriber_works() { - dotenvy::from_filename(".env.sample").expect(".env file not found"); + dotenvy::from_filename("sample.env").expect(".env file not found"); let rpcurl = std::env::var("BITCOIN_RPC_URL").unwrap(); let rpcuser = std::env::var("BITCOIN_RPC_USER").unwrap(); let rpcpassword = std::env::var("BITCOIN_RPC_PASSWORD").unwrap(); + let network = Network::from_core_arg(&std::env::var("BITCOIN_NETWORK").unwrap_or_default()) + .unwrap_or(Network::Regtest); let secp = Secp256k1::new(); - let key_pair = Keypair::new(&secp, &mut rand::thread_rng()); - let (public_key, _parity) = key_pair.x_only_public_key(); + let keypair = Keypair::new(&secp, &mut rand::thread_rng()); + let (public_key, _parity) = keypair.x_only_public_key(); let script_pubkey = ScriptBuf::new_p2tr(&secp, public_key, None); let address: Address = - Address::from_script(&script_pubkey, Network::Regtest).unwrap(); + Address::from_script(&script_pubkey, network).unwrap(); + println!("rpcurl: {}, network: {}", rpcurl, network); println!("address: {}", address); let inscriber = Inscriber::new(&InscriberOptions { @@ -495,7 +641,7 @@ mod tests { rpcurl, rpcuser, rpcpassword, - network: Network::Regtest, + network, }, }) .unwrap(); @@ -540,6 +686,7 @@ mod tests { txid: tx.txid(), vout: i as u32, amount: v.value, + script_pubkey: v.script_pubkey.clone(), }) } else { None @@ -551,7 +698,7 @@ mod tests { let names = vec![get_name("0")]; let fee_rate = Amount::from_sat(20); let txid = inscriber - .inscribe(&names, fee_rate, &key_pair.secret_key(), &unspent_txs, None) + .inscribe(&names, fee_rate, &keypair.secret_key(), &unspent_txs, None) .await .unwrap(); println!("txid: {}", txid); @@ -591,6 +738,7 @@ mod tests { txid: tx.txid(), vout: i as u32, amount: v.value, + script_pubkey: v.script_pubkey.clone(), }) } else { None @@ -613,9 +761,9 @@ mod tests { .inscribe( &names, fee_rate, - &key_pair.secret_key(), + &keypair.secret_key(), &unspent_txs, - Some(key_pair), + Some(keypair), ) .await .unwrap(); diff --git a/crates/ns-inscriber/src/lib.rs b/crates/ns-inscriber/src/lib.rs index 54d5b6c..a69a215 100644 --- a/crates/ns-inscriber/src/lib.rs +++ b/crates/ns-inscriber/src/lib.rs @@ -1,2 +1,3 @@ pub mod bitcoin; pub mod inscriber; +pub mod wallet; diff --git a/crates/ns-inscriber/src/wallet/cose_key.rs b/crates/ns-inscriber/src/wallet/cose_key.rs new file mode 100644 index 0000000..54badae --- /dev/null +++ b/crates/ns-inscriber/src/wallet/cose_key.rs @@ -0,0 +1,124 @@ +use ciborium::Value; +use coset::{ + iana, CborSerializable, CoseKey, CoseKeyBuilder, KeyType, Label, RegisteredLabelWithPrivate, +}; +use rand_core::{OsRng, RngCore}; + +use super::secp256k1::Keypair; + +const KEY_PARAM_K: Label = Label::Int(iana::SymmetricKeyParameter::K as i64); +const KEY_PARAM_D: Label = Label::Int(iana::OkpKeyParameter::D as i64); + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Key(pub CoseKey); + +impl Key { + pub fn new_sym(alg: iana::Algorithm, kid: &[u8]) -> anyhow::Result { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + + let mut key = CoseKeyBuilder::new_symmetric_key(key.to_vec()).algorithm(alg); + if !kid.is_empty() { + key = key.key_id(kid.to_vec()); + } + Ok(Self(key.build())) + } + + pub fn ed25519_from_secret(secret: &[u8; 32], kid: &[u8]) -> anyhow::Result { + let mut key = CoseKeyBuilder::new_okp_key() + .algorithm(iana::Algorithm::EdDSA) + .param( + iana::OkpKeyParameter::Crv as i64, + Value::from(iana::EllipticCurve::Ed25519 as i64), + ) + .param( + iana::OkpKeyParameter::D as i64, + Value::Bytes(secret.to_vec()), + ); + + if !kid.is_empty() { + key = key.key_id(kid.to_vec()); + } + Ok(Self(key.build())) + } + + pub fn secp256k1_from_keypair(keypair: &Keypair, kid: &[u8]) -> anyhow::Result { + let mut key = CoseKey { + kty: KeyType::Assigned(iana::KeyType::EC2), + alg: Some(RegisteredLabelWithPrivate::Assigned( + iana::Algorithm::ES256K, + )), + params: vec![ + ( + Label::Int(iana::Ec2KeyParameter::Crv as i64), + Value::from(iana::EllipticCurve::Secp256k1 as i64), + ), + ( + Label::Int(iana::Ec2KeyParameter::D as i64), + Value::Bytes(keypair.secret_key().as_ref().to_vec()), + ), + ], + ..Default::default() + }; + + if !kid.is_empty() { + key.key_id.extend_from_slice(kid); + } + Ok(Self(key)) + } + + pub fn key_id(&self) -> Vec { + self.0.key_id.clone() + } + + pub fn is_crv(&self, crv: iana::EllipticCurve) -> bool { + for (label, value) in &self.0.params { + if label == &Label::Int(iana::Ec2KeyParameter::Crv as i64) { + if let Some(val) = value.as_integer() { + return val == (crv as i64).into(); + } + } + } + false + } + + pub fn to_vec(self) -> anyhow::Result> { + self.0.to_vec().map_err(anyhow::Error::msg) + } + + pub fn from_slice(data: &[u8]) -> anyhow::Result { + let key = CoseKey::from_slice(data).map_err(anyhow::Error::msg)?; + Ok(Self(key)) + } + + pub fn secret_key(&self) -> anyhow::Result<[u8; 32]> { + let key_param = match self.0.kty { + KeyType::Assigned(iana::KeyType::Symmetric) => &KEY_PARAM_K, + KeyType::Assigned(iana::KeyType::OKP) => &KEY_PARAM_D, + KeyType::Assigned(iana::KeyType::EC2) => &Label::Int(iana::Ec2KeyParameter::D as i64), + _ => { + return Err(anyhow::Error::msg("unsupport key type")); + } + }; + + for (label, value) in &self.0.params { + if label == key_param { + match value { + Value::Bytes(val) => { + if val.len() != 32 { + return Err(anyhow::Error::msg("invalid key length, expected 32")); + } + let mut key = [0u8; 32]; + key.copy_from_slice(val); + return Ok(key); + } + _ => { + return Err(anyhow::Error::msg("invalid key type")); + } + } + } + } + + Err(anyhow::Error::msg("invalid key")) + } +} diff --git a/crates/ns-inscriber/src/wallet/ed25519.rs b/crates/ns-inscriber/src/wallet/ed25519.rs new file mode 100644 index 0000000..54fa4f7 --- /dev/null +++ b/crates/ns-inscriber/src/wallet/ed25519.rs @@ -0,0 +1,26 @@ +use bitcoin::bip32::DerivationPath; +use rand_core::{OsRng, RngCore}; +use slip10_ed25519::derive_ed25519_private_key; + +pub use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; + +pub fn derive_ed25519(seed: &[u8], path: &DerivationPath) -> SigningKey { + let secret = derive_ed25519_private_key(seed, &path.to_u32_vec()); + SigningKey::from_bytes(&secret) +} + +pub fn new_ed25519() -> SigningKey { + let mut secret = [0u8; 32]; + OsRng.fill_bytes(&mut secret); + SigningKey::from_bytes(&secret) +} + +pub fn sign_message(sk: &SigningKey, msg: &str) -> Signature { + sk.sign(msg.as_bytes()) +} + +pub fn verify_message(pk: &VerifyingKey, msg: &str, sig: &[u8]) -> anyhow::Result<()> { + let sig = Signature::from_slice(sig)?; + pk.verify_strict(msg.as_bytes(), &sig)?; + Ok(()) +} diff --git a/crates/ns-inscriber/src/wallet/encrypt.rs b/crates/ns-inscriber/src/wallet/encrypt.rs new file mode 100644 index 0000000..6516fe7 --- /dev/null +++ b/crates/ns-inscriber/src/wallet/encrypt.rs @@ -0,0 +1,80 @@ +use aes_gcm::{ + aead::{AeadCore, KeyInit}, + AeadInPlace, Aes256Gcm, Key, Nonce, +}; +use coset::{iana, CoseEncrypt0, CoseEncrypt0Builder, HeaderBuilder, TaggedCborSerializable}; +use rand_core::OsRng; + +pub struct Encrypt0 { + cipher: Aes256Gcm, +} + +impl Encrypt0 { + pub fn new(key: [u8; 32]) -> Self { + let key = Key::::from_slice(&key); + let cipher = Aes256Gcm::new(key); + Self { cipher } + } + + pub fn encrypt(&self, plaintext: &[u8], aad: &[u8], kid: &[u8]) -> anyhow::Result> { + let protected = HeaderBuilder::new() + .algorithm(iana::Algorithm::A256GCM) + .build(); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let unprotected = HeaderBuilder::new() + .key_id(kid.to_vec()) + .iv(nonce.to_vec()) + .build(); + + let e0 = CoseEncrypt0Builder::new() + .protected(protected) + .unprotected(unprotected) + .create_ciphertext(plaintext, aad, |plain, enc| { + let mut buf: Vec = Vec::with_capacity(plain.len() + 16); + buf.extend_from_slice(plain); + self.cipher.encrypt_in_place(&nonce, enc, &mut buf).unwrap(); + buf + }) + .build(); + e0.to_tagged_vec().map_err(anyhow::Error::msg) + } + + pub fn decrypt(&self, encrypt0_data: &[u8], aad: &[u8]) -> anyhow::Result> { + let e0 = CoseEncrypt0::from_tagged_slice(encrypt0_data).map_err(anyhow::Error::msg)?; + if e0.unprotected.iv.len() != 12 { + return Err(anyhow::Error::msg("invalid iv length")); + } + let nonce = Nonce::from_slice(&e0.unprotected.iv); + e0.decrypt(aad, |cipher, enc| { + let mut buf: Vec = Vec::with_capacity(cipher.len() + 16); + buf.extend_from_slice(cipher); + self.cipher + .decrypt_in_place(nonce, enc, &mut buf) + .map_err(anyhow::Error::msg)?; + Ok(buf) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use rand_core::RngCore; + + #[test] + fn encrypt0_works() { + let mut key = [0u8; 32]; + + OsRng.fill_bytes(&mut key); + let encrypt0 = Encrypt0::new(key); + + let plaintext = b"hello world"; + let data = encrypt0.encrypt(plaintext, b"yiwen.ai", b"test").unwrap(); + // println!("{}", hex_string(&data)); + let res = encrypt0.decrypt(&data, b"yiwen.ai").unwrap(); + assert_eq!(res, plaintext); + assert!(encrypt0.decrypt(&data[1..], b"yiwen.ai").is_err()); + assert!(encrypt0.decrypt(&data, b"yiwen").is_err()); + } +} diff --git a/crates/ns-inscriber/src/wallet/mod.rs b/crates/ns-inscriber/src/wallet/mod.rs new file mode 100644 index 0000000..355bbb6 --- /dev/null +++ b/crates/ns-inscriber/src/wallet/mod.rs @@ -0,0 +1,60 @@ +use base64::{engine::general_purpose, Engine as _}; +use rand_core::{OsRng, RngCore}; +use sha3::{Digest, Sha3_256}; + +mod cose_key; +pub mod ed25519; +mod encrypt; +pub mod secp256k1; + +pub use bitcoin::bip32::DerivationPath; +pub use cose_key::Key; +pub use coset::iana; +pub use encrypt::Encrypt0; + +// https://www.rfc-editor.org/rfc/rfc8949.html#name-self-described-cbor +pub const CBOR_TAG: [u8; 3] = [0xd9, 0xd9, 0xf7]; + +pub fn base64url_encode(data: &[u8]) -> String { + general_purpose::URL_SAFE_NO_PAD.encode(data) +} + +pub fn base64_encode(data: &[u8]) -> String { + general_purpose::STANDARD.encode(data) +} + +pub fn base64url_decode(data: &str) -> anyhow::Result> { + general_purpose::URL_SAFE_NO_PAD + .decode(data) + .map_err(anyhow::Error::msg) +} + +pub fn base64_decode(data: &str) -> anyhow::Result> { + general_purpose::STANDARD + .decode(data) + .map_err(anyhow::Error::msg) +} + +pub fn wrap_cbor_tag(data: &[u8]) -> Vec { + let mut buf: Vec = Vec::with_capacity(data.len() + 3); + buf.extend_from_slice(&CBOR_TAG); + buf.extend_from_slice(data); + buf +} + +pub fn unwrap_cbor_tag(data: &[u8]) -> &[u8] { + if data.len() > 3 && data[..3] == CBOR_TAG { + return &data[3..]; + } + data +} + +pub fn hash_256(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha3_256::new(); + hasher.update(data); + hasher.finalize().into() +} + +pub fn random_bytes(dest: &mut [u8]) { + OsRng.fill_bytes(dest); +} diff --git a/crates/ns-inscriber/src/wallet/secp256k1.rs b/crates/ns-inscriber/src/wallet/secp256k1.rs new file mode 100644 index 0000000..99ab66f --- /dev/null +++ b/crates/ns-inscriber/src/wallet/secp256k1.rs @@ -0,0 +1,79 @@ +use bitcoin::Network; +// use bip32::{DerivationPath, XPrv}; +use rand_core::OsRng; + +pub use bitcoin::{ + bip32::{ChildNumber, DerivationPath, Xpriv, Xpub}, + secp256k1::{ + hashes::{sha256, Hash}, + schnorr::Signature, + Keypair, Message, PublicKey, Secp256k1, SecretKey, Signing, Verification, + }, + sign_message::{signed_msg_hash, MessageSignature}, + ScriptBuf, +}; + +pub fn derive_secp256k1( + secp: &Secp256k1, + network: Network, + seed: &[u8], + path: &DerivationPath, +) -> anyhow::Result +where + C: Signing, +{ + let root = Xpriv::new_master(network, seed)?; + let child = root.derive_priv(secp, &path)?; + let key_pair = Keypair::from_seckey_slice(secp, child.to_priv().to_bytes().as_slice())?; + Ok(key_pair) +} + +pub fn new_secp256k1(secp: &Secp256k1) -> Keypair +where + C: Signing, +{ + Keypair::new(secp, &mut OsRng) +} + +// return (p2wpkh_pubkey, p2tr_pubkey) +pub fn as_script_pubkey(secp: &Secp256k1, keypair: &Keypair) -> (ScriptBuf, ScriptBuf) +where + C: Verification, +{ + let (xpk, _parity) = keypair.x_only_public_key(); + ( + ScriptBuf::new_p2wpkh( + &bitcoin::PublicKey::new(keypair.public_key()) + .wpubkey_hash() + .expect("key is compressed"), + ), + ScriptBuf::new_p2tr(secp, xpk, None), + ) +} + +pub fn sign_message(secp: &Secp256k1, sk: &SecretKey, message: &str) -> MessageSignature +where + C: Signing, +{ + let msg: Message = signed_msg_hash(message).into(); + let sig = secp.sign_ecdsa_recoverable(&msg, sk); + MessageSignature::new(sig, true) +} + +pub fn verify_message( + secp: &Secp256k1, + pk: &PublicKey, + message: &str, + sig: &[u8], +) -> anyhow::Result<()> +where + C: Verification, +{ + let msg = signed_msg_hash(message); + let sig = MessageSignature::from_slice(sig)?; + let pubkey = sig.recover_pubkey(secp, msg)?; + if pk != &pubkey.inner { + anyhow::bail!("invalid signature"); + } + Ok(()) +} diff --git a/crates/ns-protocol/Cargo.toml b/crates/ns-protocol/Cargo.toml index 608f8bd..2990343 100644 --- a/crates/ns-protocol/Cargo.toml +++ b/crates/ns-protocol/Cargo.toml @@ -22,5 +22,5 @@ finl_unicode = "1.2.0" sha3 = "0.10" [dev-dependencies] -faster-hex = "0.8" +hex = "0.4" hex-literal = "0.4" diff --git a/crates/ns-protocol/src/ns.rs b/crates/ns-protocol/src/ns.rs index 36eed71..53b2170 100644 --- a/crates/ns-protocol/src/ns.rs +++ b/crates/ns-protocol/src/ns.rs @@ -146,6 +146,7 @@ pub fn valid_name(name: &str) -> bool { || c.is_punctuation() || c.is_separator() || c.is_mark() + || c.is_symbol() || c.is_other() { return false; @@ -696,12 +697,11 @@ fn kind_of_value(v: &Value) -> String { #[cfg(test)] mod tests { use super::*; - use faster_hex::hex_string; use hex_literal::hex; #[test] fn valid_name_works() { - for name in &["a", "abc", "公信", "0", "🀄", "b0"] { + for name in &["a", "abc", "公信", "0", "b0"] { assert!(valid_name(name), "{} is invalid", name) } for name in &[ @@ -720,6 +720,7 @@ mod tests { "——", "\0", "\u{301}", + "🀄", "❤️‍🔥", "a\u{301}", ] { @@ -727,6 +728,21 @@ mod tests { } } + #[test] + fn check_ascii_name() { + let mut i = 0; + let mut result: String = String::new(); + while i < 127 { + let s = char::from(i).to_string(); + // println!("{}>{}<: {}", i, s, valid_name(&s)); + if valid_name(&s) { + result.push_str(&s) + } + i += 1; + } + assert_eq!(result, "0123456789abcdefghijklmnopqrstuvwxyz"); + } + #[test] fn signature_ser_de() { let sig = Signature(hex!("6b71fd0c8ae2ccc910c39dd20e76653fccca2638b7935f2312e954f5dccd71b209c58ca57e9d4fc2d3c06a57d585dbadf4535abb8a9cf103eeb9b9717d87f201").to_vec()); @@ -776,7 +792,7 @@ mod tests { )]); let data = name.to_bytes().unwrap(); - assert_eq!(hex_string(&data), "d83584616100820081820182815820ee90735ac719e85dc2f3e5974036387fdf478af7d9d1f8480e97eee60189026601815840e23554d996647e86f69115d04515398cc7463062d2683b099371360e93fa1cba02351492b70ef31037baa7780053bcf20b12bafe9531ee17fe140b93082a3f0c"); + assert_eq!(hex::encode(&data), "d83584616100820081820182815820ee90735ac719e85dc2f3e5974036387fdf478af7d9d1f8480e97eee60189026601815840e23554d996647e86f69115d04515398cc7463062d2683b099371360e93fa1cba02351492b70ef31037baa7780053bcf20b12bafe9531ee17fe140b93082a3f0c"); // 53(["a", 0, [0, [[1, [[h'ee90735ac719e85dc2f3e5974036387fdf478af7d9d1f8480e97eee601890266'], 1]]]], [h'e23554d996647e86f69115d04515398cc7463062d2683b099371360e93fa1cba02351492b70ef31037baa7780053bcf20b12bafe9531ee17fe140b93082a3f0c']]) let name2 = Name::decode_from(&data[..]).unwrap(); diff --git a/sample.env b/sample.env new file mode 100644 index 0000000..4f90ff0 --- /dev/null +++ b/sample.env @@ -0,0 +1,22 @@ +LOG_LEVEL=info + +BITCOIN_NETWORK=regtest +BITCOIN_RPC_URL=http://127.0.0.1:18443 +BITCOIN_RPC_USER=test +BITCOIN_RPC_PASSWORD=123456 + +# ns-indexer env +INDEXER_SERVER_WORKER_THREADS=0 # defaults to the number of cpus on the system +INDEXER_SERVER_ADDR=0.0.0.0:8080 +INDEXER_SERVER_NOSCAN=false # run as API server +INDEXER_UTXO=false # index UTXO when scanning +INDEXER_START_HEIGHT=0 + +SCYLLA_NODES=127.0.0.1:9042 # more nodes split by comma +SCYLLA_USERNAME="" +SCYLLA_PASSWORD="" +SCYLLA_KEYSPACE=ns_indexer + +# ns-inscriber env +INSCRIBER_KEK="" +INSCRIBER_KEYS_DIR="./keys" \ No newline at end of file