Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 34 additions & 22 deletions crates/itc-oracle/src/exit.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,37 @@
//! 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)
//! exit: burn 1.00 aITC → release 0.95 ITC (5% governance fee)
//! 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;

Expand Down Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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')");
Expand Down