Skip to content
Open
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
23 changes: 21 additions & 2 deletions apps/api/src/__tests__/bazaar-lock-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({})),
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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 },
);
});
});
17 changes: 15 additions & 2 deletions apps/api/src/__tests__/bazaar-reputation-accept.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ vi.mock("../db/bazaar.js", () => ({
upsertAgentHistory: vi.fn(async (address: string, delta: Record<string, number>) => {
const current = histories.get(address) ?? {
broadcasts: 0,
intents_accepted: 0,
swaps_completed: 0,
swaps_cancelled: 0,
volume_usdc: 0,
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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");
});
Expand Down
16 changes: 14 additions & 2 deletions apps/api/src/db/bazaar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,12 +81,16 @@ export async function initBazaarTables(): Promise<void> {
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;
`);
}

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -365,6 +376,7 @@ export async function getBazaarStats(): Promise<BazaarStats> {
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,
Expand Down
19 changes: 18 additions & 1 deletion apps/api/src/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
26 changes: 17 additions & 9 deletions apps/api/src/routes/bazaar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,21 @@ function getAgentTier(completed: number, total: number) {
?? AGENT_TIERS[AGENT_TIERS.length - 1];
}

const memoryAgentHistory = new Map<string, { broadcasts: number; swaps_completed: number; swaps_cancelled: number; volume_usdc: number; first_seen: string; last_active: string }>();
const memoryAgentHistory = new Map<string, { broadcasts: number; intents_accepted: number; swaps_completed: number; swaps_cancelled: number; volume_usdc: number; first_seen: string; last_active: string }>();

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;
}
Expand Down Expand Up @@ -180,12 +180,12 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise<void> {
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: "⭐"
},
],
Expand Down Expand Up @@ -241,7 +241,7 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise<void> {
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(),
};
Expand All @@ -263,6 +263,7 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise<void> {
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,
Expand All @@ -278,7 +279,7 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise<void> {
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(),
});
}
Expand Down Expand Up @@ -381,7 +382,12 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise<void> {
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.
Expand All @@ -400,6 +406,8 @@ export async function bazaarRoutes(fastify: FastifyInstance): Promise<void> {
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
Expand Down
Loading