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