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
499 changes: 496 additions & 3 deletions backend/package-lock.json

Large diffs are not rendered by default.

152 changes: 152 additions & 0 deletions backend/src/services/reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,4 +499,156 @@ describe("reconciliation API routes", () => {
expect(response.status).toBe(404);
expect(response.body.error).toBe("NO_RECONCILIATION_REPORT");
});
});

// ─── Repair (idempotent drift repair) ─────────────────────────────────────────

describe("ReconciliationService.repair", () => {
it("repairs missing_on_chain by updating local status to released", async () => {
const records = [makeRecord({ taskId: "t1", nodeId: "n1", status: "locked" })];
const paymentDb = makePaymentDb(records);
const service = new ReconciliationService({
paymentDb,
onChainProvider: makeOnChainProvider([]), // no on-chain balance → missing_on_chain
reportStore: makeReportStore().store,
});

const report = await service.repair("manual");

expect(report.discrepancies).toHaveLength(1);
expect(report.discrepancies[0].type).toBe("missing_on_chain");
// Local record should now be "released"
const updated = paymentDb.findByKey("t1", "n1");
expect(updated?.status).toBe("released");
expect(updated?.txHash).toBe("reconciled-repair");
});

it("repairs amount_mismatch when on-chain balance is 0", async () => {
const records = [makeRecord({ taskId: "t2", nodeId: "n2", status: "locked" })];
const paymentDb = makePaymentDb(records);
const service = new ReconciliationService({
paymentDb,
onChainProvider: makeOnChainProvider([
makeBalance({ balanceId: "cb-local-1", amountStroops: "0" }),
]),
reportStore: makeReportStore().store,
});

const report = await service.repair("manual");

expect(report.discrepancies).toHaveLength(1);
expect(report.discrepancies[0].type).toBe("amount_mismatch");
const updated = paymentDb.findByKey("t2", "n2");
expect(updated?.status).toBe("released");
});

it("does not auto-repair missing_local discrepancies", async () => {
const paymentDb = makePaymentDb([]);
const service = new ReconciliationService({
paymentDb,
onChainProvider: makeOnChainProvider([makeBalance()]),
reportStore: makeReportStore().store,
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
});

const report = await service.repair("manual");

expect(report.discrepancies).toHaveLength(1);
expect(report.discrepancies[0].type).toBe("missing_local");
// No local records to update
});

it("repair is idempotent — running twice produces the same result", async () => {
const records = [makeRecord({ taskId: "t3", nodeId: "n3", status: "locked" })];
const paymentDb = makePaymentDb(records);
const service = new ReconciliationService({
paymentDb,
onChainProvider: makeOnChainProvider([]),
reportStore: makeReportStore().store,
});

const report1 = await service.repair("manual");
const report2 = await service.repair("manual");

// Second run: the record is already released, so no missing_on_chain discrepancy
expect(report1.discrepancies).toHaveLength(1);
expect(report2.discrepancies).toHaveLength(0);
expect(report2.status).toBe("consistent");
});

it("returns consistent report when no discrepancies exist", async () => {
const paymentDb = makePaymentDb([makeRecord()]);
const service = new ReconciliationService({
paymentDb,
onChainProvider: makeOnChainProvider([
makeBalance({ balanceId: "cb-local-1" }),
]),
reportStore: makeReportStore().store,
});

const report = await service.repair("manual");

expect(report.status).toBe("consistent");
expect(report.discrepancies).toHaveLength(0);
});
});

// ─── Frequent scheduling (5-minute drift detection) ───────────────────────────

