Skip to main content

macula_rust/
bolt4.rs

1//! BOLT#4-style error taxonomy for CALL failures, ported from
2//! `src/peering/macula_bolt4.erl` (`macula-io/macula`) — see
3//! `plans/PLAN_WIRE_PROTOCOL.md` §9. Adapted from Lightning Network's
4//! BOLT#4 onion-failure codes: a small, specific taxonomy that prevents
5//! retry loops and enables post-mortem, rather than an open-ended error
6//! string. Codes are stable across V2 minor versions; new codes append
7//! at the next free integer.
8//!
9//! The retry policy is advisory — a caller's own CALL state machine is
10//! the actual decision point (not implemented by this module).
11
12/// The 17 codes macula's own `table/0` defines, in order.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Code {
15    Ok = 0x00,
16    UnknownNextPeer = 0x01,
17    TemporaryRelayFailure = 0x02,
18    RelayDisabled = 0x03,
19    NodeNotFoundAtTargetRelay = 0x04,
20    TargetRealmRefused = 0x05,
21    LoopDetected = 0x06,
22    ExpiryTooSoon = 0x07,
23    UpstreamCongestion = 0x08,
24    InvalidPathHeader = 0x09,
25    CryptoPuzzleInvalid = 0x0A,
26    RealmNotAuthoritativeHere = 0x0B,
27    Tombstoned = 0x0C,
28    PayloadTooLarge = 0x0D,
29    SignatureInvalid = 0x0E,
30    UnknownError = 0x0F,
31    /// Direct-dial dual-trust: the caller lacked a valid UCAN capability
32    /// for a gated procedure.
33    Unauthorized = 0x10,
34}
35
36/// Whether the retry policy for a code permits retrying at all. `none`
37/// (success), `application` (handler-level remedy), and `crypto_drop`
38/// (security-critical) are all non-retryable — everything else means
39/// "retry, differently."
40impl Code {
41    pub fn as_u8(self) -> u8 {
42        self as u8
43    }
44
45    pub fn name(self) -> &'static str {
46        match self {
47            Code::Ok => "ok",
48            Code::UnknownNextPeer => "unknown_next_peer",
49            Code::TemporaryRelayFailure => "temporary_relay_failure",
50            Code::RelayDisabled => "relay_disabled",
51            Code::NodeNotFoundAtTargetRelay => "node_not_found_at_target_relay",
52            Code::TargetRealmRefused => "target_realm_refused",
53            Code::LoopDetected => "loop_detected",
54            Code::ExpiryTooSoon => "expiry_too_soon",
55            Code::UpstreamCongestion => "upstream_congestion",
56            Code::InvalidPathHeader => "invalid_path_header",
57            Code::CryptoPuzzleInvalid => "crypto_puzzle_invalid",
58            Code::RealmNotAuthoritativeHere => "realm_not_authoritative_here",
59            Code::Tombstoned => "tombstoned",
60            Code::PayloadTooLarge => "payload_too_large",
61            Code::SignatureInvalid => "signature_invalid",
62            Code::UnknownError => "unknown_error",
63            Code::Unauthorized => "unauthorized",
64        }
65    }
66
67    pub fn is_retryable(self) -> bool {
68        !matches!(
69            self,
70            Code::Ok
71                | Code::TargetRealmRefused
72                | Code::Tombstoned
73                | Code::PayloadTooLarge
74                | Code::Unauthorized
75                | Code::CryptoPuzzleInvalid
76                | Code::SignatureInvalid
77        )
78    }
79
80    pub fn from_u8(code: u8) -> Option<Code> {
81        Some(match code {
82            0x00 => Code::Ok,
83            0x01 => Code::UnknownNextPeer,
84            0x02 => Code::TemporaryRelayFailure,
85            0x03 => Code::RelayDisabled,
86            0x04 => Code::NodeNotFoundAtTargetRelay,
87            0x05 => Code::TargetRealmRefused,
88            0x06 => Code::LoopDetected,
89            0x07 => Code::ExpiryTooSoon,
90            0x08 => Code::UpstreamCongestion,
91            0x09 => Code::InvalidPathHeader,
92            0x0A => Code::CryptoPuzzleInvalid,
93            0x0B => Code::RealmNotAuthoritativeHere,
94            0x0C => Code::Tombstoned,
95            0x0D => Code::PayloadTooLarge,
96            0x0E => Code::SignatureInvalid,
97            0x0F => Code::UnknownError,
98            0x10 => Code::Unauthorized,
99            _ => return None,
100        })
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn round_trips_every_defined_code() {
110        for code in 0x00u8..=0x10 {
111            let parsed =
112                Code::from_u8(code).unwrap_or_else(|| panic!("code {code:#x} should be defined"));
113            assert_eq!(parsed.as_u8(), code);
114        }
115    }
116
117    #[test]
118    fn unknown_code_is_none() {
119        assert_eq!(Code::from_u8(0x11), None);
120        assert_eq!(Code::from_u8(0xFF), None);
121    }
122
123    #[test]
124    fn non_retryable_codes_match_the_reference_table() {
125        // none | application | crypto_drop, per macula_bolt4.erl's table/0.
126        assert!(!Code::Ok.is_retryable());
127        assert!(!Code::TargetRealmRefused.is_retryable());
128        assert!(!Code::Tombstoned.is_retryable());
129        assert!(!Code::PayloadTooLarge.is_retryable());
130        assert!(!Code::Unauthorized.is_retryable());
131        assert!(!Code::CryptoPuzzleInvalid.is_retryable());
132        assert!(!Code::SignatureInvalid.is_retryable());
133    }
134
135    #[test]
136    fn retryable_codes_match_the_reference_table() {
137        assert!(Code::UnknownNextPeer.is_retryable());
138        assert!(Code::TemporaryRelayFailure.is_retryable());
139        assert!(Code::RelayDisabled.is_retryable());
140        assert!(Code::NodeNotFoundAtTargetRelay.is_retryable());
141        assert!(Code::LoopDetected.is_retryable());
142        assert!(Code::ExpiryTooSoon.is_retryable());
143        assert!(Code::UpstreamCongestion.is_retryable());
144        assert!(Code::InvalidPathHeader.is_retryable());
145        assert!(Code::RealmNotAuthoritativeHere.is_retryable());
146        assert!(Code::UnknownError.is_retryable());
147    }
148
149    #[test]
150    fn names_match_the_reference_spelling() {
151        assert_eq!(Code::UnknownNextPeer.name(), "unknown_next_peer");
152        assert_eq!(Code::Unauthorized.name(), "unauthorized");
153    }
154}