@brninpay/sdk 0.1.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/.turbo/turbo-build.log +4 -0
- package/LICENSE +203 -0
- package/README.md +5 -0
- package/dist/actor-client.d.ts +13 -0
- package/dist/actor-client.d.ts.map +1 -0
- package/dist/actor-client.js +44 -0
- package/dist/actor-client.js.map +1 -0
- package/dist/bundler/client.d.ts +20 -0
- package/dist/bundler/client.d.ts.map +1 -0
- package/dist/bundler/client.js +228 -0
- package/dist/bundler/client.js.map +1 -0
- package/dist/bundler/providers/alchemy.d.ts +3 -0
- package/dist/bundler/providers/alchemy.d.ts.map +1 -0
- package/dist/bundler/providers/alchemy.js +15 -0
- package/dist/bundler/providers/alchemy.js.map +1 -0
- package/dist/bundler/providers/pimlico.d.ts +3 -0
- package/dist/bundler/providers/pimlico.d.ts.map +1 -0
- package/dist/bundler/providers/pimlico.js +12 -0
- package/dist/bundler/providers/pimlico.js.map +1 -0
- package/dist/client.d.ts +24 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +352 -0
- package/dist/client.js.map +1 -0
- package/dist/conversion.d.ts +3 -0
- package/dist/conversion.d.ts.map +1 -0
- package/dist/conversion.js +27 -0
- package/dist/conversion.js.map +1 -0
- package/dist/erc4337/builder.d.ts +15 -0
- package/dist/erc4337/builder.d.ts.map +1 -0
- package/dist/erc4337/builder.js +144 -0
- package/dist/erc4337/builder.js.map +1 -0
- package/dist/erc4337/providers/kernel.d.ts +23 -0
- package/dist/erc4337/providers/kernel.d.ts.map +1 -0
- package/dist/erc4337/providers/kernel.js +120 -0
- package/dist/erc4337/providers/kernel.js.map +1 -0
- package/dist/erc4337/providers/safe.d.ts +23 -0
- package/dist/erc4337/providers/safe.d.ts.map +1 -0
- package/dist/erc4337/providers/safe.js +117 -0
- package/dist/erc4337/providers/safe.js.map +1 -0
- package/dist/erc4337/providers/types.d.ts +88 -0
- package/dist/erc4337/providers/types.d.ts.map +1 -0
- package/dist/erc4337/providers/types.js +35 -0
- package/dist/erc4337/providers/types.js.map +1 -0
- package/dist/erc4337/smart-wallet.d.ts +16 -0
- package/dist/erc4337/smart-wallet.d.ts.map +1 -0
- package/dist/erc4337/smart-wallet.js +86 -0
- package/dist/erc4337/smart-wallet.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/payment-intents.d.ts +99 -0
- package/dist/modules/payment-intents.d.ts.map +1 -0
- package/dist/modules/payment-intents.js +118 -0
- package/dist/modules/payment-intents.js.map +1 -0
- package/dist/modules/policies.d.ts +87 -0
- package/dist/modules/policies.d.ts.map +1 -0
- package/dist/modules/policies.js +61 -0
- package/dist/modules/policies.js.map +1 -0
- package/dist/paymaster/client.d.ts +11 -0
- package/dist/paymaster/client.d.ts.map +1 -0
- package/dist/paymaster/client.js +119 -0
- package/dist/paymaster/client.js.map +1 -0
- package/dist/runtimee.d.ts +4 -0
- package/dist/runtimee.d.ts.map +1 -0
- package/dist/runtimee.js +4 -0
- package/dist/runtimee.js.map +1 -0
- package/dist/types.d.ts +389 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +29 -0
- package/src/actor-client.ts +57 -0
- package/src/bundler/client.ts +306 -0
- package/src/bundler/providers/alchemy.ts +21 -0
- package/src/bundler/providers/pimlico.ts +17 -0
- package/src/client.ts +472 -0
- package/src/conversion.ts +25 -0
- package/src/erc4337/builder.ts +181 -0
- package/src/erc4337/providers/kernel.ts +136 -0
- package/src/erc4337/providers/safe.ts +133 -0
- package/src/erc4337/providers/types.ts +73 -0
- package/src/erc4337/smart-wallet.ts +115 -0
- package/src/index.ts +60 -0
- package/src/modules/payment-intents.ts +156 -0
- package/src/modules/policies.ts +79 -0
- package/src/paymaster/client.ts +155 -0
- package/src/runtimee.ts +3 -0
- package/src/types.ts +469 -0
- package/test/architecture/architecture-invariants.test.ts +61 -0
- package/test/client.test.ts +180 -0
- package/test/conversion.test.ts +42 -0
- package/test/runtimee.test.ts +30 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +8 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AllowlistDeniedError,
|
|
3
|
+
AuthorizationError,
|
|
4
|
+
BudgetExhaustedError,
|
|
5
|
+
ErrorCode,
|
|
6
|
+
ExecutionError,
|
|
7
|
+
HmacVerificationError,
|
|
8
|
+
NetworkError,
|
|
9
|
+
RateLimitExceededError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
deserializeBigInts,
|
|
12
|
+
generateNonce,
|
|
13
|
+
sha256Hex,
|
|
14
|
+
signHmacSha256,
|
|
15
|
+
} from '@brninpay/core'
|
|
16
|
+
import { ActorClient } from './actor-client.js'
|
|
17
|
+
import { PaymentIntentClient } from './modules/payment-intents.js'
|
|
18
|
+
import { PolicyClient } from './modules/policies.js'
|
|
19
|
+
import type {
|
|
20
|
+
ApiErrorPayload,
|
|
21
|
+
ClientRequestOptions,
|
|
22
|
+
ClientTransport,
|
|
23
|
+
RequestContext,
|
|
24
|
+
RequestInterceptor,
|
|
25
|
+
ResponseContext,
|
|
26
|
+
ResponseInterceptor,
|
|
27
|
+
RetryPolicy,
|
|
28
|
+
RuntimeeConfig,
|
|
29
|
+
SdkLogger,
|
|
30
|
+
} from './types.js'
|
|
31
|
+
|
|
32
|
+
const DEFAULT_BASE_URL = 'https://api.runtimee.dev'
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 15_000
|
|
34
|
+
const DEFAULT_RETRY_POLICY: RetryPolicy = {
|
|
35
|
+
retries: 2,
|
|
36
|
+
baseDelayMs: 250,
|
|
37
|
+
maxDelayMs: 2_000,
|
|
38
|
+
retryMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultLogger: SdkLogger = {
|
|
42
|
+
debug: () => undefined,
|
|
43
|
+
info: () => undefined,
|
|
44
|
+
warn: () => undefined,
|
|
45
|
+
error: () => undefined,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateBaseUrl(baseUrl: string): URL {
|
|
49
|
+
const url = new URL(baseUrl)
|
|
50
|
+
const isLocalhost = ['localhost', '127.0.0.1'].includes(url.hostname)
|
|
51
|
+
|
|
52
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLocalhost)) {
|
|
53
|
+
throw new ValidationError(
|
|
54
|
+
'SDK baseUrl must use HTTPS unless it points to localhost for development',
|
|
55
|
+
{ baseUrl }
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return url
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sleep(ms: number): Promise<void> {
|
|
63
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mergeRetryPolicy(policy?: Partial<RetryPolicy>): RetryPolicy {
|
|
67
|
+
return {
|
|
68
|
+
retries: policy?.retries ?? DEFAULT_RETRY_POLICY.retries,
|
|
69
|
+
baseDelayMs: policy?.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs,
|
|
70
|
+
maxDelayMs: policy?.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
|
|
71
|
+
retryMethods: policy?.retryMethods ?? DEFAULT_RETRY_POLICY.retryMethods,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildQueryString(
|
|
76
|
+
query: Record<string, string | number | boolean | null | undefined> | undefined
|
|
77
|
+
): string {
|
|
78
|
+
if (!query) {
|
|
79
|
+
return ''
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const search = new URLSearchParams()
|
|
83
|
+
for (const [key, value] of Object.entries(query)) {
|
|
84
|
+
if (value !== undefined && value !== null) {
|
|
85
|
+
search.set(key, String(value))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const encoded = search.toString()
|
|
90
|
+
return encoded ? `?${encoded}` : ''
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizePath(path: string): string {
|
|
94
|
+
return path.startsWith('/') ? path : `/${path}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createAbortSignal(signal: AbortSignal | undefined, timeoutMs: number): {
|
|
98
|
+
signal: AbortSignal
|
|
99
|
+
cleanup(): void
|
|
100
|
+
} {
|
|
101
|
+
const controller = new AbortController()
|
|
102
|
+
const timeoutId = setTimeout(() => controller.abort('Request timed out'), timeoutMs)
|
|
103
|
+
|
|
104
|
+
const onAbort = () => controller.abort(signal?.reason)
|
|
105
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
signal: controller.signal,
|
|
109
|
+
cleanup() {
|
|
110
|
+
clearTimeout(timeoutId)
|
|
111
|
+
signal?.removeEventListener('abort', onAbort)
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isRetriableStatus(status: number): boolean {
|
|
117
|
+
return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getRetryAfterMs(response: Response): number | null {
|
|
121
|
+
const retryAfter = response.headers.get('retry-after')
|
|
122
|
+
if (!retryAfter) {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const seconds = Number(retryAfter)
|
|
127
|
+
if (Number.isFinite(seconds)) {
|
|
128
|
+
return seconds * 1000
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const dateMs = new Date(retryAfter).getTime()
|
|
132
|
+
if (Number.isNaN(dateMs)) {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Math.max(dateMs - Date.now(), 0)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function computeBackoffMs(attempt: number, policy: RetryPolicy, response?: Response): number {
|
|
140
|
+
const retryAfterMs = response ? getRetryAfterMs(response) : null
|
|
141
|
+
if (retryAfterMs !== null) {
|
|
142
|
+
return retryAfterMs
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const exponentialDelay = Math.min(
|
|
146
|
+
policy.baseDelayMs * 2 ** Math.max(attempt - 1, 0),
|
|
147
|
+
policy.maxDelayMs
|
|
148
|
+
)
|
|
149
|
+
const jitter = Math.floor(Math.random() * Math.min(100, policy.baseDelayMs))
|
|
150
|
+
return exponentialDelay + jitter
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatRequestBody(body: unknown): string | undefined {
|
|
154
|
+
if (body === undefined) {
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function parseResponsePayload(response: Response): Promise<unknown> {
|
|
162
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
163
|
+
|
|
164
|
+
if (contentType.includes('application/json')) {
|
|
165
|
+
return deserializeBigInts(await response.json())
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (contentType.startsWith('text/')) {
|
|
169
|
+
return response.text()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (response.status === 204) {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const text = await response.text()
|
|
177
|
+
if (!text) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
return deserializeBigInts(JSON.parse(text))
|
|
183
|
+
} catch {
|
|
184
|
+
return text
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeApiError(payload: unknown): ApiErrorPayload {
|
|
189
|
+
if (payload && typeof payload === 'object') {
|
|
190
|
+
const value = payload as Record<string, unknown>
|
|
191
|
+
return {
|
|
192
|
+
code: typeof value.code === 'string' ? value.code : undefined,
|
|
193
|
+
message:
|
|
194
|
+
typeof value.message === 'string'
|
|
195
|
+
? value.message
|
|
196
|
+
: typeof value.error === 'string'
|
|
197
|
+
? value.error
|
|
198
|
+
: undefined,
|
|
199
|
+
details: value.details,
|
|
200
|
+
statusCode: typeof value.statusCode === 'number' ? value.statusCode : undefined,
|
|
201
|
+
error: typeof value.error === 'string' ? value.error : undefined,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof payload === 'string') {
|
|
206
|
+
return { message: payload, error: payload }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function mapApiError(status: number, payload: unknown): Error {
|
|
213
|
+
const apiError = normalizeApiError(payload)
|
|
214
|
+
const message = apiError.message ?? apiError.error ?? `Request failed with status ${status}`
|
|
215
|
+
|
|
216
|
+
switch (apiError.code) {
|
|
217
|
+
case ErrorCode.VALIDATION_ERROR:
|
|
218
|
+
return new ValidationError(message, apiError.details)
|
|
219
|
+
case ErrorCode.BUDGET_EXHAUSTED: {
|
|
220
|
+
const details = (apiError.details ?? {}) as Record<string, unknown>
|
|
221
|
+
return new BudgetExhaustedError(
|
|
222
|
+
typeof details.policyId === 'string' ? details.policyId : 'unknown',
|
|
223
|
+
BigInt(typeof details.budgetUsed === 'string' ? details.budgetUsed : '0'),
|
|
224
|
+
BigInt(typeof details.budgetLimit === 'string' ? details.budgetLimit : '0')
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
case ErrorCode.ALLOWLIST_DENIED: {
|
|
228
|
+
const details = (apiError.details ?? {}) as Record<string, unknown>
|
|
229
|
+
return new AllowlistDeniedError(
|
|
230
|
+
typeof details.policyId === 'string' ? details.policyId : 'unknown',
|
|
231
|
+
typeof details.target === 'string' ? details.target : 'unknown'
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
case ErrorCode.RATE_LIMIT_EXCEEDED: {
|
|
235
|
+
const details = (apiError.details ?? {}) as Record<string, unknown>
|
|
236
|
+
return new RateLimitExceededError(
|
|
237
|
+
typeof details.policyId === 'string' ? details.policyId : 'unknown',
|
|
238
|
+
typeof details.windowMs === 'number' ? details.windowMs : 0,
|
|
239
|
+
typeof details.maxCalls === 'number' ? details.maxCalls : 0
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
case ErrorCode.HMAC_VERIFICATION_FAILED:
|
|
243
|
+
return new HmacVerificationError(message, apiError.details)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (status === 400) {
|
|
247
|
+
return new ValidationError(message, apiError.details)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (status === 401 || status === 403) {
|
|
251
|
+
return new AuthorizationError(apiError.code ?? ErrorCode.AUTHORIZATION_ERROR, message, apiError.details)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (status === 429) {
|
|
255
|
+
return new RateLimitExceededError('api', 0, 0, { cause: payload as Error })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (status >= 500) {
|
|
259
|
+
return new ExecutionError(apiError.code ?? ErrorCode.EXECUTION_ERROR, message, apiError.details)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return new ExecutionError(apiError.code ?? ErrorCode.EXECUTION_ERROR, message, apiError.details)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export class BrninpayClient implements ClientTransport {
|
|
266
|
+
public readonly actors: ActorClient
|
|
267
|
+
public readonly paymentIntents: PaymentIntentClient
|
|
268
|
+
public readonly policies: PolicyClient
|
|
269
|
+
|
|
270
|
+
protected readonly fetchImplementation: typeof fetch
|
|
271
|
+
protected readonly logger: SdkLogger
|
|
272
|
+
protected readonly baseUrl: URL
|
|
273
|
+
protected readonly apiKey: string
|
|
274
|
+
protected readonly apiSecret?: string
|
|
275
|
+
protected readonly timeoutMs: number
|
|
276
|
+
protected readonly defaultHeaders: Record<string, string>
|
|
277
|
+
protected readonly retryPolicy: RetryPolicy
|
|
278
|
+
protected readonly requestInterceptors: RequestInterceptor[]
|
|
279
|
+
protected readonly responseInterceptors: ResponseInterceptor[]
|
|
280
|
+
|
|
281
|
+
constructor(config: RuntimeeConfig) {
|
|
282
|
+
if (!config.apiKey?.trim()) {
|
|
283
|
+
throw new ValidationError('Runtimee SDK requires a non-empty apiKey')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this.baseUrl = validateBaseUrl(config.baseUrl ?? DEFAULT_BASE_URL)
|
|
287
|
+
this.apiKey = config.apiKey
|
|
288
|
+
this.apiSecret = config.apiSecret?.trim() || undefined
|
|
289
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
290
|
+
this.fetchImplementation = config.fetchImplementation ?? fetch.bind(globalThis)
|
|
291
|
+
this.logger = config.logger ?? defaultLogger
|
|
292
|
+
this.defaultHeaders = { ...(config.headers ?? {}) }
|
|
293
|
+
this.retryPolicy = mergeRetryPolicy(config.retryPolicy)
|
|
294
|
+
this.requestInterceptors = config.requestInterceptors ?? []
|
|
295
|
+
this.responseInterceptors = config.responseInterceptors ?? []
|
|
296
|
+
|
|
297
|
+
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs <= 0) {
|
|
298
|
+
throw new ValidationError('Runtimee SDK timeoutMs must be a positive integer', {
|
|
299
|
+
timeoutMs: config.timeoutMs,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.actors = new ActorClient(this)
|
|
304
|
+
this.paymentIntents = new PaymentIntentClient(this)
|
|
305
|
+
this.policies = new PolicyClient(this)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async request<T>(options: ClientRequestOptions): Promise<T> {
|
|
309
|
+
const method = options.method ?? 'GET'
|
|
310
|
+
const retryPolicy = mergeRetryPolicy(options.retryPolicy)
|
|
311
|
+
|
|
312
|
+
if (!retryPolicy.retryMethods.includes(method) && retryPolicy.retries > 0) {
|
|
313
|
+
this.logger.debug('Retries disabled for HTTP method', { method, path: options.path })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let lastError: Error | undefined
|
|
317
|
+
|
|
318
|
+
for (let attempt = 1; attempt <= retryPolicy.retries + 1; attempt += 1) {
|
|
319
|
+
try {
|
|
320
|
+
return await this.executeRequest<T>({
|
|
321
|
+
...options,
|
|
322
|
+
method,
|
|
323
|
+
retryPolicy,
|
|
324
|
+
attempt,
|
|
325
|
+
})
|
|
326
|
+
} catch (error) {
|
|
327
|
+
lastError = error instanceof Error ? error : new Error(String(error))
|
|
328
|
+
const isLastAttempt = attempt > retryPolicy.retries
|
|
329
|
+
|
|
330
|
+
if (isLastAttempt || !retryPolicy.retryMethods.includes(method)) {
|
|
331
|
+
throw lastError
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const response = (error as Error & { response?: Response }).response
|
|
335
|
+
if (response && !isRetriableStatus(response.status)) {
|
|
336
|
+
throw lastError
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const backoffMs = computeBackoffMs(attempt, retryPolicy, response)
|
|
340
|
+
this.logger.warn('Retrying SDK request after failure', {
|
|
341
|
+
attempt,
|
|
342
|
+
method,
|
|
343
|
+
path: options.path,
|
|
344
|
+
backoffMs,
|
|
345
|
+
error: lastError.message,
|
|
346
|
+
})
|
|
347
|
+
await sleep(backoffMs)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
throw lastError ?? new Error('SDK request failed without an error object')
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async executeRequest<T>(
|
|
355
|
+
options: ClientRequestOptions & {
|
|
356
|
+
method: NonNullable<ClientRequestOptions['method']>
|
|
357
|
+
retryPolicy: RetryPolicy
|
|
358
|
+
attempt: number
|
|
359
|
+
}
|
|
360
|
+
): Promise<T> {
|
|
361
|
+
const path = normalizePath(options.path)
|
|
362
|
+
const url = new URL(`${this.baseUrl.origin}${path}${buildQueryString(options.query)}`)
|
|
363
|
+
const bodyText = formatRequestBody(options.body)
|
|
364
|
+
const headers = new Headers({
|
|
365
|
+
Accept: 'application/json',
|
|
366
|
+
'Content-Type': 'application/json',
|
|
367
|
+
'X-API-Key': this.apiKey,
|
|
368
|
+
...this.defaultHeaders,
|
|
369
|
+
...(options.headers ?? {}),
|
|
370
|
+
})
|
|
371
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs
|
|
372
|
+
|
|
373
|
+
const requestContext: RequestContext = {
|
|
374
|
+
url,
|
|
375
|
+
method: options.method,
|
|
376
|
+
headers,
|
|
377
|
+
bodyText,
|
|
378
|
+
timeoutMs,
|
|
379
|
+
attempt: options.attempt,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const interceptor of this.requestInterceptors) {
|
|
383
|
+
const nextContext = await interceptor(requestContext)
|
|
384
|
+
if (nextContext) {
|
|
385
|
+
requestContext.url = nextContext.url
|
|
386
|
+
requestContext.method = nextContext.method
|
|
387
|
+
requestContext.headers = nextContext.headers
|
|
388
|
+
requestContext.bodyText = nextContext.bodyText
|
|
389
|
+
requestContext.timeoutMs = nextContext.timeoutMs
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
await this.applyAuthentication(requestContext)
|
|
394
|
+
|
|
395
|
+
const { signal, cleanup } = createAbortSignal(options.signal, requestContext.timeoutMs)
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const response = await this.fetchImplementation(requestContext.url, {
|
|
399
|
+
method: requestContext.method,
|
|
400
|
+
headers: requestContext.headers,
|
|
401
|
+
body: requestContext.bodyText,
|
|
402
|
+
signal,
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const payload = await parseResponsePayload(response)
|
|
406
|
+
|
|
407
|
+
if (!response.ok) {
|
|
408
|
+
const mappedError = mapApiError(response.status, payload) as Error & { response?: Response }
|
|
409
|
+
mappedError.response = response
|
|
410
|
+
throw mappedError
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let responseContext: ResponseContext<T> = {
|
|
414
|
+
request: requestContext,
|
|
415
|
+
response,
|
|
416
|
+
data: payload as T,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const interceptor of this.responseInterceptors) {
|
|
420
|
+
const nextContext = await interceptor(responseContext)
|
|
421
|
+
if (nextContext) {
|
|
422
|
+
responseContext = nextContext as ResponseContext<T>
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return responseContext.data
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
429
|
+
throw new NetworkError(`Request timed out after ${requestContext.timeoutMs}ms`, {
|
|
430
|
+
path,
|
|
431
|
+
method: requestContext.method,
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (error instanceof TypeError) {
|
|
436
|
+
throw new NetworkError(error.message, { path, method: requestContext.method }, { cause: error })
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
throw error
|
|
440
|
+
} finally {
|
|
441
|
+
cleanup()
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private async applyAuthentication(context: RequestContext): Promise<void> {
|
|
446
|
+
if (!this.apiSecret) {
|
|
447
|
+
context.headers.set('Authorization', `Bearer ${this.apiKey}`)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const timestamp = new Date().toISOString()
|
|
452
|
+
const nonce = generateNonce()
|
|
453
|
+
const payloadHash = await sha256Hex(context.bodyText ?? '')
|
|
454
|
+
const signingPayload = [
|
|
455
|
+
timestamp,
|
|
456
|
+
nonce,
|
|
457
|
+
context.method.toUpperCase(),
|
|
458
|
+
`${context.url.pathname}${context.url.search}`,
|
|
459
|
+
payloadHash,
|
|
460
|
+
].join('.')
|
|
461
|
+
|
|
462
|
+
const signature = await signHmacSha256({
|
|
463
|
+
secret: this.apiSecret,
|
|
464
|
+
message: signingPayload,
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
context.headers.set('X-API-Key', this.apiKey)
|
|
468
|
+
context.headers.set('X-Signature', signature)
|
|
469
|
+
context.headers.set('X-Timestamp', timestamp)
|
|
470
|
+
context.headers.set('X-Nonce', nonce)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const USDC_DECIMALS = 6
|
|
2
|
+
const MULTIPLIER = 10n ** BigInt(USDC_DECIMALS)
|
|
3
|
+
|
|
4
|
+
export function parseUsdc(amount: string): bigint {
|
|
5
|
+
const parts = amount.split('.')
|
|
6
|
+
if (parts.length > 2) throw new Error(`Invalid USDC amount: ${amount}`)
|
|
7
|
+
const whole = parts[0] || '0'
|
|
8
|
+
let fraction = parts[1] || ''
|
|
9
|
+
if (fraction.length > USDC_DECIMALS) {
|
|
10
|
+
fraction = fraction.slice(0, USDC_DECIMALS)
|
|
11
|
+
}
|
|
12
|
+
fraction = fraction.padEnd(USDC_DECIMALS, '0')
|
|
13
|
+
const full = whole + fraction
|
|
14
|
+
const parsed = BigInt(full)
|
|
15
|
+
if (parsed < 0n) throw new Error(`Negative USDC amount: ${amount}`)
|
|
16
|
+
return parsed
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatUsdc(amount: bigint): string {
|
|
20
|
+
const whole = amount / MULTIPLIER
|
|
21
|
+
const fraction = amount % MULTIPLIER
|
|
22
|
+
if (fraction === 0n) return whole.toString()
|
|
23
|
+
const fractionStr = fraction.toString().padStart(USDC_DECIMALS, '0').replace(/0+$/, '')
|
|
24
|
+
return `${whole}.${fractionStr}`
|
|
25
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ValidationError,
|
|
3
|
+
type Address,
|
|
4
|
+
type GasEstimate,
|
|
5
|
+
type UserOperation,
|
|
6
|
+
type UserOperationCall,
|
|
7
|
+
} from '@brninpay/core'
|
|
8
|
+
import { encodeFunctionData } from 'viem'
|
|
9
|
+
import type {
|
|
10
|
+
PreparedUserOperationContext,
|
|
11
|
+
SmartWalletAccountProvider,
|
|
12
|
+
UserOperationBuilderConfig,
|
|
13
|
+
} from '../types.js'
|
|
14
|
+
|
|
15
|
+
const USDC_ABI = [
|
|
16
|
+
{
|
|
17
|
+
type: 'function',
|
|
18
|
+
name: 'transfer',
|
|
19
|
+
stateMutability: 'nonpayable',
|
|
20
|
+
inputs: [
|
|
21
|
+
{ name: 'to', type: 'address' },
|
|
22
|
+
{ name: 'value', type: 'uint256' },
|
|
23
|
+
],
|
|
24
|
+
outputs: [{ name: '', type: 'bool' }],
|
|
25
|
+
},
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
function appendFactoryToInitCode(factory: Address, factoryData: `0x${string}`): `0x${string}` {
|
|
29
|
+
return `${factory}${factoryData.slice(2)}` as `0x${string}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class UserOperationBuilder {
|
|
33
|
+
constructor(private readonly config: UserOperationBuilderConfig) {}
|
|
34
|
+
|
|
35
|
+
buildUSDCTransfer(parameters: { recipient: Address; amount: bigint }): UserOperationCall {
|
|
36
|
+
if (parameters.amount <= 0n) {
|
|
37
|
+
throw new ValidationError('USDC transfer amount must be greater than zero')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
to: this.config.usdcAddress,
|
|
42
|
+
value: 0n,
|
|
43
|
+
data: encodeFunctionData({
|
|
44
|
+
abi: USDC_ABI,
|
|
45
|
+
functionName: 'transfer',
|
|
46
|
+
args: [parameters.recipient, parameters.amount],
|
|
47
|
+
}),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
buildBatchOperations(calls: readonly UserOperationCall[]): readonly UserOperationCall[] {
|
|
52
|
+
if (calls.length === 0) {
|
|
53
|
+
throw new ValidationError('buildBatchOperations requires at least one call')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return calls.map((call) => ({ ...call }))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async prepareUserOperation(
|
|
60
|
+
provider: SmartWalletAccountProvider,
|
|
61
|
+
context: PreparedUserOperationContext
|
|
62
|
+
): Promise<UserOperation> {
|
|
63
|
+
const callData = await provider.encodeCalls(context.calls)
|
|
64
|
+
const nonce = context.nonce ?? (await provider.getNonce(context.walletAddress))
|
|
65
|
+
const fees = await this.getSuggestedFees()
|
|
66
|
+
const stubSignature = await provider.getStubSignature()
|
|
67
|
+
const factoryArgs = context.walletDeployed ? {} : await provider.getFactoryArgs()
|
|
68
|
+
|
|
69
|
+
const baseUserOperation: UserOperation =
|
|
70
|
+
this.config.entryPointVersion === 'v0.6'
|
|
71
|
+
? {
|
|
72
|
+
version: 'v0.6',
|
|
73
|
+
sender: context.walletAddress,
|
|
74
|
+
nonce,
|
|
75
|
+
initCode:
|
|
76
|
+
factoryArgs.factory && factoryArgs.factoryData
|
|
77
|
+
? appendFactoryToInitCode(factoryArgs.factory, factoryArgs.factoryData)
|
|
78
|
+
: '0x',
|
|
79
|
+
callData,
|
|
80
|
+
callGasLimit: 0n,
|
|
81
|
+
verificationGasLimit: 0n,
|
|
82
|
+
preVerificationGas: 0n,
|
|
83
|
+
maxFeePerGas: fees.maxFeePerGas,
|
|
84
|
+
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
|
|
85
|
+
paymasterAndData:
|
|
86
|
+
context.sponsorship?.version === 'v0.6' ? context.sponsorship.paymasterAndData : '0x',
|
|
87
|
+
signature: stubSignature,
|
|
88
|
+
}
|
|
89
|
+
: {
|
|
90
|
+
version: 'v0.7',
|
|
91
|
+
sender: context.walletAddress,
|
|
92
|
+
nonce,
|
|
93
|
+
factory: factoryArgs.factory ?? '0x',
|
|
94
|
+
factoryData: factoryArgs.factoryData ?? '0x',
|
|
95
|
+
callData,
|
|
96
|
+
callGasLimit: 0n,
|
|
97
|
+
verificationGasLimit: 0n,
|
|
98
|
+
preVerificationGas: 0n,
|
|
99
|
+
maxFeePerGas: fees.maxFeePerGas,
|
|
100
|
+
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
|
|
101
|
+
paymaster: context.sponsorship?.version === 'v0.7' ? context.sponsorship.paymaster : '0x',
|
|
102
|
+
paymasterVerificationGasLimit:
|
|
103
|
+
context.sponsorship?.version === 'v0.7'
|
|
104
|
+
? context.sponsorship.paymasterVerificationGasLimit
|
|
105
|
+
: 0n,
|
|
106
|
+
paymasterPostOpGasLimit:
|
|
107
|
+
context.sponsorship?.version === 'v0.7'
|
|
108
|
+
? context.sponsorship.paymasterPostOpGasLimit
|
|
109
|
+
: 0n,
|
|
110
|
+
paymasterData:
|
|
111
|
+
context.sponsorship?.version === 'v0.7' ? context.sponsorship.paymasterData : '0x',
|
|
112
|
+
signature: stubSignature,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!this.config.bundlerClient) {
|
|
116
|
+
return this.applyGasEstimate(baseUserOperation, {
|
|
117
|
+
callGasLimit: 180_000n,
|
|
118
|
+
verificationGasLimit: 120_000n,
|
|
119
|
+
preVerificationGas: 60_000n,
|
|
120
|
+
maxFeePerGas: fees.maxFeePerGas,
|
|
121
|
+
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const gasEstimate = await this.config.bundlerClient.estimateUserOperationGas(baseUserOperation)
|
|
126
|
+
return this.applyGasEstimate(baseUserOperation, gasEstimate)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private applyGasEstimate(userOperation: UserOperation, gasEstimate: GasEstimate): UserOperation {
|
|
130
|
+
if (userOperation.version === 'v0.6') {
|
|
131
|
+
return {
|
|
132
|
+
...userOperation,
|
|
133
|
+
callGasLimit: gasEstimate.callGasLimit,
|
|
134
|
+
verificationGasLimit: gasEstimate.verificationGasLimit,
|
|
135
|
+
preVerificationGas: gasEstimate.preVerificationGas,
|
|
136
|
+
maxFeePerGas: gasEstimate.maxFeePerGas,
|
|
137
|
+
maxPriorityFeePerGas: gasEstimate.maxPriorityFeePerGas,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
...userOperation,
|
|
143
|
+
callGasLimit: gasEstimate.callGasLimit,
|
|
144
|
+
verificationGasLimit: gasEstimate.verificationGasLimit,
|
|
145
|
+
preVerificationGas: gasEstimate.preVerificationGas,
|
|
146
|
+
maxFeePerGas: gasEstimate.maxFeePerGas,
|
|
147
|
+
maxPriorityFeePerGas: gasEstimate.maxPriorityFeePerGas,
|
|
148
|
+
paymasterVerificationGasLimit:
|
|
149
|
+
gasEstimate.paymasterVerificationGasLimit ?? userOperation.paymasterVerificationGasLimit,
|
|
150
|
+
paymasterPostOpGasLimit:
|
|
151
|
+
gasEstimate.paymasterPostOpGasLimit ?? userOperation.paymasterPostOpGasLimit,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async getSuggestedFees(): Promise<{
|
|
156
|
+
maxFeePerGas: bigint
|
|
157
|
+
maxPriorityFeePerGas: bigint
|
|
158
|
+
}> {
|
|
159
|
+
if (this.config.publicClient?.estimateFeesPerGas) {
|
|
160
|
+
const fees = await this.config.publicClient.estimateFeesPerGas()
|
|
161
|
+
const gasPrice = fees.gasPrice ?? 0n
|
|
162
|
+
return {
|
|
163
|
+
maxFeePerGas: fees.maxFeePerGas ?? gasPrice,
|
|
164
|
+
maxPriorityFeePerGas: fees.maxPriorityFeePerGas ?? gasPrice,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (this.config.publicClient?.getGasPrice) {
|
|
169
|
+
const gasPrice = await this.config.publicClient.getGasPrice()
|
|
170
|
+
return {
|
|
171
|
+
maxFeePerGas: gasPrice,
|
|
172
|
+
maxPriorityFeePerGas: gasPrice,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
maxFeePerGas: 1_000_000_000n,
|
|
178
|
+
maxPriorityFeePerGas: 100_000_000n,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|