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
@@ -0,0 +1,634 @@
1
+ import { Address as core_Address, Hex, Secp256k1, Signature } from 'ox'
2
+ import { decodeFunctionData } from 'viem'
3
+ import type { Address } from 'viem/accounts'
4
+ import { Abis } from 'viem/tempo'
5
+ import { describe, expect, test } from 'vp/test'
6
+
7
+ import * as Storage from '../Storage.js'
8
+ import * as Store from '../Store.js'
9
+ import { privy } from './privy.js'
10
+
11
+ // Deterministic test keys so addresses and signatures are reproducible across
12
+ // runs. Real signing is required by upcoming signer-recovery validation, and
13
+ // keeps the mocks honest about what the production adapter sees from Privy.
14
+ const privateKeyA = Hex.padLeft('0x01', 32)
15
+ const privateKeyB = Hex.padLeft('0x02', 32)
16
+ const address = core_Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: privateKeyA }))
17
+ const other = core_Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: privateKeyB }))
18
+
19
+ function signWithKey(privateKey: Hex.Hex, payload: Hex.Hex): Hex.Hex {
20
+ const signature = Secp256k1.sign({ payload, privateKey })
21
+ return Signature.toHex(signature)
22
+ }
23
+
24
+ function privateKeyForAddress(walletAddress: string): Hex.Hex {
25
+ if (core_Address.from(walletAddress) === address) return privateKeyA
26
+ if (core_Address.from(walletAddress) === other) return privateKeyB
27
+ throw new Error(`No test private key for ${walletAddress}`)
28
+ }
29
+
30
+ describe('privy', () => {
31
+ test('default: createAccount delegates registration and signs the requested digest', async () => {
32
+ const { adapter, client } = setup()
33
+
34
+ const result = await adapter.actions.createAccount(
35
+ { digest: '0x1234', name: 'Ada' },
36
+ { method: 'wallet_connect', params: undefined },
37
+ )
38
+
39
+ expect(client.initCalls).toMatchInlineSnapshot(`1`)
40
+ expect(client.signPayloads).toMatchInlineSnapshot(`
41
+ [
42
+ "0x1234",
43
+ ]
44
+ `)
45
+ expect(result).toMatchInlineSnapshot(`
46
+ {
47
+ "accounts": [
48
+ {
49
+ "address": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
50
+ "label": "Ada",
51
+ },
52
+ ],
53
+ "signature": "0xced9d002f487622c7e218274065c327bdfe274ea7da91349bb48fe7c4495baeb71cc6b2f9b3d5f34e5b404cec0ed0dcb085f990a7b7a7f4cb81a5e8abb76aa981b",
54
+ }
55
+ `)
56
+ })
57
+
58
+ test('default: createAccount falls back to loadAccounts when not provided', async () => {
59
+ const { adapter, client } = setup({ createAccount: false })
60
+
61
+ const result = await adapter.actions.createAccount(
62
+ { digest: '0x1234', name: 'Ada' },
63
+ { method: 'wallet_connect', params: undefined },
64
+ )
65
+
66
+ expect(client.createCalls).toMatchInlineSnapshot(`0`)
67
+ expect(client.loadCalls).toMatchInlineSnapshot(`1`)
68
+ expect(client.signPayloads).toMatchInlineSnapshot(`
69
+ [
70
+ "0x1234",
71
+ ]
72
+ `)
73
+ expect(result.accounts).toMatchInlineSnapshot(`
74
+ [
75
+ {
76
+ "address": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
77
+ "label": "Ada",
78
+ },
79
+ ]
80
+ `)
81
+ })
82
+
83
+ test('default: loadAccounts delegates login and caches embedded wallets for signing', async () => {
84
+ const { adapter, client } = setup()
85
+
86
+ await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined })
87
+ const result = await adapter.actions.signPersonalMessage(
88
+ { address, data: '0x68656c6c6f' },
89
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
90
+ )
91
+
92
+ expect(client.loadCalls).toMatchInlineSnapshot(`1`)
93
+ expect(client.signWith).toMatchInlineSnapshot(`
94
+ [
95
+ "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
96
+ ]
97
+ `)
98
+ expect(result).toMatchInlineSnapshot(
99
+ `"0xe5ddc160e4c8f92de507c7db9b982d4f9b7197bfa421864aeadc586bc96b09ae0ba0c5b131650ae4994cff1839341d00f3735ef5abc62ac8fe2cf50f65208e2a1b"`,
100
+ )
101
+ })
102
+
103
+ test('default: loadAccounts can provision an external access key', async () => {
104
+ const { adapter, client } = setup()
105
+
106
+ const result = await adapter.actions.loadAccounts(
107
+ {
108
+ authorizeAccessKey: {
109
+ address: other,
110
+ expiry: 123,
111
+ keyType: 'secp256k1',
112
+ },
113
+ },
114
+ { method: 'wallet_connect', params: undefined },
115
+ )
116
+
117
+ expect(client.signPayloads).toMatchInlineSnapshot(`
118
+ [
119
+ "0xe77ac2b1d13a90cbd8c4912ff18d0d044cc89c5c6781941001640b8d251f3783",
120
+ ]
121
+ `)
122
+ expect(result).toMatchInlineSnapshot(`
123
+ {
124
+ "accounts": [
125
+ {
126
+ "address": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
127
+ },
128
+ ],
129
+ "keyAuthorization": {
130
+ "chainId": "0x1",
131
+ "expiry": "0x7b",
132
+ "keyId": "0x2b5ad5c4795c026514f8317c7a215e218dccd6cf",
133
+ "keyType": "secp256k1",
134
+ "limits": undefined,
135
+ "signature": {
136
+ "r": "0xb364cd8e50555239adf9f7d655b018ea386764d44ed9b56e894f4a101f0b1a6b",
137
+ "s": "0x4910cc8497358eb73a08df09c9cfb2618e3c949b3847ab310ad7ab0d76a9c624",
138
+ "type": "secp256k1",
139
+ "yParity": "0x1",
140
+ },
141
+ },
142
+ "signature": undefined,
143
+ }
144
+ `)
145
+ })
146
+
147
+ test('default: authorizeAccessKey signs with the connected Privy account', async () => {
148
+ const { adapter, client, store } = setup()
149
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
150
+
151
+ const result = await adapter.actions.authorizeAccessKey!(
152
+ {
153
+ address: other,
154
+ expiry: 123,
155
+ keyType: 'secp256k1',
156
+ },
157
+ { method: 'wallet_authorizeAccessKey', params: [{ expiry: 123 }] },
158
+ )
159
+
160
+ expect(client.loadCalls).toMatchInlineSnapshot(`0`)
161
+ expect(client.restoreCalls).toMatchInlineSnapshot(`1`)
162
+ expect(result).toMatchInlineSnapshot(`
163
+ {
164
+ "keyAuthorization": {
165
+ "chainId": "0x1",
166
+ "expiry": "0x7b",
167
+ "keyId": "0x2b5ad5c4795c026514f8317c7a215e218dccd6cf",
168
+ "keyType": "secp256k1",
169
+ "limits": undefined,
170
+ "signature": {
171
+ "r": "0xb364cd8e50555239adf9f7d655b018ea386764d44ed9b56e894f4a101f0b1a6b",
172
+ "s": "0x4910cc8497358eb73a08df09c9cfb2618e3c949b3847ab310ad7ab0d76a9c624",
173
+ "type": "secp256k1",
174
+ "yParity": "0x1",
175
+ },
176
+ },
177
+ "rootAddress": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
178
+ }
179
+ `)
180
+ })
181
+
182
+ test('default: revokeAccessKey revokes with the connected Privy account', async () => {
183
+ const { adapter, client, store } = setup()
184
+ store.setState({
185
+ accounts: [{ address }],
186
+ activeAccount: 0,
187
+ accessKeys: [
188
+ {
189
+ access: address,
190
+ address: other,
191
+ chainId: 1,
192
+ keyType: 'secp256k1',
193
+ } as never,
194
+ ],
195
+ })
196
+
197
+ await adapter.actions.revokeAccessKey!(
198
+ { accessKeyAddress: other, address },
199
+ { method: 'wallet_revokeAccessKey', params: [{ accessKeyAddress: other, address }] },
200
+ )
201
+
202
+ const transaction = client.transactions[0] as
203
+ | { account: { address: Address }; data: Hex.Hex; to: Address }
204
+ | undefined
205
+ const decoded = transaction
206
+ ? decodeFunctionData({ abi: Abis.accountKeychain, data: transaction.data })
207
+ : undefined
208
+ expect(
209
+ transaction &&
210
+ decoded && {
211
+ account: transaction.account.address,
212
+ args: decoded.args,
213
+ functionName: decoded.functionName,
214
+ to: transaction.to,
215
+ },
216
+ ).toMatchInlineSnapshot(`
217
+ {
218
+ "account": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
219
+ "args": [
220
+ "0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF",
221
+ ],
222
+ "functionName": "revokeKey",
223
+ "to": "0xaAAAaaAA00000000000000000000000000000000",
224
+ }
225
+ `)
226
+ expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`)
227
+ })
228
+
229
+ test('behavior: signing silently restores wallet accounts via the Privy SDK', async () => {
230
+ const { adapter, client, store } = setup()
231
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
232
+
233
+ await adapter.actions.signPersonalMessage(
234
+ { address, data: '0x68656c6c6f' },
235
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
236
+ )
237
+
238
+ expect(client.restoreCalls).toMatchInlineSnapshot(`1`)
239
+ expect(client.loadCalls).toMatchInlineSnapshot(`0`)
240
+ })
241
+
242
+ test('behavior: silent restore does not connect accounts when the provider store is empty', async () => {
243
+ const { adapter, client } = setup()
244
+
245
+ await expect(
246
+ adapter.actions.signPersonalMessage(
247
+ { address, data: '0x68656c6c6f' },
248
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
249
+ ),
250
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: No Privy account connected.]')
251
+
252
+ expect(client.loadCalls).toMatchInlineSnapshot(`0`)
253
+ expect(client.restoreCalls).toMatchInlineSnapshot(`0`)
254
+ expect(client.signPayloads).toMatchInlineSnapshot(`[]`)
255
+ })
256
+
257
+ test('behavior: silent restore only reconnects persisted provider accounts', async () => {
258
+ const { adapter, client, store } = setup()
259
+ client.addWallet(other)
260
+ store.setState({ accounts: [{ address: other }], activeAccount: 0 })
261
+
262
+ await adapter.actions.signPersonalMessage(
263
+ { address: other, data: '0x68656c6c6f' },
264
+ { method: 'personal_sign', params: ['0x68656c6c6f', other] },
265
+ )
266
+
267
+ expect(client.signWith).toMatchInlineSnapshot(`
268
+ [
269
+ "0x2b5ad5c4795c026514f8317c7a215e218dccd6cf",
270
+ ]
271
+ `)
272
+ expect(store.getState().accounts).toMatchInlineSnapshot(`
273
+ [
274
+ {
275
+ "address": "0x2b5ad5c4795c026514f8317c7a215e218dccd6cf",
276
+ },
277
+ ]
278
+ `)
279
+ })
280
+
281
+ test('behavior: expired sessions clear provider accounts', async () => {
282
+ const { adapter, client, store } = setup({ token: null })
283
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
284
+
285
+ await expect(
286
+ adapter.actions.signPersonalMessage(
287
+ { address, data: '0x68656c6c6f' },
288
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
289
+ ),
290
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
291
+
292
+ expect(client.signPayloads).toMatchInlineSnapshot(`[]`)
293
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
294
+ })
295
+
296
+ test('behavior: server session errors clear provider accounts', async () => {
297
+ const { adapter, store } = setup({ signError: { code: 'embedded_wallet_request_error' } })
298
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
299
+
300
+ await expect(
301
+ adapter.actions.signPersonalMessage(
302
+ { address, data: '0x68656c6c6f' },
303
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
304
+ ),
305
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
306
+
307
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
308
+ })
309
+
310
+ test('behavior: session errors are recognized via fuzzy code match', async () => {
311
+ const { adapter, store } = setup({ signError: { code: 'session_invalid_token' } })
312
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
313
+
314
+ await expect(
315
+ adapter.actions.signPersonalMessage(
316
+ { address, data: '0x68656c6c6f' },
317
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
318
+ ),
319
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
320
+
321
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
322
+ })
323
+
324
+ test('behavior: session errors are recognized via nested cause messages', async () => {
325
+ const inner = new Error('User must be logged in to sign.')
326
+ const outer = new Error('Wallet operation failed', { cause: inner })
327
+ const { adapter, store } = setup({ signError: outer })
328
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
329
+
330
+ await expect(
331
+ adapter.actions.signPersonalMessage(
332
+ { address, data: '0x68656c6c6f' },
333
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
334
+ ),
335
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
336
+
337
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
338
+ })
339
+
340
+ test('behavior: session errors are recognized via message fallback', async () => {
341
+ const { adapter, store } = setup({
342
+ signError: Object.assign(new Error('User must be logged in to sign.'), {}),
343
+ })
344
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
345
+
346
+ await expect(
347
+ adapter.actions.signPersonalMessage(
348
+ { address, data: '0x68656c6c6f' },
349
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
350
+ ),
351
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
352
+
353
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
354
+ })
355
+
356
+ test('behavior: silent restore clears stale persisted accounts when Privy no longer has them', async () => {
357
+ const { adapter, client, store } = setup()
358
+ // Persisted address that is NOT linked on the Privy user.
359
+ store.setState({ accounts: [{ address: other }], activeAccount: 0 })
360
+
361
+ await expect(
362
+ adapter.actions.signPersonalMessage(
363
+ { address: other, data: '0x68656c6c6f' },
364
+ { method: 'personal_sign', params: ['0x68656c6c6f', other] },
365
+ ),
366
+ ).rejects.toMatchInlineSnapshot(
367
+ '[Provider.DisconnectedError: Privy session no longer matches persisted accounts.]',
368
+ )
369
+
370
+ expect(client.signPayloads).toMatchInlineSnapshot(`[]`)
371
+ // Stale persisted accounts are wiped so the adapter and store agree.
372
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
373
+ })
374
+
375
+ test('error: silent restore rejects non-hex secp256k1_sign results', async () => {
376
+ const { adapter, store } = setup({ signResult: 'not-hex' })
377
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
378
+
379
+ await expect(
380
+ adapter.actions.signPersonalMessage(
381
+ { address, data: '0x68656c6c6f' },
382
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
383
+ ),
384
+ ).rejects.toMatchInlineSnapshot(
385
+ '[ProviderRpcError: Privy provider returned a non-hex secp256k1_sign result.]',
386
+ )
387
+ })
388
+
389
+ test('error: app-returned wallet with malformed address is rejected at connect', async () => {
390
+ const { adapter, client } = setup()
391
+ client.wallets = [client.makeWallet('0xnot-an-address')]
392
+
393
+ await expect(
394
+ adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined }),
395
+ ).rejects.toThrowError(/Address.*invalid/i)
396
+ })
397
+
398
+ test('error: malformed secp256k1_sign result is rejected by signer recovery', async () => {
399
+ const { adapter } = setup({ signResult: '0x1234' })
400
+ await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined })
401
+
402
+ await expect(
403
+ adapter.actions.signPersonalMessage(
404
+ { address, data: '0x68656c6c6f' },
405
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
406
+ ),
407
+ ).rejects.toMatchInlineSnapshot(
408
+ `[Provider.UnauthorizedError: Privy provider returned a signature for "unknown" that does not match the requested wallet "${address}".]`,
409
+ )
410
+ })
411
+
412
+ test('error: signing for an unconnected address while others are connected throws Unauthorized', async () => {
413
+ const { adapter } = setup()
414
+ await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined })
415
+
416
+ await expect(
417
+ adapter.actions.signPersonalMessage(
418
+ { address: other, data: '0x68656c6c6f' },
419
+ { method: 'personal_sign', params: ['0x68656c6c6f', other] },
420
+ ),
421
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
422
+ `[Provider.UnauthorizedError: Account "${other}" not found.]`,
423
+ )
424
+ })
425
+
426
+ test('error: unsupported secp256k1_sign maps to UnsupportedMethodError', async () => {
427
+ const { adapter, store } = setup({ signError: { code: 4200, message: 'Method not supported' } })
428
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
429
+
430
+ await expect(
431
+ adapter.actions.signPersonalMessage(
432
+ { address, data: '0x68656c6c6f' },
433
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
434
+ ),
435
+ ).rejects.toMatchInlineSnapshot(
436
+ '[Provider.UnsupportedMethodError: Privy adapter requires raw secp256k1 hash signing via `secp256k1_sign` for Tempo transactions and access keys.]',
437
+ )
438
+ })
439
+
440
+ test('disconnect: clears provider accounts and logs the user out of Privy', async () => {
441
+ const { adapter, client, store } = setup()
442
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
443
+
444
+ await adapter.actions.disconnect!()
445
+
446
+ expect(client.logoutCalls).toMatchInlineSnapshot(`1`)
447
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
448
+ })
449
+
450
+ test('behavior: restore surfaces silent-restore session errors as `Privy session expired.`', async () => {
451
+ const { adapter, store } = setup({
452
+ restoreError: Object.assign(new Error('boom'), { code: 'session_expired' }),
453
+ })
454
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
455
+
456
+ await expect(
457
+ adapter.actions.signPersonalMessage(
458
+ { address, data: '0x68656c6c6f' },
459
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
460
+ ),
461
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
462
+
463
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
464
+ })
465
+
466
+ test('error: signature recovered from a different key is rejected as Unauthorized', async () => {
467
+ const { adapter } = setup({ signWithPrivateKey: privateKeyB })
468
+ await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined })
469
+
470
+ await expect(
471
+ adapter.actions.signPersonalMessage(
472
+ { address, data: '0x68656c6c6f' },
473
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
474
+ ),
475
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
476
+ `[Provider.UnauthorizedError: Privy provider returned a signature for "${other}" that does not match the requested wallet "${address}".]`,
477
+ )
478
+ })
479
+ })
480
+
481
+ function setup(options: setup.Options = {}) {
482
+ const storage = Storage.memory()
483
+ const store = Store.create({ chainId: 1, storage })
484
+ const client = createClient(options)
485
+ const adapter = privy({
486
+ client,
487
+ ...(options.createAccount === false
488
+ ? {}
489
+ : {
490
+ createAccount: async () => {
491
+ client.createCalls++
492
+ return undefined
493
+ },
494
+ }),
495
+ loadAccounts: async () => {
496
+ client.loadCalls++
497
+ return undefined
498
+ },
499
+ })({
500
+ getAccount: (() => {
501
+ throw new Error('not implemented')
502
+ }) as never,
503
+ getClient: (() => ({
504
+ chain: { id: 1 },
505
+ sendTransaction: async (parameters: unknown) => {
506
+ client.transactions.push(parameters)
507
+ return Hex.padLeft('0x1', 32)
508
+ },
509
+ })) as never,
510
+ storage,
511
+ store,
512
+ })
513
+ return { adapter, client, store }
514
+ }
515
+
516
+ declare namespace setup {
517
+ type Options = {
518
+ /** Pass `false` to omit the adapter's `createAccount` callback (tests fallback to `loadAccounts`). */
519
+ createAccount?: false | undefined
520
+ /** Make the mock client's `user.get` throw, to test restore-side session errors. */
521
+ restoreError?: unknown
522
+ token?: string | null | undefined
523
+ signError?: unknown
524
+ /** Override the value returned by the embedded provider's `secp256k1_sign`. */
525
+ signResult?: unknown
526
+ /** Force the test wallet to sign with this private key (for wrong-signer tests). */
527
+ signWithPrivateKey?: Hex.Hex | undefined
528
+ }
529
+ }
530
+
531
+ type MockClient = privy.Client & {
532
+ createCalls: number
533
+ initCalls: number
534
+ loadCalls: number
535
+ logoutCalls: number
536
+ logoutWith: (string | undefined)[]
537
+ restoreCalls: number
538
+ signPayloads: Hex.Hex[]
539
+ signWith: string[]
540
+ transactions: unknown[]
541
+ wallets: privy.EmbeddedWallet[]
542
+ makeWallet: (address: string) => privy.EmbeddedWallet
543
+ addWallet: (address: string) => void
544
+ }
545
+
546
+ function createClient(options: setup.Options = {}) {
547
+ const client: MockClient = {
548
+ createCalls: 0,
549
+ initCalls: 0,
550
+ loadCalls: 0,
551
+ logoutCalls: 0,
552
+ logoutWith: [] as (string | undefined)[],
553
+ restoreCalls: 0,
554
+ signPayloads: [] as Hex.Hex[],
555
+ signWith: [] as string[],
556
+ transactions: [] as unknown[],
557
+ wallets: [] as privy.EmbeddedWallet[],
558
+ makeWallet(address: string): privy.EmbeddedWallet {
559
+ return {
560
+ address,
561
+ provider: {
562
+ async request(req: {
563
+ method: string
564
+ params?: readonly unknown[] | undefined
565
+ }): Promise<unknown> {
566
+ if (req.method !== 'secp256k1_sign') throw new Error(`unexpected method: ${req.method}`)
567
+ if (options.signError) throw options.signError
568
+ const hash = (req.params as readonly Hex.Hex[])[0] as Hex.Hex
569
+ client.signPayloads.push(hash)
570
+ client.signWith.push(address)
571
+ if (options.signResult !== undefined) return options.signResult
572
+ const privateKey =
573
+ options.signWithPrivateKey ??
574
+ (() => {
575
+ try {
576
+ return privateKeyForAddress(address)
577
+ } catch {
578
+ return privateKeyA
579
+ }
580
+ })()
581
+ return signWithKey(privateKey, hash)
582
+ },
583
+ },
584
+ }
585
+ },
586
+ /** Adds an embedded wallet so silent restore (`user.get`) returns it. */
587
+ addWallet(address: string) {
588
+ client.wallets.push(client.makeWallet(address))
589
+ },
590
+ auth: {
591
+ logout(parameters?: { userId: string } | undefined) {
592
+ client.logoutCalls++
593
+ client.logoutWith.push(parameters?.userId)
594
+ },
595
+ },
596
+ embeddedWallet: {
597
+ async getEthereumProvider({ wallet }) {
598
+ const existing = client.wallets.find(
599
+ (w) => core_Address.from(w.address) === core_Address.from(wallet.address as string),
600
+ )
601
+ return (existing ?? client.makeWallet(wallet.address as string)).provider
602
+ },
603
+ },
604
+ async getAccessToken() {
605
+ return options.token === undefined ? 'token' : options.token
606
+ },
607
+ initialize() {
608
+ client.initCalls++
609
+ },
610
+ user: {
611
+ async get() {
612
+ client.restoreCalls++
613
+ if (options.restoreError) throw options.restoreError
614
+ return {
615
+ user: {
616
+ id: 'user_1',
617
+ linked_accounts: client.wallets.map((wallet, index) => ({
618
+ address: wallet.address,
619
+ chain_type: 'ethereum',
620
+ connector_type: 'embedded',
621
+ type: 'wallet',
622
+ wallet_client_type: 'privy',
623
+ wallet_index: index,
624
+ })),
625
+ },
626
+ }
627
+ },
628
+ },
629
+ }
630
+
631
+ client.addWallet(address)
632
+
633
+ return client
634
+ }