accounts 0.12.2 → 0.14.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 (128) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +6 -5
  3. package/dist/cli/adapter.d.ts.map +1 -1
  4. package/dist/cli/adapter.js +76 -69
  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 +230 -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 +24 -4
  15. package/dist/core/Adapter.d.ts.map +1 -1
  16. package/dist/core/Client.d.ts +1 -1
  17. package/dist/core/Client.d.ts.map +1 -1
  18. package/dist/core/Client.js.map +1 -1
  19. package/dist/core/Provider.d.ts +6 -7
  20. package/dist/core/Provider.d.ts.map +1 -1
  21. package/dist/core/Provider.js +73 -77
  22. package/dist/core/Provider.js.map +1 -1
  23. package/dist/core/Remote.d.ts +2 -0
  24. package/dist/core/Remote.d.ts.map +1 -1
  25. package/dist/core/Remote.js +3 -1
  26. package/dist/core/Remote.js.map +1 -1
  27. package/dist/core/Schema.d.ts +17 -7
  28. package/dist/core/Schema.d.ts.map +1 -1
  29. package/dist/core/adapters/dialog.d.ts.map +1 -1
  30. package/dist/core/adapters/dialog.js +126 -131
  31. package/dist/core/adapters/dialog.js.map +1 -1
  32. package/dist/core/adapters/local.d.ts.map +1 -1
  33. package/dist/core/adapters/local.js +92 -105
  34. package/dist/core/adapters/local.js.map +1 -1
  35. package/dist/core/adapters/privy.d.ts +164 -0
  36. package/dist/core/adapters/privy.d.ts.map +1 -0
  37. package/dist/core/adapters/privy.js +578 -0
  38. package/dist/core/adapters/privy.js.map +1 -0
  39. package/dist/core/adapters/turnkey.d.ts.map +1 -1
  40. package/dist/core/adapters/turnkey.js +136 -133
  41. package/dist/core/adapters/turnkey.js.map +1 -1
  42. package/dist/core/internal/AccessKeyTransaction.d.ts +59 -0
  43. package/dist/core/internal/AccessKeyTransaction.d.ts.map +1 -0
  44. package/dist/core/internal/AccessKeyTransaction.js +155 -0
  45. package/dist/core/internal/AccessKeyTransaction.js.map +1 -0
  46. package/dist/core/zod/rpc.d.ts +71 -4
  47. package/dist/core/zod/rpc.d.ts.map +1 -1
  48. package/dist/core/zod/rpc.js +33 -4
  49. package/dist/core/zod/rpc.js.map +1 -1
  50. package/dist/index.d.ts +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/react-native/adapter.d.ts.map +1 -1
  55. package/dist/react-native/adapter.js +86 -75
  56. package/dist/react-native/adapter.js.map +1 -1
  57. package/dist/server/CliAuth.d.ts +55 -0
  58. package/dist/server/CliAuth.d.ts.map +1 -1
  59. package/dist/server/CliAuth.js +18 -1
  60. package/dist/server/CliAuth.js.map +1 -1
  61. package/dist/server/Kv.d.ts +19 -9
  62. package/dist/server/Kv.d.ts.map +1 -1
  63. package/dist/server/Kv.js +32 -9
  64. package/dist/server/Kv.js.map +1 -1
  65. package/dist/server/internal/handlers/auth.d.ts.map +1 -1
  66. package/dist/server/internal/handlers/auth.js +3 -1
  67. package/dist/server/internal/handlers/auth.js.map +1 -1
  68. package/dist/server/internal/handlers/codeAuth.js +1 -1
  69. package/dist/server/internal/handlers/codeAuth.js.map +1 -1
  70. package/dist/server/internal/handlers/exchange.js +1 -1
  71. package/dist/server/internal/handlers/exchange.js.map +1 -1
  72. package/dist/server/internal/handlers/relay.d.ts +18 -12
  73. package/dist/server/internal/handlers/relay.d.ts.map +1 -1
  74. package/dist/server/internal/handlers/relay.js +116 -3
  75. package/dist/server/internal/handlers/relay.js.map +1 -1
  76. package/dist/server/internal/handlers/webAuthn.d.ts +6 -1
  77. package/dist/server/internal/handlers/webAuthn.d.ts.map +1 -1
  78. package/dist/server/internal/handlers/webAuthn.js +16 -9
  79. package/dist/server/internal/handlers/webAuthn.js.map +1 -1
  80. package/package.json +3 -3
  81. package/src/cli/{Provider.test.ts → Provider.localnet.test.ts} +49 -1
  82. package/src/cli/adapter.ts +95 -76
  83. package/src/core/AccessKey.test.ts +563 -560
  84. package/src/core/AccessKey.ts +327 -327
  85. package/src/core/Account.test.ts +6 -51
  86. package/src/core/Account.ts +1 -19
  87. package/src/core/Adapter.ts +22 -4
  88. package/src/core/Client.test.ts +1 -1
  89. package/src/core/Client.ts +1 -1
  90. package/src/core/Provider.connect.browser.test.ts +1 -1
  91. package/src/core/{Provider.test.ts → Provider.localnet.test.ts} +98 -32
  92. package/src/core/Provider.ts +118 -112
  93. package/src/core/Remote.test.ts +28 -0
  94. package/src/core/Remote.ts +4 -1
  95. package/src/core/Schema.test-d.ts +41 -0
  96. package/src/core/adapters/dialog.test.ts +335 -15
  97. package/src/core/adapters/dialog.ts +120 -127
  98. package/src/core/adapters/local.test.ts +1 -1
  99. package/src/core/adapters/local.ts +95 -119
  100. package/src/core/adapters/privy.test-d.ts +128 -0
  101. package/src/core/adapters/privy.test.ts +648 -0
  102. package/src/core/adapters/privy.ts +811 -0
  103. package/src/core/adapters/turnkey.test.ts +117 -13
  104. package/src/core/adapters/turnkey.ts +132 -149
  105. package/src/core/internal/AccessKeyTransaction.ts +243 -0
  106. package/src/core/{mppx.test.ts → mppx.localnet.test.ts} +32 -2
  107. package/src/core/zod/request.test.ts +36 -0
  108. package/src/core/zod/rpc.test.ts +139 -0
  109. package/src/core/zod/rpc.ts +44 -10
  110. package/src/index.ts +1 -1
  111. package/src/react-native/{Provider.test.ts → Provider.localnet.test.ts} +65 -0
  112. package/src/react-native/adapter.ts +107 -87
  113. package/src/server/CliAuth.test-d.ts +23 -0
  114. package/src/server/CliAuth.test.ts +47 -0
  115. package/src/server/CliAuth.ts +20 -1
  116. package/src/server/Handler.test-d.ts +1 -1
  117. package/src/server/Kv.test.ts +51 -0
  118. package/src/server/Kv.ts +43 -9
  119. package/src/server/internal/handlers/auth.test.ts +10 -5
  120. package/src/server/internal/handlers/auth.ts +4 -1
  121. package/src/server/internal/handlers/codeAuth.ts +1 -1
  122. package/src/server/internal/handlers/exchange.ts +1 -1
  123. package/src/server/internal/handlers/{relay.test.ts → relay.localnet.test.ts} +4 -1
  124. package/src/server/internal/handlers/relay.ts +139 -14
  125. package/src/server/internal/handlers/session.test.ts +1 -1
  126. package/src/server/internal/handlers/webAuthn.test.ts +20 -0
  127. package/src/server/internal/handlers/webAuthn.ts +24 -12
  128. /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,36 +308,70 @@ 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
 
