Skip to main content

dig_events_protocol/
lib.rs

1//! # dig-events-protocol — the canonical blockchain→app event contract
2//!
3//! The ONE ecosystem definition of the wallet/chain event taxonomy that the DIG node/wallet engine
4//! EMITS and apps (dig-app) SUBSCRIBE to. This crate owns only the CONTRACT — the wire types and the
5//! two shape traits — so a second implementation matches the first byte-for-byte. The machinery that
6//! produces, persists, and streams events (the event bus, the catch-up delta store, live
7//! subscriptions) STAYS in the engine (dig-wallet-backend); moving it here would couple every
8//! consumer to the runtime.
9//!
10//! ## The contract
11//!
12//! - [`WalletEvent`] — the event enum the engine emits (tagged `type` snake_case on the wire), with
13//!   [`WalletEvent::kind`] and [`WalletEvent::matches`] for subscription filtering.
14//! - [`EventKind`] — the kind discriminant; an `EnumSet<EventKind>` is the subscription FILTER
15//!   (serialized as a snake_case list).
16//! - [`Cursor`] + [`EmittedEvent`] — the monotonic delivery cursor and the envelope that flows over
17//!   the live stream and is returned by catch-up.
18//! - [`SyncLifecycle`] / [`SyncStatus`] — the tri-state sync snapshot.
19//! - [`WalletId`] / [`Amount`] / [`AssetId`] — the payload newtypes.
20//! - [`EventEmitter`] + [`CatchUp`] + [`filter_events`] — the emit/backfill shape traits and the
21//!   shared filtering rule.
22//!
23//! ## The drift-freeze
24//!
25//! The wire format is frozen by golden-JSON conformance KATs (`tests/conformance.rs`): every
26//! [`WalletEvent`] variant and the [`EventKind`] list round-trip against a byte-stable fixture. A
27//! change that alters the wire shape breaks a KAT — that is the guardrail against silent drift.
28
29#![forbid(unsafe_code)]
30#![warn(missing_docs)]
31
32mod cursor;
33mod event;
34mod kind;
35mod sync;
36mod traits;
37mod value;
38
39pub use cursor::{Cursor, EmittedEvent};
40pub use event::WalletEvent;
41pub use kind::EventKind;
42pub use sync::{SyncLifecycle, SyncStatus};
43pub use traits::{filter_events, CatchUp, EventEmitter};
44pub use value::{Amount, AssetId, WalletId};
45
46// Re-export enumset so consumers can name `EnumSet<EventKind>` (the subscription filter) without
47// depending on a matching enumset version themselves.
48pub use enumset::{enum_set, EnumSet};
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn cursor_advances_monotonically() {
56        assert_eq!(Cursor(0).next(), Cursor(1));
57        assert!(Cursor(1) > Cursor(0));
58    }
59
60    #[test]
61    fn event_is_tagged_snake_case() {
62        let e = WalletEvent::Confirmation {
63            tx_id: "ab".into(),
64            height: 100,
65        };
66        let json = serde_json::to_string(&e).unwrap();
67        assert!(json.contains("\"type\":\"confirmation\""), "{json}");
68        let back: WalletEvent = serde_json::from_str(&json).unwrap();
69        assert_eq!(back, e);
70    }
71
72    #[test]
73    fn kind_maps_each_variant() {
74        let e = WalletEvent::FundsReceived {
75            wallet_id: WalletId(1),
76            asset: None,
77            amount: Amount(5),
78            coin_id: "c".into(),
79            confirmed_height: 10,
80        };
81        assert_eq!(e.kind(), EventKind::FundsReceived);
82    }
83
84    #[test]
85    fn filter_admits_only_matching_kinds() {
86        let received = WalletEvent::FundsReceived {
87            wallet_id: WalletId(1),
88            asset: None,
89            amount: Amount(5),
90            coin_id: "c".into(),
91            confirmed_height: 10,
92        };
93        let tip = WalletEvent::NewTip {
94            height: 9,
95            header_hash: "hh".into(),
96        };
97
98        let funds_only = EventKind::FundsReceived | EventKind::FundsSent;
99        assert!(received.matches(funds_only));
100        assert!(!tip.matches(funds_only));
101    }
102
103    #[test]
104    fn filter_events_retains_only_matching_in_order() {
105        let events = vec![
106            EmittedEvent {
107                cursor: Cursor(1),
108                event: WalletEvent::NewTip {
109                    height: 1,
110                    header_hash: "a".into(),
111                },
112            },
113            EmittedEvent {
114                cursor: Cursor(2),
115                event: WalletEvent::FundsReceived {
116                    wallet_id: WalletId(1),
117                    asset: None,
118                    amount: Amount(5),
119                    coin_id: "c".into(),
120                    confirmed_height: 10,
121                },
122            },
123        ];
124        let kept = filter_events(events, EventKind::FundsReceived.into());
125        assert_eq!(kept.len(), 1);
126        assert_eq!(kept[0].cursor, Cursor(2));
127    }
128
129    #[test]
130    fn amount_exposes_mojos() {
131        assert_eq!(Amount(42).mojos(), 42);
132    }
133
134    #[test]
135    fn newtypes_display() {
136        assert_eq!(WalletId(3).to_string(), "3");
137        assert_eq!(Amount(9).to_string(), "9");
138        assert_eq!(AssetId("tail".into()).to_string(), "tail");
139    }
140
141    #[test]
142    fn sync_status_round_trips() {
143        let s = SyncStatus {
144            state: SyncLifecycle::Synced,
145            peak_height: 100,
146            target_height: 100,
147        };
148        let json = serde_json::to_string(&s).unwrap();
149        assert!(json.contains("\"state\":\"synced\""), "{json}");
150        let back: SyncStatus = serde_json::from_str(&json).unwrap();
151        assert_eq!(back, s);
152    }
153}