Skip to main content

polyester/codecs/decode/
lifecycle.rs

1//! Lifecycle flow decoders.
2
3use crate::codecs::decode::enums::enum_proto_name;
4use crate::codecs::scalars::format_uint64_id;
5use crate::errors::{Error, Result};
6use crate::models::{LifecycleFlowSummary, LifecycleFlowsList, ZipperReasonDetails};
7use crate::proto::chain::lifecycle::v1::{
8    FlowKind as FlowKindEnum, FlowStep, FlowSummaryView, FlowTxMatchView, GetFlowResponse,
9    LifecycleReason, ListFlowsByTxResponse, ListFlowsResponse,
10};
11use crate::proto::chain::zipper::v1::ZipperReasonDetails as ProtoZipperReasonDetails;
12
13fn lifecycle_reason_label(value: &buffa::EnumValue<LifecycleReason>) -> String {
14    match value.as_known() {
15        Some(LifecycleReason::REASON_UNSPECIFIED) => "unspecified".to_owned(),
16        Some(LifecycleReason::ZIPPER_VALIDATION_REJECTED) => {
17            "zipper_validation_rejected".to_owned()
18        }
19        Some(LifecycleReason::ZIPPER_EXECUTION_REJECTED) => "zipper_execution_rejected".to_owned(),
20        Some(LifecycleReason::ZIPPER_WITHDRAW_EXECUTION_FAILED) => {
21            "zipper_withdraw_execution_failed".to_owned()
22        }
23        Some(LifecycleReason::ZIPPER_DEPOSIT_REFUND_FAILED) => {
24            "zipper_deposit_refund_failed".to_owned()
25        }
26        Some(LifecycleReason::LEDGER_MIRROR_REJECTED) => "ledger_mirror_rejected".to_owned(),
27        Some(LifecycleReason::LEDGER_MIRROR_TRANSFER_EXCEEDS_CREDITS) => {
28            "ledger_mirror_transfer_exceeds_credits".to_owned()
29        }
30        Some(LifecycleReason::LEDGER_MIRROR_TRANSFER_EXISTS) => {
31            "ledger_mirror_transfer_exists".to_owned()
32        }
33        Some(LifecycleReason::LEDGER_MIRROR_PENDING_TRANSFER_NOT_FOUND) => {
34            "ledger_mirror_pending_transfer_not_found".to_owned()
35        }
36        Some(LifecycleReason::LEDGER_MIRROR_TRANSFER_ID_ALREADY_FAILED) => {
37            "ledger_mirror_transfer_id_already_failed".to_owned()
38        }
39        Some(LifecycleReason::TRADING_WITHDRAW_POLICY_DENIED) => {
40            "trading_withdraw_policy_denied".to_owned()
41        }
42        Some(LifecycleReason::TRADING_WITHDRAW_CONTRACT_REVERTED) => {
43            "trading_withdraw_contract_reverted".to_owned()
44        }
45        Some(LifecycleReason::TRADING_WITHDRAW_EXECUTION_FAILED) => {
46            "trading_withdraw_execution_failed".to_owned()
47        }
48        None => format!("unknown_reason_{}", value.to_i32()),
49    }
50}
51
52fn zipper_reason_from_proto(msg: &ProtoZipperReasonDetails) -> ZipperReasonDetails {
53    ZipperReasonDetails {
54        code: msg.code.to_i32(),
55        reason_id: msg.reason_id.clone(),
56        message: msg.message.clone(),
57    }
58}
59
60fn flow_kind_label(value: &buffa::EnumValue<FlowKindEnum>) -> String {
61    match value.as_known() {
62        Some(FlowKindEnum::KIND_DEPOSIT) => "deposit".to_owned(),
63        Some(FlowKindEnum::KIND_WITHDRAW) => "withdraw".to_owned(),
64        Some(FlowKindEnum::KIND_TRANSFER) => "transfer".to_owned(),
65        Some(FlowKindEnum::KIND_UNSPECIFIED) => "unspecified".to_owned(),
66        None => {
67            let raw = enum_proto_name(value);
68            if raw.is_empty() { String::new() } else { raw }
69        }
70    }
71}
72
73fn flow_step_label(value: &buffa::EnumValue<FlowStep>) -> String {
74    match value.as_known() {
75        Some(FlowStep::FLOW_STEP_SOURCE) => "source".to_owned(),
76        Some(FlowStep::FLOW_STEP_TRANSFER) => "transfer".to_owned(),
77        Some(FlowStep::FLOW_STEP_REQUEST) => "request".to_owned(),
78        Some(FlowStep::FLOW_STEP_VALIDATION) => "validation".to_owned(),
79        Some(FlowStep::FLOW_STEP_EXECUTION) => "execution".to_owned(),
80        Some(FlowStep::FLOW_STEP_BRIDGE_FULFILLMENT) => "bridge_fulfillment".to_owned(),
81        Some(FlowStep::FLOW_STEP_DROPPED) => "dropped".to_owned(),
82        Some(FlowStep::FLOW_STEP_FAILED) => "failed".to_owned(),
83        Some(FlowStep::FLOW_STEP_REFUNDED) => "refunded".to_owned(),
84        Some(FlowStep::FLOW_STEP_FULFILLING) => "fulfilling".to_owned(),
85        Some(FlowStep::FLOW_STEP_SETTLEMENT) => "settlement".to_owned(),
86        Some(FlowStep::FLOW_STEP_UNSPECIFIED) => "unspecified".to_owned(),
87        None => {
88            let raw = enum_proto_name(value);
89            if raw.is_empty() { String::new() } else { raw }
90        }
91    }
92}
93
94fn flow_summary_from_proto(msg: &FlowSummaryView) -> LifecycleFlowSummary {
95    LifecycleFlowSummary {
96        intent_id: msg.flow_id.clone(),
97        flow_kind: flow_kind_label(&msg.flow_kind),
98        latest_step: flow_step_label(&msg.current_step),
99        is_open: msg.is_open,
100        is_terminal: msg.is_terminal,
101        owner_account_id: format_uint64_id(msg.owner_account_id),
102        smart_account_address: msg.smart_account_address.clone(),
103        lifecycle_reason: lifecycle_reason_label(&msg.lifecycle_reason),
104        zipper_reason: msg.zipper_reason.as_option().map(zipper_reason_from_proto),
105    }
106}
107
108pub fn flow_summary_message_from_proto(msg: &FlowSummaryView) -> LifecycleFlowSummary {
109    flow_summary_from_proto(msg)
110}
111
112pub fn flows_list_from_proto(msg: &ListFlowsResponse) -> LifecycleFlowsList {
113    LifecycleFlowsList {
114        flows: msg.flows.iter().map(flow_summary_from_proto).collect(),
115        next_page_token: msg.next_page_token.clone(),
116    }
117}
118
119fn flow_tx_match_from_proto(msg: &FlowTxMatchView) -> LifecycleFlowSummary {
120    LifecycleFlowSummary {
121        intent_id: msg.flow_id.clone(),
122        flow_kind: flow_kind_label(&msg.flow_kind),
123        latest_step: flow_step_label(&msg.current_step),
124        is_open: msg.is_open,
125        is_terminal: msg.is_terminal,
126        owner_account_id: format_uint64_id(msg.owner_account_id),
127        smart_account_address: msg.smart_account_address.clone(),
128        lifecycle_reason: lifecycle_reason_label(&msg.lifecycle_reason),
129        zipper_reason: msg.zipper_reason.as_option().map(zipper_reason_from_proto),
130    }
131}
132
133pub fn flows_by_tx_list_from_proto(msg: &ListFlowsByTxResponse) -> LifecycleFlowsList {
134    LifecycleFlowsList {
135        flows: msg.matches.iter().map(flow_tx_match_from_proto).collect(),
136        next_page_token: msg.next_page_token.clone(),
137    }
138}
139
140pub fn flow_from_get_response(msg: &GetFlowResponse) -> Result<LifecycleFlowSummary> {
141    let detail = msg
142        .flow
143        .as_option()
144        .ok_or_else(|| Error::transport("invalid GetFlow response: missing flow"))?;
145    detail
146        .summary
147        .as_option()
148        .map(flow_summary_from_proto)
149        .ok_or_else(|| Error::transport("invalid GetFlow response: missing flow summary"))
150}
151
152/// Decode every match from a transaction lookup response.
153///
154/// The legacy helper name is retained for compatibility, but transaction
155/// lookups are one-to-many and must not silently discard bundled flows.
156pub fn flow_from_get_by_tx_response(msg: &ListFlowsByTxResponse) -> LifecycleFlowsList {
157    flows_by_tx_list_from_proto(msg)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::proto::chain::lifecycle::v1::{FlowKind as FlowKindEnum, FlowStep};
164    use crate::proto::chain::zipper::v1::ZipperReasonCode;
165
166    #[test]
167    fn flow_summary_maps_fields() {
168        let msg = FlowSummaryView {
169            flow_id: "flow-abc".into(),
170            flow_kind: FlowKindEnum::KindDeposit.into(),
171            current_step: FlowStep::Settlement.into(),
172            is_open: false,
173            is_terminal: true,
174            owner_account_id: 99,
175            smart_account_address: "0xabc".into(),
176            source_address: "0xsource".into(),
177            lifecycle_reason: LifecycleReason::LedgerMirrorTransferExceedsCredits.into(),
178            zipper_reason: ProtoZipperReasonDetails {
179                code: ZipperReasonCode::DepositAmountBelowMinimum.into(),
180                reason_id: "deposit_amount_below_minimum".into(),
181                message: "Deposit amount is below the minimum".into(),
182                ..Default::default()
183            }
184            .into(),
185            ..Default::default()
186        };
187        let flow = flow_summary_message_from_proto(&msg);
188        assert_eq!(flow.intent_id, "flow-abc");
189        assert!(flow.is_terminal);
190        assert_eq!(flow.flow_kind, "deposit");
191        assert_eq!(flow.latest_step, "settlement");
192        assert_eq!(flow.smart_account_address, "0xabc");
193        assert_eq!(flow.owner_account_id, format_uint64_id(99));
194        assert_eq!(
195            flow.lifecycle_reason,
196            "ledger_mirror_transfer_exceeds_credits"
197        );
198        let zipper = flow.zipper_reason.expect("zipper_reason");
199        assert_eq!(zipper.code, 1003);
200        assert_eq!(zipper.reason_id, "deposit_amount_below_minimum");
201        assert_eq!(zipper.message, "Deposit amount is below the minimum");
202    }
203
204    #[test]
205    fn lifecycle_reason_preserves_unknown_codes() {
206        let msg = FlowSummaryView {
207            flow_id: "flow-unknown".into(),
208            lifecycle_reason: buffa::EnumValue::from(2001),
209            ..Default::default()
210        };
211        let flow = flow_summary_message_from_proto(&msg);
212        assert_eq!(flow.flow_kind, "unspecified");
213        assert_eq!(flow.latest_step, "unspecified");
214        assert_eq!(flow.lifecycle_reason, "unknown_reason_2001");
215        assert!(flow.zipper_reason.is_none());
216    }
217
218    #[test]
219    fn trading_withdraw_lifecycle_reasons() {
220        let cases = [
221            (
222                LifecycleReason::TRADING_WITHDRAW_POLICY_DENIED,
223                "trading_withdraw_policy_denied",
224            ),
225            (
226                LifecycleReason::TRADING_WITHDRAW_CONTRACT_REVERTED,
227                "trading_withdraw_contract_reverted",
228            ),
229            (
230                LifecycleReason::TRADING_WITHDRAW_EXECUTION_FAILED,
231                "trading_withdraw_execution_failed",
232            ),
233        ];
234        for (reason, expected) in cases {
235            let msg = FlowSummaryView {
236                flow_id: "flow-trading-withdraw".into(),
237                flow_kind: FlowKindEnum::KIND_WITHDRAW.into(),
238                current_step: FlowStep::FLOW_STEP_FAILED.into(),
239                is_terminal: true,
240                lifecycle_reason: reason.into(),
241                ..Default::default()
242            };
243            let flow = flow_summary_message_from_proto(&msg);
244            assert_eq!(flow.lifecycle_reason, expected);
245        }
246    }
247
248    #[test]
249    fn flows_list_maps_pagination() {
250        let msg = ListFlowsResponse {
251            flows: vec![
252                FlowSummaryView {
253                    flow_id: "a".into(),
254                    ..Default::default()
255                },
256                FlowSummaryView {
257                    flow_id: "b".into(),
258                    ..Default::default()
259                },
260            ],
261            next_page_token: "next".into(),
262            ..Default::default()
263        };
264        let result = flows_list_from_proto(&msg);
265        assert_eq!(result.flows.len(), 2);
266        assert_eq!(result.next_page_token, "next");
267        assert_eq!(result.flows[0].lifecycle_reason, "unspecified");
268    }
269
270    #[test]
271    fn flow_by_tx_response_preserves_all_matches_and_owner_identity() {
272        let msg = ListFlowsByTxResponse {
273            matches: vec![
274                FlowTxMatchView {
275                    flow_id: "flow-a".into(),
276                    owner_account_id: 99,
277                    smart_account_address: "0xsmart".into(),
278                    ..Default::default()
279                },
280                FlowTxMatchView {
281                    flow_id: "flow-b".into(),
282                    ..Default::default()
283                },
284            ],
285            next_page_token: "next".into(),
286            ..Default::default()
287        };
288        let result = flow_from_get_by_tx_response(&msg);
289        assert_eq!(result.flows.len(), 2);
290        assert_eq!(result.flows[0].owner_account_id, format_uint64_id(99));
291        assert_eq!(result.flows[0].smart_account_address, "0xsmart");
292        assert_eq!(result.flows[1].intent_id, "flow-b");
293        assert_eq!(result.next_page_token, "next");
294    }
295
296    #[test]
297    fn singular_flow_responses_reject_missing_required_entities() {
298        assert!(flow_from_get_response(&GetFlowResponse::default()).is_err());
299    }
300}