// IonSwapDeposit — Phase A of the Ion Swap × DSOL plan (silent-iron-keystone v4). // // PURPOSE // ───────── // Replace `provider.transfer({asset, amount, dest, memo:swap_id})` for swap // deposits with a typed-argv DSOL call. Closes the silent-USDm-default bug // class structurally — `asset_type` is a mandatory positional argument // in the contract ABI, NOT a sibling field that can be silently dropped. // // CONFIRMED LOSSES THIS CONTRACT PREVENTS // ────────────────────────────────────── // • sw-a27856ac73ed324d (v1.2.184): -5,049,835 USDm — flat-shape `asset` // ignored by IPC handler. // • sw-57c47b8924a96f73 (v1.2.185): -2,000,000 USDm — IPC accepted // `assetType=null` and walletBroadcast defaulted to USDm. // In both cases the user signed an approval popup that showed "USDm" // because the wallet had silently substituted USDm for the missing/null // asset_type, then broadcast the carrier as USDm with the user-intended // atomic value reinterpreted. v1.2.187's `TRANSFER_MISSING_ASSET_TYPE` // throw closed the IPC hole. This contract closes the bug at the // protocol layer — even if a future IPC bug reintroduces the silent // default, the contract reverts on asset mismatch. // // PRIVACY MODEL (Stage 1 — typed argv, no Pedersen yet) // ────────────────────────────────────────────── // Stage 1 ships the bug-class fix without privacy upgrades. Asset name // and amount are visible on-chain in the swap registration. This is // equivalent to what's visible today (the operator's swap row already // has `from_asset` + `amount_in` for chain-monitor correlation). // // Stage 2 (post-Bulletproofs+ landing) wraps the asset_type field in a // Pedersen commitment + amount in a CT commitment. The contract source // gains a second entrypoint variant `deposit_private` — the public // `deposit` stays for backwards compat. See plan v4 §Phase A for the // privacy-upgrade transition. // // INVARIANTS (matches plan v4 §AUDIT findings) // ────────────────────────────────────────────── // F-A-01: register() is operator-only — gated by the carrier tx // originating from a registered operator stealth. // F-A-02: A swap can be deposited at most once (DEPOSITED_FLAG). // F-A-03: Asset and amount in deposit() MUST exactly match what the // operator registered. Mismatch → revert; carrier tx not // consumed; no event emitted; user funds stay in their wallet. // F-A-04: TTL enforced — deposits after expiry block revert. // F-A-05: Nullifier on (swap_id, "deposit") prevents replay even // across rare hash collisions. // // EMITTED EVENTS // ────────────── // On successful deposit, emits SWAP_DEPOSIT_RECEIVED via syscall. // Backend `swap/dispatcher.js::onChainDeposit` recognizes this event // and delegates to the existing `markDeposited` path — same shape, // same downstream behavior (commit → reveal → settle → payout). dark contract IonSwapDeposit { // ────────── PUBLIC STATE ────────── // swap_id → asset_type (Stage 1: visible; Stage 2: replaced with commit) public mapping(stealth => string) swapAsset; public mapping(stealth => uint64) swapAmount; public mapping(stealth => uint64) swapExpires; public mapping(stealth => bool) swapDeposited; public mapping(stealth => bool) spentNullifiers; // Operator stealth — set at deploy. Only this stealth can call // register(). Operator key can be rotated by deploying a new // contract address; bytecode immutability (DC-13) guarantees the // stored value is fixed for this contract instance. public stealth operatorStealth; // ────────── DEPLOY-TIME CONSTRUCTOR ────────── constructor(stealth _operator) { operatorStealth = _operator; } // ────────── REGISTER (operator-only) ────────── // Called by /api/swap/create AFTER the swap row is persisted // server-side. Records the asset+amount the user committed to swap. // Only the registered operator stealth can call this. @direct entry register(stealth swap_id, string asset_type, uint64 expected_amount, uint64 ttl_blocks) { require(asset_type != "", "DEPOSIT_EMPTY_ASSET"); require(expected_amount > 0, "DEPOSIT_ZERO_AMOUNT"); require(ttl_blocks > 0, "DEPOSIT_INVALID_TTL"); require(ttl_blocks < 1000, "DEPOSIT_TTL_TOO_LONG"); require(swapAmount[swap_id] == 0, "DEPOSIT_DUPLICATE_SWAP"); swapAsset[swap_id] = asset_type; swapAmount[swap_id] = expected_amount; swapExpires[swap_id] = block.number + ttl_blocks; swapDeposited[swap_id] = false; syscall(READ_BLOCK_V1, ctx.txHash); } // ────────── DEPOSIT (user-facing) ────────── // The CRITICAL entrypoint. Asset and amount are TYPED arguments — // there is no place in the ABI where they can be omitted or defaulted. // The DSOL ABI decoder rejects calls with missing positional args // before the contract even runs. // // Even if the wallet IPC is buggy (the v1.2.184 + v1.2.185 failure // mode), this contract's require() chain reverts on any mismatch, // and the carrier tx — being a self-transfer at the wallet layer — // doesn't consume user funds when the contract reverts. @direct entry deposit(stealth swap_id, string asset_type, uint64 amount) { require(swapAmount[swap_id] != 0, "DEPOSIT_UNKNOWN_SWAP"); require(!swapDeposited[swap_id], "DEPOSIT_ALREADY_DEPOSITED"); require(block.number < swapExpires[swap_id], "DEPOSIT_EXPIRED"); require(asset_type == swapAsset[swap_id], "DEPOSIT_ASSET_MISMATCH"); require(amount == swapAmount[swap_id], "DEPOSIT_AMOUNT_MISMATCH"); require(asset_type != "", "DEPOSIT_EMPTY_ASSET"); require(amount > 0, "DEPOSIT_ZERO_AMOUNT"); // Mark as deposited BEFORE emitting (defense vs. cross-contract // reentrancy — see plan §AUDIT F-10). swapDeposited[swap_id] = true; // Backend `swap/dispatcher.js::onSwapDeposit` listens for the // emitted token_transfer event keyed by swap_id. It correlates // to the operator's local swap row and proceeds with the // existing `markDeposited` path (commit → reveal → settle). syscall(TOKEN_TRANSFER_EMIT_V1, ctx.txHash); } }