@tangle-network/agent-gateway 0.7.1 → 0.8.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.
Files changed (64) hide show
  1. package/README.md +90 -3
  2. package/dist/chunk-C7Z2BRYV.js +5693 -0
  3. package/dist/chunk-C7Z2BRYV.js.map +1 -0
  4. package/dist/chunk-GITV7CPT.js +84 -0
  5. package/dist/chunk-GITV7CPT.js.map +1 -0
  6. package/dist/chunk-J5SDVHOL.js +104 -0
  7. package/dist/chunk-J5SDVHOL.js.map +1 -0
  8. package/dist/index.d.ts +70 -10
  9. package/dist/index.js +303 -21
  10. package/dist/index.js.map +1 -1
  11. package/dist/middleware.d.ts +7 -2
  12. package/dist/middleware.js +3 -2
  13. package/dist/nonce-store.d.ts +47 -11
  14. package/dist/nonce-store.js +9 -3
  15. package/dist/observer-types-A0RtA8uL.d.ts +95 -0
  16. package/dist/observer.d.ts +79 -0
  17. package/dist/observer.js +11 -0
  18. package/dist/observer.js.map +1 -0
  19. package/dist/{types-DEsMmS-X.d.ts → types-oQ58UakD.d.ts} +447 -172
  20. package/dist/types.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/a2a/execution-fence.ts +162 -0
  23. package/src/a2a/handler.ts +506 -560
  24. package/src/a2a/message-send-execution.ts +241 -0
  25. package/src/a2a/message-stream-execution.ts +392 -0
  26. package/src/a2a/payment-recovery.ts +431 -0
  27. package/src/a2a/push-config-methods.ts +158 -0
  28. package/src/a2a/push-notifications.ts +172 -22
  29. package/src/a2a/task-cancellation.ts +50 -0
  30. package/src/a2a/task-finalization.ts +451 -0
  31. package/src/a2a/task-lifecycle.ts +54 -0
  32. package/src/a2a/task-methods.ts +163 -0
  33. package/src/a2a/task-push-delivery.ts +119 -0
  34. package/src/a2a/task-recovery.ts +11 -0
  35. package/src/a2a/task-state.ts +99 -0
  36. package/src/a2a/task-store-sql.ts +222 -24
  37. package/src/a2a/task-store.ts +58 -1
  38. package/src/a2a/task-submission-recovery.ts +178 -0
  39. package/src/a2a/types.ts +1 -0
  40. package/src/dispatch-authorization.ts +468 -0
  41. package/src/dispatch-payment-recovery.ts +248 -0
  42. package/src/dispatch-payment.ts +425 -0
  43. package/src/dispatch-pricing.ts +108 -0
  44. package/src/dispatch-sandbox.ts +424 -0
  45. package/src/dispatch-settlement.ts +139 -0
  46. package/src/dispatch-types.ts +84 -0
  47. package/src/dispatch.ts +35 -483
  48. package/src/index.ts +59 -1
  49. package/src/middleware.ts +339 -35
  50. package/src/mpp-payment.ts +117 -0
  51. package/src/nonce-store.ts +122 -20
  52. package/src/observer-types.ts +63 -0
  53. package/src/observer.ts +3 -63
  54. package/src/payment-operations.ts +485 -0
  55. package/src/payment-recovery-sql.ts +108 -0
  56. package/src/payment-recovery-worker.ts +488 -0
  57. package/src/payment-recovery.ts +331 -0
  58. package/src/payment-types.ts +48 -0
  59. package/src/types.ts +188 -49
  60. package/src/verify.ts +240 -71
  61. package/dist/chunk-M7ZJAK4K.js +0 -53
  62. package/dist/chunk-M7ZJAK4K.js.map +0 -1
  63. package/dist/chunk-Q4YAIEZY.js +0 -1763
  64. package/dist/chunk-Q4YAIEZY.js.map +0 -1
