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