@cartridge/controller 0.12.2 → 0.13.3

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.
@@ -2,6 +2,8 @@ export type ToastPosition = "top-left" | "top-right" | "top-center" | "bottom-le
2
2
  export interface BaseToastOptions {
3
3
  duration?: number;
4
4
  position?: ToastPosition;
5
+ preset?: string;
6
+ safeToClose?: boolean;
5
7
  onClick?: () => void;
6
8
  }
7
9
  export interface ErrorToastOptions extends BaseToastOptions {
@@ -24,6 +26,7 @@ export interface AchievementToastOptions extends BaseToastOptions {
24
26
  title: string;
25
27
  subtitle?: string;
26
28
  xpAmount: number;
29
+ progress: number;
27
30
  isDraft?: boolean;
28
31
  }
29
32
  export interface QuestToastOptions extends BaseToastOptions {
@@ -33,8 +36,9 @@ export interface QuestToastOptions extends BaseToastOptions {
33
36
  }
34
37
  export interface MarketplaceToastOptions extends BaseToastOptions {
35
38
  variant: "marketplace";
36
- itemName: string;
37
- itemImage: string;
38
- action: "purchased" | "sold";
39
+ itemNames: string[];
40
+ itemImages: string[];
41
+ collectionName: string;
42
+ action: "purchased" | "sold" | "sent" | "listed" | "unlisted";
39
43
  }
40
44
  export type ToastOptions = ErrorToastOptions | TransactionToastOptions | NetworkSwitchToastOptions | AchievementToastOptions | QuestToastOptions | MarketplaceToastOptions;
package/dist/types.d.ts CHANGED
@@ -169,5 +169,7 @@ export type OpenOptions = {
169
169
  export type StarterpackOptions = {
170
170
  /** The preimage to use */
171
171
  preimage?: string;
172
+ /** Callback fired after the Play button closes the starterpack modal */
173
+ onPurchaseComplete?: () => void;
172
174
  };
173
175
  export {};
package/dist/utils.d.ts CHANGED
@@ -9,6 +9,14 @@ export declare function normalizeCalls(calls: Call | Call[]): {
9
9
  calldata: import('starknet').HexCalldata;
10
10
  }[];
11
11
  export declare function toSessionPolicies(policies: Policies): SessionPolicies;
12
+ /**
13
+ * Converts parsed session policies to WASM-compatible Policy objects.
14
+ *
15
+ * IMPORTANT: Policies are sorted canonically before hashing. Without this,
16
+ * Object.keys/entries reordering can cause identical policies to produce
17
+ * different merkle roots, leading to "session/not-registered" errors.
18
+ * See: https://github.com/cartridge-gg/controller/issues/2357
19
+ */
12
20
  export declare function toWasmPolicies(policies: ParsedSessionPolicies): Policy[];
13
21
  export declare function toArray<T>(val: T | T[]): T[];
14
22
  export declare function humanizeString(str: string): string;
package/package.json CHANGED
@@ -1,10 +1,26 @@
1
1
  {
2
2
  "name": "@cartridge/controller",
3
- "version": "0.12.2",
3
+ "version": "0.13.3",
4
4
  "description": "Cartridge Controller",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/cartridge-gg/controller.git",
8
+ "directory": "packages/controller"
9
+ },
5
10
  "module": "dist/index.js",
6
11
  "types": "dist/index.d.ts",
7
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
+ },
8
24
  "exports": {
9
25
  ".": {
10
26
  "types": "./dist/index.d.ts",
@@ -21,8 +37,8 @@
21
37
  }
22
38
  },
23
39
  "dependencies": {
24
- "@cartridge/controller-wasm": "0.9.1",
25
- "@cartridge/penpal": "^6.2.4",
40
+ "@cartridge/controller-wasm": "catalog:",
41
+ "@cartridge/penpal": "catalog:",
26
42
  "micro-sol-signer": "^0.5.0",
27
43
  "bs58": "^6.0.0",
28
44
  "ethers": "^6.13.5",
@@ -36,31 +52,20 @@
36
52
  "@walletconnect/ethereum-provider": "^2.20.0"
37
53
  },
38
54
  "devDependencies": {
39
- "@types/jest": "^29.5.14",
40
- "@types/mocha": "^10.0.10",
41
- "@types/node": "^18.0.6",
55
+ "@cartridge/tsconfig": "workspace:*",
56
+ "@types/jest": "catalog:",
57
+ "@types/mocha": "catalog:",
58
+ "@types/node": "catalog:",
42
59
  "jest": "^29.7.0",
43
- "prettier": "^3.4.2",
60
+ "prettier": "catalog:",
44
61
  "rollup-plugin-visualizer": "^5.12.0",
45
62
  "ts-jest": "^29.2.5",
46
- "tsup": "^8.0.1",
47
- "typescript": "^5.7.3",
48
- "vite": "^6.0.0",
63
+ "tsup": "catalog:",
64
+ "typescript": "catalog:",
65
+ "vite": "catalog:",
49
66
  "vite-plugin-dts": "^4.5.3",
50
67
  "vite-plugin-node-polyfills": "^0.23.0",
51
- "vite-plugin-top-level-await": "^1.4.4",
52
- "vite-plugin-wasm": "^3.4.1",
53
- "@cartridge/tsconfig": "0.12.2"
54
- },
55
- "scripts": {
56
- "build:deps": "pnpm build",
57
- "dev": "vite build --watch",
58
- "build:browser": "vite build",
59
- "build:node": "tsup --config tsup.node.config.ts",
60
- "build": "pnpm build:browser && pnpm build:node",
61
- "format": "prettier --write \"src/**/*.ts\"",
62
- "format:check": "prettier --check \"src/**/*.ts\"",
63
- "test": "jest",
64
- "version": "pnpm pkg get version"
68
+ "vite-plugin-top-level-await": "catalog:",
69
+ "vite-plugin-wasm": "catalog:"
65
70
  }
66
- }
71
+ }
@@ -41,30 +41,30 @@ describe("ControllerProvider defaults", () => {
41
41
  );
42
42
  });
