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
56 changes: 17 additions & 39 deletions contracts/sysio.msgch/src/sysio.msgch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -903,25 +903,23 @@ void apply_consensus(name self, uint64_t chain_code, uint32_t epoch_index,
auto decode_result = in(envelope);
check(decode_result == zpp::bits::errc{}, "failed to decode inbound OPP Envelope");

// Inbound chain verification (staged). Outposts self-chain their outbound envelopes:
// Inbound chain verification (ENFORCED). Outposts self-chain their outbound envelopes:
// `previous_envelope_hash` must continue from the canonical epoch digest of the envelope
// accepted before it from the same outpost (`outpcons.envelope_digest`). The digest is
// recomputed from the DECODED envelope (a defensive re-encode, exactly like the outposts'
// `OPPCommon.epochHash`), so a byte-mutated but semantically identical delivery canonicalises
// to the same digest. Staging mirrors the Ethereum OPPInbound: an EMPTY prev-hash is accepted
// and logged, because not every outpost chains yet; a NON-empty prev-hash that does not
// continue the recorded tip is a chain break and the envelope is dropped before any dispatch,
// to the same digest. Both outposts now self-chain per stream — the EVM outpost via
// `OPP.prevEpochHash`, the SVM outpost via `previous_outbound_epoch_hash` (wire-solana
// SEC-114) — so once a chain tip is recorded for an outpost, a NON-continuing
// `previous_envelope_hash` is a chain break and the envelope is dropped before any dispatch,
// consensus record, or cleanup (delivery rows stay intact for audit/dispute), per the
// never-throw handler convention.
//
// INTERIM, remove when the Solana outpost adopts per-stream chaining: the Solana outpost
// stamps its outbound `previous_envelope_hash` from its single `previous_epoch_hash` slot,
// which only its `epoch_in` advances, so its envelopes link to the digest of the last DEPOT
// envelope it accepted rather than to its own previous emit. That is still an exact digest
// binding the depot can verify locally: the linked digest is this contract's own last
// outbound emit for the outpost (the surviving one-deep `outenvelopes` row). Accept that
// link as an alternate continuation for SVM outposts ONLY; EVM outposts chain per-stream
// and get no fallback.
// never-throw handler convention. An EMPTY `previous_envelope_hash` is valid only at genesis,
// when the recorded tip itself is still empty; a non-empty value must be exactly the 32-byte
// tip (`to_checksum256_exact` rejects any other length, so a wrong-sized or all-zero field is
// a break, matching the outposts' canonical-form enforcement). The previous STAGED acceptance
// of an empty prev-hash for non-genesis epochs, and the INTERIM SVM cross-stream fallback that
// bridged the pre-SEC-114 Solana outpost, are both removed now that per-stream chaining is
// live on both chains.
//
// The source chain row also feeds the audit-log endpoints projection below; `deliver`
// validated it at delivery time, and `resolvedisp` re-enters through the same registry.
Expand All @@ -936,32 +934,12 @@ void apply_consensus(name self, uint64_t chain_code, uint32_t epoch_index,
const checksum256 chain_tip =
opcons.contains(opc_pk) ? opcons.get(opc_pk).envelope_digest : checksum256{};
if (chain_tip != checksum256{}) {
if (envelope.previous_envelope_hash.empty()) {
sysio::print_f("apply_consensus: chain_code=%llu epoch=%u accepted with empty "
"previous_envelope_hash (staged chain enforcement)\n",
const auto prev = to_checksum256_exact(envelope.previous_envelope_hash);
if (!prev.has_value() || *prev != chain_tip) {
sysio::print_f("DROP envelope chain break: chain_code=%llu epoch=%u "
"previous_envelope_hash does not continue the accepted chain\n",
static_cast<unsigned long long>(chain_code), epoch_index);
} else {
const auto prev = to_checksum256_exact(envelope.previous_envelope_hash);
bool continues_chain = prev.has_value() && *prev == chain_tip;
if (!continues_chain && prev.has_value() &&
op_row.kind == ChainKind::CHAIN_KIND_SVM) {
msgch::outenvelopes_t outenvs(self);
auto by_outpost = outenvs.get_index<"byoutpost"_n>();
auto out_it = by_outpost.lower_bound(chain_code);
if (out_it != by_outpost.end() && out_it->chain_code == chain_code &&
*prev == out_it->envelope_hash) {
continues_chain = true;
sysio::print_f("apply_consensus: chain_code=%llu epoch=%u accepted via "
"cross-stream link to the depot's last outbound emit\n",
static_cast<unsigned long long>(chain_code), epoch_index);
}
}
if (!continues_chain) {
sysio::print_f("DROP envelope chain break: chain_code=%llu epoch=%u "
"previous_envelope_hash does not continue the accepted chain\n",
static_cast<unsigned long long>(chain_code), epoch_index);
return;
}
return;
}
}
}
Expand Down
Binary file modified contracts/sysio.msgch/sysio.msgch.wasm
Binary file not shown.
68 changes: 37 additions & 31 deletions contracts/tests/sysio.msgch_chain_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
/// including proto3 defaults) that the outpost codecs (`OPPCommon.epochHash`) re-derive.
///
/// Inbound: `apply_consensus` records the accepted envelope's canonical digest as the per-outpost
/// chain tip (`outpcons.envelope_digest`) and verifies each envelope continues the chain. STAGED
/// enforcement, mirroring the Ethereum `OPPInbound`: an empty `previous_envelope_hash` is accepted
/// and logged (not every outpost chains yet); a non-empty one that does not match the tip drops
/// the envelope without dispatching and without throwing.
/// chain tip (`outpcons.envelope_digest`) and verifies each envelope continues the chain. ENFORCED
/// (SEC-107 completion): both outposts self-chain per stream, so once a tip is recorded an empty
/// `previous_envelope_hash` is a chain break (valid only at genesis, before any tip); a non-empty
/// one must be exactly the 32-byte tip. Any other value — empty, wrong length, or non-matching —
/// drops the envelope without dispatching and without throwing. An outpost's FIRST accepted
/// envelope (no tip yet) still bootstraps regardless of its prev-hash.
///
/// The oracle encoder in this file is an independent host-side reimplementation of the canonical
/// encoding (the contract-side implementation is
Expand Down Expand Up @@ -676,24 +678,25 @@ BOOST_FIXTURE_TEST_CASE(inbound_chain_tip_recorded_and_verified, sysio_msgch_cha
BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), n2_digest.str()); // tip unchanged
BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, break_epoch), 0u); // nothing dispatched

