@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/src/lookup.ts CHANGED
@@ -1,8 +1,43 @@
1
- import { LookupRequest, LookupResponse } from "./types";
2
- import { num } from "starknet";
1
+ import {
2
+ AuthOption,
3
+ HeadlessUsernameLookupResult,
4
+ IMPLEMENTED_AUTH_OPTIONS,
5
+ LookupRequest,
6
+ LookupResponse,
7
+ } from "./types";
8
+ import { constants, num } from "starknet";
3
9
  import { API_URL } from "./constants";
4
10
 
5
11
  const cache = new Map<string, string>();
12
+ const QUERY_URL = `${API_URL}/query`;
13
+
14
+ type LookupSigner = {
15
+ isOriginal: boolean;
16
+ isRevoked: boolean;
17
+ metadata: {
18
+ __typename: string;
19
+ eip191?: Array<{
20
+ provider: string;
21
+ ethAddress: string;
22
+ }> | null;
23
+ };
24
+ };
25
+
26
+ type LookupSignersQueryResponse = {
27
+ data?: {
28
+ account?: {
29
+ username: string;
30
+ controllers?: {
31
+ edges?: Array<{
32
+ node?: {
33
+ signers?: LookupSigner[] | null;
34
+ } | null;
35
+ } | null> | null;
36
+ } | null;
37
+ } | null;
38
+ };
39
+ errors?: Array<{ message?: string }>;
40
+ };
6
41
 
