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_intent_from_proto,
7    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    WithdrawIntentResult,
19};
20use crate::proto::chain::deposit::v1::{CreateDepositAddressRequest, ListDepositAddressesRequest};
21use crate::proto::chain::withdraw::v1::{
22    CreateTradingWithdrawRequest, CreateWalletTradingWithdrawRequest, TradingWithdrawAction,
23    TradingWithdrawIntentPayload,
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    /// Withdraw from trading to funding. Amount must be an [`crate::types::AssetAmount`].
336    pub async fn create_to_funding(
337        &self,
338        params: CreateTradingWithdrawParams,
339    ) -> Result<WithdrawIntentResult> {
340        if params.payload_signature.is_empty() {
341            return Err(Error::validation(
342                "payload_signature is required for trading withdraw",
343            ));
344        }
345        let deadline_ts_sec = params.deadline_ts_sec.ok_or_else(|| {
346            Error::validation("deadline_ts_sec is required when payload_signature is precomputed")
347        })?;
348        let payload = Self::encode_payload(EncodeWithdrawPayload {
349            action: TradingWithdrawAction::ToFunding,
350            asset_id: params.asset_id,
351            amount: &params.amount,
352            amount_scale: params.amount_scale,
353            idempotency_key: params.idempotency_key,
354            destination_chain_id: 0,
355            destination_address: params.destination_address,
356            deadline_ts_sec,
357            nonce: params.nonce,
358        })?;
359        let mut req = CreateTradingWithdrawRequest {
360            payload_signature: params.payload_signature,
361            ..Default::default()
362        };
363        *req.payload.get_or_insert_default() = payload;
364        self.create_trading_withdraw(req).await
365    }
366
367    /// Withdraw from trading to an external chain. Amount must be an [`AssetAmount`].
368    pub async fn create_to_external_chain(
369        &self,
370        params: CreateTradingWithdrawParams,
371        destination_chain_id: u64,
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        if params.destination_address.is_empty() {
379            return Err(Error::validation(
380                "destination_address is required for external-chain withdraw",
381            ));
382        }
383        let deadline_ts_sec = params.deadline_ts_sec.ok_or_else(|| {
384            Error::validation("deadline_ts_sec is required when payload_signature is precomputed")
385        })?;
386        let payload = Self::encode_payload(EncodeWithdrawPayload {
387            action: TradingWithdrawAction::ToExternalChain,
388            asset_id: params.asset_id,
389            amount: &params.amount,
390            amount_scale: params.amount_scale,
391            idempotency_key: params.idempotency_key,
392            destination_chain_id,
393            destination_address: params.destination_address,
394            deadline_ts_sec,
395            nonce: params.nonce,
396        })?;
397        let mut req = CreateTradingWithdrawRequest {
398            payload_signature: params.payload_signature,
399            ..Default::default()
400        };
401        *req.payload.get_or_insert_default() = payload;
402        self.create_trading_withdraw(req).await
403    }
404
405    async fn create_trading_withdraw(
406        &self,
407        req: CreateTradingWithdrawRequest,
408    ) -> Result<WithdrawIntentResult> {
409        let client = self.connect_client();
410        let resp = unary::await_auth(
411            &self.ctx.factory,
412            "/chain.withdraw.v1.WithdrawService/CreateTradingWithdraw",
413            req,
414            |req, opts| client.create_trading_withdraw_with_options(req, opts),
415        )
416        .await?
417        .into_owned();
418        withdraw_intent_from_proto(&resp)
419    }
420
421    /// Wallet-signed trading withdraw. Amount must be an [`AssetAmount`].
422    pub async fn create_wallet_trading_withdraw(
423        &self,
424        params: CreateWalletTradingWithdrawParams,
425    ) -> Result<WithdrawIntentResult> {
426        if params.payload_signature.is_empty() {
427            return Err(Error::validation(
428                "payload_signature is required for trading withdraw",
429            ));
430        }
431        let action = match params
432            .action
433            .to_ascii_lowercase()
434            .replace('-', "_")
435            .as_str()
436        {
437            "to_funding" => TradingWithdrawAction::ToFunding,
438            "to_external_chain" => TradingWithdrawAction::ToExternalChain,
439            _ => {
440                return Err(Error::validation(format!(
441                    "unknown trading withdraw action: {}",
442                    params.action
443                )));
444            }
445        };
446        let deadline_ts_sec = params.deadline_ts_sec.ok_or_else(|| {
447            Error::validation("deadline_ts_sec is required when payload_signature is precomputed")
448        })?;
449        let payload = Self::encode_payload(EncodeWithdrawPayload {
450            action,
451            asset_id: params.asset_id,
452            amount: &params.amount,
453            amount_scale: params.amount_scale,
454            idempotency_key: params.idempotency_key,
455            destination_chain_id: params.destination_chain_id,
456            destination_address: params.destination_address,
457            deadline_ts_sec,
458            nonce: params.nonce,
459        })?;
460        let mut req = CreateWalletTradingWithdrawRequest {
461            signer_wallet: params.signer_wallet,
462            payload_signature: params.payload_signature,
463            subaccount_id: scope::optional_subaccount(&self.ctx, params.subaccount_id)?
464                .unwrap_or(0),
465            ..Default::default()
466        };
467        *req.payload.get_or_insert_default() = payload;
468        let client = self.connect_client();
469        let resp = unary::await_auth(
470            &self.ctx.factory,
471            "/chain.withdraw.v1.WithdrawService/CreateWalletTradingWithdraw",
472            req,
473            |req, opts| client.create_wallet_trading_withdraw_with_options(req, opts),
474        )
475        .await?
476        .into_owned();
477        withdraw_intent_from_wallet_proto(&resp)
478    }
479}
480
481#[derive(Clone)]
482pub struct ZipperService {
483    ctx: ServiceContext,
484}
485
486impl ZipperService {
487    pub fn new(ctx: ServiceContext) -> Self {
488        Self { ctx }
489    }
490
491    pub async fn get_deposit_withdraw_config(&self) -> Result<DepositWithdrawConfig> {
492        let client = ZipperServiceClient::new(
493            self.ctx.factory.transport(),
494            self.ctx.factory.connect_config(),
495        );
496        let resp = unary::await_public(
497            client.get_deposit_withdraw_config(GetDepositWithdrawConfigRequest::default()),
498        )
499        .await?
500        .into_owned();
501        Ok(deposit_withdraw_config_from_proto(&resp))
502    }
503
504    /// Subscribe to zipped-asset supply updates (requires `realtime` feature).
505    ///
506    /// When `patch_catalog` is true, each batch updates
507    /// [`crate::catalogs::Manager::patch_zipper_supply`] (`zipped_asset_id` → supply string).
508    pub async fn subscribe_zipped_asset_supply(
509        &self,
510        patch_catalog: bool,
511    ) -> Result<crate::realtime::TypedSubscription<ZippedAssetSupplyBatch>> {
512        let catalogs = self.ctx.catalogs.clone();
513        self.ctx
514            .realtime
515            .subscribe_proto("public:chain:zipped-asset:supply:proto", move |payload| {
516                let batch =
517                    crate::codecs::decode::zipped_asset_supply_batch_from_bytes(payload, |id| {
518                        catalogs.quantity_scale_for_zipped_asset_id(id)
519                    })?;
520                if patch_catalog {
521                    catalogs.patch_zipper_supply(&batch.updates);
522                }
523                Ok(batch)
524            })
525            .await
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use buffa::Message;
533
534    fn encode(amount: &AssetAmount) -> Result<TradingWithdrawIntentPayload> {
535        WithdrawService::encode_payload(EncodeWithdrawPayload {
536            action: TradingWithdrawAction::ToFunding,
537            asset_id: 7,
538            amount,
539            amount_scale: Some(18),
540            idempotency_key: "withdraw-equivalence".into(),
541            destination_chain_id: 0,
542            destination_address: String::new(),
543            deadline_ts_sec: 1_800_000_000,
544            nonce: 42,
545        })
546    }
547
548    #[test]
549    fn decimal_and_scaled_withdraw_amount_encode_identically() {
550        let decimal =
551            AssetAmount::from_decimal_str("0.5", 18, QuantityDomain::LedgerE18, Some(7)).unwrap();
552        let scaled = AssetAmount::from_scaled(
553            500_000_000_000_000_000,
554            Some(18),
555            QuantityDomain::LedgerE18,
556            Some(7),
557        )
558        .unwrap();
559
560        assert_eq!(
561            encode(&decimal).unwrap().encode_to_vec(),
562            encode(&scaled).unwrap().encode_to_vec()
563        );
564    }
565
566    #[test]
567    fn withdraw_rejects_wrong_amount_domain() {
568        let amount =
569            AssetAmount::from_scaled(100, Some(18), QuantityDomain::Asset, Some(7)).unwrap();
570        assert!(encode(&amount).is_err());
571    }
572
573    #[test]
574    fn withdraw_rejects_missing_amount_scale_before_transport() {
575        let amount = AssetAmount::from_scaled(1, None, QuantityDomain::LedgerE18, Some(7)).unwrap();
576        let err = WithdrawService::encode_payload(EncodeWithdrawPayload {
577            action: TradingWithdrawAction::ToFunding,
578            asset_id: 7,
579            amount: &amount,
580            amount_scale: None,
581            idempotency_key: "missing-scale".into(),
582            destination_chain_id: 0,
583            destination_address: String::new(),
584            deadline_ts_sec: 1_800_000_000,
585            nonce: 42,
586        })
587        .expect_err("missing scale must not silently mean e18");
588        assert!(err.to_string().contains("amount scale is required"));
589    }
590
591    #[tokio::test]
592    async fn withdraw_rejects_missing_signature_before_transport() {
593        let client = crate::Client::new(crate::Config {
594            hydrate_catalogs: false,
595            ..Default::default()
596        })
597        .unwrap();
598        let params = CreateTradingWithdrawParams {
599            asset_id: 7,
600            amount: AssetAmount::from_scaled(100, Some(18), QuantityDomain::LedgerE18, Some(7))
601                .unwrap(),
602            payload_signature: Vec::new(),
603            destination_address: String::new(),
604            idempotency_key: "missing-signature".into(),
605            amount_scale: Some(18),
606            deadline_ts_sec: Some(1_800_000_000),
607            nonce: 42,
608        };
609        let err = client.withdraw.create_to_funding(params).await.unwrap_err();
610        assert!(err.to_string().contains("payload_signature"));
611    }
612
613    #[test]
614    fn withdraw_rejects_empty_idempotency_key() {
615        let amount =
616            AssetAmount::from_scaled(100, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
617        let err = WithdrawService::encode_payload(EncodeWithdrawPayload {
618            action: TradingWithdrawAction::ToFunding,
619            asset_id: 7,
620            amount: &amount,
621            amount_scale: Some(18),
622            idempotency_key: "  ".into(),
623            destination_chain_id: 0,
624            destination_address: String::new(),
625            deadline_ts_sec: 1_800_000_000,
626            nonce: 42,
627        })
628        .unwrap_err();
629        assert!(err.to_string().contains("idempotency_key"));
630    }
631
632    #[test]
633    fn withdraw_rejects_zero_nonce() {
634        let amount =
635            AssetAmount::from_scaled(100, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
636        let err = WithdrawService::encode_payload(EncodeWithdrawPayload {
637            action: TradingWithdrawAction::ToFunding,
638            asset_id: 7,
639            amount: &amount,
640            amount_scale: Some(18),
641            idempotency_key: "stable-withdraw".into(),
642            destination_chain_id: 0,
643            destination_address: String::new(),
644            deadline_ts_sec: 1_800_000_000,
645            nonce: 0,
646        })
647        .unwrap_err();
648        assert!(err.to_string().contains("nonce"));
649    }
650
651    #[test]
652    fn withdrawal_generators_return_explicit_unique_values() {
653        let first_key = new_trading_withdraw_idempotency_key().unwrap();
654        let second_key = new_trading_withdraw_idempotency_key().unwrap();
655        assert!(first_key.starts_with("wd-"));
656        assert_eq!(first_key.len(), 35);
657        assert_ne!(first_key, second_key);
658        assert_ne!(new_trading_withdraw_nonce().unwrap(), 0);
659    }
660
661    fn signing_client(seed_hex: &str) -> crate::Client {
662        crate::Client::new(crate::Config {
663            api_key_id: Some("withdraw-test-key".into()),
664            api_private_key: Some(seed_hex.into()),
665            hydrate_catalogs: false,
666            ..Default::default()
667        })
668        .unwrap()
669    }
670
671    fn api_key_params(amount: AssetAmount) -> CreateApiKeyTradingWithdrawParams {
672        CreateApiKeyTradingWithdrawParams {
673            asset_id: 7,
674            amount,
675            destination_address: String::new(),
676            idempotency_key: "prepared-withdraw".into(),
677            amount_scale: Some(2),
678            deadline_ts_sec: Some(1_800_000_000),
679            nonce: Some(42),
680        }
681    }
682
683    #[test]
684    fn prepared_api_key_withdraw_retains_deadline_rescales_e18_and_signs_exact_bytes() {
685        use ed25519_dalek::{Signature, Verifier, VerifyingKey};
686
687        let seed = [7_u8; 32];
688        let client = signing_client(&hex::encode(seed));
689        let amount =
690            AssetAmount::from_scaled(125, Some(2), QuantityDomain::LedgerE18, Some(7)).unwrap();
691        let prepared = client
692            .withdraw
693            .prepare_api_key_to_funding(api_key_params(amount))
694            .unwrap();
695        let payload = prepared.payload();
696
697        assert_eq!(payload.deadline_ts_sec, 1_800_000_000);
698        let amount = payload.amount_e18.as_option().unwrap();
699        assert_eq!(
700            (u128::from(amount.hi) << 64) | u128::from(amount.lo),
701            1_250_000_000_000_000_000
702        );
703        let verifying_key = VerifyingKey::from(&ed25519_dalek::SigningKey::from_bytes(&seed));
704        let signature = Signature::from_slice(prepared.payload_signature()).unwrap();
705        verifying_key
706            .verify(&prepared.deterministic_payload_bytes(), &signature)
707            .unwrap();
708        let restored =
709            PreparedTradingWithdraw::from_request_bytes(&prepared.request_bytes()).unwrap();
710        assert_eq!(restored.request_bytes(), prepared.request_bytes());
711
712        let identical = client
713            .withdraw
714            .prepare_api_key_to_funding(api_key_params(
715                AssetAmount::from_scaled(125, Some(2), QuantityDomain::LedgerE18, Some(7)).unwrap(),
716            ))
717            .unwrap();
718        assert_eq!(
719            prepared.deterministic_payload_bytes(),
720            identical.deterministic_payload_bytes()
721        );
722        assert_eq!(prepared.payload_signature(), identical.payload_signature());
723    }
724
725    #[tokio::test]
726    async fn precomputed_signature_path_rejects_missing_deadline() {
727        let client = crate::Client::new(crate::Config {
728            hydrate_catalogs: false,
729            ..Default::default()
730        })
731        .unwrap();
732        let err = client
733            .withdraw
734            .create_to_funding(CreateTradingWithdrawParams {
735                asset_id: 7,
736                amount: AssetAmount::from_scaled(1, Some(18), QuantityDomain::LedgerE18, Some(7))
737                    .unwrap(),
738                payload_signature: vec![1],
739                destination_address: String::new(),
740                idempotency_key: "missing-deadline".into(),
741                amount_scale: Some(18),
742                deadline_ts_sec: None,
743                nonce: 42,
744            })
745            .await
746            .unwrap_err();
747        assert!(matches!(err, Error::Validation(_)));
748        assert!(err.to_string().contains("deadline_ts_sec"));
749    }
750
751    #[tokio::test]
752    async fn wallet_withdraw_rejects_unknown_action_as_validation() {
753        let client = crate::Client::new(crate::Config {
754            hydrate_catalogs: false,
755            ..Default::default()
756        })
757        .unwrap();
758        let err = client
759            .withdraw
760            .create_wallet_trading_withdraw(CreateWalletTradingWithdrawParams {
761                action: "future_action".into(),
762                asset_id: 7,
763                amount: AssetAmount::from_scaled(1, Some(18), QuantityDomain::LedgerE18, Some(7))
764                    .unwrap(),
765                idempotency_key: "unknown-action".into(),
766                payload_signature: vec![1],
767                signer_wallet: "0x1".into(),
768                destination_chain_id: 0,
769                destination_address: String::new(),
770                subaccount_id: None,
771                amount_scale: Some(18),
772                deadline_ts_sec: Some(1_800_000_000),
773                nonce: 42,
774            })
775            .await
776            .unwrap_err();
777        assert!(matches!(err, Error::Validation(_)));
778        assert!(err.to_string().contains("unknown trading withdraw action"));
779    }
780}