outpost_solana: read inbound envelopes through the IDL decoder#502
outpost_solana: read inbound envelopes through the IDL decoder#502valthon wants to merge 1 commit into
Conversation
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.
cde2a8c to
19083a3
Compare
|
Thanks Kevin — all five addressed in the follow-up commits. 1. Test coverage. Moved 2. Field-type asserts. 3. RCA comment. Fixed — it now reads the correct direction: the removed hardcoded standalone offsets (epoch@8) decoding the integrated account produced epoch=511 ( 4. Resolve at boot. Took your recommendation — the layout is resolved once in the 5. Checked multiply + cross-ref. The fixed-array width multiply is now overflow-checked — a local mirror of libfc's file-local |
|
Reviewed at head 19083a3. The core approach is sound — I confirmed the integrated CorrectnessOffset accumulator can still wrap. Layout binds to the first IDL, never matched to the program id. The layout resolves from IDL-vs-deployment drift still stalls silently. This fixes the case where the operator's IDL file matches the deployed program, but if they drift, the misread lands in the Boot layout assert bricks roles that never read the account. The constructor requires a decodable No discriminator or owner check on the fetched account. Reuse / altitudeThe offset apparatus largely duplicates Coverage
Minor
|
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CbgvM8EhrPL1VjX8RhNze
19083a3 to
aab3575
Compare
|
Thanks — took the reuse direction. Per point:
libfc is untouched. Verified end-to-end — the full OPP flow suite (13/13) is green on both stacks, so the single nodeop binary reads both envelope layouts backwards-compatibly:
|
Problem
nodeop read the on-chain
LatestOutboundEnvelopeaccount with hardcoded Borsh offsets that assumed the standaloneopp_outpostfield order ({epoch_index, checksum, data, bump}—epoch_indexat byte 8, past the 8-byte Anchor discriminator). The foldedliqsol_coreprogram declares{bump, epoch_index, checksum, data}, shiftingepoch_indexto byte 9, so the hardcoded reader decoded the leadingbump(0xFF) as the low byte of the epoch (0xFF | 1<<8 = 511):read_inbound_envelopenever matched the requested epoch, every SOL→depot envelope relay silently stalled, and no reward or deposit reached the depot.Change
Read the account through libfc's IDL-driven
decode_account_data— the same path this class already uses forReserve/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:
addressmatching the deployed program id, so--solana-idl-fileorder can never decide which same-named IDL version's field order drives decoding; multiple candidates with none matching refuse to boot rather than silently pick one.keccak256(encoded_envelope)is verified, so a decode that read the wrong bytes fordata(field-order drift) is caught and rejected instead of stalling.owner != program_idare rejected before decoding.batch_operator) require a decodableLatestOutboundEnvelope; the underwriter (which never reads inbound) is not bricked at boot by an instructions-only IDL.Also retires the now-unused
add_attestationbinding + stub instruction: the foldedliqsol_coreIDL dropped it (attestations flow throughepoch_in/emit_outbound_envelope), it had zero call sites, and binding it viaget_idl("add_attestation")threw at construction against the folded IDL — nodeop now consumes the IDL verbatim.Backwards-compatible: the standalone
opp_outpostlayout still decodes correctly (its field order drives the decode just as the integrated one does), and dropping the never-calledadd_attestationbinding is inert for every program variant.Verification
test_outpost_solana_client_plugin(37 cases): the complete post-fetch read path driven end-to-end against synthesized accounts for both program layouts, the epoch=511 reproduction (integrated bytes read through a standalone-order IDL → rejected), every drift/reject path (bad discriminator, checksum mismatch, epoch-ahead, truncation, oversize, bad protobuf, owner mismatch), the IDL-selection rules, and a pin on libfc'sbytes→base64 rendering the reader depends on.nodeoplinks (thesolana_outpost_rolethreading throughbatch_operator/underwriter).Companion PRs
next-integ, ci: add independent macOS 25 workflow #385) — the foldedliqsol_coreoutpost +--features developmentE2E build.🤖 Generated with Claude Code
https://claude.ai/code/session_015CbgvM8EhrPL1VjX8RhNze