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
62 changes: 62 additions & 0 deletions platforms/eCurrency-api/src/controllers/LedgerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions platforms/eCurrency-api/src/database/entities/Ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +79 to +83
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -path "*/platforms/eCurrency-api/src/database/entities/Ledger.ts"

Repository: MetaState-Prototype-Project/prototype

Length of output: 138


🏁 Script executed:

cd platforms/eCurrency-api/src/database/entities && ls -la | grep -i ledger

Repository: MetaState-Prototype-Project/prototype

Length of output: 139


🏁 Script executed:

# List files in the entities directory if it exists
if [ -d "platforms/eCurrency-api/src/database/entities" ]; then
  ls -la platforms/eCurrency-api/src/database/entities/
else
  echo "Path does not exist, searching for Ledger files..."
  find . -name "*Ledger*" -type f 2>/dev/null
fi

Repository: MetaState-Prototype-Project/prototype

Length of output: 557


🏁 Script executed:

# Search for any references to hash and prevHash in entity files
rg "prevHash" --type ts --type js

Repository: MetaState-Prototype-Project/prototype

Length of output: 1705


🏁 Script executed:

# Check git status and recent changes
git ls-files --modified --deleted --others --exclude-standard 2>/dev/null | head -20

Repository: MetaState-Prototype-Project/prototype

Length of output: 63


🏁 Script executed:

# Get repository structure overview
git ls-files | head -30

Repository: MetaState-Prototype-Project/prototype

Length of output: 794


🏁 Script executed:

# Search all TypeScript files for hash or prevHash
rg "hash|prevHash" --type ts -B 2 -A 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 45571


🏁 Script executed:

git log --oneline -20

Repository: MetaState-Prototype-Project/prototype

Length of output: 114


🏁 Script executed:

# Check if we can find the actual PR/change files
git show --name-only 2>/dev/null | head -30

Repository: MetaState-Prototype-Project/prototype

Length of output: 854


🏁 Script executed:

# Look for the specific path mentioned in the review
find . -path "*platforms*" -name "*.ts" 2>/dev/null | head -10

Repository: MetaState-Prototype-Project/prototype

Length of output: 637


🏁 Script executed:

# Broader search - find all entity files
find . -name "*entities*" -type d 2>/dev/null

Repository: MetaState-Prototype-Project/prototype

Length of output: 603


🏁 Script executed:

# Check if there are any ts files at all, and search for hash/prevHash patterns
rg -t ts "hash|prevHash" --max-count 50

Repository: MetaState-Prototype-Project/prototype

Length of output: 14862


🏁 Script executed:

# Look for migration files in the eCurrency-api directory
find platforms/eCurrency-api/src/database/migrations -type f -name "*.ts" 2>/dev/null | head -10

Repository: MetaState-Prototype-Project/prototype

Length of output: 455


🏁 Script executed:

# Search for migration files that mention ledger, hash, or prevHash
rg -t ts "ledger|Ledger" platforms/eCurrency-api/src/database/migrations/ 2>/dev/null | head -20

Repository: MetaState-Prototype-Project/prototype

Length of output: 4061


🏁 Script executed:

# Check if index is already created on hash or prevHash
rg "@Index" platforms/eCurrency-api/src/database/entities/Ledger.ts

Repository: MetaState-Prototype-Project/prototype

Length of output: 131


🏁 Script executed:

# Look for any files mentioning migration in eCurrency-api
rg -t ts "migration|Migration" platforms/eCurrency-api/ --max-count 5

Repository: MetaState-Prototype-Project/prototype

Length of output: 2134


🏁 Script executed:

# Check the LedgerService implementation for hash logic
cat -n platforms/eCurrency-api/src/services/LedgerService.ts | head -100

Repository: MetaState-Prototype-Project/prototype

Length of output: 4530


🏁 Script executed:

# Look for any reference to index creation on hash field
rg -t ts "@Index|createIndex" platforms/eCurrency-api/src/database/

Repository: MetaState-Prototype-Project/prototype

Length of output: 187


🏁 Script executed:

# Check LedgerService for hash computation implementation
grep -n "computeHash\|getPrevHash" platforms/eCurrency-api/src/services/LedgerService.ts

Repository: MetaState-Prototype-Project/prototype

Length of output: 330


🏁 Script executed:

# Also check if there's an update to any migration or initial migration that includes these fields
git log --oneline -20 -- platforms/eCurrency-api/src/database/

Repository: MetaState-Prototype-Project/prototype

Length of output: 114


🏁 Script executed:

