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 b09bf4cc69..415b50e2d7 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ 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 bd1979853c..1784a6b1ef 100755 Binary files a/contracts/sysio.uwrit/sysio.uwrit.wasm and b/contracts/sysio.uwrit/sysio.uwrit.wasm differ 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..6f0fe7f4dc 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,30 @@ 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` 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 @@ -1304,7 +1422,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 +1434,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 +2181,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 +3710,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 +5351,332 @@ 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. 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(); + 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)); + 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; + 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; + 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(); + + // 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); // 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(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 +// 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() } + +// 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. +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