outpost_solana: derive envelope offsets from IDL#502
Conversation
The Solana outpost client decoded the on-chain
LatestOutboundEnvelope account with hardcoded Borsh field offsets
that assumed the standalone opp_outpost layout ({epoch_index,
checksum, data, bump} — epoch_index at byte 8, past the 8-byte
Anchor discriminator). The opp-cleanroom-integrated program folds
the outpost into liqsol_core and declares the struct {bump,
epoch_index, checksum, data}, shifting epoch_index to byte 9. The
hardcoded reader then decoded the leading bump byte as part of the
epoch (bump 0xFF | epoch 1<<8 = 511), so read_inbound_envelope
never matched the requested epoch, every SOL->depot envelope relay
silently stalled, and no reward or deposit reached the depot.
Derive the offsets at runtime from the loaded program IDL's
declared field order instead. resolve_latest_layout walks the
LatestOutboundEnvelope fields, summing the fixed Borsh width of
each leading field (borsh_fixed_size) to locate epoch_index and
the data payload regardless of order; read_inbound_envelope uses
those offsets in place of the former LATEST_* constants. One
nodeop build now reads both the standalone opp_outpost and the
folded liqsol_core layouts correctly. An unexpected layout — a
variable-length field ahead of the payload — fails loudly via
FC_ASSERT rather than misreading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CbgvM8EhrPL1VjX8RhNze
heifner
left a comment
There was a problem hiding this comment.
Verified the offset derivation against both known layouts - the standalone order resolves epoch/vec_len/data to 8/44/48 and the integrated liqsol_core order to 9/45/49 - and that every subsequent buffer read stays bounds-safe (the data_off early-return plus the epoch-precedes-data assert cover the epoch and length reads). Also confirmed caller behavior: outpost_opp_job::run_inbound catches fc::exception, logs, and retries next cycle, so the new asserts fail loudly without taking down nodeop. Deriving from the IDL rather than adding a second hardcoded layout is the right fix.
Comments, roughly in priority order:
-
Test coverage:
resolve_latest_layoutandborsh_fixed_sizeare the heart of the fix but sit in the anonymous namespace with no tests. The plugin already unit-testsoutpost_solana_client_detailfunctions (extract_inbound_recipient_pubkeyset al.), so moving these there enables table-driven cases: both field orders pinned to their exact offsets, a variable-length field beforedatathrows, missingepoch_index/datathrows. Given the RCA was wrong offsets silently stalling the relay, a regression test pinning the offsets for both IDLs is exactly the guard this change exists to provide. -
Fields are located by name only. If a future layout declares
epoch_indexas u16/u64,read_u32_lemisreads it; ifdatawere a fixed[u8; N]there is no Borsh length prefix andvec_len_offwould read payload bytes as a length. Two cheap asserts (epoch_indexis u32,datais bytes/Vec<u8>) would complete the fail-loudly guarantee this PR is built around. -
The RCA comment has the direction inverted: it says "the integrated offsets decoding the standalone account produced the epoch=511 RCA", but the removed constants hardcoded the standalone offsets (epoch at byte 8), and 511 =
0xFF | 1<<8arises when those offsets read the integrated account (bump 0xFF at byte 8, epoch 1 at byte 9). The PR body states it correctly. Worth fixing since this comment is the postmortem record future readers will trust. -
Design question, your call: the comment justifying per-read resolution notes the IDL is immutable after construction. Resolving once in the constructor would fail at boot on an undecodable IDL instead of at the first inbound poll (where the job loop wlogs and retries forever), surfacing operator misconfiguration much earlier. Per-read is defensible given the cost is negligible next to the RPC.
-
Minor:
(*type.array_len) * (*element)inborsh_fixed_sizeis an unchecked multiply. Operator-file trust plus the downstreambuf.size()checks make it low risk, but PR #500 in flight addschecked_mul_sizein libfc for the same shape of computation. Related:borsh_fixed_sizeand #500'smin_borsh_encoded_sizelook near-identical but are semantically different (exact fixed size vs conservative lower bound -stringis nullopt in one, 4 in the other); a cross-referencing comment would keep someone from deduplicating them into a bug later.
reserve_info_for_codes read custody_mint and custody_decimals off the per-reserve Reserve PDA and FC_ASSERTed their presence. The folded liqsol_core Reserve does not carry those fields: it centralizes the mint in OutpostConfig.token_addresses_by_code (keyed by token_code) and handles decimals on-chain via precision_by_token_code. Every WIRE->SOL remit therefore tripped "Reserve account missing custody_mint field" during terminal account derivation, before any epoch_in was submitted, stalling the depot on consensus-retry until the flow timed out. Resolve the custody mint from OutpostConfig.token_addresses_by_code instead -- the exact source the on-chain handle_swap_remit uses (configured_mint_for) -- so the recipient ATA the relay declares on the terminal call matches the one the handler derives. A token_code with no configured row (or an unreadable config) is native and resolves to the system-program marker, matching configured_mint_for's unwrap_or(NATIVE_TOKEN_MARKER); is_native_custody then omits the SPL ATA/mint accounts. Drop the custody_decimals field and its assert: nodeop never consumed the value. The reserve creator is still read from the Reserve PDA (that field exists). Verified end-to-end against the OPP flow suite: the four WIRE->SOL remit flows that previously failed on the missing-field assert (swap-with-underwriting, swap-from-wire, swap-non-native-tokens, swap-private-reserves) now pass, including the real SPL-mint path (terminal finalize carrying the recipient ATA + mint + vault as remaining_accounts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CbgvM8EhrPL1VjX8RhNze
Problem
nodeop's Solana outpost client decoded the on-chain
LatestOutboundEnvelopeaccount with hardcoded Borsh offsets that assumed the standaloneopp_outpostlayout ({epoch_index, checksum, data, bump}—epoch_indexat byte 8, past the 8-byte Anchor discriminator). Theopp-cleanroom-integratedprogram folds the outpost intoliqsol_coreand declares{bump, epoch_index, checksum, data}, shiftingepoch_indexto byte 9. The hardcoded reader then decoded the leadingbumpbyte as part of the epoch (bump 0xFF | epoch 1<<8 = 511), soread_inbound_envelopenever matched the requested epoch, every SOL→depot envelope relay silently stalled, and no reward or deposit reached the depot.Change
Derive the offsets at runtime from the loaded program IDL's declared field order.
resolve_latest_layoutwalks theLatestOutboundEnvelopefields, summing the fixed Borsh width of each leading field (borsh_fixed_size) to locateepoch_indexand thedatapayload regardless of order;read_inbound_envelopeuses those offsets in place of the formerLATEST_*constants. An unexpected layout — a variable-length field ahead of the payload — fails loudly viaFC_ASSERTrather than misreading.Backwards-compatible: one nodeop build now reads both the standalone
opp_outpostand the foldedliqsol_corelayouts correctly. Single-file change; nodeop builds clean.Companion PRs
liqsol_coreoutpost.opp-cleanroom-integrated): thedev_seed_staker_yieldtest helper.🤖 Generated with Claude Code
https://claude.ai/code/session_015CbgvM8EhrPL1VjX8RhNze