diff --git a/Cargo.lock b/Cargo.lock index 4b8c897130d45b..be861fede8a698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7659,6 +7659,24 @@ dependencies = [ "prost-types", ] +[[package]] +name = "solana-svm-example-paytube" +version = "2.1.0" +dependencies = [ + "solana-bpf-loader-program", + "solana-client", + "solana-compute-budget", + "solana-logger", + "solana-program-runtime", + "solana-sdk", + "solana-svm", + "solana-system-program", + "solana-test-validator", + "spl-associated-token-account", + "spl-token", + "termcolor", +] + [[package]] name = "solana-svm-transaction" version = "2.1.0" @@ -8675,9 +8693,9 @@ dependencies = [ [[package]] name = "termcolor" -version = "1.1.2" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ "winapi-util", ] diff --git a/Cargo.toml b/Cargo.toml index 45f6625f01ec6d..d87789abc3b334 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,6 +120,7 @@ members = [ "svm", "svm-conformance", "svm-transaction", + "svm/examples/paytube", "test-validator", "thin-client", "timings", @@ -422,6 +423,7 @@ solana-storage-proto = { path = "storage-proto", version = "=2.1.0" } solana-streamer = { path = "streamer", version = "=2.1.0" } solana-svm = { path = "svm", version = "=2.1.0" } solana-svm-conformance = { path = "svm-conformance", version = "=2.1.0" } +solana-svm-example-paytube = { path = "svm/examples/paytube", version = "=2.1.0" } solana-svm-transaction = { path = "svm-transaction", version = "=2.1.0" } solana-system-program = { path = "programs/system", version = "=2.1.0" } solana-test-validator = { path = "test-validator", version = "=2.1.0" } diff --git a/svm/examples/paytube/Cargo.toml b/svm/examples/paytube/Cargo.toml new file mode 100644 index 00000000000000..ceb30ff55516d7 --- /dev/null +++ b/svm/examples/paytube/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "solana-svm-example-paytube" +description = "Reference example using Solana SVM API" +version = { workspace = true } +edition = { workspace = true } +publish = false + +[dependencies] +solana-bpf-loader-program = { workspace = true } +solana-client = { workspace = true } +solana-compute-budget = { workspace = true } +solana-logger = { workspace = true } +solana-program-runtime = { workspace = true } +solana-sdk = { workspace = true } +solana-svm = { workspace = true } +solana-system-program = { workspace = true } +spl-associated-token-account = { workspace = true } +spl-token = { workspace = true } +termcolor = "1.4.1" + +[dev-dependencies] +solana-test-validator = { workspace = true } diff --git a/svm/examples/paytube/README.md b/svm/examples/paytube/README.md new file mode 100644 index 00000000000000..1f6a79e6ac74fd --- /dev/null +++ b/svm/examples/paytube/README.md @@ -0,0 +1,19 @@ +# PayTube + +A reference implementation of an off-chain [state channel](https://ethereum.org/en/developers/docs/scaling/state-channels/) +built using [Anza's SVM API](https://www.anza.xyz/blog/anzas-new-svm-api). + +With the release of Agave 2.0, we've decoupled the SVM API from the rest of the +runtime, which means it can be used outside the validator. This unlocks +SVM-based solutions such as sidecars, channels, rollups, and more. This project +demonstrates everything you need to know about boostrapping with this new API. + +PayTube is a state channel (more specifically a payment channel), designed to +allow multiple parties to transact amongst each other in SOL or SPL tokens +off-chain. When the channel is closed, the resulting changes in each user's +balances are posted to the base chain (Solana). + +Although this project is for demonstration purposes, a payment channel similar +to PayTube could be created that scales to handle massive bandwidth of +transfers, saving the overhead of posting transactions to the chain for last. + diff --git a/svm/examples/paytube/src/lib.rs b/svm/examples/paytube/src/lib.rs new file mode 100644 index 00000000000000..e08588a65dee06 --- /dev/null +++ b/svm/examples/paytube/src/lib.rs @@ -0,0 +1,195 @@ +//! PayTube. A simple SPL payment channel. +//! +//! PayTube is an SVM-based payment channel that allows two parties to exchange +//! tokens off-chain. The channel is opened by invoking the PayTube "VM", +//! running on some arbitrary server(s). When transacting has concluded, the +//! channel is closed by submitting the final payment ledger to Solana. +//! +//! The final ledger tracks debits and credits to all registered token accounts +//! or system accounts (native SOL) during the lifetime of a channel. It is +//! then used to to craft a batch of transactions to submit to the settlement +//! chain (Solana). +//! +//! Users opt-in to using a PayTube channel by "registering" their token +//! accounts to the channel. This is done by delegating a token account to the +//! PayTube on-chain program on Solana. This delegation is temporary, and +//! released immediately after channel settlement. +//! +//! Note: This opt-in solution is for demonstration purposes only. +//! +//! ```text +//! +//! PayTube "VM" +//! +//! Bob Alice Bob Alice Will +//! | | | | | +//! | --o--o--o-> | | --o--o--o-> | | +//! | | | | --o--o--o-> | <--- PayTube +//! | <-o--o--o-- | | <-o--o--o-- | | Transactions +//! | | | | | +//! | --o--o--o-> | | -----o--o--o-----> | +//! | | | | +//! | --o--o--o-> | | <----o--o--o------ | +//! +//! \ / \ | / +//! +//! ------ ------ +//! Alice: x Alice: x +//! Bob: x Bob: x <--- Solana Transaction +//! Will: x with final ledgers +//! ------ ------ +//! +//! \\ \\ +//! x x +//! +//! Solana Solana <--- Settled to Solana +//! ``` +//! +//! The Solana SVM's `TransactionBatchProcessor` requires projects to provide a +//! "loader" plugin, which implements the `TransactionProcessingCallback` +//! interface. +//! +//! PayTube defines a `PayTubeAccountLoader` that implements the +//! `TransactionProcessingCallback` interface, and provides it to the +//! `TransactionBatchProcessor` to process PayTube transactions. + +mod loader; +mod log; +mod processor; +mod settler; +pub mod transaction; + +use { + crate::{ + loader::PayTubeAccountLoader, settler::PayTubeSettler, transaction::PayTubeTransaction, + }, + processor::{create_transaction_batch_processor, get_transaction_check_results}, + solana_client::rpc_client::RpcClient, + solana_compute_budget::compute_budget::ComputeBudget, + solana_sdk::{ + feature_set::FeatureSet, fee::FeeStructure, hash::Hash, rent_collector::RentCollector, + signature::Keypair, + }, + solana_svm::transaction_processor::{ + TransactionProcessingConfig, TransactionProcessingEnvironment, + }, + std::sync::Arc, + transaction::create_svm_transactions, +}; + +/// A PayTube channel instance. +/// +/// Facilitates native SOL or SPL token transfers amongst various channel +/// participants, settling the final changes in balances to the base chain. +pub struct PayTubeChannel { + /// I think you know why this is a bad idea... + keys: Vec, + rpc_client: RpcClient, +} + +impl PayTubeChannel { + pub fn new(keys: Vec, rpc_client: RpcClient) -> Self { + Self { keys, rpc_client } + } + + /// The PayTube API. Processes a batch of PayTube transactions. + /// + /// Obviously this is a very simple implementation, but one could imagine + /// a more complex service that employs custom functionality, such as: + /// + /// * Increased throughput for individual P2P transfers. + /// * Custom Solana transaction ordering (e.g. MEV). + /// + /// The general scaffold of the PayTube API would remain the same. + pub fn process_paytube_transfers(&self, transactions: &[PayTubeTransaction]) { + log::setup_solana_logging(); + log::creating_paytube_channel(); + + // PayTube default configs. + // + // These can be configurable for channel customization, including + // imposing resource or feature restrictions, but more commonly they + // would likely be hoisted from the cluster. + // + // For example purposes, they are provided as defaults here. + let compute_budget = ComputeBudget::default(); + let feature_set = FeatureSet::all_enabled(); + let fee_structure = FeeStructure::default(); + let lamports_per_signature = fee_structure.lamports_per_signature; + let rent_collector = RentCollector::default(); + + // PayTube loader/callback implementation. + // + // Required to provide the SVM API with a mechanism for loading + // accounts. + let account_loader = PayTubeAccountLoader::new(&self.rpc_client); + + // Solana SVM transaction batch processor. + // + // Creates an instance of `TransactionBatchProcessor`, which can be + // used by PayTube to process transactions using the SVM. + // + // This allows programs such as the System and Token programs to be + // translated and executed within a provisioned virtual machine, as + // well as offers many of the same functionality as the lower-level + // Solana runtime. + let processor = + create_transaction_batch_processor(&account_loader, &feature_set, &compute_budget); + + // The PayTube transaction processing runtime environment. + // + // Again, these can be configurable or hoisted from the cluster. + let processing_environment = TransactionProcessingEnvironment { + blockhash: Hash::default(), + epoch_total_stake: None, + epoch_vote_accounts: None, + feature_set: Arc::new(feature_set), + fee_structure: Some(&fee_structure), + lamports_per_signature, + rent_collector: Some(&rent_collector), + }; + + // The PayTube transaction processing config for Solana SVM. + // + // Extended configurations for even more customization of the SVM API. + let processing_config = TransactionProcessingConfig { + compute_budget: Some(compute_budget), + ..Default::default() + }; + + // Step 1: Convert the batch of PayTube transactions into + // SVM-compatible transactions for processing. + // + // In the future, the SVM API may allow for trait-based transactions. + // In this case, `PayTubeTransaction` could simply implement the + // interface, and avoid this conversion entirely. + let svm_transactions = create_svm_transactions(transactions); + + // Step 2: Process the SVM-compatible transactions with the SVM API. + log::processing_transactions(svm_transactions.len()); + let results = processor.load_and_execute_sanitized_transactions( + &account_loader, + &svm_transactions, + get_transaction_check_results(svm_transactions.len(), lamports_per_signature), + &processing_environment, + &processing_config, + ); + + // Step 3: Convert the SVM API processor results into a final ledger + // using `PayTubeSettler`, and settle the resulting balance differences + // to the Solana base chain. + // + // Here the settler is basically iterating over the transaction results + // to track debits and credits, but only for those transactions which + // were executed succesfully. + // + // The final ledger of debits and credits to each participant can then + // be packaged into a minimal number of settlement transactions for + // submission. + let settler = PayTubeSettler::new(&self.rpc_client, transactions, results, &self.keys); + log::settling_to_base_chain(settler.num_transactions()); + settler.process_settle(); + + log::channel_closed(); + } +} diff --git a/svm/examples/paytube/src/loader.rs b/svm/examples/paytube/src/loader.rs new file mode 100644 index 00000000000000..676598216200f5 --- /dev/null +++ b/svm/examples/paytube/src/loader.rs @@ -0,0 +1,58 @@ +//! PayTube's "account loader" component, which provides the SVM API with the +//! ability to load accounts for PayTube channels. +//! +//! The account loader is a simple example of an RPC client that can first load +//! an account from the base chain, then cache it locally within the protocol +//! for the duration of the channel. + +use { + solana_client::rpc_client::RpcClient, + solana_sdk::{ + account::{AccountSharedData, ReadableAccount}, + pubkey::Pubkey, + }, + solana_svm::transaction_processing_callback::TransactionProcessingCallback, + std::{collections::HashMap, sync::RwLock}, +}; + +/// An account loading mechanism to hoist accounts from the base chain up to +/// an active PayTube channel. +/// +/// Employs a simple cache mechanism to ensure accounts are only loaded once. +pub struct PayTubeAccountLoader<'a> { + cache: RwLock>, + rpc_client: &'a RpcClient, +} + +impl<'a> PayTubeAccountLoader<'a> { + pub fn new(rpc_client: &'a RpcClient) -> Self { + Self { + cache: RwLock::new(HashMap::new()), + rpc_client, + } + } +} + +/// Implementation of the SVM API's `TransactionProcessingCallback` interface. +/// +/// The SVM API requires this plugin be provided to provide the SVM with the +/// ability to load accounts. +/// +/// In the Agave validator, this implementation is Bank, powered by AccountsDB. +impl TransactionProcessingCallback for PayTubeAccountLoader<'_> { + fn get_account_shared_data(&self, pubkey: &Pubkey) -> Option { + if let Some(account) = self.cache.read().unwrap().get(pubkey) { + return Some(account.clone()); + } + + let account: AccountSharedData = self.rpc_client.get_account(pubkey).ok()?.into(); + self.cache.write().unwrap().insert(*pubkey, account.clone()); + + Some(account) + } + + fn account_matches_owners(&self, account: &Pubkey, owners: &[Pubkey]) -> Option { + self.get_account_shared_data(account) + .and_then(|account| owners.iter().position(|key| account.owner().eq(key))) + } +} diff --git a/svm/examples/paytube/src/log.rs b/svm/examples/paytube/src/log.rs new file mode 100644 index 00000000000000..92c75573080741 --- /dev/null +++ b/svm/examples/paytube/src/log.rs @@ -0,0 +1,47 @@ +//! Just logging! +use { + std::io::Write, + termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}, +}; + +fn log_magenta(msg: &str) { + let mut stdout = StandardStream::stdout(ColorChoice::Always); + + stdout + .set_color(ColorSpec::new().set_fg(Some(Color::Magenta)).set_bold(true)) + .unwrap(); + + writeln!(&mut stdout, "\n[PAYTUBE]: INFO: {}\n", msg).unwrap(); + + stdout.reset().unwrap(); +} + +pub(crate) fn setup_solana_logging() { + #[rustfmt::skip] + solana_logger::setup_with_default( + "solana_rbpf::vm=debug,\ + solana_runtime::message_processor=debug,\ + solana_runtime::system_instruction_processor=trace", + ); +} + +pub(crate) fn creating_paytube_channel() { + log_magenta("Creating PayTube channel..."); +} + +pub(crate) fn processing_transactions(num_transactions: usize) { + log_magenta("Processing PayTube transactions with the SVM API..."); + log_magenta(&format!("Number of transactions: {}", num_transactions)); +} + +pub(crate) fn settling_to_base_chain(num_transactions: usize) { + log_magenta("Settling results from PayTube to the base chain..."); + log_magenta(&format!( + "Number of settlement transactions: {}", + num_transactions + )); +} + +pub(crate) fn channel_closed() { + log_magenta("PayTube channel closed."); +} diff --git a/svm/examples/paytube/src/processor.rs b/svm/examples/paytube/src/processor.rs new file mode 100644 index 00000000000000..ebc53bc2b6f049 --- /dev/null +++ b/svm/examples/paytube/src/processor.rs @@ -0,0 +1,121 @@ +//! A helper to initialize Solana SVM API's `TransactionBatchProcessor`. + +use { + solana_bpf_loader_program::syscalls::create_program_runtime_environment_v1, + solana_compute_budget::compute_budget::ComputeBudget, + solana_program_runtime::loaded_programs::{ + BlockRelation, ForkGraph, LoadProgramMetrics, ProgramCacheEntry, + }, + solana_sdk::{account::ReadableAccount, clock::Slot, feature_set::FeatureSet, transaction}, + solana_svm::{ + account_loader::CheckedTransactionDetails, + transaction_processing_callback::TransactionProcessingCallback, + transaction_processor::TransactionBatchProcessor, + }, + solana_system_program::system_processor, + std::sync::{Arc, RwLock}, +}; + +/// In order to use the `TransactionBatchProcessor`, another trait - Solana +/// Program Runtime's `ForkGraph` - must be implemented, to tell the batch +/// processor how to work across forks. +/// +/// Since PayTube doesn't use slots or forks, this implementation is mocked. +pub(crate) struct PayTubeForkGraph {} + +impl ForkGraph for PayTubeForkGraph { + fn relationship(&self, _a: Slot, _b: Slot) -> BlockRelation { + BlockRelation::Unknown + } +} + +/// This function encapsulates some initial setup required to tweak the +/// `TransactionBatchProcessor` for use within PayTube. +/// +/// We're simply configuring the mocked fork graph on the SVM API's program +/// cache, then adding the System program to the processor's builtins. +pub(crate) fn create_transaction_batch_processor( + callbacks: &CB, + feature_set: &FeatureSet, + compute_budget: &ComputeBudget, +) -> TransactionBatchProcessor { + let processor = TransactionBatchProcessor::::default(); + + { + let mut cache = processor.program_cache.write().unwrap(); + + // Initialize the mocked fork graph. + let fork_graph = Arc::new(RwLock::new(PayTubeForkGraph {})); + cache.fork_graph = Some(Arc::downgrade(&fork_graph)); + + // Initialize a proper cache environment. + // (Use Loader v4 program to initialize runtime v2 if desired) + cache.environments.program_runtime_v1 = Arc::new( + create_program_runtime_environment_v1(feature_set, compute_budget, false, false) + .unwrap(), + ); + + // Add the SPL Token program to the cache. + if let Some(program_account) = callbacks.get_account_shared_data(&spl_token::id()) { + let elf_bytes = program_account.data(); + let program_runtime_environment = cache.environments.program_runtime_v1.clone(); + cache.assign_program( + spl_token::id(), + Arc::new( + ProgramCacheEntry::new( + &solana_sdk::bpf_loader::id(), + program_runtime_environment, + 0, + 0, + elf_bytes, + elf_bytes.len(), + &mut LoadProgramMetrics::default(), + ) + .unwrap(), + ), + ); + } + } + + // Add the system program builtin. + processor.add_builtin( + callbacks, + solana_system_program::id(), + "system_program", + ProgramCacheEntry::new_builtin( + 0, + b"system_program".len(), + system_processor::Entrypoint::vm, + ), + ); + + // Add the BPF Loader v2 builtin, for the SPL Token program. + processor.add_builtin( + callbacks, + solana_sdk::bpf_loader::id(), + "solana_bpf_loader_program", + ProgramCacheEntry::new_builtin( + 0, + b"solana_bpf_loader_program".len(), + solana_bpf_loader_program::Entrypoint::vm, + ), + ); + + processor +} + +/// This functions is also a mock. In the Agave validator, the bank pre-checks +/// transactions before providing them to the SVM API. We mock this step in +/// PayTube, since we don't need to perform such pre-checks. +pub(crate) fn get_transaction_check_results( + len: usize, + lamports_per_signature: u64, +) -> Vec> { + vec![ + transaction::Result::Ok(CheckedTransactionDetails { + nonce: None, + lamports_per_signature, + }); + len + ] +} diff --git a/svm/examples/paytube/src/settler.rs b/svm/examples/paytube/src/settler.rs new file mode 100644 index 00000000000000..c4feeb5aaed9dc --- /dev/null +++ b/svm/examples/paytube/src/settler.rs @@ -0,0 +1,173 @@ +//! PayTube's "settler" component for settling the final ledgers across all +//! channel participants. +//! +//! When users are finished transacting, the resulting ledger is used to craft +//! a batch of transactions to settle all state changes to the base chain +//! (Solana). +//! +//! The interesting piece here is that there can be hundreds or thousands of +//! transactions across a handful of users, but only the resulting difference +//! between their balance when the channel opened and their balance when the +//! channel is about to close are needed to create the settlement transaction. + +use { + crate::transaction::PayTubeTransaction, + solana_client::{rpc_client::RpcClient, rpc_config::RpcSendTransactionConfig}, + solana_sdk::{ + commitment_config::CommitmentConfig, instruction::Instruction as SolanaInstruction, + pubkey::Pubkey, signature::Keypair, signer::Signer, system_instruction, + transaction::Transaction as SolanaTransaction, + }, + solana_svm::transaction_processor::LoadAndExecuteSanitizedTransactionsOutput, + spl_associated_token_account::get_associated_token_address, + std::collections::HashMap, +}; + +/// The key used for storing ledger entries. +/// +/// Each entry in the ledger represents the movement of SOL or tokens between +/// two parties. The two keys of the two parties are stored in a sorted array +/// of length two, and the value's sign determines the direction of transfer. +/// +/// This design allows the ledger to combine transfers from a -> b and b -> a +/// in the same entry, calculating the final delta between two parties. +/// +/// Note that this design could be even _further_ optimized to minimize the +/// number of required settlement transactions in a few ways, including +/// combining transfers across parties, ignoring zero-balance changes, and +/// more. An on-chain program on the base chain could even facilitate +/// multi-party transfers, further reducing the number of required +/// settlement transactions. +#[derive(PartialEq, Eq, Hash)] +struct LedgerKey { + mint: Option, + keys: [Pubkey; 2], +} + +/// A ledger of PayTube transactions, used to deconstruct into base chain +/// transactions. +/// +/// The value is stored as a signed `i128`, in order to include a sign but also +/// provide enough room to store `u64::MAX`. +struct Ledger { + ledger: HashMap, +} + +impl Ledger { + fn new( + paytube_transactions: &[PayTubeTransaction], + svm_output: LoadAndExecuteSanitizedTransactionsOutput, + ) -> Self { + let mut ledger: HashMap = HashMap::new(); + paytube_transactions + .iter() + .zip(svm_output.execution_results) + .for_each(|(transaction, result)| { + // Only append to the ledger if the PayTube transaction was + // successful. + if result.was_executed_successfully() { + let mint = transaction.mint; + let mut keys = [transaction.from, transaction.to]; + keys.sort(); + let amount = if keys.iter().position(|k| k.eq(&transaction.from)).unwrap() == 0 + { + transaction.amount as i128 + } else { + transaction.amount.checked_neg().unwrap() as i128 + }; + ledger + .entry(LedgerKey { mint, keys }) + .and_modify(|e| *e = e.checked_add(amount).unwrap()) + .or_insert(amount); + } + }); + Self { ledger } + } + + fn generate_base_chain_instructions(&self) -> Vec { + self.ledger + .iter() + .map(|(key, amount)| { + let (from, to, amount) = if *amount < 0 { + (key.keys[1], key.keys[0], (amount * -1) as u64) + } else { + (key.keys[0], key.keys[1], *amount as u64) + }; + if let Some(mint) = key.mint { + let source_pubkey = get_associated_token_address(&from, &mint); + let destination_pubkey = get_associated_token_address(&to, &mint); + return spl_token::instruction::transfer( + &spl_token::id(), + &source_pubkey, + &destination_pubkey, + &from, + &[], + amount, + ) + .unwrap(); + } + system_instruction::transfer(&from, &to, amount) + }) + .collect::>() + } +} + +const CHUNK_SIZE: usize = 10; + +/// PayTube final transaction settler. +pub struct PayTubeSettler<'a> { + instructions: Vec, + keys: &'a [Keypair], + rpc_client: &'a RpcClient, +} + +impl<'a> PayTubeSettler<'a> { + /// Create a new instance of a `PayTubeSettler` by tallying up all + /// transfers into a ledger. + pub fn new( + rpc_client: &'a RpcClient, + paytube_transactions: &[PayTubeTransaction], + svm_output: LoadAndExecuteSanitizedTransactionsOutput, + keys: &'a [Keypair], + ) -> Self { + // Build the ledger from the processed PayTube transactions. + let ledger = Ledger::new(paytube_transactions, svm_output); + + // Build the Solana instructions from the ledger. + let instructions = ledger.generate_base_chain_instructions(); + + Self { + instructions, + keys, + rpc_client, + } + } + + /// Count how many settlement transactions are estimated to be required. + pub(crate) fn num_transactions(&self) -> usize { + self.instructions.len().div_ceil(CHUNK_SIZE) + } + + /// Settle the payment channel results to the Solana blockchain. + pub fn process_settle(&self) { + let recent_blockhash = self.rpc_client.get_latest_blockhash().unwrap(); + self.instructions.chunks(CHUNK_SIZE).for_each(|chunk| { + let transaction = SolanaTransaction::new_signed_with_payer( + chunk, + Some(&self.keys[0].pubkey()), + self.keys, + recent_blockhash, + ); + self.rpc_client + .send_and_confirm_transaction_with_spinner_and_config( + &transaction, + CommitmentConfig::processed(), + RpcSendTransactionConfig { + skip_preflight: true, + ..Default::default() + }, + ) + .unwrap(); + }); + } +} diff --git a/svm/examples/paytube/src/transaction.rs b/svm/examples/paytube/src/transaction.rs new file mode 100644 index 00000000000000..8e27a3dbb31f03 --- /dev/null +++ b/svm/examples/paytube/src/transaction.rs @@ -0,0 +1,83 @@ +//! PayTube's custom transaction format, tailored specifically for SOL or SPL +//! token transfers. +//! +//! Mostly for demonstration purposes, to show how projects may use completely +//! different transactions in their protocol, then convert the resulting state +//! transitions into the necessary transactions for the base chain - in this +//! case Solana. + +use { + solana_sdk::{ + instruction::Instruction as SolanaInstruction, + pubkey::Pubkey, + system_instruction, + transaction::{ + SanitizedTransaction as SolanaSanitizedTransaction, Transaction as SolanaTransaction, + }, + }, + spl_associated_token_account::get_associated_token_address, + std::collections::HashSet, +}; + +/// A simple PayTube transaction. Transfers SPL tokens or SOL from one account +/// to another. +/// +/// A `None` value for `mint` represents native SOL. +pub struct PayTubeTransaction { + pub mint: Option, + pub from: Pubkey, + pub to: Pubkey, + pub amount: u64, +} + +impl From<&PayTubeTransaction> for SolanaInstruction { + fn from(value: &PayTubeTransaction) -> Self { + let PayTubeTransaction { + mint, + from, + to, + amount, + } = value; + if let Some(mint) = mint { + let source_pubkey = get_associated_token_address(from, mint); + let destination_pubkey = get_associated_token_address(to, mint); + return spl_token::instruction::transfer( + &spl_token::id(), + &source_pubkey, + &destination_pubkey, + from, + &[], + *amount, + ) + .unwrap(); + } + system_instruction::transfer(from, to, *amount) + } +} + +impl From<&PayTubeTransaction> for SolanaTransaction { + fn from(value: &PayTubeTransaction) -> Self { + SolanaTransaction::new_with_payer(&[SolanaInstruction::from(value)], Some(&value.from)) + } +} + +impl From<&PayTubeTransaction> for SolanaSanitizedTransaction { + fn from(value: &PayTubeTransaction) -> Self { + SolanaSanitizedTransaction::try_from_legacy_transaction( + SolanaTransaction::from(value), + &HashSet::new(), + ) + .unwrap() + } +} + +/// Create a batch of Solana transactions, for the Solana SVM's transaction +/// processor, from a batch of PayTube instructions. +pub fn create_svm_transactions( + paytube_transactions: &[PayTubeTransaction], +) -> Vec { + paytube_transactions + .iter() + .map(SolanaSanitizedTransaction::from) + .collect() +} diff --git a/svm/examples/paytube/tests/native_sol.rs b/svm/examples/paytube/tests/native_sol.rs new file mode 100644 index 00000000000000..98a6dd30670e0e --- /dev/null +++ b/svm/examples/paytube/tests/native_sol.rs @@ -0,0 +1,72 @@ +mod setup; + +use { + setup::{system_account, TestValidatorContext}, + solana_sdk::{signature::Keypair, signer::Signer}, + solana_svm_example_paytube::{transaction::PayTubeTransaction, PayTubeChannel}, +}; + +#[test] +fn test_native_sol() { + let alice = Keypair::new(); + let bob = Keypair::new(); + let will = Keypair::new(); + + let alice_pubkey = alice.pubkey(); + let bob_pubkey = bob.pubkey(); + let will_pubkey = will.pubkey(); + + let accounts = vec![ + (alice_pubkey, system_account(10_000_000)), + (bob_pubkey, system_account(10_000_000)), + (will_pubkey, system_account(10_000_000)), + ]; + + let context = TestValidatorContext::start_with_accounts(accounts); + let test_validator = &context.test_validator; + let payer = context.payer.insecure_clone(); + + let rpc_client = test_validator.get_rpc_client(); + + let paytube_channel = PayTubeChannel::new(vec![payer, alice, bob, will], rpc_client); + + paytube_channel.process_paytube_transfers(&[ + // Alice -> Bob 2_000_000 + PayTubeTransaction { + from: alice_pubkey, + to: bob_pubkey, + amount: 2_000_000, + mint: None, + }, + // Bob -> Will 5_000_000 + PayTubeTransaction { + from: bob_pubkey, + to: will_pubkey, + amount: 5_000_000, + mint: None, + }, + // Alice -> Bob 2_000_000 + PayTubeTransaction { + from: alice_pubkey, + to: bob_pubkey, + amount: 2_000_000, + mint: None, + }, + // Will -> Alice 1_000_000 + PayTubeTransaction { + from: will_pubkey, + to: alice_pubkey, + amount: 1_000_000, + mint: None, + }, + ]); + + // Ledger: + // Alice: 10_000_000 - 2_000_000 - 2_000_000 + 1_000_000 = 7_000_000 + // Bob: 10_000_000 + 2_000_000 - 5_000_000 + 2_000_000 = 9_000_000 + // Will: 10_000_000 + 5_000_000 - 1_000_000 = 14_000_000 + let rpc_client = test_validator.get_rpc_client(); + assert_eq!(rpc_client.get_balance(&alice_pubkey).unwrap(), 7_000_000); + assert_eq!(rpc_client.get_balance(&bob_pubkey).unwrap(), 9_000_000); + assert_eq!(rpc_client.get_balance(&will_pubkey).unwrap(), 14_000_000); +} diff --git a/svm/examples/paytube/tests/setup.rs b/svm/examples/paytube/tests/setup.rs new file mode 100644 index 00000000000000..1f2fe2a3ec0726 --- /dev/null +++ b/svm/examples/paytube/tests/setup.rs @@ -0,0 +1,94 @@ +#![allow(unused)] + +use { + solana_sdk::{ + account::{Account, AccountSharedData, ReadableAccount}, + epoch_schedule::EpochSchedule, + program_pack::Pack, + pubkey::Pubkey, + signature::Keypair, + system_program, + }, + solana_test_validator::{TestValidator, TestValidatorGenesis}, + spl_token::state::{Account as TokenAccount, Mint}, +}; + +const SLOTS_PER_EPOCH: u64 = 50; + +pub struct TestValidatorContext { + pub test_validator: TestValidator, + pub payer: Keypair, +} + +impl TestValidatorContext { + pub fn start_with_accounts(accounts: Vec<(Pubkey, AccountSharedData)>) -> Self { + // #[rustfmt::skip] + // solana_logger::setup_with_default( + // "solana_rbpf::vm=debug,\ + // solana_runtime::message_processor=debug,\ + // solana_runtime::system_instruction_processor=trace", + // ); + + let epoch_schedule = EpochSchedule::custom(SLOTS_PER_EPOCH, SLOTS_PER_EPOCH, false); + + let (test_validator, payer) = TestValidatorGenesis::default() + .epoch_schedule(epoch_schedule) + .add_accounts(accounts) + .start(); + + Self { + test_validator, + payer, + } + } +} + +pub fn get_token_account_balance(token_account: Account) -> u64 { + let state = TokenAccount::unpack(token_account.data()).unwrap(); + state.amount +} + +pub fn mint_account() -> AccountSharedData { + let data = { + let mut data = [0; Mint::LEN]; + Mint::pack( + Mint { + supply: 100_000_000, + decimals: 0, + is_initialized: true, + ..Default::default() + }, + &mut data, + ) + .unwrap(); + data + }; + let mut account = AccountSharedData::new(100_000_000, data.len(), &spl_token::id()); + account.set_data_from_slice(&data); + account +} + +pub fn system_account(lamports: u64) -> AccountSharedData { + AccountSharedData::new(lamports, 0, &system_program::id()) +} + +pub fn token_account(owner: &Pubkey, mint: &Pubkey, amount: u64) -> AccountSharedData { + let data = { + let mut data = [0; TokenAccount::LEN]; + TokenAccount::pack( + TokenAccount { + mint: *mint, + owner: *owner, + amount, + state: spl_token::state::AccountState::Initialized, + ..Default::default() + }, + &mut data, + ) + .unwrap(); + data + }; + let mut account = AccountSharedData::new(100_000_000, data.len(), &spl_token::id()); + account.set_data_from_slice(&data); + account +} diff --git a/svm/examples/paytube/tests/spl_tokens.rs b/svm/examples/paytube/tests/spl_tokens.rs new file mode 100644 index 00000000000000..88ab5db7e365ad --- /dev/null +++ b/svm/examples/paytube/tests/spl_tokens.rs @@ -0,0 +1,105 @@ +mod setup; + +use { + setup::{ + get_token_account_balance, mint_account, system_account, token_account, + TestValidatorContext, + }, + solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}, + solana_svm_example_paytube::{transaction::PayTubeTransaction, PayTubeChannel}, + spl_associated_token_account::get_associated_token_address, +}; + +#[test] +fn test_spl_tokens() { + let mint = Pubkey::new_unique(); + + let alice = Keypair::new(); + let bob = Keypair::new(); + let will = Keypair::new(); + + let alice_pubkey = alice.pubkey(); + let alice_token_account_pubkey = get_associated_token_address(&alice_pubkey, &mint); + + let bob_pubkey = bob.pubkey(); + let bob_token_account_pubkey = get_associated_token_address(&bob_pubkey, &mint); + + let will_pubkey = will.pubkey(); + let will_token_account_pubkey = get_associated_token_address(&will_pubkey, &mint); + + let accounts = vec![ + (mint, mint_account()), + (alice_pubkey, system_account(10_000_000)), + ( + alice_token_account_pubkey, + token_account(&alice_pubkey, &mint, 10), + ), + (bob_pubkey, system_account(10_000_000)), + ( + bob_token_account_pubkey, + token_account(&bob_pubkey, &mint, 10), + ), + (will_pubkey, system_account(10_000_000)), + ( + will_token_account_pubkey, + token_account(&will_pubkey, &mint, 10), + ), + ]; + + let context = TestValidatorContext::start_with_accounts(accounts); + let test_validator = &context.test_validator; + let payer = context.payer.insecure_clone(); + + let rpc_client = test_validator.get_rpc_client(); + + let paytube_channel = PayTubeChannel::new(vec![payer, alice, bob, will], rpc_client); + + paytube_channel.process_paytube_transfers(&[ + // Alice -> Bob 2 + PayTubeTransaction { + from: alice_pubkey, + to: bob_pubkey, + amount: 2, + mint: Some(mint), + }, + // Bob -> Will 5 + PayTubeTransaction { + from: bob_pubkey, + to: will_pubkey, + amount: 5, + mint: Some(mint), + }, + // Alice -> Bob 2 + PayTubeTransaction { + from: alice_pubkey, + to: bob_pubkey, + amount: 2, + mint: Some(mint), + }, + // Will -> Alice 1 + PayTubeTransaction { + from: will_pubkey, + to: alice_pubkey, + amount: 1, + mint: Some(mint), + }, + ]); + + // Ledger: + // Alice: 10 - 2 - 2 + 1 = 7 + // Bob: 10 + 2 - 5 + 2 = 9 + // Will: 10 + 5 - 1 = 14 + let rpc_client = test_validator.get_rpc_client(); + assert_eq!( + get_token_account_balance(rpc_client.get_account(&alice_token_account_pubkey).unwrap()), + 7 + ); + assert_eq!( + get_token_account_balance(rpc_client.get_account(&bob_token_account_pubkey).unwrap()), + 9 + ); + assert_eq!( + get_token_account_balance(rpc_client.get_account(&will_token_account_pubkey).unwrap()), + 14 + ); +}