Skip to content

Commit bfedcf1

Browse files
feat(validator-debt): better summarized debt (malbeclabs/doublezero-offchain#218)
## Summary of Changes This PR adds in more details for when debt collection is posted to slack and also summarizes debt collection for all epochs. It moves some of the debt-related slack composition to the validator-debt crate so we can reuse the existing structs instead of a many-arity function. It also adds total debt, total paid, and insufficient payment counts to the pay debt GenServer which are then posted to slack at the end of a debt collection run. Closes #2256. ## Testing Verification * This was tested locally in a private slack channel: <img width="1269" height="702" alt="Screenshot 2025-12-02 at 17 19 46" src="https://github.com/user-attachments/assets/a8f75951-01e0-4ba4-a4f6-9d8cf2657dab" />
1 parent 5800f16 commit bfedcf1

12 files changed

Lines changed: 263 additions & 105 deletions

File tree

offchain/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

offchain/crates/slack-notifier/src/validator_debt.rs

Lines changed: 5 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub async fn post_distribution_to_slack(
3939
transaction.unwrap_or("No transaction details".to_string()),
4040
];
4141

42-
post_to_slack(filepath, client, header, table_header, table_values).await?;
42+
post_to_slack(filepath, &client, header, table_header, table_values).await?;
4343

4444
Ok(())
4545
}
@@ -60,51 +60,14 @@ pub async fn post_finalized_distribution_to_slack(
6060

6161
let table_values = vec![dz_epoch.to_string(), finalized_sig.to_string()];
6262

63-
post_to_slack(None, client, header, table_header, table_values).await?;
63+
post_to_slack(None, &client, header, table_header, table_values).await?;
6464

6565
Ok(())
6666
}
6767

68-
pub async fn post_debt_collection_to_slack(
69-
total_transactions: usize,
70-
total_success: usize,
71-
insufficient_funds: usize,
72-
already_paid: usize,
73-
dz_epoch: u64,
74-
filepath: Option<String>,
75-
dry_run: bool,
76-
) -> Result<()> {
77-
let client = reqwest::Client::new();
78-
let header = if dry_run {
79-
"DRY RUN Debt Collected DRY RUN"
80-
} else {
81-
"Debt Collected"
82-
};
83-
84-
let table_header = vec![
85-
"DoubleZero Epoch".to_string(),
86-
"Total Attempted Transactions".to_string(),
87-
"Successful Transactions".to_string(),
88-
"Insufficient Funds".to_string(),
89-
"Already Paid".to_string(),
90-
];
91-
92-
let table_values = vec![
93-
dz_epoch.to_string(),
94-
total_transactions.to_string(),
95-
total_success.to_string(),
96-
insufficient_funds.to_string(),
97-
already_paid.to_string(),
98-
];
99-
100-
post_to_slack(filepath, client, header, table_header, table_values).await?;
101-
102-
Ok(())
103-
}
104-
105-
async fn post_to_slack(
68+
pub async fn post_to_slack(
10669
filepath: Option<String>,
107-
client: Client,
70+
client: &Client,
10871
header: &str,
10972
mut table_header: Vec<String>,
11073
mut table_values: Vec<String>,
@@ -121,7 +84,7 @@ async fn post_to_slack(
12184

12285
let payload = serde_json::to_string(&msg)?;
12386
let body = Body::from(payload);
124-
let request = slack::build_message_request(&client, body, slack_webhook()?)?;
87+
let request = slack::build_message_request(client, body, slack_webhook()?)?;
12588
let _resp = request.send().await?;
12689

12790
Ok(())

offchain/crates/solana-cli/src/command/revenue_distribution/relay/mod.rs

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ use doublezero_solana_client_tools::{
1313
rpc::DoubleZeroLedgerConnection,
1414
};
1515
use doublezero_solana_validator_debt::worker;
16-
use slack_notifier::validator_debt;
1716

1817
#[derive(Debug, Clone, ValueEnum)]
1918
pub enum ExportFormat {
@@ -89,23 +88,15 @@ async fn execute_pay_solana_validator_debt(
8988
};
9089
let mut writer = csv::Writer::from_path(string_filename.clone())?;
9190

92-
for tx_result in tx_results.collection_results {
91+
for tx_result in tx_results.collection_results.clone() {
9392
writer.serialize(tx_result)?;
9493
}
9594
filename = Some(string_filename);
9695
writer.flush()?;
9796
};
97+
9898
if let Some(ExportFormat::Slack) = export {
99-
validator_debt::post_debt_collection_to_slack(
100-
tx_results.total_transactions_attempted,
101-
tx_results.successful_transactions,
102-
tx_results.insufficient_funds,
103-
tx_results.already_paid,
104-
epoch,
105-
filename,
106-
dry_run,
107-
)
108-
.await?;
99+
worker::post_debt_collection_to_slack(tx_results, dry_run, filename).await?;
109100
}
110101

111102
Ok(())

offchain/crates/validator-debt/src/transaction.rs

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,16 @@ pub struct Transaction {
4242
pub force: bool,
4343
}
4444

45-
#[derive(Debug, Serialize)]
45+
#[derive(Clone, Debug, Serialize)]
4646
pub struct DebtCollectionResults {
47+
pub dz_epoch: u64,
4748
pub collection_results: Vec<DebtCollectionResult>,
48-
pub total_transactions_attempted: usize,
49-
pub successful_transactions: usize,
50-
pub insufficient_funds: usize,
51-
pub already_paid: usize,
49+
pub insufficient_funds: Vec<DebtCollectionResult>,
50+
pub already_paid: Vec<DebtCollectionResult>,
51+
pub successful_transactions: Vec<DebtCollectionResult>,
5252
}
5353

54-
#[derive(Debug, Serialize)]
54+
#[derive(Clone, Debug, Serialize)]
5555
pub struct DebtCollectionResult {
5656
pub validator_id: String,
5757
pub amount: u64,
@@ -362,23 +362,24 @@ impl Transaction {
362362
}
363363
}
364364

365-
let total_transactions = debt_collection_result.len();
366-
let total_success = debt_collection_result
367-
.iter()
365+
let successful_transactions: Vec<DebtCollectionResult> = debt_collection_result
366+
.clone()
367+
.into_iter()
368368
.filter(|pr| pr.success)
369-
.count();
369+
.collect();
370370

371-
let insufficient_funds_count =
372-
count_failed_debt_collection("Insufficient funds", debt_collection_result.as_ref());
373-
let already_paid_count =
374-
count_failed_debt_collection("Merkle leaf", debt_collection_result.as_ref());
371+
let already_paid =
372+
count_failed_debt_collection("Merkle leaf", debt_collection_result.clone());
373+
374+
let insufficient_funds =
375+
count_failed_debt_collection("Insufficient funds", debt_collection_result.clone());
375376

376377
let debt_collection_results = DebtCollectionResults {
378+
dz_epoch,
379+
successful_transactions,
377380
collection_results: debt_collection_result,
378-
total_transactions_attempted: total_transactions,
379-
successful_transactions: total_success,
380-
insufficient_funds: insufficient_funds_count,
381-
already_paid: already_paid_count,
381+
insufficient_funds,
382+
already_paid,
382383
};
383384
Ok(debt_collection_results)
384385
}
@@ -402,14 +403,17 @@ impl Transaction {
402403
}
403404
}
404405

405-
fn count_failed_debt_collection(error_type: &str, dcr: &[DebtCollectionResult]) -> usize {
406-
dcr.iter()
406+
fn count_failed_debt_collection(
407+
error_type: &str,
408+
dcr: Vec<DebtCollectionResult>,
409+
) -> Vec<DebtCollectionResult> {
410+
dcr.into_iter()
407411
.filter(|pr| {
408412
pr.result
409413
.as_ref()
410414
.is_some_and(|res| res.contains(error_type))
411415
})
412-
.count()
416+
.collect()
413417
}
414418

415419
fn parse_program_logs(

offchain/crates/validator-debt/src/worker.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,81 @@ pub async fn initialize_distribution(
522522
Ok(())
523523
}
524524

525+
pub async fn post_debt_collection_to_slack(
526+
debt_collection_results: DebtCollectionResults,
527+
dry_run: bool,
528+
filepath: Option<String>,
529+
) -> Result<()> {
530+
let client = reqwest::Client::new();
531+
let header = if dry_run {
532+
"DRY RUN Debt Collected DRY RUN"
533+
} else {
534+
"Debt Collected"
535+
};
536+
537+
let table_header = vec![
538+
"DoubleZero Epoch".to_string(),
539+
"Total Paid".to_string(),
540+
"Total Debt".to_string(),
541+
"Percentage Paid".to_string(),
542+
"Total Attempted Transactions".to_string(),
543+
"Successful Transactions".to_string(),
544+
"Insufficient Funds".to_string(),
545+
"Already Paid".to_string(),
546+
];
547+
548+
let total_attempted_transactions_count = debt_collection_results.collection_results.len();
549+
let successful_transactions_count = debt_collection_results.successful_transactions.len();
550+
let already_paid_count = debt_collection_results.already_paid.len();
551+
552+
let percentage_paid: f64 = if total_attempted_transactions_count == 0 {
553+
0.0
554+
} else {
555+
(already_paid_count + successful_transactions_count) as f64
556+
/ total_attempted_transactions_count as f64
557+
};
558+
559+
// the total amount paid for an epoch is `total_collected_this_run` + `already_paid`
560+
let already_paid_total: u64 = debt_collection_results
561+
.already_paid
562+
.iter()
563+
.map(|ap| ap.amount)
564+
.sum();
565+
let total_collected_this_run: u64 = debt_collection_results
566+
.successful_transactions
567+
.iter()
568+
.map(|ap| ap.amount)
569+
.sum();
570+
571+
let total_paid = already_paid_total + total_collected_this_run;
572+
let total_debt: u64 = debt_collection_results
573+
.collection_results
574+
.iter()
575+
.map(|cr| cr.amount)
576+
.sum();
577+
let table_values = vec![
578+
debt_collection_results.dz_epoch.to_string(),
579+
total_paid.to_string(),
580+
total_debt.to_string(),
581+
format!("{:.2}%", percentage_paid * 100.0),
582+
total_attempted_transactions_count.to_string(),
583+
successful_transactions_count.to_string(),
584+
debt_collection_results.insufficient_funds.len().to_string(),
585+
already_paid_count.to_string(),
586+
];
587+
588+
slack_notifier::validator_debt::post_to_slack(
589+
filepath,
590+
&client,
591+
header,
592+
table_header,
593+
table_values,
594+
)
595+
.await?;
596+
597+
Ok(())
598+
}
599+
525600
async fn create_or_validate_ledger_record(
526601
solana_debt_calculator: &impl ValidatorRewards,
527602
transaction: &Transaction,

offchain/scheduler/CHANGELOG.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
- feat(summarize_debt): Summarize debt for each epoch and then for all epochs ([#218](https://github.com/doublezerofoundation/doublezero-offchain/pull/218))
810

911
## [v0.1.3]
10-
- feat(deploy service): Add deploy steps through actions and goreleaser ([#205]https://github.com/doublezerofoundation/doublezero-offchain/pull/205)
11-
- feat(automate_finalize_distribution):
12-
- feat(automate_calculate_distribution): Add GenServer and Rust NIF to automatically calculate a distribution on a configurable interval ([#197]https://github.com/doublezerofoundation/doublezero-offchain/pull/197)
13-
- feat(automate_initialize_distribution): Add GenServer and Rust NIF to automatically initialize a distribution on a configurable interval ([#197]https://github.com/doublezerofoundation/doublezero-offchain/pull/197)
14-
- feat(automate_debt_payment): Add GenServer and Rust NIF to automatically collect debt on a configurable interval ([#183]https://github.com/doublezerofoundation/doublezero-offchain/pull/183)
15-
- feat(scheduler): add Elixir app that manages scheduling and executing Rust processes for debt collection and payment ([#183]https://github.com/doublezerofoundation/doublezero-offchain/pull/183)
12+
- feat(deploy service): Add deploy steps through actions and goreleaser ([#205](https://github.com/doublezerofoundation/doublezero-offchain/pull/205))
13+
- feat(automate_finalize_distribution): Update calculate distribution GenServer to finalize distribution through a Rust NIF ([#200](https://github.com/doublezerofoundation/doublezero-offchain/pull/200))
14+
- feat(automate_calculate_distribution): Add GenServer and Rust NIF to automatically calculate a distribution on a configurable interval ([#199](https://github.com/doublezerofoundation/doublezero-offchain/pull/199))
15+
- feat(automate_initialize_distribution): Add GenServer and Rust NIF to automatically initialize a distribution on a configurable interval ([#197](https://github.com/doublezerofoundation/doublezero-offchain/pull/197))
16+
- feat(automate_debt_payment): Add GenServer and Rust NIF to automatically collect debt on a configurable interval ([#183](https://github.com/doublezerofoundation/doublezero-offchain/pull/183))
17+
- feat(scheduler): add Elixir app that manages scheduling and executing Rust processes for debt collection and payment ([#183](https://github.com/doublezerofoundation/doublezero-offchain/pull/183)

offchain/scheduler/lib/scheduler/scheduler_doublezero.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,7 @@ defmodule Scheduler.DoubleZero do
1111
do: :erlang.nif_error(:nif_not_loaded)
1212

1313
def current_dz_epoch(_ledger_rpc), do: :erlang.nif_error(:nif_not_loaded)
14+
15+
def post_debt_summary(_insufficient_funds_count, _total_debt, _total_paid),
16+
do: :erlang.nif_error(:nif_not_loaded)
1417
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
defmodule Scheduler.ValidatorDebt.DebtCollection do
2+
defstruct total_paid: 0,
3+
total_debt: 0,
4+
total_validators: 0,
5+
insufficient_funds_count: 0
6+
end
7+
8+
defmodule Scheduler.ValidatorDebt.Debt do
9+
defstruct [:validator_id, :amount, :result, :success]
10+
end

offchain/scheduler/lib/scheduler/worker/pay_debt.ex

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ defmodule Scheduler.Worker.PayDebt do
44
require Logger
55

66
def start_link(_var \\ []) do
7-
state = %{genesis_epoch: genesis_epoch(), current_epoch: genesis_epoch()}
7+
state = %{
8+
genesis_epoch: genesis_epoch(),
9+
current_epoch: genesis_epoch(),
10+
total_debt: 0,
11+
total_paid: 0,
12+
insufficient_funds_count: 0
13+
}
14+
815
GenServer.start_link(__MODULE__, state, name: __MODULE__)
916
end
1017

@@ -24,18 +31,34 @@ defmodule Scheduler.Worker.PayDebt do
2431
if String.contains?(error, "Record account not found at address") ||
2532
String.contains?(error, "Failed to fetch record") do
2633
Logger.info("scheduler completed sweep at epoch #{state.current_epoch}")
34+
35+
Scheduler.DoubleZero.post_debt_summary(
36+
state.insufficient_funds_count,
37+
state.total_debt,
38+
state.total_paid
39+
)
40+
2741
{:stop, :shutdown, state}
2842
else
2943
Logger.error(
3044
"scheduler encountered unexpected error at epoch #{state.current_epoch}: #{inspect(error)}"
3145
)
3246

33-
{:stop,:shutdown, state}
47+
{:stop, :shutdown, state}
3448
end
3549

36-
_ ->
50+
debt ->
3751
Logger.info("completed epoch #{state.current_epoch}")
38-
state = %{state | current_epoch: state.current_epoch + 1}
52+
53+
state = %{
54+
state
55+
| current_epoch: state.current_epoch + 1,
56+
total_debt: state.total_debt + debt.total_debt,
57+
total_paid: state.total_paid + debt.total_paid,
58+
insufficient_funds_count:
59+
state.insufficient_funds_count + debt.insufficient_funds_count
60+
}
61+
3962
{:noreply, state, {:continue, :queue_debt_payment}}
4063
end
4164
end

offchain/scheduler/mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ defmodule Scheduler.MixProject do
77
version: "0.1.0",
88
elixir: "~> 1.18",
99
start_permanent: Mix.env() == :prod,
10-
deps: deps(),
10+
deps: deps()
1111
]
1212
end
1313

0 commit comments

Comments
 (0)