From ad8214c7f147a51a21cc675fda7f594610ef8c55 Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Mon, 21 Sep 2026 16:29:23 -0700 Subject: [PATCH] Add skip onchain cancellation feature for delegations - Introduced new UI elements for "Skip onchain cancellation" in the Wallet Configurator. - Updated the Cancel Delegation modal to handle local execution when skipping onchain cancellation. - Added new properties to the IStyleFormState interface and corresponding presets. - Implemented logic in the DelegationService to remove stored delegations without onchain submission. - Updated relevant types and interfaces to support the new feature. --- ...alletConfiguratorTextTabWalletSections.tsx | 16 +++ host/src/styleForm.ts | 14 ++ src/components/ModalHost.tsx | 1 + src/components/delegations/DelegationsTab.tsx | 10 +- .../modals/CancelDelegationModal.tsx | 121 +++++++++++++++--- .../business/DelegationService.ts | 9 +- .../interfaces/business/IDelegationService.ts | 7 + .../events/productEvents/DelegationEvents.ts | 2 +- src/style/configureSchemas.ts | 2 + src/style/defaults.ts | 3 + src/wallet/WalletProvider.tsx | 9 +- src/wallet/modalTypes.ts | 9 +- src/wallet/useWalletBoot.ts | 11 +- 13 files changed, 185 insertions(+), 29 deletions(-) diff --git a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx index 9374783..578b274 100644 --- a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx +++ b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx @@ -323,6 +323,22 @@ export function WalletConfiguratorTextTabWalletSections({ value={form.cancelDelegationReject} onChange={(value) => patch("cancelDelegationReject", value)} /> + + patch("cancelDelegationSkipOnchainLabel", value) + } + /> + + patch("cancelDelegationSkipOnchainAcknowledgement", value) + } + /> put(cancelDelegation, "title", form.cancelDelegationTitle); put(cancelDelegation, "confirmLabel", form.cancelDelegationConfirm); put(cancelDelegation, "rejectLabel", form.cancelDelegationReject); + put(cancelDelegation, "skipOnchainLabel", form.cancelDelegationSkipOnchainLabel); + put( + cancelDelegation, + "skipOnchainAcknowledgement", + form.cancelDelegationSkipOnchainAcknowledgement, + ); if (Object.keys(cancelDelegation).length > 0) { copy.cancelDelegation = cancelDelegation; } diff --git a/src/components/ModalHost.tsx b/src/components/ModalHost.tsx index f390c72..7849995 100644 --- a/src/components/ModalHost.tsx +++ b/src/components/ModalHost.tsx @@ -114,6 +114,7 @@ export function ModalHost() { Promise; + executeLocal: () => Promise; onRegisterAwaitingConfirmation?: (notify: () => void) => void; - onResolve: (hash: EVMTransactionHash) => void; + onResolve: (hash: EVMTransactionHash | null) => void; onReject: (error: unknown) => void; }) { const { style } = useStyle(); const copy = style.copy.cancelDelegation; const relayerCopy = style.copy.relayerSubmit; + const [skipOnchain, setSkipOnchain] = useState(false); + const [localBusy, setLocalBusy] = useState(false); + const [localError, setLocalError] = useState(null); + + const rejectMessage = "User rejected canceling the permission"; const submit = useRelayerConfirmSubmit({ execute, onRegisterAwaitingConfirmation, onResolve, onReject, - rejectMessage: "User rejected canceling the permission", + rejectMessage, retainDisplayDuringSubmit: true, signingMessage: relayerCopy.signingMessage, waitingMessage: relayerCopy.waitingMessage, @@ -48,31 +60,71 @@ export function CancelDelegationModal({ .replace("{domain}", request.domain) .replace("{chainName}", request.chainName); + const showConfirmActions = + skipOnchain || + submit.phase === "confirm" || + submit.phase === "finalFee"; + + const canConfirm = skipOnchain + ? !localBusy + : submit.canConfirm; + + const onConfirm = () => { + if (skipOnchain) { + setLocalError(null); + setLocalBusy(true); + void executeLocal() + .then(() => onResolve(null)) + .catch((error: unknown) => { + setLocalBusy(false); + setLocalError( + error instanceof Error ? error.message : String(error), + ); + }); + return; + } + if (submit.phase === "finalFee") { + submit.confirmFinalFee(); + } else { + submit.startSubmit(); + } + }; + + const onCancel = () => { + if (skipOnchain) { + onReject(new OwsUserRejectedError(rejectMessage)); + return; + } + submit.cancel(); + }; + return ( {request.chainName} - + {!skipOnchain ? ( + + ) : null} + {request.allowSkipOnchain ? ( +
+ + {skipOnchain ? ( +

+ {copy.skipOnchainAcknowledgement} +

+ ) : null} + {localError ? ( +

+ {localError} +

+ ) : null} +
+ ) : null}
); } diff --git a/src/lib/implementations/business/DelegationService.ts b/src/lib/implementations/business/DelegationService.ts index 17c03c3..6980d7b 100644 --- a/src/lib/implementations/business/DelegationService.ts +++ b/src/lib/implementations/business/DelegationService.ts @@ -61,7 +61,7 @@ import type { ISignedDelegation, IStoredDelegation, } from "../../types/domain/StoredDelegation"; -import { makeDelegationId } from "../../types/primitives/DelegationId"; +import { makeDelegationId, type DelegationId } from "../../types/primitives/DelegationId"; import { EPasskeyPromptReason } from "../../types/enum/EPasskeyPromptReason"; import { withCeremonyUiReason } from "../../../wallet/ceremonyUiOverrideStore"; import { withCoalescedSignDigest } from "../../../wallet/withCoalescedSignDigest"; @@ -256,6 +256,13 @@ export class DelegationService implements IDelegationService { return { ...result, deletedDelegationId }; } + async removeStoredDelegation( + stored: IStoredDelegation, + ): Promise { + await this.delegationRepository.deleteDelegation(stored.delegationId); + return stored.delegationId; + } + private async resolveCancelDelegation(params: { chainId: EVMChainId; stored?: IStoredDelegation; diff --git a/src/lib/interfaces/business/IDelegationService.ts b/src/lib/interfaces/business/IDelegationService.ts index 785f56e..863bea7 100644 --- a/src/lib/interfaces/business/IDelegationService.ts +++ b/src/lib/interfaces/business/IDelegationService.ts @@ -86,6 +86,13 @@ export interface IDelegationService { params: ICancelDelegationParams, ): Promise; + /** + * Remove a vault row (local cache + relayer blob) without submitting + * on-chain `disableDelegation`. The signed delegation remains usable + * by anyone who still holds it. + */ + removeStoredDelegation(stored: IStoredDelegation): Promise; + getSupportedExecutionPermissions(): Promise; getGrantedExecutionPermissions(): Promise; diff --git a/src/lib/types/events/productEvents/DelegationEvents.ts b/src/lib/types/events/productEvents/DelegationEvents.ts index 6366b0a..38a36a0 100644 --- a/src/lib/types/events/productEvents/DelegationEvents.ts +++ b/src/lib/types/events/productEvents/DelegationEvents.ts @@ -46,7 +46,7 @@ export class DelegationCancelledEvent extends OWSAnalyticsEvent { hostDomain: DomainString, public readonly accountAddress: EVMAccountAddress, public readonly chainId: EVMChainId, - public readonly txHash: EVMTransactionHash, + public readonly txHash: EVMTransactionHash | null, public readonly durationMs: number, ) { super(EAnalyticsEventName.DelegationCancelled, hostDomain); diff --git a/src/style/configureSchemas.ts b/src/style/configureSchemas.ts index 4dd4177..b7e15c5 100644 --- a/src/style/configureSchemas.ts +++ b/src/style/configureSchemas.ts @@ -325,6 +325,8 @@ export const styleCopyCancelDelegationSchema = z.strictObject({ confirmLabel: z.string(), signingMessage: z.string(), waitingMessage: z.string(), + skipOnchainLabel: z.string(), + skipOnchainAcknowledgement: z.string(), }); /** Shared relayer TX confirm phases (estimate → sign → final fee → submit). */ diff --git a/src/style/defaults.ts b/src/style/defaults.ts index ea57f9c..4d9fe7c 100644 --- a/src/style/defaults.ts +++ b/src/style/defaults.ts @@ -323,6 +323,9 @@ export const DEFAULT_STYLE: IResolvedStyle = { confirmLabel: "Cancel permission", signingMessage: "Confirm in the signing panel…", waitingMessage: "Waiting for on-chain confirmation…", + skipOnchainLabel: "Skip onchain cancellation", + skipOnchainAcknowledgement: + "I acknowledge that this delegation may still be used onchain by anybody that holds it, and that canceling it without submitting an onchain cancellation will only remove it from my wallet", }, relayerSubmit: { finalFeeNotice: diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index 7bdd806..9a8a132 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -261,12 +261,13 @@ export type WalletContextValue = { /** * In-wallet cancel from the Delegations tab. Opens the same confirm modal as * `wallet_revokeExecutionPermission`, then deletes the vault row on success. + * `transactionHash` is null when the user skipped on-chain cancellation. */ cancelStoredDelegation: ( delegationId: DelegationId, ) => Promise<{ chainId: EVMChainId; - transactionHash: EVMTransactionHash; + transactionHash: EVMTransactionHash | null; }>; listTrackedAssets: (chainId?: EVMChainId) => Promise; addTrackedAsset: ( @@ -704,7 +705,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { chainId: stored.chainId, stored, }); - const transactionHash = await pushModal( + const transactionHash = await pushModal( ({ id, resolve, reject }) => ({ id, kind: "cancelDelegation", @@ -714,6 +715,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { chainId: stored.chainId, ownerAddress: owner, work: cancelWork, + allowSkipOnchain: true, }, execute: async (payment: IRelayerConfirmSendResult, ui) => { const result = await delegationService.cancelDelegation({ @@ -725,6 +727,9 @@ export function WalletProvider({ children }: { children: ReactNode }) { }); return result.transactionHash; }, + executeLocal: async () => { + await delegationService.removeStoredDelegation(stored); + }, resolve, reject, }), diff --git a/src/wallet/modalTypes.ts b/src/wallet/modalTypes.ts index 36e93a4..5cc4f7d 100644 --- a/src/wallet/modalTypes.ts +++ b/src/wallet/modalTypes.ts @@ -89,6 +89,11 @@ export interface ICancelDelegationConfirmRequest { ownerAddress: EVMAccountAddress; /** ExactCalldata work for unsigned fee estimate. */ work: ITransactionWork; + /** + * When true, the modal offers “Skip onchain cancellation” (vault delete + * only). Requires a stored vault row. + */ + allowSkipOnchain: boolean; } export type ModalRequest = @@ -185,8 +190,10 @@ export type ModalRequest = payment: IRelayerConfirmSendResult, ui: IRelayerSendUiCallbacks, ) => Promise; + /** Vault-only delete when the user skips on-chain cancel. */ + executeLocal: () => Promise; onRegisterAwaitingConfirmation?: (notify: () => void) => void; - resolve: (hash: EVMTransactionHash) => void; + resolve: (hash: EVMTransactionHash | null) => void; reject: (error: unknown) => void; } | { diff --git a/src/wallet/useWalletBoot.ts b/src/wallet/useWalletBoot.ts index 7c88fd5..7ef3808 100644 --- a/src/wallet/useWalletBoot.ts +++ b/src/wallet/useWalletBoot.ts @@ -516,7 +516,7 @@ export function useWalletBoot({ await runWithAnalytics( (event) => eventBus.emitAnalytics(event), async () => { - const txHash = await ask( + const txHash = await ask( ({ id, resolve, reject }) => ({ id, kind: "cancelDelegation", @@ -526,6 +526,7 @@ export function useWalletBoot({ chainId, ownerAddress: owner, work: cancelWork, + allowSkipOnchain: Boolean(stored), }, execute: async (payment: IRelayerConfirmSendResult, ui) => { const result = await delegationService.cancelDelegation({ @@ -538,6 +539,14 @@ export function useWalletBoot({ }); return result.transactionHash; }, + executeLocal: async () => { + if (!stored) { + throw new Error( + "Skip onchain cancellation requires a stored permission", + ); + } + await delegationService.removeStoredDelegation(stored); + }, resolve, reject, }),