@@ -0,0 +1,248 @@
1
+ import {
2
+ PAYMENT_RECOVERY_VERSION,
3
+ PaymentRecoveryFenceError,
4
+ PaymentRecoveryReplayError,
5
+ recoveryTiming,
6
+ serializePaymentOperation,
7
+ updateOwnedPaymentRecovery,
8
+ type PaymentRecoveryRecord,
9
+ type PaymentRecoveryTarget,
10
+ type PaymentSettlementBasis,
11
+ } from './payment-recovery'
12
+ import type { GatewayConfig, SandboxUsageReceipt } from './types'
13
+ import type {
14
+ AuthorizedRequest,
15
+ PaymentClaimHooks,
16
+ } from './dispatch-types'
17
+
18
+ export function paymentAuthorizationContext(authz: AuthorizedRequest) {
19
+ return {
20
+ requestId: authz.requestId,
21
+ agentId: authz.agent.id,
22
+ requiredAmount: authz.requiredPaymentAmount,
23
+ maxOutputTokens: authz.maxOutputTokens,
24
+ executionBudget: authz.executionBudget,
25
+ }
26
+ }
27
+
28
+ export async function preparePaymentRecovery(
29
+ authz: AuthorizedRequest,
30
+ config: GatewayConfig,
31
+ payment: PaymentRecoveryTarget,
32
+ hooks: PaymentClaimHooks,
33
+ ): Promise<void> {
34
+ const recovery = config.paymentRecovery
35
+ if (!recovery) throw new Error('durable payment recovery is not configured')
36
+ const now = Date.now()
37
+ const fenceId = globalThis.crypto.randomUUID()
38
+ const leaseExpiresAt = now + recoveryTiming(recovery).staleRequestMs
39
+ const record: PaymentRecoveryRecord = {
40
+ version: PAYMENT_RECOVERY_VERSION,
41
+ id: payment.operationId,
42
+ revision: 0,
43
+ state: 'claiming',
44
+ payment,
45
+ attribution: {
46
+ requestId: authz.requestId,
47
+ agentId: authz.agent.id,
48
+ agentSlug: authz.agent.slug,
49
+ consumerId: authz.consumerId,
50
+ paymentMethod: authz.paymentMethod,
51
+ startMs: authz.startMs,
52
+ pricePerTokenUsd: authz.agent.pricePerTokenUsd,
53
+ platformFeePercent: authz.agent.platformFeePercent,
54
+ requiredAmount: authz.requiredPaymentAmount.toString(),
55
+ currencyDecimals: config.x402.currencyDecimals ?? 6,
56
+ maxOutputTokens: authz.maxOutputTokens,
57
+ executionBudget: authz.executionBudget,
58
+ },
59
+ workStarted: false,
60
+ usageRecorded: false,
61
+ attempts: 0,
62
+ nextAttemptAt: leaseExpiresAt,
63
+ lease: { id: fenceId, expiresAt: leaseExpiresAt },
64
+ createdAt: now,
65
+ updatedAt: now,
66
+ }
67
+ if (!await recovery.store.createIfAbsent(record)) {
68
+ if ((await recovery.store.get(record.id))?.state === 'reconciled') {
69
+ throw new PaymentRecoveryReplayError(record.id)
70
+ }
71
+ throw new Error('payment recovery identity was already claimed')
72
+ }
73
+ authz.paymentRecoveryId = record.id
74
+ authz.paymentRecoveryFence = fenceId
75
+ await hooks.onRecoveryPrepared?.(record.id)
76
+ }
77
+
78
+ export async function markRecoveryClaimed(
79
+ authz: AuthorizedRequest,
80
+ config: GatewayConfig,
81
+ ): Promise<void> {
82
+ const recovery = config.paymentRecovery
83
+ if (!recovery || !authz.paymentRecoveryId) return
84
+ const fenceId = requirePaymentRecoveryFence(authz)
85
+ const now = Date.now()
86
+ const leaseExpiresAt = now + recoveryTiming(recovery).staleRequestMs
87
+ await updateRecovery(authz, config, (record) => ({
88
+ ...record,
89
+ state: 'claimed',
90
+ payment: recoveryTarget(authz, record.payment),
91
+ lease: { id: fenceId, expiresAt: leaseExpiresAt },
92
+ nextAttemptAt: leaseExpiresAt,
93
+ }), now)
94
+ }
95
+
96
+ export function requirePaymentRecoveryFence(authz: AuthorizedRequest): string {
97
+ if (!authz.paymentRecoveryFence) {
98
+ throw new Error('payment recovery fence is unavailable')
99
+ }
100
+ return authz.paymentRecoveryFence
101
+ }
102
+
103
+ async function updateRecovery(
104
+ authz: AuthorizedRequest,
105
+ config: GatewayConfig,
106
+ update: (record: PaymentRecoveryRecord) => PaymentRecoveryRecord,
107
+ now = Date.now(),
108
+ ): Promise<PaymentRecoveryRecord | undefined> {
109
+ const recovery = config.paymentRecovery
110
+ if (!recovery || !authz.paymentRecoveryId) return undefined
111
+ return updateOwnedPaymentRecovery(
112
+ recovery.store,
113
+ authz.paymentRecoveryId,
114
+ requirePaymentRecoveryFence(authz),
115
+ update,
116
+ now,
117
+ )
118
+ }
119
+
120
+ export function recoveryTarget(
121
+ authz: AuthorizedRequest,
122
+ current: PaymentRecoveryTarget,
123
+ ): PaymentRecoveryTarget {
124
+ if (authz.paymentOperation) {
125
+ return {
126
+ kind: 'x402',
127
+ operationId: authz.paymentOperation.operationId,
128
+ operation: serializePaymentOperation(authz.paymentOperation),
129
+ }
130
+ }
131
+ if (authz.mppChargeOperation) {
132
+ return {
133
+ kind: 'mpp-charge',
134
+ method: authz.mppChargeOperation.method,
135
+ operationId: authz.mppChargeOperation.operationId,
136
+ operation: authz.mppChargeOperation,
137
+ }
138
+ }
139
+ return current
140
+ }
141
+
142
+ export async function relinquishPaymentRecovery(
143
+ authz: AuthorizedRequest,
144
+ config: GatewayConfig,
145
+ nextAttemptAt: number,
146
+ ): Promise<void> {
147
+ const recovery = config.paymentRecovery
148
+ if (!recovery || !authz.paymentRecoveryId || !authz.paymentRecoveryFence) return
149
+ try {
150
+ await updateOwnedPaymentRecovery(
151
+ recovery.store,
152
+ authz.paymentRecoveryId,
153
+ authz.paymentRecoveryFence,
154
+ (record) => ({ ...record, lease: undefined, nextAttemptAt }),
155
+ )
156
+ } catch (error) {
157
+ if (!(error instanceof PaymentRecoveryFenceError)) throw error
158
+ }
159
+ }
160
+
161
+ export async function markRecoveryReleasing(
162
+ authz: AuthorizedRequest,
163
+ config: GatewayConfig,
164
+ reason: string,
165
+ ): Promise<void> {
166
+ const recovery = config.paymentRecovery
167
+ if (!recovery || !authz.paymentRecoveryId) return
168
+ await updateRecovery(authz, config, (record) => ({
169
+ ...record,
170
+ state: 'releasing',
171
+ payment: recoveryTarget(authz, record.payment),
172
+ reason,
173
+ nextAttemptAt: Date.now(),
174
+ }))
175
+ }
176
+
177
+ export async function markRecoveryReconciled(
178
+ authz: AuthorizedRequest,
179
+ config: GatewayConfig,
180
+ ): Promise<void> {
181
+ const recovery = config.paymentRecovery
182
+ if (!recovery || !authz.paymentRecoveryId) return
183
+ const now = Date.now()
184
+ await updateRecovery(authz, config, (record) => ({
185
+ ...record,
186
+ state: 'reconciled',
187
+ payment: recoveryTarget(authz, record.payment),
188
+ lease: undefined,
189
+ lastError: undefined,
190
+ nextAttemptAt: Number.MAX_SAFE_INTEGER,
191
+ reconciledAt: now,
192
+ }), now)
193
+ }
194
+
195
+ export function assertX402V1SettlementSafe(authz: AuthorizedRequest, config: GatewayConfig): void {
196
+ if (
197
+ authz.paymentMethod === 'x402' &&
198
+ config.x402.paymentProtocolVersion !== 2 &&
199
+ !config.x402.demoMode &&
200
+ config.settlePayment
201
+ ) {
202
+ throw new Error(
203
+ 'production x402 version 1 cannot use settlePayment; ' +
204
+ 'use paymentProtocolVersion: 2 with paymentOperations',
205
+ )
206
+ }
207
+ }
208
+
209
+ export async function markRecoverySettling(
210
+ authz: AuthorizedRequest,
211
+ usage: SandboxUsageReceipt,
212
+ settlementBasis: PaymentSettlementBasis,
213
+ config: GatewayConfig,
214
+ ): Promise<void> {
215
+ const recovery = config.paymentRecovery
216
+ if (!recovery || !authz.paymentRecoveryId) return
217
+ await updateRecovery(authz, config, (record) => {
218
+ const next: PaymentRecoveryRecord = {
219
+ ...record,
220
+ state: 'settling',
221
+ payment: recoveryTarget(authz, record.payment),
222
+ workStarted: true,
223
+ settlementBasis,
224
+ nextAttemptAt: Date.now(),
225
+ }
226
+ // A quoted-ceiling settlement has no provider receipt. Keep the durable
227
+ // basis and original amount, then rebuild the synthetic accounting input
228
+ // on each retry instead of persisting a lossy floating-point surrogate.
229
+ if (settlementBasis !== 'quoted-ceiling' || record.usage !== undefined) {
230
+ next.usage = usage
231
+ } else {
232
+ delete next.usage
233
+ }
234
+ return next
235
+ })
236
+ }
237
+
238
+ export async function markRecoveryUsageRecorded(
239
+ authz: AuthorizedRequest,
240
+ config: GatewayConfig,
241
+ ): Promise<void> {
242
+ const recovery = config.paymentRecovery
243
+ if (!recovery || !authz.paymentRecoveryId) return
244
+ await updateRecovery(authz, config, (record) => ({
245
+ ...record,
246
+ usageRecorded: true,
247
+ }))
248
+ }
@@ -0,0 +1,425 @@
1
+ import {
2
+ assertMppChargeOperation,
3
+ mppPaymentOperationId,
4
+ } from './mpp-payment'
5
+ import { claimStoredNonce, nonceTtlSeconds, type NonceStore } from './nonce-store'
6
+ import {
7
+ paymentNonceKey,
8
+ type PaymentOperation,
9
+ type PaymentOperationRecoveryResult,
10
+ } from './payment-operations'
11
+ import { recoveryTiming, updateOwnedPaymentRecovery } from './payment-recovery'
12
+ import type {
13
+ AuthorizedRequest,
14
+ GatewayState,
15
+ PaymentClaimHooks,
16
+ } from './dispatch-types'
17
+ import {
18
+ assertX402V1SettlementSafe,
19
+ markRecoveryClaimed,
20
+ markRecoveryReconciled,
21
+ markRecoveryReleasing,
22
+ paymentAuthorizationContext,
23
+ preparePaymentRecovery,
24
+ recoveryTarget,
25
+ relinquishPaymentRecovery,
26
+ requirePaymentRecoveryFence,
27
+ } from './dispatch-payment-recovery'
28
+ import type { GatewayConfig } from './types'
29
+
30
+ /** Claim payment ownership after every request guard has accepted the call. */
31
+ export async function claimPayment(
32
+ authz: AuthorizedRequest,
33
+ config: GatewayConfig,
34
+ state: GatewayState,
35
+ hooks: PaymentClaimHooks = {},
36
+ ): Promise<void> {
37
+ assertX402V1SettlementSafe(authz, config)
38
+ if (authz.paymentMethod === 'x402' && authz.paymentPayload) {
39
+ const context = paymentAuthorizationContext(authz)
40
+ if (config.x402.paymentProtocolVersion === 2) {
41
+ await preparePaymentRecovery(authz, config, {
42
+ kind: 'x402',
43
+ operationId: `x402:${paymentNonceKey(authz.paymentPayload)}`,
44
+ }, hooks)
45
+ }
46
+ let operation: PaymentOperation | undefined
47
+ if (config.x402.authorizePayment) {
48
+ if (config.x402.paymentProtocolVersion !== 2 && !config.x402.demoMode) {
49
+ throw new Error(
50
+ 'production x402 version 1 cannot use authorizePayment; ' +
51
+ 'use paymentProtocolVersion: 2 with paymentOperations',
52
+ )
53
+ }
54
+ // Version 1 has no durable operation to release if another request wins
55
+ // the shared nonce while this callback is still running. This callback
56
+ // remains only for explicit demo-mode compatibility; production callers
57
+ // must use the durable version 2 operation lifecycle.
58
+ const legacyClaimed = config.x402.paymentProtocolVersion !== 2 && authz.paymentNonceKey
59
+ ? await claimPaymentNonce(state.nonceStore, authz.paymentNonceKey, authz.paymentPayload)
60
+ : undefined
61
+ if (legacyClaimed === false) throw new Error('payment nonce was already consumed')
62
+ const result = await config.x402.authorizePayment(authz.paymentPayload, context)
63
+ if (!result) throw new Error('payment authorization was rejected')
64
+ if (typeof result !== 'boolean') {
65
+ operation = result
66
+ }
67
+ else if (config.x402.paymentProtocolVersion === 2) {
68
+ throw new Error('version 2 payment authorization did not return an operation')
69
+ } else if (authz.paymentNonceKey && legacyClaimed === undefined) {
70
+ const claimed = await claimPaymentNonce(state.nonceStore, authz.paymentNonceKey, authz.paymentPayload)
71
+ if (!claimed) throw new Error('payment nonce was already consumed')
72
+ }
73
+ } else if (config.x402.paymentOperations) {
74
+ operation = await config.x402.paymentOperations.claimPayment(authz.paymentPayload, context)
75
+ } else if (authz.paymentNonceKey) {
76
+ const claimed = await claimPaymentNonce(state.nonceStore, authz.paymentNonceKey, authz.paymentPayload)
77
+ if (!claimed) throw new Error('payment nonce was already consumed')
78
+ }
79
+ if (operation && operation.protocolVersion !== 2) {
80
+ throw new Error('payment operation protocol version mismatch')
81
+ }
82
+ if (
83
+ operation &&
84
+ config.x402.paymentProtocolVersion === 2 &&
85
+ operation.operationId !== authz.paymentRecoveryId
86
+ ) {
87
+ throw new Error('x402 payment operation identity mismatch')
88
+ }
89
+ if (operation && !config.x402.paymentOperations) {
90
+ throw new Error('durable payment operations are required to settle a claimed operation')
91
+ }
92
+ if (operation) {
93
+ authz.paymentOperationAcquired = operation.acquiredByRequestId === context.requestId
94
+ if (!authz.paymentOperationAcquired) {
95
+ throw new Error('payment operation was already claimed')
96
+ }
97
+ // Attach owned state before the shared nonce claim. If that claim fails,
98
+ // the caller can persist release recovery after an ambiguous refund.
99
+ authz.paymentOperation = operation
100
+ await markRecoveryClaimed(authz, config)
101
+ }
102
+ if (operation && authz.paymentNonceKey) {
103
+ const claimed = await claimPaymentNonce(
104
+ state.nonceStore,
105
+ authz.paymentNonceKey,
106
+ authz.paymentPayload,
107
+ `${operation.operationId}:${context.requestId}`,
108
+ )
109
+ if (!claimed) {
110
+ await releaseAfterNonceConflict(authz, config)
111
+ throw new Error('payment nonce was already consumed')
112
+ }
113
+ }
114
+ } else if (authz.paymentMethod === 'mpp') {
115
+ if (!authz.paymentNonceKey) {
116
+ throw new Error('MPP payment has no replay identity')
117
+ }
118
+ const mppMethod = (authz.mppMethod ?? config.mpp?.method ?? 'blueprintevm').toLowerCase()
119
+ const durablePayload = durableMppPaymentPayload(authz.paymentPayload)
120
+ // Only BlueprinTEVM carries x402 authorization fields. Other MPP methods
121
+ // use the isolated immediate-charge lifecycle below.
122
+ if (mppMethod === 'blueprintevm' && durablePayload && config.x402.paymentOperations) {
123
+ const context = paymentAuthorizationContext(authz)
124
+ await preparePaymentRecovery(authz, config, {
125
+ kind: 'x402',
126
+ operationId: `x402:${paymentNonceKey(durablePayload)}`,
127
+ }, hooks)
128
+ const operation = await config.x402.paymentOperations.claimPayment(durablePayload, context)
129
+ if (operation.protocolVersion !== 2) throw new Error('payment operation protocol version mismatch')
130
+ if (operation.operationId !== authz.paymentRecoveryId) {
131
+ throw new Error('x402 payment operation identity mismatch')
132
+ }
133
+ if (operation.acquiredByRequestId !== context.requestId) {
134
+ throw new Error('payment operation was already claimed')
135
+ }
136
+ authz.paymentPayload = durablePayload
137
+ authz.paymentOperation = operation
138
+ authz.paymentOperationAcquired = true
139
+ await markRecoveryClaimed(authz, config)
140
+ const claimed = await claimPaymentNonce(
141
+ state.nonceStore,
142
+ authz.paymentNonceKey,
143
+ durablePayload,
144
+ `${operation.operationId}:${context.requestId}`,
145
+ )
146
+ if (!claimed) {
147
+ await releaseAfterNonceConflict(authz, config)
148
+ throw new Error('payment nonce was already consumed')
149
+ }
150
+ } else if (mppMethod === 'blueprintevm') {
151
+ const claimed = await claimPaymentNonce(state.nonceStore, authz.paymentNonceKey, authz.paymentPayload ?? {})
152
+ if (!claimed) throw new Error('payment nonce was already consumed')
153
+ } else {
154
+ const lifecycle = config.mpp?.charge
155
+ if (!lifecycle || lifecycle.protocolVersion !== 1) {
156
+ throw new Error('MPP charge lifecycle is not configured')
157
+ }
158
+ if (!authz.mppCredential) throw new Error('MPP payment credential is unavailable')
159
+ if (!authz.mppPaymentIdentity) throw new Error('MPP payment identity is unavailable')
160
+ const operationId = await mppPaymentOperationId(mppMethod, authz.mppPaymentIdentity)
161
+ await preparePaymentRecovery(authz, config, {
162
+ kind: 'mpp-charge',
163
+ method: mppMethod,
164
+ operationId,
165
+ }, hooks)
166
+ const claimed = await claimPaymentNonce(
167
+ state.nonceStore,
168
+ authz.paymentNonceKey,
169
+ authz.paymentPayload ?? {},
170
+ `${operationId}:${authz.requestId}`,
171
+ )
172
+ if (!claimed) {
173
+ await markRecoveryReconciled(authz, config)
174
+ throw new Error('payment nonce was already consumed')
175
+ }
176
+ const operation = await lifecycle.confirmPayment({
177
+ operationId,
178
+ requestId: authz.requestId,
179
+ agentId: authz.agent.id,
180
+ consumerId: authz.consumerId,
181
+ method: mppMethod,
182
+ credential: authz.mppCredential,
183
+ amount: authz.requiredPaymentAmount,
184
+ currencyDecimals: config.x402.currencyDecimals ?? 6,
185
+ })
186
+ assertMppChargeOperation(
187
+ operation,
188
+ { operationId, requestId: authz.requestId, method: mppMethod },
189
+ ['confirmed'],
190
+ false,
191
+ )
192
+ authz.mppChargeOperation = operation
193
+ await markRecoveryClaimed(authz, config)
194
+ assertMppChargeOperation(
195
+ operation,
196
+ { operationId, requestId: authz.requestId, method: mppMethod },
197
+ ['confirmed'],
198
+ )
199
+ }
200
+ }
201
+
202
+ try {
203
+ await state.obs?.onPaymentVerified?.(
204
+ {
205
+ requestId: authz.requestId,
206
+ agentSlug: authz.agent.slug,
207
+ startMs: authz.startMs,
208
+ },
209
+ {
210
+ method: authz.paymentMethod,
211
+ consumerId: authz.consumerId,
212
+ keyId: authz.keyInfo?.keyId,
213
+ },
214
+ )
215
+ } catch (error) {
216
+ // Observability must not turn a durable claim into a stranded payment.
217
+ console.error(
218
+ '[agent-gateway] payment observer failed for ' + authz.requestId + ':',
219
+ error instanceof Error ? error.message : String(error),
220
+ )
221
+ }
222
+ }
223
+
224
+ /** Release an owned operation when execution cannot produce a valid receipt. */
225
+ export async function releasePayment(
226
+ authz: AuthorizedRequest,
227
+ config: GatewayConfig,
228
+ reason: string,
229
+ ): Promise<void> {
230
+ const ownsX402 = authz.paymentOperation &&
231
+ authz.paymentOperationAcquired === true &&
232
+ config.x402.paymentOperations
233
+ const ownsMpp = authz.mppChargeOperation && config.mpp?.charge
234
+ if (!ownsX402 && !ownsMpp) {
235
+ await relinquishPaymentRecovery(authz, config, Date.now())
236
+ return
237
+ }
238
+ let reconciled = false
239
+ try {
240
+ await markRecoveryReleasing(authz, config, reason)
241
+ if (ownsX402) {
242
+ authz.paymentOperation = await config.x402.paymentOperations!.releasePayment(
243
+ authz.paymentOperation!,
244
+ reason,
245
+ )
246
+ } else {
247
+ const operation = await config.mpp!.charge!.releasePayment(authz.mppChargeOperation!, reason)
248
+ assertMppChargeOperation(
249
+ operation,
250
+ {
251
+ operationId: authz.mppChargeOperation!.operationId,
252
+ requestId: authz.requestId,
253
+ method: authz.mppChargeOperation!.method,
254
+ },
255
+ ['released'],
256
+ false,
257
+ )
258
+ authz.mppChargeOperation = operation
259
+ }
260
+ await markRecoveryReconciled(authz, config)
261
+ reconciled = true
262
+ } finally {
263
+ if (!reconciled) {
264
+ try {
265
+ await relinquishPaymentRecovery(authz, config, Date.now())
266
+ } catch {
267
+ // Preserve the original provider or metadata error. A later worker
268
+ // retry still has the durable row when cleanup itself is unavailable.
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ /** Mark a durable reservation active immediately before sandbox execution. */
275
+ export async function beginPaymentExecution(
276
+ authz: AuthorizedRequest,
277
+ config: GatewayConfig,
278
+ ): Promise<void> {
279
+ if (authz.paymentOperation && authz.paymentOperationAcquired === true && config.x402.paymentOperations) {
280
+ authz.paymentOperation = await config.x402.paymentOperations.beginPaymentExecution(authz.paymentOperation)
281
+ }
282
+ }
283
+
284
+ /** Persist the sandbox handoff immediately before the adapter call. */
285
+ export async function markPaymentExecutionStarted(
286
+ authz: AuthorizedRequest,
287
+ config: GatewayConfig,
288
+ ): Promise<void> {
289
+ await updateExecutionLease(authz, config, true)
290
+ }
291
+
292
+ /** Renew the live execution lease while a provider stream is still open. */
293
+ export async function renewPaymentExecution(
294
+ authz: AuthorizedRequest,
295
+ config: GatewayConfig,
296
+ ): Promise<void> {
297
+ await updateExecutionLease(authz, config, false)
298
+ }
299
+
300
+ async function updateExecutionLease(
301
+ authz: AuthorizedRequest,
302
+ config: GatewayConfig,
303
+ markStarted: boolean,
304
+ ): Promise<void> {
305
+ if (!authz.paymentRecoveryId) return
306
+ const recovery = config.paymentRecovery
307
+ if (!recovery) throw new Error('durable payment recovery is not configured')
308
+ const fenceId = requirePaymentRecoveryFence(authz)
309
+ const now = Date.now()
310
+ const fallbackAt = now + recoveryTiming(recovery).receiptTimeoutMs
311
+ await updateOwnedPaymentRecovery(recovery.store, authz.paymentRecoveryId, fenceId, (record) => ({
312
+ ...record,
313
+ state: 'executing',
314
+ ...(markStarted
315
+ ? { payment: recoveryTarget(authz, record.payment), workStarted: true }
316
+ : {}),
317
+ fallbackAt,
318
+ lease: { id: fenceId, expiresAt: fallbackAt },
319
+ nextAttemptAt: fallbackAt,
320
+ }), now)
321
+ }
322
+
323
+ /**
324
+ * Release only when no sandbox work was observed. Once output or a receipt
325
+ * exists, retain the owner for settlement or background recovery.
326
+ */
327
+ export async function releasePaymentAfterFailure(
328
+ authz: AuthorizedRequest,
329
+ config: GatewayConfig,
330
+ reason: string,
331
+ workObserved: boolean,
332
+ ): Promise<void> {
333
+ if (workObserved) {
334
+ const recovery = config.paymentRecovery
335
+ if (recovery && authz.paymentRecoveryId) {
336
+ const fenceId = requirePaymentRecoveryFence(authz)
337
+ await updateOwnedPaymentRecovery(recovery.store, authz.paymentRecoveryId, fenceId, (record) => {
338
+ if (record.state === 'settling' && record.usage) {
339
+ return { ...record, lease: undefined, nextAttemptAt: Date.now() }
340
+ }
341
+ const fallbackAt = record.fallbackAt ??
342
+ Date.now() + recoveryTiming(recovery).receiptTimeoutMs
343
+ return {
344
+ ...record,
345
+ state: 'retained',
346
+ payment: recoveryTarget(authz, record.payment),
347
+ workStarted: true,
348
+ fallbackAt,
349
+ reason,
350
+ lease: undefined,
351
+ nextAttemptAt: fallbackAt,
352
+ }
353
+ })
354
+ }
355
+ if (authz.paymentOperation && authz.paymentOperationAcquired === true && config.x402.paymentOperations) {
356
+ authz.paymentOperation = await config.x402.paymentOperations.retainPayment(authz.paymentOperation, reason)
357
+ }
358
+ console.error(
359
+ `[agent-gateway] retaining payment ownership after sandbox work for ${authz.requestId}: ${reason}`,
360
+ )
361
+ return
362
+ }
363
+ await releasePayment(authz, config, reason)
364
+ }
365
+
366
+ export async function reclaimPayment(
367
+ operationId: string,
368
+ config: GatewayConfig,
369
+ ): Promise<PaymentOperationRecoveryResult> {
370
+ if (!config.x402.paymentOperations) throw new Error('durable payment operations are not configured')
371
+ return config.x402.paymentOperations.reclaimPayment(operationId)
372
+ }
373
+
374
+ async function releaseAfterNonceConflict(
375
+ authz: AuthorizedRequest,
376
+ config: GatewayConfig,
377
+ ): Promise<void> {
378
+ try {
379
+ await releasePayment(authz, config, 'shared payment nonce was already owned')
380
+ } catch (releaseError) {
381
+ console.error(
382
+ `[agent-gateway] payment release failed for ${authz.requestId}:`,
383
+ releaseError instanceof Error ? releaseError.message : String(releaseError),
384
+ )
385
+ }
386
+ }
387
+
388
+ async function claimPaymentNonce(
389
+ nonceStore: NonceStore,
390
+ nonceKey: string,
391
+ payload: Record<string, unknown>,
392
+ ownerId?: string,
393
+ ): Promise<boolean> {
394
+ const expiry = payload.expiry === undefined
395
+ ? BigInt(Math.floor(Date.now() / 1000) + 3600)
396
+ : BigInt(String(payload.expiry))
397
+ const ttl = nonceTtlSeconds(expiry)
398
+ if (ttl === undefined) return false
399
+ return claimStoredNonce(nonceStore, nonceKey, ttl, ownerId)
400
+ }
401
+
402
+ function durableMppPaymentPayload(
403
+ payload: Record<string, unknown> | null,
404
+ ): Record<string, unknown> | undefined {
405
+ if (!payload) return undefined
406
+ const commitment = payload.commitment ?? payload.from
407
+ const amount = payload.amount ?? payload.value
408
+ const nonce = payload.nonce
409
+ if (typeof commitment !== 'string' || commitment.length === 0) return undefined
410
+ if (amount === undefined || nonce === undefined) return undefined
411
+ const amountText = String(amount)
412
+ const nonceText = String(nonce)
413
+ if (!/^\d+$/.test(amountText) || !/^\d+$/.test(nonceText)) return undefined
414
+ const expiryText = payload.expiry === undefined
415
+ ? String(Math.floor(Date.now() / 1000) + 3600)
416
+ : String(payload.expiry)
417
+ if (!/^\d+$/.test(expiryText)) return undefined
418
+ return {
419
+ ...payload,
420
+ commitment,
421
+ amount: amountText,
422
+ nonce: nonceText,
423
+ expiry: expiryText,
424
+ }
425
+ }