diff --git a/README.md b/README.md index f2a30322..f41328dc 100644 --- a/README.md +++ b/README.md @@ -405,16 +405,22 @@ Console.WriteLine(); ### Sending an SPL token ```c# +var wallet = new Wallet(MnemonicWords); +var ownerAccount = wallet.GetAccount(10); // fee payer + +// load wallet and its token accounts var client = ClientFactory.GetClient(Cluster.MainNet, logger); -var tokens = New TokenMintResolver(); -var wallet = TokenWallet.Load(client, tokens, feePayer); +var tokenDefs = new TokenMintResolver(); +var tokenWallet = TokenWallet.Load(client, tokenDefs, ownerAccount); // find source of funds -var source = wallet.TokenAccounts.ForToken(WellKnownTokens.Serum).WithAtLeast(12.75D).FirstOrDefault(); +var source = tokenWallet.TokenAccounts().ForToken(WellKnownTokens.Serum).WithAtLeast(12.75M).FirstOrDefault(); // single-line SPL send - sends 12.75 SRM to target wallet ATA -// if required, ATA will be created funded by feePayer -var sig = wallet.Send(source, 12.75D, target, feePayer); +// if required, ATA will be created funded by ownerAccount. +// transaction is signed by you in the txBuilder callback to avoid passing your private keys out of this scope +var sig = tokenWallet.Send(source, 12.75M, target, txBuilder => txBuilder.Build(ownerAccount)); +Console.WriteLine($"tx: {sig}"); ``` ## Support diff --git a/SharedBuildProperties.props b/SharedBuildProperties.props index a8cbc53f..c89c5565 100644 --- a/SharedBuildProperties.props +++ b/SharedBuildProperties.props @@ -2,7 +2,7 @@ xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> Solnet - 6.0.8 + 6.0.9 Copyright 2022 © Solnet blockmountain blockmountain diff --git a/src/Solnet.Extensions/Models/TokenWallet/TokenWalletFilterList.cs b/src/Solnet.Extensions/Models/TokenWallet/TokenWalletFilterList.cs index 7ef0b0a0..c9d603c3 100644 --- a/src/Solnet.Extensions/Models/TokenWallet/TokenWalletFilterList.cs +++ b/src/Solnet.Extensions/Models/TokenWallet/TokenWalletFilterList.cs @@ -93,6 +93,17 @@ public TokenWalletFilterList WithMint(string mint) return new TokenWalletFilterList(_list.Where(x => x.TokenMint == mint)); } + /// + /// Uses the TokenDef TokenMint to keep all matching accounts. + /// + /// A TokenDef instance. + /// A filtered list of accounts for the given mint. + public TokenWalletFilterList WithMint(TokenDef tokenDef) + { + if (tokenDef == null) throw new ArgumentNullException(nameof(tokenDef)); + return WithMint(tokenDef.TokenMint); + } + /// /// Keeps all accounts with at least the supplied minimum balance. /// diff --git a/src/Solnet.Extensions/TokenWallet.cs b/src/Solnet.Extensions/TokenWallet.cs index 2f6b4f75..6c114b92 100644 --- a/src/Solnet.Extensions/TokenWallet.cs +++ b/src/Solnet.Extensions/TokenWallet.cs @@ -72,6 +72,18 @@ private TokenWallet(ITokenWalletRpcProxy client, ITokenMintResolver mintResolver _ataCache = new Dictionary(); } + /// + /// Private constructor, get your instances via Load methods + /// + private TokenWallet(ITokenMintResolver mintResolver, PublicKey publicKey) + { + if (mintResolver is null) throw new ArgumentNullException(nameof(mintResolver)); + if (publicKey is null) throw new ArgumentNullException(nameof(publicKey)); + MintResolver = mintResolver; + PublicKey = publicKey; + _ataCache = new Dictionary(); + } + #region Overloaded Load methods /// @@ -181,6 +193,106 @@ public async static Task LoadAsync(ITokenWalletRpcProxy client, return output; } + + /// + /// Creates and loads a TokenWallet instance using an existing RPC batch call. + /// + /// An instance of SolanaRpcBatchWithCallbacks + /// An instance of a mint resolver. + /// The account public key. + /// The state commitment to consider when querying the ledger state. + /// A TokenWallet task that will trigger once the batch has executed. + public static Task LoadAsync(SolanaRpcBatchWithCallbacks batch, + ITokenMintResolver mintResolver, + PublicKey publicKey, + Commitment commitment = Commitment.Finalized) + { + if (publicKey == null) throw new ArgumentNullException(nameof(publicKey)); + if (!publicKey.IsOnCurve()) throw new ArgumentException("PublicKey not valid - check this is native wallet address (not an ATA, PDA or aux account)"); + return LoadAsync(batch, mintResolver, publicKey.Key, commitment); + } + + /// + /// Creates and loads a TokenWallet instance using an existing RPC batch call. + /// + /// An instance of SolanaRpcBatchWithCallbacks + /// An instance of a mint resolver. + /// The account public key. + /// The state commitment to consider when querying the ledger state. + /// A TokenWallet task that will trigger once the batch has executed. + public static Task LoadAsync(SolanaRpcBatchWithCallbacks batch, + ITokenMintResolver mintResolver, + string publicKey, + Commitment commitment = Commitment.Finalized) + { + if (batch == null) throw new ArgumentNullException(nameof(batch)); + if (mintResolver == null) throw new ArgumentNullException(nameof(mintResolver)); + if (publicKey == null) throw new ArgumentNullException(nameof(publicKey)); + + // create the task source + var taskSource = new TaskCompletionSource(); + var success = 0; + var fail = 0; + ulong lamports = 0; + List tokenAccounts = null; + + // function to create a token wallet when both callbacks have responsed (in any order) + Action wrapUp = ex => + { + if (success == 2) + { + var tokenWallet = new TokenWallet(mintResolver, new PublicKey(publicKey)); + tokenWallet.Lamports = lamports; + tokenWallet._tokenAccounts = tokenAccounts; + taskSource.SetResult(tokenWallet); + } + else if (fail + success == 2) + taskSource.SetException(new ApplicationException("Failed to load TokenWallet via Batch")); + }; + + // get sol balance + batch.GetBalance(publicKey, commitment, callback: (x, ex) => + { + // handle balance response + lock (taskSource) + { + if (x != null) + { + lamports = x.Value; + success += 1; + } + else + fail += 1; + } + + // finished? + wrapUp.Invoke(ex); + }); + + // load token accounts + batch.GetTokenAccountsByOwner(publicKey, null, TokenProgram.ProgramIdKey, commitment, callback: (x, ex) => + { + // handle token list response + lock (taskSource) + { + if (x != null) + { + tokenAccounts = x.Value; + success += 1; + } + else + fail += 1; + } + + // finished? + wrapUp.Invoke(ex); + }); + + // return the task + return taskSource.Task; + + } + #endregion /// diff --git a/src/Solnet.Rpc/SolanaRpcBatchWithCallbacks.cs b/src/Solnet.Rpc/SolanaRpcBatchWithCallbacks.cs index e9c4c4e9..ad00b0fc 100644 --- a/src/Solnet.Rpc/SolanaRpcBatchWithCallbacks.cs +++ b/src/Solnet.Rpc/SolanaRpcBatchWithCallbacks.cs @@ -53,7 +53,8 @@ public void AutoExecute(BatchAutoExecuteMode mode, int batchSizeTrigger) /// public void Flush() { - _composer.Flush(); + if (_composer.Count > 0) + _composer.Flush(); } #region RPC Methods diff --git a/src/Solnet.Rpc/Solnet.Rpc.csproj b/src/Solnet.Rpc/Solnet.Rpc.csproj index 87293031..0136dedc 100644 --- a/src/Solnet.Rpc/Solnet.Rpc.csproj +++ b/src/Solnet.Rpc/Solnet.Rpc.csproj @@ -10,7 +10,10 @@ <_Parameter1>Solnet.Rpc.Test - + + <_Parameter1>Solnet.Extensions.Test + + <_Parameter1>DynamicProxyGenAssembly2 diff --git a/test/Solnet.Extensions.Test/Resources/TokenWallet/SampleBatchRequest.json b/test/Solnet.Extensions.Test/Resources/TokenWallet/SampleBatchRequest.json new file mode 100644 index 00000000..9707eb52 --- /dev/null +++ b/test/Solnet.Extensions.Test/Resources/TokenWallet/SampleBatchRequest.json @@ -0,0 +1 @@ +[{"method":"getBalance","params":["9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5"],"jsonrpc":"2.0","id":0},{"method":"getTokenAccountsByOwner","params":["9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5",{"programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},{"encoding":"jsonParsed"}],"jsonrpc":"2.0","id":1}] \ No newline at end of file diff --git a/test/Solnet.Extensions.Test/Resources/TokenWallet/SampleBatchResponse.json b/test/Solnet.Extensions.Test/Resources/TokenWallet/SampleBatchResponse.json new file mode 100644 index 00000000..f18f972d --- /dev/null +++ b/test/Solnet.Extensions.Test/Resources/TokenWallet/SampleBatchResponse.json @@ -0,0 +1,219 @@ +[ + { + "jsonrpc": "2.0", + "result": { + "context": { + "slot": 79274779 + }, + "value": 168855000000 + }, + "id": 0 + }, + { + "jsonrpc": "2.0", + "result": { + "context": { + "slot": 79200468 + }, + "value": [ + { + "account": { + "data": { + "parsed": { + "info": { + "isNative": false, + "mint": "V15kW7xzaTJRDbdbC35GYT6QUcBtDzwmhrnGZDRtR2j", + "owner": "9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5", + "state": "initialized", + "tokenAmount": { + "amount": "1000", + "decimals": 2, + "uiAmount": 10.0, + "uiAmountString": "10" + } + }, + "type": "account" + }, + "program": "spl-token", + "space": 165 + }, + "executable": false, + "lamports": 2039280, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 195 + }, + "pubkey": "7WU3jHeeJh4sHUkVBpMrirpm2c518j61uhKSEimSM7WW" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "isNative": false, + "mint": "G5SNrU9nCx65WUcEksc7ue63Eib5wNyVZivVvZrDi8ZU", + "owner": "9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5", + "state": "initialized", + "tokenAmount": { + "amount": "1000", + "decimals": 2, + "uiAmount": 10.0, + "uiAmountString": "10" + } + }, + "type": "account" + }, + "program": "spl-token", + "space": 165 + }, + "executable": false, + "lamports": 2039280, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 195 + }, + "pubkey": "AZDRzWyQ4yeUWaNHx9BcEj3h92K1rwtk8FeacHEgjsL5" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "isNative": false, + "mint": "98mCaWvZYTmTHmimisaAQW4WGLphN1cWhcC7KtnZF819", + "owner": "9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5", + "state": "initialized", + "tokenAmount": { + "amount": "1000", + "decimals": 2, + "uiAmount": 10.0, + "uiAmountString": "10" + } + }, + "type": "account" + }, + "program": "spl-token", + "space": 165 + }, + "executable": false, + "lamports": 2039280, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 195 + }, + "pubkey": "G5SA5eMmbqSFnNZNB2fQV9ipHbh9y9KS65aZkAh9t8zv" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "isNative": false, + "mint": "88ocFjrLgHEMQRMwozC7NnDBQUsq2UoQaqREFZoDEex", + "owner": "9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5", + "state": "initialized", + "tokenAmount": { + "amount": "1000", + "decimals": 2, + "uiAmount": 10.0, + "uiAmountString": "10" + } + }, + "type": "account" + }, + "program": "spl-token", + "space": 165 + }, + "executable": false, + "lamports": 2039280, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 195 + }, + "pubkey": "4NSREK36nAr32vooa3L9z8tu6JWj5rY3k4KnsqTgynvm" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "isNative": false, + "mint": "6rmHU2X6nau25MdDgYpg53SXNuQ6BAPnsxSGraf3D4qM", + "owner": "9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5", + "state": "initialized", + "tokenAmount": { + "amount": "1000", + "decimals": 2, + "uiAmount": 10.0, + "uiAmountString": "10" + } + }, + "type": "account" + }, + "program": "spl-token", + "space": 165 + }, + "executable": false, + "lamports": 2039280, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 196 + }, + "pubkey": "gazDxCc7qX3RR4EiY7Y3ByBV5G6KTdELwusoJXnMZ3d" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "isNative": false, + "mint": "GXEbZguUSni5at9MQkEC4k9Q7iXzzfWtLsZfKWeUFeMW", + "owner": "9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5", + "state": "initialized", + "tokenAmount": { + "amount": "1000", + "decimals": 2, + "uiAmount": 10.0, + "uiAmountString": "10" + } + }, + "type": "account" + }, + "program": "spl-token", + "space": 165 + }, + "executable": false, + "lamports": 2039280, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 195 + }, + "pubkey": "4YqDGVsBzHfnyGweeVoYhyxvdcrYUBx8kwnfDohnAAyj" + }, + { + "account": { + "data": { + "parsed": { + "info": { + "isNative": false, + "mint": "D1NFXiUz8CmFaF9raPt57rMh9xYQp16zuNcRzBcBcUiq", + "owner": "9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5", + "state": "initialized", + "tokenAmount": { + "amount": "1000", + "decimals": 2, + "uiAmount": 10.0, + "uiAmountString": "10" + } + }, + "type": "account" + }, + "program": "spl-token", + "space": 165 + }, + "executable": false, + "lamports": 2039280, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 195 + }, + "pubkey": "CFAibeX7kA5QaGLfFHN93bMD1uap9nL7B7UvPrzaZVdZ" + } + ] + }, + "id": 0 + } +] \ No newline at end of file diff --git a/test/Solnet.Extensions.Test/Solnet.Extensions.Test.csproj b/test/Solnet.Extensions.Test/Solnet.Extensions.Test.csproj index ca9fa2d6..ff579957 100644 --- a/test/Solnet.Extensions.Test/Solnet.Extensions.Test.csproj +++ b/test/Solnet.Extensions.Test/Solnet.Extensions.Test.csproj @@ -30,6 +30,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/test/Solnet.Extensions.Test/TokenWalletTest.cs b/test/Solnet.Extensions.Test/TokenWalletTest.cs index d9b0f341..e1116c11 100644 --- a/test/Solnet.Extensions.Test/TokenWalletTest.cs +++ b/test/Solnet.Extensions.Test/TokenWalletTest.cs @@ -1,12 +1,18 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; +using Solnet.Extensions.Models; +using Solnet.Extensions.TokenMint; using Solnet.Programs; +using Solnet.Rpc; using Solnet.Rpc.Builders; +using Solnet.Rpc.Core.Http; using Solnet.Rpc.Messages; using Solnet.Wallet; using Solnet.Wallet.Utilities; using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Text.Json; using System.Text.Json.Serialization; @@ -17,7 +23,7 @@ namespace Solnet.Extensions.Test /// Reusing testing mock base class from SolnetRpc.Test project /// [TestClass] - public class TokenWalletTest + public class TokenWalletTest { private const string MnemonicWords = @@ -146,7 +152,7 @@ public void TestProvisionAtaInjectBuilder() var testAccounts = wallet.TokenAccounts().WithMint("98mCaWvZYTmTHmimisaAQW4WGLphN1cWhcC7KtnZF819"); Assert.AreEqual(1, testAccounts.Count()); Assert.AreEqual(0, testAccounts.WhichAreAssociatedTokenAccounts().Count()); - + // provision the ata var builder = new TransactionBuilder(); builder @@ -379,6 +385,138 @@ public void TestOnCurveSanityChecks() } + [TestMethod] + public void TestTokenWalletViaBatch() + { + + var expected_request = File.ReadAllText("Resources/TokenWallet/SampleBatchRequest.json"); + var expected_response = File.ReadAllText("Resources/TokenWallet/SampleBatchResponse.json"); + + // token resolver + var tokens = new TokenMintResolver(); + var testToken = new TokenMint.TokenDef("98mCaWvZYTmTHmimisaAQW4WGLphN1cWhcC7KtnZF819", "TEST", "TEST", 2); + tokens.Add(testToken); + + // init batch for mockery + var unusedRpcClient = ClientFactory.GetClient(Cluster.MainNet); + var batch = new SolanaRpcBatchWithCallbacks(unusedRpcClient); + + // test wallet + var ownerWallet = new Wallet.Wallet(MnemonicWords); + var signer = ownerWallet.GetAccount(1); + var pubkey = signer.PublicKey.Key; + Assert.AreEqual("9we6kjtbcZ2vy3GSLLsZTEhbAqXPTRvEyoxa8wxSqKp5", pubkey); + var walletPromise = TokenWallet.LoadAsync(batch, tokens, pubkey); + + // serialize batch and check we're good + var reqs = batch.Composer.CreateJsonRequests(); + var serializerOptions = CreateJsonOptions(); + var json = JsonSerializer.Serialize(reqs, serializerOptions); + Assert.IsNotNull(reqs); + Assert.AreEqual(2, reqs.Count); + Assert.AreEqual(expected_request, json); + + // fake RPC response + var resp = CreateMockRequestResult(expected_request, expected_response, HttpStatusCode.OK); + Assert.IsNotNull(resp.Result); + Assert.AreEqual(2, resp.Result.Count); + + // process and invoke callbacks - this will unblock walletPromise + batch.Composer.ProcessBatchResponse(resp); + + // assert wallet + var wallet = walletPromise.Result; + Assert.AreEqual((ulong)168855000000, wallet.Lamports); + Assert.AreEqual(168.855M, wallet.Sol); + Assert.AreEqual(168.855000000M, wallet.Sol); + + // define some mints + Assert.AreEqual(10M, wallet.TokenAccounts().WithSymbol("TEST").First().QuantityDecimal); + Assert.AreEqual(10M, wallet.TokenAccounts().WithMint(testToken).First().QuantityDecimal); + Assert.AreEqual(10M, wallet.TokenAccounts().WithAtLeast(10M).First().QuantityDecimal); + Assert.AreEqual(10M, wallet.TokenAccounts().WithAtLeast(1000U).First().QuantityDecimal); + Assert.AreEqual(10M, wallet.TokenAccounts().WithNonZero().First().QuantityDecimal); + + } + + [TestMethod] + public void TestTokenWalletFilterList() + { + var accounts = new List(); + var list = new TokenWalletFilterList(accounts); + var pass = false; + try + { + list.WithPublicKey((string)null); + } + catch (ArgumentException) + { + pass = true; + } + try + { + list.WithMint((TokenDef)null); + } + catch (ArgumentNullException) + { + pass = pass && true; + } + try + { + list.WithCustomFilter(null); + } + catch (ArgumentNullException) + { + pass = pass && true; + } + var count = 0; + foreach (var check in list) + count++; + Assert.AreEqual(0, count); + Assert.IsTrue(pass); + } + + /// + /// Common JSON options + /// + /// + private JsonSerializerOptions CreateJsonOptions() + { + return new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) + } + }; + } + + /// + /// Create a mocked RequestResult + /// + /// + /// + /// + /// + /// + public RequestResult CreateMockRequestResult(string req, string resp, HttpStatusCode status) + { + var x = new RequestResult(); + x.HttpStatusCode = status; + x.RawRpcRequest = req; + x.RawRpcResponse = resp; + + // deserialize resp + if (status == HttpStatusCode.OK) + { + var serializerOptions = CreateJsonOptions(); + x.Result = JsonSerializer.Deserialize(resp, serializerOptions); + } + + return x; + } + } diff --git a/test/Solnet.Rpc.Test/SolanaRpcClientBatchTests.cs b/test/Solnet.Rpc.Test/SolanaRpcClientBatchTests.cs index 7b874770..96f85ea9 100644 --- a/test/Solnet.Rpc.Test/SolanaRpcClientBatchTests.cs +++ b/test/Solnet.Rpc.Test/SolanaRpcClientBatchTests.cs @@ -149,6 +149,9 @@ public void TestAutoExecuteMode() // how many requests in batch? should be zero - already flushed/executed Assert.AreEqual(0, batch.Composer.Count); + // flush again - this should be non-fatal if batch is empty + batch.Flush(); + // assertions Assert.AreEqual((ulong)237543960, found_lamports); Assert.AreEqual(12.5M, found_balance);