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_hash` and `tx_receipt` are `None`.
170#[derive(Debug, Clone)]
171pub struct SettledOrder {
172    tx_hash: Option<B256>,
173    settled_amount: Option<BigUint>,
174    gas_cost: BigUint,
175}
176
177impl SettledOrder {
178    pub(crate) fn new(
179        tx_hash: Option<B256>,
180        settled_amount: Option<BigUint>,
181        gas_cost: BigUint,
182    ) -> Self {
183        Self { tx_hash, settled_amount, gas_cost }
184    }
185
186    /// The transaction hash of the mined swap. `None` for dry-run simulations.
187    pub fn tx_hash(&self) -> Option<B256> {
188        self.tx_hash
189    }
190
191    /// The total amount of `token_out` actually received by the receiver, summed across all
192    /// matching ERC-20 and ERC-6909 Transfer logs. Returns `None` when no matching logs are found
193    /// (e.g. the swap reverted or used an unsupported token standard).
194    pub fn settled_amount(&self) -> Option<&BigUint> {
195        self.settled_amount.as_ref()
196    }
197
198    /// The actual gas cost of the transaction in wei (`gas_used * effective_gas_price`).
199    pub fn gas_cost(&self) -> &BigUint {
200        &self.gas_cost
201    }
202}
203
204// ============================================================================
205// EXECUTION RECEIPT
206// ============================================================================
207
208/// A future that resolves once the swap transaction is mined and settled.
209///
210/// Returned by [`FyndClient::execute_swap`](crate::FyndClient::execute_swap). The inner future
211/// polls the RPC node every 2 seconds and **has no built-in timeout** — it will poll indefinitely
212/// if the transaction is never mined. Callers should wrap it with [`tokio::time::timeout`]:
213///
214/// ```rust,no_run
215/// # use fynd_client::ExecutionReceipt;
216/// # use std::time::Duration;
217/// # async fn example(receipt: ExecutionReceipt) {
218/// let result = tokio::time::timeout(Duration::from_secs(120), receipt).await;
219/// # }
220/// ```
221pub enum ExecutionReceipt {
222    /// A pending on-chain transaction.
223    Transaction(Pin<Box<dyn Future<Output = Result<SettledOrder, FyndError>> + Send + 'static>>),
224}
225
226impl Future for ExecutionReceipt {
227    type Output = Result<SettledOrder, FyndError>;
228
229    fn poll(
230        self: Pin<&mut Self>,
231        cx: &mut std::task::Context<'_>,
232    ) -> std::task::Poll<Self::Output> {
233        match self.get_mut() {
234            Self::Transaction(fut) => fut.as_mut().poll(cx),
235        }
236    }
237}
238
239// ============================================================================
240// APPROVAL PAYLOAD
241// ============================================================================
242
243/// An unsigned EIP-1559 `approve(spender, amount)` transaction.
244///
245/// Obtain via [`FyndClient::approval`](crate::FyndClient::approval). Sign its
246/// [`signing_hash`](Self::signing_hash) and pass the result to [`SignedApproval::assemble`].
247pub struct ApprovalPayload {
248    pub(crate) tx: TxEip1559,
249    /// ERC-20 token contract address (20 raw bytes).
250    pub(crate) token: bytes::Bytes,
251    /// Spender address being approved (20 raw bytes).
252    pub(crate) spender: bytes::Bytes,
253    /// Amount being approved (token units).
254    pub(crate) amount: BigUint,
255}
256
257impl ApprovalPayload {
258    /// The 32-byte hash to sign.
259    pub fn signing_hash(&self) -> B256 {
260        use alloy::consensus::SignableTransaction;
261        self.tx.signature_hash()
262    }
263
264    /// The unsigned EIP-1559 transaction.
265    pub fn tx(&self) -> &TxEip1559 {
266        &self.tx
267    }
268
269    /// ERC-20 token address (20 raw bytes).
270    pub fn token(&self) -> &bytes::Bytes {
271        &self.token
272    }
273
274    /// Spender address (20 raw bytes).
275    pub fn spender(&self) -> &bytes::Bytes {
276        &self.spender
277    }
278
279    /// Amount to approve (token units).
280    pub fn amount(&self) -> &BigUint {
281        &self.amount
282    }
283}
284
285/// An [`ApprovalPayload`] paired with its cryptographic signature.
286///
287/// Construct via [`SignedApproval::assemble`] after signing the
288/// [`signing_hash`](ApprovalPayload::signing_hash). Pass to
289/// [`FyndClient::execute_approval`](crate::FyndClient::execute_approval).
290pub struct SignedApproval {
291    payload: ApprovalPayload,
292    signature: Signature,
293}
294
295impl SignedApproval {
296    /// Pair a payload with the signature produced by signing its
297    /// [`signing_hash`](ApprovalPayload::signing_hash).
298    pub fn assemble(payload: ApprovalPayload, signature: Signature) -> Self {
299        Self { payload, signature }
300    }
301
302    /// The underlying approval payload.
303    pub fn payload(&self) -> &ApprovalPayload {
304        &self.payload
305    }
306
307    /// The signature over the payload's signing hash.
308    pub fn signature(&self) -> &Signature {
309        &self.signature
310    }
311
312    pub(crate) fn into_parts(self) -> (ApprovalPayload, Signature) {
313        (self.payload, self.signature)
314    }
315}
316
317// ============================================================================
318// MINED TX / TX RECEIPT
319// ============================================================================
320
321/// The result of a successfully mined transaction (non-swap).
322#[derive(Debug, Clone)]
323pub struct MinedTx {
324    tx_hash: B256,
325    gas_cost: BigUint,
326}
327
328impl MinedTx {
329    pub(crate) fn new(tx_hash: B256, gas_cost: BigUint) -> Self {
330        Self { tx_hash, gas_cost }
331    }
332
333    /// Transaction hash.
334    pub fn tx_hash(&self) -> B256 {
335        self.tx_hash
336    }
337
338    /// Actual gas cost in wei (`gas_used * effective_gas_price`).
339    pub fn gas_cost(&self) -> &BigUint {
340        &self.gas_cost
341    }
342}
343
344/// A future that resolves once a submitted transaction is mined.
345///
346/// Returned by [`FyndClient::execute_approval`](crate::FyndClient::execute_approval). Polls the RPC
347/// node every 2 seconds with no built-in timeout — wrap with [`tokio::time::timeout`] as needed.
348pub enum TxReceipt {
349    /// A pending on-chain transaction.
350    Pending(Pin<Box<dyn Future<Output = Result<MinedTx, FyndError>> + Send + 'static>>),
351}
352
353impl Future for TxReceipt {
354    type Output = Result<MinedTx, FyndError>;
355
356    fn poll(
357        self: Pin<&mut Self>,
358        cx: &mut std::task::Context<'_>,
359    ) -> std::task::Poll<Self::Output> {
360        match self.get_mut() {
361            Self::Pending(fut) => fut.as_mut().poll(cx),
362        }
363    }
364}
365
366// ============================================================================
367// TRANSFER LOG DECODING
368// ============================================================================
369
370/// Compute the total amount of `token_out` received by `receiver` from a transaction receipt.
371///
372/// Scans ERC-20 and ERC-6909 Transfer logs matching the given token address and receiver.
373pub(crate) fn compute_settled_amount(
374    receipt: &alloy::rpc::types::TransactionReceipt,
375    token_out_addr: &Address,
376    receiver_addr: &Address,
377) -> Option<BigUint> {
378    use alloy::primitives::keccak256;
379
380    // ERC-20: Transfer(address indexed from, address indexed to, uint256 value)
381    let erc20_topic = keccak256(b"Transfer(address,address,uint256)");
382    // ERC-6909: Transfer(address caller, address indexed from, address indexed to,
383    //                    uint256 indexed id, uint256 amount)
384    let erc6909_topic = keccak256(b"Transfer(address,address,address,uint256,uint256)");
385
386    let mut total = BigUint::ZERO;
387    let mut found = false;
388
389    for log in receipt.logs() {
390        if log.address() != *token_out_addr {
391            continue;
392        }
393        let topics = log.topics();
394        if topics.is_empty() {
395            continue;
396        }
397
398        if topics[0] == erc20_topic && topics.len() >= 3 {
399            // topics[2] is the `to` address (padded to 32 bytes); address is in last 20 bytes.
400            let to = Address::from_slice(&topics[2].as_slice()[12..]);
401            if to == *receiver_addr {
402                let data = &log.data().data;
403                if data.len() >= 32 {
404                    let amount = BigUint::from_bytes_be(&data[0..32]);
405                    total += amount;
406                    found = true;
407                }
408            }
409        } else if topics[0] == erc6909_topic && topics.len() >= 3 {
410            // topics[2] is the `to` address.
411            let to = Address::from_slice(&topics[2].as_slice()[12..]);
412            if to == *receiver_addr {
413                // data encodes (address caller, uint256 amount) = 64 bytes; amount at bytes 32..64.
414                let data = &log.data().data;
415                if data.len() >= 64 {
416                    let amount = BigUint::from_bytes_be(&data[32..64]);
417                    total += amount;
418                    found = true;
419                }
420            }
421        }
422    }
423
424    if found {
425        Some(total)
426    } else {
427        None
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use alloy::{
434        primitives::{keccak256, Address, Bytes as AlloyBytes, LogData, B256},
435        rpc::types::{Log, TransactionReceipt},
436    };
437
438    use super::*;
439
440    // -----------------------------------------------------------------------
441    // Helpers
442    // -----------------------------------------------------------------------
443
444    fn make_receipt(logs: Vec<Log>) -> TransactionReceipt {
445        use alloy::{
446            consensus::{Receipt, ReceiptEnvelope, ReceiptWithBloom},
447            primitives::{Bloom, TxHash},
448        };
449
450        TransactionReceipt {
451            inner: ReceiptEnvelope::Eip1559(ReceiptWithBloom {
452                receipt: Receipt {
453                    status: alloy::consensus::Eip658Value::Eip658(true),
454                    cumulative_gas_used: 21_000,
455                    logs,
456                },
457                logs_bloom: Bloom::default(),
458            }),
459            transaction_hash: TxHash::default(),
460            transaction_index: None,
461            block_hash: None,
462            block_number: None,
463            gas_used: 21_000,
464            effective_gas_price: 1,
465            blob_gas_used: None,
466            blob_gas_price: None,
467            from: Address::ZERO,
468            to: None,
469            contract_address: None,
470        }
471    }
472
473    fn erc20_topic() -> B256 {
474        keccak256(b"Transfer(address,address,uint256)")
475    }
476
477    fn erc6909_topic() -> B256 {
478        keccak256(b"Transfer(address,address,address,uint256,uint256)")
479    }
480
481    /// Pad an address into a 32-byte B256 topic (right-align in 32 bytes).
482    fn addr_topic(addr: Address) -> B256 {
483        let mut topic = [0u8; 32];
484        topic[12..].copy_from_slice(addr.as_slice());
485        B256::from(topic)
486    }
487
488    /// Encode a u64 amount as 32 big-endian bytes.
489    fn encode_u256(amount: u64) -> Vec<u8> {
490        let mut buf = [0u8; 32];
491        let bytes = amount.to_be_bytes();
492        buf[24..].copy_from_slice(&bytes);
493        buf.to_vec()
494    }
495
496    fn make_log(address: Address, topics: Vec<B256>, data: Vec<u8>) -> Log {
497        Log {
498            inner: alloy::primitives::Log {
499                address,
500                data: LogData::new_unchecked(topics, AlloyBytes::from(data)),
501            },
502            block_hash: None,
503            block_number: None,
504            block_timestamp: None,
505            transaction_hash: None,
506            transaction_index: None,
507            log_index: None,
508            removed: false,
509        }
510    }
511
512    // -----------------------------------------------------------------------
513    // ERC-20 tests
514    // -----------------------------------------------------------------------
515
516    #[test]
517    fn erc20_transfer_log_matched() {
518        let token = Address::with_last_byte(0x01);
519        let from = Address::with_last_byte(0x02);
520        let receiver = Address::with_last_byte(0x03);
521
522        let log = make_log(
523            token,
524            vec![erc20_topic(), addr_topic(from), addr_topic(receiver)],
525            encode_u256(500),
526        );
527        let receipt = make_receipt(vec![log]);
528
529        let result = compute_settled_amount(&receipt, &token, &receiver);
530        assert_eq!(result, Some(BigUint::from(500u64)));
531    }
532
533    #[test]
534    fn erc20_transfer_log_wrong_token() {
535        let token = Address::with_last_byte(0x01);
536        let other_token = Address::with_last_byte(0x99);
537        let receiver = Address::with_last_byte(0x03);
538
539        let log = make_log(
540            other_token, // different token address
541            vec![erc20_topic(), addr_topic(Address::ZERO), addr_topic(receiver)],
542            encode_u256(500),
543        );
544        let receipt = make_receipt(vec![log]);
545
546        let result = compute_settled_amount(&receipt, &token, &receiver);
547        assert!(result.is_none());
548    }
549
550    #[test]
551    fn erc20_transfer_log_wrong_receiver() {
552        let token = Address::with_last_byte(0x01);
553        let receiver = Address::with_last_byte(0x03);
554        let other_receiver = Address::with_last_byte(0x04);
555
556        let log = make_log(
557            token,
558            vec![erc20_topic(), addr_topic(Address::ZERO), addr_topic(other_receiver)],
559            encode_u256(500),
560        );
561        let receipt = make_receipt(vec![log]);
562
563        let result = compute_settled_amount(&receipt, &token, &receiver);
564        assert!(result.is_none());
565    }
566
567    // -----------------------------------------------------------------------
568    // ERC-6909 tests
569    // -----------------------------------------------------------------------
570
571    #[test]
572    fn erc6909_transfer_log_matched() {
573        let token = Address::with_last_byte(0x10);
574        let from = Address::with_last_byte(0x11);
575        let receiver = Address::with_last_byte(0x12);
576
577        // ERC-6909: data = (address caller [32 bytes], uint256 amount [32 bytes])
578        let mut data = [0u8; 64];
579        // caller address in first 32 bytes (right-aligned)
580        data[12..32].copy_from_slice(Address::with_last_byte(0xca).as_slice());
581        // amount in last 32 bytes
582        let amount_bytes = encode_u256(750);
583        data[32..].copy_from_slice(&amount_bytes);
584
585        let log = make_log(
586            token,
587            vec![erc6909_topic(), addr_topic(from), addr_topic(receiver)],
588            data.to_vec(),
589        );
590        let receipt = make_receipt(vec![log]);
591
592        let result = compute_settled_amount(&receipt, &token, &receiver);
593        assert_eq!(result, Some(BigUint::from(750u64)));
594    }
595
596    // -----------------------------------------------------------------------
597    // Edge cases
598    // -----------------------------------------------------------------------
599
600    #[test]
601    fn no_matching_logs_returns_none() {
602        let token = Address::with_last_byte(0x01);
603        let receiver = Address::with_last_byte(0x03);
604
605        // Log with unrelated topic
606        let unrelated_topic = keccak256(b"Approval(address,address,uint256)");
607        let log = make_log(
608            token,
609            vec![unrelated_topic, addr_topic(Address::ZERO), addr_topic(receiver)],
610            encode_u256(100),
611        );
612        let receipt = make_receipt(vec![log]);
613
614        let result = compute_settled_amount(&receipt, &token, &receiver);
615        assert!(result.is_none());
616    }
617
618    #[test]
619    fn empty_logs_returns_none() {
620        let token = Address::with_last_byte(0x01);
621        let receiver = Address::with_last_byte(0x03);
622        let receipt = make_receipt(vec![]);
623        assert!(compute_settled_amount(&receipt, &token, &receiver).is_none());
624    }
625
626    #[test]
627    fn multiple_matching_logs_amounts_summed() {
628        let token = Address::with_last_byte(0x01);
629        let from = Address::with_last_byte(0x02);
630        let receiver = Address::with_last_byte(0x03);
631
632        let log1 = make_log(
633            token,
634            vec![erc20_topic(), addr_topic(from), addr_topic(receiver)],
635            encode_u256(100),
636        );
637        let log2 = make_log(
638            token,
639            vec![erc20_topic(), addr_topic(from), addr_topic(receiver)],
640            encode_u256(200),
641        );
642        // A log to a different receiver that should NOT be counted.
643        let log3 = make_log(
644            token,
645            vec![erc20_topic(), addr_topic(from), addr_topic(Address::with_last_byte(0xff))],
646            encode_u256(999),
647        );
648        let receipt = make_receipt(vec![log1, log2, log3]);
649
650        let result = compute_settled_amount(&receipt, &token, &receiver);
651        assert_eq!(result, Some(BigUint::from(300u64)));
652    }
653}