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
45 changes: 16 additions & 29 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,22 @@ model Proposal {
@@index([createdAt])
}

// Event sourcing table for immutable audit trail
model Event {
id String @id @default(cuid())
eventId String @unique // Global unique event identifier
streamId String // Reference to the stream this event relates to
eventType String // CREATE, WITHDRAW, CANCEL, PAUSE, RESUME
payload Json // Complete event data as JSON
timestamp DateTime @default(now())
hash String // SHA-256 hash of this event
previousHash String? // Hash of the previous event in the chain

@@index([streamId])
@@index([eventType])
@@index([timestamp])
}

enum StreamStatus {
ACTIVE
PAUSED
Expand Down Expand Up @@ -479,35 +495,6 @@ enum DisbursementStatus {
}
// ── V3 Fund-Split Tables ──────────────────────────────────────────────────────

// Header record for a single fund-split disbursement
model Disbursement {
id String @id @default(cuid())
senderAddress String @map("sender_address")
totalAmount String @map("total_amount")
asset String
txHash String @unique @map("tx_hash")
createdAt DateTime @default(now()) @map("created_at")

recipients SplitRecipient[]

@@index([senderAddress])
@@index([txHash])
}

// Individual payout row linked to a parent Disbursement
model SplitRecipient {
id String @id @default(cuid())
disbursementId String @map("disbursement_id")
recipientAddress String @map("recipient_address")
amount String
status PayoutStatus @default(PENDING)

disbursement Disbursement @relation(fields: [disbursementId], references: [id])

@@index([disbursementId])
@@index([recipientAddress])
}

enum PayoutStatus {
PENDING
SENT
Expand Down
137 changes: 137 additions & 0 deletions backend/src/__jest__/event-sourcing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { EventSourceService } from "../services/event-source.service.js";
import { HashChainVerificationService } from "../services/hash-chain-verification.service.js";
import { prisma } from "../lib/db.js";

describe("Event Sourcing Hash Chain", () => {
let eventSourceService: EventSourceService;
let hashChainVerificationService: HashChainVerificationService;

beforeEach(async () => {
eventSourceService = new EventSourceService();
hashChainVerificationService = new HashChainVerificationService();

// Clear events table before each test
await prisma.event.deleteMany({});
});

afterAll(async () => {
await prisma.event.deleteMany({});
await prisma.$disconnect();
});

it("should maintain hash chain integrity for 100 events", async () => {
// Create 100 test events
const eventCount = 100;
const streamIds = [`stream-1`, `stream-2`, `stream-3`];
const eventTypes = ["CREATE", "WITHDRAW", "PAUSE", "RESUME", "CANCEL"];

for (let i = 0; i < eventCount; i++) {
const streamId = streamIds[i % streamIds.length];
const eventType = eventTypes[i % eventTypes.length];

await eventSourceService.appendEvent({
streamId,
eventType,
payload: {
sequenceNumber: i,
amount: (Math.random() * 1000000).toString(),
timestamp: new Date().toISOString(),
testData: `Event ${i} for ${streamId}`,
},
});

// Add small delay to ensure timestamp ordering
await new Promise(resolve => setTimeout(resolve, 1));
}

// Verify the entire chain
const verificationResult = await hashChainVerificationService.verifyEventChain();

expect(verificationResult.isValid).toBe(true);
expect(verificationResult.totalEvents).toBe(eventCount);
expect(verificationResult.errors).toHaveLength(0);

// Verify events are properly chained
const events = await prisma.event.findMany({
orderBy: { timestamp: "asc" },
select: { hash: true, previousHash: true },
});

// First event should have null previousHash
expect(events[0].previousHash).toBeNull();

// Each subsequent event should reference the previous event's hash
for (let i = 1; i < events.length; i++) {
expect(events[i].previousHash).toBe(events[i - 1].hash);
}
});

it("should detect tampering in the event chain", async () => {
// Create a few events
await eventSourceService.appendEvent({
streamId: "test-stream",
eventType: "CREATE",
payload: { amount: "1000" },
});

await eventSourceService.appendEvent({
streamId: "test-stream",
eventType: "WITHDRAW",
payload: { amount: "500" },
});

await eventSourceService.appendEvent({
streamId: "test-stream",
eventType: "PAUSE",
payload: { reason: "test" },
});

// Tamper with the middle event's hash
const events = await prisma.event.findMany({
orderBy: { timestamp: "asc" },
});

await prisma.event.update({
where: { id: events[1].id },
data: { hash: "tampered-hash" },
});

// Verification should detect the tampering
const verificationResult = await hashChainVerificationService.verifyEventChain();

expect(verificationResult.isValid).toBe(false);
expect(verificationResult.errors.length).toBeGreaterThan(0);
expect(verificationResult.errors[0]).toContain("Hash chain broken");
});

it("should handle empty event chain", async () => {
const verificationResult = await hashChainVerificationService.verifyEventChain();

expect(verificationResult.isValid).toBe(true);
expect(verificationResult.totalEvents).toBe(0);
expect(verificationResult.errors).toHaveLength(0);
});

it("should verify event range correctly", async () => {
// Create 10 events
const eventIds = [];
for (let i = 0; i < 10; i++) {
const eventId = await eventSourceService.appendEvent({
streamId: "range-test-stream",
eventType: "TEST",
payload: { sequence: i },
});
eventIds.push(eventId);
}

// Verify a range of events (events 3-7)
const verificationResult = await hashChainVerificationService.verifyEventRange(
eventIds[2],
eventIds[6]
);

expect(verificationResult.isValid).toBe(true);
expect(verificationResult.totalEvents).toBe(5);
expect(verificationResult.errors).toHaveLength(0);
});
});
67 changes: 49 additions & 18 deletions backend/src/generated/client/edge.js

Large diffs are not rendered by default.

56 changes: 43 additions & 13 deletions backend/src/generated/client/index-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,17 @@ exports.Prisma.ProposalScalarFieldEnum = {
updatedAt: 'updatedAt'
};

exports.Prisma.EventScalarFieldEnum = {
id: 'id',
eventId: 'eventId',
streamId: 'streamId',
eventType: 'eventType',
payload: 'payload',
timestamp: 'timestamp',
hash: 'hash',
previousHash: 'previousHash'
};

exports.Prisma.OrganizationMemberScalarFieldEnum = {
id: 'id',
orgAddress: 'orgAddress',
Expand Down Expand Up @@ -378,6 +389,10 @@ exports.Prisma.AssetScalarFieldEnum = {
imageUrl: 'imageUrl',
decimals: 'decimals',
isVerified: 'isVerified',
stellarExpertVerified: 'stellarExpertVerified',
tomlUrl: 'tomlUrl',
orgVerified: 'orgVerified',
orgHomeDomain: 'orgHomeDomain',
lastFetchedAt: 'lastFetchedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
Expand Down Expand Up @@ -412,21 +427,32 @@ exports.Prisma.AssetConfigScalarFieldEnum = {
updatedAt: 'updatedAt'
};

exports.Prisma.DisbursementScalarFieldEnum = {
exports.Prisma.ArchivedDisbursementScalarFieldEnum = {
id: 'id',
senderAddress: 'senderAddress',
totalAmount: 'totalAmount',
asset: 'asset',
streamId: 'streamId',
txHash: 'txHash',
createdAt: 'createdAt'
sender: 'sender',
receiver: 'receiver',
amount: 'amount',
tokenAddress: 'tokenAddress',
status: 'status',
completedAt: 'completedAt',
archivedAt: 'archivedAt',
originalLedger: 'originalLedger'
};

exports.Prisma.SplitRecipientScalarFieldEnum = {
exports.Prisma.DisbursementScalarFieldEnum = {
id: 'id',
disbursementId: 'disbursementId',
recipientAddress: 'recipientAddress',
streamId: 'streamId',
txHash: 'txHash',
sender: 'sender',
receiver: 'receiver',
amount: 'amount',
status: 'status'
tokenAddress: 'tokenAddress',
status: 'status',
createdAt: 'createdAt',
completedAt: 'completedAt',
ledger: 'ledger'
};

exports.Prisma.AssetMappingScalarFieldEnum = {
Expand Down Expand Up @@ -554,6 +580,7 @@ exports.Prisma.SplitLinkScalarFieldEnum = {
fullUrl: 'fullUrl',
payloadHash: 'payloadHash',
creatorAddress: 'creatorAddress',
passwordHash: 'passwordHash',
clickCount: 'clickCount',
lastClickedAt: 'lastClickedAt',
expiresAt: 'expiresAt',
Expand Down Expand Up @@ -603,10 +630,12 @@ exports.NotificationPlatform = exports.$Enums.NotificationPlatform = {
telegram: 'telegram'
};

exports.PayoutStatus = exports.$Enums.PayoutStatus = {
exports.DisbursementStatus = exports.$Enums.DisbursementStatus = {
PENDING: 'PENDING',
SENT: 'SENT',
FAILED: 'FAILED'
PROCESSING: 'PROCESSING',
COMPLETED: 'COMPLETED',
FAILED: 'FAILED',
CANCELLED: 'CANCELLED'
};

exports.Prisma.ModelName = {
Expand All @@ -621,6 +650,7 @@ exports.Prisma.ModelName = {
StreamArchive: 'StreamArchive',
BridgeLog: 'BridgeLog',
Proposal: 'Proposal',
Event: 'Event',
OrganizationMember: 'OrganizationMember',
ApiKey: 'ApiKey',
LedgerHash: 'LedgerHash',
Expand All @@ -633,8 +663,8 @@ exports.Prisma.ModelName = {
Asset: 'Asset',
AutopilotSchedule: 'AutopilotSchedule',
AssetConfig: 'AssetConfig',
ArchivedDisbursement: 'ArchivedDisbursement',
Disbursement: 'Disbursement',
SplitRecipient: 'SplitRecipient',
AssetMapping: 'AssetMapping',
PriceHistory: 'PriceHistory',
ProtocolInefficiencyReport: 'ProtocolInefficiencyReport',
Expand Down
Loading
Loading