@tangle-network/agent-gateway 0.7.0 → 0.8.0
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.
- package/README.md +108 -6
- package/dist/chunk-GITV7CPT.js +84 -0
- package/dist/chunk-GITV7CPT.js.map +1 -0
- package/dist/chunk-J5SDVHOL.js +104 -0
- package/dist/chunk-J5SDVHOL.js.map +1 -0
- package/dist/chunk-MP6IIAIA.js +5651 -0
- package/dist/chunk-MP6IIAIA.js.map +1 -0
- package/dist/index.d.ts +76 -12
- package/dist/index.js +307 -21
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +7 -2
- package/dist/middleware.js +3 -2
- package/dist/nonce-store.d.ts +47 -11
- package/dist/nonce-store.js +9 -3
- package/dist/observer-types-A0RtA8uL.d.ts +95 -0
- package/dist/observer.d.ts +79 -0
- package/dist/observer.js +11 -0
- package/dist/observer.js.map +1 -0
- package/dist/{types-CX2V06cN.d.ts → types-BHISsm7D.d.ts} +423 -166
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/src/a2a/agent-card.ts +4 -3
- package/src/a2a/execution-fence.ts +162 -0
- package/src/a2a/handler.ts +507 -562
- package/src/a2a/message-send-execution.ts +241 -0
- package/src/a2a/message-stream-execution.ts +392 -0
- package/src/a2a/payment-recovery.ts +431 -0
- package/src/a2a/push-config-methods.ts +158 -0
- package/src/a2a/push-notifications.ts +172 -22
- package/src/a2a/task-cancellation.ts +50 -0
- package/src/a2a/task-finalization.ts +451 -0
- package/src/a2a/task-lifecycle.ts +54 -0
- package/src/a2a/task-methods.ts +163 -0
- package/src/a2a/task-push-delivery.ts +119 -0
- package/src/a2a/task-recovery.ts +11 -0
- package/src/a2a/task-state.ts +99 -0
- package/src/a2a/task-store-sql.ts +222 -24
- package/src/a2a/task-store.ts +58 -1
- package/src/a2a/task-submission-recovery.ts +178 -0
- package/src/a2a/types.ts +1 -0
- package/src/dispatch-authorization.ts +437 -0
- package/src/dispatch-payment-recovery.ts +248 -0
- package/src/dispatch-payment.ts +425 -0
- package/src/dispatch-pricing.ts +108 -0
- package/src/dispatch-sandbox.ts +422 -0
- package/src/dispatch-settlement.ts +139 -0
- package/src/dispatch-types.ts +81 -0
- package/src/dispatch.ts +35 -462
- package/src/index.ts +64 -2
- package/src/middleware.ts +313 -32
- package/src/mpp-payment.ts +117 -0
- package/src/nonce-store.ts +122 -20
- package/src/observer-types.ts +63 -0
- package/src/observer.ts +3 -63
- package/src/payment-operations.ts +485 -0
- package/src/payment-recovery-sql.ts +108 -0
- package/src/payment-recovery-worker.ts +488 -0
- package/src/payment-recovery.ts +331 -0
- package/src/payment-types.ts +48 -0
- package/src/types.ts +153 -42
- package/src/verify.ts +265 -36
- package/dist/chunk-3IKQWFKX.js +0 -1703
- package/dist/chunk-3IKQWFKX.js.map +0 -1
- package/dist/chunk-M7ZJAK4K.js +0 -53
- package/dist/chunk-M7ZJAK4K.js.map +0 -1
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PaymentSettlementBasis,
|
|
3
|
+
SandboxUsageReceipt,
|
|
4
|
+
} from './payment-types'
|
|
5
|
+
|
|
6
|
+
/** Version negotiated by gateways that use durable payment operations. */
|
|
7
|
+
export const PAYMENT_PROTOCOL_VERSION = 2 as const
|
|
8
|
+
|
|
9
|
+
export type PaymentOperationState =
|
|
10
|
+
| 'claiming'
|
|
11
|
+
| 'claimed'
|
|
12
|
+
| 'executing'
|
|
13
|
+
| 'retained'
|
|
14
|
+
| 'settling'
|
|
15
|
+
| 'settled'
|
|
16
|
+
| 'releasing'
|
|
17
|
+
| 'released'
|
|
18
|
+
| 'reclaimable'
|
|
19
|
+
| 'reclaimed'
|
|
20
|
+
|
|
21
|
+
/** Fenced result for a recovery lookup that found no provider operation. */
|
|
22
|
+
export interface PaymentOperationNotFound {
|
|
23
|
+
protocolVersion: typeof PAYMENT_PROTOCOL_VERSION
|
|
24
|
+
operationId: string
|
|
25
|
+
state: 'not-found'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PaymentOperationRecoveryResult = PaymentOperation | PaymentOperationNotFound
|
|
29
|
+
|
|
30
|
+
/** Durable ownership of one signed payment authorization. */
|
|
31
|
+
export interface PaymentOperation {
|
|
32
|
+
protocolVersion: typeof PAYMENT_PROTOCOL_VERSION
|
|
33
|
+
operationId: string
|
|
34
|
+
/** Request that atomically created this operation. Idempotent reads retain the original value. */
|
|
35
|
+
acquiredByRequestId: string
|
|
36
|
+
executionStartedAt?: number
|
|
37
|
+
retentionReason?: string
|
|
38
|
+
nonceKey: string
|
|
39
|
+
authorizationId: string
|
|
40
|
+
reservedAmount: bigint
|
|
41
|
+
settledAmount: bigint
|
|
42
|
+
refundAmount: bigint
|
|
43
|
+
expiresAt: number
|
|
44
|
+
state: PaymentOperationState
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PaymentAuthorizationContext {
|
|
48
|
+
requestId: string
|
|
49
|
+
agentId: string
|
|
50
|
+
requiredAmount: bigint
|
|
51
|
+
maxOutputTokens: number
|
|
52
|
+
executionBudget: {
|
|
53
|
+
maxInputTokens: number
|
|
54
|
+
maxOutputTokens: number
|
|
55
|
+
maxReasoningTokens: number
|
|
56
|
+
maxToolTokens: number
|
|
57
|
+
maxToolCalls: number
|
|
58
|
+
maxProviderCostUsd: number
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PaymentSettlementInput {
|
|
63
|
+
amount: bigint
|
|
64
|
+
totalCostUsd: number
|
|
65
|
+
usage: SandboxUsageReceipt
|
|
66
|
+
/** Distinguishes a provider receipt from the bounded missing-receipt fallback. */
|
|
67
|
+
basis: PaymentSettlementBasis
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One payment lifecycle shared by every payment-backed gateway surface.
|
|
72
|
+
* Implementations must persist the operation before external side effects.
|
|
73
|
+
*/
|
|
74
|
+
export interface PaymentOperations {
|
|
75
|
+
readonly protocolVersion: typeof PAYMENT_PROTOCOL_VERSION
|
|
76
|
+
claimPayment(
|
|
77
|
+
payload: Record<string, unknown>,
|
|
78
|
+
context: PaymentAuthorizationContext,
|
|
79
|
+
): Promise<PaymentOperation>
|
|
80
|
+
/** Prevent expiry reclaim while the sandbox can consume provider resources. */
|
|
81
|
+
beginPaymentExecution(operation: PaymentOperation): Promise<PaymentOperation>
|
|
82
|
+
/** Preserve funds after work when the final usage receipt is still missing. */
|
|
83
|
+
retainPayment(operation: PaymentOperation, reason: string): Promise<PaymentOperation>
|
|
84
|
+
settlePayment(
|
|
85
|
+
operation: PaymentOperation,
|
|
86
|
+
input: PaymentSettlementInput,
|
|
87
|
+
): Promise<PaymentOperation>
|
|
88
|
+
/**
|
|
89
|
+
* Read the authoritative durable operation without changing its state.
|
|
90
|
+
* Recovery uses this to avoid repeating a provider settlement after the
|
|
91
|
+
* provider committed but the task finalization write lost its acknowledgement.
|
|
92
|
+
*/
|
|
93
|
+
getPaymentOperation(operationId: string): Promise<PaymentOperationRecoveryResult>
|
|
94
|
+
/**
|
|
95
|
+
* Release an unused authorization.
|
|
96
|
+
* Repeated calls must recover an ambiguous acknowledgement by operationId.
|
|
97
|
+
*/
|
|
98
|
+
releasePayment(operation: PaymentOperation, reason: string): Promise<PaymentOperation>
|
|
99
|
+
reclaimPayment(operationId: string): Promise<PaymentOperationRecoveryResult>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface MemoryPaymentOperationsOptions {
|
|
103
|
+
now?: () => number
|
|
104
|
+
onClaim?: (operation: PaymentOperation) => Promise<void>
|
|
105
|
+
onSettle?: (operation: PaymentOperation, input: PaymentSettlementInput) => Promise<void>
|
|
106
|
+
onRelease?: (operation: PaymentOperation, reason: string) => Promise<void>
|
|
107
|
+
onReclaim?: (operation: PaymentOperation) => Promise<void>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Small atomic implementation used by single-process deployments and tests. */
|
|
111
|
+
export class MemoryPaymentOperations implements PaymentOperations {
|
|
112
|
+
readonly protocolVersion = PAYMENT_PROTOCOL_VERSION
|
|
113
|
+
private readonly operations = new Map<string, PaymentOperation>()
|
|
114
|
+
private readonly claimFlights = new Map<string, Promise<PaymentOperation>>()
|
|
115
|
+
private readonly claimTokens = new Map<string, string>()
|
|
116
|
+
private readonly settleFlights = new Map<string, Promise<PaymentOperation>>()
|
|
117
|
+
private readonly releaseFlights = new Map<string, Promise<PaymentOperation>>()
|
|
118
|
+
private readonly reclaimFlights = new Map<string, Promise<PaymentOperation>>()
|
|
119
|
+
private readonly now: () => number
|
|
120
|
+
|
|
121
|
+
constructor(private readonly options: MemoryPaymentOperationsOptions = {}) {
|
|
122
|
+
if ((options.onClaim || options.onSettle || options.onRelease) && !options.onReclaim) {
|
|
123
|
+
throw new Error('onReclaim is required when payment callbacks can lose acknowledgement')
|
|
124
|
+
}
|
|
125
|
+
this.now = options.now ?? (() => Math.floor(Date.now() / 1000))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async claimPayment(
|
|
129
|
+
payload: Record<string, unknown>,
|
|
130
|
+
context: PaymentAuthorizationContext,
|
|
131
|
+
): Promise<PaymentOperation> {
|
|
132
|
+
const nonceKey = paymentNonceKey(payload)
|
|
133
|
+
const operationId = `x402:${nonceKey}`
|
|
134
|
+
const existing = this.operations.get(operationId)
|
|
135
|
+
if (existing) {
|
|
136
|
+
if (existing.state === 'claiming') throw new Error('payment operation is being recovered')
|
|
137
|
+
throw new Error('payment operation was already claimed')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const reservedAmount = unsignedAmount(payload.amount)
|
|
141
|
+
if (reservedAmount < context.requiredAmount) {
|
|
142
|
+
throw new Error('payment authorization is below the request ceiling')
|
|
143
|
+
}
|
|
144
|
+
const expiresAt = unsignedAmount(payload.expiry, 'expiry')
|
|
145
|
+
if (expiresAt > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('payment expiry is too large')
|
|
146
|
+
if (Number(expiresAt) <= this.now()) throw new Error('payment authorization has expired')
|
|
147
|
+
|
|
148
|
+
const operation: PaymentOperation = {
|
|
149
|
+
protocolVersion: PAYMENT_PROTOCOL_VERSION,
|
|
150
|
+
operationId,
|
|
151
|
+
acquiredByRequestId: context.requestId,
|
|
152
|
+
nonceKey,
|
|
153
|
+
authorizationId: typeof payload.authHash === 'string' ? payload.authHash : operationId,
|
|
154
|
+
reservedAmount,
|
|
155
|
+
settledAmount: 0n,
|
|
156
|
+
refundAmount: reservedAmount,
|
|
157
|
+
expiresAt: Number(expiresAt),
|
|
158
|
+
state: 'claiming',
|
|
159
|
+
}
|
|
160
|
+
// The map write is synchronous. No second caller can observe a free nonce
|
|
161
|
+
// between the uniqueness check and ownership write.
|
|
162
|
+
this.operations.set(operationId, operation)
|
|
163
|
+
const claimToken = globalThis.crypto.randomUUID()
|
|
164
|
+
this.claimTokens.set(operationId, claimToken)
|
|
165
|
+
const claim = (async () => {
|
|
166
|
+
try {
|
|
167
|
+
await this.options.onClaim?.(operation)
|
|
168
|
+
if (this.claimTokens.get(operationId) !== claimToken) {
|
|
169
|
+
throw new Error('payment claim ownership was reclaimed')
|
|
170
|
+
}
|
|
171
|
+
const current = this.operations.get(operationId)
|
|
172
|
+
if (!current || current.state !== 'claiming') {
|
|
173
|
+
throw new Error('payment claim ownership was lost')
|
|
174
|
+
}
|
|
175
|
+
const claimed = { ...current, state: 'claimed' as const }
|
|
176
|
+
this.operations.set(operationId, claimed)
|
|
177
|
+
return claimed
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// Keep the durable `claiming` row. The external authorization may have
|
|
180
|
+
// committed before its acknowledgement was lost, so expiry recovery
|
|
181
|
+
// must own the decision to reclaim it.
|
|
182
|
+
throw error
|
|
183
|
+
} finally {
|
|
184
|
+
if (this.claimTokens.get(operationId) === claimToken) {
|
|
185
|
+
this.claimTokens.delete(operationId)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
})()
|
|
189
|
+
this.claimFlights.set(operationId, claim)
|
|
190
|
+
try {
|
|
191
|
+
return await claim
|
|
192
|
+
} finally {
|
|
193
|
+
if (this.claimFlights.get(operationId) === claim) {
|
|
194
|
+
this.claimFlights.delete(operationId)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async settlePayment(
|
|
200
|
+
operation: PaymentOperation,
|
|
201
|
+
input: PaymentSettlementInput,
|
|
202
|
+
): Promise<PaymentOperation> {
|
|
203
|
+
const current = this.requireCurrent(operation)
|
|
204
|
+
if (current.state === 'settled') {
|
|
205
|
+
if (current.settledAmount !== input.amount) throw new Error('payment operation was settled twice')
|
|
206
|
+
return current
|
|
207
|
+
}
|
|
208
|
+
if (current.state === 'settling') {
|
|
209
|
+
if (current.settledAmount !== input.amount) throw new Error('payment operation has a different pending settlement')
|
|
210
|
+
const flight = this.settleFlights.get(current.operationId)
|
|
211
|
+
if (flight) return flight
|
|
212
|
+
const reclaimFlight = this.reclaimFlights.get(current.operationId)
|
|
213
|
+
if (reclaimFlight) return reclaimFlight
|
|
214
|
+
const recovery = this.recoverSettlement(current, input)
|
|
215
|
+
this.settleFlights.set(current.operationId, recovery)
|
|
216
|
+
try {
|
|
217
|
+
return await recovery
|
|
218
|
+
} finally {
|
|
219
|
+
if (this.settleFlights.get(current.operationId) === recovery) {
|
|
220
|
+
this.settleFlights.delete(current.operationId)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} else if (current.state !== 'claimed' && current.state !== 'executing' && current.state !== 'retained') {
|
|
224
|
+
throw new Error(`cannot settle payment in state ${current.state}`)
|
|
225
|
+
}
|
|
226
|
+
validateSettlement(current, input)
|
|
227
|
+
const settling = {
|
|
228
|
+
...current,
|
|
229
|
+
state: 'settling' as const,
|
|
230
|
+
settledAmount: input.amount,
|
|
231
|
+
refundAmount: current.reservedAmount - input.amount,
|
|
232
|
+
}
|
|
233
|
+
this.operations.set(current.operationId, settling)
|
|
234
|
+
const flight = this.runSettlement(settling, input)
|
|
235
|
+
this.settleFlights.set(current.operationId, flight)
|
|
236
|
+
try {
|
|
237
|
+
return await flight
|
|
238
|
+
} finally {
|
|
239
|
+
if (this.settleFlights.get(current.operationId) === flight) {
|
|
240
|
+
this.settleFlights.delete(current.operationId)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async beginPaymentExecution(operation: PaymentOperation): Promise<PaymentOperation> {
|
|
246
|
+
const current = this.requireCurrent(operation)
|
|
247
|
+
if (current.state === 'executing') return current
|
|
248
|
+
if (current.state !== 'claimed') {
|
|
249
|
+
throw new Error(`cannot begin payment execution in state ${current.state}`)
|
|
250
|
+
}
|
|
251
|
+
const executing = {
|
|
252
|
+
...current,
|
|
253
|
+
state: 'executing' as const,
|
|
254
|
+
executionStartedAt: this.now(),
|
|
255
|
+
}
|
|
256
|
+
this.operations.set(current.operationId, executing)
|
|
257
|
+
return executing
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async retainPayment(operation: PaymentOperation, reason: string): Promise<PaymentOperation> {
|
|
261
|
+
const current = this.requireCurrent(operation)
|
|
262
|
+
if (current.state === 'retained' || current.state === 'settling' || current.state === 'settled') {
|
|
263
|
+
return current
|
|
264
|
+
}
|
|
265
|
+
if (current.state !== 'claimed' && current.state !== 'executing') {
|
|
266
|
+
throw new Error(`cannot retain payment in state ${current.state}`)
|
|
267
|
+
}
|
|
268
|
+
const retained = {
|
|
269
|
+
...current,
|
|
270
|
+
state: 'retained' as const,
|
|
271
|
+
retentionReason: reason,
|
|
272
|
+
}
|
|
273
|
+
this.operations.set(current.operationId, retained)
|
|
274
|
+
return retained
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async releasePayment(operation: PaymentOperation, reason: string): Promise<PaymentOperation> {
|
|
278
|
+
const current = this.requireCurrent(operation)
|
|
279
|
+
if (current.state === 'released') return current
|
|
280
|
+
if (current.state === 'releasing') {
|
|
281
|
+
const flight = this.releaseFlights.get(current.operationId)
|
|
282
|
+
if (flight) return flight
|
|
283
|
+
const recovered = await this.reclaimPayment(current.operationId)
|
|
284
|
+
if (recovered.state === 'not-found') {
|
|
285
|
+
throw new Error('payment operation disappeared during release recovery')
|
|
286
|
+
}
|
|
287
|
+
return recovered
|
|
288
|
+
}
|
|
289
|
+
if (current.state !== 'claimed' && current.state !== 'executing') {
|
|
290
|
+
throw new Error(`cannot release payment in state ${current.state}`)
|
|
291
|
+
}
|
|
292
|
+
const releasing = { ...current, state: 'releasing' as const }
|
|
293
|
+
this.operations.set(current.operationId, releasing)
|
|
294
|
+
return this.runRelease(releasing, reason)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async reclaimPayment(operationId: string): Promise<PaymentOperationRecoveryResult> {
|
|
298
|
+
const current = this.operations.get(operationId)
|
|
299
|
+
if (!current) {
|
|
300
|
+
return {
|
|
301
|
+
protocolVersion: PAYMENT_PROTOCOL_VERSION,
|
|
302
|
+
operationId,
|
|
303
|
+
state: 'not-found',
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (current.state === 'reclaimed') return current
|
|
307
|
+
if (current.state === 'executing' || current.state === 'retained') {
|
|
308
|
+
throw new Error(`cannot reclaim payment in state ${current.state} without a usage receipt`)
|
|
309
|
+
}
|
|
310
|
+
if (current.state === 'settling') {
|
|
311
|
+
const flight = this.settleFlights.get(operationId)
|
|
312
|
+
if (flight) return flight
|
|
313
|
+
const recoveryFlight = this.reclaimFlights.get(operationId)
|
|
314
|
+
if (recoveryFlight) return recoveryFlight
|
|
315
|
+
const recovery = this.recoverSettlement(current, {
|
|
316
|
+
amount: current.settledAmount,
|
|
317
|
+
totalCostUsd: 0,
|
|
318
|
+
basis: 'usage-receipt',
|
|
319
|
+
usage: {
|
|
320
|
+
inputTokens: 0,
|
|
321
|
+
outputTokens: 0,
|
|
322
|
+
reasoningTokens: 0,
|
|
323
|
+
toolTokens: 0,
|
|
324
|
+
toolCallCount: 0,
|
|
325
|
+
providerCostUsd: 0,
|
|
326
|
+
budgetEnforced: true,
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
this.reclaimFlights.set(operationId, recovery)
|
|
330
|
+
try {
|
|
331
|
+
return await recovery
|
|
332
|
+
} finally {
|
|
333
|
+
if (this.reclaimFlights.get(operationId) === recovery) this.reclaimFlights.delete(operationId)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (current.state === 'releasing') {
|
|
337
|
+
if (!this.options.onReclaim) throw new Error('payment release recovery is not configured')
|
|
338
|
+
const releaseFlight = this.releaseFlights.get(operationId)
|
|
339
|
+
if (releaseFlight) return releaseFlight
|
|
340
|
+
const flight = this.reclaimFlights.get(operationId)
|
|
341
|
+
if (flight) return flight
|
|
342
|
+
const recovery = this.runReclaim(current, 'released')
|
|
343
|
+
this.reclaimFlights.set(operationId, recovery)
|
|
344
|
+
try {
|
|
345
|
+
return await recovery
|
|
346
|
+
} finally {
|
|
347
|
+
if (this.reclaimFlights.get(operationId) === recovery) this.reclaimFlights.delete(operationId)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (current.state !== 'claiming' && current.state !== 'claimed' && current.state !== 'reclaimable') {
|
|
351
|
+
throw new Error(`cannot reclaim payment in state ${current.state}`)
|
|
352
|
+
}
|
|
353
|
+
const flight = this.reclaimFlights.get(operationId)
|
|
354
|
+
if (flight) return flight
|
|
355
|
+
if (current.expiresAt > this.now()) throw new Error('payment operation has not expired')
|
|
356
|
+
if (current.state === 'claiming') {
|
|
357
|
+
// Invalidate the live claim before starting recovery. Its callback may
|
|
358
|
+
// still finish, but it can no longer promote the operation to claimed.
|
|
359
|
+
this.claimTokens.delete(operationId)
|
|
360
|
+
}
|
|
361
|
+
const reclaimable = { ...current, state: 'reclaimable' as const }
|
|
362
|
+
this.operations.set(operationId, reclaimable)
|
|
363
|
+
const recovery = this.runReclaim(reclaimable, 'reclaimed')
|
|
364
|
+
this.reclaimFlights.set(operationId, recovery)
|
|
365
|
+
try {
|
|
366
|
+
return await recovery
|
|
367
|
+
} finally {
|
|
368
|
+
if (this.reclaimFlights.get(operationId) === recovery) this.reclaimFlights.delete(operationId)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async runSettlement(
|
|
373
|
+
settling: PaymentOperation,
|
|
374
|
+
input: PaymentSettlementInput,
|
|
375
|
+
): Promise<PaymentOperation> {
|
|
376
|
+
await this.options.onSettle?.(settling, input)
|
|
377
|
+
const settled = {
|
|
378
|
+
...settling,
|
|
379
|
+
state: 'settled' as const,
|
|
380
|
+
settledAmount: input.amount,
|
|
381
|
+
refundAmount: settling.reservedAmount - input.amount,
|
|
382
|
+
}
|
|
383
|
+
this.operations.set(settling.operationId, settled)
|
|
384
|
+
return settled
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private async recoverSettlement(
|
|
388
|
+
settling: PaymentOperation,
|
|
389
|
+
input: PaymentSettlementInput,
|
|
390
|
+
): Promise<PaymentOperation> {
|
|
391
|
+
if (!this.options.onReclaim) throw new Error('payment settlement recovery is not configured')
|
|
392
|
+
await this.options.onReclaim(settling)
|
|
393
|
+
const settled = {
|
|
394
|
+
...settling,
|
|
395
|
+
state: 'settled' as const,
|
|
396
|
+
settledAmount: input.amount,
|
|
397
|
+
refundAmount: settling.reservedAmount - input.amount,
|
|
398
|
+
}
|
|
399
|
+
this.operations.set(settling.operationId, settled)
|
|
400
|
+
return settled
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async runReclaim(
|
|
404
|
+
operation: PaymentOperation,
|
|
405
|
+
finalState: 'released' | 'reclaimed',
|
|
406
|
+
): Promise<PaymentOperation> {
|
|
407
|
+
await this.options.onReclaim?.(operation)
|
|
408
|
+
const recovered = { ...operation, state: finalState as PaymentOperationState }
|
|
409
|
+
this.operations.set(operation.operationId, recovered)
|
|
410
|
+
return recovered
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private async runRelease(
|
|
414
|
+
releasing: PaymentOperation,
|
|
415
|
+
reason: string,
|
|
416
|
+
): Promise<PaymentOperation> {
|
|
417
|
+
const flight = (async () => {
|
|
418
|
+
await this.options.onRelease?.(releasing, reason)
|
|
419
|
+
const released = { ...releasing, state: 'released' as const }
|
|
420
|
+
this.operations.set(releasing.operationId, released)
|
|
421
|
+
return released
|
|
422
|
+
})()
|
|
423
|
+
this.releaseFlights.set(releasing.operationId, flight)
|
|
424
|
+
try {
|
|
425
|
+
return await flight
|
|
426
|
+
} finally {
|
|
427
|
+
if (this.releaseFlights.get(releasing.operationId) === flight) {
|
|
428
|
+
this.releaseFlights.delete(releasing.operationId)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
get(operationId: string): PaymentOperation | undefined {
|
|
434
|
+
const operation = this.operations.get(operationId)
|
|
435
|
+
return operation ? { ...operation } : undefined
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async getPaymentOperation(operationId: string): Promise<PaymentOperationRecoveryResult> {
|
|
439
|
+
const operation = this.get(operationId)
|
|
440
|
+
return operation ?? {
|
|
441
|
+
protocolVersion: PAYMENT_PROTOCOL_VERSION,
|
|
442
|
+
operationId,
|
|
443
|
+
state: 'not-found',
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private requireCurrent(operation: PaymentOperation): PaymentOperation {
|
|
448
|
+
const current = this.operations.get(operation.operationId)
|
|
449
|
+
if (!current) throw new Error('payment operation was not found')
|
|
450
|
+
if (current.protocolVersion !== operation.protocolVersion) {
|
|
451
|
+
throw new Error('payment operation protocol version mismatch')
|
|
452
|
+
}
|
|
453
|
+
return current
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function paymentNonceKey(payload: Record<string, unknown>): string {
|
|
458
|
+
const commitment = String(payload.commitment ?? '').toLowerCase()
|
|
459
|
+
const nonce = unsignedAmount(payload.nonce, 'nonce').toString()
|
|
460
|
+
if (!commitment) throw new Error('payment commitment is required')
|
|
461
|
+
return `${commitment}:${nonce}`
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function unsignedAmount(value: unknown, name = 'amount'): bigint {
|
|
465
|
+
const raw = typeof value === 'string'
|
|
466
|
+
? value
|
|
467
|
+
: typeof value === 'number' && Number.isSafeInteger(value)
|
|
468
|
+
? String(value)
|
|
469
|
+
: ''
|
|
470
|
+
if (!/^\d+$/.test(raw)) throw new Error(`${name} is not an unsigned integer`)
|
|
471
|
+
return BigInt(raw)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function validateSettlement(operation: PaymentOperation, input: PaymentSettlementInput): void {
|
|
475
|
+
if (input.amount < 0n || input.amount > operation.reservedAmount) {
|
|
476
|
+
throw new Error('settled amount must be between zero and the reserved amount')
|
|
477
|
+
}
|
|
478
|
+
if (!Number.isFinite(input.totalCostUsd) || input.totalCostUsd < 0) {
|
|
479
|
+
throw new Error('settlement cost must be finite and non-negative')
|
|
480
|
+
}
|
|
481
|
+
if (!input.usage.budgetEnforced) throw new Error('sandbox usage receipt is not budget-enforced')
|
|
482
|
+
if (input.basis !== 'usage-receipt' && input.basis !== 'quoted-ceiling') {
|
|
483
|
+
throw new Error('payment settlement basis is invalid')
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { SqlAdapter } from './a2a/task-store-sql'
|
|
2
|
+
import type {
|
|
3
|
+
PaymentRecoveryRecord,
|
|
4
|
+
PaymentRecoveryStore,
|
|
5
|
+
} from './payment-recovery'
|
|
6
|
+
|
|
7
|
+
const TABLE_DDL = (table: string) => `
|
|
8
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
state TEXT NOT NULL,
|
|
11
|
+
next_attempt_at INTEGER NOT NULL,
|
|
12
|
+
revision INTEGER NOT NULL,
|
|
13
|
+
payload TEXT NOT NULL,
|
|
14
|
+
updated_at INTEGER NOT NULL
|
|
15
|
+
)
|
|
16
|
+
`
|
|
17
|
+
|
|
18
|
+
const DUE_INDEX_DDL = (table: string) => `
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_${table}_due
|
|
20
|
+
ON ${table} (state, next_attempt_at)
|
|
21
|
+
`
|
|
22
|
+
|
|
23
|
+
/** Durable recovery outbox for D1, sqlite, libSQL, or an adapted SQL driver. */
|
|
24
|
+
export class SqlPaymentRecoveryStore implements PaymentRecoveryStore {
|
|
25
|
+
private readonly table: string
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly db: SqlAdapter,
|
|
29
|
+
options: { table?: string } = {},
|
|
30
|
+
) {
|
|
31
|
+
this.table = options.table ?? 'payment_recovery'
|
|
32
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(this.table)) {
|
|
33
|
+
throw new Error('payment recovery table name is invalid')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Idempotent. Run this before the gateway starts accepting traffic. */
|
|
38
|
+
async migrate(): Promise<void> {
|
|
39
|
+
await this.db.exec(TABLE_DDL(this.table))
|
|
40
|
+
await this.db.exec(DUE_INDEX_DDL(this.table))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async createIfAbsent(record: PaymentRecoveryRecord): Promise<boolean> {
|
|
44
|
+
try {
|
|
45
|
+
const result = await this.db.exec(
|
|
46
|
+
`INSERT INTO ${this.table} (id, state, next_attempt_at, revision, payload, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
47
|
+
[
|
|
48
|
+
record.id,
|
|
49
|
+
record.state,
|
|
50
|
+
record.nextAttemptAt,
|
|
51
|
+
record.revision,
|
|
52
|
+
JSON.stringify(record),
|
|
53
|
+
record.updatedAt,
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
return result.rowsAffected === 1
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (await this.get(record.id)) return false
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async get(id: string): Promise<PaymentRecoveryRecord | undefined> {
|
|
64
|
+
const rows = await this.db.query<{ payload: string }>(
|
|
65
|
+
`SELECT payload FROM ${this.table} WHERE id = ?`,
|
|
66
|
+
[id],
|
|
67
|
+
)
|
|
68
|
+
return rows[0] ? parseRecord(rows[0].payload) : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async compareAndSet(
|
|
72
|
+
expected: PaymentRecoveryRecord,
|
|
73
|
+
next: PaymentRecoveryRecord,
|
|
74
|
+
): Promise<boolean> {
|
|
75
|
+
const result = await this.db.exec(
|
|
76
|
+
`UPDATE ${this.table} SET state = ?, next_attempt_at = ?, revision = ?, payload = ?, updated_at = ? WHERE id = ? AND revision = ?`,
|
|
77
|
+
[
|
|
78
|
+
next.state,
|
|
79
|
+
next.nextAttemptAt,
|
|
80
|
+
next.revision,
|
|
81
|
+
JSON.stringify(next),
|
|
82
|
+
next.updatedAt,
|
|
83
|
+
expected.id,
|
|
84
|
+
expected.revision,
|
|
85
|
+
],
|
|
86
|
+
)
|
|
87
|
+
return result.rowsAffected === 1
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async listDue(now: number, limit: number): Promise<PaymentRecoveryRecord[]> {
|
|
91
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
92
|
+
throw new Error('payment recovery scan limit must be a positive safe integer')
|
|
93
|
+
}
|
|
94
|
+
const rows = await this.db.query<{ payload: string }>(
|
|
95
|
+
`SELECT payload FROM ${this.table} WHERE state <> ? AND next_attempt_at <= ? ORDER BY next_attempt_at ASC LIMIT ?`,
|
|
96
|
+
['reconciled', now, limit],
|
|
97
|
+
)
|
|
98
|
+
return rows.map((row) => parseRecord(row.payload))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseRecord(payload: string): PaymentRecoveryRecord {
|
|
103
|
+
const value = JSON.parse(payload) as PaymentRecoveryRecord
|
|
104
|
+
if (value.version !== 1 || !value.id || !Number.isSafeInteger(value.revision)) {
|
|
105
|
+
throw new Error('stored payment recovery record is invalid')
|
|
106
|
+
}
|
|
107
|
+
return value
|
|
108
|
+
}
|