diff --git a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx index f488c93..4f30480 100644 --- a/host/src/components/WalletConfiguratorTextTabWalletSections.tsx +++ b/host/src/components/WalletConfiguratorTextTabWalletSections.tsx @@ -339,6 +339,13 @@ export function WalletConfiguratorTextTabWalletSections({ patch("cancelDelegationSkipOnchainAcknowledgement", value) } /> + + patch("cancelDelegationInsufficientBalanceError", value) + } + /> "skipOnchainAcknowledgement", form.cancelDelegationSkipOnchainAcknowledgement, ); + put( + cancelDelegation, + "insufficientBalanceError", + form.cancelDelegationInsufficientBalanceError, + ); if (Object.keys(cancelDelegation).length > 0) { copy.cancelDelegation = cancelDelegation; } diff --git a/src/circle/onrampTypes.ts b/src/circle/onrampTypes.ts index 2f67736..cd217f7 100644 --- a/src/circle/onrampTypes.ts +++ b/src/circle/onrampTypes.ts @@ -1,4 +1,4 @@ -import type { EVMAccountAddress } from "@1shotapi/ows-types"; +import type { EVMAccountAddress, EVMContractAddress } from "@1shotapi/ows-types"; /** Params for opening the Circle onramp fullscreen view. */ export type IOnrampOpenRequest = { @@ -6,6 +6,6 @@ export type IOnrampOpenRequest = { chainId?: number; amount?: string; tokenSymbol?: string; - tokenAddress?: EVMAccountAddress; + tokenAddress?: EVMContractAddress; iconUrl?: string; }; diff --git a/src/components/AssetIcon.tsx b/src/components/AssetIcon.tsx index 6e654b7..e8dc93d 100644 --- a/src/components/AssetIcon.tsx +++ b/src/components/AssetIcon.tsx @@ -1,62 +1,59 @@ -import type { - EVMAccountAddress, - EVMChainId, -} from "@1shotapi/ows-types"; -import { cn } from "@/lib/utils"; -import { resolveAssetIconUrl } from "../lib/utils/tokenIcons"; -import { SafeAssetImage } from "./SafeAssetImage"; - -export interface IAssetIconProps { - chainId: EVMChainId; - address: EVMAccountAddress; - symbol: string; - /** Optional host / tracked override (HTTPS). */ - iconUrl?: string; - size?: "sm" | "lg"; - className?: string; -} - -const SIZE_CLASSES = { - sm: "size-6 text-[0.65rem]", - lg: "size-14 text-lg", -} as const; - -export function AssetIcon({ - chainId, - address, - symbol, - iconUrl: iconUrlOverride, - size = "sm", - className, -}: IAssetIconProps) { - const iconUrl = resolveAssetIconUrl( - chainId, - address, - symbol, - iconUrlOverride, - ); - const sizeClass = SIZE_CLASSES[size]; - - return ( - - $ - - } - /> - ); -} +import type { EVMChainId, EVMContractAddress } from "@1shotapi/ows-types"; +import { cn } from "@/lib/utils"; +import { resolveAssetIconUrl } from "../lib/utils/tokenIcons"; +import { SafeAssetImage } from "./SafeAssetImage"; + +export interface IAssetIconProps { + chainId: EVMChainId; + address: EVMContractAddress; + symbol: string; + /** Optional host / tracked override (HTTPS). */ + iconUrl?: string; + size?: "sm" | "lg"; + className?: string; +} + +const SIZE_CLASSES = { + sm: "size-6 text-[0.65rem]", + lg: "size-14 text-lg", +} as const; + +export function AssetIcon({ + chainId, + address, + symbol, + iconUrl: iconUrlOverride, + size = "sm", + className, +}: IAssetIconProps) { + const iconUrl = resolveAssetIconUrl( + chainId, + address, + symbol, + iconUrlOverride, + ); + const sizeClass = SIZE_CLASSES[size]; + + return ( + + $ + + } + /> + ); +} diff --git a/src/components/AssetIdentityMark.tsx b/src/components/AssetIdentityMark.tsx index 62bafae..fab1a78 100644 --- a/src/components/AssetIdentityMark.tsx +++ b/src/components/AssetIdentityMark.tsx @@ -1,53 +1,50 @@ -import type { - EVMAccountAddress, - EVMChainId, -} from "@1shotapi/ows-types"; -import { cn } from "@/lib/utils"; -import { resolveAssetIconUrl } from "../lib/utils/tokenIcons"; -import { SafeAssetImage } from "./SafeAssetImage"; - -export interface IAssetIdentityMarkProps { - chainId: EVMChainId; - address: EVMAccountAddress; - symbol: string; - /** Optional host / tracked override (HTTPS). */ - iconUrl?: string; - chainLogoUrl?: string; - className?: string; -} - -/** Large token icon with optional chain logo badge at bottom-right. */ -export function AssetIdentityMark({ - chainId, - address, - symbol, - iconUrl: iconUrlOverride, - chainLogoUrl, - className, -}: IAssetIdentityMarkProps) { - const iconUrl = resolveAssetIconUrl( - chainId, - address, - symbol, - iconUrlOverride, - ); - const letter = (symbol.trim()[0] ?? "?").toUpperCase(); - - return ( -
- - {letter} -
- } - /> - - - ); -} +import type { EVMChainId, EVMContractAddress } from "@1shotapi/ows-types"; +import { cn } from "@/lib/utils"; +import { resolveAssetIconUrl } from "../lib/utils/tokenIcons"; +import { SafeAssetImage } from "./SafeAssetImage"; + +export interface IAssetIdentityMarkProps { + chainId: EVMChainId; + address: EVMContractAddress; + symbol: string; + /** Optional host / tracked override (HTTPS). */ + iconUrl?: string; + chainLogoUrl?: string; + className?: string; +} + +/** Large token icon with optional chain logo badge at bottom-right. */ +export function AssetIdentityMark({ + chainId, + address, + symbol, + iconUrl: iconUrlOverride, + chainLogoUrl, + className, +}: IAssetIdentityMarkProps) { + const iconUrl = resolveAssetIconUrl( + chainId, + address, + symbol, + iconUrlOverride, + ); + const letter = (symbol.trim()[0] ?? "?").toUpperCase(); + + return ( +
+ + {letter} +
+ } + /> + + + ); +} diff --git a/src/components/OnrampView.tsx b/src/components/OnrampView.tsx index 4eb4efd..efa635f 100644 --- a/src/components/OnrampView.tsx +++ b/src/components/OnrampView.tsx @@ -6,8 +6,8 @@ import type { } from "@circle-fin/app-kit"; import { ChainUtils, - EVMAccountAddress, - type EVMAccountAddress as EVMAccountAddressType, + EVMContractAddress, + type EVMAccountAddress, type EVMChainId, } from "@1shotapi/ows-types"; import { zeroAddress } from "viem"; @@ -27,7 +27,7 @@ export type IOnrampViewProps = IOnrampOpenRequest & { onClose: () => void; }; -const PLACEHOLDER_TOKEN_ADDRESS = EVMAccountAddress(zeroAddress); +const PLACEHOLDER_TOKEN_ADDRESS = EVMContractAddress(zeroAddress); /** * Full-screen Circle AppKit onramp inside the Branding Layer shell. @@ -61,7 +61,7 @@ export function OnrampView({ const [popupReady, setPopupReady] = useState(false); const [popupOpened, setPopupOpened] = useState(false); const [catalogTokenAddress, setCatalogTokenAddress] = useState< - EVMAccountAddressType | null + EVMContractAddress | null >(null); const [catalogIconUrl, setCatalogIconUrl] = useState(); diff --git a/src/components/PaymentFeePicker.tsx b/src/components/PaymentFeePicker.tsx index dbd5539..33f5813 100644 --- a/src/components/PaymentFeePicker.tsx +++ b/src/components/PaymentFeePicker.tsx @@ -1,240 +1,381 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; -import { formatUnits } from "viem"; -import type { - IPaymentQuote, - IPaymentTokenOption, - ITransactionWork, -} from "../lib/interfaces/business"; -import type { IFinalRelayerFee } from "../lib/types/domain/RelayerSendUi"; -import { useWallet } from "../wallet/WalletProvider"; -import { AssetIcon } from "./AssetIcon"; -import { QuoteCountdown } from "./QuoteCountdown"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "./ui/select"; - -export type IPaymentFeePickerMode = "estimate" | "final"; - -export interface IPaymentFeePickerProps { - chainId: EVMChainId; - ownerAddress: EVMAccountAddress; - /** ExactCalldata work used for unsigned `relayer_estimate7710Transaction`. */ - work: ITransactionWork | ITransactionWork[]; - quote: IPaymentQuote | null; - error: string | null; - loading: boolean; - /** When true, pause the quote countdown (e.g. submit in flight). */ - paused?: boolean; - onQuoteChange: (quote: IPaymentQuote | null, error: string | null) => void; - mode?: IPaymentFeePickerMode; - /** Relayer-settled fee after the first estimate (mode `final`). */ - finalFee?: IFinalRelayerFee | null; -} - -function findSelectedToken( - quote: IPaymentQuote, - paymentToken?: EVMAccountAddress, -): IPaymentTokenOption | undefined { - const target = paymentToken ?? quote.selectedToken; - return quote.tokens.find( - (token) => - String(token.address).toLowerCase() === String(target).toLowerCase(), - ); -} - -function PaymentTokenRow({ - chainId, - token, -}: { - chainId: EVMChainId; - token: IPaymentTokenOption; -}) { - return ( - - - {token.symbol} - - ({formatUnits(token.balance, token.decimals)}) - - - ); -} - -/** - * Loads payment-token options (USDC preferred) and shows a live fee quote - * from unsigned `relayer_estimate7710Transaction`. Use mode `final` after the - * signed estimate settles the amount at submit. - */ -export function PaymentFeePicker({ - chainId, - ownerAddress, - work, - quote, - error, - loading, - paused = false, - onQuoteChange, - mode = "estimate", - finalFee = null, -}: IPaymentFeePickerProps) { - const { transactionService } = useWallet(); - const [preferredToken, setPreferredToken] = useState< - EVMAccountAddress | undefined - >(undefined); - const [selectBusy, setSelectBusy] = useState(false); - const onQuoteChangeRef = useRef(onQuoteChange); - useEffect(() => { - onQuoteChangeRef.current = onQuoteChange; - }, [onQuoteChange]); - - const workKey = useMemo(() => { - const items = Array.isArray(work) ? work : [work]; - return items - .map( - (item) => - `${String(item.to)}:${String(item.data || "0x")}:${item.value ?? 0n}`, - ) - .join("|"); - }, [work]); - - const getNewQuote = useCallback(async (): Promise => { - try { - const next = await transactionService.quotePayment( - chainId, - ownerAddress, - work, - preferredToken, - ); - onQuoteChangeRef.current(next, null); - return next.feeFormatted; - } catch (err: unknown) { - onQuoteChangeRef.current( - null, - err instanceof Error ? err.message : "Failed to quote fee", - ); - throw err; - } - }, [chainId, ownerAddress, preferredToken, transactionService, work]); - - async function onSelectToken(token: EVMAccountAddress): Promise { - setSelectBusy(true); - try { - setPreferredToken(token); - const next = await transactionService.quotePayment( - chainId, - ownerAddress, - work, - token, - ); - onQuoteChange(next, null); - } catch (err: unknown) { - onQuoteChange( - null, - err instanceof Error ? err.message : "Failed to quote fee", - ); - } finally { - setSelectBusy(false); - } - } - - const isLoading = loading || selectBusy; - const isFinal = mode === "final" && finalFee !== null; - const iconChainId = quote?.paymentChainId ?? chainId; - const selectedToken = isFinal - ? quote - ? findSelectedToken(quote, finalFee.paymentToken) - : undefined - : quote - ? findSelectedToken(quote) - : undefined; - const feeLabel = isFinal ? "Final fee:" : "Est. fee:"; - const feeDisplay = isFinal - ? finalFee.feeFormatted - : null; - - return ( -
-

- Network fee (1Shot Relayer) -

- {error ? ( -

{error}

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

- Paid on {quote.paymentChainName} -

- ) : null} -

- {feeLabel} - - {isFinal ? ( - {feeDisplay} - ) : ( - - )} - {selectedToken ? ( - <> - - {selectedToken.symbol} - - ) : null} - -

- {quote && !isFinal ? ( -
- Pay with - -
- ) : isFinal && selectedToken ? ( -
- Pay with {selectedToken.symbol} -
- ) : null} -
- ); -} +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + EVMContractAddress, + type EVMAccountAddress, + type EVMChainId, +} from "@1shotapi/ows-types"; +import { formatUnits } from "viem"; +import type { + IPaymentQuote, + IPaymentTokenOption, + ITransactionWork, +} from "../lib/interfaces/business"; +import type { IFinalRelayerFee } from "../lib/types/domain/RelayerSendUi"; +import { useWallet } from "../wallet/WalletProvider"; +import { AssetIcon } from "./AssetIcon"; +import { QuoteCountdown } from "./QuoteCountdown"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; + +export type IPaymentFeePickerMode = "estimate" | "final"; + +export interface IPaymentFeePickerProps { + chainId: EVMChainId; + ownerAddress: EVMAccountAddress; + /** ExactCalldata work used for unsigned `relayer_estimate7710Transaction`. */ + work?: ITransactionWork | ITransactionWork[]; + /** + * Multichain ExactCalldata work — when set, quotes via + * `quotePaymentMultichain` (one fee across all chains). Overrides `work`. + */ + workByChain?: readonly { + chainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + }[]; + quote: IPaymentQuote | null; + error: string | null; + loading: boolean; + /** When true, pause the quote countdown (e.g. submit in flight). */ + paused?: boolean; + onQuoteChange: (quote: IPaymentQuote | null, error: string | null) => void; + mode?: IPaymentFeePickerMode; + /** Relayer-settled fee after the first estimate (mode `final`). */ + finalFee?: IFinalRelayerFee | null; +} + +function paymentTokenKey(token: { + chainId: EVMChainId; + address: EVMContractAddress; +}): string { + return `${token.chainId}:${token.address}`; +} + +function parsePaymentTokenKey(value: string): { + chainId: EVMChainId; + address: EVMContractAddress; +} | null { + const sep = value.indexOf(":"); + if (sep <= 0) return null; + return { + chainId: value.slice(0, sep) as EVMChainId, + address: EVMContractAddress(value.slice(sep + 1) as `0x${string}`), + }; +} + +function findTokenInList( + tokens: readonly IPaymentTokenOption[], + address: EVMContractAddress, + chainId?: EVMChainId, +): IPaymentTokenOption | undefined { + return tokens.find( + (token) => + (chainId === undefined || token.chainId === chainId) && + token.address === address, + ); +} + +function PaymentTokenRow({ token }: { token: IPaymentTokenOption }) { + return ( + + + {token.symbol} + + on {token.chainName} + + + ({formatUnits(token.balance, token.decimals)}) + + + ); +} + +/** + * Loads payment-token options (USDC preferred) and shows a live fee quote + * from unsigned `relayer_estimate7710Transaction`. Use mode `final` after the + * signed estimate settles the amount at submit. + * + * Token options load independently of a successful quote so the Select stays + * usable when estimate fails (e.g. Arc dust). + */ +export function PaymentFeePicker({ + chainId, + ownerAddress, + work, + workByChain, + quote, + error, + loading, + paused = false, + onQuoteChange, + mode = "estimate", + finalFee = null, +}: IPaymentFeePickerProps) { + const { transactionService, paymentTokenUtils } = useWallet(); + const [preferredToken, setPreferredToken] = useState< + EVMContractAddress | undefined + >(undefined); + const [preferredChainId, setPreferredChainId] = useState< + EVMChainId | undefined + >(undefined); + const [tokenOptions, setTokenOptions] = useState([]); + const [selectBusy, setSelectBusy] = useState(false); + const onQuoteChangeRef = useRef(onQuoteChange); + useEffect(() => { + onQuoteChangeRef.current = onQuoteChange; + }, [onQuoteChange]); + + const executionChainIds = useMemo(() => { + if (workByChain && workByChain.length > 0) { + return [...new Set(workByChain.map((g) => g.chainId))]; + } + return [chainId]; + }, [chainId, workByChain]); + + const workKey = useMemo(() => { + if (workByChain && workByChain.length > 0) { + return workByChain + .map((group) => { + const items = Array.isArray(group.work) ? group.work : [group.work]; + const body = items + .map( + (item) => + `${String(item.to)}:${String(item.data || "0x")}:${item.value ?? 0n}`, + ) + .join("|"); + return `${String(group.chainId)}:${body}`; + }) + .join(";"); + } + const items = Array.isArray(work) ? work : work ? [work] : []; + return items + .map( + (item) => + `${String(item.to)}:${String(item.data || "0x")}:${item.value ?? 0n}`, + ) + .join("|"); + }, [work, workByChain]); + + // Load selectable tokens even when estimate fails (quote stays null). + useEffect(() => { + let cancelled = false; + void paymentTokenUtils + .listPaymentOptions(ownerAddress, executionChainIds) + .then((options) => { + if (!cancelled) setTokenOptions(options); + }) + .catch(() => { + if (!cancelled) setTokenOptions([]); + }); + return () => { + cancelled = true; + }; + }, [executionChainIds, ownerAddress, paymentTokenUtils, workKey]); + + // Prefer tokens from a successful quote (fresher balances), else catalog list. + const displayTokens = useMemo(() => { + if (quote && quote.tokens.length > 0) return quote.tokens; + return tokenOptions; + }, [quote, tokenOptions]); + + const fetchQuote = useCallback( + async (token?: EVMContractAddress) => { + if (workByChain && workByChain.length > 0) { + return transactionService.quotePaymentMultichain( + ownerAddress, + workByChain, + token, + ); + } + if (!work) { + throw new Error("PaymentFeePicker requires work or workByChain"); + } + return transactionService.quotePayment( + chainId, + ownerAddress, + work, + token, + ); + }, + [chainId, ownerAddress, transactionService, work, workByChain], + ); + + const getNewQuote = useCallback(async (): Promise => { + try { + const next = await fetchQuote(preferredToken); + onQuoteChangeRef.current(next, null); + setTokenOptions(next.tokens); + return next.feeFormatted; + } catch (err: unknown) { + // Keep prior quote.tokens / tokenOptions so Pay with stays usable. + onQuoteChangeRef.current( + null, + err instanceof Error ? err.message : "Failed to quote fee", + ); + throw err; + } + }, [fetchQuote, preferredToken]); + + async function onSelectToken( + token: EVMContractAddress, + tokenChainId: EVMChainId, + ): Promise { + setSelectBusy(true); + try { + setPreferredToken(token); + setPreferredChainId(tokenChainId); + const next = await fetchQuote(token); + onQuoteChange(next, null); + setTokenOptions(next.tokens); + } catch (err: unknown) { + onQuoteChange( + null, + err instanceof Error ? err.message : "Failed to quote fee", + ); + } finally { + setSelectBusy(false); + } + } + + const isLoading = loading || selectBusy; + const isFinal = mode === "final" && finalFee !== null; + + const selectedToken = useMemo(() => { + if (isFinal && quote && finalFee) { + return findTokenInList( + displayTokens, + finalFee.paymentToken, + quote.paymentChainId, + ); + } + if (quote) { + return findTokenInList( + displayTokens, + quote.selectedToken, + quote.paymentChainId, + ); + } + if (preferredToken) { + return findTokenInList(displayTokens, preferredToken, preferredChainId); + } + return undefined; + }, [ + displayTokens, + finalFee, + isFinal, + preferredChainId, + preferredToken, + quote, + ]); + + const feeLabel = isFinal ? "Final fee:" : "Est. fee:"; + const feeDisplay = isFinal ? finalFee.feeFormatted : null; + const selectValue = selectedToken + ? paymentTokenKey(selectedToken) + : quote + ? paymentTokenKey({ + chainId: quote.paymentChainId, + address: quote.selectedToken, + }) + : preferredToken && preferredChainId + ? paymentTokenKey({ + chainId: preferredChainId, + address: preferredToken, + }) + : ""; + + const showTokenSelect = !isFinal && displayTokens.length > 0; + const paidOnLabel = + selectedToken && selectedToken.chainId !== chainId + ? selectedToken.chainName + : quote && quote.paymentChainId !== chainId + ? quote.paymentChainName + : null; + + return ( +
+

+ Network fee (1Shot Relayer) +

+ {error ? ( +

{error}

+ ) : null} + {paidOnLabel ? ( +

+ Paid on {paidOnLabel} +

+ ) : null} +

+ {feeLabel} + + {isFinal ? ( + {feeDisplay} + ) : ( + + )} + {selectedToken ? ( + <> + + {selectedToken.symbol} + + ) : null} + +

+ {showTokenSelect ? ( +
+ Pay with + +
+ ) : isFinal && selectedToken ? ( +
+ + Pay with {selectedToken.symbol} on {selectedToken.chainName} + +
+ ) : null} +
+ ); +} diff --git a/src/components/balances/AddAssetView.tsx b/src/components/balances/AddAssetView.tsx index d038537..0f5e270 100644 --- a/src/components/balances/AddAssetView.tsx +++ b/src/components/balances/AddAssetView.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; +import { getAddress } from "viem"; import { ChainUtils, - EVMAccountAddress, + EVMContractAddress, + type EVMContractAddress as EVMContractAddressType, } from "@1shotapi/ows-types"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -10,8 +12,6 @@ import { useStyle } from "../../style/StyleProvider"; import { useWallet } from "../../wallet/WalletProvider"; import { useWalletSessionStore } from "../../wallet/sessionStore"; -const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; - export interface IAddAssetViewProps { onClose: () => void; } @@ -32,18 +32,17 @@ export function AddAssetView({ onClose }: IAddAssetViewProps) { setError(copy.addFailedError); return; } - const trimmed = addressInput.trim(); - if (!ADDRESS_RE.test(trimmed)) { + let address: EVMContractAddressType; + try { + address = EVMContractAddress(getAddress(addressInput.trim())); + } catch { setError(copy.invalidAddressError); return; } setAdding(true); setError(null); try { - await addTrackedAsset( - chainId, - EVMAccountAddress(trimmed as `0x${string}`), - ); + await addTrackedAsset(chainId, address); onClose(); } catch (err: unknown) { setError(err instanceof Error ? err.message : copy.addFailedError); diff --git a/src/components/modals/CCTPBridge.tsx b/src/components/modals/CCTPBridge.tsx index fc4d554..c775667 100644 --- a/src/components/modals/CCTPBridge.tsx +++ b/src/components/modals/CCTPBridge.tsx @@ -426,8 +426,8 @@ export function CCTPBridge({ if (!irisQuote || !paymentQuote || !sourceUsdc) return null; const burn = usdcAmountFromAtoms(irisQuote.totalBurn); const same = - String(paymentQuote.selectedToken).toLowerCase() === - String(sourceUsdc.address).toLowerCase(); + paymentQuote.selectedToken === + sourceUsdc.address; if (!same) { return burn; } @@ -886,9 +886,7 @@ function ConfirmSummary({
{payment.feeFormatted}{" "} {payment.tokens.find( - (token) => - String(token.address).toLowerCase() === - String(payment.selectedToken).toLowerCase(), + (token) => token.address === payment.selectedToken, )?.symbol ?? "USDC"}
diff --git a/src/components/modals/CancelDelegationModal.tsx b/src/components/modals/CancelDelegationModal.tsx index 3290475..9e0c1da 100644 --- a/src/components/modals/CancelDelegationModal.tsx +++ b/src/components/modals/CancelDelegationModal.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ICancelDelegationConfirmRequest, - ICancelDelegationPayment, + IRelayerConfirmSendResult, } from "../../wallet/modalTypes"; import type { IPaymentQuote } from "../../lib/interfaces/business"; import type { IRelayerSendUiCallbacks } from "../../lib/types/domain/RelayerSendUi"; @@ -44,8 +44,8 @@ function groupItemsByChain( } /** - * On-chain cancel / revoke confirm — lists selected delegations, quotes a - * relayer fee per chain, then runs execute. Optional “Skip onchain + * On-chain cancel / revoke confirm — lists selected delegations, quotes one + * combined Multichain fee, then runs execute. Optional “Skip onchain * cancellation” removes vault rows only. */ export function CancelDelegationModal({ @@ -58,7 +58,7 @@ export function CancelDelegationModal({ }: { request: ICancelDelegationConfirmRequest; execute: ( - payments: ICancelDelegationPayment[], + payment: IRelayerConfirmSendResult, ui: IRelayerSendUiCallbacks, ) => Promise; executeLocal: () => Promise; @@ -74,12 +74,8 @@ export function CancelDelegationModal({ const [localError, setLocalError] = useState(null); const [phase, setPhase] = useState("confirm"); const [error, setError] = useState(null); - const [quotes, setQuotes] = useState>( - {}, - ); - const [quoteErrors, setQuoteErrors] = useState>( - {}, - ); + const [quote, setQuote] = useState(null); + const [quoteError, setQuoteError] = useState(null); const abortedRef = useRef(false); const finalFeeGateRef = useRef<{ resolve: () => void; @@ -94,6 +90,17 @@ export function CancelDelegationModal({ [request.items], ); + const workByChain = useMemo( + () => + chainGroups.map((group) => ({ + chainId: group.chainId, + work: group.work, + })), + [chainGroups], + ); + + const primaryChainId = chainGroups[0]?.chainId; + const chainNames = useMemo( () => [...new Set(chainGroups.map((g) => g.chainName))].join(", ") || @@ -111,12 +118,26 @@ export function CancelDelegationModal({ }; }, []); - const allQuotesReady = - chainGroups.length > 0 && - chainGroups.every((group) => { - const key = group.chainId; - return quotes[key] != null && !quoteErrors[key]; - }); + const quoteReady = quote != null && !quoteError; + + const selectedBalance = + quote?.tokens.find( + (t) => + t.chainId === quote.paymentChainId && + t.address === quote.selectedToken, + )?.balance ?? null; + + const insufficientBalance = + quote !== null && + selectedBalance !== null && + quote.feeAtoms > selectedBalance; + + const balanceError = insufficientBalance + ? copy.insufficientBalanceError.replace( + "{chainName}", + quote.paymentChainName, + ) + : null; const showConfirmActions = skipOnchain || phase === "confirm" || phase === "finalFee"; @@ -125,50 +146,28 @@ export function CancelDelegationModal({ ? !localBusy : phase === "finalFee" ? true - : phase === "confirm" && allQuotesReady; + : phase === "confirm" && quoteReady && !insufficientBalance; const body = copy.body .replace("{domain}", request.domain) .replace("{chainName}", chainNames); - const setChainQuote = useCallback( - (chainId: EVMChainId, quote: IPaymentQuote | null, err: string | null) => { - const key = chainId; - setQuotes((prev) => ({ ...prev, [key]: quote })); - setQuoteErrors((prev) => ({ ...prev, [key]: err })); - }, - [], - ); - - const buildPayments = useCallback((): ICancelDelegationPayment[] => { - return chainGroups.map((group) => { - const quote = quotes[group.chainId]; - if (!quote) { - throw new Error(`Missing fee quote for chain ${group.chainName}`); - } - return { - chainId: group.chainId, - paymentToken: quote.selectedToken, - feeAtoms: quote.feeAtoms, - paymentChainId: quote.paymentChainId, - }; - }); - }, [chainGroups, quotes]); - const runExecute = useCallback(() => { abortedRef.current = false; setError(null); - setPhase("signing"); - let payments: ICancelDelegationPayment[]; - try { - payments = buildPayments(); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : String(err)); + if (!quote) { + setError("Missing fee quote"); setPhase("confirm"); return; } + setPhase("signing"); + const payment: IRelayerConfirmSendResult = { + paymentToken: quote.selectedToken, + feeAtoms: quote.feeAtoms, + paymentChainId: quote.paymentChainId, + }; - void execute(payments, { + void execute(payment, { retainDisplayDuringSubmit: true, onAwaitingConfirmation: () => setPhase("submitting"), onFinalFeeRequired: (fee) => @@ -193,7 +192,7 @@ export function CancelDelegationModal({ setError(err instanceof Error ? err.message : String(err)); setPhase(showedFinalFeeRef.current ? "finalFee" : "confirm"); }); - }, [buildPayments, execute, onResolve]); + }, [execute, onResolve, quote]); const onConfirm = () => { if (skipOnchain) { @@ -302,7 +301,7 @@ export function CancelDelegationModal({ ))} - {!skipOnchain ? ( + {!skipOnchain && primaryChainId !== undefined ? (
{phase === "finalFee" ? (

@@ -310,31 +309,25 @@ export function CancelDelegationModal({ {finalFeeLabel ? ` (${finalFeeLabel})` : null}

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

- {group.chainName} -

- ) : null} - { - setChainQuote(group.chainId, next, err); - }} - /> -
- ); - })} + { + setQuote(next); + setQuoteError(err); + }} + /> + {balanceError ? ( +

+ {balanceError} +

+ ) : null} {statusMessage ? (

{statusMessage} diff --git a/src/components/modals/permissionGrantTerms/erc20PeriodicTerms.tsx b/src/components/modals/permissionGrantTerms/erc20PeriodicTerms.tsx index 24cdacf..c0e72c0 100644 --- a/src/components/modals/permissionGrantTerms/erc20PeriodicTerms.tsx +++ b/src/components/modals/permissionGrantTerms/erc20PeriodicTerms.tsx @@ -1,188 +1,190 @@ -import { useEffect, useMemo, useState } from "react"; -import { EVMAccountAddress, type IExecutionPermissionRequest } from "@1shotapi/ows-types"; -import { formatUnits, getAddress } from "viem"; -import { ERC20_TOKEN_PERIODIC } from "../../../lib/interfaces/business/IDelegationService"; -import { EAssetType } from "../../../lib/types/enum/EAssetType"; -import { - formatUnixSecondsLabel, - humanizePeriodDuration, - parsePeriodDurationSeconds, - readHostMemoOrJustification, - readPermissionAmountAtoms, - readPermissionPeriodDurationText, - readPermissionStartText, - resolvePermissionEndUnixSeconds, -} from "../../../lib/utils/delegationDisplay"; -import { resolveAssetIconUrl } from "../../../lib/utils/tokenIcons"; -import { useStyle } from "../../../style/StyleProvider"; -import type { IGrantExecutionPermissionResult } from "../../../wallet/modalTypes"; -import { useWallet } from "../../../wallet/WalletProvider"; -import { ConsentSummaryRow } from "../../ConsentSummaryRow"; -import { SafeAssetImage } from "../../SafeAssetImage"; -import { PermissionGrantTermsCard } from "../PermissionGrantConsentLayout"; - -function readTokenAddress(data: Record): string | null { - const raw = data.tokenAddress ?? data.token; - return typeof raw === "string" ? raw : null; -} - -export function isErc20PeriodicPermissionValid( - executionRequest: IExecutionPermissionRequest, -): boolean { - const permissionData = executionRequest.permission.data as Record; - const tokenAddress = readTokenAddress(permissionData); - const amountAtoms = readPermissionAmountAtoms(permissionData); - const durationSeconds = parsePeriodDurationSeconds( - readPermissionPeriodDurationText(permissionData), - ); - return ( - Boolean(tokenAddress) && - amountAtoms !== null && - amountAtoms > 0n && - durationSeconds !== null - ); -} - -export function buildErc20PeriodicGrantResult( - executionRequest: IExecutionPermissionRequest, -): IGrantExecutionPermissionResult { - const permissionData = executionRequest.permission.data as Record; - return { - permission: { - type: ERC20_TOKEN_PERIODIC, - isAdjustmentAllowed: executionRequest.permission.isAdjustmentAllowed, - data: permissionData, - }, - memo: readHostMemoOrJustification(permissionData), - }; -} - -export function Erc20PeriodicPermissionTerms({ - executionRequest, -}: { - executionRequest: IExecutionPermissionRequest; -}) { - const copy = useStyle().style.copy.grantExecutionPermission; - const { listTrackedAssets, getKnownAsset } = useWallet(); - const permissionData = executionRequest.permission.data as Record; - const tokenAddress = readTokenAddress(permissionData); - - const [tokenSymbol, setTokenSymbol] = useState("TOKEN"); - const [tokenDecimals, setTokenDecimals] = useState(6); - const [tokenIconUrl, setTokenIconUrl] = useState(); - - useEffect(() => { - if (!tokenAddress) return; - let cancelled = false; - const chainId = executionRequest.chainId; - const checksummed = getAddress(tokenAddress as `0x${string}`); - - void (async () => { - const assets = await listTrackedAssets(); - if (cancelled) return; - const tracked = assets.find( - (a) => - a.type === EAssetType.Erc20 && - String(a.chainId).toLowerCase() === String(chainId).toLowerCase() && - getAddress(String(a.address)).toLowerCase() === checksummed.toLowerCase(), - ); - if (tracked) { - setTokenSymbol(tracked.symbol); - setTokenDecimals(tracked.decimals ?? 6); - setTokenIconUrl( - resolveAssetIconUrl( - chainId, - EVMAccountAddress(checksummed), - tracked.symbol, - tracked.iconUrl, - ), - ); - return; - } - try { - const known = await getKnownAsset( - chainId, - EVMAccountAddress(checksummed), - ); - if (cancelled) return; - if (known) { - setTokenSymbol(known.symbol); - setTokenDecimals(known.decimals ?? 6); - setTokenIconUrl( - resolveAssetIconUrl( - chainId, - EVMAccountAddress(checksummed), - known.symbol, - known.iconUrl, - ), - ); - } else { - setTokenIconUrl(undefined); - } - } catch { - setTokenIconUrl(undefined); - } - })(); - - return () => { - cancelled = true; - }; - }, [executionRequest.chainId, getKnownAsset, listTrackedAssets, tokenAddress]); - - const amountAtoms = readPermissionAmountAtoms(permissionData); - const durationSeconds = parsePeriodDurationSeconds( - readPermissionPeriodDurationText(permissionData), - ); - const startDisplay = formatUnixSecondsLabel( - readPermissionStartText(permissionData) || undefined, - ); - const endDisplay = formatUnixSecondsLabel( - resolvePermissionEndUnixSeconds({ - rules: executionRequest.rules, - permissionData, - }) ?? undefined, - ); - - const summaryAmount = useMemo(() => { - if (amountAtoms === null || amountAtoms <= 0n) return null; - try { - return `${formatUnits(amountAtoms, tokenDecimals)} ${tokenSymbol}`; - } catch { - return null; - } - }, [amountAtoms, tokenDecimals, tokenSymbol]); - - const summaryWindow = - durationSeconds === null - ? "—" - : humanizePeriodDuration(durationSeconds); - - return ( - - - {tokenIconUrl ? ( - - ) : null} - - {summaryAmount ?? "—"} - - - - {summaryWindow} - - {startDisplay ? ( - - {startDisplay} - - ) : null} - {endDisplay ? ( - - {endDisplay} - - ) : null} - - ); -} +import { useEffect, useMemo, useState } from "react"; +import { EVMContractAddress, type IExecutionPermissionRequest } from "@1shotapi/ows-types"; +import { formatUnits, getAddress } from "viem"; +import { ERC20_TOKEN_PERIODIC } from "../../../lib/interfaces/business/IDelegationService"; +import { EAssetType } from "../../../lib/types/enum/EAssetType"; +import { + formatUnixSecondsLabel, + humanizePeriodDuration, + parsePeriodDurationSeconds, + readHostMemoOrJustification, + readPermissionAmountAtoms, + readPermissionPeriodDurationText, + readPermissionStartText, + resolvePermissionEndUnixSeconds, +} from "../../../lib/utils/delegationDisplay"; +import { resolveAssetIconUrl } from "../../../lib/utils/tokenIcons"; +import { useStyle } from "../../../style/StyleProvider"; +import type { IGrantExecutionPermissionResult } from "../../../wallet/modalTypes"; +import { useWallet } from "../../../wallet/WalletProvider"; +import { ConsentSummaryRow } from "../../ConsentSummaryRow"; +import { SafeAssetImage } from "../../SafeAssetImage"; +import { PermissionGrantTermsCard } from "../PermissionGrantConsentLayout"; + +function readTokenAddress(data: Record): string | null { + const raw = data.tokenAddress ?? data.token; + return typeof raw === "string" ? raw : null; +} + +export function isErc20PeriodicPermissionValid( + executionRequest: IExecutionPermissionRequest, +): boolean { + const permissionData = executionRequest.permission.data as Record; + const tokenAddress = readTokenAddress(permissionData); + const amountAtoms = readPermissionAmountAtoms(permissionData); + const durationSeconds = parsePeriodDurationSeconds( + readPermissionPeriodDurationText(permissionData), + ); + return ( + Boolean(tokenAddress) && + amountAtoms !== null && + amountAtoms > 0n && + durationSeconds !== null + ); +} + +export function buildErc20PeriodicGrantResult( + executionRequest: IExecutionPermissionRequest, +): IGrantExecutionPermissionResult { + const permissionData = executionRequest.permission.data as Record; + return { + permission: { + type: ERC20_TOKEN_PERIODIC, + isAdjustmentAllowed: executionRequest.permission.isAdjustmentAllowed, + data: permissionData, + }, + memo: readHostMemoOrJustification(permissionData), + }; +} + +export function Erc20PeriodicPermissionTerms({ + executionRequest, +}: { + executionRequest: IExecutionPermissionRequest; +}) { + const copy = useStyle().style.copy.grantExecutionPermission; + const { listTrackedAssets, getKnownAsset } = useWallet(); + const permissionData = executionRequest.permission.data as Record; + const tokenAddress = readTokenAddress(permissionData); + + const [tokenSymbol, setTokenSymbol] = useState("TOKEN"); + const [tokenDecimals, setTokenDecimals] = useState(6); + const [tokenIconUrl, setTokenIconUrl] = useState(); + + useEffect(() => { + if (!tokenAddress) return; + let cancelled = false; + const chainId = executionRequest.chainId; + let token: EVMContractAddress; + try { + token = EVMContractAddress(getAddress(tokenAddress)); + } catch { + return; + } + + void (async () => { + const assets = await listTrackedAssets(); + if (cancelled) return; + const tracked = assets.find( + (a) => + a.type === EAssetType.Erc20 && + a.chainId === chainId && + a.address === token, + ); + if (tracked) { + setTokenSymbol(tracked.symbol); + setTokenDecimals(tracked.decimals ?? 6); + setTokenIconUrl( + resolveAssetIconUrl( + chainId, + token, + tracked.symbol, + tracked.iconUrl, + ), + ); + return; + } + try { + const known = await getKnownAsset(chainId, token); + if (cancelled) return; + if (known) { + setTokenSymbol(known.symbol); + setTokenDecimals(known.decimals ?? 6); + setTokenIconUrl( + resolveAssetIconUrl( + chainId, + token, + known.symbol, + known.iconUrl, + ), + ); + } else { + setTokenIconUrl(undefined); + } + } catch { + setTokenIconUrl(undefined); + } + })(); + + return () => { + cancelled = true; + }; + }, [executionRequest.chainId, getKnownAsset, listTrackedAssets, tokenAddress]); + + const amountAtoms = readPermissionAmountAtoms(permissionData); + const durationSeconds = parsePeriodDurationSeconds( + readPermissionPeriodDurationText(permissionData), + ); + const startDisplay = formatUnixSecondsLabel( + readPermissionStartText(permissionData) || undefined, + ); + const endDisplay = formatUnixSecondsLabel( + resolvePermissionEndUnixSeconds({ + rules: executionRequest.rules, + permissionData, + }) ?? undefined, + ); + + const summaryAmount = useMemo(() => { + if (amountAtoms === null || amountAtoms <= 0n) return null; + try { + return `${formatUnits(amountAtoms, tokenDecimals)} ${tokenSymbol}`; + } catch { + return null; + } + }, [amountAtoms, tokenDecimals, tokenSymbol]); + + const summaryWindow = + durationSeconds === null + ? "—" + : humanizePeriodDuration(durationSeconds); + + return ( + + + {tokenIconUrl ? ( + + ) : null} + + {summaryAmount ?? "—"} + + + + {summaryWindow} + + {startDisplay ? ( + + {startDisplay} + + ) : null} + {endDisplay ? ( + + {endDisplay} + + ) : null} + + ); +} diff --git a/src/components/modals/permissionGrantTerms/lifiApproveTerms.tsx b/src/components/modals/permissionGrantTerms/lifiApproveTerms.tsx index c8fd1f6..4e627d8 100644 --- a/src/components/modals/permissionGrantTerms/lifiApproveTerms.tsx +++ b/src/components/modals/permissionGrantTerms/lifiApproveTerms.tsx @@ -1,164 +1,152 @@ -import { useEffect, useMemo, useState } from "react"; -import { EVMAccountAddress, type IExecutionPermissionRequest } from "@1shotapi/ows-types"; -import { getAddress } from "viem"; -import { - parseLiFiApproveData, -} from "../../../lib/implementations/business/DelegationService"; -import { LIFI_SWAP_APPROVE } from "../../../lib/interfaces/business/IDelegationService"; -import { EAssetType } from "../../../lib/types/enum/EAssetType"; -import { readHostMemoOrJustification } from "../../../lib/utils/delegationDisplay"; -import { resolveAssetIconUrl } from "../../../lib/utils/tokenIcons"; -import { useStyle } from "../../../style/StyleProvider"; -import type { IGrantExecutionPermissionResult } from "../../../wallet/modalTypes"; -import { useWallet } from "../../../wallet/WalletProvider"; -import { ConsentSummaryRow } from "../../ConsentSummaryRow"; -import { SafeAssetImage } from "../../SafeAssetImage"; -import { PermissionGrantTermsCard } from "../PermissionGrantConsentLayout"; -import { ExplorerAddressLink } from "./ExplorerAddressLink"; - -export function isLiFiApprovePermissionValid( - executionRequest: IExecutionPermissionRequest, -): boolean { - try { - parseLiFiApproveData(executionRequest.permission.data); - return true; - } catch { - return false; - } -} - -export function buildLiFiApproveGrantResult( - executionRequest: IExecutionPermissionRequest, -): IGrantExecutionPermissionResult { - const permissionData = executionRequest.permission.data as Record; - return { - permission: { - type: LIFI_SWAP_APPROVE, - isAdjustmentAllowed: executionRequest.permission.isAdjustmentAllowed, - data: permissionData, - }, - memo: readHostMemoOrJustification(permissionData), - }; -} - -export function LiFiApprovePermissionTerms({ - executionRequest, -}: { - executionRequest: IExecutionPermissionRequest; -}) { - const copy = useStyle().style.copy.grantLiFiApprovePermission; - const { listTrackedAssets, resolveChain, getKnownAsset } = useWallet(); - const permissionData = executionRequest.permission.data as Record; - - const parsedApprove = useMemo(() => { - try { - return parseLiFiApproveData(permissionData); - } catch { - return null; - } - }, [permissionData]); - - const tokenAddress = parsedApprove - ? String(parsedApprove.tokenAddress) - : ""; - const spender = parsedApprove ? String(parsedApprove.spender) : ""; - - const [tokenSymbol, setTokenSymbol] = useState("TOKEN"); - const [tokenIconUrl, setTokenIconUrl] = useState(); - - useEffect(() => { - if (!tokenAddress) return; - let cancelled = false; - const chainId = executionRequest.chainId; - let checksummed: `0x${string}`; - try { - checksummed = getAddress(tokenAddress as `0x${string}`); - } catch { - return; - } - - void (async () => { - const assets = await listTrackedAssets(); - if (cancelled) return; - const tracked = assets.find( - (a) => - a.type === EAssetType.Erc20 && - String(a.chainId).toLowerCase() === String(chainId).toLowerCase() && - getAddress(String(a.address)).toLowerCase() === checksummed.toLowerCase(), - ); - if (tracked) { - setTokenSymbol(tracked.symbol); - setTokenIconUrl( - resolveAssetIconUrl( - chainId, - EVMAccountAddress(checksummed), - tracked.symbol, - tracked.iconUrl, - ), - ); - return; - } - try { - const known = await getKnownAsset( - chainId, - EVMAccountAddress(checksummed), - ); - if (cancelled) return; - if (known) { - setTokenSymbol(known.symbol); - setTokenIconUrl( - resolveAssetIconUrl( - chainId, - EVMAccountAddress(checksummed), - known.symbol, - known.iconUrl, - ), - ); - } else { - setTokenIconUrl(undefined); - } - } catch { - setTokenIconUrl(undefined); - } - })(); - - return () => { - cancelled = true; - }; - }, [executionRequest.chainId, getKnownAsset, listTrackedAssets, tokenAddress]); - - const chain = resolveChain(executionRequest.chainId); - const spenderExplorerUrl = spender - ? chain?.addressExplorerUrl(spender) - : undefined; - - return ( - - - {tokenIconUrl ? ( - - ) : null} - - {tokenAddress ? tokenSymbol : "—"} - - - - {spender ? ( - - ) : ( - — - )} - - - ); -} +import { useEffect, useMemo, useState } from "react"; +import { type IExecutionPermissionRequest } from "@1shotapi/ows-types"; +import { + parseLiFiApproveData, +} from "../../../lib/implementations/business/DelegationService"; +import { LIFI_SWAP_APPROVE } from "../../../lib/interfaces/business/IDelegationService"; +import { EAssetType } from "../../../lib/types/enum/EAssetType"; +import { readHostMemoOrJustification } from "../../../lib/utils/delegationDisplay"; +import { resolveAssetIconUrl } from "../../../lib/utils/tokenIcons"; +import { useStyle } from "../../../style/StyleProvider"; +import type { IGrantExecutionPermissionResult } from "../../../wallet/modalTypes"; +import { useWallet } from "../../../wallet/WalletProvider"; +import { ConsentSummaryRow } from "../../ConsentSummaryRow"; +import { SafeAssetImage } from "../../SafeAssetImage"; +import { PermissionGrantTermsCard } from "../PermissionGrantConsentLayout"; +import { ExplorerAddressLink } from "./ExplorerAddressLink"; + +export function isLiFiApprovePermissionValid( + executionRequest: IExecutionPermissionRequest, +): boolean { + try { + parseLiFiApproveData(executionRequest.permission.data); + return true; + } catch { + return false; + } +} + +export function buildLiFiApproveGrantResult( + executionRequest: IExecutionPermissionRequest, +): IGrantExecutionPermissionResult { + const permissionData = executionRequest.permission.data as Record; + return { + permission: { + type: LIFI_SWAP_APPROVE, + isAdjustmentAllowed: executionRequest.permission.isAdjustmentAllowed, + data: permissionData, + }, + memo: readHostMemoOrJustification(permissionData), + }; +} + +export function LiFiApprovePermissionTerms({ + executionRequest, +}: { + executionRequest: IExecutionPermissionRequest; +}) { + const copy = useStyle().style.copy.grantLiFiApprovePermission; + const { listTrackedAssets, resolveChain, getKnownAsset } = useWallet(); + const permissionData = executionRequest.permission.data as Record; + + const parsedApprove = useMemo(() => { + try { + return parseLiFiApproveData(permissionData); + } catch { + return null; + } + }, [permissionData]); + + const token = parsedApprove?.tokenAddress; + const spender = parsedApprove ? String(parsedApprove.spender) : ""; + + const [tokenSymbol, setTokenSymbol] = useState("TOKEN"); + const [tokenIconUrl, setTokenIconUrl] = useState(); + + useEffect(() => { + if (!token) return; + let cancelled = false; + const chainId = executionRequest.chainId; + + void (async () => { + const assets = await listTrackedAssets(); + if (cancelled) return; + const tracked = assets.find( + (a) => + a.type === EAssetType.Erc20 && + a.chainId === chainId && + a.address === token, + ); + if (tracked) { + setTokenSymbol(tracked.symbol); + setTokenIconUrl( + resolveAssetIconUrl( + chainId, + token, + tracked.symbol, + tracked.iconUrl, + ), + ); + return; + } + try { + const known = await getKnownAsset(chainId, token); + if (cancelled) return; + if (known) { + setTokenSymbol(known.symbol); + setTokenIconUrl( + resolveAssetIconUrl( + chainId, + token, + known.symbol, + known.iconUrl, + ), + ); + } else { + setTokenIconUrl(undefined); + } + } catch { + setTokenIconUrl(undefined); + } + })(); + + return () => { + cancelled = true; + }; + }, [executionRequest.chainId, getKnownAsset, listTrackedAssets, token]); + + const chain = resolveChain(executionRequest.chainId); + const spenderExplorerUrl = spender + ? chain?.addressExplorerUrl(spender) + : undefined; + + return ( + + + {tokenIconUrl ? ( + + ) : null} + + {token ? tokenSymbol : "—"} + + + + {spender ? ( + + ) : ( + — + )} + + + ); +} diff --git a/src/components/modals/permissionGrantTerms/lifiSwapTerms.tsx b/src/components/modals/permissionGrantTerms/lifiSwapTerms.tsx index 0947f4b..9a5485a 100644 --- a/src/components/modals/permissionGrantTerms/lifiSwapTerms.tsx +++ b/src/components/modals/permissionGrantTerms/lifiSwapTerms.tsx @@ -1,296 +1,284 @@ -import { useEffect, useMemo, useState } from "react"; -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"; -import { EAssetType } from "../../../lib/types/enum/EAssetType"; -import { - formatSlippageBpsLabel, - formatUnixSecondsLabel, - humanizePeriodDuration, - readHostMemoOrJustification, - readPermissionStartText, - resolvePermissionEndUnixSeconds, - truncateMiddle, -} from "../../../lib/utils/delegationDisplay"; -import { resolveAssetIconUrl } from "../../../lib/utils/tokenIcons"; -import { useStyle } from "../../../style/StyleProvider"; -import type { IGrantExecutionPermissionResult } from "../../../wallet/modalTypes"; -import { useWallet } from "../../../wallet/WalletProvider"; -import { ConsentSummaryRow } from "../../ConsentSummaryRow"; -import { SafeAssetImage } from "../../SafeAssetImage"; -import { PermissionGrantTermsCard } from "../PermissionGrantConsentLayout"; -import { ExplorerAddressLink } from "./ExplorerAddressLink"; - -function readString(data: Record, ...keys: string[]): string { - for (const key of keys) { - const raw = data[key]; - if (typeof raw === "string" && raw.trim() !== "") return raw.trim(); - if (typeof raw === "number" && Number.isFinite(raw)) return String(raw); - } - return ""; -} - -export function isLiFiSwapPermissionValid( - executionRequest: IExecutionPermissionRequest, - defaultSlippageBps: number, -): boolean { - try { - parseLiFiSwapData(executionRequest.permission.data, defaultSlippageBps); - return true; - } catch { - return false; - } -} - -export function buildLiFiSwapGrantResult( - executionRequest: IExecutionPermissionRequest, -): IGrantExecutionPermissionResult { - const permissionData = executionRequest.permission.data as Record; - return { - permission: { - type: LIFI_SWAP_PERIODIC, - isAdjustmentAllowed: executionRequest.permission.isAdjustmentAllowed, - data: permissionData, - }, - memo: readHostMemoOrJustification(permissionData), - }; -} - -export function LiFiSwapPermissionTerms({ - executionRequest, -}: { - executionRequest: IExecutionPermissionRequest; -}) { - const copy = useStyle().style.copy.grantLiFiSwapPermission; - const { listTrackedAssets, resolveChain, getKnownAsset, liFiUtils } = - useWallet(); - const permissionData = executionRequest.permission.data as Record; - - const parsedSwap = useMemo(() => { - try { - return parseLiFiSwapData( - permissionData, - liFiUtils.defaultSlippageBps, - ); - } catch { - return null; - } - }, [liFiUtils.defaultSlippageBps, permissionData]); - - const tokenAddress = parsedSwap - ? getAddress(parsedSwap.tokenAddress) - : readString(permissionData, "tokenAddress", "inputToken"); - - const [tokenSymbol, setTokenSymbol] = useState("TOKEN"); - const [tokenDecimals, setTokenDecimals] = useState(6); - const [tokenIconUrl, setTokenIconUrl] = useState(); - - useEffect(() => { - if (!tokenAddress) return; - let cancelled = false; - const chainId = executionRequest.chainId; - let checksummed: `0x${string}`; - try { - checksummed = getAddress(tokenAddress as `0x${string}`); - } catch { - return; - } - - void (async () => { - const assets = await listTrackedAssets(); - if (cancelled) return; - const tracked = assets.find( - (a) => - a.type === EAssetType.Erc20 && - String(a.chainId).toLowerCase() === String(chainId).toLowerCase() && - getAddress(String(a.address)).toLowerCase() === checksummed.toLowerCase(), - ); - if (tracked) { - setTokenSymbol(tracked.symbol); - setTokenDecimals(tracked.decimals ?? 6); - setTokenIconUrl( - resolveAssetIconUrl( - chainId, - EVMAccountAddress(checksummed), - tracked.symbol, - tracked.iconUrl, - ), - ); - return; - } - try { - const known = await getKnownAsset( - chainId, - EVMAccountAddress(checksummed), - ); - if (cancelled) return; - if (known) { - setTokenSymbol(known.symbol); - setTokenDecimals(known.decimals ?? 6); - setTokenIconUrl( - resolveAssetIconUrl( - chainId, - EVMAccountAddress(checksummed), - known.symbol, - known.iconUrl, - ), - ); - } else { - setTokenIconUrl(undefined); - } - } catch { - setTokenIconUrl(undefined); - } - })(); - - return () => { - cancelled = true; - }; - }, [executionRequest.chainId, getKnownAsset, listTrackedAssets, tokenAddress]); - - const summaryAmount = useMemo(() => { - if (!parsedSwap || parsedSwap.periodAmount <= 0n) return null; - try { - return `${formatUnits(parsedSwap.periodAmount, tokenDecimals)} ${tokenSymbol}`; - } catch { - return null; - } - }, [parsedSwap, tokenDecimals, tokenSymbol]); - - const summaryWindow = parsedSwap - ? humanizePeriodDuration(parsedSwap.periodDuration) - : "—"; - - const slippageDisplay = parsedSwap - ? formatSlippageBpsLabel(parsedSwap.slippageBps) - : null; - - const startDisplay = formatUnixSecondsLabel( - readPermissionStartText(permissionData) || - (parsedSwap ? String(parsedSwap.startDate) : undefined), - ); - - const endDisplay = formatUnixSecondsLabel( - resolvePermissionEndUnixSeconds({ - rules: executionRequest.rules, - permissionData, - }) ?? undefined, - ); - - const sourceChain = resolveChain(executionRequest.chainId); - - const destChainIdRaw = readString(permissionData, "destinationChainId"); - let destChainHex: string | null = null; - if (destChainIdRaw !== "") { - try { - destChainHex = ChainUtils.asEVMChainId(destChainIdRaw); - } catch { - destChainHex = null; - } - } - const destChain = destChainHex - ? resolveChain(destChainHex as never) - : undefined; - const destinationLabel = destChain?.label ?? (destChainIdRaw || "—"); - - const outputAssetId = readString(permissionData, "outputAssetId"); - const outputRecipient = readString(permissionData, "outputRecipient"); - const lifiDiamond = readString(permissionData, "lifiDiamond"); - const quoteSigner = readString(permissionData, "quoteSigner"); - - const diamondExplorerUrl = lifiDiamond - ? sourceChain?.addressExplorerUrl(lifiDiamond) - : undefined; - const quoteSignerExplorerUrl = quoteSigner - ? sourceChain?.addressExplorerUrl(quoteSigner) - : undefined; - - return ( - - - {tokenIconUrl ? ( - - ) : null} - - {summaryAmount ?? "—"} - - - - {summaryWindow} - - {slippageDisplay ? ( - - {slippageDisplay} - - ) : null} - - - {outputAssetId ? truncateMiddle(outputAssetId) : "—"} - - - - - {outputRecipient ? truncateMiddle(outputRecipient) : "—"} - - - - {destChain?.logoUrl ? ( - - ) : null} - {destinationLabel} - - - {lifiDiamond ? ( - - ) : ( - — - )} - - - {quoteSigner ? ( - - ) : ( - — - )} - - {startDisplay ? ( - - {startDisplay} - - ) : null} - {endDisplay ? ( - - {endDisplay} - - ) : null} - - ); -} +import { useEffect, useMemo, useState } from "react"; +import { + ChainUtils, + type IExecutionPermissionRequest, +} from "@1shotapi/ows-types"; +import { formatUnits } from "viem"; +import { parseLiFiSwapData } from "../../../lib/implementations/business/DelegationService"; +import { LIFI_SWAP_PERIODIC } from "../../../lib/interfaces/business/IDelegationService"; +import { EAssetType } from "../../../lib/types/enum/EAssetType"; +import { + formatSlippageBpsLabel, + formatUnixSecondsLabel, + humanizePeriodDuration, + readHostMemoOrJustification, + readPermissionStartText, + resolvePermissionEndUnixSeconds, + truncateMiddle, +} from "../../../lib/utils/delegationDisplay"; +import { resolveAssetIconUrl } from "../../../lib/utils/tokenIcons"; +import { useStyle } from "../../../style/StyleProvider"; +import type { IGrantExecutionPermissionResult } from "../../../wallet/modalTypes"; +import { useWallet } from "../../../wallet/WalletProvider"; +import { ConsentSummaryRow } from "../../ConsentSummaryRow"; +import { SafeAssetImage } from "../../SafeAssetImage"; +import { PermissionGrantTermsCard } from "../PermissionGrantConsentLayout"; +import { ExplorerAddressLink } from "./ExplorerAddressLink"; + +function readString(data: Record, ...keys: string[]): string { + for (const key of keys) { + const raw = data[key]; + if (typeof raw === "string" && raw.trim() !== "") return raw.trim(); + if (typeof raw === "number" && Number.isFinite(raw)) return String(raw); + } + return ""; +} + +export function isLiFiSwapPermissionValid( + executionRequest: IExecutionPermissionRequest, + defaultSlippageBps: number, +): boolean { + try { + parseLiFiSwapData(executionRequest.permission.data, defaultSlippageBps); + return true; + } catch { + return false; + } +} + +export function buildLiFiSwapGrantResult( + executionRequest: IExecutionPermissionRequest, +): IGrantExecutionPermissionResult { + const permissionData = executionRequest.permission.data as Record; + return { + permission: { + type: LIFI_SWAP_PERIODIC, + isAdjustmentAllowed: executionRequest.permission.isAdjustmentAllowed, + data: permissionData, + }, + memo: readHostMemoOrJustification(permissionData), + }; +} + +export function LiFiSwapPermissionTerms({ + executionRequest, +}: { + executionRequest: IExecutionPermissionRequest; +}) { + const copy = useStyle().style.copy.grantLiFiSwapPermission; + const { listTrackedAssets, resolveChain, getKnownAsset, liFiUtils } = + useWallet(); + const permissionData = executionRequest.permission.data as Record; + + const parsedSwap = useMemo(() => { + try { + return parseLiFiSwapData( + permissionData, + liFiUtils.defaultSlippageBps, + ); + } catch { + return null; + } + }, [liFiUtils.defaultSlippageBps, permissionData]); + + const token = parsedSwap?.tokenAddress; + + const [tokenSymbol, setTokenSymbol] = useState("TOKEN"); + const [tokenDecimals, setTokenDecimals] = useState(6); + const [tokenIconUrl, setTokenIconUrl] = useState(); + + useEffect(() => { + if (!token) return; + let cancelled = false; + const chainId = executionRequest.chainId; + + void (async () => { + const assets = await listTrackedAssets(); + if (cancelled) return; + const tracked = assets.find( + (a) => + a.type === EAssetType.Erc20 && + a.chainId === chainId && + a.address === token, + ); + if (tracked) { + setTokenSymbol(tracked.symbol); + setTokenDecimals(tracked.decimals ?? 6); + setTokenIconUrl( + resolveAssetIconUrl( + chainId, + token, + tracked.symbol, + tracked.iconUrl, + ), + ); + return; + } + try { + const known = await getKnownAsset(chainId, token); + if (cancelled) return; + if (known) { + setTokenSymbol(known.symbol); + setTokenDecimals(known.decimals ?? 6); + setTokenIconUrl( + resolveAssetIconUrl( + chainId, + token, + known.symbol, + known.iconUrl, + ), + ); + } else { + setTokenIconUrl(undefined); + } + } catch { + setTokenIconUrl(undefined); + } + })(); + + return () => { + cancelled = true; + }; + }, [executionRequest.chainId, getKnownAsset, listTrackedAssets, token]); + + const summaryAmount = useMemo(() => { + if (!parsedSwap || parsedSwap.periodAmount <= 0n) return null; + try { + return `${formatUnits(parsedSwap.periodAmount, tokenDecimals)} ${tokenSymbol}`; + } catch { + return null; + } + }, [parsedSwap, tokenDecimals, tokenSymbol]); + + const summaryWindow = parsedSwap + ? humanizePeriodDuration(parsedSwap.periodDuration) + : "—"; + + const slippageDisplay = parsedSwap + ? formatSlippageBpsLabel(parsedSwap.slippageBps) + : null; + + const startDisplay = formatUnixSecondsLabel( + readPermissionStartText(permissionData) || + (parsedSwap ? String(parsedSwap.startDate) : undefined), + ); + + const endDisplay = formatUnixSecondsLabel( + resolvePermissionEndUnixSeconds({ + rules: executionRequest.rules, + permissionData, + }) ?? undefined, + ); + + const sourceChain = resolveChain(executionRequest.chainId); + + const destChainIdRaw = readString(permissionData, "destinationChainId"); + let destChainHex: string | null = null; + if (destChainIdRaw !== "") { + try { + destChainHex = ChainUtils.asEVMChainId(destChainIdRaw); + } catch { + destChainHex = null; + } + } + const destChain = destChainHex + ? resolveChain(destChainHex as never) + : undefined; + const destinationLabel = destChain?.label ?? (destChainIdRaw || "—"); + + const outputAssetId = readString(permissionData, "outputAssetId"); + const outputRecipient = readString(permissionData, "outputRecipient"); + const lifiDiamond = readString(permissionData, "lifiDiamond"); + const quoteSigner = readString(permissionData, "quoteSigner"); + + const diamondExplorerUrl = lifiDiamond + ? sourceChain?.addressExplorerUrl(lifiDiamond) + : undefined; + const quoteSignerExplorerUrl = quoteSigner + ? sourceChain?.addressExplorerUrl(quoteSigner) + : undefined; + + return ( + + + {tokenIconUrl ? ( + + ) : null} + + {summaryAmount ?? "—"} + + + + {summaryWindow} + + {slippageDisplay ? ( + + {slippageDisplay} + + ) : null} + + + {outputAssetId ? truncateMiddle(outputAssetId) : "—"} + + + + + {outputRecipient ? truncateMiddle(outputRecipient) : "—"} + + + + {destChain?.logoUrl ? ( + + ) : null} + {destinationLabel} + + + {lifiDiamond ? ( + + ) : ( + — + )} + + + {quoteSigner ? ( + + ) : ( + — + )} + + {startDisplay ? ( + + {startDisplay} + + ) : null} + {endDisplay ? ( + + {endDisplay} + + ) : null} + + ); +} diff --git a/src/lib/implementations/business/BridgeService.ts b/src/lib/implementations/business/BridgeService.ts index d7690e4..bb9eb5b 100644 --- a/src/lib/implementations/business/BridgeService.ts +++ b/src/lib/implementations/business/BridgeService.ts @@ -1,4 +1,5 @@ import { + EVMContractAddress, EVMTransactionHash, UriString, type EVMAccountAddress, @@ -251,7 +252,7 @@ export class BridgeService implements IBridgeService { private async readAllowance( chainId: EVMChainId, - usdc: EVMAccountAddress, + usdc: EVMContractAddress, owner: EVMAccountAddress, spender: EVMAccountAddress, ): Promise { diff --git a/src/lib/implementations/business/DelegationService.ts b/src/lib/implementations/business/DelegationService.ts index f831990..0161ce4 100644 --- a/src/lib/implementations/business/DelegationService.ts +++ b/src/lib/implementations/business/DelegationService.ts @@ -265,40 +265,32 @@ export class DelegationService implements IDelegationService { if (item.stored) group.stored.push(item.stored); } - const paymentByChain = new Map(); - for (const payment of params.payments) { - paymentByChain.set(payment.chainId, payment); - } + const workByChain = [...byChain.values()].map((group) => ({ + chainId: group.chainId, + work: group.work, + })); + + const sendResults = await this.transactionUtils.sendViaRelayerMultichain({ + workByChain, + paymentToken: params.paymentToken, + feeAtoms: params.feeAtoms, + paymentChainId: params.paymentChainId, + prefetchRelayerVaultAssertion: true, + retainDisplayDuringSubmit: true, + onAwaitingConfirmation: params.onAwaitingConfirmation, + onFinalFeeRequired: params.onFinalFeeRequired, + }); const results: ICancelDelegationsResult["results"] = []; - let firstChain = true; + let index = 0; for (const group of byChain.values()) { - const payment = paymentByChain.get(group.chainId); - if (!payment) { + const result = sendResults[index]; + if (!result) { throw new Error( - `cancelDelegations missing payment for chain ${group.chainId}`, + `cancelDelegations missing relayer result for chain ${group.chainId}`, ); } - const chain = await this.requireRelayerChain(group.chainId); - const result = await this.transactionUtils.sendViaRelayer({ - chainId: group.chainId, - work: group.work, - paymentToken: payment.paymentToken, - feeAtoms: payment.feeAtoms, - ...(payment.paymentChainId - ? { paymentChainId: payment.paymentChainId } - : {}), - relayerUrl: chain.relayerUrl, - prefetchRelayerVaultAssertion: true, - retainDisplayDuringSubmit: true, - // Only the first chain owns the confirm UI; later chains keep the - // flyout open without re-triggering "awaiting confirmation". - onAwaitingConfirmation: firstChain - ? params.onAwaitingConfirmation - : undefined, - onFinalFeeRequired: params.onFinalFeeRequired, - }); - firstChain = false; + index += 1; const deletedIds: DelegationId[] = []; for (const stored of group.stored) { @@ -329,13 +321,9 @@ export class DelegationService implements IDelegationService { : {}), }, ], - payments: [ - { - chainId: params.chainId, - paymentToken: params.paymentToken, - feeAtoms: params.feeAtoms, - }, - ], + paymentToken: params.paymentToken, + feeAtoms: params.feeAtoms, + paymentChainId: params.paymentChainId ?? params.chainId, onAwaitingConfirmation: params.onAwaitingConfirmation, onFinalFeeRequired: params.onFinalFeeRequired, retainDisplayDuringSubmit: params.retainDisplayDuringSubmit, @@ -678,7 +666,7 @@ export class DelegationService implements IDelegationService { } type Erc20PeriodData = { - tokenAddress: EVMAccountAddress; + tokenAddress: EVMContractAddress; periodAmount: bigint; periodDuration: number; startDate?: number; @@ -687,7 +675,7 @@ type Erc20PeriodData = { type LiFiSwapData = { lifiDiamond: EVMAccountAddress; - tokenAddress: EVMAccountAddress; + tokenAddress: EVMContractAddress; outputAssetId: Hex; outputRecipient: Hex; destinationChainId: bigint; @@ -699,7 +687,7 @@ type LiFiSwapData = { }; type LiFiApproveData = { - tokenAddress: EVMAccountAddress; + tokenAddress: EVMContractAddress; spender: EVMAccountAddress; }; @@ -720,7 +708,7 @@ function parseErc20PeriodData( } const startRaw = data.startDate ?? data.start; return { - tokenAddress: EVMAccountAddress(getAddress(tokenRaw as `0x${string}`)), + tokenAddress: EVMContractAddress(getAddress(tokenRaw as `0x${string}`)), periodAmount: toBigIntAmount(amountRaw), periodDuration: Number(durationRaw), ...(typeof startRaw === "number" || typeof startRaw === "string" @@ -737,7 +725,7 @@ export function parseLiFiSwapData( defaultSlippageBps: number, ): LiFiSwapData { const lifiDiamond = requireAddress(data.lifiDiamond, "lifiDiamond"); - const tokenAddress = requireAddress( + const tokenAddress = requireContractAddress( data.tokenAddress ?? data.inputToken, "tokenAddress", ); @@ -800,7 +788,7 @@ export function parseLiFiApproveData( data: Record, ): LiFiApproveData { return { - tokenAddress: requireAddress( + tokenAddress: requireContractAddress( data.tokenAddress ?? data.inputToken, "tokenAddress", ), @@ -818,6 +806,16 @@ function requireAddress( return EVMAccountAddress(getAddress(value as `0x${string}`)); } +function requireContractAddress( + value: unknown, + field: string, +): EVMContractAddress { + if (typeof value !== "string") { + throw new Error(`${field} is required`); + } + return EVMContractAddress(getAddress(value as `0x${string}`)); +} + function requireBytes32(value: unknown, field: string): Hex { if (typeof value !== "string" || !isHex(value) || (value.length - 2) / 2 !== 32) { throw new Error(`${field} must be a 32-byte hex string`); diff --git a/src/lib/implementations/business/TransactionService.ts b/src/lib/implementations/business/TransactionService.ts index 5929dd7..8cfda91 100644 --- a/src/lib/implementations/business/TransactionService.ts +++ b/src/lib/implementations/business/TransactionService.ts @@ -1,181 +1,197 @@ -import type { - EVMAccountAddress, - EVMChainId, -} from "@1shotapi/ows-types"; -import { HexString } from "@1shotapi/ows-types"; -import type { IChainRepository } from "../../interfaces/data/IChainRepository"; -import type { IEVMRepository } from "../../interfaces/data/IEVMRepository"; -import type { - IOneshotRelayerRepository, - IRelayerAuthorizationEntry, - ISendTransactionResult, -} from "../../interfaces/data/IOneshotRelayerRepository"; -import type { - IPaymentQuote, - ITransactionService, - ITransactionWork, -} from "../../interfaces/business/ITransactionService"; -import type { ITransactionUtils } from "../../interfaces/business/utils/ITransactionUtils"; -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"; - -const EMPTY_CALLDATA = HexString("0x"); - -export type TransactionServiceOptions = { - chainRepository: IChainRepository; - relayerRepository: IOneshotRelayerRepository; - evmRepository: IEVMRepository; - /** Shared EIP-7702 / ExactCalldata / relayer submit helpers. */ - transactionUtils: ITransactionUtils; -}; - -/** - * Business orchestration for raw and public-relayer (EIP-7710) sends. - * Relayer plumbing lives in business {@link ITransactionUtils}. - */ -export class TransactionService implements ITransactionService { - constructor(private readonly options: TransactionServiceOptions) {} - - needsWalletUpgrade( - chainId: EVMChainId, - address: EVMAccountAddress, - ): Promise { - return this.options.transactionUtils.needsWalletUpgrade(chainId, address); - } - - getWalletUpgradeStatus( - chainId: EVMChainId, - address: EVMAccountAddress, - ): Promise { - return this.options.transactionUtils.getWalletUpgradeStatus( - chainId, - address, - ); - } - - signWalletUpgradeAuthorization( - chainId: EVMChainId, - ): Promise { - return this.options.transactionUtils.signWalletUpgradeAuthorization( - chainId, - ); - } - - quotePayment( - chainId: EVMChainId, - owner: EVMAccountAddress, - work: ITransactionWork | ITransactionWork[], - preferredToken?: EVMAccountAddress, - ): Promise { - return this.options.transactionUtils.quotePayment( - chainId, - owner, - work, - preferredToken, - ); - } - - quoteActivation( - owner: EVMAccountAddress, - upgradeChainIds: readonly EVMChainId[], - payment: IRelayerPayment, - ): Promise { - return this.options.transactionUtils.quoteActivation( - owner, - upgradeChainIds, - payment, - ); - } - - activateDelegations( - args: { - upgradeChainIds: readonly EVMChainId[]; - payment: IRelayerPayment; - feeAtoms: TokenAmount; - } & IRelayerSendUiCallbacks, - ): Promise { - return this.options.transactionUtils.activateDelegations(args); - } - - async sendTransaction( - chainId: EVMChainId, - work: ITransactionWork, - options?: { - paymentToken?: EVMAccountAddress; - feeAtoms?: TokenAmount; - paymentChainId?: EVMChainId; - authorizationList?: IRelayerAuthorizationEntry[]; - } & IRelayerSendUiCallbacks, - ): Promise { - const chain = await this.options.chainRepository.get(chainId); - if (!chain) { - throw new Error(`Unsupported chain: ${chainId}`); - } - - if (!chain.useRelayer) { - return this.options.evmRepository.broadcastRawTransaction( - chainId, - work.to, - work.data, - work.value, - ); - } - - if (!options?.paymentToken || options.feeAtoms === undefined) { - throw new Error( - "Relayer sends require paymentToken and feeAtoms from the confirm UI", - ); - } - - return this.options.transactionUtils.sendViaRelayer({ - chainId, - work, - paymentToken: options.paymentToken, - feeAtoms: options.feeAtoms, - ...(options.paymentChainId - ? { paymentChainId: options.paymentChainId } - : {}), - authorizationList: options.authorizationList, - relayerUrl: chain.relayerUrl, - onFinalFeeRequired: options.onFinalFeeRequired, - onAwaitingConfirmation: options.onAwaitingConfirmation, - retainDisplayDuringSubmit: options.retainDisplayDuringSubmit, - }); - } - - async sendNativeTransfer( - chainId: EVMChainId, - to: EVMAccountAddress, - value: bigint, - ): Promise { - const chain = await this.options.chainRepository.get(chainId); - if (!chain) { - throw new Error(`Unsupported chain: ${chainId}`); - } - const planned = await this.options.transactionUtils.planNativeTransfer( - chainId, - value, - ); - return this.options.evmRepository.broadcastRawTransaction( - chainId, - to, - EMPTY_CALLDATA, - planned.value, - { - gas: planned.gas, - maxFeePerGas: planned.maxFeePerGas, - maxPriorityFeePerGas: planned.maxPriorityFeePerGas, - }, - ); - } - - estimateNativeTransferFee(chainId: EVMChainId): Promise<{ - gasPrice: bigint; - maxPriorityFeePerGas: bigint; - feeAtoms: bigint; - }> { - return this.options.transactionUtils.estimateNativeTransferFee(chainId); - } -} +import type { + EVMAccountAddress, + EVMChainId, + EVMContractAddress, +} from "@1shotapi/ows-types"; +import { HexString } from "@1shotapi/ows-types"; +import type { IChainRepository } from "../../interfaces/data/IChainRepository"; +import type { IEVMRepository } from "../../interfaces/data/IEVMRepository"; +import type { + IOneshotRelayerRepository, + IRelayerAuthorizationEntry, + ISendTransactionResult, +} from "../../interfaces/data/IOneshotRelayerRepository"; +import type { + IPaymentQuote, + ITransactionService, + ITransactionWork, +} from "../../interfaces/business/ITransactionService"; +import type { ITransactionUtils } from "../../interfaces/business/utils/ITransactionUtils"; +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"; + +const EMPTY_CALLDATA = HexString("0x"); + +export type TransactionServiceOptions = { + chainRepository: IChainRepository; + relayerRepository: IOneshotRelayerRepository; + evmRepository: IEVMRepository; + /** Shared EIP-7702 / ExactCalldata / relayer submit helpers. */ + transactionUtils: ITransactionUtils; +}; + +/** + * Business orchestration for raw and public-relayer (EIP-7710) sends. + * Relayer plumbing lives in business {@link ITransactionUtils}. + */ +export class TransactionService implements ITransactionService { + constructor(private readonly options: TransactionServiceOptions) {} + + needsWalletUpgrade( + chainId: EVMChainId, + address: EVMAccountAddress, + ): Promise { + return this.options.transactionUtils.needsWalletUpgrade(chainId, address); + } + + getWalletUpgradeStatus( + chainId: EVMChainId, + address: EVMAccountAddress, + ): Promise { + return this.options.transactionUtils.getWalletUpgradeStatus( + chainId, + address, + ); + } + + signWalletUpgradeAuthorization( + chainId: EVMChainId, + ): Promise { + return this.options.transactionUtils.signWalletUpgradeAuthorization( + chainId, + ); + } + + quotePayment( + chainId: EVMChainId, + owner: EVMAccountAddress, + work: ITransactionWork | ITransactionWork[], + preferredToken?: EVMContractAddress, + ): Promise { + return this.options.transactionUtils.quotePayment( + chainId, + owner, + work, + preferredToken, + ); + } + + quoteActivation( + owner: EVMAccountAddress, + upgradeChainIds: readonly EVMChainId[], + payment: IRelayerPayment, + ): Promise { + return this.options.transactionUtils.quoteActivation( + owner, + upgradeChainIds, + payment, + ); + } + + quotePaymentMultichain( + owner: EVMAccountAddress, + workByChain: readonly { + chainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + }[], + preferredToken?: EVMContractAddress, + ): Promise { + return this.options.transactionUtils.quotePaymentMultichain( + owner, + workByChain, + preferredToken, + ); + } + + activateDelegations( + args: { + upgradeChainIds: readonly EVMChainId[]; + payment: IRelayerPayment; + feeAtoms: TokenAmount; + } & IRelayerSendUiCallbacks, + ): Promise { + return this.options.transactionUtils.activateDelegations(args); + } + + async sendTransaction( + chainId: EVMChainId, + work: ITransactionWork, + options?: { + paymentToken?: EVMContractAddress; + feeAtoms?: TokenAmount; + paymentChainId?: EVMChainId; + authorizationList?: IRelayerAuthorizationEntry[]; + } & IRelayerSendUiCallbacks, + ): Promise { + const chain = await this.options.chainRepository.get(chainId); + if (!chain) { + throw new Error(`Unsupported chain: ${chainId}`); + } + + if (!chain.useRelayer) { + return this.options.evmRepository.broadcastRawTransaction( + chainId, + work.to, + work.data, + work.value, + ); + } + + if (!options?.paymentToken || options.feeAtoms === undefined) { + throw new Error( + "Relayer sends require paymentToken and feeAtoms from the confirm UI", + ); + } + + return this.options.transactionUtils.sendViaRelayer({ + chainId, + work, + paymentToken: options.paymentToken, + feeAtoms: options.feeAtoms, + ...(options.paymentChainId + ? { paymentChainId: options.paymentChainId } + : {}), + authorizationList: options.authorizationList, + relayerUrl: chain.relayerUrl, + onFinalFeeRequired: options.onFinalFeeRequired, + onAwaitingConfirmation: options.onAwaitingConfirmation, + retainDisplayDuringSubmit: options.retainDisplayDuringSubmit, + }); + } + + async sendNativeTransfer( + chainId: EVMChainId, + to: EVMAccountAddress, + value: bigint, + ): Promise { + const chain = await this.options.chainRepository.get(chainId); + if (!chain) { + throw new Error(`Unsupported chain: ${chainId}`); + } + const planned = await this.options.transactionUtils.planNativeTransfer( + chainId, + value, + ); + return this.options.evmRepository.broadcastRawTransaction( + chainId, + to, + EMPTY_CALLDATA, + planned.value, + { + gas: planned.gas, + maxFeePerGas: planned.maxFeePerGas, + maxPriorityFeePerGas: planned.maxPriorityFeePerGas, + }, + ); + } + + estimateNativeTransferFee(chainId: EVMChainId): Promise<{ + gasPrice: bigint; + maxPriorityFeePerGas: bigint; + feeAtoms: bigint; + }> { + return this.options.transactionUtils.estimateNativeTransferFee(chainId); + } +} diff --git a/src/lib/implementations/business/utils/PaymentTokenUtils.ts b/src/lib/implementations/business/utils/PaymentTokenUtils.ts index da443d0..b65db3c 100644 --- a/src/lib/implementations/business/utils/PaymentTokenUtils.ts +++ b/src/lib/implementations/business/utils/PaymentTokenUtils.ts @@ -1,4 +1,10 @@ -import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; +import { + ChainUtils, + EVMContractAddress, + type EVMAccountAddress, + type EVMChainId, +} from "@1shotapi/ows-types"; +import { parseUnits } from "viem"; import type { IChainRepository } from "../../../interfaces/data/IChainRepository"; import type { IOneshotRelayerRepository } from "../../../interfaces/data/IOneshotRelayerRepository"; import type { ITrackedAssetRepository } from "../../../interfaces/data/ITrackedAssetRepository"; @@ -9,12 +15,20 @@ import { EAssetType } from "../../../types/enum/EAssetType"; import { makeTokenAmount } from "../../../types/primitives"; import { DEFAULT_CHAIN_ID } from "../../data/HardcodedChainRepository"; +/** Matches unsigned quote seed in TransactionUtils (`parseUnits("0.01", decimals)`). */ +const SEED_FEE_HUMAN = "0.01"; + type ChainPaymentOptions = { chainId: EVMChainId; chainName: string; tokens: IPaymentTokenOption[]; }; +type FundedPick = { + row: ChainPaymentOptions; + selected: IPaymentTokenOption; +}; + /** * Single policy for which chain/token pays the public-relayer fee. */ @@ -28,48 +42,126 @@ export class PaymentTokenUtils implements IPaymentTokenUtils { async resolvePayment( owner: EVMAccountAddress, executionChainIds: readonly EVMChainId[], - preferredToken?: EVMAccountAddress, + preferredToken?: EVMContractAddress, ): 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 { execution, arc, others } = await this.loadCandidateChains( + owner, + unique, + ); + const searchOrder = [...execution, ...arcRows(arc, unique), ...others]; - 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. Preferred token wins on any candidate chain (any positive balance — + // user explicitly chose it; quote/UI can still surface insufficiency). + if (preferredToken) { + for (const row of searchOrder) { + const match = findTokenByAddress(row.tokens, preferredToken, true); + if (match) return toRelayerPayment(row, match); + } } - // 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. Work chains — Arc default when it can cover the seed fee; else first + // usable execution chain (skips dust that would fail estimate). + const fundedExecution = fundedPicks(execution); + const arcInExecution = fundedExecution.find( + (p) => p.row.chainId === DEFAULT_CHAIN_ID, + ); + if (arcInExecution) { + return toRelayerPayment(arcInExecution.row, arcInExecution.selected); + } + if (fundedExecution.length > 0) { + const first = fundedExecution[0]!; + return toRelayerPayment(first.row, first.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, + // 3. Arc fallback (even when not in execution set). + if (arc) { + const usdc = arc.tokens.find( + (t) => t.symbol.toUpperCase() === "USDC" && hasSeedBalance(t), ); - if (usdc) return toRelayerPayment(arcOptions, usdc); + if (usdc) return toRelayerPayment(arc, usdc); + const any = pickPaymentToken(arc.tokens); + if (any) return toRelayerPayment(arc, any); } - // 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); + // 4. Other wallet relayer chains. + for (const row of others) { + const selected = pickPaymentToken(row.tokens); + if (selected) return toRelayerPayment(row, selected); } return null; } + async listPaymentOptions( + owner: EVMAccountAddress, + executionChainIds: readonly EVMChainId[], + ): Promise { + const unique = uniqueChainIds(executionChainIds); + const { execution, arc, others } = await this.loadCandidateChains( + owner, + unique, + ); + const priorityRows = [...execution, ...arcRows(arc, unique)]; + const priorityChainIds = new Set(priorityRows.map((r) => r.chainId)); + const rows = [...priorityRows, ...others]; + const options: IPaymentTokenOption[] = []; + const seen = new Set(); + for (const row of rows) { + for (const token of row.tokens) { + // Always list execution + Arc tokens; other chains only when funded. + if (!priorityChainIds.has(row.chainId) && token.balance <= 0n) { + continue; + } + const key = `${token.chainId}:${token.address}`; + if (seen.has(key)) continue; + seen.add(key); + options.push(token); + } + } + return options; + } + + private async loadCandidateChains( + owner: EVMAccountAddress, + executionChainIds: readonly EVMChainId[], + ): Promise<{ + execution: ChainPaymentOptions[]; + arc: ChainPaymentOptions | null; + others: ChainPaymentOptions[]; + }> { + const execution = ( + await Promise.all( + executionChainIds.map((id) => this.loadChainOptions(owner, id)), + ) + ).filter((row): row is ChainPaymentOptions => row !== null); + + const executionSet = new Set(executionChainIds); + const arc = executionSet.has(DEFAULT_CHAIN_ID) + ? (execution.find((r) => r.chainId === DEFAULT_CHAIN_ID) ?? null) + : await this.loadChainOptions(owner, DEFAULT_CHAIN_ID); + + const catalog = await this.chainRepository.list(); + const considered = new Set([ + ...executionChainIds, + DEFAULT_CHAIN_ID, + ]); + const otherIds: EVMChainId[] = []; + for (const c of catalog) { + if (!c.useRelayer) continue; + if (!ChainUtils.isEVMChainId(c.chainId)) continue; + if (considered.has(c.chainId)) continue; + otherIds.push(c.chainId); + } + const others = ( + await Promise.all(otherIds.map((id) => this.loadChainOptions(owner, id))) + ).filter((row): row is ChainPaymentOptions => row !== null); + + return { execution, arc, others }; + } + private async loadChainOptions( owner: EVMAccountAddress, chainId: EVMChainId, @@ -83,19 +175,18 @@ export class PaymentTokenUtils implements IPaymentTokenUtils { this.relayerRepository.getCapabilities(chain.relayerUrl, chainId), ]); - const balanceByAddress = new Map( - tracked.map((asset) => [ - String(asset.address).toLowerCase(), - asset.balance ?? 0n, - ]), + // Tracked assets still brand ERC-20s as EVMAccountAddress; both brands + // share the EIP-55 string so Map lookup by contract address works. + const balanceByAddress = new Map( + tracked.map((asset) => [asset.address, asset.balance ?? 0n]), ); const tokens: IPaymentTokenOption[] = capabilities.tokens.map( (token) => ({ ...token, - balance: makeTokenAmount( - balanceByAddress.get(String(token.address).toLowerCase()) ?? 0n, - ), + chainId, + chainName: chain.label, + balance: makeTokenAmount(balanceByAddress.get(token.address) ?? 0n), }), ); @@ -103,19 +194,19 @@ export class PaymentTokenUtils implements IPaymentTokenUtils { 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, - ); + const contract = asset.address; + const already = tokens.some((t) => t.address === contract); if (already) continue; const accepted = capabilities.tokens.some( - (t) => String(t.address).toLowerCase() === key, + (t) => t.address === contract, ); if (!accepted) continue; tokens.push({ - address: asset.address, + address: contract, symbol: asset.symbol, decimals: asset.decimals, + chainId, + chainName: chain.label, balance: makeTokenAmount(asset.balance ?? 0n), }); } @@ -131,6 +222,25 @@ export class PaymentTokenUtils implements IPaymentTokenUtils { } } +/** Arc row only when not already included in the execution list. */ +function arcRows( + arc: ChainPaymentOptions | null, + executionChainIds: readonly EVMChainId[], +): ChainPaymentOptions[] { + if (!arc) return []; + if (executionChainIds.includes(DEFAULT_CHAIN_ID)) return []; + return [arc]; +} + +function fundedPicks(rows: ChainPaymentOptions[]): FundedPick[] { + const out: FundedPick[] = []; + for (const row of rows) { + const selected = pickPaymentToken(row.tokens); + if (selected) out.push({ row, selected }); + } + return out; +} + function uniqueChainIds(chainIds: readonly EVMChainId[]): EVMChainId[] { const unique: EVMChainId[] = []; const seen = new Set(); @@ -142,23 +252,45 @@ function uniqueChainIds(chainIds: readonly EVMChainId[]): EVMChainId[] { return unique; } +function hasSeedBalance(token: IPaymentTokenOption): boolean { + try { + return token.balance >= parseUnits(SEED_FEE_HUMAN, token.decimals); + } catch { + return token.balance > 0n; + } +} + +function findTokenByAddress( + tokens: IPaymentTokenOption[], + address: EVMContractAddress, + requireBalance: boolean, +): IPaymentTokenOption | null { + const match = tokens.find( + (t) => t.address === address && (!requireBalance || t.balance > 0n), + ); + return match ?? null; +} + function pickPaymentToken( tokens: IPaymentTokenOption[], - preferred?: EVMAccountAddress, + preferred?: EVMContractAddress, ): IPaymentTokenOption | null { - const withBalance = tokens.filter((t) => t.balance > 0n); + // Default picks require enough balance for the unsigned seed fee so dust on + // Arc does not win over a usable Base balance. + const usable = tokens.filter((t) => hasSeedBalance(t)); if (preferred) { - const match = withBalance.find( - (t) => - String(t.address).toLowerCase() === String(preferred).toLowerCase(), - ); + const match = usable.find((t) => t.address === preferred); if (match) return match; + const anyPreferred = tokens.find( + (t) => t.address === preferred && t.balance > 0n, + ); + if (anyPreferred) return anyPreferred; } - const usdc = withBalance.find((t) => t.symbol.toUpperCase() === "USDC"); + const usdc = usable.find((t) => t.symbol.toUpperCase() === "USDC"); if (usdc) return usdc; - const usdt = withBalance.find((t) => t.symbol.toUpperCase() === "USDT"); + const usdt = usable.find((t) => t.symbol.toUpperCase() === "USDT"); if (usdt) return usdt; - return withBalance[0] ?? null; + return usable[0] ?? null; } function toRelayerPayment( diff --git a/src/lib/implementations/business/utils/TransactionUtils.ts b/src/lib/implementations/business/utils/TransactionUtils.ts index fb5ec65..8af7c6d 100644 --- a/src/lib/implementations/business/utils/TransactionUtils.ts +++ b/src/lib/implementations/business/utils/TransactionUtils.ts @@ -1,2305 +1,3014 @@ -import { - createDelegation, - getSmartAccountsEnvironment, - Implementation, - ScopeType, - toMetaMaskSmartAccount, -} from "@metamask/smart-accounts-kit"; -import { toViemLocalAccount } from "@1shotapi/ows-signer-utils"; -import type { IBlockchainProvider } from "@1shotapi/ows-wallet-utils"; -import { - EVMAccountAddress, - EVMContractAddress, - EVMTransactionHash, - type CeremonyUiParams, - type EVMChainId, - type HexString, - type RelayerTransactionId, -} from "@1shotapi/ows-types"; -import { - encodeFunctionData, - erc20Abi, - formatUnits, - getAddress, - parseUnits, - type Hex, -} from "viem"; -import { recoverAuthorizationAddress } from "viem/utils"; -import type { LocalAccount } from "viem/accounts"; -import type { IChainRepository } from "../../../interfaces/data/IChainRepository"; -import type { IDelegationRepository } from "../../../interfaces/data/IDelegationRepository"; -import type { - IOneshotRelayerRepository, - IRelayer7710Params, - IRelayerAuthorizationEntry, - ISendTransactionResult, -} from "../../../interfaces/data/IOneshotRelayerRepository"; -import type { ITrackedAssetRepository } from "../../../interfaces/data/ITrackedAssetRepository"; -import type { - IPaymentQuote, - IPaymentTokenOption, - ITransactionWork, -} from "../../../interfaces/business/ITransactionService"; -import { - NATIVE_TRANSFER_GAS, - maxNativeSendable, - withNativeFeeHeadroom, - type ITransactionUtils, -} from "../../../interfaces/business/utils/ITransactionUtils"; -import type { ITransactionUtils as IPresentationTransactionUtils } from "../../../interfaces/utils/ITransactionUtils"; -import type { IOWSProvider } from "../../../interfaces/utils/IOWSProvider"; -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 { - makeTokenAmount, - tokenAmountFromAtomString, - type TokenAmount, -} from "../../../types/primitives"; -import { idbGetString, idbSetString } from "../../../utils/idbStringStore"; -import { withCeremonyUiReason } from "../../../../wallet/ceremonyUiOverrideStore"; -import { withCoalescedSignDigest } from "../../../../wallet/withCoalescedSignDigest"; -import type { CoalesceSignDigestOptions } from "../../../../wallet/withCoalescedSignDigest"; -import { - loadCachedEvmAddress, - loadCachedSecp256k1PublicKey, -} from "../../../../storage"; -import { styleController } from "../../../../style/styleController"; -// Ensure Arc mainnet Smart Accounts env is registered before any kit lookups. -import "../../utils/registerSmartAccountsEnvironments"; - -const STATELESS_DELEGATOR_IMPL = - EVMContractAddress("0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B"); - -/** - * 65-byte zero signature for `relayer_estimate7710Transaction` unsigned - * estimates. Valid ECDSA length so the DelegatorEstimateShim's - * ECDSA.tryRecover pays ecrecover precompile gas (prefer over bytes32(0)). - */ -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; -/** 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>; - delegate: EVMAccountAddress; - target: EVMAccountAddress; - value: bigint; - callData: Hex; - 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; - trackedAssetRepository: ITrackedAssetRepository; - paymentTokenUtils: IPaymentTokenUtils; - blockchain: IBlockchainProvider; - /** Presentation helpers (host domain for relayer memo). */ - presentationTransactionUtils: IPresentationTransactionUtils; - owsProvider: IOWSProvider; - delegationRepository: IDelegationRepository; -}; - -/** - * Shared EIP-7702 / ExactCalldata / public-relayer submit plumbing used by - * TransactionService and DelegationService. - */ -export class TransactionUtils implements ITransactionUtils { - constructor(private readonly options: TransactionUtilsOptions) {} - - async needsWalletUpgrade( - chainId: EVMChainId, - address: EVMAccountAddress, - ): Promise { - // Always verify on-chain. localStorage may still record the last known - // status, but must not skip EIP-7702 auth: a cached `true` written after - // send (before confirm) or after a failed upgrade leaves the account - // unable to estimate on that chain. - try { - const status = await this.getWalletUpgradeStatus(chainId, address); - console.debug("[business/TransactionUtils] EIP-7702 upgrade check", { - chainId, - address, - upgraded: status.upgraded, - codeAddress: status.codeAddress, - needsUpgrade: !status.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). - console.warn( - "[business/TransactionUtils] getCode failed; assuming EIP-7702 upgrade required", - { chainId, address, error }, - ); - return true; - } - } - - 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 { - await this.options.owsProvider.ensureDisplay(); - return withCeremonyUiReason(EPasskeyPromptReason.WalletUpgrade, () => - this.signWalletUpgradeAuthorizationInner(chainId), - ); - } - - private async signWalletUpgradeAuthorizationInner( - chainId: EVMChainId, - options?: { - account?: LocalAccount; - /** Prefetched so signing can share one passkey with fee/work digests. */ - nonce?: number; - contractAddress?: `0x${string}`; - }, - ): Promise { - const account = options?.account ?? (await this.getViemAccount()); - const chainIdNumber = Number(BigInt(chainId)); - const client = this.options.blockchain.getPublicClient(chainId); - - let contractAddress: `0x${string}` = - options?.contractAddress ?? STATELESS_DELEGATOR_IMPL; - if (!options?.contractAddress) { - try { - const env = getSmartAccountsEnvironment(chainIdNumber); - contractAddress = getAddress( - env.implementations.EIP7702StatelessDeleGatorImpl, - ); - } catch { - // Fall back to the known Stateless7702 implementation address. - } - } - - const nonce = - options?.nonce ?? - (await client.getTransactionCount({ - address: account.address, - blockTag: "pending", - })); - - if (!account.signAuthorization) { - throw new Error("Signer does not support EIP-7702 signAuthorization"); - } - - const signed = await account.signAuthorization({ - chainId: chainIdNumber, - contractAddress, - nonce, - }); - - const yParity = yParityFromSignedAuthorization(signed); - const entry: IRelayerAuthorizationEntry = { - address: getAddress(signed.address), - chainId: Number(signed.chainId), - nonce: Number(signed.nonce), - r: signed.r as `0x${string}`, - s: signed.s as `0x${string}`, - yParity, - }; - - // Verify the auth list entry recovers to this EOA before sending to the relayer. - const recovered = await recoverAuthorizationAddress({ - authorization: { - address: entry.address, - chainId: entry.chainId, - nonce: entry.nonce, - r: entry.r, - s: entry.s, - yParity: entry.yParity as 0 | 1, - }, - }); - if (getAddress(recovered) !== getAddress(account.address)) { - throw new Error( - `EIP-7702 authorization recovers to ${recovered}, expected ${account.address}`, - ); - } - console.debug("[business/TransactionUtils] EIP-7702 authorization verified", { - eoa: account.address, - contractAddress: entry.address, - chainId: entry.chainId, - nonce: entry.nonce, - yParity: entry.yParity, - r: entry.r, - s: entry.s, - recovered, - }); - - return entry; - } - - async quotePayment( - chainId: EVMChainId, - owner: EVMAccountAddress, - work: ITransactionWork | ITransactionWork[], - preferredToken?: EVMAccountAddress, - ): Promise { - const workItems = Array.isArray(work) ? work : [work]; - if (workItems.length === 0) { - throw new Error("quotePayment requires at least one work item"); - } - - 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: payment.paymentChainId }, - ); - const balanceByAddress = new Map( - tracked.map((asset) => [ - String(asset.address).toLowerCase(), - asset.balance ?? 0n, - ]), - ); - const tokens: IPaymentTokenOption[] = paymentCapabilities.tokens.map( - (token) => ({ - ...token, - balance: makeTokenAmount( - balanceByAddress.get(String(token.address).toLowerCase()) ?? 0n, - ), - }), - ); - - 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"); - } - - const seedFeeAtoms = makeTokenAmount( - parseUnits("0.01", selected.decimals), - ); - const crossChain = payment.paymentChainId !== chainId; - - 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: [paymentCapabilities.feeCollector, seedFeeAtoms], - }), - ); - - const feeDelegation = this.createUnsignedExactCalldataDelegation({ - smartAccount, - 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])], - 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, - paymentChainId: payment.paymentChainId, - paymentToken: selected.address, - workCount: workItems.length, - crossChain: false, - }, - ); - - 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( - estimate.error ?? "relayer_estimate7710Transaction failed", - ); - } - - const feeAtoms = tokenAmountFromAtomString(estimate.requiredPaymentAmount); - - return { - tokens, - selectedToken: selected.address, - paymentChainId: payment.paymentChainId, - paymentChainName: payment.paymentChainName, - feeAtoms, - feeFormatted: formatUnits(feeAtoms, selected.decimals), - feeCollector: paymentCapabilities.feeCollector, - targetAddress: paymentCapabilities.targetAddress, - minFee: feeAtoms, - }; - } - - async quoteActivation( - owner: EVMAccountAddress, - upgradeChainIds: readonly EVMChainId[], - payment: IRelayerPayment, - ): 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.decimals)), - 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.symbol, - decimals: payment.decimals, - balance: payment.balance, - }; - - return { - tokens: [tokenOption], - selectedToken: payment.paymentToken, - paymentChainId: payment.paymentChainId, - paymentChainName: payment.paymentChainName, - feeAtoms, - feeFormatted: formatUnits(feeAtoms, payment.decimals), - feeCollector: capabilities.feeCollector, - targetAddress: capabilities.targetAddress, - minFee: feeAtoms, - }; - } - - async activateDelegations(args: { - upgradeChainIds: readonly EVMChainId[]; - payment: IRelayerPayment; - 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( - payment.paymentChainId, - paymentSmartAccount, - ); - upgradeCapabilities.set( - payment.paymentChainId, - paymentCapabilities, - ); - const missingUpgradeIds = upgradeChainIds.filter( - (chainId) => !chainSmartAccounts.has(chainId), - ); - await Promise.all( - missingUpgradeIds.map(async (chainId) => { - const key = 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( - chainId, - ); - const caps = upgradeCapabilities.get(chainId); - if (!smartAccount || !caps) { - throw new Error( - `Missing smart account or capabilities for ${chainId}`, - ); - } - return this.createAndSignActivationNoOpDelegation({ - smartAccount, - delegate: caps.targetAddress, - }); - }), - ), - ]); - return { authEntries, feeDelegation, workDelegations }; - }, - { minCalls } satisfies CoalesceSignDigestOptions, - ), - ); - - const authByChain = new Map(); - for (let i = 0; i < upgradeChainIds.length; i += 1) { - authByChain.set( - upgradeChainIds[i]!, - signed.authEntries[i]!, - ); - } - let feeDelegation = signed.feeDelegation; - const workByChain = new Map(); - for (let i = 0; i < upgradeChainIds.length; i += 1) { - workByChain.set( - 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 = chainId === payment.paymentChainId; - const needsUpgrade = upgradeChainIds.some((id) => - id === chainId, - ); - const chainKey = chainId; - 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(chainKey); - if (!workSig) { - throw new Error( - `Missing work delegation for upgrade chain ${chainId}`, - ); - } - transactions.push({ - permissionContext: [toRelayerJson(workSig)], - executions: [ - { - target: ACTIVATION_NOOP_TARGET, - 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(chainKey); - const context = contexts?.[chainKey]; - return { - chainId: chainKey, - 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.decimals), - 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 - ? { - [payment.paymentChainId]: 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) => id === 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; - feeAtoms: bigint; - }> { - const client = this.options.blockchain.getPublicClient(chainId); - let maxFeePerGas: bigint; - let maxPriorityFeePerGas = 0n; - try { - const fees = await client.estimateFeesPerGas(); - maxFeePerGas = fees.maxFeePerGas ?? (await client.getGasPrice()); - maxPriorityFeePerGas = fees.maxPriorityFeePerGas ?? 0n; - } catch { - maxFeePerGas = await client.getGasPrice(); - } - // Pin Max / balance checks to a buffered cap so a later prepare that - // re-quotes fees (or base-fee bumps while pending) does not exceed the - // reserved budget. Actual ETH paid is still baseFee + tip ≤ maxFeePerGas. - const gasPrice = withNativeFeeHeadroom(maxFeePerGas); - return { - gasPrice, - maxPriorityFeePerGas, - feeAtoms: gasPrice * NATIVE_TRANSFER_GAS, - }; - } - - async planNativeTransfer( - chainId: EVMChainId, - value: bigint, - ): Promise<{ - value: bigint; - gas: bigint; - maxFeePerGas: bigint; - maxPriorityFeePerGas: bigint; - }> { - if (value < 0n) { - throw new Error("Native transfer value must be non-negative"); - } - const estimate = await this.estimateNativeTransferFee(chainId); - const account = await this.getViemAccount(); - const client = this.options.blockchain.getPublicClient(chainId); - const balance = await client.getBalance({ address: account.address }); - const maxSendable = maxNativeSendable(balance, estimate.feeAtoms); - if (maxSendable <= 0n) { - throw new Error("Insufficient balance for network fee"); - } - // Clamp when fees moved up since Max / form validation — same pattern as - // MetaMask refreshing Max against the fee used on the submitted tx. - const sendValue = value > maxSendable ? maxSendable : value; - return { - value: sendValue, - gas: NATIVE_TRANSFER_GAS, - maxFeePerGas: estimate.gasPrice, - maxPriorityFeePerGas: estimate.maxPriorityFeePerGas, - }; - } - - async sendViaRelayer(args: { - chainId: EVMChainId; - work: ITransactionWork | ITransactionWork[]; - paymentToken: EVMAccountAddress; - feeAtoms: TokenAmount; - paymentChainId?: EVMChainId; - authorizationList?: IRelayerAuthorizationEntry[]; - relayerUrl: string; - prefetchRelayerVaultAssertion?: boolean; - retainDisplayDuringSubmit?: boolean; - onAwaitingConfirmation?: () => void; - onFinalFeeRequired?: (fee: IFinalRelayerFee) => Promise; - }): Promise { - const paymentChainId = args.paymentChainId ?? args.chainId; - if (paymentChainId !== args.chainId) { - return this.sendViaRelayerCrossChain({ - ...args, - paymentChainId, - }); - } - - const { - chainId, - paymentToken, - relayerUrl, - 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 signer = await this.options.owsProvider.getSigner(); - const eoa = - signer.getCachedAddress?.() ?? - loadCachedEvmAddress() ?? - (await signer.evm.getAccountAddress()); - - const needsUpgrade = - !authorizationList?.length && - (await this.needsWalletUpgrade(chainId, eoa)); - - console.debug("[business/TransactionUtils] sendViaRelayer", { - chainId, - eoa, - needsUpgrade, - presuppliedAuth: Boolean(authorizationList?.length), - }); - - await this.options.owsProvider.ensureDisplay(); - try { - const delegationSecret = await loadOrCreateDelegationBinding(); - // Bind the LocalAccount to the same EOA used for upgrade checks / nonce / - // smartAccount — do not re-resolve address inside getViemAccount. - const viemAccount = await this.getViemAccount(eoa); - const publicClient = this.options.blockchain.getPublicClient(chainId); - const chainIdNumber = Number(BigInt(chainId)); - - const smartAccount = await toMetaMaskSmartAccount({ - client: publicClient as never, - implementation: Implementation.Stateless7702, - address: eoa, - signer: { account: viemAccount }, - }); - - const capabilities = await this.options.relayerRepository.getCapabilities( - relayerUrl, - chainId, - ); - - // Prefetch EIP-7702 inputs before the coalesced ceremony. A nonce RPC - // inside Promise.all lets fee/work start a signer Confirm first; the - // later auth RPC then cancels it (`ceremonyCancelled`). - let upgradeNonce: number | undefined; - let upgradeContract: `0x${string}` | undefined; - if (needsUpgrade) { - upgradeContract = STATELESS_DELEGATOR_IMPL; - try { - const env = getSmartAccountsEnvironment(chainIdNumber); - upgradeContract = getAddress( - env.implementations.EIP7702StatelessDeleGatorImpl, - ); - } catch { - // keep hardcoded fallback - } - upgradeNonce = await publicClient.getTransactionCount({ - address: getAddress(eoa), - blockTag: "pending", - }); - } - - const feeCalldata = HexStringCompat( - encodeFunctionData({ - abi: erc20Abi, - functionName: "transfer", - args: [capabilities.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, - ); - }; - } - - // One passkey: optional EIP-7702 auth + fee + each work delegation - // (+ relayer vault auth when prefetchRelayerVaultAssertion). - const signed = await withCeremonyUiReason( - EPasskeyPromptReason.ApproveTransaction, - () => - withCoalescedSignDigest( - signer, - approveCopy, - async () => { - const [authEntry, feeDelegation, ...workDelegations] = - await Promise.all([ - needsUpgrade - ? this.signWalletUpgradeAuthorizationInner(chainId, { - account: viemAccount, - nonce: upgradeNonce, - contractAddress: upgradeContract, - }) - : Promise.resolve(undefined), - this.createAndSignExactCalldataDelegation({ - smartAccount, - delegate: capabilities.targetAddress, - target: paymentToken, - value: 0n, - callData: feeCalldata, - chainIdNumber, - }), - ...workItems.map((item) => - this.createAndSignExactCalldataDelegation({ - smartAccount, - delegate: capabilities.targetAddress, - target: item.to, - value: item.value ?? 0n, - callData: (item.data || "0x") as Hex, - chainIdNumber, - }), - ), - ]); - 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, - context?: string, - ): IRelayer7710Params => { - const feeData = HexStringCompat( - encodeFunctionData({ - abi: erc20Abi, - functionName: "transfer", - args: [capabilities.feeCollector, feeAmount], - }), - ); - const destinationUrl = styleController.get().destinationUrl; - return { - chainId: chainIdNumber.toString(10), - transactions: [ - { - permissionContext: [toRelayerJson(feeSig)], - executions: [ - { - target: paymentToken, - value: "0", - data: feeData 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, - }, - ], - }; - }), - ], - ...(authorizationList?.length - ? { authorizationList } - : {}), - ...(context ? { context } : {}), - memo: buildMemo( - eoa, - this.options.presentationTransactionUtils.resolveHostDomain(), - ), - delegationSecret, - ...(destinationUrl ? { destinationUrl } : {}), - }; - }; - - let params = buildParams(feeDelegation, feeAtoms); - console.debug( - "[business/TransactionUtils] relayer_estimate7710Transaction", - { - chainId, - hasAuthorizationList: Boolean(authorizationList?.length), - authorizationChainId: authorizationList?.[0]?.chainId, - authorizationNonce: authorizationList?.[0]?.nonce, - authorizationAddress: authorizationList?.[0]?.address, - }, - ); - let estimate = - await this.options.relayerRepository.estimate7710Transaction( - relayerUrl, - params, - ); - - if ( - estimate.success && - estimate.requiredPaymentAmount && - tokenAmountFromAtomString(estimate.requiredPaymentAmount) > feeAtoms - ) { - feeAtoms = tokenAmountFromAtomString(estimate.requiredPaymentAmount); - const paymentTokenMeta = capabilities.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: [capabilities.feeCollector, feeAtoms], - }), - ); - const adjustCopy = adjustFeeCeremony(); - feeDelegation = await withCeremonyUiReason( - EPasskeyPromptReason.AdjustFee, - () => - withCoalescedSignDigest(signer, adjustCopy, () => - this.createAndSignExactCalldataDelegation({ - smartAccount, - delegate: capabilities.targetAddress, - target: paymentToken, - value: 0n, - callData: nextFeeCalldata, - chainIdNumber, - }), - ), - ); - params = buildParams(feeDelegation, feeAtoms); - // Keep estimate₁ context + requiredPaymentAmount. A second estimate would - // mint a new quote while leaving feeAtoms at required₁ — payment/context - // mismatch under rising gas. Match the UI fee-bump path (no re-estimate). - } - // If signed required ≤ quoted feeAtoms, keep the already-signed fee - // ExactCalldata (slight overpay is fine; no AdjustFee ceremony). - - if (!estimate.success) { - throw new Error( - estimate.error ?? "relayer_estimate7710Transaction failed", - ); - } - - // Last passkey is done — collapse the flyout while submit/poll run. - if (!retainDisplayDuringSubmit) { - await this.options.owsProvider.hideDisplay(); - } else { - onAwaitingConfirmation?.(); - } - - params = buildParams(feeDelegation, feeAtoms, estimate.context); - console.debug( - "[business/TransactionUtils] relayer_send7710Transaction", - { - chainId, - hasAuthorizationList: Boolean(authorizationList?.length), - authorizationChainId: authorizationList?.[0]?.chainId, - authorizationNonce: authorizationList?.[0]?.nonce, - }, - ); - const taskId = await this.options.relayerRepository.send7710Transaction( - relayerUrl, - params, - ); - - try { - const hash = await this.pollUntilTerminal(relayerUrl, taskId); - // Only cache "upgraded" after the type-4 tx confirms on-chain. - if (authorizationList?.length) { - await this.options.chainRepository.setWalletUpgraded( - chainId, - eoa, - true, - ); - } - return { - relayerTransactionId: taskId, - transactionHash: hash, - }; - } catch (pollError) { - // Auth may or may not have landed; force a fresh getCode next time. - if (authorizationList?.length) { - await this.options.chainRepository.setWalletUpgraded( - chainId, - eoa, - false, - ); - } - throw pollError; - } - } catch (error) { - // Host-initiated sends collapse the flyout on failure; in-wallet flows - // (TransferTokensModal, cancel) keep the open display. - if (!retainDisplayDuringSubmit) { - await this.options.owsProvider.hideDisplay(); - } - throw error; - } - } - - /** - * 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 = paymentChainId; - const executionKey = 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 { - const { smartAccount, delegate, target, value, callData } = args; - const salt = randomSalt32(); - const selector = methodSelector(callData); - - return createDelegation({ - to: getAddress(delegate), - from: smartAccount.address, - environment: smartAccount.environment, - salt, - scope: { - type: ScopeType.FunctionCall, - targets: [getAddress(target)], - selectors: [selector], - exactCalldata: { calldata: callData }, - valueLte: { maxValue: value }, - }, - }); - } - - /** - * 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 { - const delegation = this.createExactCalldataDelegation(args); - return { - ...delegation, - signature: PLACEHOLDER_DELEGATION_SIGNATURE_65_ZERO, - }; - } - - private createUnsignedActivationNoOpDelegation( - args: ActivationNoOpDelegationArgs, - ): unknown { - const delegation = this.createActivationNoOpDelegation(args); - return { - ...delegation, - signature: PLACEHOLDER_DELEGATION_SIGNATURE_65_ZERO, - }; - } - - private async createAndSignExactCalldataDelegation( - args: ExactCalldataDelegationArgs, - ): Promise { - const { smartAccount } = args; - const delegation = this.createExactCalldataDelegation(args); - - // Callers must already have the flyout open (SignHelper.withDisplay for - // eth_sendTransaction, plus sendViaRelayer.ensureDisplay for size). Do not - // call ensureDisplay here: parallel requestDisplay awaits stagger the two - // signDelegation → signDigest paths and the second signer RPC cancels the - // first Confirm UI (`ceremonyCancelled`). withCeremonyUiReason only sets - // Confirm copy — it does not open/close display and awaits this method. - const signature = await smartAccount.signDelegation({ delegation }); - 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 { - const signer = await this.options.owsProvider.getSigner(); - const address = - addressOverride ?? - signer.getCachedAddress?.() ?? - loadCachedEvmAddress() ?? - undefined; - const publicKey = - signer.getLastPublicKeyData?.()?.secp256k1PublicKey ?? - loadCachedSecp256k1PublicKey() ?? - undefined; - const account = await toViemLocalAccount(signer, { - ...(address ? { address } : {}), - ...(publicKey ? { publicKey } : {}), - }); - return account; - } - - private async pollUntilTerminal( - relayerUrl: string, - taskId: RelayerTransactionId, - ): Promise { - let lastHash: EVMTransactionHash | undefined; - - for (let i = 0; i < MAX_POLL_ATTEMPTS; i += 1) { - const status = await this.options.relayerRepository.getStatus( - relayerUrl, - taskId, - ); - // 110: top-level `hash`; 200: `receipt.transactionHash` (mapped in getStatus). - if (status.hash) { - lastHash = status.hash; - } - - if (status.status === 200) { - if (status.hash) return status.hash; - if (lastHash) return lastHash; - throw new Error( - "Relayer reported confirmed (200) without a transaction hash", - ); - } - if (status.status === 400 || status.status === 500) { - throw new Error( - status.message ?? `Relayer task failed with status ${status.status}`, - ); - } - await sleep(POLL_MS); - } - - if (lastHash) { - return lastHash; - } - throw new Error("Timed out waiting for relayer transaction status"); - } - - private async readCodeUpgradeStatus( - chainId: EVMChainId, - address: EVMAccountAddress, - ): Promise { - const client = this.options.blockchain.getPublicClient(chainId); - const code = await client.getCode({ address }); - if (!code || code === "0x") { - return { upgraded: false }; - } - - let impl = STATELESS_DELEGATOR_IMPL.toLowerCase(); - try { - const env = getSmartAccountsEnvironment(Number(BigInt(chainId))); - impl = env.implementations.EIP7702StatelessDeleGatorImpl.toLowerCase(); - } catch { - // keep hardcoded fallback - } - - const normalized = code.toLowerCase(); - // EIP-7702 designator only: 0xef0100 || implementation address. - // 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 { upgraded: false }; - } - const delegated = `0x${normalized.slice(8, 48)}`; - if (delegated !== impl) { - return { upgraded: false }; - } - return { - upgraded: true, - codeAddress: EVMContractAddress(getAddress(delegated)), - }; - } - - private async requireRelayerChain(chainId: EVMChainId) { - const chain = await this.options.chainRepository.get(chainId); - if (!chain) { - throw new Error(`Unsupported chain: ${chainId}`); - } - if (!chain.useRelayer) { - throw new Error(`Chain ${chainId} does not support the 1Shot relayer`); - } - return chain; - } - - /** - * 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, - ); - - 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], - ); - } - - /** - * 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: IRelayerPayment; - 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 = chainId === payment.paymentChainId; - const needsUpgrade = upgradeChainIds.some((id) => - id === 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.createUnsignedActivationNoOpDelegation({ - smartAccount, - delegate: capabilities.targetAddress, - }); - transactions.push({ - permissionContext: [toRelayerJson(workDelegation)], - executions: [ - { - target: ACTIVATION_NOOP_TARGET, - 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 { - const prompts = styleController.get().copy.passkeyPrompt; - return { - explanationHeader: prompts.approveTransaction.title, - explanationText: includeUpgrade - ? `${prompts.approveTransaction.body} This includes a one-time wallet upgrade authorization.` - : prompts.approveTransaction.body, - }; -} - -function adjustFeeCeremony(): CeremonyUiParams { - const prompts = styleController.get().copy.passkeyPrompt; - return { - explanationHeader: prompts.adjustFee.title, - explanationText: prompts.adjustFee.body, - }; -} - -function shouldUseActivationMultichain( - upgradeChainIds: readonly EVMChainId[], - paymentChainId: EVMChainId, -): boolean { - if (upgradeChainIds.length !== 1) return true; - return upgradeChainIds[0]! !== paymentChainId; -} - -/** Fee/payment chain first, then remaining upgrade chains. */ -function orderedActivationChainIds( - upgradeChainIds: readonly EVMChainId[], - paymentChainId: EVMChainId, -): EVMChainId[] { - const ordered: EVMChainId[] = [paymentChainId]; - const seen = new Set([paymentChainId]); - for (const chainId of upgradeChainIds) { - if (seen.has(chainId)) continue; - seen.add(chainId); - ordered.push(chainId); - } - return ordered; -} - -function methodSelector(callData: Hex): Hex { - if (callData.length >= 10) { - return callData.slice(0, 10) as Hex; - } - // FunctionCall + AllowedMethodsEnforcer needs ≥4 calldata bytes. Empty - // activation work must use NativeTokenTransferAmount instead (see - // createActivationNoOpDelegation). This fallback is only a last resort. - return "0x00000000"; -} - -function randomSalt32(): Hex { - const bytes = crypto.getRandomValues(new Uint8Array(32)); - return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}` as Hex; -} - -let cachedDelegationBinding: string | undefined; - -/** - * Stable per-browser binding value for `relayer_send7710Transaction`. - * Kept out of localStorage/sessionStorage (XSS-readable by default scrapers); - * IndexedDB + in-memory cache. Migrates the legacy localStorage key once. - */ -async function loadOrCreateDelegationBinding(): Promise { - if (cachedDelegationBinding && cachedDelegationBinding.length >= 10) { - return cachedDelegationBinding; - } - - try { - const legacy = localStorage.getItem(LEGACY_DELEGATION_SECRET_KEY); - if (legacy && legacy.length >= 10) { - await idbSetString(DELEGATION_BINDING_IDB_KEY, legacy); - localStorage.removeItem(LEGACY_DELEGATION_SECRET_KEY); - cachedDelegationBinding = legacy; - return legacy; - } - } catch { - // localStorage may be unavailable - } - - try { - const existing = await idbGetString(DELEGATION_BINDING_IDB_KEY); - if (existing && existing.length >= 10) { - cachedDelegationBinding = existing; - return existing; - } - const next = crypto.randomUUID(); - await idbSetString(DELEGATION_BINDING_IDB_KEY, next); - cachedDelegationBinding = next; - return next; - } catch { - const fallback = crypto.randomUUID(); - cachedDelegationBinding = fallback; - return fallback; - } -} - -function buildMemo(wallet: EVMAccountAddress, host: string): string { - const memo = JSON.stringify({ wallet: String(wallet), host }); - return memo.length <= 256 ? memo : memo.slice(0, 256); -} - -function toRelayerJson(value: unknown): unknown { - if (value === null || value === undefined) return value; - if (typeof value === "bigint") return `0x${value.toString(16)}`; - if (value instanceof Uint8Array) { - return `0x${Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("")}`; - } - if (Array.isArray(value)) return value.map(toRelayerJson); - if (typeof value === "object") { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = toRelayerJson(v); - } - return out; - } - return value; -} - -function HexStringCompat(value: string): Hex { - return value as Hex; -} - -function yParityFromSignedAuthorization(signed: { - yParity?: number | undefined; - v?: bigint | number | undefined; -}): 0 | 1 { - if (signed.yParity === 0 || signed.yParity === 1) { - return signed.yParity; - } - if (signed.v !== undefined) { - const v = Number(signed.v); - if (v === 0 || v === 1) return v; - if (v === 27 || v === 28) return (v - 27) as 0 | 1; - } - throw new Error( - "EIP-7702 authorization missing yParity (relayer requires 0|1)", - ); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +import { + createDelegation, + getSmartAccountsEnvironment, + Implementation, + ScopeType, + toMetaMaskSmartAccount, +} from "@metamask/smart-accounts-kit"; +import { toViemLocalAccount } from "@1shotapi/ows-signer-utils"; +import type { IBlockchainProvider } from "@1shotapi/ows-wallet-utils"; +import { + EVMAccountAddress, + EVMContractAddress, + EVMTransactionHash, + type CeremonyUiParams, + type EVMChainId, + type HexString, + type RelayerTransactionId, +} from "@1shotapi/ows-types"; +import { + encodeFunctionData, + erc20Abi, + formatUnits, + getAddress, + parseUnits, + type Hex, +} from "viem"; +import { recoverAuthorizationAddress } from "viem/utils"; +import type { LocalAccount } from "viem/accounts"; +import type { IChainRepository } from "../../../interfaces/data/IChainRepository"; +import type { IDelegationRepository } from "../../../interfaces/data/IDelegationRepository"; +import type { + IOneshotRelayerRepository, + IRelayer7710Params, + IRelayerAuthorizationEntry, + ISendTransactionResult, +} from "../../../interfaces/data/IOneshotRelayerRepository"; +import type { ITrackedAssetRepository } from "../../../interfaces/data/ITrackedAssetRepository"; +import type { + IPaymentQuote, + IPaymentTokenOption, + ITransactionWork, +} from "../../../interfaces/business/ITransactionService"; +import { + NATIVE_TRANSFER_GAS, + maxNativeSendable, + withNativeFeeHeadroom, + type ITransactionUtils, +} from "../../../interfaces/business/utils/ITransactionUtils"; +import type { ITransactionUtils as IPresentationTransactionUtils } from "../../../interfaces/utils/ITransactionUtils"; +import type { IOWSProvider } from "../../../interfaces/utils/IOWSProvider"; +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 { + makeTokenAmount, + tokenAmountFromAtomString, + type TokenAmount, +} from "../../../types/primitives"; +import { idbGetString, idbSetString } from "../../../utils/idbStringStore"; +import { withCeremonyUiReason } from "../../../../wallet/ceremonyUiOverrideStore"; +import { withCoalescedSignDigest } from "../../../../wallet/withCoalescedSignDigest"; +import type { CoalesceSignDigestOptions } from "../../../../wallet/withCoalescedSignDigest"; +import { + loadCachedEvmAddress, + loadCachedSecp256k1PublicKey, +} from "../../../../storage"; +import { styleController } from "../../../../style/styleController"; +// Ensure Arc mainnet Smart Accounts env is registered before any kit lookups. +import "../../utils/registerSmartAccountsEnvironments"; + +const STATELESS_DELEGATOR_IMPL = + EVMContractAddress("0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B"); + +/** + * 65-byte zero signature for `relayer_estimate7710Transaction` unsigned + * estimates. Valid ECDSA length so the DelegatorEstimateShim's + * ECDSA.tryRecover pays ecrecover precompile gas (prefer over bytes32(0)). + */ +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; +/** 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>; + delegate: EVMAccountAddress; + target: EVMAccountAddress | EVMContractAddress; + value: bigint; + callData: Hex; + 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; + trackedAssetRepository: ITrackedAssetRepository; + paymentTokenUtils: IPaymentTokenUtils; + blockchain: IBlockchainProvider; + /** Presentation helpers (host domain for relayer memo). */ + presentationTransactionUtils: IPresentationTransactionUtils; + owsProvider: IOWSProvider; + delegationRepository: IDelegationRepository; +}; + +/** + * Shared EIP-7702 / ExactCalldata / public-relayer submit plumbing used by + * TransactionService and DelegationService. + */ +export class TransactionUtils implements ITransactionUtils { + constructor(private readonly options: TransactionUtilsOptions) {} + + async needsWalletUpgrade( + chainId: EVMChainId, + address: EVMAccountAddress, + ): Promise { + // Always verify on-chain. localStorage may still record the last known + // status, but must not skip EIP-7702 auth: a cached `true` written after + // send (before confirm) or after a failed upgrade leaves the account + // unable to estimate on that chain. + try { + const status = await this.getWalletUpgradeStatus(chainId, address); + console.debug("[business/TransactionUtils] EIP-7702 upgrade check", { + chainId, + address, + upgraded: status.upgraded, + codeAddress: status.codeAddress, + needsUpgrade: !status.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). + console.warn( + "[business/TransactionUtils] getCode failed; assuming EIP-7702 upgrade required", + { chainId, address, error }, + ); + return true; + } + } + + 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 { + await this.options.owsProvider.ensureDisplay(); + return withCeremonyUiReason(EPasskeyPromptReason.WalletUpgrade, () => + this.signWalletUpgradeAuthorizationInner(chainId), + ); + } + + private async signWalletUpgradeAuthorizationInner( + chainId: EVMChainId, + options?: { + account?: LocalAccount; + /** Prefetched so signing can share one passkey with fee/work digests. */ + nonce?: number; + contractAddress?: EVMContractAddress; + }, + ): Promise { + const account = options?.account ?? (await this.getViemAccount()); + const chainIdNumber = Number(BigInt(chainId)); + const client = this.options.blockchain.getPublicClient(chainId); + + let contractAddress: EVMContractAddress = + options?.contractAddress ?? STATELESS_DELEGATOR_IMPL; + if (!options?.contractAddress) { + try { + const env = getSmartAccountsEnvironment(chainIdNumber); + contractAddress = EVMContractAddress(getAddress( + env.implementations.EIP7702StatelessDeleGatorImpl, + )); + } catch { + // Fall back to the known Stateless7702 implementation address. + } + } + + const nonce = + options?.nonce ?? + (await client.getTransactionCount({ + address: account.address, + blockTag: "pending", + })); + + if (!account.signAuthorization) { + throw new Error("Signer does not support EIP-7702 signAuthorization"); + } + + const signed = await account.signAuthorization({ + chainId: chainIdNumber, + contractAddress, + nonce, + }); + + const yParity = yParityFromSignedAuthorization(signed); + const entry: IRelayerAuthorizationEntry = { + address: getAddress(signed.address), + chainId: Number(signed.chainId), + nonce: Number(signed.nonce), + r: signed.r as `0x${string}`, + s: signed.s as `0x${string}`, + yParity, + }; + + // Verify the auth list entry recovers to this EOA before sending to the relayer. + const recovered = await recoverAuthorizationAddress({ + authorization: { + address: entry.address, + chainId: entry.chainId, + nonce: entry.nonce, + r: entry.r, + s: entry.s, + yParity: entry.yParity as 0 | 1, + }, + }); + if (getAddress(recovered) !== getAddress(account.address)) { + throw new Error( + `EIP-7702 authorization recovers to ${recovered}, expected ${account.address}`, + ); + } + console.debug("[business/TransactionUtils] EIP-7702 authorization verified", { + eoa: account.address, + contractAddress: entry.address, + chainId: entry.chainId, + nonce: entry.nonce, + yParity: entry.yParity, + r: entry.r, + s: entry.s, + recovered, + }); + + return entry; + } + + async quotePayment( + chainId: EVMChainId, + owner: EVMAccountAddress, + work: ITransactionWork | ITransactionWork[], + preferredToken?: EVMContractAddress, + ): Promise { + const workItems = Array.isArray(work) ? work : [work]; + if (workItems.length === 0) { + throw new Error("quotePayment requires at least one work item"); + } + + const payment = await this.options.paymentTokenUtils.resolvePayment( + owner, + [chainId], + preferredToken, + ); + if (!payment) { + throw new Error("No relayer payment token with a positive balance"); + } + + const tokens = await this.options.paymentTokenUtils.listPaymentOptions( + owner, + [chainId], + ); + + // Trust resolvePayment for chain+token (including preferredToken). Do not + // re-match preferred by address alone — that can pick the same address on + // a different chain and rewrite paymentChainId. + const selected = + tokens.find( + (t) => + t.chainId === payment.paymentChainId && + t.address === payment.paymentToken, + ) ?? null; + if (!selected || selected.balance <= 0n) { + throw new Error("No relayer payment token with a positive balance"); + } + + const resolvedPayment: IRelayerPayment = { + paymentChainId: selected.chainId, + paymentToken: selected.address, + paymentChainName: selected.chainName, + balance: selected.balance, + decimals: selected.decimals, + symbol: selected.symbol, + }; + + const paymentChain = await this.requireRelayerChain( + resolvedPayment.paymentChainId, + ); + const paymentCapabilities = + await this.options.relayerRepository.getCapabilities( + paymentChain.relayerUrl, + resolvedPayment.paymentChainId, + ); + + const seedFeeAtoms = makeTokenAmount( + parseUnits("0.01", selected.decimals), + ); + const crossChain = resolvedPayment.paymentChainId !== chainId; + + 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: [paymentCapabilities.feeCollector, seedFeeAtoms], + }), + ); + + const feeDelegation = this.createUnsignedExactCalldataDelegation({ + smartAccount, + 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])], + 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, + paymentChainId: resolvedPayment.paymentChainId, + paymentToken: selected.address, + workCount: workItems.length, + crossChain: false, + }, + ); + + estimate = await this.options.relayerRepository.estimate7710Transaction( + paymentChain.relayerUrl, + params, + ); + } else { + estimate = await this.quotePaymentCrossChain({ + owner, + executionChainId: chainId, + payment: resolvedPayment, + workItems, + seedFeeAtoms, + paymentCapabilities, + }); + } + + if (!estimate.success || !estimate.requiredPaymentAmount) { + throw new Error( + estimate.error ?? "relayer_estimate7710Transaction failed", + ); + } + + const feeAtoms = tokenAmountFromAtomString(estimate.requiredPaymentAmount); + + return { + tokens, + selectedToken: selected.address, + paymentChainId: resolvedPayment.paymentChainId, + paymentChainName: resolvedPayment.paymentChainName, + feeAtoms, + feeFormatted: formatUnits(feeAtoms, selected.decimals), + feeCollector: paymentCapabilities.feeCollector, + targetAddress: paymentCapabilities.targetAddress, + minFee: feeAtoms, + }; + } + + async quoteActivation( + owner: EVMAccountAddress, + upgradeChainIds: readonly EVMChainId[], + payment: IRelayerPayment, + ): 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.decimals)), + 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.symbol, + decimals: payment.decimals, + balance: payment.balance, + chainId: payment.paymentChainId, + chainName: payment.paymentChainName, + }; + + return { + tokens: [tokenOption], + selectedToken: payment.paymentToken, + paymentChainId: payment.paymentChainId, + paymentChainName: payment.paymentChainName, + feeAtoms, + feeFormatted: formatUnits(feeAtoms, payment.decimals), + feeCollector: capabilities.feeCollector, + targetAddress: capabilities.targetAddress, + minFee: feeAtoms, + }; + } + + async quotePaymentMultichain( + owner: EVMAccountAddress, + workByChain: readonly { + chainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + }[], + preferredToken?: EVMContractAddress, + ): Promise { + const groups = normalizeWorkByChain(workByChain); + if (groups.length === 0) { + throw new Error("quotePaymentMultichain requires at least one work item"); + } + + const executionChainIds = groups.map((g) => g.chainId); + if ( + groups.length === 1 && + executionChainIds[0] !== undefined + ) { + return this.quotePayment( + executionChainIds[0], + owner, + groups[0]!.work, + preferredToken, + ); + } + + const payment = await this.options.paymentTokenUtils.resolvePayment( + owner, + executionChainIds, + preferredToken, + ); + if (!payment) { + throw new Error("No relayer payment token with a positive balance"); + } + + const tokens = await this.options.paymentTokenUtils.listPaymentOptions( + owner, + executionChainIds, + ); + + // Trust resolvePayment for chain+token (including preferredToken). Do not + // re-match preferred by address alone — that can pick the same address on + // a different chain and rewrite paymentChainId. + const selected = + tokens.find( + (t) => + t.chainId === payment.paymentChainId && + t.address === payment.paymentToken, + ) ?? null; + if (!selected || selected.balance <= 0n) { + throw new Error("No relayer payment token with a positive balance"); + } + + const resolvedPayment: IRelayerPayment = { + paymentChainId: selected.chainId, + paymentToken: selected.address, + paymentChainName: selected.chainName, + balance: selected.balance, + decimals: selected.decimals, + symbol: selected.symbol, + }; + + const paymentChain = await this.requireRelayerChain( + resolvedPayment.paymentChainId, + ); + const paymentCapabilities = + await this.options.relayerRepository.getCapabilities( + paymentChain.relayerUrl, + resolvedPayment.paymentChainId, + ); + + const seedFeeAtoms = makeTokenAmount( + parseUnits("0.01", selected.decimals), + ); + + const estimate = await this.quotePaymentWorkMultichain({ + owner, + payment: resolvedPayment, + groups, + seedFeeAtoms, + paymentCapabilities, + }); + + if (!estimate.success || !estimate.requiredPaymentAmount) { + throw new Error( + estimate.error ?? "relayer_estimate7710TransactionMultichain failed", + ); + } + + const feeAtoms = tokenAmountFromAtomString(estimate.requiredPaymentAmount); + + return { + tokens, + selectedToken: selected.address, + paymentChainId: resolvedPayment.paymentChainId, + paymentChainName: resolvedPayment.paymentChainName, + feeAtoms, + feeFormatted: formatUnits(feeAtoms, selected.decimals), + feeCollector: paymentCapabilities.feeCollector, + targetAddress: paymentCapabilities.targetAddress, + minFee: feeAtoms, + }; + } + + async activateDelegations(args: { + upgradeChainIds: readonly EVMChainId[]; + payment: IRelayerPayment; + 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 = STATELESS_DELEGATOR_IMPL; + try { + const env = getSmartAccountsEnvironment(chainIdNumber); + contractAddress = EVMContractAddress(getAddress( + env.implementations.EIP7702StatelessDeleGatorImpl, + )); + } catch { + // keep hardcoded fallback + } + const nonce = await client.getTransactionCount({ + address: 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( + payment.paymentChainId, + paymentSmartAccount, + ); + upgradeCapabilities.set( + payment.paymentChainId, + paymentCapabilities, + ); + const missingUpgradeIds = upgradeChainIds.filter( + (chainId) => !chainSmartAccounts.has(chainId), + ); + await Promise.all( + missingUpgradeIds.map(async (chainId) => { + const key = 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( + chainId, + ); + const caps = upgradeCapabilities.get(chainId); + if (!smartAccount || !caps) { + throw new Error( + `Missing smart account or capabilities for ${chainId}`, + ); + } + return this.createAndSignActivationNoOpDelegation({ + smartAccount, + delegate: caps.targetAddress, + }); + }), + ), + ]); + return { authEntries, feeDelegation, workDelegations }; + }, + { minCalls } satisfies CoalesceSignDigestOptions, + ), + ); + + const authByChain = new Map(); + for (let i = 0; i < upgradeChainIds.length; i += 1) { + authByChain.set( + upgradeChainIds[i]!, + signed.authEntries[i]!, + ); + } + let feeDelegation = signed.feeDelegation; + const workByChain = new Map(); + for (let i = 0; i < upgradeChainIds.length; i += 1) { + workByChain.set( + 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 = chainId === payment.paymentChainId; + const needsUpgrade = upgradeChainIds.some((id) => + id === chainId, + ); + const chainKey = chainId; + 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(chainKey); + if (!workSig) { + throw new Error( + `Missing work delegation for upgrade chain ${chainId}`, + ); + } + transactions.push({ + permissionContext: [toRelayerJson(workSig)], + executions: [ + { + target: ACTIVATION_NOOP_TARGET, + 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(chainKey); + const context = contexts?.[chainKey]; + return { + chainId: chainKey, + 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.decimals), + 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 + ? { + [payment.paymentChainId]: 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) => id === 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 sendViaRelayerMultichain(args: { + workByChain: readonly { + chainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + }[]; + paymentToken: EVMContractAddress; + feeAtoms: TokenAmount; + paymentChainId: EVMChainId; + prefetchRelayerVaultAssertion?: boolean; + retainDisplayDuringSubmit?: boolean; + onAwaitingConfirmation?: () => void; + onFinalFeeRequired?: (fee: IFinalRelayerFee) => Promise; + }): Promise { + const groups = normalizeWorkByChain(args.workByChain); + if (groups.length === 0) { + throw new Error("sendViaRelayerMultichain requires at least one work item"); + } + + const { + paymentToken, + paymentChainId, + onAwaitingConfirmation, + onFinalFeeRequired, + retainDisplayDuringSubmit, + } = args; + let feeAtoms: TokenAmount = args.feeAtoms; + + const workChainIds = groups.map((g) => g.chainId); + if ( + groups.length === 1 && + workChainIds[0] === paymentChainId + ) { + const chain = await this.requireRelayerChain(paymentChainId); + const single = await this.sendViaRelayer({ + chainId: paymentChainId, + work: groups[0]!.work, + paymentToken, + feeAtoms, + paymentChainId, + relayerUrl: chain.relayerUrl, + prefetchRelayerVaultAssertion: args.prefetchRelayerVaultAssertion, + retainDisplayDuringSubmit, + onAwaitingConfirmation, + onFinalFeeRequired, + }); + return [single]; + } + + const paymentChain = await this.requireRelayerChain(paymentChainId); + const useMultichain = shouldUseActivationMultichain( + workChainIds, + 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(), + ); + + const paymentCapabilities = + await this.options.relayerRepository.getCapabilities( + paymentChain.relayerUrl, + paymentChainId, + ); + const paymentChainIdNumber = Number(BigInt(paymentChainId)); + const paymentClient = + this.options.blockchain.getPublicClient(paymentChainId); + const paymentSmartAccount = await toMetaMaskSmartAccount({ + client: paymentClient as never, + implementation: Implementation.Stateless7702, + address: eoa, + signer: { account: viemAccount }, + }); + + const chainSmartAccounts = new Map< + EVMChainId, + Awaited> + >(); + const chainCapabilities = new Map< + EVMChainId, + Awaited> + >(); + chainSmartAccounts.set(paymentChainId, paymentSmartAccount); + chainCapabilities.set(paymentChainId, paymentCapabilities); + + await Promise.all( + workChainIds.map(async (chainId) => { + if (chainSmartAccounts.has(chainId)) return; + const chain = await this.requireRelayerChain(chainId); + const caps = await this.options.relayerRepository.getCapabilities( + chain.relayerUrl, + chainId, + ); + chainCapabilities.set(chainId, 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(chainId, smartAccount); + }), + ); + + const upgradePrep = await Promise.all( + workChainIds.map(async (chainId) => { + const needsUpgrade = await this.needsWalletUpgrade(chainId, eoa); + if (!needsUpgrade) { + return { chainId, needsUpgrade: false as const }; + } + const chainIdNumber = Number(BigInt(chainId)); + const client = this.options.blockchain.getPublicClient(chainId); + let contractAddress = STATELESS_DELEGATOR_IMPL; + try { + const env = getSmartAccountsEnvironment(chainIdNumber); + contractAddress = EVMContractAddress(getAddress( + env.implementations.EIP7702StatelessDeleGatorImpl, + )); + } catch { + // keep hardcoded fallback + } + const nonce = await client.getTransactionCount({ + address: getAddress(eoa), + blockTag: "pending", + }); + return { + chainId, + needsUpgrade: true as const, + chainIdNumber, + contractAddress, + nonce, + }; + }), + ); + + const feeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, feeAtoms], + }), + ); + + const upgradeCount = upgradePrep.filter((p) => p.needsUpgrade).length; + const workCount = groups.reduce((n, g) => n + g.work.length, 0); + const approveCopy = approveTransactionCeremony(upgradeCount > 0); + const minCalls = upgradeCount + 1 + workCount; + 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 authEntries = await Promise.all( + upgradePrep.map((prep) => + prep.needsUpgrade + ? this.signWalletUpgradeAuthorizationInner(prep.chainId, { + account: viemAccount, + nonce: prep.nonce, + contractAddress: prep.contractAddress, + }) + : Promise.resolve(undefined), + ), + ); + const feeDelegation = + await this.createAndSignExactCalldataDelegation({ + smartAccount: paymentSmartAccount, + delegate: paymentCapabilities.targetAddress, + target: paymentToken, + value: 0n, + callData: feeCalldata, + chainIdNumber: paymentChainIdNumber, + }); + const workDelegationsByChain = new Map(); + await Promise.all( + groups.map(async (group) => { + const smartAccount = chainSmartAccounts.get(group.chainId); + const caps = chainCapabilities.get(group.chainId); + if (!smartAccount || !caps) { + throw new Error( + `Missing smart account or capabilities for ${group.chainId}`, + ); + } + const chainIdNumber = Number(BigInt(group.chainId)); + const workDelegations = await Promise.all( + group.work.map((item) => + this.createAndSignExactCalldataDelegation({ + smartAccount, + delegate: caps.targetAddress, + target: item.to, + value: item.value ?? 0n, + callData: (item.data || "0x") as Hex, + chainIdNumber, + }), + ), + ); + workDelegationsByChain.set(group.chainId, workDelegations); + }), + ); + return { authEntries, feeDelegation, workDelegationsByChain }; + }, + coalesceOptions, + ), + ); + + const authByChain = new Map(); + for (let i = 0; i < workChainIds.length; i += 1) { + const entry = signed.authEntries[i]; + if (entry) authByChain.set(workChainIds[i]!, entry); + } + let feeDelegation = signed.feeDelegation; + const workDelegationsByChain = signed.workDelegationsByChain; + + const buildChainParams = ( + feeAmount: TokenAmount, + contexts?: Record, + ): IRelayer7710Params[] => { + const feeData = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [paymentCapabilities.feeCollector, feeAmount], + }), + ); + const orderedChainIds = orderedActivationChainIds( + workChainIds, + paymentChainId, + ); + return orderedChainIds.map((chainId) => { + const isPayment = chainId === paymentChainId; + const group = groups.find((g) => g.chainId === chainId); + const transactions: IRelayer7710Params["transactions"] = []; + + if (isPayment) { + transactions.push({ + permissionContext: [toRelayerJson(feeDelegation)], + executions: [ + { + target: paymentToken, + value: "0", + data: feeData as HexString, + }, + ], + }); + } + + if (group) { + const workSigs = workDelegationsByChain.get(chainId); + if (!workSigs || workSigs.length !== group.work.length) { + throw new Error( + `Missing work delegations for chain ${chainId}`, + ); + } + for (let i = 0; i < group.work.length; i += 1) { + const item = group.work[i]!; + const value = item.value ?? 0n; + transactions.push({ + permissionContext: [toRelayerJson(workSigs[i])], + executions: [ + { + target: item.to, + value: value === 0n ? "0" : `0x${value.toString(16)}`, + data: (item.data || "0x") as HexString, + }, + ], + }); + } + } + + if (transactions.length === 0) { + throw new Error( + `Multichain params for chain ${chainId} have no transactions`, + ); + } + + const auth = authByChain.get(chainId); + const chainKey = Number(BigInt(chainId)).toString(10); + const context = contexts?.[chainKey]; + return { + chainId: chainKey, + transactions, + ...(auth ? { authorizationList: [auth] } : {}), + ...(context ? { context } : {}), + memo, + delegationSecret, + ...(destinationUrl ? { destinationUrl } : {}), + } satisfies IRelayer7710Params; + }); + }; + + let params = 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, paymentCapabilities.tokens.find( + (t) => + t.address === paymentToken, + )?.decimals ?? 6), + 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 = buildChainParams(feeAtoms); + } + + if (!estimate.success) { + throw new Error( + estimate.error ?? "relayer multichain estimate failed", + ); + } + + if (!retainDisplayDuringSubmit) { + await this.options.owsProvider.hideDisplay(); + } else { + onAwaitingConfirmation?.(); + } + + const paymentChainKey = Number(BigInt(paymentChainId)).toString(10); + const contextByChainId = + estimate.contextByChainId ?? + (estimate.context + ? { [paymentChainKey]: estimate.context } + : undefined); + params = 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( + workChainIds, + paymentChainId, + ); + + try { + const byChain = new Map(); + await Promise.all( + taskIds.map(async (taskId, i) => { + const chainId = orderedChainIds[i]!; + const hash = await this.pollUntilTerminal( + paymentChain.relayerUrl, + taskId, + ); + if (authByChain.has(chainId)) { + await this.options.chainRepository.setWalletUpgraded( + chainId, + eoa, + true, + ); + } + byChain.set(chainId, { + relayerTransactionId: taskId, + transactionHash: hash, + }); + }), + ); + return groups.map((group) => { + const result = byChain.get(group.chainId); + if (!result) { + throw new Error( + `Missing relayer result for work chain ${group.chainId}`, + ); + } + return result; + }); + } catch (pollError) { + await Promise.all( + [...authByChain.keys()].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; + feeAtoms: bigint; + }> { + const client = this.options.blockchain.getPublicClient(chainId); + let maxFeePerGas: bigint; + let maxPriorityFeePerGas = 0n; + try { + const fees = await client.estimateFeesPerGas(); + maxFeePerGas = fees.maxFeePerGas ?? (await client.getGasPrice()); + maxPriorityFeePerGas = fees.maxPriorityFeePerGas ?? 0n; + } catch { + maxFeePerGas = await client.getGasPrice(); + } + // Pin Max / balance checks to a buffered cap so a later prepare that + // re-quotes fees (or base-fee bumps while pending) does not exceed the + // reserved budget. Actual ETH paid is still baseFee + tip ≤ maxFeePerGas. + const gasPrice = withNativeFeeHeadroom(maxFeePerGas); + return { + gasPrice, + maxPriorityFeePerGas, + feeAtoms: gasPrice * NATIVE_TRANSFER_GAS, + }; + } + + async planNativeTransfer( + chainId: EVMChainId, + value: bigint, + ): Promise<{ + value: bigint; + gas: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + }> { + if (value < 0n) { + throw new Error("Native transfer value must be non-negative"); + } + const estimate = await this.estimateNativeTransferFee(chainId); + const account = await this.getViemAccount(); + const client = this.options.blockchain.getPublicClient(chainId); + const balance = await client.getBalance({ address: account.address }); + const maxSendable = maxNativeSendable(balance, estimate.feeAtoms); + if (maxSendable <= 0n) { + throw new Error("Insufficient balance for network fee"); + } + // Clamp when fees moved up since Max / form validation — same pattern as + // MetaMask refreshing Max against the fee used on the submitted tx. + const sendValue = value > maxSendable ? maxSendable : value; + return { + value: sendValue, + gas: NATIVE_TRANSFER_GAS, + maxFeePerGas: estimate.gasPrice, + maxPriorityFeePerGas: estimate.maxPriorityFeePerGas, + }; + } + + async sendViaRelayer(args: { + chainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + paymentToken: EVMContractAddress; + feeAtoms: TokenAmount; + paymentChainId?: EVMChainId; + authorizationList?: IRelayerAuthorizationEntry[]; + relayerUrl: string; + prefetchRelayerVaultAssertion?: boolean; + retainDisplayDuringSubmit?: boolean; + onAwaitingConfirmation?: () => void; + onFinalFeeRequired?: (fee: IFinalRelayerFee) => Promise; + }): Promise { + const paymentChainId = args.paymentChainId ?? args.chainId; + if (paymentChainId !== args.chainId) { + return this.sendViaRelayerCrossChain({ + ...args, + paymentChainId, + }); + } + + const { + chainId, + paymentToken, + relayerUrl, + 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 signer = await this.options.owsProvider.getSigner(); + const eoa = + signer.getCachedAddress?.() ?? + loadCachedEvmAddress() ?? + (await signer.evm.getAccountAddress()); + + const needsUpgrade = + !authorizationList?.length && + (await this.needsWalletUpgrade(chainId, eoa)); + + console.debug("[business/TransactionUtils] sendViaRelayer", { + chainId, + eoa, + needsUpgrade, + presuppliedAuth: Boolean(authorizationList?.length), + }); + + await this.options.owsProvider.ensureDisplay(); + try { + const delegationSecret = await loadOrCreateDelegationBinding(); + // Bind the LocalAccount to the same EOA used for upgrade checks / nonce / + // smartAccount — do not re-resolve address inside getViemAccount. + const viemAccount = await this.getViemAccount(eoa); + const publicClient = this.options.blockchain.getPublicClient(chainId); + const chainIdNumber = Number(BigInt(chainId)); + + const smartAccount = await toMetaMaskSmartAccount({ + client: publicClient as never, + implementation: Implementation.Stateless7702, + address: eoa, + signer: { account: viemAccount }, + }); + + const capabilities = await this.options.relayerRepository.getCapabilities( + relayerUrl, + chainId, + ); + + // Prefetch EIP-7702 inputs before the coalesced ceremony. A nonce RPC + // inside Promise.all lets fee/work start a signer Confirm first; the + // later auth RPC then cancels it (`ceremonyCancelled`). + let upgradeNonce: number | undefined; + let upgradeContract: EVMContractAddress | undefined; + if (needsUpgrade) { + upgradeContract = STATELESS_DELEGATOR_IMPL; + try { + const env = getSmartAccountsEnvironment(chainIdNumber); + upgradeContract = EVMContractAddress(getAddress( + env.implementations.EIP7702StatelessDeleGatorImpl, + )); + } catch { + // keep hardcoded fallback + } + upgradeNonce = await publicClient.getTransactionCount({ + address: getAddress(eoa), + blockTag: "pending", + }); + } + + const feeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [capabilities.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, + ); + }; + } + + // One passkey: optional EIP-7702 auth + fee + each work delegation + // (+ relayer vault auth when prefetchRelayerVaultAssertion). + const signed = await withCeremonyUiReason( + EPasskeyPromptReason.ApproveTransaction, + () => + withCoalescedSignDigest( + signer, + approveCopy, + async () => { + const [authEntry, feeDelegation, ...workDelegations] = + await Promise.all([ + needsUpgrade + ? this.signWalletUpgradeAuthorizationInner(chainId, { + account: viemAccount, + nonce: upgradeNonce, + contractAddress: upgradeContract, + }) + : Promise.resolve(undefined), + this.createAndSignExactCalldataDelegation({ + smartAccount, + delegate: capabilities.targetAddress, + target: paymentToken, + value: 0n, + callData: feeCalldata, + chainIdNumber, + }), + ...workItems.map((item) => + this.createAndSignExactCalldataDelegation({ + smartAccount, + delegate: capabilities.targetAddress, + target: item.to, + value: item.value ?? 0n, + callData: (item.data || "0x") as Hex, + chainIdNumber, + }), + ), + ]); + 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, + context?: string, + ): IRelayer7710Params => { + const feeData = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [capabilities.feeCollector, feeAmount], + }), + ); + const destinationUrl = styleController.get().destinationUrl; + return { + chainId: chainIdNumber.toString(10), + transactions: [ + { + permissionContext: [toRelayerJson(feeSig)], + executions: [ + { + target: paymentToken, + value: "0", + data: feeData 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, + }, + ], + }; + }), + ], + ...(authorizationList?.length + ? { authorizationList } + : {}), + ...(context ? { context } : {}), + memo: buildMemo( + eoa, + this.options.presentationTransactionUtils.resolveHostDomain(), + ), + delegationSecret, + ...(destinationUrl ? { destinationUrl } : {}), + }; + }; + + let params = buildParams(feeDelegation, feeAtoms); + console.debug( + "[business/TransactionUtils] relayer_estimate7710Transaction", + { + chainId, + hasAuthorizationList: Boolean(authorizationList?.length), + authorizationChainId: authorizationList?.[0]?.chainId, + authorizationNonce: authorizationList?.[0]?.nonce, + authorizationAddress: authorizationList?.[0]?.address, + }, + ); + let estimate = + await this.options.relayerRepository.estimate7710Transaction( + relayerUrl, + params, + ); + + if ( + estimate.success && + estimate.requiredPaymentAmount && + tokenAmountFromAtomString(estimate.requiredPaymentAmount) > feeAtoms + ) { + feeAtoms = tokenAmountFromAtomString(estimate.requiredPaymentAmount); + const paymentTokenMeta = capabilities.tokens.find( + (token) => + token.address === paymentToken, + ); + const feeDecimals = paymentTokenMeta?.decimals ?? 6; + + if (onFinalFeeRequired) { + await onFinalFeeRequired({ + feeAtoms, + feeFormatted: formatUnits(feeAtoms, feeDecimals), + paymentToken, + }); + } + + const nextFeeCalldata = HexStringCompat( + encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [capabilities.feeCollector, feeAtoms], + }), + ); + const adjustCopy = adjustFeeCeremony(); + feeDelegation = await withCeremonyUiReason( + EPasskeyPromptReason.AdjustFee, + () => + withCoalescedSignDigest(signer, adjustCopy, () => + this.createAndSignExactCalldataDelegation({ + smartAccount, + delegate: capabilities.targetAddress, + target: paymentToken, + value: 0n, + callData: nextFeeCalldata, + chainIdNumber, + }), + ), + ); + params = buildParams(feeDelegation, feeAtoms); + // Keep estimate₁ context + requiredPaymentAmount. A second estimate would + // mint a new quote while leaving feeAtoms at required₁ — payment/context + // mismatch under rising gas. Match the UI fee-bump path (no re-estimate). + } + // If signed required ≤ quoted feeAtoms, keep the already-signed fee + // ExactCalldata (slight overpay is fine; no AdjustFee ceremony). + + if (!estimate.success) { + throw new Error( + estimate.error ?? "relayer_estimate7710Transaction failed", + ); + } + + // Last passkey is done — collapse the flyout while submit/poll run. + if (!retainDisplayDuringSubmit) { + await this.options.owsProvider.hideDisplay(); + } else { + onAwaitingConfirmation?.(); + } + + params = buildParams(feeDelegation, feeAtoms, estimate.context); + console.debug( + "[business/TransactionUtils] relayer_send7710Transaction", + { + chainId, + hasAuthorizationList: Boolean(authorizationList?.length), + authorizationChainId: authorizationList?.[0]?.chainId, + authorizationNonce: authorizationList?.[0]?.nonce, + }, + ); + const taskId = await this.options.relayerRepository.send7710Transaction( + relayerUrl, + params, + ); + + try { + const hash = await this.pollUntilTerminal(relayerUrl, taskId); + // Only cache "upgraded" after the type-4 tx confirms on-chain. + if (authorizationList?.length) { + await this.options.chainRepository.setWalletUpgraded( + chainId, + eoa, + true, + ); + } + return { + relayerTransactionId: taskId, + transactionHash: hash, + }; + } catch (pollError) { + // Auth may or may not have landed; force a fresh getCode next time. + if (authorizationList?.length) { + await this.options.chainRepository.setWalletUpgraded( + chainId, + eoa, + false, + ); + } + throw pollError; + } + } catch (error) { + // Host-initiated sends collapse the flyout on failure; in-wallet flows + // (TransferTokensModal, cancel) keep the open display. + if (!retainDisplayDuringSubmit) { + await this.options.owsProvider.hideDisplay(); + } + throw error; + } + } + + /** + * 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: EVMContractAddress; + 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: EVMContractAddress | undefined; + if (needsUpgrade) { + upgradeContract = STATELESS_DELEGATOR_IMPL; + try { + const env = getSmartAccountsEnvironment(executionChainIdNumber); + upgradeContract = EVMContractAddress(getAddress( + env.implementations.EIP7702StatelessDeleGatorImpl, + )); + } catch { + // keep hardcoded fallback + } + upgradeNonce = await executionClient.getTransactionCount({ + address: 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 = paymentChainId; + const executionKey = 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) => + token.address === paymentToken, + ); + 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 { + const { smartAccount, delegate, target, value, callData } = args; + const salt = randomSalt32(); + const selector = methodSelector(callData); + + return createDelegation({ + to: getAddress(delegate), + from: smartAccount.address, + environment: smartAccount.environment, + salt, + scope: { + type: ScopeType.FunctionCall, + targets: [getAddress(target)], + selectors: [selector], + exactCalldata: { calldata: callData }, + valueLte: { maxValue: value }, + }, + }); + } + + /** + * 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 { + const delegation = this.createExactCalldataDelegation(args); + return { + ...delegation, + signature: PLACEHOLDER_DELEGATION_SIGNATURE_65_ZERO, + }; + } + + private createUnsignedActivationNoOpDelegation( + args: ActivationNoOpDelegationArgs, + ): unknown { + const delegation = this.createActivationNoOpDelegation(args); + return { + ...delegation, + signature: PLACEHOLDER_DELEGATION_SIGNATURE_65_ZERO, + }; + } + + private async createAndSignExactCalldataDelegation( + args: ExactCalldataDelegationArgs, + ): Promise { + const { smartAccount } = args; + const delegation = this.createExactCalldataDelegation(args); + + // Callers must already have the flyout open (SignHelper.withDisplay for + // eth_sendTransaction, plus sendViaRelayer.ensureDisplay for size). Do not + // call ensureDisplay here: parallel requestDisplay awaits stagger the two + // signDelegation → signDigest paths and the second signer RPC cancels the + // first Confirm UI (`ceremonyCancelled`). withCeremonyUiReason only sets + // Confirm copy — it does not open/close display and awaits this method. + const signature = await smartAccount.signDelegation({ delegation }); + 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 { + const signer = await this.options.owsProvider.getSigner(); + const address = + addressOverride ?? + signer.getCachedAddress?.() ?? + loadCachedEvmAddress() ?? + undefined; + const publicKey = + signer.getLastPublicKeyData?.()?.secp256k1PublicKey ?? + loadCachedSecp256k1PublicKey() ?? + undefined; + const account = await toViemLocalAccount(signer, { + ...(address ? { address } : {}), + ...(publicKey ? { publicKey } : {}), + }); + return account; + } + + private async pollUntilTerminal( + relayerUrl: string, + taskId: RelayerTransactionId, + ): Promise { + let lastHash: EVMTransactionHash | undefined; + + for (let i = 0; i < MAX_POLL_ATTEMPTS; i += 1) { + const status = await this.options.relayerRepository.getStatus( + relayerUrl, + taskId, + ); + // 110: top-level `hash`; 200: `receipt.transactionHash` (mapped in getStatus). + if (status.hash) { + lastHash = status.hash; + } + + if (status.status === 200) { + if (status.hash) return status.hash; + if (lastHash) return lastHash; + throw new Error( + "Relayer reported confirmed (200) without a transaction hash", + ); + } + if (status.status === 400 || status.status === 500) { + throw new Error( + status.message ?? `Relayer task failed with status ${status.status}`, + ); + } + await sleep(POLL_MS); + } + + if (lastHash) { + return lastHash; + } + throw new Error("Timed out waiting for relayer transaction status"); + } + + private async readCodeUpgradeStatus( + chainId: EVMChainId, + address: EVMAccountAddress, + ): Promise { + const client = this.options.blockchain.getPublicClient(chainId); + const code = await client.getCode({ address }); + if (!code || code === "0x") { + return { upgraded: false }; + } + + let impl = STATELESS_DELEGATOR_IMPL.toLowerCase(); + try { + const env = getSmartAccountsEnvironment(Number(BigInt(chainId))); + impl = env.implementations.EIP7702StatelessDeleGatorImpl.toLowerCase(); + } catch { + // keep hardcoded fallback + } + + const normalized = code.toLowerCase(); + // EIP-7702 designator only: 0xef0100 || implementation address. + // 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 { upgraded: false }; + } + const delegated = `0x${normalized.slice(8, 48)}`; + if (delegated !== impl) { + return { upgraded: false }; + } + return { + upgraded: true, + codeAddress: EVMContractAddress(getAddress(delegated)), + }; + } + + private async requireRelayerChain(chainId: EVMChainId) { + const chain = await this.options.chainRepository.get(chainId); + if (!chain) { + throw new Error(`Unsupported chain: ${chainId}`); + } + if (!chain.useRelayer) { + throw new Error(`Chain ${chainId} does not support the 1Shot relayer`); + } + return chain; + } + + /** + * 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, + ); + + 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], + ); + } + + /** + * Unsigned Multichain estimate: fee on payment chain + ExactCalldata work on + * each execution chain (combined into payment-chain entry when they match). + */ + private async quotePaymentWorkMultichain(args: { + owner: EVMAccountAddress; + payment: IRelayerPayment; + groups: Array<{ chainId: EVMChainId; work: ITransactionWork[] }>; + seedFeeAtoms: TokenAmount; + paymentCapabilities: Awaited< + ReturnType + >; + }): Promise< + Awaited> + > { + const { + owner, + payment, + groups, + seedFeeAtoms, + paymentCapabilities, + } = args; + const paymentChain = await this.requireRelayerChain(payment.paymentChainId); + const paymentChainIdNumber = Number(BigInt(payment.paymentChainId)); + const viemAccount = await this.getViemAccount(owner); + + const paymentClient = this.options.blockchain.getPublicClient( + payment.paymentChainId, + ); + const paymentSmartAccount = await toMetaMaskSmartAccount({ + client: paymentClient 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 workChainIds = groups.map((g) => g.chainId); + const ordered = orderedActivationChainIds( + workChainIds, + payment.paymentChainId, + ); + + const params: IRelayer7710Params[] = await Promise.all( + ordered.map(async (chainId) => { + const isPayment = chainId === payment.paymentChainId; + const group = groups.find((g) => g.chainId === chainId); + const transactions: IRelayer7710Params["transactions"] = []; + + if (isPayment) { + transactions.push({ + permissionContext: [toRelayerJson(feeDelegation)], + executions: [ + { + target: payment.paymentToken, + value: "0", + data: feeCalldata as HexString, + }, + ], + }); + } + + if (group) { + const chain = await this.requireRelayerChain(chainId); + const caps = 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: owner, + signer: { account: viemAccount }, + }); + for (const item of group.work) { + const workDelegation = this.createUnsignedExactCalldataDelegation({ + smartAccount, + delegate: caps.targetAddress, + target: item.to, + value: item.value ?? 0n, + callData: (item.data || "0x") as Hex, + chainIdNumber, + }); + const value = item.value ?? 0n; + transactions.push({ + permissionContext: [toRelayerJson(workDelegation)], + executions: [ + { + target: item.to, + value: value === 0n ? "0" : `0x${value.toString(16)}`, + data: (item.data || "0x") as HexString, + }, + ], + }); + } + } + + if (transactions.length === 0) { + throw new Error( + `quotePaymentWorkMultichain: no transactions for ${chainId}`, + ); + } + + return { + chainId: Number(BigInt(chainId)).toString(10), + transactions, + } satisfies IRelayer7710Params; + }), + ); + + console.debug( + "[business/TransactionUtils] quotePaymentMultichain unsigned estimate", + { + paymentChainId: payment.paymentChainId, + paymentToken: payment.paymentToken, + workChains: workChainIds, + workCount: groups.reduce((n, g) => n + g.work.length, 0), + }, + ); + + return this.options.relayerRepository.estimate7710TransactionMultichain( + paymentChain.relayerUrl, + params, + ); + } + + /** + * 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: IRelayerPayment; + 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 = chainId === payment.paymentChainId; + const needsUpgrade = upgradeChainIds.some((id) => + id === 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.createUnsignedActivationNoOpDelegation({ + smartAccount, + delegate: capabilities.targetAddress, + }); + transactions.push({ + permissionContext: [toRelayerJson(workDelegation)], + executions: [ + { + target: ACTIVATION_NOOP_TARGET, + 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 { + const prompts = styleController.get().copy.passkeyPrompt; + return { + explanationHeader: prompts.approveTransaction.title, + explanationText: includeUpgrade + ? `${prompts.approveTransaction.body} This includes a one-time wallet upgrade authorization.` + : prompts.approveTransaction.body, + }; +} + +function adjustFeeCeremony(): CeremonyUiParams { + const prompts = styleController.get().copy.passkeyPrompt; + return { + explanationHeader: prompts.adjustFee.title, + explanationText: prompts.adjustFee.body, + }; +} + +function shouldUseActivationMultichain( + upgradeChainIds: readonly EVMChainId[], + paymentChainId: EVMChainId, +): boolean { + if (upgradeChainIds.length !== 1) return true; + return upgradeChainIds[0]! !== paymentChainId; +} + +/** Fee/payment chain first, then remaining upgrade/work chains. */ +function orderedActivationChainIds( + upgradeChainIds: readonly EVMChainId[], + paymentChainId: EVMChainId, +): EVMChainId[] { + const ordered: EVMChainId[] = [paymentChainId]; + const seen = new Set([paymentChainId]); + for (const chainId of upgradeChainIds) { + if (seen.has(chainId)) continue; + seen.add(chainId); + ordered.push(chainId); + } + return ordered; +} + +function normalizeWorkByChain( + workByChain: readonly { + chainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + }[], +): Array<{ chainId: EVMChainId; work: ITransactionWork[] }> { + const byChain = new Map(); + for (const entry of workByChain) { + const items = Array.isArray(entry.work) ? entry.work : [entry.work]; + if (items.length === 0) continue; + const existing = byChain.get(entry.chainId); + if (existing) { + existing.push(...items); + } else { + byChain.set(entry.chainId, [...items]); + } + } + return [...byChain.entries()].map(([chainId, work]) => ({ chainId, work })); +} + +function methodSelector(callData: Hex): Hex { + if (callData.length >= 10) { + return callData.slice(0, 10) as Hex; + } + // FunctionCall + AllowedMethodsEnforcer needs ≥4 calldata bytes. Empty + // activation work must use NativeTokenTransferAmount instead (see + // createActivationNoOpDelegation). This fallback is only a last resort. + return "0x00000000"; +} + +function randomSalt32(): Hex { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}` as Hex; +} + +let cachedDelegationBinding: string | undefined; + +/** + * Stable per-browser binding value for `relayer_send7710Transaction`. + * Kept out of localStorage/sessionStorage (XSS-readable by default scrapers); + * IndexedDB + in-memory cache. Migrates the legacy localStorage key once. + */ +async function loadOrCreateDelegationBinding(): Promise { + if (cachedDelegationBinding && cachedDelegationBinding.length >= 10) { + return cachedDelegationBinding; + } + + try { + const legacy = localStorage.getItem(LEGACY_DELEGATION_SECRET_KEY); + if (legacy && legacy.length >= 10) { + await idbSetString(DELEGATION_BINDING_IDB_KEY, legacy); + localStorage.removeItem(LEGACY_DELEGATION_SECRET_KEY); + cachedDelegationBinding = legacy; + return legacy; + } + } catch { + // localStorage may be unavailable + } + + try { + const existing = await idbGetString(DELEGATION_BINDING_IDB_KEY); + if (existing && existing.length >= 10) { + cachedDelegationBinding = existing; + return existing; + } + const next = crypto.randomUUID(); + await idbSetString(DELEGATION_BINDING_IDB_KEY, next); + cachedDelegationBinding = next; + return next; + } catch { + const fallback = crypto.randomUUID(); + cachedDelegationBinding = fallback; + return fallback; + } +} + +function buildMemo(wallet: EVMAccountAddress, host: string): string { + const memo = JSON.stringify({ wallet: String(wallet), host }); + return memo.length <= 256 ? memo : memo.slice(0, 256); +} + +function toRelayerJson(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (typeof value === "bigint") return `0x${value.toString(16)}`; + if (value instanceof Uint8Array) { + return `0x${Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("")}`; + } + if (Array.isArray(value)) return value.map(toRelayerJson); + if (typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = toRelayerJson(v); + } + return out; + } + return value; +} + +function HexStringCompat(value: string): Hex { + return value as Hex; +} + +function yParityFromSignedAuthorization(signed: { + yParity?: number | undefined; + v?: bigint | number | undefined; +}): 0 | 1 { + if (signed.yParity === 0 || signed.yParity === 1) { + return signed.yParity; + } + if (signed.v !== undefined) { + const v = Number(signed.v); + if (v === 0 || v === 1) return v; + if (v === 27 || v === 28) return (v - 27) as 0 | 1; + } + throw new Error( + "EIP-7702 authorization missing yParity (relayer requires 0|1)", + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } \ No newline at end of file diff --git a/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts b/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts index ce6c312..ac8a07a 100644 --- a/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts +++ b/src/lib/implementations/data/BlockscoutAssetActivityRepository.ts @@ -1,9 +1,9 @@ import { ChainUtils, EVMAccountAddress, + EVMContractAddress, EVMTransactionHash, - type EVMAccountAddress as EVMAccountAddressType, - type EVMChainId as EVMChainIdType, + type EVMChainId, type UriString, } from "@1shotapi/ows-types"; import { @@ -25,6 +25,7 @@ import { makeTrackedAssetId, type TrackedAssetId, } from "../../types/primitives"; +import { getAddress } from "viem"; type StoredOptimistic = { hash: string; @@ -167,9 +168,9 @@ export class BlockscoutAssetActivityRepository } private async fetchIndexed(args: { - owner: EVMAccountAddressType; - chainId: EVMChainIdType; - tokenAddress: EVMAccountAddressType; + owner: EVMAccountAddress; + chainId: EVMChainId; + tokenAddress: EVMContractAddress; decimals: number; trackedAssetId: TrackedAssetId; limit: number; @@ -217,9 +218,9 @@ export class BlockscoutAssetActivityRepository private transferToActivity( transfer: RelayerActivityTransfer, args: { - owner: EVMAccountAddressType; - chainId: EVMChainIdType; - tokenAddress: EVMAccountAddressType; + owner: EVMAccountAddress; + chainId: EVMChainId; + tokenAddress: EVMContractAddress; decimals: number; trackedAssetId: TrackedAssetId; }, @@ -265,7 +266,7 @@ export class BlockscoutAssetActivityRepository const fromLower = from.toLowerCase(); const toLower = to.toLowerCase(); let kind: EAssetActivityKind; - let counterparty: EVMAccountAddressType; + let counterparty: EVMAccountAddress; if (fromLower === ownerLower) { kind = EAssetActivityKind.Sent; counterparty = EVMAccountAddress(to as `0x${string}`); @@ -310,7 +311,7 @@ export class BlockscoutAssetActivityRepository return new AssetActivity( EVMTransactionHash(row.hash as `0x${string}`), ChainUtils.asEVMChainId(row.chainId), - EVMAccountAddress(row.tokenAddress as `0x${string}`), + EVMContractAddress(getAddress(row.tokenAddress)), trackedAssetId, EVMAccountAddress(row.owner as `0x${string}`), EVMAccountAddress(row.counterparty as `0x${string}`), diff --git a/src/lib/implementations/data/CachedRelayerVaultRepository.ts b/src/lib/implementations/data/CachedRelayerVaultRepository.ts index 447ef7c..9ecc80b 100644 --- a/src/lib/implementations/data/CachedRelayerVaultRepository.ts +++ b/src/lib/implementations/data/CachedRelayerVaultRepository.ts @@ -42,6 +42,7 @@ import type { IRelayerCredentialsClient } from "../../interfaces/data/IRelayerCr import { loadCosePublicKey, loadCredentialId } from "../../../storage"; import { EPasskeyPromptReason } from "../../types/enum/EPasskeyPromptReason"; import { withCeremonyUiReason } from "../../../wallet/ceremonyUiOverrideStore"; +import { getAddress } from "viem"; export type { CredentialStorageBackend }; @@ -544,7 +545,7 @@ export class CachedRelayerVaultRepository permissionType: d.permissionResponse.permission.type, to: d.permissionResponse.to, ...(typeof tokenRaw === "string" - ? { tokenAddress: EVMAccountAddress(this.asHex(tokenRaw)) } + ? { tokenAddress: EVMContractAddress(getAddress(tokenRaw)) } : {}), ...(typeof amountRaw === "string" ? { periodAmount: HexString(this.asHex(amountRaw)) } diff --git a/src/lib/implementations/data/EVMRepository.ts b/src/lib/implementations/data/EVMRepository.ts index e3519ff..46ea731 100644 --- a/src/lib/implementations/data/EVMRepository.ts +++ b/src/lib/implementations/data/EVMRepository.ts @@ -1,12 +1,13 @@ import { prepareEvmTransaction } from "@1shotapi/ows-signer-utils"; import type { IBlockchainProvider } from "@1shotapi/ows-wallet-utils"; import { + EVMAccountAddress, EVMTransactionHash, HexString, OwsInvalidParamsError, RelayerTransactionId, - type EVMAccountAddress, type EVMChainId, + type EVMContractAddress, } from "@1shotapi/ows-types"; import type { IEVMRepository, @@ -29,7 +30,7 @@ export class EVMRepository implements IEVMRepository { async broadcastRawTransaction( chainId: EVMChainId, - to: EVMAccountAddress, + to: EVMAccountAddress | EVMContractAddress, data: HexString, value?: bigint, gasOverrides?: IEvmGasOverrides, @@ -57,7 +58,8 @@ export class EVMRepository implements IEVMRepository { const prepared = await prepareEvmTransaction(chainRpc, from, { from, - to, + // Tx `to` is polymorphic (EOA or contract); EIP-1193 request type uses account brand. + to: EVMAccountAddress(to), data: txData, value: valueHex, chainId, diff --git a/src/lib/implementations/data/HardcodedKnownAssetRepository.ts b/src/lib/implementations/data/HardcodedKnownAssetRepository.ts index 94e7bd0..4e0dce5 100644 --- a/src/lib/implementations/data/HardcodedKnownAssetRepository.ts +++ b/src/lib/implementations/data/HardcodedKnownAssetRepository.ts @@ -1,196 +1,196 @@ -import { erc20Abi, zeroAddress } from "viem"; -import { - ChainUtils, - EVMAccountAddress, - type EVMAccountAddress as EVMAccountAddressType, - type EVMChainId as EVMChainIdType, -} from "@1shotapi/ows-types"; -import type { IBlockchainProvider } from "@1shotapi/ows-wallet-utils"; -import type { IKnownAssetRepository } from "../../interfaces/data/IKnownAssetRepository"; -import { KnownAsset } from "../../types/domain/KnownAsset"; -import { NewTrackedAsset } from "../../types/domain/TrackedAsset"; -import { EAssetType } from "../../types/enum/EAssetType"; -import { EChain } from "../../types/enum/EChain"; -import { makeTrackedAssetId } from "@/lib/types/primitives"; -import { - RELAYER_KNOWN_ASSETS, - getCctpBridgeAsset as lookupCctpBridgeAsset, - getOnrampAsset as lookupOnrampAsset, -} from "./relayerKnownAssets"; -import { HardcodedChainRepository } from "./HardcodedChainRepository"; -import { registerKnownAssetIconResolver } from "../../utils/tokenIcons"; - -const NATIVE_ADDRESS = EVMAccountAddress(zeroAddress); -const NATIVE_WEIGHT = 50; - -/** Arc gas is the pinned USDC ERC-20 — do not also show a zero-address Native row. */ -const SKIP_NATIVE_DEFAULT = new Set([ - String(EChain.Arc).toLowerCase(), - String(EChain.ArcTestnet).toLowerCase(), -]); - -function buildNativeKnownAssets(): KnownAsset[] { - const catalog = new HardcodedChainRepository().getCatalog(); - const natives: KnownAsset[] = []; - for (const chain of catalog) { - if (!ChainUtils.isEVMChainId(chain.chainId)) continue; - const key = String(chain.chainId).toLowerCase(); - if (SKIP_NATIVE_DEFAULT.has(key)) continue; - natives.push( - new KnownAsset( - chain.chainId, - NATIVE_ADDRESS, - EAssetType.Native, - chain.nativeCurrency.name, - chain.nativeCurrency.symbol, - chain.nativeCurrency.decimals, - false, - false, - NATIVE_WEIGHT, - chain.logoUrl, - ), - ); - } - return natives; -} - -const NATIVE_KNOWN_ASSETS: readonly KnownAsset[] = buildNativeKnownAssets(); - -const ALL_KNOWN_ASSETS: readonly KnownAsset[] = [ - ...RELAYER_KNOWN_ASSETS, - ...NATIVE_KNOWN_ASSETS, -]; - -const BY_KEY = new Map( - ALL_KNOWN_ASSETS.map((asset) => [ - makeTrackedAssetId(asset.chainId, asset.address), - asset, - ]), -); - -// Include native (chain logo) rows in the known-asset icon resolver. -registerKnownAssetIconResolver( - (chainId, address) => BY_KEY.get(makeTrackedAssetId(chainId, address))?.iconUrl, -); - -/** - * Pinned payment stables use {@link KnownAsset.weight} ≥ 100 in - * {@link RELAYER_KNOWN_ASSETS} (USDC on each supported EVM chain, USDG on - * Robinhood). Weight is the source of truth — no per-chain symbol allowlist. - */ -function isPinnedStable(asset: KnownAsset): boolean { - return asset.type === EAssetType.Erc20 && asset.weight >= 100; -} - -/** - * Pinned Balances rows: default stables (weight 100) then natives (weight 50), - * sorted by weight desc. Not removable; always merged in by - * {@link LocalStorageTrackedAssetRepository.mergeWithDefaults}. - */ -export const DEFAULT_TRACKED_ASSETS: readonly NewTrackedAsset[] = [ - ...RELAYER_KNOWN_ASSETS.filter(isPinnedStable), - ...NATIVE_KNOWN_ASSETS, -] - .sort((a, b) => { - if (b.weight !== a.weight) return b.weight - a.weight; - return a.symbol.localeCompare(b.symbol); - }) - .map((asset) => NewTrackedAsset.fromKnown(asset)); - -const DEFAULT_TRACKED_KEYS = new Set( - DEFAULT_TRACKED_ASSETS.map((asset) => - makeTrackedAssetId(asset.chainId, asset.address), - ), -); - -export function isDefaultTrackedAsset( - chainId: EVMChainIdType, - address: EVMAccountAddressType, -): boolean { - return DEFAULT_TRACKED_KEYS.has(makeTrackedAssetId(chainId, address)); -} - -export class HardcodedKnownAssetRepository implements IKnownAssetRepository { - constructor(private readonly blockchain: IBlockchainProvider) {} - - async getKnownAsset( - chainId: EVMChainIdType, - address: EVMAccountAddressType, - ): Promise { - return BY_KEY.get(makeTrackedAssetId(chainId, address)) ?? null; - } - - async getCctpBridgeAsset( - chainId: EVMChainIdType, - ): Promise { - return lookupCctpBridgeAsset(chainId); - } - - async getOnrampAsset( - chainId: EVMChainIdType, - symbol?: string, - ): Promise { - return lookupOnrampAsset(chainId, symbol); - } - - async resolveForTracking( - chainId: EVMChainIdType, - address: EVMAccountAddressType, - owner: EVMAccountAddressType, - ): Promise { - const known = await this.getKnownAsset(chainId, address); - if ( - known?.type === EAssetType.Erc20 || - known?.type === EAssetType.Native - ) { - return NewTrackedAsset.fromKnown(known); - } - - const client = this.blockchain.getPublicClient(chainId); - const code = await client.getCode({ address: address }); - if (!code || code === "0x") { - throw new Error("Address is not a contract"); - } - - try { - const [name, symbol, decimals] = await Promise.all([ - client.readContract({ - address: address, - abi: erc20Abi, - functionName: "name", - }), - client.readContract({ - address: address, - abi: erc20Abi, - functionName: "symbol", - }), - client.readContract({ - address: address, - abi: erc20Abi, - functionName: "decimals", - }), - ]); - - // Validate ERC-20 with a balanceOf probe (reverts → not ERC-20). - await client.readContract({ - address: address, - abi: erc20Abi, - functionName: "balanceOf", - args: [owner], - }); - - return new NewTrackedAsset( - chainId, - address, - EAssetType.Erc20, - name, - symbol, - decimals, - ); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : "ERC-20 probe failed"; - throw new Error(`Not an ERC-20 token: ${message}`); - } - } -} +import { erc20Abi, zeroAddress } from "viem"; +import { + ChainUtils, + EVMAccountAddress, + EVMContractAddress, + type EVMChainId, +} from "@1shotapi/ows-types"; +import type { IBlockchainProvider } from "@1shotapi/ows-wallet-utils"; +import type { IKnownAssetRepository } from "../../interfaces/data/IKnownAssetRepository"; +import { KnownAsset } from "../../types/domain/KnownAsset"; +import { NewTrackedAsset } from "../../types/domain/TrackedAsset"; +import { EAssetType } from "../../types/enum/EAssetType"; +import { EChain } from "../../types/enum/EChain"; +import { makeTrackedAssetId } from "@/lib/types/primitives"; +import { + RELAYER_KNOWN_ASSETS, + getCctpBridgeAsset as lookupCctpBridgeAsset, + getOnrampAsset as lookupOnrampAsset, +} from "./relayerKnownAssets"; +import { HardcodedChainRepository } from "./HardcodedChainRepository"; +import { registerKnownAssetIconResolver } from "../../utils/tokenIcons"; + +const NATIVE_ADDRESS = EVMContractAddress(zeroAddress); +const NATIVE_WEIGHT = 50; + +/** Arc gas is the pinned USDC ERC-20 — do not also show a zero-address Native row. */ +const SKIP_NATIVE_DEFAULT = new Set([ + String(EChain.Arc).toLowerCase(), + String(EChain.ArcTestnet).toLowerCase(), +]); + +function buildNativeKnownAssets(): KnownAsset[] { + const catalog = new HardcodedChainRepository().getCatalog(); + const natives: KnownAsset[] = []; + for (const chain of catalog) { + if (!ChainUtils.isEVMChainId(chain.chainId)) continue; + const key = String(chain.chainId).toLowerCase(); + if (SKIP_NATIVE_DEFAULT.has(key)) continue; + natives.push( + new KnownAsset( + chain.chainId, + NATIVE_ADDRESS, + EAssetType.Native, + chain.nativeCurrency.name, + chain.nativeCurrency.symbol, + chain.nativeCurrency.decimals, + false, + false, + NATIVE_WEIGHT, + chain.logoUrl, + ), + ); + } + return natives; +} + +const NATIVE_KNOWN_ASSETS: readonly KnownAsset[] = buildNativeKnownAssets(); + +const ALL_KNOWN_ASSETS: readonly KnownAsset[] = [ + ...RELAYER_KNOWN_ASSETS, + ...NATIVE_KNOWN_ASSETS, +]; + +const BY_KEY = new Map( + ALL_KNOWN_ASSETS.map((asset) => [ + makeTrackedAssetId(asset.chainId, asset.address), + asset, + ]), +); + +// Include native (chain logo) rows in the known-asset icon resolver. +registerKnownAssetIconResolver( + (chainId, address) => BY_KEY.get(makeTrackedAssetId(chainId, address))?.iconUrl, +); + +/** + * Pinned payment stables use {@link KnownAsset.weight} ≥ 100 in + * {@link RELAYER_KNOWN_ASSETS} (USDC on each supported EVM chain, USDG on + * Robinhood). Weight is the source of truth — no per-chain symbol allowlist. + */ +function isPinnedStable(asset: KnownAsset): boolean { + return asset.type === EAssetType.Erc20 && asset.weight >= 100; +} + +/** + * Pinned Balances rows: default stables (weight 100) then natives (weight 50), + * sorted by weight desc. Not removable; always merged in by + * {@link LocalStorageTrackedAssetRepository.mergeWithDefaults}. + */ +export const DEFAULT_TRACKED_ASSETS: readonly NewTrackedAsset[] = [ + ...RELAYER_KNOWN_ASSETS.filter(isPinnedStable), + ...NATIVE_KNOWN_ASSETS, +] + .sort((a, b) => { + if (b.weight !== a.weight) return b.weight - a.weight; + return a.symbol.localeCompare(b.symbol); + }) + .map((asset) => NewTrackedAsset.fromKnown(asset)); + +const DEFAULT_TRACKED_KEYS = new Set( + DEFAULT_TRACKED_ASSETS.map((asset) => + makeTrackedAssetId(asset.chainId, asset.address), + ), +); + +export function isDefaultTrackedAsset( + chainId: EVMChainId, + address: EVMContractAddress, +): boolean { + return DEFAULT_TRACKED_KEYS.has(makeTrackedAssetId(chainId, address)); +} + +export class HardcodedKnownAssetRepository implements IKnownAssetRepository { + constructor(private readonly blockchain: IBlockchainProvider) {} + + async getKnownAsset( + chainId: EVMChainId, + address: EVMContractAddress, + ): Promise { + return BY_KEY.get(makeTrackedAssetId(chainId, address)) ?? null; + } + + async getCctpBridgeAsset( + chainId: EVMChainId, + ): Promise { + return lookupCctpBridgeAsset(chainId); + } + + async getOnrampAsset( + chainId: EVMChainId, + symbol?: string, + ): Promise { + return lookupOnrampAsset(chainId, symbol); + } + + async resolveForTracking( + chainId: EVMChainId, + address: EVMContractAddress, + owner: EVMAccountAddress, + ): Promise { + const known = await this.getKnownAsset(chainId, address); + if ( + known?.type === EAssetType.Erc20 || + known?.type === EAssetType.Native + ) { + return NewTrackedAsset.fromKnown(known); + } + + const client = this.blockchain.getPublicClient(chainId); + const code = await client.getCode({ address: address }); + if (!code || code === "0x") { + throw new Error("Address is not a contract"); + } + + try { + const [name, symbol, decimals] = await Promise.all([ + client.readContract({ + address: address, + abi: erc20Abi, + functionName: "name", + }), + client.readContract({ + address: address, + abi: erc20Abi, + functionName: "symbol", + }), + client.readContract({ + address: address, + abi: erc20Abi, + functionName: "decimals", + }), + ]); + + // Validate ERC-20 with a balanceOf probe (reverts → not ERC-20). + await client.readContract({ + address: address, + abi: erc20Abi, + functionName: "balanceOf", + args: [owner], + }); + + return new NewTrackedAsset( + chainId, + address, + EAssetType.Erc20, + name, + symbol, + decimals, + ); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "ERC-20 probe failed"; + throw new Error(`Not an ERC-20 token: ${message}`); + } + } +} diff --git a/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts b/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts index 842bc88..ac79541 100644 --- a/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts +++ b/src/lib/implementations/data/LocalStorageTrackedAssetRepository.ts @@ -1,355 +1,355 @@ -import { erc20Abi, type Address } from "viem"; -import { - ChainUtils, - EVMAccountAddress, - type EVMAccountAddress as EVMAccountAddressType, - type EVMChainId as EVMChainIdType, -} from "@1shotapi/ows-types"; -import type { IBlockchainProvider } from "@1shotapi/ows-wallet-utils"; -import { - createMemoryStorageBackend, - type CredentialStorageBackend, -} from "../../../demo/local-storage-store"; -import type { IConfigProvider } from "../../interfaces/utils/IConfigProvider"; -import type { IEventBus } from "../../interfaces/utils/IEventBus"; -import type { ITrackedAssetRepository } from "../../interfaces/data/ITrackedAssetRepository"; -import { - DEFAULT_TRACKED_ASSETS, - isDefaultTrackedAsset, -} from "./HardcodedKnownAssetRepository"; -import { NewTrackedAsset, TrackedAsset } from "../../types/domain/TrackedAsset"; -import { EAssetType } from "../../types/enum/EAssetType"; -import { BalanceUpdatedEvent } from "../../types/events/BalanceUpdatedEvent"; -import { - makeTrackedAssetId, - type TrackedAssetId, -} from "../../types/primitives"; -import { - registerTrackedAssetIconUrl, - unregisterTrackedAssetIconUrl, - syncTrackedAssetIconUrls, -} from "../../utils/tokenIcons"; - -type StoredBlob = { - assets: Array<{ - chainId: string; - address: string; - type: string; - name: string; - symbol: string; - decimals: number; - id: string; - iconUrl?: string; - }>; -}; - -export type TrackedAssetRepositoryOptions = { - storage?: CredentialStorageBackend; -}; - -const EMPTY_OWNER = EVMAccountAddress("0x0"); - -export class LocalStorageTrackedAssetRepository - implements ITrackedAssetRepository -{ - private readonly storage: CredentialStorageBackend; - private readonly balanceCache = new Map(); - private storageKey: string | null = null; - - constructor( - private readonly blockchain: IBlockchainProvider, - private readonly eventBus: IEventBus, - private readonly configProvider: IConfigProvider, - options: TrackedAssetRepositoryOptions = {}, - ) { - this.storage = - options.storage ?? - (typeof localStorage !== "undefined" - ? localStorage - : createMemoryStorageBackend()); - } - - private async resolveStorageKey(): Promise { - if (this.storageKey) { - return this.storageKey; - } - const config = await this.configProvider.getConfig(); - this.storageKey = config.trackedAssetsStorageKey; - return this.storageKey; - } - - async list(chainId?: EVMChainIdType): Promise { - const storageKey = await this.resolveStorageKey(); - const assets = this.filterByChain( - this.mergeWithDefaults(this.readStoredAssets(storageKey)), - chainId, - ); - syncTrackedAssetIconUrls(assets); - // Cache only — no RPC. Call getBalances when balances are needed. - return assets.map((asset) => - this.balanceCache.has(asset.id) - ? asset.withBalance(this.balanceCache.get(asset.id)!) - : asset.withBalance(null), - ); - } - - async has( - chainId: EVMChainIdType, - address: EVMAccountAddressType, - ): Promise { - if (isDefaultTrackedAsset(chainId, address)) { - return true; - } - const storageKey = await this.resolveStorageKey(); - const key = makeTrackedAssetId(chainId, address); - return this.readStoredAssets(storageKey).some((asset) => asset.id === key); - } - - async add( - asset: NewTrackedAsset, - owner: EVMAccountAddressType, - ): Promise { - if (isDefaultTrackedAsset(asset.chainId, asset.address)) { - const existing = TrackedAsset.fromNew(asset); - const [withBalance] = await this.ensureBalances([existing], owner, false); - return withBalance!; - } - - const storageKey = await this.resolveStorageKey(); - const assets = this.readStoredAssets(storageKey); - const key = makeTrackedAssetId(asset.chainId, asset.address); - const foundIndex = assets.findIndex((a) => a.id === key); - if (foundIndex >= 0) { - const found = assets[foundIndex]!; - if (asset.iconUrl !== undefined && asset.iconUrl !== found.iconUrl) { - const updated = TrackedAsset.fromNew( - found.withIconUrl(asset.iconUrl), - found.balance, - ); - assets[foundIndex] = updated; - this.writeAssets(storageKey, assets); - if (asset.iconUrl) { - registerTrackedAssetIconUrl(key, asset.iconUrl); - } else { - unregisterTrackedAssetIconUrl(key); - } - const [withBalance] = await this.ensureBalances([updated], owner, false); - return withBalance!; - } - const [withBalance] = await this.ensureBalances([found], owner, false); - return withBalance!; - } - - const tracked = TrackedAsset.fromNew(asset); - assets.push(tracked); - this.writeAssets(storageKey, assets); - if (tracked.iconUrl) { - registerTrackedAssetIconUrl(key, tracked.iconUrl); - } - const [withBalance] = await this.ensureBalances([tracked], owner, false); - return withBalance!; - } - - async remove( - chainId: EVMChainIdType, - address: EVMAccountAddressType, - ): Promise { - if (isDefaultTrackedAsset(chainId, address)) { - return; - } - const storageKey = await this.resolveStorageKey(); - const key = makeTrackedAssetId(chainId, address); - const next = this.readStoredAssets(storageKey).filter( - (asset) => asset.id !== key, - ); - this.writeAssets(storageKey, next); - this.balanceCache.delete(key); - unregisterTrackedAssetIconUrl(key); - } - - async getBalances( - owner: EVMAccountAddressType, - options: { id: TrackedAssetId } | { chainId: EVMChainIdType }, - ): Promise { - const storageKey = await this.resolveStorageKey(); - const all = this.mergeWithDefaults(this.readStoredAssets(storageKey)); - syncTrackedAssetIconUrls(all); - - let targets: TrackedAsset[]; - if ("id" in options) { - this.balanceCache.delete(options.id); - targets = all.filter((asset) => asset.id === options.id); - } else { - const chainKey = String(options.chainId).toLowerCase(); - for (const asset of all) { - if (String(asset.chainId).toLowerCase() === chainKey) { - this.balanceCache.delete(asset.id); - } - } - targets = this.filterByChain(all, options.chainId); - } - - return this.ensureBalances(targets, owner, true); - } - - private filterByChain( - assets: TrackedAsset[], - chainId?: EVMChainIdType, - ): TrackedAsset[] { - if (chainId == null) return assets; - const key = String(chainId).toLowerCase(); - return assets.filter( - (asset) => String(asset.chainId).toLowerCase() === key, - ); - } - - private mergeWithDefaults(stored: TrackedAsset[]): TrackedAsset[] { - const seen = new Set(); - const merged: TrackedAsset[] = []; - for (const asset of DEFAULT_TRACKED_ASSETS) { - const key = makeTrackedAssetId(asset.chainId, asset.address); - seen.add(key); - merged.push(TrackedAsset.fromNew(asset)); - } - for (const asset of stored) { - if (seen.has(asset.id)) continue; - seen.add(asset.id); - merged.push(asset); - } - return merged; - } - - private async ensureBalances( - assets: TrackedAsset[], - owner: EVMAccountAddressType, - forceEmit: boolean, - ): Promise { - let anyFetched = forceEmit; - - const result = await Promise.all( - assets.map(async (asset) => { - if (this.balanceCache.has(asset.id)) { - return asset.withBalance(this.balanceCache.get(asset.id)!); - } - - anyFetched = true; - const balance = await this.fetchBalance(asset, owner); - if (balance !== null) { - this.balanceCache.set(asset.id, balance); - } - return asset.withBalance(balance); - }), - ); - - if (anyFetched && result.length > 0) { - this.eventBus.emit(new BalanceUpdatedEvent(result)); - } - return result; - } - - private async fetchBalance( - asset: TrackedAsset, - owner: EVMAccountAddressType, - ): Promise { - if (owner === EMPTY_OWNER) { - return null; - } - if (asset.type === EAssetType.Native) { - try { - const client = this.blockchain.getPublicClient(asset.chainId); - return await client.getBalance({ address: owner as Address }); - } catch (error: unknown) { - console.warn("[balances] getBalance failed", error); - return null; - } - } - if (asset.type !== EAssetType.Erc20) { - return null; - } - try { - const client = this.blockchain.getPublicClient(asset.chainId); - return await client.readContract({ - address: asset.address as Address, - abi: erc20Abi, - functionName: "balanceOf", - args: [owner as Address], - }); - } catch (error: unknown) { - console.warn("[balances] balanceOf failed", error); - return null; - } - } - - private readStoredAssets(storageKey: string): TrackedAsset[] { - const raw = this.storage.getItem(storageKey); - if (!raw) return []; - try { - const parsed = JSON.parse(raw) as StoredBlob; - if (!parsed || !Array.isArray(parsed.assets)) return []; - const assets: TrackedAsset[] = []; - for (const row of parsed.assets) { - if ( - typeof row?.chainId !== "string" || - typeof row?.address !== "string" || - typeof row?.name !== "string" || - typeof row?.symbol !== "string" || - typeof row?.decimals !== "number" || - !/^0x[0-9a-fA-F]+$/.test(row.chainId) || - !/^0x[0-9a-fA-F]{40}$/.test(row.address) - ) { - continue; - } - const chainId = ChainUtils.asEVMChainId(row.chainId); - const address = EVMAccountAddress(row.address as `0x${string}`); - const type = - row.type === EAssetType.Native - ? EAssetType.Native - : row.type === EAssetType.Erc721 - ? EAssetType.Erc721 - : row.type === EAssetType.Erc1155 - ? EAssetType.Erc1155 - : EAssetType.Erc20; - const iconUrl = - typeof row.iconUrl === "string" && row.iconUrl.length > 0 - ? row.iconUrl - : undefined; - assets.push( - new TrackedAsset( - chainId, - address, - type, - row.name, - row.symbol, - row.decimals, - makeTrackedAssetId(chainId, address), - null, - iconUrl, - ), - ); - } - return assets; - } catch { - return []; - } - } - - private writeAssets(storageKey: string, assets: TrackedAsset[]): void { - // Persist only user-added (non-default) rows as NewTrackedAsset fields + id. - const userAssets = assets.filter( - (asset) => !isDefaultTrackedAsset(asset.chainId, asset.address), - ); - const blob: StoredBlob = { - assets: userAssets.map((asset) => ({ - chainId: asset.chainId, - address: asset.address, - type: asset.type, - name: asset.name, - symbol: asset.symbol, - decimals: asset.decimals, - id: asset.id, - ...(asset.iconUrl ? { iconUrl: asset.iconUrl } : {}), - })), - }; - this.storage.setItem(storageKey, JSON.stringify(blob)); - } -} +import { erc20Abi, type Address } from "viem"; +import { + ChainUtils, + EVMAccountAddress, + EVMContractAddress, + EVMChainId, +} from "@1shotapi/ows-types"; +import type { IBlockchainProvider } from "@1shotapi/ows-wallet-utils"; +import { + createMemoryStorageBackend, + type CredentialStorageBackend, +} from "../../../demo/local-storage-store"; +import type { IConfigProvider } from "../../interfaces/utils/IConfigProvider"; +import type { IEventBus } from "../../interfaces/utils/IEventBus"; +import type { ITrackedAssetRepository } from "../../interfaces/data/ITrackedAssetRepository"; +import { + DEFAULT_TRACKED_ASSETS, + isDefaultTrackedAsset, +} from "./HardcodedKnownAssetRepository"; +import { NewTrackedAsset, TrackedAsset } from "../../types/domain/TrackedAsset"; +import { EAssetType } from "../../types/enum/EAssetType"; +import { BalanceUpdatedEvent } from "../../types/events/BalanceUpdatedEvent"; +import { + makeTrackedAssetId, + type TrackedAssetId, +} from "../../types/primitives"; +import { + registerTrackedAssetIconUrl, + unregisterTrackedAssetIconUrl, + syncTrackedAssetIconUrls, +} from "../../utils/tokenIcons"; + +type StoredBlob = { + assets: Array<{ + chainId: string; + address: string; + type: string; + name: string; + symbol: string; + decimals: number; + id: string; + iconUrl?: string; + }>; +}; + +export type TrackedAssetRepositoryOptions = { + storage?: CredentialStorageBackend; +}; + +const EMPTY_OWNER = EVMAccountAddress("0x0"); + +export class LocalStorageTrackedAssetRepository + implements ITrackedAssetRepository +{ + private readonly storage: CredentialStorageBackend; + private readonly balanceCache = new Map(); + private storageKey: string | null = null; + + constructor( + private readonly blockchain: IBlockchainProvider, + private readonly eventBus: IEventBus, + private readonly configProvider: IConfigProvider, + options: TrackedAssetRepositoryOptions = {}, + ) { + this.storage = + options.storage ?? + (typeof localStorage !== "undefined" + ? localStorage + : createMemoryStorageBackend()); + } + + private async resolveStorageKey(): Promise { + if (this.storageKey) { + return this.storageKey; + } + const config = await this.configProvider.getConfig(); + this.storageKey = config.trackedAssetsStorageKey; + return this.storageKey; + } + + async list(chainId?: EVMChainId): Promise { + const storageKey = await this.resolveStorageKey(); + const assets = this.filterByChain( + this.mergeWithDefaults(this.readStoredAssets(storageKey)), + chainId, + ); + syncTrackedAssetIconUrls(assets); + // Cache only — no RPC. Call getBalances when balances are needed. + return assets.map((asset) => + this.balanceCache.has(asset.id) + ? asset.withBalance(this.balanceCache.get(asset.id)!) + : asset.withBalance(null), + ); + } + + async has( + chainId: EVMChainId, + address: EVMContractAddress, + ): Promise { + if (isDefaultTrackedAsset(chainId, address)) { + return true; + } + const storageKey = await this.resolveStorageKey(); + const key = makeTrackedAssetId(chainId, address); + return this.readStoredAssets(storageKey).some((asset) => asset.id === key); + } + + async add( + asset: NewTrackedAsset, + owner: EVMAccountAddress, + ): Promise { + if (isDefaultTrackedAsset(asset.chainId, asset.address)) { + const existing = TrackedAsset.fromNew(asset); + const [withBalance] = await this.ensureBalances([existing], owner, false); + return withBalance!; + } + + const storageKey = await this.resolveStorageKey(); + const assets = this.readStoredAssets(storageKey); + const key = makeTrackedAssetId(asset.chainId, asset.address); + const foundIndex = assets.findIndex((a) => a.id === key); + if (foundIndex >= 0) { + const found = assets[foundIndex]!; + if (asset.iconUrl !== undefined && asset.iconUrl !== found.iconUrl) { + const updated = TrackedAsset.fromNew( + found.withIconUrl(asset.iconUrl), + found.balance, + ); + assets[foundIndex] = updated; + this.writeAssets(storageKey, assets); + if (asset.iconUrl) { + registerTrackedAssetIconUrl(key, asset.iconUrl); + } else { + unregisterTrackedAssetIconUrl(key); + } + const [withBalance] = await this.ensureBalances([updated], owner, false); + return withBalance!; + } + const [withBalance] = await this.ensureBalances([found], owner, false); + return withBalance!; + } + + const tracked = TrackedAsset.fromNew(asset); + assets.push(tracked); + this.writeAssets(storageKey, assets); + if (tracked.iconUrl) { + registerTrackedAssetIconUrl(key, tracked.iconUrl); + } + const [withBalance] = await this.ensureBalances([tracked], owner, false); + return withBalance!; + } + + async remove( + chainId: EVMChainId, + address: EVMContractAddress, + ): Promise { + if (isDefaultTrackedAsset(chainId, address)) { + return; + } + const storageKey = await this.resolveStorageKey(); + const key = makeTrackedAssetId(chainId, address); + const next = this.readStoredAssets(storageKey).filter( + (asset) => asset.id !== key, + ); + this.writeAssets(storageKey, next); + this.balanceCache.delete(key); + unregisterTrackedAssetIconUrl(key); + } + + async getBalances( + owner: EVMAccountAddress, + options: { id: TrackedAssetId } | { chainId: EVMChainId }, + ): Promise { + const storageKey = await this.resolveStorageKey(); + const all = this.mergeWithDefaults(this.readStoredAssets(storageKey)); + syncTrackedAssetIconUrls(all); + + let targets: TrackedAsset[]; + if ("id" in options) { + this.balanceCache.delete(options.id); + targets = all.filter((asset) => asset.id === options.id); + } else { + const chainKey = String(options.chainId).toLowerCase(); + for (const asset of all) { + if (String(asset.chainId).toLowerCase() === chainKey) { + this.balanceCache.delete(asset.id); + } + } + targets = this.filterByChain(all, options.chainId); + } + + return this.ensureBalances(targets, owner, true); + } + + private filterByChain( + assets: TrackedAsset[], + chainId?: EVMChainId, + ): TrackedAsset[] { + if (chainId == null) return assets; + const key = String(chainId).toLowerCase(); + return assets.filter( + (asset) => String(asset.chainId).toLowerCase() === key, + ); + } + + private mergeWithDefaults(stored: TrackedAsset[]): TrackedAsset[] { + const seen = new Set(); + const merged: TrackedAsset[] = []; + for (const asset of DEFAULT_TRACKED_ASSETS) { + const key = makeTrackedAssetId(asset.chainId, asset.address); + seen.add(key); + merged.push(TrackedAsset.fromNew(asset)); + } + for (const asset of stored) { + if (seen.has(asset.id)) continue; + seen.add(asset.id); + merged.push(asset); + } + return merged; + } + + private async ensureBalances( + assets: TrackedAsset[], + owner: EVMAccountAddress, + forceEmit: boolean, + ): Promise { + let anyFetched = forceEmit; + + const result = await Promise.all( + assets.map(async (asset) => { + if (this.balanceCache.has(asset.id)) { + return asset.withBalance(this.balanceCache.get(asset.id)!); + } + + anyFetched = true; + const balance = await this.fetchBalance(asset, owner); + if (balance !== null) { + this.balanceCache.set(asset.id, balance); + } + return asset.withBalance(balance); + }), + ); + + if (anyFetched && result.length > 0) { + this.eventBus.emit(new BalanceUpdatedEvent(result)); + } + return result; + } + + private async fetchBalance( + asset: TrackedAsset, + owner: EVMAccountAddress, + ): Promise { + if (owner === EMPTY_OWNER) { + return null; + } + if (asset.type === EAssetType.Native) { + try { + const client = this.blockchain.getPublicClient(asset.chainId); + return await client.getBalance({ address: owner as Address }); + } catch (error: unknown) { + console.warn("[balances] getBalance failed", error); + return null; + } + } + if (asset.type !== EAssetType.Erc20) { + return null; + } + try { + const client = this.blockchain.getPublicClient(asset.chainId); + return await client.readContract({ + address: asset.address as Address, + abi: erc20Abi, + functionName: "balanceOf", + args: [owner as Address], + }); + } catch (error: unknown) { + console.warn("[balances] balanceOf failed", error); + return null; + } + } + + private readStoredAssets(storageKey: string): TrackedAsset[] { + const raw = this.storage.getItem(storageKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as StoredBlob; + if (!parsed || !Array.isArray(parsed.assets)) return []; + const assets: TrackedAsset[] = []; + for (const row of parsed.assets) { + if ( + typeof row?.chainId !== "string" || + typeof row?.address !== "string" || + typeof row?.name !== "string" || + typeof row?.symbol !== "string" || + typeof row?.decimals !== "number" || + !/^0x[0-9a-fA-F]+$/.test(row.chainId) || + !/^0x[0-9a-fA-F]{40}$/.test(row.address) + ) { + continue; + } + const chainId = ChainUtils.asEVMChainId(row.chainId); + const address = EVMContractAddress(row.address as `0x${string}`); + const type = + row.type === EAssetType.Native + ? EAssetType.Native + : row.type === EAssetType.Erc721 + ? EAssetType.Erc721 + : row.type === EAssetType.Erc1155 + ? EAssetType.Erc1155 + : EAssetType.Erc20; + const iconUrl = + typeof row.iconUrl === "string" && row.iconUrl.length > 0 + ? row.iconUrl + : undefined; + assets.push( + new TrackedAsset( + chainId, + address, + type, + row.name, + row.symbol, + row.decimals, + makeTrackedAssetId(chainId, address), + null, + iconUrl, + ), + ); + } + return assets; + } catch { + return []; + } + } + + private writeAssets(storageKey: string, assets: TrackedAsset[]): void { + // Persist only user-added (non-default) rows as NewTrackedAsset fields + id. + const userAssets = assets.filter( + (asset) => !isDefaultTrackedAsset(asset.chainId, asset.address), + ); + const blob: StoredBlob = { + assets: userAssets.map((asset) => ({ + chainId: asset.chainId, + address: asset.address, + type: asset.type, + name: asset.name, + symbol: asset.symbol, + decimals: asset.decimals, + id: asset.id, + ...(asset.iconUrl ? { iconUrl: asset.iconUrl } : {}), + })), + }; + this.storage.setItem(storageKey, JSON.stringify(blob)); + } +} diff --git a/src/lib/implementations/data/OneshotRelayerRepository.ts b/src/lib/implementations/data/OneshotRelayerRepository.ts index 0470421..47f2a7b 100644 --- a/src/lib/implementations/data/OneshotRelayerRepository.ts +++ b/src/lib/implementations/data/OneshotRelayerRepository.ts @@ -1,5 +1,6 @@ import { EVMAccountAddress, + EVMContractAddress, EVMTransactionHash, HexString, RelayerTransactionIdSchema, @@ -60,7 +61,7 @@ export class OneshotRelayerRepository implements IOneshotRelayerRepository { feeCollector: EVMAccountAddress(entry.feeCollector as `0x${string}`), targetAddress: EVMAccountAddress(entry.targetAddress as `0x${string}`), tokens: entry.tokens.map((token) => ({ - address: EVMAccountAddress(token.address as `0x${string}`), + address: EVMContractAddress(token.address as `0x${string}`), symbol: token.symbol ?? "TOKEN", name: token.name, decimals: Number(token.decimals), @@ -73,7 +74,7 @@ export class OneshotRelayerRepository implements IOneshotRelayerRepository { async getFeeData( relayerUrl: string, chainId: EVMChainId, - token: ReturnType, + token: EVMContractAddress, ): Promise { const decimal = chainIdToDecimal(chainId); const result = await this.postJsonRpc<{ @@ -99,7 +100,7 @@ export class OneshotRelayerRepository implements IOneshotRelayerRepository { return { chainId: result.chainId, token: { - address: EVMAccountAddress(result.token.address as `0x${string}`), + address: EVMContractAddress(result.token.address as `0x${string}`), symbol: result.token.symbol ?? "TOKEN", name: result.token.name, decimals: Number(result.token.decimals), @@ -280,7 +281,7 @@ function mapEstimateResult(result: RawEstimateResult): IRelayerEstimateResult { return { success: result.success, paymentTokenAddress: result.paymentTokenAddress - ? EVMAccountAddress(result.paymentTokenAddress as `0x${string}`) + ? EVMContractAddress(result.paymentTokenAddress as `0x${string}`) : undefined, paymentChain: result.paymentChain, gasUsed: result.gasUsed ?? {}, diff --git a/src/lib/implementations/data/relayerKnownAssets.ts b/src/lib/implementations/data/relayerKnownAssets.ts index 6267952..4449134 100644 --- a/src/lib/implementations/data/relayerKnownAssets.ts +++ b/src/lib/implementations/data/relayerKnownAssets.ts @@ -1,6 +1,5 @@ import { - EVMAccountAddress, - type EVMAccountAddress as EVMAccountAddressType, + EVMContractAddress, type EVMChainId as EVMChainIdType, } from "@1shotapi/ows-types"; import { EChain } from "../../types/enum/EChain"; @@ -14,7 +13,7 @@ import { type ISeedRow = { chainId: EVMChainIdType; - address: EVMAccountAddressType; + address: EVMContractAddress; symbol: string; name: string; decimals: number; @@ -49,7 +48,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Arc mainnet (5042) — USDC is gas; same pinned ERC-20 as testnet { chainId: EChain.Arc, - address: EVMAccountAddress( + address: EVMContractAddress( "0x3600000000000000000000000000000000000000", ), symbol: "USDC", @@ -61,7 +60,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Arc Testnet (5042002) — native USDC { chainId: EChain.ArcTestnet, - address: EVMAccountAddress( + address: EVMContractAddress( "0x3600000000000000000000000000000000000000", ), symbol: "USDC", @@ -73,7 +72,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Robinhood (4663) — official USDG (USDC is not deployed) { chainId: EChain.Robinhood, - address: EVMAccountAddress( + address: EVMContractAddress( "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", ), symbol: "USDG", @@ -84,7 +83,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Ethereum mainnet (1) { chainId: EChain.Ethereum, - address: EVMAccountAddress( + address: EVMContractAddress( "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", ), symbol: "USDC", @@ -95,7 +94,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Ethereum, - address: EVMAccountAddress( + address: EVMContractAddress( "0xdac17f958d2ee523a2206206994597c13d831ec7", ), symbol: "USDT", @@ -104,7 +103,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Ethereum, - address: EVMAccountAddress( + address: EVMContractAddress( "0xe343167631d89B6Ffc58B88d6b7fB0228795491D", ), symbol: "USDG", @@ -113,7 +112,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Ethereum, - address: EVMAccountAddress( + address: EVMContractAddress( "0xacA92E438df0B2401fF60dA7E4337B687a2435DA", ), symbol: "mUSD", @@ -123,7 +122,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Optimism (10) { chainId: EChain.Optimism, - address: EVMAccountAddress( + address: EVMContractAddress( "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", ), symbol: "USDC", @@ -134,7 +133,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Optimism, - address: EVMAccountAddress( + address: EVMContractAddress( "0x94b008aa00579c1307b0ef2c499ad98a8ce58e58", ), symbol: "USDT", @@ -144,7 +143,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // BSC (56) { chainId: EChain.Bsc, - address: EVMAccountAddress( + address: EVMContractAddress( "0x8AC76a51cc950d9822D68b83fe1Ad97B32Cd580d", ), symbol: "USDC", @@ -154,7 +153,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Bsc, - address: EVMAccountAddress( + address: EVMContractAddress( "0x55d398326f99059fF775485246999027B3197955", ), symbol: "USDT", @@ -164,7 +163,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Unichain (130) { chainId: EChain.Unichain, - address: EVMAccountAddress( + address: EVMContractAddress( "0x078D782b760474a361dDA0AF3839290b0EF57AD6", ), symbol: "USDC", @@ -175,7 +174,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Unichain, - address: EVMAccountAddress( + address: EVMContractAddress( "0xfe97E85d13ABD9c1c33384E796F10B73905637cE", ), symbol: "USD₮0", @@ -185,7 +184,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Polygon (137) { chainId: EChain.Polygon, - address: EVMAccountAddress( + address: EVMContractAddress( "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", ), symbol: "USDC", @@ -196,7 +195,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Polygon, - address: EVMAccountAddress( + address: EVMContractAddress( "0xc2132D05D31c914a87C6611C10748AeB04B58e8F", ), symbol: "USDT", @@ -206,7 +205,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Sonic (146) { chainId: EChain.Sonic, - address: EVMAccountAddress( + address: EVMContractAddress( "0x29219dd400f2Bf60E5a23d13Be72B486D4038894", ), symbol: "USDC", @@ -218,7 +217,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Monad (143) { chainId: EChain.Monad, - address: EVMAccountAddress( + address: EVMContractAddress( "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", ), symbol: "USDC", @@ -229,7 +228,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Monad, - address: EVMAccountAddress( + address: EVMContractAddress( "0xe7cd86e13AC4309349F30B3435a9d337750fC82D", ), symbol: "USDT0", @@ -239,7 +238,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Base (8453) { chainId: EChain.Base, - address: EVMAccountAddress( + address: EVMContractAddress( "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", ), symbol: "USDC", @@ -250,7 +249,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Base, - address: EVMAccountAddress( + address: EVMContractAddress( "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2", ), symbol: "USDT", @@ -260,7 +259,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Arbitrum (42161) { chainId: EChain.Arbitrum, - address: EVMAccountAddress( + address: EVMContractAddress( "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", ), symbol: "USDC", @@ -271,7 +270,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Arbitrum, - address: EVMAccountAddress( + address: EVMContractAddress( "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", ), symbol: "USDT", @@ -281,7 +280,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Celo (42220) { chainId: EChain.Celo, - address: EVMAccountAddress( + address: EVMContractAddress( "0xcebA9300f2b948710d2653dd7b07f33A8B32118C", ), symbol: "USDC", @@ -291,7 +290,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Celo, - address: EVMAccountAddress( + address: EVMContractAddress( "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e", ), symbol: "USDT", @@ -301,7 +300,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Linea (59144) { chainId: EChain.Linea, - address: EVMAccountAddress( + address: EVMContractAddress( "0x176211869cA2b568f2A7D4EE941E073a821EE1ff", ), symbol: "USDC", @@ -312,7 +311,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Linea, - address: EVMAccountAddress( + address: EVMContractAddress( "0xA219439258ca9da29E9Cc4cE5596924745e12B93", ), symbol: "USDT", @@ -321,7 +320,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ }, { chainId: EChain.Linea, - address: EVMAccountAddress( + address: EVMContractAddress( "0xaca92e438df0b2401ff60da7e4337b687a2435da", ), symbol: "mUSD", @@ -331,7 +330,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Base Sepolia (84532) { chainId: EChain.BaseSepolia, - address: EVMAccountAddress( + address: EVMContractAddress( "0x036CbD53842c5426634e7929541eC2318f3dCF7e", ), symbol: "USDC", @@ -343,7 +342,7 @@ const SEED_ROWS: readonly ISeedRow[] = [ // Sepolia (11155111) { chainId: EChain.Sepolia, - address: EVMAccountAddress( + address: EVMContractAddress( "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", ), symbol: "USDC", @@ -366,7 +365,7 @@ const BY_KEY = new Map( export function getKnownAssetIconUrl( chainId: EVMChainIdType, - address: EVMAccountAddressType, + address: EVMContractAddress, ): string | undefined { return BY_KEY.get(makeTrackedAssetId(chainId, address))?.iconUrl; } diff --git a/src/lib/implementations/utils/TransactionUtils.ts b/src/lib/implementations/utils/TransactionUtils.ts index c9d55dc..a819d8a 100644 --- a/src/lib/implementations/utils/TransactionUtils.ts +++ b/src/lib/implementations/utils/TransactionUtils.ts @@ -7,6 +7,7 @@ import { } from "viem"; import { EVMAccountAddress, + EVMContractAddress, type EVMChainId, type HexString, } from "@1shotapi/ows-types"; @@ -18,7 +19,7 @@ import type { /** EVM transfer helpers (decode, amount formatting, host/chain labels). */ export class TransactionUtils implements ITransactionUtils { tryDecodeErc20Transfer( - to: EVMAccountAddress | null, + to: EVMContractAddress | null, data: HexString, ): IDecodedErc20Transfer | null { if (!to || !data || String(data) === "0x" || data.length < 10) { diff --git a/src/lib/interfaces/business/IBridgeService.ts b/src/lib/interfaces/business/IBridgeService.ts index cf764bc..7977971 100644 --- a/src/lib/interfaces/business/IBridgeService.ts +++ b/src/lib/interfaces/business/IBridgeService.ts @@ -1,6 +1,7 @@ import type { EVMAccountAddress, EVMChainId, + EVMContractAddress, EVMTransactionHash, HexString, } from "@1shotapi/ows-types"; @@ -40,7 +41,7 @@ export interface ICctpBridgeQuote { } export interface ICctpBridgePayment { - paymentToken: EVMAccountAddress; + paymentToken: EVMContractAddress; feeAtoms: TokenAmount; paymentChainId?: EVMChainId; } diff --git a/src/lib/interfaces/business/IDelegationService.ts b/src/lib/interfaces/business/IDelegationService.ts index f245991..563e590 100644 --- a/src/lib/interfaces/business/IDelegationService.ts +++ b/src/lib/interfaces/business/IDelegationService.ts @@ -1,6 +1,6 @@ import type { - EVMAccountAddress, EVMChainId, + EVMContractAddress, HexString, IExecutionPermission, IExecutionPermissionRequest, @@ -46,8 +46,10 @@ export interface ICreateExecutionPermissionsParams { export interface ICancelDelegationParams extends IRelayerSendUiCallbacks { chainId: EVMChainId; - paymentToken: EVMAccountAddress; + paymentToken: EVMContractAddress; feeAtoms: TokenAmount; + /** Fee payment chain — defaults to `chainId`. */ + paymentChainId?: EVMChainId; /** Vault row when canceling from the Delegations tab. */ stored?: IStoredDelegation; /** @@ -64,19 +66,12 @@ export type ICancelDelegationItem = { permissionContext?: HexString; }; -/** 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 { items: readonly ICancelDelegationItem[]; - /** One payment per unique chain in `items` (same key as `chainId`). */ - payments: readonly ICancelDelegationChainPayment[]; + /** Fee token (local-first / Arc USDC) — one payment for the whole batch. */ + paymentToken: EVMContractAddress; + feeAtoms: TokenAmount; + paymentChainId: EVMChainId; } export type IBuildCancelWorkParams = { @@ -109,8 +104,9 @@ export interface IDelegationService { buildCancelWork(params: IBuildCancelWorkParams): Promise; /** - * Disable one or more delegations. Groups by chain and submits one - * `sendViaRelayer(work[])` per chain (one fee + one passkey each). + * Disable one or more delegations. Groups ExactCalldata work by execution + * chain and submits one Multichain (or single-chain) 7710 send — one fee, + * one passkey. Returns one result per execution chain. */ cancelDelegations( params: ICancelDelegationsParams, diff --git a/src/lib/interfaces/business/ITransactionService.ts b/src/lib/interfaces/business/ITransactionService.ts index 10adfd9..64994c0 100644 --- a/src/lib/interfaces/business/ITransactionService.ts +++ b/src/lib/interfaces/business/ITransactionService.ts @@ -1,6 +1,7 @@ import type { EVMAccountAddress, EVMChainId, + EVMContractAddress, HexString, } from "@1shotapi/ows-types"; import type { @@ -13,16 +14,20 @@ import type { IWalletUpgradeStatus } from "../../types/domain/WalletUpgradeStatu import type { TokenAmount } from "../../types/primitives"; export interface IPaymentTokenOption { - address: EVMAccountAddress; + /** ERC-20 (or other) payment token contract. */ + address: EVMContractAddress; symbol: string; name?: string; decimals: number; balance: TokenAmount; + /** Chain this payment token lives on (fee ExactCalldata chain). */ + chainId: EVMChainId; + chainName: string; } export interface IPaymentQuote { tokens: IPaymentTokenOption[]; - selectedToken: EVMAccountAddress; + selectedToken: EVMContractAddress; /** Chain where the fee ExactCalldata runs (may differ from the work chain). */ paymentChainId: EVMChainId; paymentChainName: string; @@ -34,7 +39,7 @@ export interface IPaymentQuote { } export interface ITransactionWork { - to: EVMAccountAddress; + to: EVMAccountAddress | EVMContractAddress; data: HexString; value?: bigint; } @@ -42,7 +47,7 @@ export interface ITransactionWork { export type ISendViaRelayerParams = { chainId: EVMChainId; work: ITransactionWork | ITransactionWork[]; - paymentToken: EVMAccountAddress; + paymentToken: EVMContractAddress; /** Fee atoms from the confirm UI quote; may be adjusted after estimate. */ feeAtoms: TokenAmount; /** @@ -80,7 +85,7 @@ export interface ITransactionService { chainId: EVMChainId, owner: EVMAccountAddress, work: ITransactionWork | ITransactionWork[], - preferredToken?: EVMAccountAddress, + preferredToken?: EVMContractAddress, ): Promise; /** Unsigned fee quote for multi/single-chain EIP-7702 activation. */ @@ -90,6 +95,19 @@ export interface ITransactionService { payment: IRelayerPayment, ): Promise; + /** + * Combined unsigned fee quote for ExactCalldata work across one or more + * chains (local-first payment, then Arc USDC). + */ + quotePaymentMultichain( + owner: EVMAccountAddress, + workByChain: readonly { + chainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + }[], + preferredToken?: EVMContractAddress, + ): Promise; + /** * Submit EIP-7702 activation (no-op work + USDC fee) and poll to confirm. */ @@ -110,7 +128,7 @@ export interface ITransactionService { chainId: EVMChainId, work: ITransactionWork, options?: { - paymentToken?: EVMAccountAddress; + paymentToken?: EVMContractAddress; feeAtoms?: TokenAmount; paymentChainId?: EVMChainId; authorizationList?: IRelayerAuthorizationEntry[]; diff --git a/src/lib/interfaces/business/index.ts b/src/lib/interfaces/business/index.ts index 27f9666..9fc4199 100644 --- a/src/lib/interfaces/business/index.ts +++ b/src/lib/interfaces/business/index.ts @@ -23,7 +23,6 @@ export type { export { IBitcoinServiceType } from "./IBitcoinService"; export type { IBuildCancelWorkParams, - ICancelDelegationChainPayment, ICancelDelegationItem, ICancelDelegationParams, ICancelDelegationResult, diff --git a/src/lib/interfaces/business/utils/ICCTPUtils.ts b/src/lib/interfaces/business/utils/ICCTPUtils.ts index feaf465..3cb3252 100644 --- a/src/lib/interfaces/business/utils/ICCTPUtils.ts +++ b/src/lib/interfaces/business/utils/ICCTPUtils.ts @@ -1,6 +1,7 @@ import type { EVMAccountAddress, EVMChainId, + EVMContractAddress, HexString, UriString, } from "@1shotapi/ows-types"; @@ -34,7 +35,7 @@ export interface IEncodeDepositForBurnWithHookParams { totalBurn: bigint; destDomain: ECircleDomainId; mintRecipient: EVMAccountAddress; - burnToken: EVMAccountAddress; + burnToken: EVMContractAddress; maxFee: bigint; minFinalityThreshold: number; } @@ -42,7 +43,7 @@ export interface IEncodeDepositForBurnWithHookParams { export interface IBuildCctpRelayerWorkParams { allowance: bigint; totalBurn: bigint; - usdcAddress: EVMAccountAddress; + usdcAddress: EVMContractAddress; tokenMessenger: EVMAccountAddress; approveData: HexString; burnData: HexString; diff --git a/src/lib/interfaces/business/utils/IPaymentTokenUtils.ts b/src/lib/interfaces/business/utils/IPaymentTokenUtils.ts index bd336f2..4e3fe76 100644 --- a/src/lib/interfaces/business/utils/IPaymentTokenUtils.ts +++ b/src/lib/interfaces/business/utils/IPaymentTokenUtils.ts @@ -1,25 +1,41 @@ -import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; +import type { + EVMAccountAddress, + EVMChainId, + EVMContractAddress, +} from "@1shotapi/ows-types"; +import type { IPaymentTokenOption } from "../ITransactionService"; 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. + * Work chains (Arc preferred) → Arc → other wallet relayer chains. */ 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. + * 1. `preferredToken` wins on any candidate chain (execution → Arc → others). + * 2. Among funded execution chains, prefer Arc when funded; else first funded. + * 3. Else Arc USDC when funded (even if Arc is not an execution chain). + * 4. Else first other wallet relayer chain with a funded payment token. + * 5. Else null. + * + * Token pick within a chain: USDC → USDT → first funded (`preferredToken` wins). */ resolvePayment( owner: EVMAccountAddress, executionChainIds: readonly EVMChainId[], - preferredToken?: EVMAccountAddress, + preferredToken?: EVMContractAddress, ): Promise; + + /** + * All payment-token options across execution chains, Arc, and other funded + * relayer chains — for the fee-picker Select. + */ + listPaymentOptions( + owner: EVMAccountAddress, + executionChainIds: readonly EVMChainId[], + ): Promise; } diff --git a/src/lib/interfaces/business/utils/ITransactionUtils.ts b/src/lib/interfaces/business/utils/ITransactionUtils.ts index 1113aec..5b08b6f 100644 --- a/src/lib/interfaces/business/utils/ITransactionUtils.ts +++ b/src/lib/interfaces/business/utils/ITransactionUtils.ts @@ -2,6 +2,7 @@ import type { LocalAccount } from "viem/accounts"; import type { EVMAccountAddress, EVMChainId, + EVMContractAddress, } from "@1shotapi/ows-types"; import type { IRelayerAuthorizationEntry, @@ -53,7 +54,7 @@ export interface ITransactionUtils { chainId: EVMChainId, owner: EVMAccountAddress, work: ITransactionWork | ITransactionWork[], - preferredToken?: EVMAccountAddress, + preferredToken?: EVMContractAddress, ): Promise; /** @@ -66,6 +67,20 @@ export interface ITransactionUtils { payment: IRelayerPayment, ): Promise; + /** + * Combined unsigned fee quote for ExactCalldata work across one or more + * chains (local-first payment, then Arc USDC). Uses Multichain estimate when + * payment ≠ sole work chain or there are multiple work chains. + */ + quotePaymentMultichain( + owner: EVMAccountAddress, + workByChain: readonly { + chainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + }[], + preferredToken?: EVMContractAddress, + ): Promise; + /** * Public-relayer ExactCalldata fee + work path: optional EIP-7702 upgrade, * estimate, send, poll. `work` may be one item (Send) or several @@ -75,7 +90,7 @@ export interface ITransactionUtils { sendViaRelayer(args: { chainId: EVMChainId; work: ITransactionWork | ITransactionWork[]; - paymentToken: EVMAccountAddress; + paymentToken: EVMContractAddress; feeAtoms: TokenAmount; /** Defaults to `chainId`. */ paymentChainId?: EVMChainId; @@ -85,6 +100,22 @@ export interface ITransactionUtils { prefetchRelayerVaultAssertion?: boolean; } & IRelayerSendUiCallbacks): Promise; + /** + * One Multichain (or single-chain) 7710 submit for ExactCalldata work on + * multiple execution chains — one fee, one passkey. Returns one result per + * entry in `workByChain` (same order). + */ + sendViaRelayerMultichain(args: { + workByChain: readonly { + chainId: EVMChainId; + work: ITransactionWork | ITransactionWork[]; + }[]; + paymentToken: EVMContractAddress; + feeAtoms: TokenAmount; + paymentChainId: EVMChainId; + 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 diff --git a/src/lib/interfaces/data/IAssetActivityRepository.ts b/src/lib/interfaces/data/IAssetActivityRepository.ts index ffe6fc1..63408c5 100644 --- a/src/lib/interfaces/data/IAssetActivityRepository.ts +++ b/src/lib/interfaces/data/IAssetActivityRepository.ts @@ -1,6 +1,7 @@ import type { EVMAccountAddress, EVMChainId, + EVMContractAddress, EVMTransactionHash, } from "@1shotapi/ows-types"; import type { AssetActivity } from "../../types/domain/AssetActivity"; @@ -8,7 +9,7 @@ import type { TrackedAsset } from "../../types/domain/TrackedAsset"; export interface IRecordSentActivityParams { chainId: EVMChainId; - tokenAddress: EVMAccountAddress; + tokenAddress: EVMContractAddress; owner: EVMAccountAddress; to: EVMAccountAddress; amount: bigint; diff --git a/src/lib/interfaces/data/IEVMRepository.ts b/src/lib/interfaces/data/IEVMRepository.ts index 251c7d6..6eabb3c 100644 --- a/src/lib/interfaces/data/IEVMRepository.ts +++ b/src/lib/interfaces/data/IEVMRepository.ts @@ -1,6 +1,7 @@ import type { EVMAccountAddress, EVMChainId, + EVMContractAddress, HexString, } from "@1shotapi/ows-types"; import type { ISendTransactionResult } from "./IOneshotRelayerRepository"; @@ -19,7 +20,7 @@ export interface IEvmGasOverrides { export interface IEVMRepository { broadcastRawTransaction( chainId: EVMChainId, - to: EVMAccountAddress, + to: EVMAccountAddress | EVMContractAddress, data: HexString, value?: bigint, gasOverrides?: IEvmGasOverrides, diff --git a/src/lib/interfaces/data/IKnownAssetRepository.ts b/src/lib/interfaces/data/IKnownAssetRepository.ts index bc3f0c9..261681e 100644 --- a/src/lib/interfaces/data/IKnownAssetRepository.ts +++ b/src/lib/interfaces/data/IKnownAssetRepository.ts @@ -1,30 +1,30 @@ -import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; -import type { KnownAsset, NewTrackedAsset } from "../../types/domain"; - -export interface IKnownAssetRepository { - getKnownAsset( - chainId: EVMChainId, - address: EVMAccountAddress, - ): Promise; - - /** Native Circle USDC on `chainId` when the catalog marks `useCCTPBridge`. */ - getCctpBridgeAsset(chainId: EVMChainId): Promise; - - /** Buyable stable on `chainId` for Circle onramp (defaults to USDC). */ - getOnrampAsset( - chainId: EVMChainId, - symbol?: string, - ): Promise; - - /** - * Catalog hit or on-chain ERC-20 probe → NewTrackedAsset. - * Throws if the address is not a contract or not ERC-20. - */ - resolveForTracking( - chainId: EVMChainId, - address: EVMAccountAddress, - owner: EVMAccountAddress, - ): Promise; -} - -export const IKnownAssetRepositoryType = Symbol.for("IKnownAssetRepository"); +import type { EVMAccountAddress, EVMChainId, EVMContractAddress } from "@1shotapi/ows-types"; +import type { KnownAsset, NewTrackedAsset } from "../../types/domain"; + +export interface IKnownAssetRepository { + getKnownAsset( + chainId: EVMChainId, + address: EVMContractAddress, + ): Promise; + + /** Native Circle USDC on `chainId` when the catalog marks `useCCTPBridge`. */ + getCctpBridgeAsset(chainId: EVMChainId): Promise; + + /** Buyable stable on `chainId` for Circle onramp (defaults to USDC). */ + getOnrampAsset( + chainId: EVMChainId, + symbol?: string, + ): Promise; + + /** + * Catalog hit or on-chain ERC-20 probe → NewTrackedAsset. + * Throws if the address is not a contract or not ERC-20. + */ + resolveForTracking( + chainId: EVMChainId, + address: EVMContractAddress, + owner: EVMAccountAddress, + ): Promise; +} + +export const IKnownAssetRepositoryType = Symbol.for("IKnownAssetRepository"); diff --git a/src/lib/interfaces/data/IOneshotRelayerRepository.ts b/src/lib/interfaces/data/IOneshotRelayerRepository.ts index 9d33511..aa37eb8 100644 --- a/src/lib/interfaces/data/IOneshotRelayerRepository.ts +++ b/src/lib/interfaces/data/IOneshotRelayerRepository.ts @@ -1,6 +1,7 @@ import type { EVMAccountAddress, EVMChainId, + EVMContractAddress, EVMTransactionHash, HexString, RelayerTransactionId, @@ -12,7 +13,7 @@ export interface ISendTransactionResult { } export interface IRelayerPaymentToken { - address: EVMAccountAddress; + address: EVMContractAddress; symbol: string; name?: string; decimals: number; @@ -37,7 +38,8 @@ export interface IRelayerFeeData { } export interface IRelayer7710Execution { - target: EVMAccountAddress; + /** Call target — EOA or contract (e.g. ERC-20 fee token). */ + target: EVMAccountAddress | EVMContractAddress; value: string; data: HexString; } @@ -69,7 +71,7 @@ export interface IRelayer7710Params { export interface IRelayerEstimateResult { success: boolean; - paymentTokenAddress?: EVMAccountAddress; + paymentTokenAddress?: EVMContractAddress; paymentChain?: number; gasUsed: Record; requiredPaymentAmount?: string; @@ -105,7 +107,7 @@ export interface IOneshotRelayerRepository { getFeeData( relayerUrl: string, chainId: EVMChainId, - token: EVMAccountAddress, + token: EVMContractAddress, ): Promise; estimate7710Transaction( diff --git a/src/lib/interfaces/data/ITrackedAssetRepository.ts b/src/lib/interfaces/data/ITrackedAssetRepository.ts index 190223c..b5c2ad8 100644 --- a/src/lib/interfaces/data/ITrackedAssetRepository.ts +++ b/src/lib/interfaces/data/ITrackedAssetRepository.ts @@ -1,29 +1,29 @@ -import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; -import type { TrackedAssetId } from "../../types/primitives"; -import type { NewTrackedAsset, TrackedAsset } from "../../types/domain"; - -export interface ITrackedAssetRepository { - /** - * Catalog + stored assets. Optional `chainId` scopes the result. - * Does **not** hit RPC — balances come from session cache only (else `null`). - */ - list(chainId?: EVMChainId): Promise; - has(chainId: EVMChainId, address: EVMAccountAddress): Promise; - add( - asset: NewTrackedAsset, - owner: EVMAccountAddress, - ): Promise; - remove(chainId: EVMChainId, address: EVMAccountAddress): Promise; - /** - * Network balance fetch. Pass `id` for one asset, or `chainId` for every - * tracked asset on that chain. One of the two is required. - */ - getBalances( - owner: EVMAccountAddress, - options: { id: TrackedAssetId } | { chainId: EVMChainId }, - ): Promise; -} - -export const ITrackedAssetRepositoryType = Symbol.for( - "ITrackedAssetRepository", -); +import type { EVMAccountAddress, EVMChainId, EVMContractAddress } from "@1shotapi/ows-types"; +import type { TrackedAssetId } from "../../types/primitives"; +import type { NewTrackedAsset, TrackedAsset } from "../../types/domain"; + +export interface ITrackedAssetRepository { + /** + * Catalog + stored assets. Optional `chainId` scopes the result. + * Does **not** hit RPC — balances come from session cache only (else `null`). + */ + list(chainId?: EVMChainId): Promise; + has(chainId: EVMChainId, address: EVMContractAddress): Promise; + add( + asset: NewTrackedAsset, + owner: EVMAccountAddress, + ): Promise; + remove(chainId: EVMChainId, address: EVMContractAddress): Promise; + /** + * Network balance fetch. Pass `id` for one asset, or `chainId` for every + * tracked asset on that chain. One of the two is required. + */ + getBalances( + owner: EVMAccountAddress, + options: { id: TrackedAssetId } | { chainId: EVMChainId }, + ): Promise; +} + +export const ITrackedAssetRepositoryType = Symbol.for( + "ITrackedAssetRepository", +); diff --git a/src/lib/interfaces/utils/ITransactionUtils.ts b/src/lib/interfaces/utils/ITransactionUtils.ts index 0e1120b..0126009 100644 --- a/src/lib/interfaces/utils/ITransactionUtils.ts +++ b/src/lib/interfaces/utils/ITransactionUtils.ts @@ -1,11 +1,12 @@ import type { EVMAccountAddress, + EVMContractAddress, HexString, OWSChainId, } from "@1shotapi/ows-types"; export interface IDecodedErc20Transfer { - tokenAddress: EVMAccountAddress; + tokenAddress: EVMContractAddress; recipient: EVMAccountAddress; amount: bigint; } @@ -13,7 +14,7 @@ export interface IDecodedErc20Transfer { export interface ITransactionUtils { /** Decode ERC-20 `transfer(address,uint256)` when calldata matches. */ tryDecodeErc20Transfer( - to: EVMAccountAddress | null, + to: EVMContractAddress | null, data: HexString, ): IDecodedErc20Transfer | null; diff --git a/src/lib/types/domain/AssetActivity.ts b/src/lib/types/domain/AssetActivity.ts index 8d60862..728c664 100644 --- a/src/lib/types/domain/AssetActivity.ts +++ b/src/lib/types/domain/AssetActivity.ts @@ -1,6 +1,7 @@ import type { EVMAccountAddress, EVMChainId, + EVMContractAddress, EVMTransactionHash, } from "@1shotapi/ows-types"; import type { EAssetActivityKind } from "../enum/EAssetActivityKind"; @@ -12,7 +13,7 @@ export class AssetActivity { constructor( public readonly hash: EVMTransactionHash, public readonly chainId: EVMChainId, - public readonly tokenAddress: EVMAccountAddress, + public readonly tokenAddress: EVMContractAddress, public readonly trackedAssetId: TrackedAssetId, public readonly owner: EVMAccountAddress, public readonly counterparty: EVMAccountAddress, diff --git a/src/lib/types/domain/KnownAsset.ts b/src/lib/types/domain/KnownAsset.ts index 504d517..f9f922b 100644 --- a/src/lib/types/domain/KnownAsset.ts +++ b/src/lib/types/domain/KnownAsset.ts @@ -1,20 +1,21 @@ -import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; -import type { EAssetType } from "../enum/EAssetType"; - -/** Catalog metadata for a known token (hardcoded registry). */ -export class KnownAsset { - constructor( - public readonly chainId: EVMChainId, - public readonly address: EVMAccountAddress, - public readonly type: EAssetType, - public readonly name: string, - public readonly symbol: string, - public readonly decimals: number, - public readonly useCCTPBridge: boolean, - /** When true, Asset Details shows Circle onramp Buy. */ - public readonly canBuy: boolean = false, - /** Higher weight sorts above peers in Balances defaults (e.g. stable > native). */ - public readonly weight: number = 0, - public readonly iconUrl?: string, - ) {} -} +import type { EVMChainId, EVMContractAddress } from "@1shotapi/ows-types"; +import type { EAssetType } from "../enum/EAssetType"; + +/** Catalog metadata for a known token (hardcoded registry). */ +export class KnownAsset { + constructor( + public readonly chainId: EVMChainId, + /** Token contract, or zero address for native. */ + public readonly address: EVMContractAddress, + public readonly type: EAssetType, + public readonly name: string, + public readonly symbol: string, + public readonly decimals: number, + public readonly useCCTPBridge: boolean, + /** When true, Asset Details shows Circle onramp Buy. */ + public readonly canBuy: boolean = false, + /** Higher weight sorts above peers in Balances defaults (e.g. stable > native). */ + public readonly weight: number = 0, + public readonly iconUrl?: string, + ) {} +} diff --git a/src/lib/types/domain/RelayerPayment.ts b/src/lib/types/domain/RelayerPayment.ts index 71a018a..c70f6ea 100644 --- a/src/lib/types/domain/RelayerPayment.ts +++ b/src/lib/types/domain/RelayerPayment.ts @@ -1,7 +1,4 @@ -import type { - EVMAccountAddress, - EVMChainId, -} from "@1shotapi/ows-types"; +import type { EVMChainId, EVMContractAddress } from "@1shotapi/ows-types"; import type { TokenAmount } from "../primitives"; /** @@ -10,7 +7,8 @@ import type { TokenAmount } from "../primitives"; */ export interface IRelayerPayment { paymentChainId: EVMChainId; - paymentToken: EVMAccountAddress; + /** ERC-20 payment token contract on `paymentChainId`. */ + paymentToken: EVMContractAddress; /** Human-readable payment-chain label for confirm UI. */ paymentChainName: string; balance: TokenAmount; diff --git a/src/lib/types/domain/RelayerSendUi.ts b/src/lib/types/domain/RelayerSendUi.ts index 240e7e1..88fc482 100644 --- a/src/lib/types/domain/RelayerSendUi.ts +++ b/src/lib/types/domain/RelayerSendUi.ts @@ -1,11 +1,11 @@ -import type { EVMAccountAddress } from "@1shotapi/ows-types"; +import type { EVMContractAddress } from "@1shotapi/ows-types"; import type { TokenAmount } from "../primitives"; /** Relayer-settled fee shown between prepare and submit. */ export type IFinalRelayerFee = { feeAtoms: TokenAmount; feeFormatted: string; - paymentToken: EVMAccountAddress; + paymentToken: EVMContractAddress; }; /** Branding-layer hooks for {@link ITransactionUtils.sendViaRelayer}. */ diff --git a/src/lib/types/domain/StoredDelegation.ts b/src/lib/types/domain/StoredDelegation.ts index 6921c58..d8c5530 100644 --- a/src/lib/types/domain/StoredDelegation.ts +++ b/src/lib/types/domain/StoredDelegation.ts @@ -59,7 +59,7 @@ export interface IDelegationSummary { permissionType: string; to: EVMAccountAddress; /** ERC-20 / LiFi input token when present on the stored permission. */ - tokenAddress?: EVMAccountAddress; + tokenAddress?: EVMContractAddress; /** Hex atom amount per period (`0x…`). */ periodAmount?: HexString; /** Period length in seconds. */ diff --git a/src/lib/types/domain/TrackedAsset.ts b/src/lib/types/domain/TrackedAsset.ts index dab4a9c..07c8188 100644 --- a/src/lib/types/domain/TrackedAsset.ts +++ b/src/lib/types/domain/TrackedAsset.ts @@ -1,110 +1,111 @@ -import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; -import { - makeTrackedAssetId, - type TrackedAssetId, -} from "../primitives/TrackedAssetId"; -import type { EAssetType } from "../enum/EAssetType"; -import type { KnownAsset } from "./KnownAsset"; - -/** - * Persistable tracked-asset DTO (no session id/balance). - * Constructed from the known catalog or an on-chain ERC-20 probe. - */ -export class NewTrackedAsset { - constructor( - public readonly chainId: EVMChainId, - public readonly address: EVMAccountAddress, - public readonly type: EAssetType, - public readonly name: string, - public readonly symbol: string, - public readonly decimals: number, - /** Optional host- or catalog-supplied HTTPS icon URL. */ - public readonly iconUrl?: string, - /** Higher weight sorts above peers in Balances defaults. */ - public readonly weight: number = 0, - /** When true, Asset Details shows Circle onramp Buy. */ - public readonly canBuy: boolean = false, - ) {} - - static fromKnown(known: KnownAsset): NewTrackedAsset { - return new NewTrackedAsset( - known.chainId, - known.address, - known.type, - known.name, - known.symbol, - known.decimals, - known.iconUrl, - known.weight, - known.canBuy, - ); - } - - withIconUrl(iconUrl: string | undefined): NewTrackedAsset { - return new NewTrackedAsset( - this.chainId, - this.address, - this.type, - this.name, - this.symbol, - this.decimals, - iconUrl, - this.weight, - this.canBuy, - ); - } -} - -/** Session-facing tracked asset with id and optional raw balance. */ -export class TrackedAsset extends NewTrackedAsset { - constructor( - chainId: EVMChainId, - address: EVMAccountAddress, - type: EAssetType, - name: string, - symbol: string, - decimals: number, - public readonly id: TrackedAssetId, - public balance: bigint | null, - iconUrl?: string, - weight: number = 0, - canBuy: boolean = false, - ) { - super(chainId, address, type, name, symbol, decimals, iconUrl, weight, canBuy); - } - - static fromNew( - asset: NewTrackedAsset, - balance: bigint | null = null, - ): TrackedAsset { - return new TrackedAsset( - asset.chainId, - asset.address, - asset.type, - asset.name, - asset.symbol, - asset.decimals, - makeTrackedAssetId(asset.chainId, asset.address), - balance, - asset.iconUrl, - asset.weight, - asset.canBuy, - ); - } - - withBalance(balance: bigint | null): TrackedAsset { - return new TrackedAsset( - this.chainId, - this.address, - this.type, - this.name, - this.symbol, - this.decimals, - this.id, - balance, - this.iconUrl, - this.weight, - this.canBuy, - ); - } -} +import type { EVMChainId, EVMContractAddress } from "@1shotapi/ows-types"; +import { + makeTrackedAssetId, + type TrackedAssetId, +} from "../primitives/TrackedAssetId"; +import type { EAssetType } from "../enum/EAssetType"; +import type { KnownAsset } from "./KnownAsset"; + +/** + * Persistable tracked-asset DTO (no session id/balance). + * Constructed from the known catalog or an on-chain ERC-20 probe. + */ +export class NewTrackedAsset { + constructor( + public readonly chainId: EVMChainId, + /** Token contract, or zero address for native. */ + public readonly address: EVMContractAddress, + public readonly type: EAssetType, + public readonly name: string, + public readonly symbol: string, + public readonly decimals: number, + /** Optional host- or catalog-supplied HTTPS icon URL. */ + public readonly iconUrl?: string, + /** Higher weight sorts above peers in Balances defaults. */ + public readonly weight: number = 0, + /** When true, Asset Details shows Circle onramp Buy. */ + public readonly canBuy: boolean = false, + ) {} + + static fromKnown(known: KnownAsset): NewTrackedAsset { + return new NewTrackedAsset( + known.chainId, + known.address, + known.type, + known.name, + known.symbol, + known.decimals, + known.iconUrl, + known.weight, + known.canBuy, + ); + } + + withIconUrl(iconUrl: string | undefined): NewTrackedAsset { + return new NewTrackedAsset( + this.chainId, + this.address, + this.type, + this.name, + this.symbol, + this.decimals, + iconUrl, + this.weight, + this.canBuy, + ); + } +} + +/** Session-facing tracked asset with id and optional raw balance. */ +export class TrackedAsset extends NewTrackedAsset { + constructor( + chainId: EVMChainId, + address: EVMContractAddress, + type: EAssetType, + name: string, + symbol: string, + decimals: number, + public readonly id: TrackedAssetId, + public balance: bigint | null, + iconUrl?: string, + weight: number = 0, + canBuy: boolean = false, + ) { + super(chainId, address, type, name, symbol, decimals, iconUrl, weight, canBuy); + } + + static fromNew( + asset: NewTrackedAsset, + balance: bigint | null = null, + ): TrackedAsset { + return new TrackedAsset( + asset.chainId, + asset.address, + asset.type, + asset.name, + asset.symbol, + asset.decimals, + makeTrackedAssetId(asset.chainId, asset.address), + balance, + asset.iconUrl, + asset.weight, + asset.canBuy, + ); + } + + withBalance(balance: bigint | null): TrackedAsset { + return new TrackedAsset( + this.chainId, + this.address, + this.type, + this.name, + this.symbol, + this.decimals, + this.id, + balance, + this.iconUrl, + this.weight, + this.canBuy, + ); + } +} diff --git a/src/lib/types/events/productEvents/TransactionEvents.ts b/src/lib/types/events/productEvents/TransactionEvents.ts index f649ca0..4e4b968 100644 --- a/src/lib/types/events/productEvents/TransactionEvents.ts +++ b/src/lib/types/events/productEvents/TransactionEvents.ts @@ -3,6 +3,7 @@ import { EVMAccountAddress, OWSAnalyticsEvent, type EVMChainId, + type EVMContractAddress, type EVMTransactionHash, } from "@1shotapi/ows-types"; import { EAnalyticsEventName } from "../../enum/EAnalyticsEventName"; @@ -12,7 +13,7 @@ export class TransactionSubmittedEvent extends OWSAnalyticsEvent { hostDomain: DomainString, public readonly accountAddress: EVMAccountAddress, public readonly chainId: EVMChainId, - public readonly to: EVMAccountAddress, + public readonly to: EVMAccountAddress | EVMContractAddress, public readonly txHash: EVMTransactionHash, public readonly durationMs: number, public readonly methodId: string | null = null, @@ -28,7 +29,7 @@ export class TransactionSubmitFailedEvent extends OWSAnalyticsEvent { public readonly chainId: EVMChainId, public readonly errorCode: string, public readonly durationMs: number, - public readonly to: EVMAccountAddress | null = null, + public readonly to: EVMAccountAddress | EVMContractAddress | null = null, ) { super(EAnalyticsEventName.TransactionSubmitFailed, hostDomain); } @@ -40,7 +41,7 @@ export class TransactionSubmitCancelledEvent extends OWSAnalyticsEvent { public readonly accountAddress: EVMAccountAddress, public readonly chainId: EVMChainId, public readonly durationMs: number, - public readonly to: EVMAccountAddress | null = null, + public readonly to: EVMAccountAddress | EVMContractAddress | null = null, ) { super(EAnalyticsEventName.TransactionSubmitCancelled, hostDomain); } diff --git a/src/lib/types/primitives/TrackedAssetId.ts b/src/lib/types/primitives/TrackedAssetId.ts index 5471d41..15f297a 100644 --- a/src/lib/types/primitives/TrackedAssetId.ts +++ b/src/lib/types/primitives/TrackedAssetId.ts @@ -1,17 +1,20 @@ -import { type Brand, make } from "ts-brand"; -import type { EVMAccountAddress, EVMChainId } from "@1shotapi/ows-types"; - -/** - * Deterministic tracked-asset id: `${chainId}:${address}` lowercased. - */ -export type TrackedAssetId = Brand; -export const TrackedAssetId = make(); - -export function makeTrackedAssetId( - chainId: EVMChainId | string, - address: EVMAccountAddress | string, -): TrackedAssetId { - return TrackedAssetId( - `${String(chainId).toLowerCase()}:${String(address).toLowerCase()}`, - ); -} +import { type Brand, make } from "ts-brand"; +import type { + EVMChainId, + EVMContractAddress, +} from "@1shotapi/ows-types"; + +/** + * Deterministic tracked-asset id: `${chainId}:${address}` lowercased. + */ +export type TrackedAssetId = Brand; +export const TrackedAssetId = make(); + +export function makeTrackedAssetId( + chainId: EVMChainId | string, + address: EVMContractAddress | string, +): TrackedAssetId { + return TrackedAssetId( + `${String(chainId).toLowerCase()}:${String(address).toLowerCase()}`, + ); +} diff --git a/src/lib/utils/tokenIcons.ts b/src/lib/utils/tokenIcons.ts index d0f5389..4edcbd6 100644 --- a/src/lib/utils/tokenIcons.ts +++ b/src/lib/utils/tokenIcons.ts @@ -1,115 +1,112 @@ -import type { - EVMAccountAddress, - EVMChainId, -} from "@1shotapi/ows-types"; -import type { TrackedAssetId } from "../types/primitives/TrackedAssetId"; -import { makeTrackedAssetId } from "../types/primitives/TrackedAssetId"; - -import usdcIcon from "../../assets/images/tokens/CircleUSDC.svg"; -import usdgIcon from "../../assets/images/tokens/GlobalDollarUSDG.svg"; -import musdIcon from "../../assets/images/tokens/mUSD-icon.svg"; -import usdtIcon from "../../assets/images/tokens/tetherUSD.svg"; - -const ICON_BY_SYMBOL: Readonly> = { - USDC: usdcIcon, - USDT: usdtIcon, - USDG: usdgIcon, - MUSD: musdIcon, -}; - -/** Host / tracked custom icons (HTTPS only). */ -const TRACKED_ICON_BY_ID = new Map(); - -/** True when `url` is a usable remote icon (`https:` only). */ -export function isSafeHttpsIconUrl(url: string): boolean { - try { - const parsed = new URL(url.trim()); - return parsed.protocol === "https:"; - } catch { - return false; - } -} - -/** Normalize relayer/market symbol variants (e.g. USDT0, USD₮0) to icon keys. */ -function normalizeSymbol(symbol: string): string { - const upper = symbol.toUpperCase(); - if (upper === "USDT0" || upper === "USD₮0") { - return "USDT"; - } - return upper; -} - -/** Bundled SVG URL for a relayer stablecoin symbol, if recognized. */ -export function iconUrlForSymbol(symbol: string): string | undefined { - return ICON_BY_SYMBOL[normalizeSymbol(symbol)]; -} - -export type IResolveAssetIconUrl = ( - chainId: EVMChainId, - address: EVMAccountAddress, -) => string | undefined; - -let resolveKnownAssetIconUrl: IResolveAssetIconUrl | null = null; - -/** Wired by relayerKnownAssets after the known-asset map is built. */ -export function registerKnownAssetIconResolver( - resolver: IResolveAssetIconUrl, -): void { - resolveKnownAssetIconUrl = resolver; -} - -export function registerTrackedAssetIconUrl( - id: TrackedAssetId, - iconUrl: string, -): void { - if (!isSafeHttpsIconUrl(iconUrl)) { - TRACKED_ICON_BY_ID.delete(id); - return; - } - TRACKED_ICON_BY_ID.set(id, iconUrl.trim()); -} - -export function unregisterTrackedAssetIconUrl(id: TrackedAssetId): void { - TRACKED_ICON_BY_ID.delete(id); -} - -/** Replace the tracked custom-icon map (call after list/load). */ -export function syncTrackedAssetIconUrls( - entries: ReadonlyArray<{ id: TrackedAssetId; iconUrl?: string }>, -): void { - TRACKED_ICON_BY_ID.clear(); - for (const entry of entries) { - if (entry.iconUrl && isSafeHttpsIconUrl(entry.iconUrl)) { - TRACKED_ICON_BY_ID.set(entry.id, entry.iconUrl.trim()); - } - } -} - -/** - * Resolve display icon URL. - * Priority: explicit override → tracked custom (host) → known catalog → symbol bundle. - */ -export function resolveAssetIconUrl( - chainId: EVMChainId, - address: EVMAccountAddress, - symbol?: string, - iconUrlOverride?: string, -): string | undefined { - if (iconUrlOverride && isSafeHttpsIconUrl(iconUrlOverride)) { - return iconUrlOverride.trim(); - } - const fromTracked = TRACKED_ICON_BY_ID.get( - makeTrackedAssetId(chainId, address), - ); - if (fromTracked) { - return fromTracked; - } - const fromCatalog = resolveKnownAssetIconUrl?.(chainId, address); - if (fromCatalog) { - return fromCatalog; - } - if (symbol) { - return iconUrlForSymbol(symbol); - } - return undefined; -} +import type { EVMChainId, EVMContractAddress } from "@1shotapi/ows-types"; +import type { TrackedAssetId } from "../types/primitives/TrackedAssetId"; +import { makeTrackedAssetId } from "../types/primitives/TrackedAssetId"; + +import usdcIcon from "../../assets/images/tokens/CircleUSDC.svg"; +import usdgIcon from "../../assets/images/tokens/GlobalDollarUSDG.svg"; +import musdIcon from "../../assets/images/tokens/mUSD-icon.svg"; +import usdtIcon from "../../assets/images/tokens/tetherUSD.svg"; + +const ICON_BY_SYMBOL: Readonly> = { + USDC: usdcIcon, + USDT: usdtIcon, + USDG: usdgIcon, + MUSD: musdIcon, +}; + +/** Host / tracked custom icons (HTTPS only). */ +const TRACKED_ICON_BY_ID = new Map(); + +/** True when `url` is a usable remote icon (`https:` only). */ +export function isSafeHttpsIconUrl(url: string): boolean { + try { + const parsed = new URL(url.trim()); + return parsed.protocol === "https:"; + } catch { + return false; + } +} + +/** Normalize relayer/market symbol variants (e.g. USDT0, USD₮0) to icon keys. */ +function normalizeSymbol(symbol: string): string { + const upper = symbol.toUpperCase(); + if (upper === "USDT0" || upper === "USD₮0") { + return "USDT"; + } + return upper; +} + +/** Bundled SVG URL for a relayer stablecoin symbol, if recognized. */ +export function iconUrlForSymbol(symbol: string): string | undefined { + return ICON_BY_SYMBOL[normalizeSymbol(symbol)]; +} + +export type IResolveAssetIconUrl = ( + chainId: EVMChainId, + address: EVMContractAddress, +) => string | undefined; + +let resolveKnownAssetIconUrl: IResolveAssetIconUrl | null = null; + +/** Wired by relayerKnownAssets after the known-asset map is built. */ +export function registerKnownAssetIconResolver( + resolver: IResolveAssetIconUrl, +): void { + resolveKnownAssetIconUrl = resolver; +} + +export function registerTrackedAssetIconUrl( + id: TrackedAssetId, + iconUrl: string, +): void { + if (!isSafeHttpsIconUrl(iconUrl)) { + TRACKED_ICON_BY_ID.delete(id); + return; + } + TRACKED_ICON_BY_ID.set(id, iconUrl.trim()); +} + +export function unregisterTrackedAssetIconUrl(id: TrackedAssetId): void { + TRACKED_ICON_BY_ID.delete(id); +} + +/** Replace the tracked custom-icon map (call after list/load). */ +export function syncTrackedAssetIconUrls( + entries: ReadonlyArray<{ id: TrackedAssetId; iconUrl?: string }>, +): void { + TRACKED_ICON_BY_ID.clear(); + for (const entry of entries) { + if (entry.iconUrl && isSafeHttpsIconUrl(entry.iconUrl)) { + TRACKED_ICON_BY_ID.set(entry.id, entry.iconUrl.trim()); + } + } +} + +/** + * Resolve display icon URL. + * Priority: explicit override → tracked custom (host) → known catalog → symbol bundle. + */ +export function resolveAssetIconUrl( + chainId: EVMChainId, + address: EVMContractAddress, + symbol?: string, + iconUrlOverride?: string, +): string | undefined { + if (iconUrlOverride && isSafeHttpsIconUrl(iconUrlOverride)) { + return iconUrlOverride.trim(); + } + const fromTracked = TRACKED_ICON_BY_ID.get( + makeTrackedAssetId(chainId, address), + ); + if (fromTracked) { + return fromTracked; + } + const fromCatalog = resolveKnownAssetIconUrl?.(chainId, address); + if (fromCatalog) { + return fromCatalog; + } + if (symbol) { + return iconUrlForSymbol(symbol); + } + return undefined; +} diff --git a/src/style/configureSchemas.ts b/src/style/configureSchemas.ts index 59cce80..834155d 100644 --- a/src/style/configureSchemas.ts +++ b/src/style/configureSchemas.ts @@ -327,6 +327,7 @@ export const styleCopyCancelDelegationSchema = z.strictObject({ waitingMessage: z.string(), skipOnchainLabel: z.string(), skipOnchainAcknowledgement: z.string(), + insufficientBalanceError: z.string(), }); export const styleCopyActivateOfflinePermissionsSchema = z.strictObject({ diff --git a/src/style/defaults.ts b/src/style/defaults.ts index acd71db..1cc2d24 100644 --- a/src/style/defaults.ts +++ b/src/style/defaults.ts @@ -326,6 +326,8 @@ export const DEFAULT_STYLE: IResolvedStyle = { skipOnchainLabel: "Skip onchain cancellation", skipOnchainAcknowledgement: "I acknowledge that this delegation may still be used onchain by anybody that holds it, and that canceling it without submitting an onchain cancellation will only remove it from my wallet", + insufficientBalanceError: + "Insufficient balance to pay the network fee on {chainName}. Choose another payment token.", }, activateOfflinePermissions: { title: "Activate offline permissions", diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index b80a9af..1d15bb7 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -1,1003 +1,1006 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, - type RefObject, -} from "react"; -import { OWSSigner } from "@1shotapi/ows-signer-utils"; -import { - AddressUtils, - OWSWallet, - RpcHelper, - type IBlockchainProvider, -} from "@1shotapi/ows-wallet-utils"; -import { - ChainUtils, - EVMAccountAddress, - EVMChainId, - HexString, - SolanaAccountAddress, - type CredentialId, - type CredentialSummary, - type EVMTransactionHash, - type OWSChainId, - type StoredCredential, -} from "@1shotapi/ows-types"; -import { CachedRelayerVaultRepository } from "../lib/implementations/data/CachedRelayerVaultRepository"; -import type { AccountConnectStorage } from "../ows/registerAccountConnect"; -import { RelayerCredentialsClient } from "../lib/implementations/data/utils/RelayerCredentialsClient"; -import { HardcodedChainRepository } from "../lib/implementations/data/HardcodedChainRepository"; -import { AnkrBitcoinRpc } from "../lib/implementations/data/AnkrBitcoinRpc"; -import { CircleRepository } from "../lib/implementations/data/CircleRepository"; -import { HardcodedKnownAssetRepository } from "../lib/implementations/data/HardcodedKnownAssetRepository"; -import { LocalStorageTrackedAssetRepository } from "../lib/implementations/data/LocalStorageTrackedAssetRepository"; -import { BlockscoutAssetActivityRepository } from "../lib/implementations/data/BlockscoutAssetActivityRepository"; -import { OneshotRelayerRepository } from "../lib/implementations/data/OneshotRelayerRepository"; -import { EVMRepository } from "../lib/implementations/data/EVMRepository"; -import { - BitcoinService, - BridgeService, - BusinessTransactionUtils, - CCTPUtils, - DelegationService, - LiFiUtils, - PaymentTokenUtils, - TransactionService, -} from "../lib/implementations/business"; -import { - ConfigProvider, - CircleProvider, - OWSProvider, - SupportedChainsBlockchainProvider, - EventBus, - TransactionUtils, - AnalyticsBridge, - runWithAnalytics, -} from "../lib/implementations/utils"; -import { - TransactionSubmitCancelledEvent, - TransactionSubmittedEvent, - TransactionSubmitFailedEvent, -} from "../lib/types/events/productEvents"; -import type { - IAssetActivityRepository, - IChainRepository, - ICircleRepository, - IEVMRepository, - IKnownAssetRepository, - IOneshotRelayerRepository, - IRecordSentActivityParams, - ITrackedAssetRepository, -} from "../lib/interfaces/data"; -import type { - IBridgeService, - IBitcoinService, - IDelegationService, - ITransactionService, -} 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, - IEventBus, - IOWSProvider, - ITransactionUtils, -} from "../lib/interfaces/utils"; -import type { - AssetActivity, - KnownAsset, - SupportedChain, - TrackedAsset, -} from "../lib/types/domain"; -import type { - IDelegationSummary, - IStoredDelegation, -} from "../lib/types/domain/StoredDelegation"; -import type { DelegationId } from "../lib/types/primitives/DelegationId"; -import type { TrackedAssetId, TokenAmount } from "../lib/types/primitives"; -import { - loadCachedEvmAddress, - loadAccountsPermissionGranted, - saveAccountsPermissionGranted, - saveCachedAddresses, - clearWalletStorage, -} from "../storage"; -import { pushModal } from "./pushModal"; -import { useWalletAuth } from "./useWalletAuth"; -import { useWalletAssets } from "./useWalletAssets"; -import { useWalletBoot } from "./useWalletBoot"; -import { useWalletSessionStore } from "./sessionStore"; -import { CircleContextProvider } from "../circle/CircleContext"; -import { openCctpBridge } from "../circle/openCctpBridge"; -/** Filled once the Signing Layer iframe finishes loading / wallet handshake. */ -const configProvider: IConfigProvider = new ConfigProvider(); -const circleProvider: ICircleProvider = new CircleProvider(configProvider); -const owsProvider: IOWSProvider = new OWSProvider(); -const chainRepository: IChainRepository = new HardcodedChainRepository(); -const bitcoinRpc = new AnkrBitcoinRpc(configProvider); -const bitcoinService = new BitcoinService(bitcoinRpc, owsProvider); -const blockchainProvider: IBlockchainProvider = - new SupportedChainsBlockchainProvider(chainRepository); -const addressUtils = new AddressUtils(blockchainProvider); -const eventBus: IEventBus = new EventBus(); -const analyticsBridge = new AnalyticsBridge({ - eventBus, - owsProvider, - configProvider, -}); -analyticsBridge.start(); -const transactionUtils: ITransactionUtils = new TransactionUtils(); -const knownAssetRepository: IKnownAssetRepository = - new HardcodedKnownAssetRepository(blockchainProvider); -const trackedAssetRepository: ITrackedAssetRepository = - new LocalStorageTrackedAssetRepository( - blockchainProvider, - eventBus, - configProvider, - ); -const assetActivityRepository: IAssetActivityRepository = - new BlockscoutAssetActivityRepository(eventBus, configProvider); -const oneshotRelayerRepository: IOneshotRelayerRepository = - new OneshotRelayerRepository(); -const evmRepository: IEVMRepository = new EVMRepository( - blockchainProvider, - owsProvider, -); -const circleRepository: ICircleRepository = new CircleRepository(); - -const relayerCredentialsClient = new RelayerCredentialsClient({ - configProvider, - owsProvider, -}); - -const credentialRepository = new CachedRelayerVaultRepository({ - client: relayerCredentialsClient, - configProvider, - owsProvider, -}); - -const paymentTokenUtils = new PaymentTokenUtils( - chainRepository, - oneshotRelayerRepository, - trackedAssetRepository, -); - -const businessTransactionUtils = new BusinessTransactionUtils({ - chainRepository, - relayerRepository: oneshotRelayerRepository, - trackedAssetRepository, - paymentTokenUtils, - blockchain: blockchainProvider, - presentationTransactionUtils: transactionUtils, - owsProvider, - delegationRepository: credentialRepository, -}); - -const cctpUtils: ICCTPUtils = new CCTPUtils(); -const liFiUtils: ILiFiUtils = new LiFiUtils(); - -const transactionService: ITransactionService = new TransactionService({ - chainRepository, - relayerRepository: oneshotRelayerRepository, - evmRepository, - transactionUtils: businessTransactionUtils, -}); - -const bridgeService: IBridgeService = new BridgeService( - chainRepository, - knownAssetRepository, - circleRepository, - businessTransactionUtils, - cctpUtils, - blockchainProvider, -); - -const delegationService: IDelegationService = new DelegationService( - chainRepository, - credentialRepository, - blockchainProvider, - businessTransactionUtils, - transactionUtils, - owsProvider, - liFiUtils, -); - -const walletStorage: AccountConnectStorage = { - loadCachedEvmAddress, - saveCachedAddresses: (evm, solana) => { - saveCachedAddresses(evm, solana); - const session = useWalletSessionStore.getState(); - if (solana) { - session.setAddresses(evm, solana); - } else { - session.setAddresses(evm, session.solanaAddress); - } - }, - loadAccountsPermissionGranted, - saveAccountsPermissionGranted, -}; - -/** Imperative wallet APIs that need refs / boot (not UI session state). */ -export type WalletContextValue = { - /** Startup singletons — prefer these over constructing repos/utils in components. */ - chainRepository: IChainRepository; - blockchainProvider: IBlockchainProvider; - addressUtils: AddressUtils; - configProvider: IConfigProvider; - transactionUtils: ITransactionUtils; - knownAssetRepository: IKnownAssetRepository; - trackedAssetRepository: ITrackedAssetRepository; - assetActivityRepository: IAssetActivityRepository; - oneshotRelayerRepository: IOneshotRelayerRepository; - evmRepository: IEVMRepository; - transactionService: ITransactionService; - paymentTokenUtils: IPaymentTokenUtils; - bridgeService: IBridgeService; - bitcoinService: IBitcoinService; - delegationService: IDelegationService; - liFiUtils: ILiFiUtils; - eventBus: IEventBus; - - chains: SupportedChain[]; - resolveChain: (chainId: OWSChainId) => SupportedChain | null; - signerContainerRef: RefObject; - getSigner: () => OWSSigner | null; - /** Resolves when the Signing Layer iframe has finished loading. */ - awaitSignerReady: () => Promise; - /** Awaits Signing Layer load, then unlocks / runs setup if needed. */ - ensureReady: () => Promise; - setUnlocked: (value: boolean) => void; - refreshAddresses: () => Promise; - refreshCredentialCount: () => Promise; - switchChain: (chainId: OWSChainId) => Promise; - requestHide: () => Promise; - listCredentials: () => Promise; - getCredential: ( - credentialId: CredentialId, - ) => Promise; - refreshCredentialsFromRelayer: () => Promise; - listDelegations: () => Promise; - getDelegation: ( - delegationId: DelegationId, - ) => Promise; - refreshDelegationsFromRelayer: () => Promise; - /** - * In-wallet cancel from the Delegations tab. Opens the same confirm modal as - * `requestCancelDelegations` / `wallet_revokeExecutionPermission`, then - * deletes vault rows on success. `transactionHashes` is null when the user - * skipped on-chain cancellation. - */ - cancelStoredDelegations: ( - delegationIds: readonly DelegationId[], - ) => Promise<{ - results: Array<{ - chainId: EVMChainId; - transactionHash: EVMTransactionHash; - }>; - /** Null when the user skipped on-chain cancellation. */ - transactionHashes: EVMTransactionHash[] | null; - }>; - listTrackedAssets: (chainId?: EVMChainId) => Promise; - addTrackedAsset: ( - chainId: EVMChainId, - address: EVMAccountAddress, - ) => Promise; - removeTrackedAsset: ( - chainId: EVMChainId, - address: EVMAccountAddress, - ) => Promise; - getKnownAsset: ( - chainId: EVMChainId, - address: EVMAccountAddress, - ) => Promise; - resolveTrackedAsset: ( - chainId: EVMChainId, - address: EVMAccountAddress, - ) => Promise; - requestBalanceRefresh: ( - id?: TrackedAssetId, - chainId?: EVMChainId, - ) => Promise; - listAssetActivity: ( - owner: EVMAccountAddress, - asset: TrackedAsset, - limit?: number, - ) => Promise; - recordSentActivity: ( - params: IRecordSentActivityParams, - ) => Promise; - /** - * In-wallet submit (TransferTokensModal). Does not show host consent — - * callers already collected amount/recipient. Branches via TransactionService - * (`useRelayer` → 7710, else raw RPC). - */ - sendTransaction: ( - chainId: EVMChainId, - to: EVMAccountAddress, - data: HexString, - value?: bigint, - payment?: { - paymentToken: EVMAccountAddress; - feeAtoms: TokenAmount; - paymentChainId?: EVMChainId; - }, - ) => Promise; - /** - * In-wallet native Send — always eth_sendRawTransaction (never the relayer). - */ - sendNativeTransfer: ( - chainId: EVMChainId, - to: EVMAccountAddress, - value: bigint, - ) => Promise; - /** Gas fee preview for native Send Max / summary. */ - estimateNativeTransferFee: (chainId: EVMChainId) => Promise<{ - gasPrice: bigint; - maxPriorityFeePerGas: bigint; - feeAtoms: bigint; - }>; - openExportPrivateKey: () => Promise; - openImportPrivateKey: () => Promise; - openAdvancedOptions: (options?: { allowExport?: boolean }) => Promise; - loginWithPasskey: () => Promise; - createNewWalletFromUi: () => Promise; -}; - -const WalletContext = createContext(null); - -export function useWallet(): WalletContextValue { - const value = useContext(WalletContext); - if (!value) { - throw new Error("useWallet must be used within WalletProvider"); - } - return value; -} - -export function WalletProvider({ children }: { children: ReactNode }) { - const signerContainerRef = useRef(null); - const walletRef = useRef(null); - const signerRef = useRef(null); - const rpcHelperRef = useRef(null); - const awaitSignerRef = useRef<(() => Promise) | null>(null); - - const [chains, setChains] = useState(() => - [...chainRepository.getCatalog()].filter((c) => c.enabled), - ); - - const resolveChain = useCallback((chainId: OWSChainId): SupportedChain | null => { - const key = String(chainId).toLowerCase(); - return ( - chainRepository - .getCatalog() - .find((chain) => String(chain.chainId).toLowerCase() === key) ?? null - ); - }, []); - - const refreshAllowedChains = useCallback(async () => { - const listed = await chainRepository.list(); - setChains(listed); - const session = useWalletSessionStore.getState(); - const stillAllowed = listed.some( - (chain) => - String(chain.chainId).toLowerCase() === - String(session.chainId).toLowerCase(), - ); - if (!stillAllowed && listed[0]) { - const next = listed[0]; - session.setChainId(next.chainId); - const rpc = rpcHelperRef.current; - if (rpc && ChainUtils.isEVMChainId(next.chainId)) { - try { - await rpc.switchChain(next.chainId); - } catch (error: unknown) { - console.warn("[oneshot-wallet] failed to switch after allowlist", error); - } - } else { - walletRef.current?.providerEvents.emit("chainChanged", next.chainId); - } - } - }, []); - - useEffect(() => { - return chainRepository.onAllowedChainsChanged(() => { - void refreshAllowedChains(); - }); - }, [refreshAllowedChains]); - - const evmAddress = useWalletSessionStore((state) => state.evmAddress); - const unlocked = useWalletSessionStore((state) => state.unlocked); - useEffect(() => { - if (!unlocked || !evmAddress || String(evmAddress).toLowerCase() === "0x0") { - return; - } - let cancelled = false; - void bridgeService.resume(evmAddress).then((inFlight) => { - if (cancelled || !inFlight) return; - void openCctpBridge({ - sourceChainId: inFlight.sourceChainId, - ownerAddress: evmAddress, - resume: inFlight, - }).catch(() => { - /* user closed resume modal */ - }); - }); - return () => { - cancelled = true; - }; - }, [evmAddress, unlocked]); - - const { - setUnlocked, - refreshAddresses, - refreshCredentialCount, - loginWithPasskey, - createNewWallet, - createNewWalletFromUi, - createPasskeyRegistrationOnly, - ensureReady, - ensureReadyRef, - ensureOnboardedForSigning, - onSigningAuthenticated, - awaitSignerReady, - } = useWalletAuth({ - signerRef, - walletRef, - awaitSignerRef, - credentialRepository, - relayerCredentialsClient, - eventBus, - configProvider, - }); - - // Keep create callbacks current for mount-only wallet boot closures. - const createNewWalletRef = useRef(createNewWallet); - const createNewWalletFromUiRef = useRef(createNewWalletFromUi); - const createPasskeyRegistrationOnlyRef = useRef( - createPasskeyRegistrationOnly, - ); - useEffect(() => { - createNewWalletRef.current = createNewWallet; - createNewWalletFromUiRef.current = createNewWalletFromUi; - createPasskeyRegistrationOnlyRef.current = createPasskeyRegistrationOnly; - }, [ - createNewWallet, - createNewWalletFromUi, - createPasskeyRegistrationOnly, - ]); - - const { - listCredentials, - getCredential, - refreshCredentialsFromRelayer, - listDelegations, - getDelegation, - refreshDelegationsFromRelayer, - listTrackedAssets, - addTrackedAsset, - removeTrackedAsset, - getKnownAsset, - resolveTrackedAsset, - requestBalanceRefresh, - listAssetActivity, - recordSentActivity, - } = useWalletAssets({ - credentialRepository, - knownAssetRepository, - trackedAssetRepository, - assetActivityRepository, - eventBus, - awaitSignerReady, - refreshCredentialCount, - }); - - useWalletBoot({ - signerContainerRef, - walletRef, - signerRef, - rpcHelperRef, - awaitSignerRef, - ensureReadyRef, - ensureReady, - ensureOnboardedForSigning, - onSigningAuthenticated, - createNewWallet: (accountName) => createNewWalletRef.current(accountName), - createNewWalletFromUi: () => createNewWalletFromUiRef.current(), - createPasskeyRegistrationOnly: (accountName) => - createPasskeyRegistrationOnlyRef.current(accountName), - resolveChain, - owsProvider, - chainRepository, - knownAssetRepository, - trackedAssetRepository, - transactionService, - paymentTokenUtils, - delegationService, - transactionUtils, - cctpUtils, - liFiUtils, - credentialRepository, - walletStorage, - eventBus, - configProvider, - }); - - const switchChain = useCallback(async (next: OWSChainId) => { - if (ChainUtils.isBitcoinChainId(next)) { - const session = useWalletSessionStore.getState(); - session.setChainId(next); - if (session.focusedAssetAddress) { - session.setFocusedAssetAddress(null); - } - walletRef.current?.providerEvents.emit("chainChanged", next); - return; - } - - if (!ChainUtils.isEVMChainId(next)) { - useWalletSessionStore.getState().setChainId(next); - walletRef.current?.providerEvents.emit("chainChanged", next); - return; - } - - const rpc = rpcHelperRef.current; - if (!rpc) { - useWalletSessionStore.getState().setChainId(next); - return; - } - const previous = rpc.getChainId(); - try { - await rpc.switchChain(next); - // RpcHelper no-ops when already on `next` (e.g. session was Bitcoin while - // the helper stayed on Arc). Sync session + notify when the event path - // did not run — avoid double-emit when onChainChanged already updated. - const session = useWalletSessionStore.getState(); - if ( - String(session.chainId).toLowerCase() !== String(next).toLowerCase() - ) { - session.setChainId(next); - walletRef.current?.providerEvents.emit("chainChanged", next); - } - } catch (error: unknown) { - useWalletSessionStore.getState().setChainId(previous); - console.error("[oneshot-wallet] chain switch failed", error); - throw error; - } - }, []); - - const requestHide = useCallback(async () => { - await walletRef.current?.requestHide(); - }, []); - - const sendTransaction = useCallback( - async ( - chainId: EVMChainId, - to: EVMAccountAddress, - data: HexString, - value?: bigint, - payment?: { - paymentToken: EVMAccountAddress; - feeAtoms: TokenAmount; - }, - ) => { - const { hostDomain } = await configProvider.getConfig(); - const started = performance.now(); - const methodId = data.length >= 10 ? data.slice(0, 10) : null; - const accountAddress = (): EVMAccountAddress => - useWalletSessionStore.getState().evmAddress || - loadCachedEvmAddress() || - EVMAccountAddress("0x0"); - - return runWithAnalytics( - (event) => eventBus.emitAnalytics(event), - async () => { - await ensureOnboardedForSigning(); - const result = await transactionService.sendTransaction( - chainId, - { to, data, value }, - { - ...payment, - // Flyout is already open for in-wallet Send — do not requestHide - // after passkey (host eth_sendTransaction defaults to hide). - retainDisplayDuringSubmit: true, - }, - ); - await onSigningAuthenticated(); - return result.transactionHash; - }, - { - success: (txHash) => - new TransactionSubmittedEvent( - hostDomain, - accountAddress(), - chainId, - to, - txHash, - Math.round(performance.now() - started), - methodId, - ), - cancelled: () => - new TransactionSubmitCancelledEvent( - hostDomain, - accountAddress(), - chainId, - Math.round(performance.now() - started), - to, - ), - failed: (errorCode) => - new TransactionSubmitFailedEvent( - hostDomain, - accountAddress(), - chainId, - errorCode, - Math.round(performance.now() - started), - to, - ), - }, - ); - }, - [ensureOnboardedForSigning, onSigningAuthenticated], - ); - - const sendNativeTransfer = useCallback( - async ( - chainId: EVMChainId, - to: EVMAccountAddress, - value: bigint, - ) => { - const { hostDomain } = await configProvider.getConfig(); - const started = performance.now(); - const accountAddress = (): EVMAccountAddress => - useWalletSessionStore.getState().evmAddress || - loadCachedEvmAddress() || - EVMAccountAddress("0x0"); - - return runWithAnalytics( - (event) => eventBus.emitAnalytics(event), - async () => { - await ensureOnboardedForSigning(); - const result = await transactionService.sendNativeTransfer( - chainId, - to, - value, - ); - await onSigningAuthenticated(); - return result.transactionHash; - }, - { - success: (txHash) => - new TransactionSubmittedEvent( - hostDomain, - accountAddress(), - chainId, - to, - txHash, - Math.round(performance.now() - started), - null, - ), - cancelled: () => - new TransactionSubmitCancelledEvent( - hostDomain, - accountAddress(), - chainId, - Math.round(performance.now() - started), - to, - ), - failed: (errorCode) => - new TransactionSubmitFailedEvent( - hostDomain, - accountAddress(), - chainId, - errorCode, - Math.round(performance.now() - started), - to, - ), - }, - ); - }, - [ensureOnboardedForSigning, onSigningAuthenticated], - ); - - const estimateNativeTransferFee = useCallback( - (chainId: EVMChainId) => - transactionService.estimateNativeTransferFee(chainId), - [], - ); - - const cancelStoredDelegations = useCallback( - async (delegationIds: readonly DelegationId[]) => { - await ensureOnboardedForSigning(); - if (delegationIds.length === 0) { - throw new Error("Select at least one permission to cancel."); - } - - const storedList: IStoredDelegation[] = []; - for (const delegationId of delegationIds) { - const stored = await credentialRepository.getDelegation(delegationId); - if (!stored) { - throw new Error("Permission not found in local cache."); - } - storedList.push(stored); - } - - for (const stored of storedList) { - const chain = resolveChain(stored.chainId); - if (!chain?.useRelayer) { - throw new Error( - `Chain ${stored.chainId} does not support canceling permissions`, - ); - } - } - - const owner = - useWalletSessionStore.getState().evmAddress || - loadCachedEvmAddress(); - if (!owner) { - throw new Error("Wallet address is required to cancel a permission"); - } - - const items = await Promise.all( - storedList.map(async (stored) => { - const chain = resolveChain(stored.chainId)!; - const work = await delegationService.buildCancelWork({ - chainId: stored.chainId, - stored, - }); - return { - memo: stored.memo, - chainName: chain.label, - chainId: stored.chainId, - work, - }; - }), - ); - - const domain = String(storedList[0]!.hostDomain); - const transactionHashes = await pushModal( - ({ id, resolve, reject }) => ({ - id, - kind: "cancelDelegation", - request: { - domain, - ownerAddress: owner, - items, - allowSkipOnchain: true, - }, - execute: async (payments, ui) => { - const batch = await delegationService.cancelDelegations({ - items: storedList.map((stored) => ({ - chainId: stored.chainId, - stored, - })), - payments, - ...ui, - }); - return batch.results.map((r) => r.transactionHash); - }, - executeLocal: async () => { - await delegationService.removeStoredDelegations(storedList); - }, - resolve, - reject, - }), - ); - await onSigningAuthenticated(); - if (transactionHashes === null) { - return { results: [], transactionHashes: null }; - } - // One hash per unique chain (cancelDelegations groups by chain). - const chainOrder: EVMChainId[] = []; - const seen = new Set(); - for (const stored of storedList) { - if (seen.has(stored.chainId)) continue; - seen.add(stored.chainId); - chainOrder.push(stored.chainId); - } - return { - results: transactionHashes.map((transactionHash, index) => ({ - chainId: chainOrder[index] ?? chainOrder[0]!, - transactionHash, - })), - transactionHashes, - }; - }, - [ - ensureOnboardedForSigning, - onSigningAuthenticated, - resolveChain, - ], - ); - - const openExportPrivateKey = useCallback(async () => { - const wallet = walletRef.current; - if (!wallet) return; - const display = await wallet.requestDisplay(); - try { - await pushModal(({ id, resolve, reject }) => ({ - id, - kind: "exportPrivateKey", - resolve, - reject, - })); - } finally { - await display.hide(); - } - }, []); - - const openImportPrivateKey = useCallback(async () => { - const wallet = walletRef.current; - if (!wallet) return false; - const display = await wallet.requestDisplay(); - try { - const imported = await pushModal(({ id, resolve, reject }) => ({ - id, - kind: "importPrivateKey", - resolve, - reject, - })); - if (imported) { - setUnlocked(true); - useWalletSessionStore.getState().setWalletCreated(true); - await refreshAddresses(); - } - return imported; - } finally { - await display.hide(); - } - }, [refreshAddresses, setUnlocked]); - - const openAdvancedOptions = useCallback( - async (options?: { allowExport?: boolean }) => { - const wallet = walletRef.current; - if (!wallet) return; - const allowExport = options?.allowExport !== false; - const display = await wallet.requestDisplay(); - let choice: import("./modalTypes").AdvancedOptionsChoice = "close"; - try { - choice = await pushModal< - import("./modalTypes").AdvancedOptionsChoice - >(({ id, resolve }) => ({ - id, - kind: "advancedOptions", - allowExport, - resolve, - })); - } finally { - // Change Account lands on OnboardingPanel — keep the wallet open. - if (choice === "changeAccount") { - display.release(); - } else { - await display.hide(); - } - } - if (choice === "export") { - await openExportPrivateKey(); - } else if (choice === "import") { - await openImportPrivateKey(); - } else if (choice === "changeAccount") { - clearWalletStorage(); - signerRef.current?.clearSession(); - const session = useWalletSessionStore.getState(); - session.setUnlocked(false); - session.setWalletCreated(false); - session.setAddresses( - EVMAccountAddress("0x0"), - SolanaAccountAddress("—"), - null, - null, - ); - session.setCredentialCount(0); - session.setTrackedAssetCount(0); - session.unfocusWallet(); - walletRef.current?.providerEvents.emit("accountsChanged", []); - // Land on OnboardingPanel (login / create). Do not call ensureReady — - // that would immediately reopen the setup modal. - } - }, - [openExportPrivateKey, openImportPrivateKey], - ); - - const getSigner = useCallback(() => signerRef.current, []); - - const value = useMemo( - () => ({ - chainRepository, - blockchainProvider, - addressUtils, - configProvider, - transactionUtils, - knownAssetRepository, - trackedAssetRepository, - assetActivityRepository, - oneshotRelayerRepository, - evmRepository, - transactionService, - paymentTokenUtils, - bridgeService, - bitcoinService, - delegationService, - liFiUtils, - eventBus, - chains, - resolveChain, - signerContainerRef, - getSigner, - awaitSignerReady, - ensureReady, - setUnlocked, - refreshAddresses, - refreshCredentialCount, - switchChain, - requestHide, - listCredentials, - getCredential, - refreshCredentialsFromRelayer, - listDelegations, - getDelegation, - refreshDelegationsFromRelayer, - cancelStoredDelegations, - listTrackedAssets, - addTrackedAsset, - removeTrackedAsset, - getKnownAsset, - resolveTrackedAsset, - requestBalanceRefresh, - listAssetActivity, - recordSentActivity, - sendTransaction, - sendNativeTransfer, - estimateNativeTransferFee, - openExportPrivateKey, - openImportPrivateKey, - openAdvancedOptions, - loginWithPasskey, - createNewWalletFromUi, - }), - [ - chains, - resolveChain, - getSigner, - awaitSignerReady, - ensureReady, - setUnlocked, - refreshAddresses, - refreshCredentialCount, - switchChain, - requestHide, - listCredentials, - getCredential, - refreshCredentialsFromRelayer, - listDelegations, - getDelegation, - refreshDelegationsFromRelayer, - cancelStoredDelegations, - listTrackedAssets, - addTrackedAsset, - removeTrackedAsset, - getKnownAsset, - resolveTrackedAsset, - requestBalanceRefresh, - listAssetActivity, - recordSentActivity, - sendTransaction, - sendNativeTransfer, - estimateNativeTransferFee, - openExportPrivateKey, - openImportPrivateKey, - openAdvancedOptions, - loginWithPasskey, - createNewWalletFromUi, - ], - ); - - return ( - - {children} - - ); -} +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { OWSSigner } from "@1shotapi/ows-signer-utils"; +import { + AddressUtils, + OWSWallet, + RpcHelper, + type IBlockchainProvider, +} from "@1shotapi/ows-wallet-utils"; +import { + ChainUtils, + EVMAccountAddress, + EVMContractAddress, + EVMChainId, + HexString, + SolanaAccountAddress, + type CredentialId, + type CredentialSummary, + type EVMTransactionHash, + type OWSChainId, + type StoredCredential, +} from "@1shotapi/ows-types"; +import { CachedRelayerVaultRepository } from "../lib/implementations/data/CachedRelayerVaultRepository"; +import type { AccountConnectStorage } from "../ows/registerAccountConnect"; +import { RelayerCredentialsClient } from "../lib/implementations/data/utils/RelayerCredentialsClient"; +import { HardcodedChainRepository } from "../lib/implementations/data/HardcodedChainRepository"; +import { AnkrBitcoinRpc } from "../lib/implementations/data/AnkrBitcoinRpc"; +import { CircleRepository } from "../lib/implementations/data/CircleRepository"; +import { HardcodedKnownAssetRepository } from "../lib/implementations/data/HardcodedKnownAssetRepository"; +import { LocalStorageTrackedAssetRepository } from "../lib/implementations/data/LocalStorageTrackedAssetRepository"; +import { BlockscoutAssetActivityRepository } from "../lib/implementations/data/BlockscoutAssetActivityRepository"; +import { OneshotRelayerRepository } from "../lib/implementations/data/OneshotRelayerRepository"; +import { EVMRepository } from "../lib/implementations/data/EVMRepository"; +import { + BitcoinService, + BridgeService, + BusinessTransactionUtils, + CCTPUtils, + DelegationService, + LiFiUtils, + PaymentTokenUtils, + TransactionService, +} from "../lib/implementations/business"; +import { + ConfigProvider, + CircleProvider, + OWSProvider, + SupportedChainsBlockchainProvider, + EventBus, + TransactionUtils, + AnalyticsBridge, + runWithAnalytics, +} from "../lib/implementations/utils"; +import { + TransactionSubmitCancelledEvent, + TransactionSubmittedEvent, + TransactionSubmitFailedEvent, +} from "../lib/types/events/productEvents"; +import type { + IAssetActivityRepository, + IChainRepository, + ICircleRepository, + IEVMRepository, + IKnownAssetRepository, + IOneshotRelayerRepository, + IRecordSentActivityParams, + ITrackedAssetRepository, +} from "../lib/interfaces/data"; +import type { + IBridgeService, + IBitcoinService, + IDelegationService, + ITransactionService, +} 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, + IEventBus, + IOWSProvider, + ITransactionUtils, +} from "../lib/interfaces/utils"; +import type { + AssetActivity, + KnownAsset, + SupportedChain, + TrackedAsset, +} from "../lib/types/domain"; +import type { + IDelegationSummary, + IStoredDelegation, +} from "../lib/types/domain/StoredDelegation"; +import type { DelegationId } from "../lib/types/primitives/DelegationId"; +import type { TrackedAssetId, TokenAmount } from "../lib/types/primitives"; +import { + loadCachedEvmAddress, + loadAccountsPermissionGranted, + saveAccountsPermissionGranted, + saveCachedAddresses, + clearWalletStorage, +} from "../storage"; +import { pushModal } from "./pushModal"; +import { useWalletAuth } from "./useWalletAuth"; +import { useWalletAssets } from "./useWalletAssets"; +import { useWalletBoot } from "./useWalletBoot"; +import { useWalletSessionStore } from "./sessionStore"; +import { CircleContextProvider } from "../circle/CircleContext"; +import { openCctpBridge } from "../circle/openCctpBridge"; +/** Filled once the Signing Layer iframe finishes loading / wallet handshake. */ +const configProvider: IConfigProvider = new ConfigProvider(); +const circleProvider: ICircleProvider = new CircleProvider(configProvider); +const owsProvider: IOWSProvider = new OWSProvider(); +const chainRepository: IChainRepository = new HardcodedChainRepository(); +const bitcoinRpc = new AnkrBitcoinRpc(configProvider); +const bitcoinService = new BitcoinService(bitcoinRpc, owsProvider); +const blockchainProvider: IBlockchainProvider = + new SupportedChainsBlockchainProvider(chainRepository); +const addressUtils = new AddressUtils(blockchainProvider); +const eventBus: IEventBus = new EventBus(); +const analyticsBridge = new AnalyticsBridge({ + eventBus, + owsProvider, + configProvider, +}); +analyticsBridge.start(); +const transactionUtils: ITransactionUtils = new TransactionUtils(); +const knownAssetRepository: IKnownAssetRepository = + new HardcodedKnownAssetRepository(blockchainProvider); +const trackedAssetRepository: ITrackedAssetRepository = + new LocalStorageTrackedAssetRepository( + blockchainProvider, + eventBus, + configProvider, + ); +const assetActivityRepository: IAssetActivityRepository = + new BlockscoutAssetActivityRepository(eventBus, configProvider); +const oneshotRelayerRepository: IOneshotRelayerRepository = + new OneshotRelayerRepository(); +const evmRepository: IEVMRepository = new EVMRepository( + blockchainProvider, + owsProvider, +); +const circleRepository: ICircleRepository = new CircleRepository(); + +const relayerCredentialsClient = new RelayerCredentialsClient({ + configProvider, + owsProvider, +}); + +const credentialRepository = new CachedRelayerVaultRepository({ + client: relayerCredentialsClient, + configProvider, + owsProvider, +}); + +const paymentTokenUtils = new PaymentTokenUtils( + chainRepository, + oneshotRelayerRepository, + trackedAssetRepository, +); + +const businessTransactionUtils = new BusinessTransactionUtils({ + chainRepository, + relayerRepository: oneshotRelayerRepository, + trackedAssetRepository, + paymentTokenUtils, + blockchain: blockchainProvider, + presentationTransactionUtils: transactionUtils, + owsProvider, + delegationRepository: credentialRepository, +}); + +const cctpUtils: ICCTPUtils = new CCTPUtils(); +const liFiUtils: ILiFiUtils = new LiFiUtils(); + +const transactionService: ITransactionService = new TransactionService({ + chainRepository, + relayerRepository: oneshotRelayerRepository, + evmRepository, + transactionUtils: businessTransactionUtils, +}); + +const bridgeService: IBridgeService = new BridgeService( + chainRepository, + knownAssetRepository, + circleRepository, + businessTransactionUtils, + cctpUtils, + blockchainProvider, +); + +const delegationService: IDelegationService = new DelegationService( + chainRepository, + credentialRepository, + blockchainProvider, + businessTransactionUtils, + transactionUtils, + owsProvider, + liFiUtils, +); + +const walletStorage: AccountConnectStorage = { + loadCachedEvmAddress, + saveCachedAddresses: (evm, solana) => { + saveCachedAddresses(evm, solana); + const session = useWalletSessionStore.getState(); + if (solana) { + session.setAddresses(evm, solana); + } else { + session.setAddresses(evm, session.solanaAddress); + } + }, + loadAccountsPermissionGranted, + saveAccountsPermissionGranted, +}; + +/** Imperative wallet APIs that need refs / boot (not UI session state). */ +export type WalletContextValue = { + /** Startup singletons — prefer these over constructing repos/utils in components. */ + chainRepository: IChainRepository; + blockchainProvider: IBlockchainProvider; + addressUtils: AddressUtils; + configProvider: IConfigProvider; + transactionUtils: ITransactionUtils; + knownAssetRepository: IKnownAssetRepository; + trackedAssetRepository: ITrackedAssetRepository; + assetActivityRepository: IAssetActivityRepository; + oneshotRelayerRepository: IOneshotRelayerRepository; + evmRepository: IEVMRepository; + transactionService: ITransactionService; + paymentTokenUtils: IPaymentTokenUtils; + bridgeService: IBridgeService; + bitcoinService: IBitcoinService; + delegationService: IDelegationService; + liFiUtils: ILiFiUtils; + eventBus: IEventBus; + + chains: SupportedChain[]; + resolveChain: (chainId: OWSChainId) => SupportedChain | null; + signerContainerRef: RefObject; + getSigner: () => OWSSigner | null; + /** Resolves when the Signing Layer iframe has finished loading. */ + awaitSignerReady: () => Promise; + /** Awaits Signing Layer load, then unlocks / runs setup if needed. */ + ensureReady: () => Promise; + setUnlocked: (value: boolean) => void; + refreshAddresses: () => Promise; + refreshCredentialCount: () => Promise; + switchChain: (chainId: OWSChainId) => Promise; + requestHide: () => Promise; + listCredentials: () => Promise; + getCredential: ( + credentialId: CredentialId, + ) => Promise; + refreshCredentialsFromRelayer: () => Promise; + listDelegations: () => Promise; + getDelegation: ( + delegationId: DelegationId, + ) => Promise; + refreshDelegationsFromRelayer: () => Promise; + /** + * In-wallet cancel from the Delegations tab. Opens the same confirm modal as + * `requestCancelDelegations` / `wallet_revokeExecutionPermission`, then + * deletes vault rows on success. `transactionHashes` is null when the user + * skipped on-chain cancellation. + */ + cancelStoredDelegations: ( + delegationIds: readonly DelegationId[], + ) => Promise<{ + results: Array<{ + chainId: EVMChainId; + transactionHash: EVMTransactionHash; + }>; + /** Null when the user skipped on-chain cancellation. */ + transactionHashes: EVMTransactionHash[] | null; + }>; + listTrackedAssets: (chainId?: EVMChainId) => Promise; + addTrackedAsset: ( + chainId: EVMChainId, + address: EVMContractAddress, + ) => Promise; + removeTrackedAsset: ( + chainId: EVMChainId, + address: EVMContractAddress, + ) => Promise; + getKnownAsset: ( + chainId: EVMChainId, + address: EVMContractAddress, + ) => Promise; + resolveTrackedAsset: ( + chainId: EVMChainId, + address: EVMContractAddress, + ) => Promise; + requestBalanceRefresh: ( + id?: TrackedAssetId, + chainId?: EVMChainId, + ) => Promise; + listAssetActivity: ( + owner: EVMAccountAddress, + asset: TrackedAsset, + limit?: number, + ) => Promise; + recordSentActivity: ( + params: IRecordSentActivityParams, + ) => Promise; + /** + * In-wallet submit (TransferTokensModal). Does not show host consent — + * callers already collected amount/recipient. Branches via TransactionService + * (`useRelayer` → 7710, else raw RPC). + */ + sendTransaction: ( + chainId: EVMChainId, + to: EVMAccountAddress | EVMContractAddress, + data: HexString, + value?: bigint, + payment?: { + paymentToken: EVMContractAddress; + feeAtoms: TokenAmount; + paymentChainId?: EVMChainId; + }, + ) => Promise; + /** + * In-wallet native Send — always eth_sendRawTransaction (never the relayer). + */ + sendNativeTransfer: ( + chainId: EVMChainId, + to: EVMAccountAddress, + value: bigint, + ) => Promise; + /** Gas fee preview for native Send Max / summary. */ + estimateNativeTransferFee: (chainId: EVMChainId) => Promise<{ + gasPrice: bigint; + maxPriorityFeePerGas: bigint; + feeAtoms: bigint; + }>; + openExportPrivateKey: () => Promise; + openImportPrivateKey: () => Promise; + openAdvancedOptions: (options?: { allowExport?: boolean }) => Promise; + loginWithPasskey: () => Promise; + createNewWalletFromUi: () => Promise; +}; + +const WalletContext = createContext(null); + +export function useWallet(): WalletContextValue { + const value = useContext(WalletContext); + if (!value) { + throw new Error("useWallet must be used within WalletProvider"); + } + return value; +} + +export function WalletProvider({ children }: { children: ReactNode }) { + const signerContainerRef = useRef(null); + const walletRef = useRef(null); + const signerRef = useRef(null); + const rpcHelperRef = useRef(null); + const awaitSignerRef = useRef<(() => Promise) | null>(null); + + const [chains, setChains] = useState(() => + [...chainRepository.getCatalog()].filter((c) => c.enabled), + ); + + const resolveChain = useCallback((chainId: OWSChainId): SupportedChain | null => { + const key = String(chainId).toLowerCase(); + return ( + chainRepository + .getCatalog() + .find((chain) => String(chain.chainId).toLowerCase() === key) ?? null + ); + }, []); + + const refreshAllowedChains = useCallback(async () => { + const listed = await chainRepository.list(); + setChains(listed); + const session = useWalletSessionStore.getState(); + const stillAllowed = listed.some( + (chain) => + String(chain.chainId).toLowerCase() === + String(session.chainId).toLowerCase(), + ); + if (!stillAllowed && listed[0]) { + const next = listed[0]; + session.setChainId(next.chainId); + const rpc = rpcHelperRef.current; + if (rpc && ChainUtils.isEVMChainId(next.chainId)) { + try { + await rpc.switchChain(next.chainId); + } catch (error: unknown) { + console.warn("[oneshot-wallet] failed to switch after allowlist", error); + } + } else { + walletRef.current?.providerEvents.emit("chainChanged", next.chainId); + } + } + }, []); + + useEffect(() => { + return chainRepository.onAllowedChainsChanged(() => { + void refreshAllowedChains(); + }); + }, [refreshAllowedChains]); + + const evmAddress = useWalletSessionStore((state) => state.evmAddress); + const unlocked = useWalletSessionStore((state) => state.unlocked); + useEffect(() => { + if (!unlocked || !evmAddress || String(evmAddress).toLowerCase() === "0x0") { + return; + } + let cancelled = false; + void bridgeService.resume(evmAddress).then((inFlight) => { + if (cancelled || !inFlight) return; + void openCctpBridge({ + sourceChainId: inFlight.sourceChainId, + ownerAddress: evmAddress, + resume: inFlight, + }).catch(() => { + /* user closed resume modal */ + }); + }); + return () => { + cancelled = true; + }; + }, [evmAddress, unlocked]); + + const { + setUnlocked, + refreshAddresses, + refreshCredentialCount, + loginWithPasskey, + createNewWallet, + createNewWalletFromUi, + createPasskeyRegistrationOnly, + ensureReady, + ensureReadyRef, + ensureOnboardedForSigning, + onSigningAuthenticated, + awaitSignerReady, + } = useWalletAuth({ + signerRef, + walletRef, + awaitSignerRef, + credentialRepository, + relayerCredentialsClient, + eventBus, + configProvider, + }); + + // Keep create callbacks current for mount-only wallet boot closures. + const createNewWalletRef = useRef(createNewWallet); + const createNewWalletFromUiRef = useRef(createNewWalletFromUi); + const createPasskeyRegistrationOnlyRef = useRef( + createPasskeyRegistrationOnly, + ); + useEffect(() => { + createNewWalletRef.current = createNewWallet; + createNewWalletFromUiRef.current = createNewWalletFromUi; + createPasskeyRegistrationOnlyRef.current = createPasskeyRegistrationOnly; + }, [ + createNewWallet, + createNewWalletFromUi, + createPasskeyRegistrationOnly, + ]); + + const { + listCredentials, + getCredential, + refreshCredentialsFromRelayer, + listDelegations, + getDelegation, + refreshDelegationsFromRelayer, + listTrackedAssets, + addTrackedAsset, + removeTrackedAsset, + getKnownAsset, + resolveTrackedAsset, + requestBalanceRefresh, + listAssetActivity, + recordSentActivity, + } = useWalletAssets({ + credentialRepository, + knownAssetRepository, + trackedAssetRepository, + assetActivityRepository, + eventBus, + awaitSignerReady, + refreshCredentialCount, + }); + + useWalletBoot({ + signerContainerRef, + walletRef, + signerRef, + rpcHelperRef, + awaitSignerRef, + ensureReadyRef, + ensureReady, + ensureOnboardedForSigning, + onSigningAuthenticated, + createNewWallet: (accountName) => createNewWalletRef.current(accountName), + createNewWalletFromUi: () => createNewWalletFromUiRef.current(), + createPasskeyRegistrationOnly: (accountName) => + createPasskeyRegistrationOnlyRef.current(accountName), + resolveChain, + owsProvider, + chainRepository, + knownAssetRepository, + trackedAssetRepository, + transactionService, + paymentTokenUtils, + delegationService, + transactionUtils, + cctpUtils, + liFiUtils, + credentialRepository, + walletStorage, + eventBus, + configProvider, + }); + + const switchChain = useCallback(async (next: OWSChainId) => { + if (ChainUtils.isBitcoinChainId(next)) { + const session = useWalletSessionStore.getState(); + session.setChainId(next); + if (session.focusedAssetAddress) { + session.setFocusedAssetAddress(null); + } + walletRef.current?.providerEvents.emit("chainChanged", next); + return; + } + + if (!ChainUtils.isEVMChainId(next)) { + useWalletSessionStore.getState().setChainId(next); + walletRef.current?.providerEvents.emit("chainChanged", next); + return; + } + + const rpc = rpcHelperRef.current; + if (!rpc) { + useWalletSessionStore.getState().setChainId(next); + return; + } + const previous = rpc.getChainId(); + try { + await rpc.switchChain(next); + // RpcHelper no-ops when already on `next` (e.g. session was Bitcoin while + // the helper stayed on Arc). Sync session + notify when the event path + // did not run — avoid double-emit when onChainChanged already updated. + const session = useWalletSessionStore.getState(); + if ( + String(session.chainId).toLowerCase() !== String(next).toLowerCase() + ) { + session.setChainId(next); + walletRef.current?.providerEvents.emit("chainChanged", next); + } + } catch (error: unknown) { + useWalletSessionStore.getState().setChainId(previous); + console.error("[oneshot-wallet] chain switch failed", error); + throw error; + } + }, []); + + const requestHide = useCallback(async () => { + await walletRef.current?.requestHide(); + }, []); + + const sendTransaction = useCallback( + async ( + chainId: EVMChainId, + to: EVMAccountAddress | EVMContractAddress, + data: HexString, + value?: bigint, + payment?: { + paymentToken: EVMContractAddress; + feeAtoms: TokenAmount; + }, + ) => { + const { hostDomain } = await configProvider.getConfig(); + const started = performance.now(); + const methodId = data.length >= 10 ? data.slice(0, 10) : null; + const accountAddress = (): EVMAccountAddress => + useWalletSessionStore.getState().evmAddress || + loadCachedEvmAddress() || + EVMAccountAddress("0x0"); + + return runWithAnalytics( + (event) => eventBus.emitAnalytics(event), + async () => { + await ensureOnboardedForSigning(); + const result = await transactionService.sendTransaction( + chainId, + { to, data, value }, + { + ...payment, + // Flyout is already open for in-wallet Send — do not requestHide + // after passkey (host eth_sendTransaction defaults to hide). + retainDisplayDuringSubmit: true, + }, + ); + await onSigningAuthenticated(); + return result.transactionHash; + }, + { + success: (txHash) => + new TransactionSubmittedEvent( + hostDomain, + accountAddress(), + chainId, + to, + txHash, + Math.round(performance.now() - started), + methodId, + ), + cancelled: () => + new TransactionSubmitCancelledEvent( + hostDomain, + accountAddress(), + chainId, + Math.round(performance.now() - started), + to, + ), + failed: (errorCode) => + new TransactionSubmitFailedEvent( + hostDomain, + accountAddress(), + chainId, + errorCode, + Math.round(performance.now() - started), + to, + ), + }, + ); + }, + [ensureOnboardedForSigning, onSigningAuthenticated], + ); + + const sendNativeTransfer = useCallback( + async ( + chainId: EVMChainId, + to: EVMAccountAddress, + value: bigint, + ) => { + const { hostDomain } = await configProvider.getConfig(); + const started = performance.now(); + const accountAddress = (): EVMAccountAddress => + useWalletSessionStore.getState().evmAddress || + loadCachedEvmAddress() || + EVMAccountAddress("0x0"); + + return runWithAnalytics( + (event) => eventBus.emitAnalytics(event), + async () => { + await ensureOnboardedForSigning(); + const result = await transactionService.sendNativeTransfer( + chainId, + to, + value, + ); + await onSigningAuthenticated(); + return result.transactionHash; + }, + { + success: (txHash) => + new TransactionSubmittedEvent( + hostDomain, + accountAddress(), + chainId, + to, + txHash, + Math.round(performance.now() - started), + null, + ), + cancelled: () => + new TransactionSubmitCancelledEvent( + hostDomain, + accountAddress(), + chainId, + Math.round(performance.now() - started), + to, + ), + failed: (errorCode) => + new TransactionSubmitFailedEvent( + hostDomain, + accountAddress(), + chainId, + errorCode, + Math.round(performance.now() - started), + to, + ), + }, + ); + }, + [ensureOnboardedForSigning, onSigningAuthenticated], + ); + + const estimateNativeTransferFee = useCallback( + (chainId: EVMChainId) => + transactionService.estimateNativeTransferFee(chainId), + [], + ); + + const cancelStoredDelegations = useCallback( + async (delegationIds: readonly DelegationId[]) => { + await ensureOnboardedForSigning(); + if (delegationIds.length === 0) { + throw new Error("Select at least one permission to cancel."); + } + + const storedList: IStoredDelegation[] = []; + for (const delegationId of delegationIds) { + const stored = await credentialRepository.getDelegation(delegationId); + if (!stored) { + throw new Error("Permission not found in local cache."); + } + storedList.push(stored); + } + + for (const stored of storedList) { + const chain = resolveChain(stored.chainId); + if (!chain?.useRelayer) { + throw new Error( + `Chain ${stored.chainId} does not support canceling permissions`, + ); + } + } + + const owner = + useWalletSessionStore.getState().evmAddress || + loadCachedEvmAddress(); + if (!owner) { + throw new Error("Wallet address is required to cancel a permission"); + } + + const items = await Promise.all( + storedList.map(async (stored) => { + const chain = resolveChain(stored.chainId)!; + const work = await delegationService.buildCancelWork({ + chainId: stored.chainId, + stored, + }); + return { + memo: stored.memo, + chainName: chain.label, + chainId: stored.chainId, + work, + }; + }), + ); + + const domain = String(storedList[0]!.hostDomain); + const transactionHashes = await pushModal( + ({ id, resolve, reject }) => ({ + id, + kind: "cancelDelegation", + request: { + domain, + ownerAddress: owner, + items, + allowSkipOnchain: true, + }, + execute: async (payment, ui) => { + const batch = await delegationService.cancelDelegations({ + items: storedList.map((stored) => ({ + chainId: stored.chainId, + stored, + })), + paymentToken: payment.paymentToken, + feeAtoms: payment.feeAtoms, + paymentChainId: payment.paymentChainId, + ...ui, + }); + return batch.results.map((r) => r.transactionHash); + }, + executeLocal: async () => { + await delegationService.removeStoredDelegations(storedList); + }, + resolve, + reject, + }), + ); + await onSigningAuthenticated(); + if (transactionHashes === null) { + return { results: [], transactionHashes: null }; + } + // One hash per unique chain (cancelDelegations groups by chain). + const chainOrder: EVMChainId[] = []; + const seen = new Set(); + for (const stored of storedList) { + if (seen.has(stored.chainId)) continue; + seen.add(stored.chainId); + chainOrder.push(stored.chainId); + } + return { + results: transactionHashes.map((transactionHash, index) => ({ + chainId: chainOrder[index] ?? chainOrder[0]!, + transactionHash, + })), + transactionHashes, + }; + }, + [ + ensureOnboardedForSigning, + onSigningAuthenticated, + resolveChain, + ], + ); + + const openExportPrivateKey = useCallback(async () => { + const wallet = walletRef.current; + if (!wallet) return; + const display = await wallet.requestDisplay(); + try { + await pushModal(({ id, resolve, reject }) => ({ + id, + kind: "exportPrivateKey", + resolve, + reject, + })); + } finally { + await display.hide(); + } + }, []); + + const openImportPrivateKey = useCallback(async () => { + const wallet = walletRef.current; + if (!wallet) return false; + const display = await wallet.requestDisplay(); + try { + const imported = await pushModal(({ id, resolve, reject }) => ({ + id, + kind: "importPrivateKey", + resolve, + reject, + })); + if (imported) { + setUnlocked(true); + useWalletSessionStore.getState().setWalletCreated(true); + await refreshAddresses(); + } + return imported; + } finally { + await display.hide(); + } + }, [refreshAddresses, setUnlocked]); + + const openAdvancedOptions = useCallback( + async (options?: { allowExport?: boolean }) => { + const wallet = walletRef.current; + if (!wallet) return; + const allowExport = options?.allowExport !== false; + const display = await wallet.requestDisplay(); + let choice: import("./modalTypes").AdvancedOptionsChoice = "close"; + try { + choice = await pushModal< + import("./modalTypes").AdvancedOptionsChoice + >(({ id, resolve }) => ({ + id, + kind: "advancedOptions", + allowExport, + resolve, + })); + } finally { + // Change Account lands on OnboardingPanel — keep the wallet open. + if (choice === "changeAccount") { + display.release(); + } else { + await display.hide(); + } + } + if (choice === "export") { + await openExportPrivateKey(); + } else if (choice === "import") { + await openImportPrivateKey(); + } else if (choice === "changeAccount") { + clearWalletStorage(); + signerRef.current?.clearSession(); + const session = useWalletSessionStore.getState(); + session.setUnlocked(false); + session.setWalletCreated(false); + session.setAddresses( + EVMAccountAddress("0x0"), + SolanaAccountAddress("—"), + null, + null, + ); + session.setCredentialCount(0); + session.setTrackedAssetCount(0); + session.unfocusWallet(); + walletRef.current?.providerEvents.emit("accountsChanged", []); + // Land on OnboardingPanel (login / create). Do not call ensureReady — + // that would immediately reopen the setup modal. + } + }, + [openExportPrivateKey, openImportPrivateKey], + ); + + const getSigner = useCallback(() => signerRef.current, []); + + const value = useMemo( + () => ({ + chainRepository, + blockchainProvider, + addressUtils, + configProvider, + transactionUtils, + knownAssetRepository, + trackedAssetRepository, + assetActivityRepository, + oneshotRelayerRepository, + evmRepository, + transactionService, + paymentTokenUtils, + bridgeService, + bitcoinService, + delegationService, + liFiUtils, + eventBus, + chains, + resolveChain, + signerContainerRef, + getSigner, + awaitSignerReady, + ensureReady, + setUnlocked, + refreshAddresses, + refreshCredentialCount, + switchChain, + requestHide, + listCredentials, + getCredential, + refreshCredentialsFromRelayer, + listDelegations, + getDelegation, + refreshDelegationsFromRelayer, + cancelStoredDelegations, + listTrackedAssets, + addTrackedAsset, + removeTrackedAsset, + getKnownAsset, + resolveTrackedAsset, + requestBalanceRefresh, + listAssetActivity, + recordSentActivity, + sendTransaction, + sendNativeTransfer, + estimateNativeTransferFee, + openExportPrivateKey, + openImportPrivateKey, + openAdvancedOptions, + loginWithPasskey, + createNewWalletFromUi, + }), + [ + chains, + resolveChain, + getSigner, + awaitSignerReady, + ensureReady, + setUnlocked, + refreshAddresses, + refreshCredentialCount, + switchChain, + requestHide, + listCredentials, + getCredential, + refreshCredentialsFromRelayer, + listDelegations, + getDelegation, + refreshDelegationsFromRelayer, + cancelStoredDelegations, + listTrackedAssets, + addTrackedAsset, + removeTrackedAsset, + getKnownAsset, + resolveTrackedAsset, + requestBalanceRefresh, + listAssetActivity, + recordSentActivity, + sendTransaction, + sendNativeTransfer, + estimateNativeTransferFee, + openExportPrivateKey, + openImportPrivateKey, + openAdvancedOptions, + loginWithPasskey, + createNewWalletFromUi, + ], + ); + + return ( + + {children} + + ); +} diff --git a/src/wallet/modalTypes.ts b/src/wallet/modalTypes.ts index de1bf62..4c6b2bc 100644 --- a/src/wallet/modalTypes.ts +++ b/src/wallet/modalTypes.ts @@ -1,294 +1,286 @@ -import type { - PersonalSignApprovalRequest, - SendTransactionApprovalRequest, - SignTypedDataApprovalRequest, -} from "@1shotapi/ows-signer-utils"; -import type { - CredentialOfferApprovalRequest, - CredentialPresentationApprovalRequest, - EVMAccountAddress, - EVMChainId, - EVMSignatureHex, - EVMTransactionHash, - IExecutionPermission, - IExecutionPermissionRequest, -} from "@1shotapi/ows-types"; -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"; -import type { IOnrampOpenRequest } from "../circle/onrampTypes"; -import type { - ICctpBridgeModalResult, - ICctpBridgeOpenRequest, -} from "../circle/cctpBridgeTypes"; -import type { TokenAmount } from "../lib/types/primitives"; -import type { ITransactionWork } from "../lib/interfaces/business/ITransactionService"; - -export type WalletSetupChoice = "login" | "create" | "import" | "cancel"; - -/** Friendly host ERC-20 transfer consent (decoded transfer calldata). */ -export interface IConfirmTransferRequest { - domain: string; - amount: string; - tokenName: string; - tokenSymbol: string; - tokenAddress: EVMAccountAddress; - receiver: string; - chainName: string; - chainId: EVMChainId; - ownerAddress: EVMAccountAddress; - useRelayer: boolean; - /** ExactCalldata work for unsigned fee estimate (host send payload). */ - work: ITransactionWork; -} - -/** Relayer payment selection from TX confirm UI (before execute). */ -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). */ -export type IConfirmSendResult = false | IConfirmSendPayment; - -export type GrantPermissionModalKind = - | "grantExecutionPermission" - | "grantLiFiSwapPermission" - | "grantLiFiApprovePermission"; - -/** One permission in a grant consent batch. */ -export interface IGrantExecutionPermissionsBatchItem { - request: IExecutionPermissionRequest; - chainName: string; - grantKind: GrantPermissionModalKind; -} - -/** Host EIP-7715 grant consent — single or compound permission requests. */ -export interface IGrantExecutionPermissionsBatchRequest { - domain: string; - items: IGrantExecutionPermissionsBatchItem[]; -} - -export type IGrantExecutionPermissionResult = { - permission: IExecutionPermission; - memo: string; -}; - -/** One delegation in a cancel / revoke confirm batch. */ -export interface ICancelDelegationConfirmItem { - memo: string; - chainName: string; - chainId: EVMChainId; - /** ExactCalldata work for unsigned fee estimate on this chain. */ - work: ITransactionWork; -} - -/** Per-chain payment when canceling across one or more networks. */ -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). */ -export interface ICancelDelegationConfirmRequest { - domain: string; - ownerAddress: EVMAccountAddress; - items: ICancelDelegationConfirmItem[]; - /** - * When true, the modal offers “Skip onchain cancellation” (vault delete - * only). Requires stored vault rows for every item. - */ - 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 for this grant — requested grant chains - * plus the USDC payment chain (usually Arc) when either needs upgrade. - */ - upgradeChains: Array<{ chainId: EVMChainId; chainName: string }>; - payment: IRelayerPayment; -} - -export type ModalRequest = - | { - id: string; - kind: "walletSetup"; - resolve: (choice: WalletSetupChoice) => void; - } - | { - id: string; - kind: "passkeyName"; - resolve: (name: string | null) => void; - } - | { - id: string; - kind: "connect"; - resolve: (approved: boolean) => void; - } - | { - id: string; - kind: "personalSign"; - request: PersonalSignApprovalRequest; - resolve: (signature: EVMSignatureHex) => void; - reject: (error: unknown) => void; - } - | { - id: string; - kind: "typedData"; - request: SignTypedDataApprovalRequest; - resolve: (signature: EVMSignatureHex) => void; - reject: (error: unknown) => void; - } - | { - id: string; - kind: "siwe"; - source: "typedData" | "personalSign"; - request: SignTypedDataApprovalRequest | PersonalSignApprovalRequest; - fields: ISiweFields; - resolve: (signature: EVMSignatureHex) => void; - reject: (error: unknown) => void; - } - | { - id: string; - kind: "sendTransaction"; - request: SendTransactionApprovalRequest & { useRelayer?: boolean }; - execute: ( - payment: IConfirmSendPayment, - ui?: IRelayerSendUiCallbacks, - ) => Promise; - resolve: (hash: EVMTransactionHash) => void; - reject: (error: unknown) => void; - } - | { - id: string; - kind: "confirmTransfer"; - request: IConfirmTransferRequest; - execute: ( - payment: IConfirmSendPayment, - ui?: IRelayerSendUiCallbacks, - ) => Promise; - resolve: (hash: EVMTransactionHash) => void; - reject: (error: unknown) => void; - } - | { - id: string; - kind: "credentialOffer"; - request: CredentialOfferApprovalRequest; - resolve: (approved: boolean) => void; - } - | { - id: string; - kind: "credentialPresentation"; - request: CredentialPresentationApprovalRequest; - resolve: (approved: boolean) => void; - } - | { - id: string; - kind: "addAsset"; - request: IAddAssetApprovalRequest; - resolve: (approved: boolean) => void; - } - | { - id: string; - kind: "grantExecutionPermissions"; - request: IGrantExecutionPermissionsBatchRequest; - 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"; - request: ICancelDelegationConfirmRequest; - execute: ( - payments: ICancelDelegationPayment[], - ui: IRelayerSendUiCallbacks, - ) => Promise; - /** Vault-only delete when the user skips on-chain cancel. */ - executeLocal: () => Promise; - onRegisterAwaitingConfirmation?: (notify: () => void) => void; - resolve: (hashes: EVMTransactionHash[] | null) => void; - reject: (error: unknown) => void; - } - | { - id: string; - kind: "exportPrivateKey"; - resolve: () => void; - reject: (error: unknown) => void; - } - | { - id: string; - kind: "importPrivateKey"; - resolve: (imported: boolean) => void; - reject: (error: unknown) => void; - } - | { - id: string; - kind: "advancedOptions"; - allowExport: boolean; - resolve: (choice: AdvancedOptionsChoice) => void; - } - | { - id: string; - kind: "openCreateTab"; - createUrl: string; - /** true when user confirms open; false when cancelled. */ - resolve: (opened: boolean) => void; - } - | { - id: string; - kind: "onramp"; - request: IOnrampOpenRequest; - resolve: () => void; - reject: (error: unknown) => void; - } - | { - id: string; - kind: "cctpBridge"; - request: ICctpBridgeOpenRequest; - resolve: (result: ICctpBridgeModalResult) => void; - reject: (error: unknown) => void; - }; - -export type ActiveModal = ModalRequest; - -export type AdvancedOptionsChoice = - | "export" - | "import" - | "changeAccount" - | "close"; - -let modalId = 0; - -export function nextModalId(): string { - modalId += 1; - return `modal-${modalId}`; -} +import type { + PersonalSignApprovalRequest, + SendTransactionApprovalRequest, + SignTypedDataApprovalRequest, +} from "@1shotapi/ows-signer-utils"; +import type { + CredentialOfferApprovalRequest, + CredentialPresentationApprovalRequest, + EVMAccountAddress, + EVMChainId, + EVMContractAddress, + EVMSignatureHex, + EVMTransactionHash, + IExecutionPermission, + IExecutionPermissionRequest, +} from "@1shotapi/ows-types"; +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"; +import type { IOnrampOpenRequest } from "../circle/onrampTypes"; +import type { + ICctpBridgeModalResult, + ICctpBridgeOpenRequest, +} from "../circle/cctpBridgeTypes"; +import type { TokenAmount } from "../lib/types/primitives"; +import type { ITransactionWork } from "../lib/interfaces/business/ITransactionService"; + +export type WalletSetupChoice = "login" | "create" | "import" | "cancel"; + +/** Friendly host ERC-20 transfer consent (decoded transfer calldata). */ +export interface IConfirmTransferRequest { + domain: string; + amount: string; + tokenName: string; + tokenSymbol: string; + tokenAddress: EVMContractAddress; + receiver: string; + chainName: string; + chainId: EVMChainId; + ownerAddress: EVMAccountAddress; + useRelayer: boolean; + /** ExactCalldata work for unsigned fee estimate (host send payload). */ + work: ITransactionWork; +} + +/** Relayer payment selection from TX confirm UI (before execute). */ +export type IConfirmSendPayment = { + /** Required when the confirm modal was opened with `useRelayer: true`. */ + paymentToken?: EVMContractAddress; + feeAtoms?: TokenAmount; + paymentChainId?: EVMChainId; +}; + +/** Relayer confirm payload after UI validation. */ +export type IRelayerConfirmSendResult = { + paymentToken: EVMContractAddress; + 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). */ +export type IConfirmSendResult = false | IConfirmSendPayment; + +export type GrantPermissionModalKind = + | "grantExecutionPermission" + | "grantLiFiSwapPermission" + | "grantLiFiApprovePermission"; + +/** One permission in a grant consent batch. */ +export interface IGrantExecutionPermissionsBatchItem { + request: IExecutionPermissionRequest; + chainName: string; + grantKind: GrantPermissionModalKind; +} + +/** Host EIP-7715 grant consent — single or compound permission requests. */ +export interface IGrantExecutionPermissionsBatchRequest { + domain: string; + items: IGrantExecutionPermissionsBatchItem[]; +} + +export type IGrantExecutionPermissionResult = { + permission: IExecutionPermission; + memo: string; +}; + +/** One delegation in a cancel / revoke confirm batch. */ +export interface ICancelDelegationConfirmItem { + memo: string; + chainName: string; + chainId: EVMChainId; + /** ExactCalldata work for unsigned fee estimate on this chain. */ + work: ITransactionWork; +} + +/** Cancel / revoke confirm (on-chain disableDelegation, possibly batched). */ +export interface ICancelDelegationConfirmRequest { + domain: string; + ownerAddress: EVMAccountAddress; + items: ICancelDelegationConfirmItem[]; + /** + * When true, the modal offers “Skip onchain cancellation” (vault delete + * only). Requires stored vault rows for every item. + */ + 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 for this grant — requested grant chains + * plus the USDC payment chain (usually Arc) when either needs upgrade. + */ + upgradeChains: Array<{ chainId: EVMChainId; chainName: string }>; + payment: IRelayerPayment; +} + +export type ModalRequest = + | { + id: string; + kind: "walletSetup"; + resolve: (choice: WalletSetupChoice) => void; + } + | { + id: string; + kind: "passkeyName"; + resolve: (name: string | null) => void; + } + | { + id: string; + kind: "connect"; + resolve: (approved: boolean) => void; + } + | { + id: string; + kind: "personalSign"; + request: PersonalSignApprovalRequest; + resolve: (signature: EVMSignatureHex) => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "typedData"; + request: SignTypedDataApprovalRequest; + resolve: (signature: EVMSignatureHex) => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "siwe"; + source: "typedData" | "personalSign"; + request: SignTypedDataApprovalRequest | PersonalSignApprovalRequest; + fields: ISiweFields; + resolve: (signature: EVMSignatureHex) => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "sendTransaction"; + request: SendTransactionApprovalRequest & { useRelayer?: boolean }; + execute: ( + payment: IConfirmSendPayment, + ui?: IRelayerSendUiCallbacks, + ) => Promise; + resolve: (hash: EVMTransactionHash) => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "confirmTransfer"; + request: IConfirmTransferRequest; + execute: ( + payment: IConfirmSendPayment, + ui?: IRelayerSendUiCallbacks, + ) => Promise; + resolve: (hash: EVMTransactionHash) => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "credentialOffer"; + request: CredentialOfferApprovalRequest; + resolve: (approved: boolean) => void; + } + | { + id: string; + kind: "credentialPresentation"; + request: CredentialPresentationApprovalRequest; + resolve: (approved: boolean) => void; + } + | { + id: string; + kind: "addAsset"; + request: IAddAssetApprovalRequest; + resolve: (approved: boolean) => void; + } + | { + id: string; + kind: "grantExecutionPermissions"; + request: IGrantExecutionPermissionsBatchRequest; + 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"; + request: ICancelDelegationConfirmRequest; + execute: ( + payment: IRelayerConfirmSendResult, + ui: IRelayerSendUiCallbacks, + ) => Promise; + /** Vault-only delete when the user skips on-chain cancel. */ + executeLocal: () => Promise; + onRegisterAwaitingConfirmation?: (notify: () => void) => void; + resolve: (hashes: EVMTransactionHash[] | null) => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "exportPrivateKey"; + resolve: () => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "importPrivateKey"; + resolve: (imported: boolean) => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "advancedOptions"; + allowExport: boolean; + resolve: (choice: AdvancedOptionsChoice) => void; + } + | { + id: string; + kind: "openCreateTab"; + createUrl: string; + /** true when user confirms open; false when cancelled. */ + resolve: (opened: boolean) => void; + } + | { + id: string; + kind: "onramp"; + request: IOnrampOpenRequest; + resolve: () => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "cctpBridge"; + request: ICctpBridgeOpenRequest; + resolve: (result: ICctpBridgeModalResult) => void; + reject: (error: unknown) => void; + }; + +export type ActiveModal = ModalRequest; + +export type AdvancedOptionsChoice = + | "export" + | "import" + | "changeAccount" + | "close"; + +let modalId = 0; + +export function nextModalId(): string { + modalId += 1; + return `modal-${modalId}`; +} diff --git a/src/wallet/registerAddAsset.ts b/src/wallet/registerAddAsset.ts index 9c69ffa..5468cb0 100644 --- a/src/wallet/registerAddAsset.ts +++ b/src/wallet/registerAddAsset.ts @@ -1,106 +1,104 @@ -import { z } from "zod"; -import type { OWSWallet } from "@1shotapi/ows-wallet-utils"; -import { - EVMAccountAddress, - EVMChainIdSchema, - OwsUserRejectedError, - type EVMAccountAddress as EVMAccountAddressType, - type EVMChainId, -} from "@1shotapi/ows-types"; -import type { - IKnownAssetRepository, - ITrackedAssetRepository, -} from "../lib/interfaces/data"; -import { isSafeHttpsIconUrl } from "../lib/utils/tokenIcons"; -import { useWalletSessionStore } from "./sessionStore"; - -/** Custom RPC — host: `await proxy.rpc("addAsset", { chainId, assetAddress, iconUrl? })`. */ -export const ADD_ASSET_RPC_METHOD = "addAsset"; - -const addAssetParamsSchema = z.strictObject({ - chainId: EVMChainIdSchema, - assetAddress: z - .string() - .regex(/^0x[0-9a-fA-F]{40}$/) - .transform((value) => EVMAccountAddress(value as `0x${string}`)), - iconUrl: z - .url() - .refine((url) => isSafeHttpsIconUrl(url), { - message: "iconUrl must be an https URL", - }) - .optional(), -}); - -export type IAddAssetParams = z.infer; - -export interface IAddAssetApprovalRequest { - chainId: EVMChainId; - assetAddress: EVMAccountAddress; - /** Resolved token name for the confirm modal. */ - assetName: string; - assetSymbol: string; - /** Optional host-supplied HTTPS icon for preview + persistence. */ - iconUrl?: string; -} - -export type RegisterAddAssetOptions = { - knownAssetRepository: IKnownAssetRepository; - trackedAssetRepository: ITrackedAssetRepository; - getOwnerAddress: () => EVMAccountAddressType; - requestAddAssetApproval: ( - request: IAddAssetApprovalRequest, - ) => Promise; -}; - -/** - * Register host `addAsset` RPC (always requires user confirmation). - * Resolves ERC-20 metadata before the confirm modal. - */ -export function registerAddAssetRpc( - wallet: OWSWallet, - options: RegisterAddAssetOptions, -): void { - wallet.registerRpc( - ADD_ASSET_RPC_METHOD, - async (params) => { - const { chainId, assetAddress, iconUrl } = params as IAddAssetParams; - const owner = options.getOwnerAddress(); - const [resolved, display] = await Promise.all([ - options.knownAssetRepository.resolveForTracking( - chainId, - assetAddress, - owner, - ), - wallet.requestDisplay(), - ]); - const toPersist = iconUrl ? resolved.withIconUrl(iconUrl) : resolved; - try { - // Consent UI requires the flyout already open — keep sequential. - if ( - !(await options.requestAddAssetApproval({ - chainId, - assetAddress, - assetName: resolved.name, - assetSymbol: resolved.symbol, - iconUrl: toPersist.iconUrl, - })) - ) { - throw new OwsUserRejectedError("User rejected add asset request"); - } - - await options.trackedAssetRepository.add(toPersist, owner); - const listed = await options.trackedAssetRepository.list(); - useWalletSessionStore.getState().setTrackedAssetCount(listed.length); - - return { - ok: true as const, - chainId, - assetAddress, - }; - } finally { - await display.hide(); - } - }, - addAssetParamsSchema, - ); -} +import { z } from "zod"; +import type { OWSWallet } from "@1shotapi/ows-wallet-utils"; +import { + EVMContractAddress, + EVMChainIdSchema, + OwsUserRejectedError, + type EVMAccountAddress as EVMAccountAddressType, + type EVMChainId, + EVMContractAddressSchema, +} from "@1shotapi/ows-types"; +import type { + IKnownAssetRepository, + ITrackedAssetRepository, +} from "../lib/interfaces/data"; +import { isSafeHttpsIconUrl } from "../lib/utils/tokenIcons"; +import { useWalletSessionStore } from "./sessionStore"; + +/** Custom RPC — host: `await proxy.rpc("addAsset", { chainId, assetAddress, iconUrl? })`. */ +export const ADD_ASSET_RPC_METHOD = "addAsset"; + +const addAssetParamsSchema = z.strictObject({ + chainId: EVMChainIdSchema, + assetAddress: EVMContractAddressSchema, + iconUrl: z + .url() + .refine((url) => isSafeHttpsIconUrl(url), { + message: "iconUrl must be an https URL", + }) + .optional(), +}); + +export type IAddAssetParams = z.infer; + +export interface IAddAssetApprovalRequest { + chainId: EVMChainId; + assetAddress: EVMContractAddress; + /** Resolved token name for the confirm modal. */ + assetName: string; + assetSymbol: string; + /** Optional host-supplied HTTPS icon for preview + persistence. */ + iconUrl?: string; +} + +export type RegisterAddAssetOptions = { + knownAssetRepository: IKnownAssetRepository; + trackedAssetRepository: ITrackedAssetRepository; + getOwnerAddress: () => EVMAccountAddressType; + requestAddAssetApproval: ( + request: IAddAssetApprovalRequest, + ) => Promise; +}; + +/** + * Register host `addAsset` RPC (always requires user confirmation). + * Resolves ERC-20 metadata before the confirm modal. + */ +export function registerAddAssetRpc( + wallet: OWSWallet, + options: RegisterAddAssetOptions, +): void { + wallet.registerRpc( + ADD_ASSET_RPC_METHOD, + async (params) => { + const { chainId, assetAddress, iconUrl } = params as IAddAssetParams; + const owner = options.getOwnerAddress(); + const [resolved, display] = await Promise.all([ + options.knownAssetRepository.resolveForTracking( + chainId, + assetAddress, + owner, + ), + wallet.requestDisplay(), + ]); + const toPersist = iconUrl ? resolved.withIconUrl(iconUrl) : resolved; + try { + // Consent UI requires the flyout already open — keep sequential. + if ( + !(await options.requestAddAssetApproval({ + chainId, + assetAddress, + assetName: resolved.name, + assetSymbol: resolved.symbol, + iconUrl: toPersist.iconUrl, + })) + ) { + throw new OwsUserRejectedError("User rejected add asset request"); + } + + await options.trackedAssetRepository.add(toPersist, owner); + const listed = await options.trackedAssetRepository.list(); + useWalletSessionStore.getState().setTrackedAssetCount(listed.length); + + return { + ok: true as const, + chainId, + assetAddress, + }; + } finally { + await display.hide(); + } + }, + addAssetParamsSchema, + ); +} diff --git a/src/wallet/registerFocusMode.ts b/src/wallet/registerFocusMode.ts index 94858dc..12cbea6 100644 --- a/src/wallet/registerFocusMode.ts +++ b/src/wallet/registerFocusMode.ts @@ -1,53 +1,53 @@ -import { z } from "zod"; -import type { OWSWallet, RpcHelper } from "@1shotapi/ows-wallet-utils"; -import { EVMAccountAddressSchema, EVMChainIdSchema } from "@1shotapi/ows-types"; -import { - EWalletMode, - useWalletSessionStore, -} from "./sessionStore"; - -/** Custom RPC — host: `await proxy.rpc("focusWallet", { chainId, assetAddress })`. */ -export const FOCUS_WALLET_RPC_METHOD = "focusWallet"; - -/** Custom RPC — host: `await proxy.rpc("unfocusWallet")`. */ -export const UNFOCUS_WALLET_RPC_METHOD = "unfocusWallet"; - -const focusWalletParamsSchema = z.strictObject({ - chainId: EVMChainIdSchema, - assetAddress: EVMAccountAddressSchema, -}); - -export type IFocusWalletParams = z.infer; - -/** - * Register host-controlled focus / unfocus RPCs. - * Must run after `RpcHelper` exists and before `wallet.start()`. - */ -export function registerFocusModeRpc( - wallet: OWSWallet, - rpcHelper: RpcHelper, -): void { - wallet.registerRpc( - FOCUS_WALLET_RPC_METHOD, - async (params) => { - const { chainId, assetAddress } = params as IFocusWalletParams; - await rpcHelper.switchChain(chainId); - useWalletSessionStore.getState().focusWallet(chainId, assetAddress); - return { - ok: true as const, - mode: EWalletMode.Focused, - chainId, - assetAddress, - }; - }, - focusWalletParamsSchema, - ); - - wallet.registerRpc(UNFOCUS_WALLET_RPC_METHOD, async () => { - useWalletSessionStore.getState().unfocusWallet(); - return { - ok: true as const, - mode: EWalletMode.General, - }; - }); -} +import { z } from "zod"; +import type { OWSWallet, RpcHelper } from "@1shotapi/ows-wallet-utils"; +import { EVMContractAddressSchema, EVMChainIdSchema } from "@1shotapi/ows-types"; +import { + EWalletMode, + useWalletSessionStore, +} from "./sessionStore"; + +/** Custom RPC — host: `await proxy.rpc("focusWallet", { chainId, assetAddress })`. */ +export const FOCUS_WALLET_RPC_METHOD = "focusWallet"; + +/** Custom RPC — host: `await proxy.rpc("unfocusWallet")`. */ +export const UNFOCUS_WALLET_RPC_METHOD = "unfocusWallet"; + +const focusWalletParamsSchema = z.strictObject({ + chainId: EVMChainIdSchema, + assetAddress: EVMContractAddressSchema, +}); + +export type IFocusWalletParams = z.infer; + +/** + * Register host-controlled focus / unfocus RPCs. + * Must run after `RpcHelper` exists and before `wallet.start()`. + */ +export function registerFocusModeRpc( + wallet: OWSWallet, + rpcHelper: RpcHelper, +): void { + wallet.registerRpc( + FOCUS_WALLET_RPC_METHOD, + async (params) => { + const { chainId, assetAddress } = params as IFocusWalletParams; + await rpcHelper.switchChain(chainId); + useWalletSessionStore.getState().focusWallet(chainId, assetAddress); + return { + ok: true as const, + mode: EWalletMode.Focused, + chainId, + assetAddress, + }; + }, + focusWalletParamsSchema, + ); + + wallet.registerRpc(UNFOCUS_WALLET_RPC_METHOD, async () => { + useWalletSessionStore.getState().unfocusWallet(); + return { + ok: true as const, + mode: EWalletMode.General, + }; + }); +} diff --git a/src/wallet/registerRequestCancelDelegations.ts b/src/wallet/registerRequestCancelDelegations.ts index 88b221d..4ac1d02 100644 --- a/src/wallet/registerRequestCancelDelegations.ts +++ b/src/wallet/registerRequestCancelDelegations.ts @@ -139,13 +139,15 @@ export function registerRequestCancelDelegationsRpc( items, allowSkipOnchain: true, }, - execute: async (payments, ui) => { + execute: async (payment, ui) => { const batch = await options.delegationService.cancelDelegations({ items: storedList.map((stored) => ({ chainId: stored.chainId, stored, })), - payments, + paymentToken: payment.paymentToken, + feeAtoms: payment.feeAtoms, + paymentChainId: payment.paymentChainId, ...ui, }); return batch.results.map((r) => r.transactionHash); diff --git a/src/wallet/sessionStore.ts b/src/wallet/sessionStore.ts index 13bcc6b..0cc6d95 100644 --- a/src/wallet/sessionStore.ts +++ b/src/wallet/sessionStore.ts @@ -1,164 +1,165 @@ -import { create } from "zustand"; -import { - BITCOIN_MAINNET_CHAIN_ID, - type BitcoinChainId, - type BitcoinSegwitAccountAddress, - EVMAccountAddress, - type OWSChainId, - SolanaAccountAddress, -} from "@1shotapi/ows-types"; -import { DEFAULT_CHAIN_ID } from "../lib/implementations/data/HardcodedChainRepository"; -import { reconcileCachedWalletSession } from "../storage"; - -/** Host-controlled shell mode — users cannot switch between these. */ -export enum EWalletMode { - General = "general", - Focused = "focused", -} - -export interface IWalletSessionState { - ready: boolean; - /** Signing Layer iframe loaded (`OWSSigner.create` resolved). */ - signerReady: boolean; - bootError: string | null; - embedded: boolean; - unlocked: boolean; - walletCreated: boolean; - evmAddress: EVMAccountAddress; - solanaAddress: SolanaAccountAddress; - bitcoinMainnetAddress: BitcoinSegwitAccountAddress | null; - bitcoinTestnetAddress: BitcoinSegwitAccountAddress | null; - chainId: OWSChainId; - credentialCount: number; - /** Bumped on tracked-asset add/remove so Balances tab reloads. */ - trackedAssetCount: number; - mode: EWalletMode; - focusedAssetAddress: EVMAccountAddress | null; - - setReady: (ready: boolean) => void; - setSignerReady: (ready: boolean) => void; - setBootError: (error: string | null) => void; - setUnlocked: (unlocked: boolean) => void; - setWalletCreated: (created: boolean) => void; - setAddresses: ( - evm: EVMAccountAddress, - solana: SolanaAccountAddress, - /** Omit to leave unchanged; pass `null` to clear (e.g. Change Account). */ - bitcoinMainnet?: BitcoinSegwitAccountAddress | null, - bitcoinTestnet?: BitcoinSegwitAccountAddress | null, - ) => void; - setBitcoinAddress: ( - chainId: BitcoinChainId, - address: BitcoinSegwitAccountAddress, - ) => void; - setChainId: (chainId: OWSChainId) => void; - setCredentialCount: (count: number) => void; - setTrackedAssetCount: (count: number) => void; - setMode: (mode: EWalletMode) => void; - setFocusedAssetAddress: (address: EVMAccountAddress | null) => void; - focusWallet: ( - chainId: OWSChainId, - assetAddress: EVMAccountAddress, - ) => void; - unfocusWallet: () => void; -} - -function initialEmbedded(): boolean { - return typeof window !== "undefined" && window.parent !== window; -} - -function hydrateSessionFromCache(): { - walletCreated: boolean; - unlocked: boolean; - evmAddress: EVMAccountAddress; - solanaAddress: SolanaAccountAddress; - bitcoinMainnetAddress: BitcoinSegwitAccountAddress | null; - bitcoinTestnetAddress: BitcoinSegwitAccountAddress | null; -} { - if (typeof window === "undefined") { - return { - walletCreated: false, - unlocked: false, - evmAddress: EVMAccountAddress("0x0"), - solanaAddress: SolanaAccountAddress("—"), - bitcoinMainnetAddress: null, - bitcoinTestnetAddress: null, - }; - } - const cached = reconcileCachedWalletSession(); - return { - walletCreated: cached.walletCreated, - unlocked: cached.walletCreated, - evmAddress: cached.evmAddress ?? EVMAccountAddress("0x0"), - solanaAddress: cached.solanaAddress ?? SolanaAccountAddress("—"), - bitcoinMainnetAddress: cached.bitcoinMainnetAddress ?? null, - bitcoinTestnetAddress: cached.bitcoinTestnetAddress ?? null, - }; -} - -const hydratedSession = hydrateSessionFromCache(); - -export const useWalletSessionStore = create((set) => ({ - ready: false, - signerReady: false, - bootError: null, - embedded: initialEmbedded(), - unlocked: hydratedSession.unlocked, - walletCreated: hydratedSession.walletCreated, - evmAddress: hydratedSession.evmAddress, - solanaAddress: hydratedSession.solanaAddress, - bitcoinMainnetAddress: hydratedSession.bitcoinMainnetAddress, - bitcoinTestnetAddress: hydratedSession.bitcoinTestnetAddress, - chainId: DEFAULT_CHAIN_ID, - credentialCount: 0, - trackedAssetCount: 0, - mode: EWalletMode.General, - focusedAssetAddress: null, - - setReady: (ready) => set({ ready }), - setSignerReady: (signerReady) => set({ signerReady }), - setBootError: (bootError) => set({ bootError }), - setUnlocked: (unlocked) => set({ unlocked }), - setWalletCreated: (walletCreated) => set({ walletCreated }), - setAddresses: ( - evmAddress, - solanaAddress, - bitcoinMainnet, - bitcoinTestnet, - ) => - set((state) => ({ - evmAddress, - solanaAddress, - // `undefined` = leave unchanged; `null` = clear (Change Account). - bitcoinMainnetAddress: - bitcoinMainnet === undefined - ? state.bitcoinMainnetAddress - : bitcoinMainnet, - bitcoinTestnetAddress: - bitcoinTestnet === undefined - ? state.bitcoinTestnetAddress - : bitcoinTestnet, - })), - setBitcoinAddress: (chainId, address) => - set( - chainId === BITCOIN_MAINNET_CHAIN_ID - ? { bitcoinMainnetAddress: address } - : { bitcoinTestnetAddress: address }, - ), - setChainId: (chainId) => set({ chainId }), - setCredentialCount: (credentialCount) => set({ credentialCount }), - setTrackedAssetCount: (trackedAssetCount) => set({ trackedAssetCount }), - setMode: (mode) => set({ mode }), - setFocusedAssetAddress: (focusedAssetAddress) => set({ focusedAssetAddress }), - focusWallet: (chainId, focusedAssetAddress) => - set({ - mode: EWalletMode.Focused, - chainId, - focusedAssetAddress, - }), - unfocusWallet: () => - set({ - mode: EWalletMode.General, - focusedAssetAddress: null, - }), -})); +import { create } from "zustand"; +import { + BITCOIN_MAINNET_CHAIN_ID, + type BitcoinChainId, + type BitcoinSegwitAccountAddress, + EVMAccountAddress, + EVMContractAddress, + type OWSChainId, + SolanaAccountAddress, +} from "@1shotapi/ows-types"; +import { DEFAULT_CHAIN_ID } from "../lib/implementations/data/HardcodedChainRepository"; +import { reconcileCachedWalletSession } from "../storage"; + +/** Host-controlled shell mode — users cannot switch between these. */ +export enum EWalletMode { + General = "general", + Focused = "focused", +} + +export interface IWalletSessionState { + ready: boolean; + /** Signing Layer iframe loaded (`OWSSigner.create` resolved). */ + signerReady: boolean; + bootError: string | null; + embedded: boolean; + unlocked: boolean; + walletCreated: boolean; + evmAddress: EVMAccountAddress; + solanaAddress: SolanaAccountAddress; + bitcoinMainnetAddress: BitcoinSegwitAccountAddress | null; + bitcoinTestnetAddress: BitcoinSegwitAccountAddress | null; + chainId: OWSChainId; + credentialCount: number; + /** Bumped on tracked-asset add/remove so Balances tab reloads. */ + trackedAssetCount: number; + mode: EWalletMode; + focusedAssetAddress: EVMContractAddress | null; + + setReady: (ready: boolean) => void; + setSignerReady: (ready: boolean) => void; + setBootError: (error: string | null) => void; + setUnlocked: (unlocked: boolean) => void; + setWalletCreated: (created: boolean) => void; + setAddresses: ( + evm: EVMAccountAddress, + solana: SolanaAccountAddress, + /** Omit to leave unchanged; pass `null` to clear (e.g. Change Account). */ + bitcoinMainnet?: BitcoinSegwitAccountAddress | null, + bitcoinTestnet?: BitcoinSegwitAccountAddress | null, + ) => void; + setBitcoinAddress: ( + chainId: BitcoinChainId, + address: BitcoinSegwitAccountAddress, + ) => void; + setChainId: (chainId: OWSChainId) => void; + setCredentialCount: (count: number) => void; + setTrackedAssetCount: (count: number) => void; + setMode: (mode: EWalletMode) => void; + setFocusedAssetAddress: (address: EVMContractAddress | null) => void; + focusWallet: ( + chainId: OWSChainId, + assetAddress: EVMContractAddress, + ) => void; + unfocusWallet: () => void; +} + +function initialEmbedded(): boolean { + return typeof window !== "undefined" && window.parent !== window; +} + +function hydrateSessionFromCache(): { + walletCreated: boolean; + unlocked: boolean; + evmAddress: EVMAccountAddress; + solanaAddress: SolanaAccountAddress; + bitcoinMainnetAddress: BitcoinSegwitAccountAddress | null; + bitcoinTestnetAddress: BitcoinSegwitAccountAddress | null; +} { + if (typeof window === "undefined") { + return { + walletCreated: false, + unlocked: false, + evmAddress: EVMAccountAddress("0x0"), + solanaAddress: SolanaAccountAddress("—"), + bitcoinMainnetAddress: null, + bitcoinTestnetAddress: null, + }; + } + const cached = reconcileCachedWalletSession(); + return { + walletCreated: cached.walletCreated, + unlocked: cached.walletCreated, + evmAddress: cached.evmAddress ?? EVMAccountAddress("0x0"), + solanaAddress: cached.solanaAddress ?? SolanaAccountAddress("—"), + bitcoinMainnetAddress: cached.bitcoinMainnetAddress ?? null, + bitcoinTestnetAddress: cached.bitcoinTestnetAddress ?? null, + }; +} + +const hydratedSession = hydrateSessionFromCache(); + +export const useWalletSessionStore = create((set) => ({ + ready: false, + signerReady: false, + bootError: null, + embedded: initialEmbedded(), + unlocked: hydratedSession.unlocked, + walletCreated: hydratedSession.walletCreated, + evmAddress: hydratedSession.evmAddress, + solanaAddress: hydratedSession.solanaAddress, + bitcoinMainnetAddress: hydratedSession.bitcoinMainnetAddress, + bitcoinTestnetAddress: hydratedSession.bitcoinTestnetAddress, + chainId: DEFAULT_CHAIN_ID, + credentialCount: 0, + trackedAssetCount: 0, + mode: EWalletMode.General, + focusedAssetAddress: null, + + setReady: (ready) => set({ ready }), + setSignerReady: (signerReady) => set({ signerReady }), + setBootError: (bootError) => set({ bootError }), + setUnlocked: (unlocked) => set({ unlocked }), + setWalletCreated: (walletCreated) => set({ walletCreated }), + setAddresses: ( + evmAddress, + solanaAddress, + bitcoinMainnet, + bitcoinTestnet, + ) => + set((state) => ({ + evmAddress, + solanaAddress, + // `undefined` = leave unchanged; `null` = clear (Change Account). + bitcoinMainnetAddress: + bitcoinMainnet === undefined + ? state.bitcoinMainnetAddress + : bitcoinMainnet, + bitcoinTestnetAddress: + bitcoinTestnet === undefined + ? state.bitcoinTestnetAddress + : bitcoinTestnet, + })), + setBitcoinAddress: (chainId, address) => + set( + chainId === BITCOIN_MAINNET_CHAIN_ID + ? { bitcoinMainnetAddress: address } + : { bitcoinTestnetAddress: address }, + ), + setChainId: (chainId) => set({ chainId }), + setCredentialCount: (credentialCount) => set({ credentialCount }), + setTrackedAssetCount: (trackedAssetCount) => set({ trackedAssetCount }), + setMode: (mode) => set({ mode }), + setFocusedAssetAddress: (focusedAssetAddress) => set({ focusedAssetAddress }), + focusWallet: (chainId, focusedAssetAddress) => + set({ + mode: EWalletMode.Focused, + chainId, + focusedAssetAddress, + }), + unfocusWallet: () => + set({ + mode: EWalletMode.General, + focusedAssetAddress: null, + }), +})); diff --git a/src/wallet/useWalletAssets.ts b/src/wallet/useWalletAssets.ts index 3e00412..030230f 100644 --- a/src/wallet/useWalletAssets.ts +++ b/src/wallet/useWalletAssets.ts @@ -1,207 +1,208 @@ -import { useCallback } from "react"; -import type { - CredentialId, - EVMAccountAddress, - EVMChainId, -} from "@1shotapi/ows-types"; -import { ChainUtils } from "@1shotapi/ows-types"; -import type { CachedRelayerVaultRepository } from "../lib/implementations/data/CachedRelayerVaultRepository"; -import type { - IAssetActivityRepository, - IKnownAssetRepository, - IRecordSentActivityParams, - ITrackedAssetRepository, -} from "../lib/interfaces/data"; -import type { IEventBus } from "../lib/interfaces/utils"; -import type { - AssetActivity, - TrackedAsset, -} from "../lib/types/domain"; -import type { DelegationId } from "../lib/types/primitives/DelegationId"; -import { RefreshBalanceRequestedEvent } from "../lib/types/events/RefreshBalanceRequestedEvent"; -import type { TrackedAssetId } from "../lib/types/primitives"; -import { useWalletSessionStore } from "./sessionStore"; - -export interface IUseWalletAssetsParams { - credentialRepository: CachedRelayerVaultRepository; - knownAssetRepository: IKnownAssetRepository; - trackedAssetRepository: ITrackedAssetRepository; - assetActivityRepository: IAssetActivityRepository; - eventBus: IEventBus; - /** Vault recover needs the Signing Layer for decryptAES256 only — not unlock. */ - awaitSignerReady: () => Promise; - refreshCredentialCount: () => Promise; -} - -export function useWalletAssets({ - credentialRepository, - knownAssetRepository, - trackedAssetRepository, - assetActivityRepository, - eventBus, - awaitSignerReady, - refreshCredentialCount, -}: IUseWalletAssetsParams) { - const refreshTrackedAssetCount = useCallback(async () => { - const listed = await trackedAssetRepository.list(); - useWalletSessionStore.getState().setTrackedAssetCount(listed.length); - }, [trackedAssetRepository]); - - const listCredentials = useCallback(async () => { - return credentialRepository.list(); - }, [credentialRepository]); - - const getCredential = useCallback( - async (credentialId: CredentialId) => { - return credentialRepository.get(credentialId); - }, - [credentialRepository], - ); - - const refreshCredentialsFromRelayer = useCallback(async () => { - // Do not call ensureReady/unlock first — that can nest unlock + recover. - // Empty vault: one signer assert (or cached unlock assertion). Blobs: - // assert + Decrypt (PRF inside decryptAES256). - await awaitSignerReady(); - await credentialRepository.refreshFromRelayer(); - await refreshCredentialCount(); - }, [awaitSignerReady, credentialRepository, refreshCredentialCount]); - - const listDelegations = useCallback(async () => { - return credentialRepository.listDelegations(); - }, [credentialRepository]); - - const getDelegation = useCallback( - async (delegationId: DelegationId) => { - return credentialRepository.getDelegation(delegationId); - }, - [credentialRepository], - ); - - const refreshDelegationsFromRelayer = useCallback(async () => { - await refreshCredentialsFromRelayer(); - }, [refreshCredentialsFromRelayer]); - - const listTrackedAssets = useCallback( - async (chainId?: EVMChainId) => { - return trackedAssetRepository.list(chainId); - }, - [trackedAssetRepository], - ); - - const addTrackedAsset = useCallback( - async (chainId: EVMChainId, address: EVMAccountAddress) => { - const owner = useWalletSessionStore.getState().evmAddress; - const resolved = await knownAssetRepository.resolveForTracking( - chainId, - address, - owner, - ); - const tracked = await trackedAssetRepository.add(resolved, owner); - await refreshTrackedAssetCount(); - return tracked; - }, - [ - knownAssetRepository, - refreshTrackedAssetCount, - trackedAssetRepository, - ], - ); - - const removeTrackedAsset = useCallback( - async (chainId: EVMChainId, address: EVMAccountAddress) => { - await trackedAssetRepository.remove(chainId, address); - await refreshTrackedAssetCount(); - }, - [refreshTrackedAssetCount, trackedAssetRepository], - ); - - const getKnownAsset = useCallback( - async (chainId: EVMChainId, address: EVMAccountAddress) => { - return knownAssetRepository.getKnownAsset(chainId, address); - }, - [knownAssetRepository], - ); - - const resolveTrackedAsset = useCallback( - async (chainId: EVMChainId, address: EVMAccountAddress) => { - const owner = useWalletSessionStore.getState().evmAddress; - const listed = await trackedAssetRepository.list(chainId); - const existing = listed.find( - (asset) => - asset.chainId === chainId && asset.address === address, - ); - if (existing) return existing; - const resolved = await knownAssetRepository.resolveForTracking( - chainId, - address, - owner, - ); - const tracked = await trackedAssetRepository.add(resolved, owner); - await refreshTrackedAssetCount(); - return tracked; - }, - [ - knownAssetRepository, - refreshTrackedAssetCount, - trackedAssetRepository, - ], - ); - - const requestBalanceRefresh = useCallback( - async (id?: TrackedAssetId, chainId?: EVMChainId) => { - eventBus.emit(new RefreshBalanceRequestedEvent(id)); - const { evmAddress: owner, chainId: sessionChainId } = - useWalletSessionStore.getState(); - try { - if (id) { - await trackedAssetRepository.getBalances(owner, { id }); - return; - } - const scope = chainId ?? sessionChainId; - if (ChainUtils.isEVMChainId(scope)) { - await trackedAssetRepository.getBalances(owner, { chainId: scope }); - } - } catch (error: unknown) { - console.error("[oneshot-wallet] balance refresh failed", error); - throw error; - } - }, - [eventBus, trackedAssetRepository], - ); - - const listAssetActivity = useCallback( - async ( - owner: EVMAccountAddress, - asset: TrackedAsset, - limit?: number, - ): Promise => { - return assetActivityRepository.list({ owner, asset, limit }); - }, - [assetActivityRepository], - ); - - const recordSentActivity = useCallback( - async (params: IRecordSentActivityParams) => { - return assetActivityRepository.recordSent(params); - }, - [assetActivityRepository], - ); - - return { - listCredentials, - getCredential, - refreshCredentialsFromRelayer, - listDelegations, - getDelegation, - refreshDelegationsFromRelayer, - listTrackedAssets, - addTrackedAsset, - removeTrackedAsset, - getKnownAsset, - resolveTrackedAsset, - requestBalanceRefresh, - listAssetActivity, - recordSentActivity, - }; -} +import { useCallback } from "react"; +import type { + CredentialId, + EVMAccountAddress, + EVMContractAddress, + EVMChainId, +} from "@1shotapi/ows-types"; +import { ChainUtils } from "@1shotapi/ows-types"; +import type { CachedRelayerVaultRepository } from "../lib/implementations/data/CachedRelayerVaultRepository"; +import type { + IAssetActivityRepository, + IKnownAssetRepository, + IRecordSentActivityParams, + ITrackedAssetRepository, +} from "../lib/interfaces/data"; +import type { IEventBus } from "../lib/interfaces/utils"; +import type { + AssetActivity, + TrackedAsset, +} from "../lib/types/domain"; +import type { DelegationId } from "../lib/types/primitives/DelegationId"; +import { RefreshBalanceRequestedEvent } from "../lib/types/events/RefreshBalanceRequestedEvent"; +import type { TrackedAssetId } from "../lib/types/primitives"; +import { useWalletSessionStore } from "./sessionStore"; + +export interface IUseWalletAssetsParams { + credentialRepository: CachedRelayerVaultRepository; + knownAssetRepository: IKnownAssetRepository; + trackedAssetRepository: ITrackedAssetRepository; + assetActivityRepository: IAssetActivityRepository; + eventBus: IEventBus; + /** Vault recover needs the Signing Layer for decryptAES256 only — not unlock. */ + awaitSignerReady: () => Promise; + refreshCredentialCount: () => Promise; +} + +export function useWalletAssets({ + credentialRepository, + knownAssetRepository, + trackedAssetRepository, + assetActivityRepository, + eventBus, + awaitSignerReady, + refreshCredentialCount, +}: IUseWalletAssetsParams) { + const refreshTrackedAssetCount = useCallback(async () => { + const listed = await trackedAssetRepository.list(); + useWalletSessionStore.getState().setTrackedAssetCount(listed.length); + }, [trackedAssetRepository]); + + const listCredentials = useCallback(async () => { + return credentialRepository.list(); + }, [credentialRepository]); + + const getCredential = useCallback( + async (credentialId: CredentialId) => { + return credentialRepository.get(credentialId); + }, + [credentialRepository], + ); + + const refreshCredentialsFromRelayer = useCallback(async () => { + // Do not call ensureReady/unlock first — that can nest unlock + recover. + // Empty vault: one signer assert (or cached unlock assertion). Blobs: + // assert + Decrypt (PRF inside decryptAES256). + await awaitSignerReady(); + await credentialRepository.refreshFromRelayer(); + await refreshCredentialCount(); + }, [awaitSignerReady, credentialRepository, refreshCredentialCount]); + + const listDelegations = useCallback(async () => { + return credentialRepository.listDelegations(); + }, [credentialRepository]); + + const getDelegation = useCallback( + async (delegationId: DelegationId) => { + return credentialRepository.getDelegation(delegationId); + }, + [credentialRepository], + ); + + const refreshDelegationsFromRelayer = useCallback(async () => { + await refreshCredentialsFromRelayer(); + }, [refreshCredentialsFromRelayer]); + + const listTrackedAssets = useCallback( + async (chainId?: EVMChainId) => { + return trackedAssetRepository.list(chainId); + }, + [trackedAssetRepository], + ); + + const addTrackedAsset = useCallback( + async (chainId: EVMChainId, address: EVMContractAddress) => { + const owner = useWalletSessionStore.getState().evmAddress; + const resolved = await knownAssetRepository.resolveForTracking( + chainId, + address, + owner, + ); + const tracked = await trackedAssetRepository.add(resolved, owner); + await refreshTrackedAssetCount(); + return tracked; + }, + [ + knownAssetRepository, + refreshTrackedAssetCount, + trackedAssetRepository, + ], + ); + + const removeTrackedAsset = useCallback( + async (chainId: EVMChainId, address: EVMContractAddress) => { + await trackedAssetRepository.remove(chainId, address); + await refreshTrackedAssetCount(); + }, + [refreshTrackedAssetCount, trackedAssetRepository], + ); + + const getKnownAsset = useCallback( + async (chainId: EVMChainId, address: EVMContractAddress) => { + return knownAssetRepository.getKnownAsset(chainId, address); + }, + [knownAssetRepository], + ); + + const resolveTrackedAsset = useCallback( + async (chainId: EVMChainId, address: EVMContractAddress) => { + const owner = useWalletSessionStore.getState().evmAddress; + const listed = await trackedAssetRepository.list(chainId); + const existing = listed.find( + (asset) => + asset.chainId === chainId && asset.address === address, + ); + if (existing) return existing; + const resolved = await knownAssetRepository.resolveForTracking( + chainId, + address, + owner, + ); + const tracked = await trackedAssetRepository.add(resolved, owner); + await refreshTrackedAssetCount(); + return tracked; + }, + [ + knownAssetRepository, + refreshTrackedAssetCount, + trackedAssetRepository, + ], + ); + + const requestBalanceRefresh = useCallback( + async (id?: TrackedAssetId, chainId?: EVMChainId) => { + eventBus.emit(new RefreshBalanceRequestedEvent(id)); + const { evmAddress: owner, chainId: sessionChainId } = + useWalletSessionStore.getState(); + try { + if (id) { + await trackedAssetRepository.getBalances(owner, { id }); + return; + } + const scope = chainId ?? sessionChainId; + if (ChainUtils.isEVMChainId(scope)) { + await trackedAssetRepository.getBalances(owner, { chainId: scope }); + } + } catch (error: unknown) { + console.error("[oneshot-wallet] balance refresh failed", error); + throw error; + } + }, + [eventBus, trackedAssetRepository], + ); + + const listAssetActivity = useCallback( + async ( + owner: EVMAccountAddress, + asset: TrackedAsset, + limit?: number, + ): Promise => { + return assetActivityRepository.list({ owner, asset, limit }); + }, + [assetActivityRepository], + ); + + const recordSentActivity = useCallback( + async (params: IRecordSentActivityParams) => { + return assetActivityRepository.recordSent(params); + }, + [assetActivityRepository], + ); + + return { + listCredentials, + getCredential, + refreshCredentialsFromRelayer, + listDelegations, + getDelegation, + refreshDelegationsFromRelayer, + listTrackedAssets, + addTrackedAsset, + removeTrackedAsset, + getKnownAsset, + resolveTrackedAsset, + requestBalanceRefresh, + listAssetActivity, + recordSentActivity, + }; +} diff --git a/src/wallet/useWalletBoot.ts b/src/wallet/useWalletBoot.ts index 22b0953..15de8df 100644 --- a/src/wallet/useWalletBoot.ts +++ b/src/wallet/useWalletBoot.ts @@ -1,1204 +1,1207 @@ -import { useEffect, type RefObject } from "react"; -import { OWSSigner } from "@1shotapi/ows-signer-utils"; -import { OWSWallet, RpcHelper } from "@1shotapi/ows-wallet-utils"; -import { - ChainUtils, - EVMAccountAddress, - OwsInvalidParamsError, - OwsUserRejectedError, - type CredentialOfferApprovalRequest, - type CredentialPresentationApprovalRequest, - type EVMChainId, - type EVMTransactionHash, -} from "@1shotapi/ows-types"; -import type { - PersonalSignApprovalRequest, - SendTransactionApprovalRequest, - SignTypedDataApprovalRequest, -} from "@1shotapi/ows-signer-utils"; -import { InMemoryIssuerTrustRegistry } from "../demo/in-memory-trust-registry"; -import type { CachedRelayerVaultRepository } from "../lib/implementations/data/CachedRelayerVaultRepository"; -import { - DemoWalletAttestationProvider, - FetchUtils, - HttpOid4vciClient, - HttpOid4vpClient, - ParseUtils, -} from "@1shotapi/ows-oid4"; -import { - registerAccountConnect, - type AccountConnectStorage, -} from "../ows/registerAccountConnect"; -import { registerApprovalSigning } from "../ows/registerApprovalSigning"; -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, - runWithAnalytics, -} from "../lib/implementations/utils"; -import type { - IChainRepository, - IKnownAssetRepository, - ITrackedAssetRepository, -} from "../lib/interfaces/data"; -import type { - IDelegationService, - ITransactionService, -} from "../lib/interfaces/business"; -import { - ERC20_TOKEN_PERIODIC, - LIFI_SWAP_APPROVE, - LIFI_SWAP_PERIODIC, -} from "../lib/interfaces/business/IDelegationService"; -import type { - IConfigProvider, - IEventBus, - IOWSProvider, - ISIWEUtils, - ITransactionUtils, -} 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"; -import { - DelegationCancelAbortedEvent, - DelegationCancelledEvent, - DelegationCancelFailedEvent, - DelegationCreateCancelledEvent, - DelegationCreatedEvent, - DelegationCreateFailedEvent, - PersonalSignCancelledEvent, - PersonalSignEvent, - PersonalSignFailedEvent, - TransactionSubmitCancelledEvent, - TransactionSubmittedEvent, - TransactionSubmitFailedEvent, - TypedSignCancelledEvent, - TypedSignEvent, - TypedSignFailedEvent, -} from "../lib/types/events/productEvents"; -import { registerAddAssetRpc } from "./registerAddAsset"; -import { registerCreateAccountRpc } from "./registerCreateAccount"; -import type { IPasskeyRegistrationResult } from "./registerCreateAccount"; -import { registerFocusModeRpc } from "./registerFocusMode"; -import { registerSwitchChainRpc } from "./registerSwitchChain"; -import { registerOnrampRpc } from "./registerOnramp"; -import { registerBridgeRpc } from "./registerBridge"; -import { registerGetUpgradedRpc } from "./registerGetUpgraded"; -import { registerRequestCancelDelegationsRpc } from "./registerRequestCancelDelegations"; -import { registerBitcoinProvider } from "../ows/registerBitcoinProvider"; -import { loadCachedEvmAddress, loadCredentialId } from "../storage"; -import { hydrateBitcoinAddressesFromCachedSecp } from "./hydrateBitcoinAddresses"; -import { pushModal } from "./pushModal"; -import type { - ActiveModal, - IGrantExecutionPermissionResult, - IRelayerConfirmSendResult, -} from "./modalTypes"; -import { useWalletSessionStore } from "./sessionStore"; -import { DEMO_HOLDER_PRIVATE_JWK } from "../demo/demo-keys"; - -function analyticsAccountAddress( - fallback?: EVMAccountAddress, -): EVMAccountAddress { - return ( - fallback ?? - (useWalletSessionStore.getState().evmAddress || - loadCachedEvmAddress() || - EVMAccountAddress("0x0")) - ); -} - -function analyticsMethodId(data: string | null | undefined): string | null { - if (!data || data.length < 10) { - return null; - } - return data.slice(0, 10); -} - -function createDeferredSigner( - awaitSigner: () => Promise, -): OWSSigner { - let instance: OWSSigner | undefined; - let loadError: unknown; - void awaitSigner() - .then((signer) => { - instance = signer; - }) - .catch((error: unknown) => { - loadError = error; - console.error( - "[oneshot-wallet] deferred Signing Layer load failed", - error, - ); - }); - return new Proxy({} as OWSSigner, { - get(_target, property) { - if (property === "then") { - return undefined; - } - if (!instance) { - if (loadError !== undefined) { - throw loadError instanceof Error - ? loadError - : new Error( - `Signing Layer failed to load: ${String(loadError)}`, - ); - } - throw new Error( - "Signing Layer not ready — await ensureReady() before using the signer", - ); - } - const value = Reflect.get(instance, property, instance); - return typeof value === "function" - ? (value as (...args: unknown[]) => unknown).bind(instance) - : value; - }, - }); -} - -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, - }; -} - -export interface IUseWalletBootParams { - signerContainerRef: RefObject; - walletRef: RefObject; - signerRef: RefObject; - rpcHelperRef: RefObject; - awaitSignerRef: RefObject<(() => Promise) | null>; - ensureReadyRef: RefObject<() => Promise>; - ensureReady: () => Promise; - ensureOnboardedForSigning: () => Promise; - onSigningAuthenticated: () => Promise; - createNewWallet: (accountName: string) => Promise; - createNewWalletFromUi: () => Promise; - createPasskeyRegistrationOnly: ( - accountName?: string, - ) => Promise; - resolveChain: (chainId: EVMChainId) => SupportedChain | null; - owsProvider: IOWSProvider; - chainRepository: IChainRepository; - knownAssetRepository: IKnownAssetRepository; - trackedAssetRepository: ITrackedAssetRepository; - transactionService: ITransactionService; - paymentTokenUtils: IPaymentTokenUtils; - delegationService: IDelegationService; - transactionUtils: ITransactionUtils; - cctpUtils: ICCTPUtils; - liFiUtils: ILiFiUtils; - credentialRepository: CachedRelayerVaultRepository; - walletStorage: AccountConnectStorage; - eventBus: IEventBus; - configProvider: IConfigProvider; -} - -const siweUtils: ISIWEUtils = new SIWEUtils(); - -export function useWalletBoot({ - signerContainerRef, - walletRef, - signerRef, - rpcHelperRef, - awaitSignerRef, - ensureReadyRef: _ensureReadyRef, - ensureReady, - ensureOnboardedForSigning, - onSigningAuthenticated, - createNewWallet, - createNewWalletFromUi, - createPasskeyRegistrationOnly, - resolveChain, - owsProvider, - chainRepository, - knownAssetRepository, - trackedAssetRepository, - transactionService, - paymentTokenUtils, - delegationService, - transactionUtils, - cctpUtils, - liFiUtils, - credentialRepository, - walletStorage, - eventBus, - configProvider, -}: IUseWalletBootParams): void { - useEffect(() => { - let cancelled = false; - let chainEvents: RpcHelper["events"] | undefined; - const onChainChanged = (next: EVMChainId) => { - useWalletSessionStore.getState().setChainId(next); - walletRef.current?.providerEvents.emit("chainChanged", next); - }; - const session = useWalletSessionStore.getState(); - - const issuerTrust = new InMemoryIssuerTrustRegistry(); - const fetchUtils = new FetchUtils(); - const parseUtils = new ParseUtils(); - const oid4vci = new HttpOid4vciClient(fetchUtils, parseUtils); - const oid4vp = new HttpOid4vpClient(fetchUtils); - const attestationProvider = new DemoWalletAttestationProvider({ - privateJwk: DEMO_HOLDER_PRIVATE_JWK, - issuer: "ows-demo-wallet", - }); - - async function boot(): Promise { - await Promise.resolve(); - const container = signerContainerRef.current; - if (!container) { - throw new Error("#signer-container not mounted"); - } - - const signerUrl = new URL("/signer/", window.location.origin).href; - const signerPromise = OWSSigner.create(container, signerUrl, { - hidden: true, - credentialId: loadCredentialId(), - }); - const awaitSigner = async (): Promise => { - const loaded = wrapSignerWithCeremonyCopy(await signerPromise); - signerRef.current = loaded; - owsProvider.setSigner(loaded); - return loaded; - }; - awaitSignerRef.current = awaitSigner; - const signer = createDeferredSigner(awaitSigner); - - const wallet = OWSWallet.prepare({ debug: true }); - walletRef.current = wallet; - owsProvider.setWallet(wallet); - - const ask = ( - build: (handlers: { - id: string; - resolve: (value: T) => void; - reject: (error: unknown) => void; - }) => ActiveModal, - ) => pushModal(build); - - registerConfigureRpc(wallet, chainRepository); - - registerAccountConnect(wallet, signer, { - storage: walletStorage, - ensureReady, - requestConnectApproval: () => - ask(({ id, resolve }) => ({ - id, - kind: "connect", - resolve, - })), - getChainId: () => { - const id = useWalletSessionStore.getState().chainId; - if (ChainUtils.isBitcoinChainId(id)) { - return DEFAULT_CHAIN_ID; - } - return id; - }, - }); - - registerBitcoinProvider(wallet, { - owsProvider, - ensureReady, - }); - - const catalog = chainRepository.getCatalog(); - const defaultChainId = DEFAULT_CHAIN_ID; - const rpcHelper = new RpcHelper( - new Map( - catalog - .filter((chain) => ChainUtils.isEVMChainId(chain.chainId)) - .map((chain) => [chain.chainId as typeof defaultChainId, chain.rpcUrl]), - ), - wallet, - signer, - { - defaultChainId, - onChainChanged, - executionPermissions: { - requestExecutionPermissions: async (requests) => { - await ensureOnboardedForSigning(); - const wallet = await owsProvider.getWallet(); - const display = await wallet.requestDisplay(); - try { - if (requests.length === 0) { - return []; - } - - const prepared = requests.map((request) => { - const permissionType = request.permission.type; - const isErc20Periodic = - permissionType === ERC20_TOKEN_PERIODIC; - const isLiFiSwap = permissionType === LIFI_SWAP_PERIODIC; - const isLiFiApprove = permissionType === LIFI_SWAP_APPROVE; - if (!isErc20Periodic && !isLiFiSwap && !isLiFiApprove) { - throw new OwsInvalidParamsError( - `Unsupported execution permission type: ${permissionType}`, - ); - } - const chain = resolveChain(request.chainId); - if (!chain?.useRelayer) { - throw new OwsInvalidParamsError( - `Chain ${request.chainId} does not support execution permissions`, - ); - } - if ( - (isLiFiSwap || isLiFiApprove) && - liFiUtils.resolveSwapEnforcer(request.chainId) === null - ) { - throw new OwsInvalidParamsError( - `LiFi swap permissions are not supported on chain ${request.chainId}`, - ); - } - const grantKind = isLiFiSwap - ? ("grantLiFiSwapPermission" as const) - : isLiFiApprove - ? ("grantLiFiApprovePermission" as const) - : ("grantExecutionPermission" as const); - return { request, chain, grantKind }; - }); - - const domain = transactionUtils.resolveHostDomain(); - 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 }) => [ - request.chainId, - request.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( - 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 payment = await paymentTokenUtils.resolvePayment( - owner, - upgradeChainIds, - ); - if (!payment) { - throw new OwsInvalidParamsError( - styleController.get().copy.activateOfflinePermissions - .noUsdcError, - ); - } - - // Payment chain must be upgraded too (fee ExactCalldata). - if ( - !upgradeChainIds.some( - (id) => id === payment.paymentChainId, - ) - ) { - const paymentNeedsUpgrade = - await transactionService.needsWalletUpgrade( - payment.paymentChainId, - owner, - ); - if (paymentNeedsUpgrade) { - upgradeChainIds.push(payment.paymentChainId); - } - } - - const upgradeChains = upgradeChainIds.map((chainId) => { - const preparedItem = prepared.find( - (item) => item.request.chainId === 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 = - await ask( - ({ id, resolve, reject }) => ({ - id, - kind: "grantExecutionPermissions", - request: { - domain, - items: prepared.map(({ request, chain, grantKind }) => ({ - request, - chainName: chain.label, - grantKind, - })), - }, - resolve, - reject, - }), - ); - } catch (error: unknown) { - const durationMs = Math.round( - performance.now() - signStartedBatch, - ); - const chainId = - prepared[0]?.request.chainId ?? requests[0]!.chainId; - if (isAnalyticsCancelled(error)) { - eventBus.emitAnalytics( - new DelegationCreateCancelledEvent( - hostDomain, - account, - chainId, - durationMs, - ), - ); - } else { - eventBus.emitAnalytics( - new DelegationCreateFailedEvent( - hostDomain, - account, - chainId, - analyticsErrorCode(error), - durationMs, - ), - ); - } - throw error; - } - - const approvedItems = prepared.map((item, index) => { - const approved = approvedResults[index]!; - return { - request: item.request, - permission: approved.permission, - memo: approved.memo, - }; - }); - - const signStarted = performance.now(); - try { - const storedList = - await delegationService.createExecutionPermissions({ - items: approvedItems, - onDelegationsSigned: onSigningAuthenticated, - }); - const durationMs = Math.round(performance.now() - signStarted); - for (const stored of storedList) { - eventBus.emitAnalytics( - new DelegationCreatedEvent( - hostDomain, - account, - stored.chainId, - durationMs, - ), - ); - } - return storedList.map((stored) => stored.permissionResponse); - } catch (error: unknown) { - const durationMs = Math.round(performance.now() - signStarted); - const chainId = approvedItems[0]!.request.chainId; - if (isAnalyticsCancelled(error)) { - eventBus.emitAnalytics( - new DelegationCreateCancelledEvent( - hostDomain, - account, - chainId, - durationMs, - ), - ); - } else { - eventBus.emitAnalytics( - new DelegationCreateFailedEvent( - hostDomain, - account, - chainId, - analyticsErrorCode(error), - durationMs, - ), - ); - } - throw error; - } - } finally { - await display.hide(); - } - }, - revokeExecutionPermission: async (params) => { - await ensureOnboardedForSigning(); - const wallet = await owsProvider.getWallet(); - const display = await wallet.requestDisplay(); - try { - const stored = await delegationService.findByPermissionContext( - params.permissionContext, - ); - const chainId = - stored?.chainId ?? rpcHelper.getChainId(); - const chain = resolveChain(chainId); - if (!chain?.useRelayer) { - throw new OwsInvalidParamsError( - `Chain ${chainId} does not support canceling permissions`, - ); - } - const owner = - useWalletSessionStore.getState().evmAddress || - loadCachedEvmAddress(); - if (!owner) { - throw new OwsInvalidParamsError( - "Wallet address is required to cancel a permission", - ); - } - const cancelWork = await delegationService.buildCancelWork({ - chainId, - ...(stored ? { stored } : {}), - permissionContext: params.permissionContext, - }); - const domain = - stored?.hostDomain ?? - transactionUtils.resolveHostDomain(); - const { hostDomain } = await configProvider.getConfig(); - const started = performance.now(); - const account = analyticsAccountAddress(owner); - await runWithAnalytics( - (event) => eventBus.emitAnalytics(event), - async () => { - const txHashes = await ask( - ({ id, resolve, reject }) => ({ - id, - kind: "cancelDelegation", - request: { - domain: String(domain), - ownerAddress: owner, - items: [ - { - memo: stored?.memo ?? "", - chainName: chain.label, - chainId, - work: cancelWork, - }, - ], - allowSkipOnchain: Boolean(stored), - }, - execute: async (payments, ui) => { - const batch = - await delegationService.cancelDelegations({ - items: [ - { - chainId, - ...(stored ? { stored } : {}), - permissionContext: params.permissionContext, - }, - ], - payments, - ...ui, - }); - return batch.results.map((r) => r.transactionHash); - }, - executeLocal: async () => { - if (!stored) { - throw new Error( - "Skip onchain cancellation requires a stored permission", - ); - } - await delegationService.removeStoredDelegation( - stored, - ); - }, - resolve, - reject, - }), - ); - return txHashes?.[0] ?? null; - }, - { - success: (txHash) => - new DelegationCancelledEvent( - hostDomain, - account, - chainId, - txHash, - Math.round(performance.now() - started), - ), - cancelled: () => - new DelegationCancelAbortedEvent( - hostDomain, - account, - chainId, - Math.round(performance.now() - started), - ), - failed: (errorCode) => - new DelegationCancelFailedEvent( - hostDomain, - account, - chainId, - errorCode, - Math.round(performance.now() - started), - ), - }, - ); - return null; - } finally { - await display.hide(); - } - }, - getSupportedExecutionPermissions: () => - delegationService.getSupportedExecutionPermissions(), - getGrantedExecutionPermissions: async () => { - await ensureReady(); - return delegationService.getGrantedExecutionPermissions(); - }, - }, - }, - ); - rpcHelperRef.current = rpcHelper; - owsProvider.setRpcHelper(rpcHelper); - session.setChainId(rpcHelper.getChainId()); - chainEvents = rpcHelper.events; - - registerSwitchChainRpc(wallet, rpcHelper); - registerFocusModeRpc(wallet, rpcHelper); - - registerOnrampRpc(wallet, { - getOwnerAddress: () => { - const address = useWalletSessionStore.getState().evmAddress; - if (!address || String(address).toLowerCase() === "0x0") { - return null; - } - return address; - }, - }); - - registerGetUpgradedRpc(wallet, { - getOwnerAddress: () => { - const address = useWalletSessionStore.getState().evmAddress; - if (!address || String(address).toLowerCase() === "0x0") { - return null; - } - return address; - }, - transactionService, - }); - - registerRequestCancelDelegationsRpc(wallet, { - configProvider, - delegationService, - ensureOnboardedForSigning, - resolveChain, - ask, - }); - - registerBridgeRpc(wallet, { - getOwnerAddress: () => { - const address = useWalletSessionStore.getState().evmAddress; - if (!address || String(address).toLowerCase() === "0x0") { - return null; - } - return address; - }, - getSessionChainId: () => { - const id = useWalletSessionStore.getState().chainId; - if (ChainUtils.isBitcoinChainId(id)) { - return DEFAULT_CHAIN_ID; - } - return id as EVMChainId; - }, - chainRepository, - knownAssetRepository, - cctpUtils, - }); - - registerAddAssetRpc(wallet, { - knownAssetRepository, - trackedAssetRepository, - getOwnerAddress: () => useWalletSessionStore.getState().evmAddress, - requestAddAssetApproval: (request) => - ask(({ id, resolve }) => ({ - id, - kind: "addAsset", - request, - resolve, - })), - }); - - registerCreateAccountRpc(wallet, { - createNewWallet, - createNewWalletFromUi, - createPasskeyRegistrationOnly, - }); - - registerApprovalSigning(wallet, signer, { - ensureReady: ensureOnboardedForSigning, - onAuthenticated: onSigningAuthenticated, - chainRpc: rpcHelper, - approveAndSignPersonalMessage: async ( - request: PersonalSignApprovalRequest, - ) => { - const { hostDomain } = await configProvider.getConfig(); - const started = performance.now(); - const account = analyticsAccountAddress(request.address); - const siweFields = siweUtils.tryParsePersonalMessage(request.message); - return runWithAnalytics( - (event) => eventBus.emitAnalytics(event), - () => - ask(({ id, resolve, reject }) => - siweFields - ? { - id, - kind: "siwe", - source: "personalSign", - request, - fields: siweFields, - resolve, - reject, - } - : { - id, - kind: "personalSign", - request, - resolve, - reject, - }, - ), - { - success: () => - new PersonalSignEvent( - hostDomain, - account, - request.message.length, - Math.round(performance.now() - started), - ), - cancelled: () => - new PersonalSignCancelledEvent( - hostDomain, - account, - Math.round(performance.now() - started), - ), - failed: (errorCode) => - new PersonalSignFailedEvent( - hostDomain, - account, - errorCode, - Math.round(performance.now() - started), - ), - }, - ); - }, - approveAndSignTypedData: async ( - request: SignTypedDataApprovalRequest, - ) => { - const { hostDomain } = await configProvider.getConfig(); - const started = performance.now(); - const account = analyticsAccountAddress(request.address); - const siweFields = siweUtils.tryParseTypedData(request.typedData); - return runWithAnalytics( - (event) => eventBus.emitAnalytics(event), - () => - ask(({ id, resolve, reject }) => - siweFields - ? { - id, - kind: "siwe", - source: "typedData", - request, - fields: siweFields, - resolve, - reject, - } - : { - id, - kind: "typedData", - request, - resolve, - reject, - }, - ), - { - success: () => - new TypedSignEvent( - hostDomain, - account, - request.typedData.primaryType, - Math.round(performance.now() - started), - ), - cancelled: () => - new TypedSignCancelledEvent( - hostDomain, - account, - Math.round(performance.now() - started), - ), - failed: (errorCode) => - new TypedSignFailedEvent( - hostDomain, - account, - errorCode, - Math.round(performance.now() - started), - ), - }, - ); - }, - approveAndSignTransaction: async ( - request: SendTransactionApprovalRequest, - ) => { - const { hostDomain } = await configProvider.getConfig(); - const started = performance.now(); - const account = analyticsAccountAddress(request.address); - const methodId = analyticsMethodId(request.data); - const to = request.to; - - return runWithAnalytics( - (event) => eventBus.emitAnalytics(event), - async () => { - if (!request.to) { - throw new OwsUserRejectedError( - "Contract creation is not supported yet", - ); - } - - await ensureOnboardedForSigning(); - - const chain = resolveChain(request.chainId); - const useRelayer = chain?.useRelayer === true; - - const transfer = transactionUtils.tryDecodeErc20Transfer( - request.to, - request.data, - ); - - const executeSend = async ( - payment: { - paymentToken?: EVMAccountAddress; - feeAtoms?: TokenAmount; - paymentChainId?: EVMChainId; - }, - ui?: import("../lib/types/domain/RelayerSendUi").IRelayerSendUiCallbacks, - ) => { - let relayerOptions: - | { - paymentToken: EVMAccountAddress; - feeAtoms: TokenAmount; - paymentChainId: EVMChainId; - } - | undefined; - if (useRelayer) { - const confirmed = requireRelayerConfirmPayment(payment); - relayerOptions = { - paymentToken: confirmed.paymentToken, - feeAtoms: confirmed.feeAtoms, - paymentChainId: confirmed.paymentChainId, - }; - } - - const valueRaw = String(request.value); - const value = - valueRaw && valueRaw !== "0x0" && valueRaw !== "0x" - ? BigInt(valueRaw) - : undefined; - - const result = await transactionService.sendTransaction( - request.chainId, - { - to: request.to!, - data: request.data, - value, - }, - useRelayer && relayerOptions - ? { ...relayerOptions, ...ui } - : undefined, - ); - return result.transactionHash; - }; - - let hash: EVMTransactionHash; - const valueRaw = String(request.value); - const workValue = - valueRaw && valueRaw !== "0x0" && valueRaw !== "0x" - ? BigInt(valueRaw) - : undefined; - const sendWork = { - to: request.to!, - data: request.data, - value: workValue, - }; - if (transfer) { - const known = await knownAssetRepository.getKnownAsset( - request.chainId, - transfer.tokenAddress, - ); - const tracked = ( - await trackedAssetRepository.list(request.chainId) - ).find( - (asset) => - asset.chainId === request.chainId && - asset.address === transfer.tokenAddress, - ); - const tokenName = - tracked?.name ?? known?.name ?? transfer.tokenAddress; - const tokenSymbol = tracked?.symbol ?? known?.symbol ?? "TOKEN"; - const decimals = tracked?.decimals ?? known?.decimals ?? null; - hash = await ask( - ({ id, resolve, reject }) => ({ - id, - kind: "confirmTransfer", - request: { - domain: transactionUtils.resolveHostDomain(), - amount: transactionUtils.formatTokenAmount( - transfer.amount, - decimals, - ), - tokenName, - tokenSymbol, - tokenAddress: transfer.tokenAddress, - receiver: transfer.recipient, - chainName: transactionUtils.chainLabelFor( - request.chainId, - chainRepository.getCatalog(), - ), - chainId: request.chainId, - ownerAddress: request.address, - useRelayer, - work: sendWork, - }, - execute: executeSend, - resolve, - reject, - }), - ); - } else { - hash = await ask( - ({ id, resolve, reject }) => ({ - id, - kind: "sendTransaction", - request: { - ...request, - useRelayer, - }, - execute: executeSend, - resolve, - reject, - }), - ); - } - - return hash; - }, - { - success: (txHash) => - new TransactionSubmittedEvent( - hostDomain, - account, - request.chainId, - to!, - txHash, - Math.round(performance.now() - started), - methodId, - ), - cancelled: () => - new TransactionSubmitCancelledEvent( - hostDomain, - account, - request.chainId, - Math.round(performance.now() - started), - to, - ), - failed: (errorCode) => - new TransactionSubmitFailedEvent( - hostDomain, - account, - request.chainId, - errorCode, - Math.round(performance.now() - started), - to, - ), - }, - ); - }, - }); - - registerCredentialsProvider(wallet, signer, { - repository: credentialRepository, - oid4vci, - oid4vp, - trust: issuerTrust, - attestationProvider, - ensureReady, - ensureOnboarded: ensureOnboardedForSigning, - onAuthenticated: onSigningAuthenticated, - emitAnalytics: (event) => eventBus.emitAnalytics(event), - configProvider, - requestCredentialOfferApproval: ( - request: CredentialOfferApprovalRequest, - ) => - ask(({ id, resolve }) => ({ - id, - kind: "credentialOffer", - request, - resolve, - })), - requestCredentialPresentationApproval: ( - request: CredentialPresentationApprovalRequest, - ) => - ask(({ id, resolve }) => ({ - id, - kind: "credentialPresentation", - request, - resolve, - })), - }); - - void wallet.start().catch((error: unknown) => { - if (cancelled) return; - console.error("[oneshot-wallet] Postmate handshake failed", error); - useWalletSessionStore - .getState() - .setBootError(error instanceof Error ? error.message : String(error)); - }); - - void awaitSigner() - .then((signer) => { - if (cancelled) return; - useWalletSessionStore.getState().setSignerReady(true); - // Returning sessions hydrate as unlocked with EVM/Solana cache only — - // backfill Bitcoin addresses from cached secp key (no ceremony). - hydrateBitcoinAddressesFromCachedSecp(signer); - }) - .catch((error: unknown) => { - if (cancelled) return; - console.error("[oneshot-wallet] Signing Layer failed to load", error); - useWalletSessionStore - .getState() - .setBootError(error instanceof Error ? error.message : String(error)); - }); - - const listed = await credentialRepository.list(); - if (cancelled) return; - useWalletSessionStore.getState().setCredentialCount(listed.length); - const tracked = await trackedAssetRepository.list(); - if (cancelled) return; - useWalletSessionStore.getState().setTrackedAssetCount(tracked.length); - useWalletSessionStore.getState().setReady(true); - console.info("[oneshot-wallet] ready", { - chainId: rpcHelper.getChainId(), - }); - } - - void boot().catch((error: unknown) => { - console.error("[oneshot-wallet] failed to start", error); - useWalletSessionStore - .getState() - .setBootError(error instanceof Error ? error.message : String(error)); - }); - - return () => { - cancelled = true; - chainEvents?.off("chainChanged", onChainChanged); - }; - // Boot once; handlers close over ensureReady via ensureReadyRef. - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional mount-only boot - }, []); -} +import { useEffect, type RefObject } from "react"; +import { OWSSigner } from "@1shotapi/ows-signer-utils"; +import { OWSWallet, RpcHelper } from "@1shotapi/ows-wallet-utils"; +import { + ChainUtils, + EVMAccountAddress, + EVMContractAddress, + OwsInvalidParamsError, + OwsUserRejectedError, + type CredentialOfferApprovalRequest, + type CredentialPresentationApprovalRequest, + type EVMChainId, + type EVMTransactionHash, +} from "@1shotapi/ows-types"; +import type { + PersonalSignApprovalRequest, + SendTransactionApprovalRequest, + SignTypedDataApprovalRequest, +} from "@1shotapi/ows-signer-utils"; +import { InMemoryIssuerTrustRegistry } from "../demo/in-memory-trust-registry"; +import type { CachedRelayerVaultRepository } from "../lib/implementations/data/CachedRelayerVaultRepository"; +import { + DemoWalletAttestationProvider, + FetchUtils, + HttpOid4vciClient, + HttpOid4vpClient, + ParseUtils, +} from "@1shotapi/ows-oid4"; +import { + registerAccountConnect, + type AccountConnectStorage, +} from "../ows/registerAccountConnect"; +import { registerApprovalSigning } from "../ows/registerApprovalSigning"; +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, + runWithAnalytics, +} from "../lib/implementations/utils"; +import type { + IChainRepository, + IKnownAssetRepository, + ITrackedAssetRepository, +} from "../lib/interfaces/data"; +import type { + IDelegationService, + ITransactionService, +} from "../lib/interfaces/business"; +import { + ERC20_TOKEN_PERIODIC, + LIFI_SWAP_APPROVE, + LIFI_SWAP_PERIODIC, +} from "../lib/interfaces/business/IDelegationService"; +import type { + IConfigProvider, + IEventBus, + IOWSProvider, + ISIWEUtils, + ITransactionUtils, +} 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"; +import { + DelegationCancelAbortedEvent, + DelegationCancelledEvent, + DelegationCancelFailedEvent, + DelegationCreateCancelledEvent, + DelegationCreatedEvent, + DelegationCreateFailedEvent, + PersonalSignCancelledEvent, + PersonalSignEvent, + PersonalSignFailedEvent, + TransactionSubmitCancelledEvent, + TransactionSubmittedEvent, + TransactionSubmitFailedEvent, + TypedSignCancelledEvent, + TypedSignEvent, + TypedSignFailedEvent, +} from "../lib/types/events/productEvents"; +import { registerAddAssetRpc } from "./registerAddAsset"; +import { registerCreateAccountRpc } from "./registerCreateAccount"; +import type { IPasskeyRegistrationResult } from "./registerCreateAccount"; +import { registerFocusModeRpc } from "./registerFocusMode"; +import { registerSwitchChainRpc } from "./registerSwitchChain"; +import { registerOnrampRpc } from "./registerOnramp"; +import { registerBridgeRpc } from "./registerBridge"; +import { registerGetUpgradedRpc } from "./registerGetUpgraded"; +import { registerRequestCancelDelegationsRpc } from "./registerRequestCancelDelegations"; +import { registerBitcoinProvider } from "../ows/registerBitcoinProvider"; +import { loadCachedEvmAddress, loadCredentialId } from "../storage"; +import { hydrateBitcoinAddressesFromCachedSecp } from "./hydrateBitcoinAddresses"; +import { pushModal } from "./pushModal"; +import type { + ActiveModal, + IGrantExecutionPermissionResult, + IRelayerConfirmSendResult, +} from "./modalTypes"; +import { useWalletSessionStore } from "./sessionStore"; +import { DEMO_HOLDER_PRIVATE_JWK } from "../demo/demo-keys"; + +function analyticsAccountAddress( + fallback?: EVMAccountAddress, +): EVMAccountAddress { + return ( + fallback ?? + (useWalletSessionStore.getState().evmAddress || + loadCachedEvmAddress() || + EVMAccountAddress("0x0")) + ); +} + +function analyticsMethodId(data: string | null | undefined): string | null { + if (!data || data.length < 10) { + return null; + } + return data.slice(0, 10); +} + +function createDeferredSigner( + awaitSigner: () => Promise, +): OWSSigner { + let instance: OWSSigner | undefined; + let loadError: unknown; + void awaitSigner() + .then((signer) => { + instance = signer; + }) + .catch((error: unknown) => { + loadError = error; + console.error( + "[oneshot-wallet] deferred Signing Layer load failed", + error, + ); + }); + return new Proxy({} as OWSSigner, { + get(_target, property) { + if (property === "then") { + return undefined; + } + if (!instance) { + if (loadError !== undefined) { + throw loadError instanceof Error + ? loadError + : new Error( + `Signing Layer failed to load: ${String(loadError)}`, + ); + } + throw new Error( + "Signing Layer not ready — await ensureReady() before using the signer", + ); + } + const value = Reflect.get(instance, property, instance); + return typeof value === "function" + ? (value as (...args: unknown[]) => unknown).bind(instance) + : value; + }, + }); +} + +function requireRelayerConfirmPayment(confirmed: { + paymentToken?: EVMContractAddress; + 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, + }; +} + +export interface IUseWalletBootParams { + signerContainerRef: RefObject; + walletRef: RefObject; + signerRef: RefObject; + rpcHelperRef: RefObject; + awaitSignerRef: RefObject<(() => Promise) | null>; + ensureReadyRef: RefObject<() => Promise>; + ensureReady: () => Promise; + ensureOnboardedForSigning: () => Promise; + onSigningAuthenticated: () => Promise; + createNewWallet: (accountName: string) => Promise; + createNewWalletFromUi: () => Promise; + createPasskeyRegistrationOnly: ( + accountName?: string, + ) => Promise; + resolveChain: (chainId: EVMChainId) => SupportedChain | null; + owsProvider: IOWSProvider; + chainRepository: IChainRepository; + knownAssetRepository: IKnownAssetRepository; + trackedAssetRepository: ITrackedAssetRepository; + transactionService: ITransactionService; + paymentTokenUtils: IPaymentTokenUtils; + delegationService: IDelegationService; + transactionUtils: ITransactionUtils; + cctpUtils: ICCTPUtils; + liFiUtils: ILiFiUtils; + credentialRepository: CachedRelayerVaultRepository; + walletStorage: AccountConnectStorage; + eventBus: IEventBus; + configProvider: IConfigProvider; +} + +const siweUtils: ISIWEUtils = new SIWEUtils(); + +export function useWalletBoot({ + signerContainerRef, + walletRef, + signerRef, + rpcHelperRef, + awaitSignerRef, + ensureReadyRef: _ensureReadyRef, + ensureReady, + ensureOnboardedForSigning, + onSigningAuthenticated, + createNewWallet, + createNewWalletFromUi, + createPasskeyRegistrationOnly, + resolveChain, + owsProvider, + chainRepository, + knownAssetRepository, + trackedAssetRepository, + transactionService, + paymentTokenUtils, + delegationService, + transactionUtils, + cctpUtils, + liFiUtils, + credentialRepository, + walletStorage, + eventBus, + configProvider, +}: IUseWalletBootParams): void { + useEffect(() => { + let cancelled = false; + let chainEvents: RpcHelper["events"] | undefined; + const onChainChanged = (next: EVMChainId) => { + useWalletSessionStore.getState().setChainId(next); + walletRef.current?.providerEvents.emit("chainChanged", next); + }; + const session = useWalletSessionStore.getState(); + + const issuerTrust = new InMemoryIssuerTrustRegistry(); + const fetchUtils = new FetchUtils(); + const parseUtils = new ParseUtils(); + const oid4vci = new HttpOid4vciClient(fetchUtils, parseUtils); + const oid4vp = new HttpOid4vpClient(fetchUtils); + const attestationProvider = new DemoWalletAttestationProvider({ + privateJwk: DEMO_HOLDER_PRIVATE_JWK, + issuer: "ows-demo-wallet", + }); + + async function boot(): Promise { + await Promise.resolve(); + const container = signerContainerRef.current; + if (!container) { + throw new Error("#signer-container not mounted"); + } + + const signerUrl = new URL("/signer/", window.location.origin).href; + const signerPromise = OWSSigner.create(container, signerUrl, { + hidden: true, + credentialId: loadCredentialId(), + }); + const awaitSigner = async (): Promise => { + const loaded = wrapSignerWithCeremonyCopy(await signerPromise); + signerRef.current = loaded; + owsProvider.setSigner(loaded); + return loaded; + }; + awaitSignerRef.current = awaitSigner; + const signer = createDeferredSigner(awaitSigner); + + const wallet = OWSWallet.prepare({ debug: true }); + walletRef.current = wallet; + owsProvider.setWallet(wallet); + + const ask = ( + build: (handlers: { + id: string; + resolve: (value: T) => void; + reject: (error: unknown) => void; + }) => ActiveModal, + ) => pushModal(build); + + registerConfigureRpc(wallet, chainRepository); + + registerAccountConnect(wallet, signer, { + storage: walletStorage, + ensureReady, + requestConnectApproval: () => + ask(({ id, resolve }) => ({ + id, + kind: "connect", + resolve, + })), + getChainId: () => { + const id = useWalletSessionStore.getState().chainId; + if (ChainUtils.isBitcoinChainId(id)) { + return DEFAULT_CHAIN_ID; + } + return id; + }, + }); + + registerBitcoinProvider(wallet, { + owsProvider, + ensureReady, + }); + + const catalog = chainRepository.getCatalog(); + const defaultChainId = DEFAULT_CHAIN_ID; + const rpcHelper = new RpcHelper( + new Map( + catalog + .filter((chain) => ChainUtils.isEVMChainId(chain.chainId)) + .map((chain) => [chain.chainId as typeof defaultChainId, chain.rpcUrl]), + ), + wallet, + signer, + { + defaultChainId, + onChainChanged, + executionPermissions: { + requestExecutionPermissions: async (requests) => { + await ensureOnboardedForSigning(); + const wallet = await owsProvider.getWallet(); + const display = await wallet.requestDisplay(); + try { + if (requests.length === 0) { + return []; + } + + const prepared = requests.map((request) => { + const permissionType = request.permission.type; + const isErc20Periodic = + permissionType === ERC20_TOKEN_PERIODIC; + const isLiFiSwap = permissionType === LIFI_SWAP_PERIODIC; + const isLiFiApprove = permissionType === LIFI_SWAP_APPROVE; + if (!isErc20Periodic && !isLiFiSwap && !isLiFiApprove) { + throw new OwsInvalidParamsError( + `Unsupported execution permission type: ${permissionType}`, + ); + } + const chain = resolveChain(request.chainId); + if (!chain?.useRelayer) { + throw new OwsInvalidParamsError( + `Chain ${request.chainId} does not support execution permissions`, + ); + } + if ( + (isLiFiSwap || isLiFiApprove) && + liFiUtils.resolveSwapEnforcer(request.chainId) === null + ) { + throw new OwsInvalidParamsError( + `LiFi swap permissions are not supported on chain ${request.chainId}`, + ); + } + const grantKind = isLiFiSwap + ? ("grantLiFiSwapPermission" as const) + : isLiFiApprove + ? ("grantLiFiApprovePermission" as const) + : ("grantExecutionPermission" as const); + return { request, chain, grantKind }; + }); + + const domain = transactionUtils.resolveHostDomain(); + 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 }) => [ + request.chainId, + request.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( + 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 payment = await paymentTokenUtils.resolvePayment( + owner, + upgradeChainIds, + ); + if (!payment) { + throw new OwsInvalidParamsError( + styleController.get().copy.activateOfflinePermissions + .noUsdcError, + ); + } + + // Payment chain must be upgraded too (fee ExactCalldata). + if ( + !upgradeChainIds.some( + (id) => id === payment.paymentChainId, + ) + ) { + const paymentNeedsUpgrade = + await transactionService.needsWalletUpgrade( + payment.paymentChainId, + owner, + ); + if (paymentNeedsUpgrade) { + upgradeChainIds.push(payment.paymentChainId); + } + } + + const upgradeChains = upgradeChainIds.map((chainId) => { + const preparedItem = prepared.find( + (item) => item.request.chainId === 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 = + await ask( + ({ id, resolve, reject }) => ({ + id, + kind: "grantExecutionPermissions", + request: { + domain, + items: prepared.map(({ request, chain, grantKind }) => ({ + request, + chainName: chain.label, + grantKind, + })), + }, + resolve, + reject, + }), + ); + } catch (error: unknown) { + const durationMs = Math.round( + performance.now() - signStartedBatch, + ); + const chainId = + prepared[0]?.request.chainId ?? requests[0]!.chainId; + if (isAnalyticsCancelled(error)) { + eventBus.emitAnalytics( + new DelegationCreateCancelledEvent( + hostDomain, + account, + chainId, + durationMs, + ), + ); + } else { + eventBus.emitAnalytics( + new DelegationCreateFailedEvent( + hostDomain, + account, + chainId, + analyticsErrorCode(error), + durationMs, + ), + ); + } + throw error; + } + + const approvedItems = prepared.map((item, index) => { + const approved = approvedResults[index]!; + return { + request: item.request, + permission: approved.permission, + memo: approved.memo, + }; + }); + + const signStarted = performance.now(); + try { + const storedList = + await delegationService.createExecutionPermissions({ + items: approvedItems, + onDelegationsSigned: onSigningAuthenticated, + }); + const durationMs = Math.round(performance.now() - signStarted); + for (const stored of storedList) { + eventBus.emitAnalytics( + new DelegationCreatedEvent( + hostDomain, + account, + stored.chainId, + durationMs, + ), + ); + } + return storedList.map((stored) => stored.permissionResponse); + } catch (error: unknown) { + const durationMs = Math.round(performance.now() - signStarted); + const chainId = approvedItems[0]!.request.chainId; + if (isAnalyticsCancelled(error)) { + eventBus.emitAnalytics( + new DelegationCreateCancelledEvent( + hostDomain, + account, + chainId, + durationMs, + ), + ); + } else { + eventBus.emitAnalytics( + new DelegationCreateFailedEvent( + hostDomain, + account, + chainId, + analyticsErrorCode(error), + durationMs, + ), + ); + } + throw error; + } + } finally { + await display.hide(); + } + }, + revokeExecutionPermission: async (params) => { + await ensureOnboardedForSigning(); + const wallet = await owsProvider.getWallet(); + const display = await wallet.requestDisplay(); + try { + const stored = await delegationService.findByPermissionContext( + params.permissionContext, + ); + const chainId = + stored?.chainId ?? rpcHelper.getChainId(); + const chain = resolveChain(chainId); + if (!chain?.useRelayer) { + throw new OwsInvalidParamsError( + `Chain ${chainId} does not support canceling permissions`, + ); + } + const owner = + useWalletSessionStore.getState().evmAddress || + loadCachedEvmAddress(); + if (!owner) { + throw new OwsInvalidParamsError( + "Wallet address is required to cancel a permission", + ); + } + const cancelWork = await delegationService.buildCancelWork({ + chainId, + ...(stored ? { stored } : {}), + permissionContext: params.permissionContext, + }); + const domain = + stored?.hostDomain ?? + transactionUtils.resolveHostDomain(); + const { hostDomain } = await configProvider.getConfig(); + const started = performance.now(); + const account = analyticsAccountAddress(owner); + await runWithAnalytics( + (event) => eventBus.emitAnalytics(event), + async () => { + const txHashes = await ask( + ({ id, resolve, reject }) => ({ + id, + kind: "cancelDelegation", + request: { + domain: String(domain), + ownerAddress: owner, + items: [ + { + memo: stored?.memo ?? "", + chainName: chain.label, + chainId, + work: cancelWork, + }, + ], + allowSkipOnchain: Boolean(stored), + }, + execute: async (payment, ui) => { + const batch = + await delegationService.cancelDelegations({ + items: [ + { + chainId, + ...(stored ? { stored } : {}), + permissionContext: params.permissionContext, + }, + ], + paymentToken: payment.paymentToken, + feeAtoms: payment.feeAtoms, + paymentChainId: payment.paymentChainId, + ...ui, + }); + return batch.results.map((r) => r.transactionHash); + }, + executeLocal: async () => { + if (!stored) { + throw new Error( + "Skip onchain cancellation requires a stored permission", + ); + } + await delegationService.removeStoredDelegation( + stored, + ); + }, + resolve, + reject, + }), + ); + return txHashes?.[0] ?? null; + }, + { + success: (txHash) => + new DelegationCancelledEvent( + hostDomain, + account, + chainId, + txHash, + Math.round(performance.now() - started), + ), + cancelled: () => + new DelegationCancelAbortedEvent( + hostDomain, + account, + chainId, + Math.round(performance.now() - started), + ), + failed: (errorCode) => + new DelegationCancelFailedEvent( + hostDomain, + account, + chainId, + errorCode, + Math.round(performance.now() - started), + ), + }, + ); + return null; + } finally { + await display.hide(); + } + }, + getSupportedExecutionPermissions: () => + delegationService.getSupportedExecutionPermissions(), + getGrantedExecutionPermissions: async () => { + await ensureReady(); + return delegationService.getGrantedExecutionPermissions(); + }, + }, + }, + ); + rpcHelperRef.current = rpcHelper; + owsProvider.setRpcHelper(rpcHelper); + session.setChainId(rpcHelper.getChainId()); + chainEvents = rpcHelper.events; + + registerSwitchChainRpc(wallet, rpcHelper); + registerFocusModeRpc(wallet, rpcHelper); + + registerOnrampRpc(wallet, { + getOwnerAddress: () => { + const address = useWalletSessionStore.getState().evmAddress; + if (!address || String(address).toLowerCase() === "0x0") { + return null; + } + return address; + }, + }); + + registerGetUpgradedRpc(wallet, { + getOwnerAddress: () => { + const address = useWalletSessionStore.getState().evmAddress; + if (!address || String(address).toLowerCase() === "0x0") { + return null; + } + return address; + }, + transactionService, + }); + + registerRequestCancelDelegationsRpc(wallet, { + configProvider, + delegationService, + ensureOnboardedForSigning, + resolveChain, + ask, + }); + + registerBridgeRpc(wallet, { + getOwnerAddress: () => { + const address = useWalletSessionStore.getState().evmAddress; + if (!address || String(address).toLowerCase() === "0x0") { + return null; + } + return address; + }, + getSessionChainId: () => { + const id = useWalletSessionStore.getState().chainId; + if (ChainUtils.isBitcoinChainId(id)) { + return DEFAULT_CHAIN_ID; + } + return id as EVMChainId; + }, + chainRepository, + knownAssetRepository, + cctpUtils, + }); + + registerAddAssetRpc(wallet, { + knownAssetRepository, + trackedAssetRepository, + getOwnerAddress: () => useWalletSessionStore.getState().evmAddress, + requestAddAssetApproval: (request) => + ask(({ id, resolve }) => ({ + id, + kind: "addAsset", + request, + resolve, + })), + }); + + registerCreateAccountRpc(wallet, { + createNewWallet, + createNewWalletFromUi, + createPasskeyRegistrationOnly, + }); + + registerApprovalSigning(wallet, signer, { + ensureReady: ensureOnboardedForSigning, + onAuthenticated: onSigningAuthenticated, + chainRpc: rpcHelper, + approveAndSignPersonalMessage: async ( + request: PersonalSignApprovalRequest, + ) => { + const { hostDomain } = await configProvider.getConfig(); + const started = performance.now(); + const account = analyticsAccountAddress(request.address); + const siweFields = siweUtils.tryParsePersonalMessage(request.message); + return runWithAnalytics( + (event) => eventBus.emitAnalytics(event), + () => + ask(({ id, resolve, reject }) => + siweFields + ? { + id, + kind: "siwe", + source: "personalSign", + request, + fields: siweFields, + resolve, + reject, + } + : { + id, + kind: "personalSign", + request, + resolve, + reject, + }, + ), + { + success: () => + new PersonalSignEvent( + hostDomain, + account, + request.message.length, + Math.round(performance.now() - started), + ), + cancelled: () => + new PersonalSignCancelledEvent( + hostDomain, + account, + Math.round(performance.now() - started), + ), + failed: (errorCode) => + new PersonalSignFailedEvent( + hostDomain, + account, + errorCode, + Math.round(performance.now() - started), + ), + }, + ); + }, + approveAndSignTypedData: async ( + request: SignTypedDataApprovalRequest, + ) => { + const { hostDomain } = await configProvider.getConfig(); + const started = performance.now(); + const account = analyticsAccountAddress(request.address); + const siweFields = siweUtils.tryParseTypedData(request.typedData); + return runWithAnalytics( + (event) => eventBus.emitAnalytics(event), + () => + ask(({ id, resolve, reject }) => + siweFields + ? { + id, + kind: "siwe", + source: "typedData", + request, + fields: siweFields, + resolve, + reject, + } + : { + id, + kind: "typedData", + request, + resolve, + reject, + }, + ), + { + success: () => + new TypedSignEvent( + hostDomain, + account, + request.typedData.primaryType, + Math.round(performance.now() - started), + ), + cancelled: () => + new TypedSignCancelledEvent( + hostDomain, + account, + Math.round(performance.now() - started), + ), + failed: (errorCode) => + new TypedSignFailedEvent( + hostDomain, + account, + errorCode, + Math.round(performance.now() - started), + ), + }, + ); + }, + approveAndSignTransaction: async ( + request: SendTransactionApprovalRequest, + ) => { + const { hostDomain } = await configProvider.getConfig(); + const started = performance.now(); + const account = analyticsAccountAddress(request.address); + const methodId = analyticsMethodId(request.data); + const to = request.to; + + return runWithAnalytics( + (event) => eventBus.emitAnalytics(event), + async () => { + if (!request.to) { + throw new OwsUserRejectedError( + "Contract creation is not supported yet", + ); + } + + await ensureOnboardedForSigning(); + + const chain = resolveChain(request.chainId); + const useRelayer = chain?.useRelayer === true; + + const transfer = transactionUtils.tryDecodeErc20Transfer( + request.to ? EVMContractAddress(request.to) : null, + request.data, + ); + + const executeSend = async ( + payment: { + paymentToken?: EVMContractAddress; + feeAtoms?: TokenAmount; + paymentChainId?: EVMChainId; + }, + ui?: import("../lib/types/domain/RelayerSendUi").IRelayerSendUiCallbacks, + ) => { + let relayerOptions: + | { + paymentToken: EVMContractAddress; + feeAtoms: TokenAmount; + paymentChainId: EVMChainId; + } + | undefined; + if (useRelayer) { + const confirmed = requireRelayerConfirmPayment(payment); + relayerOptions = { + paymentToken: confirmed.paymentToken, + feeAtoms: confirmed.feeAtoms, + paymentChainId: confirmed.paymentChainId, + }; + } + + const valueRaw = String(request.value); + const value = + valueRaw && valueRaw !== "0x0" && valueRaw !== "0x" + ? BigInt(valueRaw) + : undefined; + + const result = await transactionService.sendTransaction( + request.chainId, + { + to: request.to!, + data: request.data, + value, + }, + useRelayer && relayerOptions + ? { ...relayerOptions, ...ui } + : undefined, + ); + return result.transactionHash; + }; + + let hash: EVMTransactionHash; + const valueRaw = String(request.value); + const workValue = + valueRaw && valueRaw !== "0x0" && valueRaw !== "0x" + ? BigInt(valueRaw) + : undefined; + const sendWork = { + to: request.to!, + data: request.data, + value: workValue, + }; + if (transfer) { + const known = await knownAssetRepository.getKnownAsset( + request.chainId, + transfer.tokenAddress, + ); + const tracked = ( + await trackedAssetRepository.list(request.chainId) + ).find( + (asset) => + asset.chainId === request.chainId && + asset.address === transfer.tokenAddress, + ); + const tokenName = + tracked?.name ?? known?.name ?? transfer.tokenAddress; + const tokenSymbol = tracked?.symbol ?? known?.symbol ?? "TOKEN"; + const decimals = tracked?.decimals ?? known?.decimals ?? null; + hash = await ask( + ({ id, resolve, reject }) => ({ + id, + kind: "confirmTransfer", + request: { + domain: transactionUtils.resolveHostDomain(), + amount: transactionUtils.formatTokenAmount( + transfer.amount, + decimals, + ), + tokenName, + tokenSymbol, + tokenAddress: transfer.tokenAddress, + receiver: transfer.recipient, + chainName: transactionUtils.chainLabelFor( + request.chainId, + chainRepository.getCatalog(), + ), + chainId: request.chainId, + ownerAddress: request.address, + useRelayer, + work: sendWork, + }, + execute: executeSend, + resolve, + reject, + }), + ); + } else { + hash = await ask( + ({ id, resolve, reject }) => ({ + id, + kind: "sendTransaction", + request: { + ...request, + useRelayer, + }, + execute: executeSend, + resolve, + reject, + }), + ); + } + + return hash; + }, + { + success: (txHash) => + new TransactionSubmittedEvent( + hostDomain, + account, + request.chainId, + to!, + txHash, + Math.round(performance.now() - started), + methodId, + ), + cancelled: () => + new TransactionSubmitCancelledEvent( + hostDomain, + account, + request.chainId, + Math.round(performance.now() - started), + to, + ), + failed: (errorCode) => + new TransactionSubmitFailedEvent( + hostDomain, + account, + request.chainId, + errorCode, + Math.round(performance.now() - started), + to, + ), + }, + ); + }, + }); + + registerCredentialsProvider(wallet, signer, { + repository: credentialRepository, + oid4vci, + oid4vp, + trust: issuerTrust, + attestationProvider, + ensureReady, + ensureOnboarded: ensureOnboardedForSigning, + onAuthenticated: onSigningAuthenticated, + emitAnalytics: (event) => eventBus.emitAnalytics(event), + configProvider, + requestCredentialOfferApproval: ( + request: CredentialOfferApprovalRequest, + ) => + ask(({ id, resolve }) => ({ + id, + kind: "credentialOffer", + request, + resolve, + })), + requestCredentialPresentationApproval: ( + request: CredentialPresentationApprovalRequest, + ) => + ask(({ id, resolve }) => ({ + id, + kind: "credentialPresentation", + request, + resolve, + })), + }); + + void wallet.start().catch((error: unknown) => { + if (cancelled) return; + console.error("[oneshot-wallet] Postmate handshake failed", error); + useWalletSessionStore + .getState() + .setBootError(error instanceof Error ? error.message : String(error)); + }); + + void awaitSigner() + .then((signer) => { + if (cancelled) return; + useWalletSessionStore.getState().setSignerReady(true); + // Returning sessions hydrate as unlocked with EVM/Solana cache only — + // backfill Bitcoin addresses from cached secp key (no ceremony). + hydrateBitcoinAddressesFromCachedSecp(signer); + }) + .catch((error: unknown) => { + if (cancelled) return; + console.error("[oneshot-wallet] Signing Layer failed to load", error); + useWalletSessionStore + .getState() + .setBootError(error instanceof Error ? error.message : String(error)); + }); + + const listed = await credentialRepository.list(); + if (cancelled) return; + useWalletSessionStore.getState().setCredentialCount(listed.length); + const tracked = await trackedAssetRepository.list(); + if (cancelled) return; + useWalletSessionStore.getState().setTrackedAssetCount(tracked.length); + useWalletSessionStore.getState().setReady(true); + console.info("[oneshot-wallet] ready", { + chainId: rpcHelper.getChainId(), + }); + } + + void boot().catch((error: unknown) => { + console.error("[oneshot-wallet] failed to start", error); + useWalletSessionStore + .getState() + .setBootError(error instanceof Error ? error.message : String(error)); + }); + + return () => { + cancelled = true; + chainEvents?.off("chainChanged", onChainChanged); + }; + // Boot once; handlers close over ensureReady via ensureReadyRef. + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional mount-only boot + }, []); +}