Skip to content

feat(solana-solvers): PR 3 solution assembly (custom interactions, prices, lookup tables)#4652

Open
squadgazzz wants to merge 14 commits into
mainfrom
solana-solvers/pr3-solution-assembly
Open

feat(solana-solvers): PR 3 solution assembly (custom interactions, prices, lookup tables)#4652
squadgazzz wants to merge 14 commits into
mainfrom
solana-solvers/pr3-solution-assembly

Conversation

@squadgazzz

@squadgazzz squadgazzz commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Description

PR 3 of the Jupiter solver plan (after #4632, #4635, #4637): turn one quoted swap into the single-order solution the driver consumes on /solve.

A solution is a receipt the driver can act on: which prices clear the trade, which order it fills, and the exact instructions to splice into the settlement transaction. The solver's only degrees of freedom are the clearing prices, derived from the quoted amounts so executed times price matches on both sides, and the interactions array, which carries Jupiter's instructions verbatim with full account metas. Address lookup tables ride along so the driver can build the v0 transaction the instructions assume.

Amounts go out as decimal strings, not JSON numbers. A Solana u64 reaches ~1.8e19, but a JSON number read as a double is only exact up to 2^53 (~9e15), and anything past that rounds to the nearest value the double can hold. Real amounts get there (10M SOL is 1e16 lamports), so a bare number risks silent corruption in a reader like JavaScript, whose only number type is a double. A string carries the exact digits and each consumer parses it back whole. The EVM solution DTO strings its amounts for the same reason.

Deliberately minimal:

  • cu_estimate is optional and left unset. The driver sizes the CU limit by simulating the whole settlement transaction (driver §7.2), which this solver never sees, so it has no meaningful total to report. This mirrors the EVM solution's optional gas. The adapter still drops Jupiter's own compute-budget instructions, since the driver emits its own.
  • No payout instruction. The swap output lands in the settlement's per-token buffer and FinalizeSettle pushes each order's amount to the user (Push proceeds to users in FinalizeSettle solana-programs#62).
  • No slippage handling. Jupiter bakes it into the instruction data and the driver applies none to solver-supplied interactions.
  • Interactions are a flat Vec<Instruction>, matching the driver's single interaction kind at MVP. There is no liquidity-interaction variant: the on-chain liquidity subsystem is frozen, so every interaction is a solver-supplied instruction.

Guards: same-mint orders are rejected (a uniform price map cannot hold two prices for one mint), and zero quoted amounts are rejected.

Changes

  • New domain::solution module: Solution, Trade, and a flat Instruction DTO (matching driver §2's Vec<Instruction>), wire serialization (base58 keys, hex order uid, base64 instruction data), and Solution::single mapping a dex::Swap into the DTO
  • Token amounts (prices values, executed_amount, fee) serialize as decimal strings (see the precision note above)
  • cu_estimate: Option<u64> on the solution, left None (the driver sizes the CU limit from simulation, mirroring EVM's optional gas)
  • OrderUid newtype (32-byte hash(intent)), hex-formatted the same way as the EVM model crate

How to test

New unit tests: sell and buy mapping, clearing prices, verbatim instruction carry-over, lookup-table pass-through, rejection guards, and a wire-format test pinning the JSON shape.

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

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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


Review: PR #4652 — Solana solvers solution assembly

  • Read changed files & gather context
  • Review domain/solution.rs (core logic)
  • Review order.rs, jupiter mod/dto changes
  • Post inline findings & summary

The clearing-price math is sound: pricing each mint at the counter amount (sell→out, buy→in) makes executed × price cancel exactly on both sides for both order sides, with no rounding exposure. The SameMint/ZeroAmount guards are correct and necessary, instructions are carried through verbatim, and the OrderUid hex Display matches the model crate. The design decisions the description calls out (no CU estimate, no payout, no slippage, single Custom variant, 32-byte uid) all check out.

Two non-blocking points, both left inline on domain/solution.rs:

  • Wire format uses raw u64 numbers for prices / executedAmount / fee, while the EVM solvers-dto serializes these as HexOrDecimalU256 strings. Worth aligning — both for consistency with the existing /solve contract and to avoid >2^53 precision loss for any non-Rust JSON consumer.
  • Minor: Interaction::custom clones each instruction's data/accounts even though swap is owned and dropped right after; into_iter() + move would avoid the copies.

Nothing here blocks merge.
· solana-solvers/pr3-solution-assembly

Comment on lines +22 to +23
#[serde_as(as = "HashMap<serde_with::DisplayFromStr, _>")]
pub prices: HashMap<Pubkey, u64>,

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.

prices (and Trade::executed_amount / fee below) go on the wire as raw u64 JSON numbers, whereas the established /solve DTO in crates/solvers-dto/src/solution.rs serializes every price/amount/fee as a HexOrDecimalU256 string:

#[serde_as(as = "HashMap<_, HexOrDecimalU256>")]
pub prices: HashMap<Address, U256>,
...
#[serde_as(as = "HexOrDecimalU256")]
pub executed_amount: U256,

Two concerns:

  1. Consistency — the driver already understands the EVM wire convention (decimal/hex strings). Emitting bare numbers means the Solana /solve contract diverges from the rest of the protocol for no stated reason (the description pins the wire shape but doesn't call out the number-vs-string choice).
  2. Precision — a Solana token amount / price is a full u64, whose max (~1.8e19) exceeds 2^53. Any non-Rust JSON consumer that reads these as IEEE-754 doubles will silently corrupt large values. Strings sidestep that.

Suggest serializing these as strings to match the EVM DTO. Fix this →

executed_amount,
fee: 0,
}],
interactions: swap.instructions.iter().map(Interaction::custom).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.

Minor: swap is owned here and swap.instructions is never read again, but Interaction::custom takes &Instruction and so clones data and rebuilds every AccountMeta. Consuming the vec by value would let you move the data out instead of cloning:

interactions: swap.instructions.into_iter().map(Interaction::custom).collect(),

with custom(instruction: Instruction) moving instruction.data. This requires reading swap.instructions before swap.address_lookup_tables is moved (already the case in this struct literal).

Comment on lines +22 to +23
#[serde_as(as = "HashMap<serde_with::DisplayFromStr, _>")]
pub prices: HashMap<Pubkey, u64>,

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.

prices (and Trade::executed_amount / fee below) go on the wire as raw u64 JSON numbers, whereas the established /solve DTO in crates/solvers-dto/src/solution.rs serializes every price/amount/fee as a HexOrDecimalU256 string:

#[serde_as(as = "HashMap<_, HexOrDecimalU256>")]
pub prices: HashMap<Address, U256>,
...
#[serde_as(as = "HexOrDecimalU256")]
pub executed_amount: U256,

Two concerns:

  1. Consistency — the driver already understands the EVM wire convention (decimal/hex strings). Emitting bare numbers here means the Solana /solve contract diverges from the rest of the protocol for no stated reason (the PR description pins the wire shape but doesn't call out the number-vs-string choice).
  2. Precision — a Solana token amount / price is a full u64, whose max (~1.8e19) exceeds 2^53. Any non-Rust JSON consumer (or a generic JSON tool) that reads these as IEEE-754 doubles will silently corrupt large values. Strings sidestep that entirely.

Suggest serializing these as strings to match the EVM DTO. Fix this →

executed_amount,
fee: 0,
}],
interactions: swap.instructions.iter().map(Interaction::custom).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.

Minor: swap is owned here and swap.instructions is never read again, but Interaction::custom takes &Instruction and so clones data and rebuilds every AccountMeta. Consuming the vec by value would let you move the data out instead of cloning:

interactions: swap.instructions.into_iter().map(Interaction::custom).collect(),

with custom(instruction: Instruction) moving instruction.data. Note this requires reading swap.instructions before swap.address_lookup_tables is moved (already the case in this struct literal).

Comment on lines +14 to +17
// Unwrap: the destination length always matches the input.
const_hex::encode_to_slice(self.0.as_slice(), &mut bytes[2..]).unwrap();
// Unwrap: hex output is always valid UTF-8.
f.write_str(std::str::from_utf8(&bytes).unwrap())

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.

Things like this always make me wonder if matching and adding an unreachable on the error path would make this faster or not, specially given the amount of times order uids get printed in the system

(though for the utf8 there's probably an unchecked version)

Comment on lines +21 to +23
/// Uniform clearing prices keyed by mint. Values are decimal strings.
#[serde_as(as = "HashMap<serde_with::DisplayFromStr, serde_with::DisplayFromStr>")]
pub prices: HashMap<Pubkey, u64>,

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.

What is a "mint"?

Comment on lines +76 to +79
/// Sell and buy mint coincide, so a uniform clearing-price map (one price
/// per mint) cannot represent the trade.
#[error("sell and buy mint are the same")]
SameMint,

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.

given all the effort done into removing UCPs, did we check with solvers if we should actually be adding the UCP concept back in?

Comment on lines +192 to +200
let solution = Solution::single(42, OrderUid([8; 32]), &order, swap()).unwrap();

assert_eq!(solution.id, 42);
// Clearing prices: sell mint priced at the output amount, buy mint at
// the input amount, so executed × price matches on both sides.
assert_eq!(solution.prices[&order.sell_mint], 2_000);
assert_eq!(solution.prices[&order.buy_mint], 1_000);
assert_eq!(solution.trades.len(), 1);
assert_eq!(solution.trades[0].order_uid, OrderUid([8; 32]));

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.

should we const come of the repeated values like the orderuid? so its easier to track what they are etc?

.unwrap()
);
assert_eq!(json["addressLookupTables"][0], pubkey(7).to_string());
}

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.

couldn't you write the json using the json! macro and just eq on that?
value impls partialeq which is what the assert_eq requires

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