Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Runtime/codebase/SolanaMobileStack/MwaAuthCache.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions Runtime/codebase/SolanaMobileStack/MwaAuthCache/IMwaAuthCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Threading.Tasks;

// ReSharper disable once CheckNamespace
namespace Solana.Unity.SDK
{
/// <summary>
/// Where the Mobile Wallet Adapter auth token gets stored.
///
/// By default the SDK uses <see cref="PlayerPrefsAuthCache"/>, which
/// writes the token into <see cref="UnityEngine.PlayerPrefs"/>. That is
/// fine for most games but PlayerPrefs is plaintext on Android, so games
/// that hold real on-chain value should plug in a custom implementation
/// backed by Android Keystore / EncryptedSharedPreferences (or iOS
/// Keychain in the future).
///
/// We only persist the auth token here. The public key is not a secret,
/// so it stays in PlayerPrefs and is out of scope for this interface.
///
/// Implementations should keep all three methods cheap. <see cref="Clear"/>
/// is awaited synchronously from <c>Logout()</c>, so do not block on UI
/// or network calls inside it.
/// </summary>
public interface IMwaAuthCache
{
/// <summary>
/// Returns the stored auth token, or <c>null</c> if nothing is stored
/// yet. Return <c>null</c> (not an empty string) on a fresh install,
/// otherwise the SDK cannot tell "never logged in" from "logged in
/// with empty token".
/// </summary>
Task<string> Get();

/// <summary>
/// Persists <paramref name="authToken"/>. Treat <c>null</c> or empty
/// input as a no-op so a stale callsite cannot wipe a valid session
/// by accident.
/// </summary>
Task Set(string authToken);

/// <summary>
/// Removes the stored token. Must be idempotent so calling it twice
/// (e.g. <c>Logout()</c> followed by <c>DisconnectWallet()</c>) does
/// not throw.
/// </summary>
Task Clear();
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Scripting;

// ReSharper disable once CheckNamespace
namespace Solana.Unity.SDK
{
/// <summary>
/// Default <see cref="IMwaAuthCache"/>, backed by
/// <see cref="PlayerPrefs"/>. Uses the same storage key
/// (<see cref="DefaultKey"/>) the SDK has been writing since PR #269,
/// so anyone who already has a cached session keeps it after upgrading.
/// No migration step needed.
///
/// PlayerPrefs is plaintext on Android. Fine for hobby games and demos,
/// not fine for games that hold real assets - swap this out for a
/// custom <see cref="IMwaAuthCache"/> backed by Android Keystore or
/// EncryptedSharedPreferences in that case.
///
/// The optional <paramref name="scope"/> on the second constructor lets
/// a single app keep independent sessions per wallet identity, e.g.
/// <c>new PlayerPrefsAuthCache("phantom")</c> writes to
/// <c>solana_sdk.mwa.auth_token.phantom</c>. Default (no scope) is
/// backward compatible with existing installs.
/// </summary>
[Preserve]
public class PlayerPrefsAuthCache : IMwaAuthCache
{
/// <summary>
/// Storage key used when no scope is supplied. Public so other
/// <see cref="IMwaAuthCache"/> implementations can reference the
/// same key for a one-time copy-up migration if they want to inherit
/// the existing PlayerPrefs session on first run.
/// </summary>
public const string DefaultKey = "solana_sdk.mwa.auth_token";

private readonly string _key;

/// <summary>
/// Creates the default unscoped cache. Equivalent to
/// <c>new PlayerPrefsAuthCache(null)</c>.
/// </summary>
public PlayerPrefsAuthCache() : this(null) { }

/// <summary>
/// Creates a cache that namespaces its storage under
/// <c>{DefaultKey}.{scope}</c>. If <paramref name="scope"/> is null
/// or empty the default key is used, which keeps backward
/// compatibility with installs created before scoping existed.
/// </summary>
public PlayerPrefsAuthCache(string scope)
{
_key = string.IsNullOrEmpty(scope)
? DefaultKey
: DefaultKey + "." + scope;
}

/// <inheritdoc />
public Task<string> Get()
{
string value = PlayerPrefs.GetString(_key, null);
return Task.FromResult(string.IsNullOrEmpty(value) ? null : value);
}

/// <inheritdoc />
public Task Set(string authToken)
{
if (string.IsNullOrEmpty(authToken))
{
return Task.CompletedTask;
}
PlayerPrefs.SetString(_key, authToken);
PlayerPrefs.Save();
return Task.CompletedTask;
}

/// <inheritdoc />
public Task Clear()
{
PlayerPrefs.DeleteKey(_key);
PlayerPrefs.Save();
return Task.CompletedTask;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 32 additions & 16 deletions Runtime/codebase/SolanaMobileStack/SolanaMobileWalletAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ public class SolanaMobileWalletAdapterOptions
public class SolanaMobileWalletAdapter : WalletBase
{
private const string PrefKeyPublicKey = "solana_sdk.mwa.public_key";
private const string PrefKeyAuthToken = "solana_sdk.mwa.auth_token";
// Single source of truth lives in PlayerPrefsAuthCache.DefaultKey.
// Kept here as a private alias because the legacy key migration
// below still touches PlayerPrefs directly. Live reads and writes
// for the auth token go through _authCache (see IMwaAuthCache).
private const string PrefKeyAuthToken = PlayerPrefsAuthCache.DefaultKey;

private readonly SolanaMobileWalletAdapterOptions _walletOptions;

Expand All @@ -35,6 +39,7 @@ public class SolanaMobileWalletAdapter : WalletBase
private TaskCompletionSource<Account> _loginTaskCompletionSource;
private TaskCompletionSource<Transaction> _signedTransactionTaskCompletionSource;
private readonly WalletBase _internalWallet;
private readonly IMwaAuthCache _authCache;
private string _authToken;

public event Action OnWalletDisconnected;
Expand All @@ -45,14 +50,16 @@ public SolanaMobileWalletAdapter(
RpcCluster rpcCluster = RpcCluster.DevNet,
string customRpcUri = null,
string customStreamingRpcUri = null,
bool autoConnectOnStartup = false) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup
bool autoConnectOnStartup = false,
IMwaAuthCache authCache = null) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup
)
{
_walletOptions = solanaWalletOptions;
if (Application.platform != RuntimePlatform.Android)
{
throw new Exception("SolanaMobileWalletAdapter can only be used on Android");
}
_authCache = authCache ?? new PlayerPrefsAuthCache();
MigrateLegacyPrefKeys();
}

Expand Down Expand Up @@ -80,7 +87,7 @@ protected override async Task<Account> _Login(string password = null)
if (_walletOptions.keepConnectionAlive)
{
string pk = PlayerPrefs.GetString(PrefKeyPublicKey, null);
string authToken = PlayerPrefs.GetString(PrefKeyAuthToken, null);
string authToken = await _authCache.Get();
if (!pk.IsNullOrEmpty() && !authToken.IsNullOrEmpty())
{
string reauthPublicKey = null;
Expand Down Expand Up @@ -114,16 +121,15 @@ protected override async Task<Account> _Login(string password = null)
}
else
{
PlayerPrefs.SetString(PrefKeyAuthToken, _authToken);
PlayerPrefs.Save();
await _authCache.Set(_authToken);
var resolvedKey = !string.IsNullOrEmpty(reauthPublicKey) ? reauthPublicKey : pk;
return new Account(string.Empty, new PublicKey(resolvedKey));
}
}
// Reauthorize failed or returned empty token - clear cached credentials
PlayerPrefs.DeleteKey(PrefKeyPublicKey);
PlayerPrefs.DeleteKey(PrefKeyAuthToken);
PlayerPrefs.Save();
await _authCache.Clear();
}
else if (!pk.IsNullOrEmpty())
{
Expand Down Expand Up @@ -162,8 +168,8 @@ protected override async Task<Account> _Login(string password = null)
if (_walletOptions.keepConnectionAlive)
{
PlayerPrefs.SetString(PrefKeyPublicKey, publicKey.ToString());
PlayerPrefs.SetString(PrefKeyAuthToken, _authToken);
PlayerPrefs.Save();
await _authCache.Set(_authToken);
}
}
return new Account(string.Empty, publicKey);
Expand All @@ -179,7 +185,7 @@ protected override async Task<Transaction> _SignTransaction(Transaction transact
protected override async Task<Transaction[]> _SignAllTransactions(Transaction[] transactions)
{
if (_authToken.IsNullOrEmpty() && _walletOptions.keepConnectionAlive)
_authToken = PlayerPrefs.GetString(PrefKeyAuthToken, null);
_authToken = await _authCache.Get();

var cluster = RPCNameMap[(int)RpcCluster];
SignedResult res = null;
Expand Down Expand Up @@ -229,28 +235,39 @@ protected override async Task<Transaction[]> _SignAllTransactions(Transaction[]
_authToken = authorization.AuthToken;
if (_walletOptions.keepConnectionAlive)
{
PlayerPrefs.SetString(PrefKeyAuthToken, _authToken);
PlayerPrefs.Save();
await _authCache.Set(_authToken);
}
}
return res.SignedPayloads.Select(transaction => Transaction.Deserialize(transaction)).ToArray();
}


/// <summary>
/// Clears the in-memory token, the cached public key in PlayerPrefs,
/// and the auth token stored in <see cref="IMwaAuthCache"/>. Does
/// NOT call <c>deauthorize</c> on the wallet side. Use
/// <see cref="DisconnectWallet"/> when the wallet-side session also
/// needs to be revoked.
///
/// Stays synchronous to keep the <see cref="WalletBase"/> override
/// signature stable. The cache <see cref="IMwaAuthCache.Clear"/>
/// call is awaited synchronously, so custom cache impls must not
/// block on UI or network here.
/// </summary>
public override void Logout()
{
base.Logout();
PlayerPrefs.DeleteKey(PrefKeyPublicKey);
_authToken = null;
PlayerPrefs.DeleteKey(PrefKeyAuthToken);
PlayerPrefs.Save();
_authToken = null;
_authCache.Clear().GetAwaiter().GetResult();
}
Comment thread
JoshhSandhu marked this conversation as resolved.

public async Task DisconnectWallet()
{
string authToken = _authToken;
if (authToken.IsNullOrEmpty())
authToken = PlayerPrefs.GetString(PrefKeyAuthToken, null);
authToken = await _authCache.Get();

if (!authToken.IsNullOrEmpty())
{
Expand Down Expand Up @@ -333,7 +350,7 @@ public async Task<CapabilitiesResult> GetCapabilities()
public override async Task<byte[]> SignMessage(byte[] message)
{
if (_authToken.IsNullOrEmpty() && _walletOptions.keepConnectionAlive)
_authToken = PlayerPrefs.GetString(PrefKeyAuthToken, null);
_authToken = await _authCache.Get();

string cachedPk = Account?.PublicKey?.ToString()
?? PlayerPrefs.GetString(PrefKeyPublicKey, null);
Expand Down Expand Up @@ -391,8 +408,7 @@ public override async Task<byte[]> SignMessage(byte[] message)
_authToken = authorization.AuthToken;
if (_walletOptions.keepConnectionAlive)
{
PlayerPrefs.SetString(PrefKeyAuthToken, _authToken);
PlayerPrefs.Save();
await _authCache.Set(_authToken);
}
}
return signedMessages.SignedPayloadsBytes[0];
Expand Down
17 changes: 15 additions & 2 deletions Runtime/codebase/SolanaWalletAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,24 @@ public class SolanaWalletAdapter: WalletBase
public event Action OnWalletDisconnected;
public event Action OnWalletReconnected;

public SolanaWalletAdapter(SolanaWalletAdapterOptions options, RpcCluster rpcCluster = RpcCluster.DevNet, string customRpcUri = null, string customStreamingRpcUri = null, bool autoConnectOnStartup = false) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup)
/// <summary>
/// Creates a cross-platform wallet adapter.
/// </summary>
/// <param name="authCache">
/// Optional Mobile Wallet Adapter auth-token cache. Forwarded to the
/// Android <see cref="SolanaMobileWalletAdapter"/>. Ignored on WebGL
/// and iOS since those platforms do not use MWA bearer tokens. When
/// left <c>null</c> the SDK falls back to
/// <see cref="PlayerPrefsAuthCache"/>. Pass a custom
/// <see cref="IMwaAuthCache"/> (e.g. Android Keystore /
/// EncryptedSharedPreferences) for production builds that need
/// encryption at rest.
/// </param>
public SolanaWalletAdapter(SolanaWalletAdapterOptions options, RpcCluster rpcCluster = RpcCluster.DevNet, string customRpcUri = null, string customStreamingRpcUri = null, bool autoConnectOnStartup = false, IMwaAuthCache authCache = null) : base(rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup)
{
#if UNITY_ANDROID
#pragma warning disable CS0618
_internalWallet = new SolanaMobileWalletAdapter(options.solanaMobileWalletAdapterOptions, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup);
_internalWallet = new SolanaMobileWalletAdapter(options.solanaMobileWalletAdapterOptions, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup, authCache);
#elif UNITY_WEBGL
#pragma warning disable CS0618
_internalWallet = new SolanaWalletAdapterWebGL(options.solanaWalletAdapterWebGLOptions, rpcCluster, customRpcUri, customStreamingRpcUri, autoConnectOnStartup);
Expand Down
8 changes: 8 additions & 0 deletions Tests/EditMode/MwaAuthCache.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading