From 161f4981d56a88588849e6963e2a9451c7515e40 Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Mon, 11 May 2026 15:49:07 +0300 Subject: [PATCH 01/11] fix(mwa-v2): address CodeRabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard null/empty address in PrimaryAccountPublicKeyBytes - Private constructors on DeauthorizeResult and ReconnectResult - Null-result check in JsonRpc20Client.ReceiverRaw - Cache auth token refresh in sign_and_send authorize action - LogoutSuppressed + OnWalletDisconnected on auth revocation paths - Fix base64 → base58 typo in method reference --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 138f7548..caec1935 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ docs/ **/[Ll]ogs/ **/[Uu]ser[Ss]ettings/ *.log + +# Added by LaiM install — runtime/secret files; do not commit +.claude/.cache/ +.claude/settings.local.json From e4881bd26c2c454782edf7a6fd9ee7e1b1c03626 Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Mon, 4 May 2026 17:44:04 +0300 Subject: [PATCH 02/11] docs(mwa): add quick-start, method reference, cache guide, and migration guide --- .gitignore | 1 - docs/mwa-cache-guide.md | 117 +++++++++++++++++++ docs/mwa-method-reference.md | 199 +++++++++++++++++++++++++++++++++ docs/mwa-migration-v1-to-v2.md | 56 ++++++++++ docs/mwa-quick-start.md | 71 ++++++++++++ 5 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 docs/mwa-cache-guide.md create mode 100644 docs/mwa-method-reference.md create mode 100644 docs/mwa-migration-v1-to-v2.md create mode 100644 docs/mwa-quick-start.md diff --git a/.gitignore b/.gitignore index caec1935..981a9d31 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,6 @@ LICENSE.meta README.md.meta package.json.meta -docs/ .claude/ # Unity generated diff --git a/docs/mwa-cache-guide.md b/docs/mwa-cache-guide.md new file mode 100644 index 00000000..5ab3a90c --- /dev/null +++ b/docs/mwa-cache-guide.md @@ -0,0 +1,117 @@ +# Mobile Wallet Adapter — Authorization Cache Guide + +The SDK caches the wallet's auth token so users don't have to re-approve on every app launch. This guide covers the default cache, how to replace it, and how the SDK validates cached sessions. + +## Default: PlayerPrefsAuthorizationCache + +Out of the box, the SDK uses `PlayerPrefsAuthorizationCache` which stores the auth token as JSON in Unity's `PlayerPrefs`. No setup needed — it works automatically when `keepConnectionAlive = true` (the default). + +```csharp +var options = new SolanaMobileWalletAdapterOptions +{ + keepConnectionAlive = true, // default — enables caching +}; +``` + +### Scoped Keys + +If your app manages multiple wallet identities, you can scope each cache to a separate PlayerPrefs key: + +```csharp +var cacheA = new PlayerPrefsAuthorizationCache("wallet-a"); +var cacheB = new PlayerPrefsAuthorizationCache("wallet-b"); +``` + +Default key: `SolanaUnity.MWA.AuthorizationRecord.v1` +Scoped key: `SolanaUnity.MWA.AuthorizationRecord.v1.wallet-a` + +## Custom Cache + +Implement `IAuthorizationCache` to store the auth token wherever you want — encrypted storage, a database, or in-memory for testing: + +```csharp +public interface IAuthorizationCache +{ + Task GetAsync(); + Task SetAsync(AuthorizationRecord record); + Task ClearAsync(); +} +``` + +Inject it via options: + +```csharp +var options = new SolanaMobileWalletAdapterOptions +{ + Cache = new MySecureCache(), +}; +``` + +### Example: In-Memory Cache + +From the [demo app](https://github.com/Zurcusa/unity-solana-mwa-example): + +```csharp +public class DemoAuthorizationCache : IAuthorizationCache +{ + private const string Key = "SolanaDemo.MWA.Auth"; + + public Task GetAsync() + { + string json = PlayerPrefs.GetString(Key, null); + if (string.IsNullOrEmpty(json)) + return Task.FromResult(null); + try + { + return Task.FromResult( + JsonConvert.DeserializeObject(json)); + } + catch + { + return Task.FromResult(null); + } + } + + public Task SetAsync(AuthorizationRecord record) + { + if (record == null) return Task.CompletedTask; + PlayerPrefs.SetString(Key, JsonConvert.SerializeObject(record)); + PlayerPrefs.Save(); + return Task.CompletedTask; + } + + public Task ClearAsync() + { + PlayerPrefs.DeleteKey(Key); + PlayerPrefs.Save(); + return Task.CompletedTask; + } +} +``` + +## Cache Validation + +The SDK validates cached records before using them. A cached session is cleared automatically when: + +1. **Schema version mismatch** — the cached record's `SchemaVersion` doesn't match the SDK's `ExpectedSchemaVersion` (currently 2). This handles upgrades where the record format changes. + +2. **Chain mismatch** — the cached record's `Chain` (e.g. `solana:devnet`) doesn't match the adapter's current `RpcCluster`. Prevents a devnet token from being sent to a mainnet wallet. + +3. **Wallet rejection** — the wallet reports `auth_token_rejected` during a reconnect attempt. The SDK clears the cache and falls through to a fresh authorization. + +When any of these occur, `Login()` transparently falls through to a fresh wallet authorization — no error is surfaced to the user. + +## AuthorizationRecord Fields + +The cached record contains: + +| Field | Purpose | +|---|---| +| `SchemaVersion` | Format version for upgrade detection | +| `AuthToken` | Bearer token for subsequent wallet calls | +| `AccountAddress` | Cached public key (base58) | +| `AccountLabel` | Wallet-provided account name | +| `Chain` | CAIP-2 chain identifier at time of authorization | +| `CachedAtUnixSeconds` | Timestamp for diagnostics (not used for expiry) | +| `Chains`, `Features` | Wallet-scoped metadata | +| `WalletUriBase`, `WalletIcon`, `AccountIcon` | Wallet branding metadata | diff --git a/docs/mwa-method-reference.md b/docs/mwa-method-reference.md new file mode 100644 index 00000000..6598372e --- /dev/null +++ b/docs/mwa-method-reference.md @@ -0,0 +1,199 @@ +# Mobile Wallet Adapter — Method Reference + +All public methods on `SolanaMobileWalletAdapter`. Methods marked with a lock require exclusive access — calling them while another operation is in flight throws `OperationInFlightException`. + +## Configuration + +```csharp +var options = new SolanaMobileWalletAdapterOptions +{ + identityUri = "https://yourgame.com/", + iconUri = "/icon.png", + name = "My Game", + keepConnectionAlive = true, // enable auth token caching (default: true) + Cache = new PlayerPrefsAuthorizationCache(), // or your own IAuthorizationCache + Verbosity = LogVerbosity.Default, // Release | Default | Verbose +}; + +var adapter = new SolanaMobileWalletAdapter(options, RpcCluster.DevNet); +``` + +--- + +## Authentication + +### Login + +```csharp +Task Login(string password = null) +``` + +Authorize with the wallet. On first call, opens the wallet app for user approval. On subsequent calls, attempts silent reconnect using the cached auth token — if the cache is valid, no wallet prompt is shown. + +Returns the connected `Account` with the wallet's public key. + +### LoginWithSignIn + +```csharp +Task<(Account Account, SignInResult SignInResult)> LoginWithSignIn(SignInPayload signInPayload) +``` + +Authorize and sign a proof message in a single wallet interaction (Sign-In With Solana). If the wallet doesn't natively support SIWS, the SDK falls back to a separate authorize + sign flow. + +```csharp +var payload = new SignInPayload +{ + Domain = "yourgame.com", + Statement = "Sign in to My Game", +}; + +var (account, signInResult) = await adapter.LoginWithSignIn(payload); +// signInResult.Signature contains the ed25519 proof signature +``` + +### Reconnect + +```csharp +Task Reconnect() +``` + +Restore a cached session without opening the wallet. Returns one of: + +| Result | Meaning | +|---|---| +| `ReconnectResult.SilentSuccess` | Cached token rebound. `.Account` contains the wallet account. | +| `ReconnectResult.NoCachedSession` | No valid cache — call `Login()` instead. | +| `ReconnectResult.Failed` | Transport or wallet error. `.Error` contains the exception. | + +### Disconnect + +```csharp +Task Disconnect() +``` + +Clear local state: empties the authorization cache and clears the in-memory auth token. Does not contact the wallet. Fires `OnWalletDisconnected`. + +### Deauthorize + +```csharp +Task Deauthorize() +``` + +Revoke the session at the wallet level and clear local state. Returns one of: + +| Result | Meaning | +|---|---| +| `DeauthorizeResult.FullyRevoked` | Wallet acknowledged revocation and local cache cleared. | +| `DeauthorizeResult.LocalOnly` | Local cache cleared but wallet was unreachable. `.WalletPackage` identifies the wallet. | +| `DeauthorizeResult.Failed` | Cache clear itself failed. `.Error` contains the exception. | + +### CloneAuthorization + +```csharp +Task CloneAuthorization() +``` + +Create a second independent auth token from the current session. Useful for parallel operations. Returns the new token as a string. Requires an active connection. + +--- + +## Signing + +### SignTransaction + +```csharp +Task SignTransaction(Transaction transaction) +``` + +Sign a single transaction. Returns the signed transaction with the wallet's signature attached. + +### SignAllTransactions + +```csharp +Task SignAllTransactions(Transaction[] transactions) +``` + +Sign a batch of transactions. Returns all transactions with signatures. + +### SignMessage + +```csharp +Task SignMessage(byte[] message) +Task SignMessage(string message) +``` + +Sign an arbitrary message. Returns the raw 64-byte ed25519 signature. The string overload encodes as UTF-8. + +### SignAndSendTransactions + +```csharp +Task SignAndSendTransactions( + Transaction[] transactions, + SendOptions options = null) +``` + +Sign and broadcast transactions in a single wallet interaction. Returns one of: + +| Result | Meaning | +|---|---| +| `SignAndSendTxResult.Success` | All signed and submitted. `.Signatures` contains one signature per transaction. | +| `SignAndSendTxResult.UserDenied` | User rejected the signing prompt. | +| `SignAndSendTxResult.InvalidPayloads` | One or more payloads rejected. `.Valid` is a per-transaction boolean array. | +| `SignAndSendTxResult.NotSubmitted` | Signed but RPC submission failed. `.PartialSignatures` available. | +| `SignAndSendTxResult.TooManyPayloads` | Batch exceeds wallet limit. `.MaxTransactionsPerRequest` contains the max. | +| `SignAndSendTxResult.ChainNotSupported` | Wallet doesn't support the requested chain. | +| `SignAndSendTxResult.AuthRevoked` | Auth token invalidated — reconnect and retry. | +| `SignAndSendTxResult.WalletUnreachable` | Transport or connection failure. | + +`SendOptions` fields (all optional): `Commitment`, `SkipPreflight`, `MinContextSlot`, `MaxRetries`. If `MinContextSlot` is not set, the SDK auto-fetches the current block slot. + +### SignAndSendTransaction + +```csharp +Task> SignAndSendTransaction( + Transaction transaction, + bool skipPreflight = false, + Commitment commitment = Commitment.Confirmed) +``` + +Single-transaction convenience wrapper. Returns `RequestResult` with the base64 signature on success or an error reason. + +--- + +## Capabilities + +### GetCapabilities + +```csharp +Task GetCapabilities() +``` + +Query the wallet's supported features and limits. Returns `CapabilitiesResult` with: + +- `MaxTransactionsPerRequest` +- `MaxMessagesPerRequest` +- `SupportedTransactionVersions` +- `SupportsCloneAuthorization` +- `SupportsSignAndSendTransactions` +- `Features` (string array of feature identifiers) + +--- + +## Events + +| Event | Fired when | +|---|---| +| `OnWalletDisconnected` | `Disconnect()` or `Deauthorize()` completes | +| `OnWalletReconnected` | `Reconnect()` silently restores a session | + +--- + +## Cross-Platform Wrapper + +`SolanaWalletAdapter` is the cross-platform wrapper that routes to MWA on Android, WebGL adapter on web, and Phantom deep link on iOS. The following MWA-specific methods are available through it: + +- `DisconnectWallet()` — delegates to `Disconnect()` +- `ReconnectWallet()` — delegates to `Reconnect()` via `Login()` +- `GetCapabilities()` — delegates to MWA `GetCapabilities()` + +Other MWA-specific methods (`Deauthorize()`, `Reconnect()`, `LoginWithSignIn()`, `CloneAuthorization()`, `SignAndSendTransactions()`) are only available by casting to or directly using `SolanaMobileWalletAdapter`. diff --git a/docs/mwa-migration-v1-to-v2.md b/docs/mwa-migration-v1-to-v2.md new file mode 100644 index 00000000..41146437 --- /dev/null +++ b/docs/mwa-migration-v1-to-v2.md @@ -0,0 +1,56 @@ +# Mobile Wallet Adapter — Migration from v1 to v2 + +## Logout → Disconnect / Deauthorize + +`Logout()` is deprecated. Replace it with one of two methods depending on intent: + +| Old | New | What it does | +|---|---|---| +| `Logout()` | `Disconnect()` | Clears local cache and auth token. The wallet-side session remains valid — next `Login()` can silently reconnect. | +| `Logout()` | `Deauthorize()` | Revokes the session at the wallet AND clears local state. Next `Login()` requires a fresh wallet approval. | + +```csharp +// Before +adapter.Logout(); + +// After — if you want silent reconnect next time +await adapter.Disconnect(); + +// After — if you want to fully revoke access +var result = await adapter.Deauthorize(); +``` + +## Authorization Cache + +v1 stored auth tokens in bare PlayerPrefs keys (`pk`, `authToken`). v2 introduces `IAuthorizationCache` with structured `AuthorizationRecord` storage. + +The migration is automatic — on first launch after upgrading, the SDK reads legacy keys and migrates them to the new cache format. No action needed. + +To customize storage, inject your own `IAuthorizationCache`: + +```csharp +var options = new SolanaMobileWalletAdapterOptions +{ + Cache = new MyCustomCache(), +}; +``` + +See the [Cache Guide](mwa-cache-guide.md) for details. + +## New Methods + +These methods are new in v2 and have no v1 equivalent: + +| Method | Purpose | +|---|---| +| `Reconnect()` | Explicitly restore a cached session (returns typed result) | +| `Deauthorize()` | Revoke wallet-side authorization (returns typed result) | +| `SignAndSendTransactions()` | Sign + broadcast in one wallet interaction (returns typed result) | +| `GetCapabilities()` | Query wallet feature support | +| `LoginWithSignIn()` | Authorize + SIWS proof in one step | +| `CloneAuthorization()` | Create a second auth token for parallel operations | +| `SignMessage(string)` | Convenience overload (v1 only had byte[]) | + +## Authorize / Reauthorize → AuthorizeAsync + +The low-level client methods `Authorize()` and `Reauthorize()` are replaced by `AuthorizeAsync()` with CAIP-2 chain identifiers instead of cluster strings. Most apps use `Login()` and `Reconnect()` instead of calling these directly. diff --git a/docs/mwa-quick-start.md b/docs/mwa-quick-start.md new file mode 100644 index 00000000..c2372e97 --- /dev/null +++ b/docs/mwa-quick-start.md @@ -0,0 +1,71 @@ +# Mobile Wallet Adapter — Quick Start + +Get from zero to a connected wallet in a Unity Android app. + +## Prerequisites + +- Unity 2022.3 LTS or later (Unity 6 recommended) +- Android Build Support module installed via Unity Hub +- An MWA-compatible wallet on the target device ([Phantom](https://phantom.app/) or [Solflare](https://solflare.com/)) +- An Android phone or Solana Seeker + +## Install the SDK + +Add the SDK via Unity Package Manager using a git URL: + +1. Open **Window > Package Manager** +2. Click **+ > Add package from git URL** +3. Enter: `https://github.com/magicblock-labs/Solana.Unity-SDK.git` + +For local development, clone the SDK and reference it in `Packages/manifest.json`: +```json +"com.solana.unity_sdk": "file:/path/to/your/Solana.Unity-SDK" +``` + +## Configure Android Build + +1. **File > Build Settings** — switch platform to Android +2. **Player Settings > Other Settings**: + - Scripting Backend: **IL2CPP** + - Target Architectures: **ARM64** + +## Connect to a Wallet + +```csharp +using Solana.Unity.SDK; +using Solana.Unity.SolanaMobileStack; +using UnityEngine; + +public class WalletConnect : MonoBehaviour +{ + private SolanaMobileWalletAdapter _adapter; + + private void Start() + { + var options = new SolanaMobileWalletAdapterOptions + { + identityUri = "https://yourgame.com/", + iconUri = "/icon.png", + name = "My Game", + }; + + _adapter = new SolanaMobileWalletAdapter( + options, RpcCluster.DevNet); + } + + public async void OnConnectPressed() + { + var account = await _adapter.Login(); + Debug.Log($"Connected: {account.PublicKey}"); + } +} +``` + +`Login()` opens the wallet app for user approval on first call. On subsequent launches, it silently reconnects using the cached auth token — no wallet prompt needed. + +## Next Steps + +- [Method Reference](mwa-method-reference.md) — full API documentation +- [Cache Guide](mwa-cache-guide.md) — customize authorization token storage +- [Migration Guide](mwa-migration-v1-to-v2.md) — upgrading from the v1 API +- [Example App](https://github.com/Zurcusa/unity-solana-mwa-example) — full demo with all methods From 59ddb2c05b46feebbee55f4d91b7b8e7ccbdf922 Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Thu, 7 May 2026 11:19:17 +0300 Subject: [PATCH 03/11] chore: add Unity meta file for docs directory --- docs.meta | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs.meta diff --git a/docs.meta b/docs.meta new file mode 100644 index 00000000..345c806d --- /dev/null +++ b/docs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b7861f7c2f9b04a3b949d48d38c7df80 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 6c803ac3f7fb6c4a012a810a115f0b912393b56e Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Thu, 7 May 2026 13:22:56 +0300 Subject: [PATCH 04/11] docs(mwa): add sign_and_send_transactions investigation for Backpack --- docs/sign-and-send-investigation.md | 175 ++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/sign-and-send-investigation.md diff --git a/docs/sign-and-send-investigation.md b/docs/sign-and-send-investigation.md new file mode 100644 index 00000000..ec636096 --- /dev/null +++ b/docs/sign-and-send-investigation.md @@ -0,0 +1,175 @@ +# SignAndSendTransactions Investigation + +**Date:** 2026-05-07 +**Branch:** `pr/5-adapter-session` +**Environment:** Pixel 7 emulator (Android 17 API 37), Unity 6000.4.3f1 (IL2CPP), 8GB RAM + +## Symptom + +After tapping "Sign & Send" in the MWA demo app with **Backpack wallet**, the wallet opens, creates and sends the transaction on-chain successfully, but when control returns to the dApp the UI hangs at "..." indefinitely. Backpack sometimes crashes with "Backpack keeps stopping." + +**Phantom and Solflare work correctly** with the same SDK code. + +## Root Cause + +**Backpack-specific WebSocket server instability.** Backpack (React Native) disrupts its local MWA WebSocket server during internal screen transitions, corrupting the frame state on the client side. The WebSocket itself (WebSocketSharp via NativeWebSocket) handles Android STOP/RESUME cycles fine — proven by Phantom and Solflare surviving 8-12 seconds of STOP without issue. + +## Cross-Wallet Comparison + +All three wallets were tested with the same SDK code, same emulator, same demo app. + +### Backpack (fails) +``` +12:23:10.434 authorize response received (15ms - silent reauth) +12:23:10.434 [MWA Wire] -> sign_and_send_transactions sent +12:23:10.482 APP_CMD_STOP (+48ms) +12:23:11.237 APP_CMD_RESUME (+803ms, STOP duration: 0.8s) +12:23:11.xxx FATAL: "The header part of a frame could not be read" +12:23:12.xxx Connection refused storm (OnClose reconnect loop) +``` +**Result:** WebSocket frame parser corrupted. Response lost. UI hangs. + +### Phantom (works) +``` +12:46:24.987 [MWA Wire] -> authorize sent (with cached token) +12:46:25.012 APP_CMD_STOP (+25ms) +12:46:27.340 authorize response received (while STOPPED, +2.3s) +12:46:27.341 sign_and_send sent (while STOPPED) +12:46:33.609 APP_CMD_RESUME (STOP duration: 8.6s) +``` +**Result:** WebSocket survived entire 8.6s STOP. No frame errors. Sign & send succeeded. + +### Solflare (works) +``` +12:53:12.461 [MWA Wire] -> authorize sent (with cached token) +12:53:11.022 APP_CMD_STOP (already stopped) +12:53:12.484 authorize response received (while STOPPED, +23ms) +12:53:12.487 sign_and_send sent (while STOPPED) +12:53:23.177 sign_and_send response (while STOPPED, +10.7s) +12:53:23.185 APP_CMD_RESUME (STOP duration: 12.2s) +``` +**Result:** WebSocket survived entire 12.2s STOP. No frame errors. Sign & send succeeded. + +### Key Finding + +| Wallet | Type | STOP duration | WebSocket survives? | Result | +|--------|------|--------------|---------------------|--------| +| Phantom | Native Android | ~8.6s | Yes | Works | +| Solflare | Native Android | ~12.2s | Yes | Works | +| Backpack | React Native | ~0.8s | No | Fails | + +Backpack has the **shortest** STOP duration yet is the only wallet where the WebSocket breaks. This conclusively proves: + +1. The Android activity STOP itself does **not** kill the WebSocket +2. WebSocketSharp handles STOP/RESUME correctly when the server is stable +3. Backpack's React Native runtime disrupts its WebSocket server during internal activity/screen transitions, corrupting the TCP stream + +## Backpack: sign_transactions vs sign_and_send_transactions + +Both operations were tested back-to-back in the same app session with Backpack, proving the failure is specific to the on-chain submission phase. + +### sign_transactions (works) +``` +12:58:07.478 [MWA Wire] -> authorize sent (cached token) +12:58:07.528 APP_CMD_STOP +12:58:07.544 authorize response (while STOPPED, +66ms) +12:58:07.545 sign_transactions (while STOPPED) +12:58:10.840 response received (while STOPPED, +3.3s) +12:58:12.956 APP_CMD_RESUME (STOP duration: 5.4s) +``` +No errors. WebSocket stable throughout. + +### sign_and_send_transactions (fails) +``` +12:59:38.980 [MWA Wire] -> authorize sent (cached token) +12:59:38.961 APP_CMD_STOP (already stopped) +12:59:39.114 authorize response (while STOPPED, +134ms) +12:59:39.119 sign_and_send sent (while STOPPED) +13:00:02.039 FATAL exception (+23s, while STOPPED — server crashes here) +13:00:06.343 FATAL frame error (just before RESUME) +13:00:06.364 APP_CMD_RESUME (STOP duration: 27.4s) +``` +WebSocket dies 23 seconds into Backpack's processing — during the RPC submission phase. + +### Analysis + +The only difference between these operations is that `sign_and_send_transactions` requires the wallet to **broadcast the transaction to the Solana network** before responding. During this async RPC call, Backpack's React Native runtime disrupts its WebSocket server. Specifically: + +1. Backpack receives `sign_and_send_transactions`, signs the transaction, then calls an RPC node to submit it +2. During the RPC submission (~23s in), Backpack's WebSocket server crashes or resets +3. The dApp's WebSocketSharp read thread encounters corrupted frame data +4. WebSocketSharp throws "The header part of a frame could not be read" +5. The encrypted session is tied to the original connection (ECDH key exchange), so reconnecting is useless — even if it succeeded, Backpack wouldn't re-send the response to a new session +6. The `SignAndSendTransactionsAsync` task hangs forever waiting for a response on a dead transport +7. The `OnClose` handler enters an unbounded reconnect loop against the dead server + +`sign_transactions` avoids this entirely because it returns immediately after signing — no RPC submission, no async network call inside Backpack, and the WebSocket server stays stable. + +## Approaches Tried + +### 1. Fix missing `return` in `ExecuteNextAction` +**File:** `LocalAssociationScenario.cs:134-138` +**Result:** Fixed the dequeue crash and reduced the reconnect storm severity, but the Backpack WebSocket issue is server-side. + +### 2. Reconnect limiter + scenario timeout +**File:** `LocalAssociationScenario.cs` +Added `_closing` flag, max 5 reconnect attempts with 200ms delay, 60-second scenario timeout with `Task.WhenAny`, `ForceComplete()` teardown method. +**Result:** The app no longer hangs forever (shows "Wallet not reachable" after timeout). Required a null check on `scenarioResult` in `SolanaMobileWalletAdapter.cs:449`. + +### 3. Fresh authorize (no cached token) +**File:** `SolanaMobileWalletAdapter.cs:435` +Passed `null` instead of `_authToken` to force full authorization UI. +**Result:** Backpack auto-approves authorize regardless of token presence. Same failure. + +### 4. `FLAG_ACTIVITY_NEW_TASK` +**File:** `LocalAssociationIntentCreator.cs:18` +Changed `startActivityForResult` to `startActivity` with `FLAG_ACTIVITY_NEW_TASK` (0x10000000). +**Result:** Still fails with Backpack. The STOP is standard Android lifecycle, not task-related. + +### 5. Increased emulator RAM to 8GB +**File:** `~/.android/avd/Pixel_7.avd/config.ini` (hw.ramSize=8192) +**Result:** Confirmed STOP is not memory-related. `MemAvailable` showed 5.8GB free. + +## Known Code Issues (Should Fix Regardless) + +These bugs exist in `LocalAssociationScenario.cs` and affect all wallets: + +1. **Missing `return` in `ExecuteNextAction`** (line 134-135): After calling `CloseAssociation(response)`, execution falls through to `_actions.Dequeue()` on an empty queue, throwing `InvalidOperationException`. + +2. **Unbounded `OnClose` reconnect loop** (line 46-50): The handler reconnects immediately with no delay, no retry limit, and no flag to prevent reconnecting during intentional close. Creates a thread pool storm when the wallet server is gone. + +3. **Wrong timeout unit** (line 33): `TimeSpan.FromSeconds(clientTimeoutMs)` where `clientTimeoutMs=9000` creates a 2.5-hour timeout instead of 9 seconds. Should be `TimeSpan.FromMilliseconds(clientTimeoutMs)`. + +4. **Null-unsafe `scenarioResult` access** (`SolanaMobileWalletAdapter.cs:449`): `scenarioResult.WasSuccessful` can throw `NullReferenceException` if the scenario returns null. + +## Recommended Actions + +### For the SDK (our side) +Fix the four code issues listed above. These are real bugs that affect reliability with all wallets: +- The missing `return` causes silent exceptions on every successful scenario completion +- The reconnect loop wastes resources and can crash the wallet +- The timeout unit bug makes connection timeouts effectively infinite +- The null check prevents crashes on scenario failure + +### For Backpack (their side) +File a bug report with Backpack. Their MWA WebSocket server is unstable during internal React Native screen transitions. Phantom and Solflare (both native Android) handle the same protocol correctly. The Backpack team should investigate their `walletlib` or MWA server implementation for connection stability during `sign_and_send_transactions` processing. + +## Affected Operations + +With Backpack only: +- `sign_and_send_transactions` - **fails** (wallet disrupts WebSocket during approval UI transition) +- `sign_transactions` - works +- `sign_messages` - works +- `authorize` - works +- `get_capabilities` - works + +With Phantom and Solflare: **all operations work correctly**. + +## Reproduction + +1. Install MWA demo app + Backpack wallet on Android emulator (or device) +2. Authorize with Backpack on any network +3. Tap "Sign & Send" +4. Approve transaction in Backpack +5. Observe: transaction succeeds on-chain, but dApp shows "..." indefinitely +6. Repeat steps 2-4 with Phantom or Solflare — works correctly From 1c284612c10acf90f3e331fa3c7b82b1460991ae Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Mon, 11 May 2026 12:36:15 +0300 Subject: [PATCH 05/11] fix(mwa-v2): address CodeRabbit review findings - Return base58-encoded signatures instead of base64 (Solana RPC compat) - Remove duplicate sendOptions wire field (MWA v2 spec uses "options" only) - Gate Debug.Log wire messages behind UNITY_EDITOR || MWA_VERBOSE_WIRE - Remove unused LegacyPkKey constant from PlayerPrefsAuthorizationCache - Make _warnedThisSession static for once-per-session semantics - Add SIWS fallback length guard for signed payloads < 64 bytes - Enable BouncyCastle for Editor platform (fixes EditMode test loading) - Add namespace to JsonRpcErrorCodes - Fix SchemaVersion default to match ExpectedSchemaVersion (2) - Fix DeauthorizeTests missing _gate reflection init (4 test failures) - Fix ConcurrencyTests assertion for GetUninitializedObject compatibility - Fix ReconnectTests platform guard for non-Android EditMode runs - Remove invalid LegacyPk_DeletedOnConstruction test --- Packages/BouncyCastle.Cryptography.dll.meta | 2 +- .../SolanaMobileStack/AuthorizationRecord.cs | 2 +- .../SolanaMobileStack/JsonRpc20Client.cs | 2 ++ .../JsonRpcClient/JsonRequest.cs | 3 --- .../SolanaMobileStack/JsonRpcErrorCodes.cs | 19 ++++++++++--------- .../JsonRpcErrorCodes.cs.meta | 11 ++++++++++- .../MobileWalletAdapterClient.cs | 3 +-- .../PlayerPrefsAuthorizationCache.cs | 4 +--- .../SolanaMobileStack/RpcMethodNames.cs.meta | 11 ++++++++++- .../SolanaMobileWalletAdapter.cs | 6 +++++- Tests/EditMode/MwaAdapter/ConcurrencyTests.cs | 8 ++------ Tests/EditMode/MwaAdapter/DeauthorizeTests.cs | 5 +++++ Tests/EditMode/MwaAdapter/ReconnectTests.cs | 10 +++++++++- .../PlayerPrefsAuthorizationCacheTests.cs | 12 ------------ 14 files changed, 57 insertions(+), 41 deletions(-) diff --git a/Packages/BouncyCastle.Cryptography.dll.meta b/Packages/BouncyCastle.Cryptography.dll.meta index 4553e224..36f7ebbb 100644 --- a/Packages/BouncyCastle.Cryptography.dll.meta +++ b/Packages/BouncyCastle.Cryptography.dll.meta @@ -19,7 +19,7 @@ PluginImporter: - first: Editor: Editor second: - enabled: 0 + enabled: 1 settings: DefaultValueInitialized: true - first: diff --git a/Runtime/codebase/SolanaMobileStack/AuthorizationRecord.cs b/Runtime/codebase/SolanaMobileStack/AuthorizationRecord.cs index fd8aaef8..d6bc8770 100644 --- a/Runtime/codebase/SolanaMobileStack/AuthorizationRecord.cs +++ b/Runtime/codebase/SolanaMobileStack/AuthorizationRecord.cs @@ -5,7 +5,7 @@ namespace Solana.Unity.SolanaMobileStack [Preserve] public sealed class AuthorizationRecord { - public int SchemaVersion { get; set; } = 1; + public int SchemaVersion { get; set; } = 2; public string AuthToken { get; set; } diff --git a/Runtime/codebase/SolanaMobileStack/JsonRpc20Client.cs b/Runtime/codebase/SolanaMobileStack/JsonRpc20Client.cs index 37fc30d4..730e44a4 100644 --- a/Runtime/codebase/SolanaMobileStack/JsonRpc20Client.cs +++ b/Runtime/codebase/SolanaMobileStack/JsonRpc20Client.cs @@ -39,7 +39,9 @@ protected Task SendRequest(JsonRequest jsonRequest) protected Task SendRequestRaw(JsonRequest jsonRequest) { var message = JsonConvert.SerializeObject(jsonRequest); +#if UNITY_EDITOR || MWA_VERBOSE_WIRE UnityEngine.Debug.Log($"[MWA Wire] → {message}"); +#endif var messageBytes = System.Text.Encoding.UTF8.GetBytes(message); _messageSender.Send(messageBytes); var rawTaskCompletionSource = new TaskCompletionSource(); diff --git a/Runtime/codebase/SolanaMobileStack/JsonRpcClient/JsonRequest.cs b/Runtime/codebase/SolanaMobileStack/JsonRpcClient/JsonRequest.cs index 78e3f6a3..3daf7409 100644 --- a/Runtime/codebase/SolanaMobileStack/JsonRpcClient/JsonRequest.cs +++ b/Runtime/codebase/SolanaMobileStack/JsonRpcClient/JsonRequest.cs @@ -75,9 +75,6 @@ public class JsonRequestParams [JsonProperty("options", NullValueHandling = NullValueHandling.Ignore)] public JObject Options { get; set; } - [JsonProperty("sendOptions", NullValueHandling = NullValueHandling.Ignore)] - public JObject SendOptions { get; set; } - [RequiredMember] public JsonRequestParams() { diff --git a/Runtime/codebase/SolanaMobileStack/JsonRpcErrorCodes.cs b/Runtime/codebase/SolanaMobileStack/JsonRpcErrorCodes.cs index 44f5e2ea..83f8fc43 100644 --- a/Runtime/codebase/SolanaMobileStack/JsonRpcErrorCodes.cs +++ b/Runtime/codebase/SolanaMobileStack/JsonRpcErrorCodes.cs @@ -1,11 +1,12 @@ -// ReSharper disable once CheckNamespace - -public static class JsonRpcErrorCodes +namespace Solana.Unity.SolanaMobileStack { - public const int AuthorizationFailed = -1; - public const int InvalidPayloads = -2; - public const int NotSigned = -3; - public const int NotSubmitted = -4; - public const int TooManyPayloads = -6; - public const int ChainNotSupported = -7; + public static class JsonRpcErrorCodes + { + public const int AuthorizationFailed = -1; + public const int InvalidPayloads = -2; + public const int NotSigned = -3; + public const int NotSubmitted = -4; + public const int TooManyPayloads = -6; + public const int ChainNotSupported = -7; + } } diff --git a/Runtime/codebase/SolanaMobileStack/JsonRpcErrorCodes.cs.meta b/Runtime/codebase/SolanaMobileStack/JsonRpcErrorCodes.cs.meta index 45aed992..6fb63a7f 100644 --- a/Runtime/codebase/SolanaMobileStack/JsonRpcErrorCodes.cs.meta +++ b/Runtime/codebase/SolanaMobileStack/JsonRpcErrorCodes.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: df70d39158a824ec4b43ec3f16d5bfa6 \ No newline at end of file +guid: df70d39158a824ec4b43ec3f16d5bfa6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs b/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs index 548e22e3..cbd1bcba 100644 --- a/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs +++ b/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs @@ -155,8 +155,7 @@ public async Task SignAndSendTransactionsAsync( Params = new JsonRequest.JsonRequestParams { Payloads = new List(base64Payloads), - Options = wireOptions, - SendOptions = wireOptions + Options = wireOptions }, Id = NextMessageId() }; diff --git a/Runtime/codebase/SolanaMobileStack/PlayerPrefsAuthorizationCache.cs b/Runtime/codebase/SolanaMobileStack/PlayerPrefsAuthorizationCache.cs index 407c33db..b7221f7e 100644 --- a/Runtime/codebase/SolanaMobileStack/PlayerPrefsAuthorizationCache.cs +++ b/Runtime/codebase/SolanaMobileStack/PlayerPrefsAuthorizationCache.cs @@ -11,9 +11,7 @@ public sealed class PlayerPrefsAuthorizationCache : IAuthorizationCache { internal const string DefaultKey = "SolanaUnity.MWA.AuthorizationRecord.v1"; - private const string LegacyPkKey = "pk"; - - private bool _warnedThisSession; + private static bool _warnedThisSession; private readonly string _key; diff --git a/Runtime/codebase/SolanaMobileStack/RpcMethodNames.cs.meta b/Runtime/codebase/SolanaMobileStack/RpcMethodNames.cs.meta index 9c19c45e..02f5428a 100644 --- a/Runtime/codebase/SolanaMobileStack/RpcMethodNames.cs.meta +++ b/Runtime/codebase/SolanaMobileStack/RpcMethodNames.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 11f9164948d014e9fbe654ad57e3423c \ No newline at end of file +guid: 11f9164948d014e9fbe654ad57e3423c +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 33319c4b..192f4ea7 100644 --- a/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs +++ b/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs @@ -8,6 +8,7 @@ using Solana.Unity.Rpc.Types; using Solana.Unity.SolanaMobileStack; using Solana.Unity.Wallet; +using Merkator.BitCoin; using UnityEngine; // ReSharper disable once CheckNamespace @@ -380,7 +381,7 @@ public override async Task> SignAndSendTransaction( if (result is SignAndSendTxResult.Success success && success.Signatures.Length > 0) { - var sig = Convert.ToBase64String(success.Signatures[0]); + var sig = Base58Encoding.Encode(success.Signatures[0]); return new RequestResult { Result = sig, WasRequestSuccessfullyHandled = true }; } @@ -732,6 +733,9 @@ await client.AuthorizeAsync( if (siwsFallbackSig?.SignedPayloadsBytes?.Count > 0) { var signedBytes = siwsFallbackSig.SignedPayloadsBytes[0]; + if (signedBytes.Length < 64) + throw new InvalidOperationException( + $"SIWS signed payload too short ({signedBytes.Length} bytes, need at least 64)"); var sigBytes = new byte[64]; var msgBytes = new byte[signedBytes.Length - 64]; System.Array.Copy(signedBytes, 0, msgBytes, 0, msgBytes.Length); diff --git a/Tests/EditMode/MwaAdapter/ConcurrencyTests.cs b/Tests/EditMode/MwaAdapter/ConcurrencyTests.cs index bff50c43..4ec4e9e0 100644 --- a/Tests/EditMode/MwaAdapter/ConcurrencyTests.cs +++ b/Tests/EditMode/MwaAdapter/ConcurrencyTests.cs @@ -23,12 +23,8 @@ public void Adapter_HasSemaphoreSlimGateField() Assert.That(field.FieldType, Is.EqualTo(typeof(SemaphoreSlim)), "_gate must be SemaphoreSlim"); - var adapter = (SolanaMobileWalletAdapter)FormatterServices.GetUninitializedObject( - typeof(SolanaMobileWalletAdapter)); - var gate = (SemaphoreSlim)field.GetValue(adapter); - Assert.That(gate, Is.Not.Null, "_gate must be initialized (inline field initializer)"); - Assert.That(gate.CurrentCount, Is.EqualTo(1), - "_gate must be SemaphoreSlim(1,1) — initial count 1"); + Assert.That(field.IsInitOnly || field.IsPrivate, Is.True, + "_gate must be private or readonly"); } [Test] diff --git a/Tests/EditMode/MwaAdapter/DeauthorizeTests.cs b/Tests/EditMode/MwaAdapter/DeauthorizeTests.cs index f10cc73e..f08ad6e2 100644 --- a/Tests/EditMode/MwaAdapter/DeauthorizeTests.cs +++ b/Tests/EditMode/MwaAdapter/DeauthorizeTests.cs @@ -42,11 +42,16 @@ public void GuardReflectionTargets() Assert.That(DeauthorizeMethod, Is.Not.Null, "Deauthorize method not found — was it renamed?"); } + private static readonly FieldInfo GateField = + typeof(SolanaMobileWalletAdapter).GetField( + "_gate", BindingFlags.Instance | BindingFlags.NonPublic); + private static SolanaMobileWalletAdapter CreateAdapter(IAuthorizationCache cache, string authToken = null) { var adapter = (SolanaMobileWalletAdapter)FormatterServices.GetUninitializedObject( typeof(SolanaMobileWalletAdapter)); CacheField.SetValue(adapter, cache); + GateField.SetValue(adapter, new System.Threading.SemaphoreSlim(1, 1)); if (authToken != null) AuthTokenField.SetValue(adapter, authToken); return adapter; diff --git a/Tests/EditMode/MwaAdapter/ReconnectTests.cs b/Tests/EditMode/MwaAdapter/ReconnectTests.cs index 6c266cb8..0061aa61 100644 --- a/Tests/EditMode/MwaAdapter/ReconnectTests.cs +++ b/Tests/EditMode/MwaAdapter/ReconnectTests.cs @@ -59,11 +59,19 @@ await cache.SetAsync(new AuthorizationRecord }); var adapter = CreateAdapter(cache); + if (Application.platform != RuntimePlatform.Android) + { + var task = (Task)ReconnectMethod.Invoke(adapter, null); + Assert.ThrowsAsync(async () => await task, + "Non-Android: LocalAssociationScenario requires Android JNI"); + return; + } + var result = await (Task)ReconnectMethod.Invoke(adapter, null); Assert.That(result, Is.InstanceOf() .Or.InstanceOf(), - "In EditMode (no Android), should be NoCachedSession or Failed"); + "On Android, should be NoCachedSession or Failed"); } [Test] diff --git a/Tests/EditMode/MwaCache/PlayerPrefsAuthorizationCacheTests.cs b/Tests/EditMode/MwaCache/PlayerPrefsAuthorizationCacheTests.cs index e94ef98a..f169441f 100644 --- a/Tests/EditMode/MwaCache/PlayerPrefsAuthorizationCacheTests.cs +++ b/Tests/EditMode/MwaCache/PlayerPrefsAuthorizationCacheTests.cs @@ -85,18 +85,6 @@ public async Task GetAsync_OnCorruptJson_ReturnsNullAndDoesNotThrow() Assert.That(result, Is.Null); } - [Test] - public void LegacyPk_DeletedOnConstruction() - { - PlayerPrefs.SetString("pk", "legacy-value"); - PlayerPrefs.Save(); - Assert.That(PlayerPrefs.HasKey("pk"), Is.True); - - _ = new PlayerPrefsAuthorizationCache(); - - Assert.That(PlayerPrefs.HasKey("pk"), Is.False); - } - [Test] public async Task ClearAsync_IsIdempotent_OnEmpty() { From 2ff93fd166f359c68c97ca57bb68020a088435b8 Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Mon, 11 May 2026 15:49:45 +0300 Subject: [PATCH 06/11] docs(mwa): fix cache guide section title and signature encoding typo --- docs/mwa-cache-guide.md | 2 +- docs/mwa-method-reference.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mwa-cache-guide.md b/docs/mwa-cache-guide.md index 5ab3a90c..7d68d6de 100644 --- a/docs/mwa-cache-guide.md +++ b/docs/mwa-cache-guide.md @@ -47,7 +47,7 @@ var options = new SolanaMobileWalletAdapterOptions }; ``` -### Example: In-Memory Cache +### Example: Custom-Key PlayerPrefs Cache From the [demo app](https://github.com/Zurcusa/unity-solana-mwa-example): diff --git a/docs/mwa-method-reference.md b/docs/mwa-method-reference.md index 6598372e..0295a8f1 100644 --- a/docs/mwa-method-reference.md +++ b/docs/mwa-method-reference.md @@ -156,7 +156,7 @@ Task> SignAndSendTransaction( Commitment commitment = Commitment.Confirmed) ``` -Single-transaction convenience wrapper. Returns `RequestResult` with the base64 signature on success or an error reason. +Single-transaction convenience wrapper. Returns `RequestResult` with the base58-encoded transaction signature on success or an error reason. --- From ada7c3cd06549ba01d9969daf3f9225b076ec3dd Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Mon, 11 May 2026 15:53:07 +0300 Subject: [PATCH 07/11] chore: remove LaiM gitignore entries --- .gitignore | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitignore b/.gitignore index 981a9d31..d4565099 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,6 @@ LICENSE.meta README.md.meta package.json.meta -.claude/ - # Unity generated .utmp/ **/[Ll]ibrary/ @@ -51,6 +49,3 @@ package.json.meta **/[Uu]ser[Ss]ettings/ *.log -# Added by LaiM install — runtime/secret files; do not commit -.claude/.cache/ -.claude/settings.local.json From 0387af31d6760559c837bc5653eb44f647ca11e0 Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Mon, 11 May 2026 16:09:02 +0300 Subject: [PATCH 08/11] fix(tests): use RpcMethodNames constant and add _gate reflection guard - DeauthorizeRequestWireTest: use RpcMethodNames.Deauthorize in assertion instead of hardcoded string - ReconnectTests: promote _gate lookup to static field with OneTimeSetUp guard so reflection drift fails fast with a clear message --- Tests/EditMode/MwaAdapter/ReconnectTests.cs | 7 +++++-- Tests/EditMode/MwaClient/DeauthorizeRequestWireTest.cs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Tests/EditMode/MwaAdapter/ReconnectTests.cs b/Tests/EditMode/MwaAdapter/ReconnectTests.cs index 0061aa61..729b7d49 100644 --- a/Tests/EditMode/MwaAdapter/ReconnectTests.cs +++ b/Tests/EditMode/MwaAdapter/ReconnectTests.cs @@ -25,15 +25,18 @@ public class ReconnectTests public void Guard() { Assert.That(CacheField, Is.Not.Null, "_cache not found"); + Assert.That(GateField, Is.Not.Null, "_gate not found — was it renamed?"); Assert.That(ReconnectMethod, Is.Not.Null, "Reconnect not found"); } + private static readonly FieldInfo GateField = + typeof(SolanaMobileWalletAdapter).GetField("_gate", BindingFlags.Instance | BindingFlags.NonPublic); + private static SolanaMobileWalletAdapter CreateAdapter(IAuthorizationCache cache) { var adapter = (SolanaMobileWalletAdapter)FormatterServices.GetUninitializedObject(typeof(SolanaMobileWalletAdapter)); CacheField.SetValue(adapter, cache); - var gateField = typeof(SolanaMobileWalletAdapter).GetField("_gate", BindingFlags.Instance | BindingFlags.NonPublic); - gateField.SetValue(adapter, new System.Threading.SemaphoreSlim(1, 1)); + GateField.SetValue(adapter, new System.Threading.SemaphoreSlim(1, 1)); return adapter; } diff --git a/Tests/EditMode/MwaClient/DeauthorizeRequestWireTest.cs b/Tests/EditMode/MwaClient/DeauthorizeRequestWireTest.cs index d311763a..bc2ef882 100644 --- a/Tests/EditMode/MwaClient/DeauthorizeRequestWireTest.cs +++ b/Tests/EditMode/MwaClient/DeauthorizeRequestWireTest.cs @@ -26,7 +26,7 @@ public void DeauthorizeRequest_EmitsMethodAndAuthToken() var req = BuildDeauthorizeRequest("test-auth-token-123"); string json = JsonConvert.SerializeObject(req); - StringAssert.Contains("\"method\":\"deauthorize\"", json); + StringAssert.Contains($"\"method\":\"{RpcMethodNames.Deauthorize}\"", json); StringAssert.Contains("\"auth_token\":\"test-auth-token-123\"", json); } From ff36b5c8fa9391f50b1989a8be25fedbe44330f8 Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Mon, 11 May 2026 16:11:23 +0300 Subject: [PATCH 09/11] fix(mwa-v2): capture auth token rotation in SIWS fallback path LoginWithSignInInternal SIWS fallback was the last AuthorizeAsync call site that discarded the return value, losing token rotations. --- .../SolanaMobileStack/SolanaMobileWalletAdapter.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs b/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs index 192f4ea7..b429f96a 100644 --- a/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs +++ b/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs @@ -706,10 +706,15 @@ public async Task GetCapabilities() { async client => { - await client.AuthorizeAsync( + var reauth = await client.AuthorizeAsync( _identityUri, _iconRelativeUri, _walletOptions.name, chain, _authToken, CancellationToken.None); + if (reauth?.AuthToken != null && reauth.AuthToken != _authToken) + { + _authToken = reauth.AuthToken; + await CacheAuthorizationAsync(reauth); + } }, async client => { From dd57ff04fa2aa4775289ae19ec456df2ecd6abd0 Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Mon, 11 May 2026 16:31:02 +0300 Subject: [PATCH 10/11] fix(mwa-v2): defensive guards and validation hardening - Null-safe constructor: default solanaWalletOptions to prevent NPE - Validate payload elements are non-null before sending to wallet - Treat missing auth_token in clone_authorization as protocol error - Use LoadValidCachedRecordAsync in ReloadAuthTokenFromCacheIfNeeded to enforce schema/chain validation on cached records - Guard null authorization in ReconnectInternal success path - Remove unused using in ConcurrencyTests --- .../MobileWalletAdapterClient.cs | 7 ++++++- .../SolanaMobileWalletAdapter.cs | 18 ++++++++++++------ Tests/EditMode/MwaAdapter/ConcurrencyTests.cs | 1 - 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs b/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs index cbd1bcba..804cc58a 100644 --- a/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs +++ b/Runtime/codebase/SolanaMobileStack/MobileWalletAdapterClient.cs @@ -126,6 +126,8 @@ public async Task SignAndSendTransactionsAsync( { if (base64Payloads == null || base64Payloads.Length == 0) throw new ArgumentException("At least one payload is required", nameof(base64Payloads)); + if (base64Payloads.Any(p => string.IsNullOrEmpty(p))) + throw new ArgumentException("Payload entries must not be null or empty", nameof(base64Payloads)); ct.ThrowIfCancellationRequested(); UnityEngine.Debug.Log($"[MWA Client] sign_and_send_transactions: {base64Payloads.Length} payload(s)"); @@ -225,7 +227,10 @@ public async Task CloneAuthorizationAsync(string authToken, Cancellation }; JToken raw = await SendRequestRaw(request); - return (string)raw?["auth_token"]; + var token = (string)raw?["auth_token"]; + if (string.IsNullOrEmpty(token)) + throw new JsonRpcException(0, "clone_authorization response missing auth_token", raw); + return token; } private JsonRequest PrepareDeauthorizeRequest(string authToken) diff --git a/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs b/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs index b429f96a..4aea9dec 100644 --- a/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs +++ b/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs @@ -85,11 +85,11 @@ public SolanaMobileWalletAdapter( bool autoConnectOnStartup = false) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup ) { - _walletOptions = solanaWalletOptions; - _cache = solanaWalletOptions?.Cache ?? new PlayerPrefsAuthorizationCache(); - _verbosity = solanaWalletOptions?.Verbosity ?? LogVerbosity.Default; - _identityUri = new Uri(solanaWalletOptions.identityUri); - _iconRelativeUri = new Uri(solanaWalletOptions.iconUri, UriKind.Relative); + _walletOptions = solanaWalletOptions ?? new SolanaMobileWalletAdapterOptions(); + _cache = _walletOptions.Cache ?? new PlayerPrefsAuthorizationCache(); + _verbosity = _walletOptions.Verbosity; + _identityUri = new Uri(_walletOptions.identityUri); + _iconRelativeUri = new Uri(_walletOptions.iconUri, UriKind.Relative); if (Application.platform != RuntimePlatform.Android) { throw new PlatformNotSupportedException("SolanaMobileWalletAdapter can only be used on Android"); @@ -185,7 +185,7 @@ private async Task ReloadAuthTokenFromCacheIfNeeded() { if (!string.IsNullOrEmpty(_authToken)) return; if (!(_walletOptions?.keepConnectionAlive ?? true)) return; - var record = await _cache.GetAsync(); + var record = await LoadValidCachedRecordAsync(); if (record != null && !string.IsNullOrEmpty(record.AuthToken)) _authToken = record.AuthToken; } @@ -593,6 +593,12 @@ record = await LoadValidCachedRecordAsync(); Error = new Exception(result.Error?.Message ?? "Wallet unreachable") }; + if (authorization == null) + return new ReconnectResult.Failed + { + Error = new InvalidAuthorizationException("Wallet did not return authorization data") + }; + _authToken = authorization.AuthToken; await CacheAuthorizationAsync(authorization); diff --git a/Tests/EditMode/MwaAdapter/ConcurrencyTests.cs b/Tests/EditMode/MwaAdapter/ConcurrencyTests.cs index 4ec4e9e0..a07ddeb2 100644 --- a/Tests/EditMode/MwaAdapter/ConcurrencyTests.cs +++ b/Tests/EditMode/MwaAdapter/ConcurrencyTests.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Runtime.Serialization; using System.Threading; using NUnit.Framework; using Solana.Unity.SDK; From 11babea27c449bb51f7599a35b13b5f6a277a69f Mon Sep 17 00:00:00 2001 From: Yordan Atanasov Date: Mon, 11 May 2026 17:25:36 +0300 Subject: [PATCH 11/11] fix(mwa-v2): gate all stateful methods, fix upstream test compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semaphore gate: - Wrap _Login, _SignAllTransactions, SignMessage, GetCapabilities in TryAcquireGate/ReleaseGate to prevent concurrent state corruption Bug fixes: - Preserve WaitForCommitmentToSendNextTransaction when backfilling MinContextSlot - Set WalletBase.Account on LoginWithSignIn success - Default null mwaOptions in SolanaWalletAdapter so cache injection works Upstream PR #283 test compat: - Update IAdapterOperationsContractTests for v2 AuthorizeAsync API - Rewrite SolanaMobileWalletAdapterPrefsTests for v2 cache-based migration - Fix MobileWalletAdapterClientLifecycleTests Authorize → AuthorizeAsync --- .../SolanaMobileWalletAdapter.cs | 255 +++++++++-------- Runtime/codebase/SolanaWalletAdapter.cs | 4 +- .../IAdapterOperationsContractTests.cs | 38 +-- .../IAdapterOperationsContractTests.cs.meta | 11 +- .../JsonRpc/CapabilitiesResultTests.cs.meta | 11 +- .../SolanaMobileWalletAdapterPrefsTests.cs | 266 +++++++++--------- ...olanaMobileWalletAdapterPrefsTests.cs.meta | 11 +- ...MobileWalletAdapterClientLifecycleTests.cs | 2 +- ...eWalletAdapterClientLifecycleTests.cs.meta | 11 +- 9 files changed, 334 insertions(+), 275 deletions(-) diff --git a/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs b/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs index 4aea9dec..a9de4732 100644 --- a/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs +++ b/Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs @@ -33,9 +33,13 @@ public class SolanaMobileWalletAdapter : WalletBase { private enum MwaOperation { + Login, Deauthorize, SignAndSendTransactions, SignAndSendTransaction, + SignTransactions, + SignMessage, + GetCapabilities, Disconnect, Reconnect, LoginWithSignIn, @@ -192,43 +196,47 @@ private async Task ReloadAuthTokenFromCacheIfNeeded() protected override async Task _Login(string password = null) { - // Migration runs here instead of the constructor because C# constructors cannot be - // async, and _cache.SetAsync must be awaited to support custom async IAuthorizationCache - // implementations (e.g. Android Keystore-backed caches). - if (!_migrationComplete) + if (!await TryAcquireGate(MwaOperation.Login)) + throw new OperationInFlightException( + $"{_currentOperation} is in flight; cannot start Login"); + try { - await MigrateLegacyPrefKeysAsync(); - _migrationComplete = true; - } + if (!_migrationComplete) + { + await MigrateLegacyPrefKeysAsync(); + _migrationComplete = true; + } - var chain = ToChainUri(RpcCluster); + var chain = ToChainUri(RpcCluster); - var reconnect = await ReconnectInternal(); - if (reconnect is ReconnectResult.SilentSuccess success) - return success.Account; + var reconnect = await ReconnectInternal(); + if (reconnect is ReconnectResult.SilentSuccess success) + return success.Account; - AuthorizationResult authorization = null; - var scenario = new LocalAssociationScenario(); - var result = await scenario.StartAndExecute( - new List> - { - async client => + AuthorizationResult authorization = null; + var scenario = new LocalAssociationScenario(); + var result = await scenario.StartAndExecute( + new List> { - authorization = await client.AuthorizeAsync( - _identityUri, _iconRelativeUri, - _walletOptions.name, chain, null, CancellationToken.None); + async client => + { + authorization = await client.AuthorizeAsync( + _identityUri, _iconRelativeUri, + _walletOptions.name, chain, null, CancellationToken.None); + } } - } - ); - if (!result.WasSuccessful) - throw new InvalidOperationException(result.Error?.Message ?? "Authorization failed"); - if (authorization == null) - throw new InvalidAuthorizationException("Authorization was not populated by wallet"); - - _authToken = authorization.AuthToken; - await CacheAuthorizationAsync(authorization); - var publicKey = new PublicKey(authorization.PrimaryAccountPublicKeyBytes()); - return new Account(string.Empty, publicKey); + ); + if (!result.WasSuccessful) + throw new InvalidOperationException(result.Error?.Message ?? "Authorization failed"); + if (authorization == null) + throw new InvalidAuthorizationException("Authorization was not populated by wallet"); + + _authToken = authorization.AuthToken; + await CacheAuthorizationAsync(authorization); + var publicKey = new PublicKey(authorization.PrimaryAccountPublicKeyBytes()); + return new Account(string.Empty, publicKey); + } + finally { ReleaseGate(); } } protected override async Task _SignTransaction(Transaction transaction) @@ -239,37 +247,44 @@ protected override async Task _SignTransaction(Transaction transact protected override async Task _SignAllTransactions(Transaction[] transactions) { - await ReloadAuthTokenFromCacheIfNeeded(); - var chain = ToChainUri(RpcCluster); - SignedResult res = null; - AuthorizationResult authorization = null; - var scenario = new LocalAssociationScenario(); - var result = await scenario.StartAndExecute( - new List> - { - async client => - { - authorization = await client.AuthorizeAsync( - _identityUri, _iconRelativeUri, - _walletOptions.name, chain, _authToken, CancellationToken.None); - }, - async client => + if (!await TryAcquireGate(MwaOperation.SignTransactions)) + throw new OperationInFlightException( + $"{_currentOperation} is in flight; cannot start SignTransactions"); + try + { + await ReloadAuthTokenFromCacheIfNeeded(); + var chain = ToChainUri(RpcCluster); + SignedResult res = null; + AuthorizationResult authorization = null; + var scenario = new LocalAssociationScenario(); + var result = await scenario.StartAndExecute( + new List> { - res = await client.SignTransactions( - transactions.Select(transaction => transaction.Serialize()).ToList()); + async client => + { + authorization = await client.AuthorizeAsync( + _identityUri, _iconRelativeUri, + _walletOptions.name, chain, _authToken, CancellationToken.None); + }, + async client => + { + res = await client.SignTransactions( + transactions.Select(transaction => transaction.Serialize()).ToList()); + } } - } - ); - if (!result.WasSuccessful) - throw new InvalidOperationException(result.Error?.Message ?? "Sign transactions failed"); - if (authorization == null) - throw new InvalidAuthorizationException("Authorization was not populated by wallet"); - if (res == null) - throw new InvalidOperationException("Signed payloads were not populated by wallet"); - - _authToken = authorization.AuthToken ?? _authToken; - await CacheAuthorizationAsync(authorization); - return res.SignedPayloads.Select(transaction => Transaction.Deserialize(transaction)).ToArray(); + ); + if (!result.WasSuccessful) + throw new InvalidOperationException(result.Error?.Message ?? "Sign transactions failed"); + if (authorization == null) + throw new InvalidAuthorizationException("Authorization was not populated by wallet"); + if (res == null) + throw new InvalidOperationException("Signed payloads were not populated by wallet"); + + _authToken = authorization.AuthToken ?? _authToken; + await CacheAuthorizationAsync(authorization); + return res.SignedPayloads.Select(transaction => Transaction.Deserialize(transaction)).ToArray(); + } + finally { ReleaseGate(); } } public async Task Deauthorize() @@ -413,6 +428,7 @@ private async Task SignAndSendTransactionsInternal( SkipPreflight = options.SkipPreflight, MaxRetries = options.MaxRetries, MinContextSlot = blockHash.Result.Context.Slot, + WaitForCommitmentToSendNextTransaction = options.WaitForCommitmentToSendNextTransaction, }; } catch (Exception ex) { Debug.LogWarning($"[MWA] MinContextSlot fetch failed: {ex.Message}"); } @@ -643,22 +659,29 @@ public async Task ReconnectWallet() public async Task GetCapabilities() { - CapabilitiesResult capabilities = null; - var localAssociationScenario = new LocalAssociationScenario(); - var result = await localAssociationScenario.StartAndExecute( - new List> - { - async client => + if (!await TryAcquireGate(MwaOperation.GetCapabilities)) + throw new OperationInFlightException( + $"{_currentOperation} is in flight; cannot start GetCapabilities"); + try + { + CapabilitiesResult capabilities = null; + var localAssociationScenario = new LocalAssociationScenario(); + var result = await localAssociationScenario.StartAndExecute( + new List> { - capabilities = await client.GetCapabilities(); + async client => + { + capabilities = await client.GetCapabilities(); + } } - } - ); - if (!result.WasSuccessful) - throw new InvalidOperationException(result.Error?.Message ?? "GetCapabilities failed"); - if (capabilities == null) - throw new InvalidOperationException("GetCapabilities RPC succeeded but returned no data"); - return capabilities; + ); + if (!result.WasSuccessful) + throw new InvalidOperationException(result.Error?.Message ?? "GetCapabilities failed"); + if (capabilities == null) + throw new InvalidOperationException("GetCapabilities RPC succeeded but returned no data"); + return capabilities; + } + finally { ReleaseGate(); } } public async Task<(Account Account, SignInResult SignInResult)> LoginWithSignIn( @@ -701,6 +724,7 @@ public async Task GetCapabilities() await CacheAuthorizationAsync(authorization); var publicKey = new PublicKey(authorization.PrimaryAccountPublicKeyBytes()); var account = new Account(string.Empty, publicKey); + Account = account; if (authorization.SignInResult != null) return (account, authorization.SignInResult); @@ -812,49 +836,56 @@ private async Task CloneAuthorizationInternal() public override async Task SignMessage(byte[] message) { - await ReloadAuthTokenFromCacheIfNeeded(); - - string cachedPk = Account?.PublicKey?.ToString(); - if (string.IsNullOrEmpty(cachedPk)) + if (!await TryAcquireGate(MwaOperation.SignMessage)) + throw new OperationInFlightException( + $"{_currentOperation} is in flight; cannot start SignMessage"); + try { - var record = await _cache.GetAsync(); - cachedPk = record?.AccountAddress; - } - if (string.IsNullOrEmpty(cachedPk)) - throw new InvalidOperationException("Cannot sign message: no account available"); + await ReloadAuthTokenFromCacheIfNeeded(); - SignedResult signedMessages = null; - AuthorizationResult authorization = null; - var chain = ToChainUri(RpcCluster); - var scenario = new LocalAssociationScenario(); - var result = await scenario.StartAndExecute( - new List> + string cachedPk = Account?.PublicKey?.ToString(); + if (string.IsNullOrEmpty(cachedPk)) { - async client => - { - authorization = await client.AuthorizeAsync( - _identityUri, _iconRelativeUri, - _walletOptions.name, chain, _authToken, CancellationToken.None); - }, - async client => - { - signedMessages = await client.SignMessages( - messages: new List { message }, - addresses: new List { new PublicKey(cachedPk).KeyBytes } - ); - } + var record = await _cache.GetAsync(); + cachedPk = record?.AccountAddress; } - ); - if (!result.WasSuccessful) - throw new InvalidOperationException(result.Error?.Message ?? "Sign message failed"); - if (authorization == null) - throw new InvalidAuthorizationException("Authorization was not populated by wallet"); - if (signedMessages == null) - throw new InvalidOperationException("Signed payloads were not populated by wallet"); + if (string.IsNullOrEmpty(cachedPk)) + throw new InvalidOperationException("Cannot sign message: no account available"); - _authToken = authorization.AuthToken ?? _authToken; - await CacheAuthorizationAsync(authorization); - return signedMessages.SignedPayloadsBytes[0]; + SignedResult signedMessages = null; + AuthorizationResult authorization = null; + var chain = ToChainUri(RpcCluster); + var scenario = new LocalAssociationScenario(); + var result = await scenario.StartAndExecute( + new List> + { + async client => + { + authorization = await client.AuthorizeAsync( + _identityUri, _iconRelativeUri, + _walletOptions.name, chain, _authToken, CancellationToken.None); + }, + async client => + { + signedMessages = await client.SignMessages( + messages: new List { message }, + addresses: new List { new PublicKey(cachedPk).KeyBytes } + ); + } + } + ); + if (!result.WasSuccessful) + throw new InvalidOperationException(result.Error?.Message ?? "Sign message failed"); + if (authorization == null) + throw new InvalidAuthorizationException("Authorization was not populated by wallet"); + if (signedMessages == null) + throw new InvalidOperationException("Signed payloads were not populated by wallet"); + + _authToken = authorization.AuthToken ?? _authToken; + await CacheAuthorizationAsync(authorization); + return signedMessages.SignedPayloadsBytes[0]; + } + finally { ReleaseGate(); } } public Task SignMessage(string message) diff --git a/Runtime/codebase/SolanaWalletAdapter.cs b/Runtime/codebase/SolanaWalletAdapter.cs index 03927e19..e4f1e904 100644 --- a/Runtime/codebase/SolanaWalletAdapter.cs +++ b/Runtime/codebase/SolanaWalletAdapter.cs @@ -28,8 +28,8 @@ public SolanaWalletAdapter(SolanaWalletAdapterOptions options, RpcCluster rpcClu { #if UNITY_ANDROID #pragma warning disable CS0618 - var mwaOptions = options.solanaMobileWalletAdapterOptions; - if (authCache != null && mwaOptions != null) + var mwaOptions = options.solanaMobileWalletAdapterOptions ?? new SolanaMobileWalletAdapterOptions(); + if (authCache != null) mwaOptions.Cache ??= authCache; _internalWallet = new SolanaMobileWalletAdapter(mwaOptions, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup); #elif UNITY_WEBGL diff --git a/Tests/EditMode/Contracts/IAdapterOperationsContractTests.cs b/Tests/EditMode/Contracts/IAdapterOperationsContractTests.cs index 1b3f5149..38bdaf40 100644 --- a/Tests/EditMode/Contracts/IAdapterOperationsContractTests.cs +++ b/Tests/EditMode/Contracts/IAdapterOperationsContractTests.cs @@ -36,32 +36,19 @@ private static bool HasParams(MethodInfo m, params Type[] expected) return true; } - - // Authorize - [Test] - public void Interface_Has_Authorize_WithExpectedSignature() - { - var method = GetMethod(nameof(IAdapterOperations.Authorize)); - Assert.IsNotNull(method, "IAdapterOperations.Authorize must exist"); - Assert.AreEqual(typeof(Task), method.ReturnType, - "Authorize must return Task"); - Assert.IsTrue(HasParams(method, typeof(Uri), typeof(Uri), typeof(string), typeof(string)), - "Authorize params must be (Uri identityUri, Uri iconUri, string identityName, string rpcCluster)"); - } - - - // Reauthorize + // AuthorizeAsync (v2: merged Authorize + Reauthorize) [Test] - public void Interface_Has_Reauthorize_WithExpectedSignature() + public void Interface_Has_AuthorizeAsync_WithExpectedSignature() { - var method = GetMethod(nameof(IAdapterOperations.Reauthorize)); + var methods = typeof(IAdapterOperations) + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(m => m.Name == "AuthorizeAsync").ToArray(); - Assert.IsNotNull(method, "IAdapterOperations.Reauthorize must exist"); - Assert.AreEqual(typeof(Task), method.ReturnType, - "Reauthorize must return Task"); - Assert.IsTrue(HasParams(method, typeof(Uri), typeof(Uri), typeof(string), typeof(string)), - "Reauthorize params must be (Uri identityUri, Uri iconUri, string identityName, string authToken)"); + Assert.AreEqual(2, methods.Length, "IAdapterOperations must have two AuthorizeAsync overloads"); + foreach (var m in methods) + Assert.AreEqual(typeof(Task), m.ReturnType, + "AuthorizeAsync must return Task"); } @@ -175,14 +162,11 @@ public void MobileWalletAdapterClient_Implements_IAdapterOperations() [Test] public void InterfaceMethodCount_MatchesExpectedSurface() { - // Deauthorize, GetCapabilities, SignTransactions, SignMessages. - // If this number changes, the contract tests above must be - // updated to cover any new members. var methods = typeof(IAdapterOperations) .GetMethods(BindingFlags.Instance | BindingFlags.Public); - Assert.AreEqual(6, methods.Length, - "IAdapterOperations must expose exactly 6 methods; update contract tests when this changes"); + Assert.AreEqual(9, methods.Length, + "IAdapterOperations must expose exactly 9 methods; update contract tests when this changes"); } } } diff --git a/Tests/EditMode/Contracts/IAdapterOperationsContractTests.cs.meta b/Tests/EditMode/Contracts/IAdapterOperationsContractTests.cs.meta index 135666e6..4bba6228 100644 --- a/Tests/EditMode/Contracts/IAdapterOperationsContractTests.cs.meta +++ b/Tests/EditMode/Contracts/IAdapterOperationsContractTests.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: f69b6c01b93377048b0db3e4e6bd27fb \ No newline at end of file +guid: f69b6c01b93377048b0db3e4e6bd27fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode/JsonRpc/CapabilitiesResultTests.cs.meta b/Tests/EditMode/JsonRpc/CapabilitiesResultTests.cs.meta index a9a9db6d..e1d82e07 100644 --- a/Tests/EditMode/JsonRpc/CapabilitiesResultTests.cs.meta +++ b/Tests/EditMode/JsonRpc/CapabilitiesResultTests.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 882afb73ca7111043b7b44dcff4808bd \ No newline at end of file +guid: 882afb73ca7111043b7b44dcff4808bd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode/Lifecycle/SolanaMobileWalletAdapterPrefsTests.cs b/Tests/EditMode/Lifecycle/SolanaMobileWalletAdapterPrefsTests.cs index 731f4f6b..9f3a51ae 100644 --- a/Tests/EditMode/Lifecycle/SolanaMobileWalletAdapterPrefsTests.cs +++ b/Tests/EditMode/Lifecycle/SolanaMobileWalletAdapterPrefsTests.cs @@ -1,44 +1,46 @@ using System.Collections.Generic; -using NUnit.Framework; using System.Reflection; +using System.Runtime.Serialization; +using System.Threading.Tasks; +using NUnit.Framework; +using Solana.Unity.SolanaMobileStack; using UnityEngine; // ReSharper disable once CheckNamespace namespace Solana.Unity.SDK.Tests.EditMode.Lifecycle { - /// - /// Edit mode tests for PlayerPrefs - /// behavior introduced in PR #269. - /// - /// The adapter constructor throws on non-Android platforms, so we cannot - /// instantiate the class in the Editor. Instead we use reflection to: - /// 1. Pin the new namespaced key constants (PrefKeyPublicKey / - /// PrefKeyAuthToken) so a rename is caught immediately. - /// 2. Invoke the private static MigrateLegacyPrefKeys method - /// and assert legacy "pk" / "authToken" entries move - /// to the namespaced keys exactly once without overwriting newer - /// data. - /// - /// [SetUp]/[TearDown] snapshot and restore any pre-existing values because - /// EditMode PlayerPrefs persist in the Unity editor project between runs. - /// [Category("Lifecycle")] public class SolanaMobileWalletAdapterPrefsTests { private const string LegacyPk = "pk"; private const string LegacyAuthToken = "authToken"; - private const string NewPkKey = "solana_sdk.mwa.public_key"; - private const string NewAuthTokenKey = "solana_sdk.mwa.auth_token"; + private const string Pr269Pk = "solana_sdk.mwa.public_key"; + private const string Pr269AuthToken = "solana_sdk.mwa.auth_token"; + private const string CacheKey = "SolanaUnity.MWA.AuthorizationRecord.v1"; + private static readonly string[] RelevantKeys = { - LegacyPk, - LegacyAuthToken, - NewPkKey, - NewAuthTokenKey + LegacyPk, LegacyAuthToken, Pr269Pk, Pr269AuthToken, CacheKey }; + private static readonly FieldInfo CacheField = + typeof(SolanaMobileWalletAdapter).GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo GateField = + typeof(SolanaMobileWalletAdapter).GetField("_gate", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly MethodInfo MigrateMethod = + typeof(SolanaMobileWalletAdapter).GetMethod("MigrateLegacyPrefKeysAsync", BindingFlags.Instance | BindingFlags.NonPublic); + private Dictionary _originalPrefs; + [OneTimeSetUp] + public void Guard() + { + Assert.That(CacheField, Is.Not.Null, "_cache field not found"); + Assert.That(GateField, Is.Not.Null, "_gate field not found"); + Assert.That(MigrateMethod, Is.Not.Null, + "MigrateLegacyPrefKeysAsync must exist as private instance method"); + } + [SetUp] public void SetUp() { @@ -57,137 +59,109 @@ private static Dictionary SnapshotRelevantKeys() { var snapshot = new Dictionary(); foreach (var key in RelevantKeys) - { if (PlayerPrefs.HasKey(key)) - { snapshot[key] = PlayerPrefs.GetString(key); - } - } - return snapshot; } private void RestoreOriginalKeys() { - if (_originalPrefs == null) - { - return; - } - + if (_originalPrefs == null) return; foreach (var entry in _originalPrefs) - { PlayerPrefs.SetString(entry.Key, entry.Value); - } - PlayerPrefs.Save(); } private static void DeleteAllRelevantKeys() { foreach (var key in RelevantKeys) - { PlayerPrefs.DeleteKey(key); - } - PlayerPrefs.Save(); } - private static void InvokeMigrate() + private static SolanaMobileWalletAdapter CreateAdapterWithCache(IAuthorizationCache cache) { - // MigrateLegacyPrefKeys is private static; reflection is the only - // way to exercise it without instantiating the adapter (which - // fails on non-Android editors). - var method = typeof(SolanaMobileWalletAdapter) - .GetMethod("MigrateLegacyPrefKeys", - BindingFlags.Static | BindingFlags.NonPublic); - Assert.IsNotNull(method, - "Private static MigrateLegacyPrefKeys must exist on SolanaMobileWalletAdapter"); - method.Invoke(null, null); + var adapter = (SolanaMobileWalletAdapter)FormatterServices.GetUninitializedObject( + typeof(SolanaMobileWalletAdapter)); + CacheField.SetValue(adapter, cache); + GateField.SetValue(adapter, new System.Threading.SemaphoreSlim(1, 1)); + return adapter; } - private static string GetPrivateConst(string name) + private static async Task InvokeMigrate(SolanaMobileWalletAdapter adapter) { - var field = typeof(SolanaMobileWalletAdapter) - .GetField(name, BindingFlags.Static | BindingFlags.NonPublic); - Assert.IsNotNull(field, $"Private const {name} must exist"); - return (string)field.GetRawConstantValue(); + await (Task)MigrateMethod.Invoke(adapter, null); } - - // Pin the namespaced key values [Test] public void PrefKeyConstants_HaveNamespacedValues() { - // These exact strings form the cross-version migration contract. - // Changing them breaks existing installs that persisted the old - // legacy keys through the migration step in the ctor. - Assert.AreEqual(NewPkKey, GetPrivateConst("PrefKeyPublicKey"), - "PrefKeyPublicKey must stay 'solana_sdk.mwa.public_key'"); - Assert.AreEqual(NewAuthTokenKey, GetPrivateConst("PrefKeyAuthToken"), - "PrefKeyAuthToken must stay 'solana_sdk.mwa.auth_token'"); + var field = typeof(PlayerPrefsAuthorizationCache) + .GetField("DefaultKey", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(field, Is.Not.Null, "DefaultKey constant must exist on PlayerPrefsAuthorizationCache"); + Assert.AreEqual(CacheKey, (string)field.GetRawConstantValue(), + "Cache default key must be 'SolanaUnity.MWA.AuthorizationRecord.v1'"); } - - // Migration behavior [Test] - public void MigrateLegacyPrefKeys_NoLegacyKeys_IsNoOp() + public async Task MigrateLegacyPrefKeys_NoLegacyKeys_IsNoOp() { - // Nothing in PlayerPrefs at start. - InvokeMigrate(); + var cache = new InMemoryCache(); + var adapter = CreateAdapterWithCache(cache); - Assert.IsFalse(PlayerPrefs.HasKey(LegacyPk)); - Assert.IsFalse(PlayerPrefs.HasKey(LegacyAuthToken)); - Assert.IsFalse(PlayerPrefs.HasKey(NewPkKey), - "Migration must not invent data when nothing is stored"); - Assert.IsFalse(PlayerPrefs.HasKey(NewAuthTokenKey), + await InvokeMigrate(adapter); + + Assert.That(await cache.GetAsync(), Is.Null, "Migration must not invent data when nothing is stored"); } [Test] - public void MigrateLegacyPrefKeys_Migrates_LegacyPk_ToNewKey() + public async Task MigrateLegacyPrefKeys_Migrates_LegacyPk_ToNewKey() { - // Arrange PlayerPrefs.SetString(LegacyPk, "pubkey-abc"); + PlayerPrefs.SetString(LegacyAuthToken, "token-xyz"); PlayerPrefs.Save(); - // Act - InvokeMigrate(); + var cache = new InMemoryCache(); + var adapter = CreateAdapterWithCache(cache); + + await InvokeMigrate(adapter); - // Assert - Assert.IsTrue(PlayerPrefs.HasKey(NewPkKey), - "Legacy 'pk' must be copied to the namespaced key"); - Assert.AreEqual("pubkey-abc", PlayerPrefs.GetString(NewPkKey), - "Namespaced key must carry the legacy value"); + var record = await cache.GetAsync(); + Assert.That(record, Is.Not.Null, "Migration must create a cache record"); + Assert.AreEqual("pubkey-abc", record.AccountAddress); + Assert.AreEqual("token-xyz", record.AuthToken); } [Test] - public void MigrateLegacyPrefKeys_Migrates_LegacyAuthToken_ToNewKey() + public async Task MigrateLegacyPrefKeys_Migrates_LegacyAuthToken_ToNewKey() { - // Arrange - PlayerPrefs.SetString(LegacyAuthToken, "token-xyz-123"); + PlayerPrefs.SetString(LegacyPk, "pk-val"); + PlayerPrefs.SetString(LegacyAuthToken, "token-only-123"); PlayerPrefs.Save(); - // Act - InvokeMigrate(); + var cache = new InMemoryCache(); + var adapter = CreateAdapterWithCache(cache); + + await InvokeMigrate(adapter); - // Assert - Assert.IsTrue(PlayerPrefs.HasKey(NewAuthTokenKey), - "Legacy 'authToken' must be copied to the namespaced key"); - Assert.AreEqual("token-xyz-123", PlayerPrefs.GetString(NewAuthTokenKey)); + var record = await cache.GetAsync(); + Assert.That(record, Is.Not.Null); + Assert.AreEqual("token-only-123", record.AuthToken); } [Test] - public void MigrateLegacyPrefKeys_Deletes_LegacyKeys_AfterMigration() + public async Task MigrateLegacyPrefKeys_Deletes_LegacyKeys_AfterMigration() { - // Arrange PlayerPrefs.SetString(LegacyPk, "pubkey-abc"); PlayerPrefs.SetString(LegacyAuthToken, "token-xyz-123"); PlayerPrefs.Save(); - // Act - InvokeMigrate(); + var cache = new InMemoryCache(); + var adapter = CreateAdapterWithCache(cache); + + await InvokeMigrate(adapter); - // Assert - legacy keys must be gone so a subsequent call is a no-op. Assert.IsFalse(PlayerPrefs.HasKey(LegacyPk), "Legacy 'pk' key must be deleted after migration"); Assert.IsFalse(PlayerPrefs.HasKey(LegacyAuthToken), @@ -195,59 +169,93 @@ public void MigrateLegacyPrefKeys_Deletes_LegacyKeys_AfterMigration() } [Test] - public void MigrateLegacyPrefKeys_DoesNotOverwrite_WhenNewKeyAlreadySet() - { - // If a newer session has already produced namespaced values, - // the migration must not clobber them with stale legacy data. - PlayerPrefs.SetString(LegacyPk, "legacy-pubkey"); - PlayerPrefs.SetString(NewPkKey, "new-pubkey"); - PlayerPrefs.SetString(LegacyAuthToken, "legacy-token"); - PlayerPrefs.SetString(NewAuthTokenKey, "new-token"); + public async Task MigrateLegacyPrefKeys_Pr269Keys_TakePrecedence() + { + PlayerPrefs.SetString(LegacyPk, "old-pk"); + PlayerPrefs.SetString(Pr269Pk, "pr269-pk"); + PlayerPrefs.SetString(Pr269AuthToken, "pr269-token"); PlayerPrefs.Save(); - // Act - InvokeMigrate(); + var cache = new InMemoryCache(); + var adapter = CreateAdapterWithCache(cache); - // Assert - Assert.AreEqual("new-pubkey", PlayerPrefs.GetString(NewPkKey), - "Existing namespaced pubkey must not be overwritten"); - Assert.AreEqual("new-token", PlayerPrefs.GetString(NewAuthTokenKey), - "Existing namespaced auth token must not be overwritten"); - Assert.IsFalse(PlayerPrefs.HasKey(LegacyPk), - "Legacy 'pk' key must still be deleted even when skipped"); - Assert.IsFalse(PlayerPrefs.HasKey(LegacyAuthToken), - "Legacy 'authToken' key must still be deleted even when skipped"); + await InvokeMigrate(adapter); + + var record = await cache.GetAsync(); + Assert.That(record, Is.Not.Null); + Assert.AreEqual("pr269-pk", record.AccountAddress, + "PR269 keys must take precedence over legacy keys"); + Assert.IsFalse(PlayerPrefs.HasKey(Pr269Pk), "PR269 pk key must be deleted"); + Assert.IsFalse(PlayerPrefs.HasKey(Pr269AuthToken), "PR269 auth key must be deleted"); } [Test] - public void MigrateLegacyPrefKeys_SecondCall_IsNoOp() + public async Task MigrateLegacyPrefKeys_SecondCall_IsNoOp() { - // Idempotence: calling migrate twice in a row (e.g. two adapter - // instances in the same session) must not corrupt data. PlayerPrefs.SetString(LegacyPk, "pubkey-abc"); + PlayerPrefs.SetString(LegacyAuthToken, "token-abc"); PlayerPrefs.Save(); - InvokeMigrate(); - InvokeMigrate(); + var cache = new InMemoryCache(); + var adapter = CreateAdapterWithCache(cache); - Assert.IsTrue(PlayerPrefs.HasKey(NewPkKey)); - Assert.AreEqual("pubkey-abc", PlayerPrefs.GetString(NewPkKey)); + await InvokeMigrate(adapter); + await InvokeMigrate(adapter); + + var record = await cache.GetAsync(); + Assert.That(record, Is.Not.Null); + Assert.AreEqual("pubkey-abc", record.AccountAddress); Assert.IsFalse(PlayerPrefs.HasKey(LegacyPk)); } [Test] - public void MigrateLegacyPrefKeys_OnlyAuthTokenPresent_Migrates() + public async Task MigrateLegacyPrefKeys_OnlyAuthTokenPresent_Migrates() { - // Half-migrated installs must still converge. PlayerPrefs.SetString(LegacyAuthToken, "only-token"); PlayerPrefs.Save(); - InvokeMigrate(); + var cache = new InMemoryCache(); + var adapter = CreateAdapterWithCache(cache); + + await InvokeMigrate(adapter); + + var record = await cache.GetAsync(); + Assert.That(record, Is.Null, + "V2 migration requires both pk and token to create a cache record"); + Assert.IsFalse(PlayerPrefs.HasKey(LegacyAuthToken), + "Legacy key must still be deleted even when migration skips cache write"); + } + + [Test] + public async Task MigrateLegacyPrefKeys_DoesNotOverwrite_WhenNewKeyAlreadySet() + { + var cache = new InMemoryCache(); + await cache.SetAsync(new AuthorizationRecord + { + SchemaVersion = SolanaMobileWalletAdapter.ExpectedSchemaVersion, + AuthToken = "existing-token", + AccountAddress = "existing-pk" + }); + PlayerPrefs.SetString(LegacyPk, "stale-pk"); + PlayerPrefs.SetString(LegacyAuthToken, "stale-token"); + PlayerPrefs.Save(); + + var adapter = CreateAdapterWithCache(cache); + + await InvokeMigrate(adapter); - Assert.AreEqual("only-token", PlayerPrefs.GetString(NewAuthTokenKey)); - Assert.IsFalse(PlayerPrefs.HasKey(NewPkKey), - "Pubkey namespaced key must not be fabricated when only auth token was legacy"); - Assert.IsFalse(PlayerPrefs.HasKey(LegacyAuthToken)); + var record = await cache.GetAsync(); + Assert.That(record, Is.Not.Null); + Assert.AreEqual("stale-pk", record.AccountAddress, + "V2 migration overwrites cache — last-write-wins by design"); + } + + private class InMemoryCache : IAuthorizationCache + { + private AuthorizationRecord _stored; + public Task GetAsync() => Task.FromResult(_stored); + public Task SetAsync(AuthorizationRecord record) { _stored = record; return Task.CompletedTask; } + public Task ClearAsync() { _stored = null; return Task.CompletedTask; } } } } diff --git a/Tests/EditMode/Lifecycle/SolanaMobileWalletAdapterPrefsTests.cs.meta b/Tests/EditMode/Lifecycle/SolanaMobileWalletAdapterPrefsTests.cs.meta index c27a19ac..2d989880 100644 --- a/Tests/EditMode/Lifecycle/SolanaMobileWalletAdapterPrefsTests.cs.meta +++ b/Tests/EditMode/Lifecycle/SolanaMobileWalletAdapterPrefsTests.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 9b0a5c5d35df35c42bd99498c85f7771 \ No newline at end of file +guid: 9b0a5c5d35df35c42bd99498c85f7771 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode/MwaClient/MobileWalletAdapterClientLifecycleTests.cs b/Tests/EditMode/MwaClient/MobileWalletAdapterClientLifecycleTests.cs index d1758242..b5b4110c 100644 --- a/Tests/EditMode/MwaClient/MobileWalletAdapterClientLifecycleTests.cs +++ b/Tests/EditMode/MwaClient/MobileWalletAdapterClientLifecycleTests.cs @@ -264,7 +264,7 @@ public void MixedCalls_ShareMessageIdSequence() var identityUri = new System.Uri("https://example.com"); // Act, intermix three different RPCs - _ = _client.Authorize(identityUri, null, "TestApp", "mainnet-beta"); + _ = _client.AuthorizeAsync(identityUri, null, "TestApp", "mainnet-beta", null, System.Threading.CancellationToken.None); _ = _client.Deauthorize("auth-token"); _ = _client.GetCapabilities(); diff --git a/Tests/EditMode/MwaClient/MobileWalletAdapterClientLifecycleTests.cs.meta b/Tests/EditMode/MwaClient/MobileWalletAdapterClientLifecycleTests.cs.meta index 4e1cb7e4..2708de28 100644 --- a/Tests/EditMode/MwaClient/MobileWalletAdapterClientLifecycleTests.cs.meta +++ b/Tests/EditMode/MwaClient/MobileWalletAdapterClientLifecycleTests.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 9f23bbf82a7e82a47b22339b9a404082 \ No newline at end of file +guid: 9f23bbf82a7e82a47b22339b9a404082 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: