Skip to content

feat(solana-indexer): 7.2 decode settlement instructions into typed events#4612

Open
squadgazzz wants to merge 6 commits into
mainfrom
solana-indexer/PR7.2-settlement-decode
Open

feat(solana-indexer): 7.2 decode settlement instructions into typed events#4612
squadgazzz wants to merge 6 commits into
mainfrom
solana-indexer/PR7.2-settlement-decode

Conversation

@squadgazzz

@squadgazzz squadgazzz commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Description

The settlement decoder now turns settlement-program instructions into typed SettlementEvent instead of just routing them. CreateOrder decodes fully. BeginSettle and FinalizeSettle are paired into a SettlementFinalized, and CreateBuffer yields BufferCreated. 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

  • Decode CreateOrder into OrderCreated: uid from the encoded intent hash, owner from the intent, creator from the accounts.
  • Pair BeginSettle with its FinalizeSettle and emit SettlementFinalized, summing each order's sell amounts from the BeginSettle body.
  • Decode CreateBuffer into one BufferCreated per buffer.
  • Resolve the database-backed order fields through an injected closure, keeping the decode pure and testable.
  • Placeholder the auction id and the buy-side amount until the program carries them.

How to test

New unit tests.

Notes

Known gaps, all documented in-code:

  • auction_id and the buy amount are placeholder constants until the program emits them.
  • The order uid / fulfilled lookup returns None in production until the persistence layer lands (PR 14).
  • solver is read as the transaction fee payer, which is wrong if a relayer submits the settlement. Needs confirming against how Solana settlements are sent.
  • Failed transactions are not filtered yet, so a reverted settlement would emit an event. To be gated before persistence.

@squadgazzz squadgazzz changed the title feat(solana-indexer): dispatch settlement instructions by discriminator feat(solana-indexer): decode settlement instructions into typed events Jul 8, 2026
@squadgazzz squadgazzz changed the title feat(solana-indexer): decode settlement instructions into typed events feat(solana-indexer): 7.2 decode settlement instructions into typed events Jul 9, 2026
@squadgazzz squadgazzz force-pushed the solana-indexer/PR7-dispatch branch 3 times, most recently from d93e181 to 2503510 Compare July 9, 2026 11:08
@squadgazzz squadgazzz force-pushed the solana-indexer/PR7.2-settlement-decode branch from 95ca473 to a2b6041 Compare July 9, 2026 11:12
Base automatically changed from solana-indexer/PR7-dispatch to main July 9, 2026 11:32
@squadgazzz squadgazzz force-pushed the solana-indexer/PR7.2-settlement-decode branch from a2b6041 to f3bb264 Compare July 9, 2026 16:31

@tilacog tilacog 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.

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

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.

nit: id' put all const declarations at the top of the file
feel free to ignore this comment since some of these are temporary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 {

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.

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.

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +244 to +249
Some(SettlementEvent::OrderCreated {
order_uid: OrderUid(uid.to_bytes()),
owner: to_sdk_pubkey(intent.owner),
created_by,
})
}

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +208 to +219
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 => {}

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.

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_created can return a Result instead of an Option.
  • decode_buffers_created and decode_settlements_finalized can return Result<Vec> (that vec containing at least 1 event, otherwise Err)

@squadgazzz squadgazzz Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

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:

This applies to the other instructions as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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?

@squadgazzz squadgazzz marked this pull request as ready for review July 10, 2026 10:59
@squadgazzz squadgazzz requested a review from a team as a code owner July 10, 2026 10:59
@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @squadgazzz's task in 4m 13s —— View job


PR Review: 7.2 decode settlement instructions into typed events

  • Read the PR diff and changed files
  • Analyze decoder logic for correctness
  • Review tests
  • Post inline comments for issues
  • Post summary

Reviewed the decoder end-to-end. This is clean, well-documented, and the tests cover the tricky parts (CPI-nested resolution, stack_height path reconstruction and clamping, ALT account-list ordering, and the Begin/Finalize pairing). The known gaps (auction_id / buy-amount placeholders, resolve_order returning None in prod, fee-payer-as-solver, unfiltered failed txs) are all documented in-code and acknowledged, so I haven't flagged them.

One point worth confirming — the BeginSettleFinalizeSettle pairing matches on instruction_index, which is the top-level parent index for a CPI, while finalize_ix_index is a sysvar-derived top-level transaction index. These only agree if settlement instructions are always top-level. Given the module explicitly supports settlement-via-CPI elsewhere, it's worth either asserting that invariant with a comment or handling the CPI case. Left as an inline comment.

Everything else checks out — bounds are guarded (split_at_checked, as_chunks remainder checks, counts_sum == amounts.len() before slicing, checked_add on amounts), and the account-position arithmetic in parse_begin_settle_orders matches the documented wire layout. No blocking issues from me.

On tilacog's settlement-interface-parser discussion and squadgazzz's follow-up (#63): agree that reusing the crate's data-only parsers where they exist is the right long-term direction, and gating the hand-rolled parse_begin_settle_orders on a crate-side data-only helper is a sensible follow-up rather than a blocker here.

@gemini-code-assist gemini-code-assist Bot 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.

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.

Comment on lines +349 to +362
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();

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.

high

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
  1. 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)

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.

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.

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