Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 29 additions & 18 deletions contracts/sysio.msgch/src/sysio.msgch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include <sysio.opp.common/safe_ops.hpp> // to_depot_amount — WSA-028 fail-closed TokenAmount gate
#include <sysio.opp.common/name_ops.hpp> // parse_wire_account_name — never-throw account-name parse
#include <sysio.opp.common/opp_canonical_codec.hpp> // canonical envelope encoding + keccak epoch digest
#include <sysio.opp.common/uic_codec.hpp> // bounded UnderwriteIntentCommit decode
#include <sysio.system/emissions.hpp> // emitcfg_t / t5state_t existence checks in bootstrap()
#include <sysio/opp/opp.pb.hpp>
#include <sysio/opp/attestations/attestations.pb.hpp>
Expand Down Expand Up @@ -596,6 +597,19 @@ void dispatch_operator_action(name self, const std::vector<char>& 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
Expand All @@ -614,34 +628,31 @@ void dispatch_operator_action(name self, const std::vector<char>& 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<char>& 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;

Expand All @@ -650,7 +661,7 @@ void dispatch_underwrite_commit(name self, const std::vector<char>& 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,
Expand Down
Binary file modified contracts/sysio.msgch/sysio.msgch.wasm
Binary file not shown.
16 changes: 16 additions & 0 deletions contracts/sysio.opp.common/include/sysio.opp.common/safe_ops.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ inline std::optional<uint64_t> to_depot_amount(int64_t amount) {
return static_cast<uint64_t>(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<int64_t>` 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<int64_t> to_token_amount(uint64_t amount) {
if (amount == 0) return std::nullopt;
if (amount > static_cast<uint64_t>(depot_amount_max)) return std::nullopt;
return static_cast<int64_t>(amount);
}

/// Saturating unsigned 64-bit addition. Returns `a + b`, clamped to
/// `UINT64_MAX` on overflow instead of wrapping.
///
Expand Down
73 changes: 73 additions & 0 deletions contracts/sysio.opp.common/include/sysio.opp.common/uic_codec.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#pragma once
/**
* @file uic_codec.hpp
* @brief Bounded decoding of UnderwriteIntentCommit payloads, shared by sysio.msgch and sysio.uwrit.
*/

#include <sysio/opp/attestations/attestations.pb.hpp>

#include <zpp_bits.h>

#include <cstdint>
#include <optional>
#include <span>
#include <string>
#include <type_traits>
#include <vector>

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 <typename T>
consteval bool alloc_limit_covers() {
using type = std::remove_cvref_t<T>;
if constexpr (std::is_same_v<type, std::string> || std::is_same_v<type, std::vector<char>>) {
return true;
} else if constexpr (zpp::bits::concepts::container<type>) {
return false;
} else if constexpr (std::is_class_v<type> && !zpp::bits::concepts::varint<type>) {
// visit_members_types yields a default-constructed value of the visitor's return type.
return decltype(zpp::bits::visit_members_types<type>([]<typename... Members>() {
return std::bool_constant<(alloc_limit_covers<Members>() && ...)>{};
}))::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<attestations::UnderwriteIntentCommit> decode_uic(std::span<const char> 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<MAX_UIC_LEG_BYTES>{}};
if (in(uic) != zpp::bits::errc{}) return std::nullopt;
return uic;
}

static_assert(detail::alloc_limit_covers<attestations::UnderwriteIntentCommit>(),
"the UnderwriteIntentCommit tree gained a non-byte-string container; decode_uic no longer "
"bounds every allocation");

} // namespace sysio::opp
7 changes: 2 additions & 5 deletions contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading