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/// Create a program-derived address from seeds and a program ID.
8///
9/// Returns `Err(InvalidSeeds)` if the derived address falls on the
10/// ed25519 curve (not a valid PDA).
11#[inline]
12pub fn create_program_address(
13    seeds: &[&[u8]],
14    program_id: &Address,
15) -> Result<Address, ProgramError> {
16    crate::native_boundary::create_program_address(seeds, program_id)
17}
18
19/// Find a program-derived address and its bump seed.
20///
21/// Iterates bump seeds 255..=0 until a valid PDA is found.
22///
23/// # Panics
24///
25/// Panics if no viable bump exists (matching upstream
26/// `Pubkey::find_program_address`), and on non-SVM hosts, where the sha256
27/// syscall is unavailable. The earlier host fallback silently returned
28/// `(Address::default(), 0)`, the all-zero System Program address; which
29/// made host tests "pass" derivation while comparing against a meaningless
30/// key. Host tests should exercise PDA paths through the SVM harness.
31#[inline]
32pub fn find_program_address(seeds: &[&[u8]], program_id: &Address) -> (Address, u8) {
33    #[cfg(target_os = "solana")]
34    {
35        crate::native_boundary::find_program_address(seeds, program_id)
36    }
37    #[cfg(not(target_os = "solana"))]
38    {
39        let _ = (seeds, program_id);
40        panic!(
41            "hopper: find_program_address requires the SVM sha256 syscall; \
42             run PDA paths under the SVM harness (target_os = \"solana\")"
43        );
44    }
45}
46
47/// Hopper-facing alias for PDA derivation.
48#[inline(always)]
49pub fn derive(seeds: &[&[u8]], program_id: &Address) -> (Address, u8) {
50    find_program_address(seeds, program_id)
51}
52
53/// A program-derived address evaluated at compile time.
54///
55/// For seeds that are all literals (a `b"config"` singleton, a
56/// `b"vault"` + declared-id pair), the address is a constant of the
57/// program, so there is nothing to hash on chain: declare it once and
58/// check the account with `#[account(address = CONFIG)]`, a 32-byte
59/// compare instead of a `sol_sha256` (about 150 CU) or a
60/// `create_program_address` syscall (1,500 CU) on every instruction that
61/// touches the account. [`crate::const_pda!`] is the same call with the
62/// seed list spelled inline.
63///
64/// This computes the hash for the selected bump. It does not search for the
65/// canonical bump or check that the result is off-curve. Establish those
66/// properties separately before using the result as a PDA. For literal inputs,
67/// the facade's `hopper::canonical_pda!` macro derives a canonical address and
68/// bump on the build host. Account ownership, layout and privilege checks are
69/// still separate application obligations.
70pub const fn const_program_address(program_id: &Address, seeds: &[&[u8]], bump: u8) -> Address {
71    let backend = hopper_native::address::Address::new_from_array(*program_id.as_array());
72    Address::new_from_array(
73        hopper_native::pda::program_address_const(seeds, bump, &backend).to_bytes(),
74    )
75}
76
77/// Verify that `expected` is the address the PDA hash of `seeds` (bump
78/// included) yields under `program_id`: one `sol_sha256` (about 150 CU),
79/// no `create_program_address` syscall (1,500 CU) and no curve check.
80///
81/// Sound wherever the address is already bound to something only a PDA
82/// can be: an account this program owns and whose layout validated (no
83/// private key can sign a program-owned account into existence at a hash
84/// output), or an account about to be created by a CPI signed with these
85/// seeds (the runtime's own signer check rejects an on-curve address). For
86/// an address with no such binding, an unchecked or system account, use
87/// [`verify_pda_address_checked`], which keeps the curve rejection.
88#[inline]
89pub fn verify_pda_address(
90    seeds: &[&[u8]],
91    program_id: &Address,
92    expected: &Address,
93) -> Result<(), ProgramError> {
94    #[cfg(target_os = "solana")]
95    {
96        hopper_native::pda::verify_program_address(
97            seeds,
98            crate::native_boundary::as_backend_address(program_id),
99            crate::native_boundary::as_backend_address(expected),
100        )
101        .map_err(ProgramError::from)
102    }
103    #[cfg(not(target_os = "solana"))]
104    {
105        let _ = (seeds, program_id, expected);
106        Err(ProgramError::InvalidSeeds)
107    }
108}
109
110/// [`verify_pda_address`] kept out of line.
111///
112/// `#[derive(Accounts)]` calls this on the branch of a CPI-proven `init`
113/// field that the creation CPI cannot prove (a signer, or an account that
114/// already holds data). That branch is cold, so the seed staging and the
115/// hash compare, about 700 bytes inlined, are linked once for the program
116/// instead of once per such field.
117#[cold]
118#[inline(never)]
119pub fn verify_pda_address_cold(
120    seeds: &[&[u8]],
121    program_id: &Address,
122    expected: &Address,
123) -> Result<(), ProgramError> {
124    verify_pda_address(seeds, program_id, expected)
125}
126
127/// [`verify_pda_address`] with the full `create_program_address` syscall,
128/// so an address whose hash lands on the ed25519 curve is refused.
129#[inline]
130pub fn verify_pda_address_checked(
131    seeds: &[&[u8]],
132    program_id: &Address,
133    expected: &Address,
134) -> Result<(), ProgramError> {
135    let derived = create_program_address(seeds, program_id)?;
136    if crate::address::address_eq(&derived, expected) {
137        Ok(())
138    } else {
139        Err(ProgramError::InvalidSeeds)
140    }
141}
142
143/// Find the bump under which `seeds` hash to `expected`, searching from
144/// 255 down with one `sol_sha256` per candidate and no curve check (about
145/// 150 CU per candidate instead of about 310). Returns `InvalidSeeds` when
146/// no bump matches. Same soundness condition as [`verify_pda_address`]:
147/// use it only when `expected` is bound to a program-owned or about-to-be
148/// created account; otherwise [`find_canonical_bump_checked`].
149///
150/// This finds a matching bump, not necessarily the canonical (highest
151/// off-curve) bump. Ownership does not prove canonicality. Use
152/// [`find_canonical_bump_checked`] whenever one address per seed set is required.
153#[inline]
154pub fn find_bump_for_address(
155    seeds: &[&[u8]],
156    program_id: &Address,
157    expected: &Address,
158) -> Result<u8, ProgramError> {
159    #[cfg(target_os = "solana")]
160    {
161        hopper_native::pda::find_bump_for_address(
162            seeds,
163            crate::native_boundary::as_backend_address(program_id),
164            crate::native_boundary::as_backend_address(expected),
165        )
166        .map_err(ProgramError::from)
167    }
168    #[cfg(not(target_os = "solana"))]
169    {
170        let _ = (seeds, program_id, expected);
171        Err(ProgramError::InvalidSeeds)
172    }
173}
174
175/// The canonical bump for `seeds`, found with the curve check on every
176/// candidate, provided the canonical address equals `expected`.
177#[inline]
178pub fn find_canonical_bump_checked(
179    seeds: &[&[u8]],
180    program_id: &Address,
181    expected: &Address,
182) -> Result<u8, ProgramError> {
183    #[cfg(target_os = "solana")]
184    let (derived, bump) = hopper_native::pda::based_try_find_program_address(
185        seeds,
186        crate::native_boundary::as_backend_address(program_id),
187    )
188    .map(|(address, bump)| (Address::new_from_array(address.to_bytes()), bump))
189    .map_err(ProgramError::from)?;
190    #[cfg(not(target_os = "solana"))]
191    let (derived, bump) = find_program_address(seeds, program_id);
192    if crate::address::address_eq(&derived, expected) {
193        Ok(bump)
194    } else {
195        Err(ProgramError::InvalidSeeds)
196    }
197}
198
199/// Verify that an account's address matches a PDA derived from the given seeds.
200#[inline]
201pub fn verify_pda(
202    account: &AccountView<'_>,
203    seeds: &[&[u8]],
204    program_id: &Address,
205) -> Result<(), ProgramError> {
206    #[cfg(target_os = "solana")]
207    {
208        hopper_native::pda::verify_pda(
209            account.as_backend(),
210            seeds,
211            crate::native_boundary::as_backend_address(program_id),
212        )
213        .map_err(ProgramError::from)
214    }
215
216    #[cfg(not(target_os = "solana"))]
217    {
218        let expected = create_program_address(seeds, program_id)?;
219        if crate::address::address_eq(account.address(), &expected) {
220            Ok(())
221        } else {
222            Err(ProgramError::InvalidSeeds)
223        }
224    }
225}
226
227/// Verify a PDA with an explicit bump seed appended to the seeds.
228#[inline]
229pub fn verify_pda_with_bump(
230    account: &AccountView<'_>,
231    seeds: &[&[u8]],
232    bump: u8,
233    program_id: &Address,
234) -> Result<(), ProgramError> {
235    #[cfg(target_os = "solana")]
236    {
237        hopper_native::pda::verify_pda_with_bump(
238            account.as_backend(),
239            seeds,
240            bump,
241            crate::native_boundary::as_backend_address(program_id),
242        )
243        .map_err(ProgramError::from)
244    }
245
246    #[cfg(not(target_os = "solana"))]
247    {
248        if seeds.len() >= 16 {
249            return Err(ProgramError::InvalidSeeds);
250        }
251        let mut full_seeds: [&[u8]; 16] = [&[]; 16];
252        let num = seeds.len();
253        let mut i = 0;
254        while i < num {
255            full_seeds[i] = seeds[i];
256            i += 1;
257        }
258        let bump_bytes = [bump];
259        full_seeds[num] = &bump_bytes;
260
261        let expected = create_program_address(&full_seeds[..num + 1], program_id)?;
262        if crate::address::address_eq(account.address(), &expected) {
263            Ok(())
264        } else {
265            Err(ProgramError::InvalidSeeds)
266        }
267    }
268}
269
270/// Verify that an account matches a PDA derived from the given seeds.
271///
272// ---------------------------------------------------------------------
273/// no `sol_curve_validate_point` needed because we compare each hash directly
274/// against the known PDA address. This saves ~159 CU per attempt compared to
275/// the standard `find_program_address` approach (sha256+curve_validate).
276///
277/// Average cost: ~200 CU for bump=255, ~400 CU for bump=254, etc.
278/// Standard find_program_address: ~544 CU per attempt.
279///
280/// Returns the bump seed on success.
281#[inline]
282pub fn find_and_verify_pda(
283    account: &AccountView<'_>,
284    seeds: &[&[u8]],
285    program_id: &Address,
286) -> Result<u8, ProgramError> {
287    #[cfg(target_os = "solana")]
288    {
289        let expected_addr = account.as_backend().address();
290        let backend_expected =
291            // 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.
292            unsafe { &*(expected_addr as *const hopper_native::address::Address) };
293        verify_pda_sha256_loop(backend_expected, seeds, program_id)
294    }
295
296    #[cfg(not(target_os = "solana"))]
297    {
298        let (expected, bump) = find_program_address(seeds, program_id);
299        if crate::address::address_eq(account.address(), &expected) {
300            Ok(bump)
301        } else {
302            Err(ProgramError::InvalidSeeds)
303        }
304    }
305}
306
307/// Verify that a raw address matches a PDA derived from the given seeds.
308///
309/// Uses the same verify-only sha256 loop as `find_and_verify_pda`.
310#[inline]
311pub fn verify_pda_strict(
312    expected: &Address,
313    seeds: &[&[u8]],
314    program_id: &Address,
315) -> Result<(), ProgramError> {
316    #[cfg(target_os = "solana")]
317    {
318        let backend_expected =
319            // 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.
320            unsafe { &*(expected as *const Address as *const hopper_native::address::Address) };
321        verify_pda_sha256_loop(backend_expected, seeds, program_id).map(|_| ())
322    }
323
324    #[cfg(not(target_os = "solana"))]
325    {
326        let (derived, _) = find_program_address(seeds, program_id);
327        if crate::address::address_eq(&derived, expected) {
328            Ok(())
329        } else {
330            Err(ProgramError::InvalidSeeds)
331        }
332    }
333}
334
335/// Shared sha256-only PDA verify loop used by both `find_and_verify_pda`
336/// and `verify_pda_strict`.
337///
338// ---------------------------------------------------------------------
339/// Returns the matching bump on success.
340///
341/// `#[inline(always)]` is deliberate and MEASURED, do not "fix" the
342/// duplication: outlining this (`inline(never)`) was tried on 2026-07-09
343/// and saved only 88 bytes of release `.text` while costing **+44..+73
344/// CU on every benched vault row** (Authorize 420→464, Counter 518→591,
345/// Deposit 1653→1697, Withdraw 494→541), the call boundary defeats
346/// LLVM's per-call-site specialization of the seed-list build and bump
347/// loop, and the syscall does NOT dominate at that point. Size-per-CU,
348/// the inlined copies win decisively.
349#[cfg(target_os = "solana")]
350#[inline(always)]
351fn verify_pda_sha256_loop(
352    expected: &hopper_native::address::Address,
353    seeds: &[&[u8]],
354    program_id: &Address,
355) -> Result<u8, ProgramError> {
356    // Keep a single, fully inlined seed-domain check and hash loop. The old
357    // copy clamped the seed count, silently ignoring caller-supplied suffixes.
358    hopper_native::pda::find_bump_for_address(
359        seeds,
360        crate::native_boundary::as_backend_address(program_id),
361        expected,
362    )
363    .map_err(ProgramError::from)
364}
365
366/// Verify a PDA using the bump stored in account data (cheapest path).
367///
368/// Reads the bump byte at `bump_offset` in account data, appends it to seeds,
369/// then hashes with SHA-256 and compares to the account address. ~200 CU total.
370#[inline]
371pub fn verify_pda_from_stored_bump(
372    account: &AccountView<'_>,
373    seeds: &[&[u8]],
374    bump_offset: usize,
375    program_id: &Address,
376) -> Result<(), ProgramError> {
377    #[cfg(target_os = "solana")]
378    {
379        hopper_native::verify_pda_from_stored_bump(
380            account.as_backend(),
381            seeds,
382            bump_offset,
383            crate::native_boundary::as_backend_address(program_id),
384        )
385        .map_err(ProgramError::from)
386    }
387
388    #[cfg(not(target_os = "solana"))]
389    {
390        // Off-chain fallback: read bump, append to seeds, derive + compare.
391        let data = account.try_borrow()?;
392        if bump_offset >= data.len() {
393            return Err(ProgramError::AccountDataTooSmall);
394        }
395        let bump = data[bump_offset];
396        if seeds.len() >= 16 {
397            return Err(ProgramError::InvalidSeeds);
398        }
399        let mut full_seeds: [&[u8]; 16] = [&[]; 16];
400        let num = seeds.len();
401        let mut i = 0;
402        while i < num {
403            full_seeds[i] = seeds[i];
404            i += 1;
405        }
406        let bump_bytes = [bump];
407        full_seeds[num] = &bump_bytes;
408
409        let expected = create_program_address(&full_seeds[..num + 1], program_id)?;
410        if crate::address::address_eq(account.address(), &expected) {
411            Ok(())
412        } else {
413            Err(ProgramError::InvalidSeeds)
414        }
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    /// The devnet lane of 2026-09-21 (`audit/devnet-evidence-2026-09-21/counter/`)
423    /// created these PDAs on chain under program `F4Um7PWs…`; the const
424    /// derivation must land on the same addresses, and on the address pina's
425    /// counter program derives for the same payer.
426    #[test]
427    fn const_program_address_matches_devnet_created_pdas() {
428        const PROGRAM: Address = crate::address!("F4Um7PWsnZfN7y8WFzu1aPYJwqGduJTa4zuCGY9EUqMy");
429        const PAYER: Address = crate::address!("4sbBUbY71JFeA4kJckBmNnTADiFu4jtu84Gzev52ZEhn");
430        const AUTHORITY_C: Address =
431            crate::address!("7Qj28pSptq3YEdppwTmxDEP4jLsS1o67D1ZfKQJB9SE2");
432        const PINA_COUNTER: Address =
433            crate::address!("GJQcuWrT2f3f4KNuJcXhhwUa1ZQTYbxzzJ1hotzKu8hS");
434
435        const PDA_A: Address = crate::const_pda!(PROGRAM, [b"counter", PAYER.as_array()], 252);
436        const PDA_C: Address =
437            crate::const_pda!(PROGRAM, [b"counter", AUTHORITY_C.as_array()], 254);
438        const PDA_PINA: Address =
439            const_program_address(&PINA_COUNTER, &[b"counter", PAYER.as_array()], 253);
440
441        assert_eq!(
442            PDA_A,
443            crate::address!("Cn3JBYNBEctRDGuotxM7c3Fz3QgCZgRXkKV1G7h1qZKn")
444        );
445        assert_eq!(
446            PDA_C,
447            crate::address!("6vh34eBGs3gvwdaJ3fgXDLQMtNfqUwSJYrHqZ3FgCwYP")
448        );
449        assert_eq!(
450            PDA_PINA,
451            crate::address!("CW1z5aL4hTAFFubWKVKw1ANkdYurNEAiWbqxKsDCaERH")
452        );
453        // A different bump is a different address, never a silent match.
454        assert_ne!(
455            const_program_address(&PROGRAM, &[b"counter", PAYER.as_array()], 251),
456            PDA_A
457        );
458    }
459}