diff --git a/Packages/Solana.Unity.Gum.dll b/Packages/Solana.Unity.Gum.dll new file mode 100644 index 00000000..fb2b67a5 Binary files /dev/null and b/Packages/Solana.Unity.Gum.dll differ diff --git a/Packages/Solana.Unity.Gum.dll.meta b/Packages/Solana.Unity.Gum.dll.meta new file mode 100644 index 00000000..fe8aa2e3 --- /dev/null +++ b/Packages/Solana.Unity.Gum.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: dadd4879b166e4e459e23d4eb0bbae7a +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/codebase/InGameWallet.cs b/Runtime/codebase/InGameWallet.cs index d33b5fe6..df41fcb7 100644 --- a/Runtime/codebase/InGameWallet.cs +++ b/Runtime/codebase/InGameWallet.cs @@ -17,8 +17,8 @@ public class InGameWallet : WalletBase { private const string EncryptedKeystoreKey = "EncryptedKeystore"; - public InGameWallet(RpcCluster rpcCluster = RpcCluster.DevNet, - string customRpcUri = null, string customStreamingRpcUri = null, + public InGameWallet(RpcCluster rpcCluster = RpcCluster.DevNet, + string customRpcUri = null, string customStreamingRpcUri = null, bool autoConnectOnStartup = false) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup) { } @@ -71,7 +71,7 @@ protected override Task _CreateAccount(string secret = null, string pas secret = mnem.ToString(); } if(account == null) return Task.FromResult(null); - + password ??= ""; var keystoreService = new KeyStorePbkdf2Service(); @@ -100,7 +100,7 @@ public override Task SignMessage(byte[] message) { return Task.FromResult(Account.Sign(message)); } - + /// /// Returns an instance of Keypair from a mnemonic, byte array or secret key /// @@ -124,7 +124,7 @@ public static Account FromSecret(string secret) return account; } - + /// /// Returns an instance of Keypair from a mnemonic /// @@ -135,7 +135,7 @@ private static Account FromMnemonic(string mnemonic) var wallet = new Wallet.Wallet(new Mnemonic(mnemonic)); return wallet.Account; } - + /// /// Returns an instance of Keypair from a secret key /// @@ -146,14 +146,14 @@ private static Account FromSecretKey(string secretKey) try { var wallet = new Wallet.Wallet(new PrivateKey(secretKey).KeyBytes, "", SeedMode.Bip39); - return wallet.Account; + return wallet.Account; }catch (ArgumentException) { return null; } } - + /// /// Returns an instance of Keypair from a Byte Array /// @@ -164,13 +164,13 @@ private static Account FromByteArray(byte[] secretByteArray) var wallet = new Wallet.Wallet(secretByteArray, "", SeedMode.Bip39); return wallet.Account; } - + /// - /// Takes a string as input and checks if it is a valid mnemonic + /// Takes a string as input and checks if it is a valid mnemonic /// /// /// - private static bool IsMnemonic(string secret) + protected static bool IsMnemonic(string secret) { return secret.Split(' ').Length is 12 or 24; } @@ -183,7 +183,7 @@ private static bool IsByteArray(string secret) { return secret.StartsWith('[') && secret.EndsWith(']'); } - + /// /// Takes a string as input and tries to parse it into a Keypair /// @@ -199,14 +199,14 @@ private static Account ParseByteArray(string secret) return FromByteArray(parsed); } - - private static string LoadPlayerPrefs(string key) + + protected static string LoadPlayerPrefs(string key) { return PlayerPrefs.GetString(key); } - private static void SavePlayerPrefs(string key, string value) + protected static void SavePlayerPrefs(string key, string value) { PlayerPrefs.SetString(key, value); #if UNITY_WEBGL diff --git a/Runtime/codebase/SessionWallet.cs b/Runtime/codebase/SessionWallet.cs new file mode 100644 index 00000000..0436ad0e --- /dev/null +++ b/Runtime/codebase/SessionWallet.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Solana.Unity.KeyStore.Exceptions; +using Solana.Unity.KeyStore.Services; +using Solana.Unity.Rpc.Models; +using Solana.Unity.Wallet; +using Solana.Unity.Programs; +using Solana.Unity.Wallet.Bip39; +using Solana.Unity.Gum.GplSession; +using Solana.Unity.Gum.GplSession.Accounts; +using Solana.Unity.Gum.GplSession.Program; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace Solana.Unity.SDK +{ + public class SessionWallet : InGameWallet + { + private const string EncryptedKeystoreKey = "SessionKeystore"; + + public PublicKey TargetProgram { get; protected set; } + public PublicKey SessionTokenPDA { get; protected set; } + + public SessionWallet(RpcCluster rpcCluster = RpcCluster.DevNet, + string customRpcUri = null, string customStreamingRpcUri = null, + bool autoConnectOnStartup = false) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup) + { + } + + /// + /// Checks if a session wallet exists by checking if the encrypted keystore key is present in the player preferences. + /// + /// True if a session wallet exists, false otherwise. + public static bool HasSessionWallet() + { + var prefs = LoadPlayerPrefs(EncryptedKeystoreKey); + return !string.IsNullOrEmpty(prefs); + } + + /// + /// Derives the public key of the session token account for the current session wallet. + /// + /// The public key of the session token account. + private static PublicKey FindSessionToken(PublicKey TargetProgram, Account Account, Account Authority) + { + return SessionToken.DeriveSessionTokenAccount( + authority: Authority.PublicKey, + targetProgram: TargetProgram, + sessionSigner: Account.PublicKey + ); + } + + /// + /// Creates a new SessionWallet instance and logs in with the provided password if a session wallet exists, otherwise creates a new account and logs in. + /// + /// The target program to interact with. + /// The password to decrypt the session keystore. + /// The Solana RPC cluster to connect to. + /// A custom URI to connect to the Solana RPC cluster. + /// A custom URI to connect to the Solana streaming RPC cluster. + /// Whether to automatically connect to the Solana RPC cluster on startup. + /// A SessionWallet instance. + public static async Task GetSessionWallet(PublicKey targetProgram, string password, RpcCluster rpcCluster = RpcCluster.DevNet, + string customRpcUri = null, string customStreamingRpcUri = null, + bool autoConnectOnStartup = false) + { + Debug.Log("Found Session Wallet"); + SessionWallet sessionWallet = new SessionWallet(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup); + sessionWallet.TargetProgram = targetProgram; + if (HasSessionWallet()) + { + sessionWallet.Account = await sessionWallet.Login(password); + sessionWallet.SessionTokenPDA = FindSessionToken(targetProgram, sessionWallet.Account, Web3.Account); + + // If it is not uninitialized, return the session wallet + if(!(await sessionWallet.IsSessionTokenInitialized())) { + Debug.Log("Session Token is not initialized"); + return sessionWallet; + } + + // Otherwise check for a valid session token + if ((await sessionWallet.IsSessionTokenValid())) { + Debug.Log("Session Token is valid"); + return sessionWallet; + } + else + { + Debug.Log("Session Token is invalid"); + sessionWallet.Logout(); + return await GetSessionWallet(targetProgram, password, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup); + } + } + sessionWallet.Account = await sessionWallet.CreateAccount(password:password); + sessionWallet.SessionTokenPDA = FindSessionToken(targetProgram, sessionWallet.Account, Web3.Account); + return sessionWallet; + } + + /// + protected override Task _Login(string password = "") + { + var keystoreService = new KeyStorePbkdf2Service(); + var encryptedKeystoreJson = LoadPlayerPrefs(EncryptedKeystoreKey); + byte[] decryptedKeystore; + try + { + if (string.IsNullOrEmpty(encryptedKeystoreJson)) + return Task.FromResult(null); + decryptedKeystore = keystoreService.DecryptKeyStoreFromJson(password, encryptedKeystoreJson); + } + catch (DecryptionException e) + { + Debug.LogException(e); + return Task.FromResult(null); + } + + var secret = Encoding.UTF8.GetString(decryptedKeystore); + var account = FromSecret(secret); + if (IsMnemonic(secret)) + { + var restoredMnemonic = new Mnemonic(secret); + Mnemonic = restoredMnemonic; + } + return Task.FromResult(account); + } + + /// + public override async void Logout() + { + // Revoke Session + var tx = new Transaction() + { + FeePayer = Account, + Instructions = new List(), + RecentBlockHash = await Web3.BlockHash() + }; + + // Get balance and calculate refund + var balance = await GetBalance(Account.PublicKey); + var estimatedFees = await ActiveRpcClient.GetFeeCalculatorForBlockhashAsync(tx.RecentBlockHash); + //var refund = balance - (estimatedFees.LamportsPerSignature * 2); + var refund = balance - 1000000; + + tx.Add(RevokeSessionIX()); + // Issue Refund + tx.Add(SystemProgram.Transfer(Account.PublicKey, Web3.Account.PublicKey, (ulong)refund)); + await SignAndSendTransaction(tx); + // Purge Keystore + PlayerPrefs.DeleteKey(EncryptedKeystoreKey); + base.Logout(); + } + + /// + protected override Task _CreateAccount(string secret = null, string password = null) + { + Account account; + Mnemonic mnem = null; + if (secret != null) + { + account = FromSecret(secret); + if (IsMnemonic(secret)) + { + mnem = new Mnemonic(secret); + } + } + else + { + mnem = new Mnemonic(WordList.English, WordCount.Twelve); + var wallet = new Wallet.Wallet(mnem); + account = wallet.Account; + secret = mnem.ToString(); + } + if (account == null) return Task.FromResult(null); + + password ??= ""; + + var keystoreService = new KeyStorePbkdf2Service(); + var stringByteArray = Encoding.UTF8.GetBytes(secret); + var encryptedKeystoreJson = keystoreService.EncryptAndGenerateKeyStoreAsJson( + password, stringByteArray, account.PublicKey.Key); + + SavePlayerPrefs(EncryptedKeystoreKey, encryptedKeystoreJson); + Mnemonic = mnem; + return Task.FromResult(account); + } + + /// + /// Creates a transaction instruction to create a new session token account and initialize it with the provided session signer and target program. + /// + /// Whether to top up the session token account with SOL. + /// The validity period of the session token account, in seconds. + /// A transaction instruction to create a new session token account. + public TransactionInstruction CreateSessionIX(bool topUp, long sessionValidity) + { + CreateSessionAccounts createSessionAccounts = new CreateSessionAccounts() + { + SessionToken = SessionTokenPDA, + SessionSigner = Account.PublicKey, + Authority = Web3.Account, + TargetProgram = TargetProgram, + SystemProgram = SystemProgram.ProgramIdKey, + }; + + return GplSessionProgram.CreateSession( + createSessionAccounts, + topUp: topUp, + validUntil: sessionValidity + ); + } + + /// + /// Creates a transaction instruction to revoke the current session token account. + /// + /// A transaction instruction to revoke the current session token account. + public TransactionInstruction RevokeSessionIX() + { + RevokeSessionAccounts revokeSessionAccounts = new RevokeSessionAccounts() + { + SessionToken = SessionTokenPDA, + Authority = Account, + SystemProgram = SystemProgram.ProgramIdKey, + }; + + return GplSessionProgram.RevokeSession( + revokeSessionAccounts + ); + } + + /// + /// Checks if the session token account has been initialized by checking if the account data is present on the blockchain. + /// + /// True if the session token account has been initialized, false otherwise. + public async Task IsSessionTokenInitialized() + { + var sessionTokenData = await ActiveRpcClient.GetAccountInfoAsync(SessionTokenPDA); + return sessionTokenData.Result.Value != null; + } + + /// + /// Checks if the session token is still valid by verifying if the session token account exists on the blockchain and if its validity period has not expired. + /// + /// True if the session token is still valid, false otherwise. + public async Task IsSessionTokenValid() + { + var sessionTokenData = (await ActiveRpcClient.GetAccountInfoAsync(SessionTokenPDA)).Result.Value.Data[0]; + if (sessionTokenData == null) return false; + return SessionToken.Deserialize(Convert.FromBase64String(sessionTokenData)).ValidUntil > DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + } + + } +} diff --git a/Runtime/codebase/SessionWallet.cs.meta b/Runtime/codebase/SessionWallet.cs.meta new file mode 100644 index 00000000..7e6cd524 --- /dev/null +++ b/Runtime/codebase/SessionWallet.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0a63555b655f44dff8272988e1c6e082 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/codebase/WalletBase.cs b/Runtime/codebase/WalletBase.cs index 28d958bb..f116d3e2 100644 --- a/Runtime/codebase/WalletBase.cs +++ b/Runtime/codebase/WalletBase.cs @@ -35,7 +35,7 @@ public abstract class WalletBase : IWalletBase { 1, Cluster.DevNet }, { 2, Cluster.TestNet } }; - + protected readonly Dictionary RPCNameMap = new () { { 0, "mainnet-beta" }, @@ -98,7 +98,7 @@ public async Task CreateAccount(string mnemonic = null, string password Account = await _CreateAccount(mnemonic, password); return Account; } - + /// /// Create a new account /// @@ -106,14 +106,14 @@ public async Task CreateAccount(string mnemonic = null, string password /// /// protected abstract Task _CreateAccount(string mnemonic = null, string password = null); - + /// public async Task GetBalance(PublicKey publicKey, Commitment commitment = Commitment.Finalized) { var balance= await ActiveRpcClient.GetBalanceAsync(publicKey, commitment); return (double)(balance.Result?.Value ?? 0) / SolLamports; } - + /// public async Task GetBalance(Commitment commitment = Commitment.Finalized) { @@ -122,13 +122,13 @@ public async Task GetBalance(Commitment commitment = Commitment.Finalize /// public async Task> Transfer( - PublicKey destination, - PublicKey tokenMint, - ulong amount, + PublicKey destination, + PublicKey tokenMint, + ulong amount, Commitment commitment = Commitment.Finalized) { var sta = AssociatedTokenAccountProgram.DeriveAssociatedTokenAccount( - Account.PublicKey, + Account.PublicKey, tokenMint); var ata = AssociatedTokenAccountProgram.DeriveAssociatedTokenAccount(destination, tokenMint); var tokenAccounts = await ActiveRpcClient.GetTokenAccountsByOwnerAsync(destination, tokenMint, null); @@ -141,7 +141,7 @@ public async Task> Transfer( }; if (tokenAccounts.Result == null || tokenAccounts.Result.Value.Count == 0) { - transaction.Instructions.Add( + transaction.Instructions.Add( AssociatedTokenAccountProgram.CreateAssociatedTokenAccount( Account, destination, @@ -156,9 +156,9 @@ public async Task> Transfer( )); return await SignAndSendTransaction(transaction, commitment: commitment); } - + /// - public async Task> Transfer(PublicKey destination, ulong amount, + public async Task> Transfer(PublicKey destination, ulong amount, Commitment commitment = Commitment.Finalized) { var transaction = new Transaction @@ -166,10 +166,10 @@ public async Task> Transfer(PublicKey destination, ulong a RecentBlockHash = await GetBlockHash(), FeePayer = Account.PublicKey, Instructions = new List - { + { SystemProgram.Transfer( - Account.PublicKey, - destination, + Account.PublicKey, + destination, amount) }, Signatures = new List() @@ -181,22 +181,22 @@ public async Task> Transfer(PublicKey destination, ulong a public async Task GetTokenAccounts(PublicKey tokenMint, PublicKey tokenProgramPublicKey) { var rpc = ActiveRpcClient; - var result = await + var result = await rpc.GetTokenAccountsByOwnerAsync( - Account.PublicKey, - tokenMint, + Account.PublicKey, + tokenMint, tokenProgramPublicKey); return result.Result?.Value?.ToArray(); } - + /// public async Task GetTokenAccounts(Commitment commitment = Commitment.Finalized) { var rpc = ActiveRpcClient; - var result = await + var result = await rpc.GetTokenAccountsByOwnerAsync( - Account.PublicKey, - null, + Account.PublicKey, + null, TokenProgram.ProgramIdKey, commitment); return result.Result?.Value?.ToArray(); @@ -226,7 +226,7 @@ public virtual async Task SignTransaction(Transaction transaction) /// /// protected abstract Task _SignAllTransactions(Transaction[] transactions); - + /// public virtual async Task SignAllTransactions(Transaction[] transactions) { @@ -246,7 +246,7 @@ public virtual async Task SignAllTransactions(Transaction[] trans /// public virtual async Task> SignAndSendTransaction ( - Transaction transaction, + Transaction transaction, bool skipPreflight = false, Commitment commitment = Commitment.Finalized) { @@ -305,11 +305,11 @@ public async Task GetBlockHash( } #endregion - - + + /// - /// Start RPC connection and return new RPC Client + /// Start RPC connection and return new RPC Client /// /// private IRpcClient StartConnection() @@ -332,9 +332,9 @@ private IRpcClient StartConnection() return null; } } - + /// - /// Start streaming RPC connection and return a new streaming RPC Client + /// Start streaming RPC connection and return a new streaming RPC Client /// /// private IStreamingRpcClient StartStreamingConnection() @@ -409,4 +409,4 @@ private static List DeduplicateTransactionSignatures( return signaturesList; } } -} \ No newline at end of file +} diff --git a/Runtime/codebase/Web3.cs b/Runtime/codebase/Web3.cs index 2c0ea500..d1a81fa9 100644 --- a/Runtime/codebase/Web3.cs +++ b/Runtime/codebase/Web3.cs @@ -447,4 +447,4 @@ public static void Setup() MainThreadUtil.Setup(); } } -} +} \ No newline at end of file