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 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}