feat: сеть (network) для крипто-холдингов

Свободное текстовое поле на холдинге — какой сети принадлежит конкретная
монета (Ethereum, Tron и т.д.), с редактированием прямо в таблице и
разбивкой % по сетям на отдельной вкладке. Помогает увидеть, что часть
стейблкоинов лежит на рискованных сетях и стоит перевести на безопасные.
This commit is contained in:
ZProger
2026-09-09 00:52:11 +03:00
parent 526bfe104d
commit c3047c6d6a
16 changed files with 421 additions and 13 deletions

View File

@@ -3,6 +3,7 @@ import type {
CryptoHistoryResponse,
CryptoHolding,
CryptoHoldingCreateInput,
CryptoHoldingUpdateInput,
CryptoPerformanceResponse,
CryptoPortfolio,
CryptoPortfolioInput,
@@ -48,6 +49,13 @@ export function createCryptoHolding(input: CryptoHoldingCreateInput) {
return api.post<CryptoHolding>("/crypto/holdings", input);
}
// Holding-only metadata (currently just `network`) — quantity/price/date
// live on the transaction log, risk_level on the underlying Asset (see
// updateCryptoHoldingRisk above).
export function updateCryptoHolding(assetId: number, input: CryptoHoldingUpdateInput) {
return api.patch<CryptoHolding>(`/crypto/holdings/${assetId}`, input);
}
// Buy more of, or sell some of, a coin already being tracked.
export function addCryptoTransaction(assetId: number, input: CryptoTransactionInput) {
return api.post<CryptoHolding>(`/crypto/holdings/${assetId}/transactions`, input);

View File

@@ -4,7 +4,7 @@ import { searchCryptoCoins } from "@/api/crypto";
import { Button } from "@/components/ui/Button";
import { Dialog } from "@/components/ui/Dialog";
import { Input, Label, Select } from "@/components/ui/Input";
import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto";
import { useCreateCryptoHolding, useCryptoHoldings, useCryptoPortfolios } from "@/hooks/useCrypto";
import { useTranslation, type TranslationKey } from "@/lib/i18n";
import type { CryptoSearchResult, RiskLevel } from "@/types";
@@ -26,6 +26,13 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
const { t } = useTranslation();
const createHolding = useCreateCryptoHolding();
const { data: portfolios } = useCryptoPortfolios();
// Every portfolio's holdings, purely to build the network datalist below
// (already-typed values like "Ethereum"/"Tron" as autocomplete, not a
// fixed list — see CryptoHolding.network).
const { data: allHoldings } = useCryptoHoldings();
const knownNetworks = Array.from(
new Set((allHoldings?.holdings ?? []).map((h) => h.network).filter((n): n is string => Boolean(n)))
).sort();
const [query, setQuery] = useState("");
const [results, setResults] = useState<CryptoSearchResult[]>([]);
@@ -39,6 +46,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
// Defaults to "high", same as the backend default — crypto is this app's
// own textbook HIGH risk example — but overridable per coin here.
const [riskLevel, setRiskLevel] = useState<RiskLevel>("high");
const [network, setNetwork] = useState("");
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
@@ -50,6 +58,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
setPricePerUnit("");
setDate(new Date().toISOString().slice(0, 10));
setRiskLevel("high");
setNetwork("");
setSearchError(null);
setSaveError(null);
}, [open]);
@@ -106,6 +115,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
price_per_unit: pricePerUnit,
date,
risk_level: riskLevel,
network: network.trim() || null,
});
onClose();
} catch {
@@ -242,6 +252,22 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
</Select>
</div>
<div>
<Label htmlFor="crypto-network">{t("crypto.form.networkLabel")}</Label>
<Input
id="crypto-network"
list="crypto-known-networks"
placeholder={t("crypto.form.networkPlaceholder")}
value={network}
onChange={(event) => setNetwork(event.target.value.toUpperCase())}
/>
<datalist id="crypto-known-networks">
{knownNetworks.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
{saveError && <p className="text-sm text-danger">{saveError}</p>}
<div className="flex justify-end gap-2 pt-2">

View File

@@ -1,4 +1,5 @@
import { Bitcoin, Plus, Trash2 } from "lucide-react";
import { NetworkPicker } from "@/components/crypto/NetworkPicker";
import { RiskLevelPicker } from "@/components/crypto/RiskLevelPicker";
import { formatCryptoAmount, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
@@ -11,6 +12,7 @@ interface CryptoHoldingsTableProps {
onViewHistory: (holding: CryptoHolding) => void;
onDelete: (holding: CryptoHolding) => void;
onRiskChange: (holding: CryptoHolding, riskLevel: RiskLevel) => void;
onNetworkChange: (holding: CryptoHolding, network: string | null) => void;
// Only passed while viewing the "All" tab — renders each row's portfolio
// as a small colored badge under the coin name so it's still clear which
// portfolio it belongs to. Omitted while a single portfolio is selected,
@@ -37,6 +39,7 @@ export function CryptoHoldingsTable({
onViewHistory,
onDelete,
onRiskChange,
onNetworkChange,
portfoliosById,
}: CryptoHoldingsTableProps) {
const { t } = useTranslation();
@@ -45,9 +48,13 @@ export function CryptoHoldingsTable({
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
}
// Every network already typed anywhere in this table, for NetworkPicker's
// autocomplete — deduped and sorted, same idea as CryptoAddModal's own list.
const knownNetworks = Array.from(new Set(items.map((h) => h.network).filter((n): n is string => Boolean(n)))).sort();
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] text-sm">
<table className="w-full min-w-[960px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-text-muted">
<th className="py-2.5 pr-4 font-medium">{t("crypto.table.name")}</th>
@@ -59,6 +66,7 @@ export function CryptoHoldingsTable({
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.avgBuyPrice")}</th>
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.profitLoss")}</th>
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.risk")}</th>
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.network")}</th>
<th className="whitespace-nowrap py-2.5 pl-4 text-right font-medium">{t("crypto.table.actions")}</th>
</tr>
</thead>
@@ -154,10 +162,19 @@ export function CryptoHoldingsTable({
)}
</td>
<td className="py-3.5 pr-4 align-middle" onClick={(event) => event.stopPropagation()}>
<span className="flex justify-end">
<span className="flex w-full justify-end">
<RiskLevelPicker value={holding.risk_level} onChange={(level) => onRiskChange(holding, level)} />
</span>
</td>
<td className="py-3.5 pr-4 align-middle" onClick={(event) => event.stopPropagation()}>
<span className="flex w-full justify-end">
<NetworkPicker
value={holding.network}
knownNetworks={knownNetworks}
onChange={(network) => onNetworkChange(holding, network)}
/>
</span>
</td>
<td className="py-3.5 pl-4 align-middle">
<div className="flex justify-end gap-1">
<button

View File

@@ -0,0 +1,148 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { formatCryptoAmount, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoNetworkAllocationBodyProps {
holdings: CryptoHolding[];
isLoading: boolean;
hidden: boolean;
}
// Same fixed categorical ramp as CryptoAllocationBody's per-coin donut —
// see net_worth_service.py's _CLASS_META comment for why this exact order.
const SERIES_COLORS = [
"var(--series-1)",
"var(--series-2)",
"var(--series-3)",
"var(--series-4)",
"var(--series-5)",
"var(--series-6)",
"var(--series-7)",
];
const OTHER_COLOR = "var(--series-other)";
const MAX_SLICES = SERIES_COLORS.length;
export interface Slice {
key: string;
label: string;
amount: number;
color: string;
}
// Exported for direct unit testing (see CryptoNetworkAllocationBody.test.ts)
// — the donut/table JSX below isn't worth rendering just to check the math.
export function buildSlices(holdings: CryptoHolding[], unsetLabel: string): Slice[] {
const totals = new Map<string, number>();
for (const h of holdings) {
if (h.value === null) continue;
const key = h.network ?? unsetLabel;
totals.set(key, (totals.get(key) ?? 0) + Number(h.value));
}
const sorted = Array.from(totals.entries())
.map(([label, amount]) => ({ key: label, label, amount }))
.sort((a, b) => b.amount - a.amount);
const top = sorted.slice(0, MAX_SLICES).map((item, index) => ({ ...item, color: SERIES_COLORS[index] }));
const rest = sorted.slice(MAX_SLICES);
if (rest.length === 0) return top;
const otherTotal = rest.reduce((sum, item) => sum + item.amount, 0);
return [...top, { key: "__other__", label: "__other__", amount: otherTotal, color: OTHER_COLOR }];
}
function DonutTooltip({
active,
payload,
hidden,
otherLabel,
}: {
active?: boolean;
payload?: Array<{ payload: Slice & { percent: number } }>;
hidden: boolean;
otherLabel: string;
}) {
if (!active || !payload?.length) return null;
const slice = payload[0].payload;
return (
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
<p className="font-medium text-text-primary">{slice.key === "__other__" ? otherLabel : slice.label}</p>
<p className="text-text-muted">
{maskAmount(formatCryptoAmount(slice.amount), hidden)} · {slice.percent.toFixed(1)}%
</p>
</div>
);
}
/** The "Networks" tab's content inside CryptoOverviewCard — same donut +
* table shape as CryptoAllocationBody's per-coin view, grouped by
* `holding.network` instead, so a stablecoin split across several chains
* shows up as separate slices (bridge/counterparty risk differs by chain,
* which risk_level alone doesn't capture — see CryptoHolding.network).
* Holdings with no network set fold into one "unset" slice rather than
* disappearing, so nothing silently drops out of the total. */
export function CryptoNetworkAllocationBody({ holdings, isLoading, hidden }: CryptoNetworkAllocationBodyProps) {
const { t } = useTranslation();
const unsetLabel = t("crypto.networkAllocation.unset");
const otherLabel = t("crypto.allocation.other");
if (isLoading) {
return <p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>;
}
const slices = buildSlices(holdings, unsetLabel);
if (slices.length === 0) {
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
}
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
const donutData = slices.map((slice) => ({ ...slice, percent: (slice.amount / total) * 100 }));
return (
<div className="flex flex-col items-center justify-center gap-6 sm:flex-row">
<div className="h-48 w-48 shrink-0 sm:h-56 sm:w-56">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={donutData}
dataKey="amount"
nameKey="label"
innerRadius="68%"
outerRadius="100%"
paddingAngle={donutData.length > 1 ? 2 : 0}
stroke="var(--surface-1)"
strokeWidth={2}
isAnimationActive={false}
>
{donutData.map((slice) => (
<Cell key={slice.key} fill={slice.color} />
))}
</Pie>
<Tooltip content={<DonutTooltip hidden={hidden} otherLabel={otherLabel} />} />
</PieChart>
</ResponsiveContainer>
</div>
<table className="text-sm">
<tbody>
{donutData.map((slice) => (
<tr key={slice.key} title={slice.key === "__other__" ? undefined : slice.label}>
<td className="py-1.5 pr-3">
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: slice.color }} />
<span className="max-w-[140px] truncate font-medium text-text-primary">
{slice.key === "__other__" ? otherLabel : slice.label}
</span>
</span>
</td>
<td className="py-1.5 pr-3 text-right font-medium tabular-nums text-text-primary">
{slice.percent.toFixed(1)}%
</td>
<td className="py-1.5 text-right tabular-nums text-text-muted">{maskAmount(formatCryptoAmount(slice.amount), hidden)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { ArrowDown, ArrowUp, Eye, EyeOff } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { CryptoAllocationBody } from "@/components/crypto/CryptoAllocationBody";
import { CryptoHistoryChartBody } from "@/components/crypto/CryptoHistoryChartBody";
import { CryptoNetworkAllocationBody } from "@/components/crypto/CryptoNetworkAllocationBody";
import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector";
import { CryptoRiskAllocationBody } from "@/components/crypto/CryptoRiskAllocationBody";
import { PillSelector } from "@/components/layout/PillSelector";
@@ -10,7 +11,7 @@ import { formatCurrency, formatSignedCurrency, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHistoryResponse, CryptoHolding, CryptoRange } from "@/types";
type OverviewTab = "history" | "allocation" | "risk";
type OverviewTab = "history" | "allocation" | "risk" | "networks";
interface CryptoOverviewCardProps {
totalValue: number;
@@ -24,11 +25,12 @@ interface CryptoOverviewCardProps {
onToggleHidden: () => void;
}
/** One card, three tabs — "History" (the value-over-time chart),
* "Allocation" (a donut broken down by coin), and "Risk levels" (% of the
* portfolio in each RiskLevel tier), same CoinMarketCap-style layout the
* user asked for: switching views instead of stacking separate cards,
* so the tab keeps its footprint on the page instead of growing it. The
/** One card, four tabs — "History" (the value-over-time chart),
* "Allocation" (a donut broken down by coin), "Risk levels" (% of the
* portfolio in each RiskLevel tier), and "Networks" (a donut broken down by
* CryptoHolding.network) — same CoinMarketCap-style layout the user asked
* for: switching views instead of stacking separate cards, so the tab keeps
* its footprint on the page instead of growing it. The
* total/eye toggle/change line stay in the shared header regardless of
* which tab is active — only the content panel below switches. */
export function CryptoOverviewCard({
@@ -80,6 +82,7 @@ export function CryptoOverviewCard({
{ value: "history" as const, label: t("crypto.overview.history") },
{ value: "allocation" as const, label: t("crypto.overview.allocation") },
{ value: "risk" as const, label: t("crypto.overview.risk") },
{ value: "networks" as const, label: t("crypto.overview.networks") },
]}
value={tab}
onChange={setTab}
@@ -92,8 +95,10 @@ export function CryptoOverviewCard({
<CryptoHistoryChartBody history={history} isLoading={isHistoryLoading} hidden={hidden} />
) : tab === "allocation" ? (
<CryptoAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
) : (
) : tab === "risk" ? (
<CryptoRiskAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
) : (
<CryptoNetworkAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
)}
</CardContent>
</Card>

View File

@@ -0,0 +1,67 @@
import { useId, useState } from "react";
import { useTranslation } from "@/lib/i18n";
interface NetworkPickerProps {
value: string | null;
knownNetworks: string[];
onChange: (network: string | null) => void;
}
/** Click-to-edit-in-place text for a holding's network (see
* CryptoHolding.network) — a plain button that swaps for a text input on
* click, autocompleting from every network already typed elsewhere via
* `knownNetworks`. No fixed list: chains are numerous and change too often
* for an enum, so this stays free text. */
export function NetworkPicker({ value, knownNetworks, onChange }: NetworkPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value ?? "");
const listId = useId();
function commit() {
setOpen(false);
const trimmed = draft.trim();
if (trimmed !== (value ?? "")) onChange(trimmed || null);
}
if (open) {
return (
<>
<input
autoFocus
list={listId}
value={draft}
onChange={(event) => setDraft(event.target.value.toUpperCase())}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
}
if (event.key === "Escape") setOpen(false);
}}
className="w-28 rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-text-primary outline-none"
/>
<datalist id={listId}>
{knownNetworks.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</>
);
}
return (
<button
type="button"
onClick={() => {
setDraft(value ?? "");
setOpen(true);
}}
title={value ?? undefined}
className="max-w-[100px] truncate rounded px-1.5 py-0.5 text-right text-xs text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
{value ?? t("crypto.table.addNetwork")}
</button>
);
}

View File

@@ -12,11 +12,19 @@ import {
fetchCryptoPortfolios,
fetchCryptoTransactions,
refreshCryptoPrices,
updateCryptoHolding,
updateCryptoHoldingRisk,
updateCryptoPortfolio,
updateCryptoTransaction,
} from "@/api/crypto";
import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput, RiskLevel } from "@/types";
import type {
CryptoHoldingCreateInput,
CryptoHoldingUpdateInput,
CryptoPortfolioInput,
CryptoRange,
CryptoTransactionInput,
RiskLevel,
} from "@/types";
function useInvalidateCrypto() {
const queryClient = useQueryClient();
@@ -153,3 +161,12 @@ export function useUpdateCryptoHoldingRisk() {
onSuccess: invalidate,
});
}
export function useUpdateCryptoHolding() {
const invalidate = useInvalidateCrypto();
return useMutation({
mutationFn: ({ assetId, input }: { assetId: number; input: CryptoHoldingUpdateInput }) =>
updateCryptoHolding(assetId, input),
onSuccess: invalidate,
});
}

View File

@@ -128,6 +128,8 @@ const ru = {
"crypto.form.pricePerUnitLabel": "Цена за штуку",
"crypto.form.dateLabel": "Дата",
"crypto.form.noteLabel": "Заметка (необязательно)",
"crypto.form.networkLabel": "Сеть (необязательно)",
"crypto.form.networkPlaceholder": "Например, Ethereum, Tron…",
"crypto.form.addTitle": "Добавить {{name}}",
"crypto.form.tradeTitle": "Сделка: {{name}}",
"crypto.form.editTradeTitle": "Изменить сделку: {{name}}",
@@ -144,6 +146,8 @@ const ru = {
"crypto.table.holdings": "Держите",
"crypto.table.avgBuyPrice": "Ср. цена",
"crypto.table.profitLoss": "Прибыль/убыток",
"crypto.table.network": "Сеть",
"crypto.table.addNetwork": "+ Сеть",
"crypto.table.risk": "Риск",
"crypto.table.actions": "Действия",
"crypto.riskAllocationOfPortfolio": "от крипто-портфеля",
@@ -158,6 +162,8 @@ const ru = {
"crypto.overview.history": "История",
"crypto.overview.allocation": "Аллокация",
"crypto.overview.risk": "Уровни риска",
"crypto.overview.networks": "Сети",
"crypto.networkAllocation.unset": "Сеть не указана",
"crypto.allocation.other": "Другое",
"crypto.stats.allTimeProfit": "Прибыль за всё время",
"crypto.stats.costBasis": "Вложено всего",
@@ -626,6 +632,8 @@ const en: Record<keyof typeof ru, string> = {
"crypto.form.pricePerUnitLabel": "Price per unit",
"crypto.form.dateLabel": "Date",
"crypto.form.noteLabel": "Note (optional)",
"crypto.form.networkLabel": "Network (optional)",
"crypto.form.networkPlaceholder": "e.g. Ethereum, Tron…",
"crypto.form.addTitle": "Add {{name}}",
"crypto.form.tradeTitle": "Trade: {{name}}",
"crypto.form.editTradeTitle": "Edit trade: {{name}}",
@@ -642,6 +650,8 @@ const en: Record<keyof typeof ru, string> = {
"crypto.table.holdings": "Holdings",
"crypto.table.avgBuyPrice": "Avg. Price",
"crypto.table.profitLoss": "Profit/Loss",
"crypto.table.network": "Network",
"crypto.table.addNetwork": "+ Network",
"crypto.table.risk": "Risk",
"crypto.table.actions": "Actions",
"crypto.riskAllocationOfPortfolio": "of crypto portfolio",
@@ -656,6 +666,8 @@ const en: Record<keyof typeof ru, string> = {
"crypto.overview.history": "History",
"crypto.overview.allocation": "Allocation",
"crypto.overview.risk": "Risk levels",
"crypto.overview.networks": "Networks",
"crypto.networkAllocation.unset": "No network set",
"crypto.allocation.other": "Other",
"crypto.stats.allTimeProfit": "All-time profit",
"crypto.stats.costBasis": "Total invested",

View File

@@ -17,6 +17,7 @@ import {
useCryptoPortfolios,
useDeleteCryptoHolding,
useRefreshCryptoPrices,
useUpdateCryptoHolding,
useUpdateCryptoHoldingRisk,
} from "@/hooks/useCrypto";
import { getIntlLocale } from "@/lib/format";
@@ -46,6 +47,7 @@ export function CryptoPage() {
const refresh = useRefreshCryptoPrices();
const deleteHolding = useDeleteCryptoHolding();
const updateRisk = useUpdateCryptoHoldingRisk();
const updateHolding = useUpdateCryptoHolding();
const [addOpen, setAddOpen] = useState(false);
const [editingPortfolio, setEditingPortfolio] = useState<CryptoPortfolio | null>(null);
@@ -86,6 +88,10 @@ export function CryptoPage() {
updateRisk.mutate({ assetId: holding.asset_id, riskLevel });
}
function handleNetworkChange(holding: CryptoHolding, network: string | null) {
updateHolding.mutate({ assetId: holding.asset_id, input: { network } });
}
function handleEditTransaction(transaction: CryptoTransaction) {
setEditingTransaction(transaction);
setTradingHolding(historyHolding);
@@ -167,6 +173,7 @@ export function CryptoPage() {
onViewHistory={setHistoryHolding}
onDelete={handleDelete}
onRiskChange={handleRiskChange}
onNetworkChange={handleNetworkChange}
portfoliosById={portfolioFilter === null ? portfoliosById : undefined}
/>
</>

View File

@@ -445,6 +445,11 @@ export interface CryptoHolding {
// Lives on the underlying Asset — edited via PATCH /assets/{asset_id},
// same endpoint every other Asset already uses.
risk_level: RiskLevel;
// Free-form chain name (e.g. "Ethereum", "Tron") — not validated against
// a fixed list, since the same coin can carry different bridge/
// counterparty risk per chain and risk_level alone can't capture that.
// Edited via PATCH /crypto/holdings/{asset_id}.
network: string | null;
// Both derived from the buy/sell log (see CryptoTransaction) — never
// edited directly.
quantity: string;
@@ -483,6 +488,12 @@ export interface CryptoHoldingCreateInput {
// Omit to default to "high" server-side — crypto's own textbook risk
// tier — but overridable per coin (a large-cap isn't a memecoin).
risk_level?: RiskLevel;
// Free text, e.g. "Ethereum" or "Tron" — see CryptoHolding.network.
network?: string | null;
}
export interface CryptoHoldingUpdateInput {
network?: string | null;
}
export interface CryptoTransaction {