@sip-protocol/api 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Webhook Delivery Service
3
+ *
4
+ * Delivers payment notifications to registered agent URLs with:
5
+ * - HMAC-SHA256 signature (X-SIP-Signature header)
6
+ * - Exponential backoff retry (1s, 4s, 16s)
7
+ * - Graceful shutdown drain
8
+ */
9
+
10
+ import { hmac } from '@noble/hashes/hmac'
11
+ import { sha256 } from '@noble/hashes/sha256'
12
+ import { bytesToHex } from '@noble/hashes/utils'
13
+ import { logger } from '../logger'
14
+ import { env } from '../config'
15
+ import type { WebhookDeliveryPayload } from '../types/api'
16
+ import type { WebhookRegistration } from '../stores/webhook-store'
17
+ import type { SolanaScanResult } from '@sip-protocol/sdk'
18
+
19
+ /**
20
+ * Compute HMAC-SHA256 signature for webhook payload
21
+ */
22
+ export function computeHmacSignature(secret: string, body: string): string {
23
+ const encoder = new TextEncoder()
24
+ const sig = bytesToHex(hmac(sha256, encoder.encode(secret), encoder.encode(body)))
25
+ return `sha256=${sig}`
26
+ }
27
+
28
+ /**
29
+ * Sleep helper for retry backoff
30
+ */
31
+ function sleep(ms: number): Promise<void> {
32
+ return new Promise(resolve => setTimeout(resolve, ms))
33
+ }
34
+
35
+ export class WebhookDeliveryService {
36
+ private readonly maxRetries: number
37
+ private pendingDeliveries = new Set<Promise<void>>()
38
+
39
+ constructor(maxRetries?: number) {
40
+ this.maxRetries = maxRetries ?? env.WEBHOOK_DELIVERY_MAX_RETRIES
41
+ }
42
+
43
+ /**
44
+ * Build the delivery payload from a scan result
45
+ */
46
+ buildPayload(registration: WebhookRegistration, payment: SolanaScanResult): WebhookDeliveryPayload {
47
+ return {
48
+ event: 'payment.received',
49
+ webhookId: registration.id,
50
+ timestamp: new Date().toISOString(),
51
+ data: {
52
+ txSignature: payment.txSignature,
53
+ stealthAddress: payment.stealthAddress,
54
+ ephemeralPublicKey: payment.ephemeralPublicKey,
55
+ amount: payment.amount.toString(),
56
+ mint: payment.mint,
57
+ tokenSymbol: payment.tokenSymbol,
58
+ slot: payment.slot,
59
+ blockTime: payment.timestamp,
60
+ },
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Deliver a payment notification to a registered webhook
66
+ *
67
+ * Retries with exponential backoff on failure.
68
+ */
69
+ async deliver(registration: WebhookRegistration, payment: SolanaScanResult): Promise<boolean> {
70
+ const payload = this.buildPayload(registration, payment)
71
+ const body = JSON.stringify(payload)
72
+ const signature = computeHmacSignature(registration.secret, body)
73
+
74
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
75
+ try {
76
+ const response = await fetch(registration.url, {
77
+ method: 'POST',
78
+ headers: {
79
+ 'Content-Type': 'application/json',
80
+ 'X-SIP-Signature': signature,
81
+ 'X-SIP-Webhook-Id': registration.id,
82
+ },
83
+ body,
84
+ signal: AbortSignal.timeout(10_000),
85
+ })
86
+
87
+ if (response.ok) {
88
+ logger.info(
89
+ { webhookId: registration.id, attempt, status: response.status },
90
+ 'Webhook delivered'
91
+ )
92
+ return true
93
+ }
94
+
95
+ logger.warn(
96
+ { webhookId: registration.id, attempt, status: response.status },
97
+ 'Webhook delivery failed (non-2xx)'
98
+ )
99
+ } catch (error) {
100
+ logger.warn(
101
+ { webhookId: registration.id, attempt, error: (error as Error).message },
102
+ 'Webhook delivery error'
103
+ )
104
+ }
105
+
106
+ if (attempt < this.maxRetries) {
107
+ const backoffMs = Math.pow(4, attempt) * 1000 // 1s, 4s, 16s
108
+ await sleep(backoffMs)
109
+ }
110
+ }
111
+
112
+ logger.error(
113
+ { webhookId: registration.id, maxRetries: this.maxRetries },
114
+ 'Webhook delivery exhausted all retries'
115
+ )
116
+ return false
117
+ }
118
+
119
+ /**
120
+ * Queue a delivery (fire-and-forget with tracking)
121
+ */
122
+ queueDelivery(registration: WebhookRegistration, payment: SolanaScanResult): void {
123
+ const promise: Promise<void> = this.deliver(registration, payment)
124
+ .then(() => {})
125
+ .catch(err => {
126
+ logger.error({ webhookId: registration.id, err }, 'Unhandled webhook delivery error')
127
+ })
128
+ .finally(() => {
129
+ this.pendingDeliveries.delete(promise)
130
+ })
131
+ this.pendingDeliveries.add(promise)
132
+ }
133
+
134
+ /**
135
+ * Wait for all pending deliveries to complete (graceful shutdown)
136
+ */
137
+ async drainPending(): Promise<void> {
138
+ if (this.pendingDeliveries.size > 0) {
139
+ logger.info({ pending: this.pendingDeliveries.size }, 'Draining pending webhook deliveries')
140
+ await Promise.allSettled(this.pendingDeliveries)
141
+ logger.info('All webhook deliveries drained')
142
+ }
143
+ }
144
+ }
145
+
146
+ export const webhookDeliveryService = new WebhookDeliveryService()
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Graceful Shutdown Handler
3
+ *
4
+ * Handles SIGTERM and SIGINT signals to:
5
+ * - Stop accepting new connections
6
+ * - Allow in-flight requests to complete
7
+ * - Clean up resources
8
+ * - Exit gracefully
9
+ */
10
+
11
+ import { Server } from 'http'
12
+ import { logger } from './logger'
13
+ import { env } from './config'
14
+
15
+ let isShuttingDown = false
16
+
17
+ /**
18
+ * Check if server is currently shutting down
19
+ */
20
+ export function isServerShuttingDown(): boolean {
21
+ return isShuttingDown
22
+ }
23
+
24
+ /**
25
+ * Setup graceful shutdown handlers
26
+ *
27
+ * @param server - HTTP server instance
28
+ * @param cleanup - Optional cleanup function to run before exit
29
+ */
30
+ export function setupGracefulShutdown(
31
+ server: Server,
32
+ cleanup?: () => Promise<void>
33
+ ): void {
34
+ const shutdown = async (signal: string) => {
35
+ if (isShuttingDown) {
36
+ logger.warn({ signal }, 'Shutdown already in progress, ignoring signal')
37
+ return
38
+ }
39
+
40
+ isShuttingDown = true
41
+ logger.info({ signal }, 'Received shutdown signal, starting graceful shutdown...')
42
+
43
+ // Stop accepting new connections
44
+ server.close(async (err) => {
45
+ if (err) {
46
+ logger.error({ err }, 'Error closing HTTP server')
47
+ process.exit(1)
48
+ }
49
+
50
+ logger.info('HTTP server closed, no longer accepting connections')
51
+
52
+ // Run cleanup if provided
53
+ if (cleanup) {
54
+ try {
55
+ logger.info('Running cleanup tasks...')
56
+ await cleanup()
57
+ logger.info('Cleanup completed successfully')
58
+ } catch (cleanupErr) {
59
+ logger.error({ err: cleanupErr }, 'Error during cleanup')
60
+ }
61
+ }
62
+
63
+ logger.info('Graceful shutdown complete, exiting')
64
+ process.exit(0)
65
+ })
66
+
67
+ // Force exit after timeout
68
+ setTimeout(() => {
69
+ logger.error(
70
+ { timeoutMs: env.SHUTDOWN_TIMEOUT_MS },
71
+ 'Graceful shutdown timeout exceeded, forcing exit'
72
+ )
73
+ process.exit(1)
74
+ }, env.SHUTDOWN_TIMEOUT_MS)
75
+ }
76
+
77
+ // Handle termination signals
78
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
79
+ process.on('SIGINT', () => shutdown('SIGINT'))
80
+
81
+ // Handle uncaught errors
82
+ process.on('uncaughtException', (err) => {
83
+ logger.fatal({ err }, 'Uncaught exception, shutting down')
84
+ shutdown('uncaughtException')
85
+ })
86
+
87
+ process.on('unhandledRejection', (reason) => {
88
+ logger.fatal({ reason }, 'Unhandled rejection, shutting down')
89
+ shutdown('unhandledRejection')
90
+ })
91
+
92
+ logger.debug('Graceful shutdown handlers registered')
93
+ }
94
+
95
+ /**
96
+ * Middleware to reject new requests during shutdown
97
+ */
98
+ export function shutdownMiddleware(
99
+ req: { path: string },
100
+ res: { status: (code: number) => { json: (body: unknown) => void } },
101
+ next: () => void
102
+ ): void {
103
+ if (isShuttingDown) {
104
+ // Allow health checks to report unhealthy during shutdown
105
+ if (req.path === '/api/v1/health') {
106
+ return next()
107
+ }
108
+
109
+ res.status(503).json({
110
+ success: false,
111
+ error: {
112
+ code: 'SERVICE_UNAVAILABLE',
113
+ message: 'Server is shutting down',
114
+ },
115
+ })
116
+ return
117
+ }
118
+ next()
119
+ }
@@ -0,0 +1,2 @@
1
+ export { SwapStore, swapStore, type SwapData, type SwapStoreConfig } from './swap-store'
2
+ export { WebhookStore, webhookStore, type WebhookRegistration } from './webhook-store'
@@ -0,0 +1,158 @@
1
+ import { LRUCache } from 'lru-cache'
2
+ import type { HexString } from '@sip-protocol/types'
3
+ import { logger } from '../logger'
4
+
5
+ /**
6
+ * Swap data stored in the cache
7
+ */
8
+ export interface SwapData {
9
+ id: string
10
+ status: 'pending' | 'processing' | 'completed' | 'failed'
11
+ transactionHash?: HexString
12
+ inputAmount: string
13
+ outputAmount?: string
14
+ createdAt: string
15
+ updatedAt: string
16
+ error?: string
17
+ }
18
+
19
+ /**
20
+ * Swap store configuration
21
+ */
22
+ export interface SwapStoreConfig {
23
+ /** Maximum number of swaps to store (default: 10000) */
24
+ maxSize?: number
25
+ /** TTL in milliseconds (default: 24 hours) */
26
+ ttlMs?: number
27
+ }
28
+
29
+ const DEFAULT_MAX_SIZE = 10_000
30
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
31
+
32
+ /**
33
+ * LRU cache-backed swap store with TTL
34
+ *
35
+ * Features:
36
+ * - Bounded memory (max 10,000 entries by default)
37
+ * - Automatic TTL-based expiration (24 hours by default)
38
+ * - LRU eviction when at capacity
39
+ * - Metrics for monitoring
40
+ */
41
+ export class SwapStore {
42
+ private cache: LRUCache<string, SwapData>
43
+ private readonly maxSize: number
44
+ private readonly ttlMs: number
45
+
46
+ constructor(config: SwapStoreConfig = {}) {
47
+ this.maxSize = config.maxSize ?? DEFAULT_MAX_SIZE
48
+ this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS
49
+
50
+ this.cache = new LRUCache<string, SwapData>({
51
+ max: this.maxSize,
52
+ ttl: this.ttlMs,
53
+ updateAgeOnGet: false, // Don't reset TTL on read
54
+ updateAgeOnHas: false,
55
+ // Log when items are evicted or expired
56
+ disposeAfter: (_value, key, reason) => {
57
+ if (reason === 'evict') {
58
+ logger.debug({ swapId: key, reason }, 'Swap evicted from cache (LRU)')
59
+ } else if (reason === 'expire') {
60
+ logger.debug({ swapId: key, reason }, 'Swap expired from cache (TTL)')
61
+ }
62
+ },
63
+ })
64
+
65
+ logger.info(
66
+ { maxSize: this.maxSize, ttlMs: this.ttlMs },
67
+ 'SwapStore initialized'
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Get a swap by ID
73
+ */
74
+ get(id: string): SwapData | undefined {
75
+ return this.cache.get(id)
76
+ }
77
+
78
+ /**
79
+ * Check if a swap exists
80
+ */
81
+ has(id: string): boolean {
82
+ return this.cache.has(id)
83
+ }
84
+
85
+ /**
86
+ * Set/update a swap
87
+ */
88
+ set(id: string, data: SwapData): void {
89
+ this.cache.set(id, data)
90
+ }
91
+
92
+ /**
93
+ * Delete a swap
94
+ */
95
+ delete(id: string): boolean {
96
+ return this.cache.delete(id)
97
+ }
98
+
99
+ /**
100
+ * Update swap status
101
+ */
102
+ updateStatus(
103
+ id: string,
104
+ status: SwapData['status'],
105
+ updates?: Partial<SwapData>
106
+ ): SwapData | undefined {
107
+ const existing = this.cache.get(id)
108
+ if (!existing) {
109
+ return undefined
110
+ }
111
+
112
+ const updated: SwapData = {
113
+ ...existing,
114
+ ...updates,
115
+ status,
116
+ updatedAt: new Date().toISOString(),
117
+ }
118
+
119
+ this.cache.set(id, updated)
120
+ return updated
121
+ }
122
+
123
+ /**
124
+ * Get store metrics for monitoring
125
+ */
126
+ getMetrics(): {
127
+ size: number
128
+ maxSize: number
129
+ ttlMs: number
130
+ utilizationPercent: number
131
+ } {
132
+ const size = this.cache.size
133
+ return {
134
+ size,
135
+ maxSize: this.maxSize,
136
+ ttlMs: this.ttlMs,
137
+ utilizationPercent: Math.round((size / this.maxSize) * 100),
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Clear all swaps (for testing)
143
+ */
144
+ clear(): void {
145
+ this.cache.clear()
146
+ }
147
+
148
+ /**
149
+ * Purge stale entries (normally handled automatically by LRU cache)
150
+ */
151
+ purgeStale(): void {
152
+ this.cache.purgeStale()
153
+ }
154
+ }
155
+
156
+ // Export singleton instance with default config
157
+ // Can be overridden in tests or for custom configurations
158
+ export const swapStore = new SwapStore()
@@ -0,0 +1,120 @@
1
+ /**
2
+ * In-memory Webhook Registration Store
3
+ *
4
+ * Stores agent webhook registrations with bounded capacity.
5
+ * Data is lost on restart (MVP limitation — future: Redis/SQLite).
6
+ */
7
+
8
+ import { randomBytes } from 'crypto'
9
+ import type { HexString } from '@sip-protocol/types'
10
+ import { logger } from '../logger'
11
+ import { env } from '../config'
12
+
13
+ /**
14
+ * Webhook registration entry
15
+ */
16
+ export interface WebhookRegistration {
17
+ id: string
18
+ url: string
19
+ viewingPrivateKey: HexString
20
+ spendingPublicKey: HexString
21
+ secret: string
22
+ createdAt: string
23
+ active: boolean
24
+ }
25
+
26
+ /**
27
+ * Bounded in-memory store for webhook registrations
28
+ */
29
+ export class WebhookStore {
30
+ private registrations = new Map<string, WebhookRegistration>()
31
+ private readonly maxSize: number
32
+
33
+ constructor(maxSize?: number) {
34
+ this.maxSize = maxSize ?? env.WEBHOOK_STORE_MAX_SIZE
35
+ logger.info({ maxSize: this.maxSize }, 'WebhookStore initialized')
36
+ }
37
+
38
+ /**
39
+ * Register a new webhook
40
+ *
41
+ * @returns The registration with secret (shown once only)
42
+ */
43
+ register(url: string, viewingPrivateKey: HexString, spendingPublicKey: HexString): WebhookRegistration {
44
+ if (this.registrations.size >= this.maxSize) {
45
+ throw new Error('WEBHOOK_STORE_FULL')
46
+ }
47
+
48
+ const id = randomBytes(16).toString('hex')
49
+ const secret = randomBytes(32).toString('hex')
50
+
51
+ const registration: WebhookRegistration = {
52
+ id,
53
+ url,
54
+ viewingPrivateKey,
55
+ spendingPublicKey,
56
+ secret,
57
+ createdAt: new Date().toISOString(),
58
+ active: true,
59
+ }
60
+
61
+ this.registrations.set(id, registration)
62
+ logger.info({ webhookId: id, url }, 'Webhook registered')
63
+ return registration
64
+ }
65
+
66
+ /**
67
+ * Unregister a webhook by ID
68
+ *
69
+ * @returns true if found and removed, false if not found
70
+ */
71
+ unregister(id: string): boolean {
72
+ const existed = this.registrations.delete(id)
73
+ if (existed) {
74
+ logger.info({ webhookId: id }, 'Webhook unregistered')
75
+ }
76
+ return existed
77
+ }
78
+
79
+ /**
80
+ * Get a registration by ID
81
+ */
82
+ get(id: string): WebhookRegistration | undefined {
83
+ return this.registrations.get(id)
84
+ }
85
+
86
+ /**
87
+ * Get all active registrations
88
+ */
89
+ getAll(): WebhookRegistration[] {
90
+ return Array.from(this.registrations.values()).filter(r => r.active)
91
+ }
92
+
93
+ /**
94
+ * Get all registrations (including inactive) for listing
95
+ */
96
+ getAllForList(): Array<{ id: string; url: string; active: boolean; createdAt: string }> {
97
+ return Array.from(this.registrations.values()).map(r => ({
98
+ id: r.id,
99
+ url: r.url,
100
+ active: r.active,
101
+ createdAt: r.createdAt,
102
+ }))
103
+ }
104
+
105
+ /**
106
+ * Current store size
107
+ */
108
+ get size(): number {
109
+ return this.registrations.size
110
+ }
111
+
112
+ /**
113
+ * Clear all registrations (for testing)
114
+ */
115
+ clear(): void {
116
+ this.registrations.clear()
117
+ }
118
+ }
119
+
120
+ export const webhookStore = new WebhookStore()
package/src/types/api.ts CHANGED
@@ -17,10 +17,28 @@ export interface ApiResponse<T = unknown> {
17
17
  * Health check response
18
18
  */
19
19
  export interface HealthResponse {
20
- status: 'healthy' | 'unhealthy'
20
+ status: 'healthy' | 'unhealthy' | 'shutting_down'
21
21
  version: string
22
22
  timestamp: string
23
23
  uptime: number
24
+ services?: {
25
+ proofProvider: {
26
+ ready: boolean
27
+ error: string | null
28
+ }
29
+ rateLimiter?: {
30
+ store: 'redis' | 'memory'
31
+ redisConfigured: boolean
32
+ redisConnected: boolean
33
+ }
34
+ }
35
+ cache?: {
36
+ swaps: {
37
+ size: number
38
+ maxSize: number
39
+ utilizationPercent: number
40
+ }
41
+ }
24
42
  }
25
43
 
26
44
  /**
@@ -121,3 +139,51 @@ export interface SwapStatusResponse {
121
139
  updatedAt: string
122
140
  error?: string
123
141
  }
142
+
143
+ /**
144
+ * Webhook registration request
145
+ */
146
+ export interface RegisterWebhookRequest {
147
+ url: string
148
+ viewingPrivateKey: HexString
149
+ spendingPublicKey: HexString
150
+ }
151
+
152
+ /**
153
+ * Webhook registration response (secret shown once only)
154
+ */
155
+ export interface RegisterWebhookResponse {
156
+ id: string
157
+ url: string
158
+ secret: string
159
+ createdAt: string
160
+ }
161
+
162
+ /**
163
+ * Webhook list item (never exposes keys or secret)
164
+ */
165
+ export interface WebhookListItem {
166
+ id: string
167
+ url: string
168
+ active: boolean
169
+ createdAt: string
170
+ }
171
+
172
+ /**
173
+ * Webhook delivery payload sent to agent URLs
174
+ */
175
+ export interface WebhookDeliveryPayload {
176
+ event: 'payment.received'
177
+ webhookId: string
178
+ timestamp: string
179
+ data: {
180
+ txSignature: string
181
+ stealthAddress: string
182
+ ephemeralPublicKey: string
183
+ amount: string
184
+ mint: string
185
+ tokenSymbol?: string
186
+ slot: number
187
+ blockTime: number
188
+ }
189
+ }