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}