diff --git a/packages/connector/package.json b/packages/connector/package.json index 1d429edbe7..af618fc82a 100644 --- a/packages/connector/package.json +++ b/packages/connector/package.json @@ -1,6 +1,6 @@ { "name": "@cartridge/connector", - "version": "0.13.12", + "version": "0.13.12-provable-oe.1", "description": "Cartridge Controller Connector", "repository": { "type": "git", diff --git a/packages/controller/package.json b/packages/controller/package.json index ba4cd9cf4e..65f8457dff 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -1,6 +1,6 @@ { "name": "@cartridge/controller", - "version": "0.13.12", + "version": "0.13.12-provable-oe.1", "description": "Cartridge Controller", "repository": { "type": "git", @@ -38,18 +38,18 @@ "dependencies": { "@cartridge/controller-wasm": "catalog:", "@cartridge/penpal": "catalog:", - "micro-sol-signer": "^0.5.0", - "bs58": "^6.0.0", - "ethers": "^6.13.5", "@starknet-io/get-starknet-core": "5.0.0", "@starknet-io/types-js": "0.9.1", - "@wallet-standard/wallet": "^1.1.0", "@turnkey/sdk-browser": "^5.15.2", + "@wallet-standard/wallet": "^1.1.0", + "@walletconnect/ethereum-provider": "^2.20.0", + "bs58": "^6.0.0", "cbor-x": "^1.5.0", - "starknet": "^8.5.2", + "ethers": "^6.13.5", + "micro-sol-signer": "^0.5.0", "mipd": "^0.0.7", "open": "^10.1.0", - "@walletconnect/ethereum-provider": "^2.20.0" + "starknet": "^8.5.2" }, "devDependencies": { "@cartridge/tsconfig": "workspace:*", diff --git a/packages/controller/src/__tests__/getOutsideTransaction.test.ts b/packages/controller/src/__tests__/getOutsideTransaction.test.ts new file mode 100644 index 0000000000..d5a2290c89 --- /dev/null +++ b/packages/controller/src/__tests__/getOutsideTransaction.test.ts @@ -0,0 +1,198 @@ +import { AsyncMethodReturns } from "@cartridge/penpal"; +import { Call } from "starknet"; + +import ControllerAccount from "../account"; +import { ControllerOutsideExecutionUnsupported } from "../errors"; +import { + Keychain, + Modal, + ResponseCodes, + SignedOutsideExecution, +} from "../types"; +import type BaseProvider from "../provider"; +import SessionAccount from "../session/account"; + +// The session WASM is only consumed at construction time; mock it (jest +// hoists this above the imports) so importing SessionAccount in the node +// test environment does not try to instantiate WebAssembly. +jest.mock("@cartridge/controller-wasm/session", () => ({ + CartridgeSessionAccount: { newAsRegistered: jest.fn() }, +})); + +const ADDRESS = + "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d26589bea4189"; +const RPC_URL = "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10"; + +// Envelope exactly as the controller WASM serializes it: ANY_CALLER short +// string felt, execute_after 0, execute_before now+600 (numbers), a random +// [channel, 0x1] nonce pair, fixed-length hex felts, and the call selector +// rendered as a decimal string. +const WASM_SIGNED: SignedOutsideExecution = { + outside_execution: { + caller: + "0x00000000000000000000000000000000000000000000414e595f43414c4c4552", + execute_after: 0, + execute_before: 1765000600, + calls: [ + { + contractAddress: + "0x02d970e9ae65ff80ff3e39268279e6bb271fa3c50c025c6b66c032728b065125", + entrypoint: + "1488470508959902113554091917598462812858669094441227922119121426064847696246", + calldata: [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + ], + }, + ], + nonce: [ + "0x06d8e1c4f7c1a90e0a4f5a9bf26c0a6e2f9c87bb39c0a45f02c6f6a0e6f0a111", + "0x0000000000000000000000000000000000000000000000000000000000000001", + ], + }, + signature: [ + "0x0000000000000000000000000000000000000000000000000000000000000aaa", + "0x0000000000000000000000000000000000000000000000000000000000000bbb", + ], +}; + +const CALLS: Call[] = [ + { + contractAddress: + "0x02d970e9ae65ff80ff3e39268279e6bb271fa3c50c025c6b66c032728b065125", + entrypoint: "request_random", + calldata: ["0x1"], + }, +]; + +const OPTIONS = { + caller: "0x123", + execute_after: 100, + execute_before: 160, +}; + +function makeAccount(keychain: Partial>) { + // WalletAccount's constructor only consumes `walletProvider.on`. + const provider = { on: jest.fn(), off: jest.fn() } as unknown as BaseProvider; + const modal = { open: jest.fn(), close: jest.fn() } as unknown as Modal; + return new ControllerAccount( + provider, + RPC_URL, + ADDRESS, + keychain as unknown as AsyncMethodReturns, + {}, + modal, + ); +} + +describe("ControllerAccount.getOutsideTransaction", () => { + test("returns a contract-shaped V3 outside transaction", async () => { + const signExecuteFromOutside = jest.fn().mockResolvedValue(WASM_SIGNED); + const account = makeAccount({ signExecuteFromOutside }); + + const result = await account.getOutsideTransaction(OPTIONS, CALLS); + + expect(signExecuteFromOutside).toHaveBeenCalledTimes(1); + expect(signExecuteFromOutside).toHaveBeenCalledWith(CALLS); + + expect(result).toEqual({ + outsideExecution: { + caller: WASM_SIGNED.outside_execution.caller, + nonce: WASM_SIGNED.outside_execution.nonce, + execute_after: 0, + execute_before: 1765000600, + calls: [ + { + to: WASM_SIGNED.outside_execution.calls[0].contractAddress, + selector: WASM_SIGNED.outside_execution.calls[0].entrypoint, + calldata: WASM_SIGNED.outside_execution.calls[0].calldata, + }, + ], + }, + signature: WASM_SIGNED.signature, + signerAddress: ADDRESS, + version: "3", + }); + + // The V3 nonce pair must be preserved verbatim, not flattened. + expect(result.outsideExecution.nonce).toEqual( + WASM_SIGNED.outside_execution.nonce, + ); + // Window fields stay numbers, exactly as the WASM returned them. + expect(typeof result.outsideExecution.execute_after).toBe("number"); + expect(typeof result.outsideExecution.execute_before).toBe("number"); + }); + + test("normalizes a single call to an array", async () => { + const signExecuteFromOutside = jest.fn().mockResolvedValue(WASM_SIGNED); + const account = makeAccount({ signExecuteFromOutside }); + + await account.getOutsideTransaction(OPTIONS, CALLS[0]); + + expect(signExecuteFromOutside).toHaveBeenCalledWith([CALLS[0]]); + }); + + test("throws the typed error when the keychain lacks signExecuteFromOutside", async () => { + // Penpal proxies only contain methods the connected keychain registered; + // a pre-upgrade keychain (e.g. production x.cartridge.gg) omits it. + const account = makeAccount({ execute: jest.fn() }); + + const promise = account.getOutsideTransaction(OPTIONS, CALLS); + + await expect(promise).rejects.toMatchObject({ + name: "ControllerOutsideExecutionUnsupported", + }); + await expect(promise).rejects.toBeInstanceOf( + ControllerOutsideExecutionUnsupported, + ); + await expect(promise).rejects.toThrow( + /does not expose signExecuteFromOutside/, + ); + }); + + test("maps a keychain NOT_CONNECTED reply to the typed error", async () => { + const signExecuteFromOutside = jest.fn().mockResolvedValue({ + code: ResponseCodes.NOT_CONNECTED, + message: "Controller not connected", + }); + const account = makeAccount({ signExecuteFromOutside }); + + await expect( + account.getOutsideTransaction(OPTIONS, CALLS), + ).rejects.toMatchObject({ + name: "ControllerOutsideExecutionUnsupported", + }); + }); + + test("surfaces other keychain errors as plain errors", async () => { + const signExecuteFromOutside = jest.fn().mockResolvedValue({ + code: ResponseCodes.ERROR, + message: "signing failed", + error: { code: 69, message: "signing failed", data: {} }, + }); + const account = makeAccount({ signExecuteFromOutside }); + + const promise = account.getOutsideTransaction(OPTIONS, CALLS); + + await expect(promise).rejects.toThrow("signing failed"); + await expect(promise).rejects.not.toMatchObject({ + name: "ControllerOutsideExecutionUnsupported", + }); + }); +}); + +describe("SessionAccount.getOutsideTransaction", () => { + test("always throws the typed error (no recursion into starknet.js)", async () => { + // Invoke via the prototype: the override must fail fast regardless of + // instance state, so no constructed session (or WASM) is required. + const promise = SessionAccount.prototype.getOutsideTransaction.call( + Object.create(SessionAccount.prototype), + ); + + await expect(promise).rejects.toMatchObject({ + name: "ControllerOutsideExecutionUnsupported", + }); + await expect(promise).rejects.toThrow( + /cannot sign SNIP-9 outside executions/, + ); + }); +}); diff --git a/packages/controller/src/account.ts b/packages/controller/src/account.ts index 9341b0d7e9..18eba1f345 100644 --- a/packages/controller/src/account.ts +++ b/packages/controller/src/account.ts @@ -8,11 +8,15 @@ import { import { ConnectError, + ControllerOutsideExecutionOptions, + ControllerOutsideTransaction, Keychain, KeychainOptions, Modal, ResponseCodes, + SignedOutsideExecution, } from "./types"; +import { ControllerOutsideExecutionUnsupported } from "./errors"; import { AsyncMethodReturns } from "@cartridge/penpal"; import BaseProvider from "./provider"; import { toArray } from "./utils"; @@ -168,6 +172,93 @@ class ControllerAccount extends WalletAccount { }); } + /** + * Build and sign — but do NOT submit — a SNIP-9 (SRC-9) V3 outside + * execution over `calls`, via the keychain's `signExecuteFromOutside` RPC + * (which wraps the controller WASM method of the same name). + * + * IMPORTANT: the controller WASM, not this client, decides the execution + * envelope. `options` is accepted only for call-signature compatibility + * with starknet.js `Account.getOutsideTransaction` and is intentionally + * ignored — the WASM always signs: + * - `caller`: the 'ANY_CALLER' short string felt (any relayer may submit), + * - `execute_after`: 0, + * - `execute_before`: WASM wall clock + 600 seconds, + * - `nonce`: a fresh random V3 `[channel, bitmask=0x1]` felt pair + * (preserved verbatim in the returned payload). + * + * Returned field types at runtime: `execute_after`/`execute_before` are JS + * numbers, all felts (caller, nonce pair, call fields, signature) are + * 0x-prefixed zero-padded hex strings, and `calls[].selector` is the + * selector felt as a decimal string (the WASM renders it via Felt + * Display) — consumers must treat it as a felt value, not a name. + * + * @throws {ControllerOutsideExecutionUnsupported} (match on `error.name`) + * when the connected keychain does not expose `signExecuteFromOutside` + * (e.g. a keychain deployment predating this method) or when the + * keychain has no connected controller. + */ + // @ts-expect-error TS2416 -- intentional covariant break with starknet.js + // Account.getOutsideTransaction: Cartridge's V3 outside execution uses a + // two-felt nonce pair and version "3", which the upstream OutsideExecution + // type (single BigNumberish nonce, version "0"|"1"|"2") cannot express. + async getOutsideTransaction( + _options: ControllerOutsideExecutionOptions, + calls: AllowArray, + ): Promise { + if (!this.keychain) { + throw new ControllerOutsideExecutionUnsupported( + "Keychain connection not established: call connect() before requesting an outside execution.", + ); + } + + // Penpal parent proxies only contain the methods the connected keychain + // child actually registered during the handshake. Keychain deployments + // built before signExecuteFromOutside existed (e.g. the production + // keychain at x.cartridge.gg today) simply omit the property, so detect + // that and fail with the typed error instead of a TypeError. + if (typeof this.keychain.signExecuteFromOutside !== "function") { + throw new ControllerOutsideExecutionUnsupported( + "The connected keychain does not expose signExecuteFromOutside; it was likely built before outside-execution signing support and cannot produce a signed outside execution.", + ); + } + + const response = await this.keychain.signExecuteFromOutside(toArray(calls)); + + if (response && typeof response === "object" && "code" in response) { + const connectError = response as ConnectError; + if (connectError.code === ResponseCodes.NOT_CONNECTED) { + throw new ControllerOutsideExecutionUnsupported( + `Keychain has no connected controller: ${connectError.message}`, + ); + } + throw new Error( + connectError.message ?? "signExecuteFromOutside failed", + connectError.error ? { cause: connectError.error } : undefined, + ); + } + + const { outside_execution, signature } = response as SignedOutsideExecution; + + return { + outsideExecution: { + caller: outside_execution.caller, + // V3 nonce is a [channel, bitmask] felt pair — preserve it verbatim. + nonce: outside_execution.nonce, + execute_after: outside_execution.execute_after, + execute_before: outside_execution.execute_before, + calls: outside_execution.calls.map((call) => ({ + to: call.contractAddress, + selector: call.entrypoint, + calldata: call.calldata, + })), + }, + signature, + signerAddress: this.address, + version: "3", + }; + } + /** * Sign an JSON object for off-chain usage with the starknet private key and return the signature * This adds a message prefix so it cant be interchanged with transactions diff --git a/packages/controller/src/errors.ts b/packages/controller/src/errors.ts index f2a71e8c60..d0cf06c314 100644 --- a/packages/controller/src/errors.ts +++ b/packages/controller/src/errors.ts @@ -35,3 +35,32 @@ export class HeadlessModeNotSupportedError extends Error { Object.setPrototypeOf(this, HeadlessModeNotSupportedError.prototype); } } + +/** + * Thrown when SNIP-9 (SRC-9) outside-execution signing is not available: + * + * 1. Always, from the standalone `SessionAccount` (connector id + * `controller_session`) — it has no keychain, and the inherited + * starknet.js implementation would route back through the account's own + * `signMessage` and recurse. + * 2. From `ControllerAccount.getOutsideTransaction` when the connected + * keychain does not expose the `signExecuteFromOutside` RPC method (e.g. + * a keychain deployment built before this method existed). + * 3. From `ControllerAccount.getOutsideTransaction` when the keychain + * reports it has no connected controller. + * + * Dapps should match on + * `error.name === "ControllerOutsideExecutionUnsupported"` rather than + * `instanceof`, so detection survives bundlers duplicating this module. + */ +export class ControllerOutsideExecutionUnsupported extends Error { + constructor(message: string) { + super(message); + this.name = "ControllerOutsideExecutionUnsupported"; + + Object.setPrototypeOf( + this, + ControllerOutsideExecutionUnsupported.prototype, + ); + } +} diff --git a/packages/controller/src/session/account.ts b/packages/controller/src/session/account.ts index 99a0acd9ae..ac4a39c709 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -2,6 +2,7 @@ import { Policy } from "@cartridge/controller-wasm"; import { CartridgeSessionAccount } from "@cartridge/controller-wasm/session"; import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; +import { ControllerOutsideExecutionUnsupported } from "../errors"; import { normalizeCalls } from "../utils"; import BaseProvider from "../provider"; @@ -81,4 +82,18 @@ export default class SessionAccount extends WalletAccount { return this.controller.execute(normalizeCalls(calls)); } } + + /** + * SNIP-9 outside-execution signing is NOT supported by the standalone + * session account. The inherited starknet.js implementation must not run + * either: it would call this account's own `signMessage`, which routes + * back through the session provider and recurses. Fail fast with a typed + * error that dapps can detect via + * `error.name === "ControllerOutsideExecutionUnsupported"`. + */ + async getOutsideTransaction(): Promise { + throw new ControllerOutsideExecutionUnsupported( + "SessionAccount (controller_session) cannot sign SNIP-9 outside executions; connect the full Cartridge Controller (connector id 'controller') instead.", + ); + } } diff --git a/packages/controller/src/types.ts b/packages/controller/src/types.ts index a98a37e51a..bcc77f0680 100644 --- a/packages/controller/src/types.ts +++ b/packages/controller/src/types.ts @@ -165,6 +165,89 @@ type ContractAddress = string; type CartridgeID = string; export type ControllerAccounts = Record; +/** + * A call inside a WASM-signed V3 outside execution. + * + * Field shapes (as serialized by the controller WASM): + * - `contractAddress` and each `calldata` entry are 0x-prefixed, + * zero-padded (64 hex digit) felt strings. + * - `entrypoint` carries the call's **selector felt rendered as a decimal + * string** (the WASM round-trips `Call.selector` through `Felt::to_string`), + * NOT the human-readable entrypoint name that was passed in. + */ +export type SignedOutsideExecutionCall = { + contractAddress: string; + entrypoint: string; + calldata: string[]; +}; + +/** + * The SNIP-9 V3 outside-execution envelope as constructed by the controller + * WASM's `signExecuteFromOutside`. The WASM — not the dapp — chooses every + * envelope field: + * - `caller`: always the 'ANY_CALLER' short string felt + * (`0x...414e595f43414c4c4552`), i.e. any relayer may submit it. + * - `execute_after`: always `0`. + * - `execute_before`: the WASM's wall clock + 600 seconds. + * - `nonce`: a `[channel, bitmask]` pair — a fresh random felt channel with + * bitmask `0x1`. Note this is the V3 two-felt nonce, not the single-felt + * V1/V2 nonce. + * + * `execute_after`/`execute_before` are JS numbers (Rust `u64`); all felts are + * 0x-prefixed, zero-padded hex strings. + */ +export type SignedOutsideExecutionV3 = { + caller: string; + execute_after: number; + execute_before: number; + calls: SignedOutsideExecutionCall[]; + nonce: [string, string]; +}; + +/** + * Wire type returned by the keychain's `signExecuteFromOutside` RPC method. + * Mirrors the controller WASM's `JsSignedOutsideExecution`. + */ +export type SignedOutsideExecution = { + outside_execution: SignedOutsideExecutionV3; + signature: string[]; +}; + +/** + * Options accepted by `ControllerAccount.getOutsideTransaction` purely for + * signature compatibility with starknet.js `Account.getOutsideTransaction`. + * The controller WASM decides the actual envelope (caller, nonce and + * execution window — see {@link SignedOutsideExecutionV3}); these values are + * NOT forwarded to the signer. + */ +export type ControllerOutsideExecutionOptions = { + caller?: string; + execute_after?: number; + execute_before?: number; + nonce?: string; + version?: string; +}; + +/** + * A signed — but not submitted — SNIP-9 V3 outside execution, shaped like + * starknet.js's `OutsideTransaction` so existing relaying code can consume + * it. `nonce` is always the V3 `[channel, bitmask]` pair and + * `execute_after`/`execute_before` are always numbers at runtime (the wider + * unions exist for structural compatibility with V1/V2-style consumers). + */ +export type ControllerOutsideTransaction = { + outsideExecution: { + caller: string; + nonce: string | [string, string]; + execute_after: number | string; + execute_before: number | string; + calls: { to: string; selector: string; calldata: string[] }[]; + }; + signature: string[]; + signerAddress: string; + version: "3"; +}; + export interface Keychain { probe(rpcUrl: string): Promise; connect(options?: ConnectOptions): Promise; @@ -187,6 +270,19 @@ export interface Keychain { account: string, async?: boolean, ): Promise; + /** + * Sign (without submitting) a SNIP-9 V3 outside execution over `calls`. + * The WASM chooses the envelope (ANY_CALLER, execute_after 0, + * execute_before now+600s, random `[channel, 0x1]` nonce pair). + * + * NOTE: keychain deployments built before this method existed do not + * register it with penpal, in which case the property is absent from the + * connected proxy at runtime — callers must capability-check before + * invoking (see `ControllerAccount.getOutsideTransaction`). + */ + signExecuteFromOutside( + calls: Call[], + ): Promise; updateSession( policies?: SessionPolicies, preset?: string, diff --git a/packages/keychain/src/utils/connection/index.ts b/packages/keychain/src/utils/connection/index.ts index 758a22d65a..56f3594bdf 100644 --- a/packages/keychain/src/utils/connection/index.ts +++ b/packages/keychain/src/utils/connection/index.ts @@ -13,6 +13,7 @@ import { headlessConnect } from "./headless"; import { probe } from "./probe"; import { openSettingsFactory } from "./settings"; import { signMessageFactory } from "./sign"; +import { signExecuteFromOutsideFactory } from "./sign-execute-from-outside"; import { switchChain } from "./switchChain"; import { navigateFactory } from "./navigate"; import { updateSession } from "./update-session"; @@ -140,6 +141,10 @@ export function connectToController< navigate, }), ), + // Sign (without submitting) a SNIP-9 V3 outside execution. Dapps + // capability-detect this method on the penpal proxy, so its presence + // here is what advertises outside-execution signing support. + signExecuteFromOutside: () => signExecuteFromOutsideFactory(), openSettings: () => openSettingsFactory(), navigate: () => navigateFactory(), reset: () => () => { diff --git a/packages/keychain/src/utils/connection/sign-execute-from-outside.ts b/packages/keychain/src/utils/connection/sign-execute-from-outside.ts new file mode 100644 index 0000000000..fc5ede265d --- /dev/null +++ b/packages/keychain/src/utils/connection/sign-execute-from-outside.ts @@ -0,0 +1,47 @@ +import type { + ConnectError, + ControllerError, + SignedOutsideExecution, +} from "@cartridge/controller"; +import { ResponseCodes } from "@cartridge/controller"; +import type { Call } from "starknet"; + +import { parseControllerError } from "./execute"; + +/** + * Keychain RPC handler for `signExecuteFromOutside`: signs — but does NOT + * submit — a SNIP-9 V3 outside execution over `calls` via the controller + * WASM (`Controller.signExecuteFromOutside`). The WASM chooses the envelope: + * caller 'ANY_CALLER', execute_after 0, execute_before now+600s, and a fresh + * random `[channel, 0x1]` V3 nonce pair. + * + * Mirrors the error contract of sibling handlers (`execute`, `signMessage`): + * failures are returned as `ConnectError` objects rather than thrown, so the + * dapp side can branch on `code`. + */ +export function signExecuteFromOutsideFactory() { + return async ( + calls: Call[], + ): Promise => { + const controller = window.controller; + + if (!controller) { + return { + code: ResponseCodes.NOT_CONNECTED, + message: "Controller not connected", + }; + } + + try { + return (await controller.signExecuteFromOutside( + calls, + )) as SignedOutsideExecution; + } catch (e) { + return { + code: ResponseCodes.ERROR, + message: (e as Error).message, + error: parseControllerError(e as ControllerError & { code: number }), + }; + } + }; +}