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 Op {
16    #[wincode(tag = 0)]
17    Noop,
18    #[wincode(tag = 1)]
19    IngestInstruction { offset: u16, len: u8 },
20    #[wincode(tag = 2)]
21    IngestAccount { index: u8 },
22    #[wincode(tag = 3)]
23    IngestInstructionDataSize,
24}
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
27#[repr(C)]
28pub struct StoredOp {
29    pub kind: u8,
30    pub arg0: u8,
31    pub arg1: u8,
32    pub arg2: u8,
33}
34
35impl StoredOp {
36    pub const fn noop() -> Self {
37        Self {
38            kind: 0,
39            arg0: 0,
40            arg1: 0,
41            arg2: 0,
42        }
43    }
44
45    fn try_to_op(self) -> Result<Op, ActionHashError> {
46        let op = match self.kind {
47            0 if self.arg0 == 0 && self.arg1 == 0 && self.arg2 == 0 => Op::Noop,
48            1 => Op::IngestInstruction {
49                offset: u16::from_le_bytes([self.arg1, self.arg2]),
50                len: self.arg0,
51            },
52            2 if self.arg1 == 0 && self.arg2 == 0 => Op::IngestAccount { index: self.arg0 },
53            3 if self.arg0 == 0 && self.arg1 == 0 && self.arg2 == 0 => {
54                Op::IngestInstructionDataSize
55            }
56            _ => return Err(ActionHashError::InvalidOp),
57        };
58
59        Ok(op)
60    }
61}
62
63impl Default for StoredOp {
64    fn default() -> Self {
65        Self::noop()
66    }
67}
68
69impl From<Op> for StoredOp {
70    fn from(op: Op) -> Self {
71        match op {
72            Op::Noop => Self::noop(),
73            Op::IngestInstruction { offset, len } => {
74                let [arg1, arg2] = offset.to_le_bytes();
75                Self {
76                    kind: 1,
77                    arg0: len,
78                    arg1,
79                    arg2,
80                }
81            }
82            Op::IngestAccount { index } => Self {
83                kind: 2,
84                arg0: index,
85                arg1: 0,
86                arg2: 0,
87            },
88            Op::IngestInstructionDataSize => Self {
89                kind: 3,
90                arg0: 0,
91                arg1: 0,
92                arg2: 0,
93            },
94        }
95    }
96}
97
98#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
99#[repr(C)]
100pub struct Ops {
101    pub ops: [StoredOp; 32],
102    pub ops_len: u8,
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use wincode::serialize;
109
110    #[test]
111    fn stored_ops_are_compact() {
112        assert_eq!(core::mem::size_of::<StoredOp>(), 4);
113        assert_eq!(core::mem::size_of::<Ops>(), MAX_ACTION_OPS * 4 + 1);
114        assert_eq!(serialize(&StoredOp::noop()).unwrap().len(), 4);
115        assert_eq!(
116            serialize(&Ops::empty()).unwrap().len(),
117            MAX_ACTION_OPS * 4 + 1
118        );
119    }
120
121    #[test]
122    fn ops_new_enforces_capacity() {
123        let ops = vec![Op::Noop; MAX_ACTION_OPS + 1];
124
125        assert_eq!(Ops::new(ops), Err(ActionHashError::TooManyOps));
126    }
127
128    #[test]
129    fn ops_round_trip_logical_ops() {
130        let ops = Ops::new([
131            Op::Noop,
132            Op::IngestInstruction {
133                offset: 513,
134                len: 7,
135            },
136            Op::IngestAccount { index: 9 },
137            Op::IngestInstructionDataSize,
138        ])
139        .unwrap();
140        let decoded = ops.iter().unwrap().collect::<Result<Vec<_>, _>>().unwrap();
141
142        assert_eq!(
143            decoded,
144            vec![
145                Op::Noop,
146                Op::IngestInstruction {
147                    offset: 513,
148                    len: 7,
149                },
150                Op::IngestAccount { index: 9 },
151                Op::IngestInstructionDataSize,
152            ]
153        );
154    }
155
156    #[test]
157    fn hash_rejects_corrupt_stored_ops() {
158        let program_id = Pubkey::new_unique();
159        let account_metas = [];
160        let ix_data = [];
161
162        let mut too_many = Ops::empty();
163        too_many.ops_len = u8::try_from(MAX_ACTION_OPS + 1).unwrap();
164        assert_eq!(
165            compute_action_hash_from_metas(&program_id, &too_many, &account_metas, &ix_data),
166            Err(ActionHashError::TooManyOps)
167        );
168
169        let mut invalid_kind = Ops::empty();
170        invalid_kind.ops[0] = StoredOp {
171            kind: 255,
172            arg0: 0,
173            arg1: 0,
174            arg2: 0,
175        };
176        invalid_kind.ops_len = 1;
177        assert_eq!(
178            compute_action_hash_from_metas(&program_id, &invalid_kind, &account_metas, &ix_data),
179            Err(ActionHashError::InvalidOp)
180        );
181
182        let mut non_canonical = Ops::empty();
183        non_canonical.ops[0] = StoredOp {
184            kind: 0,
185            arg0: 1,
186            arg1: 0,
187            arg2: 0,
188        };
189        non_canonical.ops_len = 1;
190        assert_eq!(
191            compute_action_hash_from_metas(&program_id, &non_canonical, &account_metas, &ix_data),
192            Err(ActionHashError::InvalidOp)
193        );
194    }
195}
196
197impl Ops {
198    pub const fn empty() -> Self {
199        Self {
200            ops: [StoredOp::noop(); MAX_ACTION_OPS],
201            ops_len: 0,
202        }
203    }
204
205    pub fn new(ops: impl IntoIterator<Item = Op>) -> Result<Self, ActionHashError> {
206        let mut stored_ops = Self::empty();
207
208        for (index, op) in ops.into_iter().enumerate() {
209            if index >= MAX_ACTION_OPS {
210                return Err(ActionHashError::TooManyOps);
211            }
212
213            stored_ops.ops[index] = StoredOp::from(op);
214            stored_ops.ops_len =
215                u8::try_from(index + 1).map_err(|_| ActionHashError::InvalidInstructionData)?;
216        }
217
218        Ok(stored_ops)
219    }
220
221    pub fn len(&self) -> Result<usize, ActionHashError> {
222        let len = usize::from(self.ops_len);
223        if len > MAX_ACTION_OPS {
224            return Err(ActionHashError::TooManyOps);
225        }
226
227        Ok(len)
228    }
229
230    pub fn is_empty(&self) -> Result<bool, ActionHashError> {
231        Ok(self.len()? == 0)
232    }
233
234    pub fn iter(
235        &self,
236    ) -> Result<impl Iterator<Item = Result<Op, ActionHashError>> + '_, ActionHashError> {
237        let len = self.len()?;
238        Ok(self.ops[..len].iter().copied().map(StoredOp::try_to_op))
239    }
240}
241
242impl Default for Ops {
243    fn default() -> Self {
244        Self::empty()
245    }
246}
247
248#[derive(Clone, Copy, Debug, Eq, PartialEq)]
249pub enum ActionHashError {
250    InvalidOp,
251    TooManyOps,
252    InstructionSliceOutOfBounds,
253    AccountIndexOutOfBounds,
254    InvalidInstructionData,
255}
256
257pub fn compute_action_hash_from_metas(
258    program_id: &Pubkey,
259    ops: &Ops,
260    accounts: &[AccountMeta],
261    ix_data: &[u8],
262) -> Result<[u8; 32], ActionHashError> {
263    let mut chunks = vec![program_id.to_bytes().to_vec()];
264
265    for op in ops.iter()? {
266        match op? {
267            Op::Noop => chunks.push(vec![0]),
268            Op::IngestInstruction { offset, len } => {
269                let start = usize::from(offset);
270                let length = usize::from(len);
271                let end = start
272                    .checked_add(length)
273                    .ok_or(ActionHashError::InstructionSliceOutOfBounds)?;
274                let slice = ix_data
275                    .get(start..end)
276                    .ok_or(ActionHashError::InstructionSliceOutOfBounds)?;
277
278                chunks.push(vec![1]);
279                chunks.push(offset.to_le_bytes().to_vec());
280                chunks.push(vec![len]);
281                chunks.push(slice.to_vec());
282            }
283            Op::IngestAccount { index } => {
284                let account = accounts
285                    .get(usize::from(index))
286                    .ok_or(ActionHashError::AccountIndexOutOfBounds)?;
287
288                chunks.push(vec![2]);
289                chunks.push(vec![index]);
290                chunks.push(account.pubkey.to_bytes().to_vec());
291                chunks.push(vec![u8::from(account.is_signer)]);
292                chunks.push(vec![u8::from(account.is_writable)]);
293            }
294            Op::IngestInstructionDataSize => {
295                let data_len = u32::try_from(ix_data.len())
296                    .map_err(|_| ActionHashError::InvalidInstructionData)?;
297
298                chunks.push(vec![3]);
299                chunks.push(data_len.to_le_bytes().to_vec());
300            }
301        }
302    }
303
304    let refs = chunks.iter().map(Vec::as_slice).collect::<Vec<_>>();
305    Ok(hashv(&refs).to_bytes())
306}