feat(solana-indexer): 7.2 decode settlement instructions into typed events#4612
feat(solana-indexer): 7.2 decode settlement instructions into typed events#4612squadgazzz wants to merge 6 commits into
Conversation
d93e181 to
2503510
Compare
95ca473 to
a2b6041
Compare
a2b6041 to
f3bb264
Compare
tilacog
left a comment
There was a problem hiding this comment.
LGTM, but before merging I'd like to discuss if we should replace our own decoding logic for the parsers from the settlement-interface crate.
| } | ||
| } | ||
|
|
||
| /// Auction id is not carried on the `BeginSettle` wire yet, so every |
There was a problem hiding this comment.
nit: id' put all const declarations at the top of the file
feel free to ignore this comment since some of these are temporary.
There was a problem hiding this comment.
Keeping them next to the code that uses them for now.
|
|
||
| // Instructions decodable on their own, without tx-level pairing. | ||
| for instruction in instructions { | ||
| let Ok((discriminator, _)) = recover_discriminator(&instruction.data) else { |
There was a problem hiding this comment.
I think we can capture the remainder of the instruction bytes here, and pass it to the specific decoder fn after matching on the discriminator.
| let Ok((discriminator, _)) = recover_discriminator(&instruction.data) else { | |
| let Ok((discriminator, raw_instruction)) = recover_discriminator(&instruction.data) else { |
It' will spare downstream fns from having to call the decode fn again in their bodies, which is the case of decode_order_create, for example.
There was a problem hiding this comment.
Done. decode_settlement now keeps the body from recover_discriminator and hands it to decode_order_created, so the decode fn no longer re-runs the split.
| Some(SettlementEvent::OrderCreated { | ||
| order_uid: OrderUid(uid.to_bytes()), | ||
| owner: to_sdk_pubkey(intent.owner), | ||
| created_by, | ||
| }) | ||
| } |
There was a problem hiding this comment.
Just a note for later since this is out of scope for this PR.
Besides the event, this function likely needs to parse the full Order data for the database table eventually.
This likely applies to the other instructions as well.
There was a problem hiding this comment.
True. The events here carry only what the settlement flow needs today (uid, owner, created_by). The full solana.orders row (amounts, tokens, kind, class) lands with the persistence adapter, so I've kept it out of scope for this PR as you suggested.
| SettlementInstruction::CreateOrder => { | ||
| if let Some(event) = decode_order_created(instruction, &ctx.account_keys) { | ||
| events.push(event); | ||
| } | ||
| } | ||
| SettlementInstruction::CreateBuffer => { | ||
| events.extend(decode_buffers_created(instruction, &ctx.account_keys)); | ||
| } | ||
| // Bootstrap only, no domain event. | ||
| SettlementInstruction::Initialize => {} | ||
| // Paired below, once both halves of the settlement are in hand. | ||
| SettlementInstruction::BeginSettle | SettlementInstruction::FinalizeSettle => {} |
There was a problem hiding this comment.
Considering the transaction exist (it didn't fail/revert) I believe we can be pretty confident the ix data is valid so we can expect at least one event out of each of these decode calls.
That said, I'd not fail silently here, and think
decode_order_createdcan return aResultinstead of anOption.decode_buffers_createdanddecode_settlements_finalizedcan returnResult<Vec>(that vec containing at least 1 event, otherwiseErr)
There was a problem hiding this comment.
Done, with one exception: I kept decode_settlements_finalized returning Vec, not Result<Vec> with a >=1 rule. It isn't dispatched per instruction, it scans the whole tx for Begin/Finalize pairs, so zero is the correct answer for a tx with no settlement (a CreateOrder-only tx, for example). It already logs every unpaired or malformed BeginSettle, so nothing is dropped silently. A ">=1-or-Err" rule there would flag every non-settlement tx as an error.
There was a problem hiding this comment.
Since the solana-programs crate is public now, we can try using their instruction parsing types to follow/reuse the Settlement program logic.
For instance, Here's some links for how the CreateOrder instruction is parsed by the program:
InstructionInputParsing::parseCreateOrderInput sructand it'sInstructionInputParsingimpl.- How the Settlement program decodes this ix
- Settlement program's entrypoint (which is very similar to what we're doing in this module)
This applies to the other instructions as well.
There was a problem hiding this comment.
We already reuse the crate's data-only decoders (recover_discriminator, recover_counterpart, decode_and_hash). The account-coupled half (InstructionInputParsing::parse, the *Input structs) we can't: it needs &mut [AccountView], and the indexer runs off-chain with no AccountView, only the tx's pubkeys and account indices.
The one piece we hand-roll is the BeginSettle pull layout, which is the single spot the crate exposes no data-only parser for. I raised that on cowprotocol/solana-programs#63: split the pull parse into a data-only fn that parse_body and the indexer share, so the hand-parse goes away. So there's nothing more to swap in right now, and the real fix is that crate-side parser. Ok to merge as-is and follow up via #63?
|
Claude finished @squadgazzz's task in 4m 13s —— View job PR Review: 7.2 decode settlement instructions into typed events
Reviewed the decoder end-to-end. This is clean, well-documented, and the tests cover the tricky parts (CPI-nested resolution, One point worth confirming — the Everything else checks out — bounds are guarded ( On tilacog's |
There was a problem hiding this comment.
Code Review
This pull request implements decoding of Solana settlement-program instructions into domain events within the solana-indexer crate, adding support for decoding order creation, buffer creation, and finalized settlements, along with unit tests. Feedback is provided regarding the decoding of finalized settlements, where failures to resolve individual orders are currently ignored silently; adding a warning log for unresolved order PDAs is recommended to avoid silent indexing gaps.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| let trades = orders | ||
| .into_iter() | ||
| .filter_map(|order| { | ||
| let order_pda = | ||
| resolve_account(begin, &ctx.account_keys, order.order_pda_position)?; | ||
| let resolved = resolve_order(&order_pda)?; | ||
| Some(TradeDelta { | ||
| order_uid: resolved.order_uid, | ||
| amount_withdrawn_delta: order.amount_withdrawn_delta, | ||
| amount_received_delta: PLACEHOLDER_AMOUNT_RECEIVED, | ||
| order_fulfilled: resolved.order_fulfilled, | ||
| }) | ||
| }) | ||
| .collect(); |
There was a problem hiding this comment.
Silently ignoring resolution failures for individual orders in a batch settlement can lead to incomplete indexing and desynchronized state without any visibility. Instead of silently filtering out unresolved orders with filter_map, log a warning or explicitly track failed resolutions so that the system does not silently drop trades.
let trades = orders
.into_iter()
.filter_map(|order| {
let order_pda =
resolve_account(begin, &ctx.account_keys, order.order_pda_position)?;
if let Some(resolved) = resolve_order(&order_pda) {
Some(TradeDelta {
order_uid: resolved.order_uid,
amount_withdrawn_delta: order.amount_withdrawn_delta,
amount_received_delta: PLACEHOLDER_AMOUNT_RECEIVED,
order_fulfilled: resolved.order_fulfilled,
})
} else {
tracing::warn!(?order_pda, "failed to resolve order PDA during settlement");
None
}
})
.collect();References
- When fetching a batch of items where individual fetches can fail, do not silently ignore errors. The API response should explicitly indicate which items failed and provide error details for each failure.
| }; | ||
| // The named `FinalizeSettle` must actually be present in this tx. | ||
| let paired = instructions.iter().any(|instruction| { | ||
| instruction.instruction_index == u32::from(finalize_ix_index) |
There was a problem hiding this comment.
Pairing matches on instruction_index, which for a CPI is the top-level parent's index, not the instruction's own position. But finalize_ix_index is recovered from the BeginSettle body, where the on-chain program derives it from the instructions sysvar — i.e. a top-level transaction instruction index.
This only lines up if BeginSettle/FinalizeSettle are always submitted as top-level instructions. If a settlement is ever reached via CPI (a case this module otherwise goes out of its way to support — see relevant_instructions and its CPI tests), two settlement instructions nested under the same top-level ix would share an instruction_index, and the sysvar-derived finalize_ix_index wouldn't correspond to it — silently failing to pair (lost event, debug-only log) or mispairing in a batched tx.
Could you confirm BeginSettle/FinalizeSettle are guaranteed top-level? If so, a short comment stating that invariant here would help; if not, the pairing needs the sysvar-level index rather than instruction_index.
Description
The settlement decoder now turns settlement-program instructions into typed
SettlementEventinstead of just routing them.CreateOrderdecodes fully.BeginSettleandFinalizeSettleare paired into aSettlementFinalized, andCreateBufferyieldsBufferCreated. Two fields, the on-chain program does not emit yet, the auction id and the buy-side amount, are fixed placeholder constants. The order fields that come from the database (order uid, fulfilled flag) resolve through an injected lookup, so tests can mock them ahead of the persistence layer. This lets a later PR drive the pipeline end-to-end before the contract and the store are finished.Changes
CreateOrderintoOrderCreated: uid from the encoded intent hash, owner from the intent, creator from the accounts.BeginSettlewith itsFinalizeSettleand emitSettlementFinalized, summing each order's sell amounts from the BeginSettle body.CreateBufferinto oneBufferCreatedper buffer.How to test
New unit tests.
Notes
Known gaps, all documented in-code:
auction_idand the buy amount are placeholder constants until the program emits them.Nonein production until the persistence layer lands (PR 14).solveris read as the transaction fee payer, which is wrong if a relayer submits the settlement. Needs confirming against how Solana settlements are sent.