describe("ReconciliationService.startFrequent", () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it("starts a 5-minute interval scheduler", async () => {
const paymentDb = makePaymentDb([]);
const service = new ReconciliationService({
paymentDb,
onChainProvider: makeOnChainProvider([]),
reportStore: makeReportStore().store,
});
const runSpy = jest.spyOn(service, "run").mockResolvedValue({
id: "r-1",
runAt: new Date().toISOString(),
triggeredBy: "scheduled",
status: "consistent",
summary: {
totalLocalRecords: 0,
totalOnChainBalances: 0,
matched: 0,
discrepancies: 0,
missingOnChain: 0,
missingLocal: 0,
amountMismatch: 0,
},
discrepancies: [],
});

service.startFrequent(300_000);

// Advance 5 minutes → should trigger one run
jest.advanceTimersByTime(300_000);
await Promise.resolve(); // flush microtasks

expect(runSpy).toHaveBeenCalledWith("scheduled");

service.stop();
});

it("stop() cancels the scheduler", () => {
const service = new ReconciliationService({
paymentDb: makePaymentDb(),
onChainProvider: makeOnChainProvider([]),
reportStore: makeReportStore().store,
});
service.startFrequent(300_000);
service.stop();

// No timer active after stop
expect((service as any).timer).toBeNull();
});
});
65 changes: 64 additions & 1 deletion backend/src/services/reconciliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
const log = createLogger({ component: 'reconciliation' });

const DEFAULT_DAILY_INTERVAL_MS = 86_400_000; // 24h
const DEFAULT_FREQUENT_INTERVAL_MS = 300_000; // 5 minutes — drift detection SLA
const LIST_BALANCES_MAX_PAGES = 10;
const LIST_BALANCES_PAGE_LIMIT = 200;

Expand Down Expand Up @@ -318,13 +319,75 @@ export class ReconciliationService {
this.logger.info({ intervalMs }, 'Daily reconciliation scheduled');
}

/** Schedule automated (frequent) reconciliation runs for drift detection (default 5 min). */
startFrequent(intervalMs: number = DEFAULT_FREQUENT_INTERVAL_MS): void {
if (this.timer) return;
const tick = async () => {
try {
await this.run('scheduled');
} catch (err) {
this.logger.error({ err }, 'Frequent reconciliation run failed');
}
};
this.timer = setInterval(tick, intervalMs);
this.timer.unref?.();
this.logger.info({ intervalMs }, 'Frequent reconciliation scheduled');
}

/** Stop the automated scheduler. */
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
this.logger.info('Daily reconciliation stopped');
this.logger.info('Reconciliation scheduler stopped');
}
}

/**
* Idempotent repair: for each discrepancy, update the local DB to match
* the on-chain reality. Safe to re-run — produces the same result.
*
* Repair actions:
* - `missing_on_chain` with status `locked` → mark as `released` (balance was claimed externally)
* - `amount_mismatch` with status `locked` and on-chain balance < local → mark as `released` (partial claim)
* - `missing_local` → no action (on-chain-only balances have no local source)
* - All other types → logged, no action (manual review needed)
*/
async repair(triggeredBy: ReconciliationTrigger = 'manual'): Promise<ReconciliationReport> {
const report = await this.run(triggeredBy);
if (report.status === 'consistent') return report;

let repaired = 0;
for (const d of report.discrepancies) {
if (d.type === 'missing_on_chain' && d.taskId && d.nodeId) {
// Balance was claimed on-chain but local still says locked → sync to released
this.paymentDb.updateStatus(d.taskId, d.nodeId, 'released', 'reconciled-repair');
repaired++;
this.logger.info(
{ balanceId: d.balanceId, taskId: d.taskId, nodeId: d.nodeId },
'Repair: updated local status locked → released (missing_on_chain)'
);
} else if (d.type === 'amount_mismatch' && d.taskId && d.nodeId && d.onChainAmountStroops === '0') {
// On-chain balance is 0 but local says locked → was claimed, sync status
this.paymentDb.updateStatus(d.taskId, d.nodeId, 'released', 'reconciled-repair');
repaired++;
this.logger.info(
{ balanceId: d.balanceId, taskId: d.taskId, nodeId: d.nodeId },
'Repair: updated local status locked → released (amount_mismatch, on-chain=0)'
);
} else {
this.logger.warn(
{ discrepancy: d },
'Repair: discrepancy requires manual review — no automatic repair applied'
);
}
}

if (repaired > 0) {
this.logger.info({ repaired, total: report.discrepancies.length }, 'Reconciliation repair complete');
}

return report;
}

