hopper_native/pda.rs
1//! PDA (Program Derived Address) helpers.
2//!
3//! Direct syscall-based PDA creation and derivation. No external dependencies.
4
5use crate::account_view::AccountView;
6use crate::address::{Address, MAX_SEEDS, MAX_SEED_LEN};
7use crate::error::ProgramError;
8
9#[cfg(target_os = "solana")]
10const CURVE25519_EDWARDS: u64 = 0;
11#[cfg(target_os = "solana")]
12const PDA_MARKER_BYTES: &[u8; 21] = crate::address::PDA_MARKER;
13
14/// SHA-based paths must accept exactly the seed domain that the PDA signing
15/// syscall accepts. A helper which appends a bump reserves one of the 16 slots.
16#[inline(always)]
17fn validate_seeds(seeds: &[&[u8]], max_count: usize) -> Result<(), ProgramError> {
18 if seeds.len() > max_count || seeds.iter().any(|seed| seed.len() > MAX_SEED_LEN) {
19 return Err(ProgramError::InvalidSeeds);
20 }
21 Ok(())
22}
23
24/// Create a program-derived address from seeds and a program ID.
25///
26/// Returns `Err(InvalidSeeds)` if the derived address falls on the
27/// ed25519 curve (not a valid PDA), or if more than [`MAX_SEEDS`] seeds
28/// are supplied (matching upstream `Pubkey::create_program_address`
29/// semantics, never silently truncating the seed set).
30#[inline(always)]
31pub fn create_program_address(
32 seeds: &[&[u8]],
33 program_id: &Address,
34) -> Result<Address, ProgramError> {
35 validate_seeds(seeds, MAX_SEEDS)?;
36 #[cfg(target_os = "solana")]
37 {
38 // The syscall reads `seeds.len()` (ptr, len) pairs of 8-byte words,
39 // which is exactly the in-memory shape of a `&[&[u8]]` on the SBF
40 // target (the same layout the Solana SDK and pinocchio hand over).
41 // Passing the slice directly replaces the zero-filled 256-byte
42 // staging buffer and repack loop the wrapper used to run before
43 // every derivation; measured 2026-09-21 on the framework-comparison
44 // counter at ~70 CU per call above the syscall's own charge.
45 const _: () = assert!(core::mem::size_of::<&[u8]>() == 16);
46 let mut result = Address::default();
47 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
48 let rc = unsafe {
49 crate::syscalls::sol_create_program_address(
50 seeds.as_ptr() as *const u8,
51 seeds.len() as u64,
52 program_id.as_array().as_ptr(),
53 result.0.as_mut_ptr(),
54 )
55 };
56 if rc == 0 {
57 Ok(result)
58 } else {
59 Err(ProgramError::InvalidSeeds)
60 }
61 }
62 #[cfg(not(target_os = "solana"))]
63 {
64 let _ = (seeds, program_id);
65 Err(ProgramError::InvalidSeeds)
66 }
67}
68
69/// Find a program-derived address and its bump seed.
70///
71/// Iterates bump seeds 255..=0 until a valid PDA is found.
72///
73/// # Panics
74///
75/// Panics if no viable bump exists, 16 or more base seeds are supplied,
76/// or a seed exceeds 32 bytes. The bump occupies the final seed slot,
77/// matching upstream `Pubkey::find_program_address` semantics.
78/// Silently returning a placeholder here would hand callers the all-zero
79/// address, the System Program, as if it were their PDA. Use
80/// [`based_try_find_program_address`] for the fallible variant.
81///
82/// `#[inline(always)]` is deliberate: outlining the sibling
83/// `verify_pda_sha256_loop` was measured on 2026-07-09 at only −88 bytes
84/// of release `.text` for +44..+73 CU on every benched vault row, the
85/// call boundary defeats LLVM's per-call-site specialization of the seed
86/// staging and bump loop. The size answer to PDA duplication is
87/// `bump = stored` (one hash, no search), not outlining the search.
88#[inline(always)]
89pub fn find_program_address(seeds: &[&[u8]], program_id: &Address) -> (Address, u8) {
90 #[cfg(target_os = "solana")]
91 {
92 match based_try_find_program_address(seeds, program_id) {
93 Ok(found) => found,
94 Err(_) => panic!("hopper: unable to find a viable program address bump seed"),
95 }
96 }
97 #[cfg(not(target_os = "solana"))]
98 {
99 let _ = (seeds, program_id);
100 panic!(
101 "hopper: find_program_address requires the SVM sha256 syscall (target_os = \"solana\")"
102 );
103 }
104}
105
106/// The program-derived address for `seeds` and `bump` under `program_id`,
107/// computed with the const SHA-256 so it can be evaluated at compile time.
108///
109/// This is the hash half of `create_program_address` (seeds, bump, program
110/// id, the `ProgramDerivedAddress` marker) without the curve rejection, so
111/// callers must pass the canonical bump their client obtained from
112/// `find_program_address`; a bump the runtime would skip because its hash
113/// lands on the ed25519 curve is not detected here. Static seeds hashed
114/// once at compile time turn a runtime PDA check into a 32-byte compare.
115/// Panics on 16 or more base seeds or a seed longer than 32 bytes. The bump
116/// occupies one of the runtime's 16 seed slots. In a const expression the
117/// refusal is a compilation error.
118pub const fn program_address_const(seeds: &[&[u8]], bump: u8, program_id: &Address) -> Address {
119 assert!(
120 seeds.len() < MAX_SEEDS,
121 "a PDA takes at most 15 seeds plus its bump"
122 );
123 let mut hasher = crate::sha256::ConstSha256::new();
124 let mut i = 0;
125 while i < seeds.len() {
126 assert!(
127 seeds[i].len() <= crate::address::MAX_SEED_LEN,
128 "a PDA seed is at most 32 bytes"
129 );
130 hasher = hasher.update(seeds[i]);
131 i += 1;
132 }
133 let hash = hasher
134 .update(&[bump])
135 .update(program_id.as_array())
136 .update(crate::address::PDA_MARKER)
137 .finalize();
138 Address::new_from_array(hash)
139}
140
141/// Verify that an expected address matches the PDA hash for the provided seeds.
142///
143/// The seeds slice must already include the bump byte.
144/// This checks hash equality only. It does not establish curve membership,
145/// canonicality, account ownership, or an account's initialization history.
146#[inline(always)]
147pub fn verify_program_address(
148 seeds: &[&[u8]],
149 program_id: &Address,
150 expected: &Address,
151) -> Result<(), ProgramError> {
152 validate_seeds(seeds, MAX_SEEDS)?;
153
154 #[cfg(target_os = "solana")]
155 {
156 let n = seeds.len();
157 let mut slices = core::mem::MaybeUninit::<[&[u8]; MAX_SEEDS + 2]>::uninit();
158 let slice_ptr = slices.as_mut_ptr() as *mut &[u8];
159
160 let mut i = 0;
161 while i < n {
162 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
163 unsafe { slice_ptr.add(i).write(seeds[i]) };
164 i += 1;
165 }
166 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
167 unsafe {
168 slice_ptr.add(n).write(program_id.as_ref());
169 slice_ptr.add(n + 1).write(PDA_MARKER_BYTES.as_slice());
170 }
171
172 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
173 let input = unsafe { core::slice::from_raw_parts(slice_ptr, n + 2) };
174 let mut hash = core::mem::MaybeUninit::<[u8; 32]>::uninit();
175
176 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
177 unsafe {
178 crate::syscalls::sol_sha256(
179 input as *const _ as *const u8,
180 input.len() as u64,
181 hash.as_mut_ptr() as *mut u8,
182 );
183 }
184
185 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
186 let derived = unsafe { &*(hash.as_ptr() as *const Address) };
187 if derived == expected {
188 Ok(())
189 } else {
190 Err(ProgramError::InvalidSeeds)
191 }
192 }
193 #[cfg(not(target_os = "solana"))]
194 {
195 let _ = (seeds, program_id, expected);
196 Err(ProgramError::InvalidSeeds)
197 }
198}
199
200/// Find a valid PDA by hashing seeds directly and checking curve validity.
201///
202/// This avoids the `sol_try_find_program_address` syscall and substantially
203/// reduces the per-attempt CU cost on SBF.
204#[inline(always)]
205pub fn based_try_find_program_address(
206 seeds: &[&[u8]],
207 program_id: &Address,
208) -> Result<(Address, u8), ProgramError> {
209 validate_seeds(seeds, MAX_SEEDS - 1)?;
210
211 #[cfg(target_os = "solana")]
212 {
213 let n = seeds.len();
214 let mut slices = core::mem::MaybeUninit::<[&[u8]; MAX_SEEDS + 2]>::uninit();
215 let slice_ptr = slices.as_mut_ptr() as *mut &[u8];
216
217 let mut i = 0;
218 while i < n {
219 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
220 unsafe { slice_ptr.add(i).write(seeds[i]) };
221 i += 1;
222 }
223 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
224 unsafe {
225 slice_ptr.add(n + 1).write(program_id.as_ref());
226 slice_ptr.add(n + 2).write(PDA_MARKER_BYTES.as_slice());
227 }
228
229 let mut hash = core::mem::MaybeUninit::<[u8; 32]>::uninit();
230 let mut bump: u64 = u8::MAX as u64;
231
232 loop {
233 let bump_seed = [bump as u8];
234 // SAFETY: n <= MAX_SEEDS - 1. Install this iteration's immutable
235 // seed before constructing the initialized prefix. The syscall
236 // consumes it synchronously; no shared seed reference is reused
237 // after the next iteration rewrites the descriptor array.
238 unsafe {
239 slice_ptr
240 .add(n)
241 .write(core::slice::from_raw_parts(bump_seed.as_ptr(), 1))
242 };
243 let input = unsafe { core::slice::from_raw_parts(slice_ptr, n + 3) };
244
245 // SAFETY: All n + 3 descriptors are initialized, every referenced
246 // byte is live for the call, and hash has space for its 32-byte output.
247 unsafe {
248 crate::syscalls::sol_sha256(
249 input as *const _ as *const u8,
250 input.len() as u64,
251 hash.as_mut_ptr() as *mut u8,
252 );
253 }
254
255 // SAFETY: `hash` was fully written by sol_sha256 above; the
256 // syscall only reads 32 bytes from it.
257 // Return code semantics: 0 = the point IS on the ed25519 curve
258 // (not a valid PDA), nonzero = off-curve (valid PDA).
259 let curve_rc = unsafe {
260 crate::syscalls::sol_curve_validate_point(
261 CURVE25519_EDWARDS,
262 hash.as_ptr() as *const u8,
263 core::ptr::null_mut(),
264 )
265 };
266
267 if curve_rc != 0 {
268 return Ok((
269 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
270 Address::new_from_array(unsafe { hash.assume_init() }),
271 bump as u8,
272 ));
273 }
274
275 if bump == 0 {
276 break;
277 }
278 bump -= 1;
279 }
280
281 Err(ProgramError::InvalidSeeds)
282 }
283 #[cfg(not(target_os = "solana"))]
284 {
285 let _ = (seeds, program_id);
286 Err(ProgramError::InvalidSeeds)
287 }
288}
289
290/// Verify that an account's address matches a PDA derived from the given seeds.
291///
292/// Returns `Ok(())` if the account address matches the derived PDA,
293/// or `Err(InvalidSeeds)` if it does not.
294#[inline(always)]
295pub fn verify_pda(
296 account: &AccountView<'_>,
297 seeds: &[&[u8]],
298 program_id: &Address,
299) -> Result<(), ProgramError> {
300 let expected = create_program_address(seeds, program_id)?;
301 if account.address() == &expected {
302 Ok(())
303 } else {
304 Err(ProgramError::InvalidSeeds)
305 }
306}
307
308/// Verify a PDA with an explicit bump seed appended to the seeds.
309///
310/// Appends `&[bump]` to the end of the seed list before verifying via
311/// SHA-256 (~200 CU). This is substantially cheaper than the syscall-based
312/// `create_program_address` approach (~1500 CU).
313///
314/// Returns `Err(InvalidSeeds)` for 16 or more base seeds or a seed longer
315/// than 32 bytes. The bump counts toward the 16-seed runtime limit.
316///
317/// # Bump canonicalization
318///
319/// `bump` must be the **canonical** bump for these seeds, the one
320/// `find_program_address` returns and the program stored at init. Passing
321/// an attacker-supplied bump (e.g. straight from instruction data) lets
322/// multiple addresses verify for the same logical seed set, the classic
323/// bump-canonicalization vulnerability. Prefer
324/// [`verify_pda_from_stored_bump`], which reads the bump the account
325/// itself recorded.
326#[inline]
327pub fn verify_pda_with_bump(
328 account: &AccountView<'_>,
329 seeds: &[&[u8]],
330 bump: u8,
331 program_id: &Address,
332) -> Result<(), ProgramError> {
333 validate_seeds(seeds, MAX_SEEDS - 1)?;
334 // Build a seed list with the bump appended.
335 // Stack-allocated: at most 15 base seeds plus the bump.
336 let mut full_seeds: [&[u8]; MAX_SEEDS] = [&[]; MAX_SEEDS];
337 let num = seeds.len();
338 let mut i = 0;
339 while i < num {
340 full_seeds[i] = seeds[i];
341 i += 1;
342 }
343 let bump_bytes = [bump];
344 full_seeds[num] = &bump_bytes;
345
346 verify_program_address(&full_seeds[..num + 1], program_id, account.address())
347}
348
349/// Verify that an address matches a PDA derived from the given seeds.
350///
351/// Unlike `verify_pda` which takes an `AccountView`, this accepts a raw
352/// `Address` reference directly. Useful when validating addresses outside
353/// of the account parsing flow (e.g. instruction data, cross-program reads).
354///
355/// The seeds slice must already include the bump byte (like
356/// `verify_program_address`). Uses SHA-256 verify-only path (~200 CU)
357/// instead of the full `find_program_address` (~1500 CU).
358///
359/// The included bump must be the **canonical** one for the seed set (see
360/// [`verify_pda_with_bump`]'s bump-canonicalization note): verifying with
361/// an attacker-supplied bump lets multiple addresses pass for the same
362/// logical seeds.
363///
364/// Returns `Ok(())` if the address matches the derived PDA,
365/// or `Err(InvalidSeeds)` if it does not.
366#[inline]
367pub fn verify_pda_strict(
368 expected: &Address,
369 seeds: &[&[u8]],
370 program_id: &Address,
371) -> Result<(), ProgramError> {
372 verify_program_address(seeds, program_id, expected)
373}
374
375/// Find the bump seed for a known PDA address, skipping curve validation.
376///
377/// This is a hash-match search, not canonical derivation or an off-curve
378/// proof. Mere presence in a transaction or existence on chain does not
379/// establish either property. The caller must establish the required PDA
380/// provenance separately. Use [`based_try_find_program_address`] and compare
381/// its result when a canonical address is required.
382///
383/// Returns the bump seed, or `Err(InvalidSeeds)` if no bump produces a match.
384#[inline(always)]
385pub fn find_bump_for_address(
386 seeds: &[&[u8]],
387 program_id: &Address,
388 expected: &Address,
389) -> Result<u8, ProgramError> {
390 validate_seeds(seeds, MAX_SEEDS - 1)?;
391
392 #[cfg(target_os = "solana")]
393 {
394 let n = seeds.len();
395 let mut slices = core::mem::MaybeUninit::<[&[u8]; MAX_SEEDS + 2]>::uninit();
396 let slice_ptr = slices.as_mut_ptr() as *mut &[u8];
397
398 let mut i = 0;
399 while i < n {
400 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
401 unsafe { slice_ptr.add(i).write(seeds[i]) };
402 i += 1;
403 }
404 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
405 unsafe {
406 slice_ptr.add(n + 1).write(program_id.as_ref());
407 slice_ptr.add(n + 2).write(PDA_MARKER_BYTES.as_slice());
408 }
409
410 let mut hash = core::mem::MaybeUninit::<[u8; 32]>::uninit();
411 let mut bump: u64 = u8::MAX as u64;
412
413 loop {
414 let bump_seed = [bump as u8];
415 // SAFETY: n <= MAX_SEEDS - 1. Install this iteration's immutable
416 // seed before constructing the initialized prefix. No shared
417 // seed reference is reused after its backing byte changes.
418 unsafe {
419 slice_ptr
420 .add(n)
421 .write(core::slice::from_raw_parts(bump_seed.as_ptr(), 1))
422 };
423 let input = unsafe { core::slice::from_raw_parts(slice_ptr, n + 3) };
424
425 // SAFETY: All descriptors and input bytes remain live through the
426 // synchronous call, which fills the 32-byte hash output.
427 unsafe {
428 crate::syscalls::sol_sha256(
429 input as *const _ as *const u8,
430 input.len() as u64,
431 hash.as_mut_ptr() as *mut u8,
432 );
433 }
434
435 // Address-match shortcut: skip curve check entirely.
436 // Matching this address does not establish canonicality or
437 // curve membership; those are separate caller obligations.
438 // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
439 let derived = unsafe { &*(hash.as_ptr() as *const Address) };
440 if derived == expected {
441 return Ok(bump as u8);
442 }
443
444 if bump == 0 {
445 break;
446 }
447 bump -= 1;
448 }
449
450 Err(ProgramError::InvalidSeeds)
451 }
452 #[cfg(not(target_os = "solana"))]
453 {
454 let _ = (seeds, program_id, expected);
455 Err(ProgramError::InvalidSeeds)
456 }
457}
458
459/// Read the bump byte directly from account data at a known offset.
460///
461/// Used with `BUMP_OFFSET` from `hopper_layout!` types to read the stored
462/// bump without any derivation. Combined with `verify_program_address`,
463/// the total PDA verification cost is ~200 CU vs ~1500 CU for
464/// `find_program_address`.
465///
466/// Returns `Err(AccountDataTooSmall)` if the account data is shorter than
467/// `bump_offset + 1`.
468#[inline(always)]
469pub fn read_bump_from_account(
470 account: &AccountView<'_>,
471 bump_offset: usize,
472) -> Result<u8, ProgramError> {
473 let data = account.try_borrow()?;
474 if data.len() <= bump_offset {
475 return Err(ProgramError::AccountDataTooSmall);
476 }
477 Ok(data[bump_offset])
478}
479
480/// Verify a PDA using the bump stored in account data (cheapest path).
481///
482/// Reads the bump at `bump_offset`, appends it to seeds, then uses
483/// SHA-256 verify-only. Total cost: ~200 CU vs ~1500 CU.
484///
485/// This is the optimal PDA verification path and should be the default
486/// for Hopper programs that store bumps in their account layout.
487#[inline]
488pub fn verify_pda_from_stored_bump(
489 account: &AccountView<'_>,
490 seeds: &[&[u8]],
491 bump_offset: usize,
492 program_id: &Address,
493) -> Result<(), ProgramError> {
494 validate_seeds(seeds, MAX_SEEDS - 1)?;
495 let bump = read_bump_from_account(account, bump_offset)?;
496
497 let mut full_seeds: [&[u8]; MAX_SEEDS] = [&[]; MAX_SEEDS];
498 let num = seeds.len();
499 let mut i = 0;
500 while i < num {
501 full_seeds[i] = seeds[i];
502 i += 1;
503 }
504 let bump_bytes = [bump];
505 full_seeds[num] = &bump_bytes;
506
507 verify_program_address(&full_seeds[..num + 1], program_id, account.address())
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn const_pda_accepts_fifteen_base_seeds() {
516 let id = Address::new_from_array([91; 32]);
517 let fifteen: [&[u8]; 15] = [&[]; 15];
518 assert_eq!(
519 program_address_const(&fifteen, 255, &id),
520 program_address_const(&[], 255, &id)
521 );
522 }
523
524 #[test]
525 #[should_panic(expected = "at most 15 seeds plus its bump")]
526 fn const_pda_reserves_the_bump_slot() {
527 let seeds: [&[u8]; 16] = [&[]; 16];
528 program_address_const(&seeds, 255, &Address::new_from_array([91; 32]));
529 }
530
531 #[test]
532 #[should_panic(expected = "at most 32 bytes")]
533 fn const_pda_rejects_oversized_seeds() {
534 program_address_const(&[&[0; 33]], 255, &Address::new_from_array([91; 32]));
535 }
536}