diff --git a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp index e5355417bf..7c8661afe9 100644 --- a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp +++ b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp @@ -211,6 +211,11 @@ namespace sysio { checksum256 envelope_hash; opp::types::EnvelopeStatus status; std::vector raw_envelope; + /// The `message_id` of the last (today: only) message in this envelope — the message + /// stream tip for this outpost. The next `buildenv` for the outpost carries it in its + /// header's `previous_message_id` and continues the big-endian sequence number embedded + /// in its first 8 bytes (see opp_canonical_codec.hpp `derive_message_id`). + checksum256 last_message_id; uint64_t by_outpost() const { return chain_code; } uint128_t by_outpost_epoch() const { @@ -218,7 +223,7 @@ namespace sysio { } SYSLIB_SERIALIZE(outbound_envelope, - (id)(chain_code)(epoch_index)(envelope_hash)(status)(raw_envelope)) + (id)(chain_code)(epoch_index)(envelope_hash)(status)(raw_envelope)(last_message_id)) }; using outenvelopes_t = sysio::kv::table<"outenvelopes"_n, id_key, outbound_envelope, @@ -243,21 +248,32 @@ namespace sysio { /// classify each operator's delivery: a matching checksum is a hit, a non-matching delivered /// checksum is slashed. Zero until a winner exists for the current epoch. /// - /// `envelope_digest` is the inbound chain tip: the canonical epoch digest (keccak256 over - /// the canonical field-complete encoding with `envelope_hash` blanked; see + /// `envelope_digest` is the inbound ENVELOPE chain tip: the canonical epoch digest (keccak256 + /// over the canonical field-complete encoding with `envelope_hash` blanked; see /// opp_canonical_codec.hpp) of the last ACCEPTED envelope from this outpost. The next /// accepted envelope's `previous_envelope_hash` must continue from it. Zero until the first /// envelope from this outpost is accepted. Unlike `epoch_index`/`winning_checksum` it is a /// running tip, not per-epoch state. + /// + /// `message_tip` is the inbound MESSAGE chain tip: the `message_id` of the last ACCEPTED + /// message from this outpost. The next accepted message's `previous_message_id` must equal + /// it, which — with the per-message sequence splice validated in `semantic_headers_ok` — + /// makes the message stream strictly monotonic and non-replayable. The envelope chain orders + /// envelopes but does not by itself bind the messages inside them, so without this a + /// correctly envelope-chained successor could replay an earlier valid `Message` verbatim and + /// re-dispatch its attestations. Zero until the first message from this outpost is accepted + /// (which may lag `envelope_digest` if the first accepted envelopes are empty-message acks). struct [[sysio::table("outpcons")]] outpost_consensus_entry { uint64_t chain_code; uint32_t epoch_index; bool consensus_reached; checksum256 winning_checksum; checksum256 envelope_digest; + checksum256 message_tip; SYSLIB_SERIALIZE(outpost_consensus_entry, - (chain_code)(epoch_index)(consensus_reached)(winning_checksum)(envelope_digest)) + (chain_code)(epoch_index)(consensus_reached)(winning_checksum)(envelope_digest) + (message_tip)) }; using outpost_consensus_t = diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index 1ad421661b..b12b7d62f2 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -328,6 +328,118 @@ name resolve_account_from_op_address(const opp::types::ChainAddress& op_address) return false; } +/// Reinterpret an exactly-32-byte protobuf `bytes` field as a checksum256. Returns std::nullopt +/// for any other length; chain and header verification treat a malformed hash as a mismatch, +/// never as a match or a wildcard. +std::optional to_checksum256_exact(const std::vector& bytes) { + if (bytes.size() != 32) + return std::nullopt; + std::array raw{}; + for (size_t i = 0; i < 32; ++i) + raw[i] = static_cast(bytes[i]); + return checksum256{raw}; +} + +/// Validate every message's semantic header (opp.proto MessageHeader) against the spec +/// derivation shared by all three emitters AND against the per-outpost inbound message chain. +/// +/// Per-message self-consistency: each attestation's `data_size` must equal its `data` length, +/// `payload_size` / `payload_checksum` must recompute over the payload's canonical bytes, +/// `header_checksum` must recompute over the field-complete header with `message_id` and +/// `header_checksum` blanked, and `message_id` must be that checksum with its first 8 bytes +/// replaced by `previous_message_id`'s sequence number + 1. +/// +/// Chain continuity: `inbound_message_tip` is the `message_id` of the last accepted message from +/// this outpost (empty at stream genesis). The first message must continue it -- its +/// `previous_message_id` must equal the tip -- and each subsequent message in the envelope must +/// chain to the one before it (`previous_message_id` == the prior message's `message_id`). This +/// is what stops replay: the envelope-level chain orders envelopes but does not bind the messages +/// inside them, so without this a correctly envelope-chained successor could carry an earlier +/// valid `Message` verbatim (self-consistent header and all) and re-dispatch its attestations -- +/// e.g. crediting one escrow deposit twice. As with the envelope chain, the FIRST message ever +/// accepted for an outpost (empty tip) bootstraps regardless of its `previous_message_id`; from +/// then on the stream is strictly chained. +/// +/// On success `new_message_tip` is set to the last message's `message_id` (or left equal to +/// `inbound_message_tip` for a zero-message envelope -- the EVM outpost emits empty-message epoch +/// acks, which are valid and leave the tip unmoved). Returns false on the first violation; the +/// caller drops the envelope without dispatching and without throwing, per the never-throw +/// handler convention. +[[nodiscard]] bool semantic_headers_ok(const opp::Envelope& envelope, + const checksum256& inbound_message_tip, + checksum256& new_message_tip, uint64_t chain_code, + uint32_t epoch_index) { + const auto drop = [&](size_t msg_index, const char* what) { + sysio::print_f("DROP envelope semantic header: chain_code=%llu epoch=%u message=%llu %s\n", + static_cast(chain_code), epoch_index, + static_cast(msg_index), what); + return false; + }; + + checksum256 running_tip = inbound_message_tip; + bool have_predecessor = inbound_message_tip != checksum256{}; + + for (size_t i = 0; i < envelope.messages.size(); ++i) { + const auto& msg = envelope.messages[i]; + const auto& header = msg.header; + + for (const auto& entry : msg.payload.attestations) { + if (static_cast(entry.data_size) != entry.data.size()) { + return drop(i, "attestation data_size does not match data length"); + } + } + + const std::vector payload_bytes = opp::canonical::encode(msg.payload); + if (static_cast(header.payload_size) != payload_bytes.size()) { + return drop(i, "payload_size does not match the canonical payload bytes"); + } + const checksum256 payload_checksum = keccak(payload_bytes.data(), payload_bytes.size()); + const auto claimed_payload_checksum = to_checksum256_exact(header.payload_checksum); + if (!claimed_payload_checksum.has_value() || *claimed_payload_checksum != payload_checksum) { + return drop(i, "payload_checksum does not recompute"); + } + + const checksum256 header_checksum = opp::canonical::header_digest(header); + const auto claimed_header_checksum = to_checksum256_exact(header.header_checksum); + if (!claimed_header_checksum.has_value() || *claimed_header_checksum != header_checksum) { + return drop(i, "header_checksum does not recompute"); + } + + // `previous_message_id` has exactly two canonical forms -- empty at stream genesis or a + // full 32-byte id; `message_sequence` yields nullopt for anything else, so a truncated or + // padded value can never alias genesis or a valid sequence source, even when the checksum + // and id were re-derived over it by a colluding emitter. + const auto prev_sequence = opp::canonical::message_sequence(header.previous_message_id); + if (!prev_sequence.has_value()) { + return drop(i, "previous_message_id is neither empty nor 32 bytes"); + } + const checksum256 expected_id = + opp::canonical::derive_message_id(header_checksum, *prev_sequence + 1); + const auto claimed_id = to_checksum256_exact(header.message_id); + if (!claimed_id.has_value() || *claimed_id != expected_id) { + return drop(i, "message_id does not derive from header_checksum and the sequence number"); + } + + // Chain continuity: bind this message to the real predecessor (the stored inbound tip for + // the first message, the prior message's id for the rest). The self-consistency check above + // only proves `message_id` embeds `seq(previous_message_id) + 1`; without this a replayed + // message would carry a self-consistent header whose `previous_message_id` points at some + // OLD tip rather than the current one. Skipped only for the very first message ever accepted + // for the outpost (empty tip), matching the envelope-chain bootstrap. + if (have_predecessor) { + const auto claimed_prev = to_checksum256_exact(header.previous_message_id); + if (!claimed_prev.has_value() || *claimed_prev != running_tip) { + return drop(i, "previous_message_id does not continue the accepted message stream"); + } + } + running_tip = *claimed_id; + have_predecessor = true; + } + + new_message_tip = running_tip; + return true; +} + /// Decode an OperatorAction sub-message and dispatch to the appropriate /// sysio.opreg action. Called from the inbound dispatch loop in `evalcons`. /// @@ -844,16 +956,50 @@ void dispatch_attestation(name self, uint64_t attestation_id, } } -/// Reinterpret an exactly-32-byte protobuf `bytes` field as a checksum256. Returns std::nullopt -/// for any other length; chain verification treats a malformed hash as a mismatch, never as a -/// match or a wildcard. -std::optional to_checksum256_exact(const std::vector& bytes) { - if (bytes.size() != 32) - return std::nullopt; - std::array raw{}; - for (size_t i = 0; i < 32; ++i) - raw[i] = static_cast(bytes[i]); - return checksum256{raw}; +/// Validate an inbound envelope against the per-outpost chains for `chain_code` at `epoch_index`: +/// the envelope-level chain (`previous_envelope_hash` continues `outpcons.envelope_digest`) and the +/// per-message semantic headers + inbound message chain (`semantic_headers_ok`). Pure read + compute +/// -- writes no state. On success `out_envelope_digest` is the envelope's canonical epoch digest and +/// `out_new_message_tip` the message tip the outpost advances to on acceptance; returns false (with a +/// diagnostic, never throwing) on any chain break or header violation. +/// +/// Run at BOTH ingress and acceptance: `deliver` `check()`s it, rejecting the operator's own +/// malformed / forged / stale envelope before it is ever recorded, while `apply_consensus` soft-drops +/// on it as defense in depth. Within an epoch the tip is stable -- it only advances when that epoch's +/// winner is accepted -- so a deliver-accepted envelope validates identically at acceptance. Ingress +/// rejection is what stops a majority-relayed invalid envelope from reaching consensus and stranding +/// the epoch: a reverted `deliver` records no row, so no invalid version can win the tally (and the +/// operator can re-deliver a corrected envelope), whereas a recorded-then-soft-dropped winner would +/// leave `consensus_reached` true, no `outpcons` row, and no dispute or re-delivery path -- a +/// permanent stall. +[[nodiscard]] bool inbound_envelope_valid(name self, const opp::Envelope& envelope, + uint64_t chain_code, uint32_t epoch_index, + checksum256& out_envelope_digest, + checksum256& out_new_message_tip) { + out_envelope_digest = opp::canonical::epoch_digest(envelope); + checksum256 inbound_message_tip{}; + { + msgch::outpost_consensus_t opcons(self); + auto opc_pk = msgch::outpost_consensus_key{chain_code}; + checksum256 chain_tip{}; + if (opcons.contains(opc_pk)) { + const auto row = opcons.get(opc_pk); + chain_tip = row.envelope_digest; + inbound_message_tip = row.message_tip; + } + if (chain_tip != checksum256{}) { + 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(chain_code), epoch_index); + return false; + } + } + } + out_new_message_tip = checksum256{}; + return semantic_headers_ok(envelope, inbound_message_tip, out_new_message_tip, chain_code, + epoch_index); } /// Apply a consensus-reached inbound envelope for one (outpost, epoch): decode it, verify it @@ -866,8 +1012,17 @@ std::optional to_checksum256_exact(const std::vector& bytes) /// so `sysio.epoch::advance` can classify each operator's delivery as canonical or slashable. /// Acceptance also records the envelope's canonical epoch digest on `outpcons.envelope_digest`, /// the inbound chain tip the next envelope from this outpost must continue from. -void apply_consensus(name self, uint64_t chain_code, uint32_t epoch_index, - const std::vector& raw, const checksum256& winning_checksum) { +/// +/// Returns true iff the winner was ACCEPTED (`outpcons` written, or an idempotent re-fire of an +/// already-recorded winner); false iff it was DROPPED (envelope-chain break or semantic-header +/// failure). The two callers treat a drop differently and MUST NOT ignore the result: +/// `evalcons` soft-drops (the epoch simply stays un-consensused until a valid delivery or a +/// dispute — never throw on the deliver/evalcons path), whereas `resolvedisp` `check()`s it so a +/// rejected dispute WINNER reverts the enclosing `chkdispute` crank, leaving the dispute +/// OPEN/paused rather than stranding the epoch unpaused with no consensus row. +[[nodiscard]] bool apply_consensus(name self, uint64_t chain_code, uint32_t epoch_index, + const std::vector& raw, + const checksum256& winning_checksum) { const uint128_t composite = opp::outpost_epoch_key(chain_code, epoch_index); // Idempotency guard, keyed off the DURABLE per-outpost consensus row. `outpcons.epoch_index` is @@ -889,7 +1044,7 @@ void apply_consensus(name self, uint64_t chain_code, uint32_t epoch_index, sysio::print_f("apply_consensus: chain_code=%llu epoch=%u already dispatched, " "treating as benign no-op\n", static_cast(chain_code), epoch_index); - return; + return true; // winner already recorded for this epoch -- an accepted state } } } @@ -903,45 +1058,25 @@ 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 (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. 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. 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. + // The source chain row feeds the audit-log endpoints projection below; `deliver` validated the + // outpost at delivery time and `resolvedisp` re-enters through the same registry. The inbound + // envelope-chain + semantic-header verification (ENFORCED since SEC-102) lives in + // `inbound_envelope_valid`, which `deliver` already ran at ingress and which is re-checked below. const auto op_row = [&]() { sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); return chains_tbl.get(sysio::chains::chain_key{sysio::slug_name{chain_code}}); }(); - const checksum256 envelope_digest = opp::canonical::epoch_digest(envelope); - { - msgch::outpost_consensus_t opcons(self); - auto opc_pk = msgch::outpost_consensus_key{chain_code}; - const checksum256 chain_tip = - opcons.contains(opc_pk) ? opcons.get(opc_pk).envelope_digest : checksum256{}; - if (chain_tip != checksum256{}) { - 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(chain_code), epoch_index); - return; - } - } + // Validate the envelope-level chain + per-message semantic headers -- the SAME check `deliver` + // runs at ingress (see `inbound_envelope_valid`), re-run here as defense in depth. A drop is a + // SOFT return, never a throw: on the evalcons path one operator's bad envelope must not revert a + // consensus-eval tx (`resolvedisp` `check()`s the result instead; see the callers). With ingress + // validation in place this should not fail for a recorded delivery -- the tip is stable across an + // epoch's deliveries -- but the guard stays as the authoritative acceptance point. + checksum256 envelope_digest{}; + checksum256 new_message_tip{}; + if (!inbound_envelope_valid(self, envelope, chain_code, epoch_index, envelope_digest, + new_message_tip)) { + return false; } // The `messages` row is intentionally not written here. The raw envelope bytes have already served @@ -1014,8 +1149,9 @@ void apply_consensus(name self, uint64_t chain_code, uint32_t epoch_index, } } - // Record per-outpost consensus: the winning delivery checksum (slash classification) and the - // accepted envelope's canonical epoch digest (the new inbound chain tip). + // Record per-outpost consensus: the winning delivery checksum (slash classification), the + // accepted envelope's canonical epoch digest (the new inbound ENVELOPE tip), and the last + // accepted message's id (the new inbound MESSAGE tip -- unchanged for a zero-message ack). msgch::outpost_consensus_t opcons(self); auto opc_pk = msgch::outpost_consensus_key{chain_code}; if (!opcons.contains(opc_pk)) { @@ -1025,6 +1161,7 @@ void apply_consensus(name self, uint64_t chain_code, uint32_t epoch_index, .consensus_reached = true, .winning_checksum = winning_checksum, .envelope_digest = envelope_digest, + .message_tip = new_message_tip, }); } else { opcons.modify(same_payer, opc_pk, [&](auto& r) { @@ -1032,8 +1169,10 @@ void apply_consensus(name self, uint64_t chain_code, uint32_t epoch_index, r.consensus_reached = true; r.winning_checksum = winning_checksum; r.envelope_digest = envelope_digest; + r.message_tip = new_message_tip; }); } + return true; } /// Evaluate the dispute trigger and, if met, open a Tier-1 dispute vote via sysio.chalg. Trigger: @@ -1147,10 +1286,11 @@ void msgch::deliver(name batch_op_name, uint64_t chain_code, std::vector d check(!op_row.is_depot, "deliver: chain_code refers to the depot self-row"); check(op_row.active, "deliver: outpost is not active"); - // Decode envelope to validate epoch_index matches current WIRE epoch + // Decode envelope to validate epoch_index matches current WIRE epoch. The decoded envelope is + // re-used by the semantic-header/chain validation below (after the duplicate check). uint32_t epoch = current_epoch_index(); + opp::Envelope env_check; { - opp::Envelope env_check; auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; auto result = in(env_check); check(result == zpp::bits::errc{}, "failed to decode inbound envelope"); @@ -1182,6 +1322,22 @@ void msgch::deliver(name batch_op_name, uint64_t chain_code, std::vector d "operator already delivered for this outpost+epoch"); } + // Reject a forged, malformed, or stale (chain-breaking / replayed) envelope at ingress by + // REVERTING -- see `inbound_envelope_valid`. Runs AFTER the duplicate check, so a same-operator + // re-delivery reports "already delivered" rather than a chain break once its first delivery has + // reached consensus and advanced the tip. Validating here, rather than only soft-dropping at + // consensus, keeps an invalid envelope from ever being recorded: a post-boundary majority + // relaying the same invalid envelope can no longer reach consensus only to be soft-dropped, which + // would strand the epoch with no `outpcons` row and no dispute path (consensus_reached stays + // true). A reverted tx records nothing, so the operator can immediately deliver a corrected one. + { + checksum256 ingress_digest{}; + checksum256 ingress_message_tip{}; + check(inbound_envelope_valid(get_self(), env_check, chain_code, epoch, ingress_digest, + ingress_message_tip), + "delivered envelope failed inbound-chain or semantic-header validation"); + } + // Store envelope uint64_t env_id = std::max(1, envs.available_primary_key()); @@ -1296,9 +1452,12 @@ void msgch::evalcons(uint64_t chain_code, uint32_t epoch_index) { // Consensus reached: store + dispatch the winning envelope and record the per-outpost winner // (so sysio.epoch::advance can classify each delivery). advance itself is triggered by chkcons - // once next_epoch_start has passed. - apply_consensus(get_self(), chain_code, epoch_index, - checksum_data[consensus_group], seen_checksums[consensus_group]); + // once next_epoch_start has passed. On the evalcons path a rejected winner is a SOFT drop: the + // result is intentionally discarded and the epoch simply stays un-consensused (no outpcons row) + // until a valid delivery arrives or the split opens a dispute -- never throw here, a throw + // reverts the delivering operator's tx and stalls consensus chain-wide. + (void)apply_consensus(get_self(), chain_code, epoch_index, + checksum_data[consensus_group], seen_checksums[consensus_group]); } // --------------------------------------------------------------------------- @@ -1401,7 +1560,19 @@ void msgch::resolvedisp(uint64_t chain_code, uint32_t epoch_index, checksum256 w // safe stall, not state corruption. check(found, "resolvedisp: winning envelope not found for this outpost+epoch"); - apply_consensus(get_self(), chain_code, epoch_index, raw, winning_checksum); + // Unlike the evalcons path, a rejected winner here MUST throw. `chkdispute` (chalg) marks the + // dispute RESOLVED and then unpauses the epoch in the SAME transaction that inline-calls this + // action; if `apply_consensus` soft-dropped the voted winner (envelope-chain break or semantic- + // header failure), it would report success without writing `outpcons`, leaving the epoch + // unpaused with no consensus row -- `chkcons` waits forever for that row while `evalcons` + // no-ops on the lingering (now RESOLVED) dispute. Asserting rolls the whole `chkdispute` crank + // back, so the dispute stays OPEN/paused and recoverable -- the same safe-stall contract as the + // `check(found, ...)` above. In practice this cannot trigger: `deliver`'s ingress validation + // (`inbound_envelope_valid`) rejects a forged or chain-breaking envelope before it is ever + // recorded, so it can never become a dispute candidate or voted winner -- this guard is defense + // in depth for that invariant. + check(apply_consensus(get_self(), chain_code, epoch_index, raw, winning_checksum), + "resolvedisp: winning envelope failed depot validation; dispute left OPEN"); } // --------------------------------------------------------------------------- @@ -1471,6 +1642,11 @@ void msgch::buildenv(uint64_t chain_code) { uint32_t epoch = current_epoch_index(); attestations_t atts(get_self()); auto now_sec = static_cast(current_time_point().sec_since_epoch()); + // OPP wire timestamps (`MessageHeader.timestamp`, `Envelope.epoch_timestamp`) are milliseconds + // since the Unix epoch (opp.proto). WIRE's half-second blocks make sub-second resolution + // meaningful, so emit real milliseconds; `now_sec` stays for the seconds-unit table columns. + const uint64_t now_ms = + static_cast(current_time_point().time_since_epoch().count()) / 1000; // ── Phase 1: collect candidate READY attestations for this outpost. // Order is the secondary index's natural order, which is stable across @@ -1546,18 +1722,22 @@ void msgch::buildenv(uint64_t chain_code) { candidate_ids.begin(), candidate_ids.begin() + included_count); - // Chain link: the previous envelope emitted for this outpost. `outenvelopes` is one-deep per - // outpost (see the cleanup below), so the single surviving row is the previous emit and its - // `envelope_hash` is that envelope's epoch digest. The first emit for an outpost has no row and - // chains from the empty hash (genesis), matching the outpost contracts' zero genesis tip. + // Chain links: the previous envelope emitted for this outpost. `outenvelopes` is one-deep per + // outpost (see the cleanup below), so the single surviving row is the previous emit; its + // `envelope_hash` is that envelope's epoch digest and its `last_message_id` is this outpost's + // message-stream tip. The first emit for an outpost has no row and chains both links from + // empty (genesis), matching the outpost contracts' zero genesis tip. outenvelopes_t envelopes(get_self()); std::vector prev_envelope_digest; + std::vector prev_message_id; { auto by_outpost = envelopes.get_index<"byoutpost"_n>(); auto prev_it = by_outpost.lower_bound(chain_code); if (prev_it != by_outpost.end() && prev_it->chain_code == chain_code) { const auto digest_bytes = prev_it->envelope_hash.extract_as_byte_array(); prev_envelope_digest.assign(digest_bytes.begin(), digest_bytes.end()); + const auto tip_bytes = prev_it->last_message_id.extract_as_byte_array(); + prev_message_id.assign(tip_bytes.begin(), tip_bytes.end()); } } @@ -1565,16 +1745,49 @@ void msgch::buildenv(uint64_t chain_code) { // format (opp_canonical_codec.hpp; no size prefix). Because `envelope_hash` is empty on the // wire, the packed bytes are exactly the epoch-digest preimage: keccak256(packed) is this // envelope's epoch digest, which the NEXT envelope for this outpost carries in - // `previous_envelope_hash`. Pulled into a lambda so the trim loop below can re-run it after - // popping an entry. `src` is copied (not moved) because the trim loop may need to rebuild from - // a shorter prefix of `entries`. + // `previous_envelope_hash`. + // + // The semantic header (opp.proto MessageHeader) is derived on every (re)build: + // `payload_size` / `payload_checksum` over the payload's canonical bytes, `header_checksum` + // over the field-complete header with `message_id`/`header_checksum` blanked, and + // `message_id` — that checksum with its first 8 bytes replaced by the message's big-endian + // sequence number, continuing this outpost's stream from `prev_message_id`. + // + // Pulled into a lambda so the trim loop below can re-run it after popping an entry. `src` is + // copied (not moved) because the trim loop may need to rebuild from a shorter prefix of + // `entries`. `message_id` holds the last build's derived id; after the trim loop converges it + // is this outpost's new message-stream tip, stored on the outbound row below. + checksum256 message_id{}; auto build_packed = [&](const std::vector& src) { opp::MessagePayload payload; payload.version = zpp::bits::vuint32_t{1}; payload.attestations = src; + const std::vector payload_bytes = opp::canonical::encode(payload); + const checksum256 payload_checksum = keccak(payload_bytes.data(), payload_bytes.size()); + opp::MessageHeader header; - header.timestamp = zpp::bits::vuint64_t{now_sec}; + header.previous_message_id = prev_message_id; + header.payload_size = zpp::bits::vuint32_t{static_cast(payload_bytes.size())}; + { + const auto pc_bytes = payload_checksum.extract_as_byte_array(); + header.payload_checksum.assign(pc_bytes.begin(), pc_bytes.end()); + } + header.timestamp = zpp::bits::vuint64_t{now_ms}; + + // The stored tip is always empty (genesis) or a full 32-byte id; a non-canonical length + // here means corrupted own-state and must fail loudly, unlike the inbound never-throw path. + const auto prev_sequence = opp::canonical::message_sequence(prev_message_id); + check(prev_sequence.has_value(), + "sysio.msgch::buildenv: stored outbound message tip is malformed"); + const checksum256 header_checksum = opp::canonical::header_digest(header); + message_id = opp::canonical::derive_message_id(header_checksum, *prev_sequence + 1); + { + const auto hc_bytes = header_checksum.extract_as_byte_array(); + header.header_checksum.assign(hc_bytes.begin(), hc_bytes.end()); + const auto mid_bytes = message_id.extract_as_byte_array(); + header.message_id.assign(mid_bytes.begin(), mid_bytes.end()); + } opp::Message msg; msg.header = std::move(header); @@ -1582,7 +1795,7 @@ void msgch::buildenv(uint64_t chain_code) { opp::Envelope env; env.epoch_index = zpp::bits::vuint32_t{epoch}; - env.epoch_timestamp = zpp::bits::vuint64_t{now_sec}; + env.epoch_timestamp = zpp::bits::vuint64_t{now_ms}; env.previous_envelope_hash = prev_envelope_digest; env.messages.push_back(std::move(msg)); @@ -1629,12 +1842,13 @@ void msgch::buildenv(uint64_t chain_code) { uint64_t out_id = std::max(1, envelopes.available_primary_key()); envelopes.emplace(ram_payer, id_key{out_id}, outbound_envelope{ - .id = out_id, - .chain_code = chain_code, - .epoch_index = epoch, - .envelope_hash = envelope_digest, - .status = EnvelopeStatus::ENVELOPE_STATUS_PENDING_DELIVERY, - .raw_envelope = packed, + .id = out_id, + .chain_code = chain_code, + .epoch_index = epoch, + .envelope_hash = envelope_digest, + .status = EnvelopeStatus::ENVELOPE_STATUS_PENDING_DELIVERY, + .raw_envelope = packed, + .last_message_id = message_id, }); // === AUDIT LOG + INLINE CLEANUP OF WORKING STATE === diff --git a/contracts/sysio.msgch/sysio.msgch.abi b/contracts/sysio.msgch/sysio.msgch.abi index 72c68d2987..1a423908ef 100644 --- a/contracts/sysio.msgch/sysio.msgch.abi +++ b/contracts/sysio.msgch/sysio.msgch.abi @@ -301,6 +301,10 @@ { "name": "raw_envelope", "type": "bytes" + }, + { + "name": "last_message_id", + "type": "checksum256" } ] }, @@ -327,6 +331,10 @@ { "name": "envelope_digest", "type": "checksum256" + }, + { + "name": "message_tip", + "type": "checksum256" } ] }, diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index f08f12c05d..01344f8e37 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/opp_canonical_codec.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/opp_canonical_codec.hpp index eadfa82625..3c6d46f353 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/opp_canonical_codec.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/opp_canonical_codec.hpp @@ -44,6 +44,7 @@ #include #include +#include #include namespace sysio::opp::canonical { @@ -84,7 +85,7 @@ namespace field { constexpr uint32_t endpoints = 1; constexpr uint32_t message_id = 2; constexpr uint32_t previous_message_id = 3; - constexpr uint32_t encoding_flags = 4; + // Slot 4 was `encoding_flags` — removed from opp.proto; reserved, do not reuse. constexpr uint32_t payload_size = 5; constexpr uint32_t payload_checksum = 6; constexpr uint32_t timestamp = 7; @@ -99,11 +100,6 @@ namespace field { constexpr uint32_t data_size = 2; constexpr uint32_t data = 3; } // namespace attestation_entry - namespace encoding_flags { - constexpr uint32_t endianness = 1; - constexpr uint32_t hash_algorithm = 2; - constexpr uint32_t length_encoding = 3; - } // namespace encoding_flags } // namespace field namespace detail { @@ -199,21 +195,6 @@ inline void encode_into(std::vector& out, const types::ChainId& m) { } /// @} -/// @{ sysio.opp.types.EncodingFlags -inline size_t encoded_size(const types::EncodingFlags& m) { - return detail::varint_field_size(field::encoding_flags::endianness, detail::enum_wire_value(m.endianness)) + - detail::varint_field_size(field::encoding_flags::hash_algorithm, - detail::enum_wire_value(m.hash_algorithm)) + - detail::varint_field_size(field::encoding_flags::length_encoding, - detail::enum_wire_value(m.length_encoding)); -} -inline void encode_into(std::vector& out, const types::EncodingFlags& m) { - detail::put_varint_field(out, field::encoding_flags::endianness, detail::enum_wire_value(m.endianness)); - detail::put_varint_field(out, field::encoding_flags::hash_algorithm, detail::enum_wire_value(m.hash_algorithm)); - detail::put_varint_field(out, field::encoding_flags::length_encoding, detail::enum_wire_value(m.length_encoding)); -} -/// @} - /// @{ sysio.opp.Endpoints inline size_t encoded_size(const Endpoints& m) { return detail::submessage_field_size(field::endpoints::start, encoded_size(m.start)) + @@ -232,7 +213,6 @@ inline size_t encoded_size(const MessageHeader& m) { return detail::submessage_field_size(field::message_header::endpoints, encoded_size(m.endpoints)) + detail::bytes_field_size(field::message_header::message_id, m.message_id) + detail::bytes_field_size(field::message_header::previous_message_id, m.previous_message_id) + - detail::submessage_field_size(field::message_header::encoding_flags, encoded_size(m.encoding_flags)) + detail::varint_field_size(field::message_header::payload_size, static_cast(m.payload_size)) + detail::bytes_field_size(field::message_header::payload_checksum, m.payload_checksum) + detail::varint_field_size(field::message_header::timestamp, static_cast(m.timestamp)) + @@ -243,8 +223,6 @@ inline void encode_into(std::vector& out, const MessageHeader& m) { encode_into(out, m.endpoints); detail::put_bytes_field(out, field::message_header::message_id, m.message_id); detail::put_bytes_field(out, field::message_header::previous_message_id, m.previous_message_id); - detail::put_submessage_prefix(out, field::message_header::encoding_flags, encoded_size(m.encoding_flags)); - encode_into(out, m.encoding_flags); detail::put_varint_field(out, field::message_header::payload_size, static_cast(m.payload_size)); detail::put_bytes_field(out, field::message_header::payload_checksum, m.payload_checksum); detail::put_varint_field(out, field::message_header::timestamp, static_cast(m.timestamp)); @@ -364,6 +342,69 @@ inline sysio::checksum256 epoch_digest(const Envelope& env) { return sysio::keccak(preimage.data(), preimage.size()); } +/// Canonical field-complete bytes of a standalone MessagePayload — the sub-message bytes exactly +/// as embedded inside an Envelope, minus the enclosing field tag + length prefix. Feeds +/// `MessageHeader.payload_size` and `MessageHeader.payload_checksum`. +inline std::vector encode(const MessagePayload& payload) { + std::vector out; + out.reserve(encoded_size(payload)); + encode_into(out, payload); + return out; +} + +/// Canonical field-complete bytes of a standalone MessageHeader (same sub-message form as +/// `encode(MessagePayload)`). +inline std::vector encode(const MessageHeader& header) { + std::vector out; + out.reserve(encoded_size(header)); + encode_into(out, header); + return out; +} + +/// The `MessageHeader.header_checksum` value: keccak256 over the canonical encoding of `header` +/// with `message_id` and `header_checksum` blanked. Takes a copy so callers can pass the header +/// they are about to finalize without pre-blanking either field. Matches the generated Solidity +/// `OPPCommon.headerChecksum`. +inline sysio::checksum256 header_digest(MessageHeader header) { + header.message_id.clear(); + header.header_checksum.clear(); + const std::vector preimage = encode(header); + return sysio::keccak(preimage.data(), preimage.size()); +} + +/// The `MessageHeader.message_id` value: `header_checksum` with its first 8 bytes replaced by the +/// message's big-endian sequence number (the previous message's sequence number + 1; a stream's +/// first message is sequence number 1). Mirrors the Solidity `OPPCommon.getMessageID` / +/// `setMessageNumber` splice. +inline sysio::checksum256 derive_message_id(const sysio::checksum256& header_checksum, + uint64_t sequence) { + auto bytes = header_checksum.extract_as_byte_array(); + for (size_t i = 0; i < 8; ++i) { + bytes[i] = static_cast(sequence >> (8 * (7 - i))); + } + return sysio::checksum256{bytes}; +} + +/// The big-endian sequence number carried in the first 8 bytes of a wire `message_id`, accepting +/// only the two canonical forms: an EMPTY id (stream genesis) reads as 0, so the successor +/// message's sequence number is 1, and a 32-byte id yields its first 8 bytes. Any other length +/// is non-canonical and yields std::nullopt — verifiers treat it as a mismatch, never as genesis +/// or as a sequence source (1-7 bytes would otherwise alias genesis, and any other length would +/// alias a truncated or padded id). +inline std::optional message_sequence(const std::vector& message_id) { + if (message_id.empty()) { + return 0; + } + if (message_id.size() != 32) { + return std::nullopt; + } + uint64_t sequence = 0; + for (size_t i = 0; i < 8; ++i) { + sequence = (sequence << 8) | static_cast(message_id[i]); + } + return sequence; +} + // ------------------------------------------------------------------------------------------------- // Drift guards: fail the build when a regenerated pb header changes a member count, forcing this // encoder (and the pinned field numbers above) to be revisited. Field renumbering without a count @@ -375,7 +416,7 @@ static_assert(zpp::bits::access::number_of_members() == 2, "opp.proto Endpoints changed; update opp_canonical_codec.hpp to match"); static_assert(zpp::bits::access::number_of_members() == 2, "opp.proto Message changed; update opp_canonical_codec.hpp to match"); -static_assert(zpp::bits::access::number_of_members() == 8, +static_assert(zpp::bits::access::number_of_members() == 7, "opp.proto MessageHeader changed; update opp_canonical_codec.hpp to match"); static_assert(zpp::bits::access::number_of_members() == 2, "opp.proto MessagePayload changed; update opp_canonical_codec.hpp to match"); @@ -383,7 +424,5 @@ static_assert(zpp::bits::access::number_of_members() == 3, "opp.proto AttestationEntry changed; update opp_canonical_codec.hpp to match"); static_assert(zpp::bits::access::number_of_members() == 2, "types.proto ChainId changed; update opp_canonical_codec.hpp to match"); -static_assert(zpp::bits::access::number_of_members() == 3, - "types.proto EncodingFlags changed; update opp_canonical_codec.hpp to match"); } // namespace sysio::opp::canonical diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/opp_table_types.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/opp_table_types.hpp index 41fb0f0637..4abc09f303 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/opp_table_types.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/opp_table_types.hpp @@ -132,16 +132,6 @@ DataStream& operator>>(DataStream& ds, WirePermission& t) { return ds >> t.account >> t.permission; } -// EncodingFlags: { Endianness; HashAlgorithm; LengthEncoding; } -template -DataStream& operator<<(DataStream& ds, const EncodingFlags& t) { - return ds << t.endianness << t.hash_algorithm << t.length_encoding; -} -template -DataStream& operator>>(DataStream& ds, EncodingFlags& t) { - return ds >> t.endianness >> t.hash_algorithm >> t.length_encoding; -} - // --------------------------------------------------------------------------- // v6 registry-entity messages (Chain / Token / ChainToken / Reserve / ReserveAmount) // --------------------------------------------------------------------------- @@ -242,17 +232,17 @@ DataStream& operator>>(DataStream& ds, MessagePayload& t) { return ds >> t.version >> t.attestations; } -// MessageHeader: all 8 fields +// MessageHeader: all 7 fields template DataStream& operator<<(DataStream& ds, const MessageHeader& t) { return ds << t.endpoints << t.message_id << t.previous_message_id - << t.encoding_flags << t.payload_size << t.payload_checksum + << t.payload_size << t.payload_checksum << t.timestamp << t.header_checksum; } template DataStream& operator>>(DataStream& ds, MessageHeader& t) { return ds >> t.endpoints >> t.message_id >> t.previous_message_id - >> t.encoding_flags >> t.payload_size >> t.payload_checksum + >> t.payload_size >> t.payload_checksum >> t.timestamp >> t.header_checksum; } diff --git a/contracts/tests/opp_envelope_oracle.hpp b/contracts/tests/opp_envelope_oracle.hpp new file mode 100644 index 0000000000..961d9a14b2 --- /dev/null +++ b/contracts/tests/opp_envelope_oracle.hpp @@ -0,0 +1,194 @@ +/// Shared host-side oracle for the canonical field-complete OPP envelope encoding and the +/// semantic MessageHeader derivation (opp.proto). An independent reimplementation of +/// `contracts/sysio.opp.common/include/sysio.opp.common/opp_canonical_codec.hpp` over the Google +/// protobuf classes, used by every contract test that builds or verifies inbound/outbound OPP +/// envelopes. Inbound envelopes delivered to `sysio.msgch` MUST carry a spec-derived header +/// (`oracle::finalize_header`) or `apply_consensus` drops them before dispatch. +#pragma once + +#include + +#include + +#include + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Canonical field-complete encoding oracle (host side, over the Google +// protobuf classes). Field numbers and presence rules mirror +// opp_canonical_codec.hpp; see that header for the encoding definition. +// --------------------------------------------------------------------------- +namespace oracle { + + inline void put_varint(std::vector& out, uint64_t v) { + while (v >= 0x80) { + out.push_back(static_cast(static_cast(v) | 0x80)); + v >>= 7; + } + out.push_back(static_cast(static_cast(v))); + } + + /// wire type 0 = varint, 2 = length-delimited + inline void put_tag(std::vector& out, uint32_t field, uint32_t wire) { + put_varint(out, (static_cast(field) << 3) | wire); + } + + inline void put_varint_field(std::vector& out, uint32_t field, uint64_t v) { + put_tag(out, field, 0); + put_varint(out, v); + } + + inline void put_bytes_field(std::vector& out, uint32_t field, const std::string& v) { + put_tag(out, field, 2); + put_varint(out, v.size()); + out.insert(out.end(), v.begin(), v.end()); + } + + inline void put_submessage(std::vector& out, uint32_t field, const std::vector& body) { + put_tag(out, field, 2); + put_varint(out, body.size()); + out.insert(out.end(), body.begin(), body.end()); + } + + inline std::vector encode(const sysio::opp::types::ChainId& m) { + std::vector out; + put_varint_field(out, 1, magic_enum::enum_integer(m.kind())); + put_varint_field(out, 2, m.id()); + return out; + } + + inline std::vector encode(const sysio::opp::Endpoints& m) { + std::vector out; + put_submessage(out, 1, encode(m.start())); + put_submessage(out, 2, encode(m.end())); + return out; + } + + inline std::vector encode(const sysio::opp::MessageHeader& m) { + std::vector out; + put_submessage(out, 1, encode(m.endpoints())); + put_bytes_field(out, 2, m.message_id()); + put_bytes_field(out, 3, m.previous_message_id()); + // Slot 4 was `encoding_flags` — removed from opp.proto; reserved, do not reuse. + put_varint_field(out, 5, m.payload_size()); + put_bytes_field(out, 6, m.payload_checksum()); + put_varint_field(out, 7, m.timestamp()); + put_bytes_field(out, 8, m.header_checksum()); + return out; + } + + inline std::vector encode(const sysio::opp::AttestationEntry& m) { + std::vector out; + put_varint_field(out, 1, magic_enum::enum_integer(m.type())); + put_varint_field(out, 2, m.data_size()); + put_bytes_field(out, 3, m.data()); + return out; + } + + inline std::vector encode(const sysio::opp::MessagePayload& m) { + std::vector out; + put_varint_field(out, 1, m.version()); + for (const auto& a : m.attestations()) + put_submessage(out, 2, encode(a)); + return out; + } + + inline std::vector encode(const sysio::opp::Message& m) { + std::vector out; + put_submessage(out, 1, encode(m.header())); + put_submessage(out, 2, encode(m.payload())); + return out; + } + + inline std::vector encode(const sysio::opp::Envelope& m, bool blank_envelope_hash = false) { + std::vector out; + put_bytes_field(out, 1, blank_envelope_hash ? std::string{} : m.envelope_hash()); + put_submessage(out, 2, encode(m.endpoints())); + put_varint_field(out, 5, m.epoch_timestamp()); + put_varint_field(out, 6, m.epoch_index()); + put_varint_field(out, 7, m.epoch_envelope_index()); + put_bytes_field(out, 20, m.previous_envelope_hash()); + for (const auto& msg : m.messages()) + put_submessage(out, 40, encode(msg)); + return out; + } + + /// keccak256 over the canonical encoding with `envelope_hash` blanked: the cross-chain epoch + /// digest (`OPPCommon.epochHash` on the outposts, `opp::canonical::epoch_digest` in the depot). + inline fc::crypto::keccak256 epoch_digest(const sysio::opp::Envelope& env) { + const auto preimage = encode(env, /*blank_envelope_hash=*/true); + return fc::crypto::keccak256::hash(std::span( + reinterpret_cast(preimage.data()), preimage.size())); + } + + /// The digest as the raw 32-byte string a protobuf `bytes` field carries. + inline std::string digest_bytes(const fc::crypto::keccak256& d) { + return std::string(reinterpret_cast(d.data()), d.data_size()); + } + + inline fc::crypto::keccak256 keccak_of(const std::vector& bytes) { + return fc::crypto::keccak256::hash(std::span( + reinterpret_cast(bytes.data()), bytes.size())); + } + + /// The spec derivation of `MessageHeader.header_checksum`: keccak256 over the canonical + /// header with `message_id` and `header_checksum` blanked (mirrors + /// `opp::canonical::header_digest` and Solidity `OPPCommon.headerChecksum`). + inline fc::crypto::keccak256 header_checksum(const sysio::opp::MessageHeader& header) { + sysio::opp::MessageHeader blanked = header; + blanked.set_message_id(""); + blanked.set_header_checksum(""); + return keccak_of(encode(blanked)); + } + + /// The spec derivation of `MessageHeader.message_id`: the header checksum with its first 8 + /// bytes replaced by the message's big-endian sequence number (mirrors + /// `opp::canonical::derive_message_id` and Solidity `OPPCommon.getMessageID`). + inline std::string derive_message_id(const fc::crypto::keccak256& checksum, uint64_t sequence) { + std::string id = digest_bytes(checksum); + for (size_t i = 0; i < 8; ++i) { + id[i] = static_cast(static_cast(sequence >> (8 * (7 - i)))); + } + return id; + } + + /// Big-endian sequence number in the first 8 bytes of a wire message id, in its two canonical + /// forms only: empty (stream genesis) reads 0, a 32-byte id yields its first 8 bytes, and any + /// other length is non-canonical std::nullopt (mirrors `opp::canonical::message_sequence`). + inline std::optional message_sequence(const std::string& message_id) { + if (message_id.empty()) { + return 0; + } + if (message_id.size() != 32) { + return std::nullopt; + } + uint64_t sequence = 0; + for (size_t i = 0; i < 8; ++i) { + sequence = (sequence << 8) | static_cast(message_id[i]); + } + return sequence; + } + + /// Populate `msg`'s semantic header per the spec derivation from its payload and stream + /// predecessor: `payload_size` / `payload_checksum` over the payload's canonical bytes, then + /// `header_checksum` over the blanked header, then `message_id` at the predecessor's sequence + /// number + 1. Mirrors what `sysio.msgch::buildenv` derives on emit. + inline void finalize_header(sysio::opp::Message& msg, const std::string& prev_message_id, + uint64_t timestamp_ms) { + auto* header = msg.mutable_header(); + header->set_previous_message_id(prev_message_id); + const auto payload_bytes = encode(msg.payload()); + header->set_payload_size(static_cast(payload_bytes.size())); + header->set_payload_checksum(digest_bytes(keccak_of(payload_bytes))); + header->set_timestamp(timestamp_ms); + const auto checksum = header_checksum(*header); + header->set_header_checksum(digest_bytes(checksum)); + header->set_message_id( + derive_message_id(checksum, message_sequence(prev_message_id).value() + 1)); + } + +} // namespace oracle diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index ba5cb9f57a..cbadc48e61 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -30,6 +30,9 @@ #include #include "contracts.hpp" +// Canonical-encoding + header-derivation oracle: inbound envelopes must carry +// spec-derived semantic headers or apply_consensus drops them before dispatch. +#include "opp_envelope_oracle.hpp" using namespace sysio::testing; using namespace sysio; @@ -93,6 +96,8 @@ std::vector encode_envelope_with_one_attestation( att->set_data(att_data); att->set_data_size(static_cast(att_data.size())); + oracle::finalize_header(*env.mutable_messages(0), {}, 1'775'612'516'983ULL); + std::vector out(env.ByteSizeLong()); env.SerializeToArray(out.data(), static_cast(out.size())); return out; @@ -121,6 +126,8 @@ std::vector encode_envelope_with_attestations( att->set_data_size(static_cast(d.size())); } + oracle::finalize_header(*env.mutable_messages(0), {}, 1'775'612'516'983ULL); + std::vector out(env.ByteSizeLong()); env.SerializeToArray(out.data(), static_cast(out.size())); return out; @@ -1432,6 +1439,72 @@ BOOST_FIXTURE_TEST_CASE(chkcons_survives_non_advancing_advance, sysio_dispatch_t BOOST_REQUIRE_EQUAL(retry_count(), rc1 + 1); } FC_LOG_AND_RETHROW() } +// A forged/invalid delivery cannot strand the epoch. SEC-102's semantic-header check runs at +// INGRESS (msgch::deliver's inbound_envelope_valid gate), so a forged envelope reverts on delivery +// and records no envelope row -- it can never reach the consensus tally and leave a phantom +// consensus_reached with no outpcons row. A subsequent VALID delivery reaches consensus normally and +// chkcons proceeds to ATTEMPT advance (retry_count bumps) rather than waiting forever for a consensus +// row that an accepted-then-soft-dropped invalid winner would have left missing. Drives the +// production chkcons gate, not a direct epoch::advance. +BOOST_FIXTURE_TEST_CASE(forged_delivery_does_not_strand_chkcons, sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + const auto eth_code = fc::slug_name{"ETH"}.value; + const uint32_t epoch0 = current_epoch(); + const uint32_t target = epoch0 + 1; + + auto operator_payload = encode_operator_action( + sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, + sysio::opp::types::CHAIN_KIND_EVM, + uwrit_op_eth_pubkey, + /*chain_code_v=*/ eth_code, + /*token_code_v=*/ eth_code, + /*amount=*/ 1'000'000); + const auto valid = encode_envelope_with_one_attestation( + epoch0, + sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, + operator_payload); + + // A forged copy: corrupt payload_checksum so the semantic header no longer recomputes. + std::vector forged; + { + sysio::opp::Envelope env; + BOOST_REQUIRE(env.ParseFromArray(valid.data(), static_cast(valid.size()))); + auto* h = env.mutable_messages(0)->mutable_header(); + std::string c = h->payload_checksum(); + c[0] ^= 0x01; + h->set_payload_checksum(c); + forged.resize(env.ByteSizeLong()); + env.SerializeToArray(forged.data(), static_cast(forged.size())); + } + + // Forged delivery is rejected at ingress -- nothing recorded, no phantom consensus. + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: delivered envelope failed inbound-chain or " + "semantic-header validation"), + deliver(eth_code, forged)); + produce_blocks(); + + auto retry_count = [&]() -> uint32_t { + auto bl = get_blocklog(target); + return bl.is_null() ? 0u : bl["retry_count"].as(); + }; + + // The valid delivery (operators_per_epoch == 1 => Option-A unanimous) reaches consensus normally. + BOOST_REQUIRE_EQUAL(success(), deliver(eth_code, valid)); + produce_blocks(); + const uint32_t rc0 = retry_count(); + + // Pass the wall clock so chkcons fires advance; confirm it ATTEMPTED advance (retry_count bumps), + // i.e. it was NOT stranded waiting on a missing consensus row. Advance itself gate-blocks on + // emissions (this fixture deploys no sysio.system), exactly as in chkcons_survives_non_advancing_advance. + produce_block(fc::seconds(120)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), chkcons()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(current_epoch(), epoch0); + BOOST_REQUIRE_EQUAL(retry_count(), rc0 + 1); +} FC_LOG_AND_RETHROW() } + // #5-residual: a race winner lacking a destination-chain authex link must be // DISQUALIFIED (skipped, uwreq left PENDING), reached via try_build_swap_remit // BEFORE any CONFIRMED / reserve write so nothing throws in evalcons. diff --git a/contracts/tests/sysio.dispute_tests.cpp b/contracts/tests/sysio.dispute_tests.cpp index f01d2de63e..1066cf79b9 100644 --- a/contracts/tests/sysio.dispute_tests.cpp +++ b/contracts/tests/sysio.dispute_tests.cpp @@ -32,6 +32,9 @@ #include #include "contracts.hpp" +// Canonical-encoding + header-derivation oracle: inbound envelopes must carry +// spec-derived semantic headers or apply_consensus drops them before dispatch. +#include "opp_envelope_oracle.hpp" using namespace sysio::testing; using namespace sysio; @@ -78,6 +81,8 @@ std::vector encode_envelope(uint32_t epoch_index, const std::string& tag) att->set_data(tag); att->set_data_size(static_cast(tag.size())); + oracle::finalize_header(*env.mutable_messages(0), {}, 1'775'612'516'983ULL); + std::vector out(env.ByteSizeLong()); env.SerializeToArray(out.data(), static_cast(out.size())); return out; @@ -580,6 +585,42 @@ BOOST_FIXTURE_TEST_CASE(chkdispute_fast_path_resolves, sysio_dispute_tester) { t BOOST_REQUIRE(!epoch_paused()); } FC_LOG_AND_RETHROW() } +// SEC-102 (huang): a dispute whose voted winner FAILS depot semantic validation must not +// resolve-and-strand. chkdispute marks the dispute RESOLVED and unpauses in the same tx that +// inline-calls resolvedisp -> apply_consensus; if apply_consensus soft-dropped the winner it would +// report success with no outpcons row, freezing the epoch (chkcons waits for the row forever while +// evalcons no-ops on the lingering RESOLVED dispute). apply_consensus now returns false on a drop +// and resolvedisp asserts on it, so the whole chkdispute crank rolls back: the dispute stays +// OPEN/paused and recoverable. +// A dispute winner whose header is forged can never reach resolvedisp: since SEC-102's semantic- +// header check runs at INGRESS (msgch::deliver's inbound_envelope_valid gate), a forged envelope is +// rejected on delivery and is never recorded, so it cannot become a dispute candidate or a voted +// winner. This is the root-cause prevention for the resolvedisp-stranding scenario; resolvedisp's +// own `check(apply_consensus(...))` guard remains as defense in depth (unreachable via this path, +// so it is not exercised here -- chkdispute_fast_path_resolves covers valid resolution). +BOOST_FIXTURE_TEST_CASE(forged_dispute_winner_rejected_at_delivery, sysio_dispute_tester) { try { + const uint32_t epoch = current_epoch(); + + // Forge `payload_checksum` so the header no longer recomputes over the payload. + auto winner_valid = encode_envelope(epoch, "winner"); + sysio::opp::Envelope env; + BOOST_REQUIRE(env.ParseFromArray(winner_valid.data(), static_cast(winner_valid.size()))); + { + auto* h = env.mutable_messages(0)->mutable_header(); + std::string c = h->payload_checksum(); + c[0] ^= 0x01; + h->set_payload_checksum(c); + } + std::vector winner_bytes(env.ByteSizeLong()); + env.SerializeToArray(winner_bytes.data(), static_cast(winner_bytes.size())); + + // Rejected at ingress -- never recorded, so it can never be voted into a dispute resolution. + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: delivered envelope failed inbound-chain or " + "semantic-header validation"), + deliver(eth_code(), winner_bytes)); +} FC_LOG_AND_RETHROW() } + // Sub-quorum: with N=3 (Q=2), a single vote never resolves before the deadline. BOOST_FIXTURE_TEST_CASE(chkdispute_sub_quorum_waits, sysio_dispute_tester) { try { register_node_owner("voter1"_n, 1); diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 85a509ed38..3518297af8 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -12,17 +12,18 @@ /// (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. +/// is rejected at ingress: `deliver` reverts, so nothing is recorded or dispatched. 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 /// `contracts/sysio.opp.common/include/sysio.opp.common/opp_canonical_codec.hpp`). The golden -/// vectors pin BOTH implementations to the deployed Solidity codec: the encoding hex and keccak -/// digests below were produced by `OPPCommon.epochHash` (via the `OPPEpochHashHelper` trampoline) -/// in the wire-ethereum test suite for the same logical envelopes, so -/// oracle == Solidity (golden vectors) and contract == oracle (round-trip assertions) together -/// give contract == Solidity. +/// vectors pin the canonical encoding — including the semantic-header derivation — across +/// languages: the identical hex/digest values are pinned in the wire-solana and wire-ethereum +/// vector suites, where the Rust and Solidity implementations (`OPPCommon.epochHash` via the +/// `OPPEpochHashHelper` trampoline on the Solidity side) must reproduce them independently, so +/// oracle == Solidity/Rust (golden vectors) and contract == oracle (round-trip assertions) +/// together give contract == outposts. #include #include #include @@ -43,135 +44,10 @@ using namespace sysio::chain; using mvo = fc::mutable_variant_object; -namespace { - -// --------------------------------------------------------------------------- -// Canonical field-complete encoding oracle (host side, over the Google -// protobuf classes). Field numbers and presence rules mirror -// opp_canonical_codec.hpp; see that header for the encoding definition. -// --------------------------------------------------------------------------- -namespace oracle { - - void put_varint(std::vector& out, uint64_t v) { - while (v >= 0x80) { - out.push_back(static_cast(static_cast(v) | 0x80)); - v >>= 7; - } - out.push_back(static_cast(static_cast(v))); - } - - /// wire type 0 = varint, 2 = length-delimited - void put_tag(std::vector& out, uint32_t field, uint32_t wire) { - put_varint(out, (static_cast(field) << 3) | wire); - } - - void put_varint_field(std::vector& out, uint32_t field, uint64_t v) { - put_tag(out, field, 0); - put_varint(out, v); - } - - void put_bytes_field(std::vector& out, uint32_t field, const std::string& v) { - put_tag(out, field, 2); - put_varint(out, v.size()); - out.insert(out.end(), v.begin(), v.end()); - } - - void put_submessage(std::vector& out, uint32_t field, const std::vector& body) { - put_tag(out, field, 2); - put_varint(out, body.size()); - out.insert(out.end(), body.begin(), body.end()); - } - - std::vector encode(const sysio::opp::types::ChainId& m) { - std::vector out; - put_varint_field(out, 1, magic_enum::enum_integer(m.kind())); - put_varint_field(out, 2, m.id()); - return out; - } - - std::vector encode(const sysio::opp::Endpoints& m) { - std::vector out; - put_submessage(out, 1, encode(m.start())); - put_submessage(out, 2, encode(m.end())); - return out; - } - - std::vector encode(const sysio::opp::types::EncodingFlags& m) { - std::vector out; - put_varint_field(out, 1, magic_enum::enum_integer(m.endianness())); - put_varint_field(out, 2, magic_enum::enum_integer(m.hash_algorithm())); - put_varint_field(out, 3, magic_enum::enum_integer(m.length_encoding())); - return out; - } - - std::vector encode(const sysio::opp::MessageHeader& m) { - std::vector out; - put_submessage(out, 1, encode(m.endpoints())); - put_bytes_field(out, 2, m.message_id()); - put_bytes_field(out, 3, m.previous_message_id()); - put_submessage(out, 4, encode(m.encoding_flags())); - put_varint_field(out, 5, m.payload_size()); - put_bytes_field(out, 6, m.payload_checksum()); - put_varint_field(out, 7, m.timestamp()); - put_bytes_field(out, 8, m.header_checksum()); - return out; - } - - std::vector encode(const sysio::opp::AttestationEntry& m) { - std::vector out; - put_varint_field(out, 1, magic_enum::enum_integer(m.type())); - put_varint_field(out, 2, m.data_size()); - put_bytes_field(out, 3, m.data()); - return out; - } - - std::vector encode(const sysio::opp::MessagePayload& m) { - std::vector out; - put_varint_field(out, 1, m.version()); - for (const auto& a : m.attestations()) - put_submessage(out, 2, encode(a)); - return out; - } - - std::vector encode(const sysio::opp::Message& m) { - std::vector out; - put_submessage(out, 1, encode(m.header())); - put_submessage(out, 2, encode(m.payload())); - return out; - } - - std::vector encode(const sysio::opp::Envelope& m, bool blank_envelope_hash = false) { - std::vector out; - put_bytes_field(out, 1, blank_envelope_hash ? std::string{} : m.envelope_hash()); - put_submessage(out, 2, encode(m.endpoints())); - put_varint_field(out, 5, m.epoch_timestamp()); - put_varint_field(out, 6, m.epoch_index()); - put_varint_field(out, 7, m.epoch_envelope_index()); - put_bytes_field(out, 20, m.previous_envelope_hash()); - for (const auto& msg : m.messages()) - put_submessage(out, 40, encode(msg)); - return out; - } - - /// keccak256 over the canonical encoding with `envelope_hash` blanked: the cross-chain epoch - /// digest (`OPPCommon.epochHash` on the outposts, `opp::canonical::epoch_digest` in the depot). - fc::crypto::keccak256 epoch_digest(const sysio::opp::Envelope& env) { - const auto preimage = encode(env, /*blank_envelope_hash=*/true); - return fc::crypto::keccak256::hash(std::span( - reinterpret_cast(preimage.data()), preimage.size())); - } - - /// The digest as the raw 32-byte string a protobuf `bytes` field carries. - std::string digest_bytes(const fc::crypto::keccak256& d) { - return std::string(reinterpret_cast(d.data()), d.data_size()); - } - - fc::crypto::keccak256 keccak_of(const std::vector& bytes) { - return fc::crypto::keccak256::hash(std::span( - reinterpret_cast(bytes.data()), bytes.size())); - } +// Canonical-encoding + header-derivation oracle shared across the contract tests. +#include "opp_envelope_oracle.hpp" -} // namespace oracle +namespace { inline fc::mutable_variant_object codename_mvo(std::string_view s) { return mvo()("value", fc::slug_name{s}.value); @@ -458,9 +334,13 @@ class sysio_msgch_chain_tester : public tester { /// Encode a deliverable envelope carrying one out-of-scope STAKE attestation (dispatch drops /// the attestation silently; acceptance is still fully observable via `outpcons` and the - /// stored attestation row). `prev` / `env_hash` are raw 32-byte strings (or empty). + /// stored attestation row). The semantic header is derived per the spec — `apply_consensus` + /// drops envelopes whose header fields do not recompute or whose message does not continue the + /// per-outpost message chain. `prev` (previous_envelope_hash), `prev_message_id`, and + /// `env_hash` are raw 32-byte strings (or empty for stream genesis). std::vector encode_delivery(uint32_t epoch_index, const std::string& att_data, const std::string& prev = {}, + const std::string& prev_message_id = {}, const std::string& env_hash = {}) { sysio::opp::Envelope env; env.set_epoch_index(epoch_index); @@ -472,6 +352,7 @@ class sysio_msgch_chain_tester : public tester { att->set_type(sysio::opp::types::ATTESTATION_TYPE_STAKE); att->set_data(att_data); att->set_data_size(static_cast(att_data.size())); + oracle::finalize_header(*env.mutable_messages(0), prev_message_id, 1'775'612'516'983ULL); std::vector out(env.ByteSizeLong()); env.SerializeToArray(out.data(), static_cast(out.size())); return out; @@ -484,6 +365,12 @@ class sysio_msgch_chain_tester : public tester { return env; } + /// The raw 32-byte `message_id` of a delivery's single message — the value the NEXT delivery + /// on the same outpost stream must carry in `previous_message_id`. + std::string delivery_message_id(const std::vector& raw) { + return decode_envelope(raw).messages(0).header().message_id(); + } + abi_serializer sysio_abi, token_abi, epoch_abi, opreg_abi, msgch_abi, chains_abi, uwrit_abi; }; @@ -493,21 +380,25 @@ class sysio_msgch_chain_tester : public tester { BOOST_AUTO_TEST_SUITE(sysio_msgch_chain_tests) -/// The encoding hex and keccak256 digests below are authoritative outputs of the deployed -/// Solidity codec: computed by `OPPCommon.epochHash` through the `OPPEpochHashHelper` test -/// trampoline in wire-ethereum (hardhat) for these exact logical envelopes. Two consecutive -/// depot-shape epochs (B chains from A's digest) plus the wire-ethereum test-fixture shape. +/// The encoding hex and keccak256 digests below pin the canonical encoding of these exact +/// logical envelopes for cross-language agreement: the identical values are pinned in the +/// wire-solana and wire-ethereum vector suites, where the Rust and Solidity implementations +/// must reproduce them independently (Solidity via `OPPCommon.epochHash` through the +/// `OPPEpochHashHelper` hardhat trampoline). Two consecutive depot-shape epochs — B chains from +/// A at BOTH levels (envelope digest and message id) — plus the wire-ethereum test-fixture +/// shape. The depot-shape headers are DERIVED per the spec (`oracle::finalize_header`), so the +/// pins cover the full semantic-header derivation, not just the byte layout. BOOST_AUTO_TEST_CASE(canonical_oracle_matches_solidity_golden_vectors) { try { - constexpr uint64_t GOLDEN_TS = 1'775'612'516ULL; + constexpr uint64_t GOLDEN_TS_MS = 1'775'612'516'983ULL; auto depot_shape = [&](uint32_t epoch_index, const std::string& prev, + const std::string& prev_message_id, const std::vector& att_datas) { sysio::opp::Envelope env; - env.set_epoch_timestamp(GOLDEN_TS); + env.set_epoch_timestamp(GOLDEN_TS_MS); env.set_epoch_index(epoch_index); if (!prev.empty()) env.set_previous_envelope_hash(prev); auto* msg = env.add_messages(); - msg->mutable_header()->set_timestamp(GOLDEN_TS); auto* payload = msg->mutable_payload(); payload->set_version(1); for (const auto& d : att_datas) { @@ -516,31 +407,40 @@ BOOST_AUTO_TEST_CASE(canonical_oracle_matches_solidity_golden_vectors) { try { att->set_data(d); att->set_data_size(static_cast(d.size())); } + oracle::finalize_header(*msg, prev_message_id, GOLDEN_TS_MS); return env; }; - // Vector A: depot shape, epoch 7, genesis (empty prev), one attestation 0xdeadbeef. - const auto env_a = depot_shape(7, {}, { std::string("\xde\xad\xbe\xef", 4) }); + // Vector A: depot shape, epoch 7, stream genesis (empty envelope prev + empty message prev, + // sequence number 1), one attestation 0xdeadbeef. + const auto env_a = depot_shape(7, {}, {}, { std::string("\xde\xad\xbe\xef", 4) }); const auto enc_a = oracle::encode(env_a); BOOST_REQUIRE_EQUAL(fc::to_hex(enc_a.data(), enc_a.size()), - "0a00120c0a040800100012040800100028e4e4d6ce0630073800a20100c202390a260a0c0a0408001000" - "12040800100012001a0022060800100018002800320038e4e4d6ce064200120f0801120b08d10f10041a" - "04deadbeef"); + "0a00120c0a040800100012040800100028f7b483d6d63330073800a20100c20292010a7f0a0c0a040800" + "100012040800100012200000000000000001210103982d1ae1f083b047bde00e77e4a337f3b31c8d223c" + "1a00280f32206429fe11b290953c3e28e6ed7887059307329591c6296d6e41d27e4e6ddcae9938f7b483" + "d6d6334220fb2b80f90bf26934210103982d1ae1f083b047bde00e77e4a337f3b31c8d223c120f080112" + "0b08d10f10041a04deadbeef"); const auto digest_a = oracle::epoch_digest(env_a); BOOST_REQUIRE_EQUAL(digest_a.str(), - "28ccf00f6852162f6cfcc262f7b6dad43d7aabb40846b12c91515fcd4ce8cc41"); + "c7c6502a5b047c0742c887122350c0b6731c60f3f5f9d48cdde9d4f2b6b8880a"); - // Vector B: depot shape, epoch 8, chained from A's digest, two attestations. + // Vector B: depot shape, epoch 8, chained from A at both levels (envelope digest + message + // id, so B's message carries sequence number 2), two attestations. const auto env_b = depot_shape(8, oracle::digest_bytes(digest_a), + env_a.messages(0).header().message_id(), { std::string("\xde\xad\xbe\xef", 4), std::string("\xca\xfe\xba\xbe\x01", 5) }); const auto enc_b = oracle::encode(env_b); BOOST_REQUIRE_EQUAL(fc::to_hex(enc_b.data(), enc_b.size()), - "0a00120c0a040800100012040800100028e4e4d6ce0630083800a2012028ccf00f6852162f6cfcc262f7" - "b6dad43d7aabb40846b12c91515fcd4ce8cc41c202470a260a0c0a04080010001204080010001200" - "1a0022060800100018002800320038e4e4d6ce064200121d0801120b08d10f10041a04deadbeef120c08" - "d10f10051a05cafebabe01"); + "0a00120c0a040800100012040800100028f7b483d6d63330083800a20120c7c6502a5b047c0742c88712" + "2350c0b6731c60f3f5f9d48cdde9d4f2b6b8880ac202c1010a9f010a0c0a040800100012040800100012" + "2000000000000000022437e72cf67a093c4c5753cbb3ce71b76c890da8f9965c351a2000000000000000" + "01210103982d1ae1f083b047bde00e77e4a337f3b31c8d223c281d3220fdbcffc45ad50a6a2d1376af8c" + "498d86910751868ae7e14fe909477b319ec98d38f7b483d6d63342208d135355c556a6ed2437e72cf67a" + "093c4c5753cbb3ce71b76c890da8f9965c35121d0801120b08d10f10041a04deadbeef120c08d10f1005" + "1a05cafebabe01"); BOOST_REQUIRE_EQUAL(oracle::epoch_digest(env_b).str(), - "b1dfe8c944b05364d56eccdbba2c6e43d0a2112ab23a5c0487fb0907813d73f0"); + "c7e7d905b6c87a209fde8319701a5a7d95350c989e234eedb26bdeb14cd5bddd"); // Vector C: wire-ethereum test-fixture shape: WIRE(1) -> EVM(31337) endpoints, message-free. sysio::opp::Envelope env_c; @@ -549,13 +449,13 @@ BOOST_AUTO_TEST_CASE(canonical_oracle_matches_solidity_golden_vectors) { try { eps->mutable_start()->set_id(1); eps->mutable_end()->set_kind(sysio::opp::types::CHAIN_KIND_EVM); eps->mutable_end()->set_id(31337); - env_c.set_epoch_timestamp(GOLDEN_TS); + env_c.set_epoch_timestamp(GOLDEN_TS_MS); env_c.set_epoch_index(1); const auto enc_c = oracle::encode(env_c); BOOST_REQUIRE_EQUAL(fc::to_hex(enc_c.data(), enc_c.size()), - "0a00120e0a04080110011206080210e9f40128e4e4d6ce0630013800a20100"); + "0a00120e0a04080110011206080210e9f40128f7b483d6d63330013800a20100"); BOOST_REQUIRE_EQUAL(oracle::epoch_digest(env_c).str(), - "8df462b6ed2e2a15b91c65faece549f26eee5b430581a883ae82d630cdbf5438"); + "dc4f4d15bd8c5e9685e2c8e2bf9d52c736bd158dd3fc67f0afbe585e2e0a5fa6"); // The digest must blank a populated envelope_hash: setting it changes the exact encoding but // not the epoch digest. @@ -608,6 +508,33 @@ BOOST_FIXTURE_TEST_CASE(buildenv_chains_consecutive_envelopes, sysio_msgch_chain BOOST_REQUIRE_EQUAL( fc::to_hex(env.previous_envelope_hash().data(), env.previous_envelope_hash().size()), predecessor["envelope_hash"].as_string()); + + // Semantic header (opp.proto MessageHeader): every field the contract derived on emit + // recomputes identically through the independent oracle. + BOOST_REQUIRE_EQUAL(env.messages_size(), 1); + const auto& header = env.messages(0).header(); + const auto payload_bytes = oracle::encode(env.messages(0).payload()); + BOOST_REQUIRE_EQUAL(header.payload_size(), static_cast(payload_bytes.size())); + BOOST_REQUIRE_EQUAL( + fc::to_hex(header.payload_checksum().data(), header.payload_checksum().size()), + oracle::keccak_of(payload_bytes).str()); + BOOST_REQUIRE_EQUAL( + fc::to_hex(header.header_checksum().data(), header.header_checksum().size()), + oracle::header_checksum(header).str()); + BOOST_REQUIRE_EQUAL( + header.message_id(), + oracle::derive_message_id( + oracle::header_checksum(header), + oracle::message_sequence(header.previous_message_id()).value() + 1)); + + // Message chain link: previous_message_id carries the predecessor row's stream tip, and + // the new row's `last_message_id` records this emit's id for the next link. + BOOST_REQUIRE_EQUAL( + fc::to_hex(header.previous_message_id().data(), header.previous_message_id().size()), + predecessor["last_message_id"].as_string()); + BOOST_REQUIRE_EQUAL( + row["last_message_id"].as_string(), + fc::to_hex(header.message_id().data(), header.message_id().size())); } } FC_LOG_AND_RETHROW() } @@ -632,6 +559,14 @@ BOOST_FIXTURE_TEST_CASE(buildenv_first_emit_chains_from_empty, sysio_msgch_chain auto env = decode_envelope(raw); BOOST_REQUIRE_EQUAL(env.previous_envelope_hash().size(), 0u); BOOST_REQUIRE_EQUAL(row["envelope_hash"].as_string(), oracle::keccak_of(raw).str()); + + // Message-stream genesis: empty previous_message_id, sequence number 1, tip recorded. + BOOST_REQUIRE_EQUAL(env.messages_size(), 1); + const auto& header = env.messages(0).header(); + BOOST_REQUIRE_EQUAL(header.previous_message_id().size(), 0u); + BOOST_REQUIRE_EQUAL(oracle::message_sequence(header.message_id()).value(), 1u); + BOOST_REQUIRE_EQUAL(row["last_message_id"].as_string(), + fc::to_hex(header.message_id().data(), header.message_id().size())); } FC_LOG_AND_RETHROW() } // --------------------------------------------------------------------------- @@ -644,7 +579,8 @@ BOOST_FIXTURE_TEST_CASE(inbound_chain_tip_recorded_and_verified, sysio_msgch_cha // Epoch E: first envelope from ETH: no tip exists yet, accepted (bootstrap), tip recorded. uint32_t epoch = current_epoch(); auto n1 = encode_delivery(epoch, "alpha"); - const auto n1_digest = oracle::epoch_digest(decode_envelope(n1)); + const auto n1_digest = oracle::epoch_digest(decode_envelope(n1)); + const auto n1_msg_id = delivery_message_id(n1); BOOST_REQUIRE_EQUAL(success(), deliver(ETH_OUTPOST_ID, n1)); produce_blocks(); @@ -654,10 +590,12 @@ BOOST_FIXTURE_TEST_CASE(inbound_chain_tip_recorded_and_verified, sysio_msgch_cha BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), n1_digest.str()); BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, epoch), 1u); - // Epoch E+1: correctly chained envelope (prev = tip) is accepted and advances the tip. + // Epoch E+1: correctly chained envelope (prev = tip at BOTH the envelope and message level) is + // accepted and advances both tips. epoch = advance_one_epoch(); - auto n2 = encode_delivery(epoch, "bravo", oracle::digest_bytes(n1_digest)); + auto n2 = encode_delivery(epoch, "bravo", oracle::digest_bytes(n1_digest), n1_msg_id); const auto n2_digest = oracle::epoch_digest(decode_envelope(n2)); + const auto n2_msg_id = delivery_message_id(n2); BOOST_REQUIRE_EQUAL(success(), deliver(ETH_OUTPOST_ID, n2)); produce_blocks(); @@ -666,11 +604,15 @@ BOOST_FIXTURE_TEST_CASE(inbound_chain_tip_recorded_and_verified, sysio_msgch_cha BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), n2_digest.str()); BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, epoch), 1u); - // Epoch E+2: chain break: a non-empty prev that does not continue the tip is dropped - // without throwing: deliver succeeds, nothing is dispatched, the tip does not move. + // Epoch E+2: chain break: a non-empty prev that does not continue the tip is REJECTED at + // ingress -- deliver reverts (see msgch::deliver's inbound_envelope_valid gate), so no row is + // recorded, nothing is dispatched, and the tip does not move. const uint32_t break_epoch = advance_one_epoch(); auto n3 = encode_delivery(break_epoch, "charlie", std::string(32, '\x11')); - BOOST_REQUIRE_EQUAL(success(), deliver(ETH_OUTPOST_ID, n3)); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: delivered envelope failed inbound-chain or " + "semantic-header validation"), + deliver(ETH_OUTPOST_ID, n3)); produce_blocks(); opc = get_outpcons(ETH_OUTPOST_ID); @@ -678,12 +620,15 @@ 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: 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. + // Epoch E+3: enforcement (SEC-107 completion): an EMPTY prev on a non-genesis epoch is a chain + // break. Both outposts self-chain per stream, so once a tip is recorded an empty prev-hash no + // longer bootstraps — it is REJECTED at ingress (deliver reverts) 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)); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: delivered envelope failed inbound-chain or " + "semantic-header validation"), + deliver(ETH_OUTPOST_ID, n4)); produce_blocks(); opc = get_outpcons(ETH_OUTPOST_ID); @@ -696,7 +641,7 @@ BOOST_FIXTURE_TEST_CASE(inbound_chain_tip_recorded_and_verified, sysio_msgch_cha // 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(n2_digest), + auto n5 = encode_delivery(blank_epoch, "echo", oracle::digest_bytes(n2_digest), n2_msg_id, /*env_hash=*/std::string(32, '\x22')); auto n5_env = decode_envelope(n5); BOOST_REQUIRE_EQUAL(n5_env.envelope_hash().size(), 32u); @@ -709,6 +654,144 @@ 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() } +/// Semantic-header enforcement: every header field must recompute per the spec derivation; a +/// forged field is REJECTED at ingress -- `deliver` reverts (the header check runs there), so no +/// envelope row is recorded, no attestation, no consensus record, no chain-tip movement. Each +/// forgery is exercised at its own epoch on a correctly CHAINED envelope, so the rejection is +/// attributable to the header check rather than the envelope-chain check; a final well-formed +/// delivery proves the stream resumes. +BOOST_FIXTURE_TEST_CASE(inbound_semantic_header_forgeries_dropped, sysio_msgch_chain_tester) { try { + bootstrap(); + + // Establish a tip so every subsequent forgery chains correctly at the envelope AND message + // level -- so each drop is attributable to the mutated field, not the chain checks. + uint32_t epoch = current_epoch(); + auto base = encode_delivery(epoch, "alpha"); + const auto tip = oracle::epoch_digest(decode_envelope(base)); + const auto tip_msg_id = delivery_message_id(base); + BOOST_REQUIRE_EQUAL(success(), deliver(ETH_OUTPOST_ID, base)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, epoch), 1u); + const uint32_t accepted_epoch = epoch; + + // Deliver a correctly-chained envelope whose decoded form was altered by `mutate`, then + // assert it was REJECTED at ingress: `deliver` reverts (the semantic-header check runs there, + // see msgch::deliver's inbound_envelope_valid gate), so no envelope row is recorded, no + // attestation lands for its epoch, the consensus record stays at the last accepted epoch, and + // the tip does not move. + auto forged_delivery_dropped = [&](const char* tag, + const std::function& mutate) { + const uint32_t e = advance_one_epoch(); + auto env = decode_envelope(encode_delivery(e, tag, oracle::digest_bytes(tip), tip_msg_id)); + mutate(env); + std::vector out(env.ByteSizeLong()); + env.SerializeToArray(out.data(), static_cast(out.size())); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: delivered envelope failed inbound-chain or " + "semantic-header validation"), + deliver(ETH_OUTPOST_ID, out)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, e), 0u); + auto opc = get_outpcons(ETH_OUTPOST_ID); + BOOST_REQUIRE(!opc.is_null()); + BOOST_REQUIRE_EQUAL(opc["epoch_index"].as(), accepted_epoch); + BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), tip.str()); + }; + + forged_delivery_dropped("pc", [](sysio::opp::Envelope& env) { + auto* h = env.mutable_messages(0)->mutable_header(); + std::string c = h->payload_checksum(); c[0] ^= 0x01; h->set_payload_checksum(c); + }); + forged_delivery_dropped("hc", [](sysio::opp::Envelope& env) { + auto* h = env.mutable_messages(0)->mutable_header(); + std::string c = h->header_checksum(); c[31] ^= 0x01; h->set_header_checksum(c); + }); + forged_delivery_dropped("ps", [](sysio::opp::Envelope& env) { + auto* h = env.mutable_messages(0)->mutable_header(); + h->set_payload_size(h->payload_size() + 1); + }); + forged_delivery_dropped("seq", [](sysio::opp::Envelope& env) { + // Correct checksum tail, wrong embedded sequence number: the derivation demands + // previous_message_id's sequence + 1, so + 2 (skipping one) is always wrong regardless of + // where the base sits in the stream. + auto* h = env.mutable_messages(0)->mutable_header(); + const uint64_t wrong_seq = + oracle::message_sequence(h->previous_message_id()).value() + 2; + h->set_message_id(oracle::derive_message_id(oracle::header_checksum(*h), wrong_seq)); + }); + forged_delivery_dropped("ds", [](sysio::opp::Envelope& env) { + auto* a = env.mutable_messages(0)->mutable_payload()->mutable_attestations(0); + a->set_data_size(a->data_size() + 1); + }); + forged_delivery_dropped("prevlen", [](sysio::opp::Envelope& env) { + // Non-canonical previous_message_id length (neither empty nor 32 bytes), with the checksum + // and id honestly re-derived over it the way a colluding emitter would -- the drop must + // come from the length rule alone, not from a checksum mismatch. + auto* h = env.mutable_messages(0)->mutable_header(); + h->set_previous_message_id(std::string(5, '\x07')); + const auto checksum = oracle::header_checksum(*h); + h->set_header_checksum(oracle::digest_bytes(checksum)); + h->set_message_id(oracle::derive_message_id(checksum, 1)); + }); + + // The stream resumes: a well-formed envelope, chained from the still-current tip at both the + // envelope and message level, is accepted and advances the tip past all the dropped epochs. + const uint32_t resume_epoch = advance_one_epoch(); + auto resume = encode_delivery(resume_epoch, "omega", oracle::digest_bytes(tip), tip_msg_id); + const auto resume_digest = oracle::epoch_digest(decode_envelope(resume)); + BOOST_REQUIRE_EQUAL(success(), deliver(ETH_OUTPOST_ID, resume)); + produce_blocks(); + auto opc = get_outpcons(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(opc["epoch_index"].as(), resume_epoch); + BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), resume_digest.str()); + BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, resume_epoch), 1u); +} FC_LOG_AND_RETHROW() } + +/// SEC-102 P1 (huang): the message chain stops replay of an earlier valid message inside a +/// correctly ENVELOPE-chained successor. The envelope chain orders envelopes but does not bind +/// the messages they carry, so without the per-outpost message tip a malicious batch-operator +/// quorum could re-emit an old `Message` verbatim -- self-consistent header and all -- in a fresh, +/// correctly-chained envelope and re-dispatch its attestations (e.g. crediting one escrow deposit +/// twice). Here the replay carries a value-bearing OPERATOR_ACTION so the double-dispatch would be +/// financially real; the message-tip check drops it before any dispatch. +BOOST_FIXTURE_TEST_CASE(inbound_message_replay_dropped, sysio_msgch_chain_tester) { try { + bootstrap(); + + // Epoch E: accept a genesis envelope carrying message M1. The message tip becomes M1's id. + uint32_t epoch = current_epoch(); + auto e1 = encode_delivery(epoch, "m1"); + const auto e1_digest = oracle::epoch_digest(decode_envelope(e1)); + BOOST_REQUIRE_EQUAL(success(), deliver(ETH_OUTPOST_ID, e1)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, epoch), 1u); + + // Epoch E+1: a fresh envelope, correctly chained at the ENVELOPE level (previous_envelope_hash + // = E's digest), but carrying M1 replayed verbatim. M1's previous_message_id is empty (it was + // the stream's genesis message), which no longer continues the recorded message tip, so the + // envelope is REJECTED at ingress: deliver reverts, no new attestation row, the tip does not move. + const uint32_t replay_epoch = advance_one_epoch(); + auto replay = [&]() { + sysio::opp::Envelope env; + env.set_epoch_index(replay_epoch); + env.set_epoch_envelope_index(1); + env.set_epoch_timestamp(1'775'612'516'983ULL); + env.set_previous_envelope_hash(oracle::digest_bytes(e1_digest)); + *env.add_messages() = decode_envelope(e1).messages(0); // M1 verbatim + std::vector out(env.ByteSizeLong()); + env.SerializeToArray(out.data(), static_cast(out.size())); + return out; + }(); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: delivered envelope failed inbound-chain or " + "semantic-header validation"), + deliver(ETH_OUTPOST_ID, replay)); + produce_blocks(); + + auto opc = get_outpcons(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(opc["epoch_index"].as(), epoch); // still E + BOOST_REQUIRE_EQUAL(attestation_count(ETH_OUTPOST_ID, replay_epoch), 0u); // replay not dispatched +} FC_LOG_AND_RETHROW() } + /// 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 @@ -741,9 +824,11 @@ BOOST_FIXTURE_TEST_CASE(inbound_rejects_cross_stream_link_for_svm_outposts, outbound_digest_bytes.data(), outbound_digest_bytes.size())); - // 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(), + // Cross-stream link (prev = depot's outbound emit digest) is a chain break: REJECTED at ingress + // (deliver reverts), tip does not move, nothing recorded or dispatched. + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: delivered envelope failed inbound-chain or " + "semantic-header validation"), deliver(SOL_OUTPOST_ID, encode_delivery(epoch, "xstream-b", outbound_digest_bytes))); produce_blocks(); @@ -777,7 +862,9 @@ BOOST_FIXTURE_TEST_CASE(inbound_rejects_cross_stream_link_for_evm_outposts, outbound_digest_bytes.data(), outbound_digest_bytes.size())); - BOOST_REQUIRE_EQUAL(success(), + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: delivered envelope failed inbound-chain or " + "semantic-header validation"), deliver(ETH_OUTPOST_ID, encode_delivery(epoch, "evm-xstream-b", outbound_digest_bytes))); produce_blocks(); diff --git a/libraries/opp/include/sysio/opp/opp.hpp b/libraries/opp/include/sysio/opp/opp.hpp index 757f402021..7224652467 100644 --- a/libraries/opp/include/sysio/opp/opp.hpp +++ b/libraries/opp/include/sysio/opp/opp.hpp @@ -197,24 +197,6 @@ FC_REFLECT_ENUM(sysio::opp::types::NodeOwnerTier, (NODE_OWNER_TIER_T2) (NODE_OWNER_TIER_T3)) -// --------------------------------------------------------------------------- -// Encoding enums -// --------------------------------------------------------------------------- - -FC_REFLECT_ENUM(sysio::opp::types::Endianness, - (ENDIANNESS_BIG) - (ENDIANNESS_LITTLE)) - -FC_REFLECT_ENUM(sysio::opp::types::HashAlgorithm, - (HASH_ALGORITHM_KECCAK256) - (HASH_ALGORITHM_SHA256) - (HASH_ALGORITHM_RESERVED_1) - (HASH_ALGORITHM_RESERVED_2)) - -FC_REFLECT_ENUM(sysio::opp::types::LengthEncoding, - (LENGTH_ENCODING_VARUINT) - (LENGTH_ENCODING_UINT32)) - FC_REFLECT_ENUM(sysio::opp::types::StakeStatus, (STAKE_STATUS_UNKNOWN) (STAKE_STATUS_WARMUP) diff --git a/libraries/opp/proto/sysio/opp/opp.proto b/libraries/opp/proto/sysio/opp/opp.proto index 6b78bf6b61..cc6999a069 100644 --- a/libraries/opp/proto/sysio/opp/opp.proto +++ b/libraries/opp/proto/sysio/opp/opp.proto @@ -20,15 +20,49 @@ message Endpoints { // Message header // --------------------------------------------------------------------------- +// Semantic header carried by every Message. +// +// Every checksum / id below is keccak256 over the FIELD-COMPLETE canonical encoding of the +// respective sub-message — the same canonical form the envelope epoch digest uses (produced by +// the generated Solidity/Solana codecs and, on the WIRE depot, by `opp_canonical_codec.hpp`). +// Every emitter (the WIRE depot's `sysio.msgch::buildenv`, the Ethereum outpost, the Solana +// outpost) populates these fields identically; the depot drops inbound envelopes whose fields +// do not recompute. message MessageHeader { - Endpoints endpoints = 1; - bytes message_id = 2; // 32 bytes; last 8 bytes = sequence number - bytes previous_message_id = 3; // 32 bytes - sysio.opp.types.EncodingFlags encoding_flags = 4; - uint32 payload_size = 5; - bytes payload_checksum = 6; // 32 bytes - uint64 timestamp = 7; // milliseconds since epoch - bytes header_checksum = 8; // 32 bytes + // Reserved in this revision: emitters leave `endpoints` at its proto3 default. Routing + // derives from the proven delivery chain (`sysio.chains`), never from self-declared header + // fields. + Endpoints endpoints = 1; + + // 32 bytes: `header_checksum` with its FIRST 8 bytes (big-endian) replaced by this message's + // sequence number, defined as the previous message's sequence number + 1. A message with an + // empty `previous_message_id` starts its stream at sequence number 1. + bytes message_id = 2; + + // 32 bytes; the `message_id` of the previous message emitted on this stream. Empty at + // genesis. + bytes previous_message_id = 3; + + // Slot 4 was `sysio.opp.types.EncodingFlags encoding_flags` — removed. The canonical + // encoding is normative and singular; per-chain hash selection, if ever needed, belongs on + // the `sysio.chains` registry row, not on a self-declared per-message field. + reserved 4; + reserved "encoding_flags"; + + // Byte length of the canonical encoding of the sibling `payload`. + uint32 payload_size = 5; + + // 32 bytes; keccak256 over the canonical encoding of the sibling `payload`. + bytes payload_checksum = 6; + + // Milliseconds since the Unix epoch, at the emitting chain's native clock resolution. + // Sub-second precision is only meaningful from the WIRE depot (half-second blocks); EVM and + // SVM platform clocks are whole seconds, emitted as seconds * 1000. + uint64 timestamp = 7; + + // 32 bytes; keccak256 over the canonical encoding of this header with `message_id` and + // `header_checksum` blanked (empty bytes). + bytes header_checksum = 8; } // --------------------------------------------------------------------------- @@ -44,6 +78,8 @@ message MessagePayload { // Single attestation within a payload. message AttestationEntry { sysio.opp.types.AttestationType type = 1; + // MUST equal the byte length of `data`. Canonical-form field only: receivers size the + // attestation by `data` itself and never trust `data_size` for allocation or bounds. uint32 data_size = 2; bytes data = 3; } @@ -70,21 +106,24 @@ message Message { // That canonical encoding is produced by the generated Solidity/Solana codecs and, on the WIRE // depot, by `contracts/sysio.opp.common/include/sysio.opp.common/opp_canonical_codec.hpp`. // Any change to this message or the types it embeds (Endpoints, ChainId, Message, MessageHeader, -// MessagePayload, AttestationEntry, EncodingFlags) MUST be mirrored there, and the cross-language -// golden vectors in `contracts/tests/sysio.msgch_chain_tests.cpp` regenerated. +// MessagePayload, AttestationEntry) MUST be mirrored there, and the cross-language golden +// vectors in `contracts/tests/sysio.msgch_chain_tests.cpp` regenerated. message Envelope { bytes envelope_hash = 1; // 32 bytes Endpoints endpoints = 2; - uint64 epoch_timestamp = 5; + uint64 epoch_timestamp = 5; // milliseconds since the Unix epoch (see MessageHeader.timestamp) uint32 epoch_index = 6; uint32 epoch_envelope_index = 7; // Slot 15 was `bytes merkle` — removed; do not reuse. + reserved 15; + reserved "merkle"; bytes previous_envelope_hash = 20; // 32 bytes - // Slot 30 was `bytes start_message_id` — removed; do not reuse. - // Slot 31 was `bytes end_message_id` — removed; do not reuse. + // Slots 30/31 were `bytes start_message_id` / `bytes end_message_id` — removed; do not reuse. + reserved 30, 31; + reserved "start_message_id", "end_message_id"; repeated Message messages = 40; } diff --git a/libraries/opp/proto/sysio/opp/types/types.proto b/libraries/opp/proto/sysio/opp/types/types.proto index 436408de52..eb05ede717 100644 --- a/libraries/opp/proto/sysio/opp/types/types.proto +++ b/libraries/opp/proto/sysio/opp/types/types.proto @@ -224,33 +224,6 @@ message ReserveAmount { TokenAmount amount = 3; } -// --------------------------------------------------------------------------- -// Encoding flags -// --------------------------------------------------------------------------- - -enum Endianness { - ENDIANNESS_BIG = 0; - ENDIANNESS_LITTLE = 1; -} - -enum HashAlgorithm { - HASH_ALGORITHM_KECCAK256 = 0; - HASH_ALGORITHM_SHA256 = 1; - HASH_ALGORITHM_RESERVED_1 = 2; - HASH_ALGORITHM_RESERVED_2 = 3; -} - -enum LengthEncoding { - LENGTH_ENCODING_VARUINT = 0; - LENGTH_ENCODING_UINT32 = 1; -} - -message EncodingFlags { - Endianness endianness = 1; - HashAlgorithm hash_algorithm = 2; - LengthEncoding length_encoding = 3; -} - // --------------------------------------------------------------------------- // Attestation discriminant // ---------------------------------------------------------------------------