Implement ASP spending of expired VTXO UTXOs

This commit is contained in:
Steven Roose
2024-02-15 20:38:25 +00:00
parent 378c9dac75
commit 1328e294e0
22 changed files with 510 additions and 72 deletions

3
Cargo.lock generated
View File

@@ -130,6 +130,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ark-lib", "ark-lib",
"arkd-rpc-client",
"bdk", "bdk",
"bdk_bitcoind_rpc", "bdk_bitcoind_rpc",
"bdk_electrum", "bdk_electrum",
@@ -137,6 +138,7 @@ dependencies = [
"bip39", "bip39",
"bitcoin 0.30.0", "bitcoin 0.30.0",
"ciborium", "ciborium",
"clap",
"electrum-client 0.19.0", "electrum-client 0.19.0",
"env_logger", "env_logger",
"lazy_static", "lazy_static",
@@ -144,6 +146,7 @@ dependencies = [
"prost", "prost",
"serde", "serde",
"sled", "sled",
"sled-utils",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tonic", "tonic",

View File

@@ -3,18 +3,20 @@
use std::iter; use std::iter;
use bitcoin::{ use bitcoin::{
Address, Amount, Network, OutPoint, Script, ScriptBuf, Sequence, Transaction, TxIn, Address, Amount, Network, OutPoint, Script, ScriptBuf, Sequence, Transaction, TxIn, TxOut,
TxOut, Witness, Witness,
}; };
use bitcoin::hashes::Hash; use bitcoin::hashes::Hash;
use bitcoin::secp256k1::{self, KeyPair, PublicKey}; use bitcoin::secp256k1::{self, KeyPair, PublicKey};
use bitcoin::sighash::{self, SighashCache, TapSighashType}; use bitcoin::sighash::{self, SighashCache, TapSighashType};
use crate::{fee, util}; use crate::{fee, util};
use crate::util::KeyPairExt;
/// The size in vbytes of each connector tx. /// The size in vbytes of each connector tx.
const TX_SIZE: u64 = 154; const TX_SIZE: u64 = 154;
pub const INPUT_WEIGHT: usize = 66;
/// A chain of connector outputs. /// A chain of connector outputs.
@@ -88,7 +90,7 @@ impl ConnectorChain {
ConnectorTxIter { ConnectorTxIter {
len: self.len, len: self.len,
spk: &self.spk, spk: &self.spk,
sign_key: Some(sign_key), sign_key: Some(sign_key.for_keyspend()),
prev: self.utxo, prev: self.utxo,
idx: 0, idx: 0,
} }
@@ -117,7 +119,7 @@ impl ConnectorChain {
pub struct ConnectorTxIter<'a> { pub struct ConnectorTxIter<'a> {
len: usize, len: usize,
spk: &'a Script, spk: &'a Script,
sign_key: Option<&'a KeyPair>, sign_key: Option<KeyPair>,
prev: OutPoint, prev: OutPoint,
idx: usize, idx: usize,
@@ -158,16 +160,12 @@ impl<'a> iter::Iterator for ConnectorTxIter<'a> {
value: ConnectorChain::required_budget(self.len - self.idx).to_sat(), value: ConnectorChain::required_budget(self.len - self.idx).to_sat(),
}; };
let mut shc = SighashCache::new(&ret); let mut shc = SighashCache::new(&ret);
let sighash = shc.taproot_signature_hash( let sighash = shc.taproot_key_spend_signature_hash(
0, 0, &sighash::Prevouts::All(&[&prevout]), TapSighashType::Default,
&sighash::Prevouts::All(&[&prevout]),
None,
None,
TapSighashType::Default,
).expect("sighash error"); ).expect("sighash error");
// TODO(stevenroose) use from_digest here after secp version update // TODO(stevenroose) use from_digest here after secp version update
let msg = secp256k1::Message::from_slice(&sighash.to_byte_array()).unwrap(); 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()]); 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.connectors().count(), 100);
assert_eq!(chain.iter_unsigned_txs().count(), 99); assert_eq!(chain.iter_unsigned_txs().count(), 99);
assert_eq!(chain.iter_signed_txs(&key).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::<u64>(); let size = chain.iter_signed_txs(&key).map(|t| t.vsize() as u64).sum::<u64>();
assert_eq!(size, ConnectorChain::total_vsize(100)); assert_eq!(size, ConnectorChain::total_vsize(100));
chain.iter_unsigned_txs().for_each(|t| assert_eq!(t.output[1].value, fee::DUST.to_sat())); chain.iter_unsigned_txs().for_each(|t| assert_eq!(t.output[1].value, fee::DUST.to_sat()));

View File

@@ -7,7 +7,7 @@ pub mod forfeit;
pub mod musig; pub mod musig;
pub mod onboard; pub mod onboard;
pub mod tree; pub mod tree;
mod util; pub mod util;
#[cfg(test)] #[cfg(test)]
mod napkin; mod napkin;

View File

@@ -7,7 +7,7 @@ use bitcoin::{
}; };
use bitcoin::secp256k1::{self, schnorr, PublicKey, XOnlyPublicKey}; use bitcoin::secp256k1::{self, schnorr, PublicKey, XOnlyPublicKey};
use bitcoin::sighash::{self, SighashCache, TapSighash, TapSighashType}; 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::{fee, musig, util, VtxoRequest};
use crate::tree::Tree; use crate::tree::Tree;
@@ -22,6 +22,9 @@ const NODE3_TX_VSIZE: u64 = 197;
/// Size in vbytes for a node tx with radix 4. /// Size in vbytes for a node tx with radix 4.
const NODE4_TX_VSIZE: u64 = 240; 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)] #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
pub struct VtxoTreeSpec { pub struct VtxoTreeSpec {
@@ -106,6 +109,15 @@ impl VtxoTreeSpec {
util::timelock_sign(self.expiry_height.try_into().unwrap(), pk) 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 { pub fn cosign_taproot(&self) -> taproot::TaprootSpendInfo {
TaprootBuilder::new() TaprootBuilder::new()
.add_leaf(0, self.expiry_clause()).unwrap() .add_leaf(0, self.expiry_clause()).unwrap()

View File

@@ -1,12 +1,26 @@
use bitcoin::{opcodes, ScriptBuf}; use std::borrow::Borrow;
use bitcoin::secp256k1::{self, XOnlyPublicKey};
use bitcoin::{opcodes, taproot, ScriptBuf};
use bitcoin::secp256k1::{self, KeyPair, XOnlyPublicKey};
lazy_static::lazy_static! { lazy_static::lazy_static! {
/// Global secp context. /// Global secp context.
pub static ref SECP: secp256k1::Secp256k1<secp256k1::All> = secp256k1::Secp256k1::new(); pub static ref SECP: secp256k1::Secp256k1<secp256k1::All> = secp256k1::Secp256k1::new();
} }
pub trait KeyPairExt: Borrow<KeyPair> {
/// 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. /// Create a tapscript that is a checksig and a relative timelock.
pub fn delayed_sign(delay_blocks: u16, pubkey: XOnlyPublicKey) -> ScriptBuf { pub fn delayed_sign(delay_blocks: u16, pubkey: XOnlyPublicKey) -> ScriptBuf {
let csv = bitcoin::Sequence::from_height(delay_blocks); let csv = bitcoin::Sequence::from_height(delay_blocks);

View File

@@ -182,6 +182,14 @@ pub struct VtxoSignaturesRequest {
} }
#[allow(clippy::derive_partial_eq_without_eq)] #[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)] #[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 {} pub struct Empty {}
/// Generated client implementations. /// Generated client implementations.
pub mod ark_service_client { pub mod ark_service_client {
@@ -536,6 +544,31 @@ pub mod admin_service_client {
self.inner = self.inner.max_encoding_message_size(limit); self.inner = self.inner.max_encoding_message_size(limit);
self self
} }
pub async fn wallet_status(
&mut self,
request: impl tonic::IntoRequest<super::Empty>,
) -> std::result::Result<
tonic::Response<super::WalletStatusResponse>,
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( pub async fn stop(
&mut self, &mut self,
request: impl tonic::IntoRequest<super::Empty>, request: impl tonic::IntoRequest<super::Empty>,
@@ -550,9 +583,9 @@ pub mod admin_service_client {
) )
})?; })?;
let codec = tonic::codec::ProstCodec::default(); 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(); 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 self.inner.unary(req, path, codec).await
} }
} }

View File

@@ -4,3 +4,4 @@ mod arkd;
pub use arkd::*; pub use arkd::*;
pub use arkd::ark_service_client::ArkServiceClient; pub use arkd::ark_service_client::ArkServiceClient;
pub use arkd::admin_service_client::AdminServiceClient;

View File

@@ -10,11 +10,14 @@ tonic-build = "0.10"
[dependencies] [dependencies]
ark-lib = { path = "../ark-lib" } ark-lib = { path = "../ark-lib" }
arkd-rpc-client = { path = "../arkd-rpc-client" }
sled-utils = { path = "../sled-utils" }
anyhow.workspace = true anyhow.workspace = true
lazy_static.workspace = true lazy_static.workspace = true
log.workspace = true log.workspace = true
env_logger.workspace = true env_logger.workspace = true
clap.workspace = true
serde.workspace = true serde.workspace = true
ciborium.workspace = true ciborium.workspace = true
bitcoin.workspace = true bitcoin.workspace = true
@@ -29,4 +32,4 @@ tonic.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
sled = "0.34.7" sled.workspace = true

View File

@@ -128,7 +128,13 @@ message VtxoSignaturesRequest {
/// Administration service for arkd. /// Administration service for arkd.
service AdminService { 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 {} message Empty {}

View File

@@ -9,12 +9,14 @@ use sled::transaction as tx;
use ark::{VtxoId, VtxoSpec}; use ark::{VtxoId, VtxoSpec};
use ark::tree::signed::SignedVtxoTree; use ark::tree::signed::SignedVtxoTree;
use sled_utils::BucketTree;
// TREE KEYS // TREE KEYS
const FORFEIT_VTXO_TREE: &str = "forfeited_vtxos"; const FORFEIT_VTXO_TREE: &str = "forfeited_vtxos";
const ROUND_TREE: &str = "rounds"; const ROUND_TREE: &str = "rounds";
const ROUND_EXPIRY_TREE: &str = "rounds_by_expiry";
// ENTRY KEYS // ENTRY KEYS
@@ -127,6 +129,7 @@ impl Db {
} }
pub fn store_round(&self, round_tx: Transaction, vtxos: SignedVtxoTree) -> anyhow::Result<()> { pub fn store_round(&self, round_tx: Transaction, vtxos: SignedVtxoTree) -> anyhow::Result<()> {
let expiry = vtxos.spec.expiry_height;
let round = StoredRound { let round = StoredRound {
tx: round_tx, tx: round_tx,
signed_tree: vtxos, signed_tree: vtxos,
@@ -134,6 +137,8 @@ impl Db {
if self.db.open_tree(ROUND_TREE)?.insert(round.id(), round.encode())?.is_some() { if self.db.open_tree(ROUND_TREE)?.insert(round.id(), round.encode())?.is_some() {
warn!("Round with id {} already present!", round.id()); 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()?; let mut fresh = self.get_fresh_round_ids()?;
fresh.push(round.id()); 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<Vec<Txid>> {
let mut ret = Vec::new();
for res in BucketTree::<Txid>::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<Vec<Txid>> { pub fn get_fresh_round_ids(&self) -> anyhow::Result<Vec<Txid>> {
Ok(self.db.get(FRESH_ROUND_IDS)?.map(|b| { Ok(self.db.get(FRESH_ROUND_IDS)?.map(|b| {
ciborium::from_reader(&b[..]).expect("corrupt db") ciborium::from_reader(&b[..]).expect("corrupt db")

View File

@@ -6,6 +6,7 @@
mod database; mod database;
mod psbtext;
mod rpc; mod rpc;
mod rpcserver; mod rpcserver;
mod round; mod round;
@@ -19,12 +20,14 @@ use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use bitcoin::{Amount, Address}; use bitcoin::{bip32, sighash, psbt, taproot, Amount, Address, OutPoint, Witness};
use bitcoin::bip32;
use bitcoin::secp256k1::{self, KeyPair}; use bitcoin::secp256k1::{self, KeyPair};
use tokio::sync::Mutex; 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"; const DB_MAGIC: &str = "bdk_wallet";
@@ -67,6 +70,7 @@ impl Default for Config {
pub struct App { pub struct App {
config: Config, config: Config,
db: database::Db, db: database::Db,
master_xpriv: bip32::ExtendedPrivKey,
master_key: KeyPair, master_key: KeyPair,
wallet: Mutex<bdk::Wallet<bdk_file_store::Store<'static, bdk::wallet::ChangeSet>>>, wallet: Mutex<bdk::Wallet<bdk_file_store::Store<'static, bdk::wallet::ChangeSet>>>,
bitcoind: bdk_bitcoind_rpc::bitcoincore_rpc::Client, bitcoind: bdk_bitcoind_rpc::bitcoincore_rpc::Client,
@@ -126,6 +130,7 @@ impl App {
let ret = Arc::new(App { let ret = Arc::new(App {
config: config, config: config,
db: db, db: db,
master_xpriv: xpriv,
master_key: master_key, master_key: master_key,
wallet: Mutex::new(wallet), wallet: Mutex::new(wallet),
bitcoind: bitcoind, bitcoind: bitcoind,
@@ -149,7 +154,7 @@ impl App {
Ok((ret, jh)) Ok((ret, jh))
} }
pub async fn onchain_address(self: &Arc<Self>) -> anyhow::Result<Address> { pub async fn onchain_address(&self) -> anyhow::Result<Address> {
let mut wallet = self.wallet.lock().await; let mut wallet = self.wallet.lock().await;
let ret = wallet.try_get_address(bdk::wallet::AddressIndex::New)?.address; let ret = wallet.try_get_address(bdk::wallet::AddressIndex::New)?.address;
// should always return the same address // should always return the same address
@@ -157,7 +162,7 @@ impl App {
Ok(ret) Ok(ret)
} }
pub async fn sync_onchain_wallet(self: &Arc<Self>) -> anyhow::Result<Amount> { pub async fn sync_onchain_wallet(&self) -> anyhow::Result<Amount> {
let mut wallet = self.wallet.lock().await; let mut wallet = self.wallet.lock().await;
let prev_tip = wallet.latest_checkpoint(); let prev_tip = wallet.latest_checkpoint();
// let keychain_spks = self.wallet.spks_of_all_keychains(); // let keychain_spks = self.wallet.spks_of_all_keychains();
@@ -195,8 +200,119 @@ impl App {
Ok(Amount::from_sat(balance.total())) Ok(Amount::from_sat(balance.total()))
} }
pub fn cosign_onboard(self: &Arc<Self>, 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); info!("Cosigning onboard request for utxo {}", user_part.utxo);
ark::onboard::new_asp(&user_part, &self.master_key) 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<Vec<SpendableUtxo>> {
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::<Vec<_>>();
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)
}
} }

View File

@@ -2,36 +2,101 @@
#[macro_use] extern crate log; #[macro_use] extern crate log;
use std::{env, fs, process}; use std::{env, fs, process};
use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use anyhow::Context;
use bitcoin::Amount;
use clap::Parser;
use arkd::{App, Config}; use arkd::{App, Config};
use arkd_rpc_client as rpc;
#[derive(Parser)]
#[command(author = "Steven Roose <steven@roose.io>", version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(clap::Subcommand)]
enum Command {
#[command()]
Balance,
#[command()]
GetAddress,
/// Stop arkd.
#[command()]
Stop,
}
const RPC_ADDR: &str = "[::1]:35035";
#[tokio::main] #[tokio::main]
async fn 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() env_logger::builder()
.target(env_logger::Target::Stderr)
.filter_module("sled", log::LevelFilter::Warn) .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) .filter_level(log::LevelFilter::Trace)
.init(); .init();
let cfg = Config { let asp_endpoint = tonic::transport::Uri::from_str(&format!("http://{}", RPC_ADDR))
network: bitcoin::Network::Regtest, .context("invalid asp addr")?;
public_rpc_address: "[::1]:35035".parse().unwrap(), let mut asp = rpc::AdminServiceClient::connect(asp_endpoint)
datadir: env::current_dir().unwrap().join("test/arkd/"), .await.context("failed to connect to asp")?;
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(); match cmd {
info!("arkd onchain address: {}", app.onchain_address().await.unwrap()); Command::Balance => {
if let Err(e) = jh.await.unwrap() { let res = asp.wallet_status(rpc::Empty {}).await?.into_inner();
error!("Shutdown error from arkd: {:?}", e); println!("{}", Amount::from_sat(res.balance));
process::exit(1); },
Command::GetAddress => {
let res = asp.wallet_status(rpc::Empty {}).await?.into_inner();
println!("{}", res.address);
},
Command::Stop => unreachable!(),
} }
Ok(())
} }

49
arkd/src/psbtext.rs Normal file
View File

@@ -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<psbt::Input> {
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<Option<(Txid, RoundMeta)>> {
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 {}

View File

@@ -5,8 +5,9 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Context; use anyhow::Context;
use bitcoin::{Amount, FeeRate, OutPoint, Transaction}; use bitcoin::{Amount, FeeRate, OutPoint, Sequence, Transaction};
use bitcoin::hashes::Hash; use bitcoin::hashes::Hash;
use bitcoin::locktime::absolute::LockTime;
use bitcoin::secp256k1::{rand, KeyPair, PublicKey}; use bitcoin::secp256k1::{rand, KeyPair, PublicKey};
use bitcoin::sighash::TapSighash; use bitcoin::sighash::TapSighash;
@@ -151,6 +152,8 @@ pub async fn run_round_scheduler(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let cfg = &app.config; let cfg = &app.config;
info!("Onchain balance: {}", app.sync_onchain_wallet().await?);
//TODO(stevenroose) somehow get these from a fee estimator service //TODO(stevenroose) somehow get these from a fee estimator service
let offboard_feerate = FeeRate::from_sat_per_vb(10).unwrap(); let offboard_feerate = FeeRate::from_sat_per_vb(10).unwrap();
let round_tx_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 }); 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 // 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::<VtxoId, Vtxo>::new(); let mut all_inputs = HashMap::<VtxoId, Vtxo>::new();
let mut all_outputs = Vec::<VtxoRequest>::new(); let mut all_outputs = Vec::<VtxoRequest>::new();
let mut all_offboards = Vec::<OffboardRequest>::new(); let mut all_offboards = Vec::<OffboardRequest>::new();
@@ -276,8 +281,8 @@ pub async fn run_round_scheduler(
// * meaning from the root tx down to the leaves. // * meaning from the root tx down to the leaves.
// **************************************************************** // ****************************************************************
let tip = bdk_bitcoind_rpc::bitcoincore_rpc::RpcApi::get_block_count(&app.bitcoind)?; let tip = bdk_bitcoind_rpc::bitcoincore_rpc::RpcApi::get_block_count(&app.bitcoind)? as u32;
let expiry = tip as u32 + cfg.vtxo_expiry_delta as u32; let expiry = tip+ cfg.vtxo_expiry_delta as u32;
debug!("Current tip is {}, so round vtxos will expire at {}", tip, expiry); debug!("Current tip is {}, so round vtxos will expire at {}", tip, expiry);
let cosign_agg_pk = musig::combine_keys(cosigners.iter().copied()); let cosign_agg_pk = musig::combine_keys(cosigners.iter().copied());
@@ -297,18 +302,33 @@ pub async fn run_round_scheduler(
); );
// Build round tx. // 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::<Amount>(),
);
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 //TODO(stevenroose) think about if we can release lock sooner
let mut wallet = app.wallet.lock().await; let mut wallet = app.wallet.lock().await;
let mut round_tx_psbt = { let mut round_tx_psbt = {
let mut b = wallet.build_tx(); let mut b = wallet.build_tx();
b.ordering(bdk::wallet::tx_builder::TxOrdering::Untouched); 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(vtxos_spec.cosign_spk(), vtxos_spec.total_required_value().to_sat());
b.add_recipient(connector_output.script_pubkey, connector_output.value); b.add_recipient(connector_output.script_pubkey, connector_output.value);
for offb in &all_offboards { for offb in &all_offboards {
b.add_recipient(offb.script_pubkey.clone(), offb.amount.to_sat()); b.add_recipient(offb.script_pubkey.clone(), offb.amount.to_sat());
} }
b.fee_rate(round_tx_feerate.to_bdk()); 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 round_tx = round_tx_psbt.clone().extract_tx();
let vtxos_utxo = OutPoint::new(round_tx.txid(), 0); let vtxos_utxo = OutPoint::new(round_tx.txid(), 0);
@@ -541,6 +561,7 @@ pub async fn run_round_scheduler(
// **************************************************************** // ****************************************************************
// Sign the on-chain tx. // 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())?; let finalized = wallet.sign(&mut round_tx_psbt, bdk::SignOptions::default())?;
assert!(finalized); assert!(finalized);
let round_tx = round_tx_psbt.extract_tx(); let round_tx = round_tx_psbt.extract_tx();

View File

@@ -182,6 +182,14 @@ pub struct VtxoSignaturesRequest {
} }
#[allow(clippy::derive_partial_eq_without_eq)] #[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)] #[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 {} pub struct Empty {}
/// Generated server implementations. /// Generated server implementations.
pub mod ark_service_server { 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. /// Generated trait containing gRPC methods that should be implemented for use with AdminServiceServer.
#[async_trait] #[async_trait]
pub trait AdminService: Send + Sync + 'static { pub trait AdminService: Send + Sync + 'static {
async fn wallet_status(
&self,
request: tonic::Request<super::Empty>,
) -> std::result::Result<
tonic::Response<super::WalletStatusResponse>,
tonic::Status,
>;
async fn stop( async fn stop(
&self, &self,
request: tonic::Request<super::Empty>, request: tonic::Request<super::Empty>,
@@ -817,11 +832,55 @@ pub mod admin_service_server {
fn call(&mut self, req: http::Request<B>) -> Self::Future { fn call(&mut self, req: http::Request<B>) -> Self::Future {
let inner = self.inner.clone(); let inner = self.inner.clone();
match req.uri().path() { match req.uri().path() {
"/arkd.AdminService/stop" => { "/arkd.AdminService/WalletStatus" => {
#[allow(non_camel_case_types)] #[allow(non_camel_case_types)]
struct stopSvc<T: AdminService>(pub Arc<T>); struct WalletStatusSvc<T: AdminService>(pub Arc<T>);
impl<T: AdminService> tonic::server::UnaryService<super::Empty> impl<T: AdminService> tonic::server::UnaryService<super::Empty>
for stopSvc<T> { for WalletStatusSvc<T> {
type Response = super::WalletStatusResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<super::Empty>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as AdminService>::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<T: AdminService>(pub Arc<T>);
impl<T: AdminService> tonic::server::UnaryService<super::Empty>
for StopSvc<T> {
type Response = super::Empty; type Response = super::Empty;
type Future = BoxFuture< type Future = BoxFuture<
tonic::Response<Self::Response>, tonic::Response<Self::Response>,
@@ -845,7 +904,7 @@ pub mod admin_service_server {
let inner = self.inner.clone(); let inner = self.inner.clone();
let fut = async move { let fut = async move {
let inner = inner.0; let inner = inner.0;
let method = stopSvc(inner); let method = StopSvc(inner);
let codec = tonic::codec::ProstCodec::default(); let codec = tonic::codec::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec) let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config( .apply_compression_config(

View File

@@ -2,3 +2,4 @@
mod arkd; mod arkd;
pub use self::arkd::*; pub use self::arkd::*;
pub use self::arkd::ark_service_server::{ArkService, ArkServiceServer}; pub use self::arkd::ark_service_server::{ArkService, ArkServiceServer};
pub use self::arkd::admin_service_server::{AdminService, AdminServiceServer};

View File

@@ -50,7 +50,7 @@ impl rpc::ArkService for Arc<App> {
) -> Result<tonic::Response<rpc::ArkInfo>, tonic::Status> { ) -> Result<tonic::Response<rpc::ArkInfo>, tonic::Status> {
let ret = rpc::ArkInfo { let ret = rpc::ArkInfo {
pubkey: self.master_key.public_key().serialize().to_vec(), 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, nb_round_nonces: self.config.nb_round_nonces as u32,
vtxo_exit_delta: self.config.vtxo_exit_delta as u32, vtxo_exit_delta: self.config.vtxo_exit_delta as u32,
vtxo_expiry_delta: self.config.vtxo_expiry_delta as u32, vtxo_expiry_delta: self.config.vtxo_expiry_delta as u32,
@@ -261,13 +261,37 @@ impl rpc::ArkService for Arc<App> {
} }
} }
#[tonic::async_trait]
impl rpc::AdminService for Arc<App> {
async fn wallet_status(
&self,
_req: tonic::Request<rpc::Empty>,
) -> Result<tonic::Response<rpc::WalletStatusResponse>, 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<rpc::Empty>,
) -> Result<tonic::Response<rpc::Empty>, tonic::Status> {
//TODO(stevenroose) implement graceful shutdown
std::process::exit(0);
}
}
/// Run the public gRPC endpoint. /// Run the public gRPC endpoint.
pub async fn run_public_rpc_server(app: Arc<App>) -> anyhow::Result<()> { pub async fn run_public_rpc_server(app: Arc<App>) -> anyhow::Result<()> {
let addr = app.config.public_rpc_address; let addr = app.config.public_rpc_address;
info!("Starting gRPC service on address {}", addr); 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() 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) .serve(addr)
.await?; .await?;
Ok(()) Ok(())

View File

@@ -8,7 +8,7 @@ use bitcoin::{secp256k1, sighash, taproot, Amount, OutPoint, Witness};
use ark::{Vtxo, VtxoSpec}; use ark::{Vtxo, VtxoSpec};
use crate::{SECP, Wallet}; use crate::{SECP, Wallet};
use crate::psbt::PsbtInputExt; use crate::psbtext::PsbtInputExt;
@@ -211,10 +211,9 @@ impl Wallet {
.control_block(&(exit_script.clone(), lver)) .control_block(&(exit_script.clone(), lver))
.expect("script is in taproot"); .expect("script is in taproot");
let mut wit = Witness::new(); let wit = Witness::from_slice(
wit.push(&sig[..]); &[&sig[..], exit_script.as_bytes(), &cb.serialize()],
wit.push(exit_script.as_bytes()); );
wit.push(cb.serialize());
debug_assert_eq!(wit.serialized_len(), claim.satisfaction_weight()); debug_assert_eq!(wit.serialized_len(), claim.satisfaction_weight());
input.final_script_witness = Some(wit); input.final_script_witness = Some(wit);
} }

View File

@@ -6,7 +6,7 @@
mod database; mod database;
mod exit; mod exit;
mod onchain; mod onchain;
mod psbt; mod psbtext;
use std::{env, fs, iter}; use std::{env, fs, iter};
@@ -116,7 +116,7 @@ impl Wallet {
.await.context("failed to connect to asp")?; .await.context("failed to connect to asp")?;
let ark_info = { 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(); .await.context("ark info request failed")?.into_inner();
ArkInfo { ArkInfo {
asp_pubkey: PublicKey::from_slice(&res.pubkey).context("asp pubkey")?, asp_pubkey: PublicKey::from_slice(&res.pubkey).context("asp pubkey")?,

View File

@@ -1,5 +1,6 @@
use std::iter;
use std::path::Path; use std::path::Path;
use anyhow::Context; use anyhow::Context;
@@ -12,7 +13,7 @@ use bitcoin::{
use bitcoin::psbt::PartiallySignedTransaction as Psbt; //TODO(stevenroose) when v0.31 use bitcoin::psbt::PartiallySignedTransaction as Psbt; //TODO(stevenroose) when v0.31
use crate::exit; use crate::exit;
use crate::psbt::PsbtInputExt; use crate::psbtext::PsbtInputExt;
const DB_MAGIC: &str = "onchain_bdk"; const DB_MAGIC: &str = "onchain_bdk";
@@ -162,7 +163,8 @@ impl Wallet {
version: 2, version: 2,
lock_time: bitcoin::absolute::LockTime::ZERO, lock_time: bitcoin::absolute::LockTime::ZERO,
input: vec![], 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() ..Default::default()
}; };

View File

@@ -7,9 +7,13 @@ use bitcoin::psbt;
use crate::exit; use crate::exit;
lazy_static::lazy_static! { const PROP_KEY_PREFIX: &'static [u8] = "ark_noah".as_bytes();
static ref 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 { static ref PROP_KEY_CLAIM_INPUT: psbt::raw::ProprietaryKey = psbt::raw::ProprietaryKey {
prefix: PROP_KEY_PREFIX.to_vec(), prefix: PROP_KEY_PREFIX.to_vec(),
subtype: PropKey::ClaimInput as u8, subtype: PropKey::ClaimInput as u8,
@@ -17,10 +21,9 @@ lazy_static::lazy_static! {
}; };
} }
enum PropKey { //TODO(stevenroose) the "corrupt psbt" expects are only safe if all psbts stay
ClaimInput = 1, // within internal use, if we ever share them for communication or in a db,
} // they need to return errors
pub trait PsbtInputExt: BorrowMut<psbt::Input> { pub trait PsbtInputExt: BorrowMut<psbt::Input> {
fn set_claim_input(&mut self, input: &exit::ClaimInput) { fn set_claim_input(&mut self, input: &exit::ClaimInput) {
self.borrow_mut().proprietary.insert(PROP_KEY_CLAIM_INPUT.clone(), input.encode()); self.borrow_mut().proprietary.insert(PROP_KEY_CLAIM_INPUT.clone(), input.encode());

View File

@@ -58,7 +58,11 @@ impl<T: serde::de::DeserializeOwned + serde::Serialize + Eq + Hash + Clone> Buck
// naively extrapolate the set's size // naively extrapolate the set's size
let old_len = setbuf.map(|b| b.len()).unwrap_or_default(); 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); let mut buf = Vec::with_capacity(old_len + item_len);
ciborium::into_writer(&set, &mut buf).expect("bufs don't error"); ciborium::into_writer(&set, &mut buf).expect("bufs don't error");
Some(buf) Some(buf)