feat: expose signExecuteFromOutside via keychain RPC + ControllerAccount.getOutsideTransaction#1
feat: expose signExecuteFromOutside via keychain RPC + ControllerAccount.getOutsideTransaction#1loothero wants to merge 1 commit into
Conversation
…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>
There was a problem hiding this comment.
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.
| // 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(), |
There was a problem hiding this comment.
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.
| // 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(), |
| 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; |
There was a problem hiding this comment.
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");
}| } catch (e) { | ||
| return { | ||
| code: ResponseCodes.ERROR, | ||
| message: (e as Error).message, | ||
| error: parseControllerError(e as ControllerError & { code: number }), | ||
| }; | ||
| } |
There was a problem hiding this comment.
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 }),
};
}
Summary
Exposes the controller WASM's existing
signExecuteFromOutsideend-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
packages/keychain/src/utils/connection/sign-execute-from-outside.ts, registered inconnection/index.ts): new penpal RPC methodsignExecuteFromOutside(calls)that wraps the existing keychain wrapperController.signExecuteFromOutside(packages/keychain/src/utils/controller.ts), returningConnectErrorobjects on failure per the house error contract (NOT_CONNECTEDwhen no controller,ERROR+parseControllerErrorotherwise).packages/controller/src/account.ts):ControllerAccount.getOutsideTransaction(options, calls)override. Calls the keychain RPC and maps the WASM result to the starknet.jsOutsideTransactionshape:{ outsideExecution: { caller, nonce, execute_after, execute_before, calls: [{ to, selector, calldata }] }, signature, signerAddress, version: "3" }. The V3[channel, bitmask]nonce pair is preserved verbatim. Theoptionsargument is accepted only for signature compatibility with starknet.js — the WASM decides the whole envelope.packages/controller/src/types.ts):signExecuteFromOutsideadded to theKeychaininterface plusSignedOutsideExecution/SignedOutsideExecutionV3wire types mirroring the WASM'sJsSignedOutsideExecution, and the dapp-facingControllerOutsideTransaction/ControllerOutsideExecutionOptionstypes.packages/controller/src/errors.ts): newControllerOutsideExecutionUnsupportederror, detectable viaerror.name(survives bundler module duplication).packages/controller/src/session/account.ts):SessionAccount.getOutsideTransaction()now throwsControllerOutsideExecutionUnsupportedinstead of letting the inherited starknet.js implementation run — that implementation calls the account's ownsignMessage, which routes back through the session provider and recurses.0.13.12-provable-oe.1to 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.ggtoday) never registersignExecuteFromOutside, so the property is simply absent from the proxy at runtime.getOutsideTransactioncheckstypeof this.keychain.signExecuteFromOutside === "function"and throws the typedControllerOutsideExecutionUnsupportederror when absent, so dapps degrade gracefully instead of hitting aTypeError.ControllerOutsideExecutionUnsupportedfires in three cases:SessionAccount(connector idcontroller_session);ControllerAccountwhen the connected keychain does not expose the RPC method;ControllerAccountwhen the keychain repliesNOT_CONNECTED(no logged-in controller).WASM envelope verification (VR-1)
From
cartridge-gg/controller-rsaccount-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:0execute_before:now + 600secondsnonce:(SigningKey::from_random().secret_scalar(), 1)— a fresh random[channel, 0x1]V3 pairSerialization (
account-wasm/src/types/outside_execution.rs,types/mod.rs): felts are 0x-prefixed zero-padded hex viato_fixed_hex_string();execute_after/execute_beforeare u64 → JS numbers; returnedcalls[].entrypointcarries the selector felt as a decimal string (FeltDisplay), which this PR maps to theselectorfield — consumers must treat it as a felt, not a name.A live probe of the target relay (a
cartridge_addExecuteOutsideTransactionJSON-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
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_CONNECTEDmapping, plain-error passthrough for other keychain failures, and the session-account throw.tsc -bclean 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