diff --git a/README.md b/README.md index 4120f0ea..53170e62 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,10 @@ Create a `.env.local` file (or configure your deployment environment) with the f ### App -| Variable | Description | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `NEXT_PUBLIC_APP_ENV` | `"development"` or `"production"`. Controls which backend URLs are used and which chain tokens are on (ETH Sepolia or BASE). | +| Variable | Description | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_APP_ENV` | `"development"` or `"production"`. Controls which backend URLs are used and which chain tokens are on (ETH Sepolia or BASE). | +| `NEXT_PUBLIC_LEGACY_ESCROW_ADDRESS` | Optional. Address of the previous Escrow deployment on Base. When set, the escrow page shows a contract selector so users can view and withdraw funds left in it. Ignored on Sepolia. | ### Alchemy diff --git a/src/components/escrow/escrow-page.module.css b/src/components/escrow/escrow-page.module.css index c81d8aa8..7703736b 100644 --- a/src/components/escrow/escrow-page.module.css +++ b/src/components/escrow/escrow-page.module.css @@ -3,6 +3,28 @@ flex-direction: column; } +.contractSelector { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} + +.contractSelect { + width: 350px; + max-width: 100%; +} + +.hint { + color: var(--text-secondary); + padding: 0 16px; +} + +/* Line the chip up with the select control, below its label. */ +.legacyChip { + margin-top: 38px; +} + .panels { display: flex; flex-direction: column; diff --git a/src/components/escrow/escrow-page.tsx b/src/components/escrow/escrow-page.tsx index 547b8078..f8a08b46 100644 --- a/src/components/escrow/escrow-page.tsx +++ b/src/components/escrow/escrow-page.tsx @@ -1,13 +1,22 @@ +import Card from '@/components/card/card'; import Container from '@/components/container/container'; import EscrowTokenPanel from '@/components/escrow/escrow-token-panel'; +import LegacyEscrowBanner from '@/components/homepage/legacy-escrow-banner'; +import Select from '@/components/input/select'; import SectionTitle from '@/components/section-title/section-title'; +import { EscrowContractVersion, LEGACY_ESCROW_ADDRESS } from '@/constants/escrow'; import { useEscrowData } from '@/lib/use-escrow-data'; +import { formatWalletAddress } from '@/utils/formatters'; import { CircularProgress } from '@mui/material'; import classNames from 'classnames'; +import { useState } from 'react'; import styles from './escrow-page.module.css'; const EscrowPage = () => { - const { tokens, spenders, loading, reload } = useEscrowData(); + const [contractVersion, setContractVersion] = useState('current'); + const isLegacy = contractVersion === 'legacy'; + const escrowAddress = isLegacy ? LEGACY_ESCROW_ADDRESS : undefined; + const { tokens, spenders, loading, reload } = useEscrowData(escrowAddress); return ( @@ -17,6 +26,31 @@ const EscrowPage = () => { subTitle="Deposit and withdraw escrow funds, and manage the spending authorizations used to pay for compute jobs." />
+ + {LEGACY_ESCROW_ADDRESS && ( + +
+ + className={styles.contractSelect} + label="Escrow contract" + onChange={(e) => setContractVersion(e.target.value as EscrowContractVersion)} + options={[ + { label: 'Current contract', value: 'current' }, + { label: `Legacy contract (${formatWalletAddress(LEGACY_ESCROW_ADDRESS)})`, value: 'legacy' }, + ]} + value={contractVersion} + /> + {isLegacy && Withdraw only} +
+ {isLegacy ? ( +
+ Viewing the previous escrow deployment. +
+ You can withdraw your funds; deposits and authorization changes are disabled. +
+ ) : undefined} +
+ )} {loading && tokens.length === 0 ? ( ) : ( @@ -27,6 +61,7 @@ const EscrowPage = () => { ); return ( void; + // Set when the panel shows the legacy escrow deployment: reads and withdrawals target this + // address, and deposits/authorization changes are disabled. + escrowAddress?: string; }; // One spending-authorization card (right-hand side). A token can have multiple authorized @@ -36,10 +39,12 @@ const AuthorizationCard = ({ spender, token, onChange, + readOnly, }: { spender: EscrowSpenderInfo; token: EscrowTokenInfo; onChange: () => void; + readOnly?: boolean; }) => { const [isEditOpen, setIsEditOpen] = useState(false); const [isRevokeOpen, setIsRevokeOpen] = useState(false); @@ -88,48 +93,52 @@ const AuthorizationCard = ({ />
- setMenuAnchor(menuButtonRef.current)} - ref={menuButtonRef} - size="small" - sx={{ color: 'var(--text-secondary)' }} - > - - - - { - setIsEditOpen(true); - closeMenu(); - }} - > - - - - Edit - - { - setIsRevokeOpen(true); - closeMenu(); - }} - sx={{ color: 'var(--error-darker)' }} - > - - - - Revoke - - + {!readOnly && ( + <> + setMenuAnchor(menuButtonRef.current)} + ref={menuButtonRef} + size="small" + sx={{ color: 'var(--text-secondary)' }} + > + + + + { + setIsEditOpen(true); + closeMenu(); + }} + > + + + + Edit + + { + setIsRevokeOpen(true); + closeMenu(); + }} + sx={{ color: 'var(--error-darker)' }} + > + + + + Revoke + + + + )} {/* Stats grid */} @@ -202,31 +211,36 @@ const AuthorizationCard = ({ No active locks )} - setIsEditOpen(false)} - onSuccess={() => { - setIsEditOpen(false); - onChange(); - }} - spender={spender} - /> + {!readOnly && ( + <> + setIsEditOpen(false)} + onSuccess={() => { + setIsEditOpen(false); + onChange(); + }} + spender={spender} + /> - setIsRevokeOpen(false)} - onSuccess={() => { - setIsRevokeOpen(false); - onChange(); - }} - spender={spender} - /> + setIsRevokeOpen(false)} + onSuccess={() => { + setIsRevokeOpen(false); + onChange(); + }} + spender={spender} + /> + + )} ); }; -const EscrowTokenPanel = ({ token, spenders, loadingSpenders, onChange }: EscrowTokenPanelProps) => { +const EscrowTokenPanel = ({ token, spenders, loadingSpenders, onChange, escrowAddress }: EscrowTokenPanelProps) => { const [isCreateOpen, setIsCreateOpen] = useState(false); + const isLegacy = !!escrowAddress; // Single amount input drives both actions. amount > 0 is the base check; the per-action // balance ceiling (wallet for deposit, escrow for withdraw) is validated at click time @@ -272,7 +286,7 @@ const EscrowTokenPanel = ({ token, spenders, loadingSpenders, onChange }: Escrow return; } setError(undefined); - handleWithdraw({ tokenAddresses: [token.address], amounts: [value.toString()] }); + handleWithdraw({ tokenAddresses: [token.address], amounts: [value.toString()], escrowAddress }); }; return ( @@ -311,6 +325,12 @@ const EscrowTokenPanel = ({ token, spenders, loadingSpenders, onChange }: Escrow {/* Move funds */}
+ {isLegacy && ( + + This is the previous escrow contract — you can only withdraw from it. Deposits go to the current + contract. + + )} {token.symbol}} type="number" value={amount} /> @@ -340,19 +360,21 @@ const EscrowTokenPanel = ({ token, spenders, loadingSpenders, onChange }: Escrow > Withdraw - + {!isLegacy && ( + + )}
@@ -366,16 +388,18 @@ const EscrowTokenPanel = ({ token, spenders, loadingSpenders, onChange }: Escrow {spenders.length} {spenders.length === 1 ? 'node' : 'nodes'} )} - + {!isLegacy && ( + + )} {loadingSpenders && spenders.length === 0 ? (
@@ -383,30 +407,38 @@ const EscrowTokenPanel = ({ token, spenders, loadingSpenders, onChange }: Escrow
) : spenders.length > 0 ? ( spenders.map((spender) => ( - + )) ) : (
- No authorization yet + {isLegacy ? 'No authorizations' : 'No authorization yet'}
)} - s.spender)} - isOpen={isCreateOpen} - onClose={() => setIsCreateOpen(false)} - onSuccess={() => { - setIsCreateOpen(false); - onChange(); - }} - tokenAddress={token.address} - tokenSymbol={token.symbol} - /> + {!isLegacy && ( + s.spender)} + isOpen={isCreateOpen} + onClose={() => setIsCreateOpen(false)} + onSuccess={() => { + setIsCreateOpen(false); + onChange(); + }} + tokenAddress={token.address} + tokenSymbol={token.symbol} + /> + )} ); }; diff --git a/src/components/homepage/hero-section.module.css b/src/components/homepage/hero-section.module.css index dd506783..98841c23 100644 --- a/src/components/homepage/hero-section.module.css +++ b/src/components/homepage/hero-section.module.css @@ -69,6 +69,21 @@ color: #d54335; } +.legacyEscrowBanner { + /* The hero pads its top for the nav overlap (see hero-section.module.css); pull the banner up + through most of that padding so it sits just below the header. Breakpoints mirror the hero's. */ + margin-top: -80px; + margin-bottom: 48px; + + @media (min-width: 768px) { + margin-top: -90px; + } + + @media (min-width: 992px) { + margin-top: -120px; + } +} + @media (max-width: 1200px) { .title { font-size: 72px; diff --git a/src/components/homepage/hero-section.tsx b/src/components/homepage/hero-section.tsx index 0091cce6..05eee2c6 100644 --- a/src/components/homepage/hero-section.tsx +++ b/src/components/homepage/hero-section.tsx @@ -5,6 +5,7 @@ import { TransitionGroup } from 'react-transition-group'; import Button from '../button/button'; import Container from '../container/container'; import styles from './hero-section.module.css'; +import LegacyEscrowBanner from './legacy-escrow-banner'; const videoSrc = '/hero.mp4'; // const posterSrc = '/hero.jpg'; @@ -34,6 +35,7 @@ export default function HeroSection() { src={videoSrc} /> +

