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
30pub 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
42pub 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#[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 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 pub fn deterministic_payload_bytes(&self) -> Vec<u8> {
120 self.payload().encode_to_vec()
121 }
122
123 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: ¶ms.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 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 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 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 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 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 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: ¶ms.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 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: ¶ms.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 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: ¶ms.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 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}