hopper_native/verify.rs
1//! Verified CPI -- pre/post state assertions around cross-program invocations.
2//!
3//! Hopper can bind a CPI call to explicit post-conditions instead of treating
4//! a successful return code as proof of the application-level result.
5//!
6//! The pattern: snapshot relevant state before CPI, invoke, then assert
7//! post-conditions. These helpers return an error on mismatch. Propagate that
8//! error to the instruction boundary (`?`) to roll back the transaction; catching
9//! or ignoring it does not undo the CPI. They do not grant transfer authority.
10//!
11//! # Usage
12//!
13//! ```ignore
14//! use hopper_native::verify::LamportSnapshot;
15//!
16//! // Before CPI transfer:
17//! let snap = LamportSnapshot::capture(source, destination);
18//!
19//! // Do the CPI:
20//! system_transfer(&source, &destination, amount)?;
21//!
22//! // Verify the transfer actually happened correctly:
23//! snap.verify_transfer(source, destination, amount)?;
24//! ```
25//!
26//! This catches:
27//! - Called program transferring wrong amount
28//! - Called program not deducting from source
29//! - Called program crediting wrong destination
30//! - Integer overflow in lamport accounting
31
32use crate::account_view::AccountView;
33use crate::error::ProgramError;
34use crate::ProgramResult;
35
36// ---- Lamport snapshot ------------------------------------------------
37
38/// Snapshot of lamport balances for two accounts before a CPI.
39///
40/// Captures the source and destination balances so that after the CPI
41/// completes, we can verify the expected transfer occurred.
42#[derive(Clone, Copy, Debug)]
43pub struct LamportSnapshot {
44 source_before: u64,
45 destination_before: u64,
46}
47
48impl LamportSnapshot {
49 /// Capture the current lamport balances of source and destination.
50 #[inline(always)]
51 pub fn capture(source: &AccountView<'_>, destination: &AccountView<'_>) -> Self {
52 Self {
53 source_before: source.lamports(),
54 destination_before: destination.lamports(),
55 }
56 }
57
58 /// Verify that exactly `amount` lamports moved from source to destination.
59 ///
60 /// Checks:
61 /// 1. Source decreased by exactly `amount`
62 /// 2. Destination increased by exactly `amount`
63 /// 3. No overflow/underflow occurred
64 #[inline]
65 pub fn verify_transfer(
66 &self,
67 source: &AccountView<'_>,
68 destination: &AccountView<'_>,
69 amount: u64,
70 ) -> ProgramResult {
71 let source_after = source.lamports();
72 let dest_after = destination.lamports();
73
74 // Source must have decreased by exactly `amount`.
75 let source_delta = self
76 .source_before
77 .checked_sub(source_after)
78 .ok_or(ProgramError::ArithmeticOverflow)?;
79 if source_delta != amount {
80 return Err(ProgramError::InvalidAccountData);
81 }
82
83 // Destination must have increased by exactly `amount`.
84 let dest_delta = dest_after
85 .checked_sub(self.destination_before)
86 .ok_or(ProgramError::ArithmeticOverflow)?;
87 if dest_delta != amount {
88 return Err(ProgramError::InvalidAccountData);
89 }
90
91 Ok(())
92 }
93
94 /// Verify that the source decreased by exactly `amount` (one-sided check).
95 ///
96 /// Use this when the destination is a program-controlled escrow or
97 /// fee account where you only care about the deduction.
98 #[inline]
99 pub fn verify_deduction(&self, source: &AccountView<'_>, amount: u64) -> ProgramResult {
100 let delta = self
101 .source_before
102 .checked_sub(source.lamports())
103 .ok_or(ProgramError::ArithmeticOverflow)?;
104 if delta != amount {
105 return Err(ProgramError::InvalidAccountData);
106 }
107 Ok(())
108 }
109
110 /// Verify that neither balance changed (no-op CPI or read-only call).
111 #[inline]
112 pub fn verify_unchanged(
113 &self,
114 source: &AccountView<'_>,
115 destination: &AccountView<'_>,
116 ) -> ProgramResult {
117 if source.lamports() != self.source_before
118 || destination.lamports() != self.destination_before
119 {
120 return Err(ProgramError::InvalidAccountData);
121 }
122 Ok(())
123 }
124
125 /// Get the pre-CPI source balance.
126 #[inline(always)]
127 pub fn source_before(&self) -> u64 {
128 self.source_before
129 }
130
131 /// Get the pre-CPI destination balance.
132 #[inline(always)]
133 pub fn destination_before(&self) -> u64 {
134 self.destination_before
135 }
136}
137
138// ---- Single-account snapshot -----------------------------------------
139
140/// Snapshot of a single account's lamports for simple balance assertions.
141#[derive(Clone, Copy, Debug)]
142pub struct BalanceSnapshot {
143 before: u64,
144}
145
146impl BalanceSnapshot {
147 /// Capture a single account's lamport balance.
148 #[inline(always)]
149 pub fn capture(account: &AccountView<'_>) -> Self {
150 Self {
151 before: account.lamports(),
152 }
153 }
154
155 /// Verify the balance increased by at least `min_increase`.
156 #[inline]
157 pub fn verify_increased_by(
158 &self,
159 account: &AccountView<'_>,
160 min_increase: u64,
161 ) -> ProgramResult {
162 let current = account.lamports();
163 let delta = current
164 .checked_sub(self.before)
165 .ok_or(ProgramError::ArithmeticOverflow)?;
166 if delta < min_increase {
167 return Err(ProgramError::InsufficientFunds);
168 }
169 Ok(())
170 }
171
172 /// Verify the balance decreased by at most `max_decrease`.
173 #[inline]
174 pub fn verify_decreased_by_at_most(
175 &self,
176 account: &AccountView<'_>,
177 max_decrease: u64,
178 ) -> ProgramResult {
179 let current = account.lamports();
180 let delta = self
181 .before
182 .checked_sub(current)
183 .ok_or(ProgramError::ArithmeticOverflow)?;
184 if delta > max_decrease {
185 return Err(ProgramError::InsufficientFunds);
186 }
187 Ok(())
188 }
189
190 /// Verify the balance is unchanged.
191 #[inline]
192 pub fn verify_unchanged(&self, account: &AccountView<'_>) -> ProgramResult {
193 if account.lamports() != self.before {
194 return Err(ProgramError::InvalidAccountData);
195 }
196 Ok(())
197 }
198
199 /// Get the captured balance.
200 #[inline(always)]
201 pub fn before(&self) -> u64 {
202 self.before
203 }
204
205 /// Compute the net change (positive = gained, negative = lost).
206 ///
207 /// Returns the change as an i128 to avoid overflow.
208 #[inline(always)]
209 pub fn net_change(&self, account: &AccountView<'_>) -> i128 {
210 account.lamports() as i128 - self.before as i128
211 }
212}
213
214// ---- Data integrity snapshot -----------------------------------------
215
216/// Fast integrity check for account data using FNV-1a hash.
217///
218/// Use this to detect unexpected data mutations around CPI calls.
219/// Not cryptographically secure -- purely for integrity assertions.
220#[derive(Clone, Copy, Debug)]
221pub struct DataFingerprint {
222 hash: u64,
223 data_len: usize,
224}
225
226impl DataFingerprint {
227 /// Compute a fast fingerprint of the first `len` bytes of account data.
228 ///
229 /// Uses FNV-1a (fast, no dependencies, good collision resistance for
230 /// short inputs). Not suitable for cryptographic purposes.
231 #[inline]
232 pub fn capture(account: &AccountView<'_>, len: usize) -> Self {
233 let data_len = account.data_len().min(len);
234 let data_ptr = account.data_ptr_unchecked();
235
236 // FNV-1a hash.
237 let mut hash: u64 = 0xcbf29ce484222325;
238 let mut i = 0;
239 while i < data_len {
240 // 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.
241 let byte = unsafe { *data_ptr.add(i) };
242 hash ^= byte as u64;
243 hash = hash.wrapping_mul(0x100000001b3);
244 i += 1;
245 }
246
247 Self { hash, data_len }
248 }
249
250 /// Verify the data has not changed since the snapshot.
251 #[inline]
252 pub fn verify_unchanged(&self, account: &AccountView<'_>) -> ProgramResult {
253 let current = Self::capture(account, self.data_len);
254 if current.hash != self.hash || current.data_len != self.data_len {
255 return Err(ProgramError::InvalidAccountData);
256 }
257 Ok(())
258 }
259
260 /// Get the fingerprint hash.
261 #[inline(always)]
262 pub fn hash(&self) -> u64 {
263 self.hash
264 }
265}