hopper_runtime/lamports.rs
1//! Gate-aware lamport movement.
2//!
3//! [`transfer_lamports`] is the checked transfer helper for programs whose
4//! contexts declare `strict_writes` + `lamports(...)`. It follows the checked
5//! arithmetic ordering of the substrate helper
6//! (`hopper_native::batch::transfer_lamports`, with insufficient-funds
7//! checked before overflow, all-or-nothing), but every balance write
8//! crosses the runtime's lamport funnel
9//! ([`native_boundary::try_set_lamports`](crate::native_boundary::try_set_lamports)),
10//! so an installed lamport gate ([`write_policy`](crate::write_policy)) sees
11//! the move. The substrate helper writes balances
12//! directly at the native layer and bypasses the gate by design (it is
13//! the cheap no-CPI path); this module closes that gap for gated
14//! programs without force-routing anyone else.
15
16use crate::account::AccountView;
17use crate::address::address_eq;
18use crate::error::ProgramError;
19use crate::ProgramResult;
20
21/// Transfer `amount` lamports between two accounts without CPI, through
22/// the runtime's **gated** lamport funnel.
23///
24/// This is the lamport transfer for `strict_writes` + `lamports(...)`
25/// programs under the mutation-complete contract: both sides are
26/// checked against the installed lamport gate **before any balance
27/// changes**, so a refusal, `Custom(0xD000 | account_index)` on the
28/// first undeclared side, can never half-apply the move. On a
29/// `lamports(...)` bound context the generated
30/// `ctx.transfer_lamports(from, to, amount)` method delegates here.
31///
32/// # Semantics
33///
34/// Identical arithmetic to the substrate helper
35/// `hopper_native::batch::transfer_lamports`: insufficient funds is
36/// checked before credit overflow ([`ProgramError::InsufficientFunds`]
37/// wins when both would fail), both post-balances are computed before
38/// either is applied (an arithmetic refusal also cannot half-apply),
39/// and both accounts must be writable before balances change.
40///
41/// As in the substrate helper, a **self-transfer**
42/// (`from` and `to` carry the same address, i.e. the same underlying
43/// account) is handled explicitly as a balance-checked net zero,
44/// mirroring the host System-transfer emulation and the real System
45/// program. The caller must verify ownership and application authority
46/// to debit the source; this helper does not authenticate a user.
47///
48/// # Cost
49///
50/// When no gate is installed, the only work added over the substrate
51/// helper is the gate's existing
52/// no-gate fast path; the per-account address comparisons against the
53/// declared set happen only while a gate is actually installed.
54///
55/// # Errors
56///
57/// - `Custom(0xD000 | index)`, an installed lamport gate refuses
58/// `from` or `to` (checked in that order), before any mutation.
59/// - [`ProgramError::InsufficientFunds`], `from` holds fewer than
60/// `amount` lamports.
61/// - [`ProgramError::ArithmeticOverflow`], crediting `to` would
62/// overflow `u64`.
63/// - [`ProgramError::Immutable`], either account is read-only.
64#[inline]
65pub fn transfer_lamports(
66 from: &AccountView<'_>,
67 to: &AccountView<'_>,
68 amount: u64,
69) -> ProgramResult {
70 // Pre-validate both sides against the lamport gate before
71 // any balance mutation. Relying on the per-account `try_set_lamports`
72 // funnel alone would debit `from` and then have `to` refused at the
73 // funnel, destroying lamports on the error path, a transfer must be
74 // all-or-nothing. (Same pattern as the host System-transfer
75 // emulation in `cpi.rs`.)
76 crate::write_policy::check_lamport_mutation(from.address())?;
77 crate::write_policy::check_lamport_mutation(to.address())?;
78 from.require_writable()?;
79 to.require_writable()?;
80
81 // Self-transfer (same address = same underlying account): net zero.
82 // Handled explicitly because the compute-both-then-apply sequence
83 // below would otherwise credit from the pre-debit balance and mint
84 // `amount` out of thin air.
85 if address_eq(from.address(), to.address()) {
86 if from.lamports() < amount {
87 return Err(ProgramError::InsufficientFunds);
88 }
89 return Ok(());
90 }
91
92 // Compute both post-balances before applying either, so an
93 // arithmetic refusal (insufficient funds, overflow) also cannot
94 // half-apply the transfer. Check order matches the substrate
95 // helper: insufficient funds before credit overflow.
96 let debited = from
97 .lamports()
98 .checked_sub(amount)
99 .ok_or(ProgramError::InsufficientFunds)?;
100 let credited = to
101 .lamports()
102 .checked_add(amount)
103 .ok_or(ProgramError::ArithmeticOverflow)?;
104 from.try_set_lamports(debited)?;
105 to.try_set_lamports(credited)?;
106 Ok(())
107}
108
109// ── Tests ────────────────────────────────────────────────────────────
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::write_policy::{install_lamport_gate, write_policy_violation, WritePolicy};
115 use hopper_native::{
116 AccountView as NativeAccountView, Address as NativeAddress, RuntimeAccount, NOT_BORROWED,
117 };
118
119 fn make_backend(seed: u8, lamports: u64) -> (std::vec::Vec<u64>, NativeAccountView<'static>) {
120 let mut backing = std::vec![0u64; (RuntimeAccount::SIZE + 32).div_ceil(8)];
121 let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
122 // SAFETY: the test owns `backing`, writes one valid RuntimeAccount
123 // header, and keeps the buffer alive for the returned view.
124 unsafe {
125 raw.write(RuntimeAccount {
126 borrow_state: NOT_BORROWED,
127 is_signer: 1,
128 is_writable: 1,
129 executable: 0,
130 resize_delta: 0,
131 address: NativeAddress::new_from_array([seed; 32]),
132 owner: NativeAddress::new_from_array([2; 32]),
133 lamports,
134 data_len: 32,
135 });
136 }
137 // SAFETY: `raw` points at the RuntimeAccount just initialized above.
138 let backend = unsafe { NativeAccountView::new_unchecked(raw) };
139 (backing, backend)
140 }
141
142 fn make_account(seed: u8, lamports: u64) -> (std::vec::Vec<u64>, AccountView<'static>) {
143 let (backing, backend) = make_backend(seed, lamports);
144 (backing, AccountView::from_backend(backend))
145 }
146
147 // ── (b) Ungated: byte-identical to the substrate helper ─────────
148
149 /// Differential parity: with no gate installed, the runtime helper
150 /// and `hopper_native::batch::transfer_lamports` must produce the
151 /// same result and the same post-balances for every distinct-account
152 /// case, including the error ordering (insufficient funds wins over
153 /// overflow when both apply).
154 #[test]
155 fn ungated_behavior_matches_substrate_helper_exactly() {
156 // (from_balance, to_balance, amount)
157 let cases: [(u64, u64, u64); 6] = [
158 (100, 50, 30), // plain success
159 (100, 50, 100), // drain to exactly zero
160 (100, 50, 0), // zero amount is a no-op success
161 (100, 50, 150), // insufficient funds
162 (100, u64::MAX, 1), // credit overflow
163 (100, u64::MAX, 150), // both would fail: sub is checked first
164 ];
165
166 for (i, &(from_bal, to_bal, amount)) in cases.iter().enumerate() {
167 let seed = (10 + 4 * i) as u8;
168 let (_rf, runtime_from) = make_account(seed, from_bal);
169 let (_rt, runtime_to) = make_account(seed + 1, to_bal);
170 let (_nf, native_from) = make_backend(seed + 2, from_bal);
171 let (_nt, native_to) = make_backend(seed + 3, to_bal);
172
173 let ours = transfer_lamports(&runtime_from, &runtime_to, amount);
174 let theirs = hopper_native::batch::transfer_lamports(&native_from, &native_to, amount)
175 .map_err(ProgramError::from);
176
177 assert_eq!(ours, theirs, "case {i}: result diverged");
178 assert_eq!(
179 runtime_from.lamports(),
180 native_from.lamports(),
181 "case {i}: from balance diverged"
182 );
183 assert_eq!(
184 runtime_to.lamports(),
185 native_to.lamports(),
186 "case {i}: to balance diverged"
187 );
188 // A refusal must leave both sides untouched.
189 if ours.is_err() {
190 assert_eq!(runtime_from.lamports(), from_bal, "case {i}");
191 assert_eq!(runtime_to.lamports(), to_bal, "case {i}");
192 }
193 }
194 }
195
196 // ── (a) Gated ────────────────────────────────────────────────────
197
198 #[test]
199 fn gated_transfer_between_declared_accounts_moves_exact_balances() {
200 let (_b0, from) = make_account(40, 1_000);
201 let (_b1, to) = make_account(41, 250);
202 let accounts = [from, to];
203 static P: WritePolicy = WritePolicy::with_lamports(&[], &[0, 1]);
204 let _gate = install_lamport_gate(&accounts, &P);
205
206 transfer_lamports(&accounts[0], &accounts[1], 400).unwrap();
207 assert_eq!(accounts[0].lamports(), 600);
208 assert_eq!(accounts[1].lamports(), 650);
209 }
210
211 #[test]
212 fn gated_transfer_from_undeclared_account_is_refused_before_any_mutation() {
213 // `from` (index 0) is NOT in the declared set; `to` (index 1) is.
214 let (_b0, from) = make_account(42, 1_000);
215 let (_b1, to) = make_account(43, 250);
216 let accounts = [from, to];
217 static P: WritePolicy = WritePolicy::with_lamports(&[], &[1]);
218 let _gate = install_lamport_gate(&accounts, &P);
219
220 assert_eq!(
221 transfer_lamports(&accounts[0], &accounts[1], 400),
222 Err(write_policy_violation(0))
223 );
224 assert_eq!(accounts[0].lamports(), 1_000);
225 assert_eq!(accounts[1].lamports(), 250);
226 }
227
228 #[test]
229 fn gated_transfer_to_undeclared_account_is_refused_before_any_mutation() {
230 // `from` (index 0) is declared; `to` (index 1) is NOT. Without
231 // the both-sides pre-check the debit would land at the funnel
232 // and the credit be refused, destroying 400 lamports.
233 let (_b0, from) = make_account(44, 1_000);
234 let (_b1, to) = make_account(45, 250);
235 let accounts = [from, to];
236 static P: WritePolicy = WritePolicy::with_lamports(&[], &[0]);
237 let _gate = install_lamport_gate(&accounts, &P);
238
239 assert_eq!(
240 transfer_lamports(&accounts[0], &accounts[1], 400),
241 Err(write_policy_violation(1))
242 );
243 assert_eq!(accounts[0].lamports(), 1_000);
244 assert_eq!(accounts[1].lamports(), 250);
245 }
246
247 #[test]
248 fn gated_arithmetic_refusals_keep_indexed_gate_errors_out_of_the_way() {
249 // Both sides declared: the gate admits the move, and the
250 // arithmetic errors surface exactly as in the ungated path.
251 let (_b0, from) = make_account(46, 100);
252 let (_b1, to) = make_account(47, u64::MAX);
253 let accounts = [from, to];
254 static P: WritePolicy = WritePolicy::with_lamports(&[], &[0, 1]);
255 let _gate = install_lamport_gate(&accounts, &P);
256
257 assert_eq!(
258 transfer_lamports(&accounts[0], &accounts[1], 150),
259 Err(ProgramError::InsufficientFunds)
260 );
261 assert_eq!(
262 transfer_lamports(&accounts[0], &accounts[1], 1),
263 Err(ProgramError::ArithmeticOverflow)
264 );
265 assert_eq!(accounts[0].lamports(), 100);
266 assert_eq!(accounts[1].lamports(), u64::MAX);
267 }
268
269 // ── (c) Self-transfer ────────────────────────────────────────────
270
271 #[test]
272 fn ungated_self_transfer_is_balance_checked_net_zero() {
273 // Two views over the SAME underlying account (duplicate metas).
274 let (_b, a) = make_account(50, 500);
275 let alias = a.clone();
276
277 // Balance-covered: net zero, no minting (the substrate helper
278 // would set the balance to 500 + 200 here).
279 transfer_lamports(&a, &alias, 200).unwrap();
280 assert_eq!(a.lamports(), 500);
281
282 // Over-balance: refused, balance untouched.
283 assert_eq!(
284 transfer_lamports(&a, &alias, 501),
285 Err(ProgramError::InsufficientFunds)
286 );
287 assert_eq!(a.lamports(), 500);
288 }
289
290 #[test]
291 fn gated_self_transfer_follows_the_declared_set() {
292 // Declared: net zero succeeds under the gate.
293 let (_b0, declared) = make_account(51, 500);
294 let (_b1, foreign) = make_account(52, 500);
295 let accounts = [declared];
296 static P: WritePolicy = WritePolicy::with_lamports(&[], &[0]);
297 let _gate = install_lamport_gate(&accounts, &P);
298
299 let alias = accounts[0].clone();
300 transfer_lamports(&accounts[0], &alias, 200).unwrap();
301 assert_eq!(accounts[0].lamports(), 500);
302 assert_eq!(
303 transfer_lamports(&accounts[0], &alias, 501),
304 Err(ProgramError::InsufficientFunds)
305 );
306
307 // Undeclared (foreign to the gated slice): refused fail-closed
308 // even though the move would net zero, the gate is consulted
309 // before the self-transfer branch, mirroring the host
310 // System-transfer emulation.
311 let foreign_alias = foreign.clone();
312 assert_eq!(
313 transfer_lamports(&foreign, &foreign_alias, 1),
314 Err(write_policy_violation(u8::MAX))
315 );
316 assert_eq!(foreign.lamports(), 500);
317 }
318
319 #[test]
320 fn dropping_the_gate_restores_ungated_passthrough() {
321 let (_b0, from) = make_account(53, 1_000);
322 let (_b1, to) = make_account(54, 0);
323 let accounts = [from, to];
324 static P: WritePolicy = WritePolicy::with_lamports(&[], &[]);
325 {
326 let _gate = install_lamport_gate(&accounts, &P);
327 // Empty declared set: everything is refused while installed.
328 assert_eq!(
329 transfer_lamports(&accounts[0], &accounts[1], 1),
330 Err(write_policy_violation(0))
331 );
332 }
333 // Gate dropped: the same call goes through ungated.
334 transfer_lamports(&accounts[0], &accounts[1], 1).unwrap();
335 assert_eq!(accounts[0].lamports(), 999);
336 assert_eq!(accounts[1].lamports(), 1);
337 }
338
339 /// A realistic mutation-complete policy carries data ranges AND the
340 /// lamport set; the transfer consults only the lamport dimension.
341 // Guarded-tier semantics: installs a data-declaring policy, which the
342 // `unguarded-raw-surfaces` fence refuses at install (covered by its
343 // own explicit test in that shape).
344 #[test]
345 #[cfg(not(feature = "unguarded-raw-surfaces"))]
346 fn gated_transfer_composes_with_data_ranges() {
347 let (_b0, from) = make_account(55, 10);
348 let (_b1, to) = make_account(56, 10);
349 let accounts = [from, to];
350 static P: WritePolicy = WritePolicy::with_lamports(
351 &[crate::write_policy::WriteRange::whole_account(0)],
352 &[0, 1],
353 );
354 let _gate = install_lamport_gate(&accounts, &P);
355 transfer_lamports(&accounts[0], &accounts[1], 10).unwrap();
356 assert_eq!(accounts[0].lamports(), 0);
357 assert_eq!(accounts[1].lamports(), 20);
358 }
359}