Skip to content

Commit 87b92d6

Browse files
fix(exchange): move execution state to DB, add callback verifier
P0.3: Remove in-memory execution state from internal provider. Canonical execution state now lives in exchange_executions table. Add execution-state.ts transition rules and callback-verifier.ts for HMAC-SHA-256 provider callback authentication. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent f58f034 commit 87b92d6

4 files changed

Lines changed: 354 additions & 30 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import type { ExecutionState } from './types'
2+
3+
// ─── Execution State Transition Rules ───
4+
//
5+
// Defines the allowed transitions for the normalized execution lifecycle.
6+
// Stale events, invalid regressions, and terminal-state updates are rejected.
7+
// Rejected/stale observations can be stored for audit but cannot regress
8+
// canonical execution state.
9+
10+
const TERMINAL_STATES: Set<ExecutionState> = new Set([
11+
'failed',
12+
'cancelled',
13+
'disputed',
14+
'expired',
15+
'settled',
16+
])
17+
18+
/**
19+
* The forward progression of the execution lifecycle.
20+
* Index represents the ordinal position in the lifecycle.
21+
*/
22+
const STATE_ORDER: Record<ExecutionState, number> = {
23+
created: 0,
24+
offered: 1,
25+
accepted: 2,
26+
funded: 3,
27+
executing: 4,
28+
delivered: 5,
29+
verified: 6,
30+
settled: 7,
31+
failed: 100,
32+
cancelled: 101,
33+
disputed: 102,
34+
expired: 103,
35+
}
36+
37+
/**
38+
* Allowed transitions from each state.
39+
* A state can transition to itself (idempotent duplicate).
40+
*/
41+
const ALLOWED_TRANSITIONS: Record<ExecutionState, Set<ExecutionState>> = {
42+
created: new Set(['offered', 'accepted', 'funded', 'executing', 'delivered', 'verified', 'settled', 'failed', 'cancelled', 'disputed', 'expired']),
43+
offered: new Set(['accepted', 'funded', 'executing', 'delivered', 'verified', 'settled', 'failed', 'cancelled', 'disputed', 'expired', 'offered']),
44+
accepted: new Set(['funded', 'executing', 'delivered', 'verified', 'settled', 'failed', 'cancelled', 'disputed', 'expired', 'accepted']),
45+
funded: new Set(['executing', 'delivered', 'verified', 'settled', 'failed', 'cancelled', 'disputed', 'expired', 'funded']),
46+
executing: new Set(['delivered', 'verified', 'settled', 'failed', 'cancelled', 'disputed', 'expired', 'executing']),
47+
delivered: new Set(['verified', 'settled', 'failed', 'cancelled', 'disputed', 'expired', 'delivered']),
48+
verified: new Set(['settled', 'disputed', 'failed', 'verified']),
49+
settled: new Set(['disputed', 'settled']),
50+
failed: new Set(['disputed', 'failed']),
51+
cancelled: new Set(['cancelled']),
52+
disputed: new Set(['settled', 'failed', 'disputed']),
53+
expired: new Set(['expired']),
54+
}
55+
56+
export interface TransitionResult {
57+
allowed: boolean
58+
reason: string
59+
is_duplicate: boolean
60+
}
61+
62+
/**
63+
* Check if a transition from currentState to newState is allowed.
64+
*
65+
* Rules:
66+
* - Terminal states (except disputed) cannot be left except to disputed
67+
* - Forward transitions are always allowed
68+
* - Duplicate transitions (same state) are idempotent
69+
* - Regressions (going backward) are rejected
70+
* - Dispute can be raised after delivery, verification, settlement, or failure
71+
*/
72+
export function validateTransition(
73+
currentState: ExecutionState,
74+
newState: ExecutionState,
75+
): TransitionResult {
76+
// Same state = idempotent duplicate
77+
if (currentState === newState) {
78+
return { allowed: true, reason: 'duplicate transition (idempotent)', is_duplicate: true }
79+
}
80+
81+
// Check if this transition is in the allowed set
82+
const allowed = ALLOWED_TRANSITIONS[currentState]
83+
if (!allowed || !allowed.has(newState)) {
84+
// Check if it's a regression
85+
if (STATE_ORDER[newState] < STATE_ORDER[currentState] && !TERMINAL_STATES.has(currentState)) {
86+
return {
87+
allowed: false,
88+
reason: `stale event: cannot regress from '${currentState}' to '${newState}'`,
89+
is_duplicate: false,
90+
}
91+
}
92+
return {
93+
allowed: false,
94+
reason: `invalid transition from '${currentState}' to '${newState}'`,
95+
is_duplicate: false,
96+
}
97+
}
98+
99+
return { allowed: true, reason: 'valid forward transition', is_duplicate: false }
100+
}
101+
102+
/**
103+
* Check if a state is terminal (no further forward transitions possible,
104+
* except dispute).
105+
*/
106+
export function isTerminalState(state: ExecutionState): boolean {
107+
return TERMINAL_STATES.has(state)
108+
}
109+
110+
/**
111+
* Map a receipt status to a normalized execution state.
112+
*/
113+
export function receiptStatusToState(status: string): ExecutionState {
114+
switch (status) {
115+
case 'delivered': return 'delivered'
116+
case 'verified': return 'verified'
117+
case 'failed': return 'failed'
118+
case 'cancelled': return 'cancelled'
119+
case 'disputed': return 'disputed'
120+
default: return 'delivered'
121+
}
122+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { createHmac, timingSafeEqual } from 'node:crypto'
2+
3+
// ─── Provider Callback Authentication ───
4+
//
5+
// External provider callbacks must be cryptographically authenticated
6+
// before they can update execution state. The verifier abstraction
7+
// keeps provider-specific signature formats behind the interface so
8+
// canonical execution types stay clean.
9+
//
10+
// HMAC-SHA-256 is the temporary minimum for providers without a native
11+
// asymmetric webhook-signature scheme.
12+
13+
export interface VerifiedProviderEvent {
14+
providerId: string
15+
providerEventId: string
16+
timestamp: Date
17+
nonce?: string
18+
payloadHash: string
19+
}
20+
21+
export interface ProviderCallbackVerifier {
22+
providerId: string
23+
verify(input: {
24+
rawBody: Uint8Array
25+
headers: Headers
26+
receivedAt: Date
27+
}): Promise<VerifiedProviderEvent>
28+
}
29+
30+
// ─── HMAC-SHA-256 Verifier ───
31+
32+
export interface HmacCredential {
33+
keyId: string
34+
secret: string
35+
}
36+
37+
const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
38+
39+
/**
40+
* Compute the HMAC-SHA-256 signature over the exact raw body.
41+
*
42+
* The signed material is:
43+
* provider_event_id.timestamp.rawBody
44+
*
45+
* This binds the event ID, timestamp, and body together so that
46+
* replaying the same signature with a different body, event ID,
47+
* or timestamp fails.
48+
*/
49+
function computeSignature(secret: string, providerEventId: string, timestamp: string, rawBody: Uint8Array): Buffer {
50+
const signedMaterial = Buffer.concat([
51+
Buffer.from(`${providerEventId}.${timestamp}.`, 'utf8'),
52+
Buffer.from(rawBody),
53+
])
54+
return createHmac('sha256', secret).update(signedMaterial).digest()
55+
}
56+
57+
/**
58+
* Constant-time comparison of two buffers.
59+
* Returns true if they are equal in length and content.
60+
*/
61+
function safeEqual(a: Buffer, b: Buffer): boolean {
62+
if (a.length !== b.length) return false
63+
return timingSafeEqual(a, b)
64+
}
65+
66+
/**
67+
* Parse a signature header in the format:
68+
* t=<unix_ms>,v1=<hex_sig>,key_id=<keyId>
69+
*/
70+
function parseSignatureHeader(header: string | null): {
71+
timestamp: string | null
72+
signature: string | null
73+
keyId: string | null
74+
} {
75+
if (!header) return { timestamp: null, signature: null, keyId: null }
76+
const parts = header.split(',')
77+
const map: Record<string, string> = {}
78+
for (const part of parts) {
79+
const eq = part.indexOf('=')
80+
if (eq > 0) map[part.slice(0, eq).trim()] = part.slice(eq + 1).trim()
81+
}
82+
return {
83+
timestamp: map['t'] ?? null,
84+
signature: map['v1'] ?? null,
85+
keyId: map['key_id'] ?? null,
86+
}
87+
}
88+
89+
/**
90+
* Create an HMAC-SHA-256 callback verifier for a specific provider.
91+
*
92+
* Credentials are scoped to one provider and one environment.
93+
* Supports key rotation by accepting both current and previous credentials.
94+
*/
95+
export function createHmacVerifier(
96+
providerId: string,
97+
credentials: HmacCredential[],
98+
): ProviderCallbackVerifier {
99+
if (credentials.length === 0) {
100+
throw new Error(`HMAC verifier for provider '${providerId}' requires at least one credential`)
101+
}
102+
103+
return {
104+
providerId,
105+
106+
async verify(input: {
107+
rawBody: Uint8Array
108+
headers: Headers
109+
receivedAt: Date
110+
}): Promise<VerifiedProviderEvent> {
111+
const { rawBody, headers, receivedAt } = input
112+
113+
const sigHeader = headers.get('x-provider-signature')
114+
const { timestamp, signature, keyId } = parseSignatureHeader(sigHeader)
115+
116+
if (!timestamp || !signature || !keyId) {
117+
throw new VerificationError('missing_signature', 'Signature header is missing or malformed')
118+
}
119+
120+
// Find the credential by key ID
121+
const credential = credentials.find((c) => c.keyId === keyId)
122+
if (!credential) {
123+
throw new VerificationError('unknown_key', `Unknown key ID: ${keyId}`)
124+
}
125+
126+
// Check timestamp window
127+
const eventTime = new Date(Number(timestamp))
128+
if (isNaN(eventTime.getTime())) {
129+
throw new VerificationError('invalid_timestamp', 'Timestamp is not a valid number')
130+
}
131+
const ageMs = Math.abs(receivedAt.getTime() - eventTime.getTime())
132+
if (ageMs > TIMESTAMP_WINDOW_MS) {
133+
throw new VerificationError('expired', `Callback timestamp is outside the ${TIMESTAMP_WINDOW_MS}ms window`)
134+
}
135+
136+
// Get provider event ID from header
137+
const providerEventId = headers.get('x-provider-event-id')
138+
if (!providerEventId) {
139+
throw new VerificationError('missing_event_id', 'Provider event ID header is missing')
140+
}
141+
142+
// Compute expected signature over exact raw body
143+
const expectedSig = computeSignature(credential.secret, providerEventId, timestamp, rawBody)
144+
const providedSig = Buffer.from(signature, 'hex')
145+
146+
if (!safeEqual(expectedSig, providedSig)) {
147+
throw new VerificationError('invalid_signature', 'Signature does not match the raw body')
148+
}
149+
150+
// Get optional nonce
151+
const nonce = headers.get('x-provider-nonce') ?? undefined
152+
153+
// Compute payload hash for idempotency
154+
const payloadHash = createHashSha256(rawBody)
155+
156+
return {
157+
providerId,
158+
providerEventId,
159+
timestamp: eventTime,
160+
nonce,
161+
payloadHash,
162+
}
163+
},
164+
}
165+
}
166+
167+
/**
168+
* Custom error class for verification failures.
169+
* Contains a safe code that can be logged without exposing secrets.
170+
*/
171+
export class VerificationError extends Error {
172+
constructor(
173+
public readonly code: string,
174+
message: string,
175+
) {
176+
super(message)
177+
this.name = 'VerificationError'
178+
}
179+
}
180+
181+
/**
182+
* Compute SHA-256 hash of a buffer, return hex string.
183+
*/
184+
function createHashSha256(data: Uint8Array): string {
185+
const { createHash } = require('node:crypto')
186+
return createHash('sha256').update(Buffer.from(data)).digest('hex')
187+
}
188+
189+
// ─── Verifier Registry ───
190+
191+
const verifiers = new Map<string, ProviderCallbackVerifier>()
192+
193+
/**
194+
* Register a callback verifier for a provider.
195+
*/
196+
export function registerVerifier(verifier: ProviderCallbackVerifier): void {
197+
verifiers.set(verifier.providerId, verifier)
198+
}
199+
200+
/**
201+
* Get a registered verifier by provider ID.
202+
*/
203+
export function getVerifier(providerId: string): ProviderCallbackVerifier | undefined {
204+
return verifiers.get(providerId)
205+
}
206+
207+
/**
208+
* Check if a verifier is registered for a provider.
209+
*/
210+
export function hasVerifier(providerId: string): boolean {
211+
return verifiers.has(providerId)
212+
}

0 commit comments

Comments
 (0)