accounts 0.6.1 → 0.6.2

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 (50) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/core/Schema.d.ts +12 -12
  3. package/dist/core/adapters/dialog.d.ts.map +1 -1
  4. package/dist/core/adapters/dialog.js +3 -1
  5. package/dist/core/adapters/dialog.js.map +1 -1
  6. package/dist/core/zod/rpc.d.ts +9 -9
  7. package/dist/core/zod/rpc.js +1 -1
  8. package/dist/core/zod/rpc.js.map +1 -1
  9. package/dist/server/CliAuth.d.ts +11 -11
  10. package/dist/server/CliAuth.js +1 -1
  11. package/dist/server/CliAuth.js.map +1 -1
  12. package/dist/server/Handler.d.ts +4 -252
  13. package/dist/server/Handler.d.ts.map +1 -1
  14. package/dist/server/Handler.js +4 -573
  15. package/dist/server/Handler.js.map +1 -1
  16. package/dist/server/internal/handlers/codeAuth.d.ts +41 -0
  17. package/dist/server/internal/handlers/codeAuth.d.ts.map +1 -0
  18. package/dist/server/internal/handlers/codeAuth.js +104 -0
  19. package/dist/server/internal/handlers/codeAuth.js.map +1 -0
  20. package/dist/server/internal/handlers/feePayer.d.ts +73 -0
  21. package/dist/server/internal/handlers/feePayer.d.ts.map +1 -0
  22. package/dist/server/internal/handlers/feePayer.js +184 -0
  23. package/dist/server/internal/handlers/feePayer.js.map +1 -0
  24. package/dist/server/internal/handlers/relay.d.ts +148 -0
  25. package/dist/server/internal/handlers/relay.d.ts.map +1 -0
  26. package/dist/server/internal/handlers/relay.js +600 -0
  27. package/dist/server/internal/handlers/relay.js.map +1 -0
  28. package/dist/server/internal/handlers/utils.d.ts +12 -0
  29. package/dist/server/internal/handlers/utils.d.ts.map +1 -0
  30. package/dist/server/internal/handlers/utils.js +80 -0
  31. package/dist/server/internal/handlers/utils.js.map +1 -0
  32. package/dist/server/internal/handlers/webAuthn.d.ts +57 -0
  33. package/dist/server/internal/handlers/webAuthn.d.ts.map +1 -0
  34. package/dist/server/internal/handlers/webAuthn.js +143 -0
  35. package/dist/server/internal/handlers/webAuthn.js.map +1 -0
  36. package/package.json +2 -2
  37. package/src/core/Provider.connect.browser.test.ts +23 -2
  38. package/src/core/adapters/dialog.ts +6 -1
  39. package/src/core/zod/rpc.ts +1 -1
  40. package/src/server/CliAuth.ts +1 -1
  41. package/src/server/Handler.test.ts +3 -418
  42. package/src/server/Handler.ts +5 -766
  43. package/src/server/internal/handlers/codeAuth.ts +148 -0
  44. package/src/server/internal/handlers/feePayer.test.ts +335 -0
  45. package/src/server/internal/handlers/feePayer.ts +271 -0
  46. package/src/server/internal/handlers/relay.test.ts +767 -0
  47. package/src/server/internal/handlers/relay.ts +817 -0
  48. package/src/server/internal/handlers/utils.ts +96 -0
  49. package/src/server/internal/handlers/webAuthn.test.ts +170 -0
  50. package/src/server/internal/handlers/webAuthn.ts +213 -0
