diff --git a/android/java/org/chromium/chrome/browser/crypto_wallet/util/AsyncUtils.java b/android/java/org/chromium/chrome/browser/crypto_wallet/util/AsyncUtils.java index 986b1472d08..5a4fa5caefa 100644 --- a/android/java/org/chromium/chrome/browser/crypto_wallet/util/AsyncUtils.java +++ b/android/java/org/chromium/chrome/browser/crypto_wallet/util/AsyncUtils.java @@ -13,8 +13,10 @@ import org.chromium.brave_wallet.mojom.AssetRatioService; import org.chromium.brave_wallet.mojom.AssetTimePrice; import org.chromium.brave_wallet.mojom.BlockchainRegistry; import org.chromium.brave_wallet.mojom.BlockchainToken; +import org.chromium.brave_wallet.mojom.BraveWalletConstants; import org.chromium.brave_wallet.mojom.JsonRpcService; import org.chromium.brave_wallet.mojom.NetworkInfo; +import org.chromium.brave_wallet.mojom.SolanaFeeEstimation; import org.chromium.brave_wallet.mojom.SolanaTxManagerProxy; import org.chromium.brave_wallet.mojom.TransactionInfo; import org.chromium.brave_wallet.mojom.TxService; @@ -324,7 +326,7 @@ public class AsyncUtils { } public static class GetSolanaEstimatedTxFeeResponseContext extends SingleResponseBaseContext - implements SolanaTxManagerProxy.GetEstimatedTxFee_Response { + implements SolanaTxManagerProxy.GetSolanaTxFeeEstimation_Response { public Long fee; public Integer error; public String errorMessage; @@ -335,8 +337,11 @@ public class AsyncUtils { } @Override - public void call(long fee, int error, String errorMessage) { - this.fee = fee; + public void call(SolanaFeeEstimation fee, int error, String errorMessage) { + this.fee = + fee.baseFee + + (((long) fee.computeUnits * fee.feePerComputeUnit) + / BraveWalletConstants.MICRO_LAMPORTS_PER_LAMPORT); this.error = error; this.errorMessage = errorMessage; super.fireResponseCompleteCallback(); diff --git a/android/java/org/chromium/chrome/browser/crypto_wallet/util/SolanaTransactionsGasHelper.java b/android/java/org/chromium/chrome/browser/crypto_wallet/util/SolanaTransactionsGasHelper.java index 59c1cf82e12..7cbd58895ce 100644 --- a/android/java/org/chromium/chrome/browser/crypto_wallet/util/SolanaTransactionsGasHelper.java +++ b/android/java/org/chromium/chrome/browser/crypto_wallet/util/SolanaTransactionsGasHelper.java @@ -60,20 +60,23 @@ public class SolanaTransactionsGasHelper { estimatesContexts.add(estimatesContext); if (mActivity.get() != null) - mActivity.get().getSolanaTxManagerProxy().getEstimatedTxFee( - txInfo.chainId, txInfo.id, estimatesContext); + mActivity + .get() + .getSolanaTxManagerProxy() + .getSolanaTxFeeEstimation(txInfo.chainId, txInfo.id, estimatesContext); } - estimatesMultiResponse.setWhenAllCompletedAction(() -> { - for (AsyncUtils.GetSolanaEstimatedTxFeeResponseContext estimatesContext : - estimatesContexts) { - if (estimatesContext.error != SolanaProviderError.SUCCESS) { - continue; - } - mPerTxFee.put(estimatesContext.txMetaId, estimatesContext.fee); - } + estimatesMultiResponse.setWhenAllCompletedAction( + () -> { + for (AsyncUtils.GetSolanaEstimatedTxFeeResponseContext estimatesContext : + estimatesContexts) { + if (estimatesContext.error != SolanaProviderError.SUCCESS) { + continue; + } + mPerTxFee.put(estimatesContext.txMetaId, estimatesContext.fee); + } - runWhenDone.run(); - }); + runWhenDone.run(); + }); } } diff --git a/browser/brave_wallet/solana_provider_browsertest.cc b/browser/brave_wallet/solana_provider_browsertest.cc index a81f91e2ff1..e3077d2313d 100644 --- a/browser/brave_wallet/solana_provider_browsertest.cc +++ b/browser/brave_wallet/solana_provider_browsertest.cc @@ -398,12 +398,70 @@ class SolanaProviderTest : public InProcessBrowserTest { } else if (*method == "getBlockHeight") { std::string reply = R"({ "jsonrpc": "2.0", "id": 1, "result": 1233 })"; http_response->set_content(reply); + } else if (*method == "getLatestBlockhash") { + std::string reply = R"({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "context": { + "slot": 1069 + }, + "value": { + "blockhash": "EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N", + "lastValidBlockHeight": 18446744073709551615 + } + } + })"; + http_response->set_content(reply); + } else if (*method == "simulateTransaction") { + std::string reply = R"({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "context": { + "apiVersion": "1.17.25", + "slot": 259225005 + }, + "value": { + "accounts": null, + "err": null, + "logs": [ + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY invoke [1]", + "Program log: Instruction: Transfer", + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY success" + ], + "returnData": null, + "unitsConsumed": 69017 + } + } + })"; + http_response->set_content(reply); + } else if (*method == "getSignatureStatuses") { + std::string reply = + R"({"jsonrpc":"2.0", "id":1, "result":"signature status not provided"})"; + http_response->set_content(reply); + } else if (*method == "getFeeForMessage") { + std::string reply = + R"({"jsonrpc":"2.0", "id":1, "result":{"value":5000}})"; + http_response->set_content(reply); + } else if (*method == "getRecentPrioritizationFees") { + std::string reply = R"({ + "jsonrpc": "2.0", + "id": 1, + "result": [ + {"prioritizationFee": 100, "slot": 293251906}, + {"prioritizationFee": 200, "slot": 293251906}, + {"prioritizationFee": 0, "slot": 293251805} + ] + })"; + http_response->set_content(reply); } else { - http_response->set_content(R"({ - "jsonrpc": "2.0", - "id": 1, - "result": "ns1aBL6AowxpiPzQL3ZeBK1RpCSLq1VfhqNw9KFSsytayARYdYrqrmbmhaizUTTkT4SXEnjnbVmPBrie3o9yuyB" - })"); + std::string reply = R"({ + "jsonrpc": "2.0", + "id": 1, + "result": "ns1aBL6AowxpiPzQL3ZeBK1RpCSLq1VfhqNw9KFSsytayARYdYrqrmbmhaizUTTkT4SXEnjnbVmPBrie3o9yuyB" + })"; + http_response->set_content(reply); } return std::move(http_response); } diff --git a/browser/ui/views/toolbar/wallet_button_notification_source_browsertest.cc b/browser/ui/views/toolbar/wallet_button_notification_source_browsertest.cc index fb49cfa0921..feed40a21e0 100644 --- a/browser/ui/views/toolbar/wallet_button_notification_source_browsertest.cc +++ b/browser/ui/views/toolbar/wallet_button_notification_source_browsertest.cc @@ -9,9 +9,12 @@ #include #include "base/test/bind.h" +#include "base/test/values_test_util.h" +#include "brave/browser/brave_wallet/json_rpc_service_factory.h" #include "brave/browser/brave_wallet/keyring_service_factory.h" #include "brave/browser/brave_wallet/tx_service_factory.h" #include "brave/components/brave_wallet/browser/brave_wallet_utils.h" +#include "brave/components/brave_wallet/browser/json_rpc_service.h" #include "brave/components/brave_wallet/browser/test_utils.h" #include "brave/components/brave_wallet/common/brave_wallet.mojom.h" #include "chrome/browser/profiles/profile.h" @@ -19,6 +22,8 @@ #include "chrome/test/base/in_process_browser_test.h" #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" +#include "net/dns/mock_host_resolver.h" +#include "net/test/embedded_test_server/embedded_test_server.h" #include "testing/gtest/include/gtest/gtest.h" namespace brave { @@ -28,12 +33,21 @@ class WalletButtonNotificationSourceTest : public InProcessBrowserTest { WalletButtonNotificationSourceTest() = default; void SetUpOnMainThread() override { + host_resolver()->AddRule("*", "127.0.0.1"); + keyring_service_ = brave_wallet::KeyringServiceFactory::GetServiceForContext( browser()->profile()); tx_service_ = brave_wallet::TxServiceFactory::GetServiceForContext( browser()->profile()); + json_rpc_service_ = + brave_wallet::JsonRpcServiceFactory::GetServiceForContext( + browser()->profile()); WaitForTxStorageDelegateInitialized(tx_service_->GetDelegateForTesting()); + + StartRPCServer( + base::BindRepeating(&WalletButtonNotificationSourceTest::HandleRequest, + base::Unretained(this))); } ~WalletButtonNotificationSourceTest() override = default; @@ -51,9 +65,152 @@ class WalletButtonNotificationSourceTest : public InProcessBrowserTest { brave_wallet::kTestWalletPassword); } + std::unique_ptr HandleRequest( + const net::test_server::HttpRequest& request) { + std::unique_ptr http_response( + new net::test_server::BasicHttpResponse()); + http_response->set_code(net::HTTP_OK); + http_response->set_content_type("application/json"); + std::string request_path = request.GetURL().path(); + + auto body = base::test::ParseJsonDict(request.content); + auto* method = body.FindString("method"); + EXPECT_TRUE(method); + std::string reply; + + if (*method == "getBlockHeight") { + reply = R"({"jsonrpc":"2.0","id":1,"result":18446744073709551615})"; + } else if (*method == "getLatestBlockhash") { + reply = R"({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "context": { + "slot": 1069 + }, + "value": { + "blockhash": "EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N", + "lastValidBlockHeight": 18446744073709551615 + } + } + })"; + } else if (*method == "simulateTransaction") { + reply = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.17.25", + "slot": 259225005 + }, + "value": { + "accounts": null, + "err": null, + "logs": [ + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY invoke [1]", + "Program log: Instruction: Transfer", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV invoke [2]", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV consumed 39 of 183791 compute units", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV success", + "Program cmtDvXumGCrqC1Age74AVPhSRVXJMd8PJS91L8KbNCK invoke [2]", + "Program log: Instruction: ReplaceLeaf", + "Program log: Attempting to fill in proof", + "Program consumption: 148976 units remaining", + "Program log: Active Index: 4", + "Program log: Rightmost Index: 1479308", + "Program log: Buffer Size: 64", + "Program log: Leaf Index: 885106", + "Program log: Fast-forwarding proof, starting index 4", + "Program consumption: 145902 units remaining", + "Program consumption: 145795 units remaining", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV invoke [3]", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV consumed 39 of 133311 compute units", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV success", + "Program cmtDvXumGCrqC1Age74AVPhSRVXJMd8PJS91L8KbNCK consumed 36402 of 168927 compute units", + "Program cmtDvXumGCrqC1Age74AVPhSRVXJMd8PJS91L8KbNCK success", + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY consumed 69017 of 200000 compute units", + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY success" + ], + "returnData": null, + "unitsConsumed": 69017 + } + }, + "id": 1 + })"; + } else if (*method == "getSignatureStatuses") { + reply = R"("")"; + } else if (*method == "getFeeForMessage") { + reply = R"({"jsonrpc":"2.0", "id":1, "result":{"value":5000}})"; + } else if (*method == "getRecentPrioritizationFees") { + reply = R"({ + "jsonrpc": "2.0", + "result": [ + { + "prioritizationFee": 100, + "slot": 293251906 + }, + { + "prioritizationFee": 200, + "slot": 293251906 + }, + { + "prioritizationFee": 0, + "slot": 293251805 + } + ], + "id": 1 + })"; + } else { + reply = ""; + } + http_response->set_content(reply); + return std::move(http_response); + } + + void StartRPCServer( + const net::EmbeddedTestServer::HandleRequestCallback& callback) { + https_server_for_rpc()->SetSSLConfig(net::EmbeddedTestServer::CERT_OK); + https_server_for_rpc()->RegisterRequestHandler(callback); + ASSERT_TRUE(https_server_for_rpc()->Start()); + + // Update rpc url for kLocalhostChainId + brave_wallet::mojom::NetworkInfoPtr chain; + json_rpc_service_->SetNetwork(brave_wallet::mojom::kLocalhostChainId, + brave_wallet::mojom::CoinType::SOL, + std::nullopt); + base::RunLoop run_loop; + json_rpc_service_->GetNetwork( + brave_wallet::mojom::CoinType::SOL, std::nullopt, + base::BindLambdaForTesting( + [&](brave_wallet::mojom::NetworkInfoPtr info) { + chain = info.Clone(); + run_loop.Quit(); + })); + run_loop.Run(); + base::RunLoop run_loop1; + chain->rpc_endpoints = + std::vector({https_server_for_rpc()->base_url()}); + json_rpc_service_->AddChain( + std::move(chain), + base::BindLambdaForTesting([&](const std::string& chain_id, + brave_wallet::mojom::ProviderError error, + const std::string& error_message) { + ASSERT_EQ(chain_id, brave_wallet::mojom::kLocalhostChainId); + ASSERT_EQ(error, brave_wallet::mojom::ProviderError::kSuccess); + ASSERT_TRUE(error_message.empty()); + run_loop1.Quit(); + })); + run_loop1.Run(); + } + + net::EmbeddedTestServer* https_server_for_rpc() { + return &https_server_for_rpc_; + } + private: raw_ptr keyring_service_; raw_ptr tx_service_; + raw_ptr json_rpc_service_ = nullptr; + net::test_server::EmbeddedTestServer https_server_for_rpc_; }; IN_PROC_BROWSER_TEST_F(WalletButtonNotificationSourceTest, @@ -249,18 +406,32 @@ IN_PROC_BROWSER_TEST_F(WalletButtonNotificationSourceTest, auto from_account = GetAccountUtils().EnsureSolAccount(0); std::string to_account = "JDqrvDz8d8tFCADashbUKQDKfJZFobNy13ugN65t1wvV"; + const std::vector data = {2, 0, 0, 0, 128, 150, + 152, 0, 0, 0, 0, 0}; + std::vector account_metas; + auto account_meta1 = brave_wallet::mojom::SolanaAccountMeta::New( + from_account->address, nullptr, true, true); + auto account_meta2 = brave_wallet::mojom::SolanaAccountMeta::New( + to_account, nullptr, false, true); + account_metas.push_back(std::move(account_meta1)); + account_metas.push_back(std::move(account_meta2)); + + auto instruction = brave_wallet::mojom::SolanaInstruction::New( + brave_wallet::mojom::kSolanaSystemProgramId, std::move(account_metas), + data, nullptr); + std::vector instructions; + instructions.push_back(std::move(instruction)); auto tx_data = brave_wallet::mojom::SolanaTxData::New( - "" /* recent_blockhash */, 0, from_account->address, to_account, - "" /* spl_token_mint_address */, 10000000u /* lamport */, - 0 /* amount */, + "", 0, from_account->address, to_account, "", 10000000, 0, brave_wallet::mojom::TransactionType::SolanaSystemTransfer, - std::vector(), + std::move(instructions), brave_wallet::mojom::SolanaMessageVersion::kLegacy, - brave_wallet::mojom::SolanaMessageHeader::New(0, 0, 0), - std::vector(), + brave_wallet::mojom::SolanaMessageHeader::New(1, 0, 1), + std::vector({from_account->address, to_account, + brave_wallet::mojom::kSolanaSystemProgramId}), std::vector(), - nullptr, nullptr); + nullptr, nullptr, nullptr); tx_service()->AddUnapprovedTransaction( brave_wallet::mojom::TxDataUnion::NewSolanaTxData(std::move(tx_data)), diff --git a/components/brave_wallet/browser/brave_wallet_constants.h b/components/brave_wallet/browser/brave_wallet_constants.h index 2a6f54b3071..10467ba820d 100644 --- a/components/brave_wallet/browser/brave_wallet_constants.h +++ b/components/brave_wallet/browser/brave_wallet_constants.h @@ -1569,6 +1569,8 @@ const std::vector& GetEthSupportedNftInterfaces(); // Returns the URL for the Ratios service. const std::string GetAssetRatioBaseURL(); const base::flat_map& GetAnkrBlockchains(); +// https://docs.rs/solana-program/1.18.10/src/solana_program/clock.rs.html#129-131 +inline constexpr int kSolanaValidBlockHeightThreshold = 150; } // namespace brave_wallet #endif // BRAVE_COMPONENTS_BRAVE_WALLET_BROWSER_BRAVE_WALLET_CONSTANTS_H_ diff --git a/components/brave_wallet/browser/brave_wallet_p3a_unittest.cc b/components/brave_wallet/browser/brave_wallet_p3a_unittest.cc index 113cba2ecb7..59125589c2b 100644 --- a/components/brave_wallet/browser/brave_wallet_p3a_unittest.cc +++ b/components/brave_wallet/browser/brave_wallet_p3a_unittest.cc @@ -181,6 +181,55 @@ class BraveWalletP3AUnitTest : public testing::Test { request.url.spec(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"" + tx_hash + "\"}"); + } else if (*method == "simulateTransaction") { + url_loader_factory_.AddResponse(request.url.spec(), R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.17.25", + "slot": 259225005 + }, + "value": { + "accounts": null, + "err": null, + "logs": [ + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY invoke [1]", + "Program log: Instruction: Transfer", + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY success" + ], + "returnData": null, + "unitsConsumed": 69017 + } + }, + "id": 1 + })"); + } else if (*method == "getRecentPrioritizationFees") { + url_loader_factory_.AddResponse(request.url.spec(), R"({ + "jsonrpc": "2.0", + "result": [ + { + "prioritizationFee": 100, + "slot": 293251906 + }, + { + "prioritizationFee": 200, + "slot": 293251906 + }, + { + "prioritizationFee": 0, + "slot": 293251805 + } + ], + "id": 1 + })"); + } else if (*method == "getFeeForMessage") { + url_loader_factory_.AddResponse(request.url.spec(), R"({ + "jsonrpc":"2.0","id":1, + "result": { + "context":{"slot":123065869}, + "value": 5000 + } + })"); } })); } @@ -646,12 +695,7 @@ TEST_F(BraveWalletP3AUnitTest, SolTransactionSentObservation) { std::vector( {from_account_address, to_account, mojom::kSolanaSystemProgramId}), std::vector(), nullptr, - nullptr); - - std::string tx_meta_id; - EXPECT_TRUE(AddUnapprovedTransaction( - mojom::TxDataUnion::NewSolanaTxData(std::move(solana_tx_data)), - mojom::kSolanaMainnet, sol_from(), &tx_meta_id)); + nullptr, nullptr); std::string tx_hash1 = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpR" @@ -662,6 +706,11 @@ TEST_F(BraveWalletP3AUnitTest, SolTransactionSentObservation) { SetSolInterceptor(latest_blockhash1, last_valid_block_height1, tx_hash1, last_valid_block_height1); + std::string tx_meta_id; + EXPECT_TRUE(AddUnapprovedTransaction( + mojom::TxDataUnion::NewSolanaTxData(std::move(solana_tx_data)), + mojom::kSolanaMainnet, sol_from(), &tx_meta_id)); + // Approve the SOL transaction EXPECT_TRUE(ApproveTransaction(mojom::CoinType::SOL, mojom::kSolanaMainnet, tx_meta_id)); diff --git a/components/brave_wallet/browser/json_rpc_service.cc b/components/brave_wallet/browser/json_rpc_service.cc index 1459ac028bd..ad9b71c4da3 100644 --- a/components/brave_wallet/browser/json_rpc_service.cc +++ b/components/brave_wallet/browser/json_rpc_service.cc @@ -3495,4 +3495,89 @@ void JsonRpcService::ContinueGetSPLTokenProgramByMint( std::move(callback).Run(program, mojom::SolanaProviderError::kSuccess, ""); } +void JsonRpcService::SimulateSolanaTransaction( + const std::string& chain_id, + const std::string& unsigned_tx, + SimulateSolanaTransactionCallback callback) { + if (unsigned_tx.empty()) { + std::move(callback).Run( + 0, mojom::SolanaProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + return; + } + + auto internal_callback = + base::BindOnce(&JsonRpcService::OnSimulateSolanaTransaction, + weak_ptr_factory_.GetWeakPtr(), std::move(callback)); + RequestInternal( + solana::simulateTransaction(unsigned_tx), true, + GetNetworkURL(prefs_, chain_id, mojom::CoinType::SOL), + std::move(internal_callback), + base::BindOnce(&ConvertUint64ToString, "/result/value/unitsConsumed")); +} + +void JsonRpcService::OnSimulateSolanaTransaction( + SimulateSolanaTransactionCallback callback, + APIRequestResult api_request_result) { + if (!api_request_result.Is2XXResponseCode()) { + std::move(callback).Run( + 0, mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + return; + } + std::optional compute_units = + solana::ParseSimulateTransaction(api_request_result.value_body()); + if (!compute_units) { + mojom::SolanaProviderError error; + std::string error_message; + ParseErrorResult( + api_request_result.value_body(), &error, &error_message); + std::move(callback).Run(0, error, error_message); + return; + } + + std::move(callback).Run(*compute_units, mojom::SolanaProviderError::kSuccess, + ""); +} + +void JsonRpcService::GetRecentSolanaPrioritizationFees( + const std::string& chain_id, + GetRecentSolanaPrioritizationFeesCallback callback) { + auto internal_callback = + base::BindOnce(&JsonRpcService::OnGetRecentSolanaPrioritizationFees, + weak_ptr_factory_.GetWeakPtr(), std::move(callback)); + RequestInternal( + solana::getRecentPrioritizationFees(), true, + GetNetworkURL(prefs_, chain_id, mojom::CoinType::SOL), + std::move(internal_callback), + base::BindOnce(&ConvertMultiUint64InObjectArrayToString, "/result", "", + std::vector{"slot", "prioritizationFee"})); +} + +void JsonRpcService::OnGetRecentSolanaPrioritizationFees( + GetRecentSolanaPrioritizationFeesCallback callback, + APIRequestResult api_request_result) { + std::vector> recent_fees; + if (!api_request_result.Is2XXResponseCode()) { + std::move(callback).Run( + recent_fees, mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + return; + } + + auto recent_fees_opt = + solana::ParseGetSolanaPrioritizationFees(api_request_result.value_body()); + if (!recent_fees_opt) { + mojom::SolanaProviderError error; + std::string error_message; + ParseErrorResult( + api_request_result.value_body(), &error, &error_message); + std::move(callback).Run(recent_fees, error, error_message); + return; + } + + std::move(callback).Run(*recent_fees_opt, + mojom::SolanaProviderError::kSuccess, ""); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/json_rpc_service.h b/components/brave_wallet/browser/json_rpc_service.h index 67092a353c2..66a20359f2e 100644 --- a/components/brave_wallet/browser/json_rpc_service.h +++ b/components/brave_wallet/browser/json_rpc_service.h @@ -556,6 +556,22 @@ class JsonRpcService : public KeyedService, public mojom::JsonRpcService { const std::vector& chain_ids, AnkrGetAccountBalancesCallback callback) override; + using SimulateSolanaTransactionCallback = + base::OnceCallback; + void SimulateSolanaTransaction(const std::string& chain_id, + const std::string& unsigned_tx, + SimulateSolanaTransactionCallback callback); + + using GetRecentSolanaPrioritizationFeesCallback = base::OnceCallback>& recent_fees, + mojom::SolanaProviderError error, + const std::string& error_message)>; + void GetRecentSolanaPrioritizationFees( + const std::string& chain_id, + GetRecentSolanaPrioritizationFeesCallback callback); + private: void FireNetworkChanged(mojom::CoinType coin, const std::string& chain_id, @@ -755,6 +771,13 @@ class JsonRpcService : public KeyedService, public mojom::JsonRpcService { void OnAnkrGetAccountBalances(AnkrGetAccountBalancesCallback callback, APIRequestResult api_request_result); + void OnSimulateSolanaTransaction(SimulateSolanaTransactionCallback callback, + APIRequestResult api_request_result); + + void OnGetRecentSolanaPrioritizationFees( + GetRecentSolanaPrioritizationFeesCallback callback, + APIRequestResult api_request_result); + scoped_refptr url_loader_factory_; std::unique_ptr api_request_helper_; std::unique_ptr api_request_helper_ens_offchain_; diff --git a/components/brave_wallet/browser/json_rpc_service_unittest.cc b/components/brave_wallet/browser/json_rpc_service_unittest.cc index 096b016ab41..e0145840c7e 100644 --- a/components/brave_wallet/browser/json_rpc_service_unittest.cc +++ b/components/brave_wallet/browser/json_rpc_service_unittest.cc @@ -1656,6 +1656,26 @@ class JsonRpcServiceUnitTest : public testing::Test { run_loop.Run(); } + void TestSimulateSolanaTransaction( + const std::string& chain_id, + uint64_t expected_compute_units, + mojom::SolanaProviderError expected_error, + const std::string& expected_error_message, + const std::string& unsigned_tx = "unsigned_tx") { + base::RunLoop run_loop; + json_rpc_service_->SimulateSolanaTransaction( + chain_id, unsigned_tx, + base::BindLambdaForTesting([&](uint64_t compute_units, + mojom::SolanaProviderError error, + const std::string& error_message) { + EXPECT_EQ(compute_units, expected_compute_units); + EXPECT_EQ(error, expected_error); + EXPECT_EQ(error_message, expected_error_message); + run_loop.Quit(); + })); + run_loop.Run(); + } + void TestGetSolanaLatestBlockhash(const std::string& chain_id, const std::string& expected_hash, uint64_t expected_last_valid_block_height, @@ -1843,6 +1863,26 @@ class JsonRpcServiceUnitTest : public testing::Test { run_loop.Run(); } + void TestGetRecentSolanaPrioritizationFees( + const std::string& chain_id, + const std::vector>& expected_recent_fees, + mojom::SolanaProviderError expected_error, + const std::string& expected_error_message) { + base::RunLoop run_loop; + json_rpc_service_->GetRecentSolanaPrioritizationFees( + chain_id, + base::BindLambdaForTesting( + [&](std::vector>& recent_fees, + mojom::SolanaProviderError error, + const std::string& error_message) { + EXPECT_EQ(error, expected_error); + EXPECT_EQ(error_message, expected_error_message); + EXPECT_EQ(expected_recent_fees, recent_fees); + run_loop.Quit(); + })); + run_loop.Run(); + } + void GetFilEstimateGas(const std::string& chain_id, const std::string& from, const std::string& to, @@ -7827,4 +7867,156 @@ TEST_F(JsonRpcServiceUnitTest, GetSPLTokenProgramByMint) { l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); } +TEST_F(JsonRpcServiceUnitTest, SimulateSolanaTransaction) { + // Empty transaction yields invalid params error + TestSimulateSolanaTransaction( + mojom::kSolanaMainnet, 0, mojom::SolanaProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS), ""); + + auto network_url = GetNetwork(mojom::kSolanaMainnet, mojom::CoinType::SOL); + std::string response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.17.25", + "slot": 259225005 + }, + "value": { + "accounts": null, + "err": null, + "logs": [ + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY invoke [1]", + "Program log: Instruction: Transfer", + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY success" + ], + "returnData": null, + "unitsConsumed": 69017 + } + }, + "id": 1 + })"; + SetInterceptor(network_url, "simulateTransaction", "", response); + + TestSimulateSolanaTransaction(mojom::kSolanaMainnet, 69017, + mojom::SolanaProviderError::kSuccess, ""); + + // Response parsing error + response = R"({"jsonrpc":"2.0","id":1,"result":0})"; + SetInterceptor(network_url, "simulateTransaction", "", response); + TestSimulateSolanaTransaction( + mojom::kSolanaMainnet, 0, mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // JSON RPC Error + response = R"({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32601, + "message": "method does not exist" + } + })"; + SetInterceptor(network_url, "simulateTransaction", "", response); + TestSimulateSolanaTransaction(mojom::kSolanaMainnet, 0, + mojom::SolanaProviderError::kMethodNotFound, + "method does not exist"); + + // HTTP error + SetHTTPRequestTimeoutInterceptor(); + TestSimulateSolanaTransaction( + mojom::kSolanaMainnet, 0, mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Blockhash not found error + response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.18.11", + "slot": 262367830 + }, + "value": { + "accounts": null, + "err": "BlockhashNotFound", + "innerInstructions": null, + "logs": [], + "returnData": null, + "unitsConsumed": 0 + } + }, + "id": 1 + })"; + SetInterceptor(network_url, "simulateTransaction", "", response); + TestSimulateSolanaTransaction( + mojom::kSolanaMainnet, 0, mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); +} + +TEST_F(JsonRpcServiceUnitTest, GetRecentSolanaPrioritizationFees) { + auto network_url = GetNetwork(mojom::kSolanaMainnet, mojom::CoinType::SOL); + + // Successful response + std::string response = R"({ + "jsonrpc": "2.0", + "result": [ + { + "prioritizationFee": 100, + "slot": 293251906 + }, + { + "prioritizationFee": 200, + "slot": 293251906 + }, + { + "prioritizationFee": 0, + "slot": 293251805 + } + ], + "id": 1 + })"; + SetInterceptor(network_url, "getRecentPrioritizationFees", "", response); + TestGetRecentSolanaPrioritizationFees( + mojom::kSolanaMainnet, + {{293251906, 100}, {293251906, 200}, {293251805, 0}}, + mojom::SolanaProviderError::kSuccess, ""); + + // Response parsing error + response = R"({ + "jsonrpc": "2.0", + "result": [ + { + }, + { + "prioritizationFee": 0, + "slot": 293251805 + } + ], + "id": 1 + })"; + SetInterceptor(network_url, "getRecentPrioritizationFees", "", response); + TestGetRecentSolanaPrioritizationFees( + mojom::kSolanaMainnet, {}, mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // JSON RPC Error + response = R"({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32601, + "message": "method does not exist" + } + })"; + SetInterceptor(network_url, "getRecentPrioritizationFees", "", response); + TestGetRecentSolanaPrioritizationFees( + mojom::kSolanaMainnet, {}, mojom::SolanaProviderError::kMethodNotFound, + "method does not exist"); + + // HTTP error + SetHTTPRequestTimeoutInterceptor(); + TestGetRecentSolanaPrioritizationFees( + mojom::kSolanaMainnet, {}, mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_instruction_builder.cc b/components/brave_wallet/browser/solana_instruction_builder.cc index 8a4a0fa6199..be04301eb69 100644 --- a/components/brave_wallet/browser/solana_instruction_builder.cc +++ b/components/brave_wallet/browser/solana_instruction_builder.cc @@ -52,7 +52,7 @@ namespace system_program { // Account references: // 0. Funding account [signer, writable]. // 1. Recipient account [non-signer, writable]. -// Insturction data: u32 instruction index and u64 lamport. +// Instruction data: u32 instruction index and u64 lamport. std::optional Transfer(const std::string& from_pubkey, const std::string& to_pubkey, uint64_t lamport) { @@ -183,6 +183,39 @@ std::optional CreateAssociatedTokenAccount( } // namespace spl_associated_token_account_program +namespace compute_budget_program { + +// Set the compute unit limit for transaction execution. +// https://docs.rs/solana-sdk/1.18.14/src/solana_sdk/compute_budget.rs.html#33 +SolanaInstruction SetComputeUnitLimit(uint32_t units) { + std::vector instruction_data = {static_cast( + mojom::SolanaComputeBudgetInstruction::kSetComputeUnitLimit)}; + + std::vector units_bytes; + UintToLEBytes(units, &units_bytes); + instruction_data.insert(instruction_data.end(), units_bytes.begin(), + units_bytes.end()); + return SolanaInstruction(mojom::kSolanaComputeBudgetProgramId, {}, + instruction_data); +} + +// Set the compute unit price for transaction execution. +// https://docs.rs/solana-sdk/1.18.14/src/solana_sdk/compute_budget.rs.html#36 +SolanaInstruction SetComputeUnitPrice(uint64_t price) { + std::vector instruction_data = {static_cast( + mojom::SolanaComputeBudgetInstruction::kSetComputeUnitPrice)}; + + std::vector price_bytes; + UintToLEBytes(price, &price_bytes); + instruction_data.insert(instruction_data.end(), price_bytes.begin(), + price_bytes.end()); + + return SolanaInstruction(mojom::kSolanaComputeBudgetProgramId, {}, + instruction_data); +} + +} // namespace compute_budget_program + } // namespace solana } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_instruction_builder.h b/components/brave_wallet/browser/solana_instruction_builder.h index b32b5d6d9fb..55e9d4747f7 100644 --- a/components/brave_wallet/browser/solana_instruction_builder.h +++ b/components/brave_wallet/browser/solana_instruction_builder.h @@ -51,6 +51,14 @@ std::optional CreateAssociatedTokenAccount( } // namespace spl_associated_token_account_program +namespace compute_budget_program { + +SolanaInstruction SetComputeUnitLimit(uint32_t units); + +SolanaInstruction SetComputeUnitPrice(uint64_t price); + +} // namespace compute_budget_program + } // namespace solana } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_instruction_builder_unittest.cc b/components/brave_wallet/browser/solana_instruction_builder_unittest.cc index e4fcb82776d..5107f5ae02f 100644 --- a/components/brave_wallet/browser/solana_instruction_builder_unittest.cc +++ b/components/brave_wallet/browser/solana_instruction_builder_unittest.cc @@ -129,6 +129,38 @@ TEST(SolanaInstructionBuilderUnitTest, CreateAssociatedTokenAccount) { } // namespace spl_associated_token_account_program +namespace compute_budget_program { + +TEST(SolanaInstructionBuilderUnitTest, SetComputeUnitLimit) { + auto instruction = SetComputeUnitLimit(1); + std::vector expected_data1 = {2, 1, 0, 0, 0}; + EXPECT_EQ(instruction, + SolanaInstruction(mojom::kSolanaComputeBudgetProgramId, {}, + base::span(expected_data1))); + + instruction = SetComputeUnitLimit(99); + std::vector expected_data2 = {2, 99, 0, 0, 0}; + EXPECT_EQ(instruction, + SolanaInstruction(mojom::kSolanaComputeBudgetProgramId, {}, + base::span(expected_data2))); +} + +TEST(SolanaInstructionBuilderUnitTest, SetComputeUnitPrice) { + auto instruction = SetComputeUnitPrice(1); + std::vector expected_data1 = {3, 1, 0, 0, 0, 0, 0, 0, 0}; + EXPECT_EQ(instruction, + SolanaInstruction(mojom::kSolanaComputeBudgetProgramId, {}, + base::span(expected_data1))); + + instruction = SetComputeUnitPrice(99); + std::vector expected_data2 = {3, 99, 0, 0, 0, 0, 0, 0, 0}; + EXPECT_EQ(instruction, + SolanaInstruction(mojom::kSolanaComputeBudgetProgramId, {}, + base::span(expected_data2))); +} + +} // namespace compute_budget_program + } // namespace solana } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_instruction_data_decoder.cc b/components/brave_wallet/browser/solana_instruction_data_decoder.cc index ea86362adfb..8dd5754880a 100644 --- a/components/brave_wallet/browser/solana_instruction_data_decoder.cc +++ b/components/brave_wallet/browser/solana_instruction_data_decoder.cc @@ -920,4 +920,25 @@ std::optional GetSystemInstructionType( return DecodeSystemInstructionType(data, offset); } +std::optional +GetComputeBudgetInstructionType(const std::vector& data, + const std::string& program_id) { + if (program_id != mojom::kSolanaComputeBudgetProgramId) { + return std::nullopt; + } + + if (data.empty()) { + return std::nullopt; + } + + uint8_t ins_type = data[0]; // First byte is the instruction type + auto mojo_ins_type = + static_cast(ins_type); + if (!mojom::IsKnownEnumValue(mojo_ins_type)) { + return std::nullopt; + } + + return mojo_ins_type; +} + } // 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 3fddcdd66cc..e20dde612ce 100644 --- a/components/brave_wallet/browser/solana_instruction_data_decoder.h +++ b/components/brave_wallet/browser/solana_instruction_data_decoder.h @@ -23,6 +23,10 @@ std::optional GetSystemInstructionType( base::span data, const std::string& program_id); +std::optional +GetComputeBudgetInstructionType(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 1fdee356c76..9b94592655e 100644 --- a/components/brave_wallet/browser/solana_instruction_data_decoder_unittest.cc +++ b/components/brave_wallet/browser/solana_instruction_data_decoder_unittest.cc @@ -10,6 +10,8 @@ #include #include +#include "brave/components/brave_wallet/browser/solana_instruction.h" +#include "brave/components/brave_wallet/browser/solana_instruction_builder.h" #include "brave/components/brave_wallet/common/brave_wallet.mojom.h" #include "brave/components/brave_wallet/common/brave_wallet_constants.h" #include "testing/gtest/include/gtest/gtest.h" @@ -385,4 +387,43 @@ TEST_F(SolanaInstructionDecoderTest, Decode_InitializeMint2) { {{"decimals", "9"}, {"mint_authority", kPubkey1}}, true); } +TEST_F(SolanaInstructionDecoderTest, GetComputeBudgetInstructionType) { + // Recognizes compute limit instruction + auto instruction = solana::compute_budget_program::SetComputeUnitLimit(0); + auto instruction_type = GetComputeBudgetInstructionType( + instruction.data(), instruction.GetProgramId()); + ASSERT_TRUE(instruction_type); + EXPECT_EQ(*instruction_type, + mojom::SolanaComputeBudgetInstruction::kSetComputeUnitLimit); + + // Recognizes compute unit instruction + instruction = solana::compute_budget_program::SetComputeUnitPrice(0); + instruction_type = GetComputeBudgetInstructionType( + instruction.data(), instruction.GetProgramId()); + ASSERT_TRUE(instruction_type); + EXPECT_EQ(*instruction_type, + mojom::SolanaComputeBudgetInstruction::kSetComputeUnitPrice); + + // Returns nullopt for system instruction + std::string from_pubkey = "5PfVQ7u360kP2uvPX9oeKtp3bpHbSYKJyQEEMKB3eF7V"; + std::string to_pubkey = "H5sGH5Avk14e6h6gV8vXuBwYxVtT1VkmmVU5sPMinHho"; + auto system_instruction = + solana::system_program::Transfer(from_pubkey, to_pubkey, 1000); + ASSERT_TRUE(system_instruction); + instruction_type = GetComputeBudgetInstructionType( + (*system_instruction).data(), (*system_instruction).GetProgramId()); + ASSERT_FALSE(instruction_type); + + // Returns nullopt for empty data + instruction_type = + GetComputeBudgetInstructionType({}, mojom::kSolanaComputeBudgetProgramId); + ASSERT_FALSE(instruction_type); + + // Returns nullopt for invalid instruction type + std::vector invalid_data = {255}; // 255 is not a valid enum value + instruction_type = GetComputeBudgetInstructionType( + invalid_data, mojom::kSolanaComputeBudgetProgramId); + ASSERT_FALSE(instruction_type); +} + } // 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 6ead9399e41..92970918770 100644 --- a/components/brave_wallet/browser/solana_message.cc +++ b/components/brave_wallet/browser/solana_message.cc @@ -15,6 +15,7 @@ #include "base/strings/string_number_conversions.h" #include "base/values.h" #include "brave/components/brave_wallet/browser/solana_compiled_instruction.h" +#include "brave/components/brave_wallet/browser/solana_instruction_builder.h" #include "brave/components/brave_wallet/browser/solana_instruction_data_decoder.h" #include "brave/components/brave_wallet/common/brave_wallet_constants.h" #include "brave/components/brave_wallet/common/encoding_utils.h" @@ -140,34 +141,18 @@ std::optional SolanaMessage::CreateLegacyMessage( std::vector unique_account_metas; GetUniqueAccountMetas(fee_payer, instructions, &unique_account_metas); std::vector static_accounts; + + // Check for non-legacy meta for (const auto& meta : unique_account_metas) { - if (meta.address_table_lookup_index) { // Not legacy. + if (meta.address_table_lookup_index) { return std::nullopt; } + } - auto addr = SolanaAddress::FromBase58(meta.pubkey); - if (!addr) { - return std::nullopt; - } - - if (meta.is_signer) { - num_required_signatures++; - } - if (meta.is_signer && !meta.is_writable) { - num_readonly_signed_accounts++; - } - if (!meta.is_signer && !meta.is_writable) { - num_readonly_unsigned_accounts++; - } - - if (num_required_signatures > UINT8_MAX || - num_readonly_signed_accounts > UINT8_MAX || - num_readonly_unsigned_accounts > UINT8_MAX || - static_accounts.size() == UINT8_MAX) { - return std::nullopt; - } - - static_accounts.emplace_back(*addr); + if (!ProcessAccountMetas( + unique_account_metas, static_accounts, num_required_signatures, + num_readonly_signed_accounts, num_readonly_unsigned_accounts)) { + return std::nullopt; } return SolanaMessage( @@ -719,4 +704,100 @@ bool SolanaMessage::UsesDurableNonce() const { return true; } +bool SolanaMessage::UsesPriorityFee() const { + for (const auto& instruction : instructions_) { + auto instruction_type = + solana_ins_data_decoder::GetComputeBudgetInstructionType( + instruction.data(), instruction.GetProgramId()); + if (instruction_type == + mojom::SolanaComputeBudgetInstruction::kSetComputeUnitPrice || + instruction_type == + mojom::SolanaComputeBudgetInstruction::kSetComputeUnitLimit) { + return true; + } + } + + return false; +} + +bool SolanaMessage::AddPriorityFee(uint32_t compute_units, + uint64_t fee_per_compute_unit) { + SolanaInstruction modify_compute_units_instruction = + solana::compute_budget_program::SetComputeUnitLimit(compute_units); + SolanaInstruction add_priority_fee_instruction = + solana::compute_budget_program::SetComputeUnitPrice(fee_per_compute_unit); + + // Do not add a priority fee if there already is one added. + if (UsesPriorityFee()) { + return false; + } + + if (UsesDurableNonce()) { + // The first instruction should remain the advance nonce instruction. + // https://solana.com/developers/guides/advanced/how-to-use-priority-fees#special-considerations + instructions_.insert( + instructions_.begin() + 1, + {modify_compute_units_instruction, add_priority_fee_instruction}); + } else { + instructions_.insert( + instructions_.begin(), + {modify_compute_units_instruction, add_priority_fee_instruction}); + } + + uint16_t num_required_signatures = 0; + uint16_t num_readonly_signed_accounts = 0; + uint16_t num_readonly_unsigned_accounts = 0; + std::vector unique_account_metas; + GetUniqueAccountMetas(fee_payer_, instructions_, &unique_account_metas); + std::vector static_accounts; + + if (!ProcessAccountMetas( + unique_account_metas, static_accounts, num_required_signatures, + num_readonly_signed_accounts, num_readonly_unsigned_accounts)) { + return false; + } + + static_account_keys_ = static_accounts; + message_header_ = + SolanaMessageHeader(num_required_signatures, num_readonly_signed_accounts, + num_readonly_unsigned_accounts); + return true; +} + +// static +bool SolanaMessage::ProcessAccountMetas( + const std::vector& unique_account_metas, + std::vector& static_accounts, + uint16_t& num_required_signatures, + uint16_t& num_readonly_signed_accounts, + uint16_t& num_readonly_unsigned_accounts) { + static_accounts.clear(); + for (const auto& meta : unique_account_metas) { + auto addr = SolanaAddress::FromBase58(meta.pubkey); + if (!addr) { + return false; + } + + if (meta.is_signer) { + num_required_signatures++; + } + if (meta.is_signer && !meta.is_writable) { + num_readonly_signed_accounts++; + } + if (!meta.is_signer && !meta.is_writable) { + num_readonly_unsigned_accounts++; + } + + if (num_required_signatures > UINT8_MAX || + num_readonly_signed_accounts > UINT8_MAX || + num_readonly_unsigned_accounts > UINT8_MAX || + static_accounts.size() == UINT8_MAX) { + return false; + } + static_accounts.emplace_back(*addr); + } + + return true; +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_message.h b/components/brave_wallet/browser/solana_message.h index 67be9c00299..032c9e55111 100644 --- a/components/brave_wallet/browser/solana_message.h +++ b/components/brave_wallet/browser/solana_message.h @@ -82,20 +82,41 @@ class SolanaMessage { return instructions_; } + const std::vector& static_account_keys() const { + return static_account_keys_; + } + + const SolanaMessageHeader& message_header() const { return message_header_; } + mojom::SolanaMessageVersion version() const { return version_; } // Returns true if transaction begins with a valid advance nonce instruction. // https://docs.rs/solana-sdk/1.18.9/src/solana_sdk/transaction/versioned/mod.rs.html#192 bool UsesDurableNonce() const; + // Returns whether the priority fee was added. + bool AddPriorityFee(uint32_t compute_units, uint64_t fee_per_compute_unit); + private: FRIEND_TEST_ALL_PREFIXES(SolanaMessageUnitTest, GetUniqueAccountMetas); + FRIEND_TEST_ALL_PREFIXES(SolanaMessageUnitTest, UsesPriorityFee); static void GetUniqueAccountMetas( const std::string& fee_payer, const std::vector& instructions, std::vector* unique_account_metas); + static bool ProcessAccountMetas( + const std::vector& unique_account_metas, + std::vector& static_accounts, + uint16_t& num_required_signatures, + uint16_t& num_readonly_signed_accounts, + uint16_t& num_readonly_unsigned_accounts); + + // Returns true if transaction contains a set compute price or set compute + // unit price instruction. + bool UsesPriorityFee() const; + mojom::SolanaMessageVersion version_; std::string recent_blockhash_; uint64_t last_valid_block_height_ = 0; diff --git a/components/brave_wallet/browser/solana_message_unittest.cc b/components/brave_wallet/browser/solana_message_unittest.cc index ce52c1f3859..e643b78c4e6 100644 --- a/components/brave_wallet/browser/solana_message_unittest.cc +++ b/components/brave_wallet/browser/solana_message_unittest.cc @@ -14,6 +14,7 @@ #include "base/test/gtest_util.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" #include "brave/components/brave_wallet/browser/solana_test_utils.h" #include "brave/components/brave_wallet/common/brave_wallet.mojom.h" #include "brave/components/brave_wallet/common/brave_wallet_constants.h" @@ -561,4 +562,170 @@ TEST(SolanaMessageUnitTest, UsesDurableNonce) { } } +TEST(SolanaMessageUnitTest, AddPriorityFee) { + auto legacy_message = GetTestLegacyMessage(); + auto static_account_keys_before = legacy_message.static_account_keys(); + auto legacy_message_header_before = legacy_message.message_header(); + auto instructions_size_before = legacy_message.instructions().size(); + ASSERT_TRUE(legacy_message.AddPriorityFee(300, 1000)); + + // Should have two more instrucitons, one to modify compute units, one to + // specify the priority fee. + EXPECT_EQ(instructions_size_before + 2, legacy_message.instructions().size()); + EXPECT_EQ(legacy_message.instructions()[0].GetProgramId(), + mojom::kSolanaComputeBudgetProgramId); + EXPECT_EQ(legacy_message.instructions()[1].GetProgramId(), + mojom::kSolanaComputeBudgetProgramId); + EXPECT_EQ(static_account_keys_before.size() + 1, + legacy_message.static_account_keys().size()); + + // Compute budget program ID should only be found in the new static account + // keys + bool found_compute_budge_program_id = false; + for (const auto& key : legacy_message.static_account_keys()) { + if (key.ToBase58() == mojom::kSolanaComputeBudgetProgramId) { + found_compute_budge_program_id = true; + break; + } + } + EXPECT_TRUE(found_compute_budge_program_id); + + found_compute_budge_program_id = false; + for (const auto& key : static_account_keys_before) { + if (key.ToBase58() == mojom::kSolanaComputeBudgetProgramId) { + found_compute_budge_program_id = true; + break; + } + } + EXPECT_FALSE(found_compute_budge_program_id); + + // Header should be the same except for the number of readonly unsigned + // accounts. + EXPECT_EQ(legacy_message_header_before.num_required_signatures, + legacy_message.message_header().num_required_signatures); + EXPECT_EQ(legacy_message_header_before.num_readonly_signed_accounts, + legacy_message.message_header().num_readonly_signed_accounts); + EXPECT_EQ(legacy_message_header_before.num_readonly_unsigned_accounts + 1, + legacy_message.message_header().num_readonly_unsigned_accounts); + + auto legacy_message_with_durable_nonce = GetTestLegacyMessage(); + SolanaInstruction instruction = GetAdvanceNonceAccountInstruction(); + std::vector vec; + vec.emplace_back(instruction); + vec.emplace_back(legacy_message_with_durable_nonce.instructions()[0]); + legacy_message_with_durable_nonce.SetInstructionsForTesting(vec); + instructions_size_before = + legacy_message_with_durable_nonce.instructions().size(); + ASSERT_TRUE(legacy_message_with_durable_nonce.AddPriorityFee(300, 1000)); + EXPECT_EQ(instructions_size_before + 2, + legacy_message_with_durable_nonce.instructions().size()); + EXPECT_EQ(instructions_size_before + 2, + legacy_message_with_durable_nonce.instructions().size()); + EXPECT_EQ(legacy_message_with_durable_nonce.instructions()[0].GetProgramId(), + mojom::kSolanaSystemProgramId); // Nonce instruction + EXPECT_EQ(legacy_message_with_durable_nonce.instructions()[1].GetProgramId(), + mojom::kSolanaComputeBudgetProgramId); + EXPECT_EQ(legacy_message_with_durable_nonce.instructions()[2].GetProgramId(), + mojom::kSolanaComputeBudgetProgramId); +} + +TEST(SolanaMessageUnitTest, AddPriorityFeeV0) { + // Test without durable nonce + auto v0_message = GetTestV0Message(); + auto v0_instructions_size_before = v0_message.instructions().size(); + ASSERT_TRUE(v0_message.AddPriorityFee(300, 1000)); + + // Check instruction count and positions + EXPECT_EQ(v0_instructions_size_before + 2, v0_message.instructions().size()); + EXPECT_EQ( + v0_message.instructions()[0].GetProgramId(), + mojom::kSolanaComputeBudgetProgramId); // First priority fee instruction + EXPECT_EQ( + v0_message.instructions()[1].GetProgramId(), + mojom::kSolanaComputeBudgetProgramId); // Second priority fee instruction + + // Test with durable nonce + auto v0_message_with_nonce = GetTestV0Message(); + SolanaInstruction nonce_instruction = GetAdvanceNonceAccountInstruction(); + std::vector nonce_vec = { + nonce_instruction, v0_message_with_nonce.instructions()[0]}; + v0_message_with_nonce.SetInstructionsForTesting(nonce_vec); + v0_instructions_size_before = v0_message_with_nonce.instructions().size(); + ASSERT_TRUE(v0_message_with_nonce.AddPriorityFee(300, 1000)); + + // Check instruction count and positions with nonce + EXPECT_EQ(v0_instructions_size_before + 2, + v0_message_with_nonce.instructions().size()); + EXPECT_EQ(v0_message_with_nonce.instructions()[0].GetProgramId(), + mojom::kSolanaSystemProgramId); // Nonce instruction remains first + EXPECT_EQ( + v0_message_with_nonce.instructions()[1].GetProgramId(), + mojom::kSolanaComputeBudgetProgramId); // First priority fee instruction + EXPECT_EQ( + v0_message_with_nonce.instructions()[2].GetProgramId(), + mojom::kSolanaComputeBudgetProgramId); // Second priority fee instruction +} + +TEST(SolanaMessageUnitTest, UsesPriorityFee) { + // Legacy message without durable nonce + SolanaMessage message1 = GetTestLegacyMessage(); + EXPECT_FALSE(message1.UsesPriorityFee()); + ASSERT_TRUE(message1.AddPriorityFee(0, 0)); + EXPECT_TRUE(message1.UsesPriorityFee()); + + // Add a specific test for kSetComputeUnitLimit + SolanaMessage message1a = GetTestLegacyMessage(); + SolanaInstruction set_compute_unit_limit_instruction = + solana::compute_budget_program::SetComputeUnitLimit(0); + std::vector vec1a = {set_compute_unit_limit_instruction}; + message1a.SetInstructionsForTesting(vec1a); + EXPECT_TRUE(message1a.UsesPriorityFee()); + + // Add a specific test for kSetComputeUnitPrice + SolanaMessage message1b = GetTestLegacyMessage(); + SolanaInstruction set_compute_unit_price_instruction = + solana::compute_budget_program::SetComputeUnitPrice(0); + std::vector vec1b = {set_compute_unit_price_instruction}; + message1b.SetInstructionsForTesting(vec1b); + EXPECT_TRUE(message1b.UsesPriorityFee()); + + // Legacy message with durable nonce + SolanaMessage message2 = GetTestLegacyMessage(); + EXPECT_FALSE(message2.UsesPriorityFee()); + SolanaInstruction instruction1 = GetAdvanceNonceAccountInstruction(); + std::vector vec1 = {instruction1, + message2.instructions()[0]}; + message2.SetInstructionsForTesting(vec1); + ASSERT_TRUE(message2.AddPriorityFee(0, 0)); + EXPECT_TRUE(message2.UsesPriorityFee()); + + // V0 message without durable nonce + SolanaMessage message3 = GetTestV0Message(); + EXPECT_FALSE(message3.UsesPriorityFee()); + ASSERT_TRUE(message3.AddPriorityFee(0, 0)); + EXPECT_TRUE(message3.UsesPriorityFee()); + + // Add a specific test for kSetComputeUnitLimit in V0 message + SolanaMessage message3a = GetTestV0Message(); + std::vector vec3a = {set_compute_unit_limit_instruction}; + message3a.SetInstructionsForTesting(vec3a); + EXPECT_TRUE(message3a.UsesPriorityFee()); + + // Add a specific test for kSetComputeUnitPrice in V0 message + SolanaMessage message3b = GetTestV0Message(); + std::vector vec3b = {set_compute_unit_price_instruction}; + message3b.SetInstructionsForTesting(vec3b); + EXPECT_TRUE(message3b.UsesPriorityFee()); + + // V0 message with durable nonce + SolanaMessage message4 = GetTestV0Message(); + EXPECT_FALSE(message4.UsesPriorityFee()); + SolanaInstruction instruction2 = GetAdvanceNonceAccountInstruction(); + std::vector vec2 = {instruction2, + message4.instructions()[0]}; + message4.SetInstructionsForTesting(vec2); + ASSERT_TRUE(message4.AddPriorityFee(0, 0)); + EXPECT_TRUE(message4.UsesPriorityFee()); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_requests.cc b/components/brave_wallet/browser/solana_requests.cc index 5be2e57b146..781e306b467 100644 --- a/components/brave_wallet/browser/solana_requests.cc +++ b/components/brave_wallet/browser/solana_requests.cc @@ -98,7 +98,19 @@ std::string getAccountInfo(const std::string& pubkey) { } std::string getFeeForMessage(const std::string& message) { - return GetJsonRpcString("getFeeForMessage", message); + base::Value::List params; + params.Append(message); + + base::Value::Dict configuration; + // dApps may supply a blockhash with a confirmed commitment level, + // so fetching a fee for those transactions requires us using + // a confirmed commitment level. + configuration.Set("commitment", "confirmed"); + params.Append(std::move(configuration)); + + base::Value::Dict dictionary = + GetJsonRpcDictionary("getFeeForMessage", std::move(params)); + return GetJSON(dictionary); } std::string getBlockHeight() { @@ -143,6 +155,27 @@ std::string isBlockhashValid(const std::string& blockhash, return GetJSON(dictionary); } +std::string simulateTransaction(const std::string& unsigned_tx) { + base::Value::List params; + params.Append(unsigned_tx); + + base::Value::Dict configuration; + configuration.Set("encoding", "base64"); + // dApps may supply a blockhash with a confirmed commitment level, + // so simulating that transaction requires us using + // a confirmed commitment level. + configuration.Set("commitment", "confirmed"); + params.Append(std::move(configuration)); + + base::Value::Dict dictionary = + GetJsonRpcDictionary("simulateTransaction", std::move(params)); + return GetJSON(dictionary); +} + +std::string getRecentPrioritizationFees() { + return GetJsonRpcString("getRecentPrioritizationFees"); +} + } // namespace solana } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_requests.h b/components/brave_wallet/browser/solana_requests.h index 7751c89fa56..7e6c1a8918d 100644 --- a/components/brave_wallet/browser/solana_requests.h +++ b/components/brave_wallet/browser/solana_requests.h @@ -31,6 +31,8 @@ std::string getTokenAccountsByOwner(const std::string& pubkey, const std::string& program_id); std::string isBlockhashValid(const std::string& blockhash, const std::optional& commitment); +std::string simulateTransaction(const std::string& unsigned_tx); +std::string getRecentPrioritizationFees(); } // namespace solana diff --git a/components/brave_wallet/browser/solana_requests_unittest.cc b/components/brave_wallet/browser/solana_requests_unittest.cc index 76da7a18dc1..195c9d819e8 100644 --- a/components/brave_wallet/browser/solana_requests_unittest.cc +++ b/components/brave_wallet/browser/solana_requests_unittest.cc @@ -102,7 +102,7 @@ TEST(SolanaRequestsUnitTest, getAccountInfo) { TEST(SolanaRequestsUnitTest, getFeeForMessage) { ASSERT_EQ( getFeeForMessage("message"), - R"({"id":1,"jsonrpc":"2.0","method":"getFeeForMessage","params":["message"]})"); + R"({"id":1,"jsonrpc":"2.0","method":"getFeeForMessage","params":["message",{"commitment":"confirmed"}]})"); } TEST(SolanaRequestsUnitTest, getBlockHeight) { @@ -173,6 +173,34 @@ TEST(SolanaRequestsUnitTest, isBlockhashValid) { EXPECT_CHECK_DEATH(isBlockhashValid(kBlockhash, "invalid_commitment")); } +TEST(SolanaRequestsUnitTest, simulateTransaction) { + EXPECT_EQ(base::test::ParseJsonDict(simulateTransaction("unsigned tx")), + base::test::ParseJsonDict( + R"({ + "id": 1, + "jsonrpc": "2.0", + "method": "simulateTransaction", + "params": [ + "unsigned tx", + { + "commitment": "confirmed", + "encoding": "base64" + } + ] + })")); +} + +TEST(SolanaRequestsUnitTest, getRecentPrioritizationFees) { + EXPECT_EQ(base::test::ParseJsonDict(getRecentPrioritizationFees()), + base::test::ParseJsonDict( + R"({ + "id": 1, + "jsonrpc": "2.0", + "method": "getRecentPrioritizationFees", + "params": [] + })")); +} + } // namespace solana } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_response_parser.cc b/components/brave_wallet/browser/solana_response_parser.cc index 97b1f60cd20..c56f25be2fa 100644 --- a/components/brave_wallet/browser/solana_response_parser.cc +++ b/components/brave_wallet/browser/solana_response_parser.cc @@ -411,6 +411,63 @@ std::optional ParseIsBlockhashValid(const base::Value& json_value) { return *is_valid; } +std::optional ParseSimulateTransaction( + const base::Value& json_value) { + auto result = ParseResultDict(json_value); + if (!result) { + return std::nullopt; + } + + const auto* value = result->FindDict("value"); + if (!value) { + return std::nullopt; + } + + auto* err = value->FindString("err"); + if (err) { + return std::nullopt; + } + + uint64_t units_consumed; + if (!GetUint64FromDictValue(*value, "unitsConsumed", false, + &units_consumed)) { + return std::nullopt; + } + + return units_consumed; +} + +std::optional>> +ParseGetSolanaPrioritizationFees(const base::Value& json_value) { + std::vector> fees; + auto result = ParseResultList(json_value); + if (!result) { + return std::nullopt; + } + + for (const auto& item : *result) { + const auto* fee_value = item.GetIfDict(); + if (!fee_value) { + return std::nullopt; + } + + uint64_t slot; + if (!GetUint64FromDictValue(*fee_value, "slot", false, &slot)) { + return std::nullopt; + } + + uint64_t prioritizationFee; + if (!GetUint64FromDictValue(*fee_value, "prioritizationFee", false, + &prioritizationFee)) { + return std::nullopt; + } + + fees.push_back(std::make_pair(slot, prioritizationFee)); + } + + return fees; +} + base::OnceCallback(const std::string& raw_response)> ConverterForGetAccountInfo() { return base::BindOnce(&ConvertMultiUint64ToString, diff --git a/components/brave_wallet/browser/solana_response_parser.h b/components/brave_wallet/browser/solana_response_parser.h index 70c0bd425cf..05d915df84d 100644 --- a/components/brave_wallet/browser/solana_response_parser.h +++ b/components/brave_wallet/browser/solana_response_parser.h @@ -51,6 +51,10 @@ std::optional ParseIsBlockhashValid(const base::Value& json_value); std::optional> ParseGetSPLTokenBalances( const base::Value& json_value); +std::optional ParseSimulateTransaction(const base::Value& json_value); +std::optional>> +ParseGetSolanaPrioritizationFees(const base::Value& json_value); + base::OnceCallback(const std::string& raw_response)> ConverterForGetAccountInfo(); base::OnceCallback(const std::string& raw_response)> diff --git a/components/brave_wallet/browser/solana_response_parser_unittest.cc b/components/brave_wallet/browser/solana_response_parser_unittest.cc index 9f433715cb5..95fbbf9526d 100644 --- a/components/brave_wallet/browser/solana_response_parser_unittest.cc +++ b/components/brave_wallet/browser/solana_response_parser_unittest.cc @@ -725,6 +725,178 @@ TEST(SolanaResponseParserUnitTest, ConverterForGetProgramAccounts) { EXPECT_EQ(ParseJson(*json_converted), ParseJson(json_expected)); } +TEST(SolanaResponseParserUnitTest, ParseSimulateTransaction) { + // Test with a valid JSON string that includes a unitsConsumed field. + std::string json_valid = R"( + { + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.17.25", + "slot": 259225005 + }, + "value": { + "accounts": null, + "err": null, + "logs": [ + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY invoke [1]", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV consumed 39 of 183791 compute units", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV success" + ], + "returnData": null, + "unitsConsumed": "18446744073709551615" + } + }, + "id": 1 + } + )"; + + auto parsed_units_valid = ParseSimulateTransaction(ParseJson(json_valid)); + ASSERT_TRUE(parsed_units_valid.has_value()); + EXPECT_EQ(*parsed_units_valid, UINT64_MAX); + + // Test with a JSON string that lacks the unitsConsumed field. + std::string json_no_units = R"( + { + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.17.25", + "slot": 259225005 + }, + "value": { + "accounts": null, + "err": null, + "logs": [ + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY invoke [1]", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV consumed 39 of 183791 compute units", + "Program noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV success" + ], + "returnData": null + } + }, + "id": 1 + } + )"; + + auto parsed_no_units = ParseSimulateTransaction(ParseJson(json_no_units)); + EXPECT_FALSE(parsed_no_units.has_value()); + + // Test with invalid JSON (e.g., missing result). + std::string json_invalid = R"( + { + "jsonrpc": "2.0", + "id": 1 + } + )"; + + auto parsed_invalid = ParseSimulateTransaction(ParseJson(json_invalid)); + EXPECT_FALSE(parsed_invalid.has_value()); + + // Test with blockhash not found error + std::string json_blockhash_err = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.18.11", + "slot": 262367830 + }, + "value": { + "accounts": null, + "err": "BlockhashNotFound", + "innerInstructions": null, + "logs": [], + "returnData": null, + "unitsConsumed": "0" + } + }, + "id": 1 + })"; + auto parsed_blockhash_err = + ParseSimulateTransaction(ParseJson(json_blockhash_err)); + EXPECT_FALSE(parsed_blockhash_err.has_value()); +} + +TEST(SolanaResponseParserUnitTest, ParseGetSolanaPrioritizationFees) { + // Parsing valid JSON with prioritization fees + std::string json = R"({ + "jsonrpc": "2.0", + "result": [ + { + "slot": "348125", + "prioritizationFee": "0" + }, + { + "slot": "348126", + "prioritizationFee": "1000" + }, + { + "slot": "348127", + "prioritizationFee": "500" + }, + { + "slot": "348128", + "prioritizationFee": "0" + }, + { + "slot": "18446744073709551615", + "prioritizationFee": "18446744073709551615" + } + ], + "id": 1 + })"; + + std::optional>> fees = + ParseGetSolanaPrioritizationFees(ParseJson(json)); + ASSERT_TRUE(fees.has_value()); + + std::vector> expected_fees = { + {348125, 0}, + {348126, 1000}, + {348127, 500}, + {348128, 0}, + {UINT64_MAX, UINT64_MAX}}; + + EXPECT_EQ(*fees, expected_fees); + + // Testing invalid JSON without 'result' key + std::string invalid_json = R"( + { + "jsonrpc": "2.0", + "id": 1 + } + )"; + + fees = ParseGetSolanaPrioritizationFees(ParseJson(invalid_json)); + EXPECT_FALSE(fees.has_value()); + + // Testing JSON with an empty 'result' array + std::string empty_result_json = R"({ + "jsonrpc": "2.0", + "result": [], + "id": 1 + })"; + + fees = ParseGetSolanaPrioritizationFees(ParseJson(empty_result_json)); + EXPECT_TRUE(fees.has_value()); + EXPECT_TRUE(fees->empty()); + + // Testing JSON with wrong data types for 'slot' and 'prioritizationFee' + std::string wrong_types_json = R"({ + "jsonrpc": "2.0", + "result": [ + { + "slot": 348125, + "prioritizationFee": 1000 + } + ], + "id": 1 + })"; + + fees = ParseGetSolanaPrioritizationFees(ParseJson(wrong_types_json)); + EXPECT_FALSE(fees.has_value()); +} + } // namespace solana } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_transaction.cc b/components/brave_wallet/browser/solana_transaction.cc index 909d8b03765..46524907f0e 100644 --- a/components/brave_wallet/browser/solana_transaction.cc +++ b/components/brave_wallet/browser/solana_transaction.cc @@ -192,7 +192,8 @@ bool SolanaTransaction::operator==(const SolanaTransaction& tx) const { to_wallet_address_ == tx.to_wallet_address_ && spl_token_mint_address_ == tx.spl_token_mint_address_ && tx_type_ == tx.tx_type_ && lamports_ == tx.lamports_ && - amount_ == tx.amount_ && send_options_ == tx.send_options_; + amount_ == tx.amount_ && send_options_ == tx.send_options_ && + tx.fee_estimation_ == fee_estimation_; } bool SolanaTransaction::operator!=(const SolanaTransaction& tx) const { @@ -331,6 +332,33 @@ std::string SolanaTransaction::GetSignedTransaction( return base::Base64Encode(*transaction_bytes); } +std::string SolanaTransaction::GetUnsignedTransaction() const { + auto message_signers_pair = GetSerializedMessage(); + if (!message_signers_pair) { + return ""; + } + + auto& message_bytes = message_signers_pair->first; + auto& signers = message_signers_pair->second; + + std::vector transaction_bytes; + + CompactU16Encode(signers.size(), &transaction_bytes); + + // Insert an empty (default) signature for each signer. + transaction_bytes.insert(transaction_bytes.end(), + kSolanaSignatureSize * signers.size(), 0); + + transaction_bytes.insert(transaction_bytes.end(), message_bytes.begin(), + message_bytes.end()); + + if (transaction_bytes.size() > kSolanaMaxTxSize) { + return ""; + } + + return base::Base64Encode(transaction_bytes); +} + std::string SolanaTransaction::GetBase64EncodedMessage() const { auto message_signers_pair = GetSerializedMessage(); if (!message_signers_pair) { @@ -347,6 +375,7 @@ mojom::SolanaTxDataPtr SolanaTransaction::ToSolanaTxData() const { solana_tx_data->tx_type = tx_type_; solana_tx_data->lamports = lamports_; solana_tx_data->amount = amount_; + solana_tx_data->fee_estimation = fee_estimation_.Clone(); if (send_options_) { solana_tx_data->send_options = send_options_->ToMojomSendOptions(); @@ -362,7 +391,6 @@ mojom::SolanaTxDataPtr SolanaTransaction::ToSolanaTxData() const { base::Value::Dict SolanaTransaction::ToValue() const { base::Value::Dict dict; dict.Set("message", message_.ToValue()); - dict.Set("to_wallet_address", to_wallet_address_); dict.Set("spl_token_mint_address", spl_token_mint_address_); dict.Set("tx_type", static_cast(tx_type_)); @@ -387,14 +415,24 @@ base::Value::Dict SolanaTransaction::ToValue() const { signature_dict.Set(kSignature, base::Base64Encode(*signature->signature)); } - signatures_list.Append(std::move(signature_dict)); } sign_tx_param_dict.Set(kSignatures, std::move(signatures_list)); - dict.Set(kSignTxParam, std::move(sign_tx_param_dict)); } + if (fee_estimation_) { + base::Value::Dict fee_estimation_dict; + fee_estimation_dict.Set("base_fee", + base::NumberToString(fee_estimation_->base_fee)); + fee_estimation_dict.Set( + "compute_units", base::NumberToString(fee_estimation_->compute_units)); + fee_estimation_dict.Set( + "fee_per_compute_unit", + base::NumberToString(fee_estimation_->fee_per_compute_unit)); + dict.Set("fee_estimation", std::move(fee_estimation_dict)); + } + return dict; } @@ -507,6 +545,39 @@ std::unique_ptr SolanaTransaction::FromValue( tx->set_sign_tx_param(std::move(sign_tx_param)); } + const base::Value::Dict* fee_estimation_dict = + value.FindDict("fee_estimation"); + if (fee_estimation_dict) { + auto fee_estimation = mojom::SolanaFeeEstimation::New(); + const auto* base_fee_string = fee_estimation_dict->FindString("base_fee"); + uint64_t base_fee = 0; + if (base_fee_string && base::StringToUint64(*base_fee_string, &base_fee)) { + fee_estimation->base_fee = base_fee; + } + + const auto* compute_units_string = + fee_estimation_dict->FindString("compute_units"); + uint32_t compute_units = 0; + if (compute_units_string && + base::StringToUint(*compute_units_string, &compute_units)) { + fee_estimation->compute_units = compute_units; + } + + const auto* fee_per_compute_unit_string = + fee_estimation_dict->FindString("fee_per_compute_unit"); + uint64_t fee_per_compute_unit = 0; + if (fee_per_compute_unit_string && + base::StringToUint64(*fee_per_compute_unit_string, + &fee_per_compute_unit)) { + fee_estimation->fee_per_compute_unit = fee_per_compute_unit; + } + + if (fee_estimation->base_fee != 0 || fee_estimation->compute_units != 0 || + fee_estimation->fee_per_compute_unit != 0) { + tx->set_fee_estimation(std::move(fee_estimation)); + } + } + return tx; } diff --git a/components/brave_wallet/browser/solana_transaction.h b/components/brave_wallet/browser/solana_transaction.h index 927e82e2a66..0e10f34209f 100644 --- a/components/brave_wallet/browser/solana_transaction.h +++ b/components/brave_wallet/browser/solana_transaction.h @@ -81,6 +81,9 @@ class SolanaTransaction { const mojom::AccountIdPtr& selected_account, const std::vector* selected_account_signature = nullptr) const; + // https://docs.rs/solana-sdk/1.18.14/src/solana_sdk/transaction/mod.rs.html#271-276 + std::string GetUnsignedTransaction() const; + // Serialize and encode the message in Base64. std::string GetBase64EncodedMessage() const; @@ -129,9 +132,16 @@ class SolanaTransaction { sign_tx_param_ = std::move(sign_tx_param); } void set_wired_tx(const std::string& wired_tx) { wired_tx_ = wired_tx; } + void set_fee_estimation(mojom::SolanaFeeEstimationPtr estimation) { + fee_estimation_ = std::move(estimation); + } + const mojom::SolanaFeeEstimationPtr& fee_estimation() const { + return fee_estimation_; + } private: FRIEND_TEST_ALL_PREFIXES(SolanaTransactionUnitTest, GetBase64EncodedMessage); + SolanaMessage message_; // Value will be assigned when FromSignedTransactionBytes is called. std::vector raw_signatures_; @@ -163,6 +173,9 @@ class SolanaTransaction { // Currently might be specified by solana.signAndSendTransaction provider // API as the options to be passed to sendTransaction RPC call. std::optional send_options_; + + // Fee estimation result + mojom::SolanaFeeEstimationPtr fee_estimation_; }; } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_transaction_unittest.cc b/components/brave_wallet/browser/solana_transaction_unittest.cc index 4df9ddec476..2a337cdd2a1 100644 --- a/components/brave_wallet/browser/solana_transaction_unittest.cc +++ b/components/brave_wallet/browser/solana_transaction_unittest.cc @@ -540,6 +540,12 @@ TEST_F(SolanaTransactionUnitTest, FromToValue) { std::vector(2, 1), "public_key2")); transaction.set_sign_tx_param(sign_tx_param->Clone()); + auto fee_estimation = mojom::SolanaFeeEstimation::New(); + fee_estimation->base_fee = 5000; + fee_estimation->compute_units = 200; + fee_estimation->fee_per_compute_unit = 25; + transaction.set_fee_estimation(std::move(fee_estimation)); + base::Value::Dict value = transaction.ToValue(); auto expect_tx_value = base::test::ParseJson(R"( { @@ -639,6 +645,11 @@ TEST_F(SolanaTransactionUnitTest, FromToValue) { {"public_key": "public_key1"}, {"signature": "AQE=", "public_key": "public_key2"} ] + }, + "fee_estimation": { + "base_fee": "5000", + "compute_units": "200", + "fee_per_compute_unit": "25" } } )"); @@ -958,4 +969,53 @@ TEST_F(SolanaTransactionUnitTest, IsPartialSigned) { EXPECT_TRUE(tx.IsPartialSigned()); } +TEST_F(SolanaTransactionUnitTest, GetUnsignedTransaction) { + auto msg1 = + SolanaMessage::CreateLegacyMessage(kRecentBlockhash, 0, kFromAccount, {}); + auto tx1 = SolanaTransaction(std::move(*msg1)); + EXPECT_EQ(tx1.GetUnsignedTransaction(), ""); + + SolanaInstruction ins1( + mojom::kSolanaSystemProgramId, + {SolanaAccountMeta(kFromAccount, std::nullopt, true, true), + SolanaAccountMeta(kToAccount, std::nullopt, true, true), + SolanaAccountMeta(kTestAccount, std::nullopt, true, true)}, + {}); + + auto msg2 = SolanaMessage::CreateLegacyMessage(kRecentBlockhash, 0, + kFromAccount, {ins1}); + ASSERT_TRUE(msg2); + auto tx2 = SolanaTransaction(std::move(*msg2)); + EXPECT_EQ(tx2.GetUnsignedTransaction(), + "AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAQQi" + "Qpz5e+d8NwrhAMqG/WfddvN4Tz69QaQxYsK2YW+h/v/" + "g5PVe7heEzihS+dvLZ55u2135j4bPrLNMQwappJUmItA1NksucDd7D+" + "gJLbL8xD5AqdVCV8AQmGz+" + "lLcnM8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIO/U8lswd7/" + "sEOI0dsqBqnwiY65qQYRV3sGKjeiQHhbAQMDAAECAA=="); + + // Test unsigned transaction over kSolanaMaxTransactionSize. + std::vector large_accounts; + for (size_t i = 0; i < 50; ++i) { + large_accounts.emplace_back(kFromAccount, std::nullopt, true, true); + large_accounts.emplace_back(kToAccount, std::nullopt, true, true); + } + + std::vector large_data(1000, 0xAA); + + SolanaInstruction large_instruction(mojom::kSolanaSystemProgramId, + std::move(large_accounts), large_data, + std::nullopt); + + auto large_message = SolanaMessage::CreateLegacyMessage( + kRecentBlockhash, 0, kFromAccount, {large_instruction}); + ASSERT_TRUE(large_message); + + SolanaTransaction large_tx(std::move(*large_message)); + EXPECT_EQ(large_tx.GetUnsignedTransaction(), ""); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_tx_manager.cc b/components/brave_wallet/browser/solana_tx_manager.cc index e9a6b666234..0b3bac68e74 100644 --- a/components/brave_wallet/browser/solana_tx_manager.cc +++ b/components/brave_wallet/browser/solana_tx_manager.cc @@ -33,8 +33,12 @@ namespace brave_wallet { -// https://docs.rs/solana-program/1.18.10/src/solana_program/clock.rs.html#129-131 -constexpr int kValidBlockHeightThreshold = 150; +// The number of compute units required to modify the compute +// units and add a priority fee. +constexpr int kAddPriorityFeeComputeUnits = 300; +// Minimum fee per compute units is 1 micro lamport. +// There are 10^6 micro-lamports in one lamport. +constexpr int kMininumFeePerComputeUnits = 1; // Transactions submitted after 30 minutes are safe to be updated to dropped // since usual transactions are only valid for about 2 minutes. @@ -85,19 +89,195 @@ void SolanaTxManager::AddUnapprovedTransaction( return; } - SolanaTxMeta meta(from, std::move(tx)); - meta.set_id(TxMeta::GenerateMetaID()); - meta.set_origin( + auto meta = std::make_unique(from, std::move(tx)); + meta->set_id(TxMeta::GenerateMetaID()); + meta->set_origin( origin.value_or(url::Origin::Create(GURL("chrome://wallet")))); - meta.set_created_time(base::Time::Now()); - meta.set_status(mojom::TransactionStatus::Unapproved); - meta.set_chain_id(chain_id); - if (!tx_state_manager_->AddOrUpdateTx(meta)) { + meta->set_created_time(base::Time::Now()); + meta->set_status(mojom::TransactionStatus::Unapproved); + meta->set_chain_id(chain_id); + + auto internal_callback = + base::BindOnce(&SolanaTxManager::ContinueAddUnapprovedTransaction, + weak_ptr_factory_.GetWeakPtr(), std::move(callback)); + + GetSolanaTxFeeEstimationForMeta(chain_id, std::move(meta), + std::move(internal_callback)); +} + +void SolanaTxManager::OnSimulateSolanaTransaction( + const std::string& chain_id, + std::unique_ptr meta, + uint64_t base_fee, + GetSolanaTxFeeEstimationForMetaCallback callback, + uint64_t compute_units_consumed, + mojom::SolanaProviderError error, + const std::string& error_message) { + if (error != mojom::SolanaProviderError::kSuccess) { + // If the simulation fails, we'll still propagate the base + // fee - the client can use it even if the priority fee fails. + mojom::SolanaFeeEstimationPtr estimation = + mojom::SolanaFeeEstimation::New(); + estimation->base_fee = base_fee; + estimation->compute_units = 0; + estimation->fee_per_compute_unit = 0; + std::move(callback).Run(std::move(meta), std::move(estimation), error, + error_message); + return; + } + + auto internal_callback = + base::BindOnce(&SolanaTxManager::OnGetRecentSolanaPrioritizationFees, + weak_ptr_factory_.GetWeakPtr(), std::move(meta), base_fee, + compute_units_consumed, std::move(callback)); + json_rpc_service_->GetRecentSolanaPrioritizationFees( + chain_id, std::move(internal_callback)); +} + +void SolanaTxManager::OnGetRecentSolanaPrioritizationFees( + std::unique_ptr meta, + uint64_t base_fee, + uint64_t compute_units, + GetSolanaTxFeeEstimationForMetaCallback callback, + std::vector>& recent_fees, + mojom::SolanaProviderError error, + const std::string& error_message) { + if (error != mojom::SolanaProviderError::kSuccess) { + // If the call to fetch recent priority fees fails, we'll still propagate + // the base fee and compute units, but use the default fee per compute unit. + mojom::SolanaFeeEstimationPtr estimation = + mojom::SolanaFeeEstimation::New(); + estimation->base_fee = base_fee; + estimation->compute_units = compute_units; + estimation->fee_per_compute_unit = kMininumFeePerComputeUnits; + std::move(callback).Run(std::move(meta), std::move(estimation), error, + error_message); + return; + } + + uint64_t median = 0; + if (!recent_fees.empty()) { + base::ranges::sort(recent_fees, [](const auto& a, const auto& b) { + return a.second < b.second; + }); + + size_t size = recent_fees.size(); + if (size % 2 == 0) { + median = + (recent_fees[size / 2 - 1].second + recent_fees[size / 2].second) / 2; + } else { + median = recent_fees[size / 2].second; + } + } + + mojom::SolanaFeeEstimationPtr estimation = mojom::SolanaFeeEstimation::New(); + estimation->base_fee = base_fee; + // The simulation was performed without the instructions that set a compute + // budget and priority fee, so we must add those as well. + estimation->compute_units = compute_units + kAddPriorityFeeComputeUnits; + + if (median == 0) { + estimation->fee_per_compute_unit = kMininumFeePerComputeUnits; + } else { + estimation->fee_per_compute_unit = median; + } + + std::move(callback).Run(std::move(meta), std::move(estimation), + mojom::SolanaProviderError::kSuccess, ""); +} + +void SolanaTxManager::OnGetEstimatedTxBaseFee( + const std::string& chain_id, + GetSolanaTxFeeEstimationForMetaCallback callback, + std::unique_ptr meta, + const std::string& unsigned_tx, + uint64_t base_fee, + mojom::SolanaProviderError error, + const std::string& error_message) { + if (error != mojom::SolanaProviderError::kSuccess) { + // If the base fee fetching fails, set it to zero so + // ContinueAddUnapprovedTransaction can identify the failure is from base + // fee fetching. + mojom::SolanaFeeEstimationPtr estimation = + mojom::SolanaFeeEstimation::New(); + estimation->base_fee = 0; + estimation->compute_units = 0; + estimation->fee_per_compute_unit = 0; + std::move(callback).Run(std::move(meta), std::move(estimation), error, + error_message); + return; + } + + // If the transaction is partially signed, we can't modify the instructions + // to add a priority fee. So we return just the base fee. + if (meta->tx()->IsPartialSigned()) { + mojom::SolanaFeeEstimationPtr estimation = + mojom::SolanaFeeEstimation::New(); + estimation->base_fee = base_fee; + estimation->compute_units = 0; + estimation->fee_per_compute_unit = 0; + std::move(callback).Run(std::move(meta), std::move(estimation), + mojom::SolanaProviderError::kSuccess, ""); + return; + } + + auto internal_callback = + base::BindOnce(&SolanaTxManager::OnSimulateSolanaTransaction, + weak_ptr_factory_.GetWeakPtr(), chain_id, std::move(meta), + base_fee, std::move(callback)); + json_rpc_service_->SimulateSolanaTransaction(chain_id, unsigned_tx, + std::move(internal_callback)); +} + +void SolanaTxManager::FinishGetSolanaTxFeeEstimation( + GetSolanaTxFeeEstimationCallback callback, + std::unique_ptr meta, + mojom::SolanaFeeEstimationPtr estimation, + mojom::SolanaProviderError error, + const std::string& error_message) { + std::move(callback).Run(std::move(estimation), error, error_message); +} + +void SolanaTxManager::ContinueAddUnapprovedTransaction( + AddUnapprovedTransactionCallback callback, + std::unique_ptr meta, + mojom::SolanaFeeEstimationPtr estimation, + mojom::SolanaProviderError error, + const std::string& error_message) { + // If the base fee is zero (base feefetching failed), we add the transaction + // without adding the priority fee instruction and without setting the gas + // estimate. + if (!estimation || (error != mojom::SolanaProviderError::kSuccess && + estimation->base_fee == 0)) { + if (!tx_state_manager_->AddOrUpdateTx(*meta)) { + std::move(callback).Run( + false, "", l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + return; + } + + std::move(callback).Run(true, meta->id(), ""); + return; + } + + // If we successfully fetched a base fee, then we add the gas estimate + // to the transaction. + auto compute_units = estimation->compute_units; + auto fee_per_compute_unit = estimation->fee_per_compute_unit; + meta->tx()->set_fee_estimation(std::move(estimation)); + + // Only add the priority fee instruction if we successfully fetched the + // the total compute unit estimate. + if (compute_units > 0) { + meta->tx()->message()->AddPriorityFee(compute_units, fee_per_compute_unit); + } + + if (!tx_state_manager_->AddOrUpdateTx(*meta)) { std::move(callback).Run( false, "", l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); return; } - std::move(callback).Run(true, meta.id(), ""); + + std::move(callback).Run(true, meta->id(), ""); } void SolanaTxManager::ApproveTransaction(const std::string& tx_meta_id, @@ -149,7 +329,7 @@ void SolanaTxManager::OnGetBlockHeightForBlockhash( } OnGetLatestBlockhash(std::move(meta), std::move(callback), blockhash, - block_height + kValidBlockHeightThreshold, + block_height + kSolanaValidBlockHeightThreshold, mojom::SolanaProviderError::kSuccess, ""); } @@ -502,7 +682,7 @@ void SolanaTxManager::OnGetBlockHeightForBlockhashHardware( } OnGetLatestBlockhashHardware(std::move(meta), std::move(callback), blockhash, - block_height + kValidBlockHeightThreshold, + block_height + kSolanaValidBlockHeightThreshold, mojom::SolanaProviderError::kSuccess, ""); } @@ -747,54 +927,119 @@ void SolanaTxManager::OnGetAccountInfo( mojom::SolanaProviderError::kSuccess, ""); } -void SolanaTxManager::GetEstimatedTxFee(const std::string& tx_meta_id, - GetEstimatedTxFeeCallback callback) { - std::unique_ptr meta = - GetSolanaTxStateManager()->GetSolanaTx(tx_meta_id); - if (!meta) { - DCHECK(false) << "Transaction should be found"; - std::move(callback).Run( - false, mojom::SolanaProviderError::kInternalError, - l10n_util::GetStringUTF8(IDS_BRAVE_WALLET_TRANSACTION_NOT_FOUND)); - return; +void SolanaTxManager::GetEstimatedTxBaseFee( + std::unique_ptr meta, + GetEstimatedTxBaseFeeCallback callback) { + const std::string& chain_id = meta->chain_id(); + const std::string& blockhash = meta->tx()->message()->recent_blockhash(); + if (blockhash.empty()) { + GetSolanaBlockTracker()->GetLatestBlockhash( + chain_id, + base::BindOnce( + &SolanaTxManager::OnGetLatestBlockhashForGetEstimatedTxFee, + weak_ptr_factory_.GetWeakPtr(), std::move(meta), + std::move(callback)), + true); + } else { + const std::string base64_encoded_message = + meta->tx()->GetBase64EncodedMessage(); + const std::string unsigned_tx = meta->tx()->GetUnsignedTransaction(); + json_rpc_service_->GetSolanaFeeForMessage( + chain_id, base64_encoded_message, + base::BindOnce(&SolanaTxManager::OnGetFeeForMessage, + weak_ptr_factory_.GetWeakPtr(), std::move(callback), + std::move(meta), unsigned_tx)); } - - auto chain_id = meta->chain_id(); - GetSolanaBlockTracker()->GetLatestBlockhash( - chain_id, - base::BindOnce(&SolanaTxManager::OnGetLatestBlockhashForGetEstimatedTxFee, - weak_ptr_factory_.GetWeakPtr(), std::move(meta), - std::move(callback)), - true); } void SolanaTxManager::OnGetLatestBlockhashForGetEstimatedTxFee( std::unique_ptr meta, - GetEstimatedTxFeeCallback callback, + GetEstimatedTxBaseFeeCallback callback, const std::string& latest_blockhash, uint64_t last_valid_block_height, mojom::SolanaProviderError error, const std::string& error_message) { if (error != mojom::SolanaProviderError::kSuccess) { - std::move(callback).Run(0, error, error_message); + std::move(callback).Run({}, "", 0, error, error_message); return; } + // Temporarily set the recent blockhash and last valid block height so + // they are included in the base64 encoded message used in getFeeForMessage + // and unsigned transaction used in simulateTransaction. meta->tx()->message()->set_recent_blockhash(latest_blockhash); meta->tx()->message()->set_last_valid_block_height(last_valid_block_height); const std::string base64_encoded_message = meta->tx()->GetBase64EncodedMessage(); + const std::string unsigned_tx = meta->tx()->GetUnsignedTransaction(); + + // Clear recent blockhash and last valid block height. We will fetch fresh + // values when user approves the transaction. + meta->tx()->message()->set_recent_blockhash(""); + meta->tx()->message()->set_last_valid_block_height(0); + + const std::string& chain_id = meta->chain_id(); json_rpc_service_->GetSolanaFeeForMessage( - meta->chain_id(), base64_encoded_message, + chain_id, base64_encoded_message, base::BindOnce(&SolanaTxManager::OnGetFeeForMessage, - weak_ptr_factory_.GetWeakPtr(), std::move(callback))); + weak_ptr_factory_.GetWeakPtr(), std::move(callback), + std::move(meta), unsigned_tx)); } -void SolanaTxManager::OnGetFeeForMessage(GetEstimatedTxFeeCallback callback, +void SolanaTxManager::OnGetFeeForMessage(GetEstimatedTxBaseFeeCallback callback, + std::unique_ptr meta, + const std::string& unsigned_tx, uint64_t tx_fee, mojom::SolanaProviderError error, const std::string& error_message) { - std::move(callback).Run(tx_fee, error, error_message); + std::move(callback).Run(std::move(meta), unsigned_tx, tx_fee, error, + error_message); +} + +void SolanaTxManager::GetSolanaTxFeeEstimation( + const std::string& chain_id, + const std::string& tx_meta_id, + GetSolanaTxFeeEstimationCallback callback) { + // Get the TxMeta. + std::unique_ptr meta = + GetSolanaTxStateManager()->GetSolanaTx(tx_meta_id); + if (!meta) { + std::move(callback).Run( + {}, mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_BRAVE_WALLET_TRANSACTION_NOT_FOUND)); + return; + } + + auto internal_callback = + base::BindOnce(&SolanaTxManager::FinishGetSolanaTxFeeEstimation, + weak_ptr_factory_.GetWeakPtr(), std::move(callback)); + + GetSolanaTxFeeEstimationForMeta(chain_id, std::move(meta), + std::move(internal_callback)); +} + +void SolanaTxManager::GetSolanaTxFeeEstimationForMeta( + const std::string& chain_id, + std::unique_ptr meta, + GetSolanaTxFeeEstimationForMetaCallback callback) { + // This function makes three RPC calls to create the SolanaFeeEstimation: + // + // 1. getFeeForMessage to get the base fee + // 2. simulateTransaction to get the compute unit estimation + // 3. getRecentPrioritizationFees to get the fee per compute unit + // + // If fetching the base fee fails, we set the base fee, compute units, and fee + // per compute unit to zero so the caller can identify the failure. + // + // If estimating the compute units fails, we set the correct base fee, but + // set the compute units and fee per compute unit to zero. + // + // If fetching the fee per compute unit fails, we set the correct base fee and + // compute units, but set the fee per compute unit to the default value. + auto internal_callback = base::BindOnce( + &SolanaTxManager::OnGetEstimatedTxBaseFee, weak_ptr_factory_.GetWeakPtr(), + chain_id, std::move(callback)); + GetEstimatedTxBaseFee(std::move(meta), std::move(internal_callback)); } void SolanaTxManager::OnLatestBlockhashUpdated( diff --git a/components/brave_wallet/browser/solana_tx_manager.h b/components/brave_wallet/browser/solana_tx_manager.h index db61aefb647..87117aded2e 100644 --- a/components/brave_wallet/browser/solana_tx_manager.h +++ b/components/brave_wallet/browser/solana_tx_manager.h @@ -67,8 +67,19 @@ class SolanaTxManager : public TxManager, public SolanaBlockTracker::Observer { mojom::SolanaTxManagerProxy::MakeTokenProgramTransferTxDataCallback; using MakeTxDataFromBase64EncodedTransactionCallback = mojom:: SolanaTxManagerProxy::MakeTxDataFromBase64EncodedTransactionCallback; - using GetEstimatedTxFeeCallback = - mojom::SolanaTxManagerProxy::GetEstimatedTxFeeCallback; + using GetEstimatedTxBaseFeeCallback = + base::OnceCallback tx_meta, + const std::string& unsigned_tx, + uint64_t tx_fee, + mojom::SolanaProviderError error, + const std::string& error_message)>; + using GetSolanaTxFeeEstimationCallback = + mojom::SolanaTxManagerProxy::GetSolanaTxFeeEstimationCallback; + using GetSolanaTxFeeEstimationForMetaCallback = + base::OnceCallback tx_meta, + mojom::SolanaFeeEstimationPtr fee_estimation, + mojom::SolanaProviderError error, + const std::string& error_message)>; void MakeSystemProgramTransferTxData( const std::string& from, const std::string& to, @@ -87,8 +98,15 @@ class SolanaTxManager : public TxManager, public SolanaBlockTracker::Observer { const mojom::TransactionType tx_type, mojom::SolanaSendTransactionOptionsPtr send_options, MakeTxDataFromBase64EncodedTransactionCallback callback); - void GetEstimatedTxFee(const std::string& tx_meta_id, - GetEstimatedTxFeeCallback callback); + void GetEstimatedTxBaseFee(std::unique_ptr meta, + GetEstimatedTxBaseFeeCallback callback); + void GetSolanaTxFeeEstimation(const std::string& chain_id, + const std::string& tx_meta_id, + GetSolanaTxFeeEstimationCallback callback); + void GetSolanaTxFeeEstimationForMeta( + const std::string& chain_id, + std::unique_ptr meta, + GetSolanaTxFeeEstimationForMetaCallback callback); void ProcessSolanaHardwareSignature( const std::string& tx_meta_id, const std::vector& signature_bytes, @@ -108,6 +126,8 @@ class SolanaTxManager : public TxManager, public SolanaBlockTracker::Observer { FRIEND_TEST_ALL_PREFIXES(SolanaTxManagerUnitTest, ProcessSolanaHardwareSignature); FRIEND_TEST_ALL_PREFIXES(SolanaTxManagerUnitTest, RetryTransaction); + FRIEND_TEST_ALL_PREFIXES(SolanaTxManagerUnitTest, GetEstimatedTxFee); + FRIEND_TEST_ALL_PREFIXES(SolanaTxManagerUnitTest, GetSolanaTxFeeEstimation); friend class SolanaTxManagerUnitTest; mojom::CoinType GetCoinType() const override; @@ -176,12 +196,14 @@ class SolanaTxManager : public TxManager, public SolanaBlockTracker::Observer { const std::string& error_message); void OnGetLatestBlockhashForGetEstimatedTxFee( std::unique_ptr meta, - GetEstimatedTxFeeCallback callback, + GetEstimatedTxBaseFeeCallback callback, const std::string& latest_blockhash, uint64_t last_valid_block_height, mojom::SolanaProviderError error, const std::string& error_message); - void OnGetFeeForMessage(GetEstimatedTxFeeCallback callback, + void OnGetFeeForMessage(GetEstimatedTxBaseFeeCallback callback, + std::unique_ptr meta, + const std::string& unsigned_tx, uint64_t tx_fee, mojom::SolanaProviderError error, const std::string& error_message); @@ -197,6 +219,45 @@ class SolanaTxManager : public TxManager, public SolanaBlockTracker::Observer { mojom::SolanaProviderError error, const std::string& error_message); + void OnSimulateSolanaTransaction( + const std::string& chain_id, + std::unique_ptr meta, + uint64_t base_fee, + GetSolanaTxFeeEstimationForMetaCallback callback, + uint64_t compute_units_consumed, + mojom::SolanaProviderError error, + const std::string& error_message); + + void OnGetRecentSolanaPrioritizationFees( + std::unique_ptr meta, + uint64_t base_fee, + uint64_t compute_units, + GetSolanaTxFeeEstimationForMetaCallback callback, + std::vector>& recent_fees, + mojom::SolanaProviderError error, + const std::string& error_message); + + void OnGetEstimatedTxBaseFee(const std::string& chain_id, + GetSolanaTxFeeEstimationForMetaCallback callback, + std::unique_ptr meta, + const std::string& unsigned_tx, + uint64_t base_fee, + mojom::SolanaProviderError error, + const std::string& error_message); + + void FinishGetSolanaTxFeeEstimation(GetSolanaTxFeeEstimationCallback callback, + std::unique_ptr meta, + mojom::SolanaFeeEstimationPtr estimation, + mojom::SolanaProviderError error, + const std::string& error_message); + + void ContinueAddUnapprovedTransaction( + AddUnapprovedTransactionCallback callback, + std::unique_ptr meta, + mojom::SolanaFeeEstimationPtr estimation, + mojom::SolanaProviderError error, + const std::string& error_message); + // SolanaBlockTracker::Observer void OnLatestBlockhashUpdated(const std::string& chain_id, const std::string& blockhash, diff --git a/components/brave_wallet/browser/solana_tx_manager_unittest.cc b/components/brave_wallet/browser/solana_tx_manager_unittest.cc index 986924dffc4..23f4057b2a8 100644 --- a/components/brave_wallet/browser/solana_tx_manager_unittest.cc +++ b/components/brave_wallet/browser/solana_tx_manager_unittest.cc @@ -49,6 +49,23 @@ #include "ui/base/l10n/l10n_util.h" #include "url/origin.h" +namespace { + +constexpr char kEncodedSerializedMessage[] = + "FDmjJVJ5XUQPik2xqs7NqP7VdMkDXNWLimqTR8C2KstRHZAdRUoCMQr7LXUjQ6dSer9jfWWfbN" + "XzMToAWzoQLWvgduNCLxSVWVuiVZzqGPwC8mWT4SAu5NDCC5VTWcSNWj4Q9HSvgQitodttQiQR" + "3yQvRZJurNzub3SBK3umEqULkVJPYZJRCmPbXQm9ebPEXGYQRKrjiAt7"; + +constexpr char kMockGetFeeForMessageResponse[] = R"({ + "jsonrpc":"2.0","id":1, + "result": { + "context":{"slot":123065869}, + "value": 5000 + } + })"; + +} // namespace + namespace brave_wallet { class MockTxStateManagerObserver : public TxStateManager::Observer { @@ -86,6 +103,10 @@ class SolanaTxManagerUnitTest : public testing::Test { last_valid_block_height1_ = 3090; last_valid_block_height2_ = 3290; last_valid_block_height3_ = 3490; + fee_estimation1_ = mojom::SolanaFeeEstimation::New(); + fee_estimation1_->base_fee = 5000; + fee_estimation1_->compute_units = 69017 + 300; + fee_estimation1_->fee_per_compute_unit = 100; SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, "", false, last_valid_block_height1_); @@ -185,11 +206,13 @@ class SolanaTxManagerUnitTest : public testing::Test { const std::string& content = "", bool get_signature_statuses = false, uint64_t block_height = 0, - std::optional mock_signature_value = std::nullopt) { + std::optional mock_signature_value = std::nullopt, + std::optional> + rpc_method_responses = std::nullopt) { url_loader_factory_.SetInterceptor(base::BindLambdaForTesting( [&, latest_blockhash, tx_hash, content, get_signature_statuses, - last_valid_block_height, block_height, - mock_signature_value](const network::ResourceRequest& request) { + last_valid_block_height, block_height, mock_signature_value, + rpc_method_responses](const network::ResourceRequest& request) { url_loader_factory_.ClearResponses(); std::string_view request_string(request.request_body->elements() ->at(0) @@ -201,6 +224,13 @@ class SolanaTxManagerUnitTest : public testing::Test { std::string* method = request_root.FindString("method"); ASSERT_TRUE(method); + // Check if there's a custom response for the method + if (rpc_method_responses && rpc_method_responses->contains(*method)) { + url_loader_factory_.AddResponse(request.url.spec(), + rpc_method_responses->at(*method)); + return; + } + if (*method == "getLatestBlockhash") { url_loader_factory_.AddResponse( request.url.spec(), @@ -218,6 +248,28 @@ class SolanaTxManagerUnitTest : public testing::Test { request.url.spec(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"" + tx_hash + "\"}"); + } else if (*method == "simulateTransaction") { + url_loader_factory_.AddResponse(request.url.spec(), R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.17.25", + "slot": 259225005 + }, + "value": { + "accounts": null, + "err": null, + "logs": [ + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY invoke [1]", + "Program log: Instruction: Transfer", + "Program BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY success" + ], + "returnData": null, + "unitsConsumed": 69017 + } + }, + "id": 1 + })"); } else if (*method == "getAccountInfo" || *method == "getFeeForMessage") { url_loader_factory_.AddResponse(request.url.spec(), content); @@ -293,6 +345,25 @@ class SolanaTxManagerUnitTest : public testing::Test { )"; } url_loader_factory_.AddResponse(request.url.spec(), json); + } else if (*method == "getRecentPrioritizationFees") { + url_loader_factory_.AddResponse(request.url.spec(), R"({ + "jsonrpc": "2.0", + "result": [ + { + "prioritizationFee": 100, + "slot": 293251906 + }, + { + "prioritizationFee": 200, + "slot": 293251906 + }, + { + "prioritizationFee": 0, + "slot": 293251805 + } + ], + "id": 1 + })"); } })); } @@ -461,23 +532,6 @@ class SolanaTxManagerUnitTest : public testing::Test { run_loop.Run(); } - void TestGetEstimatedTxFee(const std::string& tx_meta_id, - uint64_t expected_tx_fee, - mojom::SolanaProviderError expected_error, - const std::string& expected_err_message) { - base::RunLoop run_loop; - solana_tx_manager()->GetEstimatedTxFee( - tx_meta_id, base::BindLambdaForTesting( - [&](uint64_t tx_fee, mojom::SolanaProviderError error, - const std::string& err_message) { - EXPECT_EQ(expected_tx_fee, tx_fee); - EXPECT_EQ(expected_error, error); - EXPECT_EQ(expected_err_message, err_message); - run_loop.Quit(); - })); - run_loop.Run(); - } - void TestGetTransactionMessageToSign( const std::string& tx_meta_id, std::optional> expected_tx_message) { @@ -559,6 +613,26 @@ class SolanaTxManagerUnitTest : public testing::Test { testing::Mock::VerifyAndClearExpectations(&observer); } + void TestGetSolanaTxFeeEstimation( + const std::string& chain_id, + const std::string& tx_meta_id, + mojom::SolanaFeeEstimationPtr expected_estimation, + mojom::SolanaProviderError expected_error, + const std::string& expected_error_message) { + base::RunLoop run_loop; + solana_tx_manager()->GetSolanaTxFeeEstimation( + chain_id, tx_meta_id, + base::BindLambdaForTesting([&](mojom::SolanaFeeEstimationPtr estimation, + mojom::SolanaProviderError error, + const std::string& error_message) { + EXPECT_EQ(expected_error, expected_error); + EXPECT_EQ(expected_error_message, error_message); + EXPECT_EQ(expected_estimation, estimation); + run_loop.Quit(); + })); + run_loop.Run(); + } + protected: base::test::TaskEnvironment task_environment_; base::ScopedTempDir temp_dir_; @@ -581,6 +655,7 @@ class SolanaTxManagerUnitTest : public testing::Test { uint64_t last_valid_block_height2_ = 0; uint64_t last_valid_block_height3_ = 0; size_t send_transaction_calls_ = 0; + mojom::SolanaFeeEstimationPtr fee_estimation1_; }; TEST_F(SolanaTxManagerUnitTest, AddAndApproveTransaction) { @@ -615,36 +690,119 @@ TEST_F(SolanaTxManagerUnitTest, AddAndApproveTransaction) { std::vector( {from_account_address, to_account, mojom::kSolanaSystemProgramId}), std::vector(), nullptr, - nullptr); + nullptr, nullptr); + // First add a partially signed transaction - it should fetch a base fee + // but not a priority fee (simulateTransaction + getRecentPrioritizationFees). + auto param = mojom::SolanaSignTransactionParam::New( + kEncodedSerializedMessage, std::vector()); + param->signatures.emplace_back(mojom::SignaturePubkeyPair::New( + std::vector(kSolanaSignatureSize, 1), sol_account()->address)); + solana_tx_data->sign_transaction_param = std::move(param); auto tx = SolanaTransaction::FromSolanaTxData(solana_tx_data.Clone()); ASSERT_TRUE(tx); - std::string meta_id1; + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_, std::nullopt); AddUnapprovedTransaction(mojom::kSolanaMainnet, solana_tx_data.Clone(), from_account, &meta_id1); - auto tx_meta1 = solana_tx_manager()->GetTxForTesting(meta_id1); ASSERT_TRUE(tx_meta1); - EXPECT_EQ(tx_meta1->chain_id(), mojom::kSolanaMainnet); - + mojom::SolanaFeeEstimationPtr expected_estimate = + mojom::SolanaFeeEstimation::New(); + expected_estimate->base_fee = 5000; + expected_estimate->compute_units = 0; + expected_estimate->fee_per_compute_unit = 0; + tx->set_fee_estimation(expected_estimate.Clone()); EXPECT_EQ(*tx_meta1->tx(), *tx); - EXPECT_EQ(tx_meta1->signature_status(), SolanaSignatureStatus()); - EXPECT_EQ(tx_meta1->from(), from_account); - EXPECT_EQ(tx_meta1->status(), mojom::TransactionStatus::Unapproved); + // Remove partial signature + solana_tx_data->sign_transaction_param = nullptr; + tx = SolanaTransaction::FromSolanaTxData(solana_tx_data.Clone()); + ASSERT_TRUE(tx); + + // When base fee fetching fails, the tx should not have a fee estimation. + base::flat_map responses; + responses["getFeeForMessage"] = "invalid"; + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, "", + false, last_valid_block_height1_, std::nullopt, responses); + AddUnapprovedTransaction(mojom::kSolanaMainnet, solana_tx_data.Clone(), + from_account, &meta_id1); + tx_meta1 = solana_tx_manager()->GetTxForTesting(meta_id1); + ASSERT_TRUE(tx_meta1); + EXPECT_FALSE(tx_meta1->tx()->fee_estimation()); + responses.clear(); + + // When priority fee fetching fails (simulateTransaction), the tx should have + // a fee estimation, but only base fee should be non zero. std::string meta_id2; + responses["simulateTransaction"] = "invalid"; + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_, std::nullopt, responses); AddUnapprovedTransaction(mojom::kSolanaMainnet, solana_tx_data.Clone(), from_account, &meta_id2); auto tx_meta2 = solana_tx_manager()->GetTxForTesting(meta_id2); ASSERT_TRUE(tx_meta2); - EXPECT_EQ(tx_meta2->chain_id(), mojom::kSolanaMainnet); - EXPECT_EQ(*tx_meta2->tx(), *tx); - EXPECT_EQ(tx_meta2->signature_status(), SolanaSignatureStatus()); - EXPECT_EQ(tx_meta2->from(), from_account); - EXPECT_EQ(tx_meta2->status(), mojom::TransactionStatus::Unapproved); + EXPECT_TRUE(tx_meta2->tx()->fee_estimation()); + EXPECT_EQ(tx_meta2->tx()->fee_estimation()->base_fee, 5000U); + EXPECT_EQ(tx_meta2->tx()->fee_estimation()->compute_units, 0U); + EXPECT_EQ(tx_meta2->tx()->fee_estimation()->fee_per_compute_unit, 0U); + responses.clear(); - ApproveTransaction(meta_id1); + // When priority fee fetching fails (getRecentPrioritizationFees), + // the tx should have a fee estimation, but fee_per_compute_unit should + // be the default. + std::string meta_id3; + responses["getRecentPrioritizationFees"] = "invalid"; + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_, std::nullopt, responses); + + AddUnapprovedTransaction(mojom::kSolanaMainnet, solana_tx_data.Clone(), + from_account, &meta_id3); + auto tx_meta3 = solana_tx_manager()->GetTxForTesting(meta_id3); + ASSERT_TRUE(tx_meta3); + EXPECT_TRUE(tx_meta3->tx()->fee_estimation()); + EXPECT_EQ(tx_meta3->tx()->fee_estimation()->base_fee, 5000U); + EXPECT_EQ(tx_meta3->tx()->fee_estimation()->compute_units, 69017U); + EXPECT_EQ(tx_meta3->tx()->fee_estimation()->fee_per_compute_unit, 1U); + + // When `everything is successful, the tx should have a fee estimation with + // each of base_fee, compute_units and fee_per_compute_unit set from data in + // RPC responses + std::string meta_id4; + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_); + AddUnapprovedTransaction(mojom::kSolanaMainnet, solana_tx_data.Clone(), + from_account, &meta_id4); + + auto tx_meta4 = solana_tx_manager()->GetTxForTesting(meta_id4); + ASSERT_TRUE(tx_meta4); + EXPECT_EQ(tx_meta4->chain_id(), mojom::kSolanaMainnet); + tx->message()->AddPriorityFee( + 69017 + 300, + 100); // Added priority automatically in AddUnapprovedTransaction + tx->set_fee_estimation(fee_estimation1_.Clone()); + EXPECT_EQ(*tx_meta4->tx(), *tx); + EXPECT_EQ(tx_meta4->signature_status(), SolanaSignatureStatus()); + EXPECT_EQ(tx_meta4->from(), from_account); + EXPECT_EQ(tx_meta4->status(), mojom::TransactionStatus::Unapproved); + + std::string meta_id5; + AddUnapprovedTransaction(mojom::kSolanaMainnet, solana_tx_data.Clone(), + from_account, &meta_id5); + auto tx_meta5 = solana_tx_manager()->GetTxForTesting(meta_id5); + ASSERT_TRUE(tx_meta5); + EXPECT_EQ(tx_meta5->chain_id(), mojom::kSolanaMainnet); + EXPECT_EQ(*tx_meta5->tx(), *tx); + EXPECT_EQ(tx_meta5->signature_status(), SolanaSignatureStatus()); + EXPECT_EQ(tx_meta5->from(), from_account); + EXPECT_EQ(tx_meta5->status(), mojom::TransactionStatus::Unapproved); + + ApproveTransaction(meta_id4); WaitForUpdatePendingTransactions(); tx->message()->set_recent_blockhash(latest_blockhash1_); @@ -652,29 +810,29 @@ TEST_F(SolanaTxManagerUnitTest, AddAndApproveTransaction) { tx->set_wired_tx( tx->GetSignedTransaction(keyring_service_.get(), from_account)); - tx_meta1 = solana_tx_manager()->GetTxForTesting(meta_id1); - ASSERT_TRUE(tx_meta1); - EXPECT_EQ(tx_meta1->chain_id(), mojom::kSolanaMainnet); - EXPECT_EQ(*tx_meta1->tx(), *tx); - EXPECT_EQ(tx_meta1->signature_status(), SolanaSignatureStatus()); - EXPECT_EQ(tx_meta1->from(), from_account); - EXPECT_EQ(tx_meta1->status(), mojom::TransactionStatus::Submitted); - EXPECT_EQ(tx_meta1->tx_hash(), tx_hash1_); + tx_meta4 = solana_tx_manager()->GetTxForTesting(meta_id4); + ASSERT_TRUE(tx_meta4); + EXPECT_EQ(tx_meta4->chain_id(), mojom::kSolanaMainnet); + EXPECT_EQ(*tx_meta4->tx(), *tx); + EXPECT_EQ(tx_meta4->signature_status(), SolanaSignatureStatus()); + EXPECT_EQ(tx_meta4->from(), from_account); + EXPECT_EQ(tx_meta4->status(), mojom::TransactionStatus::Submitted); + EXPECT_EQ(tx_meta4->tx_hash(), tx_hash1_); // Send another tx. SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash2_, "", false, last_valid_block_height1_); - ApproveTransaction(meta_id2); + ApproveTransaction(meta_id5); WaitForUpdatePendingTransactions(); - tx_meta2 = solana_tx_manager()->GetTxForTesting(meta_id2); - ASSERT_TRUE(tx_meta2); - EXPECT_EQ(tx_meta2->chain_id(), mojom::kSolanaMainnet); - EXPECT_EQ(*tx_meta2->tx(), *tx); - EXPECT_EQ(tx_meta2->signature_status(), SolanaSignatureStatus()); - EXPECT_EQ(tx_meta2->from(), from_account); - EXPECT_EQ(tx_meta2->status(), mojom::TransactionStatus::Submitted); - EXPECT_EQ(tx_meta2->tx_hash(), tx_hash2_); + tx_meta5 = solana_tx_manager()->GetTxForTesting(meta_id5); + ASSERT_TRUE(tx_meta5); + EXPECT_EQ(tx_meta5->chain_id(), mojom::kSolanaMainnet); + EXPECT_EQ(*tx_meta5->tx(), *tx); + EXPECT_EQ(tx_meta5->signature_status(), SolanaSignatureStatus()); + EXPECT_EQ(tx_meta5->from(), from_account); + EXPECT_EQ(tx_meta5->status(), mojom::TransactionStatus::Submitted); + EXPECT_EQ(tx_meta5->tx_hash(), tx_hash2_); // Fast forward to have block tracker run with current interceptor. task_environment_.FastForwardBy( @@ -686,14 +844,14 @@ TEST_F(SolanaTxManagerUnitTest, AddAndApproveTransaction) { // Fast forward again to have block tracker run with the new interceptor. task_environment_.FastForwardBy( base::Seconds(kSolanaBlockTrackerTimeInSeconds)); - tx_meta1 = solana_tx_manager()->GetTxForTesting(meta_id1); - EXPECT_EQ(mojom::TransactionStatus::Submitted, tx_meta1->status()); - EXPECT_EQ(tx_meta1->signature_status(), + tx_meta4 = solana_tx_manager()->GetTxForTesting(meta_id4); + EXPECT_EQ(mojom::TransactionStatus::Submitted, tx_meta4->status()); + EXPECT_EQ(tx_meta4->signature_status(), SolanaSignatureStatus(100u, 10u, "", "confirmed")); - tx_meta2 = solana_tx_manager()->GetTxForTesting(meta_id2); - EXPECT_EQ(mojom::TransactionStatus::Confirmed, tx_meta2->status()); - EXPECT_EQ(tx_meta2->signature_status(), + tx_meta5 = solana_tx_manager()->GetTxForTesting(meta_id5); + EXPECT_EQ(mojom::TransactionStatus::Confirmed, tx_meta5->status()); + EXPECT_EQ(tx_meta5->signature_status(), SolanaSignatureStatus(72u, 0u, "", "finalized")); } @@ -718,6 +876,10 @@ TEST_F(SolanaTxManagerUnitTest, WalletOrigin) { &system_transfer_data); ASSERT_TRUE(system_transfer_data); + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_); + std::string meta_id; AddUnapprovedTransaction(mojom::kSolanaMainnet, std::move(system_transfer_data), from, std::nullopt, @@ -738,6 +900,9 @@ TEST_F(SolanaTxManagerUnitTest, SomeSiteOrigin) { &system_transfer_data); ASSERT_TRUE(system_transfer_data); + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_); std::string meta_id; AddUnapprovedTransaction( mojom::kSolanaMainnet, std::move(system_transfer_data), from, @@ -790,7 +955,7 @@ TEST_F(SolanaTxManagerUnitTest, MakeSystemProgramTransferTxData) { std::vector( {from_account_address, to_account, mojom::kSolanaSystemProgramId}), std::vector(), nullptr, - nullptr); + nullptr, nullptr); TestMakeSystemProgramTransferTxData(from_account, to_account, 10000000, std::move(tx_data), @@ -876,7 +1041,7 @@ TEST_P(TokenProgramTest, MakeTokenProgramTransferTxData) { *to_associated_token_account, token_program_id, spl_token_mint_address}), std::vector(), nullptr, - nullptr); + nullptr, nullptr); // Owner is the token program account. std::string json = R"( @@ -945,7 +1110,7 @@ TEST_P(TokenProgramTest, MakeTokenProgramTransferTxData) { spl_token_mint_address, mojom::kSolanaSystemProgramId, token_program_id}), std::vector(), nullptr, - nullptr); + nullptr, nullptr); // Test owner is not token program account. SetAccountInfoInterceptor( @@ -1053,7 +1218,7 @@ TEST_F(SolanaTxManagerUnitTest, MakeTxDataFromBase64EncodedTransaction) { std::vector( {from_account, to_account, mojom::kSolanaSystemProgramId}), std::vector(), - send_options.ToMojomSendOptions(), nullptr); + send_options.ToMojomSendOptions(), nullptr, nullptr); TestMakeTxDataFromBase64EncodedTransaction( encoded_transaction, mojom::TransactionType::SolanaSwap, send_options.ToMojomSendOptions(), std::move(tx_data), @@ -1078,63 +1243,6 @@ TEST_F(SolanaTxManagerUnitTest, MakeTxDataFromBase64EncodedTransaction) { l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); } -TEST_F(SolanaTxManagerUnitTest, GetEstimatedTxFee) { - const auto& from = sol_account(); - const std::string to = "JDqrvDz8d8tFCADashbUKQDKfJZFobNy13ugN65t1wvV"; - mojom::SolanaTxDataPtr system_transfer_data = nullptr; - TestMakeSystemProgramTransferTxData(from, to, 10000000, nullptr, - mojom::SolanaProviderError::kSuccess, "", - &system_transfer_data); - ASSERT_TRUE(system_transfer_data); - - std::string system_transfer_meta_id; - AddUnapprovedTransaction(mojom::kSolanaMainnet, - std::move(system_transfer_data), from, - &system_transfer_meta_id); - ASSERT_FALSE(system_transfer_meta_id.empty()); - - std::string json = R"({ - "jsonrpc": "2.0", - "result": { "context": { "slot": 5068 }, "value": 18446744073709551615 }, - "id": 1 - })"; - - // GetEstimatedTxFee without a valid latest blockhash being returned by - // remote. - SetInterceptor("", 0, "", json); - TestGetEstimatedTxFee(system_transfer_meta_id, 0, - mojom::SolanaProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - - std::string json2 = R"({ - "jsonrpc": "2.0", - "result": { "context": { "slot": 5068 }, "value": null }, - "id": 1 - })"; - - // GetEstimatedTxFee with latest blockhash and non-null tx fee from remote. - SetInterceptor(latest_blockhash1_, last_valid_block_height1_, "", json); - TestGetEstimatedTxFee(system_transfer_meta_id, UINT64_MAX, - mojom::SolanaProviderError::kSuccess, ""); - - // GetEstimatedTxFee with cached blockhash and error at parsing tx fee. - SetInterceptor(latest_blockhash1_, last_valid_block_height1_, "", "{}"); - TestGetEstimatedTxFee(system_transfer_meta_id, 0, - mojom::SolanaProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - - // GetEstimatedTxFee with cached blockhash and null tx fee from remote. - SetInterceptor(latest_blockhash1_, last_valid_block_height1_, "", json2); - TestGetEstimatedTxFee(system_transfer_meta_id, 0, - mojom::SolanaProviderError::kSuccess, ""); - - // GetEstimatedTxFee with cached latest blockhash and non-null tx fee from - // remote. - SetInterceptor("", 0, "", json); - TestGetEstimatedTxFee(system_transfer_meta_id, UINT64_MAX, - mojom::SolanaProviderError::kSuccess, ""); -} - TEST_F(SolanaTxManagerUnitTest, DropTxWithInvalidBlockhash) { const auto& from = sol_account(); const std::string to = "JDqrvDz8d8tFCADashbUKQDKfJZFobNy13ugN65t1wvV"; @@ -1218,9 +1326,9 @@ TEST_F(SolanaTxManagerUnitTest, DropTxWithInvalidBlockhash_DappBlockhash) { system_transfer_data->recent_blockhash = latest_blockhash1_; ASSERT_EQ(system_transfer_data->last_valid_block_height, 0u); - // Mock GetBlockHeight response. - SetInterceptor(latest_blockhash1_, last_valid_block_height1_, "", "", true, - last_valid_block_height1_, "[null, null]"); + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_); std::string meta_id1; AddUnapprovedTransaction(mojom::kSolanaMainnet, system_transfer_data.Clone(), sol_account(), &meta_id1); @@ -1240,9 +1348,9 @@ TEST_F(SolanaTxManagerUnitTest, DropTxWithInvalidBlockhash_DappBlockhash) { auto tx_meta2 = solana_tx_manager()->GetTxForTesting(meta_id2); ASSERT_TRUE(tx_meta2); ASSERT_EQ(tx_meta2->tx()->message()->last_valid_block_height(), - last_valid_block_height1_ + 150); + last_valid_block_height1_ + kSolanaValidBlockHeightThreshold); tx_meta2->tx()->message()->set_last_valid_block_height( - last_valid_block_height2_ + 150); + last_valid_block_height2_ + kSolanaValidBlockHeightThreshold); ASSERT_TRUE( solana_tx_manager()->GetSolanaTxStateManager()->AddOrUpdateTx(*tx_meta2)); @@ -1253,7 +1361,7 @@ TEST_F(SolanaTxManagerUnitTest, DropTxWithInvalidBlockhash_DappBlockhash) { ASSERT_TRUE(tx_meta); ASSERT_EQ(tx_meta->tx()->message()->recent_blockhash(), latest_blockhash1_); ASSERT_EQ(tx_meta->tx()->message()->last_valid_block_height(), - last_valid_block_height1_ + 150); + last_valid_block_height1_ + kSolanaValidBlockHeightThreshold); EXPECT_EQ(tx_meta->status(), mojom::TransactionStatus::Submitted); tx_meta2 = solana_tx_manager()->GetTxForTesting(meta_id2); @@ -1310,8 +1418,9 @@ TEST_F(SolanaTxManagerUnitTest, DropTxAfterSafeDropThreshold) { &system_transfer_data); ASSERT_TRUE(system_transfer_data); - SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, "", - true, last_valid_block_height1_, "[null]"); + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, true, last_valid_block_height1_, + "[null]"); std::string meta_id1; AddUnapprovedTransaction(mojom::kSolanaMainnet, system_transfer_data.Clone(), from, &meta_id1); @@ -1349,6 +1458,9 @@ TEST_F(SolanaTxManagerUnitTest, RetryTransaction) { tx_data->recent_blockhash = latest_blockhash1_; tx_data->last_valid_block_height = last_valid_block_height1_; std::string meta_id1; + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_); AddUnapprovedTransaction(mojom::kSolanaMainnet, std::move(tx_data), sol_account(), &meta_id1); @@ -1358,7 +1470,7 @@ TEST_F(SolanaTxManagerUnitTest, RetryTransaction) { auto nonce_account = SolAccount(3); durable_nonce_tx_data->recent_blockhash = nonce_account->address; durable_nonce_tx_data->last_valid_block_height = - last_valid_block_height1_ + 150; + last_valid_block_height1_ + kSolanaValidBlockHeightThreshold; std::string meta_id2; AddUnapprovedTransaction(mojom::kSolanaMainnet, std::move(durable_nonce_tx_data), sol_account(), @@ -1449,24 +1561,24 @@ TEST_F(SolanaTxManagerUnitTest, GetTransactionMessageToSign) { mojom::SolanaProviderError::kSuccess, "", &system_transfer_data); ASSERT_TRUE(system_transfer_data); + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_); std::string system_transfer_meta_id; AddUnapprovedTransaction(mojom::kSolanaMainnet, std::move(system_transfer_data), from, &system_transfer_meta_id); ASSERT_FALSE(system_transfer_meta_id.empty()); - // Invalid latest blockhash yields null message - SetInterceptor("", 0, ""); - TestGetTransactionMessageToSign(system_transfer_meta_id, std::nullopt); - // Valid latest blockhash yields valid transaction message to sign SetInterceptor(latest_blockhash1_, last_valid_block_height1_, ""); std::optional> message = base::Base64Decode( - "AQABA2odJRVUDnxVZv71pBNy0DZ/" + "AQACBGodJRVUDnxVZv71pBNy0DZ/" "ui6dv1N37VgGEA+" - "aezhZAMzywrLOSju1o9VJQ5KaB2lsblgqvdjtkDFlmZHz4KQAAAAAAAAAAAAAAAAAAAAAAAA" - "AAAAAAAAAAAAAAAAAAMxJDpKM0uOHO7ND/" - "JXaMxecpg9Nv0bCw26RKZ1V1Oa5AQICAAEMAgAAAAEAAAAAAAAA"); + "aezhZAMzywrLOSju1o9VJQ5KaB2lsblgqvdjtkDFlmZHz4KQDBkZv5SEXMv/" + "srbpyw5vnvIzlu8X3EmssQ5s6QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AzEkOkozS44c7s0P8ldozF5ymD02/" + "RsLDbpEpnVXU5rkDAgAFAsUOAQACAAkDZAAAAAAAAAADAgABDAIAAAABAAAAAAAAAA=="); TestGetTransactionMessageToSign(system_transfer_meta_id, message); // Valid cached latest blockhash @@ -1493,6 +1605,9 @@ TEST_F(SolanaTxManagerUnitTest, ProcessSolanaHardwareSignature) { mojom::SolanaProviderError::kSuccess, "", &system_transfer_data); ASSERT_TRUE(system_transfer_data); + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_); std::string system_transfer_meta_id; AddUnapprovedTransaction(mojom::kSolanaMainnet, std::move(system_transfer_data), from, @@ -1506,12 +1621,6 @@ TEST_F(SolanaTxManagerUnitTest, ProcessSolanaHardwareSignature) { std::vector signature_bytes; EXPECT_TRUE(Base58Decode(signature, &signature_bytes, kSolanaSignatureSize)); - // Blockhash not set is invalid - TestProcessSolanaHardwareSignature( - system_transfer_meta_id, signature_bytes, false, - mojom::SolanaProviderError::kInternalError, - l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); - auto meta = solana_tx_manager()->GetTxForTesting(system_transfer_meta_id); meta->tx()->message()->set_recent_blockhash(latest_blockhash1_); meta->tx()->message()->set_last_valid_block_height(last_valid_block_height1_); @@ -1531,6 +1640,9 @@ TEST_F(SolanaTxManagerUnitTest, RebroadcastTransaction) { last_valid_block_height1_, "[null]"); std::string meta_id1; + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_); AddUnapprovedTransaction(mojom::kSolanaMainnet, data.Clone(), sol_account(), &meta_id1); ASSERT_FALSE(meta_id1.empty()); @@ -1564,8 +1676,8 @@ TEST_F(SolanaTxManagerUnitTest, RebroadcastTransaction) { // No rebroadcast when blockhash is expired. send_transaction_calls_ = 0; - SetInterceptor(latest_blockhash1_, last_valid_block_height1_, "", "", true, - last_valid_block_height2_, + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, "", + kMockGetFeeForMessageResponse, true, last_valid_block_height2_, R"([{"slot": 100, "confirmations": 100, "err": null, "confirmationStatus": "processed"}])"); task_environment_.FastForwardBy( @@ -1573,8 +1685,9 @@ TEST_F(SolanaTxManagerUnitTest, RebroadcastTransaction) { WaitForUpdatePendingTransactions(); EXPECT_EQ(send_transaction_calls_, 0u); - SetInterceptor(latest_blockhash2_, last_valid_block_height2_, "", "", true, - last_valid_block_height2_, "[null, null]"); + SetInterceptor(latest_blockhash2_, last_valid_block_height2_, "", + kMockGetFeeForMessageResponse, true, last_valid_block_height2_, + "[null, null]"); data->send_options = mojom::SolanaSendTransactionOptions::New( mojom::OptionalMaxRetries::New(1u), std::nullopt, nullptr); std::string meta_id2; @@ -1603,4 +1716,69 @@ TEST_F(SolanaTxManagerUnitTest, RebroadcastTransaction) { EXPECT_EQ(send_transaction_calls_, 1u); } +TEST_F(SolanaTxManagerUnitTest, GetSolanaTxFeeEstimation) { + // Fetching fee estimate for non existant tx id meta fails. + TestGetSolanaTxFeeEstimation( + mojom::kSolanaMainnet, "non existant tx meta id", {}, + mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_BRAVE_WALLET_TRANSACTION_NOT_FOUND)); + + // Add an unapproved tx manually (circumventing the fee estimation fetching + // built into that function). + const auto& from_account = sol_account(); + std::string from_account_address = from_account->address; + + std::string to_account = "JDqrvDz8d8tFCADashbUKQDKfJZFobNy13ugN65t1wvV"; + const std::vector data = {2, 0, 0, 0, 128, 150, 152, 0, 0, 0, 0, 0}; + + std::vector account_metas; + auto account_meta1 = + mojom::SolanaAccountMeta::New(from_account_address, nullptr, true, true); + auto account_meta2 = + mojom::SolanaAccountMeta::New(to_account, nullptr, false, true); + account_metas.push_back(std::move(account_meta1)); + account_metas.push_back(std::move(account_meta2)); + + auto instruction = mojom::SolanaInstruction::New( + mojom::kSolanaSystemProgramId, std::move(account_metas), data, nullptr); + std::vector instructions; + instructions.push_back(std::move(instruction)); + auto solana_tx_data = mojom::SolanaTxData::New( + "", 0, from_account_address, to_account, "", 10000000, 0, + mojom::TransactionType::SolanaSystemTransfer, std::move(instructions), + mojom::SolanaMessageVersion::kLegacy, + mojom::SolanaMessageHeader::New(1, 0, 1), + std::vector( + {from_account_address, to_account, mojom::kSolanaSystemProgramId}), + std::vector(), nullptr, + nullptr, nullptr); + + auto tx = SolanaTransaction::FromSolanaTxData(solana_tx_data.Clone()); + ASSERT_TRUE(tx); + + SolanaTxMeta meta(from_account, std::move(tx)); + meta.set_id(TxMeta::GenerateMetaID()); + meta.set_created_time(base::Time::Now()); + meta.set_status(mojom::TransactionStatus::Unapproved); + meta.set_chain_id(mojom::kSolanaMainnet); + + ASSERT_TRUE( + solana_tx_manager()->GetSolanaTxStateManager()->AddOrUpdateTx(meta)); + task_environment_.RunUntilIdle(); + + // Call fetch fee estimation with appropriate interceptors. Verify median and + // priority fees. + SetInterceptor(latest_blockhash1_, last_valid_block_height1_, tx_hash1_, + kMockGetFeeForMessageResponse, false, + last_valid_block_height1_); + mojom::SolanaFeeEstimationPtr expected_estimate = + mojom::SolanaFeeEstimation::New(); + expected_estimate->base_fee = 5000; + expected_estimate->compute_units = 69017 + 300; + expected_estimate->fee_per_compute_unit = 100; + TestGetSolanaTxFeeEstimation(mojom::kSolanaMainnet, meta.id(), + std::move(expected_estimate), + mojom::SolanaProviderError::kSuccess, ""); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_tx_meta_unittest.cc b/components/brave_wallet/browser/solana_tx_meta_unittest.cc index 786861e4736..67863c33c7e 100644 --- a/components/brave_wallet/browser/solana_tx_meta_unittest.cc +++ b/components/brave_wallet/browser/solana_tx_meta_unittest.cc @@ -171,7 +171,7 @@ TEST(SolanaTxMetaUnitTest, ToTransactionInfo) { std::vector( {from_account, to_account, mojom::kSolanaSystemProgramId}), std::vector(), nullptr, - nullptr)); + nullptr, nullptr)); } TEST(SolanaTxMetaUnitTest, ToValue) { diff --git a/components/brave_wallet/browser/tx_service.cc b/components/brave_wallet/browser/tx_service.cc index 1701247adb4..8e3b7fe2418 100644 --- a/components/brave_wallet/browser/tx_service.cc +++ b/components/brave_wallet/browser/tx_service.cc @@ -478,10 +478,12 @@ void TxService::MakeTxDataFromBase64EncodedTransaction( std::move(callback)); } -void TxService::GetEstimatedTxFee(const std::string& chain_id, - const std::string& tx_meta_id, - GetEstimatedTxFeeCallback callback) { - GetSolanaTxManager()->GetEstimatedTxFee(tx_meta_id, std::move(callback)); +void TxService::GetSolanaTxFeeEstimation( + const std::string& chain_id, + const std::string& tx_meta_id, + GetSolanaTxFeeEstimationCallback callback) { + GetSolanaTxManager()->GetSolanaTxFeeEstimation(chain_id, tx_meta_id, + std::move(callback)); } void TxService::ProcessSolanaHardwareSignature( diff --git a/components/brave_wallet/browser/tx_service.h b/components/brave_wallet/browser/tx_service.h index 8cd5c93e385..cf7a0b25d7c 100644 --- a/components/brave_wallet/browser/tx_service.h +++ b/components/brave_wallet/browser/tx_service.h @@ -219,10 +219,10 @@ class TxService : public KeyedService, const mojom::TransactionType tx_type, mojom::SolanaSendTransactionOptionsPtr send_options, MakeTxDataFromBase64EncodedTransactionCallback callback) override; - - void GetEstimatedTxFee(const std::string& chain_id, - const std::string& tx_meta_id, - GetEstimatedTxFeeCallback callback) override; + void GetSolanaTxFeeEstimation( + const std::string& chain_id, + const std::string& tx_meta_id, + GetSolanaTxFeeEstimationCallback callback) override; void ProcessSolanaHardwareSignature( const std::string& chain_id, const std::string& tx_meta_id, diff --git a/components/brave_wallet/common/brave_wallet.mojom b/components/brave_wallet/common/brave_wallet.mojom index a3dea484835..9a8324b4a42 100644 --- a/components/brave_wallet/common/brave_wallet.mojom +++ b/components/brave_wallet/common/brave_wallet.mojom @@ -1299,6 +1299,7 @@ const string kSolanaEd25519SigVerifyProgramId = "Ed25519SigVerify111111111111111111111111111"; const string kSolanaKeccakSecp256kProgramId = "KeccakSecp256k11111111111111111111111111111"; +const string kSolanaComputeBudgetProgramId = "ComputeBudget111111111111111111111111111111"; // It is required to keep these chain IDs lowercase ASCII const string kMainnetChainId = "0x1"; @@ -1944,8 +1945,8 @@ interface SolanaTxManagerProxy { uint8 decimals) => (SolanaTxData? tx_data, SolanaProviderError error, string error_message); - GetEstimatedTxFee(string chain_id, string tx_meta_id) - => (uint64 fee, SolanaProviderError error, string error_message); + GetSolanaTxFeeEstimation(string chain_id, string tx_meta_id) + => (SolanaFeeEstimation fee, SolanaProviderError error, string error_message); // Get transaction data from transaction bytes encoded in base64 format. // @@ -2409,6 +2410,15 @@ enum SolanaTokenInstruction { kInitializeMint2 = 20, }; +// https://docs.rs/solana-sdk/1.18.14/src/solana_sdk/compute_budget.rs.html#25-39 +enum SolanaComputeBudgetInstruction { + kUnused = 0, + kRequestHeapFrame = 1, + kSetComputeUnitLimit = 2, + kSetComputeUnitPrice = 3, + kSetLoadedAccountsDataSizeLimit = 4, +}; + // Solana instruction parameter names shared between C++ backend and UI. // Note: UI can add names they need here, and please update // solana_instruction_data_decoder.cc to use the same constant. @@ -2479,6 +2489,17 @@ struct SolanaMessageHeader { uint8 num_readonly_unsigned_accounts; }; +// https://github.com/solana-labs/solana/blob/ced8f6a512c61e0dd5308095ae8457add4a39e94/program-runtime/src/prioritization_fee.rs#L1-L2C41 +const uint64 kMicroLamportsPerLamport = 1000000; + +struct SolanaFeeEstimation { + uint64 base_fee; + + // Priority fee = compute units * fee_per_compute_unit + uint32 compute_units; + uint64 fee_per_compute_unit; +}; + struct SolanaTxData { string recent_blockhash; uint64 last_valid_block_height; @@ -2502,6 +2523,8 @@ struct SolanaTxData { // signAllTransactions provider APIs, which includes serialized message and // signatures from partial_sign. SolanaSignTransactionParam? sign_transaction_param; + + SolanaFeeEstimation? fee_estimation; }; // This is used by BraveWalletProviderDelegate::RequestPermissions for us to diff --git a/components/brave_wallet_ui/common/async/__mocks__/bridge.ts b/components/brave_wallet_ui/common/async/__mocks__/bridge.ts index 5e4b64a636c..04da9ff6830 100644 --- a/components/brave_wallet_ui/common/async/__mocks__/bridge.ts +++ b/components/brave_wallet_ui/common/async/__mocks__/bridge.ts @@ -998,11 +998,15 @@ export class MockedWalletApiProxy { solanaTxManagerProxy: Partial< InstanceType > = { - getEstimatedTxFee: async (chainId, txMetaId) => { + getSolanaTxFeeEstimation: async (chainId, txMetaId) => { return { error: 0, errorMessage: '', - fee: BigInt(100) + fee: { + baseFee: BigInt(0), + computeUnits: 0, + feePerComputeUnit: BigInt(0), + } } } } diff --git a/components/brave_wallet_ui/common/constants/mocks.ts b/components/brave_wallet_ui/common/constants/mocks.ts index a91f07571e0..458a5015c7c 100644 --- a/components/brave_wallet_ui/common/constants/mocks.ts +++ b/components/brave_wallet_ui/common/constants/mocks.ts @@ -479,7 +479,8 @@ BraveWallet.SignTransactionRequest = { ], addressTableLookups: [], sendOptions: undefined, - signTransactionParam: undefined + signTransactionParam: undefined, + feeEstimation: undefined } }, rawMessage: { bytes: [1], str: undefined }, @@ -563,7 +564,8 @@ SerializableTransactionInfo = { ], addressTableLookups: [], sendOptions: undefined, - signTransactionParam: undefined + signTransactionParam: undefined, + feeEstimation: undefined } }, txStatus: 0, @@ -639,7 +641,8 @@ BraveWallet.SignAllTransactionsRequest = { ], addressTableLookups: [], sendOptions: undefined, - signTransactionParam: undefined + signTransactionParam: undefined, + feeEstimation: undefined } }, { @@ -690,7 +693,8 @@ BraveWallet.SignAllTransactionsRequest = { ], addressTableLookups: [], sendOptions: undefined, - signTransactionParam: undefined + signTransactionParam: undefined, + feeEstimation: undefined } } ], diff --git a/components/brave_wallet_ui/common/slices/endpoints/transaction.endpoints.ts b/components/brave_wallet_ui/common/slices/endpoints/transaction.endpoints.ts index 5f5772989cc..3c3a458b35c 100644 --- a/components/brave_wallet_ui/common/slices/endpoints/transaction.endpoints.ts +++ b/components/brave_wallet_ui/common/slices/endpoints/transaction.endpoints.ts @@ -1772,14 +1772,22 @@ export const transactionEndpoints = ({ try { const { solanaTxManagerProxy } = baseQuery(undefined).data const { errorMessage, fee } = - await solanaTxManagerProxy.getEstimatedTxFee(arg.chainId, arg.txId) + await solanaTxManagerProxy.getSolanaTxFeeEstimation( + arg.chainId, + arg.txId + ) if (!fee) { throw new Error(errorMessage) } + const priorityFee = (BigInt(fee.computeUnits) + * BigInt(fee.feePerComputeUnit)) + / BigInt(BraveWallet.MICRO_LAMPORTS_PER_LAMPORT); + const totalFee = BigInt(fee.baseFee) + priorityFee; + return { - data: fee.toString() + data: totalFee.toString() } } catch (error) { return handleEndpointError( diff --git a/components/brave_wallet_ui/stories/mock-data/mock-transaction-info.ts b/components/brave_wallet_ui/stories/mock-data/mock-transaction-info.ts index 778f062baef..9728c83eebb 100644 --- a/components/brave_wallet_ui/stories/mock-data/mock-transaction-info.ts +++ b/components/brave_wallet_ui/stories/mock-data/mock-transaction-info.ts @@ -93,6 +93,7 @@ export const mockSolanaTransactionInfo: SerializableTransactionInfo = { encodedSerializedMsg: '', signatures: [] }, + feeEstimation: undefined, splTokenMintAddress: '', staticAccountKeys: [], toWalletAddress: mockSolanaAccountInfo.address, diff --git a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift index 470a92b7e0e..932f6d3fa94 100644 --- a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift +++ b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/AccountActivityStore.swift @@ -333,7 +333,7 @@ class AccountActivityStore: ObservableObject, WalletObserverStore { tokenInfoCache.append(contentsOf: unknownTokens) } case .sol: - solEstimatedTxFees = await solTxManagerProxy.estimatedTxFees(for: transactions) + solEstimatedTxFees = await solTxManagerProxy.solanaTxFeeEstimations(for: transactions) self.solEstimatedTxFeesCache.merge(with: solEstimatedTxFees) default: break diff --git a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/AssetDetailStore.swift b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/AssetDetailStore.swift index 74b24bfbb02..23a216b5c26 100644 --- a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/AssetDetailStore.swift +++ b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/AssetDetailStore.swift @@ -540,7 +540,7 @@ class AssetDetailStore: ObservableObject, WalletObserverStore { @MainActor private func updateSolEstimatedTxFeesCache( _ solTransactions: [BraveWallet.TransactionInfo] ) async { - let fees = await solTxManagerProxy.estimatedTxFees(for: solTransactions) + let fees = await solTxManagerProxy.solanaTxFeeEstimations(for: solTransactions) for (key, value) in fees { // update cached values self.solEstimatedTxFeesCache[key] = value } diff --git a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionConfirmationStore.swift b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionConfirmationStore.swift index e425af8d322..6d2e58a41dc 100644 --- a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionConfirmationStore.swift +++ b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionConfirmationStore.swift @@ -501,11 +501,15 @@ public class TransactionConfirmationStore: ObservableObject, WalletObserverStore for transactions: [BraveWallet.TransactionInfo] ) async { for transaction in transactions where transaction.coin == .sol { - let (solEstimatedTxFee, _, _) = await solTxManagerProxy.estimatedTxFee( + let (solEstimatedTxFee, _, _) = await solTxManagerProxy.solanaTxFeeEstimation( chainId: transaction.chainId, txMetaId: transaction.id ) - self.solEstimatedTxFeeCache[transaction.id] = solEstimatedTxFee + let priorityFee = + UInt64(solEstimatedTxFee.computeUnits) * solEstimatedTxFee.feePerComputeUnit + * BraveWallet.MicroLamportsPerLamport + let totalFee = solEstimatedTxFee.baseFee + priorityFee + self.solEstimatedTxFeeCache[transaction.id] = totalFee } updateTransaction( with: activeTransaction, diff --git a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionDetailsStore.swift b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionDetailsStore.swift index 9487f9c4df9..715c79d3b81 100644 --- a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionDetailsStore.swift +++ b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionDetailsStore.swift @@ -185,11 +185,15 @@ class TransactionDetailsStore: ObservableObject, WalletObserverStore { } if transaction.coin == .sol, solEstimatedTxFeesCache[transaction.id] == nil { - let (solEstimatedTxFee, _, _) = await solanaTxManagerProxy.estimatedTxFee( + let (solEstimatedTxFee, _, _) = await solanaTxManagerProxy.solanaTxFeeEstimation( chainId: network.chainId, txMetaId: transaction.id ) - self.solEstimatedTxFeesCache[transaction.id] = solEstimatedTxFee + let priorityFee = + UInt64(solEstimatedTxFee.computeUnits) * solEstimatedTxFee.feePerComputeUnit + * BraveWallet.MicroLamportsPerLamport + let totalFee = solEstimatedTxFee.baseFee + priorityFee + self.solEstimatedTxFeesCache[transaction.id] = totalFee } guard let parsedTransaction = transaction.parsedTransaction( diff --git a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift index 5b4a8d9858b..d5662c85b79 100644 --- a/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift +++ b/ios/brave-ios/Sources/BraveWallet/Crypto/Stores/TransactionsActivityStore.swift @@ -318,7 +318,7 @@ class TransactionsActivityStore: ObservableObject, WalletObserverStore { @MainActor private func updateSolEstimatedTxFeesCache( _ solTransactions: [BraveWallet.TransactionInfo] ) async { - let fees = await solTxManagerProxy.estimatedTxFees(for: solTransactions) + let fees = await solTxManagerProxy.solanaTxFeeEstimations(for: solTransactions) for (key, value) in fees { // update cached values self.solEstimatedTxFeesCache[key] = value } diff --git a/ios/brave-ios/Sources/BraveWallet/Extensions/SolanaTxManagerProxyExtensions.swift b/ios/brave-ios/Sources/BraveWallet/Extensions/SolanaTxManagerProxyExtensions.swift index 69135b228c5..e7839ac0d83 100644 --- a/ios/brave-ios/Sources/BraveWallet/Extensions/SolanaTxManagerProxyExtensions.swift +++ b/ios/brave-ios/Sources/BraveWallet/Extensions/SolanaTxManagerProxyExtensions.swift @@ -7,8 +7,8 @@ import BraveCore extension BraveWalletSolanaTxManagerProxy { - /// Fetches the estimatedTxFee for an array of transactions - @MainActor func estimatedTxFees( + /// Fetches the solanaTxFeeEstimation for an array of transactions + @MainActor func solanaTxFeeEstimations( for transactions: [BraveWallet.TransactionInfo] ) async -> [String: UInt64] { return await withTaskGroup( @@ -16,8 +16,12 @@ extension BraveWalletSolanaTxManagerProxy { body: { @MainActor group in for tx in transactions { group.addTask { @MainActor in - let (fee, _, _) = await self.estimatedTxFee(chainId: tx.chainId, txMetaId: tx.id) - return [tx.id: fee] + let (fee, _, _) = await self.solanaTxFeeEstimation(chainId: tx.chainId, txMetaId: tx.id) + let priorityFee = + (UInt64(fee.computeUnits) * fee.feePerComputeUnit) + / BraveWallet.MicroLamportsPerLamport + let totalFee = fee.baseFee + priorityFee + return [tx.id: totalFee] } } var estimatedFees: [String: UInt64] = [:] diff --git a/ios/brave-ios/Sources/BraveWallet/Preview Content/MockContent.swift b/ios/brave-ios/Sources/BraveWallet/Preview Content/MockContent.swift index 318c8ea9a21..b981613d91e 100644 --- a/ios/brave-ios/Sources/BraveWallet/Preview Content/MockContent.swift +++ b/ios/brave-ios/Sources/BraveWallet/Preview Content/MockContent.swift @@ -390,7 +390,8 @@ extension BraveWallet.TransactionInfo { staticAccountKeys: [], addressTableLookups: [], send: nil, - signTransactionParam: nil + signTransactionParam: nil, + feeEstimation: nil ) ), txStatus: .confirmed, @@ -429,7 +430,8 @@ extension BraveWallet.TransactionInfo { staticAccountKeys: [], addressTableLookups: [], send: nil, - signTransactionParam: nil + signTransactionParam: nil, + feeEstimation: nil ) ), txStatus: .confirmed, diff --git a/ios/brave-ios/Sources/BraveWallet/Preview Content/MockStores.swift b/ios/brave-ios/Sources/BraveWallet/Preview Content/MockStores.swift index 81fd4d5edf7..216e9e8e7b6 100644 --- a/ios/brave-ios/Sources/BraveWallet/Preview Content/MockStores.swift +++ b/ios/brave-ios/Sources/BraveWallet/Preview Content/MockStores.swift @@ -305,8 +305,13 @@ extension BraveWallet.TestSolanaTxManagerProxy { solTxManagerProxy._makeTokenProgramTransferTxData = { _, _, _, _, _, _, completion in completion(.init(), .success, "") } - solTxManagerProxy._estimatedTxFee = { _, _, completion in - completion(UInt64(0), .success, "") + solTxManagerProxy._solanaTxFeeEstimation = { _, _, completion in + let feeEstimation = BraveWallet.SolanaFeeEstimation( + baseFee: UInt64(0), + computeUnits: UInt32(0), + feePerComputeUnit: UInt64(0) + ) + completion(feeEstimation, .success, "") } return solTxManagerProxy diff --git a/ios/brave-ios/Tests/BraveWalletTests/AccountActivityStoreTests.swift b/ios/brave-ios/Tests/BraveWalletTests/AccountActivityStoreTests.swift index dd575da8aa2..130bc81f30d 100644 --- a/ios/brave-ios/Tests/BraveWalletTests/AccountActivityStoreTests.swift +++ b/ios/brave-ios/Tests/BraveWalletTests/AccountActivityStoreTests.swift @@ -165,7 +165,12 @@ class AccountActivityStoreTests: XCTestCase { } let solTxManagerProxy = BraveWallet.TestSolanaTxManagerProxy() - solTxManagerProxy._estimatedTxFee = { $2(0, .success, "") } + let feeEstimation = BraveWallet.SolanaFeeEstimation( + baseFee: UInt64(0), + computeUnits: UInt32(0), + feePerComputeUnit: UInt64(0) + ) + solTxManagerProxy._solanaTxFeeEstimation = { $2(feeEstimation, .success, "") } let ipfsApi = TestIpfsAPI() diff --git a/ios/brave-ios/Tests/BraveWalletTests/AssetDetailStoreTests.swift b/ios/brave-ios/Tests/BraveWalletTests/AssetDetailStoreTests.swift index e577a57824a..e7db00f70fa 100644 --- a/ios/brave-ios/Tests/BraveWalletTests/AssetDetailStoreTests.swift +++ b/ios/brave-ios/Tests/BraveWalletTests/AssetDetailStoreTests.swift @@ -87,8 +87,16 @@ class AssetDetailStoreTests: XCTestCase { } let solTxManagerProxy = BraveWallet.TestSolanaTxManagerProxy() - solTxManagerProxy._estimatedTxFee = { - $2(UInt64(0.1), .success, "") + solTxManagerProxy._solanaTxFeeEstimation = { _, _, completion in + completion( + BraveWallet.SolanaFeeEstimation( + baseFee: UInt64(1), + computeUnits: UInt32(0), + feePerComputeUnit: UInt64(0) + ), + .success, + "" + ) } let swapService = BraveWallet.TestSwapService() diff --git a/ios/brave-ios/Tests/BraveWalletTests/TransactionConfirmationStoreTests.swift b/ios/brave-ios/Tests/BraveWalletTests/TransactionConfirmationStoreTests.swift index 7d4d051242c..1aa00c88d5d 100644 --- a/ios/brave-ios/Tests/BraveWalletTests/TransactionConfirmationStoreTests.swift +++ b/ios/brave-ios/Tests/BraveWalletTests/TransactionConfirmationStoreTests.swift @@ -147,7 +147,13 @@ class TransactionConfirmationStoreTests: XCTestCase { } let solTxManagerProxy = BraveWallet.TestSolanaTxManagerProxy() - solTxManagerProxy._estimatedTxFee = { $2(0, .success, "") } + let feeEstimation = BraveWallet.SolanaFeeEstimation( + baseFee: UInt64(0), + computeUnits: UInt32(0), + feePerComputeUnit: UInt64(0) + ) + + solTxManagerProxy._solanaTxFeeEstimation = { $2(feeEstimation, .success, "") } let bitcoinWalletService = BraveWallet.TestBitcoinWalletService() bitcoinWalletService._balance = { accountId, completion in diff --git a/ios/brave-ios/Tests/BraveWalletTests/TransactionParserTests.swift b/ios/brave-ios/Tests/BraveWalletTests/TransactionParserTests.swift index fb1f33d6249..0acf82e21f8 100644 --- a/ios/brave-ios/Tests/BraveWalletTests/TransactionParserTests.swift +++ b/ios/brave-ios/Tests/BraveWalletTests/TransactionParserTests.swift @@ -762,7 +762,8 @@ class TransactionParserTests: XCTestCase { staticAccountKeys: [], addressTableLookups: [], send: .init(maxRetries: .init(maxRetries: 1), preflightCommitment: nil, skipPreflight: nil), - signTransactionParam: nil + signTransactionParam: nil, + feeEstimation: nil ) let transaction = BraveWallet.TransactionInfo( id: "7", @@ -868,7 +869,8 @@ class TransactionParserTests: XCTestCase { staticAccountKeys: [], addressTableLookups: [], send: .init(maxRetries: .init(maxRetries: 1), preflightCommitment: nil, skipPreflight: nil), - signTransactionParam: nil + signTransactionParam: nil, + feeEstimation: nil ) let transaction = BraveWallet.TransactionInfo( id: "7", @@ -957,7 +959,8 @@ class TransactionParserTests: XCTestCase { staticAccountKeys: [], addressTableLookups: [], send: .init(maxRetries: .init(maxRetries: 1), preflightCommitment: nil, skipPreflight: nil), - signTransactionParam: nil + signTransactionParam: nil, + feeEstimation: nil ) let transaction = BraveWallet.TransactionInfo( id: "7", diff --git a/ios/brave-ios/Tests/BraveWalletTests/TransactionsActivityStoreTests.swift b/ios/brave-ios/Tests/BraveWalletTests/TransactionsActivityStoreTests.swift index 2136d314638..cc992fc783d 100644 --- a/ios/brave-ios/Tests/BraveWalletTests/TransactionsActivityStoreTests.swift +++ b/ios/brave-ios/Tests/BraveWalletTests/TransactionsActivityStoreTests.swift @@ -144,7 +144,17 @@ class TransactionsActivityStoreTests: XCTestCase { } let solTxManagerProxy = BraveWallet.TestSolanaTxManagerProxy() - solTxManagerProxy._estimatedTxFee = { $2(UInt64(1), .success, "") } + solTxManagerProxy._solanaTxFeeEstimation = { _, _, completion in + completion( + BraveWallet.SolanaFeeEstimation( + baseFee: UInt64(1), + computeUnits: UInt32(0), + feePerComputeUnit: UInt64(0) + ), + .success, + "" + ) + } let mockUserManager = TestableWalletUserAssetManager() mockUserManager._getAllUserAssetsInNetworkAssets = { [weak self] networks, _ in