From 0214359448fdc9d72017f21ad37c8df189747f82 Mon Sep 17 00:00:00 2001 From: "Vex (Hyperagent)" Date: Fri, 3 Jul 2026 13:06:04 -0400 Subject: [PATCH] docs+test(exit): fix stale docs + a real test-hygiene gap post-sendtoaddress pivot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module doc still described the SUPERSEDED design (a WIF-funded legacy release key, calldata format that was never implemented, detection framed as living in this file). Rewrote it to describe what actually ships: detection lives in itc-node::sequencer::produce_block (not here), calldata is always the ASCII bech32 recipient with no pubkey-derived fallback, and release pays via the L1 NODE WALLET's sendtoaddress against the bridged float (no separate key/pool) — the actual chain proven live tonight (7 real L1 payouts, mainnet). More than cosmetic: the three exactly-once guard tests called std::env::remove_var("ITC_BRIDGE_RELEASE_WIF") to force dry-run — a variable release_on_l1 has not read since the sendtoaddress pivot (PR #81). That left the tests silently trusting that ITC_BRIDGE_RELEASE_ENABLE happens to be unset in whatever shell or CI runs the test suite. Given tonight's live debugging was exactly an exported env var not landing where expected, a test suite quietly depending on an env var NOT leaking in is the same class of fragility, just inverted — fixed to explicitly clear the variable the code actually checks, so the tests are deterministic regardless of the ambient environment. No behavior change to the running node. Pure documentation accuracy and test determinism. Co-Authored-By: Claude Fable 5 --- crates/itc-oracle/src/exit.rs | 56 +++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 22 deletions(-) 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')");