Skip to content

Comments

Rewrite local association scenario for cleaner error handling and cle…#260

Open
Kuldotha wants to merge 2 commits intomainfrom
fix/wallet-adapter
Open

Rewrite local association scenario for cleaner error handling and cle…#260
Kuldotha wants to merge 2 commits intomainfrom
fix/wallet-adapter

Conversation

@Kuldotha
Copy link
Contributor

@Kuldotha Kuldotha commented Jan 31, 2026

Problem

Issues connecting to and disconnecting from wallet apps on android

Solution

Rewrote LocalAssociationScenario.cs

Summary by CodeRabbit

  • New Features

    • Added a helper to create configured session wallets
    • Compile-time option to default to MainNet or DevNet
  • Improvements

    • Cancellation support for mobile wallet flows
    • Deterministic disposal and stronger cleanup of wallet connections
    • More robust async execution and timeout handling for wallet interactions

@coderabbitai
Copy link

coderabbitai bot commented Jan 31, 2026

Walkthrough

LocalAssociationScenario was rewritten to implement IDisposable and to provide an asynchronous StartAndExecute flow with cancellation, WebSocket/backoff/key-exchange handling and cleanup. Three call sites now use using for LocalAssociationScenario. SessionWallet gained a new factory method. Web3 rpcCluster defaulting is now conditional on a MAINNET compile symbol.

Changes

