1use 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
152pub fn flow_from_get_by_tx_response(msg: &ListFlowsByTxResponse) -> Result<LifecycleFlowSummary> {
153 msg.matches
154 .first()
155 .map(flow_tx_match_from_proto)
156 .ok_or_else(|| Error::transport("invalid GetFlowByTx response: no matching flow"))
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::proto::chain::lifecycle::v1::{FlowKind as FlowKindEnum, FlowStep};
163 use crate::proto::chain::zipper::v1::ZipperReasonCode;
164
165 #[test]
166 fn flow_summary_maps_fields() {
167 let msg = FlowSummaryView {
168 flow_id: "flow-abc".into(),
169 flow_kind: FlowKindEnum::KindDeposit.into(),
170 current_step: FlowStep::Settlement.into(),
171 is_open: false,
172 is_terminal: true,
173 owner_account_id: 99,
174 smart_account_address: "0xabc".into(),
175 source_address: "0xsource".into(),
176 lifecycle_reason: LifecycleReason::LedgerMirrorTransferExceedsCredits.into(),
177 zipper_reason: ProtoZipperReasonDetails {
178 code: ZipperReasonCode::DepositAmountBelowMinimum.into(),
179 reason_id: "deposit_amount_below_minimum".into(),
180 message: "Deposit amount is below the minimum".into(),
181 ..Default::default()
182 }
183 .into(),
184 ..Default::default()
185 };
186 let flow = flow_summary_message_from_proto(&msg);
187 assert_eq!(flow.intent_id, "flow-abc");
188 assert!(flow.is_terminal);
189 assert_eq!(flow.flow_kind, "deposit");
190 assert_eq!(flow.latest_step, "settlement");
191 assert_eq!(flow.smart_account_address, "0xabc");
192 assert_eq!(flow.owner_account_id, format_uint64_id(99));
193 assert_eq!(
194 flow.lifecycle_reason,
195 "ledger_mirror_transfer_exceeds_credits"
196 );
197 let zipper = flow.zipper_reason.expect("zipper_reason");
198 assert_eq!(zipper.code, 1003);
199 assert_eq!(zipper.reason_id, "deposit_amount_below_minimum");
200 assert_eq!(zipper.message, "Deposit amount is below the minimum");
201 }
202
203 #[test]
204 fn lifecycle_reason_preserves_unknown_codes() {
205 let msg = FlowSummaryView {
206 flow_id: "flow-unknown".into(),
207 lifecycle_reason: buffa::EnumValue::from(2001),
208 ..Default::default()
209 };
210 let flow = flow_summary_message_from_proto(&msg);
211 assert_eq!(flow.flow_kind, "unspecified");
212 assert_eq!(flow.latest_step, "unspecified");
213 assert_eq!(flow.lifecycle_reason, "unknown_reason_2001");
214 assert!(flow.zipper_reason.is_none());
215 }
216
217 #[test]
218 fn trading_withdraw_lifecycle_reasons() {
219 let cases = [
220 (
221 LifecycleReason::TRADING_WITHDRAW_POLICY_DENIED,
222 "trading_withdraw_policy_denied",
223 ),
224 (
225 LifecycleReason::TRADING_WITHDRAW_CONTRACT_REVERTED,
226 "trading_withdraw_contract_reverted",
227 ),
228 (
229 LifecycleReason::TRADING_WITHDRAW_EXECUTION_FAILED,
230 "trading_withdraw_execution_failed",
231 ),
232 ];
233 for (reason, expected) in cases {
234 let msg = FlowSummaryView {
235 flow_id: "flow-trading-withdraw".into(),
236 flow_kind: FlowKindEnum::KIND_WITHDRAW.into(),
237 current_step: FlowStep::FLOW_STEP_FAILED.into(),
238 is_terminal: true,
239 lifecycle_reason: reason.into(),
240 ..Default::default()
241 };
242 let flow = flow_summary_message_from_proto(&msg);
243 assert_eq!(flow.lifecycle_reason, expected);
244 }
245 }
246
247 #[test]
248 fn flows_list_maps_pagination() {
249 let msg = ListFlowsResponse {
250 flows: vec![
251 FlowSummaryView {
252 flow_id: "a".into(),
253 ..Default::default()
254 },
255 FlowSummaryView {
256 flow_id: "b".into(),
257 ..Default::default()
258 },
259 ],
260 next_page_token: "next".into(),
261 ..Default::default()
262 };
263 let result = flows_list_from_proto(&msg);
264 assert_eq!(result.flows.len(), 2);
265 assert_eq!(result.next_page_token, "next");
266 assert_eq!(result.flows[0].lifecycle_reason, "unspecified");
267 }
268
269 #[test]
270 fn flow_tx_match_preserves_owner_identity() {
271 let msg = ListFlowsByTxResponse {
272 matches: vec![FlowTxMatchView {
273 flow_id: "flow-tx".into(),
274 owner_account_id: 99,
275 smart_account_address: "0xsmart".into(),
276 ..Default::default()
277 }],
278 ..Default::default()
279 };
280 let flow = flow_from_get_by_tx_response(&msg).expect("matching flow");
281 assert_eq!(flow.owner_account_id, format_uint64_id(99));
282 assert_eq!(flow.smart_account_address, "0xsmart");
283 }
284
285 #[test]
286 fn singular_flow_responses_reject_missing_required_entities() {
287 assert!(flow_from_get_response(&GetFlowResponse::default()).is_err());
288 assert!(flow_from_get_by_tx_response(&ListFlowsByTxResponse::default()).is_err());
289 }
290}