diff --git a/docs/spl/memo/constants.md b/docs/spl/memo/constants.md new file mode 100644 index 00000000..3c1bbd7c --- /dev/null +++ b/docs/spl/memo/constants.md @@ -0,0 +1,3 @@ +# Constants + +:::spl.memo.constants diff --git a/docs/spl/memo/instructions.md b/docs/spl/memo/instructions.md new file mode 100644 index 00000000..512dc51c --- /dev/null +++ b/docs/spl/memo/instructions.md @@ -0,0 +1,3 @@ +# Memo Program + +:::spl.memo.instructions diff --git a/docs/spl/memo/intro.md b/docs/spl/memo/intro.md new file mode 100644 index 00000000..c339c35c --- /dev/null +++ b/docs/spl/memo/intro.md @@ -0,0 +1,76 @@ +# Intro + +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 + +Solana's programming model and the definitions of the Solana terms used in this +document are available at: + +- [https://docs.solana.com/apps](https://docs.solana.com/apps) +- [https://docs.solana.com/terminology](https://docs.solana.com/terminology) + +## Source + +The Memo Program's source is available on +[github](https://github.com/solana-labs/solana-program-library) + +## Interface + +The on-chain Memo Program is written in Rust and available on crates.io as +[spl-memo](https://crates.io/crates/spl-memo) and +[docs.rs](https://docs.rs/spl-memo). + +The crate provides a `build_memo()` method to easily create a properly +constructed Instruction. + +## 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/on-chain-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. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 0e506391..1eefe21a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,5 +69,9 @@ nav: - spl/token/constants.md - spl/token/instructions.md - spl/token/core.md + - Memo Program: + - spl/memo/intro.md + - spl/memo/constants.md + - spl/memo/instructions.md extra_css: - css/mkdocstrings.css diff --git a/src/solana/message.py b/src/solana/message.py index 5710933d..0fca5053 100644 --- a/src/solana/message.py +++ b/src/solana/message.py @@ -3,9 +3,10 @@ from typing import List, NamedTuple -from solders.message import MessageHeader, Message as SoldersMessage -from solders.instruction import CompiledInstruction from solders.hash import Hash +from solders.instruction import CompiledInstruction +from solders.message import Message as SoldersMessage +from solders.message import MessageHeader from solders.pubkey import Pubkey from solana.blockhash import Blockhash diff --git a/src/solana/rpc/core.py b/src/solana/rpc/core.py index c0d7ab45..37b35800 100644 --- a/src/solana/rpc/core.py +++ b/src/solana/rpc/core.py @@ -98,7 +98,8 @@ def _get_block_args(slot: int, encoding: str) -> Tuple[types.RPCMethod, int, str def _get_block_height_args(self, commitment: Optional[Commitment]) -> Tuple[types.RPCMethod, Dict[str, Commitment]]: return types.RPCMethod("getBlockHeight"), {self._comm_key: commitment or self._commitment} - def _get_recent_performance_samples_args(self, limit: Optional[int]) -> Tuple[types.RPCMethod, Optional[int]]: + @staticmethod + def _get_recent_performance_samples_args(limit: Optional[int]) -> Tuple[types.RPCMethod, Optional[int]]: return types.RPCMethod("getRecentPerformanceSamples"), limit @staticmethod diff --git a/src/solana/system_program.py b/src/solana/system_program.py index 072c19a6..4e6ea474 100644 --- a/src/solana/system_program.py +++ b/src/solana/system_program.py @@ -2,8 +2,8 @@ from __future__ import annotations from typing import NamedTuple, Union -from solders import system_program as ssp +from solders import system_program as ssp from solana.publickey import PublicKey from solana.transaction import Transaction, TransactionInstruction diff --git a/src/solana/transaction.py b/src/solana/transaction.py index 51c871bf..080e96b4 100644 --- a/src/solana/transaction.py +++ b/src/solana/transaction.py @@ -2,15 +2,17 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, List, NamedTuple, NewType, Optional, Union, Tuple, Sequence +from typing import Any, List, NamedTuple, NewType, Optional, Sequence, Tuple, Union -from solders.signature import Signature from solders import instruction -from solders.presigner import Presigner from solders.hash import Hash +from solders.instruction import AccountMeta as SoldersAccountMeta +from solders.instruction import Instruction from solders.message import Message as SoldersMessage -from solders.transaction import Transaction as SoldersTx, TransactionError -from solders.instruction import Instruction, AccountMeta as SoldersAccountMeta +from solders.presigner import Presigner +from solders.signature import Signature +from solders.transaction import Transaction as SoldersTx +from solders.transaction import TransactionError from solana.blockhash import Blockhash from solana.keypair import Keypair @@ -333,10 +335,14 @@ def verify_signatures(self) -> bool: return False return True - def serialize(self) -> bytes: + def serialize(self, verify_signatures: bool = True) -> bytes: """Serialize the Transaction in the wire format. The Transaction must have a valid `signature` before invoking this method. + verify_signatures can be added if the signature does not require to be verified. + + Args: + verify_signatures: a bool indicating to verify the signature or not. Defaults to True Example: @@ -358,8 +364,9 @@ def serialize(self) -> bytes: if self.signatures == [Signature.default() for sig in self.signatures]: raise AttributeError("transaction has not been signed") - if not self.verify_signatures(): - raise AttributeError("transaction has not been signed correctly") + if verify_signatures: + if not self.verify_signatures(): + raise AttributeError("transaction has not been signed correctly") return bytes(self._solders) diff --git a/src/spl/memo/__init__.py b/src/spl/memo/__init__.py new file mode 100644 index 00000000..c6a5eaa8 --- /dev/null +++ b/src/spl/memo/__init__.py @@ -0,0 +1 @@ +"""Client code for interacting with the Memo Program.""" diff --git a/src/spl/memo/constants.py b/src/spl/memo/constants.py new file mode 100644 index 00000000..42100107 --- /dev/null +++ b/src/spl/memo/constants.py @@ -0,0 +1,5 @@ +"""Memo program constants.""" +from solana.publickey import PublicKey + +MEMO_PROGRAM_ID: PublicKey = PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") +"""Public key that identifies the Memo program.""" diff --git a/src/spl/memo/instructions.py b/src/spl/memo/instructions.py new file mode 100644 index 00000000..a5de826c --- /dev/null +++ b/src/spl/memo/instructions.py @@ -0,0 +1,60 @@ +"""Memo program instructions.""" +from __future__ import annotations + +from typing import NamedTuple + +from solana.publickey import PublicKey +from solana.transaction import AccountMeta, TransactionInstruction + + +class MemoParams(NamedTuple): + """Create memo transaction params.""" + + program_id: PublicKey + """Memo program account.""" + signer: PublicKey + """Signing account.""" + message: bytes + """Memo message in bytes.""" + + +def decode_create_memo(instruction: TransactionInstruction) -> MemoParams: + """Decode a create_memo_instruction and retrieve the instruction params. + + Args: + instruction: The instruction to decode. + + Returns: + The decoded instruction. + """ + return MemoParams(signer=instruction.keys[0].pubkey, message=instruction.data, program_id=instruction.program_id) + + +def create_memo(params: MemoParams) -> TransactionInstruction: + """Creates a transaction instruction that creates a memo. + + Message need to be encoded in bytes. + + Example: + + >>> signer, memo_program = PublicKey(1), PublicKey(2) + >>> message = bytes("test", encoding="utf8") + >>> params = MemoParams( + ... program_id=memo_program, + ... message=message, + ... signer=signer + ... ) + >>> type(create_memo(params)) + + + Returns: + The instruction to create a memo. + """ + keys = [ + AccountMeta(pubkey=params.signer, is_signer=True, is_writable=True), + ] + return TransactionInstruction( + keys=keys, + program_id=params.program_id, + data=params.message, + ) diff --git a/src/spl/token/_layouts.py b/src/spl/token/_layouts.py index e72848df..7868f698 100644 --- a/src/spl/token/_layouts.py +++ b/src/spl/token/_layouts.py @@ -1,7 +1,9 @@ """Token instruction layouts.""" from enum import IntEnum -from construct import Int8ul, Int32ul, Int64ul, Pass, Bytes, Struct as cStruct, Switch +from construct import Bytes, Int8ul, Int32ul, Int64ul, Pass +from construct import Struct as cStruct +from construct import Switch PUBLIC_KEY_LAYOUT = Bytes(32) diff --git a/tests/integration/test_memo.py b/tests/integration/test_memo.py new file mode 100644 index 00000000..36c14799 --- /dev/null +++ b/tests/integration/test_memo.py @@ -0,0 +1,52 @@ +"""Tests for the Memo program.""" +import pytest + +from solana.keypair import Keypair +from solana.rpc.api import Client +from solana.rpc.commitment import Finalized +from solana.transaction import Transaction +from spl.memo.constants import MEMO_PROGRAM_ID +from spl.memo.instructions import MemoParams, create_memo + +from .utils import assert_valid_response + + +@pytest.mark.integration +def test_send_memo_in_transaction(stubbed_sender: Keypair, test_http_client: Client): + """Test sending a memo instruction to localnet.""" + raw_message = "test" + message = bytes(raw_message, encoding="utf8") + # Create memo params + memo_params = MemoParams( + program_id=MEMO_PROGRAM_ID, + signer=stubbed_sender.public_key, + message=message, + ) + # Create memo instruction + memo_ix = create_memo(memo_params) + # Create transfer tx to add memo to transaction from stubbed sender + transfer_tx = Transaction().add(memo_ix) + resp = test_http_client.send_transaction(transfer_tx, stubbed_sender) + assert_valid_response(resp) + txn_id = resp["result"] + test_http_client.confirm_transaction(txn_id) + resp2 = test_http_client.get_transaction(txn_id, commitment=Finalized, encoding="jsonParsed") + log_message = resp2["result"]["meta"]["logMessages"][2].split('"') + assert log_message[1] == raw_message + assert resp2["result"]["transaction"]["message"]["instructions"][0]["parsed"] == raw_message + assert resp2["result"]["transaction"]["message"]["instructions"][0]["programId"] == str(MEMO_PROGRAM_ID) + + +@pytest.mark.integration +def test_send_invalid_memo_in_memo_params(stubbed_sender: Keypair): + """Test creating a string message instead of bytes for the message.""" + message = "test" + with pytest.raises(TypeError): + memo_params = MemoParams( + program_id=MEMO_PROGRAM_ID, + signer=stubbed_sender.public_key, + message=message, + ) + memo_ix = create_memo(memo_params) + # The test will fail here. + Transaction().add(memo_ix) diff --git a/tests/unit/test_memo_program.py b/tests/unit/test_memo_program.py new file mode 100644 index 00000000..bb933d9d --- /dev/null +++ b/tests/unit/test_memo_program.py @@ -0,0 +1,9 @@ +from solana.keypair import Keypair +from spl.memo.constants import MEMO_PROGRAM_ID +from spl.memo.instructions import MemoParams, create_memo, decode_create_memo + + +def test_memo(): + """Test creating a memo instruction.""" + params = MemoParams(signer=Keypair().public_key, message=b"test", program_id=MEMO_PROGRAM_ID) + assert decode_create_memo(create_memo(params)) == params diff --git a/tests/unit/test_transaction.py b/tests/unit/test_transaction.py index af65546d..06bc14cf 100644 --- a/tests/unit/test_transaction.py +++ b/tests/unit/test_transaction.py @@ -2,6 +2,12 @@ from base64 import b64decode, b64encode import pytest +import solders.system_program as ssp +from solders.hash import Hash +from solders.message import Message as SoldersMessage +from solders.pubkey import Pubkey +from solders.signature import Signature +from solders.transaction import Transaction as SoldersTx import solana.system_program as sp import solana.transaction as txlib @@ -9,12 +15,6 @@ from solana.keypair import Keypair from solana.message import CompiledInstruction, Message, MessageArgs, MessageHeader from solana.publickey import PublicKey -import solders.system_program as ssp -from solders.transaction import Transaction as SoldersTx -from solders.message import Message as SoldersMessage -from solders.hash import Hash -from solders.pubkey import Pubkey -from solders.signature import Signature def example_tx(stubbed_blockhash, kp0: Keypair, kp1: Keypair, kp2: Keypair) -> txlib.Transaction: @@ -177,6 +177,33 @@ def test_serialize_unsigned_transaction(stubbed_blockhash, stubbed_receiver, stu assert txn.signatures != (Signature.default(),) +def test_serialize_unsigned_transaction_without_verifying_signatures( + stubbed_blockhash, stubbed_receiver, stubbed_sender +): + """Test to serialize an unsigned transaction without verifying the signatures.""" + transfer = sp.transfer( + sp.TransferParams(from_pubkey=stubbed_sender.public_key, to_pubkey=stubbed_receiver, lamports=49) + ) + txn = txlib.Transaction(recent_blockhash=stubbed_blockhash).add(transfer) + assert txn.signatures == (Signature.default(),) + + # empty signatures should not fail + txn.serialize(verify_signatures=False) + assert txn.signatures == (Signature.default(),) + + # Set fee payer + txn.fee_payer = stubbed_sender.public_key + # Serialize message + assert b64encode(txn.serialize_message()) == ( + b"AQABAxOY9ixtGkV8UbpqS189vS9p/KkyFiGNyJl+QWvRfZPK/UOfzLZnJ/KJxcbeO8So/l3V13dwvI/xXD7u3LFK8/wAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMSa53YDeCBU8Xqd7OpDtETroO2xLG8dMcbg5KhL8FLrAQICAAEMAgAAADEAAAAAAAAA" + ) + assert len(txn.instructions) == 1 + # Signature array populated with null signatures should not fail + txn.serialize(verify_signatures=False) + assert txn.signatures == (Signature.default(),) + + def test_sort_account_metas(stubbed_blockhash): """ Test AccountMeta sorting after calling Transaction.compile_message()