diff --git a/contracts/sysio.epoch/CMakeLists.txt b/contracts/sysio.epoch/CMakeLists.txt index 533340f93c..7ffb82c8eb 100644 --- a/contracts/sysio.epoch/CMakeLists.txt +++ b/contracts/sysio.epoch/CMakeLists.txt @@ -36,6 +36,7 @@ if(BUILD_SYSTEM_CONTRACTS) $ $ $ + $ $ $ ) diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 56d3a3871a..f51a3c4554 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -4,6 +4,9 @@ #include #include #include +// For uwrit::MAX_UWREQ_PRUNE_PER_EPOCH — the per-epoch budget advance hands +// to the inline `pruneuwreqs` sweep (the constant is owned by sysio.uwrit). +#include // Canonical sysio.system emissions types + compute_epoch_emission. The // [[sysio::contract("sysio.system")]] attribute on emission_config / t5_state // pins them to sysio.system's ABI; no readonly mirror needed here. @@ -394,6 +397,20 @@ void epoch::advance() { std::make_tuple() ).send(); + // Bounded UWREQ lifecycle sweep (SEC-129 / WSA-223): erase terminal + // uwreqs whose retention window elapsed; expire + refund PENDING uwreqs + // whose race never resolved inside the pending timeout. Runs after + // chklocks so both uwrit maintenance sweeps stay adjacent, and before + // buildenv so any SWAP_REVERT the expiry path emits rides THIS epoch's + // outbound envelopes. Budget-bounded (never throws) — a backlog simply + // drains across subsequent epochs. + action( + permission_level{get_self(), "owner"_n}, + UWRIT_ACCOUNT, + "pruneuwreqs"_n, + std::make_tuple(uwrit::MAX_UWREQ_PRUNE_PER_EPOCH) + ).send(); + // Before incrementing: evaluate per-op delivery state for the EXPIRING // epoch. The active group of the expiring epoch (`current_batch_op_group` // BEFORE the increment) is the set of ops responsible for delivering diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 3d66b1ac22..5cfbfc42a4 100755 Binary files a/contracts/sysio.epoch/sysio.epoch.wasm and b/contracts/sysio.epoch/sysio.epoch.wasm differ diff --git a/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp b/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp index 0e35f815ae..c0105e280b 100644 --- a/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp +++ b/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp @@ -74,10 +74,79 @@ namespace sysio { static constexpr name RESERVE_ACCOUNT = "sysio.reserv"_n; static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; - // Number of epochs an UWREQ row lives after settlement / abort. 10 epochs - // matches the bootstrap doc's "losers retained 10 epochs for debugging" - // requirement; covers SETTLED, REVERTED, EXPIRED uwreqs alike. - static constexpr uint32_t UWREQ_RETENTION_EPOCHS = 10; + // Default number of epochs a TERMINAL (COMPLETED / REJECTED / EXPIRED) + // UWREQ row lives before `pruneuwreqs` erases it. 10 epochs matches the + // bootstrap doc's "losers retained 10 epochs for debugging" requirement. + // Live value is `uwconfig.uwreq_retention_epochs` (retunable via + // `setconfig`); this constant is only the fresh-chain default. + static constexpr uint32_t DEFAULT_UWREQ_RETENTION_EPOCHS = 10; + + // Default number of epochs a PENDING UWREQ may wait for its underwriter + // race to resolve before `pruneuwreqs` expires it (refund/revert + flip + // to UNDERWRITE_REQUEST_STATUS_EXPIRED). A pending request only resolves + // if underwriter commits arrive — an external, optional event — so + // without this deadline an abandoned request is indistinguishable from a + // slow one forever and its row (plus the user's escrowed/deposited + // funds) lingers indefinitely (SEC-129 / WSA-223). Live value is + // `uwconfig.uwreq_pending_timeout_epochs` (retunable via `setconfig`). + static constexpr uint32_t DEFAULT_UWREQ_PENDING_TIMEOUT_EPOCHS = 10; + + // Ceiling `setconfig` accepts for BOTH epoch-count lifecycle knobs + // (pending timeout + retention). Prevents a misconfigured value near + // UINT32_MAX from wrapping the `current_epoch + knob` deadline stamp to + // a tiny epoch index (instant expiry/prune). One million epochs is ~2 + // years at the 60s test cadence and far beyond any real retention need, + // while `current_epoch + 1'000'000` stays astronomically below the + // uint32 wrap point for any reachable epoch index. + static constexpr uint32_t MAX_UWREQ_LIFECYCLE_EPOCHS = 1'000'000; + + // Rows `sysio.epoch::advance` passes to `pruneuwreqs` each epoch. Sized + // like MAX_FWQ_DRAIN_PER_EPOCH (and for the same reason): the sweep runs + // inline inside advance's hard, uncatchable transaction CPU deadline, so + // per-epoch work must be bounded; a backlog simply drains across + // subsequent epochs. + static constexpr uint32_t MAX_UWREQ_PRUNE_PER_EPOCH = 32; + + // ── UWREQ row-growth rails (SEC-129 / WSA-223) ───────────────────────── + // Every uwreqs `modify` re-serializes the whole row inside the + // never-throw evalcons / advance dispatch surfaces, so per-field byte + // caps + a total-row guard keep the serialized row from approaching + // database value limits (where a later consensus-dispatched modify + // would abort and stall the chain). All are protocol backstops sized + // generously above legitimate traffic — violations log + skip, never + // check(). + + // Per-candidate cap on `uw_request_t.commits_by`. rcrdcommit already + // gates entries on ACTIVE underwriters; this bounds the row even if the + // ACTIVE roster grows large. + static constexpr uint32_t MAX_UWREQ_CANDIDATES = 32; + + // Per-leg cap on a stored verbatim `UnderwriteIntentCommit` payload + // (`commit_entry.source_uic_bytes` / `dest_uic_bytes`). A real UIC is a + // handful of ids + codes + a signature `verify_uic_signature` already + // bounds to 1024 bytes — 2 KiB is ~2x the largest legitimate encoding. + static constexpr uint32_t MAX_UIC_LEG_BYTES = 2048; + + // Cap on the stored inbound SwapRequest payload + // (`uw_request_t.attestation_inbound_data`) at createuwreq. + static constexpr uint32_t MAX_ATTESTATION_INBOUND_DATA_BYTES = 4096; + + // Cap on `SwapRequest.source_tx_id` (largest real id today is a 64-byte + // Solana signature; 2x headroom for future chains). + static constexpr uint32_t MAX_SOURCE_TX_ID_BYTES = 128; + + // Cap on the depositor address bytes (`SwapRequest.actor.address`). + // Mirrors swapfromwire's existing 64-byte `recipient_addr` cap — the + // platform's notion of a maximum chain address. + static constexpr uint32_t MAX_DEPOSITOR_BYTES = 64; + + // Belt-and-braces ceiling on the projected serialized uwreqs row before + // any growing `modify`. Structural worst case under the caps above + // (32 candidates x 2 x 2 KiB legs + 4 KiB attestation + fixed fields) + // is ~135 KiB, comfortably inside this guard; the guard exists so no + // future field addition can silently push a row toward chain KV value + // limits. + static constexpr uint32_t MAX_UWREQ_ROW_BYTES = 262'144; // Safety rails on the depot-originated swap-from-WIRE queue (SEC-77 / // WSA-165). `swapfromwire` is public and escrows only the caller's WIRE, @@ -167,11 +236,19 @@ namespace sysio { /// queued from-WIRE swap reverts at drain for a caller-controlled /// cause (see `drainfwq`); routed exactly like the settlement fee. /// Capped at MAX_FEE_BPS so the post-fee refund stays positive. + /// * `uwreq_pending_timeout_epochs` — epochs a PENDING uwreq may wait + /// for its race to resolve before `pruneuwreqs` expires it + /// (refund/revert + EXPIRED). 1..MAX_UWREQ_LIFECYCLE_EPOCHS. + /// * `uwreq_retention_epochs` — epochs a terminal (COMPLETED / + /// REJECTED / EXPIRED) uwreq row is retained for audit before + /// `pruneuwreqs` erases it. 1..MAX_UWREQ_LIFECYCLE_EPOCHS. [[sysio::action]] void setconfig(uint32_t fee_bps, uint64_t collateral_lock_duration_ms, uint64_t min_fromwire_amount, - uint32_t fromwire_revert_fee_bps); + uint32_t fromwire_revert_fee_bps, + uint32_t uwreq_pending_timeout_epochs, + uint32_t uwreq_retention_epochs); /// Called inline from `sysio.msgch::dispatch` when a SWAP attestation /// arrives. Decodes the SwapRequest, runs the variance-tolerance check @@ -234,6 +311,32 @@ namespace sysio { [[sysio::action]] void chklocks(); + /// Bounded UWREQ lifecycle sweep (SEC-129 / WSA-223). Inlined from + /// `sysio.epoch::advance` each epoch with `MAX_UWREQ_PRUNE_PER_EPOCH`; + /// also invocable by `sysio.uwrit` itself with a caller-chosen budget + /// for manual backlog drains. Walks the `byexpire` index over rows whose + /// `expires_at_epoch` is non-zero and has elapsed, handling at most + /// `max_rows` rows (`max_rows == 0` is a no-op): + /// + /// * PENDING past its deadline — the underwriter race never resolved + /// inside `uwconfig.uwreq_pending_timeout_epochs`. Refund the source + /// side (SWAP_REVERT to the source outpost, or a full `refundwire` + /// of the from-WIRE escrow), flip the row to EXPIRED, clear its + /// heavy payloads, and re-stamp `expires_at_epoch` for the terminal + /// retention window. + /// * COMPLETED / REJECTED / EXPIRED past retention — erase the row. + /// * CONFIRMED never appears: winner selection zeroes the deadline + /// (the wall-clock lock window owns the row until `chklocks` + /// terminalizes it). + /// + /// NEVER throws past the auth gate — it runs inline inside `advance`, + /// where an abort stalls epoch progress chain-wide. There are no timers + /// anywhere in this lifecycle: `expires_at_epoch` is passive row data, + /// and this action — a trigger fired by the epoch machinery — is the + /// only thing that ever evaluates it. + [[sysio::action]] + void pruneuwreqs(uint32_t max_rows); + /// Swap FROM WIRE (the depot is the source chain). `user` escrows /// `wire_amount` REAL WIRE into `sysio.reserv` custody NOW and the /// request is QUEUED — no uwreq is created in this transaction. The @@ -473,8 +576,16 @@ namespace sysio { name winner; uint64_t committed_at_ms = 0; uint64_t settled_at_ms = 0; - /// Epoch after which this row is eligible for prune (kept for - /// debugging; see `UWREQ_RETENTION_EPOCHS`). + /// The row's lifecycle deadline, evaluated (lazily, at trigger time) + /// by `pruneuwreqs` via the `byexpire` index. Semantics by status: + /// * PENDING — `creation_epoch + uwconfig.uwreq_pending_timeout_epochs`; + /// past it the race is abandoned (refund + EXPIRED). + /// * CONFIRMED — 0 (no deadline): the wall-clock lock window owns + /// the row; `chklocks` always terminalizes it. + /// * terminal — `terminal_epoch + uwconfig.uwreq_retention_epochs`; + /// past it the row is erased. + /// 0 means "no deadline, never swept" (same zero-sentinel predicate + /// as `sysio.dclaim::flushexpired`). uint32_t expires_at_epoch = 0; /// Inbound attestation payload (zpp_bits-encoded protobuf). @@ -487,6 +598,7 @@ namespace sysio { uint64_t by_status() const { return magic_enum::enum_integer(status); } uint64_t by_winner() const { return winner.value; } + uint64_t by_expires_at_epoch() const { return expires_at_epoch; } SYSLIB_SERIALIZE(uw_request_t, (id)(type)(status) @@ -501,7 +613,9 @@ namespace sysio { sysio::kv::index<"bystatus"_n, sysio::const_mem_fun>, sysio::kv::index<"bywinner"_n, - sysio::const_mem_fun> + sysio::const_mem_fun>, + sysio::kv::index<"byexpire"_n, + sysio::const_mem_fun> >; /// Singleton holding the next-issued `lock_id` + the depot-origin @@ -575,8 +689,17 @@ namespace sysio { /// Fee (bps of the escrow) on caller-controlled drain-time reverts, /// routed like the settlement fee. <= MAX_FEE_BPS. uint32_t fromwire_revert_fee_bps = DEFAULT_FROMWIRE_REVERT_FEE_BPS; + /// Epochs a PENDING uwreq may wait for its underwriter race before + /// `pruneuwreqs` expires it (refund/revert + EXPIRED). The deadline + /// is stamped per row at creation; retuning is not retroactive. + uint32_t uwreq_pending_timeout_epochs = DEFAULT_UWREQ_PENDING_TIMEOUT_EPOCHS; + /// Epochs a terminal (COMPLETED / REJECTED / EXPIRED) uwreq row is + /// retained for audit before `pruneuwreqs` erases it. Stamped per + /// row at the terminal transition; retuning is not retroactive. + uint32_t uwreq_retention_epochs = DEFAULT_UWREQ_RETENTION_EPOCHS; SYSLIB_SERIALIZE(uw_config, (fee_bps)(collateral_lock_duration_ms) - (min_fromwire_amount)(fromwire_revert_fee_bps)) + (min_fromwire_amount)(fromwire_revert_fee_bps) + (uwreq_pending_timeout_epochs)(uwreq_retention_epochs)) }; using uwconfig_t = sysio::kv::global<"uwconfig"_n, uw_config>; diff --git a/contracts/sysio.uwrit/src/sysio.uwrit.cpp b/contracts/sysio.uwrit/src/sysio.uwrit.cpp index 54a6d8d836..359373b7ef 100644 --- a/contracts/sysio.uwrit/src/sysio.uwrit.cpp +++ b/contracts/sysio.uwrit/src/sysio.uwrit.cpp @@ -57,6 +57,13 @@ constexpr sysio::symbol WIRE_SYMBOL{"WIRE", 9}; /// from 1 and can never reach 2^63, so the two id spaces are disjoint. constexpr uint64_t DEPOT_ORIGIN_ID_BASE = 0x8000000000000000ULL; +/// Fixed serialized-size allowance for a NEW `commit_entry`'s non-vector +/// fields (underwriter name, two timestamp/outpost-id pairs, status, empty +/// reason + the vectors' length prefixes) in rcrdcommit's projected-row-size +/// guard. The true packed size is ~50 bytes; 128 over-counts on purpose — +/// the guard may only ever be tighter than reality, never looser. +constexpr size_t COMMIT_ENTRY_PACK_ALLOWANCE_BYTES = 128; + uint64_t current_time_ms() { return static_cast(current_time_point().sec_since_epoch()) * 1000; } @@ -678,7 +685,9 @@ uint64_t next_fromwire_id(name self) { void uwrit::setconfig(uint32_t fee_bps, uint64_t collateral_lock_duration_ms, uint64_t min_fromwire_amount, - uint32_t fromwire_revert_fee_bps) { + uint32_t fromwire_revert_fee_bps, + uint32_t uwreq_pending_timeout_epochs, + uint32_t uwreq_retention_epochs) { require_auth(get_self()); // Reject a 100% (or higher) fee: it zeroes the post-fee WIRE leg // (`net == 0`), which let a swap debit destination reserve liquidity while @@ -701,13 +710,28 @@ void uwrit::setconfig(uint32_t fee_bps, // the never-throw drainfwq drain (`refundwire` rejects zero transfers). check(fromwire_revert_fee_bps <= MAX_FEE_BPS, "fromwire_revert_fee_bps must be below 10000 (100%): a 100% revert fee zeroes the refund"); + // A zero pending timeout would expire every uwreq the epoch it is created — + // no race could ever resolve; a zero retention would erase terminal rows + // before the audit window they exist for. The shared ceiling prevents a + // near-UINT32_MAX knob from wrapping the `current_epoch + knob` deadline + // stamp to a tiny epoch index (instant expiry). + check(uwreq_pending_timeout_epochs > 0, + "uwreq_pending_timeout_epochs must be positive"); + check(uwreq_pending_timeout_epochs <= MAX_UWREQ_LIFECYCLE_EPOCHS, + "uwreq_pending_timeout_epochs exceeds the lifecycle ceiling"); + check(uwreq_retention_epochs > 0, + "uwreq_retention_epochs must be positive"); + check(uwreq_retention_epochs <= MAX_UWREQ_LIFECYCLE_EPOCHS, + "uwreq_retention_epochs exceeds the lifecycle ceiling"); uwconfig_t cfg_tbl(get_self()); uw_config cfg = cfg_tbl.get_or_default(uw_config{}); - cfg.fee_bps = fee_bps; - cfg.collateral_lock_duration_ms = collateral_lock_duration_ms; - cfg.min_fromwire_amount = min_fromwire_amount; - cfg.fromwire_revert_fee_bps = fromwire_revert_fee_bps; + cfg.fee_bps = fee_bps; + cfg.collateral_lock_duration_ms = collateral_lock_duration_ms; + cfg.min_fromwire_amount = min_fromwire_amount; + cfg.fromwire_revert_fee_bps = fromwire_revert_fee_bps; + cfg.uwreq_pending_timeout_epochs = uwreq_pending_timeout_epochs; + cfg.uwreq_retention_epochs = uwreq_retention_epochs; cfg_tbl.set(cfg, ram_payer); } @@ -763,6 +787,10 @@ void uwrit::createuwreq(uint64_t attestation_id, } } + // One config snapshot for the whole ingestion: the variance quote and the + // PENDING deadline stamp below must read one consistent config. + const uw_config cfg = read_config(get_self()); + // Pull the slug_name triples + amounts out of the decoded SwapRequest. // The source token's code lives on the TokenAmount; the source chain // and source reserve are top-level fields. Destination has all three @@ -854,6 +882,22 @@ void uwrit::createuwreq(uint64_t attestation_id, return; } + // Row-growth rails (SEC-129 / WSA-223): the row stores these payloads + // verbatim and every later `modify` re-serializes the whole row inside the + // never-throw dispatch surfaces, so oversized inputs are refused at the + // door. All caps sit far above legitimate traffic; a violation is a + // malformed/hostile outpost payload — refund it like every other + // structural rejection, never check(). + if (data.size() > MAX_ATTESTATION_INBOUND_DATA_BYTES || + sr.source_tx_id.size() > MAX_SOURCE_TX_ID_BYTES || + sr.actor.address.size() > MAX_DEPOSITOR_BYTES) { + emit_swap_revert(get_self(), chain_code, attestation_id, sr, + src_chain_code, src_reserve_code, + "SwapRequest rejected: payload exceeds a size cap " + "(attestation data / source_tx_id / depositor address)"); + return; + } + // Privacy gate — a private reserve only swaps against a counterpart // reserve owned by the same WIRE account (the authex-linked matcher // recorded at match time), and is excluded from WIRE-endpoint swaps @@ -898,7 +942,7 @@ void uwrit::createuwreq(uint64_t attestation_id, // matching reserves are present. const uint64_t current_quote = swap_quote(src_chain_code, src_token_code, src_reserve_code, dst_chain_code, dst_token_code, dst_reserve_code, - src_amount, current_fee_bps(get_self())); + src_amount, cfg.fee_bps); if (sr.target_amount != 0) { if (current_quote == 0) { // Zero quote: skip only when a required reserve is genuinely @@ -949,7 +993,12 @@ void uwrit::createuwreq(uint64_t attestation_id, .winner = name{}, .committed_at_ms = 0, .settled_at_ms = 0, - .expires_at_epoch = 0, + // The PENDING deadline (SEC-129 / WSA-223): if no underwriter wins the + // race within this many epochs, `pruneuwreqs` expires the row and + // refunds the source side. No trigger ever announces "no winner is + // coming" — commits are external and optional — so the deadline is what + // converts a later epoch advance into the recovery trigger. + .expires_at_epoch = get_current_epoch() + cfg.uwreq_pending_timeout_epochs, .attestation_inbound_data = std::move(data), .attestation_outbound_data = {}, }); @@ -995,20 +1044,29 @@ void disqualify_candidate(uwrit::uwreqs_t& reqs, const uwrit::id_key& pk, }); } -/// Terminally reject a uwreq that can never settle: refund the source side -/// and mark the row REJECTED, releasing any in-flight commits. **Non-throwing** -/// — safe inside the evalcons dispatch chain (a `check()` here would stall OPP -/// consensus chain-wide). The refund routes by source-leg kind: +/// Terminally close a uwreq that can never settle: refund the source side and +/// mark the row with `terminal_status` (REJECTED for active rejections — the +/// default; EXPIRED when `pruneuwreqs` abandons a PENDING race past its +/// deadline), releasing any in-flight commits. **Non-throwing** — safe inside +/// the evalcons dispatch chain (a `check()` here would stall OPP consensus +/// chain-wide). The refund routes by source-leg kind: /// * outpost source (`src_needed`): best-effort SWAP_REVERT back to the /// source outpost (requires the stored SwapRequest to decode; a depot /// source has no outpost to route to, so `find_outpost_id_for_chain` /// returns nullopt and the revert is skipped). /// * depot source (from-WIRE): refund the escrowed WIRE to the /// depositor via `reserv::refundwire`. +/// The terminal modify also stamps the retention deadline +/// (`uwconfig.uwreq_retention_epochs`) and clears the row's heavy payloads +/// (inbound attestation copy + every stored UIC byte blob) — they were only +/// needed to reach this decision; the compact audit metadata (ids, codes, +/// amounts, statuses, reasons, timestamps) is what retention keeps. void reject_and_refund(name self, uwrit::uwreqs_t& reqs, const uwrit::id_key& pk, const uwrit::uw_request_t& req, bool src_needed, const std::string& revert_reason, - const std::string& commit_reason) { + const std::string& commit_reason, + UnderwriteRequestStatus terminal_status = + UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_REJECTED) { if (src_needed) { opp::attestations::SwapRequest sr; auto in = zpp::bits::in{ @@ -1036,15 +1094,23 @@ void reject_and_refund(name self, uwrit::uwreqs_t& reqs, const uwrit::id_key& pk "refund on uwreq ", req.id, "\n"); } reqs.modify(same_payer, pk, [&](auto& r) { - r.status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_REJECTED; + r.status = terminal_status; r.settled_at_ms = current_time_ms(); - r.expires_at_epoch = get_current_epoch() + uwrit::UWREQ_RETENTION_EPOCHS; + r.expires_at_epoch = get_current_epoch() + read_config(self).uwreq_retention_epochs; for (auto& c : r.commits_by) { if (c.status == UnderwriteStatus::UNDERWRITE_STATUS_INTENT_SUBMITTED) { c.status = UnderwriteStatus::UNDERWRITE_STATUS_RELEASED; c.reason = commit_reason; } + // Terminal compaction (SEC-129 / WSA-223): the raw UIC blobs were + // only needed to resolve the race; retention keeps the compact + // audit metadata, not the payload bytes. + c.source_uic_bytes.clear(); + c.dest_uic_bytes.clear(); } + // The refund above already decoded the snapshot's copy; the stored + // bytes have no remaining reader on a terminal row. + r.attestation_inbound_data.clear(); }); } @@ -1403,6 +1469,11 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate) { r.status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED; r.winner = candidate; r.committed_at_ms = current_time_ms(); + // Deadline cleared (SEC-129 / WSA-223): a CONFIRMED row is owned by the + // wall-clock lock window — `chklocks` is guaranteed to terminalize it + // once the locks expire, so a second (epoch-count) deadline would just + // race the first. 0 excludes the row from the `pruneuwreqs` sweep. + r.expires_at_epoch = 0; // Mark the winner's commit_entry CONFIRMED, others RELEASED (loser). for (auto& c : r.commits_by) { if (c.underwriter == candidate) { @@ -1412,6 +1483,14 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate) { c.status = UnderwriteStatus::UNDERWRITE_STATUS_RELEASED; c.reason = "lost the COMMIT race"; } + // Race resolved — non-winner UIC blobs have no remaining reader + // (re-arm via rcrdcommit is PENDING-gated, and only PENDING rows + // re-evaluate). The winner's bytes stay through the lock window as + // the challenge-evidence trail; `chklocks` clears them at COMPLETED. + if (c.underwriter != candidate) { + c.source_uic_bytes.clear(); + c.dest_uic_bytes.clear(); + } } }); @@ -1523,6 +1602,40 @@ void uwrit::rcrdcommit(uint64_t uwreq_id, return; } + // Row-growth rails (SEC-129 / WSA-223) — every rail fails closed with no + // mutation, never check() (we are inside the evalcons dispatch chain). + // A stored UIC leg is capped so a hostile relay cannot inflate the row + // with an oversized blob… + if (uic_bytes.size() > MAX_UIC_LEG_BYTES) { + sysio::print("rcrdcommit: uwreq ", uwreq_id, " UIC payload of ", + uic_bytes.size(), " bytes exceeds the per-leg cap, skipping\n"); + return; + } + // …the candidate roster is capped so the ACTIVE-underwriter population + // bounds scan cost but not row size… + const bool existing_entry = + std::any_of(req_snapshot.commits_by.begin(), req_snapshot.commits_by.end(), + [&](const commit_entry& c) { return c.underwriter == underwriter; }); + if (!existing_entry && req_snapshot.commits_by.size() >= MAX_UWREQ_CANDIDATES) { + sysio::print("rcrdcommit: uwreq ", uwreq_id, " already carries ", + req_snapshot.commits_by.size(), + " candidates (cap reached), skipping new candidate\n"); + return; + } + // …and the projected whole-row size is guarded so THIS modify — and every + // later consensus-dispatched one — stays clear of chain KV value limits. + // Conservative projection: current packed row + the incoming leg bytes + + // a fixed allowance for a new entry's non-vector fields (replaced bytes, + // if any, are ignored — over-counting only tightens the guard). + const size_t projected_row_bytes = pack_size(req_snapshot) + + uic_bytes.size() + + (existing_entry ? 0 : COMMIT_ENTRY_PACK_ALLOWANCE_BYTES); + if (projected_row_bytes > MAX_UWREQ_ROW_BYTES) { + sysio::print("rcrdcommit: uwreq ", uwreq_id, " projected row size ", + projected_row_bytes, " bytes exceeds the row cap, skipping\n"); + return; + } + reqs.modify(same_payer, pk, [&](auto& r) { auto* c = find_or_create_commit(r, underwriter); uint64_t now_ms = current_time_ms(); @@ -1807,7 +1920,10 @@ void uwrit::drainfwq() { .winner = name{}, .committed_at_ms = 0, .settled_at_ms = 0, - .expires_at_epoch = 0, + // Same PENDING deadline as createuwreq (SEC-129 / WSA-223): a + // from-WIRE race that never resolves is expired by `pruneuwreqs`, + // which refunds the escrow in full via `refundwire`. + .expires_at_epoch = get_current_epoch() + cfg.uwreq_pending_timeout_epochs, .attestation_inbound_data = std::move(encoded), .attestation_outbound_data = {}, }); @@ -1880,10 +1996,15 @@ void uwrit::chklocks() { } // COMPLETED flip — a CONFIRMED uwreq whose final lock just swept has - // exited its challenge window. + // exited its challenge window. The flip stamps the retention deadline + // (`pruneuwreqs` erases the row once it elapses) and clears the remaining + // heavy payloads: with the challenge window closed, the winner's UIC + // evidence and the inbound attestation copy have no remaining reader — + // retention keeps the compact audit metadata only (SEC-129 / WSA-223). uwreqs_t reqs(get_self()); auto byuwreq = locks.get_index<"byuwreq"_n>(); const uint32_t now_ep = get_current_epoch(); + const uint32_t retention_epochs = read_config(get_self()).uwreq_retention_epochs; for (uint64_t id : affected) { auto lit = byuwreq.lower_bound(id); if (lit != byuwreq.end() && lit->uwreq_id == id) continue; // locks remain @@ -1894,9 +2015,89 @@ void uwrit::chklocks() { reqs.modify(same_payer, pk, [&](auto& row) { row.status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_COMPLETED; row.settled_at_ms = current_time_ms(); - row.expires_at_epoch = now_ep + UWREQ_RETENTION_EPOCHS; + row.expires_at_epoch = now_ep + retention_epochs; + row.attestation_inbound_data.clear(); + for (auto& c : row.commits_by) { + c.source_uic_bytes.clear(); + c.dest_uic_bytes.clear(); + } }); } } +// --------------------------------------------------------------------------- +// pruneuwreqs — bounded UWREQ lifecycle sweep (SEC-129 / WSA-223) +// --------------------------------------------------------------------------- +// +// The enforcement half of `uw_request_t.expires_at_epoch`: every deadline is +// stamped by the lifecycle writes (createuwreq / drainfwq / reject_and_refund +// / chklocks) and evaluated only HERE, lazily, when the epoch machinery +// triggers this action. Nothing is scheduled; a row past its deadline is +// inert data until the sweep fires. NEVER throws past the auth gate: it runs +// inline inside `sysio.epoch::advance`, where an abort stalls epoch progress +// chain-wide. The per-call `max_rows` budget keeps the sweep inside advance's +// transaction CPU deadline (same rationale as MAX_FWQ_DRAIN_PER_EPOCH); a +// backlog drains across subsequent epochs. +void uwrit::pruneuwreqs(uint32_t max_rows) { + // Two valid callers, mirroring chklocks / drainfwq: + // * sysio.epoch::advance — inlined each epoch with MAX_UWREQ_PRUNE_PER_EPOCH. + // * sysio.uwrit — manual backlog drain with a caller-chosen budget. + check(has_auth(EPOCH_ACCOUNT) || has_auth(get_self()), + "pruneuwreqs requires sysio.epoch or sysio.uwrit authority"); + if (max_rows == 0) return; + + const uint32_t now_ep = get_current_epoch(); + uwreqs_t reqs(get_self()); + auto idx = reqs.get_index<"byexpire"_n>(); + + // Collect-then-act, bounded to `max_rows` copies: a modify/erase + // invalidates the index cursor (same two-pass shape as chklocks), and the + // bounded copy keeps the scan cost budgeted. `lower_bound(1)` skips the + // `expires_at_epoch == 0` population — rows with no deadline (CONFIRMED, + // owned by the wall-clock lock window) are never swept. + std::vector due; + for (auto it = idx.lower_bound(uint64_t{1}); + it != idx.end() && it->expires_at_epoch <= now_ep && due.size() < max_rows; + ++it) { + due.push_back(*it); + } + if (due.empty()) return; + + for (const auto& req : due) { + auto pk = id_key{req.id}; + switch (req.status) { + case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_PENDING: { + // The race never resolved inside the pending timeout — abandon + // it: refund the source side (SWAP_REVERT to the source outpost, + // or the full from-WIRE escrow via refundwire — expiry is not a + // caller-controlled revert cause), flip to EXPIRED, and start the + // terminal retention window. PENDING rows hold no locks (locks + // are first written in the same modify that flips CONFIRMED), so + // there is nothing to release. + const bool src_needed = !leg_is_depot(req.src_chain_code); + reject_and_refund(get_self(), reqs, pk, req, src_needed, + "underwrite request expired: no underwriter resolved the race " + "within the pending timeout", + "uwreq expired: pending deadline elapsed before the race resolved", + UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_EXPIRED); + break; + } + case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_REJECTED: + case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_COMPLETED: + case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_EXPIRED: + // Terminal row past its retention window — remove it. + reqs.erase(pk); + break; + default: + // CONFIRMED (or a future status) with a live deadline should be + // unreachable — winner selection zeroes the deadline. Defensive + // skip + log; never throw inside advance. + sysio::print("pruneuwreqs: uwreq ", req.id, " in status ", + magic_enum::enum_integer(req.status), + " unexpectedly carries an elapsed deadline, skipping\n"); + break; + } + } +} + } // namespace sysio diff --git a/contracts/sysio.uwrit/sysio.uwrit.abi b/contracts/sysio.uwrit/sysio.uwrit.abi index 95bc74caca..68e0f90ee9 100644 --- a/contracts/sysio.uwrit/sysio.uwrit.abi +++ b/contracts/sysio.uwrit/sysio.uwrit.abi @@ -199,6 +199,16 @@ } ] }, + { + "name": "pruneuwreqs", + "base": "", + "fields": [ + { + "name": "max_rows", + "type": "uint32" + } + ] + }, { "name": "rcrdcommit", "base": "", @@ -252,6 +262,14 @@ { "name": "fromwire_revert_fee_bps", "type": "uint32" + }, + { + "name": "uwreq_pending_timeout_epochs", + "type": "uint32" + }, + { + "name": "uwreq_retention_epochs", + "type": "uint32" } ] }, @@ -344,6 +362,14 @@ { "name": "fromwire_revert_fee_bps", "type": "uint32" + }, + { + "name": "uwreq_pending_timeout_epochs", + "type": "uint32" + }, + { + "name": "uwreq_retention_epochs", + "type": "uint32" } ] }, @@ -468,6 +494,11 @@ "type": "drainfwq", "ricardian_contract": "" }, + { + "name": "pruneuwreqs", + "type": "pruneuwreqs", + "ricardian_contract": "" + }, { "name": "rcrdcommit", "type": "rcrdcommit", @@ -563,6 +594,11 @@ "name": "bywinner", "key_type": "uint64", "table_id": 29561 + }, + { + "name": "byexpire", + "key_type": "uint64", + "table_id": 36295 } ] } diff --git a/contracts/sysio.uwrit/sysio.uwrit.wasm b/contracts/sysio.uwrit/sysio.uwrit.wasm index eb22f28046..cce16ed527 100755 Binary files a/contracts/sysio.uwrit/sysio.uwrit.wasm and b/contracts/sysio.uwrit/sysio.uwrit.wasm differ diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index cbadc48e61..b83eb97028 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -807,6 +807,87 @@ class sysio_dispatch_tester : public tester { BOOST_REQUIRE(!get_uwreq(att_id).is_null()); } + // ── SEC-129 / WSA-223: real epoch-aging helpers ────────────────────────── + // + // The bootstrap advance gate-blocks on missing emissions state, so every + // dispatch test normally runs at epoch 0. The UWREQ lifecycle sweep is + // trigger-driven off real advances, so its tests configure emissions the + // way emissions_tests.cpp does and then genuinely advance the epoch index. + + /// Push a sysio.system action (ABI resolved from chain state — the system + /// account runs this build's genesis code, like sysio.roa above). + action_result push_system(name signer, name action_name, const fc::variant_object& data) { + try { + base_tester::push_action(config::system_account_name, action_name, signer, data); + return success(); + } catch (const fc::exception& ex) { + return error(ex.top_message()); + } + } + + /// Make `sysio.epoch::advance` genuinely advance: the genesis `sysio` + /// account runs only the boot contract, so the full sysio.system (where + /// the emissions gate's config + t5 state live) is deployed first — the + /// same sequence as emissions_tests.cpp's fixture — then the emission + /// config + t5 state are initialized and the T5 holding accounts payepoch + /// transfers WIRE to are created. Values mirror emissions_tests.cpp's + /// defaults. Requires setup_wire_token_and_reserves() first (payepoch pays + /// WIRE out of sysio's token balance). + void enable_epoch_advancement() { + set_code(config::system_account_name, contracts::system_wasm()); + set_abi(config::system_account_name, contracts::system_abi().data()); + BOOST_REQUIRE_EQUAL(success(), push_system(config::system_account_name, "init"_n, + mvo()("version", 0)("core", "4,SYS"))); + produce_blocks(); + for (auto a : {"sysio.dclaim"_n, "sysio.gov"_n, "sysio.batch"_n, "sysio.ops"_n}) { + if (!control->db().find(a)) { + create_accounts({a}, /*multisig=*/false, /*include_code=*/false, + /*include_roa_policy=*/false, /*include_ram_gift=*/true); + } + } + produce_blocks(); + constexpr uint32_t seconds_per_month = 30u * 24u * 60u * 60u; + BOOST_REQUIRE_EQUAL(success(), push_system(config::system_account_name, "setemitcfg"_n, + mvo()("cfg", mvo() + ("t1_allocation", int64_t{7'500'000'000'000'000LL}) + ("t2_allocation", int64_t{1'000'000'000'000'000LL}) + ("t3_allocation", int64_t{100'000'000'000'000LL}) + ("t1_duration", 12u * seconds_per_month) + ("t2_duration", 24u * seconds_per_month) + ("t3_duration", 36u * seconds_per_month) + ("min_claimable", int64_t{10'000'000'000LL}) + ("t5_distributable", int64_t{375'000'000'000'000'000LL}) + ("t5_floor", int64_t{125'000'000'000'000'000LL}) + ("target_annual_decay_bps", uint16_t(6940)) + ("annual_initial_emission", int64_t{563'150'000'000'000LL} * 365) + ("annual_max_emission", int64_t{3'000'000'000'000'000LL} * 365) + ("annual_min_emission", int64_t{100'000'000'000'000LL} * 365) + ("compute_bps", uint16_t(4000)) + ("capex_bps", uint16_t(2000)) + ("governance_bps", uint16_t(1000)) + ("producer_bps", uint16_t(7000)) + ("batch_op_bps", uint16_t(3000)) + ("standby_end_rank", uint32_t(28)) + ("epoch_log_retention_count", uint32_t(8640)) + ("pay_cadence_epochs", uint16_t(1))))); + BOOST_REQUIRE_EQUAL(success(), push_system(config::system_account_name, "initt5"_n, + mvo()("start_time", time_point_sec(control->head().block_time())))); + produce_blocks(); + } + + /// Cross one epoch boundary and advance. epoch_duration_sec is 60 in this + /// fixture (bootstrap_for_dispatch); 124 half-second blocks = 62s crosses + /// it. advance is pushed with the epoch contract's own authority (advance + /// accepts sysio.msgch OR sysio.epoch post-genesis). Each successful + /// advance fires the real inline maintenance chain — chklocks, + /// pruneuwreqs(MAX_UWREQ_PRUNE_PER_EPOCH), drainfwq, buildenv — exactly as + /// in production. + void age_one_epoch() { + produce_blocks(124); + BOOST_REQUIRE_EQUAL(success(), + push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "advance"_n, mvo())); + } + abi_serializer msgch_abi, opreg_abi, uwrit_abi, epoch_abi, reserv_abi, authex_abi, chains_abi, roa_abi, token_abi; @@ -2315,7 +2396,8 @@ BOOST_FIXTURE_TEST_CASE(drainfwq_bounds_rows_per_epoch, sysio_dispatch_tester) { // default 5-WIRE floor and the revert fee have their own dedicated cases below. BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 0))); + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 0) + ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); // A funded from-WIRE swap user (plain account, no ROA policy). create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, @@ -2416,7 +2498,8 @@ BOOST_FIXTURE_TEST_CASE(swapfromwire_enforces_min_amount, sysio_dispatch_tester) constexpr uint64_t LOWERED_FLOOR = 1'000'000; BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", LOWERED_FLOOR)("fromwire_revert_fee_bps", 10))); + ("min_fromwire_amount", LOWERED_FLOOR)("fromwire_revert_fee_bps", 10) + ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); BOOST_REQUIRE_EQUAL( error("assertion failure with message: swapfromwire: wire_amount below the configured minimum"), swap(LOWERED_FLOOR - 1)); @@ -2441,7 +2524,8 @@ BOOST_FIXTURE_TEST_CASE(drainfwq_charges_revert_fee_on_caller_fault, sysio_dispa register_wire_depot(); // depot registered => the drain reaches the variance check BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", REVERT_FEE_BPS))); + ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", REVERT_FEE_BPS) + ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, /*include_code=*/true, /*include_roa_policy=*/false); @@ -2496,7 +2580,8 @@ BOOST_FIXTURE_TEST_CASE(drainfwq_full_refund_on_system_caused_revert, sysio_disp // empty, which is a system-caused revert (the registry, not the caller's parameters). BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", 100))); + ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", 100) + ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, /*include_code=*/true, /*include_roa_policy=*/false); @@ -2528,4 +2613,344 @@ BOOST_FIXTURE_TEST_CASE(drainfwq_full_refund_on_system_caused_revert, sysio_disp "rewardbkt"_n, "rewardbkt"_n).empty()); } FC_LOG_AND_RETHROW() } +// ═════════════════════════════════════════════════════════════════════════ +// SEC-129 / WSA-223 — UWREQ lifecycle: expiry is enforced by triggers. +// +// `expires_at_epoch` is passive row data; the `pruneuwreqs` sweep inlined in +// every real `sysio.epoch::advance` is the only thing that evaluates it. The +// cases below drive genuine advances (emissions gate satisfied) and assert +// the full lifecycle: PENDING deadline → EXPIRED + refund + compaction → +// retention → erased; CONFIRMED → COMPLETED (chklocks) → retention → erased; +// the per-epoch budget; and the rcrdcommit row-growth rails. +// ═════════════════════════════════════════════════════════════════════════ + +// A PENDING uwreq whose underwriter race never resolves survives pre-deadline +// advances untouched, is expired by the first advance at/past its deadline +// (EXPIRED + payload compaction), and is erased once retention elapses. +BOOST_FIXTURE_TEST_CASE(uwreq_pending_timeout_expires_then_retention_erases, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + // Pending timeout 2 epochs + retention 1 epoch — the shortest schedule + // that still proves the pre-deadline advance is a no-op for the row. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() + ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 10) + ("uwreq_pending_timeout_epochs", 2)("uwreq_retention_epochs", 1))); + constexpr uint64_t ATT_ID = 9100; + setup_eth_to_sol_uwreq(ATT_ID); // PENDING, no commits, deadline = 0 + 2 + enable_epoch_advancement(); + + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL(2u, req["expires_at_epoch"].as()); + BOOST_REQUIRE(req["attestation_inbound_data"].as_string().size() > 0); + } + + // Epoch 1 < deadline 2 — the row is inert data; the sweep leaves it alone. + age_one_epoch(); + BOOST_REQUIRE_EQUAL(1u, current_epoch()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", + get_uwreq(ATT_ID)["status"].as_string()); + + // Epoch 2 == deadline — the same advance's inline sweep expires it. + age_one_epoch(); + BOOST_REQUIRE_EQUAL(2u, current_epoch()); + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE(!req.is_null()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_EXPIRED", req["status"].as_string()); + BOOST_REQUIRE(req["settled_at_ms"].as_uint64() > 0); + // Terminal compaction: the inbound attestation copy is gone; the + // compact audit metadata (codes, amounts, timestamps) is retained. + BOOST_REQUIRE_EQUAL(0u, req["attestation_inbound_data"].as_string().size()); + // Retention window re-stamped: epoch 2 + 1. + BOOST_REQUIRE_EQUAL(3u, req["expires_at_epoch"].as()); + } + + // Epoch 3 — retention elapsed; the row is erased outright. + age_one_epoch(); + BOOST_REQUIRE_EQUAL(3u, current_epoch()); + BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); +} FC_LOG_AND_RETHROW() } + +// A queued swap-from-WIRE whose race never resolves refunds the user's FULL +// escrow at expiry (expiry is not a caller-controlled revert cause — no +// revert fee) and follows the same EXPIRED → retention → erased lifecycle. +BOOST_FIXTURE_TEST_CASE(uwreq_from_wire_pending_timeout_refunds_escrow, + sysio_dispatch_tester) { try { + constexpr uint64_t DEPOT_ORIGIN_ID_0 = 0x8000000000000000ull; + constexpr uint64_t ESCROW = 1'000'000; + const auto WIRE_SYM = symbol(9, "WIRE"); + + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); + register_wire_depot(); + // Non-zero revert fee configured on purpose: the full refund below proves + // the expiry path is fee-exempt even when a fee is configured. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() + ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 100) + ("uwreq_pending_timeout_epochs", 1)("uwreq_retention_epochs", 1))); + + // Created + funded before the full system contract lands on `sysio`, the + // same way every other from-WIRE case provisions its user. + create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, + /*include_code=*/true, /*include_roa_policy=*/false); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, + "transfer"_n, mvo()("from", "sysio")("to", "swapuser") + ("quantity", "10.000000000 WIRE")("memo", "fund swap user"))); + const int64_t funded = + get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount(); + + enable_epoch_advancement(); + + // target == wire_amount with 100% tolerance prices within variance against + // the balanced 1e12/1e12 reserve, so the drain emplaces the uwreq (no + // refund at drain) — same recipe as drainfwq_bounds_rows_per_epoch. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, "swapuser"_n, + "swapfromwire"_n, mvo() + ("user", "swapuser") + ("wire_amount", ESCROW) + ("dst_chain_code", codename_mvo("ETH")) + ("dst_token_code", codename_mvo("ETH")) + ("dst_reserve_code", codename_mvo("PRIMARY")) + ("target_amount", uint64_t{1'000'000}) + ("target_tolerance_bps", uint32_t{10000}) + ("recipient_kind", ChainKind::CHAIN_KIND_EVM) + ("recipient_addr", std::vector(20, '\x0a')))); + BOOST_REQUIRE_EQUAL(funded - int64_t(ESCROW), + get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); + + // Advance #1: drainfwq (inline, post-increment) creates the PENDING uwreq + // at epoch 1 with deadline 1 + 1 = 2. + age_one_epoch(); + { + const auto req = get_uwreq(DEPOT_ORIGIN_ID_0); + BOOST_REQUIRE(!req.is_null()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); + BOOST_REQUIRE_EQUAL(2u, req["expires_at_epoch"].as()); + } + + // Advance #2 (epoch 2 == deadline): the sweep expires the row and refunds + // the FULL escrow via reserv::refundwire. + age_one_epoch(); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_EXPIRED", + get_uwreq(DEPOT_ORIGIN_ID_0)["status"].as_string()); + BOOST_REQUIRE_EQUAL(funded, + get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); + + // Advance #3 (epoch 3): retention elapsed — erased. + age_one_epoch(); + BOOST_REQUIRE(get_uwreq(DEPOT_ORIGIN_ID_0).is_null()); +} FC_LOG_AND_RETHROW() } + +// A settled swap's row is compacted at COMPLETED (chklocks closes the +// challenge window) and erased once retention elapses — the full happy-path +// lifecycle driven end to end by real advances. Winner selection itself +// zeroes the deadline (the lock window owns a CONFIRMED row). +BOOST_FIXTURE_TEST_CASE(uwreq_completed_row_erased_after_retention, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); + BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); + // 60s challenge window (== one epoch) + 1-epoch retention; the pending + // timeout stays clear of the race so only the lock machinery drives it. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() + ("fee_bps", 10)("collateral_lock_duration_ms", 60'000u) + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 10) + ("uwreq_pending_timeout_epochs", 5)("uwreq_retention_epochs", 1))); + enable_epoch_advancement(); + + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t primary = fc::slug_name{"PRIMARY"}.value; + const uint64_t secondary = fc::slug_name{"SECOND"}.value; + constexpr uint64_t ATT_ID = 9200; + + // Same-chain double-leg winner recipe as swap_same_token_legs_exact_balance_wins. + BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 200)); + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, primary, /*src_amount*/ 100, + eth, eth, secondary, /*dst_amount*/ 100, + /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", + make_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary))); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", + make_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary))); + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", req["status"].as_string()); + // Winner selection cleared the deadline — chklocks owns the row now. + BOOST_REQUIRE_EQUAL(0u, req["expires_at_epoch"].as()); + } + + // Advance #1 (62s elapsed > the 60s lock window): chklocks sweeps both + // locks, flips COMPLETED, stamps retention 1 + 1 = 2, and clears the + // remaining heavy payloads. The same advance's sweep (epoch 1 < 2) must + // NOT touch the freshly-completed row. + age_one_epoch(); + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE(!req.is_null()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", req["status"].as_string()); + BOOST_REQUIRE_EQUAL(2u, req["expires_at_epoch"].as()); + BOOST_REQUIRE_EQUAL(0u, req["attestation_inbound_data"].as_string().size()); + for (const auto& c : req["commits_by"].get_array()) { + BOOST_REQUIRE_EQUAL(0u, c["source_uic_bytes"].as_string().size()); + BOOST_REQUIRE_EQUAL(0u, c["dest_uic_bytes"].as_string().size()); + } + } + + // Advance #2 (epoch 2): retention elapsed — erased. Steady state: the + // table carries nothing from a fully-settled swap. + age_one_epoch(); + BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); +} FC_LOG_AND_RETHROW() } + +// The per-epoch budget bounds the sweep's work: with more due rows than +// MAX_UWREQ_PRUNE_PER_EPOCH (32), one advance handles exactly 32 and the +// backlog drains the next epoch — advance's CPU stays bounded, nothing is +// lost, nothing is double-handled. +BOOST_FIXTURE_TEST_CASE(pruneuwreqs_budget_bounds_rows_per_epoch, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + // Retention kept long (100) so the second advance only expires the + // leftover PENDING row instead of competing with 32 fresh erases. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() + ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 10) + ("uwreq_pending_timeout_epochs", 1)("uwreq_retention_epochs", 100))); + constexpr uint64_t FIRST_ATT_ID = 9300; + constexpr uint32_t ROWS = 33; // MAX_UWREQ_PRUNE_PER_EPOCH + 1 + setup_eth_to_sol_uwreq(FIRST_ATT_ID); // registers SOLANA + reserves once + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; + const uint64_t sol_token = fc::slug_name{"SOL"}.value; + const uint64_t primary = fc::slug_name{"PRIMARY"}.value; + for (uint32_t i = 1; i < ROWS; ++i) { + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, primary, 100, sol_chain, sol_token, primary, 100, + 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(FIRST_ATT_ID + i, eth, sr)); + if (i % 8 == 7) produce_blocks(); // spread across blocks (per-block CPU) + } + produce_blocks(); + enable_epoch_advancement(); + + // Advance #1 (epoch 1 == every row's deadline): exactly the budgeted 32 + // rows are expired; one stays PENDING for the next epoch. + age_one_epoch(); + uint32_t expired = 0, pending = 0; + for (uint32_t i = 0; i < ROWS; ++i) { + const auto req = get_uwreq(FIRST_ATT_ID + i); + BOOST_REQUIRE(!req.is_null()); + const auto st = req["status"].as_string(); + if (st == "UNDERWRITE_REQUEST_STATUS_EXPIRED") ++expired; + else if (st == "UNDERWRITE_REQUEST_STATUS_PENDING") ++pending; + } + BOOST_REQUIRE_EQUAL(32u, expired); + BOOST_REQUIRE_EQUAL(1u, pending); + + // Advance #2 drains the backlog: every row is now EXPIRED. + age_one_epoch(); + expired = 0; + for (uint32_t i = 0; i < ROWS; ++i) { + const auto req = get_uwreq(FIRST_ATT_ID + i); + BOOST_REQUIRE(!req.is_null()); + if (req["status"].as_string() == "UNDERWRITE_REQUEST_STATUS_EXPIRED") ++expired; + } + BOOST_REQUIRE_EQUAL(ROWS, expired); +} FC_LOG_AND_RETHROW() } + +// ── rcrdcommit row-growth rails (SEC-129 / WSA-223) ── + +// An oversized UIC payload is refused at the door: no candidate entry, no +// stored bytes, no throw. At the cap, the commit records normally. +BOOST_FIXTURE_TEST_CASE(rcrdcommit_oversized_uic_leg_is_dropped, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + constexpr uint64_t ATT_ID = 9400; + setup_eth_to_sol_uwreq(ATT_ID); + const uint64_t eth = fc::slug_name{"ETH"}.value; + + // One byte past MAX_UIC_LEG_BYTES (2048) — dropped with no mutation. + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", + std::vector(2049, '\x01'))); + BOOST_REQUIRE_EQUAL(0u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); + + // At the cap the commit records (bytes are stored verbatim; nothing + // decodes them until winner selection, which this single leg never arms). + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", + std::vector(2048, '\x01'))); + BOOST_REQUIRE_EQUAL(1u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); +} FC_LOG_AND_RETHROW() } + +// The candidate roster is capped at MAX_UWREQ_CANDIDATES (32): the 33rd +// distinct ACTIVE underwriter is refused with no row growth, while an +// existing candidate still updates its entry at the cap (dedupe, not append). +BOOST_FIXTURE_TEST_CASE(rcrdcommit_candidate_cap_bounds_row, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + constexpr uint64_t ATT_ID = 9500; + setup_eth_to_sol_uwreq(ATT_ID); + const uint64_t eth = fc::slug_name{"ETH"}.value; + + // Provision 33 ACTIVE underwriters (register + meet the 1-unit ETH/ETH + // minimum from bootstrap_for_dispatch's opreg config). UWRIT_OP stays out + // of this roster. + std::vector uws; + for (uint32_t i = 0; i < 33; ++i) { + std::string s = "uwcap"; + s += static_cast('a' + i / 26); + s += static_cast('a' + i % 26); + uws.emplace_back(s); + } + create_accounts(uws); + produce_blocks(); + // Blocks are produced along the way — 33 registrations + credits + 33 + // commits in one block would exhaust the per-block billable CPU. + for (uint32_t i = 0; i < uws.size(); ++i) { + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo() + ("account", uws[i].to_string()) + ("type", OperatorType::OPERATOR_TYPE_UNDERWRITER) + ("is_bootstrapped", false))); + BOOST_REQUIRE_EQUAL(success(), depositinle_credit(uws[i], "ETH", "ETH", 1'000)); + if (i % 8 == 7) produce_blocks(); + } + produce_blocks(); + + // 32 distinct candidates record; the 33rd is refused at the cap. + for (uint32_t i = 0; i < 32; ++i) { + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, uws[i], eth, "ETH", "ETH", "PRIMARY", + std::vector{1, 2, 3})); + if (i % 8 == 7) produce_blocks(); + } + BOOST_REQUIRE_EQUAL(32u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, uws[32], eth, "ETH", "ETH", "PRIMARY", + std::vector{1, 2, 3})); + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL(32u, req["commits_by"].get_array().size()); + for (const auto& c : req["commits_by"].get_array()) { + BOOST_REQUIRE(c["underwriter"].as_string() != uws[32].to_string()); + } + } + + // An EXISTING candidate still updates its entry at the cap. + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, uws[0], eth, "ETH", "ETH", "PRIMARY", + std::vector{4, 5, 6})); + BOOST_REQUIRE_EQUAL(32u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.uwrit_tests.cpp b/contracts/tests/sysio.uwrit_tests.cpp index 4562144166..338dae9d6d 100644 --- a/contracts/tests/sysio.uwrit_tests.cpp +++ b/contracts/tests/sysio.uwrit_tests.cpp @@ -81,15 +81,19 @@ class sysio_uwrit_tester : public tester { } } - action_result setconfig(uint32_t fee_bps = 10, - uint64_t collateral_lock_duration_ms = 43'200'000, - uint64_t min_fromwire_amount = 5'000'000'000ull, - uint32_t fromwire_revert_fee_bps = 10) { + action_result setconfig(uint32_t fee_bps = 10, + uint64_t collateral_lock_duration_ms = 43'200'000, + uint64_t min_fromwire_amount = 5'000'000'000ull, + uint32_t fromwire_revert_fee_bps = 10, + uint32_t uwreq_pending_timeout_epochs = 10, + uint32_t uwreq_retention_epochs = 10) { return push_uwrit_action(UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", fee_bps) - ("collateral_lock_duration_ms", collateral_lock_duration_ms) - ("min_fromwire_amount", min_fromwire_amount) - ("fromwire_revert_fee_bps", fromwire_revert_fee_bps) + ("fee_bps", fee_bps) + ("collateral_lock_duration_ms", collateral_lock_duration_ms) + ("min_fromwire_amount", min_fromwire_amount) + ("fromwire_revert_fee_bps", fromwire_revert_fee_bps) + ("uwreq_pending_timeout_epochs", uwreq_pending_timeout_epochs) + ("uwreq_retention_epochs", uwreq_retention_epochs) ); } @@ -124,6 +128,8 @@ BOOST_FIXTURE_TEST_CASE(setconfig_basic, sysio_uwrit_tester) { try { BOOST_REQUIRE_EQUAL(43'200'000u, cfg["collateral_lock_duration_ms"].as_uint64()); BOOST_REQUIRE_EQUAL(5'000'000'000u, cfg["min_fromwire_amount"].as_uint64()); BOOST_REQUIRE_EQUAL(10, cfg["fromwire_revert_fee_bps"].as_uint64()); + BOOST_REQUIRE_EQUAL(10, cfg["uwreq_pending_timeout_epochs"].as_uint64()); + BOOST_REQUIRE_EQUAL(10, cfg["uwreq_retention_epochs"].as_uint64()); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(setconfig_writes_custom_lock_duration, sysio_uwrit_tester) { try { @@ -190,6 +196,57 @@ BOOST_FIXTURE_TEST_CASE(setconfig_accepts_max_revert_fee_and_custom_floor, sysio BOOST_REQUIRE_EQUAL(9999, cfg["fromwire_revert_fee_bps"].as_uint64()); } FC_LOG_AND_RETHROW() } +// ── SEC-129 / WSA-223: UWREQ lifecycle knobs ── +// +// A zero pending timeout would expire every uwreq the epoch it is created; a +// zero retention would erase terminal rows before the audit window they exist +// for; a near-UINT32_MAX value would wrap the `current_epoch + knob` deadline +// stamp to a tiny epoch index (instant expiry). setconfig rejects all three. + +BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_pending_timeout, sysio_uwrit_tester) { try { + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: uwreq_pending_timeout_epochs must be positive"), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/0) + ); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_retention, sysio_uwrit_tester) { try { + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: uwreq_retention_epochs must be positive"), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/10, /*retention*/0) + ); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(setconfig_rejects_lifecycle_epochs_over_ceiling, sysio_uwrit_tester) { try { + // The ceiling itself is accepted for both knobs; one epoch beyond it is + // rejected before the value could wrap a deadline stamp. + constexpr uint32_t ceiling = 1'000'000; + BOOST_REQUIRE_EQUAL(success(), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/ceiling, /*retention*/ceiling)); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: uwreq_pending_timeout_epochs exceeds the lifecycle ceiling"), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/ceiling + 1, /*retention*/ceiling) + ); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: uwreq_retention_epochs exceeds the lifecycle ceiling"), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/ceiling, /*retention*/ceiling + 1) + ); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(setconfig_roundtrips_lifecycle_epochs, sysio_uwrit_tester) { try { + BOOST_REQUIRE_EQUAL(success(), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/3, /*retention*/7)); + auto cfg = get_uwconfig(); + BOOST_REQUIRE_EQUAL(3, cfg["uwreq_pending_timeout_epochs"].as_uint64()); + BOOST_REQUIRE_EQUAL(7, cfg["uwreq_retention_epochs"].as_uint64()); +} FC_LOG_AND_RETHROW() } + BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_lock_duration, sysio_uwrit_tester) { try { BOOST_REQUIRE_EQUAL( error("assertion failure with message: collateral_lock_duration_ms must be positive"), @@ -238,6 +295,31 @@ BOOST_FIXTURE_TEST_CASE(chklocks_noop_with_no_locks, sysio_uwrit_tester) { try { push_uwrit_action(UWRIT_ACCOUNT, "chklocks"_n, mvo())); } FC_LOG_AND_RETHROW() } +// ── pruneuwreqs — bounded UWREQ lifecycle sweep (SEC-129 / WSA-223) ── +// +// The enforcement trigger for `uw_request_t.expires_at_epoch`. Auth mirrors +// its sibling epoch-inline sweeps; the lifecycle behavior itself (PENDING +// timeout, terminal retention erase, per-epoch budget) needs real epoch +// movement and lives in sysio.dispatch_tests.cpp. + +BOOST_FIXTURE_TEST_CASE(pruneuwreqs_requires_epoch_or_self_auth, sysio_uwrit_tester) { try { + BOOST_REQUIRE(push_uwrit_action("uwrit.a"_n, "pruneuwreqs"_n, mvo() + ("max_rows", 10) + ).find("pruneuwreqs requires sysio.epoch or sysio.uwrit authority") != std::string::npos); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(pruneuwreqs_noop_with_empty_table, sysio_uwrit_tester) { try { + // Steady-state: nothing due, nothing to sweep — must be a clean no-op + // (it runs inside every epoch advance). + BOOST_REQUIRE_EQUAL(success(), + push_uwrit_action(UWRIT_ACCOUNT, "pruneuwreqs"_n, mvo()("max_rows", 10))); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(pruneuwreqs_zero_budget_is_noop, sysio_uwrit_tester) { try { + BOOST_REQUIRE_EQUAL(success(), + push_uwrit_action(UWRIT_ACCOUNT, "pruneuwreqs"_n, mvo()("max_rows", 0))); +} FC_LOG_AND_RETHROW() } + // ── drainfwq — epoch-boundary swap-from-WIRE queue drain ── BOOST_FIXTURE_TEST_CASE(drainfwq_requires_epoch_or_self_auth, sysio_uwrit_tester) { try {