Skip to content
Draft
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
24 changes: 24 additions & 0 deletions contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,30 @@ namespace sysio {
sysio::kv::index<"byextid"_n, sysio::const_mem_fun<chain_row, uint64_t, &chain_row::by_external_chain_id>>,
sysio::kv::index<"byactive"_n, sysio::const_mem_fun<chain_row, uint64_t, &chain_row::by_active>>
>;

/// True when a chains row represents an active OUTPOST — i.e. it is active and
/// is not the depot self-row (which carries no outpost).
///
/// Shared because three call sites need the identical predicate: `sysio.epoch`'s
/// envelope fan-out loops, `sysio.opreg::regoperator`'s authex-link requirement,
/// and the roster-ceiling derivation. Divergence between them would mean the
/// depot sizes a roster against one outpost set while fanning it to another.
static inline bool is_active_outpost(const chain_row& row) {
return row.active && !row.is_depot;
}

/// Number of active outposts. This is the number of chain addresses a fully
/// bonded operator carries, since every operator type must bond on every
/// registered outpost (see `outpost-three-concerns.md`), which is what makes it
/// the correct multiplier for the OPERATORS roster's bytes-per-operator.
static inline uint32_t active_outpost_count() {
chains_t chains_tbl(name{"sysio.chains"_n});
uint32_t count = 0;
for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) {
if (is_active_outpost(*it)) ++count;
}
return count;
}
};

} // namespace sysio
124 changes: 124 additions & 0 deletions contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
#include <sysio/system.hpp>
#include <sysio/opp/types/types.pb.hpp>
#include <sysio.opp.common/opp_table_types.hpp>
// For the OPP envelope budget — the roster ceiling below derives from it rather than
// restating a consensus-visible cap in a second place. Deliberately the shared
// opp.common header and NOT `sysio.msgch.hpp`: several contracts include this header
// without msgch on their include path.
#include <sysio.opp.common/opp_envelope_budget.hpp>

