Skip to main content

urma_runtime/
transfer.rs

1use crate::config;
2use crate::error::{Context, Error, bail, ensure};
3use crate::node::Node;
4use crate::publish::{MempoolCheck, test_accept};
5use crate::transaction::{signed_vsize, spending};
6use crate::wallet::{FundingInput, Selection};
7use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, consensus::serialize};
8use serde_json::json;
9use std::num::NonZeroU64;
10use urma_wallet::{
11    account::{Account, Branch, KeyPath},
12    wallet::{FeeBudget, FeeRate, SpendRequest},
13};
14
15pub struct TransferRequest {
16    pub destination: ScriptBuf,
17    pub amount: u64,
18    pub rate: NonZeroU64,
19    pub max_fee: u64,
20}
21
22pub struct Change {
23    pub value: u64,
24    pub path: KeyPath,
25}
26
27pub struct Transfer {
28    pub transaction: Transaction,
29    pub fee: u64,
30    pub change: Option<Change>,
31    pub inputs: Vec<FundingInput>,
32}
33
34fn output(value: u64, script: &ScriptBuf) -> TxOut {
35    TxOut {
36        value: Amount::from_sat(value),
37        script_pubkey: script.clone(),
38    }
39}
40
41fn quote(
42    request: &TransferRequest,
43    inputs: usize,
44    change: Option<&ScriptBuf>,
45) -> Result<u64, Error> {
46    let mut draft = spending(
47        &vec![OutPoint::null(); inputs],
48        output(request.amount, &request.destination),
49    );
50    for script in change.iter() {
51        draft.output.push(output(0, script));
52    }
53    signed_vsize(&draft)?
54        .checked_mul(request.rate.get())
55        .context("transfer fee overflow")
56}
57
58fn settle(
59    request: &TransferRequest,
60    account: &Account,
61    selection: &Selection,
62    retained: u64,
63) -> Result<(u64, Option<Change>), Error> {
64    let inputs = selection.inputs.len();
65    let rest = selection
66        .total
67        .checked_sub(request.amount)
68        .context("insufficient funding")?;
69    let path = KeyPath::new(Branch::Internal, selection.next_change);
70    let fee = quote(request, inputs, Some(&account.script(path)?))?;
71    if rest >= fee.checked_add(retained).context("transfer fee overflow")? {
72        let value = rest - fee;
73        return Ok((fee, Some(Change { value, path })));
74    }
75    ensure!(
76        rest >= quote(request, inputs, None)?,
77        "insufficient funding"
78    );
79    Ok((rest, None))
80}
81
82pub fn prepare(
83    node: &Node,
84    account: &Account,
85    request: &TransferRequest,
86) -> Result<Transfer, Error> {
87    let retained = config::publication_return(node.chain());
88    ensure!(
89        request.amount >= retained,
90        "amount below the dust-safe minimum of {retained} base units"
91    );
92    ensure!(
93        !request.destination.is_op_return(),
94        "destination cannot be a data output"
95    );
96    let single = quote(request, 1, None)?;
97    let per_input = quote(request, 2, None)?
98        .checked_sub(single)
99        .context("transfer fee underflow")?;
100    let minimum = request
101        .amount
102        .checked_add(single)
103        .context("transfer amount overflow")?;
104    let selection = node.select_coins(account, minimum, per_input)?;
105    let (fee, change) = settle(request, account, &selection, retained)?;
106    ensure!(
107        fee <= request.max_fee,
108        "transfer fee {fee} exceeds the approved maximum {}",
109        request.max_fee
110    );
111    let mut outpoints = Vec::new();
112    let mut prevouts = Vec::new();
113    let mut paths = Vec::new();
114    for input in &selection.inputs {
115        let (outpoint, previous) = input.funding.prevout()?;
116        outpoints.push(outpoint);
117        prevouts.push(previous);
118        paths.push(input.path);
119    }
120    let mut unsigned = spending(&outpoints, output(request.amount, &request.destination));
121    for change in change.iter() {
122        unsigned
123            .output
124            .push(output(change.value, &account.script(change.path)?));
125    }
126    let signed = account.sign(
127        &SpendRequest {
128            transaction: &unsigned,
129            prevouts: &prevouts,
130            budget: FeeBudget {
131                chain: node.chain().genesis()?,
132                rate: FeeRate(request.rate),
133                maximum_base_units: request.max_fee,
134            },
135        },
136        &paths,
137    )?;
138    Ok(Transfer {
139        transaction: signed,
140        fee,
141        change,
142        inputs: selection.inputs,
143    })
144}
145
146pub fn submit(node: &Node, transfer: &Transfer) -> Result<Txid, Error> {
147    let raw = hex::encode(serialize(&transfer.transaction));
148    match test_accept(node, &raw)? {
149        MempoolCheck::Allowed => {}
150        MempoolCheck::Rejected(reason) => {
151            bail!("node policy rejected the transfer: {reason}; nothing was broadcast")
152        }
153        MempoolCheck::Unavailable(reason) => {
154            tracing::warn!(%reason, "transfer broadcast without mempool preflight");
155        }
156    }
157    let txid = transfer.transaction.compute_txid();
158    let answer = node.call("sendrawtransaction", &[json!(raw)])?;
159    ensure!(
160        answer == json!(txid),
161        "broadcast returned an unexpected TXID"
162    );
163    Ok(txid)
164}