Skip to content

feat: expose signExecuteFromOutside via keychain RPC + ControllerAccount.getOutsideTransaction#1

Open
loothero wants to merge 1 commit into
mainfrom
feat/expose-sign-execute-from-outside
Open

feat: expose signExecuteFromOutside via keychain RPC + ControllerAccount.getOutsideTransaction#1
loothero wants to merge 1 commit into
mainfrom
feat/expose-sign-execute-from-outside

Conversation

@loothero

Copy link
Copy Markdown
Member

Summary

Exposes the controller WASM's existing signExecuteFromOutside end-to-end so dapps can obtain a signed (not submitted) SNIP-9 (SRC-9) V3 outside execution from the Cartridge Controller and relay it through their own gateway (e.g. a hosted-VRF gateway that wraps the execution with randomness fulfillment before submitting).

Patch

  • keychain (packages/keychain/src/utils/connection/sign-execute-from-outside.ts, registered in connection/index.ts): new penpal RPC method signExecuteFromOutside(calls) that wraps the existing keychain wrapper Controller.signExecuteFromOutside (packages/keychain/src/utils/controller.ts), returning ConnectError objects on failure per the house error contract (NOT_CONNECTED when no controller, ERROR + parseControllerError otherwise).
  • controller (packages/controller/src/account.ts): ControllerAccount.getOutsideTransaction(options, calls) override. Calls the keychain RPC and maps the WASM result to the starknet.js OutsideTransaction shape: { outsideExecution: { caller, nonce, execute_after, execute_before, calls: [{ to, selector, calldata }] }, signature, signerAddress, version: "3" }. The V3 [channel, bitmask] nonce pair is preserved verbatim. The options argument is accepted only for signature compatibility with starknet.js — the WASM decides the whole envelope.
  • controller (packages/controller/src/types.ts): signExecuteFromOutside added to the Keychain interface plus SignedOutsideExecution / SignedOutsideExecutionV3 wire types mirroring the WASM's JsSignedOutsideExecution, and the dapp-facing ControllerOutsideTransaction / ControllerOutsideExecutionOptions types.
  • controller (packages/controller/src/errors.ts): new ControllerOutsideExecutionUnsupported error, detectable via error.name (survives bundler module duplication).
  • session (packages/controller/src/session/account.ts): SessionAccount.getOutsideTransaction() now throws ControllerOutsideExecutionUnsupported instead of letting the inherited starknet.js implementation run — that implementation calls the account's own signMessage, which routes back through the session provider and recurses.
  • Versions bumped to 0.13.12-provable-oe.1 to identify this fork build (tarballs attached to the release).

Capability detection

Penpal parent proxies only contain the methods the connected keychain child registered during the handshake. Keychain deployments built before this change (including the production keychain at x.cartridge.gg today) never register signExecuteFromOutside, so the property is simply absent from the proxy at runtime. getOutsideTransaction checks typeof this.keychain.signExecuteFromOutside === "function" and throws the typed ControllerOutsideExecutionUnsupported error when absent, so dapps degrade gracefully instead of hitting a TypeError.

ControllerOutsideExecutionUnsupported fires in three cases:

  1. always, from SessionAccount (connector id controller_session);
  2. from ControllerAccount when the connected keychain does not expose the RPC method;
  3. from ControllerAccount when the keychain replies NOT_CONNECTED (no logged-in controller).

WASM envelope verification (VR-1)

From cartridge-gg/controller-rs account-wasm/src/account.rs (sign_execute_from_outside), the WASM constructs the envelope itself:

  • caller: OutsideExecutionCaller::Any — the 'ANY_CALLER' short string felt (0x...414e595f43414c4c4552)
  • execute_after: 0
  • execute_before: now + 600 seconds
  • nonce: (SigningKey::from_random().secret_scalar(), 1) — a fresh random [channel, 0x1] V3 pair

Serialization (account-wasm/src/types/outside_execution.rs, types/mod.rs): felts are 0x-prefixed zero-padded hex via to_fixed_hex_string(); execute_after/execute_before are u64 → JS numbers; returned calls[].entrypoint carries the selector felt as a decimal string (Felt Display), which this PR maps to the selector field — consumers must treat it as a felt, not a name.

