Skip to main content

fynd_client/
signing.rs

1use std::{future::Future, pin::Pin};
2
3use alloy::{
4    consensus::{TxEip1559, TypedTransaction},
5    dyn_abi::TypedData,
6    primitives::{Address, Signature, B256},
7};
8use num_bigint::BigUint;
9
10use crate::{error::FyndError, Quote};
11
12// ============================================================================
13// PAYLOADS
14// ============================================================================
15
16/// A ready-to-sign EIP-1559 transaction produced by the Fynd execution path.
17///
18/// Obtain one via [`FyndClient::swap_payload`](crate::FyndClient::swap_payload) when
19/// the quote's backend is [`BackendKind::Fynd`](crate::BackendKind::Fynd).
20#[derive(Debug)]
21pub struct FyndPayload {
22    quote: Quote,
23    tx: TypedTransaction,
24}
25
26impl FyndPayload {
27    pub(crate) fn new(quote: Quote, tx: TypedTransaction) -> Self {
28        Self { quote, tx }
29    }
30
31    /// The order quote this payload was built from.
32    pub fn quote(&self) -> &Quote {
33        &self.quote
34    }
35
36    /// The unsigned EIP-1559 transaction. Sign its
37    /// [`signature_hash()`](alloy::consensus::SignableTransaction::signature_hash) and pass the
38    /// result to [`SignedSwap::assemble`].
39    pub fn tx(&self) -> &TypedTransaction {
40        &self.tx
41    }
42
43    /// Consume the payload and return the inner parts for use in `execute()`.
44    pub(crate) fn into_parts(self) -> (Quote, TypedTransaction) {
45        (self.quote, self.tx)
46    }
47}
48
49/// Turbine payload stub. Fields are `()` placeholders until the Turbine signing story lands.
50#[derive(Debug)]
51pub struct TurbinePayload {
52    #[allow(dead_code)]
53    // Placeholder: will be populated in the Turbine signing story
54    _order_quote: (),
55}
56
57/// A payload that needs to be signed before a swap can be executed.
58///
59/// Use [`signing_hash`](Self::signing_hash) to obtain the bytes to sign, then pass the resulting
60/// [`alloy::primitives::Signature`] to [`SignedSwap::assemble`].
61///
62/// Only the [`Fynd`](Self::Fynd) variant is currently executable; calling methods on the
63/// [`Turbine`](Self::Turbine) variant will panic with `unimplemented!`.
64#[derive(Debug)]
65pub enum SwapPayload {
66    /// Fynd execution path — an EIP-1559 transaction targeting the RouterV3 contract.
67    Fynd(Box<FyndPayload>),
68    /// Turbine execution path — not yet implemented.
69    Turbine(TurbinePayload),
70}
71
72impl SwapPayload {
73    /// Returns the 32-byte hash that must be signed.
74    ///
75    /// For the Fynd path this is the EIP-1559 transaction's `signature_hash()`.
76    ///
77    /// # Panics
78    ///
79    /// Panics if called on the `Turbine` variant.
80    pub fn signing_hash(&self) -> B256 {
81        match self {
82            Self::Fynd(p) => {
83                use alloy::consensus::SignableTransaction;
84                p.tx.signature_hash()
85            }
86            Self::Turbine(_) => unimplemented!("Turbine signing not yet implemented"),
87        }
88    }
89
90    /// Returns EIP-712 typed data for wallets that support `eth_signTypedData_v4`.
91    ///
92    /// Always returns `None` for EIP-1559 transactions (Fynd path); those use
93    /// [`signing_hash`](Self::signing_hash) instead.
94    pub fn typed_data(&self) -> Option<&TypedData> {
95        // EIP-1559 transactions use a signing hash, not EIP-712 typed data.
96        match self {
97            Self::Fynd(_) | Self::Turbine(_) => None,
98        }
99    }
100
101    /// The order quote embedded in this payload.
102    ///
103    /// # Panics
104    ///
105    /// Panics if called on the `Turbine` variant.
106    pub fn quote(&self) -> &Quote {
107        match self {
108            Self::Fynd(p) => &p.quote,
109            Self::Turbine(_) => unimplemented!("Turbine signing not yet implemented"),
110        }
111    }
112
113    /// Consume the payload and return its inner parts for use in `execute_swap()`.
114    pub(crate) fn into_fynd_parts(
115        self,
116    ) -> Result<(Quote, TypedTransaction), crate::error::FyndError> {
117        match self {
118            Self::Fynd(p) => Ok(p.into_parts()),
119            Self::Turbine(_) => Err(crate::error::FyndError::Protocol(
120                "Turbine execution not yet implemented".into(),
121            )),
122        }
123    }
124}
125
126// ============================================================================
127// SIGNED ORDER
128// ============================================================================
129
130/// A [`SwapPayload`] paired with its cryptographic signature.
131///
132/// Construct via [`SignedSwap::assemble`] after signing the
133/// [`signing_hash`](SwapPayload::signing_hash). Pass to
134/// [`FyndClient::execute_swap`](crate::FyndClient::execute_swap) to broadcast and settle.
135pub struct SignedSwap {
136    payload: SwapPayload,
137    signature: Signature,
138}
139
140impl SignedSwap {
141    /// Pair a payload with the signature produced by signing its
142    /// [`signing_hash`](SwapPayload::signing_hash).
143    pub fn assemble(payload: SwapPayload, signature: Signature) -> Self {
144        Self { payload, signature }
145    }
146
147    /// The underlying swap payload.
148    pub fn payload(&self) -> &SwapPayload {
149        &self.payload
150    }
151
152    /// The signature over the payload's signing hash.
153    pub fn signature(&self) -> &Signature {
154        &self.signature
155    }
156
157    pub(crate) fn into_parts(self) -> (SwapPayload, Signature) {
158        (self.payload, self.signature)
159    }
160}
161
162// ============================================================================
163// SETTLED ORDER
164// ============================================================================
165
166/// The result of a successfully mined or simulated swap transaction.
167///
168/// Returned by awaiting an [`ExecutionReceipt`]. For dry-run executions
169/// ([`ExecutionOptions::dry_run`](crate::ExecutionOptions)), `tx_receipt` is `None`.
170pub struct SettledOrder {
171    settled_amount: Option<BigUint>,
172    gas_cost: BigUint,
173}
174
175impl SettledOrder {
176    pub(crate) fn new(settled_amount: Option<BigUint>, gas_cost: BigUint) -> Self {
177        Self { settled_amount, gas_cost }
178    }
179
180    /// The total amount of `token_out` actually received by the receiver, summed across all
181    /// matching ERC-20 and ERC-6909 Transfer logs. Returns `None` when no matching logs are found
182    /// (e.g. the swap reverted or used an unsupported token standard).
183    pub fn settled_amount(&self) -> Option<&BigUint> {
184        self.settled_amount.as_ref()
185    }
186
187    /// The actual gas cost of the transaction in wei (`gas_used * effective_gas_price`).
188    pub fn gas_cost(&self) -> &BigUint {
189        &self.gas_cost
190    }
191}
192
193// ============================================================================
194// EXECUTION RECEIPT
195// ============================================================================
196
197/// A future that resolves once the swap transaction is mined and settled.
198///
199/// Returned by [`FyndClient::execute_swap`](crate::FyndClient::execute_swap). The inner future
200/// polls the RPC node every 2 seconds and **has no built-in timeout** — it will poll indefinitely
201/// if the transaction is never mined. Callers should wrap it with [`tokio::time::timeout`]:
202///
203/// ```rust,no_run
204/// # use fynd_client::ExecutionReceipt;
205/// # use std::time::Duration;
206/// # async fn example(receipt: ExecutionReceipt) {
207/// let result = tokio::time::timeout(Duration::from_secs(120), receipt).await;
208/// # }
209/// ```
210pub enum ExecutionReceipt {
211    /// A pending on-chain transaction.
212    Transaction(Pin<Box<dyn Future<Output = Result<SettledOrder, FyndError>> + Send + 'static>>),
213}
214
215impl Future for ExecutionReceipt {
216    type Output = Result<SettledOrder, FyndError>;
217
218    fn poll(
219        self: Pin<&mut Self>,
220        cx: &mut std::task::Context<'_>,
221    ) -> std::task::Poll<Self::Output> {
222        match self.get_mut() {
223            Self::Transaction(fut) => fut.as_mut().poll(cx),
224        }
225    }
226}
227
228// ============================================================================
229// APPROVAL PAYLOAD
230// ============================================================================
231
232/// An unsigned EIP-1559 `approve(spender, amount)` transaction.
233///
234/// Obtain via [`FyndClient::approval`](crate::FyndClient::approval). Sign its
235/// [`signing_hash`](Self::signing_hash) and pass the result to [`SignedApproval::assemble`].
236pub struct ApprovalPayload {
237    pub(crate) tx: TxEip1559,
238    /// ERC-20 token contract address (20 raw bytes).
239    pub(crate) token: bytes::Bytes,
240    /// Spender address being approved (20 raw bytes).
241    pub(crate) spender: bytes::Bytes,
242    /// Amount being approved (token units).
243    pub(crate) amount: BigUint,
244}
245
246impl ApprovalPayload {
247    /// The 32-byte hash to sign.
248    pub fn signing_hash(&self) -> B256 {
249        use alloy::consensus::SignableTransaction;
250        self.tx.signature_hash()
251    }
252
253    /// The unsigned EIP-1559 transaction.
254    pub fn tx(&self) -> &TxEip1559 {
255        &self.tx
256    }
257
258    /// ERC-20 token address (20 raw bytes).
259    pub fn token(&self) -> &bytes::Bytes {
260        &self.token
261    }
262
263    /// Spender address (20 raw bytes).
264    pub fn spender(&self) -> &bytes::Bytes {
265        &self.spender
266    }
267
268    /// Amount to approve (token units).
269    pub fn amount(&self) -> &BigUint {
270        &self.amount
271    }
272}
273
274/// An [`ApprovalPayload`] paired with its cryptographic signature.
275///
276/// Construct via [`SignedApproval::assemble`] after signing the
277/// [`signing_hash`](ApprovalPayload::signing_hash). Pass to
278/// [`FyndClient::execute_approval`](crate::FyndClient::execute_approval).
279pub struct SignedApproval {
280    payload: ApprovalPayload,
281    signature: Signature,
282}
283
284impl SignedApproval {
285    /// Pair a payload with the signature produced by signing its
286    /// [`signing_hash`](ApprovalPayload::signing_hash).
287    pub fn assemble(payload: ApprovalPayload, signature: Signature) -> Self {
288        Self { payload, signature }
289    }
290
291    /// The underlying approval payload.
292    pub fn payload(&self) -> &ApprovalPayload {
293        &self.payload
294    }
295
296    /// The signature over the payload's signing hash.
297    pub fn signature(&self) -> &Signature {
298        &self.signature
299    }
300
301    pub(crate) fn into_parts(self) -> (ApprovalPayload, Signature) {
302        (self.payload, self.signature)
303    }
304}
305
306// ============================================================================
307// MINED TX / TX RECEIPT
308// ============================================================================
309
310/// The result of a successfully mined transaction (non-swap).
311pub struct MinedTx {
312    tx_hash: B256,
313    gas_cost: BigUint,
314}
315
316impl MinedTx {
317    pub(crate) fn new(tx_hash: B256, gas_cost: BigUint) -> Self {
318        Self { tx_hash, gas_cost }
319    }
320
321    /// Transaction hash.
322    pub fn tx_hash(&self) -> B256 {
323        self.tx_hash
324    }
325
326    /// Actual gas cost in wei (`gas_used * effective_gas_price`).
327    pub fn gas_cost(&self) -> &BigUint {
328        &self.gas_cost
329    }
330}
331
332/// A future that resolves once a submitted transaction is mined.
333///
334/// Returned by [`FyndClient::execute_approval`](crate::FyndClient::execute_approval). Polls the RPC
335/// node every 2 seconds with no built-in timeout — wrap with [`tokio::time::timeout`] as needed.
336pub enum TxReceipt {
337    /// A pending on-chain transaction.
338    Pending(Pin<Box<dyn Future<Output = Result<MinedTx, FyndError>> + Send + 'static>>),
339}
340
341impl Future for TxReceipt {
342    type Output = Result<MinedTx, FyndError>;
343
344    fn poll(
345        self: Pin<&mut Self>,
346        cx: &mut std::task::Context<'_>,
347    ) -> std::task::Poll<Self::Output> {
348        match self.get_mut() {
349            Self::Pending(fut) => fut.as_mut().poll(cx),
350        }
351    }
352}
353
354// ============================================================================
355// TRANSFER LOG DECODING
356// ============================================================================
357
358/// Compute the total amount of `token_out` received by `receiver` from a transaction receipt.
359///
360/// Scans ERC-20 and ERC-6909 Transfer logs matching the given token address and receiver.
361pub(crate) fn compute_settled_amount(
362    receipt: &alloy::rpc::types::TransactionReceipt,
363    token_out_addr: &Address,
364    receiver_addr: &Address,
365) -> Option<BigUint> {
366    use alloy::primitives::keccak256;
367
368    // ERC-20: Transfer(address indexed from, address indexed to, uint256 value)
369    let erc20_topic = keccak256(b"Transfer(address,address,uint256)");
370    // ERC-6909: Transfer(address caller, address indexed from, address indexed to,
371    //                    uint256 indexed id, uint256 amount)
372    let erc6909_topic = keccak256(b"Transfer(address,address,address,uint256,uint256)");
373
374    let mut total = BigUint::ZERO;
375    let mut found = false;
376
377    for log in receipt.logs() {
378        if log.address() != *token_out_addr {
379            continue;
380        }
381        let topics = log.topics();
382        if topics.is_empty() {
383            continue;
384        }
385
386        if topics[0] == erc20_topic && topics.len() >= 3 {
387            // topics[2] is the `to` address (padded to 32 bytes); address is in last 20 bytes.
388            let to = Address::from_slice(&topics[2].as_slice()[12..]);
389            if to == *receiver_addr {
390                let data = &log.data().data;
391                if data.len() >= 32 {
392                    let amount = BigUint::from_bytes_be(&data[0..32]);
393                    total += amount;
394                    found = true;
395                }
396            }
397        } else if topics[0] == erc6909_topic && topics.len() >= 3 {
398            // topics[2] is the `to` address.
399            let to = Address::from_slice(&topics[2].as_slice()[12..]);
400            if to == *receiver_addr {
401                // data encodes (address caller, uint256 amount) = 64 bytes; amount at bytes 32..64.
402                let data = &log.data().data;
403                if data.len() >= 64 {
404                    let amount = BigUint::from_bytes_be(&data[32..64]);
405                    total += amount;
406                    found = true;
407                }
408            }
409        }
410    }
411
412    if found {
413        Some(total)
414    } else {
415        None
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use alloy::{
422        primitives::{keccak256, Address, Bytes as AlloyBytes, LogData, B256},
423        rpc::types::{Log, TransactionReceipt},
424    };
425
426    use super::*;
427
428    // -----------------------------------------------------------------------
429    // Helpers
430    // -----------------------------------------------------------------------
431
432    fn make_receipt(logs: Vec<Log>) -> TransactionReceipt {
433        use alloy::{
434            consensus::{Receipt, ReceiptEnvelope, ReceiptWithBloom},
435            primitives::{Bloom, TxHash},
436        };
437
438        TransactionReceipt {
439            inner: ReceiptEnvelope::Eip1559(ReceiptWithBloom {
440                receipt: Receipt {
441                    status: alloy::consensus::Eip658Value::Eip658(true),
442                    cumulative_gas_used: 21_000,
443                    logs,
444                },
445                logs_bloom: Bloom::default(),
446            }),
447            transaction_hash: TxHash::default(),
448            transaction_index: None,
449            block_hash: None,
450            block_number: None,
451            gas_used: 21_000,
452            effective_gas_price: 1,
453            blob_gas_used: None,
454            blob_gas_price: None,
455            from: Address::ZERO,
456            to: None,
457            contract_address: None,
458        }
459    }
460
461    fn erc20_topic() -> B256 {
462        keccak256(b"Transfer(address,address,uint256)")
463    }
464
465    fn erc6909_topic() -> B256 {
466        keccak256(b"Transfer(address,address,address,uint256,uint256)")
467    }
468
469    /// Pad an address into a 32-byte B256 topic (right-align in 32 bytes).
470    fn addr_topic(addr: Address) -> B256 {
471        let mut topic = [0u8; 32];
472        topic[12..].copy_from_slice(addr.as_slice());
473        B256::from(topic)
474    }
475
476    /// Encode a u64 amount as 32 big-endian bytes.
477    fn encode_u256(amount: u64) -> Vec<u8> {
478        let mut buf = [0u8; 32];
479        let bytes = amount.to_be_bytes();
480        buf[24..].copy_from_slice(&bytes);
481        buf.to_vec()
482    }
483
484    fn make_log(address: Address, topics: Vec<B256>, data: Vec<u8>) -> Log {
485        Log {
486            inner: alloy::primitives::Log {
487                address,
488                data: LogData::new_unchecked(topics, AlloyBytes::from(data)),
489            },
490            block_hash: None,
491            block_number: None,
492            block_timestamp: None,
493            transaction_hash: None,
494            transaction_index: None,
495            log_index: None,
496            removed: false,
497        }
498    }
499
500    // -----------------------------------------------------------------------
501    // ERC-20 tests
502    // -----------------------------------------------------------------------
503
504    #[test]
505    fn erc20_transfer_log_matched() {
506        let token = Address::with_last_byte(0x01);
507        let from = Address::with_last_byte(0x02);
508        let receiver = Address::with_last_byte(0x03);
509
510        let log = make_log(
511            token,
512            vec![erc20_topic(), addr_topic(from), addr_topic(receiver)],
513            encode_u256(500),
514        );
515        let receipt = make_receipt(vec![log]);
516
517        let result = compute_settled_amount(&receipt, &token, &receiver);
518        assert_eq!(result, Some(BigUint::from(500u64)));
519    }
520
521    #[test]
522    fn erc20_transfer_log_wrong_token() {
523        let token = Address::with_last_byte(0x01);
524        let other_token = Address::with_last_byte(0x99);
525        let receiver = Address::with_last_byte(0x03);
526
527        let log = make_log(
528            other_token, // different token address
529            vec![erc20_topic(), addr_topic(Address::ZERO), addr_topic(receiver)],
530            encode_u256(500),
531        );
532        let receipt = make_receipt(vec![log]);
533
534        let result = compute_settled_amount(&receipt, &token, &receiver);
535        assert!(result.is_none());
536    }
537
538    #[test]
539    fn erc20_transfer_log_wrong_receiver() {
540        let token = Address::with_last_byte(0x01);
541        let receiver = Address::with_last_byte(0x03);
542        let other_receiver = Address::with_last_byte(0x04);
543
544        let log = make_log(
545            token,
546            vec![erc20_topic(), addr_topic(Address::ZERO), addr_topic(other_receiver)],
547            encode_u256(500),
548        );
549        let receipt = make_receipt(vec![log]);
550
551        let result = compute_settled_amount(&receipt, &token, &receiver);
552        assert!(result.is_none());
553    }
554
555    // -----------------------------------------------------------------------
556    // ERC-6909 tests
557    // -----------------------------------------------------------------------
558
559    #[test]
560    fn erc6909_transfer_log_matched() {
561        let token = Address::with_last_byte(0x10);
562        let from = Address::with_last_byte(0x11);
563        let receiver = Address::with_last_byte(0x12);
564
565        // ERC-6909: data = (address caller [32 bytes], uint256 amount [32 bytes])
566        let mut data = [0u8; 64];
567        // caller address in first 32 bytes (right-aligned)
568        data[12..32].copy_from_slice(Address::with_last_byte(0xca).as_slice());
569        // amount in last 32 bytes
570        let amount_bytes = encode_u256(750);
571        data[32..].copy_from_slice(&amount_bytes);
572
573        let log = make_log(
574            token,
575            vec![erc6909_topic(), addr_topic(from), addr_topic(receiver)],
576            data.to_vec(),
577        );
578        let receipt = make_receipt(vec![log]);
579
580        let result = compute_settled_amount(&receipt, &token, &receiver);
581        assert_eq!(result, Some(BigUint::from(750u64)));
582    }
583
584    // -----------------------------------------------------------------------
585    // Edge cases
586    // -----------------------------------------------------------------------
587
588    #[test]
589    fn no_matching_logs_returns_none() {
590        let token = Address::with_last_byte(0x01);
591        let receiver = Address::with_last_byte(0x03);
592
593        // Log with unrelated topic
594        let unrelated_topic = keccak256(b"Approval(address,address,uint256)");
595        let log = make_log(
596            token,
597            vec![unrelated_topic, addr_topic(Address::ZERO), addr_topic(receiver)],
598            encode_u256(100),
599        );
600        let receipt = make_receipt(vec![log]);
601
602        let result = compute_settled_amount(&receipt, &token, &receiver);
603        assert!(result.is_none());
604    }
605
606    #[test]
607    fn empty_logs_returns_none() {
608        let token = Address::with_last_byte(0x01);
609        let receiver = Address::with_last_byte(0x03);
610        let receipt = make_receipt(vec![]);
611        assert!(compute_settled_amount(&receipt, &token, &receiver).is_none());
612    }
613
614    #[test]
615    fn multiple_matching_logs_amounts_summed() {
616        let token = Address::with_last_byte(0x01);
617        let from = Address::with_last_byte(0x02);
618        let receiver = Address::with_last_byte(0x03);
619
620        let log1 = make_log(
621            token,
622            vec![erc20_topic(), addr_topic(from), addr_topic(receiver)],
623            encode_u256(100),
624        );
625        let log2 = make_log(
626            token,
627            vec![erc20_topic(), addr_topic(from), addr_topic(receiver)],
628            encode_u256(200),
629        );
630        // A log to a different receiver that should NOT be counted.
631        let log3 = make_log(
632            token,
633            vec![erc20_topic(), addr_topic(from), addr_topic(Address::with_last_byte(0xff))],
634            encode_u256(999),
635        );
636        let receipt = make_receipt(vec![log1, log2, log3]);
637
638        let result = compute_settled_amount(&receipt, &token, &receiver);
639        assert_eq!(result, Some(BigUint::from(300u64)));
640    }
641}