diff --git a/packages/agents-api/src/db/database.ts b/packages/agents-api/src/db/database.ts index 7da36b1..b872612 100644 --- a/packages/agents-api/src/db/database.ts +++ b/packages/agents-api/src/db/database.ts @@ -198,18 +198,27 @@ export class AgentsDatabase { } } - // Compute timing metrics + // Compute timing metrics (only if timestamps are logically ordered and result is non-negative) if (escrow.fundedAt && existing?.created_at) { - updates.push('time_to_fund = ?') - values.push(escrow.fundedAt - existing.created_at) + const timeToFund = escrow.fundedAt - existing.created_at + if (timeToFund >= 0) { + updates.push('time_to_fund = ?') + values.push(timeToFund) + } } if (escrow.releasedAt && existing?.funded_at) { - updates.push('time_to_release = ?') - values.push(escrow.releasedAt - existing.funded_at) + const timeToRelease = escrow.releasedAt - existing.funded_at + if (timeToRelease >= 0) { + updates.push('time_to_release = ?') + values.push(timeToRelease) + } } if (escrow.claimedAt && existing?.released_at) { - updates.push('time_to_claim = ?') - values.push(escrow.claimedAt - existing.released_at) + const timeToClaim = escrow.claimedAt - existing.released_at + if (timeToClaim >= 0) { + updates.push('time_to_claim = ?') + values.push(timeToClaim) + } } if (updates.length > 0) { @@ -634,6 +643,61 @@ export class AgentsDatabase { `).get() as { avg_to_fund: number | null; avg_to_release: number | null; avg_to_claim: number | null } } + /** + * Clean up invalid timing metrics in existing escrows. + * Recalculates time_to_fund, time_to_release, time_to_claim based on actual timestamps. + * Sets metrics to NULL if timestamps are missing or would result in negative values. + * @returns Number of escrows fixed + */ + cleanupInvalidTimingMetrics(): number { + // Fix time_to_fund: should be funded_at - created_at, must be >= 0 + const fixFund = this.db.prepare(` + UPDATE escrows SET time_to_fund = CASE + WHEN funded_at IS NOT NULL AND created_at IS NOT NULL AND funded_at >= created_at + THEN funded_at - created_at + ELSE NULL + END + WHERE time_to_fund IS NOT NULL AND ( + time_to_fund < 0 OR + funded_at IS NULL OR + created_at IS NULL OR + time_to_fund != (funded_at - created_at) + ) + `).run() + + // Fix time_to_release: should be released_at - funded_at, must be >= 0 + const fixRelease = this.db.prepare(` + UPDATE escrows SET time_to_release = CASE + WHEN released_at IS NOT NULL AND funded_at IS NOT NULL AND released_at >= funded_at + THEN released_at - funded_at + ELSE NULL + END + WHERE time_to_release IS NOT NULL AND ( + time_to_release < 0 OR + released_at IS NULL OR + funded_at IS NULL OR + time_to_release != (released_at - funded_at) + ) + `).run() + + // Fix time_to_claim: should be claimed_at - released_at, must be >= 0 + const fixClaim = this.db.prepare(` + UPDATE escrows SET time_to_claim = CASE + WHEN claimed_at IS NOT NULL AND released_at IS NOT NULL AND claimed_at >= released_at + THEN claimed_at - released_at + ELSE NULL + END + WHERE time_to_claim IS NOT NULL AND ( + time_to_claim < 0 OR + claimed_at IS NULL OR + released_at IS NULL OR + time_to_claim != (claimed_at - released_at) + ) + `).run() + + return fixFund.changes + fixRelease.changes + fixClaim.changes + } + close() { this.db.close() } diff --git a/packages/agents-api/test/indexer.test.ts b/packages/agents-api/test/indexer.test.ts index d1cd684..31cc54e 100644 --- a/packages/agents-api/test/indexer.test.ts +++ b/packages/agents-api/test/indexer.test.ts @@ -117,4 +117,51 @@ describe('Database + Reputation integration', () => { const count = db.countEscrows({ seller: '0xseller' }) expect(count).toBe(bySeller.length) }) + + it('prevents negative timing metrics from being stored', () => { + // Create an escrow with a created_at timestamp + db.upsertEscrow({ + id: 100, chainId: 8453, + seller: '0xtest', amount: '1000', + state: 'created', createdAt: 2000000000, // Future timestamp + sellerAgentId: 1, + }) + + // Try to fund it with an earlier timestamp (would cause negative time_to_fund) + db.upsertEscrow({ + id: 100, chainId: 8453, + buyer: '0xbuyer', buyerAgentId: 2, + state: 'funded', fundedAt: 1999999000, // Before created_at + }) + + const escrow = db.getEscrow(100)! + // time_to_fund should NOT be set (or should be null) because it would be negative + expect(escrow.time_to_fund).toBeNull() + }) + + it('cleanupInvalidTimingMetrics fixes bad data', () => { + // Manually insert an escrow with bad timing via raw SQL + // (simulating legacy data that bypassed validation) + const stmt = (db as any).db.prepare(` + INSERT INTO escrows (id, chain_id, seller, state, created_at, funded_at, time_to_fund, time_to_release) + VALUES (200, 8453, '0xbaddata', 'funded', 1000, 2000, -500, -1000) + `) + stmt.run() + + // Verify the bad data exists + let escrow = db.getEscrow(200)! + expect(escrow.time_to_fund).toBe(-500) + expect(escrow.time_to_release).toBe(-1000) + + // Run cleanup + const fixed = db.cleanupInvalidTimingMetrics() + expect(fixed).toBeGreaterThan(0) + + // Verify timing metrics are now valid + escrow = db.getEscrow(200)! + // time_to_fund should be recalculated to 1000 (funded_at - created_at) + expect(escrow.time_to_fund).toBe(1000) + // time_to_release should be null (no released_at timestamp) + expect(escrow.time_to_release).toBeNull() + }) })