diff --git a/package.json b/package.json index 0899626..0af86a9 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.5.0", "@vitest/coverage-v8": "^4.1.9", + "ajv": "^8.18.0", "autoprefixer": "^10.4.20", "concurrently": "^9.2.1", "http-server": "^14.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index add3469..d47c70b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.9 version: 4.1.10(vitest@4.1.10) + ajv: + specifier: ^8.18.0 + version: 8.18.0 autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.9) diff --git a/src/pages/Activity.tsx b/src/pages/Activity.tsx index b0fd30e..f0fd7b8 100644 --- a/src/pages/Activity.tsx +++ b/src/pages/Activity.tsx @@ -1,38 +1,40 @@ -import { useState, useMemo } from 'react'; +import { useMemo, useState } from 'react'; +import { ActivityRow } from '@/components/ActivityRow'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { useActivityStore, - ActivityKind, - ActivityStatus, - ActivityEntry, + type ActivityEntry, + type ActivityKind, + type ActivityStatus, } from '@/stores/activityStore'; -import { ActivityRow } from '@/components/ActivityRow'; +import { downloadActivityCsv, downloadActivityJson } from '@/utils/activityExport'; + +type ActivityChain = 'horizen' | 'stellar' | 'solana' | 'ckb'; export default function Activity() { const { address } = useStellarWallet(); const { entries, clearHistory } = useActivityStore(); - - const [filterChain, setFilterChain] = useState<'all' | 'horizen' | 'stellar' | 'solana' | 'ckb'>( - 'all', - ); + const [filterChain, setFilterChain] = useState('all'); const [filterKind, setFilterKind] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const walletEntries = useMemo(() => { if (!address) return []; - return entries.filter((e: ActivityEntry) => e.wallet === address); + return entries.filter((entry: ActivityEntry) => entry.wallet === address); }, [entries, address]); - const filteredEntries = useMemo(() => { - return walletEntries - .filter((e: ActivityEntry) => { - if (filterChain !== 'all' && e.chain !== filterChain) return false; - if (filterKind !== 'all' && e.kind !== filterKind) return false; - if (filterStatus !== 'all' && e.status !== filterStatus) return false; - return true; - }) - .sort((a: ActivityEntry, b: ActivityEntry) => b.timestamp - a.timestamp); - }, [walletEntries, filterChain, filterKind, filterStatus]); + const filteredEntries = useMemo( + () => + walletEntries + .filter((entry: ActivityEntry) => { + if (filterChain !== 'all' && entry.chain !== filterChain) return false; + if (filterKind !== 'all' && entry.kind !== filterKind) return false; + if (filterStatus !== 'all' && entry.status !== filterStatus) return false; + return true; + }) + .sort((a: ActivityEntry, b: ActivityEntry) => b.timestamp - a.timestamp), + [walletEntries, filterChain, filterKind, filterStatus], + ); if (!address) { return ( @@ -41,7 +43,7 @@ export default function Activity() { Activity

- Connect your wallet to view your transaction history. + Connect your wallet to view and export your transaction history.

); @@ -59,6 +61,7 @@ export default function Activity() { -
-
- - +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + {filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'} +
-
- - -
-
- - + Export JSON + +

+ Exports include only the entries matching the current chain, type, and status filters. +

@@ -127,8 +172,8 @@ export default function Activity() { )}
- {filteredEntries.map((tx: ActivityEntry) => ( - + {filteredEntries.map((entry: ActivityEntry) => ( + ))}
diff --git a/src/schema/activity.json b/src/schema/activity.json new file mode 100644 index 0000000..d1462e1 --- /dev/null +++ b/src/schema/activity.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wraith.finance/schema/activity.json", + "title": "Wraith activity export", + "description": "A filtered list of client-side Wraith activity entries.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "chain", "wallet", "kind", "direction", "status", "timestamp"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "chain": { + "type": "string", + "minLength": 1 + }, + "wallet": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["stealth-send", "stealth-receive", "withdrawal", "name-registration"] + }, + "direction": { + "type": "string", + "enum": ["in", "out"] + }, + "status": { + "type": "string", + "enum": ["pending", "confirmed", "failed"] + }, + "amount": { + "type": "string" + }, + "token": { + "type": "string" + }, + "recipient": { + "type": "string" + }, + "metadata": true, + "timestamp": { + "type": "integer", + "minimum": 0 + } + } + } +} diff --git a/src/utils/activityExport.test.ts b/src/utils/activityExport.test.ts new file mode 100644 index 0000000..ebd29b4 --- /dev/null +++ b/src/utils/activityExport.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs'; +import Ajv2020 from 'ajv/dist/2020.js'; +import { describe, expect, it } from 'vitest'; +import type { ActivityEntry } from '@/stores/activityStore'; +import { activityToCsv, activityToJson, sanitizeCsvMemo } from '@/utils/activityExport'; + +const baseEntry: ActivityEntry = { + id: 'abc123', + chain: 'stellar', + wallet: 'GWALLET', + kind: 'stealth-send', + direction: 'out', + status: 'confirmed', + amount: '25.50', + recipient: 'GRECIPIENT', + timestamp: Date.UTC(2026, 6, 26, 12, 30), +}; + +describe('activity CSV export', () => { + it.each(['=1+1', '+SUM(A1:A2)', '-2+3', '@SUM(A1:A2)'])( + 'neutralizes formula-like memo %s', + (memo) => { + expect(sanitizeCsvMemo(memo)).toBe(`'${memo}`); + + const csv = activityToCsv([{ ...baseEntry, metadata: { memo } }]); + expect(csv.split('\r\n')[1]).toContain(`,'${memo}`); + }, + ); + + it('escapes commas, quotes, and newlines using RFC 4180 quoting', () => { + const csv = activityToCsv([{ ...baseEntry, metadata: { memo: 'invoice "July", phase\n2' } }]); + + expect(csv).toContain('"invoice ""July"", phase\n2"'); + }); +}); + +describe('activity JSON export', () => { + it('validates against the shipped activity schema', () => { + const schema = JSON.parse( + readFileSync(new URL('../schema/activity.json', import.meta.url), 'utf8'), + ); + const validate = new Ajv2020({ strict: true }).compile(schema); + const exported = JSON.parse( + activityToJson([{ ...baseEntry, metadata: { memo: 'Invoice 42' } }]), + ); + + expect(validate(exported), validate.errors?.map((error) => error.message).join(', ')).toBe( + true, + ); + }); +}); diff --git a/src/utils/activityExport.ts b/src/utils/activityExport.ts new file mode 100644 index 0000000..b09de52 --- /dev/null +++ b/src/utils/activityExport.ts @@ -0,0 +1,104 @@ +import type { ActivityEntry } from '@/stores/activityStore'; + +export const ACTIVITY_CSV_HEADERS = [ + 'timestamp', + 'id', + 'chain', + 'wallet', + 'kind', + 'direction', + 'status', + 'amount', + 'token', + 'recipient', + 'memo', +] as const; + +function activityMemo(entry: ActivityEntry): string { + if ( + entry.metadata && + typeof entry.metadata === 'object' && + typeof entry.metadata.memo === 'string' + ) { + return entry.metadata.memo; + } + + return ''; +} + +/** + * Prevent spreadsheet applications from interpreting a memo as a formula. + * Leading whitespace is included so a memo cannot bypass the prefix check. + */ +export function sanitizeCsvMemo(memo: string): string { + return /^[\t\r ]*[=+\-@]/.test(memo) ? `'${memo}` : memo; +} + +function escapeCsvCell(value: string | number): string { + const text = String(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function activityCsvRow(entry: ActivityEntry): string[] { + return [ + new Date(entry.timestamp).toISOString(), + entry.id, + entry.chain, + entry.wallet, + entry.kind, + entry.direction, + entry.status, + entry.amount ?? '', + entry.token ?? (entry.chain === 'stellar' ? 'XLM' : ''), + entry.recipient ?? '', + sanitizeCsvMemo(activityMemo(entry)), + ]; +} + +/** Serialize activity using RFC 4180 line endings for broad spreadsheet support. */ +export function activityToCsv(entries: readonly ActivityEntry[]): string { + const rows = [ + ACTIVITY_CSV_HEADERS.map(escapeCsvCell).join(','), + ...entries.map((entry) => activityCsvRow(entry).map(escapeCsvCell).join(',')), + ]; + + return rows.join('\r\n'); +} + +/** Serialize the filtered entries in the same shape as the shipped JSON schema. */ +export function activityToJson(entries: readonly ActivityEntry[]): string { + return JSON.stringify(entries, null, 2); +} + +function datedFilename(extension: 'csv' | 'json'): string { + return `wraith-activity-${new Date().toISOString().slice(0, 10)}.${extension}`; +} + +function downloadText(content: string, filename: string, type: string): void { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = filename; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +export function downloadActivityCsv( + entries: readonly ActivityEntry[], + filename = datedFilename('csv'), +): void { + // A UTF-8 BOM makes non-ASCII memo text open correctly in desktop Excel. + downloadText(`\uFEFF${activityToCsv(entries)}`, filename, 'text/csv;charset=utf-8'); +} + +export function downloadActivityJson( + entries: readonly ActivityEntry[], + filename = datedFilename('json'), +): void { + downloadText(activityToJson(entries), filename, 'application/json;charset=utf-8'); +}