Skip to main content

urma_runtime/
wallet.rs

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}