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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { Bytes, Hash, Hex, Json } from 'ox'
|
|
2
|
+
|
|
3
|
+
import { PaymentExpiredError, VerificationFailedError } from '../../Errors.js'
|
|
4
|
+
import type * as Method from '../../Method.js'
|
|
5
|
+
import * as Receipt from '../../Receipt.js'
|
|
6
|
+
|
|
7
|
+
const defaultApiBaseUrl = 'https://api.tempo.xyz'
|
|
8
|
+
|
|
9
|
+
const relayErrorCode = [
|
|
10
|
+
'already_used',
|
|
11
|
+
'broadcast_failed',
|
|
12
|
+
'expired',
|
|
13
|
+
'invalid_payment',
|
|
14
|
+
'insufficient_funds',
|
|
15
|
+
'policy_denied',
|
|
16
|
+
'screen_rejected',
|
|
17
|
+
'simulation_failed',
|
|
18
|
+
'temporarily_unavailable',
|
|
19
|
+
'unsupported',
|
|
20
|
+
'unknown',
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
type RelayErrorCode = (typeof relayErrorCode)[number]
|
|
24
|
+
|
|
25
|
+
/** Error body returned by Tempo API's MPP relay. */
|
|
26
|
+
type RelayError = {
|
|
27
|
+
/** Stable machine-readable reason the relay rejected the credential. */
|
|
28
|
+
code: RelayErrorCode
|
|
29
|
+
/** Human-readable explanation of the relay result. */
|
|
30
|
+
message?: string | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Credential fields accepted by Tempo API's MPP relay. */
|
|
34
|
+
type RelayInput = {
|
|
35
|
+
/** Challenge from the submitted credential. */
|
|
36
|
+
challenge: Record<string, unknown>
|
|
37
|
+
/** Method-specific credential payload. */
|
|
38
|
+
payload: unknown
|
|
39
|
+
/** Optional payer identity. */
|
|
40
|
+
source?: string | undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Response returned by the MPP relay validation endpoint. */
|
|
44
|
+
type ValidateResponse = { success: true } | { error: RelayError; success: false }
|
|
45
|
+
|
|
46
|
+
/** Receipt returned by the MPP relay after broadcast. */
|
|
47
|
+
type RelayReceipt = {
|
|
48
|
+
/** Optional caller-provided payment reference. */
|
|
49
|
+
externalId?: string | undefined
|
|
50
|
+
/** Payment method that settled the credential. */
|
|
51
|
+
method: string
|
|
52
|
+
/** On-chain or payment-system settlement reference. */
|
|
53
|
+
reference: string
|
|
54
|
+
/** RFC 3339 settlement timestamp. */
|
|
55
|
+
timestamp: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Response returned by the MPP relay broadcast endpoint. */
|
|
59
|
+
type BroadcastResponse =
|
|
60
|
+
| { receipt: RelayReceipt; success: true }
|
|
61
|
+
| { error: RelayError; success: false }
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Configures a Tempo payment method to use Tempo API's MPP relay.
|
|
65
|
+
*
|
|
66
|
+
* The adapter preserves the supplied method's challenge configuration while
|
|
67
|
+
* delegating credential validation and terminal broadcast to
|
|
68
|
+
* `/v1/mpp/validate` and `/v1/mpp/broadcast` respectively.
|
|
69
|
+
*
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
export function configure<const intent extends Method.Method>(
|
|
73
|
+
method: Method.Server<intent>,
|
|
74
|
+
options: configure.Options,
|
|
75
|
+
): configure.Adapter<intent> {
|
|
76
|
+
const request = createRequest(options)
|
|
77
|
+
|
|
78
|
+
const validate: Method.ValidateFn<intent> = async (parameters) => {
|
|
79
|
+
const input = toRelayInput(parameters.credential)
|
|
80
|
+
await request.validate(input)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
challenge: parameters.credential.challenge,
|
|
84
|
+
credential: parameters.credential,
|
|
85
|
+
details: {},
|
|
86
|
+
intent: method.intent,
|
|
87
|
+
method: method.name,
|
|
88
|
+
request: parameters.credential.challenge.request,
|
|
89
|
+
...(parameters.credential.source ? { source: parameters.credential.source } : {}),
|
|
90
|
+
} as Method.Validation<intent>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const broadcast: Method.BroadcastFn<intent> = async (parameters) => {
|
|
94
|
+
const input = toRelayInput(parameters.credential)
|
|
95
|
+
const receipt = await request.broadcast(input, {
|
|
96
|
+
idempotencyKey: idempotencyKey(input),
|
|
97
|
+
})
|
|
98
|
+
if (receipt.method !== method.name) throw failure()
|
|
99
|
+
try {
|
|
100
|
+
return Receipt.from({ ...receipt, status: 'success' })
|
|
101
|
+
} catch {
|
|
102
|
+
throw failure()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Preserve the legacy combined hook for direct method consumers.
|
|
107
|
+
const verify: Method.VerifyFn<intent> = async (parameters) => {
|
|
108
|
+
await validate(parameters)
|
|
109
|
+
return broadcast(parameters)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
...method,
|
|
114
|
+
broadcast,
|
|
115
|
+
verify,
|
|
116
|
+
validate,
|
|
117
|
+
} as configure.Adapter<intent>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export declare namespace configure {
|
|
121
|
+
/**
|
|
122
|
+
* Server method augmented with Tempo API validation and broadcast hooks.
|
|
123
|
+
*
|
|
124
|
+
* The legacy `verify` method validates and broadcasts in one call.
|
|
125
|
+
*/
|
|
126
|
+
type Adapter<intent extends Method.Method> = Omit<
|
|
127
|
+
Method.Server<intent>,
|
|
128
|
+
'broadcast' | 'validate'
|
|
129
|
+
> & {
|
|
130
|
+
/** Broadcasts the credential through Tempo API. */
|
|
131
|
+
broadcast: Method.BroadcastFn<intent>
|
|
132
|
+
/** Validates the credential through Tempo API. */
|
|
133
|
+
validate: Method.ValidateFn<intent>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Tempo API relay configuration for server-side Tempo charges. */
|
|
137
|
+
type Options = {
|
|
138
|
+
/** Tempo API key with the `mpp:write` scope. */
|
|
139
|
+
apiKey: string
|
|
140
|
+
/** Fetch implementation used to call Tempo API. */
|
|
141
|
+
fetch?: typeof globalThis.fetch | undefined
|
|
142
|
+
/** Tempo API base URL, including an optional path prefix. @default 'https://api.tempo.xyz' */
|
|
143
|
+
apiBaseUrl?: string | undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Stable failure codes returned by Tempo API's MPP relay. */
|
|
147
|
+
type ErrorCode = RelayErrorCode
|
|
148
|
+
|
|
149
|
+
/** Safe relay error details exposed in Payment Auth problem details. */
|
|
150
|
+
type ErrorDetails =
|
|
151
|
+
| { code: 'already_used' | 'broadcast_failed' | 'insufficient_funds' | 'invalid_payment' }
|
|
152
|
+
| { code: 'simulation_failed' | 'unsupported' }
|
|
153
|
+
| { code: 'temporarily_unavailable'; retry: 'same_credential' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function createRequest(options: configure.Options) {
|
|
157
|
+
const fetch = options.fetch ?? globalThis.fetch
|
|
158
|
+
const apiBaseUrl = new URL(options.apiBaseUrl ?? defaultApiBaseUrl)
|
|
159
|
+
if (!apiBaseUrl.pathname.endsWith('/')) apiBaseUrl.pathname += '/'
|
|
160
|
+
|
|
161
|
+
async function post(
|
|
162
|
+
path: 'v1/mpp/broadcast' | 'v1/mpp/validate',
|
|
163
|
+
input: RelayInput,
|
|
164
|
+
headers?: Record<string, string>,
|
|
165
|
+
): Promise<unknown> {
|
|
166
|
+
let response: Response
|
|
167
|
+
try {
|
|
168
|
+
response = await fetch(new URL(path, apiBaseUrl), {
|
|
169
|
+
body: JSON.stringify(input),
|
|
170
|
+
headers: {
|
|
171
|
+
Accept: 'application/json',
|
|
172
|
+
'content-type': 'application/json',
|
|
173
|
+
'tempo-api-key': options.apiKey,
|
|
174
|
+
...headers,
|
|
175
|
+
},
|
|
176
|
+
method: 'POST',
|
|
177
|
+
})
|
|
178
|
+
} catch {
|
|
179
|
+
throw failure()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!response.ok) throw failure()
|
|
183
|
+
return response.json().catch(() => undefined)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const validate = async (input: RelayInput) => {
|
|
187
|
+
const response = await post('v1/mpp/validate', input)
|
|
188
|
+
if (!isValidateSuccess(response)) throw failure(response)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const broadcast = async (input: RelayInput, broadcastOptions: { idempotencyKey: string }) => {
|
|
192
|
+
const response = await post('v1/mpp/broadcast', input, {
|
|
193
|
+
'idempotency-key': broadcastOptions.idempotencyKey,
|
|
194
|
+
})
|
|
195
|
+
if (!isBroadcastSuccess(response)) throw failure(response)
|
|
196
|
+
return response.receipt
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
broadcast,
|
|
201
|
+
validate,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function toRelayInput(credential: {
|
|
206
|
+
challenge: Record<string, unknown>
|
|
207
|
+
payload: unknown
|
|
208
|
+
source?: string | undefined
|
|
209
|
+
}): RelayInput {
|
|
210
|
+
return {
|
|
211
|
+
challenge: credential.challenge,
|
|
212
|
+
payload: credential.payload,
|
|
213
|
+
...(credential.source ? { source: credential.source } : {}),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function idempotencyKey(input: RelayInput): string {
|
|
218
|
+
const payload = input.payload
|
|
219
|
+
if (
|
|
220
|
+
isRecord(payload) &&
|
|
221
|
+
payload.type === 'transaction' &&
|
|
222
|
+
typeof payload.signature === 'string' &&
|
|
223
|
+
Hex.validate(payload.signature)
|
|
224
|
+
) {
|
|
225
|
+
const transactionHash = Hash.keccak256(Hex.toBytes(payload.signature), { as: 'Hex' })
|
|
226
|
+
return `mppx_${transactionHash}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const hash = Hash.sha256(Bytes.fromString(Json.canonicalize(input)), { as: 'Hex' })
|
|
230
|
+
return `mppx_${hash}`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function failure(value?: unknown) {
|
|
234
|
+
const code = relayErrorCodeFrom(value)
|
|
235
|
+
if (code === 'expired') return new PaymentExpiredError()
|
|
236
|
+
|
|
237
|
+
const details = code && safeDetails(code)
|
|
238
|
+
return new VerificationFailedError(details ? { details } : undefined)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isValidateSuccess(value: unknown): value is Extract<ValidateResponse, { success: true }> {
|
|
242
|
+
return isRecord(value) && value.success === true
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isBroadcastSuccess(
|
|
246
|
+
value: unknown,
|
|
247
|
+
): value is Extract<BroadcastResponse, { success: true }> {
|
|
248
|
+
return isRecord(value) && value.success === true && isRelayReceipt(value.receipt)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function relayErrorCodeFrom(value: unknown): RelayErrorCode | undefined {
|
|
252
|
+
if (!isRecord(value) || !isRecord(value.error) || !isRelayErrorCode(value.error.code)) return
|
|
253
|
+
return value.error.code
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isRelayErrorCode(value: unknown): value is RelayErrorCode {
|
|
257
|
+
return typeof value === 'string' && (relayErrorCode as readonly string[]).includes(value)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function safeDetails(code: RelayErrorCode): configure.ErrorDetails | undefined {
|
|
261
|
+
switch (code) {
|
|
262
|
+
case 'already_used':
|
|
263
|
+
case 'broadcast_failed':
|
|
264
|
+
case 'insufficient_funds':
|
|
265
|
+
case 'invalid_payment':
|
|
266
|
+
case 'simulation_failed':
|
|
267
|
+
case 'unsupported':
|
|
268
|
+
return { code }
|
|
269
|
+
case 'temporarily_unavailable':
|
|
270
|
+
return { code, retry: 'same_credential' }
|
|
271
|
+
default:
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isRelayReceipt(value: unknown): value is RelayReceipt {
|
|
277
|
+
return (
|
|
278
|
+
isRecord(value) &&
|
|
279
|
+
typeof value.method === 'string' &&
|
|
280
|
+
typeof value.reference === 'string' &&
|
|
281
|
+
typeof value.timestamp === 'string' &&
|
|
282
|
+
(value.externalId === undefined || typeof value.externalId === 'string')
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
287
|
+
return typeof value === 'object' && value !== null
|
|
288
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Hex } from 'viem'
|
|
2
|
+
import { describe, expect, test } from 'vp/test'
|
|
3
|
+
|
|
4
|
+
import * as Store from '../../Store.js'
|
|
5
|
+
import * as SponsorBudget from './SponsorBudget.js'
|
|
6
|
+
|
|
7
|
+
const sponsor = '0x0000000000000000000000000000000000000001'
|
|
8
|
+
const hash1 = `0x${'01'.repeat(32)}` as Hex
|
|
9
|
+
const hash2 = `0x${'02'.repeat(32)}` as Hex
|
|
10
|
+
const storeKey = `mppx:charge:sponsor-budget:42431:${sponsor}` as const
|
|
11
|
+
|
|
12
|
+
function memoryStore() {
|
|
13
|
+
return Store.memory() as Parameters<typeof SponsorBudget.reserve>[0]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parameters(overrides: Partial<Parameters<typeof SponsorBudget.reserve>[1]> = {}) {
|
|
17
|
+
return {
|
|
18
|
+
chainId: 42431,
|
|
19
|
+
expiresAt: Date.now() + 10_000,
|
|
20
|
+
fee: 1n,
|
|
21
|
+
getReceipt: async () => {
|
|
22
|
+
throw new Error('not found')
|
|
23
|
+
},
|
|
24
|
+
id: hash1,
|
|
25
|
+
maxReservations: 10,
|
|
26
|
+
maxTotalFee: 1n,
|
|
27
|
+
owner: 'worker-1',
|
|
28
|
+
sponsor,
|
|
29
|
+
transactionHash: hash1,
|
|
30
|
+
waitUntil: Date.now() + 10_000,
|
|
31
|
+
...overrides,
|
|
32
|
+
} satisfies Parameters<typeof SponsorBudget.reserve>[1]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('SponsorBudget', () => {
|
|
36
|
+
test('rejects a transaction larger than the aggregate budget', async () => {
|
|
37
|
+
const store = memoryStore()
|
|
38
|
+
await expect(
|
|
39
|
+
SponsorBudget.reserve(store, parameters({ fee: 2n, maxTotalFee: 1n })),
|
|
40
|
+
).rejects.toThrow('fee exceeds the aggregate sponsor budget')
|
|
41
|
+
expect(await store.get(storeKey)).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('retains pending exposure until a receipt is observed', async () => {
|
|
45
|
+
const store = memoryStore()
|
|
46
|
+
let confirmed = false
|
|
47
|
+
const getReceipt = async (hash: Hex) => {
|
|
48
|
+
if (hash === hash1 && confirmed) return {}
|
|
49
|
+
throw new Error('not found')
|
|
50
|
+
}
|
|
51
|
+
const first = await SponsorBudget.reserve(store, parameters({ getReceipt }))
|
|
52
|
+
expect(await SponsorBudget.transition(store, first, 'broadcasting')).toBe(true)
|
|
53
|
+
expect(await SponsorBudget.transition(store, first, 'pending')).toBe(true)
|
|
54
|
+
|
|
55
|
+
const second = SponsorBudget.reserve(
|
|
56
|
+
store,
|
|
57
|
+
parameters({
|
|
58
|
+
getReceipt,
|
|
59
|
+
id: hash2,
|
|
60
|
+
owner: 'worker-2',
|
|
61
|
+
transactionHash: hash2,
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
expect(
|
|
65
|
+
await Promise.race([
|
|
66
|
+
second.then(() => 'reserved' as const),
|
|
67
|
+
new Promise<'waiting'>((resolve) => setTimeout(() => resolve('waiting'), 40)),
|
|
68
|
+
]),
|
|
69
|
+
).toBe('waiting')
|
|
70
|
+
|
|
71
|
+
confirmed = true
|
|
72
|
+
await expect(second).resolves.toMatchObject({ id: hash2, owner: 'worker-2' })
|
|
73
|
+
const state = await store.get(storeKey)
|
|
74
|
+
expect(Object.keys(state!.reservations)).toEqual([hash2])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('fences release and transition by reservation owner', async () => {
|
|
78
|
+
const store = memoryStore()
|
|
79
|
+
const handle = await SponsorBudget.reserve(store, parameters())
|
|
80
|
+
const staleHandle = { ...handle, owner: 'stale-worker' }
|
|
81
|
+
|
|
82
|
+
expect(await SponsorBudget.transition(store, staleHandle, 'broadcasting')).toBe(false)
|
|
83
|
+
expect(await SponsorBudget.release(store, staleHandle)).toBe(false)
|
|
84
|
+
expect(await store.get(storeKey)).toMatchObject({
|
|
85
|
+
reservations: {
|
|
86
|
+
[hash1]: {
|
|
87
|
+
owner: 'worker-1',
|
|
88
|
+
phase: 'prepared',
|
|
89
|
+
transactionHash: hash1,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(await SponsorBudget.release(store, handle)).toBe(true)
|
|
95
|
+
expect(await store.get(storeKey)).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('caps reservation count independently of fee exposure', async () => {
|
|
99
|
+
const store = memoryStore()
|
|
100
|
+
const first = await SponsorBudget.reserve(
|
|
101
|
+
store,
|
|
102
|
+
parameters({ fee: 0n, maxReservations: 1, maxTotalFee: 100n }),
|
|
103
|
+
)
|
|
104
|
+
const second = SponsorBudget.reserve(
|
|
105
|
+
store,
|
|
106
|
+
parameters({
|
|
107
|
+
fee: 0n,
|
|
108
|
+
id: hash2,
|
|
109
|
+
maxReservations: 1,
|
|
110
|
+
maxTotalFee: 100n,
|
|
111
|
+
owner: 'worker-2',
|
|
112
|
+
transactionHash: hash2,
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
await Promise.race([
|
|
118
|
+
second.then(() => 'reserved' as const),
|
|
119
|
+
new Promise<'waiting'>((resolve) => setTimeout(() => resolve('waiting'), 40)),
|
|
120
|
+
]),
|
|
121
|
+
).toBe('waiting')
|
|
122
|
+
await SponsorBudget.release(store, first)
|
|
123
|
+
await expect(second).resolves.toMatchObject({ id: hash2 })
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { Hex } from 'viem'
|
|
2
|
+
|
|
3
|
+
import { VerificationFailedError } from '../../Errors.js'
|
|
4
|
+
import type * as Store from '../../Store.js'
|
|
5
|
+
|
|
6
|
+
export type Phase = 'prepared' | 'broadcasting' | 'pending'
|
|
7
|
+
|
|
8
|
+
export type Reservation = {
|
|
9
|
+
expiresAt: number
|
|
10
|
+
fee: string
|
|
11
|
+
leaseUntil: number
|
|
12
|
+
owner: string
|
|
13
|
+
phase: Phase
|
|
14
|
+
transactionHash: Hex
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type State = {
|
|
18
|
+
reservations: Record<string, Reservation>
|
|
19
|
+
version: 1
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Handle = {
|
|
23
|
+
chainId: number
|
|
24
|
+
id: string
|
|
25
|
+
owner: string
|
|
26
|
+
sponsor: Hex
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ItemMap = {
|
|
30
|
+
[key: `mppx:charge:sponsor-budget:${string}`]: State
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ReserveParameters = Handle & {
|
|
34
|
+
expiresAt: number
|
|
35
|
+
fee: bigint
|
|
36
|
+
getReceipt: (hash: Hex) => Promise<unknown>
|
|
37
|
+
maxReservations: number
|
|
38
|
+
maxTotalFee: bigint
|
|
39
|
+
transactionHash: Hex
|
|
40
|
+
waitUntil: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const initialPollIntervalMs = 10
|
|
44
|
+
const maxPollIntervalMs = 250
|
|
45
|
+
const preparedLeaseMs = 30_000
|
|
46
|
+
|
|
47
|
+
function key(parameters: Pick<Handle, 'chainId' | 'sponsor'>) {
|
|
48
|
+
return `mppx:charge:sponsor-budget:${parameters.chainId}:${parameters.sponsor.toLowerCase()}` as const
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isState(value: State | null): value is State {
|
|
52
|
+
return value?.version === 1 && typeof value.reservations === 'object'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function mutateOwned(
|
|
56
|
+
store: Store.AtomicStore<ItemMap>,
|
|
57
|
+
handle: Handle,
|
|
58
|
+
mutate: (reservation: Reservation) => Reservation | null,
|
|
59
|
+
) {
|
|
60
|
+
return store.update(key(handle), (current) => {
|
|
61
|
+
if (!isState(current)) return { op: 'noop', result: false }
|
|
62
|
+
const reservation = current.reservations[handle.id]
|
|
63
|
+
if (!reservation || reservation.owner !== handle.owner) return { op: 'noop', result: false }
|
|
64
|
+
|
|
65
|
+
const reservations = { ...current.reservations }
|
|
66
|
+
const next = mutate(reservation)
|
|
67
|
+
if (next) reservations[handle.id] = next
|
|
68
|
+
else delete reservations[handle.id]
|
|
69
|
+
|
|
70
|
+
if (Object.keys(reservations).length === 0) return { op: 'delete', result: true }
|
|
71
|
+
return {
|
|
72
|
+
op: 'set',
|
|
73
|
+
value: { reservations, version: 1 },
|
|
74
|
+
result: true,
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function reconcile(
|
|
80
|
+
store: Store.AtomicStore<ItemMap>,
|
|
81
|
+
parameters: Pick<ReserveParameters, 'chainId' | 'getReceipt' | 'sponsor'>,
|
|
82
|
+
) {
|
|
83
|
+
const state = await store.get(key(parameters))
|
|
84
|
+
if (!isState(state)) return
|
|
85
|
+
|
|
86
|
+
const now = Date.now()
|
|
87
|
+
await Promise.all(
|
|
88
|
+
Object.entries(state.reservations).map(async ([id, reservation]) => {
|
|
89
|
+
const handle = {
|
|
90
|
+
chainId: parameters.chainId,
|
|
91
|
+
id,
|
|
92
|
+
owner: reservation.owner,
|
|
93
|
+
sponsor: parameters.sponsor,
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
reservation.expiresAt <= now ||
|
|
97
|
+
(reservation.phase === 'prepared' && reservation.leaseUntil <= now)
|
|
98
|
+
) {
|
|
99
|
+
await release(store, handle)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (reservation.phase === 'prepared') return
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await parameters.getReceipt(reservation.transactionHash)
|
|
106
|
+
} catch {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
await release(store, handle)
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reserves aggregate sponsor fee capacity across processes.
|
|
116
|
+
*
|
|
117
|
+
* Pending broadcasts remain charged to the budget until a receipt is observed
|
|
118
|
+
* or their expiring nonce becomes invalid. Capacity waiters do not rewrite the
|
|
119
|
+
* shared state while waiting.
|
|
120
|
+
*
|
|
121
|
+
* @internal
|
|
122
|
+
*/
|
|
123
|
+
export async function reserve(
|
|
124
|
+
store: Store.AtomicStore<ItemMap>,
|
|
125
|
+
parameters: ReserveParameters,
|
|
126
|
+
): Promise<Handle> {
|
|
127
|
+
if (parameters.fee > parameters.maxTotalFee)
|
|
128
|
+
throw new VerificationFailedError({
|
|
129
|
+
reason: 'Sponsored transaction fee exceeds the aggregate sponsor budget',
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
let pollIntervalMs = initialPollIntervalMs
|
|
133
|
+
for (;;) {
|
|
134
|
+
const now = Date.now()
|
|
135
|
+
if (now >= parameters.waitUntil)
|
|
136
|
+
throw new VerificationFailedError({
|
|
137
|
+
reason: 'Sponsored transaction expired while waiting for sponsor budget',
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
await reconcile(store, parameters)
|
|
141
|
+
const result = await store.update(key(parameters), (current) => {
|
|
142
|
+
if (current !== null && !isState(current)) return { op: 'noop', result: 'invalid' as const }
|
|
143
|
+
|
|
144
|
+
const reservations = { ...(current?.reservations ?? {}) }
|
|
145
|
+
const existing = reservations[parameters.id]
|
|
146
|
+
if (existing) return { op: 'noop', result: 'duplicate' as const }
|
|
147
|
+
|
|
148
|
+
const values = Object.values(reservations)
|
|
149
|
+
const totalFee = values.reduce((total, reservation) => total + BigInt(reservation.fee), 0n)
|
|
150
|
+
if (
|
|
151
|
+
values.length >= parameters.maxReservations ||
|
|
152
|
+
totalFee + parameters.fee > parameters.maxTotalFee
|
|
153
|
+
)
|
|
154
|
+
return { op: 'noop', result: 'wait' as const }
|
|
155
|
+
|
|
156
|
+
reservations[parameters.id] = {
|
|
157
|
+
expiresAt: parameters.expiresAt,
|
|
158
|
+
fee: parameters.fee.toString(),
|
|
159
|
+
leaseUntil: Math.min(parameters.expiresAt, now + preparedLeaseMs),
|
|
160
|
+
owner: parameters.owner,
|
|
161
|
+
phase: 'prepared',
|
|
162
|
+
transactionHash: parameters.transactionHash,
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
op: 'set',
|
|
166
|
+
value: { reservations, version: 1 },
|
|
167
|
+
result: 'reserved' as const,
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
if (result === 'reserved') return parameters
|
|
172
|
+
if (result === 'invalid')
|
|
173
|
+
throw new VerificationFailedError({
|
|
174
|
+
reason: 'Sponsor budget store contains incompatible state',
|
|
175
|
+
})
|
|
176
|
+
if (result === 'duplicate')
|
|
177
|
+
throw new VerificationFailedError({
|
|
178
|
+
reason: 'Sponsored transaction already has a budget reservation',
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
await new Promise((resolve) =>
|
|
182
|
+
setTimeout(resolve, Math.min(pollIntervalMs, parameters.waitUntil - now)),
|
|
183
|
+
)
|
|
184
|
+
pollIntervalMs = Math.min(pollIntervalMs * 2, maxPollIntervalMs)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Advances a reservation before and after the broadcast call.
|
|
190
|
+
*
|
|
191
|
+
* The owner token fences stale workers from mutating a replacement reservation.
|
|
192
|
+
*
|
|
193
|
+
* @internal
|
|
194
|
+
*/
|
|
195
|
+
export async function transition(
|
|
196
|
+
store: Store.AtomicStore<ItemMap>,
|
|
197
|
+
handle: Handle,
|
|
198
|
+
phase: Exclude<Phase, 'prepared'>,
|
|
199
|
+
): Promise<boolean> {
|
|
200
|
+
return mutateOwned(store, handle, (reservation) => ({
|
|
201
|
+
...reservation,
|
|
202
|
+
phase,
|
|
203
|
+
}))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Releases a reservation only when the caller still owns it.
|
|
208
|
+
*
|
|
209
|
+
* @internal
|
|
210
|
+
*/
|
|
211
|
+
export async function release(store: Store.AtomicStore<ItemMap>, handle: Handle): Promise<boolean> {
|
|
212
|
+
return mutateOwned(store, handle, () => null)
|
|
213
|
+
}
|