Skip to main content

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}