Skip to content

Commit 6effffa

Browse files
authored
feat: CSV export (#871)
* feat: CSV export Adds CSV export Fixes issue with kv namespace bindings Added rate limiters * Add Durable objects to individual envs
1 parent 02e7cb5 commit 6effffa

11 files changed

Lines changed: 1485 additions & 809 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import type { Address } from 'ox'
2+
import * as React from 'react'
3+
4+
import { cx } from '#lib/css'
5+
import { getApiUrl } from '#lib/env.ts'
6+
import type { HistorySources } from '#lib/queries/account'
7+
import DownloadIcon from '~icons/lucide/download'
8+
9+
function getDownloadFilename(
10+
contentDisposition: string | null,
11+
fallback: string,
12+
): string {
13+
if (!contentDisposition) return fallback
14+
15+
const match = /filename="([^"]+)"/.exec(contentDisposition)
16+
return match?.[1] ?? fallback
17+
}
18+
19+
export function AddressCsvExportButton(
20+
props: AddressCsvExportButton.Props,
21+
): React.JSX.Element {
22+
const { address, kind } = props
23+
const status = kind === 'transactions' ? props.status : undefined
24+
const include = kind === 'transactions' ? props.include : 'all'
25+
const after = kind === 'transactions' ? props.after : undefined
26+
const sources = kind === 'transactions' ? props.sources : []
27+
const [isExporting, setIsExporting] = React.useState(false)
28+
const [error, setError] = React.useState<string | null>(null)
29+
30+
const handleExport = React.useCallback(async () => {
31+
setIsExporting(true)
32+
setError(null)
33+
34+
try {
35+
const searchParams = new URLSearchParams({ format: 'csv' })
36+
searchParams.set('sort', 'desc')
37+
38+
let url: URL
39+
let fallbackFilename: string
40+
41+
if (kind === 'balances') {
42+
url = getApiUrl(`/api/address/balances/${address}`)
43+
fallbackFilename = `balances-${address.toLowerCase()}.csv`
44+
} else {
45+
url = getApiUrl(`/api/address/history/${address}`)
46+
fallbackFilename = `transactions-${address.toLowerCase()}.csv`
47+
48+
if (status) searchParams.set('status', status)
49+
if (include !== 'all') searchParams.set('include', include)
50+
if (after) searchParams.set('after', String(after))
51+
if (sources.length > 0) {
52+
searchParams.set('sources', sources.join(','))
53+
}
54+
}
55+
56+
url.search = searchParams.toString()
57+
58+
const response = await fetch(url, {
59+
headers: { Accept: 'text/csv' },
60+
})
61+
62+
if (!response.ok) {
63+
let message =
64+
kind === 'balances'
65+
? 'Failed to export balances.'
66+
: 'Failed to export transactions.'
67+
68+
const contentType = response.headers.get('Content-Type') ?? ''
69+
if (contentType.includes('application/json')) {
70+
const payload = (await response.json()) as { error?: string }
71+
if (payload.error) message = payload.error
72+
} else {
73+
const text = (await response.text()).trim()
74+
if (text) message = text
75+
}
76+
77+
throw new Error(message)
78+
}
79+
80+
const blob = await response.blob()
81+
const objectUrl = URL.createObjectURL(blob)
82+
83+
try {
84+
const anchor = document.createElement('a')
85+
anchor.href = objectUrl
86+
anchor.download = getDownloadFilename(
87+
response.headers.get('Content-Disposition'),
88+
fallbackFilename,
89+
)
90+
document.body.appendChild(anchor)
91+
anchor.click()
92+
document.body.removeChild(anchor)
93+
} finally {
94+
URL.revokeObjectURL(objectUrl)
95+
}
96+
} catch (error) {
97+
setError(error instanceof Error ? error.message : 'CSV export failed.')
98+
} finally {
99+
setIsExporting(false)
100+
}
101+
}, [address, after, include, kind, sources, status])
102+
103+
return (
104+
<div className="flex flex-col gap-[4px] min-[800px]:items-end">
105+
<button
106+
type="button"
107+
onClick={handleExport}
108+
disabled={isExporting}
109+
aria-label={
110+
kind === 'balances'
111+
? 'Export balances as CSV'
112+
: 'Export transactions as CSV'
113+
}
114+
className={cx(
115+
'flex size-[28px] items-center justify-center rounded-[6px] border text-[12px] transition-colors',
116+
'border-transparent text-tertiary hover:text-secondary hover:bg-base-alt',
117+
isExporting && 'cursor-default opacity-60',
118+
!isExporting && 'cursor-pointer',
119+
)}
120+
title={
121+
kind === 'balances'
122+
? 'Export balances as CSV'
123+
: 'Export transactions as CSV'
124+
}
125+
>
126+
<DownloadIcon className="size-[14px]" />
127+
</button>
128+
{error && <span className="text-[11px] text-red-400">{error}</span>}
129+
</div>
130+
)
131+
}
132+
133+
export declare namespace AddressCsvExportButton {
134+
type Props =
135+
| {
136+
address: Address.Address
137+
kind: 'balances'
138+
}
139+
| {
140+
address: Address.Address
141+
kind: 'transactions'
142+
status?: 'success' | 'reverted' | undefined
143+
include: 'all' | 'sent' | 'received'
144+
after?: number | undefined
145+
sources: ReadonlyArray<HistorySources>
146+
}
147+
}

