Skip to main content

simulator_api/
direct_fill.rs

1//! Caller-supplied direct-fill templates: one instruction the server rewrites per probe.
2//!
3//! Split from the crate root because the wire shape, its parse errors and its round-trip
4//! tests are one subject, and `lib.rs` is long enough that adding a ninth unrelated block
5//! to it makes every one of them harder to find.
6
7use std::{collections::HashSet, str::FromStr};
8
9use serde::{Deserialize, Serialize};
10use solana_address::Address;
11
12use crate::{Base64DecodeError, BinaryEncoding, EncodedBinary, SwapMode};
13
14/// The width of the little-endian amount a template's `amountOffset` addresses.
15pub const AMOUNT_WIDTH: usize = 8;
16
17/// One instruction the caller serialized, with the slots the server rewrites per probe and the
18/// offset it writes the amount at. Matches hops of `mode` originals trading `inputMint` →
19/// `outputMint` and is priced against each such hop's realized fill.
20///
21/// Every floor, fee, direction and venue payload is the caller's: the server writes the amount,
22/// rewrites the taker, funds, simulates and reads the fill.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(try_from = "DirectFillTemplateWire", into = "DirectFillTemplateWire")]
25pub struct DirectFillTemplate {
26    /// The template's identity on rows, events, notifications, report groups and stats keys.
27    /// Unique per session, non-blank after trim.
28    pub label: String,
29    /// Which originals match, and whether the hop's input (`ExactIn`) or output (`ExactOut`)
30    /// amount is written.
31    pub mode: SwapMode,
32    pub input_mint: Address,
33    pub output_mint: Address,
34    pub instruction: TemplateInstruction,
35    /// Offset of the [`AMOUNT_WIDTH`]-byte little-endian amount in `instruction.data`.
36    pub amount_offset: u32,
37    pub taker: TakerSlots,
38}
39
40/// The template's single instruction, sent verbatim except the role slots' addresses and the
41/// amount bytes.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TemplateInstruction {
44    pub program_id: Address,
45    /// Metas in program order. Flags are never touched by the server; duplicate addresses are
46    /// legal; a role slot's address is a placeholder (by convention the zero address).
47    pub accounts: Vec<TemplateAccount>,
48    pub data: Vec<u8>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct TemplateAccount {
53    pub address: Address,
54    pub writable: bool,
55    pub signer: bool,
56}
57
58/// The account slots a probe rewrites, as indices into `instruction.accounts`.
59///
60/// Each role is a list because one instruction can name the same account more than once: a
61/// router's own swap instruction carries the taker's token accounts for itself and again inside
62/// the venue's account run, and rewriting only the first leaves the venue pointed at the
63/// exemplar's accounts. A bare index decodes as a one-element list.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct TakerSlots {
67    /// Every slot rewritten to the original's fee payer. Non-empty; each must be a signer.
68    pub user: Vec<u8>,
69    /// Rewritten to the fee payer's associated account for `inputMint` and seeded with the
70    /// trade. Non-empty; each must be writable.
71    #[serde(with = "slot_list")]
72    pub input_token_account: Vec<u8>,
73    /// Rewritten to the fee payer's associated account for `outputMint` and seeded to zero, so
74    /// what lands there is the fill. Non-empty; each must be writable.
75    #[serde(with = "slot_list")]
76    pub output_token_account: Vec<u8>,
77}
78
79/// One slot or a list of them on the wire, always a list in memory.
80mod slot_list {
81    use serde::{Deserialize, Deserializer, Serialize, Serializer};
82
83    #[derive(Deserialize)]
84    #[serde(untagged)]
85    enum OneOrMany {
86        One(u8),
87        Many(Vec<u8>),
88    }
89
90    pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
91        Ok(match OneOrMany::deserialize(d)? {
92            OneOrMany::One(slot) => vec![slot],
93            OneOrMany::Many(slots) => slots,
94        })
95    }
96
97    pub(super) fn serialize<S: Serializer>(slots: &[u8], s: S) -> Result<S::Ok, S::Error> {
98        match slots {
99            [only] => only.serialize(s),
100            many => many.serialize(s),
101        }
102    }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107struct DirectFillTemplateWire {
108    label: String,
109    mode: SwapMode,
110    input_mint: String,
111    output_mint: String,
112    instruction: TemplateInstructionWire,
113    amount_offset: u32,
114    taker: TakerSlots,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119struct TemplateInstructionWire {
120    program_id: String,
121    accounts: Vec<TemplateAccountWire>,
122    data: EncodedBinary,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127struct TemplateAccountWire {
128    address: String,
129    #[serde(default)]
130    writable: bool,
131    #[serde(default)]
132    signer: bool,
133}
134
135/// Why a supplied template could not be used.
136#[derive(Debug, thiserror::Error, PartialEq, Eq)]
137pub enum ParseDirectFillTemplateError {
138    #[error("invalid address {value} in template field {field}")]
139    Address { field: String, value: String },
140    #[error("a template's label must not be blank")]
141    BlankLabel,
142    #[error("a template must trade two different mints")]
143    SameMint,
144    /// The per-instruction runtime ceiling; an empty list names no slot to rewrite.
145    #[error("a template instruction must hold 1..=255 accounts, got {count}")]
146    AccountCount { count: usize },
147    #[error("template instruction data is not valid base64: {0}")]
148    Data(#[from] Base64DecodeError),
149    #[error("amount offset {offset} leaves no room for an amount in {len} bytes of data")]
150    AmountOffset { offset: u32, len: usize },
151    #[error("a template must name at least one user slot")]
152    NoUserSlot,
153    #[error("a template must name at least one slot for each token account")]
154    NoTokenAccountSlot,
155    #[error("{role} slot {slot} is past the instruction's accounts")]
156    SlotOutOfRange { role: &'static str, slot: u8 },
157    #[error("slot {slot} is named by more than one taker role")]
158    DuplicateRoleSlot { slot: u8 },
159    #[error("user slot {slot} must be a signer")]
160    UserNotSigner { slot: u8 },
161    #[error("token account slot {slot} must be writable")]
162    TokenAccountReadOnly { slot: u8 },
163}
164
165impl TryFrom<DirectFillTemplateWire> for DirectFillTemplate {
166    type Error = ParseDirectFillTemplateError;
167
168    fn try_from(wire: DirectFillTemplateWire) -> Result<Self, Self::Error> {
169        let address = |field: String, value: &str| {
170            Address::from_str(value).map_err(|_| ParseDirectFillTemplateError::Address {
171                field,
172                value: value.to_owned(),
173            })
174        };
175        let accounts = wire
176            .instruction
177            .accounts
178            .iter()
179            .enumerate()
180            .map(|(index, account)| {
181                Ok(TemplateAccount {
182                    address: address(
183                        format!("instruction.accounts[{index}].address"),
184                        &account.address,
185                    )?,
186                    writable: account.writable,
187                    signer: account.signer,
188                })
189            })
190            .collect::<Result<Vec<_>, ParseDirectFillTemplateError>>()?;
191        let template = Self {
192            label: wire.label,
193            mode: wire.mode,
194            input_mint: address("inputMint".to_owned(), &wire.input_mint)?,
195            output_mint: address("outputMint".to_owned(), &wire.output_mint)?,
196            instruction: TemplateInstruction {
197                program_id: address(
198                    "instruction.programId".to_owned(),
199                    &wire.instruction.program_id,
200                )?,
201                accounts,
202                data: wire.instruction.data.decode()?,
203            },
204            amount_offset: wire.amount_offset,
205            taker: wire.taker,
206        };
207        template.validate()?;
208        Ok(template)
209    }
210}
211
212impl DirectFillTemplate {
213    /// The account slots a probe rewrites: every user slot, then the input and output token
214    /// accounts.
215    pub fn role_slots(&self) -> impl Iterator<Item = u8> {
216        self.taker
217            .user
218            .iter()
219            .chain(&self.taker.input_token_account)
220            .chain(&self.taker.output_token_account)
221            .copied()
222    }
223
224    /// The rules `TryFrom<DirectFillTemplateWire>` enforces, for a template built in code:
225    /// [`simulator_trading::template::instantiate`] indexes on them without checking.
226    pub fn validate(&self) -> Result<(), ParseDirectFillTemplateError> {
227        use ParseDirectFillTemplateError as Error;
228
229        if self.label.trim().is_empty() {
230            return Err(Error::BlankLabel);
231        }
232        if self.input_mint == self.output_mint {
233            return Err(Error::SameMint);
234        }
235        let accounts = &self.instruction.accounts;
236        if !(1..=usize::from(u8::MAX)).contains(&accounts.len()) {
237            return Err(Error::AccountCount {
238                count: accounts.len(),
239            });
240        }
241        let len = self.instruction.data.len();
242        let end = usize::try_from(self.amount_offset)
243            .ok()
244            .and_then(|offset| offset.checked_add(AMOUNT_WIDTH));
245        if end.is_none_or(|end| end > len) {
246            return Err(Error::AmountOffset {
247                offset: self.amount_offset,
248                len,
249            });
250        }
251        if self.taker.user.is_empty() {
252            return Err(Error::NoUserSlot);
253        }
254        if self.taker.input_token_account.is_empty() || self.taker.output_token_account.is_empty() {
255            return Err(Error::NoTokenAccountSlot);
256        }
257        let mut role_slots = HashSet::new();
258        fn labelled(role: &'static str, slots: &[u8]) -> impl Iterator<Item = (&'static str, u8)> {
259            slots.iter().map(move |&slot| (role, slot))
260        }
261        self.taker
262            .user
263            .iter()
264            .map(|&slot| ("user", slot))
265            .chain(labelled(
266                "inputTokenAccount",
267                &self.taker.input_token_account,
268            ))
269            .chain(labelled(
270                "outputTokenAccount",
271                &self.taker.output_token_account,
272            ))
273            .try_for_each(|(role, slot)| {
274                if usize::from(slot) >= accounts.len() {
275                    return Err(Error::SlotOutOfRange { role, slot });
276                }
277                role_slots
278                    .insert(usize::from(slot))
279                    .then_some(())
280                    .ok_or(Error::DuplicateRoleSlot { slot })
281            })?;
282        let flags_of = |slot: u8| accounts[usize::from(slot)];
283        if let Some(&slot) = self.taker.user.iter().find(|&&slot| !flags_of(slot).signer) {
284            return Err(Error::UserNotSigner { slot });
285        }
286        if let Some(&slot) = self
287            .taker
288            .input_token_account
289            .iter()
290            .chain(&self.taker.output_token_account)
291            .find(|&&slot| !flags_of(slot).writable)
292        {
293            return Err(Error::TokenAccountReadOnly { slot });
294        }
295        Ok(())
296    }
297}
298
299impl From<DirectFillTemplate> for DirectFillTemplateWire {
300    fn from(template: DirectFillTemplate) -> Self {
301        Self {
302            label: template.label,
303            mode: template.mode,
304            input_mint: template.input_mint.to_string(),
305            output_mint: template.output_mint.to_string(),
306            instruction: TemplateInstructionWire {
307                program_id: template.instruction.program_id.to_string(),
308                accounts: template
309                    .instruction
310                    .accounts
311                    .into_iter()
312                    .map(|account| TemplateAccountWire {
313                        address: account.address.to_string(),
314                        writable: account.writable,
315                        signer: account.signer,
316                    })
317                    .collect(),
318                data: EncodedBinary::from_bytes(&template.instruction.data, BinaryEncoding::Base64),
319            },
320            amount_offset: template.amount_offset,
321            taker: template.taker,
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use rstest::rstest;
329
330    use super::*;
331
332    fn address(byte: u8) -> Address {
333        Address::from([byte; 32])
334    }
335
336    /// A captured Tempest SOL → USDC template: 16 Titan `swap_route_v3` prefix slots, the
337    /// 13-account Tempest run, 37 data bytes.
338    fn tempest_template_json() -> serde_json::Value {
339        serde_json::json!({
340            "label": "tempest/sol-usdc/sol-to-usdc",
341            "mode": "ExactIn",
342            "inputMint": "So11111111111111111111111111111111111111112",
343            "outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
344            "instruction": {
345                "programId": "T1TANpTeScyeqVzzgNViGDNrkQ6qHz9KrSBS4aNXvGT",
346                "accounts": [
347                    {"address": "11111111111111111111111111111111", "writable": true, "signer": true},
348                    {"address": "11111111111111111111111111111111", "writable": true, "signer": true},
349                    {"address": "D5YqVMoSxnqeZAKAUUE1Dm3bmjtdxQ5DCF356ozqN9cM"},
350                    {"address": "11111111111111111111111111111111", "writable": true},
351                    {"address": "11111111111111111111111111111111", "writable": true},
352                    {"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},
353                    {"address": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"},
354                    {"address": "11111111111111111111111111111111"},
355                    {"address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"},
356                    {"address": "T1TANpTeScyeqVzzgNViGDNrkQ6qHz9KrSBS4aNXvGT"},
357                    {"address": "T1TANpTeScyeqVzzgNViGDNrkQ6qHz9KrSBS4aNXvGT"},
358                    {"address": "T1TANpTeScyeqVzzgNViGDNrkQ6qHz9KrSBS4aNXvGT"},
359                    {"address": "EW9diL91VgHY5i9qYScz53W3PihQPSnPoMtAKCo1Bs7J", "writable": true},
360                    {"address": "3nnVbsCfN1mwUk2XSLCnjzY3bDDTdzpnKjvRmd8nESS2", "writable": true},
361                    {"address": "So11111111111111111111111111111111111111112"},
362                    {"address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"},
363                    {"address": "D5YqVMoSxnqeZAKAUUE1Dm3bmjtdxQ5DCF356ozqN9cM", "writable": true},
364                    {"address": "FQmFVQ7i8GCLqwE7EriUA4WQCvyCKxAeY4XUr55ZD6D7", "writable": true},
365                    {"address": "EW9diL91VgHY5i9qYScz53W3PihQPSnPoMtAKCo1Bs7J", "writable": true},
366                    {"address": "3nnVbsCfN1mwUk2XSLCnjzY3bDDTdzpnKjvRmd8nESS2", "writable": true},
367                    {"address": "4kHHmecPiRNJtAQug2cHFChovM4wqdBesWQckrXBWinD", "writable": true},
368                    {"address": "6vNWbfi3HwDtoANb13CszPwEdAiyaUyZQ4A5A3hw4pqt", "writable": true},
369                    {"address": "So11111111111111111111111111111111111111112"},
370                    {"address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"},
371                    {"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},
372                    {"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},
373                    {"address": "FUQcTWJjXtRxk6XHQBS6gqZ6VCgYeQkGs1H7inC2YKVe"},
374                    {"address": "Sysvar1nstructions1111111111111111111111111"},
375                    {"address": "2G71FYychcDbuNvMmVcFzU8J9N3giFvgp3a5PS2LmgZm"}
376                ],
377                "data": {"data": "KgAAAAAAAAAAAAAAAAAAAAAAMgACAAAAAQAAAFsAAAEAypo7DQ==", "encoding": "base64"}
378            },
379            "amountOffset": 2,
380            "taker": {"user": [0, 1], "inputTokenAccount": 3, "outputTokenAccount": 4}
381        })
382    }
383
384    #[test]
385    fn a_direct_fill_template_round_trips_through_its_wire_shape() {
386        let json = tempest_template_json();
387
388        let template: DirectFillTemplate = serde_json::from_value(json.clone()).expect("decode");
389
390        assert_eq!(template.label, "tempest/sol-usdc/sol-to-usdc");
391        assert_eq!(template.mode, SwapMode::ExactIn);
392        assert_eq!(
393            template.input_mint.to_string(),
394            "So11111111111111111111111111111111111111112"
395        );
396        assert_eq!(
397            template.output_mint.to_string(),
398            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
399        );
400        assert_eq!(
401            template.instruction.program_id.to_string(),
402            "T1TANpTeScyeqVzzgNViGDNrkQ6qHz9KrSBS4aNXvGT"
403        );
404        assert_eq!(template.instruction.accounts.len(), 29);
405        assert_eq!(
406            template.instruction.accounts[0],
407            TemplateAccount {
408                address: Address::default(),
409                writable: true,
410                signer: true,
411            }
412        );
413        assert_eq!(
414            template.instruction.accounts[2],
415            TemplateAccount {
416                address: "D5YqVMoSxnqeZAKAUUE1Dm3bmjtdxQ5DCF356ozqN9cM"
417                    .parse()
418                    .unwrap(),
419                writable: false,
420                signer: false,
421            }
422        );
423        assert_eq!(template.instruction.data.len(), 37);
424        assert_eq!(template.instruction.data[0], 42);
425        assert_eq!(template.instruction.data[2..10], [0; 8]);
426        assert_eq!(template.instruction.data[28], 91);
427        assert_eq!(template.amount_offset, 2);
428        assert_eq!(
429            template.taker,
430            TakerSlots {
431                user: vec![0, 1],
432                input_token_account: vec![3],
433                output_token_account: vec![4],
434            }
435        );
436
437        let encoded = serde_json::to_value(&template).expect("encode");
438        assert_eq!(encoded["mode"], "ExactIn");
439        assert_eq!(encoded["amountOffset"], 2);
440        assert_eq!(encoded["instruction"]["data"], json["instruction"]["data"]);
441        assert_eq!(encoded["instruction"]["accounts"][2]["signer"], false);
442        assert_eq!(encoded["taker"]["inputTokenAccount"], 3);
443        assert_eq!(
444            serde_json::from_value::<DirectFillTemplate>(encoded).expect("decode again"),
445            template
446        );
447    }
448
449    fn tempest_wire() -> DirectFillTemplateWire {
450        serde_json::from_value(tempest_template_json()).expect("wire shape")
451    }
452
453    /// Each case corrupts exactly one thing about the Tempest template, and the parse names it.
454    #[rstest]
455    #[case::address(
456        |wire: &mut DirectFillTemplateWire| wire.instruction.program_id = "not-base58".to_owned(),
457        ParseDirectFillTemplateError::Address {
458            field: "instruction.programId".to_owned(),
459            value: "not-base58".to_owned(),
460        }
461    )]
462    #[case::blank_label(
463        |wire: &mut DirectFillTemplateWire| wire.label = "  ".to_owned(),
464        ParseDirectFillTemplateError::BlankLabel
465    )]
466    #[case::same_mint(
467        |wire: &mut DirectFillTemplateWire| wire.output_mint = wire.input_mint.clone(),
468        ParseDirectFillTemplateError::SameMint
469    )]
470    #[case::no_accounts(
471        |wire: &mut DirectFillTemplateWire| wire.instruction.accounts.clear(),
472        ParseDirectFillTemplateError::AccountCount { count: 0 }
473    )]
474    #[case::too_many_accounts(
475        |wire: &mut DirectFillTemplateWire| {
476            let filler = wire.instruction.accounts[2].clone();
477            wire.instruction.accounts.resize(256, filler);
478        },
479        ParseDirectFillTemplateError::AccountCount { count: 256 }
480    )]
481    #[case::data(
482        |wire: &mut DirectFillTemplateWire| wire.instruction.data.data = "!!!".to_owned(),
483        ParseDirectFillTemplateError::Data(Base64DecodeError::InvalidByte(0, b'!'))
484    )]
485    #[case::amount_offset(
486        |wire: &mut DirectFillTemplateWire| wire.amount_offset = 30,
487        ParseDirectFillTemplateError::AmountOffset { offset: 30, len: 37 }
488    )]
489    #[case::no_user_slot(
490        |wire: &mut DirectFillTemplateWire| wire.taker.user.clear(),
491        ParseDirectFillTemplateError::NoUserSlot
492    )]
493    #[case::slot_out_of_range(
494        |wire: &mut DirectFillTemplateWire| wire.taker.output_token_account = vec![29],
495        ParseDirectFillTemplateError::SlotOutOfRange { role: "outputTokenAccount", slot: 29 }
496    )]
497    #[case::duplicate_role_slot(
498        |wire: &mut DirectFillTemplateWire| wire.taker.input_token_account = vec![1],
499        ParseDirectFillTemplateError::DuplicateRoleSlot { slot: 1 }
500    )]
501    #[case::user_not_signer(
502        |wire: &mut DirectFillTemplateWire| wire.instruction.accounts[1].signer = false,
503        ParseDirectFillTemplateError::UserNotSigner { slot: 1 }
504    )]
505    #[case::token_account_read_only(
506        |wire: &mut DirectFillTemplateWire| wire.instruction.accounts[4].writable = false,
507        ParseDirectFillTemplateError::TokenAccountReadOnly { slot: 4 }
508    )]
509    fn a_malformed_template_is_refused_at_parse(
510        #[case] corrupt: fn(&mut DirectFillTemplateWire),
511        #[case] expected: ParseDirectFillTemplateError,
512    ) {
513        let mut wire = tempest_wire();
514        corrupt(&mut wire);
515
516        assert_eq!(DirectFillTemplate::try_from(wire), Err(expected));
517    }
518
519    #[test]
520    fn a_255_account_template_is_admitted() {
521        let mut wire = tempest_wire();
522        wire.instruction.accounts = (0..u8::MAX)
523            .map(|byte| TemplateAccountWire {
524                address: address(byte).to_string(),
525                writable: true,
526                signer: true,
527            })
528            .collect();
529        wire.taker.user = vec![0];
530
531        let template = DirectFillTemplate::try_from(wire).expect("at the bound");
532
533        assert_eq!(template.role_slots().collect::<Vec<_>>(), vec![0, 3, 4]);
534    }
535}