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