Skip to main content

decdn_cli/commands/
pool.rs

1//! `decdn pool` — client-side payment-pool lifecycle.
2//!
3//! One `PaymentPool` deposit fans out to every provider its owner pays
4//! (ADR 003) — there is no per-provider pool. `open` escrows a fresh deposit,
5//! `top-up` adds funds to a pool the caller owns, `close` starts the
6//! grace-window close, and `reclaim` refunds the residual once that window has
7//! elapsed. `list`/`status` is a read-only dump of the tracked buyer pools and
8//! their per-lane voucher watermark.
9
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::str::FromStr;
13use std::sync::Arc;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use alloy::primitives::{Address, B256, TxHash, U256};
17use alloy::signers::local::PrivateKeySigner;
18use anyhow::Context as _;
19use decdn_client::buyer_pool::{
20    ToppedUpPool, ensure_allowance, escrowed_but_untracked, grade_deposit_credit, open_pool,
21    top_up, topped_up_effect,
22};
23use decdn_common::admin::{AdminRpcClient as _, BuyerPoolsResponse};
24use decdn_common::cli::{self, common::expand_tilde};
25use decdn_common::config::{
26    DEFAULT_CHAIN_ID, DEFAULT_REDEEM_INTERVAL_SECS, FileConfig, capability_expiry_margin_secs,
27    load_file_config,
28};
29use decdn_common::redact::{sanitize_err_chain, sanitize_error_sources};
30use decdn_incentive::buyer_pool::{BuyerLoad, BuyerPoolState, BuyerPoolStore};
31use decdn_incentive::buyer_pool_redb::{ReadOnlyBuyerPoolStore, RedbBuyerPoolStore};
32use decdn_incentive::eth_identity::{self, PasswordUse, load_signer};
33use decdn_incentive::payment_pool::{PaymentPool, enumerate_owned_pools};
34use decdn_incentive::{Capability, CapabilityGrant, Deployment, PoolId, voucher_domain};
35use serde::Serialize;
36
37use decdn_client::provider;
38
39use super::buyer_store::{
40    BuyerStoreOwner, classify_buyer_store, client_buyer_db, node_buyer_db,
41    open_client_store_for_escrow,
42};
43use super::chain_ctx;
44
45/// Dispatch `decdn pool <subcommand>`.
46pub async fn pool_dispatch(args: &cli::PoolArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
47    match &args.command {
48        cli::PoolCommand::List(a) => list(a, config_path).await,
49        cli::PoolCommand::Open(a) => open(a, config_path).await,
50        cli::PoolCommand::TopUp(a) => top_up_cmd(a, config_path).await,
51        cli::PoolCommand::Close(a) => close(a, config_path).await,
52        cli::PoolCommand::Reclaim(a) => reclaim(a, config_path).await,
53        cli::PoolCommand::Assign(a) => assign(a, config_path).await,
54    }
55}
56
57/// Chain coordinates resolved flag > `[blockchain]`/`[identity]` config >
58/// default. Pure (parse-only) so the precedence is unit-testable.
59#[derive(Debug)]
60struct Resolved {
61    rpc_url: String,
62    payment_pool: Address,
63    chain_id: u64,
64    data_dir: PathBuf,
65    keystore: PathBuf,
66    /// File holding the keystore password, consulted after the
67    /// `DECDN_KEYSTORE_PASSWORD` env var and before a prompt. CLI/env-only —
68    /// passwords do not belong in a config file even by reference.
69    keystore_password_file: Option<PathBuf>,
70}
71
72/// Resolve the buyer-store data dir: flag > `[identity]` config > client-scoped
73/// `~/.decdn/client` default. Shared by the read-only `list` path (which needs
74/// nothing else) and [`resolve_chain`].
75fn resolve_data_dir(data_dir: Option<PathBuf>, file: &FileConfig) -> anyhow::Result<PathBuf> {
76    data_dir
77        .or_else(|| file.identity.as_ref().and_then(|i| i.data_dir.clone()))
78        .map(|p| expand_tilde(&p))
79        .or_else(cli::default_client_data_dir)
80        .ok_or_else(|| {
81            anyhow::anyhow!("data_dir not set and no default available (pass --data-dir)")
82        })
83}
84
85fn resolve_chain(args: &cli::PoolChainArgs, file: &FileConfig) -> anyhow::Result<Resolved> {
86    let bc = file.blockchain.as_ref();
87    let rpc_url = args
88        .rpc_url
89        .clone()
90        .or_else(|| bc.and_then(|b| b.rpc_url.clone()))
91        .ok_or_else(|| anyhow::anyhow!("rpc_url not set (--rpc-url or blockchain.rpc_url)"))?;
92    let payment_pool_raw = args
93        .payment_pool_address
94        .clone()
95        .or_else(|| bc.and_then(|b| b.payment_pool_address.clone()))
96        .ok_or_else(|| {
97            anyhow::anyhow!(
98                "payment_pool_address not set (--payment-pool-address or \
99                 blockchain.payment_pool_address)"
100            )
101        })?;
102    let payment_pool =
103        super::chain_ctx::parse_nonzero_address(&payment_pool_raw, "payment_pool_address")?;
104    let chain_id = args
105        .chain_id
106        .or_else(|| bc.and_then(|b| b.chain_id))
107        .unwrap_or(DEFAULT_CHAIN_ID);
108    let data_dir = resolve_data_dir(args.data_dir.clone(), file)?;
109    let keystore = args
110        .keystore
111        .clone()
112        .or_else(|| bc.and_then(|b| b.eth_keystore.clone()))
113        .map_or_else(
114            || eth_identity::keystore_path(&data_dir),
115            |p| expand_tilde(&p),
116        );
117    Ok(Resolved {
118        rpc_url,
119        payment_pool,
120        chain_id,
121        data_dir,
122        keystore,
123        keystore_password_file: args.keystore_password_file.as_deref().map(expand_tilde),
124    })
125}
126
127/// Buyer signer for the on-chain `pool` commands (vouchers +
128/// open/top-up/close/reclaim txs). Password from `KEYSTORE_PASSWORD_ENV`, else
129/// `--keystore-password-file`, else TTY.
130fn load_buyer_signer(chain: &Resolved) -> anyhow::Result<PrivateKeySigner> {
131    let password = super::chain_ctx::read_keystore_password(
132        &super::chain_ctx::password_sources(
133            chain.keystore_password_file.as_deref(),
134            PasswordUse::Unlock,
135        ),
136        "eth keystore password",
137    )?
138    .into_secret();
139    load_signer(&chain.keystore, &password)
140}
141
142/// Parse a user-supplied `--pool`: 64 hex chars, optionally `0x`-prefixed (the
143/// on-chain `poolId` is `keccak256(owner, ownerPoolNonce)`, a `bytes32`).
144fn parse_pool_id(s: &str) -> anyhow::Result<PoolId> {
145    B256::from_str(s)
146        .map_err(|e| anyhow::anyhow!("invalid --pool {s:?}: expected a 32-byte hex hash: {e}"))
147}
148
149/// Reject a `getPool` read whose on-chain `owner` isn't this keystore — a wrong
150/// keystore/contract or a stale `--pool` id (a zero `owner` means the pool was
151/// never opened on this contract).
152fn ensure_owned(on_chain_owner: Address, ours: Address, pool_id: PoolId) -> anyhow::Result<()> {
153    anyhow::ensure!(
154        on_chain_owner == ours,
155        "pool {pool_id} on-chain owner {on_chain_owner} is not this keystore's address {ours} \
156         — wrong keystore/contract, or a mistyped --pool"
157    );
158    Ok(())
159}
160
161/// `decdn pool open`: escrow a fresh `PaymentPool` deposit (`openPool`). The
162/// resulting pool fans out to every provider the owner later pays — there is no
163/// per-provider open.
164async fn open(args: &cli::PoolOpenArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
165    let file = load_file_config(config_path)?;
166    // Before chain coordinates are even resolved, and so before the keystore
167    // prompt: escrowing into a store the daemon never reads is the failure,
168    // not a degraded outcome, and the answer depends only on the data dir.
169    let data_dir = resolve_data_dir(args.chain.data_dir.clone(), &file)?;
170    let store = open_client_store_for_escrow(&data_dir, "open a pool")?;
171    let chain = resolve_chain(&args.chain, &file)?;
172    let signer = Arc::new(load_buyer_signer(&chain)?);
173    let owner = signer.address();
174    let rpc = provider::build_provider(&chain.rpc_url, &signer)?;
175    let contract = PaymentPool::new(chain.payment_pool, rpc.clone());
176    let deployment = Deployment {
177        chain_id: chain.chain_id,
178        payment_pool: chain.payment_pool,
179    };
180
181    let token = contract
182        .usdc()
183        .call()
184        .await
185        .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
186    let deposit = U256::from(args.deposit_micro_usdc);
187
188    ensure_allowance(&rpc, token, owner, chain.payment_pool, Some(deposit)).await?;
189
190    let opened = open_pool(
191        &contract,
192        Arc::clone(&signer),
193        deployment,
194        token,
195        owner,
196        deposit,
197    )
198    .await?;
199
200    // The deposit is escrowed on-chain; a failed local record leaves it
201    // untracked (reconcile against the tx).
202    store
203        .record(&opened.state)
204        .map_err(|e| escrowed_but_untracked("buyer pool opened", opened.tx, e))?;
205
206    let out = PoolOpenJson {
207        pool_id: format!("{:#x}", opened.state.pool_id),
208    };
209    serde_json::to_writer_pretty(std::io::stdout(), &out)?;
210    println!();
211    Ok(())
212}
213
214/// `decdn pool open` machine-readable output.
215#[derive(Serialize)]
216struct PoolOpenJson {
217    #[serde(rename = "poolId")]
218    pool_id: String,
219}
220
221/// `decdn pool top-up`: add funds to a pool the caller owns (`topUp`).
222async fn top_up_cmd(args: &cli::PoolTopUpArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
223    let file = load_file_config(config_path)?;
224    let data_dir = resolve_data_dir(args.chain.data_dir.clone(), &file)?;
225    let store = open_client_store_for_escrow(&data_dir, "top up a pool")?;
226    let chain = resolve_chain(&args.chain, &file)?;
227    let pool_id = parse_pool_id(&args.pool)?;
228    let signer = Arc::new(load_buyer_signer(&chain)?);
229    let owner = signer.address();
230    let rpc = provider::build_provider(&chain.rpc_url, &signer)?;
231    let contract = PaymentPool::new(chain.payment_pool, rpc.clone());
232
233    let pool = contract
234        .getPool(pool_id)
235        .call()
236        .await
237        .map_err(|e| anyhow::Error::new(e).context("getPool failed"))?;
238    ensure_owned(pool.owner, owner, pool_id)?;
239
240    let additional = U256::from(args.amount_micro_usdc);
241    // The pool does not store its token — it is the contract's immutable
242    // `usdc()`, the same address the open path reads.
243    let token = contract
244        .usdc()
245        .call()
246        .await
247        .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
248    ensure_allowance(&rpc, token, owner, chain.payment_pool, Some(additional)).await?;
249
250    let ToppedUpPool { credited, tx, .. } = top_up(&contract, owner, pool_id, additional).await?;
251
252    // `add_deposit` reports a backend fault as `Err` and a committed-row
253    // mismatch as a non-`Added` `Ok` (the row vanished or now tracks a
254    // different pool). Both mean the same thing here: the USDC is escrowed and
255    // the local deposit is short by `credited`. Exiting 0 on either would let a
256    // wrapper record the pool as funded — after which the low-water check
257    // re-fires on every later fetch, and re-running this command escrows again.
258    let effect = topped_up_effect(pool_id, credited);
259    let new_deposit =
260        grade_deposit_credit(store.add_deposit(owner, pool_id, credited), &effect, tx)?;
261    println!("topped up pool {pool_id} by {credited} µUSDC; deposit now {new_deposit} µUSDC");
262    Ok(())
263}
264
265/// Where a landed `closePool` / `reclaim` records that the pool is gone.
266///
267/// The on-chain leg of both is identical either way; only the local
268/// bookkeeping differs, and on a daemon's data dir there is none the CLI can
269/// legitimately do (#2078).
270#[derive(Debug, Clone, Copy)]
271enum LocalBookkeeping<'a> {
272    /// Clear the row in the client store this CLI owns.
273    Store(&'a RedbBuyerPoolStore),
274    /// A `decdn-node` daemon owns this data dir. `decdn pool` has no code path
275    /// to that daemon's `buyer.redb`, so its row stays as it is and the
276    /// operator is told so — the alternative, a no-op `forget` graded as a
277    /// clean close, is the silent lie this variant exists to remove.
278    DaemonOwned(&'a Path),
279}
280
281impl LocalBookkeeping<'_> {
282    /// Build from the store the command opened: `None` means the data dir is a
283    /// daemon's, so `buyer_db` names the file that was left untouched.
284    const fn new<'a>(
285        store: Option<&'a RedbBuyerPoolStore>,
286        buyer_db: &'a Path,
287    ) -> LocalBookkeeping<'a> {
288        match store {
289            Some(store) => LocalBookkeeping::Store(store),
290            None => LocalBookkeeping::DaemonOwned(buyer_db),
291        }
292    }
293
294    /// Say, on stderr, that the daemon's row survives and what to do about it.
295    /// The on-chain state has already changed, so this is not a warning the
296    /// operator may ignore: the daemon still believes it owns a live pool.
297    fn report_daemon_row_untouched(buyer_db: &Path, pool_id: PoolId, verb: &str) {
298        eprintln!(
299            "warning: pool {pool_id} {verb} on-chain, but the daemon's row in {} was NOT \
300             cleared — `decdn pool` writes only the client store, and a running decdn-node holds \
301             its own exclusively. Run `decdn node pools`: if that pool is listed, the daemon is \
302             still using it — restart decdn-node so its bootstrap reconciles against the chain.",
303            buyer_db.display()
304        );
305    }
306
307    /// Clear the row after a mined `closePool`.
308    ///
309    /// # Errors
310    ///
311    /// Errors when the client store faulted — see [`grade_local_forget`].
312    fn forget_after_close(
313        self,
314        owner: Address,
315        pool_id: PoolId,
316        tx: TxHash,
317        reclaim_note: &str,
318    ) -> anyhow::Result<()> {
319        match self {
320            Self::Store(store) => grade_local_forget(
321                store.forget_if_pool(owner, pool_id),
322                pool_id,
323                tx,
324                reclaim_note,
325            ),
326            Self::DaemonOwned(buyer_db) => {
327                Self::report_daemon_row_untouched(buyer_db, pool_id, "closed");
328                Ok(())
329            }
330        }
331    }
332
333    /// Clear the row after a mined `reclaim`. Best-effort on both arms: the
334    /// refund itself already landed.
335    fn forget_after_reclaim(self, owner: Address, pool_id: PoolId) {
336        match self {
337            Self::Store(store) => {
338                // `Ok(false)` costs nothing: reclaim is permissionless and the
339                // row is legitimately absent after a `pool close` or a prior
340                // reclaim. An `Err` is not free — the row may survive pointing
341                // at a pool that is now `Closed`, whose `deposit` field still
342                // reads healthy, so a later fetch reuses it and signs vouchers
343                // `redeemMany` rejects outright. Warn rather than fail: the
344                // refund itself landed, and re-running the reclaim is what
345                // clears the row.
346                if let Err(e) = store.forget_if_pool(owner, pool_id) {
347                    eprintln!(
348                        "warning: pool {pool_id} reclaimed on-chain but clearing it from the \
349                         local store failed: {e}; if the row survived, a later fetch reuses this \
350                         closed pool and its vouchers are rejected — re-run `decdn pool reclaim \
351                         --pool {pool_id}` to clear it"
352                    );
353                }
354            }
355            Self::DaemonOwned(buyer_db) => {
356                Self::report_daemon_row_untouched(buyer_db, pool_id, "reclaimed");
357            }
358        }
359    }
360}
361
362/// Grade the local row-clear that follows a mined `closePool`.
363///
364/// `pool close` drops the local record *so that* a later `fetch` / `bundle pull`
365/// opens a fresh pool rather than reusing one that is winding down. Only the
366/// `Err` leaves that in doubt.
367///
368/// `forget_if_pool` is compare-and-delete, and its `Ok(false)` means the compare
369/// found nothing to delete: the owner index holds no row, or it already points at
370/// a newer pool. In both states nothing maps this owner to the closed pool, which
371/// is the guarantee `close` wanted, and deleting anything further would destroy a
372/// live replacement — the lost update the compare-and-delete exists to prevent.
373/// Closing a pool this store never tracked lands there routinely: a second
374/// machine, a fresh `--data-dir`, or an older pool superseded by a later
375/// `pool open`. That is a clean close.
376///
377/// An `Err` is different. The write faulted, so the row may survive with a live
378/// owner index pointing at the closed pool. A later fetch then reuses it and
379/// signs vouchers that stop being redeemable at the dispute deadline, against a
380/// deposit the owner is about to reclaim.
381///
382/// The close itself landed, so the error carries `tx` and `reclaim_note`: the
383/// reclaim is both the deadline the success line would have printed and the
384/// operation that clears the stale row.
385///
386/// # Errors
387///
388/// Errors when the store faulted, leaving the row's fate unknown.
389fn grade_local_forget(
390    outcome: Result<bool, decdn_incentive::StoreError>,
391    pool_id: PoolId,
392    tx: TxHash,
393    reclaim_note: &str,
394) -> anyhow::Result<()> {
395    match outcome {
396        Ok(_) => Ok(()),
397        Err(e) => anyhow::bail!(
398            "pool {pool_id} closed on-chain (tx {tx}) but clearing it from the local store \
399             failed: {e}; the row may survive and send a later fetch back to this closing \
400             pool, whose vouchers stop being redeemable at the dispute deadline — \
401             {reclaim_note}, which also clears the row"
402        ),
403    }
404}
405
406/// Terminal outcome of a single close/reclaim tx.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408enum TxOutcome {
409    /// Mined with a success receipt.
410    Landed,
411    /// Deterministically reverted (caught at estimation or a failing receipt).
412    Reverted,
413}
414
415/// A mined-but-reverted tx returns `Ok(receipt)` with `status() == false`, so map
416/// the receipt status to the outcome.
417const fn receipt_outcome(landed: bool) -> TxOutcome {
418    if landed {
419        TxOutcome::Landed
420    } else {
421        TxOutcome::Reverted
422    }
423}
424
425/// What `close --all` does with one enumerated pool, decided from its on-chain
426/// status alone. Only `Open` pools have anything to close.
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428enum ClosePlan {
429    /// `Open` — send `closePool`.
430    Close,
431    /// Not `Open` — nothing to do; the string is the human-readable reason.
432    Skip(&'static str),
433}
434
435/// What `reclaim` does with one pool, decided from its status and dispute
436/// deadline against the chain's head time. It drives the `--all` sweep, the
437/// `--pool` pre-check (`single_reclaim_gate`), and the `pool list` Reclaimable
438/// column.
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440enum ReclaimPlan {
441    /// `Closing` and its dispute window has elapsed — send `reclaim`.
442    Reclaim,
443    /// `Open` — it must be closed first, so there is nothing to reclaim yet.
444    SkipOpen,
445    /// `Closing` but still inside the dispute window; carries the absolute
446    /// Unix deadline so the operator learns when it becomes reclaimable.
447    SkipInWindow(u64),
448    /// `Closed` — the reclaim already happened (that is what sets `Closed`).
449    SkipClosed,
450    /// An unrecognized on-chain status (the hidden invalid `sol!` variant) —
451    /// nothing safe to reclaim, and not asserted to have been reclaimed.
452    SkipUnknown,
453}
454
455/// Running counts for a `--all` sweep. The exit code keys off `failed`; the
456/// other two are reported for the operator.
457#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
458struct BatchTally {
459    /// Pools closed or reclaimed successfully.
460    acted: usize,
461    /// Pools intentionally left untouched (wrong status / window not elapsed).
462    skipped: usize,
463    /// Pools whose read or transaction errored.
464    failed: usize,
465}
466
467/// Classify one enumerated pool for `close --all`.
468const fn plan_close(status: PaymentPool::Status) -> ClosePlan {
469    match status {
470        PaymentPool::Status::Open => ClosePlan::Close,
471        PaymentPool::Status::Closing => ClosePlan::Skip("already closing"),
472        PaymentPool::Status::Closed => ClosePlan::Skip("already closed"),
473        // `sol!` enums carry a hidden invalid variant, so a wildcard is
474        // required; an unrecognized status has nothing safe to close.
475        _ => ClosePlan::Skip("unknown on-chain status"),
476    }
477}
478
479/// Classify one pool for `reclaim`. `reclaim` reverts unless
480/// the pool is `Closing` past its deadline, so the plan pre-filters to exactly
481/// that case and names why each other pool is skipped. `now == deadline` counts
482/// as elapsed, matching the on-chain `block.timestamp >= disputeDeadline` gate.
483const fn plan_reclaim(status: PaymentPool::Status, dispute_deadline: u64, now: u64) -> ReclaimPlan {
484    match status {
485        PaymentPool::Status::Open => ReclaimPlan::SkipOpen,
486        PaymentPool::Status::Closing if now >= dispute_deadline => ReclaimPlan::Reclaim,
487        PaymentPool::Status::Closing => ReclaimPlan::SkipInWindow(dispute_deadline),
488        PaymentPool::Status::Closed => ReclaimPlan::SkipClosed,
489        // `sol!` enums carry a hidden invalid variant, so a wildcard is
490        // required; an unrecognized status is not asserted to be reclaimed.
491        _ => ReclaimPlan::SkipUnknown,
492    }
493}
494
495/// Decide from one `getPool` read and the chain's head time whether `reclaim
496/// --pool` may send. Each status or deadline condition that makes `reclaim`
497/// revert is refused here with its own cause, so the operator learns what to
498/// do (and, in the dispute window, when) without a transaction or a keystore
499/// unlock.
500///
501/// # Errors
502///
503/// Errors when the pool does not exist, is still `Open`, is `Closing` inside
504/// its dispute window, is already `Closed`, or has an unrecognized status. The
505/// zero-`owner` check runs first: a never-opened pool reads as status `0`,
506/// which is `Open`, and would otherwise be reported as still Open.
507fn single_reclaim_gate(
508    pool_id: PoolId,
509    owner: Address,
510    status: PaymentPool::Status,
511    dispute_deadline: u64,
512    now: u64,
513) -> anyhow::Result<()> {
514    anyhow::ensure!(
515        owner != Address::ZERO,
516        "pool {pool_id} does not exist on this PaymentPool contract — a mistyped --pool, or \
517         the wrong payment_pool address"
518    );
519    match plan_reclaim(status, dispute_deadline, now) {
520        ReclaimPlan::Reclaim => Ok(()),
521        ReclaimPlan::SkipOpen => anyhow::bail!(
522            "pool {pool_id} is still Open — run `decdn pool close --pool {pool_id}` first"
523        ),
524        ReclaimPlan::SkipInWindow(deadline) => anyhow::bail!(
525            "pool {pool_id} is still in its dispute window; reclaimable after {}",
526            format_expiry(deadline, now)
527        ),
528        ReclaimPlan::SkipClosed => {
529            anyhow::bail!("pool {pool_id} is already Closed — its residual was already reclaimed")
530        }
531        ReclaimPlan::SkipUnknown => {
532            anyhow::bail!("pool {pool_id} has an unrecognized on-chain status — nothing to reclaim")
533        }
534    }
535}
536
537/// Turn a finished `--all` sweep into a process result. Any failed pool is a
538/// nonzero exit; "nothing eligible" is success — the summary line already says
539/// so.
540///
541/// # Errors
542///
543/// Errors when at least one pool failed to `verb`.
544fn batch_result(tally: &BatchTally, verb: &str) -> anyhow::Result<()> {
545    anyhow::ensure!(
546        tally.failed == 0,
547        "{} pool(s) failed to {verb}; see the summary above",
548        tally.failed
549    );
550    Ok(())
551}
552
553/// Send `closePool(pool_id)`, wait for the receipt, and on success clear the
554/// local buyer row so a later fetch opens a fresh pool. Ownership and `Open`
555/// status are the caller's responsibility. Returns the reclaim note shared by
556/// the success line and the row-clear warning.
557///
558/// # Errors
559///
560/// Errors when the send is rejected, the receipt cannot be read, the tx
561/// reverted, or the local row-clear faulted after a landed close.
562async fn close_and_forget<P>(
563    contract: &PaymentPool::PaymentPoolInstance<P>,
564    store: LocalBookkeeping<'_>,
565    owner: Address,
566    pool_id: PoolId,
567) -> anyhow::Result<String>
568where
569    P: alloy::providers::Provider + Clone,
570{
571    let pending = match contract.closePool(pool_id).send().await {
572        Ok(pending) => pending,
573        Err(e) if e.as_revert_data().is_some() => {
574            anyhow::bail!("closePool reverted on-chain for pool {pool_id}")
575        }
576        Err(e) => return Err(anyhow::Error::new(e).context("closePool send failed")),
577    };
578    let receipt = pending
579        .get_receipt()
580        .await
581        .map_err(|e| anyhow::Error::new(e).context("closePool receipt failed"))?;
582
583    match receipt_outcome(receipt.status()) {
584        TxOutcome::Landed => {
585            // The dispute deadline is only known post-close; read it best-effort
586            // and never print the pre-close `0` if that read fails. The local
587            // record is dropped so a later `fetch`/`bundle pull` opens a fresh
588            // pool rather than reusing one that is winding down. Any transport
589            // error is redacted here, since the caller may print it directly
590            // rather than through `main`'s error boundary.
591            let deadline_note = match contract.getPool(pool_id).call().await {
592                Ok(p) => format!("after Unix {}", p.disputeDeadline),
593                Err(e) => format!(
594                    "after the dispute window (couldn't read the exact deadline: {})",
595                    sanitize_error_sources(&e)
596                ),
597            };
598            // One note for both the success line and the failure, so the two
599            // cannot drift into different instructions for the same deadline.
600            let reclaim_note = format!("run `decdn pool reclaim --pool {pool_id}` {deadline_note}");
601            store.forget_after_close(owner, pool_id, receipt.transaction_hash, &reclaim_note)?;
602            Ok(reclaim_note)
603        }
604        TxOutcome::Reverted => anyhow::bail!(
605            "closePool reverted on-chain for pool {pool_id} (it may have raced a concurrent \
606             close)"
607        ),
608    }
609}
610
611/// Send `reclaim(pool_id)`, wait for the receipt, and on success best-effort
612/// clear the local row. `reclaim` is permissionless, so no ownership check is
613/// needed. Callers pre-check the pool with `plan_reclaim`, so a revert here
614/// means the chain disagreed with that pre-check. Returns `Ok(())` once the
615/// refund lands.
616///
617/// # Errors
618///
619/// Errors when the send is rejected, the receipt cannot be read, or the tx
620/// reverted after the pre-check passed (see `reclaim_reverted`). A failed local
621/// row-clear only warns — the refund itself landed.
622async fn reclaim_and_forget<P>(
623    contract: &PaymentPool::PaymentPoolInstance<P>,
624    store: LocalBookkeeping<'_>,
625    owner: Address,
626    pool_id: PoolId,
627) -> anyhow::Result<()>
628where
629    P: alloy::providers::Provider + Clone,
630{
631    let pending = match contract.reclaim(pool_id).send().await {
632        Ok(pending) => pending,
633        Err(e) if e.as_revert_data().is_some() => anyhow::bail!(reclaim_reverted(pool_id)),
634        Err(e) => return Err(anyhow::Error::new(e).context("reclaim send failed")),
635    };
636    let receipt = pending
637        .get_receipt()
638        .await
639        .map_err(|e| anyhow::Error::new(e).context("reclaim receipt failed"))?;
640
641    match receipt_outcome(receipt.status()) {
642        TxOutcome::Landed => {
643            store.forget_after_reclaim(owner, pool_id);
644            Ok(())
645        }
646        TxOutcome::Reverted => anyhow::bail!(reclaim_reverted(pool_id)),
647    }
648}
649
650/// The error for a `reclaim` the chain rejected after the pre-check passed. One message for the estimation-time and receipt-time reverts, so the
651/// two cannot drift apart.
652fn reclaim_reverted(pool_id: PoolId) -> String {
653    format!(
654        "reclaim reverted on-chain for pool {pool_id} after the pre-check passed (most likely \
655         a concurrent reclaim; the USDC refund transfer to the owner can also revert)"
656    )
657}
658
659/// `decdn pool close`: start the grace-window close on a pool the caller owns
660/// (`closePool`). Redemptions stay valid until the dispute deadline; `pool
661/// reclaim` refunds the residual after it elapses. `--all` closes every `Open`
662/// pool this keystore owns; `--pool` closes exactly one.
663async fn close(args: &cli::PoolCloseArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
664    let file = load_file_config(config_path)?;
665    // Parse the target id before any store/keystore/provider work, so a
666    // malformed `--pool` fails fast without touching the chain. `None` is the
667    // `--all` sweep (clap's arg group guarantees exactly one of the two).
668    let target = args.pool.as_deref().map(parse_pool_id).transpose()?;
669
670    let store_owner = classify_buyer_store(&resolve_data_dir(args.chain.data_dir.clone(), &file)?)?;
671    if target.is_none() {
672        // `--all` enumerates from chain, not from the store, so on a node data
673        // dir it would close the pool the daemon is paying from right now.
674        store_owner.refuse_sweep("close")?;
675    }
676    let chain = resolve_chain(&args.chain, &file)?;
677    let store = store_owner.open_for_write()?;
678    let buyer_db = node_buyer_db(&chain.data_dir);
679    let books = LocalBookkeeping::new(store.as_ref(), &buyer_db);
680    let signer = Arc::new(load_buyer_signer(&chain)?);
681    let owner = signer.address();
682    let rpc = provider::build_provider(&chain.rpc_url, &signer)?;
683    let contract = PaymentPool::new(chain.payment_pool, rpc);
684
685    let Some(pool_id) = target else {
686        return close_all(&contract, books, owner).await;
687    };
688
689    let pool = contract
690        .getPool(pool_id)
691        .call()
692        .await
693        .map_err(|e| anyhow::Error::new(e).context("getPool failed"))?;
694    ensure_owned(pool.owner, owner, pool_id)?;
695    anyhow::ensure!(
696        matches!(pool.status, PaymentPool::Status::Open),
697        "pool {pool_id} is not Open — nothing to close (already closing or closed)"
698    );
699
700    let reclaim_note = close_and_forget(&contract, books, owner, pool_id).await?;
701    println!("closed pool {pool_id}; dispute window open — {reclaim_note}");
702    Ok(())
703}
704
705/// `close --all`: enumerate every pool this keystore owns and close the `Open`
706/// ones. One pool's read or close failure is recorded and the sweep continues;
707/// the exit code is nonzero only if any pool failed.
708async fn close_all<P>(
709    contract: &PaymentPool::PaymentPoolInstance<P>,
710    store: LocalBookkeeping<'_>,
711    owner: Address,
712) -> anyhow::Result<()>
713where
714    P: alloy::providers::Provider + Clone,
715{
716    let ids = enumerate_owned_pools(contract, owner).await?;
717    if ids.is_empty() {
718        println!("no pools found for this keystore — nothing to close");
719        return Ok(());
720    }
721
722    let mut tally = BatchTally::default();
723    for pool_id in ids {
724        let status = match contract.getPool(pool_id).call().await {
725            Ok(pool) => pool.status,
726            Err(e) => {
727                eprintln!(
728                    "failed to read pool {pool_id}: {}",
729                    sanitize_error_sources(&e)
730                );
731                tally.failed += 1;
732                continue;
733            }
734        };
735        match plan_close(status) {
736            ClosePlan::Close => match close_and_forget(contract, store, owner, pool_id).await {
737                Ok(note) => {
738                    println!("closed pool {pool_id}; dispute window open — {note}");
739                    tally.acted += 1;
740                }
741                Err(e) => {
742                    eprintln!("failed to close pool {pool_id}: {}", sanitize_err_chain(&e));
743                    tally.failed += 1;
744                }
745            },
746            ClosePlan::Skip(reason) => {
747                println!("skipped pool {pool_id}: {reason}");
748                tally.skipped += 1;
749            }
750        }
751    }
752    println!(
753        "close --all: closed {}, skipped {}, failed {}",
754        tally.acted, tally.skipped, tally.failed
755    );
756    batch_result(&tally, "close")
757}
758
759/// `decdn pool reclaim`: refund the residual deposit of a pool once its grace
760/// window has elapsed (`reclaim`; permissionless — callable by anyone, but only
761/// the owner receives funds). `--all` reclaims every pool this keystore owns
762/// whose window has elapsed; `--pool` reclaims exactly one, after a read-only
763/// pre-check of its status and deadline that runs before the keystore unlock.
764async fn reclaim(args: &cli::PoolReclaimArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
765    let file = load_file_config(config_path)?;
766    // Parse the target id before any store/keystore/provider work, so a
767    // malformed `--pool` fails fast without touching the chain. `None` is the
768    // `--all` sweep (clap's arg group guarantees exactly one of the two).
769    let target = args.pool.as_deref().map(parse_pool_id).transpose()?;
770
771    let store_owner = classify_buyer_store(&resolve_data_dir(args.chain.data_dir.clone(), &file)?)?;
772    if target.is_none() {
773        store_owner.refuse_sweep("reclaim")?;
774    }
775    let chain = resolve_chain(&args.chain, &file)?;
776    if let Some(pool_id) = target {
777        // `getPool` and the head block are reads, so they run on a wallet-free
778        // provider before the keystore unlock: a pool that `reclaim` would
779        // refuse fails here with its cause and asks for no password.
780        let reader = PaymentPool::new(
781            chain.payment_pool,
782            provider::build_read_provider(&chain.rpc_url)?,
783        );
784        let pool = reader
785            .getPool(pool_id)
786            .call()
787            .await
788            .map_err(|e| anyhow::Error::new(e).context("getPool failed"))?;
789        let now = chain_ctx::head_timestamp(reader.provider()).await?;
790        single_reclaim_gate(pool_id, pool.owner, pool.status, pool.disputeDeadline, now)?;
791    }
792    let store = store_owner.open_for_write()?;
793    let buyer_db = node_buyer_db(&chain.data_dir);
794    let books = LocalBookkeeping::new(store.as_ref(), &buyer_db);
795    let signer = Arc::new(load_buyer_signer(&chain)?);
796    let owner = signer.address();
797    let rpc = provider::build_provider(&chain.rpc_url, &signer)?;
798    let contract = PaymentPool::new(chain.payment_pool, rpc);
799
800    let Some(pool_id) = target else {
801        return reclaim_all(&contract, books, owner).await;
802    };
803
804    reclaim_and_forget(&contract, books, owner, pool_id).await?;
805    println!("reclaimed pool {pool_id}; residual deposit refunded to its owner");
806    Ok(())
807}
808
809/// `reclaim --all`: enumerate every pool this keystore owns and reclaim the ones
810/// whose dispute window has elapsed. Pools still `Open`, still inside the
811/// window, or already reclaimed are skipped (in-window pools report when they
812/// become reclaimable). One pool's failure is recorded and the sweep continues;
813/// the exit code is nonzero only if any pool failed.
814async fn reclaim_all<P>(
815    contract: &PaymentPool::PaymentPoolInstance<P>,
816    store: LocalBookkeeping<'_>,
817    owner: Address,
818) -> anyhow::Result<()>
819where
820    P: alloy::providers::Provider + Clone,
821{
822    let ids = enumerate_owned_pools(contract, owner).await?;
823    if ids.is_empty() {
824        println!("no pools found for this keystore — nothing to reclaim");
825        return Ok(());
826    }
827    let now = chain_ctx::head_timestamp(contract.provider()).await?;
828
829    let mut tally = BatchTally::default();
830    for pool_id in ids {
831        let pool = match contract.getPool(pool_id).call().await {
832            Ok(pool) => pool,
833            Err(e) => {
834                eprintln!(
835                    "failed to read pool {pool_id}: {}",
836                    sanitize_error_sources(&e)
837                );
838                tally.failed += 1;
839                continue;
840            }
841        };
842        match plan_reclaim(pool.status, pool.disputeDeadline, now) {
843            ReclaimPlan::Reclaim => {
844                match reclaim_and_forget(contract, store, owner, pool_id).await {
845                    Ok(()) => {
846                        println!(
847                            "reclaimed pool {pool_id}; residual deposit refunded to its owner"
848                        );
849                        tally.acted += 1;
850                    }
851                    Err(e) => {
852                        eprintln!(
853                            "failed to reclaim pool {pool_id}: {}",
854                            sanitize_err_chain(&e)
855                        );
856                        tally.failed += 1;
857                    }
858                }
859            }
860            ReclaimPlan::SkipOpen => {
861                println!("skipped pool {pool_id}: still Open — run `pool close` first");
862                tally.skipped += 1;
863            }
864            ReclaimPlan::SkipInWindow(deadline) => {
865                println!(
866                    "skipped pool {pool_id}: dispute window open — reclaimable after {}",
867                    format_expiry(deadline, now)
868                );
869                tally.skipped += 1;
870            }
871            ReclaimPlan::SkipClosed => {
872                println!("skipped pool {pool_id}: already reclaimed");
873                tally.skipped += 1;
874            }
875            ReclaimPlan::SkipUnknown => {
876                println!("skipped pool {pool_id}: unrecognized on-chain status");
877                tally.skipped += 1;
878            }
879        }
880    }
881    println!(
882        "reclaim --all: reclaimed {}, skipped {}, failed {}",
883        tally.acted, tally.skipped, tally.failed
884    );
885    batch_result(&tally, "reclaim")
886}
887
888/// Resolve the capability's absolute Unix-seconds expiry from the mutually
889/// exclusive `--expiry-secs` (relative to now) / `--expiry-at` (absolute)
890/// flags. Exactly one is required, and the result must lie in the future — a
891/// capability that expires at or before now is dead on arrival (a node stops
892/// accepting its vouchers a margin before expiry, ADR 003 §Revocation).
893fn resolve_expiry(
894    now: u64,
895    expiry_secs: Option<u64>,
896    expiry_at: Option<u64>,
897) -> anyhow::Result<u64> {
898    let expiry = match (expiry_secs, expiry_at) {
899        (Some(secs), None) => now.checked_add(secs).ok_or_else(|| {
900            anyhow::anyhow!("--expiry-secs {secs} overflows the Unix epoch from now ({now})")
901        })?,
902        (None, Some(at)) => at,
903        // clap `conflicts_with` rules out `(Some, Some)`; this leaves `(None, None)`.
904        _ => anyhow::bail!("exactly one of --expiry-secs or --expiry-at is required"),
905    };
906    anyhow::ensure!(
907        expiry > now,
908        "capability expiry {expiry} is not in the future (now is {now}); it would be dead on \
909         arrival — pick a later --expiry-at or a positive --expiry-secs"
910    );
911    Ok(expiry)
912}
913
914/// The capability-expiry margin of a node at the default redeem interval.
915const DEFAULT_NODE_EXPIRY_MARGIN_SECS: u64 =
916    capability_expiry_margin_secs(DEFAULT_REDEEM_INTERVAL_SECS);
917
918/// Whether a capability's remaining lifetime at `now` lies within the
919/// capability-expiry margin of a node at the default redeem interval. Such a node
920/// refuses every voucher under it from the start (ADR 003 §Revocation). A node's
921/// actual interval is unknown here, so `assign` only warns.
922const fn expires_within_default_node_margin(expiry: u64, now: u64) -> bool {
923    expiry.saturating_sub(now) <= DEFAULT_NODE_EXPIRY_MARGIN_SECS
924}
925
926/// Current Unix time in whole seconds.
927///
928/// # Errors
929///
930/// Errors if the system clock is before the Unix epoch. Defaulting to `0` there
931/// would let `assign` pass its `expiry > now` guard trivially and print a token
932/// that expired decades ago.
933fn unix_now() -> anyhow::Result<u64> {
934    SystemTime::now()
935        .duration_since(UNIX_EPOCH)
936        .map(|d| d.as_secs())
937        .map_err(|e| anyhow::anyhow!("the system clock is before the Unix epoch: {e}"))
938}
939
940/// How long `assign` waits for the chain head before it falls back to the
941/// local clock.
942const HEAD_READ_TIMEOUT: Duration = Duration::from_secs(5);
943
944/// The clock `assign` checks a capability's expiry against: the chain head's
945/// timestamp, which is the clock the contract applies at redemption. A local
946/// clock that drifts would otherwise let `assign` print a token the chain
947/// already treats as expired.
948///
949/// The read needs no keystore, so it runs before the unlock. Offline issuance
950/// stays valid: when the head cannot be read within `timeout`, `assign` warns
951/// and falls back to the local clock, so an endpoint that accepts and never
952/// answers cannot stall it.
953///
954/// # Errors
955///
956/// Errors only on that fallback, when the system clock is before the Unix epoch.
957async fn issuance_now(rpc_url: &str, timeout: Duration) -> anyhow::Result<u64> {
958    let head = match provider::build_read_provider(rpc_url) {
959        Ok(reader) => tokio::time::timeout(timeout, chain_ctx::head_timestamp(&reader))
960            .await
961            .unwrap_or_else(|_| Err(anyhow::anyhow!("no answer within {timeout:?}"))),
962        Err(e) => Err(e),
963    };
964    match head {
965        Ok(now) => Ok(now),
966        Err(e) => {
967            eprintln!(
968                "warning: could not read the chain head time ({}); checking the capability \
969                 expiry against the local clock instead",
970                sanitize_err_chain(&e)
971            );
972            unix_now()
973        }
974    }
975}
976
977/// Render an absolute Unix-seconds expiry as `"<ts> (in Nd Nh Nm)"` relative to
978/// `now`. The relative tail is what an operator actually reasons about; the raw
979/// timestamp is kept for an exact, timezone-free reference.
980fn format_expiry(expiry: u64, now: u64) -> String {
981    let remaining = expiry.saturating_sub(now);
982    let days = remaining / 86_400;
983    let hours = (remaining % 86_400) / 3_600;
984    let mins = (remaining % 3_600) / 60;
985    format!("Unix {expiry} (in {days}d {hours}h {mins}m)")
986}
987
988/// `decdn pool assign`: issue an owner-signed spending capability delegating a
989/// bounded spend on a pool the caller owns to a delegate `--signer` key, and
990/// print the `dcap1:` token to hand to that delegated client (ADR 003
991/// §Capability delegation).
992///
993/// The capability is signed offline with the owner keystore against the pool's
994/// EIP-712 domain (`PaymentPool` address + chain id). Offline issuance is valid,
995/// so an unreachable RPC only warns — the node is the one that enforces the
996/// owner signature against the pool's on-chain owner at redemption time. An
997/// owner read that *succeeds* is different evidence: a pool's owner is fixed at
998/// open, so a different owner — or no such pool — proves the token is already
999/// dead, and no token is printed.
1000async fn assign(args: &cli::PoolAssignArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
1001    let file = load_file_config(config_path)?;
1002    let chain = resolve_chain(&args.chain, &file)?;
1003    let pool_id = parse_pool_id(&args.pool)?;
1004    let signer_addr = super::chain_ctx::parse_nonzero_address(&args.signer, "--signer")?;
1005
1006    let now = issuance_now(&chain.rpc_url, HEAD_READ_TIMEOUT).await?;
1007    let expiry = resolve_expiry(now, args.expiry_secs, args.expiry_at)?;
1008    if expires_within_default_node_margin(expiry, now) {
1009        eprintln!(
1010            "warning: the capability expires in {}s; a node with the default redeem interval \
1011             refuses vouchers within {DEFAULT_NODE_EXPIRY_MARGIN_SECS}s of a capability's \
1012             expiry, so such a node serves this delegate nothing",
1013            expiry.saturating_sub(now)
1014        );
1015    }
1016
1017    let owner_signer = load_buyer_signer(&chain)?;
1018    let owner = owner_signer.address();
1019    let domain = voucher_domain(chain.chain_id, chain.payment_pool);
1020    let spending_cap = args.cap_micro_usdc;
1021
1022    let capability = Capability {
1023        signer: signer_addr,
1024        spending_cap,
1025        pool_id,
1026        expiry,
1027    };
1028    let signed = capability.sign(&owner_signer, &domain)?;
1029    let grant = CapabilityGrant::from_signed_capability(&signed);
1030    let token = grant.to_token();
1031
1032    // The owner the node will recover from the token — by construction this is
1033    // the keystore we just signed with, so it doubles as a codec round-trip
1034    // check before the token ever leaves the machine.
1035    let recovered = grant
1036        .owner(&domain)
1037        .map_err(|e| anyhow::anyhow!("re-recovering the owner from the fresh token failed: {e}"))?;
1038
1039    // On-chain owner check. A read we could not perform only warns — offline
1040    // issuance is valid, and the serving node enforces the owner signature
1041    // against the pool's real owner at redemption. A read that *succeeded* and
1042    // disagrees is different evidence entirely, and fails: see
1043    // `ensure_on_chain_owner`.
1044    match provider::build_provider(&chain.rpc_url, &owner_signer) {
1045        Ok(rpc) => {
1046            let contract = PaymentPool::new(chain.payment_pool, rpc);
1047            ensure_on_chain_owner(&contract, pool_id, owner).await?;
1048        }
1049        Err(e) => eprintln!(
1050            "warning: could not build an RPC provider to check the on-chain owner of pool \
1051             {pool_id} ({}); issuing anyway — the node verifies the owner signature at redemption",
1052            sanitize_err_chain(&e)
1053        ),
1054    }
1055
1056    println!("pool:         {pool_id}");
1057    println!("delegate:     {signer_addr} (the voucher-signing key this authorizes)");
1058    println!(
1059        "spending cap: {} USDC ({} µUSDC)",
1060        format_usdc_u256(U256::from(spending_cap)),
1061        args.cap_micro_usdc
1062    );
1063    println!("expiry:       {}", format_expiry(expiry, now));
1064    println!("owner:        {recovered} (recovered from the signature)");
1065    println!("Token (give this to the delegated client):");
1066    println!("{token}");
1067    Ok(())
1068}
1069
1070/// Refuse to issue a capability the command has already proven dead, and warn
1071/// when it could not find out either way.
1072///
1073/// The two outcomes are different evidence, and it matters that they do not
1074/// share an exit code. A `getPool` that **succeeded** proves what the token is
1075/// worth: a pool's owner is fixed at open, so an owner that is not this keystore
1076/// means the token is rejected at redemption. Printing it and exiting 0 means
1077/// `decdn pool assign … > delegate.token` writes a file that looks valid and is
1078/// not, and nothing downstream finds out until a delegate's first voucher
1079/// bounces. A read that could not be performed proves nothing: offline issuance
1080/// is valid by design (`Assign` is signed entirely from the owner keystore), and
1081/// the serving node enforces the owner signature at redemption regardless — so
1082/// that leg degrades to a warning.
1083///
1084/// # Errors
1085///
1086/// Errors when the read succeeded and the pool is not owned by `expected` —
1087/// including a pool that does not exist on this contract.
1088async fn ensure_on_chain_owner<P>(
1089    contract: &PaymentPool::PaymentPoolInstance<P>,
1090    pool_id: PoolId,
1091    expected: Address,
1092) -> anyhow::Result<()>
1093where
1094    P: alloy::providers::Provider + Clone,
1095{
1096    match contract.getPool(pool_id).call().await {
1097        Ok(pool) => grade_on_chain_owner(pool.owner, expected).into_result(pool_id, expected),
1098        Err(e) => {
1099            eprintln!(
1100                "warning: could not read pool {pool_id} on-chain to confirm ownership ({}); \
1101                 issuing anyway — the node verifies the owner signature at redemption",
1102                sanitize_error_sources(&e)
1103            );
1104            Ok(())
1105        }
1106    }
1107}
1108
1109/// The verdict [`ensure_on_chain_owner`] reaches on a read that succeeded.
1110///
1111/// Separated from the RPC call so the decision is unit-testable without a
1112/// provider, and so the two arms — "the chain says no" and "the chain did not
1113/// answer" — stay distinct.
1114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115enum OwnerVerdict {
1116    /// The pool exists and this keystore owns it.
1117    Owned,
1118    /// No pool exists at this id on this contract. `getPool` zero-fills an
1119    /// unknown key instead of reverting, so this arrives as a successful read.
1120    NoSuchPool,
1121    /// The pool exists and this address owns it instead.
1122    OwnedByOther(Address),
1123}
1124
1125/// Grade the `owner` a successful `getPool` returned against the issuing
1126/// keystore.
1127fn grade_on_chain_owner(on_chain: Address, expected: Address) -> OwnerVerdict {
1128    if on_chain.is_zero() {
1129        OwnerVerdict::NoSuchPool
1130    } else if on_chain == expected {
1131        OwnerVerdict::Owned
1132    } else {
1133        OwnerVerdict::OwnedByOther(on_chain)
1134    }
1135}
1136
1137impl OwnerVerdict {
1138    /// Refuse to issue a capability this verdict has proven dead.
1139    ///
1140    /// Both failing arms name what the operator has to change, and they name
1141    /// different things: a wrong keystore is not a wrong `--pool`.
1142    ///
1143    /// # Errors
1144    ///
1145    /// Errors on every verdict except [`OwnerVerdict::Owned`].
1146    fn into_result(self, pool_id: PoolId, expected: Address) -> anyhow::Result<()> {
1147        match self {
1148            Self::Owned => Ok(()),
1149            Self::NoSuchPool => anyhow::bail!(
1150                "pool {pool_id} does not exist on this PaymentPool contract; a capability for a \
1151                 pool that was never opened is rejected at redemption, so no token was issued — \
1152                 check --pool, --payment-pool-address, and --chain-id"
1153            ),
1154            Self::OwnedByOther(on_chain) => anyhow::bail!(
1155                "pool {pool_id} on-chain owner {on_chain} is not this keystore's address \
1156                 {expected}; a capability signed by a non-owner is rejected at redemption, so no \
1157                 token was issued — sign with the owner keystore, or check --pool"
1158            ),
1159        }
1160    }
1161}
1162
1163/// `decdn pool list` / `status`: read-only dump of the tracked buyer pools and
1164/// their per-lane voucher watermark.
1165///
1166/// Names the store it read, always. There are two, in the same directory and
1167/// with the same table format but no other relation: the CLI's own
1168/// `buyer-pools.redb` and a daemon's `buyer.redb`. Reading the first and
1169/// reporting it as the second makes `pools=0` meaningless on a node host
1170/// (#2078), so on a daemon's data dir this asks the daemon instead — redb holds
1171/// that file exclusively for the daemon's lifetime, so there is no disk path to
1172/// it while the node is up.
1173async fn list(args: &cli::PoolListArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
1174    let data_dir = match args.chain.data_dir.clone() {
1175        Some(dir) => expand_tilde(&dir),
1176        None => resolve_data_dir(None, &load_file_config(config_path)?)?,
1177    };
1178
1179    if args.all {
1180        return list_all(args, config_path, &data_dir).await;
1181    }
1182
1183    match classify_buyer_store(&data_dir)? {
1184        BuyerStoreOwner::Node { data_dir, .. } => {
1185            list_from_daemon(args, config_path, &data_dir).await
1186        }
1187        BuyerStoreOwner::Client { data_dir } => list_from_client_store(args, &data_dir),
1188    }
1189}
1190
1191/// `decdn pool list --all`: every pool this keystore owns on chain.
1192///
1193/// The local listings answer "what does this store remember?". This answers
1194/// "what do I own?", and the two are different questions precisely when it
1195/// matters: a reset data dir loses the only local record of a funded deposit,
1196/// and the store-backed listing then prints nothing because the file it reads
1197/// is the file that went missing (#2072). The chain still has the pool.
1198///
1199/// Read-only, so unlike `close --all` / `reclaim --all` it is not refused on a
1200/// node's data dir. Those two write — they would close the pool the daemon is
1201/// paying from. This one looks.
1202///
1203/// The local view is consulted best-effort, and feeds three things: the
1204/// `TRACKED` column, the `local_store=` / `local_store_read` flag, and the
1205/// stderr warning naming rows that will not decode. An unreadable store leaves
1206/// the column unknown rather than failing the listing, because an unreadable
1207/// store is the case this exists for.
1208async fn list_all(
1209    args: &cli::PoolListArgs,
1210    config_path: Option<&Path>,
1211    data_dir: &Path,
1212) -> anyhow::Result<()> {
1213    let file = load_file_config(config_path)?;
1214    let chain = resolve_chain(&args.chain, &file)?;
1215    let signer = Arc::new(load_buyer_signer(&chain)?);
1216    let owner = signer.address();
1217    let rpc = provider::build_provider(&chain.rpc_url, &signer)?;
1218    let contract = PaymentPool::new(chain.payment_pool, rpc);
1219
1220    let ids = enumerate_owned_pools(&contract, owner).await?;
1221    let tracked = tracked_pool_ids(args, config_path, data_dir).await;
1222    let now = chain_ctx::head_timestamp(contract.provider()).await?;
1223
1224    let mut rows = Vec::with_capacity(ids.len());
1225    for pool_id in ids {
1226        let pool = contract.getPool(pool_id).call().await.map_err(|e| {
1227            anyhow::anyhow!("getPool({pool_id}) failed: {}", sanitize_error_sources(&e))
1228        })?;
1229        rows.push(ChainPoolRow {
1230            pool_id,
1231            status: pool.status,
1232            deposit: U256::from(pool.deposit),
1233            total_redeemed: U256::from(pool.totalRedeemed),
1234            dispute_deadline: pool.disputeDeadline,
1235            tracked: tracked.as_ref().and_then(|local| local.verdict(pool_id)),
1236        });
1237    }
1238
1239    // The corrupt rows are named on stderr here too, so `--all` is not the one
1240    // listing that stays silent about a deposit stranded by an unreadable
1241    // record. stdout keeps only the table, for scripts.
1242    if let Some(local) = tracked.as_ref() {
1243        write_skipped_pools(&mut std::io::stderr().lock(), &local.undecodable())?;
1244    }
1245
1246    let mut out = std::io::stdout().lock();
1247    if args.json {
1248        let view = PoolListAllJson {
1249            source: SOURCE_CHAIN,
1250            owner: format!("{owner:#x}"),
1251            chain_id: chain.chain_id,
1252            payment_pool: format!("{:#x}", chain.payment_pool),
1253            local_store_read: tracked.is_some(),
1254            pools: rows.iter().map(|r| ChainPoolJson::at(r, now)).collect(),
1255        };
1256        serde_json::to_writer_pretty(&mut out, &view)?;
1257        writeln!(out)?;
1258    } else {
1259        write_chain_pools(
1260            &mut out,
1261            owner,
1262            chain.chain_id,
1263            &rows,
1264            now,
1265            tracked.is_some(),
1266        )?;
1267    }
1268    Ok(())
1269}
1270
1271/// The state of one pool's row in the local record.
1272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1273enum RowState {
1274    /// The row decoded.
1275    Decoded,
1276    /// A row exists and will not decode. The escrowed deposit is untracked
1277    /// until the record is repaired — a different remedy from an absent row, so
1278    /// it must not render as one.
1279    Undecodable,
1280}
1281
1282/// What the local record says about the pools the chain reports.
1283///
1284/// One entry per pool the record mentions, because "the store has no row for
1285/// this pool" and "the store has a row it cannot decode" are different answers
1286/// and only the first means the deposit is untracked. An undecodable row is
1287/// still a row: `pool_id` is the table's primary key, so it survives whatever
1288/// corrupted the value bytes, and the store demonstrably holds that pool.
1289///
1290/// A map rather than two sets, so a pool cannot be both at once.
1291#[derive(Debug, Default)]
1292struct TrackedPools(std::collections::BTreeMap<PoolId, RowState>);
1293
1294impl TrackedPools {
1295    /// Whether the local record tracks `pool_id`: `None` when a row exists but
1296    /// could not be decoded, so the honest answer for that one pool is unknown
1297    /// while every other pool's answer stays exact.
1298    fn verdict(&self, pool_id: PoolId) -> Option<bool> {
1299        match self.0.get(&pool_id) {
1300            Some(RowState::Decoded) => Some(true),
1301            Some(RowState::Undecodable) => None,
1302            None => Some(false),
1303        }
1304    }
1305
1306    /// The pools with a row that will not decode, for the operator warning.
1307    fn undecodable(&self) -> Vec<PoolId> {
1308        self.0
1309            .iter()
1310            .filter(|(_, state)| **state == RowState::Undecodable)
1311            .map(|(id, _)| *id)
1312            .collect()
1313    }
1314
1315    /// Project a running daemon's `admin_v1_pools` answer.
1316    ///
1317    /// `None` when any id on the wire will not parse. That is a format drift
1318    /// between the daemon's spelling and this binary's, and the failure mode of
1319    /// guessing is the worst one available: a dropped id renders as `no`, which
1320    /// tells an operator to close a pool the daemon is paying from right now.
1321    /// One unparseable id makes the whole answer untrustworthy, so it is
1322    /// reported as unknown rather than partially believed.
1323    fn from_wire(resp: &BuyerPoolsResponse) -> Option<Self> {
1324        let mut rows = std::collections::BTreeMap::new();
1325        for (id, state) in resp
1326            .pools
1327            .iter()
1328            .map(|p| (p.pool_id.as_str(), RowState::Decoded))
1329            .chain(
1330                resp.skipped
1331                    .iter()
1332                    .map(|p| (p.as_str(), RowState::Undecodable)),
1333            )
1334        {
1335            rows.insert(PoolId::from_str(id).ok()?, state);
1336        }
1337        Some(Self(rows))
1338    }
1339}
1340
1341impl From<BuyerLoad> for TrackedPools {
1342    fn from(load: BuyerLoad) -> Self {
1343        Self(
1344            load.pools
1345                .iter()
1346                .map(|p| (p.pool_id, RowState::Decoded))
1347                .chain(
1348                    load.skipped
1349                        .into_iter()
1350                        .map(|id| (id, RowState::Undecodable)),
1351                )
1352                .collect(),
1353        )
1354    }
1355}
1356
1357/// What the local record tracks, or `None` when it gave no usable answer.
1358///
1359/// `None` is a real answer, not a failure: it is what an operator sees when the
1360/// store is the thing that was lost, and rendering it as "unknown" beside the
1361/// chain's rows is more honest than reporting every pool untracked. It covers
1362/// the whole store — one undecodable row makes that one pool unknown, never the
1363/// others.
1364///
1365/// **Every read here is read-only.** `--all` sends no transaction and writes no
1366/// pool record, and it must not manufacture a store either: creating an empty
1367/// one and reporting its emptiness is the #2078 defect, and it would also make
1368/// a lost store — the condition this command exists to surface — indistinguish-
1369/// able from a store that tracks nothing.
1370///
1371/// A store that will not open is named on stderr rather than folded into the
1372/// column, because `?` alone cannot say whether to restore a backup, wait for
1373/// another process, or repair a record.
1374async fn tracked_pool_ids(
1375    args: &cli::PoolListArgs,
1376    config_path: Option<&Path>,
1377    data_dir: &Path,
1378) -> Option<TrackedPools> {
1379    let owner = match classify_buyer_store(data_dir) {
1380        Ok(owner) => owner,
1381        Err(err) => {
1382            let mut w = std::io::stderr().lock();
1383            let _ = writeln!(
1384                w,
1385                "warning: {err:#}; every TRACKED value below is unknown, which is not the same \
1386                 as untracked"
1387            );
1388            return None;
1389        }
1390    };
1391    match owner {
1392        BuyerStoreOwner::Client { data_dir } => read_local_pools(&client_buyer_db(&data_dir)),
1393        BuyerStoreOwner::Node { data_dir, .. } => {
1394            let buyer_db = node_buyer_db(&data_dir);
1395            match ReadOnlyBuyerPoolStore::open_file(&buyer_db) {
1396                Ok(reader) => load_local_pools(&reader, &buyer_db),
1397                // Only the lock means "a daemon holds this file"; that is the
1398                // one failure the admin RPC can answer instead. Every other
1399                // failure is about the file, and asking the daemon would report
1400                // it as an unknown column with no reason attached.
1401                Err(decdn_incentive::StoreError::AlreadyOpen { .. }) => {
1402                    daemon_pool_ids(args, config_path).await
1403                }
1404                Err(err) => {
1405                    warn_unreadable_store(&buyer_db, &err);
1406                    None
1407                }
1408            }
1409        }
1410    }
1411}
1412
1413/// Read a buyer store at `path` without creating or locking it.
1414///
1415/// An absent store is an empty [`TrackedPools`], not an unknown: nothing is
1416/// tracked, which is exactly what a reset data dir should report against the
1417/// pools the chain still holds.
1418fn read_local_pools(path: &Path) -> Option<TrackedPools> {
1419    match ReadOnlyBuyerPoolStore::open_file(path) {
1420        Ok(reader) => load_local_pools(&reader, path),
1421        Err(decdn_incentive::StoreError::Absent { .. }) => Some(TrackedPools::default()),
1422        Err(err) => {
1423            warn_unreadable_store(path, &err);
1424            None
1425        }
1426    }
1427}
1428
1429/// Hydrate an opened store, naming a read failure rather than silently
1430/// blanking the column.
1431fn load_local_pools(reader: &ReadOnlyBuyerPoolStore, path: &Path) -> Option<TrackedPools> {
1432    match reader.load_all() {
1433        Ok(load) => Some(TrackedPools::from(load)),
1434        Err(err) => {
1435            warn_unreadable_store(path, &err);
1436            None
1437        }
1438    }
1439}
1440
1441/// Say on stderr why the `TRACKED` column is unknown.
1442///
1443/// The column has one glyph for every reason, so without this an operator
1444/// cannot tell a store another process holds from one that needs restoring
1445/// from backup. stdout keeps only the table, for scripts.
1446fn warn_unreadable_store(path: &Path, err: &decdn_incentive::StoreError) {
1447    let mut w = std::io::stderr().lock();
1448    let _ = writeln!(
1449        w,
1450        "warning: the local buyer store at {} could not be read ({err}); every TRACKED value \
1451         below is unknown, which is not the same as untracked",
1452        path.display()
1453    );
1454}
1455
1456/// Ask a running daemon what it tracks. `None` on any failure — this feeds one
1457/// column, never the listing itself.
1458///
1459/// `skipped` crosses the admin wire as hex strings for exactly this reason: the
1460/// daemon cannot decode those rows either, and dropping them here would report
1461/// the pools as untracked.
1462async fn daemon_pool_ids(
1463    args: &cli::PoolListArgs,
1464    config_path: Option<&Path>,
1465) -> Option<TrackedPools> {
1466    if args.timeout_ms == 0 {
1467        // `list` rejects this outright; here the listing still stands, so say
1468        // what was skipped rather than leaving an unexplained column.
1469        let mut w = std::io::stderr().lock();
1470        let _ = writeln!(
1471            w,
1472            "warning: --timeout-ms 0 skips the daemon lookup (jsonrpsee reads a zero duration \
1473             as 'never'), so every TRACKED value below is unknown"
1474        );
1475        return None;
1476    }
1477    let url =
1478        crate::commands::node::resolve_admin_url(args.admin_url.as_deref(), config_path).ok()?;
1479    let client = jsonrpsee::http_client::HttpClientBuilder::default()
1480        .request_timeout(std::time::Duration::from_millis(args.timeout_ms))
1481        .build(&url)
1482        .ok()?;
1483    let resp: BuyerPoolsResponse = client.pools().await.ok()?;
1484    let tracked = TrackedPools::from_wire(&resp);
1485    if tracked.is_none() {
1486        let mut w = std::io::stderr().lock();
1487        let _ = writeln!(
1488            w,
1489            "warning: the daemon reported a pool id this binary cannot parse, so every TRACKED \
1490             value below is unknown. The daemon and this CLI disagree on the wire format — \
1491             check that both are the same build."
1492        );
1493    }
1494    tracked
1495}
1496
1497/// Read the CLI's own `buyer-pools.redb` under `data_dir`.
1498fn list_from_client_store(args: &cli::PoolListArgs, data_dir: &Path) -> anyhow::Result<()> {
1499    let store_path = client_buyer_db(data_dir);
1500    let store = RedbBuyerPoolStore::open(data_dir)?;
1501    let load = store.load_all()?;
1502    write_store_listing(args, &store_path, SOURCE_CLIENT_STORE, "", load)
1503}
1504
1505/// Render a [`BuyerLoad`] this process read out of a redb file itself.
1506///
1507/// Shared by the client store and the offline read of a stopped daemon's
1508/// store. `provenance` is appended to the `store=` line: the two reads produce
1509/// the same row shape from different files, so which file — and whether a
1510/// daemon was running — must be on the output, not inferred.
1511fn write_store_listing(
1512    args: &cli::PoolListArgs,
1513    store_path: &Path,
1514    source: &'static str,
1515    provenance: &str,
1516    load: BuyerLoad,
1517) -> anyhow::Result<()> {
1518    let BuyerLoad {
1519        mut pools,
1520        mut skipped,
1521    } = load;
1522    // Stable output regardless of the store's internal key order.
1523    pools.sort_by_key(|p| p.pool_id);
1524    skipped.sort_unstable();
1525    write_skipped_pools(&mut std::io::stderr().lock(), &skipped)?;
1526
1527    let mut out = std::io::stdout().lock();
1528    if args.json {
1529        let view = PoolListJson {
1530            store: store_path.display().to_string(),
1531            source,
1532            pools: pools.iter().map(PoolJson::from).collect(),
1533            skipped: skipped.iter().map(|p| format!("{p:#x}")).collect(),
1534        };
1535        serde_json::to_writer_pretty(&mut out, &view)?;
1536        writeln!(out)?;
1537    } else {
1538        writeln!(out, "store={}{provenance}", store_path.display())?;
1539        write_pools(&mut out, &pools, &skipped)?;
1540    }
1541    Ok(())
1542}
1543
1544/// Read a node's `buyer.redb` — through `admin_v1_pools` while the daemon runs,
1545/// and off disk when it does not.
1546///
1547/// The fallback is never the *client* store: that is a different file with
1548/// unrelated contents, and printing its `pools=0` here is precisely the failure
1549/// this path exists to prevent. It is the daemon's own file, read-only.
1550///
1551/// Only a refused connection takes the offline route, because it is the one
1552/// failure that positively means nothing is listening. A `Call` error means the
1553/// daemon ANSWERED and refused, so it is up. Every other transport error and
1554/// every timeout is ambiguous — a dropped packet, a firewall, a blackholing
1555/// port — and an ambiguous signal must not be resolved by opening a file a
1556/// running daemon may own.
1557///
1558/// On the refused path the lock settles it: `redb` holds its process-exclusive
1559/// lock for the lifetime of an open `Database`, so a read-only open succeeding
1560/// proves no daemon holds the file, and `AlreadyOpen` proves one does — which
1561/// makes a refused admin port an admin-URL problem rather than a stopped node.
1562async fn list_from_daemon(
1563    args: &cli::PoolListArgs,
1564    config_path: Option<&Path>,
1565    data_dir: &Path,
1566) -> anyhow::Result<()> {
1567    let buyer_db = node_buyer_db(data_dir);
1568    anyhow::ensure!(
1569        args.timeout_ms > 0,
1570        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
1571         'never' rather than 'sub-millisecond deadline')"
1572    );
1573    let url = crate::commands::node::resolve_admin_url(args.admin_url.as_deref(), config_path)?;
1574    let client = jsonrpsee::http_client::HttpClientBuilder::default()
1575        .request_timeout(std::time::Duration::from_millis(args.timeout_ms))
1576        .build(&url)
1577        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
1578
1579    let resp: BuyerPoolsResponse = match client.pools().await {
1580        Ok(resp) => resp,
1581        // Connection refused: nothing is listening, so the daemon is very
1582        // likely down and its store readable. Try it before reporting.
1583        Err(jsonrpsee::core::client::Error::Transport(inner))
1584            if crate::commands::node::is_connection_refused(inner.as_ref()) =>
1585        {
1586            return list_from_stopped_daemon(args, data_dir, &buyer_db, &url, inner.as_ref());
1587        }
1588        Err(err) => {
1589            return Err(unreachable_daemon_error(
1590                args, data_dir, &buyer_db, &url, err,
1591            ));
1592        }
1593    };
1594
1595    let mut out = std::io::stdout().lock();
1596    if args.json {
1597        let view = DaemonPoolListJson {
1598            store: buyer_db.display().to_string(),
1599            source: SOURCE_DAEMON,
1600            pools: &resp.pools,
1601            skipped: &resp.skipped,
1602        };
1603        serde_json::to_writer_pretty(&mut out, &view)?;
1604        writeln!(out)?;
1605    } else {
1606        write_skipped_buyer_pools(&mut std::io::stderr().lock(), &resp.skipped)?;
1607        writeln!(
1608            out,
1609            "store={} (read from the running daemon)",
1610            buyer_db.display()
1611        )?;
1612        write_buyer_pools(&mut out, &resp)?;
1613    }
1614    Ok(())
1615}
1616
1617/// The error for an admin call that produced no pools, explaining which file
1618/// the answer would have come from.
1619fn unreachable_daemon_error(
1620    args: &cli::PoolListArgs,
1621    data_dir: &Path,
1622    buyer_db: &Path,
1623    url: &str,
1624    err: jsonrpsee::core::client::Error,
1625) -> anyhow::Error {
1626    {
1627        // A `Call` error means the daemon ANSWERED and refused — it is up, so
1628        // telling the operator to start it would send them the wrong way. Only
1629        // the transport and timeout arms warrant that advice.
1630        let answered = matches!(err, jsonrpsee::core::client::Error::Call(_));
1631        let classified = crate::commands::node::classify_client_error(url, args.timeout_ms, err);
1632        if answered {
1633            classified.context(format!(
1634                "{} is a decdn-node data dir, so its buyer pools live in {}, which this command \
1635                 reads through the daemon — a running daemon holds that file exclusively. The \
1636                 daemon answered and refused the read.",
1637                data_dir.display(),
1638                buyer_db.display(),
1639            ))
1640        } else {
1641            classified.context(format!(
1642                "{} is a decdn-node data dir, so the pools that matter are in {} — which a \
1643                 running daemon holds exclusively and no other process can open. This command \
1644                 therefore asks the daemon, and the daemon did not answer. Start decdn-node, or \
1645                 pass --data-dir <client dir> to inspect a client store instead.",
1646                data_dir.display(),
1647                buyer_db.display(),
1648            ))
1649        }
1650    }
1651}
1652
1653/// Read a stopped daemon's `buyer.redb` off disk, or say why that is not what
1654/// is happening.
1655///
1656/// Three outcomes, and the third is why this is not simply a fallback:
1657///
1658/// - the file opens read-only, so no daemon holds it: render it, labelled as a
1659///   disk read, so provenance stays on the output.
1660/// - the file is write-locked, so a daemon IS running: the admin URL is wrong,
1661///   not the node down. Say that instead — it is the answer the operator needs.
1662/// - anything else: the original unreachable-daemon error, with what the disk
1663///   read found appended, because "no daemon answered AND the store will not
1664///   open" is two facts, not one.
1665fn list_from_stopped_daemon(
1666    args: &cli::PoolListArgs,
1667    data_dir: &Path,
1668    buyer_db: &Path,
1669    url: &str,
1670    transport: &(dyn std::error::Error + Send + Sync + 'static),
1671) -> anyhow::Result<()> {
1672    match ReadOnlyBuyerPoolStore::open_file(buyer_db) {
1673        Ok(reader) => {
1674            let load = reader.load_all()?;
1675            write_store_listing(
1676                args,
1677                buyer_db,
1678                SOURCE_NODE_STORE_OFFLINE,
1679                " (read from disk; no daemon running)",
1680                load,
1681            )
1682        }
1683        Err(decdn_incentive::StoreError::AlreadyOpen { .. }) => anyhow::bail!(
1684            "admin at {url} refused the connection ({transport}), but a process holds {} \
1685             exclusively — so a decdn-node IS running against {}. The admin URL or admin_port \
1686             is wrong, not the node down. Pass --admin-url, or check admin_port in the node's \
1687             config.",
1688            buyer_db.display(),
1689            data_dir.display(),
1690        ),
1691        // A crashed daemon is both unreachable AND unrepaired, so this is the
1692        // common post-mortem case, not a footnote on the generic failure. The
1693        // remedy names the process that owns the file, which the store cannot.
1694        Err(decdn_incentive::StoreError::NeedsRepair { .. }) => anyhow::bail!(
1695            "admin at {url} refused the connection ({transport}), and {} was not shut down \
1696             cleanly — it needs a repair pass that only a writable open can run. Start \
1697             decdn-node once against {} to repair it, then read it again.",
1698            buyer_db.display(),
1699            data_dir.display(),
1700        ),
1701        // No store at all is an answer, not a failure to report: this node has
1702        // never opened a pool, or the store was deleted to force re-adoption.
1703        Err(decdn_incentive::StoreError::Absent { .. }) => anyhow::bail!(
1704            "admin at {url} refused the connection ({transport}), and {} does not exist — this \
1705             node holds no buyer pools on disk. It has not opened one, or the store was deleted \
1706             to force re-adoption. Start decdn-node and read it again, or run `decdn pool list \
1707             --all` to see what this keystore owns on chain.",
1708            buyer_db.display(),
1709        ),
1710        Err(store_err) => Err(anyhow::anyhow!(
1711            "admin at {url} refused the connection ({transport}); is the node running, and is \
1712             admin_port configured correctly? Reading {} from disk instead did not work either: \
1713             {store_err}",
1714            buyer_db.display(),
1715        )
1716        .context(format!(
1717            "{} is a decdn-node data dir, so the pools that matter are in {} — not in a client \
1718             store. Pass --data-dir <client dir> to inspect a client store instead.",
1719            data_dir.display(),
1720            buyer_db.display(),
1721        ))),
1722    }
1723}
1724
1725/// One pool as the chain describes it, plus whether the local record has it.
1726///
1727/// No `Debug`: the `sol!`-generated `Status` has none. [`status_label`] is the
1728/// spelling anything user-facing wants anyway.
1729struct ChainPoolRow {
1730    /// On-chain `poolId`.
1731    pool_id: PoolId,
1732    /// Lifecycle state from `getPool`.
1733    status: PaymentPool::Status,
1734    /// Escrowed deposit, in micro-USDC.
1735    deposit: U256,
1736    /// Cumulative amount redeemed against the pool, in micro-USDC.
1737    total_redeemed: U256,
1738    /// Absolute Unix deadline after which a `Closing` pool is reclaimable. `0`
1739    /// while the pool is still `Open`.
1740    dispute_deadline: u64,
1741    /// Whether the local record tracks this pool; `None` when it gave no usable
1742    /// answer for this pool. See [`TrackedPools::verdict`].
1743    tracked: Option<bool>,
1744}
1745
1746/// Wire spelling of a `getPool` status, matching the lowercase vocabulary the
1747/// rest of the `pool` output uses.
1748pub(crate) const fn status_label(status: PaymentPool::Status) -> &'static str {
1749    match status {
1750        PaymentPool::Status::Open => "open",
1751        PaymentPool::Status::Closing => "closing",
1752        PaymentPool::Status::Closed => "closed",
1753        // `sol!` enums carry a hidden invalid variant.
1754        _ => "?",
1755    }
1756}
1757
1758/// When the pool's residual can be reclaimed, in the operator's terms.
1759fn reclaimable_label(row: &ChainPoolRow, now: u64) -> String {
1760    match plan_reclaim(row.status, row.dispute_deadline, now) {
1761        ReclaimPlan::Reclaim => "now".to_string(),
1762        ReclaimPlan::SkipOpen => "close it first".to_string(),
1763        ReclaimPlan::SkipInWindow(deadline) => format_expiry(deadline, now),
1764        ReclaimPlan::SkipClosed => "already reclaimed".to_string(),
1765        ReclaimPlan::SkipUnknown => "?".to_string(),
1766    }
1767}
1768
1769/// `yes` / `no` / `?`, where `?` means the local record gave no usable answer
1770/// for this pool — the whole store was unreadable, or it holds a row for this
1771/// pool that will not decode. Neither is the claim "this pool is untracked",
1772/// and the remedies differ: one is a lost store, the other a record to repair.
1773const fn tracked_label(tracked: Option<bool>) -> &'static str {
1774    match tracked {
1775        Some(true) => "yes",
1776        Some(false) => "no",
1777        None => "?",
1778    }
1779}
1780
1781/// Render `--all` as the aligned table.
1782///
1783/// Same conventions as [`write_pools`]: a `key=value` summary line first so
1784/// scripts can grep without `--json`, a sentinel when there is nothing, and the
1785/// one variable-width column last and unpadded.
1786fn write_chain_pools(
1787    w: &mut impl Write,
1788    owner: Address,
1789    chain_id: u64,
1790    rows: &[ChainPoolRow],
1791    now: u64,
1792    local_store_read: bool,
1793) -> std::io::Result<()> {
1794    // `local_store=` is what separates a column of `?` that means "no local
1795    // answer at all" from one that means "these rows will not decode". Without
1796    // it the table is silently lossy about the one condition `--all` exists to
1797    // surface, and only `--json` carries the distinction.
1798    writeln!(
1799        w,
1800        "owner={owner:#x} chain_id={chain_id} pools={} local_store={}",
1801        rows.len(),
1802        if local_store_read {
1803            "read"
1804        } else {
1805            "unreadable"
1806        },
1807    )?;
1808    if rows.is_empty() {
1809        writeln!(w, "(this keystore owns no pools on this contract)")?;
1810        return Ok(());
1811    }
1812    writeln!(
1813        w,
1814        "{:<14} {:<8} {:>14} {:>14} {:>7} RECLAIMABLE",
1815        "POOL", "STATUS", "DEPOSIT", "REDEEMED", "TRACKED"
1816    )?;
1817    for row in rows {
1818        writeln!(
1819            w,
1820            "{:<14} {:<8} {:>14} {:>14} {:>7} {}",
1821            short_hex(&format!("{:#x}", row.pool_id)),
1822            status_label(row.status),
1823            format_usdc_u256(row.deposit),
1824            format_usdc_u256(row.total_redeemed),
1825            tracked_label(row.tracked),
1826            reclaimable_label(row, now),
1827        )?;
1828    }
1829    Ok(())
1830}
1831
1832/// `--all` as JSON.
1833///
1834/// A third shape beside [`PoolListJson`] and [`DaemonPoolListJson`], and
1835/// deliberately so: these rows come from the chain, carry lifecycle fields the
1836/// stores do not hold, and carry no lanes. Read `source` before `pools`.
1837#[derive(Serialize)]
1838struct PoolListAllJson {
1839    /// Always [`SOURCE_CHAIN`].
1840    source: &'static str,
1841    /// The keystore address the pools were enumerated by.
1842    owner: String,
1843    /// Chain id the contract was read on.
1844    chain_id: u64,
1845    /// `PaymentPool` address the pools live in.
1846    payment_pool: String,
1847    /// Whether the local record could be read at all. When `false`, every
1848    /// pool's `tracked` is `null` and says nothing about the pool. When `true`,
1849    /// a `null` `tracked` is specific to that pool: its row will not decode.
1850    local_store_read: bool,
1851    /// Every pool the owner holds, in the order the contract enumerates them.
1852    pools: Vec<ChainPoolJson>,
1853}
1854
1855/// One `--all` pool.
1856#[derive(Serialize)]
1857struct ChainPoolJson {
1858    /// On-chain `poolId`, `0x`-prefixed hex.
1859    pool_id: String,
1860    /// `open`, `closing`, `closed`, or `?`.
1861    status: &'static str,
1862    /// Escrowed deposit, in micro-USDC.
1863    deposit_micro_usdc: String,
1864    /// Cumulative redeemed amount, in micro-USDC.
1865    total_redeemed_micro_usdc: String,
1866    /// Absolute Unix deadline after which the residual is reclaimable; `0`
1867    /// while the pool is `Open`.
1868    dispute_deadline: u64,
1869    /// True once the dispute window has elapsed and the pool is `Closing`.
1870    reclaimable_now: bool,
1871    /// Whether the local record tracks this pool; `null` when the record gave
1872    /// no usable answer — an unreadable store, or a row for this pool that will
1873    /// not decode. Read `local_store_read` to tell the two apart.
1874    tracked: Option<bool>,
1875}
1876
1877impl ChainPoolJson {
1878    /// Project a row as of `now`, so the time-dependent field is computed once
1879    /// against the same clock the table uses.
1880    fn at(row: &ChainPoolRow, now: u64) -> Self {
1881        Self {
1882            pool_id: format!("{:#x}", row.pool_id),
1883            status: status_label(row.status),
1884            deposit_micro_usdc: row.deposit.to_string(),
1885            total_redeemed_micro_usdc: row.total_redeemed.to_string(),
1886            dispute_deadline: row.dispute_deadline,
1887            reclaimable_now: matches!(
1888                plan_reclaim(row.status, row.dispute_deadline, now),
1889                ReclaimPlan::Reclaim
1890            ),
1891            tracked: row.tracked,
1892        }
1893    }
1894}
1895
1896/// `source` value for a listing read directly from the CLI's own store.
1897const SOURCE_CLIENT_STORE: &str = "client_store";
1898/// `source` value for a listing the running daemon answered.
1899const SOURCE_DAEMON: &str = "daemon";
1900/// `source` value for a listing read off a stopped daemon's own store file.
1901///
1902/// Its own value, never [`SOURCE_CLIENT_STORE`], even though the row shape is
1903/// identical: the two come from different files with unrelated contents, and
1904/// conflating them is the whole defect this area exists to prevent.
1905const SOURCE_NODE_STORE_OFFLINE: &str = "node_store_offline";
1906/// `source` value for the chain-authoritative `--all` listing.
1907const SOURCE_CHAIN: &str = "chain";
1908
1909/// Warn about every undecodable buyer row on stderr. The `pool_id` (the store's
1910/// primary key) is the only available repair handle.
1911fn write_skipped_pools(w: &mut impl Write, skipped: &[PoolId]) -> std::io::Result<()> {
1912    for pool_id in skipped {
1913        writeln!(
1914            w,
1915            "warning: buyer pool {pool_id:#x} could not be decoded; its deposit remains \
1916             escrowed but untracked until the record is repaired"
1917        )?;
1918    }
1919    Ok(())
1920}
1921
1922/// Render the tracked buyer pools as an aligned table. Pure (writes to any
1923/// sink) so the layout is unit-testable without a store.
1924fn write_pools(
1925    w: &mut impl Write,
1926    pools: &[BuyerPoolState],
1927    skipped: &[PoolId],
1928) -> std::io::Result<()> {
1929    writeln!(w, "pools={}", pools.len())?;
1930    if pools.is_empty() {
1931        if skipped.is_empty() {
1932            writeln!(w, "(no tracked pools)")?;
1933        }
1934        return Ok(());
1935    }
1936    writeln!(
1937        w,
1938        "{:<14} {:<14} {:<14} {:>14} {:>8}",
1939        "POOL", "OWNER", "TOKEN", "DEPOSIT", "LANES"
1940    )?;
1941    for p in pools {
1942        writeln!(
1943            w,
1944            "{:<14} {:<14} {:<14} {:>14} {:>8}",
1945            short_hex(&format!("{:#x}", p.pool_id)),
1946            short_hex(&format!("{:#x}", p.owner)),
1947            short_hex(&format!("{:#x}", p.token)),
1948            format_usdc_u256(p.deposit),
1949            p.lane_count(),
1950        )?;
1951    }
1952    Ok(())
1953}
1954
1955/// Render a daemon's buyer-pool snapshot as an aligned table, in the same
1956/// columns [`write_pools`] uses for the client store, so an operator reading
1957/// both sees the same shape. Pure (writes to any sink) so the layout is
1958/// unit-testable without an admin hop.
1959///
1960/// Addresses arrive EIP-55 checksummed from the wire, where the client path
1961/// renders them lowercase — the columns match, the casing does not.
1962///
1963/// Writes the table only. Undecodable rows go to stderr through
1964/// [`write_skipped_buyer_pools`], as they do on the client path, so a script
1965/// capturing stdout does not find warning lines ahead of `pools=`.
1966///
1967/// Shared by `decdn node pools` and by `decdn pool list` when it routes to a
1968/// running daemon.
1969pub(crate) fn write_buyer_pools(
1970    w: &mut impl Write,
1971    resp: &BuyerPoolsResponse,
1972) -> std::io::Result<()> {
1973    writeln!(w, "pools={}", resp.pools.len())?;
1974    if resp.pools.is_empty() {
1975        if resp.skipped.is_empty() {
1976            writeln!(w, "(no tracked pools)")?;
1977        }
1978        return Ok(());
1979    }
1980    writeln!(
1981        w,
1982        "{:<14} {:<14} {:<14} {:>14} {:>8}",
1983        "POOL", "OWNER", "TOKEN", "DEPOSIT", "LANES"
1984    )?;
1985    for p in &resp.pools {
1986        writeln!(
1987            w,
1988            "{:<14} {:<14} {:<14} {:>14} {:>8}",
1989            short_hex(&p.pool_id),
1990            short_hex(&p.owner),
1991            short_hex(&p.token),
1992            format_usdc_u256(U256::from(p.deposit_micro_usdc)),
1993            p.lanes.len(),
1994        )?;
1995    }
1996    Ok(())
1997}
1998
1999/// Warn about every undecodable row in a daemon's answer, on stderr — the
2000/// stream [`write_skipped_pools`] uses for the client store's equivalent.
2001pub(crate) fn write_skipped_buyer_pools(
2002    w: &mut impl Write,
2003    skipped: &[String],
2004) -> std::io::Result<()> {
2005    for pool_id in skipped {
2006        writeln!(
2007            w,
2008            "warning: buyer pool {pool_id} could not be decoded; its deposit remains escrowed but \
2009             untracked until the record is repaired"
2010        )?;
2011    }
2012    Ok(())
2013}
2014
2015/// Top-level `--json` document for a listing of the CLI's own store.
2016///
2017/// `store` and `source` are the half a machine consumer needs to know *which*
2018/// buyer store answered: the same command on the same host can read either
2019/// file, and they are unrelated (#2078).
2020#[derive(Serialize)]
2021struct PoolListJson {
2022    store: String,
2023    source: &'static str,
2024    pools: Vec<PoolJson>,
2025    skipped: Vec<String>,
2026}
2027
2028/// Top-level `--json` document for a listing the daemon answered.
2029///
2030/// Borrows the admin DTOs rather than re-shaping them, so the wire types stay
2031/// the single definition of the daemon's answer. The consequence is that the
2032/// two sources do NOT emit the same pool objects: this one carries
2033/// `deposit_micro_usdc` as a number and EIP-55 addresses, where a client-store
2034/// listing carries `deposit_usdc` as a decimal string and lowercase addresses.
2035/// Read `source` before parsing `pools`.
2036#[derive(Serialize)]
2037struct DaemonPoolListJson<'a> {
2038    store: String,
2039    source: &'static str,
2040    pools: &'a [decdn_common::admin::BuyerPoolSnapshot],
2041    skipped: &'a [String],
2042}
2043
2044/// Serializable view for `--json`, including the per-lane watermark so a
2045/// programmatic consumer sees the same fan-out the table's `LANES` count only
2046/// summarizes.
2047#[derive(Serialize)]
2048struct PoolJson {
2049    pool_id: String,
2050    chain_id: u64,
2051    payment_pool: String,
2052    owner: String,
2053    token: String,
2054    deposit_usdc: String,
2055    lanes: Vec<LaneJson>,
2056}
2057
2058#[derive(Serialize)]
2059struct LaneJson {
2060    signer: String,
2061    provider: String,
2062    last_amount_usdc: String,
2063    last_bytes_delivered: String,
2064}
2065
2066impl From<&BuyerPoolState> for PoolJson {
2067    fn from(p: &BuyerPoolState) -> Self {
2068        Self {
2069            pool_id: format!("{:#x}", p.pool_id),
2070            chain_id: p.deployment.chain_id,
2071            payment_pool: format!("{:#x}", p.deployment.payment_pool),
2072            owner: format!("{:#x}", p.owner),
2073            token: format!("{:#x}", p.token),
2074            deposit_usdc: format_usdc_u256(p.deposit),
2075            lanes: p
2076                .lanes()
2077                .map(|(lane, progress)| LaneJson {
2078                    signer: format!("{:#x}", lane.signer),
2079                    provider: format!("{:#x}", lane.provider),
2080                    last_amount_usdc: format_usdc_u256(progress.last_amount),
2081                    last_bytes_delivered: progress.last_bytes.to_string(),
2082                })
2083                .collect(),
2084        }
2085    }
2086}
2087
2088/// Format a `U256` micro-USDC amount as `"N.NNNNNN"`. USDC has 6 decimals
2089/// (ADR 003), so 1 USDC == `1_000_000` micro-USDC.
2090fn format_usdc_u256(micro: U256) -> String {
2091    let scale = U256::from(1_000_000u64);
2092    let whole = micro / scale;
2093    // `micro % scale < 1_000_000`, so the `u64` narrowing is always exact.
2094    let frac = (micro % scale).to::<u64>();
2095    format!("{whole}.{frac:06}")
2096}
2097
2098/// Abbreviate a `0x`-prefixed hex string to `0x` + the first 10 nibbles + `…`
2099/// for table display; short inputs pass through unchanged. Uses `get(..)` to
2100/// stay within the workspace `indexing_slicing` clippy denial.
2101fn short_hex(hex: &str) -> String {
2102    match hex.get(..12) {
2103        Some(prefix) if hex.len() > 12 => format!("{prefix}…"),
2104        _ => hex.to_string(),
2105    }
2106}
2107
2108#[cfg(test)]
2109#[allow(clippy::unwrap_used, clippy::expect_used)]
2110mod tests;