mppx 0.8.13 → 0.8.14
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/CHANGELOG.md +16 -0
- package/dist/Errors.d.ts +10 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +11 -2
- package/dist/Errors.js.map +1 -1
- package/dist/Method.d.ts +105 -3
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +102 -2
- package/dist/Method.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +12 -2
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/MethodChallenge.d.ts +17 -0
- package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
- package/dist/client/internal/MethodChallenge.js +11 -0
- package/dist/client/internal/MethodChallenge.js.map +1 -0
- package/dist/server/Mppx.d.ts +23 -4
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +91 -82
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +1 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +7 -2
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.js +2 -1
- package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +38 -77
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +289 -209
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +12 -1
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Relay.d.ts +48 -0
- package/dist/tempo/server/Relay.d.ts.map +1 -0
- package/dist/tempo/server/Relay.js +177 -0
- package/dist/tempo/server/Relay.js.map +1 -0
- package/dist/tempo/server/SponsorBudget.d.ts +59 -0
- package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
- package/dist/tempo/server/SponsorBudget.js +144 -0
- package/dist/tempo/server/SponsorBudget.js.map +1 -0
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
- package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/session/client/ChannelOps.js +9 -4
- package/dist/tempo/session/client/ChannelOps.js.map +1 -1
- package/dist/tempo/session/client/CredentialState.d.ts +5 -1
- package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
- package/dist/tempo/session/client/CredentialState.js +37 -19
- package/dist/tempo/session/client/CredentialState.js.map +1 -1
- package/dist/tempo/session/client/Runtime.d.ts +8 -0
- package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
- package/dist/tempo/session/client/Runtime.js +17 -0
- package/dist/tempo/session/client/Runtime.js.map +1 -1
- package/dist/tempo/session/client/Session.d.ts +15 -5
- package/dist/tempo/session/client/Session.d.ts.map +1 -1
- package/dist/tempo/session/client/Session.js +93 -36
- package/dist/tempo/session/client/Session.js.map +1 -1
- package/dist/tempo/session/client/SessionManager.d.ts +8 -1
- package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +54 -26
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/client/Transports.d.ts +7 -5
- package/dist/tempo/session/client/Transports.d.ts.map +1 -1
- package/dist/tempo/session/client/Transports.js +48 -24
- package/dist/tempo/session/client/Transports.js.map +1 -1
- package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Chain.js +96 -8
- package/dist/tempo/session/precompile/Chain.js.map +1 -1
- package/dist/tempo/session/precompile/Channel.d.ts +2 -0
- package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Channel.js +4 -0
- package/dist/tempo/session/precompile/Channel.js.map +1 -1
- package/dist/tempo/session/server/RequestState.js +3 -1
- package/dist/tempo/session/server/RequestState.js.map +1 -1
- package/package.json +1 -1
- package/src/Errors.test.ts +23 -0
- package/src/Errors.ts +21 -2
- package/src/Method.test.ts +102 -1
- package/src/Method.ts +241 -5
- package/src/client/Mppx.test-d.ts +3 -3
- package/src/client/internal/Fetch.ts +16 -7
- package/src/client/internal/MethodChallenge.ts +30 -0
- package/src/server/Mppx.test-d.ts +2 -0
- package/src/server/Mppx.test.ts +205 -0
- package/src/server/Mppx.ts +167 -103
- package/src/tempo/PublicExports.test-d.ts +20 -0
- package/src/tempo/internal/fee-payer.ts +7 -3
- package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
- package/src/tempo/legacy/client/ChannelOps.ts +2 -1
- package/src/tempo/server/Charge.test.ts +337 -63
- package/src/tempo/server/Charge.ts +435 -267
- package/src/tempo/server/Methods.ts +11 -0
- package/src/tempo/server/Relay.test.ts +523 -0
- package/src/tempo/server/Relay.ts +288 -0
- package/src/tempo/server/SponsorBudget.test.ts +125 -0
- package/src/tempo/server/SponsorBudget.ts +213 -0
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/client/ChannelOps.test.ts +17 -2
- package/src/tempo/session/client/ChannelOps.ts +12 -4
- package/src/tempo/session/client/CredentialState.ts +61 -20
- package/src/tempo/session/client/Runtime.test.ts +12 -0
- package/src/tempo/session/client/Runtime.ts +22 -0
- package/src/tempo/session/client/Session.test.ts +235 -2
- package/src/tempo/session/client/Session.ts +130 -41
- package/src/tempo/session/client/SessionManager.test.ts +102 -4
- package/src/tempo/session/client/SessionManager.ts +71 -27
- package/src/tempo/session/client/Transports.test.ts +32 -1
- package/src/tempo/session/client/Transports.ts +79 -35
- package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
- package/src/tempo/session/precompile/Chain.test.ts +197 -10
- package/src/tempo/session/precompile/Chain.ts +102 -8
- package/src/tempo/session/precompile/Channel.test.ts +9 -0
- package/src/tempo/session/precompile/Channel.ts +5 -0
- package/src/tempo/session/server/RequestState.test.ts +8 -1
- package/src/tempo/session/server/RequestState.ts +2 -2
|
@@ -37,6 +37,8 @@ import * as Selectors from '../internal/selectors.js'
|
|
|
37
37
|
import type * as types from '../internal/types.js'
|
|
38
38
|
import * as Methods from '../Methods.js'
|
|
39
39
|
import { html as htmlContent } from './internal/html.gen.js'
|
|
40
|
+
import * as Relay from './Relay.js'
|
|
41
|
+
import * as SponsorBudget from './SponsorBudget.js'
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
44
|
* Creates a Tempo charge method intent for usage on the server.
|
|
@@ -53,7 +55,7 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
53
55
|
parameters,
|
|
54
56
|
charge.Parameters
|
|
55
57
|
>,
|
|
56
|
-
) {
|
|
58
|
+
): Method.Server<typeof Methods.charge, charge.DeriveDefaults<parameters>> {
|
|
57
59
|
const {
|
|
58
60
|
amount,
|
|
59
61
|
currency = defaults.resolveCurrency(parameters),
|
|
@@ -64,16 +66,30 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
64
66
|
feePayerPolicy,
|
|
65
67
|
html,
|
|
66
68
|
memo,
|
|
69
|
+
relay,
|
|
67
70
|
splits,
|
|
68
71
|
supportedModes,
|
|
69
72
|
validateSender,
|
|
70
73
|
waitForConfirmation = true,
|
|
71
74
|
} = parameters
|
|
72
75
|
const storeKeyPrefix = parameters.storeKeyPrefix ?? ''
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const rawStore = (parameters.store ?? Store.memory()) as Store.AtomicStore<charge.StoreItemMap>
|
|
77
|
+
const store = Store.from(rawStore, { keyPrefix: storeKeyPrefix })
|
|
78
|
+
// Aggregate exposure belongs to the actual sponsor, not a caller-specific
|
|
79
|
+
// replay namespace. Sharing the raw store gives every tenant/process the same
|
|
80
|
+
// atomic sponsor-wide budget.
|
|
81
|
+
const sponsorBudgetStore = Store.from(rawStore) as Store.AtomicStore<{
|
|
82
|
+
[key: `mppx:charge:sponsor-budget:${string}`]: SponsorBudget.State
|
|
83
|
+
}>
|
|
84
|
+
const {
|
|
85
|
+
maxInFlightReservations = 100,
|
|
86
|
+
maxInFlightTotalFee = (feePayerPolicy?.maxTotalFee ?? 50_000_000_000_000_000n) * 10n,
|
|
87
|
+
...transactionFeePayerPolicy
|
|
88
|
+
} = feePayerPolicy ?? {}
|
|
89
|
+
if (!Number.isSafeInteger(maxInFlightReservations) || maxInFlightReservations <= 0)
|
|
90
|
+
throw new Error('`feePayerPolicy.maxInFlightReservations` must be a positive safe integer.')
|
|
91
|
+
if (maxInFlightTotalFee <= 0n)
|
|
92
|
+
throw new Error('`feePayerPolicy.maxInFlightTotalFee` must be greater than zero.')
|
|
77
93
|
const proofStore = parameters.store
|
|
78
94
|
? Store.from(parameters.store as Store.AtomicStore<charge.StoreItemMap>, {
|
|
79
95
|
keyPrefix: storeKeyPrefix,
|
|
@@ -91,8 +107,259 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
91
107
|
rpcUrl: defaults.rpcUrl,
|
|
92
108
|
})
|
|
93
109
|
|
|
110
|
+
function resolveRequest(request: Method.VerifyContext<typeof Methods.charge>['request']) {
|
|
111
|
+
const parsed = Methods.charge.schema.request.safeParse(request)
|
|
112
|
+
if (parsed.success) return parsed.data
|
|
113
|
+
// Credential handlers receive the HMAC-bound request in canonical output
|
|
114
|
+
// form, so it must not be transformed a second time.
|
|
115
|
+
return request as unknown as z.output<typeof Methods.charge.schema.request>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolveCredentialContext({
|
|
119
|
+
credential,
|
|
120
|
+
request,
|
|
121
|
+
}: {
|
|
122
|
+
credential: Method.VerifyContext<typeof Methods.charge>['credential']
|
|
123
|
+
request: Method.VerifyContext<typeof Methods.charge>['request']
|
|
124
|
+
}) {
|
|
125
|
+
const { challenge, payload } = credential
|
|
126
|
+
const resolvedRequest = resolveRequest(request)
|
|
127
|
+
const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId
|
|
128
|
+
const { amount, methodDetails } = resolvedRequest
|
|
129
|
+
const supportedModes = methodDetails?.supportedModes as
|
|
130
|
+
| readonly Methods.ChargeMode[]
|
|
131
|
+
| undefined
|
|
132
|
+
const currency = resolvedRequest.currency as `0x${string}`
|
|
133
|
+
const recipient = resolvedRequest.recipient as `0x${string}`
|
|
134
|
+
const memo = methodDetails?.memo as `0x${string}` | undefined
|
|
135
|
+
const isZeroAmount = BigInt(amount) === 0n
|
|
136
|
+
|
|
137
|
+
Expires.assert(challenge.expires, challenge.id)
|
|
138
|
+
|
|
139
|
+
if (isZeroAmount && payload.type !== 'proof')
|
|
140
|
+
throw new MismatchError('Zero-amount challenges require a proof credential.', {})
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
amount,
|
|
144
|
+
chainId,
|
|
145
|
+
challenge,
|
|
146
|
+
client: await getClient({ chainId }),
|
|
147
|
+
currency,
|
|
148
|
+
isZeroAmount,
|
|
149
|
+
memo,
|
|
150
|
+
methodDetails,
|
|
151
|
+
payload,
|
|
152
|
+
recipient,
|
|
153
|
+
requestAllowsFeePayer: request.feePayer !== false,
|
|
154
|
+
resolvedRequest,
|
|
155
|
+
supportedModes,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type CredentialContext = Awaited<ReturnType<typeof resolveCredentialContext>>
|
|
160
|
+
type Credential = Method.VerifyContext<typeof Methods.charge>['credential']
|
|
161
|
+
type HashPayload = Extract<Credential['payload'], { type: 'hash' }>
|
|
162
|
+
type ProofPayload = Extract<Credential['payload'], { type: 'proof' }>
|
|
163
|
+
type TransactionPayload = Extract<Credential['payload'], { type: 'transaction' }>
|
|
164
|
+
|
|
165
|
+
async function validateHashCredential(
|
|
166
|
+
credential: Credential,
|
|
167
|
+
payload: HashPayload,
|
|
168
|
+
context: CredentialContext,
|
|
169
|
+
) {
|
|
170
|
+
const {
|
|
171
|
+
amount,
|
|
172
|
+
chainId,
|
|
173
|
+
challenge,
|
|
174
|
+
client,
|
|
175
|
+
currency,
|
|
176
|
+
memo,
|
|
177
|
+
methodDetails,
|
|
178
|
+
recipient,
|
|
179
|
+
supportedModes,
|
|
180
|
+
} = context
|
|
181
|
+
if (supportedModes && !supportedModes.includes('push'))
|
|
182
|
+
throw new MismatchError('Hash credentials are not supported for this challenge.', {})
|
|
183
|
+
|
|
184
|
+
const source = parseHashCredentialSource({
|
|
185
|
+
chainId: chainId ?? client.chain?.id,
|
|
186
|
+
source: credential.source,
|
|
187
|
+
})
|
|
188
|
+
const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
|
|
189
|
+
const receipt = await getTransactionReceipt(client, {
|
|
190
|
+
hash: payload.hash as `0x${string}`,
|
|
191
|
+
})
|
|
192
|
+
const sender = source?.address ?? receipt.from
|
|
193
|
+
const matchedLogs = await assertTransferLogs(receipt, {
|
|
194
|
+
currency,
|
|
195
|
+
sender,
|
|
196
|
+
source,
|
|
197
|
+
transfers,
|
|
198
|
+
validateSender,
|
|
199
|
+
})
|
|
200
|
+
if (!memo)
|
|
201
|
+
assertChallengeBoundMemo(matchedLogs, {
|
|
202
|
+
challengeId: challenge.id,
|
|
203
|
+
realm: challenge.realm,
|
|
204
|
+
})
|
|
205
|
+
return { receipt: toReceipt(receipt), sender, transfers }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function validateProofCredential(
|
|
209
|
+
credential: Credential,
|
|
210
|
+
payload: ProofPayload,
|
|
211
|
+
context: CredentialContext,
|
|
212
|
+
) {
|
|
213
|
+
const { chainId, challenge, client, isZeroAmount } = context
|
|
214
|
+
if (!isZeroAmount)
|
|
215
|
+
throw new MismatchError('Proof credentials are only valid for zero-amount challenges.', {})
|
|
216
|
+
|
|
217
|
+
const expectedSource = credential.source
|
|
218
|
+
if (!expectedSource) throw new MismatchError('Proof credential must include a source.', {})
|
|
219
|
+
|
|
220
|
+
const resolvedChainId = challenge.request.methodDetails?.chainId ?? chainId!
|
|
221
|
+
const source = Proof.parsePkhSource(expectedSource)
|
|
222
|
+
if (!source || source.chainId !== resolvedChainId)
|
|
223
|
+
throw new MismatchError('Proof credential source is invalid.', {})
|
|
224
|
+
|
|
225
|
+
const valid = await verifyTypedData(client, {
|
|
226
|
+
address: source.address,
|
|
227
|
+
...Proof.typedData({
|
|
228
|
+
account: source.address,
|
|
229
|
+
chainId: resolvedChainId,
|
|
230
|
+
challengeId: challenge.id,
|
|
231
|
+
realm: challenge.realm,
|
|
232
|
+
}),
|
|
233
|
+
signature: payload.signature as `0x${string}`,
|
|
234
|
+
})
|
|
235
|
+
if (!valid) {
|
|
236
|
+
const proofSigner = recoverAuthorizedProofSigner({
|
|
237
|
+
chainId: resolvedChainId,
|
|
238
|
+
challengeId: challenge.id,
|
|
239
|
+
realm: challenge.realm,
|
|
240
|
+
signature: payload.signature as `0x${string}`,
|
|
241
|
+
sourceAddress: source.address,
|
|
242
|
+
})
|
|
243
|
+
const authorized = proofSigner
|
|
244
|
+
? await isActiveAccessKey(client, { accessKey: proofSigner, account: source.address })
|
|
245
|
+
: false
|
|
246
|
+
if (!authorized) throw new MismatchError('Proof signature does not match source.', {})
|
|
247
|
+
}
|
|
248
|
+
return { sender: source.address }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function validateTransactionCredential(
|
|
252
|
+
credential: Credential,
|
|
253
|
+
payload: TransactionPayload,
|
|
254
|
+
request: Method.VerifyContext<typeof Methods.charge>['request'],
|
|
255
|
+
context: CredentialContext,
|
|
256
|
+
) {
|
|
257
|
+
const {
|
|
258
|
+
amount,
|
|
259
|
+
chainId,
|
|
260
|
+
challenge,
|
|
261
|
+
client,
|
|
262
|
+
currency,
|
|
263
|
+
memo,
|
|
264
|
+
methodDetails,
|
|
265
|
+
recipient,
|
|
266
|
+
requestAllowsFeePayer,
|
|
267
|
+
supportedModes,
|
|
268
|
+
} = context
|
|
269
|
+
if (supportedModes && !supportedModes.includes('pull'))
|
|
270
|
+
throw new MismatchError('Transaction credentials are not supported for this challenge.', {})
|
|
271
|
+
|
|
272
|
+
const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo
|
|
273
|
+
if (!FeePayer.isTempoTransaction(serializedTransaction))
|
|
274
|
+
throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {})
|
|
275
|
+
|
|
276
|
+
const transaction = Transaction.deserialize(serializedTransaction)
|
|
277
|
+
if (!transaction.signature || !transaction.from)
|
|
278
|
+
throw new MismatchError(
|
|
279
|
+
'Transaction must be signed by the sender before fee payer co-signing.',
|
|
280
|
+
{},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
const isFeePayerTx =
|
|
284
|
+
methodDetails?.feePayer === true &&
|
|
285
|
+
requestAllowsFeePayer &&
|
|
286
|
+
!!(typeof request.feePayer === 'object' ? request.feePayer : feePayer || feePayerUrl)
|
|
287
|
+
const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
|
|
288
|
+
const matchedCalls = assertTransferCalls(transaction.calls ?? [], {
|
|
289
|
+
currency,
|
|
290
|
+
exactCount: isFeePayerTx,
|
|
291
|
+
transfers,
|
|
292
|
+
})
|
|
293
|
+
if (!memo)
|
|
294
|
+
assertChallengeBoundCallMemo(matchedCalls, {
|
|
295
|
+
challengeId: challenge.id,
|
|
296
|
+
realm: challenge.realm,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
if (isFeePayerTx) {
|
|
300
|
+
FeePayer.validateCalls(
|
|
301
|
+
transaction.calls,
|
|
302
|
+
{ amount, currency, recipient },
|
|
303
|
+
{ currency, expectedTransfers: transfers },
|
|
304
|
+
)
|
|
305
|
+
FeePayer.assertAllowedFeeToken(transaction, FeePayer.defaultAllowedFeeTokens(chainId))
|
|
306
|
+
} else {
|
|
307
|
+
await viem_call(
|
|
308
|
+
client,
|
|
309
|
+
FeePayer.simulationTransaction(transaction, { feePayer: false }) as never,
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { isFeePayerTx, serializedTransaction, transaction, transfers }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function validateCredential(
|
|
317
|
+
credential: Credential,
|
|
318
|
+
request: Method.VerifyContext<typeof Methods.charge>['request'],
|
|
319
|
+
context: CredentialContext,
|
|
320
|
+
) {
|
|
321
|
+
switch (context.payload.type) {
|
|
322
|
+
case 'hash': {
|
|
323
|
+
const { receipt, sender, transfers } = await validateHashCredential(
|
|
324
|
+
credential,
|
|
325
|
+
context.payload,
|
|
326
|
+
context,
|
|
327
|
+
)
|
|
328
|
+
return {
|
|
329
|
+
details: { mode: 'push' as const, sender, transfers },
|
|
330
|
+
hash: context.payload.hash as `0x${string}`,
|
|
331
|
+
receipt,
|
|
332
|
+
type: 'hash' as const,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
case 'proof': {
|
|
337
|
+
const { sender } = await validateProofCredential(credential, context.payload, context)
|
|
338
|
+
return { details: { mode: 'proof' as const, sender }, type: 'proof' as const }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
case 'transaction': {
|
|
342
|
+
const { isFeePayerTx, serializedTransaction, transaction, transfers } =
|
|
343
|
+
await validateTransactionCredential(credential, context.payload, request, context)
|
|
344
|
+
return {
|
|
345
|
+
details: {
|
|
346
|
+
mode: 'pull' as const,
|
|
347
|
+
sender: transaction.from as `0x${string}`,
|
|
348
|
+
serializedTransaction,
|
|
349
|
+
transfers,
|
|
350
|
+
},
|
|
351
|
+
isFeePayerTx,
|
|
352
|
+
serializedTransaction,
|
|
353
|
+
transaction,
|
|
354
|
+
transfers,
|
|
355
|
+
type: 'transaction' as const,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
94
361
|
type Defaults = charge.DeriveDefaults<parameters>
|
|
95
|
-
|
|
362
|
+
const method = Method.toServer<typeof Methods.charge, Defaults>(Methods.charge, {
|
|
96
363
|
defaults: {
|
|
97
364
|
amount,
|
|
98
365
|
currency,
|
|
@@ -172,25 +439,35 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
172
439
|
}
|
|
173
440
|
},
|
|
174
441
|
|
|
175
|
-
async
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const parsed = Methods.charge.schema.request.safeParse(request)
|
|
179
|
-
if (parsed.success) return parsed.data
|
|
180
|
-
// verifyCredential() passes the HMAC-bound challenge request, which is
|
|
181
|
-
// already in canonical output form and should not be transformed again.
|
|
182
|
-
return request as unknown as z.output<typeof Methods.charge.schema.request>
|
|
183
|
-
})()
|
|
184
|
-
const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId
|
|
442
|
+
async validate({ credential, request }) {
|
|
443
|
+
const context = await resolveCredentialContext({ credential, request })
|
|
444
|
+
const validated = await validateCredential(credential, request, context)
|
|
185
445
|
|
|
186
|
-
|
|
446
|
+
return {
|
|
447
|
+
challenge: context.challenge,
|
|
448
|
+
credential,
|
|
449
|
+
details: validated.details,
|
|
450
|
+
intent: 'charge',
|
|
451
|
+
method: 'tempo',
|
|
452
|
+
request: context.resolvedRequest,
|
|
453
|
+
source: credential.source,
|
|
454
|
+
}
|
|
455
|
+
},
|
|
187
456
|
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
457
|
+
async broadcast({ credential, request }) {
|
|
458
|
+
const context = await resolveCredentialContext({ credential, request })
|
|
459
|
+
const {
|
|
460
|
+
amount,
|
|
461
|
+
chainId,
|
|
462
|
+
challenge,
|
|
463
|
+
client,
|
|
464
|
+
currency,
|
|
465
|
+
memo,
|
|
466
|
+
methodDetails,
|
|
467
|
+
recipient,
|
|
468
|
+
requestAllowsFeePayer,
|
|
469
|
+
} = context
|
|
470
|
+
const validated = await validateCredential(credential, request, context)
|
|
194
471
|
const feePayerAccount =
|
|
195
472
|
methodDetails?.feePayer === true && requestAllowsFeePayer
|
|
196
473
|
? typeof request.feePayer === 'object'
|
|
@@ -198,133 +475,23 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
198
475
|
: feePayer
|
|
199
476
|
: undefined
|
|
200
477
|
const expires = challenge.expires
|
|
201
|
-
const supportedModes = methodDetails?.supportedModes as
|
|
202
|
-
| readonly Methods.ChargeMode[]
|
|
203
|
-
| undefined
|
|
204
|
-
|
|
205
|
-
const currency = resolvedRequest.currency as `0x${string}`
|
|
206
|
-
const recipient = resolvedRequest.recipient as `0x${string}`
|
|
207
|
-
|
|
208
|
-
Expires.assert(expires, challenge.id)
|
|
209
|
-
|
|
210
|
-
const memo = methodDetails?.memo as `0x${string}` | undefined
|
|
211
|
-
|
|
212
|
-
const payload = credential.payload
|
|
213
|
-
const isZeroAmount = BigInt(amount) === 0n
|
|
214
|
-
|
|
215
|
-
if (isZeroAmount && payload.type !== 'proof')
|
|
216
|
-
throw new MismatchError('Zero-amount challenges require a proof credential.', {})
|
|
217
478
|
|
|
218
|
-
switch (
|
|
479
|
+
switch (validated.type) {
|
|
219
480
|
case 'hash': {
|
|
220
|
-
|
|
221
|
-
throw new MismatchError('Hash credentials are not supported for this challenge.', {})
|
|
222
|
-
|
|
223
|
-
const hash = payload.hash as `0x${string}`
|
|
224
|
-
// Validate client-supplied identity before reserving the hash so a
|
|
225
|
-
// malformed source cannot burn an otherwise valid payment attempt.
|
|
226
|
-
const source = parseHashCredentialSource({
|
|
227
|
-
chainId: chainId ?? client.chain?.id,
|
|
228
|
-
source: credential.source,
|
|
229
|
-
})
|
|
230
|
-
// Reserve the hash while we verify it. This blocks concurrent
|
|
231
|
-
// requests from racing to reuse the same on-chain payment.
|
|
481
|
+
const { hash, receipt } = validated
|
|
232
482
|
if (!(await markHashUsed(store, hash))) {
|
|
233
|
-
throw new VerificationFailedError({
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
// If verification fails after reservation, release it so transient
|
|
237
|
-
// RPC/log-validation errors do not force the payer to pay again.
|
|
238
|
-
// Once we have proven the receipt is a successful matching payment,
|
|
239
|
-
// keep the marker to enforce single-use semantics.
|
|
240
|
-
let releaseReservation = true
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
const expectedTransfers = getExpectedTransfers({
|
|
244
|
-
amount,
|
|
245
|
-
memo,
|
|
246
|
-
methodDetails,
|
|
247
|
-
recipient,
|
|
248
|
-
})
|
|
249
|
-
const receipt = await getTransactionReceipt(client, { hash })
|
|
250
|
-
const sender = source?.address ?? receipt.from
|
|
251
|
-
const matchedLogs = await assertTransferLogs(receipt, {
|
|
252
|
-
currency,
|
|
253
|
-
sender,
|
|
254
|
-
source,
|
|
255
|
-
transfers: expectedTransfers,
|
|
256
|
-
validateSender,
|
|
483
|
+
throw new VerificationFailedError({
|
|
484
|
+
reason: 'Transaction hash has already been used',
|
|
257
485
|
})
|
|
258
|
-
// Only verify challenge binding when using auto-generated attribution memos.
|
|
259
|
-
// Explicit memos (set by the server) are strictly matched by assertTransferLogs
|
|
260
|
-
// but are NOT challenge-bound — callers that set explicit memos are responsible
|
|
261
|
-
// for ensuring memo uniqueness per challenge to prevent cross-challenge hash reuse.
|
|
262
|
-
if (!memo)
|
|
263
|
-
assertChallengeBoundMemo(matchedLogs, {
|
|
264
|
-
challengeId: challenge.id,
|
|
265
|
-
realm: challenge.realm,
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
const paymentReceipt = toReceipt(receipt)
|
|
269
|
-
// `toReceipt` can throw for reverted transactions. Only keep the
|
|
270
|
-
// reservation after it confirms the referenced transaction settled.
|
|
271
|
-
releaseReservation = false
|
|
272
|
-
return paymentReceipt
|
|
273
|
-
} catch (error) {
|
|
274
|
-
if (releaseReservation) await releaseHashUse(store, hash)
|
|
275
|
-
throw error
|
|
276
486
|
}
|
|
487
|
+
return receipt
|
|
277
488
|
}
|
|
278
489
|
|
|
279
490
|
case 'proof': {
|
|
280
|
-
if (!isZeroAmount)
|
|
281
|
-
throw new MismatchError(
|
|
282
|
-
'Proof credentials are only valid for zero-amount challenges.',
|
|
283
|
-
{},
|
|
284
|
-
)
|
|
285
|
-
|
|
286
|
-
const expectedSource = credential.source
|
|
287
|
-
if (!expectedSource)
|
|
288
|
-
throw new MismatchError('Proof credential must include a source.', {})
|
|
289
|
-
|
|
290
|
-
const resolvedChainId = challenge.request.methodDetails?.chainId ?? chainId!
|
|
291
|
-
const source = Proof.parsePkhSource(expectedSource)
|
|
292
|
-
|
|
293
|
-
if (!source || source.chainId !== resolvedChainId) {
|
|
294
|
-
throw new MismatchError('Proof credential source is invalid.', {})
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const valid = await verifyTypedData(client, {
|
|
298
|
-
// Bind verification to the claimed payer (`source.address`): the
|
|
299
|
-
// signer may be the payer itself or an access key authorized for it.
|
|
300
|
-
address: source.address,
|
|
301
|
-
...Proof.typedData({
|
|
302
|
-
account: source.address,
|
|
303
|
-
chainId: resolvedChainId,
|
|
304
|
-
challengeId: challenge.id,
|
|
305
|
-
realm: challenge.realm,
|
|
306
|
-
}),
|
|
307
|
-
signature: payload.signature as `0x${string}`,
|
|
308
|
-
})
|
|
309
|
-
if (!valid) {
|
|
310
|
-
const proofSigner = recoverAuthorizedProofSigner({
|
|
311
|
-
chainId: resolvedChainId,
|
|
312
|
-
challengeId: challenge.id,
|
|
313
|
-
realm: challenge.realm,
|
|
314
|
-
signature: payload.signature as `0x${string}`,
|
|
315
|
-
sourceAddress: source.address,
|
|
316
|
-
})
|
|
317
|
-
const authorized = proofSigner
|
|
318
|
-
? await isActiveAccessKey(client, {
|
|
319
|
-
accessKey: proofSigner,
|
|
320
|
-
account: source.address,
|
|
321
|
-
})
|
|
322
|
-
: false
|
|
323
|
-
if (!authorized) throw new MismatchError('Proof signature does not match source.', {})
|
|
324
|
-
}
|
|
325
|
-
|
|
326
491
|
if (proofStore && !(await markProofUsed(proofStore, challenge.id))) {
|
|
327
|
-
throw new VerificationFailedError({
|
|
492
|
+
throw new VerificationFailedError({
|
|
493
|
+
reason: 'Proof credential has already been used',
|
|
494
|
+
})
|
|
328
495
|
}
|
|
329
496
|
|
|
330
497
|
return {
|
|
@@ -336,82 +503,26 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
336
503
|
}
|
|
337
504
|
|
|
338
505
|
case 'transaction': {
|
|
339
|
-
|
|
340
|
-
throw new MismatchError(
|
|
341
|
-
'Transaction credentials are not supported for this challenge.',
|
|
342
|
-
{},
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo
|
|
506
|
+
const { isFeePayerTx, serializedTransaction, transaction, transfers } = validated
|
|
346
507
|
|
|
347
508
|
// Pre-broadcast dedup: catch exact byte-for-byte replays early.
|
|
348
509
|
const hash = keccak256(serializedTransaction)
|
|
349
510
|
if (!(await markHashUsed(store, hash))) {
|
|
350
|
-
throw new VerificationFailedError({
|
|
511
|
+
throw new VerificationFailedError({
|
|
512
|
+
reason: 'Transaction hash has already been used',
|
|
513
|
+
})
|
|
351
514
|
}
|
|
352
515
|
|
|
353
|
-
let
|
|
354
|
-
let
|
|
516
|
+
let broadcastAttempted = false
|
|
517
|
+
let finalHash: `0x${string}` | undefined
|
|
518
|
+
let reservation: SponsorBudget.Handle | undefined
|
|
355
519
|
|
|
356
520
|
try {
|
|
357
|
-
if (!FeePayer.isTempoTransaction(serializedTransaction))
|
|
358
|
-
throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {})
|
|
359
|
-
|
|
360
|
-
const transaction = Transaction.deserialize(serializedTransaction)
|
|
361
|
-
if (!transaction.signature || !transaction.from)
|
|
362
|
-
throw new MismatchError(
|
|
363
|
-
'Transaction must be signed by the sender before fee payer co-signing.',
|
|
364
|
-
{},
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
const calls = (transaction.calls ?? []) as readonly {
|
|
368
|
-
data?: `0x${string}` | undefined
|
|
369
|
-
to?: `0x${string}` | undefined
|
|
370
|
-
}[]
|
|
371
|
-
const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
|
|
372
|
-
const isFeePayerTx =
|
|
373
|
-
methodDetails?.feePayer === true &&
|
|
374
|
-
requestAllowsFeePayer &&
|
|
375
|
-
!!(feePayerAccount || feePayerUrl)
|
|
376
|
-
const matchedCalls = assertTransferCalls(calls, {
|
|
377
|
-
currency,
|
|
378
|
-
exactCount: isFeePayerTx,
|
|
379
|
-
transfers,
|
|
380
|
-
})
|
|
381
|
-
if (!memo)
|
|
382
|
-
assertChallengeBoundCallMemo(matchedCalls, {
|
|
383
|
-
challengeId: challenge.id,
|
|
384
|
-
realm: challenge.realm,
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
if (isFeePayerTx) {
|
|
388
|
-
const reservationChainId = chainId ?? client.chain!.id
|
|
389
|
-
if (
|
|
390
|
-
!(await markSponsoredSenderInFlight(store, {
|
|
391
|
-
chainId: reservationChainId,
|
|
392
|
-
sender: transaction.from as `0x${string}`,
|
|
393
|
-
}))
|
|
394
|
-
) {
|
|
395
|
-
throw new VerificationFailedError({
|
|
396
|
-
reason: 'Sponsored transaction from this sender is already in flight',
|
|
397
|
-
})
|
|
398
|
-
}
|
|
399
|
-
sponsoredSenderReservation = {
|
|
400
|
-
chainId: reservationChainId,
|
|
401
|
-
sender: transaction.from as `0x${string}`,
|
|
402
|
-
}
|
|
403
|
-
FeePayer.validateCalls(
|
|
404
|
-
transaction.calls,
|
|
405
|
-
{ amount, currency, recipient },
|
|
406
|
-
{ currency, expectedTransfers: transfers },
|
|
407
|
-
)
|
|
408
|
-
}
|
|
409
|
-
|
|
410
521
|
const allowedFeeTokens = FeePayer.defaultAllowedFeeTokens(chainId)
|
|
411
522
|
if (isFeePayerTx) FeePayer.assertAllowedFeeToken(transaction, allowedFeeTokens)
|
|
412
523
|
const selectableFeeTokens = allowedFeeTokens as readonly `0x${string}`[]
|
|
413
524
|
|
|
414
|
-
const
|
|
525
|
+
const completedTransaction = await (async () => {
|
|
415
526
|
if (feePayerAccount && methodDetails?.feePayer !== false) {
|
|
416
527
|
const completed = await FeePayer.preflightSponsorship({
|
|
417
528
|
transaction,
|
|
@@ -432,7 +543,7 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
432
543
|
challengeExpires: expires,
|
|
433
544
|
chainId: chainId ?? client.chain!.id,
|
|
434
545
|
details: { amount, currency, recipient },
|
|
435
|
-
policy:
|
|
546
|
+
policy: transactionFeePayerPolicy,
|
|
436
547
|
transaction: {
|
|
437
548
|
...transaction,
|
|
438
549
|
...(feeToken ? { feeToken } : {}),
|
|
@@ -441,7 +552,13 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
441
552
|
return { feePayer: feePayerAccount.address, transaction: sponsored }
|
|
442
553
|
},
|
|
443
554
|
})
|
|
444
|
-
return
|
|
555
|
+
return {
|
|
556
|
+
serializedTransaction: await signTransaction(
|
|
557
|
+
client,
|
|
558
|
+
completed.transaction as never,
|
|
559
|
+
),
|
|
560
|
+
sponsor: completed.feePayer,
|
|
561
|
+
}
|
|
445
562
|
}
|
|
446
563
|
if (feePayerUrl && isFeePayerTx) {
|
|
447
564
|
const completed = await FeePayer.preflightSponsorship({
|
|
@@ -453,17 +570,66 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
453
570
|
challengeExpires: expires,
|
|
454
571
|
chainId: chainId ?? client.chain!.id,
|
|
455
572
|
details: { amount, currency, recipient },
|
|
456
|
-
policy:
|
|
573
|
+
policy: transactionFeePayerPolicy,
|
|
457
574
|
transaction,
|
|
458
575
|
url: feePayerUrl,
|
|
459
576
|
})
|
|
460
577
|
return { ...hosted, transaction }
|
|
461
578
|
},
|
|
462
579
|
})
|
|
463
|
-
return
|
|
580
|
+
return {
|
|
581
|
+
serializedTransaction: completed.serializedTransaction,
|
|
582
|
+
sponsor: completed.feePayer,
|
|
583
|
+
}
|
|
464
584
|
}
|
|
465
|
-
return serializedTransaction
|
|
585
|
+
return { serializedTransaction, sponsor: undefined }
|
|
466
586
|
})()
|
|
587
|
+
const serializedTransaction_final = completedTransaction.serializedTransaction
|
|
588
|
+
finalHash = keccak256(serializedTransaction_final)
|
|
589
|
+
|
|
590
|
+
if (
|
|
591
|
+
finalHash.toLowerCase() !== hash.toLowerCase() &&
|
|
592
|
+
!(await markHashUsed(store, finalHash))
|
|
593
|
+
)
|
|
594
|
+
throw new VerificationFailedError({
|
|
595
|
+
reason: 'Transaction hash has already been used',
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
if (isFeePayerTx) {
|
|
599
|
+
const sponsor = completedTransaction.sponsor
|
|
600
|
+
if (!sponsor)
|
|
601
|
+
throw new VerificationFailedError({
|
|
602
|
+
reason: 'Sponsored transaction has no configured fee payer',
|
|
603
|
+
})
|
|
604
|
+
const reservationChainId = chainId ?? client.chain!.id
|
|
605
|
+
const { totalFee, validBeforeValue } = FeePayer.assertTransactionPolicy({
|
|
606
|
+
challengeExpires: expires,
|
|
607
|
+
chainId: reservationChainId,
|
|
608
|
+
details: { amount, currency, recipient },
|
|
609
|
+
policy: transactionFeePayerPolicy,
|
|
610
|
+
transaction,
|
|
611
|
+
})
|
|
612
|
+
const expiresAt = validBeforeValue * 1_000
|
|
613
|
+
const waitUntil = Math.min(
|
|
614
|
+
expiresAt,
|
|
615
|
+
expires ? Date.parse(expires) : Number.MAX_SAFE_INTEGER,
|
|
616
|
+
)
|
|
617
|
+
reservation = await SponsorBudget.reserve(sponsorBudgetStore, {
|
|
618
|
+
chainId: reservationChainId,
|
|
619
|
+
expiresAt,
|
|
620
|
+
fee: totalFee,
|
|
621
|
+
getReceipt: (transactionHash) =>
|
|
622
|
+
getTransactionReceipt(client, { hash: transactionHash }),
|
|
623
|
+
id: finalHash.toLowerCase(),
|
|
624
|
+
maxReservations: maxInFlightReservations,
|
|
625
|
+
maxTotalFee: maxInFlightTotalFee,
|
|
626
|
+
owner: globalThis.crypto.randomUUID(),
|
|
627
|
+
sponsor,
|
|
628
|
+
transactionHash: finalHash,
|
|
629
|
+
waitUntil,
|
|
630
|
+
})
|
|
631
|
+
Expires.assert(challenge.expires, challenge.id)
|
|
632
|
+
}
|
|
467
633
|
|
|
468
634
|
// Pre-broadcast simulation for non-sponsored transactions.
|
|
469
635
|
if (!isFeePayerTx)
|
|
@@ -472,10 +638,20 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
472
638
|
FeePayer.simulationTransaction(transaction, { feePayer: false }) as never,
|
|
473
639
|
)
|
|
474
640
|
|
|
641
|
+
if (
|
|
642
|
+
reservation &&
|
|
643
|
+
!(await SponsorBudget.transition(sponsorBudgetStore, reservation, 'broadcasting'))
|
|
644
|
+
)
|
|
645
|
+
throw new VerificationFailedError({
|
|
646
|
+
reason: 'Sponsor budget reservation ownership was lost before broadcast',
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
broadcastAttempted = true
|
|
475
650
|
if (waitForConfirmation) {
|
|
476
651
|
const receipt = await sendRawTransactionSync(client, {
|
|
477
652
|
serializedTransaction: serializedTransaction_final,
|
|
478
653
|
})
|
|
654
|
+
if (reservation) await SponsorBudget.release(sponsorBudgetStore, reservation)
|
|
479
655
|
const matchedLogs = await assertTransferLogs(receipt, {
|
|
480
656
|
currency,
|
|
481
657
|
sender: transaction.from! as `0x${string}`,
|
|
@@ -486,19 +662,10 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
486
662
|
challengeId: challenge.id,
|
|
487
663
|
realm: challenge.realm,
|
|
488
664
|
})
|
|
489
|
-
|
|
490
|
-
// (different serialized bytes, same underlying tx) that
|
|
491
|
-
// bypass the pre-broadcast check. Skip if the broadcast
|
|
492
|
-
// hash matches the input hash (already stored above).
|
|
493
|
-
if (
|
|
494
|
-
receipt.transactionHash.toLowerCase() !== hash.toLowerCase() &&
|
|
495
|
-
!(await markHashUsed(store, receipt.transactionHash))
|
|
496
|
-
) {
|
|
665
|
+
if (receipt.transactionHash.toLowerCase() !== finalHash.toLowerCase())
|
|
497
666
|
throw new VerificationFailedError({
|
|
498
|
-
reason: '
|
|
667
|
+
reason: 'Broadcast transaction hash does not match the signed transaction',
|
|
499
668
|
})
|
|
500
|
-
}
|
|
501
|
-
releaseReservation = false
|
|
502
669
|
return toReceipt(receipt)
|
|
503
670
|
}
|
|
504
671
|
|
|
@@ -508,16 +675,17 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
508
675
|
const reference = await sendRawTransaction(client, {
|
|
509
676
|
serializedTransaction: serializedTransaction_final,
|
|
510
677
|
})
|
|
511
|
-
|
|
678
|
+
if (reference.toLowerCase() !== finalHash.toLowerCase())
|
|
679
|
+
throw new VerificationFailedError({
|
|
680
|
+
reason: 'Broadcast transaction hash does not match the signed transaction',
|
|
681
|
+
})
|
|
512
682
|
if (
|
|
513
|
-
|
|
514
|
-
!(await
|
|
515
|
-
)
|
|
683
|
+
reservation &&
|
|
684
|
+
!(await SponsorBudget.transition(sponsorBudgetStore, reservation, 'pending'))
|
|
685
|
+
)
|
|
516
686
|
throw new VerificationFailedError({
|
|
517
|
-
reason: '
|
|
687
|
+
reason: 'Sponsor budget reservation ownership was lost after broadcast',
|
|
518
688
|
})
|
|
519
|
-
}
|
|
520
|
-
releaseReservation = false
|
|
521
689
|
return {
|
|
522
690
|
method: 'tempo',
|
|
523
691
|
status: 'success',
|
|
@@ -525,28 +693,37 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
525
693
|
reference,
|
|
526
694
|
} as const
|
|
527
695
|
} catch (error) {
|
|
528
|
-
if (
|
|
696
|
+
if (!broadcastAttempted) {
|
|
697
|
+
if (reservation) await SponsorBudget.release(sponsorBudgetStore, reservation)
|
|
698
|
+
if (finalHash && finalHash.toLowerCase() !== hash.toLowerCase())
|
|
699
|
+
await releaseHashUse(store, finalHash)
|
|
700
|
+
await releaseHashUse(store, hash)
|
|
701
|
+
}
|
|
529
702
|
throw error
|
|
530
|
-
} finally {
|
|
531
|
-
if (sponsoredSenderReservation)
|
|
532
|
-
await releaseSponsoredSenderInFlight(store, sponsoredSenderReservation)
|
|
533
703
|
}
|
|
534
704
|
}
|
|
535
|
-
|
|
536
|
-
default:
|
|
537
|
-
throw new Error(`Unsupported credential type "${(payload as { type: string }).type}".`)
|
|
538
705
|
}
|
|
539
706
|
},
|
|
540
707
|
})
|
|
708
|
+
return relay ? (Relay.configure(method, relay) as typeof method) : method
|
|
541
709
|
}
|
|
542
710
|
|
|
543
711
|
export declare namespace charge {
|
|
544
|
-
type StoreItemMap = {
|
|
712
|
+
type StoreItemMap = {
|
|
713
|
+
[key: `mppx:charge:${string}`]: number | SponsorBudget.State
|
|
714
|
+
}
|
|
545
715
|
|
|
546
716
|
type Defaults = LooseOmit<Method.RequestDefaults<typeof Methods.charge>, 'feePayer' | 'recipient'>
|
|
547
717
|
|
|
548
718
|
type ValidateSender = (parameters: ValidateSenderParameters) => boolean | Promise<boolean>
|
|
549
719
|
|
|
720
|
+
type ValidationDetails = {
|
|
721
|
+
mode: 'proof' | 'pull' | 'push'
|
|
722
|
+
sender?: `0x${string}` | undefined
|
|
723
|
+
serializedTransaction?: Transaction.TransactionSerializedTempo | undefined
|
|
724
|
+
transfers?: readonly ExpectedTransfer[] | undefined
|
|
725
|
+
}
|
|
726
|
+
|
|
550
727
|
type ValidateSenderParameters = {
|
|
551
728
|
/** Actual TIP-20 `Transfer.from` address. */
|
|
552
729
|
sender: `0x${string}`
|
|
@@ -562,10 +739,13 @@ export declare namespace charge {
|
|
|
562
739
|
/**
|
|
563
740
|
* Override the fee-sponsor policy used when co-signing Tempo charge
|
|
564
741
|
* transactions. Defaults resolve per chain, including a higher
|
|
565
|
-
* priority-fee ceiling on Moderato
|
|
742
|
+
* priority-fee ceiling on Moderato and a bounded aggregate in-flight fee
|
|
743
|
+
* budget. Sponsored transactions reserve their declared maximum fee
|
|
744
|
+
* atomically; independent expiring-nonce transactions run concurrently
|
|
745
|
+
* while capacity remains and wait for capacity when the budget is full.
|
|
566
746
|
*
|
|
567
|
-
* If you increase `maxGas` or `
|
|
568
|
-
*
|
|
747
|
+
* If you increase `maxGas`, `maxFeePerGas`, or `maxTotalFee`, you may also
|
|
748
|
+
* need to raise `maxInFlightTotalFee`.
|
|
569
749
|
*/
|
|
570
750
|
feePayerPolicy?: FeePayerPolicy | undefined
|
|
571
751
|
/**
|
|
@@ -605,6 +785,8 @@ export declare namespace charge {
|
|
|
605
785
|
* By default, no prefix is applied.
|
|
606
786
|
*/
|
|
607
787
|
storeKeyPrefix?: string | undefined
|
|
788
|
+
/** Delegates Tempo charge credential validation and broadcast to Tempo API or a compatible MPP relay. */
|
|
789
|
+
relay?: RelayOptions | undefined
|
|
608
790
|
/**
|
|
609
791
|
* Whether to wait for the charge transaction to confirm on-chain before
|
|
610
792
|
* responding. @default true
|
|
@@ -630,7 +812,19 @@ export declare namespace charge {
|
|
|
630
812
|
}
|
|
631
813
|
>
|
|
632
814
|
|
|
633
|
-
type FeePayerPolicy = Partial<FeePayer.Policy>
|
|
815
|
+
type FeePayerPolicy = Partial<FeePayer.Policy> & {
|
|
816
|
+
/** Maximum number of sponsored transactions awaiting a terminal receipt. @default 100 */
|
|
817
|
+
maxInFlightReservations?: number | undefined
|
|
818
|
+
/**
|
|
819
|
+
* Maximum aggregate declared fee exposure awaiting terminal receipts.
|
|
820
|
+
*
|
|
821
|
+
* @default `maxTotalFee * 10`
|
|
822
|
+
*/
|
|
823
|
+
maxInFlightTotalFee?: bigint | undefined
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Tempo API relay configuration for server-side charges. */
|
|
827
|
+
type RelayOptions = Relay.configure.Options
|
|
634
828
|
}
|
|
635
829
|
|
|
636
830
|
type ChargeRequest = z.output<typeof Methods.charge.schema.request>
|
|
@@ -971,14 +1165,6 @@ function getProofStoreKey(challengeId: string): `mppx:charge:${string}` {
|
|
|
971
1165
|
return `mppx:charge:proof:${challengeId}`
|
|
972
1166
|
}
|
|
973
1167
|
|
|
974
|
-
/** @internal */
|
|
975
|
-
function getSponsoredSenderStoreKey(parameters: {
|
|
976
|
-
chainId: number
|
|
977
|
-
sender: `0x${string}`
|
|
978
|
-
}): `mppx:charge:${string}` {
|
|
979
|
-
return `mppx:charge:sponsor:${parameters.chainId}:${parameters.sender.toLowerCase()}`
|
|
980
|
-
}
|
|
981
|
-
|
|
982
1168
|
async function markHashUsed(
|
|
983
1169
|
store: Store.AtomicStore<charge.StoreItemMap>,
|
|
984
1170
|
hash: `0x${string}`,
|
|
@@ -1012,25 +1198,6 @@ function parseHashCredentialSource(parameters: {
|
|
|
1012
1198
|
return parsed
|
|
1013
1199
|
}
|
|
1014
1200
|
|
|
1015
|
-
/** @internal */
|
|
1016
|
-
async function markSponsoredSenderInFlight(
|
|
1017
|
-
store: Store.AtomicStore<charge.StoreItemMap>,
|
|
1018
|
-
parameters: { chainId: number; sender: `0x${string}` },
|
|
1019
|
-
): Promise<boolean> {
|
|
1020
|
-
return store.update(getSponsoredSenderStoreKey(parameters), (current) => {
|
|
1021
|
-
if (current !== null) return { op: 'noop', result: false }
|
|
1022
|
-
return { op: 'set', value: Date.now(), result: true }
|
|
1023
|
-
})
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/** @internal */
|
|
1027
|
-
async function releaseSponsoredSenderInFlight(
|
|
1028
|
-
store: Store.AtomicStore<charge.StoreItemMap>,
|
|
1029
|
-
parameters: { chainId: number; sender: `0x${string}` },
|
|
1030
|
-
): Promise<void> {
|
|
1031
|
-
await store.delete(getSponsoredSenderStoreKey(parameters))
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
1201
|
/** @internal */
|
|
1035
1202
|
async function markProofUsed(
|
|
1036
1203
|
store: Store.AtomicStore<charge.StoreItemMap>,
|
|
@@ -1170,6 +1337,7 @@ class MismatchError extends PaymentError {
|
|
|
1170
1337
|
: `Payment verification failed: ${reason}`,
|
|
1171
1338
|
...Object.entries(details).map(([k, v]) => ` - ${k}: ${v}`),
|
|
1172
1339
|
].join('\n'),
|
|
1340
|
+
{ details },
|
|
1173
1341
|
)
|
|
1174
1342
|
}
|
|
1175
1343
|
}
|