decdn-cli 0.2.0

User-facing deCDN CLI (decdn binary): probe, fetch, pools, publishing, origin import, node admin
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
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
use super::*;

#[tokio::test]
async fn wait_resolves_again_after_it_fired() {
    let (fire, mut interrupt) = Interrupt::manual();
    fire.send(()).unwrap();
    interrupt.wait().await;
    // A second wait (a later `select!`) must not poll the spent oneshot.
    interrupt.wait().await;
}

#[tokio::test]
async fn wait_pends_while_nothing_fired() {
    let (_fire, mut interrupt) = Interrupt::manual();
    let waited = tokio::time::timeout(std::time::Duration::from_millis(20), interrupt.wait()).await;
    assert!(waited.is_err());
}

fn open_tx(byte: u8, nonce: u64) -> OpenUnconfirmed {
    OpenUnconfirmed {
        tx: Some(alloy::primitives::B256::repeat_byte(byte)),
        nonce,
    }
}

fn open(byte: u8, nonce: u64) -> EscrowUnconfirmed {
    open_tx(byte, nonce).into()
}

fn top_up_tx(byte: u8, nonce: u64) -> TopUpUnconfirmed {
    TopUpUnconfirmed {
        tx: Some(alloy::primitives::B256::repeat_byte(byte)),
        nonce,
    }
}

fn top_up(byte: u8, nonce: u64) -> EscrowUnconfirmed {
    top_up_tx(byte, nonce).into()
}

fn gave_up() -> anyhow::Error {
    GaveUp {
        idle: std::time::Duration::from_secs(600),
    }
    .into()
}

/// [`name_unconfirmed_escrows`] for a command that holds no untracked escrow.
fn name<T>(result: anyhow::Result<T>, escrows: &[EscrowUnconfirmed]) -> anyhow::Result<T> {
    name_unconfirmed_escrows(result, escrows, &[])
}

fn pending(pending: Vec<EscrowUnconfirmed>) -> UnreconciledEscrows {
    UnreconciledEscrows {
        pending,
        untracked: Vec::new(),
    }
}

/// A handle that holds an untracked escrow for each `bytes` tx: a `topUp`
/// that mined and whose credit failed, as a lane build reports it.
fn holding(bytes: &[u8]) -> (UnconfirmedEscrows, Vec<UntrackedEscrow>) {
    let escrows = UnconfirmedEscrows::default();
    for byte in bytes {
        escrows.hold_untracked_for_test(&decdn_client::buyer_pool::escrowed_but_untracked(
            "pool 0x01 topped up by 5 µUSDC",
            alloy::primitives::B256::repeat_byte(*byte),
            "deposit credit lost",
        ));
    }
    let held = escrows.untracked();
    (escrows, held)
}

#[test]
fn a_command_with_no_dropped_open_ends_as_it_did() {
    assert_eq!(name(Ok(7), &[]).unwrap(), 7);
    let err = name::<()>(Err(Interrupted.into()), &[]).unwrap_err();
    assert_eq!(
        stop_message(&err).unwrap(),
        "interrupted; run the same command again to resume"
    );
    assert_eq!(stop_exit(&err), Some(INTERRUPTED_EXIT));
    let err = name::<()>(Err(gave_up()), &[]).unwrap_err();
    assert_eq!(
        stop_message(&err).unwrap(),
        "no progress for 10m; rerun to resume"
    );
    assert_eq!(stop_exit(&err), Some(GAVE_UP_EXIT));
}

/// A `decdn get` run without `-o` names the directory its `.partial` state
/// sits in, so a re-run from elsewhere does not start over and pay again.
#[test]
fn an_interrupt_names_the_resume_directory() {
    let dir = Path::new("/abs/dir");
    let err = name_resume_dir::<()>(Err(Interrupted.into()), dir).unwrap_err();
    assert_eq!(
        err.downcast_ref::<ResumeFrom>(),
        Some(&ResumeFrom(dir.to_path_buf()))
    );
    assert_eq!(
        stop_message(&err).unwrap(),
        "interrupted; run the same command again from /abs/dir to resume"
    );
    assert_eq!(stop_exit(&err), Some(INTERRUPTED_EXIT));
}

