Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
29 changes: 28 additions & 1 deletion contracts/sysio.chains/src/sysio.chains.cpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
#include <sysio.chains/sysio.chains.hpp>
#include <sysio.epoch/sysio.epoch.hpp>
#include <sysio.opp.common/registry_metadata.hpp>

namespace sysio {

namespace {

using sysio::slug_name_literals::operator""_s;

// The canonical code of the depot self-row. Bootstrap invariant V3
// (docs/platform-bootstrap-config.md) fixes the depot at
// `(kind=WIRE, code="WIRE", external_chain_id=0, is_depot=true)`.
constexpr sysio::slug_name WIRE_CHAIN_CODE = "WIRE"_s;

// System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract
// model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool.
constexpr name ram_payer = "sysio"_n;
Expand Down Expand Up @@ -40,18 +48,37 @@ void chains::regchain(opp::types::ChainKind kind,

sysio::check(kind != opp::types::CHAIN_KIND_UNKNOWN,
"sysio.chains: kind must not be UNKNOWN");
// Both strings persist into a `sysio`-billed row -- bound them before emplace.
opp::registry::check_metadata(name, description, "sysio.chains");

chains_t tbl(get_self());
chain_key pk{code};
sysio::check(tbl.find(pk) == tbl.end(),
"sysio.chains: chain code already registered");

// Enforce: at most one row with kind == WIRE (the depot self-row).
// Enforce: the code `WIRE` and `CHAIN_KIND_WIRE` are reserved FOR EACH OTHER, and the
// depot self-row is unique.
//
// Forward: `is_depot` is derived from the KIND alone below, so kind=WIRE must carry the
// canonical code or a row could claim depot identity under any code (e.g. `FAKE`) --
// previously only the cardinality half of bootstrap invariant V3 was on-chain and the code
// rested entirely on the off-chain config validator.
//
// Inverse: chain codes are unique and there is NO erase action, so a non-WIRE row
// registered under the code `WIRE` would permanently squat the depot's identity and leave
// the canonical row unregisterable -- bricking bootstrap with no on-chain recovery. Both
// directions are needed; the forward check alone still admits `regchain(EVM, "WIRE", ...)`.
if (kind == opp::types::CHAIN_KIND_WIRE) {
sysio::check(code == WIRE_CHAIN_CODE,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reserve the WIRE code bidirectionally

This guard only handles kind == CHAIN_KIND_WIRE. During bootstrap, regchain(CHAIN_KIND_EVM, "WIRE", ...) can still succeed first; code uniqueness then permanently prevents registering the canonical depot row, and there is no erase action. Enforce the inverse implication too: code == WIRE_CHAIN_CODE must require kind == CHAIN_KIND_WIRE, with a regression test for this ordering.

"sysio.chains: a WIRE chain must use the code WIRE");

auto by_kind_idx = tbl.template get_index<"bykind"_n>();
const auto wire_kind_value = magic_enum::enum_integer(opp::types::CHAIN_KIND_WIRE);
sysio::check(by_kind_idx.lower_bound(wire_kind_value) == by_kind_idx.upper_bound(wire_kind_value),
"sysio.chains: a WIRE chain (depot self-row) already exists");
} else {
sysio::check(code != WIRE_CHAIN_CODE,
"sysio.chains: the code WIRE is reserved for the depot self-row");
}

// Enforce: EVM rows are unique per `external_chain_id`. The pair (kind, external_chain_id)
Expand Down
Binary file modified contracts/sysio.chains/sysio.chains.wasm
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#pragma once
/**
* @file registry_metadata.hpp
* @brief Byte bounds for the human-readable metadata every depot registry row carries.
*
* The depot's three system-owned registries -- `sysio.chains::chains`,
* `sysio.tokens::tokens`, and `sysio.reserv::reserves` -- each persist two
* free-form strings alongside their typed columns: a short display LABEL
* (`Chain.name`, `Token.symbol_name`, `Reserve.name`) and a longer
* DESCRIPTION. Both land in chain state rather than transient action data.
*
* Those rows bill to `ram_payer = "sysio"` -- the shared system pool -- so an
* unbounded string is not merely cosmetic: a privileged operator, a compromised
* privileged account, or a faulty governance/admin workflow can make each unique
* registry key consume up to the KV/action ceiling instead of a
* business-appropriate size, inflating state and making the registries expensive
* to inspect. Every registration action bounds both strings before `emplace`.
*
* The limits live here, once, so the three registries cannot drift apart, and
* so raising a bound is a single reviewed edit rather than three. The precedent
* for bounding a persisted contract string is `sysio.token::issue`'s
* `memo.size() <= 256`; `description_max_bytes` matches it deliberately.
*/

#include <sysio/check.hpp>

#include <cstddef>
#include <string>
#include <string_view>

namespace sysio::opp::registry {

/// Maximum byte length of a registry row's short display label --
/// `Chain.name`, `Token.symbol_name`, `Reserve.name`. These are ticker- or
/// title-sized ("Wire", "Ethereum", "wire-depot"), never prose.
inline constexpr std::size_t label_max_bytes = 32;

/// Maximum byte length of a registry row's free-form description. Matches the
/// established `sysio.token::issue` memo bound.
inline constexpr std::size_t description_max_bytes = 256;

/**
* @brief Bound the two system-paid metadata strings of a registry row.
*
* Call before the row is emplaced/modified, so an oversized string never
* reaches chain state.
*
* @param label The row's short display label (`name` / `symbol_name`).
* @param description The row's free-form description.
* @param context Contract-scoped message prefix, e.g. `"sysio.tokens"`.
*/
inline void check_metadata(std::string_view label,
std::string_view description,
std::string_view context) {
// Build the message only on failure -- `sysio::check(bool, const std::string&)`
// would otherwise construct it on every passing call.
if (label.size() > label_max_bytes) {
sysio::check(false, std::string(context) + ": label exceeds "
+ std::to_string(label_max_bytes) + " bytes");
}
if (description.size() > description_max_bytes) {
sysio::check(false, std::string(context) + ": description exceeds "
+ std::to_string(description_max_bytes) + " bytes");
}
}

/**
* @brief Non-throwing counterpart of `check_metadata`, for OPP inbound dispatch handlers.
*
* A handler reachable from `sysio.msgch::deliver -> evalcons -> dispatch` MUST NOT abort
* on operator-relayed data: a `check()` there rolls back the consensus-tipping delivery
* and stalls epoch advancement chain-wide (`feedback_opp_handlers_never_throw`). Such a
* handler asks this instead, and routes an over-bound row into its own reject/refund path.
*
* @return true when either string is over its bound.
*/
inline bool metadata_exceeds_bounds(std::string_view label, std::string_view description) {
return label.size() > label_max_bytes || description.size() > description_max_bytes;
}

/**
* @brief Trim `s` to at most `max_bytes`, never splitting a UTF-8 character.
*
* A byte-wise `resize` cuts at a byte offset, not a code-point boundary: a 33-byte label of
* 31 ASCII bytes plus `é` (0xC3 0xA9) clamped to 32 would keep a lone 0xC3 lead byte and
* persist malformed text in state. Backing off to the previous boundary drops the straddling
* character whole. The BOUND itself stays a byte bound -- it exists to cap state size.
*/
inline std::string clamp_utf8(std::string s, std::size_t max_bytes) {
if (s.size() <= max_bytes) return s;
// `s[cut]` is the first dropped byte. While it is a continuation byte (10xxxxxx) the
// character straddling the boundary is being split, so walk back onto its lead byte and
// drop the whole sequence. Terminates at 0 in the worst case.
std::size_t cut = max_bytes;
while (cut > 0 && (static_cast<unsigned char>(s[cut]) & 0xC0) == 0x80) --cut;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Name the UTF-8 bit masks — The repository’s “No magic literals” rule requires nontrivial numeric values behind named constants. These masks define the correctness of the UTF-8 boundary check, so introduce named inline constexpr values for 0xC0 and 0x80 and use them here.

s.resize(cut);
return s;
}

/// Clamp a label for storage on a reject-path tombstone row. The tombstone still lands in
/// `sysio`-billed state, so an over-bound string must be truncated rather than stored
/// verbatim — otherwise rejecting an oversized registration would persist exactly the
/// state the bound exists to prevent.
inline std::string truncate_label(std::string label) {
return clamp_utf8(std::move(label), label_max_bytes);
}

/// Clamp a description for storage on a reject-path tombstone row. See `truncate_label`.
inline std::string truncate_description(std::string description) {
return clamp_utf8(std::move(description), description_max_bytes);
}

} // namespace sysio::opp::registry
20 changes: 15 additions & 5 deletions contracts/sysio.reserv/src/sysio.reserv.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <sysio.opp.common/opp_table_types.hpp>
#include <sysio.opp.common/amm_math.hpp>
#include <sysio.opp.common/safe_ops.hpp>
#include <sysio.opp.common/registry_metadata.hpp>

#include <zpp_bits.h>

Expand Down Expand Up @@ -204,6 +205,8 @@ void reserve::regreserve(sysio::slug_name chain_code,
"bootstrap reserve must seed both chain_amount and wire_amount > 0");
sysio::check(!is_private || owner != sysio::name{},
"a private bootstrap reserve must name an owner");
// Both strings persist into a `sysio`-billed row -- bound them before emplace.
opp::registry::check_metadata(name, description, "sysio.reserv");

reserves_t tbl(get_self());
auto pk = make_key(chain_code, token_code, reserve_code);
Expand Down Expand Up @@ -273,6 +276,10 @@ void reserve::oncrtreserve(sysio::slug_name chain_code,
// the SAME cancel/refund path as an unlinked creator below (insert a CANCELLED
// row + queue RESERVE_CREATE_CANCELLED), idempotently.
const bool invalid_amount = (external_token_amount == 0 || requested_wire_amount == 0);
// Same `sysio`-billed metadata bound the privileged registrations enforce with
// `check_metadata`, asked the non-throwing way: this handler must never abort, so an
// over-bound string joins the reject/refund path below rather than reverting dispatch.
const bool oversized_metadata = opp::registry::metadata_exceeds_bounds(name, description);
// The outpost downscales to min(native, 9) at its boundary, so a
// source_token_precision above the depot frame means a malformed attestation.
if (source_token_precision > WIRE_PRECISION) {
Expand Down Expand Up @@ -305,7 +312,8 @@ void reserve::oncrtreserve(sysio::slug_name chain_code,
// Create gating: the creator must already be authex-linked to a WIRE
// account ("the only requirement to create a reserve"). Reconstruct the
// creator's key variant and probe `sysio.authex::links.bypubkey`. On
// any failure — malformed key bytes OR no link — reject by inserting a
// any failure — malformed key bytes, no link, an invalid amount, OR
// over-bound metadata — reject by inserting a
// CANCELLED row (for refund idempotency) and queueing
// RESERVE_CREATE_CANCELLED so the outpost refunds the creator's escrow.
// The CANCELLED row does NOT permanently burn the identity: a later,
Expand All @@ -323,7 +331,7 @@ void reserve::oncrtreserve(sysio::slug_name chain_code,
canonical_creator_key = sysio::pubkey_to_bytes(*pk_variant);
}
}
if (!linked || invalid_amount) {
if (!linked || invalid_amount || oversized_metadata) {
// A CANCELLED row already standing means this is a re-relay of the same
// rejected create (an unlinked squatter OR an invalid amount). Leave it
// and do NOT queue a second refund — the refund was queued when the row
Expand All @@ -335,14 +343,16 @@ void reserve::oncrtreserve(sysio::slug_name chain_code,
return;
}
sysio::print("oncrtreserve: rejecting with RESERVE_CREATE_CANCELLED "
"(invalid amount or unlinked / malformed creator key)\n");
"(invalid amount, over-bound metadata, or unlinked / malformed creator key)\n");
const auto now = current_time_ms();
tbl.emplace(ram_payer, pk, reserve_row{
.chain_code = chain_code,
.token_code = token_code,
.reserve_code = reserve_code,
.name = std::move(name),
.description = std::move(description),
// The tombstone is itself a `sysio`-billed row, so the metadata is clamped here —
// storing it verbatim would persist exactly the state the bound exists to prevent.
.name = opp::registry::truncate_label(std::move(name)),
.description = opp::registry::truncate_description(std::move(description)),
.status = opp::types::RESERVE_STATUS_CANCELLED,
.reserve_chain_amount = 0,
.reserve_wire_amount = 0,
Expand Down
Binary file modified contracts/sysio.reserv/sysio.reserv.wasm
Binary file not shown.
10 changes: 10 additions & 0 deletions contracts/sysio.roa/sysio.roa.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,16 @@ namespace sysio {
// Reduction weights must be in the core SYS symbol (matches the stored policy weights).
check_core_symbol(state.total_sys.symbol, net_weight, cpu_weight, ram_weight);

// A reduction is applied as a SUBTRACTION, so a negative weight ADDS quota. The bounds below
// only cap the request from above -- a condition any negative amount satisfies whenever the
// stored weight is positive -- so without this guard a node owner could inflate an account's
// NET/CPU past the issuer's ROA budget (bypassing expandpolicy's free-allocation check) and
// desynchronise the reslimit row and the issuer's nodeowners accounting from the policy
// weights. addpolicy and expandpolicy already reject negatives; mirror them here.
check(net_weight.amount >= 0, "NET weight cannot be negative");
check(cpu_weight.amount >= 0, "CPU weight cannot be negative");
check(ram_weight.amount >= 0, "RAM weight cannot be negative");

// Validate time block
uint32_t current_block = current_block_number();
check(current_block >= pol_row.time_block, "Cannot reduce policy before time_block");
Expand Down
11 changes: 8 additions & 3 deletions contracts/sysio.roa/sysio.roa.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,16 @@ namespace sysio {
*
* Note: Will reclaim UPTO ram_weight worth of bytes, limited to the pool of unused bytes on 'owner's reslimit and upper bound by the policy ram_weight.
*
* Every weight must be non-negative and no greater than the stored policy weight: the
* reduction is applied as a subtraction, so a negative weight would ADD quota and break
* the conservation relationship between policy weights, resource limits, and the
* issuer's nodeowners accounting.
*
* @param owner The account this policy is issued to.
* @param issuer The Node Owner who issued this policy.
* @param net_weight The amount in SYS to decrease NET by.
* @param cpu_weight The amount in SYS to decrease CPU by.
* @param ram_weight The amount in SYS to attempt decreasing RAM by, returning only
* @param net_weight The non-negative amount in SYS to decrease NET by.
* @param cpu_weight The non-negative amount in SYS to decrease CPU by.
* @param ram_weight The non-negative amount in SYS to attempt decreasing RAM by, returning only
* @param network_gen Generation of issuer, in cases where you are a Node Owner in multiple,
* specifies which allocation of SYS to adjust.
*/
Expand Down
Binary file modified contracts/sysio.roa/sysio.roa.wasm
Binary file not shown.
4 changes: 0 additions & 4 deletions contracts/sysio.token/include/sysio.token/sysio.token.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@

#include <string>

namespace sysiosystem {
class system_contract;
}

namespace sysio {

using std::string;
Expand Down
7 changes: 7 additions & 0 deletions contracts/sysio.token/sysio.token.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ void token::create( const name& issuer,
check( maximum_supply.is_valid(), "invalid supply");
check( maximum_supply.amount > 0, "max-supply must be positive");

// `issuer` is stored verbatim and is the ONLY account `issue` will mint to
// (`to == st.issuer` + `require_auth(st.issuer)`). A null or non-existent issuer
// therefore creates a token that can never be issued, while permanently occupying
// the symbol -- `create` rejects duplicates, so the symbol cannot be reclaimed.
check( issuer.value != 0, "issuer account cannot be empty" );
check( is_account( issuer ), "issuer account does not exist" );

stats statstable( get_self(), sym.code().raw() );
statstable.emplace( ram_payer, stat_key{sym.code().raw()}, currency_stats{
.supply = asset{0, maximum_supply.symbol},
Expand Down
Binary file modified contracts/sysio.token/sysio.token.wasm
Binary file not shown.
3 changes: 3 additions & 0 deletions contracts/sysio.tokens/src/sysio.tokens.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include <sysio.tokens/sysio.tokens.hpp>
#include <sysio.epoch/sysio.epoch.hpp>
#include <sysio.opp.common/registry_metadata.hpp>

namespace sysio {

Expand Down Expand Up @@ -50,6 +51,8 @@ void tokens::regtoken(opp::types::TokenKind kind,
"sysio.tokens: token kind must not be UNKNOWN");
sysio::check(precision <= MAX_TOKEN_PRECISION,
"sysio.tokens: precision exceeds the depot frame maximum (9)");
// Both strings persist into a `sysio`-billed row -- bound them before emplace.
opp::registry::check_metadata(symbol_name, description, "sysio.tokens");

tokens_t tbl(get_self());
token_key pk{code};
Expand Down
Binary file modified contracts/sysio.tokens/sysio.tokens.wasm
Binary file not shown.
Loading
Loading