namespace sysio {

Expand Down Expand Up @@ -118,6 +123,45 @@ namespace sysio {

using blocklog_t = sysio::kv::table<"blocklog"_n, blocklog_key, blocklog_entry>;

/// Digest of the last OPERATORS attestation queued for one outpost.
///
/// `advance` re-derives the roster every epoch but queues it only when this
/// digest changes, so a static roster costs zero envelope bytes. The roster
/// changes on registration / activation / slash — rarely — while `advance`
/// runs every epoch, and re-sending it bought nothing: an outpost cannot miss
/// an envelope and continue (both outposts enforce strict epoch sequencing, so
/// a missed envelope stalls rather than diverges), and an outpost that DROPS a
/// roster does so on conditions that are pure functions of the payload, which a
/// byte-identical re-send reproduces exactly.
///
/// Per-outpost rather than global because a newly-activated outpost has never
/// received the roster: the ABSENCE of a row is what makes its first `advance`
/// send unconditionally. Rows are reused, never erased, mirroring
/// `msgch::outpost_consensus_entry`.
///
/// There is deliberately no periodic re-send. Every case in which an outpost
/// could want a roster it was already sent is an explicit operational act that
/// needs depot-side coordination regardless: an outpost re-init resets its epoch
/// cursor alongside its registry, so it rejects every envelope until it is
/// re-bootstrapped, and raising an outpost's own roster ceiling is a program
/// upgrade with a runbook. A timer would serve those late while costing a send
/// forever.
struct roster_digest_key {
uint64_t chain_code;
uint64_t primary_key() const { return chain_code; }
SYSLIB_SERIALIZE(roster_digest_key, (chain_code))
};

struct [[sysio::table("rosterdig")]] roster_digest_entry {
uint64_t chain_code = 0;
checksum256 digest = {};
uint32_t sent_at_epoch = 0;

SYSLIB_SERIALIZE(roster_digest_entry, (chain_code)(digest)(sent_at_epoch))
};

using rosterdig_t = sysio::kv::table<"rosterdig"_n, roster_digest_key, roster_digest_entry>;

// Well-known accounts
static constexpr name CHALG_ACCOUNT = "sysio.chalg"_n;
static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n;
Expand Down Expand Up @@ -172,6 +216,86 @@ namespace sysio {
/// envelope, and `buildenv` would carry the remainder to the next epoch.
static constexpr uint32_t MAX_SCHEDULED_BATCH_OPERATORS = 1000;

/// Upper bound on the authex-linked chain addresses carried for ONE operator
/// in the OPERATORS attestation.
///
/// The per-operator address walk is otherwise unbounded — `authex::links` has
/// no cap on how many keys one account may link — so a single account could
/// inflate one roster entry without limit and push the attestation past the
/// envelope on its own. An operator legitimately needs one address per
/// registered outpost chain (two today: EVM + SVM), so 8 is generous headroom
/// while still bounding the entry. It also matches what the Solana outpost
/// already assumes when sizing its own pre-decode payload gate.
static constexpr uint32_t MAX_OPERATOR_CHAIN_ADDRESSES = 8;

// -----------------------------------------------------------------------
// OPERATORS roster ceiling
// -----------------------------------------------------------------------
//
// `MAX_SCHEDULED_BATCH_OPERATORS` above does this arithmetic for the sibling
// BATCH_OPERATOR_GROUPS attestation; it was never done for OPERATORS, which is
// ~5x fatter per member and drawn from an UNBOUNDED registry rather than a
// bounded schedule window. `sysio.opreg::setconfig` validates its summed
// `max_available_*` ceilings against the result, so a governance change cannot
// raise the registry past what an envelope can carry.

/// Encoded bytes of one `ChainAddress`: 1 B tag + 1 B length + 2 B `kind` varint
/// + 2 B inner tag/length + a 33-byte compressed secp256k1 key (Ed25519 is 32,
/// so 33 is the worst case).
static constexpr uint32_t ROSTER_BYTES_PER_ADDRESS = 39;

/// Encoded bytes of one `OperatorEntry` carrying `addresses` chain addresses:
/// a maximum-length WIRE account name (4 B framing + 13 B) + its addresses
/// + 2 B `type` + 3 B `status` (SLASHED is 241, a two-byte varint) + 2 B for the
/// repeated-field framing in `Operators`.
///
/// Parameterised on the address count rather than pinned to today's outpost set:
/// an operator carries one address per registered outpost, so registering a third
/// outpost makes every entry ~39 B fatter. A fixed constant would silently
/// overstate capacity the moment that happens.
static constexpr uint32_t roster_bytes_per_operator(uint32_t addresses) {
return 17 + addresses * ROSTER_BYTES_PER_ADDRESS + 2 + 3 + 2;
}

/// The share of one envelope the roster may claim.
///
/// Sizing the ceiling against the WHOLE budget would let a legal configuration
/// produce a roster that fills an envelope by itself — the exact hazard
/// `MAX_SCHEDULED_BATCH_OPERATORS` names ("little room for value-bearing
/// attestations in the same envelope"). Half leaves the remainder for
/// BATCH_OPERATOR_GROUPS and the SWAP_REMIT / WITHDRAW_REMIT / RESERVE_READY
/// traffic that settles user value, which must not be deferred behind the roster.
static constexpr uint32_t ROSTER_ENVELOPE_SHARE_DIVISOR = 2;

/// Strictest OUTPOST-side ceiling on roster ENTRIES.
///
/// The depot's envelope arithmetic is necessary but not sufficient: an outpost
/// that cannot seat the roster it receives skips the WHOLE attestation and keeps
/// its previous one, so the roster silently stops tracking the depot. The Solana
/// outpost's `OperatorRegistry` is a fixed-size PDA — 32 entries today, raised to
/// 128 by wire-solana#442 (SOL-385), which explicitly defers the depot-side number
/// to WIRE-342. Ethereum applies no count cap, so Solana is the binding one.
///
/// Keep in lock-step with `liqsol-core`'s `MAX_OPERATORS`; this bound is only
/// meaningful while it is the smaller of the two.
static constexpr uint32_t MAX_OUTPOST_ROSTER_ENTRIES = 128;

/// Roster ceiling for a network carrying `outpost_count` active outposts: the
/// smaller of what half an envelope can carry and what the strictest outpost can
/// seat. Both bounds are real and neither implies the other — bytes gate what the
/// depot can SEND, entries gate what an outpost can STORE.
static constexpr uint32_t max_roster_operators(uint32_t outpost_count) {
const uint32_t addresses =
outpost_count == 0 ? 1
: (outpost_count > MAX_OPERATOR_CHAIN_ADDRESSES
? MAX_OPERATOR_CHAIN_ADDRESSES : outpost_count);
const uint32_t by_bytes =
static_cast<uint32_t>(sysio::opp::SINGLE_ATTESTATION_BUDGET_BYTES
/ ROSTER_ENVELOPE_SHARE_DIVISOR
/ roster_bytes_per_operator(addresses));
return by_bytes < MAX_OUTPOST_ROSTER_ENTRIES ? by_bytes : MAX_OUTPOST_ROSTER_ENTRIES;
}

private:

// Namespace alias for OPP protobuf enum types
Expand Down
136 changes: 99 additions & 37 deletions contracts/sysio.epoch/src/sysio.epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include <sysio.system/emissions.hpp>
#include <sysio.chains/sysio.chains.hpp>
#include <sysio/opp/attestations/attestations.pb.hpp>
#include <magic_enum/magic_enum.hpp>

namespace sysio {

Expand Down Expand Up @@ -58,6 +59,53 @@ inline bool is_active_outpost(const sysio::chains::chain_row& row) {
return row.active && !row.is_depot;
}

/// Build one OPERATORS-roster entry for `op`, collecting its authex-linked chain
/// addresses from the `byname` index.
///
/// The address walk is bounded at `epoch::MAX_OPERATOR_CHAIN_ADDRESSES`: `authex::links`
/// places no cap on how many keys one account may link, so an unbounded walk lets a
/// single account inflate one roster entry without limit — and the roster is the one
/// attestation that grows with adoption, so an oversized entry can push it past the
/// outbound envelope on its own.
template <typename LinksByNameIndex>
opp::attestations::OperatorEntry build_operator_entry(const opreg::operator_entry& op,
LinksByNameIndex& links_by_name) {
opp::attestations::OperatorEntry entry;
entry.account.name = op.account.to_string();
entry.type = op.type;
entry.status = op.status;

// Store raw public key bytes from the variant (33 bytes for EM/secp256k1,
// 32 bytes for ED/Ed25519).
auto link_it = links_by_name.lower_bound(op.account.value);
while (link_it != links_by_name.end() && link_it->username == op.account &&
entry.addresses.size() < epoch::MAX_OPERATOR_CHAIN_ADDRESSES) {
opp::types::ChainAddress chain_addr;
chain_addr.kind = static_cast<opp::types::ChainKind>(link_it->chain_kind);

std::visit([&](const auto& key_data) {
using T = std::decay_t<decltype(key_data)>;
if constexpr (std::is_same_v<T, webauthn_public_key>) {
// EM (secp256k1 compressed) — 33 bytes in key.key
chain_addr.address.assign(key_data.key.begin(), key_data.key.end());
} else if constexpr (std::is_same_v<T, ed_public_key>) {
// ED (Ed25519) — 32 bytes
chain_addr.address.assign(
reinterpret_cast<const char*>(key_data.data()),
reinterpret_cast<const char*>(key_data.data() + key_data.size()));
} else if constexpr (std::is_same_v<T, ecc_public_key>) {
// K1/R1 (secp256k1/P-256 compressed) — 33 bytes
chain_addr.address.assign(key_data.begin(), key_data.end());
}
// Skip BLS keys — not used for chain address linking
}, link_it->pub_key);

entry.addresses.push_back(std::move(chain_addr));
++link_it;
}
return entry;
}

struct emissions_gate_result {
bool ready = false;
bool is_pay_epoch = false; // true on the period-boundary epoch where payepoch fires
Expand Down Expand Up @@ -683,61 +731,62 @@ void epoch::advance() {
std::make_tuple(state.current_epoch_index)
).send();

// Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost.
// Queue the OPERATORS attestation for each outpost — but ONLY when the roster's
// content changed since that outpost's last send (see `epoch::roster_digest_entry`).
//
// IMPORTANT: Must come before BATCH_OPERATOR_GROUPS so that the ETH outpost's
// _handleOperators populates operatorEthAddress before _handleBatchOperatorGroups
// looks up those addresses.
// looks up those addresses. Skipping an UNCHANGED roster cannot break that ordering:
// both outposts retain the roster they already hold (ETH never prunes its address
// map; SOL only ever replaces it wholesale), and any newly-scheduled operator is by
// definition an ACTIVE batch operator, so its arrival IS a roster change and sends in
// the very epoch it is first scheduled.
{
opp::attestations::Operators ops_attest;
opreg::operators_t opreg_ops(OPREG_ACCOUNT);
authex::links_t authex_links(AUTHEX_ACCOUNT);
auto links_by_name = authex_links.get_index<"byname"_n>();
auto status_idx = opreg_ops.get_index<"bystatus"_n>();

for (auto it = opreg_ops.begin(); it != opreg_ops.end(); ++it) {
opp::attestations::OperatorEntry entry;
entry.account.name = it->account.to_string();
entry.type = it->type;
entry.status = it->status;

// Collect all authex-linked chain addresses for this operator.
// Store raw public key bytes from the variant (33 bytes for EM/secp256k1,
// 32 bytes for ED/Ed25519).
auto link_it = links_by_name.lower_bound(it->account.value);
while (link_it != links_by_name.end() && link_it->username == it->account) {
opp::types::ChainAddress chain_addr;
chain_addr.kind = static_cast<opp::types::ChainKind>(link_it->chain_kind);

std::visit([&](const auto& key_data) {
using T = std::decay_t<decltype(key_data)>;
if constexpr (std::is_same_v<T, webauthn_public_key>) {
// EM (secp256k1 compressed) — 33 bytes in key.key
chain_addr.address.assign(key_data.key.begin(), key_data.key.end());
} else if constexpr (std::is_same_v<T, ed_public_key>) {
// ED (Ed25519) — 32 bytes
chain_addr.address.assign(
reinterpret_cast<const char*>(key_data.data()),
reinterpret_cast<const char*>(key_data.data() + key_data.size()));
} else if constexpr (std::is_same_v<T, ecc_public_key>) {
// K1/R1 (secp256k1/P-256 compressed) — 33 bytes
chain_addr.address.assign(key_data.begin(), key_data.end());
}
// Skip BLS keys — not used for chain address linking
}, link_it->pub_key);

entry.addresses.push_back(std::move(chain_addr));
++link_it;
// ACTIVE and SLASHED only — never the full table.
//
// UNKNOWN (registered but never bonded) has no consumer on either outpost: it
// cannot be scheduled and cannot commit. Excluding it is what makes a never-bonded
// registration cost zero envelope bytes, so growing the broadcast requires bonding
// collateral per operator rather than being free. TERMINATED rows are settled
// history and are never erased, so including them would make the roster grow
// monotonically over the network's lifetime even under perfect operator churn.
//
// SLASHED MUST stay in: `opreg::slash` flips the row to SLASHED *before* it emits
// OPERATOR_ACTION(SLASH), and the Solana outpost resolves a slash's target through
// this roster — so an ACTIVE-only roster would silently no-op every slash.
for (const auto status : { OperatorStatus::OPERATOR_STATUS_ACTIVE,

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.

[P1] Ensure a target is published before its slash action

Excluding UNKNOWN leaves an operator absent from every outpost until its first ACTIVE/SLASHED roster. If it remains UNKNOWN across advances, then becomes ACTIVE and is slashed before the next advance, opreg::slash queues OPERATOR_ACTION(SLASH) first; this roster is queued later, so FIFO packing makes Solana process the slash without a mapping, return OperatorNotFound, and consume the action before the roster arrives. Retaining SLASHED therefore does not close the first-publication race. Ensure the target roster precedes any dependent action, or make slash application independent of the cached roster.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed the mechanism, and I agree the gap is real — but I think the fix belongs outpost-side, so I have not changed it here.

Verified on the depot: opreg::slash calls emit_slash_attestation inline mid-epoch, the roster is queued from advance at the epoch boundary, and buildenv walks the READY status index in att_id order. So the slash genuinely does precede a roster queued later in the same envelope. And you are right that retaining SLASHED does not close it — that carve-out keeps a published operator present, it does not publish a new one.

Where I differ is the remedy. "Ensure the target roster precedes any dependent action" is not something the depot can do without restructuring when slashes are emitted: the slash attestation is minted at slash time, and the roster only exists as a queued attestation at advance. Reordering would mean either deferring emit_slash_attestation to advance (changing existing, unrelated behaviour) or having opreg reach into sysio.epochs roster gate.

Your second alternative — make slash application independent of the cached roster — is outpost-side and composes with the tombstone work already in wire-solana#444, which is touching exactly this code (process_slash_action + the rebuild loop). I would rather it land there than contort the depot.

On reachability, for the record: for BATCH operators the window looks closed, since a slash requires delivery, which requires scheduling, which happens at the same advance that publishes the roster. It looks plausible for UNDERWRITERS now that WIRE-297 has landed an underwriting-fault slash path that does not depend on scheduling. I have not proven either.

Filing a SOL ticket for the outpost-side fix and linking it here unless you would rather it be tracked under WIRE-342.

OperatorStatus::OPERATOR_STATUS_SLASHED }) {
for (auto it = status_idx.lower_bound(magic_enum::enum_integer(status));
it != status_idx.end() && it->status == status; ++it) {
ops_attest.operators.push_back(build_operator_entry(*it, links_by_name));
}

ops_attest.operators.push_back(std::move(entry));
}

std::vector<char> encoded;
auto out = zpp::bits::out{encoded, zpp::bits::no_size{}};
(void)out(ops_attest);

// Content digest over the exact bytes each outpost would receive. Every outpost is
// sent the identical payload, so one digest compares against every per-outpost row.
const checksum256 roster_digest = sysio::keccak(encoded.data(), encoded.size());

epoch::rosterdig_t rosterdigs(get_self());
sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT);
for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) {
if (!is_active_outpost(*it)) continue;

const auto digest_pk = epoch::roster_digest_key{it->code.value};
const bool seen = rosterdigs.contains(digest_pk);
if (seen && rosterdigs.get(digest_pk).digest == roster_digest) {
continue; // unchanged since this outpost's last send — nothing to say
}

action(
permission_level{get_self(), "owner"_n},
MSGCH_ACCOUNT,
Expand All @@ -748,6 +797,19 @@ void epoch::advance() {
encoded
)
).send();

if (seen) {
rosterdigs.modify(ram_payer, digest_pk, [&](auto& row) {
row.digest = roster_digest;
row.sent_at_epoch = state.current_epoch_index;
});
} else {
rosterdigs.emplace(ram_payer, digest_pk, epoch::roster_digest_entry{
.chain_code = it->code.value,
.digest = roster_digest,
.sent_at_epoch = state.current_epoch_index,
});
}
}
}

Expand Down
Loading
Loading