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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Prefer **clean code over backwards compatibility**. Do not add legacy redirects,
| `/signer/` | Signing Layer (`@1shotapi/ows-signer`) |
| `src/lib/types/primitives/` | Wallet-local branded types (one file each) |
| `src/lib/types/enum/` | Domain enums (`EAssetType`, `EWalletEventKind`, …) |
| `src/lib/types/business/` | Domain DTOs (e.g. `KnownAsset`, `TrackedAsset`) |
| `src/lib/types/domain/` | Domain DTOs (e.g. `KnownAsset`, `TrackedAsset`, `WalletConfig`) |
| `src/lib/types/events/` | Domain event classes (one file each) |
| `src/lib/interfaces/{business,data,utils}/` | Layer interfaces |
| `src/lib/implementations/{business,data,utils}/` | Layer implementations |
Expand Down
94 changes: 4 additions & 90 deletions src/components/AssetDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import {
ArrowDownLeftIcon,
ArrowUpRightIcon,
PlusIcon,
QrCodeIcon,
SendIcon,
ShoppingBagIcon,
} from "lucide-react";
import { AddressUtils } from "@1shotapi/ows-wallet-utils";
import type { TrackedAsset } from "../lib/types/business";
import type { TrackedAsset } from "../lib/types/domain";
import { EAssetType } from "../lib/types/enum";
import type { EVMChainId } from "@1shotapi/ows-types";
import { DEMO_CHAINS } from "../ows/demoChains";
Expand All @@ -20,71 +17,26 @@ import { useWallet } from "../wallet/WalletProvider";
import { resolveActiveAddress } from "../wallet/activeAddress";
import { useWalletSessionStore } from "../wallet/sessionStore";
import { BalanceDisplay } from "./BalanceDisplay";
import { TransactionHistory } from "./TransactionHistory";
import { ReceiveModal } from "./modals/ReceiveModal";
import { PurchaseComingSoonModal } from "./modals/PurchaseComingSoonModal";
import { TransferTokensModal } from "./modals/TransferTokensModal";

const addressUtils = new AddressUtils(new DemoChainsBlockchainProvider());

type ActivityKind = "received" | "sent" | "purchase";

interface IMockActivity {
kind: ActivityKind;
title: string;
when: string;
amount: string;
positive: boolean;
}

const MOCK_ACTIVITY: IMockActivity[] = [
{
kind: "received",
title: "Received from 0x…4a2b",
when: "Today, 2:45 PM",
amount: "+10.00",
positive: true,
},
{
kind: "sent",
title: "Sent to alex.eth",
when: "Yesterday, 11:12 AM",
amount: "-5.00",
positive: false,
},
{
kind: "purchase",
title: "Purchase from Uniswap",
when: "Oct 24, 09:30 AM",
amount: "+50.00",
positive: true,
},
];

function chainLabel(chainId: EVMChainId): string {
return (
DEMO_CHAINS.find((chain) => chain.chainId === chainId)?.label ?? chainId
);
}

function ActivityIcon({ kind }: { kind: ActivityKind }) {
const className = "size-4 text-muted-foreground";
switch (kind) {
case "received":
return <ArrowDownLeftIcon className={className} />;
case "sent":
return <ArrowUpRightIcon className={className} />;
case "purchase":
return <ShoppingBagIcon className={className} />;
}
}

export interface IAssetDetailsProps {
asset: TrackedAsset;
}

