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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/core/AccessKey.d.ts +55 -0
- package/dist/core/AccessKey.d.ts.map +1 -0
- package/dist/core/AccessKey.js +69 -0
- package/dist/core/AccessKey.js.map +1 -0
- package/dist/core/Account.d.ts +91 -0
- package/dist/core/Account.d.ts.map +1 -0
- package/dist/core/Account.js +64 -0
- package/dist/core/Account.js.map +1 -0
- package/dist/core/Adapter.d.ts +187 -0
- package/dist/core/Adapter.d.ts.map +1 -0
- package/dist/core/Adapter.js +7 -0
- package/dist/core/Adapter.js.map +1 -0
- package/dist/core/Ceremony.d.ts +109 -0
- package/dist/core/Ceremony.d.ts.map +1 -0
- package/dist/core/Ceremony.js +104 -0
- package/dist/core/Ceremony.js.map +1 -0
- package/dist/core/Client.d.ts +16 -0
- package/dist/core/Client.d.ts.map +1 -0
- package/dist/core/Client.js +18 -0
- package/dist/core/Client.js.map +1 -0
- package/dist/core/Dialog.d.ts +52 -0
- package/dist/core/Dialog.d.ts.map +1 -0
- package/dist/core/Dialog.js +342 -0
- package/dist/core/Dialog.js.map +1 -0
- package/dist/core/Expiry.d.ts +15 -0
- package/dist/core/Expiry.d.ts.map +1 -0
- package/dist/core/Expiry.js +29 -0
- package/dist/core/Expiry.js.map +1 -0
- package/dist/core/Messenger.d.ts +86 -0
- package/dist/core/Messenger.d.ts.map +1 -0
- package/dist/core/Messenger.js +127 -0
- package/dist/core/Messenger.js.map +1 -0
- package/dist/core/Provider.d.ts +69 -0
- package/dist/core/Provider.d.ts.map +1 -0
- package/dist/core/Provider.js +401 -0
- package/dist/core/Provider.js.map +1 -0
- package/dist/core/Remote.d.ts +114 -0
- package/dist/core/Remote.d.ts.map +1 -0
- package/dist/core/Remote.js +116 -0
- package/dist/core/Remote.js.map +1 -0
- package/dist/core/Schema.d.ts +805 -0
- package/dist/core/Schema.d.ts.map +1 -0
- package/dist/core/Schema.js +43 -0
- package/dist/core/Schema.js.map +1 -0
- package/dist/core/Storage.d.ts +42 -0
- package/dist/core/Storage.d.ts.map +1 -0
- package/dist/core/Storage.js +173 -0
- package/dist/core/Storage.js.map +1 -0
- package/dist/core/Store.d.ts +58 -0
- package/dist/core/Store.d.ts.map +1 -0
- package/dist/core/Store.js +58 -0
- package/dist/core/Store.js.map +1 -0
- package/dist/core/adapters/dangerous_secp256k1.d.ts +30 -0
- package/dist/core/adapters/dangerous_secp256k1.d.ts.map +1 -0
- package/dist/core/adapters/dangerous_secp256k1.js +39 -0
- package/dist/core/adapters/dangerous_secp256k1.js.map +1 -0
- package/dist/core/adapters/dialog.d.ts +31 -0
- package/dist/core/adapters/dialog.d.ts.map +1 -0
- package/dist/core/adapters/dialog.js +306 -0
- package/dist/core/adapters/dialog.js.map +1 -0
- package/dist/core/adapters/local.d.ts +33 -0
- package/dist/core/adapters/local.d.ts.map +1 -0
- package/dist/core/adapters/local.js +227 -0
- package/dist/core/adapters/local.js.map +1 -0
- package/dist/core/adapters/webAuthn.d.ts +36 -0
- package/dist/core/adapters/webAuthn.d.ts.map +1 -0
- package/dist/core/adapters/webAuthn.js +93 -0
- package/dist/core/adapters/webAuthn.js.map +1 -0
- package/dist/core/internal/withDedupe.d.ts +12 -0
- package/dist/core/internal/withDedupe.d.ts.map +1 -0
- package/dist/core/internal/withDedupe.js +12 -0
- package/dist/core/internal/withDedupe.js.map +1 -0
- package/dist/core/zod/request.d.ts +31 -0
- package/dist/core/zod/request.d.ts.map +1 -0
- package/dist/core/zod/request.js +41 -0
- package/dist/core/zod/request.js.map +1 -0
- package/dist/core/zod/rpc.d.ts +603 -0
- package/dist/core/zod/rpc.d.ts.map +1 -0
- package/dist/core/zod/rpc.js +293 -0
- package/dist/core/zod/rpc.js.map +1 -0
- package/dist/core/zod/utils.d.ts +18 -0
- package/dist/core/zod/utils.d.ts.map +1 -0
- package/dist/core/zod/utils.js +21 -0
- package/dist/core/zod/utils.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/types.d.ts +284 -0
- package/dist/internal/types.d.ts.map +1 -0
- package/dist/internal/types.js +2 -0
- package/dist/internal/types.js.map +1 -0
- package/dist/server/Handler.d.ts +257 -0
- package/dist/server/Handler.d.ts.map +1 -0
- package/dist/server/Handler.js +433 -0
- package/dist/server/Handler.js.map +1 -0
- package/dist/server/Kv.d.ts +16 -0
- package/dist/server/Kv.d.ts.map +1 -0
- package/dist/server/Kv.js +30 -0
- package/dist/server/Kv.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +3 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/internal/requestListener.d.ts +124 -0
- package/dist/server/internal/requestListener.d.ts.map +1 -0
- package/dist/server/internal/requestListener.js +173 -0
- package/dist/server/internal/requestListener.js.map +1 -0
- package/dist/wagmi/Connector.d.ts +93 -0
- package/dist/wagmi/Connector.d.ts.map +1 -0
- package/dist/wagmi/Connector.js +238 -0
- package/dist/wagmi/Connector.js.map +1 -0
- package/dist/wagmi/index.d.ts +3 -0
- package/dist/wagmi/index.d.ts.map +1 -0
- package/dist/wagmi/index.js +3 -0
- package/dist/wagmi/index.js.map +1 -0
- package/package.json +56 -2
- package/src/core/AccessKey.test.ts +257 -0
- package/src/core/AccessKey.ts +123 -0
- package/src/core/Account.test.ts +309 -0
- package/src/core/Account.ts +152 -0
- package/src/core/Adapter.ts +238 -0
- package/src/core/Ceremony.browser.test.ts +239 -0
- package/src/core/Ceremony.test.ts +151 -0
- package/src/core/Ceremony.ts +203 -0
- package/src/core/Client.ts +36 -0
- package/src/core/Dialog.browser.test.ts +309 -0
- package/src/core/Dialog.test-d.ts +19 -0
- package/src/core/Dialog.ts +442 -0
- package/src/core/Expiry.ts +34 -0
- package/src/core/Messenger.ts +206 -0
- package/src/core/Provider.browser.test.ts +774 -0
- package/src/core/Provider.connect.browser.test.ts +415 -0
- package/src/core/Provider.test-d.ts +53 -0
- package/src/core/Provider.test.ts +1566 -0
- package/src/core/Provider.ts +559 -0
- package/src/core/Remote.ts +262 -0
- package/src/core/Schema.test-d.ts +211 -0
- package/src/core/Schema.ts +143 -0
- package/src/core/Storage.ts +213 -0
- package/src/core/Store.test.ts +287 -0
- package/src/core/Store.ts +129 -0
- package/src/core/adapters/dangerous_secp256k1.ts +53 -0
- package/src/core/adapters/dialog.ts +379 -0
- package/src/core/adapters/local.test.ts +97 -0
- package/src/core/adapters/local.ts +277 -0
- package/src/core/adapters/webAuthn.ts +129 -0
- package/src/core/internal/withDedupe.test.ts +116 -0
- package/src/core/internal/withDedupe.ts +20 -0
- package/src/core/mppx.test.ts +83 -0
- package/src/core/zod/request.test.ts +121 -0
- package/src/core/zod/request.ts +70 -0
- package/src/core/zod/rpc.ts +374 -0
- package/src/core/zod/utils.test.ts +69 -0
- package/src/core/zod/utils.ts +40 -0
- package/src/index.ts +14 -0
- package/src/internal/types.ts +378 -0
- package/src/server/Handler.test.ts +1014 -0
- package/src/server/Handler.ts +605 -0
- package/src/server/Kv.ts +46 -0
- package/src/server/index.ts +2 -0
- package/src/server/internal/requestListener.ts +273 -0
- package/src/tsconfig.json +9 -0
- package/src/wagmi/Connector.ts +287 -0
- 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
|
+
}
|