/// A give-up advises a re-run too, so it names the directory the same way and
/// keeps its exit status.
#[test]
fn a_give_up_names_the_resume_directory() {
    let err = name_resume_dir::<()>(Err(gave_up()), Path::new("/abs/dir")).unwrap_err();
    assert_eq!(
        stop_message(&err).unwrap(),
        "no progress for 10m; rerun from /abs/dir to resume"
    );
    assert_eq!(stop_exit(&err), Some(GAVE_UP_EXIT));
}

/// An error to fix is not resumed, and a success stays a success: neither
/// takes a resume directory.
#[test]
fn name_resume_dir_leaves_anything_but_a_stop_alone() {
    let dir = Path::new("/abs/dir");
    assert_eq!(name_resume_dir(Ok(7), dir).unwrap(), 7);
    let err = name_resume_dir::<()>(Err(anyhow::anyhow!("pool empty")), dir).unwrap_err();
    assert!(err.downcast_ref::<ResumeFrom>().is_none());
    assert_eq!(stop_message(&err), None);
}

/// A give-up with an `openPool` in flight names the tx, not the directory, and
/// does not tell a wrapper that a re-run is safe.
#[test]
fn an_escrow_in_flight_wins_over_the_resume_directory_on_a_give_up() {
    let err = name::<()>(Err(gave_up()), &[open(0xef, 5)]);
    let err = name_resume_dir(err, Path::new("/abs/dir")).unwrap_err();
    let line = stop_message(&err).unwrap();
    assert!(line.starts_with("no progress for 600s: "), "{line}");
    assert!(line.contains("nonce 5"), "{line}");
    assert!(!line.contains("/abs/dir"), "{line}");
    assert_eq!(stop_exit(&err), None);
}

/// An `openPool` still in flight replaces the re-run advice even when the run
/// names a resume directory: a re-run before it confirms escrows again.
#[test]
fn an_escrow_in_flight_wins_over_the_resume_directory() {
    let err = name::<()>(Err(Interrupted.into()), &[open(0xef, 5)]);
    let err = name_resume_dir(err, Path::new("/abs/dir")).unwrap_err();
    let line = stop_message(&err).unwrap();
    assert!(line.starts_with("interrupted: "), "{line}");
    assert!(line.contains("nonce 5"), "{line}");
    assert!(!line.contains("/abs/dir"), "{line}");
    assert_eq!(stop_exit(&err), Some(INTERRUPTED_EXIT));
}

/// A command that succeeded beside a dropped open still exits non-zero, naming
/// the tx: the deposit may escrow with no local record.
#[test]
fn a_dropped_open_turns_success_into_an_error_naming_the_tx() {
    let err = name(Ok(()), &[open(0xef, 5)]).unwrap_err();
    assert_eq!(
        err.downcast_ref::<UnreconciledEscrows>(),
        Some(&pending(vec![open(0xef, 5)]))
    );
    assert!(format!("{err:#}").contains("nonce 5"), "{err:#}");
    assert_eq!(stop_message(&err), None);
    assert_eq!(stop_exit(&err), None);
}

/// An interrupt during an open still exits 130, and `main` names the tx
/// instead of telling the user to run the command again.
#[test]
fn an_interrupted_open_keeps_the_interrupt_and_names_the_tx() {
    let err = name::<()>(Err(Interrupted.into()), &[open(0xef, 5)]).unwrap_err();
    assert!(err.downcast_ref::<Interrupted>().is_some());
    assert_eq!(stop_exit(&err), Some(INTERRUPTED_EXIT));
    let message = stop_message(&err).unwrap();
    assert!(
        message.starts_with("interrupted: openPool tx 0xefef"),
        "{message}"
    );
    assert!(!message.contains("run the same command again"), "{message}");
}

/// A give-up during an open names the tx in place of "rerun to resume", and
/// exits 1: `EX_TEMPFAIL` would tell a wrapper the re-run is safe.
#[test]
fn a_give_up_during_an_open_names_the_tx_and_is_not_a_tempfail() {
    let err = name::<()>(Err(gave_up()), &[open(0xef, 5)]).unwrap_err();
    let message = stop_message(&err).unwrap();
    assert!(
        message.starts_with("no progress for 600s: openPool tx 0xefef"),
        "{message}"
    );
    assert!(!message.contains("rerun"), "{message}");
    assert_eq!(stop_exit(&err), None);
}

