From aab3575ac5b63e414200027d84ee3804ec75af8f Mon Sep 17 00:00:00 2001 From: David J Parrott Date: Wed, 22 Jul 2026 19:58:32 -0400 Subject: [PATCH] outpost_solana: read inbound envelopes through the IDL decoder nodeop read the on-chain LatestOutboundEnvelope with hardcoded Borsh offsets that assumed the standalone opp_outpost field order ({epoch_index, checksum, data, bump} => epoch at byte 8). The folded liqsol_core program declares {bump, epoch_index, checksum, data}, shifting epoch_index to byte 9, so the hardcoded reader decoded the leading `bump` (0xFF) as the low byte of the epoch (0xFF | 1<<8 = 511): read_inbound_envelope never matched the requested epoch, every SOL->depot envelope relay silently stalled, and no reward or deposit reached the depot. Read the account through libfc's IDL-driven decode_account_data instead -- the same path the class already uses for Reserve/OutpostConfig. It verifies the 8-byte Anchor discriminator and follows the loaded IDL's declared field order, so one nodeop decodes both program layouts value-exactly and the hand-derived offset arithmetic (and its whole bug class) is gone. Hardening around the read: - Match the loaded IDL to the deployed program id (idl::program.address) so --solana-idl-file ORDER can never decide which same-named IDL version's field order drives decoding; multiple candidates with none matching refuse to boot rather than pick one silently. - Validate the stored keccak256(encoded_envelope) checksum -- a decode that read the wrong bytes for `data` (field-order drift) is caught and rejected instead of stalling. - Reject accounts whose owner != program id before decoding. - Role-gate the boot-time IDL shape check: only roles that read inbound envelopes (batch_operator) require a decodable LatestOutboundEnvelope, so the underwriter (which never reads inbound) is not bricked at boot by an instructions-only IDL. - Log drift signals (undecodable account, stored epoch ahead of request, checksum mismatch) at warning level so a stalled relay is diagnosable; normal emit-cadence lag stays at debug. Retire the now-unused add_attestation binding and stub instruction along with it: the folded liqsol_core IDL dropped add_attestation (attestations flow through epoch_in / emit_outbound_envelope), it had zero call sites, and binding it via get_idl("add_attestation") threw at construction against the folded IDL -- so nodeop now consumes the IDL verbatim. Covered by table-driven plugin tests that drive the complete post-fetch read path against synthesized accounts for BOTH program layouts, including the epoch=511 reproduction, every drift/reject path, and a pin on libfc's bytes-as-base64 rendering the reader depends on. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015CbgvM8EhrPL1VjX8RhNze --- .../src/batch_operator_plugin.cpp | 3 +- .../sysio/outpost_solana_client_plugin.hpp | 34 +- .../outpost_solana_client.hpp | 99 ++++- .../src/outpost_solana_client.cpp | 405 +++++++++++++---- .../src/outpost_solana_client_plugin.cpp | 11 +- .../test_outpost_solana_client_plugin.cpp | 419 +++++++++++++++++- .../src/underwriter_plugin.cpp | 3 +- .../fixtures/solana-idl-opp-outpost-stub.json | 82 ---- 8 files changed, 869 insertions(+), 187 deletions(-) diff --git a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp index 7bcfddd021..d52164caf8 100644 --- a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp @@ -633,7 +633,8 @@ struct batch_operator_plugin::impl { continue; } client = sol_plug->create_outpost_client(sol_client_id, op.id, op.chain_id, - bound->second.opp_addr); + bound->second.opp_addr, + solana_outpost_role::batch_operator); } else { wlog("batch_operator: outpost {} has unsupported chain_kind, skipping job build", op.id); diff --git a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp index 486db4fc6b..02dda4de91 100644 --- a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp +++ b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp @@ -39,6 +39,23 @@ inline constexpr auto SOL_SWAP_DEPOSIT_POLL_INTERVAL = fc::seconds(15); /// this horizon) and a production RPC (≥ 2 epochs of tx history). inline constexpr auto SOL_SWAP_DEPOSIT_TOTAL_TIMEOUT = fc::seconds(120); +/// Consumer role a Solana `outpost_client` is constructed for. Boot-time IDL +/// validation is role-aware: only roles that call `read_inbound_envelope` +/// require the loaded IDL to declare a decodable `LatestOutboundEnvelope` +/// account, so an instructions-only IDL keeps working for roles that never +/// read inbound. A new role must pick the variant matching whether it reads +/// inbound envelopes. +enum class solana_outpost_role { + /// Delivers outbound envelopes AND polls `read_inbound_envelope` - the + /// IDL must declare a readable `LatestOutboundEnvelope`, asserted at boot. + batch_operator, + /// Only submits `uw_commit`; never reads inbound envelopes, so the + /// `LatestOutboundEnvelope` boot assertion is skipped. If such a client + /// ever does call `read_inbound_envelope` against an IDL without the + /// account, the read logs a warning and yields no envelope. + underwriter +}; + struct solana_client_entry_t { std::string id; std::string url; @@ -135,8 +152,6 @@ struct opp_solana_outpost_client : fc::network::solana::solana_program_client { /// never calls it because the consensus-reaching terminal `epoch_in` emits /// the outbound envelope inline. solana_program_tx_fn emit_outbound_envelope; - /// `add_attestation(attestation_type: i32, data: bytes) -> signature`. - solana_program_tx_fn> add_attestation; /// `deposit(operator_type: u8, wire_account_name: string, amount: u64) -> signature`. solana_program_tx_fn deposit; /// `commit_underwrite(uic_bytes: bytes) -> signature`. @@ -337,7 +352,6 @@ struct opp_solana_outpost_client : fc::network::solana::solana_program_client { program_invoke_data_items params = {fc::variant(wire_epoch_index)}; return execute_tx_and_confirm(instr, resolve_accounts(instr, params, overrides), params); }) - , add_attestation(create_tx_and_confirm>(get_idl("add_attestation"))) , deposit(create_tx_and_confirm(get_idl("deposit"))) // commit_underwrite is `(uic_bytes: bytes) -> signature`. The IDL declares // three accounts — `underwriter` (signer, default-resolved from the @@ -386,12 +400,16 @@ class outpost_solana_client_plugin : public appbase::plugin create_outpost_client(const std::string& sol_client_id, - uint64_t chain_code, - uint32_t chain_id, - const std::string& program_id); + std::shared_ptr create_outpost_client(const std::string& sol_client_id, + uint64_t chain_code, + uint32_t chain_id, + const std::string& program_id, + solana_outpost_role role); private: std::unique_ptr my; diff --git a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp index 4b62a65410..c4b78296ac 100644 --- a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp +++ b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp @@ -28,6 +28,102 @@ inline constexpr size_t SOLANA_MAX_ENVELOPE_BYTES = OPP_MAX_ENVELOPE_BYTES; /// regenerated and will catch any packet overflow. inline constexpr size_t SOLANA_MAX_CHUNK_BYTES = 672; +namespace outpost_solana_client_detail { + +/// Assert that the loaded IDL's `LatestOutboundEnvelope` declaration has the +/// shape `read_inbound_envelope` relies on: the account exists (inline fields +/// or the Anchor IDL v2 `types`-section fallback), `epoch_index` is a u32, and +/// `data` is a length-prefixed `bytes` / `Vec` payload. Field ORDER is +/// deliberately unconstrained: the reader decodes the whole account through +/// libfc's IDL-driven `decode_account_data`, which follows the declared field +/// order at decode time, so BOTH the standalone `opp_outpost` +/// ({epoch_index, checksum, data, bump}) and the integrated `liqsol_core` +/// ({bump, epoch_index, checksum, data}) layouts are handled by a single build. +/// +/// Called at construction for roles that read inbound envelopes so a +/// misshaped IDL fails at boot (`create_outpost_client`) instead of on the +/// first inbound poll, where the job loop would wlog and retry forever. +/// +/// Exposed in this header (rather than the .cpp's anonymous namespace) so the +/// plugin's unit tests can exercise the pass and fail-loud paths against +/// synthesized IDLs. +/// +/// @param program the program's loaded Anchor IDL. +/// @throws fc::exception if the account or either field is absent, or a field +/// has a type the reader cannot faithfully interpret. +void assert_latest_envelope_shape(const fc::network::solana::idl::program& program); + +/// Reduce the name-filtered IDL candidates to the ones whose declared +/// `address` (Anchor IDL v2 top-level / `metadata.address`) matches the +/// configured program id. Without this, WHICH same-named IDL version drives +/// account decoding is decided by `--solana-idl-file` order, and an IDL whose +/// field order disagrees with the deployed program silently misreads accounts +/// (the epoch=511 RCA class). +/// +/// * any candidate matches -> only the matching ones are returned. +/// * single candidate, no match -> returned as-is (address-less stub +/// IDLs and dev fixtures stay usable; a declared-but-mismatched address +/// logs a warning). +/// * multiple candidates, no match -> throws: the selection would be +/// order-dependent, which is exactly the misread risk. +/// +/// @param program_idls name-filtered candidate IDLs (order preserved). +/// @param program_id the deployed outpost program id the client will bind. +/// @return the surviving candidates, order preserved. +/// @throws fc::exception when multiple candidates are loaded and none carries +/// a matching declared address. +std::vector +select_program_idls_matching(std::vector program_idls, + const fc::network::solana::solana_public_key& program_id); + +/// Raw payload bytes of a decoded Borsh `bytes` / `Vec` IDL field. +/// libfc's `decode_account_data` renders `bytes` as a base64 string variant +/// and `Vec` as an array-of-integers variant; both IDL spellings appear +/// across outpost program versions, so the reader accepts either. +/// +/// @param field_value the decoded field variant. +/// @return the payload bytes. +/// @throws fc::exception if the variant is neither shape or an array element +/// is out of byte range. +std::vector borsh_payload_bytes(const fc::variant& field_value); + +/// Decode an already-fetched `LatestOutboundEnvelope` account through the +/// outpost program client's loaded IDL and validate it end-to-end: +/// +/// 1. IDL-driven decode (`decode_account_info_data`) - verifies the 8-byte +/// Anchor discriminator and follows the IDL's declared field order, so +/// the same binary reads both known program layouts value-exactly. +/// 2. `epoch_index` gate - 0 (never emitted) and stored != requested both +/// return empty. A stored epoch AHEAD of the request is warned about +/// (likely IDL-vs-deployment drift misreading the account, or an outpost +/// relaying for a stale WIRE view); a stored epoch behind the request is +/// normal emit-cadence lag and stays at debug. +/// 3. `checksum` gate - when the IDL declares the 32-byte checksum field, +/// the payload's keccak256 must match it (both program versions write +/// `keccak256(encoded_envelope)`); a mismatch means the decode read the +/// wrong bytes for `data` (field-order drift) and is warned + rejected. +/// 4. envelope-cap, protobuf-decode and inner-epoch checks, as before. +/// +/// Any decode/extraction failure logs a warning (visible at default log +/// level - a permanently undecodable account must not be silent) and returns +/// empty so the poll loop keeps running. +/// +/// Exposed in this header so the plugin's unit tests can drive the complete +/// post-fetch read path against synthesized accounts for BOTH program +/// layouts without a live RPC endpoint. +/// +/// @param program_client outpost program client carrying the loaded IDL. +/// @param account_data raw fetched account bytes (incl. discriminator). +/// @param epoch_index the WIRE epoch the caller expects to read. +/// @param log_label client identity for log lines (`to_string()`). +/// @return the envelope's protobuf bytes, or empty when unavailable/invalid. +std::vector decode_latest_envelope_account(opp_solana_outpost_client& program_client, + const std::vector& account_data, + uint32_t epoch_index, + const std::string& log_label); + +} // namespace outpost_solana_client_detail + /** * @brief Solana concrete `outpost_client`. * @@ -49,7 +145,8 @@ class outpost_solana_client : public outpost_client { fc::network::solana::solana_public_key program_id, std::vector program_idls, uint64_t chain_code, - uint32_t chain_id); + uint32_t chain_id, + solana_outpost_role role); // ── outpost_client SPI ─────────────────────────────────────────────── sysio::opp::types::ChainKind chain_kind() const override; diff --git a/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp b/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp index 4b7a82bdc7..fa1b073050 100644 --- a/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp +++ b/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp @@ -1,13 +1,15 @@ #include #include -#include #include #include #include +#include #include #include +#include +#include #include #include #include @@ -28,30 +30,29 @@ constexpr std::string_view OP_EPOCH_IN = "deliver_outbound_envelope:epoch_in" constexpr std::string_view OP_READ_LATEST = "read_inbound_envelope:get_account_info"; constexpr std::string_view OP_UW_COMMIT = "uw_commit:commit_underwrite"; -/// 8-byte Anchor discriminator that prefixes every `#[account]`-tagged -/// account's serialized form. -constexpr size_t ANCHOR_DISCRIMINATOR_LEN = 8; - -/// Borsh layout of `LatestOutboundEnvelope`: -/// epoch_index: u32 (4) -/// checksum: [u8; 32] (32) -/// data: Vec (4 + N) -/// bump: u8 (1) -constexpr size_t LATEST_HEADER_LEN = ANCHOR_DISCRIMINATOR_LEN + 4 + 32; -constexpr size_t LATEST_VEC_LEN_OFF = LATEST_HEADER_LEN; -constexpr size_t LATEST_DATA_OFF = LATEST_HEADER_LEN + 4; -constexpr size_t LATEST_EPOCH_OFF = ANCHOR_DISCRIMINATOR_LEN; - -/// Read a little-endian u32 from `buf` at `off`. Borsh is little-endian on the wire; the native-endian -/// `memcpy` below is correct only on a little-endian host. WIRE is x86_64-only today; the static_assert -/// documents that dependency and fails the build if a future port to a big-endian host removes it. -uint32_t read_u32_le(const std::vector& buf, size_t off) { - static_assert(std::endian::native == std::endian::little, "read_u32_le assumes a little-endian host"); - if (off + 4 > buf.size()) FC_THROW("LatestOutboundEnvelope: truncated u32 at {}", off); - uint32_t v; - std::memcpy(&v, buf.data() + off, 4); - return v; -} +/// Byte width the on-chain `LatestOutboundEnvelope.checksum` field decodes +/// to: `keccak256(encoded_envelope)`, written identically by both program +/// versions (standalone `opp_outpost` and integrated `liqsol_core`). +constexpr size_t LATEST_ENVELOPE_CHECKSUM_BYTES = fc::crypto::keccak256::byte_size; + +/// Identifiers of the `LatestOutboundEnvelope` account + the fields the +/// inbound reader consumes. The two program versions declare the struct's +/// fields in a DIFFERENT ORDER: +/// * standalone `opp_outpost` - {epoch_index, checksum, data, bump} +/// * integrated `liqsol_core` - {bump, epoch_index, checksum, data} +/// so the reader decodes the WHOLE account through libfc's IDL-driven +/// `decode_account_data` (which follows the loaded IDL's declared field order +/// and verifies the Anchor discriminator) instead of hand-deriving byte +/// offsets. Historical context: hardcoded STANDALONE offsets (epoch@8) +/// decoding the INTEGRATED account produced the epoch=511 RCA - the +/// integrated layout puts `bump`=0xFF at byte 8 and `epoch_index`=1 at byte +/// 9, so a u32 read at byte 8 yields 0xFF | (1<<8) = 511. +namespace latest_envelope { + constexpr auto account_name = "LatestOutboundEnvelope"; + constexpr auto field_epoch = "epoch_index"; + constexpr auto field_data = "data"; + constexpr auto field_checksum = "checksum"; +} // namespace latest_envelope } // anonymous namespace @@ -75,6 +76,258 @@ std::optional sol_pubkey_from_chain_addr } // anonymous namespace (within outpost_solana_client_detail) +namespace { + +/// Render an IDL type for diagnostics. `idl_type::to_string()` assumes the +/// per-kind payload members are populated (it dereferences them without +/// checking), so a malformed type object must be reported instead of +/// formatted - the diagnostic path is exactly where malformed inputs appear. +std::string describe_idl_type(const fc::network::solana::idl::idl_type& type) { + if ((type.is_primitive() && !type.primitive) || + (type.is_defined() && !type.defined_name) || + (type.is_option() && !type.option_inner) || + (type.is_vec() && !type.vec_element) || + (type.is_array() && (!type.array_element || !type.array_len)) || + (type.is_tuple() && !type.tuple_elements)) { + return ""; + } + return type.to_string(); +} + +} // anonymous namespace (within outpost_solana_client_detail) + +/// Assert the loaded IDL declares `LatestOutboundEnvelope` with the field +/// types the inbound reader relies on. Field order is unconstrained (the +/// reader decodes through the IDL at runtime). Full contract on the header +/// declaration. +void assert_latest_envelope_shape(const fc::network::solana::idl::program& program) { + namespace idl = fc::network::solana::idl; + const idl::account* account = program.find_account(latest_envelope::account_name); + FC_ASSERT(account, "IDL has no '{}' account definition", latest_envelope::account_name); + + // Anchor IDL v2 keeps the struct's fields in the `types` section; the + // `accounts` entry carries only name + discriminator. Mirror + // `solana_program_client::decode_account_data`'s inline-then-types fallback. + const std::vector* fields = &account->fields; + if (fields->empty()) { + const idl::type_def* type_def = program.find_type(latest_envelope::account_name); + FC_ASSERT(type_def && type_def->is_struct() && type_def->struct_fields, + "IDL '{}' has no struct field definition", latest_envelope::account_name); + fields = &(*type_def->struct_fields); + } + + bool has_epoch = false; + bool has_data = false; + for (const auto& field : *fields) { + // Compare against the optional member directly: a malformed type object + // (kind==primitive but no primitive value) must produce the per-field + // diagnostic below, not `get_primitive()`'s generic throw. + if (field.name == latest_envelope::field_epoch) { + // The reader narrows the decoded value to uint32; a differently + // declared width means the IDL disagrees with the on-chain struct. + FC_ASSERT(field.type.is_primitive() && field.type.primitive == idl::primitive_type::u32, + "LatestOutboundEnvelope '{}' must be declared u32, got '{}'", + latest_envelope::field_epoch, describe_idl_type(field.type)); + has_epoch = true; + } else if (field.name == latest_envelope::field_data) { + // The payload must be length-prefixed `bytes` / `Vec`. A fixed + // `[u8; N]` array would decode, but cannot represent the on-chain + // variable-length envelope - such an IDL disagrees with both known + // program versions, so refuse it at boot. + const bool is_bytes = field.type.is_primitive() && + field.type.primitive == idl::primitive_type::bytes; + const bool is_vec_u8 = field.type.is_vec() && field.type.vec_element && + field.type.vec_element->is_primitive() && + field.type.vec_element->primitive == idl::primitive_type::u8; + FC_ASSERT(is_bytes || is_vec_u8, + "LatestOutboundEnvelope '{}' must be declared bytes/Vec, got '{}'", + latest_envelope::field_data, describe_idl_type(field.type)); + has_data = true; + } + } + FC_ASSERT(has_epoch, "LatestOutboundEnvelope IDL missing '{}' field", latest_envelope::field_epoch); + FC_ASSERT(has_data, "LatestOutboundEnvelope IDL missing '{}' field", latest_envelope::field_data); +} + +/// Keep only the candidate IDLs whose declared address matches the deployed +/// program id, so `--solana-idl-file` ORDER can never decide which same-named +/// IDL version's field order drives account decoding. Full contract on the +/// header declaration. +std::vector +select_program_idls_matching(std::vector program_idls, + const fc::network::solana::solana_public_key& program_id) { + namespace idl = fc::network::solana::idl; + std::vector matching; + size_t declared_addresses = 0; + for (auto& candidate : program_idls) { + if (candidate.address.empty()) + continue; + ++declared_addresses; + try { + if (fc::network::solana::solana_public_key::from_base58_string(candidate.address) == program_id) { + // Moved-from entries are only left behind when `matching` is + // non-empty, in which case `program_idls` is never returned. + matching.push_back(std::move(candidate)); + } + } catch (const fc::exception&) { + wlog("IDL '{}' declares unparseable program address '{}'; treating as non-matching", + candidate.name, candidate.address); + } + } + if (!matching.empty()) { + if (matching.size() < program_idls.size()) { + ilog("selected {} of {} loaded IDL(s) whose declared address matches program id {}", + matching.size(), program_idls.size(), + program_id.to_string(fc::yield_function_t{})); + } + return matching; + } + if (program_idls.size() == 1) { + // Address-less stub/dev IDLs stay usable; a declared-but-mismatched + // address is suspicious but unambiguous, so warn instead of refusing. + if (declared_addresses > 0) { + wlog("the single loaded IDL '{}' declares address {} which does not match the configured " + "program id {}; using it anyway - verify --solana-idl-file matches the deployment", + program_idls.front().name, program_idls.front().address, + program_id.to_string(fc::yield_function_t{})); + } + return program_idls; + } + FC_ASSERT(false, + "{} IDLs are loaded for this program name but none declares address {}; " + "which one to trust would depend on --solana-idl-file order, and a wrong pick " + "silently misreads accounts. Load only the IDL generated from the deployed " + "program, or ensure it declares the deployed address", + program_idls.size(), program_id.to_string(fc::yield_function_t{})); +} + +/// Extract raw payload bytes from a decoded `bytes` (base64 string variant) +/// or `Vec` (integer array variant) field. Full contract on the header +/// declaration. +std::vector borsh_payload_bytes(const fc::variant& field_value) { + if (field_value.is_string()) + return fc::base64_decode(field_value.as_string()); + FC_ASSERT(field_value.is_array(), + "payload field decoded to neither a base64 string (bytes) nor a byte array (Vec)"); + const auto& arr = field_value.get_array(); + std::vector out; + out.reserve(arr.size()); + for (const auto& element : arr) { + const auto value = element.as_uint64(); + FC_ASSERT(value <= 0xFF, "payload array element {} is out of byte range", value); + out.push_back(static_cast(value)); + } + return out; +} + +/// Decode + validate a fetched `LatestOutboundEnvelope` account through the +/// loaded IDL. Full contract on the header declaration. +std::vector decode_latest_envelope_account(opp_solana_outpost_client& program_client, + const std::vector& account_data, + uint32_t epoch_index, + const std::string& log_label) { + uint32_t stored_epoch = 0; + std::vector envelope_bytes; + bool checksum_ok = true; + try { + // IDL-driven decode: verifies the 8-byte Anchor discriminator and + // follows the loaded IDL's declared field order, so the standalone and + // integrated `LatestOutboundEnvelope` layouts both decode value-exactly. + const auto decoded = program_client.decode_account_info_data(latest_envelope::account_name, + account_data); + const auto& obj = decoded.get_object(); + stored_epoch = static_cast(obj[latest_envelope::field_epoch].as_uint64()); + if (stored_epoch != 0 && stored_epoch == epoch_index) { + envelope_bytes = borsh_payload_bytes(obj[latest_envelope::field_data]); + // Both program versions store `checksum = keccak256(encoded_envelope)`. + // Verifying it proves the bytes extracted as `data` are the bytes the + // program hashed - i.e. the IDL's field order matches the deployment. + // An IDL that omits the field (or declares an unrecognized shape) + // skips the check rather than failing envelopes it cannot verify. + if (obj.contains(latest_envelope::field_checksum)) { + const auto& checksum_v = obj[latest_envelope::field_checksum]; + if (checksum_v.is_array() && checksum_v.get_array().size() == LATEST_ENVELOPE_CHECKSUM_BYTES) { + const auto& stored = checksum_v.get_array(); + const auto actual = fc::crypto::keccak256::hash(std::span( + reinterpret_cast(envelope_bytes.data()), envelope_bytes.size())); + for (size_t i = 0; i < LATEST_ENVELOPE_CHECKSUM_BYTES; ++i) { + if (static_cast(stored[i].as_uint64()) != actual.data()[i]) { + checksum_ok = false; + break; + } + } + } + } + } + } catch (const fc::exception& e) { + // Permanently undecodable account bytes are an IDL-vs-deployment drift + // signal, not a transient condition - keep this visible at default log + // level so a stalled relay is diagnosable. + wlog("outpost_solana_client[{}]: cannot decode latest_outbound_envelope account " + "({} bytes) through the loaded IDL - IDL/deployment drift? {}", + log_label, account_data.size(), e.to_detail_string()); + return {}; + } + + if (stored_epoch == 0) { + // Initialized state: outpost has not emitted any envelope yet. + // Expected during cluster warm-up; resolves on the next emit. + dlog("outpost_solana_client[{}]: latest_outbound_envelope unwritten (epoch=0)", log_label); + return {}; + } + if (stored_epoch != epoch_index) { + if (stored_epoch > epoch_index) { + // The outpost claims an epoch AHEAD of the one this relay is trying + // to read - either the account is being misread (IDL/deployment + // field-order drift; the epoch=511 RCA surfaced exactly here) or the + // relay's WIRE view is far behind. Neither self-heals quickly, so + // stay visible at default log level. + wlog("outpost_solana_client[{}]: latest_outbound_envelope stored_epoch={} is AHEAD of " + "requested {} - possible IDL/deployment drift misreading the account", + log_label, stored_epoch, epoch_index); + } else { + // Timing skew between the WIRE batch op and the outpost's emit + // cadence. Resolves on the next poll once the outpost catches up; + // kept at debug so steady-state polling isn't noisy (matches the + // ethereum sibling's identical branch). + dlog("outpost_solana_client[{}]: latest_outbound_envelope stored_epoch={} != requested {}", + log_label, stored_epoch, epoch_index); + } + return {}; + } + if (!checksum_ok) { + wlog("outpost_solana_client[{}]: latest_outbound_envelope checksum does not match " + "keccak256 of the decoded payload ({} bytes) - IDL/deployment drift?", + log_label, envelope_bytes.size()); + return {}; + } + if (envelope_bytes.size() > SOLANA_MAX_ENVELOPE_BYTES) { + wlog("outpost_solana_client[{}]: latest_outbound_envelope data length " + "{} exceeds envelope cap of {} bytes", + log_label, envelope_bytes.size(), SOLANA_MAX_ENVELOPE_BYTES); + return {}; + } + + sysio::opp::Envelope envelope; + if (!envelope.ParseFromArray(envelope_bytes.data(), + static_cast(envelope_bytes.size()))) { + wlog("outpost_solana_client[{}]: latest_outbound_envelope did not " + "decode as a protobuf Envelope ({} bytes)", + log_label, envelope_bytes.size()); + return {}; + } + if (static_cast(envelope.epoch_index()) != epoch_index) { + wlog("outpost_solana_client[{}]: latest_outbound_envelope inner " + "epoch={} != requested epoch={}", + log_label, envelope.epoch_index(), epoch_index); + return {}; + } + + ilog("outpost_solana_client[{}]: read inbound envelope for epoch {} ({} bytes)", + log_label, epoch_index, envelope_bytes.size()); + return envelope_bytes; +} + /// Append a terminal remaining-account meta, merging permissions when a /// previous effect branch already added the same pubkey. void record_terminal_account(std::vector& metas, @@ -409,7 +662,8 @@ outpost_solana_client::outpost_solana_client( fc::network::solana::solana_public_key program_id, std::vector program_idls, uint64_t chain_code, - uint32_t chain_id) + uint32_t chain_id, + solana_outpost_role role) : _entry(std::move(entry)) , _program_id(program_id) , _outpost_id(chain_code) @@ -419,8 +673,28 @@ outpost_solana_client::outpost_solana_client( FC_ASSERT(!program_idls.empty(), "Solana outpost requires at least one program IDL"); + // Reduce same-named IDL versions to the one(s) whose declared address + // matches the deployed program id BEFORE the program client caches its + // decode-driving IDL, so `--solana-idl-file` order can never decide which + // field order accounts are decoded with. + program_idls = + outpost_solana_client_detail::select_program_idls_matching(std::move(program_idls), _program_id); + _program_client = std::make_shared( _entry->client, _program_id, program_idls); + + // Role-gated boot validation: only roles that read inbound envelopes need + // a decodable `LatestOutboundEnvelope` in the IDL. For those roles the + // shape is asserted HERE so a misshaped IDL throws at boot + // (`create_outpost_client`) rather than on the first inbound poll - the + // poll loop wlogs and retries forever, which would hide the misconfig. + // The IDL is immutable after construction, so the check can never go stale. + if (role == solana_outpost_role::batch_operator) { + FC_ASSERT(_program_client->get_program(), + "outpost_solana_client: no IDL program loaded; cannot validate " + "the LatestOutboundEnvelope declaration"); + outpost_solana_client_detail::assert_latest_envelope_shape(*_program_client->get_program()); + } } sysio::opp::types::ChainKind outpost_solana_client::chain_kind() const { @@ -699,7 +973,7 @@ std::vector outpost_solana_client::read_inbound_envelope( _program_client->latest_outbound_envelope_pda, fc::network::solana::commitment_t::finalized); if (!info.has_value()) { - // PDA was init'd at outpost initialize — absence here means the + // PDA was init'd at outpost initialize - absence here means the // RPC is out of sync or the program redeployed mid-run. Surface. wlog("outpost_solana_client[{}]: latest_outbound_envelope PDA absent", to_string()); @@ -710,69 +984,26 @@ std::vector outpost_solana_client::read_inbound_envelope( to_string()); return {}; } - - const auto& buf = info->data; - dlog("outpost_solana_client[{}]: latest_outbound_envelope account_size={}", - to_string(), buf.size()); - if (buf.size() < LATEST_DATA_OFF) { - wlog("outpost_solana_client[{}]: latest_outbound_envelope account is " - "smaller than expected header ({} bytes)", - to_string(), buf.size()); + if (info->owner != _program_id) { + // A program upgrade that re-homed the seed (or an RPC serving a stale + // fork) would otherwise decode foreign account bytes into an + // undiagnosable stall - cheap defense-in-depth before decoding. + wlog("outpost_solana_client[{}]: latest_outbound_envelope PDA is owned by {} " + "instead of program {}; refusing to decode", + to_string(), info->owner.to_string(fc::yield_function_t{}), + _program_id.to_string(fc::yield_function_t{})); return {}; } - const uint32_t stored_epoch = read_u32_le(buf, LATEST_EPOCH_OFF); - if (stored_epoch == 0) { - // Initialized state: outpost has not emitted any envelope yet. - // Expected during cluster warm-up; resolves on the next emit. - dlog("outpost_solana_client[{}]: latest_outbound_envelope unwritten (epoch=0)", - to_string()); - return {}; - } - if (stored_epoch != epoch_index) { - // Timing skew between the WIRE batch op and the outpost's emit - // cadence. Resolves on the next poll once the outpost catches up. - dlog("outpost_solana_client[{}]: latest_outbound_envelope stored_epoch={} != requested {}", - to_string(), stored_epoch, epoch_index); - return {}; - } - - const uint32_t data_len = read_u32_le(buf, LATEST_VEC_LEN_OFF); - if (data_len > SOLANA_MAX_ENVELOPE_BYTES) { - wlog("outpost_solana_client[{}]: latest_outbound_envelope data length " - "{} exceeds envelope cap of {} bytes", - to_string(), data_len, SOLANA_MAX_ENVELOPE_BYTES); - return {}; - } - if (LATEST_DATA_OFF + data_len > buf.size()) { - wlog("outpost_solana_client[{}]: latest_outbound_envelope data length " - "{} exceeds account size {}", - to_string(), data_len, buf.size()); - return {}; - } - - std::vector envelope_bytes( - reinterpret_cast(buf.data() + LATEST_DATA_OFF), - reinterpret_cast(buf.data() + LATEST_DATA_OFF + data_len)); - - sysio::opp::Envelope envelope; - if (!envelope.ParseFromArray(envelope_bytes.data(), - static_cast(envelope_bytes.size()))) { - wlog("outpost_solana_client[{}]: latest_outbound_envelope did not " - "decode as a protobuf Envelope ({} bytes)", - to_string(), envelope_bytes.size()); - return {}; - } - if (static_cast(envelope.epoch_index()) != epoch_index) { - wlog("outpost_solana_client[{}]: latest_outbound_envelope inner " - "epoch={} != requested epoch={}", - to_string(), envelope.epoch_index(), epoch_index); - return {}; - } - - ilog("outpost_solana_client[{}]: read inbound envelope for epoch {} ({} bytes)", - to_string(), epoch_index, envelope_bytes.size()); - return envelope_bytes; + // Decode the fetched account through the loaded IDL: the IDL's declared + // field order drives the decode, so the same nodeop reads both the + // standalone `opp_outpost` and integrated `liqsol_core` + // `LatestOutboundEnvelope` layouts value-exactly (same path the class + // already uses for `Reserve` / `OutpostConfig`). For inbound-reading roles + // the account declaration was validated at boot, so a decode failure here + // signals IDL-vs-deployment drift and is logged at warning level. + return outpost_solana_client_detail::decode_latest_envelope_account( + *_program_client, info->data, epoch_index, to_string()); } std::string outpost_solana_client::uw_commit( diff --git a/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp b/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp index 1ba6f61387..26604fe313 100644 --- a/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp +++ b/plugins/outpost_solana_client_plugin/src/outpost_solana_client_plugin.cpp @@ -149,10 +149,11 @@ outpost_solana_client_plugin::get_idl_files() { } std::shared_ptr -outpost_solana_client_plugin::create_outpost_client(const std::string& sol_client_id, - uint64_t chain_code, - uint32_t chain_id, - const std::string& program_id) { +outpost_solana_client_plugin::create_outpost_client(const std::string& sol_client_id, + uint64_t chain_code, + uint32_t chain_id, + const std::string& program_id, + solana_outpost_role role) { auto entry = my->get_client(sol_client_id); FC_ASSERT(entry, "Unknown solana client id: {}", sol_client_id); FC_ASSERT(!program_id.empty(), "Solana program id is required"); @@ -170,7 +171,7 @@ outpost_solana_client_plugin::create_outpost_client(const std::string& sol_clien option_outpost_program_name); return std::make_shared( - entry, program_key, std::move(program_idls), chain_code, chain_id); + entry, program_key, std::move(program_idls), chain_code, chain_id, role); } std::vector filter_outpost_program_idls( diff --git a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp index b195db73b6..dab1152ae1 100644 --- a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp +++ b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include #include #include #include @@ -15,8 +17,11 @@ #include #include +#include +#include #include #include +#include using namespace std::literals; using namespace fc::network::solana; @@ -532,9 +537,11 @@ BOOST_AUTO_TEST_CASE(opp_outpost_emit_has_wire_epoch_arg) try { BOOST_CHECK_EQUAL(emit->args[0].name, "wire_epoch_index"); } FC_LOG_AND_RETHROW(); -BOOST_AUTO_TEST_CASE(opp_outpost_has_initialize_and_add_attestation) try { +BOOST_AUTO_TEST_CASE(opp_outpost_has_initialize_and_deposit) try { auto prog = load_idl_fixture(opp_outpost_idl_fixture); + // `add_attestation` was retired from the client (attestations flow through + // `commit_underwrite` / `epoch_in`); the fixture must not resurrect it. bool has_initialize = false, has_add = false, has_deposit = false; for (auto& instr : prog.instructions) { if (instr.name == "initialize") has_initialize = true; @@ -542,7 +549,7 @@ BOOST_AUTO_TEST_CASE(opp_outpost_has_initialize_and_add_attestation) try { if (instr.name == "deposit") has_deposit = true; } BOOST_CHECK(has_initialize); - BOOST_CHECK(has_add); + BOOST_CHECK(!has_add); BOOST_CHECK(has_deposit); } FC_LOG_AND_RETHROW(); @@ -942,4 +949,412 @@ BOOST_AUTO_TEST_CASE(extract_swap_revert_spl_targets_uses_depositor_pubkey) try BOOST_CHECK(targets[0].recipient.serialize() == depositor); } FC_LOG_AND_RETHROW(); +// ── LatestOutboundEnvelope inbound-read path. The epoch=511 RCA was +// hardcoded STANDALONE offsets (epoch@8) silently misreading the +// INTEGRATED account (`bump`=0xFF at byte 8, `epoch_index`=1 at byte 9 ⇒ +// u32@8 = 0xFF | 1<<8 = 511), stalling the relay without an error. The +// reader now decodes the whole account through libfc's IDL-driven +// `decode_account_data` (declared field order + Anchor discriminator) and +// validates the stored keccak256 checksum, so these tests drive the +// complete post-fetch read path against synthesized accounts for BOTH +// program layouts, plus every reject path. + +namespace { + +using sysio::outpost_solana_client_detail::assert_latest_envelope_shape; +using sysio::outpost_solana_client_detail::borsh_payload_bytes; +using sysio::outpost_solana_client_detail::decode_latest_envelope_account; +using sysio::outpost_solana_client_detail::select_program_idls_matching; + +/// Shorthand for a primitive IDL type. +idl::idl_type prim(idl::primitive_type p) { return idl::idl_type::make_primitive(p); } + +/// `[u8; 32]` — the checksum field's declared shape in both known layouts. +idl::idl_type u8_32_array() { return idl::idl_type::make_array(prim(idl::primitive_type::u8), 32); } + +/// Field list of the standalone `opp_outpost` layout: +/// {epoch_index, checksum, data(bytes), bump}. +std::vector standalone_latest_fields() { + return {{"epoch_index", prim(idl::primitive_type::u32)}, + {"checksum", u8_32_array()}, + {"data", prim(idl::primitive_type::bytes)}, + {"bump", prim(idl::primitive_type::u8)}}; +} + +/// Field list of the integrated `liqsol_core` layout: +/// {bump, epoch_index, checksum, data(Vec)}. Declaring `data` as +/// `Vec` (vs the standalone `bytes`) also exercises the second decoded +/// payload shape (integer array vs base64 string). +std::vector integrated_latest_fields() { + return {{"bump", prim(idl::primitive_type::u8)}, + {"epoch_index", prim(idl::primitive_type::u32)}, + {"checksum", u8_32_array()}, + {"data", idl::idl_type::make_vec(prim(idl::primitive_type::u8))}}; +} + +/// Declare `LatestOutboundEnvelope` with `fields` on `prog` — inline on the +/// account (legacy IDL shape) or via the `types` section (Anchor IDL v2 +/// keeps account struct fields there). +void declare_latest_envelope(idl::program& prog, std::vector fields, + bool fields_in_types_section) { + idl::account account; + account.name = "LatestOutboundEnvelope"; + account.compute_discriminator(); + if (fields_in_types_section) { + idl::type_def def; + def.name = account.name; + def.struct_fields = std::move(fields); + prog.types.push_back(std::move(def)); + } else { + account.fields = std::move(fields); + } + prog.accounts.push_back(std::move(account)); +} + +/// Build a minimal synthetic program declaring only `LatestOutboundEnvelope`. +idl::program latest_envelope_program(std::vector fields, bool fields_in_types_section) { + idl::program prog; + prog.name = "layout_fixture"; + declare_latest_envelope(prog, std::move(fields), fields_in_types_section); + return prog; +} + +/// Stub outpost program (real instruction set, so `opp_solana_outpost_client` +/// constructs) + a synthesized `LatestOutboundEnvelope` declaration. +idl::program outpost_program_with_latest(std::vector fields) { + auto prog = load_idl_fixture(opp_outpost_idl_fixture); + declare_latest_envelope(prog, std::move(fields), /*fields_in_types_section=*/true); + return prog; +} + +/// Serialized protobuf Envelope carrying `epoch` — the payload the outpost +/// program stores in `LatestOutboundEnvelope.data`. +std::vector envelope_payload_bytes(uint32_t epoch) { + sysio::opp::Envelope env; + env.set_epoch_index(epoch); + std::string buf; + env.SerializeToString(&buf); + return std::vector(buf.begin(), buf.end()); +} + +/// `keccak256(payload)` — the checksum both program versions store. +std::array keccak_checksum(const std::vector& payload) { + const auto hash = + fc::crypto::keccak256::hash(std::span(payload.data(), payload.size())); + std::array out{}; + std::memcpy(out.data(), hash.data(), out.size()); + return out; +} + +/// Serialize a STANDALONE-layout account: disc + {epoch, checksum, data, bump}. +std::vector standalone_latest_account(uint32_t epoch, + const std::vector& payload, + const std::array& checksum) { + borsh::encoder enc; + const auto disc = idl::compute_account_discriminator("LatestOutboundEnvelope"); + enc.write_fixed_bytes(disc.data(), disc.size()); + enc.write_u32(epoch); + enc.write_fixed_bytes(checksum.data(), checksum.size()); + enc.write_bytes(payload); + enc.write_u8(0xFF); // bump + return enc.data(); +} + +/// Serialize an INTEGRATED-layout account: disc + {bump, epoch, checksum, data}. +/// `bump`=0xFF reproduces the epoch=511 RCA input when misread as standalone. +std::vector integrated_latest_account(uint32_t epoch, + const std::vector& payload, + const std::array& checksum) { + borsh::encoder enc; + const auto disc = idl::compute_account_discriminator("LatestOutboundEnvelope"); + enc.write_fixed_bytes(disc.data(), disc.size()); + enc.write_u8(0xFF); // bump + enc.write_u32(epoch); + enc.write_fixed_bytes(checksum.data(), checksum.size()); + enc.write_bytes(payload); + return enc.data(); +} + +/// Drive the complete post-fetch read path: construct the typed outpost +/// client around a stub program declaring `fields`, then decode +/// `account_bytes` asking for `requested_epoch`. No RPC endpoint is needed — +/// decode is pure and the null client is never dereferenced. +std::vector decode_with_layout(std::vector fields, + const std::vector& account_bytes, + uint32_t requested_epoch) { + const auto prog = outpost_program_with_latest(std::move(fields)); + sysio::opp_solana_outpost_client program_client( + solana_client_ptr{}, measurement_pubkey(42), {prog}); + return decode_latest_envelope_account(program_client, account_bytes, requested_epoch, "test"); +} + +/// Compare a decoded envelope byte vector against the synthesized payload. +bool bytes_equal(const std::vector& actual, const std::vector& expected) { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin(), + [](char a, uint8_t b) { return static_cast(a) == b; }); +} + +/// Name-and-address-only IDL program for selection tests. +idl::program named_program(std::string address) { + idl::program prog; + prog.name = "opp_outpost"; + prog.address = std::move(address); + return prog; +} + +} // anonymous namespace + +BOOST_AUTO_TEST_CASE(latest_envelope_shape_accepts_known_layouts) try { + // Field ORDER is unconstrained — the reader decodes through the IDL at + // runtime — so both known layouts pass, in both IDL field homes. + for (bool in_types : {false, true}) { + BOOST_TEST_CONTEXT("fields_in_types_section=" << in_types) { + BOOST_CHECK_NO_THROW( + assert_latest_envelope_shape(latest_envelope_program(standalone_latest_fields(), in_types))); + BOOST_CHECK_NO_THROW( + assert_latest_envelope_shape(latest_envelope_program(integrated_latest_fields(), in_types))); + // The IDL-driven decoder follows the declared field order at runtime, + // so a variable-length field ahead of the payload or an epoch-after- + // data order still decodes: the shape check validates field TYPES, + // never field ORDER, and must accept these. + BOOST_CHECK_NO_THROW(assert_latest_envelope_shape(latest_envelope_program( + {{"note", prim(idl::primitive_type::string)}, + {"data", prim(idl::primitive_type::bytes)}, + {"epoch_index", prim(idl::primitive_type::u32)}}, + in_types))); + } + } +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(latest_envelope_shape_rejects_misdeclared_idls) try { + struct reject_case { + const char* name; + std::vector fields; + }; + + std::vector cases; + cases.push_back({"missing epoch_index field", + {{"bump", prim(idl::primitive_type::u8)}, + {"checksum", u8_32_array()}, + {"data", prim(idl::primitive_type::bytes)}}}); + cases.push_back({"missing data field", + {{"epoch_index", prim(idl::primitive_type::u32)}, + {"checksum", u8_32_array()}, + {"bump", prim(idl::primitive_type::u8)}}}); + // A wrong declared width means the IDL disagrees with the on-chain u32. + cases.push_back({"epoch_index declared u16", + {{"epoch_index", prim(idl::primitive_type::u16)}, + {"data", prim(idl::primitive_type::bytes)}}}); + cases.push_back({"epoch_index declared u64", + {{"epoch_index", prim(idl::primitive_type::u64)}, + {"data", prim(idl::primitive_type::bytes)}}}); + // A fixed `[u8; N]` payload cannot represent the variable-length envelope. + cases.push_back({"data declared fixed [u8; 64] array", + {{"epoch_index", prim(idl::primitive_type::u32)}, + {"data", idl::idl_type::make_array(prim(idl::primitive_type::u8), 64)}}}); + // A malformed type object (primitive kind without a primitive value) must + // hit the per-field diagnostic, not crash formatting it. + cases.push_back({"data declared with malformed type object", + {{"epoch_index", prim(idl::primitive_type::u32)}, + {"data", idl::idl_type{}}}}); + + for (const auto& c : cases) { + BOOST_TEST_CONTEXT(c.name) { + for (bool in_types : {false, true}) { + BOOST_CHECK_THROW( + assert_latest_envelope_shape(latest_envelope_program(c.fields, in_types)), + fc::assert_exception); + } + } + } + + // No `LatestOutboundEnvelope` account declared at all. + BOOST_CHECK_THROW(assert_latest_envelope_shape(idl::program{}), fc::assert_exception); + // Account declared but with no field definition anywhere (inline or types). + BOOST_CHECK_THROW(assert_latest_envelope_shape(latest_envelope_program({}, false)), + fc::assert_exception); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(decode_latest_envelope_reads_both_known_layouts) try { + constexpr uint32_t epoch = 7; + const auto payload = envelope_payload_bytes(epoch); + const auto checksum = keccak_checksum(payload); + + // Standalone layout (data declared `bytes` → base64-string decode path). + const auto standalone = decode_with_layout( + standalone_latest_fields(), standalone_latest_account(epoch, payload, checksum), epoch); + BOOST_CHECK(bytes_equal(standalone, payload)); + + // Integrated layout (data declared `Vec` → integer-array decode path). + // Byte-identical result from a DIFFERENT serialized field order is the + // property the epoch=511 RCA violated. + const auto integrated = decode_with_layout( + integrated_latest_fields(), integrated_latest_account(epoch, payload, checksum), epoch); + BOOST_CHECK(bytes_equal(integrated, payload)); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(decode_latest_envelope_catches_field_order_drift) try { + // The epoch=511 RCA input: an INTEGRATED account (bump=0xFF first) read + // by a client whose loaded IDL declares the STANDALONE field order. The + // decode either trips a Borsh bound (vec length read from misaligned + // bytes) or yields stored_epoch=511 ≠ requested — both must reject. + constexpr uint32_t epoch = 1; + const auto payload = envelope_payload_bytes(epoch); + const auto checksum = keccak_checksum(payload); + const auto integrated_account = integrated_latest_account(epoch, payload, checksum); + + BOOST_CHECK(decode_with_layout(standalone_latest_fields(), integrated_account, epoch).empty()); + // Even a caller asking for exactly the misread value (511 = 0xFF | 1<<8) + // must not be handed drift-garbage bytes. + BOOST_CHECK(decode_with_layout(standalone_latest_fields(), integrated_account, 511).empty()); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(decode_latest_envelope_rejects_invalid_accounts) try { + constexpr uint32_t epoch = 7; + const auto payload = envelope_payload_bytes(epoch); + const auto checksum = keccak_checksum(payload); + const auto valid = integrated_latest_account(epoch, payload, checksum); + + // Wrong Anchor discriminator (foreign or re-homed account bytes). + { + auto corrupted = valid; + corrupted[0] ^= 0x01; + BOOST_CHECK(decode_with_layout(integrated_latest_fields(), corrupted, epoch).empty()); + } + // Truncated account. + { + auto truncated = valid; + truncated.resize(truncated.size() / 2); + BOOST_CHECK(decode_with_layout(integrated_latest_fields(), truncated, epoch).empty()); + } + // Unwritten sentinel (epoch=0) and stale/ahead epochs. + BOOST_CHECK(decode_with_layout(integrated_latest_fields(), + integrated_latest_account(0, payload, checksum), 0).empty()); + BOOST_CHECK(decode_with_layout(integrated_latest_fields(), valid, epoch + 1).empty()); + BOOST_CHECK(decode_with_layout(integrated_latest_fields(), valid, epoch - 1).empty()); + // Stored checksum disagreeing with keccak256(data) — the drift tripwire. + { + auto bad_checksum = checksum; + bad_checksum[0] ^= 0x01; + BOOST_CHECK(decode_with_layout(integrated_latest_fields(), + integrated_latest_account(epoch, payload, bad_checksum), + epoch).empty()); + } + // Payload that is not a protobuf Envelope (checksum matches the garbage, + // so this exercises the protobuf gate, not the checksum gate). + { + const std::vector garbage = {0xFF, 0xFF, 0xFF, 0xFF}; + BOOST_CHECK(decode_with_layout(integrated_latest_fields(), + integrated_latest_account(epoch, garbage, keccak_checksum(garbage)), + epoch).empty()); + } + // Envelope whose INNER epoch disagrees with the stored account epoch. + { + const auto inner_mismatch = envelope_payload_bytes(epoch + 1); + BOOST_CHECK(decode_with_layout(integrated_latest_fields(), + integrated_latest_account(epoch, inner_mismatch, + keccak_checksum(inner_mismatch)), + epoch).empty()); + } +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(borsh_payload_bytes_accepts_both_decoded_shapes) try { + const std::vector payload = {0x12, 0x00, 0xFF, 0x42}; + + // `bytes` decodes to a base64 string variant. + const auto b64 = fc::base64_encode(reinterpret_cast(payload.data()), + static_cast(payload.size())); + BOOST_CHECK(bytes_equal(borsh_payload_bytes(fc::variant(b64)), payload)); + + // `Vec` decodes to an integer array variant. + fc::variants arr; + for (auto b : payload) arr.push_back(fc::variant(static_cast(b))); + BOOST_CHECK(bytes_equal(borsh_payload_bytes(fc::variant(arr)), payload)); + + // Out-of-byte-range element and non-payload shapes must throw. + fc::variants oversized = {fc::variant(static_cast(256))}; + BOOST_CHECK_THROW(borsh_payload_bytes(fc::variant(oversized)), fc::assert_exception); + BOOST_CHECK_THROW(borsh_payload_bytes(fc::variant(static_cast(7))), fc::assert_exception); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(libfc_decode_renders_bytes_base64_and_vec_u8_as_array) try { + // `borsh_payload_bytes` depends on HOW libfc's IDL decoder renders the + // payload field into a variant: `bytes` as a base64 STRING, `Vec` as + // an integer ARRAY. That rendering is an internal libfc convention, not a + // documented contract - this test pins it against `decode_account_info_data` + // itself so a future libfc rendering change fails HERE at test time + // instead of silently breaking the envelope reader at runtime. + constexpr uint32_t epoch = 7; + const auto payload = envelope_payload_bytes(epoch); + const auto checksum = keccak_checksum(payload); + + // `bytes` spelling (standalone layout) -> base64 string variant. + { + const auto prog = outpost_program_with_latest(standalone_latest_fields()); + sysio::opp_solana_outpost_client program_client( + solana_client_ptr{}, measurement_pubkey(42), {prog}); + const auto decoded = program_client.decode_account_info_data( + "LatestOutboundEnvelope", standalone_latest_account(epoch, payload, checksum)); + const auto& obj = decoded.get_object(); + BOOST_CHECK_EQUAL(obj["epoch_index"].as_uint64(), epoch); + BOOST_REQUIRE(obj["data"].is_string()); + BOOST_CHECK_EQUAL(obj["data"].as_string(), + fc::base64_encode(reinterpret_cast(payload.data()), + static_cast(payload.size()))); + } + // `Vec` spelling (integrated layout) -> per-element integer array. + { + const auto prog = outpost_program_with_latest(integrated_latest_fields()); + sysio::opp_solana_outpost_client program_client( + solana_client_ptr{}, measurement_pubkey(42), {prog}); + const auto decoded = program_client.decode_account_info_data( + "LatestOutboundEnvelope", integrated_latest_account(epoch, payload, checksum)); + const auto& obj = decoded.get_object(); + BOOST_CHECK_EQUAL(obj["epoch_index"].as_uint64(), epoch); + BOOST_REQUIRE(obj["data"].is_array()); + const auto& arr = obj["data"].get_array(); + BOOST_REQUIRE_EQUAL(arr.size(), payload.size()); + for (size_t i = 0; i < arr.size(); ++i) { + BOOST_CHECK_EQUAL(arr[i].as_uint64(), static_cast(payload[i])); + } + } +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(select_program_idls_prefers_declared_address_match) try { + const auto deployed = measurement_pubkey(90); + const auto other = measurement_pubkey(91); + const auto deployed_b58 = deployed.to_string(fc::yield_function_t{}); + const auto other_b58 = other.to_string(fc::yield_function_t{}); + + // Two same-named IDL versions loaded; the SECOND matches the deployed + // program id. File order must not decide — only the match survives. + { + const auto selected = + select_program_idls_matching({named_program(other_b58), named_program(deployed_b58)}, deployed); + BOOST_REQUIRE_EQUAL(selected.size(), 1u); + BOOST_CHECK_EQUAL(selected.front().address, deployed_b58); + } + // An unparseable declared address is skipped, not fatal, when another + // candidate matches. + { + const auto selected = + select_program_idls_matching({named_program("not-base58!"), named_program(deployed_b58)}, deployed); + BOOST_REQUIRE_EQUAL(selected.size(), 1u); + BOOST_CHECK_EQUAL(selected.front().address, deployed_b58); + } + // A single candidate stays usable without a declared address (stub/dev + // IDLs) and — with a warning — with a mismatched one. + BOOST_CHECK_EQUAL(select_program_idls_matching({named_program("")}, deployed).size(), 1u); + BOOST_CHECK_EQUAL(select_program_idls_matching({named_program(other_b58)}, deployed).size(), 1u); + // Multiple candidates with no address match would make the pick + // order-dependent — exactly the silent-misread risk — so it throws. + BOOST_CHECK_THROW( + select_program_idls_matching({named_program(other_b58), named_program("")}, deployed), + fc::assert_exception); + BOOST_CHECK_THROW( + select_program_idls_matching({named_program(""), named_program("")}, deployed), + fc::assert_exception); +} FC_LOG_AND_RETHROW(); + BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/underwriter_plugin/src/underwriter_plugin.cpp b/plugins/underwriter_plugin/src/underwriter_plugin.cpp index aa77e922ca..2948591800 100644 --- a/plugins/underwriter_plugin/src/underwriter_plugin.cpp +++ b/plugins/underwriter_plugin/src/underwriter_plugin.cpp @@ -886,7 +886,8 @@ struct underwriter_plugin::impl { } else if (ep.kind == ChainKind::CHAIN_KIND_SVM) { outpost_by_chain[chain_code] = sol_plug->create_outpost_client(ep.client_id, chain_code, ext_id, - ep.commit_addr /*program_id*/); + ep.commit_addr /*program_id*/, + solana_outpost_role::underwriter); ilog("underwriter_plugin: wired SOL outpost_client chain={} (client_id='{}', program={})", code_str, ep.client_id, ep.commit_addr); } else { diff --git a/tests/fixtures/solana-idl-opp-outpost-stub.json b/tests/fixtures/solana-idl-opp-outpost-stub.json index 8416fe794d..64d69e0996 100644 --- a/tests/fixtures/solana-idl-opp-outpost-stub.json +++ b/tests/fixtures/solana-idl-opp-outpost-stub.json @@ -310,88 +310,6 @@ } ] }, - { - "name": "add_attestation", - "discriminator": [ - 206, - 82, - 129, - 170, - 54, - 159, - 161, - 156 - ], - "accounts": [ - { - "name": "authority", - "signer": true - }, - { - "name": "config", - "pda": { - "seeds": [ - { - "kind": "const", - "value": [ - 111, - 117, - 116, - 112, - 111, - 115, - 116, - 95, - 99, - 111, - 110, - 102, - 105, - 103 - ] - } - ] - } - }, - { - "name": "message_buffer", - "writable": true, - "pda": { - "seeds": [ - { - "kind": "const", - "value": [ - 109, - 101, - 115, - 115, - 97, - 103, - 101, - 95, - 98, - 117, - 102, - 102, - 101, - 114 - ] - } - ] - } - } - ], - "args": [ - { - "name": "attestation_type", - "type": "i32" - }, - { - "name": "data", - "type": "bytes" - } - ] - }, { "name": "deposit", "discriminator": [