@cartridge/controller 0.10.6 → 0.11.1

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
@@ -13,8 +13,12 @@ export type KeychainSession = {
13
13
  privateKey: string;
14
14
  };
15
15
  };
16
- export type AuthOption = "google" | "webauthn" | "discord" | "walletconnect" | "password" | ExternalWalletType;
17
- export type AuthOptions = Omit<AuthOption, "phantom" | "argent">[];
16
+ export declare const EMBEDDED_WALLETS: readonly ["google", "webauthn", "discord", "walletconnect", "password"];
17
+ export type EmbeddedWallet = (typeof EMBEDDED_WALLETS)[number];
18
+ export declare const ALL_AUTH_OPTIONS: readonly ["google", "webauthn", "discord", "walletconnect", "password", "metamask", "rabby", "argent", "braavos", "phantom", "base"];
19
+ export type AuthOption = (typeof ALL_AUTH_OPTIONS)[number];
20
+ export declare const IMPLEMENTED_AUTH_OPTIONS: ("metamask" | "rabby" | "google" | "webauthn" | "discord" | "walletconnect" | "password")[];
21
+ export type AuthOptions = (typeof IMPLEMENTED_AUTH_OPTIONS)[number][];
18
22
  export declare enum ResponseCodes {
19
23
  SUCCESS = "SUCCESS",
20
24
  NOT_CONNECTED = "NOT_CONNECTED",
@@ -75,7 +79,7 @@ type CartridgeID = string;
75
79
  export type ControllerAccounts = Record<ContractAddress, CartridgeID>;
76
80
  export interface Keychain {
77
81
  probe(rpcUrl: string): Promise<ProbeReply | ConnectError>;
78
- connect(policies: SessionPolicies, rpcUrl: string, signupOptions?: AuthOptions): Promise<ConnectReply | ConnectError>;
82
+ connect(signupOptions?: AuthOptions): Promise<ConnectReply | ConnectError>;
79
83
  disconnect(): void;
80
84
  reset(): void;
81
85
  revoke(origin: string): void;
@@ -92,8 +96,9 @@ export interface Keychain {
92
96
  openPurchaseCredits(): void;
93
97
  openExecute(calls: Call[]): Promise<void>;
94
98
  switchChain(rpcUrl: string): Promise<void>;
95
- openStarterPack(options: string | StarterPack): Promise<void>;
99
+ openStarterPack(starterpackId: string | number): Promise<void>;
96
100
  navigate(path: string): Promise<void>;
101
+ hasStorageAccess(): Promise<boolean>;
97
102
  externalDetectWallets(): Promise<ExternalWallet[]>;
98
103
  externalConnectWallet(type: ExternalWalletType, address?: string): Promise<ExternalWalletResponse>;
99
104
  externalSignMessage(type: ExternalWalletType, message: string): Promise<ExternalWalletResponse>;
@@ -132,6 +137,8 @@ export type KeychainOptions = IFrameOptions & {
132
137
  url?: string;
133
138
  /** The origin of keychain */
134
139
  origin?: string;
140
+ /** The RPC URL to use (derived from defaultChainId) */
141
+ rpcUrl?: string;
135
142
  /** Propagate transaction errors back to caller instead of showing modal */
136
143
  propagateSessionErrors?: boolean;
137
144
  /** The fee source to use for execute from outside */
@@ -154,23 +161,8 @@ export type Token = "eth" | "strk" | "lords" | "usdc" | "usdt";
154
161
  export type Tokens = {
155
162
  erc20?: Token[];
156
163
  };
157
- export declare enum StarterPackItemType {
158
- NONFUNGIBLE = "NONFUNGIBLE",
159
- FUNGIBLE = "FUNGIBLE"
160
- }
161
- export interface StarterPackItem {
162
- type: StarterPackItemType;
163
- name: string;
164
- description: string;
165
- iconURL?: string;
166
- amount?: number;
167
- price?: bigint;
168
- call?: Call[];
169
- }
170
- export interface StarterPack {
171
- name: string;
172
- description: string;
173
- iconURL?: string;
174
- items: StarterPackItem[];
175
- }
164
+ export type OpenOptions = {
165
+ /** The URL to redirect to after authentication (defaults to current page) */
166
+ redirectUrl?: string;
167
+ };
176
168
  export {};
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Validates a redirect URL to prevent XSS and open redirect attacks.
3
+ *
4
+ * This validator is designed for the standalone auth flow where we want to
5
+ * support redirecting to external game domains (e.g., lootsurvivor.io)
6
+ * after authentication, while blocking dangerous attack vectors.
7
+ *
8
+ * @param redirectUrl - The URL to validate (from redirectUrl option)
9
+ * @returns Object with isValid boolean and optional error message
10
+ */
11
+ export declare function validateRedirectUrl(redirectUrl: string): {
12
+ isValid: boolean;
13
+ error?: string;
14
+ };
package/dist/utils.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Call } from 'starknet';
2
1
  import { Policy } from '@cartridge/controller-wasm/controller';
3
2
  import { Policies, SessionPolicies } from '@cartridge/presets';
4
3
  import { ChainId } from '@starknet-io/types-js';
4
+ import { Call } from 'starknet';
5
5
  import { ParsedSessionPolicies } from './policies';
6
6
  export declare function normalizeCalls(calls: Call | Call[]): {
7
7
  entrypoint: string;
@@ -13,3 +13,4 @@ export declare function toWasmPolicies(policies: ParsedSessionPolicies): Policy[
13
13
  export declare function toArray<T>(val: T | T[]): T[];
14
14
  export declare function humanizeString(str: string): string;
15
15
  export declare function parseChainId(url: URL): ChainId;
16
+ export declare function isMobile(): boolean;
@@ -1,4 +1,9 @@
1
- export type ExternalWalletType = "argent" | "braavos" | "metamask" | "phantom" | "rabby" | "base";
1
+ export declare const AUTH_EXTERNAL_WALLETS: readonly ["metamask", "rabby"];
2
+ export type AuthExternalWallet = (typeof AUTH_EXTERNAL_WALLETS)[number];
3
+ export declare const EXTRA_EXTERNAL_WALLETS: readonly ["argent", "braavos", "phantom", "base"];
4
+ export type ExtraExternalWallet = (typeof EXTRA_EXTERNAL_WALLETS)[number];
5
+ export declare const EXTERNAL_WALLETS: readonly ["metamask", "rabby", "argent", "braavos", "phantom", "base"];
6
+ export type ExternalWalletType = (typeof EXTERNAL_WALLETS)[number];
2
7
  export type ExternalPlatform = "starknet" | "ethereum" | "solana" | "base" | "arbitrum" | "optimism";
3
8
  export interface ExternalWallet {
4
9
  type: ExternalWalletType;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartridge/controller",
3
- "version": "0.10.6",
3
+ "version": "0.11.1",
4
4
  "description": "Cartridge Controller",
5
5
  "module": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -21,7 +21,7 @@
21
21
  }
22
22
  },
23
23
  "dependencies": {
24
- "@cartridge/controller-wasm": "^0.3.6",
24
+ "@cartridge/controller-wasm": "0.3.16",
25
25
  "@cartridge/penpal": "^6.2.4",
26
26
  "micro-sol-signer": "^0.5.0",
27
27
  "bs58": "^6.0.0",
@@ -50,7 +50,7 @@
50
50
  "vite-plugin-node-polyfills": "^0.23.0",
51
51
  "vite-plugin-top-level-await": "^1.4.4",
52
52
  "vite-plugin-wasm": "^3.4.1",
53
- "@cartridge/tsconfig": "0.10.6"
53
+ "@cartridge/tsconfig": "0.11.1"
54
54
  },
55
55
  "scripts": {
56
56
  "build:deps": "pnpm build",
@@ -0,0 +1,85 @@
1
+ import { constants } from "starknet";
2
+ import ControllerProvider from "../controller";
3
+
4
+ // Mock the KeychainIFrame
5
+ jest.mock("../iframe/keychain", () => ({
6
+ KeychainIFrame: jest.fn().mockImplementation(() => ({
7
+ open: jest.fn(),
8
+ close: jest.fn(),
9
+ })),
10
+ }));
11
+
12
+ describe("ControllerProvider connect with defaultChainId", () => {
13
+ let originalConsoleError: any;
14
+ let originalConsoleWarn: any;
15
+ let originalConsoleLog: any;
16
+
17
+ beforeEach(() => {
18
+ // Mock console methods to suppress expected errors/warnings
19
+ originalConsoleError = console.error;
20
+ originalConsoleWarn = console.warn;
21
+ originalConsoleLog = console.log;
22
+ console.error = jest.fn();
23
+ console.warn = jest.fn();
24
+ console.log = jest.fn();
25
+ });
26
+
27
+ afterEach(() => {
28
+ console.error = originalConsoleError;
29
+ console.warn = originalConsoleWarn;
30
+ console.log = originalConsoleLog;
31
+ });
32
+
33
+ test("should use defaultChainId when connecting with Sepolia", async () => {
34
+ const controller = new ControllerProvider({
35
+ defaultChainId: constants.StarknetChainId.SN_SEPOLIA,
36
+ });
37
+
38
+ // The controller should be initialized with Sepolia RPC
39
+ expect(controller.rpcUrl()).toBe(
40
+ "https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9",
41
+ );
42
+ });
43
+
44
+ test("should use defaultChainId when connecting with Mainnet", async () => {
45
+ const controller = new ControllerProvider({
46
+ defaultChainId: constants.StarknetChainId.SN_MAIN,
47
+ });
48
+
49
+ // The controller should be initialized with Mainnet RPC
50
+ expect(controller.rpcUrl()).toBe(
51
+ "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_9",
52
+ );
53
+ });
54
+
55
+ test("should prioritize custom chains over default chains based on defaultChainId", async () => {
56
+ const customChains = [
57
+ { rpcUrl: "https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9" },
58
+ ];
59
+
60
+ const controller = new ControllerProvider({
61
+ chains: customChains,
62
+ defaultChainId: constants.StarknetChainId.SN_SEPOLIA,
63
+ });
64
+
65
+ // Should use the Sepolia RPC since defaultChainId is set to Sepolia
66
+ expect(controller.rpcUrl()).toBe(
67
+ "https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9",
68
+ );
69
+ });
70
+
71
+ test("should use defaultChainId with custom chain configuration", async () => {
72
+ const controller = new ControllerProvider({
73
+ chains: [
74
+ { rpcUrl: "https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9" },
75
+ { rpcUrl: "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_9" },
76
+ ],
77
+ defaultChainId: constants.StarknetChainId.SN_SEPOLIA,
78
+ });
79
+
80
+ // Should respect the defaultChainId and use Sepolia
81
+ expect(controller.rpcUrl()).toBe(
82
+ "https://api.cartridge.gg/x/starknet/sepolia/rpc/v0_9",
83
+ );
84
+ });
85
+ });
package/src/controller.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { constants, shortString, WalletAccount } from "starknet";
10
10
  import { version } from "../package.json";
11
11
  import ControllerAccount from "./account";
12
+ import { KEYCHAIN_URL } from "./constants";
12
13
  import { NotReadyToConnect } from "./errors";
13
14
  import { KeychainIFrame } from "./iframe";
14
15
  import BaseProvider from "./provider";
@@ -22,16 +23,18 @@ import {
22
23
  ProbeReply,
23
24
  ProfileContextTypeVariant,
24
25
  ResponseCodes,
25
- StarterPack,
26
+ OpenOptions,
26
27
  } from "./types";
28
+ import { validateRedirectUrl } from "./url-validator";
27
29
  import { parseChainId } from "./utils";
28
30
 
29
31
  export default class ControllerProvider extends BaseProvider {
30
32
  private keychain?: AsyncMethodReturns<Keychain>;
31
33
  private options: ControllerOptions;
32
- private iframes: IFrames;
34
+ private iframes?: IFrames;
33
35
  private selectedChain: ChainId;
34
36
  private chains: Map<ChainId, Chain>;
37
+ private referral: { ref?: string; refGroup?: string };
35
38
 
36
39
  isReady(): boolean {
37
40
  return !!this.keychain;
@@ -54,14 +57,89 @@ export default class ControllerProvider extends BaseProvider {
54
57
 
55
58
  this.selectedChain = defaultChainId;
56
59
  this.chains = new Map<ChainId, Chain>();
60
+
61
+ // Auto-extract referral parameters from URL
62
+ // This allows games to pass referrals via their own URL: game.com/?ref=alice&ref_group=campaign1
63
+ const urlParams =
64
+ typeof window !== "undefined"
65
+ ? new URLSearchParams(window.location.search)
66
+ : null;
67
+ this.referral = {
68
+ ref: urlParams?.get("ref") ?? undefined,
69
+ refGroup: urlParams?.get("ref_group") ?? undefined,
70
+ };
71
+
57
72
  this.options = { ...options, chains, defaultChainId };
58
73
 
74
+ // Handle automatic redirect to keychain for standalone flow
75
+ // When controller_redirect is present, automatically redirect to keychain
76
+ // This establishes first-party storage access for the keychain
77
+ // IMPORTANT: Check this BEFORE cleaning up URL parameters
78
+ if (typeof window !== "undefined") {
79
+ // Check if controller_redirect flag is present (any value or just the key)
80
+ const hasControllerRedirect = urlParams?.has("controller_redirect");
81
+ if (hasControllerRedirect) {
82
+ // Use configured keychain URL (not user-provided)
83
+ const keychainUrl = new URL(options.url || KEYCHAIN_URL);
84
+
85
+ // Build redirect URL preserving all query params and hash except controller_redirect
86
+ // This matches the behavior of open() method which uses window.location.href
87
+ const currentUrl = new URL(window.location.href);
88
+ currentUrl.searchParams.delete("controller_redirect");
89
+ const redirectUrl = currentUrl.toString();
90
+
91
+ keychainUrl.searchParams.set("redirect_url", redirectUrl);
92
+
93
+ // Preserve the preset if it was configured in options
94
+ if (options.preset) {
95
+ keychainUrl.searchParams.set("preset", options.preset);
96
+ }
97
+
98
+ // Redirect to keychain
99
+ window.location.href = keychainUrl.toString();
100
+ return; // Stop further initialization
101
+ }
102
+ }
103
+
104
+ // Auto-detect and set lastUsedConnector from URL parameter
105
+ // This is set by the keychain after redirect flow completion
106
+ if (typeof window !== "undefined" && typeof localStorage !== "undefined") {
107
+ const lastUsedConnector = urlParams?.get("lastUsedConnector");
108
+ if (lastUsedConnector) {
109
+ localStorage.setItem("lastUsedConnector", lastUsedConnector);
110
+ }
111
+
112
+ // Clean up the URL by removing controller flow parameters
113
+ if (urlParams && window.history?.replaceState) {
114
+ let needsCleanup = false;
115
+
116
+ if (lastUsedConnector) {
117
+ urlParams.delete("lastUsedConnector");
118
+ needsCleanup = true;
119
+ }
120
+
121
+ // Also remove controller_redirect if present (shouldn't be after redirect, but just in case)
122
+ if (urlParams.has("controller_redirect")) {
123
+ urlParams.delete("controller_redirect");
124
+ needsCleanup = true;
125
+ }
126
+
127
+ if (needsCleanup) {
128
+ const newUrl =
129
+ window.location.pathname +
130
+ (urlParams.toString() ? "?" + urlParams.toString() : "") +
131
+ window.location.hash;
132
+ window.history.replaceState({}, "", newUrl);
133
+ }
134
+ }
135
+ }
136
+
137
+ this.initializeChains(chains);
138
+
59
139
  this.iframes = {
60
140
  keychain: options.lazyload ? undefined : this.createKeychainIframe(),
61
141
  };
62
142
 
63
- this.initializeChains(chains);
64
-
65
143
  if (typeof window !== "undefined") {
66
144
  (window as any).starknet_controller = this;
67
145
  }
@@ -105,6 +183,10 @@ export default class ControllerProvider extends BaseProvider {
105
183
  }
106
184
 
107
185
  async probe(): Promise<WalletAccount | undefined> {
186
+ if (!this.iframes) {
187
+ return;
188
+ }
189
+
108
190
  try {
109
191
  // Ensure iframe is created if using lazy loading
110
192
  if (!this.iframes.keychain) {
@@ -139,6 +221,10 @@ export default class ControllerProvider extends BaseProvider {
139
221
  }
140
222
 
141
223
  async connect(): Promise<WalletAccount | undefined> {
224
+ if (!this.iframes) {
225
+ return;
226
+ }
227
+
142
228
  if (this.account) {
143
229
  return this.account;
144
230
  }
@@ -165,19 +251,7 @@ export default class ControllerProvider extends BaseProvider {
165
251
  this.iframes.keychain.open();
166
252
 
167
253
  try {
168
- let response = await this.keychain.connect(
169
- // Policy precedence logic:
170
- // 1. If shouldOverridePresetPolicies is true and policies are provided, use policies
171
- // 2. Otherwise, if preset is defined, use empty object (let preset take precedence)
172
- // 3. Otherwise, use provided policies or empty object
173
- this.options.shouldOverridePresetPolicies && this.options.policies
174
- ? this.options.policies
175
- : this.options.preset
176
- ? {}
177
- : this.options.policies || {},
178
- this.rpcUrl(),
179
- this.options.signupOptions,
180
- );
254
+ let response = await this.keychain.connect(this.options.signupOptions);
181
255
  if (response.code !== ResponseCodes.SUCCESS) {
182
256
  throw new Error(response.message);
183
257
  }
@@ -201,6 +275,10 @@ export default class ControllerProvider extends BaseProvider {
201
275
  }
202
276
 
203
277
  async switchStarknetChain(chainId: string): Promise<boolean> {
278
+ if (!this.iframes) {
279
+ return false;
280
+ }
281
+
204
282
  if (!this.keychain || !this.iframes.keychain) {
205
283
  console.error(new NotReadyToConnect().message);
206
284
  return false;
@@ -243,6 +321,10 @@ export default class ControllerProvider extends BaseProvider {
243
321
  }
244
322
 
245
323
  async openProfile(tab: ProfileContextTypeVariant = "inventory") {
324
+ if (!this.iframes) {
325
+ return;
326
+ }
327
+
246
328
  // Profile functionality is now integrated into keychain
247
329
  // Navigate keychain iframe to profile page
248
330
  if (!this.keychain || !this.iframes.keychain) {
@@ -267,6 +349,10 @@ export default class ControllerProvider extends BaseProvider {
267
349
  }
268
350
 
269
351
  async openProfileTo(to: string) {
352
+ if (!this.iframes) {
353
+ return;
354
+ }
355
+
270
356
  // Profile functionality is now integrated into keychain
271
357
  if (!this.keychain || !this.iframes.keychain) {
272
358
  console.error(new NotReadyToConnect().message);
@@ -289,6 +375,10 @@ export default class ControllerProvider extends BaseProvider {
289
375
  }
290
376
 
291
377
  async openProfileAt(at: string) {
378
+ if (!this.iframes) {
379
+ return;
380
+ }
381
+
292
382
  // Profile functionality is now integrated into keychain
293
383
  if (!this.keychain || !this.iframes.keychain) {
294
384
  console.error(new NotReadyToConnect().message);
@@ -304,6 +394,10 @@ export default class ControllerProvider extends BaseProvider {
304
394
  }
305
395
 
306
396
  openSettings() {
397
+ if (!this.iframes) {
398
+ return;
399
+ }
400
+
307
401
  if (!this.keychain || !this.iframes.keychain) {
308
402
  console.error(new NotReadyToConnect().message);
309
403
  return;
@@ -344,27 +438,38 @@ export default class ControllerProvider extends BaseProvider {
344
438
  }
345
439
 
346
440
  openPurchaseCredits() {
441
+ if (!this.iframes) {
442
+ return;
443
+ }
444
+
347
445
  if (!this.keychain || !this.iframes.keychain) {
348
446
  console.error(new NotReadyToConnect().message);
349
447
  return;
350
448
  }
351
449
  this.keychain.navigate("/purchase/credits").then(() => {
352
- this.iframes.keychain?.open();
450
+ this.iframes!.keychain?.open();
353
451
  });
354
452
  }
355
453
 
356
- async openStarterPack(options: string | StarterPack): Promise<void> {
454
+ async openStarterPack(starterpackId: string | number): Promise<void> {
455
+ if (!this.iframes) {
456
+ return;
457
+ }
458
+
357
459
  if (!this.keychain || !this.iframes.keychain) {
358
460
  console.error(new NotReadyToConnect().message);
359
461
  return;
360
462
  }
361
463
 
362
- // Pass options directly to keychain's unified openStarterPack method
363
- await this.keychain.openStarterPack(options);
464
+ await this.keychain.openStarterPack(starterpackId);
364
465
  this.iframes.keychain?.open();
365
466
  }
366
467
 
367
468
  async openExecute(calls: any, chainId?: string) {
469
+ if (!this.iframes) {
470
+ return;
471
+ }
472
+
368
473
  if (!this.keychain || !this.iframes.keychain) {
369
474
  console.error(new NotReadyToConnect().message);
370
475
  return;
@@ -404,6 +509,84 @@ export default class ControllerProvider extends BaseProvider {
404
509
  return await this.keychain.delegateAccount();
405
510
  }
406
511
 
512
+ /**
513
+ * Opens the keychain in standalone mode (first-party context) for authentication.
514
+ * This establishes first-party storage, enabling seamless iframe access across all games.
515
+ * @param options - Configuration for redirect after authentication
516
+ */
517
+ open(options: OpenOptions = {}) {
518
+ if (typeof window === "undefined") {
519
+ console.error("open can only be called in browser context");
520
+ return;
521
+ }
522
+
523
+ const keychainUrl = new URL(this.options.url || KEYCHAIN_URL);
524
+
525
+ // Add redirect target (defaults to current page)
526
+ const redirectUrl = options.redirectUrl || window.location.href;
527
+
528
+ // Validate redirect URL to prevent XSS and open redirect attacks
529
+ const validation = validateRedirectUrl(redirectUrl);
530
+ if (!validation.isValid) {
531
+ console.error(
532
+ `Invalid redirect URL: ${validation.error}`,
533
+ `URL: ${redirectUrl}`,
534
+ );
535
+ return;
536
+ }
537
+
538
+ keychainUrl.searchParams.set("redirect_url", redirectUrl);
539
+
540
+ // Add preset if provided
541
+ if (this.options.preset) {
542
+ keychainUrl.searchParams.set("preset", this.options.preset);
543
+ }
544
+
545
+ // Add controller configuration parameters
546
+ if (this.options.slot) {
547
+ keychainUrl.searchParams.set("ps", this.options.slot);
548
+ }
549
+
550
+ if (this.options.namespace) {
551
+ keychainUrl.searchParams.set("ns", this.options.namespace);
552
+ }
553
+
554
+ if (this.options.tokens?.erc20) {
555
+ keychainUrl.searchParams.set(
556
+ "erc20",
557
+ this.options.tokens.erc20.toString(),
558
+ );
559
+ }
560
+
561
+ if (this.rpcUrl()) {
562
+ keychainUrl.searchParams.set("rpc_url", this.rpcUrl());
563
+ }
564
+
565
+ // Navigate to standalone keychain
566
+ window.location.href = keychainUrl.toString();
567
+ }
568
+
569
+ /**
570
+ * Checks if the keychain iframe has first-party storage access.
571
+ * Returns true if the user has previously authenticated via standalone mode.
572
+ * @returns Promise<boolean> indicating if storage access is available
573
+ */
574
+ async hasFirstPartyAccess(): Promise<boolean> {
575
+ if (!this.keychain) {
576
+ console.error(new NotReadyToConnect().message);
577
+ return false;
578
+ }
579
+
580
+ try {
581
+ // Ask the keychain iframe if it has storage access
582
+ const hasAccess = await this.keychain.hasStorageAccess();
583
+ return hasAccess;
584
+ } catch (error) {
585
+ console.error("Error checking storage access:", error);
586
+ return false;
587
+ }
588
+ }
589
+
407
590
  private initializeChains(chains: Chain[]) {
408
591
  for (const chain of chains) {
409
592
  try {
@@ -442,11 +625,14 @@ export default class ControllerProvider extends BaseProvider {
442
625
  private createKeychainIframe(): KeychainIFrame {
443
626
  return new KeychainIFrame({
444
627
  ...this.options,
628
+ rpcUrl: this.rpcUrl(),
445
629
  onClose: this.keychain?.reset,
446
630
  onConnect: (keychain) => {
447
631
  this.keychain = keychain;
448
632
  },
449
633
  version: version,
634
+ ref: this.referral.ref,
635
+ refGroup: this.referral.refGroup,
450
636
  });
451
637
  }
452
638
 
@@ -1,5 +1,5 @@
1
1
  import { AsyncMethodReturns, connectToChild } from "@cartridge/penpal";
2
- import { ControllerOptions, Modal } from "../types";
2
+ import { Modal } from "../types";
3
3
 
4
4
  export type IFrameOptions<CallSender> = Omit<
5
5
  ConstructorParameters<typeof IFrame>[0],
@@ -20,11 +20,10 @@ export class IFrame<CallSender extends {}> implements Modal {
20
20
  constructor({
21
21
  id,
22
22
  url,
23
- preset,
24
23
  onClose,
25
24
  onConnect,
26
25
  methods = {},
27
- }: Pick<ControllerOptions, "preset"> & {
26
+ }: {
28
27
  id: string;
29
28
  url: URL;
30
29
  onClose?: () => void;
@@ -35,12 +34,17 @@ export class IFrame<CallSender extends {}> implements Modal {
35
34
  return;
36
35
  }
37
36
 
38
- if (preset) {
39
- url.searchParams.set("preset", preset);
40
- }
41
-
42
37
  this.url = url;
43
38
 
39
+ const docHead = document.head;
40
+
41
+ const meta = document.createElement("meta");
42
+ meta.name = "viewport";
43
+ meta.id = "controller-viewport";
44
+ meta.content =
45
+ "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, interactive-widget=resizes-content";
46
+ docHead.appendChild(meta);
47
+
44
48
  const iframe = document.createElement("iframe");
45
49
  iframe.src = url.toString();
46
50
  iframe.id = id;
@@ -52,6 +56,12 @@ export class IFrame<CallSender extends {}> implements Modal {
52
56
  iframe.sandbox.add("allow-same-origin");
53
57
  iframe.allow =
54
58
  "publickey-credentials-create *; publickey-credentials-get *; clipboard-write";
59
+ iframe.style.scrollbarWidth = "none";
60
+ iframe.style.setProperty("-ms-overflow-style", "none");
61
+ iframe.style.setProperty("-webkit-scrollbar", "none");
62
+ // Enable Storage Access API for the iframe
63
+ // This allows the keychain iframe to request access to its first-party storage
64
+ // when embedded in third-party contexts (other games/apps)
55
65
  if (!!document.hasStorageAccess) {
56
66
  iframe.sandbox.add("allow-storage-access-by-user-activation");
57
67
  }
@@ -71,8 +81,43 @@ export class IFrame<CallSender extends {}> implements Modal {
71
81
  container.style.transition = "opacity 0.2s ease";
72
82
  container.style.opacity = "0";
73
83
  container.style.pointerEvents = "auto";
84
+ container.style.overscrollBehaviorY = "contain";
85
+ container.style.scrollbarWidth = "none";
86
+ container.style.setProperty("-ms-overflow-style", "none");
87
+ container.style.setProperty("-webkit-scrollbar", "none");
74
88
  container.appendChild(iframe);
75
89
 
90
+ // Disables pinch to zoom
91
+ container.addEventListener(
92
+ "touchstart",
93
+ (e) => {
94
+ if (e.touches.length > 1) {
95
+ e.preventDefault();
96
+ }
97
+ },
98
+ { passive: false },
99
+ );
100
+
101
+ container.addEventListener(
102
+ "touchmove",
103
+ (e) => {
104
+ if (e.touches.length > 1) {
105
+ e.preventDefault();
106
+ }
107
+ },
108
+ { passive: false },
109
+ );
110
+
111
+ container.addEventListener(
112
+ "touchend",
113
+ (e) => {
114
+ if (e.touches.length > 1) {
115
+ e.preventDefault();
116
+ }
117
+ },
118
+ { passive: false },
119
+ );
120
+
76
121
  // Add click event listener to close iframe when clicking outside
77
122
  container.addEventListener("click", (e) => {
78
123
  if (e.target === container) {