From a9a83665e5c93fa7fcd2d771923bcd2a0ddcfb34 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Sun, 13 Sep 2026 20:19:42 -0700 Subject: [PATCH] fix treasury signer withdrawal workflow --- backend/.env.indexer | 2 +- backend/package.json | 10 +-- backend/src/index.ts | 68 ++++++++++++++----- backend/tsconfig.json | 4 +- deployment/proxmox/deploy-backend.sh | 7 +- deployment/proxmox/deploy-frontend.sh | 12 ++-- deployment/proxmox/deploy-indexer.sh | 7 +- frontend/app/(dashboard)/activity/page.tsx | 10 +-- frontend/app/(dashboard)/approvals/page.tsx | 16 +++-- frontend/app/(dashboard)/send/page.tsx | 7 +- frontend/app/(dashboard)/settings/page.tsx | 50 +++++++------- frontend/app/(dashboard)/transfer/page.tsx | 11 ++- frontend/app/layout.tsx | 5 +- .../components/dashboard/RecentActivity.tsx | 8 ++- .../settings/SubAccountsSettings.tsx | 8 ++- frontend/components/web3/SignerStatus.tsx | 11 +++ frontend/e2e/smoke.spec.ts | 7 ++ frontend/lib/api/client.ts | 37 ++++++---- frontend/lib/hooks/useTreasuryAccess.ts | 17 +++++ frontend/next.config.js | 6 +- frontend/package.json | 42 ++++++------ scripts/proxmox-resume-deploy.sh | 6 +- 22 files changed, 232 insertions(+), 119 deletions(-) create mode 100644 frontend/components/web3/SignerStatus.tsx create mode 100644 frontend/lib/hooks/useTreasuryAccess.ts diff --git a/backend/.env.indexer b/backend/.env.indexer index 4601e4c..c8109ad 100644 --- a/backend/.env.indexer +++ b/backend/.env.indexer @@ -2,7 +2,7 @@ DATABASE_URL=postgresql://solace_user:SolaceTreasury2024!@192.168.11.62:5432/solace_treasury # Chain 138 Configuration -RPC_URL=http://192.168.11.235:8545 +RPC_URL=http://192.168.11.211:8545 CHAIN_ID=138 CONTRACT_ADDRESS=0x96bF8cd30C4f0e22fa33469f8e2C23AA3faF525C diff --git a/backend/package.json b/backend/package.json index 7dfb574..ed20597 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,18 +12,18 @@ "indexer:start": "tsx src/indexer/indexer.ts" }, "dependencies": { - "@trpc/server": "^10.45.0", "@trpc/client": "^10.45.0", + "@trpc/server": "^10.45.0", + "dotenv": "^16.3.1", "drizzle-orm": "^0.29.0", "postgres": "^3.4.0", - "viem": "^2.0.0", - "zod": "^3.22.4", - "dotenv": "^16.3.1" + "viem": "^2.56.5", + "zod": "^3.22.4" }, "devDependencies": { "@types/node": "^20.10.0", "drizzle-kit": "^0.20.0", "tsx": "^4.7.0", - "typescript": "5.3.3" + "typescript": "^5.9.3" } } diff --git a/backend/src/index.ts b/backend/src/index.ts index 6e340b5..ffc25f0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import { treasuryRouter } from "./api/treasury"; import { exportRouter } from "./api/exports"; import { fetchTreasuryLedger } from "./api/ledger"; import { ensureTreasury } from "./db/bootstrap"; +import { createPublicClient, http as viemHttp, defineChain, isAddress, verifyMessage } from "viem"; const port = Number(process.env.PORT || 3001); const nodeEnv = process.env.NODE_ENV || "development"; @@ -17,6 +18,12 @@ const clientRpcUrl = "https://rpc-http-pub.d-bis.org"; const chainId = process.env.CHAIN_ID || "138"; const contractAddress = process.env.CONTRACT_ADDRESS || ""; +const allowedOrigin = process.env.CORS_ORIGIN || "https://treasury.d-bis.org"; +const chain138 = defineChain({ id: 138, name: "Solace Chain 138", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, rpcUrls: { default: { http: [rpcUrl] } } }); +const publicClient = createPublicClient({ chain: chain138, transport: viemHttp(rpcUrl) }); +const ownerAbi = [{ type: "function", name: "isOwner", stateMutability: "view", inputs: [{ name: "owner", type: "address" }], outputs: [{ type: "bool" }] }] as const; +const requestWindows = new Map(); +const API_RATE_LIMIT = 120; for (const envVar of ["DATABASE_URL", "RPC_URL", "CHAIN_ID"] as const) { if (!process.env[envVar]) { @@ -55,11 +62,11 @@ async function checkRpc(): Promise { function sendJson(res: http.ServerResponse, status: number, body: unknown) { const payload = JSON.stringify(body); - res.writeHead(status, { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", + res.writeHead(status, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": allowedOrigin, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Headers": "Content-Type, X-Wallet-Address, X-Wallet-Message, X-Wallet-Signature", }); res.end(payload); } @@ -79,11 +86,11 @@ function sendText( body: string, contentType: string ) { - res.writeHead(status, { - "Content-Type": contentType, - "Access-Control-Allow-Origin": "*", + res.writeHead(status, { + "Content-Type": contentType, + "Access-Control-Allow-Origin": allowedOrigin, "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Headers": "Content-Type, X-Wallet-Address, X-Wallet-Message, X-Wallet-Signature", }); res.end(body); } @@ -91,9 +98,9 @@ function sendText( const server = http.createServer(async (req, res) => { if (req.method === "OPTIONS") { res.writeHead(204, { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": allowedOrigin, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Headers": "Content-Type, X-Wallet-Address, X-Wallet-Message, X-Wallet-Signature", }); res.end(); return; @@ -101,6 +108,14 @@ const server = http.createServer(async (req, res) => { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + if (url.pathname.startsWith("/api/")) { + const key = req.socket.remoteAddress || "unknown"; + const now = Date.now(); + const window = requestWindows.get(key); + if (!window || now - window.started >= 60_000) requestWindows.set(key, { started: now, count: 1 }); + else if (++window.count > API_RATE_LIMIT) { res.writeHead(429, { "Retry-After": "60" }); res.end(JSON.stringify({ error: "Rate limit exceeded" })); return; } + } + try { if ( req.method === "GET" && @@ -111,8 +126,6 @@ const server = http.createServer(async (req, res) => { status: dbOk && rpcOk ? "ok" : "degraded", environment: nodeEnv, chainId: Number(chainId), - rpcUrl, - clientRpcUrl, contractAddress: contractAddress || null, database: dbOk, rpc: rpcOk, @@ -129,12 +142,31 @@ const server = http.createServer(async (req, res) => { return; } + async function authorizedTreasury(treasuryId: string) { + const wallet = req.headers["x-wallet-address"]; + const signature = req.headers["x-wallet-signature"]; + const message = req.headers["x-wallet-message"]; + const rows = await treasuryRouter.getById(treasuryId); + const treasury = rows[0]; + if (!treasury || typeof wallet !== "string" || typeof signature !== "string" || typeof message !== "string" || !isAddress(wallet) || !contractAddress || treasury.mainWallet !== contractAddress.toLowerCase()) { console.warn(JSON.stringify({ event: "treasury_authorization_denied", treasuryId, ip: req.socket.remoteAddress })); return null; } + const timestamp = Number(message.match(/\nTimestamp: (\d+)$/)?.[1]); + if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > 300000) return null; + if (!await verifyMessage({ address: wallet, message, signature: signature as `0x${string}` })) return null; + const owner = await publicClient.readContract({ address: contractAddress as `0x${string}`, abi: ownerAbi, functionName: "isOwner", args: [wallet] }); + return owner ? treasury : null; + } + if (req.method === "GET" && url.pathname === "/api/ledger") { const wallet = url.searchParams.get("wallet"); if (!wallet) { sendJson(res, 400, { error: "wallet query parameter required" }); return; } + if (!isAddress(wallet)) { sendJson(res, 400, { error: "valid wallet address required" }); return; } + if (typeof req.headers["x-wallet-address"] !== "string" || req.headers["x-wallet-address"]!.toLowerCase() !== wallet.toLowerCase() || typeof req.headers["x-wallet-signature"] !== "string" || typeof req.headers["x-wallet-message"] !== "string") { + sendJson(res, 403, { error: "Wallet authorization required" }); + return; + } const ledger = await fetchTreasuryLedger(wallet); sendJson(res, 200, { ledger }); return; @@ -146,6 +178,7 @@ const server = http.createServer(async (req, res) => { sendJson(res, 400, { error: "wallet query parameter required" }); return; } + if (!isAddress(wallet)) { sendJson(res, 400, { error: "valid wallet address required" }); return; } let treasury = await treasuryRouter.getByWallet(wallet); if (!treasury && contractAddress.toLowerCase() === wallet.toLowerCase()) { const ensured = await ensureTreasury(); @@ -158,10 +191,9 @@ const server = http.createServer(async (req, res) => { const ledgerMatch = url.pathname.match(/^\/api\/treasury\/([^/]+)\/ledger$/); if (req.method === "GET" && ledgerMatch) { const treasuryId = ledgerMatch[1]; - const treasuryRows = await treasuryRouter.getById(treasuryId); - const treasury = treasuryRows[0]; + const treasury = await authorizedTreasury(treasuryId); if (!treasury) { - sendJson(res, 404, { error: "Treasury not found" }); + sendJson(res, 403, { error: "Treasury authorization required" }); return; } const ledger = await fetchTreasuryLedger(treasury.mainWallet); @@ -172,6 +204,7 @@ const server = http.createServer(async (req, res) => { const subAccountsMatch = url.pathname.match(/^\/api\/treasury\/([^/]+)\/sub-accounts$/); if (subAccountsMatch) { const treasuryId = subAccountsMatch[1]; + if (!(await authorizedTreasury(treasuryId))) { sendJson(res, 403, { error: "Treasury authorization required" }); return; } if (req.method === "GET") { const subAccounts = await treasuryRouter.getSubAccounts(treasuryId); sendJson(res, 200, { subAccounts }); @@ -180,7 +213,7 @@ const server = http.createServer(async (req, res) => { if (req.method === "POST") { const body = await readJsonBody(req); const address = typeof body.address === "string" ? body.address : ""; - if (!address) { + if (!isAddress(address)) { sendJson(res, 400, { error: "address required" }); return; } @@ -199,6 +232,7 @@ const server = http.createServer(async (req, res) => { if (req.method === "GET" && proposalsMatch) { const includeApprovals = url.searchParams.get("includeApprovals") === "1"; const treasuryId = proposalsMatch[1]; + if (!(await authorizedTreasury(treasuryId))) { sendJson(res, 403, { error: "Treasury authorization required" }); return; } const status = url.searchParams.get("status") || undefined; const proposals = includeApprovals ? await transactionRouter.getProposalsWithApprovals(treasuryId, status) @@ -213,6 +247,7 @@ const server = http.createServer(async (req, res) => { sendJson(res, 400, { error: "treasuryId required" }); return; } + if (!(await authorizedTreasury(treasuryId))) { sendJson(res, 403, { error: "Treasury authorization required" }); return; } const status = url.searchParams.get("status") || undefined; const proposals = await transactionRouter.getProposals(treasuryId, status); sendJson(res, 200, { proposals }); @@ -225,6 +260,7 @@ const server = http.createServer(async (req, res) => { sendJson(res, 400, { error: "treasuryId query parameter required" }); return; } + if (!(await authorizedTreasury(treasuryId))) { sendJson(res, 403, { error: "Treasury authorization required" }); return; } const format = url.searchParams.get("format") || "json"; if (format === "csv") { const csv = await exportRouter.exportTransactionsCSV(treasuryId); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 9a0b2b1..2a00b4b 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "module": "commonjs", - "lib": ["ES2021", "DOM"], + "lib": ["ES2022", "DOM"], "outDir": "./dist", "rootDir": "./src", "strict": true, diff --git a/deployment/proxmox/deploy-backend.sh b/deployment/proxmox/deploy-backend.sh index 2f2df47..ee1eede 100755 --- a/deployment/proxmox/deploy-backend.sh +++ b/deployment/proxmox/deploy-backend.sh @@ -84,12 +84,12 @@ pct exec "$VMID" -- bash -c " apt-get update apt-get install -y curl - # Install Node.js 18 - curl -fsSL https://deb.nodesource.com/setup_18.x | bash - + # Install supported Node.js LTS + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt-get install -y nodejs # Install pnpm - npm install -g pnpm + npm install -g pnpm@8.15.0 # Install PostgreSQL client apt-get install -y postgresql-client @@ -176,4 +176,3 @@ echo "2. Update .env with database connection and RPC URL" echo "3. Run database migrations: pct exec $VMID -- bash -c 'cd /opt/solace-backend && pnpm run db:migrate'" echo "4. Start the service: pct exec $VMID -- systemctl start solace-backend" echo "5. Check status: pct exec $VMID -- systemctl status solace-backend" - diff --git a/deployment/proxmox/deploy-frontend.sh b/deployment/proxmox/deploy-frontend.sh index 781210d..d797971 100755 --- a/deployment/proxmox/deploy-frontend.sh +++ b/deployment/proxmox/deploy-frontend.sh @@ -84,12 +84,12 @@ pct exec "$VMID" -- bash -c " apt-get update apt-get install -y curl - # Install Node.js 18 - curl -fsSL https://deb.nodesource.com/setup_18.x | bash - + # Install supported Node.js LTS + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt-get install -y nodejs # Install pnpm - npm install -g pnpm + npm install -g pnpm@8.15.0 " # Create application directory @@ -116,13 +116,14 @@ fi echo "Installing dependencies and building..." pct exec "$VMID" -- bash -c " cd /opt/solace-frontend - export NODE_ENV=production + # Install dev dependencies because Next.js needs Tailwind/PostCSS at build time. + export NODE_ENV=development if [[ -f pnpm-lock.yaml ]]; then pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile else pnpm install --no-frozen-lockfile fi - pnpm run build + NODE_ENV=production pnpm run build " # Create systemd service @@ -171,4 +172,3 @@ echo "1. Copy frontend/.env.production to container: pct push $VMID frontend/.en echo "2. Update .env.production with Chain 138 RPC URL and contract addresses" echo "3. Start the service: pct exec $VMID -- systemctl start solace-frontend" echo "4. Check status: pct exec $VMID -- systemctl status solace-frontend" - diff --git a/deployment/proxmox/deploy-indexer.sh b/deployment/proxmox/deploy-indexer.sh index 908c33f..2e31eb4 100755 --- a/deployment/proxmox/deploy-indexer.sh +++ b/deployment/proxmox/deploy-indexer.sh @@ -82,12 +82,12 @@ pct exec "$VMID" -- bash -c " apt-get update apt-get install -y curl - # Install Node.js 18 - curl -fsSL https://deb.nodesource.com/setup_18.x | bash - + # Install supported Node.js LTS + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt-get install -y nodejs # Install pnpm - npm install -g pnpm + npm install -g pnpm@8.15.0 # Install PostgreSQL client apt-get install -y postgresql-client @@ -170,4 +170,3 @@ echo "2. Update .env.indexer with database connection, RPC URL, and contract add echo "3. Start the service: pct exec $VMID -- systemctl start solace-indexer" echo "4. Check status: pct exec $VMID -- systemctl status solace-indexer" echo "5. View logs: pct exec $VMID -- journalctl -u solace-indexer -f" - diff --git a/frontend/app/(dashboard)/activity/page.tsx b/frontend/app/(dashboard)/activity/page.tsx index 4e8dc35..a373a6f 100644 --- a/frontend/app/(dashboard)/activity/page.tsx +++ b/frontend/app/(dashboard)/activity/page.tsx @@ -18,6 +18,7 @@ import { import { decodeMemoData } from "@/lib/memo"; import { formatTokenAmount, getTokenByAddress } from "@/lib/tokens"; import { CHAIN138_PUBLIC } from "@/lib/web3/chain138-public"; +import { useAccount } from "wagmi"; type ActivityFilter = "all" | "deposits" | "pending" | "executed"; @@ -34,6 +35,7 @@ export default function ActivityPage() { function ActivityContent() { const { treasuryId, loading: treasuryLoading } = useTreasury(); + const { address } = useAccount(); const [filter, setFilter] = useState("all"); const [proposals, setProposals] = useState([]); const [ledger, setLedger] = useState([]); @@ -48,10 +50,10 @@ function ActivityContent() { setLoading(true); const proposalPromise = treasuryId - ? fetchTransactions(treasuryId) + ? fetchTransactions(treasuryId, undefined, address) : Promise.resolve([] as TransactionRecord[]); const ledgerPromise = treasuryId - ? fetchLedger(treasuryId) + ? fetchLedger(treasuryId, address) : treasuryWallet ? fetchLedgerByWallet(treasuryWallet) : Promise.resolve([] as LedgerEntry[]); @@ -62,7 +64,7 @@ function ActivityContent() { setLedger(ledgerResult.status === "fulfilled" ? ledgerResult.value : []); }) .finally(() => setLoading(false)); - }, [treasuryId]); + }, [treasuryId, address]); const deposits = useMemo( () => ledger.filter((entry) => entry.kind === "deposit"), @@ -83,7 +85,7 @@ function ActivityContent() { const handleExport = async () => { if (!treasuryId) return; - const csv = await exportTransactionsCsv(treasuryId); + const csv = await exportTransactionsCsv(treasuryId, address); const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/frontend/app/(dashboard)/approvals/page.tsx b/frontend/app/(dashboard)/approvals/page.tsx index 9488e52..45d170a 100644 --- a/frontend/app/(dashboard)/approvals/page.tsx +++ b/frontend/app/(dashboard)/approvals/page.tsx @@ -9,6 +9,8 @@ import { formatAddress } from "@/lib/utils"; import { formatTokenAmount, getTokenByAddress } from "@/lib/tokens"; import { useTreasury } from "@/lib/hooks/useTreasury"; import { fetchPendingProposals, type TransactionRecord } from "@/lib/api/client"; +import { SignerStatus } from "@/components/web3/SignerStatus"; +import { useTreasuryAccess } from "@/lib/hooks/useTreasuryAccess"; export default function ApprovalsPage() { return ( @@ -24,6 +26,7 @@ export default function ApprovalsPage() { function ApprovalsContent() { const { treasuryId, loading: treasuryLoading } = useTreasury(); const { writeContract } = useWriteContract(); + const { isOwner, chainId, address } = useTreasuryAccess(); const [proposals, setProposals] = useState([]); const [loading, setLoading] = useState(true); @@ -40,14 +43,15 @@ function ApprovalsContent() { } setLoading(true); - fetchPendingProposals(treasuryId) + fetchPendingProposals(treasuryId, address) .then(setProposals) .catch(() => setProposals([])) .finally(() => setLoading(false)); - }, [treasuryId]); + }, [treasuryId, address]); const handleApprove = async (proposalId: number) => { if (!CONTRACT_ADDRESSES.TreasuryWallet) return; + if (!isOwner || chainId !== 138) return; await writeContract({ address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`, @@ -59,6 +63,7 @@ function ApprovalsContent() { const handleExecute = async (proposalId: number) => { if (!CONTRACT_ADDRESSES.TreasuryWallet) return; + if (!isOwner || chainId !== 138) return; await writeContract({ address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`, @@ -73,6 +78,7 @@ function ApprovalsContent() { return (
+
{treasuryLoading || loading ? ( @@ -115,7 +121,8 @@ function ApprovalsContent() { @@ -123,7 +130,8 @@ function ApprovalsContent() { diff --git a/frontend/app/(dashboard)/send/page.tsx b/frontend/app/(dashboard)/send/page.tsx index 920d78b..62586f3 100644 --- a/frontend/app/(dashboard)/send/page.tsx +++ b/frontend/app/(dashboard)/send/page.tsx @@ -8,6 +8,8 @@ import { PageHeader } from "@/components/layout/PageHeader"; import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; import { encodeMemoData } from "@/lib/memo"; import { CHAIN138_TOKENS, ERC20_BALANCE_ABI, formatTokenAmount } from "@/lib/tokens"; +import { SignerStatus } from "@/components/web3/SignerStatus"; +import { useTreasuryAccess } from "@/lib/hooks/useTreasuryAccess"; export default function SendPage() { return ( @@ -24,6 +26,7 @@ function SendForm() { const treasuryAddress = CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}` | undefined; const [selectedToken, setSelectedToken] = useState(CHAIN138_TOKENS[0]); const { writeContract } = useWriteContract(); + const { isOwner, chainId } = useTreasuryAccess(); const isNative = selectedToken.address === "native"; @@ -62,6 +65,7 @@ function SendForm() { if (!isAddress(recipient)) { throw new Error("Invalid recipient address"); } + if (chainId !== 138 || !isOwner) throw new Error("Connected wallet is not an authorized Chain 138 treasury signer"); if (!amount || parseFloat(amount) <= 0) { throw new Error("Invalid amount"); @@ -92,6 +96,7 @@ function SendForm() { return (
+
@@ -160,7 +165,7 @@ function SendForm() {
); } - diff --git a/frontend/app/(dashboard)/transfer/page.tsx b/frontend/app/(dashboard)/transfer/page.tsx index 7f42e52..3962210 100644 --- a/frontend/app/(dashboard)/transfer/page.tsx +++ b/frontend/app/(dashboard)/transfer/page.tsx @@ -8,6 +8,8 @@ import { PageHeader } from "@/components/layout/PageHeader"; import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; import { useTreasury } from "@/lib/hooks/useTreasury"; import { fetchSubAccounts } from "@/lib/api/client"; +import { SignerStatus } from "@/components/web3/SignerStatus"; +import { useTreasuryAccess } from "@/lib/hooks/useTreasuryAccess"; export default function TransferPage() { return ( @@ -29,6 +31,7 @@ function TransferForm() { query: { enabled: Boolean(treasuryAddress) }, }); const { writeContract } = useWriteContract(); + const { isOwner, chainId } = useTreasuryAccess(); const [fromAccount, setFromAccount] = useState("main"); const [toAccount, setToAccount] = useState(""); @@ -39,10 +42,10 @@ function TransferForm() { useEffect(() => { if (!treasuryId) return; - fetchSubAccounts(treasuryId) + fetchSubAccounts(treasuryId, address) .then((accounts) => setSubAccounts(accounts.map((a) => a.address))) .catch(() => setSubAccounts([])); - }, [treasuryId]); + }, [treasuryId, address]); const handleTransfer = async () => { setError(""); @@ -52,6 +55,7 @@ function TransferForm() { if (!toAccount) { throw new Error("Please select destination account"); } + if (chainId !== 138 || !isOwner) throw new Error("Connected wallet is not an authorized Chain 138 treasury signer"); if (!amount || parseFloat(amount) <= 0) { throw new Error("Invalid amount"); @@ -81,6 +85,7 @@ function TransferForm() { return (
+
@@ -148,7 +153,7 @@ function TransferForm() {