@@ -0,0 +1,148 @@
1
+ import type { Chain, Client, Transport } from 'viem'
2
+ import { createClient, http } from 'viem'
3
+ import { tempo, tempoModerato } from 'viem/chains'
4
+ import * as z from 'zod/mini'
5
+
6
+ import * as CliAuth from '../../CliAuth.js'
7
+ import { type Handler, from } from '../../Handler.js'
8
+
9
+ /**
10
+ * Instantiates a generic device-code handler for access-key bootstrap.
11
+ *
12
+ * Exposes 4 endpoints:
13
+ * - `GET /auth/pkce/pending/:code`
14
+ * - `POST /auth/pkce/code`
15
+ * - `POST /auth/pkce/poll/:code`
16
+ * - `POST /auth/pkce`
17
+ *
18
+ * @param options - Options.
19
+ * @returns Request handler.
20
+ */
21
+ export function codeAuth(options: codeAuth.Options = {}): Handler {
22
+ const {
23
+ chains = [tempo, tempoModerato],
24
+ now,
25
+ path = '/auth/pkce',
26
+ policy,
27
+ random,
28
+ store = CliAuth.Store.memory(),
29
+ transports = {},
30
+ ttlMs,
31
+ ...rest
32
+ } = options
33
+
34
+ const clients = new Map<number, Client>()
35
+ for (const chain of chains) {
36
+ const transport = transports[chain.id] ?? http()
37
+ clients.set(chain.id, createClient({ chain, transport }))
38
+ }
39
+
40
+ function getClient(chainId?: bigint | number): Client {
41
+ if (typeof chainId !== 'undefined') {
42
+ const id = Number(chainId)
43
+ const client = clients.get(id)
44
+ if (!client) throw new Error(`Chain ${id} not configured`)
45
+ return client
46
+ }
47
+ return clients.get(chains[0]!.id)!
48
+ }
49
+
50
+ const router = from(rest)
51
+
52
+ router.get(`${path}/pending/:code`, async (c) => {
53
+ try {
54
+ const code = c.req.param('code')
55
+ const result = await CliAuth.pending({
56
+ code,
57
+ ...(now ? { now } : {}),
58
+ store,
59
+ })
60
+
61
+ return Response.json(z.encode(CliAuth.pendingResponse, result))
62
+ } catch (error) {
63
+ const status = error instanceof CliAuth.PendingError ? error.status : 400
64
+ return Response.json({ error: (error as Error).message }, { status })
65
+ }
66
+ })
67
+
68
+ router.post(`${path}/code`, async (c) => {
69
+ try {
70
+ const request = z.decode(CliAuth.createRequest, await c.req.raw.json())
71
+ const chainId = request.chainId ?? chains[0]!.id
72
+ getClient(chainId)
73
+ const result = await CliAuth.createDeviceCode({
74
+ chainId,
75
+ ...(now ? { now } : {}),
76
+ ...(policy ? { policy } : {}),
77
+ ...(random ? { random } : {}),
78
+ request,
79
+ store,
80
+ ...(typeof ttlMs !== 'undefined' ? { ttlMs } : {}),
81
+ })
82
+
83
+ return Response.json(z.encode(CliAuth.createResponse, result))
84
+ } catch (error) {
85
+ return Response.json({ error: (error as Error).message }, { status: 400 })
86
+ }
87
+ })
88
+
89
+ router.post(`${path}/poll/:code`, async (c) => {
90
+ try {
91
+ const request = z.decode(CliAuth.pollRequest, await c.req.raw.json())
92
+ const code = c.req.param('code')
93
+ const result = await CliAuth.poll({
94
+ code,
95
+ ...(now ? { now } : {}),
96
+ request,
97
+ store,
98
+ })
99
+
100
+ return Response.json(z.encode(CliAuth.pollResponse, result))
101
+ } catch (error) {
102
+ return Response.json({ error: (error as Error).message }, { status: 400 })
103
+ }
104
+ })
105
+
106
+ router.post(path, async (c) => {
107
+ try {
108
+ const request = z.decode(CliAuth.authorizeRequest, await c.req.raw.json())
109
+ const result = await CliAuth.authorize({
110
+ client: getClient(request.keyAuthorization.chainId),
111
+ ...(now ? { now } : {}),
112
+ request,
113
+ store,
114
+ })
115
+
116
+ return Response.json(z.encode(CliAuth.authorizeResponse, result))
117
+ } catch (error) {
118
+ return Response.json({ error: (error as Error).message }, { status: 400 })
119
+ }
120
+ })
121
+
122
+ return router
123
+ }
124
+
125
+ export declare namespace codeAuth {
126
+ export type Options = from.Options & {
127
+ /**
128
+ * Supported chains. The handler resolves the client based on chain IDs carried
129
+ * by device-code requests and key authorizations.
130
+ * @default [tempo, tempoModerato]
131
+ */
132
+ chains?: readonly [Chain, ...Chain[]] | undefined
133
+ /** Time source used for TTL evaluation. */
134
+ now?: (() => number) | undefined
135
+ /** Path prefix for the code auth endpoints. @default "/auth/pkce" */
136
+ path?: string | undefined
137
+ /** Policy used to validate and default requested CLI auth fields. */
138
+ policy?: CliAuth.Policy | undefined
139
+ /** Random byte generator used for device-code allocation. */
140
+ random?: ((size: number) => Uint8Array) | undefined
141
+ /** Device-code store. */
142
+ store?: CliAuth.Store | undefined
143
+ /** Transports keyed by chain ID. Defaults to `http()` for each chain. */
144
+ transports?: Record<number, Transport> | undefined
145
+ /** Pending entry TTL in milliseconds. @default 600000 */
146
+ ttlMs?: number | undefined
147
+ }
148
+ }
@@ -0,0 +1,335 @@
1
+ import type { RpcRequest } from 'ox'
2
+ import { SignatureEnvelope, Transaction as core_Transaction, TxEnvelopeTempo } from 'ox/tempo'
3
+ import { parseUnits } from 'viem'
4
+ import { sendTransactionSync } from 'viem/actions'
5
+ import { Actions, Transaction, withFeePayer } from 'viem/tempo'
6
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vp/test'
7
+
8
+ import { accounts, addresses, chain, getClient, http } from '../../../../test/config.js'
9
+ import { createServer, type Server } from '../../../../test/utils.js'
10
+ import { feePayer } from './feePayer.js'
11
+
12
+ const userAccount = accounts[9]!
13
+ const feePayerAccount = accounts[0]!
14
+
15
+ let server: Server
16
+ let fp: ReturnType<typeof getClient>
17
+ let requests: RpcRequest.RpcRequest[] = []
18
+
19
+ beforeAll(async () => {
20
+ server = await createServer(
21
+ feePayer({
22
+ account: feePayerAccount,
23
+ chains: [chain],
24
+ transports: { [chain.id]: http() },
25
+ onRequest: async (request) => {
26
+ requests.push(request)
27
+ },
28
+ }).listener,
29
+ )
30
+ fp = getClient({ transport: http(server.url) })
31
+ })
32
+
33
+ afterAll(() => {
34
+ server.close()
35
+ process.on('SIGINT', () => {
36
+ server.close()
37
+ process.exit(0)
38
+ })
39
+ process.on('SIGTERM', () => {
40
+ server.close()
41
+ process.exit(0)
42
+ })
43
+ })
44
+
45
+ afterEach(() => {
46
+ requests = []
47
+ })
48
+
49
+ /** Signs a sponsor-bound Tempo transaction, preserving the feePayerSignature. */
50
+ async function signSponsoredTx(account: (typeof accounts)[number], transaction: object) {
51
+ const serialized = (await Transaction.serialize(transaction as never)) as `0x76${string}`
52
+ const envelope = TxEnvelopeTempo.deserialize(serialized)
53
+ const signature = await account.sign({
54
+ hash: TxEnvelopeTempo.getSignPayload(envelope),
55
+ })
56
+ return TxEnvelopeTempo.serialize(envelope, {
57
+ signature: SignatureEnvelope.from(signature),
58
+ })
59
+ }
60
+
61
+ describe('POST /', () => {
62
+ test('default: eth_fillTransaction returns a sponsor-bound transaction the sender can broadcast', async () => {
63
+ const response = (await fp.request({
64
+ method: 'eth_fillTransaction',
65
+ params: [
66
+ {
67
+ chainId: chain.id,
68
+ feePayer: true,
69
+ from: userAccount.address,
70
+ to: '0x0000000000000000000000000000000000000000',
71
+ },
72
+ ],
73
+ })) as {
74
+ sponsor: { address: string }
75
+ tx: Record<string, unknown>
76
+ }
77
+ const prepared = core_Transaction.fromRpc(response.tx as never) as {
78
+ feePayerSignature?: unknown
79
+ }
80
+ const signed = await signSponsoredTx(userAccount, prepared)
81
+ const receipt = (await getClient().request({
82
+ method: 'eth_sendRawTransactionSync',
83
+ params: [signed],
84
+ })) as { feePayer?: string | undefined }
85
+
86
+ expect(response.sponsor.address).toBe(feePayerAccount.address)
87
+ expect(prepared?.feePayerSignature).toBeDefined()
88
+ expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
89
+ expect(requests.map(({ method }) => method)).toMatchInlineSnapshot(`
90
+ [
91
+ "eth_fillTransaction",
92
+ ]
93
+ `)
94
+ })
95
+
96
+ test('behavior: mutating a sponsor-bound transaction invalidates the fee payer binding', async () => {
97
+ const response = (await fp.request({
98
+ method: 'eth_fillTransaction',
99
+ params: [
100
+ {
101
+ chainId: chain.id,
102
+ feePayer: true,
103
+ from: userAccount.address,
104
+ to: '0x0000000000000000000000000000000000000000',
105
+ },
106
+ ],
107
+ })) as { tx: Record<string, unknown> }
108
+ const prepared = core_Transaction.fromRpc(response.tx as never) as {
109
+ gas?: bigint | undefined
110
+ feePayerSignature?: unknown
111
+ }
112
+ const signed = await signSponsoredTx(userAccount, {
113
+ ...prepared,
114
+ gas: (prepared?.gas ?? 0n) + 1n,
115
+ })
116
+
117
+ await expect(
118
+ getClient().request({
119
+ method: 'eth_sendRawTransactionSync',
120
+ params: [signed],
121
+ }),
122
+ ).rejects.toThrowError()
123
+ })
124
+
125
+ test('behavior: eth_signRawTransaction', async () => {
126
+ const client = getClient({
127
+ account: userAccount,
128
+ transport: withFeePayer(http(), http(server.url)),
129
+ })
130
+
131
+ const receipt = await sendTransactionSync(client, {
132
+ feePayer: true,
133
+ to: '0x0000000000000000000000000000000000000000',
134
+ })
135
+
136
+ expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
137
+
138
+ expect(requests.map(({ method }) => method)).toMatchInlineSnapshot(`
139
+ [
140
+ "eth_signRawTransaction",
141
+ ]
142
+ `)
143
+ })
144
+
145
+ test('behavior: eth_sendRawTransaction', async () => {
146
+ const client = getClient({
147
+ account: userAccount,
148
+ transport: withFeePayer(http(), http(server.url), {
149
+ policy: 'sign-and-broadcast',
150
+ }),
151
+ })
152
+
153
+ const receipt = await sendTransactionSync(client, {
154
+ feePayer: true,
155
+ to: '0x0000000000000000000000000000000000000000',
156
+ })
157
+
158
+ expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
159
+
160
+ expect(requests.map(({ method }) => method)).toMatchInlineSnapshot(`
161
+ [
162
+ "eth_sendRawTransactionSync",
163
+ ]
164
+ `)
165
+ })
166
+
167
+ test('behavior: eth_sendRawTransactionSync', async () => {
168
+ const client = getClient({
169
+ account: userAccount,
170
+ transport: withFeePayer(http(), http(server.url), {
171
+ policy: 'sign-and-broadcast',
172
+ }),
173
+ })
174
+
175
+ const receipt = await sendTransactionSync(client, {
176
+ feePayer: true,
177
+ to: '0x0000000000000000000000000000000000000000',
178
+ })
179
+
180
+ expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
181
+
182
+ expect(requests.map(({ method }) => method)).toMatchInlineSnapshot(`
183
+ [
184
+ "eth_sendRawTransactionSync",
185
+ ]
186
+ `)
187
+ })
188
+
189
+ test('behavior: unsupported method', async () => {
190
+ await expect(fp.request({ method: 'eth_chainId' })).rejects.toThrowError()
191
+ })
192
+
193
+ test('behavior: internal error', async () => {
194
+ await expect(
195
+ fp.request({
196
+ method: 'eth_signRawTransaction' as never,
197
+ params: ['0xinvalid'],
198
+ }),
199
+ ).rejects.toThrowError()
200
+ })
201
+ })
202
+
203
+ describe('behavior: conditional sponsoring', () => {
204
+ const rejectedAccount = accounts[3]!
205
+ let conditionalServer: Server
206
+ let conditionalFp: ReturnType<typeof getClient>
207
+
208
+ beforeAll(async () => {
209
+ // Fund accounts with alphaUsd for transfers + fee payment.
210
+ const rpc = getClient()
211
+ await Actions.token.mintSync(rpc, {
212
+ account: accounts[0]!,
213
+ token: addresses.alphaUsd,
214
+ amount: parseUnits('100', 6),
215
+ to: userAccount.address,
216
+ })
217
+ await Actions.fee.setUserToken(rpc, { account: userAccount, token: addresses.alphaUsd })
218
+ await Actions.token.mintSync(rpc, {
219
+ account: accounts[0]!,
220
+ token: addresses.alphaUsd,
221
+ amount: parseUnits('100', 6),
222
+ to: rejectedAccount.address,
223
+ })
224
+ await Actions.fee.setUserToken(rpc, { account: rejectedAccount, token: addresses.alphaUsd })
225
+
226
+ conditionalServer = await createServer(
227
+ feePayer({
228
+ account: feePayerAccount,
229
+ chains: [chain],
230
+ transports: { [chain.id]: http() },
231
+ validate: (request) =>
232
+ request.from?.toLowerCase() !== rejectedAccount.address.toLowerCase(),
233
+ }).listener,
234
+ )
235
+ conditionalFp = getClient({ transport: http(conditionalServer.url) })
236
+ })
237
+
238
+ afterAll(() => {
239
+ conditionalServer.close()
240
+ })
241
+
242
+ test('behavior: approved tx is sponsored and can be broadcast', async () => {
243
+ const { data, to } = Actions.token.transfer.call({
244
+ token: addresses.alphaUsd,
245
+ to: rejectedAccount.address,
246
+ amount: 1n,
247
+ })
248
+ const response = (await conditionalFp.request({
249
+ method: 'eth_fillTransaction',
250
+ params: [
251
+ {
252
+ chainId: chain.id,
253
+ feePayer: true,
254
+ from: userAccount.address,
255
+ to,
256
+ data,
257
+ },
258
+ ],
259
+ })) as { sponsor?: { address: string }; tx: Record<string, unknown> }
260
+
261
+ const prepared = core_Transaction.fromRpc(response.tx as never) as {
262
+ feePayerSignature?: unknown
263
+ }
264
+ expect(prepared.feePayerSignature).toBeDefined()
265
+ expect(response.sponsor?.address).toBe(feePayerAccount.address)
266
+
267
+ const signed = await signSponsoredTx(userAccount, prepared)
268
+ const receipt = (await getClient().request({
269
+ method: 'eth_sendRawTransactionSync',
270
+ params: [signed],
271
+ })) as { feePayer?: string | undefined }
272
+
273
+ expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
274
+ })
275
+
276
+ test('behavior: rejected tx is not sponsored and can be self-paid', async () => {
277
+ const { data, to } = Actions.token.transfer.call({
278
+ token: addresses.alphaUsd,
279
+ to: userAccount.address,
280
+ amount: 1n,
281
+ })
282
+ const response = (await conditionalFp.request({
283
+ method: 'eth_fillTransaction',
284
+ params: [
285
+ {
286
+ chainId: chain.id,
287
+ feePayer: true,
288
+ from: rejectedAccount.address,
289
+ to,
290
+ data,
291
+ },
292
+ ],
293
+ })) as { sponsor?: { address: string }; tx: Record<string, unknown> }
294
+
295
+ const prepared = core_Transaction.fromRpc(response.tx as never) as {
296
+ feePayerSignature?: unknown
297
+ }
298
+ expect(prepared.feePayerSignature).toBeUndefined()
299
+ expect(response.sponsor).toBeUndefined()
300
+
301
+ const signed = await signSponsoredTx(rejectedAccount, prepared)
302
+ const receipt = (await getClient().request({
303
+ method: 'eth_sendRawTransactionSync',
304
+ params: [signed],
305
+ })) as { feePayer?: string | undefined }
306
+
307
+ // Sender pays their own fee — no external fee payer.
308
+ expect(receipt.feePayer).not.toBe(feePayerAccount.address.toLowerCase())
309
+ })
310
+
311
+ test('behavior: rejected raw transaction returns error', async () => {
312
+ // First fill without the conditional server to get a signed tx.
313
+ const response = (await fp.request({
314
+ method: 'eth_fillTransaction',
315
+ params: [
316
+ {
317
+ chainId: chain.id,
318
+ feePayer: true,
319
+ from: rejectedAccount.address,
320
+ to: '0x0000000000000000000000000000000000000000',
321
+ },
322
+ ],
323
+ })) as { tx: Record<string, unknown> }
324
+ const prepared = core_Transaction.fromRpc(response.tx as never)
325
+ const signed = await signSponsoredTx(rejectedAccount, prepared)
326
+
327
+ // Submit the signed tx to the conditional server — should reject.
328
+ await expect(
329
+ conditionalFp.request({
330
+ method: 'eth_signRawTransaction' as never,
331
+ params: [signed],
332
+ }),
333
+ ).rejects.toThrowError()
334
+ })
335
+ })