Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

179 changes: 112 additions & 67 deletions src/pages/Activity.tsx
Original file line number Diff line number Diff line change
@@ -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<ActivityChain | 'all'>('all');
const [filterKind, setFilterKind] = useState<ActivityKind | 'all'>('all');
const [filterStatus, setFilterStatus] = useState<ActivityStatus | 'all'>('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 (
Expand All @@ -41,7 +43,7 @@ export default function Activity() {
Activity
</h1>
<p className="font-body text-sm leading-relaxed text-on-surface-variant">
Connect your wallet to view your transaction history.
Connect your wallet to view and export your transaction history.
</p>
</section>
);
Expand All @@ -59,60 +61,103 @@ export default function Activity() {
</h1>
</div>
<button
type="button"
onClick={() => clearHistory(filterChain === 'all' ? 'stellar' : filterChain, address)}
className="rounded-lg bg-surface-container px-4 py-2 font-mono text-xs uppercase tracking-widest text-on-surface transition-colors hover:bg-error/20 hover:text-error"
>
Clear History
</button>
</div>

<div className="flex flex-wrap gap-4">
<div className="flex flex-col gap-1">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Chain
</label>
<select
value={filterChain}
onChange={(e) => setFilterChain(e.target.value as any)}
className="rounded-lg border border-outline bg-surface-container px-3 py-2 font-mono text-sm text-on-surface outline-none focus:border-tertiary"
>
<option value="all">All Chains</option>
<option value="stellar">Stellar</option>
<option value="horizen">Horizen</option>
<option value="solana">Solana</option>
<option value="ckb">CKB</option>
</select>
<div className="flex flex-col gap-4 border border-outline-variant bg-surface-container p-4">
<div className="flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-1">
<label
htmlFor="activity-chain"
className="font-mono text-[10px] uppercase tracking-widest text-outline"
>
Chain
</label>
<select
id="activity-chain"
value={filterChain}
onChange={(event) => setFilterChain(event.target.value as ActivityChain | 'all')}
className="rounded-lg border border-outline bg-surface px-3 py-2 font-mono text-sm text-on-surface outline-none focus:border-tertiary"
>
<option value="all">All Chains</option>
<option value="stellar">Stellar</option>
<option value="horizen">Horizen</option>
<option value="solana">Solana</option>
<option value="ckb">CKB</option>
</select>
</div>

<div className="flex flex-col gap-1">
<label
htmlFor="activity-kind"
className="font-mono text-[10px] uppercase tracking-widest text-outline"
>
Type
</label>
<select
id="activity-kind"
value={filterKind}
onChange={(event) => setFilterKind(event.target.value as ActivityKind | 'all')}
className="rounded-lg border border-outline bg-surface px-3 py-2 font-mono text-sm text-on-surface outline-none focus:border-tertiary"
>
<option value="all">All Types</option>
<option value="stealth-send">Stealth Send</option>
<option value="stealth-receive">Stealth Receive</option>
<option value="withdrawal">Withdrawal</option>
<option value="name-registration">Name Registration</option>
</select>
</div>

<div className="flex flex-col gap-1">
<label
htmlFor="activity-status"
className="font-mono text-[10px] uppercase tracking-widest text-outline"
>
Status
</label>
<select
id="activity-status"
value={filterStatus}
onChange={(event) => setFilterStatus(event.target.value as ActivityStatus | 'all')}
className="rounded-lg border border-outline bg-surface px-3 py-2 font-mono text-sm text-on-surface outline-none focus:border-tertiary"
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="failed">Failed</option>
</select>
</div>

<span className="pb-2 font-mono text-[10px] uppercase tracking-widest text-outline">
{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}
</span>
</div>
<div className="flex flex-col gap-1">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Type
</label>
<select
value={filterKind}
onChange={(e) => setFilterKind(e.target.value as any)}
className="rounded-lg border border-outline bg-surface-container px-3 py-2 font-mono text-sm text-on-surface outline-none focus:border-tertiary"

<div className="flex flex-wrap gap-2 border-t border-outline-variant pt-4">
<button
type="button"
onClick={() => downloadActivityCsv(filteredEntries)}
disabled={filteredEntries.length === 0}
className="border border-outline px-4 py-2 font-mono text-xs uppercase tracking-widest text-on-surface transition-colors hover:border-tertiary hover:text-tertiary disabled:cursor-not-allowed disabled:opacity-40"
>
<option value="all">All Types</option>
<option value="stealth-send">Stealth Send</option>
<option value="stealth-receive">Stealth Receive</option>
<option value="withdrawal">Withdrawal</option>
<option value="name-registration">Name Registration</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Status
</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as any)}
className="rounded-lg border border-outline bg-surface-container px-3 py-2 font-mono text-sm text-on-surface outline-none focus:border-tertiary"
Export CSV
</button>
<button
type="button"
onClick={() => downloadActivityJson(filteredEntries)}
disabled={filteredEntries.length === 0}
className="border border-outline px-4 py-2 font-mono text-xs uppercase tracking-widest text-on-surface transition-colors hover:border-tertiary hover:text-tertiary disabled:cursor-not-allowed disabled:opacity-40"
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="failed">Failed</option>
</select>
Export JSON
</button>
<p className="basis-full font-body text-xs text-outline">
Exports include only the entries matching the current chain, type, and status filters.
</p>
</div>
</div>

Expand All @@ -127,8 +172,8 @@ export default function Activity() {
)}

<div className="flex flex-col gap-4">
{filteredEntries.map((tx: ActivityEntry) => (
<ActivityRow key={tx.id} entry={tx} />
{filteredEntries.map((entry: ActivityEntry) => (
<ActivityRow key={entry.id} entry={entry} />
))}
</div>
</section>
Expand Down
51 changes: 51 additions & 0 deletions src/schema/activity.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
51 changes: 51 additions & 0 deletions src/utils/activityExport.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading
Loading