accounts 0.18.4 → 0.19.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 (71) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/core/AccessKey.d.ts +5 -1
  3. package/dist/core/AccessKey.d.ts.map +1 -1
  4. package/dist/core/AccessKey.js +2 -0
  5. package/dist/core/AccessKey.js.map +1 -1
  6. package/dist/core/Adapter.d.ts +4 -0
  7. package/dist/core/Adapter.d.ts.map +1 -1
  8. package/dist/core/Client.d.ts.map +1 -1
  9. package/dist/core/Client.js +62 -2
  10. package/dist/core/Client.js.map +1 -1
  11. package/dist/core/ExecutionError.d.ts.map +1 -1
  12. package/dist/core/ExecutionError.js +38 -3
  13. package/dist/core/ExecutionError.js.map +1 -1
  14. package/dist/core/Provider.d.ts.map +1 -1
  15. package/dist/core/Provider.js +16 -4
  16. package/dist/core/Provider.js.map +1 -1
  17. package/dist/core/Schema.d.ts +43 -0
  18. package/dist/core/Schema.d.ts.map +1 -1
  19. package/dist/core/adapters/privy.d.ts.map +1 -1
  20. package/dist/core/adapters/privy.js +7 -2
  21. package/dist/core/adapters/privy.js.map +1 -1
  22. package/dist/core/zod/rpc.d.ts +18 -0
  23. package/dist/core/zod/rpc.d.ts.map +1 -1
  24. package/dist/core/zod/rpc.js +13 -2
  25. package/dist/core/zod/rpc.js.map +1 -1
  26. package/dist/react/Remote.d.ts.map +1 -1
  27. package/dist/react/Remote.js +5 -1
  28. package/dist/react/Remote.js.map +1 -1
  29. package/dist/server/CliAuth.d.ts.map +1 -1
  30. package/dist/server/CliAuth.js +6 -4
  31. package/dist/server/CliAuth.js.map +1 -1
  32. package/dist/server/internal/handlers/relay.d.ts +0 -7
  33. package/dist/server/internal/handlers/relay.d.ts.map +1 -1
  34. package/dist/server/internal/handlers/relay.js +0 -46
  35. package/dist/server/internal/handlers/relay.js.map +1 -1
  36. package/dist/server/internal/handlers/sponsorship.d.ts +0 -2
  37. package/dist/server/internal/handlers/sponsorship.d.ts.map +1 -1
  38. package/dist/server/internal/handlers/sponsorship.js +1 -2
  39. package/dist/server/internal/handlers/sponsorship.js.map +1 -1
  40. package/dist/trusted-hosts.json +1 -0
  41. package/package.json +5 -5
  42. package/src/cli/Provider.localnet.test.ts +3 -3
  43. package/src/core/AccessKey.permissions.test.ts +73 -0
  44. package/src/core/AccessKey.test.ts +29 -27
  45. package/src/core/AccessKey.ts +7 -1
  46. package/src/core/Adapter.ts +4 -0
  47. package/src/core/Client.test.ts +153 -1
  48. package/src/core/Client.ts +73 -2
  49. package/src/core/ExecutionError.ts +39 -3
  50. package/src/core/Provider.localnet.test.ts +116 -1
  51. package/src/core/Provider.ts +25 -8
  52. package/src/core/Schema.test-d.ts +14 -0
  53. package/src/core/adapters/deviceCode/deviceCode.localnet.test.ts +4 -1
  54. package/src/core/adapters/privy.test.ts +150 -8
  55. package/src/core/adapters/privy.ts +13 -5
  56. package/src/core/zod/request.test.ts +26 -0
  57. package/src/core/zod/rpc.ts +15 -4
  58. package/src/react/Remote.test.ts +153 -0
  59. package/src/react/Remote.ts +7 -1
  60. package/src/server/CliAuth.test.ts +5 -3
  61. package/src/server/CliAuth.ts +6 -4
  62. package/src/server/internal/handlers/relay.ts +0 -56
  63. package/src/server/internal/handlers/sponsorship.ts +1 -4
  64. package/src/trusted-hosts.json +1 -0
  65. package/dist/server/internal/handlers/multisig.d.ts +0 -214
  66. package/dist/server/internal/handlers/multisig.d.ts.map +0 -1
  67. package/dist/server/internal/handlers/multisig.js +0 -505
  68. package/dist/server/internal/handlers/multisig.js.map +0 -1
  69. package/src/server/internal/handlers/multisig.localnet.test.ts +0 -94
  70. package/src/server/internal/handlers/multisig.test.ts +0 -743
  71. package/src/server/internal/handlers/multisig.ts +0 -774