// Epoch E+3: staged enforcement: an EMPTY prev is still accepted (not every outpost chains
// yet) and re-tips the chain to the accepted envelope's digest.
const uint32_t staged_epoch = advance_one_epoch();
auto n4 = encode_delivery(staged_epoch, "delta");
const auto n4_digest = oracle::epoch_digest(decode_envelope(n4));
// Epoch E+3: enforcement (SEC-107 completion): an EMPTY prev on a non-genesis epoch is now a
// chain break. Both outposts self-chain per stream, so once a tip is recorded an empty
// prev-hash no longer bootstraps — it is dropped without throwing and the tip does not move.
const uint32_t empty_epoch = advance_one_epoch();
auto n4 = encode_delivery(empty_epoch, "delta"); // no prev => empty field
BOOST_REQUIRE_EQUAL(success(), deliver(ETH_OUTPOST_ID, n4));
produce_blocks();

opc = get_outpcons(ETH_OUTPOST_ID);
BOOST_REQUIRE_EQUAL(opc["epoch_index"].as<uint32_t>(), staged_epoch);
BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), n4_digest.str());
BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, staged_epoch), 1u);
BOOST_REQUIRE_EQUAL(opc["epoch_index"].as<uint32_t>(), epoch); // still E+1
BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), n2_digest.str()); // tip unchanged
BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, empty_epoch), 0u); // nothing dispatched

// Epoch E+4: a populated envelope_hash field is blanked out of the digest: the envelope is
// byte-different but canonicalises to the same digest as its blank-hash twin, and a correctly
// chained prev keeps the chain moving.
// chained prev (from the still-current tip n2, since the empty E+3 delivery was dropped) keeps
// the chain moving.
const uint32_t blank_epoch = advance_one_epoch();
auto n5 = encode_delivery(blank_epoch, "echo", oracle::digest_bytes(n4_digest),
auto n5 = encode_delivery(blank_epoch, "echo", oracle::digest_bytes(n2_digest),
/*env_hash=*/std::string(32, '\x22'));
auto n5_env = decode_envelope(n5);
BOOST_REQUIRE_EQUAL(n5_env.envelope_hash().size(), 32u);
Expand All @@ -706,21 +709,24 @@ BOOST_FIXTURE_TEST_CASE(inbound_chain_tip_recorded_and_verified, sysio_msgch_cha
BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), n5_digest.str());
} FC_LOG_AND_RETHROW() }

