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    OrderVerificationContext, OrderVerifier, PlatformOrderBatchOperation,
32    PlatformOrderChallengeRequest, PlatformOrderType, PlatformTradeSide, TwapVerificationContext,
33    TwapVerifier,
34};
35
36/// Programs a Vault session key must never sign for directly.
37const WELL_KNOWN_PROGRAMS: [&str; 10] = [
38    "11111111111111111111111111111111",             // system
39    "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",  // token
40    "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",  // token-2022
41    "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", // associated token
42    "ComputeBudget111111111111111111111111111111",  // compute budget
43    "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",  // memo
44    "Stake11111111111111111111111111111111111111",  // stake
45    "Vote111111111111111111111111111111111111111",  // vote
46    "AddressLookupTab1e1111111111111111111111111",  // address lookup tables
47    "BPFLoaderUpgradeab1e11111111111111111111111",  // upgradeable loader
48];
49
50/// Delegated-instruction envelope tag (`execute_with_delegate`).
51const DELEGATED_ENVELOPE_TAG: u8 = 3;
52/// Inner instruction tags a delegated order-control transaction may carry.
53const INNER_TAG_BALANCE: u8 = 1;
54const INNER_TAG_CANCEL_ORDER: u8 = 4;
55const INNER_TAG_PLACE_ORDER: u8 = 33;
56const INNER_TAG_MARKET_ACCOUNT: u8 = 34;
57const INNER_TAG_TWAP_CANCEL: u8 = 36;
58const INNER_TAG_TWAP_POST: u8 = 38;
59
60/// Message version of a decoded transaction.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum TransactionVersion {
63    Legacy,
64    V0,
65}
66
67/// One compiled instruction exactly as it appears in the message.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct DecodedInstruction {
70    pub program_id_index: u8,
71    pub account_indexes: Vec<u8>,
72    pub data: Vec<u8>,
73}
74
75/// A decoded legacy or v0 transaction envelope. Address-table lookups are
76/// counted but never resolved: verification only reasons about static keys.
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct DecodedTransaction {
79    pub version: TransactionVersion,
80    pub signature_count: usize,
81    pub num_required_signatures: u8,
82    pub num_readonly_signed: u8,
83    pub num_readonly_unsigned: u8,
84    /// Base58 static account keys, in message order.
85    pub static_account_keys: Vec<String>,
86    pub recent_blockhash: String,
87    pub instructions: Vec<DecodedInstruction>,
88    pub address_table_lookup_count: usize,
89}
90
91struct Reader<'a> {
92    bytes: &'a [u8],
93    offset: usize,
94}
95
96impl<'a> Reader<'a> {
97    fn new(bytes: &'a [u8]) -> Self {
98        Self { bytes, offset: 0 }
99    }
100
101    fn u8(&mut self) -> Result<u8, String> {
102        let byte = *self
103            .bytes
104            .get(self.offset)
105            .ok_or_else(|| "transaction is truncated".to_owned())?;
106        self.offset += 1;
107        Ok(byte)
108    }
109
110    fn compact_u16(&mut self) -> Result<usize, String> {
111        let mut value = 0usize;
112        let mut shift = 0u32;
113        for _ in 0..3 {
114            let byte = self.u8()?;
115            value |= usize::from(byte & 0x7f) << shift;
116            if byte & 0x80 == 0 {
117                return Ok(value);
118            }
119            shift += 7;
120        }
121        Err("transaction length prefix is invalid".to_owned())
122    }
123
124    fn take(&mut self, length: usize) -> Result<&'a [u8], String> {
125        let end = self
126            .offset
127            .checked_add(length)
128            .filter(|end| *end <= self.bytes.len())
129            .ok_or_else(|| "transaction is truncated".to_owned())?;
130        let out = &self.bytes[self.offset..end];
131        self.offset = end;
132        Ok(out)
133    }
134
135    fn done(&self) -> bool {
136        self.offset == self.bytes.len()
137    }
138}
139
140/// Decode a base64 legacy or v0 transaction without any RPC.
141pub fn decode_transaction(transaction_base64: &str) -> Result<DecodedTransaction, String> {
142    let bytes = base64::engine::general_purpose::STANDARD
143        .decode(transaction_base64.trim())
144        .map_err(|_| "invalid base64 payload".to_owned())?;
145    let mut reader = Reader::new(&bytes);
146    let signature_count = reader.compact_u16()?;
147    reader.take(signature_count.saturating_mul(64))?;
148    let mut first = reader.u8()?;
149    let mut version = TransactionVersion::Legacy;
150    if first & 0x80 != 0 {
151        if first & 0x7f != 0 {
152            return Err("unsupported transaction version".to_owned());
153        }
154        version = TransactionVersion::V0;
155        first = reader.u8()?;
156    }
157    let num_required_signatures = first;
158    let num_readonly_signed = reader.u8()?;
159    let num_readonly_unsigned = reader.u8()?;
160    let key_count = reader.compact_u16()?;
161    let mut static_account_keys = Vec::with_capacity(key_count);
162    for _ in 0..key_count {
163        static_account_keys.push(bs58::encode(reader.take(32)?).into_string());
164    }
165    let recent_blockhash = bs58::encode(reader.take(32)?).into_string();
166    let instruction_count = reader.compact_u16()?;
167    let mut instructions = Vec::with_capacity(instruction_count);
168    for _ in 0..instruction_count {
169        let program_id_index = reader.u8()?;
170        let account_count = reader.compact_u16()?;
171        let account_indexes = reader.take(account_count)?.to_vec();
172        let data_length = reader.compact_u16()?;
173        let data = reader.take(data_length)?.to_vec();
174        instructions.push(DecodedInstruction {
175            program_id_index,
176            account_indexes,
177            data,
178        });
179    }
180    let mut address_table_lookup_count = 0;
181    if version == TransactionVersion::V0 {
182        address_table_lookup_count = reader.compact_u16()?;
183        for _ in 0..address_table_lookup_count {
184            reader.take(32)?;
185            let writable = reader.compact_u16()?;
186            reader.take(writable)?;
187            let readonly = reader.compact_u16()?;
188            reader.take(readonly)?;
189        }
190    }
191    if !reader.done() {
192        return Err("transaction carries trailing bytes".to_owned());
193    }
194    if signature_count != usize::from(num_required_signatures) || num_required_signatures == 0 {
195        return Err("transaction signature layout is invalid".to_owned());
196    }
197    if static_account_keys.len() < usize::from(num_required_signatures) {
198        return Err("transaction signer layout is invalid".to_owned());
199    }
200    Ok(DecodedTransaction {
201        version,
202        signature_count,
203        num_required_signatures,
204        num_readonly_signed,
205        num_readonly_unsigned,
206        static_account_keys,
207        recent_blockhash,
208        instructions,
209        address_table_lookup_count,
210    })
211}
212
213struct DelegatedInstruction {
214    inner_tag: u8,
215    inner: Vec<u8>,
216    /// Base58 keys of the inner instruction's accounts, in order (`None` when
217    /// an index points outside the static key table).
218    inner_accounts: Vec<Option<String>>,
219}
220
221struct StructuralOptions {
222    allow_address_tables: bool,
223    require_envelope: bool,
224}
225
226/// The structural invariants every session-signed Strata transaction must
227/// hold. Returns the delegated instructions for the caller's own decoding.
228fn structural_checks(
229    tx: &DecodedTransaction,
230    session_public_key: &str,
231    owner_wallet: &str,
232    recent_blockhash: &str,
233    options: StructuralOptions,
234) -> Result<Vec<DelegatedInstruction>, String> {
235    if tx.recent_blockhash != recent_blockhash {
236        return Err("prepared transaction blockhash does not match".to_owned());
237    }
238    let keys = &tx.static_account_keys;
239    let required = usize::from(tx.num_required_signatures);
240    let session_index = keys
241        .iter()
242        .position(|key| key == session_public_key)
243        .filter(|index| *index < required)
244        .ok_or_else(|| "the session key is not a required signer".to_owned())?;
245    if session_index == 0 {
246        return Err("the session key must never be the fee payer".to_owned());
247    }
248    if let Some(owner_index) = keys.iter().position(|key| key == owner_wallet) {
249        if owner_index < required {
250            return Err("the owner wallet must not be asked to sign".to_owned());
251        }
252    }
253    if !options.allow_address_tables && tx.address_table_lookup_count != 0 {
254        return Err("order-control transactions carry no lookup tables".to_owned());
255    }
256    let session_index_u8 = u8::try_from(session_index)
257        .map_err(|_| "transaction signer layout is invalid".to_owned())?;
258    let mut envelope_program: Option<&str> = None;
259    let mut inner_program: Option<&str> = None;
260    let mut delegated = Vec::new();
261    for instruction in &tx.instructions {
262        if !instruction.account_indexes.contains(&session_index_u8) {
263            continue;
264        }
265        let program = keys
266            .get(usize::from(instruction.program_id_index))
267            .ok_or_else(|| "instruction program is not static".to_owned())?;
268        if WELL_KNOWN_PROGRAMS.contains(&program.as_str()) {
269            return Err("the session key must not sign a system or token instruction".to_owned());
270        }
271        match envelope_program {
272            None => envelope_program = Some(program),
273            Some(existing) if existing != program => {
274                return Err("the session key signs for more than one program".to_owned());
275            }
276            Some(_) => {}
277        }
278        if instruction.account_indexes.first() != Some(&session_index_u8) {
279            return Err("the session key is not the delegate signer".to_owned());
280        }
281        if !options.require_envelope {
282            continue;
283        }
284        let data = &instruction.data;
285        if data.len() < 14 || data[0] != DELEGATED_ENVELOPE_TAG {
286            return Err("the session key signs a non-delegated instruction".to_owned());
287        }
288        let inner_length = usize::from(data[11]) | (usize::from(data[12]) << 8);
289        let inner_end = 14 + inner_length;
290        if inner_length == 0 || inner_end > data.len() {
291            return Err("delegated instruction is malformed".to_owned());
292        }
293        let inner = data[14..inner_end].to_vec();
294        let inner_program_key = instruction
295            .account_indexes
296            .get(3)
297            .and_then(|index| keys.get(usize::from(*index)))
298            .ok_or_else(|| "delegated instruction target is not static".to_owned())?;
299        match inner_program {
300            None => inner_program = Some(inner_program_key),
301            Some(existing) if existing != inner_program_key => {
302                return Err("delegated instructions target more than one program".to_owned());
303            }
304            Some(_) => {}
305        }
306        delegated.push(DelegatedInstruction {
307            inner_tag: inner[0],
308            inner,
309            inner_accounts: instruction
310                .account_indexes
311                .iter()
312                .skip(6)
313                .map(|index| keys.get(usize::from(*index)).cloned())
314                .collect(),
315        });
316    }
317    if delegated.is_empty() && options.require_envelope {
318        return Err("the transaction carries no delegated instruction".to_owned());
319    }
320    Ok(delegated)
321}
322
323fn read_u64(bytes: &[u8], offset: usize) -> Result<u64, String> {
324    let slice = bytes
325        .get(offset..offset + 8)
326        .ok_or_else(|| "delegated instruction is truncated".to_owned())?;
327    let mut array = [0u8; 8];
328    array.copy_from_slice(slice);
329    Ok(u64::from_le_bytes(array))
330}
331
332const fn side_wire(side: PlatformTradeSide) -> u8 {
333    match side {
334        PlatformTradeSide::Buy => 0,
335        PlatformTradeSide::Sell => 1,
336    }
337}
338
339fn order_type_wire(order_type: PlatformOrderType) -> Result<u8, String> {
340    match order_type {
341        PlatformOrderType::GoodUntilCancelled => Ok(0),
342        PlatformOrderType::PostOnly => Ok(3),
343        PlatformOrderType::ImmediateOrCancel | PlatformOrderType::FillOrKill => {
344            Err("order type is not a resting order".to_owned())
345        }
346    }
347}
348
349fn atoms(value: &str, field: &str) -> Result<u64, String> {
350    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
351        return Err(format!("{field} must be an unsigned atomic decimal string"));
352    }
353    value
354        .parse::<u64>()
355        .map_err(|_| format!("{field} exceeds u64"))
356}
357
358#[derive(Clone, Debug, Eq, Hash, PartialEq)]
359struct ExpectedPlace {
360    side: u8,
361    order_type: u8,
362    price: u64,
363    size: u64,
364}
365
366impl ExpectedPlace {
367    fn from_request(
368        side: PlatformTradeSide,
369        order_type: PlatformOrderType,
370        limit_price_atoms: &str,
371        size_atoms: &str,
372    ) -> Result<Self, String> {
373        Ok(Self {
374            side: side_wire(side),
375            order_type: order_type_wire(order_type)?,
376            price: atoms(limit_price_atoms, "limit_price_atoms")?,
377            size: atoms(size_atoms, "size_atoms")?,
378        })
379    }
380}
381
382enum ExpectedCancels {
383    /// Order IDs that must be cancelled.
384    Ids(Vec<String>),
385    /// `cancel_all`: at least one cancellation, identities chosen server-side.
386    All,
387}
388
389struct ExpectedOrderIntent {
390    places: Vec<ExpectedPlace>,
391    cancels: ExpectedCancels,
392}
393
394fn expected_order_intent(
395    operation: &PlatformOrderChallengeRequest,
396) -> Result<ExpectedOrderIntent, String> {
397    Ok(match operation {
398        PlatformOrderChallengeRequest::Place {
399            side,
400            order_type,
401            limit_price_atoms,
402            size_atoms,
403            ..
404        } => ExpectedOrderIntent {
405            places: vec![ExpectedPlace::from_request(
406                *side,
407                *order_type,
408                limit_price_atoms,
409                size_atoms,
410            )?],
411            cancels: ExpectedCancels::Ids(Vec::new()),
412        },
413        PlatformOrderChallengeRequest::Cancel { order_id, .. } => ExpectedOrderIntent {
414            places: Vec::new(),
415            cancels: ExpectedCancels::Ids(vec![order_id.clone()]),
416        },
417        PlatformOrderChallengeRequest::CancelAll { .. } => ExpectedOrderIntent {
418            places: Vec::new(),
419            cancels: ExpectedCancels::All,
420        },
421        PlatformOrderChallengeRequest::Replace {
422            order_id,
423            side,
424            order_type,
425            limit_price_atoms,
426            size_atoms,
427            ..
428        } => ExpectedOrderIntent {
429            places: vec![ExpectedPlace::from_request(
430                *side,
431                *order_type,
432                limit_price_atoms,
433                size_atoms,
434            )?],
435            cancels: ExpectedCancels::Ids(vec![order_id.clone()]),
436        },
437        PlatformOrderChallengeRequest::Batch { operations, .. } => {
438            let mut places = Vec::new();
439            let mut cancels = Vec::new();
440            for item in operations {
441                match item {
442                    PlatformOrderBatchOperation::Place {
443                        side,
444                        order_type,
445                        limit_price_atoms,
446                        size_atoms,
447                        ..
448                    } => places.push(ExpectedPlace::from_request(
449                        *side,
450                        *order_type,
451                        limit_price_atoms,
452                        size_atoms,
453                    )?),
454                    PlatformOrderBatchOperation::Cancel { order_id } => {
455                        cancels.push(order_id.clone());
456                    }
457                    PlatformOrderBatchOperation::Replace {
458                        order_id,
459                        side,
460                        order_type,
461                        limit_price_atoms,
462                        size_atoms,
463                        ..
464                    } => {
465                        cancels.push(order_id.clone());
466                        places.push(ExpectedPlace::from_request(
467                            *side,
468                            *order_type,
469                            limit_price_atoms,
470                            size_atoms,
471                        )?);
472                    }
473                }
474            }
475            ExpectedOrderIntent {
476                places,
477                cancels: ExpectedCancels::Ids(cancels),
478            }
479        }
480    })
481}
482
483fn same_multiset<T, K, F>(left: &[T], right: &[T], key: F) -> bool
484where
485    K: std::hash::Hash + Eq,
486    F: Fn(&T) -> K,
487{
488    if left.len() != right.len() {
489        return false;
490    }
491    let mut counts: HashMap<K, usize> = HashMap::new();
492    for value in left {
493        *counts.entry(key(value)).or_insert(0) += 1;
494    }
495    for value in right {
496        match counts.get_mut(&key(value)) {
497            Some(remaining) if *remaining > 0 => *remaining -= 1,
498            _ => return false,
499        }
500    }
501    true
502}
503
504fn decode_order_key(order: &str) -> Result<Vec<u8>, String> {
505    bs58::decode(order)
506        .into_vec()
507        .map_err(|_| "order account key is not base58".to_owned())
508}
509
510/// Deny-by-default verification of a prepared resting-order transaction: it
511/// must be exactly the requested operation for this market and session.
512pub fn verify_order_transaction(context: &OrderVerificationContext<'_>) -> Result<(), String> {
513    let tx = decode_transaction(&context.prepared.transaction_base64)?;
514    let delegated = structural_checks(
515        &tx,
516        context.session_public_key,
517        context.owner_wallet,
518        &context.prepared.recent_blockhash,
519        StructuralOptions {
520            allow_address_tables: false,
521            require_envelope: true,
522        },
523    )?;
524    struct DecodedPlace {
525        place: ExpectedPlace,
526        market: String,
527        order: String,
528    }
529    struct DecodedCancel {
530        market: String,
531        order: String,
532    }
533    let mut places: Vec<DecodedPlace> = Vec::new();
534    let mut cancels: Vec<DecodedCancel> = Vec::new();
535    for instruction in &delegated {
536        match instruction.inner_tag {
537            INNER_TAG_BALANCE | INNER_TAG_MARKET_ACCOUNT => {}
538            INNER_TAG_PLACE_ORDER => {
539                let inner = &instruction.inner;
540                // [tag][side][order_type][0,0][price u64][size u64][expiry u64][bump]
541                if inner.len() < 30 {
542                    return Err("place instruction is truncated".to_owned());
543                }
544                let market = instruction.inner_accounts.get(1).cloned().flatten();
545                let order = instruction.inner_accounts.get(3).cloned().flatten();
546                let (Some(market), Some(order)) = (market, order) else {
547                    return Err("place instruction accounts are not static".to_owned());
548                };
549                places.push(DecodedPlace {
550                    place: ExpectedPlace {
551                        side: inner[1],
552                        order_type: inner[2],
553                        price: read_u64(inner, 5)?,
554                        size: read_u64(inner, 13)?,
555                    },
556                    market,
557                    order,
558                });
559            }
560            INNER_TAG_CANCEL_ORDER => {
561                let market = instruction.inner_accounts.get(1).cloned().flatten();
562                let order = instruction.inner_accounts.get(3).cloned().flatten();
563                let (Some(market), Some(order)) = (market, order) else {
564                    return Err("cancel instruction accounts are not static".to_owned());
565                };
566                cancels.push(DecodedCancel { market, order });
567            }
568            other => {
569                return Err(format!(
570                    "the transaction delegates an unexpected instruction ({other})"
571                ));
572            }
573        }
574    }
575    // Every touched market must be the requested one.
576    let markets: HashSet<&str> = places
577        .iter()
578        .map(|entry| entry.market.as_str())
579        .chain(cancels.iter().map(|entry| entry.market.as_str()))
580        .collect();
581    for market in markets {
582        if opaque_market_id(market) != context.market_id {
583            return Err("the transaction touches another market".to_owned());
584        }
585    }
586    let expected = expected_order_intent(context.operation)?;
587    let decoded_places: Vec<ExpectedPlace> =
588        places.iter().map(|entry| entry.place.clone()).collect();
589    if !same_multiset(&decoded_places, &expected.places, |place| place.clone()) {
590        return Err("the transaction does not place exactly the requested orders".to_owned());
591    }
592    let cancelled_ids = cancels
593        .iter()
594        .map(|entry| {
595            Ok(opaque_order_id(
596                context.market_id,
597                &decode_order_key(&entry.order)?,
598            ))
599        })
600        .collect::<Result<Vec<String>, String>>()?;
601    match &expected.cancels {
602        ExpectedCancels::All => {
603            if cancelled_ids.is_empty() {
604                return Err("cancel_all prepared no cancellation".to_owned());
605            }
606        }
607        ExpectedCancels::Ids(ids) => {
608            if !same_multiset(&cancelled_ids, ids, |id| id.clone()) {
609                return Err(
610                    "the transaction does not cancel exactly the requested orders".to_owned(),
611                );
612            }
613        }
614    }
615    // The echoed order IDs must be exactly the orders this transaction touches.
616    let placed_ids = places
617        .iter()
618        .map(|entry| {
619            Ok(opaque_order_id(
620                context.market_id,
621                &decode_order_key(&entry.order)?,
622            ))
623        })
624        .collect::<Result<Vec<String>, String>>()?;
625    let touched: Vec<String> = cancelled_ids.into_iter().chain(placed_ids).collect();
626    if !same_multiset(&touched, &context.prepared.order_ids, |id| id.clone()) {
627        return Err("prepared order IDs do not match the transaction".to_owned());
628    }
629    Ok(())
630}
631
632/// Structural verification of a prepared TWAP-control transaction: session
633/// co-signs only delegated TWAP instructions and never pays; the bound TWAP
634/// economics are checked against the echoed prepare fields by the client.
635pub fn verify_twap_transaction(context: &TwapVerificationContext<'_>) -> Result<(), String> {
636    let tx = decode_transaction(&context.prepared.transaction_base64)?;
637    let delegated = structural_checks(
638        &tx,
639        context.session_public_key,
640        context.owner_wallet,
641        &context.prepared.recent_blockhash,
642        StructuralOptions {
643            allow_address_tables: false,
644            require_envelope: true,
645        },
646    )?;
647    for instruction in &delegated {
648        if !matches!(
649            instruction.inner_tag,
650            INNER_TAG_BALANCE
651                | INNER_TAG_MARKET_ACCOUNT
652                | INNER_TAG_TWAP_POST
653                | INNER_TAG_TWAP_CANCEL
654        ) {
655            return Err(format!(
656                "the transaction delegates an unexpected instruction ({})",
657                instruction.inner_tag
658            ));
659        }
660    }
661    Ok(())
662}
663
664/// Structural verification of a prepared immediate execution: the session
665/// co-signs only Vault-delegated instructions of one program and never pays.
666/// The bound quote economics are checked against the echoed prepare fields.
667pub fn verify_execution_transaction(
668    context: &ExecutionVerificationContext<'_>,
669) -> Result<(), String> {
670    let tx = decode_transaction(&context.prepared.transaction_base64)?;
671    structural_checks(
672        &tx,
673        context.session_public_key,
674        context.owner_wallet,
675        &context.prepared.recent_blockhash,
676        StructuralOptions {
677            allow_address_tables: true,
678            require_envelope: false,
679        },
680    )?;
681    Ok(())
682}
683
684/// The SDK's built-in verifier: [`verify_order_transaction`],
685/// [`verify_twap_transaction`], and [`verify_execution_transaction`] behind
686/// the [`OrderVerifier`], [`TwapVerifier`], and [`ExecutionVerifier`] traits.
687/// Pass `&DefaultTransactionVerifier` to the one-call `execute_*` helpers
688/// unless the application enforces a stricter policy of its own.
689#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
690pub struct DefaultTransactionVerifier;
691
692#[async_trait]
693impl OrderVerifier for DefaultTransactionVerifier {
694    async fn verify(&self, context: &OrderVerificationContext<'_>) -> Result<(), String> {
695        verify_order_transaction(context)
696    }
697}
698
699#[async_trait]
700impl TwapVerifier for DefaultTransactionVerifier {
701    async fn verify(&self, context: &TwapVerificationContext<'_>) -> Result<(), String> {
702        verify_twap_transaction(context)
703    }
704}
705
706#[async_trait]
707impl ExecutionVerifier for DefaultTransactionVerifier {
708    async fn verify(&self, context: &ExecutionVerificationContext<'_>) -> Result<(), String> {
709        verify_execution_transaction(context)
710    }
711}
712
713/// Synthetic delegated-place transactions shared by the verifier unit tests
714/// and the one-signature client tests.
715#[cfg(test)]
716pub(crate) mod test_support {
717    use super::*;
718
719    pub(crate) const OWNER_WALLET: &str = "5Ji61Fbeb22Yntgv1hhHeSSLgdEdZchHeM1Tv1MjGhSL";
720    pub(crate) const SESSION_PUBLIC_KEY: &str = "9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2";
721    pub(crate) const FEE_PAYER: [u8; 32] = [1; 32];
722    pub(crate) const MARKET_PDA: [u8; 32] = [2; 32];
723    pub(crate) const ORDER_PDA: [u8; 32] = [3; 32];
724    pub(crate) const RECENT_BLOCKHASH: [u8; 32] = [5; 32];
725    const VAULT_PROGRAM: [u8; 32] = [11; 32];
726    const STRATA_PROGRAM: [u8; 32] = [12; 32];
727    const VAULT_PDA: [u8; 32] = [13; 32];
728    const DELEGATE_PDA: [u8; 32] = [14; 32];
729    const USER_ACCOUNT: [u8; 32] = [15; 32];
730    const RENT_BANK: [u8; 32] = [16; 32];
731    pub(crate) const PLACE_PRICE: u64 = 150_000_000;
732    pub(crate) const PLACE_SIZE: u64 = 1_000_000_000;
733
734    #[derive(Clone, Copy, Debug, Default)]
735    pub(crate) struct PlaceTransactionOptions {
736        /// Wire side (`0` buy, `1` sell).
737        pub(crate) side: u8,
738        /// Put the session key in the fee-payer slot.
739        pub(crate) session_pays: bool,
740        /// Append a session-signed system transfer.
741        pub(crate) extra_system_transfer: bool,
742        /// Place on another market key.
743        pub(crate) market: Option<[u8; 32]>,
744    }
745
746    pub(crate) fn key(value: &str) -> [u8; 32] {
747        bs58::decode(value).into_vec().unwrap().try_into().unwrap()
748    }
749
750    pub(crate) fn market_id() -> String {
751        opaque_market_id(&bs58::encode(MARKET_PDA).into_string())
752    }
753
754    pub(crate) fn order_id() -> String {
755        opaque_order_id(&market_id(), &ORDER_PDA)
756    }
757
758    pub(crate) fn recent_blockhash() -> String {
759        bs58::encode(RECENT_BLOCKHASH).into_string()
760    }
761
762    fn compact(mut value: usize) -> Vec<u8> {
763        let mut out = Vec::new();
764        loop {
765            let byte = (value & 0x7f) as u8;
766            value >>= 7;
767            if value == 0 {
768                out.push(byte);
769                return out;
770            }
771            out.push(byte | 0x80);
772        }
773    }
774
775    fn envelope(inner: &[u8]) -> Vec<u8> {
776        let mut data = vec![DELEGATED_ENVELOPE_TAG];
777        data.extend_from_slice(&0u64.to_le_bytes());
778        data.extend_from_slice(&[0, 0]);
779        data.extend_from_slice(&(inner.len() as u16).to_le_bytes());
780        data.push(6);
781        data.extend_from_slice(inner);
782        data.extend_from_slice(&[2; 6]);
783        data
784    }
785
786    fn place_inner(side: u8, order_type: u8, price: u64, size: u64) -> Vec<u8> {
787        let mut inner = vec![INNER_TAG_PLACE_ORDER, side, order_type, 0, 0];
788        inner.extend_from_slice(&price.to_le_bytes());
789        inner.extend_from_slice(&size.to_le_bytes());
790        inner.extend_from_slice(&0u64.to_le_bytes());
791        inner.push(255);
792        inner
793    }
794
795    fn instruction(program: u8, accounts: &[u8], data: &[u8]) -> Vec<u8> {
796        let mut out = vec![program];
797        out.extend(compact(accounts.len()));
798        out.extend_from_slice(accounts);
799        out.extend(compact(data.len()));
800        out.extend_from_slice(data);
801        out
802    }
803
804    /// A v0 transaction: fee payer + session sign; one compute-budget
805    /// instruction (fee payer only) and one delegated place through the Vault
806    /// program targeting the Strata program.
807    pub(crate) fn place_transaction(options: PlaceTransactionOptions) -> String {
808        let session = key(SESSION_PUBLIC_KEY);
809        let owner = key(OWNER_WALLET);
810        let system = key("11111111111111111111111111111111");
811        let compute_budget = key("ComputeBudget111111111111111111111111111111");
812        let mut keys: Vec<[u8; 32]> = if options.session_pays {
813            vec![session, FEE_PAYER]
814        } else {
815            vec![FEE_PAYER, session]
816        };
817        keys.extend([
818            VAULT_PDA,
819            DELEGATE_PDA,
820            USER_ACCOUNT,
821            ORDER_PDA,
822            RENT_BANK,
823            MARKET_PDA,
824            owner,
825            VAULT_PROGRAM,
826            STRATA_PROGRAM,
827            system,
828            compute_budget,
829        ]);
830        if let Some(market) = options.market {
831            keys.push(market);
832        }
833        let at = |wanted: [u8; 32]| -> u8 {
834            keys.iter()
835                .position(|candidate| *candidate == wanted)
836                .unwrap() as u8
837        };
838        let mut instructions = Vec::new();
839        instructions.push(instruction(at(compute_budget), &[], &[2, 0, 0, 0, 0]));
840        let market = options.market.unwrap_or(MARKET_PDA);
841        // Delegated place: [session, vault, delegate, strataProgram, owner,
842        // feePayer, inner: vault, market, userAccount, order, system, rentBank]
843        let place_accounts = [
844            at(session),
845            at(VAULT_PDA),
846            at(DELEGATE_PDA),
847            at(STRATA_PROGRAM),
848            at(owner),
849            at(FEE_PAYER),
850            at(VAULT_PDA),
851            at(market),
852            at(USER_ACCOUNT),
853            at(ORDER_PDA),
854            at(system),
855            at(RENT_BANK),
856        ];
857        let place_data = envelope(&place_inner(options.side, 3, PLACE_PRICE, PLACE_SIZE));
858        instructions.push(instruction(at(VAULT_PROGRAM), &place_accounts, &place_data));
859        if options.extra_system_transfer {
860            let mut data = vec![2, 0, 0, 0];
861            data.extend_from_slice(&1u64.to_le_bytes());
862            instructions.push(instruction(
863                at(system),
864                &[at(session), at(FEE_PAYER)],
865                &data,
866            ));
867        }
868        let mut message = vec![0x80, 2, 0, 5];
869        message.extend(compact(keys.len()));
870        for key in &keys {
871            message.extend_from_slice(key);
872        }
873        message.extend_from_slice(&RECENT_BLOCKHASH);
874        message.extend(compact(instructions.len()));
875        for instruction in &instructions {
876            message.extend_from_slice(instruction);
877        }
878        message.extend(compact(0));
879        let mut wire = compact(2);
880        wire.extend_from_slice(&[0u8; 128]);
881        wire.extend_from_slice(&message);
882        base64::engine::general_purpose::STANDARD.encode(wire)
883    }
884}
885
886#[cfg(test)]
887mod tests {
888    use super::test_support::*;
889    use super::*;
890    use crate::{PlatformOrderAction, PlatformOrderPrepareResponse};
891
892    fn prepared(transaction_base64: String) -> PlatformOrderPrepareResponse {
893        PlatformOrderPrepareResponse {
894            schema_version: 2,
895            contract_version: "2.0".to_owned(),
896            order_control_id: "or_44444444444444444444444444444444".to_owned(),
897            market_id: market_id(),
898            action: PlatformOrderAction::Place,
899            order_ids: vec![order_id()],
900            transaction_base64,
901            recent_blockhash: recent_blockhash(),
902            last_valid_block_height: 400_000_000,
903            expires_at_ms: 1_786_550_460_000,
904        }
905    }
906
907    fn operation() -> PlatformOrderChallengeRequest {
908        PlatformOrderChallengeRequest::Place {
909            owner_wallet: OWNER_WALLET.to_owned(),
910            session_public_key: SESSION_PUBLIC_KEY.to_owned(),
911            account_sequence: None,
912            client_order_id: "agent-42".to_owned(),
913            side: PlatformTradeSide::Buy,
914            order_type: PlatformOrderType::PostOnly,
915            limit_price_atoms: PLACE_PRICE.to_string(),
916            size_atoms: PLACE_SIZE.to_string(),
917        }
918    }
919
920    fn verify(options: PlaceTransactionOptions) -> Result<(), String> {
921        let prepared = prepared(place_transaction(options));
922        let operation = operation();
923        let market_id = market_id();
924        verify_order_transaction(&OrderVerificationContext {
925            challenge: None,
926            operation: &operation,
927            market_id: &market_id,
928            prepared: &prepared,
929            owner_wallet: OWNER_WALLET,
930            session_public_key: SESSION_PUBLIC_KEY,
931        })
932    }
933
934    #[test]
935    fn decodes_a_v0_transaction_with_static_keys_and_instructions() {
936        let decoded =
937            decode_transaction(&place_transaction(PlaceTransactionOptions::default())).unwrap();
938        assert_eq!(decoded.version, TransactionVersion::V0);
939        assert_eq!(decoded.signature_count, 2);
940        assert_eq!(decoded.num_required_signatures, 2);
941        assert_eq!(decoded.num_readonly_signed, 0);
942        assert_eq!(decoded.num_readonly_unsigned, 5);
943        assert_eq!(decoded.static_account_keys.len(), 13);
944        assert_eq!(decoded.static_account_keys[1], SESSION_PUBLIC_KEY);
945        assert_eq!(decoded.recent_blockhash, recent_blockhash());
946        assert_eq!(decoded.instructions.len(), 2);
947        assert_eq!(decoded.instructions[1].account_indexes.len(), 12);
948        assert_eq!(decoded.instructions[1].data[0], DELEGATED_ENVELOPE_TAG);
949        assert_eq!(decoded.address_table_lookup_count, 0);
950    }
951
952    #[test]
953    fn decoder_rejects_truncated_and_trailing_bytes() {
954        let raw = base64::engine::general_purpose::STANDARD
955            .decode(place_transaction(PlaceTransactionOptions::default()))
956            .unwrap();
957        let truncated = base64::engine::general_purpose::STANDARD.encode(&raw[..raw.len() - 1]);
958        assert_eq!(
959            decode_transaction(&truncated).unwrap_err(),
960            "transaction is truncated"
961        );
962        let mut trailing = raw.clone();
963        trailing.push(0);
964        assert_eq!(
965            decode_transaction(&base64::engine::general_purpose::STANDARD.encode(trailing))
966                .unwrap_err(),
967            "transaction carries trailing bytes"
968        );
969        assert_eq!(
970            decode_transaction("not base64!").unwrap_err(),
971            "invalid base64 payload"
972        );
973    }
974
975    #[test]
976    fn built_in_verifier_accepts_exactly_the_requested_place() {
977        verify(PlaceTransactionOptions::default()).unwrap();
978    }
979
980    #[test]
981    fn built_in_verifier_refuses_a_different_side() {
982        let error = verify(PlaceTransactionOptions {
983            side: 1,
984            ..PlaceTransactionOptions::default()
985        })
986        .unwrap_err();
987        assert!(error.contains("exactly the requested orders"), "{error}");
988    }
989
990    #[test]
991    fn built_in_verifier_refuses_the_session_as_fee_payer() {
992        let error = verify(PlaceTransactionOptions {
993            session_pays: true,
994            ..PlaceTransactionOptions::default()
995        })
996        .unwrap_err();
997        assert!(error.contains("fee payer"), "{error}");
998    }
999
1000    #[test]
1001    fn built_in_verifier_refuses_a_session_signed_system_transfer() {
1002        let error = verify(PlaceTransactionOptions {
1003            extra_system_transfer: true,
1004            ..PlaceTransactionOptions::default()
1005        })
1006        .unwrap_err();
1007        assert!(error.contains("system or token instruction"), "{error}");
1008    }
1009
1010    #[test]
1011    fn built_in_verifier_refuses_another_market() {
1012        let error = verify(PlaceTransactionOptions {
1013            market: Some([7; 32]),
1014            ..PlaceTransactionOptions::default()
1015        })
1016        .unwrap_err();
1017        assert!(error.contains("another market"), "{error}");
1018    }
1019
1020    #[test]
1021    fn built_in_verifier_binds_the_echoed_order_ids_and_blockhash() {
1022        let transaction = place_transaction(PlaceTransactionOptions::default());
1023        let operation = operation();
1024        let market_id = market_id();
1025        let mut wrong_ids = prepared(transaction.clone());
1026        wrong_ids.order_ids = vec!["order_33333333333333333333333333333333".to_owned()];
1027        let error = verify_order_transaction(&OrderVerificationContext {
1028            challenge: None,
1029            operation: &operation,
1030            market_id: &market_id,
1031            prepared: &wrong_ids,
1032            owner_wallet: OWNER_WALLET,
1033            session_public_key: SESSION_PUBLIC_KEY,
1034        })
1035        .unwrap_err();
1036        assert!(error.contains("order IDs do not match"), "{error}");
1037        let mut wrong_blockhash = prepared(transaction);
1038        wrong_blockhash.recent_blockhash = bs58::encode([9u8; 32]).into_string();
1039        let error = verify_order_transaction(&OrderVerificationContext {
1040            challenge: None,
1041            operation: &operation,
1042            market_id: &market_id,
1043            prepared: &wrong_blockhash,
1044            owner_wallet: OWNER_WALLET,
1045            session_public_key: SESSION_PUBLIC_KEY,
1046        })
1047        .unwrap_err();
1048        assert!(error.contains("blockhash"), "{error}");
1049    }
1050
1051    #[test]
1052    fn twap_and_execution_verification_are_structural() {
1053        // A place envelope is not a TWAP instruction: the TWAP verifier rejects
1054        // it by inner tag while the execution verifier (structure only) accepts
1055        // the same session-signed envelope.
1056        let transaction = place_transaction(PlaceTransactionOptions::default());
1057        let twap_prepared = crate::PlatformTwapPrepareResponse {
1058            schema_version: 2,
1059            contract_version: "2.0".to_owned(),
1060            twap_control_id: "twctl_44444444444444444444444444444444".to_owned(),
1061            market_id: market_id(),
1062            action: crate::PlatformTwapControlAction::Place,
1063            twap_id: "twap_33333333333333333333333333333333".to_owned(),
1064            transaction_base64: transaction.clone(),
1065            recent_blockhash: recent_blockhash(),
1066            last_valid_block_height: 1,
1067            expires_at_ms: 1,
1068        };
1069        let twap_operation = crate::PlatformTwapChallengeRequest::Place {
1070            owner_wallet: OWNER_WALLET.to_owned(),
1071            session_public_key: SESSION_PUBLIC_KEY.to_owned(),
1072            side: PlatformTradeSide::Buy,
1073            total_size_atoms: "10".to_owned(),
1074            slices_total: 2,
1075            maximum_tolerance_bps: 1,
1076            interval_slots: 25,
1077            limit_price_atoms: "1".to_owned(),
1078        };
1079        let market_id = market_id();
1080        let error = verify_twap_transaction(&TwapVerificationContext {
1081            challenge: None,
1082            operation: &twap_operation,
1083            market_id: &market_id,
1084            prepared: &twap_prepared,
1085            owner_wallet: OWNER_WALLET,
1086            session_public_key: SESSION_PUBLIC_KEY,
1087        })
1088        .unwrap_err();
1089        assert!(error.contains("unexpected instruction (33)"), "{error}");
1090
1091        let quote: crate::QuoteResponse =
1092            serde_json::from_str(strata_public_contract::contract_fixtures::QUOTE).unwrap();
1093        let execution_prepared = crate::ExecutionPrepareResponse {
1094            schema_version: 1,
1095            contract_version: "1.1".to_owned(),
1096            execution_id: "se_0123456789abcdef0123456789abcdef".to_owned(),
1097            quote_id: quote.quote_id.clone(),
1098            market_id: quote.market_id.clone(),
1099            side: quote.side,
1100            amount_in_atoms: quote.amount_in_atoms.clone(),
1101            minimum_output_atoms: quote.minimum_output_atoms.clone(),
1102            transaction_base64: transaction,
1103            recent_blockhash: recent_blockhash(),
1104            last_valid_block_height: 1,
1105            expires_at_ms: 1,
1106        };
1107        verify_execution_transaction(&ExecutionVerificationContext {
1108            quote: &quote,
1109            challenge: None,
1110            prepared: &execution_prepared,
1111            owner_wallet: OWNER_WALLET,
1112            session_public_key: SESSION_PUBLIC_KEY,
1113        })
1114        .unwrap();
1115    }
1116}