diff --git a/Cargo.lock b/Cargo.lock index 14fdcdd69fe..b7407c89e2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3554,7 +3554,7 @@ dependencies = [ "solana-sdk", "solana-stake-program", "solana-vote-program", - "spl-memo 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-memo 2.0.1", "spl-token 3.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "thiserror", ] @@ -3712,17 +3712,20 @@ dependencies = [ [[package]] name = "spl-memo" version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb2b771f6146dec14ef5fbf498f9374652c54badc3befc8c40c1d426dd45d720" dependencies = [ "solana-program", ] [[package]] name = "spl-memo" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb2b771f6146dec14ef5fbf498f9374652c54badc3befc8c40c1d426dd45d720" +version = "3.0.0" dependencies = [ "solana-program", + "solana-program-test", + "solana-sdk", + "tokio 0.3.6", ] [[package]] @@ -4062,7 +4065,7 @@ name = "test-client" version = "0.1.0" dependencies = [ "solana-sdk", - "spl-memo 2.0.1", + "spl-memo 3.0.0", "spl-token 3.0.1", "spl-token-swap", ] diff --git a/docs/src/memo.md b/docs/src/memo.md index 292831a6e38..d2098e8705c 100644 --- a/docs/src/memo.md +++ b/docs/src/memo.md @@ -2,9 +2,12 @@ title: Memo Program --- -A simple program that validates a string of UTF-8 encoded characters. It can be -used to record a string on-chain, stored in the instruction data of a successful -transaction. +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. ## Background @@ -22,9 +25,54 @@ The Memo Program's source is available on ## Interface The on-chain Memo Program is written in Rust and available on crates.io as -[spl-memo](https://crates.io/crates/spl-memo). +[spl-memo](https://crates.io/crates/spl-memo) and +[docs.rs](https://docs.rs/spl-memo). -## Operational overview +The crate provides a `build_memo()` method to easily create a properly +constructed Instruction. -The Memo program attempts to UTF-8 decode the instruction data; if successfully -decoded, the instruction is successful. +## Operational Notes + +If zero accounts are provided to the signed-memo instruction, the program +succeeds when the memo is valid UTF-8, and logs the memo to the transaction log. + +If one or more accounts are provided to the signed-memo instruction, all must be +valid signers of the transaction for the instruction to succeed. + +### Logs + +This section details expected log output for memo instructions. + +Logging begins with entry into the program: +`Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr invoke [1]` + +The program will include a separate log for each verified signer: +`Program log: Signed by ` + +Then the program logs the memo length and UTF-8 text: +`Program log: Memo (len 4): "🐆"` + +If UTF-8 parsing fails, the program will log the failure point: +`Program log: Invalid UTF-8, from byte 4` + +Logging ends with the status of the instruction, one of: +`Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr success` +`Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr failed: missing required signature for instruction` +`Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr failed: invalid instruction data` + +For more information about exposing program logs on a node, head to the +[developer +docs](https://docs.solana.com/developing/deployed-programs/debugging#logging) + +### Compute Limits + +Like all programs, the Memo Program is subject to the cluster's [compute +budget](https://docs.solana.com/developing/programming-model/runtime#compute-budget). +In Memo, compute is used for parsing UTF-8, verifying signers, and logging, +limiting the memo length and number of signers that can be processed +successfully in a single instruction. The longer or more complex the UTF-8 memo, +the fewer signers can be supported, and vice versa. + +As of v1.5.1, an unsigned instruction can support single-byte UTF-8 of up to 566 +bytes. An instruction with a simple memo of 32 bytes can support up to 12 +signers. diff --git a/memo/README.md b/memo/README.md index 73744f63c6e..1cff1b99999 100644 --- a/memo/README.md +++ b/memo/README.md @@ -1,7 +1,9 @@ # Memo Program -A simple program that validates a string of UTF-8 encoded characters. It can be -used to record a string on-chain, stored in the instruction data of a successful -transaction. +A simple program that validates a string of UTF-8 encoded characters and logs it +in the transaction log. The program also verifies that any accounts provided are +signers of the transaction, and if so, logs their addresses. It can be used to +record a string on-chain, stored in the instruction data of a successful +transaction, and optionally verify the originator. Full documentation is available at https://spl.solana.com/memo diff --git a/memo/program/Cargo.toml b/memo/program/Cargo.toml index 9cfcd28cbf8..8657cc40b1e 100644 --- a/memo/program/Cargo.toml +++ b/memo/program/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spl-memo" -version = "2.0.1" +version = "3.0.0" description = "Solana Program Library Memo" authors = ["Solana Maintainers "] repository = "https://github.com/solana-labs/solana-program-library" @@ -9,10 +9,16 @@ edition = "2018" [features] no-entrypoint = [] +test-bpf = [] [dependencies] solana-program = "1.5.1" +[dev-dependencies] +solana-program-test = "1.5.1" +solana-sdk = "1.5.1" +tokio = { version = "0.3", features = ["macros"]} + [lib] crate-type = ["cdylib", "lib"] diff --git a/memo/program/program-id.md b/memo/program/program-id.md index bda1618a607..284ff6d8137 100644 --- a/memo/program/program-id.md +++ b/memo/program/program-id.md @@ -1 +1 @@ -Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo +MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr diff --git a/memo/program/run-tests.sh b/memo/program/run-tests.sh new file mode 100755 index 00000000000..802b1ee53c2 --- /dev/null +++ b/memo/program/run-tests.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -ex +cd "$(dirname "$0")" +cargo fmt -- --check +cargo clippy +cargo build +cargo build-bpf + +if [[ $1 = -v ]]; then + export RUST_LOG=solana=debug +fi + +cargo test +cargo test-bpf diff --git a/memo/program/src/entrypoint.rs b/memo/program/src/entrypoint.rs index 0852451b548..82641a41587 100644 --- a/memo/program/src/entrypoint.rs +++ b/memo/program/src/entrypoint.rs @@ -1,43 +1,16 @@ //! Program entrypoint +#![cfg(not(feature = "no-entrypoint"))] + use solana_program::{ - account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, program_error::ProgramError, - pubkey::Pubkey, + account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, pubkey::Pubkey, }; -use std::str::from_utf8; entrypoint!(process_instruction); fn process_instruction( - _program_id: &Pubkey, - _accounts: &[AccountInfo], + program_id: &Pubkey, + accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { - from_utf8(instruction_data).map_err(|_| ProgramError::InvalidInstructionData)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use solana_program::{program_error::ProgramError, pubkey::Pubkey}; - - #[test] - fn test_utf8_memo() { - let program_id = Pubkey::new(&[0; 32]); - - let string = b"letters and such"; - assert_eq!(Ok(()), process_instruction(&program_id, &[], string)); - - let emoji = "🐆".as_bytes(); - let bytes = [0xF0, 0x9F, 0x90, 0x86]; - assert_eq!(emoji, bytes); - assert_eq!(Ok(()), process_instruction(&program_id, &[], &emoji)); - - let mut bad_utf8 = bytes; - bad_utf8[3] = 0xFF; // Invalid UTF-8 byte - assert_eq!( - Err(ProgramError::InvalidInstructionData), - process_instruction(&program_id, &[], &bad_utf8) - ); - } + crate::processor::process_instruction(program_id, accounts, instruction_data) } diff --git a/memo/program/src/lib.rs b/memo/program/src/lib.rs index ba495f0e97a..4f3b5d294e5 100644 --- a/memo/program/src/lib.rs +++ b/memo/program/src/lib.rs @@ -1,11 +1,34 @@ #![deny(missing_docs)] -//! A simple program that accepts a string of encoded characters and verifies that it parses. Currently handles UTF-8. +//! A program that accepts a string of encoded characters and verifies that it parses, +//! while verifying and logging signers. Currently handles UTF-8 characters. -#[cfg(not(feature = "no-entrypoint"))] mod entrypoint; +pub mod processor; // Export current sdk types for downstream users building with a different sdk version pub use solana_program; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; -solana_program::declare_id!("Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo"); +solana_program::declare_id!("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"); + +/// Build a memo instruction, possibly signed +/// +/// Accounts expected by this instruction: +/// +/// 0. ..0+N. `[signer]` Expected signers; if zero provided, instruction will be processed as a +/// normal, unsigned spl-memo +/// +pub fn build_memo(memo: &[u8], signer_pubkeys: &[&Pubkey]) -> Instruction { + Instruction { + program_id: id(), + accounts: signer_pubkeys + .iter() + .map(|&pubkey| AccountMeta::new_readonly(*pubkey, true)) + .collect(), + data: memo.to_vec(), + } +} diff --git a/memo/program/src/processor.rs b/memo/program/src/processor.rs new file mode 100644 index 00000000000..f1cb19d95c1 --- /dev/null +++ b/memo/program/src/processor.rs @@ -0,0 +1,109 @@ +//! Program state processor + +use solana_program::{ + account_info::AccountInfo, entrypoint::ProgramResult, msg, program_error::ProgramError, + pubkey::Pubkey, +}; +use std::str::from_utf8; + +/// Instruction processor +pub fn process_instruction( + _program_id: &Pubkey, + accounts: &[AccountInfo], + input: &[u8], +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let mut missing_required_signature = false; + for account_info in account_info_iter { + if let Some(address) = account_info.signer_key() { + msg!("Signed by {:?}", address); + } else { + missing_required_signature = true; + } + } + if missing_required_signature { + return Err(ProgramError::MissingRequiredSignature); + } + + let memo = from_utf8(input).map_err(|err| { + msg!("Invalid UTF-8, from byte {}", err.valid_up_to()); + ProgramError::InvalidInstructionData + })?; + msg!("Memo (len {}): {:?}", memo.len(), memo); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use solana_program::{ + account_info::IntoAccountInfo, program_error::ProgramError, pubkey::Pubkey, + }; + use solana_sdk::account::Account; + + #[test] + fn test_utf8_memo() { + let program_id = Pubkey::new(&[0; 32]); + + let string = b"letters and such"; + assert_eq!(Ok(()), process_instruction(&program_id, &[], string)); + + let emoji = "🐆".as_bytes(); + let bytes = [0xF0, 0x9F, 0x90, 0x86]; + assert_eq!(emoji, bytes); + assert_eq!(Ok(()), process_instruction(&program_id, &[], &emoji)); + + let mut bad_utf8 = bytes; + bad_utf8[3] = 0xFF; // Invalid UTF-8 byte + assert_eq!( + Err(ProgramError::InvalidInstructionData), + process_instruction(&program_id, &[], &bad_utf8) + ); + } + + #[test] + fn test_signers() { + let program_id = Pubkey::new(&[0; 32]); + let memo = "🐆".as_bytes(); + + let pubkey0 = Pubkey::new_unique(); + let pubkey1 = Pubkey::new_unique(); + let pubkey2 = Pubkey::new_unique(); + let mut account0 = Account::default(); + let mut account1 = Account::default(); + let mut account2 = Account::default(); + + let signed_account_infos = vec![ + (&pubkey0, true, &mut account0).into_account_info(), + (&pubkey1, true, &mut account1).into_account_info(), + (&pubkey2, true, &mut account2).into_account_info(), + ]; + assert_eq!( + Ok(()), + process_instruction(&program_id, &signed_account_infos, memo) + ); + + assert_eq!(Ok(()), process_instruction(&program_id, &[], memo)); + + let unsigned_account_infos = vec![ + (&pubkey0, false, &mut account0).into_account_info(), + (&pubkey1, false, &mut account1).into_account_info(), + (&pubkey2, false, &mut account2).into_account_info(), + ]; + assert_eq!( + Err(ProgramError::MissingRequiredSignature), + process_instruction(&program_id, &unsigned_account_infos, memo) + ); + + let partially_signed_account_infos = vec![ + (&pubkey0, true, &mut account0).into_account_info(), + (&pubkey1, false, &mut account1).into_account_info(), + (&pubkey2, true, &mut account2).into_account_info(), + ]; + assert_eq!( + Err(ProgramError::MissingRequiredSignature), + process_instruction(&program_id, &partially_signed_account_infos, memo) + ); + } +} diff --git a/memo/program/tests/functional.rs b/memo/program/tests/functional.rs new file mode 100644 index 00000000000..41f917d5f40 --- /dev/null +++ b/memo/program/tests/functional.rs @@ -0,0 +1,207 @@ +#![cfg(feature = "test-bpf")] + +use solana_program::{ + instruction::{AccountMeta, Instruction, InstructionError}, + pubkey::Pubkey, +}; +use solana_program_test::*; +use solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; +use spl_memo::*; + +fn program_test() -> ProgramTest { + ProgramTest::new("spl_memo", id(), processor!(processor::process_instruction)) +} + +#[tokio::test] +async fn test_memo_signing() { + let memo = "🐆".as_bytes(); + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + + let keypairs = vec![Keypair::new(), Keypair::new(), Keypair::new()]; + let pubkeys: Vec = keypairs.iter().map(|keypair| keypair.pubkey()).collect(); + + // Test complete signing + let signer_key_refs: Vec<&Pubkey> = pubkeys.iter().collect(); + let mut transaction = + Transaction::new_with_payer(&[build_memo(memo, &signer_key_refs)], Some(&payer.pubkey())); + let mut signers = vec![&payer]; + for keypair in keypairs.iter() { + signers.push(keypair); + } + transaction.sign(&signers, recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + // Test unsigned memo + let mut transaction = + Transaction::new_with_payer(&[build_memo(memo, &[])], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + // Demonstrate success on signature provided, regardless of specific memo AccountMeta + let mut transaction = Transaction::new_with_payer( + &[Instruction { + program_id: id(), + accounts: vec![ + AccountMeta::new_readonly(keypairs[0].pubkey(), true), + AccountMeta::new_readonly(keypairs[1].pubkey(), true), + AccountMeta::new_readonly(payer.pubkey(), false), + ], + data: memo.to_vec(), + }], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &keypairs[0], &keypairs[1]], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + // Test missing signer(s) + let mut transaction = Transaction::new_with_payer( + &[Instruction { + program_id: id(), + accounts: vec![ + AccountMeta::new_readonly(keypairs[0].pubkey(), true), + AccountMeta::new_readonly(keypairs[1].pubkey(), false), + AccountMeta::new_readonly(keypairs[2].pubkey(), true), + ], + data: memo.to_vec(), + }], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &keypairs[0], &keypairs[2]], recent_blockhash); + assert_eq!( + banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(), + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature) + ); + + let mut transaction = Transaction::new_with_payer( + &[Instruction { + program_id: id(), + accounts: vec![ + AccountMeta::new_readonly(keypairs[0].pubkey(), false), + AccountMeta::new_readonly(keypairs[1].pubkey(), false), + AccountMeta::new_readonly(keypairs[2].pubkey(), false), + ], + data: memo.to_vec(), + }], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer], recent_blockhash); + assert_eq!( + banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(), + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature) + ); + + // Test invalid utf-8; demonstrate log + let invalid_utf8 = [0xF0, 0x9F, 0x90, 0x86, 0xF0, 0x9F, 0xFF, 0x86]; + let mut transaction = + Transaction::new_with_payer(&[build_memo(&invalid_utf8, &[])], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + assert_eq!( + banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(), + TransactionError::InstructionError(0, InstructionError::InvalidInstructionData) + ); +} + +#[tokio::test] +async fn test_memo_compute_limits() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + + // Test memo length + let mut memo = vec![]; + for _ in 0..1000 { + let mut vec = vec![0x53, 0x4F, 0x4C]; + memo.append(&mut vec); + } + + let mut transaction = + Transaction::new_with_payer(&[build_memo(&memo[..566], &[])], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let mut transaction = + Transaction::new_with_payer(&[build_memo(&memo[..567], &[])], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + assert_eq!( + banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(), + TransactionError::InstructionError(0, InstructionError::ProgramFailedToComplete) + ); + + let mut memo = vec![]; + for _ in 0..100 { + let mut vec = vec![0xE2, 0x97, 0x8E]; + memo.append(&mut vec); + } + + let mut transaction = + Transaction::new_with_payer(&[build_memo(&memo[..60], &[])], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let mut transaction = + Transaction::new_with_payer(&[build_memo(&memo[..63], &[])], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + assert_eq!( + banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(), + TransactionError::InstructionError(0, InstructionError::ProgramFailedToComplete) + ); + + // Test num signers with 32-byte memo + let memo = Pubkey::new_unique().to_bytes(); + let mut keypairs = vec![]; + for _ in 0..20 { + keypairs.push(Keypair::new()); + } + let pubkeys: Vec = keypairs.iter().map(|keypair| keypair.pubkey()).collect(); + let signer_key_refs: Vec<&Pubkey> = pubkeys.iter().collect(); + + let mut signers = vec![&payer]; + for keypair in keypairs[..12].iter() { + signers.push(keypair); + } + let mut transaction = Transaction::new_with_payer( + &[build_memo(&memo, &signer_key_refs[..12])], + Some(&payer.pubkey()), + ); + transaction.sign(&signers, recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let mut signers = vec![&payer]; + for keypair in keypairs[..13].iter() { + signers.push(keypair); + } + let mut transaction = Transaction::new_with_payer( + &[build_memo(&memo, &signer_key_refs[..13])], + Some(&payer.pubkey()), + ); + transaction.sign(&signers, recent_blockhash); + assert_eq!( + banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(), + TransactionError::InstructionError(0, InstructionError::ProgramFailedToComplete) + ); +}