Skip to content
Merged
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
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,100 @@
#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`.
///
/// The bound exists to stop a row consuming up to the KV/action ceiling of
/// system-paid state; it is NOT a house style for terse names. Real labels run
/// well past ticker length -- a reserve names its full leg
/// ("ETHEREUM-ETH/WIRE unlinked-creator reserve" is 42 bytes) -- so the bound
/// is set with room for descriptive names rather than at the current longest.
/// A label that has to be shortened to satisfy this is a sign the bound is
/// wrong, not the label.
inline constexpr std::size_t label_max_bytes = 128;

/// 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 The label a handler stores on a row it rejected for over-bound metadata.
*
* The rejected strings are NOT truncated onto the row. Truncation would have to cut at a
* byte offset rather than a UTF-8 code-point boundary (splitting a multi-byte character), and
* the salvaged text buys nothing: nothing reads a tombstone's metadata — the reclaim path
* overwrites every field — and the creator's original strings are preserved in the inbound
* OPP envelope artifact regardless. A short fixed marker states plainly that the row carries
* no metadata BECAUSE it was rejected, rather than leaving a blank a reader has to interpret.
*/
inline constexpr std::string_view rejected_label = "<rejected>";

} // namespace sysio::opp::registry
26 changes: 21 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,22 @@ 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 over-bound strings are NOT
// carried onto it — storing them verbatim would persist exactly the state the
// bound exists to prevent. Only THAT rejection reason substitutes the marker; a
// row rejected for an unlinked creator or an invalid amount keeps its (already
// in-bounds) metadata. The creator's originals stay in the inbound OPP envelope
// artifact either way.
.name = oversized_metadata
? std::string(opp::registry::rejected_label)
: std::move(name),
.description = oversized_metadata ? std::string{} : 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