/**
Expand Down
76 changes: 76 additions & 0 deletions docs/SECURITY_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Security Checklist — Release Gate

**Every item must be checked before tagging a release.**
Unchecked items must have a tracked issue with owner and ETA.

---

## Pre-Release Gate

- [ ] **T1** — Threat model reviewed: `docs/THREAT_MODEL.md` exists and covers all contracts
- [ ] **T2** — No hardcoded secrets: `STELLAR_SECRET_KEY`, `VENICE_API_KEY`, DB passwords absent from source
- [ ] **T3** — All environment variables validated at startup (fail-fast on missing required vars)
- [ ] **T4** — Input validation: all API endpoints use Zod/schema validation on request body
- [ ] **T5** — SQL injection prevention: all queries use parameterized statements (no string concatenation)
- [ ] **T6** — XSS prevention: frontend HTML is escaped; no `dangerouslySetInnerHTML` on untrusted content
- [ ] **T7** — CSRF: SameSite=Strict cookies on all auth tokens
- [ ] **T8** — Rate limiting: all API endpoints have rate limits configured
- [ ] **T9** — Error messages: no stack traces or internal details leaked to clients
- [ ] **T10** — Logging: no secrets or PII in log output

## Smart Contracts

- [ ] **S1** — Coordinator: `CyclicDAGError` thrown for circular dependency detection
- [ ] **S2** — Coordinator: `handleAgentFailure` retries with fallback agents correctly
- [ ] **S3** — Coordinator: `MAX_RETRIES` (3) enforced per agent before failover
- [ ] **S4** — Payment: `lockEscrow` rejects taskId > 28 bytes (Stellar Memo limit)
- [ ] **S5** — Payment: `releasePayment` atomic claim+pay (single Stellar transaction)
- [ ] **S6** — Payment: `EscrowAlreadySettledError` thrown for double-release attempts
- [ ] **S7** — Payment: `getEscrowBalance` returns 0 for settled balances (not a false positive)
- [ ] **S8** — Registry: `clearRegistry` provides test isolation (no cross-test contamination)

## Fault Injection Tests

- [ ] **F1** — Agent crash: node retries with fallback agent, task completes
- [ ] **F2** — All agents for type fail: node marked failed, dependent nodes cascade-fail
- [ ] **F3** — Provider timeout: abort triggers retry, then fallback
- [ ] **F4** — Horizon 429/504: exponential backoff retries, then fails cleanly
- [ ] **F5** — Horizon 404 on release: `EscrowAlreadySettledError` thrown (not silent)
- [ ] **F6** — Horizon 404 on refund: `EscrowAlreadySettledError` thrown (not silent)

## Reconciliation

- [ ] **R1** — Drift detection runs on configurable interval (default 5 minutes)
- [ ] **R2** — DB records vs on-chain claimable balance status compared
- [ ] **R3** — Drift events logged with full diff (DB status, on-chain status, timestamp)
- [ ] **R4** — Idempotent repair: re-running reconciliation produces same result
- [ ] **R5** — Alert emitted when drift detected (event bus or webhook)

## i18n

- [ ] **I1** — Zero untranslated UI strings (en/zh parity test passes in CI)
- [ ] **I2** — Language switcher persists choice to localStorage
- [ ] **I3** — `<html lang>` attribute synced on language change
- [ ] **I4** — `zh-CN` resolves to `zh` bundle (languageOnly mode active)

## CI/CD

- [ ] **C1** — All tests pass (unit + integration + fault injection)
- [ ] **C2** — Linter passes with zero warnings
- [ ] **C3** — TypeScript compilation succeeds with zero errors
- [ ] **C4** — Test coverage >= 80% for new code
- [ ] **C5** — No new `console.log` or `debug` statements in committed code

---

## Sign-Off

| Item | Owner | Status | Date |
|------|-------|--------|------|
| Threat model | | | |
| Fault injection | | | |
| Reconciliation | | | |
| i18n parity | | | |
| Full checklist | | | |

**Release blocked** if any CRITICAL or HIGH item is unchecked.
Loading