From 969eee2b860f19b7bb568daa2bf29a05a627f32c Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 28 Sep 2026 11:32:03 -0500 Subject: [PATCH 1/2] uwrit: recheck privacy at settlement, range-check remits, bound UICs createuwreq judged the private-reserve rule only against the rows that existed at ingestion. A request naming a reserve that was missing, PENDING or CANCELLED passed, and that identity could become an ACTIVE private reserve before settlement (a new registration, matchreserve, or oncrtreserve's reclaim of a CANCELLED row). try_select_winner rechecked privacy only on the from-WIRE path. The rule is now one predicate, used wherever uwrit admits, drains or settles a swap; at race resolution a violation is final and the request is rejected and refunded before any lock or reserve write. try_build_swap_remit wrote the settlement quote into the signed TokenAmount.amount with a bare static_cast. opp::safe:: to_token_amount, the outbound mirror of to_depot_amount, now range-checks it after the request-level verdicts, rejecting and refunding a quote outside (0, depot_amount_max]. The path is unreachable today, because opreg caps every collateral bucket at asset::max_amount and the destination bond gate runs first; this is defense in depth. A UIC field declaring a length far past its input made zpp_bits resize before checking the remaining bytes, trapping on memory growth and reverting the whole consensus delivery. msgch and uwrit now decode UICs through opp::decode_uic, which refuses input over MAX_UIC_LEG_BYTES and caps every allocation at that bound; a compile-time check fails the build if the UIC tree gains a container the cap does not bound. --- contracts/sysio.msgch/src/sysio.msgch.cpp | 47 +- contracts/sysio.msgch/sysio.msgch.wasm | Bin 172091 -> 172733 bytes .../include/sysio.opp.common/safe_ops.hpp | 16 + .../include/sysio.opp.common/uic_codec.hpp | 73 +++ .../include/sysio.uwrit/sysio.uwrit.hpp | 7 +- contracts/sysio.uwrit/src/sysio.uwrit.cpp | 216 ++++--- contracts/sysio.uwrit/sysio.uwrit.wasm | Bin 161007 -> 162083 bytes contracts/tests/safe_ops_tests.cpp | 29 + contracts/tests/sysio.dispatch_tests.cpp | 574 +++++++++++++++++- 9 files changed, 844 insertions(+), 118 deletions(-) create mode 100644 contracts/sysio.opp.common/include/sysio.opp.common/uic_codec.hpp diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index efcfeeb01b..1962211fa1 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -11,6 +11,7 @@ #include // to_depot_amount — WSA-028 fail-closed TokenAmount gate #include // parse_wire_account_name — never-throw account-name parse #include // canonical envelope encoding + keccak epoch digest +#include // bounded UnderwriteIntentCommit decode #include // emitcfg_t / t5state_t existence checks in bootstrap() #include #include @@ -596,6 +597,19 @@ void dispatch_operator_action(name self, const std::vector& data, } } +/// Why `dispatch_underwrite_commit` drops a UIC before it reaches `rcrdcommit`. +enum class uic_dispatch_rejection { + oversized_uic, ///< larger than `opp::MAX_UIC_LEG_BYTES`, which rcrdcommit refuses anyway + malformed_uic, ///< not a decodable UnderwriteIntentCommit + invalid_wire_account, ///< `uw_account.name` is not a valid WIRE account name +}; + +/// Log a UIC dropped at dispatch under the stable `UIC_DISPATCH_REJECTED_LOG_PREFIX` marker. +void log_uic_dispatch_rejection(uint64_t chain_code, uic_dispatch_rejection reason) { + const std::string reason_name{magic_enum::enum_name(reason)}; + sysio::print(UIC_DISPATCH_REJECTED_LOG_PREFIX, ": chain_code=", chain_code, ", reason=", reason_name, "\n"); +} + /// Dispatch an UNDERWRITE_INTENT_COMMIT to sysio.uwrit::rcrdcommit. /// /// The full UIC bytes are forwarded verbatim so the depot can reconstruct @@ -614,34 +628,31 @@ void dispatch_operator_action(name self, const std::vector& data, /// code, so a forged `uic.chain_code` could misroute the commit onto the wrong leg; we bind it to the /// proven outpost and drop on divergence. void dispatch_underwrite_commit(name self, const std::vector& data, uint64_t chain_code) { - opp::attestations::UnderwriteIntentCommit uic; - { - auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; - auto rc = in(uic); - if (rc != zpp::bits::errc{}) { - sysio::print(UIC_DISPATCH_REJECTED_LOG_PREFIX, - ": chain_code=", chain_code, - ", reason=malformed_uic\n"); - return; - } + // rcrdcommit refuses a UIC over this size, so it is not worth decoding. + if (data.size() > opp::MAX_UIC_LEG_BYTES) { + log_uic_dispatch_rejection(chain_code, uic_dispatch_rejection::oversized_uic); + return; + } + const auto uic = opp::decode_uic(data); + if (!uic) { + log_uic_dispatch_rejection(chain_code, uic_dispatch_rejection::malformed_uic); + return; } // Pre-validate the relayed account string before constructing `name` below. The CDT `name` // ctor aborts on an empty, overlong, or out-of-charset string, and an abort here would revert // the whole evalcons/apply_consensus delivery; a malformed name is dropped instead. - const auto underwriter = sysio::opp::safe::parse_wire_account_name(uic.uw_account.name); + const auto underwriter = sysio::opp::safe::parse_wire_account_name(uic->uw_account.name); if (!underwriter) { - sysio::print(UIC_DISPATCH_REJECTED_LOG_PREFIX, - ": chain_code=", chain_code, - ", reason=invalid_wire_account\n"); + log_uic_dispatch_rejection(chain_code, uic_dispatch_rejection::invalid_wire_account); return; } // WSA-005: the leg's chain (uic.chain_code) must be the proven delivering outpost before the // commit is recorded against a swap leg. - if (!source_chain_binding_ok(chain_code, uic.chain_code, "dispatch_underwrite_commit")) return; + if (!source_chain_binding_ok(chain_code, uic->chain_code, "dispatch_underwrite_commit")) return; - const sysio::slug_name uic_token_code{uic.token_code}; - const sysio::slug_name uic_reserve_code{uic.reserve_code}; + const sysio::slug_name uic_token_code{uic->token_code}; + const sysio::slug_name uic_reserve_code{uic->reserve_code}; if (!payload_codes_canonical({uic_token_code, uic_reserve_code}, "dispatch_underwrite_commit")) return; @@ -650,7 +661,7 @@ void dispatch_underwrite_commit(name self, const std::vector& data, uint64 action( permission_level{self, "active"_n}, UWRIT_ACCOUNT, "rcrdcommit"_n, - std::make_tuple(uic.uw_request_id, *underwriter, chain_code, + std::make_tuple(uic->uw_request_id, *underwriter, chain_code, sysio::slug_name{chain_code}, uic_token_code, uic_reserve_code, diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index b09bf4cc693e825e7f3df3a544dd693714c3fd91..415b50e2d713d55e6c2ba6efa5a2cdb3a7b61f99 100755 GIT binary patch delta 33859 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 diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/safe_ops.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/safe_ops.hpp index 68cf5b4f50..ae4bfa9d71 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/safe_ops.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/safe_ops.hpp @@ -72,6 +72,22 @@ inline std::optional to_depot_amount(int64_t amount) { return static_cast(amount); } +/// Fail-closed conversion of a depot amount into an outbound OPP +/// `TokenAmount.amount`, the inverse of `to_depot_amount`. +/// +/// The wire field is signed, so a bare `static_cast` turns any value +/// at or above `2^63` negative. This accepts exactly the range the inbound gate +/// accepts, so the depot never emits an amount it would refuse to receive. +/// +/// @param amount a depot-frame amount. +/// @return the amount as the signed wire value, or `std::nullopt` when it is +/// zero or above `depot_amount_max`. +inline std::optional to_token_amount(uint64_t amount) { + if (amount == 0) return std::nullopt; + if (amount > static_cast(depot_amount_max)) return std::nullopt; + return static_cast(amount); +} + /// Saturating unsigned 64-bit addition. Returns `a + b`, clamped to /// `UINT64_MAX` on overflow instead of wrapping. /// diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/uic_codec.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/uic_codec.hpp new file mode 100644 index 0000000000..073fdf9966 --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/uic_codec.hpp @@ -0,0 +1,73 @@ +#pragma once +/** + * @file uic_codec.hpp + * @brief Bounded decoding of UnderwriteIntentCommit payloads, shared by sysio.msgch and sysio.uwrit. + */ + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace sysio::opp { + +/// Largest encoded UnderwriteIntentCommit the depot accepts. A real UIC is a handful of ids and +/// codes plus one fixed-size signature of at most 97 bytes; the outposts refuse a larger one before +/// relaying it, and `sysio.uwrit::rcrdcommit` refuses one before storing it. +inline constexpr uint32_t MAX_UIC_LEG_BYTES = 2048; + +namespace detail { + +/// True when the only containers anywhere in `T` are byte strings. zpp_bits checks a length-delimited +/// byte string against `alloc_limit` before resizing it, but reserves a packed repeated varint +/// field's declared length without that check. A byte string sent under any other wire type grows +/// one byte at a time unchecked; only `decode_uic`'s input cap bounds that. +template +consteval bool alloc_limit_covers() { + using type = std::remove_cvref_t; + if constexpr (std::is_same_v || std::is_same_v>) { + return true; + } else if constexpr (zpp::bits::concepts::container) { + return false; + } else if constexpr (std::is_class_v && !zpp::bits::concepts::varint) { + // visit_members_types yields a default-constructed value of the visitor's return type. + return decltype(zpp::bits::visit_members_types([]() { + return std::bool_constant<(alloc_limit_covers() && ...)>{}; + }))::value; + } else { + return true; + } +} + +} // namespace detail + +/// Decode `bytes` as an UnderwriteIntentCommit, with every allocation bounded by +/// `MAX_UIC_LEG_BYTES`. +/// +/// Uncapped, zpp_bits resizes a bytes or string field to its declared length before checking that +/// the input holds that many bytes, so a few bytes declaring a huge length trap on memory growth +/// instead of returning an error, and the trap reverts the enclosing consensus delivery. The input +/// cap also bounds the one growth `alloc_limit` does not see: a bytes field sent under a +/// non-length-delimited wire type appends one byte per value. +/// +/// @return the decoded UIC, or `std::nullopt` when `bytes` exceeds `MAX_UIC_LEG_BYTES` or does not +/// decode. +inline std::optional decode_uic(std::span bytes) { + if (bytes.size() > MAX_UIC_LEG_BYTES) return std::nullopt; + attestations::UnderwriteIntentCommit uic; + auto in = zpp::bits::in{bytes, zpp::bits::no_size{}, zpp::bits::alloc_limit{}}; + if (in(uic) != zpp::bits::errc{}) return std::nullopt; + return uic; +} + +static_assert(detail::alloc_limit_covers(), + "the UnderwriteIntentCommit tree gained a non-byte-string container; decode_uic no longer " + "bounds every allocation"); + +} // namespace sysio::opp diff --git a/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp b/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp index f8b85cccae..b2cfab6246 100644 --- a/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp +++ b/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp @@ -152,11 +152,8 @@ namespace sysio { // ACTIVE roster grows large. static constexpr uint32_t MAX_UWREQ_CANDIDATES = 32; - // Per-leg cap on a stored verbatim `UnderwriteIntentCommit` payload - // (`commit_entry.source_uic_bytes` / `dest_uic_bytes`). A real UIC is a - // handful of ids + codes + a signature `verify_uic_signature` already - // bounds to 1024 bytes — 2 KiB is ~2x the largest legitimate encoding. - static constexpr uint32_t MAX_UIC_LEG_BYTES = 2048; + // The per-leg cap on a stored UIC payload (`commit_entry.source_uic_bytes` + // / `dest_uic_bytes`) is `opp::MAX_UIC_LEG_BYTES`, shared with sysio.msgch. // Cap on the stored inbound SwapRequest payload // (`uw_request_t.attestation_inbound_data`) at createuwreq. diff --git a/contracts/sysio.uwrit/src/sysio.uwrit.cpp b/contracts/sysio.uwrit/src/sysio.uwrit.cpp index ef84b1b387..c2ba773de1 100644 --- a/contracts/sysio.uwrit/src/sysio.uwrit.cpp +++ b/contracts/sysio.uwrit/src/sysio.uwrit.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -105,6 +106,26 @@ constexpr const char* UIC_SIGNATURE_REJECTED_REASON_PREFIX = /// Separates the rejected leg from its typed verification result. constexpr const char* UIC_SIGNATURE_REJECTED_REASON_SEPARATOR = " signature: "; +/// SWAP_REVERT reason when a private reserve would serve a WIRE-endpoint swap. +constexpr const char* PRIVATE_RESERVE_WIRE_ENDPOINT_REASON = + "private reserves are excluded from WIRE-endpoint swaps"; + +/// SWAP_REVERT reason when a private reserve's counterpart lacks the same WIRE owner. +constexpr const char* PRIVATE_RESERVE_OWNER_MISMATCH_REASON = + "private reserve pairing violation: counterpart reserve must be owned by the same WIRE account"; + +/// Candidate-release reason when race resolution rejects a request for reserve privacy. +constexpr const char* PRIVATE_RESERVE_COMMIT_REASON = + "uwreq rejected: reserve privacy violation at race resolution"; + +/// SWAP_REVERT reason when the settlement quote does not fit an OPP TokenAmount. +constexpr const char* REMIT_AMOUNT_OUT_OF_RANGE_REASON = + "swap unremittable at race resolution: quoted amount is outside the OPP token amount range"; + +/// Candidate-release reason when the settlement quote does not fit an OPP TokenAmount. +constexpr const char* REMIT_AMOUNT_OUT_OF_RANGE_COMMIT_REASON = + "uwreq rejected: destination quote outside the OPP token amount range"; + /// Fixed serialized-size allowance for a NEW `commit_entry`'s non-vector /// fields (underwriter name, two timestamp/outpost-id pairs, status, empty /// reason + the vectors' length prefixes) in rcrdcommit's projected-row-size @@ -523,6 +544,26 @@ std::optional find_active_reserve(sysio::slug_name chain_c return row; } +/// The reserve-privacy rule for one swap route, used wherever uwrit admits, drains or settles a +/// swap. A private reserve never serves a WIRE-endpoint swap, and pairs only with a counterpart +/// owned by the same WIRE account (the authex-linked matcher recorded at match time). A missing row +/// is not private. +/// +/// @param src_r source reserve, or nullopt for a missing reserve or a depot (WIRE) leg. +/// @param dst_r destination reserve, same convention. +/// @param wire_endpoint true when either leg is the depot. +/// @return the SWAP_REVERT reason for a violation, or nullptr when the route complies. +const char* reserve_privacy_violation(const std::optional& src_r, + const std::optional& dst_r, + bool wire_endpoint) { + const bool src_private = src_r && src_r->is_private; + const bool dst_private = dst_r && dst_r->is_private; + if (!src_private && !dst_private) return nullptr; + if (wire_endpoint) return PRIVATE_RESERVE_WIRE_ENDPOINT_REASON; + const bool same_owner = src_r && dst_r && src_r->owner != name{} && src_r->owner == dst_r->owner; + return same_owner ? nullptr : PRIVATE_RESERVE_OWNER_MISMATCH_REASON; +} + /// Parse a WIRE account name from its string-spelling bytes (the canonical /// `ChainAddress.address` encoding for CHAIN_KIND_WIRE). Delegates to the shared /// `sysio::opp::safe::parse_wire_account_name`, which validates charset, length, @@ -547,13 +588,16 @@ std::vector wire_name_bytes(name n) { /// non-throwing instead of letting a `check()` abort the evalcons dispatch /// chain and stall OPP consensus chain-wide. enum class swap_remit_disp { - ok, ///< envelope built — proceed to settle + `queue_swap_remit` + ok, ///< remit built, amount pending: proceed to settle + `queue_swap_remit` terminal, ///< no underwriter can EVER remit this uwreq — REJECT + refund disqualified, ///< THIS candidate cannot remit — skip it, leave uwreq PENDING }; -/// Pre-validate + build (but do NOT send) the outbound SWAP_REMIT envelope -/// for `candidate` winning `req`, mutating no state. +/// Pre-validate + build (but do NOT send) the outbound SWAP_REMIT for +/// `candidate` winning `req`, mutating no state. Every field is set except +/// `amount`: `req.dst_amount` can still be a stale or zero ingestion quote +/// here, so `try_select_winner` sets the amount once the settlement quote is +/// final and fits an OPP TokenAmount. /// /// **Non-throwing.** The former `emit_swap_remit` ran AFTER the caller's /// `reserv::applyswap` / `applyfromwire` reserve mutation and resolved the @@ -563,11 +607,11 @@ enum class swap_remit_disp { /// envelope and stalls OPP epoch advancement chain-wide. This function instead /// reports a `swap_remit_disp` so the caller can disqualify the candidate (or /// reject the uwreq) cleanly. It is called BEFORE any lock / CONFIRMED / -/// reserve write; `queue_swap_remit` (below) only ships the pre-built envelope, -/// AFTER the reserve books have moved (so every intervening quote prices the -/// post-swap books). +/// reserve write; `queue_swap_remit` (below) only ships the remit, AFTER the +/// reserve books have moved (so every intervening quote prices the post-swap +/// books). /// -/// On `ok`, `dst_outpost_id` + `encoded` carry the ready-to-send envelope. +/// On `ok`, `dst_outpost_id` + `remit` carry the remit awaiting its amount. /// Failure classification: /// * stored-request decode / dst outpost / dst chain-kind — uwreq-wide and /// identical for every candidate ⇒ `terminal` (REJECT + refund/revert). @@ -576,7 +620,7 @@ enum class swap_remit_disp { swap_remit_disp try_build_swap_remit(const uwrit::uw_request_t& req, name candidate, uint64_t& dst_outpost_id, - std::vector& encoded) { + opp::attestations::SwapRemit& remit) { // Decode the stored SwapRequest for its `recipient` (the row keeps only // chain/kind/amount summaries). Same bytes for every candidate ⇒ terminal. opp::attestations::SwapRequest sr; @@ -612,8 +656,7 @@ swap_remit_disp try_build_swap_remit(const uwrit::uw_request_t& req, std::vector uw_addr = sysio::pubkey_to_bytes(it->pub_key); if (uw_addr.empty()) return swap_remit_disp::disqualified; - // All identities resolved — build the envelope. - opp::attestations::SwapRemit remit; + // All identities resolved; build the remit. remit.recipient = sr.recipient; // FORCE recipient.kind to the dst chain's actual ChainKind. The ETH // outpost ships SwapRequest with `recipient.kind = CHAIN_KIND_UNKNOWN` @@ -623,10 +666,6 @@ swap_remit_disp try_build_swap_remit(const uwrit::uw_request_t& req, // → on-chain `handle_swap_remit` rejects "recipient not in // remaining_accounts". Per the project rule against 0-as-sentinel enums. remit.recipient.kind = dst_kind; - remit.amount = opp::types::TokenAmount{ - .token_code = req.dst_token_code.value, - .amount = static_cast(req.dst_amount), - }; // `original_message_id` low 8 bytes encode uwreq_id; the reflected // SWAP_REMIT envelope back to msgch's dispatch uses this for the // release-trigger decode (see sysio.msgch.cpp's SWAP_REMIT case). @@ -640,23 +679,22 @@ swap_remit_disp try_build_swap_remit(const uwrit::uw_request_t& req, remit.underwriter.kind = dst_kind; remit.underwriter.address = std::move(uw_addr); remit.unlock_timestamp = 0; - - // `no_size{}` — see emit_swap_revert for the rationale. - encoded.clear(); - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; - (void)out(remit); dst_outpost_id = *dst_outpost_opt; return swap_remit_disp::ok; } -/// Queue a pre-built SWAP_REMIT envelope (from `try_build_swap_remit`) to the -/// destination outpost. Sent by `try_select_winner` AFTER the reserve mutation -/// in the same transaction, so every intervening quote prices the post-swap -/// books. The destination outpost's ReserveManager (ETH) / reserve PDA (SOL) -/// pays the recipient inline via `_handleSwapRemit` / `handle_swap_remit`. -/// Non-throwing. +/// Encode the SWAP_REMIT that `try_build_swap_remit` built and +/// `try_select_winner` priced, and queue it to the destination outpost. Sent by +/// `try_select_winner` AFTER the reserve mutation in the same transaction, so +/// every intervening quote prices the post-swap books. The destination +/// outpost's ReserveManager (ETH) / reserve PDA (SOL) pays the recipient inline +/// via `_handleSwapRemit` / `handle_swap_remit`. Non-throwing. void queue_swap_remit(name self, uint64_t dst_outpost_id, - const std::vector& encoded) { + const opp::attestations::SwapRemit& remit) { + // `no_size{}`: see emit_swap_revert for the rationale. + std::vector encoded; + auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; + (void)out(remit); action( permission_level{self, "active"_n}, uwrit::MSGCH_ACCOUNT, "queueout"_n, @@ -756,14 +794,10 @@ uic_signature_result verify_uic_signature(name underwriter, const std::vector& uic_bytes) { if (uic_bytes.empty()) return uic_signature_result::empty_uic; - // Decode the UIC payload. - opp::attestations::UnderwriteIntentCommit uic; - { - auto in = zpp::bits::in{ - std::span{uic_bytes.data(), uic_bytes.size()}, - zpp::bits::no_size{}}; - if (in(uic) != zpp::bits::errc{}) return uic_signature_result::malformed_uic; - } + // Decode the UIC payload; `decode_uic` bounds every allocation. + auto decoded_uic = opp::decode_uic(uic_bytes); + if (!decoded_uic) return uic_signature_result::malformed_uic; + auto& uic = *decoded_uic; // Require the exact canonical protobuf encoding before hashing or recovery. // zpp decode normalizes duplicate/default/unknown-field representations; @@ -1170,39 +1204,17 @@ void uwrit::createuwreq(uint64_t attestation_id, return; } - // Privacy gate — a private reserve only swaps against a counterpart - // reserve owned by the same WIRE account (the authex-linked matcher - // recorded at match time), and is excluded from WIRE-endpoint swaps - // entirely. Ownership is immutable while a reserve is ACTIVE, so this - // ingestion-time gate needs no race-time recheck. + // Privacy gate (`reserve_privacy_violation`). A reserve that is missing, + // PENDING or CANCELLED here can still become an ACTIVE private reserve + // before settlement, so `try_select_winner` applies the same rule again. { const auto src_r = find_reserve(src_chain_code, src_token_code, src_reserve_code); - if (dst_depot) { - // Swap-to-WIRE: only the source reserve exists — it must be public. - if (src_r && src_r->is_private) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: private reserves are " - "excluded from WIRE-endpoint swaps"); - return; - } - } else { - const auto dst_r = find_reserve(dst_chain_code, dst_token_code, dst_reserve_code); - const bool src_priv = src_r && src_r->is_private; - const bool dst_priv = dst_r && dst_r->is_private; - if (src_priv || dst_priv) { - const bool same_owner = src_r && dst_r - && src_r->owner != name{} - && src_r->owner == dst_r->owner; - if (!same_owner) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: private reserve pairing " - "violation — counterpart reserve must be owned " - "by the same WIRE account"); - return; - } - } + const auto dst_r = dst_depot ? std::nullopt + : find_reserve(dst_chain_code, dst_token_code, dst_reserve_code); + if (const char* violation = reserve_privacy_violation(src_r, dst_r, dst_depot)) { + emit_swap_revert(get_self(), chain_code, attestation_id, sr, + src_chain_code, src_reserve_code, violation); + return; } } @@ -1453,13 +1465,13 @@ void reject_and_refund(name self, uwrit::uwreqs_t& reqs, const uwrit::id_key& pk /// (WIRE) leg needs no UIC, no bond, and no lock — single-leg swaps /// (to/from WIRE) therefore resolve on their one outpost commit. On a win: /// verify the required legs' signatures + bond, pre-validate reserve -/// liquidity against the local mirror AND pre-build the outbound SWAP_REMIT -/// envelope (so both the inline reserv settlement actions and the remit are -/// unreachable-failure by construction — nothing past the CONFIRMED write can -/// `check()`-abort and stall evalcons), push one lock per required leg (a 12h -/// wall-clock challenge window — never released by delivery: `chklocks` sweeps -/// it at expiry, or `sweeplocks` erases it earlier on an UPHELD challenge), -/// mark CONFIRMED, then settle: +/// liquidity against the local mirror AND pre-build the outbound SWAP_REMIT, +/// priced once the settlement quote is final (so both the inline reserv +/// settlement actions and the remit are unreachable-failure by construction: +/// nothing past the CONFIRMED write can `check()`-abort and stall evalcons), +/// push one lock per required leg (a 12h wall-clock challenge window, never +/// released by delivery: `chklocks` sweeps it at expiry, or `sweeplocks` +/// erases it earlier on an UPHELD challenge), mark CONFIRMED, then settle: /// * normal — reserv::applyswap + SWAP_REMIT to the dst outpost /// * from-WIRE — reserv::applyfromwire + SWAP_REMIT to the dst outpost /// * to-WIRE — reserv::paywire (REAL WIRE to the recipient; no remit) @@ -1626,9 +1638,9 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate, // Request-wide build failures remain terminal, and every outcome here is // still after the authoritative bond gate above. uint64_t remit_dst_outpost_id = 0; - std::vector remit_encoded; + opp::attestations::SwapRemit remit; if (dst_needed) { - switch (try_build_swap_remit(req, candidate, remit_dst_outpost_id, remit_encoded)) { + switch (try_build_swap_remit(req, candidate, remit_dst_outpost_id, remit)) { case swap_remit_disp::ok: break; case swap_remit_disp::disqualified: @@ -1675,6 +1687,24 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate, uwreq_id, ", leaving request PENDING\n"); return; } + + // Reserve privacy. createuwreq applies the same rule, but a reserve that + // was missing, PENDING or CANCELLED at ingestion can since have become an + // ACTIVE private reserve: a new registration, a matchreserve, or + // oncrtreserve's reclaim of a CANCELLED row. A non-zero quote means every + // required reserve is ACTIVE, and an ACTIVE reserve's privacy and owner + // never change, so the verdict is final. + const auto src_r = src_needed + ? find_active_reserve(req.src_chain_code, req.src_token_code, req.src_reserve_code) + : std::nullopt; + const auto dst_r = dst_needed + ? find_active_reserve(req.dst_chain_code, req.dst_token_code, req.dst_reserve_code) + : std::nullopt; + if (const char* violation = reserve_privacy_violation(src_r, dst_r, !src_needed || !dst_needed)) { + reject_and_refund(self, reqs, pk, req, src_needed, violation, PRIVATE_RESERVE_COMMIT_REASON); + return; + } + // Slippage — the live settlement quote against the user's ORIGINAL // `target_amount`, never against the previous quote. Measuring drift from // `dst_amount` (the ingestion quote) would compound the tolerance across the @@ -1700,6 +1730,24 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate, } } + // Outpost payout amount. The SWAP_REMIT carries the quote as a signed OPP + // TokenAmount, where a bare cast would turn a quote at or above 2^63 + // negative. Terminal, and ahead of any lock / CONFIRMED / reserve write. + // The bond gate above keeps this unreachable while opreg caps every + // collateral bucket at asset::max_amount. + if (dst_needed) { + const std::optional remit_amount = opp::safe::to_token_amount(req.dst_amount); + if (!remit_amount) { + reject_and_refund(self, reqs, pk, req, src_needed, + REMIT_AMOUNT_OUT_OF_RANGE_REASON, REMIT_AMOUNT_OUT_OF_RANGE_COMMIT_REASON); + return; + } + remit.amount = opp::types::TokenAmount{ + .token_code = req.dst_token_code.value, + .amount = *remit_amount, + }; + } + // ── Settlement pre-checks against the local reserve mirror ─────────── // The reserv-side settlement actions (`applyswap` / `applyfromwire` / // `paywire`) `check()`-abort on violation; an abort inside this @@ -1755,7 +1803,6 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate, "uwreq rejected: to-WIRE quote exceeds asset max_amount"); return; } - auto src_r = find_active_reserve(req.src_chain_code, req.src_token_code, req.src_reserve_code); // paywire gives up `dst_amount` (to the recipient) + the fee (on the gross // WIRE leg) out of the source reserve's WIRE; pre-validate that exact sum. // `dst_amount` is bounded <= asset::max_amount above and `to_wire_fee` @@ -1781,10 +1828,9 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate, return; } } else if (!src_needed) { - // Swap-from-WIRE. drainfwq validated at queue-drain; re-validate the - // live state (privacy is immutable, but liquidity can drift). - auto dst_r = find_active_reserve(req.dst_chain_code, req.dst_token_code, req.dst_reserve_code); - if (!dst_r || dst_r->is_private || dst_r->reserve_chain_amount < req.dst_amount) { + // Swap-from-WIRE. drainfwq validated at queue-drain; liquidity can + // drift since, so re-validate it. + if (!dst_r || dst_r->reserve_chain_amount < req.dst_amount) { sysio::print("try_select_winner: destination reserve cannot settle uwreq ", uwreq_id, ", leaving request PENDING\n"); return; @@ -1793,8 +1839,6 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate, // Normal outpost↔outpost swap — both rows must cover the four-leg // apply (the WIRE intermediate is derived from the same pre-mutation // source row `applyswap` will read). - auto src_r = find_active_reserve(req.src_chain_code, req.src_token_code, req.src_reserve_code); - auto dst_r = find_active_reserve(req.dst_chain_code, req.dst_token_code, req.dst_reserve_code); // applyswap debits the gross weighted WIRE intermediate from the source // (the fee is taken from it before the net reaches dst); pre-validate the // same conditions applyswap will check. @@ -1919,7 +1963,7 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate, std::make_tuple(req.dst_chain_code, req.dst_token_code, req.dst_reserve_code, req.src_amount, req.dst_amount, candidate) ).send(); - queue_swap_remit(self, remit_dst_outpost_id, remit_encoded); + queue_swap_remit(self, remit_dst_outpost_id, remit); } else { // Normal swap: emit-time four-leg apply, then the remit tail. action( @@ -1930,7 +1974,7 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate, req.dst_chain_code, req.dst_token_code, req.dst_reserve_code, req.dst_amount, candidate) ).send(); - queue_swap_remit(self, remit_dst_outpost_id, remit_encoded); + queue_swap_remit(self, remit_dst_outpost_id, remit); } } @@ -2058,7 +2102,7 @@ void uwrit::rcrdcommit(uint64_t uwreq_id, // Reject oversized blobs before protobuf decoding, hashing, or recovery. // Candidate evidence/status/reason/timestamps remain untouched. - if (uic_bytes.size() > MAX_UIC_LEG_BYTES) { + if (uic_bytes.size() > opp::MAX_UIC_LEG_BYTES) { sysio::print("rcrdcommit: uwreq ", uwreq_id, " UIC payload of ", uic_bytes.size(), " bytes exceeds the per-leg cap, skipping\n"); return; @@ -2172,7 +2216,7 @@ void uwrit::swapfromwire(name user, check(r.has_value(), "swapfromwire: target reserve not found"); check(r->status == ReserveStatus::RESERVE_STATUS_ACTIVE, "swapfromwire: target reserve not ACTIVE"); - check(!r->is_private, + check(!reserve_privacy_violation(std::nullopt, r, /*wire_endpoint*/ true), "swapfromwire: private reserves are excluded from WIRE-endpoint swaps"); } @@ -2281,7 +2325,7 @@ void uwrit::drainfwq() { refund_and_drop("target reserve missing or not ACTIVE", REFUND_FEE_EXEMPT_BPS); continue; } - if (r->is_private) { + if (reserve_privacy_violation(std::nullopt, r, /*wire_endpoint*/ true)) { refund_and_drop("target reserve is private (excluded from WIRE-endpoint swaps)", REFUND_FEE_EXEMPT_BPS); continue; diff --git a/contracts/sysio.uwrit/sysio.uwrit.wasm b/contracts/sysio.uwrit/sysio.uwrit.wasm index bd1979853c4a432eb2b44c94d105d7a6f8d4fcd9..1784a6b1ef2f7a7e806a34423363a8a855fa7023 100755 GIT binary patch delta 41316 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 delta 40637 zcmc(|3!IhH{y)Ay>)Dr?*>iciXS(gZyO^jHsmP^g=!S^n;OiXc=(x5gmC%ucJzXd% zCQqyo4Wf|KFfz$)+y`MIM5#dtq3Afd_J4mq>)Ctuo=fGN)9sjlwKDYJx zeAZ{Jeb0B<-+qx@(KTTGX#Lym5jerUBrv3%--`B8N*&a#uj&^*xL-fj!^+RkKg4}K z@GtlI;FRpLQVJ)0PFbltG}Jdy?$17rd=7;VT4>#2-Dxed%B{Ps4c3R&Mr)H*XMJRy zcKR9rcjlk|eAZvi9`m>3mstXA8>jmpY>m}=DtH%1o+H8Gl zeQs^BzOZuE-5p@u|!c|ONi5O(mlNR^gS#OEAgPmWPMrUvk{d9gDQ zvm(B@QxJD1UL5lcP&@fs<=B@-e8+POxTiRO;2xaSl?J+}Wp!=2BY|?M>-dhw6))#z z^-wcPD_3OgY@=qCRxa;c>8D%WbGm*>w^p9m?Ifa?-8;LlrbjAEdfcPvhRTAT(-pm3 z=^j|3=qGnq?^E#ils+4J&$c4uWX0{}3Rl~^wvXFV-qE{8_!?d{pz==#9onXg8Ul!e zL-Tx*KyTlIz|cI4Z?^cIU34)X+`EPrCdyUZnOEzxC&ZLJF`|0=Km@#`h$>Q9cHdkI zM*x7>q`9Z zC$+8|e%vLBDk`5i;j93C?Cy8UOLVnccgk^;sO{vjX$Nte|LflE8q6n*HVOf`}#$d?mIxm+>_3E ztK;r!R~~t8m}sUu^1N=j{P`>}dc<#E=T13qa^)50A4;LwAa0g@nfurU{pd1x%LOA+ zB?B+ylCc+d=x{wngSi&!In^YX=WootyXeBdpw@2}j>6v)##YkYo8o_t-5>*i6gXP| zcg2@J`$lwJq8t_y>I-S#I{d-@m@ZkJv3q!UvXs&e?ar}8XE{sWb|*~VFTk8I-<>M#wEkj-*1)o=;AlLUOh`?m#prF!Rv8`E*ShyzpMASqaP7cB3%Ol@G3{NSbKSeX zNKS_VD#Bl(jK^93&3kw&BmN2>-{D?;ZJ%S)p}pok@AWP3`RqF>SCp5GJQA`OQ7oG| zMNn(-5y_2MP!r|uwrktbU2fLQ13IKPS1rxefluB+dw08|W{%wN9uffXXQdU#-wMEI zsIz?LK)TKS9Df(OU1se!=*mJ?!+SHTj|6i-^3534Vt3-KV<23Q&iXUm>$bSAPj@IZ z#bO!pL%;BiEhRgWCAhKgbI-l*to@~YDb$bD#EsO9_M5E=Uy{TtfUK z{v!2DfUEShN`v+#(E#5KD(BlEzuX**p=oDJFb-7^%VN0?ICM!g8-=C8cr*tZyKg*- zIuM4Cd)AyI2XbE_XLQ^aw&Xtel@W>`pz^rA+ECn?{}n@4gS)4`^4ZJWHFF|8QV{Y- zJXrn|W<$t1JKodc>xFUqA-CP!uIDvf6*Gvb2W#LV;2ykzHWmb@mwspUm1jSVpxGla8eR;F=@U$ z2n}o6iAnoQO+iEoK{JZv+W(|j-axBN5WO;J>MaOfi5BwC;0k8~;h_#NGW^agAULEF z+!bHBr%`!Ho9_kclfS4RVEC?lu5?pkzVtOi;l2 zbgN-1W~KTuGE-Hsa*70@fKYp-seSj?yQ$6W!g93*b2ZE8WpIN8HED3jb6pNSP&n%j9noO+46&p#66y%I-RO;@kR8;?6bO_^G~+6X)gj zdTLt|>}lJ5_M>DbIW%7#jFI8SQm`C|ToZK1-E>a>7k~-yB98*>13S&{pa@RG9r%6( zTF+uVGC-l(NVum0rfUQ4!)~OgELoku+XN%>*rqC%N#))v(TkE^n9G49!9Pmv%LQXz z&&<^@S`T&)8?}sUFqHO4h7A&9gvIaR4Zhk9nozSGFuj8TfFeSI&htBV_c$CM8V>iQ zVHvbzQW_o&xQpjU63xiX%Cwr*fqHRDD_&4vT$@(Bj*D5!UND7_g(9^Yg+dyNsZ(+0a31l}`diioS*aK7omoZ_c0z{v(`~qlWVm17+%8e$Jym4yK-g<_#$c?`1fo(GIWcA$_u;tJw03noC>TI+Bh zpym(Ir(J@E8w|y3G<`rMBTDRfM0GqO7+j`xjA$H#=8XuQhV66safTH`uLuKJ4172e zW(r9O(?|xDRdRe@%ZNMyy^!~?J+OXAkuYKa04I!(n0FYfGIaBCBWkp9Z(L+wlnMWQ z(2gm>G-QgD1qNE{82)tvKclmyeG!0wR;slyf1nx+p`9u>2e%p2Z>HC74SH;f2Wq*; z+BA&C^IGs&ga-_O+f;7$?-}QWJ&&`7d#%YBC(M$Jac)t$Sq;V+%khZQ2)~8y#M^TB zf4cFX zqfL##YqVZEEvObANMg?esbK6^qzz;ob2kqJ`UahP(pjfGSyU{YlqQ|2L9ukUFw%(; zWlAT;!qVx1!=!2+_`QdHDZ_4DBVa~|AaY`07eQXf$TB#OSR)aU;HSC}j}7mEcTpK3 zJT+7qEm(`zqts~dz111z3|YCss;4u=1sT1H!;#i&jmi!D zk9#fSUdu9i6$?16*J{PVfJP|(Z^nuk8Vrq$UeQB_wpyt;T((S6qd2Fq~7(!GEKxpX^Du9%j|EhH!ZW$0J@<(23i-QKNCPE=mL{wxJ?UKj^)9h{z1@3>} z94aRJUe2LvE$!_bIs$Oiq>W-6b479rZIhfr7kE?X{M}EXn3UvWZwd_z@ND*dGljCx zQk@2R9F}(qbYlwd4MWREtj;4XQj@HTCZu?Sit_wwD8(iW1{k(iP%LCWhWHE!3?qlv z#NfdoI2N#TIgW;hX5$sFd?&#J)L^Ik+VA-(i+yDd_hj)Bg7J#oBbT&j1UBaktNsNFuE@11*R4jGM)QJT6NHN$e`}B2XCLtmOD+ zu>!No#C%;S_X~pdN)APt9wR<`Jns5Jkc-p~KjIQ)Z}?zF;_t`~4`metFWj2jZ)rbR z#O}FE58=g736EqYQRpvDa3_fOiAs#b#P;xA8AJRxg8xrNjML9+3|SK9)w9bBE`T^yHroTorILRjxZ$DtzS_Pf7LEBM6rO;ji* zjrZ0)@cs^oWI0FLP~Bi5iLVABq^cA*c-TS|P*z9{XBf;OuEsF^D+8ug^)TfMuNnAq zi8(OYTO;b=6~JRm?FkSNwK=jGaxmsih^n`*#xkqAKw8fPQ<_dS1K==*1O|Bk5C1D2 zsZa0#7+04;+CBUkTsA0_z-HJU9mNM6isu>8$w9ybu0`>V>7}LRa7VZBL+NH++tL|` z6?;ENo)(iPmxFgD8@buL!O}?nc<&d^=Bl^U~9z_w;l}i zve5)Iv*E=^;ZY?=&VyoZ;Bp>Rl5C86<{~a%8SGF`H>NGq%_9Lqq|2AJ$C^dlu4aWk z6)Ph4vs7pZB5`dFR6j??>kt|?4HYv=5-MhtBvi~OO+m$@*ycf);9)RS$zhb@&VXLe zO$<46SPJrZamA+D@BWin3g{|dv2yFnD-Okb9?bK*9}GJ)sF@^7f**EA7nVH!?>`(m zST#A+Ou~4h9wN-|35$`9vOwnWN=w&%wSD9L;A9!!3+dy_wcG>l@0e26Jm2%=!&-nn zXK|6k&aCf}3hQM|zb_^g7L-2f!xag{LNfnwG_@`OHZV&zMQQ=e0(s*s$T2OxI{jbmjGAuu$F#~CVy zwM32=TfGuKUkrZ82v+SpX(8Z-$`XB=CC-!Lbw`B2e(?ZBd>;RLs+~~RrD$DzVI?t# zEQG-DIp|GMHnkf5LpFysnb)hbLcxH~Zz=MH6DZ6&DKEs)Q$ql>E)e7IEEQ`G?liC; zD&9rYv98)Q+zSCCBbYJ4a4*30ayerQCNKUbF#Tu2i32WsB8VG>@vC@NtM>EcyYC@N zcpxF&OMGV`qd){IXF#;l&l>c94N*df+Z2*61Vrpz*FyyU%YdlDZxTnW)cS)k7t@a^ zHw?=b=3_%p$dar-2!UrQ!(+n#$aLGO4)=jX%-RTFVF3aPRwN|F!F$aQzl)6%%*SV= zEB5J=Y&?!3_t+R9Sy-OH<6=LO5OFQ{^VqD21=N`mr2ZJ_{0M*sBSC&Dg*eCYDOM-E zw-MH(5Gq8Z>ZzFEiss{e!s=&WP#hDery`(Bqhf-MFOJM)ARF$&Uc)5lhB_0i5rd@d zS#OL+Z|w5}-w8z0vJj~VaECJ#@Y93VI@RhfIKBhoEo`lbf1i$XHO0(B*#CF0r$oakUV*s!}63s`79AAN)xEm zm|38*j6i`{83KiovOsy%kOhS#Q9XR6O?k~yHjy#q?U}TLFsMQGBZ~vN)W~F>nZcZD z=oJY_B!ow(Fd?#tBy|9MeIsxQfw`1tWU1G1!tC3qYRle+`{W;RtNsr2xl-g+ktDC zV@jFp(}jVB00pxcSIoDt|Kt%I6=GrWaI*uju$jCSS7$3s+>rgUu*Fu($pKe^Y4905 zBK+M7h%pbtu5|p8Qp_K)b{)st1!%IFytjh)keS6iDKRDm>y*DvG#)kvjdfxb^@CIm zI>OxIcdpLp$ltgl3|heCi6>;7R+?; zPw-|Eiy3QwOqxMw*Dl}jQ3$CMnjFjLoJzr}RL(aQ;W>!V%g7T=QygHI`lI;`1`vRE z67iQrf(%RuB!t!i+2A5PRBT-x%PWq|T7oAbStM^sI`Uzey?rN_CIRd zG`)KQCjL_jva=3R_SWhQ;^&yj)Q<@gk%|Vyz+eKAu!o}n;|K$H5*al1)LjGFVSR$+ z36I7NRSMXiDV%{JNI@p{n-)qmjOC2LKH_naN-zcoCy=K@hc)!+HpD^?cSYo?a19AS2K#5HyzeNKR6{pUd;m|4_d- z7>;Owu>0E?T@uY|4{%m{k#NM@{n5N8TVM_Y=CXN$wVcb?%f%FD)Kh_|5Lg7Fo(eM5 zzNem|h+xC&6XlUtTuNu6nY?I$(V`q}!-N%QuJ>j$4nRR^<`rW$I}OuXUU6tvtN<|{ zOVp^;E;z!+iXS!?Sj?Z)ON&JW%NvVeg4L|ZU{cMB3}!(FGei*fd=qL`L=bBB08vvJ z9OEgeJ&9nVnTb(a-e)2xN{aC#j#g#_BPD}|CyfYVc5g}qfu=MOY#K+&DLy5F-mGr0 zl@xR{C#6qEp@uUZ8`W&XH9gby^?0>Hc(pON!pvZ9wLESOFt=itRw9#KA&AYft7m%c zhFjSk+Iw!5+64))YXe{nHyRFR>5|#=KjF$j9!gg31|fFY-PvKmYN8ti$tiz*{f`5!8n^4fggyP2A|FItVDZWw<~Ah!pH!7F1q!z+OO~t$05h{CDK8 zq9*I(^na0rKR@83pt3lu9zoCo3H}I}NW@|X-y#sc4Y63(Eyx^|hV2@PVKvi|U@(8P zrT&|(-ZxwDjg9Jd@|#!W8xv;bZ|cl92@Z{H_CBh^NA2-Z`{YNj%169S#tC4U43_f| z(8r_X*`8Nme25YAC=D5bW+@i)AAf)tmO{ohoY6Xb>tkmY=rQ#Y{51!I9YiqKflUDl z<9z_)$|wZlexZnBr*{Xg&JJu(Oggp+6$+by{$B-m%S}O z=1-)1y}NUIc?$?olo|jmQ??+D)64b-oAFIKy*#qCDGNAB&WJ203A0jXyxu)Iz2T&J z+5i^phsa@A#5zSFH4bAnhhYh&&CXyfW}gPIBto2423rqru&XF#oHlfN!ECU-crQ%D z*h9G9>BW@2C#N@@a(bn5H~~5|7)@hSph>5<@mQL2dQH{KyK!_ewNF5y$^?+11fppKf%kXX+G~!rpMP*@PFIw3LL}A$w6EOicz!bv&nE9J> zWYggeM3Mo8lp~vAM6=3lOg6y=;t3{Jks)MAwA_D1)Wbs44D8`@A7O!o$-!c9G{=nJ zEbL`Ct0@FX7%V5N^MplJ|989+or?5pxtF!5Ef7|))mY-CD0tC)?T51NWa{?}@`!Ek~l?oVfQ zg`e7(b41|5IgS0)6j4O%b6D*6?5A=EX@06VO-lT4ANi>{#+KW?pDJVd3(xJwGuWd? z&=^r7BpH5c3PDC^xxH3&{QptRjIt4j6l$hPe>4SfGTL14kJgJ>V}GUYoX4vw7?vEnp1N@(IzEZ#%t}~pv z7yf8tlq9*YDStH8ORyxd_rBLYK}n)tYND;ds-aH;Gu}{Ve-h1Y~X5YvDBO|o8Z#AD#lMO$TSNPl! ztMd|Nu$a{!8-QUXR7R3WYA|q_c`)V;8)-xH z_6JY!w=iAJI2)!T$l#ImS@y5pR@kZi z)#t4J)rY84-?;Ok+zg64J8+|%pXISA?p*g9S3u|?fS85-I>Axq)Yv!NGv0iezHuY9 zcZD7dsx0K2>W5oU8*|{3eys%+?KcH6S<5NIT+K;#7ANDILx6N{OWKd_bB|mz31?AN ztZ9e8HETN2o9<_8ZYbjQG7c7Cn3gjKR3ny+t4v`j2UJXf-qecr%SA2)vShdydyV$D zrUTut-#Qkj5FPyX-|_c>xBp5Hx;blm(p9?eajLz0!rJ4qf3+g;m+jwh6wTUVRJ!+_ z{oU+$PRK)f!0y-Z-6U0N=|6o#MKskde)m$;uX^_{-YHuLyf=v6cF%cl7_D_5c<+qR zJBXe5?RVYmbq;-kL$M-m$+{7=$bEEO*E7pO1LijD+;$dw_u0I}nhP2Sog7|w2s+lK z*bKwu;;Ol*TI1bAYC>3xbH4eP*%p+8L)~ z0e91eljvLbpbv+&{GQW;{mx>L9C`oHIy>B%AD(pPCW80x=X@_ns^AJqyu8ib`Dv%m z_Hhv>@I%?+s33mes%h`}Z8f;0@msg=#wh*EJ!RuF1ZkPwA@S;QNY~-)Y2;3C!vc#O z66I_G_Aqw%kxsRwLR)-`^kkMJzTF1LDl7Gbs&yC#zg)-T!E>$mT;pAlJ1y^#J1y6x z<{Gogy#gdr%L~rs=Y~qX>o)g|P3;n!>j6UJe58S7Fmqn@3;X~bQC*(`;-!@1@Bx!y zjm@2Kh51;VC)p+T;Kx}XhyghZKpo^^Co`-C`a0wd6rn|!O)`CD1II<^DVFE<{VH~J z4WD6yUh^c-xsZv7oJ342fEmfih(lIz9(@JV->E}Xg_Ef94$1e3`qm%!@nzk?;~9K2 zoebln1{j=F-)V;j)NlmH8#$93nY93thlKXij`m1F?lo%Q&j3HMXYkW@;2ZGAnPU(H zeDoSHXM1e+vw?$ANvJjOU?tA6XaN(gfSELPuE!qmWfJuQ zH~}#xiF)pkuUa;Mp(*k&U>MAf{UrI3PvMc@fL!ZOo%FHWQDd*4B(J8y@WB_Y8oouB2 z5(703mo=KGd8q4;7%)J4r?AwpB0`s*Bv*Tq{G^w)TQA9}A+aPQJDLZG@rWd+HZfx) zEXf`;OrjL3{|nG30wLz;fh4{~hz#f=_bXv`dL2aaBrg|F%t*{Q$j>s`1$K91?P51% z@x4?p_2&~LwF_EDl106C!43+T_>FsP*kNPI_POi7XsBMA;>5F=wP0YH*~08}$%J94 z_!s*UhWc{}l6b&ml!1qGac=e{45NVobId>z2AqwM0Ry%{z_Z$yFw`Fskc0t?2pKSx ziCZTEot$mxF3FDmt=Ob+OrE-3xOJFYM|8@?IT;>;3Zdf zhk;J8Xp&aJEb+UASN5vU2FkaoK8w&DGSln(6d6hDvvjW%7#o|cR5RJ9RTHx`%+vP2 zlVKug8|mcr{NfpKdz_VbU|W#cS!tj%vvMRb3|g1Tg~|B8ot5{3ciN+KtoYaS4#dyn zoi6Sr-?Yw{lRbNxYwX>e%!vQr&&jZF*riOu%sXiWW}dfTMH}N9N&7;H9kNLXrgF-- z&X|&UBaIo3nUV{!*O?3U&6EtmgtL@_oOjBkAeR|=8>?F*sLjvFQiZuU;_VMG%$M-> zwEEh&gLQC*KF)PxdutsmEh&B*PrZE`OfiSMm_ON3tc8ph*FN~ryK{NVH%>PenP;lO zH{^Tl4HbtmZy9!ga+|EEail0(mILbSe=812sUc9Ey?y0sD8dqWjr9zq$|wJ!WQXDq z1t+`&logO2FHY3%6Ai$;l(1^Pd*}9cUHKL}+yYKC9;k~T`!T@9#VE9 zb{}v?KCZ|(2tamYb~Il;2ar2?l^dRWv5yZScD7MG>9TLBzg@?Rgvs*N-WLX&e1-zQ z!DbG*mSQibQHj8upxa!H+YL&uWZRsv%HFnr5)=PPVZds?oj`H|h&MuF1`$~0T#-u# zfo%K@*5jW^ev5NUSQR~kFwE6#u)_mj|Jxn>T@?G7@bOmfRG$ zgmN4U!1|`d3lS#+$vDDk27|FgYzdqCBl%LzFfFGMNMa6GPv+&|-Y?(BkY*&w#U`1L zGrZGMa`=<_j2s4~8etm%RsTKIV18i{79utzQi<^Okmi-ZqRID`Hf>IWR}nk8-d5o4AhA-*vw|{g%7(X=N*rj=7$@+? zk}W2FUz1TA;}+RXLx;Dpoey*X)HHN(Qa9w)LkGO=I0qV<} zD0vU3FN1I%Iy{xl3;8xE0DIr&w4OXk>)Ab4*m)80Mwik21R{P;570lgjV7;t8x5Zf zlF_?e5<{YWiR{0NfQrx$5g&JK@2+Dz3?M_2No1!oiJ)fCJ@#|unf)_NI7SyExFNrJ z2xflc(F*FD;I}3a%-ISik*J;tB#@M(ziv}V_K^l-9Q;l$^9vuzl5L8QGB9Bp0l=}4 z&M%~PiQP4Uv2%J5EBrJWL=)grASAUHBhT!F$;)K#ror;|z_3Gr{donFuGhG6PBZZm zoAXR!b6#>CnY^7mSOM@-@axBn{Bo9is2V_A;3eP}L^(Fe#Ul5-CS0F@pJ3VrQCBkX z1Gqu{F5rp;Jjq9Ok|pE}9C%h75?66vhV4oDUZR0DTf~k#qEHgR!#ecW6pn2s(>_K+ z!YDv5Wp}U80I@@Cg;REohD%8xcoSPr@;Iljkp%iUqtEsv5HXfG1d=oQ5)t3-L=dZU z*n!8D^I-Gg$?P z^*onhY-TEyU14ealA2L!NhU}XHWYJVEg^kU)`hRKpm4n0Gt*>NL#dZ0p`4nU`I^N- zqRFYR2`q>P1ItJFuRQ1?e1ho!Qi*?Sbo)HfeAsm(5wwS`oz)a00dyC)) zq;$Q|IYabBgV}plUXZX@L)@BgS*(({mApP=hh^e6)DX8xjVjra;D)VY&$Q0~41^0| ziicR_x%rQ_Yfd=}nef{KshxoJ8s2LG%1>HoPB(ikNQPj;DaTiD;z-V0GbWBVp^W90^V8DONFU;9%4zM;bwE)VJhC z=5t;oge{d936V8vkz(6m{>Hv>*iB8Vl>ge8&JZ4v%#37P#LvM-iQUZ(B9-8yBFH%# z6gWo(6KSS3fgySFE}Qyfn^YK0dz<=5Q8`S;pXyGH_4Ptv4n zhy=zD>(4YKz|YHVs89H3ubM=H~PWp;0>SQA( zWD2DpWH&9zqH#1&x6UTy*62Cebh;jqLtSaE9+yK`(oX$F4jt0=M^y-Xfd`NXbhwZA znnf^p{c$$^F^_+N(Ql4lt){Aj!n6^;AX$~4Pcw+#)ouX|qWARb0y;>3FR`s|Y10Ap zlRna>5$(6h&#ncWT2L^+LFRy@@od+3*mP-|Qe-|MgC2X{5JPgdaUHus>a$wV z-~b{5=Q@y~8@MknUd{xJ!pT5_-wSLa{#GOvofSNwgiWVyen zJp{^p!!NM?kCXc>K&yMVq06k_R74-thAzR`UwUE@wb6A&w4ZKyC~c(*z3WhVO+E9A zK6wybq;~zHs|V5lsq$a-Ed%Lswa6>qRA1iVFnWaRzj>Icf7Ico{;h}8UG$kAJeWGr zv-2~&`zFqz2C&m4?u>grnr#4z z5SO=SpwPZXZ#atPBB2ao&*$UdiWqvJzsd#UUwt%PNHg{4N7Mdvmo6AWXSUlVyDn|; zaQKsu@+TA@Y+0ss=@4o&;Ai~2AsodVS>!_q6z56H308phQsegOqs9TlU+MR29kT?K_s0OimH{!a+(L zVQ^mkAPo{8Jbtv6K|zkDNk+S2z1JN+o9L)v3F%=)suX7`vxG5#Qf7K(Va+i)wZ+%{ z^WK}@$+Fi{lt+wTRJ?lwasiPgm?P)k@h^b^1I(p4l5v@f0_bICx19Q8UXd*Kgjos@iAL)z z1?G-_SEvXDkOCo!{SM(KDG3;Q|5#XX$C7}$IN!iX0$vq)ueiKds=T-FZ12b|RJqVp z&iym?qI#vda_~84Z!rCNcWsUrvd<~TVn?Ym&log6^2pl51B+wFqv0BZT^_Fo@w@um zV(tXfYkN4|@69N+!!e_**FO)Z4`{o5Lf`4oNQ%&Px@aUFmOGp04+F-_ z`l69^xO#qrzJDa0NFVB*BdHs0)SZr_Luive`8ayn`WF3nIG#Gl&0)vWXH-`eIe|_i z`bhtM6h$#>+&YSm3jMUf=lJZ8^~O;&q}$CO$^*v+ferk~@lG)s@Ozc5^y z0}GQs?~I&Uez)^OOsag83Rly|3DfJR z`p=`OAC&10qk-1VRj-bw*V_tSKi3N{py%lmefEVw%@z9k3t@1`Z$4`pO9L@`9X6Iu z@~X}qOEsbO8_ih$9H;4YnXZe|lotQ8aIQIE!91nxRzvL{s^TRWUFUzpH1vfFiSX0G z_7=*l^o4dWq66AZGbRuJTBe5gRmZ;*n0!Z7 zm&<7w(ZBThQ|L?jL7#jD{ha-8hNv$OdBT-+u%7u3+LVpG1@&bk+i)*Ga*^yUFt2{n zH!Q(K`Lq7wN~)wC`i`sUXY6>dy6tLunAES3s8SlFexs_HrPNCbR!pUXJT!EtI<>bQ3z$R9v_WTrDex63Br|dMb!P4!t_;l*rOVESm zb9rGbHG7vN@HTDP9il&Z4)VgpK_n>a4i*QPl>ieLT#2kMW25yEo$f4c;(m= z@043;1!wRR%!H0JDa!V-S1LctyGD1a~dD*f;->Zv!+p*A4A-nNT+>!P`IsCs;p{_9-& z1F%*(mrfo0@g~>?5kK$z5ep!d*pA>E9D57DtpK}VZk=aAq*v6%H^8>tp;z4i$(pWj zzY(TeWu5-vMmkc6@uMD8Mh5|tk~6~z=S~G?1a_4}Y;cuPlxFDiG8#|UR29sFJE1^iVor31W2QWufsb!kZuN=;to_qo=MUN^2*s8$_8CWxrALF zy4n>Jew|C5VfzU%yt*!#AA(372D9*P0xuf`y};)=d_J)O|G<1P;A1z_0z5r&Gu=to z>N9VlLmJtUJuRjM?8suh`4;Lyx9G5jzm?klk)_YnRM2xKejxmOF$H!eJl; z1OtQsOA8_T+$n?@eWwuOnLB9+(Y#yAsl>zo#B$kDgPoo!{9{K=694+)a^6v+Un>`6 zA9a@?`?R|R+0r49Z85UZAGlUucb7r7DdQ%A?3H&3vWxB(WS8F!9-gP`?xuTinQ;$o z#Nf_ZObFG%F%L4v6ks|fT7wc1^K`ddJMXE z%uu{GF#?cpx?8{trSvr|7dD6n`6w9!|OOvT;|3ku!(+!C^N(NW6)K z-6O8R{i(cG$O%Q0nPB)S51f7+>`{X5bPy7R-J$vi#aVQJ`thiP`J$3Eg% zL&$mcBmVXGCD4~Q>c2li!`pAf3;fy-I1o!2kA2J!PABLeC+wLZ&vsP1Zmp7Mb}o+QAX1pODTZGh;4MdDJ@RfI{?&JP&Ft2C z^9t&m1J%G1Y1s>P?qh^aTl%EO=q7cW-%U*GqPtF|?t1?!{OG~$dPx;stQPw9#Gb0P z?z57*(`{9!u7tUPtGkMhRji2Lf0CxBWct#lWKy{CDH@GQq3)^Vq)@24K262yS4(fJ zrYGoDz3f@~@Rn-yGEbLQ)A;%~Re1AfCOL}n)$?>74Cm{fPZ`dSK2JY1p11Ev&D(2U zAbrA}GWlS)B$#(l++LLS2wNomu~*zquUwS**bR^R(HCi|TKt)wx19#*2`^EIA4)b; zH@)yBy8I~9+Ggo%Er@ZKbY`A$s<+o0CcXKILT?O)#uqrg2^; z-SmMq;PQL)h#HtQ=xa(1odnDNl^Uu!T6$xr>1;Fu+Ou?~Ix)`(5|~c8+UQg@q%+%# zbnPqDwtcD*#8bRRVDsVaZ?B-^yLE?G>5suFF=e}??|7Ly>-n$J61r0#@fxC8-|2C$ z(SjbSHu|CsgXspS1uhrq16I=!YMRo&zDS+)HLGa~Ez%#o4xwJ8e|VkxtJ(6ZogVW# zcp>xE_1>#v-oP$0(>aU@ebH;wQ5U>PQ(G9!v-by!NZ`Ng&GgYRip*O1;vOTY{?Lx(?|WDMIeQh7xK6@#x#>XVy@u zH^d`T*lQ0MIm*Ruy%a(=F|8o@)zgabpZ4~*kY5`$_&9UuF#Df+-dl7*(zu1K8+n_$ ztCgSV@?%stUGg^F)WLX;KKR`BBY+~*O6FHx@E+Ynw6f}_b@T)E z;ekNKaQ6?wHsHSRXw2%IzAK+B35aY*Hc()cO9J?3YytSAE zpHzjiy;YySkw%4|mVxs~uG7mm(t`yk@W!4h98>k`CQNMfjL!K8;heP5CvP$E%-vkah(2!EaFnmM`Ofn0@QWp;s1$R-B7^P% zpgR-Kvt%-Gu$#+NUVs~EdNFRKd!9<9X>2-#Kwq$(eR0u--So*H({h9{=@XdW&*?vY zLVXUCZ-B1n89t0#t{C(xeg@%R03;mN#2=g&SK@R*1|sZnwD{yF@H1c3+dsjav`Y8i z4AUN^P_#pG?=e6ZA+Gt7F8z!;gkDAo+zLJ9J8G@J-c0AyOZwDL>0VW%boCHSR|TKZ zdFkK1BHuy&7Jf!A(5rgP=QJqz8VosmwO;f&Vgqkfz4tk6PW3v1z&zib{snzaZ|b36 z!s4y1y7WsbP(~#7(|2s8 zw))sZ{`T*v6QVE$-&6PSW&(6kY100XfnI3){Xa4!^FZ5MA=!ygm{e!9J z2@RSO#%}GQ676`X^g4l)A}~eJ4j(EA)@3uRNLG>nD=Qc&XNZ!O`!-cL2!&8q`8+H$ zdhTM$jDL{|e+ig4oP|x#`q_}WKz(KSttxjXR56ft+K+T#iX=;ZgthdIKIbPofxfM( z_=#R37z=;@1xr-MJJzrKhB)1gx?&g1GUFB>qDy|G{R_q4KiqdaI}{6Lj)Y6)kX?o; z0B&^?AD%%s-aJwrOzzEBs)KX*dqhV3aBxt+?agnfJ6hj@2pD`O32c@V;gr<6A9aFM zQP#CT`J&_?;Iy1n8xB0ZM5sB@&2A*fxzf^k(`bKxU!3yKVXyA#0iGxH zXO=oJ`ANe2*jB?1yA#isj54pvxnCg_#j_{hfQOV+(ggl@v*4@%0TW7P8CV}`3&&ar8?w1>wZ}#>4iZ!-Wo|U{|{VXM{ zpXGCq)O^MISw825*X1fa2=PfZ1g$LJad$g?P(Ymnj&TF32TiG36@bHq1*WQyx)@IX zgr2I6J}OJ~p||u!S*kylP%{^jkPE^gB8dR}n1v>0=}5Nf)2QB|3cpoxl|DCHB|NL5 zoBq>F(9FHp!d!+y(IZEVvL40EI`uO+2y^tj92Gk{NddV+yyXg{%V-467=<7iS72Dm zVQSiQ8A&zzprI<7>h7?x>I+LI9#+G+?2)iqobpP>=PC|WU6-p)fLHQvuG-&(stR@c zJaw{~iCHF3J*z(6sr%%s>uJ7Tm9Gw?oAr)-^=PV*>H=wGeE~T6Cf&Ico1&B8stCgQ2AwQ3iyt)p6^p8DB6b7EJ0ekXN0wAj;~;O?%_A9Yd(;wi7QTn_83&ZJU3 zzq2}sHG(n7Hg;AICs?##v#{iPQf5DHv2DkN!bIkMz3|$rtZ;YpK4L!Ns={BUs<2q0 z6H2z?HJ#QVzP6u~R-jOPNyWgojaqbq;f~UofX7wRAboldkUo=o->6+?47=LeKitC% z#Q+`Q4m{<1rqZUa zsyj4Lpqq-pr$hU|SW-&f)`PmKLy=H1zMInRJpu8g!xCZXAGhDuow}=MX|3MWT@|Z$ zEdB6L)Lswnp+US`l)<%Apb_7S$*DWh1Lvszm-JO9 z@7GYDeb2ap$y=#g^ux@SRDap1hhGZim?V>Dpfu%~Ac;aZohZ z^;Zt&!QTd`K2<;Vhm2wx9WzMH)lUpi#q?MeUi=MZRVe#RozOr!m?a<+vn@D&;eKh9 zOvA8X8qy*Z2DEYo``xK;>!iAPxdc^%5!U_1((4C{c!rKpy+T)EbT8ko^wFJF2mQw* zVEpZ}st!9+eMWGB3>EM4=GIbwa&AppbLcR6i@uV#MSPo_Ta$%g@8m7Bck-V7Bk9L@ z@#v7JR)PL>o?FG(kN$;FPbaQJvbi6oZf3NpzRh>;!z z6@V9VF6F?2l=J*|4G)CJ|F}NMQJb+;(Z5)Y%|c2@z`h3ABxmDTyhhc(KBnr4K?-fhW{I9-~I`6+wn-x?Ybt zRv2OKv6$|z(huP2uMOGXvyw0%Pf;h8B^pkuU%tf%j3*TDx#);}ej_y8l3TE|_ zN2_DKcO7-*Xmz*$Rt!_GIZ@5lpPUR`EcGVSTR8r!qdw&i>NJ{Hb^jk!Uj(4_2Y*!k z0#`Zu154Y-qsKe-fv2fn81EUU$>^t_rb=P{_)o|5@qj-3bafJy>)EHPK|}6S*qBe? z?t7Cr%aS)sk~jA!Z|+FmJe0gys?`~)duR~^`S~F^RejEY`=d`eOSNq^anjhyV=g@} zGU>cae9j}azVP%cJ@Y(Jl;3twdwbrb$>&8Tju|^??Dz{JlgCaNH|El@6UIkAE}gIY zm#8E3wh3xr)y-$KKk9QH^SeZC-?8;ENd*Gnc`HtC%6eCM8b$)#h*3$#ApLc9y$-QBoy z-^*~#!*vy|EpT1!T{q&|9?$>AwGh{q*a+4R*8^~Ei>rfc0j?+GYU4V2%s5?rsagg& hU)$*mF9}tRyG&i9sFnWi@9JIs=H+U%-yY)g{eSQig3tf} diff --git a/contracts/tests/safe_ops_tests.cpp b/contracts/tests/safe_ops_tests.cpp index 629c178176..8f89ac0a1a 100644 --- a/contracts/tests/safe_ops_tests.cpp +++ b/contracts/tests/safe_ops_tests.cpp @@ -24,6 +24,7 @@ using sysio::opp::safe::add_sat_i64; using sysio::opp::safe::add_sat_u64; using sysio::opp::safe::depot_amount_max; using sysio::opp::safe::to_depot_amount; +using sysio::opp::safe::to_token_amount; BOOST_AUTO_TEST_SUITE(safe_ops_tests) @@ -99,4 +100,32 @@ BOOST_AUTO_TEST_CASE(to_depot_amount_gates_signed_token_amounts) { static_cast(depot_amount_max)); } +// to_token_amount is the outbound mirror of to_depot_amount: a depot amount +// becomes a signed TokenAmount.amount only inside (0, depot_amount_max]. A bare +// int64 cast of a value at or above 2^63 would go negative on the wire. +BOOST_AUTO_TEST_CASE(to_token_amount_gates_outbound_depot_amounts) { + constexpr uint64_t max_amount = static_cast(depot_amount_max); + constexpr uint64_t sign_bit = uint64_t{1} << 63; + + // Rejected: zero, and everything past the asset range, including the values + // a bare cast would turn negative. + BOOST_CHECK(!to_token_amount(0).has_value()); + BOOST_CHECK(!to_token_amount(max_amount + 1).has_value()); // 2^62 + BOOST_CHECK(!to_token_amount(static_cast(INT64_MAX)).has_value()); + BOOST_CHECK(!to_token_amount(sign_bit).has_value()); + BOOST_CHECK(!to_token_amount(sign_bit + 1).has_value()); + BOOST_CHECK(!to_token_amount(UINT64_MAX).has_value()); + + // Accepted: returned unchanged. + BOOST_CHECK_EQUAL(to_token_amount(1).value(), int64_t{1}); + BOOST_CHECK_EQUAL(to_token_amount(1'000'000).value(), int64_t{1'000'000}); + BOOST_CHECK_EQUAL(to_token_amount(max_amount).value(), depot_amount_max); + + // The two gates accept the same range, so an accepted amount survives the + // outbound-then-inbound round trip unchanged. + for (const uint64_t amount : {uint64_t{1}, uint64_t{1'000'000}, max_amount}) { + BOOST_CHECK_EQUAL(to_depot_amount(to_token_amount(amount).value()).value(), amount); + } +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index bcf8ef6ffb..83eaa3904f 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -87,14 +87,20 @@ void append_varint_field(std::vector& out, uint32_t field_number, append_proto_varint(out, value); } -/** Append one length-delimited protobuf field without a generated schema. */ -void append_length_delimited_field(std::vector& out, uint32_t field_number, - std::span payload) { +/** Append a length-delimited field's key and declared length, without its payload. */ +void append_length_delimited_prefix(std::vector& out, uint32_t field_number, + uint64_t declared_length) { constexpr uint32_t LENGTH_DELIMITED_WIRE_TYPE = 2; append_proto_varint(out, (static_cast(field_number) << PROTOBUF_FIELD_TAG_SHIFT) | LENGTH_DELIMITED_WIRE_TYPE); - append_proto_varint(out, payload.size()); + append_proto_varint(out, declared_length); +} + +/** Append one length-delimited protobuf field without a generated schema. */ +void append_length_delimited_field(std::vector& out, uint32_t field_number, + std::span payload) { + append_length_delimited_prefix(out, field_number, payload.size()); out.insert(out.end(), payload.begin(), payload.end()); } @@ -414,6 +420,13 @@ class sysio_dispatch_tester : public tester { /// `sysio.epoch::advance` passes. Tests that sweep "as production would" use this; tests /// exercising the bound itself pass their own smaller value. static constexpr uint32_t kMaxLockReleasePerEpoch = 32; + + /// Mirror `sysio.uwrit`'s reserve-privacy SWAP_REVERT reasons (contract sources are not + /// host-compilable). + static constexpr std::string_view kPrivateWireEndpointReason = + "private reserves are excluded from WIRE-endpoint swaps"; + static constexpr std::string_view kPrivateOwnerMismatchReason = + "private reserve pairing violation: counterpart reserve must be owned by the same WIRE account"; static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; static constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; static constexpr auto DCLAIM_ACCOUNT = "sysio.dclaim"_n; @@ -425,6 +438,9 @@ class sysio_dispatch_tester : public tester { // nodeownreg's active_key_matches succeeds when the claim carries that same key (existing-account // path -- exercises the dispatch decode + routing without the account-creation machinery). static constexpr auto CLAIM_ACCOUNT = "claimacct"_n; + // Owners of the private reserves the reserve-privacy cases register. + static constexpr auto LP_OWNER_A = "lpowner.a"_n; + static constexpr auto LP_OWNER_B = "lpowner.b"_n; static constexpr uint64_t ROA_NETWORK_GEN = 0; sysio_dispatch_tester() { @@ -698,6 +714,37 @@ class sysio_dispatch_tester : public tester { return fc::variant(); } + /// Every queued attestation of `type`, decoded as `Message`, in queue order. + template + std::vector queued_attestations(AttestationType type, uint64_t scan_until = 32) { + std::vector messages; + for (uint64_t id = 0; id <= scan_until; ++id) { + const auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attestations"_n, id); + if (data.empty()) continue; + const auto row = msgch_abi.binary_to_variant( + "attestation_entry", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + if (row["type"].as() != type) continue; + const auto payload = row["data"].as>(); + Message message; + BOOST_REQUIRE(message.ParseFromArray(payload.data(), static_cast(payload.size()))); + messages.push_back(std::move(message)); + } + return messages; + } + + /// Every queued SWAP_REVERT, decoded, in queue order. + std::vector queued_swap_reverts() { + return queued_attestations( + AttestationType::ATTESTATION_TYPE_SWAP_REVERT); + } + + /// Every queued SWAP_REMIT, decoded, in queue order. + std::vector queued_swap_remits() { + return queued_attestations( + AttestationType::ATTESTATION_TYPE_SWAP_REMIT); + } + /// Find an operator's balance entry for a (chain_code, token_code) pair. fc::variant find_balance(const fc::variant& op, std::string_view chain_code, @@ -766,6 +813,51 @@ class sysio_dispatch_tester : public tester { ("original_message_id", fc::sha256())); } + /// Overwrite one of `account`'s opreg collateral balances in chain state, past the collateral + /// cap every opreg credit path enforces, to reach a depot guard that the cap otherwise keeps + /// unreachable. The row keeps its size, so its RAM billing is unchanged. + void force_opreg_balance(name account, std::string_view chain_code, std::string_view token_code, + uint64_t balance) { + const auto target_chain = fc::slug_name{chain_code}.value; + const auto target_token = fc::slug_name{token_code}.value; + // The controller hands tests only a const database; the state itself is writable. + auto& db = const_cast(control->db()); + const auto table_id = chain::compute_table_id("operators"_n.to_uint64_t()); + const auto& kv_idx = db.get_index(); + auto itr = kv_idx.lower_bound(boost::make_tuple(OPREG_ACCOUNT, table_id, std::string_view{})); + for (; itr != kv_idx.end() && itr->code == OPREG_ACCOUNT && itr->table_id == table_id; ++itr) { + const std::vector raw(itr->value.data(), itr->value.data() + itr->value.size()); + fc::variant row; + try { + row = opreg_abi.binary_to_variant("operator_entry", raw, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } catch (...) { + continue; // Not an operator_entry row. + } + if (row["account"].as_string() != account.to_string()) continue; + + fc::mutable_variant_object updated{row}; + fc::variants balances = row["balances"].get_array(); + bool found = false; + for (auto& entry : balances) { + if (entry["chain_code"].as().value != target_chain || + entry["token_code"].as().value != target_token) continue; + entry = fc::mutable_variant_object{entry}.set("balance", balance); + found = true; + } + BOOST_REQUIRE(found); + updated.set("balances", balances); + const auto packed = opreg_abi.variant_to_binary("operator_entry", updated, + abi_serializer::create_yield_function(abi_serializer_max_time)); + BOOST_REQUIRE_EQUAL(raw.size(), packed.size()); + db.modify(*itr, [&](chain::kv_object& obj) { + obj.value = std::string_view{packed.data(), packed.size()}; + }); + return; + } + BOOST_FAIL("no opreg operator row for " + account.to_string()); + } + action_result createuwreq_direct(uint64_t attestation_id, uint64_t chain_code_v, const std::string& swap_request_bytes) { return push(UWRIT_ACCOUNT, uwrit_abi, MSGCH_ACCOUNT, "createuwreq"_n, mvo() @@ -1266,6 +1358,15 @@ class sysio_dispatch_tester : public tester { return fc::variant(); } + /// A reserve's (chain, WIRE) pool balances, for asserting that a rejected swap moved nothing. + std::pair reserve_balances(std::string_view chain_code, + std::string_view token_code, + std::string_view reserve_code) { + const auto row = find_reserve(chain_code, token_code, reserve_code); + BOOST_REQUIRE(!row.is_null()); + return {row["reserve_chain_amount"].as_uint64(), row["reserve_wire_amount"].as_uint64()}; + } + /// The depot's own quote for a swap, computed with the SAME kernel the /// contract uses (`sysio.uwrit::swap_quote` -> `opp::amm::quote_swap`) over /// the live reserve rows. Tests assert settlement against this rather than @@ -1289,13 +1390,22 @@ class sysio_dispatch_tester : public tester { src_amount, fee_bps); } + /// Per-side pool depth of the balanced reserves `regreserve_active` seeds. + static constexpr uint64_t kSeedReserveAmount = 1'000'000'000'000ull; + /// Register one ACTIVE reserve with ample balanced liquidity (1e12 / 1e12, /// 50% connector weight). Shared by the bootstrap pair below and by the /// same-(chain, token) multi-reserve swap tests, which add a second reserve /// on an already-registered (chain, token) pair. action_result regreserve_active(std::string_view c, std::string_view t, std::string_view r) { - return regreserve_active_amounts(c, t, r, /*chain_amount*/ 1'000'000'000'000ull, - /*wire_amount*/ 1'000'000'000'000ull); + return regreserve_active_amounts(c, t, r, kSeedReserveAmount, kSeedReserveAmount); + } + + /// `regreserve_active` for a PRIVATE reserve owned by `owner`. + action_result regreserve_private(std::string_view c, std::string_view t, std::string_view r, + name owner) { + return regreserve_active_amounts(c, t, r, kSeedReserveAmount, kSeedReserveAmount, + /*is_private*/ true, owner); } /// `regreserve_active` with explicit pool depths, for a test that needs a deliberately @@ -1304,7 +1414,8 @@ class sysio_dispatch_tester : public tester { /// floors a small conversion to zero — the "dust bucket" the bond quote has to survive. action_result regreserve_active_amounts(std::string_view c, std::string_view t, std::string_view r, - uint64_t chain_amount, uint64_t wire_amount) { + uint64_t chain_amount, uint64_t wire_amount, + bool is_private = false, name owner = {}) { return push(RESERV_ACCOUNT, reserv_abi, RESERV_ACCOUNT, "regreserve"_n, mvo() ("chain_code", c) ("token_code", t) @@ -1315,8 +1426,8 @@ class sysio_dispatch_tester : public tester { ("initial_wire_amount", wire_amount) ("source_token_precision", uint32_t{9}) ("connector_weight_bps", uint32_t{5000}) - ("is_private", false) - ("owner", "")); + ("is_private", is_private) + ("owner", owner.to_string())); } /// Drain chain-side reserve liquidity through the same UWRIT-authorized @@ -2062,6 +2173,114 @@ BOOST_FIXTURE_TEST_CASE(underwrite_commit_early_rejections_log_and_continue, BOOST_CHECK(!commits.front()["dest_uic_bytes"].as_string().empty()); } FC_LOG_AND_RETHROW() } +// A UIC whose bytes or string field declares ~2^28 bytes but carries none must be rejected as +// malformed by both depot decoders, not trap on the resize that length would trigger (the trap +// would revert the whole delivery). Covers the top-level signature and the two fields nested in +// sub-messages. Through deliver, the valid UIC after them in the same envelope still lands; through +// rcrdcommit, the signature verifier logs each one and records nothing. +BOOST_FIXTURE_TEST_CASE(forged_uic_length_prefix_is_rejected_without_trapping, + sysio_dispatch_tester) { try { + using sysio::opp::attestations::UnderwriteIntentCommit; + using sysio::opp::types::ChainAddress; + using sysio::opp::types::WireAccount; + + bootstrap_for_dispatch(); + constexpr uint64_t ATT_ID = 9210; + setup_eth_to_sol_uwreq(ATT_ID); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto sol_chain = fc::slug_name{"SOLANA"}.value; + const auto sol_token = fc::slug_name{"SOL"}.value; + const auto primary = fc::slug_name{"PRIMARY"}.value; + + // Far past the decoders' 2 KiB allocation cap and past what contract memory can grow to. + constexpr uint64_t FORGED_LENGTH = (uint64_t{1} << 28) - 1; + // A UIC whose only content is `field` declaring FORGED_LENGTH bytes, wrapped in `outer_field` + // when that field lives in a sub-message. + auto forge = [&](uint32_t field, std::optional outer_field) { + std::vector inner; + append_length_delimited_prefix(inner, field, FORGED_LENGTH); + if (!outer_field) return inner; + std::vector outer; + append_length_delimited_field(outer, *outer_field, inner); + return outer; + }; + const std::vector> forged_uics{ + forge(UnderwriteIntentCommit::kSignatureFieldNumber, std::nullopt), + forge(WireAccount::kNameFieldNumber, UnderwriteIntentCommit::kUwAccountFieldNumber), + forge(ChainAddress::kAddressFieldNumber, UnderwriteIntentCommit::kUwExtChainAddrFieldNumber), + }; + + std::vector attestations; + for (const auto& forged : forged_uics) attestations.emplace_back(forged.begin(), forged.end()); + const auto valid_uic = create_signed_uic(UWRIT_OP, ATT_ID, sol_chain, sol_token, primary); + attestations.emplace_back(valid_uic.begin(), valid_uic.end()); + + const auto trace = deliver_trace(/*proven=*/ sol_chain, + encode_envelope_with_attestations( + current_epoch(), sysio::opp::types::ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT, attestations)); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + const auto console = all_console(trace); + const auto rejected = "UIC_DISPATCH_REJECTED: chain_code=" + std::to_string(sol_chain) + + ", reason=malformed_uic"; + size_t rejections = 0; + for (auto pos = console.find(rejected); pos != std::string::npos; pos = console.find(rejected, pos + 1)) { + ++rejections; + } + BOOST_CHECK_EQUAL(forged_uics.size(), rejections); + auto commits = get_uwreq(ATT_ID)["commits_by"].get_array(); + BOOST_REQUIRE_EQUAL(1u, commits.size()); + BOOST_CHECK(!commits.front()["dest_uic_bytes"].as_string().empty()); + + // rcrdcommit decodes on its own: offer each forged UIC as the missing source leg. + for (const auto& forged : forged_uics) { + const auto rcrd_trace = rcrdcommit_trace(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", forged); + BOOST_REQUIRE(rcrd_trace != nullptr); + BOOST_REQUIRE(!rcrd_trace->except); + const auto rcrd_console = all_console(rcrd_trace); + BOOST_CHECK_NE(std::string::npos, rcrd_console.find("UIC_SIGNATURE_REJECTED")); + BOOST_CHECK_NE(std::string::npos, rcrd_console.find("reason=malformed_uic")); + } + commits = get_uwreq(ATT_ID)["commits_by"].get_array(); + BOOST_REQUIRE_EQUAL(1u, commits.size()); + BOOST_CHECK(commits.front()["source_uic_bytes"].as_string().empty()); +} FC_LOG_AND_RETHROW() } + +// msgch drops a UIC over the depot's size cap before decoding it, since rcrdcommit would refuse it +// anyway, and logs why. A validly signed UIC exactly at the cap, later in the same envelope, lands. +BOOST_FIXTURE_TEST_CASE(underwrite_commit_over_the_size_cap_is_dropped_at_dispatch, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + constexpr uint64_t ATT_ID = 9211; + setup_eth_to_sol_uwreq(ATT_ID); + + const auto sol_chain = fc::slug_name{"SOLANA"}.value; + const auto sol_token = fc::slug_name{"SOL"}.value; + const auto primary = fc::slug_name{"PRIMARY"}.value; + // Mirrors opp::MAX_UIC_LEG_BYTES (contract headers are not host-compilable). + constexpr size_t UIC_CAP_BYTES = 2048; + + const auto oversized_uic = create_signed_uic_of_size( + UWRIT_OP, ATT_ID, sol_chain, sol_token, primary, UIC_CAP_BYTES + 1); + const auto capped_uic = create_signed_uic_of_size( + UWRIT_OP, ATT_ID, sol_chain, sol_token, primary, UIC_CAP_BYTES); + const auto trace = deliver_trace(/*proven=*/ sol_chain, + encode_envelope_with_attestations( + current_epoch(), sysio::opp::types::ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT, + {std::string(oversized_uic.begin(), oversized_uic.end()), + std::string(capped_uic.begin(), capped_uic.end())})); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + BOOST_CHECK_NE(std::string::npos, all_console(trace).find( + "UIC_DISPATCH_REJECTED: chain_code=" + std::to_string(sol_chain) + ", reason=oversized_uic")); + + const auto commits = get_uwreq(ATT_ID)["commits_by"].get_array(); + BOOST_REQUIRE_EQUAL(1u, commits.size()); + BOOST_CHECK_EQUAL(fc::to_hex(capped_uic.data(), capped_uic.size()), + commits.front()["dest_uic_bytes"].as_string()); +} FC_LOG_AND_RETHROW() } + // A UIC whose (chain_code, token_code, reserve_code) triple matches neither the source nor the // destination leg of the pending request must leave commits_by untouched — no inert commit_entry, // no mutation. rcrdcommit is msgch-authorized; call it directly. @@ -3483,6 +3702,76 @@ BOOST_FIXTURE_TEST_CASE(swap_to_wire_oversized_target_is_refused_at_ingress, run(7003, ~uint64_t{0}); // UINT64_MAX — wrapped dst_amount + fee pre-fix } FC_LOG_AND_RETHROW() } +// An outpost payout rides the SWAP_REMIT as a signed OPP TokenAmount, so a settlement quote +// outside the OPP amount range must be refused rather than cast (at or above 2^63 it would go +// negative). Today the destination bond gate refuses such a quote first: opreg caps every +// collateral bucket at asset::max_amount, so no winner can cover it and the request stays +// PENDING. Raising the bond past that cap in chain state shows the remit guard behind the bond +// gate still rejects and refunds, with no lock taken and neither pool moved. +BOOST_FIXTURE_TEST_CASE(swap_remit_quote_outside_opp_amount_range_is_rejected, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); // ETH/ETH/PRIMARY at 1e12 / 1e12 + // A destination pool deep enough on its token side for the quote to clear 2^63. + constexpr uint64_t DEEP_CHAIN_AMOUNT = std::numeric_limits::max(); + constexpr uint64_t SHALLOW_WIRE_AMOUNT = 100'000'000'000ull; + BOOST_REQUIRE_EQUAL(success(), regreserve_active_amounts("ETH", "ETH", "DEEP", + DEEP_CHAIN_AMOUNT, SHALLOW_WIRE_AMOUNT)); + + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t primary = fc::slug_name{"PRIMARY"}.value; + const uint64_t deep = fc::slug_name{"DEEP"}.value; + constexpr uint64_t ATT_ID = 7010; + constexpr int64_t SRC_AMOUNT = 1'000'000'000'000; + const uint64_t quote = expected_quote("ETH", "ETH", "PRIMARY", SRC_AMOUNT, + "ETH", "ETH", "DEEP", kDefaultUwritFeeBps); + BOOST_REQUIRE_GE(quote, uint64_t{1} << 63); + + // Bonded at the collateral cap, the most one bucket can legitimately hold. + constexpr uint64_t COLLATERAL_CAP = (uint64_t{1} << 62) - 1; + BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", COLLATERAL_CAP)); + + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, primary, SRC_AMOUNT, + eth, eth, deep, /*target*/ quote, + /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); + BOOST_REQUIRE_EQUAL(quote, get_uwreq(ATT_ID)["dst_amount"].as_uint64()); + + const auto src_uic = create_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 = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, deep); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "DEEP", dst_uic)); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", + get_uwreq(ATT_ID)["status"].as_string()); + + // Past the cap the candidate clears the bond gate, and an exact replay re-runs the race. + force_opreg_balance(UWRIT_OP, "ETH", "ETH", std::numeric_limits::max()); + const auto src_before = reserve_balances("ETH", "ETH", "PRIMARY"); + const auto dst_before = reserve_balances("ETH", "ETH", "DEEP"); + produce_block(); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "DEEP", dst_uic)); + + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_REJECTED", req["status"].as_string()); + const auto candidate = req["commits_by"].get_array().front(); + BOOST_CHECK_EQUAL("UNDERWRITE_STATUS_RELEASED", candidate["status"].as_string()); + BOOST_CHECK_NE(std::string::npos, + candidate["reason"].as_string().find("OPP token amount range")); + const auto reverts = queued_swap_reverts(); + BOOST_REQUIRE_EQUAL(1u, reverts.size()); + BOOST_CHECK_NE(std::string::npos, reverts.front().reason().find("OPP token amount range")); + BOOST_CHECK(queued_swap_remits().empty()); + BOOST_CHECK(get_lock(1).is_null()); + BOOST_CHECK_EQUAL(0u, get_lock_sum(UWRIT_OP, "ETH", "ETH")); + BOOST_CHECK(src_before == reserve_balances("ETH", "ETH", "PRIMARY")); + BOOST_CHECK(dst_before == reserve_balances("ETH", "ETH", "DEEP")); +} FC_LOG_AND_RETHROW() } + // WIRE-291: every protobuf-valid UIC whose embedded signature is malformed or // unsupported must be ignored before storage without aborting rcrdcommit. // The table covers all packed tags 0..5: fixed-size K1/R1/EM/ED truncations, @@ -5054,6 +5343,273 @@ BOOST_FIXTURE_TEST_CASE(swap_missing_reserve_complete_candidate_recovers_after_p BOOST_REQUIRE(!get_lock(2).is_null()); } FC_LOG_AND_RETHROW() } +// createuwreq's reserve-privacy gate, against rows that exist at ingestion: a private reserve is +// refused as the source of a to-WIRE swap and against a counterpart that is public or has another +// owner, while a pair under one owner is admitted. A refusal reverts the deposit and creates no +// uwreq. +BOOST_FIXTURE_TEST_CASE(swap_request_private_reserve_gate_at_ingestion, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + register_wire_depot(); + setup_wire_token_and_reserves(); // public ETH/ETH/PRIMARY, no owner + BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVA", LP_OWNER_A)); + BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVB", LP_OWNER_B)); + BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVC", LP_OWNER_A)); + + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t wire = fc::slug_name{"WIRE"}.value; + const uint64_t primary = fc::slug_name{"PRIMARY"}.value; + const uint64_t priv_a = fc::slug_name{"PRIVA"}.value; + const uint64_t priv_b = fc::slug_name{"PRIVB"}.value; + const uint64_t priv_c = fc::slug_name{"PRIVC"}.value; + constexpr int64_t SRC_AMOUNT = 1'000'000; + const std::string wire_recipient = UWRIT_OP.to_string(); + + // A swap out of PRIVA. The target sits well inside the tolerance, so only the privacy gate can + // refuse it. + auto submit = [&](uint64_t att_id, uint64_t dst_chain, uint64_t dst_reserve) { + const bool to_wire = dst_chain == wire; + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, priv_a, SRC_AMOUNT, + dst_chain, to_wire ? wire : eth, dst_reserve, /*target*/ SRC_AMOUNT, + /*tolerance_bps*/ 1'000'000, + to_wire ? ChainKind::CHAIN_KIND_WIRE : ChainKind::CHAIN_KIND_EVM, + to_wire ? std::vector(wire_recipient.begin(), wire_recipient.end()) + : std::vector(20, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); + }; + + submit(/*att_id*/ 9901, wire, primary); // to-WIRE out of a private reserve + submit(/*att_id*/ 9902, eth, priv_b); // private pair, different owners + submit(/*att_id*/ 9903, eth, primary); // private reserve, public counterpart + submit(/*att_id*/ 9904, eth, priv_c); // private pair, one owner + + BOOST_CHECK(get_uwreq(9901).is_null()); + BOOST_CHECK(get_uwreq(9902).is_null()); + BOOST_CHECK(get_uwreq(9903).is_null()); + BOOST_CHECK(!get_uwreq(9904).is_null()); + + const auto reverts = queued_swap_reverts(); + BOOST_REQUIRE_EQUAL(3u, reverts.size()); + BOOST_CHECK_EQUAL(kPrivateWireEndpointReason, reverts[0].reason()); + BOOST_CHECK_EQUAL(kPrivateOwnerMismatchReason, reverts[1].reason()); + BOOST_CHECK_EQUAL(kPrivateOwnerMismatchReason, reverts[2].reason()); +} FC_LOG_AND_RETHROW() } + +// The ingestion gate can only judge rows that exist. A to-WIRE request naming a source reserve +// that does not exist yet is admitted, and that identity is then registered as a private pool. +// Race resolution applies the same rule: the request is rejected and the deposit reverted, with +// no lock taken and the private pool untouched. +BOOST_FIXTURE_TEST_CASE(swap_to_wire_source_provisioned_private_after_ingestion_is_rejected, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + register_wire_depot(); + setup_wire_token_and_reserves(); + + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t wire = fc::slug_name{"WIRE"}.value; + const uint64_t primary = fc::slug_name{"PRIMARY"}.value; + const uint64_t priv_a = fc::slug_name{"PRIVA"}.value; + constexpr uint64_t ATT_ID = 9911; + constexpr int64_t SRC_AMOUNT = 1'000'000; + + BOOST_REQUIRE_EQUAL(success(), + depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); + + const std::string recipient = UWRIT_OP.to_string(); + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, priv_a, SRC_AMOUNT, + wire, wire, primary, /*target*/ SRC_AMOUNT, + /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_WIRE, + std::vector(recipient.begin(), recipient.end())); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", + get_uwreq(ATT_ID)["status"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVA", LP_OWNER_A)); + const auto balances_before = reserve_balances("ETH", "ETH", "PRIVA"); + + BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( + ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIVA", + create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, priv_a))); + + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_REJECTED", req["status"].as_string()); + const auto candidate = req["commits_by"].get_array().front(); + BOOST_CHECK_EQUAL("UNDERWRITE_STATUS_RELEASED", candidate["status"].as_string()); + BOOST_CHECK_NE(std::string::npos, + candidate["reason"].as_string().find("reserve privacy violation")); + const auto reverts = queued_swap_reverts(); + BOOST_REQUIRE_EQUAL(1u, reverts.size()); + BOOST_CHECK_EQUAL(kPrivateWireEndpointReason, reverts.front().reason()); + BOOST_CHECK(get_lock(1).is_null()); + BOOST_CHECK_EQUAL(0u, get_lock_sum(UWRIT_OP, "ETH", "ETH")); + BOOST_CHECK(balances_before == reserve_balances("ETH", "ETH", "PRIVA")); +} FC_LOG_AND_RETHROW() } + +// The outpost-to-outpost form of the same race: both reserves are missing at ingestion and are +// then registered as private pools of different owners. Race resolution rejects the pairing, +// reverts the deposit, takes no lock and moves neither pool. +BOOST_FIXTURE_TEST_CASE(swap_pair_provisioned_private_for_different_owners_is_rejected, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); + + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t priv_a = fc::slug_name{"PRIVA"}.value; + const uint64_t priv_b = fc::slug_name{"PRIVB"}.value; + constexpr uint64_t ATT_ID = 9912; + constexpr int64_t SRC_AMOUNT = 1'000'000; + + BOOST_REQUIRE_EQUAL(success(), + depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, priv_a, SRC_AMOUNT, + eth, eth, priv_b, /*target*/ SRC_AMOUNT, + /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", + get_uwreq(ATT_ID)["status"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVA", LP_OWNER_A)); + BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVB", LP_OWNER_B)); + const auto src_before = reserve_balances("ETH", "ETH", "PRIVA"); + const auto dst_before = reserve_balances("ETH", "ETH", "PRIVB"); + + BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( + ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIVA", + create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, priv_a))); + BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( + ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIVB", + create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, priv_b))); + + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_REJECTED", req["status"].as_string()); + const auto candidate = req["commits_by"].get_array().front(); + BOOST_CHECK_EQUAL("UNDERWRITE_STATUS_RELEASED", candidate["status"].as_string()); + BOOST_CHECK_NE(std::string::npos, + candidate["reason"].as_string().find("reserve privacy violation")); + const auto reverts = queued_swap_reverts(); + BOOST_REQUIRE_EQUAL(1u, reverts.size()); + BOOST_CHECK_EQUAL(kPrivateOwnerMismatchReason, reverts.front().reason()); + BOOST_CHECK(queued_swap_remits().empty()); + BOOST_CHECK(get_lock(1).is_null()); + BOOST_CHECK_EQUAL(0u, get_lock_sum(UWRIT_OP, "ETH", "ETH")); + BOOST_CHECK(src_before == reserve_balances("ETH", "ETH", "PRIVA")); + BOOST_CHECK(dst_before == reserve_balances("ETH", "ETH", "PRIVB")); +} FC_LOG_AND_RETHROW() } + +// Control for the case above: provisioned under ONE owner, the private pair is permitted and the +// swap settles at race resolution. +BOOST_FIXTURE_TEST_CASE(swap_pair_provisioned_private_for_one_owner_settles, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); + + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t priv_a = fc::slug_name{"PRIVA"}.value; + const uint64_t priv_b = fc::slug_name{"PRIVB"}.value; + constexpr uint64_t ATT_ID = 9913; + constexpr int64_t SRC_AMOUNT = 1'000'000; + + BOOST_REQUIRE_EQUAL(success(), + depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, priv_a, SRC_AMOUNT, + eth, eth, priv_b, /*target*/ SRC_AMOUNT, + /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); + + BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVA", LP_OWNER_A)); + BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVB", LP_OWNER_A)); + const uint64_t quote = expected_quote("ETH", "ETH", "PRIVA", SRC_AMOUNT, + "ETH", "ETH", "PRIVB", kDefaultUwritFeeBps); + BOOST_REQUIRE(quote > 0); + const auto dst_before = reserve_balances("ETH", "ETH", "PRIVB"); + + BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( + ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIVA", + create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, priv_a))); + BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( + ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIVB", + create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, priv_b))); + + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", req["status"].as_string()); + BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), req["winner"].as_string()); + BOOST_REQUIRE_EQUAL(quote, req["dst_amount"].as_uint64()); + BOOST_CHECK(queued_swap_reverts().empty()); + // The remit pays out exactly the quote the destination pool gave up. + const auto remits = queued_swap_remits(); + BOOST_REQUIRE_EQUAL(1u, remits.size()); + BOOST_CHECK_EQUAL(static_cast(quote), remits.front().amount().amount()); + BOOST_CHECK_EQUAL(eth, remits.front().amount().token_code()); + BOOST_CHECK(!get_lock(1).is_null()); + BOOST_CHECK(!get_lock(2).is_null()); + BOOST_CHECK_EQUAL(dst_before.first - quote, reserve_balances("ETH", "ETH", "PRIVB").first); +} FC_LOG_AND_RETHROW() } + +// Swap-from-WIRE settles through the same remit path as an outpost-to-outpost swap, with the +// amount set after the request-level verdicts: the SWAP_REMIT to the destination outpost carries +// the settled quote in the destination token. +BOOST_FIXTURE_TEST_CASE(swap_from_wire_settles_and_remits_the_settled_quote, + sysio_dispatch_tester) { try { + constexpr uint64_t FROM_WIRE_ID = 0x8000000000000000ull; // the first depot-origin uwreq id + constexpr uint64_t ESCROW = 5'000'000'000; // the default 5 WIRE swapfromwire floor + + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); // ACTIVE public ETH/ETH/PRIMARY + register_wire_depot(); // so drainfwq's depot_chain_code() resolves + create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, + /*include_code=*/true, /*include_roa_policy=*/false); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, + "transfer"_n, mvo()("from", "sysio")("to", "swapuser") + ("quantity", "10.000000000 WIRE")("memo", "fund swap user"))); + + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t primary = fc::slug_name{"PRIMARY"}.value; + + // Target == escrow at 100% tolerance prices within variance against the balanced pool. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, "swapuser"_n, "swapfromwire"_n, mvo() + ("user", "swapuser") + ("wire_amount", ESCROW) + ("dst_chain_code", "ETH") + ("dst_token_code", "ETH") + ("dst_reserve_code", "PRIMARY") + ("target_amount", ESCROW) + ("target_tolerance_bps", uint32_t{10000}) + ("recipient_kind", ChainKind::CHAIN_KIND_EVM) + ("recipient_addr", std::vector(20, '\x0a')))); + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, EPOCH_ACCOUNT, "drainfwq"_n, mvo())); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", + get_uwreq(FROM_WIRE_ID)["status"].as_string()); + + // Only the destination leg needs a commit and a bond. + BOOST_REQUIRE_EQUAL(success(), + depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{100'000'000'000})); + BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( + FROM_WIRE_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", + create_signed_uic(UWRIT_OP, FROM_WIRE_ID, eth, eth, primary))); + + const auto req = get_uwreq(FROM_WIRE_ID); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", req["status"].as_string()); + BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), req["winner"].as_string()); + const uint64_t settled = req["dst_amount"].as_uint64(); + BOOST_REQUIRE(settled > 0); + BOOST_CHECK(queued_swap_reverts().empty()); + const auto remits = queued_swap_remits(); + BOOST_REQUIRE_EQUAL(1u, remits.size()); + BOOST_CHECK_EQUAL(static_cast(settled), remits.front().amount().amount()); + BOOST_CHECK_EQUAL(eth, remits.front().amount().token_code()); + BOOST_CHECK_EQUAL(eth, remits.front().chain_code()); + BOOST_CHECK_EQUAL(primary, remits.front().reserve_code()); + BOOST_CHECK_EQUAL(settled, get_lock(1)["amount"].as_uint64()); +} FC_LOG_AND_RETHROW() } + // Positive + existing-locks coverage: a balance that covers the aggregate // (`src_amount + quote`) must select the underwriter and write two locks // totaling exactly that. A subsequent same-bucket swap must then see From 92e81e9c1c10f19644d6df9ce4d09148f5b10a57 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Fri, 16 Oct 2026 15:26:02 -0500 Subject: [PATCH 2/2] uwrit tests: pin the owner-based reserve-privacy rule The ingestion gate test described case 9903 as a private reserve refused against "a public counterpart", but PRIMARY has an empty owner, so the case only showed an owner mismatch. reserve_privacy_violation pairs a private reserve by owner, not by the counterpart's privacy flag: a public counterpart under the same non-empty WIRE owner is permitted. - Reword the test comment and case 9903 to "public and unowned". - Add regreserve_public_owned, registering a public reserve with an owner. - Ingestion: a public counterpart of the same owner is admitted (9905); one owned by another account is refused with the owner-mismatch reason (9906). - Settlement: a private source settles against a public destination of the same owner at race resolution, remitting the quote. Test-only; no contract or ABI change. Co-Authored-By: Claude Opus 5.5 Change-Id: I4af2d35d6e11257a5ffc0dc654b74d6bb23e2d31 --- contracts/tests/sysio.dispatch_tests.cpp | 79 ++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 83eaa3904f..6f0fe7f4dc 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -1408,6 +1408,14 @@ class sysio_dispatch_tester : public tester { /*is_private*/ true, owner); } + /// `regreserve_active` for a PUBLIC reserve owned by `owner`. The privacy rule pairs a private + /// reserve by owner, not by flag, so a public counterpart under the same owner is admitted. + action_result regreserve_public_owned(std::string_view c, std::string_view t, std::string_view r, + name owner) { + return regreserve_active_amounts(c, t, r, kSeedReserveAmount, kSeedReserveAmount, + /*is_private*/ false, owner); + } + /// `regreserve_active` with explicit pool depths, for a test that needs a deliberately /// IMBALANCED reserve. At the 50% connector weight the curve is pure constant product /// (`out = wire * amount / (chain + amount)`), so a deep token side over a shallow WIRE side @@ -5343,10 +5351,11 @@ BOOST_FIXTURE_TEST_CASE(swap_missing_reserve_complete_candidate_recovers_after_p BOOST_REQUIRE(!get_lock(2).is_null()); } FC_LOG_AND_RETHROW() } -// createuwreq's reserve-privacy gate, against rows that exist at ingestion: a private reserve is -// refused as the source of a to-WIRE swap and against a counterpart that is public or has another -// owner, while a pair under one owner is admitted. A refusal reverts the deposit and creates no -// uwreq. +// createuwreq's reserve-privacy gate, against rows that exist at ingestion. The rule pairs by owner, +// not by the counterpart's privacy flag: a private reserve is refused as the source of a to-WIRE +// swap and against a counterpart that is public and unowned or owned by another WIRE account, while +// a counterpart under the same non-empty owner is admitted whether it is private or public. A +// refusal reverts the deposit and creates no uwreq. BOOST_FIXTURE_TEST_CASE(swap_request_private_reserve_gate_at_ingestion, sysio_dispatch_tester) { try { bootstrap_for_dispatch(); @@ -5355,6 +5364,8 @@ BOOST_FIXTURE_TEST_CASE(swap_request_private_reserve_gate_at_ingestion, BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVA", LP_OWNER_A)); BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVB", LP_OWNER_B)); BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVC", LP_OWNER_A)); + BOOST_REQUIRE_EQUAL(success(), regreserve_public_owned("ETH", "ETH", "PUBA", LP_OWNER_A)); + BOOST_REQUIRE_EQUAL(success(), regreserve_public_owned("ETH", "ETH", "PUBB", LP_OWNER_B)); const uint64_t eth = fc::slug_name{"ETH"}.value; const uint64_t wire = fc::slug_name{"WIRE"}.value; @@ -5362,6 +5373,8 @@ BOOST_FIXTURE_TEST_CASE(swap_request_private_reserve_gate_at_ingestion, const uint64_t priv_a = fc::slug_name{"PRIVA"}.value; const uint64_t priv_b = fc::slug_name{"PRIVB"}.value; const uint64_t priv_c = fc::slug_name{"PRIVC"}.value; + const uint64_t pub_a = fc::slug_name{"PUBA"}.value; + const uint64_t pub_b = fc::slug_name{"PUBB"}.value; constexpr int64_t SRC_AMOUNT = 1'000'000; const std::string wire_recipient = UWRIT_OP.to_string(); @@ -5382,19 +5395,24 @@ BOOST_FIXTURE_TEST_CASE(swap_request_private_reserve_gate_at_ingestion, submit(/*att_id*/ 9901, wire, primary); // to-WIRE out of a private reserve submit(/*att_id*/ 9902, eth, priv_b); // private pair, different owners - submit(/*att_id*/ 9903, eth, primary); // private reserve, public counterpart + submit(/*att_id*/ 9903, eth, primary); // public and unowned counterpart submit(/*att_id*/ 9904, eth, priv_c); // private pair, one owner + submit(/*att_id*/ 9905, eth, pub_a); // public counterpart, same owner + submit(/*att_id*/ 9906, eth, pub_b); // public counterpart, another owner BOOST_CHECK(get_uwreq(9901).is_null()); BOOST_CHECK(get_uwreq(9902).is_null()); BOOST_CHECK(get_uwreq(9903).is_null()); BOOST_CHECK(!get_uwreq(9904).is_null()); + BOOST_CHECK(!get_uwreq(9905).is_null()); + BOOST_CHECK(get_uwreq(9906).is_null()); const auto reverts = queued_swap_reverts(); - BOOST_REQUIRE_EQUAL(3u, reverts.size()); + BOOST_REQUIRE_EQUAL(4u, reverts.size()); BOOST_CHECK_EQUAL(kPrivateWireEndpointReason, reverts[0].reason()); BOOST_CHECK_EQUAL(kPrivateOwnerMismatchReason, reverts[1].reason()); BOOST_CHECK_EQUAL(kPrivateOwnerMismatchReason, reverts[2].reason()); + BOOST_CHECK_EQUAL(kPrivateOwnerMismatchReason, reverts[3].reason()); } FC_LOG_AND_RETHROW() } // The ingestion gate can only judge rows that exist. A to-WIRE request naming a source reserve @@ -5553,6 +5571,55 @@ BOOST_FIXTURE_TEST_CASE(swap_pair_provisioned_private_for_one_owner_settles, BOOST_CHECK_EQUAL(dst_before.first - quote, reserve_balances("ETH", "ETH", "PRIVB").first); } FC_LOG_AND_RETHROW() } +// The privacy rule pairs by owner, not by flag: a private source settles against a PUBLIC +// destination that the same WIRE account owns, at race resolution as at ingestion. +BOOST_FIXTURE_TEST_CASE(swap_private_source_settles_against_public_counterpart_of_same_owner, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); + + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t priv_a = fc::slug_name{"PRIVA"}.value; + const uint64_t pub_a = fc::slug_name{"PUBA"}.value; + constexpr uint64_t ATT_ID = 9914; + constexpr int64_t SRC_AMOUNT = 1'000'000; + + BOOST_REQUIRE_EQUAL(success(), + depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, priv_a, SRC_AMOUNT, + eth, eth, pub_a, /*target*/ SRC_AMOUNT, + /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); + + BOOST_REQUIRE_EQUAL(success(), regreserve_private("ETH", "ETH", "PRIVA", LP_OWNER_A)); + BOOST_REQUIRE_EQUAL(success(), regreserve_public_owned("ETH", "ETH", "PUBA", LP_OWNER_A)); + const uint64_t quote = expected_quote("ETH", "ETH", "PRIVA", SRC_AMOUNT, + "ETH", "ETH", "PUBA", kDefaultUwritFeeBps); + BOOST_REQUIRE(quote > 0); + const auto dst_before = reserve_balances("ETH", "ETH", "PUBA"); + + BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( + ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIVA", + create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, priv_a))); + BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( + ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PUBA", + create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, pub_a))); + + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", req["status"].as_string()); + BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), req["winner"].as_string()); + BOOST_REQUIRE_EQUAL(quote, req["dst_amount"].as_uint64()); + BOOST_CHECK(queued_swap_reverts().empty()); + const auto remits = queued_swap_remits(); + BOOST_REQUIRE_EQUAL(1u, remits.size()); + BOOST_CHECK_EQUAL(static_cast(quote), remits.front().amount().amount()); + BOOST_CHECK(!get_lock(1).is_null()); + BOOST_CHECK(!get_lock(2).is_null()); + BOOST_CHECK_EQUAL(dst_before.first - quote, reserve_balances("ETH", "ETH", "PUBA").first); +} FC_LOG_AND_RETHROW() } + // Swap-from-WIRE settles through the same remit path as an outpost-to-outpost swap, with the // amount set after the request-level verdicts: the SWAP_REMIT to the destination outpost carries // the settled quote in the destination token.