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