accounts 0.12.2 → 0.13.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 (99) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +6 -5
  3. package/dist/cli/adapter.d.ts.map +1 -1
  4. package/dist/cli/adapter.js +53 -67
  5. package/dist/cli/adapter.js.map +1 -1
  6. package/dist/core/AccessKey.d.ts +89 -106
  7. package/dist/core/AccessKey.d.ts.map +1 -1
  8. package/dist/core/AccessKey.js +226 -210
  9. package/dist/core/AccessKey.js.map +1 -1
  10. package/dist/core/Account.d.ts +0 -9
  11. package/dist/core/Account.d.ts.map +1 -1
  12. package/dist/core/Account.js +1 -13
  13. package/dist/core/Account.js.map +1 -1
  14. package/dist/core/Adapter.d.ts +10 -2
  15. package/dist/core/Adapter.d.ts.map +1 -1
  16. package/dist/core/Provider.d.ts +4 -5
  17. package/dist/core/Provider.d.ts.map +1 -1
  18. package/dist/core/Provider.js +66 -76
  19. package/dist/core/Provider.js.map +1 -1
  20. package/dist/core/Schema.d.ts +7 -7
  21. package/dist/core/adapters/dialog.d.ts.map +1 -1
  22. package/dist/core/adapters/dialog.js +114 -124
  23. package/dist/core/adapters/dialog.js.map +1 -1
  24. package/dist/core/adapters/local.d.ts.map +1 -1
  25. package/dist/core/adapters/local.js +92 -105
  26. package/dist/core/adapters/local.js.map +1 -1
  27. package/dist/core/adapters/privy.d.ts +164 -0
  28. package/dist/core/adapters/privy.d.ts.map +1 -0
  29. package/dist/core/adapters/privy.js +574 -0
  30. package/dist/core/adapters/privy.js.map +1 -0
  31. package/dist/core/adapters/turnkey.d.ts.map +1 -1
  32. package/dist/core/adapters/turnkey.js +136 -133
  33. package/dist/core/adapters/turnkey.js.map +1 -1
  34. package/dist/core/internal/AccessKeyTransaction.d.ts +59 -0
  35. package/dist/core/internal/AccessKeyTransaction.d.ts.map +1 -0
  36. package/dist/core/internal/AccessKeyTransaction.js +155 -0
  37. package/dist/core/internal/AccessKeyTransaction.js.map +1 -0
  38. package/dist/core/zod/rpc.d.ts +9 -4
  39. package/dist/core/zod/rpc.d.ts.map +1 -1
  40. package/dist/core/zod/rpc.js +9 -4
  41. package/dist/core/zod/rpc.js.map +1 -1
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/react-native/adapter.d.ts.map +1 -1
  47. package/dist/react-native/adapter.js +61 -72
  48. package/dist/react-native/adapter.js.map +1 -1
  49. package/dist/server/Kv.d.ts +19 -9
  50. package/dist/server/Kv.d.ts.map +1 -1
  51. package/dist/server/Kv.js +32 -9
  52. package/dist/server/Kv.js.map +1 -1
  53. package/dist/server/internal/handlers/auth.d.ts.map +1 -1
  54. package/dist/server/internal/handlers/auth.js +2 -0
  55. package/dist/server/internal/handlers/auth.js.map +1 -1
  56. package/dist/server/internal/handlers/relay.d.ts +18 -12
  57. package/dist/server/internal/handlers/relay.d.ts.map +1 -1
  58. package/dist/server/internal/handlers/relay.js +115 -2
  59. package/dist/server/internal/handlers/relay.js.map +1 -1
  60. package/dist/server/internal/handlers/webAuthn.d.ts +6 -1
  61. package/dist/server/internal/handlers/webAuthn.d.ts.map +1 -1
  62. package/dist/server/internal/handlers/webAuthn.js +16 -9
  63. package/dist/server/internal/handlers/webAuthn.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/cli/adapter.ts +69 -74
  66. package/src/core/AccessKey.test.ts +525 -559
  67. package/src/core/AccessKey.ts +323 -327
  68. package/src/core/Account.test.ts +5 -50
  69. package/src/core/Account.ts +1 -19
  70. package/src/core/Adapter.ts +6 -2
  71. package/src/core/{Provider.test.ts → Provider.localnet.test.ts} +97 -31
  72. package/src/core/Provider.ts +110 -110
  73. package/src/core/Schema.test-d.ts +18 -0
  74. package/src/core/adapters/dialog.test.ts +137 -13
  75. package/src/core/adapters/dialog.ts +109 -121
  76. package/src/core/adapters/local.ts +95 -119
  77. package/src/core/adapters/privy.test-d.ts +128 -0
  78. package/src/core/adapters/privy.test.ts +634 -0
  79. package/src/core/adapters/privy.ts +805 -0
  80. package/src/core/adapters/turnkey.test.ts +117 -13
  81. package/src/core/adapters/turnkey.ts +132 -149
  82. package/src/core/internal/AccessKeyTransaction.ts +243 -0
  83. package/src/core/{mppx.test.ts → mppx.localnet.test.ts} +32 -2
  84. package/src/core/zod/request.test.ts +36 -0
  85. package/src/core/zod/rpc.ts +17 -10
  86. package/src/index.ts +1 -1
  87. package/src/react-native/adapter.ts +77 -84
  88. package/src/server/Kv.test.ts +51 -0
  89. package/src/server/Kv.ts +43 -9
  90. package/src/server/internal/handlers/auth.test.ts +10 -5
  91. package/src/server/internal/handlers/auth.ts +3 -0
  92. package/src/server/internal/handlers/{relay.test.ts → relay.localnet.test.ts} +3 -0
  93. package/src/server/internal/handlers/relay.ts +138 -13
  94. package/src/server/internal/handlers/session.test.ts +1 -1
  95. package/src/server/internal/handlers/webAuthn.test.ts +20 -0
  96. package/src/server/internal/handlers/webAuthn.ts +24 -12
  97. /package/src/cli/{Provider.test.ts → Provider.localnet.test.ts} +0 -0
  98. /package/src/react-native/{Provider.test.ts → Provider.localnet.test.ts} +0 -0
  99. /package/src/server/internal/handlers/{exchange.test.ts → exchange.localnet.test.ts} +0 -0
@@ -1,11 +1,12 @@
1
1
  import { Hex, WebCryptoP256 } from 'ox'
2
2
  import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo'
3
- import { encodeErrorResult } from 'viem'
4
- import { Abis, Account as TempoAccount, Actions } from 'viem/tempo'
3
+ import { BaseError, encodeErrorResult, encodeFunctionResult } from 'viem'
4
+ import { Abis, Account as TempoAccount } from 'viem/tempo'
5
5
  import { describe, expect, test } from 'vp/test'
6
6
 
7
- import { accounts, privateKeys } from '../../test/config.js'
7
+ import { accounts } from '../../test/config.js'
8
8
  import * as AccessKey from './AccessKey.js'
9
+ import * as AccessKeyTransaction from './internal/AccessKeyTransaction.js'
9
10
  import * as Store from './Store.js'
10
11
 
