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
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 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 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: ¶ms.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 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: ¶ms.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 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: ¶ms.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 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}