Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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>
// Canonical sysio.system emissions types + compute_epoch_emission. The
// [[sysio::contract("sysio.system")]] attribute on emission_config / t5_state
Expand Down Expand Up @@ -359,7 +360,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 @@ -345,28 +345,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.
8 changes: 6 additions & 2 deletions contracts/sysio.uwrit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ chain deregistered) refund in full.
|-------|----------|-------------|
| `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` |

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] Include sweeplocks in the COMPLETED transition. This row still defines COMPLETED only as the result of chklocks removing the final lock, but an UPHELD challenge calls sweeplocks, which erases the held locks and invokes the same finalize_settled_uwreqs tail before expiry. Update this row and the matching winner-selection comments in sysio.uwrit.cpp that still say locks are released only by chklocks, so all lifecycle documentation includes the early upheld-challenge path.

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 0902f94. You were right, and finalize_settled_uwreqs was already saying so from the inside — its own doc reads "Shared by chklocks (natural expiry) and sweeplocks (an UPHELD underwriter challenge's slash-sweep)". The function knew it had two callers; the lifecycle documentation around it did not.

I swept for the claim rather than fixing only this row, and there were four: the README's uwreqs row, try_select_winner's doc block, the inline comment at the lock push, and pruneuwreqs' note on why CONFIRMED never reaches it. A fifth, the collateral_lock_duration_ms field doc, was silent on the window being cut short and now says so.

Comments and markdown only — no __LINE__ or __FILE__ anywhere in the contract, so codegen is untouched and the committed artifacts stand.

Fuller write-up: #563 (comment)

