bsv-wallet-cli 0.7.5

Self-hosted BSV wallet CLI and BRC-100 server, wire-compatible with MetaNet Client
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
use anyhow::{anyhow, Context, Result};
use bsv_sdk::transaction::{Beef, Transaction};
use bsv_wallet_toolbox::services::GetBeefResult;
use bsv_wallet_toolbox::{Chain, WalletServices};
use std::future::Future;

use crate::brc29;
use crate::commands::fund;
use crate::context::WalletContext;

/// WhatsOnChain's API base: the explorer behind the three break-glass
/// reads the commands still make themselves (Rule 28: C2 here, C4 in
/// `sync`, C7 in the spend probe). Every other chain question goes through
/// the wallet's services or the header service.
pub fn woc_base(chain: Chain) -> &'static str {
    match chain {
        Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
        Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
    }
}

pub async fn run(ctx: &WalletContext, txid: &str, vout: Option<u32>) -> Result<()> {
    let (resolved_vout, accepted) = receive_txid(ctx, txid, vout).await?;

    if ctx.json_output {
        println!(
            "{}",
            serde_json::json!({
                "txid": txid,
                "vout": resolved_vout,
                "accepted": accepted,
            })
        );
    } else if accepted {
        println!("Received tx {} vout {}", txid, resolved_vout);
    } else {
        println!("Transaction was not accepted");
    }

    Ok(())
}

pub(crate) async fn receive_txid(
    ctx: &WalletContext,
    txid: &str,
    vout: Option<u32>,
) -> Result<(u32, bool)> {
    receive_txid_from(ctx, woc_base(ctx.chain), txid, vout).await
}

pub(crate) async fn receive_txid_from(
    ctx: &WalletContext,
    base: &str,
    txid: &str,
    vout: Option<u32>,
) -> Result<(u32, bool)> {
    let client = reqwest::Client::new();
    let deposit_script = brc29::deposit_script(&ctx.root_key)?;

    let services = ctx.wallet.services();
    let (beef_bytes, resolved_vout, courier) =
        fetch_beef_and_vout(&client, base, txid, vout, &deposit_script, || {
            second_courier(services, txid)
        })
        .await?;

    let accepted = fund::internalize_beef(ctx, &beef_bytes, resolved_vout)
        .await
        .map_err(|e| anyhow!("refused tx {txid} from {courier}: {e:#}"))?;
    Ok((resolved_vout, accepted))
}