43
43
 
44
- test("should throw error when using non-Cartridge RPC for mainnet", async () => {
45
- const invalidChains = [
44
+ test("should allow non-Cartridge RPC for mainnet", async () => {
45
+ const customChains = [
46
46
  { rpcUrl: "https://some-other-provider.com/starknet/mainnet/rpc/v0_9" },
47
47
  ];
48
48
 
49
49
  expect(() => {
50
50
  new ControllerProvider({
51
- chains: invalidChains,
51
+ chains: customChains,
52
52
  defaultChainId: constants.StarknetChainId.SN_MAIN,
53
53
  });
54
- }).toThrow("Only Cartridge RPC providers are allowed for mainnet");
54
+ }).not.toThrow();
55
55
  });
56
56
 
57
- test("should throw error when using non-Cartridge RPC for sepolia", async () => {
58
- const invalidChains = [
57
+ test("should allow non-Cartridge RPC for sepolia", async () => {
58
+ const customChains = [
59
59
  { rpcUrl: "https://some-other-provider.com/starknet/sepolia/rpc/v0_9" },
60
60
  ];
61
61
 
62
62
  expect(() => {
63
63
  new ControllerProvider({
64
- chains: invalidChains,
64
+ chains: customChains,
65
65
  defaultChainId: constants.StarknetChainId.SN_SEPOLIA,
66
66
  });
67
- }).toThrow("Only Cartridge RPC providers are allowed for sepolia");
67
+ }).not.toThrow();
68
68
  });
69
69
 
70
70
  test("should allow non-Cartridge RPC for custom chains", () => {
@@ -42,17 +42,25 @@ describe("parseChainId", () => {
42
42
  });
43
43
  });
44
44
 
45
+ describe("Non-Cartridge hosts", () => {
46
+ test("returns placeholder chainId in Node", () => {
47
+ expect(parseChainId(new URL("http://dl:123123"))).toBe(
48
+ shortString.encodeShortString("LOCALHOST"),
49
+ );
50
+ });
51
+ });
52
+
45
53
  describe("Error cases", () => {
46
54
  test("throws error for unsupported URL format", () => {
47
55
  expect(() =>
48
- parseChainId(new URL("https://api.example.com/unsupported")),
49
- ).toThrow("Chain https://api.example.com/unsupported not supported");
56
+ parseChainId(new URL("https://api.cartridge.gg/unsupported")),
57
+ ).toThrow("Chain https://api.cartridge.gg/unsupported not supported");
50
58
  });
51
59
 
52
60
  test("throws error for URLs without proper chain identifiers", () => {
53
61
  expect(() =>
54
- parseChainId(new URL("https://api.example.com/v1/starknet")),
55
- ).toThrow("Chain https://api.example.com/v1/starknet not supported");
62
+ parseChainId(new URL("https://api.cartridge.gg/v1/starknet")),
63
+ ).toThrow("Chain https://api.cartridge.gg/v1/starknet not supported");
56
64
  });
57
65
  });
