diff --git a/.github/workflows/webgl-package.yml b/.github/workflows/webgl-package.yml
index 670217c..5bae39e 100644
--- a/.github/workflows/webgl-package.yml
+++ b/.github/workflows/webgl-package.yml
@@ -1,10 +1,8 @@
-name: WebGL package
+name: Unity package
on:
pull_request:
- paths: ['WebGL~/**', '.github/workflows/webgl-package.yml']
push:
branches: [main]
- paths: ['WebGL~/**', '.github/workflows/webgl-package.yml']
permissions:
contents: read
jobs:
@@ -15,4 +13,4 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
- - run: node --test 'WebGL~/Tools/install.test.mjs'
+ - run: node --test 'Tools~/package.test.mjs'
diff --git a/Blockmaker.asmdef b/Blockmaker.asmdef
deleted file mode 100644
index 4425dc8..0000000
--- a/Blockmaker.asmdef
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "Blockmaker",
- "rootNamespace": "Blockmaker",
- "references": [
- "Reown.Sign",
- "Reown.Core",
- "Reown.Core.Common",
- "Reown.Core.Network",
- "Reown.Sign.Unity"
- ],
- "includePlatforms": [],
- "excludePlatforms": [],
- "allowUnsafeCode": false,
- "overrideReferences": false,
- "precompiledReferences": [],
- "autoReferenced": true,
- "defineConstraints": [],
- "versionDefines": [],
- "noEngineReferences": false
-}
diff --git a/WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker-txnlab-wallet.NOTICES.txt b/Browser~/blockmaker-txnlab-wallet.NOTICES.txt
similarity index 100%
rename from WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker-txnlab-wallet.NOTICES.txt
rename to Browser~/blockmaker-txnlab-wallet.NOTICES.txt
diff --git a/WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker-txnlab-wallet.d.mts b/Browser~/blockmaker-txnlab-wallet.d.mts
similarity index 100%
rename from WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker-txnlab-wallet.d.mts
rename to Browser~/blockmaker-txnlab-wallet.d.mts
diff --git a/WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker-txnlab-wallet.mjs b/Browser~/blockmaker-txnlab-wallet.mjs
similarity index 100%
rename from WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker-txnlab-wallet.mjs
rename to Browser~/blockmaker-txnlab-wallet.mjs
diff --git a/WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker-unity-webgl-wallet-host.mjs b/Browser~/blockmaker-unity-webgl-wallet-host.mjs
similarity index 100%
rename from WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker-unity-webgl-wallet-host.mjs
rename to Browser~/blockmaker-unity-webgl-wallet-host.mjs
diff --git a/WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker.js b/Browser~/blockmaker.js
similarity index 100%
rename from WebGL~/Assets/StreamingAssets/Blockmaker/blockmaker.js
rename to Browser~/blockmaker.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2949120..ebd0823 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## [2.0.0] - 2026-09-16
+
+- Make the current WebGL Pera/optional email SDK the root Unity Package Manager
+ package; remove the obsolete v1 runtime and its external dependencies.
+- Include verified browser assets automatically through Unity’s build API.
+- Restore a one-file installer using `PackageManager.Client.Add`, plus demo
+ scene setup and a fullscreen WebGL template. No Node.js install step.
+- Preserve the nine canonical wallet runtime files and their script/plugin
+ GUIDs. Document the major-version migration and pending attended wallet checks.
+
## WebGL preview — 2026-09-16
- Publish the current complete Pera/Lute and optional TxnLab/MetaMask Embedded
diff --git a/Core/AlgoSignTypes.cs b/Core/AlgoSignTypes.cs
deleted file mode 100644
index b9da531..0000000
--- a/Core/AlgoSignTypes.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System;
-using UnityEngine.Scripting;
-
-namespace Blockmaker
-{
-
- ///
- /// WalletConnect v2 JSON-RPC types for the Algorand algo_signTxn method.
- /// Used as List<List<AlgoSignTxnParam>> — the SDK serializes the list
- /// directly as the params field of the JSON-RPC request.
- ///
- [Preserve]
- public class AlgoSignTxnParam
- {
- [Preserve] public string txn { get; set; }
- [Preserve] public string message { get; set; }
- }
-
-}
\ No newline at end of file
diff --git a/Core/AlgoSignTypes.cs.meta b/Core/AlgoSignTypes.cs.meta
deleted file mode 100644
index 7a693e9..0000000
--- a/Core/AlgoSignTypes.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 69cb456d4b2fa40d68a5e46b5e0d6769
\ No newline at end of file
diff --git a/Core/BlockmakerAsyncExtensions.cs b/Core/BlockmakerAsyncExtensions.cs
deleted file mode 100644
index 20b30bf..0000000
--- a/Core/BlockmakerAsyncExtensions.cs
+++ /dev/null
@@ -1,142 +0,0 @@
-using System;
-using System.Threading.Tasks;
-
-namespace Blockmaker
-{
-
- ///
- /// Async/await overloads for BlockmakerAuth and BlockmakerClient.
- /// These wrap the callback-based APIs so game code can use modern C# patterns:
- ///
- /// var identity = await BlockmakerAuth.Instance.ConnectWalletAsync("Pera");
- /// var result = await BlockmakerClient.Instance.RunFlowAsync("myFlow");
- ///
- /// All methods marshal back to the Unity main thread via TaskCompletionSource.
- ///
- public static class BlockmakerAsyncExtensions
- {
- // ── BlockmakerAuth ────────────────────────────────────────────────────────
-
- /// Connect a wallet and return the identity, or throw on error.
- public static Task ConnectWalletAsync(this BlockmakerAuth auth, string provider)
- {
- var tcs = new TaskCompletionSource();
- auth.ConnectWallet(provider,
- identity => tcs.TrySetResult(identity),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
-
- /// Connect via Magic email and return the identity, or throw on error.
- public static Task ConnectMagicEmailAsync(this BlockmakerAuth auth, string email)
- {
- var tcs = new TaskCompletionSource();
- auth.ConnectMagicEmail(email,
- identity => tcs.TrySetResult(identity),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
-
- /// Connect an EVM wallet and return the identity, or throw on error.
- public static Task ConnectEvmAsync(this BlockmakerAuth auth)
- {
- var tcs = new TaskCompletionSource();
- auth.ConnectEvm(
- identity => tcs.TrySetResult(identity),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
-
- /// Verify the current session. Returns true if valid.
- public static Task VerifySessionAsync(this BlockmakerAuth auth)
- {
- var tcs = new TaskCompletionSource();
- auth.VerifySession(ok => tcs.TrySetResult(ok));
- return tcs.Task;
- }
-
- /// Request an email OTP code, or throw on error.
- public static Task RequestEmailOTPAsync(this BlockmakerAuth auth, string email)
- {
- var tcs = new TaskCompletionSource();
- auth.RequestEmailOTP(email,
- () => tcs.TrySetResult(true),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
-
- /// Verify an email OTP and return the identity, or throw on error.
- public static Task VerifyEmailOTPAsync(this BlockmakerAuth auth, string email, string otp)
- {
- var tcs = new TaskCompletionSource();
- auth.VerifyEmailOTP(email, otp,
- identity => tcs.TrySetResult(identity),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
-
- // ── BlockmakerClient ──────────────────────────────────────────────────────
-
- /// POST to a server path and return the typed response, or throw on error.
- public static Task PostAsync(this BlockmakerClient client, string path, TReq body) where TRes : class
- {
- var tcs = new TaskCompletionSource();
- client.Post(path, body,
- result => tcs.TrySetResult(result),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
-
- /// GET from a server path and return the typed response, or throw on error.
- public static Task GetAsync(this BlockmakerClient client, string path) where TRes : class
- {
- var tcs = new TaskCompletionSource();
- client.Get(path,
- result => tcs.TrySetResult(result),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
-
- // ── BlockmakerProfileManager ──────────────────────────────────────────────
-
- /// Claim a username and return the updated profile, or throw on error.
- public static Task ClaimUsernameAsync(this BlockmakerProfileManager mgr, string username)
- {
- var tcs = new TaskCompletionSource();
- mgr.ClaimUsername(username,
- profile => tcs.TrySetResult(profile),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
-
- /// Change username and return the updated profile, or throw on error.
- public static Task ChangeUsernameAsync(this BlockmakerProfileManager mgr, string newUsername)
- {
- var tcs = new TaskCompletionSource();
- mgr.ChangeUsername(newUsername,
- profile => tcs.TrySetResult(profile),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
-
- /// Set profile picture to an NFT and return the updated profile, or throw on error.
- public static Task SetProfilePicNftAsync(this BlockmakerProfileManager mgr, long assetId)
- {
- var tcs = new TaskCompletionSource();
- mgr.SetProfilePicNft(assetId,
- profile => tcs.TrySetResult(profile),
- error => tcs.TrySetException(new BlockmakerException(error)));
- return tcs.Task;
- }
- }
-
- ///
- /// Exception thrown by async SDK methods when the underlying operation fails.
- /// The message is always a user-friendly string safe to display in UI.
- ///
- public class BlockmakerException : Exception
- {
- public BlockmakerException(string message) : base(message) { }
- }
-
-}
\ No newline at end of file
diff --git a/Core/BlockmakerAsyncExtensions.cs.meta b/Core/BlockmakerAsyncExtensions.cs.meta
deleted file mode 100644
index 255cd3d..0000000
--- a/Core/BlockmakerAsyncExtensions.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: e98ea94ce3f7842d194efe21146ee002
\ No newline at end of file
diff --git a/Core/BlockmakerAuth.cs b/Core/BlockmakerAuth.cs
deleted file mode 100644
index 60f2dda..0000000
--- a/Core/BlockmakerAuth.cs
+++ /dev/null
@@ -1,2450 +0,0 @@
-using System;
-using System.Collections;
-using System.ComponentModel;
-using UnityEngine;
-using UnityEngine.Scripting;
-
-namespace Blockmaker
-{
-
- ///
- /// Central auth manager. Holds the current IBlockmakerIdentity and manages
- /// the full identity lifecycle: guest start, login, session restore, upgrade,
- /// and logout.
- ///
- /// All game code accesses identity through here:
- /// BlockmakerAuth.Instance.Identity
- /// BlockmakerAuth.Instance.Address
- /// BlockmakerAuth.Instance.CanSign
- ///
- /// Subscribe to OnIdentityChanged to react to login/logout/upgrade events.
- ///
- /// Wallet connection:
- /// Pera — uses a native WalletConnect v1 client (WalletConnectV1Client)
- /// that connects directly to Pera's bridge servers, no external SDK needed.
- /// Defly — uses the Reown SDK (WalletConnect v2).
- /// Both paths generate a QR code via OnWalletQRReady for display in the UI.
- /// After the user scans and approves, OnIdentityChanged fires with the address.
- ///
- /// Requires a WalletConnect Project ID for Defly/EVM — get one free at:
- /// https://cloud.walletconnect.com
- ///
- public class BlockmakerAuth : MonoBehaviour
- {
- // ── Singleton ──────────────────────────────────────────────────────────────
- public static BlockmakerAuth Instance { get; private set; }
-
- [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
- static void ResetStatics()
- {
- Instance = null;
- OnIdentityChanged = null;
- OnIdentityUpgraded = null;
- OnAuthError = null;
- OnWalletQRReady = null;
- OnWalletAddressChanged = null;
- SessionRestoreSettled = false;
- OnSessionRestoreSettled = null;
- BlockmakerPrefs.InvalidatePrefix();
- }
-
- // ── Provider constants ─────────────────────────────────────────────────────
- public const string ProviderPera = "Pera";
- public const string ProviderDefly = "Defly";
-
- // ── Events ─────────────────────────────────────────────────────────────────
-
- ///
- /// Fired when the current identity changes (login, logout, upgrade).
- /// Warning: This is a static event. Subscribers must unsubscribe in
- /// OnDestroy() to avoid leaks across scene loads.
- ///
- public static event Action OnIdentityChanged;
-
- ///
- /// Fired when the player upgrades from a lower tier to a higher one.
- /// Warning: This is a static event. Subscribers must unsubscribe in
- /// OnDestroy() to avoid leaks across scene loads.
- ///
- public static event Action OnIdentityUpgraded;
-
- ///
- /// Fired when an auth error occurs (wallet connection failed, session expired, etc.).
- /// Warning: This is a static event. Subscribers must unsubscribe in
- /// OnDestroy() to avoid leaks across scene loads.
- ///
- public static event Action OnAuthError;
-
- /// Non-error progress messages for the auth UI (e.g. "approve the sign-in
- /// request in your wallet") — fired at moments where the user must act
- /// somewhere OTHER than the game and would otherwise see nothing happen.
- public static event Action OnAuthStatus;
-
- ///
- /// Fired when the QR code is ready to display.
- /// Warning: This is a static event. Subscribers must unsubscribe in
- /// OnDestroy() to avoid leaks across scene loads.
- ///
- public static event Action OnWalletQRReady;
-
- ///
- /// Fired when signing in causes a wallet address change.
- /// Subscribe to show a warning that assets at the old address
- /// won't be accessible through the new sign-in method.
- /// Warning: This is a static event. Subscribers must unsubscribe in
- /// OnDestroy() to avoid leaks across scene loads.
- ///
- public static event Action OnWalletAddressChanged;
-
- // ── Boot-time session-restore resolution (additive; nothing in the SDK gates on it) ──
-
- ///
- /// True once the boot-time session-restore attempt has RESOLVED: the stored session's
- /// token was refreshed/verified (signed in), or a fresh user sign-in is required, or
- /// there was no stored session at all. Until this flips, the restored identity is a
- /// GUESS — UI should render a neutral "resolving" state rather than a stale identity
- /// (which may be logged out a moment later) or a premature SIGN IN button.
- /// A late-arriving success after this settles still fires OnIdentityChanged as usual.
- ///
- public static bool SessionRestoreSettled { get; private set; }
-
- ///
- /// Fired ONCE, when flips true. Subscribers that
- /// attach late should read the flag first — it may already be settled.
- /// Warning: This is a static event. Subscribers must unsubscribe in
- /// OnDestroy() to avoid leaks across scene loads.
- ///
- public static event Action OnSessionRestoreSettled;
-
- private static void SettleSessionRestore()
- {
- if (SessionRestoreSettled) return;
- SessionRestoreSettled = true;
- var handler = OnSessionRestoreSettled;
- if (handler == null) return;
- foreach (var d in handler.GetInvocationList())
- {
- try { ((Action)d).Invoke(); }
- catch (Exception ex) { BlockmakerLog.Exception(ex); }
- }
- }
-
- ///
- /// True when Magic email login is available on this platform and configured.
- ///
- public static bool IsMagicAvailable
- {
- get
- {
- #if UNITY_WEBGL && !UNITY_EDITOR
- var cfg = Instance?.blockmakerConfig;
- return cfg != null && cfg.enableMagicEmail && !string.IsNullOrEmpty(cfg.magicPublishableKey);
- #else
- return false;
- #endif
- }
- }
-
- ///
- /// Wallet sign timeout sourced from config, with a safe fallback.
- /// Used by identity classes instead of hardcoded constants.
- ///
- internal static float WalletSignTimeout =>
- Instance?.blockmakerConfig?.walletSignTimeoutSeconds ?? 120f;
-
- // ── Public identity accessors ──────────────────────────────────────────────
- public bool IsAuthenticating { get; private set; }
- public IBlockmakerIdentity Identity { get; private set; }
- public string Address => Identity?.Address ?? string.Empty;
- public string DisplayName => Identity?.DisplayName ?? "Guest";
- public bool HasWallet => Identity?.HasWallet ?? false;
- public bool CanSign => Identity?.CanSign ?? false;
- public IdentityTier Tier => Identity?.Tier ?? IdentityTier.Guest;
-
- /// True when the player has an active session (Email or SelfCustody tier).
- public bool IsLoggedIn => Identity != null && Identity.Tier >= IdentityTier.Email;
-
- ///
- /// Returns the player's email address if signed in via email or Magic, null otherwise.
- ///
- public string GetUserEmail()
- {
- if (Identity is EmailIdentity e) return e.Email;
- if (Identity is MagicIdentity m) return m.Email;
- return null;
- }
-
- ///
- /// Check whether the current session token is still valid.
- /// Useful before expensive operations or after app resume.
- /// Returns false for Guest identities.
- ///
- public void VerifySession(Action onResult)
- {
- if (Identity == null || Identity.Tier == IdentityTier.Guest)
- {
- onResult?.Invoke(false);
- return;
- }
-
- string token = null;
- if (Identity is EmailIdentity e) token = e.SessionToken;
- else if (Identity is MagicIdentity m) token = m.SessionToken;
-
- if (string.IsNullOrEmpty(token))
- {
- if (Identity is WalletConnectIdentity wc)
- {
- var wcv1 = wc.OwnWCv1Client;
- bool wcv1Connected = wcv1 != null && !wcv1.IsDisposed && wcv1.IsConnected;
- var connector = ReownWalletConnector.Instance;
- bool reownConnected = connector != null && connector.IsConnected;
- onResult?.Invoke(wcv1Connected || reownConnected);
- }
- else if (Identity is EvmXChainIdentity)
- {
- var connector = ReownWalletConnector.Instance;
- onResult?.Invoke(connector != null && connector.IsConnected);
- }
- else
- {
- onResult?.Invoke(false);
- }
- return;
- }
-
- if (BlockmakerClient.Instance == null)
- {
- onResult?.Invoke(false);
- return;
- }
-
- BlockmakerClient.Instance.VerifySessionToken(token, onResult);
- }
-
- // ── Pending JS sign callbacks ──────────────────────────────────────────────
- internal string PendingSignedTxn { get; private set; }
- internal string[] PendingSignedTxns { get; private set; }
- internal string PendingSignError { get; private set; }
- private int _signGeneration;
- private int _pendingSignGeneration;
-
- internal string ConsumePendingSignedTxn() { var v = PendingSignedTxn; PendingSignedTxn = null; _signAwaiting = false; return v; }
- internal string[] ConsumePendingSignedTxns() { var v = PendingSignedTxns; PendingSignedTxns = null; _signAwaiting = false; return v; }
- internal string ConsumePendingSignError() { var v = PendingSignError; PendingSignError = null; _signAwaiting = false; return v; }
-
- ///
- /// Clear pending sign state and return a generation token.
- /// If the generation changes before your result arrives, another sign
- /// request has started and yours should abort.
- ///
- internal int BeginPendingSign()
- {
- PendingSignedTxn = null;
- PendingSignedTxns = null;
- PendingSignError = null;
- _pendingSignGeneration = ++_signGeneration;
- _signAwaiting = true;
- return _signGeneration;
- }
-
- internal bool IsSignGenerationCurrent(int gen) => _signGeneration == gen;
-
- // True from BeginPendingSign() until the result is consumed (ConsumePending*). Covers
- // both "sign awaiting in JS" and "result landed but not yet read", so deferring on it
- // can't clobber an unconsumed result. Used to defer an auto wallet-login sign so it
- // doesn't bump the sign generation out from under a user-initiated sign (which would
- // otherwise make the loser die "interrupted").
- internal bool IsWebGLSignInFlight => _signAwaiting;
-
- private bool _signAwaiting;
-
- // ── Inspector ──────────────────────────────────────────────────────────────
- [Header("Blockmaker")]
- [Tooltip("Drag your BlockmakerConfig asset here. Required if no BlockmakerClient exists in the scene.")]
- public BlockmakerConfig blockmakerConfig;
-
- [Header("WalletConnect (legacy — use BlockmakerConfig instead)")]
- [Tooltip("Deprecated: set WalletConnect Project ID on BlockmakerConfig instead. This field is used as a fallback if the config field is empty.")]
- [System.Obsolete("Use BlockmakerConfig.walletConnectProjectId instead")]
- [HideInInspector]
- public string walletConnectProjectId = "";
-
- private string ResolvedWalletConnectProjectId
- {
- get
- {
- if (!string.IsNullOrEmpty(blockmakerConfig?.walletConnectProjectId))
- return blockmakerConfig.walletConnectProjectId;
- if (!string.IsNullOrEmpty(walletConnectProjectId))
- return walletConnectProjectId;
- return BlockmakerConfig.DefaultWalletConnectProjectId;
- }
- }
-
- // ── Proactive token refresh ─────────────────────────────────────────────
- private Coroutine _tokenRefreshCoroutine;
- private bool _isRefreshing;
- private const float TOKEN_REFRESH_INTERVAL = 50f * 60f; // refresh ~10 min before 1h expiry
-
- // ── In-flight coroutine tracking ──────────────────────────────────────────
- private Coroutine _magicLoginCoroutine;
- private Coroutine _emailOtpCoroutine;
- private Coroutine _peraConnectCoroutine;
- private Coroutine _webglTimeoutCoroutine;
- private bool _isWalletConnecting;
-
- // Guards against concurrent wallet-signature Login() coroutines. The existing
- // SessionToken check is a no-op during the async login window because the token
- // isn't set until the coroutine's last line, so connect+restore+reconnect events
- // could each start a Login() (duplicate signature prompts / torn SaveSession).
- private bool _walletLoginInFlight;
-
- // Monotonically-increasing id of the CURRENT wallet-login attempt. Every attempt
- // captures the value at start; RetryWalletLogin / CancelWalletLogin / Logout bump it,
- // which invalidates the old attempt's callbacks (a late success/error whose captured
- // seq no longer matches is logged and ignored — it must not clear the new attempt's
- // in-flight flag or double-fire OnIdentityChanged).
- private int _walletLoginSeq;
- // Live RunWalletLogin coroutine, so retry/cancel can hard-stop the old attempt
- // (kills the nested Login() wait too — StopCoroutine disposes the iterator chain,
- // running RunWalletLogin's finally, which is seq-guarded).
- private Coroutine _walletLoginCoroutine;
-
- // ── Native WC v1 client for Pera ──────────────────────────────────────────
- private Texture2D _peraQRTexture;
- private WalletConnectV1Client _wcv1Client;
- internal WalletConnectV1Client WCv1Client => _wcv1Client;
- private volatile string _wcv1Address;
- private volatile string _wcv1Error;
- private volatile bool _wcv1Done;
-
- private ReownWalletConnector _connector;
-
- // ── Pending connection callbacks ──────────────────────────────────────────
- private Action _pendingConnectSuccess;
- private Action _pendingConnectError;
- private Action _pendingMagicSuccess;
- private Action _pendingMagicError;
- private Action _pendingEvmSuccess;
- private Action _pendingEvmError;
- private IBlockmakerIdentity _evmRestoreCapturedIdentity;
-
- // ── Lifecycle ──────────────────────────────────────────────────────────────
-
- private void Awake()
- {
- if (Instance != null && Instance != this) { Destroy(gameObject); return; }
- Instance = this;
- DontDestroyOnLoad(gameObject);
- BlockmakerPrefs.InvalidatePrefix();
- EnsureBlockmakerClient();
- }
-
- private void EnsureBlockmakerClient()
- {
- if (BlockmakerClient.Instance != null) return;
-
- var existing = GetComponent();
- if (existing != null)
- {
- if (existing.config == null && blockmakerConfig != null)
- {
- existing.config = blockmakerConfig;
- existing.InitFromAuth();
- }
- return;
- }
-
- BlockmakerConfig cfg = blockmakerConfig;
- if (cfg == null)
- {
- var configs = Resources.FindObjectsOfTypeAll();
- if (configs.Length > 0) cfg = configs[0];
- }
-
- if (cfg == null)
- {
- BlockmakerLog.Warning("[BlockmakerAuth] No BlockmakerConfig found. Create one via Assets > Create > Blockmaker > Config.");
- return;
- }
-
- var client = gameObject.AddComponent();
- client.config = cfg;
- client.InitFromAuth();
-
- if (BlockmakerProfileManager.Instance == null && GetComponent() == null)
- gameObject.AddComponent();
- }
-
- private void OnDestroy()
- {
- if (_connector != null) _connector.OnInitialized -= OnReownInitialized;
- CleanupWCv1();
- StopTokenRefreshTimer();
- CancelWebGLTimeout();
-
- if (Instance == this)
- Instance = null;
- }
-
- private float _lastRefreshTime;
-
- private void OnApplicationPause(bool paused)
- {
- if (paused) return;
- float elapsed = Time.realtimeSinceStartup - _lastRefreshTime;
- if (elapsed < 300f) return;
- TryImmediateRefresh();
- }
-
- private void OnApplicationFocus(bool hasFocus)
- {
- if (!hasFocus) return;
- float elapsed = Time.realtimeSinceStartup - _lastRefreshTime;
- if (elapsed < 300f) return;
- TryImmediateRefresh();
- }
-
- private void TryImmediateRefresh()
- {
- string refreshToken = null;
- if (Identity is ServerSignedIdentity e) refreshToken = e.RefreshToken;
- else if (Identity is MagicIdentity m) refreshToken = m.RefreshToken;
- else if (Identity is WalletConnectIdentity wc) refreshToken = wc.RefreshToken;
- else if (Identity is EvmXChainIdentity evm) refreshToken = evm.RefreshToken;
-
- if (string.IsNullOrEmpty(refreshToken) || BlockmakerClient.Instance == null || _isRefreshing) return;
-
- _isRefreshing = true;
- _lastRefreshTime = Time.realtimeSinceStartup;
- var capturedIdentity = Identity;
- BlockmakerClient.Instance.RefreshToken(refreshToken, result =>
- {
- _isRefreshing = false;
- if (this == null || Identity != capturedIdentity) return;
- if (result == null || string.IsNullOrEmpty(result.sessionToken)) return;
-
- if (capturedIdentity is ServerSignedIdentity ce) ce.UpdateTokens(result.sessionToken, result.refreshToken);
- else if (capturedIdentity is MagicIdentity cm) cm.UpdateTokens(result.sessionToken, result.refreshToken);
- else if (capturedIdentity is WalletConnectIdentity cwc) cwc.UpdateTokens(result.sessionToken, result.refreshToken);
- else if (capturedIdentity is EvmXChainIdentity cevm) cevm.UpdateTokens(result.sessionToken, result.refreshToken);
- capturedIdentity.SaveSession();
- BlockmakerLog.Info("[BlockmakerAuth] Token refreshed after app resume.");
- }, err =>
- {
- _isRefreshing = false;
- BlockmakerLog.Warning($"[BlockmakerAuth] Resume token refresh failed: {err}");
- SafeInvoke(OnAuthError, "Your session may have expired. Please sign in again if you experience issues.");
- });
- }
-
- private void Start()
- {
- if (!TryRestoreSession())
- {
- SetIdentity(new GuestIdentity());
- SettleSessionRestore(); // no stored session — the auth state is KNOWN immediately
- }
- else
- {
- VerifyRestoredSession();
- }
-
- _connector = GetComponent();
- if (_connector == null) _connector = gameObject.AddComponent();
- var wcProjectId = ResolvedWalletConnectProjectId;
- if (!string.IsNullOrEmpty(wcProjectId))
- {
- BlockmakerLog.Info($"[BlockmakerAuth] Initializing Reown with project ID: {wcProjectId[..8]}...");
- _connector.OnInitialized += OnReownInitialized;
- _connector.Initialize(wcProjectId);
- }
- else
- {
- BlockmakerLog.Warning("[BlockmakerAuth] No WalletConnect Project ID — Defly and X-Chain will not be available.");
- }
-
-#if UNITY_WEBGL && !UNITY_EDITOR
- // Restore browser-held wallet sessions (Pera JS etc.) NOW, independent of
- // Reown init — a Reown failure/hang must not strand a returning Pera player's
- // signing (their JWT restores but the Pera JS session never re-attached).
- TryReconnectBrowserWalletSessions();
-#endif
- }
-
- private void OnReownInitialized()
- {
- if (_connector != null) _connector.OnInitialized -= OnReownInitialized;
-
- TryReconnectWalletSessions();
- }
-
- // ── Session restore ────────────────────────────────────────────────────────
-
- private bool TryRestoreSession()
- {
- var evmData = EvmXChainIdentity.TryLoadSessionData();
- if (evmData != null)
- {
- var evmIdentity = new EvmXChainIdentity(evmData.algorandAddress, evmData.evmAddress);
- evmIdentity.UpdateTokens(evmData.sessionToken, evmData.refreshToken);
- SetIdentity(evmIdentity);
- BlockmakerLog.Info($"[BlockmakerAuth] EVM xChain session restored: {evmData.evmAddress}");
- // Do NOT TriggerWalletLogin inline here — the relay/connection isn't live yet.
- // VerifyRestoredSession() refreshes/verifies any existing token; a fresh sign-in
- // login (no-token case) is triggered later from TryReconnectWalletSessions once
- // the connection is ready.
- return true;
- }
-
- foreach (var provider in new[] { "Pera", "Defly" })
- {
- var data = WalletConnectIdentity.TryLoadSessionData(provider);
- if (data != null)
- {
- var restoredIdentity = CreateWalletIdentity(provider, data.address);
- if (restoredIdentity is WalletConnectIdentity restoredWc)
- restoredWc.UpdateTokens(data.sessionToken, data.refreshToken);
- SetIdentity(restoredIdentity);
- BlockmakerLog.Info($"[BlockmakerAuth] {provider} session restored: {data.address}");
-
- var wcv1Session = WalletConnectIdentity.TryLoadWCv1Session(provider);
- if (wcv1Session != null)
- {
- CleanupWCv1();
- _wcv1Client = WalletConnectV1Client.FromSession(wcv1Session);
- if (restoredIdentity is WalletConnectIdentity wcIdentity)
- wcIdentity.OwnWCv1Client = _wcv1Client;
- _wcv1Client.Reconnect().ContinueWith(t =>
- {
- if (t.IsFaulted) BlockmakerLog.Warning($"[BlockmakerAuth] WCv1 reconnect failed: {t.Exception?.InnerException?.Message}");
- }, System.Threading.Tasks.TaskContinuationOptions.OnlyOnFaulted);
- BlockmakerLog.Info($"[BlockmakerAuth] WCv1 client restored and reconnecting for {provider}");
- }
-
- // Do NOT TriggerWalletLogin inline here — the WCv1 relay is still reconnecting
- // and Reown may not be initialized. VerifyRestoredSession() handles an existing
- // token; a fresh sign-in login (no-token case) is triggered later from
- // TryReconnectWalletSessions once the connection is confirmed ready.
- return true;
- }
- }
-
- var magicIdentity = MagicIdentity.TryLoadSession();
- if (magicIdentity != null)
- {
- SetIdentity(magicIdentity);
- BlockmakerLog.Info($"[BlockmakerAuth] Magic session restored: {magicIdentity.Email}");
- return true;
- }
-
- var emailIdentity = EmailIdentity.TryLoadSession();
- if (emailIdentity != null)
- {
- SetIdentity(emailIdentity);
- BlockmakerLog.Info($"[BlockmakerAuth] Email session restored: {emailIdentity.Email}");
- return true;
- }
-
- return false;
- }
-
- private void VerifyRestoredSession()
- {
- string token = null;
- string refreshToken = null;
- bool isWallet = false;
-
- if (Identity is EmailIdentity email)
- { token = email.SessionToken; refreshToken = email.RefreshToken; }
- else if (Identity is MagicIdentity magic)
- { token = magic.SessionToken; refreshToken = magic.RefreshToken; }
- else if (Identity is WalletConnectIdentity wc)
- { token = wc.SessionToken; refreshToken = wc.RefreshToken; isWallet = true; }
- else if (Identity is EvmXChainIdentity evm)
- { token = evm.SessionToken; refreshToken = evm.RefreshToken; isWallet = true; }
-
- if (string.IsNullOrEmpty(token) && string.IsNullOrEmpty(refreshToken))
- {
- // For wallet identities, "no tokens" is a normal restore state: the JWT is
- // acquired by a fresh wallet-signature Login once the connection is confirmed
- // ready (TryReconnectWalletSessions / OnWalletReconnectedFromJS). Leave the
- // identity in place rather than downgrading to Guest.
- if (isWallet)
- {
- BlockmakerLog.Info("[BlockmakerAuth] Restored wallet session has no JWT yet — will sign in once the connection is ready.");
- // Settled as "no valid token right now" — a later relay-gated wallet login
- // that lands a JWT announces itself via OnIdentityChanged (allowed upgrade).
- SettleSessionRestore();
- return;
- }
- BlockmakerLog.Info("[BlockmakerAuth] Restored session has no tokens — clearing.");
- SetIdentity(new GuestIdentity());
- SettleSessionRestore();
- return;
- }
-
- var capturedIdentity = Identity;
-
- // If we have a refresh token, use it to get a fresh JWT immediately
- if (!string.IsNullOrEmpty(refreshToken) && BlockmakerClient.Instance != null)
- {
- BlockmakerClient.Instance.RefreshToken(refreshToken, result =>
- {
- // Every path below settles the restore. On the success path the settle must
- // come AFTER UpdateTokens/SaveSession: settle subscribers may immediately fire
- // token-authed requests (profile fetch), which would 401 on the stale token.
- if (this == null) { SettleSessionRestore(); return; }
- if (Identity != capturedIdentity) { SettleSessionRestore(); return; }
- if (result != null && !string.IsNullOrEmpty(result.sessionToken))
- {
- if (capturedIdentity is ServerSignedIdentity ss) ss.UpdateTokens(result.sessionToken, result.refreshToken);
- else if (capturedIdentity is MagicIdentity m) m.UpdateTokens(result.sessionToken, result.refreshToken);
- else if (capturedIdentity is WalletConnectIdentity cwc) cwc.UpdateTokens(result.sessionToken, result.refreshToken);
- else if (capturedIdentity is EvmXChainIdentity cevm) cevm.UpdateTokens(result.sessionToken, result.refreshToken);
- capturedIdentity.SaveSession();
- BlockmakerLog.Info("[BlockmakerAuth] Session token refreshed on restore.");
- // Settle FIRST (fresh token is stored now), so OnIdentityChanged
- // subscribers already read SessionRestoreSettled == true.
- SettleSessionRestore();
- // The restore-time OnIdentityChanged fired BEFORE this fresh JWT existed,
- // so session-gated consumers (balance tracker, profile manager) skipped
- // their loads and are waiting for a re-fire that would otherwise never
- // come on this path. Announce the now-valid session the same way a
- // completed wallet-signature login does (see RunWalletLogin).
- SafeInvoke(OnIdentityChanged, capturedIdentity);
- }
- else
- {
- // Refresh "succeeded" but returned no usable token — resolved either way.
- SettleSessionRestore();
- }
- }, err =>
- {
- // Restore attempt resolved: the stored token could not be refreshed. Either a
- // wallet re-sign (below) or a fresh user sign-in is required from here.
- // Settled at the END of each path (after the identity is in its final state)
- // so settle subscribers never act on the not-yet-downgraded identity.
- if (this == null) { SettleSessionRestore(); return; }
- if (Identity != capturedIdentity) { SettleSessionRestore(); return; }
- // For wallet identities (WalletConnect/EVM xChain) a refresh failure does NOT
- // mean the player must drop to Guest: the live wallet relay can re-sign for a
- // fresh JWT. Clear only the stale tokens and leave the identity in place so the
- // gated TriggerWalletLogin path (TryReconnectWalletSessions / reconnect / restore
- // callbacks) re-acquires a JWT once the connection is ready.
- // Email/Magic keep the original behavior: a failed refresh means re-login.
- if (capturedIdentity is WalletConnectIdentity cwc)
- {
- BlockmakerLog.Info($"[BlockmakerAuth] Wallet token refresh failed — keeping identity, will re-sign when relay is ready: {err}");
- cwc.ClearTokens();
- cwc.SaveSession();
- // Re-sign immediately now that the stale token is cleared, rather than
- // waiting on a later connection event (an adverse async ordering could
- // otherwise leave the wallet connected-but-tokenless until reconnect).
- // The F1 in-flight guard + the empty-token check inside TriggerWalletLogin
- // prevent a duplicate prompt; tokens are now empty so it won't early-return.
- TriggerWalletLogin(cwc);
- }
- else if (capturedIdentity is EvmXChainIdentity cevm)
- {
- BlockmakerLog.Info($"[BlockmakerAuth] Wallet token refresh failed — keeping identity, will re-sign when relay is ready: {err}");
- cevm.ClearTokens();
- cevm.SaveSession();
- // Re-sign immediately now that the stale token is cleared (see WC branch).
- TriggerWalletLogin(cevm);
- }
- else
- {
- BlockmakerLog.Info($"[BlockmakerAuth] Refresh failed — clearing session: {err}");
- SetIdentity(new GuestIdentity());
- }
- SettleSessionRestore();
- });
- return;
- }
-
- // No refresh token — just verify the JWT
- if (BlockmakerClient.Instance == null)
- {
- BlockmakerLog.Warning("[BlockmakerAuth] Cannot verify session — no server connection. Clearing session.");
- SetIdentity(new GuestIdentity());
- SettleSessionRestore();
- return;
- }
- BlockmakerClient.Instance.VerifySessionToken(token, ok =>
- {
- // Verified either way — the restore attempt is resolved. Settle AFTER the identity
- // reaches its final state (the !ok downgrade), so settle subscribers never read a
- // signed-in identity that is about to drop to Guest.
- if (this == null) { SettleSessionRestore(); return; }
- if (Identity != capturedIdentity) { SettleSessionRestore(); return; }
- if (!ok)
- {
- BlockmakerLog.Info($"[BlockmakerAuth] {capturedIdentity.ProviderName} session expired — please sign in again.");
- SetIdentity(new GuestIdentity());
- }
- SettleSessionRestore();
- });
- }
-
- // ── Proactive token refresh ──────────────────────────────────────────────
-
- private void StartTokenRefreshTimer()
- {
- StopTokenRefreshTimer();
- _tokenRefreshCoroutine = StartCoroutine(TokenRefreshLoop());
- }
-
- private void StopTokenRefreshTimer()
- {
- if (_tokenRefreshCoroutine != null)
- {
- StopCoroutine(_tokenRefreshCoroutine);
- _tokenRefreshCoroutine = null;
- }
- }
-
- private IEnumerator TokenRefreshLoop()
- {
- while (true)
- {
- yield return new WaitForSecondsRealtime(TOKEN_REFRESH_INTERVAL);
-
- string refreshToken = null;
- if (Identity is ServerSignedIdentity e) refreshToken = e.RefreshToken;
- else if (Identity is MagicIdentity m) refreshToken = m.RefreshToken;
- else if (Identity is WalletConnectIdentity wc) refreshToken = wc.RefreshToken;
- else if (Identity is EvmXChainIdentity evm) refreshToken = evm.RefreshToken;
-
- if (_isRefreshing && BlockmakerClient.Instance == null)
- _isRefreshing = false;
-
- if (string.IsNullOrEmpty(refreshToken) || BlockmakerClient.Instance == null || _isRefreshing)
- continue;
-
- _isRefreshing = true;
- _lastRefreshTime = Time.realtimeSinceStartup;
- var capturedIdentity = Identity;
- BlockmakerClient.Instance.RefreshToken(refreshToken, result =>
- {
- _isRefreshing = false;
- if (this == null || Identity != capturedIdentity) return;
- if (result == null || string.IsNullOrEmpty(result.sessionToken)) return;
-
- if (capturedIdentity is ServerSignedIdentity ce) ce.UpdateTokens(result.sessionToken, result.refreshToken);
- else if (capturedIdentity is MagicIdentity cm) cm.UpdateTokens(result.sessionToken, result.refreshToken);
- else if (capturedIdentity is WalletConnectIdentity cwc) cwc.UpdateTokens(result.sessionToken, result.refreshToken);
- else if (capturedIdentity is EvmXChainIdentity cevm) cevm.UpdateTokens(result.sessionToken, result.refreshToken);
- capturedIdentity.SaveSession();
- BlockmakerLog.Info("[BlockmakerAuth] Token proactively refreshed.");
- }, err =>
- {
- _isRefreshing = false;
- BlockmakerLog.Warning($"[BlockmakerAuth] Proactive token refresh failed: {err}");
- SafeInvoke(OnAuthError, "Your session could not be refreshed. You may need to sign in again.");
- });
- }
- }
-
- private void TryReconnectWalletSessions()
- {
- if (_connector != null && _connector.IsInitialized)
- {
- var address = _connector.TryRestoreSession();
- if (address != null)
- {
- BlockmakerLog.Info($"[BlockmakerAuth] Native WC v2 session restored: {address}");
- // Connection is now ready. If the restored wallet identity has no JWT yet,
- // run a fresh wallet-signature login now (gated behind connection-ready, not
- // fired inline during TryRestoreSession). VerifyRestoredSession already
- // refreshed/verified any existing token, so this no-ops when one is present.
- TriggerWalletLogin(Identity);
- return;
- }
- }
-
- #if UNITY_WEBGL && !UNITY_EDITOR
- // Browser-held sessions (Pera JS lib / Magic / EVM) restore independently of
- // Reown — see TryReconnectBrowserWalletSessions (idempotent; normally already
- // fired from Start, this covers late callers).
- TryReconnectBrowserWalletSessions();
- #else
- // Native (non-WebGL): the WCv1 Pera path doesn't go through _connector.TryRestoreSession
- // above. If a wallet identity restored without a JWT, run a fresh wallet-signature login
- // now that we're past Reown init. Login() itself waits for the WCv1 relay to reconnect,
- // and RunWalletLogin defers while any sign is in flight. No-ops if a token already exists.
- if (Identity is WalletConnectIdentity || Identity is EvmXChainIdentity)
- TriggerWalletLogin(Identity);
- #endif
- }
-
-#if UNITY_WEBGL && !UNITY_EDITOR
- // Guard so the browser reconnect runs exactly once whether it fires from Start
- // (Reown-independent) or from OnReownInitialized (the legacy trigger).
- private bool _browserWalletReconnectAttempted;
-
- ///
- /// Restore wallet sessions that live in the BROWSER, not in Reown: Pera's JS lib
- /// (localStorage), Magic, and the EVM bridge. Historically this only ran from
- /// OnReownInitialized — if Reown's init threw or hung (its failure path swallows
- /// and never fires OnInitialized), a returning Pera player's JWT restored fine but
- /// the Pera JS session was never re-attached, so their FIRST signature failed with
- /// "Pera wallet not connected" until a manual reconnect. Pera needs nothing from
- /// Reown, so this is also called directly from Start.
- ///
- private void TryReconnectBrowserWalletSessions()
- {
- if (_browserWalletReconnectAttempted) return;
- _browserWalletReconnectAttempted = true;
-
- foreach (var provider in new[] { "Pera", "Defly" })
- {
- BlockmakerWalletBridge.TryReconnect(
- provider,
- gameObject.name,
- nameof(OnWalletReconnectedFromJS),
- nameof(OnWalletReconnectFailed)
- );
- }
-
- // Pera on WebGL sessions live in Pera's own JS library (localStorage). The jslib
- // emits "Pera:" — the same payload the generic reconnect receivers expect.
- if (Identity is WalletConnectIdentity peraId && peraId.ProviderName == ProviderPera)
- {
- BlockmakerWalletBridge.PeraJsReconnect(
- gameObject.name,
- nameof(OnWalletReconnectedFromJS),
- nameof(OnWalletReconnectFailed)
- );
- }
-
- var cfg = BlockmakerClient.Instance?.config;
- if (Identity is MagicIdentity && cfg != null && cfg.enableMagicEmail && !string.IsNullOrEmpty(cfg.magicPublishableKey))
- {
- BlockmakerWalletBridge.MagicTryRestore(
- cfg.magicPublishableKey,
- gameObject.name,
- nameof(OnMagicRestoreSuccess),
- nameof(OnMagicRestoreError)
- );
- }
-
- if (Identity is EvmXChainIdentity evm)
- {
- _evmRestoreCapturedIdentity = Identity;
- BlockmakerWalletBridge.EvmTryRestore(
- evm.EvmAddress,
- gameObject.name,
- nameof(OnEvmRestoreSuccess),
- nameof(OnEvmRestoreError)
- );
- }
- }
-#endif
-
- // ── Connect wallet (QR flow) ───────────────────────────────────────────────
-
- ///
- /// Begin a wallet connection.
- ///
- /// Pera: uses native WalletConnect v1 on all platforms.
- /// Defly: uses Reown SDK (WalletConnect v2); falls back to JS bridge on WebGL.
- ///
- /// OnWalletQRReady fires with the QR code for display.
- /// onSuccess / OnIdentityChanged fire when the user approves.
- ///
- public void ConnectWallet(
- string provider,
- Action onSuccess = null,
- Action onError = null)
- {
- if (_isWalletConnecting || IsAuthenticating)
- {
- onError?.Invoke(_isWalletConnecting
- ? "A wallet connection is already in progress. Please wait."
- : "Another sign-in is already in progress. Please wait.");
- return;
- }
-
- if (!provider.Equals(ProviderPera, StringComparison.OrdinalIgnoreCase) &&
- !provider.Equals(ProviderDefly, StringComparison.OrdinalIgnoreCase))
- {
- BlockmakerLog.Error($"[BlockmakerAuth] Unknown wallet provider '{provider}'. Supported: \"{ProviderPera}\", \"{ProviderDefly}\".");
- onError?.Invoke($"Unknown wallet provider \"{provider}\". Please use Pera or Defly.");
- return;
- }
-
- IsAuthenticating = true;
- _isWalletConnecting = true;
- _pendingConnectSuccess = onSuccess;
- _pendingConnectError = onError;
-
- if (provider.Equals(ProviderPera, StringComparison.OrdinalIgnoreCase))
- {
- #if UNITY_WEBGL && !UNITY_EDITOR
- // Pera speaks WalletConnect v1 ONLY (Pera-founder-confirmed) — never route
- // it through the WC v2 paths (Reown / the in-house jslib client): the app
- // cannot pair their QR codes. On WebGL we use Pera's official JS library
- // HEADLESS: its DOM modal is suppressed (browser fullscreen would hide it —
- // sign-in must never leave fullscreen) and the v1 URI is sent to Unity for
- // the usual in-canvas QR.
- BlockmakerWalletBridge.PeraJsConnect(
- gameObject.name,
- nameof(OnPeraJsConnected),
- nameof(OnPeraJsError),
- nameof(OnWalletQRFromJS) // v1 URI → the usual in-canvas QR pipeline
- );
- StartWebGLTimeout(WalletSignTimeout, () =>
- {
- if (_isWalletConnecting) FailWalletConnection("Connection timed out. Please try again.");
- });
- #else
- _peraConnectCoroutine = StartCoroutine(PeraNativeWCv1Flow());
- #endif
- return;
- }
-
- if (_connector != null && _connector.IsInitialized)
- {
- _connector.ConnectAlgorand(
- provider,
- onConnected: (prov, address) => CompleteWalletConnection(prov, address),
- onError: err => FailWalletConnection(err)
- );
- return;
- }
-
- #if UNITY_WEBGL && !UNITY_EDITOR
- if (string.IsNullOrEmpty(ResolvedWalletConnectProjectId))
- {
- BlockmakerLog.Error("[BlockmakerAuth] WalletConnect Project ID is not set. Set it on BlockmakerConfig (https://cloud.walletconnect.com).");
- _isWalletConnecting = false;
- IsAuthenticating = false;
- onError?.Invoke("Wallet connection is not available right now. Please try again later.");
- return;
- }
-
- BlockmakerWalletBridge.ConnectWalletQR(
- ResolvedWalletConnectProjectId,
- provider,
- gameObject.name,
- nameof(OnWalletQRFromJS),
- nameof(OnWalletConnectedFromJS),
- nameof(OnWalletErrorFromJS)
- );
- StartWebGLTimeout(WalletSignTimeout, () =>
- {
- if (_isWalletConnecting) FailWalletConnection("Connection timed out. Please try again.");
- });
- #else
- _isWalletConnecting = false;
- IsAuthenticating = false;
- onError?.Invoke("Wallet connection is not available. Please restart the game and try again.");
- #endif
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnWalletQRFromJS(string payload)
- {
- int firstPipe = payload.IndexOf('|');
- if (firstPipe < 0) return;
- var provider = payload.Substring(0, firstPipe);
- var rest = payload.Substring(firstPipe + 1);
-
- int secondPipe = rest.IndexOf('|');
- if (secondPipe < 0) return;
- var wcUri = rest.Substring(0, secondPipe);
- var qrB64 = rest.Substring(secondPipe + 1);
-
- BlockmakerLog.Info($"[BlockmakerAuth] QR ready for {provider}");
- SafeInvoke(OnWalletQRReady, new WalletQREventArgs(provider, wcUri, qrB64));
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnWalletConnectedFromJS(string payload)
- {
- CancelWebGLTimeout();
- if (!_isWalletConnecting)
- {
- BlockmakerLog.Warning("[BlockmakerAuth] Ignoring unexpected wallet connection callback.");
- return;
- }
-
- var parts = payload.Split(new[] { ':' }, 2);
- var provider = parts.Length > 1 ? parts[0] : "Unknown";
- var address = parts.Length > 1 ? parts[1] : payload;
-
- CompleteWalletConnection(provider, address);
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnWalletReconnectedFromJS(string payload)
- {
- var parts = payload.Split(new[] { ':' }, 2);
- var provider = parts.Length > 1 ? parts[0] : "Unknown";
- var address = parts.Length > 1 ? parts[1] : payload;
-
- if (Identity is WalletConnectIdentity existing && existing.Address == address)
- {
- BlockmakerLog.Info($"[BlockmakerAuth] WebGL wallet reconnected: {provider} {address}");
- // The relay is now confirmed live. If this restored identity still has no JWT,
- // this is the correct gated moment to run a fresh wallet-signature login.
- // TriggerWalletLogin no-ops when a token already exists.
- if (string.IsNullOrEmpty(existing.SessionToken))
- TriggerWalletLogin(existing);
- return;
- }
-
- if (Identity != null && !(Identity is WalletConnectIdentity) && !(Identity is GuestIdentity))
- {
- BlockmakerLog.Info($"[BlockmakerAuth] Ignoring wallet reconnect — current identity is {Identity.ProviderName}");
- return;
- }
-
- var identity = CreateWalletIdentity(provider, address);
- SetIdentity(identity);
- identity.SaveSession();
- TriggerWalletLogin(identity);
- BlockmakerLog.Info($"[BlockmakerAuth] WebGL wallet session restored via reconnect: {provider} {address}");
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnWalletReconnectFailed(string error)
- {
- BlockmakerLog.Warning($"[BlockmakerAuth] WebGL wallet reconnect failed: {error}");
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnWalletErrorFromJS(string error)
- {
- CancelWebGLTimeout();
- FailWalletConnection(error);
- }
-
- // ── Pera official JS SDK callbacks (WebGL Pera path) ─────────────────────
-
- ///
- /// Success callback for BlockmakerWalletBridge.PeraJsConnect (WebGL only).
- /// Receives the bare Algorand address — Pera's own modal handled the
- /// QR / deep-link UX, so this feeds straight into the same post-connect
- /// funnel as the other wallets (identity → SaveSession → TriggerWalletLogin).
- ///
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnPeraJsConnected(string address)
- {
- CancelWebGLTimeout();
- if (!_isWalletConnecting)
- {
- BlockmakerLog.Warning("[BlockmakerAuth] Ignoring unexpected Pera JS connection callback.");
- return;
- }
-
- CompleteWalletConnection(ProviderPera, address);
- }
-
- /// Error callback for BlockmakerWalletBridge.PeraJsConnect (WebGL only).
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnPeraJsError(string error)
- {
- CancelWebGLTimeout();
- if (!_isWalletConnecting)
- {
- BlockmakerLog.Warning($"[BlockmakerAuth] Ignoring Pera JS error after connect ended: {error}");
- return;
- }
-
- // Recognizable code sent by the jslib when the user simply closed Pera's modal.
- if (error == "PERA_CONNECT_CANCELLED")
- error = "The connection was cancelled. Please try again.";
-
- FailWalletConnection(error);
- }
-
- // ── Pera native WalletConnect v1 ──────────────────────────────────────────
-
- private IEnumerator PeraNativeWCv1Flow()
- {
- CleanupWCv1();
- var dAppUrl = blockmakerConfig?.dAppUrl;
- if (string.IsNullOrEmpty(dAppUrl)) dAppUrl = blockmakerConfig?.serverUrl;
- if (string.IsNullOrEmpty(dAppUrl)) dAppUrl = BlockmakerConfig.DefaultServerUrl;
-
- _wcv1Client = new WalletConnectV1Client(
- chainId: 4160,
- appName: Application.productName,
- appDescription: Application.productName,
- appUrl: dAppUrl
- );
-
- string wcUri = _wcv1Client.Uri;
- _wcv1Address = null;
- _wcv1Error = null;
- _wcv1Done = false;
-
- _wcv1Client.OnSessionApproved += addr => { _wcv1Address = addr; _wcv1Done = true; };
- _wcv1Client.OnSessionRejected += err => { _wcv1Error = err; _wcv1Done = true; };
- _wcv1Client.OnError += err => { _wcv1Error = err; _wcv1Done = true; };
-
- // Show QR immediately — the URI is known before connecting
- var qrTexture = QRTextureGenerator.Generate(wcUri, 512);
- _peraQRTexture = qrTexture;
- ReownWalletConnector.FireQRReadyForBridge("Pera", wcUri, qrTexture);
-
- BlockmakerLog.Info($"[BlockmakerAuth] Pera WCv1 connecting to bridge...");
-
- var connectTask = _wcv1Client.Connect();
- while (!connectTask.IsCompleted)
- yield return null;
-
- if (connectTask.IsFaulted)
- {
- _peraConnectCoroutine = null;
- if (qrTexture != null) { Destroy(qrTexture); _peraQRTexture = null; }
- string msg = connectTask.Exception?.InnerException?.Message ?? "Could not connect to the Pera wallet service. Please check your internet connection and try again.";
- FailWalletConnection(msg);
- CleanupWCv1();
- yield break;
- }
-
- float elapsed = 0f;
- float TIMEOUT = WalletSignTimeout;
-
- float lastCheck = Time.realtimeSinceStartup;
- while (!_wcv1Done && elapsed < TIMEOUT && _isWalletConnecting)
- {
- yield return new WaitForSecondsRealtime(0.25f);
- float now = Time.realtimeSinceStartup;
- elapsed += now - lastCheck;
- lastCheck = now;
- }
-
- _peraConnectCoroutine = null;
- if (qrTexture != null) { Destroy(qrTexture); _peraQRTexture = null; }
-
- if (!string.IsNullOrEmpty(_wcv1Address))
- {
- BlockmakerLog.Info($"[BlockmakerAuth] Pera WCv1 connected: {_wcv1Address}");
-
- if (_wcv1Client != null)
- {
- var sd = _wcv1Client.GetSessionData();
- if (sd != null)
- {
- SecurePrefs.SetString(BlockmakerPrefs.Key("wc_session_pera_wcv1"), JsonUtility.ToJson(sd));
- SecurePrefs.Save();
- }
- }
-
- CompleteWalletConnection("Pera", _wcv1Address);
- }
- else if (!string.IsNullOrEmpty(_wcv1Error))
- {
- FailWalletConnection(_wcv1Error);
- CleanupWCv1();
- }
- else
- {
- FailWalletConnection("Connection timed out. Please try again.");
- CleanupWCv1();
- }
- }
-
- private void CleanupWCv1()
- {
- if (_peraQRTexture != null) { Destroy(_peraQRTexture); _peraQRTexture = null; }
- if (_wcv1Client != null)
- {
- if (Identity is WalletConnectIdentity wcIdentity && wcIdentity.OwnWCv1Client == _wcv1Client)
- wcIdentity.OwnWCv1Client = null;
- _wcv1Client.Dispose();
- _wcv1Client = null;
- }
- }
-
- private void CompleteWalletConnection(string provider, string address)
- {
- try
- {
- var prevTier = Tier;
- var identity = CreateWalletIdentity(provider, address);
- if (identity is WalletConnectIdentity wcIdentity && _wcv1Client != null)
- wcIdentity.OwnWCv1Client = _wcv1Client;
-
- var successCb = _pendingConnectSuccess;
- _pendingConnectSuccess = null;
- _pendingConnectError = null;
- _isWalletConnecting = false;
- IsAuthenticating = false;
-
- SetIdentity(identity);
- identity.SaveSession();
- TriggerWalletLogin(identity);
-
- if (prevTier < IdentityTier.SelfCustody)
- SafeInvoke(OnIdentityUpgraded, identity, prevTier);
-
- successCb?.Invoke(identity);
- }
- catch (Exception ex)
- {
- BlockmakerLog.Error($"[BlockmakerAuth] Wallet connection error: {ex.Message}");
- FailWalletConnection("Something went wrong while connecting. Please try again.");
- }
- }
-
- private void FailWalletConnection(string error)
- {
- BlockmakerLog.Error($"[BlockmakerAuth] Wallet error: {error}");
- SafeInvoke(OnAuthError, error);
- _pendingConnectError?.Invoke(error);
- _pendingConnectError = null;
- _pendingConnectSuccess = null;
- _isWalletConnecting = false;
- IsAuthenticating = false;
- }
-
- public void CancelWalletConnect()
- {
- if (!_isWalletConnecting) return;
- CancelWebGLTimeout();
- if (_peraQRTexture != null) { Destroy(_peraQRTexture); _peraQRTexture = null; }
- if (_peraConnectCoroutine != null)
- {
- StopCoroutine(_peraConnectCoroutine);
- _peraConnectCoroutine = null;
- }
- CleanupWCv1();
- _connector?.CancelConnection();
- IsAuthenticating = false;
- _pendingConnectSuccess = null;
- _pendingConnectError = null;
- _isWalletConnecting = false;
- #if UNITY_WEBGL && !UNITY_EDITOR
- BlockmakerWalletBridge.CancelWalletQR();
- #endif
- }
-
- private void StartWebGLTimeout(float seconds, Action onTimeout)
- {
- CancelWebGLTimeout();
- _webglTimeoutCoroutine = StartCoroutine(WebGLTimeoutRoutine(seconds, onTimeout));
- }
-
- private void CancelWebGLTimeout()
- {
- if (_webglTimeoutCoroutine != null)
- {
- StopCoroutine(_webglTimeoutCoroutine);
- _webglTimeoutCoroutine = null;
- }
- }
-
- private IEnumerator WebGLTimeoutRoutine(float seconds, Action onTimeout)
- {
- yield return new WaitForSecondsRealtime(seconds);
- _webglTimeoutCoroutine = null;
- onTimeout?.Invoke();
- }
-
- // ── Magic email login ────────────────────────────────────────────────────
-
- ///
- /// Start a Magic SDK email login. Magic handles its own OTP verification UI.
- /// On success the DID token is sent to our server for JWT issuance.
- ///
- public void ConnectMagicEmail(
- string email,
- Action onSuccess = null,
- Action onError = null)
- {
- if (IsAuthenticating || _pendingMagicSuccess != null || _pendingMagicError != null)
- {
- onError?.Invoke("Another sign-in is already in progress. Please wait.");
- return;
- }
-
- IsAuthenticating = true;
-
- var cfg = BlockmakerClient.Instance?.config;
- if (cfg == null || !cfg.enableMagicEmail || string.IsNullOrEmpty(cfg.magicPublishableKey))
- {
- IsAuthenticating = false;
- BlockmakerLog.Error("[BlockmakerAuth] Magic publishable key not configured. Set it on the BlockmakerConfig asset.");
- onError?.Invoke("Email sign-in is not available right now. Please try again later.");
- return;
- }
-
- #if UNITY_WEBGL && !UNITY_EDITOR
- _pendingMagicSuccess = onSuccess;
- _pendingMagicError = onError;
-
- BlockmakerWalletBridge.MagicLoginWithEmail(
- cfg.magicPublishableKey,
- email,
- gameObject.name,
- nameof(OnMagicLoginSuccess),
- nameof(OnMagicLoginError)
- );
- StartWebGLTimeout(WalletSignTimeout, () =>
- {
- if (_pendingMagicSuccess != null || _pendingMagicError != null)
- OnMagicLoginError("Sign-in timed out. Please try again.");
- });
- #else
- IsAuthenticating = false;
- onError?.Invoke("Email sign-in is only available when playing in a web browser.");
- #endif
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnMagicLoginSuccess(string payload)
- {
- CancelWebGLTimeout();
- if (_pendingMagicSuccess == null && _pendingMagicError == null)
- return;
-
- var parts = payload.Split(new[] { '|' }, 4);
- if (parts.Length < 4)
- {
- OnMagicLoginError("Sign-in could not be completed. Please try again.");
- return;
- }
- var address = parts[1];
- var email = parts[2];
- var didToken = parts[3];
-
- _magicLoginCoroutine = StartCoroutine(FinishMagicLogin(address, email, didToken));
- }
-
- private IEnumerator FinishMagicLogin(string address, string email, string didToken)
- {
- if (BlockmakerClient.Instance == null)
- {
- OnMagicLoginError("Unable to reach the server. Please check your connection and try again.");
- yield break;
- }
-
- string jwt = null;
- string refreshTok = null;
- string error = null;
-
- try
- {
- yield return BlockmakerClient.Instance.VerifyMagicToken(
- didToken, email,
- result => { jwt = result.sessionToken; refreshTok = result.refreshToken; },
- err => { error = err; }
- );
- }
- finally
- {
- _magicLoginCoroutine = null;
- }
-
- if (error != null)
- {
- BlockmakerLog.Error($"[BlockmakerAuth] Magic server verify failed: {error}");
- SafeInvoke(OnAuthError, "Sign-in could not be completed. Please try again.");
- _pendingMagicError?.Invoke("Sign-in could not be completed. Please try again.");
- _pendingMagicError = null;
- _pendingMagicSuccess = null;
- IsAuthenticating = false;
- yield break;
- }
-
- if (_pendingMagicSuccess == null && _pendingMagicError == null)
- yield break;
-
- try
- {
- var prevTier = Tier;
- var identity = new MagicIdentity(email, address, jwt, refreshTok);
- SetIdentity(identity);
- identity.SaveSession();
-
- if (prevTier < IdentityTier.Email)
- SafeInvoke(OnIdentityUpgraded, identity, prevTier);
-
- IsAuthenticating = false;
- _pendingMagicSuccess?.Invoke(identity);
- _pendingMagicSuccess = null;
- _pendingMagicError = null;
- }
- catch (Exception ex)
- {
- BlockmakerLog.Error($"[BlockmakerAuth] Magic login error: {ex.Message}");
- OnMagicLoginError("Something went wrong while signing in. Please try again.");
- }
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnMagicLoginError(string error)
- {
- CancelWebGLTimeout();
- if (_pendingMagicSuccess == null && _pendingMagicError == null)
- return;
-
- BlockmakerLog.Error($"[BlockmakerAuth] Magic login error: {error}");
- SafeInvoke(OnAuthError, error);
- _pendingMagicError?.Invoke(error);
- _pendingMagicError = null;
- _pendingMagicSuccess = null;
- IsAuthenticating = false;
- }
-
- public void CancelPendingMagic()
- {
- if (_magicLoginCoroutine != null)
- {
- StopCoroutine(_magicLoginCoroutine);
- _magicLoginCoroutine = null;
- }
- _pendingMagicSuccess = null;
- _pendingMagicError = null;
- IsAuthenticating = false;
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnMagicRestoreSuccess(string payload)
- {
- var parts = payload.Split('|');
- if (parts.Length < 3) return;
- BlockmakerLog.Info($"[BlockmakerAuth] Magic JS session confirmed active for {parts[2]}");
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnMagicRestoreError(string error)
- {
- if (Identity is MagicIdentity magic)
- {
- BlockmakerLog.Info("[BlockmakerAuth] Magic JS session expired — verifying server JWT…");
- if (string.IsNullOrEmpty(magic.SessionToken))
- {
- Identity.ClearSession();
- SetIdentity(new GuestIdentity());
- return;
- }
- if (BlockmakerClient.Instance == null)
- {
- BlockmakerLog.Warning("[BlockmakerAuth] Cannot verify Magic JWT — no server connection. Clearing session.");
- Identity.ClearSession();
- SetIdentity(new GuestIdentity());
- return;
- }
- var capturedIdentity = Identity;
- BlockmakerClient.Instance.VerifySessionToken(magic.SessionToken, ok =>
- {
- if (this == null) return;
- if (Identity != capturedIdentity) return;
- if (!ok)
- {
- BlockmakerLog.Info("[BlockmakerAuth] Server JWT also invalid — clearing session.");
- Identity.ClearSession();
- SetIdentity(new GuestIdentity());
- }
- else
- {
- BlockmakerLog.Info("[BlockmakerAuth] Server JWT still valid — keeping Magic identity.");
- }
- });
- }
- }
-
- // ── EVM session restore callbacks ──────────────────────────────────────────
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnEvmRestoreSuccess(string payload)
- {
- _evmRestoreCapturedIdentity = null;
- // payload is the raw EVM address ("0x…") — the jslib already verified it
- // matches the expected address passed to EvmTryRestore.
- if (string.IsNullOrEmpty(payload)) return;
- BlockmakerLog.Info($"[BlockmakerAuth] EVM wallet reconnected: {payload}");
- // Provider confirmed live. If the restored EVM identity still has no JWT, run a
- // fresh wallet-signature login now (the gated, connection-ready moment). No-ops if
- // a token already exists (VerifyRestoredSession refreshes/verifies any existing one).
- if (Identity is EvmXChainIdentity evm && string.IsNullOrEmpty(evm.SessionToken))
- TriggerWalletLogin(evm);
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnEvmRestoreError(string error)
- {
- if (Identity != _evmRestoreCapturedIdentity) { _evmRestoreCapturedIdentity = null; return; }
- _evmRestoreCapturedIdentity = null;
- if (Identity is EvmXChainIdentity)
- {
- BlockmakerLog.Warning($"[BlockmakerAuth] EVM restore failed: {error} — clearing session.");
- Identity.ClearSession();
- SetIdentity(new GuestIdentity());
- }
- }
-
- // ── EVM xChain ─────────────────────────────────────────────────────────────
-
- ///
- /// Connect an EVM wallet via xChain Accounts. Derives an Algorand LogicSig address.
- /// This is the NO-PICKER path (auto-pick: last-used wallet → first announced →
- /// window.ethereum) — the wallet-picker UI uses +
- /// instead. onError receives a bare message here
- /// (unchanged legacy shape).
- ///
- public void ConnectEvm(
- Action onSuccess = null,
- Action onError = null)
- {
- if (IsAuthenticating || _pendingEvmSuccess != null || _pendingEvmError != null)
- {
- onError?.Invoke("Another sign-in is already in progress. Please wait.");
- return;
- }
-
- IsAuthenticating = true;
- _pendingEvmSuccess = onSuccess;
- _pendingEvmError = onError;
- _evmConnectErrorsIncludeCode = false; // legacy path: bare-message errors
- _evmExpectedRdns = null; // auto-pick: accept whichever wallet connects
-
- #if UNITY_WEBGL && !UNITY_EDITOR
- // Zero-bundle browser path: discover the installed EVM wallets first
- // (EIP-6963), then connect. OnEvmWalletsDiscovered picks the wallet and
- // calls EvmConnect; the timeout below covers the whole chain.
- // _evmConnectDispatched guards the discover→connect hop: a cancel +
- // immediate reconnect can leave a STALE discovery callback in flight,
- // and without the guard both callbacks would call EvmConnect → duplicate
- // eth_requestAccounts popups.
- _evmConnectDispatched = false;
- BlockmakerWalletBridge.EvmDiscoverWallets(
- gameObject.name,
- nameof(OnEvmWalletsDiscovered)
- );
- StartWebGLTimeout(WalletSignTimeout, () =>
- {
- if (_pendingEvmSuccess != null || _pendingEvmError != null)
- OnEvmError("Connection timed out. Please try again.");
- });
- #else
- if (_connector == null || !_connector.IsInitialized)
- {
- IsAuthenticating = false;
- _pendingEvmSuccess = null;
- _pendingEvmError = null;
- onError?.Invoke("Wallet connection is not ready yet. Please try again in a moment.");
- return;
- }
-
- // OnEvmConnected receives the raw EVM address and derives the Algorand
- // LogicSig address in C# — the same funnel the WebGL bridge feeds.
- _connector.ConnectEvm(
- evmAddr => OnEvmConnected(evmAddr),
- err => OnEvmError(err)
- );
- #endif
- }
-
- ///
- /// Callback for BlockmakerWalletBridge.EvmDiscoverWallets on the ConnectEvm
- /// AUTO-CONNECT path (WebGL only). Payload: JSON
- /// {"wallets":[{"rdns","name","icon","lastUsed"},…],"legacy":bool} — or the
- /// sentinel "!none" when no EVM provider is installed at all. This path
- /// ignores the wallet list and auto-picks via EvmConnect(""); the picker UI
- /// uses the separate DiscoverEvmWallets / OnEvmWalletsDiscoveredForUi /
- /// ConnectEvmWallet API below instead.
- ///
- // True once the current connect attempt has dispatched EvmConnect — stale
- // discovery callbacks (from a cancelled attempt) must not dispatch a second.
- private bool _evmConnectDispatched;
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnEvmWalletsDiscovered(string payload)
- {
- if (_pendingEvmSuccess == null && _pendingEvmError == null)
- return; // the connect was cancelled while discovery ran
-
- if (_evmConnectDispatched)
- return; // a discovery callback already advanced this attempt to connect
-
- if (payload == "!none")
- {
- OnEvmError("No EVM wallet found. Please install one to continue.");
- return;
- }
-
- // Do NOT log the payload itself — it now carries base64 icon bytes.
- BlockmakerLog.Info($"[BlockmakerAuth] EVM wallets discovered ({(payload?.Length ?? 0)} chars) — auto-connecting.");
-
- _evmConnectDispatched = true;
- BlockmakerWalletBridge.EvmConnect(
- "", // auto-pick: last-used rdns (localStorage) → first announced → window.ethereum
- gameObject.name,
- nameof(OnEvmConnected),
- nameof(OnEvmError)
- );
- }
-
- // ── EVM wallet picker (discover / connect split) ───────────────────────────
- // The picker UI drives these two calls: DiscoverEvmWallets → show the list →
- // ConnectEvmWallet(chosen rdns). ConnectEvm above stays the no-picker
- // auto-pick path for API compatibility.
-
- // Single pending slot for the UI discovery callback (last-wins): a newer
- // DiscoverEvmWallets call replaces the callback; the first jslib response
- // consumes the slot and any later stale response finds it empty and is dropped.
- private Action _pendingEvmDiscoverForUi;
-
- // True while the CURRENT EVM connect attempt came from ConnectEvmWallet (the
- // picker path): OnEvmError then forwards "code|message" to the pending error
- // callback so the UI can branch on EIP-1193 codes. ConnectEvm (the legacy
- // auto-pick path) resets it so its callers keep receiving the bare message.
- private bool _evmConnectErrorsIncludeCode;
-
- ///
- /// Discover installed EVM wallets for a picker UI. onResult receives the RAW
- /// jslib payload:
- /// JSON — {"wallets":[{"rdns","name","icon","lastUsed"},…],"legacy":bool}
- /// (icon = base64 96x96 PNG, no data: prefix, "" if unavailable; lastUsed
- /// marks the last-used wallet; legacy = a window.ethereum provider exists)
- /// — or the sentinel "!none" when no EVM provider is available at all.
- /// On non-WebGL platforms onResult fires immediately with
- /// {"wallets":[],"legacy":false,"native":true} — the UI should skip the
- /// picker and connect via the native (Reown) flow.
- /// Discovery is passive (no wallet popup) and does not touch IsAuthenticating.
- ///
- public void DiscoverEvmWallets(Action onResult)
- {
- #if UNITY_WEBGL && !UNITY_EDITOR
- _pendingEvmDiscoverForUi = onResult; // single slot, last-wins
- BlockmakerWalletBridge.EvmDiscoverWallets(
- gameObject.name,
- nameof(OnEvmWalletsDiscoveredForUi)
- );
- #else
- onResult?.Invoke("{\"wallets\":[],\"legacy\":false,\"native\":true}");
- #endif
- }
-
- ///
- /// Receiver for DiscoverEvmWallets (picker path) — deliberately separate from
- /// OnEvmWalletsDiscovered, which belongs to the ConnectEvm auto-connect flow
- /// and dispatches a connect on arrival. This one only relays the payload.
- ///
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnEvmWalletsDiscoveredForUi(string payload)
- {
- var cb = _pendingEvmDiscoverForUi;
- _pendingEvmDiscoverForUi = null;
- cb?.Invoke(payload);
- }
-
- ///
- /// Connect the SPECIFIC EVM wallet chosen in the picker — like ConnectEvm but
- /// skips discovery and passes rdns straight to the bridge ("" = auto-pick).
- /// A picked rdns that is no longer available FAILS (no silent fallback).
- /// onError always receives "code|message": code is the wallet's numeric
- /// EIP-1193 / JSON-RPC error code when it supplied one (e.g.
- /// "4001|User rejected the request.") and "" otherwise — including timeouts,
- /// busy-guard rejections and native-path errors, e.g. "|Connection timed out.
- /// Please try again.". On non-WebGL platforms this falls back to the
- /// ConnectEvm (Reown) behavior and rdns is ignored.
- ///
- public void ConnectEvmWallet(
- string rdns,
- Action onSuccess = null,
- Action onError = null)
- {
- if (IsAuthenticating || _pendingEvmSuccess != null || _pendingEvmError != null)
- {
- onError?.Invoke("|Another sign-in is already in progress. Please wait.");
- return;
- }
-
- IsAuthenticating = true;
- _pendingEvmSuccess = onSuccess;
- _pendingEvmError = onError;
- _evmConnectErrorsIncludeCode = true; // picker path: "code|message" errors
- // The user picked THIS wallet — a stale success echoing any other rdns
- // (an earlier cancelled attempt's still-open popup getting approved)
- // must be dropped, not logged in. Null = accept any (auto-pick paths).
- _evmExpectedRdns = string.IsNullOrEmpty(rdns) ? null : rdns;
-
- #if UNITY_WEBGL && !UNITY_EDITOR
- // Straight to connect — discovery already ran for the picker. Mark the
- // discover→connect hop as already done so a STALE discovery callback
- // (from an earlier cancelled ConnectEvm) can't dispatch a second
- // EvmConnect and race this one with a duplicate eth_requestAccounts popup.
- _evmConnectDispatched = true;
- BlockmakerWalletBridge.EvmConnect(
- rdns ?? "",
- gameObject.name,
- nameof(OnEvmConnected),
- nameof(OnEvmError)
- );
- StartWebGLTimeout(WalletSignTimeout, () =>
- {
- if (_pendingEvmSuccess != null || _pendingEvmError != null)
- OnEvmError("Connection timed out. Please try again.");
- });
- #else
- if (_connector == null || !_connector.IsInitialized)
- {
- IsAuthenticating = false;
- _pendingEvmSuccess = null;
- _pendingEvmError = null;
- onError?.Invoke("|Wallet connection is not ready yet. Please try again in a moment.");
- return;
- }
-
- // Native fallback = ConnectEvm behavior: Reown has no rdns concept, so the
- // chosen rdns is ignored and the same OnEvmConnected/OnEvmError funnel runs.
- _connector.ConnectEvm(
- evmAddr => OnEvmConnected(evmAddr),
- err => OnEvmError(err)
- );
- #endif
- }
-
- public void CancelEvmConnect()
- {
- if (_pendingEvmSuccess == null && _pendingEvmError == null) return;
- _connector?.CancelConnection();
- _pendingEvmSuccess = null;
- _pendingEvmError = null;
- IsAuthenticating = false;
- #if UNITY_WEBGL && !UNITY_EDITOR
- BlockmakerWalletBridge.CancelWalletQR();
- #endif
- }
-
- // rdns the CURRENT connect attempt expects the bridge to echo back; null =
- // accept any (auto-pick / native). Guards the cancel-then-repick race: wallet
- // A's still-open popup approved during attempt B must not become identity A.
- private string _evmExpectedRdns;
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnEvmConnected(string payload)
- {
- if (_pendingEvmSuccess == null && _pendingEvmError == null)
- return;
-
- // WebGL bridge payload is "rdns|0xEvmAddress" (rdns '' for the legacy
- // window.ethereum fallback); the native Reown connector sends a bare
- // "0x…" address. Split on the first '|' when present.
- string echoedRdns = null;
- var evmAddress = payload;
- int sep = payload?.IndexOf('|') ?? -1;
- if (sep >= 0)
- {
- echoedRdns = payload.Substring(0, sep);
- evmAddress = payload.Substring(sep + 1);
- }
-
- // Stale-success guard: this success is for a DIFFERENT wallet than the
- // current attempt picked — an earlier cancelled attempt's popup was
- // approved late. Ignore it (the current attempt keeps waiting on its own
- // callback); consuming it would log the player into the wrong wallet.
- if (_evmExpectedRdns != null && echoedRdns != null && echoedRdns != _evmExpectedRdns)
- {
- BlockmakerLog.Warning(
- $"[BlockmakerAuth] Ignoring stale EVM connect success from '{echoedRdns}' — the current attempt expects '{_evmExpectedRdns}'.");
- return;
- }
-
- CancelWebGLTimeout();
-
- // The Algorand LogicSig address is derived here in C# (byte-proven
- // against the on-chain LogicSig derivation).
- if (string.IsNullOrEmpty(evmAddress) ||
- !evmAddress.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
- {
- OnEvmError("Something went wrong during wallet connection. Please try again.");
- return;
- }
-
- try
- {
- var algoAddr = XChainAddressDeriver.DeriveAlgorandAddress(evmAddress);
- var prevTier = Tier;
- var identity = new EvmXChainIdentity(algoAddr, evmAddress);
- SetIdentity(identity);
- identity.SaveSession();
- TriggerWalletLogin(identity);
-
- if (prevTier < IdentityTier.SelfCustody)
- SafeInvoke(OnIdentityUpgraded, identity, prevTier);
-
- IsAuthenticating = false;
- _pendingEvmSuccess?.Invoke(identity);
- _pendingEvmSuccess = null;
- _pendingEvmError = null;
- }
- catch (Exception ex)
- {
- BlockmakerLog.Error($"[BlockmakerAuth] EVM connection error: {ex.Message}");
- OnEvmError("Something went wrong while connecting. Please try again.");
- }
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnEvmError(string error)
- {
- CancelWebGLTimeout();
- if (_pendingEvmSuccess == null && _pendingEvmError == null)
- return;
-
- // The jslib's EvmConnect errors arrive as "code|message" (numeric EIP-1193
- // code, or empty). Internal, timeout and native-connector errors are bare
- // messages. Normalize: humans (log + OnAuthError) always get the message
- // alone; the pending callback gets "code|message" on the picker path
- // (ConnectEvmWallet) and the bare message on the legacy ConnectEvm path.
- ParseEvmConnectError(error, out var code, out var message);
- BlockmakerLog.Error($"[BlockmakerAuth] EVM connect error: {(string.IsNullOrEmpty(code) ? message : code + " " + message)}");
- SafeInvoke(OnAuthError, message);
- _pendingEvmError?.Invoke(_evmConnectErrorsIncludeCode ? code + "|" + message : message);
- _pendingEvmError = null;
- _pendingEvmSuccess = null;
- IsAuthenticating = false;
- }
-
- ///
- /// Split a "code|message" EVM connect-error payload (the jslib EvmConnect
- /// error shape). The prefix before the FIRST '|' is accepted as the code only
- /// when it is empty or an integer (optionally negative — JSON-RPC codes like
- /// -32002); otherwise the '|' belonged to a bare message, which is returned
- /// whole with code "". Payloads without any '|' are bare messages too.
- ///
- private static void ParseEvmConnectError(string payload, out string code, out string message)
- {
- code = "";
- message = payload ?? "";
- if (string.IsNullOrEmpty(payload)) return;
-
- int pipe = payload.IndexOf('|');
- if (pipe < 0) return;
-
- var prefix = payload.Substring(0, pipe);
- if (prefix == "-") return; // a lone minus is not a code
- for (int i = 0; i < prefix.Length; i++)
- {
- char c = prefix[i];
- if (!(char.IsDigit(c) || (c == '-' && i == 0))) return;
- }
-
- code = prefix;
- message = payload.Substring(pipe + 1);
- }
-
- // ── Legacy email login (server-managed wallet) ────────────────────────────
-
- private Coroutine _otpRequestCoroutine;
-
- public void RequestEmailOTP(string email, Action onSent, Action onError)
- {
- if (IsAuthenticating)
- {
- onError?.Invoke("Another sign-in is already in progress. Please wait.");
- return;
- }
- if (_otpRequestCoroutine != null)
- {
- onError?.Invoke("A code request is already in progress. Please wait.");
- return;
- }
- if (BlockmakerClient.Instance == null)
- {
- onError?.Invoke("Unable to reach the server. Please check your connection and try again.");
- return;
- }
- _otpRequestCoroutine = StartCoroutine(RequestEmailOTPGuarded(email, onSent, onError));
- }
-
- private System.Collections.IEnumerator RequestEmailOTPGuarded(string email, Action onSent, Action onError)
- {
- try
- {
- yield return BlockmakerClient.Instance.RequestEmailOTP(email, onSent, onError);
- }
- finally
- {
- _otpRequestCoroutine = null;
- }
- }
-
- public void VerifyEmailOTP(
- string email,
- string otp,
- Action onSuccess,
- Action onError)
- {
- if (IsAuthenticating || _emailOtpCoroutine != null)
- {
- onError?.Invoke("Another sign-in is already in progress. Please wait.");
- return;
- }
- if (BlockmakerClient.Instance == null)
- {
- onError?.Invoke("Unable to reach the server. Please check your connection and try again.");
- return;
- }
-
- IsAuthenticating = true;
- _emailOtpCoroutine = StartCoroutine(VerifyEmailOTPRoutine(email, otp, onSuccess, onError));
- }
-
- private IEnumerator VerifyEmailOTPRoutine(
- string email,
- string otp,
- Action onSuccess,
- Action onError)
- {
- if (BlockmakerClient.Instance == null)
- {
- _emailOtpCoroutine = null;
- IsAuthenticating = false;
- onError?.Invoke("Unable to reach the server. Please check your connection and try again.");
- yield break;
- }
-
- EmailIdentity identity = null;
- string error = null;
-
- try
- {
- yield return BlockmakerClient.Instance.VerifyEmailOTP(
- email, otp,
- result => { identity = new EmailIdentity(email, result.walletAddress, result.sessionToken, result.refreshToken); },
- err => { error = err; }
- );
- }
- finally
- {
- _emailOtpCoroutine = null;
- IsAuthenticating = false;
- }
-
- if (error != null)
- {
- // Inline-only: the caller's onError renders the message ON the code-entry
- // page. Broadcasting OnAuthError here too made AuthPromptController close
- // the OTP page (HandleAuthError → ShowOptionsPage), so ONE typo ejected
- // the player from code entry — and re-requesting a code burns the
- // 3-per-10-min budget. A wrong code is a field-level error, not an
- // auth-flow failure.
- onError?.Invoke(error);
- yield break;
- }
-
- var prevTier = Tier;
- SetIdentity(identity);
- identity.SaveSession();
-
- if (prevTier < IdentityTier.Email)
- SafeInvoke(OnIdentityUpgraded, identity, prevTier);
-
- onSuccess?.Invoke(identity);
- }
-
- // ── Transaction signing (JS callbacks) ────────────────────────────────────
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnTxnSignedFromJS(string signedTxnBase64)
- {
- if (_pendingSignGeneration == _signGeneration)
- PendingSignedTxn = signedTxnBase64;
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnGroupTxnSignedFromJS(string signedTxnsJson)
- {
- if (_pendingSignGeneration != _signGeneration) return;
- try
- {
- var wrapper = JsonUtility.FromJson("{\"items\":" + signedTxnsJson + "}");
- if (wrapper?.items == null)
- {
- PendingSignError = "Your wallet did not return a signed transaction. Please try again.";
- return;
- }
- PendingSignedTxns = wrapper.items;
- }
- catch (Exception ex)
- {
- BlockmakerLog.Warning($"[BlockmakerAuth] Failed to parse group sign result: {ex.Message}");
- PendingSignError = "Something went wrong while processing the signed transactions. Please try again.";
- }
- }
-
- [EditorBrowsable(EditorBrowsableState.Never)]
- [Preserve]
- public void OnTxnErrorFromJS(string error)
- {
- if (_pendingSignGeneration == _signGeneration)
- PendingSignError = error;
- }
-
- // ── Logout ─────────────────────────────────────────────────────────────────
-
- public void Logout()
- {
- StopTokenRefreshTimer();
- _isRefreshing = false;
- CancelWebGLTimeout();
- BeginPendingSign();
- _signAwaiting = false; // logout invalidates any in-flight sign rather than awaiting it
-
- IsAuthenticating = false;
- _pendingConnectSuccess = null;
- _pendingConnectError = null;
- _pendingEvmSuccess = null;
- _pendingEvmError = null;
- _pendingEvmDiscoverForUi = null;
- _pendingMagicSuccess = null;
- _pendingMagicError = null;
- _isWalletConnecting = false;
- _walletLoginInFlight = false;
- // Invalidate + hard-stop any in-flight wallet-signature login: without this a
- // still-polling Login() could complete AFTER logout and write a fresh session
- // (UpdateTokens/SaveSession) for an identity the user just discarded.
- _walletLoginSeq++;
- if (_walletLoginCoroutine != null)
- {
- StopCoroutine(_walletLoginCoroutine);
- _walletLoginCoroutine = null;
- }
-
- if (_magicLoginCoroutine != null)
- {
- StopCoroutine(_magicLoginCoroutine);
- _magicLoginCoroutine = null;
- }
- if (_otpRequestCoroutine != null)
- {
- StopCoroutine(_otpRequestCoroutine);
- _otpRequestCoroutine = null;
- }
- if (_emailOtpCoroutine != null)
- {
- StopCoroutine(_emailOtpCoroutine);
- _emailOtpCoroutine = null;
- }
- if (_peraConnectCoroutine != null)
- {
- StopCoroutine(_peraConnectCoroutine);
- _peraConnectCoroutine = null;
- }
- CleanupWCv1();
-
- _connector?.CancelConnection();
-
- #if UNITY_WEBGL && !UNITY_EDITOR
- BlockmakerWalletBridge.CancelWalletQR();
- #endif
-
- string refreshToken = null;
- if (Identity is ServerSignedIdentity e) refreshToken = e.RefreshToken;
- else if (Identity is MagicIdentity m) refreshToken = m.RefreshToken;
- else if (Identity is WalletConnectIdentity wc) refreshToken = wc.RefreshToken;
- else if (Identity is EvmXChainIdentity evm) refreshToken = evm.RefreshToken;
- BlockmakerClient.Instance?.ServerLogout(refreshToken);
-
- Identity?.ClearSession();
- SetIdentity(new GuestIdentity());
- // Any in-flight boot restore is moot now — the auth state is KNOWN (guest).
- // Idempotent; prevents UI from waiting on a settle that will never come.
- SettleSessionRestore();
- BlockmakerLog.Info("[BlockmakerAuth] Logged out — reverted to guest.");
- }
-
- // ── Safe event helpers ─────────────────────────────────────────────────────
-
- private static void SafeInvoke(Action handler, T arg)
- {
- if (handler == null) return;
- foreach (var d in handler.GetInvocationList())
- {
- try { ((Action)d).Invoke(arg); }
- catch (Exception ex) { BlockmakerLog.Exception(ex); }
- }
- }
-
- private static void SafeInvoke(Action handler, T1 a, T2 b)
- {
- if (handler == null) return;
- foreach (var d in handler.GetInvocationList())
- {
- try { ((Action)d).Invoke(a, b); }
- catch (Exception ex) { BlockmakerLog.Exception(ex); }
- }
- }
-
- // ── Helpers ────────────────────────────────────────────────────────────────
-
- private void SetIdentity(IBlockmakerIdentity identity)
- {
- var oldProvider = Identity?.ProviderName;
- var oldAddress = Identity?.Address;
- var oldTier = Identity?.Tier ?? IdentityTier.Guest;
-
- if (Identity != null && Identity != identity && oldTier > IdentityTier.Guest)
- {
- if (oldProvider != identity.ProviderName ||
- oldAddress != identity.Address)
- Identity.ClearSession();
- }
- Identity = identity;
- IsAuthenticating = false;
- BlockmakerLog.Info($"[BlockmakerAuth] Identity set: {identity.ProviderName} | {identity.Address} | Tier: {identity.Tier}");
-
- if (identity is EmailIdentity || identity is MagicIdentity ||
- identity is WalletConnectIdentity || identity is EvmXChainIdentity)
- StartTokenRefreshTimer();
- else
- StopTokenRefreshTimer();
-
- SafeInvoke(OnIdentityChanged, identity);
-
- if (oldTier > IdentityTier.Guest &&
- identity.Tier > IdentityTier.Guest &&
- !string.IsNullOrEmpty(oldAddress) &&
- !string.IsNullOrEmpty(identity.Address) &&
- oldAddress != identity.Address)
- {
- SafeInvoke(OnWalletAddressChanged, new WalletAddressChangedEventArgs(oldProvider, oldAddress, identity.ProviderName, identity.Address));
- }
- }
-
- internal static void NotifyIdentityChanged(IBlockmakerIdentity identity)
- => SafeInvoke(OnIdentityChanged, identity);
-
- ///
- /// Acquire a player session token (JWT) for a self-custody wallet identity by
- /// running its wallet-signature login (challenge → sign → verify → store).
- /// No-op if the identity already holds a non-empty SessionToken. The login is
- /// fire-and-forget — game code can act on the wallet immediately; the token
- /// lands asynchronously and is then sent on player-authed requests.
- ///
- private void TriggerWalletLogin(IBlockmakerIdentity identity)
- {
- if (identity == null) return;
-
- // In-flight guard: the SessionToken check below is a no-op during the async login
- // window (token isn't set until Login()'s last line), so concurrent connect +
- // restore + reconnect events could each start a Login() — duplicate signature
- // prompts and torn SaveSession. _walletLoginInFlight closes that window.
- //
- // CRITICAL: set the flag SYNCHRONOUSLY here, before StartCoroutine. The flag used to
- // be set deep inside RunWalletLogin (after a possible frame of deferral), so two
- // TriggerWalletLogin calls in the SAME frame both passed this guard and both started
- // a full Login() → the wallet was prompted to sign twice. Setting it here makes the
- // second same-frame call early-return. RunWalletLogin now only OWNS/CLEARS the flag
- // (cleared on every exit), never sets it. Logout() also resets it.
- if (_walletLoginInFlight) return;
-
- if (identity is WalletConnectIdentity wc)
- {
- if (!string.IsNullOrEmpty(wc.SessionToken)) return;
- _walletLoginInFlight = true;
-
- // The connect approval is done; a SECOND approval (the login signature)
- // is about to arrive in the wallet app. Without this message users think
- // sign-in stalled — the request is easy to miss on a phone.
- SafeInvoke(OnAuthStatus,
- $"Connected! Now approve the sign-in request in your {wc.ProviderName} app…");
- _walletLoginCoroutine = StartCoroutine(RunWalletLogin(wc, ++_walletLoginSeq));
- }
- else if (identity is EvmXChainIdentity evm)
- {
- if (!string.IsNullOrEmpty(evm.SessionToken)) return;
- _walletLoginInFlight = true;
- _walletLoginCoroutine = StartCoroutine(RunWalletLogin(evm, ++_walletLoginSeq));
- }
- }
-
- ///
- /// Runs a wallet-signature login, deferring it while a user-initiated WebGL sign is
- /// in flight so the two signs (which share OnTxnSignedFromJS / the pending-sign slots)
- /// don't race — the generation guard would otherwise make one die "interrupted".
- /// The auto-login is the deferrer, so the user's sign always wins and the login simply
- /// runs afterwards (and is retryable on the next trigger if it never gets a clear slot).
- ///
- private IEnumerator RunWalletLogin(IBlockmakerIdentity identity, int seq)
- {
- // _walletLoginInFlight was set SYNCHRONOUSLY by TriggerWalletLogin before this
- // coroutine started. This coroutine OWNS the flag from here on: it never sets it,
- // and it must CLEAR it on EVERY exit path (early yield breaks below, plus the
- // success/error callbacks). Otherwise a future TriggerWalletLogin would be stuck.
- //
- // `seq` is this attempt's id (captured from _walletLoginSeq at start). If it stops
- // matching, RetryWalletLogin/CancelWalletLogin/Logout superseded this attempt: the
- // flag now belongs to a NEWER attempt (or was deliberately cleared), so a stale
- // attempt must exit without touching the flag and its late callbacks are ignored.
-
- // Defer briefly if a sign is already in flight (connect or a user txn sign).
- // Bounded so a stuck sign can't pin the auto-login forever; if it never clears,
- // we abort and rely on the next TriggerWalletLogin / proactive refresh.
- float waited = 0f;
- while ((_isWalletConnecting || IsWebGLSignInFlight) && waited < WalletSignTimeout)
- {
- if (_walletLoginSeq != seq) yield break; // superseded by retry/cancel/logout
- waited += Time.unscaledDeltaTime;
- yield return null;
- }
- if (_walletLoginSeq != seq) yield break; // superseded by retry/cancel/logout
- if (_isWalletConnecting || IsWebGLSignInFlight)
- {
- BlockmakerLog.Info("[BlockmakerAuth] Auto wallet sign-in deferred — a sign is still in flight; will retry on next trigger.");
- _walletLoginInFlight = false;
- yield break;
- }
-
- // Re-check guards after the wait (identity may have changed or the token may have
- // arrived while we yielded). We still own the flag, so clear it before bailing.
- if (Identity != identity) { _walletLoginInFlight = false; yield break; }
-
- string existingToken = null;
- if (identity is WalletConnectIdentity wcCheck) existingToken = wcCheck.SessionToken;
- else if (identity is EvmXChainIdentity evmCheck) existingToken = evmCheck.SessionToken;
- if (!string.IsNullOrEmpty(existingToken)) { _walletLoginInFlight = false; yield break; }
-
- // try/finally (no catch — legal around `yield` in an iterator) so the flag is
- // ALWAYS cleared, even if Login() throws mid-yield. The per-callback clears stay as
- // the fast path; the finally is the backstop. Idempotent bool write, so a double
- // clear is harmless. Without this, an unhandled exception in the yield region would
- // skip the defensive clear and pin _walletLoginInFlight until Logout().
- try
- {
- if (identity is WalletConnectIdentity wc)
- {
- yield return wc.Login(
- onSuccess: () =>
- {
- if (_walletLoginSeq != seq) { BlockmakerLog.Info($"[BlockmakerAuth] Ignoring stale wallet sign-in success for {wc.ProviderName} (superseded by retry/cancel)."); return; }
- _walletLoginInFlight = false;
- if (Identity == wc) SafeInvoke(OnIdentityChanged, wc);
- },
- onError: err =>
- {
- if (_walletLoginSeq != seq) { BlockmakerLog.Info($"[BlockmakerAuth] Ignoring stale wallet sign-in error for {wc.ProviderName} (superseded by retry/cancel): {err}"); return; }
- _walletLoginInFlight = false;
- BlockmakerLog.Warning($"[BlockmakerAuth] Wallet sign-in failed for {wc.ProviderName}: {err}");
- // Tell the step-2 panel — a declined/failed sign-in signature
- // previously only logged, leaving the panel silently pulsing
- // "approve the request" with no hint anything went wrong.
- SafeInvoke(OnAuthStatus,
- "Sign-in request was declined or failed — use RESEND to try again, or CANCEL.");
- });
- }
- else if (identity is EvmXChainIdentity evm)
- {
- yield return evm.Login(
- onSuccess: () =>
- {
- if (_walletLoginSeq != seq) { BlockmakerLog.Info("[BlockmakerAuth] Ignoring stale wallet sign-in success for EVM xChain (superseded by retry/cancel)."); return; }
- _walletLoginInFlight = false;
- if (Identity == evm) SafeInvoke(OnIdentityChanged, evm);
- },
- onError: err =>
- {
- if (_walletLoginSeq != seq) { BlockmakerLog.Info($"[BlockmakerAuth] Ignoring stale wallet sign-in error for EVM xChain (superseded by retry/cancel): {err}"); return; }
- _walletLoginInFlight = false;
- BlockmakerLog.Warning($"[BlockmakerAuth] Wallet sign-in failed for EVM xChain: {err}");
- SafeInvoke(OnAuthStatus,
- "Sign-in request was declined or failed — use RESEND to try again, or CANCEL.");
- });
- }
- else
- {
- _walletLoginInFlight = false;
- }
- }
- finally
- {
- // Defensive backstop: every Login() exit path invokes a callback that clears the
- // flag, but guarantee it's cleared even on an exception or a future Login refactor
- // that returns without one. Seq-guarded: this finally also runs when retry/cancel
- // StopCoroutine()s this attempt (Unity disposes the iterator), and a superseded
- // attempt must NOT clear the flag the newer attempt now owns.
- if (_walletLoginSeq == seq)
- {
- _walletLoginInFlight = false;
- _walletLoginCoroutine = null;
- }
- }
- }
-
- // ── Wallet-login retry / cancel (the "stuck on approval 2 of 2" escape hatch) ──
-
- ///
- /// True when the current identity is a self-custody wallet that has connected
- /// (approval 1) but not yet completed the login signature (approval 2) — i.e. it
- /// holds no session token. This is the state where the step-2 panel is shown and
- /// / are meaningful.
- /// Intentionally true even while a login attempt is in flight: the whole point of
- /// retry is to replace an attempt whose wallet request expired or was missed.
- ///
- public static bool CanRetryWalletLogin
- {
- get
- {
- var id = Instance?.Identity;
- if (id is WalletConnectIdentity wc) return string.IsNullOrEmpty(wc.SessionToken);
- if (id is EvmXChainIdentity evm) return string.IsNullOrEmpty(evm.SessionToken);
- return false;
- }
- }
-
- ///
- /// Abandon any in-flight wallet-signature login attempt and start a fresh one for
- /// the current identity: a new challenge is requested and a NEW sign request is
- /// pushed to the wallet app (the old one may have expired or been dismissed).
- /// Re-fires so the UI can show "approve the request…"
- /// feedback again. Safe no-op (log only) when is false.
- ///
- public void RetryWalletLogin()
- {
- if (!CanRetryWalletLogin)
- {
- BlockmakerLog.Info("[BlockmakerAuth] RetryWalletLogin ignored — no tokenless wallet identity to retry.");
- return;
- }
-
- BlockmakerLog.Info("[BlockmakerAuth] Retrying wallet sign-in — abandoning the previous attempt and sending a fresh request.");
- AbandonWalletLoginAttempt();
-
- // Fresh attempt. For WalletConnect identities TriggerWalletLogin itself fires the
- // "Connected! Now approve the sign-in request…" OnAuthStatus message; EVM xChain
- // has no message in the trigger path, so give the UI equivalent feedback here.
- if (Identity is EvmXChainIdentity)
- SafeInvoke(OnAuthStatus, "Approve the sign-in request in your wallet…");
- TriggerWalletLogin(Identity);
- }
-
- ///
- /// Abort the wallet login-signature phase entirely: invalidate any in-flight attempt's
- /// callbacks, clear the guards, and back to a clean guest state.
- /// A tokenless wallet identity can't call player-authed endpoints, so keeping it
- /// half-connected only causes confusion — OnIdentityChanged fires via Logout as usual.
- ///
- public void CancelWalletLogin()
- {
- BlockmakerLog.Info("[BlockmakerAuth] Wallet sign-in cancelled — aborting the login attempt and logging out.");
- AbandonWalletLoginAttempt();
- Logout();
- }
-
- ///
- /// Invalidate the in-flight wallet-login attempt (if any) so it can neither complete
- /// nor clear the guards out from under a successor: bumps the attempt seq (late
- /// callbacks become stale no-ops), hard-stops the RunWalletLogin coroutine (which also
- /// tears down the nested Login() wait), clears the in-flight flag, and frees the shared
- /// WebGL pending-sign slots (same idiom as Logout) — bumping the sign generation makes
- /// a zombie JS sign wait exit, and clearing the awaiting bit stops the next attempt
- /// from deferring behind the dead sign for a full WalletSignTimeout.
- ///
- private void AbandonWalletLoginAttempt()
- {
- _walletLoginSeq++;
- if (_walletLoginCoroutine != null)
- {
- StopCoroutine(_walletLoginCoroutine);
- _walletLoginCoroutine = null;
- }
- _walletLoginInFlight = false;
-
- BeginPendingSign();
- _signAwaiting = false; // invalidate, don't await, the abandoned sign
- }
-
- private static IBlockmakerIdentity CreateWalletIdentity(string provider, string address)
- {
- if (provider.Equals(ProviderDefly, StringComparison.OrdinalIgnoreCase))
- return new DeflyIdentity(address);
- if (provider.Equals(ProviderPera, StringComparison.OrdinalIgnoreCase))
- return new PeraIdentity(address);
-
- BlockmakerLog.Warning($"[BlockmakerAuth] Unknown provider '{provider}', defaulting to Pera.");
- return new PeraIdentity(address);
- }
- }
-
- public static class BlockmakerPrefs
- {
- private static string _prefix;
-
- public static string Prefix
- {
- get
- {
- if (_prefix == null)
- {
- var config = BlockmakerAuth.Instance?.blockmakerConfig;
- string id = config != null ? config.gameId : "";
- if (string.IsNullOrEmpty(id))
- id = Application.identifier ?? "default";
- _prefix = $"bm_{id}_";
- }
- return _prefix;
- }
- }
-
- public static string Key(string baseName) => Prefix + baseName;
-
- public static void InvalidatePrefix() => _prefix = null;
- }
-
- [Serializable]
- internal class StringArrayWrapper
- {
- public string[] items;
- }
-
- public readonly struct WalletQREventArgs
- {
- public string Provider { get; }
- public string WalletConnectUri { get; }
- public string QRCodeBase64Png { get; }
-
- public WalletQREventArgs(string provider, string walletConnectUri, string qrCodeBase64Png)
- {
- Provider = provider;
- WalletConnectUri = walletConnectUri;
- QRCodeBase64Png = qrCodeBase64Png;
- }
- }
-
- public readonly struct WalletAddressChangedEventArgs
- {
- public string OldProvider { get; }
- public string OldAddress { get; }
- public string NewProvider { get; }
- public string NewAddress { get; }
-
- public WalletAddressChangedEventArgs(string oldProvider, string oldAddress, string newProvider, string newAddress)
- {
- OldProvider = oldProvider;
- OldAddress = oldAddress;
- NewProvider = newProvider;
- NewAddress = newAddress;
- }
- }
-
-}
\ No newline at end of file
diff --git a/Core/BlockmakerAuth.cs.meta b/Core/BlockmakerAuth.cs.meta
deleted file mode 100644
index b0d3474..0000000
--- a/Core/BlockmakerAuth.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 1e6cbb4e8034b4081aa006f2461b9047
\ No newline at end of file
diff --git a/Core/BlockmakerClient.cs b/Core/BlockmakerClient.cs
deleted file mode 100644
index 7152fb3..0000000
--- a/Core/BlockmakerClient.cs
+++ /dev/null
@@ -1,1171 +0,0 @@
-using System;
-using System.Collections;
-using System.Text;
-using UnityEngine;
-using UnityEngine.Networking;
-
-namespace Blockmaker
-{
-
- ///
- /// HTTP client for the Blockmaker server.
- /// All game systems call this — it knows nothing about which wallet or
- /// auth provider is active.
- ///
- /// Auth identity is read from BlockmakerAuth.Instance.Identity automatically
- /// for every request that needs a wallet address.
- ///
- public partial class BlockmakerClient : MonoBehaviour
- {
- public static BlockmakerClient Instance { get; private set; }
-
- [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
- static void ResetStatics()
- {
- Instance = null;
- }
-
- [Header("Config")]
- public BlockmakerConfig config;
-
- private string _baseUrl;
-
- private void Awake()
- {
- if (Instance != null && Instance != this) { Destroy(gameObject); return; }
- Instance = this;
- DontDestroyOnLoad(gameObject);
-
- if (config == null)
- {
- // Config may be assigned post-Awake by BlockmakerAuth.EnsureBlockmakerClient()
- // via InitFromAuth(). Don't destroy — just disable until config arrives.
- BlockmakerLog.Verbose("[BlockmakerClient] No BlockmakerConfig assigned yet — waiting for BlockmakerAuth.InitFromAuth().");
- Instance = null;
- enabled = false;
- return;
- }
- var url = config.serverUrl;
- if (string.IsNullOrEmpty(url))
- url = BlockmakerConfig.DefaultServerUrl;
- _baseUrl = url.TrimEnd('/');
-
- }
-
- private void OnDestroy()
- {
- if (Instance == this)
- Instance = null;
- }
-
- ///
- /// Re-initializes after config was set post-Awake (e.g. when added via AddComponent at runtime).
- /// Called by BlockmakerAuth.EnsureBlockmakerClient().
- ///
- public void InitFromAuth()
- {
- if (config == null) return;
- var url = config.serverUrl;
- if (string.IsNullOrEmpty(url))
- url = BlockmakerConfig.DefaultServerUrl;
- enabled = true;
- if (Instance == null) Instance = this;
- _baseUrl = url.TrimEnd('/');
- BlockmakerLog.Info($"[BlockmakerClient] Initialized — server: {_baseUrl}");
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // FLOW RUNNER
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// Run a flow against the current identity's address.
- public void RunFlow(
- string flowId,
- Action onSuccess,
- Action onError = null,
- string contextJson = null)
- {
- var auth = BlockmakerAuth.Instance;
- if (auth == null || !auth.HasWallet)
- {
- onError?.Invoke("No wallet connected. Please connect a wallet or sign in first.");
- return;
- }
- StartCoroutine(RunFlowRoutine(flowId, auth.Address, contextJson, onSuccess, onError));
- }
-
- /// Run a flow with a custom result type (for game-specific flow data).
- public void RunFlow(
- string flowId,
- Action onSuccess,
- Action onError = null,
- string contextJson = null) where T : class
- {
- var auth = BlockmakerAuth.Instance;
- if (auth == null || !auth.HasWallet)
- {
- onError?.Invoke("No wallet connected. Please connect a wallet or sign in first.");
- return;
- }
- StartCoroutine(RunFlowRoutine(flowId, auth.Address, contextJson, onSuccess, onError));
- }
-
- /// Run a flow against an explicit wallet address.
- public void RunFlowForWallet(
- string flowId,
- string walletAddress,
- Action onSuccess,
- Action onError = null,
- string contextJson = null)
- {
- if (_baseUrl == null) { onError?.Invoke("Something went wrong. Please restart the game and try again."); return; }
- StartCoroutine(RunFlowRoutine(flowId, walletAddress, contextJson, onSuccess, onError));
- }
-
- private IEnumerator RunFlowRoutine(
- string flowId, string walletAddress, string contextJson,
- Action onSuccess, Action onError) where T : class
- {
- string url = $"{_baseUrl}/v1/flows/{flowId}/run";
- string body = JsonUtility.ToJson(new FlowRunRequest
- { wallet = walletAddress, context = contextJson });
-
- using var req = BuildPost(url, body, config.defaultTimeoutSeconds);
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // EMAIL AUTH
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// Ask the server to send an OTP to the given email.
- public IEnumerator RequestEmailOTP(string email, Action onSent, Action onError)
- {
- string url = $"{_baseUrl}/v1/auth/email/request";
- string body = JsonUtility.ToJson(new EmailOTPRequest { email = email });
-
- using var req = BuildPost(url, body, config.defaultTimeoutSeconds);
- yield return req.SendWebRequest();
-
- if (req.result != UnityWebRequest.Result.Success)
- {
- string err = "Something went wrong. Please check your connection and try again.";
- try
- {
- var respBody = req.downloadHandler?.text;
- if (!string.IsNullOrEmpty(respBody))
- {
- var parsed = JsonUtility.FromJson(respBody);
- if (!string.IsNullOrEmpty(parsed.error))
- {
- BlockmakerLog.Verbose($"[BlockmakerClient] Server error: {parsed.error}");
- err = parsed.error;
- }
- }
- }
- catch (Exception parseEx) { BlockmakerLog.Warning($"[BlockmakerClient] Error response parse failed: {parseEx.Message}"); }
- BlockmakerLog.Error($"[BlockmakerClient] Email OTP HTTP {req.responseCode}: {req.error}");
- onError?.Invoke(err);
- }
- else
- onSent?.Invoke();
- }
-
- /// Verify an OTP and receive a session token + managed wallet address.
- public IEnumerator VerifyEmailOTP(
- string email, string otp,
- Action onSuccess,
- Action onError)
- {
- string url = $"{_baseUrl}/v1/auth/email/verify";
- string body = JsonUtility.ToJson(new EmailVerifyRequest { email = email, otp = otp });
-
- using var req = BuildPost(url, body, config.defaultTimeoutSeconds);
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // MAGIC AUTH
- // ═══════════════════════════════════════════════════════════════════════════
-
- ///
- /// Send Magic's DID token to the server for verification.
- /// Server verifies the token with Magic's admin SDK, creates or finds
- /// the player account, and returns a JWT + Algorand address.
- ///
- public IEnumerator VerifyMagicToken(
- string didToken,
- string email,
- Action onSuccess,
- Action onError)
- {
- string url = $"{_baseUrl}/v1/auth/magic/verify";
- string body = JsonUtility.ToJson(new MagicVerifyRequest { didToken = didToken, email = email });
-
- using var req = BuildPost(url, body, config.defaultTimeoutSeconds);
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // WALLET-SIGNATURE AUTH (self-custody tier)
- // ═══════════════════════════════════════════════════════════════════════════
-
- ///
- /// Ask the server for a single-use challenge to sign with a self-custody wallet.
- /// chain is "algorand" (Pera/Defly) or "evm" (xChain); pass the EVM signer
- /// address for the "evm" path (null for "algorand").
- ///
- public IEnumerator RequestWalletChallenge(
- string walletAddress, string chain, string evmAddress,
- Action onSuccess,
- Action onError)
- {
- string url = $"{_baseUrl}/v1/auth/wallet/challenge";
- string body = JsonUtility.ToJson(new WalletChallengeRequest
- { walletAddress = walletAddress, chain = chain, evmAddress = evmAddress });
-
- using var req = BuildPost(url, body, config.defaultTimeoutSeconds);
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- ///
- /// Submit a wallet proof-of-ownership and receive a player session token +
- /// refresh token (same shape as email/magic verify).
- /// algorand (Pera/Defly): pass (base64 of the
- /// signed 0-amount self-payment whose note == nonce) and null for
- /// . evm (xChain): pass the personal_sign
- /// hex and null for .
- ///
- public IEnumerator VerifyWalletSignature(
- string walletAddress, string chain, string signature, string signedTxn, string nonce, string evmAddress,
- Action onSuccess,
- Action onError)
- {
- string url = $"{_baseUrl}/v1/auth/wallet/verify";
- string body = JsonUtility.ToJson(new WalletVerifyRequest
- { walletAddress = walletAddress, chain = chain, signature = signature, signedTxn = signedTxn, nonce = nonce, evmAddress = evmAddress });
-
- using var req = BuildPost(url, body, config.defaultTimeoutSeconds);
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // SERVER-SIDE SIGNING (Email tier)
- // ═══════════════════════════════════════════════════════════════════════════
-
- ///
- /// Ask the Blockmaker server to sign a transaction using the player's
- /// managed wallet. Only valid for Email tier identities.
- ///
- public IEnumerator SignTransactionServerSide(
- string unsignedTxnBase64,
- string sessionToken,
- Action onSigned,
- Action onError,
- Action onBlockmakerError = null)
- {
- string url = $"{_baseUrl}/v1/auth/sign";
- string body = JsonUtility.ToJson(new ServerSignRequest
- { unsignedTxnBase64 = unsignedTxnBase64 });
-
- using var req = new UnityWebRequest(url, "POST")
- {
- uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)),
- downloadHandler = new DownloadHandlerBuffer(),
- timeout = SafeTimeout(config.longRequestTimeoutSeconds)
- };
- req.SetRequestHeader("Content-Type", "application/json");
- req.SetRequestHeader("Authorization", $"Bearer {sessionToken}");
-
- yield return req.SendWebRequest();
-
- if (req.result != UnityWebRequest.Result.Success)
- {
- string err = "Something went wrong. Please try again.";
- string code = "";
- try
- {
- var respBody = req.downloadHandler?.text;
- if (!string.IsNullOrEmpty(respBody))
- {
- var parsed = JsonUtility.FromJson(respBody);
- if (!string.IsNullOrEmpty(parsed.error))
- {
- BlockmakerLog.Verbose($"[BlockmakerClient] Server error: {parsed.error}");
- err = parsed.error;
- }
- code = parsed.code ?? "";
- }
- }
- catch (Exception parseEx) { BlockmakerLog.Warning($"[BlockmakerClient] Error response parse failed: {parseEx.Message}"); }
- BlockmakerLog.Error($"[BlockmakerClient] Sign HTTP {req.responseCode}: {req.error}");
- onBlockmakerError?.Invoke(new BlockmakerError(code, err, (int)req.responseCode));
- onError?.Invoke(err);
- yield break;
- }
-
- try
- {
- var result = JsonUtility.FromJson(req.downloadHandler.text);
- onSigned?.Invoke(result.signedTxnBase64);
- }
- catch (Exception e)
- {
- BlockmakerLog.Error($"[BlockmakerClient] Sign parse error: {e.Message}");
- onError?.Invoke("Something went wrong. Please try again.");
- }
- }
-
- public IEnumerator SignTransactionsServerSide(
- string[] unsignedTxnsBase64,
- string sessionToken,
- Action onSigned,
- Action onError,
- Action onBlockmakerError = null)
- {
- string url = $"{_baseUrl}/v1/auth/sign";
- string body = JsonUtility.ToJson(new ServerSignRequest
- { unsignedTxnsBase64 = unsignedTxnsBase64 });
-
- using var req = new UnityWebRequest(url, "POST")
- {
- uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)),
- downloadHandler = new DownloadHandlerBuffer(),
- timeout = SafeTimeout(config.longRequestTimeoutSeconds)
- };
- req.SetRequestHeader("Content-Type", "application/json");
- req.SetRequestHeader("Authorization", $"Bearer {sessionToken}");
-
- yield return req.SendWebRequest();
-
- if (req.result != UnityWebRequest.Result.Success)
- {
- string err = "Something went wrong. Please try again.";
- string code = "";
- try
- {
- var respBody = req.downloadHandler?.text;
- if (!string.IsNullOrEmpty(respBody))
- {
- var parsed = JsonUtility.FromJson(respBody);
- if (!string.IsNullOrEmpty(parsed.error))
- {
- BlockmakerLog.Verbose($"[BlockmakerClient] Server error: {parsed.error}");
- err = parsed.error;
- }
- code = parsed.code ?? "";
- }
- }
- catch (Exception parseEx) { BlockmakerLog.Warning($"[BlockmakerClient] Error response parse failed: {parseEx.Message}"); }
- BlockmakerLog.Error($"[BlockmakerClient] Sign HTTP {req.responseCode}: {req.error}");
- onBlockmakerError?.Invoke(new BlockmakerError(code, err, (int)req.responseCode));
- onError?.Invoke(err);
- yield break;
- }
-
- try
- {
- var result = JsonUtility.FromJson(req.downloadHandler.text);
- onSigned?.Invoke(result.signedTxnsBase64);
- }
- catch (Exception e)
- {
- BlockmakerLog.Error($"[BlockmakerClient] Sign parse error: {e.Message}");
- onError?.Invoke("Something went wrong. Please try again.");
- }
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // REWARDS
- // ═══════════════════════════════════════════════════════════════════════════
-
- ///
- /// Send a reward from the game treasury wallet.
- /// Only available in the Unity Editor — never in player builds.
- /// A player who extracts the API key from a build can call the rewards
- /// endpoint directly and drain your treasury.
- /// For production, call the rewards endpoint from your own trusted server.
- ///
- ///
- /// Idempotency key. Pass a STABLE id you own for this logical reward (e.g.
- /// "{raceId}:{wallet}:{reason}") and reuse the SAME value on any retry — the
- /// server then dedups a retried send and never double-pays. Leave null and the
- /// SDK mints a fresh key per call (protects only this call, not a caller-level retry).
- ///
- public void SendReward(
- string recipientWallet,
- long amountMicroAlgo,
- string reason = "reward",
- long assetId = 0,
- string contextId = null,
- Action onSuccess = null,
- Action onError = null)
- {
- #if !UNITY_EDITOR
- BlockmakerLog.Error("[Blockmaker] SendReward is disabled in player builds — use a trusted server to send rewards.");
- onError?.Invoke("This action is not available right now.");
- return;
- #else
- StartCoroutine(PostJson(
- $"{_baseUrl}/v1/rewards/send",
- new RewardRequest
- {
- recipientWallet = recipientWallet,
- assetId = assetId,
- amountMicroAlgo = amountMicroAlgo,
- reason = reason,
- // Stable key dedups retries; mint one if the caller didn't supply it.
- contextId = string.IsNullOrEmpty(contextId)
- ? System.Guid.NewGuid().ToString("N")
- : contextId
- },
- config.longRequestTimeoutSeconds,
- onSuccess, onError
- ));
- #endif
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // GAME-SPECIFIC ENDPOINTS
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// POST JSON to a server path. Use for game-specific endpoints.
- public void Post(string path, TReq body, Action onSuccess = null, Action onError = null) where TRes : class
- {
- Post(path, body, config.defaultTimeoutSeconds, onSuccess, onError);
- }
-
- ///
- /// POST JSON to a server path with an explicit timeout. Use a longer timeout (e.g.
- /// config.walletTimeoutSeconds) for endpoints that scan a whole wallet — large
- /// wallets can take well over the 10s default to enumerate on-chain.
- ///
- public void Post(string path, TReq body, float timeoutSeconds, Action onSuccess = null, Action onError = null) where TRes : class
- {
- StartCoroutine(PostJsonAuth(
- $"{_baseUrl}{path}", body,
- timeoutSeconds,
- onSuccess, onError
- ));
- }
-
- /// GET JSON from a server path (auto-appends wallet). Use for game-specific endpoints.
- public void Get(string path, Action onSuccess, Action onError = null) where TRes : class
- {
- string wallet = UnityWebRequest.EscapeURL(BlockmakerAuth.Instance?.Address ?? "");
- string sep = path.Contains("?") ? "&" : "?";
- StartCoroutine(GetJson(
- $"{_baseUrl}{path}{sep}wallet={wallet}",
- config.defaultTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // TRANSACTION BUILDER
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// Build an unsigned payment transaction via the server.
- public void BuildPayment(
- string recipient, long amountMicroAlgo, string note = null,
- Action onSuccess = null, Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- $"{_baseUrl}/v1/transactions/build",
- new BuildTransactionRequest { type = "payment", recipient = recipient, amount = amountMicroAlgo, note = note ?? "", walletAddress = BlockmakerAuth.Instance?.Address ?? "" },
- config.defaultTimeoutSeconds, onSuccess, onError));
- }
-
- /// Build an unsigned ASA transfer transaction via the server.
- public void BuildAssetTransfer(
- string recipient, long assetId, long amount, string note = null,
- Action onSuccess = null, Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- $"{_baseUrl}/v1/transactions/build",
- new BuildTransactionRequest { type = "asset_transfer", recipient = recipient, amount = amount, assetId = assetId, note = note ?? "", walletAddress = BlockmakerAuth.Instance?.Address ?? "" },
- config.defaultTimeoutSeconds, onSuccess, onError));
- }
-
- /// Build an unsigned ASA opt-in transaction (0-amount self-transfer).
- public void BuildAssetOptIn(
- long assetId,
- Action onSuccess = null, Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- $"{_baseUrl}/v1/transactions/build",
- new BuildTransactionRequest { type = "asset_optin", assetId = assetId, walletAddress = BlockmakerAuth.Instance?.Address ?? "" },
- config.defaultTimeoutSeconds, onSuccess, onError));
- }
-
- /// Submit a signed transaction to the Algorand network via the server.
- public void SubmitTransaction(
- string signedTxnBase64,
- Action onSuccess = null, Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- $"{_baseUrl}/v1/transactions/submit",
- new SubmitTransactionRequest { signedTxnBase64 = signedTxnBase64 },
- config.longRequestTimeoutSeconds, onSuccess, onError));
- }
-
- /// Submit signed group transactions to the Algorand network via the server.
- public void SubmitTransactions(
- string[] signedTxnsBase64,
- Action onSuccess = null, Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- $"{_baseUrl}/v1/transactions/submit",
- new SubmitTransactionRequest { signedTxnsBase64 = signedTxnsBase64 },
- config.longRequestTimeoutSeconds, onSuccess, onError));
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // SERVER HEALTH
- // ═══════════════════════════════════════════════════════════════════════════
-
- public void VerifyConnection(Action onResult)
- {
- StartCoroutine(VerifyConnectionRoutine(onResult));
- }
-
- private IEnumerator VerifyConnectionRoutine(Action onResult)
- {
- using var req = BuildGet($"{_baseUrl}/v1/me", config.defaultTimeoutSeconds);
- yield return req.SendWebRequest();
- onResult?.Invoke(req.result == UnityWebRequest.Result.Success);
- }
-
- ///
- /// Verify a specific JWT session token against the server.
- /// Used during Magic session restore to confirm the server-side JWT
- /// is still valid even when the Magic JS session has expired.
- ///
- public void VerifySessionToken(string sessionToken, Action onResult)
- {
- StartCoroutine(VerifySessionTokenRoutine(sessionToken, onResult));
- }
-
- private IEnumerator VerifySessionTokenRoutine(string sessionToken, Action onResult)
- {
- using var req = new UnityWebRequest($"{_baseUrl}/v1/auth/session", "GET")
- {
- downloadHandler = new DownloadHandlerBuffer(),
- timeout = SafeTimeout(config.defaultTimeoutSeconds)
- };
- req.SetRequestHeader("Authorization", $"Bearer {sessionToken}");
- yield return req.SendWebRequest();
- onResult?.Invoke(req.result == UnityWebRequest.Result.Success);
- }
-
- ///
- /// Exchange a refresh token for a new JWT + rotated refresh token.
- /// Call on session restore to get a fresh short-lived JWT.
- ///
- public void RefreshToken(string refreshToken, Action onSuccess, Action onError = null)
- {
- StartCoroutine(RefreshTokenRoutine(refreshToken, onSuccess, onError));
- }
-
- private IEnumerator RefreshTokenRoutine(string refreshToken, Action onSuccess, Action onError)
- {
- var body = JsonUtility.ToJson(new RefreshTokenRequest { refreshToken = refreshToken });
- using var req = new UnityWebRequest($"{_baseUrl}/v1/auth/refresh", "POST")
- {
- uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)),
- downloadHandler = new DownloadHandlerBuffer(),
- timeout = SafeTimeout(config.defaultTimeoutSeconds)
- };
- req.SetRequestHeader("Content-Type", "application/json");
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- ///
- /// Invalidate a refresh token on the server (logout).
- /// Fire-and-forget — does not report errors.
- ///
- public void ServerLogout(string refreshToken)
- {
- if (string.IsNullOrEmpty(refreshToken)) return;
- StartCoroutine(ServerLogoutRoutine(refreshToken));
- }
-
- private IEnumerator ServerLogoutRoutine(string refreshToken)
- {
- var body = JsonUtility.ToJson(new LogoutRequest { refreshToken = refreshToken });
- using var req = new UnityWebRequest($"{_baseUrl}/v1/auth/logout", "POST")
- {
- uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)),
- downloadHandler = new DownloadHandlerBuffer(),
- timeout = SafeTimeout(config.defaultTimeoutSeconds)
- };
- req.SetRequestHeader("Content-Type", "application/json");
- yield return req.SendWebRequest();
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- private string ProfileUrl(string path)
- {
- string url = $"{_baseUrl}{path}";
- string wallet = BlockmakerAuth.Instance?.Address;
- if (!string.IsNullOrEmpty(wallet))
- url += (url.Contains("?") ? "&" : "?") + $"wallet={UnityWebRequest.EscapeURL(wallet)}";
- return url;
- }
-
- // PROFILE
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// Fetch the current player's profile + game data.
- public void GetProfile(Action onSuccess, Action onError = null)
- {
- StartCoroutine(GetJson(
- ProfileUrl("/v1/profile"),
- config.defaultTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- /// Fetch all ASA holdings for the authenticated wallet.
- public void GetHoldings(Action onSuccess, Action onError = null)
- {
- StartCoroutine(GetJson(
- ProfileUrl("/v1/profile/holdings"),
- config.defaultTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- /// Check if the wallet holds NFTs from the given creator addresses.
- public void CheckCollections(
- string[] creators,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/collection-check"),
- new CollectionCheckRequest { creators = creators },
- config.defaultTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- /// Fetch the full asset registry (NFT collections and tokens).
- public void GetRegistry(Action onSuccess, Action onError = null)
- {
- StartCoroutine(GetJson(
- $"{_baseUrl}/v1/profile/registry",
- config.defaultTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- /// Check if a username is available. Set isChange=true for change flow pricing.
- public void CheckUsername(
- string username,
- Action onSuccess,
- Action onError = null,
- bool isChange = false)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/username/check"),
- new UsernameCheckRequest
- {
- username = username,
- walletAddress = BlockmakerAuth.Instance?.Address,
- isChange = isChange
- },
- config.defaultTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- ///
- /// Step 1 of username claim: server validates, reserves the username,
- /// pins ARC-3 metadata to IPFS, and returns an unsigned AssetCreateTxn.
- ///
- public void PrepareUsernameClaim(
- string username,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/username/claim/prepare"),
- new UsernameClaimPrepareRequest { username = username },
- config.longRequestTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- ///
- /// Step 2 of username claim: send signed group txns; server broadcasts,
- /// confirms, and activates the username on-chain.
- ///
- public void CompleteUsernameClaim(
- string reservationId,
- string[] signedTxnsBase64,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/username/claim/complete"),
- new UsernameCompleteRequest
- {
- reservationId = reservationId,
- signedTxnsBase64 = signedTxnsBase64,
- },
- config.longRequestTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- ///
- /// Step 1 of username change: builds atomic 3-txn group
- /// (deregister old + payment + register new).
- ///
- public void PrepareUsernameChange(
- string newUsername,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/username/change/prepare"),
- new UsernameChangePrepareRequest { newUsername = newUsername },
- config.longRequestTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- /// Step 2 of username change: broadcast signed group, server confirms.
- public void CompleteUsernameChange(
- string reservationId,
- string[] signedTxnsBase64,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/username/change/complete"),
- new UsernameCompleteRequest
- {
- reservationId = reservationId,
- signedTxnsBase64 = signedTxnsBase64,
- },
- config.longRequestTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- ///
- /// Set the player's profile picture to an NFT they own.
- /// The server verifies ownership via the Algorand indexer, resolves the
- /// ARC-3/ARC-69 image URL, and persists both on the profile.
- /// Endpoint: POST /v1/profile/pfp
- ///
- public void SetProfilePicNft(
- long assetId,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/pfp"),
- new SetProfilePicRequest { assetId = assetId },
- config.longRequestTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- ///
- /// Search the current player's wallet for NFTs.
- /// The server queries the Algorand indexer for all ASAs held by the wallet,
- /// filters by the search term (matches name or asset ID), and returns
- /// name + assetId + imageUrl for each match.
- /// Endpoint: POST /v1/profile/wallet/nfts
- ///
- public void SearchWalletNFTs(
- string searchTerm,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/wallet/nfts"),
- new WalletNFTSearchRequest { search = searchTerm ?? "" },
- config.walletTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- ///
- /// Resolve the image URL for a single NFT (preview before setting as pfp).
- /// Endpoint: POST /v1/profile/nft/image
- ///
- public void GetNftImageUrl(
- long assetId,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/nft/image"),
- new NftImageRequest { assetId = assetId },
- config.defaultTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- ///
- /// Set the player's profile picture to a built-in default avatar.
- /// No NFT ownership required.
- /// Endpoint: POST /v1/profile/pfp/default
- ///
- public void SetDefaultAvatar(
- string avatarId,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/pfp/default"),
- new SetDefaultAvatarRequest { avatarId = avatarId },
- config.defaultTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- /// Upload a profile image. Pass raw image bytes and MIME type.
- public IEnumerator UploadProfileImage(
- byte[] imageBytes,
- string mimeType,
- string filename,
- Action onSuccess,
- Action onError = null)
- {
- string url = ProfileUrl("/v1/profile/image");
- var form = new WWWForm();
- form.AddBinaryData("image", imageBytes, filename, mimeType);
-
- using var req = UnityWebRequest.Post(url, form);
- req.timeout = SafeTimeout(config.longRequestTimeoutSeconds);
- var token = GetSessionToken();
- if (!string.IsNullOrEmpty(token))
- req.SetRequestHeader("Authorization", $"Bearer {token}");
-
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- /// Fetch onboarding status and wallet balances for the current player.
- public void GetOnboardingStatus(
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(GetJson(
- ProfileUrl("/v1/profile/onboarding-status"),
- config.defaultTimeoutSeconds,
- onSuccess, onError
- ));
- }
-
- /// Advance the player's onboarding step.
- public void AdvanceOnboardingStep(
- string step,
- Action onSuccess = null,
- Action onError = null)
- {
- StartCoroutine(PostJsonAuth(
- ProfileUrl("/v1/profile/onboarding/advance"),
- new OnboardingAdvanceRequest { step = step },
- config.defaultTimeoutSeconds,
- _ => onSuccess?.Invoke(), onError
- ));
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // CONVENIENCE METHODS
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// True when a wallet is connected and the SDK can make authenticated requests.
- public bool IsConnected => BlockmakerAuth.Instance != null && BlockmakerAuth.Instance.HasWallet;
-
- /// Check whether the current player holds a specific asset (any amount > 0).
- public void OwnsAsset(long assetId, Action onResult, Action onError = null)
- {
- GetHoldings(result =>
- {
- bool owns = result?.holdings != null && result.holdings.Exists(h => h.assetId == assetId && h.amount > 0);
- onResult?.Invoke(owns);
- }, onError);
- }
-
- /// Get the balance of a specific ASA for the current player.
- public void GetAssetBalance(long assetId, Action onResult, Action onError = null)
- {
- GetHoldings(result =>
- {
- long balance = 0;
- if (result?.holdings != null)
- {
- var holding = result.holdings.Find(h => h.assetId == assetId);
- if (holding != null) balance = holding.amount;
- }
- onResult?.Invoke(balance);
- }, onError);
- }
-
- /// Check whether the current player is opted into a specific ASA.
- public void IsOptedIn(long assetId, Action onResult, Action onError = null)
- {
- GetHoldings(result =>
- {
- bool optedIn = result?.holdings != null && result.holdings.Exists(h => h.assetId == assetId);
- onResult?.Invoke(optedIn);
- }, onError);
- }
-
- /// Build, sign, and submit a payment transaction in one call.
- public void SendPayment(string recipient, long amountMicroAlgo, Action onTxId, Action onError = null, string note = null)
- {
- BuildPayment(recipient, amountMicroAlgo, note, buildResult =>
- {
- if (!buildResult.success) { onError?.Invoke(buildResult.error ?? "Something went wrong while preparing your payment. Please try again."); return; }
- var txnB64 = buildResult.unsignedTxnBase64;
- var identity = BlockmakerAuth.Instance?.Identity;
- if (identity == null || !identity.CanSign) { onError?.Invoke("No wallet connected. Please connect a wallet or sign in first."); return; }
- StartCoroutine(SignAndSubmit(txnB64, onTxId, onError));
- }, onError);
- }
-
- /// Build, sign, and submit an ASA opt-in transaction in one call.
- public void OptInToAsset(long assetId, Action onTxId, Action onError = null)
- {
- BuildAssetOptIn(assetId, buildResult =>
- {
- if (!buildResult.success) { onError?.Invoke(buildResult.error ?? "Something went wrong. Please try again."); return; }
- var txnB64 = buildResult.unsignedTxnBase64;
- var identity = BlockmakerAuth.Instance?.Identity;
- if (identity == null || !identity.CanSign) { onError?.Invoke("No wallet connected. Please connect a wallet or sign in first."); return; }
- StartCoroutine(SignAndSubmit(txnB64, onTxId, onError));
- }, onError);
- }
-
- private IEnumerator SignAndSubmit(string unsignedTxnBase64, Action onTxId, Action onError)
- {
- var identity = BlockmakerAuth.Instance?.Identity;
- string signedTxn = null;
- string signError = null;
- bool signDone = false;
-
- StartCoroutine(identity.SignTransaction(unsignedTxnBase64, signed =>
- {
- signedTxn = signed;
- signDone = true;
- }, err =>
- {
- signError = err;
- signDone = true;
- }));
-
- while (!signDone) yield return null;
-
- if (!string.IsNullOrEmpty(signError)) { onError?.Invoke(signError); yield break; }
- if (string.IsNullOrEmpty(signedTxn)) { onError?.Invoke("Something went wrong. Please try again."); yield break; }
-
- SubmitTransaction(signedTxn, result =>
- {
- if (result.success)
- onTxId?.Invoke(result.txId);
- else
- onError?.Invoke(result.error ?? "Something went wrong while completing your payment. Please try again.");
- }, onError);
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // HTTP HELPERS
- // ═══════════════════════════════════════════════════════════════════════════
-
- ///
- /// True when a request would carry real backend auth (player JWT — or the
- /// dev API key in the editor). Guests get false in builds: use this to skip
- /// best-effort backend calls that would otherwise just spam 401s.
- ///
- public bool HasBackendSession => !string.IsNullOrEmpty(GetSessionToken());
-
- ///
- /// Returns the best available auth token for the current identity:
- /// JWT session token for signed-in tiers, API key in Editor only.
- /// In player builds, returns empty string if no JWT is available —
- /// the server API key must never be shipped in client builds.
- ///
- private string GetSessionToken()
- {
- var identity = BlockmakerAuth.Instance?.Identity;
- if (identity is ServerSignedIdentity ss && !string.IsNullOrEmpty(ss.SessionToken))
- return ss.SessionToken;
- if (identity is MagicIdentity magic && !string.IsNullOrEmpty(magic.SessionToken))
- return magic.SessionToken;
- if (identity is WalletConnectIdentity wc && !string.IsNullOrEmpty(wc.SessionToken))
- return wc.SessionToken;
- if (identity is EvmXChainIdentity evm && !string.IsNullOrEmpty(evm.SessionToken))
- return evm.SessionToken;
- #if UNITY_EDITOR
- return config?.apiKey ?? "";
- #else
- return "";
- #endif
- }
-
- ///
- /// Returns the auth token for server-to-server calls (Editor/testing only)
- /// or the session token in player builds.
- ///
- private string GetAuthHeader()
- {
- var session = GetSessionToken();
- if (!string.IsNullOrEmpty(session))
- return session;
- #if UNITY_EDITOR
- return config?.apiKey ?? "";
- #else
- return "";
- #endif
- }
-
- private IEnumerator PostJsonAuth(
- string url, object payload, float timeout,
- Action onSuccess, Action onError) where T : class
- {
- if (_baseUrl == null) { onError?.Invoke("Something went wrong. Please restart the game and try again."); yield break; }
- string body = JsonUtility.ToJson(payload);
- using var req = new UnityWebRequest(url, "POST")
- {
- uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)),
- downloadHandler = new DownloadHandlerBuffer(),
- timeout = SafeTimeout(timeout)
- };
- req.SetRequestHeader("Content-Type", "application/json");
- var token = GetSessionToken();
- if (!string.IsNullOrEmpty(token))
- req.SetRequestHeader("Authorization", $"Bearer {token}");
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- private IEnumerator PostJson(
- string url, object payload, float timeout,
- Action onSuccess, Action onError) where T : class
- {
- if (_baseUrl == null) { onError?.Invoke("Something went wrong. Please restart the game and try again."); yield break; }
- string body = JsonUtility.ToJson(payload);
- using var req = BuildPost(url, body, timeout);
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- private IEnumerator GetJson(
- string url, float timeout,
- Action onSuccess, Action onError) where T : class
- {
- if (_baseUrl == null) { onError?.Invoke("Something went wrong. Please restart the game and try again."); yield break; }
- using var req = BuildGet(url, timeout);
- // Override with session token so JWT-auth players work on profile endpoints
- var token = GetSessionToken();
- if (!string.IsNullOrEmpty(token))
- req.SetRequestHeader("Authorization", $"Bearer {token}");
- yield return req.SendWebRequest();
- HandleResponse(req, onSuccess, onError);
- }
-
- private static int SafeTimeout(float seconds)
- {
- return Mathf.Max(1, Mathf.RoundToInt(seconds));
- }
-
- private UnityWebRequest BuildPost(string url, string jsonBody, float timeout)
- {
- var req = new UnityWebRequest(url, "POST")
- {
- uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(jsonBody)),
- downloadHandler = new DownloadHandlerBuffer(),
- timeout = SafeTimeout(timeout)
- };
- req.SetRequestHeader("Content-Type", "application/json");
- var token = GetAuthHeader();
- if (!string.IsNullOrEmpty(token))
- req.SetRequestHeader("Authorization", $"Bearer {token}");
- return req;
- }
-
- private UnityWebRequest BuildGet(string url, float timeout)
- {
- var req = UnityWebRequest.Get(url);
- req.timeout = SafeTimeout(timeout);
- var token = GetAuthHeader();
- if (!string.IsNullOrEmpty(token))
- req.SetRequestHeader("Authorization", $"Bearer {token}");
- return req;
- }
-
- private void HandleResponse(
- UnityWebRequest req,
- Action onSuccess,
- Action onError,
- Action onBlockmakerError = null) where T : class
- {
- if (req.result != UnityWebRequest.Result.Success)
- {
- string err;
- if (req.result == UnityWebRequest.Result.ConnectionError)
- err = "Could not reach the server. Please check your connection and try again.";
- else if ((int)req.responseCode == 401 || (int)req.responseCode == 403)
- err = "Your session has expired. Please sign in again.";
- else if ((int)req.responseCode >= 500)
- err = "The server is having trouble right now. Please try again in a moment.";
- else
- err = "Something went wrong. Please try again.";
- string code = "NETWORK";
- int httpStatus = (int)req.responseCode;
- try
- {
- var body = req.downloadHandler?.text;
- if (!string.IsNullOrEmpty(body))
- {
- var parsed = JsonUtility.FromJson(body);
- if (!string.IsNullOrEmpty(parsed.error))
- {
- BlockmakerLog.Verbose($"[BlockmakerClient] Server error: {parsed.error}");
- err = parsed.error;
- }
- if (!string.IsNullOrEmpty(parsed.code))
- code = parsed.code;
- }
- }
- catch (Exception parseEx) { BlockmakerLog.Warning($"[BlockmakerClient] Error response parse failed: {parseEx.Message}"); }
- BlockmakerLog.Error($"[BlockmakerClient] HTTP {req.responseCode}: {req.error}");
-
- if (onBlockmakerError != null)
- onBlockmakerError.Invoke(new BlockmakerError(code, err, httpStatus));
- onError?.Invoke(err);
- return;
- }
- try
- {
- var body = req.downloadHandler?.text;
- if (string.IsNullOrEmpty(body))
- {
- onError?.Invoke("Something went wrong. Please try again.");
- return;
- }
- onSuccess?.Invoke(JsonUtility.FromJson(body));
- }
- catch (Exception e)
- {
- BlockmakerLog.Error($"[BlockmakerClient] JSON parse error: {e.Message}");
- onError?.Invoke("Something went wrong. Please try again.");
- }
- }
-
- }
-}
\ No newline at end of file
diff --git a/Core/BlockmakerClient.cs.meta b/Core/BlockmakerClient.cs.meta
deleted file mode 100644
index 8117dce..0000000
--- a/Core/BlockmakerClient.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: e5be56d05799a4cf5b56f2d33362c74c
\ No newline at end of file
diff --git a/Core/BlockmakerConfig.cs b/Core/BlockmakerConfig.cs
deleted file mode 100644
index 726b69c..0000000
--- a/Core/BlockmakerConfig.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-using UnityEngine;
-
-namespace Blockmaker
-{
-
- ///
- /// Stores Blockmaker connection settings.
- /// Create one via Assets → Create → Blockmaker → Config
- /// and assign it to BlockmakerClient in the scene.
- ///
- /// Never commit your API key to source control —
- /// use a separate Config asset that lives outside your Assets/Scenes folder
- /// and is listed in .gitignore, or load it from environment at build time.
- ///
- [CreateAssetMenu(fileName = "BlockmakerConfig", menuName = "Blockmaker/Config")]
- public class BlockmakerConfig : ScriptableObject
- {
- [Header("Server")]
- [Tooltip("Optional — your Blockmaker server URL. Leave empty to use the shared default.")]
- public string serverUrl = "";
-
- internal const string DefaultServerUrl = "https://blockmaker-production.up.railway.app";
-
- [Tooltip("Short unique ID for your game (e.g. 'myrace'). Used to namespace saved sessions so multiple Blockmaker games on the same device don't conflict. If left empty, falls back to Application.identifier.")]
- public string gameId = "";
-
- [Header("Auth")]
- [Tooltip("Your Blockmaker API key — sk_ prefix, 48 hex chars")]
- public string apiKey = "";
-
- [Header("WalletConnect")]
- [Tooltip("Optional — your own WalletConnect project ID from https://cloud.walletconnect.com. Leave empty to use the Blockmaker shared ID (works out of the box).")]
- public string walletConnectProjectId = "";
-
- internal const string DefaultWalletConnectProjectId = "dc1f45e68a8fb53fa03adb645365c9bd";
-
- [Header("Magic SDK (Email Wallet)")]
- [Tooltip("Magic publishable API key (pk_live_ prefix). Get one at https://dashboard.magic.link")]
- public string magicPublishableKey = "";
-
- [Tooltip("Enable Magic SDK for email login. Creates a client-side wallet — private key stays on the player's device.")]
- public bool enableMagicEmail = true;
-
- [Header("xChain EVM")]
- [Tooltip("Enable xChain Accounts — lets EVM wallet users sign Algorand transactions from " +
- "their existing wallet. Works on WebGL (any installed browser wallet via EIP-6963) " +
- "and native (WalletConnect QR). BETA: off by default while it matures — flip on " +
- "to show EVM wallets in the connect modal.")]
- public bool enableEvmXChain = false;
-
- [Header("Branding")]
- [Tooltip("URL shown in wallet apps when players approve connections. Defaults to your server URL if empty.")]
- public string dAppUrl = "";
-
- [Tooltip("Icon URL shown in wallet apps. Leave empty for default.")]
- public string dAppIconUrl = "";
-
- [Header("Timeouts (seconds)")]
- public float defaultTimeoutSeconds = 10f;
- public float longRequestTimeoutSeconds = 20f;
- [Tooltip("Timeout for wallet API queries (e.g. NFT search). Not for signing — see walletSignTimeoutSeconds.")]
- public float walletTimeoutSeconds = 30f;
- [Tooltip("Timeout for interactive wallet operations (QR scan, transaction approval). Set high because the player must interact with their wallet app.")]
- public float walletSignTimeoutSeconds = 120f;
-
- private void OnValidate()
- {
- // Server URL and API key are optional — shared defaults are used when empty.
- if (!string.IsNullOrEmpty(serverUrl) &&
- !serverUrl.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase) &&
- !serverUrl.StartsWith("http://localhost", System.StringComparison.OrdinalIgnoreCase) &&
- !serverUrl.StartsWith("http://127.0.0.1", System.StringComparison.OrdinalIgnoreCase))
- BlockmakerLog.Warning("[BlockmakerConfig] Server URL does not use HTTPS. Use HTTPS in production.");
-
- if (!string.IsNullOrEmpty(apiKey) && !apiKey.StartsWith("sk_"))
- BlockmakerLog.Warning("[BlockmakerConfig] API key should start with 'sk_'.");
-
- // WalletConnect Project ID is optional — falls back to Blockmaker's shared ID.
- // Magic SDK is WebGL-only. Email login via OTP works on all platforms.
- }
- }
-}
\ No newline at end of file
diff --git a/Core/BlockmakerConfig.cs.meta b/Core/BlockmakerConfig.cs.meta
deleted file mode 100644
index dd91384..0000000
--- a/Core/BlockmakerConfig.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: d94481a5ea87a4a6cbfb7804fefc7bcb
\ No newline at end of file
diff --git a/Core/BlockmakerErrors.cs b/Core/BlockmakerErrors.cs
deleted file mode 100644
index 3219dc2..0000000
--- a/Core/BlockmakerErrors.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-
-namespace Blockmaker
-{
- ///
- /// Helpers for classifying error strings returned by sign and auth callbacks.
- /// Use these to decide whether to retry, show specific UI, or fail gracefully.
- ///
- public static class BlockmakerErrors
- {
- public static bool IsTimeout(string error) =>
- error != null && (error.IndexOf("timed out", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
- error.IndexOf("timeout", System.StringComparison.OrdinalIgnoreCase) >= 0);
-
- public static bool IsSessionExpired(string error) =>
- error != null && (error.Contains("session has expired") ||
- error.Contains("session has ended") ||
- error.Contains("sign in again"));
-
- public static bool IsUserCancelled(string error) =>
- error != null && (error.Contains("not approved") ||
- error.Contains("cancelled") ||
- error.Contains("rejected") ||
- error.Contains("User closed") ||
- error.Contains("User denied"));
-
- public static bool IsNotConnected(string error) =>
- error != null && (error.Contains("not connected") ||
- error.Contains("connection was lost") ||
- error.Contains("connect your wallet again"));
-
- public static bool IsPlatformUnsupported(string error) =>
- error != null && (error.Contains("only available in the web browser") ||
- error.Contains("not available right now"));
-
- public static bool IsInterrupted(string error) =>
- error != null && error.Contains("interrupted");
-
- public static bool IsRetryable(string error) =>
- IsTimeout(error) || IsNotConnected(error) || IsInterrupted(error);
- }
-
-}
\ No newline at end of file
diff --git a/Core/BlockmakerErrors.cs.meta b/Core/BlockmakerErrors.cs.meta
deleted file mode 100644
index 2c83fa6..0000000
--- a/Core/BlockmakerErrors.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 9ce06a0db52f24ef58dd346bbe3a647b
\ No newline at end of file
diff --git a/Core/BlockmakerLog.cs b/Core/BlockmakerLog.cs
deleted file mode 100644
index 1bc55dc..0000000
--- a/Core/BlockmakerLog.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using System;
-using System.Diagnostics;
-using UnityEngine;
-using Debug = UnityEngine.Debug;
-
-namespace Blockmaker
-{
-
- ///
- /// Centralized logging for the Blockmaker SDK.
- /// All SDK log output goes through here so game developers can control verbosity.
- ///
- /// Default behavior:
- /// - Editor / Development builds: all levels enabled
- /// - Release builds: only warnings and errors
- ///
- /// To silence SDK logs entirely:
- /// BlockmakerLog.Level = BlockmakerLogLevel.None;
- ///
- /// To get full diagnostics:
- /// BlockmakerLog.Level = BlockmakerLogLevel.Verbose;
- ///
- /// To intercept logs (e.g. send to your own analytics):
- /// BlockmakerLog.OnLog += (level, msg) => MyAnalytics.Track(msg);
- ///
- public static class BlockmakerLog
- {
- public static BlockmakerLogLevel Level { get; set; } =
- Debug.isDebugBuild ? BlockmakerLogLevel.Verbose : BlockmakerLogLevel.Warning;
-
- ///
- /// Optional hook for game code to intercept all SDK log messages.
- /// Fires regardless of Level — filtering is the subscriber's responsibility.
- ///
- public static event Action OnLog;
-
- [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
- static void ResetStatics()
- {
- Level = Debug.isDebugBuild ? BlockmakerLogLevel.Verbose : BlockmakerLogLevel.Warning;
- OnLog = null;
- }
-
- public static void Verbose(string message)
- {
- OnLog?.Invoke(BlockmakerLogLevel.Verbose, message);
- if (Level <= BlockmakerLogLevel.Verbose)
- Debug.Log(message);
- }
-
- public static void Info(string message)
- {
- OnLog?.Invoke(BlockmakerLogLevel.Info, message);
- if (Level <= BlockmakerLogLevel.Info)
- Debug.Log(message);
- }
-
- public static void Warning(string message)
- {
- OnLog?.Invoke(BlockmakerLogLevel.Warning, message);
- if (Level <= BlockmakerLogLevel.Warning)
- Debug.LogWarning(message);
- }
-
- public static void Error(string message)
- {
- OnLog?.Invoke(BlockmakerLogLevel.Error, message);
- if (Level <= BlockmakerLogLevel.Error)
- Debug.LogError(message);
- }
-
- public static void Exception(Exception ex)
- {
- OnLog?.Invoke(BlockmakerLogLevel.Error, ex.ToString());
- if (Level <= BlockmakerLogLevel.Error)
- Debug.LogException(ex);
- }
- }
-
- public enum BlockmakerLogLevel
- {
- Verbose = 0,
- Info = 1,
- Warning = 2,
- Error = 3,
- None = 4
- }
-
-}
\ No newline at end of file
diff --git a/Core/BlockmakerLog.cs.meta b/Core/BlockmakerLog.cs.meta
deleted file mode 100644
index 60a3c3c..0000000
--- a/Core/BlockmakerLog.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 5fb8c6e08aaae442b8224f3a0150e56a
\ No newline at end of file
diff --git a/Core/BlockmakerProfileManager.cs b/Core/BlockmakerProfileManager.cs
deleted file mode 100644
index 5b578cf..0000000
--- a/Core/BlockmakerProfileManager.cs
+++ /dev/null
@@ -1,702 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using UnityEngine;
-using UnityEngine.Networking;
-
-namespace Blockmaker
-{
-
- ///
- /// Singleton that owns the current player's profile state and orchestrates
- /// all profile-mutating flows (claim/change username, onboarding steps).
- ///
- /// Subscribe to the static events to react to profile changes:
- /// BlockmakerProfileManager.OnProfileLoaded
- /// BlockmakerProfileManager.OnProfileUpdated
- /// BlockmakerProfileManager.OnOnboardingStatusLoaded
- ///
- /// Read identity-derived display info via the static accessors:
- /// BlockmakerProfileManager.Profile
- /// BlockmakerProfileManager.DisplayName
- /// BlockmakerProfileManager.ProfileImageUrl
- /// BlockmakerProfileManager.HasUsername
- /// BlockmakerProfileManager.WalletAddress
- /// BlockmakerProfileManager.IsEmailTier
- /// BlockmakerProfileManager.OnboardingStep
- ///
- public class BlockmakerProfileManager : MonoBehaviour
- {
- // ── Singleton ──────────────────────────────────────────────────────────────
- public static BlockmakerProfileManager Instance { get; private set; }
-
- [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
- static void ResetStatics()
- {
- Instance = null;
- _profile = null;
- _onboarding = null;
- IsProfileLoading = false;
- OnProfileLoaded = null;
- OnProfileUpdated = null;
- OnOnboardingStatusLoaded = null;
- }
-
- // ── Events ─────────────────────────────────────────────────────────────────
- public static event Action OnProfileLoaded;
- public static event Action OnProfileUpdated;
- public static event Action OnOnboardingStatusLoaded;
-
- // ── Cached state ───────────────────────────────────────────────────────────
- private static BlockmakerProfile _profile;
- private static OnboardingStatus _onboarding;
- private Texture2D _profileTexture;
-
- // ── Static accessors ───────────────────────────────────────────────────────
- public static BlockmakerProfile Profile => _profile;
- public static OnboardingStatus Onboarding => _onboarding;
- public static bool IsProfileLoaded => _profile != null;
- public static bool IsProfileLoading { get; private set; }
-
- public static string DisplayName =>
- _profile?.displayName
- ?? BlockmakerAuth.Instance?.DisplayName
- ?? "Guest";
-
- public static string ProfileImageUrl =>
- _profile?.profileImageUrl ?? string.Empty;
-
- /// Asset ID of the NFT set as the player's profile picture. 0 = not set.
- public static long ProfilePicAssetId =>
- _profile?.profilePicAssetId ?? 0;
-
- public static bool HasUsername =>
- !string.IsNullOrEmpty(_profile?.username);
-
- public static string WalletAddress =>
- _profile?.walletAddress
- ?? BlockmakerAuth.Instance?.Address
- ?? string.Empty;
-
- public static bool IsEmailTier =>
- string.Equals(_profile?.tier, "email", StringComparison.OrdinalIgnoreCase);
-
- public static string OnboardingStep =>
- _profile?.onboardingStep ?? "none";
-
- private static readonly List _emptyBalances = new List();
- public static List TokenBalances =>
- _onboarding?.tokenBalances ?? _emptyBalances;
-
- // ── Lifecycle ──────────────────────────────────────────────────────────────
-
- private void Awake()
- {
- if (Instance != null && Instance != this) { Destroy(gameObject); return; }
- Instance = this;
- DontDestroyOnLoad(gameObject);
- }
-
- private void OnEnable()
- {
- BlockmakerAuth.OnIdentityChanged += HandleIdentityChanged;
- }
-
- private void OnDisable()
- {
- BlockmakerAuth.OnIdentityChanged -= HandleIdentityChanged;
- }
-
- private void OnDestroy()
- {
- if (_profileTexture != null) { Destroy(_profileTexture); _profileTexture = null; }
-
- if (Instance == this)
- {
- _profile = null;
- _onboarding = null;
- Instance = null;
- }
- }
-
- // ── Identity change ────────────────────────────────────────────────────────
-
- private int _generation;
- private Coroutine _profileCoroutine;
- private Coroutine _onboardingCoroutine;
-
- private void HandleIdentityChanged(IBlockmakerIdentity identity)
- {
- if (Instance != this) return;
- if (_profileCoroutine != null) { StopCoroutine(_profileCoroutine); _profileCoroutine = null; }
- if (_onboardingCoroutine != null) { StopCoroutine(_onboardingCoroutine); _onboardingCoroutine = null; }
-
- _generation++;
- _profile = null;
- _onboarding = null;
- IsProfileLoading = false;
- _loadingTexture = false;
- _pendingTextureCallbacks.Clear();
- if (_profileTexture != null) { Destroy(_profileTexture); _profileTexture = null; }
-
- if (identity == null || identity.Tier == IdentityTier.Guest) return;
- if (!identity.HasWallet) return;
- if (!isActiveAndEnabled) return;
-
- // A restored wallet identity often has NO backend session yet (the JWT arrives
- // after the wallet-signature login round-trip) — loading now just 401s. When
- // the login completes, OnIdentityChanged re-fires and we land here again.
- if (BlockmakerClient.Instance == null || !BlockmakerClient.Instance.HasBackendSession) return;
-
- _profileCoroutine = StartCoroutine(LoadProfileRoutine(_generation));
- _onboardingCoroutine = StartCoroutine(LoadOnboardingStatusRoutine(_generation));
- }
-
- // ── Public refresh methods ─────────────────────────────────────────────────
-
- public void RefreshProfile()
- {
- if (BlockmakerAuth.Instance == null || !BlockmakerAuth.Instance.HasWallet) return;
- if (_profileCoroutine != null) StopCoroutine(_profileCoroutine);
- _profileCoroutine = StartCoroutine(LoadProfileRoutine(_generation));
- }
-
- public void RefreshOnboardingStatus()
- {
- if (BlockmakerAuth.Instance == null || !BlockmakerAuth.Instance.HasWallet) return;
- if (_onboardingCoroutine != null) StopCoroutine(_onboardingCoroutine);
- _onboardingCoroutine = StartCoroutine(LoadOnboardingStatusRoutine(_generation));
- }
-
- // ── Profile load ───────────────────────────────────────────────────────────
-
- private const float SERVER_TIMEOUT = 30f;
-
- private IEnumerator LoadProfileRoutine(int gen)
- {
- if (BlockmakerClient.Instance == null) { IsProfileLoading = false; yield break; }
-
- IsProfileLoading = true;
- bool done = false;
-
- BlockmakerClient.Instance.GetProfile(
- result =>
- {
- if (gen != _generation) { done = true; return; }
- if (result?.success == true && result.profile != null)
- {
- bool isFirst = (_profile == null);
- _profile = result.profile;
-
- if (isFirst) SafeInvoke(OnProfileLoaded, _profile);
- else SafeInvoke(OnProfileUpdated, _profile);
- }
- done = true;
- },
- err => { BlockmakerLog.Info($"[BlockmakerProfileManager] Profile load failed: {err}"); done = true; }
- );
-
- float elapsed = 0f;
- while (!done && elapsed < SERVER_TIMEOUT)
- {
- if (gen != _generation) { IsProfileLoading = false; yield break; }
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- IsProfileLoading = false;
- }
-
- private IEnumerator LoadOnboardingStatusRoutine(int gen)
- {
- if (BlockmakerClient.Instance == null) yield break;
-
- bool done = false;
-
- BlockmakerClient.Instance.GetOnboardingStatus(
- result =>
- {
- if (gen != _generation) { done = true; return; }
- if (result?.success == true)
- {
- _onboarding = result;
- SafeInvoke(OnOnboardingStatusLoaded, _onboarding);
- }
- done = true;
- },
- err => { BlockmakerLog.Info($"[BlockmakerProfileManager] Onboarding status load failed: {err}"); done = true; }
- );
-
- float elapsed = 0f;
- while (!done && elapsed < SERVER_TIMEOUT)
- {
- if (gen != _generation) yield break;
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- }
-
- // ── Profile texture ────────────────────────────────────────────────────────
-
- private bool _loadingTexture;
- private readonly List> _pendingTextureCallbacks = new List>();
-
- ///
- /// Load the profile image as a Texture2D. Cached after first load.
- /// Callback receives null if no image is set or the load fails.
- ///
- public void LoadProfileTexture(Action callback)
- {
- if (_profileTexture != null) { callback?.Invoke(_profileTexture); return; }
-
- if (_loadingTexture)
- {
- if (callback != null) _pendingTextureCallbacks.Add(callback);
- return;
- }
-
- string url = ProfileImageUrl;
- if (string.IsNullOrEmpty(url)) { callback?.Invoke(null); return; }
-
- if (callback != null) _pendingTextureCallbacks.Add(callback);
- _loadingTexture = true;
- StartCoroutine(LoadTextureRoutine(url, tex =>
- {
- _loadingTexture = false;
- if (url != ProfileImageUrl)
- {
- if (tex != null) Destroy(tex);
- _pendingTextureCallbacks.Clear();
- return;
- }
- _profileTexture = tex;
- foreach (var cb in _pendingTextureCallbacks)
- cb?.Invoke(tex);
- _pendingTextureCallbacks.Clear();
- }));
- }
-
- private IEnumerator LoadTextureRoutine(string url, Action callback)
- {
- using var req = UnityWebRequestTexture.GetTexture(url);
- req.timeout = 15;
- yield return req.SendWebRequest();
-
- if (req.result == UnityWebRequest.Result.Success)
- callback?.Invoke(DownloadHandlerTexture.GetContent(req));
- else
- callback?.Invoke(null);
- }
-
- // ── Username flow ──────────────────────────────────────────────────────────
-
- ///
- /// Full claim flow: prepare (server) → sign (wallet) → complete (server) → refresh.
- /// Requires BlockmakerAuth.Instance.CanSign == true.
- ///
- public void ClaimUsername(
- string username,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(ClaimUsernameRoutine(username, onSuccess, onError));
- }
-
- private IEnumerator ClaimUsernameRoutine(
- string username,
- Action onSuccess,
- Action onError)
- {
- int gen = _generation;
-
- var auth = BlockmakerAuth.Instance;
- if (auth == null || !auth.CanSign)
- {
- onError?.Invoke("Please connect a wallet to continue.");
- yield break;
- }
- if (BlockmakerClient.Instance == null)
- {
- onError?.Invoke("Something went wrong. Please restart the game and try again.");
- yield break;
- }
-
- // ── Step 1: prepare ────────────────────────────────────────────────────
- UsernamePrepareResult prepared = null;
- string prepareError = null;
- bool prepDone = false;
-
- BlockmakerClient.Instance.PrepareUsernameClaim(username,
- r => { prepared = r; prepDone = true; },
- e => { prepareError = e; prepDone = true; }
- );
-
- float elapsed = 0f;
- while (!prepDone && elapsed < SERVER_TIMEOUT)
- {
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- if (!prepDone) { onError?.Invoke("The request timed out. Please check your connection and try again."); yield break; }
- if (gen != _generation) { onError?.Invoke("Your account changed during this action. Please try again."); yield break; }
-
- if (prepareError != null || prepared?.success != true)
- {
- onError?.Invoke(prepareError ?? prepared?.error ?? "We couldn't set up that username. Please try again.");
- yield break;
- }
-
- // ── Step 2: sign group ────────────────────────────────────────────────
- string[] signedTxns = null;
- string signError = null;
- bool signDone = false;
-
- var txnsToSign = (prepared.unsignedTxnsBase64 != null && prepared.unsignedTxnsBase64.Length > 0)
- ? prepared.unsignedTxnsBase64
- : new[] { prepared.unsignedTxnBase64 };
- yield return auth.Identity.SignTransactions(
- txnsToSign,
- s => { signedTxns = s; signDone = true; },
- e => { signError = e; signDone = true; }
- );
-
- if (!signDone)
- {
- elapsed = 0f;
- while (!signDone && elapsed < 120f)
- {
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- }
- if (!signDone) { onError?.Invoke("The request timed out. Please check your connection and try again."); yield break; }
- if (gen != _generation) { onError?.Invoke("Your account changed during this action. Please try again."); yield break; }
-
- if (signError != null || signedTxns == null || signedTxns.Length == 0)
- {
- onError?.Invoke(signError ?? "You cancelled the request. Please try again when you're ready.");
- yield break;
- }
-
- // ── Step 3: complete ───────────────────────────────────────────────────
- if (BlockmakerClient.Instance == null)
- {
- onError?.Invoke("Something went wrong. Please restart the game and try again.");
- yield break;
- }
-
- UsernameClaimResult claimResult = null;
- string claimError = null;
- bool claimDone = false;
-
- BlockmakerClient.Instance.CompleteUsernameClaim(
- prepared.reservationId,
- signedTxns,
- r => { claimResult = r; claimDone = true; },
- e => { claimError = e; claimDone = true; }
- );
-
- elapsed = 0f;
- while (!claimDone && elapsed < SERVER_TIMEOUT)
- {
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- if (!claimDone) { onError?.Invoke("The request timed out. Please check your connection and try again."); yield break; }
- if (gen != _generation) { onError?.Invoke("Your account changed during this action. Please try again."); yield break; }
-
- if (claimError != null || claimResult?.success != true)
- {
- onError?.Invoke(claimError ?? claimResult?.error ?? "We couldn't finish claiming that username. Please try again.");
- yield break;
- }
-
- // ── Refresh and notify ─────────────────────────────────────────────────
- if (claimResult.profile != null)
- {
- _profile = claimResult.profile;
- SafeInvoke(OnProfileUpdated, _profile);
- }
-
- onSuccess?.Invoke(_profile ?? claimResult.profile);
- }
-
- ///
- /// Full change flow: prepare → sign → complete → refresh.
- /// Player must already have a username.
- ///
- public void ChangeUsername(
- string newUsername,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(ChangeUsernameRoutine(newUsername, onSuccess, onError));
- }
-
- private IEnumerator ChangeUsernameRoutine(
- string newUsername,
- Action onSuccess,
- Action onError)
- {
- int gen = _generation;
-
- var auth = BlockmakerAuth.Instance;
- if (auth == null || !auth.CanSign)
- {
- onError?.Invoke("Please connect a wallet to continue.");
- yield break;
- }
- if (BlockmakerClient.Instance == null)
- {
- onError?.Invoke("Something went wrong. Please restart the game and try again.");
- yield break;
- }
-
- // ── Step 1: prepare ────────────────────────────────────────────────────
- UsernamePrepareResult prepared = null;
- string prepareError = null;
- bool prepDone = false;
-
- BlockmakerClient.Instance.PrepareUsernameChange(newUsername,
- r => { prepared = r; prepDone = true; },
- e => { prepareError = e; prepDone = true; }
- );
-
- float elapsed = 0f;
- while (!prepDone && elapsed < SERVER_TIMEOUT)
- {
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- if (!prepDone) { onError?.Invoke("The request timed out. Please check your connection and try again."); yield break; }
- if (gen != _generation) { onError?.Invoke("Your account changed during this action. Please try again."); yield break; }
-
- if (prepareError != null || prepared?.success != true)
- {
- onError?.Invoke(prepareError ?? prepared?.error ?? "We couldn't set up the username change. Please try again.");
- yield break;
- }
-
- // ── Step 2: sign group ────────────────────────────────────────────────
- string[] signedTxns = null;
- string signError = null;
- bool signDone = false;
-
- var txnsToSign = (prepared.unsignedTxnsBase64 != null && prepared.unsignedTxnsBase64.Length > 0)
- ? prepared.unsignedTxnsBase64
- : new[] { prepared.unsignedTxnBase64 };
- yield return auth.Identity.SignTransactions(
- txnsToSign,
- s => { signedTxns = s; signDone = true; },
- e => { signError = e; signDone = true; }
- );
-
- if (!signDone)
- {
- elapsed = 0f;
- while (!signDone && elapsed < 120f)
- {
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- }
- if (!signDone) { onError?.Invoke("The request timed out. Please check your connection and try again."); yield break; }
- if (gen != _generation) { onError?.Invoke("Your account changed during this action. Please try again."); yield break; }
-
- if (signError != null || signedTxns == null || signedTxns.Length == 0)
- {
- onError?.Invoke(signError ?? "You cancelled the request. Please try again when you're ready.");
- yield break;
- }
-
- // ── Step 3: complete ───────────────────────────────────────────────────
- if (BlockmakerClient.Instance == null)
- {
- onError?.Invoke("Something went wrong. Please restart the game and try again.");
- yield break;
- }
-
- UsernameClaimResult changeResult = null;
- string changeError = null;
- bool changeDone = false;
-
- BlockmakerClient.Instance.CompleteUsernameChange(
- prepared.reservationId,
- signedTxns,
- r => { changeResult = r; changeDone = true; },
- e => { changeError = e; changeDone = true; }
- );
-
- elapsed = 0f;
- while (!changeDone && elapsed < SERVER_TIMEOUT)
- {
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- if (!changeDone) { onError?.Invoke("The request timed out. Please check your connection and try again."); yield break; }
- if (gen != _generation) { onError?.Invoke("Your account changed during this action. Please try again."); yield break; }
-
- if (changeError != null || changeResult?.success != true)
- {
- onError?.Invoke(changeError ?? changeResult?.error ?? "We couldn't finish changing your username. Please try again.");
- yield break;
- }
-
- if (changeResult.profile != null)
- {
- _profile = changeResult.profile;
- SafeInvoke(OnProfileUpdated, _profile);
- }
-
- onSuccess?.Invoke(_profile ?? changeResult.profile);
- }
-
- // ── Profile picture NFT ────────────────────────────────────────────────────
-
- ///
- /// Set the player's profile picture to an NFT they own.
- /// The server verifies ownership via the Algorand indexer, resolves the
- /// image URL from ARC metadata, and persists it on the profile.
- /// On success the cached texture is cleared so the new image loads fresh.
- ///
- public void SetProfilePicNft(
- long assetId,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(SetProfilePicNftRoutine(assetId, onSuccess, onError));
- }
-
- private IEnumerator SetProfilePicNftRoutine(
- long assetId,
- Action onSuccess,
- Action onError)
- {
- int gen = _generation;
-
- if (BlockmakerClient.Instance == null)
- {
- onError?.Invoke("Something went wrong. Please restart the game and try again.");
- yield break;
- }
-
- SetProfilePicResult result = null;
- string error = null;
- bool done = false;
-
- BlockmakerClient.Instance.SetProfilePicNft(assetId,
- r => { result = r; done = true; },
- e => { error = e; done = true; }
- );
-
- float elapsed = 0f;
- while (!done && elapsed < SERVER_TIMEOUT)
- {
- if (gen != _generation) yield break;
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- if (!done) { onError?.Invoke("The request timed out. Please check your connection and try again."); yield break; }
- if (gen != _generation) yield break;
-
- if (error != null || result?.success != true)
- {
- onError?.Invoke(error ?? result?.error ?? "We couldn't update your profile picture. Please try again.");
- yield break;
- }
-
- if (result.profile != null)
- {
- _profile = result.profile;
- if (_profileTexture != null) { Destroy(_profileTexture); _profileTexture = null; }
- SafeInvoke(OnProfileUpdated, _profile);
- }
-
- onSuccess?.Invoke(_profile ?? result.profile);
- }
-
- // ── Default avatar ──────────────────────────────────────────────────────────
-
- public void SetDefaultAvatar(
- string avatarId,
- Action onSuccess,
- Action onError = null)
- {
- StartCoroutine(SetDefaultAvatarRoutine(avatarId, onSuccess, onError));
- }
-
- private IEnumerator SetDefaultAvatarRoutine(
- string avatarId,
- Action onSuccess,
- Action onError)
- {
- int gen = _generation;
-
- if (BlockmakerClient.Instance == null)
- {
- onError?.Invoke("Something went wrong. Please restart the game and try again.");
- yield break;
- }
-
- SetProfilePicResult result = null;
- string error = null;
- bool done = false;
-
- BlockmakerClient.Instance.SetDefaultAvatar(avatarId,
- r => { result = r; done = true; },
- e => { error = e; done = true; }
- );
-
- float elapsed = 0f;
- while (!done && elapsed < SERVER_TIMEOUT)
- {
- if (gen != _generation) yield break;
- elapsed += Time.unscaledDeltaTime;
- yield return null;
- }
- if (!done) { onError?.Invoke("The request timed out. Please check your connection and try again."); yield break; }
- if (gen != _generation) yield break;
-
- if (error != null || result?.success != true)
- {
- onError?.Invoke(error ?? result?.error ?? "We couldn't update your avatar. Please try again.");
- yield break;
- }
-
- if (result.profile != null)
- {
- _profile = result.profile;
- if (_profileTexture != null) { Destroy(_profileTexture); _profileTexture = null; }
- SafeInvoke(OnProfileUpdated, _profile);
- }
-
- onSuccess?.Invoke(_profile ?? result.profile);
- }
-
- // ── Safe event helpers ──────────────────────────────────────────────────────
-
- private static void SafeInvoke(Action handler, T arg)
- {
- if (handler == null) return;
- foreach (var d in handler.GetInvocationList())
- {
- try { ((Action)d).Invoke(arg); }
- catch (Exception ex) { BlockmakerLog.Exception(ex); }
- }
- }
-
- // ── Onboarding helpers ─────────────────────────────────────────────────────
-
- ///
- /// Returns true if the current player should see an onboarding nudge.
- /// Only true for non-guest players who haven't completed onboarding.
- ///
- public static bool ShouldShowOnboardingNudge()
- {
- if (BlockmakerAuth.Instance == null) return false;
- if (BlockmakerAuth.Instance.Tier == IdentityTier.Guest) return false;
- var step = OnboardingStep;
- return step != "complete";
- }
- }
-
-}
\ No newline at end of file
diff --git a/Core/BlockmakerProfileManager.cs.meta b/Core/BlockmakerProfileManager.cs.meta
deleted file mode 100644
index 55353fc..0000000
--- a/Core/BlockmakerProfileManager.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 737f86ea0c68448a79987ed0f0305fa9
\ No newline at end of file
diff --git a/Core/BlockmakerTypes.cs b/Core/BlockmakerTypes.cs
deleted file mode 100644
index 008f305..0000000
--- a/Core/BlockmakerTypes.cs
+++ /dev/null
@@ -1,551 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-namespace Blockmaker
-{
-
- public enum ProfileTier { Guest, Email, Wallet }
- public enum WalletType { Guest, EmailDeviceKey, SelfCustody }
- public enum OnboardingStep { None, HasProfile, HasUsername, Complete }
- public enum TransactionType { Payment, AssetTransfer, AssetOptIn }
-
- // ── Flow types ─────────────────────────────────────────────────────────────────
-
- [Serializable] public class FlowRunRequest
- {
- public string wallet;
- public string context;
- }
-
- [Serializable] public class FlowResult
- {
- public bool success;
- public bool ownsNFT;
- public string output;
- public string error;
- }
-
- // ── Profile types ──────────────────────────────────────────────────────────────
-
- [Serializable] public class GameProfileField
- {
- public string key;
- public string label;
- public string type; // "number" | "string" | "boolean" | "token_balance"
- public string value;
- }
-
- [Serializable] public class GameProfileData
- {
- public List fields = new List();
- }
-
- [Serializable] public class BlockmakerProfile
- {
- public string walletAddress;
- public string username;
- public string displayName;
- public string profileImageUrl;
- /// Algorand asset ID of the NFT set as the player's profile picture. 0 = not set.
- public long profilePicAssetId;
- public string nfdName;
- public string tier; // "guest" | "email" | "wallet"
- public string walletType; // "guest" | "email_device_key" | "self_custody"
- public string onboardingStep; // "none" | "has_profile" | "has_username" | "complete"
- public long createdAt;
- public GameProfileData gameData;
- }
-
- [Serializable] public class ProfileResponse
- {
- public bool success;
- public BlockmakerProfile profile;
- public string error;
- }
-
- [Serializable] public class TokenBalance
- {
- public long assetId;
- public string name;
- public string symbol;
- public long amount;
- public int decimals;
- }
-
- [Serializable] public class OnboardingStatus
- {
- public bool success;
- public string step;
- public string nextAction;
- public string walletAddress;
- public string walletType;
- public string walletMessage;
- public long walletBalance;
- public List tokenBalances = new List();
- public string error;
- }
-
- [Serializable] public class UsernameCheckResult
- {
- public bool available;
- public string username;
- public string reason;
- public string code;
- public string error;
- public long priceMicroAlgo;
- public float priceAlgo;
- }
-
- [Serializable] public class UsernamePrepareResult
- {
- public bool success;
- public string username;
- public string newUsername;
- public string oldUsername;
- public string unsignedTxnBase64;
- public string[] unsignedTxnsBase64;
- public string reservationId;
- public long priceMicroAlgo;
- public float priceAlgo;
- public long appId;
- public string error;
- }
-
- [Serializable] public class UsernameClaimResult
- {
- public bool success;
- public string username;
- public long appId;
- public string txId;
- public BlockmakerProfile profile;
- public string error;
- }
-
- [Serializable] public class ProfileImageResult
- {
- public bool success;
- public string profileImageUrl;
- public string cid;
- public BlockmakerProfile profile;
- public string error;
- }
-
- [Serializable] public class GameDataResponse
- {
- public bool success;
- public string gameId;
- public string walletAddress;
- // Raw JSON object — use JsonUtility or a custom parser on this field
- public string data;
- public string error;
- }
-
- [Serializable] public class SchemaField
- {
- public string key;
- public string label;
- public string type; // "number" | "string" | "boolean" | "token_balance"
- public string defaultValue;
- public long assetId; // required when type = "token_balance"
- }
-
- [Serializable] public class SchemaResponse
- {
- public bool success;
- public List schema = new List();
- public string error;
- }
-
- // ── Username request types ────────────────────────────────────────────────────
-
- [Serializable] public class UsernameCheckRequest
- {
- public string username;
- public string walletAddress;
- public bool isChange;
- }
-
- [Serializable] public class UsernameClaimPrepareRequest
- {
- public string username;
- }
-
- [Serializable] public class UsernameChangePrepareRequest
- {
- public string newUsername;
- }
-
- [Serializable] public class UsernameCompleteRequest
- {
- public string reservationId;
- public string signedTxnBase64;
- public string[] signedTxnsBase64;
- }
-
- [Serializable] public class OnboardingAdvanceRequest
- {
- public string step;
- }
-
- // ── Profile picture NFT types ─────────────────────────────────────────────────
-
- ///
- /// POST /v1/profile/pfp
- /// Server verifies the player owns the NFT, fetches its ARC image URL, and
- /// stores both assetId and imageUrl on the profile.
- ///
- [Serializable] public class SetProfilePicRequest
- {
- /// Algorand asset ID of the NFT to use as the profile picture.
- public long assetId;
- }
-
- [Serializable] public class SetProfilePicResult
- {
- public bool success;
- /// Resolved image URL fetched from the NFT's ARC metadata.
- public string profileImageUrl;
- public long assetId;
- /// Full updated profile returned after the change is persisted.
- public BlockmakerProfile profile;
- public string error;
- }
-
- // ── Default avatar types ──────────────────────────────────────────────────────
-
- [Serializable] public class SetDefaultAvatarRequest
- {
- public string avatarId;
- }
-
- // ── Wallet NFT search types ───────────────────────────────────────────────────
-
- [Serializable] public class WalletNFTSearchRequest
- {
- public string search;
- }
-
- [Serializable] public class WalletNFTAsset
- {
- public long assetId;
- public string name;
- public string unitName;
- public string imageUrl;
- }
-
- [Serializable] public class WalletNFTSearchResult
- {
- public bool success;
- public List assets = new List();
- public string error;
- }
-
- [Serializable] public class NftImageRequest
- {
- public long assetId;
- }
-
- [Serializable] public class NftImageResult
- {
- public bool success;
- public long assetId;
- public string imageUrl;
- public string error;
- }
-
- // ── Wallet holdings types ─────────────────────────────────────────────────────
-
- [Serializable] public class AssetHolding
- {
- public long assetId;
- public long amount;
- }
-
- [Serializable] public class HoldingsResponse
- {
- public bool success;
- public string walletAddress;
- public List holdings = new List();
- public string error;
- }
-
- // ── Collection check types ───────────────────────────────────────────────────
-
- [Serializable] public class CollectionCheckRequest
- {
- public string[] creators;
- }
-
- [Serializable] public class CollectionCheckEntry
- {
- public string creator;
- public bool holds;
- public int count;
- public bool reliable;
- }
-
- [Serializable] public class CollectionCheckResponse
- {
- public bool success;
- public string walletAddress;
- public List results = new List();
- public string error;
- public bool degraded;
- }
-
- // ── Asset registry types ─────────────────────────────────────────────────────
-
- [Serializable] public class AssetRegistryEntry
- {
- public string id;
- public string slug;
- public string name;
- public string type;
- public string creatorAddress;
- public string unitName;
- public long assetId;
- public int decimals;
- public string imageUrl;
- public long totalSupply;
- public int verified;
- }
-
- [Serializable] public class AssetRegistryResponse
- {
- public bool success;
- public List entries = new List();
- public string error;
- }
-
- // ── Magic auth types ──────────────────────────────────────────────────────────
-
- [Serializable] public class MagicVerifyRequest
- {
- public string didToken;
- public string email;
- }
-
- // ── Email auth types ───────────────────────────────────────────────────────────
-
- [Serializable] public class EmailOTPRequest
- {
- public string email;
- }
-
- /// Returned by POST /v1/auth/email/request.
- [Serializable] public class OTPRequestResult
- {
- public bool success;
- public string error;
- }
-
- [Serializable] public class EmailVerifyRequest
- {
- public string email;
- public string otp;
- }
-
- /// Returned by POST /v1/auth/email/verify.
- [Serializable] public class EmailVerifyResult
- {
- public bool success;
- public string walletAddress; // managed Algorand wallet for this email
- public string sessionToken; // JWT — pass as Bearer token to /v1/auth/sign
- public string refreshToken; // long-lived refresh token for token rotation
- public string displayName;
- public bool isNewAccount; // true on first login (wallet was just created)
- public string error;
- }
-
- // ── Wallet-signature auth types ─────────────────────────────────────────────────
-
- /// Request body for POST /v1/auth/wallet/challenge.
- [Serializable] public class WalletChallengeRequest
- {
- public string walletAddress; // Algorand address (derived address for EVM xChain)
- public string chain; // "algorand" | "evm"
- public string evmAddress; // required when chain == "evm"; null otherwise
- }
-
- /// Returned by POST /v1/auth/wallet/challenge.
- [Serializable] public class WalletChallengeResult
- {
- public bool success;
- public string nonce; // base64url single-use nonce
- public string message; // EXACT bytes to sign (UTF-8); sign byte-for-byte
- public long expiresAt; // epoch ms
- public string error;
- }
-
- /// Request body for POST /v1/auth/wallet/verify.
- [Serializable] public class WalletVerifyRequest
- {
- public string walletAddress; // same value sent to /challenge
- public string chain; // "algorand" | "evm"
- public string signature; // evm only: 0x… personal_sign hex (null for algorand)
- public string signedTxn; // algorand only: base64 of the SIGNED 0-amount self-payment
- // whose note == nonce (null for evm)
- public string nonce; // echoes the /challenge nonce
- public string evmAddress; // required when chain == "evm"; null otherwise
- }
-
- // ── Token refresh types ──────────────────────────────────────────────────────
-
- /// Returned by POST /v1/auth/refresh.
- [Serializable] public class RefreshTokenResult
- {
- public bool success;
- public string sessionToken;
- public string refreshToken;
- public string error;
- }
-
- [Serializable] public class RefreshTokenRequest
- {
- public string refreshToken;
- }
-
- [Serializable] public class LogoutRequest
- {
- public string refreshToken;
- }
-
- // ── Server-side signing types ──────────────────────────────────────────────────
-
- [Serializable] public class ServerSignRequest
- {
- public string unsignedTxnBase64;
- public string[] unsignedTxnsBase64;
- }
-
- /// Returned by POST /v1/auth/sign. Supports both single and group signing.
- [Serializable] public class ServerSignResult
- {
- public bool success;
- public string signedTxnBase64;
- public string[] signedTxnsBase64;
- public string txId;
- public string error;
- }
-
- [Serializable] public class ServerErrorResponse
- {
- public bool success;
- public string code;
- public string error;
- }
-
- [Serializable] public class BlockmakerError
- {
- public string Code { get; }
- public string Message { get; }
- public int HttpStatus { get; }
-
- public BlockmakerError(string code, string message, int httpStatus = 0)
- {
- Code = code ?? "";
- Message = message ?? "Something went wrong.";
- HttpStatus = httpStatus;
- }
-
- public bool IsAuthError => Code == "AUTH_MISSING" || Code == "AUTH_INVALID" || Code == "AUTH_EXPIRED";
- public bool IsConfigError => Code == "SERVER_CONFIG";
- public bool IsRateLimited => Code == "RATE_LIMITED";
- public bool IsNetworkError => Code == "NETWORK" || HttpStatus == 0;
- public bool IsTransactionError => Code.StartsWith("TX_") || Code == "INVALID_AMOUNT" || Code == "NOTE_TOO_LONG";
- }
-
- // ── WalletConnect v1 JSON-RPC response types ─────────────────────────────────
-
- [Serializable] public class WcJsonRpcResult
- {
- public long id;
- public string[] result;
- }
-
- [Serializable] public class WcJsonRpcError
- {
- public long id;
- public WcRpcError error;
- }
-
- [Serializable] public class WcRpcError
- {
- public int code;
- public string message;
- }
-
- // ── Rewards (generic platform feature) ───────────────────────────────────────
-
- [Serializable] public class RewardResult
- {
- public bool success;
- public bool sent;
- public string txId;
- public string recipientWallet;
- public long amountMicroAlgo;
- public long assetId;
- public string reason;
- public string contextId;
- public string code;
- public string error;
- }
-
- [Serializable] public class RewardRequest
- {
- public string recipientWallet;
- public long assetId;
- public long amountMicroAlgo;
- public string reason;
- public string contextId;
- }
-
- // ── Transaction builder types ───────────────────────────────────────────────
-
- [Serializable] public class BuildTransactionRequest
- {
- public string type;
- public string recipient;
- public long amount;
- public long assetId;
- public string note;
- public string walletAddress;
-
- public static string TypeToString(TransactionType t)
- {
- if (t == TransactionType.AssetTransfer) return "asset_transfer";
- if (t == TransactionType.AssetOptIn) return "asset_optin";
- return "payment";
- }
- }
-
- [Serializable] public class BuildTransactionResult
- {
- public bool success;
- public string unsignedTxnBase64;
- public string[] unsignedTxnsBase64;
- public string txType;
- public string from;
- public string code;
- public string error;
- }
-
- [Serializable] public class SubmitTransactionRequest
- {
- public string signedTxnBase64;
- public string[] signedTxnsBase64;
- }
-
- [Serializable] public class SubmitTransactionResult
- {
- public bool success;
- public string txId;
- public bool alreadyConfirmed;
- public string code;
- public string error;
- }
-
- // Game-specific types (inventory results, character stats, leaderboard rows,
- // match-result requests, etc.) live in the game project, not the SDK.
-
-}
\ No newline at end of file
diff --git a/Core/BlockmakerTypes.cs.meta b/Core/BlockmakerTypes.cs.meta
deleted file mode 100644
index 9ec620f..0000000
--- a/Core/BlockmakerTypes.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: f5cece798021b422bbab8bca2c12ba79
\ No newline at end of file
diff --git a/Core/BlockmakerUIUtils.cs b/Core/BlockmakerUIUtils.cs
deleted file mode 100644
index 7bcd2a6..0000000
--- a/Core/BlockmakerUIUtils.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using UnityEngine;
-
-namespace Blockmaker
-{
-
- public static class BlockmakerUIUtils
- {
- public static string ShortenAddress(string address, int prefixLen = 6, int suffixLen = 6)
- {
- if (string.IsNullOrEmpty(address) || address.Length <= prefixLen + suffixLen + 1)
- return address ?? string.Empty;
- return $"{address[..prefixLen]}…{address[^suffixLen..]}";
- }
- }
-
-}
\ No newline at end of file
diff --git a/Core/BlockmakerUIUtils.cs.meta b/Core/BlockmakerUIUtils.cs.meta
deleted file mode 100644
index 50f84a9..0000000
--- a/Core/BlockmakerUIUtils.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: b400bd02452e0403ea0f97df0dc88817
\ No newline at end of file
diff --git a/Core/DefaultAvatarSet.cs b/Core/DefaultAvatarSet.cs
deleted file mode 100644
index be9aba1..0000000
--- a/Core/DefaultAvatarSet.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using System;
-using System.Collections.Generic;
-using UnityEngine;
-
-namespace Blockmaker
-{
-
- [CreateAssetMenu(fileName = "DefaultAvatars", menuName = "Blockmaker/Default Avatar Set")]
- public class DefaultAvatarSet : ScriptableObject
- {
- public List avatars = new List