1use crate::config;
2use crate::error::{Context, Error, ensure};
3use crate::node::Node;
4use bitcoin::{Amount, Denomination, OutPoint};
5use serde::{Serialize, Serializer};
6use serde_json::{Value, json};
7use std::{cmp::Reverse, collections::HashMap};
8use urma_wallet::{
9 account::{Account, Branch, KeyPath},
10 funding::Funding,
11};
12
13#[derive(Clone, Debug, Serialize)]
14pub struct Coin {
15 pub outpoint: OutPoint,
16 pub value: u64,
17 pub height: u64,
18 #[serde(serialize_with = "path_text")]
19 pub path: KeyPath,
20}
21
22#[derive(Clone, Debug, Serialize)]
23pub struct WalletView {
24 pub coins: Vec<Coin>,
25 pub next_change: u32,
26 pub reported: usize,
27 pub verified: bool,
28 pub skipped: usize,
29}
30
31pub struct Candidates {
32 coins: Vec<Coin>,
33 reported: usize,
34}
35
36impl Candidates {
37 pub fn new() -> Self {
38 Self {
39 coins: Vec::new(),
40 reported: 0,
41 }
42 }
43
44 pub fn add(&mut self, coin: Coin) {
45 if self.coins.iter().any(|held| held.outpoint == coin.outpoint) {
46 return;
47 }
48 self.reported += 1;
49 let rank = |held: &Coin| (Reverse(held.value), held.outpoint);
50 let position = self.coins.partition_point(|held| rank(held) < rank(&coin));
51 if position < config::WALLET_CHECKS {
52 self.coins.insert(position, coin);
53 self.coins.truncate(config::WALLET_CHECKS);
54 }
55 }
56
57 pub fn retained(&self) -> usize {
58 self.coins.len()
59 }
60
61 pub fn reported(&self) -> usize {
62 self.reported
63 }
64
65 pub fn largest(self) -> Vec<Coin> {
66 self.coins
67 }
68}
69
70impl Default for Candidates {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76#[derive(Clone)]
77pub struct FundingInput {
78 pub funding: Funding,
79 pub path: KeyPath,
80}
81
82pub struct Selection {
83 pub inputs: Vec<FundingInput>,
84 pub total: u64,
85 pub next_change: u32,
86}
87
88#[derive(Clone, Copy, PartialEq, Eq)]
89enum Liveness {
90 Scanned,
91 Remembered,
92}
93
94fn path_text<S: Serializer>(path: &KeyPath, serializer: S) -> Result<S::Ok, S::Error> {
95 serializer.collect_str(&format_args!("{}/{}", path.branch.index(), path.index))
96}
97
98pub(crate) fn coin_amount(value: &Value) -> Result<Amount, Error> {
99 let coins = value.as_f64().context("missing coin amount")?;
100 Ok(Amount::from_float_in(coins, Denomination::Bitcoin)?)
101}
102
103fn covered(total: u64, inputs: usize, minimum: u64, per_input: u64) -> Result<bool, Error> {
104 let needed = per_input
105 .checked_mul(u64::try_from(inputs)?)
106 .and_then(|inputs| inputs.checked_add(minimum))
107 .context("funding target overflow")?;
108 Ok(total >= needed)
109}
110
111impl Node {
112 pub fn wallet(&self, account: &Account) -> Result<WalletView, Error> {
113 self.verify_network()?;
114 ensure!(
115 account.chain().genesis()? == self.chain().genesis()?,
116 "funding account belongs to another chain"
117 );
118 let candidates = match self.is_public() {
119 true => self.routed_coins(account)?,
120 false => self.local_coins(account)?,
121 };
122 let reported = candidates.reported();
123 let mut live = Vec::new();
124 let mut checked = 0;
125 for coin in candidates.largest() {
126 if live.len() == config::WALLET_VERIFY_OUTPUTS {
127 break;
128 }
129 checked += 1;
130 if self.live(account, &coin)? {
131 live.push(coin);
132 }
133 }
134 let skipped = reported - checked;
135 if skipped > 0 {
136 tracing::warn!(
137 reported,
138 skipped,
139 bound = config::WALLET_VERIFY_OUTPUTS,
140 "wallet keeps only its largest coins; smaller ones are skipped unverified"
141 );
142 }
143 let next_change = live
144 .iter()
145 .filter(|coin| coin.path.branch == Branch::Internal)
146 .fold(0, |next, coin| next.max(coin.path.index + 1));
147 Ok(WalletView {
148 coins: live,
149 next_change,
150 reported,
151 verified: true,
152 skipped,
153 })
154 }
155
156 pub fn select_coins(
157 &self,
158 account: &Account,
159 minimum: u64,
160 per_input: u64,
161 ) -> Result<Selection, Error> {
162 let view = self.wallet(account)?;
163 self.chosen(account, view.coins, minimum, per_input, Liveness::Scanned)
164 }
165
166 pub fn select_from(
167 &self,
168 account: &Account,
169 mut coins: Vec<Coin>,
170 minimum: u64,
171 per_input: u64,
172 ) -> Result<Selection, Error> {
173 coins.sort_by_key(|coin| (Reverse(coin.value), coin.outpoint));
174 coins.dedup_by_key(|coin| coin.outpoint);
175 self.chosen(account, coins, minimum, per_input, Liveness::Remembered)
176 }
177
178 fn chosen(
179 &self,
180 account: &Account,
181 coins: Vec<Coin>,
182 minimum: u64,
183 per_input: u64,
184 liveness: Liveness,
185 ) -> Result<Selection, Error> {
186 let next_change = coins
187 .iter()
188 .filter(|coin| coin.path.branch == Branch::Internal)
189 .fold(0, |next, coin| next.max(coin.path.index + 1));
190 let mut chosen = Vec::new();
191 let mut total = 0u64;
192 for coin in coins {
193 if covered(total, chosen.len(), minimum, per_input)? {
194 break;
195 }
196 ensure!(
197 chosen.len() < config::WALLET_MAX_INPUTS,
198 "funding needs more than {} inputs; consolidate the wallet first",
199 config::WALLET_MAX_INPUTS
200 );
201 if liveness == Liveness::Remembered && !self.live(account, &coin)? {
202 tracing::warn!(outpoint = %coin.outpoint, "remembered coin is spent or unconfirmed; skipped");
203 continue;
204 }
205 total = total.checked_add(coin.value).context("funding overflow")?;
206 chosen.push(coin);
207 }
208 if !covered(total, chosen.len(), minimum, per_input)? {
209 return Err(Error::Missing(
210 "confirmed wallet coins do not cover the spend; fund the receive address and confirm first".into(),
211 ));
212 }
213 let mut inputs = Vec::new();
214 for coin in &chosen {
215 inputs.push(FundingInput {
216 funding: self.parent(account, coin)?,
217 path: coin.path,
218 });
219 }
220 Ok(Selection {
221 inputs,
222 total,
223 next_change,
224 })
225 }
226
227 fn local_coins(&self, account: &Account) -> Result<Candidates, Error> {
228 let mut paths = HashMap::new();
229 let mut descriptors = Vec::new();
230 for branch in [Branch::External, Branch::Internal] {
231 for index in 0..config::WALLET_RANGE {
232 let path = KeyPath::new(branch, index);
233 let script = hex::encode(account.script(path)?.as_bytes());
234 descriptors.push(json!(format!("raw({script})")));
235 paths.insert(script, path);
236 }
237 }
238 let scan = self.call("scantxoutset", &[json!("start"), Value::Array(descriptors)])?;
239 ensure!(scan["success"] == true, "UTXO scan did not complete");
240 let rows = scan["unspents"].as_array().context("missing UTXO scan")?;
241 let mut coins = Candidates::new();
242 for row in rows {
243 let script = row["scriptPubKey"]
244 .as_str()
245 .context("missing UTXO script")?;
246 let path = *paths
247 .get(script)
248 .context("UTXO scan returned a foreign script")?;
249 coins.add(Coin {
250 outpoint: OutPoint {
251 txid: row["txid"].as_str().context("missing UTXO txid")?.parse()?,
252 vout: u32::try_from(row["vout"].as_u64().context("missing UTXO vout")?)?,
253 },
254 value: coin_amount(&row["amount"])?.to_sat(),
255 height: row["height"].as_u64().context("missing UTXO height")?,
256 path,
257 });
258 }
259 Ok(coins)
260 }
261
262 fn routed_coins(&self, account: &Account) -> Result<Candidates, Error> {
263 let mut coins = Candidates::new();
264 for branch in [Branch::External, Branch::Internal] {
265 let mut empty = 0;
266 let mut index = 0;
267 while empty < config::WALLET_GAP && index < config::WALLET_RANGE {
268 let found = self.address_coins(account, KeyPath::new(branch, index))?;
269 empty = match found.is_empty() {
270 true => empty + 1,
271 false => 0,
272 };
273 for coin in found {
274 coins.add(coin);
275 }
276 index += 1;
277 }
278 }
279 Ok(coins)
280 }
281
282 fn address_coins(&self, account: &Account, path: KeyPath) -> Result<Vec<Coin>, Error> {
283 let rows = self.call("addressutxos", &[json!(account.address(path)?)])?;
284 let rows = rows.as_array().context("missing public UTXOs")?;
285 let mut coins = Vec::new();
286 for row in rows {
287 if row["status"]["confirmed"] != true {
288 continue;
289 }
290 coins.push(Coin {
291 outpoint: OutPoint {
292 txid: row["txid"].as_str().context("missing UTXO txid")?.parse()?,
293 vout: u32::try_from(row["vout"].as_u64().context("missing UTXO index")?)?,
294 },
295 value: row["value"].as_u64().context("missing UTXO value")?,
296 height: row["status"]["block_height"]
297 .as_u64()
298 .context("missing UTXO height")?,
299 path,
300 });
301 }
302 Ok(coins)
303 }
304
305 fn live(&self, account: &Account, coin: &Coin) -> Result<bool, Error> {
306 let unspent = self.call(
307 "gettxout",
308 &[
309 json!(coin.outpoint.txid),
310 json!(coin.outpoint.vout),
311 json!(true),
312 ],
313 )?;
314 if unspent.is_null() {
315 return Ok(false);
316 }
317 let confirmations = config::confirmations(&unspent)?;
318 if confirmations < 1 || (unspent["coinbase"] == true && confirmations < 100) {
319 return Ok(false);
320 }
321 ensure!(
322 unspent["scriptPubKey"]["hex"] == hex::encode(account.script(coin.path)?.as_bytes()),
323 "live coin script disagrees with its wallet path"
324 );
325 let amount = coin_amount(&unspent["value"])?;
326 ensure!(
327 amount.to_sat() == coin.value,
328 "live coin value disagrees with the wallet scan"
329 );
330 Ok(true)
331 }
332
333 fn parent(&self, account: &Account, coin: &Coin) -> Result<Funding, Error> {
334 let block = self.call("getblockhash", &[json!(coin.height)])?;
335 let raw = self.call(
336 "getrawtransaction",
337 &[json!(coin.outpoint.txid), json!(false), block],
338 )?;
339 let funding = Funding {
340 raw_transaction: raw.as_str().context("missing funding bytes")?.to_owned(),
341 vout: coin.outpoint.vout,
342 };
343 let (outpoint, previous) = funding.prevout()?;
344 ensure!(
345 outpoint == coin.outpoint && previous.value.to_sat() == coin.value,
346 "funding transaction disagrees with the scanned coin"
347 );
348 ensure!(
349 previous.script_pubkey == account.script(coin.path)?,
350 "funding transaction disagrees with the wallet path"
351 );
352 Ok(funding)
353 }
354}