diff --git a/.agents/skills/oneshot-embedded-wallet/SKILL.md b/.agents/skills/oneshot-embedded-wallet/SKILL.md index aae79a9..55e509f 100644 --- a/.agents/skills/oneshot-embedded-wallet/SKILL.md +++ b/.agents/skills/oneshot-embedded-wallet/SKILL.md @@ -3,7 +3,7 @@ name: oneshot-embedded-wallet description: >- Integrate the 1Shot embedded wallet (OWS Host Layer) with @1shotapi/ows-provider. Use when embedding wallet.1shotapi.com, wiring OWSProxy, EIP-1193, credentials, - or custom RPC such as configure / switchChain / focusWallet / addAsset / createAccount / onramp / bridge for + or custom RPC such as configure / switchChain / focusWallet / addAsset / createAccount / onramp / bridge / getUpgraded / requestCancelDelegations for theming, host-driven focus mode, tracked assets, and first-party Safari create. license: MIT metadata: @@ -320,6 +320,25 @@ await proxy.rpc("onramp", { Returns `{ ok: true }` when the user closes the onramp view. The Relayer holds the Circle kit key; the browser only receives a single-use session. When Branding is nested in a Host iframe, Buy prefers AppKit `openWindow`; top-level Branding uses `mountIframe`. Override with `localStorage.setItem("circlePopup", "true"|"false")`. +## Custom RPC — `getUpgraded` + +Read-only EIP-7702 upgrade check for the unlocked EOA on one chain. Hosts can call this before `wallet_requestExecutionPermissions` to prepare the user (onramp for USDC, expect an activation fee, etc.). No flyout. + +```typescript +const status = await proxy.rpc("getUpgraded", { + chainId: "0x2105", // Base — or "0x1", "Bitcoin", … +}); +// { upgraded: true, codeAddress: "0x…" } +// { upgraded: false } +// { upgraded: false, error: "Chain Bitcoin is not an EVM chain" } +``` + +| Method | Params | Behavior | +|--------|--------|----------| +| `getUpgraded` | `{ chainId: string }` OWS id (`0x…` / `Bitcoin` / `BitcoinTestnet`) | On-chain `getCode` for the unlocked EOA; `upgraded` when delegated to the StatelessDelegator impl | + +Returns `{ upgraded: boolean, codeAddress?: EVMContractAddress, error?: string }`. Non-EVM chain ids and getCode failures soft-fail via `error` (do not throw). Locked wallet throws `"Wallet is locked — unlock before getUpgraded"`. + ## Custom RPC — `bridge` Opens the gasless CCTP USDC bridge (native TokenMessengerV2 + Circle Forwarding Service, submitted through the 1Shot relayer). Locked wallet → error. Cancel before success → `OwsUserRejectedError`. Execute failure → thrown error. Flyout closes when the RPC settles (`requestDisplay` / `hide`). Omit `sourceChainId` to use the session chain. @@ -345,6 +364,30 @@ Returns `{ ok: true, burnTxHash, forwardTxHash? }` when the bridge succeeds (des Product analytics: `BridgeOpened`, `BridgeCompleted`, `BridgeFailed`, `BridgeCancelled` (burn submit still also emits `TransactionSubmitted*`). +## Custom RPC — `requestCancelDelegations` + +Batch on-chain revoke for permissions this host previously received from `wallet_requestExecutionPermissions`. Pass the grant response `context` values as `permissionContexts`. Opens the cancel confirm modal (same UI as the Delegations tab). Same-chain contexts are disabled in one relayer transaction; multi-chain selections submit one batched send per chain. + +Only vault rows whose `hostDomain` matches the calling host are accepted. Unknown contexts or permissions granted to another host throw `OwsInvalidParamsError` before the flyout opens. + +```typescript +// After wallet_requestExecutionPermissions → responses[].context +const result = await proxy.rpc("requestCancelDelegations", { + permissionContexts: [ + responses[0].context, + responses[1].context, // same or different chain — one modal + ], +}); +// { transactionHashes: ["0x…", …] } // one hash per unique chain +// { transactionHashes: null } // user skipped on-chain (vault delete only) +``` + +| Method | Params | Behavior | +|--------|--------|----------| +| `requestCancelDelegations` | `{ permissionContexts: HexString[] }` (min 1) | Domain-scoped batch cancel; flyout until grant/reject | + +User reject → `OwsUserRejectedError`. Prefer this over `wallet_revokeExecutionPermission` when canceling multiple grants or when the host should not touch permissions it did not receive. + ## Other Host APIs | API | Use | @@ -353,7 +396,7 @@ Product analytics: `BridgeOpened`, `BridgeCompleted`, `BridgeFailed`, `BridgeCan | `proxy.ethereum.on` / `removeListener` | Branding→Host EIP-1193 notifications (`chainChanged`, `accountsChanged` via `ows:eip1193`) | | `proxy.credentials.*` | OID4 offer / present (when enabled in wallet) | | `proxy.showWallet()` / `hideWallet()` | Host-driven flyout without an EIP-1193 call | -| `proxy.rpc(method, params)` | Custom Branding RPC (`configure`, `switchChain`, `getChainId`, `focusWallet`, `unfocusWallet`, `addAsset`, `createAccount`, `onramp`, `bridge`, …) | +| `proxy.rpc(method, params)` | Custom Branding RPC (`configure`, `switchChain`, `getChainId`, `focusWallet`, `unfocusWallet`, `addAsset`, `createAccount`, `onramp`, `getUpgraded`, `bridge`, `requestCancelDelegations`, …) | | `proxy.analytics.on(listener)` / `.on(name, listener)` / `.off(listener)` | Branding→Host product analytics (`ows:analytics`) | Subscribe so in-wallet chain/account changes update host UI without polling: @@ -396,7 +439,7 @@ The same rich payload is POSTed fire-and-forget to `POST /wallet/product-events` 1Shot relayer. The local Host (`host/`) and marketing [wallet playground](https://www.1shotapi.com/playground) include a live Analytics panel fed by `proxy.analytics.on` (filter by `name`). -EIP-7715 host RPCs: `wallet_requestExecutionPermissions`, `wallet_revokeExecutionPermission`, `wallet_getSupportedExecutionPermissions`, `wallet_getGrantedExecutionPermissions` (grant consent and on-chain revoke are wallet-driven). +EIP-7715 host RPCs: `wallet_requestExecutionPermissions`, `wallet_revokeExecutionPermission` (single `permissionContext`), `wallet_getSupportedExecutionPermissions`, `wallet_getGrantedExecutionPermissions`. For batch / domain-scoped cancel from a host, use custom RPC `requestCancelDelegations` with the grant `context` values. ### Supported permission types diff --git a/host/src/components/WalletActions.tsx b/host/src/components/WalletActions.tsx index 2b77323..08053dc 100644 --- a/host/src/components/WalletActions.tsx +++ b/host/src/components/WalletActions.tsx @@ -92,6 +92,7 @@ export interface IWalletActionsProps { onRequestDelegation: () => void; onRequestLiFiDelegation: () => void; onCancelDelegation: (id: string) => void; + onCancelSelectedDelegations: (ids: string[]) => void; onGetSupportedPermissions: () => void; onGetGrantedPermissions: () => void; } @@ -146,10 +147,14 @@ export function WalletActions({ onRequestDelegation, onRequestLiFiDelegation, onCancelDelegation, + onCancelSelectedDelegations, onGetSupportedPermissions, onGetGrantedPermissions, }: IWalletActionsProps) { const meta = hostChainMeta(chainId); + const [selectedGrantIds, setSelectedGrantIds] = useState>( + () => new Set(), + ); const [addAssetChainId, setAddAssetChainId] = useState( FOCUS_USDT_BASE.chainId, ); @@ -681,30 +686,69 @@ export function WalletActions({ {sessionGrants.length > 0 ? ( ) : null} diff --git a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx index 578b274..f488c93 100644 --- a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx +++ b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx @@ -339,6 +339,78 @@ export function WalletConfiguratorTextTabWalletSections({ patch("cancelDelegationSkipOnchainAcknowledgement", value) } /> + + patch("activateOfflinePermissionsTitle", value) + } + /> + + patch("activateOfflinePermissionsBody", value) + } + /> + + patch("activateOfflinePermissionsChainsLabel", value) + } + /> + + patch("activateOfflinePermissionsPayFromLabel", value) + } + /> + + patch("activateOfflinePermissionsFeeLabel", value) + } + /> + + patch("activateOfflinePermissionsInsufficientBalanceError", value) + } + /> + + patch("activateOfflinePermissionsNoUsdcError", value) + } + /> + + patch("activateOfflinePermissionsConfirm", value) + } + /> + + patch("activateOfflinePermissionsReject", value) + } + /> prev.filter((g) => g.id !== id)); reportStatus("Permission canceled on-chain and removed from memory."); @@ -912,7 +911,43 @@ export function useHostTestActions({ reportStatus( error instanceof Error ? error.message - : "revokeExecutionPermission failed", + : "requestCancelDelegations failed", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleCancelSelectedDelegations = (ids: string[]) => { + const proxy = proxyRef.current; + if (!proxy) return; + const grants = sessionGrants.filter((g) => ids.includes(g.id)); + if (grants.length === 0) { + reportStatus("No matching grants selected.", true); + return; + } + setBusy(true); + setDelegationsOutput(null); + reportStatus(`Canceling ${grants.length} EIP-7715 permission(s)…`); + void (async () => { + try { + proxy.showWallet(); + setWalletVisible(true); + await proxy.rpc("requestCancelDelegations", { + permissionContexts: grants.map((g) => g.response.context), + }); + const idSet = new Set(ids); + setSessionGrants((prev) => prev.filter((g) => !idSet.has(g.id))); + reportStatus( + `${grants.length} permission(s) canceled on-chain and removed from memory.`, + ); + } catch (error) { + reportStatus( + error instanceof Error + ? error.message + : "requestCancelDelegations failed", true, ); } finally { @@ -1029,6 +1064,7 @@ export function useHostTestActions({ onRequestDelegation: handleRequestDelegation, onRequestLiFiDelegation: handleRequestLiFiDelegation, onCancelDelegation: handleCancelDelegation, + onCancelSelectedDelegations: handleCancelSelectedDelegations, onGetSupportedPermissions: handleGetSupportedPermissions, onGetGrantedPermissions: handleGetGrantedPermissions, }; diff --git a/host/src/styleForm.ts b/host/src/styleForm.ts index d63c077..87e87a9 100644 --- a/host/src/styleForm.ts +++ b/host/src/styleForm.ts @@ -213,6 +213,15 @@ export interface IStyleFormState { cancelDelegationReject: string; cancelDelegationSkipOnchainLabel: string; cancelDelegationSkipOnchainAcknowledgement: string; + activateOfflinePermissionsTitle: string; + activateOfflinePermissionsBody: string; + activateOfflinePermissionsChainsLabel: string; + activateOfflinePermissionsPayFromLabel: string; + activateOfflinePermissionsFeeLabel: string; + activateOfflinePermissionsInsufficientBalanceError: string; + activateOfflinePermissionsNoUsdcError: string; + activateOfflinePermissionsConfirm: string; + activateOfflinePermissionsReject: string; // Text — Passkey ceremony overlays passkeyPromptUnlockTitle: string; @@ -453,6 +462,18 @@ export const ACME_PRESET: IStyleFormState = { 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", + activateOfflinePermissionsTitle: "Activate offline permissions", + activateOfflinePermissionsBody: + "This is your first time using offline permissions. You must activate the feature on your account with a one-time transaction.", + activateOfflinePermissionsChainsLabel: "Networks to activate", + activateOfflinePermissionsPayFromLabel: "Pay fee from", + activateOfflinePermissionsFeeLabel: "Activation fee", + activateOfflinePermissionsInsufficientBalanceError: + "Insufficient USDC to pay the activation fee on {chainName}.", + activateOfflinePermissionsNoUsdcError: + "Hold USDC on Arc or a requested network to activate offline permissions.", + activateOfflinePermissionsConfirm: "Activate", + activateOfflinePermissionsReject: "Cancel", passkeyPromptUnlockTitle: "Unlock with passkey", passkeyPromptCreateTitle: "Create passkey", passkeyPromptSignTitle: "Confirm with passkey", @@ -655,6 +676,18 @@ export const DEFAULTS_PRESET: IStyleFormState = { 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", + activateOfflinePermissionsTitle: "Activate offline permissions", + activateOfflinePermissionsBody: + "This is your first time using offline permissions. You must activate the feature on your account with a one-time transaction.", + activateOfflinePermissionsChainsLabel: "Networks to activate", + activateOfflinePermissionsPayFromLabel: "Pay fee from", + activateOfflinePermissionsFeeLabel: "Activation fee", + activateOfflinePermissionsInsufficientBalanceError: + "Insufficient USDC to pay the activation fee on {chainName}.", + activateOfflinePermissionsNoUsdcError: + "Hold USDC on Arc or a requested network to activate offline permissions.", + activateOfflinePermissionsConfirm: "Activate", + activateOfflinePermissionsReject: "Cancel", passkeyPromptUnlockTitle: "Unlock with passkey", passkeyPromptCreateTitle: "Create passkey", passkeyPromptSignTitle: "Confirm with passkey", @@ -977,6 +1010,48 @@ function buildNestedCopyFromForm(form: IStyleFormState): Record copy.cancelDelegation = cancelDelegation; } + const activateOfflinePermissions: Record = {}; + put(activateOfflinePermissions, "title", form.activateOfflinePermissionsTitle); + put(activateOfflinePermissions, "body", form.activateOfflinePermissionsBody); + put( + activateOfflinePermissions, + "chainsLabel", + form.activateOfflinePermissionsChainsLabel, + ); + put( + activateOfflinePermissions, + "payFromLabel", + form.activateOfflinePermissionsPayFromLabel, + ); + put( + activateOfflinePermissions, + "feeLabel", + form.activateOfflinePermissionsFeeLabel, + ); + put( + activateOfflinePermissions, + "insufficientBalanceError", + form.activateOfflinePermissionsInsufficientBalanceError, + ); + put( + activateOfflinePermissions, + "noUsdcError", + form.activateOfflinePermissionsNoUsdcError, + ); + put( + activateOfflinePermissions, + "confirmLabel", + form.activateOfflinePermissionsConfirm, + ); + put( + activateOfflinePermissions, + "rejectLabel", + form.activateOfflinePermissionsReject, + ); + if (Object.keys(activateOfflinePermissions).length > 0) { + copy.activateOfflinePermissions = activateOfflinePermissions; + } + const passkeyPrompt: Record> = {}; const unlock: Record = {}; put(unlock, "title", form.passkeyPromptUnlockTitle); diff --git a/package-lock.json b/package-lock.json index 8fa7145..6f59684 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,9 @@ "@1shotapi/ows-oid4": "^0.4.0", "@1shotapi/ows-provider": "^0.5.1", "@1shotapi/ows-signer": "^0.4.2", - "@1shotapi/ows-signer-utils": "^0.6.4", - "@1shotapi/ows-types": "^0.11.0", - "@1shotapi/ows-wallet-utils": "^0.5.2", + "@1shotapi/ows-signer-utils": "^0.6.5", + "@1shotapi/ows-types": "^0.12.0", + "@1shotapi/ows-wallet-utils": "^0.5.3", "@circle-fin/app-kit": "^1.15.2", "@fontsource-variable/geist": "^5.3.0", "@metamask/smart-accounts-kit": "^2.0.0", @@ -179,9 +179,9 @@ "license": "MIT" }, "node_modules/@1shotapi/ows-signer-utils": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@1shotapi/ows-signer-utils/-/ows-signer-utils-0.6.4.tgz", - "integrity": "sha512-LwaiE1g4QO22C1CbXDUWpx/8Wk9B8U1uRtdf1HgdISWG6NsqDfKP6B9Ar7RYnAQhSr1C3Liv9RYeseNUNqbQ5Q==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@1shotapi/ows-signer-utils/-/ows-signer-utils-0.6.5.tgz", + "integrity": "sha512-YziQACy4OHi3kgHr5AYizspFLnvX9X3NGvW34Rxcu8WeBwCLXzUsrJxNgTMgSxODOS8vS1fXsKUkKzOqsFM/Mg==", "license": "MIT", "dependencies": { "@1shotapi/ows-types": "*", @@ -222,9 +222,9 @@ } }, "node_modules/@1shotapi/ows-types": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@1shotapi/ows-types/-/ows-types-0.11.0.tgz", - "integrity": "sha512-5ReME3Bu0bMIoMV6R2L8+e6d+8vnugWMqvJldekHkA9pQ9NDzR9RLBwbGkIH/PkF+tYsKgxTqcPKG0lr7fij/g==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@1shotapi/ows-types/-/ows-types-0.12.0.tgz", + "integrity": "sha512-1UJBOeCxTJIp5X+HzlpIAGMtEM8hl9eRYW6E6pJdkBVFi4FtYcdnHT6sYXTiUMTbYPBCsA4VRnLvVAnnaSbuEA==", "license": "MIT", "dependencies": { "@scure/base": "^2.4.0", @@ -245,9 +245,9 @@ } }, "node_modules/@1shotapi/ows-wallet-utils": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@1shotapi/ows-wallet-utils/-/ows-wallet-utils-0.5.2.tgz", - "integrity": "sha512-7uJ6iDju/j8gQOZB1Aql6Ydp94Qds7UBt9inhKnAlH9Ovevy6uZDKtkRaX0gZId4q4rS/cGKjBbYWIyZbhuGcQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@1shotapi/ows-wallet-utils/-/ows-wallet-utils-0.5.3.tgz", + "integrity": "sha512-fkZLYv1Sl3a05lvojbK+w1AjFjejiUYHKsbbplJ3Jkxirdz5iyFBiiHX5GZfTvZ42wdVwACdcaX1Y9TkSjZX/Q==", "license": "MIT", "dependencies": { "@1shotapi/ows-types": "*", diff --git a/package.json b/package.json index aaec616..408162a 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,9 @@ "@1shotapi/ows-oid4": "^0.4.0", "@1shotapi/ows-provider": "^0.5.1", "@1shotapi/ows-signer": "^0.4.2", - "@1shotapi/ows-signer-utils": "^0.6.4", - "@1shotapi/ows-types": "^0.11.0", - "@1shotapi/ows-wallet-utils": "^0.5.2", + "@1shotapi/ows-signer-utils": "^0.6.5", + "@1shotapi/ows-types": "^0.12.0", + "@1shotapi/ows-wallet-utils": "^0.5.3", "@circle-fin/app-kit": "^1.15.2", "@fontsource-variable/geist": "^5.3.0", "@metamask/smart-accounts-kit": "^2.0.0", diff --git a/skills/oneshot-embedded-wallet/SKILL.md b/skills/oneshot-embedded-wallet/SKILL.md index 11113e7..55e509f 100644 --- a/skills/oneshot-embedded-wallet/SKILL.md +++ b/skills/oneshot-embedded-wallet/SKILL.md @@ -3,7 +3,7 @@ name: oneshot-embedded-wallet description: >- Integrate the 1Shot embedded wallet (OWS Host Layer) with @1shotapi/ows-provider. Use when embedding wallet.1shotapi.com, wiring OWSProxy, EIP-1193, credentials, - or custom RPC such as configure / focusWallet / addAsset / createAccount / onramp / bridge for + or custom RPC such as configure / switchChain / focusWallet / addAsset / createAccount / onramp / bridge / getUpgraded / requestCancelDelegations for theming, host-driven focus mode, tracked assets, and first-party Safari create. license: MIT metadata: @@ -204,6 +204,40 @@ Unknown keys are rejected (Zod `.strict()`). See also [README.md](../../README.md) in this repository. +## Custom RPC — `switchChain` + +Switch the Branding Layer session chain. Accepts EVM hex ids **and** Bitcoin +sentinels (`"Bitcoin"` mainnet, `"BitcoinTestnet"` testnet). Prefer this over +EIP-1193 `wallet_switchEthereumChain` when the host catalog includes Bitcoin — +EIP-1193 params are hex-only and reject non-hex ids with `Invalid params`. + +```ts +await proxy.rpc("switchChain", { chainId: "Bitcoin" }); +await proxy.rpc("switchChain", { chainId: "0x2105" }); +``` + +| Method | Params | Behavior | +|--------|--------|----------| +| `switchChain` | `{ chainId: \`0x…\` \| \`"Bitcoin"\` \| \`"BitcoinTestnet"\` }` | Bitcoin: session-only. EVM: same as `wallet_switchEthereumChain` via RpcHelper | + +Returns `{ ok: true, chainId }`. Bitcoin switches also emit EIP-1193 +`chainChanged` with the Bitcoin sentinel so hosts stay in sync. + +## Custom RPC — `getChainId` + +Read the Branding Layer **session** chain id (EVM hex or Bitcoin sentinel). +Prefer this over EIP-1193 `eth_chainId` when the host catalog includes Bitcoin — +`eth_chainId` only reflects the last EVM RpcHelper chain. + +```ts +const { chainId } = await proxy.rpc("getChainId"); +// "0x2105" | "Bitcoin" | "BitcoinTestnet" | … +``` + +| Method | Params | Behavior | +|--------|--------|----------| +| `getChainId` | none | Returns `{ chainId }` from the wallet session store | + ## Custom RPC — `focusWallet` / `unfocusWallet` Host-controlled shell modes. Callers (not end users) switch between **General** (multi-chain tabs) and **Focused** (single chain + asset detail view). @@ -238,13 +272,15 @@ Propose a tracked **ERC-20** for the Balances tab. The wallet resolves the token await proxy.rpc("addAsset", { chainId: "0x13b2", // Arc assetAddress: "0x3600000000000000000000000000000000000000", // USDC + // Optional HTTPS icon (shown in confirm + Balances). `http:` / `data:` rejected. + iconUrl: "https://example.com/token-icon.png", }); proxy.showWallet(); ``` | Method | Params | Effect | |--------|--------|--------| -| `addAsset` | `{ chainId: \`0x…\`, assetAddress: \`0x…\` }` | Probes ERC-20, shows confirm modal; on accept, adds to tracked assets | +| `addAsset` | `{ chainId: \`0x…\`, assetAddress: \`0x…\`, iconUrl?: \`https://…\` }` | Probes ERC-20, shows confirm modal; on accept, adds to tracked assets (persists optional host icon) | Returns `{ ok: true, chainId, assetAddress }` when the user accepts. @@ -284,24 +320,73 @@ await proxy.rpc("onramp", { Returns `{ ok: true }` when the user closes the onramp view. The Relayer holds the Circle kit key; the browser only receives a single-use session. When Branding is nested in a Host iframe, Buy prefers AppKit `openWindow`; top-level Branding uses `mountIframe`. Override with `localStorage.setItem("circlePopup", "true"|"false")`. +## Custom RPC — `getUpgraded` + +Read-only EIP-7702 upgrade check for the unlocked EOA on one chain. Hosts can call this before `wallet_requestExecutionPermissions` to prepare the user (onramp for USDC, expect an activation fee, etc.). No flyout. + +```typescript +const status = await proxy.rpc("getUpgraded", { + chainId: "0x2105", // Base — or "0x1", "Bitcoin", … +}); +// { upgraded: true, codeAddress: "0x…" } +// { upgraded: false } +// { upgraded: false, error: "Chain Bitcoin is not an EVM chain" } +``` + +| Method | Params | Behavior | +|--------|--------|----------| +| `getUpgraded` | `{ chainId: string }` OWS id (`0x…` / `Bitcoin` / `BitcoinTestnet`) | On-chain `getCode` for the unlocked EOA; `upgraded` when delegated to the StatelessDelegator impl | + +Returns `{ upgraded: boolean, codeAddress?: EVMContractAddress, error?: string }`. Non-EVM chain ids and getCode failures soft-fail via `error` (do not throw). Locked wallet throws `"Wallet is locked — unlock before getUpgraded"`. + ## Custom RPC — `bridge` -Opens the gasless CCTP USDC bridge (native TokenMessengerV2 + Circle Forwarding Service, submitted through the 1Shot relayer). Locked wallet → error. Close before confirm → `OwsUserRejectedError`. Omit `sourceChainId` to use the session chain. +Opens the gasless CCTP USDC bridge (native TokenMessengerV2 + Circle Forwarding Service, submitted through the 1Shot relayer). Locked wallet → error. Cancel before success → `OwsUserRejectedError`. Execute failure → thrown error. Flyout closes when the RPC settles (`requestDisplay` / `hide`). Omit `sourceChainId` to use the session chain. + +When `amount`, `destinationChainId`, and `speed` are all provided, the wallet skips the setup form, auto-quotes, and shows the confirmation screen only (secondary action is **Cancel**). Partial params open setup with those values as defaults. ```typescript await proxy.rpc("bridge", { amount: "10.50", // optional human USDC sourceChainId: 8453, // optional decimal; omit → session chain destinationChainId: 1, // optional; omit → user picks + speed: "fast", // optional "fast" | "slow"; required with amount+dest to skip setup + tokenAddress: "0x…", // optional; must be native CCTP USDC on source (default) }); // or: await proxy.rpc("bridge", {}); ``` | Method | Params | Behavior | |--------|--------|----------| -| `bridge` | `{ amount?: string, sourceChainId?: number, destinationChainId?: number }` | Shows wallet, opens CCTP bridge for native USDC on a relayer CCTP source. Dest must be a same-network CCTP chain. | +| `bridge` | `{ amount?: string, sourceChainId?: number, destinationChainId?: number, speed?: "fast" \| "slow", tokenAddress?: string }` | Shows wallet, opens CCTP bridge for native USDC on a relayer CCTP source. Dest must be a same-network CCTP chain. Full params → confirm-only. | + +Returns `{ ok: true, burnTxHash, forwardTxHash? }` when the bridge succeeds (destination mint if Iris has completed). The user pays the relayer USDC fee (same path as Send); destination mint is Circle’s Forwarding Service — no dest-chain signature and no native gas. -Returns `{ ok: true, burnTxHash, forwardTxHash? }` when the source burn is submitted (and destination mint if Iris has completed). The user pays the relayer USDC fee (same path as Send); destination mint is Circle’s Forwarding Service — no dest-chain signature and no native gas. +Product analytics: `BridgeOpened`, `BridgeCompleted`, `BridgeFailed`, `BridgeCancelled` (burn submit still also emits `TransactionSubmitted*`). + +## Custom RPC — `requestCancelDelegations` + +Batch on-chain revoke for permissions this host previously received from `wallet_requestExecutionPermissions`. Pass the grant response `context` values as `permissionContexts`. Opens the cancel confirm modal (same UI as the Delegations tab). Same-chain contexts are disabled in one relayer transaction; multi-chain selections submit one batched send per chain. + +Only vault rows whose `hostDomain` matches the calling host are accepted. Unknown contexts or permissions granted to another host throw `OwsInvalidParamsError` before the flyout opens. + +```typescript +// After wallet_requestExecutionPermissions → responses[].context +const result = await proxy.rpc("requestCancelDelegations", { + permissionContexts: [ + responses[0].context, + responses[1].context, // same or different chain — one modal + ], +}); +// { transactionHashes: ["0x…", …] } // one hash per unique chain +// { transactionHashes: null } // user skipped on-chain (vault delete only) +``` + +| Method | Params | Behavior | +|--------|--------|----------| +| `requestCancelDelegations` | `{ permissionContexts: HexString[] }` (min 1) | Domain-scoped batch cancel; flyout until grant/reject | + +User reject → `OwsUserRejectedError`. Prefer this over `wallet_revokeExecutionPermission` when canceling multiple grants or when the host should not touch permissions it did not receive. ## Other Host APIs @@ -310,15 +395,15 @@ Returns `{ ok: true, burnTxHash, forwardTxHash? }` when the source burn is submi | `proxy.ethereum.request(...)` | EIP-1193 (accounts, sign, chain, …) | | `proxy.ethereum.on` / `removeListener` | Branding→Host EIP-1193 notifications (`chainChanged`, `accountsChanged` via `ows:eip1193`) | | `proxy.credentials.*` | OID4 offer / present (when enabled in wallet) | -| `proxy.analytics.on(listener)` / `.on(name, listener)` / `.off(listener)` | Branding→Host product analytics (`ows:analytics`) | | `proxy.showWallet()` / `hideWallet()` | Host-driven flyout without an EIP-1193 call | -| `proxy.rpc(method, params)` | Custom Branding RPC (`configure`, `focusWallet`, `unfocusWallet`, `addAsset`, `createAccount`, `onramp`, `bridge`, …) | +| `proxy.rpc(method, params)` | Custom Branding RPC (`configure`, `switchChain`, `getChainId`, `focusWallet`, `unfocusWallet`, `addAsset`, `createAccount`, `onramp`, `getUpgraded`, `bridge`, `requestCancelDelegations`, …) | +| `proxy.analytics.on(listener)` / `.on(name, listener)` / `.off(listener)` | Branding→Host product analytics (`ows:analytics`) | Subscribe so in-wallet chain/account changes update host UI without polling: ```typescript proxy.ethereum.on("chainChanged", (chainId) => { - // hex chain id string + // EVM hex (`0x…`) or Bitcoin sentinel (`Bitcoin` / `BitcoinTestnet`) }); proxy.ethereum.on("accountsChanged", (accounts) => { // EVM address array @@ -354,16 +439,7 @@ The same rich payload is POSTed fire-and-forget to `POST /wallet/product-events` 1Shot relayer. The local Host (`host/`) and marketing [wallet playground](https://www.1shotapi.com/playground) include a live Analytics panel fed by `proxy.analytics.on` (filter by `name`). -## Relayer integration (when the host submits txs) - -- **Default sends:** `eth_sendTransaction` through OWSProxy — the wallet signs delegations and calls `relayer_*` internally. The host does **not** implement a relayer JSON-RPC client. -- **Delegated execution (Path B):** when the host or backend will **redeem** a user grant via public relayer JSON-RPC, also install the **`public-relayer`** skill. - - **B1 direct:** grant **`to: relayer targetAddress`** → Example 0b in **`public-relayer/references/examples.md`**. - - **B2 session key (recommended):** grant **`to: host session account`**, redelegate **`to: targetAddress`**, submit delegation chain → Example 0c. - - See **`public-relayer/SKILL.md`** (Integration paths with `1shot-wallet`). -- **Status webhooks:** optional `configure.destinationUrl` — the wallet forwards it to the relayer on send. Still no direct relayer client in the host. - -EIP-7715 host RPCs: `wallet_requestExecutionPermissions`, `wallet_revokeExecutionPermission`, `wallet_getSupportedExecutionPermissions`, `wallet_getGrantedExecutionPermissions` (grant consent and on-chain revoke are wallet-driven). +EIP-7715 host RPCs: `wallet_requestExecutionPermissions`, `wallet_revokeExecutionPermission` (single `permissionContext`), `wallet_getSupportedExecutionPermissions`, `wallet_getGrantedExecutionPermissions`. For batch / domain-scoped cancel from a host, use custom RPC `requestCancelDelegations` with the grant `context` values. ### Supported permission types diff --git a/src/components/ModalHost.tsx b/src/components/ModalHost.tsx index 7849995..4d02ca3 100644 --- a/src/components/ModalHost.tsx +++ b/src/components/ModalHost.tsx @@ -24,6 +24,7 @@ import { OnrampView } from "./OnrampView"; import { CCTPBridge } from "./modals/CCTPBridge"; import { GrantPermissionConsentModal } from "./modals/GrantPermissionConsentModal"; import { CancelDelegationModal } from "./modals/CancelDelegationModal"; +import { ActivateOfflinePermissionsModal } from "./modals/ActivateOfflinePermissionsModal"; export function ModalHost() { const activeModal = useModalStore((state) => state.activeModal); @@ -109,6 +110,15 @@ export function ModalHost() { onReject={activeModal.reject} /> ); + case "activateOfflinePermissions": + return ( + + ); case "cancelDelegation": return ( ["chainId"], ): EVMChainId | null { - if (chainIdProp != null && Number.isFinite(chainIdProp)) { - return EVMChainId(`0x${chainIdProp.toString(16)}` as `0x${string}`); + if ( + chainIdProp != null && + Number.isInteger(chainIdProp) && + chainIdProp >= 0 + ) { + return ChainUtils.asEVMChainId(chainIdProp); } if (ChainUtils.isEVMChainId(sessionChainId)) { return sessionChainId; diff --git a/src/components/PaymentFeePicker.tsx b/src/components/PaymentFeePicker.tsx index dd70b54..dbd5539 100644 --- a/src/components/PaymentFeePicker.tsx +++ b/src/components/PaymentFeePicker.tsx @@ -149,6 +149,7 @@ export function PaymentFeePicker({ const isLoading = loading || selectBusy; const isFinal = mode === "final" && finalFee !== null; + const iconChainId = quote?.paymentChainId ?? chainId; const selectedToken = isFinal ? quote ? findSelectedToken(quote, finalFee.paymentToken) @@ -169,6 +170,11 @@ export function PaymentFeePicker({ {error ? (

{error}

) : null} + {quote && quote.paymentChainId !== chainId ? ( +

+ Paid on {quote.paymentChainName} +

+ ) : null}

