Skip to main content

hopper_sdk/
builder.rs

1//! # Manifest-driven instruction builder
2//!
3//! Turns a `ProgramManifest` + named instruction + typed args into a raw
4//! instruction shape `(program_id, data, accounts)` ready to be handed to any
5//! Solana client crate (solana-sdk, magic-block, anchor-client, etc.) for
6//! transaction assembly.
7//!
8//! The entire point: the on-chain program's manifest IS the off-chain IDL.
9//! There is no "regenerate the TypeScript types after you redeploy" step,
10//! because the source of truth is compiled into the program.
11//!
12//! # Design notes
13//!
14//! - **Single source of truth**: no separate `.json` IDL file that drifts.
15//! - **Layout-id-verified account lookups**: if a caller supplies an account
16//!   for a slot whose `layout_ref` names a layout in the manifest, the
17//!   builder can validate on construction that the passed-in account bytes
18//!   match the expected fingerprint.
19//! - **Zero serde**: args are written to a byte vec using the `ArgDescriptor`
20//!   size declarations in the manifest.
21
22#![cfg(feature = "builder")]
23
24extern crate alloc;
25
26use alloc::vec::Vec;
27use hopper_schema::{InstructionDescriptor, ProgramManifest};
28
29/// A Solana-compatible account meta. Re-defined locally so this crate does
30/// not pull in `solana-program` or `solana-sdk`. Consumers downshift to their
31/// preferred SDK's `AccountMeta` after construction.
32#[derive(Debug, Clone, Copy)]
33pub struct AccountMeta {
34    /// 32-byte Solana public key.
35    pub pubkey: [u8; 32],
36    /// Account must sign the tx.
37    pub is_signer: bool,
38    /// Account is writable.
39    pub is_writable: bool,
40}
41
42/// One built instruction ready for submission.
43#[derive(Debug, Clone)]
44pub struct BuiltInstruction {
45    /// Program id (32 bytes).
46    pub program_id: [u8; 32],
47    /// Instruction data.
48    pub data: Vec<u8>,
49    /// Ordered account metas.
50    pub accounts: Vec<AccountMeta>,
51}
52
53/// Builder error surface.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum BuildError {
56    /// No instruction by that name in the manifest.
57    UnknownInstruction,
58    /// Caller supplied a different number of args than the manifest declares.
59    ArgCountMismatch {
60        /// Expected count.
61        expected: usize,
62        /// Actual count.
63        got: usize,
64    },
65    /// One of the caller's arg byte slices was the wrong size.
66    ArgSizeMismatch {
67        /// Which arg index.
68        index: usize,
69        /// Expected size from manifest.
70        expected: u16,
71        /// Actual size from caller.
72        got: usize,
73    },
74    /// Caller supplied a different number of accounts than the manifest
75    /// declares for this instruction.
76    AccountCountMismatch {
77        /// Expected count.
78        expected: usize,
79        /// Actual count.
80        got: usize,
81    },
82}
83
84/// Fluent builder over a manifest + one instruction.
85#[derive(Debug)]
86pub struct InstructionBuilder<'a> {
87    program_id: [u8; 32],
88    ix: &'a InstructionDescriptor,
89    args: Vec<&'a [u8]>,
90    accounts: Vec<[u8; 32]>,
91}
92
93impl<'a> InstructionBuilder<'a> {
94    /// Locate `ix_name` in the manifest and return a builder seeded with the
95    /// instruction tag.
96    pub fn new(
97        manifest: &'a ProgramManifest,
98        program_id: [u8; 32],
99        ix_name: &str,
100    ) -> Result<Self, BuildError> {
101        let ix = find_instruction(manifest, ix_name).ok_or(BuildError::UnknownInstruction)?;
102        Ok(Self {
103            program_id,
104            ix,
105            args: Vec::with_capacity(ix.args.len()),
106            accounts: Vec::with_capacity(ix.accounts.len()),
107        })
108    }
109
110    /// Add one arg's raw bytes. Order must match the manifest.
111    pub fn arg(mut self, bytes: &'a [u8]) -> Self {
112        self.args.push(bytes);
113        self
114    }
115
116    /// Add one account pubkey. Order must match the manifest.
117    pub fn account(mut self, pubkey: [u8; 32]) -> Self {
118        self.accounts.push(pubkey);
119        self
120    }
121
122    /// Finalize into a `BuiltInstruction`, validating shape.
123    pub fn build(self) -> Result<BuiltInstruction, BuildError> {
124        if self.args.len() != self.ix.args.len() {
125            return Err(BuildError::ArgCountMismatch {
126                expected: self.ix.args.len(),
127                got: self.args.len(),
128            });
129        }
130        if self.accounts.len() != self.ix.accounts.len() {
131            return Err(BuildError::AccountCountMismatch {
132                expected: self.ix.accounts.len(),
133                got: self.accounts.len(),
134            });
135        }
136
137        // Data layout: [tag_byte, arg0_bytes..., arg1_bytes..., ...]
138        let mut data_len: usize = 1;
139        let mut i = 0;
140        while i < self.args.len() {
141            let want = self.ix.args[i].size as usize;
142            if self.args[i].len() != want {
143                return Err(BuildError::ArgSizeMismatch {
144                    index: i,
145                    expected: self.ix.args[i].size,
146                    got: self.args[i].len(),
147                });
148            }
149            data_len += want;
150            i += 1;
151        }
152
153        let mut data = Vec::with_capacity(data_len);
154        data.push(self.ix.tag);
155        for a in &self.args {
156            data.extend_from_slice(a);
157        }
158
159        let mut metas = Vec::with_capacity(self.accounts.len());
160        let mut i = 0;
161        while i < self.accounts.len() {
162            let entry = &self.ix.accounts[i];
163            metas.push(AccountMeta {
164                pubkey: self.accounts[i],
165                is_signer: entry.signer,
166                is_writable: entry.writable,
167            });
168            i += 1;
169        }
170
171        Ok(BuiltInstruction {
172            program_id: self.program_id,
173            data,
174            accounts: metas,
175        })
176    }
177}
178
179fn find_instruction<'a>(m: &'a ProgramManifest, name: &str) -> Option<&'a InstructionDescriptor> {
180    let mut i = 0;
181    while i < m.instructions.len() {
182        if m.instructions[i].name == name {
183            return Some(&m.instructions[i]);
184        }
185        i += 1;
186    }
187    None
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use hopper_schema::{AccountEntry, ArgDescriptor, InstructionDescriptor, ProgramManifest};
194
195    fn sample_manifest() -> ProgramManifest {
196        static ARGS: [ArgDescriptor; 2] = [
197            ArgDescriptor {
198                name: "amount",
199                canonical_type: "u64",
200                size: 8,
201            },
202            ArgDescriptor {
203                name: "bump",
204                canonical_type: "u8",
205                size: 1,
206            },
207        ];
208        static ACCTS: [AccountEntry; 2] = [
209            AccountEntry {
210                name: "vault",
211                writable: true,
212                signer: false,
213                layout_ref: "Vault",
214            },
215            AccountEntry {
216                name: "authority",
217                writable: false,
218                signer: true,
219                layout_ref: "",
220            },
221        ];
222        static IX: [InstructionDescriptor; 1] = [InstructionDescriptor {
223            name: "deposit",
224            tag: 3,
225            args: &ARGS,
226            accounts: &ACCTS,
227            capabilities: &[],
228            policy_pack: "",
229            receipt_expected: true,
230        }];
231        ProgramManifest {
232            name: "test",
233            version: "0",
234            description: "",
235            layouts: &[],
236            layout_metadata: &[],
237            instructions: &IX,
238            events: &[],
239            policies: &[],
240            compatibility_pairs: &[],
241            tooling_hints: &[],
242            contexts: &[],
243        }
244    }
245
246    #[test]
247    fn builds_a_valid_ix() {
248        let m = sample_manifest();
249        let amount = 42u64.to_le_bytes();
250        let bump = [254u8];
251        let vault = [1u8; 32];
252        let auth = [2u8; 32];
253        let ix = InstructionBuilder::new(&m, [9u8; 32], "deposit")
254            .unwrap()
255            .arg(&amount)
256            .arg(&bump)
257            .account(vault)
258            .account(auth)
259            .build()
260            .unwrap();
261        assert_eq!(ix.program_id, [9u8; 32]);
262        assert_eq!(ix.data.len(), 1 + 8 + 1);
263        assert_eq!(ix.data[0], 3);
264        assert_eq!(ix.accounts.len(), 2);
265        assert!(ix.accounts[0].is_writable);
266        assert!(ix.accounts[1].is_signer);
267    }
268
269    #[test]
270    fn rejects_mismatched_arg_size() {
271        let m = sample_manifest();
272        let amount_wrong = [1u8; 4];
273        let bump = [0u8; 1];
274        let err = InstructionBuilder::new(&m, [0u8; 32], "deposit")
275            .unwrap()
276            .arg(&amount_wrong)
277            .arg(&bump)
278            .account([0u8; 32])
279            .account([0u8; 32])
280            .build()
281            .unwrap_err();
282        assert!(matches!(
283            err,
284            BuildError::ArgSizeMismatch {
285                index: 0,
286                expected: 8,
287                got: 4
288            }
289        ));
290    }
291
292    #[test]
293    fn rejects_unknown_instruction() {
294        let m = sample_manifest();
295        let err = InstructionBuilder::new(&m, [0u8; 32], "withdraw").unwrap_err();
296        assert_eq!(err, BuildError::UnknownInstruction);
297    }
298}