@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.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/LICENSE +203 -0
  3. package/README.md +5 -0
  4. package/dist/actor-client.d.ts +13 -0
  5. package/dist/actor-client.d.ts.map +1 -0
  6. package/dist/actor-client.js +44 -0
  7. package/dist/actor-client.js.map +1 -0
  8. package/dist/bundler/client.d.ts +20 -0
  9. package/dist/bundler/client.d.ts.map +1 -0
  10. package/dist/bundler/client.js +228 -0
  11. package/dist/bundler/client.js.map +1 -0
  12. package/dist/bundler/providers/alchemy.d.ts +3 -0
  13. package/dist/bundler/providers/alchemy.d.ts.map +1 -0
  14. package/dist/bundler/providers/alchemy.js +15 -0
  15. package/dist/bundler/providers/alchemy.js.map +1 -0
  16. package/dist/bundler/providers/pimlico.d.ts +3 -0
  17. package/dist/bundler/providers/pimlico.d.ts.map +1 -0
  18. package/dist/bundler/providers/pimlico.js +12 -0
  19. package/dist/bundler/providers/pimlico.js.map +1 -0
  20. package/dist/client.d.ts +24 -0
  21. package/dist/client.d.ts.map +1 -0
  22. package/dist/client.js +352 -0
  23. package/dist/client.js.map +1 -0
  24. package/dist/conversion.d.ts +3 -0
  25. package/dist/conversion.d.ts.map +1 -0
  26. package/dist/conversion.js +27 -0
  27. package/dist/conversion.js.map +1 -0
  28. package/dist/erc4337/builder.d.ts +15 -0
  29. package/dist/erc4337/builder.d.ts.map +1 -0
  30. package/dist/erc4337/builder.js +144 -0
  31. package/dist/erc4337/builder.js.map +1 -0
  32. package/dist/erc4337/providers/kernel.d.ts +23 -0
  33. package/dist/erc4337/providers/kernel.d.ts.map +1 -0
  34. package/dist/erc4337/providers/kernel.js +120 -0
  35. package/dist/erc4337/providers/kernel.js.map +1 -0
  36. package/dist/erc4337/providers/safe.d.ts +23 -0
  37. package/dist/erc4337/providers/safe.d.ts.map +1 -0
  38. package/dist/erc4337/providers/safe.js +117 -0
  39. package/dist/erc4337/providers/safe.js.map +1 -0
  40. package/dist/erc4337/providers/types.d.ts +88 -0
  41. package/dist/erc4337/providers/types.d.ts.map +1 -0
  42. package/dist/erc4337/providers/types.js +35 -0
  43. package/dist/erc4337/providers/types.js.map +1 -0
  44. package/dist/erc4337/smart-wallet.d.ts +16 -0
  45. package/dist/erc4337/smart-wallet.d.ts.map +1 -0
  46. package/dist/erc4337/smart-wallet.js +86 -0
  47. package/dist/erc4337/smart-wallet.js.map +1 -0
  48. package/dist/index.d.ts +16 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +15 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/modules/payment-intents.d.ts +99 -0
  53. package/dist/modules/payment-intents.d.ts.map +1 -0
  54. package/dist/modules/payment-intents.js +118 -0
  55. package/dist/modules/payment-intents.js.map +1 -0
  56. package/dist/modules/policies.d.ts +87 -0
  57. package/dist/modules/policies.d.ts.map +1 -0
  58. package/dist/modules/policies.js +61 -0
  59. package/dist/modules/policies.js.map +1 -0
  60. package/dist/paymaster/client.d.ts +11 -0
  61. package/dist/paymaster/client.d.ts.map +1 -0
  62. package/dist/paymaster/client.js +119 -0
  63. package/dist/paymaster/client.js.map +1 -0
  64. package/dist/runtimee.d.ts +4 -0
  65. package/dist/runtimee.d.ts.map +1 -0
  66. package/dist/runtimee.js +4 -0
  67. package/dist/runtimee.js.map +1 -0
  68. package/dist/types.d.ts +389 -0
  69. package/dist/types.d.ts.map +1 -0
  70. package/dist/types.js +2 -0
  71. package/dist/types.js.map +1 -0
  72. package/package.json +29 -0
  73. package/src/actor-client.ts +57 -0
  74. package/src/bundler/client.ts +306 -0
  75. package/src/bundler/providers/alchemy.ts +21 -0
  76. package/src/bundler/providers/pimlico.ts +17 -0
  77. package/src/client.ts +472 -0
  78. package/src/conversion.ts +25 -0
  79. package/src/erc4337/builder.ts +181 -0
  80. package/src/erc4337/providers/kernel.ts +136 -0
  81. package/src/erc4337/providers/safe.ts +133 -0
  82. package/src/erc4337/providers/types.ts +73 -0
  83. package/src/erc4337/smart-wallet.ts +115 -0
  84. package/src/index.ts +60 -0
  85. package/src/modules/payment-intents.ts +156 -0
  86. package/src/modules/policies.ts +79 -0
  87. package/src/paymaster/client.ts +155 -0
  88. package/src/runtimee.ts +3 -0
  89. package/src/types.ts +469 -0
  90. package/test/architecture/architecture-invariants.test.ts +61 -0
  91. package/test/client.test.ts +180 -0
  92. package/test/conversion.test.ts +42 -0
  93. package/test/runtimee.test.ts +30 -0
  94. package/tsconfig.json +9 -0
  95. 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
+ }