/**
* Shared focused-asset / asset-detail shell.
* Balance is live; recent activity remains mock for now.
* Balance and recent activity are live.
*/
export function AssetDetails({ asset }: IAssetDetailsProps) {
const { style } = useStyle();
Expand Down Expand Up @@ -165,45 +117,7 @@ export function AssetDetails({ asset }: IAssetDetailsProps) {
</ActionButton>
</nav>

<section className="border-border flex flex-col gap-3 border-t pt-4">
<div className="flex items-center justify-between gap-2">
<h3 className="text-foreground text-sm font-semibold">
Recent Activity
</h3>
<button
type="button"
className="text-primary text-sm font-medium"
disabled
>
View all
</button>
</div>
<ul className="flex flex-col gap-1">
{MOCK_ACTIVITY.map((item) => (
<li
key={`${item.kind}-${item.when}`}
className="flex items-center gap-3 rounded-lg py-2"
>
<div className="bg-muted flex size-9 shrink-0 items-center justify-center rounded-md">
<ActivityIcon kind={item.kind} />
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{item.title}
</p>
<p className="text-muted-foreground text-xs">{item.when}</p>
</div>
<p
className={`shrink-0 text-sm font-medium ${
item.positive ? "text-primary" : "text-foreground"
}`}
>
{item.amount} {asset.symbol}
</p>
</li>
))}
</ul>
</section>
<TransactionHistory asset={asset} owner={evmAddress} />

{receiveOpen ? (
<ReceiveModal
Expand Down
2 changes: 1 addition & 1 deletion src/components/MainPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { TrackedAsset } from "../lib/types/business";
import type { TrackedAsset } from "../lib/types/domain";
import { useWallet } from "../wallet/WalletProvider";
import {
EWalletMode,
Expand Down
228 changes: 228 additions & 0 deletions src/components/TransactionHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { useCallback, useEffect, useState } from "react";
import { formatUnits } from "viem";
import {
ArrowDownLeftIcon,
ArrowUpRightIcon,
} from "lucide-react";
import type { EVMAccountAddress } from "@1shotapi/ows-types";
import type { AssetActivity, TrackedAsset } from "../lib/types/domain";
import {
EAssetActivityKind,
EAssetActivityStatus,
} from "../lib/types/enum";
import {
demoAddressExplorerUrl,
demoTxExplorerUrl,
} from "../ows/demoChains";
import { useWallet } from "../wallet/WalletProvider";
import { useTransactionHistoryUpdated } from "../wallet/useWalletEvent";

const RECENT_LIMIT = 10;
const TRUNCATE_CHARS = 5;

export interface ITransactionHistoryProps {
asset: TrackedAsset;
owner: EVMAccountAddress | null;
}

function truncateAddress(address: string): string {
if (address.length <= TRUNCATE_CHARS * 2 + 1) {
return address;
}
return `${address.slice(0, TRUNCATE_CHARS)}…${address.slice(-TRUNCATE_CHARS)}`;
}

function formatWhen(timestampMs: number): string {
if (!timestampMs) {
return "Unknown time";
}
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(timestampMs));
} catch {
return new Date(timestampMs).toLocaleString();
}
}

function formatSignedAmount(activity: AssetActivity, symbol: string): string {
let amount: string;
try {
amount = formatUnits(activity.amount, activity.decimals);
} catch {
amount = activity.amount.toString();
}
const sign =
activity.kind === EAssetActivityKind.Received ? "+" : "-";
return `${sign}${amount} ${symbol}`;
}

function activityTitle(activity: AssetActivity): string {
const peer = truncateAddress(String(activity.counterparty));
if (activity.kind === EAssetActivityKind.Sent) {
const pending =
activity.status === EAssetActivityStatus.Pending ? " (pending)" : "";
return `Sent to ${peer}${pending}`;
}
return `Received from ${peer}`;
}

function ActivityIcon({ kind }: { kind: EAssetActivityKind }) {
const className = "size-4 text-muted-foreground";
if (kind === EAssetActivityKind.Received) {
return <ArrowDownLeftIcon className={className} />;
}
return <ArrowUpRightIcon className={className} />;
}

/**
* Recent ERC-20 transfers for a tracked asset (Alchemy + optimistic local sends).
*/
export function TransactionHistory({
asset,
owner,
}: ITransactionHistoryProps) {
const { listAssetActivity } = useWallet();
const [rows, setRows] = useState<AssetActivity[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const refresh = useCallback(async () => {
if (!owner) {
setRows([]);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const next = await listAssetActivity(owner, asset, RECENT_LIMIT);
setRows(next);
} catch (err: unknown) {
console.error("[oneshot-wallet] activity load failed", err);
setError(
err instanceof Error ? err.message : "Failed to load activity",
);
setRows([]);
} finally {
setLoading(false);
}
}, [asset, listAssetActivity, owner]);

useEffect(() => {
void refresh();
}, [refresh]);

useTransactionHistoryUpdated(
useCallback(
(event) => {
if (
event.trackedAssetId !== undefined &&
event.trackedAssetId !== asset.id
) {
return;
}
void refresh();
},
[asset.id, refresh],
),
);

const viewAllUrl =
owner !== null ? demoAddressExplorerUrl(asset.chainId, String(owner)) : null;

return (
<section className="border-border flex flex-col gap-3 border-t pt-4">
<div className="flex items-center justify-between gap-2">
<h3 className="text-foreground text-sm font-semibold">
Recent Activity
</h3>
{viewAllUrl ? (
<a
href={viewAllUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary text-sm font-medium"
>
View all
</a>
) : (
<button
type="button"
className="text-primary text-sm font-medium"
disabled
>
View all
</button>
)}
</div>

{loading && rows.length === 0 ? (
<p className="text-muted-foreground text-sm">Loading…</p>
) : null}
{error && rows.length === 0 ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
{!loading && !error && rows.length === 0 ? (
<p className="text-muted-foreground text-sm">No recent activity.</p>
) : null}

{rows.length > 0 ? (
<ul className="flex flex-col gap-1">
{rows.map((item) => {
const explorerUrl = demoTxExplorerUrl(
item.chainId,
String(item.hash),
);
const positive = item.kind === EAssetActivityKind.Received;
const content = (
<>
<div className="bg-muted flex size-9 shrink-0 items-center justify-center rounded-md">
<ActivityIcon kind={item.kind} />
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{activityTitle(item)}
</p>
<p className="text-muted-foreground text-xs">
{formatWhen(item.timestampMs)}
</p>
</div>
<p
className={`shrink-0 text-sm font-medium ${
positive ? "text-primary" : "text-foreground"
}`}
>
{formatSignedAmount(item, asset.symbol)}
</p>
</>
);

return (
<li key={`${item.hash}-${item.kind}`}>
{explorerUrl ? (
<a
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-muted/60 flex items-center gap-3 rounded-lg py-2"
>
{content}
</a>
) : (
<div className="flex items-center gap-3 rounded-lg py-2">
{content}
</div>
)}
</li>
);
})}
</ul>
) : null}
</section>
);
}
2 changes: 1 addition & 1 deletion src/components/balances/AssetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TrackedAsset } from "../../lib/types/business";
import { TrackedAsset } from "../../lib/types/domain";
import { useStyle } from "../../style";
import { useWallet } from "../../wallet/WalletProvider";
import { useWalletSessionStore } from "../../wallet/sessionStore";
Expand Down
Loading