Skip to content
Closed
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
2 changes: 1 addition & 1 deletion OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ This allows a single unified message format across all supported chains.
### Outbound (Wire → External Chain)

1. System contracts queue attestations for a target outpost via `sysio.msgch::queueout()`.
2. When the epoch advances, `buildenv()` packs all `READY` attestations into a protobuf-encoded outbound envelope with a merkle root.
2. When the epoch advances, `buildenv()` packs each outpost's `READY` attestations, the operator schedule first and otherwise oldest first, into a protobuf-encoded outbound envelope of at most 32 KiB; the rest wait for the next epoch.
3. Batch operators read the outbound envelope and submit it to the target chain's Outpost contract.
4. The Outpost contract (`OPPInbound.sol` on Ethereum) verifies consensus, checks merkle proofs, and routes attestations to registered handler contracts.

Expand Down
172 changes: 97 additions & 75 deletions contracts/sysio.epoch/src/sysio.epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include <sysio.chains/sysio.chains.hpp>
#include <sysio/opp/attestations/attestations.pb.hpp>
#include <magic_enum/magic_enum.hpp>
#include <array>

namespace sysio {

Expand Down Expand Up @@ -57,6 +58,22 @@ constexpr name RECORD_DELIVERY = "recorddel"_n;
constexpr name TERMINATION_CHECK = "termcheck"_n;
} // namespace opreg_actions

/// Action identifiers owned by sysio.msgch and invoked while closing an epoch.
namespace msgch_actions {
constexpr name QUEUE_OUT = "queueout"_n;
} // namespace msgch_actions

/// Operator statuses the OPERATORS roster lists; both outposts treat an operator missing from the
/// latest roster as not active. UNKNOWN (registered without the required bond) is left out because
/// registration is permissionless, so listing it would let anyone inflate the roster; the cost is
/// that such an operator's bond misses the Solana outpost's deposit priority lane. Leaving TERMINATED
/// out is safe: `releaselock` may still remit its locked collateral, and WITHDRAW_REMIT needs no
/// roster entry on either outpost. SLASHED stays because `opreg::slash` marks the row before it emits
/// the SLASH, and the Solana outpost resolves a slash's target, and keeps its tombstone, through the
/// roster.
constexpr std::array ROSTER_STATUSES = {OperatorStatus::OPERATOR_STATUS_ACTIVE,
OperatorStatus::OPERATOR_STATUS_SLASHED};

/// Durable reason prefix for an epoch-delivery classification slash.
constexpr const char* NON_CANONICAL_DELIVERY_REASON_PREFIX =
"non-canonical OPP envelope delivery, epoch ";
Expand Down Expand Up @@ -763,75 +780,65 @@ void epoch::advance() {
std::make_tuple(state.current_epoch_index)
).send();

// Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost.
// IMPORTANT: Must come before BATCH_OPERATOR_GROUPS so that the ETH outpost's
// _handleOperators populates operatorEthAddress before _handleBatchOperatorGroups
// looks up those addresses.
// The operator schedule: the OPERATORS roster (with each operator's authex chain addresses) and the
// BATCH_OPERATOR_GROUPS window. Both are encoded before either is queued because together they must
// fit the schedule lane's share of the envelope (`msgch::SCHEDULE_LANE_BUDGET_BYTES`).
//
// The roster lists `ROSTER_STATUSES` only, walked through opreg's `bystatus` index.
std::vector<char> encoded_operators;
{
opp::attestations::Operators ops_attest;
opreg::operators_t opreg_ops(OPREG_ACCOUNT);
auto status_idx = opreg_ops.get_index<"bystatus"_n>();
authex::links_t authex_links(AUTHEX_ACCOUNT);
auto links_by_name = authex_links.get_index<"byname"_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 = 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);
for (const auto roster_status : ROSTER_STATUSES) {
for (auto it = status_idx.lower_bound(magic_enum::enum_integer(roster_status));
it != status_idx.end() && it->status == roster_status; ++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 = 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, which are not used for chain address linking.
}, link_it->pub_key);

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

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

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

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

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;
action(
permission_level{get_self(), "owner"_n},
MSGCH_ACCOUNT,
"queueout"_n,
std::make_tuple(
it->code.value,
opp::types::ATTESTATION_TYPE_OPERATORS,
encoded
)
).send();
}
}

