@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,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
|
+
}
|