Skip to main content

tenzro_types/
intent_7683.rs

1//! ERC-7683 Cross-Chain Intents — protocol primitives (Agent-Swarm Spec 4).
2//!
3//! Tenzro speaks both halves of ERC-7683:
4//!
5//! - As **origin settler**: Tenzro is the chain where a `CrossChainOrder` is
6//!   opened. Solvers watch Tenzro for new orders, fill them on the destination,
7//!   and come back to claim repayment.
8//! - As **destination settler**: Tenzro is the chain where a 7683 order is
9//!   filled. After proving fill, the solver claims the user's locked input on
10//!   the origin chain via that chain's settler.
11//!
12//! This module defines the wire types — `CrossChainOrder`, `ResolvedCrossChainOrder`,
13//! `Output`, `FillInstruction`, the Tenzro-specific `TenzroOrderData` payload,
14//! the `ProofRoute` enum (which underlying bridge ferries the fill proof), and
15//! the on-chain `Tenzro7683Order` envelope. Settler precompile bytecode, EIP-712
16//! verification, escrow integration, gossipsub indexing, and bridge-adapter
17//! glue land alongside their respective subsystems and consume these types
18//! verbatim.
19//!
20//! ### CAIP-2 chain-id mapping
21//!
22//! 7683 carries a `uint32` chain ID. Tenzro chains use the constants
23//! [`TENZRO_MAINNET_CHAIN_ID`] / [`TENZRO_TESTNET_CHAIN_ID`] — chosen to not
24//! collide with EVM EIP-155 IDs.
25
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28
29use crate::primitives::{Address, Hash, Timestamp};
30
31/// 7683 chain ID for Tenzro mainnet (`uint32`, encoded "TENZRO" lower bits).
32pub const TENZRO_MAINNET_CHAIN_ID: u32 = 0x10ED20;
33
34/// 7683 chain ID for Tenzro testnet.
35pub const TENZRO_TESTNET_CHAIN_ID: u32 = 0x10ED21;
36
37/// Bridge route a 7683 order designates for ferrying the fill proof from the
38/// destination chain back to the origin settler. Encoded into the order at
39/// open time so solvers see (and can refuse) the route up front.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ProofRoute {
42    LayerZero,
43    Wormhole,
44    DeBridge,
45    Hyperlane,
46}
47
48impl ProofRoute {
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            ProofRoute::LayerZero => "layerzero",
52            ProofRoute::Wormhole => "wormhole",
53            ProofRoute::DeBridge => "debridge",
54            ProofRoute::Hyperlane => "hyperlane",
55        }
56    }
57}
58
59/// A token + amount pair, encoded with chain-agnostic recipient bytes
60/// (12-byte zero-pad + 20-byte EVM addr, 32-byte SVM pubkey, or party ID for
61/// Canton — discriminated by `chain_id`).
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct TokenAmount {
64    /// Token contract address on the chain where this token lives. For
65    /// Tenzro-native TNZO, use the wTNZO EVM pointer (`0x7a4bcb13...`); for
66    /// SPL adapter, the SPL mint; for foreign tokens, their native contract.
67    pub token: Vec<u8>,
68    /// uint256 amount as 32-byte big-endian bytes — preserves precision
69    /// across the 7683 wire format.
70    pub amount: [u8; 32],
71}
72
73/// What the user wants on the destination chain. Mirrors 7683's `Output`
74/// struct.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct Output {
77    pub token: Vec<u8>,
78    pub amount: [u8; 32],
79    /// Chain-discriminated recipient — see `TokenAmount::token` doc.
80    pub recipient: [u8; 32],
81    /// 7683 chain ID of the destination chain (uint32).
82    pub chain_id: u32,
83}
84
85/// What the user wants on the destination chain — the user-side declarative
86/// half of an order.
87pub type TargetOutput = Output;
88
89/// 7683 fill instruction — pairs a destination chain with the calldata the
90/// solver will execute there.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct FillInstruction {
93    /// 7683 destination chain ID.
94    pub destination_chain_id: u32,
95    /// Settler contract on the destination chain (chain-discriminated bytes).
96    pub destination_settler: [u8; 32],
97    /// Calldata the solver passes to `IDestinationSettler.fill`.
98    pub origin_data: Vec<u8>,
99}
100
101/// Tenzro-specific payload carried in `CrossChainOrder.orderData` when Tenzro
102/// is the origin chain. Solvers decode this to know what to fill and which
103/// bridge route to use for the proof return.
104///
105/// Optional [`BridgeFeeHint`] lets the swapper bake a destination-native-fee-
106/// in-TNZO quote into the open order. When set, any bridge in the registered
107/// adapter set can pick up the order: the solver references `bridge_fee_hint.
108/// tnzo_amount_wei` to know what TNZO it's reimbursed in TNZO terms, the
109/// origin settler enforces the quote's `valid_until_ms`, and the destination
110/// settler routes the proof through whichever adapter the solver chose. This
111/// is the SOTA across-protocol pattern (Across V3 hub-and-spoke + Cosmos
112/// ICS-29 escrow fee abstraction): user signs once with one TNZO-denominated
113/// price, the solver picks the bridge based on liquidity.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct TenzroOrderData {
116    pub inputs: Vec<TokenAmount>,
117    pub outputs: Vec<TargetOutput>,
118    pub dest_chain_id: u32,
119    pub dest_recipient: [u8; 32],
120    pub fill_deadline: u32,
121    pub proof_route: ProofRoute,
122    /// Optional destination-bridge-fee hint denominated in TNZO. When `Some`,
123    /// the order is fungible across the 6 supported bridges — any solver can
124    /// pick up the order and quote whichever adapter has cheapest destination
125    /// liquidity, as long as their destination-native-fee is <= the hinted
126    /// TNZO amount.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub bridge_fee_hint: Option<BridgeFeeHint>,
129}
130
131/// Hint embedded in [`TenzroOrderData`] to make a 7683 order fungible across
132/// every registered bridge adapter. The swapper obtains this by calling
133/// `tenzro_quoteBridgeFeeInTnzo` against any of the 6 adapters before
134/// opening the order; the value bounds the solver's destination-native fee
135/// commitment in TNZO.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct BridgeFeeHint {
138    /// Reference quote id from the BridgeFeeOracle. Solvers can re-fetch
139    /// the canonical quote via `tenzro_getBridgeFeeQuote` for audit.
140    pub quote_id_hex: String,
141    /// TNZO ceiling the swapper authorized for the destination-native fee.
142    /// Encoded as decimal string to round-trip through JSON without
143    /// precision loss for u128 values.
144    pub tnzo_amount_wei: String,
145    /// Wall-clock expiry — solvers MUST NOT execute fills referencing this
146    /// hint after expiry. Mirrors the underlying quote's `valid_until_ms`.
147    pub valid_until_ms: u64,
148    /// Suggested adapter the swapper had in mind at quote time. Advisory
149    /// only; the solver may choose a different adapter as long as the
150    /// destination-native fee in TNZO terms stays <= `tnzo_amount_wei`.
151    pub preferred_adapter: String,
152}
153
154/// 7683 `CrossChainOrder` — what the user signs to open an order. Written
155/// here in protocol-native types; EIP-712 encoding happens in the settler
156/// precompile.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct CrossChainOrder {
159    pub settlement_contract: Address,
160    pub swapper: Address,
161    pub nonce: u64,
162    pub origin_chain_id: u32,
163    pub fill_deadline: u32,
164    /// EIP-712 typehash for `order_data` decoding — for Tenzro orders this
165    /// is `keccak256("TenzroOrderData(...)")`.
166    pub order_data_type: Hash,
167    /// Bincode-encoded `TenzroOrderData` (or foreign payload for non-Tenzro
168    /// origins). Opaque at the protocol layer.
169    pub order_data: Vec<u8>,
170}
171
172/// 7683 `GaslessCrossChainOrder` — same shape as `CrossChainOrder`, signed
173/// for relayer submission so the swapper does not pay origin-chain gas.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct GaslessCrossChainOrder {
176    pub settlement_contract: Address,
177    pub swapper: Address,
178    pub nonce: u64,
179    pub origin_chain_id: u32,
180    pub fill_deadline: u32,
181    pub order_data_type: Hash,
182    pub order_data: Vec<u8>,
183}
184
185/// 7683 `ResolvedCrossChainOrder` — what `IOriginSettler.resolve` returns.
186/// Solvers use this to decide whether to take an order.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct ResolvedCrossChainOrder {
189    pub settlement_contract: Address,
190    pub swapper: Address,
191    pub nonce: u64,
192    pub origin_chain_id: u32,
193    pub fill_deadline: u32,
194    /// What the solver spent on the destination chain.
195    pub max_spent: Vec<Output>,
196    /// What the user receives on the origin chain (typically the locked
197    /// inputs released to the solver after fill).
198    pub min_received: Vec<Output>,
199    pub fill_instructions: Vec<FillInstruction>,
200}
201
202/// Lifecycle of a 7683 order tracked on Tenzro.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204pub enum OrderState {
205    /// Open, escrow locked, awaiting fill.
206    Open,
207    /// Solver filled on destination; settler awaiting bridge proof.
208    AwaitingProof,
209    /// Bridge proof verified, escrow released to solver.
210    Settled,
211    /// Deadline passed without fill, swapper refunded.
212    Refunded,
213    /// Swapper Quarantined/Terminated post-open — anyone can force refund.
214    ForceRefundEligible,
215}
216
217impl OrderState {
218    pub fn as_str(&self) -> &'static str {
219        match self {
220            OrderState::Open => "open",
221            OrderState::AwaitingProof => "awaiting_proof",
222            OrderState::Settled => "settled",
223            OrderState::Refunded => "refunded",
224            OrderState::ForceRefundEligible => "force_refund_eligible",
225        }
226    }
227}
228
229/// On-chain envelope tracking a Tenzro-origin 7683 order from open through
230/// settlement or refund. Persisted in `CF_SETTLEMENTS` under the
231/// [`ORDER_KEY_PREFIX`] keyspace, keyed by `order_id`.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct Tenzro7683Order {
234    pub order_id: Hash,
235    pub state: OrderState,
236    pub resolved: ResolvedCrossChainOrder,
237    /// Escrow id from `tenzro-settlement::EscrowManager` holding the locked
238    /// inputs. Used to release/refund.
239    pub escrow_id: Hash,
240    pub opened_at: Timestamp,
241    pub fill_deadline: u32,
242    /// Filler that submitted the destination-side fill, if any. Settled once
243    /// the bridge proof verifies.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub filler: Option<Address>,
246    /// Hash of the bridge proof that ferried the fill back from destination.
247    /// Set when state transitions to `Settled`.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub fill_proof_hash: Option<Hash>,
250    pub proof_route: ProofRoute,
251}
252
253/// Destination-side fill record for an ERC-7683 order. Persisted in
254/// `CF_SETTLEMENTS` under the [`FILL_KEY_PREFIX`] keyspace, keyed by
255/// `order_id`. Existence of this record is the idempotency guard — the
256/// destination settler refuses to process a second `fill(originData)` whose
257/// decoded `orderId` already maps to a `FillRecord`.
258///
259/// The record captures everything a downstream observer (origin-chain
260/// settler, indexer, dispute resolver) needs to verify that the fill
261/// happened on this Tenzro replica:
262///
263/// - `order_id` — the canonical 7683 order id this fill discharges.
264/// - `origin_chain_id` / `origin_settler` — where the order was opened (for
265///   reverse-route addressing when the bridge ferries fill proof back).
266/// - `filler` — Tenzro address of the solver who filled.
267/// - `recipient` — chain-discriminated recipient bytes32 the solver paid
268///   (mirrors the `Output.recipient` of the resolved order).
269/// - `outputs` — exact `Output` set the solver delivered (must equal the
270///   resolved order's `min_received` per spec).
271/// - `fill_tx_hash` — hash of the Tenzro transaction in which the fill
272///   transfers happened, so the bridge can attest to a specific block.
273/// - `filled_at` — wall-clock timestamp when the registry recorded the fill.
274/// - `proof_route` — bridge route the order designated for ferrying proof
275///   back to origin (snapshot at fill-time so an audit doesn't have to
276///   re-read the open envelope).
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct FillRecord {
279    pub order_id: Hash,
280    pub origin_chain_id: u32,
281    pub origin_settler: Address,
282    pub filler: Address,
283    pub recipient: [u8; 32],
284    pub outputs: Vec<Output>,
285    pub fill_tx_hash: Hash,
286    pub filled_at: Timestamp,
287    pub proof_route: ProofRoute,
288}
289
290/// `CF_SETTLEMENTS` key prefix for Tenzro-origin 7683 orders.
291pub const ORDER_KEY_PREFIX: &[u8] = b"7683_origin:";
292
293/// `CF_SETTLEMENTS` key prefix for destination-side fill records (the
294/// idempotency guard — same `order_id` cannot be filled twice).
295pub const FILL_KEY_PREFIX: &[u8] = b"7683_dest:";
296
297/// Build the `CF_SETTLEMENTS` storage key for a destination-side fill
298/// record. The key is `7683_dest:` followed by the raw 32-byte order id —
299/// keeps key size constant and lookup an O(1) point read.
300pub fn fill_storage_key(order_id: &Hash) -> Vec<u8> {
301    let mut key = Vec::with_capacity(FILL_KEY_PREFIX.len() + 32);
302    key.extend_from_slice(FILL_KEY_PREFIX);
303    key.extend_from_slice(order_id.as_bytes());
304    key
305}
306
307/// Build the `CF_SETTLEMENTS` storage key for a Tenzro-origin 7683 order
308/// envelope. Mirrors [`fill_storage_key`] for the open side. Defined here
309/// so callers don't have to reconstruct the layout in two crates.
310pub fn order_storage_key(order_id: &Hash) -> Vec<u8> {
311    let mut key = Vec::with_capacity(ORDER_KEY_PREFIX.len() + 32);
312    key.extend_from_slice(ORDER_KEY_PREFIX);
313    key.extend_from_slice(order_id.as_bytes());
314    key
315}
316
317/// Compute the canonical `order_id` for a `CrossChainOrder`.
318///
319/// `order_id = SHA-256("tenzro/7683/order" || swapper || nonce_le ||
320/// origin_chain_id_le || fill_deadline_le || order_data_type || order_data)`.
321///
322/// Deterministic — same order produces the same id regardless of who computes
323/// it (settler, solver, indexer).
324pub fn compute_order_id(order: &CrossChainOrder) -> Hash {
325    let mut hasher = Sha256::new();
326    hasher.update(b"tenzro/7683/order");
327    hasher.update(order.swapper.as_bytes());
328    hasher.update(order.nonce.to_le_bytes());
329    hasher.update(order.origin_chain_id.to_le_bytes());
330    hasher.update(order.fill_deadline.to_le_bytes());
331    hasher.update(order.order_data_type.as_bytes());
332    hasher.update(&order.order_data);
333    let result = hasher.finalize();
334    let mut bytes = [0u8; 32];
335    bytes.copy_from_slice(&result);
336    Hash::new(bytes)
337}
338
339/// Wrap a u128 into the 32-byte big-endian uint256 layout expected by 7683
340/// `Output.amount` / `TokenAmount.amount`.
341pub fn u128_to_uint256_be(value: u128) -> [u8; 32] {
342    let mut bytes = [0u8; 32];
343    bytes[16..].copy_from_slice(&value.to_be_bytes());
344    bytes
345}
346
347/// Read the low 128 bits out of a 32-byte big-endian uint256. Returns `None`
348/// if the high 128 bits are non-zero — guards against silent truncation when
349/// downstream code (e.g. settlement) only handles u128.
350pub fn uint256_be_to_u128(bytes: &[u8; 32]) -> Option<u128> {
351    if bytes[..16].iter().any(|&b| b != 0) {
352        return None;
353    }
354    let mut low = [0u8; 16];
355    low.copy_from_slice(&bytes[16..]);
356    Some(u128::from_be_bytes(low))
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    fn sample_order() -> CrossChainOrder {
364        CrossChainOrder {
365            settlement_contract: Address::new([1u8; 32]),
366            swapper: Address::new([2u8; 32]),
367            nonce: 42,
368            origin_chain_id: TENZRO_TESTNET_CHAIN_ID,
369            fill_deadline: 1_700_001_000,
370            order_data_type: Hash::new([3u8; 32]),
371            order_data: b"opaque-payload".to_vec(),
372        }
373    }
374
375    #[test]
376    fn chain_ids_match_spec() {
377        assert_eq!(TENZRO_MAINNET_CHAIN_ID, 0x10ED20);
378        assert_eq!(TENZRO_TESTNET_CHAIN_ID, 0x10ED21);
379    }
380
381    #[test]
382    fn compute_order_id_is_deterministic() {
383        let order = sample_order();
384        assert_eq!(compute_order_id(&order), compute_order_id(&order));
385    }
386
387    #[test]
388    fn compute_order_id_changes_on_field_change() {
389        let order = sample_order();
390        let baseline = compute_order_id(&order);
391
392        let mut nonce_changed = order.clone();
393        nonce_changed.nonce = 43;
394        assert_ne!(compute_order_id(&nonce_changed), baseline);
395
396        let mut deadline_changed = order.clone();
397        deadline_changed.fill_deadline = 1_700_001_001;
398        assert_ne!(compute_order_id(&deadline_changed), baseline);
399
400        let mut data_changed = order.clone();
401        data_changed.order_data = b"different-payload".to_vec();
402        assert_ne!(compute_order_id(&data_changed), baseline);
403    }
404
405    #[test]
406    fn proof_route_str() {
407        assert_eq!(ProofRoute::LayerZero.as_str(), "layerzero");
408        assert_eq!(ProofRoute::Wormhole.as_str(), "wormhole");
409        assert_eq!(ProofRoute::DeBridge.as_str(), "debridge");
410        assert_eq!(ProofRoute::Hyperlane.as_str(), "hyperlane");
411    }
412
413    #[test]
414    fn order_state_str() {
415        assert_eq!(OrderState::Open.as_str(), "open");
416        assert_eq!(OrderState::AwaitingProof.as_str(), "awaiting_proof");
417        assert_eq!(OrderState::Settled.as_str(), "settled");
418        assert_eq!(OrderState::Refunded.as_str(), "refunded");
419        assert_eq!(
420            OrderState::ForceRefundEligible.as_str(),
421            "force_refund_eligible"
422        );
423    }
424
425    #[test]
426    fn uint256_round_trip() {
427        for v in [0u128, 1, 12345, u128::MAX] {
428            let bytes = u128_to_uint256_be(v);
429            assert_eq!(uint256_be_to_u128(&bytes), Some(v));
430        }
431    }
432
433    #[test]
434    fn uint256_high_bits_rejected() {
435        let mut bytes = [0u8; 32];
436        bytes[0] = 1;
437        assert_eq!(uint256_be_to_u128(&bytes), None);
438    }
439
440    #[test]
441    fn key_prefixes_are_distinct() {
442        assert_ne!(ORDER_KEY_PREFIX, FILL_KEY_PREFIX);
443    }
444
445    #[test]
446    fn order_envelope_round_trips_through_serde() {
447        let resolved = ResolvedCrossChainOrder {
448            settlement_contract: Address::new([1u8; 32]),
449            swapper: Address::new([2u8; 32]),
450            nonce: 7,
451            origin_chain_id: TENZRO_MAINNET_CHAIN_ID,
452            fill_deadline: 1_700_002_000,
453            max_spent: vec![Output {
454                token: vec![0xaa; 20],
455                amount: u128_to_uint256_be(1_000_000),
456                recipient: [0xbb; 32],
457                chain_id: 8453, // base
458            }],
459            min_received: vec![],
460            fill_instructions: vec![FillInstruction {
461                destination_chain_id: 8453,
462                destination_settler: [0xcc; 32],
463                origin_data: vec![0xdd, 0xee],
464            }],
465        };
466        let envelope = Tenzro7683Order {
467            order_id: Hash::new([9u8; 32]),
468            state: OrderState::Open,
469            resolved,
470            escrow_id: Hash::new([0xee; 32]),
471            opened_at: Timestamp::new(1_700_000_000),
472            fill_deadline: 1_700_002_000,
473            filler: None,
474            fill_proof_hash: None,
475            proof_route: ProofRoute::LayerZero,
476        };
477        let bytes = serde_json::to_vec(&envelope).unwrap();
478        let decoded: Tenzro7683Order = serde_json::from_slice(&bytes).unwrap();
479        assert_eq!(decoded, envelope);
480    }
481
482    #[test]
483    fn order_data_bridge_fee_hint_round_trips() {
484        let order = TenzroOrderData {
485            inputs: vec![],
486            outputs: vec![],
487            dest_chain_id: 8453,
488            dest_recipient: [0u8; 32],
489            fill_deadline: 1_700_002_000,
490            proof_route: ProofRoute::LayerZero,
491            bridge_fee_hint: Some(BridgeFeeHint {
492                quote_id_hex: "0xabcd".to_string(),
493                tnzo_amount_wei: "5100000".to_string(),
494                valid_until_ms: 1_700_001_000,
495                preferred_adapter: "layerzero".to_string(),
496            }),
497        };
498        let bytes = serde_json::to_vec(&order).unwrap();
499        let decoded: TenzroOrderData = serde_json::from_slice(&bytes).unwrap();
500        assert_eq!(decoded, order);
501        assert!(decoded.bridge_fee_hint.is_some());
502    }
503
504    #[test]
505    fn order_data_without_fee_hint_is_backward_compat() {
506        // Simulate an "old" payload that doesn't include `bridge_fee_hint`.
507        let bytes = br#"{
508            "inputs": [],
509            "outputs": [],
510            "dest_chain_id": 8453,
511            "dest_recipient": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
512            "fill_deadline": 1700002000,
513            "proof_route": "LayerZero"
514        }"#;
515        let decoded: TenzroOrderData = serde_json::from_slice(bytes).unwrap();
516        assert!(decoded.bridge_fee_hint.is_none());
517    }
518}