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
@@ -0,0 +1,648 @@
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('error: secp256k1 access key requires external key material', async () => {
183
+ const { adapter, store } = setup()
184
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
185
+
186
+ await expect(
187
+ adapter.actions.authorizeAccessKey!(
188
+ { expiry: 123, keyType: 'secp256k1' },
189
+ { method: 'wallet_authorizeAccessKey', params: [{ expiry: 123, keyType: 'secp256k1' }] },
190
+ ),
191
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
192
+ `[RpcResponse.InvalidParamsError: \`keyType: "secp256k1"\` requires externally generated key material; provide \`publicKey\` or \`address\`.]`,
193
+ )
194
+ })
195
+
196
+ test('default: revokeAccessKey revokes with the connected Privy account', async () => {
197
+ const { adapter, client, store } = setup()
198
+ store.setState({
199
+ accounts: [{ address }],
200
+ activeAccount: 0,
201
+ accessKeys: [
202
+ {
203
+ access: address,
204
+ address: other,
205
+ chainId: 1,
206
+ keyType: 'secp256k1',
207
+ } as never,
208
+ ],
209
+ })
210
+
211
+ await adapter.actions.revokeAccessKey!(
212
+ { accessKeyAddress: other, address },
213
+ { method: 'wallet_revokeAccessKey', params: [{ accessKeyAddress: other, address }] },
214
+ )
215
+
216
+ const transaction = client.transactions[0] as
217
+ | { account: { address: Address }; data: Hex.Hex; to: Address }
218
+ | undefined
219
+ const decoded = transaction
220
+ ? decodeFunctionData({ abi: Abis.accountKeychain, data: transaction.data })
221
+ : undefined
222
+ expect(
223
+ transaction &&
224
+ decoded && {
225
+ account: transaction.account.address,
226
+ args: decoded.args,
227
+ functionName: decoded.functionName,
228
+ to: transaction.to,
229
+ },
230
+ ).toMatchInlineSnapshot(`
231
+ {
232
+ "account": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
233
+ "args": [
234
+ "0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF",
235
+ ],
236
+ "functionName": "revokeKey",
237
+ "to": "0xaAAAaaAA00000000000000000000000000000000",
238
+ }
239
+ `)
240
+ expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`)
241
+ })
242
+
243
+ test('behavior: signing silently restores wallet accounts via the Privy SDK', async () => {
244
+ const { adapter, client, store } = setup()
245
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
246
+
247
+ await adapter.actions.signPersonalMessage(
248
+ { address, data: '0x68656c6c6f' },
249
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
250
+ )
251
+
252
+ expect(client.restoreCalls).toMatchInlineSnapshot(`1`)
253
+ expect(client.loadCalls).toMatchInlineSnapshot(`0`)
254
+ })
255
+
256
+ test('behavior: silent restore does not connect accounts when the provider store is empty', async () => {
257
+ const { adapter, client } = setup()
258
+
259
+ await expect(
260
+ adapter.actions.signPersonalMessage(
261
+ { address, data: '0x68656c6c6f' },
262
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
263
+ ),
264
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: No Privy account connected.]')
265
+
266
+ expect(client.loadCalls).toMatchInlineSnapshot(`0`)
267
+ expect(client.restoreCalls).toMatchInlineSnapshot(`0`)
268
+ expect(client.signPayloads).toMatchInlineSnapshot(`[]`)
269
+ })
270
+
271
+ test('behavior: silent restore only reconnects persisted provider accounts', async () => {
272
+ const { adapter, client, store } = setup()
273
+ client.addWallet(other)
274
+ store.setState({ accounts: [{ address: other }], activeAccount: 0 })
275
+
276
+ await adapter.actions.signPersonalMessage(
277
+ { address: other, data: '0x68656c6c6f' },
278
+ { method: 'personal_sign', params: ['0x68656c6c6f', other] },
279
+ )
280
+
281
+ expect(client.signWith).toMatchInlineSnapshot(`
282
+ [
283
+ "0x2b5ad5c4795c026514f8317c7a215e218dccd6cf",
284
+ ]
285
+ `)
286
+ expect(store.getState().accounts).toMatchInlineSnapshot(`
287
+ [
288
+ {
289
+ "address": "0x2b5ad5c4795c026514f8317c7a215e218dccd6cf",
290
+ },
291
+ ]
292
+ `)
293
+ })
294
+
295
+ test('behavior: expired sessions clear provider accounts', async () => {
296
+ const { adapter, client, store } = setup({ token: null })
297
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
298
+
299
+ await expect(
300
+ adapter.actions.signPersonalMessage(
301
+ { address, data: '0x68656c6c6f' },
302
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
303
+ ),
304
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
305
+
306
+ expect(client.signPayloads).toMatchInlineSnapshot(`[]`)
307
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
308
+ })
309
+
310
+ test('behavior: server session errors clear provider accounts', async () => {
311
+ const { adapter, store } = setup({ signError: { code: 'embedded_wallet_request_error' } })
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 fuzzy code match', async () => {
325
+ const { adapter, store } = setup({ signError: { code: 'session_invalid_token' } })
326
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
327
+
328
+ await expect(
329
+ adapter.actions.signPersonalMessage(
330
+ { address, data: '0x68656c6c6f' },
331
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
332
+ ),
333
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
334
+
335
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
336
+ })
337
+
338
+ test('behavior: session errors are recognized via nested cause messages', async () => {
339
+ const inner = new Error('User must be logged in to sign.')
340
+ const outer = new Error('Wallet operation failed', { cause: inner })
341
+ const { adapter, store } = setup({ signError: outer })
342
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
343
+
344
+ await expect(
345
+ adapter.actions.signPersonalMessage(
346
+ { address, data: '0x68656c6c6f' },
347
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
348
+ ),
349
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
350
+
351
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
352
+ })
353
+
354
+ test('behavior: session errors are recognized via message fallback', async () => {
355
+ const { adapter, store } = setup({
356
+ signError: Object.assign(new Error('User must be logged in to sign.'), {}),
357
+ })
358
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
359
+
360
+ await expect(
361
+ adapter.actions.signPersonalMessage(
362
+ { address, data: '0x68656c6c6f' },
363
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
364
+ ),
365
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
366
+
367
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
368
+ })
369
+
370
+ test('behavior: silent restore clears stale persisted accounts when Privy no longer has them', async () => {
371
+ const { adapter, client, store } = setup()
372
+ // Persisted address that is NOT linked on the Privy user.
373
+ store.setState({ accounts: [{ address: other }], activeAccount: 0 })
374
+
375
+ await expect(
376
+ adapter.actions.signPersonalMessage(
377
+ { address: other, data: '0x68656c6c6f' },
378
+ { method: 'personal_sign', params: ['0x68656c6c6f', other] },
379
+ ),
380
+ ).rejects.toMatchInlineSnapshot(
381
+ '[Provider.DisconnectedError: Privy session no longer matches persisted accounts.]',
382
+ )
383
+
384
+ expect(client.signPayloads).toMatchInlineSnapshot(`[]`)
385
+ // Stale persisted accounts are wiped so the adapter and store agree.
386
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
387
+ })
388
+
389
+ test('error: silent restore rejects non-hex secp256k1_sign results', async () => {
390
+ const { adapter, store } = setup({ signResult: 'not-hex' })
391
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
392
+
393
+ await expect(
394
+ adapter.actions.signPersonalMessage(
395
+ { address, data: '0x68656c6c6f' },
396
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
397
+ ),
398
+ ).rejects.toMatchInlineSnapshot(
399
+ '[ProviderRpcError: Privy provider returned a non-hex secp256k1_sign result.]',
400
+ )
401
+ })
402
+
403
+ test('error: app-returned wallet with malformed address is rejected at connect', async () => {
404
+ const { adapter, client } = setup()
405
+ client.wallets = [client.makeWallet('0xnot-an-address')]
406
+
407
+ await expect(
408
+ adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined }),
409
+ ).rejects.toThrowError(/Address.*invalid/i)
410
+ })
411
+
412
+ test('error: malformed secp256k1_sign result is rejected by signer recovery', async () => {
413
+ const { adapter } = setup({ signResult: '0x1234' })
414
+ await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined })
415
+
416
+ await expect(
417
+ adapter.actions.signPersonalMessage(
418
+ { address, data: '0x68656c6c6f' },
419
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
420
+ ),
421
+ ).rejects.toMatchInlineSnapshot(
422
+ `[Provider.UnauthorizedError: Privy provider returned a signature for "unknown" that does not match the requested wallet "${address}".]`,
423
+ )
424
+ })
425
+
426
+ test('error: signing for an unconnected address while others are connected throws Unauthorized', async () => {
427
+ const { adapter } = setup()
428
+ await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined })
429
+
430
+ await expect(
431
+ adapter.actions.signPersonalMessage(
432
+ { address: other, data: '0x68656c6c6f' },
433
+ { method: 'personal_sign', params: ['0x68656c6c6f', other] },
434
+ ),
435
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
436
+ `[Provider.UnauthorizedError: Account "${other}" not found.]`,
437
+ )
438
+ })
439
+
440
+ test('error: unsupported secp256k1_sign maps to UnsupportedMethodError', async () => {
441
+ const { adapter, store } = setup({ signError: { code: 4200, message: 'Method not supported' } })
442
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
443
+
444
+ await expect(
445
+ adapter.actions.signPersonalMessage(
446
+ { address, data: '0x68656c6c6f' },
447
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
448
+ ),
449
+ ).rejects.toMatchInlineSnapshot(
450
+ '[Provider.UnsupportedMethodError: Privy adapter requires raw secp256k1 hash signing via `secp256k1_sign` for Tempo transactions and access keys.]',
451
+ )
452
+ })
453
+
454
+ test('disconnect: clears provider accounts and logs the user out of Privy', async () => {
455
+ const { adapter, client, store } = setup()
456
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
457
+
458
+ await adapter.actions.disconnect!()
459
+
460
+ expect(client.logoutCalls).toMatchInlineSnapshot(`1`)
461
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
462
+ })
463
+
464
+ test('behavior: restore surfaces silent-restore session errors as `Privy session expired.`', async () => {
465
+ const { adapter, store } = setup({
466
+ restoreError: Object.assign(new Error('boom'), { code: 'session_expired' }),
467
+ })
468
+ store.setState({ accounts: [{ address }], activeAccount: 0 })
469
+
470
+ await expect(
471
+ adapter.actions.signPersonalMessage(
472
+ { address, data: '0x68656c6c6f' },
473
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
474
+ ),
475
+ ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Privy session expired.]')
476
+
477
+ expect(store.getState().accounts).toMatchInlineSnapshot(`[]`)
478
+ })
479
+
480
+ test('error: signature recovered from a different key is rejected as Unauthorized', async () => {
481
+ const { adapter } = setup({ signWithPrivateKey: privateKeyB })
482
+ await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined })
483
+
484
+ await expect(
485
+ adapter.actions.signPersonalMessage(
486
+ { address, data: '0x68656c6c6f' },
487
+ { method: 'personal_sign', params: ['0x68656c6c6f', address] },
488
+ ),
489
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
490
+ `[Provider.UnauthorizedError: Privy provider returned a signature for "${other}" that does not match the requested wallet "${address}".]`,
491
+ )
492
+ })
493
+ })
494
+
495
+ function setup(options: setup.Options = {}) {
496
+ const storage = Storage.memory()
497
+ const store = Store.create({ chainId: 1, storage })
498
+ const client = createClient(options)
499
+ const adapter = privy({
500
+ client,
501
+ ...(options.createAccount === false
502
+ ? {}
503
+ : {
504
+ createAccount: async () => {
505
+ client.createCalls++
506
+ return undefined
507
+ },
508
+ }),
509
+ loadAccounts: async () => {
510
+ client.loadCalls++
511
+ return undefined
512
+ },
513
+ })({
514
+ getAccount: (() => {
515
+ throw new Error('not implemented')
516
+ }) as never,
517
+ getClient: (() => ({
518
+ chain: { id: 1 },
519
+ sendTransaction: async (parameters: unknown) => {
520
+ client.transactions.push(parameters)
521
+ return Hex.padLeft('0x1', 32)
522
+ },
523
+ })) as never,
524
+ storage,
525
+ store,
526
+ })
527
+ return { adapter, client, store }
528
+ }
529
+
530
+ declare namespace setup {
531
+ type Options = {
532
+ /** Pass `false` to omit the adapter's `createAccount` callback (tests fallback to `loadAccounts`). */
533
+ createAccount?: false | undefined
534
+ /** Make the mock client's `user.get` throw, to test restore-side session errors. */
535
+ restoreError?: unknown
536
+ token?: string | null | undefined
537
+ signError?: unknown
538
+ /** Override the value returned by the embedded provider's `secp256k1_sign`. */
539
+ signResult?: unknown
540
+ /** Force the test wallet to sign with this private key (for wrong-signer tests). */
541
+ signWithPrivateKey?: Hex.Hex | undefined
542
+ }
543
+ }
544
+
545
+ type MockClient = privy.Client & {
546
+ createCalls: number
547
+ initCalls: number
548
+ loadCalls: number
549
+ logoutCalls: number
550
+ logoutWith: (string | undefined)[]
551
+ restoreCalls: number
552
+ signPayloads: Hex.Hex[]
553
+ signWith: string[]
554
+ transactions: unknown[]
555
+ wallets: privy.EmbeddedWallet[]
556
+ makeWallet: (address: string) => privy.EmbeddedWallet
557
+ addWallet: (address: string) => void
558
+ }
559
+
560
+ function createClient(options: setup.Options = {}) {
561
+ const client: MockClient = {
562
+ createCalls: 0,
563
+ initCalls: 0,
564
+ loadCalls: 0,
565
+ logoutCalls: 0,
566
+ logoutWith: [] as (string | undefined)[],
567
+ restoreCalls: 0,
568
+ signPayloads: [] as Hex.Hex[],
569
+ signWith: [] as string[],
570
+ transactions: [] as unknown[],
571
+ wallets: [] as privy.EmbeddedWallet[],
572
+ makeWallet(address: string): privy.EmbeddedWallet {
573
+ return {
574
+ address,
575
+ provider: {
576
+ async request(req: {
577
+ method: string
578
+ params?: readonly unknown[] | undefined
579
+ }): Promise<unknown> {
580
+ if (req.method !== 'secp256k1_sign') throw new Error(`unexpected method: ${req.method}`)
581
+ if (options.signError) throw options.signError
582
+ const hash = (req.params as readonly Hex.Hex[])[0] as Hex.Hex
583
+ client.signPayloads.push(hash)
584
+ client.signWith.push(address)
585
+ if (options.signResult !== undefined) return options.signResult
586
+ const privateKey =
587
+ options.signWithPrivateKey ??
588
+ (() => {
589
+ try {
590
+ return privateKeyForAddress(address)
591
+ } catch {
592
+ return privateKeyA
593
+ }
594
+ })()
595
+ return signWithKey(privateKey, hash)
596
+ },
597
+ },
598
+ }
599
+ },
600
+ /** Adds an embedded wallet so silent restore (`user.get`) returns it. */
601
+ addWallet(address: string) {
602
+ client.wallets.push(client.makeWallet(address))
603
+ },
604
+ auth: {
605
+ logout(parameters?: { userId: string } | undefined) {
606
+ client.logoutCalls++
607
+ client.logoutWith.push(parameters?.userId)
608
+ },
609
+ },
610
+ embeddedWallet: {
611
+ async getEthereumProvider({ wallet }) {
612
+ const existing = client.wallets.find(
613
+ (w) => core_Address.from(w.address) === core_Address.from(wallet.address as string),
614
+ )
615
+ return (existing ?? client.makeWallet(wallet.address as string)).provider
616
+ },
617
+ },
618
+ async getAccessToken() {
619
+ return options.token === undefined ? 'token' : options.token
620
+ },
621
+ initialize() {
622
+ client.initCalls++
623
+ },
624
+ user: {
625
+ async get() {
626
+ client.restoreCalls++
627
+ if (options.restoreError) throw options.restoreError
628
+ return {
629
+ user: {
630
+ id: 'user_1',
631
+ linked_accounts: client.wallets.map((wallet, index) => ({
632
+ address: wallet.address,
633
+ chain_type: 'ethereum',
634
+ connector_type: 'embedded',
635
+ type: 'wallet',
636
+ wallet_client_type: 'privy',
637
+ wallet_index: index,
638
+ })),
639
+ },
640
+ }
641
+ },
642
+ },
643
+ }
644
+
645
+ client.addWallet(address)
646
+
647
+ return client
648
+ }