Skip to content

Validate pushes to users in BeginSettle#61

Open
fedgiac wants to merge 1 commit into
push-funds-to-user-parsingfrom
push-funds-to-user-processing-in-begin-settle
Open

Validate pushes to users in BeginSettle#61
fedgiac wants to merge 1 commit into
push-funds-to-user-parsingfrom
push-funds-to-user-processing-in-begin-settle

Conversation

@fedgiac

@fedgiac fedgiac commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Pair each settled orderin BeginSettle with its FinalizeSettle push.

A settlement is a [BeginSettle, FinalizeSettle] pair: BeginSettle pulls the users' sell funds, FinalizeSettle pushes the proceeds back out of the buffers. Until now the two instructions validated their own halves in isolation, so nothing tied a settled order to the push that is supposed to pay it. This change makes BeginSettle cross-check the paired FinalizeSettle: every order must be paid by exactly one push, and that push must send to the order's buy token account. The actual transfers stay in FinalizeSettle and belongs in a follow-up; here I only add the validation that belongs to BeginSettle and its tests.

Notable missing validation steps, to be added in a later PR: checking that the origin of the funds is the canonical buffer PDA associated to the mint of the buy token account.

How the pairing works

BeginSettle already loads the instructions sysvar to locate its counterpart, so I reuse that introspection to read the finalize's push destinations directly off its accounts.

Orders and pushes are both laid out sorted by order PDA, so order i is paid by push i.

I deliberately don't validate the push structure here (a dangling source buffer, a destination that isn't a real token account): FinalizeSettle re-parses the same instruction from its own data and holds the destination accounts, so those checks belong to it. BeginSettle only has the orders, so it owns the count and destination-correspondence checks.

Shared factoring

The count-of-fixed-accounts constant that push_destinations needs was a private test constant in the interface's finalize module; I promoted it to a public FINALIZE_FIXED_ACCOUNTS and added a test that pins it to what the builder actually emits, so the two can't drift.

Tests

The BeginSettle unit tests gain a proptest that builds a random well-formed FinalizeSettle and asserts push_destinations (FinalizeSettle introspection) recovers the same push count and destinations as FinalizeSettleInput (standard parsing used in FinalizeSettle).

Unfortunately, this introduced a new unsafe in the tests which I wasn't able to drop. The problem is always the same as last time: there's no safe way to build an introspected transaction without starting a full Solana environment.

The integration test helpers grew to make testing easier. Please suggest improvement, it was a lot of back and forth so I could have easily missed something!

@fedgiac fedgiac marked this pull request as ready for review July 6, 2026 20:52
@fedgiac fedgiac requested a review from a team as a code owner July 6, 2026 20:52
@fedgiac fedgiac mentioned this pull request Jul 6, 2026

@kaze-cow kaze-cow 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.

Overall looking good but the tests appear to need some refinement. I think there were some functions/branch controls in the tests that were designed to increase the efficiency, but I think we should focus on developer convenience instead.

Comment on lines +94 to +101
(FINALIZE_FIXED_ACCOUNTS + 1..)
.step_by(2)
.map_while(|destination_index| {
instruction
.get_instruction_account_at(destination_index)
.ok()
.map(|account| &account.key)
})

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.

simpler:

Suggested change
(FINALIZE_FIXED_ACCOUNTS + 1..)
.step_by(2)
.map_while(|destination_index| {
instruction
.get_instruction_account_at(destination_index)
.ok()
.map(|account| &account.key)
})
(FINALIZE_FIXED_ACCOUNTS + 1..instruction.num_account_metas())
.step_by(2)
.map(|i| instruction.get_instruction_account_at(i).unwrap())

checking the code, the only way that this function can return an error is if the requested index is out of bounds.

If you want to actually optimize this further and are OK with going a little unsafe:

    (FINALIZE_FIXED_ACCOUNTS + 1..instruction.num_account_metas())
        .step_by(2)
        .map(|i| unsafe { instruction.get_instruction_account_at_unchecked(i) })

&[payer],
svm.latest_blockhash(),
);
svm.send_transaction(tx).map_err(|e| e.err)

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.

here if we left the sending of the transaction on the svm to the user, this function could build the settlement transaction and then I imagine the receiving function can make any modifications/trigger any error it may need prior to sending, kind of like what we do in the unit test.

},
&destinations,
)
}

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.

why does settle default to a failing settlement tx? I understand this is intended to be used with testing BeginSettle, but when a (I assume working) function exists to have it be successful, for developer convenience and understanding it seems best to just stick with a successful result on the basic named helper.

svm.latest_blockhash(),
);
svm.send_transaction(tx).map_err(|e| e.err)
}

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.

Following the suggestion in my other comment to remove svm.send_transaction(tx).map_err(|e| e.err) line from the end of the function settle_and_pay, the desired failing state could be simulated it seems without needing this function.

Comment on lines +764 to +766
// The order sells `sell_mint` and is paid in a distinct `buy_mint`, so the
// buy-side push touches only `buy_mint` accounts. That isolates the sell
// mint: with no pulls, no token instruction should reference its account.

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.

this comments logic seems a bit twisted. I think what its trying to say is that we want to validate that, even if the order species a mint, that doesn't mean it should be touched if there is no pull instruction (even if there is a push on the other side)

Comment on lines +30 to +32
if svm.get_account(&pda).is_some() {
return pda;
}

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.

isnt buffer creation already idempotent in the instruction? if so, we could simplify the test code and remove this branch

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.

the majority of the helper functions for the files in this folder depend upon svmand program_id as their first arguments. Consider making a shared struct

struct SvmContext {
  svm: LiteSVM,
  program_id: Pubkey

  // optionally add more fields ex.
  // mints: Map<String, Pubkey>
}

and then have all these functions be like impl Buffer for SvmContext {} to reduce the number of args for these functions.

If we are OK with a larger refactor, then an even bigger change could be made such that the chain fixture needed for a test is defined declaratively rather than functionally, sort of like what is already happening with OrderBuilder (which I really like the pattern for). Though this is probably overkill.

Comment on lines +422 to +425
prop_assert_eq!(introspected_destinations.len(), count);
prop_assert_eq!(parsed.pushes.iter().count(), count);
prop_assert_eq!(&introspected_destinations, &destinations);
prop_assert_eq!(&parsed_destinations, &destinations);

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.

the count checks seem redundant with the following array equality checks.

state_pda in any::<[u8; 32]>(),
begin_ix_index in any::<u16>(),
(source_buffers, destinations, bumps, amounts) in arb_pushes(0..=16usize),
) {

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.

took me a while to realize but this function actually primarily focuses to test correctness of push_destinations and not any other parts of the process function. I feel like the name of the test function should reflect that like push_destinations_output_matches_finalize_parser or so.

(lmk if I have the wrong idea of what is happening here)

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