diff --git a/platforms/eCurrency-api/src/controllers/LedgerController.ts b/platforms/eCurrency-api/src/controllers/LedgerController.ts index 812b7baa..84bb909a 100644 --- a/platforms/eCurrency-api/src/controllers/LedgerController.ts +++ b/platforms/eCurrency-api/src/controllers/LedgerController.ts @@ -2,14 +2,17 @@ import { Request, Response } from "express"; import { LedgerService } from "../services/LedgerService"; import { AccountType } from "../database/entities/Ledger"; import { GroupService } from "../services/GroupService"; +import { CurrencyService } from "../services/CurrencyService"; export class LedgerController { private ledgerService: LedgerService; private groupService: GroupService; + private currencyService: CurrencyService; constructor() { this.ledgerService = new LedgerService(); this.groupService = new GroupService(); + this.currencyService = new CurrencyService(); } getBalance = async (req: Request, res: Response) => { @@ -206,6 +209,8 @@ export class LedgerController { description: entry.description, balance: entry.balance, createdAt: entry.createdAt, + hash: entry.hash, + prevHash: entry.prevHash, sender: sender ? { name: (sender as any).name || (sender as any).handle, ename: (sender as any).ename, @@ -272,6 +277,8 @@ export class LedgerController { description: transaction.description, balance: transaction.balance, createdAt: transaction.createdAt, + hash: transaction.hash, + prevHash: transaction.prevHash, sender: sender ? { id: sender.id, name: (sender as any).name || (sender as any).handle, @@ -289,6 +296,61 @@ export class LedgerController { } }; + burn = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { currencyId, amount, description } = req.body; + + if (!currencyId || amount === undefined || amount === null) { + return res.status(400).json({ error: "currencyId and amount are required" }); + } + + const parsedAmount = Number(amount); + if (Number.isNaN(parsedAmount) || parsedAmount <= 0) { + return res.status(400).json({ error: "Amount must be a positive number" }); + } + + const currency = await this.currencyService.getCurrencyById(currencyId); + if (!currency) { + return res.status(404).json({ error: "Currency not found" }); + } + + // Only group admins of the currency's group can burn (treasury) + const isAdmin = await this.groupService.isGroupAdmin(currency.groupId, req.user.id); + if (!isAdmin) { + return res.status(403).json({ error: "Only group admins can burn from treasury" }); + } + + const burnEntry = await this.ledgerService.burn( + currencyId, + currency.groupId, + parsedAmount, + description + ); + + res.status(200).json({ + message: "Burn successful", + transaction: { + id: burnEntry.id, + amount: burnEntry.amount, + balance: burnEntry.balance, + createdAt: burnEntry.createdAt, + hash: burnEntry.hash, + prevHash: burnEntry.prevHash, + }, + }); + } catch (error: any) { + console.error("Error burning currency:", error); + if (error.message.includes("Insufficient balance") || error.message.includes("not found")) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Internal server error" }); + } + }; + initializeAccount = async (req: Request, res: Response) => { try { if (!req.user) { diff --git a/platforms/eCurrency-api/src/database/entities/Ledger.ts b/platforms/eCurrency-api/src/database/entities/Ledger.ts index 67b3a6d3..096a11b8 100644 --- a/platforms/eCurrency-api/src/database/entities/Ledger.ts +++ b/platforms/eCurrency-api/src/database/entities/Ledger.ts @@ -76,6 +76,12 @@ export class Ledger { @Column("decimal", { precision: 18, scale: 2 }) balance!: number; // Running balance after this entry + @Column({ type: "text", nullable: true }) + hash!: string | null; + + @Column({ type: "text", nullable: true }) + prevHash!: string | null; + @CreateDateColumn() createdAt!: Date; } diff --git a/platforms/eCurrency-api/src/database/migrations/1765809758435-migration.ts b/platforms/eCurrency-api/src/database/migrations/1765809758435-migration.ts new file mode 100644 index 00000000..8260d249 --- /dev/null +++ b/platforms/eCurrency-api/src/database/migrations/1765809758435-migration.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1765809758435 implements MigrationInterface { + name = 'Migration1765809758435' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ledger" ADD "hash" text`); + await queryRunner.query(`ALTER TABLE "ledger" ADD "prevHash" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "prevHash"`); + await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "hash"`); + } + +} diff --git a/platforms/eCurrency-api/src/index.ts b/platforms/eCurrency-api/src/index.ts index 5a052bdc..4a483f5c 100644 --- a/platforms/eCurrency-api/src/index.ts +++ b/platforms/eCurrency-api/src/index.ts @@ -118,6 +118,7 @@ app.patch("/api/currencies/:id/max-negative", authGuard, currencyController.upda app.get("/api/ledger/balance", authGuard, ledgerController.getBalance); app.get("/api/ledger/balance/:currencyId", authGuard, ledgerController.getBalanceByCurrencyId); app.post("/api/ledger/transfer", authGuard, ledgerController.transfer); +app.post("/api/ledger/burn", authGuard, ledgerController.burn); app.get("/api/ledger/history", authGuard, ledgerController.getHistory); app.get("/api/ledger/history/:currencyId", authGuard, ledgerController.getHistory); app.get("/api/ledger/transaction/:id", authGuard, ledgerController.getTransactionById); diff --git a/platforms/eCurrency-api/src/services/LedgerService.ts b/platforms/eCurrency-api/src/services/LedgerService.ts index 0f0b6422..c6cd693c 100644 --- a/platforms/eCurrency-api/src/services/LedgerService.ts +++ b/platforms/eCurrency-api/src/services/LedgerService.ts @@ -5,6 +5,7 @@ import { Currency } from "../database/entities/Currency"; import { User } from "../database/entities/User"; import { Group } from "../database/entities/Group"; import { TransactionNotificationService } from "./TransactionNotificationService"; +import crypto from "crypto"; export class LedgerService { ledgerRepository: Repository; @@ -15,6 +16,18 @@ export class LedgerService { this.currencyRepository = AppDataSource.getRepository(Currency); } + private computeHash(payload: any): string { + return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"); + } + + private async getPrevHash(currencyId: string): Promise { + const prev = await this.ledgerRepository.findOne({ + where: { currencyId }, + order: { createdAt: "DESC", id: "DESC" }, + }); + return prev?.hash ?? null; + } + async getAccountBalance(currencyId: string, accountId: string, accountType: AccountType): Promise { const latestEntry = await this.ledgerRepository.findOne({ where: { @@ -67,10 +80,34 @@ export class LedgerService { balance: newBalance, }; - const entry = this.ledgerRepository.create(entryData); + // capture previous hash before inserting current entry + const prevHash = await this.getPrevHash(currencyId); + const entry = this.ledgerRepository.create(entryData); const saved = await this.ledgerRepository.save(entry); - return Array.isArray(saved) ? saved[0] : saved; + + const hashPayload = { + id: saved.id, + currencyId: saved.currencyId, + accountId: saved.accountId, + accountType: saved.accountType, + amount: saved.amount, + type: saved.type, + description: saved.description, + senderAccountId: saved.senderAccountId, + senderAccountType: saved.senderAccountType, + receiverAccountId: saved.receiverAccountId, + receiverAccountType: saved.receiverAccountType, + balance: saved.balance, + createdAt: saved.createdAt, + prevHash, + }; + + saved.prevHash = prevHash; + saved.hash = this.computeHash(hashPayload); + + const finalized = await this.ledgerRepository.save(saved); + return Array.isArray(finalized) ? finalized[0] : finalized; } async transfer( @@ -160,6 +197,54 @@ export class LedgerService { return { debit, credit }; } + async burn( + currencyId: string, + groupId: string, + amount: number, + description?: string + ): Promise { + const currency = await this.currencyRepository.findOne({ where: { id: currencyId } }); + if (!currency) { + throw new Error("Currency not found"); + } + + // Ensure treasury account exists + await this.initializeAccount(currencyId, groupId, AccountType.GROUP); + + const currentBalance = await this.getAccountBalance(currencyId, groupId, AccountType.GROUP); + + // Enforce bounds + if (!currency.allowNegative && currentBalance < amount) { + throw new Error("Insufficient balance. Negative balances are not allowed."); + } + + if (currency.allowNegative && currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined) { + const newBalance = currentBalance - amount; + if (newBalance < Number(currency.maxNegativeBalance)) { + throw new Error(`Insufficient balance. This currency allows negative balances down to ${currency.maxNegativeBalance}.`); + } + } + + const burnDescription = description || `Burned ${amount} ${currency.name}`; + + // Single debit entry against treasury + const debit = await this.addLedgerEntry( + currencyId, + groupId, + AccountType.GROUP, + amount, + LedgerType.DEBIT, + burnDescription, + groupId, + AccountType.GROUP, + undefined, + undefined, + currentBalance + ); + + return debit; + } + async getTransactionHistory( currencyId?: string, accountId?: string, diff --git a/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx b/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx index 034a152e..609d0a3a 100644 --- a/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx +++ b/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx @@ -72,7 +72,13 @@ export default function AddCurrencyAccountModal({ open, onOpenChange, accountCon return response.data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: balancesQueryKey }); + // Refresh balances and history for the active context right after adding + queryClient.invalidateQueries({ + predicate: (q) => { + const key = q.queryKey?.[0]; + return key === "balances" || key === "history" || key === "accountDetails"; + }, + }); setCurrencyId(""); setSearchQuery(""); setIsOpen(false); diff --git a/platforms/eCurrency/client/src/components/currency/transaction-card.tsx b/platforms/eCurrency/client/src/components/currency/transaction-card.tsx index a27ba343..465d9247 100644 --- a/platforms/eCurrency/client/src/components/currency/transaction-card.tsx +++ b/platforms/eCurrency/client/src/components/currency/transaction-card.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react"; +import { ArrowLeft, ArrowRight, Sparkles, Flame } from "lucide-react"; import { formatEName } from "../../lib/utils"; interface TransactionCardProps { @@ -9,10 +9,43 @@ interface TransactionCardProps { export default function TransactionCard({ transaction, currencyName, onClick }: TransactionCardProps) { // For credits: show "Received from X", for debits: show "Sent to X" - // For minted: show "Minted" + // For minted: show "Minted", for burned: show "Burned" + // For account initialization: show "Account Created" let mainText = ""; - if (transaction.description?.includes("Minted")) { + const desc = transaction.description?.toLowerCase() || ""; + const balance = Number(transaction.balance) || 0; + const amount = Math.abs(Number(transaction.amount)) || 0; + + // Check description for mint/burn keywords + const hasMintInDesc = desc.includes("minted") || desc.includes("mint"); + const hasBurnInDesc = desc.includes("burned") || desc.includes("burn"); + const hasInitInDesc = desc.includes("initialized") || desc.includes("account created"); + + // Account initialization: balance is 0, amount is 0 (or very close), no sender + const isAccountInit = balance === 0 && amount < 0.01 && + (!transaction.sender || (!transaction.sender.name && !transaction.sender.ename)) && + (hasInitInDesc || transaction.description === "Account initialized"); + + // Fallback: credit with no sender and group account type is likely a mint (but not if balance is 0) + const isLikelyMint = transaction.type === "credit" && + (!transaction.sender || (!transaction.sender.name && !transaction.sender.ename)) && + transaction.accountType === "group" && + balance > 0; // Only consider it a mint if balance is positive + + // Debit with no receiver and group account type is likely a burn + const isLikelyBurn = transaction.type === "debit" && + (!transaction.receiver || (!transaction.receiver.name && !transaction.receiver.ename)) && + transaction.accountType === "group"; + + const isMinted = hasMintInDesc || isLikelyMint; + const isBurned = hasBurnInDesc || isLikelyBurn; + + if (isAccountInit) { + mainText = "Account Created"; + } else if (isMinted) { mainText = "Minted"; + } else if (isBurned) { + mainText = "Burned"; } else if (transaction.type === "credit") { const senderName = transaction.sender?.name || formatEName(transaction.sender?.ename) || "Unknown"; mainText = `Received from ${senderName}`; @@ -21,8 +54,6 @@ export default function TransactionCard({ transaction, currencyName, onClick }: mainText = `Sent to ${receiverName}`; } - const isMinted = transaction.description?.includes("Minted"); - return (
{isMinted ? ( + ) : isBurned ? ( + ) : transaction.type === "credit" ? ( ) : ( diff --git a/platforms/eCurrency/client/src/components/currency/transaction-detail-modal.tsx b/platforms/eCurrency/client/src/components/currency/transaction-detail-modal.tsx index 13890982..10fd7afd 100644 --- a/platforms/eCurrency/client/src/components/currency/transaction-detail-modal.tsx +++ b/platforms/eCurrency/client/src/components/currency/transaction-detail-modal.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/apiClient"; import { X } from "lucide-react"; import { formatEName } from "@/lib/utils"; +import { useState } from "react"; interface TransactionDetailModalProps { open: boolean; @@ -14,6 +15,7 @@ export default function TransactionDetailModal({ onOpenChange, transactionId, }: TransactionDetailModalProps) { + const [copiedField, setCopiedField] = useState<"hash" | "prevHash" | null>(null); const { data: transaction } = useQuery({ queryKey: ["transaction", transactionId], queryFn: async () => { @@ -151,6 +153,50 @@ export default function TransactionDetailModal({ {new Date(transaction.createdAt).toLocaleString()}

+ + {/* Hashes (conditional) */} + {(transaction.hash || transaction.prevHash) && ( +
+ {transaction.hash && ( +
+
+

Transaction Hash

+ +
+

{transaction.hash}

+
+ )} + {transaction.prevHash && ( +
+
+

Previous Hash

+ +
+

{transaction.prevHash}

+
+ )} +
+ )} ) : (
diff --git a/platforms/eCurrency/client/src/pages/currency-detail.tsx b/platforms/eCurrency/client/src/pages/currency-detail.tsx index b82819f7..3b63fe78 100644 --- a/platforms/eCurrency/client/src/pages/currency-detail.tsx +++ b/platforms/eCurrency/client/src/pages/currency-detail.tsx @@ -6,7 +6,7 @@ import TransferModal from "../components/currency/transfer-modal"; import TransactionDetailModal from "../components/currency/transaction-detail-modal"; import MintCurrencyModal from "../components/currency/mint-currency-modal"; import UserMenuDropdown from "../components/user-menu-dropdown"; -import { Send, Wallet, Sparkles, ChevronLeft } from "lucide-react"; +import { Send, Wallet, Sparkles, ChevronLeft, Flame, X } from "lucide-react"; import { useState, useEffect } from "react"; import { formatEName } from "../lib/utils"; import TransactionCard from "../components/currency/transaction-card"; @@ -18,6 +18,11 @@ export default function CurrencyDetail() { const queryClient = useQueryClient(); const [transferOpen, setTransferOpen] = useState(false); const [mintOpen, setMintOpen] = useState(false); + const [burnOpen, setBurnOpen] = useState(false); + const [burnAmount, setBurnAmount] = useState(""); + const [burnReason, setBurnReason] = useState(""); + const [burnError, setBurnError] = useState(null); + const [burnSaving, setBurnSaving] = useState(false); const [selectedTransactionId, setSelectedTransactionId] = useState(null); const [transactionOffset, setTransactionOffset] = useState(0); const [allTransactions, setAllTransactions] = useState([]); @@ -135,12 +140,23 @@ export default function CurrencyDetail() { const handleAccountContextChange = (context: { type: "user" | "group"; id: string } | null) => { // If null is passed, default to user account const finalContext = context || (user ? { type: "user" as const, id: user.id } : null); + + // Check if context actually changed + const contextChanged = !accountContext || + accountContext.type !== finalContext?.type || + accountContext.id !== finalContext?.id; + setAccountContext(finalContext); if (finalContext) { localStorage.setItem("ecurrency_account_context", JSON.stringify(finalContext)); } else { localStorage.removeItem("ecurrency_account_context"); } + + // Navigate to dashboard when context changes + if (contextChanged) { + setLocation("/"); + } }; const { data: totalSupplyData, isLoading: totalSupplyLoading } = useQuery({ @@ -234,6 +250,42 @@ export default function CurrencyDetail() { return
Currency not found
; } + const handleBurn = async () => { + if (!currencyId) return; + setBurnError(null); + setBurnSaving(true); + try { + const amountNum = parseFloat(burnAmount); + if (Number.isNaN(amountNum) || amountNum <= 0) { + setBurnError("Enter a valid amount greater than zero."); + setBurnSaving(false); + return; + } + + await apiClient.post("/api/ledger/burn", { + currencyId, + amount: amountNum, + description: burnReason || undefined, + }); + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["balance", currencyId, accountContext] }), + queryClient.invalidateQueries({ queryKey: ["balances", accountContext] }), + queryClient.invalidateQueries({ queryKey: ["history", currencyId, accountContext, transactionOffset] }), + queryClient.invalidateQueries({ queryKey: ["totalSupply", currencyId] }), + ]); + + setBurnAmount(""); + setBurnReason(""); + setBurnOpen(false); + } catch (error: any) { + const message = error?.response?.data?.error || error?.message || "Burn failed"; + setBurnError(message); + } finally { + setBurnSaving(false); + } + }; + return (
{/* Header - Same as dashboard */} @@ -375,13 +427,23 @@ export default function CurrencyDetail() {

Transactions

{isAdminOfCurrency && accountContext?.type === "group" && accountContext.id === currency.groupId && ( - + <> + + + )} +
+
+
+ + setBurnAmount(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + placeholder="0.00" + /> +
+
+ + setBurnReason(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + placeholder="e.g., supply reduction" + /> +
+ {burnError && ( +
+ {burnError} +
+ )} +
+ + +
+
+
+
+ )} ); }