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
2 changes: 1 addition & 1 deletion OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ This allows a single unified message format across all supported chains.

| Category | Types | Direction | Purpose |
|----------|-------|-----------|---------|
| **Operator Rosters** | `OPERATORS`, `BATCH_OPERATOR_GROUPS` | depot → outpost | Full operator roster and the next epoch's batch-operator group, sent every epoch |
| **Operator Rosters** | `OPERATORS`, `BATCH_OPERATOR_GROUPS` | depot → outpost | The operators the schedule window seats, with their chain addresses, and the next epoch's batch-operator group, sent every epoch, except that an empty window sends no roster and an empty next group withholds `BATCH_OPERATOR_GROUPS` |
| **Liquidity Syndication** | `SYNDICATE_LIQ`, `LIQ_YIELD` | outpost → depot | A user syndicating liq tokens into outpost custody; realized custody yield |
| **Liquidity Desyndication** | `DESYNDICATE_LIQ` | depot → outpost | Return syndicated liq tokens to a holder on the outpost |
| **Node Owners** | `NODE_OWNER_REG` | outpost → depot | Node-owner NFT committed on Ethereum, registering the owner on the depot |
Expand Down
7 changes: 4 additions & 3 deletions contracts/sysio.epoch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Operator registration, collateral and status live on `sysio.opreg`; the outpost

| Action | Auth | Description |
|--------|------|-------------|
| `setconfig` | `sysio.epoch` | Set the epoch configuration. Requires `operators_per_epoch * batch_op_groups == batch_operator_minimum_active` and enforces the size caps (`MAX_OPERATORS_PER_EPOCH` 100, `MAX_BATCH_OP_GROUPS` 255, `MAX_SCHEDULED_BATCH_OPERATORS` 1000) |
| `setconfig` | `sysio.epoch` | Set the epoch configuration. Requires `operators_per_epoch * batch_op_groups == batch_operator_minimum_active` and enforces the outposts' limits (`MAX_OPERATORS_PER_EPOCH` 7, Ethereum's group size; `MAX_BATCH_OP_GROUPS` 8, Solana's group count), so the window holds at most `MAX_SCHEDULED_BATCH_OPERATORS` (56) |
| `schbatchgps` | `sysio.epoch` | Build the initial window from the `ACTIVE` batch operators on `sysio.opreg` (non-bootstrapped first, then bootstrapped, trimmed to `batch_operator_minimum_active`) |
| `advance` | permissionless at genesis (epoch 0 → 1); afterwards `sysio.msgch` or `sysio.epoch` | Advance the epoch once the wall-clock boundary has passed and the contract is not paused |
| `pause` | `sysio.chalg` | Set the global pause |
Expand All @@ -46,8 +46,9 @@ Operator registration, collateral and status live on `sysio.opreg`; the outpost
The resident groups stay disjoint, so an operator serves at most once every `batch_op_groups`
epochs.
4. `sysio.opreg::flushwtdw` matures queued collateral withdrawals.
5. Queue `OPERATORS` and `BATCH_OPERATOR_GROUPS` to every active outpost through
`sysio.msgch::queueout` (an empty group is withheld), then `sysio.msgch::buildenv` per outpost.
5. Queue `OPERATORS` (the operators the window seats, with their status and authex links; none for
an empty window) and `BATCH_OPERATOR_GROUPS` (withheld when the next group is empty) to every
active outpost through `sysio.msgch::queueout`, then `sysio.msgch::buildenv` per outpost.
6. `sysio.system::accrueepoch` and `rcrdbatch`, plus `payepoch` on pay epochs.