@@ -0,0 +1,73 @@
1
+ import { Json } from 'ox'
2
+ import { KeyAuthorization } from 'ox/tempo'
3
+ import { describe, expect, test } from 'vp/test'
4
+
5
+ import { accounts, privateKeys } from '../../test/config.js'
6
+ import * as Storage from './Storage.js'
7
+ import * as Store from './Store.js'
8
+
9
+ const account = accounts[0]!.address
10
+ const address = accounts[1]!.address
11
+
12
+ describe('stored permission semantics', () => {
13
+ test.each([undefined, [], [{ token: address, limit: 100n }]])(
14
+ 'preserves limits through authorization updates and persistence: %#',
15
+ async (limits) => {
16
+ const storage = Storage.memory()
17
+ const store = Store.create({ chainId: 1, storage })
18
+ await Store.waitForHydration(store)
19
+ const scopes = limits === undefined ? undefined : limits.length === 0 ? [] : [{ address }]
20
+ const authorization = KeyAuthorization.from(
21
+ { address, chainId: 1n, type: 'secp256k1', limits, scopes },
22
+ { signature: `0x${'00'.repeat(65)}` },
23
+ )
24
+ const key = store.accessKeys.add({ account, authorization, privateKey: privateKeys[1] })
25
+ expect(key.permissionSemantics).toMatchInlineSnapshot(`1`)
26
+ expect(key.limits).toEqual(limits)
27
+ expect(key.scopes).toEqual(scopes)
28
+
29
+ // Replace legacy metadata with a complete authorization, then clear the pending signature.
30
+ store.setState({
31
+ accessKeys: [{ ...key, permissionSemantics: undefined, limits: [], scopes: [] }],
32
+ })
33
+ store.accessKeys.updateAuthorization({
34
+ account,
35
+ accessKey: address,
36
+ chainId: 1,
37
+ authorization,
38
+ })
39
+ store.setState((state) => ({
40
+ accessKeys: state.accessKeys.map((key) => ({ ...key, keyAuthorization: undefined })),
41
+ }))
42
+ const restored = Store.create({ chainId: 1, storage })
43
+ await Store.waitForHydration(restored)
44
+ expect(Json.stringify(restored.getState().accessKeys)).toBe(
45
+ Json.stringify([{ ...key, keyAuthorization: undefined }]),
46
+ )
47
+ },
48
+ )
49
+
50
+ test('does not mark legacy records during hydration', async () => {
51
+ const storage = Storage.memory()
52
+ const store = Store.create({ chainId: 1, storage })
53
+ await Store.waitForHydration(store)
54
+ store.setState({
55
+ accessKeys: [
56
+ {
57
+ address,
58
+ access: account,
59
+ chainId: 1,
60
+ keyType: 'secp256k1',
61
+ limits: [],
62
+ scopes: [],
63
+ privateKey: privateKeys[1],
64
+ },
65
+ ],
66
+ })
67
+ const restored = Store.create({ chainId: 1, storage })
68
+ await Store.waitForHydration(restored)
69
+ expect(restored.getState().accessKeys[0]?.permissionSemantics).toMatchInlineSnapshot(
70
+ `undefined`,
71
+ )
72
+ })
73
+ })
@@ -23,7 +23,7 @@ function createKeyAuthorization(
23
23
  options: {
24
24
  chainId?: bigint | undefined
25
25
  expiry?: number | undefined
26
- keyType?: KeyAuthorization.KeyAuthorization['type'] | undefined
26
+ keyType?: AccessKey.prepareAuthorization.Options['keyType'] | undefined
27
27
  limits?: { token: `0x${string}`; limit: bigint; period?: number | undefined }[] | undefined
28
28
  scopes?: KeyAuthorization.Scope[] | undefined
29
29
  } = {},
@@ -506,18 +506,19 @@ describe('authorize', () => {
506
506
  `)
507
507
  expect(store.getState().accessKeys.map(({ keyAuthorization: _, ...accessKey }) => accessKey))
508
508
  .toMatchInlineSnapshot(`
509
- [
510
- {
511
- "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
512
- "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650",
513
- "chainId": 1,
514
- "expiry": 123,
515
- "keyType": "secp256k1",
516
- "limits": undefined,
517
- "scopes": undefined,
518
- },
519
- ]
520
- `)
509
+ [
510
+ {
511
+ "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
512
+ "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650",
513
+ "chainId": 1,
514
+ "expiry": 123,
515
+ "keyType": "secp256k1",
516
+ "limits": undefined,
517
+ "permissionSemantics": 1,
518
+ "scopes": undefined,
519
+ },
520
+ ]
521
+ `)
521
522
  })
