diff --git a/crates/itc-oracle/src/exit.rs b/crates/itc-oracle/src/exit.rs index 0528213..9ba6dc7 100644 --- a/crates/itc-oracle/src/exit.rs +++ b/crates/itc-oracle/src/exit.rs @@ -1,14 +1,30 @@ //! Exit flow — aITC burn on L2 → ITC release on L1. //! -//! When a user wants to move aITC back to ITC mainnet: -//! 1. They burn aITC on L2 by calling the exit L2 system address with calldata -//! encoding their ITC L1 recipient address. -//! 2. The L2 exit scanner detects the burn in the executed tx receipts. -//! 3. After EXIT_CONFIRMATIONS L2 blocks (default 1), the exit is finalized. -//! 4. The exit processor builds and broadcasts an ITC L1 release transaction -//! sending the NET ITC (burn amount minus the 5% governance bridge fee — -//! same fee, same rounding, both directions) to the L1 recipient, using -//! the operator's funded ITC key (ITC_BRIDGE_RELEASE_WIF). +//! Where each step actually lives (so this module's job is clear — it does +//! NOT detect burns; it queues and pays them): +//! 1. DETECTION — `itc_node::sequencer::produce_block` watches every executed +//! L2 tx for a successful value transfer to `EXIT_ADDRESS` (0x…dEaD). The +//! calldata is the ASCII bytes of the burner's bech32 ITC L1 address — +//! always set by the Elara bridge UI. Missing/garbage calldata is NOT +//! derived or guessed from the sender's pubkey — the burn is logged and +//! skipped (funds burned, no release queued). On success, the sequencer +//! calls `queue_exit` (below). +//! 2. QUEUE + FEE — `queue_exit` applies the SAME 5% governance fee, SAME +//! env var, SAME ceil rounding as the deposit oracle's `apply_fee`, and +//! persists the full split (gross/fee/net) to `l2_pending_exits`. +//! 3. RELEASE GATE — `process_epoch` (called every L2 block by the +//! sequencer) waits for `EXIT_CONFIRMATIONS` L2 blocks, then pays +//! EXACTLY ONCE per exit: an already-`released` exit is dropped from +//! pending and never re-scanned; an in-flight `releasing` exit (a crash +//! mid-broadcast) is NEVER auto-retried — manual review only. See +//! `release_on_l1` for why every failure path here is guaranteed +//! pre-broadcast (so an Err can never mean "maybe already paid"). +//! 4. PAYMENT — `release_on_l1` pays the net ITC by calling the ITC L1 node +//! wallet's own `sendtoaddress` RPC, which already holds the bridged +//! float at the bech32 bridge address. The node selects UTXOs, does the +//! SegWit signing, adds change, pays the network fee, and broadcasts — +//! no separate release key, no manually funded pool. Dry-run (nothing +//! sent, exit stays safely pending) unless `ITC_BRIDGE_RELEASE_ENABLE=1`. //! //! Economics (round-trip symmetric): //! deposit: lock 1.00 ITC → mint 0.95 aITC (5% governance fee) @@ -16,15 +32,6 @@ //! Fee bps come from ITC_BRIDGE_FEE_BPS (default 500, capped at 1000) — //! the SAME env var and ceil rounding the deposit oracle uses, so the two //! directions can never drift apart. -//! -//! Exit tx encoding (L2 side): -//! Send aITC to EXIT_ADDRESS (0x00...DEAD or a well-known system address) -//! Calldata (20 bytes): the ITC L1 recipient address in ASCII or binary -//! OR: include the ITC L1 address as the first 34 bytes of calldata -//! -//! For v1, the exit scanner watches for txs TO the EXIT_ADDRESS with value > 0. -//! The calldata (if any) is treated as the L1 recipient. If no calldata, -//! the release goes to the ITC L1 address derived from the aITC sender's pubkey. use std::sync::Arc; @@ -311,7 +318,12 @@ mod tests { /// Without the guard, every 5s epoch re-released it until the wallet drained. #[test] fn released_exit_is_never_paid_twice() { - std::env::remove_var("ITC_BRIDGE_RELEASE_WIF"); + // Force dry-run regardless of the ambient shell/CI environment — a + // test must never depend on ITC_BRIDGE_RELEASE_ENABLE happening to be + // unset. (This is the exact env var release_on_l1 checks; the stale + // "ITC_BRIDGE_RELEASE_WIF" this used to clear stopped mattering the + // moment release moved to the node-wallet sendtoaddress path.) + std::env::remove_var("ITC_BRIDGE_RELEASE_ENABLE"); let db = std::sync::Arc::new(nedb_engine::Db::in_memory()); let scanner = ExitScanner::new(std::sync::Arc::clone(&db)); let tx = "0xburn_released"; @@ -331,7 +343,7 @@ mod tests { /// (double-pay risk). They stay put for manual review. #[test] fn releasing_exit_is_not_auto_retried() { - std::env::remove_var("ITC_BRIDGE_RELEASE_WIF"); + std::env::remove_var("ITC_BRIDGE_RELEASE_ENABLE"); // force dry-run — see note above let db = std::sync::Arc::new(nedb_engine::Db::in_memory()); let scanner = ExitScanner::new(std::sync::Arc::clone(&db)); let tx = "0xburn_inflight"; @@ -351,13 +363,13 @@ mod tests { /// intent so the exit stays retryable — never stranded as "releasing". #[test] fn dry_run_release_clears_intent_and_stays_retryable() { - std::env::remove_var("ITC_BRIDGE_RELEASE_WIF"); + std::env::remove_var("ITC_BRIDGE_RELEASE_ENABLE"); // force dry-run — see note above let db = std::sync::Arc::new(nedb_engine::Db::in_memory()); let scanner = ExitScanner::new(std::sync::Arc::clone(&db)); let tx = "0xburn_dryrun"; pending_exit(&db, tx); - scanner.process_epoch(100); // WIF unset → release_on_l1 errors pre-broadcast + scanner.process_epoch(100); // ITC_BRIDGE_RELEASE_ENABLE != "1" → release_on_l1 errors pre-broadcast assert!(db.get("l2_processed_exits", tx).is_none(), "pre-broadcast failure must roll back the intent (not strand 'releasing')");