## Dependencies
Expand Down
52 changes: 17 additions & 35 deletions contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -155,41 +155,23 @@ namespace sysio {
static constexpr uint32_t MIN_EPOCH_DURATION_SEC = 60;
static constexpr uint32_t MAX_EPOCH_DURATION_SEC = 30u * 24u * 60u * 60u;

/// Upper bound on the number of batch-operator groups. The active group is
/// carried on-chain and in the batch_operator_plugin as a `uint8_t`, which
/// reserves 255 as its "not in any group" sentinel. Groups are indexed
/// 0..batch_op_groups-1, so capping at 255 keeps the largest index (254)
/// below the sentinel and inside the byte, preventing index truncation or a
/// collision that would miselect batch operators.
static constexpr uint32_t MAX_BATCH_OP_GROUPS = 255;

/// Upper bound on `operators_per_epoch` (the size of one schedule group).
/// Every member of the expiring group costs two inline actions in
/// `advance` (opreg::recorddel + opreg::termcheck) plus a payclaims
/// credit in sysio.system::payepoch, so group size directly scales the
/// epoch-boundary transaction; an absurd value (which the product equality
/// alone cannot reject -- UINT32_MAX groups of one is internally consistent)
/// would abort `advance` on its vector reserves and halt epoch advancement
/// chain-wide. 100 mirrors sysio.system's producer-pay safety cap
/// (MAX_STANDBY_END_RANK) and is an order of magnitude above any practical
/// group size.
static constexpr uint32_t MAX_OPERATORS_PER_EPOCH = 100;

/// Upper bound on `batch_operator_minimum_active`, which the product
/// equality pins to the total resident schedule window
/// (operators_per_epoch * batch_op_groups). The full window is held in the
/// `epochstate` singleton row, flattened into a resident vector on every
/// `advance`, and serialized into the per-outpost BatchOperatorGroups
/// attestation (~20 bytes per member against the 32 KiB OPP envelope cap).
/// The ceiling is unchanged by the platform cap reduction 65 536 -> 32 768,
/// but the headroom it leaves is materially tighter: at the ceiling the
/// roster attestation alone is ~20 KB, about 62 % of a 32 KiB envelope
/// (it was ~31 % of 64 KiB). 1000 members still keeps all three bounded --
/// the resident window vector, the epoch-boundary transaction, and the
/// attestation -- while far exceeding any practical roster; a roster that
/// large would leave little room for value-bearing attestations in the same
/// envelope, and `buildenv` would carry the remainder to the next epoch.
static constexpr uint32_t MAX_SCHEDULED_BATCH_OPERATORS = 1000;
/// Upper bound on the number of batch-operator groups: Solana's operator registry seats at most
/// `MAX_OPERATOR_GROUPS` (8), and a window an outpost cannot seat stalls that outpost.
static constexpr uint32_t MAX_BATCH_OP_GROUPS = 8;

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.

Medium — keep cluster-tool schedule validation in sync with these limits. wire-tools-ts still validates with MAX_BATCH_OP_GROUPS = 255, MAX_OPERATORS_PER_EPOCH = 100, and MAX_SCHEDULED_BATCH_OPERATORS = 1000 in BatchOperatorSchedule.ts. Configurations such as 9 groups of 1 or one group of 17 therefore pass tool preflight and fail later in epoch::setconfig during cluster startup. Please update those constants and their boundary tests in a companion change, ideally from a shared or generated source of truth, so invalid schedules are rejected before startup.


/// Upper bound on `operators_per_epoch` (the size of one schedule group): Ethereum's OPPInbound is
/// sized and tested for `OPERATORS_PER_GROUP` (7), under Solana's `MAX_GROUP_MEMBERS` (16). Each
/// member of the expiring group also costs two inline actions in `advance` (opreg::recorddel +
/// opreg::termcheck) and a payclaims credit in sysio.system::payepoch.
static constexpr uint32_t MAX_OPERATORS_PER_EPOCH = 7;

/// Largest schedule window (operators_per_epoch * batch_op_groups, which the product equality pins
/// `batch_operator_minimum_active` to). The OPERATORS roster and the BatchOperatorGroups attestation
/// list only the window, so this also bounds both (about 5.6 KB of roster at the ceiling). Ethereum
/// applies the roster in one handler call, so it must fit one transaction: at 8 x 7, with 49 operators
/// Ethereum has never seen, it takes about 9.3M gas (12.4M with the outbound queue full) of EIP-7825's
/// 16,777,216, while 8 x 16 runs the handler out of gas (WIRE-342).
static constexpr uint32_t MAX_SCHEDULED_BATCH_OPERATORS = MAX_BATCH_OP_GROUPS * MAX_OPERATORS_PER_EPOCH;

private:

Expand Down
130 changes: 71 additions & 59 deletions contracts/sysio.epoch/src/sysio.epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -250,22 +250,21 @@ void epoch::setconfig(uint32_t epoch_duration_sec,
check(operators_per_epoch > 0, "operators_per_epoch must be positive");
check(batch_op_groups > 0, "batch_op_groups must be positive");
check(batch_op_groups <= MAX_BATCH_OP_GROUPS,
"batch_op_groups exceeds the uint8 group-index ceiling (255)");
"batch_op_groups exceeds the outpost group ceiling (8)");
// Widen the product to uint64 so a large operators_per_epoch cannot wrap the
// uint32 multiply into a small value that spuriously matches a small
// batch_operator_minimum_active; an honest product beyond uint32 simply fails
// the equality against the uint32 field, which is the intended rejection.
check(static_cast<uint64_t>(operators_per_epoch) * batch_op_groups == batch_operator_minimum_active,
"batch_operator_minimum_active must equal operators_per_epoch * batch_op_groups");
// Magnitude bounds, checked after the relationship above so an inconsistent
// Magnitude bound, checked after the relationship above so an inconsistent
// triple reports the equality error. The equality alone cannot reject an
// internally consistent but absurd size (UINT32_MAX groups of one), which
// would later abort advance() on its schedule-window vector reserves and
// halt epoch advancement chain-wide.
// internally consistent but absurd size (UINT32_MAX operators in one group),
// which would later abort advance() on its schedule-window vector reserves and
// halt epoch advancement chain-wide. With both factors bounded, the window
// cannot exceed MAX_SCHEDULED_BATCH_OPERATORS.
check(operators_per_epoch <= MAX_OPERATORS_PER_EPOCH,
"operators_per_epoch exceeds the per-epoch schedule ceiling (100)");
check(batch_operator_minimum_active <= MAX_SCHEDULED_BATCH_OPERATORS,
"batch_operator_minimum_active exceeds the schedule-window ceiling (1000)");
"operators_per_epoch exceeds the outpost group-size ceiling (7)");
check(epoch_retention_envelope_log_count > 0,
"epoch_retention_envelope_log_count must be positive");