Cohort / File(s) Summary
LocalAssociationScenario (async, disposable)
Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs
Class now implements IDisposable; constructor signature simplified; StartAndExecute converted to async with CancellationToken, overall timeout, backoff-based WebSocket connect, activity start for association, key-exchange wait, per-action request/response via TaskCompletionSource, message encryption/decryption handling, explicit cleanup (CleanupAsync) and disposal logic; new helper methods and state fields added.
Call-site disposal updates
Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs
Replaced direct new LocalAssociationScenario instantiations with using var localAssociationScenario = new LocalAssociationScenario() in _Login, _SignAllTransactions, and SignMessage to ensure deterministic disposal.
Session wallet factory
Runtime/codebase/SessionWallet.cs
Added public static SessionWallet GetSessionWallet(string publicKey, string privateKey, PublicKey targetProgram) that initializes an external wallet/account, sets TargetProgram, EncryptedKeystoreKey, SessionTokenPDA, and Account, and returns a configured SessionWallet.
Conditional RPC cluster default
Runtime/codebase/Web3.cs
Changed rpcCluster field to be conditionally initialized based on #if MAINNET (MainNet) vs #else (DevNet) and annotated with [NonSerialized].
Project manifest
*.csproj
Small project file edits (lines changed: +3/-3).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 3
❌ Failed checks (2 warnings, 1 inconclusive)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description is largely incomplete, missing critical sections from the template including Status/Type/Issue table, Before & After Screenshots, Other changes, and Deploy Notes. Complete the PR description by filling in the status table, providing before/after context, documenting all changes across files (SessionWallet, Web3.cs), and noting any deployment considerations.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is partially related to the changeset, referring to a real aspect (rewriting LocalAssociationScenario) but appears truncated and doesn't fully convey the scope of changes across multiple files. Complete the truncated title and ensure it clearly reflects the primary change; consider whether this covers all files affected or focus on the main component being refactored.
✅ Passed checks (1 passed)
Check name Status Explanation
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/wallet-adapter

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs`:
- Around line 176-184: ConnectWithBackoffAsync can hang because it awaits
_wsConnected.Task but OnWsError only logs; update the WebSocket error handler
(OnWsError) to signal the waiting task (use _wsConnected.TrySetException with
the exception or _wsConnected.TrySetResult(false)) so ConnectWithBackoffAsync's
await unblocks and the retry/backoff logic proceeds; also ensure the close
handler (OnWsClose/OnClose) uses _wsConnected.TrySetResult(true/false)
consistently and guard against double-sets by using TrySet... methods when
touching _wsConnected to avoid race conditions.
- Around line 53-57: You created a linked CancellationTokenSource with
CancelAfter(_overallTimeout) but still assign and use the original ct, so the
overall timeout never cancels ConnectWithBackoffAsync or the action loop; change
the assignment to use the linked CTS token (set _cancellationToken = cts.Token)
and ensure all cancellation checks and ThrowIfCancellationRequested() calls use
_cancellationToken (and not the original ct); apply the same fix to the other
linked-CTS block (the one around lines with CreateLinkedTokenSource/CancelAfter
at the second site) so the overall timeout is honored across
ConnectWithBackoffAsync and the action loop.
- Around line 138-143: The Debug.Log in StartActivityForAssociation is emitting
sensitive association tokens (and there are similar logs around lines 250-257) —
remove or redact raw tokens/decrypted payloads and instead log only
non-sensitive metadata (e.g., port, token length, a stable prefix/suffix, or a
short hash). Update StartActivityForAssociation and the other logging sites to
stop printing associationToken or decrypted payloads; if a trace is required,
compute and log a one-way hash (SHA-256) or mask all but 4 characters of the
token and include contextual text like "Launched intent for port {port},
token_hash={hash}" or "token_masked=xxxx". Ensure no plaintext tokens or
payloads are written to Debug.Log anywhere in the class.

Comment on lines +53 to +57
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_overallTimeout);

_cancellationToken = ct;
_tcs = new TaskCompletionSource<Response<object>>();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Overall timeout isn’t enforced across the connection/action flow.

You create a linked CTS with CancelAfter, but _cancellationToken and ThrowIfCancellationRequested use the original ct, so the 30s timeout won’t cancel ConnectWithBackoffAsync or the action loop when no external token is provided.

✅ Minimal fix to honor the overall timeout
-        _cancellationToken = ct;
+        _cancellationToken = cts.Token;
         _tcs = new TaskCompletionSource<Response<object>>();
@@
-                    ct.ThrowIfCancellationRequested();
+                    _cancellationToken.ThrowIfCancellationRequested();

Also applies to: 94-97

🤖 Prompt for AI Agents
In `@Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs` around lines
53 - 57, You created a linked CancellationTokenSource with
CancelAfter(_overallTimeout) but still assign and use the original ct, so the
overall timeout never cancels ConnectWithBackoffAsync or the action loop; change
the assignment to use the linked CTS token (set _cancellationToken = cts.Token)
and ensure all cancellation checks and ThrowIfCancellationRequested() calls use
_cancellationToken (and not the original ct); apply the same fix to the other
linked-CTS block (the one around lines with CreateLinkedTokenSource/CancelAfter
at the second site) so the overall timeout is honored across
ConnectWithBackoffAsync and the action loop.

Comment on lines +138 to 143
private void StartActivityForAssociation(string associationToken, int port)
{
var intent = LocalAssociationIntentCreator.CreateAssociationIntent(associationToken, port);
_currentActivity.Call("startActivityForResult", intent, 0);
_currentActivity.Call("runOnUiThread", new AndroidJavaRunnable(TryConnectWs));
_startAssociationTaskCompletionSource = new TaskCompletionSource<Response<object>>();
return _startAssociationTaskCompletionSource.Task;
Debug.Log($"[MWA] Launched intent for port {port}, token {associationToken}");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid logging association tokens and decrypted payloads.

These logs can expose sensitive wallet association data and signed payload contents in device logs. Consider redaction or logging only metadata.

🔒️ Safer logging
-        Debug.Log($"[MWA] Launched intent for port {port}, token {associationToken}");
+        Debug.Log($"[MWA] Launched intent for port {port}");
@@
-                Debug.Log($"[MWA] Received: {json}");
+                Debug.Log($"[MWA] Received response (len={json.Length})");

Also applies to: 250-257

🤖 Prompt for AI Agents
In `@Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs` around lines
138 - 143, The Debug.Log in StartActivityForAssociation is emitting sensitive
association tokens (and there are similar logs around lines 250-257) — remove or
redact raw tokens/decrypted payloads and instead log only non-sensitive metadata
(e.g., port, token length, a stable prefix/suffix, or a short hash). Update
StartActivityForAssociation and the other logging sites to stop printing
associationToken or decrypted payloads; if a trace is required, compute and log
a one-way hash (SHA-256) or mask all but 4 characters of the token and include
contextual text like "Launched intent for port {port}, token_hash={hash}" or
"token_masked=xxxx". Ensure no plaintext tokens or payloads are written to
Debug.Log anywhere in the class.

Comment on lines +176 to +184
var startTime = DateTime.UtcNow;
_wsConnected = new TaskCompletionSource<bool>();

attempt++;
Debug.Log($"[MWA] Connect attempt {attempt}, state: {_webSocket.State}");
_webSocket.Connect();

var success = await _wsConnected.Task;
Debug.Log($"[MWA] Connect attempt {attempt} result, state: {_webSocket.State}");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Connect loop can hang if OnError fires without OnClose.

ConnectWithBackoffAsync awaits _wsConnected.Task, but OnWsError only logs. If the socket errors without a close, the await never completes and the retry loop stalls.

🛠️ Signal connection failure on error
-    private static void OnWsError(string message)
+    private void OnWsError(string message)
     {
         Debug.Log($"[MWA] WS Error: {message}");
+        if (_isConnecting)
+            _wsConnected?.TrySetResult(false);
+        else
+            _tcs?.TrySetException(new Exception($"WS error: {message}"));
     }

Also applies to: 229-232

🤖 Prompt for AI Agents
In `@Runtime/codebase/SolanaMobileStack/LocalAssociationScenario.cs` around lines
176 - 184, ConnectWithBackoffAsync can hang because it awaits _wsConnected.Task
but OnWsError only logs; update the WebSocket error handler (OnWsError) to
signal the waiting task (use _wsConnected.TrySetException with the exception or
_wsConnected.TrySetResult(false)) so ConnectWithBackoffAsync's await unblocks
and the retry/backoff logic proceeds; also ensure the close handler
(OnWsClose/OnClose) uses _wsConnected.TrySetResult(true/false) consistently and
guard against double-sets by using TrySet... methods when touching _wsConnected
to avoid race conditions.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@Runtime/codebase/SessionWallet.cs`:
- Around line 69-85: GetSessionWallet(string publicKey, string privateKey,
PublicKey targetProgram) lacks the singleton guard and null checks: add the same
early return used in the other overloads (if (Instance != null) return
Instance;) before constructing a new SessionWallet, and validate Web3.Wallet and
its ActiveRpcClient (e.g., if (Web3.Wallet == null) throw/new error or handle;
if (_externalWallet.ActiveRpcClient == null) get node address another way or
throw) to avoid NullReferenceException; ensure you still set _externalWallet =
Web3.Wallet, create sessionAccount, compute SessionTokenPDA via
FindSessionToken, and populate SessionWallet fields only after these checks.

