tap_node/state_machine/fsm.rs
1//! Transaction Finite State Machine (FSM)
2//!
3//! Formal state machine modeling the lifecycle of a TAP transaction from
4//! initiation through authorization to settlement. Each state transition
5//! is driven by an incoming TAP message and may require an external
6//! decision before the node takes action.
7//!
8//! # States
9//!
10//! ```text
11//! ┌──────────────────────────────────────────┐
12//! │ Transaction Lifecycle │
13//! └──────────────────────────────────────────┘
14//!
15//! ┌─────────┐ Transfer/ ┌──────────────┐ UpdatePolicies/ ┌─────────────────┐
16//! │ │ Payment │ │ RequestPresent. │ │
17//! │ (none) │─────────────▶│ Received │─────────────────▶│ PolicyRequired │
18//! │ │ │ │ │ │
19//! └─────────┘ └──────┬───────┘ └────────┬────────┘
20//! │ │
21//! ┌────┴─────┐ Presentation
22//! │ DECISION │ received
23//! │ Authorize│ │
24//! │ Reject │◀────────────────────────┘
25//! │ Cancel │
26//! └────┬─────┘
27//! ┌──────────────┼──────────────┐
28//! │ │ │
29//! Authorize Reject Cancel
30//! │ │ │
31//! ▼ ▼ ▼
32//! ┌──────────────┐ ┌──────────┐ ┌───────────┐
33//! │ Authorized │ │ Rejected │ │ Cancelled │
34//! │ (per agent) │ │ │ │ │
35//! └──────┬───────┘ └──────────┘ └───────────┘
36//! │
37//! all agents authorized?
38//! │ yes
39//! ▼
40//! ┌──────────────────┐
41//! │ ReadyToSettle │
42//! │ │
43//! └────────┬─────────┘
44//! │
45//! ┌────┴─────┐
46//! │ DECISION │
47//! │ Settle │
48//! │ Cancel │
49//! └────┬─────┘
50//! │
51//! Settle
52//! │
53//! ▼
54//! ┌─────────────┐
55//! │ Settled │
56//! └──────┬──────┘
57//! │
58//! Revert?
59//! │
60//! ▼
61//! ┌─────────────┐
62//! │ Reverted │
63//! └─────────────┘
64//! ```
65//!
66//! # Decision Points
67//!
68//! The FSM identifies two categories of transitions:
69//!
70//! - **Automatic**: The node processes the message and moves to the next state
71//! with no external input (e.g., storing a transaction, recording an authorization).
72//!
73//! - **Decision Required**: The transition produces a [`Decision`] that an
74//! external system must resolve before the node takes further action. For
75//! example, when a Transfer arrives the node must decide whether to
76//! Authorize, Reject, or request more information via policies.
77//!
78//! # Per-Agent vs Per-Transaction State
79//!
80//! A transaction has a single top-level [`TransactionState`], but also tracks
81//! per-agent authorization status via [`AgentState`]. The transaction advances
82//! to `ReadyToSettle` only when **all** agents reach `Authorized`.
83
84use async_trait::async_trait;
85use serde::{Deserialize, Serialize};
86use std::collections::HashMap;
87use std::fmt;
88use std::sync::Arc;
89
90// ---------------------------------------------------------------------------
91// Transaction States
92// ---------------------------------------------------------------------------
93
94/// Top-level state of a TAP transaction.
95///
96/// These states represent the full lifecycle from initiation to terminal
97/// states. The FSM enforces that only valid transitions occur.
98#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
99pub enum TransactionState {
100 /// Transaction initiated — a Transfer or Payment has been received and
101 /// stored. The node must now decide how to respond (authorize, reject,
102 /// request more info, or wait for external input).
103 Received,
104
105 /// One or more counterparty policies must be satisfied before
106 /// authorization can proceed. The node is waiting for the external
107 /// system to gather and submit the required presentations or proofs.
108 PolicyRequired,
109
110 /// At least one agent has authorized but not all required agents have
111 /// done so yet. The transaction is waiting for remaining authorizations.
112 PartiallyAuthorized,
113
114 /// All required agents have authorized. The originator may now settle
115 /// the transaction on-chain. This is a decision point — the node must
116 /// decide whether to proceed with settlement.
117 ReadyToSettle,
118
119 /// The originator has sent a Settle message (with an on-chain
120 /// transaction reference). The transaction is considered complete.
121 Settled,
122
123 /// An agent has rejected the transaction. Terminal state.
124 Rejected,
125
126 /// A party has cancelled the transaction. Terminal state.
127 Cancelled,
128
129 /// A previously settled transaction has been reverted. Terminal state.
130 Reverted,
131}
132
133impl TransactionState {
134 /// Returns true if this is a terminal state (no further transitions).
135 pub fn is_terminal(&self) -> bool {
136 matches!(
137 self,
138 TransactionState::Rejected | TransactionState::Cancelled | TransactionState::Reverted
139 )
140 }
141
142 /// Returns true if this state requires an external decision before
143 /// the transaction can advance.
144 pub fn requires_decision(&self) -> bool {
145 matches!(
146 self,
147 TransactionState::Received
148 | TransactionState::PolicyRequired
149 | TransactionState::ReadyToSettle
150 )
151 }
152}
153
154impl fmt::Display for TransactionState {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 match self {
157 TransactionState::Received => write!(f, "received"),
158 TransactionState::PolicyRequired => write!(f, "policy_required"),
159 TransactionState::PartiallyAuthorized => write!(f, "partially_authorized"),
160 TransactionState::ReadyToSettle => write!(f, "ready_to_settle"),
161 TransactionState::Settled => write!(f, "settled"),
162 TransactionState::Rejected => write!(f, "rejected"),
163 TransactionState::Cancelled => write!(f, "cancelled"),
164 TransactionState::Reverted => write!(f, "reverted"),
165 }
166 }
167}
168
169impl std::str::FromStr for TransactionState {
170 type Err = String;
171
172 fn from_str(s: &str) -> Result<Self, Self::Err> {
173 match s {
174 "received" => Ok(TransactionState::Received),
175 "policy_required" => Ok(TransactionState::PolicyRequired),
176 "partially_authorized" => Ok(TransactionState::PartiallyAuthorized),
177 "ready_to_settle" => Ok(TransactionState::ReadyToSettle),
178 "settled" => Ok(TransactionState::Settled),
179 "rejected" => Ok(TransactionState::Rejected),
180 "cancelled" => Ok(TransactionState::Cancelled),
181 "reverted" => Ok(TransactionState::Reverted),
182 _ => Err(format!("Invalid transaction state: {}", s)),
183 }
184 }
185}
186
187// ---------------------------------------------------------------------------
188// Per-Agent States
189// ---------------------------------------------------------------------------
190
191/// Authorization state of an individual agent within a transaction.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub enum AgentState {
194 /// Agent has been added to the transaction but has not yet responded.
195 Pending,
196
197 /// Agent has sent an Authorize message.
198 Authorized,
199
200 /// Agent has sent a Reject message.
201 Rejected,
202
203 /// Agent has been removed from the transaction.
204 Removed,
205}
206
207impl fmt::Display for AgentState {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 match self {
210 AgentState::Pending => write!(f, "pending"),
211 AgentState::Authorized => write!(f, "authorized"),
212 AgentState::Rejected => write!(f, "rejected"),
213 AgentState::Removed => write!(f, "removed"),
214 }
215 }
216}
217
218// ---------------------------------------------------------------------------
219// Events (incoming messages that drive transitions)
220// ---------------------------------------------------------------------------
221
222/// An event that can trigger a state transition in the FSM.
223///
224/// Each variant corresponds to a TAP message type that affects transaction
225/// state. Events carry only the data needed for the state transition, not
226/// the full message payload.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub enum FsmEvent {
229 /// A new Transfer or Payment was received, initiating the transaction.
230 TransactionReceived {
231 /// DIDs of agents involved in this transaction.
232 agent_dids: Vec<String>,
233 },
234
235 /// An agent sent an Authorize message for this transaction.
236 AuthorizeReceived {
237 /// DID of the agent that authorized.
238 agent_did: String,
239 /// Optional settlement address provided by the agent.
240 settlement_address: Option<String>,
241 /// Optional expiry for this authorization.
242 expiry: Option<String>,
243 },
244
245 /// An agent sent a Reject message for this transaction.
246 RejectReceived {
247 /// DID of the agent that rejected.
248 agent_did: String,
249 /// Optional reason for rejection.
250 reason: Option<String>,
251 },
252
253 /// A party sent a Cancel message.
254 CancelReceived {
255 /// DID of the party that cancelled.
256 by_did: String,
257 /// Optional reason for cancellation.
258 reason: Option<String>,
259 },
260
261 /// A counterparty sent UpdatePolicies, indicating requirements that
262 /// must be fulfilled before they will authorize.
263 PoliciesReceived {
264 /// DID of the party that sent the policies.
265 from_did: String,
266 },
267
268 /// A Presentation was received satisfying (some) outstanding policies.
269 PresentationReceived {
270 /// DID of the party that sent the presentation.
271 from_did: String,
272 },
273
274 /// The originator sent a Settle message with an on-chain reference.
275 SettleReceived {
276 /// On-chain settlement identifier (CAIP-220).
277 settlement_id: Option<String>,
278 /// Actual amount settled.
279 amount: Option<String>,
280 },
281
282 /// A party sent a Revert message for a settled transaction.
283 RevertReceived {
284 /// DID of the party requesting revert.
285 by_did: String,
286 /// Reason for reversal.
287 reason: String,
288 },
289
290 /// New agents were added to the transaction (TAIP-5).
291 AgentsAdded {
292 /// DIDs of newly added agents.
293 agent_dids: Vec<String>,
294 },
295
296 /// An agent was removed from the transaction (TAIP-5).
297 AgentRemoved {
298 /// DID of the removed agent.
299 agent_did: String,
300 },
301}
302
303impl fmt::Display for FsmEvent {
304 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305 match self {
306 FsmEvent::TransactionReceived { .. } => write!(f, "TransactionReceived"),
307 FsmEvent::AuthorizeReceived { agent_did, .. } => {
308 write!(f, "AuthorizeReceived({})", agent_did)
309 }
310 FsmEvent::RejectReceived { agent_did, .. } => {
311 write!(f, "RejectReceived({})", agent_did)
312 }
313 FsmEvent::CancelReceived { by_did, .. } => write!(f, "CancelReceived({})", by_did),
314 FsmEvent::PoliciesReceived { from_did } => {
315 write!(f, "PoliciesReceived({})", from_did)
316 }
317 FsmEvent::PresentationReceived { from_did } => {
318 write!(f, "PresentationReceived({})", from_did)
319 }
320 FsmEvent::SettleReceived { .. } => write!(f, "SettleReceived"),
321 FsmEvent::RevertReceived { by_did, .. } => write!(f, "RevertReceived({})", by_did),
322 FsmEvent::AgentsAdded { agent_dids } => {
323 write!(f, "AgentsAdded({})", agent_dids.join(", "))
324 }
325 FsmEvent::AgentRemoved { agent_did } => write!(f, "AgentRemoved({})", agent_did),
326 }
327 }
328}
329
330// ---------------------------------------------------------------------------
331// Decisions (what the external system must resolve)
332// ---------------------------------------------------------------------------
333
334/// A decision that an external system must make before the FSM can advance.
335///
336/// When the FSM reaches a decision point, it returns one of these variants
337/// describing the choices available. The external system (compliance engine,
338/// human operator, business rules) must call back with the chosen action.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub enum Decision {
341 /// A new transaction was received. The external system must decide
342 /// how to respond.
343 ///
344 /// **Available actions:**
345 /// - Send `Authorize` to approve
346 /// - Send `Reject` to deny
347 /// - Send `UpdatePolicies` to request more information
348 /// - Send `RequestPresentation` to request credentials
349 /// - Do nothing (wait for more context)
350 AuthorizationRequired {
351 /// The transaction ID requiring a decision.
352 transaction_id: String,
353 /// DIDs of agents that need to make a decision.
354 pending_agents: Vec<String>,
355 },
356
357 /// Outstanding policies must be satisfied before authorization can
358 /// proceed. The external system must gather the required data and
359 /// submit it.
360 ///
361 /// **Available actions:**
362 /// - Send `Presentation` with requested credentials
363 /// - Send `ConfirmRelationship` to prove agent-party link
364 /// - Send `Reject` if policies cannot be satisfied
365 /// - Send `Cancel` to abort
366 PolicySatisfactionRequired {
367 /// The transaction ID.
368 transaction_id: String,
369 /// DID of the party that requested policies.
370 requested_by: String,
371 },
372
373 /// All agents have authorized. The originator must decide whether to
374 /// execute settlement on-chain and send a Settle message.
375 ///
376 /// **Available actions:**
377 /// - Execute on-chain settlement and send `Settle`
378 /// - Send `Cancel` if settlement should not proceed
379 SettlementRequired {
380 /// The transaction ID.
381 transaction_id: String,
382 },
383}
384
385impl fmt::Display for Decision {
386 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387 match self {
388 Decision::AuthorizationRequired {
389 transaction_id,
390 pending_agents,
391 } => write!(
392 f,
393 "AuthorizationRequired(tx={}, agents={})",
394 transaction_id,
395 pending_agents.join(", ")
396 ),
397 Decision::PolicySatisfactionRequired {
398 transaction_id,
399 requested_by,
400 } => write!(
401 f,
402 "PolicySatisfactionRequired(tx={}, by={})",
403 transaction_id, requested_by
404 ),
405 Decision::SettlementRequired { transaction_id } => {
406 write!(f, "SettlementRequired(tx={})", transaction_id)
407 }
408 }
409 }
410}
411
412// ---------------------------------------------------------------------------
413// Transition result
414// ---------------------------------------------------------------------------
415
416/// The outcome of applying an event to the FSM.
417#[derive(Debug, Clone)]
418pub struct Transition {
419 /// The state before the transition.
420 pub from_state: TransactionState,
421 /// The state after the transition.
422 pub to_state: TransactionState,
423 /// The event that triggered this transition.
424 pub event: FsmEvent,
425 /// If the new state is a decision point, this describes the decision
426 /// that must be made by the external system.
427 pub decision: Option<Decision>,
428}
429
430// ---------------------------------------------------------------------------
431// Transaction FSM context
432// ---------------------------------------------------------------------------
433
434/// Error returned when an invalid transition is attempted.
435#[derive(Debug, Clone)]
436pub struct InvalidTransition {
437 pub current_state: TransactionState,
438 pub event: FsmEvent,
439 pub reason: String,
440}
441
442impl fmt::Display for InvalidTransition {
443 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
444 write!(
445 f,
446 "Invalid transition: cannot apply {} in state {} ({})",
447 self.event, self.current_state, self.reason
448 )
449 }
450}
451
452impl std::error::Error for InvalidTransition {}
453
454/// The in-memory state of a single transaction tracked by the FSM.
455///
456/// This struct holds the current state plus per-agent tracking. It is the
457/// core data structure manipulated by [`TransactionFsm::apply`].
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct TransactionContext {
460 /// Unique transaction identifier (the DIDComm message ID of the
461 /// initiating Transfer/Payment).
462 pub transaction_id: String,
463
464 /// Current top-level state.
465 pub state: TransactionState,
466
467 /// Per-agent authorization state keyed by agent DID.
468 pub agents: HashMap<String, AgentState>,
469
470 /// Whether outstanding policies have been received that must be
471 /// satisfied before authorization can proceed.
472 pub has_pending_policies: bool,
473}
474
475impl TransactionContext {
476 /// Create a new transaction context in the `Received` state.
477 pub fn new(transaction_id: String, agent_dids: Vec<String>) -> Self {
478 let agents = agent_dids
479 .into_iter()
480 .map(|did| (did, AgentState::Pending))
481 .collect();
482
483 Self {
484 transaction_id,
485 state: TransactionState::Received,
486 agents,
487 has_pending_policies: false,
488 }
489 }
490
491 /// Returns true if all tracked agents have authorized.
492 pub fn all_agents_authorized(&self) -> bool {
493 if self.agents.is_empty() {
494 return true;
495 }
496 self.agents
497 .values()
498 .filter(|s| **s != AgentState::Removed)
499 .all(|s| *s == AgentState::Authorized)
500 }
501
502 /// Returns the DIDs of agents still in `Pending` state.
503 pub fn pending_agents(&self) -> Vec<String> {
504 self.agents
505 .iter()
506 .filter(|(_, s)| **s == AgentState::Pending)
507 .map(|(did, _)| did.clone())
508 .collect()
509 }
510}
511
512// ---------------------------------------------------------------------------
513// The FSM engine
514// ---------------------------------------------------------------------------
515
516/// Pure-logic FSM engine for TAP transactions.
517///
518/// This struct contains no I/O — it operates only on [`TransactionContext`]
519/// and returns [`Transition`] values describing what happened and what
520/// decisions are needed. The caller (typically the `StandardTransactionProcessor`
521/// or a higher-level orchestrator) is responsible for:
522///
523/// 1. Persisting state changes
524/// 2. Publishing events
525/// 3. Presenting decisions to external systems
526/// 4. Sending response messages
527pub struct TransactionFsm;
528
529impl TransactionFsm {
530 /// Apply an event to a transaction context, producing a state transition.
531 ///
532 /// Returns `Ok(Transition)` on success, describing the state change and
533 /// any decision required. Returns `Err(InvalidTransition)` if the event
534 /// is not valid in the current state.
535 pub fn apply(
536 ctx: &mut TransactionContext,
537 event: FsmEvent,
538 ) -> Result<Transition, InvalidTransition> {
539 let from_state = ctx.state.clone();
540
541 // Terminal states accept no further events
542 if ctx.state.is_terminal() {
543 return Err(InvalidTransition {
544 current_state: from_state,
545 event,
546 reason: "transaction is in a terminal state".to_string(),
547 });
548 }
549
550 match (&ctx.state, &event) {
551 // ----- Initiation -----
552 (TransactionState::Received, FsmEvent::TransactionReceived { .. }) => {
553 // This is the initial setup — context was just created.
554 // Stay in Received; the decision is whether to authorize.
555 let decision = Some(Decision::AuthorizationRequired {
556 transaction_id: ctx.transaction_id.clone(),
557 pending_agents: ctx.pending_agents(),
558 });
559 Ok(Transition {
560 from_state,
561 to_state: ctx.state.clone(),
562 event,
563 decision,
564 })
565 }
566
567 // ----- Policy exchange -----
568 (
569 TransactionState::Received | TransactionState::PolicyRequired,
570 FsmEvent::PoliciesReceived { from_did },
571 ) => {
572 ctx.has_pending_policies = true;
573 ctx.state = TransactionState::PolicyRequired;
574 let decision = Some(Decision::PolicySatisfactionRequired {
575 transaction_id: ctx.transaction_id.clone(),
576 requested_by: from_did.clone(),
577 });
578 Ok(Transition {
579 from_state,
580 to_state: ctx.state.clone(),
581 event,
582 decision,
583 })
584 }
585
586 (TransactionState::PolicyRequired, FsmEvent::PresentationReceived { .. }) => {
587 // Presentation received — assume policies are satisfied for now.
588 // A real implementation would check if ALL policies are met.
589 ctx.has_pending_policies = false;
590 ctx.state = TransactionState::Received;
591 let decision = Some(Decision::AuthorizationRequired {
592 transaction_id: ctx.transaction_id.clone(),
593 pending_agents: ctx.pending_agents(),
594 });
595 Ok(Transition {
596 from_state,
597 to_state: ctx.state.clone(),
598 event,
599 decision,
600 })
601 }
602
603 // ----- Authorization -----
604 (
605 TransactionState::Received
606 | TransactionState::PartiallyAuthorized
607 | TransactionState::PolicyRequired,
608 FsmEvent::AuthorizeReceived { agent_did, .. },
609 ) => {
610 // Record the agent's authorization
611 if let Some(agent_state) = ctx.agents.get_mut(agent_did) {
612 *agent_state = AgentState::Authorized;
613 }
614
615 // Determine new transaction state
616 if ctx.all_agents_authorized() {
617 ctx.state = TransactionState::ReadyToSettle;
618 let decision = Some(Decision::SettlementRequired {
619 transaction_id: ctx.transaction_id.clone(),
620 });
621 Ok(Transition {
622 from_state,
623 to_state: ctx.state.clone(),
624 event,
625 decision,
626 })
627 } else {
628 ctx.state = TransactionState::PartiallyAuthorized;
629 Ok(Transition {
630 from_state,
631 to_state: ctx.state.clone(),
632 event,
633 decision: None,
634 })
635 }
636 }
637
638 // Authorization can also arrive in ReadyToSettle if a new agent
639 // was added after others already authorized.
640 (TransactionState::ReadyToSettle, FsmEvent::AuthorizeReceived { agent_did, .. }) => {
641 if let Some(agent_state) = ctx.agents.get_mut(agent_did) {
642 *agent_state = AgentState::Authorized;
643 }
644 // Re-check if all are still authorized
645 if ctx.all_agents_authorized() {
646 let decision = Some(Decision::SettlementRequired {
647 transaction_id: ctx.transaction_id.clone(),
648 });
649 Ok(Transition {
650 from_state,
651 to_state: ctx.state.clone(),
652 event,
653 decision,
654 })
655 } else {
656 ctx.state = TransactionState::PartiallyAuthorized;
657 Ok(Transition {
658 from_state,
659 to_state: ctx.state.clone(),
660 event,
661 decision: None,
662 })
663 }
664 }
665
666 // ----- Rejection -----
667 (_, FsmEvent::RejectReceived { agent_did, .. }) => {
668 if let Some(agent_state) = ctx.agents.get_mut(agent_did) {
669 *agent_state = AgentState::Rejected;
670 }
671 ctx.state = TransactionState::Rejected;
672 Ok(Transition {
673 from_state,
674 to_state: ctx.state.clone(),
675 event,
676 decision: None,
677 })
678 }
679
680 // ----- Cancellation -----
681 (_, FsmEvent::CancelReceived { .. }) => {
682 ctx.state = TransactionState::Cancelled;
683 Ok(Transition {
684 from_state,
685 to_state: ctx.state.clone(),
686 event,
687 decision: None,
688 })
689 }
690
691 // ----- Settlement -----
692 (TransactionState::ReadyToSettle, FsmEvent::SettleReceived { .. }) => {
693 ctx.state = TransactionState::Settled;
694 Ok(Transition {
695 from_state,
696 to_state: ctx.state.clone(),
697 event,
698 decision: None,
699 })
700 }
701
702 // ----- Revert -----
703 (TransactionState::Settled, FsmEvent::RevertReceived { .. }) => {
704 ctx.state = TransactionState::Reverted;
705 Ok(Transition {
706 from_state,
707 to_state: ctx.state.clone(),
708 event,
709 decision: None,
710 })
711 }
712
713 // ----- Agent management (TAIP-5) -----
714 (_, FsmEvent::AgentsAdded { agent_dids }) => {
715 for did in agent_dids {
716 ctx.agents.entry(did.clone()).or_insert(AgentState::Pending);
717 }
718 // Adding agents may move us out of ReadyToSettle if new
719 // agents are pending.
720 if from_state == TransactionState::ReadyToSettle && !ctx.all_agents_authorized() {
721 ctx.state = TransactionState::PartiallyAuthorized;
722 }
723 Ok(Transition {
724 from_state,
725 to_state: ctx.state.clone(),
726 event,
727 decision: None,
728 })
729 }
730
731 (_, FsmEvent::AgentRemoved { agent_did }) => {
732 if let Some(agent_state) = ctx.agents.get_mut(agent_did) {
733 *agent_state = AgentState::Removed;
734 }
735 // Removing an agent may make all remaining agents authorized.
736 if matches!(
737 ctx.state,
738 TransactionState::PartiallyAuthorized | TransactionState::Received
739 ) && ctx.all_agents_authorized()
740 {
741 ctx.state = TransactionState::ReadyToSettle;
742 let decision = Some(Decision::SettlementRequired {
743 transaction_id: ctx.transaction_id.clone(),
744 });
745 return Ok(Transition {
746 from_state,
747 to_state: ctx.state.clone(),
748 event,
749 decision,
750 });
751 }
752 Ok(Transition {
753 from_state,
754 to_state: ctx.state.clone(),
755 event,
756 decision: None,
757 })
758 }
759
760 // ----- Invalid transitions -----
761 _ => Err(InvalidTransition {
762 current_state: from_state,
763 event: event.clone(),
764 reason: format!("event {} is not valid in state {}", event, ctx.state),
765 }),
766 }
767 }
768
769 /// Returns all valid events for a given state (for documentation/UI).
770 pub fn valid_events(state: &TransactionState) -> Vec<&'static str> {
771 match state {
772 TransactionState::Received => vec![
773 "TransactionReceived",
774 "AuthorizeReceived",
775 "RejectReceived",
776 "CancelReceived",
777 "PoliciesReceived",
778 "AgentsAdded",
779 "AgentRemoved",
780 ],
781 TransactionState::PolicyRequired => vec![
782 "PresentationReceived",
783 "PoliciesReceived",
784 "AuthorizeReceived",
785 "RejectReceived",
786 "CancelReceived",
787 "AgentsAdded",
788 "AgentRemoved",
789 ],
790 TransactionState::PartiallyAuthorized => vec![
791 "AuthorizeReceived",
792 "RejectReceived",
793 "CancelReceived",
794 "AgentsAdded",
795 "AgentRemoved",
796 ],
797 TransactionState::ReadyToSettle => vec![
798 "SettleReceived",
799 "AuthorizeReceived",
800 "RejectReceived",
801 "CancelReceived",
802 "AgentsAdded",
803 "AgentRemoved",
804 ],
805 TransactionState::Settled => vec!["RevertReceived"],
806 TransactionState::Rejected
807 | TransactionState::Cancelled
808 | TransactionState::Reverted => {
809 vec![]
810 }
811 }
812 }
813}
814
815// ---------------------------------------------------------------------------
816// Decision handler configuration
817// ---------------------------------------------------------------------------
818
819/// Controls how the node handles decision points during transaction
820/// processing.
821///
822/// This enum is set on [`NodeConfig`] and determines which
823/// [`DecisionHandler`] implementation the `StandardTransactionProcessor`
824/// uses at runtime.
825#[derive(Debug, Clone, Default)]
826pub enum DecisionMode {
827 /// Automatically approve all decisions — the node will immediately
828 /// send Authorize messages for registered agents and Settle when all
829 /// agents have authorized. This is the current default behavior and
830 /// is suitable for testing or fully-automated deployments.
831 #[default]
832 AutoApprove,
833
834 /// Publish each decision as a [`NodeEvent::DecisionRequired`] on the
835 /// event bus. No automatic action is taken — an external subscriber
836 /// (compliance engine, human operator UI, business rules engine) must
837 /// listen for these events and call back into the node to advance the
838 /// transaction.
839 EventBus,
840
841 /// Use a custom decision handler provided by the caller.
842 Custom(Arc<dyn DecisionHandler>),
843}
844
845// ---------------------------------------------------------------------------
846// Decision handler trait
847// ---------------------------------------------------------------------------
848
849/// Trait for handling FSM decision points.
850///
851/// When the FSM reaches a state that requires an external decision
852/// (e.g., whether to authorize a new transfer), the
853/// `StandardTransactionProcessor` calls the configured `DecisionHandler`.
854///
855/// Implementations can auto-approve, publish to an event bus, call out
856/// to a compliance API, present a UI to a human operator, etc.
857#[async_trait]
858pub trait DecisionHandler: Send + Sync + fmt::Debug {
859 /// Called when the FSM produces a [`Decision`].
860 ///
861 /// The handler receives the full [`TransactionContext`] (current state,
862 /// per-agent status) and the [`Decision`] describing what needs to be
863 /// resolved.
864 ///
865 /// Implementations that auto-resolve should return the same `Decision`
866 /// back. Implementations that defer to external systems should publish
867 /// the decision and return it for auditing.
868 async fn handle_decision(&self, ctx: &TransactionContext, decision: &Decision);
869}
870
871// ---------------------------------------------------------------------------
872// Built-in: AutoApproveHandler
873// ---------------------------------------------------------------------------
874
875/// Decision handler that automatically approves all decisions.
876///
877/// - `AuthorizationRequired` → queues Authorize messages for all registered
878/// agents (the actual sending is done by the processor, not this handler)
879/// - `SettlementRequired` → allows the processor to send Settle
880/// - `PolicySatisfactionRequired` → logged, no action (policies are not
881/// auto-satisfiable)
882///
883/// This preserves the existing tap-node behavior where registered agents
884/// are auto-authorized and settlement is automatic.
885#[derive(Debug)]
886pub struct AutoApproveHandler;
887
888#[async_trait]
889impl DecisionHandler for AutoApproveHandler {
890 async fn handle_decision(&self, _ctx: &TransactionContext, decision: &Decision) {
891 log::debug!("AutoApproveHandler: auto-resolving {}", decision);
892 }
893}
894
895// ---------------------------------------------------------------------------
896// Built-in: LogOnlyHandler
897// ---------------------------------------------------------------------------
898
899/// Decision handler that only logs decisions without taking action.
900///
901/// Useful for monitoring/observability when an external system handles
902/// decisions through the event bus channel subscription instead of the
903/// `DecisionHandler` trait.
904#[derive(Debug)]
905pub struct LogOnlyHandler;
906
907#[async_trait]
908impl DecisionHandler for LogOnlyHandler {
909 async fn handle_decision(&self, ctx: &TransactionContext, decision: &Decision) {
910 log::info!(
911 "Decision required for transaction {} (state={}): {}",
912 ctx.transaction_id,
913 ctx.state,
914 decision
915 );
916 }
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922
923 fn make_ctx(agents: &[&str]) -> TransactionContext {
924 TransactionContext::new(
925 "tx-001".to_string(),
926 agents.iter().map(|s| s.to_string()).collect(),
927 )
928 }
929
930 #[test]
931 fn test_happy_path_single_agent() {
932 let mut ctx = make_ctx(&["did:example:compliance"]);
933 assert_eq!(ctx.state, TransactionState::Received);
934
935 // Receive transaction
936 let t = TransactionFsm::apply(
937 &mut ctx,
938 FsmEvent::TransactionReceived {
939 agent_dids: vec!["did:example:compliance".to_string()],
940 },
941 )
942 .unwrap();
943 assert_eq!(t.to_state, TransactionState::Received);
944 assert!(t.decision.is_some());
945 assert!(matches!(
946 t.decision.unwrap(),
947 Decision::AuthorizationRequired { .. }
948 ));
949
950 // Agent authorizes
951 let t = TransactionFsm::apply(
952 &mut ctx,
953 FsmEvent::AuthorizeReceived {
954 agent_did: "did:example:compliance".to_string(),
955 settlement_address: None,
956 expiry: None,
957 },
958 )
959 .unwrap();
960 assert_eq!(t.to_state, TransactionState::ReadyToSettle);
961 assert!(matches!(
962 t.decision.unwrap(),
963 Decision::SettlementRequired { .. }
964 ));
965
966 // Settle
967 let t = TransactionFsm::apply(
968 &mut ctx,
969 FsmEvent::SettleReceived {
970 settlement_id: Some("eip155:1:tx/0xabc".to_string()),
971 amount: None,
972 },
973 )
974 .unwrap();
975 assert_eq!(t.to_state, TransactionState::Settled);
976 assert!(t.decision.is_none());
977 }
978
979 #[test]
980 fn test_happy_path_multi_agent() {
981 let mut ctx = make_ctx(&["did:example:a", "did:example:b"]);
982
983 // First agent authorizes
984 let t = TransactionFsm::apply(
985 &mut ctx,
986 FsmEvent::AuthorizeReceived {
987 agent_did: "did:example:a".to_string(),
988 settlement_address: None,
989 expiry: None,
990 },
991 )
992 .unwrap();
993 assert_eq!(t.to_state, TransactionState::PartiallyAuthorized);
994 assert!(t.decision.is_none());
995
996 // Second agent authorizes
997 let t = TransactionFsm::apply(
998 &mut ctx,
999 FsmEvent::AuthorizeReceived {
1000 agent_did: "did:example:b".to_string(),
1001 settlement_address: None,
1002 expiry: None,
1003 },
1004 )
1005 .unwrap();
1006 assert_eq!(t.to_state, TransactionState::ReadyToSettle);
1007 assert!(matches!(
1008 t.decision.unwrap(),
1009 Decision::SettlementRequired { .. }
1010 ));
1011 }
1012
1013 #[test]
1014 fn test_rejection() {
1015 let mut ctx = make_ctx(&["did:example:a"]);
1016
1017 let t = TransactionFsm::apply(
1018 &mut ctx,
1019 FsmEvent::RejectReceived {
1020 agent_did: "did:example:a".to_string(),
1021 reason: Some("sanctions screening failed".to_string()),
1022 },
1023 )
1024 .unwrap();
1025 assert_eq!(t.to_state, TransactionState::Rejected);
1026 assert!(ctx.state.is_terminal());
1027 }
1028
1029 #[test]
1030 fn test_cancellation() {
1031 let mut ctx = make_ctx(&["did:example:a"]);
1032
1033 let t = TransactionFsm::apply(
1034 &mut ctx,
1035 FsmEvent::CancelReceived {
1036 by_did: "did:example:originator".to_string(),
1037 reason: None,
1038 },
1039 )
1040 .unwrap();
1041 assert_eq!(t.to_state, TransactionState::Cancelled);
1042 assert!(ctx.state.is_terminal());
1043 }
1044
1045 #[test]
1046 fn test_policy_flow() {
1047 let mut ctx = make_ctx(&["did:example:a"]);
1048
1049 // Counterparty sends policies
1050 let t = TransactionFsm::apply(
1051 &mut ctx,
1052 FsmEvent::PoliciesReceived {
1053 from_did: "did:example:beneficiary-vasp".to_string(),
1054 },
1055 )
1056 .unwrap();
1057 assert_eq!(t.to_state, TransactionState::PolicyRequired);
1058 assert!(matches!(
1059 t.decision.unwrap(),
1060 Decision::PolicySatisfactionRequired { .. }
1061 ));
1062
1063 // We send a presentation satisfying the policies
1064 let t = TransactionFsm::apply(
1065 &mut ctx,
1066 FsmEvent::PresentationReceived {
1067 from_did: "did:example:originator-vasp".to_string(),
1068 },
1069 )
1070 .unwrap();
1071 assert_eq!(t.to_state, TransactionState::Received);
1072 assert!(matches!(
1073 t.decision.unwrap(),
1074 Decision::AuthorizationRequired { .. }
1075 ));
1076
1077 // Now agent can authorize
1078 let t = TransactionFsm::apply(
1079 &mut ctx,
1080 FsmEvent::AuthorizeReceived {
1081 agent_did: "did:example:a".to_string(),
1082 settlement_address: None,
1083 expiry: None,
1084 },
1085 )
1086 .unwrap();
1087 assert_eq!(t.to_state, TransactionState::ReadyToSettle);
1088 }
1089
1090 #[test]
1091 fn test_revert() {
1092 let mut ctx = make_ctx(&["did:example:a"]);
1093
1094 // Authorize → Settle → Revert
1095 TransactionFsm::apply(
1096 &mut ctx,
1097 FsmEvent::AuthorizeReceived {
1098 agent_did: "did:example:a".to_string(),
1099 settlement_address: None,
1100 expiry: None,
1101 },
1102 )
1103 .unwrap();
1104
1105 TransactionFsm::apply(
1106 &mut ctx,
1107 FsmEvent::SettleReceived {
1108 settlement_id: None,
1109 amount: None,
1110 },
1111 )
1112 .unwrap();
1113
1114 let t = TransactionFsm::apply(
1115 &mut ctx,
1116 FsmEvent::RevertReceived {
1117 by_did: "did:example:beneficiary".to_string(),
1118 reason: "incorrect amount".to_string(),
1119 },
1120 )
1121 .unwrap();
1122 assert_eq!(t.to_state, TransactionState::Reverted);
1123 assert!(ctx.state.is_terminal());
1124 }
1125
1126 #[test]
1127 fn test_terminal_state_rejects_events() {
1128 let mut ctx = make_ctx(&["did:example:a"]);
1129 ctx.state = TransactionState::Rejected;
1130
1131 let result = TransactionFsm::apply(
1132 &mut ctx,
1133 FsmEvent::AuthorizeReceived {
1134 agent_did: "did:example:a".to_string(),
1135 settlement_address: None,
1136 expiry: None,
1137 },
1138 );
1139 assert!(result.is_err());
1140 }
1141
1142 #[test]
1143 fn test_settle_only_from_ready_to_settle() {
1144 let mut ctx = make_ctx(&["did:example:a"]);
1145 // Still in Received state, try to settle
1146 let result = TransactionFsm::apply(
1147 &mut ctx,
1148 FsmEvent::SettleReceived {
1149 settlement_id: None,
1150 amount: None,
1151 },
1152 );
1153 assert!(result.is_err());
1154 }
1155
1156 #[test]
1157 fn test_add_agents_blocks_settlement() {
1158 let mut ctx = make_ctx(&["did:example:a"]);
1159
1160 // Authorize the first agent
1161 TransactionFsm::apply(
1162 &mut ctx,
1163 FsmEvent::AuthorizeReceived {
1164 agent_did: "did:example:a".to_string(),
1165 settlement_address: None,
1166 expiry: None,
1167 },
1168 )
1169 .unwrap();
1170 assert_eq!(ctx.state, TransactionState::ReadyToSettle);
1171
1172 // Add a new agent — should move back to PartiallyAuthorized
1173 let t = TransactionFsm::apply(
1174 &mut ctx,
1175 FsmEvent::AgentsAdded {
1176 agent_dids: vec!["did:example:b".to_string()],
1177 },
1178 )
1179 .unwrap();
1180 assert_eq!(t.to_state, TransactionState::PartiallyAuthorized);
1181 }
1182
1183 #[test]
1184 fn test_remove_agent_enables_settlement() {
1185 let mut ctx = make_ctx(&["did:example:a", "did:example:b"]);
1186
1187 // Only authorize agent a
1188 TransactionFsm::apply(
1189 &mut ctx,
1190 FsmEvent::AuthorizeReceived {
1191 agent_did: "did:example:a".to_string(),
1192 settlement_address: None,
1193 expiry: None,
1194 },
1195 )
1196 .unwrap();
1197 assert_eq!(ctx.state, TransactionState::PartiallyAuthorized);
1198
1199 // Remove agent b — now all remaining agents are authorized
1200 let t = TransactionFsm::apply(
1201 &mut ctx,
1202 FsmEvent::AgentRemoved {
1203 agent_did: "did:example:b".to_string(),
1204 },
1205 )
1206 .unwrap();
1207 assert_eq!(t.to_state, TransactionState::ReadyToSettle);
1208 assert!(matches!(
1209 t.decision.unwrap(),
1210 Decision::SettlementRequired { .. }
1211 ));
1212 }
1213
1214 #[test]
1215 fn test_no_agents_goes_straight_to_ready() {
1216 let mut ctx = make_ctx(&[]);
1217
1218 // With no agents, any authorize immediately reaches ReadyToSettle.
1219 // Actually, with no agents all_agents_authorized() is true from the
1220 // start. A TransactionReceived should reflect this.
1221 let t = TransactionFsm::apply(
1222 &mut ctx,
1223 FsmEvent::TransactionReceived { agent_dids: vec![] },
1224 )
1225 .unwrap();
1226 // Still Received — the decision is AuthorizationRequired even with
1227 // no agents, because the *parties* themselves may still need to decide.
1228 assert_eq!(t.to_state, TransactionState::Received);
1229 }
1230
1231 #[test]
1232 fn test_valid_events() {
1233 let events = TransactionFsm::valid_events(&TransactionState::Received);
1234 assert!(events.contains(&"AuthorizeReceived"));
1235 assert!(events.contains(&"PoliciesReceived"));
1236
1237 let events = TransactionFsm::valid_events(&TransactionState::Settled);
1238 assert_eq!(events, vec!["RevertReceived"]);
1239
1240 let events = TransactionFsm::valid_events(&TransactionState::Rejected);
1241 assert!(events.is_empty());
1242 }
1243
1244 #[test]
1245 fn test_display_implementations() {
1246 assert_eq!(TransactionState::Received.to_string(), "received");
1247 assert_eq!(
1248 TransactionState::PolicyRequired.to_string(),
1249 "policy_required"
1250 );
1251 assert_eq!(
1252 TransactionState::PartiallyAuthorized.to_string(),
1253 "partially_authorized"
1254 );
1255 assert_eq!(
1256 TransactionState::ReadyToSettle.to_string(),
1257 "ready_to_settle"
1258 );
1259 assert_eq!(TransactionState::Settled.to_string(), "settled");
1260 assert_eq!(TransactionState::Rejected.to_string(), "rejected");
1261 assert_eq!(TransactionState::Cancelled.to_string(), "cancelled");
1262 assert_eq!(TransactionState::Reverted.to_string(), "reverted");
1263 }
1264
1265 #[test]
1266 fn test_agent_state_display() {
1267 assert_eq!(AgentState::Pending.to_string(), "pending");
1268 assert_eq!(AgentState::Authorized.to_string(), "authorized");
1269 assert_eq!(AgentState::Rejected.to_string(), "rejected");
1270 assert_eq!(AgentState::Removed.to_string(), "removed");
1271 }
1272
1273 #[test]
1274 fn test_reject_during_partial_authorization() {
1275 let mut ctx = make_ctx(&["did:example:a", "did:example:b"]);
1276
1277 // Agent a authorizes
1278 TransactionFsm::apply(
1279 &mut ctx,
1280 FsmEvent::AuthorizeReceived {
1281 agent_did: "did:example:a".to_string(),
1282 settlement_address: None,
1283 expiry: None,
1284 },
1285 )
1286 .unwrap();
1287 assert_eq!(ctx.state, TransactionState::PartiallyAuthorized);
1288
1289 // Agent b rejects
1290 let t = TransactionFsm::apply(
1291 &mut ctx,
1292 FsmEvent::RejectReceived {
1293 agent_did: "did:example:b".to_string(),
1294 reason: Some("compliance failure".to_string()),
1295 },
1296 )
1297 .unwrap();
1298 assert_eq!(t.to_state, TransactionState::Rejected);
1299 }
1300
1301 #[test]
1302 fn test_cancel_during_ready_to_settle() {
1303 let mut ctx = make_ctx(&["did:example:a"]);
1304
1305 TransactionFsm::apply(
1306 &mut ctx,
1307 FsmEvent::AuthorizeReceived {
1308 agent_did: "did:example:a".to_string(),
1309 settlement_address: None,
1310 expiry: None,
1311 },
1312 )
1313 .unwrap();
1314 assert_eq!(ctx.state, TransactionState::ReadyToSettle);
1315
1316 // Cancel even though ready to settle
1317 let t = TransactionFsm::apply(
1318 &mut ctx,
1319 FsmEvent::CancelReceived {
1320 by_did: "did:example:originator".to_string(),
1321 reason: Some("changed mind".to_string()),
1322 },
1323 )
1324 .unwrap();
1325 assert_eq!(t.to_state, TransactionState::Cancelled);
1326 }
1327
1328 #[test]
1329 fn test_authorize_from_policy_required() {
1330 let mut ctx = make_ctx(&["did:example:a"]);
1331
1332 // Receive policies
1333 TransactionFsm::apply(
1334 &mut ctx,
1335 FsmEvent::PoliciesReceived {
1336 from_did: "did:example:b".to_string(),
1337 },
1338 )
1339 .unwrap();
1340 assert_eq!(ctx.state, TransactionState::PolicyRequired);
1341
1342 // Agent can still authorize even in PolicyRequired state
1343 // (they may have already satisfied the policies externally)
1344 let t = TransactionFsm::apply(
1345 &mut ctx,
1346 FsmEvent::AuthorizeReceived {
1347 agent_did: "did:example:a".to_string(),
1348 settlement_address: None,
1349 expiry: None,
1350 },
1351 )
1352 .unwrap();
1353 assert_eq!(t.to_state, TransactionState::ReadyToSettle);
1354 }
1355}