diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d3ddb1..91a976d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,12 +61,12 @@ jobs: uses: actions/upload-artifact@v4 with: name: fb-solana-${{ matrix.name }} - path: release/fb-solana-${{ steps.tag.outputs._tag }}-${{ matrix.name }}.* + path: release/fb-solana-${{ steps.tag.outputs._tag }}-${{ matrix.name }}.tar.gz - name: Upload artifact spl-token uses: actions/upload-artifact@v4 with: name: fb-spl-token-${{ matrix.name }} - path: release/fb-spl-token-${{ steps.tag.outputs._tag }}-${{ matrix.name }}.* + path: release/fb-spl-token-${{ steps.tag.outputs._tag }}-${{ matrix.name }}.tar.gz - name: Upload to GitHub Release uses: softprops/action-gh-release@v1 with: diff --git a/Cargo.lock b/Cargo.lock index da732a3..0f25821 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4446,6 +4446,83 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "mollusk-svm" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbeba84aa65353fb8330b8d748f26d2d6faf001077d1be945e2342e86cb59a6" +dependencies = [ + "agave-feature-set", + "agave-syscalls", + "bincode", + "mollusk-svm-error", + "mollusk-svm-keys", + "mollusk-svm-result", + "solana-account", + "solana-bpf-loader-program", + "solana-clock", + "solana-compute-budget", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-hash 3.0.0", + "solana-instruction", + "solana-instruction-error", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-logger", + "solana-precompile-error", + "solana-program-error", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-rent", + "solana-sdk-ids", + "solana-slot-hashes", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-log-collector", + "solana-svm-timings", + "solana-system-program", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction-context", +] + +[[package]] +name = "mollusk-svm-error" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "682ad3a990ae8f336ee10f402da2e900a37cff38730e29aa8cda2d82e1b2e9f1" +dependencies = [ + "solana-pubkey 3.0.0", + "thiserror 1.0.69", +] + +[[package]] +name = "mollusk-svm-keys" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97ddf2442ea621ea5ae25b0c21ae2861588ea34abce0d059cb601de24cd646f" +dependencies = [ + "mollusk-svm-error", + "solana-account", + "solana-instruction", + "solana-pubkey 3.0.0", + "solana-transaction-context", +] + +[[package]] +name = "mollusk-svm-result" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f23d402bb19bac3b25b02ada2cf1f58dd4bc8e4b12a38fc1f58aef0090ff0f6" +dependencies = [ + "solana-account", + "solana-instruction", + "solana-program-error", + "solana-pubkey 3.0.0", + "solana-rent", +] + [[package]] name = "multimap" version = "0.8.3" @@ -4805,6 +4882,16 @@ version = "6.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +[[package]] +name = "p-memo" +version = "0.0.0" +dependencies = [ + "mollusk-svm", + "pinocchio", + "pinocchio-log", + "solana-sdk", +] + [[package]] name = "parking" version = "2.2.1" @@ -5014,6 +5101,32 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pinocchio" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b971851087bc3699b001954ad02389d50c41405ece3548cbcafc88b3e20017a" + +[[package]] +name = "pinocchio-log" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd11022408f312e6179ece321c1f7dc0d1b2aa7765fddd39b2a7378d65a899e8" +dependencies = [ + "pinocchio-log-macro", +] + +[[package]] +name = "pinocchio-log-macro" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fb52edb3c5736b044cc462b0957b9767d0f574d138f4e2761438c498a4b467" +dependencies = [ + "quote", + "regex", + "syn 1.0.109", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -6495,6 +6608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "014dcb9293341241dd153b35f89ea906e4170914f4a347a95e7fb07ade47cd6f" dependencies = [ "bincode", + "qualifier_attr", "serde", "serde_bytes", "serde_derive", @@ -9910,6 +10024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de76c1564bd447a6bbc17a219400c1de9924e9ea24fe7642f672979f856490ac" dependencies = [ "bincode", + "qualifier_attr", "serde", "serde_derive", "solana-account", diff --git a/cli-tests/keys.sh b/cli-tests/keys.sh index e682e9a..615787a 100755 --- a/cli-tests/keys.sh +++ b/cli-tests/keys.sh @@ -1,3 +1,4 @@ STAKE="CTjxRPQ6D3pkkkYU3VwLDv38r4vgCiVTWrC4A731QraS" TOKEN22=UkQiTvTQ16q9PHAt2oWUWSsSrZnNN24v4whTWqapzMs NONCE=H6BUq6pXsLi1D9sxBHFA2j9U4Nrh6LiPyShVBgZnKoTh +PROGRAM=JE4YApSuex77V3sfEL913uATBD3B65bBgPA6itfkrRun \ No newline at end of file diff --git a/cli-tests/program.sh b/cli-tests/program.sh new file mode 100644 index 0000000..06581e4 --- /dev/null +++ b/cli-tests/program.sh @@ -0,0 +1,3 @@ +#!/bin/bash +source ./cli-tests/keys.sh +cargo run -p fireblocks-solana-cli -- --config "${1:?}" program deploy --verbose --program-id ./cli-tests/program-memo.json diff --git a/crates/memo-pinocchio/Cargo.toml b/crates/memo-pinocchio/Cargo.toml new file mode 100644 index 0000000..0bf4c06 --- /dev/null +++ b/crates/memo-pinocchio/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "p-memo" +version = "0.0.0" +description = "A pinocchio-based Memo program" +repository = "https://github.com/febo/p-memo" +license = "Apache-2.0" +edition = "2021" +readme = "./README.md" +publish = false + +[lib] +crate-type = ["cdylib"] + +[package.metadata.solana] +program-id = "PMemo11111111111111111111111111111111111111" + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = ['cfg(target_os, values("solana"))'] + +[dependencies] +pinocchio = "0.9" +pinocchio-log = "0.5" + +[dev-dependencies] +mollusk-svm = "0.7" +solana-sdk = { workspace = true } diff --git a/crates/memo-pinocchio/README.md b/crates/memo-pinocchio/README.md new file mode 100644 index 0000000..22476a9 --- /dev/null +++ b/crates/memo-pinocchio/README.md @@ -0,0 +1,55 @@ +# `p-memo` + +A `pinocchio`-based Memo program. + +## Overview + +`p-memo` is a reimplementation of the SPL Memo program using [`pinocchio`](https://github.com/anza-xyz/pinocchio). The program uses at most `~5%` of the compute units used by the current Memo program when signers are present; even when there are no signers, it needs only `~20%` of the current Memo program compute units. This efficiency is achieved by a combination of: +1. `pinocchio` "lazy" entrypoint +2. `sol_log_pubkey` syscall to log pubkey values +3. [`pinocchio-log`](https://crates.io/crates/pinocchio-log) to format the memo message + +Since it uses the syscall to log pubkeys, the output of the program is slightly different while loging the same information: +``` +1: Program PMemo11111111111111111111111111111111111111 invoke [1] +2: Program log: Signed by: +3: Program log: 1111111QLbz7JHiBTspS962RLKV8GndWFwiEaqKM +4: Program log: Memo (len 60): +5: Program log: why does spl memo use 36000 cus to print len 60 msg of ascii +6: Program PMemo11111111111111111111111111111111111111 consumed 537 of 1400000 compute units +7: Program PMemo11111111111111111111111111111111111111 success +``` + +Logging begins with entry into the program (`line 1`). Then there is a separate log to start the signers section (`line 2`); this is only present if there are signer accounts. After that there will be one line for each signer account (`line 3`), followed by the memo length and UTF-8 text (`line 4-5`). The program ends with the status of the instruction (`lines 6-7`). + +## Performance + +CU comsumption: + +| \# signers | p-memo | SPL Memo | +| ---------- | ----------- | --------- | +| 0 | 313 (`15%`) | 2,022 | +| 1 | 537 (`4%`) | 13,525 | +| 2 | 654 (`3%`) | 25,111 | +| 3 | 771 (`2%`) | 36,406 | + +> [!NOTE] +> Using Solana CLI `v2.2.15`. + +## Building + +To build the program from its directory: +```bash +cargo build-sbf +``` + +## Testing + +To run the tests (after building the program): +```bash +SBF_OUT_DIR=../target/deploy cargo test +``` + +## License + +The code is licensed under the [Apache License Version 2.0](LICENSE) \ No newline at end of file diff --git a/crates/memo-pinocchio/src/entrypoint.rs b/crates/memo-pinocchio/src/entrypoint.rs new file mode 100644 index 0000000..7660cce --- /dev/null +++ b/crates/memo-pinocchio/src/entrypoint.rs @@ -0,0 +1,54 @@ +use pinocchio::{ + entrypoint::{InstructionContext, MaybeAccount}, + program_error::ProgramError, + syscalls::{sol_log_, sol_log_pubkey}, + ProgramResult, +}; +use pinocchio_log::log; + +/// Process a memo instruction. +/// +/// This function processes a memo instruction by logging the public keys of the signers +/// (if any) and the memo data itself, checking if the required signatures are present; +/// when any required signature is missing, it returns `ProgramError::MissingRequiredSignature` +/// error. +pub fn process_instruction(mut context: InstructionContext) -> ProgramResult { + let mut missing_required_signature = false; + + // Validates signer accounts (if any). + + if context.remaining() > 0 { + // Logs a message indicating that there are signers. + log!("Signed by:"); + + while context.remaining() > 0 { + // Duplicated accounts are implicitly checked since at least one of the + // "copies" must be a signer. + if let MaybeAccount::Account(account) = context.next_account()? { + if account.is_signer() { + // SAFETY: Use the syscall to log the public key of the account. + unsafe { sol_log_pubkey(account.key().as_ptr()) }; + } else { + missing_required_signature = true; + } + } + } + + if missing_required_signature { + return Err(ProgramError::MissingRequiredSignature); + } + } + + // SAFETY: All accounts have been processed. + let instruction_data = unsafe { context.instruction_data_unchecked() }; + + // Logs the length of the memo message and its content. + + log!("Memo (len {}):", instruction_data.len()); + // SAFETY: The syscall will validate the UTF-8 encoding of the memo data. + unsafe { + sol_log_(instruction_data.as_ptr(), instruction_data.len() as u64); + } + + Ok(()) +} diff --git a/crates/memo-pinocchio/src/lib.rs b/crates/memo-pinocchio/src/lib.rs new file mode 100644 index 0000000..dd0444e --- /dev/null +++ b/crates/memo-pinocchio/src/lib.rs @@ -0,0 +1,23 @@ +//! A pinocchio-based Memo (aka 'p-memo') program. +//! +//! The Memo program is a simple program that validates a string of UTF-8 encoded +//! characters and verifies that any accounts provided are signers of the transaction. +//! The program also logs the memo, as well as any verified signer addresses, to the +//! transaction log, so that anyone can easily observe memos and know they were +//! approved by zero or more addresses by inspecting the transaction log from a +//! trusted provider. + +#![no_std] + +mod entrypoint; + +use pinocchio::{lazy_program_entrypoint, no_allocator, nostd_panic_handler}; + +use crate::entrypoint::process_instruction; + +// Process the input lazily. +lazy_program_entrypoint!(process_instruction); +// Disable the memory allocator. +no_allocator!(); +// Use a `no_std` panic handler. +nostd_panic_handler!(); diff --git a/crates/memo-pinocchio/tests/memo.rs b/crates/memo-pinocchio/tests/memo.rs new file mode 100644 index 0000000..3b000dc --- /dev/null +++ b/crates/memo-pinocchio/tests/memo.rs @@ -0,0 +1,144 @@ +use { + mollusk_svm::{result::Check, Mollusk}, + solana_sdk::{ + account::Account, + instruction::{AccountMeta, Instruction, InstructionError}, + program_error::ProgramError, + pubkey::Pubkey, + }, +}; + +/// Program ID for the p-memo program. +const PROGRAM_ID: Pubkey = Pubkey::from_str_const("PMemo11111111111111111111111111111111111111"); + +/// The memo to be printed. +const MEMO: &str = "why does spl memo use 36000 cus to print len 60 msg of ascii"; + +/// Creates an instruction for the p-memo program. +fn instruction(message: &[u8], signers: Option<&[Pubkey]>) -> Instruction { + let accounts = if let Some(signers) = signers { + let mut accounts = Vec::with_capacity(signers.len()); + for signer in signers { + accounts.push(AccountMeta::new_readonly(*signer, true)); + } + accounts + } else { + Vec::new() + }; + + Instruction { + program_id: PROGRAM_ID, + accounts, + data: message.to_vec(), + } +} + +#[test] +fn test_valid_ascii_no_accounts() { + let mollusk = Mollusk::new(&PROGRAM_ID, "p_memo"); + + let instruction = instruction(MEMO.as_bytes(), None); + + mollusk.process_and_validate_instruction(&instruction, &[], &[Check::success()]); +} + +#[test] +fn fail_test_invalid_ascii_no_accounts() { + let mollusk = Mollusk::new(&PROGRAM_ID, "p_memo"); + + let instruction = instruction(&[255, 255], None); + + mollusk.process_and_validate_instruction( + &instruction, + &[], + &[Check::instruction_err( + InstructionError::ProgramFailedToComplete, + )], + ); +} + +#[test] +fn test_valid_ascii_one_accounts() { + let mollusk = Mollusk::new(&PROGRAM_ID, "p_memo"); + + let signer = Pubkey::new_unique(); + let instruction = instruction(MEMO.as_bytes(), Some(&[signer])); + + mollusk.process_and_validate_instruction( + &instruction, + &[(signer, Account::default())], + &[Check::success()], + ); +} + +#[test] +fn fail_test_invalid_missing_signer() { + let mollusk = Mollusk::new(&PROGRAM_ID, "p_memo"); + + let signer = Pubkey::new_unique(); + let mut instruction = instruction(MEMO.as_bytes(), Some(&[signer])); + // Set the account to be non-signer. + instruction.accounts[0].is_signer = false; + + mollusk.process_and_validate_instruction( + &instruction, + &[(signer, Account::default())], + &[Check::err(ProgramError::MissingRequiredSignature)], + ); +} + +#[test] +fn test_valid_ascii_two_accounts() { + let mollusk = Mollusk::new(&PROGRAM_ID, "p_memo"); + + let signers = [Pubkey::new_unique(), Pubkey::new_unique()]; + let instruction = instruction(MEMO.as_bytes(), Some(&signers)); + + mollusk.process_and_validate_instruction( + &instruction, + &signers + .iter() + .map(|signer| (*signer, Account::default())) + .collect::>(), + &[Check::success()], + ); +} + +#[test] +fn test_valid_ascii_three_accounts() { + let mollusk = Mollusk::new(&PROGRAM_ID, "p_memo"); + + let signers = [ + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + ]; + let instruction = instruction(MEMO.as_bytes(), Some(&signers)); + + mollusk.process_and_validate_instruction( + &instruction, + &signers + .iter() + .map(|signer| (*signer, Account::default())) + .collect::>(), + &[Check::success()], + ); +} + +#[test] +fn test_valid_ascii_duplicated_accounts() { + let mollusk = Mollusk::new(&PROGRAM_ID, "p_memo"); + + let unique = Pubkey::new_unique(); + let duplicated = Pubkey::new_unique(); + let instruction = instruction(MEMO.as_bytes(), Some(&[duplicated, unique, duplicated])); + + mollusk.process_and_validate_instruction( + &instruction, + &[ + (duplicated, Account::default()), + (unique, Account::default()), + ], + &[Check::success()], + ); +}