# Check if there's a dedicated migration file or if it's inline
find platforms/eCurrency-api -name "*.ts" -type f | xargs grep -l "ALTER TABLE.*ledger\|CREATE TABLE.*ledger" 2>/dev/null

Repository: MetaState-Prototype-Project/prototype

Length of output: 230


🏁 Script executed:

# Look for migration directory or setup
find platforms/eCurrency-api/src -name "*.ts" | xargs grep -l "MigrationInterface" 2>/dev/null

Repository: MetaState-Prototype-Project/prototype

Length of output: 455


🏁 Script executed:

# Check the service implementation in detail
wc -l platforms/eCurrency-api/src/services/LedgerService.ts && echo "" && head -80 platforms/eCurrency-api/src/services/LedgerService.ts

Repository: MetaState-Prototype-Project/prototype

Length of output: 3102


🏁 Script executed:

# Check if there's a database.json or similar config that manages migrations automatically
find platforms/eCurrency-api -name "*.json" | xargs grep -l "migration" 2>/dev/null

Repository: MetaState-Prototype-Project/prototype

Length of output: 117


🏁 Script executed:

# Get git show for the actual changes
git show 68440e0:platforms/eCurrency-api/src/database/entities/Ledger.ts > /tmp/new_ledger.ts
git show 68440e0~1:platforms/eCurrency-api/src/database/entities/Ledger.ts > /tmp/old_ledger.ts 2>/dev/null
diff -u /tmp/old_ledger.ts /tmp/new_ledger.ts || echo "New file or no previous version"

Repository: MetaState-Prototype-Project/prototype

Length of output: 2151


🏁 Script executed:

# Get full diff of the commit
git show 68440e0 | head -200

Repository: MetaState-Prototype-Project/prototype

Length of output: 5242


🏁 Script executed:

# Also check if there are query patterns that would benefit from hash index
rg "where.*hash|find.*hash|query.*hash" platforms/eCurrency-api/src --type ts -A 2 -B 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 63


🏁 Script executed:

# Check if hash is used in any WHERE clauses or lookups
grep -rn "where.*hash\|find.*hash\|query.*hash" platforms/eCurrency-api/src --include="*.ts"

Repository: MetaState-Prototype-Project/prototype

Length of output: 63


🏁 Script executed:

# Check the SQL queries in the latest migration
grep -n "ALTER TABLE\|ADD" platforms/eCurrency-api/src/database/migrations/1765784749012-migration.ts | head -20

Repository: MetaState-Prototype-Project/prototype

Length of output: 279


Database migration for hash/prevHash columns is missing—application will fail at runtime.

The Ledger entity defines hash and prevHash columns and the LedgerService writes to these fields, but the latest database migration (1765784749012-migration.ts) does NOT include ALTER TABLE statements to add these columns. When the application attempts to save a ledger entry with these new fields, it will crash with a "column does not exist" error.

A migration file must be created immediately to add the nullable text columns to the ledger table before deploying this feature:

ALTER TABLE "ledger" ADD "hash" text;
ALTER TABLE "ledger" ADD "prevHash" text;
🤖 Prompt for AI Agents
In platforms/eCurrency-api/src/database/entities/Ledger.ts around lines 79-83,
the entity added nullable text columns `hash` and `prevHash` but there is no DB
migration adding those columns; create a new TypeORM migration file
(timestamped, e.g., 1765784749013-add-ledger-hash-prevHash.ts) that runs SQL to
ALTER TABLE "ledger" ADD "hash" text; and ALTER TABLE "ledger" ADD "prevHash"
text; make sure the migration export up/down functions add and drop those
columns respectively, place it in the migrations folder, commit, and run the
migration before deploying so the application won’t fail with "column does not
exist".


