@cartridge/controller 0.13.9 → 0.13.10

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
@@ -91,6 +91,7 @@ export interface Keychain {
91
91
  deploy(): Promise<DeployReply | ConnectError>;
92
92
  execute(calls: Call | Call[], abis?: Abi[], transactionsDetail?: InvocationsDetails, sync?: boolean, feeSource?: any, error?: ControllerError): Promise<ExecuteReply | ConnectError>;
93
93
  signMessage(typedData: TypedData, account: string, async?: boolean): Promise<Signature | ConnectError>;
94
+ updateSession(policies?: SessionPolicies, preset?: string): Promise<ConnectReply | ConnectError>;
94
95
  openSettings(): Promise<void | ConnectError>;
95
96
  session(): Promise<KeychainSession>;
96
97
  sessions(): Promise<{
@@ -161,6 +162,8 @@ export type KeychainOptions = IFrameOptions & {
161
162
  tokens?: Tokens;
162
163
  /** When true, defer iframe mounting until connect() is called. Reduces initial load and resource fetching. */
163
164
  lazyload?: boolean;
165
+ /** When true, force WebAuthn operations to run in a popup window instead of the iframe. Useful for development and testing. */
166
+ webauthnPopup?: boolean;
164
167
  };
165
168
  export type ProfileContextTypeVariant = "inventory" | "trophies" | "achievements" | "quests" | "leaderboard" | "activity";
166
169
  export type Token = "eth" | "strk" | "lords" | "usdc" | "usdt";
@@ -187,6 +190,13 @@ export interface ConnectOptions {
187
190
  /** Required when signer is "password" */
188
191
  password?: string;
189
192
  }
193
+ /** Options for updating session policies at runtime */
194
+ export type UpdateSessionOptions = {
195
+ /** Session policies to set */
196
+ policies?: SessionPolicies;
197
+ /** Preset name to resolve policies from */
198
+ preset?: string;
199
+ };
190
200
  export type HeadlessConnectOptions = Required<Pick<ConnectOptions, "username" | "signer">> & Pick<ConnectOptions, "password">;
191
201
  export type HeadlessConnectReply = {
192
202
  code: ResponseCodes.SUCCESS;
package/dist/utils.d.ts CHANGED
@@ -23,5 +23,4 @@ export declare function toWasmPolicies(policies: ParsedSessionPolicies): Policy[
23
23
  export declare function toArray<T>(val: T | T[]): T[];
24
24
  export declare function humanizeString(str: string): string;
25
25
  export declare function parseChainId(url: URL): ChainId;
26
- export declare function isMobile(): boolean;
27
26
  export declare function sanitizeImageSrc(src: string): string;
@@ -6,12 +6,16 @@ export declare abstract class EthereumWalletBase implements WalletAdapter {
6
6
  abstract readonly displayName: string;
7
7
  platform: ExternalPlatform | undefined;
8
8
  protected account: string | undefined;
9
- protected store: import('mipd').Store;
10
9
  protected provider: EIP6963ProviderDetail | undefined;
11
10
  protected connectedAccounts: string[];
12
11
  constructor();
13
12
  private getProvider;
14
13
  private getEthereumProvider;
14
+ /**
15
+ * Fallback provider detection when EIP-6963 announcement is missed.
16
+ * Subclasses can override to provide wallet-specific fallback logic.
17
+ */
18
+ protected getFallbackProvider(): any;
15
19
  private initializeIfAvailable;
16
20
  private initialized;
17
21
  private initializeProvider;
@@ -4,4 +4,5 @@ export declare class MetaMaskWallet extends EthereumWalletBase {
4
4
  readonly type: ExternalWalletType;
5
5
  readonly rdns = "io.metamask";
6
6
  readonly displayName = "MetaMask";
7
+ protected getFallbackProvider(): any;
7
8
  }
@@ -4,4 +4,5 @@ export declare class PhantomEVMWallet extends EthereumWalletBase {
4
4
  readonly type: ExternalWalletType;
5
5
  readonly rdns = "app.phantom";
6
6
  readonly displayName = "Phantom";
7
+ protected getFallbackProvider(): any;
7
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartridge/controller",
3
- "version": "0.13.9",
3
+ "version": "0.13.10",
4
4
  "description": "Cartridge Controller",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,14 +26,13 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@cartridge/controller-wasm": "0.9.4",
29
+ "@cartridge/controller-wasm": "0.9.6",
30
30
  "@cartridge/penpal": "^6.2.4",
31
31
  "micro-sol-signer": "^0.5.0",
32
32
  "bs58": "^6.0.0",
33
33
  "ethers": "^6.13.5",
34
34
  "@starknet-io/get-starknet-wallet-standard": "5.0.0-beta.0",
35
35
  "@starknet-io/types-js": "0.9.1",
36
- "@telegram-apps/sdk": "^2.4.0",
37
36
  "@turnkey/sdk-browser": "^4.0.0",
38
37
  "cbor-x": "^1.5.0",
39
38
  "starknet": "^8.5.2",
@@ -56,7 +55,7 @@
56
55
  "vite-plugin-node-polyfills": "^0.23.0",
57
56
  "vite-plugin-top-level-await": "^1.4.4",
58
57
  "vite-plugin-wasm": "^3.4.1",
59
- "@cartridge/tsconfig": "0.13.9"
58
+ "@cartridge/tsconfig": "0.13.10"
60
59
  },
61
60
  "scripts": {
62
61
  "build:deps": "pnpm build:browser && pnpm build:node",
@@ -98,6 +98,24 @@ describe("ControllerProvider.disconnect", () => {
98
98
  expect(keychainDisconnect).toHaveBeenCalledTimes(1);
99
99
  });
100
100
 
101
+ test("closes iframe to reset keychain state for subsequent connect", async () => {
102
+ const controller = new ControllerProvider({});
103
+ const keychainDisconnect = jest.fn().mockResolvedValue(undefined);
104
+ (controller as any).keychain = {
105
+ disconnect: keychainDisconnect,
106
+ };
107
+
108
+ const mockClose = jest.fn();
109
+ (controller as any).iframes = {
110
+ keychain: { close: mockClose },
111
+ };
112
+
113
+ await controller.disconnect();
114
+
115
+ expect(keychainDisconnect).toHaveBeenCalledTimes(1);
116
+ expect(mockClose).toHaveBeenCalledTimes(1);
117
+ });
118
+
101
119
  test("does not throw when localStorage is unavailable", async () => {
102
120
  delete (global as any).localStorage;
103
121
  const controller = new ControllerProvider({});
package/src/controller.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  OpenOptions,
32
32
  HeadlessUsernameLookupResult,
33
33
  StarterpackOptions,
34
+ UpdateSessionOptions,
34
35
  } from "./types";
35
36
  import { validateRedirectUrl } from "./url-validator";
36
37
  import { parseChainId } from "./utils";
@@ -308,8 +309,9 @@ export default class ControllerProvider extends BaseProvider {
308
309
  return this.account;
309
310
  }
310
311
 
311
- // Only open modal if NOT headless
312
- this.iframes.keychain.open();
312
+ if (!headless) {
313
+ this.iframes.keychain.open();
314
+ }
313
315
 
314
316
  // Use connect() parameter if provided, otherwise fall back to constructor options
315
317
  const effectiveOptions = Array.isArray(options)
@@ -352,7 +354,6 @@ export default class ControllerProvider extends BaseProvider {
352
354
  }
353
355
  console.log(e);
354
356
  } finally {
355
- // Only close modal if it was opened (not headless)
356
357
  if (!headless) {
357
358
  this.iframes.keychain.close();
358
359
  }
@@ -412,7 +413,8 @@ export default class ControllerProvider extends BaseProvider {
412
413
  return;
413
414
  }
414
415
 
415
- return this.keychain.disconnect();
416
+ await this.keychain.disconnect();
417
+ return this.close();
416
418
  }
417
419
 
418
420
  async openProfile(tab: ProfileContextTypeVariant = "inventory") {
@@ -508,6 +510,47 @@ export default class ControllerProvider extends BaseProvider {
508
510
  this.iframes.keychain.close();
509
511
  }
510
512
 
513
+ async updateSession(options: UpdateSessionOptions = {}) {
514
+ if (!options.policies && !options.preset) {
515
+ throw new Error("Either `policies` or `preset` must be provided");
516
+ }
517
+
518
+ if (!this.iframes) {
519
+ return;
520
+ }
521
+
522
+ // Ensure iframe is created if using lazy loading
523
+ if (!this.iframes.keychain) {
524
+ this.iframes.keychain = this.createKeychainIframe();
525
+ }
526
+
527
+ await this.waitForKeychain();
528
+
529
+ if (!this.keychain || !this.iframes.keychain) {
530
+ console.error(new NotReadyToConnect().message);
531
+ return;
532
+ }
533
+
534
+ this.iframes.keychain.open();
535
+
536
+ try {
537
+ const response = await this.keychain.updateSession(
538
+ options.policies,
539
+ options.preset,
540
+ );
541
+
542
+ if (response.code !== ResponseCodes.SUCCESS) {
543
+ throw new Error((response as ConnectError).message);
544
+ }
545
+
546
+ return response as ConnectReply;
547
+ } catch (e) {
548
+ console.error(e);
549
+ } finally {
550
+ this.iframes.keychain.close();
551
+ }
552
+ }
553
+
511
554
  revoke(origin: string, _policy: Policy[]) {
512
555
  if (!this.keychain) {
513
556
  console.error(new NotReadyToConnect().message);
@@ -40,6 +40,7 @@ export class KeychainIFrame extends IFrame<Keychain> {
40
40
  encryptedBlob,
41
41
  propagateSessionErrors,
42
42
  errorDisplayMode,
43
+ webauthnPopup,
43
44
  ...iframeOptions
44
45
  }: KeychainIframeOptions) {
45
46
  let onStarterpackPlayHandler: (() => Promise<void>) | undefined;
@@ -101,6 +102,10 @@ export class KeychainIFrame extends IFrame<Keychain> {
101
102
  _url.searchParams.set("should_override_preset_policies", "true");
102
103
  }
103
104
 
105
+ if (webauthnPopup) {
106
+ _url.searchParams.set("webauthn_popup", "true");
107
+ }
108
+
104
109
  // Policy precedence logic:
105
110
  // 1. If shouldOverridePresetPolicies is true and policies are provided, use policies
106
111
  // 2. Otherwise, if preset is defined, ignore provided policies
package/src/types.ts CHANGED
@@ -157,6 +157,10 @@ export interface Keychain {
157
157
  account: string,
158
158
  async?: boolean,
159
159
  ): Promise<Signature | ConnectError>;
160
+ updateSession(
161
+ policies?: SessionPolicies,
162
+ preset?: string,
163
+ ): Promise<ConnectReply | ConnectError>;
160
164
  openSettings(): Promise<void | ConnectError>;
161
165
  session(): Promise<KeychainSession>;
162
166
  sessions(): Promise<{
@@ -255,6 +259,8 @@ export type KeychainOptions = IFrameOptions & {
255
259
  tokens?: Tokens;
256
260
  /** When true, defer iframe mounting until connect() is called. Reduces initial load and resource fetching. */
257
261
  lazyload?: boolean;
262
+ /** When true, force WebAuthn operations to run in a popup window instead of the iframe. Useful for development and testing. */
263
+ webauthnPopup?: boolean;
258
264
  };
259
265
 
260
266
  export type ProfileContextTypeVariant =
@@ -295,6 +301,14 @@ export interface ConnectOptions {
295
301
  password?: string;
296
302
  }
297
303
 
304
+ /** Options for updating session policies at runtime */
305
+ export type UpdateSessionOptions = {
306
+ /** Session policies to set */
307
+ policies?: SessionPolicies;
308
+ /** Preset name to resolve policies from */
309
+ preset?: string;
310
+ };
311
+
298
312
  export type HeadlessConnectOptions = Required<
299
313
  Pick<ConnectOptions, "username" | "signer">
300
314
  > &
package/src/utils.ts CHANGED
@@ -256,14 +256,6 @@ export function parseChainId(url: URL): ChainId {
256
256
  throw new Error(`Chain ${url.toString()} not supported`);
257
257
  }
258
258
 
259
- export function isMobile() {
260
- return (
261
- window.matchMedia("(max-width: 768px)").matches ||
262
- "ontouchstart" in window ||
263
- navigator.maxTouchPoints > 0
264
- );
265
- }
266
-
267
259
  // Sanitize image src to prevent XSS
268
260
  export function sanitizeImageSrc(src: string): string {
269
261
  // Allow only http/https URLs (absolute)
@@ -1,6 +1,5 @@
1
1
  import { getAddress } from "ethers/address";
2
- import { createStore, EIP6963ProviderDetail } from "mipd";
3
- import { isMobile } from "../utils";
2
+ import { createStore, EIP6963ProviderDetail, Store } from "mipd";
4
3
  import { chainIdToPlatform } from "./platform";
5
4
  import {
6
5
  ExternalPlatform,
@@ -10,6 +9,17 @@ import {
10
9
  WalletAdapter,
11
10
  } from "./types";
12
11
 
12
+ // Shared store across all EthereumWalletBase instances so late EIP-6963
13
+ // announcements are captured once and visible to every wallet adapter.
14
+ let sharedStore: Store | undefined;
15
+
16
+ function getSharedStore(): Store {
17
+ if (!sharedStore) {
18
+ sharedStore = createStore();
19
+ }
20
+ return sharedStore;
21
+ }
22
+
13
23
  export abstract class EthereumWalletBase implements WalletAdapter {
14
24
  abstract readonly type: ExternalWalletType;
15
25
  abstract readonly rdns: string;
@@ -17,7 +27,6 @@ export abstract class EthereumWalletBase implements WalletAdapter {
17
27
 
18
28
  platform: ExternalPlatform | undefined;
19
29
  protected account: string | undefined = undefined;
20
- protected store = createStore();
21
30
  protected provider: EIP6963ProviderDetail | undefined;
22
31
  protected connectedAccounts: string[] = [];
23
32
 
@@ -26,10 +35,10 @@ export abstract class EthereumWalletBase implements WalletAdapter {
26
35
  }
27
36
 
28
37
  private getProvider(): EIP6963ProviderDetail | undefined {
29
- if (!this.provider) {
30
- this.provider = this.store
31
- .getProviders()
32
- .find((provider) => provider.info.rdns === this.rdns);
38
+ // Use shared store's findProvider which reflects late announcements
39
+ const found = getSharedStore().findProvider({ rdns: this.rdns as any });
40
+ if (found) {
41
+ this.provider = found;
33
42
  }
34
43
  return this.provider;
35
44
  }
@@ -40,15 +49,14 @@ export abstract class EthereumWalletBase implements WalletAdapter {
40
49
  return provider.provider;
41
50
  }
42
51
 
43
- // Fallback for MetaMask when not announced via EIP-6963
44
- if (
45
- this.rdns === "io.metamask" &&
46
- typeof window !== "undefined" &&
47
- (window as any).ethereum?.isMetaMask
48
- ) {
49
- return (window as any).ethereum;
50
- }
52
+ return this.getFallbackProvider();
53
+ }
51
54
 
55
+ /**
56
+ * Fallback provider detection when EIP-6963 announcement is missed.
57
+ * Subclasses can override to provide wallet-specific fallback logic.
58
+ */
59
+ protected getFallbackProvider(): any {
52
60
  return null;
53
61
  }
54
62
 
@@ -101,29 +109,20 @@ export abstract class EthereumWalletBase implements WalletAdapter {
101
109
  }
102
110
 
103
111
  isAvailable(): boolean {
104
- if (isMobile()) {
105
- return false;
106
- }
107
-
108
112
  // Check dynamically each time, as the provider might be announced after instantiation
109
113
  const provider = this.getProvider();
110
114
 
111
- // Also check for MetaMask via window.ethereum as a fallback for MetaMask specifically
112
- if (
113
- !provider &&
114
- this.rdns === "io.metamask" &&
115
- typeof window !== "undefined"
116
- ) {
117
- // MetaMask might be available via window.ethereum even if not announced via EIP-6963 yet
118
- return !!(window as any).ethereum?.isMetaMask;
119
- }
120
-
121
115
  // Initialize if we just found the provider
122
116
  if (provider && !this.initialized) {
123
117
  this.initializeIfAvailable();
124
118
  }
125
119
 
126
- return typeof window !== "undefined" && !!provider;
120
+ if (provider) {
121
+ return true;
122
+ }
123
+
124
+ // Fall back to wallet-specific detection when EIP-6963 announcement is missed
125
+ return typeof window !== "undefined" && !!this.getFallbackProvider();
127
126
  }
128
127
 
129
128
  getInfo(): ExternalWallet {
@@ -158,18 +157,7 @@ export abstract class EthereumWalletBase implements WalletAdapter {
158
157
  throw new Error(`${this.displayName} is not available`);
159
158
  }
160
159
 
161
- let ethereum: any;
162
- const provider = this.getProvider();
163
-
164
- if (provider) {
165
- ethereum = provider.provider;
166
- } else if (
167
- this.rdns === "io.metamask" &&
168
- (window as any).ethereum?.isMetaMask
169
- ) {
170
- // Fallback for MetaMask when not announced via EIP-6963
171
- ethereum = (window as any).ethereum;
172
- }
160
+ const ethereum = this.getEthereumProvider();
173
161
 
174
162
  if (!ethereum) {
175
163
  throw new Error(`${this.displayName} provider not found`);
@@ -183,15 +171,14 @@ export abstract class EthereumWalletBase implements WalletAdapter {
183
171
  this.account = getAddress(accounts[0]);
184
172
  this.connectedAccounts = accounts.map(getAddress);
185
173
 
186
- // If we used the fallback, store the ethereum provider for future use
187
- if (!provider && this.rdns === "io.metamask") {
188
- // Create a mock EIP6963ProviderDetail for consistency
174
+ // If we used a fallback provider, store it for future use
175
+ if (!this.getProvider()) {
189
176
  this.provider = {
190
177
  info: {
191
- uuid: "metamask-fallback",
192
- name: "MetaMask",
178
+ uuid: `${this.rdns}-fallback`,
179
+ name: this.displayName,
193
180
  icon: "data:image/svg+xml;base64,",
194
- rdns: "io.metamask",
181
+ rdns: this.rdns,
195
182
  },
196
183
  provider: ethereum,
197
184
  } as EIP6963ProviderDetail;
@@ -5,4 +5,10 @@ export class MetaMaskWallet extends EthereumWalletBase {
5
5
  readonly type: ExternalWalletType = "metamask";
6
6
  readonly rdns = "io.metamask";
7
7
  readonly displayName = "MetaMask";
8
+
9
+ protected getFallbackProvider(): any {
10
+ return (window as any).ethereum?.isMetaMask
11
+ ? (window as any).ethereum
12
+ : null;
13
+ }
8
14
  }
@@ -5,4 +5,8 @@ export class PhantomEVMWallet extends EthereumWalletBase {
5
5
  readonly type: ExternalWalletType = "phantom-evm";
6
6
  readonly rdns = "app.phantom";
7
7
  readonly displayName = "Phantom";
8
+
9
+ protected getFallbackProvider(): any {
10
+ return (window as any).phantom?.ethereum ?? null;
11
+ }
8
12
  }