58
66
  });
@@ -0,0 +1,379 @@
1
+ import { toWasmPolicies } from "../utils";
2
+ import { ParsedSessionPolicies } from "../policies";
3
+
4
+ describe("toWasmPolicies", () => {
5
+ describe("canonical ordering", () => {
6
+ test("sorts contracts by address regardless of input order", () => {
7
+ const policies1: ParsedSessionPolicies = {
8
+ verified: false,
9
+ contracts: {
10
+ "0xAAA": {
11
+ methods: [{ entrypoint: "foo", authorized: true }],
12
+ },
13
+ "0xBBB": {
14
+ methods: [{ entrypoint: "bar", authorized: true }],
15
+ },
16
+ },
17
+ };
18
+
19
+ const policies2: ParsedSessionPolicies = {
20
+ verified: false,
21
+ contracts: {
22
+ "0xBBB": {
23
+ methods: [{ entrypoint: "bar", authorized: true }],
24
+ },
25
+ "0xAAA": {
26
+ methods: [{ entrypoint: "foo", authorized: true }],
27
+ },
28
+ },
29
+ };
30
+
31
+ const result1 = toWasmPolicies(policies1);
32
+ const result2 = toWasmPolicies(policies2);
33
+
34
+ expect(result1).toEqual(result2);
35
+ // First policy should be for 0xAAA (sorted alphabetically)
36
+ expect(result1[0]).toHaveProperty("target", "0xAAA");
37
+ expect(result1[1]).toHaveProperty("target", "0xBBB");
38
+ });
39
+
40
+ test("sorts methods within contracts by entrypoint", () => {
41
+ const policies1: ParsedSessionPolicies = {
42
+ verified: false,
43
+ contracts: {
44
+ "0xAAA": {
45
+ methods: [
46
+ { entrypoint: "zebra", authorized: true },
47
+ { entrypoint: "apple", authorized: true },
48
+ { entrypoint: "mango", authorized: true },
49
+ ],
50
+ },
51
+ },
52
+ };
53
+
54
+ const policies2: ParsedSessionPolicies = {
55
+ verified: false,
56
+ contracts: {
57
+ "0xAAA": {
58
+ methods: [
59
+ { entrypoint: "mango", authorized: true },
60
+ { entrypoint: "zebra", authorized: true },
61
+ { entrypoint: "apple", authorized: true },
62
+ ],
63
+ },
64
+ },
65
+ };
66
+
67
+ const result1 = toWasmPolicies(policies1);
68
+ const result2 = toWasmPolicies(policies2);
69
+
70
+ expect(result1).toEqual(result2);
71
+ });
72
+
73
+ test("produces consistent output for complex policies", () => {
74
+ const policies1: ParsedSessionPolicies = {
75
+ verified: false,
76
+ contracts: {
77
+ "0xCCC": {
78
+ methods: [
79
+ { entrypoint: "c_method", authorized: true },
80
+ { entrypoint: "a_method", authorized: true },
81
+ ],
82
+ },
83
+ "0xAAA": {
84
+ methods: [
85
+ { entrypoint: "z_method", authorized: true },
86
+ { entrypoint: "a_method", authorized: true },
87
+ ],
88
+ },
89
+ "0xBBB": {
90
+ methods: [{ entrypoint: "b_method", authorized: true }],
91
+ },
92
+ },
93
+ };
94
+
95
+ // Same policies in different order
96
+ const policies2: ParsedSessionPolicies = {
97
+ verified: false,
98
+ contracts: {
99
+ "0xBBB": {
100
+ methods: [{ entrypoint: "b_method", authorized: true }],
101
+ },
102
+ "0xAAA": {
103
+ methods: [
104
+ { entrypoint: "a_method", authorized: true },
105
+ { entrypoint: "z_method", authorized: true },
106
+ ],
107
+ },
108
+ "0xCCC": {
109
+ methods: [
110
+ { entrypoint: "a_method", authorized: true },
111
+ { entrypoint: "c_method", authorized: true },
112
+ ],
113
+ },
114
+ },
115
+ };
116
+
117
+ const result1 = toWasmPolicies(policies1);
118
+ const result2 = toWasmPolicies(policies2);
119
+
120
+ expect(result1).toEqual(result2);
121
+
122
+ // Verify order: 0xAAA first, then 0xBBB, then 0xCCC
123
+ // Within 0xAAA: a_method before z_method
124
+ expect(result1[0]).toHaveProperty("target", "0xAAA");
125
+ expect(result1[2]).toHaveProperty("target", "0xBBB");
126
+ expect(result1[3]).toHaveProperty("target", "0xCCC");
127
+ });
128
+
129
+ test("handles case-insensitive address sorting", () => {
130
+ const policies1: ParsedSessionPolicies = {
131
+ verified: false,
132
+ contracts: {
133
+ "0xaaa": {
134
+ methods: [{ entrypoint: "foo", authorized: true }],
135
+ },
136
+ "0xAAB": {
137
+ methods: [{ entrypoint: "bar", authorized: true }],
138
+ },
139
+ },
140
+ };
141
+
142
+ const policies2: ParsedSessionPolicies = {
143
+ verified: false,
144
+ contracts: {
145
+ "0xAAB": {
146
+ methods: [{ entrypoint: "bar", authorized: true }],
147
+ },
148
+ "0xaaa": {
149
+ methods: [{ entrypoint: "foo", authorized: true }],
150
+ },
151
+ },
152
+ };
153
+
154
+ const result1 = toWasmPolicies(policies1);
155
+ const result2 = toWasmPolicies(policies2);
156
+
157
+ expect(result1).toEqual(result2);
158
+ });
159
+
160
+ test("handles empty policies", () => {
161
+ const policies: ParsedSessionPolicies = {
162
+ verified: false,
163
+ contracts: {},
164
+ messages: [],
165
+ };
166
+
167
+ const result = toWasmPolicies(policies);
168
+ expect(result).toEqual([]);
169
+ });
170
+
171
+ test("handles undefined contracts and messages", () => {
172
+ const policies: ParsedSessionPolicies = {
173
+ verified: false,
174
+ };
175
+
176
+ const result = toWasmPolicies(policies);
177
+ expect(result).toEqual([]);
178
+ });
179
+ });
180
+
181
+ describe("ApprovalPolicy handling", () => {
182
+ test("creates ApprovalPolicy for approve methods with spender and amount", () => {
183
+ const policies: ParsedSessionPolicies = {
184
+ verified: false,
185
+ contracts: {
186
+ "0xTOKEN": {
187
+ methods: [
188
+ {
189
+ entrypoint: "approve",
190
+ spender: "0xSPENDER",
191
+ amount: "1000000000000000000",
192
+ authorized: true,
193
+ },
194
+ ],
195
+ },
196
+ },
197
+ };
198
+
199
+ const result = toWasmPolicies(policies);
200
+
201
+ expect(result).toHaveLength(1);
202
+ expect(result[0]).toEqual({
203
+ target: "0xTOKEN",
204
+ spender: "0xSPENDER",
205
+ amount: "1000000000000000000",
206
+ });
207
+ // Should NOT have method or authorized fields
208
+ expect(result[0]).not.toHaveProperty("method");
209
+ expect(result[0]).not.toHaveProperty("authorized");
210
+ });
211
+
212
+ test("converts numeric amount to string in ApprovalPolicy", () => {
213
+ const policies: ParsedSessionPolicies = {
214
+ verified: false,
215
+ contracts: {
216
+ "0xTOKEN": {
217
+ methods: [
218
+ {
219
+ entrypoint: "approve",
220
+ spender: "0xSPENDER",
221
+ amount: 1000000000000000000,
222
+ authorized: true,
223
+ },
224
+ ],
225
+ },
226
+ },
227
+ };
228
+
229
+ const result = toWasmPolicies(policies);
230
+
231
+ expect(result[0]).toHaveProperty("amount", "1000000000000000000");
232
+ });
233
+
234
+ test("falls back to CallPolicy for approve without spender", () => {
235
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
236
+
237
+ const policies: ParsedSessionPolicies = {
238
+ verified: false,
239
+ contracts: {
240
+ "0xTOKEN": {
241
+ methods: [
242
+ {
243
+ entrypoint: "approve",
244
+ authorized: true,
245
+ },
246
+ ],
247
+ },
248
+ },
249
+ };
250
+
251
+ const result = toWasmPolicies(policies);
252
+
253
+ expect(result).toHaveLength(1);
254
+ expect(result[0]).toHaveProperty("method");
255
+ expect(result[0]).toHaveProperty("authorized", true);
256
+ expect(result[0]).not.toHaveProperty("spender");
257
+ expect(warnSpy).toHaveBeenCalledWith(
258
+ expect.stringContaining("[DEPRECATED]"),
259
+ );
260
+
261
+ warnSpy.mockRestore();
262
+ });
263
+
264
+ test("falls back to CallPolicy for approve without amount", () => {
265
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
266
+
267
+ const policies: ParsedSessionPolicies = {
268
+ verified: false,
269
+ contracts: {
270
+ "0xTOKEN": {
271
+ methods: [
272
+ {
273
+ entrypoint: "approve",
274
+ spender: "0xSPENDER",
275
+ authorized: true,
276
+ },
277
+ ],
278
+ },
279
+ },
280
+ };
281
+
282
+ const result = toWasmPolicies(policies);
283
+
284
+ expect(result).toHaveLength(1);
285
+ expect(result[0]).toHaveProperty("method");
286
+ expect(result[0]).not.toHaveProperty("spender");
287
+ expect(warnSpy).toHaveBeenCalled();
288
+
289
+ warnSpy.mockRestore();
290
+ });
291
+
292
+ test("creates CallPolicy for non-approve methods", () => {
293
+ const policies: ParsedSessionPolicies = {
294
+ verified: false,
295
+ contracts: {
296
+ "0xCONTRACT": {
297
+ methods: [
298
+ {
299
+ entrypoint: "transfer",
300
+ authorized: true,
301
+ },
302
+ ],
303
+ },
304
+ },
305
+ };
306
+
307
+ const result = toWasmPolicies(policies);
308
+
309
+ expect(result).toHaveLength(1);
310
+ expect(result[0]).toHaveProperty("target", "0xCONTRACT");
311
+ expect(result[0]).toHaveProperty("method");
312
+ expect(result[0]).toHaveProperty("authorized", true);
313
+ });
314
+
315
+ test("handles mixed approve and non-approve methods", () => {
316
+ const policies: ParsedSessionPolicies = {
317
+ verified: false,
318
+ contracts: {
319
+ "0xTOKEN": {
320
+ methods: [
321
+ {
322
+ entrypoint: "approve",
323
+ spender: "0xSPENDER",
324
+ amount: "1000",
325
+ authorized: true,
326
+ },
327
+ {
328
+ entrypoint: "transfer",
329
+ authorized: true,
330
+ },
331
+ ],
332
+ },
333
+ },
334
+ };
335
+
336
+ const result = toWasmPolicies(policies);
337
+
338
+ expect(result).toHaveLength(2);
339
+
340
+ // First should be approve (sorted alphabetically)
341
+ const approvePolicy = result[0];
342
+ expect(approvePolicy).toHaveProperty("spender", "0xSPENDER");
343
+ expect(approvePolicy).toHaveProperty("amount", "1000");
344
+
345
+ // Second should be transfer
346
+ const transferPolicy = result[1];
347
+ expect(transferPolicy).toHaveProperty("method");
348
+ expect(transferPolicy).toHaveProperty("authorized", true);
349
+ });
350
+
351
+ test("sorts approve policies correctly among other methods", () => {
352
+ const policies: ParsedSessionPolicies = {
353
+ verified: false,
354
+ contracts: {
355
+ "0xTOKEN": {
356
+ methods: [
357
+ { entrypoint: "transfer", authorized: true },
358
+ {
359
+ entrypoint: "approve",
360
+ spender: "0xSPENDER",
361
+ amount: "1000",
362
+ authorized: true,
363
+ },
364
+ { entrypoint: "balance_of", authorized: true },
365
+ ],
366
+ },
367
+ },
368
+ };
369
+
370
+ const result = toWasmPolicies(policies);
371
+
372
+ expect(result).toHaveLength(3);
373
+ // Sorted order: approve, balance_of, transfer
374
+ expect(result[0]).toHaveProperty("spender"); // approve -> ApprovalPolicy
375
+ expect(result[1]).toHaveProperty("method"); // balance_of -> CallPolicy
376
+ expect(result[2]).toHaveProperty("method"); // transfer -> CallPolicy
377
+ });
378
+ });
379
+ });
package/src/controller.ts CHANGED
@@ -459,7 +459,14 @@ export default class ControllerProvider extends BaseProvider {
459
459
  return;
460
460
  }