277
373
  describe('generate', () => {
278
- test('default: returns access key and key pair', async () => {
374
+ test('default: returns p256 access key and key pair', async () => {
279
375
  const result = await AccessKey.generate()
280
376
 
281
377
  expect(result.accessKey.address).toMatch(/^0x[0-9a-f]{40}$/i)
@@ -301,6 +397,22 @@ describe('prepareAuthorization', () => {
301
397
  expect(result.keyPair).toBeDefined()
302
398
  })
303
399
 
400
+ test('error: rejects secp256k1 authorization without external key material', async () => {
401
+ await expect(
402
+ AccessKey.prepareAuthorization({ chainId: 1, expiry: 123, keyType: 'secp256k1' }),
403
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
404
+ `[RpcResponse.InvalidParamsError: \`keyType: "secp256k1"\` requires externally generated key material; provide \`publicKey\` or \`address\`.]`,
405
+ )
406
+ })
407
+
408
+ test('error: rejects webAuthn authorization without external key material', async () => {
409
+ await expect(
410
+ AccessKey.prepareAuthorization({ chainId: 1, expiry: 123, keyType: 'webAuthn' }),
411
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
412
+ `[RpcResponse.InvalidParamsError: \`keyType: "webAuthn"\` requires externally generated key material; provide \`publicKey\` or \`address\`.]`,
413
+ )
414
+ })
415
+
304
416
  test('behavior: prepares external key authorization from address', async () => {
305
417
  const result = await AccessKey.prepareAuthorization({
306
418
  address: accounts[1]!.address,
@@ -374,6 +486,27 @@ describe('prepareAuthorization', () => {
374
486
  `)
375
487
  })
376
488
 
489
+ test('behavior: prepares external secp256k1 authorization from public key', async () => {
490
+ const result = await AccessKey.prepareAuthorization({
491
+ chainId: 123n,
492
+ expiry: 456,
493
+ keyType: 'secp256k1',
494
+ publicKey: accounts[1]!.publicKey,
495
+ })
496
+
497
+ expect(result.keyPair).toBeUndefined()
498
+ expect(result.keyAuthorization).toMatchInlineSnapshot(`
499
+ {
500
+ "address": "${accounts[1]!.address.toLowerCase()}",
501
+ "chainId": 123n,
502
+ "expiry": 456,
503
+ "limits": undefined,
504
+ "scopes": undefined,
505
+ "type": "secp256k1",
506
+ }
507
+ `)
508
+ })
509
+
377
510
  test('behavior: defaults external key type to secp256k1', async () => {
378
511
  const result = await AccessKey.prepareAuthorization({
379
512
  address: accounts[1]!.address,
@@ -385,23 +518,34 @@ describe('prepareAuthorization', () => {
385
518
  })
386
519
  })
387
520
 
388
- describe('saveAuthorization', () => {
389
- test('default: saves prepared authorization with provided signature', async () => {
521
+ describe('authorize', () => {
522
+ test('default: prepares, signs, and saves authorization', async () => {
390
523
  const store = createStore()
391
- const prepared = await AccessKey.prepareAuthorization({
392
- address: accounts[1]!.address,
393
- chainId: 1,
394
- expiry: 123,
395
- })
524
+ const digests: Hex.Hex[] = []
396
525
  const signature = `0x${'11'.repeat(32)}${'22'.repeat(32)}1b` as const
526
+ const account = {
527
+ ...accounts[0]!,
528
+ sign: async (parameters: { hash: Hex.Hex }) => {
529
+ digests.push(parameters.hash)
530
+ return signature
531
+ },
532
+ } as TempoAccount.Account
397
533
 
398
- const result = AccessKey.saveAuthorization({
399
- address: rootAddress,
400
- prepared,
401
- signature,
534
+ const result = await AccessKey.authorize({
535
+ account,
536
+ chainId: 1,
537
+ parameters: {
538
+ address: accounts[1]!.address,
539
+ expiry: 123,
540
+ },
402
541
  store,
403
542
  })
404
543
 
544
+ expect(digests).toMatchInlineSnapshot(`
545
+ [
546
+ "0xea47721547363fc82a5dca62b4544e4718d861b3df10bfac65d30102594b5c26",
547
+ ]
548
+ `)
405
549
  expect(result).toMatchInlineSnapshot(`
406
550
  {
407
551
  "chainId": "0x1",
@@ -434,448 +578,298 @@ describe('saveAuthorization', () => {
434
578
  })
435
579
  })
436
580
 
437
- describe('authorize', () => {
438
- test('default: prepares, signs, and saves authorization', async () => {
581
+ describe('select', () => {
582
+ async function setup(options: { pending?: boolean | undefined } = {}) {
439
583
  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
584
+ const keyPair = await WebCryptoP256.createKeyPair()
585
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
586
+ const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
449
587
 
450
- await AccessKey.authorize({
451
- account,
452
- chainId: 1,
453
- parameters: {
454
- address: accounts[1]!.address,
455
- expiry: 123,
456
- },
588
+ addAuthorization({
589
+ address: rootAddress,
590
+ keyAuthorization,
591
+ keyPair,
592
+ state: options.pending ? 'pending' : 'signed',
457
593
  store,
458
594
  })
459
595
 
460
- expect(digests).toMatchInlineSnapshot(`
461
- [
462
- "0xea47721547363fc82a5dca62b4544e4718d861b3df10bfac65d30102594b5c26",
463
- ]
464
- `)
465
- expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`)
466
- })
467
- })
596
+ return { accessKey, keyAuthorization, store }
597
+ }
468
598
 
469
- describe('hydrate', () => {
470
- test('default: hydrates webCrypto access key to signable account', async () => {
599
+ test('behavior: skips access keys for another root address', async () => {
600
+ const store = createStore()
471
601
  const keyPair = await WebCryptoP256.createKeyPair()
472
- const result = AccessKey.hydrate({
473
- access: rootAddress,
474
- address: '0x0000000000000000000000000000000000000099',
475
- chainId: 1,
602
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: accounts[1]!.address })
603
+ addAuthorization({
604
+ address: accounts[1]!.address,
605
+ keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress),
476
606
  keyPair,
477
- keyType: 'webCrypto',
607
+ state: 'signed',
608
+ store,
478
609
  })
479
610
 
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,
611
+ const result = await AccessKey.select({
612
+ account: rootAddress,
489
613
  chainId: 1,
490
- keyType: 'secp256k1',
491
- privateKey: privateKeys[1],
614
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
615
+ store,
492
616
  })
493
617
 
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
618
  expect(result).toMatchInlineSnapshot(`undefined`)
552
619
  })
553
620
 
554
621
  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
- })
622
+ const { accessKey, store } = await setup()
570
623
 
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
- ])
580
-
581
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 1, store })
624
+ const result = await AccessKey.select({
625
+ account: rootAddress,
626
+ chainId: 42_431,
627
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
628
+ store,
629
+ })
582
630
 
583
631
  expect(result).toMatchInlineSnapshot(`undefined`)
584
632
  })
585
633
 
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
- ])
634
+ test('behavior: skips external access keys without signer material', async () => {
635
+ const store = createStore()
636
+ const keyAuthorization = createKeyAuthorization('0x0000000000000000000000000000000000000099')
637
+ addAuthorization({
638
+ address: rootAddress,
639
+ keyAuthorization,
640
+ state: 'signed',
641
+ store,
642
+ })
598
643
 
599
- const result = AccessKey.selectAccount({ address: rootAddress, chainId: 1, store })
644
+ const result = await AccessKey.select({
645
+ account: rootAddress,
646
+ chainId: 1,
647
+ client: createMetadataClient(keyAuthorization.address) as never,
648
+ store,
649
+ })
600
650
 
601
651
  expect(result).toMatchInlineSnapshot(`undefined`)
602
- expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`)
603
652
  })
604
653
 
605
- test('behavior: keeps future-expiring access key', async () => {
654
+ test('behavior: matches access key scopes against transaction calls', async () => {
655
+ const store = createStore()
606
656
  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 })
657
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
658
+ const token = '0x0000000000000000000000000000000000000abc' as const
659
+ addAuthorization({
660
+ address: rootAddress,
661
+ keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress, {
662
+ scopes: [{ address: token, selector: 'transfer(address,uint256)' }],
663
+ }),
664
+ keyPair,
665
+ state: 'signed',
666
+ store,
667
+ })
619
668
 
620
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
621
- expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`)
622
- })
669
+ const match = await AccessKey.select({
670
+ account: rootAddress,
671
+ calls: [{ to: token, data: '0xa9059cbb0000000000000000000000000000000000000001' }],
672
+ chainId: 1,
673
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
674
+ store,
675
+ })
676
+ const miss = await AccessKey.select({
677
+ account: rootAddress,
678
+ calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }],
679
+ chainId: 1,
680
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
681
+ store,
682
+ })
623
683
 
624
- test('behavior: preserves limits on selected access key', async () => {
625
- const keyPair = await WebCryptoP256.createKeyPair()
626
- const store = setup([
684
+ expect({ match: !!match, miss: !!miss }).toMatchInlineSnapshot(`
627
685
  {
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
- ]
686
+ "match": true,
687
+ "miss": false,
688
+ }
652
689
  `)
653
690
  })
691
+ })
654
692
 
655
- test('behavior: unscoped access key selects with calls', async () => {
693
+ describe('create', () => {
694
+ async function setup(options: { pending?: boolean | undefined } = {}) {
695
+ const store = createStore()
656
696
  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
- ])
697
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
698
+ const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
666
699
 
667
- const result = AccessKey.selectAccount({
700
+ addAuthorization({
668
701
  address: rootAddress,
669
- chainId: 1,
702
+ keyAuthorization,
703
+ keyPair,
704
+ state: options.pending ? 'pending' : 'signed',
670
705
  store,
671
- calls: [{ to: '0x0000000000000000000000000000000000000abc', data: '0xa9059cbb' }],
672
706
  })
673
707
 
674
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
675
- })
708
+ return { accessKey, keyAuthorization, store }
709
+ }
676
710
 
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
- ])
711
+ test('behavior: returns undefined when no matching access key exists', async () => {
712
+ const store = createStore()
713
+ const { client } = createFillClient(accounts[1]!.address)
690
714
 
691
- const result = AccessKey.selectAccount({
715
+ const result = await AccessKeyTransaction.create({
692
716
  address: rootAddress,
693
717
  chainId: 1,
718
+ client: client as never,
694
719
  store,
695
- calls: [{ to: token, data: '0xa9059cbb0000000000000000000000000000000000000001' }],
696
720
  })
697
721
 
698
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
722
+ expect(result).toMatchInlineSnapshot(`undefined`)
699
723
  })
700
724
 
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
- ])
725
+ test('default: returns selected account with pending key authorization', async () => {
726
+ const { keyAuthorization, store } = await setup()
727
+ const { client, requests } = createFillClient(keyAuthorization.address)
714
728
 
715
- const result = AccessKey.selectAccount({
729
+ const result = await AccessKeyTransaction.create({
716
730
  address: rootAddress,
717
731
  chainId: 1,
732
+ client: client as never,
718
733
  store,
719
- calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }],
720
734
  })
735
+ await result?.fill({ chainId: 1, from: rootAddress })
721
736
 
722
- expect(result).toMatchInlineSnapshot(`undefined`)
737
+ expect(!!result).toMatchInlineSnapshot(`true`)
738
+ const request = requests[0] as {
739
+ params: readonly [{ keyAuthorization?: { keyId?: string | undefined } | undefined }]
740
+ }
741
+ expect(request.params[0].keyAuthorization?.keyId).toBe(keyAuthorization.address)
723
742
  })
724
743
 
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({
744
+ test('behavior: signing prepared transaction marks key authorization pending', async () => {
745
+ const { accessKey, keyAuthorization, store } = await setup()
746
+ const result = await AccessKeyTransaction.create({
740
747
  address: rootAddress,
741
748
  chainId: 1,
749
+ client: { chain: { id: 1 } } as never,
742
750
  store,
743
- calls: [{ to: token, data: '0xa9059cbb0000000000000000000000000000000000000001' }],
744
751
  })
745
752
 
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,
753
+ const prepared = await result?.prepare({
754
+ from: rootAddress,
755
+ gas: 21_000n,
756
+ maxFeePerGas: 1n,
757
+ maxPriorityFeePerGas: 1n,
758
+ nonce: 0,
759
+ to: accounts[1]!.address,
760
+ value: 1n,
769
761
  })
762
+ await prepared?.sign()
763
+
764
+ const stored = getStored(accessKey, store)
765
+ expect(stored?.keyAuthorization).toBe(keyAuthorization)
766
+ expect(stored?.keyAuthorizationPending).toMatchInlineSnapshot(`true`)
767
+ })
770
768
 
771
- const result = AccessKey.selectAccount({
769
+ test('behavior: sending asynchronously keeps key authorization pending', async () => {
770
+ const { accessKey, keyAuthorization, store } = await setup()
771
+ const requests: unknown[] = []
772
+ const result = await AccessKeyTransaction.create({
772
773
  address: rootAddress,
773
774
  chainId: 1,
775
+ client: {
776
+ chain: { id: 1 },
777
+ request: async (request: unknown) => {
778
+ requests.push(request)
779
+ return '0xtransaction'
780
+ },
781
+ } as never,
774
782
  store,
775
- calls: [call],
776
783
  })
777
784
 
778
- expect(result?.source).toMatchInlineSnapshot(`"accessKey"`)
785
+ const prepared = await result?.prepare({
786
+ from: rootAddress,
787
+ gas: 21_000n,
788
+ maxFeePerGas: 1n,
789
+ maxPriorityFeePerGas: 1n,
790
+ nonce: 0,
791
+ to: accounts[1]!.address,
792
+ value: 1n,
793
+ })
794
+ await prepared?.send()
795
+
796
+ const stored = getStored(accessKey, store)
797
+ expect(stored?.keyAuthorization).toBe(keyAuthorization)
798
+ expect(stored?.keyAuthorizationPending).toMatchInlineSnapshot(`true`)
799
+ expect(requests.map((request) => (request as { method: string }).method))
800
+ .toMatchInlineSnapshot(`
801
+ [
802
+ "eth_fillTransaction",
803
+ "eth_sendRawTransaction",
804
+ ]
805
+ `)
779
806
  })
780
807
 
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
- })
808
+ test('behavior: clears pending authorization when pending key is published on-chain', async () => {
809
+ const { accessKey, store } = await setup({ pending: true })
805
810
 
806
- const result = AccessKey.selectAccount({
811
+ const result = await AccessKeyTransaction.create({
807
812
  address: rootAddress,
808
813
  chainId: 1,
814
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
809
815
  store,
810
- calls: [call],
811
816
  })
812
817
 
813
- expect(result).toMatchInlineSnapshot(`undefined`)
818
+ expect(!!result).toMatchInlineSnapshot(`true`)
819
+ expect(store.getState().accessKeys[0]!.keyAuthorization).toMatchInlineSnapshot(`undefined`)
820
+ expect(store.getState().accessKeys[0]!.keyAuthorizationPending).toMatchInlineSnapshot(
821
+ `undefined`,
822
+ )
814
823
  })
815
824
 
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
- ])
825
+ test('behavior: reuses pending authorization when direct key check is missing', async () => {
826
+ const { accessKey, keyAuthorization, store } = await setup({ pending: true })
827
+ const { client, requests } = createFillClient(accessKey.accessKeyAddress, {
828
+ keyId: accounts[1]!.address,
829
+ })
828
830
 
829
- const result = AccessKey.selectAccount({
831
+ const result = await AccessKeyTransaction.create({
830
832
  address: rootAddress,
831
833
  chainId: 1,
834
+ client: client as never,
832
835
  store,
833
- calls: [{ to: '0x0000000000000000000000000000000000000abc', data: '0xa9059cbb' }],
834
836
  })
837
+ await result?.fill({ chainId: 1, from: rootAddress })
835
838
 
836
- expect(result).toMatchInlineSnapshot(`undefined`)
839
+ const request = requests[0] as {
840
+ params: readonly [{ keyAuthorization?: { keyId?: string | undefined } | undefined }]
841
+ }
842
+ expect(request.params[0].keyAuthorization?.keyId).toBe(keyAuthorization.address)
843
+ expect(store.getState().accessKeys[0]!.keyAuthorization).toBe(keyAuthorization)
844
+ expect(store.getState().accessKeys[0]!.keyAuthorizationPending).toMatchInlineSnapshot(`true`)
837
845
  })
838
846
 
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
- ])
847
+ test('behavior: reuses pending authorization when direct key check fails', async () => {
848
+ const { keyAuthorization, store } = await setup({ pending: true })
849
+ const requests: unknown[] = []
852
850
 
853
- const result = AccessKey.selectAccount({
851
+ const result = await AccessKeyTransaction.create({
854
852
  address: rootAddress,
855
853
  chainId: 1,
854
+ client: {
855
+ call: async () => {
856
+ throw new Error('network failed')
857
+ },
858
+ request: async (request: unknown) => {
859
+ requests.push(request)
860
+ return { capabilities: { sponsored: false }, tx: {} }
861
+ },
862
+ } as never,
856
863
  store,
857
- calls: [{ to: token, data: '0xdeadbeef' }],
858
864
  })
865
+ await result?.fill({ chainId: 1, from: rootAddress })
859
866
 
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`)
867
+ const request = requests[0] as {
868
+ params: readonly [{ keyAuthorization?: { keyId?: string | undefined } | undefined }]
869
+ }
870
+ expect(request.params[0].keyAuthorization?.keyId).toBe(keyAuthorization.address)
871
+ expect(store.getState().accessKeys[0]!.keyAuthorization).toBe(keyAuthorization)
872
+ expect(store.getState().accessKeys[0]!.keyAuthorizationPending).toMatchInlineSnapshot(`true`)
879
873
  })
880
874
  })
881
875
 
@@ -886,11 +880,18 @@ describe('getStatus', () => {
886
880
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
887
881
  const keyAuthorization = createKeyAuthorization(accessKey.address)
888
882
 
889
- AccessKey.save({ address: rootAddress, keyAuthorization, keyPair, store })
883
+ addAuthorization({
884
+ address: rootAddress,
885
+ keyAuthorization,
886
+ keyPair,
887
+ state: 'signed',
888
+ store,
889
+ })
890
890
 
891
891
  const result = await AccessKey.getStatus({
892
- address: rootAddress,
892
+ account: rootAddress,
893
893
  chainId: 1,
894
+ client: createMetadataClient(accessKey.address) as never,
894
895
  store,
895
896
  })
896
897
 
@@ -903,96 +904,98 @@ describe('getStatus', () => {
903
904
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
904
905
  const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
905
906
 
906
- AccessKey.save({ address: rootAddress, keyAuthorization, keyPair, store })
907
- AccessKey.removePending(accessKey, { store })
907
+ addAuthorization({
908
+ address: rootAddress,
909
+ keyAuthorization,
910
+ keyPair,
911
+ state: 'signed',
912
+ store,
913
+ })
914
+ markPublished({ accessKey: accessKey.accessKeyAddress, store })
908
915
 
909
916
  const result = await AccessKey.getStatus({
910
- address: rootAddress,
917
+ account: rootAddress,
911
918
  chainId: 1,
919
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
912
920
  store,
913
921
  })
914
922
 
915
923
  expect(result).toMatchInlineSnapshot(`"published"`)
916
924
  })
917
925
 
918
- test('behavior: returns expired for expired local key', async () => {
926
+ test('behavior: checks pending authorization before returning pending', async () => {
919
927
  const store = createStore()
920
928
  const keyPair = await WebCryptoP256.createKeyPair()
921
- const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
922
- const keyAuthorization = createKeyAuthorization(accessKey.address, { expiry: 100 })
929
+ const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress })
930
+ const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress)
923
931
 
924
- AccessKey.save({ address: rootAddress, keyAuthorization, keyPair, store })
932
+ addAuthorization({
933
+ address: rootAddress,
934
+ keyAuthorization,
935
+ keyPair,
936
+ state: 'pending',
937
+ store,
938
+ })
925
939
 
926
940
  const result = await AccessKey.getStatus({
927
- address: rootAddress,
941
+ account: rootAddress,
928
942
  chainId: 1,
929
- now: 101,
943
+ client: createMetadataClient(accessKey.accessKeyAddress) as never,
930
944
  store,
931
945
  })
932
946
 
933
- expect(result).toMatchInlineSnapshot(`"expired"`)
947
+ expect(result).toMatchInlineSnapshot(`"published"`)
934
948
  })
935
949
 
936
- test('behavior: returns missing when no local key matches the policy', async () => {
950
+ test('behavior: returns expired for expired local key', async () => {
937
951
  const store = createStore()
938
952
  const keyPair = await WebCryptoP256.createKeyPair()
939
953
  const accessKey = TempoAccount.fromWebCryptoP256(keyPair)
940
- const keyAuthorization = createKeyAuthorization(accessKey.address, {
941
- scopes: [{ address: '0x0000000000000000000000000000000000000abc' }],
942
- })
954
+ const keyAuthorization = createKeyAuthorization(accessKey.address, { expiry: 100 })
943
955
 
944
- AccessKey.save({ address: rootAddress, keyAuthorization, keyPair, store })
956
+ addAuthorization({
957
+ address: rootAddress,
958
+ keyAuthorization,
959
+ keyPair,
960
+ state: 'signed',
961
+ store,
962
+ })
945
963
 
946
964
  const result = await AccessKey.getStatus({
947
- address: rootAddress,
948
- calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }],
965
+ account: rootAddress,
949
966
  chainId: 1,
967
+ client: createMetadataClient(accessKey.address) as never,
968
+ now: 101,
950
969
  store,
951
970
  })
952
971
 
953
- expect(result).toMatchInlineSnapshot(`"missing"`)
972
+ expect(result).toMatchInlineSnapshot(`"expired"`)
954
973
  })
955
- })
956
974
 
957
- describe('revoke', () => {
958
- test('default: removes access keys by root address', async () => {
975
+ test('behavior: returns missing when no local key matches the policy', async () => {
959
976
  const store = createStore()
960
977
  const keyPair = await WebCryptoP256.createKeyPair()
961
978
  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)
979
+ const keyAuthorization = createKeyAuthorization(accessKey.address, {
980
+ scopes: [{ address: '0x0000000000000000000000000000000000000abc' }],
981
+ })
979
982
 
980
- AccessKey.save({
983
+ addAuthorization({
981
984
  address: rootAddress,
982
- keyAuthorization: createKeyAuthorization(ak1.address),
985
+ keyAuthorization,
986
+ keyPair,
987
+ state: 'signed',
983
988
  store,
984
989
  })
985
- AccessKey.save({
986
- address: otherRoot,
987
- keyAuthorization: createKeyAuthorization(ak2.address),
990
+
991
+ const result = await AccessKey.getStatus({
992
+ account: rootAddress,
993
+ calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }],
994
+ chainId: 1,
995
+ client: createMissingClient() as never,
988
996
  store,
989
997
  })
990
998
 
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)
999
+ expect(result).toMatchInlineSnapshot(`"missing"`)
997
1000
  })
998
1001
  })