A live probe of the target relay (a cartridge_addExecuteOutsideTransaction JSON-RPC gateway) confirmed it parses the {"V3": {...}} envelope with the pair nonce, ANY_CALLER caller and [0, now+600] window — the dummy request progressed to upstream paymaster validation, and a malformed V3 (single-felt nonce) was rejected at parse with -32602.

Tests

  • New packages/controller/src/__tests__/getOutsideTransaction.test.ts: contract-shaped V3 result with a mocked keychain (nonce pair preserved, window fields stay numbers, single-call normalization), typed error when the keychain lacks the method, NOT_CONNECTED mapping, plain-error passthrough for other keychain failures, and the session-account throw.
  • Full suites green: controller jest (10 suites / 58 tests), keychain vitest (38 files / 566 tests), connector vitest, ui vitest; tsc -b clean for keychain; prettier/eslint clean on touched packages.

Upstreaming note

The keychain handler change should be upstreamed to cartridge-gg/controller: dapps pointed at the default keychain URL (x.cartridge.gg) only gain this capability once Cartridge deploys a keychain built with the new RPC method. Until then the dapp-side package throws the typed unsupported error against production keychains by design.

🤖 Generated with Claude Code

…unt.getOutsideTransaction

Make ControllerAccount.getOutsideTransaction() return a signed (NOT
submitted) SNIP-9 V3 outside execution by exposing the controller WASM's
existing signExecuteFromOutside end-to-end:

- keychain: register a signExecuteFromOutside penpal RPC handler that
  wraps the existing Controller.signExecuteFromOutside WASM call and
  returns ConnectError objects on failure (house error contract).
- controller: override WalletAccount.getOutsideTransaction on
  ControllerAccount to capability-detect the method on the connected
  keychain proxy (older keychain deployments never registered it), call
  it, and map the WASM result to the starknet.js OutsideTransaction
  shape with the V3 [channel, bitmask] nonce pair preserved and
  version "3".
- session: override SessionAccount.getOutsideTransaction to fail fast
  with a typed error instead of letting the inherited starknet.js
  implementation recurse through the session provider's signMessage.
- errors: new ControllerOutsideExecutionUnsupported, detectable via
  error.name so it survives bundler module duplication.

The WASM chooses the whole envelope (caller ANY_CALLER, execute_after 0,
execute_before now+600s, fresh random V3 nonce); the options argument is
accepted only for signature compatibility and documented as ignored.

Versions bumped to 0.13.12-provable-oe.1 to identify this fork build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces SNIP-9 (SRC-9) V3 outside-execution signing support to the Cartridge Controller, adding the getOutsideTransaction method to ControllerAccount and implementing the corresponding keychain RPC handlers, types, and tests. Standalone session accounts explicitly throw an unsupported error for this feature. The review feedback highlights a critical bug in the Penpal RPC registration where wrapping the factory in an arrow function prevents the signing logic from executing. Additionally, the reviewer suggests adding defensive checks for malformed RPC responses and safely handling non-Error thrown values in catch blocks.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +144 to +147
// 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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

The signExecuteFromOutsideFactory function is a factory that returns the actual signing function async (calls: Call[]) => Promise<...> (as defined in sign-execute-from-outside.ts). By registering it as () => signExecuteFromOutsideFactory(), calling signExecuteFromOutside via the Penpal RPC will invoke the outer arrow function, which ignores the calls argument and returns the inner function. Since Penpal cannot serialize and return functions over postMessage, this will fail to execute the signing logic and return an empty/invalid response to the parent.

To fix this, register the function returned by the factory directly.

Suggested change
// 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(),
// 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(),

Comment on lines +226 to +241
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;

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");
    }

Comment on lines +39 to +45
} catch (e) {
return {
code: ResponseCodes.ERROR,
message: (e as Error).message,
error: parseControllerError(e as ControllerError & { code: number }),
};
}

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

In JavaScript/TypeScript, any value can be thrown, not just instances of Error. If a non-Error value (like a string or plain object) is thrown, attempting to access (e as Error).message may result in undefined. Using a type guard or fallback ensures a descriptive error message is always returned.

    } catch (e) {
      const message = e instanceof Error ? e.message : String(e);
      return {
        code: ResponseCodes.ERROR,
        message,
        error: parseControllerError(e as ControllerError & { code: number }),
      };
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant