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
@@ -8,11 +8,11 @@ import {
8
8
  RpcResponse,
9
9
  } from 'ox'
10
10
  import { KeyAuthorization } from 'ox/tempo'
11
- import { prepareTransactionRequest } from 'viem/actions'
12
11
  import { Actions, Account as TempoAccount, Secp256k1 } from 'viem/tempo'
13
12
 
14
13
  import * as AccessKey from '../core/AccessKey.js'
15
14
  import * as Adapter from '../core/Adapter.js'
15
+ import * as AccessKeyTransaction from '../core/internal/AccessKeyTransaction.js'
16
16
  import type * as Storage from '../core/Storage.js'
17
17
 
18
18
  /**
@@ -58,19 +58,19 @@ export function reactNative(options: reactNative.Options): Adapter.Adapter {
58
58
  const keyAuthorization = deserialized as KeyAuthorization.Signed
59
59
 
60
60
  if (keyAuthorization.address.toLowerCase() === keyAddress.toLowerCase())
61
- AccessKey.save({
62
- address,
63
- keyAuthorization,
61
+ AccessKey.add({
62
+ account: address,
63
+ authorization: keyAuthorization,
64
64
  privateKey: entry.key,
65
65
  store,
66
66
  })
67
67
  else
68
- store.setState((state) => ({
69
- accessKeys: state.accessKeys.filter(
70
- (accessKey) =>
71
- accessKey.address.toLowerCase() !== keyAuthorization.address.toLowerCase(),
72
- ),
73
- }))
68
+ AccessKey.remove({
69
+ account: address,
70
+ accessKey: keyAuthorization.address,
71
+ chainId: Number(keyAuthorization.chainId),
72
+ store,
73
+ })
74
74
 
75
75
  return {
76
76
  account,
@@ -127,9 +127,9 @@ export function reactNative(options: reactNative.Options): Adapter.Adapter {
127
127
  ) {
128
128
  if (!managedKey) return
129
129
 
130
- AccessKey.save({
131
- address,
132
- keyAuthorization,
130
+ AccessKey.add({
131
+ account: address,
132
+ authorization: keyAuthorization,
133
133
  privateKey: managedKey.key,
134
134
  store,
135
135
  })
@@ -189,36 +189,47 @@ export function reactNative(options: reactNative.Options): Adapter.Adapter {
189
189
  return result.keyAuthorization
190
190
  }
191
191
 
192
- async function withManagedAccessKey<result>(
193
- fn: (
194
- account: TempoAccount.Account,
195
- keyAuthorization?: KeyAuthorization.Signed | undefined,
196
- ) => Promise<result>,
192
+ async function prepareManagedTransaction(
193
+ client: ReturnType<typeof getClient>,
194
+ parameters: AccessKeyTransaction.create.PrepareParameters,
195
+ options: {
196
+ calls?: AccessKeyTransaction.create.Options['calls'] | undefined
197
+ chainId?: number | undefined
198
+ } = {},
197
199
  ) {
198
- const rootAddress = store.getState().accounts[store.getState().activeAccount]?.address
199
- const managedKey = rootAddress ? await loadManagedKey(rootAddress) : undefined
200
-
201
- const account = managedKey?.account ?? getAccount({ signable: true })
202
- let keyAuthorization = AccessKey.getPending(account, { store })
203
- if (rootAddress && managedKey && !keyAuthorization)
204
- if (!(await isManagedKeyAuthorized(rootAddress, managedKey)))
205
- keyAuthorization = await reauthorizeManagedKey(rootAddress, managedKey)
200
+ const state = store.getState()
201
+ const address = parameters.from ?? state.accounts[state.activeAccount]?.address
202
+ if (!address) throw new core_Provider.DisconnectedError({ message: 'No active account.' })
203
+ const managedKey = await loadManagedKey(address)
204
+ if (managedKey && !(await isManagedKeyAuthorized(address, managedKey)))
205
+ await reauthorizeManagedKey(address, managedKey)
206
+ const transaction = await AccessKeyTransaction.create({
207
+ address,
208
+ calls: options.calls,
209
+ chainId: options.chainId ?? state.chainId,
210
+ client,
211
+ store,
212
+ })
213
+ if (!transaction)
214
+ throw new core_Provider.UnauthorizedError({
215
+ message: `Account "${address}" cannot sign with an access key.`,
216
+ })
217
+ return await transaction.prepare(parameters)
218
+ }
206
219
 
207
- try {
208
- return await fn(account, keyAuthorization ?? undefined)
209
- } catch (error) {
210
- AccessKey.remove(account, { store })
211
- throw error
212
- }
220
+ async function loadManagedAccount(address: Adapter.signPersonalMessage.Parameters['address']) {
221
+ await loadManagedKey(address)
222
+ return getAccount({ address, signable: true })
213
223
  }
214
224
 
215
225
  async function authorize(request: {
216
226
  account?: Adapter.authorizeAccessKey.ReturnType['rootAddress'] | undefined
217
227
  authorizeAccessKey: Adapter.authorizeAccessKey.Parameters | undefined
218
228
  method: 'wallet_authorizeAccessKey' | 'wallet_connect'
229
+ showDeposit?: Adapter.createAccount.Parameters['showDeposit'] | undefined
219
230
  }) {
220
231
  const { host, redirectUri, open = defaultOpen } = options
221
- const { account, authorizeAccessKey, method } = request
232
+ const { account, authorizeAccessKey, method, showDeposit } = request
222
233
 
223
234
  const managedKey =
224
235
  authorizeAccessKey && !authorizeAccessKey.publicKey && !authorizeAccessKey.address
@@ -251,6 +262,7 @@ export function reactNative(options: reactNative.Options): Adapter.Adapter {
251
262
  ? { limits: authorizeAccessKey.limits.map((l) => ({ ...l, limit: String(l.limit) })) }
252
263
  : {}),
253
264
  pubKey: publicKey,
265
+ ...(showDeposit !== undefined ? { showDeposit } : {}),
254
266
  state,
255
267
  })
256
268
 
@@ -303,8 +315,29 @@ export function reactNative(options: reactNative.Options): Adapter.Adapter {
303
315
  rootAddress: result.accountAddress,
304
316
  }
305
317
  },
306
- async createAccount(params, request) {
307
- return this.loadAccounts(params, request)
318
+ async createAccount(parameters) {
319
+ if (parameters?.digest)
320
+ throw unsupported(
321
+ '`wallet_connect` digest signing not supported by React Native adapter.',
322
+ )
323
+
324
+ const result = await authorize({
325
+ authorizeAccessKey: parameters?.authorizeAccessKey,
326
+ method: 'wallet_connect',
327
+ ...(parameters?.showDeposit !== undefined
328
+ ? { showDeposit: parameters.showDeposit }
329
+ : {}),
330
+ })
331
+
332
+ return {
333
+ accounts: [
334
+ {
335
+ address: result.accountAddress,
336
+ capabilities: {},
337
+ },
338
+ ],
339
+ keyAuthorization: KeyAuthorization.toRpc(result.keyAuthorization),
340
+ }
308
341
  },
309
342
  async loadAccounts(parameters) {
310
343
  if (parameters?.digest)
@@ -333,74 +366,57 @@ export function reactNative(options: reactNative.Options): Adapter.Adapter {
333
366
  async sendTransaction(parameters) {
334
367
  const { feePayer, ...rest } = parameters
335
368
  const client = getClient(typeof feePayer === 'string' ? { feePayer } : {})
336
- const { account, prepared } = await withManagedAccessKey(
337
- async (account, keyAuthorization) => ({
338
- account,
339
- prepared: await prepareTransactionRequest(client, {
340
- account,
341
- ...rest,
342
- ...(feePayer ? { feePayer: true } : {}),
343
- ...(keyAuthorization ? { keyAuthorization } : {}),
344
- type: 'tempo',
345
- } as never),
346
- }),
369
+ const prepared = await prepareManagedTransaction(
370
+ client,
371
+ {
372
+ ...rest,
373
+ ...(feePayer ? { feePayer: true } : {}),
374
+ },
375
+ {
376
+ calls: parameters.calls as AccessKeyTransaction.create.Options['calls'],
377
+ chainId: parameters.chainId,
378
+ },
347
379
  )
348
- const signed = await account.signTransaction(prepared as never)
349
- const result = await client.request({
350
- method: 'eth_sendRawTransaction' as never,
351
- params: [signed],
352
- })
353
- AccessKey.removePending(account, { store })
354
- return result
380
+ return await prepared.send()
355
381
  },
356
382
  async sendTransactionSync(parameters) {
357
383
  const { feePayer, ...rest } = parameters
358
384
  const client = getClient(typeof feePayer === 'string' ? { feePayer } : {})
359
- const { account, prepared } = await withManagedAccessKey(
360
- async (account, keyAuthorization) => ({
361
- account,
362
- prepared: await prepareTransactionRequest(client, {
363
- account,
364
- ...rest,
365
- ...(feePayer ? { feePayer: true } : {}),
366
- ...(keyAuthorization ? { keyAuthorization } : {}),
367
- type: 'tempo',
368
- } as never),
369
- }),
385
+ const prepared = await prepareManagedTransaction(
386
+ client,
387
+ {
388
+ ...rest,
389
+ ...(feePayer ? { feePayer: true } : {}),
390
+ },
391
+ {
392
+ calls: parameters.calls as AccessKeyTransaction.create.Options['calls'],
393
+ chainId: parameters.chainId,
394
+ },
370
395
  )
371
- const signed = await account.signTransaction(prepared as never)
372
- const result = await client.request({
373
- method: 'eth_sendRawTransactionSync' as never,
374
- params: [signed],
375
- })
376
- AccessKey.removePending(account, { store })
377
- return result
396
+ return await prepared.sendSync()
378
397
  },
379
398
  async signPersonalMessage({ address, data }) {
380
- await loadManagedKey(address)
381
- const account = getAccount({ address, signable: true })
399
+ const account = await loadManagedAccount(address)
382
400
  return await account.signMessage({ message: { raw: data } })
383
401
  },
384
402
  async signTransaction(parameters) {
385
403
  const { feePayer, ...rest } = parameters
386
404
  const client = getClient(typeof feePayer === 'string' ? { feePayer } : {})
387
- const { account, prepared } = await withManagedAccessKey(
388
- async (account, keyAuthorization) => ({
389
- account,
390
- prepared: await prepareTransactionRequest(client, {
391
- account,
392
- ...rest,
393
- ...(feePayer ? { feePayer: true } : {}),
394
- ...(keyAuthorization ? { keyAuthorization } : {}),
395
- type: 'tempo',
396
- } as never),
397
- }),
405
+ const prepared = await prepareManagedTransaction(
406
+ client,
407
+ {
408
+ ...rest,
409
+ ...(feePayer ? { feePayer: true } : {}),
410
+ },
411
+ {
412
+ calls: parameters.calls as AccessKeyTransaction.create.Options['calls'],
413
+ chainId: parameters.chainId,
414
+ },
398
415
  )
399
- return await account.signTransaction(prepared as never)
416
+ return await prepared.sign()
400
417
  },
401
418
  async signTypedData({ address, data }) {
402
- await loadManagedKey(address)
403
- const account = getAccount({ address, signable: true })
419
+ const account = await loadManagedAccount(address)
404
420
  return await account.signTypedData(JSON.parse(data) as never)
405
421
  },
406
422
  },
@@ -490,6 +506,7 @@ function buildAuthUrl(
490
506
  keyType?: string | undefined
491
507
  limits?: readonly { token: string; limit: string }[] | undefined
492
508
  pubKey: Hex.Hex
509
+ showDeposit?: Adapter.createAccount.Parameters['showDeposit'] | undefined
493
510
  state: string
494
511
  },
495
512
  ): string {
@@ -501,6 +518,9 @@ function buildAuthUrl(
501
518
  url.searchParams.set('chainId', String(params.chainId))
502
519
  if (typeof params.expiry !== 'undefined') url.searchParams.set('expiry', String(params.expiry))
503
520
  if (params.limits) url.searchParams.set('limits', JSON.stringify(params.limits))
521
+ if (params.showDeposit === true) url.searchParams.set('showDeposit', 'true')
522
+ else if (params.showDeposit)
523
+ url.searchParams.set('showDeposit', JSON.stringify(params.showDeposit))
504
524
  url.searchParams.set('callback', params.callback)
505
525
  url.searchParams.set('state', params.state)
506
526
  return url.toString()
@@ -13,6 +13,14 @@ describe('createRequest', () => {
13
13
  keyType?: 'secp256k1' | 'p256' | 'webAuthn' | undefined
14
14
  limits?: readonly { token: Hex; limit: bigint }[] | undefined
15
15
  pubKey: Hex
16
+ showDeposit?:
17
+ | boolean
18
+ | {
19
+ amount?: string | undefined
20
+ displayName?: string | undefined
21
+ token?: string | undefined
22
+ }
23
+ | undefined
16
24
  }>()
17
25
  })
18
26
 
@@ -20,6 +28,13 @@ describe('createRequest', () => {
20
28
  type Request = z.output<typeof CliAuth.createRequest>
21
29
  expectTypeOf<Request>().not.toHaveProperty('scopes')
22
30
  })
31
+
32
+ test('showDeposit does not include address or chainId', () => {
33
+ type Request = z.output<typeof CliAuth.createRequest>
34
+ type ShowDeposit = Exclude<Exclude<Request['showDeposit'], boolean | undefined>, undefined>
35
+ expectTypeOf<ShowDeposit>().not.toHaveProperty('address')
36
+ expectTypeOf<ShowDeposit>().not.toHaveProperty('chainId')
37
+ })
23
38
  })
24
39
 
25
40
  describe('pollResponse', () => {
@@ -44,6 +59,14 @@ describe('pendingResponse', () => {
44
59
  keyType: 'secp256k1' | 'p256' | 'webAuthn'
45
60
  limits?: readonly { token: Hex; limit: bigint }[] | undefined
46
61
  pubKey: Hex
62
+ showDeposit?:
63
+ | boolean
64
+ | {
65
+ amount?: string | undefined
66
+ displayName?: string | undefined
67
+ token?: string | undefined
68
+ }
69
+ | undefined
47
70
  status: 'pending'
48
71
  }>()
49
72
  })
@@ -109,6 +109,7 @@ async function createRequest(
109
109
  expiry?: number | undefined
110
110
  keyType?: 'secp256k1' | 'p256' | 'webAuthn' | undefined
111
111
  limits?: readonly { token: `0x${string}`; limit: bigint }[] | undefined
112
+ showDeposit?: z.output<typeof CliAuth.createRequest>['showDeposit'] | undefined
112
113
  } = {},
113
114
  ) {
114
115
  const key = options.accessKey ?? accessKey
@@ -128,6 +129,7 @@ async function createRequest(
128
129
  : { keyType: key.keyType }),
129
130
  ...('limits' in options ? (options.limits ? { limits: options.limits } : {}) : { limits }),
130
131
  pubKey: key.publicKey,
132
+ ...(options.showDeposit !== undefined ? { showDeposit: options.showDeposit } : {}),
131
133
  } satisfies z.output<typeof CliAuth.createRequest>,
132
134
  }
133
135
  }
@@ -671,6 +673,51 @@ describe('pending', () => {
671
673
  `)
672
674
  })
673
675
 
676
+ test('behavior: preserves showDeposit for browser approval', async () => {
677
+ const store = CliAuth.Store.memory()
678
+ const { request } = await createRequest('device-code-verifier', {
679
+ showDeposit: {
680
+ amount: '50',
681
+ displayName: 'DoorDash',
682
+ token: 'USDC',
683
+ },
684
+ })
685
+ const { code } = await CliAuth.createDeviceCode({
686
+ chainId: chain.id,
687
+ random: () => new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
688
+ request,
689
+ store,
690
+ })
691
+
692
+ const result = await CliAuth.pending({
693
+ code,
694
+ store,
695
+ })
696
+
697
+ expect(result).toMatchInlineSnapshot(`
698
+ {
699
+ "accessKeyAddress": "${accessKey.address.toLowerCase()}",
700
+ "chainId": 1337n,
701
+ "code": "ABCDEFGH",
702
+ "expiry": ${expiry},
703
+ "keyType": "p256",
704
+ "limits": [
705
+ {
706
+ "limit": 1000n,
707
+ "token": "0x20c0000000000000000000000000000000000001",
708
+ },
709
+ ],
710
+ "pubKey": "${accessKey.publicKey}",
711
+ "showDeposit": {
712
+ "amount": "50",
713
+ "displayName": "DoorDash",
714
+ "token": "USDC",
715
+ },
716
+ "status": "pending",
717
+ }
718
+ `)
719
+ })
720
+
674
721
  test('behavior: handler returns 404 for an unknown code', async () => {
675
722
  const handler = Handler.codeAuth()
676
723
 
@@ -2,7 +2,7 @@ import { Address, Base64, Bytes, Hex, PublicKey } from 'ox'
2
2
  import { KeyAuthorization as TempoKeyAuthorization, SignatureEnvelope } from 'ox/tempo'
3
3
  import { createClient, http, type Chain, type Client, type Transport } from 'viem'
4
4
  import { verifyHash } from 'viem/actions'
5
- import { tempo } from 'viem/chains'
5
+ import { tempo } from 'viem/tempo/chains'
6
6
  import * as z from 'zod/mini'
7
7
 
8
8
  import * as u from '../core/zod/utils.js'
@@ -12,6 +12,16 @@ import type { Kv } from './Kv.js'
12
12
  const maxLimits = 10
13
13
  const limit = z.object({ token: u.address(), limit: u.bigint() })
14
14
  const limits = z.readonly(z.array(limit).check(z.maxLength(maxLimits)))
15
+ const showDeposit = z.optional(
16
+ z.union([
17
+ z.boolean(),
18
+ z.object({
19
+ amount: z.optional(z.string()),
20
+ displayName: z.optional(z.string()),
21
+ token: z.optional(z.union([u.address(), z.string()])),
22
+ }),
23
+ ]),
24
+ )
15
25
  const defaultTtlMs = 10 * 60 * 1_000
16
26
  const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
17
27
 
@@ -38,6 +48,7 @@ export const createRequest = z.object({
38
48
  keyType: z.optional(keyType),
39
49
  limits: z.optional(limits),
40
50
  pubKey: u.hex(),
51
+ showDeposit,
41
52
  })
42
53
 
43
54
  /** Response body for `POST /cli-auth/device-code`. */
@@ -75,6 +86,7 @@ export const pendingResponse = z.object({
75
86
  keyType,
76
87
  limits: z.optional(limits),
77
88
  pubKey: u.hex(),
89
+ showDeposit,
78
90
  status: z.literal('pending'),
79
91
  })
80
92
 
@@ -103,6 +115,7 @@ export const entry = u.oneOf([
103
115
  keyType,
104
116
  limits: z.optional(limits),
105
117
  pubKey: u.hex(),
118
+ showDeposit,
106
119
  status: z.literal('pending'),
107
120
  }),
108
121
  z.object({
@@ -119,6 +132,7 @@ export const entry = u.oneOf([
119
132
  keyType,
120
133
  limits: z.optional(limits),
121
134
  pubKey: u.hex(),
135
+ showDeposit,
122
136
  status: z.literal('authorized'),
123
137
  }),
124
138
  z.object({
@@ -136,6 +150,7 @@ export const entry = u.oneOf([
136
150
  keyType,
137
151
  limits: z.optional(limits),
138
152
  pubKey: u.hex(),
153
+ showDeposit,
139
154
  status: z.literal('consumed'),
140
155
  }),
141
156
  ])
@@ -576,6 +591,9 @@ export function from(options: from.Options = {}): CliAuth {
576
591
  keyType,
577
592
  ...(approved.limits ? { limits: approved.limits } : {}),
578
593
  pubKey,
594
+ ...(options.request.showDeposit !== undefined
595
+ ? { showDeposit: options.request.showDeposit }
596
+ : {}),
579
597
  status: 'pending',
580
598
  })
581
599
 
@@ -601,6 +619,7 @@ export function from(options: from.Options = {}): CliAuth {
601
619
  keyType: current.keyType,
602
620
  ...(current.limits ? { limits: current.limits } : {}),
603
621
  pubKey: current.pubKey,
622
+ ...(current.showDeposit !== undefined ? { showDeposit: current.showDeposit } : {}),
604
623
  status: 'pending',
605
624
  }
606
625
  },
@@ -1,7 +1,7 @@
1
1
  import { hc } from 'hono/client'
2
2
  import type { ExtractSchema } from 'hono/types'
3
3
  import { http, type Chain, type Transport } from 'viem'
4
- import { tempo, tempoModerato } from 'viem/chains'
4
+ import { tempo, tempoModerato } from 'viem/tempo/chains'
5
5
  import { describe, expectTypeOf, test } from 'vp/test'
6
6
 
7
7
  import * as CliAuth from './CliAuth.js'
@@ -47,6 +47,36 @@ describe('memory', () => {
47
47
  expect(await kv.get('a')).toMatchInlineSnapshot(`"v2"`)
48
48
  })
49
49
 
50
+ test('create: writes only when key is absent', async () => {
51
+ const kv = Kv.memory()
52
+
53
+ expect(await kv.create!('a', 'v1')).toMatchInlineSnapshot(`true`)
54
+ expect(await kv.create!('a', 'v2')).toMatchInlineSnapshot(`false`)
55
+ expect(await kv.get('a')).toMatchInlineSnapshot(`"v1"`)
56
+ })
57
+
58
+ test('create: replaces expired entries', async () => {
59
+ let now = 1_000_000
60
+ const kv = Kv.memory({ now: () => now })
61
+
62
+ await kv.set('a', 'v1', { ttl: 1 })
63
+ now += 2_000
64
+ expect(await kv.create!('a', 'v2')).toMatchInlineSnapshot(`true`)
65
+ expect(await kv.get('a')).toMatchInlineSnapshot(`"v2"`)
66
+ })
67
+
68
+ test('create: concurrent callers — only one writes', async () => {
69
+ const kv = Kv.memory()
70
+
71
+ const results = await Promise.all([kv.create!('a', 'v1'), kv.create!('a', 'v2')])
72
+ expect(results.filter(Boolean)).toMatchInlineSnapshot(`
73
+ [
74
+ true,
75
+ ]
76
+ `)
77
+ expect(await kv.get('a')).toMatchInlineSnapshot(`"v1"`)
78
+ })
79
+
50
80
  test('take: returns the value and removes the entry', async () => {
51
81
  const kv = Kv.memory()
52
82
 
@@ -163,6 +193,18 @@ describe('durableObject + NonceStorage', () => {
163
193
  `)
164
194
  })
165
195
 
196
+ test('create: concurrent callers — only one wins', async () => {
197
+ const kv = Kv.durableObject(fakeDurableObject())
198
+
199
+ const results = await Promise.all([kv.create!('a', 'v1'), kv.create!('a', 'v2')])
200
+ expect(results.filter(Boolean)).toMatchInlineSnapshot(`
201
+ [
202
+ true,
203
+ ]
204
+ `)
205
+ expect(await kv.get('a')).toMatchInlineSnapshot(`"v1"`)
206
+ })
207
+
166
208
  test('take: missing key returns undefined', async () => {
167
209
  const kv = Kv.durableObject(fakeDurableObject())
168
210
  expect(await kv.take!('missing')).toMatchInlineSnapshot(`undefined`)
@@ -226,6 +268,15 @@ describe('cloudflare', () => {
226
268
  expect(kv.take).toBeUndefined()
227
269
  })
228
270
 
271
+ test('create: NOT implemented (CF KV is not linearizable)', () => {
272
+ const kv = Kv.cloudflare({
273
+ get: async () => null,
274
+ put: async () => {},
275
+ delete: async () => {},
276
+ })
277
+ expect(kv.create).toBeUndefined()
278
+ })
279
+
229
280
  test('ttl: passes expirationTtl seconds to underlying put', async () => {
230
281
  const puts: { key: string; value: string; options: unknown }[] = []
231
282
  const fakeKv = {
package/src/server/Kv.ts CHANGED
@@ -16,6 +16,16 @@ export type Kv = {
16
16
  set: (key: string, value: unknown, options?: set.Options | undefined) => Promise<void>
17
17
  /** Delete a value by key. */
18
18
  delete: (key: string) => Promise<void>
19
+ /**
20
+ * Atomic create-if-absent. Returns `true` when the value was written,
21
+ * `false` when a non-expired value already exists.
22
+ *
23
+ * Optional. Backends with a linearizable create primitive should
24
+ * implement this so consumers can atomically reject duplicates.
25
+ * Consumers may fall back to `get` then `set` when strict atomicity is
26
+ * not required.
27
+ */
28
+ create?: (key: string, value: unknown, options?: set.Options | undefined) => Promise<boolean>
19
29
  /**
20
30
  * Atomic read-and-delete. Returns the value if present, `undefined` if
21
31
  * missing or expired. Across concurrent callers, exactly one observer
@@ -51,11 +61,10 @@ export function from<kv extends Kv>(kv: kv): kv {
51
61
  * Cloudflare KV's minimum TTL is 60 seconds; the platform enforces its own
52
62
  * minimum independent of what's passed here.
53
63
  *
54
- * **Not safe for one-time-consume semantics.** Cloudflare KV is eventually
55
- * consistent across data centers concurrent read+delete races can let
56
- * the same key be "consumed" twice. `take` is intentionally NOT
57
- * implemented. Use a Durable Object (or another linearizable backend)
58
- * for the SIWE challenge nonce store.
64
+ * **Not safe for one-time-consume or unique-key semantics.**
65
+ * Cloudflare KV is eventually consistent across data centers, so `take`
66
+ * and `create` are intentionally NOT implemented. Use a Durable Object
67
+ * (or another linearizable backend) for the SIWE challenge nonce store.
59
68
  */
60
69
  export function cloudflare(kv: cloudflare.Parameters): Kv {
61
70
  return from({
@@ -84,10 +93,11 @@ export declare namespace cloudflare {
84
93
 
85
94
  /**
86
95
  * Adapt a Cloudflare Durable Object namespace into a `Kv` with atomic
87
- * `take`. Unlike `Kv.cloudflare`, a Durable Object's storage is
88
- * single-actor and linearizable `take` (read+delete) is guaranteed
89
- * atomic across concurrent callers, which makes this the recommended
90
- * backend for SIWE challenge nonce storage on Cloudflare Workers.
96
+ * `take` and `create`. Unlike `Kv.cloudflare`, a Durable Object's
97
+ * storage is single-actor and linearizable, so `take` (read+delete) and
98
+ * `create` (create-if-absent) are guaranteed atomic across concurrent
99
+ * callers. This makes it the recommended backend for SIWE challenge
100
+ * nonce storage on Cloudflare Workers.
91
101
  *
92
102
  * Pair with `Kv.NonceStorage` (or your own DO class implementing the
93
103
  * same fetch protocol).
@@ -147,6 +157,12 @@ export function durableObject(
147
157
  async set(key, value, options) {
148
158
  await rpc('set', key, { value, ttl: options?.ttl })
149
159
  },
160
+ async create(key, value, options) {
161
+ const { created } = (await rpc('create', key, { value, ttl: options?.ttl })) as {
162
+ created: boolean
163
+ }
164
+ return created
165
+ },
150
166
  async delete(key) {
151
167
  await rpc('delete', key)
152
168
  },
@@ -228,6 +244,17 @@ export class NonceStorage {
228
244
  await this.state.storage.put(key, entry)
229
245
  return Response.json({})
230
246
  }
247
+ if (op === 'create') {
248
+ const current = await this.state.storage.get<NonceStorage.Entry>(key)
249
+ if (current && !isExpired(current)) return Response.json({ created: false })
250
+
251
+ const body = (await request.json()) as { value: unknown; ttl?: number }
252
+ const entry: NonceStorage.Entry = body.ttl
253
+ ? { value: body.value, expiresAt: Date.now() + body.ttl * 1000 }
254
+ : { value: body.value }
255
+ await this.state.storage.put(key, entry)
256
+ return Response.json({ created: true })
257
+ }
231
258
  if (op === 'delete') {
232
259
  await this.state.storage.delete(key)
233
260
  return Response.json({})
@@ -280,6 +307,13 @@ export function memory(options: memory.Options = {}): Kv {
280
307
  const expiresAt = options?.ttl ? now() + options.ttl * 1000 : undefined
281
308
  store.set(key, expiresAt !== undefined ? { value, expiresAt } : { value })
282
309
  },
310
+ async create(key, value, options) {
311
+ const entry = store.get(key)
312
+ if (entry && !isExpired(entry)) return false
313
+ const expiresAt = options?.ttl ? now() + options.ttl * 1000 : undefined
314
+ store.set(key, expiresAt !== undefined ? { value, expiresAt } : { value })
315
+ return true
316
+ },
283
317
  // Atomic in-process: the synchronous `Map.get` + `Map.delete` runs
284
318
  // in a single microtask, so concurrent `take(key)` callers (within
285
319
  // the same Node/Bun/Worker process) cannot both observe the value.