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}
29
30#[derive(Clone)]
31pub struct FundingInput {
32    pub funding: Funding,
33    pub path: KeyPath,
34}
35
36pub struct Selection {
37    pub inputs: Vec<FundingInput>,
38    pub total: u64,
39    pub next_change: u32,
40}
41
42fn path_text<S: Serializer>(path: &KeyPath, serializer: S) -> Result<S::Ok, S::Error> {
43    serializer.collect_str(&format_args!("{}/{}", path.branch.index(), path.index))
44}
45
46pub(crate) fn coin_amount(value: &Value) -> Result<Amount, Error> {
47    let coins = value.as_f64().context("missing coin amount")?;
48    Ok(Amount::from_float_in(coins, Denomination::Bitcoin)?)
49}
50
51fn covered(total: u64, inputs: usize, minimum: u64, per_input: u64) -> Result<bool, Error> {
52    let needed = per_input
53        .checked_mul(u64::try_from(inputs)?)
54        .and_then(|inputs| inputs.checked_add(minimum))
55        .context("funding target overflow")?;
56    Ok(total >= needed)
57}
58
59impl Node {
60    /// Unspent-only discovery: `next_change` is fresh until the change coin is
61    /// spent; history-based discovery is a follow-up.
62    pub fn wallet(&self, account: &Account) -> Result<WalletView, Error> {
63        self.verify_network()?;
64        ensure!(
65            account.chain().genesis()? == self.chain().genesis()?,
66            "funding account belongs to another chain"
67        );
68        let mut coins = match self.is_public() {
69            true => self.routed_coins(account)?,
70            false => self.local_coins(account)?,
71        };
72        coins.sort_by_key(|coin| (Reverse(coin.value), coin.outpoint));
73        let next_change = coins
74            .iter()
75            .filter(|coin| coin.path.branch == Branch::Internal)
76            .fold(0, |next, coin| next.max(coin.path.index + 1));
77        let reported = coins.len();
78        if reported > config::WALLET_VERIFY_OUTPUTS {
79            tracing::warn!(
80                reported,
81                bound = config::WALLET_VERIFY_OUTPUTS,
82                "wallet exceeds the per-output verification bound; view follows the provider's unspent rows"
83            );
84            return Ok(WalletView {
85                coins,
86                next_change,
87                reported,
88                verified: false,
89            });
90        }
91        let mut live = Vec::new();
92        for coin in coins {
93            if self.live(account, &coin)? {
94                live.push(coin);
95            }
96        }
97        Ok(WalletView {
98            coins: live,
99            next_change,
100            reported,
101            verified: true,
102        })
103    }
104
105    pub fn select_coins(
106        &self,
107        account: &Account,
108        minimum: u64,
109        per_input: u64,
110    ) -> Result<Selection, Error> {
111        let view = self.wallet(account)?;
112        let mut chosen = Vec::new();
113        let mut total = 0u64;
114        for coin in view.coins {
115            if covered(total, chosen.len(), minimum, per_input)? {
116                break;
117            }
118            ensure!(
119                chosen.len() < config::WALLET_MAX_INPUTS,
120                "funding needs more than {} inputs; consolidate the wallet first",
121                config::WALLET_MAX_INPUTS
122            );
123            if !view.verified && !self.live(account, &coin)? {
124                continue;
125            }
126            total = total.checked_add(coin.value).context("funding overflow")?;
127            chosen.push(coin);
128        }
129        if !covered(total, chosen.len(), minimum, per_input)? {
130            return Err(Error::Missing(
131                "confirmed wallet coins do not cover the spend; fund the receive address and confirm first".into(),
132            ));
133        }
134        let mut inputs = Vec::new();
135        for coin in &chosen {
136            inputs.push(FundingInput {
137                funding: self.parent(account, coin)?,
138                path: coin.path,
139            });
140        }
141        Ok(Selection {
142            inputs,
143            total,
144            next_change: view.next_change,
145        })
146    }
147
148    fn local_coins(&self, account: &Account) -> Result<Vec<Coin>, Error> {
149        let mut paths = HashMap::new();
150        let mut descriptors = Vec::new();
151        for branch in [Branch::External, Branch::Internal] {
152            for index in 0..config::WALLET_RANGE {
153                let path = KeyPath::new(branch, index);
154                let script = hex::encode(account.script(path)?.as_bytes());
155                descriptors.push(json!(format!("raw({script})")));
156                paths.insert(script, path);
157            }
158        }
159        let scan = self.call("scantxoutset", &[json!("start"), Value::Array(descriptors)])?;
160        ensure!(scan["success"] == true, "UTXO scan did not complete");
161        let rows = scan["unspents"].as_array().context("missing UTXO scan")?;
162        ensure!(
163            rows.len() <= config::WALLET_MAX_COINS,
164            "UTXO client capacity exceeded"
165        );
166        let mut coins = Vec::new();
167        for row in rows {
168            let script = row["scriptPubKey"]
169                .as_str()
170                .context("missing UTXO script")?;
171            let path = *paths
172                .get(script)
173                .context("UTXO scan returned a foreign script")?;
174            coins.push(Coin {
175                outpoint: OutPoint {
176                    txid: row["txid"].as_str().context("missing UTXO txid")?.parse()?,
177                    vout: u32::try_from(row["vout"].as_u64().context("missing UTXO vout")?)?,
178                },
179                value: coin_amount(&row["amount"])?.to_sat(),
180                height: row["height"].as_u64().context("missing UTXO height")?,
181                path,
182            });
183        }
184        Ok(coins)
185    }
186
187    fn routed_coins(&self, account: &Account) -> Result<Vec<Coin>, Error> {
188        let mut coins = Vec::new();
189        for branch in [Branch::External, Branch::Internal] {
190            let mut empty = 0;
191            let mut index = 0;
192            while empty < config::WALLET_GAP && index < config::WALLET_RANGE {
193                let found = self.address_coins(account, KeyPath::new(branch, index))?;
194                empty = match found.is_empty() {
195                    true => empty + 1,
196                    false => 0,
197                };
198                coins.extend(found);
199                ensure!(
200                    coins.len() <= config::WALLET_MAX_COINS,
201                    "UTXO client capacity exceeded"
202                );
203                index += 1;
204            }
205        }
206        Ok(coins)
207    }
208
209    fn address_coins(&self, account: &Account, path: KeyPath) -> Result<Vec<Coin>, Error> {
210        let rows = self.call("addressutxos", &[json!(account.address(path)?)])?;
211        let rows = rows.as_array().context("missing public UTXOs")?;
212        let mut coins = Vec::new();
213        for row in rows {
214            if row["status"]["confirmed"] != true {
215                continue;
216            }
217            coins.push(Coin {
218                outpoint: OutPoint {
219                    txid: row["txid"].as_str().context("missing UTXO txid")?.parse()?,
220                    vout: u32::try_from(row["vout"].as_u64().context("missing UTXO index")?)?,
221                },
222                value: row["value"].as_u64().context("missing UTXO value")?,
223                height: row["status"]["block_height"]
224                    .as_u64()
225                    .context("missing UTXO height")?,
226                path,
227            });
228        }
229        Ok(coins)
230    }
231
232    fn live(&self, account: &Account, coin: &Coin) -> Result<bool, Error> {
233        let unspent = self.call(
234            "gettxout",
235            &[
236                json!(coin.outpoint.txid),
237                json!(coin.outpoint.vout),
238                json!(true),
239            ],
240        )?;
241        if unspent.is_null() {
242            return Ok(false);
243        }
244        let confirmations = config::confirmations(&unspent)?;
245        if confirmations < 1 || (unspent["coinbase"] == true && confirmations < 100) {
246            return Ok(false);
247        }
248        ensure!(
249            unspent["scriptPubKey"]["hex"] == hex::encode(account.script(coin.path)?.as_bytes()),
250            "live coin script disagrees with its wallet path"
251        );
252        let amount = coin_amount(&unspent["value"])?;
253        ensure!(
254            amount.to_sat() == coin.value,
255            "live coin value disagrees with the wallet scan"
256        );
257        Ok(true)
258    }
259
260    fn parent(&self, account: &Account, coin: &Coin) -> Result<Funding, Error> {
261        let block = self.call("getblockhash", &[json!(coin.height)])?;
262        let raw = self.call(
263            "getrawtransaction",
264            &[json!(coin.outpoint.txid), json!(false), block],
265        )?;
266        let funding = Funding {
267            raw_transaction: raw.as_str().context("missing funding bytes")?.to_owned(),
268            vout: coin.outpoint.vout,
269        };
270        let (outpoint, previous) = funding.prevout()?;
271        ensure!(
272            outpoint == coin.outpoint && previous.value.to_sat() == coin.value,
273            "funding transaction disagrees with the scanned coin"
274        );
275        ensure!(
276            previous.script_pubkey == account.script(coin.path)?,
277            "funding transaction disagrees with the wallet path"
278        );
279        Ok(funding)
280    }
281}