1use 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
49pub 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#[derive(Debug)]
64struct Resolved {
65 rpc_url: String,
66 payment_pool: Address,
67 chain_id: u64,
68 data_dir: PathBuf,
69 keystore: PathBuf,
70 keystore_password_file: Option<PathBuf>,
74}
75
76impl Resolved {
77 const fn deployment(&self) -> Deployment {
79 Deployment {
80 chain_id: self.chain_id,
81 payment_pool: self.payment_pool,
82 }
83 }
84}
85
86fn 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
141fn 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
156fn 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
163fn 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
175async fn open(args: &cli::PoolOpenArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
179 let file = load_file_config(config_path)?;
180 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 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
222async 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 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#[derive(Serialize)]
271struct PoolOpenJson {
272 #[serde(rename = "poolId")]
273 pool_id: String,
274}
275
276async 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 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 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
319async 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 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#[derive(Clone, Copy)]
375enum LocalBookkeeping<'a> {
376 Store {
380 store: &'a dyn BuyerPoolStore,
382 deployment: Deployment,
384 },
385 DaemonOwned(&'a Path),
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394enum NotOpenClear {
395 Cleared,
397 Untouched,
400 Faulted,
403}
404
405impl NotOpenClear {
406 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 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 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 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 fn forget_after_reclaim(self, owner: Address, pool_id: PoolId) {
472 match self {
473 Self::Store { store, deployment } => {
474 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 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
550fn 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
568const fn no_longer_open(status: PaymentPool::Status) -> bool {
573 matches!(
574 status,
575 PaymentPool::Status::Closing | PaymentPool::Status::Closed
576 )
577}
578
579fn 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#[derive(Debug)]
634struct CloseUnrecorded {
635 pool_id: PoolId,
637 tx: TxHash,
639 reclaim_note: String,
641 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668enum TxOutcome {
669 Landed,
671 Reverted,
673}
674
675const fn receipt_outcome(landed: bool) -> TxOutcome {
678 if landed {
679 TxOutcome::Landed
680 } else {
681 TxOutcome::Reverted
682 }
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
688enum ClosePlan {
689 Close,
691 Skip(&'static str),
693}
694
695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum ReclaimPlan {
701 Reclaim,
703 SkipOpen,
705 SkipInWindow(u64),
708 SkipClosed,
710 SkipUnknown,
713}
714
715#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
720struct BatchTally {
721 acted: usize,
724 skipped: usize,
726 failed: usize,
728 unconfirmed: usize,
731 unrecorded: usize,
736}
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
740enum ErrorClass {
741 Failed,
743 Unconfirmed,
745 LandedUnrecorded,
748}
749
750impl BatchTally {
751 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784struct TxUnconfirmed {
785 label: &'static str,
787 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
814const 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 _ => ClosePlan::Skip("unknown on-chain status"),
823 }
824}
825
826const 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 _ => ReclaimPlan::SkipUnknown,
839 }
840}
841
842fn 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
884fn 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
919async 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 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 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 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
988async 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 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
1044const RECLAIM_REVERT_GUESS: &str =
1047 "most likely a concurrent reclaim; the USDC refund transfer to the owner can also revert";
1048
1049const RECLAIM_UNKNOWN_REVERT: &str = "not a PaymentPool lifecycle error; most likely the USDC \
1053 refund transfer to the owner reverted";
1054
1055fn 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
1064fn 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
1097fn 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
1112async fn close(args: &cli::PoolCloseArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
1117 let file = load_file_config(config_path)?;
1118 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 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
1157fn 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
1173async 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
1238async fn reclaim(args: &cli::PoolReclaimArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
1245 let file = load_file_config(config_path)?;
1246 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 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 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
1310fn 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
1340async 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
1434fn 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 _ => 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
1460const DEFAULT_NODE_EXPIRY_MARGIN_SECS: u64 =
1462 capability_expiry_margin_secs(DEFAULT_REDEEM_INTERVAL_SECS);
1463
1464const fn expires_within_default_node_margin(expiry: u64, now: u64) -> bool {
1469 expiry.saturating_sub(now) <= DEFAULT_NODE_EXPIRY_MARGIN_SECS
1470}
1471
1472fn 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
1486const HEAD_READ_TIMEOUT: Duration = Duration::from_secs(5);
1489
1490async 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
1523fn 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
1534async 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 let recovered = grant
1582 .owner(&domain)
1583 .map_err(|e| anyhow::anyhow!("re-recovering the owner from the fresh token failed: {e}"))?;
1584
1585 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
1616async 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1661enum OwnerVerdict {
1662 Owned,
1664 NoSuchPool,
1667 OwnedByOther(Address),
1669}
1670
1671fn 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 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
1709async 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
1737async 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1819enum RowState {
1820 Decoded,
1822 Undecodable,
1826}
1827
1828#[derive(Debug, Default)]
1838struct TrackedPools(std::collections::BTreeMap<PoolId, RowState>);
1839
1840impl TrackedPools {
1841 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 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 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
1903async 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 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
1959fn 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
1975fn 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
1987fn 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
2002async fn daemon_pool_ids(
2009 args: &cli::PoolListArgs,
2010 config_path: Option<&Path>,
2011) -> Option<TrackedPools> {
2012 if args.timeout_ms == 0 {
2013 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
2043fn 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
2051fn 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 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
2090async 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 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
2163fn 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 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
2199fn 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 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 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
2271struct ChainPoolRow {
2276 pool_id: PoolId,
2278 status: PaymentPool::Status,
2280 deposit: U256,
2282 total_redeemed: U256,
2284 dispute_deadline: u64,
2287 tracked: Option<bool>,
2290}
2291
2292pub(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 _ => "?",
2301 }
2302}
2303
2304fn 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
2315const fn tracked_label(tracked: Option<bool>) -> &'static str {
2320 match tracked {
2321 Some(true) => "yes",
2322 Some(false) => "no",
2323 None => "?",
2324 }
2325}
2326
2327fn 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 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#[derive(Serialize)]
2384struct PoolListAllJson {
2385 source: &'static str,
2387 owner: String,
2389 chain_id: u64,
2391 payment_pool: String,
2393 local_store_read: bool,
2397 pools: Vec<ChainPoolJson>,
2399}
2400
2401#[derive(Serialize)]
2403struct ChainPoolJson {
2404 pool_id: String,
2406 status: &'static str,
2408 deposit_micro_usdc: String,
2410 total_redeemed_micro_usdc: String,
2412 dispute_deadline: u64,
2415 reclaimable_now: bool,
2417 tracked: Option<bool>,
2421}
2422
2423impl ChainPoolJson {
2424 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
2442const SOURCE_CLIENT_STORE: &str = "client_store";
2444const SOURCE_DAEMON: &str = "daemon";
2446const SOURCE_NODE_STORE_OFFLINE: &str = "node_store_offline";
2452const SOURCE_CHAIN: &str = "chain";
2454
2455fn 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
2468fn 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
2501pub(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
2545pub(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#[derive(Serialize)]
2567struct PoolListJson {
2568 store: String,
2569 source: &'static str,
2570 pools: Vec<PoolJson>,
2571 skipped: Vec<String>,
2572}
2573
2574#[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#[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
2634fn format_usdc_u256(micro: U256) -> String {
2637 let scale = U256::from(1_000_000u64);
2638 let whole = micro / scale;
2639 let frac = (micro % scale).to::<u64>();
2641 format!("{whole}.{frac:06}")
2642}
2643
2644fn 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;