diff --git a/apps/api/src/__tests__/bazaar-lock-failure.test.ts b/apps/api/src/__tests__/bazaar-lock-failure.test.ts index 94daad5..944baed 100644 --- a/apps/api/src/__tests__/bazaar-lock-failure.test.ts +++ b/apps/api/src/__tests__/bazaar-lock-failure.test.ts @@ -43,7 +43,16 @@ vi.mock("../db/bazaar.js", () => ({ updateIntent: vi.fn(async () => {}), createQuote: vi.fn(async (q: unknown) => q), getQuotesForIntent: vi.fn(async () => []), - getAgentHistory: vi.fn(async () => null), + getAgentHistory: vi.fn(async () => ({ + agent_address: "GTESTBAZAAR", + broadcasts: 0, + intents_accepted: 0, + swaps_completed: 0, + swaps_cancelled: 0, + volume_usdc: 0, + first_seen: "2026-01-01T00:00:00.000Z", + last_active: "2026-01-01T00:00:00.000Z", + })), upsertAgentHistory: vi.fn(async () => {}), intentRowToObject: (row: unknown) => row, getBazaarStats: vi.fn(async () => ({})), @@ -117,6 +126,8 @@ describe("POST /api/v1/bazaar/accept, fallo del lock on-chain", () => { it("no se escribe reputacion: agent_history queda intacto", async () => { await acceptConLockRoto(); + // Si el lock falla no hubo ni aceptacion confirmada ni settlement, asi que + // agent_history no puede sumar contadores, volumen ni actividad. expect(vi.mocked(upsertAgentHistory)).not.toHaveBeenCalled(); }); @@ -149,10 +160,18 @@ describe("POST /api/v1/bazaar/accept, fallo del lock on-chain", () => { const body = JSON.parse(res.body); expect(body.status).toBe("negotiating"); expect(body.handshake.htlc_tx_hash).toBe("abc123"); + expect(body.acceptance_recorded).toBe(true); + expect(body.acceptance_counter).toBe("intents_accepted"); expect(body.agent_reputation_updated).toBe(false); expect(body.reputation_update).toBe("deferred_until_settlement"); + // Sin este test los cinco anteriores tambien pasarian si accept estuviera + // roto de raiz y devolviera 502 siempre. expect(vi.mocked(updateIntent)).toHaveBeenCalledOnce(); - expect(vi.mocked(upsertAgentHistory)).not.toHaveBeenCalled(); + expect(vi.mocked(upsertAgentHistory)).toHaveBeenCalledOnce(); + expect(vi.mocked(upsertAgentHistory)).toHaveBeenCalledWith( + "GTESTBAZAAR", + { intents_accepted: 1 }, + ); }); }); diff --git a/apps/api/src/__tests__/bazaar-reputation-accept.test.ts b/apps/api/src/__tests__/bazaar-reputation-accept.test.ts index ac427e4..5da7eca 100644 --- a/apps/api/src/__tests__/bazaar-reputation-accept.test.ts +++ b/apps/api/src/__tests__/bazaar-reputation-accept.test.ts @@ -42,6 +42,7 @@ vi.mock("../db/bazaar.js", () => ({ upsertAgentHistory: vi.fn(async (address: string, delta: Record) => { const current = histories.get(address) ?? { broadcasts: 0, + intents_accepted: 0, swaps_completed: 0, swaps_cancelled: 0, volume_usdc: 0, @@ -51,6 +52,7 @@ vi.mock("../db/bazaar.js", () => ({ const updated = { ...current, broadcasts: current.broadcasts + (delta.broadcasts ?? 0), + intents_accepted: current.intents_accepted + (delta.intents_accepted ?? 0), swaps_completed: current.swaps_completed + (delta.swaps_completed ?? 0), swaps_cancelled: current.swaps_cancelled + (delta.swaps_cancelled ?? 0), volume_usdc: current.volume_usdc + (delta.volume_usdc ?? 0), @@ -89,6 +91,7 @@ describe("Bazaar reputation accounting on accept", () => { histories.clear(); histories.set(AUTHOR, { broadcasts: 5, + intents_accepted: 0, swaps_completed: 4, swaps_cancelled: 1, volume_usdc: 100, @@ -97,6 +100,7 @@ describe("Bazaar reputation accounting on accept", () => { }); histories.set(ACCEPTOR, { broadcasts: 3, + intents_accepted: 7, swaps_completed: 2, swaps_cancelled: 0, volume_usdc: 50, @@ -106,7 +110,7 @@ describe("Bazaar reputation accounting on accept", () => { vi.mocked(upsertAgentHistory).mockClear(); }); - it("does not count accept as completion for either participant", async () => { + it("counts acceptance for the acceptor without counting a completion", async () => { const accepted = await app.inject({ method: "POST", url: "/api/v1/bazaar/accept", @@ -120,9 +124,15 @@ describe("Bazaar reputation accounting on accept", () => { expect(accepted.statusCode).toBe(200); const acceptedBody = JSON.parse(accepted.body); + expect(acceptedBody.acceptance_recorded).toBe(true); + expect(acceptedBody.acceptance_counter).toBe("intents_accepted"); expect(acceptedBody.agent_reputation_updated).toBe(false); expect(acceptedBody.reputation_update).toBe("deferred_until_settlement"); - expect(vi.mocked(upsertAgentHistory)).not.toHaveBeenCalled(); + expect(vi.mocked(upsertAgentHistory)).toHaveBeenCalledOnce(); + expect(vi.mocked(upsertAgentHistory)).toHaveBeenCalledWith( + ACCEPTOR, + { intents_accepted: 1 }, + ); const authorResponse = await app.inject({ method: "GET", @@ -139,8 +149,11 @@ describe("Bazaar reputation accounting on accept", () => { const author = JSON.parse(authorResponse.body).agent_reputation; const acceptor = JSON.parse(acceptorResponse.body).agent_reputation; + expect(author.intents_accepted).toBe(0); expect(author.swaps_completed).toBe(4); expect(author.volume_usdc_total).toBe("100"); + + expect(acceptor.intents_accepted).toBe(8); expect(acceptor.swaps_completed).toBe(2); expect(acceptor.volume_usdc_total).toBe("50"); }); diff --git a/apps/api/src/db/bazaar.ts b/apps/api/src/db/bazaar.ts index 14373da..da9af44 100644 --- a/apps/api/src/db/bazaar.ts +++ b/apps/api/src/db/bazaar.ts @@ -30,6 +30,7 @@ export interface BazaarQuoteRow { export interface AgentHistoryRow { agent_address: string; broadcasts: number; + intents_accepted?: number; swaps_completed: number; swaps_cancelled: number; volume_usdc: number; @@ -80,12 +81,16 @@ export async function initBazaarTables(): Promise { CREATE TABLE IF NOT EXISTS agent_history ( agent_address VARCHAR(56) PRIMARY KEY, broadcasts INTEGER NOT NULL DEFAULT 0, + intents_accepted INTEGER NOT NULL DEFAULT 0, swaps_completed INTEGER NOT NULL DEFAULT 0, swaps_cancelled INTEGER NOT NULL DEFAULT 0, volume_usdc DECIMAL(20,2) NOT NULL DEFAULT 0, first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_active TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + + ALTER TABLE agent_history + ADD COLUMN IF NOT EXISTS intents_accepted INTEGER NOT NULL DEFAULT 0; `); } @@ -191,11 +196,12 @@ export async function upsertAgentHistory( if (!existing) { await query(` - INSERT INTO agent_history (agent_address, broadcasts, swaps_completed, swaps_cancelled, volume_usdc) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO agent_history (agent_address, broadcasts, intents_accepted, swaps_completed, swaps_cancelled, volume_usdc) + VALUES ($1, $2, $3, $4, $5, $6) `, [ address, updates.broadcasts ?? 1, + updates.intents_accepted ?? 0, updates.swaps_completed ?? 0, updates.swaps_cancelled ?? 0, updates.volume_usdc ?? 0 @@ -211,6 +217,10 @@ export async function upsertAgentHistory( sets.push(`broadcasts = $${idx++}`); values.push(existing.broadcasts + updates.broadcasts); } + if (updates.intents_accepted !== undefined) { + sets.push(`intents_accepted = $${idx++}`); + values.push((existing.intents_accepted ?? 0) + updates.intents_accepted); + } if (updates.swaps_completed !== undefined) { sets.push(`swaps_completed = $${idx++}`); values.push(existing.swaps_completed + updates.swaps_completed); @@ -328,6 +338,7 @@ export interface BazaarStats { export interface AgentStats { agent_address: string; broadcasts: number; + intents_accepted: number; swaps_completed: number; completion_rate: number; volume_usdc: number; @@ -365,6 +376,7 @@ export async function getBazaarStats(): Promise { return { agent_address: agent.agent_address, broadcasts: agent.broadcasts, + intents_accepted: agent.intents_accepted ?? 0, swaps_completed: agent.swaps_completed, completion_rate: parseFloat(rate.toFixed(3)), volume_usdc: agent.volume_usdc, diff --git a/apps/api/src/docs/API.md b/apps/api/src/docs/API.md index 968621a..96757b6 100644 --- a/apps/api/src/docs/API.md +++ b/apps/api/src/docs/API.md @@ -72,7 +72,24 @@ curl -H "X-PAYMENT: mock:GTEST123:0.005" \ ``` Si el lock on-chain falla, la respuesta es **502** y el intent queda sin -modificar: no se bloquearon fondos. +modificar: no se bloquearon fondos y no cambia ningun contador de historial. + +Si el lock se confirma, el pagador que acepto el intent incrementa +`intents_accepted` en 1. Este contador representa una aceptacion real y se +mantiene separado de `swaps_completed`: aceptar solo establece el primer lock, +no demuestra settlement. Por lo tanto `swaps_completed` y `volume_usdc` no +cambian en este endpoint. + +La respuesta exitosa incluye `acceptance_recorded: true`, +`acceptance_counter: "intents_accepted"` y mantiene +`reputation_update: "deferred_until_settlement"` hasta que exista una ruta de +settlement que pueda acreditar a ambos participantes. + +#### GET /api/v1/bazaar/reputation/:addr + +`agent_reputation.intents_accepted` informa cuantas aceptaciones con lock +confirmado ha realizado ese agente. Es una metrica de actividad distinta de +`swaps_completed` y no participa en el tier ni en `completion_rate`. ### Cash (P2P Exchange) diff --git a/apps/api/src/routes/bazaar.ts b/apps/api/src/routes/bazaar.ts index ec1f43a..726b818 100644 --- a/apps/api/src/routes/bazaar.ts +++ b/apps/api/src/routes/bazaar.ts @@ -60,21 +60,21 @@ function getAgentTier(completed: number, total: number) { ?? AGENT_TIERS[AGENT_TIERS.length - 1]; } -const memoryAgentHistory = new Map(); +const memoryAgentHistory = new Map(); memoryAgentHistory.set("GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG", { - broadcasts: 87, swaps_completed: 83, swaps_cancelled: 4, volume_usdc: 241500, + broadcasts: 87, intents_accepted: 0, swaps_completed: 83, swaps_cancelled: 4, volume_usdc: 241500, first_seen: "2025-09-14T10:22:00Z", last_active: new Date(Date.now() - 1000 * 60 * 5).toISOString(), }); memoryAgentHistory.set("GDFJHLAXAUMHA4OWPOB4P7YO72AQR2HMIUYFOXLXE2DZGM633K7HZDQP", { - broadcasts: 31, swaps_completed: 28, swaps_cancelled: 3, volume_usdc: 52300, + broadcasts: 31, intents_accepted: 0, swaps_completed: 28, swaps_cancelled: 3, volume_usdc: 52300, first_seen: "2025-11-03T15:45:00Z", last_active: new Date(Date.now() - 1000 * 60 * 2).toISOString(), }); async function getOrCreateHistory(address: string) { let history = await getAgentHistory(address); if (!history) { - history = await upsertAgentHistory(address, { broadcasts: 0, swaps_completed: 0, swaps_cancelled: 0, volume_usdc: 0 }); + history = await upsertAgentHistory(address, { broadcasts: 0, intents_accepted: 0, swaps_completed: 0, swaps_cancelled: 0, volume_usdc: 0 }); } return history; } @@ -180,12 +180,12 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise { top_agents: [ { agent_address: "GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG", - broadcasts: 87, swaps_completed: 83, completion_rate: 0.954, + broadcasts: 87, intents_accepted: 0, swaps_completed: 83, completion_rate: 0.954, volume_usdc: 241500, tier: "maestro", tier_emoji: "🍄" }, { agent_address: "GDFJHLAXAUMHA4OWPOB4P7YO72AQR2HMIUYFOXLXE2DZGM633K7HZDQP", - broadcasts: 31, swaps_completed: 28, completion_rate: 0.903, + broadcasts: 31, intents_accepted: 0, swaps_completed: 28, completion_rate: 0.903, volume_usdc: 52300, tier: "experto", tier_emoji: "⭐" }, ], @@ -241,7 +241,7 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise { dataSource = "in-memory (DB unavailable)"; const seedHistory = memoryAgentHistory.get(address); history = seedHistory ?? { - broadcasts: 0, swaps_completed: 0, swaps_cancelled: 0, + broadcasts: 0, intents_accepted: 0, swaps_completed: 0, swaps_cancelled: 0, volume_usdc: 0, first_seen: new Date().toISOString(), last_active: new Date().toISOString(), }; @@ -263,6 +263,7 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise { tier: tier.name, tier_emoji: tier.emoji, tier_description: tier.description, + intents_accepted: history.intents_accepted, swaps_completed: history.swaps_completed, total_broadcasts: history.broadcasts, swaps_cancelled: history.swaps_cancelled, @@ -278,7 +279,7 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise { risk_level: !trusted ? "high" : completion_rate >= 0.95 ? "low" : "medium", }, data_source: `MicoPay Bazaar swap history (${dataSource})`, - note: "Agent reputation is derived from completed Bazaar swaps — not transferable, not buyable.", + note: "Agent reputation is derived from completed Bazaar swaps. intents_accepted tracks successful accept/lock events separately and does not count as settlement.", queried_at: new Date().toISOString(), }); } @@ -381,7 +382,12 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise { selected_quote_id: quote?.id ?? null, }); - // Accept only establishes the first on-chain lock; it is not settlement. + // Acceptance is a real event, but it is not settlement. Count it only + // after the first lock succeeds and keep it separate from completion. + const acceptorAddress = request.payerAddress ?? "GUNKNOWN"; + await getOrCreateHistory(acceptorAddress); + await upsertAgentHistory(acceptorAddress, { intents_accepted: 1 }); + // Do not increment swaps_completed or volume_usdc here. Once settlement // confirmation exists, credit both the intent author and the acceptor with // the amount actually settled. See BRIDGE-08 / issue #15. @@ -400,6 +406,8 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise { htlc_explorer_url: lock.explorerUrl, swap_id: lock.swapId, }, + acceptance_recorded: true, + acceptance_counter: "intents_accepted", agent_reputation_updated: false, reputation_update: "deferred_until_settlement", // La pierna contraria ya no es "en producción": es un escrow nativo de