diff --git a/components/brave_wallet/browser/solana_instruction_builder.cc b/components/brave_wallet/browser/solana_instruction_builder.cc index 087b805560a7..c1ccd50fb4ba 100644 --- a/components/brave_wallet/browser/solana_instruction_builder.cc +++ b/components/brave_wallet/browser/solana_instruction_builder.cc @@ -227,7 +227,7 @@ std::optional Transfer( const std::string log_wrapper = "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV"; // Init instruction data with the instruction discriminator. - std::vector instruction_data = {163, 52, 200, 231, 140, 3, 69, 186}; + std::vector instruction_data = kTransferInstructionDiscriminator; std::vector root_bytes; if (!Base58Decode(proof.root, &root_bytes, kSolanaHashSize)) { diff --git a/components/brave_wallet/browser/solana_instruction_builder.h b/components/brave_wallet/browser/solana_instruction_builder.h index 77e273f67d2f..8dbd79c91c4e 100644 --- a/components/brave_wallet/browser/solana_instruction_builder.h +++ b/components/brave_wallet/browser/solana_instruction_builder.h @@ -61,6 +61,9 @@ SolanaInstruction SetComputeUnitPrice(uint64_t price); namespace bubblegum_program { +const std::vector kTransferInstructionDiscriminator = { + 163, 52, 200, 231, 140, 3, 69, 186}; + // https://github.com/metaplex-foundation/mpl-bubblegum/blob/5b3cdfc6b236773be70dc1f0b0cb84badf881248/clients/js-solita/src/generated/instructions/transfer.ts#L81 std::optional Transfer( uint32_t canopy_depth, diff --git a/components/brave_wallet/browser/solana_instruction_data_decoder.cc b/components/brave_wallet/browser/solana_instruction_data_decoder.cc index 0af8d708dbf8..ecb3e46ab0b4 100644 --- a/components/brave_wallet/browser/solana_instruction_data_decoder.cc +++ b/components/brave_wallet/browser/solana_instruction_data_decoder.cc @@ -14,6 +14,7 @@ #include "base/no_destructor.h" #include "base/notreached.h" #include "base/sys_byteorder.h" +#include "brave/components/brave_wallet/browser/solana_instruction_builder.h" #include "brave/components/brave_wallet/common/brave_wallet_constants.h" #include "brave/components/brave_wallet/common/encoding_utils.h" #include "brave/components/brave_wallet/common/solana_utils.h" @@ -938,4 +939,21 @@ GetComputeBudgetInstructionType(const std::vector& data, return mojo_ins_type; } +bool IsCompressedNftTransferInstruction(const std::vector& data, + const std::string& program_id) { + if (program_id != mojom::kSolanaBubbleGumProgramId) { + return false; + } + + if (data.size() < + solana::bubblegum_program::kTransferInstructionDiscriminator.size()) { + return false; + } + + return std::equal( + solana::bubblegum_program::kTransferInstructionDiscriminator.begin(), + solana::bubblegum_program::kTransferInstructionDiscriminator.end(), + data.begin()); +} + } // namespace brave_wallet::solana_ins_data_decoder diff --git a/components/brave_wallet/browser/solana_instruction_data_decoder.h b/components/brave_wallet/browser/solana_instruction_data_decoder.h index 3609b9b96a63..9fbe07b3ad6b 100644 --- a/components/brave_wallet/browser/solana_instruction_data_decoder.h +++ b/components/brave_wallet/browser/solana_instruction_data_decoder.h @@ -36,6 +36,9 @@ std::optional GetComputeBudgetInstructionType(const std::vector& data, const std::string& program_id); +bool IsCompressedNftTransferInstruction(const std::vector& data, + const std::string& program_id); + std::vector GetAccountParamsForTesting( std::optional sys_ins_type, std::optional token_ins_type); diff --git a/components/brave_wallet/browser/solana_instruction_data_decoder_unittest.cc b/components/brave_wallet/browser/solana_instruction_data_decoder_unittest.cc index 9b94592655e7..78e5f341dfb8 100644 --- a/components/brave_wallet/browser/solana_instruction_data_decoder_unittest.cc +++ b/components/brave_wallet/browser/solana_instruction_data_decoder_unittest.cc @@ -426,4 +426,31 @@ TEST_F(SolanaInstructionDecoderTest, GetComputeBudgetInstructionType) { ASSERT_FALSE(instruction_type); } +TEST_F(SolanaInstructionDecoderTest, IsCompressedNftTransferInstruction) { + // Empty data is not a compressed NFT transfer instruction. + EXPECT_FALSE( + IsCompressedNftTransferInstruction({}, mojom::kSolanaBubbleGumProgramId)); + + // Compressed NFT transfer instruction is recognized. + std::vector data = { + // Contains compressed NFT transfer disctiminator. + 0xa3, 0x34, 0xc8, 0xe7, 0x8c, 0x03, 0x45, 0xba, 0x44, 0x3f, 0xca, 0x38, + 0xd1, 0x3e, 0x68, 0xf2, 0x95, 0xaf, 0xfc, 0x5f, 0x34, 0x31, 0xf3, 0x75, + 0xba, 0xd8, 0xd3, 0x82, 0x90, 0x1a, 0x94, 0x7f, 0x72, 0x96, 0xfc, 0xd8, + 0x79, 0x8a, 0xb7, 0x98, 0x3b, 0x17, 0x52, 0x74, 0x15, 0x6f, 0x94, 0x1a, + 0xe6, 0xc6, 0x1e, 0x0e, 0xb4, 0x6c, 0xcf, 0x64, 0xd6, 0x8f, 0xfd, 0x34, + 0xb7, 0x68, 0x6d, 0x97, 0x32, 0x45, 0x7e, 0x8a, 0x5c, 0x1a, 0x80, 0x31, + 0x9b, 0x22, 0x99, 0xb4, 0xc2, 0x20, 0x0e, 0x5e, 0xef, 0x2e, 0x12, 0xb1, + 0x6d, 0x4f, 0xbd, 0xf1, 0x2e, 0x11, 0xe1, 0x4f, 0xb2, 0x76, 0xc3, 0x91, + 0x21, 0x88, 0x34, 0xf3, 0x0a, 0xec, 0x39, 0x45, 0xa5, 0x15, 0x14, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xa5, 0x15, 0x14, 0x00}; + EXPECT_TRUE(IsCompressedNftTransferInstruction( + data, mojom::kSolanaBubbleGumProgramId)); + + // Compressed NFT transfer instruction is not recognized for different program + // id. + EXPECT_FALSE( + IsCompressedNftTransferInstruction(data, mojom::kSolanaTokenProgramId)); +} + } // namespace brave_wallet::solana_ins_data_decoder diff --git a/components/brave_wallet/browser/solana_message.cc b/components/brave_wallet/browser/solana_message.cc index 78cd9ecd7b51..11447e4bb7b7 100644 --- a/components/brave_wallet/browser/solana_message.cc +++ b/components/brave_wallet/browser/solana_message.cc @@ -704,6 +704,17 @@ bool SolanaMessage::UsesDurableNonce() const { return true; } +bool SolanaMessage::ContainsCompressedNftTransfer() const { + for (const auto& instruction : instructions_) { + if (solana_ins_data_decoder::IsCompressedNftTransferInstruction( + instruction.data(), instruction.GetProgramId())) { + return true; + } + } + + return false; +} + bool SolanaMessage::UsesPriorityFee() const { for (const auto& instruction : instructions_) { auto instruction_type = diff --git a/components/brave_wallet/browser/solana_message.h b/components/brave_wallet/browser/solana_message.h index 032c9e551116..778e09ba44a4 100644 --- a/components/brave_wallet/browser/solana_message.h +++ b/components/brave_wallet/browser/solana_message.h @@ -94,6 +94,10 @@ class SolanaMessage { // https://docs.rs/solana-sdk/1.18.9/src/solana_sdk/transaction/versioned/mod.rs.html#192 bool UsesDurableNonce() const; + // Returns true if the message contains a compressed NFT transfer + // instruction. + bool ContainsCompressedNftTransfer() const; + // Returns whether the priority fee was added. bool AddPriorityFee(uint32_t compute_units, uint64_t fee_per_compute_unit); diff --git a/components/brave_wallet/browser/solana_message_unittest.cc b/components/brave_wallet/browser/solana_message_unittest.cc index e643b78c4e64..4b1f85b3719a 100644 --- a/components/brave_wallet/browser/solana_message_unittest.cc +++ b/components/brave_wallet/browser/solana_message_unittest.cc @@ -12,6 +12,7 @@ #include "base/json/json_reader.h" #include "base/sys_byteorder.h" #include "base/test/gtest_util.h" +#include "brave/components/brave_wallet/browser/simple_hash_client.h" #include "brave/components/brave_wallet/browser/solana_account_meta.h" #include "brave/components/brave_wallet/browser/solana_instruction.h" #include "brave/components/brave_wallet/browser/solana_instruction_builder.h" @@ -728,4 +729,37 @@ TEST(SolanaMessageUnitTest, UsesPriorityFee) { EXPECT_TRUE(message4.UsesPriorityFee()); } +TEST(SolanaMessageUnitTest, ContainsCompressedNftTransfer) { + // Legacy message does not contain compressed NFT transfer. + SolanaMessage message1 = GetTestLegacyMessage(); + EXPECT_FALSE(message1.ContainsCompressedNftTransfer()); + + // Message with compressed NFT transfer instruction. + std::vector account_metas; + std::vector data = { + // Contains compressed NFT transfer disctiminator. + 0xa3, 0x34, 0xc8, 0xe7, 0x8c, 0x03, 0x45, 0xba, 0x44, 0x3f, 0xca, 0x38, + 0xd1, 0x3e, 0x68, 0xf2, 0x95, 0xaf, 0xfc, 0x5f, 0x34, 0x31, 0xf3, 0x75, + 0xba, 0xd8, 0xd3, 0x82, 0x90, 0x1a, 0x94, 0x7f, 0x72, 0x96, 0xfc, 0xd8, + 0x79, 0x8a, 0xb7, 0x98, 0x3b, 0x17, 0x52, 0x74, 0x15, 0x6f, 0x94, 0x1a, + 0xe6, 0xc6, 0x1e, 0x0e, 0xb4, 0x6c, 0xcf, 0x64, 0xd6, 0x8f, 0xfd, 0x34, + 0xb7, 0x68, 0x6d, 0x97, 0x32, 0x45, 0x7e, 0x8a, 0x5c, 0x1a, 0x80, 0x31, + 0x9b, 0x22, 0x99, 0xb4, 0xc2, 0x20, 0x0e, 0x5e, 0xef, 0x2e, 0x12, 0xb1, + 0x6d, 0x4f, 0xbd, 0xf1, 0x2e, 0x11, 0xe1, 0x4f, 0xb2, 0x76, 0xc3, 0x91, + 0x21, 0x88, 0x34, 0xf3, 0x0a, 0xec, 0x39, 0x45, 0xa5, 0x15, 0x14, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xa5, 0x15, 0x14, 0x00}; + auto solana_instruction = mojom::SolanaInstruction::New( + mojom::kSolanaBubbleGumProgramId, std::move(account_metas), + std::move(data), nullptr); + + std::vector mojom_instructions; + mojom_instructions.push_back(std::move(solana_instruction)); + std::vector instructions; + SolanaInstruction::FromMojomSolanaInstructions(mojom_instructions, + &instructions); + + message1.SetInstructionsForTesting(instructions); + EXPECT_TRUE(message1.ContainsCompressedNftTransfer()); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_tx_manager.cc b/components/brave_wallet/browser/solana_tx_manager.cc index bf24e0b56f11..83e95a1e5fcf 100644 --- a/components/brave_wallet/browser/solana_tx_manager.cc +++ b/components/brave_wallet/browser/solana_tx_manager.cc @@ -231,6 +231,24 @@ void SolanaTxManager::AddUnapprovedTransaction( meta->set_status(mojom::TransactionStatus::Unapproved); meta->set_chain_id(chain_id); + // Skip preflight checks for compressed NFT transfers to avoid a potential + // Solana RPC bug that incorrectly shows compute budget exceeded, causing + // simulation failures. + if (meta->tx()->message()->ContainsCompressedNftTransfer()) { + auto options = meta->tx()->send_options(); + if (options) { + if (options->skip_preflight == std::nullopt) { + // Only set skip_preflight to true if it's not already set because we + // want to respect the send options provided by dapps. + options->skip_preflight = true; + meta->tx()->set_send_options(options); + } + } else { + meta->tx()->set_send_options( + SolanaTransaction::SendOptions(std::nullopt, std::nullopt, true)); + } + } + auto internal_callback = base::BindOnce(&SolanaTxManager::ContinueAddUnapprovedTransaction, weak_ptr_factory_.GetWeakPtr(), std::move(callback)); diff --git a/components/brave_wallet/browser/solana_tx_manager_unittest.cc b/components/brave_wallet/browser/solana_tx_manager_unittest.cc index f0ddf78fe1ab..04211bf76812 100644 --- a/components/brave_wallet/browser/solana_tx_manager_unittest.cc +++ b/components/brave_wallet/browser/solana_tx_manager_unittest.cc @@ -896,6 +896,104 @@ TEST_F(SolanaTxManagerUnitTest, AddAndApproveTransaction) { SolanaSignatureStatus(72u, 0u, "", "finalized")); } +TEST_F(SolanaTxManagerUnitTest, CompressedNftTransferSendOptions) { + // Non solana compressed NFT transfer transaction (empty data is missing + // transfer instruction discriminator). + std::vector account_metas_non_compressed; + std::vector data_non_compressed; + auto solana_instruction_non_compressed = mojom::SolanaInstruction::New( + mojom::kSolanaBubbleGumProgramId, std::move(account_metas_non_compressed), + std::move(data_non_compressed), nullptr); + + std::vector instructions_non_compressed; + instructions_non_compressed.push_back( + std::move(solana_instruction_non_compressed)); + + mojom::SolanaTxDataPtr solana_tx_data_non_compressed = + mojom::SolanaTxData::New( + "", 0, "FBG2vwk2tGKHbEWHSxf7rJGDuZ2eHaaNQ8u6c7xGt9Yv", + "4szaz6FsfBzwcCJYjbwZWEw3E8rKB4tz76644C8sAZo9", "", 0, 0, + mojom::TransactionType::SolanaCompressedNftTransfer, + std::move(instructions_non_compressed), + mojom::SolanaMessageVersion::kLegacy, + mojom::SolanaMessageHeader::New(1, 0, 30), + std::vector({}), + std::vector(), nullptr, + nullptr, nullptr); + const auto& from_account = sol_account(); + + // Adding a non-compressed nft transfer should not add send options. + std::string meta_id1; + AddUnapprovedTransaction(mojom::kSolanaMainnet, + solana_tx_data_non_compressed.Clone(), from_account, + &meta_id1); + auto tx_meta1 = solana_tx_manager()->GetTxForTesting(meta_id1); + EXPECT_EQ(tx_meta1->tx()->send_options(), std::nullopt); + + // Changing the transaction type to compressed nft transfer should add + // send options, and set skip_preflight to true. + std::vector account_metas; + std::vector data = { + // Has the right discriminator. + 0xa3, 0x34, 0xc8, 0xe7, 0x8c, 0x03, 0x45, 0xba, 0x44, 0x3f, 0xca, 0x38, + 0xd1, 0x3e, 0x68, 0xf2, 0x95, 0xaf, 0xfc, 0x5f, 0x34, 0x31, 0xf3, 0x75, + 0xba, 0xd8, 0xd3, 0x82, 0x90, 0x1a, 0x94, 0x7f, 0x72, 0x96, 0xfc, 0xd8, + 0x79, 0x8a, 0xb7, 0x98, 0x3b, 0x17, 0x52, 0x74, 0x15, 0x6f, 0x94, 0x1a, + 0xe6, 0xc6, 0x1e, 0x0e, 0xb4, 0x6c, 0xcf, 0x64, 0xd6, 0x8f, 0xfd, 0x34, + 0xb7, 0x68, 0x6d, 0x97, 0x32, 0x45, 0x7e, 0x8a, 0x5c, 0x1a, 0x80, 0x31, + 0x9b, 0x22, 0x99, 0xb4, 0xc2, 0x20, 0x0e, 0x5e, 0xef, 0x2e, 0x12, 0xb1, + 0x6d, 0x4f, 0xbd, 0xf1, 0x2e, 0x11, 0xe1, 0x4f, 0xb2, 0x76, 0xc3, 0x91, + 0x21, 0x88, 0x34, 0xf3, 0x0a, 0xec, 0x39, 0x45, 0xa5, 0x15, 0x14, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xa5, 0x15, 0x14, 0x00}; + auto solana_instruction = mojom::SolanaInstruction::New( + mojom::kSolanaBubbleGumProgramId, std::move(account_metas), + std::move(data), nullptr); + + std::vector instructions; + instructions.push_back(std::move(solana_instruction)); + + mojom::SolanaTxDataPtr solana_tx_data = mojom::SolanaTxData::New( + "", 0, "FBG2vwk2tGKHbEWHSxf7rJGDuZ2eHaaNQ8u6c7xGt9Yv", + "4szaz6FsfBzwcCJYjbwZWEw3E8rKB4tz76644C8sAZo9", "", 0, 0, + mojom::TransactionType::SolanaCompressedNftTransfer, + std::move(instructions), mojom::SolanaMessageVersion::kLegacy, + mojom::SolanaMessageHeader::New(1, 0, 30), std::vector({}), + std::vector(), nullptr, + nullptr, nullptr); + + std::string meta_id2; + AddUnapprovedTransaction(mojom::kSolanaMainnet, solana_tx_data.Clone(), + from_account, &meta_id2); + auto tx_meta2 = solana_tx_manager()->GetTxForTesting(meta_id2); + ASSERT_TRUE(tx_meta2->tx()->send_options()); + ASSERT_TRUE(tx_meta2->tx()->send_options()->skip_preflight); + EXPECT_TRUE(tx_meta2->tx()->send_options()->skip_preflight.value()); + + // If send options are present but skip_preflight is null, it should be set to + // true. + solana_tx_data->send_options = mojom::SolanaSendTransactionOptions::New(); + std::string meta_id3; + AddUnapprovedTransaction(mojom::kSolanaMainnet, solana_tx_data.Clone(), + from_account, &meta_id3); + auto tx_meta3 = solana_tx_manager()->GetTxForTesting(meta_id3); + ASSERT_TRUE(tx_meta3->tx()->send_options()); + ASSERT_TRUE(tx_meta3->tx()->send_options()->skip_preflight); + EXPECT_TRUE(tx_meta3->tx()->send_options()->skip_preflight.value()); + + // If send options are present and skip_preflight is set to false, it should + // remain false. + solana_tx_data->send_options = mojom::SolanaSendTransactionOptions::New(); + solana_tx_data->send_options->skip_preflight = + mojom::OptionalSkipPreflight::New(false); + std::string meta_id4; + AddUnapprovedTransaction(mojom::kSolanaMainnet, solana_tx_data.Clone(), + from_account, &meta_id4); + auto tx_meta4 = solana_tx_manager()->GetTxForTesting(meta_id4); + ASSERT_TRUE(tx_meta4->tx()->send_options()); + ASSERT_TRUE(tx_meta4->tx()->send_options()->skip_preflight); + EXPECT_FALSE(tx_meta4->tx()->send_options()->skip_preflight.value()); +} + TEST_F(SolanaTxManagerUnitTest, OfacSanctionedToAddress) { const auto& from = sol_account(); const std::string ofac_sanctioned_to =