/// INTERIM Solana cross-stream semantics: the Solana outpost stamps its outbound
/// `previous_envelope_hash` from its single inbound tip slot, so its envelopes link to the digest
/// of the last DEPOT envelope it accepted (the depot's own outbound emit) instead of its own
/// previous emit. The depot accepts that link as an alternate continuation for SVM outposts only;
/// remove this case when the outpost adopts per-stream chaining.
BOOST_FIXTURE_TEST_CASE(inbound_accepts_cross_stream_link_for_svm_outposts,
/// SEC-107 completion: the SVM cross-stream alternate is REMOVED. The Solana outpost now
/// self-chains per stream (wire-solana SEC-114 `previous_outbound_epoch_hash`), so an envelope
/// linking to the depot's own outbound emit digest instead of the outpost's inbound tip is a
/// chain break for SVM exactly as it is for EVM — the interim fallback no longer accepts it.
BOOST_FIXTURE_TEST_CASE(inbound_rejects_cross_stream_link_for_svm_outposts,
sysio_msgch_chain_tester) { try {
bootstrap();

// Establish an inbound tip for SOL (bootstrap accept), so the alternate link is what gets
// Establish an inbound tip for SOL (bootstrap accept), so the cross-stream link is what gets
// exercised on the next delivery rather than the no-tip bootstrap rule.
uint32_t epoch = current_epoch();
BOOST_REQUIRE_EQUAL(success(), deliver(SOL_OUTPOST_ID, encode_delivery(epoch, "xstream-a")));
auto n1 = encode_delivery(epoch, "xstream-a");
const auto n1_digest = oracle::epoch_digest(decode_envelope(n1));
BOOST_REQUIRE_EQUAL(success(), deliver(SOL_OUTPOST_ID, n1));
produce_blocks();

const uint32_t tip_epoch = epoch;

// Next epoch: emit a depot outbound envelope, then deliver an inbound envelope whose prev is
// that emit's digest (NOT the inbound tip). Building the outbound envelope in the delivery
// epoch pins the one-deep outenvelopes row so no epoch-advance emission replaces it.
Expand All @@ -735,20 +741,20 @@ BOOST_FIXTURE_TEST_CASE(inbound_accepts_cross_stream_link_for_svm_outposts,
outbound_digest_bytes.data(),
outbound_digest_bytes.size()));

auto n2 = encode_delivery(epoch, "xstream-b", outbound_digest_bytes);
const auto n2_digest = oracle::epoch_digest(decode_envelope(n2));
BOOST_REQUIRE_EQUAL(success(), deliver(SOL_OUTPOST_ID, n2));
// Cross-stream link (prev = depot's outbound emit digest) is now a chain break: dropped
// without throwing, tip does not move, nothing dispatched.
BOOST_REQUIRE_EQUAL(success(),
deliver(SOL_OUTPOST_ID, encode_delivery(epoch, "xstream-b", outbound_digest_bytes)));
produce_blocks();

auto opc = get_outpcons(SOL_OUTPOST_ID);
BOOST_REQUIRE_EQUAL(opc["epoch_index"].as<uint32_t>(), epoch);
BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), n2_digest.str());
BOOST_REQUIRE_EQUAL(attestation_count(SOL_OUTPOST_ID, epoch), 1u);
BOOST_REQUIRE_EQUAL(opc["epoch_index"].as<uint32_t>(), tip_epoch); // not advanced
BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), n1_digest.str()); // tip unchanged
BOOST_REQUIRE_EQUAL(attestation_count(SOL_OUTPOST_ID, epoch), 0u); // nothing dispatched
} FC_LOG_AND_RETHROW() }

/// The cross-stream alternate is SVM-scoped: an EVM outpost chains per-stream, so an envelope
/// linking to the depot's outbound emit digest instead of the inbound tip is a chain break and
/// drops.
/// EVM outposts chain per-stream, so an envelope linking to the depot's outbound emit digest
/// instead of the inbound tip is a chain break and drops (same rule as SVM now).
BOOST_FIXTURE_TEST_CASE(inbound_rejects_cross_stream_link_for_evm_outposts,
sysio_msgch_chain_tester) { try {
bootstrap();
Expand Down
Loading