Skip to main content

strata_sdk/
transaction_verifier.rs

1//! Built-in transaction verification for one-signature order control.
2//!
3//! With the direct prepare path the session's signature over the returned
4//! transaction is the whole authorization, so before signing the SDK checks
5//! that the transaction is exactly the requested operation and nothing more:
6//!
7//! - the session key co-signs and never pays (it is not the fee payer);
8//! - the owner wallet is not asked to sign;
9//! - the session key signs only delegated instructions of one program (never a
10//!   system, token, or other well-known program instruction);
11//! - for resting orders, every delegated place/cancel is decoded and matched
12//!   against the requested sides, prices, sizes, order types, order IDs, and the
13//!   market — nothing added, nothing changed, nothing missing.
14//!
15//! TWAP and immediate execution get the same structural checks; their inner
16//! economics are bound server-side by the echoed prepare fields the client
17//! already checks. Applications with stricter policies keep supplying their
18//! own [`OrderVerifier`], [`TwapVerifier`], or [`ExecutionVerifier`].
19//!
20//! The decoder is hand-written for base64 legacy and v0 Solana transactions
21//! (no RPC, no Solana crates): compact-u16 lengths, message header, static
22//! keys, blockhash, instructions, and the v0 address-table-lookup section.
23
24use std::collections::{HashMap, HashSet};
25
26use async_trait::async_trait;
27use base64::Engine as _;
28
29use crate::{
30    opaque_market_id, opaque_order_id, ExecutionVerificationContext, ExecutionVerifier,
31    MakerVerificationContext, OrderVerificationContext, OrderVerifier, PlatformMakerControlAction,
32    PlatformMakerCurrentPrepareRequest, PlatformMakerQuickstartOperation,
33    PlatformMakerStrandPrepareRequest, PlatformOrderBatchOperation, PlatformOrderChallengeRequest,
34    PlatformOrderType, PlatformTradeSide, TwapVerificationContext, TwapVerifier,
35};
36
37/// Programs a Vault session key must never sign for directly.
38const WELL_KNOWN_PROGRAMS: [&str; 10] = [
39    "11111111111111111111111111111111",             // system
40    "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",  // token
41    "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",  // token-2022
42    "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", // associated token
43    "ComputeBudget111111111111111111111111111111",  // compute budget
44    "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",  // memo
45    "Stake11111111111111111111111111111111111111",  // stake
46    "Vote111111111111111111111111111111111111111",  // vote
47    "AddressLookupTab1e1111111111111111111111111",  // address lookup tables
48    "BPFLoaderUpgradeab1e11111111111111111111111",  // upgradeable loader
49];
50
51/// Delegated-instruction envelope tag (`execute_with_delegate`).
52const DELEGATED_ENVELOPE_TAG: u8 = 3;
53/// Inner instruction tags a delegated order-control transaction may carry.
54const INNER_TAG_BALANCE: u8 = 1;
55const INNER_TAG_CANCEL_ORDER: u8 = 4;
56const INNER_TAG_PLACE_ORDER: u8 = 33;
57const INNER_TAG_MARKET_ACCOUNT: u8 = 34;
58const INNER_TAG_TWAP_CANCEL: u8 = 36;
59const INNER_TAG_TWAP_POST: u8 = 38;
60
61/// Message version of a decoded transaction.
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum TransactionVersion {
64    Legacy,
65    V0,
66}
67
68/// One compiled instruction exactly as it appears in the message.
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct DecodedInstruction {
71    pub program_id_index: u8,
72    pub account_indexes: Vec<u8>,
73    pub data: Vec<u8>,
74}
75
76/// A decoded legacy or v0 transaction envelope. Address-table lookups are
77/// counted but never resolved: verification only reasons about static keys.
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct DecodedTransaction {
80    pub version: TransactionVersion,
81    pub signature_count: usize,
82    pub num_required_signatures: u8,
83    pub num_readonly_signed: u8,
84    pub num_readonly_unsigned: u8,
85    /// Base58 static account keys, in message order.
86    pub static_account_keys: Vec<String>,
87    pub recent_blockhash: String,
88    pub instructions: Vec<DecodedInstruction>,
89    pub address_table_lookup_count: usize,
90}
91
92struct Reader<'a> {
93    bytes: &'a [u8],
94    offset: usize,
95}
96
97impl<'a> Reader<'a> {
98    fn new(bytes: &'a [u8]) -> Self {
99        Self { bytes, offset: 0 }
100    }
101
102    fn u8(&mut self) -> Result<u8, String> {
103        let byte = *self
104            .bytes
105            .get(self.offset)
106            .ok_or_else(|| "transaction is truncated".to_owned())?;
107        self.offset += 1;
108        Ok(byte)
109    }
110
111    fn compact_u16(&mut self) -> Result<usize, String> {
112        let mut value = 0usize;
113        let mut shift = 0u32;
114        for _ in 0..3 {
115            let byte = self.u8()?;
116            value |= usize::from(byte & 0x7f) << shift;
117            if byte & 0x80 == 0 {
118                return Ok(value);
119            }
120            shift += 7;
121        }
122        Err("transaction length prefix is invalid".to_owned())
123    }
124
125    fn take(&mut self, length: usize) -> Result<&'a [u8], String> {
126        let end = self
127            .offset
128            .checked_add(length)
129            .filter(|end| *end <= self.bytes.len())
130            .ok_or_else(|| "transaction is truncated".to_owned())?;
131        let out = &self.bytes[self.offset..end];
132        self.offset = end;
133        Ok(out)
134    }
135
136    fn done(&self) -> bool {
137        self.offset == self.bytes.len()
138    }
139}
140
141/// Decode a base64 legacy or v0 transaction without any RPC.
142pub fn decode_transaction(transaction_base64: &str) -> Result<DecodedTransaction, String> {
143    let bytes = base64::engine::general_purpose::STANDARD
144        .decode(transaction_base64.trim())
145        .map_err(|_| "invalid base64 payload".to_owned())?;
146    let mut reader = Reader::new(&bytes);
147    let signature_count = reader.compact_u16()?;
148    reader.take(signature_count.saturating_mul(64))?;
149    let mut first = reader.u8()?;
150    let mut version = TransactionVersion::Legacy;
151    if first & 0x80 != 0 {
152        if first & 0x7f != 0 {
153            return Err("unsupported transaction version".to_owned());
154        }
155        version = TransactionVersion::V0;
156        first = reader.u8()?;
157    }
158    let num_required_signatures = first;
159    let num_readonly_signed = reader.u8()?;
160    let num_readonly_unsigned = reader.u8()?;
161    let key_count = reader.compact_u16()?;
162    let mut static_account_keys = Vec::with_capacity(key_count);
163    for _ in 0..key_count {
164        static_account_keys.push(bs58::encode(reader.take(32)?).into_string());
165    }
166    let recent_blockhash = bs58::encode(reader.take(32)?).into_string();
167    let instruction_count = reader.compact_u16()?;
168    let mut instructions = Vec::with_capacity(instruction_count);
169    for _ in 0..instruction_count {
170        let program_id_index = reader.u8()?;
171        let account_count = reader.compact_u16()?;
172        let account_indexes = reader.take(account_count)?.to_vec();
173        let data_length = reader.compact_u16()?;
174        let data = reader.take(data_length)?.to_vec();
175        instructions.push(DecodedInstruction {
176            program_id_index,
177            account_indexes,
178            data,
179        });
180    }
181    let mut address_table_lookup_count = 0;
182    if version == TransactionVersion::V0 {
183        address_table_lookup_count = reader.compact_u16()?;
184        for _ in 0..address_table_lookup_count {
185            reader.take(32)?;
186            let writable = reader.compact_u16()?;
187            reader.take(writable)?;
188            let readonly = reader.compact_u16()?;
189            reader.take(readonly)?;
190        }
191    }
192    if !reader.done() {
193        return Err("transaction carries trailing bytes".to_owned());
194    }
195    if signature_count != usize::from(num_required_signatures) || num_required_signatures == 0 {
196        return Err("transaction signature layout is invalid".to_owned());
197    }
198    if static_account_keys.len() < usize::from(num_required_signatures) {
199        return Err("transaction signer layout is invalid".to_owned());
200    }
201    Ok(DecodedTransaction {
202        version,
203        signature_count,
204        num_required_signatures,
205        num_readonly_signed,
206        num_readonly_unsigned,
207        static_account_keys,
208        recent_blockhash,
209        instructions,
210        address_table_lookup_count,
211    })
212}
213
214/// Prove that an external wallet filled signatures without replacing the
215/// exact transaction message the SDK verified.
216pub fn verify_signed_transaction_message(
217    prepared_transaction_base64: &str,
218    signed_transaction_base64: &str,
219) -> Result<(), String> {
220    let prepared = transaction_message_bytes(prepared_transaction_base64)?;
221    let signed = transaction_message_bytes(signed_transaction_base64)?;
222    if prepared != signed {
223        return Err("signed transaction message changed after verification".to_owned());
224    }
225    Ok(())
226}
227
228fn transaction_message_bytes(transaction_base64: &str) -> Result<Vec<u8>, String> {
229    let bytes = base64::engine::general_purpose::STANDARD
230        .decode(transaction_base64.trim())
231        .map_err(|_| "invalid base64 payload".to_owned())?;
232    let mut reader = Reader::new(&bytes);
233    let signature_count = reader.compact_u16()?;
234    reader.take(signature_count.saturating_mul(64))?;
235    if reader.offset >= bytes.len() {
236        return Err("transaction is truncated".to_owned());
237    }
238    Ok(bytes[reader.offset..].to_vec())
239}
240
241/// Deny-by-default verification for a direct maker-wallet control. Exactly one
242/// native-v0 instruction may be present; the maker is its sole signer and fee
243/// payer, its public economics equal the quickstart request, and an upsert is
244/// bound to the requested opaque market. Only the server-derived PDA bump is
245/// intentionally not predicted by the client.
246pub fn verify_maker_transaction(context: &MakerVerificationContext<'_>) -> Result<(), String> {
247    let tx = decode_transaction(&context.prepared.transaction_base64)?;
248    if tx.version != TransactionVersion::V0 || tx.address_table_lookup_count != 0 {
249        return Err("maker controls must be native v0 without lookup tables".to_owned());
250    }
251    if tx.num_required_signatures != 1
252        || tx.signature_count != 1
253        || tx.static_account_keys.first().map(String::as_str) != Some(context.maker_wallet)
254        || tx.num_readonly_signed != 0
255    {
256        return Err("maker control must require only the maker wallet".to_owned());
257    }
258    if tx.recent_blockhash != context.prepared.recent_blockhash {
259        return Err("prepared maker blockhash does not match".to_owned());
260    }
261    if tx.instructions.len() != 1 {
262        return Err("maker control must contain exactly one instruction".to_owned());
263    }
264    let instruction = &tx.instructions[0];
265    if instruction.account_indexes.first() != Some(&0) {
266        return Err("maker wallet is not the instruction signer".to_owned());
267    }
268    let program = tx
269        .static_account_keys
270        .get(usize::from(instruction.program_id_index))
271        .ok_or_else(|| "maker instruction program is not static".to_owned())?;
272    if WELL_KNOWN_PROGRAMS.contains(&program.as_str()) {
273        return Err("maker control targets an invalid program".to_owned());
274    }
275    let expected_tag = match context.prepared.action {
276        PlatformMakerControlAction::StrandUpsert => 41,
277        PlatformMakerControlAction::StrandRecenter => 42,
278        PlatformMakerControlAction::StrandCancel => 43,
279        PlatformMakerControlAction::StrandSetEnabled => 44,
280        PlatformMakerControlAction::CurrentUpsert => 47,
281        PlatformMakerControlAction::CurrentCancel => 48,
282    };
283    let data = &instruction.data;
284    if data.first() != Some(&expected_tag) {
285        return Err("maker instruction action changed".to_owned());
286    }
287    match (&context.prepared.action, context.operation) {
288        (
289            PlatformMakerControlAction::StrandUpsert,
290            PlatformMakerQuickstartOperation::Strand(PlatformMakerStrandPrepareRequest::Upsert {
291                enabled,
292                async_only,
293                sync_spread_ticks,
294                mid_price_atoms,
295                max_exposure_base_atoms,
296                bid_offsets_ticks,
297                ask_offsets_ticks,
298                bid_sizes_base_atoms,
299                ask_sizes_base_atoms,
300                valid_until_slot,
301                ..
302            }),
303        ) => {
304            if data.len() != 353 {
305                return Err("Strand upsert has an invalid length".to_owned());
306            }
307            verify_maker_market(&tx, instruction, context.market_id)?;
308            expect_u8(
309                data,
310                1,
311                u8::from(*enabled) | (u8::from(*async_only) << 1),
312                "Strand flags",
313            )?;
314            expect_u16(data, 3, *sync_spread_ticks, "Strand sync spread")?;
315            expect_u64(
316                data,
317                9,
318                atoms(mid_price_atoms, "mid_price_atoms")?,
319                "Strand mid price",
320            )?;
321            expect_u64(
322                data,
323                17,
324                atoms(max_exposure_base_atoms, "max_exposure_base_atoms")?,
325                "Strand exposure",
326            )?;
327            for (index, value) in bid_offsets_ticks.iter().enumerate() {
328                expect_u16(data, 25 + index * 2, *value, "Strand bid offset")?;
329            }
330            for (index, value) in ask_offsets_ticks.iter().enumerate() {
331                expect_u16(data, 57 + index * 2, *value, "Strand ask offset")?;
332            }
333            for (index, value) in bid_sizes_base_atoms.iter().enumerate() {
334                expect_u64(
335                    data,
336                    89 + index * 8,
337                    atoms(value, "bid size")?,
338                    "Strand bid size",
339                )?;
340            }
341            for (index, value) in ask_sizes_base_atoms.iter().enumerate() {
342                expect_u64(
343                    data,
344                    217 + index * 8,
345                    atoms(value, "ask size")?,
346                    "Strand ask size",
347                )?;
348            }
349            expect_u64(
350                data,
351                345,
352                atoms(valid_until_slot, "valid_until_slot")?,
353                "Strand expiry",
354            )
355        }
356        (
357            PlatformMakerControlAction::StrandRecenter,
358            PlatformMakerQuickstartOperation::Strand(PlatformMakerStrandPrepareRequest::Recenter {
359                new_mid_price_atoms,
360                valid_until_slot,
361                ..
362            }),
363        ) => {
364            if data.len() != 17 {
365                return Err("Strand recenter has an invalid length".to_owned());
366            }
367            expect_u64(
368                data,
369                1,
370                atoms(new_mid_price_atoms, "new_mid_price_atoms")?,
371                "Strand mid price",
372            )?;
373            expect_u64(
374                data,
375                9,
376                atoms(valid_until_slot, "valid_until_slot")?,
377                "Strand expiry",
378            )
379        }
380        (
381            PlatformMakerControlAction::StrandSetEnabled,
382            PlatformMakerQuickstartOperation::Strand(
383                PlatformMakerStrandPrepareRequest::SetEnabled { enabled, .. },
384            ),
385        ) => {
386            if data.len() != 2 {
387                return Err("Strand enable has an invalid length".to_owned());
388            }
389            expect_u8(data, 1, u8::from(*enabled), "Strand enabled state")
390        }
391        (
392            PlatformMakerControlAction::CurrentUpsert,
393            PlatformMakerQuickstartOperation::Current(PlatformMakerCurrentPrepareRequest::Upsert {
394                enabled,
395                async_only,
396                half_spread_bps,
397                band_step_bps,
398                max_conf_bps,
399                max_oracle_dev_bps,
400                max_oracle_age_secs,
401                sync_spread_bps,
402                max_exposure_base_atoms,
403                bid_depth_base_atoms,
404                ask_depth_base_atoms,
405                valid_until_slot,
406                ..
407            }),
408        ) => {
409            if data.len() != 161 {
410                return Err("Current upsert has an invalid length".to_owned());
411            }
412            verify_maker_market(&tx, instruction, context.market_id)?;
413            expect_u8(
414                data,
415                1,
416                u8::from(*enabled) | (u8::from(*async_only) << 1),
417                "Current flags",
418            )?;
419            expect_u16(data, 3, *half_spread_bps, "Current spread")?;
420            expect_u16(data, 5, *band_step_bps, "Current band step")?;
421            expect_u16(data, 7, *max_conf_bps, "Current confidence bound")?;
422            expect_u16(data, 9, *max_oracle_dev_bps, "Current deviation bound")?;
423            expect_u32(data, 11, *max_oracle_age_secs, "Current mark age")?;
424            expect_u16(data, 15, *sync_spread_bps, "Current sync spread")?;
425            expect_u64(
426                data,
427                17,
428                atoms(max_exposure_base_atoms, "max_exposure_base_atoms")?,
429                "Current exposure",
430            )?;
431            for (index, value) in bid_depth_base_atoms.iter().enumerate() {
432                expect_u64(
433                    data,
434                    25 + index * 8,
435                    atoms(value, "bid depth")?,
436                    "Current bid depth",
437                )?;
438            }
439            for (index, value) in ask_depth_base_atoms.iter().enumerate() {
440                expect_u64(
441                    data,
442                    89 + index * 8,
443                    atoms(value, "ask depth")?,
444                    "Current ask depth",
445                )?;
446            }
447            expect_u64(
448                data,
449                153,
450                atoms(valid_until_slot, "valid_until_slot")?,
451                "Current expiry",
452            )
453        }
454        (
455            PlatformMakerControlAction::StrandCancel,
456            PlatformMakerQuickstartOperation::Strand(PlatformMakerStrandPrepareRequest::Cancel {
457                ..
458            }),
459        )
460        | (
461            PlatformMakerControlAction::CurrentCancel,
462            PlatformMakerQuickstartOperation::Current(PlatformMakerCurrentPrepareRequest::Cancel {
463                ..
464            }),
465        ) => {
466            if data.len() != 1 || instruction.account_indexes.len() != 3 {
467                return Err("maker cancellation has an invalid shape".to_owned());
468            }
469            let receiver = instruction
470                .account_indexes
471                .get(2)
472                .and_then(|index| tx.static_account_keys.get(usize::from(*index)))
473                .map(String::as_str);
474            if receiver != Some(context.maker_wallet) {
475                return Err("maker rent receiver changed".to_owned());
476            }
477            Ok(())
478        }
479        _ => Err("prepared maker action does not match the requested operation".to_owned()),
480    }
481}
482
483fn verify_maker_market(
484    tx: &DecodedTransaction,
485    instruction: &DecodedInstruction,
486    expected_market_id: &str,
487) -> Result<(), String> {
488    let market = instruction
489        .account_indexes
490        .get(1)
491        .and_then(|index| tx.static_account_keys.get(usize::from(*index)))
492        .ok_or_else(|| "maker market account is not static".to_owned())?;
493    if opaque_market_id(market) != expected_market_id {
494        return Err("maker transaction touches another market".to_owned());
495    }
496    Ok(())
497}
498
499fn expect_u8(data: &[u8], offset: usize, expected: u8, field: &str) -> Result<(), String> {
500    if data.get(offset) == Some(&expected) {
501        Ok(())
502    } else {
503        Err(format!("{field} changed"))
504    }
505}
506
507fn expect_u16(data: &[u8], offset: usize, expected: u16, field: &str) -> Result<(), String> {
508    let bytes: [u8; 2] = data
509        .get(offset..offset + 2)
510        .and_then(|slice| slice.try_into().ok())
511        .ok_or_else(|| format!("{field} is missing"))?;
512    if u16::from_le_bytes(bytes) == expected {
513        Ok(())
514    } else {
515        Err(format!("{field} changed"))
516    }
517}
518
519fn expect_u32(data: &[u8], offset: usize, expected: u32, field: &str) -> Result<(), String> {
520    let bytes: [u8; 4] = data
521        .get(offset..offset + 4)
522        .and_then(|slice| slice.try_into().ok())
523        .ok_or_else(|| format!("{field} is missing"))?;
524    if u32::from_le_bytes(bytes) == expected {
525        Ok(())
526    } else {
527        Err(format!("{field} changed"))
528    }
529}
530
531fn expect_u64(data: &[u8], offset: usize, expected: u64, field: &str) -> Result<(), String> {
532    let actual = read_u64(data, offset).map_err(|_| format!("{field} is missing"))?;
533    if actual == expected {
534        Ok(())
535    } else {
536        Err(format!("{field} changed"))
537    }
538}
539
540struct DelegatedInstruction {
541    inner_tag: u8,
542    inner: Vec<u8>,
543    /// Base58 keys of the inner instruction's accounts, in order (`None` when
544    /// an index points outside the static key table).
545    inner_accounts: Vec<Option<String>>,
546}
547
548struct StructuralOptions {
549    allow_address_tables: bool,
550    require_envelope: bool,
551}
552
553/// The structural invariants every session-signed Strata transaction must
554/// hold. Returns the delegated instructions for the caller's own decoding.
555fn structural_checks(
556    tx: &DecodedTransaction,
557    session_public_key: &str,
558    owner_wallet: &str,
559    recent_blockhash: &str,
560    options: StructuralOptions,
561) -> Result<Vec<DelegatedInstruction>, String> {
562    if tx.recent_blockhash != recent_blockhash {
563        return Err("prepared transaction blockhash does not match".to_owned());
564    }
565    let keys = &tx.static_account_keys;
566    let required = usize::from(tx.num_required_signatures);
567    let session_index = keys
568        .iter()
569        .position(|key| key == session_public_key)
570        .filter(|index| *index < required)
571        .ok_or_else(|| "the session key is not a required signer".to_owned())?;
572    if session_index == 0 {
573        return Err("the session key must never be the fee payer".to_owned());
574    }
575    if let Some(owner_index) = keys.iter().position(|key| key == owner_wallet) {
576        if owner_index < required {
577            return Err("the owner wallet must not be asked to sign".to_owned());
578        }
579    }
580    if !options.allow_address_tables && tx.address_table_lookup_count != 0 {
581        return Err("order-control transactions carry no lookup tables".to_owned());
582    }
583    let session_index_u8 = u8::try_from(session_index)
584        .map_err(|_| "transaction signer layout is invalid".to_owned())?;
585    let mut envelope_program: Option<&str> = None;
586    let mut inner_program: Option<&str> = None;
587    let mut delegated = Vec::new();
588    for instruction in &tx.instructions {
589        if !instruction.account_indexes.contains(&session_index_u8) {
590            continue;
591        }
592        let program = keys
593            .get(usize::from(instruction.program_id_index))
594            .ok_or_else(|| "instruction program is not static".to_owned())?;
595        if WELL_KNOWN_PROGRAMS.contains(&program.as_str()) {
596            return Err("the session key must not sign a system or token instruction".to_owned());
597        }
598        match envelope_program {
599            None => envelope_program = Some(program),
600            Some(existing) if existing != program => {
601                return Err("the session key signs for more than one program".to_owned());
602            }
603            Some(_) => {}
604        }
605        if instruction.account_indexes.first() != Some(&session_index_u8) {
606            return Err("the session key is not the delegate signer".to_owned());
607        }
608        if !options.require_envelope {
609            continue;
610        }
611        let data = &instruction.data;
612        if data.len() < 14 || data[0] != DELEGATED_ENVELOPE_TAG {
613            return Err("the session key signs a non-delegated instruction".to_owned());
614        }
615        let inner_length = usize::from(data[11]) | (usize::from(data[12]) << 8);
616        let inner_end = 14 + inner_length;
617        if inner_length == 0 || inner_end > data.len() {
618            return Err("delegated instruction is malformed".to_owned());
619        }
620        let inner = data[14..inner_end].to_vec();
621        let inner_program_key = instruction
622            .account_indexes
623            .get(3)
624            .and_then(|index| keys.get(usize::from(*index)))
625            .ok_or_else(|| "delegated instruction target is not static".to_owned())?;
626        match inner_program {
627            None => inner_program = Some(inner_program_key),
628            Some(existing) if existing != inner_program_key => {
629                return Err("delegated instructions target more than one program".to_owned());
630            }
631            Some(_) => {}
632        }
633        delegated.push(DelegatedInstruction {
634            inner_tag: inner[0],
635            inner,
636            inner_accounts: instruction
637                .account_indexes
638                .iter()
639                .skip(6)
640                .map(|index| keys.get(usize::from(*index)).cloned())
641                .collect(),
642        });
643    }
644    if delegated.is_empty() && options.require_envelope {
645        return Err("the transaction carries no delegated instruction".to_owned());
646    }
647    Ok(delegated)
648}
649
650fn read_u64(bytes: &[u8], offset: usize) -> Result<u64, String> {
651    let slice = bytes
652        .get(offset..offset + 8)
653        .ok_or_else(|| "delegated instruction is truncated".to_owned())?;
654    let mut array = [0u8; 8];
655    array.copy_from_slice(slice);
656    Ok(u64::from_le_bytes(array))
657}
658
659const fn side_wire(side: PlatformTradeSide) -> u8 {
660    match side {
661        PlatformTradeSide::Buy => 0,
662        PlatformTradeSide::Sell => 1,
663    }
664}
665
666fn order_type_wire(order_type: PlatformOrderType) -> Result<u8, String> {
667    match order_type {
668        PlatformOrderType::GoodUntilCancelled => Ok(0),
669        PlatformOrderType::PostOnly => Ok(3),
670        PlatformOrderType::ImmediateOrCancel | PlatformOrderType::FillOrKill => {
671            Err("order type is not a resting order".to_owned())
672        }
673    }
674}
675
676fn atoms(value: &str, field: &str) -> Result<u64, String> {
677    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
678        return Err(format!("{field} must be an unsigned atomic decimal string"));
679    }
680    value
681        .parse::<u64>()
682        .map_err(|_| format!("{field} exceeds u64"))
683}
684
685#[derive(Clone, Debug, Eq, Hash, PartialEq)]
686struct ExpectedPlace {
687    side: u8,
688    order_type: u8,
689    price: u64,
690    size: u64,
691}
692
693impl ExpectedPlace {
694    fn from_request(
695        side: PlatformTradeSide,
696        order_type: PlatformOrderType,
697        limit_price_atoms: &str,
698        size_atoms: &str,
699    ) -> Result<Self, String> {
700        Ok(Self {
701            side: side_wire(side),
702            order_type: order_type_wire(order_type)?,
703            price: atoms(limit_price_atoms, "limit_price_atoms")?,
704            size: atoms(size_atoms, "size_atoms")?,
705        })
706    }
707}
708
709enum ExpectedCancels {
710    /// Order IDs that must be cancelled.
711    Ids(Vec<String>),
712    /// `cancel_all`: at least one cancellation, identities chosen server-side.
713    All,
714}
715
716struct ExpectedOrderIntent {
717    places: Vec<ExpectedPlace>,
718    cancels: ExpectedCancels,
719}
720
721fn expected_order_intent(
722    operation: &PlatformOrderChallengeRequest,
723) -> Result<ExpectedOrderIntent, String> {
724    Ok(match operation {
725        PlatformOrderChallengeRequest::Place {
726            side,
727            order_type,
728            limit_price_atoms,
729            size_atoms,
730            ..
731        } => ExpectedOrderIntent {
732            places: vec![ExpectedPlace::from_request(
733                *side,
734                *order_type,
735                limit_price_atoms,
736                size_atoms,
737            )?],
738            cancels: ExpectedCancels::Ids(Vec::new()),
739        },
740        PlatformOrderChallengeRequest::Cancel { order_id, .. } => ExpectedOrderIntent {
741            places: Vec::new(),
742            cancels: ExpectedCancels::Ids(vec![order_id.clone()]),
743        },
744        PlatformOrderChallengeRequest::CancelAll { .. } => ExpectedOrderIntent {
745            places: Vec::new(),
746            cancels: ExpectedCancels::All,
747        },
748        PlatformOrderChallengeRequest::Replace {
749            order_id,
750            side,
751            order_type,
752            limit_price_atoms,
753            size_atoms,
754            ..
755        } => ExpectedOrderIntent {
756            places: vec![ExpectedPlace::from_request(
757                *side,
758                *order_type,
759                limit_price_atoms,
760                size_atoms,
761            )?],
762            cancels: ExpectedCancels::Ids(vec![order_id.clone()]),
763        },
764        PlatformOrderChallengeRequest::Batch { operations, .. } => {
765            let mut places = Vec::new();
766            let mut cancels = Vec::new();
767            for item in operations {
768                match item {
769                    PlatformOrderBatchOperation::Place {
770                        side,
771                        order_type,
772                        limit_price_atoms,
773                        size_atoms,
774                        ..
775                    } => places.push(ExpectedPlace::from_request(
776                        *side,
777                        *order_type,
778                        limit_price_atoms,
779                        size_atoms,
780                    )?),
781                    PlatformOrderBatchOperation::Cancel { order_id } => {
782                        cancels.push(order_id.clone());
783                    }
784                    PlatformOrderBatchOperation::Replace {
785                        order_id,
786                        side,
787                        order_type,
788                        limit_price_atoms,
789                        size_atoms,
790                        ..
791                    } => {
792                        cancels.push(order_id.clone());
793                        places.push(ExpectedPlace::from_request(
794                            *side,
795                            *order_type,
796                            limit_price_atoms,
797                            size_atoms,
798                        )?);
799                    }
800                }
801            }
802            ExpectedOrderIntent {
803                places,
804                cancels: ExpectedCancels::Ids(cancels),
805            }
806        }
807    })
808}
809
810fn same_multiset<T, K, F>(left: &[T], right: &[T], key: F) -> bool
811where
812    K: std::hash::Hash + Eq,
813    F: Fn(&T) -> K,
814{
815    if left.len() != right.len() {
816        return false;
817    }
818    let mut counts: HashMap<K, usize> = HashMap::new();
819    for value in left {
820        *counts.entry(key(value)).or_insert(0) += 1;
821    }
822    for value in right {
823        match counts.get_mut(&key(value)) {
824            Some(remaining) if *remaining > 0 => *remaining -= 1,
825            _ => return false,
826        }
827    }
828    true
829}
830
831fn decode_order_key(order: &str) -> Result<Vec<u8>, String> {
832    bs58::decode(order)
833        .into_vec()
834        .map_err(|_| "order account key is not base58".to_owned())
835}
836
837/// Deny-by-default verification of a prepared resting-order transaction: it
838/// must be exactly the requested operation for this market and session.
839pub fn verify_order_transaction(context: &OrderVerificationContext<'_>) -> Result<(), String> {
840    let tx = decode_transaction(&context.prepared.transaction_base64)?;
841    let delegated = structural_checks(
842        &tx,
843        context.session_public_key,
844        context.owner_wallet,
845        &context.prepared.recent_blockhash,
846        StructuralOptions {
847            allow_address_tables: false,
848            require_envelope: true,
849        },
850    )?;
851    struct DecodedPlace {
852        place: ExpectedPlace,
853        market: String,
854        order: String,
855    }
856    struct DecodedCancel {
857        market: String,
858        order: String,
859    }
860    let mut places: Vec<DecodedPlace> = Vec::new();
861    let mut cancels: Vec<DecodedCancel> = Vec::new();
862    for instruction in &delegated {
863        match instruction.inner_tag {
864            INNER_TAG_BALANCE | INNER_TAG_MARKET_ACCOUNT => {}
865            INNER_TAG_PLACE_ORDER => {
866                let inner = &instruction.inner;
867                // [tag][side][order_type][0,0][price u64][size u64][expiry u64][bump]
868                if inner.len() < 30 {
869                    return Err("place instruction is truncated".to_owned());
870                }
871                let market = instruction.inner_accounts.get(1).cloned().flatten();
872                let order = instruction.inner_accounts.get(3).cloned().flatten();
873                let (Some(market), Some(order)) = (market, order) else {
874                    return Err("place instruction accounts are not static".to_owned());
875                };
876                places.push(DecodedPlace {
877                    place: ExpectedPlace {
878                        side: inner[1],
879                        order_type: inner[2],
880                        price: read_u64(inner, 5)?,
881                        size: read_u64(inner, 13)?,
882                    },
883                    market,
884                    order,
885                });
886            }
887            INNER_TAG_CANCEL_ORDER => {
888                let market = instruction.inner_accounts.get(1).cloned().flatten();
889                let order = instruction.inner_accounts.get(3).cloned().flatten();
890                let (Some(market), Some(order)) = (market, order) else {
891                    return Err("cancel instruction accounts are not static".to_owned());
892                };
893                cancels.push(DecodedCancel { market, order });
894            }
895            other => {
896                return Err(format!(
897                    "the transaction delegates an unexpected instruction ({other})"
898                ));
899            }
900        }
901    }
902    // Every touched market must be the requested one.
903    let markets: HashSet<&str> = places
904        .iter()
905        .map(|entry| entry.market.as_str())
906        .chain(cancels.iter().map(|entry| entry.market.as_str()))
907        .collect();
908    for market in markets {
909        if opaque_market_id(market) != context.market_id {
910            return Err("the transaction touches another market".to_owned());
911        }
912    }
913    let expected = expected_order_intent(context.operation)?;
914    let decoded_places: Vec<ExpectedPlace> =
915        places.iter().map(|entry| entry.place.clone()).collect();
916    if !same_multiset(&decoded_places, &expected.places, |place| place.clone()) {
917        return Err("the transaction does not place exactly the requested orders".to_owned());
918    }
919    let cancelled_ids = cancels
920        .iter()
921        .map(|entry| {
922            Ok(opaque_order_id(
923                context.market_id,
924                &decode_order_key(&entry.order)?,
925            ))
926        })
927        .collect::<Result<Vec<String>, String>>()?;
928    match &expected.cancels {
929        ExpectedCancels::All => {
930            if cancelled_ids.is_empty() {
931                return Err("cancel_all prepared no cancellation".to_owned());
932            }
933        }
934        ExpectedCancels::Ids(ids) => {
935            if !same_multiset(&cancelled_ids, ids, |id| id.clone()) {
936                return Err(
937                    "the transaction does not cancel exactly the requested orders".to_owned(),
938                );
939            }
940        }
941    }
942    // The echoed order IDs must be exactly the orders this transaction touches.
943    let placed_ids = places
944        .iter()
945        .map(|entry| {
946            Ok(opaque_order_id(
947                context.market_id,
948                &decode_order_key(&entry.order)?,
949            ))
950        })
951        .collect::<Result<Vec<String>, String>>()?;
952    let touched: Vec<String> = cancelled_ids.into_iter().chain(placed_ids).collect();
953    if !same_multiset(&touched, &context.prepared.order_ids, |id| id.clone()) {
954        return Err("prepared order IDs do not match the transaction".to_owned());
955    }
956    Ok(())
957}
958
959/// Structural verification of a prepared TWAP-control transaction: session
960/// co-signs only delegated TWAP instructions and never pays; the bound TWAP
961/// economics are checked against the echoed prepare fields by the client.
962pub fn verify_twap_transaction(context: &TwapVerificationContext<'_>) -> Result<(), String> {
963    let tx = decode_transaction(&context.prepared.transaction_base64)?;
964    let delegated = structural_checks(
965        &tx,
966        context.session_public_key,
967        context.owner_wallet,
968        &context.prepared.recent_blockhash,
969        StructuralOptions {
970            allow_address_tables: false,
971            require_envelope: true,
972        },
973    )?;
974    for instruction in &delegated {
975        if !matches!(
976            instruction.inner_tag,
977            INNER_TAG_BALANCE
978                | INNER_TAG_MARKET_ACCOUNT
979                | INNER_TAG_TWAP_POST
980                | INNER_TAG_TWAP_CANCEL
981        ) {
982            return Err(format!(
983                "the transaction delegates an unexpected instruction ({})",
984                instruction.inner_tag
985            ));
986        }
987    }
988    Ok(())
989}
990
991/// Structural verification of a prepared immediate execution: the session
992/// co-signs only Vault-delegated instructions of one program and never pays.
993/// The bound quote economics are checked against the echoed prepare fields.
994pub fn verify_execution_transaction(
995    context: &ExecutionVerificationContext<'_>,
996) -> Result<(), String> {
997    let tx = decode_transaction(&context.prepared.transaction_base64)?;
998    structural_checks(
999        &tx,
1000        context.session_public_key,
1001        context.owner_wallet,
1002        &context.prepared.recent_blockhash,
1003        StructuralOptions {
1004            allow_address_tables: true,
1005            require_envelope: false,
1006        },
1007    )?;
1008    Ok(())
1009}
1010
1011/// The SDK's built-in verifier: [`verify_order_transaction`],
1012/// [`verify_twap_transaction`], and [`verify_execution_transaction`] behind
1013/// the [`OrderVerifier`], [`TwapVerifier`], and [`ExecutionVerifier`] traits.
1014/// Pass `&DefaultTransactionVerifier` to the one-call `execute_*` helpers
1015/// unless the application enforces a stricter policy of its own.
1016#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1017pub struct DefaultTransactionVerifier;
1018
1019#[async_trait]
1020impl OrderVerifier for DefaultTransactionVerifier {
1021    async fn verify(&self, context: &OrderVerificationContext<'_>) -> Result<(), String> {
1022        verify_order_transaction(context)
1023    }
1024}
1025
1026#[async_trait]
1027impl TwapVerifier for DefaultTransactionVerifier {
1028    async fn verify(&self, context: &TwapVerificationContext<'_>) -> Result<(), String> {
1029        verify_twap_transaction(context)
1030    }
1031}
1032
1033#[async_trait]
1034impl ExecutionVerifier for DefaultTransactionVerifier {
1035    async fn verify(&self, context: &ExecutionVerificationContext<'_>) -> Result<(), String> {
1036        verify_execution_transaction(context)
1037    }
1038}
1039
1040/// Synthetic delegated-place transactions shared by the verifier unit tests
1041/// and the one-signature client tests.
1042#[cfg(test)]
1043pub(crate) mod test_support {
1044    use super::*;
1045
1046    pub(crate) const OWNER_WALLET: &str = "5Ji61Fbeb22Yntgv1hhHeSSLgdEdZchHeM1Tv1MjGhSL";
1047    pub(crate) const SESSION_PUBLIC_KEY: &str = "9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2";
1048    pub(crate) const FEE_PAYER: [u8; 32] = [1; 32];
1049    pub(crate) const MARKET_PDA: [u8; 32] = [2; 32];
1050    pub(crate) const ORDER_PDA: [u8; 32] = [3; 32];
1051    pub(crate) const RECENT_BLOCKHASH: [u8; 32] = [5; 32];
1052    const VAULT_PROGRAM: [u8; 32] = [11; 32];
1053    const STRATA_PROGRAM: [u8; 32] = [12; 32];
1054    const VAULT_PDA: [u8; 32] = [13; 32];
1055    const DELEGATE_PDA: [u8; 32] = [14; 32];
1056    const USER_ACCOUNT: [u8; 32] = [15; 32];
1057    const RENT_BANK: [u8; 32] = [16; 32];
1058    pub(crate) const PLACE_PRICE: u64 = 150_000_000;
1059    pub(crate) const PLACE_SIZE: u64 = 1_000_000_000;
1060
1061    #[derive(Clone, Copy, Debug, Default)]
1062    pub(crate) struct PlaceTransactionOptions {
1063        /// Wire side (`0` buy, `1` sell).
1064        pub(crate) side: u8,
1065        /// Put the session key in the fee-payer slot.
1066        pub(crate) session_pays: bool,
1067        /// Append a session-signed system transfer.
1068        pub(crate) extra_system_transfer: bool,
1069        /// Place on another market key.
1070        pub(crate) market: Option<[u8; 32]>,
1071    }
1072
1073    pub(crate) fn key(value: &str) -> [u8; 32] {
1074        bs58::decode(value).into_vec().unwrap().try_into().unwrap()
1075    }
1076
1077    pub(crate) fn market_id() -> String {
1078        opaque_market_id(&bs58::encode(MARKET_PDA).into_string())
1079    }
1080
1081    pub(crate) fn order_id() -> String {
1082        opaque_order_id(&market_id(), &ORDER_PDA)
1083    }
1084
1085    pub(crate) fn recent_blockhash() -> String {
1086        bs58::encode(RECENT_BLOCKHASH).into_string()
1087    }
1088
1089    fn compact(mut value: usize) -> Vec<u8> {
1090        let mut out = Vec::new();
1091        loop {
1092            let byte = (value & 0x7f) as u8;
1093            value >>= 7;
1094            if value == 0 {
1095                out.push(byte);
1096                return out;
1097            }
1098            out.push(byte | 0x80);
1099        }
1100    }
1101
1102    fn envelope(inner: &[u8]) -> Vec<u8> {
1103        let mut data = vec![DELEGATED_ENVELOPE_TAG];
1104        data.extend_from_slice(&0u64.to_le_bytes());
1105        data.extend_from_slice(&[0, 0]);
1106        data.extend_from_slice(&(inner.len() as u16).to_le_bytes());
1107        data.push(6);
1108        data.extend_from_slice(inner);
1109        data.extend_from_slice(&[2; 6]);
1110        data
1111    }
1112
1113    fn place_inner(side: u8, order_type: u8, price: u64, size: u64) -> Vec<u8> {
1114        let mut inner = vec![INNER_TAG_PLACE_ORDER, side, order_type, 0, 0];
1115        inner.extend_from_slice(&price.to_le_bytes());
1116        inner.extend_from_slice(&size.to_le_bytes());
1117        inner.extend_from_slice(&0u64.to_le_bytes());
1118        inner.push(255);
1119        inner
1120    }
1121
1122    fn instruction(program: u8, accounts: &[u8], data: &[u8]) -> Vec<u8> {
1123        let mut out = vec![program];
1124        out.extend(compact(accounts.len()));
1125        out.extend_from_slice(accounts);
1126        out.extend(compact(data.len()));
1127        out.extend_from_slice(data);
1128        out
1129    }
1130
1131    /// A v0 transaction: fee payer + session sign; one compute-budget
1132    /// instruction (fee payer only) and one delegated place through the Vault
1133    /// program targeting the Strata program.
1134    pub(crate) fn place_transaction(options: PlaceTransactionOptions) -> String {
1135        let session = key(SESSION_PUBLIC_KEY);
1136        let owner = key(OWNER_WALLET);
1137        let system = key("11111111111111111111111111111111");
1138        let compute_budget = key("ComputeBudget111111111111111111111111111111");
1139        let mut keys: Vec<[u8; 32]> = if options.session_pays {
1140            vec![session, FEE_PAYER]
1141        } else {
1142            vec![FEE_PAYER, session]
1143        };
1144        keys.extend([
1145            VAULT_PDA,
1146            DELEGATE_PDA,
1147            USER_ACCOUNT,
1148            ORDER_PDA,
1149            RENT_BANK,
1150            MARKET_PDA,
1151            owner,
1152            VAULT_PROGRAM,
1153            STRATA_PROGRAM,
1154            system,
1155            compute_budget,
1156        ]);
1157        if let Some(market) = options.market {
1158            keys.push(market);
1159        }
1160        let at = |wanted: [u8; 32]| -> u8 {
1161            keys.iter()
1162                .position(|candidate| *candidate == wanted)
1163                .unwrap() as u8
1164        };
1165        let mut instructions = Vec::new();
1166        instructions.push(instruction(at(compute_budget), &[], &[2, 0, 0, 0, 0]));
1167        let market = options.market.unwrap_or(MARKET_PDA);
1168        // Delegated place: [session, vault, delegate, strataProgram, owner,
1169        // feePayer, inner: vault, market, userAccount, order, system, rentBank]
1170        let place_accounts = [
1171            at(session),
1172            at(VAULT_PDA),
1173            at(DELEGATE_PDA),
1174            at(STRATA_PROGRAM),
1175            at(owner),
1176            at(FEE_PAYER),
1177            at(VAULT_PDA),
1178            at(market),
1179            at(USER_ACCOUNT),
1180            at(ORDER_PDA),
1181            at(system),
1182            at(RENT_BANK),
1183        ];
1184        let place_data = envelope(&place_inner(options.side, 3, PLACE_PRICE, PLACE_SIZE));
1185        instructions.push(instruction(at(VAULT_PROGRAM), &place_accounts, &place_data));
1186        if options.extra_system_transfer {
1187            let mut data = vec![2, 0, 0, 0];
1188            data.extend_from_slice(&1u64.to_le_bytes());
1189            instructions.push(instruction(
1190                at(system),
1191                &[at(session), at(FEE_PAYER)],
1192                &data,
1193            ));
1194        }
1195        let mut message = vec![0x80, 2, 0, 5];
1196        message.extend(compact(keys.len()));
1197        for key in &keys {
1198            message.extend_from_slice(key);
1199        }
1200        message.extend_from_slice(&RECENT_BLOCKHASH);
1201        message.extend(compact(instructions.len()));
1202        for instruction in &instructions {
1203            message.extend_from_slice(instruction);
1204        }
1205        message.extend(compact(0));
1206        let mut wire = compact(2);
1207        wire.extend_from_slice(&[0u8; 128]);
1208        wire.extend_from_slice(&message);
1209        base64::engine::general_purpose::STANDARD.encode(wire)
1210    }
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215    use super::test_support::*;
1216    use super::*;
1217    use crate::{PlatformOrderAction, PlatformOrderPrepareResponse};
1218
1219    fn prepared(transaction_base64: String) -> PlatformOrderPrepareResponse {
1220        PlatformOrderPrepareResponse {
1221            schema_version: 2,
1222            contract_version: "2.0".to_owned(),
1223            order_control_id: "or_44444444444444444444444444444444".to_owned(),
1224            market_id: market_id(),
1225            action: PlatformOrderAction::Place,
1226            order_ids: vec![order_id()],
1227            transaction_base64,
1228            recent_blockhash: recent_blockhash(),
1229            last_valid_block_height: 400_000_000,
1230            expires_at_ms: 1_786_550_460_000,
1231        }
1232    }
1233
1234    fn operation() -> PlatformOrderChallengeRequest {
1235        PlatformOrderChallengeRequest::Place {
1236            owner_wallet: OWNER_WALLET.to_owned(),
1237            session_public_key: SESSION_PUBLIC_KEY.to_owned(),
1238            account_sequence: None,
1239            client_order_id: "agent-42".to_owned(),
1240            side: PlatformTradeSide::Buy,
1241            order_type: PlatformOrderType::PostOnly,
1242            limit_price_atoms: PLACE_PRICE.to_string(),
1243            size_atoms: PLACE_SIZE.to_string(),
1244        }
1245    }
1246
1247    fn verify(options: PlaceTransactionOptions) -> Result<(), String> {
1248        let prepared = prepared(place_transaction(options));
1249        let operation = operation();
1250        let market_id = market_id();
1251        verify_order_transaction(&OrderVerificationContext {
1252            challenge: None,
1253            operation: &operation,
1254            market_id: &market_id,
1255            prepared: &prepared,
1256            owner_wallet: OWNER_WALLET,
1257            session_public_key: SESSION_PUBLIC_KEY,
1258        })
1259    }
1260
1261    #[test]
1262    fn decodes_a_v0_transaction_with_static_keys_and_instructions() {
1263        let decoded =
1264            decode_transaction(&place_transaction(PlaceTransactionOptions::default())).unwrap();
1265        assert_eq!(decoded.version, TransactionVersion::V0);
1266        assert_eq!(decoded.signature_count, 2);
1267        assert_eq!(decoded.num_required_signatures, 2);
1268        assert_eq!(decoded.num_readonly_signed, 0);
1269        assert_eq!(decoded.num_readonly_unsigned, 5);
1270        assert_eq!(decoded.static_account_keys.len(), 13);
1271        assert_eq!(decoded.static_account_keys[1], SESSION_PUBLIC_KEY);
1272        assert_eq!(decoded.recent_blockhash, recent_blockhash());
1273        assert_eq!(decoded.instructions.len(), 2);
1274        assert_eq!(decoded.instructions[1].account_indexes.len(), 12);
1275        assert_eq!(decoded.instructions[1].data[0], DELEGATED_ENVELOPE_TAG);
1276        assert_eq!(decoded.address_table_lookup_count, 0);
1277    }
1278
1279    #[test]
1280    fn decoder_rejects_truncated_and_trailing_bytes() {
1281        let raw = base64::engine::general_purpose::STANDARD
1282            .decode(place_transaction(PlaceTransactionOptions::default()))
1283            .unwrap();
1284        let truncated = base64::engine::general_purpose::STANDARD.encode(&raw[..raw.len() - 1]);
1285        assert_eq!(
1286            decode_transaction(&truncated).unwrap_err(),
1287            "transaction is truncated"
1288        );
1289        let mut trailing = raw.clone();
1290        trailing.push(0);
1291        assert_eq!(
1292            decode_transaction(&base64::engine::general_purpose::STANDARD.encode(trailing))
1293                .unwrap_err(),
1294            "transaction carries trailing bytes"
1295        );
1296        assert_eq!(
1297            decode_transaction("not base64!").unwrap_err(),
1298            "invalid base64 payload"
1299        );
1300    }
1301
1302    #[test]
1303    fn external_signer_may_change_signatures_but_not_the_message() {
1304        let prepared = place_transaction(PlaceTransactionOptions::default());
1305        let mut signed = base64::engine::general_purpose::STANDARD
1306            .decode(&prepared)
1307            .unwrap();
1308        signed[1] = 9;
1309        let signed = base64::engine::general_purpose::STANDARD.encode(&signed);
1310        verify_signed_transaction_message(&prepared, &signed).unwrap();
1311
1312        let mut changed = base64::engine::general_purpose::STANDARD
1313            .decode(&signed)
1314            .unwrap();
1315        *changed.last_mut().unwrap() ^= 1;
1316        let error = verify_signed_transaction_message(
1317            &prepared,
1318            &base64::engine::general_purpose::STANDARD.encode(changed),
1319        )
1320        .unwrap_err();
1321        assert!(error.contains("message changed"), "{error}");
1322    }
1323
1324    #[test]
1325    fn built_in_verifier_accepts_exactly_the_requested_place() {
1326        verify(PlaceTransactionOptions::default()).unwrap();
1327    }
1328
1329    #[test]
1330    fn built_in_verifier_refuses_a_different_side() {
1331        let error = verify(PlaceTransactionOptions {
1332            side: 1,
1333            ..PlaceTransactionOptions::default()
1334        })
1335        .unwrap_err();
1336        assert!(error.contains("exactly the requested orders"), "{error}");
1337    }
1338
1339    #[test]
1340    fn built_in_verifier_refuses_the_session_as_fee_payer() {
1341        let error = verify(PlaceTransactionOptions {
1342            session_pays: true,
1343            ..PlaceTransactionOptions::default()
1344        })
1345        .unwrap_err();
1346        assert!(error.contains("fee payer"), "{error}");
1347    }
1348
1349    #[test]
1350    fn built_in_verifier_refuses_a_session_signed_system_transfer() {
1351        let error = verify(PlaceTransactionOptions {
1352            extra_system_transfer: true,
1353            ..PlaceTransactionOptions::default()
1354        })
1355        .unwrap_err();
1356        assert!(error.contains("system or token instruction"), "{error}");
1357    }
1358
1359    #[test]
1360    fn built_in_verifier_refuses_another_market() {
1361        let error = verify(PlaceTransactionOptions {
1362            market: Some([7; 32]),
1363            ..PlaceTransactionOptions::default()
1364        })
1365        .unwrap_err();
1366        assert!(error.contains("another market"), "{error}");
1367    }
1368
1369    #[test]
1370    fn built_in_verifier_binds_the_echoed_order_ids_and_blockhash() {
1371        let transaction = place_transaction(PlaceTransactionOptions::default());
1372        let operation = operation();
1373        let market_id = market_id();
1374        let mut wrong_ids = prepared(transaction.clone());
1375        wrong_ids.order_ids = vec!["order_33333333333333333333333333333333".to_owned()];
1376        let error = verify_order_transaction(&OrderVerificationContext {
1377            challenge: None,
1378            operation: &operation,
1379            market_id: &market_id,
1380            prepared: &wrong_ids,
1381            owner_wallet: OWNER_WALLET,
1382            session_public_key: SESSION_PUBLIC_KEY,
1383        })
1384        .unwrap_err();
1385        assert!(error.contains("order IDs do not match"), "{error}");
1386        let mut wrong_blockhash = prepared(transaction);
1387        wrong_blockhash.recent_blockhash = bs58::encode([9u8; 32]).into_string();
1388        let error = verify_order_transaction(&OrderVerificationContext {
1389            challenge: None,
1390            operation: &operation,
1391            market_id: &market_id,
1392            prepared: &wrong_blockhash,
1393            owner_wallet: OWNER_WALLET,
1394            session_public_key: SESSION_PUBLIC_KEY,
1395        })
1396        .unwrap_err();
1397        assert!(error.contains("blockhash"), "{error}");
1398    }
1399
1400    #[test]
1401    fn twap_and_execution_verification_are_structural() {
1402        // A place envelope is not a TWAP instruction: the TWAP verifier rejects
1403        // it by inner tag while the execution verifier (structure only) accepts
1404        // the same session-signed envelope.
1405        let transaction = place_transaction(PlaceTransactionOptions::default());
1406        let twap_prepared = crate::PlatformTwapPrepareResponse {
1407            schema_version: 2,
1408            contract_version: "2.0".to_owned(),
1409            twap_control_id: "twctl_44444444444444444444444444444444".to_owned(),
1410            market_id: market_id(),
1411            action: crate::PlatformTwapControlAction::Place,
1412            twap_id: "twap_33333333333333333333333333333333".to_owned(),
1413            transaction_base64: transaction.clone(),
1414            recent_blockhash: recent_blockhash(),
1415            last_valid_block_height: 1,
1416            expires_at_ms: 1,
1417        };
1418        let twap_operation = crate::PlatformTwapChallengeRequest::Place {
1419            owner_wallet: OWNER_WALLET.to_owned(),
1420            session_public_key: SESSION_PUBLIC_KEY.to_owned(),
1421            side: PlatformTradeSide::Buy,
1422            total_size_atoms: "10".to_owned(),
1423            slices_total: 2,
1424            maximum_tolerance_bps: 1,
1425            interval_slots: 25,
1426            limit_price_atoms: "1".to_owned(),
1427        };
1428        let market_id = market_id();
1429        let error = verify_twap_transaction(&TwapVerificationContext {
1430            challenge: None,
1431            operation: &twap_operation,
1432            market_id: &market_id,
1433            prepared: &twap_prepared,
1434            owner_wallet: OWNER_WALLET,
1435            session_public_key: SESSION_PUBLIC_KEY,
1436        })
1437        .unwrap_err();
1438        assert!(error.contains("unexpected instruction (33)"), "{error}");
1439
1440        let quote: crate::QuoteResponse =
1441            serde_json::from_str(strata_public_contract::contract_fixtures::QUOTE).unwrap();
1442        let execution_prepared = crate::ExecutionPrepareResponse {
1443            schema_version: 1,
1444            contract_version: "1.1".to_owned(),
1445            execution_id: "se_0123456789abcdef0123456789abcdef".to_owned(),
1446            quote_id: quote.quote_id.clone(),
1447            market_id: quote.market_id.clone(),
1448            side: quote.side,
1449            amount_in_atoms: quote.amount_in_atoms.clone(),
1450            minimum_output_atoms: quote.minimum_output_atoms.clone(),
1451            transaction_base64: transaction,
1452            recent_blockhash: recent_blockhash(),
1453            last_valid_block_height: 1,
1454            expires_at_ms: 1,
1455        };
1456        verify_execution_transaction(&ExecutionVerificationContext {
1457            quote: &quote,
1458            challenge: None,
1459            prepared: &execution_prepared,
1460            owner_wallet: OWNER_WALLET,
1461            session_public_key: SESSION_PUBLIC_KEY,
1462        })
1463        .unwrap();
1464    }
1465}