Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/connector/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 7 additions & 7 deletions packages/controller/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cartridge/controller",
"version": "0.13.12",
"version": "0.13.12-provable-oe.1",
"description": "Cartridge Controller",
"repository": {
"type": "git",
Expand Down Expand Up @@ -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:*",
Expand Down
198 changes: 198 additions & 0 deletions packages/controller/src/__tests__/getOutsideTransaction.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<keyof Keychain, unknown>>) {
// 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<Keychain>,
{},
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/,
);
});
});
91 changes: 91 additions & 0 deletions packages/controller/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Call>,
): Promise<ControllerOutsideTransaction> {
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;
Comment on lines +226 to +241

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If response is null or undefined, or if the returned object does not contain outside_execution, destructuring or accessing its properties will throw a TypeError at runtime. Adding defensive checks for response and outside_execution ensures the application handles unexpected or malformed RPC responses gracefully.

    const response = await this.keychain.signExecuteFromOutside(toArray(calls));

    if (!response) {
      throw new Error("No response received from signExecuteFromOutside");
    }

    if (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;

    if (!outside_execution) {
      throw new Error("Invalid response from signExecuteFromOutside: missing outside_execution");
    }


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
Expand Down
29 changes: 29 additions & 0 deletions packages/controller/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
Loading