diff --git a/apps/explorer/src/routeTree.gen.ts b/apps/explorer/src/routeTree.gen.ts index 07f5b75d0..9df2be9b9 100644 --- a/apps/explorer/src/routeTree.gen.ts +++ b/apps/explorer/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ApiAbiBatchRouteImport } from './routes/api/abi/batch' import { Route as LayoutTxHashRouteImport } from './routes/_layout/tx/$hash' import { Route as LayoutTokenAddressRouteImport } from './routes/_layout/token/$address' import { Route as LayoutReceiptHashRouteImport } from './routes/_layout/receipt/$hash' +import { Route as LayoutReceiptVoucherRouteImport } from './routes/_layout/receipt/voucher' import { Route as LayoutDemoTxRouteImport } from './routes/_layout/demo/tx' import { Route as LayoutDemoPaginationRouteImport } from './routes/_layout/demo/pagination' import { Route as LayoutDemoEmptyStateRouteImport } from './routes/_layout/demo/empty-state' @@ -126,6 +127,11 @@ const LayoutReceiptHashRoute = LayoutReceiptHashRouteImport.update({ path: '/receipt/$hash', getParentRoute: () => LayoutRoute, } as any) +const LayoutReceiptVoucherRoute = LayoutReceiptVoucherRouteImport.update({ + id: '/receipt/voucher', + path: '/receipt/voucher', + getParentRoute: () => LayoutRoute, +} as any) const LayoutDemoTxRoute = LayoutDemoTxRouteImport.update({ id: '/demo/tx', path: '/demo/tx', @@ -226,6 +232,7 @@ export interface FileRoutesByFullPath { '/demo/pagination': typeof LayoutDemoPaginationRoute '/demo/tx': typeof LayoutDemoTxRoute '/receipt/$hash': typeof LayoutReceiptHashRoute + '/receipt/voucher': typeof LayoutReceiptVoucherRoute '/token/$address': typeof LayoutTokenAddressRoute '/tx/$hash': typeof LayoutTxHashRoute '/api/abi/batch': typeof ApiAbiBatchRoute @@ -259,6 +266,7 @@ export interface FileRoutesByTo { '/demo/pagination': typeof LayoutDemoPaginationRoute '/demo/tx': typeof LayoutDemoTxRoute '/receipt/$hash': typeof LayoutReceiptHashRoute + '/receipt/voucher': typeof LayoutReceiptVoucherRoute '/token/$address': typeof LayoutTokenAddressRoute '/tx/$hash': typeof LayoutTxHashRoute '/api/abi/batch': typeof ApiAbiBatchRoute @@ -294,6 +302,7 @@ export interface FileRoutesById { '/_layout/demo/pagination': typeof LayoutDemoPaginationRoute '/_layout/demo/tx': typeof LayoutDemoTxRoute '/_layout/receipt/$hash': typeof LayoutReceiptHashRoute + '/_layout/receipt/voucher': typeof LayoutReceiptVoucherRoute '/_layout/token/$address': typeof LayoutTokenAddressRoute '/_layout/tx/$hash': typeof LayoutTxHashRoute '/api/abi/batch': typeof ApiAbiBatchRoute @@ -329,6 +338,7 @@ export interface FileRouteTypes { | '/demo/pagination' | '/demo/tx' | '/receipt/$hash' + | '/receipt/voucher' | '/token/$address' | '/tx/$hash' | '/api/abi/batch' @@ -362,6 +372,7 @@ export interface FileRouteTypes { | '/demo/pagination' | '/demo/tx' | '/receipt/$hash' + | '/receipt/voucher' | '/token/$address' | '/tx/$hash' | '/api/abi/batch' @@ -396,6 +407,7 @@ export interface FileRouteTypes { | '/_layout/demo/pagination' | '/_layout/demo/tx' | '/_layout/receipt/$hash' + | '/_layout/receipt/voucher' | '/_layout/token/$address' | '/_layout/tx/$hash' | '/api/abi/batch' @@ -554,6 +566,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutReceiptHashRouteImport parentRoute: typeof LayoutRoute } + '/_layout/receipt/voucher': { + id: '/_layout/receipt/voucher' + path: '/receipt/voucher' + fullPath: '/receipt/voucher' + preLoaderRoute: typeof LayoutReceiptVoucherRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/demo/tx': { id: '/_layout/demo/tx' path: '/demo/tx' @@ -674,6 +693,7 @@ interface LayoutRouteChildren { LayoutDemoPaginationRoute: typeof LayoutDemoPaginationRoute LayoutDemoTxRoute: typeof LayoutDemoTxRoute LayoutReceiptHashRoute: typeof LayoutReceiptHashRoute + LayoutReceiptVoucherRoute: typeof LayoutReceiptVoucherRoute LayoutTokenAddressRoute: typeof LayoutTokenAddressRoute LayoutTxHashRoute: typeof LayoutTxHashRoute LayoutDemoIndexRoute: typeof LayoutDemoIndexRoute @@ -692,6 +712,7 @@ const LayoutRouteChildren: LayoutRouteChildren = { LayoutDemoPaginationRoute: LayoutDemoPaginationRoute, LayoutDemoTxRoute: LayoutDemoTxRoute, LayoutReceiptHashRoute: LayoutReceiptHashRoute, + LayoutReceiptVoucherRoute: LayoutReceiptVoucherRoute, LayoutTokenAddressRoute: LayoutTokenAddressRoute, LayoutTxHashRoute: LayoutTxHashRoute, LayoutDemoIndexRoute: LayoutDemoIndexRoute, diff --git a/apps/explorer/src/routes/_layout/receipt/voucher.tsx b/apps/explorer/src/routes/_layout/receipt/voucher.tsx new file mode 100644 index 000000000..32fbb11ae --- /dev/null +++ b/apps/explorer/src/routes/_layout/receipt/voucher.tsx @@ -0,0 +1,416 @@ +import { Link } from '@tanstack/react-router' +import { createFileRoute, notFound, rootRouteId } from '@tanstack/react-router' +import type { Address as OxAddress, Hex } from 'ox' +import * as Address from 'ox/Address' +import * as HexUtils from 'ox/Hex' +import * as Value from 'ox/Value' +import { useState } from 'react' +import { recoverTypedDataAddress } from 'viem' +import { readContract } from 'viem/actions' +import { getPublicClient } from 'wagmi/actions' +import { Actions } from 'wagmi/tempo' +import type { Config } from 'wagmi' +import * as z from 'zod/mini' +import { Midcut } from 'midcut' +import { ReceiptMark } from '#comps/ReceiptMark' +import { CopyButton } from '#comps/CopyButton' +import { cx } from '#lib/css' +import { DateFormatter, PriceFormatter } from '#lib/formatting' +import { useCopy } from '#lib/hooks' +import { STREAM_CHANNEL } from '#lib/domain/known-events' +import { isTip20Address } from '#lib/domain/tip20' +import { withLoaderTiming } from '#lib/profiling' +import { getTempoChain, getWagmiConfig } from '#wagmi.config.ts' + +const escrowAbi = [ + { + type: 'function', + name: 'getChannel', + inputs: [{ name: 'channelId', type: 'bytes32' }], + outputs: [ + { + name: '', + type: 'tuple', + components: [ + { name: 'finalized', type: 'bool' }, + { name: 'closeRequestedAt', type: 'uint64' }, + { name: 'payer', type: 'address' }, + { name: 'payee', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'authorizedSigner', type: 'address' }, + { name: 'deposit', type: 'uint128' }, + { name: 'settled', type: 'uint128' }, + ], + }, + ], + stateMutability: 'view', + }, +] as const + +type ChannelState = { + finalized: boolean + closeRequestedAt: bigint + payer: OxAddress.Address + payee: OxAddress.Address + token: OxAddress.Address + authorizedSigner: OxAddress.Address + deposit: bigint + settled: bigint +} + +const UINT128_MAX = (1n << 128n) - 1n + +const voucherTypes = { + Voucher: [ + { name: 'channelId', type: 'bytes32' }, + { name: 'cumulativeAmount', type: 'uint128' }, + ], +} as const + +type VoucherReceiptData = { + channelId: Hex.Hex + cumulativeAmount: bigint + signature: Hex.Hex + channel: ChannelState + verified: boolean + closeRequestedFormatted: string | undefined + tokenMetadata: { symbol: string; decimals: number } | undefined +} + +async function fetchVoucherData(params: { + channelId: Hex.Hex + cumulativeAmount: string + signature: Hex.Hex +}): Promise { + const config = getWagmiConfig() + const client = getPublicClient(config) + if (!client) throw new Error('RPC client unavailable') + + if (!/^\d+$/.test(params.cumulativeAmount)) + throw new Error('Invalid cumulativeAmount') + const cumulativeAmount = BigInt(params.cumulativeAmount) + if (cumulativeAmount > UINT128_MAX) + throw new Error('cumulativeAmount exceeds uint128') + + const channel = (await readContract(client, { + address: STREAM_CHANNEL as OxAddress.Address, + abi: escrowAbi, + functionName: 'getChannel', + args: [params.channelId], + })) as ChannelState + + if (channel.payer === '0x0000000000000000000000000000000000000000') + throw new Error('Channel not found') + + let verified = false + try { + const chain = getTempoChain() + const signer = await recoverTypedDataAddress({ + domain: { + name: 'Tempo Stream Channel', + version: '1', + chainId: chain.id, + verifyingContract: STREAM_CHANNEL as OxAddress.Address, + }, + types: voucherTypes, + primaryType: 'Voucher', + message: { + channelId: params.channelId, + cumulativeAmount, + }, + signature: params.signature, + }) + verified = Address.isEqual(signer, channel.authorizedSigner) + } catch {} + + const closeRequestedFormatted = + channel.closeRequestedAt > 0n + ? DateFormatter.format(channel.closeRequestedAt) + : undefined + + let tokenMetadata: { symbol: string; decimals: number } | undefined + if (isTip20Address(channel.token)) { + try { + const meta = await Actions.token.getMetadata(config as Config, { + token: channel.token, + }) + tokenMetadata = { symbol: meta.symbol, decimals: meta.decimals } + } catch {} + } + + return { + channelId: params.channelId, + cumulativeAmount, + signature: params.signature, + channel, + verified, + closeRequestedFormatted, + tokenMetadata, + } +} + +export const Route = createFileRoute('/_layout/receipt/voucher')({ + component: Component, + validateSearch: z.object({ + channelId: z.string(), + cumulativeAmount: z.string(), + signature: z.string(), + }), + loader: ({ location }) => + withLoaderTiming('/_layout/receipt/voucher', async () => { + const search = location.search as { + channelId: string + cumulativeAmount: string + signature: string + } + + if ( + !HexUtils.validate(search.channelId as Hex.Hex) || + HexUtils.size(search.channelId as Hex.Hex) !== 32 || + !HexUtils.validate(search.signature as Hex.Hex) || + !/^\d+$/.test(search.cumulativeAmount) + ) + throw notFound({ + routeId: rootRouteId, + data: { type: 'voucher', value: search.channelId }, + }) + + try { + return await fetchVoucherData({ + channelId: search.channelId as Hex.Hex, + cumulativeAmount: search.cumulativeAmount, + signature: search.signature as Hex.Hex, + }) + } catch (error) { + console.error(error) + throw notFound({ + routeId: rootRouteId, + data: { type: 'voucher', value: search.channelId }, + }) + } + }), + head: ({ loaderData }) => { + const channelId = loaderData?.channelId ?? '' + const short = channelId + ? `${channelId.slice(0, 10)}…${channelId.slice(-6)}` + : 'Unknown' + const title = `MPP Voucher ${short} ⋅ Tempo Explorer` + + return { + title, + meta: [ + { title }, + { name: 'robots', content: 'noindex,nofollow,noarchive' }, + { property: 'og:title', content: title }, + { + property: 'og:description', + content: 'View offchain payment channel state on Tempo Explorer.', + }, + ], + } + }, +}) + +function getChannelStatus(channel: ChannelState): { + label: string + color: string +} { + if (channel.finalized) + return { label: 'Closed', color: 'text-base-content-negative' } + if (channel.closeRequestedAt > 0n) + return { label: 'Closing', color: 'text-warning' } + return { label: 'Open', color: 'text-positive' } +} + +function Component(): React.JSX.Element { + const data = Route.useLoaderData() as VoucherReceiptData + const { + channelId, + cumulativeAmount, + signature, + channel, + verified, + closeRequestedFormatted, + tokenMetadata, + } = data + + const [channelIdExpanded, setChannelIdExpanded] = useState(false) + const copyChannelId = useCopy() + + const decimals = tokenMetadata?.decimals ?? 6 + const symbol = tokenMetadata?.symbol + + const depositFormatted = Value.format(channel.deposit, decimals) + const settledFormatted = Value.format(channel.settled, decimals) + const cumulativeFormatted = Value.format(cumulativeAmount, decimals) + const unsettled = + cumulativeAmount > channel.settled ? cumulativeAmount - channel.settled : 0n + const unsettledFormatted = Value.format(unsettled, decimals) + const remaining = + channel.deposit > cumulativeAmount ? channel.deposit - cumulativeAmount : 0n + const remainingFormatted = Value.format(remaining, decimals) + + const status = getChannelStatus(channel) + + const formatAmount = (raw: string) => + symbol + ? `${PriceFormatter.formatAmountShort(raw)} ${symbol}` + : PriceFormatter.formatAmountShort(raw) + + return ( +
+
+ {!verified && ( +
+
+ Unverified signature +
+
+ )} + + {/* Header */} +
+
+ +
+
+
+ Status + + {status.label} + +
+
+
+ Channel + {copyChannelId.notifying && ( + + copied + + )} +
+ {channelIdExpanded ? ( + + ) : ( + + )} +
+
+ Payer + + + +
+
+ Payee + + + +
+ {symbol && ( +
+ Token + + {symbol} + +
+ )} +
+
+ + {/* On-chain channel state */} +
+
+
+ On-chain +
+
+ Deposit + {formatAmount(depositFormatted)} +
+
+ Settled + {formatAmount(settledFormatted)} +
+ {closeRequestedFormatted && ( +
+ Close requested + {closeRequestedFormatted} +
+ )} +
+ + {/* Offchain voucher state */} +
+
+
+ Off-chain (voucher) +
+
+ Cumulative + {formatAmount(cumulativeFormatted)} +
+
+ Unsettled + + {formatAmount(unsettledFormatted)} + +
+
+ + {/* Totals */} +
+
+
+ Remaining + {formatAmount(remainingFormatted)} +
+
+
+ + {/* Signature footer */} +
+
+
+ + Sig: {signature.slice(0, 10)}…{signature.slice(-8)} + + +
+
+
+
+ ) +}