In `@Runtime/codebase/Web3.cs`:
- Around line 31-37: You removed Unity serialization for the RpcCluster field by
adding [NonSerialized] to rpcCluster, which prevents Inspector values from being
saved; restore Inspector configurability by removing [NonSerialized] from the
rpcCluster field (or, if you intend compile-time locking via the MAINNET symbol,
keep the field serialized but make it read-only in the Inspector instead), and
ensure the conditional defaulting using MAINNET still sets the initial value
(rpcCluster and RpcCluster) while preserving serialization or explicitly
document the breaking change if you must keep it non-serialized.

Comment on lines +69 to +85
public static SessionWallet GetSessionWallet(string publicKey, string privateKey, PublicKey targetProgram)
{
_externalWallet = Web3.Wallet;

var sessionAccount = new Account(privateKey, publicKey);

// TODO: ActiveRpcClient can be null, get node address some other way
var sessionWallet = new SessionWallet(_externalWallet.RpcCluster, _externalWallet.ActiveRpcClient.NodeAddress.ToString())
{
TargetProgram = targetProgram,
EncryptedKeystoreKey = $"{_externalWallet.Account.PublicKey}_SessionKeyStore",
SessionTokenPDA = FindSessionToken(targetProgram, sessionAccount, _externalWallet.Account),
Account = sessionAccount,
};

return sessionWallet;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing singleton guard — will throw if Instance is already set.

The other GetSessionWallet overloads (lines 97 and 129) check if (Instance != null) return Instance; before constructing a new SessionWallet. This overload skips that check, so calling it when a session wallet already exists will hit the throw new Exception("SessionWallet already exists") in the constructor (line 37).

Additionally, Web3.Wallet (line 71) and _externalWallet.ActiveRpcClient (line 76) can both be null, which would cause a NullReferenceException.

Proposed fix
 public static SessionWallet GetSessionWallet(string publicKey, string privateKey, PublicKey targetProgram)
 {
+    if (Instance != null) return Instance;
+
     _externalWallet = Web3.Wallet;
+    if (_externalWallet?.ActiveRpcClient == null)
+        throw new InvalidOperationException("External wallet or its RPC client is not initialized.");

     var sessionAccount = new Account(privateKey, publicKey); 
     
-    // TODO: ActiveRpcClient can be null, get node address some other way
     var sessionWallet = new SessionWallet(_externalWallet.RpcCluster, _externalWallet.ActiveRpcClient.NodeAddress.ToString())
🤖 Prompt for AI Agents
In `@Runtime/codebase/SessionWallet.cs` around lines 69 - 85,
GetSessionWallet(string publicKey, string privateKey, PublicKey targetProgram)
lacks the singleton guard and null checks: add the same early return used in the
other overloads (if (Instance != null) return Instance;) before constructing a
new SessionWallet, and validate Web3.Wallet and its ActiveRpcClient (e.g., if
(Web3.Wallet == null) throw/new error or handle; if
(_externalWallet.ActiveRpcClient == null) get node address another way or throw)
to avoid NullReferenceException; ensure you still set _externalWallet =
Web3.Wallet, create sessionAccount, compute SessionTokenPDA via
FindSessionToken, and populate SessionWallet fields only after these checks.

Comment on lines +31 to +37

#if MAINNET
[NonSerialized] public RpcCluster rpcCluster = RpcCluster.MainNet;
#else
[NonSerialized] public RpcCluster rpcCluster = RpcCluster.DevNet;
#endif

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

[NonSerialized] removes Unity Inspector configurability — likely a breaking change.

Adding [NonSerialized] to rpcCluster means Unity will no longer serialize this field, so any value previously set in the Inspector will be silently ignored. Users who relied on the Inspector to select their cluster will now always get the compile-time default with no visual feedback that the setting is gone.

If the intent is to lock the cluster at compile time via MAINNET, consider keeping the field serialized but read-only in the Inspector (e.g., show it disabled), or at minimum document this as a breaking change. If the [NonSerialized] was unintentional, remove it so Inspector-based configuration continues to work.

🤖 Prompt for AI Agents
In `@Runtime/codebase/Web3.cs` around lines 31 - 37, You removed Unity
serialization for the RpcCluster field by adding [NonSerialized] to rpcCluster,
which prevents Inspector values from being saved; restore Inspector
configurability by removing [NonSerialized] from the rpcCluster field (or, if
you intend compile-time locking via the MAINNET symbol, keep the field
serialized but make it read-only in the Inspector instead), and ensure the
conditional defaulting using MAINNET still sets the initial value (rpcCluster
and RpcCluster) while preserving serialization or explicitly document the
breaking change if you must keep it non-serialized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant