Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions migrations/20260423120000_anti_abuse_bond.sql
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ CREATE TABLE IF NOT EXISTS bonds (
-- settle+refund at parent close. Unused (0) for child / non-range rows.
slashed_share_sats integer not null default 0,
-- BondState serialization: 'requested' | 'locked' | 'released' |
-- 'pending-payout' | 'slashed' | 'failed'
-- 'pending-payout' | 'slashed' | 'forfeited' | 'failed'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revert edits to previously shipped migration file

Changing migrations/20260423120000_anti_abuse_bond.sql (even comments) changes the SQLx migration checksum, so nodes that already applied version 20260423120000 will fail when startup runs migrator.run(&conn) on existing databases in src/db.rs (the checksum in _sqlx_migrations no longer matches the embedded migration). This turns a harmless enum/doc update into a runtime upgrade blocker for deployed instances; new behavior should be introduced via a new migration file instead of mutating an existing one.

Useful? React with 👍 / 👎.

state varchar(16) not null,
-- BondSlashReason: 'lost-dispute' | 'timeout'. NULL unless state in
-- ('pending-payout', 'slashed').
-- BondSlashReason: 'lost-dispute' | 'timeout'. Set on entry to
-- 'pending-payout' and never cleared, so non-NULL while state is
-- 'pending-payout', 'slashed', 'forfeited', or 'failed'.
slashed_reason varchar(16),
-- Bond hold invoice hash (hex). NULL until the hold invoice is created.
hash char(64),
Expand Down
40 changes: 29 additions & 11 deletions src/app/bond/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,18 @@ impl FromStr for BondRole {
///
/// ```text
/// Requested ──► Locked ──┬──► Released (happy / cancelled-before-timeout)
/// └──► PendingPayout ──┬──► Slashed (winner paid)
/// └──► Failed (retries exhausted)
/// └──► PendingPayout ──┬──► Slashed (winner paid)
/// ├──► Forfeited (winner never claimed in window)
/// └──► Failed (retries exhausted)
/// ```
///
/// A bond never goes back to an earlier state. `Failed` is a terminal,
/// operator-intervention-required state and is deliberately distinct from
/// `Slashed` so dashboards can alarm on it.
/// operator-intervention-required state (we have an invoice but
/// `send_payment` keeps failing). `Forfeited` is the long-stop terminal
/// state for a slash whose counterparty never submitted a payout invoice
/// within `payout_claim_window_days`; it is a *normal* outcome by design
/// (no operator action required), distinct from `Failed` so dashboards
/// can alarm correctly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BondState {
/// Hold invoice created; waiting for the bonded party to pay it so LND
Expand All @@ -61,21 +66,26 @@ pub enum BondState {
PendingPayout,
/// Winner paid successfully. Terminal.
Slashed,
/// Payout retries exhausted. Terminal, requires operator attention.
/// `payout_claim_window_days` elapsed without the counterparty ever
/// submitting a payout invoice; the node retains `amount_sats` in
/// full. Terminal — designed-in long-stop, no operator action needed.
Forfeited,
/// `send_payment` retries exhausted. Terminal, requires operator
/// attention.
Failed,
}

impl BondState {
/// True for states that should not be transitioned out of by Phase 1
/// release paths: the bond is already done with from the operator's
/// perspective. Used so call sites don't have to enumerate the trio
/// of `Released | Slashed | Failed` manually (and so the daemon
/// doesn't grow to depend on the [`Display`] string form for control
/// flow).
/// perspective. Used so call sites don't have to enumerate the four
/// of `Released | Slashed | Forfeited | Failed` manually (and so the
/// daemon doesn't grow to depend on the [`Display`] string form for
/// control flow).
pub fn is_terminal(self) -> bool {
matches!(
self,
BondState::Released | BondState::Slashed | BondState::Failed
BondState::Released | BondState::Slashed | BondState::Forfeited | BondState::Failed
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand All @@ -96,6 +106,7 @@ impl fmt::Display for BondState {
BondState::Released => "released",
BondState::PendingPayout => "pending-payout",
BondState::Slashed => "slashed",
BondState::Forfeited => "forfeited",
BondState::Failed => "failed",
};
f.write_str(s)
Expand All @@ -112,6 +123,7 @@ impl FromStr for BondState {
"released" => Ok(BondState::Released),
"pending-payout" => Ok(BondState::PendingPayout),
"slashed" => Ok(BondState::Slashed),
"forfeited" => Ok(BondState::Forfeited),
"failed" => Ok(BondState::Failed),
other => Err(BondParseError::UnknownState(other.to_string())),
}
Expand Down Expand Up @@ -189,6 +201,7 @@ mod tests {
BondState::Released,
BondState::PendingPayout,
BondState::Slashed,
BondState::Forfeited,
BondState::Failed,
] {
assert_eq!(BondState::from_str(&s.to_string()).unwrap(), s);
Expand All @@ -211,7 +224,12 @@ mod tests {

#[test]
fn terminal_and_active_helpers() {
for s in [BondState::Released, BondState::Slashed, BondState::Failed] {
for s in [
BondState::Released,
BondState::Slashed,
BondState::Forfeited,
BondState::Failed,
] {
assert!(s.is_terminal(), "{s} should be terminal");
assert!(!s.is_active(), "{s} should not be active");
}
Expand Down