From 43c91dc165a6e99ff0d2ca2b4e08bc3a91b5d15d Mon Sep 17 00:00:00 2001 From: teor Date: Thu, 25 Sep 2025 14:46:00 +0200 Subject: [PATCH 1/9] Make large balance override force transfer --- chain-alerter/src/alerts/transfer.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/chain-alerter/src/alerts/transfer.rs b/chain-alerter/src/alerts/transfer.rs index ae5f433..4c426aa 100644 --- a/chain-alerter/src/alerts/transfer.rs +++ b/chain-alerter/src/alerts/transfer.rs @@ -312,10 +312,12 @@ pub async fn check_transfer_extrinsic( // - test force alerts by checking a historic block with that call // - do we want to track burn calls? this is // a low priority because it is already covered by balance events - if extrinsic_info.pallet == "Balances" && extrinsic_info.call.starts_with("force") { + if let Some(transfer_value) = transfer_value + && transfer_value >= MIN_BALANCE_CHANGE + { alert_tx .send(Alert::new( - AlertKind::ForceBalanceTransfer { + AlertKind::LargeBalanceTransfer { extrinsic_info: extrinsic_info.clone(), transfer_value, }, @@ -323,12 +325,10 @@ pub async fn check_transfer_extrinsic( mode, )) .await?; - } else if let Some(transfer_value) = transfer_value - && transfer_value >= MIN_BALANCE_CHANGE - { + } else if extrinsic_info.pallet == "Balances" && extrinsic_info.call.starts_with("force") { alert_tx .send(Alert::new( - AlertKind::LargeBalanceTransfer { + AlertKind::ForceBalanceTransfer { extrinsic_info: extrinsic_info.clone(), transfer_value, }, From 6915a38675b1ada8414f97a8a94deb5e0870774c Mon Sep 17 00:00:00 2001 From: teor Date: Thu, 25 Sep 2025 14:51:46 +0200 Subject: [PATCH 2/9] Make transfer alerts override important account alerts --- chain-alerter/src/alerts/tests.rs | 62 ++++++++-------- chain-alerter/src/alerts/transfer.rs | 107 +++++++++++++-------------- 2 files changed, 83 insertions(+), 86 deletions(-) diff --git a/chain-alerter/src/alerts/tests.rs b/chain-alerter/src/alerts/tests.rs index 96306a6..04cdc37 100644 --- a/chain-alerter/src/alerts/tests.rs +++ b/chain-alerter/src/alerts/tests.rs @@ -311,10 +311,11 @@ async fn test_important_address_alerts() -> anyhow::Result<()> { let (event, event_info) = decode_event(&block_info, &events, event_index).await?; alerts::check_extrinsic(BlockCheckMode::Replay, &alert_tx, &extrinsic, &block_info).await?; + let alert = alert_rx.try_recv().expect("no extrinsic alert received"); - // The order of these alerts is not actually important. + // There should only be one alert per extrinsic, and one per event, because more important + // alerts override less important/specific alerts. if extrinsic_transfer_value >= MIN_BALANCE_CHANGE { - let alert = alert_rx.try_recv().expect("no extrinsic alert received"); assert_eq!( alert, Alert::new( @@ -326,25 +327,25 @@ async fn test_important_address_alerts() -> anyhow::Result<()> { BlockCheckMode::Replay, ) ); + } else { + assert_eq!( + alert, + Alert::new( + AlertKind::ImportantAddressTransfer { + address_kinds: address_kinds.to_string(), + extrinsic_info, + transfer_value: Some(extrinsic_transfer_value), + }, + block_info, + BlockCheckMode::Replay, + ) + ); } - let alert = alert_rx.try_recv().expect("no extrinsic alert received"); - assert_eq!( - alert, - Alert::new( - AlertKind::ImportantAddressTransfer { - address_kinds: address_kinds.to_string(), - extrinsic_info, - transfer_value: Some(extrinsic_transfer_value), - }, - block_info, - BlockCheckMode::Replay, - ) - ); - alerts::check_event(BlockCheckMode::Replay, &alert_tx, &event, &block_info).await?; + let alert = alert_rx.try_recv().expect("no event alert received"); + if event_transfer_value >= MIN_BALANCE_CHANGE { - let alert = alert_rx.try_recv().expect("no event alert received"); assert_eq!( alert, Alert::new( @@ -356,22 +357,21 @@ async fn test_important_address_alerts() -> anyhow::Result<()> { BlockCheckMode::Replay, ) ); + } else { + assert_eq!( + alert, + Alert::new( + AlertKind::ImportantAddressTransferEvent { + address_kinds: address_kinds.to_string(), + event_info, + transfer_value: Some(event_transfer_value), + }, + block_info, + BlockCheckMode::Replay, + ) + ); } - let alert = alert_rx.try_recv().expect("no event alert received"); - assert_eq!( - alert, - Alert::new( - AlertKind::ImportantAddressTransferEvent { - address_kinds: address_kinds.to_string(), - event_info, - transfer_value: Some(event_transfer_value), - }, - block_info, - BlockCheckMode::Replay, - ) - ); - // Check block slot parsing works on a known slot value. assert_eq!(alert.block_info.slot, Some(slot)); } diff --git a/chain-alerter/src/alerts/transfer.rs b/chain-alerter/src/alerts/transfer.rs index 4c426aa..d9e5c47 100644 --- a/chain-alerter/src/alerts/transfer.rs +++ b/chain-alerter/src/alerts/transfer.rs @@ -305,8 +305,16 @@ pub async fn check_transfer_extrinsic( // - track the total of recent transfers, so the threshold can't be bypassed by splitting the // transfer into multiple calls let transfer_value = extrinsic_info.transfer_value(); + trace!(?mode, "transfer_value: {:?}", transfer_value); - debug!(?mode, "transfer_value: {:?}", transfer_value); + let important_address_kinds = extrinsic_info.important_address_kinds_str(); + trace!( + ?mode, + ?important_address_kinds, + ?extrinsic_info, + ?block_info, + "extrinsic account list", + ); // TODO: // - test force alerts by checking a historic block with that call @@ -336,45 +344,37 @@ pub async fn check_transfer_extrinsic( mode, )) .await?; - } else if transfer_value.is_none() - && extrinsic_info.pallet == "Balances" - && !["transfer_all", "upgrade_accounts"].contains(&extrinsic_info.call.as_str()) - { - // Every other Balances extrinsic should have an amount. - // TODO: - // - check transfer_all by accessing account storage to get the value, this is a low - // priority because it is already covered by balance events - warn!( - ?mode, - ?extrinsic_info, - "Balance: extrinsic amount unavailable in block", - ); - } - - let important_address_kinds = extrinsic_info.important_address_kinds_str(); - - trace!( - ?mode, - ?important_address_kinds, - ?extrinsic_info, - ?block_info, - "extrinsic account list", - ); + } else { + if let Some(important_address_kinds) = important_address_kinds { + alert_tx + .send(Alert::new( + AlertKind::ImportantAddressTransfer { + address_kinds: important_address_kinds, + extrinsic_info: extrinsic_info.clone(), + // The transfer value can be missing for a transfer_all call. + // TODO: check account storage to get the transfer value if it is missing + transfer_value, + }, + *block_info, + mode, + )) + .await?; + } - if let Some(important_address_kinds) = important_address_kinds { - alert_tx - .send(Alert::new( - AlertKind::ImportantAddressTransfer { - address_kinds: important_address_kinds, - extrinsic_info: extrinsic_info.clone(), - // The transfer value can be missing for a transfer_all call. - // TODO: check account storage to get the transfer value if it is missing - transfer_value, - }, - *block_info, - mode, - )) - .await?; + if transfer_value.is_none() + && extrinsic_info.pallet == "Balances" + && !["transfer_all", "upgrade_accounts"].contains(&extrinsic_info.call.as_str()) + { + // Every other Balances extrinsic should have an amount. + // TODO: + // - check transfer_all by accessing account storage to get the value, this is a low + // priority because it is already covered by balance events + warn!( + ?mode, + ?extrinsic_info, + "Balance: extrinsic amount unavailable in block", + ); + } } Ok(()) @@ -394,7 +394,18 @@ pub async fn check_transfer_event( // - track the total of recent events, so the threshold can't be bypassed by splitting the // transfer into multiple calls let transfer_value = event_info.transfer_value(); - debug!(?mode, "transfer_value: {:?}", transfer_value); + trace!(?mode, "transfer_value: {:?}", transfer_value); + + let accounts = event_info.accounts(); + let important_address_kinds = event_info.important_address_kinds_str(); + trace!( + ?mode, + ?accounts, + ?important_address_kinds, + ?event_info, + ?block_info, + "event account list", + ); // TODO: // - do we want to track burn calls? @@ -411,21 +422,7 @@ pub async fn check_transfer_event( mode, )) .await?; - } - - let accounts = event_info.accounts(); - let important_address_kinds = event_info.important_address_kinds_str(); - - trace!( - ?mode, - ?accounts, - ?important_address_kinds, - ?event_info, - ?block_info, - "event account list" - ); - - if let Some(important_address_kinds) = important_address_kinds { + } else if let Some(important_address_kinds) = important_address_kinds { alert_tx .send(Alert::new( AlertKind::ImportantAddressTransferEvent { From ca2171671756164ffa0a324d95deb00f82135ca4 Mon Sep 17 00:00:00 2001 From: teor Date: Thu, 25 Sep 2025 14:56:13 +0200 Subject: [PATCH 3/9] Inline alerts in extrinsic and event functions --- chain-alerter/src/alerts.rs | 148 +++++++++++++++++++++++-- chain-alerter/src/alerts/transfer.rs | 158 +-------------------------- 2 files changed, 143 insertions(+), 163 deletions(-) diff --git a/chain-alerter/src/alerts.rs b/chain-alerter/src/alerts.rs index 1eef9b7..4083bf5 100644 --- a/chain-alerter/src/alerts.rs +++ b/chain-alerter/src/alerts.rs @@ -4,7 +4,7 @@ mod tests; pub mod transfer; -use crate::alerts::transfer::{check_transfer_event, check_transfer_extrinsic}; +use crate::alerts::transfer::{Accounts, TransferValue}; use crate::chain_fork_monitor::ChainForkEvent; use crate::format::{fmt_amount, fmt_duration, fmt_timestamp}; use crate::subspace::{ @@ -16,7 +16,7 @@ use std::fmt::{self, Display}; use std::time::Duration; use tokio::sync::{mpsc, watch}; use tokio::time::sleep; -use tracing::warn; +use tracing::{trace, warn}; /// The minimum balance change to alert on. const MIN_BALANCE_CHANGE: Balance = 1_000_000 * AI3; @@ -795,8 +795,6 @@ pub async fn check_extrinsic( // - link extrinsic and account to subscan // - add extrinsic success/failure to alerts - check_transfer_extrinsic(mode, alert_tx, &extrinsic_info, block_info).await?; - // All sudo calls are alerts. // TODO: // - test this alert by checking a historic block with a sudo call @@ -805,13 +803,93 @@ pub async fn check_extrinsic( if extrinsic_info.pallet == "Sudo" { alert_tx .send(Alert::new( - AlertKind::SudoCall { extrinsic_info }, + AlertKind::SudoCall { + extrinsic_info: extrinsic_info.clone(), + }, *block_info, mode, )) .await?; } + // "force*" calls and large balance changes are alerts. + + // TODO: + // - track the total of recent transfers, so the threshold can't be bypassed by splitting the + // transfer into multiple calls + let transfer_value = extrinsic_info.transfer_value(); + trace!(?mode, "transfer_value: {:?}", transfer_value); + + let important_address_kinds = extrinsic_info.important_address_kinds_str(); + trace!( + ?mode, + ?important_address_kinds, + ?extrinsic_info, + ?block_info, + "extrinsic account list", + ); + + // TODO: + // - test force alerts by checking a historic block with that call + // - do we want to track burn calls? this is + // a low priority because it is already covered by balance events + if let Some(transfer_value) = transfer_value + && transfer_value >= MIN_BALANCE_CHANGE + { + alert_tx + .send(Alert::new( + AlertKind::LargeBalanceTransfer { + extrinsic_info: extrinsic_info.clone(), + transfer_value, + }, + *block_info, + mode, + )) + .await?; + } else if extrinsic_info.pallet == "Balances" && extrinsic_info.call.starts_with("force") { + alert_tx + .send(Alert::new( + AlertKind::ForceBalanceTransfer { + extrinsic_info: extrinsic_info.clone(), + transfer_value, + }, + *block_info, + mode, + )) + .await?; + } else { + if let Some(important_address_kinds) = important_address_kinds { + alert_tx + .send(Alert::new( + AlertKind::ImportantAddressTransfer { + address_kinds: important_address_kinds, + extrinsic_info: extrinsic_info.clone(), + // The transfer value can be missing for a transfer_all call. + // TODO: check account storage to get the transfer value if it is missing + transfer_value, + }, + *block_info, + mode, + )) + .await?; + } + + if transfer_value.is_none() + && extrinsic_info.pallet == "Balances" + && !["transfer_all", "upgrade_accounts"].contains(&extrinsic_info.call.as_str()) + { + // Every other Balances extrinsic should have an amount. + // TODO: + // - check transfer_all by accessing account storage to get the value, this is a low + // priority because it is already covered by balance events + warn!( + ?mode, + ?extrinsic_info, + "Balance: extrinsic amount unavailable in block", + ); + } + } + Ok(()) } @@ -835,8 +913,6 @@ pub async fn check_event( // - format account IDs as ss58 with prefix 6094 // - link event and account to subscan - check_transfer_event(mode, alert_tx, &event_info, block_info).await?; - // All operator slashes are alerts. // TODO: // - test this alert by checking a historic block with an operator slash event @@ -844,7 +920,9 @@ pub async fn check_event( if event_info.pallet == "Domains" && event_info.kind == "OperatorSlashed" { alert_tx .send(Alert::new( - AlertKind::OperatorSlashed { event_info }, + AlertKind::OperatorSlashed { + event_info: event_info.clone(), + }, *block_info, mode, )) @@ -853,7 +931,59 @@ pub async fn check_event( // We already alert on sudo calls, so this exists mainly to test events. alert_tx .send(Alert::new( - AlertKind::SudoEvent { event_info }, + AlertKind::SudoEvent { + event_info: event_info.clone(), + }, + *block_info, + mode, + )) + .await?; + } + + // Large balance changes are alerts. + + // TODO: + // - track the total of recent events, so the threshold can't be bypassed by splitting the + // transfer into multiple calls + let transfer_value = event_info.transfer_value(); + trace!(?mode, "transfer_value: {:?}", transfer_value); + + let accounts = event_info.accounts(); + let important_address_kinds = event_info.important_address_kinds_str(); + trace!( + ?mode, + ?accounts, + ?important_address_kinds, + ?event_info, + ?block_info, + "event account list", + ); + + // TODO: + // - do we want to track burn calls? + if let Some(transfer_value) = transfer_value + && transfer_value >= MIN_BALANCE_CHANGE + { + alert_tx + .send(Alert::new( + AlertKind::LargeBalanceTransferEvent { + event_info: event_info.clone(), + transfer_value, + }, + *block_info, + mode, + )) + .await?; + } else if let Some(important_address_kinds) = important_address_kinds { + alert_tx + .send(Alert::new( + AlertKind::ImportantAddressTransferEvent { + address_kinds: important_address_kinds, + event_info: event_info.clone(), + // The transfer value shouldn't be missing, but we can't rely on the data + // format. + transfer_value, + }, *block_info, mode, )) diff --git a/chain-alerter/src/alerts/transfer.rs b/chain-alerter/src/alerts/transfer.rs index d9e5c47..a0b5980 100644 --- a/chain-alerter/src/alerts/transfer.rs +++ b/chain-alerter/src/alerts/transfer.rs @@ -1,15 +1,13 @@ //! Balance transfer alerts. -use crate::alerts::{Alert, AlertKind, BlockCheckMode, MIN_BALANCE_CHANGE}; use crate::subspace::decode::decode_h256_from_composite; -use crate::subspace::{Balance, BlockInfo, EventInfo, ExtrinsicInfo}; +use crate::subspace::{Balance, EventInfo, ExtrinsicInfo}; use scale_value::Composite; use std::collections::BTreeSet; use std::fmt::{self, Display}; use std::str::FromStr; use subxt::utils::AccountId32; -use tokio::sync::mpsc; -use tracing::{debug, trace, warn}; +use tracing::trace; /// A list of known important addresses. pub const IMPORTANT_ADDRESSES: &[(&str, &str)] = &[ @@ -113,6 +111,8 @@ impl TransferValue for EventInfo { } } +// TODO: split accounts into a separate file + /// An account ID and attached role type. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub enum Account { @@ -290,153 +290,3 @@ pub fn list_accounts(fields: &Composite, field_names: &[&str]) -> BTreeSet< BTreeSet::new() } } - -/// Check a Balance extrinsic for alerts. -/// Does nothing if the extrinsic is any other kind of extrinsic. -pub async fn check_transfer_extrinsic( - mode: BlockCheckMode, - alert_tx: &mpsc::Sender, - extrinsic_info: &ExtrinsicInfo, - block_info: &BlockInfo, -) -> anyhow::Result<()> { - // "force*" calls and large balance changes are alerts. - - // TODO: - // - track the total of recent transfers, so the threshold can't be bypassed by splitting the - // transfer into multiple calls - let transfer_value = extrinsic_info.transfer_value(); - trace!(?mode, "transfer_value: {:?}", transfer_value); - - let important_address_kinds = extrinsic_info.important_address_kinds_str(); - trace!( - ?mode, - ?important_address_kinds, - ?extrinsic_info, - ?block_info, - "extrinsic account list", - ); - - // TODO: - // - test force alerts by checking a historic block with that call - // - do we want to track burn calls? this is - // a low priority because it is already covered by balance events - if let Some(transfer_value) = transfer_value - && transfer_value >= MIN_BALANCE_CHANGE - { - alert_tx - .send(Alert::new( - AlertKind::LargeBalanceTransfer { - extrinsic_info: extrinsic_info.clone(), - transfer_value, - }, - *block_info, - mode, - )) - .await?; - } else if extrinsic_info.pallet == "Balances" && extrinsic_info.call.starts_with("force") { - alert_tx - .send(Alert::new( - AlertKind::ForceBalanceTransfer { - extrinsic_info: extrinsic_info.clone(), - transfer_value, - }, - *block_info, - mode, - )) - .await?; - } else { - if let Some(important_address_kinds) = important_address_kinds { - alert_tx - .send(Alert::new( - AlertKind::ImportantAddressTransfer { - address_kinds: important_address_kinds, - extrinsic_info: extrinsic_info.clone(), - // The transfer value can be missing for a transfer_all call. - // TODO: check account storage to get the transfer value if it is missing - transfer_value, - }, - *block_info, - mode, - )) - .await?; - } - - if transfer_value.is_none() - && extrinsic_info.pallet == "Balances" - && !["transfer_all", "upgrade_accounts"].contains(&extrinsic_info.call.as_str()) - { - // Every other Balances extrinsic should have an amount. - // TODO: - // - check transfer_all by accessing account storage to get the value, this is a low - // priority because it is already covered by balance events - warn!( - ?mode, - ?extrinsic_info, - "Balance: extrinsic amount unavailable in block", - ); - } - } - - Ok(()) -} - -/// Check a Balance event for alerts. -/// Does nothing if the event is any other kind of event. -pub async fn check_transfer_event( - mode: BlockCheckMode, - alert_tx: &mpsc::Sender, - event_info: &EventInfo, - block_info: &BlockInfo, -) -> anyhow::Result<()> { - // Large balance changes are alerts. - - // TODO: - // - track the total of recent events, so the threshold can't be bypassed by splitting the - // transfer into multiple calls - let transfer_value = event_info.transfer_value(); - trace!(?mode, "transfer_value: {:?}", transfer_value); - - let accounts = event_info.accounts(); - let important_address_kinds = event_info.important_address_kinds_str(); - trace!( - ?mode, - ?accounts, - ?important_address_kinds, - ?event_info, - ?block_info, - "event account list", - ); - - // TODO: - // - do we want to track burn calls? - if let Some(transfer_value) = transfer_value - && transfer_value >= MIN_BALANCE_CHANGE - { - alert_tx - .send(Alert::new( - AlertKind::LargeBalanceTransferEvent { - event_info: event_info.clone(), - transfer_value, - }, - *block_info, - mode, - )) - .await?; - } else if let Some(important_address_kinds) = important_address_kinds { - alert_tx - .send(Alert::new( - AlertKind::ImportantAddressTransferEvent { - address_kinds: important_address_kinds, - event_info: event_info.clone(), - // The transfer value shouldn't be missing, but we can't rely on the data - // format. - transfer_value, - }, - *block_info, - mode, - )) - .await?; - } - - Ok(()) -} From 8297c684ce7eb8867435a3d4db848c2851162eb4 Mon Sep 17 00:00:00 2001 From: teor Date: Thu, 25 Sep 2025 15:06:26 +0200 Subject: [PATCH 4/9] Make Sudo call/event alerts override other alerts about the sudo account --- chain-alerter/src/alerts.rs | 169 ++++++++++++++++++------------------ 1 file changed, 83 insertions(+), 86 deletions(-) diff --git a/chain-alerter/src/alerts.rs b/chain-alerter/src/alerts.rs index 4083bf5..d39b922 100644 --- a/chain-alerter/src/alerts.rs +++ b/chain-alerter/src/alerts.rs @@ -789,31 +789,10 @@ pub async fn check_extrinsic( }; // TODO: - // - extract each alert into a pallet-specific function or trait object // - add tests to make sure we can parse the extrinsics for each alert - // - format account IDs as ss58 with prefix 6094 // - link extrinsic and account to subscan // - add extrinsic success/failure to alerts - // All sudo calls are alerts. - // TODO: - // - test this alert by checking a historic block with a sudo call - // - check if the call is from the sudo account - // - decode the inner call - if extrinsic_info.pallet == "Sudo" { - alert_tx - .send(Alert::new( - AlertKind::SudoCall { - extrinsic_info: extrinsic_info.clone(), - }, - *block_info, - mode, - )) - .await?; - } - - // "force*" calls and large balance changes are alerts. - // TODO: // - track the total of recent transfers, so the threshold can't be bypassed by splitting the // transfer into multiple calls @@ -829,11 +808,25 @@ pub async fn check_extrinsic( "extrinsic account list", ); - // TODO: - // - test force alerts by checking a historic block with that call - // - do we want to track burn calls? this is - // a low priority because it is already covered by balance events - if let Some(transfer_value) = transfer_value + // The signing account is listed in the extrinsic, therefore: + // - Sudo extrinsics override balance transfers by sudo, and + // - Large balance transfers override transfers initiated by important addresses. + if extrinsic_info.pallet == "Sudo" { + // All sudo calls are alerts. + // TODO: + // - test this alert by checking a historic block with a sudo call + // - check if the call is from the sudo account + // - decode the inner call + alert_tx + .send(Alert::new( + AlertKind::SudoCall { + extrinsic_info: extrinsic_info.clone(), + }, + *block_info, + mode, + )) + .await?; + } else if let Some(transfer_value) = transfer_value && transfer_value >= MIN_BALANCE_CHANGE { alert_tx @@ -847,6 +840,10 @@ pub async fn check_extrinsic( )) .await?; } else if extrinsic_info.pallet == "Balances" && extrinsic_info.call.starts_with("force") { + // TODO: + // - test force alerts by checking a historic block with that call + // - do we want to track burn calls? + // - this is a low priority because it is already covered by balance events alert_tx .send(Alert::new( AlertKind::ForceBalanceTransfer { @@ -857,37 +854,36 @@ pub async fn check_extrinsic( mode, )) .await?; - } else { - if let Some(important_address_kinds) = important_address_kinds { - alert_tx - .send(Alert::new( - AlertKind::ImportantAddressTransfer { - address_kinds: important_address_kinds, - extrinsic_info: extrinsic_info.clone(), - // The transfer value can be missing for a transfer_all call. - // TODO: check account storage to get the transfer value if it is missing - transfer_value, - }, - *block_info, - mode, - )) - .await?; - } + } else if (extrinsic_info.pallet == "Balances" || transfer_value.is_some()) + && let Some(important_address_kinds) = important_address_kinds + { + alert_tx + .send(Alert::new( + AlertKind::ImportantAddressTransfer { + address_kinds: important_address_kinds, + extrinsic_info: extrinsic_info.clone(), + // The transfer value can be missing for a transfer_all call. + transfer_value, + }, + *block_info, + mode, + )) + .await?; + } - if transfer_value.is_none() - && extrinsic_info.pallet == "Balances" - && !["transfer_all", "upgrade_accounts"].contains(&extrinsic_info.call.as_str()) - { - // Every other Balances extrinsic should have an amount. - // TODO: - // - check transfer_all by accessing account storage to get the value, this is a low - // priority because it is already covered by balance events - warn!( - ?mode, - ?extrinsic_info, - "Balance: extrinsic amount unavailable in block", - ); - } + if transfer_value.is_none() + && extrinsic_info.pallet == "Balances" + && !["transfer_all", "upgrade_accounts"].contains(&extrinsic_info.call.as_str()) + { + // Every other Balances extrinsic should have an amount. + // TODO: + // - check transfer_all by accessing account storage to get the value, this is a low + // priority because it is already covered by balance events + warn!( + ?mode, + ?extrinsic_info, + "Balance: extrinsic amount unavailable in block", + ); } Ok(()) @@ -908,12 +904,28 @@ pub async fn check_event( let event_info = EventInfo::new(event, block_info); // TODO: - // - extract each alert into a pallet-specific function or trait object + // - combine extrinsics and events, but only if they are redundant (for example: sudo/sudid) // - add tests to make sure we can parse the events for each alert - // - format account IDs as ss58 with prefix 6094 // - link event and account to subscan - // All operator slashes are alerts. + // TODO: + // - track the total of recent events, so the threshold can't be bypassed by splitting the + // transfer into multiple calls + let transfer_value = event_info.transfer_value(); + trace!(?mode, "transfer_value: {:?}", transfer_value); + + let accounts = event_info.accounts(); + let important_address_kinds = event_info.important_address_kinds_str(); + trace!( + ?mode, + ?accounts, + ?important_address_kinds, + ?event_info, + ?block_info, + "event account list", + ); + + // All operator slashes are alerts, and they don't override any other alerts. // TODO: // - test this alert by checking a historic block with an operator slash event // - check the case of these names @@ -927,8 +939,12 @@ pub async fn check_event( mode, )) .await?; - } else if event_info.pallet == "Sudo" { - // We already alert on sudo calls, so this exists mainly to test events. + } + + // The initiating account is listed in the event when it is in the `who` field, therefore: + // - Sudo events override balance transfers by sudo, and + // - Large balance transfers override transfers initiated by important addresses. + if event_info.pallet == "Sudo" { alert_tx .send(Alert::new( AlertKind::SudoEvent { @@ -938,32 +954,11 @@ pub async fn check_event( mode, )) .await?; - } - - // Large balance changes are alerts. - - // TODO: - // - track the total of recent events, so the threshold can't be bypassed by splitting the - // transfer into multiple calls - let transfer_value = event_info.transfer_value(); - trace!(?mode, "transfer_value: {:?}", transfer_value); - - let accounts = event_info.accounts(); - let important_address_kinds = event_info.important_address_kinds_str(); - trace!( - ?mode, - ?accounts, - ?important_address_kinds, - ?event_info, - ?block_info, - "event account list", - ); - - // TODO: - // - do we want to track burn calls? - if let Some(transfer_value) = transfer_value + } else if let Some(transfer_value) = transfer_value && transfer_value >= MIN_BALANCE_CHANGE { + // TODO: + // - do we want to track burned events? alert_tx .send(Alert::new( AlertKind::LargeBalanceTransferEvent { @@ -974,7 +969,9 @@ pub async fn check_event( mode, )) .await?; - } else if let Some(important_address_kinds) = important_address_kinds { + } else if transfer_value.is_some() + && let Some(important_address_kinds) = important_address_kinds + { alert_tx .send(Alert::new( AlertKind::ImportantAddressTransferEvent { From 1978f9f879be84ae90ce1c6aa170f138afba0a0b Mon Sep 17 00:00:00 2001 From: teor Date: Thu, 25 Sep 2025 15:19:43 +0200 Subject: [PATCH 5/9] Add signing/initiator account support --- chain-alerter/src/alerts/transfer.rs | 39 +++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/chain-alerter/src/alerts/transfer.rs b/chain-alerter/src/alerts/transfer.rs index a0b5980..6762778 100644 --- a/chain-alerter/src/alerts/transfer.rs +++ b/chain-alerter/src/alerts/transfer.rs @@ -7,7 +7,7 @@ use std::collections::BTreeSet; use std::fmt::{self, Display}; use std::str::FromStr; use subxt::utils::AccountId32; -use tracing::trace; +use tracing::{error, trace}; /// A list of known important addresses. pub const IMPORTANT_ADDRESSES: &[(&str, &str)] = &[ @@ -154,6 +154,21 @@ impl Display for Account { /// A trait for accessing the account IDs and account types from an object. pub trait Accounts { + /// Returns the signer for extrinsics or the `who` for events. + fn initiator_account(&self) -> Option; + + /// Returns the account ID and type for the initiator account, if any. + #[expect(dead_code, reason = "included for completeness")] + fn initiator_account_str(&self) -> Option { + self.initiator_account().map(|account| account.to_string()) + } + + /// Returns the important address kind for the initiator account, if any. + fn initiator_account_kind(&self) -> Option<&'static str> { + self.initiator_account() + .and_then(|account| account.important_address_kind()) + } + /// Returns sorted account IDs and types, if present. fn accounts(&self) -> BTreeSet; @@ -202,12 +217,18 @@ pub trait Accounts { } impl Accounts for ExtrinsicInfo { + fn initiator_account(&self) -> Option { + self.signing_address + .as_ref() + .map(|signing_address| Account::Signer(signing_address.clone())) + } + fn accounts(&self) -> BTreeSet { let mut account_list = BTreeSet::new(); - if let Some(signing_address) = self.signing_address.as_ref() { + if let Some(signing_account) = self.initiator_account() { // Handle signer for Balances, Transporter, Domains, etc. - account_list.insert(Account::Signer(signing_address.clone())); + account_list.insert(signing_account); } if self.pallet == "Balances" { @@ -226,6 +247,18 @@ impl Accounts for ExtrinsicInfo { } impl Accounts for EventInfo { + fn initiator_account(&self) -> Option { + let who = list_accounts(&self.fields, &["who"]); + + if who.len() > 1 { + // This is technically possible in the data format, but it should never actually happen. + error!("multiple 'who' accounts in event, alerts might have been missed"); + None + } else { + who.into_iter().next().map(Account::Signer) + } + } + fn accounts(&self) -> BTreeSet { let mut account_list = BTreeSet::new(); From 4ffebc4af6982384388f1d2a6c8d995877f061bb Mon Sep 17 00:00:00 2001 From: teor Date: Thu, 25 Sep 2025 15:20:11 +0200 Subject: [PATCH 6/9] Add important address alerts, but at the lowest priority --- chain-alerter/src/alerts.rs | 92 ++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 7 deletions(-) diff --git a/chain-alerter/src/alerts.rs b/chain-alerter/src/alerts.rs index d39b922..be3a29c 100644 --- a/chain-alerter/src/alerts.rs +++ b/chain-alerter/src/alerts.rs @@ -247,6 +247,24 @@ pub enum AlertKind { transfer_value: Option, }, + /// An extrinsic initiated by an important address has been detected. + ImportantAddressExtrinsic { + /// The important address kind. + address_kind: String, + + /// The extrinsic information. + extrinsic_info: ExtrinsicInfo, + }, + + /// An event initiated by an important address has been detected. + ImportantAddressEvent { + /// The important address kind. + address_kind: String, + + /// The event information. + event_info: EventInfo, + }, + /// A Sudo call has been detected. SudoCall { /// The sudo call's extrinsic information. @@ -448,6 +466,30 @@ impl Display for AlertKind { ) } + Self::ImportantAddressExtrinsic { + address_kind, + extrinsic_info, + } => { + write!( + f, + "**Important address sent an extrinsic**\n\ + Kind: {address_kind}\n\ + {extrinsic_info}", + ) + } + + Self::ImportantAddressEvent { + address_kind, + event_info, + } => { + write!( + f, + "**Important address initiated an event**\n\ + Kind: {address_kind}\n\ + {event_info}", + ) + } + Self::SudoCall { extrinsic_info } => { write!( f, @@ -540,6 +582,8 @@ impl AlertKind { | Self::LargeBalanceTransferEvent { .. } | Self::ImportantAddressTransfer { .. } | Self::ImportantAddressTransferEvent { .. } + | Self::ImportantAddressExtrinsic { .. } + | Self::ImportantAddressEvent { .. } | Self::SudoCall { .. } | Self::SudoEvent { .. } | Self::OperatorSlashed { .. } @@ -568,6 +612,8 @@ impl AlertKind { | Self::LargeBalanceTransferEvent { .. } | Self::ImportantAddressTransfer { .. } | Self::ImportantAddressTransferEvent { .. } + | Self::ImportantAddressExtrinsic { .. } + | Self::ImportantAddressEvent { .. } | Self::SudoCall { .. } | Self::SudoEvent { .. } | Self::OperatorSlashed { .. } @@ -584,6 +630,7 @@ impl AlertKind { Self::ForceBalanceTransfer { extrinsic_info, .. } | Self::LargeBalanceTransfer { extrinsic_info, .. } | Self::ImportantAddressTransfer { extrinsic_info, .. } + | Self::ImportantAddressExtrinsic { extrinsic_info, .. } | Self::SudoCall { extrinsic_info } => Some(extrinsic_info), Self::Startup | Self::FarmersDecreasedSuddenly { .. } @@ -594,6 +641,7 @@ impl AlertKind { | Self::SideForkExtended { .. } | Self::LargeBalanceTransferEvent { .. } | Self::ImportantAddressTransferEvent { .. } + | Self::ImportantAddressEvent { .. } | Self::Reorg { .. } | Self::SudoEvent { .. } | Self::OperatorSlashed { .. } @@ -618,6 +666,8 @@ impl AlertKind { | Self::NewSideFork { .. } | Self::SideForkExtended { .. } | Self::Reorg { .. } + | Self::ImportantAddressExtrinsic { .. } + | Self::ImportantAddressEvent { .. } | Self::SudoCall { .. } | Self::SudoEvent { .. } | Self::OperatorSlashed { .. } @@ -632,7 +682,8 @@ impl AlertKind { Self::LargeBalanceTransferEvent { event_info, .. } | Self::ImportantAddressTransferEvent { event_info, .. } | Self::SudoEvent { event_info } - | Self::OperatorSlashed { event_info } => Some(event_info), + | Self::OperatorSlashed { event_info } + | Self::ImportantAddressEvent { event_info, .. } => Some(event_info), Self::Startup | Self::BlockProductionStall { .. } | Self::BlockProductionResumed { .. } @@ -642,6 +693,7 @@ impl AlertKind { | Self::ForceBalanceTransfer { .. } | Self::LargeBalanceTransfer { .. } | Self::ImportantAddressTransfer { .. } + | Self::ImportantAddressExtrinsic { .. } | Self::SudoCall { .. } | Self::SlotTime { .. } | Self::FarmersDecreasedSuddenly { .. } @@ -799,9 +851,11 @@ pub async fn check_extrinsic( let transfer_value = extrinsic_info.transfer_value(); trace!(?mode, "transfer_value: {:?}", transfer_value); + let initiator_account_kind = extrinsic_info.initiator_account_kind(); let important_address_kinds = extrinsic_info.important_address_kinds_str(); trace!( ?mode, + ?initiator_account_kind, ?important_address_kinds, ?extrinsic_info, ?block_info, @@ -809,8 +863,9 @@ pub async fn check_extrinsic( ); // The signing account is listed in the extrinsic, therefore: - // - Sudo extrinsics override balance transfers by sudo, and - // - Large balance transfers override transfers initiated by important addresses. + // - Sudo extrinsics override balance transfers by sudo, + // - Large balance transfers override transfers initiated by important addresses, and + // - An important address alert is only issued if no other alert is triggered. if extrinsic_info.pallet == "Sudo" { // All sudo calls are alerts. // TODO: @@ -869,6 +924,17 @@ pub async fn check_extrinsic( mode, )) .await?; + } else if let Some(initiator_account_kind) = initiator_account_kind { + alert_tx + .send(Alert::new( + AlertKind::ImportantAddressExtrinsic { + address_kind: initiator_account_kind.to_string(), + extrinsic_info: extrinsic_info.clone(), + }, + *block_info, + mode, + )) + .await?; } if transfer_value.is_none() @@ -914,11 +980,11 @@ pub async fn check_event( let transfer_value = event_info.transfer_value(); trace!(?mode, "transfer_value: {:?}", transfer_value); - let accounts = event_info.accounts(); + let initiator_account_kind = event_info.initiator_account_kind(); let important_address_kinds = event_info.important_address_kinds_str(); trace!( ?mode, - ?accounts, + ?initiator_account_kind, ?important_address_kinds, ?event_info, ?block_info, @@ -942,8 +1008,9 @@ pub async fn check_event( } // The initiating account is listed in the event when it is in the `who` field, therefore: - // - Sudo events override balance transfers by sudo, and - // - Large balance transfers override transfers initiated by important addresses. + // - Sudo events override balance transfers by sudo, + // - Large balance transfers override transfers initiated by important addresses, and + // - An important address alert is only issued if no other alert is triggered. if event_info.pallet == "Sudo" { alert_tx .send(Alert::new( @@ -985,6 +1052,17 @@ pub async fn check_event( mode, )) .await?; + } else if let Some(initiator_account_kind) = initiator_account_kind { + alert_tx + .send(Alert::new( + AlertKind::ImportantAddressEvent { + address_kind: initiator_account_kind.to_string(), + event_info: event_info.clone(), + }, + *block_info, + mode, + )) + .await?; } Ok(()) From c1656ce7db1bf44fb19db377ed7b864ae08990a2 Mon Sep 17 00:00:00 2001 From: teor Date: Thu, 25 Sep 2025 15:27:29 +0200 Subject: [PATCH 7/9] In tests, always check for unexpected alerts --- chain-alerter/src/alerts/tests.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/chain-alerter/src/alerts/tests.rs b/chain-alerter/src/alerts/tests.rs index 04cdc37..5db851d 100644 --- a/chain-alerter/src/alerts/tests.rs +++ b/chain-alerter/src/alerts/tests.rs @@ -154,6 +154,11 @@ async fn test_startup_alert() -> anyhow::Result<()> { Alert::new(AlertKind::Startup, block_info, BlockCheckMode::Startup), ); + // There should be no other alerts. + alert_rx + .try_recv() + .expect_err("alert received when none expected"); + // Check block slot parsing works on real blocks. assert_matches!(alert.block_info.slot, Some(Slot(_))); @@ -216,6 +221,10 @@ async fn test_sudo_alerts() -> anyhow::Result<()> { Some(("Sudo", "Sudid")) ); + alert_rx + .try_recv() + .expect_err("alert received when none expected"); + // Check block slot parsing works on a known slot value. assert_eq!(alert.block_info.slot, Some(SUDO_BLOCK.4)); @@ -259,6 +268,10 @@ async fn test_large_balance_transfer_alerts() -> anyhow::Result<()> { ) ); + alert_rx + .try_recv() + .expect_err("alert received when none expected"); + alerts::check_event(BlockCheckMode::Replay, &alert_tx, &event, &block_info).await?; let alert = alert_rx.try_recv().expect("no event alert received"); assert_eq!( @@ -273,6 +286,10 @@ async fn test_large_balance_transfer_alerts() -> anyhow::Result<()> { ) ); + alert_rx + .try_recv() + .expect_err("alert received when none expected"); + // Check block slot parsing works on a known slot value. assert_eq!(alert.block_info.slot, Some(slot)); } @@ -342,6 +359,10 @@ async fn test_important_address_alerts() -> anyhow::Result<()> { ); } + alert_rx + .try_recv() + .expect_err("alert received when none expected"); + alerts::check_event(BlockCheckMode::Replay, &alert_tx, &event, &block_info).await?; let alert = alert_rx.try_recv().expect("no event alert received"); @@ -372,6 +393,10 @@ async fn test_important_address_alerts() -> anyhow::Result<()> { ); } + alert_rx + .try_recv() + .expect_err("alert received when none expected"); + // Check block slot parsing works on a known slot value. assert_eq!(alert.block_info.slot, Some(slot)); } @@ -459,6 +484,10 @@ async fn expected_test_slot_time_alert() -> anyhow::Result<()> { ) ); + alert_rx + .try_recv() + .expect_err("alert received when none expected"); + Ok(()) } From acbdf95f9209cae0fbcfb161c112ec534500f85c Mon Sep 17 00:00:00 2001 From: teor Date: Thu, 25 Sep 2025 11:08:36 +0200 Subject: [PATCH 8/9] Add sudo address, and test for important address only alerts --- chain-alerter/src/alerts/tests.rs | 86 ++++++++++++++++++++++++---- chain-alerter/src/alerts/transfer.rs | 14 ++++- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/chain-alerter/src/alerts/tests.rs b/chain-alerter/src/alerts/tests.rs index 5db851d..d987ae2 100644 --- a/chain-alerter/src/alerts/tests.rs +++ b/chain-alerter/src/alerts/tests.rs @@ -21,7 +21,7 @@ use subxt::utils::H256; /// /// /// TODO: turn this into a struct -const SUDO_BLOCK: (BlockNumber, RawBlockHash, ExtrinsicIndex, EventIndex, Slot) = ( +const SUDO_EXTRINSIC_BLOCK: (BlockNumber, RawBlockHash, ExtrinsicIndex, EventIndex, Slot) = ( 3_795_487, hex_literal::hex!("18c2f211b752cbc2f06943788ed011ab1fe64fb2e28ffcd1aeb4490c2e8b1baa"), 5, @@ -68,7 +68,7 @@ const LARGE_TRANSFER_BLOCKS: [( clippy::type_complexity, reason = "this is a test, TODO: turn this into a struct" )] -const IMPORTANT_ADDRESS_BLOCKS: [( +const IMPORTANT_ADDRESS_TRANSFER_BLOCKS: [( BlockNumber, RawBlockHash, ExtrinsicIndex, @@ -134,6 +134,21 @@ const IMPORTANT_ADDRESS_BLOCKS: [( ), ]; +/// Some extrinsics for important address alerts (which aren't any other higher +/// priority alert). +/// TODO: find an event sent by an important address, that isn't a higher priority alert. +const IMPORTANT_ADDRESS_ONLY_BLOCKS: [(BlockNumber, RawBlockHash, ExtrinsicIndex, &str, Slot); 1] = [ + // + ( + 3_497_809, + hex_literal::hex!("bfa548573d1ff035e2009fdaa68499fe74c4ab30a775f5eb35624fdb9f95dc91"), + // + 9, + "Sudo", + Slot(21_070_789), + ), +]; + // TODO: force transfer blocks: // Failed force_transfer: // Failed force_set_balance: @@ -177,11 +192,15 @@ async fn test_sudo_alerts() -> anyhow::Result<()> { let (subspace_client, _, alert_tx, mut alert_rx, update_task) = test_setup(node_rpc_url()).await?; - let (block_info, extrinsics, events) = - fetch_block_info(&subspace_client, H256::from(SUDO_BLOCK.1), SUDO_BLOCK.0).await?; + let (block_info, extrinsics, events) = fetch_block_info( + &subspace_client, + H256::from(SUDO_EXTRINSIC_BLOCK.1), + SUDO_EXTRINSIC_BLOCK.0, + ) + .await?; let (extrinsic, extrinsic_info) = - decode_extrinsic(&block_info, &extrinsics, SUDO_BLOCK.2).await?; + decode_extrinsic(&block_info, &extrinsics, SUDO_EXTRINSIC_BLOCK.2).await?; alerts::check_extrinsic(BlockCheckMode::Replay, &alert_tx, &extrinsic, &block_info).await?; let alert = alert_rx.try_recv().expect("no alert received"); @@ -201,7 +220,7 @@ async fn test_sudo_alerts() -> anyhow::Result<()> { Some(("Sudo", "sudo")) ); - let (event, event_info) = decode_event(&block_info, &events, SUDO_BLOCK.3).await?; + let (event, event_info) = decode_event(&block_info, &events, SUDO_EXTRINSIC_BLOCK.3).await?; alerts::check_event(BlockCheckMode::Replay, &alert_tx, &event, &block_info).await?; let alert = alert_rx.try_recv().expect("no alert received"); @@ -226,7 +245,7 @@ async fn test_sudo_alerts() -> anyhow::Result<()> { .expect_err("alert received when none expected"); // Check block slot parsing works on a known slot value. - assert_eq!(alert.block_info.slot, Some(SUDO_BLOCK.4)); + assert_eq!(alert.block_info.slot, Some(SUDO_EXTRINSIC_BLOCK.4)); let result = update_task.now_or_never(); assert!( @@ -303,9 +322,9 @@ async fn test_large_balance_transfer_alerts() -> anyhow::Result<()> { Ok(()) } -/// Check that the important address alert works on known important address blocks. +/// Check that important address transfer alerts work on known important address transfer blocks. #[tokio::test(flavor = "multi_thread")] -async fn test_important_address_alerts() -> anyhow::Result<()> { +async fn test_important_address_transfer_alerts() -> anyhow::Result<()> { let (subspace_client, _, alert_tx, mut alert_rx, update_task) = test_setup(node_rpc_url()).await?; @@ -318,7 +337,7 @@ async fn test_important_address_alerts() -> anyhow::Result<()> { extrinsic_transfer_value, event_transfer_value, slot, - ) in IMPORTANT_ADDRESS_BLOCKS + ) in IMPORTANT_ADDRESS_TRANSFER_BLOCKS { let (block_info, extrinsics, events) = fetch_block_info(&subspace_client, H256::from(block_hash), block_number).await?; @@ -410,6 +429,53 @@ async fn test_important_address_alerts() -> anyhow::Result<()> { Ok(()) } +/// Check that the important address alert works on known important address blocks. +#[tokio::test(flavor = "multi_thread")] +async fn test_important_address_only_alerts() -> anyhow::Result<()> { + let (subspace_client, _, alert_tx, mut alert_rx, update_task) = + test_setup(node_rpc_url()).await?; + + for (block_number, block_hash, extrinsic_index, address_kind, slot) in + IMPORTANT_ADDRESS_ONLY_BLOCKS + { + let (block_info, extrinsics, _events) = + fetch_block_info(&subspace_client, H256::from(block_hash), block_number).await?; + + let (extrinsic, extrinsic_info) = + decode_extrinsic(&block_info, &extrinsics, extrinsic_index).await?; + + alerts::check_extrinsic(BlockCheckMode::Replay, &alert_tx, &extrinsic, &block_info).await?; + let alert = alert_rx.try_recv().expect("no extrinsic alert received"); + + assert_eq!( + alert, + Alert::new( + AlertKind::ImportantAddressExtrinsic { + address_kind: address_kind.to_string(), + extrinsic_info: extrinsic_info.clone(), + }, + block_info, + BlockCheckMode::Replay, + ) + ); + + alert_rx + .try_recv() + .expect_err("alert received when none expected"); + + // Check block slot parsing works on a known slot value. + assert_eq!(alert.block_info.slot, Some(slot)); + } + + let result = update_task.now_or_never(); + assert!( + result.is_none(), + "metadata update task exited unexpectedly with: {result:?}" + ); + + Ok(()) +} + /// Check that the slot time alert is not triggered when the time per slot is below the threshold. #[tokio::test(flavor = "multi_thread")] async fn no_expected_test_slot_time_alert() -> anyhow::Result<()> { diff --git a/chain-alerter/src/alerts/transfer.rs b/chain-alerter/src/alerts/transfer.rs index 6762778..03c3e29 100644 --- a/chain-alerter/src/alerts/transfer.rs +++ b/chain-alerter/src/alerts/transfer.rs @@ -62,6 +62,11 @@ pub const IMPORTANT_ADDRESSES: &[(&str, &str)] = &[ "Guardians of Growth", "sugQzjjyAfhzktFDdAkZrcTq5qzMaRoSV2qs1gTcjjuBeybWT", ), + // + // sudo.key() + // + // TODO: dynamically look this up from storage instead of hardcoding it + ("Sudo", "subKQqsYRyVkugvKQqLXEuhsefa9728PBAqtwxpeM5N4VD6mv"), ]; /// If the address is an important address, returns the kind of important address, otherwise returns @@ -72,7 +77,14 @@ pub fn important_address_kind(address: &Account) -> Option<&'static str> { IMPORTANT_ADDRESSES .iter() .find(|(_, addr)| { - let addr_id = AccountId32::from_str(addr).expect("constants are valid addresses"); + let addr_id = if let Ok(account) = AccountId32::from_str(addr) { + account + } else { + let bytes = hex::decode(addr).expect("constants are valid ss58check or hex"); + let array = <[u8; 32]>::try_from(bytes).expect("hex constants are 32 bytes"); + AccountId32::from(array) + }; + trace!(?addr_id, ?account_id, "important address kind check"); &addr_id == account_id }) From d5825f58336546b1a46b62aa729c1d676564cca7 Mon Sep 17 00:00:00 2001 From: teor Date: Thu, 25 Sep 2025 16:01:53 +0200 Subject: [PATCH 9/9] Document de-duplication of alerts --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 41d5cbe..9ac4c4c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,18 @@ if the solution range after an era transition is significantly different on the For details of the fork choice algorithm, see [the subspace protocol specification](https://subspace.github.io/protocol-specs/docs/decex/workflow#fork-choice-rule). +## Alert De-duplication + +Alerts about the same extrinsic or event are deduplicated, using this priority order: + +- sudo/sudid +- large balance transfer +- force balance transfer +- important address transfer +- important address (any other extrinsic or event) + +Other alert kinds are not de-duplicated. + ## Security notes - The Slack OAuth token is loaded from a file and must be readable only by the current user. @@ -77,7 +89,8 @@ For details of the fork choice algorithm, see [the subspace protocol specificati - Hardcoded Slack channel, workspace ID, and thresholds. - Minimal decoding/validation for extrinsics and events; fields are parsed best-effort. -- Minimal stateful aggregation (e.g., summing multiple related transfers) +- Minimal stateful aggregation (e.g., summing multiple related transfers). +- Alerts are partly de-duplicated on the same instance, but extrinsic and event alerts are not combined yet. - No alert deduplication when multiple instances are running. - Limited CLI configuration. - No persistent storage, no metrics, no dashboards.