diff --git a/packages/controller/src/account.ts b/packages/controller/src/account.ts index 419ac85df7..8befb27996 100644 --- a/packages/controller/src/account.ts +++ b/packages/controller/src/account.ts @@ -12,11 +12,13 @@ import { KeychainOptions, Modal, ResponseCodes, + createUserRejectedError, } from "./types"; import { AsyncMethodReturns } from "@cartridge/penpal"; import BaseProvider from "./provider"; import { toArray } from "./utils"; -import { SIGNATURE } from "@starknet-io/types-js"; +import { Signature } from "@starknet-io/types-js"; +import { KeychainIFrame } from "./iframe"; class ControllerAccount extends WalletAccount { private keychain: AsyncMethodReturns; @@ -84,22 +86,50 @@ class ControllerAccount extends WalletAccount { // Session call or Paymaster flow failed. // Session not avaialble, manual flow fallback this.modal.open(); - const manualExecute = await this.keychain.execute( - calls, - undefined, - undefined, - true, - (sessionExecute as ConnectError).error, - ); - // Manual call succeeded - if (manualExecute.code === ResponseCodes.SUCCESS) { - resolve(manualExecute as InvokeFunctionResponse); - this.modal.close(); - return; + // Check if modal supports cancellation (is a KeychainIFrame) + if (this.modal instanceof KeychainIFrame) { + const executePromise = this.keychain.execute( + calls, + undefined, + undefined, + true, + (sessionExecute as ConnectError).error, + ); + + const resultPromise = this.modal.withCancellation(executePromise, () => + reject(createUserRejectedError()), + ); + + const manualExecute = await resultPromise; + + // Manual call succeeded + if (manualExecute.code === ResponseCodes.SUCCESS) { + resolve(manualExecute as InvokeFunctionResponse); + this.modal.close(); + return; + } + + reject((manualExecute as ConnectError).error); + } else { + // Fallback for non-cancellable modals + const manualExecute = await this.keychain.execute( + calls, + undefined, + undefined, + true, + (sessionExecute as ConnectError).error, + ); + + // Manual call succeeded + if (manualExecute.code === ResponseCodes.SUCCESS) { + resolve(manualExecute as InvokeFunctionResponse); + this.modal.close(); + return; + } + + reject((manualExecute as ConnectError).error); } - - reject((manualExecute as ConnectError).error); return; }); } @@ -112,26 +142,50 @@ class ControllerAccount extends WalletAccount { * @returns the signature of the JSON object * @throws {Error} if the JSON object is not a valid JSON */ - async signMessage(typedData: TypedData): Promise { + async signMessage(typedData: TypedData): Promise { return new Promise(async (resolve, reject) => { const sessionSign = await this.keychain.signMessage(typedData, "", true); // Session sign succeeded if (!("code" in sessionSign)) { - resolve(sessionSign as SIGNATURE); + resolve(sessionSign as Signature); return; } // Session not avaialble, manual flow fallback this.modal.open(); - const manualSign = await this.keychain.signMessage(typedData, "", false); - if (!("code" in manualSign)) { - resolve(manualSign as SIGNATURE); + // Check if modal supports cancellation (is a KeychainIFrame) + if (this.modal instanceof KeychainIFrame) { + const signPromise = this.keychain.signMessage(typedData, "", false); + + const resultPromise = this.modal.withCancellation(signPromise, () => + reject(createUserRejectedError()), + ); + + const manualSign = await resultPromise; + + if (!("code" in manualSign)) { + resolve(manualSign as Signature); + } else { + reject((manualSign as ConnectError).error); + } + this.modal.close(); } else { - reject((manualSign as ConnectError).error); + // Fallback for non-cancellable modals + const manualSign = await this.keychain.signMessage( + typedData, + "", + false, + ); + + if (!("code" in manualSign)) { + resolve(manualSign as Signature); + } else { + reject((manualSign as ConnectError).error); + } + this.modal.close(); } - this.modal.close(); }); } } diff --git a/packages/controller/src/controller.ts b/packages/controller/src/controller.ts index 849446cd6c..7a80df87d1 100644 --- a/packages/controller/src/controller.ts +++ b/packages/controller/src/controller.ts @@ -23,6 +23,7 @@ import { ProfileContextTypeVariant, ResponseCodes, StarterPack, + createUserRejectedError, } from "./types"; import { parseChainId } from "./utils"; @@ -32,6 +33,7 @@ export default class ControllerProvider extends BaseProvider { private iframes: IFrames; private selectedChain: ChainId; private chains: Map; + private pendingConnectRejection?: (error: any) => void; isReady(): boolean { return !!this.keychain; @@ -164,8 +166,11 @@ export default class ControllerProvider extends BaseProvider { this.iframes.keychain.open(); - try { - let response = await this.keychain.connect( + return new Promise((resolve, reject) => { + // Track this pending connection for cancellation + this.pendingConnectRejection = reject; + + this.keychain!.connect( // Policy precedence logic: // 1. If shouldOverridePresetPolicies is true and policies are provided, use policies // 2. Otherwise, if preset is defined, use empty object (let preset take precedence) @@ -177,27 +182,38 @@ export default class ControllerProvider extends BaseProvider { : this.options.policies || {}, this.rpcUrl(), this.options.signupOptions, - ); - if (response.code !== ResponseCodes.SUCCESS) { - throw new Error(response.message); - } - - response = response as ConnectReply; - this.account = new ControllerAccount( - this, - this.rpcUrl(), - response.address, - this.keychain, - this.options, - this.iframes.keychain, - ); + ) + .then((response) => { + // Clear the pending rejection handler + this.pendingConnectRejection = undefined; + + if (response.code !== ResponseCodes.SUCCESS) { + this.iframes.keychain!.close(); + reject(new Error(response.message)); + return; + } + + const connectReply = response as ConnectReply; + this.account = new ControllerAccount( + this, + this.rpcUrl(), + connectReply.address, + this.keychain!, + this.options, + this.iframes.keychain!, + ); - return this.account; - } catch (e) { - console.log(e); - } finally { - this.iframes.keychain.close(); - } + this.iframes.keychain!.close(); + resolve(this.account); + }) + .catch((error) => { + // Clear the pending rejection handler + this.pendingConnectRejection = undefined; + this.iframes.keychain!.close(); + console.log(error); + reject(error); + }); + }); } async switchStarknetChain(chainId: string): Promise { @@ -443,6 +459,13 @@ export default class ControllerProvider extends BaseProvider { return new KeychainIFrame({ ...this.options, onClose: this.keychain?.reset, + onCancel: () => { + // User cancelled by clicking outside - reject pending operations + if (this.pendingConnectRejection) { + this.pendingConnectRejection(createUserRejectedError()); + this.pendingConnectRejection = undefined; + } + }, onConnect: (keychain) => { this.keychain = keychain; }, diff --git a/packages/controller/src/iframe/base.ts b/packages/controller/src/iframe/base.ts index 900e08c8aa..1b39c7e24b 100644 --- a/packages/controller/src/iframe/base.ts +++ b/packages/controller/src/iframe/base.ts @@ -14,6 +14,7 @@ export class IFrame implements Modal { private iframe?: HTMLIFrameElement; private container?: HTMLDivElement; private onClose?: () => void; + private onCancel?: () => void; private child?: AsyncMethodReturns; private closeTimeout?: NodeJS.Timeout; @@ -22,12 +23,14 @@ export class IFrame implements Modal { url, preset, onClose, + onCancel, onConnect, methods = {}, }: Pick & { id: string; url: URL; onClose?: () => void; + onCancel?: () => void; onConnect: (child: AsyncMethodReturns) => void; methods?: { [key: string]: (...args: any[]) => void }; }) { @@ -76,6 +79,7 @@ export class IFrame implements Modal { // Add click event listener to close iframe when clicking outside container.addEventListener("click", (e) => { if (e.target === container) { + // User clicked outside - this is a cancellation // Attempting to reset(clear context) for keychain iframe (identified by ID) if (id === "controller-keychain" && this.child) { // Type assertion for keychain child only @@ -83,6 +87,8 @@ export class IFrame implements Modal { .reset?.() .catch((e: any) => console.error("Error resetting context:", e)); } + // Call onCancel to notify about user cancellation + this.onCancel?.(); this.close(); } }); @@ -129,6 +135,7 @@ export class IFrame implements Modal { } this.onClose = onClose; + this.onCancel = onCancel; } open() { @@ -201,4 +208,24 @@ export class IFrame implements Modal { isOpen() { return this.container?.style.display !== "none"; } + + // Register a one-time cancellation handler for the current operation + withCancellation(operation: Promise, onCancel: () => void): Promise { + // Store the original onCancel + const originalOnCancel = this.onCancel; + + // Set a one-time handler that includes the provided callback + this.onCancel = () => { + onCancel(); + // Restore the original handler + this.onCancel = originalOnCancel; + // Call the original if it exists + originalOnCancel?.(); + }; + + // When the operation completes (success or failure), restore the original handler + return operation.finally(() => { + this.onCancel = originalOnCancel; + }); + } } diff --git a/packages/controller/src/iframe/keychain.ts b/packages/controller/src/iframe/keychain.ts index d67a2fb35b..75da3577fa 100644 --- a/packages/controller/src/iframe/keychain.ts +++ b/packages/controller/src/iframe/keychain.ts @@ -6,6 +6,7 @@ import { IFrame, IFrameOptions } from "./base"; type KeychainIframeOptions = IFrameOptions & KeychainOptions & { version?: string; + onCancel?: () => void; }; export class KeychainIFrame extends IFrame { diff --git a/packages/controller/src/provider.ts b/packages/controller/src/provider.ts index 4c150e57a4..c9c91efea8 100644 --- a/packages/controller/src/provider.ts +++ b/packages/controller/src/provider.ts @@ -7,7 +7,7 @@ import { StarknetWindowObject, SwitchStarknetChainParameters, TypedData, - UNEXPECTED_ERROR, + UNKNOWN_ERROR, WalletEventHandlers, WalletEventListener, WalletEvents, @@ -90,10 +90,9 @@ export default abstract class BaseProvider implements StarknetWindowObject { case "wallet_watchAsset": throw { - code: 63, - message: "An unexpected error occurred", - data: "wallet_watchAsset not implemented", - } as UNEXPECTED_ERROR; + code: 163, + message: "An error occurred (UNKNOWN_ERROR)", + } as UNKNOWN_ERROR; case "wallet_addStarknetChain": { let params = call.params as AddStarknetChainParameters; @@ -108,28 +107,25 @@ export default abstract class BaseProvider implements StarknetWindowObject { case "wallet_requestChainId": if (!this.account) { throw { - code: 63, - message: "An unexpected error occurred", - data: "Account not initialized", - } as UNEXPECTED_ERROR; + code: 163, + message: "An error occurred (UNKNOWN_ERROR)", + } as UNKNOWN_ERROR; } return await this.account.getChainId(); case "wallet_deploymentData": throw { - code: 63, - message: "An unexpected error occurred", - data: "wallet_deploymentData not implemented", - } as UNEXPECTED_ERROR; + code: 163, + message: "An error occurred (UNKNOWN_ERROR)", + } as UNKNOWN_ERROR; case "wallet_addInvokeTransaction": if (!this.account) { throw { - code: 63, - message: "An unexpected error occurred", - data: "Account not initialized", - } as UNEXPECTED_ERROR; + code: 163, + message: "An error occurred (UNKNOWN_ERROR)", + } as UNKNOWN_ERROR; } let params = call.params as AddInvokeTransactionParameters; @@ -143,18 +139,16 @@ export default abstract class BaseProvider implements StarknetWindowObject { case "wallet_addDeclareTransaction": throw { - code: 63, - message: "An unexpected error occurred", - data: "wallet_addDeclareTransaction not implemented", - } as UNEXPECTED_ERROR; + code: 163, + message: "An error occurred (UNKNOWN_ERROR)", + } as UNKNOWN_ERROR; case "wallet_signTypedData": { if (!this.account) { throw { - code: 63, - message: "An unexpected error occurred", - data: "Account not initialized", - } as UNEXPECTED_ERROR; + code: 163, + message: "An error occurred (UNKNOWN_ERROR)", + } as UNKNOWN_ERROR; } return await this.account.signMessage(call.params as TypedData); @@ -166,10 +160,9 @@ export default abstract class BaseProvider implements StarknetWindowObject { return []; default: throw { - code: 63, - message: "An unexpected error occurred", - data: `Unknown RPC call type: ${call.type}`, - } as UNEXPECTED_ERROR; + code: 163, + message: "An error occurred (UNKNOWN_ERROR)", + } as UNKNOWN_ERROR; } }; diff --git a/packages/controller/src/types.ts b/packages/controller/src/types.ts index 293bd99e0c..a010612bd4 100644 --- a/packages/controller/src/types.ts +++ b/packages/controller/src/types.ts @@ -4,6 +4,13 @@ import { ChainId, Signature, TypedData, + Address, + AccountDeploymentData, + USER_REFUSED_OP, + INVALID_REQUEST_PAYLOAD, + API_VERSION_NOT_SUPPORTED, + UNKNOWN_ERROR, + ACCOUNT_ALREADY_DEPLOYED, } from "@starknet-io/types-js"; import { Abi, @@ -48,10 +55,18 @@ export enum ResponseCodes { USER_INTERACTION_REQUIRED = "USER_INTERACTION_REQUIRED", } +// Standard wallet API error types +export type WalletApiError = + | USER_REFUSED_OP + | INVALID_REQUEST_PAYLOAD + | API_VERSION_NOT_SUPPORTED + | UNKNOWN_ERROR + | ACCOUNT_ALREADY_DEPLOYED; + export type ConnectError = { code: ResponseCodes; message: string; - error?: ControllerError; + error?: ControllerError | WalletApiError; }; export type ControllerError = { @@ -60,9 +75,21 @@ export type ControllerError = { data?: any; }; +// Helper function to create USER_REFUSED_OP error +export function createUserRejectedError(): ConnectError { + return { + code: ResponseCodes.CANCELED, + message: "User rejected the operation", + error: { + code: 113, + message: "An error occurred (USER_REFUSED_OP)", + } as USER_REFUSED_OP, + }; +} + export type ConnectReply = { code: ResponseCodes.SUCCESS; - address: string; + address: Address; policies?: SessionPolicies; }; @@ -76,13 +103,14 @@ export type ExecuteReply = export type ProbeReply = { code: ResponseCodes.SUCCESS; - address: string; + address: Address; rpcUrl?: string; }; export type DeployReply = { code: ResponseCodes.SUCCESS; transaction_hash: string; + deployment_data?: AccountDeploymentData; }; export type IFrames = { diff --git a/packages/controller/src/wallets/argent/index.ts b/packages/controller/src/wallets/argent/index.ts index 5e6987dbc4..0ee813527f 100644 --- a/packages/controller/src/wallets/argent/index.ts +++ b/packages/controller/src/wallets/argent/index.ts @@ -1,4 +1,4 @@ -import { Call, TypedData, StarknetWindowObject } from "@starknet-io/types-js"; +import { TypedData, StarknetWindowObject, Call } from "@starknet-io/types-js"; import { ExternalPlatform, ExternalWallet, diff --git a/packages/controller/src/wallets/braavos/index.ts b/packages/controller/src/wallets/braavos/index.ts index 1bcf5670bd..4d4d00b54e 100644 --- a/packages/controller/src/wallets/braavos/index.ts +++ b/packages/controller/src/wallets/braavos/index.ts @@ -1,4 +1,4 @@ -import { Call, TypedData, StarknetWindowObject } from "@starknet-io/types-js"; +import { TypedData, StarknetWindowObject, Call } from "@starknet-io/types-js"; import { ExternalPlatform, ExternalWallet,