diff --git a/contracts/sysio.authex/sysio.authex.wasm b/contracts/sysio.authex/sysio.authex.wasm index df1e85879b..75315b17b3 100755 Binary files a/contracts/sysio.authex/sysio.authex.wasm and b/contracts/sysio.authex/sysio.authex.wasm differ diff --git a/contracts/sysio.chalg/CMakeLists.txt b/contracts/sysio.chalg/CMakeLists.txt index eaee14775a..f68d36e68a 100644 --- a/contracts/sysio.chalg/CMakeLists.txt +++ b/contracts/sysio.chalg/CMakeLists.txt @@ -35,6 +35,12 @@ if(BUILD_SYSTEM_CONTRACTS) $ $ $ + $ + $ + $ + $ + $ + $ ) target_link_libraries(${target} diff --git a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp index 1e6ac3e194..07821ec9f3 100644 --- a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp +++ b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp @@ -70,6 +70,137 @@ namespace sysio { [[sysio::action]] void chkdispute(uint64_t dispute_id); + // ----------------------------------------------------------------------- + // Underwriter-fault challenge (WIRE-297) + // ----------------------------------------------------------------------- + // + // The OPP envelope dispute's sibling. Same adjudication machinery — Tier-1 electorate + // snapshotted at open, fixed quorum floor(N/2)+1, record-only votes, permissionless tally + // crank, resolution through the `slashop` -> `opreg::slash` chokepoint — with the + // differences the problem forces: + // + // * A human FILES it (the depot can observe envelope divergence itself; a source-chain + // fault it cannot — someone must allege it), so filing is permissionless and priced + // with a challenger bond. + // * The ballot is a VERDICT (uphold / reject), not a choice among candidate versions. + // * It never pauses the epoch: an unresolved challenge is survivable — the challenged + // collateral lock simply lapses back to a normal release — so the chain keeps advancing. + // * The vote deadline is the commitment's own collateral-lock expiry, and there is NO + // after-deadline relaxed tally: a challenge "must be voted on before the window + // expires" (Jonathan, 2026-07-27); past it the challenge LAPSES with a full bond + // refund. Envelope disputes relax after their deadline only because a paused chain + // MUST eventually resolve. + + /// The fault a challenger alleges against the winning underwriter's commit. The council + /// adjudicates the allegation against source-chain state (not visible to the depot) and + /// votes; the enum classifies the case for the audit row and indexers. + enum class underwrite_fault_reason : uint8_t { + SOURCE_DEPOSIT_MISSING = 0, ///< the committed source deposit does not exist on the source chain + SOURCE_DEPOSIT_MISMATCH = 1, ///< the deposit exists but its parameters do not match the commit + NON_CANONICAL_ENVELOPE = 2, ///< the commit rides an envelope later adjudicated non-canonical + }; + + /// A Tier-1 voter's ballot in an underwriter challenge. The two reject flavours let the + /// council separate an honest mistake (bond refunded to the challenger) from a frivolous or + /// malicious challenge (bond forfeited to the wrongly-challenged underwriter) — forfeiture + /// only ever happens by explicit council judgment, never by default. + enum class uwchal_ballot : uint8_t { + UPHOLD = 0, ///< fault proven — slash the underwriter; bond returns to the challenger + REJECT_REFUND = 1, ///< no fault; honest mistake — bond returns to the challenger + REJECT_FORFEIT = 2, ///< no fault; frivolous or malicious — bond goes to the underwriter + }; + + /// Terminal verdict of a challenge (NONE while it is OPEN). + enum class uwchal_verdict : uint8_t { + NONE = 0, ///< challenge still open + UPHELD = 1, ///< underwriter slashed; bond returned to the challenger + REJECTED_REFUND = 2, ///< council rejected — honest mistake; bond returned to the challenger + REJECTED_FORFEIT = 3, ///< council rejected — frivolous; bond forfeited to the underwriter + LAPSED = 4, ///< lock window expired with no quorum — bond returned; nobody punished + }; + + /// Open a challenge against the WINNING underwriter of a CONFIRMED underwrite request, + /// alleging its commit was faulty. Permissionless — anyone may file — but time-gated to the + /// commitment's collateral-lock window and priced: the challenger escrows the WIRE value of + /// the challenged locks as a bond (`uwchalbond` quotes it; both run one shared formula, so + /// the quote can never drift from the charge — the `split_wire_fee` principle). + /// + /// Effects, in order: validates the commitment (uwreq CONFIRMED, `underwriter` is its + /// winner, no prior challenge for this commitment — a verdict is final per commitment; + /// other commitments by the same underwriter are independently challengeable), snapshots + /// the Tier-1 electorate + quorum exactly as `opendispute` does, escrows the bond (inline + /// `sysio.token::transfer` under the challenger's own authority), records the OPEN row, and + /// marks the locks via `sysio.uwrit::holdlocks` — a held lock is NOT released at expiry + /// while its challenge is open. uwrit re-validates lock liveness authoritatively, so a + /// stale filing aborts whole, bond escrow included. + /// + /// `reason` is the numeric `underwrite_fault_reason` value (checked through + /// `magic_enum::enum_cast` at this trust boundary); `detail` carries the challenger's + /// free-text evidence context for the council (source tx ids, observed amounts, notes). + [[sysio::action]] + void openuwchal(name challenger, + uint64_t uwreq_id, + name underwriter, + uint8_t reason, + std::string detail); + + /// Cast a Tier-1 ballot in an open underwriter challenge. `ballot` is the numeric + /// `uwchal_ballot` value. Same eligibility rules as `votedispute`: the voter must belong to + /// the challenge's snapshotted electorate, one vote per owner. Record-only — the tally + /// lives in `chkuwchal`, mirroring the votedispute/chkdispute split. + [[sysio::action]] + void voteuwchal(name owner, uint64_t chal_id, uint8_t ballot); + + /// Permissionless tally crank — `chkdispute`'s sibling. Nobody has to run it on a cadence: + /// `sysio.uwrit::chklocks` (inlined from every `sysio.epoch::advance`) pokes it for any + /// expired-but-challenged lock, which works precisely because the chain is NOT paused + /// during an underwriter challenge. A manual call after the deciding vote resolves sooner. + /// + /// Resolution rules (N/Q = the snapshotted electorate size / quorum, fixed at open): + /// * UPHOLD ballots reach Q, any time — UPHELD: `slashop` the underwriter, then + /// `sysio.uwrit::sweeplocks` releases the now-deferred-slash locks (the slash lands + /// first — inline actions run depth-first in send order — so `opreg::releaselock` + /// takes its SLASHED branch, debiting the locked collateral and emitting the outbound + /// SLASH attestations); the bond is credited back to the challenger. + /// * REJECT ballots (both flavours combined) reach Q, any time — REJECTED: disposition by + /// majority among the rejectors, a tie favouring refund; `sysio.uwrit::freelocks` + /// clears the hold and the locks live out their natural window. + /// * `deadline_ms` (the locks' own expiry) passes with neither threshold met — LAPSED: + /// bond credited back to the challenger, holds cleared, the locks release on the next + /// normal sweep. No verdict, nobody punished. + /// + /// The bond is CREDITED to a claimable balance, never transferred from here — see + /// `claimbond`. Resolution also compacts the challenge row and erases its ballots. + [[sysio::action]] + void chkuwchal(uint64_t chal_id); + + /// Withdraw the WIRE this contract owes `account` from resolved underwriter challenges — + /// a refunded challenger bond, or one forfeited to a wrongly-challenged underwriter. + /// Erases the credit row and transfers the whole balance. Auth: `account` (the + /// `sysio.dclaim::claim` pattern). + /// + /// Why the payout is a PULL and not a push: `chkuwchal` can run inline under the epoch tick + /// (`sysio.epoch::advance` -> `sysio.uwrit::chklocks` -> `chkuwchal`), and + /// `sysio.token::transfer` notifies its recipient — `require_recipient(to)` runs whatever + /// code that account has deployed. A challenger contract that allows its outgoing escrow + /// and asserts on the incoming refund would abort the entire `advance` transaction; the + /// rollback leaves the challenge OPEN with its locks still held, so every later sweep + /// re-pokes the same failing transfer and epoch advancement stalls chain-wide + /// (`epoch-stall-is-fatal`, the same hazard `opreg::releaselock` clamps for). Crediting a + /// balance keeps every account the crank touches system-owned: the only transaction a + /// hostile recipient can abort is its own claim, and only its own funds are stranded. + [[sysio::action]] + void claimbond(name account); + + /// Read-only: the WIRE bond `openuwchal` would require RIGHT NOW to challenge this + /// commitment — the winning underwriter's live lock amounts, each valued through its own + /// reserve's current books (`opp::amm::token_to_wire`), summed. Returns 0 when the + /// commitment is not currently challengeable (no CONFIRMED uwreq, `underwriter` is not its + /// winner, a challenge already exists, or no live unheld locks) or when a leg cannot be + /// priced. Shares its formula helper with `openuwchal`. + [[sysio::action, sysio::read_only]] + uint64_t uwchalbond(uint64_t uwreq_id, name underwriter); + // ----------------------------------------------------------------------- // Tables // ----------------------------------------------------------------------- @@ -153,12 +284,118 @@ namespace sysio { using chalgstate_t = sysio::kv::global<"chalgstate"_n, chalg_state>; + /// Challenge primary key (auto-incrementing id). + struct uwchal_key { + uint64_t id; + uint64_t primary_key() const { return id; } + SYSLIB_SERIALIZE(uwchal_key, (id)) + }; + + /// An underwriter-fault challenge. Opened permissionlessly against one CONFIRMED + /// commitment; resolved by a Tier-1 vote or lapsed at the lock window's end. The row + /// survives resolution as the guard that a commitment is challenged at most once, ever + /// (mirrors one-dispute-per-(outpost, epoch)) and as the verdict record. + /// + /// It does NOT survive at full size: `chkuwchal` compacts it to a fixed-width tombstone on + /// resolution, clearing the two variable-length fields (`detail`, `electorate`) and erasing + /// the challenge's ballot rows. Filing is permissionless and the bond comes back on every + /// non-forfeit outcome, so recycled bond capital could otherwise pin unbounded + /// caller-controlled bytes in RAM billed to `sysio`; compacting bounds the retained + /// variable-length state by the number of CONCURRENTLY OPEN challenges — each backed by a + /// live lock set and an escrowed bond — rather than by every challenge ever filed. The full + /// filing and every ballot stay permanently readable in the action-trace history that + /// indexers consume. + struct [[sysio::table("uwchals")]] uwchal_entry { + uint64_t id; + uint64_t uwreq_id; ///< the challenged commitment's uwreq + name underwriter; ///< the CONFIRMED winner under challenge + name challenger; ///< who filed and posted the bond + underwrite_fault_reason reason; ///< alleged fault class + /// Challenger's free-text evidence context, capped at `max_uwchal_detail_bytes` on + /// filing and CLEARED on resolution (it is council input, not a permanent record). + std::string detail; + opp::types::DisputeStatus status; ///< OPEN / RESOLVED — same lifecycle enum as `disputes` + uwchal_verdict verdict; ///< NONE while OPEN; the terminal outcome after + uint64_t bond_amount = 0; ///< WIRE the challenger escrowed (9-decimal units) + /// The challenged locks' `expires_at_ms` — the vote deadline. Past it an unresolved + /// challenge LAPSES (bond refunded); there is no after-deadline relaxed tally. + uint64_t deadline_ms = 0; + time_point opened_at{}; + uint8_t network_gen = 0; ///< roa network generation the electorate was drawn from + /// The Tier-1 electorate + quorum, snapshotted at open — same discipline as + /// `dispute_entry`: voter eligibility (`voteuwchal`) and the tally denominator + /// (`chkuwchal`) come from one list frozen when the challenge opened, so registrations + /// after open can neither join nor dilute it. CLEARED on resolution together with the + /// ballots it gated; `quorum` is kept as the fixed-width record of the threshold used. + std::vector electorate; + uint32_t quorum = 0; + + /// Uniqueness: one challenge EVER per (uwreq, underwriter) commitment. + uint128_t by_uwreq_underwriter() const { + return (static_cast(uwreq_id) << 64) | underwriter.value; + } + + SYSLIB_SERIALIZE(uwchal_entry, + (id)(uwreq_id)(underwriter)(challenger)(reason)(detail)(status)(verdict) + (bond_amount)(deadline_ms)(opened_at)(network_gen)(electorate)(quorum)) + }; + + using uwchals_t = sysio::kv::table<"uwchals"_n, uwchal_key, uwchal_entry, + sysio::kv::index<"byuwrequw"_n, + sysio::const_mem_fun> + >; + + /// Challenge-vote primary key (the voting Tier-1 owner). The vote table is scoped by + /// `chal_id`, so the owner alone is unique within a challenge. + struct uwchal_vote_key { + uint64_t owner; + uint64_t primary_key() const { return owner; } + SYSLIB_SERIALIZE(uwchal_vote_key, (owner)) + }; + + /// One Tier-1 ballot in an underwriter challenge. Scoped by `chal_id`. Live only while the + /// challenge is OPEN — it is the tally input and the one-vote-per-owner gate, and neither + /// applies to a resolved challenge, so `chkuwchal` erases the scope on resolution. + struct [[sysio::table("uwchalvote")]] uwchal_vote { + name owner; + uwchal_ballot ballot; + time_point voted_at{}; + + SYSLIB_SERIALIZE(uwchal_vote, (owner)(ballot)(voted_at)) + }; + + using uwchalvotes_t = + sysio::kv::scoped_table<"uwchalvote"_n, uwchal_vote_key, uwchal_vote>; + + /// Claimable-bond primary key (the account the payout is owed to). + struct bond_credit_key { + uint64_t account; + uint64_t primary_key() const { return account; } + SYSLIB_SERIALIZE(bond_credit_key, (account)) + }; + + /// WIRE this contract holds on an account's behalf out of a resolved challenge's escrow. + /// `chkuwchal` credits it; `claimbond` pays it out and erases the row. Credits ACCUMULATE + /// per account, so the table is bounded by the number of accounts with an unclaimed payout + /// — not by the number of challenges ever resolved. + struct [[sysio::table("bondcredits")]] bond_credit { + name account; + uint64_t amount = 0; ///< unclaimed WIRE, 9-decimal units + + SYSLIB_SERIALIZE(bond_credit, (account)(amount)) + }; + + using bondcredits_t = sysio::kv::table<"bondcredits"_n, bond_credit_key, bond_credit>; + private: // Well-known accounts static constexpr name EPOCH_ACCOUNT = "sysio.epoch"_n; static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; static constexpr name OPREG_ACCOUNT = "sysio.opreg"_n; static constexpr name ROA_ACCOUNT = "sysio.roa"_n; + static constexpr name UWRIT_ACCOUNT = "sysio.uwrit"_n; + static constexpr name RESERV_ACCOUNT = "sysio.reserv"_n; + static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; using DisputeStatus = opp::types::DisputeStatus; using NodeOwnerTier = opp::types::NodeOwnerTier; @@ -167,6 +404,14 @@ namespace sysio { /// majority of the dispute's snapshotted electorate) can resolve; after it, the denominator /// relaxes to a quorum of cast votes plus a strict majority of cast votes. static constexpr uint32_t dispute_deadline_sec = 24 * 60 * 60; + + /// Upper bound on `openuwchal`'s caller-controlled `detail` note. Filing is permissionless + /// and the bond returns on every non-forfeit outcome, so recycled bond capital could + /// otherwise pin near-arbitrary payloads in RAM billed to `sysio`. This caps ONE note; + /// `chkuwchal`'s compaction of the resolved row is what caps the total (the two together + /// bound retained challenge bytes at `max_uwchal_detail_bytes` x concurrently-open + /// challenges). 1 KiB keeps the allegation note useful for the council. + static constexpr size_t max_uwchal_detail_bytes = 1024; }; } // namespace sysio diff --git a/contracts/sysio.chalg/src/sysio.chalg.cpp b/contracts/sysio.chalg/src/sysio.chalg.cpp index 543a7e6182..1b434711e2 100644 --- a/contracts/sysio.chalg/src/sysio.chalg.cpp +++ b/contracts/sysio.chalg/src/sysio.chalg.cpp @@ -1,19 +1,132 @@ #include -#include // authoritative T1 electorate snapshot at opendispute +#include // authoritative T1 electorate snapshot at open +#include // uwreq + lock reads for the underwriter challenge +#include // reserve books that price the challenge bond +#include // operator status guard before slashing +#include // token_to_wire — the bond's WIRE valuation #include #include +#include namespace sysio { using opp::types::DisputeStatus; using opp::types::NodeOwnerTier; +using opp::types::OperatorStatus; +using opp::types::UnderwriteRequestStatus; // System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract // model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. constexpr name ram_payer = "sysio"_n; +namespace { + +/// WIRE asset symbol for the challenge-bond escrow + payouts (9 decimals — mirrors +/// `sysio.reserv`'s WIRE_SYMBOL; deliberately NOT opreg's CORE_SYM). +constexpr sysio::symbol WIRE_SYMBOL{"WIRE", 9}; + +/// Wall-clock now in ms — the clock `sysio.uwrit`'s lock window runs on +/// (`lock_entry.expires_at_ms`), so challenge deadlines compare like-for-like. +uint64_t current_time_ms() { + return static_cast(current_time_point().sec_since_epoch()) * 1000; +} + +/// Snapshot the Tier-1 electorate: the Tier-1 rows of `sysio.roa::nodeowners` for `network_gen`, +/// walked via the `bytier` index (bounded by the Tier-1 registration cap). Shared by +/// `opendispute` and `openuwchal` so both case types freeze eligibility and quorum from the SAME +/// list at open — later registrations or a generation rotation can never change an in-flight +/// case's electorate. +std::vector snapshot_t1_electorate(name roa_account, uint8_t network_gen) { + roa::nodeowners_t nodeowners(roa_account, network_gen); + auto by_tier = nodeowners.get_index<"bytier"_n>(); + const uint64_t t1_tier = magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T1); + std::vector electorate; + for (auto it = by_tier.lower_bound(t1_tier); it != by_tier.end() && it->by_tier() == t1_tier; ++it) { + electorate.push_back(it->owner); + } + return electorate; +} + +/// The bond quote for challenging one commitment — see `compute_uwchal_bond`. +struct uwchal_bond_quote { + uint64_t bond = 0; ///< Σ `token_to_wire(leg)` over the winner's live locks; 0 = unquotable + uint64_t deadline_ms = 0; ///< the locks' shared `expires_at_ms` — becomes the vote deadline + uint32_t live_locks = 0; ///< locks counted into the bond +}; + +/// THE bond formula — one function behind both the read-only quote (`uwchalbond`) and the charge +/// (`openuwchal`), so the two can never drift (the `split_wire_fee` principle). +/// +/// "The full collateral in question" (Jonathan, 2026-07-28) is the winning underwriter's lock +/// set for this uwreq. Locks are denominated per-leg in the leg's native token, so each is +/// valued in WIRE through its OWN reserve's live books — the same books the swap rode. The sum +/// accumulates in u128 (two stacked uint64 legs can carry — the `split_wire_fee` wrap lesson). +/// +/// Returns a zeroed quote (unchallengeable / unquotable) when: the winner has no locks for the +/// uwreq, any lock is already held by another challenge or already expired (filing is time-gated +/// to the live window), a leg's reserve row is gone, a leg's books price it to zero, or the sum +/// does not fit uint64. +uwchal_bond_quote compute_uwchal_bond(name uwrit_account, name reserv_account, + uint64_t uwreq_id, name underwriter) { + uwchal_bond_quote quote; + const uint64_t now_ms = current_time_ms(); + + uwrit::locks_t locks(uwrit_account); + reserve::reserves_t reserves(reserv_account); + + opp::amm::u128 total = 0; + auto by_uwreq = locks.get_index<"byuwreq"_n>(); + for (auto it = by_uwreq.lower_bound(uwreq_id); + it != by_uwreq.end() && it->uwreq_id == uwreq_id; ++it) { + if (it->underwriter != underwriter) continue; + if (it->challenge_id != 0 || now_ms >= it->expires_at_ms) return uwchal_bond_quote{}; + auto rit = reserves.find(reserve::reserve_key{it->chain_code, it->token_code, it->reserve_code}); + if (rit == reserves.end()) return uwchal_bond_quote{}; + const uint64_t leg_wire = opp::amm::token_to_wire(rit->reserve_chain_amount, + rit->reserve_wire_amount, + rit->connector_weight_bps, + it->amount); + if (leg_wire == 0) return uwchal_bond_quote{}; + total += leg_wire; + quote.deadline_ms = it->expires_at_ms; + ++quote.live_locks; + } + if (quote.live_locks == 0) return uwchal_bond_quote{}; + if (total > std::numeric_limits::max()) return uwchal_bond_quote{}; + quote.bond = static_cast(total); + return quote; +} + +/// Credit `amount` WIRE to `account`'s claimable-bond balance, accumulating onto an existing row. +/// +/// This is how a resolved challenge's escrow leaves `chkuwchal` — deliberately NOT a transfer. +/// `chkuwchal` can run inline under `sysio.epoch::advance`, and `sysio.token::transfer` runs the +/// recipient's code through `require_recipient(to)`; an asserting recipient there would abort the +/// whole advance and, with the challenge rolled back to OPEN and its locks still held, stall epoch +/// advancement chain-wide. Crediting touches only this contract's own table. See `chalg::claimbond`. +/// +/// Saturates at `asset::max_amount` (the `sysio.dclaim::add_wire_capped` discipline): the credit +/// is paid out as an `asset`, whose amount is a signed 62-bit quantity, so an unclamped sum could +/// build a row that `claimbond` can never construct a payout for. +void credit_bond(name self, name account, uint64_t amount) { + chalg::bondcredits_t credits(self); + const auto pk = chalg::bond_credit_key{account.value}; + auto it = credits.find(pk); + if (it == credits.end()) { + const uint64_t seed = std::min(amount, static_cast(asset::max_amount)); + credits.emplace(ram_payer, pk, chalg::bond_credit{ .account = account, .amount = seed }); + return; + } + credits.modify(same_payer, pk, [&](auto& r) { + const uint64_t room = static_cast(asset::max_amount) - r.amount; + r.amount += (amount <= room ? amount : room); + }); +} + +} // anonymous namespace + // --------------------------------------------------------------------------- // slashop — execute a slash on an operator via sysio.opreg // --------------------------------------------------------------------------- @@ -63,14 +176,7 @@ void chalg::opendispute(uint64_t chain_code, // registrations or a generation rotation cannot change an in-flight dispute's electorate or // quorum. const uint8_t network_gen = roa::current_network_gen(ROA_ACCOUNT); - - roa::nodeowners_t nodeowners(ROA_ACCOUNT, network_gen); - auto by_tier = nodeowners.get_index<"bytier"_n>(); - const uint64_t t1_tier = magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T1); - std::vector electorate; - for (auto it = by_tier.lower_bound(t1_tier); it != by_tier.end() && it->by_tier() == t1_tier; ++it) { - electorate.push_back(it->owner); - } + auto electorate = snapshot_t1_electorate(ROA_ACCOUNT, network_gen); // An empty electorate could never vote, so the dispute could never resolve and the epoch pause // below would hold forever. Refuse to open instead -- the conflicting deliveries keep this @@ -227,4 +333,296 @@ void chalg::chkdispute(uint64_t dispute_id) { } } +// --------------------------------------------------------------------------- +// openuwchal — file an underwriter-fault challenge (WIRE-297) +// --------------------------------------------------------------------------- +void chalg::openuwchal(name challenger, uint64_t uwreq_id, name underwriter, + uint8_t reason, std::string detail) { + require_auth(challenger); + + // Trust boundary: the ABI carries the numeric value; the checked cast is the validation + // (never static_cast — out-of-range would be UB and hide bad input). + const auto fault = magic_enum::enum_cast(reason); + check(fault.has_value(), "openuwchal: unknown fault reason"); + check(detail.size() <= max_uwchal_detail_bytes, + "openuwchal: detail exceeds max_uwchal_detail_bytes"); + + // The challenged commitment: a CONFIRMED uwreq whose recorded winner is `underwriter`. + uwrit::uwreqs_t reqs(UWRIT_ACCOUNT); + auto rq = reqs.find(uwrit::id_key{uwreq_id}); + check(rq != reqs.end(), "openuwchal: underwrite request not found"); + check(rq->status == UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED, + "openuwchal: underwrite request is not CONFIRMED"); + check(rq->winner == underwriter, "openuwchal: underwriter is not this request's winner"); + + // A verdict is final per commitment: at most one challenge EVER per (uwreq, underwriter). + // Other commitments — other swaps, any epoch — are independently challengeable. + uwchals_t chals(get_self()); + auto uq_idx = chals.get_index<"byuwrequw"_n>(); + const uint128_t composite = (static_cast(uwreq_id) << 64) | underwriter.value; + check(uq_idx.find(composite) == uq_idx.end(), + "openuwchal: this commitment has already been challenged"); + + // Price the bond off the SAME formula `uwchalbond` quotes. Zero live locks means the window + // has closed (or never opened); an unpriceable bond refuses the filing rather than guessing. + const auto quote = compute_uwchal_bond(UWRIT_ACCOUNT, RESERV_ACCOUNT, uwreq_id, underwriter); + check(quote.live_locks > 0, + "openuwchal: no live collateral locks to challenge (the lock window has closed)"); + check(quote.bond > 0, "openuwchal: challenge bond cannot be priced"); + + const uint8_t network_gen = roa::current_network_gen(ROA_ACCOUNT); + auto electorate = snapshot_t1_electorate(ROA_ACCOUNT, network_gen); + // Same refusal as opendispute: an electorate that cannot vote could never resolve the + // challenge — it would only lapse at the window's end, having held the bond for nothing. + check(!electorate.empty(), "cannot open a challenge with no registered tier-1 node owners"); + const uint32_t quorum = static_cast(electorate.size()) / 2 + 1; + + // Escrow the bond under the challenger's OWN authority (the swapfromwire escrow pattern) — + // this contract holds it until resolution routes it per the verdict. + action( + permission_level{challenger, "active"_n}, + TOKEN_ACCOUNT, "transfer"_n, + std::make_tuple(challenger, get_self(), + asset(static_cast(quote.bond), WIRE_SYMBOL), + std::string("sysio.chalg::openuwchal challenge bond")) + ).send(); + + const auto now = current_time_point(); + uint64_t next_id = std::max(1, chals.available_primary_key()); + + chals.emplace(ram_payer, uwchal_key{next_id}, uwchal_entry{ + .id = next_id, + .uwreq_id = uwreq_id, + .underwriter = underwriter, + .challenger = challenger, + .reason = *fault, + .detail = std::move(detail), + .status = DisputeStatus::DISPUTE_STATUS_OPEN, + .verdict = uwchal_verdict::NONE, + .bond_amount = quote.bond, + .deadline_ms = quote.deadline_ms, + .opened_at = now, + .network_gen = network_gen, + .electorate = std::move(electorate), + .quorum = quorum, + }); + + // Mark the locks held. uwrit owns the lock data and re-validates liveness authoritatively — + // a stale filing aborts the WHOLE open, bond escrow included. A held lock is skipped by + // chklocks until the challenge resolves or lapses. NOTE: deliberately NO epoch pause — an + // unresolved challenge is survivable, so the chain keeps advancing. + action( + permission_level{get_self(), "active"_n}, + UWRIT_ACCOUNT, "holdlocks"_n, + std::make_tuple(uwreq_id, underwriter, next_id) + ).send(); +} + +// --------------------------------------------------------------------------- +// voteuwchal — Tier-1 ballot in an open challenge (record-only, like votedispute) +// --------------------------------------------------------------------------- +void chalg::voteuwchal(name owner, uint64_t chal_id, uint8_t ballot) { + require_auth(owner); + + const auto cast_ballot = magic_enum::enum_cast(ballot); + check(cast_ballot.has_value(), "voteuwchal: unknown ballot value"); + + uwchals_t chals(get_self()); + auto c = chals.get(uwchal_key{chal_id}, "challenge not found"); + check(c.status == DisputeStatus::DISPUTE_STATUS_OPEN, "challenge is not open"); + + // Ballots are valid only inside the challenge window. Past deadline_ms the sole lawful + // outcome is LAPSED (there is deliberately no after-deadline relaxed tally), so a late + // quorum must not be assemblable for a manual chkuwchal crank in the gap between expiry + // and the next epoch-tick poke. + check(current_time_ms() < c.deadline_ms, "voteuwchal: the challenge window has expired"); + + // Voter eligibility: membership in the challenge's snapshotted Tier-1 electorate — the same + // list chkuwchal's quorum is measured against, so a voter the tally would not count can never + // cast a ballot. + check(std::find(c.electorate.begin(), c.electorate.end(), owner) != c.electorate.end(), + "voter is not in the challenge's tier-1 electorate"); + + // One ballot per owner (the vote table is scoped by chal_id). + uwchalvotes_t votes(get_self(), chal_id); + auto v_pk = uwchal_vote_key{owner.value}; + check(!votes.contains(v_pk), "owner has already voted in this challenge"); + + votes.emplace(ram_payer, v_pk, uwchal_vote{ + .owner = owner, + .ballot = *cast_ballot, + .voted_at = current_time_point(), + }); +} + +// --------------------------------------------------------------------------- +// chkuwchal — tally an open challenge; resolve or lapse (chkdispute's sibling) +// --------------------------------------------------------------------------- +void chalg::chkuwchal(uint64_t chal_id) { + // Permissionless crank — poked inline by `sysio.uwrit::chklocks` for any expired-but- + // challenged lock (the epoch tick IS the cadence; possible because the chain is not paused), + // or called manually right after the deciding vote for a faster resolution. + uwchals_t chals(get_self()); + auto c_pk = uwchal_key{chal_id}; + auto c = chals.get(c_pk, "challenge not found"); + check(c.status == DisputeStatus::DISPUTE_STATUS_OPEN, "challenge is not open"); + + const uint32_t N = static_cast(c.electorate.size()); + const uint32_t Q = c.quorum; + check(Q > 0 && Q <= N, "challenge has no electorate snapshot"); + + // Tally the cast ballots. + uint32_t uphold = 0, reject_refund = 0, reject_forfeit = 0; + uwchalvotes_t votes(get_self(), chal_id); + for (auto it = votes.begin(); it != votes.end(); ++it) { + switch (it->ballot) { + case uwchal_ballot::UPHOLD: ++uphold; break; + case uwchal_ballot::REJECT_REFUND: ++reject_refund; break; + case uwchal_ballot::REJECT_FORFEIT: ++reject_forfeit; break; + } + } + + // Resolution rules — quorum thresholds any time; the deadline only LAPSES (there is + // deliberately no after-deadline relaxed tally: a challenge must be voted before the lock + // window expires; envelope disputes relax only because a paused chain MUST resolve). + uwchal_verdict verdict = uwchal_verdict::NONE; + if (uphold >= Q) { + verdict = uwchal_verdict::UPHELD; + } else if (reject_refund + reject_forfeit >= Q) { + // Disposition by majority among the rejectors; a tie favours the refund — forfeiture + // only ever happens by explicit council judgment. + verdict = (reject_forfeit > reject_refund) ? uwchal_verdict::REJECTED_FORFEIT + : uwchal_verdict::REJECTED_REFUND; + } else if (current_time_ms() >= c.deadline_ms) { + verdict = uwchal_verdict::LAPSED; + } + if (verdict == uwchal_verdict::NONE) return; // keep waiting for ballots + + chals.modify(same_payer, c_pk, [&](auto& r) { + r.status = DisputeStatus::DISPUTE_STATUS_RESOLVED; + r.verdict = verdict; + // Compact to a fixed-width tombstone. Past resolution the row has exactly two on-chain + // jobs — the `byuwrequw` uniqueness gate (a verdict is final per commitment) and the + // verdict record — and neither needs the variable-length fields. `detail` was + // caller-controlled evidence FOR the council; `electorate` was the ballot roll. Both stay + // permanently readable in the filing's action trace, and dropping them here is what bounds + // this contract's retained variable-length state by the number of CONCURRENTLY OPEN + // challenges (each backed by a live lock set and an escrowed bond) rather than by every + // challenge ever filed — the per-note cap alone cannot do that, because filing is + // permissionless and the bond comes back on every non-forfeit outcome. + r.detail.clear(); + r.electorate.clear(); + }); + + // The ballots go with it: they were the tally input above and the one-vote-per-owner gate, + // neither of which applies once the row leaves OPEN. Collect first, erase second (an erase + // invalidates the iterator) — bounded by the Tier-1 electorate this challenge snapshotted, the + // same bound the tally walk above already runs under. + std::vector ballot_owners; + ballot_owners.reserve(uphold + reject_refund + reject_forfeit); + for (auto it = votes.begin(); it != votes.end(); ++it) { + ballot_owners.push_back(it->owner.value); + } + for (uint64_t owner : ballot_owners) { + votes.erase(uwchal_vote_key{owner}); + } + + if (verdict == uwchal_verdict::UPHELD) { + // Slash FIRST, then sweep: inline actions run depth-first in send order, so the SLASHED + // status flip lands before sweeplocks' releaselock calls — which then take the + // deferred-slash branch, debiting the locked collateral and emitting the outbound SLASH + // attestations. Guard on live status: this can run inline from the epoch tick, where an + // aborted slash (already SLASHED/TERMINATED out-of-band) must not stall advancement. + opreg::operators_t ops(OPREG_ACCOUNT); + auto op = ops.find(opreg::operator_key{c.underwriter.value}); + const bool slashable = op != ops.end() && + op->status != OperatorStatus::OPERATOR_STATUS_SLASHED && + op->status != OperatorStatus::OPERATOR_STATUS_TERMINATED; + if (slashable) { + action( + permission_level{get_self(), "active"_n}, + get_self(), "slashop"_n, + std::make_tuple(c.underwriter, + std::string("underwriter fault upheld (") + + std::string(magic_enum::enum_name(c.reason)) + + "): challenge " + std::to_string(chal_id)) + ).send(); + } + action( + permission_level{get_self(), "active"_n}, + UWRIT_ACCOUNT, "sweeplocks"_n, + std::make_tuple(c.uwreq_id, c.underwriter) + ).send(); + } else { + // REJECTED or LAPSED: clear the hold; the locks live out their natural window and release + // on the next normal sweep (a healthy release — no collateral moves). + action( + permission_level{get_self(), "active"_n}, + UWRIT_ACCOUNT, "freelocks"_n, + std::make_tuple(c.uwreq_id, c.underwriter) + ).send(); + } + + // Route the bond per the verdict: forfeiture to the wrongly-challenged underwriter ONLY on an + // explicit REJECT_FORFEIT majority; every other outcome returns it to the challenger. + // + // CREDITED, never transferred. This whole function can run inline under + // `sysio.epoch::advance -> sysio.uwrit::chklocks`, where `sysio.token::transfer`'s + // `require_recipient(to)` would run the recipient's own code and let it abort epoch + // advancement — see `chalg::claimbond` for the full argument. The escrow stays in this + // contract's custody until the recipient pulls it. + if (c.bond_amount > 0) { + const bool forfeited = (verdict == uwchal_verdict::REJECTED_FORFEIT); + credit_bond(get_self(), forfeited ? c.underwriter : c.challenger, c.bond_amount); + } +} + +// --------------------------------------------------------------------------- +// claimbond — pull a resolved challenge's bond out of this contract's custody +// --------------------------------------------------------------------------- +void chalg::claimbond(name account) { + // The recipient's own authority, as `sysio.dclaim::claim` requires it: the transfer below + // notifies `account`, so only `account` can trigger code execution on its own behalf here. + require_auth(account); + + bondcredits_t credits(get_self()); + const auto pk = bond_credit_key{account.value}; + auto it = credits.find(pk); + check(it != credits.end(), "claimbond: no claimable bond"); + const uint64_t amount = it->amount; + check(amount > 0, "claimbond: zero claimable balance"); + + // Erase BEFORE sending: the inline transfer runs after this action returns and notifies + // `account`, which may re-enter `claimbond`. The row must already be gone when it does. + credits.erase(pk); + + action( + permission_level{get_self(), "active"_n}, + TOKEN_ACCOUNT, "transfer"_n, + std::make_tuple(get_self(), account, + asset(static_cast(amount), WIRE_SYMBOL), + std::string("sysio.chalg challenge bond payout")) + ).send(); +} + +// --------------------------------------------------------------------------- +// uwchalbond — read-only quote of the bond openuwchal would charge right now +// --------------------------------------------------------------------------- +uint64_t chalg::uwchalbond(uint64_t uwreq_id, name underwriter) { + // Soft gating — a quote, not a filing: every not-currently-challengeable state answers 0 + // (callers treat 0 as "no quote"), mirroring `sysio.reserv::swapquote`'s contract. + uwrit::uwreqs_t reqs(UWRIT_ACCOUNT); + auto rq = reqs.find(uwrit::id_key{uwreq_id}); + if (rq == reqs.end()) return 0; + if (rq->status != UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED) return 0; + if (rq->winner != underwriter) return 0; + + uwchals_t chals(get_self()); + auto uq_idx = chals.get_index<"byuwrequw"_n>(); + const uint128_t composite = (static_cast(uwreq_id) << 64) | underwriter.value; + if (uq_idx.find(composite) != uq_idx.end()) return 0; // already challenged — a verdict is final + + return compute_uwchal_bond(UWRIT_ACCOUNT, RESERV_ACCOUNT, uwreq_id, underwriter).bond; +} + } // namespace sysio diff --git a/contracts/sysio.chalg/sysio.chalg.abi b/contracts/sysio.chalg/sysio.chalg.abi index a67f9a100b..e9b03b249a 100644 --- a/contracts/sysio.chalg/sysio.chalg.abi +++ b/contracts/sysio.chalg/sysio.chalg.abi @@ -3,6 +3,30 @@ "version": "sysio::abi/1.2", "types": [], "structs": [ + { + "name": "bond_credit", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "bond_credit_key", + "base": "", + "fields": [ + { + "name": "account", + "type": "uint64" + } + ] + }, { "name": "chalg_state", "base": "", @@ -23,6 +47,26 @@ } ] }, + { + "name": "chkuwchal", + "base": "", + "fields": [ + { + "name": "chal_id", + "type": "uint64" + } + ] + }, + { + "name": "claimbond", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + } + ] + }, { "name": "dispute_candidate", "base": "", @@ -143,6 +187,32 @@ } ] }, + { + "name": "openuwchal", + "base": "", + "fields": [ + { + "name": "challenger", + "type": "name" + }, + { + "name": "uwreq_id", + "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" + }, + { + "name": "reason", + "type": "uint8" + }, + { + "name": "detail", + "type": "string" + } + ] + }, { "name": "slashop", "base": "", @@ -157,6 +227,120 @@ } ] }, + { + "name": "uwchal_entry", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "uwreq_id", + "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" + }, + { + "name": "challenger", + "type": "name" + }, + { + "name": "reason", + "type": "underwrite_fault_reason" + }, + { + "name": "detail", + "type": "string" + }, + { + "name": "status", + "type": "DisputeStatus" + }, + { + "name": "verdict", + "type": "uwchal_verdict" + }, + { + "name": "bond_amount", + "type": "uint64" + }, + { + "name": "deadline_ms", + "type": "uint64" + }, + { + "name": "opened_at", + "type": "time_point" + }, + { + "name": "network_gen", + "type": "uint8" + }, + { + "name": "electorate", + "type": "name[]" + }, + { + "name": "quorum", + "type": "uint32" + } + ] + }, + { + "name": "uwchal_key", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + } + ] + }, + { + "name": "uwchal_vote", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "ballot", + "type": "uwchal_ballot" + }, + { + "name": "voted_at", + "type": "time_point" + } + ] + }, + { + "name": "uwchal_vote_key", + "base": "", + "fields": [ + { + "name": "owner", + "type": "uint64" + } + ] + }, + { + "name": "uwchalbond", + "base": "", + "fields": [ + { + "name": "uwreq_id", + "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" + } + ] + }, { "name": "votedispute", "base": "", @@ -174,6 +358,24 @@ "type": "checksum256" } ] + }, + { + "name": "voteuwchal", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "chal_id", + "type": "uint64" + }, + { + "name": "ballot", + "type": "uint8" + } + ] } ], "actions": [ @@ -182,23 +384,56 @@ "type": "chkdispute", "ricardian_contract": "" }, + { + "name": "chkuwchal", + "type": "chkuwchal", + "ricardian_contract": "" + }, + { + "name": "claimbond", + "type": "claimbond", + "ricardian_contract": "" + }, { "name": "opendispute", "type": "opendispute", "ricardian_contract": "" }, + { + "name": "openuwchal", + "type": "openuwchal", + "ricardian_contract": "" + }, { "name": "slashop", "type": "slashop", "ricardian_contract": "" }, + { + "name": "uwchalbond", + "type": "uwchalbond", + "ricardian_contract": "" + }, { "name": "votedispute", "type": "votedispute", "ricardian_contract": "" + }, + { + "name": "voteuwchal", + "type": "voteuwchal", + "ricardian_contract": "" } ], "tables": [ + { + "name": "bondcredits", + "type": "bond_credit", + "index_type": "i64", + "key_names": ["account"], + "key_types": ["uint64"], + "table_id": 30792 + }, { "name": "chalgstate", "type": "chalg_state", @@ -234,11 +469,39 @@ "key_names": ["scope","owner"], "key_types": ["name","uint64"], "table_id": 51768 + }, + { + "name": "uwchals", + "type": "uwchal_entry", + "index_type": "i64", + "key_names": ["id"], + "key_types": ["uint64"], + "table_id": 10118, + "secondary_indexes": [ + { + "name": "byuwrequw", + "key_type": "uint128", + "table_id": 26153 + } + ] + }, + { + "name": "uwchalvote", + "type": "uwchal_vote", + "index_type": "i64", + "key_names": ["scope","owner"], + "key_types": ["name","uint64"], + "table_id": 12100 } ], "ricardian_clauses": [], "variants": [], - "action_results": [], + "action_results": [ + { + "name": "uwchalbond", + "result_type": "uint64" + } + ], "enums": [ { "name": "DisputeStatus", @@ -257,6 +520,68 @@ "value": 2 } ] + }, + { + "name": "underwrite_fault_reason", + "type": "uint8", + "values": [ + { + "name": "SOURCE_DEPOSIT_MISSING", + "value": 0 + }, + { + "name": "SOURCE_DEPOSIT_MISMATCH", + "value": 1 + }, + { + "name": "NON_CANONICAL_ENVELOPE", + "value": 2 + } + ] + }, + { + "name": "uwchal_ballot", + "type": "uint8", + "values": [ + { + "name": "UPHOLD", + "value": 0 + }, + { + "name": "REJECT_REFUND", + "value": 1 + }, + { + "name": "REJECT_FORFEIT", + "value": 2 + } + ] + }, + { + "name": "uwchal_verdict", + "type": "uint8", + "values": [ + { + "name": "NONE", + "value": 0 + }, + { + "name": "UPHELD", + "value": 1 + }, + { + "name": "REJECTED_REFUND", + "value": 2 + }, + { + "name": "REJECTED_FORFEIT", + "value": 3 + }, + { + "name": "LAPSED", + "value": 4 + } + ] } ] } \ No newline at end of file diff --git a/contracts/sysio.chalg/sysio.chalg.wasm b/contracts/sysio.chalg/sysio.chalg.wasm index 35b049021e..4bd58b1c74 100755 Binary files a/contracts/sysio.chalg/sysio.chalg.wasm and b/contracts/sysio.chalg/sysio.chalg.wasm differ diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index b8695c6026..80328d3629 100755 Binary files a/contracts/sysio.opreg/sysio.opreg.wasm and b/contracts/sysio.opreg/sysio.opreg.wasm differ diff --git a/contracts/sysio.reserv/sysio.reserv.wasm b/contracts/sysio.reserv/sysio.reserv.wasm index dff1056050..6d7e05bf20 100755 Binary files a/contracts/sysio.reserv/sysio.reserv.wasm and b/contracts/sysio.reserv/sysio.reserv.wasm differ diff --git a/contracts/sysio.roa/sysio.roa.wasm b/contracts/sysio.roa/sysio.roa.wasm index ad1204bc76..4db1d3d41c 100755 Binary files a/contracts/sysio.roa/sysio.roa.wasm and b/contracts/sysio.roa/sysio.roa.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 c0105e280b..0822b31085 100644 --- a/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp +++ b/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp @@ -308,9 +308,44 @@ namespace sysio { /// /// This sweep is the ONLY lock-release path: locks are a wall-clock /// challenge window (12h default) and are never released by delivery. + /// + /// EXCEPTION (WIRE-297): a lock whose `challenge_id` is non-zero — an + /// underwriter-fault challenge is OPEN against its commitment — is NOT + /// released at expiry. The sweep skips it and instead pokes + /// `sysio.chalg::chkuwchal`, whose resolution either sweeps the locks + /// 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. [[sysio::action]] void chklocks(); + /// 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`. + /// This is the AUTHORITATIVE liveness validation for a filing — every lock must still be + /// inside its window and free of any other challenge, and at least one lock must exist — + /// so a stale challenge aborts whole here, bond escrow included. + [[sysio::action]] + void holdlocks(uint64_t uwreq_id, name underwriter, uint64_t chal_id); + + /// Clear the challenge hold on the underwriter's locks for `uwreq_id` — the challenge was + /// REJECTED by the council or LAPSED at the window's end. The locks then release on their + /// next normal `chklocks` sweep: a healthy release, no collateral moves. Auth: + /// `sysio.chalg`. Deliberately a silent no-op when nothing is held: it runs inline from + /// the epoch tick (`chklocks` -> `chkuwchal` -> here), where an abort would stall epoch + /// advancement chain-wide. + [[sysio::action]] + void freelocks(uint64_t uwreq_id, name underwriter); + + /// Release + erase the underwriter's locks for `uwreq_id` after an UPHELD challenge. The + /// underwriter is already SLASHED (`chkuwchal` slashes before sweeping — inline actions + /// run depth-first in send order), so every inlined `opreg::releaselock` takes its + /// deferred-slash branch: the locked collateral is debited and the outbound SLASH + /// attestation queued. Ends with the same COMPLETED-flip / evidence-clear / retention + /// tail `chklocks` runs when a uwreq's last lock leaves. Auth: `sysio.chalg`; silent + /// no-op when no locks remain (same never-throw-inside-advance reasoning as `freelocks`). + [[sysio::action]] + void sweeplocks(uint64_t uwreq_id, name underwriter); + /// 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 @@ -450,6 +485,14 @@ namespace sysio { std::memcpy(buf.data() + 16, &token_code.value, 8); return sysio::sha256(reinterpret_cast(buf.data()), buf.size()); } + /// 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 + /// `expires_at_ms`: `chklocks` skips it and instead pokes the challenge's tally crank, + /// so the collateral stays at risk until the council resolves or the challenge lapses. + /// Stamped by `holdlocks`, cleared by `freelocks` (reject/lapse); an UPHELD challenge + /// erases the row through `sweeplocks` instead. + uint64_t challenge_id = 0; + /// Split-index for cheap per-operator scans (plan §B.2). Callers /// pull all rows for a given underwriter and filter on /// chain_code / token_code / reserve_code in memory. @@ -459,7 +502,7 @@ namespace sysio { SYSLIB_SERIALIZE(lock_entry, (lock_id)(uwreq_id)(underwriter)(chain_code)(token_code)(reserve_code) - (amount)(created_at_ms)(expires_at_ms)) + (amount)(created_at_ms)(expires_at_ms)(challenge_id)) }; // Per plan §B.2: split-index approach — keep only uint64 secondary diff --git a/contracts/sysio.uwrit/src/sysio.uwrit.cpp b/contracts/sysio.uwrit/src/sysio.uwrit.cpp index 359373b7ef..2bf330af5c 100644 --- a/contracts/sysio.uwrit/src/sysio.uwrit.cpp +++ b/contracts/sysio.uwrit/src/sysio.uwrit.cpp @@ -1961,6 +1961,44 @@ uint64_t uwrit::sumlocks(name underwriter, // so even a residual over-committed lock set cannot underflow + abort the // advance (defence-in-depth — the winner-selection aggregate bond check // already prevents the over-commit at lock-creation time). +namespace { + +/// COMPLETED flip for uwreqs whose LAST lock just left the table — a CONFIRMED +/// uwreq with no remaining locks has 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 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). Shared by `chklocks` (natural expiry) and +/// `sweeplocks` (an UPHELD underwriter challenge's slash-sweep). +void finalize_settled_uwreqs(name self, uwrit::locks_t& locks, + const std::vector& affected) { + uwrit::uwreqs_t reqs(self); + auto byuwreq = locks.get_index<"byuwreq"_n>(); + const uint32_t now_ep = get_current_epoch(); + const uint32_t retention_epochs = read_config(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 + auto pk = uwrit::id_key{id}; + if (!reqs.contains(pk)) continue; + auto r = reqs.get(pk); + if (r.status != UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED) continue; + 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 + retention_epochs; + row.attestation_inbound_data.clear(); + for (auto& c : row.commits_by) { + c.source_uic_bytes.clear(); + c.dest_uic_bytes.clear(); + } + }); + } +} + +} // anonymous namespace + void uwrit::chklocks() { // Two valid callers: // * sysio.epoch::advance — inlined at every epoch boundary. @@ -1975,11 +2013,40 @@ void uwrit::chklocks() { // Walk in ascending `expires_at_ms` and collect full copies while // expired — we erase in a second pass (an erase invalidates the index // cursor) and need every field for the releaselock fan-out. + // + // A lock HELD by an open underwriter-fault challenge (WIRE-297) is not + // released at expiry: its collateral stays at risk until the council + // resolves or the challenge lapses. Skip it and poke the challenge's + // tally crank instead — this poke is the challenge system's only + // cadence (the chain is not paused during a challenge, so the epoch + // tick keeps arriving; envelope disputes need a standalone crank + // precisely because theirs does not). std::vector expired; + std::vector open_challenges; for (auto it = idx.begin(); it != idx.end() && it->expires_at_ms <= now_ms; ++it) { + if (it->challenge_id != 0) { + if (std::find(open_challenges.begin(), open_challenges.end(), + it->challenge_id) == open_challenges.end()) { + open_challenges.push_back(it->challenge_id); + } + continue; + } expired.push_back(*it); } + + // Poke each held challenge: quorum reached → it resolves (slash-and-sweep + // or reject-and-free); no quorum → it LAPSES with a bond refund, and the + // freed locks release on the NEXT sweep. Resolution clears the hold in the + // same transaction tree, so a resolved challenge is never re-poked. + for (uint64_t chal_id : open_challenges) { + action( + permission_level{get_self(), "active"_n}, + CHALG_ACCOUNT, "chkuwchal"_n, + std::make_tuple(chal_id) + ).send(); + } + if (expired.empty()) return; std::vector affected; @@ -1995,36 +2062,101 @@ void uwrit::chklocks() { } } - // COMPLETED flip — a CONFIRMED uwreq whose final lock just swept has - // 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()); + finalize_settled_uwreqs(get_self(), locks, affected); +} + +// --------------------------------------------------------------------------- +// holdlocks / freelocks / sweeplocks — underwriter-challenge lock control +// (WIRE-297; auth = sysio.chalg for all three) +// --------------------------------------------------------------------------- + +void uwrit::holdlocks(uint64_t uwreq_id, name underwriter, uint64_t chal_id) { + require_auth(CHALG_ACCOUNT); + check(chal_id != 0, "holdlocks: challenge id must be non-zero"); + + const uint64_t now_ms = current_time_ms(); + locks_t locks(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 - auto pk = id_key{id}; - if (!reqs.contains(pk)) continue; - auto r = reqs.get(pk); - if (r.status != UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED) continue; - 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 + retention_epochs; - row.attestation_inbound_data.clear(); - for (auto& c : row.commits_by) { - c.source_uic_bytes.clear(); - c.dest_uic_bytes.clear(); - } + + // Authoritative liveness validation for a challenge filing — throwing here + // aborts the WHOLE openuwchal, bond escrow included. Collect first, modify + // second (a modify invalidates the index cursor). + std::vector to_hold; + for (auto it = byuwreq.lower_bound(uwreq_id); + it != byuwreq.end() && it->uwreq_id == uwreq_id; ++it) { + if (it->underwriter != underwriter) continue; + check(it->challenge_id == 0, "holdlocks: lock is already under challenge"); + check(now_ms < it->expires_at_ms, "holdlocks: the lock window has closed"); + to_hold.push_back(it->lock_id); + } + check(!to_hold.empty(), "holdlocks: no locks found for this commitment"); + + for (uint64_t lock_id : to_hold) { + locks.modify(same_payer, lock_key{lock_id}, [&](auto& row) { + row.challenge_id = chal_id; + }); + } +} + +void uwrit::freelocks(uint64_t uwreq_id, name underwriter) { + require_auth(CHALG_ACCOUNT); + + locks_t locks(get_self()); + auto byuwreq = locks.get_index<"byuwreq"_n>(); + + // Deliberately soft past the auth gate: this runs inline from the epoch + // tick (`chklocks` → `chkuwchal` → here) on a REJECTED or LAPSED + // challenge, where an abort would stall epoch advancement chain-wide. + std::vector to_free; + for (auto it = byuwreq.lower_bound(uwreq_id); + it != byuwreq.end() && it->uwreq_id == uwreq_id; ++it) { + if (it->underwriter != underwriter || it->challenge_id == 0) continue; + to_free.push_back(it->lock_id); + } + + for (uint64_t lock_id : to_free) { + locks.modify(same_payer, lock_key{lock_id}, [&](auto& row) { + row.challenge_id = 0; }); } } +void uwrit::sweeplocks(uint64_t uwreq_id, name underwriter) { + require_auth(CHALG_ACCOUNT); + + locks_t locks(get_self()); + auto byuwreq = locks.get_index<"byuwreq"_n>(); + + // Collect the underwriter's locks for this commitment (all held by + // construction on an UPHELD challenge). Soft when empty — same + // never-throw-inside-advance reasoning as `freelocks`. + std::vector held; + for (auto it = byuwreq.lower_bound(uwreq_id); + it != byuwreq.end() && it->uwreq_id == uwreq_id; ++it) { + if (it->underwriter == underwriter) held.push_back(*it); + } + if (held.empty()) return; + + // The underwriter is already SLASHED (`chkuwchal` slashes before sweeping), + // so each releaselock takes its deferred-slash branch: the locked + // collateral is debited from opreg and the outbound SLASH attestation + // queued for the outpost's slash-to-reserve hop. + std::vector affected; + for (const auto& l : held) { + action( + permission_level{get_self(), "active"_n}, + OPREG_ACCOUNT, "releaselock"_n, + std::make_tuple(l.underwriter, l.chain_code, l.token_code, l.amount) + ).send(); + locks.erase(lock_key{l.lock_id}); + if (std::find(affected.begin(), affected.end(), l.uwreq_id) == affected.end()) { + affected.push_back(l.uwreq_id); + } + } + + finalize_settled_uwreqs(get_self(), locks, affected); +} + // --------------------------------------------------------------------------- // pruneuwreqs — bounded UWREQ lifecycle sweep (SEC-129 / WSA-223) // --------------------------------------------------------------------------- diff --git a/contracts/sysio.uwrit/sysio.uwrit.abi b/contracts/sysio.uwrit/sysio.uwrit.abi index 68e0f90ee9..55204fe6cc 100644 --- a/contracts/sysio.uwrit/sysio.uwrit.abi +++ b/contracts/sysio.uwrit/sysio.uwrit.abi @@ -77,6 +77,20 @@ "base": "", "fields": [] }, + { + "name": "freelocks", + "base": "", + "fields": [ + { + "name": "uwreq_id", + "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" + } + ] + }, { "name": "fromwire_q", "base": "", @@ -137,6 +151,24 @@ } ] }, + { + "name": "holdlocks", + "base": "", + "fields": [ + { + "name": "uwreq_id", + "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" + }, + { + "name": "chal_id", + "type": "uint64" + } + ] + }, { "name": "id_key", "base": "", @@ -186,6 +218,10 @@ { "name": "expires_at_ms", "type": "uint64" + }, + { + "name": "challenge_id", + "type": "uint64" } ] }, @@ -343,6 +379,20 @@ } ] }, + { + "name": "sweeplocks", + "base": "", + "fields": [ + { + "name": "uwreq_id", + "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" + } + ] + }, { "name": "uw_config", "base": "", @@ -494,6 +544,16 @@ "type": "drainfwq", "ricardian_contract": "" }, + { + "name": "freelocks", + "type": "freelocks", + "ricardian_contract": "" + }, + { + "name": "holdlocks", + "type": "holdlocks", + "ricardian_contract": "" + }, { "name": "pruneuwreqs", "type": "pruneuwreqs", @@ -518,6 +578,11 @@ "name": "swapfromwire", "type": "swapfromwire", "ricardian_contract": "" + }, + { + "name": "sweeplocks", + "type": "sweeplocks", + "ricardian_contract": "" } ], "tables": [ diff --git a/contracts/sysio.uwrit/sysio.uwrit.wasm b/contracts/sysio.uwrit/sysio.uwrit.wasm index cce16ed527..2a9c8240a8 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 1e98cbd878..a3e5bc9d08 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -18,6 +18,7 @@ #include #include #include +#include // token_to_wire — the challenge-bond reference math #include #include @@ -3018,4 +3019,637 @@ BOOST_FIXTURE_TEST_CASE(rcrdcommit_candidate_cap_bounds_row, BOOST_REQUIRE_EQUAL(32u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); } FC_LOG_AND_RETHROW() } +// ═══════════════════════════════════════════════════════════════════════════ +// Underwriter-fault challenge (WIRE-297) — openuwchal / voteuwchal / +// chkuwchal / uwchalbond + the uwrit lock-hold trio +// ═══════════════════════════════════════════════════════════════════════════ + +/// The dispatch stack + a deployed `sysio.chalg`, a Tier-1 electorate of three, and a +/// WIRE-funded challenger. The confirmed-uwreq builder mirrors +/// `swap_same_token_legs_exact_balance_wins` exactly: two same-chain legs of 100 against the +/// 1e12/1e12 cw-5000 books, so the winner holds two (ETH, ETH) locks of 100. +class sysio_uwchal_tester : public sysio_dispatch_tester { +public: + static constexpr auto CHALLENGER = "challenger"_n; + static constexpr auto VOTER1 = "voter1"_n; + static constexpr auto VOTER2 = "voter2"_n; + static constexpr auto VOTER3 = "voter3"_n; + static constexpr auto VOTER4 = "voter4"_n; + + /// Ballot wire values (the uint8 `uwchal_ballot` members). + static constexpr uint8_t BALLOT_UPHOLD = 0; + static constexpr uint8_t BALLOT_REJECT_REFUND = 1; + static constexpr uint8_t BALLOT_REJECT_FORFEIT = 2; + /// Fault-reason wire value (`underwrite_fault_reason::SOURCE_DEPOSIT_MISSING`). + static constexpr uint8_t REASON_DEPOSIT_MISSING = 0; + + /// Per-leg swap amount the confirmed-uwreq builder uses (each leg becomes a lock of this). + static constexpr uint64_t LEG_AMOUNT = 100; + + abi_serializer chalg_abi; + + sysio_uwchal_tester() { + // CHALG_ACCOUNT exists (base fixture creates it); it just never had code until now. + deploy(CHALG_ACCOUNT, contracts::chalg_wasm(), contracts::chalg_abi(), chalg_abi); + + // Tier-1 voters (no roa policy — same shape as the dispute tester's electorate) and the + // bond-posting challenger. + for (auto v : {VOTER1, VOTER2, VOTER3, VOTER4, CHALLENGER}) { + create_account(v, config::system_account_name, /*multisig=*/false, + /*include_code=*/true, /*include_roa_policy=*/false); + } + produce_blocks(); + + // T1 electorate rows in sysio.roa (gen 0). `forcereg` works pre-emitcfg — the electorate + // snapshot walks `nodeowners` directly, never `nodecount`. Genesis already carries ONE T1 + // owner, so the snapshot is these four + it: N = 5, Q = 3 — every quorum path below casts + // three ballots, and the tie case splits four rejectors 2–2. + for (auto v : {VOTER1, VOTER2, VOTER3, VOTER4}) { + BOOST_REQUIRE_EQUAL(success(), push(ROA_ACCOUNT, roa_abi, ROA_ACCOUNT, "forcereg"_n, + mvo()("owner", v.to_string())("tier", 1))); + } + produce_blocks(); + } + + // ── setup: the challenged commitment ───────────────────────────────────── + + /// Full path to a CONFIRMED uwreq with two live (ETH, ETH) locks of LEG_AMOUNT each — + /// byte-for-byte the `swap_same_token_legs_exact_balance_wins` recipe. + void make_confirmed_uwreq(uint64_t att_id) { + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); + BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); + + // The challenger's bond funding comes from the WIRE treasury seeded above. + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, + "transfer"_n, mvo()("from", "sysio")("to", CHALLENGER.to_string()) + ("quantity", "100.000000000 WIRE")("memo", "challenge bond funding"))); + + 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; + + 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*/ LEG_AMOUNT, + eth, eth, secondary, /*dst_amount*/ LEG_AMOUNT, + /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); + + const auto src_uic = make_signed_uic(UWRIT_OP, att_id, eth, eth, primary); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(att_id, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); + const auto dst_uic = make_signed_uic(UWRIT_OP, att_id, eth, eth, secondary); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(att_id, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); + + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", + get_uwreq(att_id)["status"].as_string()); + } + + /// Walk `sysio.reserv::reserves` (checksum256-keyed, so `get_row_by_id` cannot address it) + /// and return the row matching the slug triple — the reserv tests' scan workaround. Takes the + /// raw slug VALUES so a lock row's fields feed straight in. + fc::variant find_reserve(uint64_t target_chain, uint64_t target_token, + uint64_t target_reserve) { + const auto& db = control->db(); + const auto table_id = chain::compute_table_id("reserves"_n.to_uint64_t()); + const auto& kv_idx = db.get_index(); + auto itr = kv_idx.lower_bound(boost::make_tuple(RESERV_ACCOUNT, table_id, std::string_view{})); + for (; itr != kv_idx.end() && itr->code == RESERV_ACCOUNT && itr->table_id == table_id; ++itr) { + std::vector raw(itr->value.size()); + if (!raw.empty()) + std::memcpy(raw.data(), itr->value.data(), raw.size()); + try { + auto row = reserv_abi.binary_to_variant( + "reserve_row", raw, abi_serializer::create_yield_function(abi_serializer_max_time)); + if (row["chain_code"]["value"].as_uint64() == target_chain && + row["token_code"]["value"].as_uint64() == target_token && + row["reserve_code"]["value"].as_uint64() == target_reserve) { + return row; + } + } catch (...) { + // skip rows that don't decode + } + } + return fc::variant(); + } + + /// What `uwchalbond`/`openuwchal` must price for the builder's commitment: each leg's lock + /// amount through its OWN reserve's LIVE books. Live, not the registration constants — + /// winner selection settles inline, so by challenge time `applyswap` has already moved both + /// books (src gained the chain leg / paid WIRE; dst the reverse). Recomputing on the host + /// over the same rows pins the contract to the shared kernel: right reserve per leg, right + /// field order, and the sum. + uint64_t expected_bond() { + uint64_t total = 0; + for (uint64_t lock_id : {1u, 2u}) { + const auto lock = get_lock(lock_id); + BOOST_REQUIRE(!lock.is_null()); + const auto row = find_reserve(lock["chain_code"]["value"].as_uint64(), + lock["token_code"]["value"].as_uint64(), + lock["reserve_code"]["value"].as_uint64()); + BOOST_REQUIRE(!row.is_null()); + total += sysio::opp::amm::token_to_wire(row["reserve_chain_amount"].as_uint64(), + row["reserve_wire_amount"].as_uint64(), + row["connector_weight_bps"].as_uint64(), + lock["amount"].as_uint64()); + } + return total; + } + + // ── chalg action wrappers ──────────────────────────────────────────────── + + action_result openuwchal(name challenger, uint64_t uwreq_id, name underwriter, + uint8_t reason, const std::string& detail) { + return push(CHALG_ACCOUNT, chalg_abi, challenger, "openuwchal"_n, mvo() + ("challenger", challenger.to_string())("uwreq_id", uwreq_id) + ("underwriter", underwriter.to_string())("reason", reason)("detail", detail)); + } + + action_result voteuwchal(name owner, uint64_t chal_id, uint8_t ballot) { + return push(CHALG_ACCOUNT, chalg_abi, owner, "voteuwchal"_n, mvo() + ("owner", owner.to_string())("chal_id", chal_id)("ballot", ballot)); + } + + action_result chkuwchal(uint64_t chal_id, name signer = CHALLENGER) { + return push(CHALG_ACCOUNT, chalg_abi, signer, "chkuwchal"_n, mvo()("chal_id", chal_id)); + } + + /// Pull a resolved challenge's bond out of chalg custody, signed by the recipient itself. + action_result claimbond(name account, name signer = name()) { + return push(CHALG_ACCOUNT, chalg_abi, signer == name() ? account : signer, "claimbond"_n, + mvo()("account", account.to_string())); + } + + /// Decoded return of the read-only bond quote. Seals a block first: tests re-quote the SAME + /// (uwreq, underwriter) before and after filing, and identical bytes in one block window + /// collide on transaction dedup. + uint64_t uwchalbond(uint64_t uwreq_id, name underwriter) { + produce_block(); + auto trace = base_tester::push_action(CHALG_ACCOUNT, "uwchalbond"_n, CHALG_ACCOUNT, mvo() + ("uwreq_id", uwreq_id)("underwriter", underwriter.to_string())); + BOOST_REQUIRE(trace && !trace->action_traces.empty()); + return fc::raw::unpack(trace->action_traces[0].return_value); + } + + // ── row / balance readers ──────────────────────────────────────────────── + + fc::variant get_uwchal(uint64_t id) { + auto data = get_row_by_id(CHALG_ACCOUNT, CHALG_ACCOUNT, "uwchals"_n, id); + return data.empty() ? fc::variant() : chalg_abi.binary_to_variant( + "uwchal_entry", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + /// The challenge's escrowed bond, straight off the row. + uint64_t uwchal_bond_amount(uint64_t id) { + return get_uwchal(id)["bond_amount"].as_uint64(); + } + + /// One owner's ballot in a challenge (the vote table is scoped by chal_id) — null once + /// resolution erased the scope. + fc::variant get_uwchal_vote(uint64_t chal_id, name owner) { + auto data = get_row_by_id(CHALG_ACCOUNT, name(chal_id), "uwchalvote"_n, owner.value); + return data.empty() ? fc::variant() : chalg_abi.binary_to_variant( + "uwchal_vote", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + /// Unclaimed WIRE `sysio.chalg` owes `account` from resolved challenges; 0 when no row exists. + uint64_t get_bond_credit(name account) { + auto data = get_row_by_id(CHALG_ACCOUNT, CHALG_ACCOUNT, "bondcredits"_n, account.value); + if (data.empty()) return 0; + return chalg_abi.binary_to_variant( + "bond_credit", data, + abi_serializer::create_yield_function(abi_serializer_max_time))["amount"].as_uint64(); + } + + int64_t wire_balance(name account) { + return get_currency_balance(TOKEN_ACCOUNT, symbol{9, "WIRE"}, account).get_amount(); + } + + /// The decoded `sysio.opreg::operators` row. + fc::variant get_operator(name account) { + auto data = get_row_by_id(OPREG_ACCOUNT, OPREG_ACCOUNT, "operators"_n, account.value); + return data.empty() ? fc::variant() : opreg_abi.binary_to_variant( + "operator_entry", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + /// The operator's status string straight off `sysio.opreg::operators`. + std::string operator_status(name account) { + return get_operator(account)["status"].as_string(); + } + + /// Sweep expired locks as the epoch machinery would (`chklocks` accepts epoch or self auth). + action_result chklocks() { + return push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "chklocks"_n, mvo()); + } +}; + +// The read-only quote prices the live locks through their own books and answers 0 for every +// not-challengeable state — the same soft contract as sysio.reserv::swapquote. +BOOST_FIXTURE_TEST_CASE(uwchalbond_quotes_the_live_lock_value, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9100; + make_confirmed_uwreq(ATT_ID); + + BOOST_REQUIRE_EQUAL(expected_bond(), uwchalbond(ATT_ID, UWRIT_OP)); + BOOST_REQUIRE_GT(expected_bond(), 0u); + + BOOST_REQUIRE_EQUAL(0u, uwchalbond(ATT_ID + 1, UWRIT_OP)); // no such uwreq + BOOST_REQUIRE_EQUAL(0u, uwchalbond(ATT_ID, "batchop.a"_n)); // not the winner +} FC_LOG_AND_RETHROW() } + +// Filing escrows exactly the quoted bond, stamps every lock with the challenge id, and records +// the OPEN row — and the quote answers 0 afterwards (a commitment is challengeable once). +BOOST_FIXTURE_TEST_CASE(openuwchal_escrows_bond_and_holds_locks, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9200; + make_confirmed_uwreq(ATT_ID); + + const uint64_t bond = uwchalbond(ATT_ID, UWRIT_OP); + const int64_t before = wire_balance(CHALLENGER); + + BOOST_REQUIRE_EQUAL(success(), + openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "no such deposit")); + + BOOST_REQUIRE_EQUAL(before - static_cast(bond), wire_balance(CHALLENGER)); + BOOST_REQUIRE_EQUAL(static_cast(bond), wire_balance(CHALG_ACCOUNT)); + + const auto chal = get_uwchal(1); + BOOST_REQUIRE(!chal.is_null()); + BOOST_REQUIRE_EQUAL("DISPUTE_STATUS_OPEN", chal["status"].as_string()); + BOOST_REQUIRE_EQUAL("NONE", chal["verdict"].as_string()); + BOOST_REQUIRE_EQUAL(ATT_ID, chal["uwreq_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(bond, chal["bond_amount"].as_uint64()); + // Four registered voters + the ONE genesis T1 owner (this assert breaks loudly if genesis + // ever seeds a different count — the quorum arithmetic below depends on it). + BOOST_REQUIRE_EQUAL(5u, chal["electorate"].get_array().size()); + BOOST_REQUIRE_EQUAL(3u, chal["quorum"].as_uint64()); + + BOOST_REQUIRE_EQUAL(1u, get_lock(1)["challenge_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(1u, get_lock(2)["challenge_id"].as_uint64()); + + BOOST_REQUIRE_EQUAL(0u, uwchalbond(ATT_ID, UWRIT_OP)); + BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "again") + .find("already been challenged") != std::string::npos); +} FC_LOG_AND_RETHROW() } + +// Every way a filing can be stale or malformed is refused before any WIRE moves. +BOOST_FIXTURE_TEST_CASE(openuwchal_guards, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9300; + make_confirmed_uwreq(ATT_ID); + + BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID + 77, UWRIT_OP, REASON_DEPOSIT_MISSING, "x") + .find("not found") != std::string::npos); + BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, "batchop.a"_n, REASON_DEPOSIT_MISSING, "x") + .find("not this request's winner") != std::string::npos); + BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, /*reason*/ 99, "x") + .find("unknown fault reason") != std::string::npos); + // The caller-controlled detail note is retained on the audit row indefinitely (RAM billed to + // the contract) — the byte cap refuses the filing before any escrow moves. + BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, + std::string(1024 + 1, 'x')) + .find("detail exceeds max_uwchal_detail_bytes") != std::string::npos); + + // Time-travel past the 12h window: the commitment is no longer challengeable, and the locks + // release healthy on the next sweep. Seal the pending block FIRST — a big skip aborts pending + // and re-applies its transactions at the jumped time, where they have expired. + const int64_t before = wire_balance(CHALLENGER); + produce_blocks(); + produce_block(fc::hours(13)); + BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "late") + .find("lock window has closed") != std::string::npos); + // The whole transaction rolled back (the bond gate refuses pre-escrow here; a mid-window + // race would instead throw in holdlocks AFTER the escrow was sent — same atomicity): no + // WIRE moved, no challenge row survives. + BOOST_REQUIRE_EQUAL(before, wire_balance(CHALLENGER)); + BOOST_REQUIRE(get_uwchal(1).is_null()); +} FC_LOG_AND_RETHROW() } + +// Ballot guards mirror votedispute's: electorate membership, one vote per owner, a real ballot +// value, and an OPEN challenge. +BOOST_FIXTURE_TEST_CASE(voteuwchal_guards, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9400; + make_confirmed_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL(success(), + openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "d")); + + BOOST_REQUIRE(voteuwchal(CHALLENGER, 1, BALLOT_UPHOLD) + .find("not in the challenge's tier-1 electorate") != std::string::npos); + BOOST_REQUIRE(voteuwchal(VOTER1, 1, /*ballot*/ 7).find("unknown ballot") != std::string::npos); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_UPHOLD)); + produce_block(); // identical re-vote bytes need fresh TAPOS to reach the contract's guard + BOOST_REQUIRE(voteuwchal(VOTER1, 1, BALLOT_UPHOLD) + .find("already voted") != std::string::npos); + BOOST_REQUIRE(chkuwchal(99).find("challenge not found") != std::string::npos); +} FC_LOG_AND_RETHROW() } + +// The uphold path end-to-end: quorum -> SLASHED -> the locks sweep through releaselock's +// deferred-slash branch (collateral debited) -> uwreq COMPLETED -> bond back to the challenger. +BOOST_FIXTURE_TEST_CASE(chkuwchal_uphold_slashes_and_returns_bond, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9500; + make_confirmed_uwreq(ATT_ID); + const int64_t challenger_start = wire_balance(CHALLENGER); + + BOOST_REQUIRE_EQUAL(success(), + openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "no deposit on ETH")); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_UPHOLD)); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER2, 1, BALLOT_UPHOLD)); + + // Sub-quorum (2 of Q=3): the crank resolves nothing and the operator stands. + BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); + BOOST_REQUIRE_EQUAL("DISPUTE_STATUS_OPEN", get_uwchal(1)["status"].as_string()); + BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", operator_status(UWRIT_OP)); + + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER3, 1, BALLOT_UPHOLD)); + produce_block(); // the resolving crank re-pushes identical bytes — fresh TAPOS required + BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); + + const auto chal = get_uwchal(1); + BOOST_REQUIRE_EQUAL("DISPUTE_STATUS_RESOLVED", chal["status"].as_string()); + BOOST_REQUIRE_EQUAL("UPHELD", chal["verdict"].as_string()); + + // The operator is slashed and BOTH locks are gone — swept, not expired. + BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_SLASHED", operator_status(UWRIT_OP)); + BOOST_REQUIRE(get_lock(1).is_null()); + BOOST_REQUIRE(get_lock(2).is_null()); + + // Every bucket drains: `opreg::slash` debits each bucket's slashable-now portion (balance + // minus active locks — for the challenged (ETH, ETH) bucket that is everything ABOVE the two + // 100-unit locks), and the two sweeping releaselocks then take the deferred-slash branch for + // the locked 200. Unlocked buckets on other chains were slashed in full at slash time. + // (Bind the row first — ranging over `get_operator(...)[...]` would iterate a dangling + // temporary.) + const auto slashed_op = get_operator(UWRIT_OP); + for (const auto& bal : slashed_op["balances"].get_array()) { + BOOST_REQUIRE_EQUAL(0u, bal["balance"].as_uint64()); + } + + // COMPLETED flip ran through the shared tail. + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", + get_uwreq(ATT_ID)["status"].as_string()); + + // The bond is CREDITED, not pushed: resolution moves no WIRE at all, so custody is still + // chalg's and the challenger is still down the escrow. (The crank can run under the epoch + // tick, where a transfer would run the recipient's notification handler — see + // hostile_bond_recipient_cannot_stall_the_epoch_tick.) + const uint64_t bond = chal["bond_amount"].as_uint64(); + BOOST_REQUIRE_GT(bond, 0u); + BOOST_REQUIRE_EQUAL(challenger_start - static_cast(bond), wire_balance(CHALLENGER)); + BOOST_REQUIRE_EQUAL(static_cast(bond), wire_balance(CHALG_ACCOUNT)); + BOOST_REQUIRE_EQUAL(bond, get_bond_credit(CHALLENGER)); + + // The pull settles it: the bond comes home and chalg ends at zero custody. + BOOST_REQUIRE_EQUAL(success(), claimbond(CHALLENGER)); + BOOST_REQUIRE_EQUAL(challenger_start, wire_balance(CHALLENGER)); + BOOST_REQUIRE_EQUAL(0, wire_balance(CHALG_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0u, get_bond_credit(CHALLENGER)); +} FC_LOG_AND_RETHROW() } + +// An explicit REJECT_FORFEIT majority is the ONLY road to forfeiture: the bond lands on the +// wrongly-challenged underwriter, the holds clear, and the locks release healthy at expiry +// with the collateral untouched. +BOOST_FIXTURE_TEST_CASE(chkuwchal_reject_forfeit_pays_underwriter, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9600; + make_confirmed_uwreq(ATT_ID); + const uint64_t bond = uwchalbond(ATT_ID, UWRIT_OP); + const int64_t uw_start = wire_balance(UWRIT_OP); + + BOOST_REQUIRE_EQUAL(success(), + openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "wrong")); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_REJECT_FORFEIT)); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER2, 1, BALLOT_REJECT_FORFEIT)); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER3, 1, BALLOT_REJECT_FORFEIT)); + BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); + + BOOST_REQUIRE_EQUAL("REJECTED_FORFEIT", get_uwchal(1)["verdict"].as_string()); + BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", operator_status(UWRIT_OP)); + + // Forfeiture credits the underwriter; the WIRE lands when IT pulls. + BOOST_REQUIRE_EQUAL(bond, get_bond_credit(UWRIT_OP)); + BOOST_REQUIRE_EQUAL(uw_start, wire_balance(UWRIT_OP)); + BOOST_REQUIRE_EQUAL(success(), claimbond(UWRIT_OP)); + BOOST_REQUIRE_EQUAL(uw_start + static_cast(bond), wire_balance(UWRIT_OP)); + BOOST_REQUIRE_EQUAL(0, wire_balance(CHALG_ACCOUNT)); + + // Holds cleared; locks still present until natural expiry, then a HEALTHY release. + BOOST_REQUIRE_EQUAL(0u, get_lock(1)["challenge_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, get_lock(2)["challenge_id"].as_uint64()); + const auto balances_before = get_operator(UWRIT_OP)["balances"]; + produce_blocks(); + produce_block(fc::hours(13)); + BOOST_REQUIRE_EQUAL(success(), chklocks()); + BOOST_REQUIRE(get_lock(1).is_null()); + // A healthy release moves NO collateral: the balances are byte-identical to the pre-expiry + // snapshot after a rejected challenge. + const auto balances_after = get_operator(UWRIT_OP)["balances"]; + BOOST_REQUIRE_EQUAL(fc::json::to_string(balances_before, fc::time_point::maximum()), + fc::json::to_string(balances_after, fc::time_point::maximum())); +} FC_LOG_AND_RETHROW() } + +// A split reject quorum favours the refund on a tie — forfeiture requires a strict majority of +// the rejectors. +BOOST_FIXTURE_TEST_CASE(chkuwchal_reject_tie_favours_refund, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9700; + make_confirmed_uwreq(ATT_ID); + const int64_t challenger_start = wire_balance(CHALLENGER); + + BOOST_REQUIRE_EQUAL(success(), + openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "honest mistake")); + // Four rejectors split 2–2: the combined rejects clear Q=3, and the forfeit/refund tie must + // fall to the refund — forfeiture only ever happens by a strict majority of the rejectors. + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_REJECT_REFUND)); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER2, 1, BALLOT_REJECT_FORFEIT)); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER3, 1, BALLOT_REJECT_REFUND)); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER4, 1, BALLOT_REJECT_FORFEIT)); + BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); + + BOOST_REQUIRE_EQUAL("REJECTED_REFUND", get_uwchal(1)["verdict"].as_string()); + BOOST_REQUIRE_EQUAL(success(), claimbond(CHALLENGER)); + BOOST_REQUIRE_EQUAL(challenger_start, wire_balance(CHALLENGER)); +} FC_LOG_AND_RETHROW() } + +// The epoch tick IS the challenge's cadence: an expired-but-challenged lock is NOT released by +// the sweep; the sweep pokes the tally, the sub-quorum challenge LAPSES (bond refunded — silence +// never punishes), the freed locks release on the NEXT sweep. +BOOST_FIXTURE_TEST_CASE(chklocks_skips_held_lock_and_lapse_refunds, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9800; + make_confirmed_uwreq(ATT_ID); + const int64_t challenger_start = wire_balance(CHALLENGER); + + BOOST_REQUIRE_EQUAL(success(), + openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "nobody voted")); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_UPHOLD)); // sub-quorum (Q=3) + + produce_blocks(); // seal pending before the jump (see openuwchal_guards) + produce_block(fc::hours(13)); // past both the lock expiry and therefore the vote deadline + + // Past the deadline the ballot door is closed: a late quorum can never be assembled for a + // manual chkuwchal crank in the expiry→sweep gap — the only remaining outcome is LAPSED. + BOOST_REQUIRE(voteuwchal(VOTER2, 1, BALLOT_UPHOLD) + .find("the challenge window has expired") != std::string::npos); + + // First sweep: the held locks are SKIPPED (still present), the poke lapses the challenge. + BOOST_REQUIRE_EQUAL(success(), chklocks()); + BOOST_REQUIRE(!get_lock(1).is_null()); + BOOST_REQUIRE(!get_lock(2).is_null()); + BOOST_REQUIRE_EQUAL(0u, get_lock(1)["challenge_id"].as_uint64()); + BOOST_REQUIRE_EQUAL("LAPSED", get_uwchal(1)["verdict"].as_string()); + BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", operator_status(UWRIT_OP)); + // Silence never punishes: the whole bond is credited back, claimable on demand. + BOOST_REQUIRE_EQUAL(uwchal_bond_amount(1), get_bond_credit(CHALLENGER)); + BOOST_REQUIRE_EQUAL(success(), claimbond(CHALLENGER)); + BOOST_REQUIRE_EQUAL(challenger_start, wire_balance(CHALLENGER)); + + // Second sweep: the now-unheld expired locks release healthy; uwreq completes. + produce_block(); // identical sweep bytes — fresh TAPOS + BOOST_REQUIRE_EQUAL(success(), chklocks()); + BOOST_REQUIRE(get_lock(1).is_null()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", + get_uwreq(ATT_ID)["status"].as_string()); +} FC_LOG_AND_RETHROW() } + +// A resolved challenge keeps only what consensus still needs: the uniqueness gate and the +// verdict. The caller-controlled detail note, the electorate snapshot, and every ballot row go — +// filing is permissionless and the bond returns on every non-forfeit outcome, so without this +// the same recycled capital could pin unbounded bytes in RAM billed to sysio. +BOOST_FIXTURE_TEST_CASE(chkuwchal_compacts_resolved_row_and_erases_ballots, + sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9250; + make_confirmed_uwreq(ATT_ID); + + const std::string detail(1024, 'e'); // a full-size note, right at max_uwchal_detail_bytes + BOOST_REQUIRE_EQUAL(success(), + openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, detail)); + + // While OPEN the row carries everything the council votes against. + const auto open_row = get_uwchal(1); + BOOST_REQUIRE_EQUAL(detail, open_row["detail"].as_string()); + BOOST_REQUIRE_EQUAL(5u, open_row["electorate"].get_array().size()); + + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_REJECT_REFUND)); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER2, 1, BALLOT_REJECT_REFUND)); + BOOST_REQUIRE(!get_uwchal_vote(1, VOTER1).is_null()); + BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER3, 1, BALLOT_REJECT_REFUND)); + BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); + + // Resolved: the fixed-width record stands, both variable-length fields are empty, the ballots + // are gone. + const auto tomb = get_uwchal(1); + BOOST_REQUIRE_EQUAL("DISPUTE_STATUS_RESOLVED", tomb["status"].as_string()); + BOOST_REQUIRE_EQUAL("REJECTED_REFUND", tomb["verdict"].as_string()); + BOOST_REQUIRE_EQUAL(ATT_ID, tomb["uwreq_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(3u, tomb["quorum"].as_uint64()); + BOOST_REQUIRE(tomb["detail"].as_string().empty()); + BOOST_REQUIRE_EQUAL(0u, tomb["electorate"].get_array().size()); + for (auto v : {VOTER1, VOTER2, VOTER3}) { + BOOST_REQUIRE(get_uwchal_vote(1, v).is_null()); + } + + // The tombstone still does its consensus job — a verdict is final per commitment, even though + // the reject freed the locks and they are live again. + BOOST_REQUIRE_EQUAL(0u, uwchalbond(ATT_ID, UWRIT_OP)); + BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "retry") + .find("already been challenged") != std::string::npos); +} FC_LOG_AND_RETHROW() } + +// The payout is a pull under the recipient's own authority, and it settles exactly once. +BOOST_FIXTURE_TEST_CASE(claimbond_guards, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9350; + make_confirmed_uwreq(ATT_ID); + + BOOST_REQUIRE(claimbond(CHALLENGER).find("no claimable bond") != std::string::npos); + + BOOST_REQUIRE_EQUAL(success(), + openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "d")); + for (auto v : {VOTER1, VOTER2, VOTER3}) { + BOOST_REQUIRE_EQUAL(success(), voteuwchal(v, 1, BALLOT_REJECT_REFUND)); + } + BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); + + const uint64_t bond = uwchal_bond_amount(1); + BOOST_REQUIRE_GT(bond, 0u); + BOOST_REQUIRE_EQUAL(bond, get_bond_credit(CHALLENGER)); + + // Another account cannot pull someone else's credit. + BOOST_REQUIRE(claimbond(CHALLENGER, /*signer*/ VOTER1) + .find("missing authority of challenger") != std::string::npos); + + const int64_t before = wire_balance(CHALLENGER); + BOOST_REQUIRE_EQUAL(success(), claimbond(CHALLENGER)); + BOOST_REQUIRE_EQUAL(before + static_cast(bond), wire_balance(CHALLENGER)); + + // Erased on payout — a second pull draws nothing. + produce_block(); // identical claim bytes need fresh TAPOS to reach the contract's guard + BOOST_REQUIRE(claimbond(CHALLENGER).find("no claimable bond") != std::string::npos); +} FC_LOG_AND_RETHROW() } + +// A hostile bond recipient cannot hold epoch advancement hostage. `sysio.token::transfer` runs +// its recipient's code through `require_recipient(to)`, so a contract at the challenger's account +// can assert on the incoming refund. Were the payout pushed from the crank, that assert would +// abort `sysio.epoch::advance -> chklocks -> chkuwchal`, roll the challenge back to OPEN with its +// locks still held, and be retried identically by every later sweep — a permanent chain-wide +// stall bought with one permissionless filing. Crediting keeps the crank inside system-owned +// state: the challenge resolves, the locks release, and the only transaction the hostile account +// can abort is its own claim, stranding only its own funds. +BOOST_FIXTURE_TEST_CASE(hostile_bond_recipient_cannot_stall_the_epoch_tick, + sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9450; + make_confirmed_uwreq(ATT_ID); + + BOOST_REQUIRE_EQUAL(success(), + openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "then i go hostile")); + const uint64_t bond = uwchal_bond_amount(1); + BOOST_REQUIRE_GT(bond, 0u); + + // The escrow is in; NOW the challenger deploys a contract that rejects every incoming + // notification. Nothing prevents this — filing is permissionless and code can land afterwards, + // so the payout path can never assume a bare recipient. (The fixture creates the challenger + // without a roa policy; setcode needs RAM, so grant one first.) + BOOST_REQUIRE(add_roa_policy(NODE_DADDY, CHALLENGER, "0.0010 SYS", "0.0010 SYS", + "50.0000 SYS", /*time_block*/ 0, /*network_gen*/ 0)); + set_code(CHALLENGER, contracts::util::reject_all_wasm()); + produce_blocks(); // seal pending before the jump (see openuwchal_guards) + produce_block(fc::hours(13)); // past the lock expiry: the sweep must now resolve the challenge + + // The epoch tick's sweep runs to completion: it pokes the tally, the sub-quorum challenge + // LAPSES, and nothing in the crank touches the hostile account. + BOOST_REQUIRE_EQUAL(success(), chklocks()); + BOOST_REQUIRE_EQUAL("LAPSED", get_uwchal(1)["verdict"].as_string()); + BOOST_REQUIRE_EQUAL(bond, get_bond_credit(CHALLENGER)); + BOOST_REQUIRE_EQUAL(static_cast(bond), wire_balance(CHALG_ACCOUNT)); + + // The freed locks release on the next sweep and the uwreq settles — advancement never stalled. + produce_block(); // identical sweep bytes — fresh TAPOS + BOOST_REQUIRE_EQUAL(success(), chklocks()); + BOOST_REQUIRE(get_lock(1).is_null()); + BOOST_REQUIRE(get_lock(2).is_null()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", + get_uwreq(ATT_ID)["status"].as_string()); + + // Only the hostile account's OWN claim fails, and its credit simply stays put. + BOOST_REQUIRE(claimbond(CHALLENGER).find("rejecting all notifications") != std::string::npos); + BOOST_REQUIRE_EQUAL(bond, get_bond_credit(CHALLENGER)); + BOOST_REQUIRE_EQUAL(static_cast(bond), wire_balance(CHALG_ACCOUNT)); +} FC_LOG_AND_RETHROW() } + +// The lock-control trio is chalg's alone. +BOOST_FIXTURE_TEST_CASE(lock_hold_actions_require_chalg_auth, sysio_uwchal_tester) { try { + constexpr uint64_t ATT_ID = 9900; + make_confirmed_uwreq(ATT_ID); + + BOOST_REQUIRE(push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "holdlocks"_n, mvo() + ("uwreq_id", ATT_ID)("underwriter", UWRIT_OP.to_string())("chal_id", 7)) + .find("missing authority of sysio.chalg") != std::string::npos); + BOOST_REQUIRE(push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "freelocks"_n, mvo() + ("uwreq_id", ATT_ID)("underwriter", UWRIT_OP.to_string())) + .find("missing authority of sysio.chalg") != std::string::npos); + BOOST_REQUIRE(push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "sweeplocks"_n, mvo() + ("uwreq_id", ATT_ID)("underwriter", UWRIT_OP.to_string())) + .find("missing authority of sysio.chalg") != std::string::npos); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END()