accounts 0.3.0 → 0.4.1

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 (168) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +97 -0
  4. package/dist/core/AccessKey.d.ts +55 -0
  5. package/dist/core/AccessKey.d.ts.map +1 -0
  6. package/dist/core/AccessKey.js +69 -0
  7. package/dist/core/AccessKey.js.map +1 -0
  8. package/dist/core/Account.d.ts +91 -0
  9. package/dist/core/Account.d.ts.map +1 -0
  10. package/dist/core/Account.js +64 -0
  11. package/dist/core/Account.js.map +1 -0
  12. package/dist/core/Adapter.d.ts +187 -0
  13. package/dist/core/Adapter.d.ts.map +1 -0
  14. package/dist/core/Adapter.js +7 -0
  15. package/dist/core/Adapter.js.map +1 -0
  16. package/dist/core/Ceremony.d.ts +109 -0
  17. package/dist/core/Ceremony.d.ts.map +1 -0
  18. package/dist/core/Ceremony.js +104 -0
  19. package/dist/core/Ceremony.js.map +1 -0
  20. package/dist/core/Client.d.ts +16 -0
  21. package/dist/core/Client.d.ts.map +1 -0
  22. package/dist/core/Client.js +18 -0
  23. package/dist/core/Client.js.map +1 -0
  24. package/dist/core/Dialog.d.ts +52 -0
  25. package/dist/core/Dialog.d.ts.map +1 -0
  26. package/dist/core/Dialog.js +342 -0
  27. package/dist/core/Dialog.js.map +1 -0
  28. package/dist/core/Expiry.d.ts +15 -0
  29. package/dist/core/Expiry.d.ts.map +1 -0
  30. package/dist/core/Expiry.js +29 -0
  31. package/dist/core/Expiry.js.map +1 -0
  32. package/dist/core/Messenger.d.ts +86 -0
  33. package/dist/core/Messenger.d.ts.map +1 -0
  34. package/dist/core/Messenger.js +127 -0
  35. package/dist/core/Messenger.js.map +1 -0
  36. package/dist/core/Provider.d.ts +69 -0
  37. package/dist/core/Provider.d.ts.map +1 -0
  38. package/dist/core/Provider.js +401 -0
  39. package/dist/core/Provider.js.map +1 -0
  40. package/dist/core/Remote.d.ts +114 -0
  41. package/dist/core/Remote.d.ts.map +1 -0
  42. package/dist/core/Remote.js +116 -0
  43. package/dist/core/Remote.js.map +1 -0
  44. package/dist/core/Schema.d.ts +805 -0
  45. package/dist/core/Schema.d.ts.map +1 -0
  46. package/dist/core/Schema.js +43 -0
  47. package/dist/core/Schema.js.map +1 -0
  48. package/dist/core/Storage.d.ts +42 -0
  49. package/dist/core/Storage.d.ts.map +1 -0
  50. package/dist/core/Storage.js +173 -0
  51. package/dist/core/Storage.js.map +1 -0
  52. package/dist/core/Store.d.ts +58 -0
  53. package/dist/core/Store.d.ts.map +1 -0
  54. package/dist/core/Store.js +58 -0
  55. package/dist/core/Store.js.map +1 -0
  56. package/dist/core/adapters/dangerous_secp256k1.d.ts +30 -0
  57. package/dist/core/adapters/dangerous_secp256k1.d.ts.map +1 -0
  58. package/dist/core/adapters/dangerous_secp256k1.js +39 -0
  59. package/dist/core/adapters/dangerous_secp256k1.js.map +1 -0
  60. package/dist/core/adapters/dialog.d.ts +31 -0
  61. package/dist/core/adapters/dialog.d.ts.map +1 -0
  62. package/dist/core/adapters/dialog.js +306 -0
  63. package/dist/core/adapters/dialog.js.map +1 -0
  64. package/dist/core/adapters/local.d.ts +33 -0
  65. package/dist/core/adapters/local.d.ts.map +1 -0
  66. package/dist/core/adapters/local.js +227 -0
  67. package/dist/core/adapters/local.js.map +1 -0
  68. package/dist/core/adapters/webAuthn.d.ts +36 -0
  69. package/dist/core/adapters/webAuthn.d.ts.map +1 -0
  70. package/dist/core/adapters/webAuthn.js +93 -0
  71. package/dist/core/adapters/webAuthn.js.map +1 -0
  72. package/dist/core/internal/withDedupe.d.ts +12 -0
  73. package/dist/core/internal/withDedupe.d.ts.map +1 -0
  74. package/dist/core/internal/withDedupe.js +12 -0
  75. package/dist/core/internal/withDedupe.js.map +1 -0
  76. package/dist/core/zod/request.d.ts +31 -0
  77. package/dist/core/zod/request.d.ts.map +1 -0
  78. package/dist/core/zod/request.js +41 -0
  79. package/dist/core/zod/request.js.map +1 -0
  80. package/dist/core/zod/rpc.d.ts +603 -0
  81. package/dist/core/zod/rpc.d.ts.map +1 -0
  82. package/dist/core/zod/rpc.js +293 -0
  83. package/dist/core/zod/rpc.js.map +1 -0
  84. package/dist/core/zod/utils.d.ts +18 -0
  85. package/dist/core/zod/utils.d.ts.map +1 -0
  86. package/dist/core/zod/utils.js +21 -0
  87. package/dist/core/zod/utils.js.map +1 -0
  88. package/dist/index.d.ts +15 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +15 -0
  91. package/dist/index.js.map +1 -0
  92. package/dist/internal/types.d.ts +284 -0
  93. package/dist/internal/types.d.ts.map +1 -0
  94. package/dist/internal/types.js +2 -0
  95. package/dist/internal/types.js.map +1 -0
  96. package/dist/server/Handler.d.ts +257 -0
  97. package/dist/server/Handler.d.ts.map +1 -0
  98. package/dist/server/Handler.js +433 -0
  99. package/dist/server/Handler.js.map +1 -0
  100. package/dist/server/Kv.d.ts +16 -0
  101. package/dist/server/Kv.d.ts.map +1 -0
  102. package/dist/server/Kv.js +30 -0
  103. package/dist/server/Kv.js.map +1 -0
  104. package/dist/server/index.d.ts +3 -0
  105. package/dist/server/index.d.ts.map +1 -0
  106. package/dist/server/index.js +3 -0
  107. package/dist/server/index.js.map +1 -0
  108. package/dist/server/internal/requestListener.d.ts +124 -0
  109. package/dist/server/internal/requestListener.d.ts.map +1 -0
  110. package/dist/server/internal/requestListener.js +173 -0
  111. package/dist/server/internal/requestListener.js.map +1 -0
  112. package/dist/wagmi/Connector.d.ts +93 -0
  113. package/dist/wagmi/Connector.d.ts.map +1 -0
  114. package/dist/wagmi/Connector.js +238 -0
  115. package/dist/wagmi/Connector.js.map +1 -0
  116. package/dist/wagmi/index.d.ts +3 -0
  117. package/dist/wagmi/index.d.ts.map +1 -0
  118. package/dist/wagmi/index.js +3 -0
  119. package/dist/wagmi/index.js.map +1 -0
  120. package/package.json +56 -2
  121. package/src/core/AccessKey.test.ts +257 -0
  122. package/src/core/AccessKey.ts +123 -0
  123. package/src/core/Account.test.ts +309 -0
  124. package/src/core/Account.ts +152 -0
  125. package/src/core/Adapter.ts +238 -0
  126. package/src/core/Ceremony.browser.test.ts +239 -0
  127. package/src/core/Ceremony.test.ts +151 -0
  128. package/src/core/Ceremony.ts +203 -0
  129. package/src/core/Client.ts +36 -0
  130. package/src/core/Dialog.browser.test.ts +309 -0
  131. package/src/core/Dialog.test-d.ts +19 -0
  132. package/src/core/Dialog.ts +442 -0
  133. package/src/core/Expiry.ts +34 -0
  134. package/src/core/Messenger.ts +206 -0
  135. package/src/core/Provider.browser.test.ts +774 -0
  136. package/src/core/Provider.connect.browser.test.ts +415 -0
  137. package/src/core/Provider.test-d.ts +53 -0
  138. package/src/core/Provider.test.ts +1566 -0
  139. package/src/core/Provider.ts +559 -0
  140. package/src/core/Remote.ts +262 -0
  141. package/src/core/Schema.test-d.ts +211 -0
  142. package/src/core/Schema.ts +143 -0
  143. package/src/core/Storage.ts +213 -0
  144. package/src/core/Store.test.ts +287 -0
  145. package/src/core/Store.ts +129 -0
  146. package/src/core/adapters/dangerous_secp256k1.ts +53 -0
  147. package/src/core/adapters/dialog.ts +379 -0
  148. package/src/core/adapters/local.test.ts +97 -0
  149. package/src/core/adapters/local.ts +277 -0
  150. package/src/core/adapters/webAuthn.ts +129 -0
  151. package/src/core/internal/withDedupe.test.ts +116 -0
  152. package/src/core/internal/withDedupe.ts +20 -0
  153. package/src/core/mppx.test.ts +83 -0
  154. package/src/core/zod/request.test.ts +121 -0
  155. package/src/core/zod/request.ts +70 -0
  156. package/src/core/zod/rpc.ts +374 -0
  157. package/src/core/zod/utils.test.ts +69 -0
  158. package/src/core/zod/utils.ts +40 -0
  159. package/src/index.ts +14 -0
  160. package/src/internal/types.ts +378 -0
  161. package/src/server/Handler.test.ts +1014 -0
  162. package/src/server/Handler.ts +605 -0
  163. package/src/server/Kv.ts +46 -0
  164. package/src/server/index.ts +2 -0
  165. package/src/server/internal/requestListener.ts +273 -0
  166. package/src/tsconfig.json +9 -0
  167. package/src/wagmi/Connector.ts +287 -0
  168. package/src/wagmi/index.ts +2 -0
@@ -0,0 +1,277 @@
1
+ import { Address as ox_Address, Hex, Provider as ox_Provider, PublicKey, WebCryptoP256 } from 'ox'
2
+ import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo'
3
+ import { prepareTransactionRequest } from 'viem/actions'
4
+ import { Account as TempoAccount } from 'viem/tempo'
5
+
6
+ import * as AccessKey from '../AccessKey.js'
7
+ import * as Account from '../Account.js'
8
+ import * as Adapter from '../Adapter.js'
9
+
10
+ /**
11
+ * Creates a local adapter where the app manages keys and signing in-process.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { local, Provider } from 'accounts'
16
+ *
17
+ * const Provider = Provider.create({
18
+ * adapter: local({
19
+ * loadAccounts: async () => ({
20
+ * accounts: [{ address: '0x...' }],
21
+ * }),
22
+ * }),
23
+ * })
24
+ * ```
25
+ */
26
+ export function local(options: local.Options): Adapter.Adapter {
27
+ const { createAccount, icon, loadAccounts, name, rdns } = options
28
+
29
+ return Adapter.define({ icon, name, rdns }, ({ getAccount, getClient, store }) => {
30
+ /**
31
+ * Resolves access key params and computes the key authorization digest.
32
+ *
33
+ * For external keys: derives the address from the provided publicKey/address.
34
+ * For local keys: generates a P256 key pair via `AccessKey.generate`.
35
+ */
36
+ async function prepareKeyAuthorization(options: Adapter.authorizeAccessKey.Parameters) {
37
+ const { expiry, limits } = options
38
+ const chainId = getClient().chain.id
39
+
40
+ if (options.publicKey || options.address) {
41
+ const accessKeyAddress =
42
+ options.address ?? ox_Address.fromPublicKey(PublicKey.from(options.publicKey!))
43
+ const keyType = options.keyType ?? 'secp256k1'
44
+ const keyAuthorization = KeyAuthorization.from({
45
+ address: accessKeyAddress,
46
+ chainId: BigInt(chainId),
47
+ expiry,
48
+ limits,
49
+ type: keyType,
50
+ })
51
+ return { keyAuthorization }
52
+ }
53
+
54
+ const keyPair = await WebCryptoP256.createKeyPair()
55
+ const address = ox_Address.fromPublicKey(PublicKey.from(keyPair.publicKey))
56
+ const keyAuthorization = KeyAuthorization.from({
57
+ address,
58
+ chainId: BigInt(chainId),
59
+ expiry,
60
+ limits,
61
+ type: 'p256',
62
+ })
63
+ return { keyAuthorization, keyPair }
64
+ }
65
+
66
+ /**
67
+ * Signs (or wraps a pre-computed signature into) a key authorization
68
+ * and saves the result to the store.
69
+ */
70
+ async function signKeyAuthorization(
71
+ account: TempoAccount.Account,
72
+ prepared: Awaited<ReturnType<typeof prepareKeyAuthorization>>,
73
+ options: {
74
+ signature?: Hex.Hex | undefined
75
+ } = {},
76
+ ): Promise<Adapter.authorizeAccessKey.ReturnType> {
77
+ const { keyPair } = prepared
78
+
79
+ const keyAuthorization = await (async () => {
80
+ const digest = KeyAuthorization.getSignPayload(prepared.keyAuthorization)
81
+ const signature = options.signature ?? (await account.sign({ hash: digest }))
82
+ return KeyAuthorization.from(prepared.keyAuthorization, {
83
+ signature: SignatureEnvelope.from(signature),
84
+ })
85
+ })()
86
+
87
+ AccessKey.save({ address: account.address, keyAuthorization, keyPair, store })
88
+
89
+ return KeyAuthorization.toRpc(keyAuthorization)
90
+ }
91
+
92
+ async function withAccessKey<result>(
93
+ fn: (
94
+ account: TempoAccount.Account,
95
+ keyAuthorization?: KeyAuthorization.Signed,
96
+ ) => Promise<result>,
97
+ ): Promise<result> {
98
+ const account = getAccount({ signable: true })
99
+ const keyAuthorization = AccessKey.getPending(account, { store })
100
+ try {
101
+ const result = await fn(account, keyAuthorization ?? undefined)
102
+ AccessKey.removePending(account, { store })
103
+ return result
104
+ } catch (error) {
105
+ if (account.source !== 'accessKey') throw error
106
+ AccessKey.remove(account, { store })
107
+ const root = getAccount({ accessKey: false, signable: true })
108
+ return await fn(root, undefined)
109
+ }
110
+ }
111
+
112
+ return {
113
+ actions: {
114
+ async createAccount(parameters) {
115
+ if (!createAccount)
116
+ throw new ox_Provider.UnsupportedMethodError({
117
+ message: '`createAccount` not configured on adapter.',
118
+ })
119
+ const { authorizeAccessKey: grantOptions, ...rest } = parameters
120
+ const { accounts, signature } = await createAccount(rest)
121
+
122
+ // Hydrate the first account for signing. Must be done here (not via
123
+ // the store) because accounts aren't merged into the store until
124
+ // Provider.ts processes the return value.
125
+ const account = Account.hydrate(accounts[0]!, { signable: true })
126
+
127
+ // If the caller requested a digest signature but the adapter didn't
128
+ // produce one (e.g. secp256k1 adapters), sign it ourselves.
129
+ const signature_ =
130
+ rest.digest && !signature ? await account.sign({ hash: rest.digest }) : signature
131
+
132
+ const keyAuthorization = await (async () => {
133
+ if (!grantOptions) return undefined
134
+ const prepared = await prepareKeyAuthorization(grantOptions)
135
+ return await signKeyAuthorization(account, prepared)
136
+ })()
137
+
138
+ return { accounts, keyAuthorization, signature: signature_ }
139
+ },
140
+ async authorizeAccessKey(parameters) {
141
+ const prepared = await prepareKeyAuthorization(parameters)
142
+ const account = getAccount({ accessKey: false, signable: true })
143
+ return await signKeyAuthorization(account, prepared, { signature: parameters.signature })
144
+ },
145
+ async loadAccounts(parameters) {
146
+ const { authorizeAccessKey, ...rest } =
147
+ parameters ?? ({} as Adapter.loadAccounts.Parameters)
148
+
149
+ const keyAuthorization_unsigned = authorizeAccessKey
150
+ ? await prepareKeyAuthorization(authorizeAccessKey)
151
+ : undefined
152
+
153
+ const digest = (() => {
154
+ if (rest.digest) return rest.digest
155
+ if (keyAuthorization_unsigned?.keyAuthorization)
156
+ return KeyAuthorization.getSignPayload(keyAuthorization_unsigned.keyAuthorization)
157
+ return undefined
158
+ })()
159
+
160
+ // Pass the prepared digest (or the caller's) into loadAccounts so
161
+ // the ceremony can sign it in a single biometric prompt.
162
+ const { accounts, signature } = await loadAccounts({ ...rest, digest })
163
+
164
+ // Hydrate here (not from the store) — same reason as createAccount.
165
+ // Guard against empty accounts (e.g. user cancelled the ceremony).
166
+ const account = accounts[0] ? Account.hydrate(accounts[0], { signable: true }) : undefined
167
+
168
+ // Fall back to local signing if the adapter didn't return a signature.
169
+ let signature_ = signature
170
+ if (digest && !signature_ && account) signature_ = await account.sign({ hash: digest })
171
+
172
+ const keyAuthorization =
173
+ keyAuthorization_unsigned && account
174
+ ? await signKeyAuthorization(account, keyAuthorization_unsigned, {
175
+ signature: signature_,
176
+ })
177
+ : undefined
178
+
179
+ return { accounts, keyAuthorization, signature: signature_ }
180
+ },
181
+ async revokeAccessKey(parameters) {
182
+ AccessKey.revoke({
183
+ address: parameters.address,
184
+ store,
185
+ })
186
+ },
187
+ async signPersonalMessage({ data, address }) {
188
+ const account = getAccount({ address, signable: true })
189
+ return await account.signMessage({ message: { raw: data } })
190
+ },
191
+ async signTransaction(parameters) {
192
+ const { feePayer, ...rest } = parameters
193
+ const client = getClient({
194
+ feePayer: typeof feePayer === 'string' ? feePayer : undefined,
195
+ })
196
+ const { account, prepared } = await withAccessKey(async (account, keyAuthorization) => ({
197
+ account,
198
+ prepared: await prepareTransactionRequest(client, {
199
+ account,
200
+ ...rest,
201
+ ...(feePayer ? { feePayer: true } : {}),
202
+ keyAuthorization,
203
+ type: 'tempo',
204
+ }),
205
+ }))
206
+ return await account.signTransaction(prepared as never)
207
+ },
208
+ async signTypedData({ data, address }) {
209
+ const account = getAccount({ address, signable: true })
210
+ const { domain, types, primaryType, message } = JSON.parse(data)
211
+ return await account.signTypedData({ domain, types, primaryType, message })
212
+ },
213
+ async sendTransaction(parameters) {
214
+ const { feePayer, ...rest } = parameters
215
+ const client = getClient({
216
+ feePayer: typeof feePayer === 'string' ? feePayer : undefined,
217
+ })
218
+ const { account, prepared } = await withAccessKey(async (account, keyAuthorization) => ({
219
+ account,
220
+ prepared: await prepareTransactionRequest(client, {
221
+ account,
222
+ ...rest,
223
+ ...(feePayer ? { feePayer: true } : {}),
224
+ keyAuthorization,
225
+ type: 'tempo',
226
+ }),
227
+ }))
228
+ const signed = await account.signTransaction(prepared as never)
229
+ return await client.request({
230
+ method: 'eth_sendRawTransaction' as never,
231
+ params: [signed],
232
+ })
233
+ },
234
+ async sendTransactionSync(parameters) {
235
+ const { feePayer, ...rest } = parameters
236
+ const client = getClient({
237
+ feePayer: typeof feePayer === 'string' ? feePayer : undefined,
238
+ })
239
+ const { account, prepared } = await withAccessKey(async (account, keyAuthorization) => ({
240
+ account,
241
+ prepared: await prepareTransactionRequest(client, {
242
+ account,
243
+ ...rest,
244
+ ...(feePayer ? { feePayer: true } : {}),
245
+ keyAuthorization,
246
+ type: 'tempo',
247
+ }),
248
+ }))
249
+ const signed = await account.signTransaction(prepared as never)
250
+ return await client.request({
251
+ method: 'eth_sendRawTransactionSync' as never,
252
+ params: [signed],
253
+ })
254
+ },
255
+ },
256
+ }
257
+ })
258
+ }
259
+
260
+ export declare namespace local {
261
+ type Options = {
262
+ /** Create a new account. Optional — omit for login-only flows. */
263
+ createAccount?:
264
+ | ((params: Adapter.createAccount.Parameters) => Promise<Adapter.createAccount.ReturnType>)
265
+ | undefined
266
+ /** Discover existing accounts (e.g. WebAuthn assertion). */
267
+ loadAccounts: (
268
+ params?: Adapter.loadAccounts.Parameters | undefined,
269
+ ) => Promise<Adapter.loadAccounts.ReturnType>
270
+ /** Data URI of the provider icon. @default Black 1×1 SVG. */
271
+ icon?: `data:image/${string}` | undefined
272
+ /** Display name of the provider (e.g. `"My Wallet"`). @default "Injected Wallet" */
273
+ name?: string | undefined
274
+ /** Reverse DNS identifier. @default `com.{lowercase name}` */
275
+ rdns?: string | undefined
276
+ }
277
+ }
@@ -0,0 +1,129 @@
1
+ import { PublicKey, Signature } from 'ox'
2
+ import { SignatureEnvelope } from 'ox/tempo'
3
+ import { Account } from 'viem/tempo'
4
+ import { Authentication, Registration } from 'webauthx/client'
5
+
6
+ import type { OneOf } from '../../internal/types.js'
7
+ import * as Adapter from '../Adapter.js'
8
+ import * as Ceremony from '../Ceremony.js'
9
+ import { local } from './local.js'
10
+
11
+ /**
12
+ * Creates a WebAuthn adapter backed by real passkey ceremonies.
13
+ *
14
+ * Wraps the {@link local} adapter with WebAuthn registration and authentication flows,
15
+ * using the provided {@link Ceremony} for challenge generation and verification.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { webAuthn } from 'accounts'
20
+ *
21
+ * const provider = Provider.create({
22
+ * adapter: webAuthn(),
23
+ * })
24
+ * ```
25
+ */
26
+ export function webAuthn(options: webAuthn.Options = {}): Adapter.Adapter {
27
+ const { authUrl, icon, name, rdns } = options
28
+
29
+ return Adapter.define({ icon, name, rdns }, (parameters) => {
30
+ const { storage } = parameters
31
+
32
+ const ceremony =
33
+ options.ceremony ??
34
+ (authUrl ? Ceremony.server({ url: authUrl }) : Ceremony.local({ storage }))
35
+
36
+ const base = local({
37
+ async createAccount(parameters) {
38
+ const { options } = await ceremony.getRegistrationOptions(parameters)
39
+ const rpId = options.publicKey?.rp.id
40
+ if (!rpId) throw new Error('rpId is required')
41
+ const credential = await Registration.create({ options })
42
+ const { publicKey } = await ceremony.verifyRegistration(credential, {
43
+ name: parameters.name,
44
+ })
45
+ await storage.setItem('lastCredentialId', credential.id)
46
+ const account = Account.fromWebAuthnP256({ id: credential.id, publicKey })
47
+ return {
48
+ accounts: [
49
+ {
50
+ address: account.address,
51
+ keyType: 'webAuthn',
52
+ credential: { id: credential.id, publicKey, rpId },
53
+ },
54
+ ],
55
+ }
56
+ },
57
+ async loadAccounts(parameters = {}) {
58
+ const { selectAccount, digest } = parameters
59
+
60
+ const credentialId = selectAccount
61
+ ? undefined
62
+ : (parameters?.credentialId ??
63
+ (await storage.getItem<string>('lastCredentialId')) ??
64
+ undefined)
65
+
66
+ const { options } = await ceremony.getAuthenticationOptions({
67
+ ...parameters,
68
+ challenge: digest,
69
+ credentialId,
70
+ })
71
+
72
+ const rpId = options.publicKey?.rpId
73
+ if (!rpId) throw new Error('rpId is required')
74
+
75
+ const response = await Authentication.sign({ options })
76
+ const { publicKey } = await ceremony.verifyAuthentication(response)
77
+
78
+ await storage.setItem('lastCredentialId', response.id)
79
+
80
+ const account = Account.fromWebAuthnP256({ id: response.id, publicKey }, { rpId })
81
+
82
+ const signature = digest
83
+ ? SignatureEnvelope.serialize(
84
+ {
85
+ metadata: response.metadata,
86
+ publicKey: PublicKey.fromHex(publicKey),
87
+ signature: Signature.from(response.signature),
88
+ type: 'webAuthn',
89
+ },
90
+ { magic: true },
91
+ )
92
+ : undefined
93
+
94
+ return {
95
+ accounts: [
96
+ {
97
+ address: account.address,
98
+ keyType: 'webAuthn',
99
+ credential: { id: response.id, publicKey, rpId },
100
+ },
101
+ ],
102
+ signature,
103
+ }
104
+ },
105
+ })(parameters)
106
+
107
+ return { ...base, persistAccounts: true }
108
+ })
109
+ }
110
+
111
+ export declare namespace webAuthn {
112
+ type Options = OneOf<
113
+ | {
114
+ /** Ceremony strategy for WebAuthn registration and authentication. @default Ceremony.local() */
115
+ ceremony?: Ceremony.Ceremony | undefined
116
+ }
117
+ | {
118
+ /** URL of a WebAuthn handler (shorthand for `Ceremony.server({ url })`). */
119
+ authUrl?: string | undefined
120
+ }
121
+ > & {
122
+ /** Data URI of the provider icon. @default Black 1×1 SVG. */
123
+ icon?: `data:image/${string}` | undefined
124
+ /** Display name of the provider (e.g. `"My Wallet"`). @default "Injected Wallet" */
125
+ name?: string | undefined
126
+ /** Reverse DNS identifier. @default `com.{lowercase name}` */
127
+ rdns?: string | undefined
128
+ }
129
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import { withDedupe } from './withDedupe.js'
4
+
5
+ describe('withDedupe', () => {
6
+ test('default: returns result of fn', async () => {
7
+ const result = await withDedupe(() => Promise.resolve(42), { id: 'a' })
8
+ expect(result).toMatchInlineSnapshot(`42`)
9
+ })
10
+
11
+ test('behavior: deduplicates concurrent calls with same id', async () => {
12
+ let calls = 0
13
+ const fn = () => {
14
+ calls++
15
+ return new Promise<number>((resolve) => setTimeout(() => resolve(calls), 10))
16
+ }
17
+
18
+ const [a, b, c] = await Promise.all([
19
+ withDedupe(fn, { id: 'same' }),
20
+ withDedupe(fn, { id: 'same' }),
21
+ withDedupe(fn, { id: 'same' }),
22
+ ])
23
+
24
+ expect(calls).toMatchInlineSnapshot(`1`)
25
+ expect(a).toMatchInlineSnapshot(`1`)
26
+ expect(b).toMatchInlineSnapshot(`1`)
27
+ expect(c).toMatchInlineSnapshot(`1`)
28
+ })
29
+
30
+ test('behavior: different ids execute independently', async () => {
31
+ let calls = 0
32
+ const fn = () => {
33
+ calls++
34
+ return Promise.resolve(calls)
35
+ }
36
+
37
+ const [a, b] = await Promise.all([withDedupe(fn, { id: 'x' }), withDedupe(fn, { id: 'y' })])
38
+
39
+ expect(calls).toMatchInlineSnapshot(`2`)
40
+ expect(a).toMatchInlineSnapshot(`1`)
41
+ expect(b).toMatchInlineSnapshot(`2`)
42
+ })
43
+
44
+ test('behavior: cache is cleared after promise resolves', async () => {
45
+ const fn = () => Promise.resolve('ok')
46
+
47
+ await withDedupe(fn, { id: 'clear' })
48
+ expect(withDedupe.cache.has('clear')).toMatchInlineSnapshot(`false`)
49
+ })
50
+
51
+ test('behavior: cache is cleared after promise rejects', async () => {
52
+ const fn = () => Promise.reject(new Error('fail'))
53
+
54
+ await withDedupe(fn, { id: 'reject' }).catch(() => {})
55
+ expect(withDedupe.cache.has('reject')).toMatchInlineSnapshot(`false`)
56
+ })
57
+
58
+ test('behavior: rejection is shared across deduped calls', async () => {
59
+ let calls = 0
60
+ const fn = () => {
61
+ calls++
62
+ return Promise.reject(new Error('boom'))
63
+ }
64
+
65
+ const results = await Promise.allSettled([
66
+ withDedupe(fn, { id: 'err' }),
67
+ withDedupe(fn, { id: 'err' }),
68
+ ])
69
+
70
+ expect(calls).toMatchInlineSnapshot(`1`)
71
+ expect(results[0]!.status).toMatchInlineSnapshot(`"rejected"`)
72
+ expect(results[1]!.status).toMatchInlineSnapshot(`"rejected"`)
73
+ })
74
+
75
+ test('behavior: allows new call after previous settles', async () => {
76
+ let calls = 0
77
+ const fn = () => {
78
+ calls++
79
+ return Promise.resolve(calls)
80
+ }
81
+
82
+ const first = await withDedupe(fn, { id: 'seq' })
83
+ const second = await withDedupe(fn, { id: 'seq' })
84
+
85
+ expect(first).toMatchInlineSnapshot(`1`)
86
+ expect(second).toMatchInlineSnapshot(`2`)
87
+ expect(calls).toMatchInlineSnapshot(`2`)
88
+ })
89
+
90
+ test('behavior: bypasses when enabled is false', async () => {
91
+ let calls = 0
92
+ const fn = () => {
93
+ calls++
94
+ return Promise.resolve('ok')
95
+ }
96
+
97
+ await Promise.all([
98
+ withDedupe(fn, { id: 'dis', enabled: false }),
99
+ withDedupe(fn, { id: 'dis', enabled: false }),
100
+ ])
101
+
102
+ expect(calls).toMatchInlineSnapshot(`2`)
103
+ })
104
+
105
+ test('behavior: bypasses when id is undefined', async () => {
106
+ let calls = 0
107
+ const fn = () => {
108
+ calls++
109
+ return Promise.resolve('ok')
110
+ }
111
+
112
+ await Promise.all([withDedupe(fn, { id: undefined }), withDedupe(fn, { id: undefined })])
113
+
114
+ expect(calls).toMatchInlineSnapshot(`2`)
115
+ })
116
+ })
@@ -0,0 +1,20 @@
1
+ /** Deduplicates in-flight promises by key. */
2
+ export function withDedupe<data>(
3
+ fn: () => Promise<data>,
4
+ { enabled = true, id }: withDedupe.Options,
5
+ ): Promise<data> {
6
+ if (!enabled || !id) return fn()
7
+ if (withDedupe.cache.get(id)) return withDedupe.cache.get(id)!
8
+ const promise = fn().finally(() => withDedupe.cache.delete(id))
9
+ withDedupe.cache.set(id, promise)
10
+ return promise
11
+ }
12
+
13
+ export declare namespace withDedupe {
14
+ type Options = {
15
+ enabled?: boolean | undefined
16
+ id?: string | undefined
17
+ }
18
+ }
19
+
20
+ withDedupe.cache = new Map<string, Promise<any>>()
@@ -0,0 +1,83 @@
1
+ import { Mppx as ServerMppx, tempo } from 'mppx/server'
2
+ import { parseUnits } from 'viem'
3
+ import { Addresses } from 'viem/tempo'
4
+ import { Actions } from 'viem/tempo'
5
+ import { afterAll, beforeAll, describe, expect, test } from 'vp/test'
6
+
7
+ import { headlessWebAuthn } from '../../test/adapters.js'
8
+ import { accounts, chain, getClient } from '../../test/config.js'
9
+ import { type Server, createServer } from '../../test/utils.js'
10
+ import * as Provider from './Provider.js'
11
+
12
+ const recipient = accounts[1]!.address
13
+ const client = getClient()
14
+
15
+ const payment = ServerMppx.create({
16
+ methods: [
17
+ tempo({
18
+ recipient,
19
+ currency: Addresses.pathUsd,
20
+ getClient: () => client,
21
+ }),
22
+ ],
23
+ realm: 'mppx-test',
24
+ secretKey: 'test-secret-key',
25
+ })
26
+
27
+ let server: Server
28
+
29
+ beforeAll(async () => {
30
+ server = await createServer(async (req, res) => {
31
+ const result = await ServerMppx.toNodeListener(
32
+ payment.charge({
33
+ amount: '1',
34
+ }),
35
+ )(req, res)
36
+ if (result.status === 402) return
37
+ res.writeHead(200, { 'Content-Type': 'application/json' })
38
+ res.end(JSON.stringify({ fortune: 'Your code will compile on the first try.' }))
39
+ })
40
+ })
41
+
42
+ afterAll(() => server?.closeAsync())
43
+
44
+ describe('mppx integration', () => {
45
+ test('polyfilled fetch handles 402 charge automatically', async () => {
46
+ const provider = Provider.create({
47
+ adapter: headlessWebAuthn(),
48
+ chains: [chain],
49
+ mpp: true,
50
+ })
51
+
52
+ const address = await connect(provider)
53
+
54
+ const client = getClient()
55
+ await Actions.token.transferSync(client, {
56
+ account: accounts[0]!,
57
+ feeToken: Addresses.pathUsd,
58
+ to: address,
59
+ token: Addresses.pathUsd,
60
+ amount: parseUnits('10', 6),
61
+ })
62
+
63
+ const res = await fetch(`${server.url}/fortune`)
64
+ expect(res.status).toBe(200)
65
+
66
+ const body = await res.json()
67
+ expect(body).toMatchInlineSnapshot(`
68
+ {
69
+ "fortune": "Your code will compile on the first try.",
70
+ }
71
+ `)
72
+ })
73
+ })
74
+
75
+ async function connect(provider: ReturnType<typeof Provider.create>) {
76
+ const login = await provider.request({ method: 'wallet_connect' })
77
+ if (login.accounts.length > 0) return login.accounts[0]!.address
78
+ const register = await provider.request({
79
+ method: 'wallet_connect',
80
+ params: [{ capabilities: { method: 'register' } }],
81
+ })
82
+ return register.accounts[0]!.address
83
+ }