diff --git a/PITCH.md b/PITCH.md new file mode 100644 index 00000000..78d1b440 --- /dev/null +++ b/PITCH.md @@ -0,0 +1,156 @@ +# 🎮 Unity Mobile Wallet Adapter SDK – API Parity & UX Improvements + +> **Applicant:** Pratik Kale +> **GitHub:** https://github.com/Pratikkale26/Solana.Unity-SDK +> **Proposal for:** Solana Seeker – Unity Mobile Wallet Adapter SDK RFP + +--- + +## 🧠 TL;DR + +This proposal delivers full API parity for the Unity Mobile Wallet Adapter SDK, introduces a persistent authorization cache layer, and improves wallet lifecycle UX (connect, disconnect, reconnect). + +The core SDK implementation is already completed and submitted as a Draft PR, significantly reducing execution risk. This grant focuses on productionizing the work through documentation, example apps, and final integration. + +--- + +## 🔴 Problem + +The current Mobile Wallet Adapter (MWA) implementation in the Unity SDK lacks key functionality already available in the React Native SDK. + +As a result, Unity-based Solana mobile games suffer from poor wallet UX: + +- Missing `deauthorize` and `get_capabilities` APIs +- No persistent auth token storage (tokens are lost on app restart) +- Users must re-approve wallet connections every session +- No clean disconnect or silent reconnect flow + +This leads to: +- Friction in player onboarding +- Poor user retention +- Inconsistent developer experience compared to React Native + +--- + +## ✅ Solution + +This proposal introduces a complete upgrade to the Unity MWA stack, aligning it with React Native capabilities while improving developer ergonomics. + +### 1. API Parity + +Added missing methods: +- `deauthorize` (revoke wallet session) +- `get_capabilities` (wallet feature detection) + +Implemented in: +- `IAdapterOperations` +- `MobileWalletAdapterClient` + +--- + +### 2. Extensible Auth Cache Layer + +Introduced a pluggable caching system: + +```text +IMwaAuthCache +├── PlayerPrefsAuthCache (default) +└── EncryptedAuthCache (extensible template) +``` + +Developers can inject custom secure storage: + +```csharp +var wallet = new SolanaWalletAdapter(options, authCache: new MySecureCache()); +``` + +--- + +### 3. Seamless Reconnect UX + +* `Login()` attempts silent `reauthorize` using cached token +* Falls back to full authorization only if needed + +👉 Eliminates repeated wallet approval popups + +--- + +### 4. Clean Disconnect Flow + +```csharp +await wallet.DisconnectWallet(); // revokes token + clears cache +await wallet.ReconnectWallet(); // silent or full auth + +wallet.OnWalletDisconnected += () => ShowConnectScreen(); +wallet.OnWalletReconnected += () => RestoreGameState(); + +await wallet.GetCapabilities(); +``` + +--- + +## 🔬 Proof of Work + +The full implementation is already completed and submitted as a Draft PR: + +👉 [https://github.com/magicblock-labs/Solana.Unity-SDK/pull/264](https://github.com/magicblock-labs/Solana.Unity-SDK/pull/264) + +This includes: + +* API parity implementation +* Auth cache system +* Wallet lifecycle improvements +* Unit test coverage for cache layer + +This significantly reduces delivery risk and ensures the grant funds are used for production readiness rather than initial development. + +--- + +## 🛠 Scope of Work (Grant Deliverables) + +The grant will fund the completion and productionization of this work: + +| Deliverable | Timeline | +| ----------------------------------------------------------------------- | -------- | +| 📄 SDK Documentation (installation, API reference, cache customization) | Week 1–3 | +| 📱 Open-source Unity Android Example App (demonstrating full MWA flow) | Week 4–5 | +| 🔧 PR review cycles, fixes, and upstream merge support | Ongoing | + +--- + +## 💰 Budget — $6,500 USD (in SKR) + +| Item | Cost | +| -------------------------------------------------------------- | ---------- | +| Core SDK implementation (API parity, auth cache, lifecycle UX) | $2,200 | +| PR review rounds + integration polish | $800 | +| Full SDK documentation | $1,200 | +| Open-source Example Android App | $1,200 | +| Community support + post-merge maintenance | $600 | +| Buffer / contingency | $500 | +| **Total** | **$6,500** | + +--- + +## 🚀 Impact + +This upgrade improves the developer experience for Unity game developers building on Solana Mobile by: + +* Enabling persistent wallet sessions across app restarts +* Reducing friction in player onboarding +* Providing API parity with the React Native SDK +* Making wallet lifecycle management predictable and production-ready + +By aligning Unity tooling with Solana Mobile standards, this work helps unlock broader adoption of Solana in mobile gaming. + +--- + +## 👤 About Me + +**Pratik Kale** +Full-Stack Developer & Solana Builder + +* GitHub: [https://github.com/Pratikkale26](https://github.com/Pratikkale26) +* Twitter: [https://x.com/PratikKale26](https://x.com/PratikKale26) + +I focus on building developer tooling and infrastructure in the Solana ecosystem, with an emphasis on improving developer experience and real-world usability. diff --git a/Runtime/codebase/SolanaMobileStack/Interfaces/IAdapterOperations.cs b/Runtime/codebase/SolanaMobileStack/Interfaces/IAdapterOperations.cs index fd477de5..e3598ffc 100644 --- a/Runtime/codebase/SolanaMobileStack/Interfaces/IAdapterOperations.cs +++ b/Runtime/codebase/SolanaMobileStack/Interfaces/IAdapterOperations.cs @@ -8,12 +8,27 @@ [Preserve] public interface IAdapterOperations { + /// Requests authorization from the wallet. Returns an auth token on success. [Preserve] public Task Authorize(Uri identityUri, Uri iconUri, string identityName, string rpcCluster); + + /// Re-uses a previously issued auth token to reauthorize without user re-prompt. [Preserve] public Task Reauthorize(Uri identityUri, Uri iconUri, string identityName, string authToken); + + /// Revokes an auth token so the wallet forgets the session. Always call this before Logout. + [Preserve] + public Task Deauthorize(string authToken); + + /// Queries the wallet for its supported features and limits. + [Preserve] + public Task GetCapabilities(); + + /// Requests signing of one or more serialized transactions. [Preserve] public Task SignTransactions(IEnumerable transactions); + + /// Requests signing of one or more arbitrary messages. [Preserve] public Task SignMessages(IEnumerable messages, IEnumerable addresses); } \ No newline at end of file diff --git a/Runtime/codebase/SolanaMobileStack/JsonRpcClient/Responses/WalletCapabilities.cs b/Runtime/codebase/SolanaMobileStack/JsonRpcClient/Responses/WalletCapabilities.cs new file mode 100644 index 00000000..899814ae --- /dev/null +++ b/Runtime/codebase/SolanaMobileStack/JsonRpcClient/Responses/WalletCapabilities.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using UnityEngine.Scripting; + +// ReSharper disable once CheckNamespace + +/// +/// Represents the capabilities reported by a connected wallet via the get_capabilities MWA endpoint. +/// All properties are optional — wallets may omit fields they do not support. +/// +[Preserve] +public class WalletCapabilities +{ + /// + /// Maximum number of transaction payloads that can be signed in a single request. + /// Null if the wallet does not report this limit. + /// + [JsonProperty("max_transactions_per_request")] + public int? MaxTransactionsPerRequest { get; set; } + + /// + /// Maximum number of message payloads that can be signed in a single request. + /// Null if the wallet does not report this limit. + /// + [JsonProperty("max_messages_per_request")] + public int? MaxMessagesPerRequest { get; set; } + + /// + /// Supported Solana transaction versions (e.g. "legacy", "0"). + /// Null or empty if the wallet does not report this capability. + /// + [JsonProperty("supported_transaction_versions")] + public List SupportedTransactionVersions { get; set; } + + /// + /// Whether the wallet supports clone authorization, which allows + /// one authorization context to extend to another app instance. + /// Null if the wallet does not report this capability. + /// + [JsonProperty("supports_clone_authorization")] + public bool? SupportsCloneAuthorization { get; set; } + + [Preserve] + public WalletCapabilities() { } +} diff --git a/Runtime/codebase/SolanaMobileStack/JsonRpcClient/Responses/WalletCapabilities.cs.meta b/Runtime/codebase/SolanaMobileStack/JsonRpcClient/Responses/WalletCapabilities.cs.meta new file mode 100644 index 00000000..c4e606fa --- /dev/null +++ b/Runtime/codebase/SolanaMobileStack/JsonRpcClient/Responses/WalletCapabilities.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs b/Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs index 1db054f2..1d4568b0 100644 --- a/Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs +++ b/Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs @@ -25,6 +25,8 @@ public class LocalAssociationScenario private MobileWalletAdapterClient _client; private readonly AndroidJavaObject _currentActivity; private Queue> _actions; + private Queue> _asyncActions; + private bool _useAsyncActions; public LocalAssociationScenario(int clientTimeoutMs = 9000) { @@ -69,6 +71,26 @@ public Task> StartAndExecute(List> a _startAssociationTaskCompletionSource = new TaskCompletionSource>(); return _startAssociationTaskCompletionSource.Task; } + + /// + /// Async-compatible overload of . + /// Accepts async delegate actions () and properly + /// awaits each one before executing the next, preventing fire-and-forget race conditions. + /// + public Task> StartAndExecuteAsync(List> asyncActions) + { + if (asyncActions == null || asyncActions.Count == 0) + throw new ArgumentException("Actions must be non-null and non-empty"); + _asyncActions = new Queue>(asyncActions); + _useAsyncActions = true; + var intent = LocalAssociationIntentCreator.CreateAssociationIntent( + _session.AssociationToken, + _port); + _currentActivity.Call("startActivityForResult", intent, 0); + _currentActivity.Call("runOnUiThread", new AndroidJavaRunnable(TryConnectWs)); + _startAssociationTaskCompletionSource = new TaskCompletionSource>(); + return _startAssociationTaskCompletionSource.Task; + } private async void TryConnectWs() { @@ -133,8 +155,36 @@ private void ExecuteNextAction(Response response = null) { if (_actions.Count == 0 || response is { Failed: true }) CloseAssociation(response); - var action = _actions.Dequeue(); - action.Invoke(_client); + + if (_useAsyncActions) + { + // Properly await the async action before proceeding + ExecuteNextActionAsync(response); + } + else + { + var action = _actions.Dequeue(); + action.Invoke(_client); + } + } + + private async void ExecuteNextActionAsync(Response response = null) + { + if (_asyncActions.Count == 0 || response is { Failed: true }) + { + CloseAssociation(response); + return; + } + var action = _asyncActions.Dequeue(); + try + { + await action.Invoke(_client); + } + catch (Exception e) + { + Debug.LogError($"[MWA] Async action failed: {e}"); + CloseAssociation(new Response { Error = new Response.ResponseError { Message = e.Message } }); + } } private async void CloseAssociation(Response response) diff --git a/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs b/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs index e8250c04..d58f0d44 100644 --- a/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs +++ b/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs @@ -9,42 +9,63 @@ // ReSharper disable once CheckNamespace [Preserve] -public class MobileWalletAdapterClient: JsonRpc20Client, IAdapterOperations, IMessageReceiver +public class MobileWalletAdapterClient : JsonRpc20Client, IAdapterOperations, IMessageReceiver { - private int _mNextMessageId = 1; public MobileWalletAdapterClient(IMessageSender messageSender) : base(messageSender) { } - + [Preserve] public Task Authorize(Uri identityUri, Uri iconUri, string identityName, string cluster) { - var request = PrepareAuthRequest( - identityUri, - iconUri, - identityName, - cluster, - "authorize"); - + var request = PrepareAuthRequest(identityUri, iconUri, identityName, cluster, "authorize"); return SendRequest(request); } public Task Reauthorize(Uri identityUri, Uri iconUri, string identityName, string authToken) { - var request = PrepareAuthRequest( - identityUri, - iconUri, - identityName, - null, - "reauthorize"); - + var request = PrepareAuthRequest(identityUri, iconUri, identityName, null, "reauthorize"); request.Params.AuthToken = authToken; - return SendRequest(request); } - + + /// + /// Revokes the given auth token with the wallet app. The wallet will discard the session. + /// Always call this before clearing local state on logout. + /// + public Task Deauthorize(string authToken) + { + var request = new JsonRequest + { + JsonRpc = "2.0", + Method = "deauthorize", + Params = new JsonRequest.JsonRequestParams + { + AuthToken = authToken + }, + Id = NextMessageId() + }; + return SendRequest(request); + } + + /// + /// Queries the connected wallet for its supported capabilities and limits. + /// Use this to adapt batch sizes and feature detection for your app. + /// + public Task GetCapabilities() + { + var request = new JsonRequest + { + JsonRpc = "2.0", + Method = "get_capabilities", + Params = new JsonRequest.JsonRequestParams(), + Id = NextMessageId() + }; + return SendRequest(request); + } + public Task SignTransactions(IEnumerable transactions) { var request = PrepareSignTransactionsRequest(transactions); @@ -60,14 +81,11 @@ public Task SignMessages(IEnumerable messages, IEnumerable private JsonRequest PrepareAuthRequest(Uri uriIdentity, Uri icon, string name, string cluster, string method) { if (uriIdentity != null && !uriIdentity.IsAbsoluteUri) - { throw new ArgumentException("If non-null, identityUri must be an absolute, hierarchical Uri"); - } if (icon != null && icon.IsAbsoluteUri) - { throw new ArgumentException("If non-null, iconRelativeUri must be a relative Uri"); - } - var request = new JsonRequest + + return new JsonRequest { JsonRpc = "2.0", Method = method, @@ -83,12 +101,11 @@ private JsonRequest PrepareAuthRequest(Uri uriIdentity, Uri icon, string name, s }, Id = NextMessageId() }; - return request; } - + private JsonRequest PrepareSignTransactionsRequest(IEnumerable transactions) { - var request = new JsonRequest + return new JsonRequest { JsonRpc = "2.0", Method = "sign_transactions", @@ -98,12 +115,11 @@ private JsonRequest PrepareSignTransactionsRequest(IEnumerable transacti }, Id = NextMessageId() }; - return request; } - + private JsonRequest PrepareSignMessagesRequest(IEnumerable messages, IEnumerable addresses) { - var request = new JsonRequest + return new JsonRequest { JsonRpc = "2.0", Method = "sign_messages", @@ -114,12 +130,10 @@ private JsonRequest PrepareSignMessagesRequest(IEnumerable messages, IEn }, Id = NextMessageId() }; - return request; } - + private int NextMessageId() { return _mNextMessageId++; } - } \ No newline at end of file diff --git a/Runtime/codebase/SolanaMobileStack/MwaAuthCache.meta b/Runtime/codebase/SolanaMobileStack/MwaAuthCache.meta new file mode 100644 index 00000000..a17959cc --- /dev/null +++ b/Runtime/codebase/SolanaMobileStack/MwaAuthCache.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/codebase/SolanaMobileStack/MwaAuthCache/EncryptedAuthCache.cs b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/EncryptedAuthCache.cs new file mode 100644 index 00000000..91ef4318 --- /dev/null +++ b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/EncryptedAuthCache.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; + +// ReSharper disable once CheckNamespace + +namespace Solana.Unity.SDK +{ + /// + /// A placeholder implementation of that documents + /// where to integrate an encrypted or platform-specific storage backend. + /// + /// + /// Do not use this class directly in production. It throws + /// on every call to make integration gaps visible + /// immediately during development, rather than silently dropping tokens. + /// + /// + /// To implement secure storage: + /// + /// Android Keystore (via plugin) + /// iOS Keychain (via plugin) + /// A remote wallet-server token store + /// + /// + public class EncryptedAuthCache : IMwaAuthCache + { + // TODO: inject your encryption provider or secure storage SDK here + // Example: private readonly ISecureStorage _secureStorage; + + /// + public Task GetAuthToken(string walletIdentity) + { + // TODO: retrieve from your encrypted storage using walletIdentity as key + // Example: return _secureStorage.GetAsync(walletIdentity); + throw new NotImplementedException( + "EncryptedAuthCache is a template. Implement GetAuthToken using your secure storage provider."); + } + + /// + public Task SetAuthToken(string walletIdentity, string token) + { + // TODO: persist to your encrypted storage + // Example: return _secureStorage.SetAsync(walletIdentity, token); + throw new NotImplementedException( + "EncryptedAuthCache is a template. Implement SetAuthToken using your secure storage provider."); + } + + /// + public Task ClearAuthToken(string walletIdentity) + { + // TODO: remove from your encrypted storage + // Example: return _secureStorage.RemoveAsync(walletIdentity); + throw new NotImplementedException( + "EncryptedAuthCache is a template. Implement ClearAuthToken using your secure storage provider."); + } + } +} diff --git a/Runtime/codebase/SolanaMobileStack/MwaAuthCache/EncryptedAuthCache.cs.meta b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/EncryptedAuthCache.cs.meta new file mode 100644 index 00000000..42630f8a --- /dev/null +++ b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/EncryptedAuthCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/codebase/SolanaMobileStack/MwaAuthCache/IMwaAuthCache.cs b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/IMwaAuthCache.cs new file mode 100644 index 00000000..188f36fa --- /dev/null +++ b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/IMwaAuthCache.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; + +// ReSharper disable once CheckNamespace + +namespace Solana.Unity.SDK +{ + /// + /// Extensible interface for persisting Mobile Wallet Adapter authorization tokens. + /// + /// Implement this interface to provide custom token storage backends + /// (e.g. encrypted storage, cloud sync, secure keystore). + /// + /// The default implementation is which + /// uses Unity's PlayerPrefs for simple local persistence. + /// + /// Example custom implementation: + /// + /// public class MySecureCache : IMwaAuthCache { + /// public Task<string> GetAuthToken(string walletIdentity) { ... } + /// public Task SetAuthToken(string walletIdentity, string token) { ... } + /// public Task ClearAuthToken(string walletIdentity) { ... } + /// } + /// // Then inject it: + /// var adapter = new SolanaWalletAdapter(options, authCache: new MySecureCache()); + /// + /// + public interface IMwaAuthCache + { + /// + /// Retrieves the cached auth token for the given wallet identity, or null if none. + /// + /// A unique key identifying the wallet (e.g. identity URI + name). + Task GetAuthToken(string walletIdentity); + + /// + /// Stores an auth token for the given wallet identity. + /// + Task SetAuthToken(string walletIdentity, string token); + + /// + /// Clears the stored auth token for the given wallet identity. + /// + Task ClearAuthToken(string walletIdentity); + } +} diff --git a/Runtime/codebase/SolanaMobileStack/MwaAuthCache/IMwaAuthCache.cs.meta b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/IMwaAuthCache.cs.meta new file mode 100644 index 00000000..9b66b798 --- /dev/null +++ b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/IMwaAuthCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/codebase/SolanaMobileStack/MwaAuthCache/PlayerPrefsAuthCache.cs b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/PlayerPrefsAuthCache.cs new file mode 100644 index 00000000..37d142ca --- /dev/null +++ b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/PlayerPrefsAuthCache.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace Solana.Unity.SDK +{ + /// + /// Default implementation using Unity's PlayerPrefs. + /// Tokens are stored as plain-text strings. + /// + /// + /// For production games that require stronger security, implement + /// with a platform-specific encrypted keystore backend. + /// + /// + public class PlayerPrefsAuthCache : IMwaAuthCache + { + private const string KeyPrefix = "mwa_auth_token_"; + + /// + public Task GetAuthToken(string walletIdentity) + { + ValidateIdentity(walletIdentity); + var key = BuildKey(walletIdentity); + var token = PlayerPrefs.GetString(key, null); + return Task.FromResult(string.IsNullOrEmpty(token) ? null : token); + } + + /// + public Task SetAuthToken(string walletIdentity, string token) + { + ValidateIdentity(walletIdentity); + if (string.IsNullOrEmpty(token)) + throw new ArgumentException("Token must not be null or empty.", nameof(token)); + + var key = BuildKey(walletIdentity); + PlayerPrefs.SetString(key, token); + PlayerPrefs.Save(); + return Task.CompletedTask; + } + + /// + public Task ClearAuthToken(string walletIdentity) + { + ValidateIdentity(walletIdentity); + var key = BuildKey(walletIdentity); + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + return Task.CompletedTask; + } + + private static string BuildKey(string walletIdentity) => KeyPrefix + walletIdentity; + + private static void ValidateIdentity(string walletIdentity) + { + if (string.IsNullOrEmpty(walletIdentity)) + throw new ArgumentException( + "walletIdentity must not be null or empty — this would produce a shared cache key collision.", + nameof(walletIdentity)); + } + } +} diff --git a/Runtime/codebase/SolanaMobileStack/MwaAuthCache/PlayerPrefsAuthCache.cs.meta b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/PlayerPrefsAuthCache.cs.meta new file mode 100644 index 00000000..9b390b86 --- /dev/null +++ b/Runtime/codebase/SolanaMobileStack/MwaAuthCache/PlayerPrefsAuthCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs b/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs index 71b65c8b..ea4c5e90 100644 --- a/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs +++ b/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs @@ -11,100 +11,229 @@ namespace Solana.Unity.SDK { - [Serializable] public class SolanaMobileWalletAdapterOptions { public string identityUri = "https://solana.unity-sdk.gg/"; public string iconUri = "/favicon.ico"; public string name = "Solana.Unity-SDK"; + + /// + /// When true, the auth token is cached via so users + /// are not re-prompted on every app launch. + /// public bool keepConnectionAlive = true; } - - + + [Obsolete("Use SolanaWalletAdapter class instead, which is the cross platform wrapper.")] public class SolanaMobileWalletAdapter : WalletBase { private readonly SolanaMobileWalletAdapterOptions _walletOptions; - - private Transaction _currentTransaction; + private readonly IMwaAuthCache _authCache; + private Transaction _currentTransaction; private TaskCompletionSource _loginTaskCompletionSource; private TaskCompletionSource _signedTransactionTaskCompletionSource; private readonly WalletBase _internalWallet; + + /// Cached auth token — persisted across sessions via . private string _authToken; + /// Wallet identity key used for cache lookups (derived from options). + private string WalletIdentity => _walletOptions.identityUri + "|" + _walletOptions.name; + + // ─── Events ───────────────────────────────────────────────────────────── + + /// Fired after a successful Deauthorize + state clear (explicit logout). + public event Action OnWalletDisconnected; + + /// Fired after a successful Reauthorize using a cached token (silent reconnect). + public event Action OnWalletReconnected; + + // ─── Constructor ───────────────────────────────────────────────────────── + + /// + /// Creates an instance of the Android MWA adapter. + /// + /// Wallet identity options. + /// + /// Optional custom auth cache. Defaults to when null. + /// Inject a custom implementation for encrypted storage. + /// public SolanaMobileWalletAdapter( SolanaMobileWalletAdapterOptions solanaWalletOptions, - RpcCluster rpcCluster = RpcCluster.DevNet, - string customRpcUri = null, - string customStreamingRpcUri = null, - bool autoConnectOnStartup = false) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup - ) + RpcCluster rpcCluster = RpcCluster.DevNet, + string customRpcUri = null, + string customStreamingRpcUri = null, + bool autoConnectOnStartup = false, + IMwaAuthCache authCache = null + ) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup) { _walletOptions = solanaWalletOptions; + _authCache = authCache ?? new PlayerPrefsAuthCache(); + if (Application.platform != RuntimePlatform.Android) - { throw new Exception("SolanaMobileWalletAdapter can only be used on Android"); - } } + // ─── Login ────────────────────────────────────────────────────────────── + protected override async Task _Login(string password = null) { + var cluster = RPCNameMap[(int)RpcCluster]; + + // Step 1: Try to reauthorize silently using cached token if (_walletOptions.keepConnectionAlive) { - string pk = PlayerPrefs.GetString("pk", null); - if (!pk.IsNullOrEmpty()) return new Account(string.Empty, new PublicKey(pk)); + var cachedToken = await _authCache.GetAuthToken(WalletIdentity); + if (!string.IsNullOrEmpty(cachedToken)) + { + var reauthorizeResult = await TryReauthorize(cachedToken, cluster); + if (reauthorizeResult != null) + { + _authToken = reauthorizeResult.AuthToken; + await _authCache.SetAuthToken(WalletIdentity, _authToken); + var cachedPublicKey = new PublicKey(reauthorizeResult.PublicKey); + OnWalletReconnected?.Invoke(); + return new Account(string.Empty, cachedPublicKey); + } + // Cached token was invalid — clear it and fall through to full authorize + await _authCache.ClearAuthToken(WalletIdentity); + } } + + // Step 2: Full authorization (prompts user in wallet app) AuthorizationResult authorization = null; var localAssociationScenario = new LocalAssociationScenario(); - var cluster = RPCNameMap[(int)RpcCluster]; - var result = await localAssociationScenario.StartAndExecute( - new List> + var result = await localAssociationScenario.StartAndExecuteAsync( + new List> { async client => { authorization = await client.Authorize( new Uri(_walletOptions.identityUri), new Uri(_walletOptions.iconUri, UriKind.Relative), - _walletOptions.name, cluster); + _walletOptions.name, + cluster); } } ); + if (!result.WasSuccessful) { Debug.LogError(result.Error.Message); throw new Exception(result.Error.Message); } + _authToken = authorization.AuthToken; - var publicKey = new PublicKey(authorization.PublicKey); + + // Persist the new token if (_walletOptions.keepConnectionAlive) + await _authCache.SetAuthToken(WalletIdentity, _authToken); + + return new Account(string.Empty, new PublicKey(authorization.PublicKey)); + } + + // ─── Disconnect / Reconnect ────────────────────────────────────────────── + + /// + /// Explicitly disconnects the wallet by sending a Deauthorize request to the wallet app, + /// then clears all cached state. Fires . + /// + /// Use this for a "Sign Out" button in your game UI. + /// + public async Task DisconnectWallet() + { + if (!string.IsNullOrEmpty(_authToken)) + { + try + { + var localAssociationScenario = new LocalAssociationScenario(); + var currentToken = _authToken; // capture before clearing + await localAssociationScenario.StartAndExecuteAsync( + new List> + { + async client => await client.Deauthorize(currentToken) + } + ); + } + catch (Exception e) + { + // Best-effort — don't block logout if deauthorize fails (e.g. wallet not installed) + Debug.LogWarning($"[MWA] Deauthorize failed during disconnect: {e}"); + } + } + + // Clear all local auth state + _authToken = null; + await _authCache.ClearAuthToken(WalletIdentity); + + // Notify and base logout + OnWalletDisconnected?.Invoke(); + base.Logout(); + } + + /// + /// Attempts a silent reconnect using the cached auth token. + /// If the cached token is expired or missing, performs a full Authorize flow + /// (the user will be prompted by the wallet app). + /// Fires when a silent reauthorize succeeds. + /// + /// The connected on success. + public async Task ReconnectWallet() + { + return await Login(); + } + + /// + /// Queries the wallet for its supported capabilities. + /// Returns null if the wallet does not support the get_capabilities endpoint. + /// + public async Task GetCapabilities() + { + WalletCapabilities capabilities = null; + var localAssociationScenario = new LocalAssociationScenario(); + var result = await localAssociationScenario.StartAndExecuteAsync( + new List> + { + async client => + { + capabilities = await client.GetCapabilities(); + } + } + ); + + if (!result.WasSuccessful) { - PlayerPrefs.SetString("pk", publicKey.ToString()); + Debug.LogWarning($"[MWA] GetCapabilities failed: {result.Error?.Message}"); + return null; } - return new Account(string.Empty, publicKey); + + return capabilities; } + // ─── Sign Transactions ─────────────────────────────────────────────────── + protected override async Task _SignTransaction(Transaction transaction) { var result = await _SignAllTransactions(new Transaction[] { transaction }); return result[0]; } - protected override async Task _SignAllTransactions(Transaction[] transactions) { - var cluster = RPCNameMap[(int)RpcCluster]; SignedResult res = null; var localAssociationScenario = new LocalAssociationScenario(); AuthorizationResult authorization = null; - var result = await localAssociationScenario.StartAndExecute( - new List> + + var result = await localAssociationScenario.StartAndExecuteAsync( + new List> { async client => { - if (_authToken.IsNullOrEmpty()) + if (string.IsNullOrEmpty(_authToken)) { authorization = await client.Authorize( new Uri(_walletOptions.identityUri), @@ -116,44 +245,47 @@ protected override async Task _SignAllTransactions(Transaction[] authorization = await client.Reauthorize( new Uri(_walletOptions.identityUri), new Uri(_walletOptions.iconUri, UriKind.Relative), - _walletOptions.name, _authToken); + _walletOptions.name, _authToken); } }, async client => { - res = await client.SignTransactions(transactions.Select(transaction => transaction.Serialize()).ToList()); + res = await client.SignTransactions( + transactions.Select(transaction => transaction.Serialize()).ToList()); } } ); + if (!result.WasSuccessful) { Debug.LogError(result.Error.Message); throw new Exception(result.Error.Message); } + _authToken = authorization.AuthToken; - return res.SignedPayloads.Select(transaction => Transaction.Deserialize(transaction)).ToArray(); - } + // Keep the cache up-to-date with the latest auth token + if (_walletOptions.keepConnectionAlive) + await _authCache.SetAuthToken(WalletIdentity, _authToken); - public override void Logout() - { - base.Logout(); - PlayerPrefs.DeleteKey("pk"); - PlayerPrefs.Save(); + return res.SignedPayloads.Select(transaction => Transaction.Deserialize(transaction)).ToArray(); } + // ─── Sign Messages ─────────────────────────────────────────────────────── + public override async Task SignMessage(byte[] message) { SignedResult signedMessages = null; var localAssociationScenario = new LocalAssociationScenario(); AuthorizationResult authorization = null; var cluster = RPCNameMap[(int)RpcCluster]; - var result = await localAssociationScenario.StartAndExecute( - new List> + + var result = await localAssociationScenario.StartAndExecuteAsync( + new List> { async client => { - if (_authToken.IsNullOrEmpty()) + if (string.IsNullOrEmpty(_authToken)) { authorization = await client.Authorize( new Uri(_walletOptions.identityUri), @@ -165,7 +297,7 @@ public override async Task SignMessage(byte[] message) authorization = await client.Reauthorize( new Uri(_walletOptions.identityUri), new Uri(_walletOptions.iconUri, UriKind.Relative), - _walletOptions.name, _authToken); + _walletOptions.name, _authToken); } }, async client => @@ -177,18 +309,78 @@ public override async Task SignMessage(byte[] message) } } ); + if (!result.WasSuccessful) { Debug.LogError(result.Error.Message); throw new Exception(result.Error.Message); } + _authToken = authorization.AuthToken; + + if (_walletOptions.keepConnectionAlive) + await _authCache.SetAuthToken(WalletIdentity, _authToken); + return signedMessages.SignedPayloadsBytes[0]; } + // ─── Logout ────────────────────────────────────────────────────────────── + + /// + /// Performs a full cleanup: deauthorizes the token with the wallet app, clears all + /// cached state, and fires . + /// Equivalent to — prefer that method for UI-triggered logouts. + /// + /// + /// This override must be synchronous (void) to satisfy the base class contract. + /// The underlying async disconnect is intentionally fire-and-forget here. + /// For full async control, call directly and await it. + /// + public override void Logout() + { + // Intentional fire-and-forget: the base class Logout() contract is void. + // Call DisconnectWallet() directly if you need to await deauthorization. + _ = DisconnectWallet(); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + /// + /// Attempts a silent reauthorize with the given token. + /// Returns null if the token is expired or invalid. + /// + private async Task TryReauthorize(string cachedToken, string cluster) + { + try + { + AuthorizationResult result = null; + var scenario = new LocalAssociationScenario(); + var response = await scenario.StartAndExecuteAsync( + new List> + { + async client => + { + result = await client.Reauthorize( + new Uri(_walletOptions.identityUri), + new Uri(_walletOptions.iconUri, UriKind.Relative), + _walletOptions.name, + cachedToken); + } + } + ); + + return response.WasSuccessful ? result : null; + } + catch (Exception e) + { + Debug.LogWarning($"[MWA] Silent reauthorize failed: {e}"); + return null; + } + } + protected override Task _CreateAccount(string mnemonic = null, string password = null) { - throw new NotImplementedException("Can't create a new account in phantom wallet"); + throw new NotImplementedException("Can't create a new account in a MWA wallet"); } } } diff --git a/Runtime/codebase/SolanaWalletAdapter.cs b/Runtime/codebase/SolanaWalletAdapter.cs index 65658c6e..70bca362 100644 --- a/Runtime/codebase/SolanaWalletAdapter.cs +++ b/Runtime/codebase/SolanaWalletAdapter.cs @@ -7,7 +7,6 @@ namespace Solana.Unity.SDK { - [Serializable] public class SolanaWalletAdapterOptions { @@ -15,26 +14,84 @@ public class SolanaWalletAdapterOptions public SolanaWalletAdapterWebGLOptions solanaWalletAdapterWebGLOptions; public PhantomWalletOptions phantomWalletOptions; } - - public class SolanaWalletAdapter: WalletBase + + /// + /// Cross-platform Solana wallet adapter. + /// Automatically routes to the correct platform-specific implementation: + /// Android → (MWA) + /// WebGL → + /// iOS → + /// + public class SolanaWalletAdapter : WalletBase { private readonly WalletBase _internalWallet; - public SolanaWalletAdapter(SolanaWalletAdapterOptions options, RpcCluster rpcCluster = RpcCluster.DevNet, string customRpcUri = null, string customStreamingRpcUri = null, bool autoConnectOnStartup = false) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup) + /// Convenience accessor to the Android MWA wallet. Null on non-Android platforms. + private SolanaMobileWalletAdapter MobileAdapter => + _internalWallet as SolanaMobileWalletAdapter; + + // ─── Events ───────────────────────────────────────────────────────────── + + /// + /// Fired when the wallet is explicitly disconnected via . + /// Subscribe to update your game UI (e.g. hide wallet address, show connect button). + /// + public event Action OnWalletDisconnected; + + /// + /// Fired when a silent reconnect succeeds using a cached auth token. + /// Subscribe to update your game UI (e.g. restore wallet state without re-prompting user). + /// + public event Action OnWalletReconnected; + + // ─── Constructor ───────────────────────────────────────────────────────── + + /// + /// Creates a cross-platform Solana wallet adapter. + /// + /// Platform-specific options. + /// The Solana RPC cluster (default: DevNet). + /// Override the RPC endpoint. + /// Override the streaming RPC endpoint. + /// Auto-connect when the adapter is created. + /// + /// Custom auth token cache for Android MWA. Defaults to . + /// Inject a custom for encrypted or cloud-synced token storage. + /// + public SolanaWalletAdapter( + SolanaWalletAdapterOptions options, + RpcCluster rpcCluster = RpcCluster.DevNet, + string customRpcUri = null, + string customStreamingRpcUri = null, + bool autoConnectOnStartup = false, + IMwaAuthCache authCache = null + ) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup) { - #if UNITY_ANDROID - #pragma warning disable CS0618 - _internalWallet = new SolanaMobileWalletAdapter(options.solanaMobileWalletAdapterOptions, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup); - #elif UNITY_WEBGL - #pragma warning disable CS0618 - _internalWallet = new SolanaWalletAdapterWebGL(options.solanaWalletAdapterWebGLOptions, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup); - #elif UNITY_IOS - #pragma warning disable CS0618 - _internalWallet = new PhantomDeepLink(options.phantomWalletOptions, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup); - #else - #endif +#if UNITY_ANDROID +#pragma warning disable CS0618 + var mobileAdapter = new SolanaMobileWalletAdapter( + options.solanaMobileWalletAdapterOptions, + rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup, + authCache); + + // Bubble up events from mobile adapter + mobileAdapter.OnWalletDisconnected += () => OnWalletDisconnected?.Invoke(); + mobileAdapter.OnWalletReconnected += () => OnWalletReconnected?.Invoke(); + + _internalWallet = mobileAdapter; +#elif UNITY_WEBGL +#pragma warning disable CS0618 + _internalWallet = new SolanaWalletAdapterWebGL( + options.solanaWalletAdapterWebGLOptions, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup); +#elif UNITY_IOS +#pragma warning disable CS0618 + _internalWallet = new PhantomDeepLink( + options.phantomWalletOptions, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup); +#endif } + // ─── Core Wallet Operations ────────────────────────────────────────────── + protected override Task _Login(string password = null) { if (_internalWallet != null) @@ -62,7 +119,7 @@ public override Task SignMessage(byte[] message) return _internalWallet.SignMessage(message); throw new NotImplementedException(); } - + protected override Task _CreateAccount(string mnemonic = null, string password = null) { throw new NotImplementedException(); @@ -73,5 +130,50 @@ public override void Logout() base.Logout(); _internalWallet?.Logout(); } + + // ─── New MWA APIs ──────────────────────────────────────────────────────── + + /// + /// Explicitly disconnects the wallet: + /// 1. Sends Deauthorize to the wallet app (revokes token) + /// 2. Clears cached auth state + /// 3. Fires + /// + /// Use for "Sign Out" buttons in your game UI. + /// Only available on Android (no-op on other platforms). + /// + public Task DisconnectWallet() + { + if (MobileAdapter != null) + return MobileAdapter.DisconnectWallet(); + + // On non-Android platforms, fall back to regular logout + Logout(); + return Task.CompletedTask; + } + + /// + /// Attempts a silent reconnect using a cached auth token. + /// If no valid token exists, falls back to a full Authorize flow (user prompted). + /// Fires on silent success. + /// Only meaningful on Android. Other platforms perform a normal Login. + /// + public Task ReconnectWallet() + { + if (MobileAdapter != null) + return MobileAdapter.ReconnectWallet(); + return Login(); + } + + /// + /// Queries the wallet for its supported capabilities and limits. + /// Returns null on non-Android platforms or if the wallet does not support this endpoint. + /// + public Task GetCapabilities() + { + if (MobileAdapter != null) + return MobileAdapter.GetCapabilities(); + return Task.FromResult(null); + } } } \ No newline at end of file