/// Every open left pending is named, one per line.
#[test]
fn every_pending_open_is_named() {
    let err = name::<()>(Err(Interrupted.into()), &[open(0xef, 5), open(0xab, 6)]).unwrap_err();
    let message = stop_message(&err).unwrap();
    assert!(
        message.contains("0xefef") && message.contains("0xabab"),
        "{message}"
    );
    assert_eq!(message.lines().count(), 2, "{message}");
}

/// An error that already carries an open, from a receipt read that failed,
/// does not name it twice.
#[test]
fn an_open_the_error_carries_is_not_named_twice() {
    let carried = anyhow::anyhow!("receipt read failed").context(open_tx(0xef, 5));
    let err = name::<()>(Err(carried), &[open(0xef, 5)]).unwrap_err();
    assert!(
        err.downcast_ref::<UnreconciledEscrows>().is_none(),
        "{err:#}"
    );
    assert_eq!(format!("{err:#}").matches("0xefef").count(), 1, "{err:#}");
    let carried = anyhow::anyhow!("receipt read failed").context(open_tx(0xef, 5));
    let err = name::<()>(Err(carried), &[open(0xef, 5), open(0xab, 6)]).unwrap_err();
    assert_eq!(
        err.downcast_ref::<UnreconciledEscrows>(),
        Some(&pending(vec![open(0xab, 6)]))
    );
}

/// A give-up while a recovery step's `topUp` waits for its receipt names the
/// tx in place of "rerun to resume", and is not `EX_TEMPFAIL`: a rerun before
/// it confirms escrows a second top-up (#2491).
#[test]
fn a_give_up_during_a_top_up_names_the_tx_and_is_not_a_tempfail() {
    let err = name::<()>(Err(gave_up()), &[top_up(0xcd, 7)]).unwrap_err();
    let message = stop_message(&err).unwrap();
    assert!(
        message.starts_with("no progress for 600s: topUp tx 0xcdcd"),
        "{message}"
    );
    assert!(message.contains("nonce 7"), "{message}");
    assert!(!message.contains("rerun"), "{message}");
    assert_eq!(stop_exit(&err), None);
}

/// A command that succeeded while a dropped `topUp` may still mine (a late
/// refill cut off when the run drains its lane builds) exits non-zero, naming
/// the tx.
#[test]
fn a_dropped_top_up_turns_success_into_an_error_naming_the_tx() {
    let err = name(Ok(()), &[top_up(0xcd, 7)]).unwrap_err();
    assert_eq!(
        err.downcast_ref::<UnreconciledEscrows>(),
        Some(&pending(vec![top_up(0xcd, 7)]))
    );
    assert!(format!("{err:#}").contains("topUp tx 0xcdcd"), "{err:#}");
    assert_eq!(stop_message(&err), None);
    assert_eq!(stop_exit(&err), None);
}

/// An interrupt names every pending `openPool` and `topUp`, one per line, and
/// still exits 130.
#[test]
fn an_interrupt_names_pending_opens_and_top_ups() {
    let err = name::<()>(Err(Interrupted.into()), &[open(0xef, 5), top_up(0xcd, 7)]).unwrap_err();
    assert_eq!(stop_exit(&err), Some(INTERRUPTED_EXIT));
    let message = stop_message(&err).unwrap();
    assert!(
        message.starts_with("interrupted: openPool tx 0xefef"),
        "{message}"
    );
    assert!(message.contains("topUp tx 0xcdcd"), "{message}");
    assert_eq!(message.lines().count(), 2, "{message}");
}

/// An error that already carries a `topUp`, from a lapsed receipt wait or a
/// refusal that names the held one, does not name it twice, and is still not
/// `EX_TEMPFAIL` under a give-up.
#[test]
fn a_top_up_the_error_carries_is_not_named_twice() {
    let carried = anyhow::anyhow!("receipt wait lapsed").context(top_up_tx(0xcd, 7));
    let err = name::<()>(Err(carried), &[top_up(0xcd, 7)]).unwrap_err();
    assert!(
        err.downcast_ref::<UnreconciledEscrows>().is_none(),
        "{err:#}"
    );
    assert_eq!(format!("{err:#}").matches("0xcdcd").count(), 1, "{err:#}");

    let carried = gave_up().context(top_up_tx(0xcd, 7));
    assert_eq!(stop_exit(&carried), None);
    let message = stop_message(&carried).unwrap();
    assert!(message.contains("topUp tx 0xcdcd"), "{message}");
}

