1#![allow(missing_debug_implementations, missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3
4mod error;
5
6use std::{fs, path::Path};
7#[cfg(feature = "json-codec")]
8use std::{
9 fs::File,
10 io::{BufReader, BufWriter},
11};
12
13use hpsvm_fixture::{
14 AccountCompareScope, AccountSnapshot, Compare, ExecutionSnapshot, ExecutionSnapshotFields,
15 ExecutionStatus, FixtureExpectations, FixtureHeader, FixtureInput, FixtureKind,
16 InstructionAccountMeta, InstructionFixture, ReturnDataSnapshot, RuntimeFixtureConfig,
17};
18use mollusk_svm_fuzz_fixture_firedancer as fd_codec;
19use prost::Message;
20use solana_address::Address;
21
22pub use crate::error::AdapterError;
23
24const FIREDANCER_SOURCE: &str = "firedancer";
25const FIREDANCER_TAG: &str = "external:firedancer";
26
27#[derive(Clone, Debug, PartialEq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct FiredancerFixture {
30 inner: fd_codec::proto::InstrFixture,
31}
32
33impl FiredancerFixture {
34 pub fn from_proto(inner: fd_codec::proto::InstrFixture) -> Self {
35 Self { inner }
36 }
37
38 pub fn as_proto(&self) -> &fd_codec::proto::InstrFixture {
39 &self.inner
40 }
41
42 pub fn into_proto(self) -> fd_codec::proto::InstrFixture {
43 self.inner
44 }
45
46 #[must_use]
47 pub fn to_model(&self) -> fd_codec::Fixture {
48 self.inner.clone().into()
49 }
50
51 pub fn load(path: impl AsRef<Path>) -> Result<Self, AdapterError> {
52 let path = path.as_ref();
53 match fixture_format_for_path(path)? {
54 FiredancerFixtureFormat::Binary => {
55 let bytes = fs::read(path)?;
56 let inner = fd_codec::proto::InstrFixture::decode(bytes.as_slice())?;
57 Ok(Self { inner })
58 }
59 #[cfg(feature = "json-codec")]
60 FiredancerFixtureFormat::Json => {
61 let reader = BufReader::new(File::open(path)?);
62 let inner = serde_json::from_reader(reader)?;
63 Ok(Self { inner })
64 }
65 }
66 }
67
68 pub fn save(&self, path: impl AsRef<Path>) -> Result<(), AdapterError> {
69 let path = path.as_ref();
70 match fixture_format_for_path(path)? {
71 FiredancerFixtureFormat::Binary => fs::write(path, self.inner.encode_to_vec())?,
72 #[cfg(feature = "json-codec")]
73 FiredancerFixtureFormat::Json => {
74 let writer = BufWriter::new(File::create(path)?);
75 serde_json::to_writer_pretty(writer, &self.inner)?;
76 }
77 }
78 Ok(())
79 }
80}
81
82impl From<fd_codec::Fixture> for FiredancerFixture {
83 fn from(value: fd_codec::Fixture) -> Self {
84 Self { inner: value.into() }
85 }
86}
87
88impl From<fd_codec::proto::InstrFixture> for FiredancerFixture {
89 fn from(value: fd_codec::proto::InstrFixture) -> Self {
90 Self::from_proto(value)
91 }
92}
93
94impl From<FiredancerFixture> for fd_codec::proto::InstrFixture {
95 fn from(value: FiredancerFixture) -> Self {
96 value.inner
97 }
98}
99
100impl TryFrom<FiredancerFixture> for hpsvm_fixture::Fixture {
101 type Error = AdapterError;
102
103 fn try_from(value: FiredancerFixture) -> Result<Self, Self::Error> {
104 let fd_codec::proto::InstrFixture { metadata, input, output } = value.inner;
105 let input = input.ok_or(AdapterError::MissingField { field: "input" })?;
106 let output = output.ok_or(AdapterError::MissingField { field: "output" })?;
107
108 let pre_accounts = input
109 .accounts
110 .into_iter()
111 .map(|account| account_snapshot_from_proto(account, "input.accounts"))
112 .collect::<Result<Vec<_>, _>>()?;
113
114 let instruction_accounts = input
115 .instr_accounts
116 .into_iter()
117 .map(|account| instruction_account_from_proto(&pre_accounts, account))
118 .collect::<Result<Vec<_>, _>>()?;
119
120 let post_accounts = output
121 .modified_accounts
122 .into_iter()
123 .map(|account| account_snapshot_from_proto(account, "output.modified_accounts"))
124 .collect::<Result<Vec<_>, _>>()?;
125
126 let program_id = address_from_bytes(&input.program_id, "input.program_id")?;
127 let compute_units_consumed = input.cu_avail.checked_sub(output.cu_avail).ok_or(
128 AdapterError::InconsistentComputeUnits {
129 before: input.cu_avail,
130 after: output.cu_avail,
131 },
132 )?;
133 let baseline = ExecutionSnapshot::from_fields(ExecutionSnapshotFields {
134 status: status_from_output(output.result, output.custom_err)?,
135 included: true,
136 compute_units_consumed,
137 fee: 0,
138 logs: Vec::new(),
139 return_data: return_data_from_output(program_id, output.return_data),
140 inner_instructions: Vec::new(),
141 post_accounts: post_accounts.clone(),
142 });
143
144 let header = FixtureHeader::new(
145 metadata
146 .as_ref()
147 .map(|value| value.fn_entrypoint.as_str())
148 .filter(|value| !value.is_empty())
149 .map_or_else(|| format!("firedancer-{program_id}"), str::to_owned),
150 FixtureKind::Instruction,
151 )
152 .source(FIREDANCER_SOURCE)
153 .tag(FIREDANCER_TAG);
154
155 Ok(Self::new(
156 header,
157 FixtureInput::Instruction(InstructionFixture::new(
158 RuntimeFixtureConfig::new(
159 input.slot_context.map_or(0, |slot| slot.slot),
160 None,
161 false,
162 false,
163 )
164 .with_compute_unit_limit(input.cu_avail),
165 Vec::new(),
166 pre_accounts,
167 program_id,
168 instruction_accounts,
169 input.data,
170 )),
171 FixtureExpectations::new(baseline, compares_for_post_accounts(&post_accounts)),
172 ))
173 }
174}
175
176impl TryFrom<hpsvm_fixture::Fixture> for FiredancerFixture {
177 type Error = AdapterError;
178
179 fn try_from(value: hpsvm_fixture::Fixture) -> Result<Self, Self::Error> {
180 match value.input {
181 FixtureInput::Instruction(instruction) => {
182 let input_cu_avail = instruction
183 .runtime
184 .compute_unit_limit
185 .ok_or(AdapterError::MissingComputeUnitBudget)?;
186 let output_cu_avail = input_cu_avail
187 .checked_sub(value.expectations.baseline.compute_units_consumed)
188 .ok_or(AdapterError::ComputeUnitsExceedBudget {
189 budget: input_cu_avail,
190 consumed: value.expectations.baseline.compute_units_consumed,
191 })?;
192 let input = fd_codec::proto::InstrContext {
193 program_id: address_to_bytes(instruction.program_id),
194 accounts: instruction
195 .pre_accounts
196 .iter()
197 .map(account_snapshot_to_proto)
198 .collect(),
199 instr_accounts: instruction_accounts_to_proto(
200 &instruction.pre_accounts,
201 &instruction.accounts,
202 )?,
203 data: instruction.data,
204 cu_avail: input_cu_avail,
205 slot_context: Some(fd_codec::proto::SlotContext {
206 slot: instruction.runtime.slot,
207 }),
208 epoch_context: None,
209 };
210 let output = snapshot_to_proto_effects(
211 &value.expectations.baseline,
212 instruction.program_id,
213 output_cu_avail,
214 )?;
215
216 Ok(Self::from_proto(fd_codec::proto::InstrFixture {
217 metadata: Some(fd_codec::proto::FixtureMetadata {
218 fn_entrypoint: value.header.name,
219 }),
220 input: Some(input),
221 output: Some(output),
222 }))
223 }
224 FixtureInput::Transaction(_) => Err(AdapterError::UnsupportedFixtureKind {
225 kind: "transaction",
226 expected: "instruction",
227 }),
228 _ => Err(AdapterError::UnsupportedFixtureKind {
229 kind: "unknown",
230 expected: "instruction",
231 }),
232 }
233 }
234}
235
236#[derive(Clone, Copy, Debug, PartialEq, Eq)]
237enum FiredancerFixtureFormat {
238 Binary,
239 #[cfg(feature = "json-codec")]
240 Json,
241}
242
243fn fixture_format_for_path(path: &Path) -> Result<FiredancerFixtureFormat, AdapterError> {
244 match path.extension().and_then(|value| value.to_str()) {
245 Some("fix") => Ok(FiredancerFixtureFormat::Binary),
246 #[cfg(feature = "json-codec")]
247 Some("json") => Ok(FiredancerFixtureFormat::Json),
248 _ => Err(AdapterError::UnsupportedFormat { path: path.display().to_string() }),
249 }
250}
251
252fn address_from_bytes(bytes: &[u8], field: &'static str) -> Result<Address, AdapterError> {
253 let array: [u8; 32] = bytes
254 .try_into()
255 .map_err(|_| AdapterError::InvalidAddressLength { field, actual: bytes.len() })?;
256 Ok(Address::new_from_array(array))
257}
258
259fn account_snapshot_from_proto(
260 account: fd_codec::proto::AcctState,
261 field: &'static str,
262) -> Result<AccountSnapshot, AdapterError> {
263 if account.seed_addr.is_some() {
264 return Err(AdapterError::UnsupportedSeedAddress { field });
265 }
266
267 Ok(AccountSnapshot::new(
268 address_from_bytes(&account.address, "account.address")?,
269 account.lamports,
270 address_from_bytes(&account.owner, "account.owner")?,
271 account.executable,
272 account.rent_epoch,
273 account.data,
274 ))
275}
276
277fn account_snapshot_to_proto(account: &AccountSnapshot) -> fd_codec::proto::AcctState {
278 fd_codec::proto::AcctState {
279 address: address_to_bytes(account.address),
280 lamports: account.lamports,
281 data: account.data.clone(),
282 executable: account.executable,
283 rent_epoch: account.rent_epoch,
284 owner: address_to_bytes(account.owner),
285 seed_addr: None,
286 }
287}
288
289fn address_to_bytes(address: Address) -> Vec<u8> {
290 address.to_bytes().to_vec()
291}
292
293fn instruction_account_from_proto(
294 accounts: &[AccountSnapshot],
295 account: fd_codec::proto::InstrAcct,
296) -> Result<InstructionAccountMeta, AdapterError> {
297 let index = usize::try_from(account.index).map_err(|_| {
298 AdapterError::InvalidInstructionAccountIndex {
299 index: usize::MAX,
300 accounts_len: accounts.len(),
301 }
302 })?;
303 let address = accounts
304 .get(index)
305 .ok_or(AdapterError::InvalidInstructionAccountIndex {
306 index,
307 accounts_len: accounts.len(),
308 })?
309 .address;
310 Ok(InstructionAccountMeta::new(address, account.is_signer, account.is_writable))
311}
312
313fn instruction_accounts_to_proto(
314 accounts: &[AccountSnapshot],
315 instruction_accounts: &[InstructionAccountMeta],
316) -> Result<Vec<fd_codec::proto::InstrAcct>, AdapterError> {
317 instruction_accounts
318 .iter()
319 .map(|account| {
320 let index = accounts
321 .iter()
322 .position(|candidate| candidate.address == account.pubkey)
323 .ok_or_else(|| AdapterError::MissingInstructionAccount {
324 address: account.pubkey.to_string(),
325 })?;
326 Ok(fd_codec::proto::InstrAcct {
327 index: u32::try_from(index).map_err(|_| {
328 AdapterError::InvalidInstructionAccountIndex {
329 index,
330 accounts_len: accounts.len(),
331 }
332 })?,
333 is_writable: account.is_writable,
334 is_signer: account.is_signer,
335 })
336 })
337 .collect()
338}
339
340fn snapshot_to_proto_effects(
341 snapshot: &ExecutionSnapshot,
342 instruction_program_id: Address,
343 cu_avail: u64,
344) -> Result<fd_codec::proto::InstrEffects, AdapterError> {
345 let (result, custom_err) = status_to_output(&snapshot.status)?;
346 let return_data = snapshot
347 .return_data
348 .as_ref()
349 .map(|return_data| {
350 if return_data.program_id == instruction_program_id {
351 Ok(return_data.data.clone())
352 } else {
353 Err(AdapterError::UnsupportedReturnDataProgram {
354 program_id: return_data.program_id.to_string(),
355 instruction_program_id: instruction_program_id.to_string(),
356 })
357 }
358 })
359 .transpose()?
360 .unwrap_or_default();
361
362 Ok(fd_codec::proto::InstrEffects {
363 result,
364 custom_err,
365 modified_accounts: snapshot.post_accounts.iter().map(account_snapshot_to_proto).collect(),
366 cu_avail,
367 return_data,
368 })
369}
370
371fn status_to_output(status: &ExecutionStatus) -> Result<(i32, u32), AdapterError> {
372 match status {
373 ExecutionStatus::Success => Ok((0, 0)),
374 ExecutionStatus::Failure { kind, .. } => {
375 if let Some(value) = parse_firedancer_result_with_custom_error(kind) {
376 Ok(value)
377 } else if let Some(value) = parse_firedancer_custom_error(kind) {
378 Ok((1, value))
379 } else if let Some(value) = parse_firedancer_program_result(kind) {
380 Ok((value, 0))
381 } else {
382 Err(AdapterError::UnsupportedExecutionStatus { kind: kind.clone() })
383 }
384 }
385 status => Err(AdapterError::UnsupportedExecutionStatus { kind: format!("{status:?}") }),
386 }
387}
388
389fn parse_firedancer_result_with_custom_error(kind: &str) -> Option<(i32, u32)> {
390 let inner =
391 kind.strip_prefix("FiredancerProgramResult(").and_then(|value| value.strip_suffix(')'))?;
392 let (result, custom_err) = inner.split_once(",CustomError(")?;
393 Some((result.parse().ok()?, custom_err.strip_suffix(')')?.parse().ok()?))
394}
395
396fn parse_firedancer_custom_error(kind: &str) -> Option<u32> {
397 kind.strip_prefix("FiredancerCustomError(")
398 .and_then(|value| value.strip_suffix(')'))
399 .and_then(|value| value.parse::<u32>().ok())
400}
401
402fn parse_firedancer_program_result(kind: &str) -> Option<i32> {
403 kind.strip_prefix("FiredancerProgramResult(")
404 .and_then(|value| value.strip_suffix(')'))
405 .and_then(|value| value.parse::<i32>().ok())
406}
407
408fn status_from_output(result: i32, custom_err: u32) -> Result<ExecutionStatus, AdapterError> {
409 match (result, custom_err) {
410 (0, 0) => Ok(ExecutionStatus::Success),
411 (0, custom_err) => Err(AdapterError::InconsistentExecutionStatus { result, custom_err }),
412 (result, 0) => Ok(ExecutionStatus::Failure {
413 kind: format!("FiredancerProgramResult({result})"),
414 message: format!("firedancer program returned status {result}"),
415 }),
416 (result, custom_err) => Ok(ExecutionStatus::Failure {
417 kind: format!("FiredancerProgramResult({result},CustomError({custom_err}))"),
418 message: format!(
419 "firedancer program returned status {result} with custom error {custom_err}"
420 ),
421 }),
422 }
423}
424
425fn return_data_from_output(
426 program_id: Address,
427 return_data: Vec<u8>,
428) -> Option<ReturnDataSnapshot> {
429 if return_data.is_empty() {
430 None
431 } else {
432 Some(ReturnDataSnapshot::new(program_id, return_data))
433 }
434}
435
436fn compares_for_post_accounts(post_accounts: &[AccountSnapshot]) -> Vec<Compare> {
437 let mut compares =
438 vec![Compare::Status, Compare::Included, Compare::ComputeUnits, Compare::ReturnData];
439
440 if !post_accounts.is_empty() {
441 let mut addresses = Vec::with_capacity(post_accounts.len());
442 for account in post_accounts {
443 if !addresses.contains(&account.address) {
444 addresses.push(account.address);
445 }
446 }
447 compares.push(Compare::Accounts(AccountCompareScope::Only(addresses)));
448 }
449
450 compares
451}