// Queue BATCH_OPERATOR_GROUPS attestation for each outpost.
// Encode the BATCH_OPERATOR_GROUPS attestation, queued with the roster below.
//
// Ships ALL groups, and an active index that points ONE EPOCH AHEAD --
// at the group that will be on duty for `current_epoch_index + 1`, not the
Expand All @@ -857,6 +864,8 @@ void epoch::advance() {
//
// `epoch_index` stays the epoch this envelope IS for; it identifies the
// envelope, not the roster, and no outpost reads it.
std::vector<char> encoded_groups;
bool have_next_group = false;
{
opp::attestations::BatchOperatorGroups attest;
// The window SLIDES; it does not rotate. `advance` erases the front and
Expand Down Expand Up @@ -919,7 +928,7 @@ void epoch::advance() {
// Withheld by SKIPPING THE QUEUEOUT ONLY -- never by returning from
// `advance`, which still has the epoch's remaining attestations and
// actions to issue after this block.
const bool have_next_group =
have_next_group =
next_group_index < group_count && !state.batch_op_groups[next_group_index].empty();
if (!have_next_group) {
sysio::print("sysio.epoch::advance: no non-empty next group to publish at epoch ",
Expand Down Expand Up @@ -947,27 +956,40 @@ void epoch::advance() {
attest.groups.push_back(std::move(grp));
}

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

// `have_next_group` gates the QUEUEOUT, not `advance` -- see above.
if (have_next_group) {
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;
action(
permission_level{get_self(), "owner"_n},
MSGCH_ACCOUNT,
"queueout"_n,
std::make_tuple(
it->code.value,
opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS,
encoded
)
).send();
}
// A roster that would take the schedule past its budget is withheld the same way: its queueout is
// skipped, `advance` goes on, and the outposts keep the roster they hold. BATCH_OPERATOR_GROUPS
// counts toward the budget only when it ships.
const size_t schedule_bytes =
msgch::attestation_estimate_bytes(encoded_operators.size()) +
(have_next_group ? msgch::attestation_estimate_bytes(encoded_groups.size()) : 0);
const bool ship_operators = schedule_bytes <= msgch::SCHEDULE_LANE_BUDGET_BYTES;
if (!ship_operators) {
sysio::print("sysio.epoch::advance: operator schedule needs ", schedule_bytes,
" estimated bytes at epoch ", state.current_epoch_index, ", over its ",
msgch::SCHEDULE_LANE_BUDGET_BYTES, "-byte budget; withholding Operators (",
encoded_operators.size(), " bytes), outposts retain their previous roster\n");
}

// Every outpost's OPERATORS queueout goes out before any BATCH_OPERATOR_GROUPS one: the ETH
// outpost's `_handleOperators` populates `operatorEthAddress` before `_handleBatchOperatorGroups`
// looks those addresses up. `have_next_group` gates the queueout, not `advance`; see above.
const auto queue_to_active_outposts = [&](opp::types::AttestationType type, const std::vector<char>& data) {
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;
action(permission_level{get_self(), "owner"_n}, MSGCH_ACCOUNT, msgch_actions::QUEUE_OUT,
std::make_tuple(it->code.value, type, data)).send();
}
};
if (ship_operators) {
queue_to_active_outposts(opp::types::ATTESTATION_TYPE_OPERATORS, encoded_operators);
}
if (have_next_group) {

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.

ship_operators is false when the combined schedule exceeds the budget, but this branch still publishes BATCH_OPERATOR_GROUPS. The new window is therefore resolved against the previous roster. Ethereum stores unresolved names as address(0) seats that still count toward the quorum denominator, while Solana drops them; enough newly active or changed operators can make the next epoch impossible to deliver, and the correcting roster is then trapped inside an envelope no seated group can deliver. Publish the roster and dependent window atomically, or prove the window is resolvable against the last delivered roster.

queue_to_active_outposts(opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, encoded_groups);
}

// Drain the swap-from-WIRE queue: each row queued via
Expand Down
Binary file modified contracts/sysio.epoch/sysio.epoch.wasm
Binary file not shown.
12 changes: 10 additions & 2 deletions contracts/sysio.liq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ to be AuthX-linked for the token's chain, settles and burns the shadow, and queu
`sysio.msgch::queueout`. Request ids start at 1. The burn is final: an outpost
refusal is reconciled from its log by governance through `recredit`.

`quantity` must reach the symbol's `min_desyndicate`. Every de-syndication is a
sysio-billed outbound row and a payout the outpost relay pays for, so the floor keeps
dust from buying either. `create` sets it to one hundredth of a whole token (never
below one subunit), and `setmindesyn` retunes it per symbol. A holder can be left with
less than the floor, for example after a partial exit or a small transfer in; that
remainder can still be transferred, swapped, or topped up and then de-syndicated.

**Launch ingestion (epoch-0 bootstrap window, privileged caller).** `regliqpool`
mints the LCO liq to `sysio`, deposits it with the T5 dex earmark WIRE into
`sysio.swap`, creates the pair (`sysio` fee authority, the shadow as yield leg)
Expand All @@ -57,7 +64,7 @@ import.

| Table | Scope / key | Row |
|---|---|---|
| `stat` | symbol code | `supply`, `chain_code`, `token_code`, `pair_symbol` (empty until `regliqpool`); index `bytoken` |
| `stat` | symbol code | `supply`, `chain_code`, `token_code`, `pair_symbol` (empty until `regliqpool`), `min_desyndicate`; index `bytoken` |
| `accounts` | holder / symbol code | `balance`, `index_checkpoint` (uint128), `owed_wire` |
| `yieldidx` | symbol code | `index` (uint128), `pot`, `carry` |
| `parked` | symbol code, chain kind, pubkey | `chain_kind`, `pubkey`, `holding` (an account row) |
Expand All @@ -73,6 +80,7 @@ import.
| `create(sym, chain_code, token_code)` | self | Register a shadow for an active `TOKEN_KIND_LIQ` token bound to an active outpost |
| `setkicker(bps)` | `sysio` | Kicker for the intakes from now on |
| `recredit(holder, quantity)` | self | Mint back after an outpost refused a de-syndication |
| `setmindesyn(minimum)` | self | Smallest quantity `desyndicate` accepts for `minimum`'s symbol; positive |
| `mintsynd(chain_code, sequence, account, token_code, amount)` | `sysio.msgch` | SYNDICATE_LIQ, linked user |
| `park(chain_code, sequence, chain_kind, pubkey, token_code, amount)` | `sysio.msgch` | SYNDICATE_LIQ, unlinked user |
| `mintyield(chain_code, sequence, epoch, token_code, amount)` | `sysio.msgch` | LIQ_YIELD into the pending balance |
Expand All @@ -89,7 +97,7 @@ import.

In order: `sysio.roa::setsyscode` for `sysio.liq`; the chain and its liq token
registered and active in `sysio.chains` / `sysio.tokens`; `create` per shadow
symbol; `setkicker` if the default is not wanted; `regliqpool` per pool;
symbol; `setkicker` and `setmindesyn` if the defaults are not wanted; `regliqpool` per pool;
`importsynd` batches; `importdone`. `sysio.swap` must be configured
(`setconfig`) before `regliqpool`, and the batch-operator crank pushes
`queueyield` per symbol and `sysio.swap::tickyield` per pair.
Expand Down
13 changes: 12 additions & 1 deletion contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ namespace sysio {
/// The kicker at launch: 2% of every yield intake, drawn from T5.
static constexpr uint32_t DEFAULT_KICKER_BPS = 200;

/// `create` sets a new shadow's `min_desyndicate` to one whole token divided by this, and never
/// below one subunit. Every de-syndication is a sysio-billed outbound row and a relayer-paid
/// payout, so the floor keeps dust from buying either.
static constexpr int64_t DEFAULT_MIN_DESYNDICATE_DIVISOR = 100;

// -----------------------------------------------------------------------
// Deployment and governance
// -----------------------------------------------------------------------
Expand All @@ -86,6 +91,10 @@ namespace sysio {
/// contract's authority.
[[sysio::action]] void recredit(name holder, asset quantity);

/// Set the smallest quantity `desyndicate` accepts for `minimum`'s symbol. Requires this
/// contract's authority; the minimum must be positive.
[[sysio::action]] void setmindesyn(asset minimum);

// -----------------------------------------------------------------------
// Inbound OPP effects (sysio.msgch dispatch; never throw)
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -153,6 +162,7 @@ namespace sysio {

/// Burn `quantity` of `holder`'s shadow and queue DESYNDICATE_LIQ to the
/// symbol's outpost, paying the pubkey `holder` has linked for that chain.
/// `quantity` must reach the symbol's `min_desyndicate`.
/// The burn is final: an outpost refusal is reconciled by governance through
/// `recredit`. Holder's authority.
[[sysio::action]] void desyndicate(name holder, asset quantity);
Expand Down Expand Up @@ -202,10 +212,11 @@ namespace sysio {
sysio::slug_name chain_code; ///< the outpost whose liq this shadow mirrors
sysio::slug_name token_code; ///< that outpost's liq token in sysio.tokens
symbol_code pair_symbol; ///< the swap's pair token of this shadow's yield pool; empty until regliqpool
asset min_desyndicate; ///< smallest quantity `desyndicate` accepts; `setmindesyn` retunes it

uint64_t by_token_code() const { return token_code.value; }

SYSLIB_SERIALIZE(currency_stats, (supply)(chain_code)(token_code)(pair_symbol))
SYSLIB_SERIALIZE(currency_stats, (supply)(chain_code)(token_code)(pair_symbol)(min_desyndicate))
};

using stats = kv::table<"stat"_n, symbol_key, currency_stats,
Expand Down
Loading
Loading