| `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 |
| `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 +78,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, at most `max_rows` per call (`advance` passes `MAX_LOCK_RELEASE_PER_EPOCH`); 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
161 changes: 148 additions & 13 deletions contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ namespace sysio {
// subsequent epochs.
static constexpr uint32_t MAX_UWREQ_PRUNE_PER_EPOCH = 32;

// Locks `sysio.epoch::advance` passes to `chklocks` each epoch. Sized
// like MAX_UWREQ_PRUNE_PER_EPOCH / MAX_FWQ_DRAIN_PER_EPOCH, and for the
// same reason — but the exposure here is sharper than either, because
// lock EXPIRY is inherently bursty. Every lock is stamped
// `now + collateral_lock_duration_ms` at creation, so a burst of
// settlements inside one epoch produces a burst of expiries inside one
// epoch, exactly one lock-duration later. Sustained swap traffic
// therefore presents `chklocks` with a whole epoch's settlements at
// once, and per expired lock the sweep does an inline
// `opreg::releaselock` dispatch plus an erase — all inside advance's
// hard, uncatchable transaction CPU deadline.
//
// Unbounded, a large enough expiry burst aborts `advance`; and because
// those same locks are still expired at the next advance, it aborts
// identically every epoch thereafter — a PERMANENT chain-wide epoch
// stall rather than a transient one. Bounded, an oversized burst is
// just release latency that drains across subsequent epochs, which is
// harmless: the challenge window has already closed, so a lock freed an
// epoch or two late costs only a brief overstatement of the
// underwriter's reserved collateral.
static constexpr uint32_t MAX_LOCK_RELEASE_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
Expand Down Expand Up @@ -344,8 +366,11 @@ namespace sysio {
/// (delivery itself is implicit — there is no SWAP_REMIT ack; the
/// lock window expiring IS the settlement horizon).
///
/// This sweep is the ONLY lock-release path: locks are a wall-clock
/// challenge window (12h default) and are never released by delivery.
/// This sweep is the only HEALTHY lock-release path: locks are a
/// wall-clock challenge window (12h default) and are never released by
/// delivery. It is not the only path that ERASES a lock — `sweeplocks`
/// does too, on an UPHELD challenge (see `lock_sum`, whose rollup both
/// must maintain).
///
/// EXCEPTION (WIRE-297): a lock whose `challenge_id` is non-zero — an
/// underwriter-fault challenge is OPEN against its commitment — is NOT
Expand All @@ -354,8 +379,34 @@ namespace sysio {
/// with the underwriter slashed (`sweeplocks`) or clears the hold
/// (`freelocks`) so the NEXT sweep releases them normally. The epoch
/// tick is thereby the challenge system's only cadence.
///
/// Budget-bounded, mirroring `pruneuwreqs` / `drainfwq`: walks the
/// `byexpire` index in ascending `expires_at_ms` and EXAMINES at most
/// `max_rows` rows (`max_rows == 0` is a no-op). Inlined from
/// `sysio.epoch::advance` with `MAX_LOCK_RELEASE_PER_EPOCH`; also
/// invocable by `sysio.uwrit` itself with a caller-chosen budget for a
/// manual backlog drain. NEVER throws past the auth gate: it runs inline
/// inside `advance`, where an abort stalls epoch progress chain-wide.
///
/// The budget counts rows EXAMINED, not locks released — held locks and
/// the challenge pokes they generate cost real work too. Bounding only
/// releases would let an arbitrary number of held rows be scanned and an
/// arbitrary number of distinct challenges fan out an inline
/// `chkuwchal` each, which is exactly the unbounded `advance` work this
/// bound exists to remove. `open_challenges` is therefore bounded by
/// `max_rows` as a consequence of the same counter.
///
/// Ascending-expiry order makes the bound a FIFO drain, so an oversized
/// burst simply spreads across subsequent epochs. Held locks are the one
/// thing that can sit at the head of that queue without leaving it: they
/// are skipped, not erased, so while more than `max_rows` challenges are
/// open the rows behind them wait. That is bounded and self-clearing
/// rather than a stall — each sweep pokes the challenges it can see, and
/// a resolved challenge removes its locks (`sweeplocks`) or clears their
/// hold (`freelocks`), letting the window advance on the next tick.

[[sysio::action]]
void chklocks();
void chklocks(uint32_t max_rows);

/// Mark the winning underwriter's locks for `uwreq_id` as held by the OPEN underwriter-
/// fault challenge `chal_id` (WIRE-297). Auth: `sysio.chalg`, inlined from `openuwchal`.
Expand Down Expand Up @@ -489,6 +540,30 @@ namespace sysio {
/// a slash, the outpost routes seized collateral to that reserve via
/// `ReserveAmount`, even when multiple reserves exist for the same
/// `(chain_code, token_code)` pair.
/// The `(account, chain_code, token_code)` collateral-bucket digest:
/// the three uint64 identities packed little-endian into 24 bytes and
/// hashed. 3 × uint64 = 192 bits does not fit `uint128_t`, so the triple
/// is hashed to land in a `checksum256`.
///
/// SINGLE SOURCE for that encoding, and it must stay that way.
/// `lock_entry::by_underwriter_ck()` says which bucket a lock row
/// belongs to; `lock_sum_key::primary_key()` addresses that bucket's
/// materialized total. If the two derivations ever diverged, the rollup
/// would be keyed differently from the rows it summarizes and every
/// reader would silently observe zero locked — collateral already
/// committed to a live lock would look spendable. Both call this, so
/// they cannot diverge.
static checksum256 compose_account_chain_token_ck(name account,
sysio::slug_name chain_code,
sysio::slug_name token_code) {
std::array<uint8_t, 24> buf{};
uint64_t acc_v = account.value;
std::memcpy(buf.data() + 0, &acc_v, 8);
std::memcpy(buf.data() + 8, &chain_code.value, 8);
std::memcpy(buf.data() + 16, &token_code.value, 8);
return sysio::sha256(reinterpret_cast<const char*>(buf.data()), buf.size());
}

struct lock_key {
uint64_t lock_id;
uint64_t primary_key() const { return lock_id; }
Expand All @@ -511,17 +586,11 @@ namespace sysio {
/// `byexpire` so `chklocks` sweeps expired locks in ascending order.
uint64_t expires_at_ms = 0;

/// Composite checksum index for opreg's `available()` rollup:
/// `sha256(underwriter.value || chain_code.value || token_code.value)`
/// packed as 24 little-endian bytes. 3 × uint64 = 192 bits doesn't
/// fit `uint128_t`, so we hash the triple to land in `checksum256`.
/// Which collateral bucket this lock belongs to — see
/// `compose_account_chain_token_ck`, the single source of that
/// encoding, shared with `lock_sum_key::primary_key()`.
checksum256 by_underwriter_ck() const {
std::array<uint8_t, 24> buf{};
uint64_t uw_v = underwriter.value;
std::memcpy(buf.data() + 0, &uw_v, 8);
std::memcpy(buf.data() + 8, &chain_code.value, 8);
std::memcpy(buf.data() + 16, &token_code.value, 8);
return sysio::sha256(reinterpret_cast<const char*>(buf.data()), buf.size());
return compose_account_chain_token_ck(underwriter, chain_code, token_code);
}
/// Non-zero while an underwriter-fault challenge (the `sysio.chalg::uwchals` row id) is
/// OPEN against this lock's commitment (WIRE-297). A held lock is NOT released at
Expand Down Expand Up @@ -560,6 +629,72 @@ namespace sysio {
sysio::const_mem_fun<lock_entry, uint64_t, &lock_entry::by_expires_at_ms>>
>;

/// Primary key of `locksums`: one (underwriter, chain_code, token_code)
/// collateral bucket, addressed by the SAME digest
/// `lock_entry::by_underwriter_ck()` uses to say which bucket a lock row
/// belongs to — both call `compose_account_chain_token_ck`.
struct lock_sum_key {
name underwriter;
sysio::slug_name chain_code;
sysio::slug_name token_code;
checksum256 primary_key() const {

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.

[P2] Use one shared bucket-key helper. lock_sum_key::primary_key() duplicates lock_entry::by_underwriter_ck() byte-for-byte, even though the rollup's correctness depends on both encodings remaining identical. Extract the 24-byte hash derivation into a shared helper and call it from both sites; otherwise a later change can silently separate lock rows from the cache bucket used by the readers.

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 — and it was worse than it looked: there were three copies of that derivation, not two. Alongside lock_entry::by_underwriter_ck() and lock_sum_key::primary_key(), sysio.uwrit.cpp carried a private compose_account_chain_token_ck() with the same 24-byte packing and zero callers — vestigial from the v6 split-index change, kept "for any caller that still needs to derive the same key".

Rather than coin a new name I promoted that existing one into the header as the single source, since it is already this repo's name for the concept:

static checksum256 compose_account_chain_token_ck(name account,
                                                  sysio::slug_name chain_code,
                                                  sysio::slug_name token_code);

by_underwriter_ck() and lock_sum_key::primary_key() are now one-line calls to it, and the dead .cpp copy is deleted. Its doc comment carries the consequence you identified, so the next person to touch it sees why it is shared: if the two derivations diverge, the rollup is keyed differently from the rows it summarizes and every reader silently observes zero locked — collateral committed to a live lock looks spendable.

Your framing is the better one and I have adopted it. My original comment said the encodings "must not diverge", which is a note asking a future reader to be careful; making them one function means they cannot. Contracts unit suite re-run green after the change.

return compose_account_chain_token_ck(underwriter, chain_code, token_code);
}
SYSLIB_SERIALIZE(lock_sum_key, (underwriter)(chain_code)(token_code))
};

/// Materialized Σ `lock_entry.amount` for one (underwriter, chain_code,
/// token_code) bucket — the "locked" half of `sysio.opreg::available()`.
///
/// A CACHE of the `locks` table with exactly ONE writer: every code path
/// that can change a bucket's total lives in this contract. There are
/// THREE, and any new one inherits the same obligation — nothing
/// structural enforces it:
///
/// * `try_select_winner` — ADDS, one lock per required leg, on a win.
/// * `chklocks` — DECREMENTS, releasing locks at expiry.
/// * `sweeplocks` — DECREMENTS, erasing the held locks of a commitment
/// whose underwriter-fault challenge was UPHELD (WIRE-297). This one
/// runs OUTSIDE `chklocks`, which is exactly why it was missed once:
/// this block previously said `chklocks` was the sole erase path, and
/// `sweeplocks` erased rows without decrementing.
///
/// Getting that wrong is permanent and silent rather than merely stale:
/// the rollup is authoritative for `sysio.opreg::available()`, so a bucket
/// left positive after its last row is gone suppresses that collateral
/// forever — nothing decrements it again, because the rows that would
/// have are already erased.
///
/// A row is erased when its total reaches zero, so an absent row reads as
/// zero and the table holds only live buckets.
///
/// It exists because the derivation it replaces does not scale. Both
/// `sum_locks_inline` rollups (here and in sysio.opreg) previously
/// walked every lock row an underwriter held, each documenting the
/// assumption that "per-underwriter lock counts are O(1)-ish so the scan
/// is cheap". That is false under sustained swap traffic: locks are held
/// for the full wall-clock challenge window
/// (`collateral_lock_duration_ms`, 12h default) and are NEVER released
/// by delivery, so a bucket's live lock count is
/// (settlement rate × lock duration) — unbounded within the window. The
/// scan ran per candidate inside `try_select_winner` (up to
/// MAX_UWREQ_CANDIDATES of them per uwreq), i.e. inside the same
/// consensus-dispatch CPU budget whose overrun stalls the chain.
///
/// `sumlocks` reads this rollup, so it stays the cheap external answer
/// to "how much of this bucket is locked"; the authoritative recompute
/// is the `locks` table itself, which the contract tests scan and
/// compare against this total.
struct [[sysio::table("locksums")]] lock_sum {
name underwriter;
sysio::slug_name chain_code;
sysio::slug_name token_code;
uint64_t amount = 0;
SYSLIB_SERIALIZE(lock_sum, (underwriter)(chain_code)(token_code)(amount))
};

using locksums_t = sysio::kv::table<"locksums"_n, lock_sum_key, lock_sum>;

/// Per-underwriter race entry inside an UWREQ row. Tracks when each
/// leg of a dual-COMMIT pair arrived so `try_select_winner` can
/// resolve the race deterministically. Each leg's COMMIT is an
Expand Down
Loading