From dc9dea7da6a80fa18bfce1d99ce985a3980c7040 Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Wed, 23 Sep 2026 16:37:26 -0700 Subject: [PATCH 1/8] Add offline permissions activation feature - Introduced new UI components for activating offline permissions in the Wallet Configurator. - Added properties to the IStyleFormState interface for offline permissions. - Implemented the ActivateOfflinePermissionsModal for handling activation requests. - Enhanced the TransactionService with methods for resolving and quoting activation payments. - Updated relevant types and interfaces to support the new activation feature. - Integrated activation logic into the wallet boot process to ensure proper handling of user requests. --- ...alletConfiguratorTextTabWalletSections.tsx | 32 + host/src/styleForm.ts | 31 + src/components/ModalHost.tsx | 10 + .../ActivateOfflinePermissionsModal.tsx | 206 +++++ .../business/TransactionService.ts | 33 + .../business/utils/TransactionUtils.ts | 748 +++++++++++++++++- .../data/OneshotRelayerRepository.ts | 112 ++- .../business/ITransactionService.ts | 28 + src/lib/interfaces/business/index.ts | 5 +- .../business/utils/ITransactionUtils.ts | 42 + src/lib/interfaces/business/utils/index.ts | 2 +- .../data/IOneshotRelayerRepository.ts | 12 + src/style/applyStyle.ts | 7 + src/style/configureSchemas.ts | 18 + src/style/defaults.ts | 13 + src/wallet/WalletProvider.tsx | 1 + src/wallet/modalTypes.ts | 21 + src/wallet/useWalletBoot.ts | 130 +++ 18 files changed, 1404 insertions(+), 47 deletions(-) create mode 100644 src/components/modals/ActivateOfflinePermissionsModal.tsx diff --git a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx index 578b274..58d3261 100644 --- a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx +++ b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx @@ -339,6 +339,38 @@ export function WalletConfiguratorTextTabWalletSections({ patch("cancelDelegationSkipOnchainAcknowledgement", value) } /> + + patch("activateOfflinePermissionsTitle", value) + } + /> + + patch("activateOfflinePermissionsBody", value) + } + /> + + patch("activateOfflinePermissionsConfirm", value) + } + /> + + patch("activateOfflinePermissionsReject", value) + } + /> copy.cancelDelegation = cancelDelegation; } + const activateOfflinePermissions: Record = {}; + put(activateOfflinePermissions, "title", form.activateOfflinePermissionsTitle); + put(activateOfflinePermissions, "body", form.activateOfflinePermissionsBody); + 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/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 ( 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, + 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.usdcBalance; + + 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.usdcSymbol}) +

+

+ Balance:{" "} + {formatUnits( + request.payment.usdcBalance, + request.payment.usdcDecimals, + )}{" "} + {request.payment.usdcSymbol} +

+
+ +
+ + {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.usdcSymbol} + + ) : ( + <> + + + {request.payment.usdcSymbol} + + + )} +

