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 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/packages/controller/src/__tests__/account.test.ts b/packages/controller/src/__tests__/account.test.ts new file mode 100644 index 0000000000..5c8d9a250a --- /dev/null +++ b/packages/controller/src/__tests__/account.test.ts @@ -0,0 +1,186 @@ +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"; + +// 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 STRK_CONTRACT_ADDRESS = + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"; + +const TEST_POLICIES: CallPolicy[] = [ + { + target: STRK_CONTRACT_ADDRESS, + method: TRANSFER_SELECTOR, + authorized: true, + }, +]; + +const TEST_SESSION: Session = { + policies: TEST_POLICIES, + expiresAt: 9999999999, + metadataHash: "0x0", + sessionKeyGuid: "0x0", + guardianKeyGuid: "0x0", +}; + +describe("CartridgeSessionAccount", () => { + test("executeFromOutside sends correct RPC request", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: "2.0", + id: 1, + result: { transaction_hash: "0xtxhash" }, + }), + }); + + const account = CartridgeSessionAccount.newAsRegistered( + TEST_RPC, + TEST_PRIVATE_KEY, + TEST_ADDRESS, + TEST_OWNER_GUID, + TEST_CHAIN_ID, + TEST_SESSION, + ); + + const result = await account.executeFromOutside([ + { + contractAddress: STRK_CONTRACT_ADDRESS, + 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 = CartridgeSessionAccount.newAsRegistered( + TEST_RPC, + TEST_PRIVATE_KEY, + TEST_ADDRESS, + TEST_OWNER_GUID, + TEST_CHAIN_ID, + TEST_SESSION, + ); + + await expect( + account.executeFromOutside([ + { + contractAddress: STRK_CONTRACT_ADDRESS, + entrypoint: "transfer", + calldata: [], + }, + ]), + ).rejects.toThrow("execution failed"); + }); + + test("internal CartridgeSessionAccount only exposes executeFromOutside, not execute", () => { + 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(); + expect(account.executeFromOutside).toBeDefined(); + }); + + 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: STRK_CONTRACT_ADDRESS, + 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); + }); + + test("returns false for generic errors", () => { + expect(isSnip9CompatibilityError(new Error("network timeout"))).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); + }); + }); +}); diff --git a/packages/controller/src/__tests__/execution.test.ts b/packages/controller/src/__tests__/execution.test.ts new file mode 100644 index 0000000000..766be7764a --- /dev/null +++ b/packages/controller/src/__tests__/execution.test.ts @@ -0,0 +1,329 @@ +import { ec, encode, hash, shortString } from "starknet"; +import { + buildSignedOutsideExecutionV3, + createPolicyProofIndex, +} from "../session/internal/execution"; +import { + computePolicyMerkle, + computePolicyMerkleProofs, +} from "../session/internal/merkle"; +import type { CallPolicy } from "../session/internal/types"; +import { normalizeFelt } from "../session/internal/utils"; + +const TEST_PRIVATE_KEY = "0x1"; +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")); + +const APPROVE_SELECTOR = normalizeFelt(hash.getSelectorFromName("approve")); + +const policies: CallPolicy[] = [ + { + target: STRK_CONTRACT_ADDRESS, + method: TRANSFER_SELECTOR, + authorized: true, + }, +]; + +const multiPolicies: CallPolicy[] = [ + { + target: STRK_CONTRACT_ADDRESS, + method: TRANSFER_SELECTOR, + authorized: true, + }, + { + target: STRK_CONTRACT_ADDRESS, + method: APPROVE_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: STRK_CONTRACT_ADDRESS, + 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: STRK_CONTRACT_ADDRESS, + 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("throws when execute_before <= execute_after", () => { + const { root, policyProofIndex, sessionKeyGuid } = buildTestContext(); + + expect(() => + buildSignedOutsideExecutionV3({ + calls: [ + { + contractAddress: STRK_CONTRACT_ADDRESS, + 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("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: STRK_CONTRACT_ADDRESS, + entrypoint: "transfer", + calldata: ["0x1", "0x2", "0x0"], + }, + { + contractAddress: STRK_CONTRACT_ADDRESS, + 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(); + + const result = buildSignedOutsideExecutionV3({ + calls: [ + { + contractAddress: STRK_CONTRACT_ADDRESS, + 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); + }); +}); diff --git a/packages/controller/src/__tests__/merkle.test.ts b/packages/controller/src/__tests__/merkle.test.ts new file mode 100644 index 0000000000..dc16870523 --- /dev/null +++ b/packages/controller/src/__tests__/merkle.test.ts @@ -0,0 +1,216 @@ +import { hash } from "starknet"; +import { + hashPair, + hashPolicyLeaf, + computePolicyMerkle, + computePolicyMerkleProofs, +} from "../session/internal/merkle"; +import { normalizeFelt } from "../session/internal/utils"; +import type { + CallPolicy, + TypedDataPolicy, + ApprovalPolicy, + Policy, +} from "../session/internal/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)); + }); +}); + +describe("hashPolicyLeaf", () => { + 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)); + }); + + test("TypedDataPolicy uses a different type hash than CallPolicy", () => { + 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)", () => { + 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", + }; + 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"); + }); +}); + +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); + expect(result.root).toBe(result.leaves[0]); + }); + + 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", () => { + 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]); + expect(r1.root).toBe(r2.root); + }); +}); + +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[0].proof).toHaveLength(0); + const { root } = computePolicyMerkle([policy]); + expect(proofs[0].leaf).toBe(root); + }); + + 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); + + for (const proof of proofs) { + let current = proof.leaf; + for (const sibling of proof.proof) { + current = hashPair(current, sibling); + } + 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/__tests__/subscribe.test.ts b/packages/controller/src/__tests__/subscribe.test.ts new file mode 100644 index 0000000000..1bb9439c73 --- /dev/null +++ b/packages/controller/src/__tests__/subscribe.test.ts @@ -0,0 +1,163 @@ +import { subscribeCreateSession } from "../session/internal/subscribe"; +import { + SessionProtocolError, + SessionTimeoutError, +} from "../session/internal/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("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("retries on network errors", async () => { + mockFetch + .mockRejectedValueOnce(new Error("network failure")) + .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); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + 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("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, + 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"); + }); +}); 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/__tests__/utils.test.ts b/packages/controller/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..91a02badc0 --- /dev/null +++ b/packages/controller/src/__tests__/utils.test.ts @@ -0,0 +1,97 @@ +import { ec, hash, num, shortString, encode } from "starknet"; +import { + normalizeFelt, + selectorFromEntrypoint, + normalizeContractAddress, + signerToGuid, +} from "../session/internal/utils"; + +describe("normalizeFelt", () => { + 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), + ); + }); +}); + +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("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(); + }); +}); diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index 3a8c0a67fe..efc394d2b2 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -1,5 +1,9 @@ -import { Policy } from "@cartridge/controller-wasm"; -import { CartridgeSessionAccount } from "@cartridge/controller-wasm/session"; +import type { Policy } from "../session"; +import { + CartridgeSessionAccount, + isSnip9CompatibilityError, + SessionProtocolError, +} from "../session"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; @@ -73,13 +77,25 @@ 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)) { + // 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/node/provider.ts b/packages/controller/src/node/provider.ts index 096f837491..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 "@cartridge/controller-wasm"; +import { signerToGuid } from "../session"; 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..53e6ddc146 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -1,5 +1,9 @@ -import { Policy } from "@cartridge/controller-wasm"; -import { CartridgeSessionAccount } from "@cartridge/controller-wasm/session"; +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,25 @@ 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)) { + // 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/index.ts b/packages/controller/src/session/index.ts index 902711daef..294d818115 100644 --- a/packages/controller/src/session/index.ts +++ b/packages/controller/src/session/index.ts @@ -2,4 +2,7 @@ export { default } from "./provider"; export * from "./provider"; export * from "../errors"; export * from "../types"; -export * from "@cartridge/controller-wasm"; +export * from "./internal/types"; +export * from "./internal/account"; +export * from "./internal/errors"; +export * from "./internal/utils"; diff --git a/packages/controller/src/session/internal/account.ts b/packages/controller/src/session/internal/account.ts new file mode 100644 index 0000000000..2047319a90 --- /dev/null +++ b/packages/controller/src/session/internal/account.ts @@ -0,0 +1,137 @@ +import type { InvokeFunctionResponse } from "starknet"; +import type { SessionCall, Session } from "./types"; +import { computePolicyMerkle, computePolicyMerkleProofs } from "./merkle"; +import { + buildSignedOutsideExecutionV3, + createPolicyProofIndex, + type SessionRegistration, +} from "./execution"; +import { signerToGuid } from "./utils"; + +/** + * Pure TypeScript replacement for the WASM CartridgeSessionAccount class. + * Provides the same `newAsRegistered`, `executeFromOutside`, and `execute` interface. + */ +export class CartridgeSessionAccount { + 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, + ): CartridgeSessionAccount { + return new CartridgeSessionAccount( + rpcUrl, + signer, + address, + ownerGuid, + chainId, + session, + ); + } + + async executeFromOutside( + 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, + })); + + 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); + } +} + +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/internal/errors.ts b/packages/controller/src/session/internal/errors.ts new file mode 100644 index 0000000000..607c19fb80 --- /dev/null +++ b/packages/controller/src/session/internal/errors.ts @@ -0,0 +1,73 @@ +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); + 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/internal/execution.ts b/packages/controller/src/session/internal/execution.ts new file mode 100644 index 0000000000..390cc4974b --- /dev/null +++ b/packages/controller/src/session/internal/execution.ts @@ -0,0 +1,455 @@ +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 "./utils"; + +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); + + 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], + 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/internal/index.ts b/packages/controller/src/session/internal/index.ts new file mode 100644 index 0000000000..979a7b8a5b --- /dev/null +++ b/packages/controller/src/session/internal/index.ts @@ -0,0 +1,7 @@ +export * from "./types"; +export * from "./errors"; +export * from "./merkle"; +export * from "./execution"; +export * from "./account"; +export * from "./subscribe"; +export * from "./utils"; diff --git a/packages/controller/src/session/internal/merkle.ts b/packages/controller/src/session/internal/merkle.ts new file mode 100644 index 0000000000..c2fcef859f --- /dev/null +++ b/packages/controller/src/session/internal/merkle.ts @@ -0,0 +1,206 @@ +import { hash } from "starknet"; +import type { + Policy, + CallPolicy, + TypedDataPolicy, + ApprovalPolicy, +} from "./types"; +import { normalizeFelt } from "./utils"; + +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")', + ), +); + +/** + * 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; +} + +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. + * 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([ + POLICY_CALL_TYPE_HASH, + normalizeFelt(policy.target), + normalizeFelt(policy.method), + ]), + ); + } + + if (isApprovalPolicy(policy)) { + // 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), + APPROVE_SELECTOR, + ]), + ); + } + + if (isTypedDataPolicy(policy)) { + return normalizeFelt( + hash.computePoseidonHashOnElements([ + POLICY_TYPED_DATA_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) + : isApprovalPolicy(policy) + ? APPROVE_SELECTOR + : ZERO_FELT; + + return { + contractAddress: target, + selector, + leaf: leaves[i], + proof: proofs[i], + }; + }); +} diff --git a/packages/controller/src/session/internal/subscribe.ts b/packages/controller/src/session/internal/subscribe.ts new file mode 100644 index 0000000000..96aaaecc76 --- /dev/null +++ b/packages/controller/src/session/internal/subscribe.ts @@ -0,0 +1,111 @@ +import { SessionProtocolError, 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, + requestTimeoutMs: number = 15_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 remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + + const effectiveTimeout = Math.min(requestTimeoutMs, remainingMs); + const controller = + typeof AbortController !== "undefined" + ? new AbortController() + : undefined; + const timeoutId = controller + ? setTimeout(() => controller.abort(), effectiveTimeout) + : undefined; + + 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 (!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 json = (await response.json()) as { + data?: { subscribeCreateSession?: SubscribeSessionResult | null }; + errors?: { message: string }[]; + }; + + 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)); +} diff --git a/packages/controller/src/session/internal/types.ts b/packages/controller/src/session/internal/types.ts new file mode 100644 index 0000000000..02f095e81d --- /dev/null +++ b/packages/controller/src/session/internal/types.ts @@ -0,0 +1,46 @@ +/** + * Pure TypeScript equivalents of the WASM session types. + */ + +export type Felt252 = string; + +export interface CallPolicy { + target: Felt252; + method: Felt252; + authorized?: boolean; +} + +export interface TypedDataPolicy { + scope_hash: Felt252; + authorized?: boolean; +} + +export interface ApprovalPolicy { + target: Felt252; + spender: Felt252; + amount: Felt252; +} + +export type Policy = CallPolicy | TypedDataPolicy | ApprovalPolicy; + +export interface Session { + policies: Policy[]; + expiresAt: number; + metadataHash: Felt252; + sessionKeyGuid: Felt252; + guardianKeyGuid: Felt252; +} + +export interface SessionCall { + contractAddress: Felt252; + entrypoint: string; + calldata: Felt252[]; +} + +export interface Signer { + starknet?: StarknetSigner; +} + +export interface StarknetSigner { + privateKey: Felt252; +} diff --git a/packages/controller/src/session/internal/utils.ts b/packages/controller/src/session/internal/utils.ts new file mode 100644 index 0000000000..8343e2dd00 --- /dev/null +++ b/packages/controller/src/session/internal/utils.ts @@ -0,0 +1,62 @@ +import { + addAddressPadding, + ec, + encode, + hash, + num, + shortString, +} from "starknet"; +import type { Signer } from "./types"; + +/** + * 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()); +} + +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/provider.ts b/packages/controller/src/session/provider.ts index f638430807..6c5c53f6d3 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 "./internal/utils"; +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 9cd65387a8..9bb3f9e54c 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"; import { Policies, SessionPolicies } from "@cartridge/presets"; import { ChainId } from "@starknet-io/types-js"; import { @@ -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()), ), ]; } 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==}