Skip to content

outpost_solana: derive envelope offsets from IDL#502

Draft
valthon wants to merge 2 commits into
masterfrom
feat/solana-envelope-layout-versioning
Draft

outpost_solana: derive envelope offsets from IDL#502
valthon wants to merge 2 commits into
masterfrom
feat/solana-envelope-layout-versioning

Conversation

@valthon

@valthon valthon commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Problem

nodeop's Solana outpost client decoded the on-chain LatestOutboundEnvelope account with hardcoded Borsh 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 {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.

Change

Derive the offsets at runtime from the loaded program IDL's declared field order. 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. An unexpected layout — a variable-length field ahead of the payload — fails loudly via FC_ASSERT rather than misreading.

Backwards-compatible: one nodeop build now reads both the standalone opp_outpost and the folded liqsol_core layouts correctly. Single-file change; nodeop builds clean.

Companion PRs

  • wire-tools-ts: harness support for driving the folded liqsol_core outpost.
  • wire-solana (opp-cleanroom-integrated): the dev_seed_staker_yield test helper.

🤖 Generated with Claude Code

https://claude.ai/code/session_015CbgvM8EhrPL1VjX8RhNze

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 heifner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Test coverage: resolve_latest_layout and borsh_fixed_size are the heart of the fix but sit in the anonymous namespace with no tests. The plugin already unit-tests outpost_solana_client_detail functions (extract_inbound_recipient_pubkeys et al.), so moving these there enables table-driven cases: both field orders pinned to their exact offsets, a variable-length field before data throws, missing epoch_index/data throws. 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.

  2. Fields are located by name only. If a future layout declares epoch_index as u16/u64, read_u32_le misreads it; if data were a fixed [u8; N] there is no Borsh length prefix and vec_len_off would read payload bytes as a length. Two cheap asserts (epoch_index is u32, data is bytes/Vec<u8>) would complete the fail-loudly guarantee this PR is built around.

  3. 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<<8 arises 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.

  4. 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.

  5. Minor: (*type.array_len) * (*element) in borsh_fixed_size is an unchecked multiply. Operator-file trust plus the downstream buf.size() checks make it low risk, but PR #500 in flight adds checked_mul_size in libfc for the same shape of computation. Related: borsh_fixed_size and #500's min_borsh_encoded_size look near-identical but are semantically different (exact fixed size vs conservative lower bound - string is 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants