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
24 changes: 24 additions & 0 deletions dependency-cruiser.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// dependency-cruiser.config.js
module.exports = {
forbidden: [
{
name: 'http-ws-cannot-mutate-protocol',
severity: 'error',
comment: 'HTTP and WebSocket layers must never act as protocol authorities or invoke direct contract mutations.',
from: { path: '^src/(controllers|gateways|realtime)/' },
to: { path: '^src/(contracts/mutations|blockchain/signer|authority)/' }
},
{
name: 'projections-strict-read-model',
severity: 'error',
comment: 'Projections and queries must remain read-only event-derived views.',
from: { path: '^src/(projections|queries)/' },
to: { path: '^src/(mutations|commands)/' }
}
],
options: {
doNotFollow: { path: ['node_modules', 'dist'] },
tsConfig: { fileName: 'tsconfig.json' },
reporterOptions: { dot: { collapsePattern: 'node_modules/[^/]+' } }
}
};
51 changes: 51 additions & 0 deletions src/architecture.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// src/architecture.spec.ts
import * as fs from 'fs';
import * as path from 'path';

describe('V2 Architectural Module Boundaries & Dependency Enforcement', () => {
const srcDir = path.resolve(__dirname);

function scanDirectory(dir: string, fileList: string[] = []): string[] {
const files = fs.readdirSync(dir);
files.forEach((file) => {
const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) {
scanDirectory(filePath, fileList);
} else if (filePath.endsWith('.ts') && !filePath.endsWith('.spec.ts')) {
fileList.push(filePath);
}
});
return fileList;
}

it('should prohibit HTTP and WebSocket layers from importing protocol mutation or direct blockchain authority clients', () => {
const allFiles = scanDirectory(srcDir);
const presentationLayers = allFiles.filter(
(f) => f.includes('/controllers/') || f.includes('/gateways/')
);

const forbiddenImports = [
'ethers',
'viem',
'@ethersproject',
'contract-mutator',
'protocol-signer',
];

for (const file of presentationLayers) {
const content = fs.readFileSync(file, 'utf8');
for (const forbidden of forbiddenImports) {
expect(content).not.toContain(forbidden);
}
}
});

it('should enforce that queries and projections modules depend only on read-only read models', () => {
const projectionFiles = scanDirectory(path.join(srcDir, 'projections'));
for (const file of projectionFiles) {
const content = fs.readFileSync(file, 'utf8');
expect(content).not.toContain('MutationService');
expect(content).not.toContain('TransactionSigner');
}
});
});
65 changes: 65 additions & 0 deletions src/auth/siwe-nonce.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// src/auth/siwe-nonce.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { generateNonce } from 'siwe';

