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::transaction;
6use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, Witness, consensus::serialize};
7use serde_json::json;
8use std::num::NonZeroU64;
9use urma_identity::identity::IdentitySigner;
10use urma_wallet::wallet::{FeeBudget, FeeRate, SpendRequest};
11
12pub struct TransferRequest {
13    pub destination: ScriptBuf,
14    pub amount: u64,
15    pub rate: NonZeroU64,
16    pub max_fee: u64,
17}
18
19pub struct Transfer {
20    pub transaction: Transaction,
21    pub fee: u64,
22    pub change: u64,
23}
24
25fn output(value: u64, script: &ScriptBuf) -> TxOut {
26    TxOut {
27        value: Amount::from_sat(value),
28        script_pubkey: script.clone(),
29    }
30}
31
32fn quote(request: &TransferRequest, own: &ScriptBuf, retained: u64) -> Result<u64, Error> {
33    let mut draft = transaction(OutPoint::null(), output(request.amount, &request.destination));
34    draft.output.push(output(retained, own));
35    draft.input[0].witness = Witness::from_slice(&[vec![0; 73], vec![0; 33]]);
36    let fee = u64::try_from(draft.vsize())?
37        .checked_mul(request.rate.get())
38        .context("transfer fee overflow")?;
39    ensure!(
40        fee <= request.max_fee,
41        "transfer fee {fee} exceeds the approved maximum {}",
42        request.max_fee
43    );
44    Ok(fee)
45}
46
47pub fn prepare(
48    node: &Node,
49    signer: &impl IdentitySigner,
50    request: &TransferRequest,
51) -> Result<Transfer, Error> {
52    let retained = config::publication_return(node.chain());
53    ensure!(
54        request.amount >= retained,
55        "amount below the dust-safe minimum of {retained} base units"
56    );
57    ensure!(
58        !request.destination.is_op_return(),
59        "destination cannot be a data output"
60    );
61    let own = urma_wallet::signing::script(signer)?;
62    let fee = quote(request, &own, retained)?;
63    let spent = request
64        .amount
65        .checked_add(fee)
66        .context("transfer amount overflow")?;
67    let minimum = spent.checked_add(retained).context("transfer amount overflow")?;
68    let funding = node.select_funding(signer, minimum)?;
69    let (outpoint, previous) = funding.prevout()?;
70    let change = previous
71        .value
72        .to_sat()
73        .checked_sub(spent)
74        .context("insufficient funding")?;
75    let mut unsigned = transaction(outpoint, output(request.amount, &request.destination));
76    unsigned.output.push(output(change, &own));
77    let signed = urma_wallet::signing::sign(
78        signer,
79        &SpendRequest {
80            transaction: &unsigned,
81            prevouts: std::slice::from_ref(&previous),
82            budget: FeeBudget {
83                chain: node.chain().genesis()?,
84                rate: FeeRate(request.rate),
85                maximum_base_units: request.max_fee,
86            },
87        },
88    )?;
89    Ok(Transfer {
90        transaction: signed,
91        fee,
92        change,
93    })
94}
95
96pub fn submit(node: &Node, transfer: &Transfer) -> Result<Txid, Error> {
97    let raw = hex::encode(serialize(&transfer.transaction));
98    match test_accept(node, &raw)? {
99        MempoolCheck::Allowed => {}
100        MempoolCheck::Rejected(reason) => {
101            bail!("node policy rejected the transfer: {reason}; nothing was broadcast")
102        }
103        MempoolCheck::Unavailable(reason) => {
104            tracing::warn!(%reason, "transfer broadcast without mempool preflight");
105        }
106    }
107    let txid = transfer.transaction.compute_txid();
108    let answer = node.call("sendrawtransaction", &[json!(raw)])?;
109    ensure!(
110        answer == json!(txid),
111        "broadcast returned an unexpected TXID"
112    );
113    Ok(txid)
114}