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}