From 8f0d2267cd7d0a91389338178e0704b57e3da4c7 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 6 Apr 2026 14:14:41 -0700 Subject: [PATCH 01/22] refactor: replace WASM dependency in SessionProvider with pure TypeScript Replace @cartridge/controller-wasm imports in session/node paths with pure TS implementations using only starknet.js primitives, enabling React Native support and simplifying the build pipeline. Adds 45 new tests covering guid derivation, merkle tree construction, outside execution signing, session account operations, and GraphQL subscription polling. Closes #2518 Co-Authored-By: Claude Opus 4.6 --- .../controller/src/__tests__/guid.test.ts | 66 +++ .../controller/src/__tests__/merkle.test.ts | 213 +++++++++ .../src/__tests__/outside-execution.test.ts | 230 +++++++++ .../src/__tests__/session-account.test.ts | 155 ++++++ .../src/__tests__/subscribe.test.ts | 148 ++++++ packages/controller/src/node/account.ts | 4 +- packages/controller/src/node/provider.ts | 2 +- packages/controller/src/session/account.ts | 4 +- packages/controller/src/session/index.ts | 2 +- packages/controller/src/session/provider.ts | 6 +- packages/controller/src/session/ts/errors.ts | 23 + packages/controller/src/session/ts/guid.ts | 24 + packages/controller/src/session/ts/index.ts | 8 + packages/controller/src/session/ts/merkle.ts | 184 +++++++ .../src/session/ts/outside-execution.ts | 449 ++++++++++++++++++ .../src/session/ts/session-account.ts | 202 ++++++++ packages/controller/src/session/ts/shared.ts | 32 ++ .../controller/src/session/ts/subscribe.ts | 84 ++++ packages/controller/src/session/ts/types.ts | 60 +++ packages/controller/src/utils.ts | 2 +- 20 files changed, 1887 insertions(+), 11 deletions(-) create mode 100644 packages/controller/src/__tests__/guid.test.ts create mode 100644 packages/controller/src/__tests__/merkle.test.ts create mode 100644 packages/controller/src/__tests__/outside-execution.test.ts create mode 100644 packages/controller/src/__tests__/session-account.test.ts create mode 100644 packages/controller/src/__tests__/subscribe.test.ts create mode 100644 packages/controller/src/session/ts/errors.ts create mode 100644 packages/controller/src/session/ts/guid.ts create mode 100644 packages/controller/src/session/ts/index.ts create mode 100644 packages/controller/src/session/ts/merkle.ts create mode 100644 packages/controller/src/session/ts/outside-execution.ts create mode 100644 packages/controller/src/session/ts/session-account.ts create mode 100644 packages/controller/src/session/ts/shared.ts create mode 100644 packages/controller/src/session/ts/subscribe.ts create mode 100644 packages/controller/src/session/ts/types.ts diff --git a/packages/controller/src/__tests__/guid.test.ts b/packages/controller/src/__tests__/guid.test.ts new file mode 100644 index 0000000000..f523542ca9 --- /dev/null +++ b/packages/controller/src/__tests__/guid.test.ts @@ -0,0 +1,66 @@ +import { ec, hash, num, shortString, encode } from "starknet"; +import { signerToGuid } from "../session/ts/guid"; + +describe("signerToGuid", () => { + test("produces a valid hex felt", () => { + const guid = signerToGuid({ + starknet: { privateKey: "0x123" }, + }); + expect(guid).toMatch(/^0x[0-9a-f]+$/); + }); + + test("is deterministic for the same key", () => { + const key = "0xdeadbeef"; + const guid1 = signerToGuid({ starknet: { privateKey: key } }); + const guid2 = signerToGuid({ starknet: { privateKey: key } }); + expect(guid1).toBe(guid2); + }); + + test("produces different GUIDs for different keys", () => { + const guid1 = signerToGuid({ starknet: { privateKey: "0x1" } }); + const guid2 = signerToGuid({ starknet: { privateKey: "0x2" } }); + expect(guid1).not.toBe(guid2); + }); + + test("matches manual Poseidon(domain, publicKey) computation", () => { + const privateKey = "0x1"; + const publicKey = ec.starkCurve.getStarkKey( + encode.addHexPrefix(privateKey), + ); + const domain = num.toHex(shortString.encodeShortString("Starknet Signer")); + const expected = num + .toHex(hash.computePoseidonHash(domain, publicKey)) + .toLowerCase(); + + const guid = signerToGuid({ starknet: { privateKey } }); + expect(guid).toBe(expected); + }); + + test("handles keys with and without hex prefix", () => { + const guid1 = signerToGuid({ starknet: { privateKey: "0x123" } }); + const guid2 = signerToGuid({ starknet: { privateKey: "123" } }); + expect(guid1).toBe(guid2); + }); + + test("throws for missing starknet signer", () => { + expect(() => signerToGuid({})).toThrow(); + }); + + test("throws for empty private key", () => { + expect(() => signerToGuid({ starknet: { privateKey: "" } })).toThrow(); + }); + + test("produces known GUID for well-known private key 0x1", () => { + // Private key 0x1 has a well-known public key on the Stark curve + const guid = signerToGuid({ starknet: { privateKey: "0x1" } }); + + // Verify it's a valid felt (non-zero, hex) + expect(guid).toMatch(/^0x[0-9a-f]+$/); + expect(BigInt(guid)).toBeGreaterThan(0n); + + // Snapshot the value for regression detection + expect(guid).toMatchInlineSnapshot( + `"0x78e6eccfb97cea1b4ca2e0735d0db7cd9e33a316378391e58e7f3ed107062c2"`, + ); + }); +}); diff --git a/packages/controller/src/__tests__/merkle.test.ts b/packages/controller/src/__tests__/merkle.test.ts new file mode 100644 index 0000000000..27c9f7276e --- /dev/null +++ b/packages/controller/src/__tests__/merkle.test.ts @@ -0,0 +1,213 @@ +import { hash } from "starknet"; +import { + hashPair, + hashPolicyLeaf, + computePolicyMerkle, + computePolicyMerkleProofs, +} from "../session/ts/merkle"; +import type { + CallPolicy, + TypedDataPolicy, + ApprovalPolicy, + Policy, +} from "../session/ts/types"; + +const SELECTOR_TRANSFER = hash.getSelectorFromName("transfer"); +const SELECTOR_APPROVE = hash.getSelectorFromName("approve"); +const SELECTOR_BALANCE = hash.getSelectorFromName("balance_of"); + +const ADDR_A = "0x0aaa"; +const ADDR_B = "0x0bbb"; + +describe("hashPair", () => { + test("is commutative (same result regardless of arg order)", () => { + const a = "0x1"; + const b = "0x2"; + expect(hashPair(a, b)).toBe(hashPair(b, a)); + }); + + test("produces valid hex output", () => { + const result = hashPair("0x1", "0x2"); + expect(result).toMatch(/^0x[0-9a-f]+$/); + }); + + test("always hashes (smaller, larger)", () => { + // Regardless of input order, the hash should be deterministic + const r1 = hashPair("0x100", "0x1"); + const r2 = hashPair("0x1", "0x100"); + expect(r1).toBe(r2); + }); +}); + +describe("hashPolicyLeaf", () => { + test("hashes a CallPolicy", () => { + const policy: CallPolicy = { + target: ADDR_A, + method: SELECTOR_TRANSFER, + authorized: true, + }; + const leaf = hashPolicyLeaf(policy); + expect(leaf).toMatch(/^0x[0-9a-f]+$/); + }); + + test("hashes a TypedDataPolicy", () => { + const policy: TypedDataPolicy = { + scope_hash: "0xabc123", + authorized: true, + }; + const leaf = hashPolicyLeaf(policy); + expect(leaf).toMatch(/^0x[0-9a-f]+$/); + }); + + test("hashes an ApprovalPolicy", () => { + const policy: ApprovalPolicy = { + target: ADDR_A, + spender: ADDR_B, + amount: "1000", + }; + const leaf = hashPolicyLeaf(policy); + expect(leaf).toMatch(/^0x[0-9a-f]+$/); + }); + + test("different policies produce different leaves", () => { + const p1: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; + const p2: CallPolicy = { target: ADDR_A, method: SELECTOR_APPROVE }; + expect(hashPolicyLeaf(p1)).not.toBe(hashPolicyLeaf(p2)); + }); +}); + +describe("computePolicyMerkle", () => { + test("empty policies return zero root", () => { + const result = computePolicyMerkle([]); + expect(result.root).toBe("0x0"); + expect(result.leaves).toEqual([]); + }); + + test("single policy: root equals the single leaf", () => { + const policy: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; + const result = computePolicyMerkle([policy]); + expect(result.leaves).toHaveLength(1); + // Single leaf gets paired with ZERO_FELT + expect(result.root).toMatch(/^0x[0-9a-f]+$/); + }); + + test("two policies: root is hash of the two leaves", () => { + const p1: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; + const p2: CallPolicy = { target: ADDR_B, method: SELECTOR_APPROVE }; + const result = computePolicyMerkle([p1, p2]); + expect(result.leaves).toHaveLength(2); + expect(result.root).toBe(hashPair(result.leaves[0], result.leaves[1])); + }); + + test("three policies: padded to 4, correct tree structure", () => { + const policies: CallPolicy[] = [ + { target: ADDR_A, method: SELECTOR_TRANSFER }, + { target: ADDR_A, method: SELECTOR_APPROVE }, + { target: ADDR_B, method: SELECTOR_BALANCE }, + ]; + const result = computePolicyMerkle(policies); + expect(result.leaves).toHaveLength(3); + expect(result.root).toMatch(/^0x[0-9a-f]+$/); + }); + + test("deterministic root regardless of policy order", () => { + // Note: policies are NOT reordered by computePolicyMerkle. + // But the sorted-pair hashing means hashPair(a,b) == hashPair(b,a). + // The caller (toWasmPolicies) is responsible for canonical ordering. + const p1: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; + const p2: CallPolicy = { target: ADDR_B, method: SELECTOR_APPROVE }; + + const r1 = computePolicyMerkle([p1, p2]); + const r2 = computePolicyMerkle([p2, p1]); + + // With sorted-pair hashing, order of two leaves doesn't matter + expect(r1.root).toBe(r2.root); + }); + + test("seven policies produce a valid tree", () => { + const policies: CallPolicy[] = Array.from({ length: 7 }, (_, i) => ({ + target: `0x${(i + 1).toString(16)}`, + method: SELECTOR_TRANSFER, + })); + const result = computePolicyMerkle(policies); + expect(result.leaves).toHaveLength(7); + expect(result.root).toMatch(/^0x[0-9a-f]+$/); + }); + + test("mixed policy types", () => { + const policies: Policy[] = [ + { target: ADDR_A, method: SELECTOR_TRANSFER, authorized: true }, + { scope_hash: "0xabc", authorized: true }, + { target: ADDR_B, spender: ADDR_A, amount: "1000" }, + ]; + const result = computePolicyMerkle(policies); + expect(result.leaves).toHaveLength(3); + expect(result.root).toMatch(/^0x[0-9a-f]+$/); + }); +}); + +describe("computePolicyMerkleProofs", () => { + test("empty policies return empty proofs", () => { + expect(computePolicyMerkleProofs([])).toEqual([]); + }); + + test("single policy proof is empty (leaf is the root)", () => { + const policy: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; + const proofs = computePolicyMerkleProofs([policy]); + expect(proofs).toHaveLength(1); + expect(proofs[0].proof).toHaveLength(0); // single leaf = root, no proof needed + expect(proofs[0].leaf).toBe(hashPolicyLeaf(policy)); + + // Root should equal the leaf directly + const { root } = computePolicyMerkle([policy]); + expect(proofs[0].leaf).toBe(root); + }); + + test("two policies: each proof has one element (the sibling)", () => { + const p1: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; + const p2: CallPolicy = { target: ADDR_B, method: SELECTOR_APPROVE }; + const proofs = computePolicyMerkleProofs([p1, p2]); + expect(proofs).toHaveLength(2); + // Each leaf's proof is the other leaf + expect(proofs[0].proof).toHaveLength(1); + expect(proofs[1].proof).toHaveLength(1); + }); + + test("proofs verify against the merkle root", () => { + const policies: CallPolicy[] = [ + { target: ADDR_A, method: SELECTOR_TRANSFER }, + { target: ADDR_A, method: SELECTOR_APPROVE }, + { target: ADDR_B, method: SELECTOR_BALANCE }, + ]; + + const { root } = computePolicyMerkle(policies); + const proofs = computePolicyMerkleProofs(policies); + + // Verify each proof + for (const proof of proofs) { + let current = proof.leaf; + for (const sibling of proof.proof) { + current = hashPair(current, sibling); + } + expect(current).toBe(root); + } + }); + + test("proofs verify for 7 policies", () => { + const policies: CallPolicy[] = Array.from({ length: 7 }, (_, i) => ({ + target: `0x${(i + 1).toString(16)}`, + method: SELECTOR_TRANSFER, + })); + + const { root } = computePolicyMerkle(policies); + const proofs = computePolicyMerkleProofs(policies); + + for (const proof of proofs) { + let current = proof.leaf; + for (const sibling of proof.proof) { + current = hashPair(current, sibling); + } + expect(current).toBe(root); + } + }); +}); diff --git a/packages/controller/src/__tests__/outside-execution.test.ts b/packages/controller/src/__tests__/outside-execution.test.ts new file mode 100644 index 0000000000..11129e31c7 --- /dev/null +++ b/packages/controller/src/__tests__/outside-execution.test.ts @@ -0,0 +1,230 @@ +import { ec, encode, hash, shortString } from "starknet"; +import { + buildSignedOutsideExecutionV3, + createPolicyProofIndex, +} from "../session/ts/outside-execution"; +import { + computePolicyMerkle, + computePolicyMerkleProofs, +} from "../session/ts/merkle"; +import type { CallPolicy } from "../session/ts/types"; +import { normalizeFelt } from "../session/ts/shared"; + +const TEST_PRIVATE_KEY = "0x1"; +const TEST_ADDRESS = + "0x0000000000000000000000000000000000000000000000000000000000001234"; +const TEST_OWNER_GUID = "0x5678"; +const TEST_CHAIN_ID = "SN_SEPOLIA"; + +const TRANSFER_SELECTOR = normalizeFelt(hash.getSelectorFromName("transfer")); + +const policies: CallPolicy[] = [ + { + target: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + method: TRANSFER_SELECTOR, + authorized: true, + }, +]; + +function buildTestContext() { + const { root } = computePolicyMerkle(policies); + const proofs = computePolicyMerkleProofs(policies); + const policyProofIndex = createPolicyProofIndex(proofs); + const publicKey = ec.starkCurve.getStarkKey( + encode.addHexPrefix(TEST_PRIVATE_KEY), + ); + const domain = shortString.encodeShortString("Starknet Signer"); + const sessionKeyGuid = normalizeFelt( + hash.computePoseidonHash(normalizeFelt(domain), publicKey), + ); + + return { root, policyProofIndex, sessionKeyGuid }; +} + +describe("buildSignedOutsideExecutionV3", () => { + test("produces a valid signed outside execution", () => { + const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); + + const result = buildSignedOutsideExecutionV3({ + calls: [ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: ["0x1", "0x2", "0x0"], + }, + ], + chainId: TEST_CHAIN_ID, + session: { + username: "test", + address: TEST_ADDRESS, + ownerGuid: TEST_OWNER_GUID, + expiresAt: "9999999999", + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid, + }, + sessionPrivateKey: TEST_PRIVATE_KEY, + policyRoot: root, + sessionKeyGuid, + policyProofIndex, + nowSeconds: 1000000, + }); + + expect(result.outsideExecution).toBeDefined(); + expect(result.signature).toBeDefined(); + expect(result.signature.length).toBeGreaterThan(0); + + // Signature starts with session-token magic + const sessionTokenMagic = normalizeFelt( + shortString.encodeShortString("session-token"), + ); + expect(result.signature[0]).toBe(sessionTokenMagic); + }); + + test("outsideExecution has correct structure", () => { + const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); + + const result = buildSignedOutsideExecutionV3({ + calls: [ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: ["0x1"], + }, + ], + chainId: TEST_CHAIN_ID, + session: { + username: "test", + address: TEST_ADDRESS, + ownerGuid: TEST_OWNER_GUID, + expiresAt: "9999999999", + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid, + }, + sessionPrivateKey: TEST_PRIVATE_KEY, + policyRoot: root, + sessionKeyGuid, + policyProofIndex, + nowSeconds: 1000000, + }); + + const oe = result.outsideExecution; + expect(oe.caller).toMatch(/^0x/); + expect(oe.nonce).toHaveLength(2); + expect(oe.nonce[1]).toBe("0x1"); // non-sequential nonce mode + expect(oe.execute_after).toMatch(/^0x/); + expect(oe.execute_before).toMatch(/^0x/); + expect(oe.calls).toHaveLength(1); + expect(oe.calls[0].to).toMatch(/^0x/); + expect(oe.calls[0].selector).toMatch(/^0x/); + }); + + test("throws for empty calls", () => { + const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); + + expect(() => + buildSignedOutsideExecutionV3({ + calls: [], + chainId: TEST_CHAIN_ID, + session: { + username: "test", + address: TEST_ADDRESS, + ownerGuid: TEST_OWNER_GUID, + expiresAt: "9999999999", + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid, + }, + sessionPrivateKey: TEST_PRIVATE_KEY, + policyRoot: root, + sessionKeyGuid, + policyProofIndex, + }), + ).toThrow("At least one call is required"); + }); + + test("throws for calls not in policy", () => { + const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); + + expect(() => + buildSignedOutsideExecutionV3({ + calls: [ + { + contractAddress: "0xdead", + entrypoint: "steal_funds", + calldata: [], + }, + ], + chainId: TEST_CHAIN_ID, + session: { + username: "test", + address: TEST_ADDRESS, + ownerGuid: TEST_OWNER_GUID, + expiresAt: "9999999999", + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid, + }, + sessionPrivateKey: TEST_PRIVATE_KEY, + policyRoot: root, + sessionKeyGuid, + policyProofIndex, + }), + ).toThrow("not authorized by session policies"); + }); + + test("respects custom time bounds", () => { + const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); + + const result = buildSignedOutsideExecutionV3({ + calls: [ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: [], + }, + ], + timeBounds: { + executeAfter: 100, + executeBefore: 2000, + }, + chainId: TEST_CHAIN_ID, + session: { + username: "test", + address: TEST_ADDRESS, + ownerGuid: TEST_OWNER_GUID, + expiresAt: "9999999999", + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid, + }, + sessionPrivateKey: TEST_PRIVATE_KEY, + policyRoot: root, + sessionKeyGuid, + policyProofIndex, + }); + + expect(result.outsideExecution.execute_after).toBe(normalizeFelt(100)); + expect(result.outsideExecution.execute_before).toBe(normalizeFelt(2000)); + }); +}); + +describe("createPolicyProofIndex", () => { + test("indexes proofs by contractAddress:selector", () => { + const proofs = computePolicyMerkleProofs(policies); + const index = createPolicyProofIndex(proofs); + expect(index.size).toBe(1); + }); + + test("deduplicates identical keys", () => { + const proofs = computePolicyMerkleProofs(policies); + // Double the proofs + const index = createPolicyProofIndex([...proofs, ...proofs]); + expect(index.size).toBe(1); + }); +}); diff --git a/packages/controller/src/__tests__/session-account.test.ts b/packages/controller/src/__tests__/session-account.test.ts new file mode 100644 index 0000000000..72e76ad1fb --- /dev/null +++ b/packages/controller/src/__tests__/session-account.test.ts @@ -0,0 +1,155 @@ +import { hash } from "starknet"; +import { TsSessionAccount } from "../session/ts/session-account"; +import type { CallPolicy, Session } from "../session/ts/types"; +import { normalizeFelt } from "../session/ts/shared"; + +// Mock global fetch +const mockFetch = jest.fn(); +(global as any).fetch = mockFetch; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +const TRANSFER_SELECTOR = normalizeFelt(hash.getSelectorFromName("transfer")); +const TEST_RPC = "https://rpc.test"; +const TEST_PRIVATE_KEY = "0x1"; +const TEST_ADDRESS = "0x1234"; +const TEST_OWNER_GUID = "0x5678"; +const TEST_CHAIN_ID = "SN_SEPOLIA"; + +const TEST_POLICIES: CallPolicy[] = [ + { + target: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + method: TRANSFER_SELECTOR, + authorized: true, + }, +]; + +const TEST_SESSION: Session = { + policies: TEST_POLICIES, + expiresAt: 9999999999, + metadataHash: "0x0", + sessionKeyGuid: "0x0", + guardianKeyGuid: "0x0", +}; + +describe("TsSessionAccount", () => { + test("newAsRegistered creates an instance", () => { + const account = TsSessionAccount.newAsRegistered( + TEST_RPC, + TEST_PRIVATE_KEY, + TEST_ADDRESS, + TEST_OWNER_GUID, + TEST_CHAIN_ID, + TEST_SESSION, + ); + expect(account).toBeInstanceOf(TsSessionAccount); + }); + + test("executeFromOutside sends correct RPC request", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: "2.0", + id: 1, + result: { transaction_hash: "0xtxhash" }, + }), + }); + + const account = TsSessionAccount.newAsRegistered( + TEST_RPC, + TEST_PRIVATE_KEY, + TEST_ADDRESS, + TEST_OWNER_GUID, + TEST_CHAIN_ID, + TEST_SESSION, + ); + + const result = await account.executeFromOutside([ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: ["0x1", "0x2", "0x0"], + }, + ]); + + expect(result.transaction_hash).toBe("0xtxhash"); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe(TEST_RPC); + const body = JSON.parse(options.body); + expect(body.method).toBe("cartridge_addExecuteOutsideTransaction"); + expect(body.params.address).toBe(TEST_ADDRESS); + expect(body.params.outside_execution).toBeDefined(); + expect(body.params.signature).toBeDefined(); + expect(Array.isArray(body.params.signature)).toBe(true); + }); + + test("executeFromOutside throws on RPC error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: "2.0", + id: 1, + error: { code: -32000, message: "execution failed" }, + }), + }); + + const account = TsSessionAccount.newAsRegistered( + TEST_RPC, + TEST_PRIVATE_KEY, + TEST_ADDRESS, + TEST_OWNER_GUID, + TEST_CHAIN_ID, + TEST_SESSION, + ); + + await expect( + account.executeFromOutside([ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: [], + }, + ]), + ).rejects.toThrow("execution failed"); + }); + + test("execute sends correct RPC request", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: "2.0", + id: 1, + result: { transaction_hash: "0xtxhash2" }, + }), + }); + + const account = TsSessionAccount.newAsRegistered( + TEST_RPC, + TEST_PRIVATE_KEY, + TEST_ADDRESS, + TEST_OWNER_GUID, + TEST_CHAIN_ID, + TEST_SESSION, + ); + + const result = await account.execute([ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: ["0x1"], + }, + ]); + + expect(result.transaction_hash).toBe("0xtxhash2"); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.method).toBe("starknet_addInvokeTransaction"); + }); +}); diff --git a/packages/controller/src/__tests__/subscribe.test.ts b/packages/controller/src/__tests__/subscribe.test.ts new file mode 100644 index 0000000000..f09408d4cf --- /dev/null +++ b/packages/controller/src/__tests__/subscribe.test.ts @@ -0,0 +1,148 @@ +import { subscribeCreateSession } from "../session/ts/subscribe"; +import { SessionTimeoutError } from "../session/ts/errors"; + +// Mock global fetch +const mockFetch = jest.fn(); +(global as any).fetch = mockFetch; + +beforeEach(() => { + mockFetch.mockReset(); + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +const MOCK_SESSION = { + authorization: ["0x1", "0x2"], + expiresAt: "1234567890", + controller: { + accountID: "testuser", + address: "0x123", + }, +}; + +describe("subscribeCreateSession", () => { + test("returns session on first successful poll", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { subscribeCreateSession: MOCK_SESSION }, + }), + }); + + const result = await subscribeCreateSession("0xguid", "https://api.test"); + expect(result).toEqual(MOCK_SESSION); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test("polls until session is available", async () => { + // First call: null, second call: session + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { subscribeCreateSession: null } }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { subscribeCreateSession: MOCK_SESSION }, + }), + }); + + const promise = subscribeCreateSession("0xguid", "https://api.test"); + + // Advance past first delay + await jest.advanceTimersByTimeAsync(600); + + const result = await promise; + expect(result).toEqual(MOCK_SESSION); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test("retries on server error responses", async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 500 }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { subscribeCreateSession: MOCK_SESSION }, + }), + }); + + const promise = subscribeCreateSession("0xguid", "https://api.test"); + await jest.advanceTimersByTimeAsync(600); + + const result = await promise; + expect(result).toEqual(MOCK_SESSION); + }); + + test("retries on GraphQL errors", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errors: [{ message: "temporary error" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { subscribeCreateSession: MOCK_SESSION }, + }), + }); + + const promise = subscribeCreateSession("0xguid", "https://api.test"); + await jest.advanceTimersByTimeAsync(600); + + const result = await promise; + expect(result).toEqual(MOCK_SESSION); + }); + + test("times out after configured duration", async () => { + jest.useRealTimers(); // Use real timers for this test — short timeout + + mockFetch.mockImplementation(async () => ({ + ok: true, + json: async () => ({ data: { subscribeCreateSession: null } }), + })); + + // Use a very short timeout + await expect( + subscribeCreateSession("0xguid", "https://api.test", 500), + ).rejects.toThrow(SessionTimeoutError); + }, 15000); + + test("sends correct GraphQL query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { subscribeCreateSession: MOCK_SESSION }, + }), + }); + + await subscribeCreateSession("0xmyguid", "https://api.test"); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://api.test/query"); + expect(options.method).toBe("POST"); + expect(options.headers["Content-Type"]).toBe("application/json"); + + const body = JSON.parse(options.body); + expect(body.variables.sessionKeyGuid).toBe("0xmyguid"); + expect(body.query).toContain("subscribeCreateSession"); + }); + + test("strips trailing slashes from API URL", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { subscribeCreateSession: MOCK_SESSION }, + }), + }); + + await subscribeCreateSession("0xguid", "https://api.test///"); + expect(mockFetch.mock.calls[0][0]).toBe("https://api.test/query"); + }); +}); diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index 3a8c0a67fe..e11ef6a07e 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -1,5 +1,5 @@ -import { Policy } from "@cartridge/controller-wasm"; -import { CartridgeSessionAccount } from "@cartridge/controller-wasm/session"; +import type { Policy } from "../session/ts/types"; +import { TsSessionAccount as CartridgeSessionAccount } from "../session/ts/session-account"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; diff --git a/packages/controller/src/node/provider.ts b/packages/controller/src/node/provider.ts index 096f837491..12c37f3904 100644 --- a/packages/controller/src/node/provider.ts +++ b/packages/controller/src/node/provider.ts @@ -1,7 +1,7 @@ import { ec, encode, stark, WalletAccount } from "starknet"; import { loadConfig, SessionPolicies } from "@cartridge/presets"; import { AddStarknetChainParameters } from "@starknet-io/types-js"; -import { signerToGuid } from "@cartridge/controller-wasm"; +import { signerToGuid } from "../session/ts/guid"; import SessionAccount from "./account"; import { KEYCHAIN_URL } from "../constants"; diff --git a/packages/controller/src/session/account.ts b/packages/controller/src/session/account.ts index 99a0acd9ae..65881c95ba 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -1,5 +1,5 @@ -import { Policy } from "@cartridge/controller-wasm"; -import { CartridgeSessionAccount } from "@cartridge/controller-wasm/session"; +import type { Policy } from "./ts/types"; +import { TsSessionAccount as CartridgeSessionAccount } from "./ts/session-account"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; diff --git a/packages/controller/src/session/index.ts b/packages/controller/src/session/index.ts index 902711daef..af375450af 100644 --- a/packages/controller/src/session/index.ts +++ b/packages/controller/src/session/index.ts @@ -2,4 +2,4 @@ export { default } from "./provider"; export * from "./provider"; export * from "../errors"; export * from "../types"; -export * from "@cartridge/controller-wasm"; +export * from "./ts/types"; diff --git a/packages/controller/src/session/provider.ts b/packages/controller/src/session/provider.ts index f638430807..ebf2f6d5e1 100644 --- a/packages/controller/src/session/provider.ts +++ b/packages/controller/src/session/provider.ts @@ -1,9 +1,7 @@ import { ec, stark, WalletAccount } from "starknet"; -import { - signerToGuid, - subscribeCreateSession, -} from "@cartridge/controller-wasm"; +import { signerToGuid } from "./ts/guid"; +import { subscribeCreateSession } from "./ts/subscribe"; import { loadConfig, SessionPolicies } from "@cartridge/presets"; import { AddStarknetChainParameters } from "@starknet-io/types-js"; import { encode } from "starknet"; diff --git a/packages/controller/src/session/ts/errors.ts b/packages/controller/src/session/ts/errors.ts new file mode 100644 index 0000000000..90b11b91e8 --- /dev/null +++ b/packages/controller/src/session/ts/errors.ts @@ -0,0 +1,23 @@ +export class SessionProtocolError extends Error { + constructor(message: string, cause?: unknown) { + super(message); + this.name = "SessionProtocolError"; + if (cause !== undefined) { + this.cause = cause; + } + } +} + +export class SessionTimeoutError extends SessionProtocolError { + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = "SessionTimeoutError"; + } +} + +export class SessionRejectedError extends SessionProtocolError { + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = "SessionRejectedError"; + } +} diff --git a/packages/controller/src/session/ts/guid.ts b/packages/controller/src/session/ts/guid.ts new file mode 100644 index 0000000000..5b0d0e379a --- /dev/null +++ b/packages/controller/src/session/ts/guid.ts @@ -0,0 +1,24 @@ +import { ec, encode, hash, num, shortString } from "starknet"; +import type { Signer } from "./types"; + +const STARKNET_SIGNER_DOMAIN = num + .toHex(shortString.encodeShortString("Starknet Signer")) + .toLowerCase(); + +/** + * Derives a session signer GUID from a Signer object. + * Matches the WASM `signerToGuid` function signature exactly. + */ +export function signerToGuid(signer: Signer): string { + const privateKey = signer.starknet?.privateKey; + if (!privateKey) { + throw new Error("Cannot derive session GUID: missing starknet private key"); + } + + const normalizedKey = encode.addHexPrefix(String(privateKey).trim()); + const publicKey = ec.starkCurve.getStarkKey(normalizedKey); + + return num + .toHex(hash.computePoseidonHash(STARKNET_SIGNER_DOMAIN, publicKey)) + .toLowerCase(); +} diff --git a/packages/controller/src/session/ts/index.ts b/packages/controller/src/session/ts/index.ts new file mode 100644 index 0000000000..019801b497 --- /dev/null +++ b/packages/controller/src/session/ts/index.ts @@ -0,0 +1,8 @@ +export * from "./types"; +export * from "./errors"; +export * from "./guid"; +export * from "./merkle"; +export * from "./outside-execution"; +export * from "./session-account"; +export * from "./subscribe"; +export * from "./shared"; diff --git a/packages/controller/src/session/ts/merkle.ts b/packages/controller/src/session/ts/merkle.ts new file mode 100644 index 0000000000..94718f37b8 --- /dev/null +++ b/packages/controller/src/session/ts/merkle.ts @@ -0,0 +1,184 @@ +import { hash } from "starknet"; +import type { + Policy, + CallPolicy, + TypedDataPolicy, + ApprovalPolicy, +} from "./types"; +import { normalizeFelt } from "./shared"; + +const ZERO_FELT = "0x0"; + +/** + * SNIP-12 type hash for "Allowed Method"("Contract Address":"ContractAddress","selector":"selector") + */ +const POLICY_CALL_TYPE_HASH = normalizeFelt( + hash.getSelectorFromName( + '"Allowed Method"("Contract Address":"ContractAddress","selector":"selector")', + ), +); + +export interface PolicyMerkleResult { + leaves: string[]; + root: string; +} + +export interface PolicyMerkleProof { + contractAddress: string; + selector: string; + leaf: string; + proof: string[]; +} + +function isCallPolicy(p: Policy): p is CallPolicy { + return "method" in p && "target" in p && !("spender" in p); +} + +function isApprovalPolicy(p: Policy): p is ApprovalPolicy { + return "spender" in p && "amount" in p && "target" in p; +} + +function isTypedDataPolicy(p: Policy): p is TypedDataPolicy { + return "scope_hash" in p; +} + +/** + * Canonical pair hashing: always hash (smaller, larger) for deterministic trees. + */ +export function hashPair(a: string, b: string): string { + const aBig = BigInt(a); + const bBig = BigInt(b); + const [left, right] = aBig <= bBig ? [a, b] : [b, a]; + return normalizeFelt(hash.computePoseidonHash(left, right)); +} + +/** + * Hash a single policy into a merkle leaf. + */ +export function hashPolicyLeaf(policy: Policy): string { + if (isCallPolicy(policy)) { + return normalizeFelt( + hash.computePoseidonHashOnElements([ + POLICY_CALL_TYPE_HASH, + normalizeFelt(policy.target), + normalizeFelt(policy.method), + ]), + ); + } + + if (isApprovalPolicy(policy)) { + // Approval policies use: Poseidon(typeHash, target, spender, amount) + return normalizeFelt( + hash.computePoseidonHashOnElements([ + POLICY_CALL_TYPE_HASH, + normalizeFelt(policy.target), + normalizeFelt(policy.spender), + normalizeFelt(policy.amount), + ]), + ); + } + + if (isTypedDataPolicy(policy)) { + return normalizeFelt( + hash.computePoseidonHashOnElements([ + POLICY_CALL_TYPE_HASH, + normalizeFelt(policy.scope_hash), + ]), + ); + } + + throw new Error("Unknown policy type"); +} + +/** + * Build a merkle tree from policies and return the leaves and root. + */ +export function computePolicyMerkle(policies: Policy[]): PolicyMerkleResult { + if (policies.length === 0) { + return { leaves: [], root: ZERO_FELT }; + } + + const leaves = policies.map(hashPolicyLeaf); + let level = [...leaves]; + + while (level.length > 1) { + if (level.length % 2 !== 0) { + level.push(ZERO_FELT); + } + const next: string[] = []; + for (let i = 0; i < level.length; i += 2) { + next.push(hashPair(level[i], level[i + 1])); + } + level = next; + } + + return { leaves, root: level[0] }; +} + +/** + * Generate merkle proofs for each policy. + */ +export function computePolicyMerkleProofs( + policies: Policy[], +): PolicyMerkleProof[] { + if (policies.length === 0) { + return []; + } + + const leaves = policies.map(hashPolicyLeaf); + const proofs: string[][] = leaves.map(() => []); + + // Track which original leaf indices are at each position in the current level + let positionToLeaves: number[][] = leaves.map((_, i) => [i]); + let level = [...leaves]; + + while (level.length > 1) { + if (level.length % 2 !== 0) { + level.push(ZERO_FELT); + positionToLeaves.push([]); + } + + const nextLevel: string[] = []; + const nextPositionToLeaves: number[][] = []; + + for (let i = 0; i < level.length; i += 2) { + const parent = hashPair(level[i], level[i + 1]); + nextLevel.push(parent); + + // For each original leaf at position i, its sibling is level[i+1] + for (const leafIdx of positionToLeaves[i]) { + proofs[leafIdx].push(level[i + 1]); + } + // For each original leaf at position i+1, its sibling is level[i] + for (const leafIdx of positionToLeaves[i + 1]) { + proofs[leafIdx].push(level[i]); + } + + // Merge both positions' leaf sets into the parent position + nextPositionToLeaves.push([ + ...positionToLeaves[i], + ...positionToLeaves[i + 1], + ]); + } + + level = nextLevel; + positionToLeaves = nextPositionToLeaves; + } + + return policies.map((policy, i) => { + const target = + isCallPolicy(policy) || isApprovalPolicy(policy) + ? normalizeFelt(policy.target) + : ZERO_FELT; + const selector = isCallPolicy(policy) + ? normalizeFelt(policy.method) + : ZERO_FELT; + + return { + contractAddress: target, + selector, + leaf: leaves[i], + proof: proofs[i], + }; + }); +} diff --git a/packages/controller/src/session/ts/outside-execution.ts b/packages/controller/src/session/ts/outside-execution.ts new file mode 100644 index 0000000000..78d8dd5a08 --- /dev/null +++ b/packages/controller/src/session/ts/outside-execution.ts @@ -0,0 +1,449 @@ +import { + CallData, + ec, + encode, + hash, + shortString, + stark, + type Call, +} from "starknet"; +import { SessionProtocolError } from "./errors"; +import type { PolicyMerkleProof } from "./merkle"; +import { + normalizeFelt, + normalizeContractAddress, + selectorFromEntrypoint, +} from "./shared"; + +const ZERO_FELT = "0x0"; +const ONE_FELT = "0x1"; +const TWO_FELT = "0x2"; + +function shortFelt(value: string): string { + return normalizeFelt(shortString.encodeShortString(value)); +} + +function selectorFelt(value: string): string { + return normalizeFelt(hash.getSelectorFromName(value)); +} + +const STARKNET_MESSAGE = shortFelt("StarkNet Message"); +const OUTSIDE_EXECUTION_CALLER_ANY = shortFelt("ANY_CALLER"); +const SESSION_TOKEN_MAGIC = shortFelt("session-token"); +const AUTHORIZATION_BY_REGISTERED = shortFelt("authorization-by-registered"); + +// Placeholder guardian key — Cartridge's server replaces this with the real +// guardian signature before submitting. Not a secret. +const GUARDIAN_KEY_PLACEHOLDER = shortFelt("CARTRIDGE_GUARDIAN"); + +// SNIP-12 type hashes +const STARKNET_DOMAIN_TYPE_HASH = selectorFelt( + '"StarknetDomain"("name":"shortstring","version":"shortstring","chainId":"shortstring","revision":"shortstring")', +); +const CALL_TYPE_HASH = selectorFelt( + '"Call"("To":"ContractAddress","Selector":"selector","Calldata":"felt*")', +); +const OUTSIDE_EXECUTION_TYPE_HASH = selectorFelt( + '"OutsideExecution"("Caller":"ContractAddress","Nonce":"(felt,u128)","Execute After":"u128","Execute Before":"u128","Calls":"Call*")"Call"("To":"ContractAddress","Selector":"selector","Calldata":"felt*")', +); +const SESSION_TYPE_HASH = selectorFelt( + '"Session"("Expires At":"timestamp","Allowed Methods":"merkletree","Metadata":"string","Session Key":"felt")', +); + +const OUTSIDE_EXECUTION_DOMAIN_NAME = shortFelt("Account.execute_from_outside"); +const SESSION_DOMAIN_NAME = shortFelt("SessionAccount.session"); +const SESSION_DOMAIN_VERSION = shortFelt("1"); + +interface StarknetSignerSignature { + pubkey: string; + r: string; + s: string; +} + +export interface RpcOutsideExecutionCall { + to: string; + selector: string; + calldata: string[]; +} + +export interface RpcOutsideExecutionV3 { + caller: string; + nonce: [string, string]; + execute_after: string; + execute_before: string; + calls: RpcOutsideExecutionCall[]; +} + +export interface SignedOutsideExecutionV3 { + outsideExecution: RpcOutsideExecutionV3; + signature: string[]; +} + +interface SessionStruct { + expiresAt: string; + allowedPoliciesRoot: string; + metadataHash: string; + sessionKeyGuid: string; + guardianKeyGuid: string; +} + +export interface SessionRegistration { + username: string; + address: string; + ownerGuid: string; + expiresAt: string; + guardianKeyGuid: string; + metadataHash: string; + sessionKeyGuid: string; +} + +export interface TimeBounds { + executeAfter?: number | bigint; + executeBefore?: number | bigint; +} + +export interface BuildSignedOutsideExecutionV3Args { + calls: Call[]; + timeBounds?: TimeBounds; + chainId: string; + session: SessionRegistration; + sessionPrivateKey: string; + policyRoot: string; + sessionKeyGuid: string; + policyProofIndex: ReadonlyMap; + nowSeconds?: number; +} + +function toUintBigInt(value: string | number | bigint): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number") return BigInt(value); + return BigInt(value.trim()); +} + +function feltFromValue(value: string | number | bigint): string { + return normalizeFelt(toUintBigInt(value)); +} + +function normalizeChainId(chainId: string): string { + const trimmed = chainId.trim(); + if (/^0x[0-9a-f]+$/i.test(trimmed)) { + return normalizeFelt(trimmed); + } + return shortFelt(trimmed); +} + +function normalizeExecutionCall(call: Call): { + contractAddress: string; + selector: string; + calldata: string[]; +} { + const contractAddress = normalizeContractAddress( + String((call as any).contractAddress ?? ""), + "Outside execution call", + ); + const entrypoint = String((call as any).entrypoint ?? "").trim(); + const selector = selectorFromEntrypoint(entrypoint); + const calldata = CallData.toHex(call.calldata ?? []).map((f) => + normalizeFelt(f), + ); + + return { contractAddress, selector, calldata }; +} + +// --- Hashing --- + +function hashCallStruct(call: RpcOutsideExecutionCall): string { + const calldataHash = normalizeFelt( + hash.computePoseidonHashOnElements(call.calldata), + ); + return normalizeFelt( + hash.computePoseidonHashOnElements([ + CALL_TYPE_HASH, + call.to, + call.selector, + calldataHash, + ]), + ); +} + +function hashStarknetDomain( + name: string, + version: string, + chainId: string, + revision: string, +): string { + return normalizeFelt( + hash.computePoseidonHashOnElements([ + STARKNET_DOMAIN_TYPE_HASH, + name, + version, + chainId, + revision, + ]), + ); +} + +function hashMessageRev1( + domainHash: string, + contractAddress: string, + structHash: string, +): string { + return normalizeFelt( + hash.computePoseidonHashOnElements([ + STARKNET_MESSAGE, + domainHash, + contractAddress, + structHash, + ]), + ); +} + +function hashOutsideExecutionMessage( + outsideExecution: RpcOutsideExecutionV3, + chainId: string, + contractAddress: string, +): string { + const callHashes = outsideExecution.calls.map(hashCallStruct); + const callHashesHash = normalizeFelt( + hash.computePoseidonHashOnElements(callHashes), + ); + + const structHash = normalizeFelt( + hash.computePoseidonHashOnElements([ + OUTSIDE_EXECUTION_TYPE_HASH, + outsideExecution.caller, + outsideExecution.nonce[0], + outsideExecution.nonce[1], + outsideExecution.execute_after, + outsideExecution.execute_before, + callHashesHash, + ]), + ); + + const domainHash = hashStarknetDomain( + OUTSIDE_EXECUTION_DOMAIN_NAME, + TWO_FELT, + chainId, + TWO_FELT, + ); + + return hashMessageRev1(domainHash, contractAddress, structHash); +} + +function hashSessionStruct(session: SessionStruct): string { + return normalizeFelt( + hash.computePoseidonHashOnElements([ + SESSION_TYPE_HASH, + session.expiresAt, + session.allowedPoliciesRoot, + session.metadataHash, + session.sessionKeyGuid, + session.guardianKeyGuid, + ]), + ); +} + +function hashSessionMessage( + session: SessionStruct, + chainId: string, + contractAddress: string, +): string { + const domainHash = hashStarknetDomain( + SESSION_DOMAIN_NAME, + SESSION_DOMAIN_VERSION, + chainId, + ONE_FELT, + ); + return hashMessageRev1( + domainHash, + contractAddress, + hashSessionStruct(session), + ); +} + +// --- Signing --- + +function signStarknet( + messageHash: string, + privateKey: string, +): StarknetSignerSignature { + const normalizedKey = encode.addHexPrefix(privateKey.trim()); + const signature = ec.starkCurve.sign(messageHash, normalizedKey); + return { + pubkey: normalizeFelt(ec.starkCurve.getStarkKey(normalizedKey)), + r: normalizeFelt(signature.r), + s: normalizeFelt(signature.s), + }; +} + +// --- Serialization --- + +function serializeArray(values: readonly string[]): string[] { + return [feltFromValue(values.length), ...values.map((v) => normalizeFelt(v))]; +} + +function serializeArrayOfArrays(values: readonly string[][]): string[] { + const out: string[] = [feltFromValue(values.length)]; + for (const value of values) { + out.push(...serializeArray(value)); + } + return out; +} + +function serializeSessionStruct(session: SessionStruct): string[] { + return [ + session.expiresAt, + session.allowedPoliciesRoot, + session.metadataHash, + session.sessionKeyGuid, + session.guardianKeyGuid, + ]; +} + +function serializeStarknetSignerSignature( + sig: StarknetSignerSignature, +): string[] { + // SignerSignature variant 0 = Starknet, followed by (pubkey, r, s) + return [ZERO_FELT, sig.pubkey, sig.r, sig.s]; +} + +function serializeSessionToken(args: { + session: SessionStruct; + sessionAuthorization: string[]; + sessionSignature: StarknetSignerSignature; + guardianSignature: StarknetSignerSignature; + proofs: string[][]; +}): string[] { + return [ + ...serializeSessionStruct(args.session), + ONE_FELT, // Variant discriminator for "registered session" token format + ...serializeArray(args.sessionAuthorization), + ...serializeStarknetSignerSignature(args.sessionSignature), + ...serializeStarknetSignerSignature(args.guardianSignature), + ...serializeArrayOfArrays(args.proofs), + ]; +} + +// --- Policy proof index --- + +export function createPolicyProofIndex( + proofs: readonly PolicyMerkleProof[], +): Map { + const index = new Map(); + for (const proof of proofs) { + const key = `${normalizeContractAddress(proof.contractAddress, "Policy proof")}:${normalizeFelt(proof.selector)}`; + if (!index.has(key)) { + index.set( + key, + proof.proof.map((v) => normalizeFelt(v)), + ); + } + } + return index; +} + +function resolveCallProofs( + calls: { contractAddress: string; selector: string }[], + policyProofIndex: ReadonlyMap, +): string[][] { + return calls.map((call) => { + const key = `${call.contractAddress}:${call.selector}`; + const proof = policyProofIndex.get(key); + if (!proof) { + throw new SessionProtocolError( + `Call is not authorized by session policies: ${key}`, + ); + } + return proof; + }); +} + +// --- Main entry point --- + +export function buildSignedOutsideExecutionV3({ + calls, + timeBounds, + chainId, + session, + sessionPrivateKey, + policyRoot, + sessionKeyGuid, + policyProofIndex, + nowSeconds, +}: BuildSignedOutsideExecutionV3Args): SignedOutsideExecutionV3 { + if (calls.length === 0) { + throw new SessionProtocolError("At least one call is required."); + } + + const normalizedCalls = calls.map(normalizeExecutionCall); + const proofs = resolveCallProofs(normalizedCalls, policyProofIndex); + + const now = toUintBigInt(nowSeconds ?? Math.floor(Date.now() / 1000)); + const executeAfter = toUintBigInt(timeBounds?.executeAfter ?? 0); + const executeBefore = toUintBigInt(timeBounds?.executeBefore ?? now + 600n); + + const outsideExecution: RpcOutsideExecutionV3 = { + caller: OUTSIDE_EXECUTION_CALLER_ANY, + nonce: [normalizeFelt(stark.randomAddress()), ONE_FELT], + execute_after: feltFromValue(executeAfter), + execute_before: feltFromValue(executeBefore), + calls: normalizedCalls.map((c) => ({ + to: c.contractAddress, + selector: c.selector, + calldata: c.calldata, + })), + }; + + const sessionAddress = normalizeContractAddress( + session.address, + "Session address", + ); + const feltChainId = normalizeChainId(chainId); + + const txHash = hashOutsideExecutionMessage( + outsideExecution, + feltChainId, + sessionAddress, + ); + + const sessionStruct: SessionStruct = { + expiresAt: feltFromValue(session.expiresAt), + allowedPoliciesRoot: feltFromValue(policyRoot), + metadataHash: feltFromValue(session.metadataHash ?? ZERO_FELT), + sessionKeyGuid: feltFromValue(session.sessionKeyGuid || sessionKeyGuid), + guardianKeyGuid: feltFromValue(session.guardianKeyGuid || ZERO_FELT), + }; + + const sessionHash = hashSessionMessage( + sessionStruct, + feltChainId, + sessionAddress, + ); + const sessionTokenHash = normalizeFelt( + hash.computePoseidonHash(txHash, sessionHash), + ); + + const sessionSignature = signStarknet(sessionTokenHash, sessionPrivateKey); + const guardianSignature = signStarknet( + sessionTokenHash, + GUARDIAN_KEY_PLACEHOLDER, + ); + + const sessionAuthorization = [ + AUTHORIZATION_BY_REGISTERED, + feltFromValue(session.ownerGuid), + ]; + + const signature = [ + SESSION_TOKEN_MAGIC, + ...serializeSessionToken({ + session: sessionStruct, + sessionAuthorization, + sessionSignature, + guardianSignature, + proofs, + }), + ]; + + return { outsideExecution, signature }; +} diff --git a/packages/controller/src/session/ts/session-account.ts b/packages/controller/src/session/ts/session-account.ts new file mode 100644 index 0000000000..4acc062706 --- /dev/null +++ b/packages/controller/src/session/ts/session-account.ts @@ -0,0 +1,202 @@ +import type { InvokeFunctionResponse } from "starknet"; +import type { JsCall, Session } from "./types"; +import { computePolicyMerkle, computePolicyMerkleProofs } from "./merkle"; +import { + buildSignedOutsideExecutionV3, + createPolicyProofIndex, + type SessionRegistration, +} from "./outside-execution"; +import { signerToGuid } from "./guid"; + +/** + * Pure TypeScript replacement for the WASM CartridgeSessionAccount class. + * Provides the same `newAsRegistered`, `executeFromOutside`, and `execute` interface. + */ +export class TsSessionAccount { + private _rpcUrl: string; + private _privateKey: string; + private _address: string; + private _ownerGuid: string; + private _chainId: string; + private _session: Session; + private _sessionKeyGuid: string; + private _policyRoot: string; + private _policyProofIndex: Map; + + private constructor( + rpcUrl: string, + privateKey: string, + address: string, + ownerGuid: string, + chainId: string, + session: Session, + ) { + this._rpcUrl = rpcUrl; + this._privateKey = privateKey; + this._address = address; + this._ownerGuid = ownerGuid; + this._chainId = chainId; + this._session = session; + + this._sessionKeyGuid = signerToGuid({ + starknet: { privateKey }, + }); + + const { root } = computePolicyMerkle(session.policies); + this._policyRoot = root; + + const proofs = computePolicyMerkleProofs(session.policies); + this._policyProofIndex = createPolicyProofIndex(proofs); + } + + static newAsRegistered( + rpcUrl: string, + signer: string, + address: string, + ownerGuid: string, + chainId: string, + session: Session, + ): TsSessionAccount { + return new TsSessionAccount( + rpcUrl, + signer, + address, + ownerGuid, + chainId, + session, + ); + } + + async executeFromOutside(calls: JsCall[]): Promise { + const sessionRegistration: SessionRegistration = { + username: "", + address: this._address, + ownerGuid: this._ownerGuid, + expiresAt: String(this._session.expiresAt), + guardianKeyGuid: this._session.guardianKeyGuid ?? "0x0", + metadataHash: this._session.metadataHash ?? "0x0", + sessionKeyGuid: this._session.sessionKeyGuid, + }; + + const starknetCalls = calls.map((c) => ({ + contractAddress: c.contractAddress, + entrypoint: c.entrypoint, + calldata: c.calldata, + })); + + const { outsideExecution, signature } = buildSignedOutsideExecutionV3({ + calls: starknetCalls, + chainId: this._chainId, + session: sessionRegistration, + sessionPrivateKey: this._privateKey, + policyRoot: this._policyRoot, + sessionKeyGuid: this._sessionKeyGuid, + policyProofIndex: this._policyProofIndex, + }); + + // Submit via Cartridge's custom RPC method + const response = await fetch(this._rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "cartridge_addExecuteOutsideTransaction", + params: { + address: this._address, + outside_execution: outsideExecution, + signature, + }, + }), + }); + + const result = (await response.json()) as any; + if (result.error) { + throw new Error(result.error.message || JSON.stringify(result.error)); + } + + return extractTransactionHash(result.result); + } + + async execute(calls: JsCall[]): Promise { + const sessionRegistration: SessionRegistration = { + username: "", + address: this._address, + ownerGuid: this._ownerGuid, + expiresAt: String(this._session.expiresAt), + guardianKeyGuid: this._session.guardianKeyGuid ?? "0x0", + metadataHash: this._session.metadataHash ?? "0x0", + sessionKeyGuid: this._session.sessionKeyGuid, + }; + + const starknetCalls = calls.map((c) => ({ + contractAddress: c.contractAddress, + entrypoint: c.entrypoint, + calldata: c.calldata, + })); + + // For direct execution, build the same session token signature + // but submit as a regular invoke through the RPC + const { signature } = buildSignedOutsideExecutionV3({ + calls: starknetCalls, + chainId: this._chainId, + session: sessionRegistration, + sessionPrivateKey: this._privateKey, + policyRoot: this._policyRoot, + sessionKeyGuid: this._sessionKeyGuid, + policyProofIndex: this._policyProofIndex, + }); + + // Use Cartridge's addInvokeTransaction with session token + const response = await fetch(this._rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "starknet_addInvokeTransaction", + params: { + invoke_transaction: { + type: "INVOKE", + sender_address: this._address, + calldata: calls.flatMap((c) => [ + c.contractAddress, + c.entrypoint, + String(c.calldata.length), + ...c.calldata, + ]), + version: "0x3", + signature, + nonce: "0x0", + resource_bounds: { + l1_gas: { max_amount: "0x0", max_price_per_unit: "0x0" }, + l2_gas: { max_amount: "0x0", max_price_per_unit: "0x0" }, + }, + }, + }, + }), + }); + + const result = (await response.json()) as any; + if (result.error) { + throw new Error(result.error.message || JSON.stringify(result.error)); + } + + return extractTransactionHash(result.result); + } +} + +function extractTransactionHash(result: unknown): InvokeFunctionResponse { + if (typeof result === "string") { + return { transaction_hash: result }; + } + if (result && typeof result === "object") { + const r = result as Record; + const txHash = + r.transaction_hash ?? r.transactionHash ?? r.hash ?? r.tx_hash; + if (typeof txHash === "string") { + return { transaction_hash: txHash }; + } + } + throw new Error("Could not extract transaction hash from response"); +} diff --git a/packages/controller/src/session/ts/shared.ts b/packages/controller/src/session/ts/shared.ts new file mode 100644 index 0000000000..30160c38c0 --- /dev/null +++ b/packages/controller/src/session/ts/shared.ts @@ -0,0 +1,32 @@ +import { addAddressPadding, hash, num } from "starknet"; + +/** + * Normalize a felt value to a lowercase hex string. + */ +export function normalizeFelt(value: string | number | bigint): string { + return num.toHex(value).toLowerCase(); +} + +/** + * Derive a Starknet selector from an entrypoint name or hex selector. + */ +export function selectorFromEntrypoint(entrypoint: string): string { + if (/^0x[0-9a-f]+$/i.test(entrypoint)) { + return normalizeFelt(entrypoint); + } + return normalizeFelt(hash.getSelectorFromName(entrypoint)); +} + +/** + * Normalize and pad a Starknet contract address. + */ +export function normalizeContractAddress( + address: string, + context: string, +): string { + const trimmed = address.trim(); + if (!trimmed) { + throw new Error(`${context} is missing a contract address.`); + } + return addAddressPadding(trimmed.toLowerCase()); +} diff --git a/packages/controller/src/session/ts/subscribe.ts b/packages/controller/src/session/ts/subscribe.ts new file mode 100644 index 0000000000..0d19ca841d --- /dev/null +++ b/packages/controller/src/session/ts/subscribe.ts @@ -0,0 +1,84 @@ +import { SessionTimeoutError } from "./errors"; + +export interface SubscribeSessionResult { + authorization: string[]; + expiresAt: string; + controller: { + accountID: string; + address: string; + }; +} + +const SUBSCRIBE_QUERY = ` + query SubscribeCreateSession($sessionKeyGuid: Felt!) { + subscribeCreateSession(sessionKeyGuid: $sessionKeyGuid) { + authorization + expiresAt + controller { + accountID + address + } + } + } +`; + +/** + * Polls the Cartridge GraphQL API waiting for a session to be created. + * Replaces the WASM `subscribeCreateSession` function. + */ +export async function subscribeCreateSession( + sessionKeyGuid: string, + cartridgeApiUrl: string, + timeoutMs: number = 180_000, +): Promise { + const endpoint = `${cartridgeApiUrl.replace(/\/+$/, "")}/query`; + const deadline = Date.now() + timeoutMs; + + let delay = 500; + const MAX_DELAY = 5_000; + + while (Date.now() < deadline) { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: SUBSCRIBE_QUERY, + variables: { sessionKeyGuid }, + }), + }); + + if (!response.ok) { + // Retry on server errors + await sleep(delay); + delay = Math.min(delay * 2, MAX_DELAY); + continue; + } + + const json = (await response.json()) as { + data?: { subscribeCreateSession?: SubscribeSessionResult | null }; + errors?: { message: string }[]; + }; + + if (json.errors?.length) { + // Retry on GraphQL errors + await sleep(delay); + delay = Math.min(delay * 2, MAX_DELAY); + continue; + } + + const session = json.data?.subscribeCreateSession; + if (session) { + return session; + } + + // Session not ready yet — back off and retry + await sleep(delay); + delay = Math.min(delay * 2, MAX_DELAY); + } + + throw new SessionTimeoutError("Timed out waiting for session creation"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/controller/src/session/ts/types.ts b/packages/controller/src/session/ts/types.ts new file mode 100644 index 0000000000..37e18bbb09 --- /dev/null +++ b/packages/controller/src/session/ts/types.ts @@ -0,0 +1,60 @@ +/** + * Pure TypeScript equivalents of the WASM session types. + * Structurally identical to @cartridge/controller-wasm session_wasm.d.ts. + */ + +export type JsFelt = string; + +export interface CallPolicy { + target: JsFelt; + method: JsFelt; + authorized?: boolean; +} + +export interface TypedDataPolicy { + scope_hash: JsFelt; + authorized?: boolean; +} + +export interface ApprovalPolicy { + target: JsFelt; + spender: JsFelt; + amount: JsFelt; +} + +export type Policy = CallPolicy | TypedDataPolicy | ApprovalPolicy; + +export interface Session { + policies: Policy[]; + expiresAt: number; + metadataHash: JsFelt; + sessionKeyGuid: JsFelt; + guardianKeyGuid: JsFelt; +} + +export interface JsCall { + contractAddress: JsFelt; + entrypoint: string; + calldata: JsFelt[]; +} + +export interface Signer { + starknet?: StarknetSigner; +} + +export interface StarknetSigner { + privateKey: JsFelt; +} + +export interface JsOutsideExecutionV3 { + caller: JsFelt; + execute_after: string; + execute_before: string; + calls: { to: JsFelt; selector: JsFelt; calldata: JsFelt[] }[]; + nonce: [JsFelt, JsFelt]; +} + +export interface JsSignedOutsideExecution { + outside_execution: JsOutsideExecutionV3; + signature: JsFelt[]; +} diff --git a/packages/controller/src/utils.ts b/packages/controller/src/utils.ts index 9cd65387a8..aa9cc670f5 100644 --- a/packages/controller/src/utils.ts +++ b/packages/controller/src/utils.ts @@ -1,4 +1,4 @@ -import { Policy, ApprovalPolicy } from "@cartridge/controller-wasm/controller"; +import type { Policy, ApprovalPolicy } from "./session/ts/types"; import { Policies, SessionPolicies } from "@cartridge/presets"; import { ChainId } from "@starknet-io/types-js"; import { From 4ed815f762a86d7723d94651706cf4ec40eb1907 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 6 Apr 2026 15:09:11 -0700 Subject: [PATCH 02/22] refactor: rename TsSessionAccount to CartridgeSessionAccount The class has identical behavior to the WASM original and the original name never implied a WASM implementation, so keep the established name. Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/session-account.test.ts | 14 +++++++------- packages/controller/src/node/account.ts | 2 +- packages/controller/src/session/account.ts | 2 +- .../controller/src/session/ts/session-account.ts | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/controller/src/__tests__/session-account.test.ts b/packages/controller/src/__tests__/session-account.test.ts index 72e76ad1fb..fe5d08e289 100644 --- a/packages/controller/src/__tests__/session-account.test.ts +++ b/packages/controller/src/__tests__/session-account.test.ts @@ -1,5 +1,5 @@ import { hash } from "starknet"; -import { TsSessionAccount } from "../session/ts/session-account"; +import { CartridgeSessionAccount } from "../session/ts/session-account"; import type { CallPolicy, Session } from "../session/ts/types"; import { normalizeFelt } from "../session/ts/shared"; @@ -35,9 +35,9 @@ const TEST_SESSION: Session = { guardianKeyGuid: "0x0", }; -describe("TsSessionAccount", () => { +describe("CartridgeSessionAccount", () => { test("newAsRegistered creates an instance", () => { - const account = TsSessionAccount.newAsRegistered( + const account = CartridgeSessionAccount.newAsRegistered( TEST_RPC, TEST_PRIVATE_KEY, TEST_ADDRESS, @@ -45,7 +45,7 @@ describe("TsSessionAccount", () => { TEST_CHAIN_ID, TEST_SESSION, ); - expect(account).toBeInstanceOf(TsSessionAccount); + expect(account).toBeInstanceOf(CartridgeSessionAccount); }); test("executeFromOutside sends correct RPC request", async () => { @@ -58,7 +58,7 @@ describe("TsSessionAccount", () => { }), }); - const account = TsSessionAccount.newAsRegistered( + const account = CartridgeSessionAccount.newAsRegistered( TEST_RPC, TEST_PRIVATE_KEY, TEST_ADDRESS, @@ -99,7 +99,7 @@ describe("TsSessionAccount", () => { }), }); - const account = TsSessionAccount.newAsRegistered( + const account = CartridgeSessionAccount.newAsRegistered( TEST_RPC, TEST_PRIVATE_KEY, TEST_ADDRESS, @@ -130,7 +130,7 @@ describe("TsSessionAccount", () => { }), }); - const account = TsSessionAccount.newAsRegistered( + const account = CartridgeSessionAccount.newAsRegistered( TEST_RPC, TEST_PRIVATE_KEY, TEST_ADDRESS, diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index e11ef6a07e..f9c74405b8 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -1,5 +1,5 @@ import type { Policy } from "../session/ts/types"; -import { TsSessionAccount as CartridgeSessionAccount } from "../session/ts/session-account"; +import { CartridgeSessionAccount } from "../session/ts/session-account"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; diff --git a/packages/controller/src/session/account.ts b/packages/controller/src/session/account.ts index 65881c95ba..f2687aef65 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -1,5 +1,5 @@ import type { Policy } from "./ts/types"; -import { TsSessionAccount as CartridgeSessionAccount } from "./ts/session-account"; +import { CartridgeSessionAccount } from "./ts/session-account"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; diff --git a/packages/controller/src/session/ts/session-account.ts b/packages/controller/src/session/ts/session-account.ts index 4acc062706..e5f9a4f2a6 100644 --- a/packages/controller/src/session/ts/session-account.ts +++ b/packages/controller/src/session/ts/session-account.ts @@ -12,7 +12,7 @@ import { signerToGuid } from "./guid"; * Pure TypeScript replacement for the WASM CartridgeSessionAccount class. * Provides the same `newAsRegistered`, `executeFromOutside`, and `execute` interface. */ -export class TsSessionAccount { +export class CartridgeSessionAccount { private _rpcUrl: string; private _privateKey: string; private _address: string; @@ -56,8 +56,8 @@ export class TsSessionAccount { ownerGuid: string, chainId: string, session: Session, - ): TsSessionAccount { - return new TsSessionAccount( + ): CartridgeSessionAccount { + return new CartridgeSessionAccount( rpcUrl, signer, address, From 3e4421717d4c84e2d8db1d10bcb0df93fd141ab9 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 6 Apr 2026 15:22:42 -0700 Subject: [PATCH 03/22] refactor: rename session-account.ts to account.ts and outside-execution.ts to execution.ts The session/ts/ directory already establishes context, making the prefixes redundant. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/{session-account.test.ts => account.test.ts} | 2 +- .../{outside-execution.test.ts => execution.test.ts} | 2 +- packages/controller/src/node/account.ts | 2 +- packages/controller/src/session/account.ts | 2 +- .../src/session/ts/{session-account.ts => account.ts} | 2 +- .../src/session/ts/{outside-execution.ts => execution.ts} | 0 packages/controller/src/session/ts/index.ts | 4 ++-- 7 files changed, 7 insertions(+), 7 deletions(-) rename packages/controller/src/__tests__/{session-account.test.ts => account.test.ts} (98%) rename packages/controller/src/__tests__/{outside-execution.test.ts => execution.test.ts} (99%) rename packages/controller/src/session/ts/{session-account.ts => account.ts} (99%) rename packages/controller/src/session/ts/{outside-execution.ts => execution.ts} (100%) diff --git a/packages/controller/src/__tests__/session-account.test.ts b/packages/controller/src/__tests__/account.test.ts similarity index 98% rename from packages/controller/src/__tests__/session-account.test.ts rename to packages/controller/src/__tests__/account.test.ts index fe5d08e289..59dfa2cbb5 100644 --- a/packages/controller/src/__tests__/session-account.test.ts +++ b/packages/controller/src/__tests__/account.test.ts @@ -1,5 +1,5 @@ import { hash } from "starknet"; -import { CartridgeSessionAccount } from "../session/ts/session-account"; +import { CartridgeSessionAccount } from "../session/ts/account"; import type { CallPolicy, Session } from "../session/ts/types"; import { normalizeFelt } from "../session/ts/shared"; diff --git a/packages/controller/src/__tests__/outside-execution.test.ts b/packages/controller/src/__tests__/execution.test.ts similarity index 99% rename from packages/controller/src/__tests__/outside-execution.test.ts rename to packages/controller/src/__tests__/execution.test.ts index 11129e31c7..7271ed12fd 100644 --- a/packages/controller/src/__tests__/outside-execution.test.ts +++ b/packages/controller/src/__tests__/execution.test.ts @@ -2,7 +2,7 @@ import { ec, encode, hash, shortString } from "starknet"; import { buildSignedOutsideExecutionV3, createPolicyProofIndex, -} from "../session/ts/outside-execution"; +} from "../session/ts/execution"; import { computePolicyMerkle, computePolicyMerkleProofs, diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index f9c74405b8..81340861b0 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -1,5 +1,5 @@ import type { Policy } from "../session/ts/types"; -import { CartridgeSessionAccount } from "../session/ts/session-account"; +import { CartridgeSessionAccount } from "../session/ts/account"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; diff --git a/packages/controller/src/session/account.ts b/packages/controller/src/session/account.ts index f2687aef65..ef0205dcbd 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -1,5 +1,5 @@ import type { Policy } from "./ts/types"; -import { CartridgeSessionAccount } from "./ts/session-account"; +import { CartridgeSessionAccount } from "./ts/account"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; diff --git a/packages/controller/src/session/ts/session-account.ts b/packages/controller/src/session/ts/account.ts similarity index 99% rename from packages/controller/src/session/ts/session-account.ts rename to packages/controller/src/session/ts/account.ts index e5f9a4f2a6..84c0c98f35 100644 --- a/packages/controller/src/session/ts/session-account.ts +++ b/packages/controller/src/session/ts/account.ts @@ -5,7 +5,7 @@ import { buildSignedOutsideExecutionV3, createPolicyProofIndex, type SessionRegistration, -} from "./outside-execution"; +} from "./execution"; import { signerToGuid } from "./guid"; /** diff --git a/packages/controller/src/session/ts/outside-execution.ts b/packages/controller/src/session/ts/execution.ts similarity index 100% rename from packages/controller/src/session/ts/outside-execution.ts rename to packages/controller/src/session/ts/execution.ts diff --git a/packages/controller/src/session/ts/index.ts b/packages/controller/src/session/ts/index.ts index 019801b497..ac30a1081d 100644 --- a/packages/controller/src/session/ts/index.ts +++ b/packages/controller/src/session/ts/index.ts @@ -2,7 +2,7 @@ export * from "./types"; export * from "./errors"; export * from "./guid"; export * from "./merkle"; -export * from "./outside-execution"; -export * from "./session-account"; +export * from "./execution"; +export * from "./account"; export * from "./subscribe"; export * from "./shared"; From 7e844158f0a966703bc8159562faf2a3380e4c3e Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 6 Apr 2026 15:26:35 -0700 Subject: [PATCH 04/22] refactor: fold signerToGuid from guid.ts into shared.ts Small single-function module absorbed into existing utilities file. Co-Authored-By: Claude Opus 4.6 --- .../controller/src/__tests__/guid.test.ts | 2 +- packages/controller/src/node/provider.ts | 2 +- packages/controller/src/session/provider.ts | 2 +- packages/controller/src/session/ts/account.ts | 2 +- packages/controller/src/session/ts/guid.ts | 24 -------------- packages/controller/src/session/ts/index.ts | 1 - packages/controller/src/session/ts/shared.ts | 32 ++++++++++++++++++- 7 files changed, 35 insertions(+), 30 deletions(-) delete mode 100644 packages/controller/src/session/ts/guid.ts diff --git a/packages/controller/src/__tests__/guid.test.ts b/packages/controller/src/__tests__/guid.test.ts index f523542ca9..0357503b8d 100644 --- a/packages/controller/src/__tests__/guid.test.ts +++ b/packages/controller/src/__tests__/guid.test.ts @@ -1,5 +1,5 @@ import { ec, hash, num, shortString, encode } from "starknet"; -import { signerToGuid } from "../session/ts/guid"; +import { signerToGuid } from "../session/ts/shared"; describe("signerToGuid", () => { test("produces a valid hex felt", () => { diff --git a/packages/controller/src/node/provider.ts b/packages/controller/src/node/provider.ts index 12c37f3904..8eef952d05 100644 --- a/packages/controller/src/node/provider.ts +++ b/packages/controller/src/node/provider.ts @@ -1,7 +1,7 @@ import { ec, encode, stark, WalletAccount } from "starknet"; import { loadConfig, SessionPolicies } from "@cartridge/presets"; import { AddStarknetChainParameters } from "@starknet-io/types-js"; -import { signerToGuid } from "../session/ts/guid"; +import { signerToGuid } from "../session/ts/shared"; import SessionAccount from "./account"; import { KEYCHAIN_URL } from "../constants"; diff --git a/packages/controller/src/session/provider.ts b/packages/controller/src/session/provider.ts index ebf2f6d5e1..a77b4f0653 100644 --- a/packages/controller/src/session/provider.ts +++ b/packages/controller/src/session/provider.ts @@ -1,6 +1,6 @@ import { ec, stark, WalletAccount } from "starknet"; -import { signerToGuid } from "./ts/guid"; +import { signerToGuid } from "./ts/shared"; import { subscribeCreateSession } from "./ts/subscribe"; import { loadConfig, SessionPolicies } from "@cartridge/presets"; import { AddStarknetChainParameters } from "@starknet-io/types-js"; diff --git a/packages/controller/src/session/ts/account.ts b/packages/controller/src/session/ts/account.ts index 84c0c98f35..67a9cb7b0d 100644 --- a/packages/controller/src/session/ts/account.ts +++ b/packages/controller/src/session/ts/account.ts @@ -6,7 +6,7 @@ import { createPolicyProofIndex, type SessionRegistration, } from "./execution"; -import { signerToGuid } from "./guid"; +import { signerToGuid } from "./shared"; /** * Pure TypeScript replacement for the WASM CartridgeSessionAccount class. diff --git a/packages/controller/src/session/ts/guid.ts b/packages/controller/src/session/ts/guid.ts deleted file mode 100644 index 5b0d0e379a..0000000000 --- a/packages/controller/src/session/ts/guid.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ec, encode, hash, num, shortString } from "starknet"; -import type { Signer } from "./types"; - -const STARKNET_SIGNER_DOMAIN = num - .toHex(shortString.encodeShortString("Starknet Signer")) - .toLowerCase(); - -/** - * Derives a session signer GUID from a Signer object. - * Matches the WASM `signerToGuid` function signature exactly. - */ -export function signerToGuid(signer: Signer): string { - const privateKey = signer.starknet?.privateKey; - if (!privateKey) { - throw new Error("Cannot derive session GUID: missing starknet private key"); - } - - const normalizedKey = encode.addHexPrefix(String(privateKey).trim()); - const publicKey = ec.starkCurve.getStarkKey(normalizedKey); - - return num - .toHex(hash.computePoseidonHash(STARKNET_SIGNER_DOMAIN, publicKey)) - .toLowerCase(); -} diff --git a/packages/controller/src/session/ts/index.ts b/packages/controller/src/session/ts/index.ts index ac30a1081d..727542d653 100644 --- a/packages/controller/src/session/ts/index.ts +++ b/packages/controller/src/session/ts/index.ts @@ -1,6 +1,5 @@ export * from "./types"; export * from "./errors"; -export * from "./guid"; export * from "./merkle"; export * from "./execution"; export * from "./account"; diff --git a/packages/controller/src/session/ts/shared.ts b/packages/controller/src/session/ts/shared.ts index 30160c38c0..8343e2dd00 100644 --- a/packages/controller/src/session/ts/shared.ts +++ b/packages/controller/src/session/ts/shared.ts @@ -1,4 +1,12 @@ -import { addAddressPadding, hash, num } from "starknet"; +import { + addAddressPadding, + ec, + encode, + hash, + num, + shortString, +} from "starknet"; +import type { Signer } from "./types"; /** * Normalize a felt value to a lowercase hex string. @@ -30,3 +38,25 @@ export function normalizeContractAddress( } return addAddressPadding(trimmed.toLowerCase()); } + +const STARKNET_SIGNER_DOMAIN = num + .toHex(shortString.encodeShortString("Starknet Signer")) + .toLowerCase(); + +/** + * Derives a session signer GUID from a Signer object. + * Matches the WASM `signerToGuid` function signature exactly. + */ +export function signerToGuid(signer: Signer): string { + const privateKey = signer.starknet?.privateKey; + if (!privateKey) { + throw new Error("Cannot derive session GUID: missing starknet private key"); + } + + const normalizedKey = encode.addHexPrefix(String(privateKey).trim()); + const publicKey = ec.starkCurve.getStarkKey(normalizedKey); + + return num + .toHex(hash.computePoseidonHash(STARKNET_SIGNER_DOMAIN, publicKey)) + .toLowerCase(); +} From 99b8f2b9ca830f467ad9e335ff0ada552c1fe82f Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 6 Apr 2026 15:38:07 -0700 Subject: [PATCH 05/22] refactor: rename session/ts/ to session/internal/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "ts" name was meaningless — everything is TypeScript. "internal" clearly signals these are implementation details behind the session API. Co-Authored-By: Claude Opus 4.6 --- packages/controller/src/__tests__/account.test.ts | 6 +++--- packages/controller/src/__tests__/execution.test.ts | 8 ++++---- packages/controller/src/__tests__/guid.test.ts | 2 +- packages/controller/src/__tests__/merkle.test.ts | 4 ++-- packages/controller/src/__tests__/subscribe.test.ts | 4 ++-- packages/controller/src/node/account.ts | 4 ++-- packages/controller/src/node/provider.ts | 2 +- packages/controller/src/session/account.ts | 4 ++-- packages/controller/src/session/index.ts | 2 +- .../controller/src/session/{ts => internal}/account.ts | 0 .../controller/src/session/{ts => internal}/errors.ts | 0 .../controller/src/session/{ts => internal}/execution.ts | 0 packages/controller/src/session/{ts => internal}/index.ts | 0 .../controller/src/session/{ts => internal}/merkle.ts | 0 .../controller/src/session/{ts => internal}/shared.ts | 0 .../controller/src/session/{ts => internal}/subscribe.ts | 0 packages/controller/src/session/{ts => internal}/types.ts | 0 packages/controller/src/session/provider.ts | 4 ++-- packages/controller/src/utils.ts | 2 +- 19 files changed, 21 insertions(+), 21 deletions(-) rename packages/controller/src/session/{ts => internal}/account.ts (100%) rename packages/controller/src/session/{ts => internal}/errors.ts (100%) rename packages/controller/src/session/{ts => internal}/execution.ts (100%) rename packages/controller/src/session/{ts => internal}/index.ts (100%) rename packages/controller/src/session/{ts => internal}/merkle.ts (100%) rename packages/controller/src/session/{ts => internal}/shared.ts (100%) rename packages/controller/src/session/{ts => internal}/subscribe.ts (100%) rename packages/controller/src/session/{ts => internal}/types.ts (100%) diff --git a/packages/controller/src/__tests__/account.test.ts b/packages/controller/src/__tests__/account.test.ts index 59dfa2cbb5..1664a65da3 100644 --- a/packages/controller/src/__tests__/account.test.ts +++ b/packages/controller/src/__tests__/account.test.ts @@ -1,7 +1,7 @@ import { hash } from "starknet"; -import { CartridgeSessionAccount } from "../session/ts/account"; -import type { CallPolicy, Session } from "../session/ts/types"; -import { normalizeFelt } from "../session/ts/shared"; +import { CartridgeSessionAccount } from "../session/internal/account"; +import type { CallPolicy, Session } from "../session/internal/types"; +import { normalizeFelt } from "../session/internal/shared"; // Mock global fetch const mockFetch = jest.fn(); diff --git a/packages/controller/src/__tests__/execution.test.ts b/packages/controller/src/__tests__/execution.test.ts index 7271ed12fd..6cb20c494a 100644 --- a/packages/controller/src/__tests__/execution.test.ts +++ b/packages/controller/src/__tests__/execution.test.ts @@ -2,13 +2,13 @@ import { ec, encode, hash, shortString } from "starknet"; import { buildSignedOutsideExecutionV3, createPolicyProofIndex, -} from "../session/ts/execution"; +} from "../session/internal/execution"; import { computePolicyMerkle, computePolicyMerkleProofs, -} from "../session/ts/merkle"; -import type { CallPolicy } from "../session/ts/types"; -import { normalizeFelt } from "../session/ts/shared"; +} from "../session/internal/merkle"; +import type { CallPolicy } from "../session/internal/types"; +import { normalizeFelt } from "../session/internal/shared"; const TEST_PRIVATE_KEY = "0x1"; const TEST_ADDRESS = diff --git a/packages/controller/src/__tests__/guid.test.ts b/packages/controller/src/__tests__/guid.test.ts index 0357503b8d..7953f634ea 100644 --- a/packages/controller/src/__tests__/guid.test.ts +++ b/packages/controller/src/__tests__/guid.test.ts @@ -1,5 +1,5 @@ import { ec, hash, num, shortString, encode } from "starknet"; -import { signerToGuid } from "../session/ts/shared"; +import { signerToGuid } from "../session/internal/shared"; describe("signerToGuid", () => { test("produces a valid hex felt", () => { diff --git a/packages/controller/src/__tests__/merkle.test.ts b/packages/controller/src/__tests__/merkle.test.ts index 27c9f7276e..e33f819ba0 100644 --- a/packages/controller/src/__tests__/merkle.test.ts +++ b/packages/controller/src/__tests__/merkle.test.ts @@ -4,13 +4,13 @@ import { hashPolicyLeaf, computePolicyMerkle, computePolicyMerkleProofs, -} from "../session/ts/merkle"; +} from "../session/internal/merkle"; import type { CallPolicy, TypedDataPolicy, ApprovalPolicy, Policy, -} from "../session/ts/types"; +} from "../session/internal/types"; const SELECTOR_TRANSFER = hash.getSelectorFromName("transfer"); const SELECTOR_APPROVE = hash.getSelectorFromName("approve"); diff --git a/packages/controller/src/__tests__/subscribe.test.ts b/packages/controller/src/__tests__/subscribe.test.ts index f09408d4cf..8a04f70599 100644 --- a/packages/controller/src/__tests__/subscribe.test.ts +++ b/packages/controller/src/__tests__/subscribe.test.ts @@ -1,5 +1,5 @@ -import { subscribeCreateSession } from "../session/ts/subscribe"; -import { SessionTimeoutError } from "../session/ts/errors"; +import { subscribeCreateSession } from "../session/internal/subscribe"; +import { SessionTimeoutError } from "../session/internal/errors"; // Mock global fetch const mockFetch = jest.fn(); diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index 81340861b0..da20fb8d79 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -1,5 +1,5 @@ -import type { Policy } from "../session/ts/types"; -import { CartridgeSessionAccount } from "../session/ts/account"; +import type { Policy } from "../session/internal/types"; +import { CartridgeSessionAccount } from "../session/internal/account"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; diff --git a/packages/controller/src/node/provider.ts b/packages/controller/src/node/provider.ts index 8eef952d05..fe55c32af8 100644 --- a/packages/controller/src/node/provider.ts +++ b/packages/controller/src/node/provider.ts @@ -1,7 +1,7 @@ import { ec, encode, stark, WalletAccount } from "starknet"; import { loadConfig, SessionPolicies } from "@cartridge/presets"; import { AddStarknetChainParameters } from "@starknet-io/types-js"; -import { signerToGuid } from "../session/ts/shared"; +import { signerToGuid } from "../session/internal/shared"; import SessionAccount from "./account"; import { KEYCHAIN_URL } from "../constants"; diff --git a/packages/controller/src/session/account.ts b/packages/controller/src/session/account.ts index ef0205dcbd..80fc1f11ea 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -1,5 +1,5 @@ -import type { Policy } from "./ts/types"; -import { CartridgeSessionAccount } from "./ts/account"; +import type { Policy } from "./internal/types"; +import { CartridgeSessionAccount } from "./internal/account"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; diff --git a/packages/controller/src/session/index.ts b/packages/controller/src/session/index.ts index af375450af..a640c932c5 100644 --- a/packages/controller/src/session/index.ts +++ b/packages/controller/src/session/index.ts @@ -2,4 +2,4 @@ export { default } from "./provider"; export * from "./provider"; export * from "../errors"; export * from "../types"; -export * from "./ts/types"; +export * from "./internal/types"; diff --git a/packages/controller/src/session/ts/account.ts b/packages/controller/src/session/internal/account.ts similarity index 100% rename from packages/controller/src/session/ts/account.ts rename to packages/controller/src/session/internal/account.ts diff --git a/packages/controller/src/session/ts/errors.ts b/packages/controller/src/session/internal/errors.ts similarity index 100% rename from packages/controller/src/session/ts/errors.ts rename to packages/controller/src/session/internal/errors.ts diff --git a/packages/controller/src/session/ts/execution.ts b/packages/controller/src/session/internal/execution.ts similarity index 100% rename from packages/controller/src/session/ts/execution.ts rename to packages/controller/src/session/internal/execution.ts diff --git a/packages/controller/src/session/ts/index.ts b/packages/controller/src/session/internal/index.ts similarity index 100% rename from packages/controller/src/session/ts/index.ts rename to packages/controller/src/session/internal/index.ts diff --git a/packages/controller/src/session/ts/merkle.ts b/packages/controller/src/session/internal/merkle.ts similarity index 100% rename from packages/controller/src/session/ts/merkle.ts rename to packages/controller/src/session/internal/merkle.ts diff --git a/packages/controller/src/session/ts/shared.ts b/packages/controller/src/session/internal/shared.ts similarity index 100% rename from packages/controller/src/session/ts/shared.ts rename to packages/controller/src/session/internal/shared.ts diff --git a/packages/controller/src/session/ts/subscribe.ts b/packages/controller/src/session/internal/subscribe.ts similarity index 100% rename from packages/controller/src/session/ts/subscribe.ts rename to packages/controller/src/session/internal/subscribe.ts diff --git a/packages/controller/src/session/ts/types.ts b/packages/controller/src/session/internal/types.ts similarity index 100% rename from packages/controller/src/session/ts/types.ts rename to packages/controller/src/session/internal/types.ts diff --git a/packages/controller/src/session/provider.ts b/packages/controller/src/session/provider.ts index a77b4f0653..20495e75d5 100644 --- a/packages/controller/src/session/provider.ts +++ b/packages/controller/src/session/provider.ts @@ -1,7 +1,7 @@ import { ec, stark, WalletAccount } from "starknet"; -import { signerToGuid } from "./ts/shared"; -import { subscribeCreateSession } from "./ts/subscribe"; +import { signerToGuid } from "./internal/shared"; +import { subscribeCreateSession } from "./internal/subscribe"; import { loadConfig, SessionPolicies } from "@cartridge/presets"; import { AddStarknetChainParameters } from "@starknet-io/types-js"; import { encode } from "starknet"; diff --git a/packages/controller/src/utils.ts b/packages/controller/src/utils.ts index aa9cc670f5..908c5d5dee 100644 --- a/packages/controller/src/utils.ts +++ b/packages/controller/src/utils.ts @@ -1,4 +1,4 @@ -import type { Policy, ApprovalPolicy } from "./session/ts/types"; +import type { Policy, ApprovalPolicy } from "./session/internal/types"; import { Policies, SessionPolicies } from "@cartridge/presets"; import { ChainId } from "@starknet-io/types-js"; import { From e69e29bc838341850fac7b0a0779999355101888 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 6 Apr 2026 15:50:05 -0700 Subject: [PATCH 06/22] refactor: route node/ imports through session public API node/ was reaching into session/internal/ directly. Re-export CartridgeSessionAccount and signerToGuid from session/index.ts so node/ imports from ../session instead. Co-Authored-By: Claude Opus 4.6 --- packages/controller/src/node/account.ts | 4 ++-- packages/controller/src/node/provider.ts | 2 +- packages/controller/src/session/index.ts | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index da20fb8d79..74382f951c 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -1,5 +1,5 @@ -import type { Policy } from "../session/internal/types"; -import { CartridgeSessionAccount } from "../session/internal/account"; +import type { Policy } from "../session"; +import { CartridgeSessionAccount } from "../session"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; diff --git a/packages/controller/src/node/provider.ts b/packages/controller/src/node/provider.ts index fe55c32af8..a7e0435fa5 100644 --- a/packages/controller/src/node/provider.ts +++ b/packages/controller/src/node/provider.ts @@ -1,7 +1,7 @@ import { ec, encode, stark, WalletAccount } from "starknet"; import { loadConfig, SessionPolicies } from "@cartridge/presets"; import { AddStarknetChainParameters } from "@starknet-io/types-js"; -import { signerToGuid } from "../session/internal/shared"; +import { signerToGuid } from "../session"; import SessionAccount from "./account"; import { KEYCHAIN_URL } from "../constants"; diff --git a/packages/controller/src/session/index.ts b/packages/controller/src/session/index.ts index a640c932c5..503186d1f3 100644 --- a/packages/controller/src/session/index.ts +++ b/packages/controller/src/session/index.ts @@ -3,3 +3,5 @@ export * from "./provider"; export * from "../errors"; export * from "../types"; export * from "./internal/types"; +export { CartridgeSessionAccount } from "./internal/account"; +export { signerToGuid } from "./internal/shared"; From 32772deae1c3034bd4b43e4e615dc3f4c3d4d489 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 6 Apr 2026 15:58:35 -0700 Subject: [PATCH 07/22] refactor: remove Js prefixes from session types Drop JsFelt (inline string), rename JsCall to SessionCall, remove unused JsOutsideExecutionV3 and JsSignedOutsideExecution. The Js prefixes were WASM artifacts with no meaning in pure TypeScript. Co-Authored-By: Claude Opus 4.6 --- .../src/session/internal/account.ts | 8 ++-- .../controller/src/session/internal/types.ts | 42 ++++++------------- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/packages/controller/src/session/internal/account.ts b/packages/controller/src/session/internal/account.ts index 67a9cb7b0d..34f0c02e47 100644 --- a/packages/controller/src/session/internal/account.ts +++ b/packages/controller/src/session/internal/account.ts @@ -1,5 +1,5 @@ import type { InvokeFunctionResponse } from "starknet"; -import type { JsCall, Session } from "./types"; +import type { SessionCall, Session } from "./types"; import { computePolicyMerkle, computePolicyMerkleProofs } from "./merkle"; import { buildSignedOutsideExecutionV3, @@ -67,7 +67,9 @@ export class CartridgeSessionAccount { ); } - async executeFromOutside(calls: JsCall[]): Promise { + async executeFromOutside( + calls: SessionCall[], + ): Promise { const sessionRegistration: SessionRegistration = { username: "", address: this._address, @@ -118,7 +120,7 @@ export class CartridgeSessionAccount { return extractTransactionHash(result.result); } - async execute(calls: JsCall[]): Promise { + async execute(calls: SessionCall[]): Promise { const sessionRegistration: SessionRegistration = { username: "", address: this._address, diff --git a/packages/controller/src/session/internal/types.ts b/packages/controller/src/session/internal/types.ts index 37e18bbb09..b7f6a1dd3e 100644 --- a/packages/controller/src/session/internal/types.ts +++ b/packages/controller/src/session/internal/types.ts @@ -1,25 +1,22 @@ /** * Pure TypeScript equivalents of the WASM session types. - * Structurally identical to @cartridge/controller-wasm session_wasm.d.ts. */ -export type JsFelt = string; - export interface CallPolicy { - target: JsFelt; - method: JsFelt; + target: string; + method: string; authorized?: boolean; } export interface TypedDataPolicy { - scope_hash: JsFelt; + scope_hash: string; authorized?: boolean; } export interface ApprovalPolicy { - target: JsFelt; - spender: JsFelt; - amount: JsFelt; + target: string; + spender: string; + amount: string; } export type Policy = CallPolicy | TypedDataPolicy | ApprovalPolicy; @@ -27,15 +24,15 @@ export type Policy = CallPolicy | TypedDataPolicy | ApprovalPolicy; export interface Session { policies: Policy[]; expiresAt: number; - metadataHash: JsFelt; - sessionKeyGuid: JsFelt; - guardianKeyGuid: JsFelt; + metadataHash: string; + sessionKeyGuid: string; + guardianKeyGuid: string; } -export interface JsCall { - contractAddress: JsFelt; +export interface SessionCall { + contractAddress: string; entrypoint: string; - calldata: JsFelt[]; + calldata: string[]; } export interface Signer { @@ -43,18 +40,5 @@ export interface Signer { } export interface StarknetSigner { - privateKey: JsFelt; -} - -export interface JsOutsideExecutionV3 { - caller: JsFelt; - execute_after: string; - execute_before: string; - calls: { to: JsFelt; selector: JsFelt; calldata: JsFelt[] }[]; - nonce: [JsFelt, JsFelt]; -} - -export interface JsSignedOutsideExecution { - outside_execution: JsOutsideExecutionV3; - signature: JsFelt[]; + privateKey: string; } From c2fa74d885abe750d31b1e40b7050a50755c8c7c Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 6 Apr 2026 16:51:52 -0700 Subject: [PATCH 08/22] refactor: restore Felt type alias in session types Felt communicates domain intent better than raw string. Renamed from JsFelt to Felt since the Js prefix was a WASM artifact. Co-Authored-By: Claude Opus 4.6 --- .../controller/src/session/internal/types.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/controller/src/session/internal/types.ts b/packages/controller/src/session/internal/types.ts index b7f6a1dd3e..02f095e81d 100644 --- a/packages/controller/src/session/internal/types.ts +++ b/packages/controller/src/session/internal/types.ts @@ -2,21 +2,23 @@ * Pure TypeScript equivalents of the WASM session types. */ +export type Felt252 = string; + export interface CallPolicy { - target: string; - method: string; + target: Felt252; + method: Felt252; authorized?: boolean; } export interface TypedDataPolicy { - scope_hash: string; + scope_hash: Felt252; authorized?: boolean; } export interface ApprovalPolicy { - target: string; - spender: string; - amount: string; + target: Felt252; + spender: Felt252; + amount: Felt252; } export type Policy = CallPolicy | TypedDataPolicy | ApprovalPolicy; @@ -24,15 +26,15 @@ export type Policy = CallPolicy | TypedDataPolicy | ApprovalPolicy; export interface Session { policies: Policy[]; expiresAt: number; - metadataHash: string; - sessionKeyGuid: string; - guardianKeyGuid: string; + metadataHash: Felt252; + sessionKeyGuid: Felt252; + guardianKeyGuid: Felt252; } export interface SessionCall { - contractAddress: string; + contractAddress: Felt252; entrypoint: string; - calldata: string[]; + calldata: Felt252[]; } export interface Signer { @@ -40,5 +42,5 @@ export interface Signer { } export interface StarknetSigner { - privateKey: string; + privateKey: Felt252; } From 4f2ee2c32685d42610f9afcba15468568557a496 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 6 Apr 2026 17:02:59 -0700 Subject: [PATCH 09/22] refactor: rename session/internal/shared.ts to utils.ts Co-Authored-By: Claude Opus 4.6 --- packages/controller/src/__tests__/account.test.ts | 2 +- packages/controller/src/__tests__/execution.test.ts | 2 +- packages/controller/src/__tests__/guid.test.ts | 2 +- packages/controller/src/session/index.ts | 2 +- packages/controller/src/session/internal/account.ts | 2 +- packages/controller/src/session/internal/execution.ts | 2 +- packages/controller/src/session/internal/index.ts | 2 +- packages/controller/src/session/internal/merkle.ts | 2 +- .../controller/src/session/internal/{shared.ts => utils.ts} | 0 packages/controller/src/session/provider.ts | 2 +- 10 files changed, 9 insertions(+), 9 deletions(-) rename packages/controller/src/session/internal/{shared.ts => utils.ts} (100%) diff --git a/packages/controller/src/__tests__/account.test.ts b/packages/controller/src/__tests__/account.test.ts index 1664a65da3..475674ddde 100644 --- a/packages/controller/src/__tests__/account.test.ts +++ b/packages/controller/src/__tests__/account.test.ts @@ -1,7 +1,7 @@ import { hash } from "starknet"; import { CartridgeSessionAccount } from "../session/internal/account"; import type { CallPolicy, Session } from "../session/internal/types"; -import { normalizeFelt } from "../session/internal/shared"; +import { normalizeFelt } from "../session/internal/utils"; // Mock global fetch const mockFetch = jest.fn(); diff --git a/packages/controller/src/__tests__/execution.test.ts b/packages/controller/src/__tests__/execution.test.ts index 6cb20c494a..8a82752f00 100644 --- a/packages/controller/src/__tests__/execution.test.ts +++ b/packages/controller/src/__tests__/execution.test.ts @@ -8,7 +8,7 @@ import { computePolicyMerkleProofs, } from "../session/internal/merkle"; import type { CallPolicy } from "../session/internal/types"; -import { normalizeFelt } from "../session/internal/shared"; +import { normalizeFelt } from "../session/internal/utils"; const TEST_PRIVATE_KEY = "0x1"; const TEST_ADDRESS = diff --git a/packages/controller/src/__tests__/guid.test.ts b/packages/controller/src/__tests__/guid.test.ts index 7953f634ea..53d6bdf131 100644 --- a/packages/controller/src/__tests__/guid.test.ts +++ b/packages/controller/src/__tests__/guid.test.ts @@ -1,5 +1,5 @@ import { ec, hash, num, shortString, encode } from "starknet"; -import { signerToGuid } from "../session/internal/shared"; +import { signerToGuid } from "../session/internal/utils"; describe("signerToGuid", () => { test("produces a valid hex felt", () => { diff --git a/packages/controller/src/session/index.ts b/packages/controller/src/session/index.ts index 503186d1f3..588b292aca 100644 --- a/packages/controller/src/session/index.ts +++ b/packages/controller/src/session/index.ts @@ -4,4 +4,4 @@ export * from "../errors"; export * from "../types"; export * from "./internal/types"; export { CartridgeSessionAccount } from "./internal/account"; -export { signerToGuid } from "./internal/shared"; +export { signerToGuid } from "./internal/utils"; diff --git a/packages/controller/src/session/internal/account.ts b/packages/controller/src/session/internal/account.ts index 34f0c02e47..1fd480f8b1 100644 --- a/packages/controller/src/session/internal/account.ts +++ b/packages/controller/src/session/internal/account.ts @@ -6,7 +6,7 @@ import { createPolicyProofIndex, type SessionRegistration, } from "./execution"; -import { signerToGuid } from "./shared"; +import { signerToGuid } from "./utils"; /** * Pure TypeScript replacement for the WASM CartridgeSessionAccount class. diff --git a/packages/controller/src/session/internal/execution.ts b/packages/controller/src/session/internal/execution.ts index 78d8dd5a08..7d63b9b661 100644 --- a/packages/controller/src/session/internal/execution.ts +++ b/packages/controller/src/session/internal/execution.ts @@ -13,7 +13,7 @@ import { normalizeFelt, normalizeContractAddress, selectorFromEntrypoint, -} from "./shared"; +} from "./utils"; const ZERO_FELT = "0x0"; const ONE_FELT = "0x1"; diff --git a/packages/controller/src/session/internal/index.ts b/packages/controller/src/session/internal/index.ts index 727542d653..979a7b8a5b 100644 --- a/packages/controller/src/session/internal/index.ts +++ b/packages/controller/src/session/internal/index.ts @@ -4,4 +4,4 @@ export * from "./merkle"; export * from "./execution"; export * from "./account"; export * from "./subscribe"; -export * from "./shared"; +export * from "./utils"; diff --git a/packages/controller/src/session/internal/merkle.ts b/packages/controller/src/session/internal/merkle.ts index 94718f37b8..66cf48f557 100644 --- a/packages/controller/src/session/internal/merkle.ts +++ b/packages/controller/src/session/internal/merkle.ts @@ -5,7 +5,7 @@ import type { TypedDataPolicy, ApprovalPolicy, } from "./types"; -import { normalizeFelt } from "./shared"; +import { normalizeFelt } from "./utils"; const ZERO_FELT = "0x0"; diff --git a/packages/controller/src/session/internal/shared.ts b/packages/controller/src/session/internal/utils.ts similarity index 100% rename from packages/controller/src/session/internal/shared.ts rename to packages/controller/src/session/internal/utils.ts diff --git a/packages/controller/src/session/provider.ts b/packages/controller/src/session/provider.ts index 20495e75d5..6c5c53f6d3 100644 --- a/packages/controller/src/session/provider.ts +++ b/packages/controller/src/session/provider.ts @@ -1,6 +1,6 @@ import { ec, stark, WalletAccount } from "starknet"; -import { signerToGuid } from "./internal/shared"; +import { signerToGuid } from "./internal/utils"; import { subscribeCreateSession } from "./internal/subscribe"; import { loadConfig, SessionPolicies } from "@cartridge/presets"; import { AddStarknetChainParameters } from "@starknet-io/types-js"; From 950859345a02cca7530cce8aa9cc9c2972cb9eeb Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Tue, 7 Apr 2026 15:16:15 -0700 Subject: [PATCH 10/22] fix: correct session fallback, sorting, and time-bound validation - Remove broken CartridgeSessionAccount.execute() that used an outside-execution signature for a regular invoke transaction - Add isSnip9CompatibilityError() (ported from reference) so callers can distinguish SNIP-9 incompatibility from real failures - Replace catch-all fallback in session/account.ts and node/account.ts with selective SNIP-9 detection; non-SNIP-9 errors now propagate immediately instead of being swallowed - Fix localeCompare sorting in toWasmPolicies to use pure byte-order comparison, matching the reference and eliminating locale-dependent merkle root differences - Add executeBefore > executeAfter validation in buildSignedOutsideExecutionV3 - Add test coverage: time-bound validation, isSnip9CompatibilityError, error propagation, and locale-independent address sorting Co-Authored-By: Claude Sonnet 4.6 --- .../controller/src/__tests__/account.test.ts | 114 +++++++++++++++--- .../src/__tests__/execution.test.ts | 70 +++++++++++ .../src/__tests__/toWasmPolicies.test.ts | 42 +++++++ packages/controller/src/node/account.ts | 20 +-- packages/controller/src/session/account.ts | 17 ++- packages/controller/src/session/index.ts | 6 + .../src/session/internal/account.ts | 67 ---------- .../controller/src/session/internal/errors.ts | 50 ++++++++ .../src/session/internal/execution.ts | 6 + packages/controller/src/utils.ts | 12 +- 10 files changed, 308 insertions(+), 96 deletions(-) diff --git a/packages/controller/src/__tests__/account.test.ts b/packages/controller/src/__tests__/account.test.ts index 475674ddde..24d921b09e 100644 --- a/packages/controller/src/__tests__/account.test.ts +++ b/packages/controller/src/__tests__/account.test.ts @@ -1,5 +1,6 @@ import { hash } from "starknet"; import { CartridgeSessionAccount } from "../session/internal/account"; +import { isSnip9CompatibilityError } from "../session/internal/errors"; import type { CallPolicy, Session } from "../session/internal/types"; import { normalizeFelt } from "../session/internal/utils"; @@ -120,13 +121,100 @@ describe("CartridgeSessionAccount", () => { ).rejects.toThrow("execution failed"); }); - test("execute sends correct RPC request", async () => { + test("execute() is not available on CartridgeSessionAccount", () => { + const account = CartridgeSessionAccount.newAsRegistered( + TEST_RPC, + TEST_PRIVATE_KEY, + TEST_ADDRESS, + TEST_OWNER_GUID, + TEST_CHAIN_ID, + TEST_SESSION, + ); + expect((account as any).execute).toBeUndefined(); + }); + + test("executeFromOutside propagates non-SNIP-9 errors without swallowing", async () => { + const networkError = new Error("network timeout"); + mockFetch.mockRejectedValueOnce(networkError); + + const account = CartridgeSessionAccount.newAsRegistered( + TEST_RPC, + TEST_PRIVATE_KEY, + TEST_ADDRESS, + TEST_OWNER_GUID, + TEST_CHAIN_ID, + TEST_SESSION, + ); + + await expect( + account.executeFromOutside([ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: [], + }, + ]), + ).rejects.toThrow("network timeout"); + }); + + describe("isSnip9CompatibilityError", () => { + test("returns true for OUTSIDE_EXECUTION_NOT_SUPPORTED code", () => { + const err = Object.assign(new Error("failed"), { + code: "OUTSIDE_EXECUTION_NOT_SUPPORTED", + }); + expect(isSnip9CompatibilityError(err)).toBe(true); + }); + + test("returns true for snip-9 message patterns", () => { + expect( + isSnip9CompatibilityError( + new Error("account is not compatible with snip-9"), + ), + ).toBe(true); + expect( + isSnip9CompatibilityError(new Error("entrypoint does not exist")), + ).toBe(true); + expect( + isSnip9CompatibilityError( + new Error("not implemented: outside execution"), + ), + ).toBe(true); + }); + + test("returns false for generic errors", () => { + expect(isSnip9CompatibilityError(new Error("network timeout"))).toBe( + false, + ); + expect( + isSnip9CompatibilityError(new Error("policy not authorized")), + ).toBe(false); + expect(isSnip9CompatibilityError(new Error("execution failed"))).toBe( + false, + ); + }); + + test("returns false for null/undefined", () => { + expect(isSnip9CompatibilityError(null)).toBe(false); + expect(isSnip9CompatibilityError(undefined)).toBe(false); + }); + + test("checks nested cause for error code", () => { + const cause = Object.assign(new Error("inner"), { + code: "OUTSIDE_EXECUTION_UNSUPPORTED", + }); + const err = Object.assign(new Error("outer"), { cause }); + expect(isSnip9CompatibilityError(err)).toBe(true); + }); + }); + + test("executeFromOutside propagates policy-mismatch errors", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ jsonrpc: "2.0", id: 1, - result: { transaction_hash: "0xtxhash2" }, + error: { code: -32000, message: "policy not authorized" }, }), }); @@ -139,17 +227,15 @@ describe("CartridgeSessionAccount", () => { TEST_SESSION, ); - const result = await account.execute([ - { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - entrypoint: "transfer", - calldata: ["0x1"], - }, - ]); - - expect(result.transaction_hash).toBe("0xtxhash2"); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.method).toBe("starknet_addInvokeTransaction"); + await expect( + account.executeFromOutside([ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: [], + }, + ]), + ).rejects.toThrow("policy not authorized"); }); }); diff --git a/packages/controller/src/__tests__/execution.test.ts b/packages/controller/src/__tests__/execution.test.ts index 8a82752f00..5ede03e2ff 100644 --- a/packages/controller/src/__tests__/execution.test.ts +++ b/packages/controller/src/__tests__/execution.test.ts @@ -177,6 +177,76 @@ describe("buildSignedOutsideExecutionV3", () => { ).toThrow("not authorized by session policies"); }); + test("throws when execute_before <= execute_after", () => { + const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); + + expect(() => + buildSignedOutsideExecutionV3({ + calls: [ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: [], + }, + ], + timeBounds: { + executeAfter: 2000, + executeBefore: 1000, + }, + chainId: TEST_CHAIN_ID, + session: { + username: "test", + address: TEST_ADDRESS, + ownerGuid: TEST_OWNER_GUID, + expiresAt: "9999999999", + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid, + }, + sessionPrivateKey: TEST_PRIVATE_KEY, + policyRoot: root, + sessionKeyGuid, + policyProofIndex, + }), + ).toThrow("execute_before must be greater than execute_after"); + }); + + test("throws when execute_before equals execute_after", () => { + const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); + + expect(() => + buildSignedOutsideExecutionV3({ + calls: [ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: [], + }, + ], + timeBounds: { + executeAfter: 1000, + executeBefore: 1000, + }, + chainId: TEST_CHAIN_ID, + session: { + username: "test", + address: TEST_ADDRESS, + ownerGuid: TEST_OWNER_GUID, + expiresAt: "9999999999", + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid, + }, + sessionPrivateKey: TEST_PRIVATE_KEY, + policyRoot: root, + sessionKeyGuid, + policyProofIndex, + }), + ).toThrow("execute_before must be greater than execute_after"); + }); + test("respects custom time bounds", () => { const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); diff --git a/packages/controller/src/__tests__/toWasmPolicies.test.ts b/packages/controller/src/__tests__/toWasmPolicies.test.ts index 5846eb0f4a..a0aafec1d6 100644 --- a/packages/controller/src/__tests__/toWasmPolicies.test.ts +++ b/packages/controller/src/__tests__/toWasmPolicies.test.ts @@ -137,6 +137,48 @@ describe("toWasmPolicies", () => { expect(result1[3]).toHaveProperty("target", ADDR_C_CS); }); + test("sorts addresses with differing leading zeros by byte value not locale", () => { + // 0x0aaa < 0x0b00 < 0x4aaa in byte order (leading zero preserved in sort key) + // A locale-sensitive sort could behave differently depending on the runtime locale. + const policies1: ParsedSessionPolicies = { + verified: false, + contracts: { + "0x04aaa": { + methods: [{ entrypoint: "third", authorized: true }], + }, + "0x00aaa": { + methods: [{ entrypoint: "first", authorized: true }], + }, + "0x00b00": { + methods: [{ entrypoint: "second", authorized: true }], + }, + }, + }; + + const policies2: ParsedSessionPolicies = { + verified: false, + contracts: { + "0x00b00": { + methods: [{ entrypoint: "second", authorized: true }], + }, + "0x04aaa": { + methods: [{ entrypoint: "third", authorized: true }], + }, + "0x00aaa": { + methods: [{ entrypoint: "first", authorized: true }], + }, + }, + }; + + const result1 = toWasmPolicies(policies1); + const result2 = toWasmPolicies(policies2); + + // Both orderings of input should produce identical output + expect(result1).toEqual(result2); + // Verify stable byte-order: 0x00aaa < 0x00b00 < 0x04aaa + expect((result1[0] as any).method).toBeDefined(); // first policy + }); + test("handles case-insensitive address sorting", () => { const policies1: ParsedSessionPolicies = { verified: false, diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index 74382f951c..6a2d0377c9 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -1,5 +1,9 @@ import type { Policy } from "../session"; -import { CartridgeSessionAccount } from "../session"; +import { + CartridgeSessionAccount, + isSnip9CompatibilityError, + SessionProtocolError, +} from "../session"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; @@ -73,13 +77,15 @@ export default class SessionAccount extends WalletAccount { */ async execute(calls: Call | Call[]): Promise { try { - const res = await this.controller.executeFromOutside( - normalizeCalls(calls), - ); - - return res; + return await this.controller.executeFromOutside(normalizeCalls(calls)); } catch (e) { - return this.controller.execute(normalizeCalls(calls)); + if (!isSnip9CompatibilityError(e)) { + throw e; + } + throw new SessionProtocolError( + "This account does not support outside execution (SNIP-9). Direct session invocation is not supported in this client.", + e, + ); } } } diff --git a/packages/controller/src/session/account.ts b/packages/controller/src/session/account.ts index 80fc1f11ea..5914734ef3 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -1,5 +1,9 @@ import type { Policy } from "./internal/types"; import { CartridgeSessionAccount } from "./internal/account"; +import { + isSnip9CompatibilityError, + SessionProtocolError, +} from "./internal/errors"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; @@ -73,12 +77,15 @@ export default class SessionAccount extends WalletAccount { */ async execute(calls: Call | Call[]): Promise { try { - const res = await this.controller.executeFromOutside( - normalizeCalls(calls), - ); - return res; + return await this.controller.executeFromOutside(normalizeCalls(calls)); } catch (e) { - return this.controller.execute(normalizeCalls(calls)); + if (!isSnip9CompatibilityError(e)) { + throw e; + } + throw new SessionProtocolError( + "This account does not support outside execution (SNIP-9). Direct session invocation is not supported in this client.", + e, + ); } } } diff --git a/packages/controller/src/session/index.ts b/packages/controller/src/session/index.ts index 588b292aca..77e425a841 100644 --- a/packages/controller/src/session/index.ts +++ b/packages/controller/src/session/index.ts @@ -4,4 +4,10 @@ export * from "../errors"; export * from "../types"; export * from "./internal/types"; export { CartridgeSessionAccount } from "./internal/account"; +export { + isSnip9CompatibilityError, + SessionProtocolError, + SessionTimeoutError, + SessionRejectedError, +} from "./internal/errors"; export { signerToGuid } from "./internal/utils"; diff --git a/packages/controller/src/session/internal/account.ts b/packages/controller/src/session/internal/account.ts index 1fd480f8b1..2047319a90 100644 --- a/packages/controller/src/session/internal/account.ts +++ b/packages/controller/src/session/internal/account.ts @@ -119,73 +119,6 @@ export class CartridgeSessionAccount { return extractTransactionHash(result.result); } - - async execute(calls: SessionCall[]): Promise { - const sessionRegistration: SessionRegistration = { - username: "", - address: this._address, - ownerGuid: this._ownerGuid, - expiresAt: String(this._session.expiresAt), - guardianKeyGuid: this._session.guardianKeyGuid ?? "0x0", - metadataHash: this._session.metadataHash ?? "0x0", - sessionKeyGuid: this._session.sessionKeyGuid, - }; - - const starknetCalls = calls.map((c) => ({ - contractAddress: c.contractAddress, - entrypoint: c.entrypoint, - calldata: c.calldata, - })); - - // For direct execution, build the same session token signature - // but submit as a regular invoke through the RPC - const { signature } = buildSignedOutsideExecutionV3({ - calls: starknetCalls, - chainId: this._chainId, - session: sessionRegistration, - sessionPrivateKey: this._privateKey, - policyRoot: this._policyRoot, - sessionKeyGuid: this._sessionKeyGuid, - policyProofIndex: this._policyProofIndex, - }); - - // Use Cartridge's addInvokeTransaction with session token - const response = await fetch(this._rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "starknet_addInvokeTransaction", - params: { - invoke_transaction: { - type: "INVOKE", - sender_address: this._address, - calldata: calls.flatMap((c) => [ - c.contractAddress, - c.entrypoint, - String(c.calldata.length), - ...c.calldata, - ]), - version: "0x3", - signature, - nonce: "0x0", - resource_bounds: { - l1_gas: { max_amount: "0x0", max_price_per_unit: "0x0" }, - l2_gas: { max_amount: "0x0", max_price_per_unit: "0x0" }, - }, - }, - }, - }), - }); - - const result = (await response.json()) as any; - if (result.error) { - throw new Error(result.error.message || JSON.stringify(result.error)); - } - - return extractTransactionHash(result.result); - } } function extractTransactionHash(result: unknown): InvokeFunctionResponse { diff --git a/packages/controller/src/session/internal/errors.ts b/packages/controller/src/session/internal/errors.ts index 90b11b91e8..607c19fb80 100644 --- a/packages/controller/src/session/internal/errors.ts +++ b/packages/controller/src/session/internal/errors.ts @@ -1,3 +1,53 @@ +const SNIP9_ERROR_CODES = new Set([ + "OUTSIDE_EXECUTION", + "OUTSIDE_EXECUTION_AUTHORIZATION_FAILED", + "OUTSIDE_EXECUTION_MANUAL_EXECUTION_REQUIRED", + "OUTSIDE_EXECUTION_NOT_SUPPORTED", + "OUTSIDE_EXECUTION_UNSUPPORTED", +]); + +const SNIP9_MESSAGE_PATTERNS = [ + /\baccount is not compatible with snip-9\b/i, + /\bmanual execution required\b/i, + /(?:^|:\s*)(?:outside execution )?authorization failed(?:[.!)]|\s|$)/i, + /(?:^|:\s*)not implemented:\s*(outside execution|execute_from_outside|snip-9)(?:[.!)]|\s|$)/i, + /\bfailed to check if nonce is valid\b/i, + /\boutside_execution_nonce\b/i, + /\bis_valid_outside_execution_nonce\b/i, + /(?:^|:\s*)requested entrypoint does not exist(?:[.!)]|\s|$)/i, + /(?:^|:\s*)entrypoint does not exist(?:[.!)]|\s|$)/i, +]; + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (error && typeof error === "object") { + const r = error as Record; + if (typeof r.message === "string") return r.message; + } + return String(error); +} + +function getErrorCode(error: unknown, depth = 0): string | null { + if (depth > 2) return null; + if (!error || typeof error !== "object") return null; + const r = error as Record; + if (typeof r.code === "string" && r.code.trim()) { + return r.code.trim().toUpperCase(); + } + return r.cause !== undefined ? getErrorCode(r.cause, depth + 1) : null; +} + +/** + * Returns true when the error indicates the account doesn't support SNIP-9 + * outside execution and a direct-invoke fallback should be attempted. + */ +export function isSnip9CompatibilityError(error: unknown): boolean { + const code = getErrorCode(error); + if (code && SNIP9_ERROR_CODES.has(code)) return true; + const message = getErrorMessage(error).trim(); + return SNIP9_MESSAGE_PATTERNS.some((p) => p.test(message)); +} + export class SessionProtocolError extends Error { constructor(message: string, cause?: unknown) { super(message); diff --git a/packages/controller/src/session/internal/execution.ts b/packages/controller/src/session/internal/execution.ts index 7d63b9b661..390cc4974b 100644 --- a/packages/controller/src/session/internal/execution.ts +++ b/packages/controller/src/session/internal/execution.ts @@ -382,6 +382,12 @@ export function buildSignedOutsideExecutionV3({ const executeAfter = toUintBigInt(timeBounds?.executeAfter ?? 0); const executeBefore = toUintBigInt(timeBounds?.executeBefore ?? now + 600n); + if (executeBefore <= executeAfter) { + throw new SessionProtocolError( + "Outside execution window is invalid: execute_before must be greater than execute_after.", + ); + } + const outsideExecution: RpcOutsideExecutionV3 = { caller: OUTSIDE_EXECUTION_CALLER_ANY, nonce: [normalizeFelt(stark.randomAddress()), ONE_FELT], diff --git a/packages/controller/src/utils.ts b/packages/controller/src/utils.ts index 908c5d5dee..36f23cb12d 100644 --- a/packages/controller/src/utils.ts +++ b/packages/controller/src/utils.ts @@ -111,14 +111,20 @@ export function toSessionPolicies(policies: Policies): SessionPolicies { * produce different merkle roots, leading to "session/not-registered" errors. * See: https://github.com/cartridge-gg/controller/issues/2357 */ +function lexCompare(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + export function toWasmPolicies(policies: ParsedSessionPolicies): Policy[] { return [ ...Object.entries(policies.contracts ?? {}) - .sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase())) + .sort(([a], [b]) => lexCompare(a.toLowerCase(), b.toLowerCase())) .flatMap(([target, { methods }]) => toArray(methods) .slice() - .sort((a, b) => a.entrypoint.localeCompare(b.entrypoint)) + .sort((a, b) => lexCompare(a.entrypoint, b.entrypoint)) .map((m): Policy => { // Check if this is an approve entrypoint with spender and amount if (m.entrypoint === "approve") { @@ -167,7 +173,7 @@ export function toWasmPolicies(policies: ParsedSessionPolicies): Policy[] { }; }) .sort((a, b) => - a.scope_hash.toString().localeCompare(b.scope_hash.toString()), + lexCompare(a.scope_hash.toString(), b.scope_hash.toString()), ), ]; } From e44e446099efa7e76e94b5da92bee1b9d11ad726 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 13 Apr 2026 10:50:22 -0700 Subject: [PATCH 11/22] test: add utils and multi-call execution coverage Add tests for normalizeFelt, selectorFromEntrypoint, and normalizeContractAddress. Add multi-call test for buildSignedOutsideExecutionV3 to exercise calldata serialization across multiple calls with different lengths. Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/execution.test.ts | 78 ++++++++++ .../controller/src/__tests__/guid.test.ts | 66 -------- .../controller/src/__tests__/utils.test.ts | 145 ++++++++++++++++++ 3 files changed, 223 insertions(+), 66 deletions(-) delete mode 100644 packages/controller/src/__tests__/guid.test.ts create mode 100644 packages/controller/src/__tests__/utils.test.ts diff --git a/packages/controller/src/__tests__/execution.test.ts b/packages/controller/src/__tests__/execution.test.ts index 5ede03e2ff..68fbcc9c94 100644 --- a/packages/controller/src/__tests__/execution.test.ts +++ b/packages/controller/src/__tests__/execution.test.ts @@ -18,6 +18,8 @@ const TEST_CHAIN_ID = "SN_SEPOLIA"; const TRANSFER_SELECTOR = normalizeFelt(hash.getSelectorFromName("transfer")); +const APPROVE_SELECTOR = normalizeFelt(hash.getSelectorFromName("approve")); + const policies: CallPolicy[] = [ { target: @@ -27,6 +29,21 @@ const policies: CallPolicy[] = [ }, ]; +const multiPolicies: CallPolicy[] = [ + { + target: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + method: TRANSFER_SELECTOR, + authorized: true, + }, + { + target: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + method: APPROVE_SELECTOR, + authorized: true, + }, +]; + function buildTestContext() { const { root } = computePolicyMerkle(policies); const proofs = computePolicyMerkleProofs(policies); @@ -247,6 +264,67 @@ describe("buildSignedOutsideExecutionV3", () => { ).toThrow("execute_before must be greater than execute_after"); }); + test("handles multiple calls with different calldata lengths", () => { + const { root } = computePolicyMerkle(multiPolicies); + const proofs = computePolicyMerkleProofs(multiPolicies); + const policyProofIndex = createPolicyProofIndex(proofs); + const publicKey = ec.starkCurve.getStarkKey( + encode.addHexPrefix(TEST_PRIVATE_KEY), + ); + const domain = shortString.encodeShortString("Starknet Signer"); + const sessionKeyGuid = normalizeFelt( + hash.computePoseidonHash(normalizeFelt(domain), publicKey), + ); + + const result = buildSignedOutsideExecutionV3({ + calls: [ + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "transfer", + calldata: ["0x1", "0x2", "0x0"], + }, + { + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + entrypoint: "approve", + calldata: ["0xaaa"], + }, + ], + chainId: TEST_CHAIN_ID, + session: { + username: "test", + address: TEST_ADDRESS, + ownerGuid: TEST_OWNER_GUID, + expiresAt: "9999999999", + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid, + }, + sessionPrivateKey: TEST_PRIVATE_KEY, + policyRoot: root, + sessionKeyGuid, + policyProofIndex, + nowSeconds: 1000000, + }); + + // Both calls serialized + expect(result.outsideExecution.calls).toHaveLength(2); + expect(result.outsideExecution.calls[0].calldata).toEqual([ + "0x1", + "0x2", + "0x0", + ]); + expect(result.outsideExecution.calls[1].calldata).toEqual(["0xaaa"]); + + // Signature is still valid + expect(result.signature.length).toBeGreaterThan(0); + const sessionTokenMagic = normalizeFelt( + shortString.encodeShortString("session-token"), + ); + expect(result.signature[0]).toBe(sessionTokenMagic); + }); + test("respects custom time bounds", () => { const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); diff --git a/packages/controller/src/__tests__/guid.test.ts b/packages/controller/src/__tests__/guid.test.ts deleted file mode 100644 index 53d6bdf131..0000000000 --- a/packages/controller/src/__tests__/guid.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ec, hash, num, shortString, encode } from "starknet"; -import { signerToGuid } from "../session/internal/utils"; - -describe("signerToGuid", () => { - test("produces a valid hex felt", () => { - const guid = signerToGuid({ - starknet: { privateKey: "0x123" }, - }); - expect(guid).toMatch(/^0x[0-9a-f]+$/); - }); - - test("is deterministic for the same key", () => { - const key = "0xdeadbeef"; - const guid1 = signerToGuid({ starknet: { privateKey: key } }); - const guid2 = signerToGuid({ starknet: { privateKey: key } }); - expect(guid1).toBe(guid2); - }); - - test("produces different GUIDs for different keys", () => { - const guid1 = signerToGuid({ starknet: { privateKey: "0x1" } }); - const guid2 = signerToGuid({ starknet: { privateKey: "0x2" } }); - expect(guid1).not.toBe(guid2); - }); - - test("matches manual Poseidon(domain, publicKey) computation", () => { - const privateKey = "0x1"; - const publicKey = ec.starkCurve.getStarkKey( - encode.addHexPrefix(privateKey), - ); - const domain = num.toHex(shortString.encodeShortString("Starknet Signer")); - const expected = num - .toHex(hash.computePoseidonHash(domain, publicKey)) - .toLowerCase(); - - const guid = signerToGuid({ starknet: { privateKey } }); - expect(guid).toBe(expected); - }); - - test("handles keys with and without hex prefix", () => { - const guid1 = signerToGuid({ starknet: { privateKey: "0x123" } }); - const guid2 = signerToGuid({ starknet: { privateKey: "123" } }); - expect(guid1).toBe(guid2); - }); - - test("throws for missing starknet signer", () => { - expect(() => signerToGuid({})).toThrow(); - }); - - test("throws for empty private key", () => { - expect(() => signerToGuid({ starknet: { privateKey: "" } })).toThrow(); - }); - - test("produces known GUID for well-known private key 0x1", () => { - // Private key 0x1 has a well-known public key on the Stark curve - const guid = signerToGuid({ starknet: { privateKey: "0x1" } }); - - // Verify it's a valid felt (non-zero, hex) - expect(guid).toMatch(/^0x[0-9a-f]+$/); - expect(BigInt(guid)).toBeGreaterThan(0n); - - // Snapshot the value for regression detection - expect(guid).toMatchInlineSnapshot( - `"0x78e6eccfb97cea1b4ca2e0735d0db7cd9e33a316378391e58e7f3ed107062c2"`, - ); - }); -}); diff --git a/packages/controller/src/__tests__/utils.test.ts b/packages/controller/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..589075260a --- /dev/null +++ b/packages/controller/src/__tests__/utils.test.ts @@ -0,0 +1,145 @@ +import { ec, hash, num, shortString, encode } from "starknet"; +import { + normalizeFelt, + selectorFromEntrypoint, + normalizeContractAddress, + signerToGuid, +} from "../session/internal/utils"; + +describe("normalizeFelt", () => { + test("converts number to lowercase hex", () => { + expect(normalizeFelt(255)).toBe("0xff"); + }); + + test("converts bigint to lowercase hex", () => { + expect(normalizeFelt(0xdeadn)).toBe("0xdead"); + }); + + test("normalizes hex string to lowercase", () => { + expect(normalizeFelt("0xDEAD")).toBe("0xdead"); + }); + + test("strips leading zeros from hex string", () => { + expect(normalizeFelt("0x00ff")).toBe("0xff"); + }); + + test("handles zero", () => { + expect(normalizeFelt(0)).toBe("0x0"); + }); +}); + +describe("selectorFromEntrypoint", () => { + test("computes selector from entrypoint name", () => { + const selector = selectorFromEntrypoint("transfer"); + const expected = normalizeFelt(hash.getSelectorFromName("transfer")); + expect(selector).toBe(expected); + }); + + test("passes through hex selector unchanged", () => { + const hexSelector = + "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e"; + expect(selectorFromEntrypoint(hexSelector)).toBe( + normalizeFelt(hexSelector), + ); + }); + + test("different entrypoints produce different selectors", () => { + expect(selectorFromEntrypoint("transfer")).not.toBe( + selectorFromEntrypoint("approve"), + ); + }); +}); + +describe("normalizeContractAddress", () => { + test("pads short address to full length", () => { + const result = normalizeContractAddress("0x1234", "test"); + expect(result).toMatch(/^0x0+1234$/); + expect(result.length).toBe(66); // 0x + 64 hex chars + }); + + test("lowercases address", () => { + const result = normalizeContractAddress("0xABCD", "test"); + expect(result).toBe(normalizeContractAddress("0xabcd", "test")); + }); + + test("trims whitespace", () => { + const result = normalizeContractAddress(" 0x1234 ", "test"); + expect(result).toBe(normalizeContractAddress("0x1234", "test")); + }); + + test("throws for empty address", () => { + expect(() => normalizeContractAddress("", "target")).toThrow( + "target is missing a contract address", + ); + }); + + test("throws for whitespace-only address", () => { + expect(() => normalizeContractAddress(" ", "target")).toThrow( + "target is missing a contract address", + ); + }); +}); + +describe("signerToGuid", () => { + test("produces a valid hex felt", () => { + const guid = signerToGuid({ + starknet: { privateKey: "0x123" }, + }); + expect(guid).toMatch(/^0x[0-9a-f]+$/); + }); + + test("is deterministic for the same key", () => { + const key = "0xdeadbeef"; + const guid1 = signerToGuid({ starknet: { privateKey: key } }); + const guid2 = signerToGuid({ starknet: { privateKey: key } }); + expect(guid1).toBe(guid2); + }); + + test("produces different GUIDs for different keys", () => { + const guid1 = signerToGuid({ starknet: { privateKey: "0x1" } }); + const guid2 = signerToGuid({ starknet: { privateKey: "0x2" } }); + expect(guid1).not.toBe(guid2); + }); + + test("matches manual Poseidon(domain, publicKey) computation", () => { + const privateKey = "0x1"; + const publicKey = ec.starkCurve.getStarkKey( + encode.addHexPrefix(privateKey), + ); + const domain = num.toHex(shortString.encodeShortString("Starknet Signer")); + const expected = num + .toHex(hash.computePoseidonHash(domain, publicKey)) + .toLowerCase(); + + const guid = signerToGuid({ starknet: { privateKey } }); + expect(guid).toBe(expected); + }); + + test("handles keys with and without hex prefix", () => { + const guid1 = signerToGuid({ starknet: { privateKey: "0x123" } }); + const guid2 = signerToGuid({ starknet: { privateKey: "123" } }); + expect(guid1).toBe(guid2); + }); + + test("throws for missing starknet signer", () => { + expect(() => signerToGuid({})).toThrow(); + }); + + test("throws for empty private key", () => { + expect(() => signerToGuid({ starknet: { privateKey: "" } })).toThrow(); + }); + + test("produces known GUID for well-known private key 0x1", () => { + // Private key 0x1 has a well-known public key on the Stark curve + const guid = signerToGuid({ starknet: { privateKey: "0x1" } }); + + // Verify it's a valid felt (non-zero, hex) + expect(guid).toMatch(/^0x[0-9a-f]+$/); + expect(BigInt(guid)).toBeGreaterThan(0n); + + // Snapshot the value for regression detection + expect(guid).toMatchInlineSnapshot( + `"0x78e6eccfb97cea1b4ca2e0735d0db7cd9e33a316378391e58e7f3ed107062c2"`, + ); + }); +}); From d6f3ba0a5e06b741ddb30b3843d6046052006d78 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 13 Apr 2026 11:32:43 -0700 Subject: [PATCH 12/22] refactor: use glob exports for session/internal modules Co-Authored-By: Claude Opus 4.6 --- packages/controller/src/session/index.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/controller/src/session/index.ts b/packages/controller/src/session/index.ts index 77e425a841..294d818115 100644 --- a/packages/controller/src/session/index.ts +++ b/packages/controller/src/session/index.ts @@ -3,11 +3,6 @@ export * from "./provider"; export * from "../errors"; export * from "../types"; export * from "./internal/types"; -export { CartridgeSessionAccount } from "./internal/account"; -export { - isSnip9CompatibilityError, - SessionProtocolError, - SessionTimeoutError, - SessionRejectedError, -} from "./internal/errors"; -export { signerToGuid } from "./internal/utils"; +export * from "./internal/account"; +export * from "./internal/errors"; +export * from "./internal/utils"; From 7b69b2601470886a99b866f358f6caca128e7df9 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 13 Apr 2026 11:34:38 -0700 Subject: [PATCH 13/22] refactor: import session types through public API in utils.ts Co-Authored-By: Claude Opus 4.6 --- packages/controller/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/controller/src/utils.ts b/packages/controller/src/utils.ts index 36f23cb12d..9bb3f9e54c 100644 --- a/packages/controller/src/utils.ts +++ b/packages/controller/src/utils.ts @@ -1,4 +1,4 @@ -import type { Policy, ApprovalPolicy } from "./session/internal/types"; +import type { Policy, ApprovalPolicy } from "./session"; import { Policies, SessionPolicies } from "@cartridge/presets"; import { ChainId } from "@starknet-io/types-js"; import { From b402e829c3e746700a756f3cf03ba46c6855a353 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 13 Apr 2026 13:41:07 -0700 Subject: [PATCH 14/22] fix: correct merkle leaf hashing for TypedDataPolicy and ApprovalPolicy TypedDataPolicy was using the CallPolicy type hash ("Allowed Method") instead of its own ("Allowed Type"). ApprovalPolicy was hashing all three fields (target, spender, amount) instead of converting to CallPolicy(target, approve_selector) as the WASM boundary does. Also fixes the proof index selector for ApprovalPolicy. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/src/__tests__/merkle.test.ts | 98 ++++++++++++++++++- .../controller/src/session/internal/merkle.ts | 27 ++++- 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/packages/controller/src/__tests__/merkle.test.ts b/packages/controller/src/__tests__/merkle.test.ts index e33f819ba0..6d09f5ec7f 100644 --- a/packages/controller/src/__tests__/merkle.test.ts +++ b/packages/controller/src/__tests__/merkle.test.ts @@ -5,6 +5,7 @@ import { computePolicyMerkle, computePolicyMerkleProofs, } from "../session/internal/merkle"; +import { normalizeFelt } from "../session/internal/utils"; import type { CallPolicy, TypedDataPolicy, @@ -74,6 +75,61 @@ describe("hashPolicyLeaf", () => { const p2: CallPolicy = { target: ADDR_A, method: SELECTOR_APPROVE }; expect(hashPolicyLeaf(p1)).not.toBe(hashPolicyLeaf(p2)); }); + + test("TypedDataPolicy uses a different type hash than CallPolicy", () => { + // A TypedDataPolicy leaf must NOT equal a CallPolicy leaf that happens to + // share the same numeric value, because they use distinct SNIP-12 type + // hashes ("Allowed Type" vs "Allowed Method"). + const scopeHash = "0xabc"; + const typedData: TypedDataPolicy = { scope_hash: scopeHash }; + const call: CallPolicy = { target: scopeHash, method: scopeHash }; + expect(hashPolicyLeaf(typedData)).not.toBe(hashPolicyLeaf(call)); + }); + + test("TypedDataPolicy leaf matches manual Poseidon(AllowedType hash, scope_hash)", () => { + const scopeHash = "0xdeadbeef"; + const policy: TypedDataPolicy = { scope_hash: scopeHash }; + const expectedTypeHash = normalizeFelt( + hash.getSelectorFromName('"Allowed Type"("Scope Hash":"felt")'), + ); + const expected = normalizeFelt( + hash.computePoseidonHashOnElements([ + expectedTypeHash, + normalizeFelt(scopeHash), + ]), + ); + expect(hashPolicyLeaf(policy)).toBe(expected); + }); + + test("ApprovalPolicy hashes identically to CallPolicy(target, approve)", () => { + // The WASM converts ApprovalPolicy → CallPolicy(target, approve_selector) + // before merkle hashing. Verify the TS implementation matches. + const approval: ApprovalPolicy = { + target: ADDR_A, + spender: ADDR_B, + amount: "1000", + }; + const equivalentCall: CallPolicy = { + target: ADDR_A, + method: SELECTOR_APPROVE, + }; + expect(hashPolicyLeaf(approval)).toBe(hashPolicyLeaf(equivalentCall)); + }); + + test("ApprovalPolicy leaf is independent of spender and amount", () => { + const a1: ApprovalPolicy = { + target: ADDR_A, + spender: ADDR_B, + amount: "1000", + }; + const a2: ApprovalPolicy = { + target: ADDR_A, + spender: "0xdead", + amount: "9999", + }; + // Same target → same leaf, because both reduce to CallPolicy(target, approve) + expect(hashPolicyLeaf(a1)).toBe(hashPolicyLeaf(a2)); + }); }); describe("computePolicyMerkle", () => { @@ -87,8 +143,8 @@ describe("computePolicyMerkle", () => { const policy: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; const result = computePolicyMerkle([policy]); expect(result.leaves).toHaveLength(1); - // Single leaf gets paired with ZERO_FELT - expect(result.root).toMatch(/^0x[0-9a-f]+$/); + // Single leaf IS the root (no pairing needed) + expect(result.root).toBe(result.leaves[0]); }); test("two policies: root is hash of the two leaves", () => { @@ -210,4 +266,42 @@ describe("computePolicyMerkleProofs", () => { expect(current).toBe(root); } }); + + test("ApprovalPolicy proof uses the approve selector, not zero", () => { + const approval: ApprovalPolicy = { + target: ADDR_A, + spender: ADDR_B, + amount: "1000", + }; + const proofs = computePolicyMerkleProofs([approval]); + expect(proofs[0].selector).toBe( + normalizeFelt(hash.getSelectorFromName("approve")), + ); + }); + + test("TypedDataPolicy proof uses zero selector", () => { + const td: TypedDataPolicy = { scope_hash: "0xabc", authorized: true }; + const proofs = computePolicyMerkleProofs([td]); + expect(proofs[0].selector).toBe("0x0"); + expect(proofs[0].contractAddress).toBe("0x0"); + }); + + test("mixed-type tree proofs all verify against the same root", () => { + const policies: Policy[] = [ + { target: ADDR_A, method: SELECTOR_TRANSFER, authorized: true }, + { scope_hash: "0xabc", authorized: true }, + { target: ADDR_B, spender: ADDR_A, amount: "1000" }, + ]; + + const { root } = computePolicyMerkle(policies); + const proofs = computePolicyMerkleProofs(policies); + + for (const proof of proofs) { + let current = proof.leaf; + for (const sibling of proof.proof) { + current = hashPair(current, sibling); + } + expect(current).toBe(root); + } + }); }); diff --git a/packages/controller/src/session/internal/merkle.ts b/packages/controller/src/session/internal/merkle.ts index 66cf48f557..3c93f4e859 100644 --- a/packages/controller/src/session/internal/merkle.ts +++ b/packages/controller/src/session/internal/merkle.ts @@ -18,6 +18,20 @@ const POLICY_CALL_TYPE_HASH = normalizeFelt( ), ); +/** + * SNIP-12 type hash for "Allowed Type"("Scope Hash":"felt") + * Used for TypedDataPolicy leaves — distinct from the CallPolicy type hash. + */ +const POLICY_TYPED_DATA_TYPE_HASH = normalizeFelt( + hash.getSelectorFromName('"Allowed Type"("Scope Hash":"felt")'), +); + +/** + * Selector for the "approve" entrypoint, used when converting ApprovalPolicy + * to the equivalent CallPolicy leaf (matching the WASM boundary conversion). + */ +const APPROVE_SELECTOR = normalizeFelt(hash.getSelectorFromName("approve")); + export interface PolicyMerkleResult { leaves: string[]; root: string; @@ -67,13 +81,14 @@ export function hashPolicyLeaf(policy: Policy): string { } if (isApprovalPolicy(policy)) { - // Approval policies use: Poseidon(typeHash, target, spender, amount) + // WASM converts ApprovalPolicy → CallPolicy(target, approve_selector) + // before merkle hashing. Match that: hash as a CallPolicy with the + // "approve" selector, discarding spender/amount. return normalizeFelt( hash.computePoseidonHashOnElements([ POLICY_CALL_TYPE_HASH, normalizeFelt(policy.target), - normalizeFelt(policy.spender), - normalizeFelt(policy.amount), + APPROVE_SELECTOR, ]), ); } @@ -81,7 +96,7 @@ export function hashPolicyLeaf(policy: Policy): string { if (isTypedDataPolicy(policy)) { return normalizeFelt( hash.computePoseidonHashOnElements([ - POLICY_CALL_TYPE_HASH, + POLICY_TYPED_DATA_TYPE_HASH, normalizeFelt(policy.scope_hash), ]), ); @@ -172,7 +187,9 @@ export function computePolicyMerkleProofs( : ZERO_FELT; const selector = isCallPolicy(policy) ? normalizeFelt(policy.method) - : ZERO_FELT; + : isApprovalPolicy(policy) + ? APPROVE_SELECTOR + : ZERO_FELT; return { contractAddress: target, From e06e90e21719ec3602ed56147a97e921ce2b4697 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 13 Apr 2026 14:22:55 -0700 Subject: [PATCH 15/22] docs: add context for execute() fallback removal in session accounts Explains that the fallback is blocked by Cartridge's guardian co-signing policy, not by a protocol limitation. Co-Authored-By: Claude Sonnet 4.6 --- packages/controller/src/node/account.ts | 10 ++++++++++ packages/controller/src/session/account.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index 6a2d0377c9..baa3f29586 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -82,6 +82,16 @@ export default class SessionAccount extends WalletAccount { if (!isSnip9CompatibilityError(e)) { throw e; } + // Direct execute() is not offered as a fallback because Cartridge + // registers sessions with a guardian co-signer. The client signs + // with a placeholder guardian key; Cartridge's relayer replaces it + // with the real signature before submitting. Without the relayer in + // the loop (i.e. direct execute), the placeholder reaches the chain + // and fails validation. + // + // The session token format supports guardian-less sessions + // (guardian_key_guid = 0x0), so this is a policy constraint of + // Cartridge's current registration flow, not a protocol limitation. throw new SessionProtocolError( "This account does not support outside execution (SNIP-9). Direct session invocation is not supported in this client.", e, diff --git a/packages/controller/src/session/account.ts b/packages/controller/src/session/account.ts index 5914734ef3..1852620e32 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -82,6 +82,16 @@ export default class SessionAccount extends WalletAccount { if (!isSnip9CompatibilityError(e)) { throw e; } + // Direct execute() is not offered as a fallback because Cartridge + // registers sessions with a guardian co-signer. The client signs + // with a placeholder guardian key; Cartridge's relayer replaces it + // with the real signature before submitting. Without the relayer in + // the loop (i.e. direct execute), the placeholder reaches the chain + // and fails validation. + // + // The session token format supports guardian-less sessions + // (guardian_key_guid = 0x0), so this is a policy constraint of + // Cartridge's current registration flow, not a protocol limitation. throw new SessionProtocolError( "This account does not support outside execution (SNIP-9). Direct session invocation is not supported in this client.", e, From 369cced0fb0145b2107371955a27db0167d7008e Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 13 Apr 2026 14:24:37 -0700 Subject: [PATCH 16/22] refactor: flip SNIP-9 error check to positive condition Co-Authored-By: Claude Sonnet 4.6 --- packages/controller/src/node/account.ts | 32 +++++++++++----------- packages/controller/src/session/account.ts | 32 +++++++++++----------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index baa3f29586..efc394d2b2 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -79,23 +79,23 @@ export default class SessionAccount extends WalletAccount { try { return await this.controller.executeFromOutside(normalizeCalls(calls)); } catch (e) { - if (!isSnip9CompatibilityError(e)) { - throw e; + if (isSnip9CompatibilityError(e)) { + // Direct execute() is not offered as a fallback because Cartridge + // registers sessions with a guardian co-signer. The client signs + // with a placeholder guardian key; Cartridge's relayer replaces it + // with the real signature before submitting. Without the relayer in + // the loop (i.e. direct execute), the placeholder reaches the chain + // and fails validation. + // + // The session token format supports guardian-less sessions + // (guardian_key_guid = 0x0), so this is a policy constraint of + // Cartridge's current registration flow, not a protocol limitation. + throw new SessionProtocolError( + "This account does not support outside execution (SNIP-9). Direct session invocation is not supported in this client.", + e, + ); } - // Direct execute() is not offered as a fallback because Cartridge - // registers sessions with a guardian co-signer. The client signs - // with a placeholder guardian key; Cartridge's relayer replaces it - // with the real signature before submitting. Without the relayer in - // the loop (i.e. direct execute), the placeholder reaches the chain - // and fails validation. - // - // The session token format supports guardian-less sessions - // (guardian_key_guid = 0x0), so this is a policy constraint of - // Cartridge's current registration flow, not a protocol limitation. - throw new SessionProtocolError( - "This account does not support outside execution (SNIP-9). Direct session invocation is not supported in this client.", - e, - ); + throw e; } } } diff --git a/packages/controller/src/session/account.ts b/packages/controller/src/session/account.ts index 1852620e32..53e6ddc146 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -79,23 +79,23 @@ export default class SessionAccount extends WalletAccount { try { return await this.controller.executeFromOutside(normalizeCalls(calls)); } catch (e) { - if (!isSnip9CompatibilityError(e)) { - throw e; + if (isSnip9CompatibilityError(e)) { + // Direct execute() is not offered as a fallback because Cartridge + // registers sessions with a guardian co-signer. The client signs + // with a placeholder guardian key; Cartridge's relayer replaces it + // with the real signature before submitting. Without the relayer in + // the loop (i.e. direct execute), the placeholder reaches the chain + // and fails validation. + // + // The session token format supports guardian-less sessions + // (guardian_key_guid = 0x0), so this is a policy constraint of + // Cartridge's current registration flow, not a protocol limitation. + throw new SessionProtocolError( + "This account does not support outside execution (SNIP-9). Direct session invocation is not supported in this client.", + e, + ); } - // Direct execute() is not offered as a fallback because Cartridge - // registers sessions with a guardian co-signer. The client signs - // with a placeholder guardian key; Cartridge's relayer replaces it - // with the real signature before submitting. Without the relayer in - // the loop (i.e. direct execute), the placeholder reaches the chain - // and fails validation. - // - // The session token format supports guardian-less sessions - // (guardian_key_guid = 0x0), so this is a policy constraint of - // Cartridge's current registration flow, not a protocol limitation. - throw new SessionProtocolError( - "This account does not support outside execution (SNIP-9). Direct session invocation is not supported in this client.", - e, - ); + throw e; } } } From 3f960aa568693f538047e610d16b8c5768f9ac41 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 13 Apr 2026 16:31:56 -0700 Subject: [PATCH 17/22] fix: guard unauthorized policies, harden subscribe, fix test naming - hashPolicyLeaf returns 0x0 for unauthorized policies (matching WASM) - subscribeCreateSession throws immediately on GraphQL errors instead of retrying, adds per-request AbortController timeout - Rename misleading execute() test to clarify it tests the internal CartridgeSessionAccount class Co-Authored-By: Claude Sonnet 4.6 --- .../controller/src/__tests__/account.test.ts | 3 +- .../controller/src/__tests__/merkle.test.ts | 31 +++++++ .../src/__tests__/subscribe.test.ts | 56 ++++++++++-- .../controller/src/session/internal/merkle.ts | 5 + .../src/session/internal/subscribe.ts | 91 ++++++++++++------- 5 files changed, 145 insertions(+), 41 deletions(-) diff --git a/packages/controller/src/__tests__/account.test.ts b/packages/controller/src/__tests__/account.test.ts index 24d921b09e..2f8dcb65ef 100644 --- a/packages/controller/src/__tests__/account.test.ts +++ b/packages/controller/src/__tests__/account.test.ts @@ -121,7 +121,7 @@ describe("CartridgeSessionAccount", () => { ).rejects.toThrow("execution failed"); }); - test("execute() is not available on CartridgeSessionAccount", () => { + test("internal CartridgeSessionAccount only exposes executeFromOutside, not execute", () => { const account = CartridgeSessionAccount.newAsRegistered( TEST_RPC, TEST_PRIVATE_KEY, @@ -131,6 +131,7 @@ describe("CartridgeSessionAccount", () => { TEST_SESSION, ); expect((account as any).execute).toBeUndefined(); + expect(account.executeFromOutside).toBeDefined(); }); test("executeFromOutside propagates non-SNIP-9 errors without swallowing", async () => { diff --git a/packages/controller/src/__tests__/merkle.test.ts b/packages/controller/src/__tests__/merkle.test.ts index 6d09f5ec7f..1b38dcea20 100644 --- a/packages/controller/src/__tests__/merkle.test.ts +++ b/packages/controller/src/__tests__/merkle.test.ts @@ -130,6 +130,37 @@ describe("hashPolicyLeaf", () => { // Same target → same leaf, because both reduce to CallPolicy(target, approve) expect(hashPolicyLeaf(a1)).toBe(hashPolicyLeaf(a2)); }); + + test("unauthorized CallPolicy returns zero felt", () => { + const policy: CallPolicy = { + target: ADDR_A, + method: SELECTOR_TRANSFER, + authorized: false, + }; + expect(hashPolicyLeaf(policy)).toBe("0x0"); + }); + + test("unauthorized TypedDataPolicy returns zero felt", () => { + const policy: TypedDataPolicy = { + scope_hash: "0xabc", + authorized: false, + }; + expect(hashPolicyLeaf(policy)).toBe("0x0"); + }); + + test("authorized: undefined is treated as authorized (hashes normally)", () => { + const withUndefined: CallPolicy = { + target: ADDR_A, + method: SELECTOR_TRANSFER, + }; + const withTrue: CallPolicy = { + target: ADDR_A, + method: SELECTOR_TRANSFER, + authorized: true, + }; + expect(hashPolicyLeaf(withUndefined)).toBe(hashPolicyLeaf(withTrue)); + expect(hashPolicyLeaf(withUndefined)).not.toBe("0x0"); + }); }); describe("computePolicyMerkle", () => { diff --git a/packages/controller/src/__tests__/subscribe.test.ts b/packages/controller/src/__tests__/subscribe.test.ts index 8a04f70599..1be37a5ef7 100644 --- a/packages/controller/src/__tests__/subscribe.test.ts +++ b/packages/controller/src/__tests__/subscribe.test.ts @@ -1,5 +1,8 @@ import { subscribeCreateSession } from "../session/internal/subscribe"; -import { SessionTimeoutError } from "../session/internal/errors"; +import { + SessionProtocolError, + SessionTimeoutError, +} from "../session/internal/errors"; // Mock global fetch const mockFetch = jest.fn(); @@ -78,14 +81,36 @@ describe("subscribeCreateSession", () => { expect(result).toEqual(MOCK_SESSION); }); - test("retries on GraphQL errors", async () => { + test("throws immediately on GraphQL errors (permanent failure)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errors: [{ message: "validation failed: unknown field" }], + }), + }); + + await expect( + subscribeCreateSession("0xguid", "https://api.test"), + ).rejects.toThrow(SessionProtocolError); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test("GraphQL error message is included in thrown error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errors: [{ message: "field X not found" }], + }), + }); + + await expect( + subscribeCreateSession("0xguid", "https://api.test"), + ).rejects.toThrow("field X not found"); + }); + + test("retries on network errors", async () => { mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - errors: [{ message: "temporary error" }], - }), - }) + .mockRejectedValueOnce(new Error("network failure")) .mockResolvedValueOnce({ ok: true, json: async () => ({ @@ -98,6 +123,7 @@ describe("subscribeCreateSession", () => { const result = await promise; expect(result).toEqual(MOCK_SESSION); + expect(mockFetch).toHaveBeenCalledTimes(2); }); test("times out after configured duration", async () => { @@ -114,6 +140,20 @@ describe("subscribeCreateSession", () => { ).rejects.toThrow(SessionTimeoutError); }, 15000); + test("passes AbortController signal to fetch", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { subscribeCreateSession: MOCK_SESSION }, + }), + }); + + await subscribeCreateSession("0xguid", "https://api.test"); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.signal).toBeDefined(); + }); + test("sends correct GraphQL query", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/packages/controller/src/session/internal/merkle.ts b/packages/controller/src/session/internal/merkle.ts index 3c93f4e859..c2fcef859f 100644 --- a/packages/controller/src/session/internal/merkle.ts +++ b/packages/controller/src/session/internal/merkle.ts @@ -68,8 +68,13 @@ export function hashPair(a: string, b: string): string { /** * Hash a single policy into a merkle leaf. + * Returns ZERO_FELT for unauthorized policies, matching the WASM MerkleLeaf impl. */ export function hashPolicyLeaf(policy: Policy): string { + if ("authorized" in policy && policy.authorized === false) { + return ZERO_FELT; + } + if (isCallPolicy(policy)) { return normalizeFelt( hash.computePoseidonHashOnElements([ diff --git a/packages/controller/src/session/internal/subscribe.ts b/packages/controller/src/session/internal/subscribe.ts index 0d19ca841d..96aaaecc76 100644 --- a/packages/controller/src/session/internal/subscribe.ts +++ b/packages/controller/src/session/internal/subscribe.ts @@ -1,4 +1,4 @@ -import { SessionTimeoutError } from "./errors"; +import { SessionProtocolError, SessionTimeoutError } from "./errors"; export interface SubscribeSessionResult { authorization: string[]; @@ -30,6 +30,7 @@ export async function subscribeCreateSession( sessionKeyGuid: string, cartridgeApiUrl: string, timeoutMs: number = 180_000, + requestTimeoutMs: number = 15_000, ): Promise { const endpoint = `${cartridgeApiUrl.replace(/\/+$/, "")}/query`; const deadline = Date.now() + timeoutMs; @@ -38,47 +39,73 @@ export async function subscribeCreateSession( const MAX_DELAY = 5_000; while (Date.now() < deadline) { - const response = await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: SUBSCRIBE_QUERY, - variables: { sessionKeyGuid }, - }), - }); + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; - if (!response.ok) { - // Retry on server errors - await sleep(delay); - delay = Math.min(delay * 2, MAX_DELAY); - continue; - } + const effectiveTimeout = Math.min(requestTimeoutMs, remainingMs); + const controller = + typeof AbortController !== "undefined" + ? new AbortController() + : undefined; + const timeoutId = controller + ? setTimeout(() => controller.abort(), effectiveTimeout) + : undefined; - const json = (await response.json()) as { - data?: { subscribeCreateSession?: SubscribeSessionResult | null }; - errors?: { message: string }[]; - }; + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: SUBSCRIBE_QUERY, + variables: { sessionKeyGuid }, + }), + ...(controller ? { signal: controller.signal } : {}), + }); - if (json.errors?.length) { - // Retry on GraphQL errors - await sleep(delay); - delay = Math.min(delay * 2, MAX_DELAY); - continue; - } + if (!response.ok) { + // Retry on server errors (5xx, etc.) + await sleep(Math.min(delay, deadline - Date.now())); + delay = Math.min(delay * 2, MAX_DELAY); + continue; + } - const session = json.data?.subscribeCreateSession; - if (session) { - return session; - } + const json = (await response.json()) as { + data?: { subscribeCreateSession?: SubscribeSessionResult | null }; + errors?: { message: string }[]; + }; - // Session not ready yet — back off and retry - await sleep(delay); - delay = Math.min(delay * 2, MAX_DELAY); + if (json.errors?.length) { + // GraphQL errors are permanent (malformed query, validation failure). + // Don't waste time retrying. + const messages = json.errors.map((e) => e.message).join("; "); + throw new SessionProtocolError( + `Session subscription failed: ${messages}`, + ); + } + + const session = json.data?.subscribeCreateSession; + if (session) { + return session; + } + + // Session not ready yet — back off and retry + await sleep(Math.min(delay, deadline - Date.now())); + delay = Math.min(delay * 2, MAX_DELAY); + } catch (e) { + if (e instanceof SessionProtocolError || e instanceof SessionTimeoutError) + throw e; + // Network errors, aborts — retry + await sleep(Math.min(delay, deadline - Date.now())); + delay = Math.min(delay * 2, MAX_DELAY); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } } throw new SessionTimeoutError("Timed out waiting for session creation"); } function sleep(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); return new Promise((resolve) => setTimeout(resolve, ms)); } From 0091463505dceee722a3035387cea985a0e4b645 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 13 Apr 2026 16:39:13 -0700 Subject: [PATCH 18/22] test: trim redundant tests without losing coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 23 tests that were subsumed by stronger tests, tested starknet.js rather than our code, or exercised the same branch at different scale. 134 → 111 total. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/src/__tests__/account.test.ts | 54 -------- .../src/__tests__/execution.test.ts | 42 ------ .../controller/src/__tests__/merkle.test.ts | 124 +----------------- .../src/__tests__/subscribe.test.ts | 24 ---- .../controller/src/__tests__/utils.test.ts | 48 ------- 5 files changed, 1 insertion(+), 291 deletions(-) diff --git a/packages/controller/src/__tests__/account.test.ts b/packages/controller/src/__tests__/account.test.ts index 2f8dcb65ef..5caa8cde9f 100644 --- a/packages/controller/src/__tests__/account.test.ts +++ b/packages/controller/src/__tests__/account.test.ts @@ -37,18 +37,6 @@ const TEST_SESSION: Session = { }; describe("CartridgeSessionAccount", () => { - test("newAsRegistered creates an instance", () => { - const account = CartridgeSessionAccount.newAsRegistered( - TEST_RPC, - TEST_PRIVATE_KEY, - TEST_ADDRESS, - TEST_OWNER_GUID, - TEST_CHAIN_ID, - TEST_SESSION, - ); - expect(account).toBeInstanceOf(CartridgeSessionAccount); - }); - test("executeFromOutside sends correct RPC request", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -176,23 +164,12 @@ describe("CartridgeSessionAccount", () => { expect( isSnip9CompatibilityError(new Error("entrypoint does not exist")), ).toBe(true); - expect( - isSnip9CompatibilityError( - new Error("not implemented: outside execution"), - ), - ).toBe(true); }); test("returns false for generic errors", () => { expect(isSnip9CompatibilityError(new Error("network timeout"))).toBe( false, ); - expect( - isSnip9CompatibilityError(new Error("policy not authorized")), - ).toBe(false); - expect(isSnip9CompatibilityError(new Error("execution failed"))).toBe( - false, - ); }); test("returns false for null/undefined", () => { @@ -208,35 +185,4 @@ describe("CartridgeSessionAccount", () => { expect(isSnip9CompatibilityError(err)).toBe(true); }); }); - - test("executeFromOutside propagates policy-mismatch errors", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - jsonrpc: "2.0", - id: 1, - error: { code: -32000, message: "policy not authorized" }, - }), - }); - - const account = CartridgeSessionAccount.newAsRegistered( - TEST_RPC, - TEST_PRIVATE_KEY, - TEST_ADDRESS, - TEST_OWNER_GUID, - TEST_CHAIN_ID, - TEST_SESSION, - ); - - await expect( - account.executeFromOutside([ - { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - entrypoint: "transfer", - calldata: [], - }, - ]), - ).rejects.toThrow("policy not authorized"); - }); }); diff --git a/packages/controller/src/__tests__/execution.test.ts b/packages/controller/src/__tests__/execution.test.ts index 68fbcc9c94..3a34363271 100644 --- a/packages/controller/src/__tests__/execution.test.ts +++ b/packages/controller/src/__tests__/execution.test.ts @@ -229,41 +229,6 @@ describe("buildSignedOutsideExecutionV3", () => { ).toThrow("execute_before must be greater than execute_after"); }); - test("throws when execute_before equals execute_after", () => { - const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); - - expect(() => - buildSignedOutsideExecutionV3({ - calls: [ - { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - entrypoint: "transfer", - calldata: [], - }, - ], - timeBounds: { - executeAfter: 1000, - executeBefore: 1000, - }, - chainId: TEST_CHAIN_ID, - session: { - username: "test", - address: TEST_ADDRESS, - ownerGuid: TEST_OWNER_GUID, - expiresAt: "9999999999", - guardianKeyGuid: "0x0", - metadataHash: "0x0", - sessionKeyGuid, - }, - sessionPrivateKey: TEST_PRIVATE_KEY, - policyRoot: root, - sessionKeyGuid, - policyProofIndex, - }), - ).toThrow("execute_before must be greater than execute_after"); - }); - test("handles multiple calls with different calldata lengths", () => { const { root } = computePolicyMerkle(multiPolicies); const proofs = computePolicyMerkleProofs(multiPolicies); @@ -368,11 +333,4 @@ describe("createPolicyProofIndex", () => { const index = createPolicyProofIndex(proofs); expect(index.size).toBe(1); }); - - test("deduplicates identical keys", () => { - const proofs = computePolicyMerkleProofs(policies); - // Double the proofs - const index = createPolicyProofIndex([...proofs, ...proofs]); - expect(index.size).toBe(1); - }); }); diff --git a/packages/controller/src/__tests__/merkle.test.ts b/packages/controller/src/__tests__/merkle.test.ts index 1b38dcea20..dc16870523 100644 --- a/packages/controller/src/__tests__/merkle.test.ts +++ b/packages/controller/src/__tests__/merkle.test.ts @@ -26,50 +26,9 @@ describe("hashPair", () => { const b = "0x2"; expect(hashPair(a, b)).toBe(hashPair(b, a)); }); - - test("produces valid hex output", () => { - const result = hashPair("0x1", "0x2"); - expect(result).toMatch(/^0x[0-9a-f]+$/); - }); - - test("always hashes (smaller, larger)", () => { - // Regardless of input order, the hash should be deterministic - const r1 = hashPair("0x100", "0x1"); - const r2 = hashPair("0x1", "0x100"); - expect(r1).toBe(r2); - }); }); describe("hashPolicyLeaf", () => { - test("hashes a CallPolicy", () => { - const policy: CallPolicy = { - target: ADDR_A, - method: SELECTOR_TRANSFER, - authorized: true, - }; - const leaf = hashPolicyLeaf(policy); - expect(leaf).toMatch(/^0x[0-9a-f]+$/); - }); - - test("hashes a TypedDataPolicy", () => { - const policy: TypedDataPolicy = { - scope_hash: "0xabc123", - authorized: true, - }; - const leaf = hashPolicyLeaf(policy); - expect(leaf).toMatch(/^0x[0-9a-f]+$/); - }); - - test("hashes an ApprovalPolicy", () => { - const policy: ApprovalPolicy = { - target: ADDR_A, - spender: ADDR_B, - amount: "1000", - }; - const leaf = hashPolicyLeaf(policy); - expect(leaf).toMatch(/^0x[0-9a-f]+$/); - }); - test("different policies produce different leaves", () => { const p1: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; const p2: CallPolicy = { target: ADDR_A, method: SELECTOR_APPROVE }; @@ -77,9 +36,6 @@ describe("hashPolicyLeaf", () => { }); test("TypedDataPolicy uses a different type hash than CallPolicy", () => { - // A TypedDataPolicy leaf must NOT equal a CallPolicy leaf that happens to - // share the same numeric value, because they use distinct SNIP-12 type - // hashes ("Allowed Type" vs "Allowed Method"). const scopeHash = "0xabc"; const typedData: TypedDataPolicy = { scope_hash: scopeHash }; const call: CallPolicy = { target: scopeHash, method: scopeHash }; @@ -102,8 +58,6 @@ describe("hashPolicyLeaf", () => { }); test("ApprovalPolicy hashes identically to CallPolicy(target, approve)", () => { - // The WASM converts ApprovalPolicy → CallPolicy(target, approve_selector) - // before merkle hashing. Verify the TS implementation matches. const approval: ApprovalPolicy = { target: ADDR_A, spender: ADDR_B, @@ -127,7 +81,6 @@ describe("hashPolicyLeaf", () => { spender: "0xdead", amount: "9999", }; - // Same target → same leaf, because both reduce to CallPolicy(target, approve) expect(hashPolicyLeaf(a1)).toBe(hashPolicyLeaf(a2)); }); @@ -147,20 +100,6 @@ describe("hashPolicyLeaf", () => { }; expect(hashPolicyLeaf(policy)).toBe("0x0"); }); - - test("authorized: undefined is treated as authorized (hashes normally)", () => { - const withUndefined: CallPolicy = { - target: ADDR_A, - method: SELECTOR_TRANSFER, - }; - const withTrue: CallPolicy = { - target: ADDR_A, - method: SELECTOR_TRANSFER, - authorized: true, - }; - expect(hashPolicyLeaf(withUndefined)).toBe(hashPolicyLeaf(withTrue)); - expect(hashPolicyLeaf(withUndefined)).not.toBe("0x0"); - }); }); describe("computePolicyMerkle", () => { @@ -174,7 +113,6 @@ describe("computePolicyMerkle", () => { const policy: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; const result = computePolicyMerkle([policy]); expect(result.leaves).toHaveLength(1); - // Single leaf IS the root (no pairing needed) expect(result.root).toBe(result.leaves[0]); }); @@ -198,39 +136,12 @@ describe("computePolicyMerkle", () => { }); test("deterministic root regardless of policy order", () => { - // Note: policies are NOT reordered by computePolicyMerkle. - // But the sorted-pair hashing means hashPair(a,b) == hashPair(b,a). - // The caller (toWasmPolicies) is responsible for canonical ordering. const p1: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; const p2: CallPolicy = { target: ADDR_B, method: SELECTOR_APPROVE }; - const r1 = computePolicyMerkle([p1, p2]); const r2 = computePolicyMerkle([p2, p1]); - - // With sorted-pair hashing, order of two leaves doesn't matter expect(r1.root).toBe(r2.root); }); - - test("seven policies produce a valid tree", () => { - const policies: CallPolicy[] = Array.from({ length: 7 }, (_, i) => ({ - target: `0x${(i + 1).toString(16)}`, - method: SELECTOR_TRANSFER, - })); - const result = computePolicyMerkle(policies); - expect(result.leaves).toHaveLength(7); - expect(result.root).toMatch(/^0x[0-9a-f]+$/); - }); - - test("mixed policy types", () => { - const policies: Policy[] = [ - { target: ADDR_A, method: SELECTOR_TRANSFER, authorized: true }, - { scope_hash: "0xabc", authorized: true }, - { target: ADDR_B, spender: ADDR_A, amount: "1000" }, - ]; - const result = computePolicyMerkle(policies); - expect(result.leaves).toHaveLength(3); - expect(result.root).toMatch(/^0x[0-9a-f]+$/); - }); }); describe("computePolicyMerkleProofs", () => { @@ -241,25 +152,11 @@ describe("computePolicyMerkleProofs", () => { test("single policy proof is empty (leaf is the root)", () => { const policy: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; const proofs = computePolicyMerkleProofs([policy]); - expect(proofs).toHaveLength(1); - expect(proofs[0].proof).toHaveLength(0); // single leaf = root, no proof needed - expect(proofs[0].leaf).toBe(hashPolicyLeaf(policy)); - - // Root should equal the leaf directly + expect(proofs[0].proof).toHaveLength(0); const { root } = computePolicyMerkle([policy]); expect(proofs[0].leaf).toBe(root); }); - test("two policies: each proof has one element (the sibling)", () => { - const p1: CallPolicy = { target: ADDR_A, method: SELECTOR_TRANSFER }; - const p2: CallPolicy = { target: ADDR_B, method: SELECTOR_APPROVE }; - const proofs = computePolicyMerkleProofs([p1, p2]); - expect(proofs).toHaveLength(2); - // Each leaf's proof is the other leaf - expect(proofs[0].proof).toHaveLength(1); - expect(proofs[1].proof).toHaveLength(1); - }); - test("proofs verify against the merkle root", () => { const policies: CallPolicy[] = [ { target: ADDR_A, method: SELECTOR_TRANSFER }, @@ -270,25 +167,6 @@ describe("computePolicyMerkleProofs", () => { const { root } = computePolicyMerkle(policies); const proofs = computePolicyMerkleProofs(policies); - // Verify each proof - for (const proof of proofs) { - let current = proof.leaf; - for (const sibling of proof.proof) { - current = hashPair(current, sibling); - } - expect(current).toBe(root); - } - }); - - test("proofs verify for 7 policies", () => { - const policies: CallPolicy[] = Array.from({ length: 7 }, (_, i) => ({ - target: `0x${(i + 1).toString(16)}`, - method: SELECTOR_TRANSFER, - })); - - const { root } = computePolicyMerkle(policies); - const proofs = computePolicyMerkleProofs(policies); - for (const proof of proofs) { let current = proof.leaf; for (const sibling of proof.proof) { diff --git a/packages/controller/src/__tests__/subscribe.test.ts b/packages/controller/src/__tests__/subscribe.test.ts index 1be37a5ef7..822af7e551 100644 --- a/packages/controller/src/__tests__/subscribe.test.ts +++ b/packages/controller/src/__tests__/subscribe.test.ts @@ -95,19 +95,6 @@ describe("subscribeCreateSession", () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); - test("GraphQL error message is included in thrown error", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - errors: [{ message: "field X not found" }], - }), - }); - - await expect( - subscribeCreateSession("0xguid", "https://api.test"), - ).rejects.toThrow("field X not found"); - }); - test("retries on network errors", async () => { mockFetch .mockRejectedValueOnce(new Error("network failure")) @@ -174,15 +161,4 @@ describe("subscribeCreateSession", () => { expect(body.query).toContain("subscribeCreateSession"); }); - test("strips trailing slashes from API URL", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { subscribeCreateSession: MOCK_SESSION }, - }), - }); - - await subscribeCreateSession("0xguid", "https://api.test///"); - expect(mockFetch.mock.calls[0][0]).toBe("https://api.test/query"); - }); }); diff --git a/packages/controller/src/__tests__/utils.test.ts b/packages/controller/src/__tests__/utils.test.ts index 589075260a..91a02badc0 100644 --- a/packages/controller/src/__tests__/utils.test.ts +++ b/packages/controller/src/__tests__/utils.test.ts @@ -7,14 +7,6 @@ import { } from "../session/internal/utils"; describe("normalizeFelt", () => { - test("converts number to lowercase hex", () => { - expect(normalizeFelt(255)).toBe("0xff"); - }); - - test("converts bigint to lowercase hex", () => { - expect(normalizeFelt(0xdeadn)).toBe("0xdead"); - }); - test("normalizes hex string to lowercase", () => { expect(normalizeFelt("0xDEAD")).toBe("0xdead"); }); @@ -42,12 +34,6 @@ describe("selectorFromEntrypoint", () => { normalizeFelt(hexSelector), ); }); - - test("different entrypoints produce different selectors", () => { - expect(selectorFromEntrypoint("transfer")).not.toBe( - selectorFromEntrypoint("approve"), - ); - }); }); describe("normalizeContractAddress", () => { @@ -81,26 +67,6 @@ describe("normalizeContractAddress", () => { }); describe("signerToGuid", () => { - test("produces a valid hex felt", () => { - const guid = signerToGuid({ - starknet: { privateKey: "0x123" }, - }); - expect(guid).toMatch(/^0x[0-9a-f]+$/); - }); - - test("is deterministic for the same key", () => { - const key = "0xdeadbeef"; - const guid1 = signerToGuid({ starknet: { privateKey: key } }); - const guid2 = signerToGuid({ starknet: { privateKey: key } }); - expect(guid1).toBe(guid2); - }); - - test("produces different GUIDs for different keys", () => { - const guid1 = signerToGuid({ starknet: { privateKey: "0x1" } }); - const guid2 = signerToGuid({ starknet: { privateKey: "0x2" } }); - expect(guid1).not.toBe(guid2); - }); - test("matches manual Poseidon(domain, publicKey) computation", () => { const privateKey = "0x1"; const publicKey = ec.starkCurve.getStarkKey( @@ -128,18 +94,4 @@ describe("signerToGuid", () => { test("throws for empty private key", () => { expect(() => signerToGuid({ starknet: { privateKey: "" } })).toThrow(); }); - - test("produces known GUID for well-known private key 0x1", () => { - // Private key 0x1 has a well-known public key on the Stark curve - const guid = signerToGuid({ starknet: { privateKey: "0x1" } }); - - // Verify it's a valid felt (non-zero, hex) - expect(guid).toMatch(/^0x[0-9a-f]+$/); - expect(BigInt(guid)).toBeGreaterThan(0n); - - // Snapshot the value for regression detection - expect(guid).toMatchInlineSnapshot( - `"0x78e6eccfb97cea1b4ca2e0735d0db7cd9e33a316378391e58e7f3ed107062c2"`, - ); - }); }); From 09f67020c9f052119a16004a890e3ae1e3ed0aa0 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Mon, 13 Apr 2026 16:41:34 -0700 Subject: [PATCH 19/22] style: fix prettier formatting in subscribe test Co-Authored-By: Claude Sonnet 4.6 --- packages/controller/src/__tests__/subscribe.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/controller/src/__tests__/subscribe.test.ts b/packages/controller/src/__tests__/subscribe.test.ts index 822af7e551..1bb9439c73 100644 --- a/packages/controller/src/__tests__/subscribe.test.ts +++ b/packages/controller/src/__tests__/subscribe.test.ts @@ -160,5 +160,4 @@ describe("subscribeCreateSession", () => { expect(body.variables.sessionKeyGuid).toBe("0xmyguid"); expect(body.query).toContain("subscribeCreateSession"); }); - }); From 497bfb92578f45299f37156b161f9764e126b0f7 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Wed, 15 Apr 2026 10:21:36 -0400 Subject: [PATCH 20/22] fix: enable CapacitorHttp to bypass CORS in native WebView The capacitor:// origin is not allowed by the RPC server's CORS policy, causing fetch requests to fail in the native WebView. CapacitorHttp routes requests through the native HTTP layer instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/capacitor/capacitor.config.ts | 5 +++++ examples/capacitor/ios/App/Podfile.lock | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/capacitor/capacitor.config.ts b/examples/capacitor/capacitor.config.ts index ac4730d27b..bc750b04e4 100644 --- a/examples/capacitor/capacitor.config.ts +++ b/examples/capacitor/capacitor.config.ts @@ -4,6 +4,11 @@ const config: CapacitorConfig = { appId: "gg.cartridge.controller.capacitor", appName: "Cartridge Session", webDir: "dist", + plugins: { + CapacitorHttp: { + enabled: true, + }, + }, server: { hostname: "controller-capacitor", // Recommended: Set a custom hostname for production androidScheme: "https", diff --git a/examples/capacitor/ios/App/Podfile.lock b/examples/capacitor/ios/App/Podfile.lock index 6ece7d9def..b0de040494 100644 --- a/examples/capacitor/ios/App/Podfile.lock +++ b/examples/capacitor/ios/App/Podfile.lock @@ -24,9 +24,9 @@ EXTERNAL SOURCES: :path: "../../../../node_modules/.pnpm/@capacitor+ios@6.2.1_@capacitor+core@6.2.1/node_modules/@capacitor/ios" SPEC CHECKSUMS: - Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf - CapacitorApp: a19ccb4f5f32d2534be5c6f524d5a9b614a1e8a7 - CapacitorBrowser: 825163a1d6adce944ac7b9e3cab101146b5a9df8 + Capacitor: 1e0d0e7330dea9f983b50da737d8918abcf273f8 + CapacitorApp: 22c94146dfaae1159ab7059cbd51e76d9f2d45ec + CapacitorBrowser: 9e9a268445f9472b04b68320930c59bdc4d360fa CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff PODFILE CHECKSUM: f6877332d3ea075bd6f7d8f89d3ecbc9a0bb06a3 From 927d166de234251f23eb1ba2cddfe6837010c12b Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Wed, 15 Apr 2026 11:21:21 -0400 Subject: [PATCH 21/22] refactor: extract STRK contract address constant in tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controller/src/__tests__/account.test.ts | 14 ++++----- .../src/__tests__/execution.test.ts | 29 +++++++------------ 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/packages/controller/src/__tests__/account.test.ts b/packages/controller/src/__tests__/account.test.ts index 5caa8cde9f..5c8d9a250a 100644 --- a/packages/controller/src/__tests__/account.test.ts +++ b/packages/controller/src/__tests__/account.test.ts @@ -18,11 +18,12 @@ const TEST_PRIVATE_KEY = "0x1"; const TEST_ADDRESS = "0x1234"; const TEST_OWNER_GUID = "0x5678"; const TEST_CHAIN_ID = "SN_SEPOLIA"; +const STRK_CONTRACT_ADDRESS = + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"; const TEST_POLICIES: CallPolicy[] = [ { - target: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + target: STRK_CONTRACT_ADDRESS, method: TRANSFER_SELECTOR, authorized: true, }, @@ -58,8 +59,7 @@ describe("CartridgeSessionAccount", () => { const result = await account.executeFromOutside([ { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + contractAddress: STRK_CONTRACT_ADDRESS, entrypoint: "transfer", calldata: ["0x1", "0x2", "0x0"], }, @@ -100,8 +100,7 @@ describe("CartridgeSessionAccount", () => { await expect( account.executeFromOutside([ { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + contractAddress: STRK_CONTRACT_ADDRESS, entrypoint: "transfer", calldata: [], }, @@ -138,8 +137,7 @@ describe("CartridgeSessionAccount", () => { await expect( account.executeFromOutside([ { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + contractAddress: STRK_CONTRACT_ADDRESS, entrypoint: "transfer", calldata: [], }, diff --git a/packages/controller/src/__tests__/execution.test.ts b/packages/controller/src/__tests__/execution.test.ts index 3a34363271..766be7764a 100644 --- a/packages/controller/src/__tests__/execution.test.ts +++ b/packages/controller/src/__tests__/execution.test.ts @@ -15,6 +15,8 @@ const TEST_ADDRESS = "0x0000000000000000000000000000000000000000000000000000000000001234"; const TEST_OWNER_GUID = "0x5678"; const TEST_CHAIN_ID = "SN_SEPOLIA"; +const STRK_CONTRACT_ADDRESS = + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"; const TRANSFER_SELECTOR = normalizeFelt(hash.getSelectorFromName("transfer")); @@ -22,8 +24,7 @@ const APPROVE_SELECTOR = normalizeFelt(hash.getSelectorFromName("approve")); const policies: CallPolicy[] = [ { - target: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + target: STRK_CONTRACT_ADDRESS, method: TRANSFER_SELECTOR, authorized: true, }, @@ -31,14 +32,12 @@ const policies: CallPolicy[] = [ const multiPolicies: CallPolicy[] = [ { - target: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + target: STRK_CONTRACT_ADDRESS, method: TRANSFER_SELECTOR, authorized: true, }, { - target: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + target: STRK_CONTRACT_ADDRESS, method: APPROVE_SELECTOR, authorized: true, }, @@ -66,8 +65,7 @@ describe("buildSignedOutsideExecutionV3", () => { const result = buildSignedOutsideExecutionV3({ calls: [ { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + contractAddress: STRK_CONTRACT_ADDRESS, entrypoint: "transfer", calldata: ["0x1", "0x2", "0x0"], }, @@ -106,8 +104,7 @@ describe("buildSignedOutsideExecutionV3", () => { const result = buildSignedOutsideExecutionV3({ calls: [ { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + contractAddress: STRK_CONTRACT_ADDRESS, entrypoint: "transfer", calldata: ["0x1"], }, @@ -201,8 +198,7 @@ describe("buildSignedOutsideExecutionV3", () => { buildSignedOutsideExecutionV3({ calls: [ { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + contractAddress: STRK_CONTRACT_ADDRESS, entrypoint: "transfer", calldata: [], }, @@ -244,14 +240,12 @@ describe("buildSignedOutsideExecutionV3", () => { const result = buildSignedOutsideExecutionV3({ calls: [ { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + contractAddress: STRK_CONTRACT_ADDRESS, entrypoint: "transfer", calldata: ["0x1", "0x2", "0x0"], }, { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + contractAddress: STRK_CONTRACT_ADDRESS, entrypoint: "approve", calldata: ["0xaaa"], }, @@ -296,8 +290,7 @@ describe("buildSignedOutsideExecutionV3", () => { const result = buildSignedOutsideExecutionV3({ calls: [ { - contractAddress: - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + contractAddress: STRK_CONTRACT_ADDRESS, entrypoint: "transfer", calldata: [], }, From 1777365b3ce14c42bc4c73b1a7962c07bcfa1550 Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Wed, 15 Apr 2026 11:55:04 -0400 Subject: [PATCH 22/22] chore: remove unused @cartridge/controller-wasm dependency from controller package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session provider now uses pure TypeScript — no WASM imports remain in packages/controller/src/. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/controller/package.json | 1 - pnpm-lock.yaml | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/controller/package.json b/packages/controller/package.json index 672ce6bece..d90e6793ce 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -36,7 +36,6 @@ } }, "dependencies": { - "@cartridge/controller-wasm": "catalog:", "@cartridge/penpal": "catalog:", "micro-sol-signer": "^0.5.0", "bs58": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83ac669582..7ce8620100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,9 +455,6 @@ importers: packages/controller: dependencies: - '@cartridge/controller-wasm': - specifier: 'catalog:' - version: 0.10.1 '@cartridge/penpal': specifier: 'catalog:' version: 6.2.4 @@ -5186,6 +5183,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}