522
523
 
523
524
  test('behavior: provisions a handle-backed record via the keystore', async () => {
@@ -590,19 +591,20 @@ describe('authorize', () => {
590
591
 
591
592
  expect(store.getState().accessKeys.map(({ keyAuthorization: _, ...accessKey }) => accessKey))
592
593
  .toMatchInlineSnapshot(`
593
- [
594
- {
595
- "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
596
- "address": "${accessKey.address}",
597
- "chainId": 1,
598
- "expiry": 123,
599
- "keyType": "secp256k1",
600
- "limits": undefined,
601
- "privateKey": "${privateKeys[1]}",
602
- "scopes": undefined,
603
- },
604
- ]
605
- `)
594
+ [
595
+ {
596
+ "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
597
+ "address": "${accessKey.address}",
598
+ "chainId": 1,
599
+ "expiry": 123,
600
+ "keyType": "secp256k1",
601
+ "limits": undefined,
602
+ "permissionSemantics": 1,
603
+ "privateKey": "${privateKeys[1]}",
604
+ "scopes": undefined,
605
+ },
606
+ ]
607
+ `)
606
608
  })
607
609
  })
608
610
 
@@ -1078,7 +1080,7 @@ describe('getStatus', () => {
1078
1080
 
1079
1081
  Docs: https://viem.sh/docs/contract/readContract
1080
1082
  Details: RPC unavailable.
1081
- Version: viem@2.56.0]
1083
+ Version: viem@2.56.9]
1082
1084
  `)
1083
1085
  })
1084
1086
 
@@ -44,6 +44,8 @@ export type AccessKey = {
44
44
  keyType: 'secp256k1' | 'p256' | 'webAuthn' | 'webCrypto'
45
45
  /** TIP-20 spending limits for the access key. */
46
46
  limits?: { token: Address.Address; limit: bigint; period?: number | undefined }[] | undefined
47
+ /** Version of stored permission semantics: omitted fields are unrestricted, empty arrays deny all. Absent on legacy records. */
48
+ permissionSemantics?: 1 | undefined
47
49
  /** Call scopes restricting which contracts/selectors this key can call. */
48
50
  scopes?:
49
51
  | {
@@ -345,7 +347,9 @@ export declare namespace prepareAuthorization {
345
347
  /** Keystore-created key material reference. */
346
348
  key?: { handle: Keystore.Handle; publicKey: Hex.Hex } | undefined
347
349
  /** Unsigned key authorization to sign with the root account. */
348
- keyAuthorization: KeyAuthorization.KeyAuthorization<false>
350
+ keyAuthorization: KeyAuthorization.KeyAuthorization<false> & {
351
+ type: NonNullable<Options['keyType']>
352
+ }
349
353
  /** Exported private key backing an external access key. */
350
354
  privateKey?: Hex.Hex | undefined
351
355
  }
@@ -558,6 +562,7 @@ export function add(options: add.Options): add.ReturnType {
558
562
  expiry: authorization.expiry ?? undefined,
559
563
  keyAuthorization: authorization,
560
564
  keyType: authorization.type,
565
+ permissionSemantics: 1 as const,
561
566
  limits: authorization.limits as AccessKey['limits'],
562
567
  scopes: authorization.scopes as AccessKey['scopes'],
563
568
  }
@@ -624,6 +629,7 @@ function updateAuthorization(options: updateAuthorization.Options): void {
624
629
  patch: {
625
630
  expiry: authorization.expiry ?? undefined,
626
631
  keyAuthorization: authorization,
632
+ permissionSemantics: 1,
627
633
  limits: authorization.limits as AccessKey['limits'],
628
634
  scopes: authorization.scopes as AccessKey['scopes'],
629
635
  },
@@ -443,6 +443,10 @@ export declare namespace revokeAccessKey {
443
443
  address: Address
444
444
  /** Address of the access key to revoke. */
445
445
  accessKeyAddress: Address
446
+ /** App-provided fee sponsorship for the revocation transaction. */
447
+ feePayer?: boolean | string | undefined
448
+ /** Signed authorization for an access key that has not been published on-chain. */
449
+ keyAuthorization?: KeyAuthorization.Signed | undefined
446
450
  }
447
451
  }
448
452
 
@@ -1,6 +1,10 @@
1
+ import { Secp256k1 } from 'ox'
2
+ import { TxEnvelopeTempo } from 'ox/tempo'
1
3
  import { custom, http } from 'viem'
4
+ import { privateKeyToAccount } from 'viem/accounts'
5
+ import { Transaction } from 'viem/tempo'
2
6
  import { tempo, tempoModerato } from 'viem/tempo/chains'
3
- import { describe, expect, test } from 'vp/test'
7
+ import { afterEach, describe, expect, test, vi } from 'vp/test'
4
8
 
5
9
  import { secp256k1 } from '../../test/adapters.js'
6
10
  import * as Client from './Client.js'
@@ -252,6 +256,37 @@ describe('providerTransport', () => {
252
256
  })
253
257
 
254
258
  describe('feePayerTransport', () => {
259
+ afterEach(() => vi.unstubAllGlobals())
260
+
261
+ const sender = privateKeyToAccount(
262
+ '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
263
+ )
264
+ const feePayerPrivateKey = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
265
+
266
+ async function sponsoredTransaction(data: `0x${string}`) {
267
+ const requested = await sender.signTransaction(
268
+ {
269
+ calls: [{ data, to: '0x20c0000000000000000000000000000000000000' }],
270
+ chainId: tempo.id,
271
+ feePayer: true,
272
+ gas: 100_000n,
273
+ maxFeePerGas: 2n,
274
+ maxPriorityFeePerGas: 1n,
275
+ nonce: 1,
276
+ } as never,
277
+ { serializer: Transaction.serialize },
278
+ )
279
+ const envelope = TxEnvelopeTempo.deserialize(requested as TxEnvelopeTempo.Serialized)
280
+ const feePayerSignature = Secp256k1.sign({
281
+ payload: TxEnvelopeTempo.getFeePayerSignPayload(envelope, { sender: envelope.from! }),
282
+ privateKey: feePayerPrivateKey,
283
+ })
284
+ return {
285
+ requested,
286
+ signed: TxEnvelopeTempo.serialize(envelope, { feePayerSignature }),
287
+ }
288
+ }
289
+
255
290
  test('behavior: passes through unrelated methods to the base transport', async () => {
256
291
  const store = setup()
257
292
  const baseRequests: { method: string; params?: unknown }[] = []
@@ -337,4 +372,121 @@ describe('feePayerTransport', () => {
337
372
  ]
338
373
  `)
339
374
  })
375
+
376
+ test('behavior: accepts a fee payer signature for the requested transaction', async () => {
377
+ const store = setup()
378
+ const { requested, signed } = await sponsoredTransaction('0x1234')
379
+ vi.stubGlobal('fetch', async (_input: string | URL, init?: RequestInit) => {
380
+ const body = JSON.parse(String(init?.body)) as { id: number }
381
+ return Response.json({ id: body.id, jsonrpc: '2.0', result: signed })
382
+ })
383
+ const baseRequests: { method: string; params?: unknown }[] = []
384
+ const hash = `0x${'11'.repeat(32)}` as `0x${string}`
385
+ const client = Client.fromChainId(tempo.id, {
386
+ chains: [tempo],
387
+ feePayer: 'https://relay.example.com',
388
+ store,
389
+ transports: {
390
+ [tempo.id]: custom({
391
+ async request({ method, params }) {
392
+ baseRequests.push({ method, params })
393
+ return hash
394
+ },
395
+ }),
396
+ },
397
+ })
398
+
399
+ const result = await client.request({
400
+ method: 'eth_sendRawTransaction',
401
+ params: [requested],
402
+ })
403
+
404
+ expect(result).toMatchInlineSnapshot(
405
+ `"0x1111111111111111111111111111111111111111111111111111111111111111"`,
406
+ )
407
+ expect(baseRequests.map((request) => request.method)).toMatchInlineSnapshot(`
408
+ [
409
+ "eth_sendRawTransaction",
410
+ ]
411
+ `)
412
+ const sent = baseRequests[0]?.params as readonly unknown[] | undefined
413
+ expect(sent?.[0]).toBe(signed)
414
+ })
415
+
416
+ test('error: rejects a fee payer signature for a different transaction', async () => {
417
+ const store = setup()
418
+ const { requested } = await sponsoredTransaction('0x1234')
419
+ const { signed } = await sponsoredTransaction('0x5678')
420
+ vi.stubGlobal('fetch', async (_input: string | URL, init?: RequestInit) => {
421
+ const body = JSON.parse(String(init?.body)) as { id: number }
422
+ return Response.json({ id: body.id, jsonrpc: '2.0', result: signed })
423
+ })
424
+ const baseRequests: unknown[] = []
425
+ const client = Client.fromChainId(tempo.id, {
426
+ chains: [tempo],
427
+ feePayer: 'https://relay.example.com',
428
+ store,
429
+ transports: {
430
+ [tempo.id]: custom({
431
+ async request(request) {
432
+ baseRequests.push(request)
433
+ return null
434
+ },
435
+ }),
436
+ },
437
+ })
438
+
439
+ await expect(
440
+ client.request({ method: 'eth_sendRawTransaction', params: [requested] }),
441
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
442
+ `[Error: Fee payer changed the requested transaction intent.]`,
443
+ )
444
+ expect(baseRequests).toMatchInlineSnapshot(`[]`)
445
+ })
446
+
447
+ test('error: rejects a filled transaction that changes the requested call', async () => {
448
+ const store = setup()
449
+ const request = {
450
+ calls: [{ data: '0x1234', to: '0x20c0000000000000000000000000000000000000', value: '0x' }],
451
+ chainId: '0x1079',
452
+ feePayer: true,
453
+ from: sender.address,
454
+ type: '0x76',
455
+ } as const
456
+ vi.stubGlobal('fetch', async (_input: string | URL, init?: RequestInit) => {
457
+ const body = JSON.parse(String(init?.body)) as { id: number }
458
+ return Response.json({
459
+ id: body.id,
460
+ jsonrpc: '2.0',
461
+ result: {
462
+ raw: '0x',
463
+ tx: {
464
+ ...request,
465
+ calls: [{ ...request.calls[0], data: '0x5678' }],
466
+ },
467
+ },
468
+ })
469
+ })
470
+ const baseRequests: unknown[] = []
471
+ const client = Client.fromChainId(tempo.id, {
472
+ chains: [tempo],
473
+ feePayer: 'https://relay.example.com',
474
+ store,
475
+ transports: {
476
+ [tempo.id]: custom({
477
+ async request(request) {
478
+ baseRequests.push(request)
479
+ return null
480
+ },
481
+ }),
482
+ },
483
+ })
484
+
485
+ await expect(
486
+ client.request({ method: 'eth_fillTransaction' as never, params: [request] as never }),
487
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
488
+ `[Error: Fee payer changed the requested transaction intent.]`,
489
+ )
490
+ expect(baseRequests).toMatchInlineSnapshot(`[]`)
491
+ })
340
492
  })
@@ -1,4 +1,5 @@
1
1
  import { type Provider as ox_Provider } from 'ox'
2
+ import { TxEnvelopeTempo } from 'ox/tempo'
2
3
  import {
3
4
  type Chain,
4
5
  createClient,
@@ -163,11 +164,14 @@ function feePayerTransport(
163
164
  typeof request === 'object' &&
164
165
  'feePayer' in request &&
165
166
  (request.feePayer === true || typeof request.feePayer === 'string')
166
- )
167
- return sponsor.request({
167
+ ) {
168
+ const response = await sponsor.request({
168
169
  method,
169
170
  params: [{ ...request, feePayer: true }],
170
171
  })
172
+ assertFilledTransactionIntent(request, response)
173
+ return response
174
+ }
171
175
  }
172
176
 
173
177
  if (method === 'eth_sendRawTransaction' || method === 'eth_sendRawTransactionSync') {
@@ -182,6 +186,7 @@ function feePayerTransport(
182
186
  method: 'eth_signRawTransaction',
183
187
  params: [serialized],
184
188
  })
189
+ assertSignedTransactionIntent(serialized, signed)
185
190
  return await baseTransport.request({ method, params: [signed] })
186
191
  }
187
192
  }
@@ -192,3 +197,69 @@ function feePayerTransport(
192
197
  } as ReturnType<Transport>
193
198
  }
194
199
  }
200
+
201
+ function assertFilledTransactionIntent(request: Record<string, unknown>, response: unknown) {
202
+ if (!isObject(response) || !isObject(response.tx))
203
+ throw new Error('Fee payer returned an invalid filled transaction.')
204
+
205
+ const transaction = response.tx
206
+ if (
207
+ (request.from !== undefined && !sameAddress(request.from, transaction.from)) ||
208
+ (request.chainId !== undefined && !sameQuantity(request.chainId, transaction.chainId)) ||
209
+ (request.calls !== undefined && !sameCalls(request.calls, transaction.calls))
210
+ )
211
+ throw new Error('Fee payer changed the requested transaction intent.')
212
+ }
213
+
214
+ function assertSignedTransactionIntent(serialized: string, signed: unknown) {
215
+ if (typeof signed !== 'string' || (!signed.startsWith('0x76') && !signed.startsWith('0x78')))
216
+ throw new Error('Fee payer returned an invalid signed transaction.')
217
+
218
+ const requested = TxEnvelopeTempo.deserialize(serialized as TxEnvelopeTempo.Serialized)
219
+ const transaction = TxEnvelopeTempo.deserialize(signed as TxEnvelopeTempo.Serialized)
220
+ if (
221
+ !transaction.feePayerSignature ||
222
+ !sameAddress(requested.from, transaction.from) ||
223
+ TxEnvelopeTempo.getSignPayload(requested) !== TxEnvelopeTempo.getSignPayload(transaction)
224
+ )
225
+ throw new Error('Fee payer changed the requested transaction intent.')
226
+ }
227
+
228
+ function sameCalls(a: unknown, b: unknown) {
229
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
230
+ return a.every((call, index) => {
231
+ const other = b[index]
232
+ return (
233
+ isObject(call) &&
234
+ isObject(other) &&
235
+ sameAddress(call.to, other.to) &&
236
+ sameQuantity(call.value, other.value) &&
237
+ sameHex(call.data, other.data)
238
+ )
239
+ })
240
+ }
241
+
242
+ function sameAddress(a: unknown, b: unknown) {
243
+ if (a === undefined && b === undefined) return true
244
+ return typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase()
245
+ }
246
+
247
+ function sameHex(a: unknown, b: unknown) {
248
+ const normalize = (value: unknown) =>
249
+ typeof value === 'string' ? value.toLowerCase() : value === undefined ? '0x' : undefined
250
+ return normalize(a) === normalize(b)
251
+ }
252
+
253
+ function sameQuantity(a: unknown, b: unknown) {
254
+ const normalize = (value: unknown) => {
255
+ if (value === undefined || value === null || value === '0x') return 0n
256
+ if (typeof value === 'bigint') return value
257
+ if (typeof value === 'number' || typeof value === 'string') return BigInt(value)
258
+ return undefined
259
+ }
260
+ return normalize(a) === normalize(b)
261
+ }
262
+
263
+ function isObject(value: unknown): value is Record<string, unknown> {
264
+ return typeof value === 'object' && value !== null
265
+ }