@cartridge/controller 0.13.3 → 0.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -79,7 +79,7 @@ type CartridgeID = string;
79
79
  export type ControllerAccounts = Record<ContractAddress, CartridgeID>;
80
80
  export interface Keychain {
81
81
  probe(rpcUrl: string): Promise<ProbeReply | ConnectError>;
82
- connect(signupOptions?: AuthOptions): Promise<ConnectReply | ConnectError>;
82
+ connect(options?: ConnectOptions): Promise<ConnectReply | ConnectError>;
83
83
  disconnect(): void;
84
84
  reset(): void;
85
85
  revoke(origin: string): void;
@@ -172,4 +172,23 @@ export type StarterpackOptions = {
172
172
  /** Callback fired after the Play button closes the starterpack modal */
173
173
  onPurchaseComplete?: () => void;
174
174
  };
175
+ export interface ConnectOptions {
176
+ /** Signup options (shown in UI when not headless) */
177
+ signupOptions?: AuthOptions;
178
+ /** Headless mode username (when combined with signer) */
179
+ username?: string;
180
+ /** Headless mode signer option (auth method) */
181
+ signer?: AuthOption;
182
+ /** Required when signer is "password" */
183
+ password?: string;
184
+ }
185
+ export type HeadlessConnectOptions = Required<Pick<ConnectOptions, "username" | "signer">> & Pick<ConnectOptions, "password">;
186
+ export type HeadlessConnectReply = {
187
+ code: ResponseCodes.SUCCESS;
188
+ address: string;
189
+ } | {
190
+ code: ResponseCodes.USER_INTERACTION_REQUIRED;
191
+ requestId: string;
192
+ message?: string;
193
+ } | ConnectError;
175
194
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartridge/controller",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "description": "Cartridge Controller",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,17 +10,6 @@
10
10
  "module": "dist/index.js",
11
11
  "types": "dist/index.d.ts",
12
12
  "type": "module",
13
- "scripts": {
14
- "build:deps": "pnpm build",
15
- "dev": "vite build --watch",
16
- "build:browser": "vite build",
17
- "build:node": "tsup --config tsup.node.config.ts",
18
- "build": "pnpm build:browser && pnpm build:node",
19
- "format": "prettier --write \"src/**/*.ts\"",
20
- "format:check": "prettier --check \"src/**/*.ts\"",
21
- "test": "jest",
22
- "version": "pnpm pkg get version"
23
- },
24
13
  "exports": {
25
14
  ".": {
26
15
  "types": "./dist/index.d.ts",
@@ -37,11 +26,12 @@
37
26
  }
38
27
  },
39
28
  "dependencies": {
40
- "@cartridge/controller-wasm": "catalog:",
41
- "@cartridge/penpal": "catalog:",
29
+ "@cartridge/controller-wasm": "0.9.3",
30
+ "@cartridge/penpal": "^6.2.4",
42
31
  "micro-sol-signer": "^0.5.0",
43
32
  "bs58": "^6.0.0",
44
33
  "ethers": "^6.13.5",
34
+ "@starknet-io/get-starknet-wallet-standard": "5.0.0-beta.0",
45
35
  "@starknet-io/types-js": "0.9.1",
46
36
  "@telegram-apps/sdk": "^2.4.0",
47
37
  "@turnkey/sdk-browser": "^4.0.0",
@@ -52,20 +42,31 @@
52
42
  "@walletconnect/ethereum-provider": "^2.20.0"
53
43
  },
54
44
  "devDependencies": {
55
- "@cartridge/tsconfig": "workspace:*",
56
- "@types/jest": "catalog:",
57
- "@types/mocha": "catalog:",
58
- "@types/node": "catalog:",
45
+ "@types/jest": "^29.5.14",
46
+ "@types/mocha": "^10.0.10",
47
+ "@types/node": "^18.0.6",
59
48
  "jest": "^29.7.0",
60
- "prettier": "catalog:",
49
+ "prettier": "^3.4.2",
61
50
  "rollup-plugin-visualizer": "^5.12.0",
62
51
  "ts-jest": "^29.2.5",
63
- "tsup": "catalog:",
64
- "typescript": "catalog:",
65
- "vite": "catalog:",
52
+ "tsup": "^8.0.1",
53
+ "typescript": "^5.7.3",
54
+ "vite": "^6.0.0",
66
55
  "vite-plugin-dts": "^4.5.3",
67
56
  "vite-plugin-node-polyfills": "^0.23.0",
68
- "vite-plugin-top-level-await": "catalog:",
69
- "vite-plugin-wasm": "catalog:"
57
+ "vite-plugin-top-level-await": "^1.4.4",
58
+ "vite-plugin-wasm": "^3.4.1",
59
+ "@cartridge/tsconfig": "0.13.5"
60
+ },
61
+ "scripts": {
62
+ "build:deps": "pnpm build",
63
+ "dev": "vite build --watch",
64
+ "build:browser": "vite build",
65
+ "build:node": "tsup --config tsup.node.config.ts",
66
+ "build": "pnpm build:browser && pnpm build:node",
67
+ "format": "prettier --write \"src/**/*.ts\"",
68
+ "format:check": "prettier --check \"src/**/*.ts\"",
69
+ "test": "jest",
70
+ "version": "pnpm pkg get version"
70
71
  }
71
- }
72
+ }
@@ -0,0 +1,87 @@
1
+ import ControllerProvider from "../controller";
2
+
3
+ // Mock StarknetInjectedWallet
4
+ const mockInnerDisconnect = jest.fn().mockResolvedValue(undefined);
5
+ const mockFeatures = {
6
+ "standard:connect": { version: "1.0.0", connect: jest.fn() },
7
+ "standard:disconnect": {
8
+ version: "1.0.0",
9
+ disconnect: mockInnerDisconnect,
10
+ },
11
+ "standard:events": { version: "1.0.0", on: jest.fn() },
12
+ "starknet:walletApi": { version: "1.0.0", request: jest.fn() },
13
+ };
14
+
15
+ jest.mock("@starknet-io/get-starknet-wallet-standard", () => ({
16
+ StarknetInjectedWallet: jest.fn().mockImplementation(() => ({
17
+ version: "1.0.0",
18
+ name: "Controller",
19
+ icon: "data:image/svg+xml,<svg/>",
20
+ chains: ["starknet:mainnet"],
21
+ accounts: [],
22
+ features: mockFeatures,
23
+ })),
24
+ }));
25
+
26
+ describe("asWalletStandard", () => {
27
+ let controller: ControllerProvider;
28
+
29
+ beforeEach(() => {
30
+ jest.clearAllMocks();
31
+ console.warn = jest.fn();
32
+ console.error = jest.fn();
33
+ controller = new ControllerProvider({});
34
+ });
35
+
36
+ test("should delegate properties from inner wallet", () => {
37
+ const wallet = controller.asWalletStandard();
38
+
39
+ expect(wallet.version).toBe("1.0.0");
40
+ expect(wallet.name).toBe("Controller");
41
+ expect(wallet.chains).toEqual(["starknet:mainnet"]);
42
+ expect(wallet.accounts).toEqual([]);
43
+ });
44
+
45
+ test("should forward non-disconnect features from inner wallet", () => {
46
+ const wallet = controller.asWalletStandard();
47
+
48
+ expect(wallet.features["standard:connect"]).toBe(
49
+ mockFeatures["standard:connect"],
50
+ );
51
+ expect(wallet.features["standard:events"]).toBe(
52
+ mockFeatures["standard:events"],
53
+ );
54
+ expect(wallet.features["starknet:walletApi"]).toBe(
55
+ mockFeatures["starknet:walletApi"],
56
+ );
57
+ });
58
+
59
+ test("should call controller.disconnect when wallet standard disconnect is called", async () => {
60
+ const wallet = controller.asWalletStandard();
61
+ const controllerDisconnect = jest
62
+ .spyOn(controller, "disconnect")
63
+ .mockResolvedValue(undefined);
64
+
65
+ await wallet.features["standard:disconnect"].disconnect();
66
+
67
+ expect(mockInnerDisconnect).toHaveBeenCalled();
68
+ expect(controllerDisconnect).toHaveBeenCalled();
69
+ });
70
+
71
+ test("should call inner disconnect before controller disconnect", async () => {
72
+ const callOrder: string[] = [];
73
+
74
+ mockInnerDisconnect.mockImplementation(async () => {
75
+ callOrder.push("inner");
76
+ });
77
+
78
+ jest.spyOn(controller, "disconnect").mockImplementation(async () => {
79
+ callOrder.push("controller");
80
+ });
81
+
82
+ const wallet = controller.asWalletStandard();
83
+ await wallet.features["standard:disconnect"].disconnect();
84
+
85
+ expect(callOrder).toEqual(["inner", "controller"]);
86
+ });
87
+ });
@@ -0,0 +1,97 @@
1
+ import { ResponseCodes } from "../types";
2
+
3
+ let iframeOpen: jest.Mock | undefined;
4
+
5
+ // Mock the KeychainIFrame so we can manually trigger onSessionCreated.
6
+ jest.mock("../iframe/keychain", () => ({
7
+ KeychainIFrame: jest.fn().mockImplementation((_opts: any) => {
8
+ iframeOpen = jest.fn();
9
+ return {
10
+ open: iframeOpen,
11
+ close: jest.fn(),
12
+ };
13
+ }),
14
+ }));
15
+
16
+ // Keep ControllerAccount lightweight for this test.
17
+ jest.mock("../account", () => ({
18
+ __esModule: true,
19
+ default: jest
20
+ .fn()
21
+ .mockImplementation(
22
+ (
23
+ _provider: unknown,
24
+ _rpcUrl: string,
25
+ address: string,
26
+ _keychain: unknown,
27
+ _options: unknown,
28
+ ) => ({
29
+ address,
30
+ }),
31
+ ),
32
+ }));
33
+
34
+ import ControllerProvider from "../controller";
35
+
36
+ describe("headless connect requiring approval", () => {
37
+ test("does not open UI and resolves connect() only after keychain.connect()", async () => {
38
+ const controller = new ControllerProvider();
39
+
40
+ let resolveConnect: ((value: any) => void) | undefined;
41
+ const connectPromise = new Promise((resolve) => {
42
+ resolveConnect = resolve;
43
+ });
44
+
45
+ const keychain = {
46
+ connect: jest.fn().mockReturnValue(connectPromise),
47
+ disconnect: jest.fn(),
48
+ reset: jest.fn(),
49
+ } as any;
50
+
51
+ // Avoid waiting for Penpal connection in this unit test.
52
+ (controller as any).keychain = keychain;
53
+ (controller as any).waitForKeychain = () => Promise.resolve();
54
+
55
+ const accountsChanged = jest.fn();
56
+ controller.on("accountsChanged", accountsChanged as any);
57
+
58
+ const controllerConnectPromise = controller.connect({
59
+ username: "alice",
60
+ signer: "webauthn",
61
+ });
62
+
63
+ let resolved = false;
64
+ void controllerConnectPromise.then(() => {
65
+ resolved = true;
66
+ });
67
+
68
+ // Flush microtasks for:
69
+ // 1) await waitForKeychain()
70
+ // 2) await keychain.connect(...)
71
+ await Promise.resolve();
72
+ await Promise.resolve();
73
+ await Promise.resolve();
74
+
75
+ expect(keychain.connect).toHaveBeenCalledWith({
76
+ username: "alice",
77
+ signer: "webauthn",
78
+ password: undefined,
79
+ });
80
+ expect(iframeOpen).not.toHaveBeenCalled();
81
+ expect(resolved).toBe(false);
82
+
83
+ resolveConnect?.({
84
+ code: ResponseCodes.SUCCESS,
85
+ address: "0xabc",
86
+ });
87
+
88
+ const account = await controllerConnectPromise;
89
+ expect(account?.address).toBe("0xabc");
90
+ expect(accountsChanged).toHaveBeenCalledWith(["0xabc"]);
91
+
92
+ // Subsequent connect() should short-circuit (no second approval flow).
93
+ const account2 = await controller.connect();
94
+ expect(account2?.address).toBe("0xabc");
95
+ expect(keychain.connect).toHaveBeenCalledTimes(1);
96
+ });
97
+ });
@@ -0,0 +1,84 @@
1
+ import {
2
+ buildIframeAllowList,
3
+ isLocalhostHostname,
4
+ validateKeychainIframeUrl,
5
+ } from "../iframe/security";
6
+
7
+ describe("iframe security", () => {
8
+ describe("isLocalhostHostname", () => {
9
+ it("returns true for localhost hosts", () => {
10
+ expect(isLocalhostHostname("localhost")).toBe(true);
11
+ expect(isLocalhostHostname("app.localhost")).toBe(true);
12
+ expect(isLocalhostHostname("127.0.0.1")).toBe(true);
13
+ expect(isLocalhostHostname("[::1]")).toBe(true);
14
+ expect(isLocalhostHostname("::1")).toBe(true);
15
+ });
16
+
17
+ it("returns false for non-local hosts", () => {
18
+ expect(isLocalhostHostname("example.com")).toBe(false);
19
+ expect(isLocalhostHostname("localhost.example.com")).toBe(false);
20
+ });
21
+ });
22
+
23
+ describe("validateKeychainIframeUrl", () => {
24
+ it("allows https URLs", () => {
25
+ expect(() =>
26
+ validateKeychainIframeUrl(new URL("https://x.cartridge.gg")),
27
+ ).not.toThrow();
28
+ });
29
+
30
+ it("allows localhost http URLs for development", () => {
31
+ expect(() =>
32
+ validateKeychainIframeUrl(new URL("http://localhost:3001")),
33
+ ).not.toThrow();
34
+ expect(() =>
35
+ validateKeychainIframeUrl(new URL("http://127.0.0.1:3001")),
36
+ ).not.toThrow();
37
+ expect(() =>
38
+ validateKeychainIframeUrl(new URL("http://[::1]:3001")),
39
+ ).not.toThrow();
40
+ });
41
+
42
+ it("rejects insecure remote http URLs", () => {
43
+ expect(() =>
44
+ validateKeychainIframeUrl(new URL("http://evil.example")),
45
+ ).toThrow(
46
+ "Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
47
+ );
48
+ });
49
+
50
+ it("rejects non-http(s) protocols", () => {
51
+ expect(() =>
52
+ validateKeychainIframeUrl(new URL("javascript:alert(1)")),
53
+ ).toThrow(
54
+ "Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
55
+ );
56
+
57
+ expect(() =>
58
+ validateKeychainIframeUrl(new URL("data:text/html,<h1>xss</h1>")),
59
+ ).toThrow(
60
+ "Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
61
+ );
62
+ });
63
+
64
+ it("rejects credentialed URLs", () => {
65
+ expect(() =>
66
+ validateKeychainIframeUrl(new URL("https://user:pass@x.cartridge.gg")),
67
+ ).toThrow("Invalid keychain iframe URL: credentials are not allowed");
68
+ });
69
+ });
70
+
71
+ describe("buildIframeAllowList", () => {
72
+ it("does not include local-network-access for remote URLs", () => {
73
+ const allowList = buildIframeAllowList(new URL("https://x.cartridge.gg"));
74
+ expect(allowList).toContain("publickey-credentials-create *");
75
+ expect(allowList).toContain("payment *");
76
+ expect(allowList).not.toContain("local-network-access *");
77
+ });
78
+
79
+ it("includes local-network-access for localhost development URLs", () => {
80
+ const allowList = buildIframeAllowList(new URL("http://localhost:3001"));
81
+ expect(allowList).toContain("local-network-access *");
82
+ });
83
+ });
84
+ });
@@ -44,7 +44,7 @@ describe("parseChainId", () => {
44
44
 
45
45
  describe("Non-Cartridge hosts", () => {
46
46
  test("returns placeholder chainId in Node", () => {
47
- expect(parseChainId(new URL("http://dl:123123"))).toBe(
47
+ expect(parseChainId(new URL("http://dl:1234"))).toBe(
48
48
  shortString.encodeShortString("LOCALHOST"),
49
49
  );
50
50
  });
package/src/controller.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { AsyncMethodReturns } from "@cartridge/penpal";
2
2
 
3
3
  import { Policy } from "@cartridge/presets";
4
+ import { StarknetInjectedWallet } from "@starknet-io/get-starknet-wallet-standard";
5
+ import type { WalletWithStarknetFeatures } from "@starknet-io/get-starknet-wallet-standard/features";
4
6
  import {
5
7
  AddInvokeTransactionResult,
6
8
  AddStarknetChainParameters,
@@ -10,7 +12,7 @@ import { constants, shortString, WalletAccount } from "starknet";
10
12
  import { version } from "../package.json";
11
13
  import ControllerAccount from "./account";
12
14
  import { KEYCHAIN_URL } from "./constants";
13
- import { NotReadyToConnect } from "./errors";
15
+ import { HeadlessAuthenticationError, NotReadyToConnect } from "./errors";
14
16
  import { KeychainIFrame } from "./iframe";
15
17
  import BaseProvider from "./provider";
16
18
  import {
@@ -18,6 +20,7 @@ import {
18
20
  Chain,
19
21
  ConnectError,
20
22
  ConnectReply,
23
+ ConnectOptions,
21
24
  ControllerOptions,
22
25
  IFrames,
23
26
  Keychain,
@@ -226,8 +229,18 @@ export default class ControllerProvider extends BaseProvider {
226
229
  }
227
230
 
228
231
  async connect(
229
- signupOptions?: AuthOptions,
232
+ options?: AuthOptions | ConnectOptions,
230
233
  ): Promise<WalletAccount | undefined> {
234
+ const connectOptions = Array.isArray(options) ? undefined : options;
235
+ const headless =
236
+ connectOptions?.username && connectOptions?.signer
237
+ ? {
238
+ username: connectOptions.username,
239
+ signer: connectOptions.signer,
240
+ password: connectOptions.password,
241
+ }
242
+ : undefined;
243
+
231
244
  if (!this.iframes) {
232
245
  return;
233
246
  }
@@ -239,21 +252,73 @@ export default class ControllerProvider extends BaseProvider {
239
252
  // Ensure iframe is created if using lazy loading
240
253
  if (!this.iframes.keychain) {
241
254
  this.iframes.keychain = this.createKeychainIframe();
242
- // Wait for the keychain to be ready
243
- await this.waitForKeychain();
244
255
  }
245
256
 
257
+ // Always wait for the keychain connection to be established
258
+ await this.waitForKeychain();
259
+
246
260
  if (!this.keychain || !this.iframes.keychain) {
247
261
  console.error(new NotReadyToConnect().message);
248
262
  return;
249
263
  }
250
264
 
251
- this.iframes.keychain.open();
252
-
253
265
  try {
266
+ if (headless) {
267
+ // Headless auth should not open the UI until the keychain determines
268
+ // user interaction is required (e.g. session approval).
269
+ const response = await this.keychain.connect({
270
+ username: headless.username,
271
+ signer: headless.signer,
272
+ password: headless.password,
273
+ });
274
+
275
+ if (response.code !== ResponseCodes.SUCCESS) {
276
+ throw new HeadlessAuthenticationError(
277
+ "message" in response && response.message
278
+ ? response.message
279
+ : "Headless authentication failed",
280
+ );
281
+ }
282
+
283
+ // Keychain will call onSessionCreated (awaitable) during headless connect,
284
+ // which probes and updates this.account. Keep a fallback for older keychains.
285
+ if (this.account) {
286
+ return this.account;
287
+ }
288
+
289
+ const address =
290
+ "address" in response && response.address ? response.address : null;
291
+ if (!address) {
292
+ throw new HeadlessAuthenticationError(
293
+ "Headless authentication failed",
294
+ );
295
+ }
296
+
297
+ this.account = new ControllerAccount(
298
+ this,
299
+ this.rpcUrl(),
300
+ address,
301
+ this.keychain,
302
+ this.options,
303
+ this.iframes.keychain,
304
+ );
305
+ this.emitAccountsChanged([address]);
306
+ return this.account;
307
+ }
308
+
309
+ // Only open modal if NOT headless
310
+ this.iframes.keychain.open();
311
+
254
312
  // Use connect() parameter if provided, otherwise fall back to constructor options
255
- const effectiveOptions = signupOptions ?? this.options.signupOptions;
256
- let response = await this.keychain.connect(effectiveOptions);
313
+ const effectiveOptions = Array.isArray(options)
314
+ ? options
315
+ : (connectOptions?.signupOptions ?? this.options.signupOptions);
316
+
317
+ // Pass options to keychain
318
+ let response = await this.keychain.connect({
319
+ signupOptions: effectiveOptions,
320
+ });
321
+
257
322
  if (response.code !== ResponseCodes.SUCCESS) {
258
323
  throw new Error(response.message);
259
324
  }
@@ -270,9 +335,25 @@ export default class ControllerProvider extends BaseProvider {
270
335
 
271
336
  return this.account;
272
337
  } catch (e) {
338
+ if (headless) {
339
+ if (e instanceof HeadlessAuthenticationError) {
340
+ throw e;
341
+ }
342
+
343
+ const message =
344
+ e instanceof Error
345
+ ? e.message
346
+ : typeof e === "object" && e && "message" in e
347
+ ? String((e as any).message)
348
+ : "Headless authentication failed";
349
+ throw new HeadlessAuthenticationError(message);
350
+ }
273
351
  console.log(e);
274
352
  } finally {
275
- this.iframes.keychain.close();
353
+ // Only close modal if it was opened (not headless)
354
+ if (!headless) {
355
+ this.iframes.keychain.close();
356
+ }
276
357
  }
277
358
  }
278
359
 
@@ -306,12 +387,22 @@ export default class ControllerProvider extends BaseProvider {
306
387
  }
307
388
 
308
389
  async disconnect() {
390
+ this.account = undefined;
391
+ this.emitAccountsChanged([]);
392
+
393
+ try {
394
+ if (typeof localStorage !== "undefined") {
395
+ localStorage.removeItem("lastUsedConnector");
396
+ }
397
+ } catch {
398
+ // Ignore environments where localStorage is unavailable.
399
+ }
400
+
309
401
  if (!this.keychain) {
310
402
  console.error(new NotReadyToConnect().message);
311
403
  return;
312
404
  }
313
405
 
314
- this.account = undefined;
315
406
  return this.keychain.disconnect();
316
407
  }
317
408
 
@@ -401,6 +492,13 @@ export default class ControllerProvider extends BaseProvider {
401
492
  this.keychain.openSettings();
402
493
  }
403
494
 
495
+ async close() {
496
+ if (!this.iframes || !this.iframes.keychain) {
497
+ return;
498
+ }
499
+ this.iframes.keychain.close();
500
+ }
501
+
404
502
  revoke(origin: string, _policy: Policy[]) {
405
503
  if (!this.keychain) {
406
504
  console.error(new NotReadyToConnect().message);
@@ -514,6 +612,54 @@ export default class ControllerProvider extends BaseProvider {
514
612
  return await this.keychain.delegateAccount();
515
613
  }
516
614
 
615
+ /**
616
+ * Returns a wallet standard interface for the controller.
617
+ * This allows using the controller with libraries that expect the wallet standard interface.
618
+ */
619
+ asWalletStandard(): WalletWithStarknetFeatures {
620
+ if (typeof window !== "undefined") {
621
+ console.warn(
622
+ `Casting Controller to WalletWithStarknetFeatures is an experimental feature. ` +
623
+ `Please report any issues at https://github.com/cartridge-gg/controller/issues`,
624
+ );
625
+ }
626
+
627
+ const controller = this;
628
+ const inner = new StarknetInjectedWallet(controller);
629
+
630
+ // Override disconnect to also disconnect controller
631
+ const disconnect = {
632
+ "standard:disconnect": {
633
+ version: "1.0.0" as const,
634
+ disconnect: async () => {
635
+ await inner.features["standard:disconnect"].disconnect();
636
+ await controller.disconnect();
637
+ },
638
+ },
639
+ };
640
+
641
+ return {
642
+ get version() {
643
+ return inner.version;
644
+ },
645
+ get name() {
646
+ return inner.name;
647
+ },
648
+ get icon() {
649
+ return inner.icon;
650
+ },
651
+ get chains() {
652
+ return inner.chains;
653
+ },
654
+ get accounts() {
655
+ return inner.accounts;
656
+ },
657
+ get features() {
658
+ return { ...inner.features, ...disconnect };
659
+ },
660
+ };
661
+ }
662
+
517
663
  /**
518
664
  * Opens the keychain in standalone mode (first-party context) for authentication.
519
665
  * This establishes first-party storage, enabling seamless iframe access across all games.
@@ -622,7 +768,9 @@ export default class ControllerProvider extends BaseProvider {
622
768
  const iframe = new KeychainIFrame({
623
769
  ...this.options,
624
770
  rpcUrl: this.rpcUrl(),
625
- onClose: this.keychain?.reset,
771
+ onClose: () => {
772
+ this.keychain?.reset?.();
773
+ },
626
774
  onConnect: (keychain) => {
627
775
  this.keychain = keychain;
628
776
  },
@@ -633,8 +781,12 @@ export default class ControllerProvider extends BaseProvider {
633
781
  encryptedBlob: encryptedBlob ?? undefined,
634
782
  username: username,
635
783
  onSessionCreated: async () => {
636
- // Re-probe to establish connection now that storage access is granted and session created
637
- await this.probe();
784
+ const previousAddress = this.account?.address;
785
+ const account = await this.probe();
786
+
787
+ if (account?.address && account.address !== previousAddress) {
788
+ this.emitAccountsChanged([account.address]);
789
+ }
638
790
  },
639
791
  });
640
792