webledgers_teller/payout.rs
1//! Payouts: the deposits are the coins, and each input is signed with its
2//! own derived secret (`teller.mjs planPayout`, `unsignedTx`, `signPayout`,
3//! `vsizeOf`, at `7c00cea`).
4//!
5//! A plan picks coins largest first until they pay the amount and the fee at
6//! the rate; change of at least [`DUST`] goes back to `change_script` (a
7//! deposit address of the operator's own account), less is left to the fee
8//! with one output. Signing computes each input's key-path sighash under the
9//! chain's rules, signs it with `normalize(operator) + that coin's tweak`,
10//! checks every input under the same rules, and checks the fee against the
11//! signed transaction's real size: nothing is returned that would not be
12//! paid.
13//!
14//! The fee is estimated at [`vsize_estimate`]: 11 vB of transaction, 43 vB
15//! per taproot output and 58 vB per key-path input. An input is 41 vB plus a
16//! 66-byte witness (a 65-byte signature with its hash type, and its length)
17//! at a quarter: 57.5 vB on paper, 58 once rounded with the rest. The
18//! teller's first estimate was one short, and a payout was refused as
19//! "min relay fee not met, 154 < 155" (`7c00cea`).
20//!
21//! The teller loads the chain's engine to learn its sighash; this crate
22//! takes the rules as a parameter. For txbt4 (a BLAKE2b parent) they are
23//! [`TXBT4_RULES`]: Knots' unified sighash, hash type `0x21`, 65-byte
24//! witnesses. Under [`SighashRules::Bip341`] a payout also signs and checks,
25//! for a stock-family chain, but its witnesses are not txbt4's.
26//!
27//! ```
28//! use bitcoin::secp256k1::SecretKey;
29//! use webledgers_teller::{deposit_address, plan_payout, pubkey_hex, sign_payout, vsize_estimate, Coin, PayoutParams, DEFAULT_HRP, TXBT4_RULES};
30//!
31//! let operator = SecretKey::from_slice(&[0x11; 32]).unwrap();
32//! let op_did = format!("did:nostr:{}", pubkey_hex(&operator));
33//! let ledger = "cd".repeat(32);
34//! let alice = format!("did:nostr:{}", "aa".repeat(32));
35//! let d = deposit_address(&op_did, &ledger, &alice, 0, DEFAULT_HRP).unwrap();
36//! let change = deposit_address(&op_did, &ledger, &op_did, 0, DEFAULT_HRP).unwrap();
37//! let coins = [Coin::from_deposit(&d, &"11".repeat(32), 0, 40_000)];
38//!
39//! let to = format!("5120{}", "33".repeat(32));
40//! let plan = plan_payout(&PayoutParams { coins: &coins, amount: 25_000, rate: 1, to_script: &to, change_script: &change.script }).unwrap();
41//! assert_eq!(plan.fee, vsize_estimate(1, 2));
42//! assert_eq!(plan.change, 40_000 - 25_000 - plan.fee);
43//!
44//! let paid = sign_payout(&plan, &operator, TXBT4_RULES).unwrap();
45//! assert_eq!(paid.tx.input[0].witness.nth(0).unwrap().last(), Some(&0x21));
46//! assert!(plan.fee >= paid.vsize);
47//! ```
48
49use core::str::FromStr;
50
51use bitcoin::absolute::LockTime;
52use bitcoin::consensus::encode::serialize_hex;
53use bitcoin::secp256k1::{Keypair, Message, SecretKey};
54use bitcoin::transaction::Version;
55use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness};
56use serde::{Deserialize, Serialize};
57use sidestr_core::block::secp;
58use sidestr_core::sighash::{key_path_sighash, verify_taproot_key_path, SighashRules};
59
60use crate::account::check_sats;
61use crate::deposit::{deposit_secret, DepositAddress};
62use crate::error::{Error, Result};
63use crate::{DUST, MIN_PAY, TXBT4_RULES};
64
65/// The highest fee rate a plan accepts, in sat/vB.
66pub const MAX_RATE: u64 = 1000;
67
68/// A deposit the operator holds, as a coin to spend: the outpoint, its
69/// value, its output script and the tweak its secret needs. With serde the
70/// field names are the teller's; the other fields of a JavaScript coin (the
71/// rest of its deposit address) are ignored when read.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Coin {
74 /// The funding transaction, 64 hex in the usual (display) order.
75 pub txid: String,
76 /// The output's index.
77 pub vout: u32,
78 /// Satoshis.
79 pub value: u64,
80 /// The output script, hex (`5120` + x).
81 pub script: String,
82 /// The deposit's tweak, 64 hex.
83 pub tweak: String,
84}
85
86impl Coin {
87 /// The coin a deposit address received: `{ ...depositAddress, txid, vout, value }`.
88 pub fn from_deposit(d: &DepositAddress, txid: &str, vout: u32, value: u64) -> Self {
89 Self {
90 txid: txid.to_owned(),
91 vout,
92 value,
93 script: d.script.clone(),
94 tweak: d.tweak.clone(),
95 }
96 }
97}
98
99/// One output of a plan.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct PlanOutput {
102 /// Satoshis.
103 pub value: u64,
104 /// The output script, hex.
105 #[serde(rename = "scriptPubKey")]
106 pub script_pubkey: String,
107}
108
109/// A payout planned (`planPayout`'s result).
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct Plan {
112 /// The coins spent, in input order (largest first).
113 pub picked: Vec<Coin>,
114 /// The amount to its destination, then the change if there is any.
115 pub outputs: Vec<PlanOutput>,
116 /// Satoshis left to the fee.
117 pub fee: u64,
118 /// Satoshis of change, 0 when there is no change output.
119 pub change: u64,
120 /// The fee rate, sat/vB.
121 pub rate: u64,
122}
123
124/// What [`plan_payout`] is given.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub struct PayoutParams<'a> {
127 /// The coins held.
128 pub coins: &'a [Coin],
129 /// Satoshis to pay, at least [`MIN_PAY`].
130 pub amount: u64,
131 /// Fee rate in whole sat/vB, 1 to [`MAX_RATE`] (the teller pays at 1).
132 pub rate: u64,
133 /// The destination's output script, hex.
134 pub to_script: &'a str,
135 /// Where change goes, hex: a deposit address of the operator's own account.
136 pub change_script: &'a str,
137}
138
139/// The estimated virtual size of a payout with `n_in` key-path inputs and
140/// `n_out` taproot outputs: `11 + 58·n_in + 43·n_out` vB.
141pub fn vsize_estimate(n_in: u64, n_out: u64) -> u64 {
142 11 + 58 * n_in + 43 * n_out
143}
144
145/// `planPayout`: coins largest first until they pay `amount` and the fee at
146/// `rate`; change of at least [`DUST`] back to `change_script`, less folded
147/// into the fee with a single output. Refuses an amount below [`MIN_PAY`], a
148/// rate outside 1 to [`MAX_RATE`], and coins that cannot cover the amount
149/// and the fee.
150pub fn plan_payout(p: &PayoutParams<'_>) -> Result<Plan> {
151 let v = check_sats(p.amount)?;
152 if v < MIN_PAY {
153 return Err(Error::PayoutTooSmall);
154 }
155 if !(1..=MAX_RATE).contains(&p.rate) {
156 return Err(Error::Rate);
157 }
158 let mut sorted: Vec<&Coin> = p.coins.iter().collect();
159 sorted.sort_by_key(|c| core::cmp::Reverse(c.value)); // stable, as Array.prototype.sort is
160 let mut picked: Vec<Coin> = Vec::new();
161 let mut in_sum = 0u64;
162 for c in sorted {
163 picked.push(c.clone());
164 in_sum = in_sum.saturating_add(c.value);
165 let n = picked.len() as u64;
166 let fee = p.rate * vsize_estimate(n, 2);
167 if in_sum >= v + fee {
168 let change = in_sum - v - fee;
169 if change >= DUST {
170 return Ok(Plan {
171 picked,
172 outputs: vec![
173 PlanOutput {
174 value: v,
175 script_pubkey: p.to_script.to_owned(),
176 },
177 PlanOutput {
178 value: change,
179 script_pubkey: p.change_script.to_owned(),
180 },
181 ],
182 fee,
183 change,
184 rate: p.rate,
185 });
186 }
187 let fee1 = p.rate * vsize_estimate(n, 1);
188 if in_sum >= v + fee1 {
189 return Ok(Plan {
190 picked,
191 outputs: vec![PlanOutput {
192 value: v,
193 script_pubkey: p.to_script.to_owned(),
194 }],
195 fee: in_sum - v,
196 change: 0,
197 rate: p.rate,
198 });
199 }
200 }
201 }
202 Err(Error::NotCovered {
203 held: in_sum,
204 amount: v,
205 })
206}
207
208fn script(what: &'static str, hex: &str) -> Result<ScriptBuf> {
209 ScriptBuf::from_hex(hex).map_err(|e| Error::Encoding {
210 what,
211 reason: e.to_string(),
212 })
213}
214
215/// The prevouts a plan's inputs spend, in input order.
216pub fn prevouts(plan: &Plan) -> Result<Vec<TxOut>> {
217 plan.picked
218 .iter()
219 .map(|c| {
220 Ok(TxOut {
221 value: Amount::from_sat(c.value),
222 script_pubkey: script("coin script", &c.script)?,
223 })
224 })
225 .collect()
226}
227
228/// `unsignedTx`: version 2, one input per picked coin (empty `scriptSig`,
229/// sequence `0xfffffffd`), the plan's outputs, lock time 0, no witnesses.
230pub fn unsigned_tx(plan: &Plan) -> Result<Transaction> {
231 let input = plan
232 .picked
233 .iter()
234 .map(|c| {
235 Ok(TxIn {
236 previous_output: OutPoint {
237 txid: Txid::from_str(&c.txid).map_err(|e| Error::Encoding {
238 what: "coin txid",
239 reason: e.to_string(),
240 })?,
241 vout: c.vout,
242 },
243 script_sig: ScriptBuf::new(),
244 sequence: Sequence(0xffff_fffd),
245 witness: Witness::new(),
246 })
247 })
248 .collect::<Result<_>>()?;
249 let output = plan
250 .outputs
251 .iter()
252 .map(|o| {
253 Ok(TxOut {
254 value: Amount::from_sat(o.value),
255 script_pubkey: script("output script", &o.script_pubkey)?,
256 })
257 })
258 .collect::<Result<_>>()?;
259 Ok(Transaction {
260 version: Version::TWO,
261 lock_time: LockTime::ZERO,
262 input,
263 output,
264 })
265}
266
267/// `vsizeOf`: the transaction's virtual size, `(3 × stripped + full) / 4`
268/// rounded up.
269pub fn vsize_of(tx: &Transaction) -> u64 {
270 tx.vsize() as u64
271}
272
273/// A payout signed and checked (`signPayout`'s result).
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct SignedPayout {
276 /// The transaction.
277 pub tx: Transaction,
278 /// Its consensus serialisation, hex: what is broadcast.
279 pub hex: String,
280 /// Its txid.
281 pub txid: String,
282 /// Its virtual size.
283 pub vsize: u64,
284}
285
286/// `signPayout`: every input signed with its deposit's own secret
287/// ([`deposit_secret`] of `operator_secret` and the coin's tweak) over its
288/// key-path sighash under `rules` (BIP 340, zero auxiliary randomness), then
289/// every input checked under the same rules ([`Error::ScriptCheck`]) and the
290/// fee checked against the signed size at the plan's rate
291/// ([`Error::FeeBelowRate`]). Either check failing means nothing is paid.
292pub fn sign_payout(
293 plan: &Plan,
294 operator_secret: &SecretKey,
295 rules: SighashRules,
296) -> Result<SignedPayout> {
297 let mut tx = unsigned_tx(plan)?;
298 let prevouts = prevouts(plan)?;
299 let mut witnesses = Vec::with_capacity(tx.input.len());
300 for (i, coin) in plan.picked.iter().enumerate() {
301 let (m, ht) = key_path_sighash(&tx, i, &prevouts, rules)
302 .map_err(|reason| Error::Sighash { input: i, reason })?;
303 let d = deposit_secret(operator_secret, &coin.tweak)?;
304 let kp = Keypair::from_secret_key(secp(), &d);
305 let sig = secp().sign_schnorr_with_aux_rand(&Message::from_digest(m), &kp, &[0u8; 32]);
306 let mut w = sig.as_ref().to_vec();
307 w.push(ht);
308 witnesses.push(Witness::from_slice(&[w]));
309 }
310 for (input, w) in tx.input.iter_mut().zip(witnesses) {
311 input.witness = w;
312 }
313 for i in 0..tx.input.len() {
314 verify_taproot_key_path(&tx, i, &prevouts, rules).map_err(|reason| Error::ScriptCheck {
315 input: i,
316 reason: reason.to_owned(),
317 })?;
318 }
319 let vsize = vsize_of(&tx);
320 if plan.fee < plan.rate * vsize {
321 return Err(Error::FeeBelowRate {
322 vsize,
323 fee: plan.fee,
324 rate: plan.rate,
325 });
326 }
327 Ok(SignedPayout {
328 hex: serialize_hex(&tx),
329 txid: tx.compute_txid().to_string(),
330 tx,
331 vsize,
332 })
333}
334
335/// [`sign_payout`] under [`TXBT4_RULES`], the teller's chain.
336pub fn sign_payout_txbt4(plan: &Plan, operator_secret: &SecretKey) -> Result<SignedPayout> {
337 sign_payout(plan, operator_secret, TXBT4_RULES)
338}