@cartridge/controller 0.13.5 → 0.13.7

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
@@ -70,6 +70,11 @@ export interface LookupResult {
70
70
  export interface LookupResponse {
71
71
  results: LookupResult[];
72
72
  }
73
+ export interface HeadlessUsernameLookupResult {
74
+ username: string;
75
+ exists: boolean;
76
+ signers: AuthOption[];
77
+ }
73
78
  export declare enum FeeSource {
74
79
  PAYMASTER = "PAYMASTER",
75
80
  CREDITS = "CREDITS"
package/dist/utils.d.ts CHANGED
@@ -8,13 +8,15 @@ export declare function normalizeCalls(calls: Call | Call[]): {
8
8
  contractAddress: string;
9
9
  calldata: import('starknet').HexCalldata;
10
10
  }[];
11
+ export declare function getPresetSessionPolicies(config: Record<string, unknown>, chainId: string): SessionPolicies | undefined;
11
12
  export declare function toSessionPolicies(policies: Policies): SessionPolicies;
12
13
  /**
13
14
  * Converts parsed session policies to WASM-compatible Policy objects.
14
15
  *
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.
16
+ * IMPORTANT: Policies are sorted canonically and addresses are normalized
17
+ * via getChecksumAddress before hashing. Without this, Object.keys/entries
18
+ * reordering or inconsistent address casing can cause identical policies to
19
+ * produce different merkle roots, leading to "session/not-registered" errors.
18
20
  * See: https://github.com/cartridge-gg/controller/issues/2357
19
21
  */
20
22
  export declare function toWasmPolicies(policies: ParsedSessionPolicies): Policy[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartridge/controller",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
4
4
  "description": "Cartridge Controller",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,7 +56,7 @@
56
56
  "vite-plugin-node-polyfills": "^0.23.0",
57
57
  "vite-plugin-top-level-await": "^1.4.4",
58
58
  "vite-plugin-wasm": "^3.4.1",
59
- "@cartridge/tsconfig": "0.13.5"
59
+ "@cartridge/tsconfig": "0.13.7"
60
60
  },
61
61
  "scripts": {
62
62
  "build:deps": "pnpm build",
@@ -0,0 +1,166 @@
1
+ import { constants } from "starknet";
2
+ import ControllerProvider from "../controller";
3
+ import { IMPLEMENTED_AUTH_OPTIONS } from "../types";
4
+
5
+ describe("lookupUsername", () => {
6
+ beforeEach(() => {
7
+ (global as any).fetch = jest.fn();
8
+ });
9
+
10
+ afterEach(() => {
11
+ jest.resetAllMocks();
12
+ });
13
+
14
+ test("returns normalized signer options in canonical order", async () => {
15
+ (global.fetch as jest.Mock).mockResolvedValue({
16
+ ok: true,
17
+ json: async () => ({
18
+ data: {
19
+ account: {
20
+ username: "alice",
21
+ controllers: {
22
+ edges: [
23
+ {
24
+ node: {
25
+ signers: [
26
+ {
27
+ isOriginal: true,
28
+ isRevoked: false,
29
+ metadata: {
30
+ __typename: "Eip191Credentials",
31
+ eip191: [
32
+ { provider: "metamask", ethAddress: "0x1" },
33
+ { provider: "google", ethAddress: "0x2" },
34
+ { provider: "unknown", ethAddress: "0x3" },
35
+ ],
36
+ },
37
+ },
38
+ {
39
+ isOriginal: true,
40
+ isRevoked: false,
41
+ metadata: { __typename: "PasswordCredentials" },
42
+ },
43
+ {
44
+ isOriginal: true,
45
+ isRevoked: false,
46
+ metadata: { __typename: "WebauthnCredentials" },
47
+ },
48
+ {
49
+ isOriginal: true,
50
+ isRevoked: true,
51
+ metadata: { __typename: "WebauthnCredentials" },
52
+ },
53
+ {
54
+ isOriginal: true,
55
+ isRevoked: false,
56
+ metadata: {
57
+ __typename: "Eip191Credentials",
58
+ eip191: [{ provider: "discord", ethAddress: "0x4" }],
59
+ },
60
+ },
61
+ ],
62
+ },
63
+ },
64
+ ],
65
+ },
66
+ },
67
+ },
68
+ }),
69
+ });
70
+
71
+ const provider = new ControllerProvider({ lazyload: true });
72
+ const result = await provider.lookupUsername("alice");
73
+ const expectedOrder = IMPLEMENTED_AUTH_OPTIONS.filter((option) =>
74
+ ["google", "webauthn", "discord", "password", "metamask"].includes(
75
+ option,
76
+ ),
77
+ );
78
+
79
+ expect(result).toEqual({
80
+ username: "alice",
81
+ exists: true,
82
+ signers: expectedOrder,
83
+ });
84
+ expect(global.fetch).toHaveBeenCalledWith(
85
+ "https://api.cartridge.gg/query",
86
+ expect.any(Object),
87
+ );
88
+ });
89
+
90
+ test("filters non-original signers on non-mainnet chains", async () => {
91
+ (global.fetch as jest.Mock).mockResolvedValue({
92
+ ok: true,
93
+ json: async () => ({
94
+ data: {
95
+ account: {
96
+ username: "alice",
97
+ controllers: {
98
+ edges: [
99
+ {
100
+ node: {
101
+ signers: [
102
+ {
103
+ isOriginal: false,
104
+ isRevoked: false,
105
+ metadata: {
106
+ __typename: "Eip191Credentials",
107
+ eip191: [{ provider: "google", ethAddress: "0x1" }],
108
+ },
109
+ },
110
+ {
111
+ isOriginal: true,
112
+ isRevoked: false,
113
+ metadata: { __typename: "PasswordCredentials" },
114
+ },
115
+ ],
116
+ },
117
+ },
118
+ ],
119
+ },
120
+ },
121
+ },
122
+ }),
123
+ });
124
+
125
+ const provider = new ControllerProvider({
126
+ lazyload: true,
127
+ defaultChainId: constants.StarknetChainId.SN_SEPOLIA,
128
+ });
129
+ const result = await provider.lookupUsername("alice");
130
+
131
+ expect(result.signers).toEqual(["password"]);
132
+ });
133
+
134
+ test("returns exists=false for unknown usernames", async () => {
135
+ (global.fetch as jest.Mock).mockResolvedValue({
136
+ ok: true,
137
+ json: async () => ({
138
+ data: {
139
+ account: null,
140
+ },
141
+ }),
142
+ });
143
+
144
+ const provider = new ControllerProvider({ lazyload: true });
145
+ const result = await provider.lookupUsername("missing-user");
146
+
147
+ expect(result).toEqual({
148
+ username: "missing-user",
149
+ exists: false,
150
+ signers: [],
151
+ });
152
+ });
153
+
154
+ test("throws on network failures", async () => {
155
+ (global.fetch as jest.Mock).mockResolvedValue({
156
+ ok: false,
157
+ status: 503,
158
+ });
159
+
160
+ const provider = new ControllerProvider({ lazyload: true });
161
+
162
+ await expect(provider.lookupUsername("alice")).rejects.toThrow(
163
+ "HTTP error! status: 503",
164
+ );
165
+ });
166
+ });
@@ -1,16 +1,27 @@
1
+ import { getChecksumAddress } from "starknet";
1
2
  import { toWasmPolicies } from "../utils";
2
3
  import { ParsedSessionPolicies } from "../policies";
3
4
 
5
+ // Valid hex addresses for testing (short but valid for getChecksumAddress)
6
+ const ADDR_A = "0x0aaa";
7
+ const ADDR_B = "0x0bbb";
8
+ const ADDR_C = "0x0ccc";
9
+
10
+ // Pre-compute checksummed forms
11
+ const ADDR_A_CS = getChecksumAddress(ADDR_A);
12
+ const ADDR_B_CS = getChecksumAddress(ADDR_B);
13
+ const ADDR_C_CS = getChecksumAddress(ADDR_C);
14
+
4
15
  describe("toWasmPolicies", () => {
5
16
  describe("canonical ordering", () => {
6
17
  test("sorts contracts by address regardless of input order", () => {
7
18
  const policies1: ParsedSessionPolicies = {
8
19
  verified: false,
9
20
  contracts: {
10
- "0xAAA": {
21
+ [ADDR_A]: {
11
22
  methods: [{ entrypoint: "foo", authorized: true }],
12
23
  },
13
- "0xBBB": {
24
+ [ADDR_B]: {
14
25
  methods: [{ entrypoint: "bar", authorized: true }],
15
26
  },
16
27
  },
@@ -19,10 +30,10 @@ describe("toWasmPolicies", () => {
19
30
  const policies2: ParsedSessionPolicies = {
20
31
  verified: false,
21
32
  contracts: {
22
- "0xBBB": {
33
+ [ADDR_B]: {
23
34
  methods: [{ entrypoint: "bar", authorized: true }],
24
35
  },
25
- "0xAAA": {
36
+ [ADDR_A]: {
26
37
  methods: [{ entrypoint: "foo", authorized: true }],
27
38
  },
28
39
  },
@@ -32,16 +43,16 @@ describe("toWasmPolicies", () => {
32
43
  const result2 = toWasmPolicies(policies2);
33
44
 
34
45
  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");
46
+ // First policy should be for ADDR_A (sorted alphabetically)
47
+ expect(result1[0]).toHaveProperty("target", ADDR_A_CS);
48
+ expect(result1[1]).toHaveProperty("target", ADDR_B_CS);
38
49
  });
39
50
 
40
51
  test("sorts methods within contracts by entrypoint", () => {
41
52
  const policies1: ParsedSessionPolicies = {
42
53
  verified: false,
43
54
  contracts: {
44
- "0xAAA": {
55
+ [ADDR_A]: {
45
56
  methods: [
46
57
  { entrypoint: "zebra", authorized: true },
47
58
  { entrypoint: "apple", authorized: true },
@@ -54,7 +65,7 @@ describe("toWasmPolicies", () => {
54
65
  const policies2: ParsedSessionPolicies = {
55
66
  verified: false,
56
67
  contracts: {
57
- "0xAAA": {
68
+ [ADDR_A]: {
58
69
  methods: [
59
70
  { entrypoint: "mango", authorized: true },
60
71
  { entrypoint: "zebra", authorized: true },
@@ -74,19 +85,19 @@ describe("toWasmPolicies", () => {
74
85
  const policies1: ParsedSessionPolicies = {
75
86
  verified: false,
76
87
  contracts: {
77
- "0xCCC": {
88
+ [ADDR_C]: {
78
89
  methods: [
79
90
  { entrypoint: "c_method", authorized: true },
80
91
  { entrypoint: "a_method", authorized: true },
81
92
  ],
82
93
  },
83
- "0xAAA": {
94
+ [ADDR_A]: {
84
95
  methods: [
85
96
  { entrypoint: "z_method", authorized: true },
86
97
  { entrypoint: "a_method", authorized: true },
87
98
  ],
88
99
  },
89
- "0xBBB": {
100
+ [ADDR_B]: {
90
101
  methods: [{ entrypoint: "b_method", authorized: true }],
91
102
  },
92
103
  },
@@ -96,16 +107,16 @@ describe("toWasmPolicies", () => {
96
107
  const policies2: ParsedSessionPolicies = {
97
108
  verified: false,
98
109
  contracts: {
99
- "0xBBB": {
110
+ [ADDR_B]: {
100
111
  methods: [{ entrypoint: "b_method", authorized: true }],
101
112
  },
102
- "0xAAA": {
113
+ [ADDR_A]: {
103
114
  methods: [
104
115
  { entrypoint: "a_method", authorized: true },
105
116
  { entrypoint: "z_method", authorized: true },
106
117
  ],
107
118
  },
108
- "0xCCC": {
119
+ [ADDR_C]: {
109
120
  methods: [
110
121
  { entrypoint: "a_method", authorized: true },
111
122
  { entrypoint: "c_method", authorized: true },
@@ -119,21 +130,21 @@ describe("toWasmPolicies", () => {
119
130
 
120
131
  expect(result1).toEqual(result2);
121
132
 
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");
133
+ // Verify order: ADDR_A first, then ADDR_B, then ADDR_C
134
+ // Within ADDR_A: a_method before z_method
135
+ expect(result1[0]).toHaveProperty("target", ADDR_A_CS);
136
+ expect(result1[2]).toHaveProperty("target", ADDR_B_CS);
137
+ expect(result1[3]).toHaveProperty("target", ADDR_C_CS);
127
138
  });
128
139
 
129
140
  test("handles case-insensitive address sorting", () => {
130
141
  const policies1: ParsedSessionPolicies = {
131
142
  verified: false,
132
143
  contracts: {
133
- "0xaaa": {
144
+ "0x0aaa": {
134
145
  methods: [{ entrypoint: "foo", authorized: true }],
135
146
  },
136
- "0xAAB": {
147
+ "0x0AAB": {
137
148
  methods: [{ entrypoint: "bar", authorized: true }],
138
149
  },
139
150
  },
@@ -142,10 +153,10 @@ describe("toWasmPolicies", () => {
142
153
  const policies2: ParsedSessionPolicies = {
143
154
  verified: false,
144
155
  contracts: {
145
- "0xAAB": {
156
+ "0x0AAB": {
146
157
  methods: [{ entrypoint: "bar", authorized: true }],
147
158
  },
148
- "0xaaa": {
159
+ "0x0aaa": {
149
160
  methods: [{ entrypoint: "foo", authorized: true }],
150
161
  },
151
162
  },
@@ -157,6 +168,34 @@ describe("toWasmPolicies", () => {
157
168
  expect(result1).toEqual(result2);
158
169
  });
159
170
 
171
+ test("normalizes address casing via getChecksumAddress", () => {
172
+ const addr =
173
+ "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7";
174
+ const policies1: ParsedSessionPolicies = {
175
+ verified: false,
176
+ contracts: {
177
+ [addr.toLowerCase()]: {
178
+ methods: [{ entrypoint: "transfer", authorized: true }],
179
+ },
180
+ },
181
+ };
182
+
183
+ const policies2: ParsedSessionPolicies = {
184
+ verified: false,
185
+ contracts: {
186
+ [addr.toUpperCase().replace("0X", "0x")]: {
187
+ methods: [{ entrypoint: "transfer", authorized: true }],
188
+ },
189
+ },
190
+ };
191
+
192
+ const result1 = toWasmPolicies(policies1);
193
+ const result2 = toWasmPolicies(policies2);
194
+
195
+ expect(result1).toEqual(result2);
196
+ expect(result1[0]).toHaveProperty("target", getChecksumAddress(addr));
197
+ });
198
+
160
199
  test("handles empty policies", () => {
161
200
  const policies: ParsedSessionPolicies = {
162
201
  verified: false,
@@ -179,15 +218,21 @@ describe("toWasmPolicies", () => {
179
218
  });
180
219
 
181
220
  describe("ApprovalPolicy handling", () => {
221
+ const TOKEN =
222
+ "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7";
223
+ const TOKEN_CS = getChecksumAddress(TOKEN);
224
+ const SPENDER =
225
+ "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
226
+
182
227
  test("creates ApprovalPolicy for approve methods with spender and amount", () => {
183
228
  const policies: ParsedSessionPolicies = {
184
229
  verified: false,
185
230
  contracts: {
186
- "0xTOKEN": {
231
+ [TOKEN]: {
187
232
  methods: [
188
233
  {
189
234
  entrypoint: "approve",
190
- spender: "0xSPENDER",
235
+ spender: SPENDER,
191
236
  amount: "1000000000000000000",
192
237
  authorized: true,
193
238
  },
@@ -200,8 +245,8 @@ describe("toWasmPolicies", () => {
200
245
 
201
246
  expect(result).toHaveLength(1);
202
247
  expect(result[0]).toEqual({
203
- target: "0xTOKEN",
204
- spender: "0xSPENDER",
248
+ target: TOKEN_CS,
249
+ spender: SPENDER,
205
250
  amount: "1000000000000000000",
206
251
  });
207
252
  // Should NOT have method or authorized fields
@@ -213,11 +258,11 @@ describe("toWasmPolicies", () => {
213
258
  const policies: ParsedSessionPolicies = {
214
259
  verified: false,
215
260
  contracts: {
216
- "0xTOKEN": {
261
+ [TOKEN]: {
217
262
  methods: [
218
263
  {
219
264
  entrypoint: "approve",
220
- spender: "0xSPENDER",
265
+ spender: SPENDER,
221
266
  amount: 1000000000000000000,
222
267
  authorized: true,
223
268
  },
@@ -237,7 +282,7 @@ describe("toWasmPolicies", () => {
237
282
  const policies: ParsedSessionPolicies = {
238
283
  verified: false,
239
284
  contracts: {
240
- "0xTOKEN": {
285
+ [TOKEN]: {
241
286
  methods: [
242
287
  {
243
288
  entrypoint: "approve",
@@ -267,11 +312,11 @@ describe("toWasmPolicies", () => {
267
312
  const policies: ParsedSessionPolicies = {
268
313
  verified: false,
269
314
  contracts: {
270
- "0xTOKEN": {
315
+ [TOKEN]: {
271
316
  methods: [
272
317
  {
273
318
  entrypoint: "approve",
274
- spender: "0xSPENDER",
319
+ spender: SPENDER,
275
320
  authorized: true,
276
321
  },
277
322
  ],
@@ -290,10 +335,14 @@ describe("toWasmPolicies", () => {
290
335
  });
291
336
 
292
337
  test("creates CallPolicy for non-approve methods", () => {
338
+ const CONTRACT =
339
+ "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49";
340
+ const CONTRACT_CS = getChecksumAddress(CONTRACT);
341
+
293
342
  const policies: ParsedSessionPolicies = {
294
343
  verified: false,
295
344
  contracts: {
296
- "0xCONTRACT": {
345
+ [CONTRACT]: {
297
346
  methods: [
298
347
  {
299
348
  entrypoint: "transfer",
@@ -307,7 +356,7 @@ describe("toWasmPolicies", () => {
307
356
  const result = toWasmPolicies(policies);
308
357
 
309
358
  expect(result).toHaveLength(1);
310
- expect(result[0]).toHaveProperty("target", "0xCONTRACT");
359
+ expect(result[0]).toHaveProperty("target", CONTRACT_CS);
311
360
  expect(result[0]).toHaveProperty("method");
312
361
  expect(result[0]).toHaveProperty("authorized", true);
313
362
  });
@@ -316,11 +365,11 @@ describe("toWasmPolicies", () => {
316
365
  const policies: ParsedSessionPolicies = {
317
366
  verified: false,
318
367
  contracts: {
319
- "0xTOKEN": {
368
+ [TOKEN]: {
320
369
  methods: [
321
370
  {
322
371
  entrypoint: "approve",
323
- spender: "0xSPENDER",
372
+ spender: SPENDER,
324
373
  amount: "1000",
325
374
  authorized: true,
326
375
  },
@@ -339,7 +388,7 @@ describe("toWasmPolicies", () => {
339
388
 
340
389
  // First should be approve (sorted alphabetically)
341
390
  const approvePolicy = result[0];
342
- expect(approvePolicy).toHaveProperty("spender", "0xSPENDER");
391
+ expect(approvePolicy).toHaveProperty("spender", SPENDER);
343
392
  expect(approvePolicy).toHaveProperty("amount", "1000");
344
393
 
345
394
  // Second should be transfer
@@ -352,12 +401,12 @@ describe("toWasmPolicies", () => {
352
401
  const policies: ParsedSessionPolicies = {
353
402
  verified: false,
354
403
  contracts: {
355
- "0xTOKEN": {
404
+ [TOKEN]: {
356
405
  methods: [
357
406
  { entrypoint: "transfer", authorized: true },
358
407
  {
359
408
  entrypoint: "approve",
360
- spender: "0xSPENDER",
409
+ spender: SPENDER,
361
410
  amount: "1000",
362
411
  authorized: true,
363
412
  },
package/src/controller.ts CHANGED
@@ -15,6 +15,7 @@ import { KEYCHAIN_URL } from "./constants";
15
15
  import { HeadlessAuthenticationError, NotReadyToConnect } from "./errors";
16
16
  import { KeychainIFrame } from "./iframe";
17
17
  import BaseProvider from "./provider";
18
+ import { lookupUsername as lookupUsernameApi } from "./lookup";
18
19
  import {
19
20
  AuthOptions,
20
21
  Chain,
@@ -28,6 +29,7 @@ import {
28
29
  ProfileContextTypeVariant,
29
30
  ResponseCodes,
30
31
  OpenOptions,
32
+ HeadlessUsernameLookupResult,
31
33
  StarterpackOptions,
32
34
  } from "./types";
33
35
  import { validateRedirectUrl } from "./url-validator";
@@ -530,6 +532,17 @@ export default class ControllerProvider extends BaseProvider {
530
532
  return this.keychain.username();
531
533
  }
532
534
 
535
+ async lookupUsername(
536
+ username: string,
537
+ ): Promise<HeadlessUsernameLookupResult> {
538
+ const trimmed = username.trim();
539
+ if (!trimmed) {
540
+ throw new Error("Username is required");
541
+ }
542
+
543
+ return lookupUsernameApi(trimmed, this.selectedChain);
544
+ }
545
+
533
546
  openPurchaseCredits() {
534
547
  if (!this.iframes) {
535
548
  return;
@@ -104,6 +104,13 @@ export class KeychainIFrame extends IFrame<Keychain> {
104
104
  );
105
105
  } else if (preset) {
106
106
  _url.searchParams.set("preset", preset);
107
+ if (policies) {
108
+ console.warn(
109
+ "[Controller] Both `preset` and `policies` provided to ControllerProvider. " +
110
+ "Policies are ignored when preset is set. " +
111
+ "Use `shouldOverridePresetPolicies: true` to override.",
112
+ );
113
+ }
107
114
  }
108
115
 
109
116
  // Add encrypted blob to URL fragment (hash) if present