Expand Down Expand Up @@ -735,7 +734,11 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) {

state_tbl.set(state, ram_payer);

// Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost.
// Queue the OPERATORS roster for each outpost: every operator seated in the window, with its current
// status and authex chain addresses. Outposts read the roster only to map the window's members to
// the keys they deliver with (Solana also refuses a window that seats an ineligible member), so the
// window is all they need, and the roster stays within MAX_SCHEDULED_BATCH_OPERATORS however many
// operators register. A member whose opreg row was pruned is left out.
// IMPORTANT: Must come before BATCH_OPERATOR_GROUPS so that the ETH outpost's
// _handleOperators populates operatorEthAddress before _handleBatchOperatorGroups
// looks up those addresses.
Expand All @@ -745,61 +748,70 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) {
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& group : state.batch_op_groups) {
for (const auto& member : group) {
const auto op = opreg_ops.try_get(opreg::operator_key{member.value});
if (!op) continue;

opp::attestations::OperatorEntry entry;
entry.account.name = member.to_string();
entry.type = op->type;
entry.status = op->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(member.value);
while (link_it != links_by_name.end() && link_it->username == member) {
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);

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));

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.

High — cap or chunk the roster to what Ethereum can dispatch atomically. The new limits permit 8 groups × 16 members, while finishadv encodes all 128 members into one OPERATORS attestation. Against current wire-ethereum/next (0b5184ce), I reproduced the maximum roster under the 16,777,216 transaction gas target: dispatch reverts on the first entry with OPP_HandlerGasExhausted (handler gas bound ≈13.94M). Ethereum resumes only between attestations, so when this atomic handler fails first, no cursor progress is committed and retries at the supported limit cannot advance the epoch. Please either lower the accepted roster ceiling, chunk OPERATORS, or make the Ethereum handler internally resumable, and add an 8×16 cross-repo gas-cap test.

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.

