diff --git a/packages/controller/package.json b/packages/controller/package.json index 223830130f..5d5097b85a 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -18,6 +18,7 @@ "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "test": "jest", + "test:wasm": "node --experimental-wasm-modules --test src/__tests__/wasm-integration.test.mjs", "version": "pnpm pkg get version" }, "exports": { diff --git a/packages/controller/src/__tests__/session-provider.test.ts b/packages/controller/src/__tests__/session-provider.test.ts new file mode 100644 index 0000000000..a36ad3e1de --- /dev/null +++ b/packages/controller/src/__tests__/session-provider.test.ts @@ -0,0 +1,474 @@ +// Mock starknet partially — stark.randomAddress is non-configurable, so jest.spyOn fails. +const MOCK_PK = + "0x01234567890abcdef01234567890abcdef01234567890abcdef01234567890ab"; + +jest.mock("starknet", () => { + const actual = jest.requireActual("starknet"); + return { + ...actual, + stark: { + ...actual.stark, + randomAddress: jest.fn(() => MOCK_PK), + }, + }; +}); + +jest.mock("@cartridge/controller-wasm", () => ({ + signerToGuid: jest.fn(() => "0xmockguid"), + subscribeCreateSession: jest.fn(), +})); + +jest.mock("@cartridge/presets", () => ({ + loadConfig: jest.fn(), +})); + +jest.mock("../session/account", () => ({ + __esModule: true, + default: jest.fn().mockImplementation((_provider: any, opts: any) => ({ + address: opts.address, + execute: jest.fn(), + })), +})); + +jest.mock("../provider", () => { + function MockBaseProvider(this: any) { + this.account = undefined; + this.subscriptions = []; + this.emitAccountsChanged = jest.fn(); + this.emitNetworkChanged = jest.fn(); + } + MockBaseProvider.prototype.safeProbe = function () { + return Promise.resolve(this.account); + }; + return { __esModule: true, default: MockBaseProvider }; +}); + +import { ec, stark } from "starknet"; +import { signerToGuid } from "@cartridge/controller-wasm"; +import SessionProvider from "../session/provider"; +import { ParsedSessionPolicies } from "../policies"; + +const mockRandomAddress = stark.randomAddress as jest.Mock; +const mockSignerToGuid = signerToGuid as jest.Mock; + +// --- Fixtures --- + +const MOCK_PRIVATE_KEY = MOCK_PK; +const MOCK_PUBLIC_KEY = ec.starkCurve.getStarkKey(MOCK_PRIVATE_KEY); +const MOCK_SESSION_KEY_GUID = "0xmockguid"; +const MOCK_RPC = "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_9"; +const MOCK_CHAIN_ID = "0x534e5f4d41494e"; +const MOCK_REDIRECT = "https://game.example.com/callback"; + +const VALID_SESSION = { + username: "testuser", + address: "0xabc", + ownerGuid: "0xowner", + expiresAt: String(Math.floor(Date.now() / 1000) + 3600), + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid: MOCK_SESSION_KEY_GUID, +}; + +const VALID_POLICIES: ParsedSessionPolicies = { + verified: false, + contracts: { + "0x1": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, +}; + +// --- localStorage helper --- + +type MockStorage = { [key: string]: string }; + +function createMockLocalStorage() { + const store: MockStorage = {}; + + return { + get length() { + return Object.keys(store).length; + }, + clear: jest.fn(() => { + Object.keys(store).forEach((key) => delete store[key]); + }), + getItem: jest.fn((key: string) => (key in store ? store[key] : null)), + setItem: jest.fn((key: string, value: string) => { + store[key] = String(value); + }), + removeItem: jest.fn((key: string) => { + delete store[key]; + }), + key: jest.fn((index: number) => { + const keys = Object.keys(store); + return keys[index] || null; + }), + _store: store, + } as Storage & { _store: MockStorage }; +} + +function setupWindowMocks() { + (global as any).window = { + open: jest.fn(), + location: { search: "", pathname: "/", hash: "" }, + history: { replaceState: jest.fn() }, + }; + (global as any).document = { title: "test" }; + (global as any).atob = (str: string) => Buffer.from(str, "base64").toString(); + (global as any).btoa = (str: string) => Buffer.from(str).toString("base64"); +} + +describe("SessionProvider", () => { + const originalLocalStorage = (global as any).localStorage; + const originalWindow = (global as any).window; + const originalDocument = (global as any).document; + let mockLocalStorage: ReturnType; + + beforeEach(() => { + jest.resetAllMocks(); + mockRandomAddress.mockReturnValue(MOCK_PRIVATE_KEY); + mockSignerToGuid.mockReturnValue(MOCK_SESSION_KEY_GUID); + + mockLocalStorage = createMockLocalStorage(); + (global as any).localStorage = mockLocalStorage; + setupWindowMocks(); + }); + + afterEach(() => { + (global as any).localStorage = originalLocalStorage; + (global as any).window = originalWindow; + (global as any).document = originalDocument; + }); + + function createProvider( + overrides: Partial<{ + rpc: string; + chainId: string; + policies: any; + preset: string; + shouldOverridePresetPolicies: boolean; + redirectUrl: string; + keychainUrl: string; + apiUrl: string; + signupOptions: any; + }> = {}, + ) { + return new SessionProvider({ + rpc: MOCK_RPC, + chainId: MOCK_CHAIN_ID, + policies: { + contracts: { "0x1": { methods: [{ entrypoint: "attack" }] } }, + }, + redirectUrl: MOCK_REDIRECT, + ...overrides, + }); + } + + // ========================================== + // Constructor + // ========================================== + + describe("constructor", () => { + it("throws when neither policies nor preset provided", () => { + expect( + () => + new SessionProvider({ + rpc: MOCK_RPC, + chainId: MOCK_CHAIN_ID, + redirectUrl: MOCK_REDIRECT, + } as any), + ).toThrow("Either `policies` or `preset` must be provided"); + }); + }); + + // ========================================== + // validatePoliciesSubset + // ========================================== + + describe("validatePoliciesSubset", () => { + function callValidate( + provider: any, + newPolicies: ParsedSessionPolicies, + existingPolicies: ParsedSessionPolicies, + ): boolean { + return provider.validatePoliciesSubset(newPolicies, existingPolicies); + } + + it("returns true when new policies are a subset of stored policies", () => { + const provider = createProvider(); + const result = callValidate(provider, VALID_POLICIES, { + verified: false, + contracts: { + "0x1": { + methods: [ + { entrypoint: "attack", authorized: true }, + { entrypoint: "defend", authorized: true }, + ], + }, + }, + }); + expect(result).toBe(true); + }); + + it("returns false when new policies contain unknown address", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + contracts: { + "0x999": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, + }, + VALID_POLICIES, + ); + expect(result).toBe(false); + }); + + it("returns false when new policies contain unknown method", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + contracts: { + "0x1": { + methods: [{ entrypoint: "unknown_method", authorized: true }], + }, + }, + }, + VALID_POLICIES, + ); + expect(result).toBe(false); + }); + + it("returns false when stored method has authorized: false", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + contracts: { + "0x1": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, + }, + { + verified: false, + contracts: { + "0x1": { + methods: [{ entrypoint: "attack", authorized: false }], + }, + }, + }, + ); + expect(result).toBe(false); + }); + + it("returns true for matching message policies", () => { + const provider = createProvider(); + const messagePolicies: ParsedSessionPolicies = { + verified: false, + messages: [ + { + domain: { + name: "TestDomain", + version: "1", + chainId: MOCK_CHAIN_ID, + }, + types: { + StarknetDomain: [ + { name: "name", type: "shortstring" }, + { name: "version", type: "shortstring" }, + { name: "chainId", type: "shortstring" }, + ], + Message: [{ name: "content", type: "felt" }], + }, + primaryType: "Message", + authorized: true, + }, + ], + }; + + const result = callValidate(provider, messagePolicies, messagePolicies); + expect(result).toBe(true); + }); + + it("returns false when message is missing from stored policies", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + messages: [ + { + domain: { + name: "TestDomain", + version: "1", + chainId: MOCK_CHAIN_ID, + }, + types: { + StarknetDomain: [ + { name: "name", type: "shortstring" }, + { name: "version", type: "shortstring" }, + { name: "chainId", type: "shortstring" }, + ], + Message: [{ name: "content", type: "felt" }], + }, + primaryType: "Message", + authorized: true, + }, + ], + }, + { verified: false }, + ); + expect(result).toBe(false); + }); + + it("returns true when new policies have no contracts or messages", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { verified: false }, + VALID_POLICIES, + ); + expect(result).toBe(true); + }); + + it("returns false when new has contracts but stored has none", () => { + const provider = createProvider(); + const result = callValidate(provider, VALID_POLICIES, { + verified: false, + }); + expect(result).toBe(false); + }); + + it("does exact address matching (no normalization)", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + contracts: { + "0xABC": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, + }, + { + verified: false, + contracts: { + "0xabc": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, + }, + ); + // Different casing = different key = not found + expect(result).toBe(false); + }); + }); + + // ========================================== + // ingestSessionFromRedirect + // ========================================== + + describe("ingestSessionFromRedirect", () => { + it("decodes valid base64 session and stores to localStorage", () => { + const provider = createProvider(); + const encoded = (global as any).btoa(JSON.stringify(VALID_SESSION)); + + const result = (provider as any).ingestSessionFromRedirect(encoded); + + expect(result).toBeDefined(); + expect(result.username).toBe("testuser"); + expect(result.address).toBe("0xabc"); + + const stored = JSON.parse(mockLocalStorage.getItem("session")!); + expect(stored.username).toBe("testuser"); + }); + + it("pads base64 correctly when padding is stripped", () => { + const provider = createProvider(); + const encoded = (global as any) + .btoa(JSON.stringify(VALID_SESSION)) + .replace(/=+$/, ""); + + const result = (provider as any).ingestSessionFromRedirect(encoded); + expect(result).toBeDefined(); + expect(result.username).toBe("testuser"); + }); + + it("returns undefined for malformed payloads without throwing", () => { + const provider = createProvider(); + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + const result = (provider as any).ingestSessionFromRedirect( + "not-valid-base64!!!", + ); + expect(result).toBeUndefined(); + + consoleSpy.mockRestore(); + }); + + it("returns undefined when required fields are missing", () => { + const provider = createProvider(); + const incomplete = { username: "test" }; + const encoded = (global as any).btoa(JSON.stringify(incomplete)); + + const result = (provider as any).ingestSessionFromRedirect(encoded); + expect(result).toBeUndefined(); + }); + + it("fills default guardianKeyGuid and metadataHash", () => { + const provider = createProvider(); + const sessionWithoutDefaults = { + username: "testuser", + address: "0xabc", + ownerGuid: "0xowner", + expiresAt: String(Math.floor(Date.now() / 1000) + 3600), + }; + const encoded = (global as any).btoa( + JSON.stringify(sessionWithoutDefaults), + ); + + const result = (provider as any).ingestSessionFromRedirect(encoded); + + expect(result.guardianKeyGuid).toBe("0x0"); + expect(result.metadataHash).toBe("0x0"); + }); + }); + + // ========================================== + // disconnect + // ========================================== + + describe("disconnect", () => { + it("removes only session keys from localStorage", async () => { + (global as any).window.open = jest.fn(() => null); + + const provider = createProvider(); + mockLocalStorage.setItem("session", JSON.stringify(VALID_SESSION)); + mockLocalStorage.setItem( + "sessionPolicies", + JSON.stringify(VALID_POLICIES), + ); + mockLocalStorage.setItem("lastUsedConnector", "controller_session"); + mockLocalStorage.setItem("unrelatedKey", "keep"); + + await provider.disconnect(); + + expect(mockLocalStorage.getItem("sessionSigner")).toBeNull(); + expect(mockLocalStorage.getItem("session")).toBeNull(); + expect(mockLocalStorage.getItem("sessionPolicies")).toBeNull(); + expect(mockLocalStorage.getItem("lastUsedConnector")).toBeNull(); + expect(mockLocalStorage.getItem("unrelatedKey")).toBe("keep"); + }); + }); +}); diff --git a/packages/controller/src/__tests__/wasm-integration.test.mjs b/packages/controller/src/__tests__/wasm-integration.test.mjs new file mode 100644 index 0000000000..d98f398375 --- /dev/null +++ b/packages/controller/src/__tests__/wasm-integration.test.mjs @@ -0,0 +1,490 @@ +/** + * WASM Integration Tests + * + * These tests exercise the real @cartridge/controller-wasm binary in Node.js + * to verify that policy conversion and session construction work end-to-end. + * + * Run with: node --experimental-wasm-modules --test packages/controller/src/__tests__/wasm-integration.test.mjs + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Resolve the WASM package via import.meta.resolve, then dynamic import. +// This handles pnpm's .pnpm store symlinks correctly. +const sessionWasmPath = import.meta.resolve( + "@cartridge/controller-wasm/session", +); +const { CartridgeSessionAccount, signerToGuid } = await import(sessionWasmPath); + +// --- Helpers --- + +/** Generate a valid 252-bit felt hex string from a seed */ +function felt(seed) { + return "0x" + seed.toString(16).padStart(64, "0"); +} + +/** Import starknet.js for getChecksumAddress and hash utilities */ +const starknet = await import("starknet"); +const { ec, stark, hash, getChecksumAddress, addAddressPadding } = starknet; + +/** Reproduce toWasmPolicies logic from src/utils.ts for test verification */ +function toWasmPolicies(parsedPolicies) { + const contractPolicies = Object.entries(parsedPolicies.contracts ?? {}) + .sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase())) + .flatMap(([target, { methods }]) => + [...methods] + .sort((a, b) => a.entrypoint.localeCompare(b.entrypoint)) + .map((m) => { + if (m.entrypoint === "approve" && m.spender && m.amount) { + return { + target: getChecksumAddress(target), + spender: m.spender, + amount: String(m.amount), + }; + } + return { + target: getChecksumAddress(target), + method: hash.getSelectorFromName(m.entrypoint), + authorized: !!m.authorized, + }; + }), + ); + + return contractPolicies; +} + +// --- Fixtures --- + +const MOCK_RPC = "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_9"; +const MOCK_CHAIN_ID = "0x534e5f4d41494e"; // SN_MAIN +const MOCK_PRIVATE_KEY = stark.randomAddress(); +const MOCK_ADDRESS = felt(0xabc123); +const MOCK_OWNER_GUID = felt(0xdef456); + +function makeSession(policies) { + const guid = signerToGuid({ starknet: { privateKey: MOCK_PRIVATE_KEY } }); + return { + expiresAt: Math.floor(Date.now() / 1000) + 3600, + policies, + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid: guid, + }; +} + +// --- Tests --- + +describe("WASM: signerToGuid", () => { + it("returns a hex string for starknet signer", () => { + const guid = signerToGuid({ + starknet: { privateKey: MOCK_PRIVATE_KEY }, + }); + assert.ok(typeof guid === "string", "guid should be a string"); + assert.ok(guid.startsWith("0x"), "guid should start with 0x"); + assert.ok(guid.length > 2, "guid should be non-empty"); + }); + + it("is deterministic for the same key", () => { + const guid1 = signerToGuid({ + starknet: { privateKey: MOCK_PRIVATE_KEY }, + }); + const guid2 = signerToGuid({ + starknet: { privateKey: MOCK_PRIVATE_KEY }, + }); + assert.equal(guid1, guid2); + }); + + it("produces different guids for different keys", () => { + const guid1 = signerToGuid({ + starknet: { privateKey: MOCK_PRIVATE_KEY }, + }); + const guid2 = signerToGuid({ + starknet: { privateKey: stark.randomAddress() }, + }); + assert.notEqual(guid1, guid2); + }); +}); + +describe("WASM: CartridgeSessionAccount.newAsRegistered", () => { + it("constructs with a single CallPolicy", () => { + const policies = [ + { + target: getChecksumAddress(MOCK_ADDRESS), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + ]; + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct, "should return a session account"); + assert.equal(typeof acct.execute, "function"); + assert.equal(typeof acct.executeFromOutside, "function"); + acct.free(); + }); + + it("constructs with multiple CallPolicies across contracts", () => { + const addr1 = felt(0x111); + const addr2 = felt(0x222); + const policies = [ + { + target: getChecksumAddress(addr1), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + { + target: getChecksumAddress(addr1), + method: hash.getSelectorFromName("defend"), + authorized: true, + }, + { + target: getChecksumAddress(addr2), + method: hash.getSelectorFromName("transfer"), + authorized: true, + }, + ]; + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("constructs with an ApprovalPolicy", () => { + const tokenAddr = "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"; + const spenderAddr = felt(0x123456); + const policies = [ + { + target: getChecksumAddress(tokenAddr), + spender: getChecksumAddress(spenderAddr), + amount: "1000000000000000000", + }, + ]; + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("constructs with mixed CallPolicy and ApprovalPolicy", () => { + const policies = [ + { + target: getChecksumAddress(felt(0x111)), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + { + target: getChecksumAddress(felt(0x222)), + spender: getChecksumAddress(felt(0x333)), + amount: "500", + }, + ]; + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("constructs with empty policies array", () => { + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession([]), + ); + assert.ok(acct); + acct.free(); + }); +}); + +describe("WASM: toWasmPolicies round-trip", () => { + it("accepts output from toWasmPolicies with single contract", () => { + const parsed = { + verified: false, + contracts: { + [felt(0x111)]: { + methods: [ + { entrypoint: "attack", authorized: true }, + { entrypoint: "defend", authorized: true }, + ], + }, + }, + }; + + const policies = toWasmPolicies(parsed); + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("accepts output from toWasmPolicies with multiple contracts", () => { + const parsed = { + verified: false, + contracts: { + [felt(0x111)]: { + methods: [{ entrypoint: "attack", authorized: true }], + }, + [felt(0x222)]: { + methods: [ + { entrypoint: "transfer", authorized: true }, + { entrypoint: "approve", authorized: true }, + ], + }, + [felt(0xaaa)]: { + methods: [{ entrypoint: "mint", authorized: true }], + }, + }, + }; + + const policies = toWasmPolicies(parsed); + assert.ok(policies.length === 4, `expected 4 policies, got ${policies.length}`); + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("accepts output from toWasmPolicies with ApprovalPolicy", () => { + const parsed = { + verified: false, + contracts: { + [felt(0x111)]: { + methods: [ + { + entrypoint: "approve", + spender: getChecksumAddress(felt(0x999)), + amount: "1000000", + authorized: true, + }, + ], + }, + }, + }; + + const policies = toWasmPolicies(parsed); + // Should produce an ApprovalPolicy (has spender+amount, no method) + assert.ok(policies[0].spender, "should have spender field"); + assert.ok(policies[0].amount, "should have amount field"); + assert.ok(!policies[0].method, "should not have method field"); + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); +}); + +describe("WASM: address normalization consistency", () => { + it("produces same session with differently-cased addresses", () => { + const addrLower = "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"; + const addrUpper = "0x049D36570D4E46F48E99674BD3FCC84644DDD6B96F7C741B1562B82F9E004DC7"; + const addrChecksum = getChecksumAddress(addrLower); + + // Both should normalize to the same checksum address + assert.equal(getChecksumAddress(addrLower), getChecksumAddress(addrUpper)); + + const makePolicies = (addr) => [ + { + target: getChecksumAddress(addr), + method: hash.getSelectorFromName("transfer"), + authorized: true, + }, + ]; + + // Both should construct without errors + const acct1 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(makePolicies(addrLower)), + ); + const acct2 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(makePolicies(addrUpper)), + ); + + assert.ok(acct1); + assert.ok(acct2); + acct1.free(); + acct2.free(); + }); + + it("handles zero-padded vs short addresses", () => { + const shortAddr = "0x1"; + const paddedAddr = addAddressPadding(shortAddr); + + const makePolicies = (addr) => [ + { + target: getChecksumAddress(addr), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + ]; + + const acct1 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(makePolicies(shortAddr)), + ); + const acct2 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(makePolicies(paddedAddr)), + ); + + assert.ok(acct1); + assert.ok(acct2); + acct1.free(); + acct2.free(); + }); +}); + +describe("WASM: policy ordering stability", () => { + it("same policies in different insertion order produce valid sessions", () => { + const policies1 = [ + { + target: getChecksumAddress(felt(0x111)), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + { + target: getChecksumAddress(felt(0x222)), + method: hash.getSelectorFromName("defend"), + authorized: true, + }, + ]; + + const policies2 = [ + { + target: getChecksumAddress(felt(0x222)), + method: hash.getSelectorFromName("defend"), + authorized: true, + }, + { + target: getChecksumAddress(felt(0x111)), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + ]; + + // Both orderings should be accepted + const acct1 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies1), + ); + const acct2 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies2), + ); + + assert.ok(acct1); + assert.ok(acct2); + acct1.free(); + acct2.free(); + }); + + it("toWasmPolicies canonical sort is stable across input orderings", () => { + const parsedA = { + verified: false, + contracts: { + [felt(0x222)]: { methods: [{ entrypoint: "defend", authorized: true }] }, + [felt(0x111)]: { methods: [{ entrypoint: "attack", authorized: true }] }, + }, + }; + + const parsedB = { + verified: false, + contracts: { + [felt(0x111)]: { methods: [{ entrypoint: "attack", authorized: true }] }, + [felt(0x222)]: { methods: [{ entrypoint: "defend", authorized: true }] }, + }, + }; + + const policiesA = toWasmPolicies(parsedA); + const policiesB = toWasmPolicies(parsedB); + + // Canonical sort should produce identical output + assert.deepEqual(policiesA, policiesB); + + // Both should be accepted by WASM + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policiesA), + ); + assert.ok(acct); + acct.free(); + }); +});