diff --git a/Thirdweb.Tests/Thirdweb.Contracts.Tests.cs b/Thirdweb.Tests/Thirdweb.Contracts.Tests.cs index 0430c3c..83f9b64 100644 --- a/Thirdweb.Tests/Thirdweb.Contracts.Tests.cs +++ b/Thirdweb.Tests/Thirdweb.Contracts.Tests.cs @@ -100,9 +100,9 @@ public async Task WriteTest_SmartAccount() var data = new byte[] { }; var result = await ThirdwebContract.Write(smartAccount, contract, "claim", 0, receiver, quantity, currency, pricePerToken, allowlistProof, data); Assert.NotNull(result); - var receipt = await Utils.GetTransactionReceipt(contract.Client, contract.Chain, result); + var receipt = await ThirdwebTransaction.WaitForTransactionReceipt(contract.Client, contract.Chain, result.TransactionHash); Assert.NotNull(receipt); - Assert.Equal(result, receipt.TransactionHash); + Assert.Equal(result.TransactionHash, receipt.TransactionHash); } [Fact] diff --git a/Thirdweb.Tests/Thirdweb.Transactions.Tests.cs b/Thirdweb.Tests/Thirdweb.Transactions.Tests.cs new file mode 100644 index 0000000..bbe6b96 --- /dev/null +++ b/Thirdweb.Tests/Thirdweb.Transactions.Tests.cs @@ -0,0 +1,289 @@ +using System.Numerics; +using Nethereum.Hex.HexTypes; +using Nethereum.RPC.Eth.DTOs; + +namespace Thirdweb.Tests; + +public class TransactionTests : BaseTests +{ + public TransactionTests(ITestOutputHelper output) + : base(output) { } + + private async Task<ThirdwebTransaction> CreateSampleTransaction() + { + var client = ThirdwebClient.Create(secretKey: _secretKey); + var wallet = await PrivateKeyWallet.Create(client, _testPrivateKey); + var chainId = new BigInteger(1); + + var transaction = await ThirdwebTransaction.Create(client, wallet, new TransactionInput(), chainId); + return transaction; + } + + [Fact] + public async Task Create_ValidatesInputParameters() + { + var client = ThirdwebClient.Create(secretKey: _secretKey); + var wallet = await PrivateKeyWallet.Create(client, _testPrivateKey); + var txInput = new TransactionInput() { From = await wallet.GetAddress() }; + var chainId = new BigInteger(1); + var transaction = await ThirdwebTransaction.Create(client, wallet, txInput, chainId); + Assert.NotNull(transaction); + } + + [Fact] + public async Task Create_ThrowsOnInvalidAddress() + { + var client = ThirdwebClient.Create(secretKey: _secretKey); + var wallet = await PrivateKeyWallet.Create(client, _testPrivateKey); + var txInput = new TransactionInput() { From = "0x123" }; + var ex = await Assert.ThrowsAsync<ArgumentException>(() => ThirdwebTransaction.Create(client, wallet, txInput, BigInteger.Zero)); + Assert.Contains("Transaction sender (from) must match wallet address", ex.Message); + } + + [Fact] + public async Task Create_ThrowsOnInvalidChainId() + { + var client = ThirdwebClient.Create(secretKey: _secretKey); + var wallet = await PrivateKeyWallet.Create(client, _testPrivateKey); + var txInput = new TransactionInput(); + _ = await Assert.ThrowsAsync<ArgumentException>(() => ThirdwebTransaction.Create(client, wallet, txInput, BigInteger.Zero)); + } + + [Fact] + public async Task ToString_OverridesCorrectly() + { + var transaction = await CreateSampleTransaction(); + Assert.NotNull(transaction.ToString()); + Assert.StartsWith("{", transaction.ToString()); + } + + [Fact] + public async Task SetTo_UpdatesToAddress() + { + var transaction = await CreateSampleTransaction(); + _ = transaction.SetTo("0x456"); + Assert.Equal("0x456", transaction.Input.To); + } + + [Fact] + public async Task SetValue_SetsValue() + { + var transaction = await CreateSampleTransaction(); + var value = new BigInteger(1000); + _ = transaction.SetValue(value); + Assert.Equal(value.ToHexBigInteger(), transaction.Input.Value); + } + + [Fact] + public async Task SetValue_SetsData() + { + var transaction = await CreateSampleTransaction(); + var data = "0x123456"; + _ = transaction.SetData(data); + Assert.Equal(data, transaction.Input.Data); + } + + [Fact] + public async Task SetValue_SetsGasPrice() + { + var transaction = await CreateSampleTransaction(); + var gas = new BigInteger(1000); + _ = transaction.SetGasPrice(gas); + Assert.Equal(gas.ToHexBigInteger(), transaction.Input.GasPrice); + } + + [Fact] + public async Task Send_ThrowsIfToAddressNotProvided() + { + var transaction = await CreateSampleTransaction(); + _ = transaction.SetTo(null); + + _ = await Assert.ThrowsAsync<ArgumentException>(() => ThirdwebTransaction.Send(transaction)); + } + + [Fact] + public async Task Send_CorrectlyHandlesNonce() + { + var transaction = await CreateSampleTransaction(); + _ = transaction.SetNonce(123); + + Assert.Equal("0x7b", transaction.Input.Nonce.HexValue); + Assert.Equal("123", transaction.Input.Nonce.Value.ToString()); + } + + [Fact] + public async Task EstimateTotalCosts_CalculatesCostsCorrectly() + { + var transaction = await CreateSampleTransaction(); + _ = transaction.SetValue(new BigInteger(1000)); + _ = transaction.SetGasLimit(21000); + _ = transaction.SetGasPrice(new BigInteger(1000000000)); + + var costs = await ThirdwebTransaction.EstimateTotalCosts(transaction); + + Assert.NotEqual(BigInteger.Zero, costs.wei); + } + + [Fact] + public async Task EstimateTotalCosts_WithoutSetting_CalculatesCostsCorrectly() + { + var transaction = await CreateSampleTransaction(); + _ = transaction.SetValue(new BigInteger(1000)); + + var costs = await ThirdwebTransaction.EstimateTotalCosts(transaction); + + Assert.NotEqual(BigInteger.Zero, costs.wei); + } + + [Fact] + public async Task EstimateGasCosts_CalculatesCostsCorrectly() + { + var transaction = await CreateSampleTransaction(); + _ = transaction.SetValue(new BigInteger(1000)); + _ = transaction.SetGasLimit(21000); + _ = transaction.SetGasPrice(new BigInteger(1000000000)); + + var costs = await ThirdwebTransaction.EstimateGasCosts(transaction); + + Assert.NotEqual(BigInteger.Zero, costs.wei); + } + + [Fact] + public async Task EstimateGasCosts_WithoutSetting_CalculatesCostsCorrectly() + { + var transaction = await CreateSampleTransaction(); + _ = transaction.SetValue(new BigInteger(1000)); + + var costs = await ThirdwebTransaction.EstimateGasCosts(transaction); + + Assert.NotEqual(BigInteger.Zero, costs.wei); + } + + [Fact] + public async Task EstimateTotalCosts_HigherThanGasCostsByValue() + { + var transaction = await CreateSampleTransaction(); + _ = transaction.SetValue(new BigInteger(1000)); + _ = transaction.SetGasLimit(21000); + + var totalCosts = await ThirdwebTransaction.EstimateTotalCosts(transaction); + var gasCosts = await ThirdwebTransaction.EstimateGasCosts(transaction); + + Assert.True(totalCosts.wei > gasCosts.wei); + Assert.True(totalCosts.wei - gasCosts.wei == transaction.Input.Value.Value); + } + + [Fact] + public async Task EstimateGasPrice_BumpsCorrectly() + { + var transaction = await CreateSampleTransaction(); + var gasPrice = await ThirdwebTransaction.EstimateGasPrice(transaction, withBump: false); + var gasPriceWithBump = await ThirdwebTransaction.EstimateGasPrice(transaction, withBump: true); + Assert.NotEqual(gasPrice, gasPriceWithBump); + Assert.True(gasPriceWithBump > gasPrice); + } + + [Fact] + public async Task Simulate_ThrowsInsufficientFunds() + { + var transaction = await CreateSampleTransaction(); + _ = transaction.SetValue(new BigInteger(1000000000000000000)); + _ = transaction.SetGasLimit(21000); + + var exception = await Assert.ThrowsAsync<Exception>(() => ThirdwebTransaction.Simulate(transaction)); + Assert.Contains("insufficient funds", exception.Message); + } + + [Fact] + public async Task Simulate_ReturnsGasEstimate() + { + var client = ThirdwebClient.Create(secretKey: _secretKey); + var privateKeyAccount = await PrivateKeyWallet.Create(client, _testPrivateKey); + var smartAccount = await SmartWallet.Create(client, personalWallet: privateKeyAccount, factoryAddress: "0xbf1C9aA4B1A085f7DA890a44E82B0A1289A40052", gasless: true, chainId: 421614); + var transaction = await ThirdwebTransaction.Create(client, smartAccount, new TransactionInput(), 421614); + _ = transaction.SetValue(new BigInteger(0)); + _ = transaction.SetGasLimit(250000); + + var gas = await ThirdwebTransaction.Simulate(transaction); + Assert.NotEqual(BigInteger.Zero, gas); + } + + [Fact] + public async Task WaitForTransactionReceipt() + { + var client = ThirdwebClient.Create(secretKey: _secretKey); + var chainId = 421614; + var normalTxHash = "0x5a0b6cdb01ecfb25b368d3de1ac844414980ee3c330ec8c1435117b75027b5d7"; + var failedTxHash = "0xd2840219ffe172377c8a455c13d95e4dca204d5c0dd72232093e092eef412488"; + var aaTxHash = "0xbf76bd85e1759cf5cf9f4c7c52e76a74d32687f0b516017ff28192d04df50782"; + var aaSilentRevertTxHash = "0x8ada86c63846da7a3f91b8c8332de03f134e7619886425df858ee5400a9d9958"; + + var normalReceipt = await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, normalTxHash); + Assert.NotNull(normalReceipt); + + var failedReceipt = await Assert.ThrowsAsync<Exception>(async () => await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, failedTxHash)); + Assert.Equal($"Transaction {failedTxHash} execution reverted.", failedReceipt.Message); + + var aaReceipt = await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, aaTxHash); + Assert.NotNull(aaReceipt); + + var aaFailedReceipt = await Assert.ThrowsAsync<Exception>(async () => await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, aaSilentRevertTxHash)); + Assert.StartsWith($"Transaction {aaSilentRevertTxHash} execution silently reverted", aaFailedReceipt.Message); + } + + [Fact] + public async Task WaitForTransactionReceipt_AAReasonString() + { + var client = ThirdwebClient.Create(secretKey: _secretKey); + var chainId = 84532; + var aaSilentRevertTxHashWithReason = "0x5374743bbb749df47a279ac21e6ed472c30cd471923a7bc78db6a40e1b6924de"; + var aaFailedReceiptWithReason = await Assert.ThrowsAsync<Exception>(async () => await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, aaSilentRevertTxHashWithReason)); + Assert.StartsWith($"Transaction {aaSilentRevertTxHashWithReason} execution silently reverted:", aaFailedReceiptWithReason.Message); + } + + [Fact] + public async Task WaitForTransactionReceipt_CancellationToken() + { + var client = ThirdwebClient.Create(secretKey: _secretKey); + var chainId = 421614; + var normalTxHash = "0x5a0b6cdb01ecfb25b368d3de1ac844414980ee3c330ec8c1435117b75027b5d7"; + var failedTxHash = "0xd2840219ffe172377c8a455c13d95e4dca204d5c0dd72232093e092eef412488"; + var aaTxHash = "0xbf76bd85e1759cf5cf9f4c7c52e76a74d32687f0b516017ff28192d04df50782"; + var aaSilentRevertTxHash = "0x8ada86c63846da7a3f91b8c8332de03f134e7619886425df858ee5400a9d9958"; + + var cts = new CancellationTokenSource(); + cts.CancelAfter(10000); + var normalReceipt = await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, normalTxHash, cts.Token); + Assert.NotNull(normalReceipt); + + cts = new CancellationTokenSource(); + cts.CancelAfter(10000); + var failedReceipt = await Assert.ThrowsAsync<Exception>(async () => await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, failedTxHash, cts.Token)); + Assert.Equal($"Transaction {failedTxHash} execution reverted.", failedReceipt.Message); + + cts = new CancellationTokenSource(); + cts.CancelAfter(10000); + var aaReceipt = await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, aaTxHash, cts.Token); + Assert.NotNull(aaReceipt); + + cts = new CancellationTokenSource(); + cts.CancelAfter(10000); + var aaFailedReceipt = await Assert.ThrowsAsync<Exception>(async () => await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, aaSilentRevertTxHash, cts.Token)); + Assert.StartsWith($"Transaction {aaSilentRevertTxHash} execution silently reverted", aaFailedReceipt.Message); + + var infiniteTxHash = "0x55181384a4b908ddf6311cf0eb55ea0aa2b1ef4d9e0cc047eab9051fec284958"; + cts = new CancellationTokenSource(); + cts.CancelAfter(1); + var infiniteReceipt = await Assert.ThrowsAsync<TaskCanceledException>(async () => await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, infiniteTxHash, cts.Token)); + Assert.Equal("A task was canceled.", infiniteReceipt.Message); + + cts = new CancellationTokenSource(); + var infiniteReceipt2 = Assert.ThrowsAsync<TaskCanceledException>(() => ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, infiniteTxHash, cts.Token)); + await Task.Delay(2000); + cts.Cancel(); + Assert.Equal("A task was canceled.", (await infiniteReceipt2).Message); + + var aaReceipt2 = await ThirdwebTransaction.WaitForTransactionReceipt(client, chainId, aaTxHash, CancellationToken.None); + Assert.NotNull(aaReceipt2); + } +} diff --git a/Thirdweb.Tests/Thirdweb.Utils.Tests.cs b/Thirdweb.Tests/Thirdweb.Utils.Tests.cs index 01f5fa8..475b90b 100644 --- a/Thirdweb.Tests/Thirdweb.Utils.Tests.cs +++ b/Thirdweb.Tests/Thirdweb.Utils.Tests.cs @@ -1,4 +1,6 @@ -namespace Thirdweb.Tests; +using System.Numerics; + +namespace Thirdweb.Tests; public class UtilsTests : BaseTests { @@ -18,85 +20,6 @@ public void HexConcat() Assert.Equal("0x1234567890AB", Utils.HexConcat(hexStrings)); } - [Fact] - public async Task GetTransactionReceipt() - { - var client = ThirdwebClient.Create(secretKey: _secretKey); - var chainId = 421614; - var normalTxHash = "0x5a0b6cdb01ecfb25b368d3de1ac844414980ee3c330ec8c1435117b75027b5d7"; - var failedTxHash = "0xd2840219ffe172377c8a455c13d95e4dca204d5c0dd72232093e092eef412488"; - var aaTxHash = "0xbf76bd85e1759cf5cf9f4c7c52e76a74d32687f0b516017ff28192d04df50782"; - var aaSilentRevertTxHash = "0x8ada86c63846da7a3f91b8c8332de03f134e7619886425df858ee5400a9d9958"; - - var normalReceipt = await Utils.GetTransactionReceipt(client, chainId, normalTxHash); - Assert.NotNull(normalReceipt); - - var failedReceipt = await Assert.ThrowsAsync<Exception>(async () => await Utils.GetTransactionReceipt(client, chainId, failedTxHash)); - Assert.Equal($"Transaction {failedTxHash} execution reverted.", failedReceipt.Message); - - var aaReceipt = await Utils.GetTransactionReceipt(client, chainId, aaTxHash); - Assert.NotNull(aaReceipt); - - var aaFailedReceipt = await Assert.ThrowsAsync<Exception>(async () => await Utils.GetTransactionReceipt(client, chainId, aaSilentRevertTxHash)); - Assert.StartsWith($"Transaction {aaSilentRevertTxHash} execution silently reverted", aaFailedReceipt.Message); - } - - [Fact] - public async Task GetTransactionReceipt_AAReasonString() - { - var client = ThirdwebClient.Create(secretKey: _secretKey); - var chainId = 84532; - var aaSilentRevertTxHashWithReason = "0x5374743bbb749df47a279ac21e6ed472c30cd471923a7bc78db6a40e1b6924de"; - var aaFailedReceiptWithReason = await Assert.ThrowsAsync<Exception>(async () => await Utils.GetTransactionReceipt(client, chainId, aaSilentRevertTxHashWithReason)); - Assert.StartsWith($"Transaction {aaSilentRevertTxHashWithReason} execution silently reverted:", aaFailedReceiptWithReason.Message); - } - - [Fact] - public async Task GetTransactionReceipt_CancellationToken() - { - var client = ThirdwebClient.Create(secretKey: _secretKey); - var chainId = 421614; - var normalTxHash = "0x5a0b6cdb01ecfb25b368d3de1ac844414980ee3c330ec8c1435117b75027b5d7"; - var failedTxHash = "0xd2840219ffe172377c8a455c13d95e4dca204d5c0dd72232093e092eef412488"; - var aaTxHash = "0xbf76bd85e1759cf5cf9f4c7c52e76a74d32687f0b516017ff28192d04df50782"; - var aaSilentRevertTxHash = "0x8ada86c63846da7a3f91b8c8332de03f134e7619886425df858ee5400a9d9958"; - - var cts = new CancellationTokenSource(); - cts.CancelAfter(10000); - var normalReceipt = await Utils.GetTransactionReceipt(client, chainId, normalTxHash, cts.Token); - Assert.NotNull(normalReceipt); - - cts = new CancellationTokenSource(); - cts.CancelAfter(10000); - var failedReceipt = await Assert.ThrowsAsync<Exception>(async () => await Utils.GetTransactionReceipt(client, chainId, failedTxHash, cts.Token)); - Assert.Equal($"Transaction {failedTxHash} execution reverted.", failedReceipt.Message); - - cts = new CancellationTokenSource(); - cts.CancelAfter(10000); - var aaReceipt = await Utils.GetTransactionReceipt(client, chainId, aaTxHash, cts.Token); - Assert.NotNull(aaReceipt); - - cts = new CancellationTokenSource(); - cts.CancelAfter(10000); - var aaFailedReceipt = await Assert.ThrowsAsync<Exception>(async () => await Utils.GetTransactionReceipt(client, chainId, aaSilentRevertTxHash, cts.Token)); - Assert.StartsWith($"Transaction {aaSilentRevertTxHash} execution silently reverted", aaFailedReceipt.Message); - - var infiniteTxHash = "0x55181384a4b908ddf6311cf0eb55ea0aa2b1ef4d9e0cc047eab9051fec284958"; - cts = new CancellationTokenSource(); - cts.CancelAfter(1); - var infiniteReceipt = await Assert.ThrowsAsync<TaskCanceledException>(async () => await Utils.GetTransactionReceipt(client, chainId, infiniteTxHash, cts.Token)); - Assert.Equal("A task was canceled.", infiniteReceipt.Message); - - cts = new CancellationTokenSource(); - var infiniteReceipt2 = Assert.ThrowsAsync<TaskCanceledException>(() => Utils.GetTransactionReceipt(client, chainId, infiniteTxHash, cts.Token)); - await Task.Delay(2000); - cts.Cancel(); - Assert.Equal("A task was canceled.", (await infiniteReceipt2).Message); - - var aaReceipt2 = await Utils.GetTransactionReceipt(client, chainId, aaTxHash, CancellationToken.None); - Assert.NotNull(aaReceipt2); - } - [Fact] public void HashPrefixedMessage() { @@ -174,4 +97,109 @@ public void ReplaceIPFS() replaced = Utils.ReplaceIPFS(uri, gateway); Assert.Equal("https://ipfs.io/ipfs/QmXn1b6Q7", replaced); } + + [Fact] + public void ToWei_ConvertsCorrectly() + { + var eth = "1.5"; + var expectedWei = "1500000000000000000"; + Assert.Equal(expectedWei, Utils.ToWei(eth)); + } + + [Fact] + public void ToWei_ThrowsOnInvalidInput() + { + var invalidEth = "abc"; + _ = Assert.Throws<ArgumentException>(() => Utils.ToWei(invalidEth)); + } + + [Fact] + public void ToWei_ThrowsExceptionForInvalidInput() + { + var invalidEth = "invalid"; + _ = Assert.Throws<ArgumentException>(() => Utils.ToWei(invalidEth)); + } + + [Fact] + public void ToWei_ConvertsNegativeValue() + { + var negativeEth = "-1.5"; + var expectedWei = new BigInteger(-1.5 * Math.Pow(10, 18)).ToString(); + Assert.Equal(expectedWei, Utils.ToWei(negativeEth)); + } + + [Fact] + public void ToWei_ConvertsLargeFloat() + { + var largeEth = "1234567890.123456789"; + var expectedWei = new BigInteger(1234567890.123456789 * Math.Pow(10, 18)).ToString(); + Assert.Equal(expectedWei, Utils.ToWei(largeEth)); + } + + [Fact] + public void ToEth_ConvertsCorrectly() + { + var wei = "1500000000000000000"; + var expectedEth = "1.5000"; + Assert.Equal(expectedEth, Utils.ToEth(wei)); + } + + [Fact] + public void ToEth_WithCommas() + { + var wei = "1234500000000000000000"; + var expectedEth = "1,234.5000"; + Assert.Equal(expectedEth, Utils.ToEth(wei, 4, true)); + } + + [Fact] + public void ToEth_ConvertsZeroWei() + { + var zeroWei = "0"; + Assert.Equal("0.0000", Utils.ToEth(zeroWei)); + } + + [Fact] + public void ToEth_ConvertsSmallWei() + { + var smallWei = "1234"; + Assert.Equal("0.0000", Utils.ToEth(smallWei)); + } + + [Fact] + public void FormatERC20_NoDecimalsNoCommas() + { + var wei = "1500000000000000000"; + var expectedEth = "2"; + Assert.Equal(expectedEth, Utils.FormatERC20(wei, 0)); + } + + [Fact] + public void FormatERC20_LargeNumberWithCommas() + { + var wei = "1000000000000000000000000"; + var expectedEth = "1,000,000"; + Assert.Equal(expectedEth, Utils.FormatERC20(wei, 0, 18, true)); + } + + [Fact] + public void FormatERC20_ConvertsZeroWei() + { + var zeroWei = "0"; + Assert.Equal("0", Utils.FormatERC20(zeroWei, 0)); + } + + [Fact] + public void FormatERC20_SmallFractionalWei() + { + var fractionalWei = "10"; + Assert.Equal("0.0000", Utils.FormatERC20(fractionalWei, 4)); + } + + [Fact] + public void FormatERC20_ThrowsOnInvalidWei() + { + var invalidWei = "not_a_number"; + Assert.Throws<ArgumentException>(() => Utils.FormatERC20(invalidWei, 4)); + } } diff --git a/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs b/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs index 3f3b3b7..70472d1 100644 --- a/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs +++ b/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs @@ -63,44 +63,26 @@ public static async Task<T> Read<T>(ThirdwebContract contract, string method, pa return function.DecodeTypeOutput<T>(resultData); } - public static async Task<string> Write(IThirdwebWallet wallet, ThirdwebContract contract, string method, BigInteger weiValue, params object[] parameters) + public static async Task<ThirdwebTransaction> Prepare(IThirdwebWallet wallet, ThirdwebContract contract, string method, BigInteger weiValue, params object[] parameters) { - var rpc = ThirdwebRPC.GetRpcInstance(contract.Client, contract.Chain); - var service = new Nethereum.Contracts.Contract(null, contract.Abi, contract.Address); var function = service.GetFunction(method); var data = function.GetData(parameters); - var transaction = new TransactionInput { From = await wallet.GetAddress(), To = contract.Address, Data = data, + Value = new HexBigInteger(weiValue), }; - // TODO: Implement 1559 - transaction.Gas = new HexBigInteger(await rpc.SendRequestAsync<string>("eth_estimateGas", transaction)); - transaction.GasPrice = new HexBigInteger(await rpc.SendRequestAsync<string>("eth_gasPrice")); - transaction.GasPrice = new HexBigInteger(transaction.GasPrice.Value * 10 / 9); - transaction.Value = new HexBigInteger(weiValue); + return await ThirdwebTransaction.Create(contract.Client, wallet, transaction, contract.Chain); + } - string hash; - switch (wallet.AccountType) - { - case ThirdwebAccountType.PrivateKeyAccount: - transaction.Nonce = new HexBigInteger(await rpc.SendRequestAsync<string>("eth_getTransactionCount", await wallet.GetAddress(), "latest")); - var signedTx = await wallet.SignTransaction(transaction, contract.Chain); - hash = await rpc.SendRequestAsync<string>("eth_sendRawTransaction", signedTx); - break; - case ThirdwebAccountType.SmartAccount: - var smartAccount = wallet as SmartWallet; - hash = await smartAccount.SendTransaction(transaction); - break; - default: - throw new NotImplementedException("Account type not supported"); - } - Console.WriteLine($"Transaction hash: {hash}"); - return hash; + public static async Task<TransactionReceipt> Write(IThirdwebWallet wallet, ThirdwebContract contract, string method, BigInteger weiValue, params object[] parameters) + { + var thirdwebTx = await Prepare(wallet, contract, method, weiValue, parameters); + return await ThirdwebTransaction.SendAndWaitForTransactionReceipt(thirdwebTx); } } } diff --git a/Thirdweb/Thirdweb.Transactions/ThirdwebTransaction.cs b/Thirdweb/Thirdweb.Transactions/ThirdwebTransaction.cs new file mode 100644 index 0000000..d5411cf --- /dev/null +++ b/Thirdweb/Thirdweb.Transactions/ThirdwebTransaction.cs @@ -0,0 +1,226 @@ +using System.Numerics; +using Nethereum.Hex.HexTypes; +using Nethereum.RPC.Eth.DTOs; +using Nethereum.RPC.Eth.Transactions; +using Newtonsoft.Json; +using Nethereum.Contracts; +using Nethereum.ABI.FunctionEncoding; +using Nethereum.Hex.HexConvertors.Extensions; + +namespace Thirdweb +{ + public struct TotalCosts + { + public string ether; + public BigInteger wei; + } + + public class ThirdwebTransaction + { + public TransactionInput Input { get; private set; } + + private readonly ThirdwebClient _client; + private readonly IThirdwebWallet _wallet; + + private ThirdwebTransaction(ThirdwebClient client, IThirdwebWallet wallet, TransactionInput txInput, BigInteger chainId) + { + Input = txInput; + _client = client; + _wallet = wallet; + Input.ChainId = chainId.ToHexBigInteger(); + } + + public static async Task<ThirdwebTransaction> Create(ThirdwebClient client, IThirdwebWallet wallet, TransactionInput txInput, BigInteger chainId) + { + var address = await wallet.GetAddress(); + txInput.From ??= address; + return address != txInput.From + ? throw new ArgumentException("Transaction sender (from) must match wallet address") + : client == null + ? throw new ArgumentNullException(nameof(client)) + : wallet == null + ? throw new ArgumentNullException(nameof(wallet)) + : chainId == 0 + ? throw new ArgumentException("Invalid Chain ID") + : new ThirdwebTransaction(client, wallet, txInput, chainId); + } + + public override string ToString() + { + return JsonConvert.SerializeObject(Input); + } + + public ThirdwebTransaction SetTo(string to) + { + Input.To = to; + return this; + } + + public ThirdwebTransaction SetData(string data) + { + Input.Data = data; + return this; + } + + public ThirdwebTransaction SetValue(BigInteger weiValue) + { + Input.Value = weiValue.ToHexBigInteger(); + return this; + } + + public ThirdwebTransaction SetGasLimit(BigInteger gas) + { + Input.Gas = gas.ToHexBigInteger(); + return this; + } + + public ThirdwebTransaction SetGasPrice(BigInteger gasPrice) + { + Input.GasPrice = gasPrice.ToHexBigInteger(); + return this; + } + + public ThirdwebTransaction SetNonce(BigInteger nonce) + { + Input.Nonce = nonce.ToHexBigInteger(); + return this; + } + + public static async Task<TotalCosts> EstimateGasCosts(ThirdwebTransaction transaction) + { + var gasPrice = transaction.Input.GasPrice?.Value ?? await EstimateGasPrice(transaction); + var gasLimit = transaction.Input.Gas?.Value ?? await EstimateGasLimit(transaction, true); + var gasCost = BigInteger.Multiply(gasLimit, gasPrice); + return new TotalCosts { ether = gasCost.ToString().ToEth(18, false), wei = gasCost }; + } + + public static async Task<TotalCosts> EstimateTotalCosts(ThirdwebTransaction transaction) + { + var gasPrice = transaction.Input.GasPrice?.Value ?? await EstimateGasPrice(transaction); + var gasLimit = transaction.Input.Gas?.Value ?? await EstimateGasLimit(transaction, true); + var gasCost = BigInteger.Multiply(gasLimit, gasPrice); + var gasCostWithValue = BigInteger.Add(gasCost, transaction.Input.Value?.Value ?? 0); + return new TotalCosts { ether = gasCostWithValue.ToString().ToEth(18, false), wei = gasCostWithValue }; + } + + public static async Task<BigInteger> EstimateGasPrice(ThirdwebTransaction transaction, bool withBump = true) + { + { + var rpc = ThirdwebRPC.GetRpcInstance(transaction._client, transaction.Input.ChainId.Value); + var hex = new HexBigInteger(await rpc.SendRequestAsync<string>("eth_gasPrice")); + return withBump ? hex.Value * 10 / 9 : hex.Value; + } + } + + public static async Task<BigInteger> Simulate(ThirdwebTransaction transaction) + { + return await EstimateGasLimit(transaction, false); + } + + public static async Task<BigInteger> EstimateGasLimit(ThirdwebTransaction transaction, bool overrideBalance = true) + { + var rpc = ThirdwebRPC.GetRpcInstance(transaction._client, transaction.Input.ChainId.Value); + var from = transaction.Input.From; + var hex = overrideBalance + ? await rpc.SendRequestAsync<string>( + "eth_estimateGas", + transaction.Input, + "latest", + new Dictionary<string, Dictionary<string, string>>() + { + { + from, + new() { { "balance", "0xFFFFFFFFFFFFFFFFFFFF" } } + } + } + ) + : await rpc.SendRequestAsync<string>("eth_estimateGas", transaction.Input, "latest"); + + return new HexBigInteger(hex).Value; + } + + public static async Task<string> Send(ThirdwebTransaction transaction) + { + if (transaction.Input.To == null) + { + throw new ArgumentException("To address must be provided"); + } + + transaction.Input.From ??= await transaction._wallet.GetAddress(); + transaction.Input.Value ??= new HexBigInteger(0); + transaction.Input.Data ??= "0x"; + transaction.Input.GasPrice ??= new HexBigInteger(await EstimateGasPrice(transaction)); + transaction.Input.MaxFeePerGas = null; + transaction.Input.MaxPriorityFeePerGas = null; + transaction.Input.Gas ??= new HexBigInteger(await EstimateGasLimit(transaction)); + + var rpc = ThirdwebRPC.GetRpcInstance(transaction._client, transaction.Input.ChainId.Value); + string hash; + switch (transaction._wallet.AccountType) + { + case ThirdwebAccountType.PrivateKeyAccount: + transaction.Input.Nonce ??= new HexBigInteger(await rpc.SendRequestAsync<string>("eth_getTransactionCount", await transaction._wallet.GetAddress(), "latest")); + var signedTx = await transaction._wallet.SignTransaction(transaction.Input, transaction.Input.ChainId.Value); + hash = await rpc.SendRequestAsync<string>("eth_sendRawTransaction", signedTx); + break; + case ThirdwebAccountType.SmartAccount: + var smartAccount = transaction._wallet as SmartWallet; + hash = await smartAccount.SendTransaction(transaction.Input); + break; + default: + throw new NotImplementedException("Account type not supported"); + } + Console.WriteLine($"Transaction hash: {hash}"); + return hash; + } + + public static async Task<TransactionReceipt> SendAndWaitForTransactionReceipt(ThirdwebTransaction transaction) + { + var txHash = await Send(transaction); + return await WaitForTransactionReceipt(transaction._client, transaction.Input.ChainId.Value, txHash); + } + + public static async Task<TransactionReceipt> WaitForTransactionReceipt(ThirdwebClient client, BigInteger chainId, string txHash, CancellationToken cancellationToken = default) + { + var rpc = ThirdwebRPC.GetRpcInstance(client, chainId); + var receipt = await rpc.SendRequestAsync<TransactionReceipt>("eth_getTransactionReceipt", txHash).ConfigureAwait(false); + while (receipt == null) + { + if (cancellationToken != CancellationToken.None) + { + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + else + { + await Task.Delay(1000, CancellationToken.None).ConfigureAwait(false); + } + + receipt = await rpc.SendRequestAsync<TransactionReceipt>("eth_getTransactionReceipt", txHash).ConfigureAwait(false); + } + + if (receipt.Failed()) + { + throw new Exception($"Transaction {txHash} execution reverted."); + } + + var userOpEvent = receipt.DecodeAllEvents<AccountAbstraction.UserOperationEventEventDTO>(); + if (userOpEvent != null && userOpEvent.Count > 0 && userOpEvent[0].Event.Success == false) + { + var revertReasonEvent = receipt.DecodeAllEvents<AccountAbstraction.UserOperationRevertReasonEventDTO>(); + if (revertReasonEvent != null && revertReasonEvent.Count > 0) + { + var revertReason = revertReasonEvent[0].Event.RevertReason; + var revertReasonString = new FunctionCallDecoder().DecodeFunctionErrorMessage(revertReason.ToHex(true)); + throw new Exception($"Transaction {txHash} execution silently reverted: {revertReasonString}"); + } + else + { + throw new Exception($"Transaction {txHash} execution silently reverted with no reason string"); + } + } + + return receipt; + } + } +} diff --git a/Thirdweb/Thirdweb.Utils/Utils.cs b/Thirdweb/Thirdweb.Utils/Utils.cs index 280a789..9d3d94a 100644 --- a/Thirdweb/Thirdweb.Utils/Utils.cs +++ b/Thirdweb/Thirdweb.Utils/Utils.cs @@ -1,10 +1,8 @@ -using System.Numerics; +using System.Globalization; +using System.Numerics; using System.Security.Cryptography; using System.Text; -using Nethereum.ABI.FunctionEncoding; -using Nethereum.Contracts; using Nethereum.Hex.HexConvertors.Extensions; -using Nethereum.RPC.Eth.DTOs; using Nethereum.Signer; namespace Thirdweb @@ -30,49 +28,6 @@ public static string HexConcat(params string[] hexStrings) return hex.ToString(); } - public static async Task<TransactionReceipt> GetTransactionReceipt(ThirdwebClient client, BigInteger chainId, string txHash, CancellationToken cancellationToken = default) - { - var rpc = ThirdwebRPC.GetRpcInstance(client, chainId); - var receipt = await rpc.SendRequestAsync<TransactionReceipt>("eth_getTransactionReceipt", txHash).ConfigureAwait(false); - while (receipt == null) - { - if (cancellationToken != CancellationToken.None) - { - await Task.Delay(1000, cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - } - else - { - await Task.Delay(1000, CancellationToken.None).ConfigureAwait(false); - } - - receipt = await rpc.SendRequestAsync<TransactionReceipt>("eth_getTransactionReceipt", txHash).ConfigureAwait(false); - } - - if (receipt.Failed()) - { - throw new Exception($"Transaction {txHash} execution reverted."); - } - - var userOpEvent = receipt.DecodeAllEvents<AccountAbstraction.UserOperationEventEventDTO>(); - if (userOpEvent != null && userOpEvent.Count > 0 && userOpEvent[0].Event.Success == false) - { - var revertReasonEvent = receipt.DecodeAllEvents<AccountAbstraction.UserOperationRevertReasonEventDTO>(); - if (revertReasonEvent != null && revertReasonEvent.Count > 0) - { - var revertReason = revertReasonEvent[0].Event.RevertReason; - var revertReasonString = new FunctionCallDecoder().DecodeFunctionErrorMessage(revertReason.ToHex(true)); - throw new Exception($"Transaction {txHash} execution silently reverted: {revertReasonString}"); - } - else - { - throw new Exception($"Transaction {txHash} execution silently reverted with no reason string"); - } - } - - return receipt; - } - public static byte[] HashPrefixedMessage(this byte[] messageBytes) { var signer = new EthereumMessageSigner(); @@ -149,5 +104,39 @@ public static Dictionary<string, string> GetThirdwebHeaders(ThirdwebClient clien return headers; } + + public static string ToWei(this string eth) + { + if (!double.TryParse(eth, NumberStyles.Number, CultureInfo.InvariantCulture, out var ethDouble)) + { + throw new ArgumentException("Invalid eth value."); + } + + var wei = (BigInteger)(ethDouble * Constants.DECIMALS_18); + return wei.ToString(); + } + + public static string ToEth(this string wei, int decimalsToDisplay = 4, bool addCommas = false) + { + return FormatERC20(wei, decimalsToDisplay, 18, addCommas); + } + + public static string FormatERC20(this string wei, int decimalsToDisplay = 4, int decimals = 18, bool addCommas = false) + { + if (!BigInteger.TryParse(wei, out var weiBigInt)) + { + throw new ArgumentException("Invalid wei value."); + } + + var eth = (double)weiBigInt / Math.Pow(10.0, decimals); + var format = addCommas ? "#,0" : "#0"; + if (decimalsToDisplay > 0) + { + format += "."; + format += new string('0', decimalsToDisplay); + } + + return eth.ToString(format); + } } } diff --git a/Thirdweb/Thirdweb.Wallets/SmartWallet/SmartWallet.cs b/Thirdweb/Thirdweb.Wallets/SmartWallet/SmartWallet.cs index d08e406..fd08d9d 100644 --- a/Thirdweb/Thirdweb.Wallets/SmartWallet/SmartWallet.cs +++ b/Thirdweb/Thirdweb.Wallets/SmartWallet/SmartWallet.cs @@ -246,7 +246,7 @@ public async Task ForceDeploy() { var input = new TransactionInput("0x", _accountContract.Address, new HexBigInteger(0)); var txHash = await SendTransaction(input); - _ = await Utils.GetTransactionReceipt(_client, _chainId, txHash); + _ = await ThirdwebTransaction.WaitForTransactionReceipt(_client, _chainId, txHash); } public Task<IThirdwebWallet> GetPersonalAccount() @@ -350,7 +350,7 @@ string reqValidityEndTimestamp Data = data }; var txHash = await SendTransaction(txInput); - return await Utils.GetTransactionReceipt(_client, _chainId, txHash); + return await ThirdwebTransaction.WaitForTransactionReceipt(_client, _chainId, txHash); } public async Task<TransactionReceipt> AddAdmin(string admin) @@ -378,7 +378,7 @@ public async Task<TransactionReceipt> AddAdmin(string admin) Data = data }; var txHash = await SendTransaction(txInput); - return await Utils.GetTransactionReceipt(_client, _chainId, txHash); + return await ThirdwebTransaction.WaitForTransactionReceipt(_client, _chainId, txHash); } public async Task<TransactionReceipt> RemoveAdmin(string admin) @@ -406,7 +406,7 @@ public async Task<TransactionReceipt> RemoveAdmin(string admin) Data = data }; var txHash = await SendTransaction(txInput); - return await Utils.GetTransactionReceipt(_client, _chainId, txHash); + return await ThirdwebTransaction.WaitForTransactionReceipt(_client, _chainId, txHash); } public Task<string> SignTypedDataV4(string json)