/// #2517: a bundle pull folds every pending escrow but the first into its
/// error as text. Each one the text names is not named twice.
#[test]
fn pending_escrows_the_error_names_as_text_are_not_named_twice() {
    let (first, second) = (top_up(0xab, 7), top_up(0xcd, 8));
    let folded = anyhow::Error::new(top_up_tx(0xab, 7)).context(format!(
        "another openPool or topUp of this command may still escrow USDC: {second}"
    ));
    let err = name::<()>(Err(folded), &[first, second]).unwrap_err();
    assert!(
        err.downcast_ref::<UnreconciledEscrows>().is_none(),
        "{err:#}"
    );
    let text = format!("{err:#}");
    assert_eq!(text.matches("0xabab").count(), 1, "{text}");
    assert_eq!(text.matches("0xcdcd").count(), 1, "{text}");
}

/// A `topUp` dropped while its submit was in flight holds no hash. Its line
/// names the nonce and claims no transport failure, because nothing failed.
#[test]
fn a_top_up_dropped_mid_submit_names_its_nonce() {
    let submitted = TopUpUnconfirmed { tx: None, nonce: 7 };
    let err = name::<()>(Err(Interrupted.into()), &[submitted.into()]).unwrap_err();
    let message = stop_message(&err).unwrap();
    assert!(
        message.starts_with("interrupted: topUp submit (nonce 7) returned no tx hash"),
        "{message}"
    );
    assert!(!message.contains("transport"), "{message}");
}

/// A Ctrl-C while the command's handle holds an untracked escrow (a late lane
/// build in the acquire's drain, after its receipt read released the
/// `topUp`) names the tx in place of "run the same command again", and still
/// exits 130 (#2511).
#[test]
fn an_interrupt_names_the_escrow_the_handle_holds() {
    let (escrows, held) = holding(&[0x9a]);
    let err = name_held_escrows::<()>(Err(Interrupted.into()), &escrows).unwrap_err();
    assert_eq!(stop_exit(&err), Some(INTERRUPTED_EXIT));
    let message = stop_message(&err).unwrap();
    assert!(
        message.starts_with("interrupted: pool 0x01 topped up"),
        "{message}"
    );
    assert!(
        message.contains(&held.first().unwrap().tx.to_string()),
        "{message}"
    );
    assert!(!message.contains("run the same command again"), "{message}");
}

/// A give-up while the handle holds an untracked escrow names the tx and is
/// not `EX_TEMPFAIL`: a re-run reads the uncredited row and escrows again.
#[test]
fn a_give_up_with_an_untracked_escrow_is_not_a_tempfail() {
    let (escrows, held) = holding(&[0x9a]);
    let err = name_held_escrows::<()>(Err(gave_up()), &escrows).unwrap_err();
    assert_eq!(stop_exit(&err), None);
    let message = stop_message(&err).unwrap();
    assert!(
        message.contains(&held.first().unwrap().tx.to_string()),
        "{message}"
    );
    assert!(!message.contains("rerun"), "{message}");
}

/// A stop whose text names the untracked escrow's tx still names it on its
/// stop line and is not `EX_TEMPFAIL`: `main` prints only the stop's own
/// line, never the chain.
#[test]
fn a_stop_whose_text_names_the_escrow_still_names_it() {
    let (escrows, held) = holding(&[0x9a]);
    let tx = held.first().unwrap().tx.to_string();
    let gave_up = gave_up().context(format!("a late lane build: {}", held.first().unwrap()));
    let err = name_held_escrows::<()>(Err(gave_up), &escrows).unwrap_err();
    assert_eq!(stop_exit(&err), None);
    assert!(stop_message(&err).unwrap().contains(&tx), "{err:#}");

    let interrupted = anyhow::Error::new(Interrupted)
        .context(format!("a late lane build: {}", held.first().unwrap()));
    let err = name_held_escrows::<()>(Err(interrupted), &escrows).unwrap_err();
    let message = stop_message(&err).unwrap();
    assert!(message.contains(&tx), "{message}");
    assert!(!message.contains("run the same command again"), "{message}");
}

