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