From fb646a69c388f69ca181dcf45485a2f5c69f8611 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 | 16 ++ programs/magic-root-interface/src/lib.rs | 71 +++++++ programs/magic-root-program/Cargo.toml | 32 +++ programs/magic-root-program/README.md | 37 ++++ programs/magic-root-program/src/account.rs | 74 +++++++ programs/magic-root-program/src/lib.rs | 25 +++ .../magic-root-program/src/post_finalize.rs | 36 ++++ programs/magic-root-program/src/processor.rs | 76 +++++++ programs/magic-root-program/src/tests.rs | 115 ++++++++++ programs/v42-calculator-interface/Cargo.toml | 22 ++ programs/v42-calculator-interface/README.md | 20 ++ .../v42-calculator-interface/src/builder.rs | 117 +++++++++++ programs/v42-calculator-interface/src/lib.rs | 14 ++ .../v42-calculator-interface/src/opcodes.rs | 30 +++ programs/v42-calculator-program/Cargo.toml | 32 +++ programs/v42-calculator-program/README.md | 36 ++++ .../v42-calculator-program/src/calculator.rs | 197 ++++++++++++++++++ programs/v42-calculator-program/src/error.rs | 29 +++ programs/v42-calculator-program/src/lib.rs | 25 +++ .../v42-calculator-program/src/transfer.rs | 41 ++++ 21 files changed, 1065 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/account.rs create mode 100644 programs/magic-root-program/src/lib.rs create mode 100644 programs/magic-root-program/src/post_finalize.rs create mode 100644 programs/magic-root-program/src/processor.rs create mode 100644 programs/magic-root-program/src/tests.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/calculator.rs create mode 100644 programs/v42-calculator-program/src/error.rs create mode 100644 programs/v42-calculator-program/src/lib.rs create mode 100644 programs/v42-calculator-program/src/transfer.rs diff --git a/programs/magic-root-interface/Cargo.toml b/programs/magic-root-interface/Cargo.toml new file mode 100644 index 0000000..269b869 --- /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, features = ["wincode"] } +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..b09ee56 --- /dev/null +++ b/programs/magic-root-interface/README.md @@ -0,0 +1,16 @@ +# `magic-root-interface` + +This crate defines the MagicRoot program id and `MagicRootInstruction` wire +schema shared by `magic-root-program` and `engine`. Instruction builders can +depend on the interface without linking the native program runtime. + +`MagicRootInstruction::compose` prepends the writable target account and +serializes the instruction with wincode. `PostFinalize` also appends each +follow-up program id and its account metas. Signer bits are cleared in the outer +instruction; the native program supplies the declared follow-up signers when it +invokes each action. + +`MagicRootInstruction::compose_account` builds the ordered patch sequence for a +complete account and appends its finalization instruction. The underlying patch +sequence applies mode before slot so the program can validate lifecycle-aware +slot progression. diff --git a/programs/magic-root-interface/src/lib.rs b/programs/magic-root-interface/src/lib.rs new file mode 100644 index 0000000..2a081ca --- /dev/null +++ b/programs/magic-root-interface/src/lib.rs @@ -0,0 +1,71 @@ +#![doc = include_str!("../README.md")] + +use solana_account::{AccountFieldPatch, OwnedAccount}; +use solana_instruction::{AccountMeta, Instruction, error::InstructionError}; +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: if it is executable, load it into the + /// transaction's program cache. Does not change lamports. + 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) -> Result { + let mut accounts = vec![AccountMeta::new(account, false)]; + self.extend_metas(&mut accounts); + // NOTE this code can never error, wincode serialization for instruction is infallible + let data = wincode::serialize(self).map_err(|_| InstructionError::BorshIoError)?; + Ok(Instruction { program_id: ID, accounts, data }) + } + + /// Composes `account` into the ordered instruction sequence that patches + /// every field on `target`, then finalizes it. + pub fn compose_account( + target: Pubkey, + account: OwnedAccount, + ) -> Result, InstructionError> { + let patches = AccountFieldPatch::sequence(account); + let mut instructions = Vec::with_capacity(patches.len() + 1); + for patch in patches { + instructions.push(Self::Patch(patch).compose(target)?); + } + instructions.push(Self::Finalize.compose(target)?); + Ok(instructions) + } + + /// 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..9df7516 --- /dev/null +++ b/programs/magic-root-program/Cargo.toml @@ -0,0 +1,32 @@ +[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 } + +[dev-dependencies] +solana-pubkey = { workspace = true } +solana-sdk-ids = { workspace = true } +solana-svm-callback = { workspace = true, features = ["agave-unstable-api"] } +solana-svm-feature-set = { workspace = true, features = ["agave-unstable-api"] } + +[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..17caa6e --- /dev/null +++ b/programs/magic-root-program/README.md @@ -0,0 +1,37 @@ +# `magic-root-program` + +MagicRoot is the engine's privileged native program for patching, finalizing, +and closing accounts. Its wire schema and program id are defined by +`magic-root-interface`. + +Every invocation must use the engine `AUTHORITY` as transaction payer/signer. +Direct transaction instructions are accepted. CPI is accepted only when the +immediate caller is registered in the transaction program cache as a builtin; +native-loader account metadata alone does not grant access. Authorization and +decoding complete before the target account is borrowed. The SVM's separate +top-level-only privilege rule is unchanged. + +## Instructions + +- `Patch` applies one `AccountFieldPatch`. Lamport changes are balanced against + the authority account, including no-op balance patches. Slot patches must + advance the stored slot; an equal slot is accepted only when an earlier patch + in the same transaction genuinely changed the account mode. Older slots are + always rejected. Account mode transitions use the policy owned by + `AccountSharedData`: read-only and placeholder accounts may transition to any + mode except transient, transient accounts may resolve only to read-only, and + delegated accounts may transition only to transient. Ephemeral accounts may + close. Other mode changes are rejected. +- `Finalize` loads an executable target into the transaction program cache. It + does not change lamports. +- `Delete` transitions read-only, placeholder, or ephemeral targets to + `AccountMode::Closed`; accountsdb removes them during writeback. Delegated and + transient targets remain protected, and modes that cannot transition to + closed are rejected. +- `PostFinalize` invokes follow-up instructions through native CPI. It rejects + any immutable instruction account marked writable. + +Complete-account patch sequences apply mode before slot. `AccountSharedData` +marks mode dirty only when its value changes, so a no-op mode patch cannot make +an equal-slot replacement appear fresh. Rejection aborts the transaction, and +therefore rolls back every earlier field patch in that sequence. diff --git a/programs/magic-root-program/src/account.rs b/programs/magic-root-program/src/account.rs new file mode 100644 index 0000000..feed6d2 --- /dev/null +++ b/programs/magic-root-program/src/account.rs @@ -0,0 +1,74 @@ +use solana_account::{AccountFieldPatch, AccountMode, ReadableAccount, WritableAccount}; +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; + +/// Applies a single-field patch to the target account. +pub(crate) fn patch( + ctx: &InvokeContext<'_, '_>, + target: IndexOfAccount, + patch: AccountFieldPatch, +) -> Result<(), InstructionError> { + let mut account = ctx.transaction_context.accounts().try_borrow_mut(target)?; + ic_msg!(ctx, "MagicRoot: patch {:?}", patch); + let old = account.lamports(); + if let Err(error) = patch.apply(&mut account) { + ic_msg!(ctx, "MagicRoot: {}", error); + return Err(InstructionError::InvalidArgument); + } + let new = account.lamports(); + let mut authority = ctx.transaction_context.accounts().try_borrow_mut(0)?; + // Balance target changes against the authority to preserve total lamports. + 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. +pub(crate) fn finalize( + ctx: &mut InvokeContext<'_, '_>, + target: IndexOfAccount, +) -> Result<(), InstructionError> { + let account = ctx.transaction_context.accounts().try_borrow(target)?; + if !account.executable() { + return Ok(()); + } + let pubkey = *ctx.transaction_context.get_key_of_account_at_index(target)?; + 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. +pub(crate) fn delete( + ctx: &InvokeContext<'_, '_>, + target: IndexOfAccount, +) -> Result<(), InstructionError> { + let mut acc = ctx.transaction_context.accounts().try_borrow_mut(target)?; + if matches!(acc.mode(), AccountMode::Delegated | AccountMode::Transient) { + ic_msg!(ctx, "MagicRoot: tried to delete protected account"); + return Err(InstructionError::PrivilegeEscalation); + } + if let Err(error) = acc.set_mode(AccountMode::Closed) { + ic_msg!(ctx, "MagicRoot: {}", error); + return Err(InstructionError::InvalidArgument); + } + ic_msg!(ctx, "MagicRoot: removed"); + Ok(()) +} diff --git a/programs/magic-root-program/src/lib.rs b/programs/magic-root-program/src/lib.rs new file mode 100644 index 0000000..f057996 --- /dev/null +++ b/programs/magic-root-program/src/lib.rs @@ -0,0 +1,25 @@ +#![doc = include_str!("../README.md")] + +mod account; +mod post_finalize; +mod processor; +#[cfg(test)] +mod tests; + +use solana_instruction_error::InstructionError; +use solana_program_runtime::invoke_context::InvokeContext; + +/// 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 dispatches it to the matching handler. +pub fn process(ctx: &mut InvokeContext<'_, '_>) -> Result<(), InstructionError> { + processor::process(ctx) +} + +#[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/magic-root-program/src/post_finalize.rs b/programs/magic-root-program/src/post_finalize.rs new file mode 100644 index 0000000..3fa5036 --- /dev/null +++ b/programs/magic-root-program/src/post_finalize.rs @@ -0,0 +1,36 @@ +use solana_instruction::Instruction; +use solana_instruction_error::InstructionError; +use solana_program_runtime::invoke_context::InvokeContext; +use solana_svm_log_collector::ic_msg; + +/// Runs the post-finalize follow-up instructions: rejects any writable +/// instruction account that is not mutable, then invokes each action via CPI, +/// vouching for exactly the signers that action itself declares. +pub(crate) fn process( + ctx: &mut InvokeContext<'_, '_>, + actions: Vec, +) -> Result<(), InstructionError> { + ic_msg!(ctx, "MagicRoot: post-finalize {} action(s)", actions.len()); + let instruction = ctx.transaction_context.get_current_instruction_context()?; + let count = instruction.get_number_of_instruction_accounts(); + for i in 0..count { + let index = instruction.get_index_of_instruction_account_in_transaction(i)?; + let account = ctx.transaction_context.accounts().try_borrow(index)?; + if instruction.is_instruction_account_writable(i)? && !account.mutable() { + ic_msg!( + ctx, + "MagicRoot: post-finalize rejected immutable writable account" + ); + return Err(InstructionError::Immutable); + } + } + for action in actions { + let signers: Vec<_> = action + .accounts + .iter() + .filter_map(|meta| meta.is_signer.then_some(meta.pubkey)) + .collect(); + ctx.native_invoke(action, &signers)?; + } + Ok(()) +} diff --git a/programs/magic-root-program/src/processor.rs b/programs/magic-root-program/src/processor.rs new file mode 100644 index 0000000..f593d85 --- /dev/null +++ b/programs/magic-root-program/src/processor.rs @@ -0,0 +1,76 @@ +use magic_root_interface::MagicRootInstruction; +use nucleus::tls::AUTHORITY; +use solana_instruction::TRANSACTION_LEVEL_STACK_HEIGHT; +use solana_instruction_error::InstructionError; +use solana_program_runtime::{ + invoke_context::InvokeContext, loaded_programs::ProgramCacheEntryType, +}; +use solana_svm_log_collector::ic_msg; + +use crate::{TARGET_ACCOUNT_IDX, account, post_finalize}; + +pub(crate) fn process(ctx: &mut InvokeContext<'_, '_>) -> Result<(), InstructionError> { + authorize(ctx)?; + let instruction = decode(ctx)?; + dispatch(ctx, instruction) +} + +/// Rejects non-builtin CPI callers and callers other than [`AUTHORITY`]. +/// +/// MagicRoot is engine-internal: it must run either at the transaction level or +/// directly below a builtin program. The transaction must be signed by the +/// authority in either case. Both conditions are checked before any account is +/// touched. +/// +/// Authorizing on account 0's key alone is sound because the engine verifies the +/// fee-payer signature at its transaction ingress (`engine::transaction`), so a +/// transaction reaching execution with `AUTHORITY` as account 0 was signed by it. +pub(crate) fn authorize(ctx: &InvokeContext<'_, '_>) -> Result<(), InstructionError> { + let height = ctx.get_stack_height(); + if height != TRANSACTION_LEVEL_STACK_HEIGHT { + let instruction = ctx.transaction_context.get_current_instruction_context()?; + let caller = ctx + .transaction_context + .get_instruction_context_at_index_in_trace(instruction.get_index_of_caller())?; + let caller_id = caller.get_program_key()?; + let Some(entry) = ctx.program_cache_for_tx_batch.find(caller_id) else { + ic_msg!(ctx, "MagicRoot: non-builtin caller {}", caller_id); + return Err(InstructionError::CallDepth); + }; + if !matches!(&entry.program, ProgramCacheEntryType::Builtin(_)) { + ic_msg!(ctx, "MagicRoot: non-builtin caller {}", caller_id); + 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 instruction = ctx.transaction_context.get_current_instruction_context()?; + wincode::deserialize(instruction.get_instruction_data()).map_err(|_| { + ic_msg!(ctx, "MagicRoot: malformed instruction data"); + InstructionError::InvalidInstructionData + }) +} + +/// Resolves the target account index and dispatches to the matching handler. +fn dispatch( + ctx: &mut InvokeContext<'_, '_>, + instruction: MagicRootInstruction, +) -> Result<(), InstructionError> { + let instruction_context = ctx.transaction_context.get_current_instruction_context()?; + let target = + instruction_context.get_index_of_instruction_account_in_transaction(TARGET_ACCOUNT_IDX)?; + match instruction { + MagicRootInstruction::Patch(patch) => account::patch(ctx, target, patch), + MagicRootInstruction::Finalize => account::finalize(ctx, target), + MagicRootInstruction::Delete => account::delete(ctx, target), + MagicRootInstruction::PostFinalize(actions) => post_finalize::process(ctx, actions), + } +} diff --git a/programs/magic-root-program/src/tests.rs b/programs/magic-root-program/src/tests.rs new file mode 100644 index 0000000..44598c5 --- /dev/null +++ b/programs/magic-root-program/src/tests.rs @@ -0,0 +1,115 @@ +use { + crate::{entrypoint::MagicRootEntrypoint, processor::authorize}, + nucleus::tls::AUTHORITY, + solana_account::AccountSharedData, + solana_instruction_error::InstructionError, + solana_program_runtime::{ + loaded_programs::{ProgramCacheEntry, ProgramCacheEntryType}, + solana_sbpf::program::BuiltinFunctionDefinition, + with_mock_invoke_context, + }, + solana_pubkey::Pubkey, + solana_sdk_ids::native_loader, + std::sync::Arc, +}; + +#[derive(Clone, Copy)] +enum Caller { + Builtin, + User, +} + +fn authorize_chain( + authority: Pubkey, + signer: Pubkey, + callers: &[Caller], +) -> Result<(), InstructionError> { + AUTHORITY.set(authority); + + let caller_ids = callers.iter().map(|_| Pubkey::new_unique()).collect::>(); + let mut accounts = Vec::with_capacity(caller_ids.len().saturating_add(2)); + accounts.push((signer, AccountSharedData::default())); + accounts.extend( + caller_ids + .iter() + .map(|id| (*id, AccountSharedData::new(1, 0, &native_loader::ID))), + ); + accounts.push(( + magic_root_interface::ID, + AccountSharedData::new(1, 0, &native_loader::ID), + )); + + with_mock_invoke_context!(ctx, transaction_context, accounts); + let mut cache = ProgramCacheForTxBatch::default(); + let environments = ProgramRuntimeEnvironments::default(); + for (&id, caller) in caller_ids.iter().zip(callers) { + let entry = match caller { + Caller::Builtin => ProgramCacheEntry::new_builtin(( + MagicRootEntrypoint::vm, + MagicRootEntrypoint::codegen, + )), + Caller::User => ProgramCacheEntry { + program: ProgramCacheEntryType::Unloaded( + environments.get_env_for_execution().clone(), + ), + }, + }; + cache.replenish(id, Arc::new(entry)); + } + ctx.program_cache_for_tx_batch = &mut cache; + + ctx.transaction_context + .configure_top_level_instruction_for_tests(1, Vec::new(), Vec::new())?; + ctx.push()?; + for program_index in 2..=callers.len().saturating_add(1) { + ctx.transaction_context.configure_next_cpi_for_tests( + program_index as u16, + Vec::new(), + Vec::new(), + )?; + ctx.push()?; + } + + authorize(&ctx) +} + +#[test] +fn authorizes_top_level_and_builtin_cpi() { + let authority = Pubkey::new_unique(); + assert_eq!(authorize_chain(authority, authority, &[]), Ok(())); + assert_eq!( + authorize_chain(authority, authority, &[Caller::Builtin]), + Ok(()) + ); + assert_eq!( + authorize_chain(authority, authority, &[Caller::Builtin, Caller::Builtin],), + Ok(()) + ); +} + +#[test] +fn rejects_a_non_builtin_caller() { + let authority = Pubkey::new_unique(); + assert_eq!( + authorize_chain(authority, authority, &[Caller::User]), + Err(InstructionError::CallDepth) + ); +} + +#[test] +fn authorizes_an_immediate_builtin_caller() { + let authority = Pubkey::new_unique(); + assert_eq!( + authorize_chain(authority, authority, &[Caller::User, Caller::Builtin]), + Ok(()) + ); +} + +#[test] +fn rejects_the_wrong_authority() { + let authority = Pubkey::new_unique(); + assert_eq!( + authorize_chain(authority, Pubkey::new_unique(), &[Caller::Builtin]), + Err(InstructionError::MissingRequiredSignature) + ); +} 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..3359c7b --- /dev/null +++ b/programs/v42-calculator-interface/README.md @@ -0,0 +1,20 @@ +# `v42-calculator-interface` + +This crate defines the v42 calculator program id, instruction wire constants, +and optional off-chain builders. The SBF program depends on the wire definitions +with the default `builder` feature disabled. + +`Expr` produces postfix instruction data from signed `i64` literals, account +operands, the `Clock` sysvar, arithmetic operators, and recursive self-CPI +subexpressions. Expression composition concatenates existing postfix byte +streams. + +`Expr::compose` builds an instruction with the writable output at account zero, +read-only operands after it, and the calculator program id last for recursive +CPI. `Expr::acc` indexes the full instruction account list, so operand indexes +start at one and remain stable across nested calls. + +`builder::transfer` applies a signed delta between distinct writable v42 accounts +at indexes 0 and 1. Its data is `TRANSFER` followed by a little-endian `i64`; +positive values move lamports and calculator value from account 0 to account 1, +while negative values reverse the direction. diff --git a/programs/v42-calculator-interface/src/builder.rs b/programs/v42-calculator-interface/src/builder.rs new file mode 100644 index 0000000..5ffb012 --- /dev/null +++ b/programs/v42-calculator-interface/src/builder.rs @@ -0,0 +1,117 @@ +//! Off-chain expression builder. + +use core::ops::{Add, Div, Mul, Sub}; + +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +use crate::{opcodes::*, ID, TRANSFER}; + +/// Build a transfer between distinct v42-owned accounts. A positive `delta` +/// moves value from `from` to `to`; a negative delta reverses the direction. +/// Neither account is required to sign, but both are writable. +pub fn transfer(from: Pubkey, to: Pubkey, delta: i64) -> Instruction { + let mut data = Vec::with_capacity(9); + data.push(TRANSFER); + data.extend_from_slice(&delta.to_le_bytes()); + Instruction { + program_id: ID, + accounts: vec![AccountMeta::new(from, false), AccountMeta::new(to, false)], + data, + } +} + +/// 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 signed literal operand. + pub fn lit(value: i64) -> Self { + let mut bytes = Vec::with_capacity(9); + bytes.push(PUSH_LIT); + bytes.extend_from_slice(&value.to_le_bytes()); + Self(bytes) + } + + /// The `i64` 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..216fef4 --- /dev/null +++ b/programs/v42-calculator-interface/src/lib.rs @@ -0,0 +1,14 @@ +#![doc = include_str!("../README.md")] + +use solana_pubkey::declare_id; + +pub mod opcodes; + +#[cfg(feature = "builder")] +pub mod builder; + +declare_id!("V42CaLcu1atormagicb1ock11111111111111111111"); + +/// Transfer lamports and calculator value between instruction accounts 0 and +/// 1; an exact little-endian `i64` delta follows. +pub const TRANSFER: u8 = 0x30; diff --git a/programs/v42-calculator-interface/src/opcodes.rs b/programs/v42-calculator-interface/src/opcodes.rs new file mode 100644 index 0000000..219fc1f --- /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 +//! `i64` 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 `i64`; 8 little-endian bytes follow. +pub const PUSH_LIT: u8 = 0x00; +/// Push the `i64` 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. +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 checked `a / b` (`b == 0` is an error). +pub const DIV: u8 = 0x13; + +/// Evaluate a nested program via self-CPI and push its return-data `i64`. 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..2b3ff24 --- /dev/null +++ b/programs/v42-calculator-program/README.md @@ -0,0 +1,36 @@ +# `v42-calculator-program` + +This SBF test program evaluates the postfix wire format from +`v42-calculator-interface`. It exercises account-data reads, the `Clock` sysvar, +checked arithmetic, recursive CPI, return data, writable account output, and +direct lamport transfers. + +The crate uses Rust 2021 and upstream minimal program crates so it remains +compatible with `cargo build-sbf`. Keeper's build script produces +`target/deploy/v42_calculator_program.so` for its testkit. + +## Execution model + +The crate entrypoint dispatches to the calculator and transfer domains. The +calculator owns expression evaluation and value encoding, transfer owns signed +balance and value movement, and error owns the stable calculator error codes. + +The evaluator processes literals, account `i64` values, clock timestamps, +arithmetic opcodes, and length-prefixed nested calls on a fixed-capacity stack. +Malformed input, stack errors, arithmetic errors, missing accounts, and short +buffers return stable `ProgramError::Custom` codes from `CalcError`. + +A top-level invocation writes the final little-endian `i64` to account zero. A +nested invocation publishes the value through return data after all child calls +finish, because a subsequent CPI would clear earlier return data. + +The program emits `v42:` trace messages for entry, CPI, clock access, results, +and failures. Result messages distinguish `account0` from `return_data` routing. + +A `TRANSFER` instruction instead reads an exact little-endian `i64` delta from +its data and applies it to two distinct writable accounts owned by this program. +Account 0 receives the negated delta and account 1 receives the delta, updating +both lamports and the little-endian `i64` calculator value in their first eight +data bytes. Both updates use wrapping two's-complement arithmetic, so a negative +delta reverses the transfer direction. Neither account needs to sign. Transfer +does not run the evaluator or publish return data. diff --git a/programs/v42-calculator-program/src/calculator.rs b/programs/v42-calculator-program/src/calculator.rs new file mode 100644 index 0000000..972dfc2 --- /dev/null +++ b/programs/v42-calculator-program/src/calculator.rs @@ -0,0 +1,197 @@ +//! Postfix calculator evaluation and result routing. + +use solana_account_info::AccountInfo; +use solana_cpi::{get_return_data, invoke, set_return_data}; +use solana_instruction::{AccountMeta, Instruction, TRANSACTION_LEVEL_STACK_HEIGHT}; +use solana_msg::msg; +use solana_program_error::{ProgramError, ProgramResult}; +use solana_sysvar::clock::Clock; +use solana_sysvar::Sysvar; +use v42_calculator_interface::{opcodes::*, ID}; + +use crate::error::CalcError; + +/// Evaluates the RPN program in `data` and routes the result: a **top-level** +/// invocation writes it (LE `i64`) 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. +pub(crate) fn process(accounts: &[AccountInfo], data: &[u8], height: usize) -> ProgramResult { + let result = eval(accounts, data)?; + if height == TRANSACTION_LEVEL_STACK_HEIGHT { + msg!("v42: result={} -> account0", result); + let output = accounts.first().ok_or(CalcError::MissingOutput)?; + write_account_value(&mut output.try_borrow_mut_data()?, result) + } 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.i64()?)?, + PUSH_ACC => stack.push(account_i64(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: i64, b: i64) -> 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 if b == 0 => Err(CalcError::DivByZero), + DIV => a.checked_div(b).ok_or(CalcError::Arithmetic), + _ => Err(CalcError::BadOpcode), + } +} + +/// Reads the calculator value from the first eight account data bytes. +pub(crate) fn read_account_value(bytes: &[u8]) -> Result { + read_head_i64(bytes, CalcError::ShortAccount).map_err(Into::into) +} + +/// Writes the calculator value to the first eight account data bytes. +pub(crate) fn write_account_value(bytes: &mut [u8], value: i64) -> ProgramResult { + bytes + .get_mut(..8) + .ok_or(CalcError::ShortAccount)? + .copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +/// Decodes the `i64` LE stored in the first 8 bytes of `bytes`, reporting `short` +/// if there aren't that many. +fn read_head_i64(bytes: &[u8], short: CalcError) -> Result { + let head = bytes.get(..8).ok_or(short)?; + Ok(i64::from_le_bytes(head.try_into().expect("length checked"))) +} + +/// Reads the `i64` LE operand from the first 8 data bytes of an instruction +/// account (borrowed, not copied). +fn account_i64(accounts: &[AccountInfo], index: u8) -> Result { + let account = accounts.get(index as usize).ok_or(CalcError::BadAccountIndex)?; + read_account_value(&account.try_borrow_data()?) +} + +fn clock_ts() -> Result { + let ts = Clock::get()?.unix_timestamp; + 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 `i64`. +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_i64(&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 i64(&mut self) -> Result { + Ok(i64::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: [i64; 64], + len: usize, +} + +impl Stack { + fn new() -> Self { + Self { slots: [0; 64], len: 0 } + } + + fn push(&mut self, value: i64) -> 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), + } + } +} diff --git a/programs/v42-calculator-program/src/error.rs b/programs/v42-calculator-program/src/error.rs new file mode 100644 index 0000000..7faf6e5 --- /dev/null +++ b/programs/v42-calculator-program/src/error.rs @@ -0,0 +1,29 @@ +//! Stable calculator errors exposed through `ProgramError::Custom`. + +use solana_msg::msg; +use solana_program_error::ProgramError; + +/// Failure modes with stable discriminants for callers and tests. +#[derive(Debug)] +#[repr(u32)] +pub(crate) 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) + } +} diff --git a/programs/v42-calculator-program/src/lib.rs b/programs/v42-calculator-program/src/lib.rs new file mode 100644 index 0000000..38b6501 --- /dev/null +++ b/programs/v42-calculator-program/src/lib.rs @@ -0,0 +1,25 @@ +#![doc = include_str!("../README.md")] + +mod calculator; +mod error; +mod transfer; + +use solana_account_info::AccountInfo; +use solana_instruction::syscalls::get_stack_height; +use solana_msg::msg; +use solana_program_entrypoint::entrypoint; +use solana_program_error::ProgramResult; +use solana_pubkey::Pubkey; +use v42_calculator_interface::TRANSFER; + +entrypoint!(process_instruction); + +fn process_instruction(_: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult { + let height = get_stack_height(); + msg!("v42: enter height={} len={}", height, data.len()); + if data.first() == Some(&TRANSFER) { + transfer::process(accounts, data) + } else { + calculator::process(accounts, data, height) + } +} diff --git a/programs/v42-calculator-program/src/transfer.rs b/programs/v42-calculator-program/src/transfer.rs new file mode 100644 index 0000000..7bd18a0 --- /dev/null +++ b/programs/v42-calculator-program/src/transfer.rs @@ -0,0 +1,41 @@ +//! Signed value transfers between calculator accounts. + +use solana_account_info::AccountInfo; +use solana_msg::msg; +use solana_program_error::{ProgramError, ProgramResult}; + +use crate::calculator::{read_account_value, write_account_value}; + +/// Applies the encoded signed delta to the lamports and calculator values of +/// two distinct accounts. Both accounts must be owned by this program and +/// writable. +pub(crate) fn process(accounts: &[AccountInfo], data: &[u8]) -> ProgramResult { + let [from, to, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + if from.key == to.key { + return Err(ProgramError::InvalidArgument); + } + let delta = data + .get(1..) + .map(TryInto::<[u8; 8]>::try_into) + .ok_or(ProgramError::InvalidInstructionData)? + .map(i64::from_le_bytes) + .map_err(|_| ProgramError::InvalidInstructionData)?; + + msg!("v42: transfer delta={}", delta); + apply_delta(from, delta.wrapping_neg())?; + apply_delta(to, delta) +} + +/// Adds the same two's-complement delta to an account's balance and stored +/// calculator value. +fn apply_delta(account: &AccountInfo, delta: i64) -> ProgramResult { + let mut lamports = account.try_borrow_mut_lamports()?; + **lamports = (**lamports).wrapping_add(delta as u64); + drop(lamports); + + let mut data = account.try_borrow_mut_data()?; + let result = read_account_value(&data)?.wrapping_add(delta); + write_account_value(&mut data, result) +}