Skip to main content

roshi_interface/
action.rs

1use solana_instruction::AccountMeta;
2use solana_pubkey::Pubkey;
3use solana_sha256_hasher::hashv;
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    #[test]
173    fn stored_ops_are_compact() {
174        assert_eq!(core::mem::size_of::<StoredOp>(), 4);
175        assert_eq!(core::mem::size_of::<Ops>(), MAX_ACTION_OPS * 4 + 1);
176        assert_eq!(serialize(&StoredOp::noop()).unwrap().len(), 4);
177        assert_eq!(
178            serialize(&Ops::empty()).unwrap().len(),
179            MAX_ACTION_OPS * 4 + 1
180        );
181    }
182
183    #[test]
184    fn ops_new_enforces_capacity() {
185        let ops = vec![Op::Noop; MAX_ACTION_OPS + 1];
186
187        assert_eq!(Ops::new(ops), Err(ActionHashError::TooManyOps));
188    }
189
190    #[test]
191    fn ops_round_trip_logical_ops() {
192        let ops = Ops::new([
193            Op::Noop,
194            Op::IngestInstruction {
195                offset: 513,
196                len: 7,
197            },
198            Op::IngestAccount { index: 9 },
199            Op::IngestInstructionDataSize,
200            Op::IngestSiblingInstruction {
201                relative_index: -1,
202                offset: 0,
203                len: 8,
204            },
205            Op::IngestSiblingAccount {
206                relative_index: 2,
207                index: 5,
208            },
209        ])
210        .unwrap();
211        let decoded = ops.iter().unwrap().collect::<Result<Vec<_>, _>>().unwrap();
212
213        assert_eq!(
214            decoded,
215            vec![
216                Op::Noop,
217                Op::IngestInstruction {
218                    offset: 513,
219                    len: 7,
220                },
221                Op::IngestAccount { index: 9 },
222                Op::IngestInstructionDataSize,
223                Op::IngestSiblingInstruction {
224                    relative_index: -1,
225                    offset: 0,
226                    len: 8,
227                },
228                Op::IngestSiblingAccount {
229                    relative_index: 2,
230                    index: 5,
231                },
232            ]
233        );
234    }
235
236    #[test]
237    fn hash_rejects_corrupt_stored_ops() {
238        let program_id = Pubkey::new_unique();
239        let account_metas = [];
240        let ix_data = [];
241
242        let mut too_many = Ops::empty();
243        too_many.ops_len = u8::try_from(MAX_ACTION_OPS + 1).unwrap();
244        assert_eq!(
245            compute_action_hash_from_metas(&program_id, &too_many, &account_metas, &ix_data, &[]),
246            Err(ActionHashError::TooManyOps)
247        );
248
249        let mut invalid_kind = Ops::empty();
250        invalid_kind.ops[0] = StoredOp {
251            kind: 255,
252            arg0: 0,
253            arg1: 0,
254            arg2: 0,
255        };
256        invalid_kind.ops_len = 1;
257        assert_eq!(
258            compute_action_hash_from_metas(
259                &program_id,
260                &invalid_kind,
261                &account_metas,
262                &ix_data,
263                &[]
264            ),
265            Err(ActionHashError::InvalidOp)
266        );
267
268        let mut non_canonical = Ops::empty();
269        non_canonical.ops[0] = StoredOp {
270            kind: 0,
271            arg0: 1,
272            arg1: 0,
273            arg2: 0,
274        };
275        non_canonical.ops_len = 1;
276        assert_eq!(
277            compute_action_hash_from_metas(
278                &program_id,
279                &non_canonical,
280                &account_metas,
281                &ix_data,
282                &[]
283            ),
284            Err(ActionHashError::InvalidOp)
285        );
286    }
287}
288
289impl Ops {
290    pub const fn empty() -> Self {
291        Self {
292            ops: [StoredOp::noop(); MAX_ACTION_OPS],
293            ops_len: 0,
294        }
295    }
296
297    pub fn new(ops: impl IntoIterator<Item = Op>) -> Result<Self, ActionHashError> {
298        let mut stored_ops = Self::empty();
299
300        for (index, op) in ops.into_iter().enumerate() {
301            if index >= MAX_ACTION_OPS {
302                return Err(ActionHashError::TooManyOps);
303            }
304
305            stored_ops.ops[index] = StoredOp::from(op);
306            stored_ops.ops_len =
307                u8::try_from(index + 1).map_err(|_| ActionHashError::InvalidInstructionData)?;
308        }
309
310        Ok(stored_ops)
311    }
312
313    pub fn len(&self) -> Result<usize, ActionHashError> {
314        let len = usize::from(self.ops_len);
315        if len > MAX_ACTION_OPS {
316            return Err(ActionHashError::TooManyOps);
317        }
318
319        Ok(len)
320    }
321
322    pub fn is_empty(&self) -> Result<bool, ActionHashError> {
323        Ok(self.len()? == 0)
324    }
325
326    pub fn iter(
327        &self,
328    ) -> Result<impl Iterator<Item = Result<Op, ActionHashError>> + '_, ActionHashError> {
329        let len = self.len()?;
330        Ok(self.ops[..len].iter().copied().map(StoredOp::try_to_op))
331    }
332}
333
334impl Default for Ops {
335    fn default() -> Self {
336        Self::empty()
337    }
338}
339
340#[derive(Clone, Copy, Debug, Eq, PartialEq)]
341pub enum ActionHashError {
342    InvalidOp,
343    TooManyOps,
344    InstructionSliceOutOfBounds,
345    AccountIndexOutOfBounds,
346    InvalidInstructionData,
347    MissingSibling,
348}
349
350/// A top-level sibling instruction already read from the transaction, supplied
351/// to the hash so [`Op::IngestSiblingInstruction`]/[`Op::IngestSiblingAccount`]
352/// can fold its observed fields. On-chain the relay reads these from the
353/// instructions sysvar; off-chain the admin supplies the intended sibling when
354/// precomputing the authorized hash. Hashing stays a pure function of its
355/// inputs — it never touches the sysvar itself.
356pub struct ResolvedSibling<'a> {
357    pub relative_index: i8,
358    pub program_id: Pubkey,
359    pub data: &'a [u8],
360    pub accounts: &'a [Pubkey],
361}
362
363pub fn compute_action_hash_from_metas(
364    program_id: &Pubkey,
365    ops: &Ops,
366    accounts: &[AccountMeta],
367    ix_data: &[u8],
368    siblings: &[ResolvedSibling],
369) -> Result<[u8; 32], ActionHashError> {
370    let mut chunks = vec![program_id.to_bytes().to_vec()];
371
372    for op in ops.iter()? {
373        match op? {
374            Op::Noop => chunks.push(vec![0]),
375            Op::IngestInstruction { offset, len } => {
376                let start = usize::from(offset);
377                let length = usize::from(len);
378                let end = start
379                    .checked_add(length)
380                    .ok_or(ActionHashError::InstructionSliceOutOfBounds)?;
381                let slice = ix_data
382                    .get(start..end)
383                    .ok_or(ActionHashError::InstructionSliceOutOfBounds)?;
384
385                chunks.push(vec![1]);
386                chunks.push(offset.to_le_bytes().to_vec());
387                chunks.push(vec![len]);
388                chunks.push(slice.to_vec());
389            }
390            Op::IngestAccount { index } => {
391                let account = accounts
392                    .get(usize::from(index))
393                    .ok_or(ActionHashError::AccountIndexOutOfBounds)?;
394
395                chunks.push(vec![2]);
396                chunks.push(vec![index]);
397                chunks.push(account.pubkey.to_bytes().to_vec());
398                chunks.push(vec![u8::from(account.is_signer)]);
399                chunks.push(vec![u8::from(account.is_writable)]);
400            }
401            Op::IngestInstructionDataSize => {
402                let data_len = u32::try_from(ix_data.len())
403                    .map_err(|_| ActionHashError::InvalidInstructionData)?;
404
405                chunks.push(vec![3]);
406                chunks.push(data_len.to_le_bytes().to_vec());
407            }
408            Op::IngestSiblingInstruction {
409                relative_index,
410                offset,
411                len,
412            } => {
413                let sibling = resolve_sibling(siblings, relative_index)?;
414                let start = usize::from(offset);
415                let end = start
416                    .checked_add(usize::from(len))
417                    .ok_or(ActionHashError::InstructionSliceOutOfBounds)?;
418                let slice = sibling
419                    .data
420                    .get(start..end)
421                    .ok_or(ActionHashError::InstructionSliceOutOfBounds)?;
422
423                chunks.push(vec![4]);
424                chunks.push(vec![relative_index as u8]);
425                chunks.push(sibling.program_id.to_bytes().to_vec());
426                chunks.push(vec![offset]);
427                chunks.push(vec![len]);
428                chunks.push(slice.to_vec());
429            }
430            Op::IngestSiblingAccount {
431                relative_index,
432                index,
433            } => {
434                let sibling = resolve_sibling(siblings, relative_index)?;
435                let account = sibling
436                    .accounts
437                    .get(usize::from(index))
438                    .ok_or(ActionHashError::AccountIndexOutOfBounds)?;
439
440                chunks.push(vec![5]);
441                chunks.push(vec![relative_index as u8]);
442                chunks.push(vec![index]);
443                chunks.push(account.to_bytes().to_vec());
444            }
445        }
446    }
447
448    let refs = chunks.iter().map(Vec::as_slice).collect::<Vec<_>>();
449    Ok(hashv(&refs).to_bytes())
450}
451
452fn resolve_sibling<'a, 'b>(
453    siblings: &'a [ResolvedSibling<'b>],
454    relative_index: i8,
455) -> Result<&'a ResolvedSibling<'b>, ActionHashError> {
456    siblings
457        .iter()
458        .find(|sibling| sibling.relative_index == relative_index)
459        .ok_or(ActionHashError::MissingSibling)
460}