461
461
 
462
- await this.keychain.openStarterPack(id, options);
462
+ const { onPurchaseComplete, ...starterpackOptions } = options ?? {};
463
+ this.iframes.keychain.setOnStarterpackPlay(onPurchaseComplete);
464
+ const sanitizedOptions =
465
+ Object.keys(starterpackOptions).length > 0
466
+ ? (starterpackOptions as Omit<StarterpackOptions, "onPurchaseComplete">)
467
+ : undefined;
468
+
469
+ await this.keychain.openStarterPack(id, sanitizedOptions);
463
470
  this.iframes.keychain?.open();
464
471
  }
465
472
 
@@ -570,20 +577,6 @@ export default class ControllerProvider extends BaseProvider {
570
577
  const url = new URL(chain.rpcUrl);
571
578
  const chainId = parseChainId(url);
572
579
 
573
- // Validate that mainnet and sepolia must use Cartridge RPC
574
- const isMainnet = chainId === constants.StarknetChainId.SN_MAIN;
575
- const isSepolia = chainId === constants.StarknetChainId.SN_SEPOLIA;
576
- const isCartridgeRpc = url.hostname === "api.cartridge.gg";
577
- const isLocalhost =
578
- url.hostname === "localhost" || url.hostname === "127.0.0.1";
579
-
580
- if ((isMainnet || isSepolia) && !(isCartridgeRpc || isLocalhost)) {
581
- throw new Error(
582
- `Only Cartridge RPC providers are allowed for ${isMainnet ? "mainnet" : "sepolia"}. ` +
583
- `Please use: https://api.cartridge.gg/x/starknet/${isMainnet ? "mainnet" : "sepolia"}/rpc/v0_9`,
584
- );
585
- }
586
-
587
580
  this.chains.set(chainId, chain);
588
581
  } catch (error) {
589
582
  console.error(`Failed to parse chainId for ${chain.rpcUrl}:`, error);
@@ -11,11 +11,15 @@ type KeychainIframeOptions = IFrameOptions<Keychain> &
11
11
  needsSessionCreation?: boolean;
12
12
  username?: string;
13
13
  onSessionCreated?: () => void;
14
+ onStarterpackPlay?: () => void;
14
15
  encryptedBlob?: string;
15
16
  };
