Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 19 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,14 +48,24 @@ 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 depot self-row is unique AND canonically coded.
//
// `is_depot` is derived from the KIND alone below, so the code half of bootstrap
// invariant V3 has to be enforced here or a registration could claim depot identity
// under any code (e.g. `FAKE`) -- previously only the cardinality half was on-chain
// and the code depended entirely on the off-chain config validator.
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),
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,96 @@
#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;
}

/// 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) {
if (label.size() > label_max_bytes) label.resize(label_max_bytes);
return label;
}

/// Clamp a description for storage on a reject-path tombstone row. See `truncate_label`.
inline std::string truncate_description(std::string description) {
if (description.size() > description_max_bytes) description.resize(description_max_bytes);

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] Preserve UTF-8 when truncating metadata

resize() truncates bytes rather than UTF-8 code points. A valid 33-byte label consisting of 31 ASCII bytes plus é is cut midway through the final character, persisting malformed human-readable metadata in the CANCELLED row. Clamp at a valid UTF-8 boundary or store a safe fixed/empty tombstone value, and add a multibyte boundary test.

return description;
}

} // 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.
38 changes: 38 additions & 0 deletions contracts/tests/sysio.epoch_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,44 @@ BOOST_FIXTURE_TEST_CASE(regchain_destination_binding_uniqueness, sysio_epoch_tes
.find("an SVM chain is already registered") != std::string::npos);
} FC_LOG_AND_RETHROW() }

/// Bootstrap invariant V3 (docs/platform-bootstrap-config.md) is "exactly one CHAIN_KIND_WIRE
/// chain, code WIRE". Only the cardinality half used to be on-chain; `is_depot` is derived from
/// the KIND alone, so a row could claim depot identity under any code and the code's validity
/// rested entirely on the off-chain config validator. CertiK WNS-11.
BOOST_FIXTURE_TEST_CASE(regchain_wire_requires_canonical_code, sysio_epoch_tester) { try {
BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_WIRE, "FAKE", 0)
.find("a WIRE chain must use the code WIRE") != std::string::npos);

// The canonical code registers, and is the depot row.
BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_WIRE, "WIRE", 0));
produce_blocks();

auto row = get_chain("WIRE");
BOOST_REQUIRE(!row.is_null());
BOOST_REQUIRE(ChainKind::CHAIN_KIND_WIRE == row["kind"].as<ChainKind>());
BOOST_REQUIRE_EQUAL(true, row["is_depot"].as<bool>());

// The cardinality half still holds — and is reported as such, not as a code error.
BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_WIRE, "WIRE", 0)
.find("already") != std::string::npos);
} FC_LOG_AND_RETHROW() }

/// `name` and `description` persist into a row billed to the shared `sysio` RAM pool, so both
/// are bounded before emplace. CertiK WNS-10 raised this for `sysio.tokens::regtoken`; the same
/// unbounded pair existed on `sysio.chains::regchain` and `sysio.reserv::regreserve`, and all
/// three now share `sysio::opp::registry::check_metadata`.
BOOST_FIXTURE_TEST_CASE(regchain_bounds_metadata, sysio_epoch_tester) { try {
BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, std::string(33, 'x'))
.find("label exceeds 32 bytes") != std::string::npos);

BOOST_REQUIRE(regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, "ok", std::string(257, 'x'))
.find("description exceeds 256 bytes") != std::string::npos);

// The bounds are inclusive.
BOOST_REQUIRE_EQUAL(success(),
regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1, std::string(32, 'x'), std::string(256, 'x')));
} FC_LOG_AND_RETHROW() }

BOOST_FIXTURE_TEST_CASE(advance_before_config, sysio_epoch_tester) { try {
BOOST_REQUIRE_EQUAL(
error("assertion failure with message: epoch config not initialized"),
Expand Down
Loading
Loading