/// A command that succeeded while its handle holds an untracked escrow exits
/// non-zero, naming the tx.
#[test]
fn an_untracked_escrow_turns_success_into_an_error() {
    let (escrows, held) = holding(&[0x9a]);
    let err = name_held_escrows(Ok(()), &escrows).unwrap_err();
    assert_eq!(
        err.downcast_ref::<UnreconciledEscrows>(),
        Some(&UnreconciledEscrows {
            pending: Vec::new(),
            untracked: held,
        })
    );
    assert_eq!(stop_exit(&err), None);
}

/// A failure whose text already names the untracked escrow's tx, even
/// through a wrapper that hides its type, does not name it twice. A pending
/// escrow beside it is still named.
#[test]
fn an_untracked_escrow_the_error_names_is_not_named_twice() {
    let (_, held) = holding(&[0x9a]);
    let tx = held.first().unwrap().tx.to_string();
    let reported = || {
        anyhow::anyhow!(
            "building the payment lane failed: {}",
            held.first().unwrap()
        )
    };
    let err = name_unconfirmed_escrows::<()>(Err(reported()), &[], &held).unwrap_err();
    assert!(
        err.downcast_ref::<UnreconciledEscrows>().is_none(),
        "{err:#}"
    );
    assert_eq!(format!("{err:#}").matches(&tx).count(), 1, "{err:#}");

    let err =
        name_unconfirmed_escrows::<()>(Err(reported()), &[top_up(0xcd, 7)], &held).unwrap_err();
    assert_eq!(
        err.downcast_ref::<UnreconciledEscrows>(),
        Some(&pending(vec![top_up(0xcd, 7)]))
    );
    assert_eq!(format!("{err:#}").matches(&tx).count(), 1, "{err:#}");
}

/// A pending `topUp` and every untracked escrow are named, one per line.
#[test]
fn pending_and_untracked_escrows_are_all_named() {
    let (_, held) = holding(&[0x9a, 0x8b]);
    let err = name_unconfirmed_escrows::<()>(Err(Interrupted.into()), &[top_up(0xcd, 7)], &held)
        .unwrap_err();
    let message = stop_message(&err).unwrap();
    assert!(
        message.starts_with("interrupted: topUp tx 0xcdcd"),
        "{message}"
    );
    for escrow in &held {
        assert!(message.contains(&escrow.tx.to_string()), "{message}");
    }
    assert_eq!(message.lines().count(), 3, "{message}");
}

/// A carried `topUp` and the untracked escrows attached beside it are both on
/// the stop line.
#[test]
fn a_carried_top_up_and_an_untracked_escrow_are_both_named() {
    let (escrows, held) = holding(&[0x9a]);
    let carried = gave_up().context(top_up_tx(0xcd, 7));
    let err = name_held_escrows::<()>(Err(carried), &escrows).unwrap_err();
    let message = stop_message(&err).unwrap();
    assert!(message.contains("topUp tx 0xcdcd"), "{message}");
    assert!(
        message.contains(&held.first().unwrap().tx.to_string()),
        "{message}"
    );
    assert_eq!(stop_exit(&err), None);
}

/// The second Ctrl-C's immediate exit names every escrow the handle holds,
/// pending or untracked.
#[test]
fn the_immediate_exit_names_untracked_escrows() {
    let (escrows, held) = holding(&[0x9a]);
    let lines = exit_lines(&escrows);
    assert_eq!(lines.len(), 1, "{lines:?}");
    assert!(
        lines
            .first()
            .unwrap()
            .starts_with("interrupted: pool 0x01 topped up"),
        "{lines:?}"
    );
    assert!(
        lines
            .first()
            .unwrap()
            .contains(&held.first().unwrap().tx.to_string()),
        "{lines:?}"
    );
    assert!(exit_lines(&UnconfirmedEscrows::default()).is_empty());
}