Skip to main content

polyester/services/
deposit_withdraw.rs

1use super::ServiceContext;
2use super::scope;
3use super::unary;
4use crate::codecs::decode::{
5    create_deposit_address_from_proto, deposit_addresses_list_from_proto,
6    deposit_withdraw_config_from_proto, withdraw_destination_validation_from_proto,
7    withdraw_intent_from_proto, withdraw_intent_from_wallet_proto,
8};
9use crate::codecs::scalars::{LEDGER_SCALE, i128_to_u128, u128_to_proto};
10use crate::connect::chain::deposit::v1::DepositAddressServiceClient;
11use crate::connect::chain::withdraw::v1::WithdrawServiceClient;
12use crate::connect::chain::zipper::v1::ZipperServiceClient;
13use crate::errors::{Error, Result};
14use crate::models::ZippedAssetSupplyBatch;
15use crate::models::{
16    CreateApiKeyTradingWithdrawParams, CreateTradingWithdrawParams,
17    CreateWalletTradingWithdrawParams, DepositAddress, DepositAddressesList, DepositWithdrawConfig,
18    WithdrawDestinationValidation, WithdrawIntentResult,
19};
20use crate::proto::chain::deposit::v1::{CreateDepositAddressRequest, ListDepositAddressesRequest};
21use crate::proto::chain::withdraw::v1::{
22    CreateTradingWithdrawRequest, CreateWalletTradingWithdrawRequest, TradingWithdrawAction,
23    TradingWithdrawIntentPayload, ValidateWithdrawDestinationRequest,
24};
25use crate::proto::chain::zipper::v1::GetDepositWithdrawConfigRequest;
26use crate::types::{AssetAmount, QuantityDomain, resolve_asset_amount_scaled_with_input_scale};
27use buffa::Message;
28use rand_core::{OsRng, RngCore};
29
30/// Generate a cryptographically random withdrawal idempotency key.
31///
32/// Generate this once per logical withdrawal, persist it with the signed
33/// payload, and reuse it unchanged for every retry.
34pub fn new_trading_withdraw_idempotency_key() -> Result<String> {
35    let mut random = [0_u8; 16];
36    OsRng
37        .try_fill_bytes(&mut random)
38        .map_err(|err| Error::transport(format!("secure randomness unavailable: {err}")))?;
39    Ok(format!("wd-{}", hex::encode(random)))
40}
41
42/// Generate a cryptographically random, non-zero withdrawal nonce.
43///
44/// Generate and persist this alongside the idempotency key before signing the
45/// payload. The SDK never changes it during submission or retry.
46pub fn new_trading_withdraw_nonce() -> Result<u128> {
47    for _ in 0..2 {
48        let mut random = [0_u8; 16];
49        OsRng
50            .try_fill_bytes(&mut random)
51            .map_err(|err| Error::transport(format!("secure randomness unavailable: {err}")))?;
52        let nonce = u128::from_be_bytes(random);
53        if nonce != 0 {
54            return Ok(nonce);
55        }
56    }
57    Err(Error::transport(
58        "secure random source returned a zero withdrawal nonce twice",
59    ))
60}
61
62struct EncodeWithdrawPayload<'a> {
63    action: TradingWithdrawAction,
64    asset_id: u32,
65    amount: &'a AssetAmount,
66    amount_scale: Option<u32>,
67    idempotency_key: String,
68    destination_chain_id: u64,
69    destination_address: String,
70    deadline_ts_sec: u64,
71    nonce: u128,
72}
73
74/// Exact API-key signed withdraw request prepared for durable persistence and retry.
75#[derive(Clone)]
76pub struct PreparedTradingWithdraw {
77    request: CreateTradingWithdrawRequest,
78}
79
80impl std::fmt::Debug for PreparedTradingWithdraw {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("PreparedTradingWithdraw")
83            .field("payload", &self.request.payload.as_option())
84            .field("payload_signature", &"<redacted>")
85            .finish()
86    }
87}
88
89impl PreparedTradingWithdraw {
90    /// Restore a prepared request previously persisted with [`Self::request_bytes`].
91    pub fn from_request_bytes(bytes: &[u8]) -> Result<Self> {
92        let request = CreateTradingWithdrawRequest::decode_from_slice(bytes)
93            .map_err(|err| Error::validation(format!("invalid prepared withdraw bytes: {err}")))?;
94        if request.payload.as_option().is_none() {
95            return Err(Error::validation(
96                "prepared withdraw request is missing payload",
97            ));
98        }
99        if request.payload_signature.is_empty() {
100            return Err(Error::validation(
101                "prepared withdraw request is missing payload_signature",
102            ));
103        }
104        Ok(Self { request })
105    }
106
107    pub fn payload(&self) -> &TradingWithdrawIntentPayload {
108        self.request
109            .payload
110            .as_option()
111            .expect("prepared withdraw always has a payload")
112    }
113
114    pub fn payload_signature(&self) -> &[u8] {
115        &self.request.payload_signature
116    }
117
118    /// Exact deterministic protobuf bytes covered by `payload_signature`.
119    pub fn deterministic_payload_bytes(&self) -> Vec<u8> {
120        self.payload().encode_to_vec()
121    }
122
123    /// Canonical bytes to persist before first submission and reuse on retries.
124    pub fn request_bytes(&self) -> Vec<u8> {
125        self.request.encode_to_vec()
126    }
127}
128
129#[derive(Clone)]
130pub struct DepositService {
131    ctx: ServiceContext,
132}
133
134impl DepositService {
135    pub fn new(ctx: ServiceContext) -> Self {
136        Self { ctx }
137    }
138
139    pub async fn list_addresses(&self) -> Result<DepositAddressesList> {
140        let client = DepositAddressServiceClient::new(
141            self.ctx.factory.transport(),
142            self.ctx.factory.connect_config(),
143        );
144        let req = ListDepositAddressesRequest::default();
145        let resp = unary::await_auth(
146            &self.ctx.factory,
147            "/chain.deposit.v1.DepositAddressService/ListDepositAddresses",
148            req,
149            |req, opts| client.list_deposit_addresses_with_options(req, opts),
150        )
151        .await?
152        .into_owned();
153        Ok(deposit_addresses_list_from_proto(&resp))
154    }
155
156    pub async fn create_address(&self, req: CreateDepositAddressRequest) -> Result<DepositAddress> {
157        let client = DepositAddressServiceClient::new(
158            self.ctx.factory.transport(),
159            self.ctx.factory.connect_config(),
160        );
161        let resp = unary::await_auth(
162            &self.ctx.factory,
163            "/chain.deposit.v1.DepositAddressService/CreateDepositAddress",
164            req,
165            |req, opts| client.create_deposit_address_with_options(req, opts),
166        )
167        .await?
168        .into_owned();
169        create_deposit_address_from_proto(&resp)
170    }
171}
172
173#[derive(Clone)]
174pub struct WithdrawService {
175    ctx: ServiceContext,
176}
177
178impl WithdrawService {
179    pub fn new(ctx: ServiceContext) -> Self {
180        Self { ctx }
181    }
182
183    pub(crate) fn connect_client(
184        &self,
185    ) -> WithdrawServiceClient<crate::transport::SharedTransport> {
186        WithdrawServiceClient::new(
187            self.ctx.factory.transport(),
188            self.ctx.factory.connect_config(),
189        )
190    }
191
192    fn encode_payload(opts: EncodeWithdrawPayload<'_>) -> Result<TradingWithdrawIntentPayload> {
193        if opts.idempotency_key.trim().is_empty() {
194            return Err(Error::validation("idempotency_key is required"));
195        }
196        if opts.deadline_ts_sec == 0 {
197            return Err(Error::validation("deadline_ts_sec must be non-zero"));
198        }
199        if opts.nonce == 0 {
200            return Err(Error::validation("nonce must be non-zero"));
201        }
202        let scaled = resolve_asset_amount_scaled_with_input_scale(
203            opts.amount,
204            opts.amount_scale,
205            LEDGER_SCALE,
206            QuantityDomain::LedgerE18,
207            Some(opts.asset_id),
208        )?;
209        let mut payload = TradingWithdrawIntentPayload {
210            action: opts.action.into(),
211            asset_id: opts.asset_id,
212            destination_chain_id: opts.destination_chain_id,
213            destination_address: opts.destination_address,
214            idempotency_key: opts.idempotency_key,
215            deadline_ts_sec: opts.deadline_ts_sec,
216            ..Default::default()
217        };
218        *payload.amount_e18.get_or_insert_default() = i128_to_u128(scaled)?;
219        *payload.nonce.get_or_insert_default() = u128_to_proto(opts.nonce);
220        if payload
221            .amount_e18
222            .as_option()
223            .is_none_or(|u| u.hi == 0 && u.lo == 0)
224        {
225            return Err(Error::validation("amount must be positive"));
226        }
227        Ok(payload)
228    }
229
230    fn default_deadline_ts_sec() -> Result<u64> {
231        let now = std::time::SystemTime::now()
232            .duration_since(std::time::UNIX_EPOCH)
233            .map_err(|_| Error::validation("system clock is before UNIX_EPOCH"))?
234            .as_secs();
235        now.checked_add(5 * 60)
236            .ok_or_else(|| Error::validation("withdraw deadline overflow"))
237    }
238
239    fn prepare_api_key(
240        &self,
241        action: TradingWithdrawAction,
242        params: CreateApiKeyTradingWithdrawParams,
243        destination_chain_id: u64,
244    ) -> Result<PreparedTradingWithdraw> {
245        if action == TradingWithdrawAction::ToExternalChain
246            && params.destination_address.trim().is_empty()
247        {
248            return Err(Error::validation(
249                "destination_address is required for external-chain withdraw",
250            ));
251        }
252        let deadline_ts_sec = match params.deadline_ts_sec {
253            Some(deadline) => deadline,
254            None => Self::default_deadline_ts_sec()?,
255        };
256        let nonce = match params.nonce {
257            Some(nonce) => nonce,
258            None => new_trading_withdraw_nonce()?,
259        };
260        let payload = Self::encode_payload(EncodeWithdrawPayload {
261            action,
262            asset_id: params.asset_id,
263            amount: &params.amount,
264            amount_scale: params.amount_scale,
265            idempotency_key: params.idempotency_key,
266            destination_chain_id,
267            destination_address: params.destination_address,
268            deadline_ts_sec,
269            nonce,
270        })?;
271        let payload_signature = self
272            .ctx
273            .factory
274            .require_credentials()?
275            .sign_payload(&payload.encode_to_vec());
276        let mut request = CreateTradingWithdrawRequest {
277            payload_signature,
278            ..Default::default()
279        };
280        *request.payload.get_or_insert_default() = payload;
281        Ok(PreparedTradingWithdraw { request })
282    }
283
284    /// Build and API-key sign a complete Trading-to-Funding payload.
285    ///
286    /// Persist the returned value before submission and reuse it unchanged
287    /// after an outcome-unknown transport error.
288    pub fn prepare_api_key_to_funding(
289        &self,
290        params: CreateApiKeyTradingWithdrawParams,
291    ) -> Result<PreparedTradingWithdraw> {
292        self.prepare_api_key(TradingWithdrawAction::ToFunding, params, 0)
293    }
294
295    /// Build and API-key sign a complete Trading-to-external-chain payload.
296    pub fn prepare_api_key_to_external_chain(
297        &self,
298        params: CreateApiKeyTradingWithdrawParams,
299        destination_chain_id: u64,
300    ) -> Result<PreparedTradingWithdraw> {
301        self.prepare_api_key(
302            TradingWithdrawAction::ToExternalChain,
303            params,
304            destination_chain_id,
305        )
306    }
307
308    /// Submit an already prepared request without rebuilding signed fields.
309    pub async fn submit_prepared(
310        &self,
311        prepared: &PreparedTradingWithdraw,
312    ) -> Result<WithdrawIntentResult> {
313        self.create_trading_withdraw(prepared.request.clone()).await
314    }
315
316    /// Build, API-key sign, and submit a Trading-to-Funding payload unchanged.
317    pub async fn create_api_key_to_funding(
318        &self,
319        params: CreateApiKeyTradingWithdrawParams,
320    ) -> Result<WithdrawIntentResult> {
321        let prepared = self.prepare_api_key_to_funding(params)?;
322        self.submit_prepared(&prepared).await
323    }
324
325    /// Build, API-key sign, and submit a Trading-to-external-chain payload unchanged.
326    pub async fn create_api_key_to_external_chain(
327        &self,
328        params: CreateApiKeyTradingWithdrawParams,
329        destination_chain_id: u64,
330    ) -> Result<WithdrawIntentResult> {
331        let prepared = self.prepare_api_key_to_external_chain(params, destination_chain_id)?;
332        self.submit_prepared(&prepared).await
333    }
334
335    /// Check one external-chain destination without creating a withdraw.
336    ///
337    /// Use for form feedback before Trading → external submission; create RPCs
338    /// remain authoritative.
339    pub async fn validate_destination(
340        &self,
341        destination_chain_id: u64,
342        destination_address: impl Into<String>,
343    ) -> Result<WithdrawDestinationValidation> {
344        if destination_chain_id == 0 {
345            return Err(Error::validation("destination_chain_id must be non-zero"));
346        }
347        let destination_address = destination_address.into();
348        if destination_address.trim().is_empty() {
349            return Err(Error::validation("destination_address is required"));
350        }
351        let req = ValidateWithdrawDestinationRequest {
352            destination_chain_id,
353            destination_address,
354            ..Default::default()
355        };
356        let client = self.connect_client();
357        let resp = unary::await_auth(
358            &self.ctx.factory,
359            "/chain.withdraw.v1.WithdrawService/ValidateWithdrawDestination",
360            req,
361            |req, opts| client.validate_withdraw_destination_with_options(req, opts),
362        )
363        .await?
364        .into_owned();
365        Ok(withdraw_destination_validation_from_proto(&resp))
366    }
367
368    /// Withdraw from trading to funding. Amount must be an [`crate::types::AssetAmount`].
369    pub async fn create_to_funding(
370        &self,
371        params: CreateTradingWithdrawParams,
372    ) -> Result<WithdrawIntentResult> {
373        if params.payload_signature.is_empty() {
374            return Err(Error::validation(
375                "payload_signature is required for trading withdraw",
376            ));
377        }
378        let deadline_ts_sec = params.deadline_ts_sec.ok_or_else(|| {
379            Error::validation("deadline_ts_sec is required when payload_signature is precomputed")
380        })?;
381        let payload = Self::encode_payload(EncodeWithdrawPayload {
382            action: TradingWithdrawAction::ToFunding,
383            asset_id: params.asset_id,
384            amount: &params.amount,
385            amount_scale: params.amount_scale,
386            idempotency_key: params.idempotency_key,
387            destination_chain_id: 0,
388            destination_address: params.destination_address,
389            deadline_ts_sec,
390            nonce: params.nonce,
391        })?;
392        let mut req = CreateTradingWithdrawRequest {
393            payload_signature: params.payload_signature,
394            ..Default::default()
395        };
396        *req.payload.get_or_insert_default() = payload;
397        self.create_trading_withdraw(req).await
398    }
399
400    /// Withdraw from trading to an external chain. Amount must be an [`AssetAmount`].
401    pub async fn create_to_external_chain(
402        &self,
403        params: CreateTradingWithdrawParams,
404        destination_chain_id: u64,
405    ) -> Result<WithdrawIntentResult> {
406        if params.payload_signature.is_empty() {
407            return Err(Error::validation(
408                "payload_signature is required for trading withdraw",
409            ));
410        }
411        if params.destination_address.is_empty() {
412            return Err(Error::validation(
413                "destination_address is required for external-chain withdraw",
414            ));
415        }
416        let deadline_ts_sec = params.deadline_ts_sec.ok_or_else(|| {
417            Error::validation("deadline_ts_sec is required when payload_signature is precomputed")
418        })?;
419        let payload = Self::encode_payload(EncodeWithdrawPayload {
420            action: TradingWithdrawAction::ToExternalChain,
421            asset_id: params.asset_id,
422            amount: &params.amount,
423            amount_scale: params.amount_scale,
424            idempotency_key: params.idempotency_key,
425            destination_chain_id,
426            destination_address: params.destination_address,
427            deadline_ts_sec,
428            nonce: params.nonce,
429        })?;
430        let mut req = CreateTradingWithdrawRequest {
431            payload_signature: params.payload_signature,
432            ..Default::default()
433        };
434        *req.payload.get_or_insert_default() = payload;
435        self.create_trading_withdraw(req).await
436    }
437
438    async fn create_trading_withdraw(
439        &self,
440        req: CreateTradingWithdrawRequest,
441    ) -> Result<WithdrawIntentResult> {
442        let client = self.connect_client();
443        let resp = unary::await_auth(
444            &self.ctx.factory,
445            "/chain.withdraw.v1.WithdrawService/CreateTradingWithdraw",
446            req,
447            |req, opts| client.create_trading_withdraw_with_options(req, opts),
448        )
449        .await?
450        .into_owned();
451        withdraw_intent_from_proto(&resp)
452    }
453
454    /// Wallet-signed trading withdraw. Amount must be an [`AssetAmount`].
455    pub async fn create_wallet_trading_withdraw(
456        &self,
457        params: CreateWalletTradingWithdrawParams,
458    ) -> Result<WithdrawIntentResult> {
459        if params.payload_signature.is_empty() {
460            return Err(Error::validation(
461                "payload_signature is required for trading withdraw",
462            ));
463        }
464        let action = match params
465            .action
466            .to_ascii_lowercase()
467            .replace('-', "_")
468            .as_str()
469        {
470            "to_funding" => TradingWithdrawAction::ToFunding,
471            "to_external_chain" => TradingWithdrawAction::ToExternalChain,
472            _ => {
473                return Err(Error::validation(format!(
474                    "unknown trading withdraw action: {}",
475                    params.action
476                )));
477            }
478        };
479        let deadline_ts_sec = params.deadline_ts_sec.ok_or_else(|| {
480            Error::validation("deadline_ts_sec is required when payload_signature is precomputed")
481        })?;
482        let payload = Self::encode_payload(EncodeWithdrawPayload {
483            action,
484            asset_id: params.asset_id,
485            amount: &params.amount,
486            amount_scale: params.amount_scale,
487            idempotency_key: params.idempotency_key,
488            destination_chain_id: params.destination_chain_id,
489            destination_address: params.destination_address,
490            deadline_ts_sec,
491            nonce: params.nonce,
492        })?;
493        let mut req = CreateWalletTradingWithdrawRequest {
494            signer_wallet: params.signer_wallet,
495            payload_signature: params.payload_signature,
496            subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?
497                .unwrap_or(0),
498            ..Default::default()
499        };
500        *req.payload.get_or_insert_default() = payload;
501        let client = self.connect_client();
502        let resp = unary::await_auth(
503            &self.ctx.factory,
504            "/chain.withdraw.v1.WithdrawService/CreateWalletTradingWithdraw",
505            req,
506            |req, opts| client.create_wallet_trading_withdraw_with_options(req, opts),
507        )
508        .await?
509        .into_owned();
510        withdraw_intent_from_wallet_proto(&resp)
511    }
512}
513
514#[derive(Clone)]
515pub struct ZipperService {
516    ctx: ServiceContext,
517}
518
519impl ZipperService {
520    pub fn new(ctx: ServiceContext) -> Self {
521        Self { ctx }
522    }
523
524    pub async fn get_deposit_withdraw_config(&self) -> Result<DepositWithdrawConfig> {
525        let client = ZipperServiceClient::new(
526            self.ctx.factory.transport(),
527            self.ctx.factory.connect_config(),
528        );
529        let resp = unary::await_public(
530            client.get_deposit_withdraw_config(GetDepositWithdrawConfigRequest::default()),
531        )
532        .await?
533        .into_owned();
534        Ok(deposit_withdraw_config_from_proto(&resp))
535    }
536
537    /// Subscribe to zipped-asset supply updates (requires `realtime` feature).
538    ///
539    /// When `patch_catalog` is true, each batch updates
540    /// [`crate::catalogs::Manager::patch_zipper_supply`] (`zipped_asset_id` → supply string).
541    pub async fn subscribe_zipped_asset_supply(
542        &self,
543        patch_catalog: bool,
544    ) -> Result<crate::realtime::TypedSubscription<ZippedAssetSupplyBatch>> {
545        let catalogs = self.ctx.catalogs.clone();
546        self.ctx
547            .realtime
548            .subscribe_proto("public:chain:zipped-asset:supply:proto", move |payload| {
549                let batch =
550                    crate::codecs::decode::zipped_asset_supply_batch_from_bytes(payload, |id| {
551                        catalogs.quantity_scale_for_zipped_asset_id(id)
552                    })?;
553                if patch_catalog {
554                    catalogs.patch_zipper_supply(&batch.updates);
555                }
556                Ok(batch)
557            })
558            .await
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use buffa::Message;
566
567    fn encode(amount: &AssetAmount) -> Result<TradingWithdrawIntentPayload> {
568        WithdrawService::encode_payload(EncodeWithdrawPayload {
569            action: TradingWithdrawAction::ToFunding,
570            asset_id: 7,
571            amount,
572            amount_scale: Some(18),
573            idempotency_key: "withdraw-equivalence".into(),
574            destination_chain_id: 0,
575            destination_address: String::new(),
576            deadline_ts_sec: 1_800_000_000,
577            nonce: 42,
578        })
579    }
580
581    #[test]
582    fn decimal_and_scaled_withdraw_amount_encode_identically() {
583        let decimal =
584            AssetAmount::from_decimal_str("0.5", 18, QuantityDomain::LedgerE18, Some(7)).unwrap();
585        let scaled = AssetAmount::from_scaled(
586            500_000_000_000_000_000,
587            Some(18),
588            QuantityDomain::LedgerE18,
589            Some(7),
590        )
591        .unwrap();
592
593        assert_eq!(
594            encode(&decimal).unwrap().encode_to_vec(),
595            encode(&scaled).unwrap().encode_to_vec()
596        );
597    }
598
599    #[test]
600    fn withdraw_rejects_wrong_amount_domain() {
601        let amount =
602            AssetAmount::from_scaled(100, Some(18), QuantityDomain::Asset, Some(7)).unwrap();
603        assert!(encode(&amount).is_err());
604    }
605
606    #[test]
607    fn withdraw_rejects_missing_amount_scale_before_transport() {
608        let amount = AssetAmount::from_scaled(1, None, QuantityDomain::LedgerE18, Some(7)).unwrap();
609        let err = WithdrawService::encode_payload(EncodeWithdrawPayload {
610            action: TradingWithdrawAction::ToFunding,
611            asset_id: 7,
612            amount: &amount,
613            amount_scale: None,
614            idempotency_key: "missing-scale".into(),
615            destination_chain_id: 0,
616            destination_address: String::new(),
617            deadline_ts_sec: 1_800_000_000,
618            nonce: 42,
619        })
620        .expect_err("missing scale must not silently mean e18");
621        assert!(err.to_string().contains("amount scale is required"));
622    }
623
624    #[tokio::test]
625    async fn withdraw_rejects_missing_signature_before_transport() {
626        let client = crate::Client::new(crate::Config {
627            hydrate_catalogs: false,
628            ..Default::default()
629        })
630        .unwrap();
631        let params = CreateTradingWithdrawParams {
632            asset_id: 7,
633            amount: AssetAmount::from_scaled(100, Some(18), QuantityDomain::LedgerE18, Some(7))
634                .unwrap(),
635            payload_signature: Vec::new(),
636            destination_address: String::new(),
637            idempotency_key: "missing-signature".into(),
638            amount_scale: Some(18),
639            deadline_ts_sec: Some(1_800_000_000),
640            nonce: 42,
641        };
642        let err = client.withdraw.create_to_funding(params).await.unwrap_err();
643        assert!(err.to_string().contains("payload_signature"));
644    }
645
646    #[test]
647    fn withdraw_rejects_empty_idempotency_key() {
648        let amount =
649            AssetAmount::from_scaled(100, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
650        let err = WithdrawService::encode_payload(EncodeWithdrawPayload {
651            action: TradingWithdrawAction::ToFunding,
652            asset_id: 7,
653            amount: &amount,
654            amount_scale: Some(18),
655            idempotency_key: "  ".into(),
656            destination_chain_id: 0,
657            destination_address: String::new(),
658            deadline_ts_sec: 1_800_000_000,
659            nonce: 42,
660        })
661        .unwrap_err();
662        assert!(err.to_string().contains("idempotency_key"));
663    }
664
665    #[test]
666    fn withdraw_rejects_zero_nonce() {
667        let amount =
668            AssetAmount::from_scaled(100, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
669        let err = WithdrawService::encode_payload(EncodeWithdrawPayload {
670            action: TradingWithdrawAction::ToFunding,
671            asset_id: 7,
672            amount: &amount,
673            amount_scale: Some(18),
674            idempotency_key: "stable-withdraw".into(),
675            destination_chain_id: 0,
676            destination_address: String::new(),
677            deadline_ts_sec: 1_800_000_000,
678            nonce: 0,
679        })
680        .unwrap_err();
681        assert!(err.to_string().contains("nonce"));
682    }
683
684    #[test]
685    fn withdrawal_generators_return_explicit_unique_values() {
686        let first_key = new_trading_withdraw_idempotency_key().unwrap();
687        let second_key = new_trading_withdraw_idempotency_key().unwrap();
688        assert!(first_key.starts_with("wd-"));
689        assert_eq!(first_key.len(), 35);
690        assert_ne!(first_key, second_key);
691        assert_ne!(new_trading_withdraw_nonce().unwrap(), 0);
692    }
693
694    fn signing_client(seed_hex: &str) -> crate::Client {
695        crate::Client::new(crate::Config {
696            api_key_id: Some("withdraw-test-key".into()),
697            api_private_key: Some(seed_hex.into()),
698            hydrate_catalogs: false,
699            ..Default::default()
700        })
701        .unwrap()
702    }
703
704    fn api_key_params(amount: AssetAmount) -> CreateApiKeyTradingWithdrawParams {
705        CreateApiKeyTradingWithdrawParams {
706            asset_id: 7,
707            amount,
708            destination_address: String::new(),
709            idempotency_key: "prepared-withdraw".into(),
710            amount_scale: Some(2),
711            deadline_ts_sec: Some(1_800_000_000),
712            nonce: Some(42),
713        }
714    }
715
716    #[test]
717    fn prepared_api_key_withdraw_retains_deadline_rescales_e18_and_signs_exact_bytes() {
718        use ed25519_dalek::{Signature, Verifier, VerifyingKey};
719
720        let seed = [7_u8; 32];
721        let client = signing_client(&hex::encode(seed));
722        let amount =
723            AssetAmount::from_scaled(125, Some(2), QuantityDomain::LedgerE18, Some(7)).unwrap();
724        let prepared = client
725            .withdraw
726            .prepare_api_key_to_funding(api_key_params(amount))
727            .unwrap();
728        let payload = prepared.payload();
729
730        assert_eq!(payload.deadline_ts_sec, 1_800_000_000);
731        let amount = payload.amount_e18.as_option().unwrap();
732        assert_eq!(
733            (u128::from(amount.hi) << 64) | u128::from(amount.lo),
734            1_250_000_000_000_000_000
735        );
736        let verifying_key = VerifyingKey::from(&ed25519_dalek::SigningKey::from_bytes(&seed));
737        let signature = Signature::from_slice(prepared.payload_signature()).unwrap();
738        verifying_key
739            .verify(&prepared.deterministic_payload_bytes(), &signature)
740            .unwrap();
741        let restored =
742            PreparedTradingWithdraw::from_request_bytes(&prepared.request_bytes()).unwrap();
743        assert_eq!(restored.request_bytes(), prepared.request_bytes());
744
745        let identical = client
746            .withdraw
747            .prepare_api_key_to_funding(api_key_params(
748                AssetAmount::from_scaled(125, Some(2), QuantityDomain::LedgerE18, Some(7)).unwrap(),
749            ))
750            .unwrap();
751        assert_eq!(
752            prepared.deterministic_payload_bytes(),
753            identical.deterministic_payload_bytes()
754        );
755        assert_eq!(prepared.payload_signature(), identical.payload_signature());
756    }
757
758    #[tokio::test]
759    async fn precomputed_signature_path_rejects_missing_deadline() {
760        let client = crate::Client::new(crate::Config {
761            hydrate_catalogs: false,
762            ..Default::default()
763        })
764        .unwrap();
765        let err = client
766            .withdraw
767            .create_to_funding(CreateTradingWithdrawParams {
768                asset_id: 7,
769                amount: AssetAmount::from_scaled(1, Some(18), QuantityDomain::LedgerE18, Some(7))
770                    .unwrap(),
771                payload_signature: vec![1],
772                destination_address: String::new(),
773                idempotency_key: "missing-deadline".into(),
774                amount_scale: Some(18),
775                deadline_ts_sec: None,
776                nonce: 42,
777            })
778            .await
779            .unwrap_err();
780        assert!(matches!(err, Error::Validation(_)));
781        assert!(err.to_string().contains("deadline_ts_sec"));
782    }
783
784    #[tokio::test]
785    async fn validate_destination_rejects_missing_inputs_before_transport() {
786        let client = crate::Client::new(crate::Config {
787            hydrate_catalogs: false,
788            ..Default::default()
789        })
790        .unwrap();
791        let chain_err = client
792            .withdraw
793            .validate_destination(0, "0xabc")
794            .await
795            .unwrap_err();
796        assert!(matches!(chain_err, Error::Validation(_)));
797        assert!(chain_err.to_string().contains("destination_chain_id"));
798        let address_err = client
799            .withdraw
800            .validate_destination(6, "  ")
801            .await
802            .unwrap_err();
803        assert!(matches!(address_err, Error::Validation(_)));
804        assert!(address_err.to_string().contains("destination_address"));
805    }
806
807    #[tokio::test]
808    async fn wallet_withdraw_rejects_unknown_action_as_validation() {
809        let client = crate::Client::new(crate::Config {
810            hydrate_catalogs: false,
811            ..Default::default()
812        })
813        .unwrap();
814        let err = client
815            .withdraw
816            .create_wallet_trading_withdraw(CreateWalletTradingWithdrawParams {
817                action: "future_action".into(),
818                asset_id: 7,
819                amount: AssetAmount::from_scaled(1, Some(18), QuantityDomain::LedgerE18, Some(7))
820                    .unwrap(),
821                idempotency_key: "unknown-action".into(),
822                payload_signature: vec![1],
823                signer_wallet: "0x1".into(),
824                destination_chain_id: 0,
825                destination_address: String::new(),
826                subaccount_id: None,
827                amount_scale: Some(18),
828                deadline_ts_sec: Some(1_800_000_000),
829                nonce: 42,
830            })
831            .await
832            .unwrap_err();
833        assert!(matches!(err, Error::Validation(_)));
834        assert!(err.to_string().contains("unknown trading withdraw action"));
835    }
836}