Skip to main content

hopper_runtime/
pda.rs

1//! Hopper-owned PDA ergonomics on top of the native runtime boundary.
2
3use crate::address::Address;
4use crate::error::ProgramError;
5use crate::AccountView;
6
7/// The longest seed of an address (`MAX_SEED_LEN`).
8pub const MAX_SEED_LEN: usize = 32;
9
10/// The suffix an owner may not end with in [`create_with_seed`]: the
11/// marker every program-derived address is hashed with. An owner ending
12/// in it would let a seeded address collide with a program's PDA.
13const PDA_MARKER: &[u8; 21] = b"ProgramDerivedAddress";
14
15/// The address the System Program's `*WithSeed` instructions derive from
16/// `base`, `seed`, and `owner`: `sha256(base, seed, owner)`, the same
17/// value as `Pubkey::create_with_seed`. One `sol_sha256` on chain.
18///
19/// Refuses a seed longer than 32 bytes (`MaxSeedLengthExceeded`) and an
20/// owner that ends with the PDA marker (`IllegalOwner`), as the runtime
21/// does. The System Program reads the seed as UTF-8 text.
22#[inline]
23pub fn create_with_seed(
24    base: &Address,
25    seed: &[u8],
26    owner: &Address,
27) -> Result<Address, ProgramError> {
28    if seed.len() > MAX_SEED_LEN {
29        return Err(ProgramError::MaxSeedLengthExceeded);
30    }
31    let owner_bytes = owner.as_array();
32    if owner_bytes[32 - PDA_MARKER.len()..] == PDA_MARKER[..] {
33        return Err(ProgramError::IllegalOwner);
34    }
35    let digest = hopper_native::hash::sha256(&[base.as_array(), seed, owner_bytes])
36        .map_err(|_| ProgramError::InvalidArgument)?;
37    Ok(Address::new_from_array(digest))
38}
39
40/// Check that `expected` is the address [`create_with_seed`] derives
41/// from `base`, `seed`, and `owner`; `InvalidSeeds` when it is not.
42#[inline]
43pub fn verify_address_with_seed(
44    expected: &Address,
45    base: &Address,
46    seed: &[u8],
47    owner: &Address,
48) -> Result<(), ProgramError> {
49    if create_with_seed(base, seed, owner)? == *expected {
50        Ok(())
51    } else {
52        Err(ProgramError::InvalidSeeds)
53    }
54}
55
56/// Create a program-derived address from seeds and a program ID.
57///
58/// Returns `Err(InvalidSeeds)` if the derived address falls on the
59/// ed25519 curve (not a valid PDA).
60#[inline]
61pub fn create_program_address(
62    seeds: &[&[u8]],
63    program_id: &Address,
64) -> Result<Address, ProgramError> {
65    crate::native_boundary::create_program_address(seeds, program_id)
66}
67
68/// Find a program-derived address and its bump seed.
69///
70/// Iterates bump seeds 255..=0 until a valid PDA is found.
71///
72/// Runs off chain too, with the const SHA-256 and curve check in
73/// `hopper_native`, and returns what the cluster returns, so a PDA check
74/// can be exercised in a plain unit test.
75///
76/// # Panics
77///
78/// Panics if no viable bump exists (matching upstream
79/// `Pubkey::find_program_address`).
80#[inline]
81pub fn find_program_address(seeds: &[&[u8]], program_id: &Address) -> (Address, u8) {
82    crate::native_boundary::find_program_address(seeds, program_id)
83}
84
85/// Find the canonical address and bump without panicking on invalid seeds.
86///
87/// Returns `InvalidSeeds` for 16 or more base seeds, a seed over 32 bytes,
88/// or an exhausted bump search. The bump occupies one of the 16 seed slots.
89/// Uses Hopper's native hashing and curve checks on chain and on the host,
90/// with no heap allocation.
91#[inline]
92pub fn try_find_program_address(
93    seeds: &[&[u8]],
94    program_id: &Address,
95) -> Result<(Address, u8), ProgramError> {
96    let backend = hopper_native::Address::new_from_array(*program_id.as_array());
97    hopper_native::pda::based_try_find_program_address(seeds, &backend)
98        .map(|(address, bump)| (Address::new_from_array(address.to_bytes()), bump))
99        .map_err(ProgramError::from)
100}
101
102/// The canonical program-derived address of `seeds` under `program_id`
103/// and its bump, found at compile time. The seeds may be any `const`
104/// expressions. See `hopper_native::pda::find_program_address_const`.
105pub const fn find_program_address_const(seeds: &[&[u8]], program_id: &Address) -> (Address, u8) {
106    let backend = hopper_native::address::Address::new_from_array(*program_id.as_array());
107    let (address, bump) = hopper_native::pda::find_program_address_const(seeds, &backend);
108    (Address::new_from_array(address.to_bytes()), bump)
109}
110
111/// Hopper-facing alias for PDA derivation.
112#[inline(always)]
113pub fn derive(seeds: &[&[u8]], program_id: &Address) -> (Address, u8) {
114    find_program_address(seeds, program_id)
115}
116
117/// A program-derived address evaluated at compile time.
118///
119/// For seeds that are all literals (a `b"config"` singleton, a
120/// `b"vault"` + declared-id pair), the address is a constant of the
121/// program, so there is nothing to hash on chain: declare it once and
122/// check the account with `#[account(address = CONFIG)]`, a 32-byte
123/// compare instead of a `sol_sha256` (about 150 CU) or a
124/// `create_program_address` syscall (1,500 CU) on every instruction that
125/// touches the account. [`crate::const_pda!`] is the same call with the
126/// seed list spelled inline.
127///
128/// This computes the hash for the selected bump. It does not search for the
129/// canonical bump or check that the result is off-curve. Establish those
130/// properties separately before using the result as a PDA. For literal inputs,
131/// the facade's `hopper::canonical_pda!` macro derives a canonical address and
132/// bump on the build host. Account ownership, layout and privilege checks are
133/// still separate application obligations.
134pub const fn const_program_address(program_id: &Address, seeds: &[&[u8]], bump: u8) -> Address {
135    let backend = hopper_native::address::Address::new_from_array(*program_id.as_array());
136    Address::new_from_array(
137        hopper_native::pda::program_address_const(seeds, bump, &backend).to_bytes(),
138    )
139}
140
141/// Verify that `expected` is the address the PDA hash of `seeds` (bump
142/// included) yields under `program_id`: one `sol_sha256` (about 150 CU),
143/// no `create_program_address` syscall (1,500 CU) and no curve check.
144///
145/// Sound wherever the address is already bound to something only a PDA
146/// can be: an account this program owns and whose layout validated (no
147/// private key can sign a program-owned account into existence at a hash
148/// output), or an account about to be created by a CPI signed with these
149/// seeds (the runtime's own signer check rejects an on-curve address). For
150/// an address with no such binding, an unchecked or system account, use
151/// [`verify_pda_address_checked`], which keeps the curve rejection.
152#[inline]
153pub fn verify_pda_address(
154    seeds: &[&[u8]],
155    program_id: &Address,
156    expected: &Address,
157) -> Result<(), ProgramError> {
158    hopper_native::pda::verify_program_address(
159        seeds,
160        crate::native_boundary::as_backend_address(program_id),
161        crate::native_boundary::as_backend_address(expected),
162    )
163    .map_err(ProgramError::from)
164}
165
166/// [`verify_pda_address`] kept out of line.
167///
168/// `#[derive(Accounts)]` calls this on the branch of a CPI-proven `init`
169/// field that the creation CPI cannot prove (a signer, or an account that
170/// already holds data). That branch is cold, so the seed staging and the
171/// hash compare, about 700 bytes inlined, are linked once for the program
172/// instead of once per such field.
173#[cold]
174#[inline(never)]
175pub fn verify_pda_address_cold(
176    seeds: &[&[u8]],
177    program_id: &Address,
178    expected: &Address,
179) -> Result<(), ProgramError> {
180    verify_pda_address(seeds, program_id, expected)
181}
182
183/// [`verify_pda_address`] with the full `create_program_address` syscall,
184/// so an address whose hash lands on the ed25519 curve is refused.
185#[inline]
186pub fn verify_pda_address_checked(
187    seeds: &[&[u8]],
188    program_id: &Address,
189    expected: &Address,
190) -> Result<(), ProgramError> {
191    let derived = create_program_address(seeds, program_id)?;
192    if crate::address::address_eq(&derived, expected) {
193        Ok(())
194    } else {
195        Err(ProgramError::InvalidSeeds)
196    }
197}
198
199/// Find the bump under which `seeds` hash to `expected`, searching from
200/// 255 down with one `sol_sha256` per candidate and no curve check (about
201/// 150 CU per candidate instead of about 310). Returns `InvalidSeeds` when
202/// no bump matches. Same soundness condition as [`verify_pda_address`]:
203/// use it only when `expected` is bound to a program-owned or about-to-be
204/// created account; otherwise [`find_canonical_bump_checked`].
205///
206/// This finds a matching bump, not necessarily the canonical (highest
207/// off-curve) bump. Ownership does not prove canonicality. Use
208/// [`find_canonical_bump_checked`] whenever one address per seed set is required.
209#[inline]
210pub fn find_bump_for_address(
211    seeds: &[&[u8]],
212    program_id: &Address,
213    expected: &Address,
214) -> Result<u8, ProgramError> {
215    hopper_native::pda::find_bump_for_address(
216        seeds,
217        crate::native_boundary::as_backend_address(program_id),
218        crate::native_boundary::as_backend_address(expected),
219    )
220    .map_err(ProgramError::from)
221}
222
223/// The canonical bump for `seeds`, found with the curve check on every
224/// candidate, provided the canonical address equals `expected`.
225#[inline]
226pub fn find_canonical_bump_checked(
227    seeds: &[&[u8]],
228    program_id: &Address,
229    expected: &Address,
230) -> Result<u8, ProgramError> {
231    #[cfg(target_os = "solana")]
232    let (derived, bump) = hopper_native::pda::based_try_find_program_address(
233        seeds,
234        crate::native_boundary::as_backend_address(program_id),
235    )
236    .map(|(address, bump)| (Address::new_from_array(address.to_bytes()), bump))
237    .map_err(ProgramError::from)?;
238    #[cfg(not(target_os = "solana"))]
239    let (derived, bump) = find_program_address(seeds, program_id);
240    if crate::address::address_eq(&derived, expected) {
241        Ok(bump)
242    } else {
243        Err(ProgramError::InvalidSeeds)
244    }
245}
246
247/// Verify that an account's address matches a PDA derived from the given seeds.
248#[inline]
249pub fn verify_pda(
250    account: &AccountView<'_>,
251    seeds: &[&[u8]],
252    program_id: &Address,
253) -> Result<(), ProgramError> {
254    #[cfg(target_os = "solana")]
255    {
256        hopper_native::pda::verify_pda(
257            account.as_backend(),
258            seeds,
259            crate::native_boundary::as_backend_address(program_id),
260        )
261        .map_err(ProgramError::from)
262    }
263
264    #[cfg(not(target_os = "solana"))]
265    {
266        let expected = create_program_address(seeds, program_id)?;
267        if crate::address::address_eq(account.address(), &expected) {
268            Ok(())
269        } else {
270            Err(ProgramError::InvalidSeeds)
271        }
272    }
273}
274
275/// Verify a PDA with an explicit bump seed appended to the seeds.
276#[inline]
277pub fn verify_pda_with_bump(
278    account: &AccountView<'_>,
279    seeds: &[&[u8]],
280    bump: u8,
281    program_id: &Address,
282) -> Result<(), ProgramError> {
283    #[cfg(target_os = "solana")]
284    {
285        hopper_native::pda::verify_pda_with_bump(
286            account.as_backend(),
287            seeds,
288            bump,
289            crate::native_boundary::as_backend_address(program_id),
290        )
291        .map_err(ProgramError::from)
292    }
293
294    #[cfg(not(target_os = "solana"))]
295    {
296        if seeds.len() >= 16 {
297            return Err(ProgramError::InvalidSeeds);
298        }
299        let mut full_seeds: [&[u8]; 16] = [&[]; 16];
300        let num = seeds.len();
301        let mut i = 0;
302        while i < num {
303            full_seeds[i] = seeds[i];
304            i += 1;
305        }
306        let bump_bytes = [bump];
307        full_seeds[num] = &bump_bytes;
308
309        let expected = create_program_address(&full_seeds[..num + 1], program_id)?;
310        if crate::address::address_eq(account.address(), &expected) {
311            Ok(())
312        } else {
313            Err(ProgramError::InvalidSeeds)
314        }
315    }
316}
317
318/// Verify that an account matches a PDA derived from the given seeds.
319///
320// ---------------------------------------------------------------------
321/// no `sol_curve_validate_point` needed because we compare each hash directly
322/// against the known PDA address. This saves ~159 CU per attempt compared to
323/// the standard `find_program_address` approach (sha256+curve_validate).
324///
325/// Average cost: ~200 CU for bump=255, ~400 CU for bump=254, etc.
326/// Standard find_program_address: ~544 CU per attempt.
327///
328/// Returns the bump seed on success.
329#[inline]
330pub fn find_and_verify_pda(
331    account: &AccountView<'_>,
332    seeds: &[&[u8]],
333    program_id: &Address,
334) -> Result<u8, ProgramError> {
335    #[cfg(target_os = "solana")]
336    {
337        let expected_addr = account.as_backend().address();
338        let backend_expected =
339            // SAFETY: The native and runtime `Address` are both
340            // `#[repr(transparent)]` over `[u8; 32]`.
341            unsafe { &*(expected_addr as *const hopper_native::address::Address) };
342        verify_pda_sha256_loop(backend_expected, seeds, program_id)
343    }
344
345    #[cfg(not(target_os = "solana"))]
346    {
347        let (expected, bump) = find_program_address(seeds, program_id);
348        if crate::address::address_eq(account.address(), &expected) {
349            Ok(bump)
350        } else {
351            Err(ProgramError::InvalidSeeds)
352        }
353    }
354}
355
356/// Verify that a raw address matches a PDA derived from the given seeds.
357///
358/// Uses the same verify-only sha256 loop as `find_and_verify_pda`.
359#[inline]
360pub fn verify_pda_strict(
361    expected: &Address,
362    seeds: &[&[u8]],
363    program_id: &Address,
364) -> Result<(), ProgramError> {
365    #[cfg(target_os = "solana")]
366    {
367        let backend_expected =
368            // SAFETY: The native and runtime `Address` are both
369            // `#[repr(transparent)]` over `[u8; 32]`.
370            unsafe { &*(expected as *const Address as *const hopper_native::address::Address) };
371        verify_pda_sha256_loop(backend_expected, seeds, program_id).map(|_| ())
372    }
373
374    #[cfg(not(target_os = "solana"))]
375    {
376        let (derived, _) = find_program_address(seeds, program_id);
377        if crate::address::address_eq(&derived, expected) {
378            Ok(())
379        } else {
380            Err(ProgramError::InvalidSeeds)
381        }
382    }
383}
384
385/// Shared sha256-only PDA verify loop used by both `find_and_verify_pda`
386/// and `verify_pda_strict`.
387///
388// ---------------------------------------------------------------------
389/// Returns the matching bump on success.
390///
391/// `#[inline(always)]` is deliberate and MEASURED, do not "fix" the
392/// duplication: outlining this (`inline(never)`) was tried on 2026-07-09
393/// and saved only 88 bytes of release `.text` while costing **+44..+73
394/// CU on every benched vault row** (Authorize 420→464, Counter 518→591,
395/// Deposit 1653→1697, Withdraw 494→541), the call boundary defeats
396/// LLVM's per-call-site specialization of the seed-list build and bump
397/// loop, and the syscall does NOT dominate at that point. Size-per-CU,
398/// the inlined copies win decisively.
399#[cfg(target_os = "solana")]
400#[inline(always)]
401fn verify_pda_sha256_loop(
402    expected: &hopper_native::address::Address,
403    seeds: &[&[u8]],
404    program_id: &Address,
405) -> Result<u8, ProgramError> {
406    // Keep a single, fully inlined seed-domain check and hash loop. The old
407    // copy clamped the seed count, silently ignoring caller-supplied suffixes.
408    hopper_native::pda::find_bump_for_address(
409        seeds,
410        crate::native_boundary::as_backend_address(program_id),
411        expected,
412    )
413    .map_err(ProgramError::from)
414}
415
416/// Verify a PDA using the bump stored in account data (cheapest path).
417///
418/// Reads the bump byte at `bump_offset` in account data, appends it to seeds,
419/// then hashes with SHA-256 and compares to the account address. ~200 CU total.
420#[inline]
421pub fn verify_pda_from_stored_bump(
422    account: &AccountView<'_>,
423    seeds: &[&[u8]],
424    bump_offset: usize,
425    program_id: &Address,
426) -> Result<(), ProgramError> {
427    #[cfg(target_os = "solana")]
428    {
429        hopper_native::verify_pda_from_stored_bump(
430            account.as_backend(),
431            seeds,
432            bump_offset,
433            crate::native_boundary::as_backend_address(program_id),
434        )
435        .map_err(ProgramError::from)
436    }
437
438    #[cfg(not(target_os = "solana"))]
439    {
440        // Off-chain fallback: read bump, append to seeds, derive + compare.
441        let data = account.try_borrow()?;
442        if bump_offset >= data.len() {
443            return Err(ProgramError::AccountDataTooSmall);
444        }
445        let bump = data[bump_offset];
446        if seeds.len() >= 16 {
447            return Err(ProgramError::InvalidSeeds);
448        }
449        let mut full_seeds: [&[u8]; 16] = [&[]; 16];
450        let num = seeds.len();
451        let mut i = 0;
452        while i < num {
453            full_seeds[i] = seeds[i];
454            i += 1;
455        }
456        let bump_bytes = [bump];
457        full_seeds[num] = &bump_bytes;
458
459        let expected = create_program_address(&full_seeds[..num + 1], program_id)?;
460        if crate::address::address_eq(account.address(), &expected) {
461            Ok(())
462        } else {
463            Err(ProgramError::InvalidSeeds)
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    /// The devnet lane of 2026-09-21 (`audit/devnet-evidence-2026-09-21/counter/`)
473    /// created these PDAs on chain under program `F4Um7PWs…`; the const
474    /// derivation must land on the same addresses, and on the address pina's
475    /// counter program derives for the same payer.
476    #[test]
477    fn const_program_address_matches_devnet_created_pdas() {
478        const PROGRAM: Address = crate::address!("F4Um7PWsnZfN7y8WFzu1aPYJwqGduJTa4zuCGY9EUqMy");
479        const PAYER: Address = crate::address!("4sbBUbY71JFeA4kJckBmNnTADiFu4jtu84Gzev52ZEhn");
480        const AUTHORITY_C: Address =
481            crate::address!("7Qj28pSptq3YEdppwTmxDEP4jLsS1o67D1ZfKQJB9SE2");
482        const PINA_COUNTER: Address =
483            crate::address!("GJQcuWrT2f3f4KNuJcXhhwUa1ZQTYbxzzJ1hotzKu8hS");
484
485        const PDA_A: Address = crate::const_pda!(PROGRAM, [b"counter", PAYER.as_array()], 252);
486        const PDA_C: Address =
487            crate::const_pda!(PROGRAM, [b"counter", AUTHORITY_C.as_array()], 254);
488        const PDA_PINA: Address =
489            const_program_address(&PINA_COUNTER, &[b"counter", PAYER.as_array()], 253);
490
491        assert_eq!(
492            PDA_A,
493            crate::address!("Cn3JBYNBEctRDGuotxM7c3Fz3QgCZgRXkKV1G7h1qZKn")
494        );
495        assert_eq!(
496            PDA_C,
497            crate::address!("6vh34eBGs3gvwdaJ3fgXDLQMtNfqUwSJYrHqZ3FgCwYP")
498        );
499        assert_eq!(
500            PDA_PINA,
501            crate::address!("CW1z5aL4hTAFFubWKVKw1ANkdYurNEAiWbqxKsDCaERH")
502        );
503        // A different bump is a different address, never a silent match.
504        assert_ne!(
505            const_program_address(&PROGRAM, &[b"counter", PAYER.as_array()], 251),
506            PDA_A
507        );
508    }
509}
510
511#[cfg(test)]
512mod seeded_address_tests {
513    use super::*;
514    use solana_pubkey::Pubkey;
515
516    #[test]
517    fn fallible_search_matches_sdk_and_rejects_invalid_seed_shapes() {
518        let program = Address::new_from_array([9; 32]);
519        for seed in [b"".as_slice(), b"vault", &[7; 32]] {
520            let (address, bump) = try_find_program_address(&[seed], &program).unwrap();
521            let (expected, expected_bump) =
522                Pubkey::find_program_address(&[seed], &Pubkey::new_from_array([9; 32]));
523            assert_eq!(address.to_bytes(), expected.to_bytes());
524            assert_eq!(bump, expected_bump);
525        }
526        assert_eq!(
527            try_find_program_address(&[&[0; 33]], &program),
528            Err(ProgramError::InvalidSeeds)
529        );
530        assert_eq!(
531            try_find_program_address(&[b"x".as_slice(); 16], &program),
532            Err(ProgramError::InvalidSeeds)
533        );
534    }
535
536    #[test]
537    fn create_with_seed_matches_the_canonical_derivation() {
538        let base = Address::new_from_array([3; 32]);
539        let owner = Address::new_from_array([9; 32]);
540        for seed in ["", "vault", "0123456789abcdef0123456789abcdef", "caf\u{e9}"] {
541            let canonical = Pubkey::create_with_seed(
542                &Pubkey::new_from_array([3; 32]),
543                seed,
544                &Pubkey::new_from_array([9; 32]),
545            )
546            .unwrap();
547            let derived = create_with_seed(&base, seed.as_bytes(), &owner).unwrap();
548            assert_eq!(derived.as_array(), &canonical.to_bytes(), "seed {seed:?}");
549            assert_eq!(
550                verify_address_with_seed(&derived, &base, seed.as_bytes(), &owner),
551                Ok(())
552            );
553            assert_eq!(
554                verify_address_with_seed(&base, &base, seed.as_bytes(), &owner),
555                Err(ProgramError::InvalidSeeds)
556            );
557        }
558    }
559
560    #[test]
561    fn create_with_seed_refuses_what_the_runtime_refuses() {
562        let base = Address::new_from_array([3; 32]);
563        let owner = Address::new_from_array([9; 32]);
564        assert_eq!(
565            create_with_seed(&base, &[b'x'; 33], &owner),
566            Err(ProgramError::MaxSeedLengthExceeded)
567        );
568        let mut marked = [9u8; 32];
569        marked[11..].copy_from_slice(b"ProgramDerivedAddress");
570        assert_eq!(
571            create_with_seed(&base, b"vault", &Address::new_from_array(marked)),
572            Err(ProgramError::IllegalOwner)
573        );
574        assert!(Pubkey::create_with_seed(
575            &Pubkey::new_from_array([3; 32]),
576            "vault",
577            &Pubkey::new_from_array(marked),
578        )
579        .is_err());
580    }
581}