Lowered, as you suggest: setconfig now takes at most 7 per group, Ethereum's OPERATORS_PER_GROUP, so the window and the roster are at most 8 × 7 = 56 (06ceae3). On wire-ethereum/next (0b5184ce), with every entry carrying an EVM key and an SVM address and every operator outside the delivering group unseen, it fits in one transaction:

Roster Peak transaction Result
8 × 7, 49 unseen 9,312,094 gas (55.5% of 16,777,216) dispatches in one transaction
the same, outbound queue full 12,422,290 (74.0%) one transaction
63 entries, 56 unseen, queue full 13,365,585 (79.7%) one transaction
8 × 16, 121 unseen — OPP_HandlerGasExhausted, as you found

The 8 × 7 case will be a permanent test in wire-ethereum's OPPWorstCase suite, in its own PR.

}

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);
// An empty roster tells an outpost nothing (Solana skips it as an anomaly), so an empty window
// queues none; the BatchOperatorGroups withhold below reports that state.
if (!ops_attest.operators.empty()) {

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.

High — an empty post-slide window preserves removed operators’ outpost authority.

This new guard suppresses the authoritative OPERATORS snapshot when the window becomes empty. On the same transition, finishadv also withholds BATCH_OPERATOR_GROUPS when the next group is empty (lines 891–939), so neither outpost receives a revocation.

This is reachable with the allowed 1-group/1-operator configuration. The sole non-bootstrap operator can deliver the current epoch and then request its full collateral withdrawal; opreg::withdraw immediately re-evaluates eligibility. The next slide removes that now-non-ACTIVE member and selects an empty tail. Previously, the full roster would have carried UNKNOWN/SLASHED/TERMINATED; this branch emits neither control attestation.

An initialized Solana outpost retains the old ACTIVE mapping and active group. Ethereum only clears an omitted/inactive resident seat when its OPERATORS handler runs, and its retained one-slot anchor can authorize that old address for the next epoch. With a 1×1 quorum, the removed key can submit a forged next envelope containing OPERATORS and BATCH_OPERATOR_GROUPS entries that re-seat itself.

Please provide an explicit fail-closed revocation shape that both outposts apply (for example bounded terminal tombstones or coordinated authoritative-empty support), or stop advancement when no authoritative next group can be conveyed. Add an initialized-outpost regression covering a nonempty→empty transition and assert that the old key cannot submit the next epoch. The new opreg slash comment is also still inaccurate on this path because the claimed group update is withheld.

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.

I built the tombstone variant to test this: every roster listed the expired group's departing members with their real status. I also wrote initialized-outpost regressions for it on Ethereum and Solana. It doesn't close the attack, so I've left it out and squashed the PR.

  • Only the departing operator can deliver envelope N. Any revocation for N, whether a tombstone or an authoritative-empty, travels in envelope N. Both outposts admit only the group that envelope N−1 seated for N, which is the departing operator. Nothing else delivers it either: batch_operator_plugin relays only for the depot's front group (outpost_opp_job.cpp:54), and that group is empty after this slide.
  • A hostile operator can forge N whatever the depot emits. With a 1×1 quorum, its forged N re-seated it in both reproductions: a scratch run on Ethereum next and the Solana harness.
  • This predates [WNS-56] OPP: bound the operator roster to the schedule window and cap queueout #692. Master's full roster travelled in the same envelope.
  • Stopping advancement doesn't change what the outposts accept for N. With an empty window no one can deliver to the depot either, so the bridge halts at N anyway.

What happens to the old key after N:

  • Ethereum: the single-group window maps exactly one epoch ahead. With BATCH_OPERATOR_GROUPS withheld, N+1 is unmapped and the key reverts with OPP_UnmappedEpochGroup, tombstone or not.
  • Solana: the active group is not tied to an epoch, so the seated group stays authorized until a groups update arrives.

Closing this needs changes outside the roster, tracked in WIRE-423:

  1. Tie Solana's active group to the epoch it was shipped for, as Ethereum does.
  2. Keep a scheduled operator from withdrawing below its minimum until its scheduled epochs close.
  3. Elect a relay when the depot's window is empty.

The slash comment no longer claims the groups update. It now says rosters list the operator as SLASHED while it is seated and leave it out once its group has left.

std::vector<char> encoded;
auto out = zpp::bits::out{encoded, 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();
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();
}
}
}