+
+ + {submit.statusMessage ? ( +

+ {submit.statusMessage} +

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

{submit.error}

+ ) : null} +
+
+ ); +} diff --git a/src/lib/implementations/business/TransactionService.ts b/src/lib/implementations/business/TransactionService.ts index 679d761..24f1b8e 100644 --- a/src/lib/implementations/business/TransactionService.ts +++ b/src/lib/implementations/business/TransactionService.ts @@ -16,6 +16,7 @@ import type { ITransactionWork, } from "../../interfaces/business/ITransactionService"; import type { ITransactionUtils } from "../../interfaces/business/utils/ITransactionUtils"; +import type { IActivationPayment } from "../../interfaces/business/utils/ITransactionUtils"; import type { IRelayerSendUiCallbacks } from "../../types/domain/RelayerSendUi"; import type { TokenAmount } from "../../types/primitives"; @@ -65,6 +66,38 @@ export class TransactionService implements ITransactionService { ); } + resolveActivationPayment( + owner: EVMAccountAddress, + candidateChainIds: readonly EVMChainId[], + ): Promise { + return this.options.transactionUtils.resolveActivationPayment( + owner, + candidateChainIds, + ); + } + + quoteActivation( + owner: EVMAccountAddress, + upgradeChainIds: readonly EVMChainId[], + payment: IActivationPayment, + ): Promise { + return this.options.transactionUtils.quoteActivation( + owner, + upgradeChainIds, + payment, + ); + } + + activateDelegations( + args: { + upgradeChainIds: readonly EVMChainId[]; + payment: IActivationPayment; + feeAtoms: TokenAmount; + } & IRelayerSendUiCallbacks, + ): Promise { + return this.options.transactionUtils.activateDelegations(args); + } + async sendTransaction( chainId: EVMChainId, work: ITransactionWork, diff --git a/src/lib/implementations/business/utils/TransactionUtils.ts b/src/lib/implementations/business/utils/TransactionUtils.ts index ed790e2..f88d992 100644 --- a/src/lib/implementations/business/utils/TransactionUtils.ts +++ b/src/lib/implementations/business/utils/TransactionUtils.ts @@ -33,6 +33,7 @@ import type { IRelayerAuthorizationEntry, ISendTransactionResult, } from "../../../interfaces/data/IOneshotRelayerRepository"; +import type { ITrackedAssetRepository } from "../../../interfaces/data/ITrackedAssetRepository"; import type { IPaymentQuote, IPaymentTokenOption, @@ -42,12 +43,14 @@ import { NATIVE_TRANSFER_GAS, maxNativeSendable, withNativeFeeHeadroom, + type IActivationPayment, type ITransactionUtils, } from "../../../interfaces/business/utils/ITransactionUtils"; import type { ITransactionUtils as IPresentationTransactionUtils } from "../../../interfaces/utils/ITransactionUtils"; import type { IOWSProvider } from "../../../interfaces/utils/IOWSProvider"; import { EPasskeyPromptReason } from "../../../types/enum/EPasskeyPromptReason"; import type { IFinalRelayerFee } from "../../../types/domain/RelayerSendUi"; +import { EAssetType } from "../../../types/enum/EAssetType"; import { makeTokenAmount, tokenAmountFromAtomString, @@ -62,6 +65,7 @@ import { loadCachedSecp256k1PublicKey, } from "../../../../storage"; import { styleController } from "../../../../style/styleController"; +import { DEFAULT_CHAIN_ID } from "../../data/HardcodedChainRepository"; // Ensure Arc mainnet Smart Accounts env is registered before any kit lookups. import "../../utils/registerSmartAccountsEnvironments"; @@ -76,11 +80,17 @@ const STATELESS_DELEGATOR_IMPL = export const PLACEHOLDER_DELEGATION_SIGNATURE_65_ZERO = "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" as const; +const PLACEHOLDER_AUTH_R = + "0x0000000000000000000000000000000000000000000000000000000000000000" as const; +const PLACEHOLDER_AUTH_S = + "0x0000000000000000000000000000000000000000000000000000000000000000" as const; + /** IndexedDB key for the client delegation-binding value (not localStorage). */ const DELEGATION_BINDING_IDB_KEY = "oneshot.dbind"; const LEGACY_DELEGATION_SECRET_KEY = "oneshot.delegationSecret"; const POLL_MS = 1000; const MAX_POLL_ATTEMPTS = 180; +const EMPTY_CALLDATA = "0x" as Hex; type ExactCalldataDelegationArgs = { smartAccount: Awaited>; @@ -94,6 +104,7 @@ type ExactCalldataDelegationArgs = { export type TransactionUtilsOptions = { chainRepository: IChainRepository; relayerRepository: IOneshotRelayerRepository; + trackedAssetRepository: ITrackedAssetRepository; blockchain: IBlockchainProvider; /** Presentation helpers (host domain for relayer memo). */ presentationTransactionUtils: IPresentationTransactionUtils; @@ -250,24 +261,24 @@ export class TransactionUtils implements ITransactionUtils { chainId, ); - const client = this.options.blockchain.getPublicClient(chainId); - const tokens: IPaymentTokenOption[] = await Promise.all( - capabilities.tokens.map(async (token) => { - let balance = 0n; - try { - balance = await client.readContract({ - address: token.address, - abi: erc20Abi, - functionName: "balanceOf", - args: [owner], - }); - } catch { - balance = 0n; - } - return { ...token, balance: makeTokenAmount(balance) }; - }), + const tracked = await this.options.trackedAssetRepository.getBalances( + owner, + { chainId }, + ); + const balanceByAddress = new Map( + tracked.map((asset) => [ + String(asset.address).toLowerCase(), + asset.balance ?? 0n, + ]), ); + const tokens: IPaymentTokenOption[] = capabilities.tokens.map((token) => ({ + ...token, + balance: makeTokenAmount( + balanceByAddress.get(String(token.address).toLowerCase()) ?? 0n, + ), + })); + const selected = pickPaymentToken(tokens, preferredToken); if (!selected) { throw new Error("No relayer payment token with a positive balance"); @@ -280,6 +291,7 @@ export class TransactionUtils implements ITransactionUtils { ); const chainIdNumber = Number(BigInt(chainId)); + const client = this.options.blockchain.getPublicClient(chainId); const viemAccount = await this.getViemAccount(owner); const smartAccount = await toMetaMaskSmartAccount({ client: client as never, @@ -378,6 +390,521 @@ export class TransactionUtils implements ITransactionUtils { }; } + async resolveActivationPayment( + owner: EVMAccountAddress, + candidateChainIds: readonly EVMChainId[], + ): Promise { + const unique: EVMChainId[] = []; + const seen = new Set(); + for (const id of candidateChainIds) { + const key = String(id); + if (seen.has(key)) continue; + seen.add(key); + unique.push(id); + } + + const balances = await Promise.all( + unique.map(async (chainId) => { + const usdc = await this.readUsdcBalance(chainId, owner); + return { chainId, usdc }; + }), + ); + + const withUsdc = balances.filter( + (row) => row.usdc !== null && row.usdc.balance > 0n, + ); + if (withUsdc.length === 0) return null; + + const preferArc = withUsdc.find( + (row) => String(row.chainId) === String(DEFAULT_CHAIN_ID), + ); + const picked = preferArc ?? withUsdc[0]!; + const chain = await this.requireRelayerChain(picked.chainId); + const usdc = picked.usdc!; + return { + paymentChainId: picked.chainId, + paymentToken: usdc.address, + paymentChainName: chain.label, + usdcBalance: usdc.balance, + usdcDecimals: usdc.decimals, + usdcSymbol: usdc.symbol, + }; + } + + async quoteActivation( + owner: EVMAccountAddress, + upgradeChainIds: readonly EVMChainId[], + payment: IActivationPayment, + ): Promise { + if (upgradeChainIds.length === 0) { + throw new Error("quoteActivation requires at least one upgrade chain"); + } + + const paymentChain = await this.requireRelayerChain(payment.paymentChainId); + const unsigned = await this.buildActivationParams({ + eoa: owner, + upgradeChainIds, + payment, + feeAtoms: makeTokenAmount(parseUnits("0.01", payment.usdcDecimals)), + signed: false, + }); + + const useMultichain = shouldUseActivationMultichain( + upgradeChainIds, + payment.paymentChainId, + ); + + const estimate = useMultichain + ? await this.options.relayerRepository.estimate7710TransactionMultichain( + paymentChain.relayerUrl, + unsigned, + ) + : await this.options.relayerRepository.estimate7710Transaction( + paymentChain.relayerUrl, + unsigned[0]!, + ); + + if (!estimate.success || !estimate.requiredPaymentAmount) { + throw new Error( + estimate.error ?? "relayer activation estimate failed", + ); + } + + const feeAtoms = tokenAmountFromAtomString(estimate.requiredPaymentAmount); + const capabilities = await this.options.relayerRepository.getCapabilities( + paymentChain.relayerUrl, + payment.paymentChainId, + ); + + const tokenOption: IPaymentTokenOption = { + address: payment.paymentToken, + symbol: payment.usdcSymbol, + decimals: payment.usdcDecimals, + balance: payment.usdcBalance, + }; + + return { + tokens: [tokenOption], + selectedToken: payment.paymentToken, + feeAtoms, + feeFormatted: formatUnits(feeAtoms, payment.usdcDecimals), + feeCollector: capabilities.feeCollector, + targetAddress: capabilities.targetAddress, + minFee: feeAtoms, + }; + } + + async activateDelegations(args: { + upgradeChainIds: readonly EVMChainId[]; + payment: IActivationPayment; + feeAtoms: TokenAmount; + retainDisplayDuringSubmit?: boolean; + onAwaitingConfirmation?: () => void; + onFinalFeeRequired?: (fee: IFinalRelayerFee) => Promise; + }): Promise { + const { + payment, + onAwaitingConfirmation, + onFinalFeeRequired, + retainDisplayDuringSubmit, + } = args; + const upgradeChainIds = [...args.upgradeChainIds]; + if (upgradeChainIds.length === 0) { + throw new Error("activateDelegations requires at least one upgrade chain"); + } + + let feeAtoms = args.feeAtoms; + const paymentChain = await this.requireRelayerChain(payment.paymentChainId); + const useMultichain = shouldUseActivationMultichain( + upgradeChainIds, + payment.paymentChainId, + ); + + const signer = await this.options.owsProvider.getSigner(); + const eoa = + signer.getCachedAddress?.() ?? + loadCachedEvmAddress() ?? + (await signer.evm.getAccountAddress()); + + await this.options.owsProvider.ensureDisplay(); + try { + const delegationSecret = await loadOrCreateDelegationBinding(); + const viemAccount = await this.getViemAccount(eoa); + const destinationUrl = styleController.get().destinationUrl; + const memo = buildMemo( + eoa, + this.options.presentationTransactionUtils.resolveHostDomain(), + ); + + // Prefetch upgrade nonces/contracts before the coalesced ceremony. + const upgradePrep = await Promise.all( + upgradeChainIds.map(async (chainId) => { + const chainIdNumber = Number(BigInt(chainId)); + const client = this.options.blockchain.getPublicClient(chainId); + let contractAddress: `0x${string}` = STATELESS_DELEGATOR_IMPL; + try { + const env = getSmartAccountsEnvironment(chainIdNumber); + contractAddress = getAddress( + env.implementations.EIP7702StatelessDeleGatorImpl, + ); + } catch { + // keep hardcoded fallback + } + const nonce = await client.getTransactionCount({ + address: getAddress(eoa), + blockTag: "pending", + }); + return { chainId, chainIdNumber, contractAddress, nonce }; + }), + ); + + const paymentCapabilities = + await this.options.relayerRepository.getCapabilities( + paymentChain.relayerUrl, + payment.paymentChainId, + ); + const paymentChainIdNumber = Number(BigInt(payment.paymentChainId)); + const paymentClient = this.options.blockchain.getPublicClient( + payment.paymentChainId, + ); + const paymentSmartAccount = await toMetaMaskSmartAccount({ + client: paymentClient as never, + implementation: Implementation.Stateless7702, + address: eoa, + signer: { account: viemAccount }, + }); + + const chainSmartAccounts = new Map< + string, + Awaited> + >(); + const upgradeCapabilities = new Map< + string, + Awaited< + ReturnType + > + >(); + chainSmartAccounts.set( + String(payment.paymentChainId), + paymentSmartAccount, + ); + upgradeCapabilities.set( + String(payment.paymentChainId), + paymentCapabilities, + ); + const missingUpgradeIds = upgradeChainIds.filter( + (chainId) => !chainSmartAccounts.has(String(chainId)), + ); + await Promise.all( + missingUpgradeIds.map(async (chainId) => { + const key = String(chainId); + if (!upgradeCapabilities.has(key)) { + const chain = await this.requireRelayerChain(chainId); + const caps = await this.options.relayerRepository.getCapabilities( + chain.relayerUrl, + chainId, + ); + upgradeCapabilities.set(key, caps); + } + const client = this.options.blockchain.getPublicClient(chainId); + const smartAccount = await toMetaMaskSmartAccount({ + client: client as never, + implementation: Implementation.Stateless7702, + address: eoa, + signer: { account: viemAccount }, + }); + chainSmartAccounts.set(key, smartAccount); + }), + ); + + const feeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, feeAtoms], + }), + ); + + const approveCopy = approveTransactionCeremony(true); + const minCalls = upgradeChainIds.length * 2 + 1; + + const signed = await withCeremonyUiReason( + EPasskeyPromptReason.ApproveTransaction, + () => + withCoalescedSignDigest( + signer, + approveCopy, + async () => { + const [authEntries, feeDelegation, workDelegations] = + await Promise.all([ + Promise.all( + upgradePrep.map((prep) => + this.signWalletUpgradeAuthorizationInner(prep.chainId, { + account: viemAccount, + nonce: prep.nonce, + contractAddress: prep.contractAddress, + }), + ), + ), + this.createAndSignExactCalldataDelegation({ + smartAccount: paymentSmartAccount, + delegate: paymentCapabilities.targetAddress, + target: payment.paymentToken, + value: 0n, + callData: feeCalldata, + chainIdNumber: paymentChainIdNumber, + }), + Promise.all( + upgradeChainIds.map((chainId) => { + const smartAccount = chainSmartAccounts.get( + String(chainId), + ); + const caps = upgradeCapabilities.get(String(chainId)); + if (!smartAccount || !caps) { + throw new Error( + `Missing smart account or capabilities for ${chainId}`, + ); + } + return this.createAndSignExactCalldataDelegation({ + smartAccount, + delegate: caps.targetAddress, + target: eoa, + value: 0n, + callData: EMPTY_CALLDATA, + chainIdNumber: Number(BigInt(chainId)), + }); + }), + ), + ]); + return { authEntries, feeDelegation, workDelegations }; + }, + { minCalls } satisfies CoalesceSignDigestOptions, + ), + ); + + const authByChain = new Map(); + for (let i = 0; i < upgradeChainIds.length; i += 1) { + authByChain.set(String(upgradeChainIds[i]), signed.authEntries[i]!); + } + let feeDelegation = signed.feeDelegation; + const workByChain = new Map(); + for (let i = 0; i < upgradeChainIds.length; i += 1) { + workByChain.set(String(upgradeChainIds[i]), signed.workDelegations[i]!); + } + + const buildChainParams = async ( + feeAmount: TokenAmount, + contexts?: Record, + ): Promise => { + const feeData = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, feeAmount], + }), + ); + + const orderedChainIds = orderedActivationChainIds( + upgradeChainIds, + payment.paymentChainId, + ); + + return Promise.all( + orderedChainIds.map(async (chainId) => { + const isPayment = + String(chainId) === String(payment.paymentChainId); + const needsUpgrade = upgradeChainIds.some( + (id) => String(id) === String(chainId), + ); + const chainIdDecimal = Number(BigInt(chainId)).toString(10); + const transactions: IRelayer7710Params["transactions"] = []; + + if (isPayment) { + transactions.push({ + permissionContext: [toRelayerJson(feeDelegation)], + executions: [ + { + target: payment.paymentToken, + value: "0", + data: feeData as HexString, + }, + ], + }); + } + + if (needsUpgrade) { + const workSig = workByChain.get(String(chainId)); + if (!workSig) { + throw new Error( + `Missing work delegation for upgrade chain ${chainId}`, + ); + } + transactions.push({ + permissionContext: [toRelayerJson(workSig)], + executions: [ + { + target: eoa, + value: "0", + data: EMPTY_CALLDATA as HexString, + }, + ], + }); + } + + if (transactions.length === 0) { + throw new Error( + `Activation params for chain ${chainId} have no transactions`, + ); + } + + const auth = authByChain.get(String(chainId)); + const context = contexts?.[chainIdDecimal]; + return { + chainId: chainIdDecimal, + transactions, + ...(auth ? { authorizationList: [auth] } : {}), + ...(context ? { context } : {}), + memo, + delegationSecret, + ...(destinationUrl ? { destinationUrl } : {}), + } satisfies IRelayer7710Params; + }), + ); + }; + + let params = await buildChainParams(feeAtoms); + let estimate = useMultichain + ? await this.options.relayerRepository.estimate7710TransactionMultichain( + paymentChain.relayerUrl, + params, + ) + : await this.options.relayerRepository.estimate7710Transaction( + paymentChain.relayerUrl, + params[0]!, + ); + + if ( + estimate.success && + estimate.requiredPaymentAmount && + tokenAmountFromAtomString(estimate.requiredPaymentAmount) > feeAtoms + ) { + feeAtoms = tokenAmountFromAtomString(estimate.requiredPaymentAmount); + if (onFinalFeeRequired) { + await onFinalFeeRequired({ + feeAtoms, + feeFormatted: formatUnits(feeAtoms, payment.usdcDecimals), + paymentToken: payment.paymentToken, + }); + } + + const nextFeeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, feeAtoms], + }), + ); + const adjustCopy = adjustFeeCeremony(); + feeDelegation = await withCeremonyUiReason( + EPasskeyPromptReason.AdjustFee, + () => + withCoalescedSignDigest(signer, adjustCopy, () => + this.createAndSignExactCalldataDelegation({ + smartAccount: paymentSmartAccount, + delegate: paymentCapabilities.targetAddress, + target: payment.paymentToken, + value: 0n, + callData: nextFeeCalldata, + chainIdNumber: paymentChainIdNumber, + }), + ), + ); + params = await buildChainParams(feeAtoms); + } + + if (!estimate.success) { + throw new Error( + estimate.error ?? "relayer activation estimate failed", + ); + } + + if (!retainDisplayDuringSubmit) { + await this.options.owsProvider.hideDisplay(); + } else { + onAwaitingConfirmation?.(); + } + + const contextByChainId = + estimate.contextByChainId ?? + (estimate.context + ? { + [Number(BigInt(payment.paymentChainId)).toString(10)]: + estimate.context, + } + : undefined); + params = await buildChainParams(feeAtoms, contextByChainId); + + const taskIds = useMultichain + ? await this.options.relayerRepository.send7710TransactionMultichain( + paymentChain.relayerUrl, + params, + ) + : [ + await this.options.relayerRepository.send7710Transaction( + paymentChain.relayerUrl, + params[0]!, + ), + ]; + + const orderedChainIds = orderedActivationChainIds( + upgradeChainIds, + payment.paymentChainId, + ); + + try { + const results = await Promise.all( + taskIds.map(async (taskId, i) => { + const chainId = orderedChainIds[i]!; + const hash = await this.pollUntilTerminal( + paymentChain.relayerUrl, + taskId, + ); + if ( + upgradeChainIds.some((id) => String(id) === String(chainId)) + ) { + await this.options.chainRepository.setWalletUpgraded( + chainId, + eoa, + true, + ); + } + return { + relayerTransactionId: taskId, + transactionHash: hash, + } satisfies ISendTransactionResult; + }), + ); + return results; + } catch (pollError) { + await Promise.all( + upgradeChainIds.map((chainId) => + this.options.chainRepository.setWalletUpgraded( + chainId, + eoa, + false, + ), + ), + ); + throw pollError; + } + } catch (error) { + if (!retainDisplayDuringSubmit) { + await this.options.owsProvider.hideDisplay(); + } + throw error; + } + } + async estimateNativeTransferFee(chainId: EVMChainId): Promise<{ gasPrice: bigint; maxPriorityFeePerGas: bigint; @@ -924,6 +1451,173 @@ export class TransactionUtils implements ITransactionUtils { } return chain; } + + private async readUsdcBalance( + chainId: EVMChainId, + owner: EVMAccountAddress, + ): Promise<{ + address: EVMAccountAddress; + symbol: string; + decimals: number; + balance: TokenAmount; + } | null> { + try { + const chain = await this.requireRelayerChain(chainId); + const [assets, capabilities] = await Promise.all([ + this.options.trackedAssetRepository.getBalances(owner, { chainId }), + this.options.relayerRepository.getCapabilities( + chain.relayerUrl, + chainId, + ), + ]); + const usdc = assets.find( + (asset) => + asset.type === EAssetType.Erc20 && + asset.symbol.toUpperCase() === "USDC", + ); + if (!usdc) return null; + const accepted = capabilities.tokens.some( + (token) => + String(token.address).toLowerCase() === + String(usdc.address).toLowerCase(), + ); + if (!accepted) return null; + return { + address: usdc.address, + symbol: usdc.symbol, + decimals: usdc.decimals, + balance: makeTokenAmount(usdc.balance ?? 0n), + }; + } catch { + return null; + } + } + + /** + * Build unsigned (placeholder) or shell params for activation estimate. + * Signed submit uses the coalesced ceremony path instead. + */ + private async buildActivationParams(args: { + eoa: EVMAccountAddress; + upgradeChainIds: readonly EVMChainId[]; + payment: IActivationPayment; + feeAtoms: TokenAmount; + signed: false; + }): Promise { + const { eoa, upgradeChainIds, payment, feeAtoms } = args; + const ordered = orderedActivationChainIds( + upgradeChainIds, + payment.paymentChainId, + ); + const viemAccount = await this.getViemAccount(eoa); + + return Promise.all( + ordered.map(async (chainId) => { + const isPayment = String(chainId) === String(payment.paymentChainId); + const needsUpgrade = upgradeChainIds.some( + (id) => String(id) === String(chainId), + ); + const chain = await this.requireRelayerChain(chainId); + const capabilities = + await this.options.relayerRepository.getCapabilities( + chain.relayerUrl, + chainId, + ); + const chainIdNumber = Number(BigInt(chainId)); + const client = this.options.blockchain.getPublicClient(chainId); + const smartAccount = await toMetaMaskSmartAccount({ + client: client as never, + implementation: Implementation.Stateless7702, + address: eoa, + signer: { account: viemAccount }, + }); + + const transactions: IRelayer7710Params["transactions"] = []; + + if (isPayment) { + const feeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [capabilities.feeCollector, feeAtoms], + }), + ); + const feeDelegation = this.createUnsignedExactCalldataDelegation({ + smartAccount, + delegate: capabilities.targetAddress, + target: payment.paymentToken, + value: 0n, + callData: feeCalldata, + chainIdNumber, + }); + transactions.push({ + permissionContext: [toRelayerJson(feeDelegation)], + executions: [ + { + target: payment.paymentToken, + value: "0", + data: feeCalldata as HexString, + }, + ], + }); + } + + if (needsUpgrade) { + const workDelegation = this.createUnsignedExactCalldataDelegation({ + smartAccount, + delegate: capabilities.targetAddress, + target: eoa, + value: 0n, + callData: EMPTY_CALLDATA, + chainIdNumber, + }); + transactions.push({ + permissionContext: [toRelayerJson(workDelegation)], + executions: [ + { + target: eoa, + value: "0", + data: EMPTY_CALLDATA as HexString, + }, + ], + }); + } + + let authorizationList: IRelayerAuthorizationEntry[] | undefined; + if (needsUpgrade) { + let contractAddress: `0x${string}` = STATELESS_DELEGATOR_IMPL; + try { + const env = getSmartAccountsEnvironment(chainIdNumber); + contractAddress = getAddress( + env.implementations.EIP7702StatelessDeleGatorImpl, + ); + } catch { + // keep hardcoded fallback + } + const nonce = await client.getTransactionCount({ + address: getAddress(eoa), + blockTag: "pending", + }); + authorizationList = [ + { + address: contractAddress, + chainId: chainIdNumber, + nonce, + r: PLACEHOLDER_AUTH_R, + s: PLACEHOLDER_AUTH_S, + yParity: 0, + }, + ]; + } + + return { + chainId: chainIdNumber.toString(10), + transactions, + ...(authorizationList ? { authorizationList } : {}), + } satisfies IRelayer7710Params; + }), + ); + } } function approveTransactionCeremony(includeUpgrade: boolean): CeremonyUiParams { @@ -944,6 +1638,28 @@ function adjustFeeCeremony(): CeremonyUiParams { }; } +function shouldUseActivationMultichain( + upgradeChainIds: readonly EVMChainId[], + paymentChainId: EVMChainId, +): boolean { + if (upgradeChainIds.length !== 1) return true; + return String(upgradeChainIds[0]) !== String(paymentChainId); +} + +/** Fee/payment chain first, then remaining upgrade chains. */ +function orderedActivationChainIds( + upgradeChainIds: readonly EVMChainId[], + paymentChainId: EVMChainId, +): EVMChainId[] { + const ordered: EVMChainId[] = [paymentChainId]; + for (const chainId of upgradeChainIds) { + if (String(chainId) !== String(paymentChainId)) { + ordered.push(chainId); + } + } + return ordered; +} + function pickPaymentToken( tokens: IPaymentTokenOption[], preferred?: EVMAccountAddress, diff --git a/src/lib/implementations/data/OneshotRelayerRepository.ts b/src/lib/implementations/data/OneshotRelayerRepository.ts index b03140e..0470421 100644 --- a/src/lib/implementations/data/OneshotRelayerRepository.ts +++ b/src/lib/implementations/data/OneshotRelayerRepository.ts @@ -2,8 +2,9 @@ import { EVMAccountAddress, EVMTransactionHash, HexString, - RelayerTransactionId, + RelayerTransactionIdSchema, type EVMChainId, + type RelayerTransactionId, } from "@1shotapi/ows-types"; import type { IOneshotRelayerRepository, @@ -119,32 +120,24 @@ export class OneshotRelayerRepository implements IOneshotRelayerRepository { relayerUrl: string, params: IRelayer7710Params, ): Promise { - const { context: _context, delegationSecret: _secret, ...estimateParams } = - params; - void _context; - void _secret; - - const result = await this.postJsonRpc<{ - success: boolean; - paymentTokenAddress?: string; - paymentChain?: number; - gasUsed?: Record; - requiredPaymentAmount?: string; - context?: string; - error?: string; - }>(relayerUrl, "relayer_estimate7710Transaction", estimateParams); + const result = await this.postJsonRpc( + relayerUrl, + "relayer_estimate7710Transaction", + stripEstimateFields(params), + ); + return mapEstimateResult(result); + } - return { - success: result.success, - paymentTokenAddress: result.paymentTokenAddress - ? EVMAccountAddress(result.paymentTokenAddress as `0x${string}`) - : undefined, - paymentChain: result.paymentChain, - gasUsed: result.gasUsed ?? {}, - requiredPaymentAmount: result.requiredPaymentAmount, - context: result.context, - error: result.error, - }; + async estimate7710TransactionMultichain( + relayerUrl: string, + params: IRelayer7710Params[], + ): Promise { + const result = await this.postJsonRpc( + relayerUrl, + "relayer_estimate7710TransactionMultichain", + params.map(stripEstimateFields), + ); + return mapEstimateResult(result); } async send7710Transaction( @@ -156,10 +149,26 @@ export class OneshotRelayerRepository implements IOneshotRelayerRepository { "relayer_send7710Transaction", params, ); - if (typeof result !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(result)) { - throw new Error("relayer_send7710Transaction returned an invalid task id"); + return parseTaskId(result, "relayer_send7710Transaction"); + } + + async send7710TransactionMultichain( + relayerUrl: string, + params: IRelayer7710Params[], + ): Promise { + const result = await this.postJsonRpc( + relayerUrl, + "relayer_send7710TransactionMultichain", + params, + ); + if (!Array.isArray(result) || result.length === 0) { + throw new Error( + "relayer_send7710TransactionMultichain returned an invalid task id list", + ); } - return RelayerTransactionId(result as `0x${string}`); + return result.map((id) => + parseTaskId(id, "relayer_send7710TransactionMultichain"), + ); } async getStatus( @@ -247,3 +256,48 @@ function relayerEndpoint(relayerUrl: string): string { function chainIdToDecimal(chainId: EVMChainId): string { return BigInt(chainId).toString(10); } + +type RawEstimateResult = { + success: boolean; + paymentTokenAddress?: string; + paymentChain?: number; + gasUsed?: Record; + requiredPaymentAmount?: string; + context?: string; + contextByChainId?: Record; + error?: string; +}; + +function stripEstimateFields(params: IRelayer7710Params): IRelayer7710Params { + const { context: _context, delegationSecret: _secret, ...estimateParams } = + params; + void _context; + void _secret; + return estimateParams; +} + +function mapEstimateResult(result: RawEstimateResult): IRelayerEstimateResult { + return { + success: result.success, + paymentTokenAddress: result.paymentTokenAddress + ? EVMAccountAddress(result.paymentTokenAddress as `0x${string}`) + : undefined, + paymentChain: result.paymentChain, + gasUsed: result.gasUsed ?? {}, + requiredPaymentAmount: result.requiredPaymentAmount, + context: result.context, + contextByChainId: result.contextByChainId, + error: result.error, + }; +} + +function parseTaskId( + result: unknown, + method: string, +): RelayerTransactionId { + const parsed = RelayerTransactionIdSchema.safeParse(result); + if (!parsed.success) { + throw new Error(`${method} returned an invalid task id`); + } + return parsed.data; +} diff --git a/src/lib/interfaces/business/ITransactionService.ts b/src/lib/interfaces/business/ITransactionService.ts index f7191f4..f1b8ce4 100644 --- a/src/lib/interfaces/business/ITransactionService.ts +++ b/src/lib/interfaces/business/ITransactionService.ts @@ -9,6 +9,7 @@ import type { } from "../data/IOneshotRelayerRepository"; import type { IRelayerSendUiCallbacks } from "../../types/domain/RelayerSendUi"; import type { TokenAmount } from "../../types/primitives"; +import type { IActivationPayment } from "./utils/ITransactionUtils"; export interface IPaymentTokenOption { address: EVMAccountAddress; @@ -70,6 +71,33 @@ export interface ITransactionService { preferredToken?: EVMAccountAddress, ): Promise; + /** + * Resolve USDC payment for EIP-7702 offline-permission activation among + * candidate chains (requested ∪ Arc). Null when none hold USDC. + */ + resolveActivationPayment( + owner: EVMAccountAddress, + candidateChainIds: readonly EVMChainId[], + ): Promise; + + /** Unsigned USDC fee quote for multi/single-chain EIP-7702 activation. */ + quoteActivation( + owner: EVMAccountAddress, + upgradeChainIds: readonly EVMChainId[], + payment: IActivationPayment, + ): Promise; + + /** + * Submit EIP-7702 activation (no-op work + USDC fee) and poll to confirm. + */ + activateDelegations( + args: { + upgradeChainIds: readonly EVMChainId[]; + payment: IActivationPayment; + feeAtoms: TokenAmount; + } & IRelayerSendUiCallbacks, + ): Promise; + /** * Branch on `SupportedChain.useRelayer`: * - false → prepare + sign + eth_sendRawTransaction diff --git a/src/lib/interfaces/business/index.ts b/src/lib/interfaces/business/index.ts index 00b4f47..c626aaf 100644 --- a/src/lib/interfaces/business/index.ts +++ b/src/lib/interfaces/business/index.ts @@ -36,7 +36,10 @@ export { IDelegationServiceType, } from "./IDelegationService"; export type { ExecutionPermissionType } from "./IDelegationService"; -export type { ITransactionUtils as IBusinessTransactionUtils } from "./utils/ITransactionUtils"; +export type { + IActivationPayment, + ITransactionUtils as IBusinessTransactionUtils, +} from "./utils/ITransactionUtils"; export { ITransactionUtilsType as IBusinessTransactionUtilsType, NATIVE_TRANSFER_GAS, diff --git a/src/lib/interfaces/business/utils/ITransactionUtils.ts b/src/lib/interfaces/business/utils/ITransactionUtils.ts index 1f9f65a..316bd81 100644 --- a/src/lib/interfaces/business/utils/ITransactionUtils.ts +++ b/src/lib/interfaces/business/utils/ITransactionUtils.ts @@ -11,6 +11,17 @@ import type { ITransactionWork, } from "../ITransactionService"; +/** Payment chain + USDC selected for offline-permission EIP-7702 activation. */ +export interface IActivationPayment { + paymentChainId: EVMChainId; + paymentToken: EVMAccountAddress; + /** Human-readable payment-chain label for the confirm modal. */ + paymentChainName: string; + usdcBalance: TokenAmount; + usdcDecimals: number; + usdcSymbol: string; +} + /** * Shared send / EIP-7702 / ExactCalldata delegation plumbing for * {@link ITransactionService} and {@link IDelegationService}. @@ -43,6 +54,26 @@ export interface ITransactionUtils { preferredToken?: EVMAccountAddress, ): Promise; + /** + * Pick USDC payment for EIP-7702 activation among `candidateChainIds` + * (requested ∪ Arc). Prefers Arc when its USDC balance is > 0; else the + * first candidate (in order) with USDC. Returns null when none have USDC. + */ + resolveActivationPayment( + owner: EVMAccountAddress, + candidateChainIds: readonly EVMChainId[], + ): Promise; + + /** + * Unsigned USDC fee quote for activating EIP-7702 on `upgradeChainIds`, + * paid on `paymentChainId`. Uses single-chain or multichain estimate. + */ + quoteActivation( + owner: EVMAccountAddress, + upgradeChainIds: readonly EVMChainId[], + payment: IActivationPayment, + ): Promise; + /** * Public-relayer ExactCalldata fee + work path: optional EIP-7702 upgrade, * estimate, send, poll. `work` may be one item (Send) or several @@ -59,6 +90,17 @@ export interface ITransactionUtils { prefetchRelayerVaultAssertion?: boolean; } & IRelayerSendUiCallbacks): Promise; + /** + * One-time EIP-7702 activation for offline permissions: no-op ExactCalldata + * work on each upgrade chain + USDC fee on the payment chain. Uses single + * or multichain 7710. Polls every task to confirmation and caches upgrades. + */ + activateDelegations(args: { + upgradeChainIds: readonly EVMChainId[]; + payment: IActivationPayment; + feeAtoms: TokenAmount; + } & IRelayerSendUiCallbacks): Promise; + /** * EIP-1559 `maxFeePerGas` (fallback `getGasPrice`) × 21000 for a plain * native transfer, plus {@link NATIVE_FEE_HEADROOM_BPS} headroom so Max / diff --git a/src/lib/interfaces/business/utils/index.ts b/src/lib/interfaces/business/utils/index.ts index ffcaacd..547d8cd 100644 --- a/src/lib/interfaces/business/utils/index.ts +++ b/src/lib/interfaces/business/utils/index.ts @@ -1,4 +1,4 @@ -export type { ITransactionUtils } from "./ITransactionUtils"; +export type { IActivationPayment, ITransactionUtils } from "./ITransactionUtils"; export { ITransactionUtilsType, NATIVE_TRANSFER_GAS, diff --git a/src/lib/interfaces/data/IOneshotRelayerRepository.ts b/src/lib/interfaces/data/IOneshotRelayerRepository.ts index 41bff70..9d33511 100644 --- a/src/lib/interfaces/data/IOneshotRelayerRepository.ts +++ b/src/lib/interfaces/data/IOneshotRelayerRepository.ts @@ -74,6 +74,8 @@ export interface IRelayerEstimateResult { gasUsed: Record; requiredPaymentAmount?: string; context?: string; + /** Per-chain signed quotes for multichain send (`params[i].context`). */ + contextByChainId?: Record; error?: string; } @@ -111,11 +113,21 @@ export interface IOneshotRelayerRepository { params: IRelayer7710Params, ): Promise; + estimate7710TransactionMultichain( + relayerUrl: string, + params: IRelayer7710Params[], + ): Promise; + send7710Transaction( relayerUrl: string, params: IRelayer7710Params, ): Promise; + send7710TransactionMultichain( + relayerUrl: string, + params: IRelayer7710Params[], + ): Promise; + getStatus( relayerUrl: string, taskId: RelayerTransactionId, diff --git a/src/style/applyStyle.ts b/src/style/applyStyle.ts index 15728e4..be9d02c 100644 --- a/src/style/applyStyle.ts +++ b/src/style/applyStyle.ts @@ -82,6 +82,10 @@ export function mergeStyle( ...current.copy.cancelDelegation, ...patch.copy?.cancelDelegation, }, + activateOfflinePermissions: { + ...current.copy.activateOfflinePermissions, + ...patch.copy?.activateOfflinePermissions, + }, relayerSubmit: { ...current.copy.relayerSubmit, ...patch.copy?.relayerSubmit, @@ -235,6 +239,9 @@ function cloneDefaultStyle(): IResolvedStyle { ...DEFAULT_STYLE.copy.grantLiFiApprovePermission, }, cancelDelegation: { ...DEFAULT_STYLE.copy.cancelDelegation }, + activateOfflinePermissions: { + ...DEFAULT_STYLE.copy.activateOfflinePermissions, + }, relayerSubmit: { ...DEFAULT_STYLE.copy.relayerSubmit }, passkeyPrompt: { unlock: { ...DEFAULT_STYLE.copy.passkeyPrompt.unlock }, diff --git a/src/style/configureSchemas.ts b/src/style/configureSchemas.ts index b7e15c5..59cce80 100644 --- a/src/style/configureSchemas.ts +++ b/src/style/configureSchemas.ts @@ -329,6 +329,18 @@ export const styleCopyCancelDelegationSchema = z.strictObject({ skipOnchainAcknowledgement: z.string(), }); +export const styleCopyActivateOfflinePermissionsSchema = z.strictObject({ + title: z.string(), + body: z.string(), + chainsLabel: z.string(), + payFromLabel: z.string(), + feeLabel: z.string(), + insufficientBalanceError: z.string(), + noUsdcError: z.string(), + rejectLabel: z.string(), + confirmLabel: z.string(), +}); + /** Shared relayer TX confirm phases (estimate → sign → final fee → submit). */ export const styleCopyRelayerSubmitSchema = z.strictObject({ finalFeeNotice: z.string(), @@ -529,6 +541,7 @@ export const styleCopyResolvedSchema = z.strictObject({ grantLiFiSwapPermission: styleCopyGrantLiFiSwapPermissionSchema, grantLiFiApprovePermission: styleCopyGrantLiFiApprovePermissionSchema, cancelDelegation: styleCopyCancelDelegationSchema, + activateOfflinePermissions: styleCopyActivateOfflinePermissionsSchema, relayerSubmit: styleCopyRelayerSubmitSchema, passkeyPrompt: styleCopyPasskeyPromptSchema, credentialOffer: styleCopyCredentialOfferSchema, @@ -580,6 +593,8 @@ export const styleCopyPatchSchema = z.strictObject({ grantLiFiApprovePermission: styleCopyGrantLiFiApprovePermissionSchema.partial().optional(), cancelDelegation: styleCopyCancelDelegationSchema.partial().optional(), + activateOfflinePermissions: + styleCopyActivateOfflinePermissionsSchema.partial().optional(), relayerSubmit: styleCopyRelayerSubmitSchema.partial().optional(), passkeyPrompt: passkeyPromptPatchSchema.optional(), credentialOffer: styleCopyCredentialOfferSchema.partial().optional(), @@ -652,6 +667,9 @@ export type IStyleCopyGrantLiFiApprovePermission = z.infer< export type IStyleCopyCancelDelegation = z.infer< typeof styleCopyCancelDelegationSchema >; +export type IStyleCopyActivateOfflinePermissions = z.infer< + typeof styleCopyActivateOfflinePermissionsSchema +>; export type IStyleCopyRelayerSubmit = z.infer< typeof styleCopyRelayerSubmitSchema >; diff --git a/src/style/defaults.ts b/src/style/defaults.ts index 4d9fe7c..acd71db 100644 --- a/src/style/defaults.ts +++ b/src/style/defaults.ts @@ -327,6 +327,19 @@ export const DEFAULT_STYLE: IResolvedStyle = { 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", }, + activateOfflinePermissions: { + title: "Activate offline permissions", + body: "This is your first time using offline permissions. You must activate the feature on your account with a one-time transaction.", + chainsLabel: "Networks to activate", + payFromLabel: "Pay fee from", + feeLabel: "Activation fee", + insufficientBalanceError: + "Insufficient USDC to pay the activation fee on {chainName}.", + noUsdcError: + "Hold USDC on Arc or a requested network to activate offline permissions.", + rejectLabel: "Cancel", + confirmLabel: "Activate", + }, relayerSubmit: { finalFeeNotice: "The relayer fee changed after signing. Review the final fee before submitting.", diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index b9fb422..a9508b9 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -165,6 +165,7 @@ const credentialRepository = new CachedRelayerVaultRepository({ const businessTransactionUtils = new BusinessTransactionUtils({ chainRepository, relayerRepository: oneshotRelayerRepository, + trackedAssetRepository, blockchain: blockchainProvider, presentationTransactionUtils: transactionUtils, owsProvider, diff --git a/src/wallet/modalTypes.ts b/src/wallet/modalTypes.ts index 5cc4f7d..8bb6788 100644 --- a/src/wallet/modalTypes.ts +++ b/src/wallet/modalTypes.ts @@ -23,6 +23,7 @@ import type { import type { TokenAmount } from "../lib/types/primitives"; import type { IRelayerSendUiCallbacks } from "../lib/types/domain/RelayerSendUi"; import type { ITransactionWork } from "../lib/interfaces/business/ITransactionService"; +import type { IActivationPayment } from "../lib/interfaces/business/utils/ITransactionUtils"; export type WalletSetupChoice = "login" | "create" | "import" | "cancel"; @@ -96,6 +97,15 @@ export interface ICancelDelegationConfirmRequest { allowSkipOnchain: boolean; } +/** One-time EIP-7702 activation before an EIP-7715 grant. */ +export interface IActivateOfflinePermissionsRequest { + domain: string; + ownerAddress: EVMAccountAddress; + /** Chains that still need EIP-7702 upgrade for this grant request. */ + upgradeChains: Array<{ chainId: EVMChainId; chainName: string }>; + payment: IActivationPayment; +} + export type ModalRequest = | { id: string; @@ -182,6 +192,17 @@ export type ModalRequest = resolve: (results: IGrantExecutionPermissionResult[]) => void; reject: (error: unknown) => void; } + | { + id: string; + kind: "activateOfflinePermissions"; + request: IActivateOfflinePermissionsRequest; + execute: ( + payment: IRelayerConfirmSendResult, + ui: IRelayerSendUiCallbacks, + ) => Promise; + resolve: (hash: EVMTransactionHash) => void; + reject: (error: unknown) => void; + } | { id: string; kind: "cancelDelegation"; diff --git a/src/wallet/useWalletBoot.ts b/src/wallet/useWalletBoot.ts index 12e108d..41cfef2 100644 --- a/src/wallet/useWalletBoot.ts +++ b/src/wallet/useWalletBoot.ts @@ -34,6 +34,7 @@ import { registerCredentialsProvider } from "../ows/registerCredentialsProvider" import { registerConfigureRpc } from "../style/registerConfigure"; import { wrapSignerWithCeremonyCopy } from "./wrapSignerWithCeremonyCopy"; import { DEFAULT_CHAIN_ID } from "../lib/implementations/data/HardcodedChainRepository"; +import { styleController } from "../style/styleController"; import { analyticsErrorCode, isAnalyticsCancelled, @@ -373,6 +374,135 @@ export function useWalletBoot({ const { hostDomain } = await configProvider.getConfig(); const signStartedBatch = performance.now(); const account = analyticsAccountAddress(); + + const owner = + useWalletSessionStore.getState().evmAddress || + loadCachedEvmAddress(); + if (!owner) { + throw new OwsInvalidParamsError( + "Wallet address is required to grant execution permissions", + ); + } + + const requestedChainIds = [ + ...new Map( + prepared.map(({ request }) => [ + String(request.chainId), + request.chainId, + ] as const), + ).values(), + ]; + + const upgradeChecks = await Promise.all( + requestedChainIds.map(async (chainId) => ({ + chainId, + needsUpgrade: await transactionService.needsWalletUpgrade( + chainId, + owner, + ), + })), + ); + const upgradeChainIds = upgradeChecks + .filter((row) => row.needsUpgrade) + .map((row) => row.chainId); + + if (upgradeChainIds.length > 0) { + const candidateChainIds = [ + ...new Map( + [...requestedChainIds, DEFAULT_CHAIN_ID].map( + (chainId) => [String(chainId), chainId] as const, + ), + ).values(), + ]; + const payment = + await transactionService.resolveActivationPayment( + owner, + candidateChainIds, + ); + if (!payment) { + throw new OwsInvalidParamsError( + styleController.get().copy.activateOfflinePermissions + .noUsdcError, + ); + } + + const upgradeChains = upgradeChainIds.map((chainId) => { + const preparedItem = prepared.find( + (item) => + String(item.request.chainId) === String(chainId), + ); + return { + chainId, + chainName: + preparedItem?.chain.label ?? + resolveChain(chainId)?.label ?? + String(chainId), + }; + }); + + try { + await ask( + ({ id, resolve, reject }) => ({ + id, + kind: "activateOfflinePermissions", + request: { + domain, + ownerAddress: owner, + upgradeChains, + payment, + }, + execute: async ( + confirmPayment: IRelayerConfirmSendResult, + ui, + ) => { + const results = + await transactionService.activateDelegations({ + upgradeChainIds, + payment, + feeAtoms: confirmPayment.feeAtoms, + ...ui, + }); + const last = results[results.length - 1]; + if (!last) { + throw new Error( + "Activation returned no transaction results", + ); + } + return last.transactionHash; + }, + resolve, + reject, + }), + ); + } catch (error: unknown) { + const durationMs = Math.round( + performance.now() - signStartedBatch, + ); + const chainId = upgradeChainIds[0] ?? requestedChainIds[0]!; + if (isAnalyticsCancelled(error)) { + eventBus.emitAnalytics( + new DelegationCreateCancelledEvent( + hostDomain, + account, + chainId, + durationMs, + ), + ); + } else { + eventBus.emitAnalytics( + new DelegationCreateFailedEvent( + hostDomain, + account, + chainId, + analyticsErrorCode(error), + durationMs, + ), + ); + } + throw error; + } + } + let approvedResults: IGrantExecutionPermissionResult[]; try { approvedResults = From 8f04d80fca0a440d0cbcfa88a32b8c5469407b4f Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Wed, 23 Sep 2026 17:07:42 -0700 Subject: [PATCH 2/8] Enhance wallet functionality with getUpgraded RPC and offline permissions UI - Added `getUpgraded` custom RPC method for checking EIP-7702 upgrade status of the unlocked EOA on a specified chain. - Introduced new UI components in the Wallet Configurator for activating offline permissions, including labels and error messages. - Updated `IStyleFormState` and relevant interfaces to support new properties related to offline permissions. - Enhanced `TransactionService` and `TransactionUtils` with methods for wallet upgrade status checks. - Integrated the new RPC method into the wallet boot process for improved user experience. --- .../skills/oneshot-embedded-wallet/SKILL.md | 23 +++++- ...alletConfiguratorTextTabWalletSections.tsx | 40 ++++++++++ host/src/styleForm.ts | 44 +++++++++++ .../business/TransactionService.ts | 13 +++- .../business/utils/TransactionUtils.ts | 51 ++++++++---- .../business/ITransactionService.ts | 8 +- src/lib/interfaces/business/index.ts | 1 - .../business/utils/ITransactionUtils.ts | 28 ++++--- src/lib/interfaces/business/utils/index.ts | 2 +- src/lib/types/domain/ActivationPayment.ts | 16 ++++ src/lib/types/domain/WalletUpgradeStatus.ts | 8 ++ src/lib/types/domain/index.ts | 3 + src/wallet/modalTypes.ts | 4 +- src/wallet/registerGetUpgraded.ts | 78 +++++++++++++++++++ src/wallet/useWalletBoot.ts | 12 +++ 15 files changed, 295 insertions(+), 36 deletions(-) create mode 100644 src/lib/types/domain/ActivationPayment.ts create mode 100644 src/lib/types/domain/WalletUpgradeStatus.ts create mode 100644 src/wallet/registerGetUpgraded.ts diff --git a/.agents/skills/oneshot-embedded-wallet/SKILL.md b/.agents/skills/oneshot-embedded-wallet/SKILL.md index aae79a9..f3c0075 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 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. @@ -353,7 +372,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`, …) | | `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: diff --git a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx index 58d3261..f488c93 100644 --- a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx +++ b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx @@ -355,6 +355,46 @@ export function WalletConfiguratorTextTabWalletSections({ patch("activateOfflinePermissionsBody", value) } /> + + patch("activateOfflinePermissionsChainsLabel", value) + } + /> + + patch("activateOfflinePermissionsPayFromLabel", value) + } + /> + + patch("activateOfflinePermissionsFeeLabel", value) + } + /> + + patch("activateOfflinePermissionsInsufficientBalanceError", value) + } + /> + + patch("activateOfflinePermissionsNoUsdcError", value) + } + /> 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", diff --git a/src/lib/implementations/business/TransactionService.ts b/src/lib/implementations/business/TransactionService.ts index 24f1b8e..c98a6a6 100644 --- a/src/lib/implementations/business/TransactionService.ts +++ b/src/lib/implementations/business/TransactionService.ts @@ -16,8 +16,9 @@ import type { ITransactionWork, } from "../../interfaces/business/ITransactionService"; import type { ITransactionUtils } from "../../interfaces/business/utils/ITransactionUtils"; -import type { IActivationPayment } from "../../interfaces/business/utils/ITransactionUtils"; +import type { IActivationPayment } from "../../types/domain/ActivationPayment"; import type { IRelayerSendUiCallbacks } from "../../types/domain/RelayerSendUi"; +import type { IWalletUpgradeStatus } from "../../types/domain/WalletUpgradeStatus"; import type { TokenAmount } from "../../types/primitives"; const EMPTY_CALLDATA = HexString("0x"); @@ -44,6 +45,16 @@ export class TransactionService implements ITransactionService { return this.options.transactionUtils.needsWalletUpgrade(chainId, address); } + getWalletUpgradeStatus( + chainId: EVMChainId, + address: EVMAccountAddress, + ): Promise { + return this.options.transactionUtils.getWalletUpgradeStatus( + chainId, + address, + ); + } + signWalletUpgradeAuthorization( chainId: EVMChainId, ): Promise { diff --git a/src/lib/implementations/business/utils/TransactionUtils.ts b/src/lib/implementations/business/utils/TransactionUtils.ts index f88d992..0aa2348 100644 --- a/src/lib/implementations/business/utils/TransactionUtils.ts +++ b/src/lib/implementations/business/utils/TransactionUtils.ts @@ -9,6 +9,7 @@ import { toViemLocalAccount } from "@1shotapi/ows-signer-utils"; import type { IBlockchainProvider } from "@1shotapi/ows-wallet-utils"; import { EVMAccountAddress, + EVMContractAddress, EVMTransactionHash, type CeremonyUiParams, type EVMChainId, @@ -43,13 +44,14 @@ import { NATIVE_TRANSFER_GAS, maxNativeSendable, withNativeFeeHeadroom, - type IActivationPayment, type ITransactionUtils, } from "../../../interfaces/business/utils/ITransactionUtils"; import type { ITransactionUtils as IPresentationTransactionUtils } from "../../../interfaces/utils/ITransactionUtils"; import type { IOWSProvider } from "../../../interfaces/utils/IOWSProvider"; -import { EPasskeyPromptReason } from "../../../types/enum/EPasskeyPromptReason"; +import type { IActivationPayment } from "../../../types/domain/ActivationPayment"; import type { IFinalRelayerFee } from "../../../types/domain/RelayerSendUi"; +import type { IWalletUpgradeStatus } from "../../../types/domain/WalletUpgradeStatus"; +import { EPasskeyPromptReason } from "../../../types/enum/EPasskeyPromptReason"; import { EAssetType } from "../../../types/enum/EAssetType"; import { makeTokenAmount, @@ -128,19 +130,15 @@ export class TransactionUtils implements ITransactionUtils { // send (before confirm) or after a failed upgrade leaves the account // unable to estimate on that chain. try { - const upgraded = await this.isCodeUpgraded(chainId, address); - await this.options.chainRepository.setWalletUpgraded( - chainId, - address, - upgraded, - ); + const status = await this.getWalletUpgradeStatus(chainId, address); console.debug("[business/TransactionUtils] EIP-7702 upgrade check", { chainId, address, - upgraded, - needsUpgrade: !upgraded, + upgraded: status.upgraded, + codeAddress: status.codeAddress, + needsUpgrade: !status.upgraded, }); - return !upgraded; + return !status.upgraded; } catch (error) { // Fail open: include an authorization rather than omit one when getCode // is unreachable (e.g. RPC origin allowlist / transient failure). @@ -152,6 +150,19 @@ export class TransactionUtils implements ITransactionUtils { } } + async getWalletUpgradeStatus( + chainId: EVMChainId, + address: EVMAccountAddress, + ): Promise { + const status = await this.readCodeUpgradeStatus(chainId, address); + await this.options.chainRepository.setWalletUpgraded( + chainId, + address, + status.upgraded, + ); + return status; + } + async signWalletUpgradeAuthorization( chainId: EVMChainId, ): Promise { @@ -1414,13 +1425,15 @@ export class TransactionUtils implements ITransactionUtils { throw new Error("Timed out waiting for relayer transaction status"); } - private async isCodeUpgraded( + private async readCodeUpgradeStatus( chainId: EVMChainId, address: EVMAccountAddress, - ): Promise { + ): Promise { const client = this.options.blockchain.getPublicClient(chainId); const code = await client.getCode({ address }); - if (!code || code === "0x") return false; + if (!code || code === "0x") { + return { upgraded: false }; + } let impl = STATELESS_DELEGATOR_IMPL.toLowerCase(); try { @@ -1435,10 +1448,16 @@ export class TransactionUtils implements ITransactionUtils { // Do not substring-match the impl inside arbitrary bytecode — that can // false-positive and skip authorization on a chain that is not upgraded. if (!(normalized.startsWith("0xef0100") && normalized.length >= 48)) { - return false; + return { upgraded: false }; } const delegated = `0x${normalized.slice(8, 48)}`; - return delegated === impl; + if (delegated !== impl) { + return { upgraded: false }; + } + return { + upgraded: true, + codeAddress: EVMContractAddress(getAddress(delegated)), + }; } private async requireRelayerChain(chainId: EVMChainId) { diff --git a/src/lib/interfaces/business/ITransactionService.ts b/src/lib/interfaces/business/ITransactionService.ts index f1b8ce4..0dffc67 100644 --- a/src/lib/interfaces/business/ITransactionService.ts +++ b/src/lib/interfaces/business/ITransactionService.ts @@ -7,9 +7,10 @@ import type { IRelayerAuthorizationEntry, ISendTransactionResult, } from "../data/IOneshotRelayerRepository"; +import type { IActivationPayment } from "../../types/domain/ActivationPayment"; import type { IRelayerSendUiCallbacks } from "../../types/domain/RelayerSendUi"; +import type { IWalletUpgradeStatus } from "../../types/domain/WalletUpgradeStatus"; import type { TokenAmount } from "../../types/primitives"; -import type { IActivationPayment } from "./utils/ITransactionUtils"; export interface IPaymentTokenOption { address: EVMAccountAddress; @@ -54,6 +55,11 @@ export interface ITransactionService { address: EVMAccountAddress, ): Promise; + getWalletUpgradeStatus( + chainId: EVMChainId, + address: EVMAccountAddress, + ): Promise; + signWalletUpgradeAuthorization( chainId: EVMChainId, ): Promise; diff --git a/src/lib/interfaces/business/index.ts b/src/lib/interfaces/business/index.ts index c626aaf..c4ff009 100644 --- a/src/lib/interfaces/business/index.ts +++ b/src/lib/interfaces/business/index.ts @@ -37,7 +37,6 @@ export { } from "./IDelegationService"; export type { ExecutionPermissionType } from "./IDelegationService"; export type { - IActivationPayment, ITransactionUtils as IBusinessTransactionUtils, } from "./utils/ITransactionUtils"; export { diff --git a/src/lib/interfaces/business/utils/ITransactionUtils.ts b/src/lib/interfaces/business/utils/ITransactionUtils.ts index 316bd81..5782f95 100644 --- a/src/lib/interfaces/business/utils/ITransactionUtils.ts +++ b/src/lib/interfaces/business/utils/ITransactionUtils.ts @@ -1,27 +1,21 @@ import type { LocalAccount } from "viem/accounts"; -import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; +import type { + EVMAccountAddress, + EVMChainId, +} from "@1shotapi/ows-types"; import type { IRelayerAuthorizationEntry, ISendTransactionResult, } from "../../data/IOneshotRelayerRepository"; +import type { IActivationPayment } from "../../../types/domain/ActivationPayment"; import type { IRelayerSendUiCallbacks } from "../../../types/domain/RelayerSendUi"; +import type { IWalletUpgradeStatus } from "../../../types/domain/WalletUpgradeStatus"; import type { TokenAmount } from "../../../types/primitives"; import type { IPaymentQuote, ITransactionWork, } from "../ITransactionService"; -/** Payment chain + USDC selected for offline-permission EIP-7702 activation. */ -export interface IActivationPayment { - paymentChainId: EVMChainId; - paymentToken: EVMAccountAddress; - /** Human-readable payment-chain label for the confirm modal. */ - paymentChainName: string; - usdcBalance: TokenAmount; - usdcDecimals: number; - usdcSymbol: string; -} - /** * Shared send / EIP-7702 / ExactCalldata delegation plumbing for * {@link ITransactionService} and {@link IDelegationService}. @@ -35,6 +29,16 @@ export interface ITransactionUtils { address: EVMAccountAddress, ): Promise; + /** + * On-chain EIP-7702 status for `address` on `chainId`. Syncs the + * per-chain upgrade cache. Throws when getCode fails (callers that + * fail-open should catch). + */ + getWalletUpgradeStatus( + chainId: EVMChainId, + address: EVMAccountAddress, + ): Promise; + signWalletUpgradeAuthorization( chainId: EVMChainId, ): Promise; diff --git a/src/lib/interfaces/business/utils/index.ts b/src/lib/interfaces/business/utils/index.ts index 547d8cd..ffcaacd 100644 --- a/src/lib/interfaces/business/utils/index.ts +++ b/src/lib/interfaces/business/utils/index.ts @@ -1,4 +1,4 @@ -export type { IActivationPayment, ITransactionUtils } from "./ITransactionUtils"; +export type { ITransactionUtils } from "./ITransactionUtils"; export { ITransactionUtilsType, NATIVE_TRANSFER_GAS, diff --git a/src/lib/types/domain/ActivationPayment.ts b/src/lib/types/domain/ActivationPayment.ts new file mode 100644 index 0000000..0255233 --- /dev/null +++ b/src/lib/types/domain/ActivationPayment.ts @@ -0,0 +1,16 @@ +import type { + EVMAccountAddress, + EVMChainId, +} from "@1shotapi/ows-types"; +import type { TokenAmount } from "../primitives"; + +/** Payment chain + USDC selected for offline-permission EIP-7702 activation. */ +export interface IActivationPayment { + paymentChainId: EVMChainId; + paymentToken: EVMAccountAddress; + /** Human-readable payment-chain label for the confirm modal. */ + paymentChainName: string; + usdcBalance: TokenAmount; + usdcDecimals: number; + usdcSymbol: string; +} diff --git a/src/lib/types/domain/WalletUpgradeStatus.ts b/src/lib/types/domain/WalletUpgradeStatus.ts new file mode 100644 index 0000000..5e3f4d5 --- /dev/null +++ b/src/lib/types/domain/WalletUpgradeStatus.ts @@ -0,0 +1,8 @@ +import type { EVMContractAddress } from "@1shotapi/ows-types"; + +/** On-chain EIP-7702 upgrade status for an EOA on one chain. */ +export interface IWalletUpgradeStatus { + upgraded: boolean; + /** StatelessDelegator implementation when {@link upgraded} is true. */ + codeAddress?: EVMContractAddress; +} diff --git a/src/lib/types/domain/index.ts b/src/lib/types/domain/index.ts index 60b0d16..4953353 100644 --- a/src/lib/types/domain/index.ts +++ b/src/lib/types/domain/index.ts @@ -1,4 +1,5 @@ export { AssetActivity } from "./AssetActivity"; +export type { IActivationPayment } from "./ActivationPayment"; export { BitcoinUtxo } from "./BitcoinUtxo"; export { KnownAsset } from "./KnownAsset"; export { NewTrackedAsset, TrackedAsset } from "./TrackedAsset"; @@ -16,4 +17,6 @@ export type { IRecoveredCredentialBlob, IRelayerCredentialsErrorBody, } from "./RelayerCredentials"; +export type { ISiweFields } from "./SiweFields"; +export type { IWalletUpgradeStatus } from "./WalletUpgradeStatus"; export { WalletConfig } from "./WalletConfig"; diff --git a/src/wallet/modalTypes.ts b/src/wallet/modalTypes.ts index 8bb6788..9ec0d6c 100644 --- a/src/wallet/modalTypes.ts +++ b/src/wallet/modalTypes.ts @@ -13,6 +13,8 @@ import type { IExecutionPermission, IExecutionPermissionRequest, } from "@1shotapi/ows-types"; +import type { IActivationPayment } from "../lib/types/domain/ActivationPayment"; +import type { IRelayerSendUiCallbacks } from "../lib/types/domain/RelayerSendUi"; import type { ISiweFields } from "../lib/types/domain/SiweFields"; import type { IAddAssetApprovalRequest } from "./registerAddAsset"; import type { IOnrampOpenRequest } from "../circle/onrampTypes"; @@ -21,9 +23,7 @@ import type { ICctpBridgeOpenRequest, } from "../circle/cctpBridgeTypes"; import type { TokenAmount } from "../lib/types/primitives"; -import type { IRelayerSendUiCallbacks } from "../lib/types/domain/RelayerSendUi"; import type { ITransactionWork } from "../lib/interfaces/business/ITransactionService"; -import type { IActivationPayment } from "../lib/interfaces/business/utils/ITransactionUtils"; export type WalletSetupChoice = "login" | "create" | "import" | "cancel"; diff --git a/src/wallet/registerGetUpgraded.ts b/src/wallet/registerGetUpgraded.ts new file mode 100644 index 0000000..366c3fb --- /dev/null +++ b/src/wallet/registerGetUpgraded.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +import type { OWSWallet } from "@1shotapi/ows-wallet-utils"; +import { + ChainUtils, + type EVMAccountAddress, + type EVMContractAddress, +} from "@1shotapi/ows-types"; +import type { ITransactionService } from "../lib/interfaces/business"; + +/** Custom RPC — host: `await proxy.rpc("getUpgraded", { chainId })`. */ +export const GET_UPGRADED_RPC_METHOD = "getUpgraded"; + +const getUpgradedParamsSchema = z.strictObject({ + chainId: z.string().regex(/^(0x[0-9a-fA-F]+|Bitcoin|BitcoinTestnet)$/), +}); + +export type IGetUpgradedParams = z.infer; + +export type IGetUpgradedResult = { + upgraded: boolean; + codeAddress?: EVMContractAddress; + error?: string; +}; + +export type RegisterGetUpgradedOptions = { + getOwnerAddress: () => EVMAccountAddress | null; + transactionService: ITransactionService; +}; + +/** + * Register host `getUpgraded` RPC — read-only EIP-7702 status for the + * unlocked EOA on a chain. Non-EVM chain ids return a soft error in the + * result (not a thrown RPC error). + */ +export function registerGetUpgradedRpc( + wallet: OWSWallet, + options: RegisterGetUpgradedOptions, +): void { + wallet.registerRpc( + GET_UPGRADED_RPC_METHOD, + async (params) => { + const { chainId: raw } = params as IGetUpgradedParams; + + if (ChainUtils.isBitcoinChainId(raw)) { + return { + upgraded: false, + error: `Chain ${raw} is not an EVM chain`, + } satisfies IGetUpgradedResult; + } + + const owner = options.getOwnerAddress(); + if (!owner) { + throw new Error("Wallet is locked — unlock before getUpgraded"); + } + + try { + const chainId = ChainUtils.asEVMChainId(raw); + const status = await options.transactionService.getWalletUpgradeStatus( + chainId, + owner, + ); + return { + upgraded: status.upgraded, + ...(status.codeAddress ? { codeAddress: status.codeAddress } : {}), + } satisfies IGetUpgradedResult; + } catch (error: unknown) { + return { + upgraded: false, + error: + error instanceof Error + ? error.message + : `Failed to check upgrade status for chain ${raw}`, + } satisfies IGetUpgradedResult; + } + }, + getUpgradedParamsSchema, + ); +} diff --git a/src/wallet/useWalletBoot.ts b/src/wallet/useWalletBoot.ts index 41cfef2..68ff126 100644 --- a/src/wallet/useWalletBoot.ts +++ b/src/wallet/useWalletBoot.ts @@ -90,6 +90,7 @@ import { registerFocusModeRpc } from "./registerFocusMode"; import { registerSwitchChainRpc } from "./registerSwitchChain"; import { registerOnrampRpc } from "./registerOnramp"; import { registerBridgeRpc } from "./registerBridge"; +import { registerGetUpgradedRpc } from "./registerGetUpgraded"; import { registerBitcoinProvider } from "../ows/registerBitcoinProvider"; import { loadCachedEvmAddress, loadCredentialId } from "../storage"; import { hydrateBitcoinAddressesFromCachedSecp } from "./hydrateBitcoinAddresses"; @@ -741,6 +742,17 @@ export function useWalletBoot({ }, }); + registerGetUpgradedRpc(wallet, { + getOwnerAddress: () => { + const address = useWalletSessionStore.getState().evmAddress; + if (!address || String(address).toLowerCase() === "0x0") { + return null; + } + return address; + }, + transactionService, + }); + registerBridgeRpc(wallet, { getOwnerAddress: () => { const address = useWalletSessionStore.getState().evmAddress; From eb0ac317094b500e2855ad480ff5c7713f989f0e Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Wed, 23 Sep 2026 18:20:07 -0700 Subject: [PATCH 3/8] Refactor TransactionUtils and enhance wallet boot process for EIP-7702 activation - Updated `TransactionUtils` to utilize `EVMContractAddress` and `EVMAccountAddress` for better type safety. - Introduced `ACTIVATION_NOOP_TARGET` for handling no-op calls during EIP-7702 activation. - Enhanced chain ID handling by replacing string conversions with `chainIdKey` and `sameEvmChainId` for consistency. - Modified wallet boot process to include activation candidate chains and ensure payment chain upgrades are checked. - Updated `ICancelDelegationConfirmRequest` interface to clarify upgrade chain requirements for activation requests. --- .../business/utils/TransactionUtils.ts | 153 +++++++++++++----- src/wallet/modalTypes.ts | 5 +- src/wallet/useWalletBoot.ts | 45 ++++-- 3 files changed, 147 insertions(+), 56 deletions(-) diff --git a/src/lib/implementations/business/utils/TransactionUtils.ts b/src/lib/implementations/business/utils/TransactionUtils.ts index 0aa2348..2ab752f 100644 --- a/src/lib/implementations/business/utils/TransactionUtils.ts +++ b/src/lib/implementations/business/utils/TransactionUtils.ts @@ -72,7 +72,7 @@ import { DEFAULT_CHAIN_ID } from "../../data/HardcodedChainRepository"; import "../../utils/registerSmartAccountsEnvironments"; const STATELESS_DELEGATOR_IMPL = - "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B" as const; + EVMContractAddress("0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B"); /** * 65-byte zero signature for `relayer_estimate7710Transaction` unsigned @@ -93,6 +93,10 @@ const LEGACY_DELEGATION_SECRET_KEY = "oneshot.delegationSecret"; const POLL_MS = 1000; const MAX_POLL_ATTEMPTS = 180; const EMPTY_CALLDATA = "0x" as Hex; +/** Safe no-op call target: empty calldata to the EOA hits the estimate shim + * (or StatelessDelegator) fallback and reverts. Zero address accepts it. */ +const ACTIVATION_NOOP_TARGET = + EVMAccountAddress("0x0000000000000000000000000000000000000000"); type ExactCalldataDelegationArgs = { smartAccount: Awaited>; @@ -103,6 +107,12 @@ type ExactCalldataDelegationArgs = { chainIdNumber: number; }; +/** EIP-7702 activation no-op: empty calldata, zero native value. */ +type ActivationNoOpDelegationArgs = { + smartAccount: Awaited>; + delegate: EVMAccountAddress; +}; + export type TransactionUtilsOptions = { chainRepository: IChainRepository; relayerRepository: IOneshotRelayerRepository; @@ -408,7 +418,7 @@ export class TransactionUtils implements ITransactionUtils { const unique: EVMChainId[] = []; const seen = new Set(); for (const id of candidateChainIds) { - const key = String(id); + const key = chainIdKey(id); if (seen.has(key)) continue; seen.add(key); unique.push(id); @@ -426,8 +436,8 @@ export class TransactionUtils implements ITransactionUtils { ); if (withUsdc.length === 0) return null; - const preferArc = withUsdc.find( - (row) => String(row.chainId) === String(DEFAULT_CHAIN_ID), + const preferArc = withUsdc.find((row) => + sameEvmChainId(row.chainId, DEFAULT_CHAIN_ID), ); const picked = preferArc ?? withUsdc[0]!; const chain = await this.requireRelayerChain(picked.chainId); @@ -596,19 +606,19 @@ export class TransactionUtils implements ITransactionUtils { > >(); chainSmartAccounts.set( - String(payment.paymentChainId), + chainIdKey(payment.paymentChainId), paymentSmartAccount, ); upgradeCapabilities.set( - String(payment.paymentChainId), + chainIdKey(payment.paymentChainId), paymentCapabilities, ); const missingUpgradeIds = upgradeChainIds.filter( - (chainId) => !chainSmartAccounts.has(String(chainId)), + (chainId) => !chainSmartAccounts.has(chainIdKey(chainId)), ); await Promise.all( missingUpgradeIds.map(async (chainId) => { - const key = String(chainId); + const key = chainIdKey(chainId); if (!upgradeCapabilities.has(key)) { const chain = await this.requireRelayerChain(chainId); const caps = await this.options.relayerRepository.getCapabilities( @@ -668,21 +678,17 @@ export class TransactionUtils implements ITransactionUtils { Promise.all( upgradeChainIds.map((chainId) => { const smartAccount = chainSmartAccounts.get( - String(chainId), + chainIdKey(chainId), ); - const caps = upgradeCapabilities.get(String(chainId)); + const caps = upgradeCapabilities.get(chainIdKey(chainId)); if (!smartAccount || !caps) { throw new Error( `Missing smart account or capabilities for ${chainId}`, ); } - return this.createAndSignExactCalldataDelegation({ + return this.createAndSignActivationNoOpDelegation({ smartAccount, delegate: caps.targetAddress, - target: eoa, - value: 0n, - callData: EMPTY_CALLDATA, - chainIdNumber: Number(BigInt(chainId)), }); }), ), @@ -695,12 +701,18 @@ export class TransactionUtils implements ITransactionUtils { const authByChain = new Map(); for (let i = 0; i < upgradeChainIds.length; i += 1) { - authByChain.set(String(upgradeChainIds[i]), signed.authEntries[i]!); + authByChain.set( + chainIdKey(upgradeChainIds[i]!), + signed.authEntries[i]!, + ); } let feeDelegation = signed.feeDelegation; const workByChain = new Map(); for (let i = 0; i < upgradeChainIds.length; i += 1) { - workByChain.set(String(upgradeChainIds[i]), signed.workDelegations[i]!); + workByChain.set( + chainIdKey(upgradeChainIds[i]!), + signed.workDelegations[i]!, + ); } const buildChainParams = async ( @@ -722,12 +734,11 @@ export class TransactionUtils implements ITransactionUtils { return Promise.all( orderedChainIds.map(async (chainId) => { - const isPayment = - String(chainId) === String(payment.paymentChainId); - const needsUpgrade = upgradeChainIds.some( - (id) => String(id) === String(chainId), + const isPayment = sameEvmChainId(chainId, payment.paymentChainId); + const needsUpgrade = upgradeChainIds.some((id) => + sameEvmChainId(id, chainId), ); - const chainIdDecimal = Number(BigInt(chainId)).toString(10); + const chainKey = chainIdKey(chainId); const transactions: IRelayer7710Params["transactions"] = []; if (isPayment) { @@ -744,7 +755,7 @@ export class TransactionUtils implements ITransactionUtils { } if (needsUpgrade) { - const workSig = workByChain.get(String(chainId)); + const workSig = workByChain.get(chainKey); if (!workSig) { throw new Error( `Missing work delegation for upgrade chain ${chainId}`, @@ -754,7 +765,7 @@ export class TransactionUtils implements ITransactionUtils { permissionContext: [toRelayerJson(workSig)], executions: [ { - target: eoa, + target: EVMAccountAddress(ACTIVATION_NOOP_TARGET), value: "0", data: EMPTY_CALLDATA as HexString, }, @@ -768,10 +779,10 @@ export class TransactionUtils implements ITransactionUtils { ); } - const auth = authByChain.get(String(chainId)); - const context = contexts?.[chainIdDecimal]; + const auth = authByChain.get(chainKey); + const context = contexts?.[chainKey]; return { - chainId: chainIdDecimal, + chainId: chainKey, transactions, ...(auth ? { authorizationList: [auth] } : {}), ...(context ? { context } : {}), @@ -849,8 +860,7 @@ export class TransactionUtils implements ITransactionUtils { estimate.contextByChainId ?? (estimate.context ? { - [Number(BigInt(payment.paymentChainId)).toString(10)]: - estimate.context, + [chainIdKey(payment.paymentChainId)]: estimate.context, } : undefined); params = await buildChainParams(feeAtoms, contextByChainId); @@ -881,7 +891,7 @@ export class TransactionUtils implements ITransactionUtils { taskId, ); if ( - upgradeChainIds.some((id) => String(id) === String(chainId)) + upgradeChainIds.some((id) => sameEvmChainId(id, chainId)) ) { await this.options.chainRepository.setWalletUpgraded( chainId, @@ -1342,6 +1352,30 @@ export class TransactionUtils implements ITransactionUtils { }); } + /** + * Empty-calldata activation work for EIP-7702. Must not use + * {@link ScopeType.FunctionCall}: AllowedMethodsEnforcer requires ≥4 bytes + * of calldata (`invalid-execution-data-length` on `0x`). + * NativeTokenTransferAmount + exactCalldata `0x` is the kit's intended + * empty-call path (no AllowedMethods). + */ + private createActivationNoOpDelegation( + args: ActivationNoOpDelegationArgs, + ): ReturnType { + const { smartAccount, delegate } = args; + return createDelegation({ + to: getAddress(delegate), + from: smartAccount.address, + environment: smartAccount.environment, + salt: randomSalt32(), + scope: { + type: ScopeType.NativeTokenTransferAmount, + maxAmount: 0n, + exactCalldata: { calldata: EMPTY_CALLDATA }, + }, + }); + } + private createUnsignedExactCalldataDelegation( args: ExactCalldataDelegationArgs, ): unknown { @@ -1352,6 +1386,16 @@ export class TransactionUtils implements ITransactionUtils { }; } + private createUnsignedActivationNoOpDelegation( + args: ActivationNoOpDelegationArgs, + ): unknown { + const delegation = this.createActivationNoOpDelegation(args); + return { + ...delegation, + signature: PLACEHOLDER_DELEGATION_SIGNATURE_65_ZERO, + }; + } + private async createAndSignExactCalldataDelegation( args: ExactCalldataDelegationArgs, ): Promise { @@ -1368,6 +1412,15 @@ export class TransactionUtils implements ITransactionUtils { return { ...delegation, signature }; } + private async createAndSignActivationNoOpDelegation( + args: ActivationNoOpDelegationArgs, + ): Promise { + const { smartAccount } = args; + const delegation = this.createActivationNoOpDelegation(args); + const signature = await smartAccount.signDelegation({ delegation }); + return { ...delegation, signature }; + } + async getViemAccount( addressOverride?: EVMAccountAddress, ): Promise { @@ -1532,9 +1585,9 @@ export class TransactionUtils implements ITransactionUtils { return Promise.all( ordered.map(async (chainId) => { - const isPayment = String(chainId) === String(payment.paymentChainId); - const needsUpgrade = upgradeChainIds.some( - (id) => String(id) === String(chainId), + const isPayment = sameEvmChainId(chainId, payment.paymentChainId); + const needsUpgrade = upgradeChainIds.some((id) => + sameEvmChainId(id, chainId), ); const chain = await this.requireRelayerChain(chainId); const capabilities = @@ -1582,19 +1635,15 @@ export class TransactionUtils implements ITransactionUtils { } if (needsUpgrade) { - const workDelegation = this.createUnsignedExactCalldataDelegation({ + const workDelegation = this.createUnsignedActivationNoOpDelegation({ smartAccount, delegate: capabilities.targetAddress, - target: eoa, - value: 0n, - callData: EMPTY_CALLDATA, - chainIdNumber, }); transactions.push({ permissionContext: [toRelayerJson(workDelegation)], executions: [ { - target: eoa, + target: EVMAccountAddress(ACTIVATION_NOOP_TARGET), value: "0", data: EMPTY_CALLDATA as HexString, }, @@ -1662,7 +1711,7 @@ function shouldUseActivationMultichain( paymentChainId: EVMChainId, ): boolean { if (upgradeChainIds.length !== 1) return true; - return String(upgradeChainIds[0]) !== String(paymentChainId); + return !sameEvmChainId(upgradeChainIds[0]!, paymentChainId); } /** Fee/payment chain first, then remaining upgrade chains. */ @@ -1671,14 +1720,28 @@ function orderedActivationChainIds( paymentChainId: EVMChainId, ): EVMChainId[] { const ordered: EVMChainId[] = [paymentChainId]; + const seen = new Set([chainIdKey(paymentChainId)]); for (const chainId of upgradeChainIds) { - if (String(chainId) !== String(paymentChainId)) { - ordered.push(chainId); - } + const key = chainIdKey(chainId); + if (seen.has(key)) continue; + seen.add(key); + ordered.push(chainId); } return ordered; } +/** Canonical decimal key so `0x13b2` and `5042` match. */ +function chainIdKey(chainId: EVMChainId | string | number | bigint): string { + return BigInt(chainId).toString(10); +} + +function sameEvmChainId( + a: EVMChainId | string | number | bigint, + b: EVMChainId | string | number | bigint, +): boolean { + return chainIdKey(a) === chainIdKey(b); +} + function pickPaymentToken( tokens: IPaymentTokenOption[], preferred?: EVMAccountAddress, @@ -1701,7 +1764,9 @@ function methodSelector(callData: Hex): Hex { if (callData.length >= 10) { return callData.slice(0, 10) as Hex; } - // Empty / short calldata (e.g. plain ETH transfer): pin via exactCalldata alone. + // FunctionCall + AllowedMethodsEnforcer needs ≥4 calldata bytes. Empty + // activation work must use NativeTokenTransferAmount instead (see + // createActivationNoOpDelegation). This fallback is only a last resort. return "0x00000000"; } diff --git a/src/wallet/modalTypes.ts b/src/wallet/modalTypes.ts index 9ec0d6c..c5176fa 100644 --- a/src/wallet/modalTypes.ts +++ b/src/wallet/modalTypes.ts @@ -101,7 +101,10 @@ export interface ICancelDelegationConfirmRequest { export interface IActivateOfflinePermissionsRequest { domain: string; ownerAddress: EVMAccountAddress; - /** Chains that still need EIP-7702 upgrade for this grant request. */ + /** + * Chains that still need EIP-7702 for this grant — requested grant chains + * plus the USDC payment chain (usually Arc) when either needs upgrade. + */ upgradeChains: Array<{ chainId: EVMChainId; chainName: string }>; payment: IActivationPayment; } diff --git a/src/wallet/useWalletBoot.ts b/src/wallet/useWalletBoot.ts index 68ff126..4e640eb 100644 --- a/src/wallet/useWalletBoot.ts +++ b/src/wallet/useWalletBoot.ts @@ -388,14 +388,25 @@ export function useWalletBoot({ const requestedChainIds = [ ...new Map( prepared.map(({ request }) => [ - String(request.chainId), + BigInt(request.chainId).toString(10), request.chainId, ] as const), ).values(), ]; + // Always consider Arc (DEFAULT_CHAIN_ID): activation fees are + // paid in USDC there even when the grant is only for Base. + const activationCandidateChainIds = [ + ...new Map( + [...requestedChainIds, DEFAULT_CHAIN_ID].map( + (chainId) => + [BigInt(chainId).toString(10), chainId] as const, + ), + ).values(), + ]; + const upgradeChecks = await Promise.all( - requestedChainIds.map(async (chainId) => ({ + activationCandidateChainIds.map(async (chainId) => ({ chainId, needsUpgrade: await transactionService.needsWalletUpgrade( chainId, @@ -408,17 +419,10 @@ export function useWalletBoot({ .map((row) => row.chainId); if (upgradeChainIds.length > 0) { - const candidateChainIds = [ - ...new Map( - [...requestedChainIds, DEFAULT_CHAIN_ID].map( - (chainId) => [String(chainId), chainId] as const, - ), - ).values(), - ]; const payment = await transactionService.resolveActivationPayment( owner, - candidateChainIds, + activationCandidateChainIds, ); if (!payment) { throw new OwsInvalidParamsError( @@ -427,10 +431,29 @@ export function useWalletBoot({ ); } + // Payment chain must be upgraded too (fee ExactCalldata). + if ( + !upgradeChainIds.some( + (id) => + BigInt(id).toString(10) === + BigInt(payment.paymentChainId).toString(10), + ) + ) { + const paymentNeedsUpgrade = + await transactionService.needsWalletUpgrade( + payment.paymentChainId, + owner, + ); + if (paymentNeedsUpgrade) { + upgradeChainIds.push(payment.paymentChainId); + } + } + const upgradeChains = upgradeChainIds.map((chainId) => { const preparedItem = prepared.find( (item) => - String(item.request.chainId) === String(chainId), + BigInt(item.request.chainId).toString(10) === + BigInt(chainId).toString(10), ); return { chainId, From b8cafbd544c8a4c639985ef9c47cf6f0342e8908 Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Wed, 23 Sep 2026 18:23:48 -0700 Subject: [PATCH 4/8] Make sure the wallet does not close prematurely. --- src/components/modals/ActivateOfflinePermissionsModal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/modals/ActivateOfflinePermissionsModal.tsx b/src/components/modals/ActivateOfflinePermissionsModal.tsx index 8d1c863..caaf002 100644 --- a/src/components/modals/ActivateOfflinePermissionsModal.tsx +++ b/src/components/modals/ActivateOfflinePermissionsModal.tsx @@ -44,6 +44,9 @@ export function ActivateOfflinePermissionsModal({ 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, From 4ef5fea68d97749f731f1d60fcb3fa4d269e342a Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Wed, 23 Sep 2026 19:19:27 -0700 Subject: [PATCH 5/8] Implement batch cancellation for delegations with custom RPC - Added `requestCancelDelegations` custom RPC method to facilitate batch on-chain revocation of permissions. - Enhanced UI components to support selection and cancellation of multiple delegations. - Updated `DelegationService` to handle batch cancellation logic and integrate with the relayer. - Modified relevant interfaces and types to accommodate new cancellation functionality. - Improved error handling and user feedback during the cancellation process. --- .../skills/oneshot-embedded-wallet/SKILL.md | 30 +- host/src/components/WalletActions.tsx | 90 ++++-- host/src/hooks/useHostTestActions.ts | 44 ++- skills/oneshot-embedded-wallet/SKILL.md | 112 +++++-- .../delegations/DelegationsList.tsx | 69 +++-- src/components/delegations/DelegationsTab.tsx | 50 ++- .../modals/CancelDelegationModal.tsx | 289 +++++++++++++++--- .../business/DelegationService.ts | 139 +++++++-- .../interfaces/business/IDelegationService.ts | 40 +++ src/lib/interfaces/business/index.ts | 4 + src/wallet/WalletProvider.tsx | 121 +++++--- src/wallet/modalTypes.ts | 31 +- .../registerRequestCancelDelegations.ts | 181 +++++++++++ src/wallet/useWalletBoot.ts | 52 +++- 14 files changed, 1043 insertions(+), 209 deletions(-) create mode 100644 src/wallet/registerRequestCancelDelegations.ts diff --git a/.agents/skills/oneshot-embedded-wallet/SKILL.md b/.agents/skills/oneshot-embedded-wallet/SKILL.md index f3c0075..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 / getUpgraded 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: @@ -364,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 | @@ -372,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`, `getUpgraded`, `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: @@ -415,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 ? (
    - {sessionGrants.map((grant) => ( -
  • -
    -

    - {grant.summary} -

    - -
    -
    -                  {grant.json}
    -                
    + {sessionGrants.map((grant) => { + const checked = selectedGrantIds.has(grant.id); + return ( +
  • +
    + + +
    +
    +                    {grant.json}
    +                  
    +
  • + ); + })} + {selectedGrantIds.size > 0 ? ( +
  • +

    + {selectedGrantIds.size} selected +

    +
  • - ))} + ) : null}
) : null} diff --git a/host/src/hooks/useHostTestActions.ts b/host/src/hooks/useHostTestActions.ts index f915d75..b9a47f0 100644 --- a/host/src/hooks/useHostTestActions.ts +++ b/host/src/hooks/useHostTestActions.ts @@ -902,9 +902,8 @@ export function useHostTestActions({ try { proxy.showWallet(); setWalletVisible(true); - await proxy.ethereum.request({ - method: "wallet_revokeExecutionPermission", - params: [{ permissionContext: grant.response.context }], + await proxy.rpc("requestCancelDelegations", { + permissionContexts: [grant.response.context], }); setSessionGrants((prev) => 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/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/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..ba7c1ca 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); + if (result.transactionHashes && result.transactionHashes.length > 0) { + const last = result.results[result.results.length - 1]!; 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/CancelDelegationModal.tsx b/src/components/modals/CancelDelegationModal.tsx index 3a6db9f..f510642 100644 --- a/src/components/modals/CancelDelegationModal.tsx +++ b/src/components/modals/CancelDelegationModal.tsx @@ -1,21 +1,57 @@ -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 chainKey(chainId: EVMChainId): string { + return BigInt(chainId).toString(10); +} + +function groupItemsByChain( + items: ICancelDelegationConfirmRequest["items"], +): ChainGroup[] { + const map = new Map(); + for (const item of items) { + const key = chainKey(item.chainId); + let group = map.get(key); + if (!group) { + group = { + chainId: item.chainId, + chainName: item.chainName, + work: [], + }; + map.set(key, 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 +63,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 +77,127 @@ 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 = chainKey(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 = chainKey(chainId); + setQuotes((prev) => ({ ...prev, [key]: quote })); + setQuoteErrors((prev) => ({ ...prev, [key]: err })); + }, + [], + ); + + const buildPayments = useCallback((): ICancelDelegationPayment[] => { + return chainGroups.map((group) => { + const quote = quotes[chainKey(group.chainId)]; + if (!quote) { + throw new Error(`Missing fee quote for chain ${group.chainName}`); + } + return { + chainId: group.chainId, + paymentToken: quote.selectedToken, + feeAtoms: quote.feeAtoms, + }; + }); + }, [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 +205,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 +227,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 = chainKey(group.chainId); + return ( +
+ {chainGroups.length > 1 ? ( +

+ {group.chainName} +

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

+ {statusMessage} +

+ ) : null} + {error ? ( +

{error}

+ ) : null} +
) : null} + {request.allowSkipOnchain ? (
@@ -178,7 +178,7 @@ export function ActivateOfflinePermissionsModal({ {submit.phase === "finalFee" && submit.finalFee ? ( - {submit.finalFee.feeFormatted} {request.payment.usdcSymbol} + {submit.finalFee.feeFormatted} {request.payment.symbol} ) : ( <> @@ -188,7 +188,7 @@ export function ActivateOfflinePermissionsModal({ paused={submit.feePickerPaused} /> - {request.payment.usdcSymbol} + {request.payment.symbol} )} diff --git a/src/components/modals/CCTPBridge.tsx b/src/components/modals/CCTPBridge.tsx index 16cac04..a405a97 100644 --- a/src/components/modals/CCTPBridge.tsx +++ b/src/components/modals/CCTPBridge.tsx @@ -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 f510642..a0f2f5b 100644 --- a/src/components/modals/CancelDelegationModal.tsx +++ b/src/components/modals/CancelDelegationModal.tsx @@ -155,6 +155,7 @@ export function CancelDelegationModal({ chainId: group.chainId, paymentToken: quote.selectedToken, feeAtoms: quote.feeAtoms, + paymentChainId: quote.paymentChainId, }; }); }, [chainGroups, quotes]); diff --git a/src/components/modals/SignModals.tsx b/src/components/modals/SignModals.tsx index 462fcf6..568cc8e 100644 --- a/src/components/modals/SignModals.tsx +++ b/src/components/modals/SignModals.tsx @@ -622,6 +622,7 @@ export function SendTransactionModal({ { paymentToken: payment.paymentToken, feeAtoms: payment.feeAtoms, + paymentChainId: payment.paymentChainId, }, ui, ), @@ -773,6 +774,7 @@ export function ConfirmTransferModal({ { paymentToken: payment.paymentToken, feeAtoms: payment.feeAtoms, + paymentChainId: payment.paymentChainId, }, ui, ), diff --git a/src/components/modals/TransferTokensModal.tsx b/src/components/modals/TransferTokensModal.tsx index b4463a8..6f6bc9b 100644 --- a/src/components/modals/TransferTokensModal.tsx +++ b/src/components/modals/TransferTokensModal.tsx @@ -238,6 +238,7 @@ export function TransferTokensModal({ ? { paymentToken: quote.selectedToken, feeAtoms: quote.feeAtoms, + paymentChainId: quote.paymentChainId, } : undefined, ); diff --git a/src/components/useRelayerConfirmSubmit.ts b/src/components/useRelayerConfirmSubmit.ts index 96a14f1..b59a6ed 100644 --- a/src/components/useRelayerConfirmSubmit.ts +++ b/src/components/useRelayerConfirmSubmit.ts @@ -127,6 +127,7 @@ export function useRelayerConfirmSubmit({ runExecute({ paymentToken: quote.selectedToken, feeAtoms: quote.feeAtoms, + paymentChainId: quote.paymentChainId, }); }, [quote, runExecute]); diff --git a/src/lib/implementations/business/BridgeService.ts b/src/lib/implementations/business/BridgeService.ts index d965aee..d7690e4 100644 --- a/src/lib/implementations/business/BridgeService.ts +++ b/src/lib/implementations/business/BridgeService.ts @@ -170,6 +170,9 @@ export class BridgeService implements IBridgeService { work, paymentToken: payment.paymentToken, feeAtoms: payment.feeAtoms, + ...(payment.paymentChainId + ? { paymentChainId: payment.paymentChainId } + : {}), relayerUrl: source.relayerUrl, }); diff --git a/src/lib/implementations/business/DelegationService.ts b/src/lib/implementations/business/DelegationService.ts index cf5c49d..12a67a2 100644 --- a/src/lib/implementations/business/DelegationService.ts +++ b/src/lib/implementations/business/DelegationService.ts @@ -286,6 +286,9 @@ export class DelegationService implements IDelegationService { work: group.work, paymentToken: payment.paymentToken, feeAtoms: payment.feeAtoms, + ...(payment.paymentChainId + ? { paymentChainId: payment.paymentChainId } + : {}), relayerUrl: chain.relayerUrl, prefetchRelayerVaultAssertion: true, retainDisplayDuringSubmit: true, diff --git a/src/lib/implementations/business/TransactionService.ts b/src/lib/implementations/business/TransactionService.ts index c98a6a6..5929dd7 100644 --- a/src/lib/implementations/business/TransactionService.ts +++ b/src/lib/implementations/business/TransactionService.ts @@ -16,7 +16,7 @@ import type { ITransactionWork, } from "../../interfaces/business/ITransactionService"; import type { ITransactionUtils } from "../../interfaces/business/utils/ITransactionUtils"; -import type { IActivationPayment } from "../../types/domain/ActivationPayment"; +import type { IRelayerPayment } from "../../types/domain/RelayerPayment"; import type { IRelayerSendUiCallbacks } from "../../types/domain/RelayerSendUi"; import type { IWalletUpgradeStatus } from "../../types/domain/WalletUpgradeStatus"; import type { TokenAmount } from "../../types/primitives"; @@ -77,20 +77,10 @@ export class TransactionService implements ITransactionService { ); } - resolveActivationPayment( - owner: EVMAccountAddress, - candidateChainIds: readonly EVMChainId[], - ): Promise { - return this.options.transactionUtils.resolveActivationPayment( - owner, - candidateChainIds, - ); - } - quoteActivation( owner: EVMAccountAddress, upgradeChainIds: readonly EVMChainId[], - payment: IActivationPayment, + payment: IRelayerPayment, ): Promise { return this.options.transactionUtils.quoteActivation( owner, @@ -102,7 +92,7 @@ export class TransactionService implements ITransactionService { activateDelegations( args: { upgradeChainIds: readonly EVMChainId[]; - payment: IActivationPayment; + payment: IRelayerPayment; feeAtoms: TokenAmount; } & IRelayerSendUiCallbacks, ): Promise { @@ -115,6 +105,7 @@ export class TransactionService implements ITransactionService { options?: { paymentToken?: EVMAccountAddress; feeAtoms?: TokenAmount; + paymentChainId?: EVMChainId; authorizationList?: IRelayerAuthorizationEntry[]; } & IRelayerSendUiCallbacks, ): Promise { @@ -143,6 +134,9 @@ export class TransactionService implements ITransactionService { work, paymentToken: options.paymentToken, feeAtoms: options.feeAtoms, + ...(options.paymentChainId + ? { paymentChainId: options.paymentChainId } + : {}), authorizationList: options.authorizationList, relayerUrl: chain.relayerUrl, onFinalFeeRequired: options.onFinalFeeRequired, diff --git a/src/lib/implementations/business/index.ts b/src/lib/implementations/business/index.ts index 181a068..87b5a17 100644 --- a/src/lib/implementations/business/index.ts +++ b/src/lib/implementations/business/index.ts @@ -5,5 +5,6 @@ export { BitcoinService } from "./BitcoinService"; export { DelegationService } from "./DelegationService"; export { TransactionUtils as BusinessTransactionUtils } from "./utils/TransactionUtils"; export type { TransactionUtilsOptions as BusinessTransactionUtilsOptions } from "./utils/TransactionUtils"; +export { PaymentTokenUtils } from "./utils/PaymentTokenUtils"; export { CCTPUtils } from "./utils/CCTPUtils"; export { LiFiUtils } from "./utils/LiFiUtils"; diff --git a/src/lib/implementations/business/utils/PaymentTokenUtils.ts b/src/lib/implementations/business/utils/PaymentTokenUtils.ts new file mode 100644 index 0000000..48ef7ad --- /dev/null +++ b/src/lib/implementations/business/utils/PaymentTokenUtils.ts @@ -0,0 +1,177 @@ +import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; +import type { IChainRepository } from "../../../interfaces/data/IChainRepository"; +import type { IOneshotRelayerRepository } from "../../../interfaces/data/IOneshotRelayerRepository"; +import type { ITrackedAssetRepository } from "../../../interfaces/data/ITrackedAssetRepository"; +import type { IPaymentTokenUtils } from "../../../interfaces/business/utils/IPaymentTokenUtils"; +import type { IPaymentTokenOption } from "../../../interfaces/business/ITransactionService"; +import type { IRelayerPayment } from "../../../types/domain/RelayerPayment"; +import { EAssetType } from "../../../types/enum/EAssetType"; +import { makeTokenAmount } from "../../../types/primitives"; +import { DEFAULT_CHAIN_ID } from "../../data/HardcodedChainRepository"; + +type ChainPaymentOptions = { + chainId: EVMChainId; + chainName: string; + tokens: IPaymentTokenOption[]; +}; + +/** + * Single policy for which chain/token pays the public-relayer fee. + */ +export class PaymentTokenUtils implements IPaymentTokenUtils { + constructor( + protected readonly chainRepository: IChainRepository, + protected readonly relayerRepository: IOneshotRelayerRepository, + protected readonly trackedAssetRepository: ITrackedAssetRepository, + ) {} + + async resolvePayment( + owner: EVMAccountAddress, + executionChainIds: readonly EVMChainId[], + preferredToken?: EVMAccountAddress, + ): Promise { + const unique = uniqueChainIds(executionChainIds); + if (unique.length === 0) return null; + + const executionOptions = ( + await Promise.all(unique.map((id) => this.loadChainOptions(owner, id))) + ).filter((row): row is ChainPaymentOptions => row !== null); + + const fundedExecution: Array<{ + row: ChainPaymentOptions; + selected: IPaymentTokenOption; + }> = []; + for (const row of executionOptions) { + const selected = pickPaymentToken(row.tokens, preferredToken); + if (selected) fundedExecution.push({ row, selected }); + } + + // 1. Exactly one execution chain has a funded payment token → pay locally. + if (fundedExecution.length === 1) { + const only = fundedExecution[0]!; + return toRelayerPayment(only.row, only.selected); + } + + // 2. Arc USDC fallback (always considered, even if not in execution set). + const arcOptions = await this.loadChainOptions(owner, DEFAULT_CHAIN_ID); + if (arcOptions) { + const usdc = arcOptions.tokens.find( + (t) => t.symbol.toUpperCase() === "USDC" && t.balance > 0n, + ); + if (usdc) return toRelayerPayment(arcOptions, usdc); + } + + // 3. First execution chain with any funded payment token (stable order). + if (fundedExecution.length > 0) { + const first = fundedExecution[0]!; + return toRelayerPayment(first.row, first.selected); + } + + return null; + } + + private async loadChainOptions( + owner: EVMAccountAddress, + chainId: EVMChainId, + ): Promise { + try { + const chain = await this.chainRepository.get(chainId); + if (!chain?.useRelayer) return null; + + const [tracked, capabilities] = await Promise.all([ + this.trackedAssetRepository.getBalances(owner, { chainId }), + this.relayerRepository.getCapabilities(chain.relayerUrl, chainId), + ]); + + const balanceByAddress = new Map( + tracked.map((asset) => [ + String(asset.address).toLowerCase(), + asset.balance ?? 0n, + ]), + ); + + const tokens: IPaymentTokenOption[] = capabilities.tokens.map( + (token) => ({ + ...token, + balance: makeTokenAmount( + balanceByAddress.get(String(token.address).toLowerCase()) ?? 0n, + ), + }), + ); + + // Also surface tracked USDC that appears in capabilities (activation path). + for (const asset of tracked) { + if (asset.type !== EAssetType.Erc20) continue; + if (asset.symbol.toUpperCase() !== "USDC") continue; + const key = String(asset.address).toLowerCase(); + const already = tokens.some( + (t) => String(t.address).toLowerCase() === key, + ); + if (already) continue; + const accepted = capabilities.tokens.some( + (t) => String(t.address).toLowerCase() === key, + ); + if (!accepted) continue; + tokens.push({ + address: asset.address, + symbol: asset.symbol, + decimals: asset.decimals, + balance: makeTokenAmount(asset.balance ?? 0n), + }); + } + + return { + chainId, + chainName: chain.label, + tokens, + }; + } catch { + return null; + } + } +} + +function uniqueChainIds(chainIds: readonly EVMChainId[]): EVMChainId[] { + const unique: EVMChainId[] = []; + const seen = new Set(); + for (const id of chainIds) { + const key = BigInt(id).toString(10); + if (seen.has(key)) continue; + seen.add(key); + unique.push(id); + } + return unique; +} + +function pickPaymentToken( + tokens: IPaymentTokenOption[], + preferred?: EVMAccountAddress, +): IPaymentTokenOption | null { + const withBalance = tokens.filter((t) => t.balance > 0n); + if (preferred) { + const match = withBalance.find( + (t) => + String(t.address).toLowerCase() === String(preferred).toLowerCase(), + ); + if (match) return match; + } + const usdc = withBalance.find((t) => t.symbol.toUpperCase() === "USDC"); + if (usdc) return usdc; + const usdt = withBalance.find((t) => t.symbol.toUpperCase() === "USDT"); + if (usdt) return usdt; + return withBalance[0] ?? null; +} + +function toRelayerPayment( + row: ChainPaymentOptions, + selected: IPaymentTokenOption, +): IRelayerPayment { + return { + paymentChainId: row.chainId, + paymentToken: selected.address, + paymentChainName: row.chainName, + balance: selected.balance, + decimals: selected.decimals, + symbol: selected.symbol, + }; +} diff --git a/src/lib/implementations/business/utils/TransactionUtils.ts b/src/lib/implementations/business/utils/TransactionUtils.ts index 2ab752f..2d904d3 100644 --- a/src/lib/implementations/business/utils/TransactionUtils.ts +++ b/src/lib/implementations/business/utils/TransactionUtils.ts @@ -48,11 +48,11 @@ import { } from "../../../interfaces/business/utils/ITransactionUtils"; import type { ITransactionUtils as IPresentationTransactionUtils } from "../../../interfaces/utils/ITransactionUtils"; import type { IOWSProvider } from "../../../interfaces/utils/IOWSProvider"; -import type { IActivationPayment } from "../../../types/domain/ActivationPayment"; +import type { IPaymentTokenUtils } from "../../../interfaces/business/utils/IPaymentTokenUtils"; +import type { IRelayerPayment } from "../../../types/domain/RelayerPayment"; import type { IFinalRelayerFee } from "../../../types/domain/RelayerSendUi"; import type { IWalletUpgradeStatus } from "../../../types/domain/WalletUpgradeStatus"; import { EPasskeyPromptReason } from "../../../types/enum/EPasskeyPromptReason"; -import { EAssetType } from "../../../types/enum/EAssetType"; import { makeTokenAmount, tokenAmountFromAtomString, @@ -67,7 +67,6 @@ import { loadCachedSecp256k1PublicKey, } from "../../../../storage"; import { styleController } from "../../../../style/styleController"; -import { DEFAULT_CHAIN_ID } from "../../data/HardcodedChainRepository"; // Ensure Arc mainnet Smart Accounts env is registered before any kit lookups. import "../../utils/registerSmartAccountsEnvironments"; @@ -117,6 +116,7 @@ export type TransactionUtilsOptions = { chainRepository: IChainRepository; relayerRepository: IOneshotRelayerRepository; trackedAssetRepository: ITrackedAssetRepository; + paymentTokenUtils: IPaymentTokenUtils; blockchain: IBlockchainProvider; /** Presentation helpers (host domain for relayer memo). */ presentationTransactionUtils: IPresentationTransactionUtils; @@ -276,15 +276,25 @@ export class TransactionUtils implements ITransactionUtils { throw new Error("quotePayment requires at least one work item"); } - const chain = await this.requireRelayerChain(chainId); - const capabilities = await this.options.relayerRepository.getCapabilities( - chain.relayerUrl, - chainId, + const payment = await this.options.paymentTokenUtils.resolvePayment( + owner, + [chainId], + preferredToken, ); + if (!payment) { + throw new Error("No relayer payment token with a positive balance"); + } + + const paymentChain = await this.requireRelayerChain(payment.paymentChainId); + const paymentCapabilities = + await this.options.relayerRepository.getCapabilities( + paymentChain.relayerUrl, + payment.paymentChainId, + ); const tracked = await this.options.trackedAssetRepository.getBalances( owner, - { chainId }, + { chainId: payment.paymentChainId }, ); const balanceByAddress = new Map( tracked.map((asset) => [ @@ -292,105 +302,137 @@ export class TransactionUtils implements ITransactionUtils { asset.balance ?? 0n, ]), ); + const tokens: IPaymentTokenOption[] = paymentCapabilities.tokens.map( + (token) => ({ + ...token, + balance: makeTokenAmount( + balanceByAddress.get(String(token.address).toLowerCase()) ?? 0n, + ), + }), + ); - const tokens: IPaymentTokenOption[] = capabilities.tokens.map((token) => ({ - ...token, - balance: makeTokenAmount( - balanceByAddress.get(String(token.address).toLowerCase()) ?? 0n, - ), - })); - - const selected = pickPaymentToken(tokens, preferredToken); - if (!selected) { + let selected = + tokens.find( + (t) => + String(t.address).toLowerCase() === + String(payment.paymentToken).toLowerCase(), + ) ?? null; + if (preferredToken) { + const preferred = tokens.find( + (t) => + String(t.address).toLowerCase() === + String(preferredToken).toLowerCase() && t.balance > 0n, + ); + if (preferred) selected = preferred; + } + if (!selected || selected.balance <= 0n) { throw new Error("No relayer payment token with a positive balance"); } - // Seed fee ExactCalldata with typical minFee; estimate returns the real - // requiredPaymentAmount (often higher on Ethereum). const seedFeeAtoms = makeTokenAmount( parseUnits("0.01", selected.decimals), ); + const crossChain = !sameEvmChainId(payment.paymentChainId, chainId); - const chainIdNumber = Number(BigInt(chainId)); - const client = this.options.blockchain.getPublicClient(chainId); - const viemAccount = await this.getViemAccount(owner); - const smartAccount = await toMetaMaskSmartAccount({ - client: client as never, - implementation: Implementation.Stateless7702, - address: owner, - signer: { account: viemAccount }, - }); + let estimate; + if (!crossChain) { + const chainIdNumber = Number(BigInt(chainId)); + const client = this.options.blockchain.getPublicClient(chainId); + const viemAccount = await this.getViemAccount(owner); + const smartAccount = await toMetaMaskSmartAccount({ + client: client as never, + implementation: Implementation.Stateless7702, + address: owner, + signer: { account: viemAccount }, + }); - const feeCalldata = HexStringCompat( - encodeFunctionData({ - abi: erc20Abi, - functionName: "transfer", - args: [capabilities.feeCollector, seedFeeAtoms], - }), - ); + const feeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, seedFeeAtoms], + }), + ); - const feeDelegation = this.createUnsignedExactCalldataDelegation({ - smartAccount, - delegate: capabilities.targetAddress, - target: selected.address, - value: 0n, - callData: feeCalldata, - chainIdNumber, - }); - const workDelegations = workItems.map((item) => - this.createUnsignedExactCalldataDelegation({ + const feeDelegation = this.createUnsignedExactCalldataDelegation({ smartAccount, - delegate: capabilities.targetAddress, - target: item.to, - value: item.value ?? 0n, - callData: (item.data || "0x") as Hex, + delegate: paymentCapabilities.targetAddress, + target: selected.address, + value: 0n, + callData: feeCalldata, chainIdNumber, - }), - ); + }); + const workDelegations = workItems.map((item) => + this.createUnsignedExactCalldataDelegation({ + smartAccount, + delegate: paymentCapabilities.targetAddress, + target: item.to, + value: item.value ?? 0n, + callData: (item.data || "0x") as Hex, + chainIdNumber, + }), + ); - const params: IRelayer7710Params = { - chainId: chainIdNumber.toString(10), - transactions: [ - { - permissionContext: [toRelayerJson(feeDelegation)], - executions: [ - { - target: selected.address, - value: "0", - data: feeCalldata as HexString, - }, - ], - }, - ...workItems.map((item, index) => { - const value = item.value ?? 0n; - return { - permissionContext: [toRelayerJson(workDelegations[index])], + const params: IRelayer7710Params = { + chainId: chainIdNumber.toString(10), + transactions: [ + { + permissionContext: [toRelayerJson(feeDelegation)], executions: [ { - target: item.to, - value: value === 0n ? "0" : `0x${value.toString(16)}`, - data: (item.data || "0x") as HexString, + target: selected.address, + value: "0", + data: feeCalldata as HexString, }, ], - }; - }), - ], - }; + }, + ...workItems.map((item, index) => { + const value = item.value ?? 0n; + return { + permissionContext: [toRelayerJson(workDelegations[index])], + executions: [ + { + target: item.to, + value: value === 0n ? "0" : `0x${value.toString(16)}`, + data: (item.data || "0x") as HexString, + }, + ], + }; + }), + ], + }; - console.debug( - "[business/TransactionUtils] quotePayment unsigned estimate", - { - chainId, - paymentToken: selected.address, - workCount: workItems.length, - }, - ); + console.debug( + "[business/TransactionUtils] quotePayment unsigned estimate", + { + chainId, + paymentChainId: payment.paymentChainId, + paymentToken: selected.address, + workCount: workItems.length, + crossChain: false, + }, + ); - const estimate = - await this.options.relayerRepository.estimate7710Transaction( - chain.relayerUrl, + estimate = await this.options.relayerRepository.estimate7710Transaction( + paymentChain.relayerUrl, params, ); + } else { + estimate = await this.quotePaymentCrossChain({ + owner, + executionChainId: chainId, + payment: { + ...payment, + paymentToken: selected.address, + balance: selected.balance, + decimals: selected.decimals, + symbol: selected.symbol, + }, + workItems, + seedFeeAtoms, + paymentCapabilities, + }); + } if (!estimate.success || !estimate.requiredPaymentAmount) { throw new Error( @@ -403,59 +445,20 @@ export class TransactionUtils implements ITransactionUtils { return { tokens, selectedToken: selected.address, + paymentChainId: payment.paymentChainId, + paymentChainName: payment.paymentChainName, feeAtoms, feeFormatted: formatUnits(feeAtoms, selected.decimals), - feeCollector: capabilities.feeCollector, - targetAddress: capabilities.targetAddress, + feeCollector: paymentCapabilities.feeCollector, + targetAddress: paymentCapabilities.targetAddress, minFee: feeAtoms, }; } - async resolveActivationPayment( - owner: EVMAccountAddress, - candidateChainIds: readonly EVMChainId[], - ): Promise { - const unique: EVMChainId[] = []; - const seen = new Set(); - for (const id of candidateChainIds) { - const key = chainIdKey(id); - if (seen.has(key)) continue; - seen.add(key); - unique.push(id); - } - - const balances = await Promise.all( - unique.map(async (chainId) => { - const usdc = await this.readUsdcBalance(chainId, owner); - return { chainId, usdc }; - }), - ); - - const withUsdc = balances.filter( - (row) => row.usdc !== null && row.usdc.balance > 0n, - ); - if (withUsdc.length === 0) return null; - - const preferArc = withUsdc.find((row) => - sameEvmChainId(row.chainId, DEFAULT_CHAIN_ID), - ); - const picked = preferArc ?? withUsdc[0]!; - const chain = await this.requireRelayerChain(picked.chainId); - const usdc = picked.usdc!; - return { - paymentChainId: picked.chainId, - paymentToken: usdc.address, - paymentChainName: chain.label, - usdcBalance: usdc.balance, - usdcDecimals: usdc.decimals, - usdcSymbol: usdc.symbol, - }; - } - async quoteActivation( owner: EVMAccountAddress, upgradeChainIds: readonly EVMChainId[], - payment: IActivationPayment, + payment: IRelayerPayment, ): Promise { if (upgradeChainIds.length === 0) { throw new Error("quoteActivation requires at least one upgrade chain"); @@ -466,7 +469,7 @@ export class TransactionUtils implements ITransactionUtils { eoa: owner, upgradeChainIds, payment, - feeAtoms: makeTokenAmount(parseUnits("0.01", payment.usdcDecimals)), + feeAtoms: makeTokenAmount(parseUnits("0.01", payment.decimals)), signed: false, }); @@ -499,16 +502,18 @@ export class TransactionUtils implements ITransactionUtils { const tokenOption: IPaymentTokenOption = { address: payment.paymentToken, - symbol: payment.usdcSymbol, - decimals: payment.usdcDecimals, - balance: payment.usdcBalance, + symbol: payment.symbol, + decimals: payment.decimals, + balance: payment.balance, }; return { tokens: [tokenOption], selectedToken: payment.paymentToken, + paymentChainId: payment.paymentChainId, + paymentChainName: payment.paymentChainName, feeAtoms, - feeFormatted: formatUnits(feeAtoms, payment.usdcDecimals), + feeFormatted: formatUnits(feeAtoms, payment.decimals), feeCollector: capabilities.feeCollector, targetAddress: capabilities.targetAddress, minFee: feeAtoms, @@ -517,7 +522,7 @@ export class TransactionUtils implements ITransactionUtils { async activateDelegations(args: { upgradeChainIds: readonly EVMChainId[]; - payment: IActivationPayment; + payment: IRelayerPayment; feeAtoms: TokenAmount; retainDisplayDuringSubmit?: boolean; onAwaitingConfirmation?: () => void; @@ -765,7 +770,7 @@ export class TransactionUtils implements ITransactionUtils { permissionContext: [toRelayerJson(workSig)], executions: [ { - target: EVMAccountAddress(ACTIVATION_NOOP_TARGET), + target: ACTIVATION_NOOP_TARGET, value: "0", data: EMPTY_CALLDATA as HexString, }, @@ -814,7 +819,7 @@ export class TransactionUtils implements ITransactionUtils { if (onFinalFeeRequired) { await onFinalFeeRequired({ feeAtoms, - feeFormatted: formatUnits(feeAtoms, payment.usdcDecimals), + feeFormatted: formatUnits(feeAtoms, payment.decimals), paymentToken: payment.paymentToken, }); } @@ -988,6 +993,7 @@ export class TransactionUtils implements ITransactionUtils { work: ITransactionWork | ITransactionWork[]; paymentToken: EVMAccountAddress; feeAtoms: TokenAmount; + paymentChainId?: EVMChainId; authorizationList?: IRelayerAuthorizationEntry[]; relayerUrl: string; prefetchRelayerVaultAssertion?: boolean; @@ -995,6 +1001,14 @@ export class TransactionUtils implements ITransactionUtils { onAwaitingConfirmation?: () => void; onFinalFeeRequired?: (fee: IFinalRelayerFee) => Promise; }): Promise { + const paymentChainId = args.paymentChainId ?? args.chainId; + if (!sameEvmChainId(paymentChainId, args.chainId)) { + return this.sendViaRelayerCrossChain({ + ...args, + paymentChainId, + }); + } + const { chainId, paymentToken, @@ -1330,6 +1344,372 @@ export class TransactionUtils implements ITransactionUtils { } } + /** + * Fee ExactCalldata on `paymentChainId`, work (+ optional EIP-7702) on + * `chainId`, submitted via multichain 7710. + */ + private async sendViaRelayerCrossChain(args: { + chainId: EVMChainId; + paymentChainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + paymentToken: EVMAccountAddress; + feeAtoms: TokenAmount; + authorizationList?: IRelayerAuthorizationEntry[]; + relayerUrl: string; + prefetchRelayerVaultAssertion?: boolean; + retainDisplayDuringSubmit?: boolean; + onAwaitingConfirmation?: () => void; + onFinalFeeRequired?: (fee: IFinalRelayerFee) => Promise; + }): Promise { + const { + chainId: executionChainId, + paymentChainId, + paymentToken, + onAwaitingConfirmation, + onFinalFeeRequired, + retainDisplayDuringSubmit, + } = args; + const workItems = Array.isArray(args.work) ? args.work : [args.work]; + if (workItems.length === 0) { + throw new Error("sendViaRelayer requires at least one work item"); + } + let feeAtoms: TokenAmount = args.feeAtoms; + let authorizationList = args.authorizationList; + + const paymentChain = await this.requireRelayerChain(paymentChainId); + const executionChain = await this.requireRelayerChain(executionChainId); + + const signer = await this.options.owsProvider.getSigner(); + const eoa = + signer.getCachedAddress?.() ?? + loadCachedEvmAddress() ?? + (await signer.evm.getAccountAddress()); + + const needsUpgrade = + !authorizationList?.length && + (await this.needsWalletUpgrade(executionChainId, eoa)); + + console.debug("[business/TransactionUtils] sendViaRelayerCrossChain", { + executionChainId, + paymentChainId, + eoa, + needsUpgrade, + }); + + await this.options.owsProvider.ensureDisplay(); + try { + const delegationSecret = await loadOrCreateDelegationBinding(); + const viemAccount = await this.getViemAccount(eoa); + const destinationUrl = styleController.get().destinationUrl; + const memo = buildMemo( + eoa, + this.options.presentationTransactionUtils.resolveHostDomain(), + ); + + const paymentCapabilities = + await this.options.relayerRepository.getCapabilities( + paymentChain.relayerUrl, + paymentChainId, + ); + const executionCapabilities = + await this.options.relayerRepository.getCapabilities( + executionChain.relayerUrl, + executionChainId, + ); + + const paymentChainIdNumber = Number(BigInt(paymentChainId)); + const executionChainIdNumber = Number(BigInt(executionChainId)); + const paymentClient = + this.options.blockchain.getPublicClient(paymentChainId); + const executionClient = + this.options.blockchain.getPublicClient(executionChainId); + + const [paymentSmartAccount, executionSmartAccount] = await Promise.all([ + toMetaMaskSmartAccount({ + client: paymentClient as never, + implementation: Implementation.Stateless7702, + address: eoa, + signer: { account: viemAccount }, + }), + toMetaMaskSmartAccount({ + client: executionClient as never, + implementation: Implementation.Stateless7702, + address: eoa, + signer: { account: viemAccount }, + }), + ]); + + let upgradeNonce: number | undefined; + let upgradeContract: `0x${string}` | undefined; + if (needsUpgrade) { + upgradeContract = STATELESS_DELEGATOR_IMPL; + try { + const env = getSmartAccountsEnvironment(executionChainIdNumber); + upgradeContract = getAddress( + env.implementations.EIP7702StatelessDeleGatorImpl, + ); + } catch { + // keep hardcoded fallback + } + upgradeNonce = await executionClient.getTransactionCount({ + address: getAddress(eoa), + blockTag: "pending", + }); + } + + const feeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, feeAtoms], + }), + ); + + const approveCopy = approveTransactionCeremony(needsUpgrade); + const minCalls = (needsUpgrade ? 1 : 0) + 1 + workItems.length; + const coalesceOptions: CoalesceSignDigestOptions = { minCalls }; + if (args.prefetchRelayerVaultAssertion) { + const { challengeId, challenge } = + await this.options.delegationRepository.mintRelayerVaultChallenge(); + coalesceOptions.challenge = challenge as `0x${string}`; + coalesceOptions.onBatchAssertion = (assertion) => { + this.options.delegationRepository.cacheRelayerVaultAssertion( + challengeId, + assertion, + ); + }; + } + + const signed = await withCeremonyUiReason( + EPasskeyPromptReason.ApproveTransaction, + () => + withCoalescedSignDigest( + signer, + approveCopy, + async () => { + const [authEntry, feeDelegation, ...workDelegations] = + await Promise.all([ + needsUpgrade + ? this.signWalletUpgradeAuthorizationInner( + executionChainId, + { + account: viemAccount, + nonce: upgradeNonce, + contractAddress: upgradeContract, + }, + ) + : Promise.resolve(undefined), + this.createAndSignExactCalldataDelegation({ + smartAccount: paymentSmartAccount, + delegate: paymentCapabilities.targetAddress, + target: paymentToken, + value: 0n, + callData: feeCalldata, + chainIdNumber: paymentChainIdNumber, + }), + ...workItems.map((item) => + this.createAndSignExactCalldataDelegation({ + smartAccount: executionSmartAccount, + delegate: executionCapabilities.targetAddress, + target: item.to, + value: item.value ?? 0n, + callData: (item.data || "0x") as Hex, + chainIdNumber: executionChainIdNumber, + }), + ), + ]); + return { authEntry, feeDelegation, workDelegations }; + }, + coalesceOptions, + ), + ); + + if (signed.authEntry) { + authorizationList = [signed.authEntry]; + } else if (needsUpgrade) { + throw new Error( + "EIP-7702 wallet upgrade was required but no authorization was signed", + ); + } + let feeDelegation = signed.feeDelegation; + const workDelegations = signed.workDelegations; + + const buildParams = ( + feeSig: unknown, + feeAmount: bigint, + contexts?: Record, + ): IRelayer7710Params[] => { + const feeData = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, feeAmount], + }), + ); + const paymentKey = chainIdKey(paymentChainId); + const executionKey = chainIdKey(executionChainId); + return [ + { + chainId: paymentChainIdNumber.toString(10), + transactions: [ + { + permissionContext: [toRelayerJson(feeSig)], + executions: [ + { + target: paymentToken, + value: "0", + data: feeData as HexString, + }, + ], + }, + ], + ...(contexts?.[paymentKey] + ? { context: contexts[paymentKey] } + : {}), + memo, + delegationSecret, + ...(destinationUrl ? { destinationUrl } : {}), + }, + { + chainId: executionChainIdNumber.toString(10), + transactions: workItems.map((item, index) => { + const value = item.value ?? 0n; + return { + permissionContext: [toRelayerJson(workDelegations[index])], + executions: [ + { + target: item.to, + value: value === 0n ? "0" : `0x${value.toString(16)}`, + data: (item.data || "0x") as HexString, + }, + ], + }; + }), + ...(authorizationList?.length + ? { authorizationList } + : {}), + ...(contexts?.[executionKey] + ? { context: contexts[executionKey] } + : {}), + memo, + delegationSecret, + ...(destinationUrl ? { destinationUrl } : {}), + }, + ]; + }; + + let params = buildParams(feeDelegation, feeAtoms); + let estimate = + await this.options.relayerRepository.estimate7710TransactionMultichain( + paymentChain.relayerUrl, + params, + ); + + if ( + estimate.success && + estimate.requiredPaymentAmount && + tokenAmountFromAtomString(estimate.requiredPaymentAmount) > feeAtoms + ) { + feeAtoms = tokenAmountFromAtomString(estimate.requiredPaymentAmount); + const paymentTokenMeta = paymentCapabilities.tokens.find( + (token) => + String(token.address).toLowerCase() === + String(paymentToken).toLowerCase(), + ); + const feeDecimals = paymentTokenMeta?.decimals ?? 6; + if (onFinalFeeRequired) { + await onFinalFeeRequired({ + feeAtoms, + feeFormatted: formatUnits(feeAtoms, feeDecimals), + paymentToken, + }); + } + const nextFeeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, feeAtoms], + }), + ); + const adjustCopy = adjustFeeCeremony(); + feeDelegation = await withCeremonyUiReason( + EPasskeyPromptReason.AdjustFee, + () => + withCoalescedSignDigest(signer, adjustCopy, () => + this.createAndSignExactCalldataDelegation({ + smartAccount: paymentSmartAccount, + delegate: paymentCapabilities.targetAddress, + target: paymentToken, + value: 0n, + callData: nextFeeCalldata, + chainIdNumber: paymentChainIdNumber, + }), + ), + ); + params = buildParams(feeDelegation, feeAtoms); + } + + if (!estimate.success) { + throw new Error( + estimate.error ?? "relayer_estimate7710TransactionMultichain failed", + ); + } + + if (!retainDisplayDuringSubmit) { + await this.options.owsProvider.hideDisplay(); + } else { + onAwaitingConfirmation?.(); + } + + params = buildParams( + feeDelegation, + feeAtoms, + estimate.contextByChainId, + ); + const taskIds = + await this.options.relayerRepository.send7710TransactionMultichain( + paymentChain.relayerUrl, + params, + ); + + try { + const hashes = await Promise.all( + taskIds.map((taskId) => + this.pollUntilTerminal(paymentChain.relayerUrl, taskId), + ), + ); + if (authorizationList?.length) { + await this.options.chainRepository.setWalletUpgraded( + executionChainId, + eoa, + true, + ); + } + // Return the execution-chain hash (second task when payment ≠ execution). + const executionHash = + hashes[hashes.length - 1] ?? hashes[0]!; + return { + relayerTransactionId: taskIds[taskIds.length - 1] ?? taskIds[0]!, + transactionHash: executionHash, + }; + } catch (pollError) { + if (authorizationList?.length) { + await this.options.chainRepository.setWalletUpgraded( + executionChainId, + eoa, + false, + ); + } + throw pollError; + } + } catch (error) { + if (!retainDisplayDuringSubmit) { + await this.options.owsProvider.hideDisplay(); + } + throw error; + } + } + private createExactCalldataDelegation( args: ExactCalldataDelegationArgs, ): ReturnType { @@ -1524,45 +1904,137 @@ export class TransactionUtils implements ITransactionUtils { return chain; } - private async readUsdcBalance( - chainId: EVMChainId, - owner: EVMAccountAddress, - ): Promise<{ - address: EVMAccountAddress; - symbol: string; - decimals: number; - balance: TokenAmount; - } | null> { - try { - const chain = await this.requireRelayerChain(chainId); - const [assets, capabilities] = await Promise.all([ - this.options.trackedAssetRepository.getBalances(owner, { chainId }), - this.options.relayerRepository.getCapabilities( - chain.relayerUrl, - chainId, - ), - ]); - const usdc = assets.find( - (asset) => - asset.type === EAssetType.Erc20 && - asset.symbol.toUpperCase() === "USDC", - ); - if (!usdc) return null; - const accepted = capabilities.tokens.some( - (token) => - String(token.address).toLowerCase() === - String(usdc.address).toLowerCase(), + /** + * Unsigned multichain estimate: fee on payment chain, ExactCalldata work on + * execution chain (used when Arc pays for a Base send, etc.). + */ + private async quotePaymentCrossChain(args: { + owner: EVMAccountAddress; + executionChainId: EVMChainId; + payment: IRelayerPayment; + workItems: ITransactionWork[]; + seedFeeAtoms: TokenAmount; + paymentCapabilities: Awaited< + ReturnType + >; + }): Promise< + Awaited> + > { + const { + owner, + executionChainId, + payment, + workItems, + seedFeeAtoms, + paymentCapabilities, + } = args; + const paymentChain = await this.requireRelayerChain(payment.paymentChainId); + const executionChain = await this.requireRelayerChain(executionChainId); + const executionCapabilities = + await this.options.relayerRepository.getCapabilities( + executionChain.relayerUrl, + executionChainId, ); - if (!accepted) return null; - return { - address: usdc.address, - symbol: usdc.symbol, - decimals: usdc.decimals, - balance: makeTokenAmount(usdc.balance ?? 0n), - }; - } catch { - return null; - } + + const viemAccount = await this.getViemAccount(owner); + const paymentChainIdNumber = Number(BigInt(payment.paymentChainId)); + const executionChainIdNumber = Number(BigInt(executionChainId)); + + const paymentClient = this.options.blockchain.getPublicClient( + payment.paymentChainId, + ); + const executionClient = + this.options.blockchain.getPublicClient(executionChainId); + + const [paymentSmartAccount, executionSmartAccount] = await Promise.all([ + toMetaMaskSmartAccount({ + client: paymentClient as never, + implementation: Implementation.Stateless7702, + address: owner, + signer: { account: viemAccount }, + }), + toMetaMaskSmartAccount({ + client: executionClient as never, + implementation: Implementation.Stateless7702, + address: owner, + signer: { account: viemAccount }, + }), + ]); + + const feeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, seedFeeAtoms], + }), + ); + const feeDelegation = this.createUnsignedExactCalldataDelegation({ + smartAccount: paymentSmartAccount, + delegate: paymentCapabilities.targetAddress, + target: payment.paymentToken, + value: 0n, + callData: feeCalldata, + chainIdNumber: paymentChainIdNumber, + }); + + const workDelegations = workItems.map((item) => + this.createUnsignedExactCalldataDelegation({ + smartAccount: executionSmartAccount, + delegate: executionCapabilities.targetAddress, + target: item.to, + value: item.value ?? 0n, + callData: (item.data || "0x") as Hex, + chainIdNumber: executionChainIdNumber, + }), + ); + + const paymentParams: IRelayer7710Params = { + chainId: paymentChainIdNumber.toString(10), + transactions: [ + { + permissionContext: [toRelayerJson(feeDelegation)], + executions: [ + { + target: payment.paymentToken, + value: "0", + data: feeCalldata as HexString, + }, + ], + }, + ], + }; + + const executionParams: IRelayer7710Params = { + chainId: executionChainIdNumber.toString(10), + transactions: workItems.map((item, index) => { + const value = item.value ?? 0n; + return { + permissionContext: [toRelayerJson(workDelegations[index])], + executions: [ + { + target: item.to, + value: value === 0n ? "0" : `0x${value.toString(16)}`, + data: (item.data || "0x") as HexString, + }, + ], + }; + }), + }; + + console.debug( + "[business/TransactionUtils] quotePayment cross-chain estimate", + { + executionChainId, + paymentChainId: payment.paymentChainId, + paymentToken: payment.paymentToken, + workCount: workItems.length, + }, + ); + + return this.options.relayerRepository.estimate7710TransactionMultichain( + paymentChain.relayerUrl, + [paymentParams, executionParams], + ); } /** @@ -1572,7 +2044,7 @@ export class TransactionUtils implements ITransactionUtils { private async buildActivationParams(args: { eoa: EVMAccountAddress; upgradeChainIds: readonly EVMChainId[]; - payment: IActivationPayment; + payment: IRelayerPayment; feeAtoms: TokenAmount; signed: false; }): Promise { @@ -1643,7 +2115,7 @@ export class TransactionUtils implements ITransactionUtils { permissionContext: [toRelayerJson(workDelegation)], executions: [ { - target: EVMAccountAddress(ACTIVATION_NOOP_TARGET), + target: ACTIVATION_NOOP_TARGET, value: "0", data: EMPTY_CALLDATA as HexString, }, @@ -1742,24 +2214,6 @@ function sameEvmChainId( return chainIdKey(a) === chainIdKey(b); } -function pickPaymentToken( - tokens: IPaymentTokenOption[], - preferred?: EVMAccountAddress, -): IPaymentTokenOption | null { - const withBalance = tokens.filter((t) => t.balance > 0n); - if (preferred) { - const match = withBalance.find( - (t) => String(t.address).toLowerCase() === String(preferred).toLowerCase(), - ); - if (match) return match; - } - const usdc = withBalance.find((t) => t.symbol.toUpperCase() === "USDC"); - if (usdc) return usdc; - const usdt = withBalance.find((t) => t.symbol.toUpperCase() === "USDT"); - if (usdt) return usdt; - return withBalance[0] ?? null; -} - function methodSelector(callData: Hex): Hex { if (callData.length >= 10) { return callData.slice(0, 10) as Hex; diff --git a/src/lib/implementations/business/utils/index.ts b/src/lib/implementations/business/utils/index.ts index 3c16184..22c2bb0 100644 --- a/src/lib/implementations/business/utils/index.ts +++ b/src/lib/implementations/business/utils/index.ts @@ -1,2 +1,3 @@ +export { PaymentTokenUtils } from "./PaymentTokenUtils"; export { TransactionUtils } from "./TransactionUtils"; export type { TransactionUtilsOptions } from "./TransactionUtils"; diff --git a/src/lib/interfaces/business/IBridgeService.ts b/src/lib/interfaces/business/IBridgeService.ts index ca71b40..cf764bc 100644 --- a/src/lib/interfaces/business/IBridgeService.ts +++ b/src/lib/interfaces/business/IBridgeService.ts @@ -42,6 +42,7 @@ export interface ICctpBridgeQuote { export interface ICctpBridgePayment { paymentToken: EVMAccountAddress; feeAtoms: TokenAmount; + paymentChainId?: EVMChainId; } export interface ICctpBridgeResult { diff --git a/src/lib/interfaces/business/IDelegationService.ts b/src/lib/interfaces/business/IDelegationService.ts index eab43bc..f245991 100644 --- a/src/lib/interfaces/business/IDelegationService.ts +++ b/src/lib/interfaces/business/IDelegationService.ts @@ -64,11 +64,13 @@ export type ICancelDelegationItem = { permissionContext?: HexString; }; -/** Per-chain USDC payment for {@link IDelegationService.cancelDelegations}. */ +/** Per-chain payment for {@link IDelegationService.cancelDelegations}. */ export type ICancelDelegationChainPayment = { chainId: EVMChainId; paymentToken: EVMAccountAddress; feeAtoms: TokenAmount; + /** Fee payment chain — defaults to `chainId`. */ + paymentChainId?: EVMChainId; }; export interface ICancelDelegationsParams extends IRelayerSendUiCallbacks { diff --git a/src/lib/interfaces/business/ITransactionService.ts b/src/lib/interfaces/business/ITransactionService.ts index 0dffc67..10adfd9 100644 --- a/src/lib/interfaces/business/ITransactionService.ts +++ b/src/lib/interfaces/business/ITransactionService.ts @@ -7,7 +7,7 @@ import type { IRelayerAuthorizationEntry, ISendTransactionResult, } from "../data/IOneshotRelayerRepository"; -import type { IActivationPayment } from "../../types/domain/ActivationPayment"; +import type { IRelayerPayment } from "../../types/domain/RelayerPayment"; import type { IRelayerSendUiCallbacks } from "../../types/domain/RelayerSendUi"; import type { IWalletUpgradeStatus } from "../../types/domain/WalletUpgradeStatus"; import type { TokenAmount } from "../../types/primitives"; @@ -23,6 +23,9 @@ export interface IPaymentTokenOption { export interface IPaymentQuote { tokens: IPaymentTokenOption[]; selectedToken: EVMAccountAddress; + /** Chain where the fee ExactCalldata runs (may differ from the work chain). */ + paymentChainId: EVMChainId; + paymentChainName: string; feeAtoms: TokenAmount; feeFormatted: string; feeCollector: EVMAccountAddress; @@ -36,14 +39,19 @@ export interface ITransactionWork { value?: bigint; } -export interface ISendViaRelayerParams { +export type ISendViaRelayerParams = { chainId: EVMChainId; work: ITransactionWork | ITransactionWork[]; paymentToken: EVMAccountAddress; /** Fee atoms from the confirm UI quote; may be adjusted after estimate. */ feeAtoms: TokenAmount; + /** + * Chain that pays the relayer fee. Defaults to `chainId`. When different, + * fee runs on this chain and work on `chainId` (multichain 7710). + */ + paymentChainId?: EVMChainId; authorizationList?: IRelayerAuthorizationEntry[]; -} +}; /** * Orchestrates EIP-7702 upgrade, fee quotes, ExactCalldata delegations, @@ -65,10 +73,8 @@ export interface ITransactionService { ): Promise; /** - * Prefer USDC with balance, then USDT, else first token with balance. - * When `preferredToken` is set, use it if present in capabilities. - * Quotes via unsigned `relayer_estimate7710Transaction` (placeholder - * signatures) so confirm UI shows an accurate fee before passkey sign. + * Resolve fee payment for work on `chainId` (local-first, Arc USDC fallback), + * then unsigned estimate — single-chain or multichain when payment ≠ work. */ quotePayment( chainId: EVMChainId, @@ -77,20 +83,11 @@ export interface ITransactionService { preferredToken?: EVMAccountAddress, ): Promise; - /** - * Resolve USDC payment for EIP-7702 offline-permission activation among - * candidate chains (requested ∪ Arc). Null when none hold USDC. - */ - resolveActivationPayment( - owner: EVMAccountAddress, - candidateChainIds: readonly EVMChainId[], - ): Promise; - - /** Unsigned USDC fee quote for multi/single-chain EIP-7702 activation. */ + /** Unsigned fee quote for multi/single-chain EIP-7702 activation. */ quoteActivation( owner: EVMAccountAddress, upgradeChainIds: readonly EVMChainId[], - payment: IActivationPayment, + payment: IRelayerPayment, ): Promise; /** @@ -99,7 +96,7 @@ export interface ITransactionService { activateDelegations( args: { upgradeChainIds: readonly EVMChainId[]; - payment: IActivationPayment; + payment: IRelayerPayment; feeAtoms: TokenAmount; } & IRelayerSendUiCallbacks, ): Promise; @@ -115,6 +112,7 @@ export interface ITransactionService { options?: { paymentToken?: EVMAccountAddress; feeAtoms?: TokenAmount; + paymentChainId?: EVMChainId; authorizationList?: IRelayerAuthorizationEntry[]; } & IRelayerSendUiCallbacks, ): Promise; diff --git a/src/lib/interfaces/business/utils/IPaymentTokenUtils.ts b/src/lib/interfaces/business/utils/IPaymentTokenUtils.ts new file mode 100644 index 0000000..bd336f2 --- /dev/null +++ b/src/lib/interfaces/business/utils/IPaymentTokenUtils.ts @@ -0,0 +1,25 @@ +import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; +import type { IRelayerPayment } from "../../../types/domain/RelayerPayment"; + +export const IPaymentTokenUtilsType = Symbol.for("IPaymentTokenUtils"); + +/** + * Centralized selection of which chain + token pays the public-relayer fee. + * Local-first on the execution chain(s), then Arc USDC fallback. + */ +export interface IPaymentTokenUtils { + /** + * Pick payment for work on `executionChainIds`. + * + * 1. If exactly one execution chain has a funded relayer payment token, + * use that chain (USDC → USDT → first; `preferredToken` wins on that chain). + * 2. Else if Arc has funded USDC, use Arc USDC. + * 3. Else first execution chain with any funded payment token. + * 4. Else null. + */ + resolvePayment( + owner: EVMAccountAddress, + executionChainIds: readonly EVMChainId[], + preferredToken?: EVMAccountAddress, + ): Promise; +} diff --git a/src/lib/interfaces/business/utils/ITransactionUtils.ts b/src/lib/interfaces/business/utils/ITransactionUtils.ts index 5782f95..1113aec 100644 --- a/src/lib/interfaces/business/utils/ITransactionUtils.ts +++ b/src/lib/interfaces/business/utils/ITransactionUtils.ts @@ -7,7 +7,7 @@ import type { IRelayerAuthorizationEntry, ISendTransactionResult, } from "../../data/IOneshotRelayerRepository"; -import type { IActivationPayment } from "../../../types/domain/ActivationPayment"; +import type { IRelayerPayment } from "../../../types/domain/RelayerPayment"; import type { IRelayerSendUiCallbacks } from "../../../types/domain/RelayerSendUi"; import type { IWalletUpgradeStatus } from "../../../types/domain/WalletUpgradeStatus"; import type { TokenAmount } from "../../../types/primitives"; @@ -46,10 +46,8 @@ export interface ITransactionUtils { getViemAccount(addressOverride?: EVMAccountAddress): Promise; /** - * Prefer USDC with balance, then USDT, else first token with balance. - * Builds unsigned ExactCalldata fee+work delegations (placeholder - * signatures) and calls `relayer_estimate7710Transaction` so the confirm - * UI shows `requiredPaymentAmount` before any passkey ceremony. + * Resolve fee payment for work on `chainId` (local-first, Arc USDC fallback), + * then unsigned estimate — single-chain or multichain when payment ≠ work. */ quotePayment( chainId: EVMChainId, @@ -59,35 +57,28 @@ export interface ITransactionUtils { ): Promise; /** - * Pick USDC payment for EIP-7702 activation among `candidateChainIds` - * (requested ∪ Arc). Prefers Arc when its USDC balance is > 0; else the - * first candidate (in order) with USDC. Returns null when none have USDC. - */ - resolveActivationPayment( - owner: EVMAccountAddress, - candidateChainIds: readonly EVMChainId[], - ): Promise; - - /** - * Unsigned USDC fee quote for activating EIP-7702 on `upgradeChainIds`, + * Unsigned fee quote for activating EIP-7702 on `upgradeChainIds`, * paid on `paymentChainId`. Uses single-chain or multichain estimate. */ quoteActivation( owner: EVMAccountAddress, upgradeChainIds: readonly EVMChainId[], - payment: IActivationPayment, + payment: IRelayerPayment, ): Promise; /** * Public-relayer ExactCalldata fee + work path: optional EIP-7702 upgrade, * estimate, send, poll. `work` may be one item (Send) or several * (e.g. USDC approve + CCTP burn) — still one fee and one passkey ceremony. + * When `paymentChainId` differs from `chainId`, fee is multichain. */ sendViaRelayer(args: { chainId: EVMChainId; work: ITransactionWork | ITransactionWork[]; paymentToken: EVMAccountAddress; feeAtoms: TokenAmount; + /** Defaults to `chainId`. */ + paymentChainId?: EVMChainId; authorizationList?: IRelayerAuthorizationEntry[]; relayerUrl: string; /** Batch relayer vault auth into the coalesced sign ceremony via executeBatch. */ @@ -101,7 +92,7 @@ export interface ITransactionUtils { */ activateDelegations(args: { upgradeChainIds: readonly EVMChainId[]; - payment: IActivationPayment; + payment: IRelayerPayment; feeAtoms: TokenAmount; } & IRelayerSendUiCallbacks): Promise; diff --git a/src/lib/interfaces/business/utils/index.ts b/src/lib/interfaces/business/utils/index.ts index ffcaacd..df6bc91 100644 --- a/src/lib/interfaces/business/utils/index.ts +++ b/src/lib/interfaces/business/utils/index.ts @@ -1,3 +1,5 @@ +export type { IPaymentTokenUtils } from "./IPaymentTokenUtils"; +export { IPaymentTokenUtilsType } from "./IPaymentTokenUtils"; export type { ITransactionUtils } from "./ITransactionUtils"; export { ITransactionUtilsType, diff --git a/src/lib/types/domain/ActivationPayment.ts b/src/lib/types/domain/ActivationPayment.ts deleted file mode 100644 index 0255233..0000000 --- a/src/lib/types/domain/ActivationPayment.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { - EVMAccountAddress, - EVMChainId, -} from "@1shotapi/ows-types"; -import type { TokenAmount } from "../primitives"; - -/** Payment chain + USDC selected for offline-permission EIP-7702 activation. */ -export interface IActivationPayment { - paymentChainId: EVMChainId; - paymentToken: EVMAccountAddress; - /** Human-readable payment-chain label for the confirm modal. */ - paymentChainName: string; - usdcBalance: TokenAmount; - usdcDecimals: number; - usdcSymbol: string; -} diff --git a/src/lib/types/domain/RelayerPayment.ts b/src/lib/types/domain/RelayerPayment.ts new file mode 100644 index 0000000..71a018a --- /dev/null +++ b/src/lib/types/domain/RelayerPayment.ts @@ -0,0 +1,19 @@ +import type { + EVMAccountAddress, + EVMChainId, +} from "@1shotapi/ows-types"; +import type { TokenAmount } from "../primitives"; + +/** + * Relayer fee payment selected for one or more execution chains. + * Fee ExactCalldata runs on `paymentChainId` (may differ from the work chain). + */ +export interface IRelayerPayment { + paymentChainId: EVMChainId; + paymentToken: EVMAccountAddress; + /** Human-readable payment-chain label for confirm UI. */ + paymentChainName: string; + balance: TokenAmount; + decimals: number; + symbol: string; +} diff --git a/src/lib/types/domain/index.ts b/src/lib/types/domain/index.ts index 4953353..73f338a 100644 --- a/src/lib/types/domain/index.ts +++ b/src/lib/types/domain/index.ts @@ -1,5 +1,5 @@ export { AssetActivity } from "./AssetActivity"; -export type { IActivationPayment } from "./ActivationPayment"; +export type { IRelayerPayment } from "./RelayerPayment"; export { BitcoinUtxo } from "./BitcoinUtxo"; export { KnownAsset } from "./KnownAsset"; export { NewTrackedAsset, TrackedAsset } from "./TrackedAsset"; diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index b777879..8294474 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -46,6 +46,7 @@ import { CCTPUtils, DelegationService, LiFiUtils, + PaymentTokenUtils, TransactionService, } from "../lib/implementations/business"; import { @@ -81,6 +82,7 @@ import type { } from "../lib/interfaces/business"; import type { ICCTPUtils } from "../lib/interfaces/business/utils/ICCTPUtils"; import type { ILiFiUtils } from "../lib/interfaces/business/utils/ILiFiUtils"; +import type { IPaymentTokenUtils } from "../lib/interfaces/business/utils/IPaymentTokenUtils"; import type { ICircleProvider, IConfigProvider, @@ -161,10 +163,17 @@ const credentialRepository = new CachedRelayerVaultRepository({ owsProvider, }); +const paymentTokenUtils = new PaymentTokenUtils( + chainRepository, + oneshotRelayerRepository, + trackedAssetRepository, +); + const businessTransactionUtils = new BusinessTransactionUtils({ chainRepository, relayerRepository: oneshotRelayerRepository, trackedAssetRepository, + paymentTokenUtils, blockchain: blockchainProvider, presentationTransactionUtils: transactionUtils, owsProvider, @@ -229,6 +238,7 @@ export type WalletContextValue = { oneshotRelayerRepository: IOneshotRelayerRepository; evmRepository: IEVMRepository; transactionService: ITransactionService; + paymentTokenUtils: IPaymentTokenUtils; bridgeService: IBridgeService; bitcoinService: IBitcoinService; delegationService: IDelegationService; @@ -316,6 +326,7 @@ export type WalletContextValue = { payment?: { paymentToken: EVMAccountAddress; feeAtoms: TokenAmount; + paymentChainId?: EVMChainId; }, ) => Promise; /** @@ -506,6 +517,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { knownAssetRepository, trackedAssetRepository, transactionService, + paymentTokenUtils, delegationService, transactionUtils, cctpUtils, @@ -906,6 +918,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { oneshotRelayerRepository, evmRepository, transactionService, + paymentTokenUtils, bridgeService, bitcoinService, delegationService, diff --git a/src/wallet/modalTypes.ts b/src/wallet/modalTypes.ts index f3b6380..de1bf62 100644 --- a/src/wallet/modalTypes.ts +++ b/src/wallet/modalTypes.ts @@ -13,7 +13,7 @@ import type { IExecutionPermission, IExecutionPermissionRequest, } from "@1shotapi/ows-types"; -import type { IActivationPayment } from "../lib/types/domain/ActivationPayment"; +import type { IRelayerPayment } from "../lib/types/domain/RelayerPayment"; import type { IRelayerSendUiCallbacks } from "../lib/types/domain/RelayerSendUi"; import type { ISiweFields } from "../lib/types/domain/SiweFields"; import type { IAddAssetApprovalRequest } from "./registerAddAsset"; @@ -48,12 +48,15 @@ export type IConfirmSendPayment = { /** Required when the confirm modal was opened with `useRelayer: true`. */ paymentToken?: EVMAccountAddress; feeAtoms?: TokenAmount; + paymentChainId?: EVMChainId; }; /** Relayer confirm payload after UI validation. */ export type IRelayerConfirmSendResult = { paymentToken: EVMAccountAddress; feeAtoms: TokenAmount; + /** Chain that pays the fee (may differ from the work chain). */ + paymentChainId: EVMChainId; }; /** Result from TX confirm when canceling or selecting payment (legacy shape). */ @@ -96,6 +99,8 @@ export type ICancelDelegationPayment = { chainId: EVMChainId; paymentToken: EVMAccountAddress; feeAtoms: TokenAmount; + /** Fee payment chain — defaults to `chainId` when omitted. */ + paymentChainId?: EVMChainId; }; /** Cancel / revoke confirm (on-chain disableDelegation, possibly batched). */ @@ -119,7 +124,7 @@ export interface IActivateOfflinePermissionsRequest { * plus the USDC payment chain (usually Arc) when either needs upgrade. */ upgradeChains: Array<{ chainId: EVMChainId; chainName: string }>; - payment: IActivationPayment; + payment: IRelayerPayment; } export type ModalRequest = diff --git a/src/wallet/useWalletBoot.ts b/src/wallet/useWalletBoot.ts index d190f08..020fc17 100644 --- a/src/wallet/useWalletBoot.ts +++ b/src/wallet/useWalletBoot.ts @@ -63,6 +63,7 @@ import type { } from "../lib/interfaces/utils"; import type { ICCTPUtils } from "../lib/interfaces/business/utils/ICCTPUtils"; import type { ILiFiUtils } from "../lib/interfaces/business/utils/ILiFiUtils"; +import type { IPaymentTokenUtils } from "../lib/interfaces/business/utils/IPaymentTokenUtils"; import { SIWEUtils } from "../lib/implementations/utils/SIWEUtils"; import type { SupportedChain } from "../lib/types/domain"; import type { TokenAmount } from "../lib/types/primitives"; @@ -166,15 +167,22 @@ function createDeferredSigner( function requireRelayerConfirmPayment(confirmed: { paymentToken?: EVMAccountAddress; feeAtoms?: TokenAmount; + paymentChainId?: EVMChainId; }): IRelayerConfirmSendResult { if (!confirmed.paymentToken || confirmed.feeAtoms === undefined) { throw new OwsInvalidParamsError( "Select a relayer payment token and fee before confirming the transaction", ); } + if (!confirmed.paymentChainId) { + throw new OwsInvalidParamsError( + "Missing paymentChainId from the fee quote", + ); + } return { paymentToken: confirmed.paymentToken, feeAtoms: confirmed.feeAtoms, + paymentChainId: confirmed.paymentChainId, }; } @@ -199,6 +207,7 @@ export interface IUseWalletBootParams { knownAssetRepository: IKnownAssetRepository; trackedAssetRepository: ITrackedAssetRepository; transactionService: ITransactionService; + paymentTokenUtils: IPaymentTokenUtils; delegationService: IDelegationService; transactionUtils: ITransactionUtils; cctpUtils: ICCTPUtils; @@ -230,6 +239,7 @@ export function useWalletBoot({ knownAssetRepository, trackedAssetRepository, transactionService, + paymentTokenUtils, delegationService, transactionUtils, cctpUtils, @@ -395,19 +405,11 @@ export function useWalletBoot({ ).values(), ]; - // Always consider Arc (DEFAULT_CHAIN_ID): activation fees are - // paid in USDC there even when the grant is only for Base. - const activationCandidateChainIds = [ - ...new Map( - [...requestedChainIds, DEFAULT_CHAIN_ID].map( - (chainId) => - [BigInt(chainId).toString(10), chainId] as const, - ), - ).values(), - ]; - + // Upgrade check is for grant/requested chains only. Arc is + // considered as a payment fallback inside PaymentTokenUtils — + // if the fee lands on Arc, we append it below when needed. const upgradeChecks = await Promise.all( - activationCandidateChainIds.map(async (chainId) => ({ + requestedChainIds.map(async (chainId) => ({ chainId, needsUpgrade: await transactionService.needsWalletUpgrade( chainId, @@ -420,11 +422,10 @@ export function useWalletBoot({ .map((row) => row.chainId); if (upgradeChainIds.length > 0) { - const payment = - await transactionService.resolveActivationPayment( - owner, - activationCandidateChainIds, - ); + const payment = await paymentTokenUtils.resolvePayment( + owner, + upgradeChainIds, + ); if (!payment) { throw new OwsInvalidParamsError( styleController.get().copy.activateOfflinePermissions @@ -977,6 +978,7 @@ export function useWalletBoot({ payment: { paymentToken?: EVMAccountAddress; feeAtoms?: TokenAmount; + paymentChainId?: EVMChainId; }, ui?: import("../lib/types/domain/RelayerSendUi").IRelayerSendUiCallbacks, ) => { @@ -984,6 +986,7 @@ export function useWalletBoot({ | { paymentToken: EVMAccountAddress; feeAtoms: TokenAmount; + paymentChainId: EVMChainId; } | undefined; if (useRelayer) { @@ -991,6 +994,7 @@ export function useWalletBoot({ relayerOptions = { paymentToken: confirmed.paymentToken, feeAtoms: confirmed.feeAtoms, + paymentChainId: confirmed.paymentChainId, }; } From d3f0865def10c406361fa5989a89abc9d6709408 Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Wed, 23 Sep 2026 21:49:03 -0700 Subject: [PATCH 7/8] Update dependencies and refactor chain ID handling across components - Updated `@1shotapi/ows-signer-utils`, `@1shotapi/ows-types`, and `@1shotapi/ows-wallet-utils` to their latest versions in `package.json`. - Refactored chain ID handling in various components and services to utilize `ChainUtils` for improved type safety and consistency. - Simplified chain ID comparisons by removing unnecessary conversions and using direct comparisons. - Enhanced the `PaymentFeePicker`, `CancelDelegationModal`, and `TransactionUtils` to streamline chain ID management and improve overall code clarity. --- package-lock.json | 24 ++++---- package.json | 6 +- src/components/OnrampView.tsx | 4 +- src/components/PaymentFeePicker.tsx | 4 +- src/components/delegations/DelegationsTab.tsx | 4 +- src/components/modals/CCTPBridge.tsx | 6 +- .../modals/CancelDelegationModal.tsx | 21 +++---- .../permissionGrantTerms/lifiSwapTerms.tsx | 22 +++++--- .../business/DelegationService.ts | 13 ++--- .../business/utils/PaymentTokenUtils.ts | 7 +-- .../business/utils/TransactionUtils.ts | 56 ++++++++----------- .../data/BlockscoutAssetActivityRepository.ts | 4 +- .../data/CachedRelayerVaultRepository.ts | 6 +- .../implementations/data/CircleRepository.ts | 6 +- .../LocalStorageTrackedAssetRepository.ts | 4 +- src/lib/utils/siweDisplay.ts | 18 +++--- src/wallet/WalletProvider.tsx | 7 +-- src/wallet/registerAddAsset.ts | 8 +-- src/wallet/registerBridge.ts | 4 +- src/wallet/registerFocusMode.ts | 14 ++--- src/wallet/useWalletBoot.ts | 10 +--- 21 files changed, 107 insertions(+), 141 deletions(-) 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/src/components/OnrampView.tsx b/src/components/OnrampView.tsx index 113d469..25a3381 100644 --- a/src/components/OnrampView.tsx +++ b/src/components/OnrampView.tsx @@ -7,8 +7,8 @@ import type { import { ChainUtils, EVMAccountAddress, - EVMChainId, type EVMAccountAddress as EVMAccountAddressType, + type EVMChainId, } from "@1shotapi/ows-types"; import { zeroAddress } from "viem"; import { Modal, type ModalAction } from "./Modal"; @@ -389,7 +389,7 @@ function resolveEffectiveEvmChainId( sessionChainId: ReturnType["chainId"], ): EVMChainId | null { if (chainIdProp != null && Number.isFinite(chainIdProp)) { - return EVMChainId(`0x${chainIdProp.toString(16)}` as `0x${string}`); + return ChainUtils.asEVMChainId(chainIdProp); } if (ChainUtils.isEVMChainId(sessionChainId)) { return sessionChainId; diff --git a/src/components/PaymentFeePicker.tsx b/src/components/PaymentFeePicker.tsx index f3f5e80..dbd5539 100644 --- a/src/components/PaymentFeePicker.tsx +++ b/src/components/PaymentFeePicker.tsx @@ -170,9 +170,7 @@ export function PaymentFeePicker({ {error ? (

{error}

) : null} - {quote && - BigInt(quote.paymentChainId).toString(10) !== - BigInt(chainId).toString(10) ? ( + {quote && quote.paymentChainId !== chainId ? (

Paid on {quote.paymentChainName}

diff --git a/src/components/delegations/DelegationsTab.tsx b/src/components/delegations/DelegationsTab.tsx index ba7c1ca..2a58ace 100644 --- a/src/components/delegations/DelegationsTab.tsx +++ b/src/components/delegations/DelegationsTab.tsx @@ -99,8 +99,8 @@ export function DelegationsTab() { setError(null); try { const result = await cancelStoredDelegations(ids); - if (result.transactionHashes && result.transactionHashes.length > 0) { - const last = result.results[result.results.length - 1]!; + const last = result.results[result.results.length - 1]; + if (last) { setSent({ chainId: last.chainId, transactionHash: last.transactionHash, diff --git a/src/components/modals/CCTPBridge.tsx b/src/components/modals/CCTPBridge.tsx index a405a97..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, diff --git a/src/components/modals/CancelDelegationModal.tsx b/src/components/modals/CancelDelegationModal.tsx index a0f2f5b..3290475 100644 --- a/src/components/modals/CancelDelegationModal.tsx +++ b/src/components/modals/CancelDelegationModal.tsx @@ -24,24 +24,19 @@ type ChainGroup = { work: ITransactionWork[]; }; -function chainKey(chainId: EVMChainId): string { - return BigInt(chainId).toString(10); -} - function groupItemsByChain( items: ICancelDelegationConfirmRequest["items"], ): ChainGroup[] { - const map = new Map(); + const map = new Map(); for (const item of items) { - const key = chainKey(item.chainId); - let group = map.get(key); + let group = map.get(item.chainId); if (!group) { group = { chainId: item.chainId, chainName: item.chainName, work: [], }; - map.set(key, group); + map.set(item.chainId, group); } group.work.push(item.work); } @@ -119,7 +114,7 @@ export function CancelDelegationModal({ const allQuotesReady = chainGroups.length > 0 && chainGroups.every((group) => { - const key = chainKey(group.chainId); + const key = group.chainId; return quotes[key] != null && !quoteErrors[key]; }); @@ -138,7 +133,7 @@ export function CancelDelegationModal({ const setChainQuote = useCallback( (chainId: EVMChainId, quote: IPaymentQuote | null, err: string | null) => { - const key = chainKey(chainId); + const key = chainId; setQuotes((prev) => ({ ...prev, [key]: quote })); setQuoteErrors((prev) => ({ ...prev, [key]: err })); }, @@ -147,7 +142,7 @@ export function CancelDelegationModal({ const buildPayments = useCallback((): ICancelDelegationPayment[] => { return chainGroups.map((group) => { - const quote = quotes[chainKey(group.chainId)]; + const quote = quotes[group.chainId]; if (!quote) { throw new Error(`Missing fee quote for chain ${group.chainName}`); } @@ -294,7 +289,7 @@ export function CancelDelegationModal({
    {request.items.map((item, index) => (
  • @@ -316,7 +311,7 @@ export function CancelDelegationModal({

    ) : null} {chainGroups.map((group) => { - const key = chainKey(group.chainId); + const key = group.chainId; return (
    {chainGroups.length > 1 ? ( diff --git a/src/components/modals/permissionGrantTerms/lifiSwapTerms.tsx b/src/components/modals/permissionGrantTerms/lifiSwapTerms.tsx index 66625bc..0947f4b 100644 --- a/src/components/modals/permissionGrantTerms/lifiSwapTerms.tsx +++ b/src/components/modals/permissionGrantTerms/lifiSwapTerms.tsx @@ -1,5 +1,9 @@ import { useEffect, useMemo, useState } from "react"; -import { EVMAccountAddress, type IExecutionPermissionRequest } from "@1shotapi/ows-types"; +import { + ChainUtils, + EVMAccountAddress, + type IExecutionPermissionRequest, +} from "@1shotapi/ows-types"; import { formatUnits, getAddress } from "viem"; import { parseLiFiSwapData } from "../../../lib/implementations/business/DelegationService"; import { LIFI_SWAP_PERIODIC } from "../../../lib/interfaces/business/IDelegationService"; @@ -181,14 +185,14 @@ export function LiFiSwapPermissionTerms({ const sourceChain = resolveChain(executionRequest.chainId); const destChainIdRaw = readString(permissionData, "destinationChainId"); - const destChainHex = - destChainIdRaw === "" - ? null - : destChainIdRaw.startsWith("0x") || destChainIdRaw.startsWith("0X") - ? destChainIdRaw - : /^\d+$/.test(destChainIdRaw) - ? `0x${BigInt(destChainIdRaw).toString(16)}` - : null; + let destChainHex: string | null = null; + if (destChainIdRaw !== "") { + try { + destChainHex = ChainUtils.asEVMChainId(destChainIdRaw); + } catch { + destChainHex = null; + } + } const destChain = destChainHex ? resolveChain(destChainHex as never) : undefined; diff --git a/src/lib/implementations/business/DelegationService.ts b/src/lib/implementations/business/DelegationService.ts index 12a67a2..f831990 100644 --- a/src/lib/implementations/business/DelegationService.ts +++ b/src/lib/implementations/business/DelegationService.ts @@ -248,7 +248,7 @@ export class DelegationService implements IDelegationService { ); const byChain = new Map< - string, + EVMChainId, { chainId: EVMChainId; work: ITransactionWork[]; @@ -256,25 +256,24 @@ export class DelegationService implements IDelegationService { } >(); for (const item of resolvedItems) { - const key = BigInt(item.chainId).toString(10); - let group = byChain.get(key); + let group = byChain.get(item.chainId); if (!group) { group = { chainId: item.chainId, work: [], stored: [] }; - byChain.set(key, group); + byChain.set(item.chainId, group); } group.work.push(item.work); if (item.stored) group.stored.push(item.stored); } - const paymentByChain = new Map(); + const paymentByChain = new Map(); for (const payment of params.payments) { - paymentByChain.set(BigInt(payment.chainId).toString(10), payment); + paymentByChain.set(payment.chainId, payment); } const results: ICancelDelegationsResult["results"] = []; let firstChain = true; for (const group of byChain.values()) { - const payment = paymentByChain.get(BigInt(group.chainId).toString(10)); + const payment = paymentByChain.get(group.chainId); if (!payment) { throw new Error( `cancelDelegations missing payment for chain ${group.chainId}`, diff --git a/src/lib/implementations/business/utils/PaymentTokenUtils.ts b/src/lib/implementations/business/utils/PaymentTokenUtils.ts index 48ef7ad..da443d0 100644 --- a/src/lib/implementations/business/utils/PaymentTokenUtils.ts +++ b/src/lib/implementations/business/utils/PaymentTokenUtils.ts @@ -133,11 +133,10 @@ export class PaymentTokenUtils implements IPaymentTokenUtils { function uniqueChainIds(chainIds: readonly EVMChainId[]): EVMChainId[] { const unique: EVMChainId[] = []; - const seen = new Set(); + const seen = new Set(); for (const id of chainIds) { - const key = BigInt(id).toString(10); - if (seen.has(key)) continue; - seen.add(key); + if (seen.has(id)) continue; + seen.add(id); unique.push(id); } return unique; diff --git a/src/lib/implementations/business/utils/TransactionUtils.ts b/src/lib/implementations/business/utils/TransactionUtils.ts index 2d904d3..d73a19c 100644 --- a/src/lib/implementations/business/utils/TransactionUtils.ts +++ b/src/lib/implementations/business/utils/TransactionUtils.ts @@ -332,7 +332,7 @@ export class TransactionUtils implements ITransactionUtils { const seedFeeAtoms = makeTokenAmount( parseUnits("0.01", selected.decimals), ); - const crossChain = !sameEvmChainId(payment.paymentChainId, chainId); + const crossChain = payment.paymentChainId !== chainId; let estimate; if (!crossChain) { @@ -611,19 +611,19 @@ export class TransactionUtils implements ITransactionUtils { > >(); chainSmartAccounts.set( - chainIdKey(payment.paymentChainId), + payment.paymentChainId, paymentSmartAccount, ); upgradeCapabilities.set( - chainIdKey(payment.paymentChainId), + payment.paymentChainId, paymentCapabilities, ); const missingUpgradeIds = upgradeChainIds.filter( - (chainId) => !chainSmartAccounts.has(chainIdKey(chainId)), + (chainId) => !chainSmartAccounts.has(chainId), ); await Promise.all( missingUpgradeIds.map(async (chainId) => { - const key = chainIdKey(chainId); + const key = chainId; if (!upgradeCapabilities.has(key)) { const chain = await this.requireRelayerChain(chainId); const caps = await this.options.relayerRepository.getCapabilities( @@ -683,9 +683,9 @@ export class TransactionUtils implements ITransactionUtils { Promise.all( upgradeChainIds.map((chainId) => { const smartAccount = chainSmartAccounts.get( - chainIdKey(chainId), + chainId, ); - const caps = upgradeCapabilities.get(chainIdKey(chainId)); + const caps = upgradeCapabilities.get(chainId); if (!smartAccount || !caps) { throw new Error( `Missing smart account or capabilities for ${chainId}`, @@ -707,7 +707,7 @@ export class TransactionUtils implements ITransactionUtils { const authByChain = new Map(); for (let i = 0; i < upgradeChainIds.length; i += 1) { authByChain.set( - chainIdKey(upgradeChainIds[i]!), + upgradeChainIds[i]!, signed.authEntries[i]!, ); } @@ -715,7 +715,7 @@ export class TransactionUtils implements ITransactionUtils { const workByChain = new Map(); for (let i = 0; i < upgradeChainIds.length; i += 1) { workByChain.set( - chainIdKey(upgradeChainIds[i]!), + upgradeChainIds[i]!, signed.workDelegations[i]!, ); } @@ -739,11 +739,11 @@ export class TransactionUtils implements ITransactionUtils { return Promise.all( orderedChainIds.map(async (chainId) => { - const isPayment = sameEvmChainId(chainId, payment.paymentChainId); + const isPayment = chainId === payment.paymentChainId; const needsUpgrade = upgradeChainIds.some((id) => - sameEvmChainId(id, chainId), + id === chainId, ); - const chainKey = chainIdKey(chainId); + const chainKey = chainId; const transactions: IRelayer7710Params["transactions"] = []; if (isPayment) { @@ -865,7 +865,7 @@ export class TransactionUtils implements ITransactionUtils { estimate.contextByChainId ?? (estimate.context ? { - [chainIdKey(payment.paymentChainId)]: estimate.context, + [payment.paymentChainId]: estimate.context, } : undefined); params = await buildChainParams(feeAtoms, contextByChainId); @@ -896,7 +896,7 @@ export class TransactionUtils implements ITransactionUtils { taskId, ); if ( - upgradeChainIds.some((id) => sameEvmChainId(id, chainId)) + upgradeChainIds.some((id) => id === chainId) ) { await this.options.chainRepository.setWalletUpgraded( chainId, @@ -1002,7 +1002,7 @@ export class TransactionUtils implements ITransactionUtils { onFinalFeeRequired?: (fee: IFinalRelayerFee) => Promise; }): Promise { const paymentChainId = args.paymentChainId ?? args.chainId; - if (!sameEvmChainId(paymentChainId, args.chainId)) { + if (paymentChainId !== args.chainId) { return this.sendViaRelayerCrossChain({ ...args, paymentChainId, @@ -1546,8 +1546,8 @@ export class TransactionUtils implements ITransactionUtils { args: [paymentCapabilities.feeCollector, feeAmount], }), ); - const paymentKey = chainIdKey(paymentChainId); - const executionKey = chainIdKey(executionChainId); + const paymentKey = paymentChainId; + const executionKey = executionChainId; return [ { chainId: paymentChainIdNumber.toString(10), @@ -2057,9 +2057,9 @@ export class TransactionUtils implements ITransactionUtils { return Promise.all( ordered.map(async (chainId) => { - const isPayment = sameEvmChainId(chainId, payment.paymentChainId); + const isPayment = chainId === payment.paymentChainId; const needsUpgrade = upgradeChainIds.some((id) => - sameEvmChainId(id, chainId), + id === chainId, ); const chain = await this.requireRelayerChain(chainId); const capabilities = @@ -2183,7 +2183,7 @@ function shouldUseActivationMultichain( paymentChainId: EVMChainId, ): boolean { if (upgradeChainIds.length !== 1) return true; - return !sameEvmChainId(upgradeChainIds[0]!, paymentChainId); + return upgradeChainIds[0]! !== paymentChainId; } /** Fee/payment chain first, then remaining upgrade chains. */ @@ -2192,9 +2192,9 @@ function orderedActivationChainIds( paymentChainId: EVMChainId, ): EVMChainId[] { const ordered: EVMChainId[] = [paymentChainId]; - const seen = new Set([chainIdKey(paymentChainId)]); + const seen = new Set([paymentChainId]); for (const chainId of upgradeChainIds) { - const key = chainIdKey(chainId); + const key = chainId; if (seen.has(key)) continue; seen.add(key); ordered.push(chainId); @@ -2202,18 +2202,6 @@ function orderedActivationChainIds( return ordered; } -/** Canonical decimal key so `0x13b2` and `5042` match. */ -function chainIdKey(chainId: EVMChainId | string | number | bigint): string { - return BigInt(chainId).toString(10); -} - -function sameEvmChainId( - a: EVMChainId | string | number | bigint, - b: EVMChainId | string | number | bigint, -): boolean { - return chainIdKey(a) === chainIdKey(b); -} - function methodSelector(callData: Hex): Hex { if (callData.length >= 10) { return callData.slice(0, 10) as Hex; diff --git a/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts b/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts index c4d5300..ce6c312 100644 --- a/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts +++ b/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts @@ -1,6 +1,6 @@ import { + ChainUtils, EVMAccountAddress, - EVMChainId, EVMTransactionHash, type EVMAccountAddress as EVMAccountAddressType, type EVMChainId as EVMChainIdType, @@ -309,7 +309,7 @@ export class BlockscoutAssetActivityRepository ): AssetActivity { return new AssetActivity( EVMTransactionHash(row.hash as `0x${string}`), - EVMChainId(row.chainId as `0x${string}`), + ChainUtils.asEVMChainId(row.chainId), EVMAccountAddress(row.tokenAddress as `0x${string}`), trackedAssetId, EVMAccountAddress(row.owner as `0x${string}`), diff --git a/src/lib/implementations/data/CachedRelayerVaultRepository.ts b/src/lib/implementations/data/CachedRelayerVaultRepository.ts index fd9621a..447ef7c 100644 --- a/src/lib/implementations/data/CachedRelayerVaultRepository.ts +++ b/src/lib/implementations/data/CachedRelayerVaultRepository.ts @@ -1,8 +1,8 @@ import { AES256CipherTextEnvelope, + ChainUtils, DomainString, EVMAccountAddress, - EVMChainId, EVMContractAddress, HexString, UnixTimestamp, @@ -671,7 +671,7 @@ export class CachedRelayerVaultRepository Record >; const response: IExecutionPermissionResponse = { - chainId: EVMChainId(this.asHex(record.chainId)), + chainId: ChainUtils.asEVMChainId(this.asHex(record.chainId)), to: EVMAccountAddress(this.asHex(record.to)), permission: record.permission as IExecutionPermissionResponse["permission"], @@ -698,7 +698,7 @@ export class CachedRelayerVaultRepository return { delegationId: DelegationId(raw.delegationId), delegationHash: HexString(raw.delegationHash), - chainId: EVMChainId(raw.chainId), + chainId: ChainUtils.asEVMChainId(raw.chainId), hostDomain: DomainString(raw.hostDomain), memo: raw.memo, createdAt: UnixTimestamp(raw.createdAt), diff --git a/src/lib/implementations/data/CircleRepository.ts b/src/lib/implementations/data/CircleRepository.ts index 22d83fe..2298bbe 100644 --- a/src/lib/implementations/data/CircleRepository.ts +++ b/src/lib/implementations/data/CircleRepository.ts @@ -1,6 +1,6 @@ import { + ChainUtils, EVMAccountAddress, - EVMChainId, EVMTransactionHash, type EVMAccountAddress as EVMAccountAddressType, type EVMTransactionHash as EVMTransactionHashType, @@ -74,8 +74,8 @@ export function parseInFlight(raw: string): ICctpInFlightBurn | null { return { burnTxHash: EVMTransactionHash(row.burnTxHash as `0x${string}`), sourceDomain: row.sourceDomain as ECircleDomainId, - sourceChainId: EVMChainId(row.sourceChainId as `0x${string}`), - destChainId: EVMChainId(row.destChainId as `0x${string}`), + sourceChainId: ChainUtils.asEVMChainId(row.sourceChainId), + destChainId: ChainUtils.asEVMChainId(row.destChainId), amountAtoms, address: EVMAccountAddress(row.address as `0x${string}`), }; diff --git a/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts b/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts index a594cee..842bc88 100644 --- a/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts +++ b/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts @@ -1,7 +1,7 @@ import { erc20Abi, type Address } from "viem"; import { + ChainUtils, EVMAccountAddress, - EVMChainId, type EVMAccountAddress as EVMAccountAddressType, type EVMChainId as EVMChainIdType, } from "@1shotapi/ows-types"; @@ -299,7 +299,7 @@ export class LocalStorageTrackedAssetRepository ) { continue; } - const chainId = EVMChainId(row.chainId as `0x${string}`); + const chainId = ChainUtils.asEVMChainId(row.chainId); const address = EVMAccountAddress(row.address as `0x${string}`); const type = row.type === EAssetType.Native diff --git a/src/lib/utils/siweDisplay.ts b/src/lib/utils/siweDisplay.ts index 2bd775d..dab187b 100644 --- a/src/lib/utils/siweDisplay.ts +++ b/src/lib/utils/siweDisplay.ts @@ -1,4 +1,7 @@ -import { EVMChainId } from "@1shotapi/ows-types"; +import { + ChainUtils, + type EVMChainId, +} from "@1shotapi/ows-types"; /** Lowercase host for SIWE origin comparison (strips port). */ export function normalizeSiweHost(value: string): string { @@ -32,16 +35,9 @@ export function resolveSiweEvmChainId(chainIdRaw: string): EVMChainId | null { if (!trimmed) { return null; } - let decimal: number; - if (/^0x[0-9a-fA-F]+$/i.test(trimmed)) { - decimal = Number(BigInt(trimmed)); - } else if (/^\d+$/.test(trimmed)) { - decimal = Number(trimmed); - } else { - return null; - } - if (!Number.isFinite(decimal) || decimal < 0) { + try { + return ChainUtils.asEVMChainId(trimmed); + } catch { return null; } - return EVMChainId(`0x${decimal.toString(16)}` as `0x${string}`); } diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index 8294474..b80a9af 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -792,11 +792,10 @@ export function WalletProvider({ children }: { children: ReactNode }) { } // One hash per unique chain (cancelDelegations groups by chain). const chainOrder: EVMChainId[] = []; - const seen = new Set(); + const seen = new Set(); for (const stored of storedList) { - const key = BigInt(stored.chainId).toString(10); - if (seen.has(key)) continue; - seen.add(key); + if (seen.has(stored.chainId)) continue; + seen.add(stored.chainId); chainOrder.push(stored.chainId); } return { diff --git a/src/wallet/registerAddAsset.ts b/src/wallet/registerAddAsset.ts index 5210631..9c69ffa 100644 --- a/src/wallet/registerAddAsset.ts +++ b/src/wallet/registerAddAsset.ts @@ -2,9 +2,10 @@ import { z } from "zod"; import type { OWSWallet } from "@1shotapi/ows-wallet-utils"; import { EVMAccountAddress, - EVMChainId, + EVMChainIdSchema, OwsUserRejectedError, type EVMAccountAddress as EVMAccountAddressType, + type EVMChainId, } from "@1shotapi/ows-types"; import type { IKnownAssetRepository, @@ -17,10 +18,7 @@ import { useWalletSessionStore } from "./sessionStore"; export const ADD_ASSET_RPC_METHOD = "addAsset"; const addAssetParamsSchema = z.strictObject({ - chainId: z - .string() - .regex(/^0x[0-9a-fA-F]+$/) - .transform((value) => EVMChainId(value as `0x${string}`)), + chainId: EVMChainIdSchema, assetAddress: z .string() .regex(/^0x[0-9a-fA-F]{40}$/) diff --git a/src/wallet/registerBridge.ts b/src/wallet/registerBridge.ts index 0fe99cb..c8cbe96 100644 --- a/src/wallet/registerBridge.ts +++ b/src/wallet/registerBridge.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { OWSWallet } from "@1shotapi/ows-wallet-utils"; import { - EVMChainId, + ChainUtils, EVMContractAddress, OwsInvalidParamsError, OwsUserRejectedError, @@ -43,7 +43,7 @@ export type RegisterBridgeOptions = { }; export function evmChainIdFromDecimal(decimal: number): EVMChainIdType { - return EVMChainId(`0x${decimal.toString(16)}`); + return ChainUtils.asEVMChainId(decimal); } /** Host `sourceChainId` is decimal; omit → the current session chain. */ diff --git a/src/wallet/registerFocusMode.ts b/src/wallet/registerFocusMode.ts index 761ec67..94858dc 100644 --- a/src/wallet/registerFocusMode.ts +++ b/src/wallet/registerFocusMode.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { OWSWallet, RpcHelper } from "@1shotapi/ows-wallet-utils"; -import { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; +import { EVMAccountAddressSchema, EVMChainIdSchema } from "@1shotapi/ows-types"; import { EWalletMode, useWalletSessionStore, @@ -13,15 +13,9 @@ export const FOCUS_WALLET_RPC_METHOD = "focusWallet"; export const UNFOCUS_WALLET_RPC_METHOD = "unfocusWallet"; const focusWalletParamsSchema = z.strictObject({ - chainId: z - .string() - .regex(/^0x[0-9a-fA-F]+$/) - .transform((value) => EVMChainId(value as `0x${string}`)), - assetAddress: z - .string() - .regex(/^0x[0-9a-fA-F]{40}$/) - .transform((value) => EVMAccountAddress(value as `0x${string}`)), - }); + chainId: EVMChainIdSchema, + assetAddress: EVMAccountAddressSchema, +}); export type IFocusWalletParams = z.infer; diff --git a/src/wallet/useWalletBoot.ts b/src/wallet/useWalletBoot.ts index 020fc17..22b0953 100644 --- a/src/wallet/useWalletBoot.ts +++ b/src/wallet/useWalletBoot.ts @@ -399,7 +399,7 @@ export function useWalletBoot({ const requestedChainIds = [ ...new Map( prepared.map(({ request }) => [ - BigInt(request.chainId).toString(10), + request.chainId, request.chainId, ] as const), ).values(), @@ -436,9 +436,7 @@ export function useWalletBoot({ // Payment chain must be upgraded too (fee ExactCalldata). if ( !upgradeChainIds.some( - (id) => - BigInt(id).toString(10) === - BigInt(payment.paymentChainId).toString(10), + (id) => id === payment.paymentChainId, ) ) { const paymentNeedsUpgrade = @@ -453,9 +451,7 @@ export function useWalletBoot({ const upgradeChains = upgradeChainIds.map((chainId) => { const preparedItem = prepared.find( - (item) => - BigInt(item.request.chainId).toString(10) === - BigInt(chainId).toString(10), + (item) => item.request.chainId === chainId, ); return { chainId, From 6fb05cd3990bbd8ebc6a9a558bcffe0ebf032f83 Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Wed, 23 Sep 2026 22:02:23 -0700 Subject: [PATCH 8/8] seen is now Set (aligned with PaymentTokenUtils / WalletProvider). Onramp chain prop checks Number.isInteger and >= 0 before asEVMChainId, so floats no longer throw. --- src/components/OnrampView.tsx | 6 +++++- src/lib/implementations/business/utils/TransactionUtils.ts | 7 +++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/OnrampView.tsx b/src/components/OnrampView.tsx index 25a3381..4eb4efd 100644 --- a/src/components/OnrampView.tsx +++ b/src/components/OnrampView.tsx @@ -388,7 +388,11 @@ function resolveEffectiveEvmChainId( chainIdProp: number | undefined, sessionChainId: ReturnType["chainId"], ): EVMChainId | null { - if (chainIdProp != null && Number.isFinite(chainIdProp)) { + if ( + chainIdProp != null && + Number.isInteger(chainIdProp) && + chainIdProp >= 0 + ) { return ChainUtils.asEVMChainId(chainIdProp); } if (ChainUtils.isEVMChainId(sessionChainId)) { diff --git a/src/lib/implementations/business/utils/TransactionUtils.ts b/src/lib/implementations/business/utils/TransactionUtils.ts index d73a19c..fb5ec65 100644 --- a/src/lib/implementations/business/utils/TransactionUtils.ts +++ b/src/lib/implementations/business/utils/TransactionUtils.ts @@ -2192,11 +2192,10 @@ function orderedActivationChainIds( paymentChainId: EVMChainId, ): EVMChainId[] { const ordered: EVMChainId[] = [paymentChainId]; - const seen = new Set([paymentChainId]); + const seen = new Set([paymentChainId]); for (const chainId of upgradeChainIds) { - const key = chainId; - if (seen.has(key)) continue; - seen.add(key); + if (seen.has(chainId)) continue; + seen.add(chainId); ordered.push(chainId); } return ordered;