/// The BEEF of `txid`, and the output of it that pays us: `vout` when the
/// caller named one, else read from the BEEF's own transaction.
///
/// Break-glass (Rule 28, C2): a COURIER. A payment to a bare address
/// arrives with no BEEF from its sender, so no row, header or proof of ours
/// holds this answer and it is fetched. What is fetched is self-verifying:
/// the BEEF is internalized, its transaction hashes to the txid and its
/// merkle paths meet the header service's roots, so the courier is trusted
/// for nothing but delivery. Two couriers: WhatsOnChain's BEEF route, and
/// behind it `second` (the toolbox's `get_beef`). A transport fault falls through;
/// a delivered body with incomplete ancestry is refused at internalization.
/// when neither delivers, the error says what each said ("could not
/// look"), and nothing is concluded about the payment.
async fn fetch_beef_and_vout<S, SF>(
    client: &reqwest::Client,
    base: &str,
    txid: &str,
    vout: Option<u32>,
    deposit_script: &[u8],
    second: S,
) -> Result<(Vec<u8>, u32, &'static str)>
where
    S: FnOnce() -> SF,
    SF: Future<Output = Result<Vec<u8>>>,
{
    let (beef_bytes, courier) = match first_courier(client, base, txid).await {
        Ok(bytes) => (bytes, "WhatsOnChain"),
        Err(first) => {
            tracing::warn!(
                marker = "break_glass_beef_courier",
                txid = %txid,
                error = %first,
                "the first BEEF courier did not deliver; asking the second"
            );
            let bytes = second().await.map_err(|second| {
                anyhow!(
                    "no courier delivered a BEEF for {txid} (could not look): \
                     WhatsOnChain: {first}; the wallet's services: {second}"
                )
            })?;
            (bytes, "wallet services")
        }
    };

    let resolved_vout = match vout {
        Some(v) => v,
        None => vout_paying(&beef_bytes, txid, deposit_script)
            .map_err(|e| anyhow!("refused tx {txid} from {courier}: {e:#}"))?,
    };
    Ok((beef_bytes, resolved_vout, courier))
}

/// WhatsOnChain's `/tx/{txid}/beef`: the transaction with its proofs, hex.
///
/// Break-glass (Rule 28, C2): a courier. Nothing we hold answers "the BEEF
/// of a payment nobody handed us"; what this returns is checked against
/// the header service when it is internalized, so the explorer is trusted
/// for delivery alone, and `second_courier` stands behind it.
async fn first_courier(client: &reqwest::Client, base: &str, txid: &str) -> Result<Vec<u8>> {
    let beef_hex = client
        .get(format!("{}/tx/{}/beef", base, txid))
        .send()
        .await
        .with_context(|| format!("WoC BEEF fetch failed for {}", txid))?
        .error_for_status()?
        .text()
        .await?;
    Ok(hex::decode(beef_hex.trim())?)
}

/// The second courier: the toolbox's `get_beef`, which carries the bytes
/// from either explorer (hashed to the txid) and a merkle path only after
/// its root met the header service.
async fn second_courier<V: WalletServices>(services: &V, txid: &str) -> Result<Vec<u8>> {
    let found = services
        .get_beef(txid, &[])
        .await
        .map_err(|e| anyhow!("{e}"))?;
    beef_from(found)
}

fn beef_from(found: GetBeefResult) -> Result<Vec<u8>> {
    found.beef.ok_or_else(|| {
        anyhow!(
            "{}",
            found
                .error
                .unwrap_or_else(|| "no BEEF and no reason given".to_string())
        )
    })
}

/// Which output of `txid` pays `deposit_script`, read from the transaction
/// the BEEF carries (Rule 28, C3, a deletion): the BEEF we just fetched
/// holds the transaction's own bytes, so its outputs are matched here and no
/// explorer is asked for a decoded copy of them.
fn vout_paying(beef_bytes: &[u8], txid: &str, deposit_script: &[u8]) -> Result<u32> {
    crate::atomic_beef::refuse_invalid_bytes(beef_bytes)?;
    let beef = Beef::from_binary(beef_bytes).context("the BEEF does not parse")?;
    let held = beef
        .find_txid(txid)
        .ok_or_else(|| anyhow!("the BEEF does not carry transaction {}", txid))?;
    let tx = match (held.tx(), held.raw_tx()) {
        (Some(tx), _) => tx.clone(),
        (None, Some(raw)) => Transaction::from_binary(raw)
            .with_context(|| format!("transaction {} in the BEEF does not parse", txid))?,
        (None, None) => return Err(anyhow!("the BEEF carries only the txid of {}", txid)),
    };
    tx.outputs
        .iter()
        .position(|o| o.locking_script.to_binary() == deposit_script)
        .map(|n| n as u32)
        .ok_or_else(|| {
            anyhow!(
                "tx {} has no output to our deposit script {}",
                txid,
                hex::encode(deposit_script)
            )
        })
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use crate::test_support::{p2pkh, raw_tx, txid_of, Fixture};
    use bsv_sdk::transaction::{Beef, MerklePath};
    use bsv_wallet_toolbox::services::mock::MockWalletServices;

    pub(crate) async fn stub_context() -> WalletContext {
        use bsv_sdk::primitives::PrivateKey;
        use bsv_sdk::transaction::MockChainTracker;
        use bsv_wallet_toolbox::{
            Services, ServicesOptions, StorageSqlx, Wallet, WalletStorageWriter,
        };
        let root_key = PrivateKey::from_hex(&format!("{:064x}", 42)).unwrap();
        let storage = StorageSqlx::open(":memory:").await.unwrap();
        storage
            .migrate("stub-refusal-test", &root_key.public_key().to_hex())
            .await
            .unwrap();
        storage.make_available().await.unwrap();
        storage
            .set_chain_tracker(std::sync::Arc::new(MockChainTracker::new(900_001)))
            .await;
        let services = Services::with_options(Chain::Main, ServicesOptions::mainnet()).unwrap();
        let wallet = Wallet::new(Some(root_key.clone()), storage, services)
            .await
            .unwrap();
        WalletContext {
            wallet,
            identity_key: root_key.public_key().to_hex(),
            root_key,
            chain: Chain::Main,
            json_output: true,
            db_path: ":memory:".into(),
        }
    }

    pub(crate) fn stub(ctx: &WalletContext, salt: u8) -> (String, String) {
        let raw = raw_tx(
            &[(&format!("{salt:02x}").repeat(32), 0)],
            &[(1000, brc29::deposit_script(&ctx.root_key).unwrap())],
        );
        let txid = txid_of(&raw);
        let mut beef = Beef::new();
        beef.merge_raw_tx(raw, None);
        (txid, beef.to_hex())
    }

    #[tokio::test]
    async fn l2_stub_refusal_names_txid_courier_reason_and_stores_nothing() {
        let ctx = stub_context().await;
        let (txid, body) = stub(&ctx, 17);
        let route = format!("/tx/{txid}/beef");
        let courier = Fixture::start(&[(&route, 200, &body)], 500).await;
        // Exercise both output discovery and an explicitly named output.
        for vout in [None, Some(0)] {
            let error = receive_txid_from(&ctx, &courier.base, &txid, vout)
                .await
                .unwrap_err();
            let text = error.to_string();
            assert!(
                text.contains(&txid)
                    && text.contains("WhatsOnChain")
                    && text.contains("BEEF structure is invalid"),
                "{text}"
            );
            for table in ["transactions", "outputs"] {
                let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
                    .fetch_one(ctx.wallet.storage().pool())
                    .await
                    .unwrap();
                assert_eq!(count, 0, "refusal wrote {table}");
            }
        }
        assert_eq!(courier.hits(), vec![route.clone(), route]);
    }

    /// A second courier that must not be reached.
    async fn no_second_courier() -> Result<Vec<u8>> {
        panic!("the second courier was asked while the first delivered")
    }

    /// C3 (Rule 28): which output pays us is read from the transaction the
    /// BEEF carries. Red at the base, with a local fixture in the explorer's
    /// place: a second request, `/tx/{txid}`, for a decoded copy of the
    /// outputs, and no answer when that request failed.
    #[tokio::test]
    async fn the_output_index_is_read_from_the_beefs_own_transaction() {
        let ours = p2pkh([0xdb; 20]);
        let raw = raw_tx(
            &[(&"11".repeat(32), 0)],
            &[(500, p2pkh([0x01; 20])), (1000, ours.clone())],
        );
        let txid = txid_of(&raw);
        let mut beef = Beef::new();
        let bump = beef.merge_bump(MerklePath::from_coinbase_txid(&txid, 900_000));
        beef.merge_raw_tx(raw, Some(bump));
        let beef_route = format!("/tx/{txid}/beef");
        let tx_route = format!("/tx/{txid}");
        let explorer = Fixture::start(&[(&beef_route, 200, &beef.to_hex())], 500).await;

        let got = fetch_beef_and_vout(
            &reqwest::Client::new(),
            &explorer.base,
            &txid,
            None,
            &ours,
            no_second_courier,
        )
        .await;

        assert_eq!(explorer.count(&tx_route), 0, "{:?}", explorer.hits());
        let (_, vout, _) = got.expect("the BEEF carries the transaction");
        assert_eq!(vout, 1);
        assert_eq!(explorer.hits(), vec![beef_route.clone()]);

        // A named output is taken as named.
        let (_, vout, _) = fetch_beef_and_vout(
            &reqwest::Client::new(),
            &explorer.base,
            &txid,
            Some(0),
            &ours,
            no_second_courier,
        )
        .await
        .unwrap();
        assert_eq!(vout, 0);

        // A transaction that pays us nothing is an error, not output 0.
        let err = vout_paying(&beef.to_binary(), &txid, &p2pkh([0x77; 20])).unwrap_err();
        assert!(
            err.to_string().contains("no output to our deposit script"),
            "{err}"
        );
        let err = vout_paying(&beef.to_binary(), &"cd".repeat(32), &ours).unwrap_err();
        assert!(
            err.to_string().contains("does not carry transaction"),
            "{err}"
        );
    }

    /// C2 (Rule 28): the BEEF courier has a second courier behind it. Red
    /// at the base: WhatsOnChain's fault was the command's error.
    #[tokio::test]
    async fn a_courier_fault_falls_through_to_the_second_courier() {
        let txid = "ab".repeat(32);
        let explorer = Fixture::start(&[], 500).await;
        let services = MockWalletServices::new();
        let got = fetch_beef_and_vout(
            &reqwest::Client::new(),
            &explorer.base,
            &txid,
            Some(0),
            &p2pkh([0xdb; 20]),
            || second_courier(&services, &txid),
        )
        .await;
        assert!(
            got.is_ok(),
            "one courier's fault ended the command: {got:?}"
        );
        assert_eq!(explorer.total(), 1, "the first courier was asked once");
        assert_eq!(services.call_count("get_beef"), 1);
    }

    /// The second courier is not asked while the first delivers.
    #[tokio::test]
    async fn the_second_courier_is_not_asked_while_the_first_delivers() {
        let txid = "ab".repeat(32);
        let route = format!("/tx/{txid}/beef");
        let explorer = Fixture::start(&[(&route, 200, "0100beef")], 500).await;
        let services = MockWalletServices::new();
        let (bytes, _, _) = fetch_beef_and_vout(
            &reqwest::Client::new(),
            &explorer.base,
            &txid,
            Some(0),
            &p2pkh([0xdb; 20]),
            || second_courier(&services, &txid),
        )
        .await
        .unwrap();
        assert_eq!(bytes, vec![0x01, 0x00, 0xbe, 0xef]);
        assert_eq!(services.call_count("get_beef"), 0);
    }

    /// Neither courier delivering is "could not look", with what each said.
    #[tokio::test]
    async fn no_courier_delivering_is_could_not_look_with_both_reasons() {
        let txid = "ab".repeat(32);
        let explorer = Fixture::start(&[], 503).await;
        let err = fetch_beef_and_vout(
            &reqwest::Client::new(),
            &explorer.base,
            &txid,
            Some(0),
            &p2pkh([0xdb; 20]),
            || async { Err(anyhow!("both explorers down")) },
        )
        .await
        .unwrap_err()
        .to_string();
        assert!(err.contains("could not look"), "{err}");
        assert!(err.contains("503"), "{err}");
        assert!(err.contains("both explorers down"), "{err}");

        // The toolbox's "no BEEF" carries its reason through.
        let none = GetBeefResult {
            name: "Services".to_string(),
            txid: txid.clone(),
            beef: None,
            has_proof: false,
            error: Some("Transaction not found".to_string()),
        };
        assert_eq!(
            beef_from(none).unwrap_err().to_string(),
            "Transaction not found"
        );
    }

    /// The courier is named at its site, in the rule's words.
    #[test]
    fn the_beef_courier_is_named_at_the_site() {
        let source = include_str!("receive.rs");
        let code = source.split("#[cfg(test)]").next().unwrap();
        assert!(code.contains("Break-glass (Rule 28, C2): a COURIER"));
    }

    /// The courier's BEEF is a stranger's bytes: a transaction with no input
    /// is refused at its offset before an output is matched, and a cut BEEF
    /// at the field that ran out.
    #[test]
    fn the_couriers_beef_is_refused_for_invalid_bytes_at_their_offset() {
        let ours = p2pkh([0xdb; 20]);
        let mut raw = vec![1, 0, 0, 0, 0, 1];
        raw.extend_from_slice(&1000u64.to_le_bytes());
        raw.push(ours.len() as u8);
        raw.extend_from_slice(&ours);
        raw.extend_from_slice(&[0, 0, 0, 0]);
        let mut h = bsv_sdk::primitives::sha256d(&raw).to_vec();
        h.reverse();
        let txid = hex::encode(h);
        let mut beef = Beef::new();
        let bump = beef.merge_bump(MerklePath::from_coinbase_txid(&txid, 900_000));
        beef.merge_raw_tx(raw.clone(), Some(bump));
        let bytes = beef.to_binary();
        let at = bytes.windows(raw.len()).position(|w| w == raw).unwrap();

        let text = vout_paying(&bytes, &txid, &ours)
            .expect_err("a transaction with no input is invalid bytes")
            .to_string();
        assert!(
            text.contains(&format!("Invalid BEEF at byte {at}")) && text.contains("NoInputs"),
            "{text}"
        );

        let text = vout_paying(&bytes[..bytes.len() - 6], &txid, &ours)
            .expect_err("cut bytes")
            .to_string();
        assert!(
            text.contains("Invalid BEEF at byte") && text.contains("Truncated"),
            "{text}"
        );
    }
}