7
42
  async function lookup(request: LookupRequest): Promise<LookupResponse> {
8
43
  if (!request.addresses?.length && !request.usernames?.length) {
@@ -24,6 +59,139 @@ async function lookup(request: LookupRequest): Promise<LookupResponse> {
24
59
  return response.json();
25
60
  }
26
61
 
62
+ async function queryLookupSigners(
63
+ username: string,
64
+ ): Promise<LookupSignersQueryResponse> {
65
+ const response = await fetch(QUERY_URL, {
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ },
70
+ body: JSON.stringify({
71
+ query: `
72
+ query LookupSigners($username: String!) {
73
+ account(username: $username) {
74
+ username
75
+ controllers(first: 1) {
76
+ edges {
77
+ node {
78
+ signers {
79
+ isOriginal
80
+ isRevoked
81
+ metadata {
82
+ __typename
83
+ ... on Eip191Credentials {
84
+ eip191 {
85
+ provider
86
+ ethAddress
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ `,
97
+ variables: { username },
98
+ }),
99
+ });
100
+
101
+ if (!response.ok) {
102
+ throw new Error(`HTTP error! status: ${response.status}`);
103
+ }
104
+
105
+ return response.json();
106
+ }
107
+
108
+ function normalizeProvider(provider: string): AuthOption | undefined {
109
+ const normalized = provider.toLowerCase() as AuthOption;
110
+ if (!HEADLESS_AUTH_OPTIONS.includes(normalized)) {
111
+ return undefined;
112
+ }
113
+ return normalized;
114
+ }
115
+
116
+ const HEADLESS_AUTH_OPTIONS: AuthOption[] = [
117
+ "google",
118
+ "webauthn",
119
+ "discord",
120
+ "walletconnect",
121
+ "password",
122
+ "metamask",
123
+ "rabby",
124
+ "phantom-evm",
125
+ ].filter((option) => IMPLEMENTED_AUTH_OPTIONS.includes(option));
126
+
127
+ function normalizeSignerOptions(
128
+ signers: LookupSigner[] | undefined,
129
+ chainId: string,
130
+ ): AuthOption[] {
131
+ if (!signers || signers.length === 0) {
132
+ return [];
133
+ }
134
+
135
+ const isMainnet = chainId === constants.StarknetChainId.SN_MAIN;
136
+ const available = signers.filter(
137
+ (signer) => !signer.isRevoked && (isMainnet || signer.isOriginal),
138
+ );
139
+
140
+ const signerSet = new Set<AuthOption>();
141
+ for (const signer of available) {
142
+ switch (signer.metadata.__typename) {
143
+ case "WebauthnCredentials":
144
+ signerSet.add("webauthn");
145
+ break;
146
+ case "PasswordCredentials":
147
+ signerSet.add("password");
148
+ break;
149
+ case "Eip191Credentials":
150
+ signer.metadata.eip191?.forEach((entry) => {
151
+ const provider = normalizeProvider(entry.provider);
152
+ if (provider) {
153
+ signerSet.add(provider);
154
+ }
155
+ });
156
+ break;
157
+ default:
158
+ break;
159
+ }
160
+ }
161
+
162
+ return HEADLESS_AUTH_OPTIONS.filter((option) => signerSet.has(option));
163
+ }
164
+
165
+ export async function lookupUsername(
166
+ username: string,
167
+ chainId: string,
168
+ ): Promise<HeadlessUsernameLookupResult> {
169
+ const response = await queryLookupSigners(username);
170
+ if (response.errors?.length) {
171
+ throw new Error(response.errors[0].message || "Lookup query failed");
172
+ }
173
+
174
+ const account = response.data?.account;
175
+ if (!account) {
176
+ return {
177
+ username,
178
+ exists: false,
179
+ signers: [],
180
+ };
181
+ }
182
+
183
+ const controller = account.controllers?.edges?.[0]?.node;
184
+ const signers = normalizeSignerOptions(
185
+ controller?.signers ?? undefined,
186
+ chainId,
187
+ );
188
+ return {
189
+ username: account.username,
190
+ exists: true,
191
+ signers,
192
+ };
193
+ }
194
+
27
195
  export async function lookupUsernames(
28
196
  usernames: string[],
29
197
  ): Promise<Map<string, string>> {
@@ -4,14 +4,14 @@ import {
4
4
  signerToGuid,
5
5
  subscribeCreateSession,
6
6
  } from "@cartridge/controller-wasm";
7
- import { SessionPolicies } from "@cartridge/presets";
7
+ import { loadConfig, SessionPolicies } from "@cartridge/presets";
8
8
  import { AddStarknetChainParameters } from "@starknet-io/types-js";
9
9
  import { encode } from "starknet";
10
10
  import { API_URL, KEYCHAIN_URL } from "../constants";
11
- import { ParsedSessionPolicies } from "../policies";
11
+ import { parsePolicies, ParsedSessionPolicies } from "../policies";
12
12
  import BaseProvider from "../provider";
13
13
  import { AuthOptions } from "../types";
14
- import { toWasmPolicies } from "../utils";
14
+ import { getPresetSessionPolicies, toWasmPolicies } from "../utils";
15
15
  import SessionAccount from "./account";
16
16
 
17
17
  interface SessionRegistration {
@@ -29,7 +29,9 @@ interface SessionRegistration {
29
29
  export type SessionOptions = {
30
30
  rpc: string;
31
31
  chainId: string;
32
- policies: SessionPolicies;
32
+ policies?: SessionPolicies;
33
+ preset?: string;
34
+ shouldOverridePresetPolicies?: boolean;
33
35
  redirectUrl: string;
34
36
  disconnectRedirectUrl?: string;
35
37
  keychainUrl?: string;
@@ -47,10 +49,12 @@ export default class SessionProvider extends BaseProvider {
47
49
  protected _redirectUrl: string;
48
50
  protected _disconnectRedirectUrl?: string;
49
51
  protected _policies: ParsedSessionPolicies;
52
+ protected _preset?: string;
53
+ private _ready: Promise<void>;
50
54
  protected _keychainUrl: string;
51
55
  protected _apiUrl: string;
52
- protected _publicKey: string;
53
- protected _sessionKeyGuid: string;
56
+ protected _publicKey!: string;
57
+ protected _sessionKeyGuid!: string;
54
58
  protected _signupOptions?: AuthOptions;
55
59
  public reopenBrowser: boolean = true;
56
60
 
@@ -58,6 +62,8 @@ export default class SessionProvider extends BaseProvider {
58
62
  rpc,
59
63
  chainId,
60
64
  policies,
65
+ preset,
66
+ shouldOverridePresetPolicies,
61
67
  redirectUrl,
62
68
  disconnectRedirectUrl,
63
69
  keychainUrl,
@@ -66,27 +72,27 @@ export default class SessionProvider extends BaseProvider {
66
72
  }: SessionOptions) {
67
73
  super();
68
74
 
69
- this._policies = {
70
- verified: false,
71
- contracts: policies.contracts
72
- ? Object.fromEntries(
73
- Object.entries(policies.contracts).map(([address, contract]) => [
74
- address,
75
- {
76
- ...contract,
77
- methods: contract.methods.map((method) => ({
78
- ...method,
79
- authorized: true,
80
- })),
81
- },
82
- ]),
83
- )
84
- : undefined,
85
- messages: policies.messages?.map((message) => ({
86
- ...message,
87
- authorized: true,
88
- })),
89
- };
75
+ if (!policies && !preset) {
76
+ throw new Error("Either `policies` or `preset` must be provided");
77
+ }
78
+
79
+ // Policy precedence logic (matching ControllerProvider):
80
+ // 1. If shouldOverridePresetPolicies is true and policies are provided, use policies
81
+ // 2. Otherwise, if preset is defined, resolve policies from preset
82
+ // 3. Otherwise, use provided policies
83
+ if ((!preset || shouldOverridePresetPolicies) && policies) {
84
+ this._policies = parsePolicies(policies);
85
+ } else {
86
+ this._preset = preset;
87
+ if (policies) {
88
+ console.warn(
89
+ "[Controller] Both `preset` and `policies` provided to SessionProvider. " +
90
+ "Policies are ignored when preset is set. " +
91
+ "Use `shouldOverridePresetPolicies: true` to override.",
92
+ );
93
+ }
94
+ this._policies = { verified: false };
95
+ }
90
96
 
91
97
  this._rpcUrl = rpc;
92
98
  this._chainId = chainId;
@@ -96,6 +102,33 @@ export default class SessionProvider extends BaseProvider {
96
102
  this._apiUrl = apiUrl ?? API_URL;
97
103
  this._signupOptions = signupOptions;
98
104
 
105
+ // Eagerly start async init: resolve preset policies (if any),
106
+ // then try to restore an existing session from storage.
107
+ // All public async methods await this before proceeding.
108
+ this._ready = this._init();
109
+
110
+ if (typeof window !== "undefined") {
111
+ (window as any).starknet_controller_session = this;
112
+ }
113
+ }
114
+
115
+ private async _init(): Promise<void> {
116
+ if (this._preset) {
117
+ const config = await loadConfig(this._preset);
118
+ if (!config) {
119
+ throw new Error(`Failed to load preset: ${this._preset}`);
120
+ }
121
+
122
+ const sessionPolicies = getPresetSessionPolicies(config, this._chainId);
123
+ if (!sessionPolicies) {
124
+ throw new Error(
125
+ `No policies found for chain ${this._chainId} in preset ${this._preset}`,
126
+ );
127
+ }
128
+
129
+ this._policies = parsePolicies(sessionPolicies);
130
+ }
131
+
99
132
  const account = this.tryRetrieveFromQueryOrStorage();
100
133
  if (!account) {
101
134
  const pk = stark.randomAddress();
@@ -125,10 +158,6 @@ export default class SessionProvider extends BaseProvider {
125
158
  starknet: { privateKey: encode.addHexPrefix(jsonPk.privKey) },
126
159
  });
127
160
  }
128
-
129
- if (typeof window !== "undefined") {
130
- (window as any).starknet_controller_session = this;
131
- }
132
161
  }
133
162
 
134
163
  private validatePoliciesSubset(
@@ -218,25 +247,18 @@ export default class SessionProvider extends BaseProvider {
218
247
  }
219
248
 
220
249
  async username() {
221
- await this.tryRetrieveFromQueryOrStorage();
250
+ await this._ready;
222
251
  return this._username;
223
252
  }
224
253
 
225
254
  async probe(): Promise<WalletAccount | undefined> {
226
- if (this.account) {
227
- return this.account;
228
- }
229
-
230
- this.account = this.tryRetrieveFromQueryOrStorage();
255
+ await this._ready;
231
256
  return this.account;
232
257
  }
233
258
 
234
259
  async connect(): Promise<WalletAccount | undefined> {
235
- if (this.account) {
236
- return this.account;
237
- }
260
+ await this._ready;
238
261
 
239
- this.account = this.tryRetrieveFromQueryOrStorage();
240
262
  if (this.account) {
241
263
  return this.account;
242
264
  }
@@ -259,13 +281,18 @@ export default class SessionProvider extends BaseProvider {
259
281
  this._sessionKeyGuid = signerToGuid({
260
282
  starknet: { privateKey: encode.addHexPrefix(pk) },
261
283
  });
262
- let url = `${
263
- this._keychainUrl
264
- }/session?public_key=${this._publicKey}&redirect_uri=${
265
- this._redirectUrl
266
- }&redirect_query_name=startapp&policies=${JSON.stringify(
267
- this._policies,
268
- )}&rpc_url=${this._rpcUrl}`;
284
+ let url =
285
+ `${this._keychainUrl}` +
286
+ `/session?public_key=${this._publicKey}` +
287
+ `&redirect_uri=${this._redirectUrl}` +
288
+ `&redirect_query_name=startapp` +
289
+ `&rpc_url=${this._rpcUrl}`;
290
+
291
+ if (this._preset) {
292
+ url += `&preset=${encodeURIComponent(this._preset)}`;
293
+ } else {
294
+ url += `&policies=${encodeURIComponent(JSON.stringify(this._policies))}`;
295
+ }
269
296
 
270
297
  if (this._signupOptions) {
271
298
  url += `&signers=${encodeURIComponent(JSON.stringify(this._signupOptions))}`;
package/src/types.ts CHANGED
@@ -120,6 +120,12 @@ export interface LookupResponse {
120
120
  results: LookupResult[];
121
121
  }
122
122
 
123
+ export interface HeadlessUsernameLookupResult {
124
+ username: string;
125
+ exists: boolean;
126
+ signers: AuthOption[];
127
+ }
128
+
123
129
  export enum FeeSource {
124
130
  PAYMASTER = "PAYMASTER",
125
131
  CREDITS = "CREDITS",
package/src/utils.ts CHANGED
@@ -48,6 +48,19 @@ export function normalizeCalls(calls: Call | Call[]) {
48
48
  });
49
49
  }
50
50
 
51
+ export function getPresetSessionPolicies(
52
+ config: Record<string, unknown>,
53
+ chainId: string,
54
+ ): SessionPolicies | undefined {
55
+ const decodedChainId = shortString.decodeShortString(chainId);
56
+ const chains = config.chains as
57
+ | Record<string, Record<string, unknown>>
58
+ | undefined;
59
+ const chainConfig = chains?.[decodedChainId];
60
+ if (!chainConfig?.policies) return undefined;
61
+ return toSessionPolicies(chainConfig.policies as Policies);
62
+ }
63
+
51
64
  export function toSessionPolicies(policies: Policies): SessionPolicies {
52
65
  return Array.isArray(policies)
53
66
  ? policies.reduce<SessionPolicies>(
@@ -92,9 +105,10 @@ export function toSessionPolicies(policies: Policies): SessionPolicies {
92
105
  /**
93
106
  * Converts parsed session policies to WASM-compatible Policy objects.
94
107
  *
95
- * IMPORTANT: Policies are sorted canonically before hashing. Without this,
96
- * Object.keys/entries reordering can cause identical policies to produce
97
- * different merkle roots, leading to "session/not-registered" errors.
108
+ * IMPORTANT: Policies are sorted canonically and addresses are normalized
109
+ * via getChecksumAddress before hashing. Without this, Object.keys/entries
110
+ * reordering or inconsistent address casing can cause identical policies to
111
+ * produce different merkle roots, leading to "session/not-registered" errors.
98
112
  * See: https://github.com/cartridge-gg/controller/issues/2357
99
113
  */
100
114
  export function toWasmPolicies(policies: ParsedSessionPolicies): Policy[] {
@@ -110,7 +124,7 @@ export function toWasmPolicies(policies: ParsedSessionPolicies): Policy[] {
110
124
  if (m.entrypoint === "approve") {
111
125
  if ("spender" in m && "amount" in m && m.spender && m.amount) {
112
126
  const approvalPolicy: ApprovalPolicy = {
113
- target,
127
+ target: getChecksumAddress(target),
114
128
  spender: m.spender,
115
129
  amount: String(m.amount),
116
130
  };
@@ -127,7 +141,7 @@ export function toWasmPolicies(policies: ParsedSessionPolicies): Policy[] {
127
141
 
128
142
  // For non-approve methods and legacy approve, create a regular CallPolicy
129
143
  return {
130
- target,
144
+ target: getChecksumAddress(target),
131
145
  method: hash.getSelectorFromName(m.entrypoint),
132
146
  authorized: !!m.authorized,
133
147
  };