{feeLabel} @@ -184,7 +190,7 @@ export function PaymentFeePicker({ {selectedToken ? ( <> {selectedToken ? ( - + ) : null} @@ -218,7 +224,7 @@ export function PaymentFeePicker({ value={String(token.address)} disabled={token.balance <= 0n} > - + ))} diff --git a/src/components/delegations/DelegationsList.tsx b/src/components/delegations/DelegationsList.tsx index b0232dc..d04503e 100644 --- a/src/components/delegations/DelegationsList.tsx +++ b/src/components/delegations/DelegationsList.tsx @@ -51,7 +51,9 @@ function groupByHost(rows: IDelegationSummary[]): IDelegationGroup[] { .sort(([a], [b]) => a.localeCompare(b)) .map(([hostDomain, groupRows]) => ({ hostDomain, - rows: [...groupRows].sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), + rows: [...groupRows].sort( + (a, b) => Number(b.createdAt) - Number(a.createdAt), + ), })); } @@ -159,16 +161,21 @@ function DelegationRowSummary({ row }: { row: IDelegationSummary }) { export function DelegationsList({ rows, - cancelingId, - onCancel, + selectedIds, + canceling, + onToggle, + onCancelSelected, }: { rows: IDelegationSummary[]; - cancelingId: DelegationId | null; - onCancel: (delegationId: DelegationId) => void; + selectedIds: ReadonlySet; + canceling: boolean; + onToggle: (delegationId: DelegationId) => void; + onCancelSelected: () => void; }) { const { style } = useStyle(); const copy = style.copy.delegations; const groups = useMemo(() => groupByHost(rows), [rows]); + const selectedCount = selectedIds.size; return (

@@ -187,26 +194,46 @@ export function DelegationsList({
    - {group.rows.map((row) => ( -
  • - - -
  • - ))} + + + ); + })}
))} + + {selectedCount > 0 ? ( +
+

+ {selectedCount} selected +

+ +
+ ) : null}
); } diff --git a/src/components/delegations/DelegationsTab.tsx b/src/components/delegations/DelegationsTab.tsx index 7d30a01..2a58ace 100644 --- a/src/components/delegations/DelegationsTab.tsx +++ b/src/components/delegations/DelegationsTab.tsx @@ -25,13 +25,16 @@ export function DelegationsTab() { const { listDelegations, refreshDelegationsFromRelayer, - cancelStoredDelegation, + cancelStoredDelegations, } = useWallet(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [cancelingId, setCancelingId] = useState(null); + const [selectedIds, setSelectedIds] = useState>( + () => new Set(), + ); + const [canceling, setCanceling] = useState(false); const [error, setError] = useState(null); const [sent, setSent] = useState<{ chainId: EVMChainId; @@ -44,6 +47,14 @@ export function DelegationsTab() { try { const listed = await listDelegations(); setRows(listed); + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const next = new Set(); + for (const row of listed) { + if (prev.has(row.delegationId)) next.add(row.delegationId); + } + return next; + }); } catch (err: unknown) { setError( err instanceof Error ? err.message : copy.loadFailedError, @@ -72,17 +83,30 @@ export function DelegationsTab() { } }; - const onCancel = async (delegationId: DelegationId) => { - setCancelingId(delegationId); + const onToggle = (delegationId: DelegationId) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(delegationId)) next.delete(delegationId); + else next.add(delegationId); + return next; + }); + }; + + const onCancelSelected = async () => { + const ids = [...selectedIds]; + if (ids.length === 0) return; + setCanceling(true); setError(null); try { - const result = await cancelStoredDelegation(delegationId); - if (result.transactionHash) { + const result = await cancelStoredDelegations(ids); + const last = result.results[result.results.length - 1]; + if (last) { setSent({ - chainId: result.chainId, - transactionHash: result.transactionHash, + chainId: last.chainId, + transactionHash: last.transactionHash, }); } + setSelectedIds(new Set()); await reload(); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); @@ -96,7 +120,7 @@ export function DelegationsTab() { err instanceof Error ? err.message : copy.cancelFailedError, ); } finally { - setCancelingId(null); + setCanceling(false); } }; @@ -137,9 +161,11 @@ export function DelegationsTab() { ) : ( { - void onCancel(id); + selectedIds={selectedIds} + canceling={canceling} + onToggle={onToggle} + onCancelSelected={() => { + void onCancelSelected(); }} /> )} diff --git a/src/components/modals/ActivateOfflinePermissionsModal.tsx b/src/components/modals/ActivateOfflinePermissionsModal.tsx new file mode 100644 index 0000000..8c2f140 --- /dev/null +++ b/src/components/modals/ActivateOfflinePermissionsModal.tsx @@ -0,0 +1,209 @@ +import { useEffect, useRef } from "react"; +import { formatUnits } from "viem"; +import { type EVMTransactionHash } from "@1shotapi/ows-types"; +import type { + IActivateOfflinePermissionsRequest, + IRelayerConfirmSendResult, +} from "../../wallet/modalTypes"; +import type { IRelayerSendUiCallbacks } from "../../lib/types/domain/RelayerSendUi"; +import { useStyle } from "../../style/StyleProvider"; +import { useWallet } from "../../wallet/WalletProvider"; +import { Modal } from "../Modal"; +import { QuoteCountdown } from "../QuoteCountdown"; +import { useRelayerConfirmSubmit } from "../useRelayerConfirmSubmit"; + +/** + * One-time EIP-7702 activation before EIP-7715 grant consent. + * Quotes a combined USDC fee, then runs {@link ITransactionService.activateDelegations}. + */ +export function ActivateOfflinePermissionsModal({ + request, + execute, + onResolve, + onReject, +}: { + request: IActivateOfflinePermissionsRequest; + execute: ( + payment: IRelayerConfirmSendResult, + ui: IRelayerSendUiCallbacks, + ) => Promise; + onResolve: (hash: EVMTransactionHash) => void; + onReject: (error: unknown) => void; +}) { + const { style } = useStyle(); + const { transactionService } = useWallet(); + const copy = style.copy.activateOfflinePermissions; + const relayerCopy = style.copy.relayerSubmit; + const rejectMessage = "User rejected offline permission activation"; + + const upgradeChainIds = request.upgradeChains.map((c) => c.chainId); + const upgradeKey = upgradeChainIds.map(String).join(","); + + const submit = useRelayerConfirmSubmit({ + execute, + onResolve, + onReject, + rejectMessage, + // Stay open through activation poll so grant consent can follow without + // collapsing the flyout between submit and confirmation. + retainDisplayDuringSubmit: true, + signingMessage: relayerCopy.signingMessage, + waitingMessage: relayerCopy.waitingMessage, + finalFeeNotice: relayerCopy.finalFeeNotice, + }); + + const onQuoteChangeRef = useRef(submit.setQuote); + const onQuoteErrorRef = useRef(submit.setQuoteError); + useEffect(() => { + onQuoteChangeRef.current = submit.setQuote; + onQuoteErrorRef.current = submit.setQuoteError; + }, [submit.setQuote, submit.setQuoteError]); + + const getNewQuote = async (): Promise => { + try { + const next = await transactionService.quoteActivation( + request.ownerAddress, + upgradeChainIds, + request.payment, + ); + onQuoteChangeRef.current(next); + onQuoteErrorRef.current(null); + return next.feeFormatted; + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to quote activation fee"; + onQuoteChangeRef.current(null); + onQuoteErrorRef.current(message); + throw err; + } + }; + + const insufficientBalance = + submit.quote !== null && + submit.quote.feeAtoms > request.payment.balance; + + const balanceError = insufficientBalance + ? copy.insufficientBalanceError.replace( + "{chainName}", + request.payment.paymentChainName, + ) + : null; + + const canConfirm = + submit.canConfirm && !insufficientBalance && balanceError === null; + + const showActions = + submit.phase === "confirm" || submit.phase === "finalFee"; + + return ( + +
+

+ {copy.body} +

+ +
+ + {copy.chainsLabel} + +
    + {request.upgradeChains.map((chain) => ( +
  • {chain.chainName}
  • + ))} +
+
+ +
+ + {copy.payFromLabel} + +

+ {request.payment.paymentChainName} ({request.payment.symbol}) +

+

+ Balance:{" "} + {formatUnits( + request.payment.balance, + request.payment.decimals, + )}{" "} + {request.payment.symbol} +

+
+ +
+ + {copy.feeLabel} + + {submit.phase === "finalFee" ? ( +

+ {submit.finalFeeNotice} +

+ ) : null} + {submit.quoteError || balanceError ? ( +

+ {balanceError ?? submit.quoteError} +

+ ) : null} +

+ + {submit.phase === "finalFee" ? "Final fee:" : "Est. fee:"} + + {submit.phase === "finalFee" && submit.finalFee ? ( + + {submit.finalFee.feeFormatted} {request.payment.symbol} + + ) : ( + <> + + + {request.payment.symbol} + + + )} +

+
+ + {submit.statusMessage ? ( +

+ {submit.statusMessage} +

+ ) : null} + {submit.error ? ( +

{submit.error}

+ ) : null} +
+
+ ); +} diff --git a/src/components/modals/CCTPBridge.tsx b/src/components/modals/CCTPBridge.tsx index 16cac04..fc4d554 100644 --- a/src/components/modals/CCTPBridge.tsx +++ b/src/components/modals/CCTPBridge.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { formatUnits, parseUnits, erc20Abi } from "viem"; import { + ChainUtils, DomainString, - EVMChainId, OwsUserRejectedError, type EVMChainId as EVMChainIdType, type EVMTransactionHash, @@ -163,7 +163,7 @@ export function CCTPBridge({ const resolvedDestChainId = useMemo((): EVMChainIdType | null => { if (!destChainId) return null; try { - return EVMChainId(destChainId as `0x${string}`); + return ChainUtils.asEVMChainId(destChainId); } catch { return null; } @@ -348,7 +348,7 @@ export function CCTPBridge({ const parsed = parseUnits(opts.amountRaw.trim(), decimals); const next = await bridgeService.quote({ sourceChainId: request.sourceChainId, - destChainId: EVMChainId(opts.dest as `0x${string}`), + destChainId: ChainUtils.asEVMChainId(opts.dest), amountAtoms: parsed, speed: opts.transferSpeed, owner: request.ownerAddress, @@ -514,6 +514,7 @@ export function CCTPBridge({ { paymentToken: paymentQuote.selectedToken, feeAtoms: paymentQuote.feeAtoms, + paymentChainId: paymentQuote.paymentChainId, }, (progress) => { setBurnTxHash(progress.burnTxHash); diff --git a/src/components/modals/CancelDelegationModal.tsx b/src/components/modals/CancelDelegationModal.tsx index 3a6db9f..3290475 100644 --- a/src/components/modals/CancelDelegationModal.tsx +++ b/src/components/modals/CancelDelegationModal.tsx @@ -1,21 +1,52 @@ -import { useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ICancelDelegationConfirmRequest, - IRelayerConfirmSendResult, + ICancelDelegationPayment, } from "../../wallet/modalTypes"; +import type { IPaymentQuote } from "../../lib/interfaces/business"; import type { IRelayerSendUiCallbacks } from "../../lib/types/domain/RelayerSendUi"; +import type { ITransactionWork } from "../../lib/interfaces/business"; import { OwsUserRejectedError, + type EVMChainId, type EVMTransactionHash, } from "@1shotapi/ows-types"; import { useStyle } from "../../style/StyleProvider"; import { Modal } from "../Modal"; -import { RelayerConfirmModalChrome } from "../RelayerConfirmModalChrome"; -import { useRelayerConfirmSubmit } from "../useRelayerConfirmSubmit"; +import { PaymentFeePicker } from "../PaymentFeePicker"; +import { isSignDenied } from "../useRelayerConfirmSubmit"; + +type CancelPhase = "confirm" | "signing" | "finalFee" | "submitting"; + +type ChainGroup = { + chainId: EVMChainId; + chainName: string; + work: ITransactionWork[]; +}; + +function groupItemsByChain( + items: ICancelDelegationConfirmRequest["items"], +): ChainGroup[] { + const map = new Map(); + for (const item of items) { + let group = map.get(item.chainId); + if (!group) { + group = { + chainId: item.chainId, + chainName: item.chainName, + work: [], + }; + map.set(item.chainId, group); + } + group.work.push(item.work); + } + return [...map.values()]; +} /** - * On-chain cancel / revoke confirm — collects relayer fee then runs execute. - * Optional “Skip onchain cancellation” removes the vault row only. + * On-chain cancel / revoke confirm — lists selected delegations, quotes a + * relayer fee per chain, then runs execute. Optional “Skip onchain + * cancellation” removes vault rows only. */ export function CancelDelegationModal({ request, @@ -27,12 +58,12 @@ export function CancelDelegationModal({ }: { request: ICancelDelegationConfirmRequest; execute: ( - payment: IRelayerConfirmSendResult, + payments: ICancelDelegationPayment[], ui: IRelayerSendUiCallbacks, - ) => Promise; + ) => Promise; executeLocal: () => Promise; onRegisterAwaitingConfirmation?: (notify: () => void) => void; - onResolve: (hash: EVMTransactionHash | null) => void; + onResolve: (hashes: EVMTransactionHash[] | null) => void; onReject: (error: unknown) => void; }) { const { style } = useStyle(); @@ -41,33 +72,128 @@ export function CancelDelegationModal({ const [skipOnchain, setSkipOnchain] = useState(false); const [localBusy, setLocalBusy] = useState(false); const [localError, setLocalError] = useState(null); + const [phase, setPhase] = useState("confirm"); + const [error, setError] = useState(null); + const [quotes, setQuotes] = useState>( + {}, + ); + const [quoteErrors, setQuoteErrors] = useState>( + {}, + ); + const abortedRef = useRef(false); + const finalFeeGateRef = useRef<{ + resolve: () => void; + reject: (error: Error) => void; + } | null>(null); + const showedFinalFeeRef = useRef(false); + const [finalFeeLabel, setFinalFeeLabel] = useState(null); const rejectMessage = "User rejected canceling the permission"; + const chainGroups = useMemo( + () => groupItemsByChain(request.items), + [request.items], + ); - const submit = useRelayerConfirmSubmit({ - execute, - onRegisterAwaitingConfirmation, - onResolve, - onReject, - rejectMessage, - retainDisplayDuringSubmit: true, - signingMessage: relayerCopy.signingMessage, - waitingMessage: relayerCopy.waitingMessage, - finalFeeNotice: relayerCopy.finalFeeNotice, - }); + const chainNames = useMemo( + () => + [...new Set(chainGroups.map((g) => g.chainName))].join(", ") || + "unknown", + [chainGroups], + ); - const body = copy.body - .replace("{domain}", request.domain) - .replace("{chainName}", request.chainName); + useEffect(() => { + onRegisterAwaitingConfirmation?.(() => setPhase("submitting")); + }, [onRegisterAwaitingConfirmation]); + + useEffect(() => { + return () => { + finalFeeGateRef.current?.reject(new OwsUserRejectedError(rejectMessage)); + }; + }, []); + + const allQuotesReady = + chainGroups.length > 0 && + chainGroups.every((group) => { + const key = group.chainId; + return quotes[key] != null && !quoteErrors[key]; + }); const showConfirmActions = - skipOnchain || - submit.phase === "confirm" || - submit.phase === "finalFee"; + skipOnchain || phase === "confirm" || phase === "finalFee"; const canConfirm = skipOnchain ? !localBusy - : submit.canConfirm; + : phase === "finalFee" + ? true + : phase === "confirm" && allQuotesReady; + + const body = copy.body + .replace("{domain}", request.domain) + .replace("{chainName}", chainNames); + + const setChainQuote = useCallback( + (chainId: EVMChainId, quote: IPaymentQuote | null, err: string | null) => { + const key = chainId; + setQuotes((prev) => ({ ...prev, [key]: quote })); + setQuoteErrors((prev) => ({ ...prev, [key]: err })); + }, + [], + ); + + const buildPayments = useCallback((): ICancelDelegationPayment[] => { + return chainGroups.map((group) => { + const quote = quotes[group.chainId]; + if (!quote) { + throw new Error(`Missing fee quote for chain ${group.chainName}`); + } + return { + chainId: group.chainId, + paymentToken: quote.selectedToken, + feeAtoms: quote.feeAtoms, + paymentChainId: quote.paymentChainId, + }; + }); + }, [chainGroups, quotes]); + + const runExecute = useCallback(() => { + abortedRef.current = false; + setError(null); + setPhase("signing"); + let payments: ICancelDelegationPayment[]; + try { + payments = buildPayments(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + setPhase("confirm"); + return; + } + + void execute(payments, { + retainDisplayDuringSubmit: true, + onAwaitingConfirmation: () => setPhase("submitting"), + onFinalFeeRequired: (fee) => + new Promise((resolve, reject) => { + showedFinalFeeRef.current = true; + setFinalFeeLabel(fee.feeFormatted); + setPhase("finalFee"); + finalFeeGateRef.current = { resolve, reject }; + }), + }) + .then((hashes) => { + if (abortedRef.current) return; + onResolve(hashes); + }) + .catch((err: unknown) => { + if (abortedRef.current) return; + finalFeeGateRef.current = null; + if (isSignDenied(err)) { + setPhase(showedFinalFeeRef.current ? "finalFee" : "confirm"); + return; + } + setError(err instanceof Error ? err.message : String(err)); + setPhase(showedFinalFeeRef.current ? "finalFee" : "confirm"); + }); + }, [buildPayments, execute, onResolve]); const onConfirm = () => { if (skipOnchain) { @@ -75,19 +201,21 @@ export function CancelDelegationModal({ setLocalBusy(true); void executeLocal() .then(() => onResolve(null)) - .catch((error: unknown) => { + .catch((err: unknown) => { setLocalBusy(false); - setLocalError( - error instanceof Error ? error.message : String(error), - ); + setLocalError(err instanceof Error ? err.message : String(err)); }); return; } - if (submit.phase === "finalFee") { - submit.confirmFinalFee(); - } else { - submit.startSubmit(); + if (phase === "finalFee") { + if (!finalFeeGateRef.current) return; + setError(null); + setPhase("signing"); + finalFeeGateRef.current.resolve(); + finalFeeGateRef.current = null; + return; } + runExecute(); }; const onCancel = () => { @@ -95,9 +223,21 @@ export function CancelDelegationModal({ onReject(new OwsUserRejectedError(rejectMessage)); return; } - submit.cancel(); + abortedRef.current = true; + finalFeeGateRef.current?.reject(new OwsUserRejectedError(rejectMessage)); + finalFeeGateRef.current = null; + onReject(new OwsUserRejectedError(rejectMessage)); }; + const statusMessage = + phase === "signing" + ? relayerCopy.signingMessage + : phase === "submitting" + ? relayerCopy.waitingMessage + : null; + + const feePickerPaused = phase !== "confirm" && phase !== "finalFee"; + return ( {copy.chainLabel} -
{request.chainName}
+
{chainNames}
+ +
    + {request.items.map((item, index) => ( +
  • +

    + {item.memo.trim() || "Permission"} +

    +

    + {item.chainName} +

    +
  • + ))} +
+ {!skipOnchain ? ( - +
+ {phase === "finalFee" ? ( +

+ {relayerCopy.finalFeeNotice} + {finalFeeLabel ? ` (${finalFeeLabel})` : null} +

+ ) : null} + {chainGroups.map((group) => { + const key = group.chainId; + return ( +
+ {chainGroups.length > 1 ? ( +

+ {group.chainName} +

+ ) : null} + { + setChainQuote(group.chainId, next, err); + }} + /> +
+ ); + })} + {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} + {error ? ( +

{error}

+ ) : null} +
) : null} + {request.allowSkipOnchain ? (