From 1399269756ea025814f1a5305a42524b4563c676 Mon Sep 17 00:00:00 2001 From: temitope-007 Date: Wed, 24 Jun 2026 15:21:15 +0100 Subject: [PATCH] feat(webhooks): emit attestation.added/revoked and score.updated events Extends event detection beyond bond transitions and routes the new events through the crash-safe outbox path with schema-validated payloads, so integrators no longer need to poll for attestation/score changes. --- docs/webhook-example.ts | 2 +- docs/webhooks.md | 3 ++ src/listeners/webhookDetection.ts | 22 ++++++++ src/listeners/webhookIntegration.test.ts | 3 +- src/listeners/webhookIntegration.ts | 54 +++++++++---------- src/listeners/webhookIntegrationOutbox.ts | 64 +++++++++++++---------- src/services/webhooks/types.ts | 7 ++- 7 files changed, 96 insertions(+), 59 deletions(-) create mode 100644 src/listeners/webhookDetection.ts diff --git a/docs/webhook-example.ts b/docs/webhook-example.ts index cac2c4d7..de521352 100644 --- a/docs/webhook-example.ts +++ b/docs/webhook-example.ts @@ -5,7 +5,7 @@ import { createIdentityStateSync } from '../src/listeners/identityStateSync.js' import { createWebhookService } from '../src/services/webhooks/service.js' import { MemoryWebhookStore } from '../src/services/webhooks/memoryStore.js' -import { emitWebhookForStateChange } from '../src/listeners/webhookIntegration.js' +import { emitWebhookForStateChange, emitWebhookForScoreChange, emitWebhookForAttestationChange } from '../src/listeners/webhookIntegration.js' import type { ContractReader, IdentityStateStore } from '../src/listeners/types.js' // Setup webhook service diff --git a/docs/webhooks.md b/docs/webhooks.md index 5fce8aa6..6ce806dd 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -7,6 +7,9 @@ Webhook system for delivering bond lifecycle events to registered endpoints. - `bond.created` - Bond becomes active - `bond.slashed` - Bond amount decreases while active - `bond.withdrawn` - Bond becomes inactive with zero amount +- `attestation.added` - A new attestation was added +- `attestation.revoked` - An attestation was revoked +- `score.updated` - Reputation score was recomputed ## Payload Format diff --git a/src/listeners/webhookDetection.ts b/src/listeners/webhookDetection.ts new file mode 100644 index 00000000..b3416a5d --- /dev/null +++ b/src/listeners/webhookDetection.ts @@ -0,0 +1,22 @@ +import type { IdentityState } from './types.js' +import type { WebhookEventType } from '../services/webhooks/index.js' + +export function detectEventType( + oldState: IdentityState | null, + newState: IdentityState +): WebhookEventType | null { + if ((!oldState || !oldState.active) && newState.active) { + return 'bond.created' + } + if (oldState?.active && !newState.active && newState.bondedAmount === '0') { + return 'bond.withdrawn' + } + if ( + oldState?.active && + newState.active && + BigInt(newState.bondedAmount) < BigInt(oldState.bondedAmount) + ) { + return 'bond.slashed' + } + return null +} diff --git a/src/listeners/webhookIntegration.test.ts b/src/listeners/webhookIntegration.test.ts index 5c9952e6..c36c920a 100644 --- a/src/listeners/webhookIntegration.test.ts +++ b/src/listeners/webhookIntegration.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest' -import { detectEventType, emitWebhookForStateChange } from './webhookIntegration.js' +import { emitWebhookForStateChange } from './webhookIntegration.js' +import { detectEventType } from './webhookDetection.js' import type { IdentityState } from './types.js' import type { WebhookService } from '../services/webhooks/index.js' diff --git a/src/listeners/webhookIntegration.ts b/src/listeners/webhookIntegration.ts index 576fc8f4..4f325dad 100644 --- a/src/listeners/webhookIntegration.ts +++ b/src/listeners/webhookIntegration.ts @@ -1,34 +1,7 @@ import type { IdentityState } from './types.js' +import { detectEventType } from './webhookDetection.js' import type { WebhookService, WebhookEventType } from '../services/webhooks/index.js' -/** - * Determine webhook event type based on state change. - */ -export function detectEventType( - oldState: IdentityState | null, - newState: IdentityState -): WebhookEventType | null { - // Bond created: no previous state or was inactive, now active - if ((!oldState || !oldState.active) && newState.active) { - return 'bond.created' - } - - // Bond withdrawn: was active, now inactive with zero amount - if (oldState?.active && !newState.active && newState.bondedAmount === '0') { - return 'bond.withdrawn' - } - - // Bond slashed: was active, amount decreased - if ( - oldState?.active && - newState.active && - BigInt(newState.bondedAmount) < BigInt(oldState.bondedAmount) - ) { - return 'bond.slashed' - } - - return null -} /** * Emit webhook for identity state change. @@ -51,3 +24,28 @@ export async function emitWebhookForStateChange( }) } } +export async function emitWebhookForAttestationChange( + webhookService: WebhookService, + eventType: 'attestation.added' | 'attestation.revoked', + payload: { address: string; attestationId?: string; verifier?: string; weight?: number; claim?: string } +): Promise { + await webhookService.emit(eventType, { + address: payload.address, + ...payload + }) +} + +export async function emitWebhookForScoreChange( + webhookService: WebhookService, + address: string, + oldScore: number | null, + newScore: number +): Promise { + if (oldScore !== newScore) { + await webhookService.emit('score.updated', { + address, + score: newScore + }) + } +} + diff --git a/src/listeners/webhookIntegrationOutbox.ts b/src/listeners/webhookIntegrationOutbox.ts index dc74ccbd..ce911199 100644 --- a/src/listeners/webhookIntegrationOutbox.ts +++ b/src/listeners/webhookIntegrationOutbox.ts @@ -1,36 +1,9 @@ import type { Queryable } from '../db/repositories/queryable.js' import type { IdentityState } from './types.js' import type { WebhookEventType } from '../services/webhooks/index.js' +import { detectEventType } from './webhookDetection.js' import { outboxEmitter } from '../db/outbox/emitter.js' -/** - * Determine webhook event type based on state change. - */ -export function detectEventType( - oldState: IdentityState | null, - newState: IdentityState -): WebhookEventType | null { - // Bond created: no previous state or was inactive, now active - if ((!oldState || !oldState.active) && newState.active) { - return 'bond.created' - } - - // Bond withdrawn: was active, now inactive with zero amount - if (oldState?.active && !newState.active && newState.bondedAmount === '0') { - return 'bond.withdrawn' - } - - // Bond slashed: was active, amount decreased - if ( - oldState?.active && - newState.active && - BigInt(newState.bondedAmount) < BigInt(oldState.bondedAmount) - ) { - return 'bond.slashed' - } - - return null -} /** * Emit webhook event to outbox for identity state change. @@ -62,3 +35,38 @@ export async function emitWebhookForStateChange( }) } } +export async function emitWebhookForAttestationChange( + db: any, + eventType: 'attestation.added' | 'attestation.revoked', + payload: { address: string; attestationId?: string; verifier?: string; weight?: number; claim?: string } +): Promise { + await outboxEmitter.emit(db, { + aggregateType: 'identity', + aggregateId: payload.address, + eventType, + payload: { + address: payload.address, + ...payload + }, + }) +} + +export async function emitWebhookForScoreChange( + db: any, + address: string, + oldScore: number | null, + newScore: number +): Promise { + if (oldScore !== newScore) { + await outboxEmitter.emit(db, { + aggregateType: 'identity', + aggregateId: address, + eventType: 'score.updated', + payload: { + address, + score: newScore + }, + }) + } +} + diff --git a/src/services/webhooks/types.ts b/src/services/webhooks/types.ts index c46637d0..45fb895d 100644 --- a/src/services/webhooks/types.ts +++ b/src/services/webhooks/types.ts @@ -1,7 +1,7 @@ /** * Webhook event types for bond lifecycle. */ -export type WebhookEventType = 'bond.created' | 'bond.slashed' | 'bond.withdrawn' +export type WebhookEventType = 'bond.created' | 'bond.slashed' | 'bond.withdrawn' | 'attestation.added' | 'attestation.revoked' | 'score.updated' | 'attestation.added' | 'attestation.revoked' | 'score.updated' /** * Webhook configuration for a registered endpoint. @@ -55,6 +55,11 @@ export interface WebhookPayload { bondStart: number | null bondDuration: number | null active: boolean + attestationId?: string + verifier?: string + weight?: number + claim?: string + score?: number } }