Skip to content
Merged
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
16 changes: 16 additions & 0 deletions host/src/components/WalletConfiguratorTextTabWalletSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,22 @@ export function WalletConfiguratorTextTabWalletSections({
value={form.cancelDelegationReject}
onChange={(value) => patch("cancelDelegationReject", value)}
/>
<TextField
id="cancel-delegation-skip-onchain-label"
label="Cancel permission skip onchain label"
value={form.cancelDelegationSkipOnchainLabel}
onChange={(value) =>
patch("cancelDelegationSkipOnchainLabel", value)
}
/>
<TextField
id="cancel-delegation-skip-onchain-ack"
label="Cancel permission skip onchain acknowledgement"
value={form.cancelDelegationSkipOnchainAcknowledgement}
onChange={(value) =>
patch("cancelDelegationSkipOnchainAcknowledgement", value)
}
/>
<TextField
id="transfer-tokens-sent-title"
label="Transfer sent title"
Expand Down
14 changes: 14 additions & 0 deletions host/src/styleForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ export interface IStyleFormState {
cancelDelegationTitle: string;
cancelDelegationConfirm: string;
cancelDelegationReject: string;
cancelDelegationSkipOnchainLabel: string;
cancelDelegationSkipOnchainAcknowledgement: string;

// Text — Passkey ceremony overlays
passkeyPromptUnlockTitle: string;
Expand Down Expand Up @@ -448,6 +450,9 @@ export const ACME_PRESET: IStyleFormState = {
cancelDelegationTitle: "Cancel permission",
cancelDelegationConfirm: "Cancel permission",
cancelDelegationReject: "Keep",
cancelDelegationSkipOnchainLabel: "Skip onchain cancellation",
cancelDelegationSkipOnchainAcknowledgement:
"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",
passkeyPromptUnlockTitle: "Unlock with passkey",
passkeyPromptCreateTitle: "Create passkey",
passkeyPromptSignTitle: "Confirm with passkey",
Expand Down Expand Up @@ -647,6 +652,9 @@ export const DEFAULTS_PRESET: IStyleFormState = {
cancelDelegationTitle: "Cancel permission",
cancelDelegationConfirm: "Cancel permission",
cancelDelegationReject: "Keep",
cancelDelegationSkipOnchainLabel: "Skip onchain cancellation",
cancelDelegationSkipOnchainAcknowledgement:
"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",
passkeyPromptUnlockTitle: "Unlock with passkey",
passkeyPromptCreateTitle: "Create passkey",
passkeyPromptSignTitle: "Confirm with passkey",
Expand Down Expand Up @@ -959,6 +967,12 @@ function buildNestedCopyFromForm(form: IStyleFormState): Record<string, unknown>
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;
}
Expand Down
1 change: 1 addition & 0 deletions src/components/ModalHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export function ModalHost() {
<CancelDelegationModal
request={activeModal.request}
execute={activeModal.execute}
executeLocal={activeModal.executeLocal}
onRegisterAwaitingConfirmation={
activeModal.onRegisterAwaitingConfirmation
}
Expand Down
10 changes: 6 additions & 4 deletions src/components/delegations/DelegationsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ export function DelegationsTab() {
setError(null);
try {
const result = await cancelStoredDelegation(delegationId);
setSent({
chainId: result.chainId,
transactionHash: result.transactionHash,
});
if (result.transactionHash) {
setSent({
chainId: result.chainId,
transactionHash: result.transactionHash,
});
}
await reload();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
Expand Down
121 changes: 102 additions & 19 deletions src/components/modals/CancelDelegationModal.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { useState } from "react";
import type {
ICancelDelegationConfirmRequest,
IRelayerConfirmSendResult,
} from "../../wallet/modalTypes";
import type { IRelayerSendUiCallbacks } from "../../lib/types/domain/RelayerSendUi";
import type { EVMTransactionHash } from "@1shotapi/ows-types";
import {
OwsUserRejectedError,
type EVMTransactionHash,
} from "@1shotapi/ows-types";
import { useStyle } from "../../style/StyleProvider";
import { Modal } from "../Modal";
import { RelayerConfirmModalChrome } from "../RelayerConfirmModalChrome";
import { useRelayerConfirmSubmit } from "../useRelayerConfirmSubmit";

/**
* On-chain cancel / revoke confirm — collects relayer fee then runs execute.
* Optional “Skip onchain cancellation” removes the vault row only.
*/
export function CancelDelegationModal({
request,
execute,
executeLocal,
onRegisterAwaitingConfirmation,
onResolve,
onReject,
Expand All @@ -24,20 +30,26 @@ export function CancelDelegationModal({
payment: IRelayerConfirmSendResult,
ui: IRelayerSendUiCallbacks,
) => Promise<EVMTransactionHash>;
executeLocal: () => Promise<void>;
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<string | null>(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,
Expand All @@ -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 (
<Modal
title={copy.title}
onBackdropDismiss={
submit.phase === "confirm" || submit.phase === "finalFee"
? submit.cancel
: undefined
skipOnchain
? localBusy
? undefined
: onCancel
: submit.phase === "confirm" || submit.phase === "finalFee"
? submit.cancel
: undefined
}
actions={
submit.phase === "confirm" || submit.phase === "finalFee"
showConfirmActions
? [
{
label: copy.rejectLabel,
variant: "secondary",
onClick: submit.cancel,
onClick: onCancel,
disabled: skipOnchain ? localBusy : undefined,
},
{
label: copy.confirmLabel,
variant: "primary",
autoFocus: true,
disabled: !submit.canConfirm,
onClick:
submit.phase === "finalFee"
? submit.confirmFinalFee
: submit.startSubmit,
disabled: !canConfirm,
onClick: onConfirm,
},
]
: undefined
Expand All @@ -93,12 +145,43 @@ export function CancelDelegationModal({
<dd className="text-foreground m-0">{request.chainName}</dd>
</div>
</dl>
<RelayerConfirmModalChrome
chainId={request.chainId}
ownerAddress={request.ownerAddress}
work={request.work}
submit={submit}
/>
{!skipOnchain ? (
<RelayerConfirmModalChrome
chainId={request.chainId}
ownerAddress={request.ownerAddress}
work={request.work}
submit={submit}
/>
) : null}
{request.allowSkipOnchain ? (
<div className="mt-4">
<label className="flex cursor-pointer items-start gap-2.5 text-left">
<input
type="checkbox"
className="border-input bg-background text-primary mt-0.5 size-4 shrink-0 rounded border accent-[var(--primary)]"
checked={skipOnchain}
disabled={localBusy || submit.phase !== "confirm"}
onChange={(event) => {
setSkipOnchain(event.target.checked);
setLocalError(null);
}}
/>
<span className="text-muted-foreground text-[0.85rem] leading-snug">
{copy.skipOnchainLabel}
</span>
</label>
{skipOnchain ? (
<p className="text-destructive m-0 mt-2 text-[0.85rem] leading-snug">
{copy.skipOnchainAcknowledgement}
</p>
) : null}
{localError ? (
<p className="text-destructive m-0 mt-2 text-[0.85rem]" role="alert">
{localError}
</p>
) : null}
</div>
) : null}
</Modal>
);
}
9 changes: 8 additions & 1 deletion src/lib/implementations/business/DelegationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -256,6 +256,13 @@ export class DelegationService implements IDelegationService {
return { ...result, deletedDelegationId };
}

async removeStoredDelegation(
stored: IStoredDelegation,
): Promise<DelegationId> {
await this.delegationRepository.deleteDelegation(stored.delegationId);
return stored.delegationId;
}

private async resolveCancelDelegation(params: {
chainId: EVMChainId;
stored?: IStoredDelegation;
Expand Down
7 changes: 7 additions & 0 deletions src/lib/interfaces/business/IDelegationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ export interface IDelegationService {
params: ICancelDelegationParams,
): Promise<ICancelDelegationResult>;

/**
* 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<DelegationId>;

getSupportedExecutionPermissions(): Promise<SupportedExecutionPermissions>;

getGrantedExecutionPermissions(): Promise<IExecutionPermissionResponse[]>;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/types/events/productEvents/DelegationEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/style/configureSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
3 changes: 3 additions & 0 deletions src/style/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions src/wallet/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrackedAsset[]>;
addTrackedAsset: (
Expand Down Expand Up @@ -704,7 +705,7 @@ export function WalletProvider({ children }: { children: ReactNode }) {
chainId: stored.chainId,
stored,
});
const transactionHash = await pushModal<EVMTransactionHash>(
const transactionHash = await pushModal<EVMTransactionHash | null>(
({ id, resolve, reject }) => ({
id,
kind: "cancelDelegation",
Expand All @@ -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({
Expand All @@ -725,6 +727,9 @@ export function WalletProvider({ children }: { children: ReactNode }) {
});
return result.transactionHash;
},
executeLocal: async () => {
await delegationService.removeStoredDelegation(stored);
},
resolve,
reject,
}),
Expand Down
Loading
Loading