Skip to main content

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}