@CreateDateColumn()
createdAt!: Date;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Migration1765809758435 implements MigrationInterface {
name = 'Migration1765809758435'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "ledger" ADD "hash" text`);
await queryRunner.query(`ALTER TABLE "ledger" ADD "prevHash" text`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "prevHash"`);
await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "hash"`);
}

}
1 change: 1 addition & 0 deletions platforms/eCurrency-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
89 changes: 87 additions & 2 deletions platforms/eCurrency-api/src/services/LedgerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ledger>;
Expand All @@ -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<string | null> {
const prev = await this.ledgerRepository.findOne({
where: { currencyId },
order: { createdAt: "DESC", id: "DESC" },
});
return prev?.hash ?? null;
}
Comment on lines +23 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition risk in hash chain creation.

When multiple concurrent addLedgerEntry calls occur for the same currencyId, both can retrieve the same prevHash before either commits, breaking the hash chain integrity. The current implementation has a TOCTOU (time-of-check to time-of-use) vulnerability.

Consider wrapping the entire addLedgerEntry operation in a database transaction with appropriate isolation level (e.g., SERIALIZABLE) or using a row-level lock on the latest entry to ensure sequential hash chain construction.

 private async getPrevHash(currencyId: string): Promise<string | null> {
-    const prev = await this.ledgerRepository.findOne({
+    // NOTE: This should be called within a serializable transaction
+    // to prevent race conditions in hash chain construction
+    const prev = await this.ledgerRepository.findOne({
         where: { currencyId },
         order: { createdAt: "DESC", id: "DESC" },
     });
     return prev?.hash ?? null;
 }

For a proper fix, the addLedgerEntry method should use a transaction:

async addLedgerEntry(...): Promise<Ledger> {
    return await AppDataSource.transaction("SERIALIZABLE", async (manager) => {
        const ledgerRepo = manager.getRepository(Ledger);
        // ... perform all operations within this transaction
    });
}
🤖 Prompt for AI Agents
In platforms/eCurrency-api/src/services/LedgerService.ts around lines 23 to 29,
the getPrevHash + addLedgerEntry flow has a TOCTOU race: concurrent
addLedgerEntry calls can read the same prev hash and break the chain; fix by
running the entire addLedgerEntry logic inside a database transaction with an
isolation level that prevents this (e.g., SERIALIZABLE) or by acquiring a
row-level lock on the latest ledger row for the currency before reading the prev
hash and inserting the new entry; specifically, refactor addLedgerEntry to use
AppDataSource.transaction(...) and use the transaction manager to get the Ledger
repository, read the latest row with a FOR UPDATE/locking mechanism (or rely on
SERIALIZABLE), compute the new hash and save the new ledger record via the
manager so the read+write happen atomically within the transaction.


async getAccountBalance(currencyId: string, accountId: string, accountType: AccountType): Promise<number> {
const latestEntry = await this.ledgerRepository.findOne({
where: {
Expand Down Expand Up @@ -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;
Comment on lines +86 to +110
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Two-phase save lacks atomicity; entry may persist without hash on failure.

The current flow saves the entry first (line 87), then computes and saves the hash (line 109). If the second save fails (e.g., network issue, DB constraint), the ledger entry persists without hash and prevHash, corrupting the hash chain.

Wrap both operations in a single transaction to ensure atomicity:

-    const entry = this.ledgerRepository.create(entryData);
-    const saved = await this.ledgerRepository.save(entry);
-
-    const hashPayload = { ... };
-
-    saved.prevHash = prevHash;
-    saved.hash = this.computeHash(hashPayload);
-
-    const finalized = await this.ledgerRepository.save(saved);
-    return Array.isArray(finalized) ? finalized[0] : finalized;
+    return await AppDataSource.transaction(async (manager) => {
+        const repo = manager.getRepository(Ledger);
+        const entry = repo.create(entryData);
+        const saved = await repo.save(entry);
+
+        const hashPayload = { /* ... fields ... */ };
+        saved.prevHash = prevHash;
+        saved.hash = this.computeHash(hashPayload);
+
+        return await repo.save(saved);
+    });

Additionally, line 110's Array.isArray(finalized) check is unnecessary—TypeORM's save() only returns an array when passed an array, and saved is always a single entity here.

Committable suggestion skipped: line range outside the PR's diff.

}

async transfer(
Expand Down Expand Up @@ -160,6 +197,54 @@ export class LedgerService {
return { debit, credit };
}

async burn(
currencyId: string,
groupId: string,
amount: number,
description?: string
): Promise<Ledger> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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}`;
Expand All @@ -21,8 +54,6 @@ export default function TransactionCard({ transaction, currencyName, onClick }:
mainText = `Sent to ${receiverName}`;
}

const isMinted = transaction.description?.includes("Minted");

return (
<div
className="p-4 hover:bg-gray-50 transition-colors cursor-pointer"
Expand All @@ -34,13 +65,17 @@ export default function TransactionCard({ transaction, currencyName, onClick }:
className={`w-10 h-10 rounded-full flex items-center justify-center ${
isMinted
? "bg-purple-100"
: transaction.type === "credit"
? "bg-green-100"
: "bg-blue-100"
: isBurned
? "bg-red-100"
: transaction.type === "credit"
? "bg-green-100"
: "bg-blue-100"
}`}
>
{isMinted ? (
<Sparkles className="h-5 w-5 text-purple-600" />
) : isBurned ? (
<Flame className="h-5 w-5 text-red-600" />
) : transaction.type === "credit" ? (
<ArrowLeft className="h-5 w-5 text-green-600" />
) : (
Expand Down
Loading