Global
diff --git a/src/components/homepage/legacy-escrow-banner.module.css b/src/components/homepage/legacy-escrow-banner.module.css new file mode 100644 index 00000000..e490c8f0 --- /dev/null +++ b/src/components/homepage/legacy-escrow-banner.module.css @@ -0,0 +1,27 @@ +.root { + padding: 16px; + text-align: left; + + .row { + display: flex; + align-items: flex-start; + gap: 8px; + + .icon { + flex-shrink: 0; + } + + .message { + flex: 1; + min-width: 240px; + font-size: 14px; + line-height: 1.5; + } + + .close { + position: relative; + top: -4px; + right: -4px; + } + } +} diff --git a/src/components/homepage/legacy-escrow-banner.tsx b/src/components/homepage/legacy-escrow-banner.tsx new file mode 100644 index 00000000..cc30d080 --- /dev/null +++ b/src/components/homepage/legacy-escrow-banner.tsx @@ -0,0 +1,72 @@ +import Button from '@/components/button/button'; +import Card from '@/components/card/card'; +import { LEGACY_ESCROW_ADDRESS } from '@/constants/escrow'; +import CloseIcon from '@mui/icons-material/Close'; +import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; +import { IconButton } from '@mui/material'; +import classNames from 'classnames'; +import { useEffect, useState } from 'react'; +import styles from './legacy-escrow-banner.module.css'; + +const DISMISSED_KEY = 'legacyEscrowBannerDismissed'; + +// Announces the escrow contract redeployment and points users with funds left in the old +// deployment to the legacy-contract withdraw flow on the escrow page. Renders only while a legacy +// address is configured, and stays hidden once dismissed. +const LegacyEscrowBanner: React.FC<{ + className?: string; + /** Whether to show/mention the link to the escrow page */ + escrowPageLink?: boolean; +}> = ({ className, escrowPageLink }) => { + // Start hidden and reveal after mount, so the server render matches the first client render and + // the dismissed flag is only read on the client. + const [visible, setVisible] = useState(false); + + useEffect(() => { + // localStorage can throw (Safari private mode, restricted iframes) — fall back to showing it. + try { + setVisible(localStorage.getItem(DISMISSED_KEY) !== 'true'); + } catch { + setVisible(true); + } + }, []); + + if (!LEGACY_ESCROW_ADDRESS || !visible) { + return null; + } + + const dismiss = () => { + try { + localStorage.setItem(DISMISSED_KEY, 'true'); + } catch { + // Persistence is best-effort; still hide the banner for this session. + } + setVisible(false); + }; + + return ( + +
+ + + A new version of the Ocean contracts was deployed. +
+ If you have funds in the old escrow contract, {escrowPageLink ? 'open the Manage escrow page,' : null} select + the legacy contract and withdraw your funds. +
+ + + +
+ {escrowPageLink ? ( +
+ +
+ ) : null} +
+ ); +}; + +export default LegacyEscrowBanner; diff --git a/src/constants/escrow.ts b/src/constants/escrow.ts new file mode 100644 index 00000000..73697d37 --- /dev/null +++ b/src/constants/escrow.ts @@ -0,0 +1,13 @@ +import { BASE_CHAIN_ID, CHAIN_ID } from '@/constants/chains'; +import { ethers } from 'ethers'; + +export type EscrowContractVersion = 'current' | 'legacy'; + +// The Escrow contract was redeployed on Base, so users may still hold funds in the previous +// deployment. When this address is set, the escrow page offers a contract selector to view and +// withdraw those funds. Legacy access is Base-only: there is no old deployment with user funds +// on Sepolia, so the variable is ignored there. +const legacyEscrowEnv = process.env.NEXT_PUBLIC_LEGACY_ESCROW_ADDRESS; + +export const LEGACY_ESCROW_ADDRESS: string | undefined = + CHAIN_ID === BASE_CHAIN_ID && legacyEscrowEnv && ethers.isAddress(legacyEscrowEnv) ? legacyEscrowEnv : undefined; diff --git a/src/lib/ocean-provider.ts b/src/lib/ocean-provider.ts index ec783f44..cfc1f36d 100644 --- a/src/lib/ocean-provider.ts +++ b/src/lib/ocean-provider.ts @@ -42,7 +42,12 @@ export class OceanProvider { return new BigNumber(number).multipliedBy(new BigNumber(10).pow(decimals)).toFixed(0); } - private async getEscrowContract(chainId: number) { + // `escrowAddress` overrides the deployment from address.json — used by the escrow page to read + // from and withdraw out of the legacy (pre-redeploy) contract. + private async getEscrowContract(chainId: number, escrowAddress?: string) { + if (escrowAddress) { + return new ethers.Contract(escrowAddress, Escrow.abi, this.provider); + } const config = await this.getConfigByChainId(chainId); if (!config.Escrow) { throw new Error('No escrow found for chainId'); @@ -158,12 +163,12 @@ export class OceanProvider { // Returns every authorization the payer has granted for a token, across all payees. // The Escrow contract treats a zero payee address as a wildcard. - async getAllAuthorizations(tokenAddress: string, payer: string): Promise { + async getAllAuthorizations(tokenAddress: string, payer: string, escrowAddress?: string): Promise { if (!ethers.isAddress(tokenAddress) || !ethers.isAddress(payer)) { console.error('Invalid address passed to getAllAuthorizations', { tokenAddress, payer }); return []; } - const escrow = await this.getEscrowContract(this.chainId); + const escrow = await this.getEscrowContract(this.chainId, escrowAddress); const tokenDecimals = await getTokenDecimals(tokenAddress); const authorizations = await escrow.getAuthorizations(tokenAddress, payer, ethers.ZeroAddress); if (!authorizations || authorizations.length === 0) { @@ -188,12 +193,16 @@ export class OceanProvider { return parseFloat(balanceString); } - async getUserFundsDetailed(tokenAddress: string, address: string): Promise<{ available: number; locked: number }> { + async getUserFundsDetailed( + tokenAddress: string, + address: string, + escrowAddress?: string + ): Promise<{ available: number; locked: number }> { if (!ethers.isAddress(tokenAddress) || !ethers.isAddress(address)) { console.error('Invalid address passed to getUserFundsDetailed', { tokenAddress, address }); return { available: 0, locked: 0 }; } - const escrow = await this.getEscrowContract(this.chainId); + const escrow = await this.getEscrowContract(this.chainId, escrowAddress); const funds = await escrow.getUserFunds(address, tokenAddress); const tokenDecimals = await getTokenDecimals(tokenAddress); return { @@ -202,12 +211,12 @@ export class OceanProvider { }; } - async getLocks(tokenAddress: string, payer: string, payee: string): Promise { + async getLocks(tokenAddress: string, payer: string, payee: string, escrowAddress?: string): Promise { if (!ethers.isAddress(tokenAddress) || !ethers.isAddress(payer) || !ethers.isAddress(payee)) { console.error('Invalid address passed to getLocks', { tokenAddress, payer, payee }); return []; } - const escrow = await this.getEscrowContract(this.chainId); + const escrow = await this.getEscrowContract(this.chainId, escrowAddress); const tokenDecimals = await getTokenDecimals(tokenAddress); const locks = await escrow.getLocks(tokenAddress, payer, payee); if (!locks || locks.length === 0) { @@ -271,12 +280,20 @@ export class OceanProvider { return deposit; } - async withdrawTokensEoa({ tokenAddresses, amounts }: { tokenAddresses: string[]; amounts: string[] }): Promise { + async withdrawTokensEoa({ + tokenAddresses, + amounts, + escrowAddress, + }: { + tokenAddresses: string[]; + amounts: string[]; + escrowAddress?: string; + }): Promise { if (!tokenAddresses.every((addr) => ethers.isAddress(addr))) { console.error('Invalid address passed to withdrawTokensEoa', { tokenAddresses }); throw new Error('Invalid address'); } - const escrow = await this.getEscrowContract(this.chainId); + const escrow = await this.getEscrowContract(this.chainId, escrowAddress); const signer = await this.provider.getSigner(); const escrowWithSigner = escrow.connect(signer) as ethers.Contract; const normalizedAmounts = await Promise.all( diff --git a/src/lib/use-escrow-data.ts b/src/lib/use-escrow-data.ts index 6d6990c9..cc556aff 100644 --- a/src/lib/use-escrow-data.ts +++ b/src/lib/use-escrow-data.ts @@ -4,7 +4,7 @@ import { getSupportedTokens } from '@/constants/tokens'; import { Authorizations, EscrowLock } from '@/types/payment'; import { Node } from '@/types/nodes'; import axios from 'axios'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; export type EscrowTokenInfo = { @@ -76,17 +76,30 @@ export type UseEscrowDataReturn = { reload: () => void; }; -export const useEscrowData = (): UseEscrowDataReturn => { +// `escrowAddress` points the reads at a different escrow deployment than the one in address.json +// (the legacy contract). Omit it for the current deployment. +export const useEscrowData = (escrowAddress?: string): UseEscrowDataReturn => { const { account, ocean } = useOceanAccount(); const [tokens, setTokens] = useState([]); const [spenders, setSpenders] = useState([]); const [loading, setLoading] = useState(false); + // Switching contracts can leave a previous load in flight; only the latest load may set state, + // so data from one contract never lands while another is selected. + const loadIdRef = useRef(0); + // load() reads the selected contract from a ref rather than closing over it, so a reload captured + // before a contract switch (e.g. by an in-flight transaction's onSuccess) refreshes the contract + // selected now instead of resurrecting the old one's data. + const escrowAddressRef = useRef(escrowAddress); const load = useCallback(async () => { + const loadId = ++loadIdRef.current; + const isCurrent = () => loadIdRef.current === loadId; + const contractAddress = escrowAddressRef.current; if (!ocean || !account?.address) { setTokens([]); setSpenders([]); + setLoading(false); return; } setLoading(true); @@ -96,7 +109,7 @@ export const useEscrowData = (): UseEscrowDataReturn => { const tokenInfos = await Promise.all( tokenList.map(async (token) => { - const funds = await ocean.getUserFundsDetailed(token.address, account.address!); + const funds = await ocean.getUserFundsDetailed(token.address, account.address!, contractAddress); const walletBalance = Number(await ocean.getBalance(token.address, account.address!)); return { symbol: token.symbol, @@ -107,6 +120,7 @@ export const useEscrowData = (): UseEscrowDataReturn => { }; }) ); + if (!isCurrent()) return; setTokens(tokenInfos); // List spenders (payees) straight from the Escrow contract: `getAllAuthorizations` queries @@ -114,13 +128,13 @@ export const useEscrowData = (): UseEscrowDataReturn => { // event indexing needed, so this works on any chain (including sepolia). const settled = await Promise.allSettled( tokenList.map(async (token) => { - const allAuthorizations = await ocean.getAllAuthorizations(token.address, account.address!); + const allAuthorizations = await ocean.getAllAuthorizations(token.address, account.address!, contractAddress); return Promise.all( allAuthorizations.map(async (authorizations) => { const spender = authorizations.payee; // Contract getLocks filters payer/token with OR, so it leaks other payers' locks and // other tokens. Narrow to this user's locks for this token client-side. - const allLocks = await ocean.getLocks(token.address, account.address!, spender); + const allLocks = await ocean.getLocks(token.address, account.address!, spender, contractAddress); const locks = allLocks.filter( (lock) => lock.payer.toLowerCase() === account.address!.toLowerCase() && @@ -137,6 +151,7 @@ export const useEscrowData = (): UseEscrowDataReturn => { ); }) ); + if (!isCurrent()) return; const failCount = settled.filter((r) => r.status === 'rejected').length; if (failCount > 0) { toast.warning(`Failed to load authorization data for ${failCount} token${failCount > 1 ? 's' : ''}`); @@ -153,6 +168,7 @@ export const useEscrowData = (): UseEscrowDataReturn => { // results back onto every matching spender. const uniqueWallets = [...new Set(spenderInfos.map((info) => info.spender))]; const nodeByWallet = await fetchNodesByWallets(uniqueWallets); + if (!isCurrent()) return; setSpenders((current) => current.map((info) => { const node = nodeByWallet.get(info.spender.toLowerCase()); @@ -162,13 +178,20 @@ export const useEscrowData = (): UseEscrowDataReturn => { } catch (err) { console.error('Failed to load escrow data:', err); } finally { - setLoading(false); + if (isCurrent()) { + setLoading(false); + } } }, [ocean, account?.address]); + // On contract switch (and initial mount): clear the previous contract's data so a loader shows + // instead of the other contract's balances, then fetch from the newly selected contract. useEffect(() => { + escrowAddressRef.current = escrowAddress; + setTokens([]); + setSpenders([]); load(); - }, [load]); + }, [escrowAddress, load]); return { tokens, spenders, loading, reload: load }; }; diff --git a/src/lib/use-withdraw-tokens.ts b/src/lib/use-withdraw-tokens.ts index 34a5bd2a..23da4b4d 100644 --- a/src/lib/use-withdraw-tokens.ts +++ b/src/lib/use-withdraw-tokens.ts @@ -12,6 +12,8 @@ import { encodeFunctionData } from 'viem'; export interface WithdrawTokensParams { tokenAddresses: string[]; amounts: string[]; + // Withdraw from this escrow deployment instead of the one in address.json (legacy contract). + escrowAddress?: string; } export interface UseWithdrawTokensParams { @@ -34,12 +36,12 @@ export const useWithdrawTokens = ({ onSuccess }: UseWithdrawTokensParams = {}): const chainId = CHAIN_ID; const handleWithdraw = useCallback( - async ({ tokenAddresses, amounts }: WithdrawTokensParams) => { + async ({ tokenAddresses, amounts, escrowAddress }: WithdrawTokensParams) => { if (user?.type === 'eoa') { try { setIsWithdrawing(true); if (!ocean) return; - const tx = await ocean.withdrawTokensEoa({ tokenAddresses, amounts }); + const tx = await ocean.withdrawTokensEoa({ tokenAddresses, amounts, escrowAddress }); await tx.wait(); setIsWithdrawing(false); setError(undefined); @@ -62,7 +64,8 @@ export const useWithdrawTokens = ({ onSuccess }: UseWithdrawTokensParams = {}): setError(undefined); const config = Object.values(Address).find((chainConfig: any) => chainConfig.chainId === chainId); - if (!config || !(config as any).Escrow) { + const escrowTarget = escrowAddress ?? (config as any)?.Escrow; + if (!escrowTarget) { throw new Error('No escrow found for chainId'); } @@ -81,7 +84,7 @@ export const useWithdrawTokens = ({ onSuccess }: UseWithdrawTokensParams = {}): }); await sendTransaction({ - to: (config as any).Escrow as `0x${string}`, + to: escrowTarget as `0x${string}`, data: data as `0x${string}`, });