@Injectable()
export class SiweNonceService {
private readonly logger = new Logger(SiweNonceService.name);
private readonly domain = process.env.SIWE_DOMAIN || 'truthbounty.app';
private readonly nonceExpiryMinutes = 5;

constructor(private readonly dataSource: DataSource) {}

async issueNonceChallenge(walletAddress: string, uri: string, chainId: number): Promise<{ nonce: string; siweMessage: string }> {
const normalizedWallet = walletAddress.toLowerCase();
const nonce = generateNonce();
const expiresAt = new Date(Date.now() + this.nonceExpiryMinutes * 60 * 1000);

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

try {
// 1. Atomically invalidate any superseded or active nonces for this wallet
await queryRunner.query(
`UPDATE "v2_auth_nonces" SET "used" = TRUE WHERE "wallet_address" = $1 AND "used" = FALSE`,
[normalizedWallet]
);

// 2. Persist new challenge nonce state
await queryRunner.query(
`INSERT INTO "v2_auth_nonces" ("wallet_address", "nonce", "expires_at", "used") VALUES ($1, $2, $3, FALSE)`,
[normalizedWallet, nonce, expiresAt]
);

await queryRunner.commitTransaction();

// 3. Construct exact canonical EIP-4361 message format
const issuedAt = new Date().toISOString();
const statement = 'Sign in to TruthBounty V2 to verify wallet ownership.';

const siweMessage = [
`${this.domain} wants you to sign in with your Ethereum account:`,
normalizedWallet,
'',
statement,
'',
`URI: ${uri}`,
`Version: 1`,
`Chain ID: ${chainId}`,
`Nonce: ${nonce}`,
`Issued At: ${issuedAt}`
].join('\n');

this.logger.log(`Issued cryptographic SIWE nonce for wallet: ${normalizedWallet}`);
return { nonce, siweMessage };
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(`Failed to issue SIWE nonce challenge: ${error.message}`);
throw error;
} finally {
await queryRunner.release();
}
}
}
110 changes: 110 additions & 0 deletions src/auth/wallet-linkage.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// src/auth/wallet-linkage.service.ts
import { Injectable, UnauthorizedException, BadRequestException, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { verifyMessage } from 'ethers';

@Injectable()
export class WalletLinkageService {
private readonly logger = new Logger(WalletLinkageService.name);

constructor(private readonly dataSource: DataSource) {}

async linkWallet(userId: string, walletAddress: string, signature: string, challengeMessage: string): Promise<void> {
const normalizedWallet = walletAddress.toLowerCase();

// 1. Cryptographically verify fresh signature proves ownership of the wallet
try {
const recoveredAddress = verifyMessage(challengeMessage, signature);
if (recoveredAddress.toLowerCase() !== normalizedWallet) {
throw new UnauthorizedException('Signature does not match wallet address.');
}
} catch (error) {
this.logger.warn(`Wallet linking signature verification failed: ${error.message}`);
throw new UnauthorizedException('Invalid cryptographic signature for wallet linkage.');
}

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

try {
// 2. Ensure wallet is not already linked to another active account
const existing = await queryRunner.query(
`SELECT * FROM "v2_user_wallets" WHERE "wallet_address" = $1 AND "unlinked_at" IS NULL`,
[normalizedWallet]
);

if (existing && existing.length > 0) {
throw new BadRequestException('Wallet is already linked to an active user account.');
}

// 3. Persist canonical linkage record with verification timestamp
await queryRunner.query(
`INSERT INTO "v2_user_wallets" ("user_id", "wallet_address", "verified_at", "unlinked_at") VALUES ($1, $2, NOW(), NULL)`,
[userId, normalizedWallet]
);

// 4. Record immutable audit history entry
await queryRunner.query(
`INSERT INTO "v2_audit_logs" ("actor", "action", "metadata") VALUES ($1, $2, $3)`,
[normalizedWallet, 'WALLET_LINKED', JSON.stringify({ userId, walletAddress: normalizedWallet })]
);

await queryRunner.commitTransaction();
this.logger.log(`Successfully linked wallet ${normalizedWallet} to user ${userId}`);
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(`Failed to link wallet: ${error.message}`);
throw error;
} finally {
await queryRunner.release();
}
}

async unlinkWallet(userId: string, walletAddress: string, signature: string, challengeMessage: string): Promise<void> {
const normalizedWallet = walletAddress.toLowerCase();

try {
const recoveredAddress = verifyMessage(challengeMessage, signature);
if (recoveredAddress.toLowerCase() !== normalizedWallet) {
throw new UnauthorizedException('Signature does not match wallet address for unlinking.');
}
} catch (error) {
throw new UnauthorizedException('Invalid cryptographic signature for wallet unlinking.');
}

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

try {
const record = await queryRunner.query(
`SELECT * FROM "v2_user_wallets" WHERE "user_id" = $1 AND "wallet_address" = $2 AND "unlinked_at" IS NULL`,
[userId, normalizedWallet]
);

if (!record || record.length === 0) {
throw new BadRequestException('Active wallet linkage not found for user.');
}

// Atomically set unlinked timestamp
await queryRunner.query(
`UPDATE "v2_user_wallets" SET "unlinked_at" = NOW() WHERE "user_id" = $1 AND "wallet_address" = $2`,
[userId, normalizedWallet]
);

await queryRunner.query(
`INSERT INTO "v2_audit_logs" ("actor", "action", "metadata") VALUES ($1, $2, $3)`,
[normalizedWallet, 'WALLET_UNLINKED', JSON.stringify({ userId, walletAddress: normalizedWallet })]
);

await queryRunner.commitTransaction();
this.logger.log(`Successfully unlinked wallet ${normalizedWallet} from user ${userId}`);
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
83 changes: 83 additions & 0 deletions src/indexer/reorg-safe-cursor.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// src/indexer/reorg-safe-cursor.service.ts
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import { DataSource, QueryRunner } from 'typeorm';

export interface IndexerCoordinate {
chainId: number;
contractAddress: string;
blockNumber: bigint;
blockHash: string;
transactionHash: string;
logIndex: number;
safeBlockNumber: bigint;
finalizedBlockNumber: bigint;
}

@Injectable()
export class ReorgSafeCursorService {
private readonly logger = new Logger(ReorgSafeCursorService.name);

constructor(private readonly dataSource: DataSource) {}

async advanceCursorAtomically(
coordinate: IndexerCoordinate,
projectionUpdates: { entityType: string; entityId: string; stateData: any; version: bigint }[]
): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

try {
// 1. Persist or update reorg-safe event cursor and checkpoint state
await queryRunner.query(
`INSERT INTO "v2_indexer_cursors"
("chain_id", "contract_address", "last_block_number", "block_hash", "transaction_hash", "log_index", "safe_block_number", "finalized_block_number", "updated_at")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT ("chain_id", "contract_address")
DO UPDATE SET
"last_block_number" = EXCLUDED.last_block_number,
"block_hash" = EXCLUDED.block_hash,
"transaction_hash" = EXCLUDED.transaction_hash,
"log_index" = EXCLUDED.log_index,
"safe_block_number" = EXCLUDED.safe_block_number,
"finalized_block_number" = EXCLUDED.finalized_block_number,
"updated_at" = NOW();`,
[
coordinate.chainId,
coordinate.contractAddress.toLowerCase(),
coordinate.blockNumber,
coordinate.blockHash,
coordinate.transactionHash,
coordinate.logIndex,
coordinate.safeBlockNumber,
coordinate.finalizedBlockNumber,
]
);

// 2. Atomically write event-derived projections within the exact same transaction
for (const proj of projectionUpdates) {
await queryRunner.query(
`INSERT INTO "v2_projections" ("entity_type", "entity_id", "state_data", "version", "updated_at")
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT ("entity_type", "entity_id")
DO UPDATE SET
"state_data" = EXCLUDED.state_data,
"version" = EXCLUDED.version,
"updated_at" = NOW();`,
[proj.entityType, proj.entityId, proj.stateData, proj.version]
);
}

await queryRunner.commitTransaction();
this.logger.log(
`Successfully advanced cursor and persisted projections for block ${coordinate.blockNumber} on chain ${coordinate.chainId}`
);
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(`Failed atomic cursor advancement and projection write: ${error.message}`);
throw new InternalServerErrorException('Indexer transaction rolled back due to error.');
} finally {
await queryRunner.release();
}
}
}