Skip to content

Commit ba15f09

Browse files
kronosapiensclaude
andauthored
fix: SessionProvider preset support and address normalization (#2401)
## Summary Fixes #2399. Addresses `session/not-registered` errors caused by policy hash divergence between the SDK and keychain. ### SDK changes (`@cartridge/controller`) - **Add `preset` and `shouldOverridePresetPolicies` to `SessionOptions`**: Developers can now pass `preset: "my-game"` instead of manually duplicating policies. Preset policies are resolved via `loadConfig()` from `@cartridge/presets`, matching the keychain's behavior. Policy precedence matches `ControllerProvider`: preset wins unless `shouldOverridePresetPolicies: true`. - **Consolidate async init into `_init()`**: Preset resolution, session retrieval from storage, and signer key setup all happen in a single eagerly-started async method. Public methods (`username`, `probe`, `connect`) simply `await this._ready`. - **Normalize addresses in `toWasmPolicies`**: Applies `getChecksumAddress()` to contract targets before hashing, preventing divergence from inconsistent address casing. - **Extract `getPresetSessionPolicies` utility**: Shared between `SessionProvider` and the keychain's `getConfigChainPolicies`, replacing ~25 lines of defensive type narrowing with a clean helper. - **Warn on silent policy drop**: When both `preset` and `policies` are provided without the override flag, a `console.warn` is logged (previously policies were silently ignored in `ControllerProvider`). - **Send `preset` in keychain URL**: When preset is used, `connect()` sends `&preset=` instead of `&policies=`, so the keychain resolves the same policies from the same source. ### Keychain changes - **Gate rendering on `isPoliciesResolved` in `Authentication`**: The `Authentication` wrapper now waits for preset policies to fully resolve before rendering child routes. This replaces scattered `isPoliciesResolved` guards that were previously in `ConnectRoute` and eliminates race conditions where components render before async preset policy resolution completes. ### Root cause When a developer configured presets and used `SessionProvider`, the keychain would resolve policies from the preset while the SDK used the developer's manually-passed policies. If these differed (or if addresses had different casing), the session hash would diverge, producing `session/not-registered` errors. ### Design note Preset resolution is async (`loadConfig` fetches from the network), but JS constructors can't be async. The `_init()` method is eagerly started in the constructor and stores its promise as `_ready`. A cleaner alternative would be a static factory method (`SessionProvider.create(options)`), but that's a breaking API change — worth considering in a future major version. ### Follow-up - Apply same preset support to `TelegramProvider` and Node.js `SessionProvider` ## Test plan - [x] All 45 controller unit tests pass (including updated `toWasmPolicies` tests) - [x] All 356 keychain tests pass - [x] `tsc --noEmit` passes with zero errors - [x] Full monorepo build succeeds - [x] Lint and format checks pass - [ ] Manual verification with a preset-configured app using SessionProvider 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bd9fd92 commit ba15f09

File tree

8 files changed

+285
-131
lines changed

8 files changed

+285
-131
lines changed

packages/controller/src/__tests__/toWasmPolicies.test.ts

Lines changed: 89 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1+
import { getChecksumAddress } from "starknet";
12
import { toWasmPolicies } from "../utils";
23
import { ParsedSessionPolicies } from "../policies";
34

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+
415
describe("toWasmPolicies", () => {
516
describe("canonical ordering", () => {
617
test("sorts contracts by address regardless of input order", () => {
718
const policies1: ParsedSessionPolicies = {
819
verified: false,
920
contracts: {
10-
"0xAAA": {
21+
[ADDR_A]: {
1122
methods: [{ entrypoint: "foo", authorized: true }],
1223
},
13-
"0xBBB": {
24+
[ADDR_B]: {
1425
methods: [{ entrypoint: "bar", authorized: true }],
1526
},
1627
},
@@ -19,10 +30,10 @@ describe("toWasmPolicies", () => {
1930
const policies2: ParsedSessionPolicies = {
2031
verified: false,
2132
contracts: {
22-
"0xBBB": {
33+
[ADDR_B]: {
2334
methods: [{ entrypoint: "bar", authorized: true }],
2435
},
25-
"0xAAA": {
36+
[ADDR_A]: {
2637
methods: [{ entrypoint: "foo", authorized: true }],
2738
},
2839
},
@@ -32,16 +43,16 @@ describe("toWasmPolicies", () => {
3243
const result2 = toWasmPolicies(policies2);
3344

3445
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);
3849
});
3950

4051
test("sorts methods within contracts by entrypoint", () => {
4152
const policies1: ParsedSessionPolicies = {
4253
verified: false,
4354
contracts: {
44-
"0xAAA": {
55+
[ADDR_A]: {
4556
methods: [
4657
{ entrypoint: "zebra", authorized: true },
4758
{ entrypoint: "apple", authorized: true },
@@ -54,7 +65,7 @@ describe("toWasmPolicies", () => {
5465
const policies2: ParsedSessionPolicies = {
5566
verified: false,
5667
contracts: {
57-
"0xAAA": {
68+
[ADDR_A]: {
5869
methods: [
5970
{ entrypoint: "mango", authorized: true },
6071
{ entrypoint: "zebra", authorized: true },
@@ -74,19 +85,19 @@ describe("toWasmPolicies", () => {
7485
const policies1: ParsedSessionPolicies = {
7586
verified: false,
7687
contracts: {
77-
"0xCCC": {
88+
[ADDR_C]: {
7889
methods: [
7990
{ entrypoint: "c_method", authorized: true },
8091
{ entrypoint: "a_method", authorized: true },
8192
],
8293
},
83-
"0xAAA": {
94+
[ADDR_A]: {
8495
methods: [
8596
{ entrypoint: "z_method", authorized: true },
8697
{ entrypoint: "a_method", authorized: true },
8798
],
8899
},
89-
"0xBBB": {
100+
[ADDR_B]: {
90101
methods: [{ entrypoint: "b_method", authorized: true }],
91102
},
92103
},
@@ -96,16 +107,16 @@ describe("toWasmPolicies", () => {
96107
const policies2: ParsedSessionPolicies = {
97108
verified: false,
98109
contracts: {
99-
"0xBBB": {
110+
[ADDR_B]: {
100111
methods: [{ entrypoint: "b_method", authorized: true }],
101112
},
102-
"0xAAA": {
113+
[ADDR_A]: {
103114
methods: [
104115
{ entrypoint: "a_method", authorized: true },
105116
{ entrypoint: "z_method", authorized: true },
106117
],
107118
},
108-
"0xCCC": {
119+
[ADDR_C]: {
109120
methods: [
110121
{ entrypoint: "a_method", authorized: true },
111122
{ entrypoint: "c_method", authorized: true },
@@ -119,21 +130,21 @@ describe("toWasmPolicies", () => {
119130

120131
expect(result1).toEqual(result2);
121132

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);
127138
});
128139

129140
test("handles case-insensitive address sorting", () => {
130141
const policies1: ParsedSessionPolicies = {
131142
verified: false,
132143
contracts: {
133-
"0xaaa": {
144+
"0x0aaa": {
134145
methods: [{ entrypoint: "foo", authorized: true }],
135146
},
136-
"0xAAB": {
147+
"0x0AAB": {
137148
methods: [{ entrypoint: "bar", authorized: true }],
138149
},
139150
},
@@ -142,10 +153,10 @@ describe("toWasmPolicies", () => {
142153
const policies2: ParsedSessionPolicies = {
143154
verified: false,
144155
contracts: {
145-
"0xAAB": {
156+
"0x0AAB": {
146157
methods: [{ entrypoint: "bar", authorized: true }],
147158
},
148-
"0xaaa": {
159+
"0x0aaa": {
149160
methods: [{ entrypoint: "foo", authorized: true }],
150161
},
151162
},
@@ -157,6 +168,34 @@ describe("toWasmPolicies", () => {
157168
expect(result1).toEqual(result2);
158169
});
159170

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+
160199
test("handles empty policies", () => {
161200
const policies: ParsedSessionPolicies = {
162201
verified: false,
@@ -179,15 +218,21 @@ describe("toWasmPolicies", () => {
179218
});
180219

181220
describe("ApprovalPolicy handling", () => {
221+
const TOKEN =
222+
"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7";
223+
const TOKEN_CS = getChecksumAddress(TOKEN);
224+
const SPENDER =
225+
"0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
226+
182227
test("creates ApprovalPolicy for approve methods with spender and amount", () => {
183228
const policies: ParsedSessionPolicies = {
184229
verified: false,
185230
contracts: {
186-
"0xTOKEN": {
231+
[TOKEN]: {
187232
methods: [
188233
{
189234
entrypoint: "approve",
190-
spender: "0xSPENDER",
235+
spender: SPENDER,
191236
amount: "1000000000000000000",
192237
authorized: true,
193238
},
@@ -200,8 +245,8 @@ describe("toWasmPolicies", () => {
200245

201246
expect(result).toHaveLength(1);
202247
expect(result[0]).toEqual({
203-
target: "0xTOKEN",
204-
spender: "0xSPENDER",
248+
target: TOKEN_CS,
249+
spender: SPENDER,
205250
amount: "1000000000000000000",
206251
});
207252
// Should NOT have method or authorized fields
@@ -213,11 +258,11 @@ describe("toWasmPolicies", () => {
213258
const policies: ParsedSessionPolicies = {
214259
verified: false,
215260
contracts: {
216-
"0xTOKEN": {
261+
[TOKEN]: {
217262
methods: [
218263
{
219264
entrypoint: "approve",
220-
spender: "0xSPENDER",
265+
spender: SPENDER,
221266
amount: 1000000000000000000,
222267
authorized: true,
223268
},
@@ -237,7 +282,7 @@ describe("toWasmPolicies", () => {
237282
const policies: ParsedSessionPolicies = {
238283
verified: false,
239284
contracts: {
240-
"0xTOKEN": {
285+
[TOKEN]: {
241286
methods: [
242287
{
243288
entrypoint: "approve",
@@ -267,11 +312,11 @@ describe("toWasmPolicies", () => {
267312
const policies: ParsedSessionPolicies = {
268313
verified: false,
269314
contracts: {
270-
"0xTOKEN": {
315+
[TOKEN]: {
271316
methods: [
272317
{
273318
entrypoint: "approve",
274-
spender: "0xSPENDER",
319+
spender: SPENDER,
275320
authorized: true,
276321
},
277322
],
@@ -290,10 +335,14 @@ describe("toWasmPolicies", () => {
290335
});
291336

292337
test("creates CallPolicy for non-approve methods", () => {
338+
const CONTRACT =
339+
"0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49";
340+
const CONTRACT_CS = getChecksumAddress(CONTRACT);
341+
293342
const policies: ParsedSessionPolicies = {
294343
verified: false,
295344
contracts: {
296-
"0xCONTRACT": {
345+
[CONTRACT]: {
297346
methods: [
298347
{
299348
entrypoint: "transfer",
@@ -307,7 +356,7 @@ describe("toWasmPolicies", () => {
307356
const result = toWasmPolicies(policies);
308357

309358
expect(result).toHaveLength(1);
310-
expect(result[0]).toHaveProperty("target", "0xCONTRACT");
359+
expect(result[0]).toHaveProperty("target", CONTRACT_CS);
311360
expect(result[0]).toHaveProperty("method");
312361
expect(result[0]).toHaveProperty("authorized", true);
313362
});
@@ -316,11 +365,11 @@ describe("toWasmPolicies", () => {
316365
const policies: ParsedSessionPolicies = {
317366
verified: false,
318367
contracts: {
319-
"0xTOKEN": {
368+
[TOKEN]: {
320369
methods: [
321370
{
322371
entrypoint: "approve",
323-
spender: "0xSPENDER",
372+
spender: SPENDER,
324373
amount: "1000",
325374
authorized: true,
326375
},
@@ -339,7 +388,7 @@ describe("toWasmPolicies", () => {
339388

340389
// First should be approve (sorted alphabetically)
341390
const approvePolicy = result[0];
342-
expect(approvePolicy).toHaveProperty("spender", "0xSPENDER");
391+
expect(approvePolicy).toHaveProperty("spender", SPENDER);
343392
expect(approvePolicy).toHaveProperty("amount", "1000");
344393

345394
// Second should be transfer
@@ -352,12 +401,12 @@ describe("toWasmPolicies", () => {
352401
const policies: ParsedSessionPolicies = {
353402
verified: false,
354403
contracts: {
355-
"0xTOKEN": {
404+
[TOKEN]: {
356405
methods: [
357406
{ entrypoint: "transfer", authorized: true },
358407
{
359408
entrypoint: "approve",
360-
spender: "0xSPENDER",
409+
spender: SPENDER,
361410
amount: "1000",
362411
authorized: true,
363412
},

packages/controller/src/iframe/keychain.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ export class KeychainIFrame extends IFrame<Keychain> {
104104
);
105105
} else if (preset) {
106106
_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+
}
107114
}
108115

109116
// Add encrypted blob to URL fragment (hash) if present

0 commit comments

Comments
 (0)