16
17
 
18
+ const STARTERPACK_PLAY_CALLBACK_DELAY_MS = 200;
19
+
17
20
  export class KeychainIFrame extends IFrame<Keychain> {
18
21
  private walletBridge: WalletBridge;
22
+ private onStarterpackPlay?: () => void;
19
23
 
20
24
  constructor({
21
25
  url,
@@ -32,11 +36,13 @@ export class KeychainIFrame extends IFrame<Keychain> {
32
36
  needsSessionCreation,
33
37
  username,
34
38
  onSessionCreated,
39
+ onStarterpackPlay,
35
40
  encryptedBlob,
36
41
  propagateSessionErrors,
37
42
  errorDisplayMode,
38
43
  ...iframeOptions
39
44
  }: KeychainIframeOptions) {
45
+ let onStarterpackPlayHandler: (() => Promise<void>) | undefined;
40
46
  const _url = new URL(url ?? KEYCHAIN_URL);
41
47
  const walletBridge = new WalletBridge();
42
48
 
@@ -118,10 +124,32 @@ export class KeychainIFrame extends IFrame<Keychain> {
118
124
  onSessionCreated();
119
125
  }
120
126
  },
127
+ onStarterpackPlay: (_origin: string) => async () => {
128
+ if (onStarterpackPlayHandler) {
129
+ await onStarterpackPlayHandler();
130
+ }
131
+ },
121
132
  },
122
133
  });
123
134
 
124
135
  this.walletBridge = walletBridge;
136
+ this.onStarterpackPlay = onStarterpackPlay;
137
+ onStarterpackPlayHandler = async () => {
138
+ this.close();
139
+ const callback = this.onStarterpackPlay;
140
+ this.onStarterpackPlay = undefined;
141
+ if (!callback) {
142
+ return;
143
+ }
144
+ await new Promise((resolve) =>
145
+ setTimeout(resolve, STARTERPACK_PLAY_CALLBACK_DELAY_MS),
146
+ );
147
+ try {
148
+ callback();
149
+ } catch (error) {
150
+ console.error("Failed to run starterpack play callback:", error);
151
+ }
152
+ };
125
153
 
126
154
  // Expose the wallet bridge instance globally for WASM interop
127
155
  if (typeof window !== "undefined") {
@@ -132,4 +160,8 @@ export class KeychainIFrame extends IFrame<Keychain> {
132
160
  getWalletBridge(): WalletBridge {
133
161
  return this.walletBridge;
134
162
  }
163
+
164
+ setOnStarterpackPlay(callback?: () => void) {
165
+ this.onStarterpackPlay = callback;
166
+ }
135
167
  }