Skip to main content

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}