11
12
  function createStore() {
@@ -17,6 +18,7 @@ const rootAddress = accounts[0]!.address
17
18
  function createKeyAuthorization(
18
19
  address: `0x${string}`,
19
20
  options: {
21
+ chainId?: bigint | undefined
20
22
  expiry?: number | undefined
21
23
  limits?: { token: `0x${string}`; limit: bigint }[] | undefined
22
24
  scopes?: KeyAuthorization.Scope[] | undefined
@@ -25,7 +27,7 @@ function createKeyAuthorization(
25
27
  return KeyAuthorization.from(
26
28
  {
27
29
  address,
28
- chainId: 1n,
30
+ chainId: options.chainId ?? 1n,
29
31
  expiry: options.expiry,
30
32
  limits: options.limits,
31
33
  scopes: options.scopes,
@@ -41,88 +43,129 @@ function createRevert(errorName: string) {
41
43
  })
42
44
  }
43
45
 
44
- describe('save', () => {
45
- test('default: saves access key to store', async () => {
46
- const store = createStore()
47
- const keyPair = await WebCryptoP256.createKeyPair()
48
- const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
49
- const keyAuthorization = createKeyAuthorization(accessKey.address)
50
-
51
- AccessKey.save({ address: rootAddress, keyAuthorization, store })
52
-
53
- const { accessKeys } = store.getState()
54
- expect(accessKeys.length).toMatchInlineSnapshot(`1`)
55
- expect(accessKeys[0]!.address).toBe(accessKey.address)
56
- expect(accessKeys[0]!.access).toBe(rootAddress)
57
- expect(accessKeys[0]!.chainId).toMatchInlineSnapshot(`1`)
58
- expect(accessKeys[0]!.keyType).toMatchInlineSnapshot(`"p256"`)
59
- expect(accessKeys[0]!.keyAuthorization).toBe(keyAuthorization)
60
- })
61
-
62
- test('behavior: saves without keyPair', async () => {
63
- const store = createStore()
64
- const keyPair = await WebCryptoP256.createKeyPair()
65
- const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
66
- const keyAuthorization = createKeyAuthorization(accessKey.address)
67
-
68
- AccessKey.save({ address: rootAddress, keyAuthorization, store })
69
-
70
- expect(store.getState().accessKeys[0]!.keyPair).toBeUndefined()
71
- })
72
-
73
- test('behavior: saves with keyPair', async () => {
74
- const store = createStore()
75
- const keyPair = await WebCryptoP256.createKeyPair()
76
- const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
77
- const keyAuthorization = createKeyAuthorization(accessKey.address)
46
+ function createMetadataClient(
47
+ accessKey: Hex.Hex,
48
+ options: { isRevoked?: boolean | undefined; keyId?: Hex.Hex | undefined } = {},
49
+ ) {
50
+ return {
51
+ call: async () => ({
52
+ data: encodeFunctionResult({
53
+ abi: Abis.accountKeychain,
54
+ functionName: 'getKey',
55
+ result: {
56
+ enforceLimits: false,
57
+ expiry: 0n,
58
+ isRevoked: options.isRevoked ?? false,
59
+ keyId: options.keyId ?? accessKey,
60
+ signatureType: 1,
61
+ },
62
+ } as never),
63
+ }),
64
+ }
65
+ }
78
66
 
79
- AccessKey.save({ address: rootAddress, keyAuthorization, keyPair, store })
67
+ function createMissingClient() {
68
+ return {
69
+ call: async () => {
70
+ throw createRevert('KeyNotFound')
71
+ },
72
+ }
73
+ }
80
74
 
81
- expect(store.getState().accessKeys[0]!.keyPair).toBe(keyPair)
82
- })
75
+ function createFillClient(
76
+ accessKey: Hex.Hex,
77
+ options: { isRevoked?: boolean | undefined; keyId?: Hex.Hex | undefined } = {},
78
+ ) {
79
+ const requests: unknown[] = []
80
+ return {
81
+ client: {
82
+ ...createMetadataClient(accessKey, options),
83
+ request: async (request: unknown) => {
84
+ requests.push(request)
85
+ return { capabilities: { sponsored: false }, tx: {} }
86
+ },
87
+ },
88
+ requests,
89
+ }
90
+ }
83
91
 
84
- test('behavior: appends to existing access keys', async () => {
85
- const store = createStore()
86
- const keyPair1 = await WebCryptoP256.createKeyPair()
87
- const keyPair2 = await WebCryptoP256.createKeyPair()
88
- const ak1 = TempoAccount.fromWebCryptoP256(keyPair1)
89
- const ak2 = TempoAccount.fromWebCryptoP256(keyPair2)
92
+ function getStored(account: TempoAccount.AccessKeyAccount, store: Store.Store) {
93
+ return store
94
+ .getState()
95
+ .accessKeys.find((key) => key.address.toLowerCase() === account.accessKeyAddress.toLowerCase())
96
+ }
90
97
 
91
- AccessKey.save({
92
- address: rootAddress,
93
- keyAuthorization: createKeyAuthorization(ak1.address),
98
+ function addAuthorization(options: {
99
+ address: `0x${string}`
100
+ keyAuthorization: KeyAuthorization.Signed
101
+ keyPair?: Awaited<ReturnType<typeof WebCryptoP256.createKeyPair>> | undefined
102
+ privateKey?: Hex.Hex | undefined
103
+ state: 'signed' | 'pending' | 'authorized'
104
+ store: Store.Store
105
+ }) {
106
+ const { address, keyAuthorization, keyPair, privateKey, state, store } = options
107
+ AccessKey.add({
108
+ account: address,
109
+ authorization: keyAuthorization,
110
+ ...(keyPair ? { keyPair } : {}),
111
+ ...(privateKey ? { privateKey } : {}),
112
+ store,
113
+ })
114
+ if (state === 'pending')
115
+ AccessKey.markPending({
116
+ account: address,
117
+ accessKey: keyAuthorization.address,
118
+ chainId: Number(keyAuthorization.chainId),
94
119
  store,
95
120
  })
96
- AccessKey.save({
97
- address: rootAddress,
98
- keyAuthorization: createKeyAuthorization(ak2.address),
121
+ if (state === 'authorized')
122
+ AccessKey.markPublished({
123
+ account: address,
124
+ accessKey: keyAuthorization.address,
125
+ chainId: Number(keyAuthorization.chainId),
99
126
  store,
100
127
  })
128
+ }
101
129
 
102
- expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`2`)
130
+ function markPublished(options: {
131
+ accessKey: `0x${string}`
132
+ address?: `0x${string}` | undefined
133
+ chainId?: number | undefined
134
+ store: Store.Store
135
+ }) {
136
+ const { accessKey, store } = options
137
+ AccessKey.markPublished({
138
+ account: options.address ?? rootAddress,
139
+ accessKey,
140
+ chainId: options.chainId ?? 1,
141
+ store,
103
142
  })
143
+ }
104
144
 
105
- test('behavior: stores expiry from key authorization', async () => {
145
+ describe('add', () => {
146
+ test('default: saves a signed authorization', async () => {
106
147
  const store = createStore()
107
148
  const keyPair = await WebCryptoP256.createKeyPair()
108
149
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
109
150
  const expiry = Math.floor(Date.now() / 1000) + 3600
110
- const keyAuthorization = createKeyAuthorization(accessKey.address, { expiry })
111
-
112
- AccessKey.save({ address: rootAddress, keyAuthorization, store })
113
-
114
- expect(store.getState().accessKeys[0]!.expiry).toBe(expiry)
115
- })
116
-
117
- test('behavior: stores limits from key authorization', async () => {
118
- const store = createStore()
119
- const keyPair = await WebCryptoP256.createKeyPair()
120
- const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
121
151
  const limits = [{ token: '0x20c0000000000000000000000000000000000001' as const, limit: 1000n }]
122
- const keyAuthorization = createKeyAuthorization(accessKey.address, { limits })
152
+ const keyAuthorization = createKeyAuthorization(accessKey.address, { expiry, limits })
123
153
 
124
- AccessKey.save({ address: rootAddress, keyAuthorization, store })
154
+ addAuthorization({
155
+ address: rootAddress,
156
+ keyAuthorization,
157
+ state: 'signed',
158
+ store,
159
+ })
125
160
 
161
+ const { accessKeys } = store.getState()
162
+ expect(accessKeys.length).toMatchInlineSnapshot(`1`)
163
+ expect(accessKeys[0]!.address).toBe(accessKey.address)
164
+ expect(accessKeys[0]!.access).toBe(rootAddress)
165
+ expect(accessKeys[0]!.chainId).toMatchInlineSnapshot(`1`)
166
+ expect(accessKeys[0]!.expiry).toBe(expiry)
167
+ expect(accessKeys[0]!.keyType).toMatchInlineSnapshot(`"p256"`)
168
+ expect(accessKeys[0]!.keyAuthorization).toBe(keyAuthorization)
126
169
  expect(store.getState().accessKeys[0]!.limits).toMatchInlineSnapshot(`
127
170
  [
128
171
  {
@@ -134,110 +177,129 @@ describe('save', () => {
134
177
  })
135
178
  })
136
179
 
137
- describe('getPending', () => {
138
- test('default: returns key authorization for access key account', async () => {
139
- const store = createStore()
140
- const keyPair = await WebCryptoP256.createKeyPair()
141
- const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
142
- const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
143
-
144
- AccessKey.save({ address: rootAddress, keyAuthorization, store })
145
-
146
- const result = AccessKey.getPending(accessKey, { store })
147
- expect(result).toBe(keyAuthorization)
148
- })
149
-
150
- test('behavior: returns undefined for root account', () => {
151
- const store = createStore()
152
- const result = AccessKey.getPending(accounts[0]!, { store })
153
- expect(result).toBeUndefined()
154
- })
155
-
156
- test('behavior: returns undefined when no matching access key', async () => {
157
- const store = createStore()
158
- const keyPair = await WebCryptoP256.createKeyPair()
159
- const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
160
-
161
- const result = AccessKey.getPending(accessKey, { store })
162
- expect(result).toBeUndefined()
163
- })
164
- })
165
-
166
- describe('removePending', () => {
180
+ describe('markPublished', () => {
167
181
  test('default: clears key authorization from access key', async () => {
168
182
  const store = createStore()
169
183
  const keyPair = await WebCryptoP256.createKeyPair()
170
184
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
171
185
  const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
172
186
 
173
- AccessKey.save({ address: rootAddress, keyAuthorization, store })
174
- expect(AccessKey.getPending(accessKey, { store })).toBeDefined()
187
+ addAuthorization({
188
+ address: rootAddress,
189
+ keyAuthorization,
190
+ state: 'signed',
191
+ store,
192
+ })
193
+ expect(getStored(accessKey, store)?.keyAuthorization).toBeDefined()
175
194
 
176
- AccessKey.removePending(accessKey, { store })
195
+ markPublished({ accessKey: accessKey.accessKeyAddress, store })
177
196
 
178
- expect(AccessKey.getPending(accessKey, { store })).toBeUndefined()
197
+ expect(getStored(accessKey, store)?.keyAuthorization).toBeUndefined()
198
+ expect(getStored(accessKey, store)?.keyAuthorizationPending).toBeUndefined()
179
199
  })
180
200
 
181
- test('behavior: no-op for root account', async () => {
201
+ test('behavior: only clears the matching account and chain', async () => {
182
202
  const store = createStore()
183
203
  const keyPair = await WebCryptoP256.createKeyPair()
184
204
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
185
205
  const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
206
+ const keyAuthorization_chain = createKeyAuthorization(accessKey.accessKeyAddress, {
207
+ chainId: 2n,
208
+ })
209
+ const keyAuthorization_account = createKeyAuthorization(accessKey.accessKeyAddress)
186
210
 
187
- AccessKey.save({ address: rootAddress, keyAuthorization, store })
188
-
189
- AccessKey.removePending(accounts[0]!, { store })
190
-
191
- expect(AccessKey.getPending(accessKey, { store })).toBeDefined()
192
- })
193
-
194
- test('behavior: does not affect other access keys', async () => {
195
- const store = createStore()
196
- const keyPair1 = await WebCryptoP256.createKeyPair()
197
- const keyPair2 = await WebCryptoP256.createKeyPair()
198
- const ak1 = TempoAccount.fromWebCryptoP256(keyPair1, { access: rootAddress })
199
- const ak2 = TempoAccount.fromWebCryptoP256(keyPair2, { access: rootAddress })
200
- const ka1 = createKeyAuthorization(ak1.accessKeyAddress)
201
- const ka2 = createKeyAuthorization(ak2.accessKeyAddress)
202
-
203
- AccessKey.save({ address: rootAddress, keyAuthorization: ka1, store })
204
- AccessKey.save({ address: rootAddress, keyAuthorization: ka2, store })
211
+ addAuthorization({
212
+ address: rootAddress,
213
+ keyAuthorization,
214
+ state: 'signed',
215
+ store,
216
+ })
217
+ addAuthorization({
218
+ address: rootAddress,
219
+ keyAuthorization: keyAuthorization_chain,
220
+ state: 'signed',
221
+ store,
222
+ })
223
+ addAuthorization({
224
+ address: accounts[1]!.address,
225
+ keyAuthorization: keyAuthorization_account,
226
+ state: 'signed',
227
+ store,
228
+ })
205
229
 
206
- AccessKey.removePending(ak1, { store })
230
+ markPublished({ accessKey: accessKey.accessKeyAddress, store })
207
231
 
208
- expect(AccessKey.getPending(ak1, { store })).toBeUndefined()
209
- expect(AccessKey.getPending(ak2, { store })).toBe(ka2)
232
+ expect(
233
+ store.getState().accessKeys.map((key) => ({
234
+ access: key.access,
235
+ chainId: key.chainId,
236
+ keyAuthorization: !!key.keyAuthorization,
237
+ })),
238
+ ).toMatchInlineSnapshot(`
239
+ [
240
+ {
241
+ "access": "${accounts[1]!.address}",
242
+ "chainId": 1,
243
+ "keyAuthorization": true,
244
+ },
245
+ {
246
+ "access": "${rootAddress}",
247
+ "chainId": 2,
248
+ "keyAuthorization": true,
249
+ },
250
+ {
251
+ "access": "${rootAddress}",
252
+ "chainId": 1,
253
+ "keyAuthorization": false,
254
+ },
255
+ ]
256
+ `)
210
257
  })
211
258
  })
212
259
 
213
- describe('invalidate', () => {
214
- async function setup() {
260
+ describe('create invalidation', () => {
261
+ async function setup(options: { other?: boolean | undefined } = {}) {
215
262
  const store = createStore()
263
+ const keyPair_other = await WebCryptoP256.createKeyPair()
264
+ const account_other = TempoAccount.fromWebCryptoP256(keyPair_other, { access: rootAddress })
265
+ if (options.other)
266
+ addAuthorization({
267
+ address: rootAddress,
268
+ keyAuthorization: createKeyAuthorization(account_other.accessKeyAddress),
269
+ keyPair: keyPair_other,
270
+ state: 'signed',
271
+ store,
272
+ })
273
+
216
274
  const keyPair = await WebCryptoP256.createKeyPair()
217
275
  const account = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
218
- AccessKey.save({
276
+ addAuthorization({
219
277
  address: rootAddress,
220
278
  keyAuthorization: createKeyAuthorization(account.accessKeyAddress),
221
279
  keyPair,
280
+ state: 'signed',
222
281
  store,
223
282
  })
224
- return { account, store }
283
+ return { account_other, store }
225
284
  }
226
285
 
227
- test('default: removes matching access key for stale-key errors', async () => {
228
- const { account, store } = await setup()
229
- const keyPair = await WebCryptoP256.createKeyPair()
230
- const account_other = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
231
- AccessKey.save({
286
+ test('behavior: removes matching access key for stale-key errors', async () => {
287
+ const { account_other, store } = await setup({ other: true })
288
+
289
+ const transaction = await AccessKeyTransaction.create({
232
290
  address: rootAddress,
233
- keyAuthorization: createKeyAuthorization(account_other.accessKeyAddress),
234
- keyPair,
291
+ chainId: 1,
292
+ client: {
293
+ request: async () => {
294
+ throw createRevert('KeyNotFound')
295
+ },
296
+ } as never,
235
297
  store,
236
298
  })
237
299
 
238
- const result = AccessKey.invalidate(account, createRevert('KeyNotFound'), { store })
239
-
240
- expect(result).toMatchInlineSnapshot(`true`)
300
+ await expect(
301
+ transaction?.fill({ chainId: 1, from: rootAddress }),
302
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: reverted]`)
241
303
  expect(store.getState().accessKeys.map((key) => key.address)).toMatchInlineSnapshot(`
242
304
  [
243
305
  "${account_other.accessKeyAddress}",
@@ -246,31 +308,65 @@ describe('invalidate', () => {
246
308
  })
247
309
 
248
310
  test('behavior: preserves access key for recoverable execution errors', async () => {
249
- const { account, store } = await setup()
311
+ const { store } = await setup()
250
312
 
251
- const result = AccessKey.invalidate(account, createRevert('SpendingLimitExceeded'), {
313
+ const transaction = await AccessKeyTransaction.create({
314
+ address: rootAddress,
315
+ chainId: 1,
316
+ client: {
317
+ request: async () => {
318
+ throw createRevert('SpendingLimitExceeded')
319
+ },
320
+ } as never,
252
321
  store,
253
322
  })
254
323
 
255
- expect(result).toMatchInlineSnapshot(`false`)
324
+ await expect(
325
+ transaction?.fill({ chainId: 1, from: rootAddress }),
326
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: reverted]`)
256
327
  expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`)
257
328
  })
258
329
 
259
330
  test('behavior: preserves access key for unknown errors', async () => {
260
- const { account, store } = await setup()
331
+ const { store } = await setup()
261
332
 
262
- const result = AccessKey.invalidate(account, new Error('network failed'), { store })
333
+ const transaction = await AccessKeyTransaction.create({
334
+ address: rootAddress,
335
+ chainId: 1,
336
+ client: {
337
+ request: async () => {
338
+ throw new Error('network failed')
339
+ },
340
+ } as never,
341
+ store,
342
+ })
263
343
 
264
- expect(result).toMatchInlineSnapshot(`false`)
344
+ await expect(
345
+ transaction?.fill({ chainId: 1, from: rootAddress }),
346
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: network failed]`)
265
347
  expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`)
266
348
  })
349
+ })
267
350
 
268
- test('behavior: no-op for root accounts', () => {
269
- const store = createStore()
351
+ describe('isUnavailableError', () => {
352
+ test('default: recognizes unavailable key revert errors', () => {
353
+ expect(AccessKey.isUnavailableError(createRevert('KeyNotFound'))).toMatchInlineSnapshot(`true`)
354
+ expect(AccessKey.isUnavailableError(createRevert('KeyAlreadyRevoked'))).toMatchInlineSnapshot(
355
+ `true`,
356
+ )
357
+ expect(
358
+ AccessKey.isUnavailableError(createRevert('SpendingLimitExceeded')),
359
+ ).toMatchInlineSnapshot(`false`)
360
+ })
270
361
 
271
- const result = AccessKey.invalidate(accounts[0]!, createRevert('KeyNotFound'), { store })
362
+ test('behavior: recognizes nested viem error data', () => {
363
+ const error = new BaseError('revoke failed', {
364
+ cause: Object.assign(new Error('execution reverted'), {
365
+ data: { errorName: 'KeyAlreadyRevoked' },
366
+ }),
367
+ })
272
368
 
273
- expect(result).toMatchInlineSnapshot(`false`)
369
+ expect(AccessKey.isUnavailableError(error)).toMatchInlineSnapshot(`true`)
274
370
  })
275
371
  })
276
372
 
@@ -385,23 +481,34 @@ describe('prepareAuthorization', () => {
385
481
  })
386
482
  })
387
483
 
388
- describe('saveAuthorization', () => {
389
- test('default: saves prepared authorization with provided signature', async () => {
484
+ describe('authorize', () => {
485
+ test('default: prepares, signs, and saves authorization', async () => {
390
486
  const store = createStore()
391
- const prepared = await AccessKey.prepareAuthorization({
392
- address: accounts[1]!.address,
393
- chainId: 1,
394
- expiry: 123,
395
- })
487
+ const digests: Hex.Hex[] = []
396
488
  const signature = `0x${'11'.repeat(32)}${'22'.repeat(32)}1b` as const
489
+ const account = {
490
+ ...accounts[0]!,
491
+ sign: async (parameters: { hash: Hex.Hex }) => {
492
+ digests.push(parameters.hash)
493
+ return signature
494
+ },
495
+ } as TempoAccount.Account
397
496
 
398
- const result = AccessKey.saveAuthorization({
399
- address: rootAddress,
400
- prepared,
401
- signature,
497
+ const result = await AccessKey.authorize({
498
+ account,
499
+ chainId: 1,
500
+ parameters: {
501
+ address: accounts[1]!.address,
502
+ expiry: 123,
503
+ },
402
504
  store,
403
505
  })
404
506
 
507
+ expect(digests).toMatchInlineSnapshot(`
508
+ [
509
+ "0xea47721547363fc82a5dca62b4544e4718d861b3df10bfac65d30102594b5c26",
510
+ ]
511
+ `)
405
512
  expect(result).toMatchInlineSnapshot(`
406
513
  {
407
514
  "chainId": "0x1",
@@ -434,448 +541,298 @@ describe('saveAuthorization', () => {
434
541
  })
435
542
  })
436
543
 
437
- describe('authorize', () => {
438
- test('default: prepares, signs, and saves authorization', async () => {
544
+ describe('select', () => {
545
+ async function setup(options: { pending?: boolean | undefined } = {}) {
439
546
  const store = createStore()
440
- const digests: Hex.Hex[] = []
441
- const signature = `0x${'11'.repeat(32)}${'22'.repeat(32)}1b` as const
442
- const account = {
443
- ...accounts[0]!,
444
- sign: async (parameters: { hash: Hex.Hex }) => {
445
- digests.push(parameters.hash)
446
- return signature
447
- },
448
- } as TempoAccount.Account
547
+ const keyPair = await WebCryptoP256.createKeyPair()
548
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
549
+ const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
449
550
 
450
- await AccessKey.authorize({
451
- account,
452
- chainId: 1,
453
- parameters: {
454
- address: accounts[1]!.address,
455
- expiry: 123,
456
- },
551
+ addAuthorization({
552
+ address: rootAddress,
553
+ keyAuthorization,
554
+ keyPair,
555
+ state: options.pending ? 'pending' : 'signed',
457
556
  store,
458
557
  })
459
558
 
460
- expect(digests).toMatchInlineSnapshot(`
461
- [
462
- "0xea47721547363fc82a5dca62b4544e4718d861b3df10bfac65d30102594b5c26",
463
- ]
464
- `)
465
- expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`)
466
- })
467
- })
559
+ return { accessKey, keyAuthorization, store }
560
+ }
468
561
 
469
- describe('hydrate', () => {
470
- test('default: hydrates webCrypto access key to signable account', async () => {
562
+ test('behavior: skips access keys for another root address', async () => {
563
+ const store = createStore()
471
564
  const keyPair = await WebCryptoP256.createKeyPair()
472
- const result = AccessKey.hydrate({
473
- access: rootAddress,
474
- address: '0x0000000000000000000000000000000000000099',
475
- chainId: 1,
565
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: accounts[1]!.address })
566
+ addAuthorization({
567
+ address: accounts[1]!.address,
568
+ keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress),
476
569
  keyPair,
477
- keyType: 'webCrypto',
570
+ state: 'signed',
571
+ store,
478
572
  })
479
573
 
480
- expect(result.type).toMatchInlineSnapshot(`"local"`)
481
- expect(typeof result.sign).toMatchInlineSnapshot(`"function"`)
482
- expect(result.source).toMatchInlineSnapshot(`"accessKey"`)
483
- })
484
-
485
- test('behavior: hydrates private-key access key to signable account', () => {
486
- const result = AccessKey.hydrate({
487
- access: rootAddress,
488
- address: accounts[1]!.address,
574
+ const result = await AccessKey.select({
575
+ account: rootAddress,
489
576
  chainId: 1,
490
- keyType: 'secp256k1',
491
- privateKey: privateKeys[1],
577
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
578
+ store,
492
579
  })
493
580
 
494
- expect(result.type).toMatchInlineSnapshot(`"local"`)
495
- expect(typeof result.sign).toMatchInlineSnapshot(`"function"`)
496
- expect(result.source).toMatchInlineSnapshot(`"accessKey"`)
497
- })
498
-
499
- test('error: throws for external access key without signer material', () => {
500
- expect(() =>
501
- AccessKey.hydrate({
502
- access: rootAddress,
503
- address: '0x0000000000000000000000000000000000000099',
504
- chainId: 1,
505
- keyType: 'p256',
506
- }),
507
- ).toThrowErrorMatchingInlineSnapshot(
508
- `[Provider.UnauthorizedError: External access key cannot be hydrated for signing.]`,
509
- )
510
- })
511
- })
512
-
513
- describe('selectAccount', () => {
514
- function setup(accessKeys: readonly Store.AccessKey[] = []) {
515
- const store = createStore()
516
- store.setState({ accessKeys })
517
- return store
518
- }
519
-
520
- test('default: selects locally-signable access key for root address', async () => {
521
- const keyPair = await WebCryptoP256.createKeyPair()
522
- const store = setup([
523
- {
524
- access: rootAddress,
525
- address: '0x0000000000000000000000000000000000000099',
526
- chainId: 1,
527
- keyPair,
528
- keyType: 'webCrypto',
529
- },
530
- ])
531
-
532
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 1, store })
533
-
534
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
535
- })
536
-
537
- test('behavior: skips access keys for another root address', async () => {
538
- const keyPair = await WebCryptoP256.createKeyPair()
539
- const store = setup([
540
- {
541
- access: accounts[1]!.address,
542
- address: '0x0000000000000000000000000000000000000099',
543
- chainId: 1,
544
- keyPair,
545
- keyType: 'webCrypto',
546
- },
547
- ])
548
-
549
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 1, store })
550
-
551
581
  expect(result).toMatchInlineSnapshot(`undefined`)
552
582
  })
553
583
 
554
584
  test('behavior: skips access keys for another chain', async () => {
555
- const keyPair = await WebCryptoP256.createKeyPair()
556
- const store = setup([
557
- {
558
- access: rootAddress,
559
- address: '0x0000000000000000000000000000000000000099',
560
- chainId: 1,
561
- keyPair,
562
- keyType: 'webCrypto',
563
- },
564
- ])
565
-
566
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 42_431, store })
567
-
568
- expect(result).toMatchInlineSnapshot(`undefined`)
569
- })
570
-
571
- test('behavior: skips external access keys without signer material', () => {
572
- const store = setup([
573
- {
574
- access: rootAddress,
575
- address: '0x0000000000000000000000000000000000000099',
576
- chainId: 1,
577
- keyType: 'p256',
578
- },
579
- ])
585
+ const { accessKey, store } = await setup()
580
586
 
581
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 1, store })
587
+ const result = await AccessKey.select({
588
+ account: rootAddress,
589
+ chainId: 42_431,
590
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
591
+ store,
592
+ })
582
593
 
583
594
  expect(result).toMatchInlineSnapshot(`undefined`)
584
595
  })
585
596
 
586
- test('behavior: removes expired access key', async () => {
587
- const keyPair = await WebCryptoP256.createKeyPair()
588
- const store = setup([
589
- {
590
- access: rootAddress,
591
- address: '0x0000000000000000000000000000000000000099',
592
- expiry: Math.floor(Date.now() / 1000) - 3600,
593
- chainId: 1,
594
- keyPair,
595
- keyType: 'webCrypto',
596
- },
597
- ])
597
+ test('behavior: skips external access keys without signer material', async () => {
598
+ const store = createStore()
599
+ const keyAuthorization = createKeyAuthorization('0x0000000000000000000000000000000000000099')
600
+ addAuthorization({
601
+ address: rootAddress,
602
+ keyAuthorization,
603
+ state: 'signed',
604
+ store,
605
+ })
598
606
 
599
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 1, store })
607
+ const result = await AccessKey.select({
608
+ account: rootAddress,
609
+ chainId: 1,
610
+ client: createMetadataClient(keyAuthorization.address) as never,
611
+ store,
612
+ })
600
613
 
601
614
  expect(result).toMatchInlineSnapshot(`undefined`)
602
- expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`)
603
615
  })
604
616
 
605
- test('behavior: keeps future-expiring access key', async () => {
617
+ test('behavior: matches access key scopes against transaction calls', async () => {
618
+ const store = createStore()
606
619
  const keyPair = await WebCryptoP256.createKeyPair()
607
- const store = setup([
608
- {
609
- access: rootAddress,
610
- address: '0x0000000000000000000000000000000000000099',
611
- expiry: Math.floor(Date.now() / 1000) + 3600,
612
- chainId: 1,
613
- keyPair,
614
- keyType: 'webCrypto',
615
- },
616
- ])
617
-
618
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 1, store })
620
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
621
+ const token = '0x0000000000000000000000000000000000000abc' as const
622
+ addAuthorization({
623
+ address: rootAddress,
624
+ keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress, {
625
+ scopes: [{ address: token, selector: 'transfer(address,uint256)' }],
626
+ }),
627
+ keyPair,
628
+ state: 'signed',
629
+ store,
630
+ })
619
631
 
620
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
621
- expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`)
622
- })
632
+ const match = await AccessKey.select({
633
+ account: rootAddress,
634
+ calls: [{ to: token, data: '0xa9059cbb0000000000000000000000000000000000000001' }],
635
+ chainId: 1,
636
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
637
+ store,
638
+ })
639
+ const miss = await AccessKey.select({
640
+ account: rootAddress,
641
+ calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }],
642
+ chainId: 1,
643
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
644
+ store,
645
+ })
623
646
 
624
- test('behavior: preserves limits on selected access key', async () => {
625
- const keyPair = await WebCryptoP256.createKeyPair()
626
- const store = setup([
647
+ expect({ match: !!match, miss: !!miss }).toMatchInlineSnapshot(`
627
648
  {
628
- access: rootAddress,
629
- address: '0x0000000000000000000000000000000000000099',
630
- chainId: 1,
631
- keyPair,
632
- keyType: 'webCrypto',
633
- limits: [
634
- {
635
- token: '0x0000000000000000000000000000000000000abc',
636
- limit: 1000n,
637
- },
638
- ],
639
- },
640
- ])
641
-
642
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 1, store })
643
-
644
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
645
- expect(store.getState().accessKeys[0]?.limits).toMatchInlineSnapshot(`
646
- [
647
- {
648
- "limit": 1000n,
649
- "token": "0x0000000000000000000000000000000000000abc",
650
- },
651
- ]
649
+ "match": true,
650
+ "miss": false,
651
+ }
652
652
  `)
653
653
  })
654
+ })
654
655
 
655
- test('behavior: unscoped access key selects with calls', async () => {
656
+ describe('create', () => {
657
+ async function setup(options: { pending?: boolean | undefined } = {}) {
658
+ const store = createStore()
656
659
  const keyPair = await WebCryptoP256.createKeyPair()
657
- const store = setup([
658
- {
659
- access: rootAddress,
660
- address: '0x0000000000000000000000000000000000000099',
661
- chainId: 1,
662
- keyPair,
663
- keyType: 'webCrypto',
664
- },
665
- ])
660
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
661
+ const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
666
662
 
667
- const result = AccessKey.selectAccount({
663
+ addAuthorization({
668
664
  address: rootAddress,
669
- chainId: 1,
665
+ keyAuthorization,
666
+ keyPair,
667
+ state: options.pending ? 'pending' : 'signed',
670
668
  store,
671
- calls: [{ to: '0x0000000000000000000000000000000000000abc', data: '0xa9059cbb' }],
672
669
  })
673
670
 
674
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
675
- })
671
+ return { accessKey, keyAuthorization, store }
672
+ }
676
673
 
677
- test('behavior: scoped access key selects when calls match', async () => {
678
- const keyPair = await WebCryptoP256.createKeyPair()
679
- const token = '0x0000000000000000000000000000000000000abc' as const
680
- const store = setup([
681
- {
682
- access: rootAddress,
683
- address: '0x0000000000000000000000000000000000000099',
684
- chainId: 1,
685
- keyPair,
686
- keyType: 'webCrypto',
687
- scopes: [{ address: token, selector: '0xa9059cbb' }],
688
- },
689
- ])
674
+ test('behavior: returns undefined when no matching access key exists', async () => {
675
+ const store = createStore()
676
+ const { client } = createFillClient(accounts[1]!.address)
690
677
 
691
- const result = AccessKey.selectAccount({
678
+ const result = await AccessKeyTransaction.create({
692
679
  address: rootAddress,
693
680
  chainId: 1,
681
+ client: client as never,
694
682
  store,
695
- calls: [{ to: token, data: '0xa9059cbb0000000000000000000000000000000000000001' }],
696
683
  })
697
684
 
698
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
685
+ expect(result).toMatchInlineSnapshot(`undefined`)
699
686
  })
700
687
 
701
- test('behavior: scoped access key skips calls that do not match', async () => {
702
- const keyPair = await WebCryptoP256.createKeyPair()
703
- const token = '0x0000000000000000000000000000000000000abc' as const
704
- const store = setup([
705
- {
706
- access: rootAddress,
707
- address: '0x0000000000000000000000000000000000000099',
708
- chainId: 1,
709
- keyPair,
710
- keyType: 'webCrypto',
711
- scopes: [{ address: token, selector: '0xa9059cbb' }],
712
- },
713
- ])
688
+ test('default: returns selected account with pending key authorization', async () => {
689
+ const { keyAuthorization, store } = await setup()
690
+ const { client, requests } = createFillClient(keyAuthorization.address)
714
691
 
715
- const result = AccessKey.selectAccount({
692
+ const result = await AccessKeyTransaction.create({
716
693
  address: rootAddress,
717
694
  chainId: 1,
695
+ client: client as never,
718
696
  store,
719
- calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }],
720
697
  })
698
+ await result?.fill({ chainId: 1, from: rootAddress })
721
699
 
722
- expect(result).toMatchInlineSnapshot(`undefined`)
700
+ expect(!!result).toMatchInlineSnapshot(`true`)
701
+ const request = requests[0] as {
702
+ params: readonly [{ keyAuthorization?: { keyId?: string | undefined } | undefined }]
703
+ }
704
+ expect(request.params[0].keyAuthorization?.keyId).toBe(keyAuthorization.address)
723
705
  })
724
706
 
725
- test('behavior: scoped access key supports human-readable selectors', async () => {
726
- const keyPair = await WebCryptoP256.createKeyPair()
727
- const token = '0x0000000000000000000000000000000000000abc' as const
728
- const store = setup([
729
- {
730
- access: rootAddress,
731
- address: '0x0000000000000000000000000000000000000099',
732
- chainId: 1,
733
- keyPair,
734
- keyType: 'webCrypto',
735
- scopes: [{ address: token, selector: 'transfer(address,uint256)' }],
736
- },
737
- ])
738
-
739
- const result = AccessKey.selectAccount({
707
+ test('behavior: signing prepared transaction marks key authorization pending', async () => {
708
+ const { accessKey, keyAuthorization, store } = await setup()
709
+ const result = await AccessKeyTransaction.create({
740
710
  address: rootAddress,
741
711
  chainId: 1,
712
+ client: { chain: { id: 1 } } as never,
742
713
  store,
743
- calls: [{ to: token, data: '0xa9059cbb0000000000000000000000000000000000000001' }],
744
714
  })
745
715
 
746
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
747
- })
748
-
749
- test('behavior: scoped access key checks recipient allowlist', async () => {
750
- const keyPair = await WebCryptoP256.createKeyPair()
751
- const token = '0x0000000000000000000000000000000000000abc' as const
752
- const recipient = '0x0000000000000000000000000000000000000def' as const
753
- const store = setup([
754
- {
755
- access: rootAddress,
756
- address: '0x0000000000000000000000000000000000000099',
757
- chainId: 1,
758
- keyPair,
759
- keyType: 'webCrypto',
760
- scopes: [
761
- { address: token, selector: 'transfer(address,uint256)', recipients: [recipient] },
762
- ],
763
- },
764
- ])
765
- const call = Actions.token.transfer.call({
766
- amount: 1n,
767
- to: recipient,
768
- token,
716
+ const prepared = await result?.prepare({
717
+ from: rootAddress,
718
+ gas: 21_000n,
719
+ maxFeePerGas: 1n,
720
+ maxPriorityFeePerGas: 1n,
721
+ nonce: 0,
722
+ to: accounts[1]!.address,
723
+ value: 1n,
769
724
  })
725
+ await prepared?.sign()
770
726
 
771
- const result = AccessKey.selectAccount({
727
+ const stored = getStored(accessKey, store)
728
+ expect(stored?.keyAuthorization).toBe(keyAuthorization)
729
+ expect(stored?.keyAuthorizationPending).toMatchInlineSnapshot(`true`)
730
+ })
731
+
732
+ test('behavior: sending asynchronously keeps key authorization pending', async () => {
733
+ const { accessKey, keyAuthorization, store } = await setup()
734
+ const requests: unknown[] = []
735
+ const result = await AccessKeyTransaction.create({
772
736
  address: rootAddress,
773
737
  chainId: 1,
738
+ client: {
739
+ chain: { id: 1 },
740
+ request: async (request: unknown) => {
741
+ requests.push(request)
742
+ return '0xtransaction'
743
+ },
744
+ } as never,
774
745
  store,
775
- calls: [call],
776
746
  })
777
747
 
778
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
748
+ const prepared = await result?.prepare({
749
+ from: rootAddress,
750
+ gas: 21_000n,
751
+ maxFeePerGas: 1n,
752
+ maxPriorityFeePerGas: 1n,
753
+ nonce: 0,
754
+ to: accounts[1]!.address,
755
+ value: 1n,
756
+ })
757
+ await prepared?.send()
758
+
759
+ const stored = getStored(accessKey, store)
760
+ expect(stored?.keyAuthorization).toBe(keyAuthorization)
761
+ expect(stored?.keyAuthorizationPending).toMatchInlineSnapshot(`true`)
762
+ expect(requests.map((request) => (request as { method: string }).method))
763
+ .toMatchInlineSnapshot(`
764
+ [
765
+ "eth_fillTransaction",
766
+ "eth_sendRawTransaction",
767
+ ]
768
+ `)
779
769
  })
780
770
 
781
- test('behavior: scoped access key skips non-allowlisted recipients', async () => {
782
- const keyPair = await WebCryptoP256.createKeyPair()
783
- const token = '0x0000000000000000000000000000000000000abc' as const
784
- const store = setup([
785
- {
786
- access: rootAddress,
787
- address: '0x0000000000000000000000000000000000000099',
788
- chainId: 1,
789
- keyPair,
790
- keyType: 'webCrypto',
791
- scopes: [
792
- {
793
- address: token,
794
- selector: 'transfer(address,uint256)',
795
- recipients: ['0x0000000000000000000000000000000000000def'],
796
- },
797
- ],
798
- },
799
- ])
800
- const call = Actions.token.transfer.call({
801
- amount: 1n,
802
- to: '0x0000000000000000000000000000000000000fed',
803
- token,
804
- })
771
+ test('behavior: clears pending authorization when pending key is published on-chain', async () => {
772
+ const { accessKey, store } = await setup({ pending: true })
805
773
 
806
- const result = AccessKey.selectAccount({
774
+ const result = await AccessKeyTransaction.create({
807
775
  address: rootAddress,
808
776
  chainId: 1,
777
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
809
778
  store,
810
- calls: [call],
811
779
  })
812
780
 
813
- expect(result).toMatchInlineSnapshot(`undefined`)
781
+ expect(!!result).toMatchInlineSnapshot(`true`)
782
+ expect(store.getState().accessKeys[0]!.keyAuthorization).toMatchInlineSnapshot(`undefined`)
783
+ expect(store.getState().accessKeys[0]!.keyAuthorizationPending).toMatchInlineSnapshot(
784
+ `undefined`,
785
+ )
814
786
  })
815
787
 
816
- test('behavior: malformed scopes skip the access key', async () => {
817
- const keyPair = await WebCryptoP256.createKeyPair()
818
- const store = setup([
819
- {
820
- access: rootAddress,
821
- address: '0x0000000000000000000000000000000000000099',
822
- chainId: 1,
823
- keyPair,
824
- keyType: 'webCrypto',
825
- scopes: [{}],
826
- } as never,
827
- ])
788
+ test('behavior: reuses pending authorization when direct key check is missing', async () => {
789
+ const { accessKey, keyAuthorization, store } = await setup({ pending: true })
790
+ const { client, requests } = createFillClient(accessKey.accessKeyAddress, {
791
+ keyId: accounts[1]!.address,
792
+ })
828
793
 
829
- const result = AccessKey.selectAccount({
794
+ const result = await AccessKeyTransaction.create({
830
795
  address: rootAddress,
831
796
  chainId: 1,
797
+ client: client as never,
832
798
  store,
833
- calls: [{ to: '0x0000000000000000000000000000000000000abc', data: '0xa9059cbb' }],
834
799
  })
800
+ await result?.fill({ chainId: 1, from: rootAddress })
835
801
 
836
- expect(result).toMatchInlineSnapshot(`undefined`)
802
+ const request = requests[0] as {
803
+ params: readonly [{ keyAuthorization?: { keyId?: string | undefined } | undefined }]
804
+ }
805
+ expect(request.params[0].keyAuthorization?.keyId).toBe(keyAuthorization.address)
806
+ expect(store.getState().accessKeys[0]!.keyAuthorization).toBe(keyAuthorization)
807
+ expect(store.getState().accessKeys[0]!.keyAuthorizationPending).toMatchInlineSnapshot(`true`)
837
808
  })
838
809
 
839
- test('behavior: scoped access key without selector allows any call to that address', async () => {
840
- const keyPair = await WebCryptoP256.createKeyPair()
841
- const token = '0x0000000000000000000000000000000000000abc' as const
842
- const store = setup([
843
- {
844
- access: rootAddress,
845
- address: '0x0000000000000000000000000000000000000099',
846
- chainId: 1,
847
- keyPair,
848
- keyType: 'webCrypto',
849
- scopes: [{ address: token }],
850
- },
851
- ])
810
+ test('behavior: reuses pending authorization when direct key check fails', async () => {
811
+ const { keyAuthorization, store } = await setup({ pending: true })
812
+ const requests: unknown[] = []
852
813
 
853
- const result = AccessKey.selectAccount({
814
+ const result = await AccessKeyTransaction.create({
854
815
  address: rootAddress,
855
816
  chainId: 1,
817
+ client: {
818
+ call: async () => {
819
+ throw new Error('network failed')
820
+ },
821
+ request: async (request: unknown) => {
822
+ requests.push(request)
823
+ return { capabilities: { sponsored: false }, tx: {} }
824
+ },
825
+ } as never,
856
826
  store,
857
- calls: [{ to: token, data: '0xdeadbeef' }],
858
827
  })
828
+ await result?.fill({ chainId: 1, from: rootAddress })
859
829
 
860
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
861
- })
862
-
863
- test('behavior: scoped access key skips when no calls are provided', async () => {
864
- const keyPair = await WebCryptoP256.createKeyPair()
865
- const store = setup([
866
- {
867
- access: rootAddress,
868
- address: '0x0000000000000000000000000000000000000099',
869
- chainId: 1,
870
- keyPair,
871
- keyType: 'webCrypto',
872
- scopes: [{ address: '0x0000000000000000000000000000000000000abc' }],
873
- },
874
- ])
875
-
876
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 1, store })
877
-
878
- expect(result).toMatchInlineSnapshot(`undefined`)
830
+ const request = requests[0] as {
831
+ params: readonly [{ keyAuthorization?: { keyId?: string | undefined } | undefined }]
832
+ }
833
+ expect(request.params[0].keyAuthorization?.keyId).toBe(keyAuthorization.address)
834
+ expect(store.getState().accessKeys[0]!.keyAuthorization).toBe(keyAuthorization)
835
+ expect(store.getState().accessKeys[0]!.keyAuthorizationPending).toMatchInlineSnapshot(`true`)
879
836
  })
880
837
  })
881
838
 
@@ -886,11 +843,18 @@ describe('getStatus', () => {
886
843
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
887
844
  const keyAuthorization = createKeyAuthorization(accessKey.address)
888
845
 
889
- AccessKey.save({ address: rootAddress, keyAuthorization, keyPair, store })
846
+ addAuthorization({
847
+ address: rootAddress,
848
+ keyAuthorization,
849
+ keyPair,
850
+ state: 'signed',
851
+ store,
852
+ })
890
853
 
891
854
  const result = await AccessKey.getStatus({
892
- address: rootAddress,
855
+ account: rootAddress,
893
856
  chainId: 1,
857
+ client: createMetadataClient(accessKey.address) as never,
894
858
  store,
895
859
  })
896
860
 
@@ -903,96 +867,98 @@ describe('getStatus', () => {
903
867
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
904
868
  const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
905
869
 
906
- AccessKey.save({ address: rootAddress, keyAuthorization, keyPair, store })
907
- AccessKey.removePending(accessKey, { store })
870
+ addAuthorization({
871
+ address: rootAddress,
872
+ keyAuthorization,
873
+ keyPair,
874
+ state: 'signed',
875
+ store,
876
+ })
877
+ markPublished({ accessKey: accessKey.accessKeyAddress, store })
908
878
 
909
879
  const result = await AccessKey.getStatus({
910
- address: rootAddress,
880
+ account: rootAddress,
911
881
  chainId: 1,
882
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
912
883
  store,
913
884
  })
914
885
 
915
886
  expect(result).toMatchInlineSnapshot(`"published"`)
916
887
  })
917
888
 
918
- test('behavior: returns expired for expired local key', async () => {
889
+ test('behavior: checks pending authorization before returning pending', async () => {
919
890
  const store = createStore()
920
891
  const keyPair = await WebCryptoP256.createKeyPair()
921
- const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
922
- const keyAuthorization = createKeyAuthorization(accessKey.address, { expiry: 100 })
892
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
893
+ const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
923
894
 
924
- AccessKey.save({ address: rootAddress, keyAuthorization, keyPair, store })
895
+ addAuthorization({
896
+ address: rootAddress,
897
+ keyAuthorization,
898
+ keyPair,
899
+ state: 'pending',
900
+ store,
901
+ })
925
902
 
926
903
  const result = await AccessKey.getStatus({
927
- address: rootAddress,
904
+ account: rootAddress,
928
905
  chainId: 1,
929
- now: 101,
906
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
930
907
  store,
931
908
  })
932
909
 
933
- expect(result).toMatchInlineSnapshot(`"expired"`)
910
+ expect(result).toMatchInlineSnapshot(`"published"`)
934
911
  })
935
912
 
936
- test('behavior: returns missing when no local key matches the policy', async () => {
913
+ test('behavior: returns expired for expired local key', async () => {
937
914
  const store = createStore()
938
915
  const keyPair = await WebCryptoP256.createKeyPair()
939
916
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
940
- const keyAuthorization = createKeyAuthorization(accessKey.address, {
941
- scopes: [{ address: '0x0000000000000000000000000000000000000abc' }],
942
- })
917
+ const keyAuthorization = createKeyAuthorization(accessKey.address, { expiry: 100 })
943
918
 
944
- AccessKey.save({ address: rootAddress, keyAuthorization, keyPair, store })
919
+ addAuthorization({
920
+ address: rootAddress,
921
+ keyAuthorization,
922
+ keyPair,
923
+ state: 'signed',
924
+ store,
925
+ })
945
926
 
946
927
  const result = await AccessKey.getStatus({
947
- address: rootAddress,
948
- calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }],
928
+ account: rootAddress,
949
929
  chainId: 1,
930
+ client: createMetadataClient(accessKey.address) as never,
931
+ now: 101,
950
932
  store,
951
933
  })
952
934
 
953
- expect(result).toMatchInlineSnapshot(`"missing"`)
935
+ expect(result).toMatchInlineSnapshot(`"expired"`)
954
936
  })
955
- })
956
937
 
957
- describe('revoke', () => {
958
- test('default: removes access keys by root address', async () => {
938
+ test('behavior: returns missing when no local key matches the policy', async () => {
959
939
  const store = createStore()
960
940
  const keyPair = await WebCryptoP256.createKeyPair()
961
941
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
962
- const keyAuthorization = createKeyAuthorization(accessKey.address)
963
-
964
- AccessKey.save({ address: rootAddress, keyAuthorization, store })
965
- expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`)
966
-
967
- AccessKey.revoke({ address: rootAddress, store })
968
-
969
- expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`)
970
- })
971
-
972
- test('behavior: only removes keys for matching root address', async () => {
973
- const store = createStore()
974
- const otherRoot = accounts[1]!.address
975
- const keyPair1 = await WebCryptoP256.createKeyPair()
976
- const keyPair2 = await WebCryptoP256.createKeyPair()
977
- const ak1 = TempoAccount.fromWebCryptoP256(keyPair1)
978
- const ak2 = TempoAccount.fromWebCryptoP256(keyPair2)
942
+ const keyAuthorization = createKeyAuthorization(accessKey.address, {
943
+ scopes: [{ address: '0x0000000000000000000000000000000000000abc' }],
944
+ })
979
945
 
980
- AccessKey.save({
946
+ addAuthorization({
981
947
  address: rootAddress,
982
- keyAuthorization: createKeyAuthorization(ak1.address),
948
+ keyAuthorization,
949
+ keyPair,
950
+ state: 'signed',
983
951
  store,
984
952
  })
985
- AccessKey.save({
986
- address: otherRoot,
987
- keyAuthorization: createKeyAuthorization(ak2.address),
953
+
954
+ const result = await AccessKey.getStatus({
955
+ account: rootAddress,
956
+ calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }],
957
+ chainId: 1,
958
+ client: createMissingClient() as never,
988
959
  store,
989
960
  })
990
961
 
991
- expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`2`)
992
-
993
- AccessKey.revoke({ address: rootAddress, store })
994
-
995
- expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`)
996
- expect(store.getState().accessKeys[0]!.access).toBe(otherRoot)
962
+ expect(result).toMatchInlineSnapshot(`"missing"`)
997
963
  })
998
964
  })