apps/explorer/src/index.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import * as Sentry from '@sentry/cloudflare'
22
import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
3+
import { ExplorerExportRateLimit } from '#lib/server/export-rate-limit'
4+
5+
export { ExplorerExportRateLimit }
36

47
export const redirects: Array<{
58
from: RegExp
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import type { Address } from 'ox'
3+
import * as React from 'react'
4+
import { formatUnits } from 'viem'
5+
6+
import { getApiUrl } from '#lib/env.ts'
7+
8+
export type TokenBalance = {
9+
token: Address.Address
10+
balance: string
11+
name?: string
12+
symbol?: string
13+
decimals?: number
14+
currency?: string
15+
}
16+
17+
export type BalancesResponse = {
18+
balances: TokenBalance[]
19+
error?: string
20+
}
21+
22+
export type AssetData = {
23+
address: Address.Address
24+
metadata:
25+
| { name?: string; symbol?: string; decimals?: number; currency?: string }
26+
| undefined
27+
balance: bigint | undefined
28+
}
29+
30+
async function fetchAddressBalances(
31+
address: Address.Address,
32+
): Promise<BalancesResponse> {
33+
const response = await fetch(getApiUrl(`/api/address/balances/${address}`), {
34+
headers: { 'Content-Type': 'application/json' },
35+
})
36+
return response.json() as Promise<BalancesResponse>
37+
}
38+
39+
export function balancesQueryOptions(address: Address.Address) {
40+
return {
41+
queryKey: ['address-balances', address],
42+
queryFn: () => fetchAddressBalances(address),
43+
staleTime: 60_000,
44+
}
45+
}
46+
47+
export function useBalancesData(
48+
accountAddress: Address.Address,
49+
initialData?: BalancesResponse,
50+
enabled = true,
51+
): {
52+
data: AssetData[]
53+
isLoading: boolean
54+
} {
55+
const { data, isLoading } = useQuery({
56+
...balancesQueryOptions(accountAddress),
57+
initialData,
58+
enabled,
59+
})
60+
61+
const assetsData = React.useMemo(() => {
62+
if (!data?.balances) return []
63+
return data.balances.map((token) => ({
64+
address: token.token,
65+
metadata: {
66+
name: token.name,
67+
symbol: token.symbol,
68+
decimals: token.decimals,
69+
currency: token.currency,
70+
},
71+
balance: BigInt(token.balance),
72+
}))
73+
}, [data])
74+
75+
return { data: assetsData, isLoading }
76+
}
77+
78+
export function calculateTotalHoldings(
79+
assetsData: ReadonlyArray<AssetData>,
80+
options?: {
81+
isTokenListed?: ((address: Address.Address) => boolean) | undefined
82+
},
83+
): number | undefined {
84+
const PRICE_PER_TOKEN = 1
85+
let total: number | undefined
86+
for (const asset of assetsData) {
87+
if (asset.metadata?.currency !== 'USD') continue
88+
if (options?.isTokenListed && !options.isTokenListed(asset.address)) {
89+
continue
90+
}
91+
const decimals = asset.metadata?.decimals
92+
const balance = asset.balance
93+
if (decimals === undefined || balance === undefined) continue
94+
total =
95+
(total ?? 0) + Number(formatUnits(balance, decimals)) * PRICE_PER_TOKEN
96+
}
97+
return total
98+
}

0 commit comments

Comments
 (0)