diff --git a/program_ids.go b/program_ids.go index 961f4e3cf..77a55c9fa 100644 --- a/program_ids.go +++ b/program_ids.go @@ -71,6 +71,10 @@ var ( // and know they were approved by zero or more addresses // by inspecting the transaction log from a trusted provider. MemoProgramID = MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") + + // MemoProgramIDV1 is the deprecated v1 Memo program. + // Some legacy transactions still reference this program ID. + MemoProgramIDV1 = MustPublicKeyFromBase58("Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo") ) var ( diff --git a/programs/memo/Create.go b/programs/memo/Create.go index 2d5a5d302..5c1fc63ea 100644 --- a/programs/memo/Create.go +++ b/programs/memo/Create.go @@ -17,6 +17,8 @@ package memo import ( "errors" "fmt" + "unicode/utf8" + ag_binary "github.com/gagliardetto/binary" ag_solanago "github.com/gagliardetto/solana-go" ag_format "github.com/gagliardetto/solana-go/text/format" @@ -27,15 +29,16 @@ type Create struct { // The memo message Message []byte - // [0] = [SIGNER] Signer - // ··········· The account that will pay for the transaction + // [0..N] = [SIGNER] Signers + // ··········· Optional signers that approve the memo. + // ··········· If zero signers are provided, the memo is unsigned. ag_solanago.AccountMetaSlice `bin:"-" borsh_skip:"true"` } // NewMemoInstructionBuilder creates a new `Memo` instruction builder. func NewMemoInstructionBuilder() *Create { nd := &Create{ - AccountMetaSlice: make(ag_solanago.AccountMetaSlice, 1), + AccountMetaSlice: make(ag_solanago.AccountMetaSlice, 0), } return nd } @@ -46,18 +49,28 @@ func (inst *Create) SetMessage(message []byte) *Create { return inst } -// SetSigner sets the signer account +// SetSigner sets a single signer account (replaces any existing signers). func (inst *Create) SetSigner(signer ag_solanago.PublicKey) *Create { - inst.AccountMetaSlice[0] = ag_solanago.Meta(signer).SIGNER() + inst.AccountMetaSlice = ag_solanago.AccountMetaSlice{ + ag_solanago.Meta(signer).SIGNER(), + } + return inst +} + +// AddSigner appends a signer account to the list of signers. +func (inst *Create) AddSigner(signer ag_solanago.PublicKey) *Create { + inst.AccountMetaSlice = append(inst.AccountMetaSlice, ag_solanago.Meta(signer).SIGNER()) return inst } func (inst *Create) GetSigner() *ag_solanago.AccountMeta { + if len(inst.AccountMetaSlice) == 0 { + return nil + } return inst.AccountMetaSlice[0] } func (inst Create) Build() *MemoInstruction { - return &MemoInstruction{BaseVariant: ag_binary.BaseVariant{ Impl: inst, TypeID: ag_binary.NoTypeIDDefaultID, @@ -75,23 +88,22 @@ func (inst Create) ValidateAndBuild() (*MemoInstruction, error) { } func (inst *Create) Validate() error { - // Check whether all (required) parameters are set: - { - if len(inst.Message) == 0 { - return errors.New("Message not set") - } + if len(inst.Message) == 0 { + return errors.New("message not set") + } + if !utf8.Valid(inst.Message) { + return errors.New("message is not valid UTF-8") } - // Check whether all accounts are set: for accIndex, acc := range inst.AccountMetaSlice { if acc == nil { - return fmt.Errorf("ins.AccountMetaSlice[%v] is not set", accIndex) + return fmt.Errorf("ins.AccountMetaSlice[%d] is not set", accIndex) } } return nil } func (inst *Create) EncodeToTree(parent ag_treeout.Branches) { - parent.Child(ag_format.Program("Memo", ag_solanago.MemoProgramID)). + parent.Child(ag_format.Program("Memo", ProgramID)). ParentFunc(func(programBranch ag_treeout.Branches) { programBranch.Child(ag_format.Instruction("Create")). ParentFunc(func(instructionBranch ag_treeout.Branches) { @@ -102,42 +114,36 @@ func (inst *Create) EncodeToTree(parent ag_treeout.Branches) { // Accounts of the instruction: instructionBranch.Child("Accounts").ParentFunc(func(accountsBranch ag_treeout.Branches) { - accountsBranch.Child(ag_format.Meta("Signer", inst.AccountMetaSlice[0])) + for i, signer := range inst.AccountMetaSlice { + accountsBranch.Child(ag_format.Meta(fmt.Sprintf("Signer[%d]", i), signer)) + } }) }) }) } func (inst Create) MarshalWithEncoder(encoder *ag_binary.Encoder) error { - // Serialize `Message` param: - { - err := encoder.WriteBytes(inst.Message, false) - if err != nil { - return err - } - } - return nil + return encoder.WriteBytes(inst.Message, false) } func (inst *Create) UnmarshalWithDecoder(decoder *ag_binary.Decoder) error { - // Deserialize `Message` param: - { - var err error - inst.Message, err = decoder.ReadBytes(decoder.Len()) - if err != nil { - return err - } - } - return nil + var err error + inst.Message, err = decoder.ReadBytes(decoder.Len()) + return err } // NewMemoInstruction declares a new Memo instruction with the provided parameters and accounts. +// Accepts zero or more signers. If no signers are provided, the memo is unsigned. func NewMemoInstruction( // Parameters: message []byte, // Accounts: - signer ag_solanago.PublicKey) *Create { - return NewMemoInstructionBuilder(). - SetMessage(message). - SetSigner(signer) + signers ...ag_solanago.PublicKey, +) *Create { + builder := NewMemoInstructionBuilder(). + SetMessage(message) + for _, signer := range signers { + builder.AddSigner(signer) + } + return builder } diff --git a/programs/memo/Create_test.go b/programs/memo/Create_test.go new file mode 100644 index 000000000..fbd71c17a --- /dev/null +++ b/programs/memo/Create_test.go @@ -0,0 +1,240 @@ +package memo + +import ( + "bytes" + "testing" + + ag_binary "github.com/gagliardetto/binary" + ag_solanago "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Ported from https://github.com/solana-program/memo/blob/main/program/src/processor.rs + +func TestUTF8Memo(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + message []byte + wantErr string + }{ + { + name: "valid ASCII", + message: []byte("letters and such"), + }, + { + name: "valid emoji", + message: []byte("🐆"), + }, + { + name: "emoji bytes match expected encoding", + message: []byte{0xF0, 0x9F, 0x90, 0x86}, // 🐆 in UTF-8 + }, + { + name: "invalid UTF-8", + message: []byte{0xF0, 0x9F, 0x90, 0xFF}, + wantErr: "message is not valid UTF-8", + }, + { + name: "empty message", + message: []byte{}, + wantErr: "message not set", + }, + { + name: "nil message", + message: nil, + wantErr: "message not set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + inst := NewMemoInstructionBuilder().SetMessage(tt.message) + err := inst.Validate() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestSigners(t *testing.T) { + t.Parallel() + + pubkey0 := ag_solanago.NewWallet().PublicKey() + pubkey1 := ag_solanago.NewWallet().PublicKey() + pubkey2 := ag_solanago.NewWallet().PublicKey() + memo := []byte("🐆") + + t.Run("all signed", func(t *testing.T) { + t.Parallel() + inst := NewMemoInstruction(memo, pubkey0, pubkey1, pubkey2) + require.NoError(t, inst.Validate()) + require.Len(t, inst.AccountMetaSlice, 3) + for _, acc := range inst.AccountMetaSlice { + require.True(t, acc.IsSigner, "all accounts should be signers") + } + }) + + t.Run("no signers (unsigned memo)", func(t *testing.T) { + t.Parallel() + inst := NewMemoInstruction(memo) + require.NoError(t, inst.Validate()) + assert.Empty(t, inst.AccountMetaSlice) + }) + + t.Run("single signer", func(t *testing.T) { + t.Parallel() + inst := NewMemoInstruction(memo, pubkey0) + require.NoError(t, inst.Validate()) + require.Len(t, inst.AccountMetaSlice, 1) + assert.Equal(t, pubkey0, inst.AccountMetaSlice[0].PublicKey) + assert.True(t, inst.AccountMetaSlice[0].IsSigner) + }) +} + +func TestSetSignerReplaces(t *testing.T) { + t.Parallel() + + pubkey0 := ag_solanago.NewWallet().PublicKey() + pubkey1 := ag_solanago.NewWallet().PublicKey() + + inst := NewMemoInstructionBuilder(). + SetMessage([]byte("hello")). + AddSigner(pubkey0). + AddSigner(pubkey1) + require.Len(t, inst.AccountMetaSlice, 2) + + // SetSigner replaces all signers with a single one + pubkey2 := ag_solanago.NewWallet().PublicKey() + inst.SetSigner(pubkey2) + require.Len(t, inst.AccountMetaSlice, 1) + assert.Equal(t, pubkey2, inst.AccountMetaSlice[0].PublicKey) +} + +func TestGetSignerEmpty(t *testing.T) { + t.Parallel() + + inst := NewMemoInstructionBuilder() + assert.Nil(t, inst.GetSigner()) + + pubkey := ag_solanago.NewWallet().PublicKey() + inst.AddSigner(pubkey) + require.NotNil(t, inst.GetSigner()) + assert.Equal(t, pubkey, inst.GetSigner().PublicKey) +} + +func TestEncodeDecode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + message []byte + }{ + {name: "ascii", message: []byte("hello world")}, + {name: "emoji", message: []byte("🐆🦀🎉")}, + {name: "long message", message: bytes.Repeat([]byte("a"), 566)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + original := NewMemoInstructionBuilder().SetMessage(tt.message) + + buf := new(bytes.Buffer) + err := ag_binary.NewBinEncoder(buf).Encode(original) + require.NoError(t, err) + + decoded := new(Create) + err = ag_binary.NewBinDecoder(buf.Bytes()).Decode(decoded) + require.NoError(t, err) + + assert.Equal(t, original.Message, decoded.Message) + }) + } +} + +func TestEncodeDataIsRawBytes(t *testing.T) { + t.Parallel() + + // The on-chain program reads instruction_data directly as the memo string — + // no length prefix, no discriminator. + message := []byte("hello memo") + inst := NewMemoInstruction(message).Build() + + data, err := inst.Data() + require.NoError(t, err) + assert.Equal(t, message, data) +} + +func FuzzUnmarshalMemo(f *testing.F) { + f.Add([]byte("hello")) + f.Add([]byte("🐆")) + f.Add([]byte{0xFF, 0xFE}) + f.Add([]byte{}) + f.Fuzz(func(t *testing.T, data []byte) { + inst := new(Create) + _ = inst.UnmarshalWithDecoder(ag_binary.NewBinDecoder(data)) + }) +} + +func TestValidateAndBuild(t *testing.T) { + t.Parallel() + + t.Run("valid", func(t *testing.T) { + t.Parallel() + inst := NewMemoInstruction([]byte("test")) + built, err := inst.ValidateAndBuild() + require.NoError(t, err) + require.NotNil(t, built) + }) + + t.Run("invalid empty message", func(t *testing.T) { + t.Parallel() + inst := NewMemoInstructionBuilder() + _, err := inst.ValidateAndBuild() + require.Error(t, err) + }) + + t.Run("invalid UTF-8", func(t *testing.T) { + t.Parallel() + inst := NewMemoInstructionBuilder().SetMessage([]byte{0xFF, 0xFE}) + _, err := inst.ValidateAndBuild() + require.EqualError(t, err, "message is not valid UTF-8") + }) +} + +func TestDecodeInstruction(t *testing.T) { + t.Parallel() + + pubkey := ag_solanago.NewWallet().PublicKey() + message := []byte("decode test") + + inst := NewMemoInstruction(message, pubkey).Build() + data, err := inst.Data() + require.NoError(t, err) + + accounts := inst.Accounts() + decoded, err := DecodeInstruction(accounts, data) + require.NoError(t, err) + + create, ok := decoded.Impl.(*Create) + require.True(t, ok) + assert.Equal(t, message, create.Message) + require.Len(t, create.AccountMetaSlice, 1) + assert.Equal(t, pubkey, create.AccountMetaSlice[0].PublicKey) +} + +func TestProgramID(t *testing.T) { + t.Parallel() + + assert.Equal(t, ag_solanago.MemoProgramID, ProgramID) + + inst := NewMemoInstruction([]byte("test")).Build() + assert.Equal(t, ag_solanago.MemoProgramID, inst.ProgramID()) +} diff --git a/transaction_v0_test.go b/transaction_v0_test.go index 5e8fe4509..78b20c492 100644 --- a/transaction_v0_test.go +++ b/transaction_v0_test.go @@ -31,7 +31,7 @@ func TestTransactionV0(t *testing.T) { MPK("2jGpE3ADYRoJPMjyGC4tvqqDfobvdvwGr3vhd66zA1rc"), MPK("FKN5imdi7yadX4axe4hxaqBET4n6DBDRF5LKo5aBF53j"), MPK("3or4uF7ZyuQW5GGmcmdXDJasNiSZUURF2az1UrRPYQTg"), - MPK("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), + MemoProgramID, }, } // set the address tables @@ -55,7 +55,7 @@ func TestTransactionV0(t *testing.T) { MPK("G6NDx85GM481GPjT5kUBAvjLxzDMsgRMQ1EAxzGswEJn"), MPK("81o7hHYN5a8fc5wdjjfznK9ziJ9wcuKXwbZnuYpanxMQ"), SystemProgramID, - MPK("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), + MemoProgramID, MPK("FKN5imdi7yadX4axe4hxaqBET4n6DBDRF5LKo5aBF53j"), MPK("3or4uF7ZyuQW5GGmcmdXDJasNiSZUURF2az1UrRPYQTg"), MPK("2jGpE3ADYRoJPMjyGC4tvqqDfobvdvwGr3vhd66zA1rc"), @@ -88,7 +88,7 @@ func TestTransactionV0(t *testing.T) { MPK("G6NDx85GM481GPjT5kUBAvjLxzDMsgRMQ1EAxzGswEJn"), MPK("81o7hHYN5a8fc5wdjjfznK9ziJ9wcuKXwbZnuYpanxMQ"), SystemProgramID, - MPK("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), + MemoProgramID, MPK("FKN5imdi7yadX4axe4hxaqBET4n6DBDRF5LKo5aBF53j"), MPK("3or4uF7ZyuQW5GGmcmdXDJasNiSZUURF2az1UrRPYQTg"), MPK("2jGpE3ADYRoJPMjyGC4tvqqDfobvdvwGr3vhd66zA1rc"),