From 8ebc9519351faed2b358600fc3a8d1ee15f338d4 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 24 Jul 2026 08:07:11 +0000 Subject: [PATCH 1/2] refactor(solana-indexer): decode settlements via shared interface parser --- Cargo.lock | 2 +- crates/solana-indexer/Cargo.toml | 2 +- crates/solana-indexer/src/indexer/decoder.rs | 267 +++++++----------- .../src/indexer/decoder/tests.rs | 48 ++-- 4 files changed, 134 insertions(+), 185 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ac4105e4a..4437b1008d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9082,7 +9082,7 @@ dependencies = [ [[package]] name = "settlement-interface" version = "0.1.0" -source = "git+https://github.com/cowprotocol/solana-programs?rev=4e0bd7eaed7c237a327705425be2a4a50a8b4a3a#4e0bd7eaed7c237a327705425be2a4a50a8b4a3a" +source = "git+https://github.com/cowprotocol/solana-programs?branch=make-parsing-generic-on-accounts#172927c9e00b588d29796a134700ffc7a79d0e8c" dependencies = [ "arrayref", "derive_more 1.0.0", diff --git a/crates/solana-indexer/Cargo.toml b/crates/solana-indexer/Cargo.toml index b73c8022f9..b4053175b2 100644 --- a/crates/solana-indexer/Cargo.toml +++ b/crates/solana-indexer/Cargo.toml @@ -23,7 +23,7 @@ futures = { workspace = true } settlement-interface = { package = "settlement-interface", git = "https://github.com/cowprotocol/solana-programs", - rev = "4e0bd7eaed7c237a327705425be2a4a50a8b4a3a" + branch = "make-parsing-generic-on-accounts" } solana-client = { workspace = true } solana-sdk = { workspace = true } diff --git a/crates/solana-indexer/src/indexer/decoder.rs b/crates/solana-indexer/src/indexer/decoder.rs index efbecf10d3..bc8c61612f 100644 --- a/crates/solana-indexer/src/indexer/decoder.rs +++ b/crates/solana-indexer/src/indexer/decoder.rs @@ -25,7 +25,12 @@ use { Pubkey as InterfacePubkey, SettlementInstruction, data::intent::EncodedOrderIntent, - instruction::settle::recover_counterpart, + instruction::{ + InstructionInputParsing, + create_buffer::CreateBufferInput, + create_order::CreateOrderInput, + settle::{BeginSettleInput, FinalizeSettleInput}, + }, recover_discriminator, }, solana_sdk::pubkey::Pubkey, @@ -146,30 +151,6 @@ impl Decoder { } } -/// Auction id is not carried on the `BeginSettle` wire yet, so every -/// `SettlementFinalized` uses this fixed placeholder. -// TODO: not in the BeginSettle wire yet; use the real value once the program -// emits it. -const PLACEHOLDER_AUCTION_ID: u64 = 0; - -/// The buy-side push amount is not carried on the `FinalizeSettle` wire yet, so -/// every trade's `amount_received_delta` uses this fixed placeholder. -// TODO: FinalizeSettle carries no push amount yet; use the real value once it -// does. -const PLACEHOLDER_AMOUNT_RECEIVED: u64 = 0; - -/// Position of the `created_by` account in a `CreateOrder`'s account list -/// `[owner (S), created_by (W,S), order_pda (W), system_program (R)]`. -const CREATE_ORDER_CREATED_BY: usize = 1; - -/// Accounts a `BeginSettle` carries before its per-order accounts: -/// `[instructions_sysvar, state_pda, token_program]`. -const BEGIN_SETTLE_FIXED_ACCOUNTS: usize = 3; - -/// Accounts a `CreateBuffer` carries before its per-buffer `(buffer_pda, mint)` -/// pairs: `[payer, system_program, token_program]`. -const CREATE_BUFFER_SHARED_ACCOUNTS: usize = 3; - /// Order fields the `BeginSettle` wire does not carry, looked up per order PDA /// through an injected resolver so the decode stays a pure function. A future /// PR backs the resolver with the persisted order rows. @@ -197,7 +178,7 @@ fn decode_settlement( // Instructions decodable on their own, without tx-level pairing. for instruction in instructions { - let Ok((discriminator, body)) = recover_discriminator(&instruction.data) else { + let Ok((discriminator, _)) = recover_discriminator(&instruction.data) else { tracing::debug!( instruction_index = instruction.instruction_index, "settlement instruction with an unknown discriminator, skipping" @@ -211,7 +192,7 @@ fn decode_settlement( // the dead-letter table. let decoded = match discriminator { SettlementInstruction::CreateOrder => { - decode_order_created(instruction, body, &ctx.account_keys).map(|event| vec![event]) + decode_order_created(instruction, &ctx.account_keys).map(|event| vec![event]) } SettlementInstruction::CreateBuffer => { decode_buffers_created(instruction, &ctx.account_keys) @@ -222,6 +203,8 @@ fn decode_settlement( SettlementInstruction::BeginSettle | SettlementInstruction::FinalizeSettle => { Ok(Vec::new()) } + // No domain event yet. + SettlementInstruction::ReclaimOrder => Ok(Vec::new()), }; match decoded { Ok(decoded_events) => events.extend(decoded_events), @@ -243,62 +226,49 @@ fn decode_settlement( events } -/// `CreateOrder` -> `OrderCreated`. The instruction data is the encoded order -/// intent: its hash is the order UID and it carries the owner. `created_by` is -/// resolved from the instruction's account list. +/// `CreateOrder` -> `OrderCreated`. The parser recovers the encoded order +/// intent and the `created_by` account; the intent's hash is the order UID and +/// it carries the owner. fn decode_order_created( instruction: &ResolvedInstruction, - body: &[u8], account_keys: &[Pubkey], ) -> Result { - let intent_bytes: &[u8; EncodedOrderIntent::SIZE] = - body.try_into().map_err(|_| DecodeError::SchemaMismatch)?; - let (intent, uid) = EncodedOrderIntent::decode_and_hash(intent_bytes) + let mut accounts = instruction_account_keys(instruction, account_keys)?; + let input = CreateOrderInput::parse(&instruction.data, &mut accounts) + .map_err(|_| DecodeError::SchemaMismatch)?; + let (intent, uid) = EncodedOrderIntent::decode_and_hash(&input.intent_bytes) .map_err(|_| DecodeError::SchemaMismatch)?; - let created_by = resolve_account(instruction, account_keys, CREATE_ORDER_CREATED_BY) - .ok_or(DecodeError::SchemaMismatch)?; Ok(SettlementEvent::OrderCreated { order_uid: OrderUid(uid.to_bytes()), owner: to_sdk_pubkey(intent.owner), - created_by, + created_by: *input.created_by, }) } -/// `CreateBuffer` -> one `BufferCreated` per created buffer. The wire body is -/// empty; each buffer is a trailing `(buffer_pda, mint)` account pair after the -/// shared accounts, and the event's token is the buffer's mint. +/// `CreateBuffer` -> one `BufferCreated` per created buffer. The parser groups +/// the trailing accounts into `[buffer_pda, mint]` pairs; the event's token is +/// each pair's mint. fn decode_buffers_created( instruction: &ResolvedInstruction, account_keys: &[Pubkey], ) -> Result, DecodeError> { - let per_buffer = instruction - .accounts - .get(CREATE_BUFFER_SHARED_ACCOUNTS..) - .ok_or(DecodeError::SchemaMismatch)?; - let (pairs, remainder) = per_buffer.as_chunks::<2>(); - if !remainder.is_empty() || pairs.is_empty() { - return Err(DecodeError::SchemaMismatch); - } - pairs + let mut accounts = instruction_account_keys(instruction, account_keys)?; + let input = CreateBufferInput::parse(&instruction.data, &mut accounts) + .map_err(|_| DecodeError::SchemaMismatch)?; + Ok(input + .buffers .iter() - .map(|pair| { - // Each pair is `[buffer_pda_index, mint_index]`; the event token is - // the buffer's mint. - let token = account_keys - .get(usize::from(pair[1])) - .ok_or(DecodeError::SchemaMismatch)?; - Ok(SettlementEvent::BufferCreated { token: *token }) - }) - .collect() + .map(|pair| SettlementEvent::BufferCreated { token: pair[1] }) + .collect()) } /// Pair each `BeginSettle` with the `FinalizeSettle` it names and emit one /// `SettlementFinalized` per pair. /// -/// Pairing is by index: a `BeginSettle` body carries the top-level instruction -/// index of its `FinalizeSettle` (recovered via [`recover_counterpart`]), which -/// must match a `FinalizeSettle` present in the same transaction. It is -/// independent of the two instructions' relative order. +/// Pairing is by index: a parsed `BeginSettle` carries the top-level +/// instruction index of its `FinalizeSettle` (`finalize_ix_index`), which must +/// match a `FinalizeSettle` present in the same transaction. It is independent +/// of the two instructions' relative order. fn decode_settlements_finalized( instructions: &[ResolvedInstruction], ctx: &TxContext, @@ -312,57 +282,90 @@ fn decode_settlements_finalized( let mut events = Vec::new(); for begin in instructions { - let Ok((SettlementInstruction::BeginSettle, body)) = recover_discriminator(&begin.data) - else { + let Ok((SettlementInstruction::BeginSettle, _)) = recover_discriminator(&begin.data) else { continue; }; - // Body: `[finalize_ix_index: u16 BE][n][bump×n][count×n][amount×T]`. - // Peel the counterpart index, leaving the per-order pull layout. - let Ok((finalize_ix_index, pull_body)) = recover_counterpart(body) else { - continue; + let mut begin_accounts = match instruction_account_keys(begin, &ctx.account_keys) { + Ok(accounts) => accounts, + Err(_) => continue, }; + let begin_input = match BeginSettleInput::parse(&begin.data, &mut begin_accounts) { + Ok(input) => input, + Err(_) => { + tracing::warn!( + instruction_index = begin.instruction_index, + "BeginSettle did not match the expected layout, skipping" + ); + continue; + } + }; + // The named `FinalizeSettle` must actually be present in this tx. - let paired = instructions.iter().any(|instruction| { - instruction.instruction_index == u32::from(finalize_ix_index) + let Some(finalize) = instructions.iter().find(|instruction| { + instruction.instruction_index == u32::from(begin_input.finalize_ix_index) && matches!( recover_discriminator(&instruction.data), Ok((SettlementInstruction::FinalizeSettle, _)) ) - }); - if !paired { + }) else { tracing::debug!( instruction_index = begin.instruction_index, - finalize_ix_index, + finalize_ix_index = begin_input.finalize_ix_index, "BeginSettle without a paired FinalizeSettle in the tx, skipping" ); continue; - } - - let Some(orders) = parse_begin_settle_orders(pull_body) else { - tracing::warn!( - instruction_index = begin.instruction_index, - "BeginSettle body did not match the expected pull layout, skipping" - ); - continue; }; + let mut finalize_accounts = match instruction_account_keys(finalize, &ctx.account_keys) { + Ok(accounts) => accounts, + Err(_) => continue, + }; + let finalize_input = + match FinalizeSettleInput::parse(&finalize.data, &mut finalize_accounts) { + Ok(input) => input, + Err(_) => { + tracing::warn!( + instruction_index = finalize.instruction_index, + "FinalizeSettle did not match the expected layout, skipping" + ); + continue; + } + }; + // Orders and finalize pushes are positionally aligned: `BeginSettle` + // enforces exactly one push per order, both sorted by order PDA, so order + // `i` is paid by push `i`. Collect the push amounts up front so the + // finalize borrow ends before the zip below. + let received: Vec = finalize_input + .pushes + .iter() + .map(|push| u64::from_le_bytes(*push.amount)) + .collect(); - 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)?; + let trades = begin_input + .orders + .iter() + .zip(received) + .filter_map(|(order, amount_received_delta)| { + let resolved = resolve_order(order.order_pda)?; + // Sell-side pull total. Amounts are little-endian `u64`; the + // stream is untrusted, so saturate instead of wrapping. + let amount_withdrawn_delta = order + .amounts + .iter() + .map(|amount| u64::from_le_bytes(*amount)) + .fold(0u64, u64::saturating_add); Some(TradeDelta { order_uid: resolved.order_uid, - amount_withdrawn_delta: order.amount_withdrawn_delta, - amount_received_delta: PLACEHOLDER_AMOUNT_RECEIVED, + amount_withdrawn_delta, + amount_received_delta, order_fulfilled: resolved.order_fulfilled, }) }) .collect(); events.push(SettlementEvent::SettlementFinalized { - auction_id: PLACEHOLDER_AUCTION_ID, + // The wire carries `auction_id` as i64; it is non-negative in + // practice. + auction_id: begin_input.auction_id as u64, solver, tx_signature: ctx.signature, slot: ctx.slot, @@ -372,77 +375,23 @@ fn decode_settlements_finalized( events } -/// One settled order recovered from a `BeginSettle` body: where its order PDA -/// sits in the instruction's account list, and the sell amount pulled for it. -struct BeginSettleOrder { - /// Position of the order's PDA within the instruction's account list. - order_pda_position: usize, - /// Sum of the order's pull amounts: the sell-side `amount_withdrawn` delta. - amount_withdrawn_delta: u64, -} - -/// Hand-parse a `BeginSettle` body (after the counterpart index) into per-order -/// pull totals and the account position of each order's PDA. -// -// TODO: this duplicates the `BeginSettle` wire layout owned by -// `settlement_interface` (`[n][bump×n][count×n][amount: u64 BE ×T]`); the -// interface's own parser is account-coupled, so there is no data-only helper to -// call yet. Replace this once one exists. The layout also shifts when the -// program adds `auction_id` to the wire, so this must move in lockstep. -fn parse_begin_settle_orders(body: &[u8]) -> Option> { - let (&order_count, rest) = body.split_first()?; - let order_count = usize::from(order_count); - // Bumps are on-chain PDA-derivation input the indexer does not need. - let (_bumps, rest) = rest.split_at_checked(order_count)?; - let (counts, amount_bytes) = rest.split_at_checked(order_count)?; - let (amounts, remainder) = amount_bytes.as_chunks::<8>(); - if !remainder.is_empty() { - return None; - } - // The per-order transfer counts must sum to the number of amounts, so every - // destination pairs with exactly one amount. - let counts_sum = counts - .iter() - .map(|&count| usize::from(count)) - .sum::(); - if counts_sum != amounts.len() { - return None; - } - - let mut orders = Vec::with_capacity(order_count); - let mut account_position = BEGIN_SETTLE_FIXED_ACCOUNTS; - let mut amount_index = 0usize; - for &count in counts { - let count = usize::from(count); - let mut amount_withdrawn_delta = 0u64; - for amount in &amounts[amount_index..amount_index + count] { - // On-chain amounts are already u64, so a sum from a single sell - // account cannot exceed u64. Guard anyway: the stream is untrusted. - amount_withdrawn_delta = - amount_withdrawn_delta.checked_add(u64::from_be_bytes(*amount))?; - } - amount_index += count; - orders.push(BeginSettleOrder { - order_pda_position: account_position, - amount_withdrawn_delta, - }); - // Each order occupies its order PDA, its sell token account, and one - // destination per transfer. - account_position += 2 + count; - } - Some(orders) -} - -/// Resolve the account at `position` in the instruction's account list to its -/// pubkey, returning `None` if the position or the resolved index is out of -/// range. -fn resolve_account( +/// Resolve an instruction's account-list indices to their pubkeys, in order, so +/// the interface parser can read them positionally. Fails if any index is out +/// of range against the transaction's account list. +fn instruction_account_keys( instruction: &ResolvedInstruction, account_keys: &[Pubkey], - position: usize, -) -> Option { - let index = *instruction.accounts.get(position)?; - account_keys.get(usize::from(index)).copied() +) -> Result, DecodeError> { + instruction + .accounts + .iter() + .map(|&index| { + account_keys + .get(usize::from(index)) + .copied() + .ok_or(DecodeError::SchemaMismatch) + }) + .collect() } /// Bridge a `settlement_interface` pubkey to the indexer's `solana_sdk` pubkey. diff --git a/crates/solana-indexer/src/indexer/decoder/tests.rs b/crates/solana-indexer/src/indexer/decoder/tests.rs index e796863487..29fb201076 100644 --- a/crates/solana-indexer/src/indexer/decoder/tests.rs +++ b/crates/solana-indexer/src/indexer/decoder/tests.rs @@ -1,13 +1,5 @@ use { - super::{ - Decoder, - PLACEHOLDER_AMOUNT_RECEIVED, - PLACEHOLDER_AUCTION_ID, - ResolvedOrder, - build_account_keys, - decode_settlement, - relevant_instructions, - }, + super::{Decoder, ResolvedOrder, build_account_keys, decode_settlement, relevant_instructions}, crate::{ persistence::Persistence, types::{ @@ -355,9 +347,10 @@ fn create_order_decodes_to_order_created() { } /// A crafted `BeginSettle` + `FinalizeSettle` pair decodes to one -/// `SettlementFinalized`: the real summed sell amount and the resolved order -/// UID (via the injected map), with the auction id and buy-side amount left at -/// their documented placeholders and the solver read as the fee payer. +/// `SettlementFinalized`: the real auction id read from the begin wire, the +/// summed sell amount, the buy-side push amount matched to the order by +/// destination, and the resolved order UID (via the injected map), with the +/// solver read as the fee payer. #[test] fn begin_and_finalize_settle_decode_to_settlement_finalized() { let (settlement, solflow) = (pubkey(1), pubkey(2)); @@ -365,7 +358,7 @@ fn begin_and_finalize_settle_decode_to_settlement_finalized() { let order_pda = pubkey(20); // Account list: // [solver(0), settlement(1), sysvar(2), state(3), token(4), order_pda(5), - // sell(6), dest0(7), dest1(8)]. + // sell(6), dest0(7), dest1(8), buffer(9)]. let account_keys = vec![ solver, settlement, @@ -376,21 +369,28 @@ fn begin_and_finalize_settle_decode_to_settlement_finalized() { pubkey(26), pubkey(27), pubkey(28), + pubkey(29), ]; - // BeginSettle body: finalize index 1, one order, bump 0xAA, two transfers of - // 300 and 700 (sum 1000 = the sell-side amount withdrawn). + // BeginSettle body: finalize index 1, auction id 4242, one order, bump 0xAA, + // two transfers of 300 and 700 (sum 1000 = the sell-side amount withdrawn). + // The wire is little-endian, matching the interface's encoder. let mut begin_data = vec![SettlementInstruction::BeginSettle.discriminator()]; - begin_data.extend_from_slice(&1u16.to_be_bytes()); + begin_data.extend_from_slice(&1u16.to_le_bytes()); + begin_data.extend_from_slice(&4242i64.to_le_bytes()); begin_data.push(1); begin_data.push(0xAA); begin_data.push(2); - begin_data.extend_from_slice(&300u64.to_be_bytes()); - begin_data.extend_from_slice(&700u64.to_be_bytes()); + begin_data.extend_from_slice(&300u64.to_le_bytes()); + begin_data.extend_from_slice(&700u64.to_le_bytes()); - // FinalizeSettle body: begin index 0. + // FinalizeSettle body: begin index 0, one push of 1234 to dest0 (bump 0xBB). + // dest0 is one of the order's begin destinations, so it credits the order's + // buy-side receipt. let mut finalize_data = vec![SettlementInstruction::FinalizeSettle.discriminator()]; - finalize_data.extend_from_slice(&0u16.to_be_bytes()); + finalize_data.extend_from_slice(&0u16.to_le_bytes()); + finalize_data.push(0xBB); + finalize_data.extend_from_slice(&1_234u64.to_le_bytes()); let tx = tx_info( account_keys, @@ -399,8 +399,8 @@ fn begin_and_finalize_settle_decode_to_settlement_finalized() { vec![ // BeginSettle @ 0: sysvar, state, token, order_pda, sell, dest0, dest1. compiled(1, vec![2, 3, 4, 5, 6, 7, 8], begin_data), - // FinalizeSettle @ 1: sysvar only. - compiled(1, vec![2], finalize_data), + // FinalizeSettle @ 1: sysvar, state, token, buffer (source), dest0. + compiled(1, vec![2, 3, 4, 9, 7], finalize_data), ], vec![], ); @@ -425,14 +425,14 @@ fn begin_and_finalize_settle_decode_to_settlement_finalized() { assert_eq!( events, vec![SettlementEvent::SettlementFinalized { - auction_id: PLACEHOLDER_AUCTION_ID, + auction_id: 4242, solver, tx_signature: signature(6), slot: Slot(5), trades: vec![TradeDelta { order_uid: expected_uid, amount_withdrawn_delta: 1_000, - amount_received_delta: PLACEHOLDER_AMOUNT_RECEIVED, + amount_received_delta: 1_234, order_fulfilled: true, }], }] From 138a48d5aaab9a5b663de794ff5cd8346726ea34 Mon Sep 17 00:00:00 2001 From: squadgazzz Date: Fri, 24 Jul 2026 12:21:23 +0000 Subject: [PATCH 2/2] docs(solana-indexer): reword decode comments to describe current behavior --- crates/solana-indexer/src/indexer/decoder.rs | 2 +- crates/solana-indexer/src/indexer/decoder/tests.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/solana-indexer/src/indexer/decoder.rs b/crates/solana-indexer/src/indexer/decoder.rs index bc8c61612f..3d70b312fe 100644 --- a/crates/solana-indexer/src/indexer/decoder.rs +++ b/crates/solana-indexer/src/indexer/decoder.rs @@ -203,7 +203,7 @@ fn decode_settlement( SettlementInstruction::BeginSettle | SettlementInstruction::FinalizeSettle => { Ok(Vec::new()) } - // No domain event yet. + // No domain event. SettlementInstruction::ReclaimOrder => Ok(Vec::new()), }; match decoded { diff --git a/crates/solana-indexer/src/indexer/decoder/tests.rs b/crates/solana-indexer/src/indexer/decoder/tests.rs index 29fb201076..138ff6db28 100644 --- a/crates/solana-indexer/src/indexer/decoder/tests.rs +++ b/crates/solana-indexer/src/indexer/decoder/tests.rs @@ -287,7 +287,7 @@ async fn run_drains_transactions_until_the_sender_drops() { assert!(decoder.run().await.is_ok()); } -/// A crafted `CreateOrder` decodes to `OrderCreated` with the real UID (the +/// A crafted `CreateOrder` decodes to `OrderCreated` with the UID (the /// hash of the encoded intent), the intent's owner, and the `created_by` /// account resolved from the instruction's account list. The account-list owner /// differs from the intent owner, so this also pins that the event owner comes @@ -347,10 +347,10 @@ fn create_order_decodes_to_order_created() { } /// A crafted `BeginSettle` + `FinalizeSettle` pair decodes to one -/// `SettlementFinalized`: the real auction id read from the begin wire, the -/// summed sell amount, the buy-side push amount matched to the order by -/// destination, and the resolved order UID (via the injected map), with the -/// solver read as the fee payer. +/// `SettlementFinalized`: the auction id read from the begin wire, the summed +/// sell amount, the buy-side push amount paired to its order by position +/// (order `i` is paid by push `i`), the order UID from the injected resolver, +/// and the solver read as the fee payer. #[test] fn begin_and_finalize_settle_decode_to_settlement_finalized() { let (settlement, solflow) = (pubkey(1), pubkey(2));