Skip to main content

roshi_interface/
action.rs

1use solana_instruction::AccountMeta;
2use solana_pubkey::Pubkey;
3use solana_sha256_hasher::hash;
4use wincode::{SchemaRead, SchemaWrite};
5
6/// Maximum number of authorization predicates stored on one Action account.
7///
8/// Each stored op is four bytes, so 32 ops reserve 128 bytes inside the fixed
9/// Action account layout. More complex workflows should split across multiple
10/// authorized actions rather than growing this account dynamically.
11pub const MAX_ACTION_OPS: usize = 32;
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
14#[wincode(tag_encoding = "u8")]
15pub enum ActionScope {
16    #[wincode(tag = 0)]
17    Manager,
18    #[wincode(tag = 1)]
19    Swap,
20    #[wincode(tag = 2)]
21    AtomicRedeem,
22    /// Strategist-relayed SPL `approve` that grants a one-shot delegate on a
23    /// sub-account custody account, bound at relay so a forced `flash_repay`
24    /// consumes it exactly and clears it. Relayed through `manage`/`manage_batch`
25    /// like `Manager`, but its approved account is exempt from the standard
26    /// custody reverify in favor of a bounded-delegate check.
27    #[wincode(tag = 3)]
28    FlashApprove,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
32#[wincode(tag_encoding = "u8")]
33pub enum Op {
34    #[wincode(tag = 0)]
35    Noop,
36    #[wincode(tag = 1)]
37    IngestInstruction { offset: u16, len: u8 },
38    #[wincode(tag = 2)]
39    IngestAccount { index: u8 },
40    #[wincode(tag = 3)]
41    IngestInstructionDataSize,
42    /// Commit a top-level sibling instruction's program id plus a leading data
43    /// slice (its selector). `relative_index` locates the sibling relative to
44    /// the executing top-level instruction (the `manage`/`manage_batch` call);
45    /// the sibling's program id is always folded so a discriminator cannot be
46    /// forged under a different program. Reaches the instructions sysvar at
47    /// relay; see [`compute_action_hash_from_metas`].
48    #[wincode(tag = 4)]
49    IngestSiblingInstruction {
50        relative_index: i8,
51        offset: u8,
52        len: u8,
53    },
54    /// Commit a top-level sibling instruction's account pubkey at `index`,
55    /// located by `relative_index` as in [`Op::IngestSiblingInstruction`].
56    #[wincode(tag = 5)]
57    IngestSiblingAccount { relative_index: i8, index: u8 },
58}
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
61#[repr(C)]
62pub struct StoredOp {
63    pub kind: u8,
64    pub arg0: u8,
65    pub arg1: u8,
66    pub arg2: u8,
67}
68
69impl StoredOp {
70    pub const fn noop() -> Self {
71        Self {
72            kind: 0,
73            arg0: 0,
74            arg1: 0,
75            arg2: 0,
76        }
77    }
78
79    fn try_to_op(self) -> Result<Op, ActionHashError> {
80        let op = match self.kind {
81            0 if self.arg0 == 0 && self.arg1 == 0 && self.arg2 == 0 => Op::Noop,
82            1 => Op::IngestInstruction {
83                offset: u16::from_le_bytes([self.arg1, self.arg2]),
84                len: self.arg0,
85            },
86            2 if self.arg1 == 0 && self.arg2 == 0 => Op::IngestAccount { index: self.arg0 },
87            3 if self.arg0 == 0 && self.arg1 == 0 && self.arg2 == 0 => {
88                Op::IngestInstructionDataSize
89            }
90            4 => Op::IngestSiblingInstruction {
91                relative_index: self.arg0 as i8,
92                offset: self.arg1,
93                len: self.arg2,
94            },
95            5 if self.arg2 == 0 => Op::IngestSiblingAccount {
96                relative_index: self.arg0 as i8,
97                index: self.arg1,
98            },
99            _ => return Err(ActionHashError::InvalidOp),
100        };
101
102        Ok(op)
103    }
104}
105
106impl Default for StoredOp {
107    fn default() -> Self {
108        Self::noop()
109    }
110}
111
112impl From<Op> for StoredOp {
113    fn from(op: Op) -> Self {
114        match op {
115            Op::Noop => Self::noop(),
116            Op::IngestInstruction { offset, len } => {
117                let [arg1, arg2] = offset.to_le_bytes();
118                Self {
119                    kind: 1,
120                    arg0: len,
121                    arg1,
122                    arg2,
123                }
124            }
125            Op::IngestAccount { index } => Self {
126                kind: 2,
127                arg0: index,
128                arg1: 0,
129                arg2: 0,
130            },
131            Op::IngestInstructionDataSize => Self {
132                kind: 3,
133                arg0: 0,
134                arg1: 0,
135                arg2: 0,
136            },
137            Op::IngestSiblingInstruction {
138                relative_index,
139                offset,
140                len,
141            } => Self {
142                kind: 4,
143                arg0: relative_index as u8,
144                arg1: offset,
145                arg2: len,
146            },
147            Op::IngestSiblingAccount {
148                relative_index,
149                index,
150            } => Self {
151                kind: 5,
152                arg0: relative_index as u8,
153                arg1: index,
154                arg2: 0,
155            },
156        }
157    }
158}
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
161#[repr(C)]
162pub struct Ops {
163    pub ops: [StoredOp; 32],
164    pub ops_len: u8,
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use wincode::serialize;
171
172    // The single-buffer preimage must hash bit-for-bit identically to the old
173    // `Vec<Vec<u8>>` + `hashv(&refs)` approach (#24). Reproduce that reference
174    // independently here, exercising every op arm, so any divergence is caught.
175    #[test]
176    fn streamed_hash_matches_chunked_reference() {
177        let program_id = Pubkey::new_unique();
178        let ix_data = (0u8..40).collect::<Vec<_>>();
179        let account = AccountMeta::new(Pubkey::new_unique(), true);
180        let sibling_accounts = [Pubkey::new_unique(), Pubkey::new_unique()];
181        let sibling_data = (10u8..30).collect::<Vec<_>>();
182        let siblings = [ResolvedSibling {
183            relative_index: -1,
184            program_id: Pubkey::new_unique(),
185            data: &sibling_data,
186            accounts: &sibling_accounts,
187        }];
188        let ops = Ops::new([
189            Op::Noop,
190            Op::IngestInstruction { offset: 5, len: 7 },
191            Op::IngestAccount { index: 0 },
192            Op::IngestInstructionDataSize,
193            Op::IngestSiblingInstruction {
194                relative_index: -1,
195                offset: 2,
196                len: 8,
197            },
198            Op::IngestSiblingAccount {
199                relative_index: -1,
200                index: 1,
201            },
202        ])
203        .unwrap();
204
205        // Reference: the pre-#24 chunked algorithm, byte-for-byte.
206        let mut chunks: Vec<Vec<u8>> = vec![program_id.to_bytes().to_vec()];
207        chunks.push(vec![0]);
208        chunks.push(vec![1]);
209        chunks.push(5u16.to_le_bytes().to_vec());
210        chunks.push(vec![7]);
211        chunks.push(ix_data[5..12].to_vec());
212        chunks.push(vec![2]);
213        chunks.push(vec![0]);
214        chunks.push(account.pubkey.to_bytes().to_vec());
215        chunks.push(vec![1]);
216        chunks.push(vec![1]);
217        chunks.push(vec![3]);
218        chunks.push((ix_data.len() as u32).to_le_bytes().to_vec());
219        chunks.push(vec![4]);
220        chunks.push(vec![(-1i8) as u8]);
221        chunks.push(siblings[0].program_id.to_bytes().to_vec());
222        chunks.push(vec![2]);
223        chunks.push(vec![8]);
224        chunks.push(sibling_data[2..10].to_vec());
225        chunks.push(vec![5]);
226        chunks.push(vec![(-1i8) as u8]);
227        chunks.push(vec![1]);
228        chunks.push(sibling_accounts[1].to_bytes().to_vec());
229        let refs = chunks.iter().map(Vec::as_slice).collect::<Vec<_>>();
230        let reference = solana_sha256_hasher::hashv(&refs).to_bytes();
231
232        let streamed =
233            compute_action_hash_from_metas(&program_id, &ops, &[account], &ix_data, &siblings)
234                .unwrap();
235
236        assert_eq!(streamed, reference);
237    }
238
239    #[test]
240    fn stored_ops_are_compact() {
241        assert_eq!(core::mem::size_of::<StoredOp>(), 4);
242        assert_eq!(core::mem::size_of::<Ops>(), MAX_ACTION_OPS * 4 + 1);
243        assert_eq!(serialize(&StoredOp::noop()).unwrap().len(), 4);
244        assert_eq!(
245            serialize(&Ops::empty()).unwrap().len(),
246            MAX_ACTION_OPS * 4 + 1
247        );
248    }
249
250    #[test]
251    fn ops_new_enforces_capacity() {
252        let ops = vec![Op::Noop; MAX_ACTION_OPS + 1];
253
254        assert_eq!(Ops::new(ops), Err(ActionHashError::TooManyOps));
255    }
256
257    #[test]
258    fn ops_round_trip_logical_ops() {
259        let ops = Ops::new([
260            Op::Noop,
261            Op::IngestInstruction {
262                offset: 513,
263                len: 7,
264            },
265            Op::IngestAccount { index: 9 },
266            Op::IngestInstructionDataSize,
267            Op::IngestSiblingInstruction {
268                relative_index: -1,
269                offset: 0,
270                len: 8,
271            },
272            Op::IngestSiblingAccount {
273                relative_index: 2,
274                index: 5,
275            },
276        ])
277        .unwrap();
278        let decoded = ops.iter().unwrap().collect::<Result<Vec<_>, _>>().unwrap();
279
280        assert_eq!(
281            decoded,
282            vec![
283                Op::Noop,
284                Op::IngestInstruction {
285                    offset: 513,
286                    len: 7,
287                },
288                Op::IngestAccount { index: 9 },
289                Op::IngestInstructionDataSize,
290                Op::IngestSiblingInstruction {
291                    relative_index: -1,
292                    offset: 0,
293                    len: 8,
294                },
295                Op::IngestSiblingAccount {
296                    relative_index: 2,
297                    index: 5,
298                },
299            ]
300        );
301    }
302
303    #[test]
304    fn hash_rejects_corrupt_stored_ops() {
305        let program_id = Pubkey::new_unique();
306        let account_metas = [];
307        let ix_data = [];
308
309        let mut too_many = Ops::empty();
310        too_many.ops_len = u8::try_from(MAX_ACTION_OPS + 1).unwrap();
311        assert_eq!(
312            compute_action_hash_from_metas(&program_id, &too_many, &account_metas, &ix_data, &[]),
313            Err(ActionHashError::TooManyOps)
314        );
315
316        let mut invalid_kind = Ops::empty();
317        invalid_kind.ops[0] = StoredOp {
318            kind: 255,
319            arg0: 0,
320            arg1: 0,
321            arg2: 0,
322        };
323        invalid_kind.ops_len = 1;
324        assert_eq!(
325            compute_action_hash_from_metas(
326                &program_id,
327                &invalid_kind,
328                &account_metas,
329                &ix_data,
330                &[]
331            ),
332            Err(ActionHashError::InvalidOp)
333        );
334
335        let mut non_canonical = Ops::empty();
336        non_canonical.ops[0] = StoredOp {
337            kind: 0,
338            arg0: 1,
339            arg1: 0,
340            arg2: 0,
341        };
342        non_canonical.ops_len = 1;
343        assert_eq!(
344            compute_action_hash_from_metas(
345                &program_id,
346                &non_canonical,
347                &account_metas,
348                &ix_data,
349                &[]
350            ),
351            Err(ActionHashError::InvalidOp)
352        );
353    }
354}
355
356impl Ops {
357    pub const fn empty() -> Self {
358        Self {
359            ops: [StoredOp::noop(); MAX_ACTION_OPS],
360            ops_len: 0,
361        }
362    }
363
364    pub fn new(ops: impl IntoIterator<Item = Op>) -> Result<Self, ActionHashError> {
365        let mut stored_ops = Self::empty();
366
367        for (index, op) in ops.into_iter().enumerate() {
368            if index >= MAX_ACTION_OPS {
369                return Err(ActionHashError::TooManyOps);
370            }
371
372            stored_ops.ops[index] = StoredOp::from(op);
373            stored_ops.ops_len =
374                u8::try_from(index + 1).map_err(|_| ActionHashError::InvalidInstructionData)?;
375        }
376
377        Ok(stored_ops)
378    }
379
380    pub fn len(&self) -> Result<usize, ActionHashError> {
381        let len = usize::from(self.ops_len);
382        if len > MAX_ACTION_OPS {
383            return Err(ActionHashError::TooManyOps);
384        }
385
386        Ok(len)
387    }
388
389    pub fn is_empty(&self) -> Result<bool, ActionHashError> {
390        Ok(self.len()? == 0)
391    }
392
393    pub fn iter(
394        &self,
395    ) -> Result<impl Iterator<Item = Result<Op, ActionHashError>> + '_, ActionHashError> {
396        let len = self.len()?;
397        Ok(self.ops[..len].iter().copied().map(StoredOp::try_to_op))
398    }
399}
400
401impl Default for Ops {
402    fn default() -> Self {
403        Self::empty()
404    }
405}
406
407#[derive(Clone, Copy, Debug, Eq, PartialEq)]
408pub enum ActionHashError {
409    InvalidOp,
410    TooManyOps,
411    InstructionSliceOutOfBounds,
412    AccountIndexOutOfBounds,
413    InvalidInstructionData,
414    MissingSibling,
415}
416
417/// A top-level sibling instruction already read from the transaction, supplied
418/// to the hash so [`Op::IngestSiblingInstruction`]/[`Op::IngestSiblingAccount`]
419/// can fold its observed fields. On-chain the relay reads these from the
420/// instructions sysvar; off-chain the admin supplies the intended sibling when
421/// precomputing the authorized hash. Hashing stays a pure function of its
422/// inputs — it never touches the sysvar itself.
423pub struct ResolvedSibling<'a> {
424    pub relative_index: i8,
425    pub program_id: Pubkey,
426    pub data: &'a [u8],
427    pub accounts: &'a [Pubkey],
428}
429
430pub fn compute_action_hash_from_metas(
431    program_id: &Pubkey,
432    ops: &Ops,
433    accounts: &[AccountMeta],
434    ix_data: &[u8],
435    siblings: &[ResolvedSibling],
436) -> Result<[u8; 32], ActionHashError> {
437    // Build the hash preimage in one pre-sized buffer and hash it in a single
438    // syscall, rather than collecting a `Vec<Vec<u8>>` of pieces. The on-chain
439    // bump allocator never frees within an instruction, so the old per-piece
440    // allocations accumulated across a large `manage_batch` and crossed the
441    // default heap (#24). The bytes — and therefore the hash — are identical:
442    // `hashv` already concatenates these same pieces. (An incremental hasher is
443    // native-only in `solana-sha256-hasher`, so streaming isn't an option here.)
444    let mut preimage = Vec::with_capacity(preimage_len(ops)?);
445    preimage.extend_from_slice(&program_id.to_bytes());
446
447    for op in ops.iter()? {
448        match op? {
449            Op::Noop => preimage.push(0),
450            Op::IngestInstruction { offset, len } => {
451                let slice = instruction_slice(ix_data, usize::from(offset), len)?;
452                preimage.push(1);
453                preimage.extend_from_slice(&offset.to_le_bytes());
454                preimage.push(len);
455                preimage.extend_from_slice(slice);
456            }
457            Op::IngestAccount { index } => {
458                let account = accounts
459                    .get(usize::from(index))
460                    .ok_or(ActionHashError::AccountIndexOutOfBounds)?;
461
462                preimage.push(2);
463                preimage.push(index);
464                preimage.extend_from_slice(&account.pubkey.to_bytes());
465                preimage.push(u8::from(account.is_signer));
466                preimage.push(u8::from(account.is_writable));
467            }
468            Op::IngestInstructionDataSize => {
469                let data_len = u32::try_from(ix_data.len())
470                    .map_err(|_| ActionHashError::InvalidInstructionData)?;
471
472                preimage.push(3);
473                preimage.extend_from_slice(&data_len.to_le_bytes());
474            }
475            Op::IngestSiblingInstruction {
476                relative_index,
477                offset,
478                len,
479            } => {
480                let sibling = resolve_sibling(siblings, relative_index)?;
481                let slice = instruction_slice(sibling.data, usize::from(offset), len)?;
482
483                preimage.push(4);
484                preimage.push(relative_index as u8);
485                preimage.extend_from_slice(&sibling.program_id.to_bytes());
486                preimage.push(offset);
487                preimage.push(len);
488                preimage.extend_from_slice(slice);
489            }
490            Op::IngestSiblingAccount {
491                relative_index,
492                index,
493            } => {
494                let sibling = resolve_sibling(siblings, relative_index)?;
495                let account = sibling
496                    .accounts
497                    .get(usize::from(index))
498                    .ok_or(ActionHashError::AccountIndexOutOfBounds)?;
499
500                preimage.push(5);
501                preimage.push(relative_index as u8);
502                preimage.push(index);
503                preimage.extend_from_slice(&account.to_bytes());
504            }
505        }
506    }
507
508    Ok(hash(&preimage).to_bytes())
509}
510
511/// Exact byte length of the hash preimage for `ops`, so the buffer is allocated
512/// once with no reallocation (each realloc would leak under the bump allocator).
513/// Mirrors the per-op layout in [`compute_action_hash_from_metas`]; slice bounds
514/// are validated there, so this uses the committed `len` as-is.
515fn preimage_len(ops: &Ops) -> Result<usize, ActionHashError> {
516    let mut len = 32; // program id
517    for op in ops.iter()? {
518        len += match op? {
519            Op::Noop => 1,
520            Op::IngestInstruction { len, .. } => 4 + usize::from(len),
521            Op::IngestAccount { .. } => 36,
522            Op::IngestInstructionDataSize => 5,
523            Op::IngestSiblingInstruction { len, .. } => 36 + usize::from(len),
524            Op::IngestSiblingAccount { .. } => 35,
525        };
526    }
527
528    Ok(len)
529}
530
531fn instruction_slice(data: &[u8], offset: usize, len: u8) -> Result<&[u8], ActionHashError> {
532    let end = offset
533        .checked_add(usize::from(len))
534        .ok_or(ActionHashError::InstructionSliceOutOfBounds)?;
535    data.get(offset..end)
536        .ok_or(ActionHashError::InstructionSliceOutOfBounds)
537}
538
539fn resolve_sibling<'a, 'b>(
540    siblings: &'a [ResolvedSibling<'b>],
541    relative_index: i8,
542) -> Result<&'a ResolvedSibling<'b>, ActionHashError> {
543    siblings
544        .iter()
545        .find(|sibling| sibling.relative_index == relative_index)
546        .ok_or(ActionHashError::MissingSibling)
547}