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, 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}