diff --git a/Cargo.lock b/Cargo.lock index 9aeb2a4..9675ede 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,7 @@ version = "0.1.0" dependencies = [ "anyhow", "ark-lib", + "arkd-rpc-client", "bdk", "bdk_bitcoind_rpc", "bdk_electrum", @@ -137,6 +138,7 @@ dependencies = [ "bip39", "bitcoin 0.30.0", "ciborium", + "clap", "electrum-client 0.19.0", "env_logger", "lazy_static", @@ -144,6 +146,7 @@ dependencies = [ "prost", "serde", "sled", + "sled-utils", "tokio", "tokio-stream", "tonic", diff --git a/ark-lib/src/connectors.rs b/ark-lib/src/connectors.rs index b34cb17..f28b53f 100644 --- a/ark-lib/src/connectors.rs +++ b/ark-lib/src/connectors.rs @@ -3,18 +3,20 @@ use std::iter; use bitcoin::{ - Address, Amount, Network, OutPoint, Script, ScriptBuf, Sequence, Transaction, TxIn, - TxOut, Witness, + Address, Amount, Network, OutPoint, Script, ScriptBuf, Sequence, Transaction, TxIn, TxOut, + Witness, }; use bitcoin::hashes::Hash; use bitcoin::secp256k1::{self, KeyPair, PublicKey}; use bitcoin::sighash::{self, SighashCache, TapSighashType}; use crate::{fee, util}; +use crate::util::KeyPairExt; /// The size in vbytes of each connector tx. const TX_SIZE: u64 = 154; +pub const INPUT_WEIGHT: usize = 66; /// A chain of connector outputs. @@ -88,7 +90,7 @@ impl ConnectorChain { ConnectorTxIter { len: self.len, spk: &self.spk, - sign_key: Some(sign_key), + sign_key: Some(sign_key.for_keyspend()), prev: self.utxo, idx: 0, } @@ -117,7 +119,7 @@ impl ConnectorChain { pub struct ConnectorTxIter<'a> { len: usize, spk: &'a Script, - sign_key: Option<&'a KeyPair>, + sign_key: Option, prev: OutPoint, idx: usize, @@ -158,16 +160,12 @@ impl<'a> iter::Iterator for ConnectorTxIter<'a> { value: ConnectorChain::required_budget(self.len - self.idx).to_sat(), }; let mut shc = SighashCache::new(&ret); - let sighash = shc.taproot_signature_hash( - 0, - &sighash::Prevouts::All(&[&prevout]), - None, - None, - TapSighashType::Default, + let sighash = shc.taproot_key_spend_signature_hash( + 0, &sighash::Prevouts::All(&[&prevout]), TapSighashType::Default, ).expect("sighash error"); // TODO(stevenroose) use from_digest here after secp version update let msg = secp256k1::Message::from_slice(&sighash.to_byte_array()).unwrap(); - let sig = util::SECP.sign_schnorr(&msg, keypair); + let sig = util::SECP.sign_schnorr(&msg, &keypair); ret.input[0].witness = Witness::from_slice(&[sig[..].to_vec()]); } @@ -240,7 +238,10 @@ mod test { assert_eq!(chain.connectors().count(), 100); assert_eq!(chain.iter_unsigned_txs().count(), 99); assert_eq!(chain.iter_signed_txs(&key).count(), 99); - chain.iter_signed_txs(&key).for_each(|t| assert_eq!(t.vsize() as u64, TX_SIZE)); + for tx in chain.iter_signed_txs(&key) { + assert_eq!(tx.vsize() as u64, TX_SIZE); + assert_eq!(tx.input[0].witness.serialized_len(), INPUT_WEIGHT); + } let size = chain.iter_signed_txs(&key).map(|t| t.vsize() as u64).sum::(); assert_eq!(size, ConnectorChain::total_vsize(100)); chain.iter_unsigned_txs().for_each(|t| assert_eq!(t.output[1].value, fee::DUST.to_sat())); diff --git a/ark-lib/src/lib.rs b/ark-lib/src/lib.rs index 5df2452..e33b1a7 100644 --- a/ark-lib/src/lib.rs +++ b/ark-lib/src/lib.rs @@ -7,7 +7,7 @@ pub mod forfeit; pub mod musig; pub mod onboard; pub mod tree; -mod util; +pub mod util; #[cfg(test)] mod napkin; diff --git a/ark-lib/src/tree/signed.rs b/ark-lib/src/tree/signed.rs index e792965..cea628c 100644 --- a/ark-lib/src/tree/signed.rs +++ b/ark-lib/src/tree/signed.rs @@ -7,7 +7,7 @@ use bitcoin::{ }; use bitcoin::secp256k1::{self, schnorr, PublicKey, XOnlyPublicKey}; use bitcoin::sighash::{self, SighashCache, TapSighash, TapSighashType}; -use bitcoin::taproot::TaprootBuilder; +use bitcoin::taproot::{ControlBlock, LeafVersion, TapNodeHash, TaprootBuilder}; use crate::{fee, musig, util, VtxoRequest}; use crate::tree::Tree; @@ -22,6 +22,9 @@ const NODE3_TX_VSIZE: u64 = 197; /// Size in vbytes for a node tx with radix 4. const NODE4_TX_VSIZE: u64 = 240; +//TODO(stevenroose) write a test for this +pub const NODE_SPEND_WEIGHT: usize = 140; + #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] pub struct VtxoTreeSpec { @@ -106,6 +109,15 @@ impl VtxoTreeSpec { util::timelock_sign(self.expiry_height.try_into().unwrap(), pk) } + /// The taproot scriptspend info for the expiry clause. + pub fn expiry_scriptspend(&self) -> (ControlBlock, ScriptBuf, LeafVersion, TapNodeHash) { + let taproot = self.cosign_taproot(); + let script = self.expiry_clause(); + let cb = taproot.control_block(&(script.clone(), LeafVersion::TapScript)) + .expect("expiry script should be in cosign taproot"); + (cb, script, LeafVersion::TapScript, taproot.merkle_root().unwrap()) + } + pub fn cosign_taproot(&self) -> taproot::TaprootSpendInfo { TaprootBuilder::new() .add_leaf(0, self.expiry_clause()).unwrap() diff --git a/ark-lib/src/util.rs b/ark-lib/src/util.rs index c63687b..4e8cd58 100644 --- a/ark-lib/src/util.rs +++ b/ark-lib/src/util.rs @@ -1,12 +1,26 @@ -use bitcoin::{opcodes, ScriptBuf}; -use bitcoin::secp256k1::{self, XOnlyPublicKey}; +use std::borrow::Borrow; + +use bitcoin::{opcodes, taproot, ScriptBuf}; +use bitcoin::secp256k1::{self, KeyPair, XOnlyPublicKey}; lazy_static::lazy_static! { /// Global secp context. pub static ref SECP: secp256k1::Secp256k1 = secp256k1::Secp256k1::new(); } +pub trait KeyPairExt: Borrow { + /// Adapt this key pair to be used in a key-spend-only taproot. + fn for_keyspend(&self) -> KeyPair { + let tweak = taproot::TapTweakHash::from_key_and_tweak( + self.borrow().x_only_public_key().0, None, + ).to_scalar(); + self.borrow().clone().add_xonly_tweak(&SECP, &tweak).expect("hashed values") + } +} + +impl KeyPairExt for KeyPair {} + /// Create a tapscript that is a checksig and a relative timelock. pub fn delayed_sign(delay_blocks: u16, pubkey: XOnlyPublicKey) -> ScriptBuf { let csv = bitcoin::Sequence::from_height(delay_blocks); diff --git a/arkd-rpc-client/src/arkd.rs b/arkd-rpc-client/src/arkd.rs index 8e0d78d..30d087d 100644 --- a/arkd-rpc-client/src/arkd.rs +++ b/arkd-rpc-client/src/arkd.rs @@ -182,6 +182,14 @@ pub struct VtxoSignaturesRequest { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct WalletStatusResponse { + #[prost(string, tag = "1")] + pub address: ::prost::alloc::string::String, + #[prost(uint64, tag = "2")] + pub balance: u64, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct Empty {} /// Generated client implementations. pub mod ark_service_client { @@ -536,6 +544,31 @@ pub mod admin_service_client { self.inner = self.inner.max_encoding_message_size(limit); self } + pub async fn wallet_status( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::new( + tonic::Code::Unknown, + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/arkd.AdminService/WalletStatus", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("arkd.AdminService", "WalletStatus")); + self.inner.unary(req, path, codec).await + } pub async fn stop( &mut self, request: impl tonic::IntoRequest, @@ -550,9 +583,9 @@ pub mod admin_service_client { ) })?; let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static("/arkd.AdminService/stop"); + let path = http::uri::PathAndQuery::from_static("/arkd.AdminService/Stop"); let mut req = request.into_request(); - req.extensions_mut().insert(GrpcMethod::new("arkd.AdminService", "stop")); + req.extensions_mut().insert(GrpcMethod::new("arkd.AdminService", "Stop")); self.inner.unary(req, path, codec).await } } diff --git a/arkd-rpc-client/src/lib.rs b/arkd-rpc-client/src/lib.rs index fb24ade..f0600c4 100644 --- a/arkd-rpc-client/src/lib.rs +++ b/arkd-rpc-client/src/lib.rs @@ -4,3 +4,4 @@ mod arkd; pub use arkd::*; pub use arkd::ark_service_client::ArkServiceClient; +pub use arkd::admin_service_client::AdminServiceClient; diff --git a/arkd/Cargo.toml b/arkd/Cargo.toml index a014c95..5006516 100644 --- a/arkd/Cargo.toml +++ b/arkd/Cargo.toml @@ -10,11 +10,14 @@ tonic-build = "0.10" [dependencies] ark-lib = { path = "../ark-lib" } +arkd-rpc-client = { path = "../arkd-rpc-client" } +sled-utils = { path = "../sled-utils" } anyhow.workspace = true lazy_static.workspace = true log.workspace = true env_logger.workspace = true +clap.workspace = true serde.workspace = true ciborium.workspace = true bitcoin.workspace = true @@ -29,4 +32,4 @@ tonic.workspace = true tokio.workspace = true tokio-stream.workspace = true -sled = "0.34.7" +sled.workspace = true diff --git a/arkd/rpc-protos/arkd.proto b/arkd/rpc-protos/arkd.proto index d0ace73..cea4d0e 100644 --- a/arkd/rpc-protos/arkd.proto +++ b/arkd/rpc-protos/arkd.proto @@ -128,7 +128,13 @@ message VtxoSignaturesRequest { /// Administration service for arkd. service AdminService { - rpc stop(Empty) returns (Empty) {} + rpc WalletStatus(Empty) returns (WalletStatusResponse) {} + rpc Stop(Empty) returns (Empty) {} +} + +message WalletStatusResponse { + string address = 1; + uint64 balance = 2; } message Empty {} diff --git a/arkd/src/database/mod.rs b/arkd/src/database/mod.rs index 3e54043..ffcdf39 100644 --- a/arkd/src/database/mod.rs +++ b/arkd/src/database/mod.rs @@ -9,12 +9,14 @@ use sled::transaction as tx; use ark::{VtxoId, VtxoSpec}; use ark::tree::signed::SignedVtxoTree; +use sled_utils::BucketTree; // TREE KEYS const FORFEIT_VTXO_TREE: &str = "forfeited_vtxos"; const ROUND_TREE: &str = "rounds"; +const ROUND_EXPIRY_TREE: &str = "rounds_by_expiry"; // ENTRY KEYS @@ -127,6 +129,7 @@ impl Db { } pub fn store_round(&self, round_tx: Transaction, vtxos: SignedVtxoTree) -> anyhow::Result<()> { + let expiry = vtxos.spec.expiry_height; let round = StoredRound { tx: round_tx, signed_tree: vtxos, @@ -134,6 +137,8 @@ impl Db { if self.db.open_tree(ROUND_TREE)?.insert(round.id(), round.encode())?.is_some() { warn!("Round with id {} already present!", round.id()); } + BucketTree::new(self.db.open_tree(ROUND_EXPIRY_TREE)?) + .insert(expiry.to_le_bytes(), &round.id())?; let mut fresh = self.get_fresh_round_ids()?; fresh.push(round.id()); @@ -150,6 +155,23 @@ impl Db { })) } + /// Get all round IDs of rounds that expired before or on [height]. + pub fn get_expired_rounds(&self, height: u32) -> anyhow::Result> { + let mut ret = Vec::new(); + for res in BucketTree::::new(self.db.open_tree(ROUND_EXPIRY_TREE)?).iter() { + let (keybuf, set) = res?; + let mut buf = [0u8; 4]; + buf[..].copy_from_slice(&keybuf[..]); + let expiry = u32::from_le_bytes(buf); + if expiry > height { + break; + } + ret.extend(set); + } + + Ok(ret) + } + pub fn get_fresh_round_ids(&self) -> anyhow::Result> { Ok(self.db.get(FRESH_ROUND_IDS)?.map(|b| { ciborium::from_reader(&b[..]).expect("corrupt db") diff --git a/arkd/src/lib.rs b/arkd/src/lib.rs index 410208d..d53112e 100644 --- a/arkd/src/lib.rs +++ b/arkd/src/lib.rs @@ -6,6 +6,7 @@ mod database; +mod psbtext; mod rpc; mod rpcserver; mod round; @@ -19,12 +20,14 @@ use std::str::FromStr; use std::time::Duration; use anyhow::Context; -use bitcoin::{Amount, Address}; -use bitcoin::bip32; +use bitcoin::{bip32, sighash, psbt, taproot, Amount, Address, OutPoint, Witness}; use bitcoin::secp256k1::{self, KeyPair}; use tokio::sync::Mutex; -use round::{RoundEvent, RoundInput}; +use ark::util::KeyPairExt; + +use crate::psbtext::{PsbtInputExt, RoundMeta}; +use crate::round::{RoundEvent, RoundInput}; const DB_MAGIC: &str = "bdk_wallet"; @@ -67,6 +70,7 @@ impl Default for Config { pub struct App { config: Config, db: database::Db, + master_xpriv: bip32::ExtendedPrivKey, master_key: KeyPair, wallet: Mutex>>, bitcoind: bdk_bitcoind_rpc::bitcoincore_rpc::Client, @@ -126,6 +130,7 @@ impl App { let ret = Arc::new(App { config: config, db: db, + master_xpriv: xpriv, master_key: master_key, wallet: Mutex::new(wallet), bitcoind: bitcoind, @@ -149,7 +154,7 @@ impl App { Ok((ret, jh)) } - pub async fn onchain_address(self: &Arc) -> anyhow::Result
{ + pub async fn onchain_address(&self) -> anyhow::Result
{ let mut wallet = self.wallet.lock().await; let ret = wallet.try_get_address(bdk::wallet::AddressIndex::New)?.address; // should always return the same address @@ -157,7 +162,7 @@ impl App { Ok(ret) } - pub async fn sync_onchain_wallet(self: &Arc) -> anyhow::Result { + pub async fn sync_onchain_wallet(&self) -> anyhow::Result { let mut wallet = self.wallet.lock().await; let prev_tip = wallet.latest_checkpoint(); // let keychain_spks = self.wallet.spks_of_all_keychains(); @@ -195,8 +200,119 @@ impl App { Ok(Amount::from_sat(balance.total())) } - pub fn cosign_onboard(self: &Arc, user_part: ark::onboard::UserPart) -> ark::onboard::AspPart { + pub fn cosign_onboard(&self, user_part: ark::onboard::UserPart) -> ark::onboard::AspPart { info!("Cosigning onboard request for utxo {}", user_part.utxo); ark::onboard::new_asp(&user_part, &self.master_key) } + + /// Returns a set of UTXOs from previous rounds that can be spent. + /// + /// It fills in the PSBT inputs with the fields required to sign, + /// for signing use [sign_round_utxo_inputs]. + fn spendable_expired_vtxos(&self, height: u32) -> anyhow::Result> { + let pubkey = self.master_key.public_key(); + + let expired_rounds = self.db.get_expired_rounds(height)?; + let mut ret = Vec::with_capacity(2 * expired_rounds.len()); + for round_txid in expired_rounds { + let round = self.db.get_round(round_txid)?.expect("db has round"); + + // First add the vtxo tree utxo. + let ( + spend_cb, spend_script, spend_lv, spend_merkle, + ) = round.signed_tree.spec.expiry_scriptspend(); + let mut psbt_in = psbt::Input { + witness_utxo: Some(round.tx.output[0].clone()), + sighash_type: Some(sighash::TapSighashType::Default.into()), + tap_internal_key: Some(round.signed_tree.spec.cosign_agg_pk), + tap_scripts: [(spend_cb, (spend_script, spend_lv))].into_iter().collect(), + tap_merkle_root: Some(spend_merkle), + non_witness_utxo: Some(round.tx.clone()), + ..Default::default() + }; + psbt_in.set_round_meta(round_txid, RoundMeta::Vtxo); + ret.push(SpendableUtxo { + point: OutPoint::new(round_txid, 0), + psbt: psbt_in, + weight: ark::tree::signed::NODE_SPEND_WEIGHT, + }); + + // Then add the connector output. + // NB this is safe because we will use SIGHASH_ALL. + let mut psbt_in = psbt::Input { + witness_utxo: Some(round.tx.output[1].clone()), + sighash_type: Some(sighash::TapSighashType::Default.into()), + tap_internal_key: Some(pubkey.x_only_public_key().0), + non_witness_utxo: Some(round.tx.clone()), + ..Default::default() + }; + psbt_in.set_round_meta(round_txid, RoundMeta::Connector); + ret.push(SpendableUtxo { + point: OutPoint::new(round_txid, 1), + psbt: psbt_in, + weight: ark::connectors::INPUT_WEIGHT, + }); + } + + Ok(ret) + } + + fn sign_round_utxo_inputs(&self, psbt: &mut psbt::Psbt) -> anyhow::Result<()> { + let mut shc = sighash::SighashCache::new(&psbt.unsigned_tx); + let prevouts = psbt.inputs.iter() + .map(|i| i.witness_utxo.clone().unwrap()) + .collect::>(); + + let connector_keypair = self.master_key.for_keyspend(); + for (idx, input) in psbt.inputs.iter_mut().enumerate() { + if let Some((_round, meta)) = input.get_round_meta().context("corrupt psbt")? { + match meta { + RoundMeta::Vtxo => { + let (control, (script, lv)) = input.tap_scripts.iter().next() + .context("corrupt psbt: missing tap_scripts")?; + let leaf_hash = taproot::TapLeafHash::from_script(script, *lv); + let sighash = shc.taproot_script_spend_signature_hash( + idx, + &sighash::Prevouts::All(&prevouts), + leaf_hash, + sighash::TapSighashType::Default, + ).expect("all prevouts provided"); + trace!("Signing expired VTXO input for sighash {}", sighash); + let msg = secp256k1::Message::from_slice(&sighash[..]).unwrap(); + let sig = SECP.sign_schnorr(&msg, &self.master_key); + let wit = Witness::from_slice( + &[&sig[..], script.as_bytes(), &control.serialize()], + ); + debug_assert_eq!(wit.serialized_len(), ark::tree::signed::NODE_SPEND_WEIGHT); + input.final_script_witness = Some(wit); + }, + RoundMeta::Connector => { + let sighash = shc.taproot_key_spend_signature_hash( + idx, + &sighash::Prevouts::All(&prevouts), + sighash::TapSighashType::Default, + ).expect("all prevouts provided"); + trace!("Signing expired connector input for sighash {}", sighash); + let msg = secp256k1::Message::from_slice(&sighash[..]).unwrap(); + let sig = SECP.sign_schnorr(&msg, &connector_keypair); + input.final_script_witness = Some(Witness::from_slice(&[sig[..].to_vec()])); + }, + } + } + } + + Ok(()) + } +} + +pub(crate) struct SpendableUtxo { + pub point: OutPoint, + pub psbt: psbt::Input, + pub weight: usize, +} + +impl SpendableUtxo { + pub fn amount(&self) -> Amount { + Amount::from_sat(self.psbt.witness_utxo.as_ref().unwrap().value) + } } diff --git a/arkd/src/main.rs b/arkd/src/main.rs index 62fccfe..b32f424 100644 --- a/arkd/src/main.rs +++ b/arkd/src/main.rs @@ -2,36 +2,101 @@ #[macro_use] extern crate log; use std::{env, fs, process}; +use std::str::FromStr; use std::time::Duration; +use anyhow::Context; +use bitcoin::Amount; +use clap::Parser; + use arkd::{App, Config}; +use arkd_rpc_client as rpc; + +#[derive(Parser)] +#[command(author = "Steven Roose ", version, about)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(clap::Subcommand)] +enum Command { + #[command()] + Balance, + #[command()] + GetAddress, + /// Stop arkd. + #[command()] + Stop, +} + +const RPC_ADDR: &str = "[::1]:35035"; #[tokio::main] async fn main() { + let cli = Cli::parse(); + + if let Some(cmd) = cli.command { + if let Err(e) = run_command(cmd).await { + eprintln!("An error occurred: {}", e); + // maybe hide second print behind a verbose flag + eprintln!(""); + eprintln!("{:?}", e); + process::exit(1); + } + } else { + env_logger::builder() + .filter_module("sled", log::LevelFilter::Warn) + .filter_module("bitcoincore_rpc", log::LevelFilter::Warn) + .filter_level(log::LevelFilter::Trace) + .init(); + + let cfg = Config { + network: bitcoin::Network::Regtest, + public_rpc_address: RPC_ADDR.parse().unwrap(), + datadir: env::current_dir().unwrap().join("test/arkd/"), + round_interval: Duration::from_secs(10), + round_submit_time: Duration::from_secs(2), + round_sign_time: Duration::from_secs(2), + nb_round_nonces: 100, + vtxo_expiry_delta: 1 * 24 * 6, + vtxo_exit_delta: 2 * 6, + ..Default::default() + }; + fs::create_dir_all(&cfg.datadir).expect("failed to create datadir"); + + let (app, jh) = App::start(cfg).unwrap(); + info!("arkd onchain address: {}", app.onchain_address().await.unwrap()); + if let Err(e) = jh.await.unwrap() { + error!("Shutdown error from arkd: {:?}", e); + process::exit(1); + } + } +} + +async fn run_command(cmd: Command) -> anyhow::Result<()> { env_logger::builder() + .target(env_logger::Target::Stderr) .filter_module("sled", log::LevelFilter::Warn) - .filter_module("bitcoincore_rpc", log::LevelFilter::Warn) + .filter_module("bitcoincore_rpc", log::LevelFilter::Debug) .filter_level(log::LevelFilter::Trace) .init(); - let cfg = Config { - network: bitcoin::Network::Regtest, - public_rpc_address: "[::1]:35035".parse().unwrap(), - datadir: env::current_dir().unwrap().join("test/arkd/"), - round_interval: Duration::from_secs(10), - round_submit_time: Duration::from_secs(2), - round_sign_time: Duration::from_secs(2), - nb_round_nonces: 100, - vtxo_expiry_delta: 1 * 24 * 6, - vtxo_exit_delta: 2 * 6, - ..Default::default() - }; - fs::create_dir_all(&cfg.datadir).expect("failed to create datadir"); + let asp_endpoint = tonic::transport::Uri::from_str(&format!("http://{}", RPC_ADDR)) + .context("invalid asp addr")?; + let mut asp = rpc::AdminServiceClient::connect(asp_endpoint) + .await.context("failed to connect to asp")?; - let (app, jh) = App::start(cfg).unwrap(); - info!("arkd onchain address: {}", app.onchain_address().await.unwrap()); - if let Err(e) = jh.await.unwrap() { - error!("Shutdown error from arkd: {:?}", e); - process::exit(1); + match cmd { + Command::Balance => { + let res = asp.wallet_status(rpc::Empty {}).await?.into_inner(); + println!("{}", Amount::from_sat(res.balance)); + }, + Command::GetAddress => { + let res = asp.wallet_status(rpc::Empty {}).await?.into_inner(); + println!("{}", res.address); + }, + Command::Stop => unreachable!(), } + Ok(()) } diff --git a/arkd/src/psbtext.rs b/arkd/src/psbtext.rs new file mode 100644 index 0000000..f110f4b --- /dev/null +++ b/arkd/src/psbtext.rs @@ -0,0 +1,49 @@ + + +use std::borrow::BorrowMut; + +use anyhow::Context; +use bitcoin::{psbt, Txid}; +use bitcoin::hashes::Hash; + + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum RoundMeta { + Connector, + Vtxo, +} + +const PROP_KEY_PREFIX: &'static [u8] = "arkd".as_bytes(); + +enum PropKey { + RoundMeta = 1, +} + +fn prop_key_round_meta(id: Txid) -> psbt::raw::ProprietaryKey { + psbt::raw::ProprietaryKey { + prefix: PROP_KEY_PREFIX.to_vec(), + subtype: PropKey::RoundMeta as u8, + key: id[..].to_vec(), + } +} + +pub trait PsbtInputExt: BorrowMut { + fn set_round_meta(&mut self, round_id: Txid, meta: RoundMeta) { + let mut buf = Vec::new(); + ciborium::into_writer(&meta, &mut buf).expect("can't fail"); + self.borrow_mut().proprietary.insert(prop_key_round_meta(round_id), buf); + } + + fn get_round_meta(&self) -> anyhow::Result> { + for (key, val) in &self.borrow().proprietary { + if key.prefix == PROP_KEY_PREFIX && key.subtype == PropKey::RoundMeta as u8 { + let txid = Txid::from_slice(&key.key).context("corrupt psbt: Txid")?; + let meta = ciborium::from_reader(&val[..]).context("corrupt psbt: RoundMeta")?; + return Ok(Some((txid, meta))); + } + } + Ok(None) + } +} + +impl PsbtInputExt for psbt::Input {} diff --git a/arkd/src/round/mod.rs b/arkd/src/round/mod.rs index 7e76d49..60051c7 100644 --- a/arkd/src/round/mod.rs +++ b/arkd/src/round/mod.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Context; -use bitcoin::{Amount, FeeRate, OutPoint, Transaction}; +use bitcoin::{Amount, FeeRate, OutPoint, Sequence, Transaction}; use bitcoin::hashes::Hash; +use bitcoin::locktime::absolute::LockTime; use bitcoin::secp256k1::{rand, KeyPair, PublicKey}; use bitcoin::sighash::TapSighash; @@ -151,6 +152,8 @@ pub async fn run_round_scheduler( ) -> anyhow::Result<()> { let cfg = &app.config; + info!("Onchain balance: {}", app.sync_onchain_wallet().await?); + //TODO(stevenroose) somehow get these from a fee estimator service let offboard_feerate = FeeRate::from_sat_per_vb(10).unwrap(); let round_tx_feerate = FeeRate::from_sat_per_vb(10).unwrap(); @@ -173,6 +176,8 @@ pub async fn run_round_scheduler( let _ = app.round_event_tx.send(RoundEvent::Start { id: round_id, offboard_feerate }); // Allocate this data once per round so that we can keep them + // Perhaps we could even keep allocations between all rounds, but time + // in between attempts is way more critial than in between rounds. let mut all_inputs = HashMap::::new(); let mut all_outputs = Vec::::new(); let mut all_offboards = Vec::::new(); @@ -276,8 +281,8 @@ pub async fn run_round_scheduler( // * meaning from the root tx down to the leaves. // **************************************************************** - let tip = bdk_bitcoind_rpc::bitcoincore_rpc::RpcApi::get_block_count(&app.bitcoind)?; - let expiry = tip as u32 + cfg.vtxo_expiry_delta as u32; + let tip = bdk_bitcoind_rpc::bitcoincore_rpc::RpcApi::get_block_count(&app.bitcoind)? as u32; + let expiry = tip+ cfg.vtxo_expiry_delta as u32; debug!("Current tip is {}, so round vtxos will expire at {}", tip, expiry); let cosign_agg_pk = musig::combine_keys(cosigners.iter().copied()); @@ -297,18 +302,33 @@ pub async fn run_round_scheduler( ); // Build round tx. + let spendable_utxos = app.spendable_expired_vtxos(tip)?; + if !spendable_utxos.is_empty() { + debug!("Will be spending {} round-related UTXOs with total value of {}", + spendable_utxos.len(), spendable_utxos.iter().map(|v| v.amount()).sum::(), + ); + for u in &spendable_utxos { + trace!("Including round-related UTXO {} with value {}", u.point, u.amount()); + } + } //TODO(stevenroose) think about if we can release lock sooner let mut wallet = app.wallet.lock().await; let mut round_tx_psbt = { let mut b = wallet.build_tx(); b.ordering(bdk::wallet::tx_builder::TxOrdering::Untouched); + b.nlocktime(LockTime::from_height(tip).expect("actual height")); + for utxo in &spendable_utxos { + b.add_foreign_utxo_with_sequence( + utxo.point, utxo.psbt.clone(), utxo.weight, Sequence::ZERO, + ).expect("bdk rejected foreign utxo"); + } b.add_recipient(vtxos_spec.cosign_spk(), vtxos_spec.total_required_value().to_sat()); b.add_recipient(connector_output.script_pubkey, connector_output.value); for offb in &all_offboards { b.add_recipient(offb.script_pubkey.clone(), offb.amount.to_sat()); } b.fee_rate(round_tx_feerate.to_bdk()); - b.finish().context("bdk failed to create round tx")? + b.finish().expect("bdk failed to create round tx") }; let round_tx = round_tx_psbt.clone().extract_tx(); let vtxos_utxo = OutPoint::new(round_tx.txid(), 0); @@ -541,6 +561,7 @@ pub async fn run_round_scheduler( // **************************************************************** // Sign the on-chain tx. + app.sign_round_utxo_inputs(&mut round_tx_psbt).context("signing round inputs")?; let finalized = wallet.sign(&mut round_tx_psbt, bdk::SignOptions::default())?; assert!(finalized); let round_tx = round_tx_psbt.extract_tx(); diff --git a/arkd/src/rpc/arkd.rs b/arkd/src/rpc/arkd.rs index f5e338b..c8969b7 100644 --- a/arkd/src/rpc/arkd.rs +++ b/arkd/src/rpc/arkd.rs @@ -182,6 +182,14 @@ pub struct VtxoSignaturesRequest { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct WalletStatusResponse { + #[prost(string, tag = "1")] + pub address: ::prost::alloc::string::String, + #[prost(uint64, tag = "2")] + pub balance: u64, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct Empty {} /// Generated server implementations. pub mod ark_service_server { @@ -732,6 +740,13 @@ pub mod admin_service_server { /// Generated trait containing gRPC methods that should be implemented for use with AdminServiceServer. #[async_trait] pub trait AdminService: Send + Sync + 'static { + async fn wallet_status( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; async fn stop( &self, request: tonic::Request, @@ -817,11 +832,55 @@ pub mod admin_service_server { fn call(&mut self, req: http::Request) -> Self::Future { let inner = self.inner.clone(); match req.uri().path() { - "/arkd.AdminService/stop" => { + "/arkd.AdminService/WalletStatus" => { #[allow(non_camel_case_types)] - struct stopSvc(pub Arc); + struct WalletStatusSvc(pub Arc); impl tonic::server::UnaryService - for stopSvc { + for WalletStatusSvc { + type Response = super::WalletStatusResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::wallet_status(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let inner = inner.0; + let method = WalletStatusSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/arkd.AdminService/Stop" => { + #[allow(non_camel_case_types)] + struct StopSvc(pub Arc); + impl tonic::server::UnaryService + for StopSvc { type Response = super::Empty; type Future = BoxFuture< tonic::Response, @@ -845,7 +904,7 @@ pub mod admin_service_server { let inner = self.inner.clone(); let fut = async move { let inner = inner.0; - let method = stopSvc(inner); + let method = StopSvc(inner); let codec = tonic::codec::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( diff --git a/arkd/src/rpc/mod.rs b/arkd/src/rpc/mod.rs index 11cfdfc..85500ae 100644 --- a/arkd/src/rpc/mod.rs +++ b/arkd/src/rpc/mod.rs @@ -2,3 +2,4 @@ mod arkd; pub use self::arkd::*; pub use self::arkd::ark_service_server::{ArkService, ArkServiceServer}; +pub use self::arkd::admin_service_server::{AdminService, AdminServiceServer}; diff --git a/arkd/src/rpcserver.rs b/arkd/src/rpcserver.rs index 1ff826e..cefec6e 100644 --- a/arkd/src/rpcserver.rs +++ b/arkd/src/rpcserver.rs @@ -50,7 +50,7 @@ impl rpc::ArkService for Arc { ) -> Result, tonic::Status> { let ret = rpc::ArkInfo { pubkey: self.master_key.public_key().serialize().to_vec(), - xonly_pubkey: self.master_key.public_key().x_only_public_key().0.serialize().to_vec(), + xonly_pubkey: self.master_key.x_only_public_key().0.serialize().to_vec(), nb_round_nonces: self.config.nb_round_nonces as u32, vtxo_exit_delta: self.config.vtxo_exit_delta as u32, vtxo_expiry_delta: self.config.vtxo_expiry_delta as u32, @@ -261,13 +261,37 @@ impl rpc::ArkService for Arc { } } +#[tonic::async_trait] +impl rpc::AdminService for Arc { + async fn wallet_status( + &self, + _req: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(rpc::WalletStatusResponse { + address: self.onchain_address().await.to_status()?.to_string(), + balance: self.sync_onchain_wallet().await.to_status()?.to_sat(), + })) + } + + async fn stop( + &self, + _req: tonic::Request, + ) -> Result, tonic::Status> { + //TODO(stevenroose) implement graceful shutdown + std::process::exit(0); + } +} + /// Run the public gRPC endpoint. pub async fn run_public_rpc_server(app: Arc) -> anyhow::Result<()> { let addr = app.config.public_rpc_address; info!("Starting gRPC service on address {}", addr); - let server = rpc::ArkServiceServer::new(app); + let ark_server = rpc::ArkServiceServer::new(app.clone()); + let admin_server = rpc::AdminServiceServer::new(app.clone()); tonic::transport::Server::builder() - .add_service(server) + .add_service(ark_server) + //TODO(stevenroose) serve on different port or so + .add_service(admin_server) .serve(addr) .await?; Ok(()) diff --git a/noah/src/exit.rs b/noah/src/exit.rs index 36715ad..31eab13 100644 --- a/noah/src/exit.rs +++ b/noah/src/exit.rs @@ -8,7 +8,7 @@ use bitcoin::{secp256k1, sighash, taproot, Amount, OutPoint, Witness}; use ark::{Vtxo, VtxoSpec}; use crate::{SECP, Wallet}; -use crate::psbt::PsbtInputExt; +use crate::psbtext::PsbtInputExt; @@ -211,10 +211,9 @@ impl Wallet { .control_block(&(exit_script.clone(), lver)) .expect("script is in taproot"); - let mut wit = Witness::new(); - wit.push(&sig[..]); - wit.push(exit_script.as_bytes()); - wit.push(cb.serialize()); + let wit = Witness::from_slice( + &[&sig[..], exit_script.as_bytes(), &cb.serialize()], + ); debug_assert_eq!(wit.serialized_len(), claim.satisfaction_weight()); input.final_script_witness = Some(wit); } diff --git a/noah/src/lib.rs b/noah/src/lib.rs index 17c76df..210f7c1 100644 --- a/noah/src/lib.rs +++ b/noah/src/lib.rs @@ -6,7 +6,7 @@ mod database; mod exit; mod onchain; -mod psbt; +mod psbtext; use std::{env, fs, iter}; @@ -116,7 +116,7 @@ impl Wallet { .await.context("failed to connect to asp")?; let ark_info = { - let res = asp.get_ark_info(arkd_rpc_client::Empty{}) + let res = asp.get_ark_info(rpc::Empty{}) .await.context("ark info request failed")?.into_inner(); ArkInfo { asp_pubkey: PublicKey::from_slice(&res.pubkey).context("asp pubkey")?, diff --git a/noah/src/onchain/mod.rs b/noah/src/onchain/mod.rs index 3c03a5f..22f5abd 100644 --- a/noah/src/onchain/mod.rs +++ b/noah/src/onchain/mod.rs @@ -1,5 +1,6 @@ +use std::iter; use std::path::Path; use anyhow::Context; @@ -12,7 +13,7 @@ use bitcoin::{ use bitcoin::psbt::PartiallySignedTransaction as Psbt; //TODO(stevenroose) when v0.31 use crate::exit; -use crate::psbt::PsbtInputExt; +use crate::psbtext::PsbtInputExt; const DB_MAGIC: &str = "onchain_bdk"; @@ -162,7 +163,8 @@ impl Wallet { version: 2, lock_time: bitcoin::absolute::LockTime::ZERO, input: vec![], - output: vec![ark::fee::dust_anchor(); utxo.vout as usize + 1], + output: iter::repeat(TxOut::default()).take(utxo.vout as usize) + .chain([ark::fee::dust_anchor()]).collect(), }), ..Default::default() }; diff --git a/noah/src/psbt.rs b/noah/src/psbtext.rs similarity index 75% rename from noah/src/psbt.rs rename to noah/src/psbtext.rs index 529cf89..c74e1ac 100644 --- a/noah/src/psbt.rs +++ b/noah/src/psbtext.rs @@ -7,9 +7,13 @@ use bitcoin::psbt; use crate::exit; -lazy_static::lazy_static! { - static ref PROP_KEY_PREFIX: &'static [u8] = "ark_noah".as_bytes(); +const PROP_KEY_PREFIX: &'static [u8] = "ark_noah".as_bytes(); +enum PropKey { + ClaimInput = 1, +} + +lazy_static::lazy_static! { static ref PROP_KEY_CLAIM_INPUT: psbt::raw::ProprietaryKey = psbt::raw::ProprietaryKey { prefix: PROP_KEY_PREFIX.to_vec(), subtype: PropKey::ClaimInput as u8, @@ -17,10 +21,9 @@ lazy_static::lazy_static! { }; } -enum PropKey { - ClaimInput = 1, -} - +//TODO(stevenroose) the "corrupt psbt" expects are only safe if all psbts stay +// within internal use, if we ever share them for communication or in a db, +// they need to return errors pub trait PsbtInputExt: BorrowMut { fn set_claim_input(&mut self, input: &exit::ClaimInput) { self.borrow_mut().proprietary.insert(PROP_KEY_CLAIM_INPUT.clone(), input.encode()); diff --git a/sled-utils/src/lib.rs b/sled-utils/src/lib.rs index cefc0a5..4e579ca 100644 --- a/sled-utils/src/lib.rs +++ b/sled-utils/src/lib.rs @@ -58,7 +58,11 @@ impl Buck // naively extrapolate the set's size let old_len = setbuf.map(|b| b.len()).unwrap_or_default(); - let item_len = old_len / (set.len() - 1); + let item_len = if set.len() == 1 { + 0 // can't know + } else { + old_len / (set.len() - 1) + }; let mut buf = Vec::with_capacity(old_len + item_len); ciborium::into_writer(&set, &mut buf).expect("bufs don't error"); Some(buf)