From 3a9caed6b4fbb06d9dbcb5061634bbfd5d37358b Mon Sep 17 00:00:00 2001 From: Babur Makhmudov Date: Tue, 23 Jun 2026 18:13:30 +0400 Subject: [PATCH] feat: added internal built-in magic root program --- programs/magic-root-interface/Cargo.toml | 20 ++ programs/magic-root-interface/README.md | 12 + programs/magic-root-interface/src/lib.rs | 55 +++++ programs/magic-root-program/Cargo.toml | 26 +++ programs/magic-root-program/README.md | 25 ++ programs/magic-root-program/src/lib.rs | 161 +++++++++++++ programs/v42-calculator-interface/Cargo.toml | 22 ++ programs/v42-calculator-interface/README.md | 26 +++ .../v42-calculator-interface/src/builder.rs | 104 +++++++++ programs/v42-calculator-interface/src/lib.rs | 10 + .../v42-calculator-interface/src/opcodes.rs | 30 +++ programs/v42-calculator-program/Cargo.toml | 32 +++ programs/v42-calculator-program/README.md | 42 ++++ programs/v42-calculator-program/src/lib.rs | 218 ++++++++++++++++++ 14 files changed, 783 insertions(+) create mode 100644 programs/magic-root-interface/Cargo.toml create mode 100644 programs/magic-root-interface/README.md create mode 100644 programs/magic-root-interface/src/lib.rs create mode 100644 programs/magic-root-program/Cargo.toml create mode 100644 programs/magic-root-program/README.md create mode 100644 programs/magic-root-program/src/lib.rs create mode 100644 programs/v42-calculator-interface/Cargo.toml create mode 100644 programs/v42-calculator-interface/README.md create mode 100644 programs/v42-calculator-interface/src/builder.rs create mode 100644 programs/v42-calculator-interface/src/lib.rs create mode 100644 programs/v42-calculator-interface/src/opcodes.rs create mode 100644 programs/v42-calculator-program/Cargo.toml create mode 100644 programs/v42-calculator-program/README.md create mode 100644 programs/v42-calculator-program/src/lib.rs diff --git a/programs/magic-root-interface/Cargo.toml b/programs/magic-root-interface/Cargo.toml new file mode 100644 index 0000000..f94677b --- /dev/null +++ b/programs/magic-root-interface/Cargo.toml @@ -0,0 +1,20 @@ +[package] +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +name = "magic-root-interface" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +wincode = { workspace = true } + +solana-account = { workspace = true, features = ["wincode"] } +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } + + +[lints] +workspace = true diff --git a/programs/magic-root-interface/README.md b/programs/magic-root-interface/README.md new file mode 100644 index 0000000..6119e46 --- /dev/null +++ b/programs/magic-root-interface/README.md @@ -0,0 +1,12 @@ +# `magic-root-interface` + +A caller that wants to build a MagicRoot instruction shouldn't have to depend on +the program that *executes* it. This crate is the seam that makes that possible: +it holds the instruction schema (`MagicRootInstruction`) shared between +`magic-root-program` and `engine`, so the builder side and the execution side +agree on the wire format without the builder pulling in execution logic. + +`compose` assembles an instruction: it prepends the target-account meta and +serializes the instruction data. Only `PostFinalize` adds anything beyond that — +it contributes extra metas for each follow-up (the follow-up's program id and its +accounts, with their signer bits cleared). diff --git a/programs/magic-root-interface/src/lib.rs b/programs/magic-root-interface/src/lib.rs new file mode 100644 index 0000000..29ba1d1 --- /dev/null +++ b/programs/magic-root-interface/src/lib.rs @@ -0,0 +1,55 @@ +#![doc = include_str!("../README.md")] + +use solana_account::AccountFieldPatch; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::{Pubkey, declare_id}; +use wincode::{SchemaRead, SchemaWrite}; + +declare_id!("MagicRootDRJ5atQjSJUxFjXzjeZXMADHUDznbk22gy"); + +/// Instructions accepted by the MagicRoot built-in program. +#[derive(SchemaRead, SchemaWrite)] +pub enum MagicRootInstruction { + /// Apply a single-field patch to the target account. + Patch(AccountFieldPatch), + /// Finalize the target account: drain its lamports back to the payer and, + /// if it is executable, load it into the transaction's program cache. + Finalize, + /// Close the target account. + Delete, + /// Run follow-up instructions once the account is finalized (e.g. + /// initializing a freshly created account); each is invoked via CPI against + /// the accounts it declares. + PostFinalize(Vec), +} + +impl MagicRootInstruction { + /// Composes this variant into an [`Instruction`] targeting the MagicRoot + /// program: prepends the target `account` meta, appends any metas the + /// variant requires, and serializes the instruction data. + pub fn compose(&self, account: Pubkey) -> wincode::Result { + let mut accounts = vec![AccountMeta::new(account, false)]; + self.extend_metas(&mut accounts); + let data = wincode::serialize(self)?; + Ok(Instruction { program_id: ID, accounts, data }) + } + + /// Appends the extra account metas a variant requires. Only [`PostFinalize`] + /// contributes any: the program id and de-signed accounts of each follow-up + /// instruction. + /// + /// [`PostFinalize`]: MagicRootInstruction::PostFinalize + fn extend_metas(&self, accounts: &mut Vec) { + let Self::PostFinalize(ixs) = self else { + return; + }; + for ix in ixs { + accounts.push(AccountMeta::new_readonly(ix.program_id, false)); + let metas = ix.accounts.clone().into_iter().map(|mut meta| { + meta.is_signer = false; + meta + }); + accounts.extend(metas) + } + } +} diff --git a/programs/magic-root-program/Cargo.toml b/programs/magic-root-program/Cargo.toml new file mode 100644 index 0000000..8323af8 --- /dev/null +++ b/programs/magic-root-program/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "magic-root-program" + +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +magic-root-interface = { workspace = true } + +nucleus = { workspace = true, features = ["tls"] } +wincode = { workspace = true } + +solana-account = { workspace = true } +solana-instruction = { workspace = true } +solana-instruction-error = { workspace = true } +solana-program-runtime = { workspace = true } +solana-svm-log-collector = { workspace = true } +solana-transaction-context = { workspace = true } + +[lints] +workspace = true diff --git a/programs/magic-root-program/README.md b/programs/magic-root-program/README.md new file mode 100644 index 0000000..fc604b0 --- /dev/null +++ b/programs/magic-root-program/README.md @@ -0,0 +1,25 @@ +# `magic-root-program` + +Some account changes are ones no ordinary on-chain program is ever allowed to +make: patching arbitrary account fields, finalizing, closing an account outright. +The engine still needs to make them, so it has a built-in program that *can* — +this one. It's the privileged escape hatch, wired in as `MagicRootEntrypoint`, +with its instruction schema defined in `nucleus::runtime` and its execution logic +here. + +Because it's unrestricted, it has to be locked down by authority instead of by +capability. Every instruction must be **top-level** (no CPI can reach it) and +**signed by `AUTHORITY`**, and both checks run *before any account is touched* — +so an unauthorized call can't have side effects on its way to being rejected. + +## Instructions + +- `Patch` — writes a single account field. Patching lamports settles the delta + against the authority (the payer) so total supply stays conserved and + re-patching the same balance is a no-op — an in-place `update` therefore does + not drain the authority again. +- `Finalize` — loads executable targets into the transaction's program cache. + Balances are already settled at patch time, so it no longer touches lamports. +- `Delete` — sets the target to `AccountMode::Closed`. +- `PostFinalize` — CPIs into follow-up instructions, after rejecting any account + that is immutable but marked writable. diff --git a/programs/magic-root-program/src/lib.rs b/programs/magic-root-program/src/lib.rs new file mode 100644 index 0000000..056fc21 --- /dev/null +++ b/programs/magic-root-program/src/lib.rs @@ -0,0 +1,161 @@ +#![doc = include_str!("../README.md")] + +use magic_root_interface::MagicRootInstruction; +use nucleus::tls::AUTHORITY; +use solana_account::{AccountFieldPatch, AccountMode, ReadableAccount, WritableAccount}; +use solana_instruction::{Instruction, TRANSACTION_LEVEL_STACK_HEIGHT}; +use solana_instruction_error::InstructionError; +use solana_program_runtime::{invoke_context::InvokeContext, loaded_programs::ProgramCacheEntry}; +use solana_svm_log_collector::ic_msg; +use solana_transaction_context::IndexOfAccount; + +/// Instruction-account index of the account a MagicRoot instruction operates on. +pub const TARGET_ACCOUNT_IDX: u16 = 0; + +/// Executes a MagicRoot instruction: authorizes the caller, decodes the +/// instruction, then applies it to the target account. +pub fn process(ctx: &mut InvokeContext<'_, '_>) -> Result<(), InstructionError> { + authorize(ctx)?; + let ix = decode(ctx)?; + apply(ctx, ix) +} + +/// Rejects CPI invocations and callers other than the configured [`AUTHORITY`]. +/// +/// MagicRoot is engine-internal: it must run as a top-level instruction signed +/// by the authority, both checked before any account is touched. +fn authorize(ctx: &InvokeContext<'_, '_>) -> Result<(), InstructionError> { + let height = ctx.get_stack_height(); + if height != TRANSACTION_LEVEL_STACK_HEIGHT { + ic_msg!(ctx, "MagicRoot: CPI denied (height {})", height); + return Err(InstructionError::CallDepth); + } + let signer = *ctx.transaction_context.get_key_of_account_at_index(0)?; + if signer != AUTHORITY.get() { + ic_msg!(ctx, "MagicRoot: unauthorized signer {}", signer); + return Err(InstructionError::MissingRequiredSignature); + } + Ok(()) +} + +/// Decodes the current instruction's data into a [`MagicRootInstruction`]. +fn decode(ctx: &InvokeContext<'_, '_>) -> Result { + let ixctx = ctx.transaction_context.get_current_instruction_context()?; + let data = ixctx.get_instruction_data(); + wincode::deserialize(data).map_err(|_| { + ic_msg!(ctx, "MagicRoot: malformed instruction data"); + InstructionError::InvalidInstructionData + }) +} + +/// Resolves the target account index and dispatches to the matching handler. +fn apply( + ctx: &mut InvokeContext<'_, '_>, + ix: MagicRootInstruction, +) -> Result<(), InstructionError> { + let ixctx = ctx.transaction_context.get_current_instruction_context()?; + let idx = ixctx.get_index_of_instruction_account_in_transaction(TARGET_ACCOUNT_IDX)?; + match ix { + MagicRootInstruction::Patch(patch) => patch_account(ctx, idx, patch), + MagicRootInstruction::Finalize => finalize_account(ctx, idx), + MagicRootInstruction::Delete => delete_account(ctx, idx), + MagicRootInstruction::PostFinalize(actions) => post_finalize(ctx, actions), + } +} + +/// Applies a single-field patch to the target account. +fn patch_account( + ctx: &InvokeContext<'_, '_>, + idx: IndexOfAccount, + patch: AccountFieldPatch, +) -> Result<(), InstructionError> { + let mut account = ctx.transaction_context.accounts().try_borrow_mut(idx)?; + ic_msg!(ctx, "MagicRoot: patch {:?}", patch); + let old = account.lamports(); + patch.apply(&mut account); + let new = account.lamports(); + let mut authority = ctx.transaction_context.accounts().try_borrow_mut(0)?; + if new > old { + authority.checked_sub_lamports(new - old)?; + } else if new < old { + authority.checked_add_lamports(old - new)?; + } + Ok(()) +} + +/// Loads an executable target into the transaction's program cache. +fn finalize_account( + ctx: &mut InvokeContext<'_, '_>, + idx: IndexOfAccount, +) -> Result<(), InstructionError> { + let account = ctx.transaction_context.accounts().try_borrow(idx)?; + if !account.executable() { + return Ok(()); + } + let pubkey = *ctx.transaction_context.get_key_of_account_at_index(idx)?; + let entry = ProgramCacheEntry::new( + ctx.environment_config + .program_runtime_environments_for_execution + .get_env_for_execution() + .clone(), + account.data(), + ) + .map_err(|_| { + ic_msg!(ctx, "MagicRoot: program load failed {}", pubkey); + InstructionError::ProgramEnvironmentSetupFailure + })? + .into(); + ctx.program_cache_for_tx_batch.store_modified_entry(pubkey, entry); + ic_msg!(ctx, "MagicRoot: finalized program load"); + Ok(()) +} + +/// Marks the target account closed for removal from storage. +fn delete_account( + ctx: &InvokeContext<'_, '_>, + idx: IndexOfAccount, +) -> Result<(), InstructionError> { + ctx.transaction_context + .accounts() + .try_borrow_mut(idx)? + .set_mode(AccountMode::Closed); + ic_msg!(ctx, "MagicRoot: removed"); + Ok(()) +} + +/// Runs the post-finalize follow-up instructions: rejects any writable +/// instruction account that is not mutable, then invokes each action via CPI, +/// forwarding the signers declared across the actions. +fn post_finalize( + ctx: &mut InvokeContext<'_, '_>, + actions: Vec, +) -> Result<(), InstructionError> { + ic_msg!(ctx, "MagicRoot: post-finalize {} action(s)", actions.len()); + let ixctx = ctx.transaction_context.get_current_instruction_context()?; + let count = ixctx.get_number_of_instruction_accounts(); + for i in 0..count { + let idx = ixctx.get_index_of_instruction_account_in_transaction(i)?; + let acc = ctx.transaction_context.accounts().try_borrow(idx)?; + if ixctx.is_instruction_account_writable(i)? && !acc.mutable() { + ic_msg!( + ctx, + "MagicRoot: post-finalize rejected immutable writable account" + ); + return Err(InstructionError::Immutable); + } + } + let signers: Vec<_> = actions + .iter() + .flat_map(|ix| ix.accounts.iter().filter_map(|m| m.is_signer.then_some(m.pubkey))) + .collect(); + for action in actions { + ctx.native_invoke(action, &signers)?; + } + Ok(()) +} + +#[allow(missing_docs)] +pub mod entrypoint { + use solana_program_runtime::declare_process_instruction; + declare_process_instruction!(MagicRootEntrypoint, 150, |ctx| { super::process(ctx) }); +} diff --git a/programs/v42-calculator-interface/Cargo.toml b/programs/v42-calculator-interface/Cargo.toml new file mode 100644 index 0000000..c0ffd3c --- /dev/null +++ b/programs/v42-calculator-interface/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "v42-calculator-interface" + +authors.workspace = true +# Compiled to BPF as the program's opcode dependency, so it stays within the +# SBF toolchain's rustc: edition 2021 and no elevated workspace MSRV. +edition = "2021" +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +solana-instruction = { workspace = true, optional = true } +solana-pubkey = { workspace = true } + +[features] +builder = ["dep:solana-instruction"] +default = ["builder"] + +[lints] +workspace = true diff --git a/programs/v42-calculator-interface/README.md b/programs/v42-calculator-interface/README.md new file mode 100644 index 0000000..4e03c14 --- /dev/null +++ b/programs/v42-calculator-interface/README.md @@ -0,0 +1,26 @@ +# `v42-calculator-interface` + +Client side of the `v42-calculator` test program: the program id, the RPN wire +format (`opcodes`), and [`Expr`](crate::builder::Expr) — a small builder for +turning ordinary arithmetic into calculator instructions. A test that wants to +*generate* a transaction depends only on this crate; it never pulls in the +on-chain program. + +The calculator evaluates a postfix (RPN) byte stream against a `u64` stack. +`Expr` hides that: leaves are literals (`lit`), account operands (`acc`), and +the `Clock` sysvar (`clock`); the `+ - * /` operators combine subexpressions, and +`.cpi()` marks a subexpression to be evaluated by a recursive self-CPI instead +of inline — useful for exercising CPI depth and the return-data path. Because +the builder keeps each subexpression already in postfix order, composition is +pure concatenation with no intermediate tree. + +`compose` assembles the instruction: account 0 is the writable output that +receives the final result (LE `u64`); the read-only operands follow and are +addressed by `Expr::acc` (starting at index 1). The calculator program id is +appended as a final read-only account so recursive CPI has its callee account in +scope without shifting operand indices. + +The opcode table lives in `opcodes` and is the single source of truth for the +wire format — the on-chain program depends on this crate with the `builder` +feature off, taking only those constants. Composing transactions needs the +`builder` feature (on by default). diff --git a/programs/v42-calculator-interface/src/builder.rs b/programs/v42-calculator-interface/src/builder.rs new file mode 100644 index 0000000..6969e7f --- /dev/null +++ b/programs/v42-calculator-interface/src/builder.rs @@ -0,0 +1,104 @@ +//! Off-chain expression builder. + +use core::ops::{Add, Div, Mul, Sub}; + +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +use crate::opcodes::*; +use crate::ID; + +/// A calculator expression, built the way you'd write the math and lowered to +/// the postfix byte stream the program evaluates. Construct leaves with the +/// associated functions, combine them with `+`, `-`, `*`, `/`, and wrap any +/// subtree in [`Expr::cpi`] to force it through a recursive self-CPI (its result +/// then travels back through return data instead of being computed inline). +/// +/// ``` +/// use solana_pubkey::Pubkey; +/// use v42_calculator_interface::builder::Expr as E; +/// +/// // (42 + (31 * 4) - 56) * 2, with the product evaluated via CPI. +/// let program = (E::lit(42) + (E::lit(31) * E::lit(4)).cpi() - E::lit(56)) * E::lit(2); +/// let ix = program.compose(Pubkey::default(), &[]); +/// assert_eq!(ix.program_id, v42_calculator_interface::ID); +/// ``` +/// +/// The wrapped bytes are already in postfix order, so combining expressions is +/// just concatenation — there is no intermediate tree to walk. +#[derive(Clone, Debug)] +pub struct Expr(Vec); + +impl Expr { + /// An immediate literal operand. + pub fn lit(value: u64) -> Self { + let mut bytes = Vec::with_capacity(9); + bytes.push(PUSH_LIT); + bytes.extend_from_slice(&value.to_le_bytes()); + Self(bytes) + } + + /// The `u64` LE stored in instruction account `index`. Account 0 is the + /// output account; the operands passed to [`Expr::compose`] start at 1. + pub fn acc(index: u8) -> Self { + Self(vec![PUSH_ACC, index]) + } + + /// The current clock unix timestamp. + pub fn clock() -> Self { + Self(vec![PUSH_CLOCK]) + } + + /// Evaluate this subexpression through a recursive self-CPI rather than + /// inline. All instruction accounts are forwarded to the callee, so + /// [`Expr::acc`] indices are unchanged at any depth. + pub fn cpi(self) -> Self { + let mut bytes = Vec::with_capacity(3 + self.0.len()); + bytes.push(CALL); + bytes.extend_from_slice(&(self.0.len() as u16).to_le_bytes()); + bytes.extend_from_slice(&self.0); + Self(bytes) + } + + /// The raw postfix byte stream — i.e. the instruction data. + pub fn program(&self) -> Vec { + self.0.clone() + } + + /// Build the instruction: account 0 is the writable `output` the final + /// result is written to, followed by the read-only `operands` referenced by + /// [`Expr::acc`], followed by this program id for recursive CPI. + pub fn compose(&self, output: Pubkey, operands: &[Pubkey]) -> Instruction { + let mut accounts = Vec::with_capacity(2 + operands.len()); + accounts.push(AccountMeta::new(output, false)); + accounts.extend(operands.iter().map(|key| AccountMeta::new_readonly(*key, false))); + accounts.push(AccountMeta::new_readonly(ID, false)); + Instruction { + program_id: ID, + accounts, + data: self.0.clone(), + } + } + + fn binary(mut self, rhs: Expr, op: u8) -> Self { + self.0.extend_from_slice(&rhs.0); + self.0.push(op); + self + } +} + +macro_rules! bin_op { + ($trait:ident, $method:ident, $op:expr) => { + impl $trait for Expr { + type Output = Expr; + fn $method(self, rhs: Expr) -> Expr { + self.binary(rhs, $op) + } + } + }; +} + +bin_op!(Add, add, ADD); +bin_op!(Sub, sub, SUB); +bin_op!(Mul, mul, MUL); +bin_op!(Div, div, DIV); diff --git a/programs/v42-calculator-interface/src/lib.rs b/programs/v42-calculator-interface/src/lib.rs new file mode 100644 index 0000000..87a4d2d --- /dev/null +++ b/programs/v42-calculator-interface/src/lib.rs @@ -0,0 +1,10 @@ +#![doc = include_str!("../README.md")] + +use solana_pubkey::declare_id; + +pub mod opcodes; + +#[cfg(feature = "builder")] +pub mod builder; + +declare_id!("V42CaLcu1atormagicb1ock11111111111111111111"); diff --git a/programs/v42-calculator-interface/src/opcodes.rs b/programs/v42-calculator-interface/src/opcodes.rs new file mode 100644 index 0000000..fd22e26 --- /dev/null +++ b/programs/v42-calculator-interface/src/opcodes.rs @@ -0,0 +1,30 @@ +//! Opcodes for the v42-calculator RPN byte stream — the single source of truth +//! for the wire format, shared by the off-chain `Expr` builder and the on-chain +//! evaluator so the two can never drift. Adding an operation is one constant +//! here plus one match arm in the program. +//! +//! A program is a flat sequence of tokens evaluated left-to-right against a +//! `u64` stack: `PUSH_*` tokens push one value, the arithmetic tokens pop two +//! and push one, and `CALL` evaluates a nested program through a self-CPI and +//! pushes its result. Exactly one value must remain when the stream ends. + +/// Push an immediate `u64`; 8 little-endian bytes follow. +pub const PUSH_LIT: u8 = 0x00; +/// Push the `u64` LE held in the first 8 data bytes of an instruction account; +/// a 1-byte account index follows. +pub const PUSH_ACC: u8 = 0x01; +/// Push the current clock unix timestamp, reinterpreted as `u64`. +pub const PUSH_CLOCK: u8 = 0x03; + +/// Pop `b`, pop `a`, push `a + b` (checked). +pub const ADD: u8 = 0x10; +/// Pop `b`, pop `a`, push `a - b` (checked). +pub const SUB: u8 = 0x11; +/// Pop `b`, pop `a`, push `a * b` (checked). +pub const MUL: u8 = 0x12; +/// Pop `b`, pop `a`, push `a / b` (`b == 0` is an error). +pub const DIV: u8 = 0x13; + +/// Evaluate a nested program via self-CPI and push its return-data `u64`. A +/// `u16` LE byte length follows, then that many bytes of nested program. +pub const CALL: u8 = 0x20; diff --git a/programs/v42-calculator-program/Cargo.toml b/programs/v42-calculator-program/Cargo.toml new file mode 100644 index 0000000..3f97941 --- /dev/null +++ b/programs/v42-calculator-program/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "v42-calculator-program" + +authors.workspace = true +edition = "2021" +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +v42-calculator-interface = { workspace = true } + +solana-account-info = { workspace = true } +solana-cpi = { workspace = true } +solana-instruction = { workspace = true, features = ["syscalls"] } +solana-msg = { workspace = true } +solana-program-entrypoint = { workspace = true } +solana-program-error = { workspace = true } +solana-pubkey = { workspace = true } +solana-sysvar = { workspace = true, features = ["bytemuck"] } + +# Compiled to BPF with `cargo build-sbf`; the `rlib` lets it also build on the +# host so the workspace's `cargo build`/`clippy` stay green. +[lib] +crate-type = ["cdylib", "rlib"] + +[lints.rust] +# The `entrypoint!` macro emits `target_os = "solana"` and `custom-heap`/ +# `custom-panic` feature gates that only exist under the SBF toolchain; silence +# the noise they produce on host builds. +unexpected_cfgs = { level = "allow" } diff --git a/programs/v42-calculator-program/README.md b/programs/v42-calculator-program/README.md new file mode 100644 index 0000000..a2dc161 --- /dev/null +++ b/programs/v42-calculator-program/README.md @@ -0,0 +1,42 @@ +# `v42-calculator` + +A tiny on-chain BPF program that exists to *generate transactions* for engine +tests. It evaluates arithmetic expressions and, in doing so, drives the paths a +test engine needs to exercise: account-data operands, the `Clock` sysvar, +recursive CPI, and return data. + +It is compiled to BPF against the upstream minimal program sdk (its `edition` +stays at 2021 and it deliberately avoids the vendored host-side `solana` forks so +it stays SBF-toolchain compatible). It shares the opcode table with +`v42-calculator-interface`, depending on it with the `builder` feature off so +only the constants come along. Build the loadable image with `cargo build-sbf` +(output lands in the workspace `target/deploy/`); the `.so` is built on demand +and is never checked into the tree. + +## Model + +Instruction data is a postfix (RPN) token stream, evaluated left-to-right against +a `u64` stack (see the shared `opcodes` table in `v42-calculator-interface`). +Values are pushed by literals, account data (`u64` LE, read in place), or the +`Clock` sysvar; the four arithmetic tokens are checked (overflow and +divide-by-zero are errors); and `CALL` evaluates a nested program by invoking +this same program, forwarding all accounts so `PUSH_ACC` indices stay stable at +any depth. + +**Result routing** is the one invariant worth stating: the top-level invocation +writes the final value (LE `u64`) into account 0, while a nested (CPI) +invocation returns its value through return data instead. The two are told apart +by the stack height, and a nested call emits its return data only after its own +child `CALL`s finish — otherwise their CPIs would have cleared it. + +Failures are returned as `ProgramError::Custom(code)` with stable codes (see +`CalcError`), so error paths are assertable too. + +## Logging + +Being a test fixture, the program emits an always-on trace (via `solana-msg`) that +tests can assert against. Every line is prefixed `v42:` — entry (stack height + +byte length), each CPI, sysvar reads, and the failing `CalcError` variant. The one +line worth relying on is the result: `result= -> account0` at the top level +versus `result= -> return_data` under CPI, which mirrors the result-routing +invariant above. diff --git a/programs/v42-calculator-program/src/lib.rs b/programs/v42-calculator-program/src/lib.rs new file mode 100644 index 0000000..1907b53 --- /dev/null +++ b/programs/v42-calculator-program/src/lib.rs @@ -0,0 +1,218 @@ +#![doc = include_str!("../README.md")] + +use solana_account_info::AccountInfo; +use solana_cpi::{get_return_data, invoke, set_return_data}; +use solana_instruction::syscalls::get_stack_height; +use solana_instruction::{AccountMeta, Instruction, TRANSACTION_LEVEL_STACK_HEIGHT}; +use solana_msg::msg; +use solana_program_entrypoint::entrypoint; +use solana_program_error::{ProgramError, ProgramResult}; +use solana_pubkey::Pubkey; +use solana_sysvar::clock::Clock; +use solana_sysvar::Sysvar; +use v42_calculator_interface::{opcodes::*, ID}; + +entrypoint!(process_instruction); + +/// Evaluates the RPN program in `data` and routes the result: a **top-level** +/// invocation writes it (LE `u64`) into the output account; a **CPI** +/// invocation returns it through return data. The two are told apart by the +/// stack height. +/// +/// A nested call must publish its result *after* its own child `CALL`s complete, +/// because every CPI clears the return-data register — which is exactly what +/// happens here, since the result is emitted only once evaluation is done. +fn process_instruction(_: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult { + let height = get_stack_height(); + msg!("v42: enter height={} len={}", height, data.len()); + let result = eval(accounts, data)?; + if height == TRANSACTION_LEVEL_STACK_HEIGHT { + msg!("v42: result={} -> account0", result); + let output = accounts.first().ok_or(CalcError::MissingOutput)?; + let mut bytes = output.try_borrow_mut_data()?; + bytes + .get_mut(..8) + .ok_or(CalcError::ShortAccount)? + .copy_from_slice(&result.to_le_bytes()); + } else { + msg!("v42: result={} -> return_data", result); + set_return_data(&result.to_le_bytes()); + } + Ok(()) +} + +/// Runs the token stream against an operand stack and returns the single value +/// left at the end. +fn eval(accounts: &[AccountInfo], data: &[u8]) -> Result { + let mut cursor = Cursor(data); + let mut stack = Stack::new(); + while !cursor.is_empty() { + match cursor.u8()? { + PUSH_LIT => stack.push(cursor.u64()?)?, + PUSH_ACC => stack.push(account_u64(accounts, cursor.u8()?)?)?, + PUSH_CLOCK => stack.push(clock_ts()?)?, + op @ (ADD | SUB | MUL | DIV) => { + let b = stack.pop()?; + let a = stack.pop()?; + stack.push(arithmetic(op, a, b)?)?; + } + CALL => { + let len = cursor.u16()? as usize; + let nested = cursor.take(len)?; + stack.push(call(accounts, nested)?)?; + } + _ => return Err(CalcError::BadOpcode.into()), + } + } + stack.into_result().map_err(Into::into) +} + +fn arithmetic(op: u8, a: u64, b: u64) -> Result { + match op { + ADD => a.checked_add(b).ok_or(CalcError::Arithmetic), + SUB => a.checked_sub(b).ok_or(CalcError::Arithmetic), + MUL => a.checked_mul(b).ok_or(CalcError::Arithmetic), + DIV => (b != 0).then(|| a / b).ok_or(CalcError::DivByZero), + _ => Err(CalcError::BadOpcode), + } +} + +/// Decodes the `u64` LE stored in the first 8 bytes of `bytes`, reporting `short` +/// if there aren't that many. +fn read_head_u64(bytes: &[u8], short: CalcError) -> Result { + let head = bytes.get(..8).ok_or(short)?; + Ok(u64::from_le_bytes(head.try_into().expect("length checked"))) +} + +/// Reads the `u64` LE operand from the first 8 data bytes of an instruction +/// account (borrowed, not copied). +fn account_u64(accounts: &[AccountInfo], index: u8) -> Result { + let account = accounts.get(index as usize).ok_or(CalcError::BadAccountIndex)?; + let data = account.try_borrow_data()?; + Ok(read_head_u64(&data, CalcError::ShortAccount)?) +} + +fn clock_ts() -> Result { + let ts = Clock::get()?.unix_timestamp as u64; + msg!("v42: clock.ts={}", ts); + Ok(ts) +} + +/// Evaluates `nested` by invoking this same program, forwarding every account +/// unchanged so `PUSH_ACC` indices are identical at every recursion depth, and +/// returns the callee's return-data `u64`. +fn call(accounts: &[AccountInfo], nested: &[u8]) -> Result { + let metas = accounts + .iter() + .map(|a| AccountMeta { + pubkey: *a.key, + is_signer: a.is_signer, + is_writable: a.is_writable, + }) + .collect(); + let instruction = Instruction { + program_id: ID, + accounts: metas, + data: nested.to_vec(), + }; + msg!("v42: cpi len={}", nested.len()); + invoke(&instruction, accounts)?; + + let (_, bytes) = get_return_data().ok_or(CalcError::MissingReturnData)?; + Ok(read_head_u64(&bytes, CalcError::ShortReturnData)?) +} + +/// A forward cursor over the instruction byte stream. Every read is bounds- +/// checked and advances the cursor; operands are read in place, never copied. +struct Cursor<'a>(&'a [u8]); + +impl<'a> Cursor<'a> { + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn u8(&mut self) -> Result { + let (&byte, rest) = self.0.split_first().ok_or(CalcError::Truncated)?; + self.0 = rest; + Ok(byte) + } + + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.array()?)) + } + + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.array()?)) + } + + fn array(&mut self) -> Result<[u8; N], CalcError> { + Ok(self.take(N)?.try_into().expect("length checked")) + } + + fn take(&mut self, len: usize) -> Result<&'a [u8], CalcError> { + if self.0.len() < len { + return Err(CalcError::Truncated); + } + let (head, rest) = self.0.split_at(len); + self.0 = rest; + Ok(head) + } +} + +/// Fixed-capacity operand stack — deep enough for any expression a test would +/// build, and allocation-free. +struct Stack { + slots: [u64; 64], + len: usize, +} + +impl Stack { + fn new() -> Self { + Self { slots: [0; 64], len: 0 } + } + + fn push(&mut self, value: u64) -> Result<(), CalcError> { + *self.slots.get_mut(self.len).ok_or(CalcError::StackOverflow)? = value; + self.len += 1; + Ok(()) + } + + fn pop(&mut self) -> Result { + self.len = self.len.checked_sub(1).ok_or(CalcError::StackUnderflow)?; + Ok(self.slots[self.len]) + } + + /// The single value a well-formed program leaves behind. + fn into_result(self) -> Result { + match self.len { + 1 => Ok(self.slots[0]), + _ => Err(CalcError::UnbalancedProgram), + } + } +} + +/// Failure modes, surfaced as `ProgramError::Custom(code)`. Discriminants are +/// stable so tests can assert on a specific failure. +#[derive(Debug)] +#[repr(u32)] +enum CalcError { + Truncated = 1, + BadOpcode, + StackOverflow, + StackUnderflow, + UnbalancedProgram, + Arithmetic, + DivByZero, + BadAccountIndex, + ShortAccount, + MissingOutput, + MissingReturnData = 12, + ShortReturnData, +} + +impl From for ProgramError { + fn from(error: CalcError) -> Self { + msg!("v42: err {:?}", error); + ProgramError::Custom(error as u32) + } +}