accounts 0.18.4 → 0.18.5

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.
@@ -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
+ }
@@ -11,6 +11,9 @@ import {
11
11
  } from 'viem'
12
12
  import {
13
13
  getBalance,
14
+ getBlockNumber,
15
+ getLogs,
16
+ getTransactionReceipt,
14
17
  sendCalls,
15
18
  sendTransactionSync,
16
19
  signMessage,
@@ -2192,6 +2195,66 @@ describe.each(adapters)('$name', ({ adapter, name }: (typeof adapters)[number])
2192
2195
  })
2193
2196
 
2194
2197
  describe('wallet_revokeAccessKey', () => {
2198
+ test('error: rejects an authorization for a different access key', async () => {
2199
+ const provider = Provider.create({ adapter: adapter(), chains: [chain] })
2200
+ await connect(provider)
2201
+
2202
+ const connected = (await provider.request({ method: 'eth_accounts' }))[0]!
2203
+ const first = await provider.request({
2204
+ method: 'wallet_authorizeAccessKey',
2205
+ params: [{ expiry: Expiry.days(1) }],
2206
+ })
2207
+ const second = await provider.request({
2208
+ method: 'wallet_authorizeAccessKey',
2209
+ params: [{ expiry: Expiry.days(1) }],
2210
+ })
2211
+
2212
+ await expect(
2213
+ provider.request({
2214
+ method: 'wallet_revokeAccessKey',
2215
+ params: [
2216
+ {
2217
+ address: connected,
2218
+ accessKeyAddress: first.keyAuthorization.address!,
2219
+ keyAuthorization: second.keyAuthorization,
2220
+ },
2221
+ ],
2222
+ }),
2223
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
2224
+ `[RpcResponse.InvalidParamsError: \`keyAuthorization\` must authorize \`accessKeyAddress\`.]`,
2225
+ )
2226
+ })
2227
+
2228
+ test('behavior: atomically authorizes and revokes an unpublished access key', async () => {
2229
+ const provider = Provider.create({ adapter: adapter(), chains: [chain] })
2230
+ await connect(provider)
2231
+
2232
+ const connected = (await provider.request({ method: 'eth_accounts' }))[0]!
2233
+ await fund(connected)
2234
+
2235
+ const { keyAuthorization } = await provider.request({
2236
+ method: 'wallet_authorizeAccessKey',
2237
+ params: [{ expiry: Expiry.days(1) }],
2238
+ })
2239
+
2240
+ await provider.request({
2241
+ method: 'wallet_revokeAccessKey',
2242
+ params: [
2243
+ {
2244
+ address: connected,
2245
+ accessKeyAddress: keyAuthorization.address!,
2246
+ keyAuthorization,
2247
+ },
2248
+ ],
2249
+ })
2250
+
2251
+ const metadata = await Actions.accessKey.getMetadata(getClient(), {
2252
+ account: connected,
2253
+ accessKey: keyAuthorization.address!,
2254
+ })
2255
+ expect(metadata.isRevoked).toMatchInlineSnapshot(`true`)
2256
+ })
2257
+
2195
2258
  test('default: revokes a granted access key on-chain', async () => {
2196
2259
  const provider = Provider.create({ adapter: adapter(), chains: [chain] })
2197
2260
  await connect(provider)
@@ -3232,6 +3295,57 @@ describe.each(adapters)('$name', ({ adapter, name }: (typeof adapters)[number])
3232
3295
  expect((result.tx as { feePayerSignature?: unknown }).feePayerSignature).toBeDefined()
3233
3296
  })
3234
3297
 
3298
+ test('behavior: feePayer URL sponsors wallet_revokeAccessKey', async () => {
3299
+ const provider = Provider.create({ adapter: adapter(), chains: [chain] })
3300
+ const connected = await connect(provider)
3301
+ await fund(connected)
3302
+ const { keyAuthorization } = await provider.request({
3303
+ method: 'wallet_authorizeAccessKey',
3304
+ params: [{ expiry: Expiry.days(1) }],
3305
+ })
3306
+ await provider.request({
3307
+ method: 'eth_sendTransactionSync',
3308
+ params: [{ calls: [transferCall] }],
3309
+ })
3310
+
3311
+ const rpc = getClient()
3312
+ const blockNumber = await getBlockNumber(rpc)
3313
+
3314
+ await provider.request({
3315
+ method: 'wallet_revokeAccessKey',
3316
+ params: [
3317
+ {
3318
+ accessKeyAddress: keyAuthorization.address!,
3319
+ address: connected,
3320
+ feePayer: server.url,
3321
+ },
3322
+ ],
3323
+ })
3324
+
3325
+ const logs = await getLogs(rpc, {
3326
+ address: Addresses.accountKeychain,
3327
+ fromBlock: blockNumber,
3328
+ toBlock: 'latest',
3329
+ })
3330
+ const event = Actions.accessKey.revoke.extractEvent(logs)
3331
+ const receipt = await getTransactionReceipt(rpc, { hash: event.transactionHash })
3332
+ const metadata = await Actions.accessKey.getMetadata(rpc, {
3333
+ account: connected,
3334
+ accessKey: keyAuthorization.address!,
3335
+ })
3336
+ expect({
3337
+ feePayer: receipt.feePayer,
3338
+ isRevoked: metadata.isRevoked,
3339
+ status: receipt.status,
3340
+ }).toMatchInlineSnapshot(`
3341
+ {
3342
+ "feePayer": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
3343
+ "isRevoked": true,
3344
+ "status": "success",
3345
+ }
3346
+ `)
3347
+ })
3348
+
3235
3349
  test('behavior: feePayer: true uses default from Provider.create', async () => {
3236
3350
  const provider = Provider.create({
3237
3351
  adapter: adapter({ feePayer: server.url }),
@@ -558,28 +558,29 @@ export function create(options: create.Options = {}): create.ReturnType {
558
558
  })
559
559
  }
560
560
 
561
- async function revokeAccessKey(parameters: {
562
- address: Address.Address
563
- accessKeyAddress: Address.Address
564
- }) {
561
+ async function revokeAccessKey(parameters: Adapter.revokeAccessKey.Parameters) {
565
562
  const selected = await getAdapterAccount({ address: parameters.address })
563
+ const feePayer = parameters.feePayer
566
564
  const client = getWalletClient({
567
565
  account: selected.account,
566
+ feePayer: feePayer === true ? undefined : feePayer,
568
567
  transport: selected.transport,
569
568
  })
570
569
  if (selected.account.type === 'json-rpc') {
571
570
  await client.request({
572
571
  method: 'wallet_revokeAccessKey' as never,
573
- params: [parameters] as never,
572
+ params: [z.encode(Rpc.wallet_revokeAccessKey.parameters, parameters)] as never,
574
573
  })
575
574
  } else {
576
575
  try {
577
- await Actions.accessKey.revokeSync(getClient(), {
576
+ await Actions.accessKey.revokeSync(client, {
578
577
  account: selected.account as TempoAccount.Account,
579
578
  accessKey: parameters.accessKeyAddress,
579
+ ...(feePayer ? { feePayer: true as never } : {}),
580
+ ...(parameters.keyAuthorization ? { keyAuthorization: parameters.keyAuthorization } : {}),
580
581
  })
581
582
  } catch (error) {
582
- if (!AccessKey.isUnavailableError(error)) throw error
583
+ if (typeof feePayer === 'string' || !AccessKey.isUnavailableError(error)) throw error
583
584
  }
584
585
  }
585
586
  store.accessKeys.remove({
@@ -677,6 +678,13 @@ export function create(options: create.Options = {}): create.ReturnType {
677
678
  parameters: Adapter.revokeAccessKey.Parameters,
678
679
  request: Pick<Rpc.wallet_revokeAccessKey.Encoded, 'method' | 'params'>,
679
680
  ) {
681
+ if (
682
+ parameters.keyAuthorization &&
683
+ !AddressUtil.isEqual(parameters.keyAuthorization.address, parameters.accessKeyAddress)
684
+ )
685
+ throw new RpcResponse.InvalidParamsError({
686
+ message: '`keyAuthorization` must authorize `accessKeyAddress`.',
687
+ })
680
688
  if (actions.revokeAccessKey) return await actions.revokeAccessKey(parameters, request)
681
689
  if (instance.getAccount) return await revokeAccessKey(parameters)
682
690
  unsupported('revokeAccessKey')
@@ -713,6 +721,12 @@ export function create(options: create.Options = {}): create.ReturnType {
713
721
  return url
714
722
  }
715
723
 
724
+ function resolveRevocationFeePayer(
725
+ feePayer: string | boolean | undefined,
726
+ ): Adapter.revokeAccessKey.Parameters['feePayer'] {
727
+ return resolveFeePayer(feePayer) ?? (feePayer === true ? true : undefined)
728
+ }
729
+
716
730
  function stripAuthorizeAccessKey(
717
731
  parameters: create.AuthorizeAccessKeyParameters,
718
732
  ): Adapter.authorizeAccessKey.Parameters {
@@ -1608,7 +1622,10 @@ export function create(options: create.Options = {}): create.ReturnType {
1608
1622
  case 'wallet_revokeAccessKey': {
1609
1623
  assertConnected()
1610
1624
  const [decoded] = request._decoded.params
1611
- await revokeAccessKeyAction({ ...decoded }, request)
1625
+ await revokeAccessKeyAction(
1626
+ { ...decoded, feePayer: resolveRevocationFeePayer(decoded.feePayer) },
1627
+ request,
1628
+ )
1612
1629
  return
1613
1630
  }
1614
1631
 
@@ -1,4 +1,5 @@
1
1
  import type { RpcSchema } from 'ox'
2
+ import type { KeyAuthorization } from 'ox/tempo'
2
3
  import type { Hex } from 'viem'
3
4
  import { describe, expectTypeOf, test } from 'vp/test'
4
5
 
@@ -309,6 +310,19 @@ describe('Request', () => {
309
310
  expectTypeOf<SwitchChain['params']>().toEqualTypeOf<readonly [{ chainId: number }]>()
310
311
  })
311
312
 
313
+ test('wallet_revokeAccessKey supports app-provided fee sponsorship', () => {
314
+ expectTypeOf<Rpc.wallet_revokeAccessKey.Decoded['params']>().toEqualTypeOf<
315
+ readonly [
316
+ {
317
+ accessKeyAddress: Hex
318
+ address: Hex
319
+ feePayer?: boolean | string | undefined
320
+ keyAuthorization?: KeyAuthorization.Signed | undefined
321
+ },
322
+ ]
323
+ >()
324
+ })
325
+
312
326
  test('eth_accounts has no params', () => {
313
327
  type EthAccounts = Extract<Schema.Request, { method: 'eth_accounts' }>
314
328
  expectTypeOf<EthAccounts>().not.toHaveProperty('params')