Expand Down
Binary file modified contracts/sysio.epoch/sysio.epoch.wasm
Binary file not shown.
2 changes: 1 addition & 1 deletion contracts/sysio.msgch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Inbound/outbound OPP message chain management and consensus tracking contract.
| `evalcons` | `sysio.msgch` | Evaluate consensus for an (outpost, epoch); on consensus store and dispatch the attestations; on an eligible split open a `sysio.chalg` dispute |
| `chkcons` | permissionless | If no dispute is open, every active outpost has consensus and the boundary has passed, call `sysio.epoch::advance` |
| `resolvedisp` | `sysio.chalg` | Dispatch the winning envelope of a resolved dispute |
| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.synd`, or `sysio.msgch` | Queue an outbound attestation for an outpost |
| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.synd`, or `sysio.msgch` | Queue an outbound attestation for an outpost. Rejects `data` larger than `MAX_ATTESTATION_DATA_BYTES`, so every queued row fits an envelope |
| `buildenv` | `sysio.epoch` | Pack `READY` attestations into the outpost's outbound envelope for the epoch |

## Attestation routing
Expand Down
26 changes: 25 additions & 1 deletion contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ namespace sysio {
public:
using contract::contract;

// -----------------------------------------------------------------------
// Envelope sizing
// -----------------------------------------------------------------------

/// Hard cap on an encoded envelope in both directions, mirroring the Solana
/// (`opp_outpost::MAX_ENVELOPE_BYTES`) and Ethereum (`OPP.MAX_ENVELOPE_BYTES`) caps. Ethereum is
/// the binding constraint: a cold `emitOutboundEnvelope` of a near-64-KiB envelope exceeds the
/// EIP-7825 per-transaction gas cap. Inbound, `deliver` rejects anything larger.
static constexpr size_t MAX_ENVELOPE_BYTES = 32'768;

/// Per-attestation framing `buildenv` budgets for (tags, length prefixes, type and data-size
/// fields). Over-counts the real encoding by a few bytes.
static constexpr size_t ATTESTATION_OVERHEAD_BYTES = 24;

/// Envelope and message header `buildenv` budgets for, with margin for length prefixes.
/// Over-counts the real encoding.
static constexpr size_t ENVELOPE_BASELINE_BYTES = 512;

/// Largest attestation `data` an otherwise empty envelope can carry. `queueout` rejects anything
/// larger, so every READY row can ship.
static constexpr size_t MAX_ATTESTATION_DATA_BYTES =
MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - ATTESTATION_OVERHEAD_BYTES;

// -----------------------------------------------------------------------
// Actions
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -60,7 +83,8 @@ namespace sysio {
void resolvedisp(uint64_t chain_code, uint32_t epoch_index, checksum256 winning_checksum);

/// Queue an outbound attestation for an outpost.
/// Writes to the attestations table with status READY.
/// Writes to the attestations table with status READY. Rejects `data` larger than
/// MAX_ATTESTATION_DATA_BYTES, so every READY row fits an envelope.
///
/// `chain_code` is the destination outpost's chain slug_name value
/// (uint64). Called by sibling system contracts that need to send
Expand Down
Loading
Loading