Skip to content
Open
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
50 changes: 48 additions & 2 deletions contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,35 @@ namespace sysio {
* `sysio.epoch::advance` reads `chains` directly via cross-contract KV read
* to determine the active-outpost fanout list. No mirror table required.
*/
/**
* @brief Remote contract identities for one chain's outpost deployment.
*
* Grouped into one struct so `regchain` keeps a readable signature and
* `setoutpost` replaces the whole set atomically. Encoding by kind:
* * `EVM` — each field is a distinct `0x`-prefixed 20-byte hex address:
* the OPP contract, the OPPInbound contract, the OperatorRegistry
* (the `uw_commit` target), and the contract that emits the source
* swap-deposit event scanned by the underwriter's verify path.
* * `SVM` — `opp_addr` is the outpost program id (base58). The single
* program serves every role, so the other three MUST be empty; the
* daemons substitute `opp_addr` wherever they need one of them.
* * `WIRE` — all empty; the depot self-row has no remote deployment.
*
* Fields may be empty at registration (the remote contract is not deployed
* yet) and filled in later via `setoutpost`. Both operator daemons fail
* closed: a chain whose address they need but do not have is skipped, so an
* unconfigured row never rides on another chain's deployment.
*/
struct outpost_addrs {
std::string opp_addr;
std::string opp_inbound_addr;
std::string operator_registry_addr;
std::string source_deposit_addr;

SYSLIB_SERIALIZE(outpost_addrs,
(opp_addr)(opp_inbound_addr)(operator_registry_addr)(source_deposit_addr))
};

class [[sysio::contract("sysio.chains")]] chains : public contract {
public:
using contract::contract;
Expand Down Expand Up @@ -58,17 +87,30 @@ namespace sysio {
/// * `kind=SVM` may appear at most once — Solana clusters have no
/// numeric chain id, so the wire binding is kind-only for SVM and a
/// second row would be indistinguishable at the outpost.
/// * `outpost` addresses, when non-empty, must match the kind's expected
/// format, and fields that are structurally meaningless for the kind
/// must be empty. See {@link outpost_addrs}.
[[sysio::action]]
void regchain(opp::types::ChainKind kind,
sysio::slug_name code,
uint32_t external_chain_id,
std::string name,
std::string description);
std::string description,
outpost_addrs outpost);

/// Activate a previously-registered chain (priv-gated, one-shot).
[[sysio::action]]
void activchain(sysio::slug_name code);

/// Replace the remote outpost contract identities for an already-registered
/// chain (priv-gated). Used when a remote contract is (re)deployed after the
/// row was registered. Same per-kind encoding and format validation as
/// `regchain`; rejects the WIRE depot self-row, which has no remote
/// deployment. The whole set is replaced, so a caller updating one address
/// must resend the others.
[[sysio::action]]
void setoutpost(sysio::slug_name code, outpost_addrs outpost);

// -----------------------------------------------------------------------
// Tables
// -----------------------------------------------------------------------
Expand All @@ -89,14 +131,18 @@ namespace sysio {
bool active = false;
uint64_t registered_at_ms = 0;
uint64_t activated_at_ms = 0;
/// Remote outpost contract identities for this chain — see {@link outpost_addrs}.
/// Read by batch_operator_plugin and underwriter_plugin; every operator
/// therefore relays through the same deployment without per-node config.
outpost_addrs outpost;

uint64_t by_kind() const { return magic_enum::enum_integer(kind); }
uint64_t by_external_chain_id() const { return external_chain_id; }
uint64_t by_active() const { return active ? 1 : 0; }

SYSLIB_SERIALIZE(chain_row,
(code)(kind)(external_chain_id)(name)(description)
(is_depot)(active)(registered_at_ms)(activated_at_ms))
(is_depot)(active)(registered_at_ms)(activated_at_ms)(outpost))
};

using chains_t = sysio::kv::table<"chains"_n, chain_key, chain_row,
Expand Down
120 changes: 119 additions & 1 deletion contracts/sysio.chains/src/sysio.chains.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,120 @@ void require_priv_caller() {
"sysio.chains: privileged account required");
}

// ---------------------------------------------------------------------------
// Remote-address format validation
//
// These addresses are consensus facts: every batch operator and underwriter
// reads the same row, so one malformed value breaks relay for the whole
// network rather than for a single misconfigured node. Validate the format at
// the ingress boundary, where the caller can still fix it.
//
// Empty is allowed -- a chain may be registered before its remote contracts
// are deployed and filled in later via `setoutpost`; both daemons fail closed
// and skip a row whose address they need but do not have. The exception is a
// field that is structurally meaningless for the kind, which must be empty.
// ---------------------------------------------------------------------------

constexpr size_t EVM_ADDR_LEN = 42; // "0x" + 20 bytes hex
constexpr size_t SVM_ADDR_MIN = 32; // base58 of a 32-byte pubkey, lower bound
constexpr size_t SVM_ADDR_MAX = 44; // base58 of a 32-byte pubkey, upper bound

// Upper bound for a kind whose address format is not constrained below. The
// EVM and SVM checks are far tighter; this exists only so an unrecognised
// future kind cannot park an unbounded string in `sysio`-billed state, the
// same concern `registry_metadata.hpp` bounds `name` and `description` for.
constexpr size_t ADDR_MAX_BYTES = 128;

bool is_hex_digit(char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}

/// Bitcoin/Solana base58 alphabet -- [1-9A-HJ-NP-Za-km-z] (excludes 0, O, I, l).
bool is_base58_char(char c) {
return (c >= '1' && c <= '9')
|| (c >= 'A' && c <= 'H') || (c >= 'J' && c <= 'N') || (c >= 'P' && c <= 'Z')
|| (c >= 'a' && c <= 'k') || (c >= 'm' && c <= 'z');
}

void check_evm_addr(const std::string& addr, const char* label) {
sysio::check(addr.size() == EVM_ADDR_LEN && addr[0] == '0' && addr[1] == 'x',
std::string("sysio.chains: ") + label + " must be a 0x-prefixed 20-byte hex address");
for (size_t i = 2; i < addr.size(); ++i) {
sysio::check(is_hex_digit(addr[i]),
std::string("sysio.chains: ") + label + " contains a non-hex character");
}
}

void check_svm_addr(const std::string& addr, const char* label) {
sysio::check(addr.size() >= SVM_ADDR_MIN && addr.size() <= SVM_ADDR_MAX,
std::string("sysio.chains: ") + label + " must be a base58 program id (32-44 chars)");
for (char c : addr) {
sysio::check(is_base58_char(c),
std::string("sysio.chains: ") + label + " contains a non-base58 character");
}
}

void check_empty(const std::string& addr, const char* label, const char* why) {
sysio::check(addr.empty(), std::string("sysio.chains: ") + label + " must be empty -- " + why);
}

/// Validate an `outpost_addrs` set against the chain kind. Non-empty values
/// must match the kind's format; structurally-unused fields must be empty.
void validate_outpost_addrs(opp::types::ChainKind kind, const outpost_addrs& o) {
// Applies to every kind, including ones with no format rule yet.
for (const auto* f : {&o.opp_addr, &o.opp_inbound_addr,
&o.operator_registry_addr, &o.source_deposit_addr}) {
sysio::check(f->size() <= ADDR_MAX_BYTES,
"sysio.chains: outpost address exceeds "
+ std::to_string(ADDR_MAX_BYTES) + " bytes");
}

switch (kind) {
case opp::types::CHAIN_KIND_WIRE: {
constexpr auto why = "the WIRE depot self-row has no remote deployment";
check_empty(o.opp_addr, "opp_addr", why);
check_empty(o.opp_inbound_addr, "opp_inbound_addr", why);
check_empty(o.operator_registry_addr, "operator_registry_addr", why);
check_empty(o.source_deposit_addr, "source_deposit_addr", why);
break;
}
case opp::types::CHAIN_KIND_EVM:
// Each role is its own contract on an EVM chain.
if (!o.opp_addr.empty()) check_evm_addr(o.opp_addr, "opp_addr");
if (!o.opp_inbound_addr.empty()) check_evm_addr(o.opp_inbound_addr, "opp_inbound_addr");
if (!o.operator_registry_addr.empty()) check_evm_addr(o.operator_registry_addr, "operator_registry_addr");
if (!o.source_deposit_addr.empty()) check_evm_addr(o.source_deposit_addr, "source_deposit_addr");
break;
case opp::types::CHAIN_KIND_SVM: {
// One program serves every role; the daemons substitute opp_addr.
constexpr auto why = "an SVM outpost is a single program, named by opp_addr";
if (!o.opp_addr.empty()) check_svm_addr(o.opp_addr, "opp_addr");
check_empty(o.opp_inbound_addr, "opp_inbound_addr", why);
check_empty(o.operator_registry_addr, "operator_registry_addr", why);
check_empty(o.source_deposit_addr, "source_deposit_addr", why);
break;
}
default:
// Future kinds: bounded above, no format constraint yet.
break;
}
}

} // namespace

void chains::regchain(opp::types::ChainKind kind,
sysio::slug_name code,
uint32_t external_chain_id,
std::string name,
std::string description) {
std::string description,
outpost_addrs outpost) {
require_priv_caller();

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");
validate_outpost_addrs(kind, outpost);

chains_t tbl(get_self());
chain_key pk{code};
Expand Down Expand Up @@ -120,6 +221,7 @@ void chains::regchain(opp::types::ChainKind kind,
.active = bootstrap,
.registered_at_ms = now,
.activated_at_ms = bootstrap ? now : 0,
.outpost = std::move(outpost),
});
}

Expand All @@ -138,4 +240,20 @@ void chains::activchain(sysio::slug_name code) {
});
}

void chains::setoutpost(sysio::slug_name code, outpost_addrs outpost) {
require_priv_caller();

chains_t tbl(get_self());
chain_key pk{code};
auto it = tbl.find(pk);
sysio::check(it != tbl.end(), "sysio.chains: chain code not registered");
sysio::check(!it->is_depot, "sysio.chains: the depot self-row has no remote deployment");

validate_outpost_addrs(it->kind, outpost);

tbl.modify(ram_payer, pk, [&](auto& row) {
row.outpost = std::move(outpost);
});
}

} // namespace sysio
49 changes: 49 additions & 0 deletions contracts/sysio.chains/sysio.chains.abi
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,32 @@
{
"name": "activated_at_ms",
"type": "uint64"
},
{
"name": "outpost",
"type": "outpost_addrs"
}
]
},
{
"name": "outpost_addrs",
"base": "",
"fields": [
{
"name": "opp_addr",
"type": "string"
},
{
"name": "opp_inbound_addr",
"type": "string"
},
{
"name": "operator_registry_addr",
"type": "string"
},
{
"name": "source_deposit_addr",
"type": "string"
}
]
},
Expand All @@ -88,6 +114,24 @@
{
"name": "description",
"type": "string"
},
{
"name": "outpost",
"type": "outpost_addrs"
}
]
},
{
"name": "setoutpost",
"base": "",
"fields": [
{
"name": "code",
"type": "slug_name"
},
{
"name": "outpost",
"type": "outpost_addrs"
}
]
},
Expand All @@ -112,6 +156,11 @@
"name": "regchain",
"type": "regchain",
"ricardian_contract": ""
},
{
"name": "setoutpost",
"type": "setoutpost",
"ricardian_contract": ""
}
],
"tables": [
Expand Down
Binary file modified contracts/sysio.chains/sysio.chains.wasm
Binary file not shown.
41 changes: 41 additions & 0 deletions contracts/tests/contract_test_support.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,45 @@ typename Tester::action_result push_contract_action_and_produce_block(
}
}

// ---------------------------------------------------------------------------
// sysio.chains::outpost_addrs builders
//
// `regchain` and `setoutpost` both take the remote outpost contract identities
// as one nested struct. These build its variant form so a test states only the
// addresses it cares about; the contract validates the set against the row's
// ChainKind (see validate_outpost_addrs in sysio.chains.cpp).
// ---------------------------------------------------------------------------

/// Every field empty — a chain registered before its remote contracts exist.
/// Valid for any kind; both operator daemons fail closed and skip such a row.
inline fc::mutable_variant_object no_outpost_mvo() {
return fc::mutable_variant_object()
("opp_addr", std::string{})
("opp_inbound_addr", std::string{})
("operator_registry_addr", std::string{})
("source_deposit_addr", std::string{});
}

/// EVM form: each role is a distinct 0x-prefixed 20-byte hex contract address.
inline fc::mutable_variant_object evm_outpost_mvo(std::string_view opp,
std::string_view opp_inbound,
std::string_view operator_registry,
std::string_view source_deposit) {
return fc::mutable_variant_object()
("opp_addr", std::string{opp})
("opp_inbound_addr", std::string{opp_inbound})
("operator_registry_addr", std::string{operator_registry})
("source_deposit_addr", std::string{source_deposit});
}

/// SVM form: one base58 program id serves every role, so the other three fields
/// must stay empty — the contract rejects a set that fills them in.
inline fc::mutable_variant_object svm_outpost_mvo(std::string_view program_id) {
return fc::mutable_variant_object()
("opp_addr", std::string{program_id})
("opp_inbound_addr", std::string{})
("operator_registry_addr", std::string{})
("source_deposit_addr", std::string{});
}

} // namespace sysio_system::test_support
Loading
Loading