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
8 changes: 8 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ export const stellarEventStore = pgPool
: undefined;
if (pgPool) app.decorate("pg", pgPool);

import { AuditAnchorWorker } from "./lib/workers/auditAnchorWorker.js";
export const auditAnchorWorker = pgPool ? new AuditAnchorWorker(pgPool) : undefined;
if (auditAnchorWorker) {
auditAnchorWorker.start();
}

// Echo the request ID back to the client so a failed call can be traced
// in the logs — see docs/request-tracing.md.
app.addHook("onRequest", async (req, reply) => {
Expand Down Expand Up @@ -412,6 +418,8 @@ app.register(reputationRoutes, { prefix: "/api/v1" });
app.register(providerRoutes, { prefix: "/api/v1" });
app.register(adminRoutes, { prefix: "/api/v1" });
app.register(sessionRoutes, { prefix: "/api/v1" });
import { auditVaultRoutes } from "./routes/audit-vault.js";
app.register(auditVaultRoutes, { prefix: "/api/v1" });
app.register(sessionRotationRoutes, { prefix: "/api/v1" });
app.register(ratesRoutes, { prefix: "/api/v1" });
app.register(statusRoutes, { prefix: "/api/v1" });
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/db/migrations/023_add_merkle_audit_vault.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TABLE audit_hash_chain (
sequence_id BIGSERIAL PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
payload_hash VARCHAR(64) NOT NULL,
prev_hash VARCHAR(64) NOT NULL,
curr_hash VARCHAR(64) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE audit_roots (
block_index BIGINT PRIMARY KEY,
start_sequence BIGINT NOT NULL,
end_sequence BIGINT NOT NULL,
merkle_root VARCHAR(64) NOT NULL,
tx_hash VARCHAR(128) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
42 changes: 42 additions & 0 deletions apps/api/src/lib/audit/__tests__/hash-chain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { computeMerkleRoot, computeMerkleProof, verifyMerkleProof } from "../merkle-aggregator.js";

describe("Merkle Aggregator", () => {
it("should compute valid root for single hash", () => {
const hashes = ["a"];
const root = computeMerkleRoot(hashes);
expect(root).toBe("a");
});

it("should compute consistent root for multiple hashes", () => {
const hashes = ["a", "b", "c", "d"];
const root = computeMerkleRoot(hashes);
expect(root).toBeTruthy();
expect(typeof root).toBe("string");
});

it("should generate and verify merkle proof", () => {
const hashes = Array.from({ length: 16 }, (_, i) => `hash${i}`);
const root = computeMerkleRoot(hashes);

const leafIndex = 5;
const leafHash = hashes[leafIndex];
const proof = computeMerkleProof(hashes, leafIndex);

expect(proof.length).toBeGreaterThan(0);

const isValid = verifyMerkleProof(leafHash, root, proof, leafIndex);
expect(isValid).toBe(true);
});

it("should fail verification with wrong leaf", () => {
const hashes = Array.from({ length: 8 }, (_, i) => `hash${i}`);
const root = computeMerkleRoot(hashes);

const leafIndex = 3;
const proof = computeMerkleProof(hashes, leafIndex);

const isValid = verifyMerkleProof("wrongHash", root, proof, leafIndex);
expect(isValid).toBe(false);
});
});
51 changes: 51 additions & 0 deletions apps/api/src/lib/audit/hash-chain-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Pool } from "pg";
import { createHash } from "node:crypto";
import { AuditLogEvent } from "@velo/shared";

export class HashChainEngine {
constructor(private pool: Pool) {}

async appendEvent(eventType: string, payload: Record<string, any>): Promise<AuditLogEvent> {
const payloadStr = JSON.stringify(payload);
const payloadHash = createHash("sha256").update(payloadStr).digest("hex");

const client = await this.pool.connect();
try {
await client.query("BEGIN");

// Get the previous hash. If table is empty, use 64 zeros.
// Using FOR UPDATE to serialize inserts and prevent gaps/race conditions.
const { rows } = await client.query(
"SELECT curr_hash FROM audit_hash_chain ORDER BY sequence_id DESC LIMIT 1 FOR UPDATE"
);
const prevHash = rows.length > 0 ? rows[0].curr_hash : "0".repeat(64);

const currHash = createHash("sha256")
.update(payloadHash + prevHash)
.digest("hex");

const insertRes = await client.query(
`INSERT INTO audit_hash_chain (event_type, payload_hash, prev_hash, curr_hash)
VALUES ($1, $2, $3, $4)
RETURNING sequence_id, created_at`,
[eventType, payloadHash, prevHash, currHash]
);

await client.query("COMMIT");

return {
sequenceId: insertRes.rows[0].sequence_id,
eventType,
payloadHash,
prevHash,
currHash,
createdAt: insertRes.rows[0].created_at.toISOString()
};
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
}
59 changes: 59 additions & 0 deletions apps/api/src/lib/audit/merkle-aggregator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createHash } from "node:crypto";

export function computeMerkleRoot(hashes: string[]): string {
if (hashes.length === 0) return "0".repeat(64);
if (hashes.length === 1) return hashes[0];

const nextLevel: string[] = [];
for (let i = 0; i < hashes.length; i += 2) {
const left = hashes[i];
const right = i + 1 < hashes.length ? hashes[i + 1] : left;
const combined = createHash("sha256").update(left + right).digest("hex");
nextLevel.push(combined);
}
return computeMerkleRoot(nextLevel);
}

export function computeMerkleProof(hashes: string[], index: number): string[] {
if (hashes.length <= 1) return [];

const proof: string[] = [];
let currentIndex = index;
let currentLevel = hashes;

while (currentLevel.length > 1) {
const nextLevel: string[] = [];
for (let i = 0; i < currentLevel.length; i += 2) {
const left = currentLevel[i];
const right = i + 1 < currentLevel.length ? currentLevel[i + 1] : left;

if (i === currentIndex || i + 1 === currentIndex) {
const sibling = i === currentIndex ? right : left;
proof.push(sibling);
}

const combined = createHash("sha256").update(left + right).digest("hex");
nextLevel.push(combined);
}
currentIndex = Math.floor(currentIndex / 2);
currentLevel = nextLevel;
}

return proof;
}

export function verifyMerkleProof(leafHash: string, root: string, proof: string[], index: number): boolean {
let currentHash = leafHash;
let currentIndex = index;

for (const sibling of proof) {
if (currentIndex % 2 === 0) {
currentHash = createHash("sha256").update(currentHash + sibling).digest("hex");
} else {
currentHash = createHash("sha256").update(sibling + currentHash).digest("hex");
}
currentIndex = Math.floor(currentIndex / 2);
}

return currentHash === root;
}
62 changes: 62 additions & 0 deletions apps/api/src/lib/audit/proof-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Pool } from "pg";
import { AuditInclusionProof } from "@velo/shared";
import { computeMerkleRoot, computeMerkleProof } from "./merkle-aggregator.js";

export class ProofGenerator {
constructor(private pool: Pool) {}

async generateProof(sequenceIdStr: string): Promise<AuditInclusionProof> {
const sequenceId = BigInt(sequenceIdStr);
// Each block is 1000 events
const blockIndex = (sequenceId - 1n) / 1000n;
const startSequence = blockIndex * 1000n + 1n;
const endSequence = startSequence + 999n;

const client = await this.pool.connect();
try {
// Check if block is anchored
const rootRes = await client.query(
"SELECT merkle_root, tx_hash FROM audit_roots WHERE block_index = $1",
[blockIndex.toString()]
);

if (rootRes.rows.length === 0) {
throw new Error("Audit root not yet anchored for this event");
}

const { merkle_root: root, tx_hash: txHash } = rootRes.rows[0];

// Fetch the entire block
const blockRes = await client.query(
"SELECT sequence_id, curr_hash FROM audit_hash_chain WHERE sequence_id >= $1 AND sequence_id <= $2 ORDER BY sequence_id ASC",
[startSequence.toString(), endSequence.toString()]
);

if (blockRes.rows.length === 0) {
throw new Error("Events not found in block");
}

const hashes = blockRes.rows.map(r => r.curr_hash);
const computedRoot = computeMerkleRoot(hashes);

// Find index of requested event
const leafIndex = blockRes.rows.findIndex(r => r.sequence_id === sequenceIdStr);
if (leafIndex === -1) {
throw new Error("Event not found in fetched block");
}

const proof = computeMerkleProof(hashes, leafIndex);

return {
eventId: sequenceIdStr,
merkleRoot: root,
proof,
leafIndex,
stellarTxHash: txHash,
verified: computedRoot === root
};
} finally {
client.release();
}
}
}
50 changes: 50 additions & 0 deletions apps/api/src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1807,3 +1807,53 @@ export async function getRotationProposal(
);
return proposal ?? null;
}

export async function anchorAuditRoot(contractId: string, sequence: number, rootHex: string): Promise<string> {
const signer = loadSignerKeypair();
const account = await server.getAccount(signer.publicKey());
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: NETWORK_PASSPHRASE,
})
.addOperation(
Operation.invokeContractFunction({
contract: contractId,
function: "anchor_audit_root",
args: [
nativeToScVal(BigInt(sequence), { type: "u64" }),
nativeToScVal(Buffer.from(rootHex, "hex"), { type: "bytes" })
],
})
)
.setTimeout(30)
.build();

const sim = await server.simulateTransaction(tx);
if (Api.isSimulationError(sim)) {
throw new Error(`simulation failed: ${sim.error}`);
}

const prepared = assembleTransaction(tx, sim).build();
prepared.sign(signer);

const sendResult = await server.sendTransaction(prepared);
if (sendResult.status === "ERROR") {
throw new Error(`submission failed: ${JSON.stringify(sendResult.errorResult)}`);
}

let getResult = await server.getTransaction(sendResult.hash);
const start = Date.now();
while (getResult.status === Api.GetTransactionStatus.NOT_FOUND) {
if (Date.now() - start > 30_000) {
throw new Error(`timed out waiting for tx ${sendResult.hash} to confirm`);
}
await new Promise((r) => setTimeout(r, 1500));
getResult = await server.getTransaction(sendResult.hash);
}

if (getResult.status !== Api.GetTransactionStatus.SUCCESS) {
throw new Error(`tx ${sendResult.hash} failed with status ${getResult.status}`);
}

return sendResult.hash;
}
Loading
Loading