Skip to main content

polyester/codecs/decode/
deposit_withdraw.rs

1//! Deposit / withdraw / internal-transfer decoders.
2
3use super::money::decode_asset_amount_u128;
4use crate::codecs::scalars::LEDGER_SCALE;
5use crate::errors::{Error, Result};
6use crate::models::{
7    DepositAddress, DepositAddressesList, InternalTransferResult, WithdrawDestinationValidation,
8    WithdrawIntentResult,
9};
10use crate::proto::chain::deposit::v1::{
11    CreateDepositAddressResponse, DepositAddress as ProtoDepositAddress,
12    ListDepositAddressesResponse,
13};
14use crate::proto::chain::withdraw::v1::{
15    CreateTradingWithdrawResponse, CreateWalletTradingWithdrawResponse,
16    ValidateWithdrawDestinationResponse, WithdrawDestinationValidationCode,
17};
18use crate::proto::transfer::v1::CreateInternalTransferResponse;
19use crate::types::QuantityDomain;
20
21fn withdraw_destination_validation_code_label(
22    value: &buffa::EnumValue<WithdrawDestinationValidationCode>,
23) -> String {
24    match value.as_known() {
25        Some(WithdrawDestinationValidationCode::RESULT_UNSPECIFIED) => "unspecified".to_owned(),
26        Some(WithdrawDestinationValidationCode::VALID) => "valid".to_owned(),
27        Some(WithdrawDestinationValidationCode::INVALID_ADDRESS) => "invalid_address".to_owned(),
28        Some(WithdrawDestinationValidationCode::UNSUPPORTED_CHAIN) => {
29            "unsupported_chain".to_owned()
30        }
31        Some(WithdrawDestinationValidationCode::POLYESTER_SMART_ACCOUNT) => {
32            "polyester_smart_account".to_owned()
33        }
34        Some(WithdrawDestinationValidationCode::TOKEN_CONTRACT) => "token_contract".to_owned(),
35        Some(WithdrawDestinationValidationCode::DENYLISTED_ADDRESS) => {
36            "denylisted_address".to_owned()
37        }
38        None => format!("unknown_code_{}", value.to_i32()),
39    }
40}
41
42pub fn withdraw_destination_validation_from_proto(
43    msg: &ValidateWithdrawDestinationResponse,
44) -> WithdrawDestinationValidation {
45    WithdrawDestinationValidation {
46        valid: msg.valid,
47        code: withdraw_destination_validation_code_label(&msg.code),
48        message: msg.message.clone(),
49        canonical_destination_address: msg.canonical_destination_address.clone(),
50    }
51}
52
53pub fn deposit_address_from_proto(msg: &ProtoDepositAddress) -> DepositAddress {
54    DepositAddress {
55        chain_id: msg.chain_id,
56        deposit_address: msg.deposit_address.clone(),
57    }
58}
59
60pub fn deposit_addresses_list_from_proto(
61    msg: &ListDepositAddressesResponse,
62) -> DepositAddressesList {
63    DepositAddressesList {
64        addresses: msg
65            .deposit_addresses
66            .iter()
67            .map(deposit_address_from_proto)
68            .collect(),
69    }
70}
71
72pub fn create_deposit_address_from_proto(
73    msg: &CreateDepositAddressResponse,
74) -> Result<DepositAddress> {
75    let address = msg.deposit_address.as_option().ok_or_else(|| {
76        Error::transport("invalid CreateDepositAddress response: missing deposit_address")
77    })?;
78    if address.deposit_address.trim().is_empty() {
79        return Err(Error::transport(
80            "invalid CreateDepositAddress response: empty deposit address",
81        ));
82    }
83    Ok(deposit_address_from_proto(address))
84}
85
86pub fn withdraw_intent_from_proto(
87    msg: &CreateTradingWithdrawResponse,
88) -> Result<WithdrawIntentResult> {
89    if msg.intent_id.trim().is_empty() {
90        return Err(Error::transport(
91            "invalid CreateTradingWithdraw response: missing intent_id",
92        ));
93    }
94    Ok(WithdrawIntentResult {
95        intent_id: msg.intent_id.clone(),
96        status: String::new(),
97        flow_id: String::new(),
98    })
99}
100
101pub fn withdraw_intent_from_wallet_proto(
102    msg: &CreateWalletTradingWithdrawResponse,
103) -> Result<WithdrawIntentResult> {
104    if msg.intent_id.trim().is_empty() {
105        return Err(Error::transport(
106            "invalid CreateWalletTradingWithdraw response: missing intent_id",
107        ));
108    }
109    Ok(WithdrawIntentResult {
110        intent_id: msg.intent_id.clone(),
111        status: String::new(),
112        flow_id: String::new(),
113    })
114}
115
116pub fn internal_transfer_from_proto(
117    msg: &CreateInternalTransferResponse,
118) -> Result<InternalTransferResult> {
119    if msg.request_id.trim().is_empty() || msg.transfer_id.trim().is_empty() {
120        return Err(Error::transport(
121            "invalid CreateInternalTransfer response: missing request_id or transfer_id",
122        ));
123    }
124    let asset_id = msg.asset_id;
125    let quantity = msg.amount_e18.as_option().and_then(|u| {
126        decode_asset_amount_u128(
127            u.hi,
128            u.lo,
129            Some(LEDGER_SCALE),
130            QuantityDomain::LedgerE18,
131            Some(asset_id),
132        )
133    });
134    Ok(InternalTransferResult {
135        request_id: msg.request_id.clone(),
136        transfer_id: msg.transfer_id.clone(),
137        asset_id,
138        asset_code: msg.asset_code.clone(),
139        quantity,
140    })
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::proto::polyester::r#type::v1::U128;
147
148    #[test]
149    fn deposit_addresses_list_maps_rows() {
150        let msg = ListDepositAddressesResponse {
151            deposit_addresses: vec![ProtoDepositAddress {
152                chain_id: 1,
153                deposit_address: "0xabc".into(),
154                ..Default::default()
155            }],
156            ..Default::default()
157        };
158        let list = deposit_addresses_list_from_proto(&msg);
159        assert_eq!(list.addresses.len(), 1);
160        assert_eq!(list.addresses[0].chain_id, 1);
161        assert_eq!(list.addresses[0].deposit_address, "0xabc");
162    }
163
164    #[test]
165    fn create_deposit_address_rejects_missing_required_entity() {
166        let err = create_deposit_address_from_proto(&CreateDepositAddressResponse::default())
167            .expect_err("missing deposit address must fail closed");
168        assert!(err.to_string().contains("missing deposit_address"));
169    }
170
171    #[test]
172    fn internal_transfer_maps_u128_quantity() {
173        let msg = CreateInternalTransferResponse {
174            request_id: "req".into(),
175            transfer_id: "xfer".into(),
176            asset_id: 7,
177            asset_code: "USDT".into(),
178            amount_e18: U128 {
179                hi: 0,
180                lo: 1_000_000_000_000_000_000,
181                ..Default::default()
182            }
183            .into(),
184            ..Default::default()
185        };
186        let result = internal_transfer_from_proto(&msg).unwrap();
187        assert_eq!(result.request_id, "req");
188        assert_eq!(result.transfer_id, "xfer");
189        assert_eq!(result.asset_id, 7);
190        assert_eq!(
191            result.quantity.as_ref().unwrap().as_scaled(),
192            1_000_000_000_000_000_000
193        );
194    }
195
196    #[test]
197    fn withdraw_intent_maps_id() {
198        let msg = CreateTradingWithdrawResponse {
199            intent_id: "intent-1".into(),
200            ..Default::default()
201        };
202        assert_eq!(
203            withdraw_intent_from_proto(&msg).unwrap().intent_id,
204            "intent-1"
205        );
206    }
207
208    #[test]
209    fn withdraw_destination_validation_maps_codes() {
210        let cases = [
211            (
212                WithdrawDestinationValidationCode::RESULT_UNSPECIFIED,
213                "unspecified",
214            ),
215            (WithdrawDestinationValidationCode::VALID, "valid"),
216            (
217                WithdrawDestinationValidationCode::INVALID_ADDRESS,
218                "invalid_address",
219            ),
220            (
221                WithdrawDestinationValidationCode::UNSUPPORTED_CHAIN,
222                "unsupported_chain",
223            ),
224            (
225                WithdrawDestinationValidationCode::POLYESTER_SMART_ACCOUNT,
226                "polyester_smart_account",
227            ),
228            (
229                WithdrawDestinationValidationCode::TOKEN_CONTRACT,
230                "token_contract",
231            ),
232            (
233                WithdrawDestinationValidationCode::DENYLISTED_ADDRESS,
234                "denylisted_address",
235            ),
236        ];
237        for (code, expected) in cases {
238            let msg = ValidateWithdrawDestinationResponse {
239                valid: code == WithdrawDestinationValidationCode::VALID,
240                code: code.into(),
241                message: "msg".into(),
242                canonical_destination_address: if code == WithdrawDestinationValidationCode::VALID {
243                    "0xabc".into()
244                } else {
245                    String::new()
246                },
247                ..Default::default()
248            };
249            let got = withdraw_destination_validation_from_proto(&msg);
250            assert_eq!(got.code, expected);
251            assert_eq!(got.message, "msg");
252        }
253        let unknown = ValidateWithdrawDestinationResponse {
254            code: buffa::EnumValue::from(99),
255            ..Default::default()
256        };
257        assert_eq!(
258            withdraw_destination_validation_from_proto(&unknown).code,
259            "unknown_code_99"
260        );
261    }
262
263    #[test]
264    fn singular_mutations_reject_empty_success_responses() {
265        assert!(withdraw_intent_from_proto(&CreateTradingWithdrawResponse::default()).is_err());
266        assert!(
267            withdraw_intent_from_wallet_proto(&CreateWalletTradingWithdrawResponse::default())
268                .is_err()
269        );
270        assert!(internal_transfer_from_proto(&CreateInternalTransferResponse::default()).is_err());
271    }
272}