mirror of
https://github.com/aljazceru/clArk.git
synced 2025-12-17 13:14:20 +01:00
Implement ASP spending of expired VTXO UTXOs
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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<KeyPair>,
|
||||
|
||||
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::<u64>();
|
||||
assert_eq!(size, ConnectorChain::total_vsize(100));
|
||||
chain.iter_unsigned_txs().for_each(|t| assert_eq!(t.output[1].value, fee::DUST.to_sat()));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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::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.
|
||||
pub fn delayed_sign(delay_blocks: u16, pubkey: XOnlyPublicKey) -> ScriptBuf {
|
||||
let csv = bitcoin::Sequence::from_height(delay_blocks);
|
||||
|
||||
@@ -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<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(
|
||||
&mut self,
|
||||
request: impl tonic::IntoRequest<super::Empty>,
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ mod arkd;
|
||||
|
||||
pub use arkd::*;
|
||||
pub use arkd::ark_service_client::ArkServiceClient;
|
||||
pub use arkd::admin_service_client::AdminServiceClient;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<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>> {
|
||||
Ok(self.db.get(FRESH_ROUND_IDS)?.map(|b| {
|
||||
ciborium::from_reader(&b[..]).expect("corrupt db")
|
||||
|
||||
128
arkd/src/lib.rs
128
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<bdk::Wallet<bdk_file_store::Store<'static, bdk::wallet::ChangeSet>>>,
|
||||
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<Self>) -> anyhow::Result<Address> {
|
||||
pub async fn onchain_address(&self) -> anyhow::Result<Address> {
|
||||
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<Self>) -> anyhow::Result<Amount> {
|
||||
pub async fn sync_onchain_wallet(&self) -> anyhow::Result<Amount> {
|
||||
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<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);
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,49 @@
|
||||
#[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 <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]
|
||||
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)
|
||||
@@ -16,7 +53,7 @@ async fn main() {
|
||||
|
||||
let cfg = Config {
|
||||
network: bitcoin::Network::Regtest,
|
||||
public_rpc_address: "[::1]:35035".parse().unwrap(),
|
||||
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),
|
||||
@@ -35,3 +72,31 @@ async fn main() {
|
||||
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::Debug)
|
||||
.filter_level(log::LevelFilter::Trace)
|
||||
.init();
|
||||
|
||||
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")?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
49
arkd/src/psbtext.rs
Normal file
49
arkd/src/psbtext.rs
Normal 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 {}
|
||||
@@ -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::<VtxoId, Vtxo>::new();
|
||||
let mut all_outputs = Vec::<VtxoRequest>::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.
|
||||
// ****************************************************************
|
||||
|
||||
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::<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
|
||||
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();
|
||||
|
||||
@@ -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<super::Empty>,
|
||||
) -> std::result::Result<
|
||||
tonic::Response<super::WalletStatusResponse>,
|
||||
tonic::Status,
|
||||
>;
|
||||
async fn stop(
|
||||
&self,
|
||||
request: tonic::Request<super::Empty>,
|
||||
@@ -817,11 +832,55 @@ pub mod admin_service_server {
|
||||
fn call(&mut self, req: http::Request<B>) -> Self::Future {
|
||||
let inner = self.inner.clone();
|
||||
match req.uri().path() {
|
||||
"/arkd.AdminService/stop" => {
|
||||
"/arkd.AdminService/WalletStatus" => {
|
||||
#[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>
|
||||
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 Future = BoxFuture<
|
||||
tonic::Response<Self::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(
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -50,7 +50,7 @@ impl rpc::ArkService for Arc<App> {
|
||||
) -> Result<tonic::Response<rpc::ArkInfo>, 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<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.
|
||||
pub async fn run_public_rpc_server(app: Arc<App>) -> 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(())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")?,
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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<psbt::Input> {
|
||||
fn set_claim_input(&mut self, input: &exit::ClaimInput) {
|
||||
self.borrow_mut().proprietary.insert(PROP_KEY_CLAIM_INPUT.clone(), input.encode());
|
||||
@@ -58,7 +58,11 @@ impl<T: serde::de::DeserializeOwned + serde::Serialize + Eq + Hash + Clone> 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)
|
||||
|
||||
Reference in New Issue
Block a user