Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
7 changes: 4 additions & 3 deletions contracts/sysio.epoch/src/sysio.epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
#include <sysio.opp.common/opp_keys.hpp>
#include <sysio.authex/sysio.authex.hpp>
#include <sysio.token/sysio.token.hpp>
// 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).
// For uwrit::MAX_LOCK_RELEASE_PER_EPOCH and uwrit::MAX_UWREQ_PRUNE_PER_EPOCH —
// the per-epoch budgets advance hands to the inline `chklocks` and
// `pruneuwreqs` sweeps (both constants are owned by sysio.uwrit).
#include <sysio.uwrit/sysio.uwrit.hpp>
// For reserve::MAX_CLAIM_SWEEP_PER_EPOCH — the per-epoch budget advance hands
// to the inline `sweepclaims` retention sweep (the constant is owned by
Expand Down Expand Up @@ -414,7 +415,7 @@ void epoch::advance() {
permission_level{get_self(), "owner"_n},
UWRIT_ACCOUNT,
"chklocks"_n,
std::make_tuple()
std::make_tuple(uwrit::MAX_LOCK_RELEASE_PER_EPOCH)
).send();

// Bounded UWREQ lifecycle sweep (SEC-129 / WSA-223): erase terminal
Expand Down
Binary file modified contracts/sysio.epoch/sysio.epoch.wasm
Binary file not shown.
35 changes: 15 additions & 20 deletions contracts/sysio.opreg/src/sysio.opreg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -370,28 +370,23 @@ void opreg::regoperator(name account,

namespace {

/// Sum the active locks on `sysio.uwrit::locks` for a given (op, chain, token).
/// Returns 0 if uwrit's locks table is empty or if the operator has no locks
/// on that chain/token.
/// The active lock total on `sysio.uwrit` for a given (op, chain, token) —
/// an O(1) read of uwrit's `locksums` rollup. Returns 0 when the operator
/// holds no live locks on that chain/token (the rollup erases a bucket's row
/// once it empties, so an absent row IS zero).
///
/// Per v6 plan §B.2 (split-index design): `sysio.uwrit::locks_t` exposes only
/// uint64 secondary indexes. The `byuw` index keys on `underwriter.value`;
/// rows are filtered on `(chain_code, token_code)` in memory. Per-underwriter
/// lock counts are O(1)-ish in steady state so the scan is cheap.
/// This used to scan `sysio.uwrit::locks` through its `byuw` index and filter
/// `(chain_code, token_code)` in memory, on the stated assumption that
/// per-underwriter lock counts are "O(1)-ish in steady state so the scan is
/// cheap". That assumption does not hold: uwrit locks are held for the full
/// wall-clock challenge window and are never released by delivery, so a
/// bucket's live lock count is (settlement rate × lock duration). uwrit now
/// maintains the total at the two sites that can change it; see
/// `uwrit::lock_sum`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Keep this on the three-path invariant. This says “two sites,” but the rollup is mutated at three: try_select_winner adds, chklocks decrements, and sweeplocks decrements after an upheld challenge. That third path was the source of the stale-cache bug fixed in this PR, so leaving the old count here undercuts the invariant documented in lock_sum. Please update this to say three sites and name them. The adjacent locks_t comment in sysio.uwrit.hpp also still says opreg::available() scans byunderwriter; it should identify locks as the authority and locksums as the O(1) read cache.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bc881dd.

You are right on both, and the second one is the more useful catch: the locks_t comment was still describing the scan that locksums exists to replace, so the file taught the old model at the point a reader is most likely to look it up.

sum_locks_inline's comment now enumerates all three with direction, and states why the count matters at this site specifically — it is the cross-contract reader, it trusts the rollup completely, and a bucket left positive after its last row is gone suppresses that collateral permanently.

locks_t now names locks as the AUTHORITY and locksums as the O(1) read cache available() reads, with the reason the old scan does not scale: locks are held for the full wall-clock challenge window and are never released by delivery, so a bucket's live count is (settlement rate × lock duration).

I swept for the claim rather than fixing only the two sites you named, and three more had available() reading the lock rows:

  • the file header's opening bullet, which had opreg reading "this table via a mirror"
  • the lock_entry doc, which called the triple the indexing surface available() uses and pointed cross-contract at locks_t rather than locksums_t
  • the README's locks row ("consulted by sysio.opreg::available()") and its integration note — both contradicting the locksums row one line below, which already said the read is O(1) instead of a scan

Two adjacent bits of staleness turned up while there. The header bullet described the composite as one of "two secondary indexes", byuwck and byunderwriter — neither exists; the composite is deliberately not a table-managed index (the locks_t comment says so directly), and the uint64 indexes are byuw, byuwreq and byexpire. And lock_entry said its rows are erased by release, an action this contract does not have — the erase sites are chklocks and sweeplocks, which is also where the two sub_locked_total calls live.

Comments and markdown only. sysio.uwrit.wasm, sysio.opreg.wasm and both .abi files rebuild byte-identical, so this commit carries no artifact.

uint64_t sum_locks_inline(name account, sysio::slug_name chain_code, sysio::slug_name token_code) {
uwrit::locks_t locks(opreg::UWRIT_ACCOUNT);
auto idx = locks.template get_index<"byuw"_n>();

uint64_t total = 0;
auto it = idx.lower_bound(account.value);
auto end = idx.upper_bound(account.value);
for (; it != end; ++it) {
if (it->chain_code != chain_code || it->token_code != token_code) continue;
// Saturating: amounts are uncapped uint64 (external-chain values); a
// wrapped subtotal would understate `reserved` and overstate availability.
total = opp::safe::add_sat_u64(total, it->amount);
}
return total;
uwrit::locksums_t sums(opreg::UWRIT_ACCOUNT);
uwrit::lock_sum_key pk{account, chain_code, token_code};
return sums.contains(pk) ? sums.get(pk).amount : 0;
}

/// Sum the pending (not-yet-flushed) withdraws on this contract for a given
Expand Down
Binary file modified contracts/sysio.opreg/sysio.opreg.wasm
Binary file not shown.
17 changes: 12 additions & 5 deletions contracts/sysio.uwrit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ rate every swap is charged.
against their WIRE account permissions, re-runs the LP variance check,
re-checks available collateral, then picks a winner.
- Writes one collateral lock per required leg and holds it for the full
wall-clock challenge window. **Locks are never released by delivery** — only
`chklocks` sweeps them once `expires_at_ms` passes.
wall-clock challenge window. **Locks are never released by delivery** — on the
healthy path only `chklocks` sweeps them, once `expires_at_ms` passes. An
UPHELD underwriter-fault challenge ends the window early instead: `sweeplocks`
erases that commitment's locks where they stand, which can be well before
`expires_at_ms`.
- Settles the winning swap against `sysio.reserv` and queues the outbound
`SWAP_REMIT`.
- Escrows and drains swap-from-WIRE requests (`fwqueue`), charging a revert fee
Expand Down Expand Up @@ -63,8 +66,9 @@ chain deregistered) refund in full.
| Table | Row type | Description |
|-------|----------|-------------|
| `uwconfig` | `uw_config` | Singleton: `fee_bps`, `collateral_lock_duration_ms`, `min_fromwire_amount`, `fromwire_revert_fee_bps`, `uwreq_pending_timeout_epochs`, `uwreq_retention_epochs` |
| `uwreqs` | `uw_request_t` | One row per swap intent — race state in `commits_by`, `winner`, lifecycle status, mirrored `variance_tolerance_bps`. Retained for `uwreq_retention_epochs` after ANY terminal transition — `COMPLETED` (after `chklocks` sweeps the final collateral lock; the reserve settlement itself already happened at winner selection, which is what made the row CONFIRMED), `REJECTED` (immediate failure via `reject_and_refund`), or `EXPIRED` (pending timeout, same path) — then erased by `pruneuwreqs` |
| `locks` | `lock_entry` | Flat per-leg lock vector consulted by `sysio.opreg::available()`. The `byexpire` secondary index lets `chklocks` sweep expired locks in one pass |
| `uwreqs` | `uw_request_t` | One row per swap intent — race state in `commits_by`, `winner`, lifecycle status, mirrored `variance_tolerance_bps`. Retained for `uwreq_retention_epochs` after ANY terminal transition — `COMPLETED` (once the last collateral lock is gone — normally when `chklocks` sweeps it at expiry, but earlier when `sweeplocks` erases it on an UPHELD challenge; both run the same `finalize_settled_uwreqs` tail. The reserve settlement itself already happened at winner selection, which is what made the row CONFIRMED), `REJECTED` (immediate failure via `reject_and_refund`), or `EXPIRED` (pending timeout, same path) — then erased by `pruneuwreqs` |
| `locks` | `lock_entry` | Flat per-leg lock vector consulted by `sysio.opreg::available()`. The `byexpire` secondary index lets `chklocks` sweep expired locks oldest-first, up to its per-epoch budget |
| `locksums` | `lock_sum` | Materialized Σ `lock_entry.amount` per `(underwriter, chain_code, token_code)` bucket — the "locked" half of `sysio.opreg::available()`, read O(1) instead of scanning `locks`. Written by exactly three paths, all in this contract: `try_select_winner` ADDS (on a win), `chklocks` DECREMENTS (healthy release at expiry), and `sweeplocks` DECREMENTS (erasing a commitment's held locks on an UPHELD challenge). A bucket's row is erased once its total reaches zero, so an absent row reads as zero. Any new erase path must decrement too — the rollup is authoritative for `available()`, so a bucket left positive after its last row is gone suppresses that collateral permanently |
| `fwqueue` | `fromwire_q` | Escrowed swap-from-WIRE requests awaiting drain. `byepoch` secondary index |
| `uwcounters` | `uw_counters` | Monotonic id allocators (uwreq ids, lock ids) |

Expand All @@ -77,8 +81,11 @@ chain deregistered) refund in full.
| `rcrdcommit` | `sysio.msgch` | Record an underwriter's per-leg `UNDERWRITE_INTENT_COMMIT` bytes; resolves the race once both legs are present |
| `swapfromwire` | `user` | Escrow WIRE and enqueue a swap-FROM-WIRE request |
| `drainfwq` | `sysio.epoch` or self | Drain the from-WIRE queue: settle what prices, revert the rest (charging the revert fee on caller-fault causes) |
| `chklocks` | `sysio.epoch` or self | Sweep collateral locks whose wall-clock window has expired |
| `chklocks` | `sysio.epoch` or self | Sweep collateral locks whose wall-clock window has expired, oldest-first, EXAMINING at most `max_rows` rows per call (`advance` passes `MAX_LOCK_RELEASE_PER_EPOCH`) — the budget counts rows examined rather than locks released, so held locks and the challenge pokes they generate are bounded by it too; an oversized expiry burst drains across later epochs rather than aborting `advance` |
| `pruneuwreqs` | `sysio.epoch` or self | Expire timed-out PENDING uwreqs and erase terminal rows past their retention window |
| `holdlocks` | `sysio.chalg` | Mark a commitment's winning locks as held by an OPEN underwriter-fault challenge (WIRE-297); held locks are skipped by `chklocks` instead of released |
| `freelocks` | `sysio.chalg` | Clear the hold after a REJECTED or LAPSED challenge, so the next `chklocks` releases the locks normally |
| `sweeplocks` | `sysio.chalg` | Erase a commitment's held locks after an UPHELD challenge — the underwriter is already SLASHED, so each `releaselock` takes its deferred-slash branch. Decrements `locksums` like `chklocks` does |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Qualify the early upheld-challenge sweep. This new row documents that sweeplocks erases and debits held locks after an UPHELD challenge, potentially before expires_at_ms, but the Responsibility summary above still says every lock is held for the full window and only chklocks sweeps it after expiry. Qualify that summary as the normal healthy path and mention the early slash-sweep so the two sections describe the same lifecycle.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f3e23c2. The Responsibility summary now marks the chklocks route as the healthy path and names the early sweeplocks erase alongside it, so it and the actions table describe the same lifecycle.

While there I found the chklocks row in that table stale in the same way and fixed it too: it read as a bound on locks released, which is what the budget counted before this branch made it challenge-aware. It counts rows EXAMINED now.

Fuller write-up: #563 (comment)

| `sumlocks` | read-only | Sum an underwriter's active locks for a `(chain, token)` bucket — the lock half of `sysio.opreg::available()` |

## Dependencies
Expand Down
Loading
Loading