@etherkit/viem-tx-tracker 0.0.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.
@@ -0,0 +1,472 @@
1
+ import {
2
+ parseTransaction,
3
+ recoverTransactionAddress,
4
+ type Abi,
5
+ type Account,
6
+ type Address,
7
+ type Chain,
8
+ type ContractFunctionArgs,
9
+ type ContractFunctionName,
10
+ type Hash,
11
+ type PublicClient,
12
+ type TransactionReceipt,
13
+ type TransactionSerialized,
14
+ type Transport,
15
+ type WalletClient,
16
+ } from 'viem';
17
+ import {Emitter} from 'radiate';
18
+ import type {
19
+ BlockTag,
20
+ NonceOption,
21
+ TrackedRawTransactionParameters,
22
+ TrackedSendTransactionParameters,
23
+ TrackedTransaction,
24
+ TrackedWalletClient,
25
+ TrackedWriteContractParameters,
26
+ TransactionMetadata,
27
+ } from './types.js';
28
+
29
+ /**
30
+ * Check if a value is a block tag string
31
+ */
32
+ function isBlockTag(value: unknown): value is BlockTag {
33
+ return (
34
+ typeof value === 'string' &&
35
+ ['latest', 'pending', 'earliest', 'safe', 'finalized'].includes(value)
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Resolve the account address from various account formats
41
+ */
42
+ function resolveAccountAddress(
43
+ account: Account | Address | undefined | null,
44
+ ): Address | undefined {
45
+ if (!account) return undefined;
46
+ if (typeof account === 'string') return account;
47
+ return account.address;
48
+ }
49
+
50
+ /**
51
+ * Coerce potentially null account to undefined for type compatibility with extractTransactionContext
52
+ */
53
+ function normalizeAccount(
54
+ account: Account | Address | undefined | null,
55
+ ): Account | Address | undefined {
56
+ return account === null ? undefined : account;
57
+ }
58
+
59
+ /**
60
+ * Generate a unique tracking ID if not provided in metadata
61
+ */
62
+ function generateTrackingId(): string {
63
+ return crypto.randomUUID();
64
+ }
65
+
66
+ /**
67
+ * Context for transaction tracking - common data extracted from request
68
+ */
69
+ interface TransactionContext {
70
+ from: Address;
71
+ intendedNonce: number;
72
+ }
73
+
74
+ /**
75
+ * Create a tracked wallet client that wraps a viem WalletClient.
76
+ *
77
+ * The tracked client provides the same API as WalletClient but with:
78
+ * - Optional metadata field for transaction tracking
79
+ * - Automatic nonce fetching (with 'pending' by default)
80
+ * - Post-broadcast transaction verification
81
+ * - TODO: Event emission for tracking
82
+ *
83
+ * @param walletClient - The underlying viem WalletClient
84
+ * @param publicClient - A PublicClient for nonce fetching and tx verification
85
+ * @returns A TrackedWalletClient instance
86
+ */
87
+ export function createTrackedWalletClient<
88
+ TTransport extends Transport = Transport,
89
+ TChain extends Chain | undefined = Chain | undefined,
90
+ TAccount extends Account | undefined = Account | undefined,
91
+ >(
92
+ walletClient: WalletClient<TTransport, TChain, TAccount>,
93
+ publicClient: PublicClient,
94
+ ): TrackedWalletClient<TTransport, TChain, TAccount> {
95
+ // Create emitter for transaction broadcast events
96
+ const emitter = new Emitter<{
97
+ 'transaction:broadcasted': TrackedTransaction;
98
+ }>();
99
+
100
+ /**
101
+ * Resolve the nonce to use for a transaction.
102
+ *
103
+ * @param nonceOption - The nonce option provided by the caller
104
+ * @param from - The sender address
105
+ * @returns The resolved nonce number
106
+ */
107
+ async function resolveNonce(
108
+ nonceOption: NonceOption | undefined,
109
+ from: Address,
110
+ ): Promise<number> {
111
+ if (typeof nonceOption === 'number') {
112
+ // Explicit number - use as-is
113
+ return nonceOption;
114
+ }
115
+
116
+ // Block tag (string) or undefined - fetch from chain
117
+ const blockTag = isBlockTag(nonceOption) ? nonceOption : 'pending';
118
+ return await publicClient.getTransactionCount({
119
+ address: from,
120
+ blockTag,
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Extract common transaction context (account, nonce) from request args.
126
+ * This is the shared logic between all transaction methods.
127
+ *
128
+ * @param args - The transaction args containing account and nonce options
129
+ * @returns TransactionContext with resolved from address and nonce
130
+ */
131
+ async function extractTransactionContext(args: {
132
+ account?: Account | Address;
133
+ nonce?: NonceOption;
134
+ }): Promise<TransactionContext> {
135
+ // Get account/from address
136
+ const account = args.account ?? walletClient.account;
137
+ const from = resolveAccountAddress(account);
138
+
139
+ if (!from) {
140
+ throw new Error(
141
+ '[TrackedWalletClient] No account available. ' +
142
+ 'Provide an account in the request or configure the wallet client with an account.',
143
+ );
144
+ }
145
+
146
+ // Resolve nonce
147
+ const intendedNonce = await resolveNonce(args.nonce, from);
148
+
149
+ return {from, intendedNonce};
150
+ }
151
+
152
+ /**
153
+ * Extract transaction context from a serialized (signed) transaction.
154
+ * Parses the transaction and recovers the sender address.
155
+ *
156
+ * @param serializedTransaction - The RLP-encoded signed transaction
157
+ * @returns TransactionContext with from address and nonce
158
+ */
159
+ async function extractRawTransactionContext(
160
+ serializedTransaction: TransactionSerialized,
161
+ ): Promise<TransactionContext> {
162
+ // Parse the serialized transaction to get the nonce
163
+ const parsedTx = parseTransaction(serializedTransaction);
164
+
165
+ if (parsedTx.nonce === undefined) {
166
+ throw new Error(
167
+ '[TrackedWalletClient] Could not extract nonce from serialized transaction.',
168
+ );
169
+ }
170
+
171
+ // Recover the sender address from the signature
172
+ const from = await recoverTransactionAddress({
173
+ serializedTransaction,
174
+ });
175
+
176
+ return {
177
+ from,
178
+ intendedNonce: parsedTx.nonce,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Fetch the transaction after broadcast to verify nonce.
184
+ * Logs a warning if the nonce was overridden or if tx cannot be found.
185
+ *
186
+ * @param hash - The transaction hash
187
+ * @param intendedNonce - The nonce we intended to use
188
+ * @returns The actual nonce, or the intended nonce if fetch failed
189
+ */
190
+ async function verifyTransactionNonce(
191
+ hash: Hash,
192
+ intendedNonce: number,
193
+ ): Promise<number> {
194
+ try {
195
+ const tx = await publicClient.getTransaction({hash});
196
+ const actualNonce = tx.nonce;
197
+
198
+ if (actualNonce !== intendedNonce) {
199
+ console.warn(
200
+ `[TrackedWalletClient] Nonce mismatch: intended ${intendedNonce}, actual ${actualNonce}. ` +
201
+ `Wallet may have overridden the nonce.`,
202
+ );
203
+ }
204
+
205
+ return actualNonce;
206
+ } catch (fetchError) {
207
+ // Transaction not found in mempool/chain yet
208
+ console.warn(
209
+ `[TrackedWalletClient] Could not fetch tx ${hash} after broadcast. ` +
210
+ `It may not be in the mempool yet.`,
211
+ );
212
+ return intendedNonce;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Create a tracked transaction record.
218
+ */
219
+ function createTrackedTransaction<M extends TransactionMetadata>(
220
+ txHash: Hash,
221
+ from: Address,
222
+ nonce: number,
223
+ metadata: M | undefined,
224
+ request: unknown,
225
+ ): TrackedTransaction<M> {
226
+ return {
227
+ trackingId: metadata?.id ?? generateTrackingId(),
228
+ txHash,
229
+ from,
230
+ nonce,
231
+ chainId: walletClient.chain?.id ?? 1,
232
+ metadata: (metadata ?? {}) as M,
233
+ initiatedAt: Date.now(),
234
+ request,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Common wrapper for transaction methods that broadcast (sendTransaction, writeContract).
240
+ * Handles nonce resolution, underlying call, post-broadcast verification, and tracking record creation.
241
+ */
242
+ async function executeTrackedTransaction<T, R>(args: {
243
+ account?: Account | Address;
244
+ nonce?: NonceOption;
245
+ metadata?: TransactionMetadata;
246
+ restArgs: T;
247
+ execute: (argsWithNonce: T & {nonce: number}) => Promise<R>;
248
+ extractHash: (result: R) => Hash;
249
+ }): Promise<R> {
250
+ const {metadata, restArgs, execute, extractHash} = args;
251
+
252
+ // Extract common context
253
+ const {from, intendedNonce} = await extractTransactionContext(args);
254
+
255
+ // Execute the underlying transaction with nonce injected
256
+ const result = await execute({...restArgs, nonce: intendedNonce} as T & {
257
+ nonce: number;
258
+ });
259
+ const hash = extractHash(result);
260
+
261
+ // Verify transaction and get actual nonce
262
+ const actualNonce = await verifyTransactionNonce(hash, intendedNonce);
263
+
264
+ // Create tracked transaction record
265
+ const trackedTx = createTrackedTransaction(
266
+ hash,
267
+ from,
268
+ actualNonce,
269
+ metadata,
270
+ restArgs,
271
+ );
272
+
273
+ // Emit transaction broadcasted event
274
+ emitter.emit('transaction:broadcasted', trackedTx);
275
+
276
+ return result;
277
+ }
278
+
279
+ /**
280
+ * Common wrapper for raw transaction broadcasts (sendRawTransaction).
281
+ * Decodes the transaction to extract from/nonce, broadcasts, and creates tracking record.
282
+ */
283
+ async function executeTrackedRawTransaction<R>(args: {
284
+ serializedTransaction: TransactionSerialized;
285
+ metadata?: TransactionMetadata;
286
+ execute: () => Promise<R>;
287
+ extractHash: (result: R) => Hash;
288
+ }): Promise<R> {
289
+ const {serializedTransaction, metadata, execute, extractHash} = args;
290
+
291
+ // Extract context from the serialized transaction
292
+ const {from, intendedNonce} = await extractRawTransactionContext(
293
+ serializedTransaction,
294
+ );
295
+
296
+ // Execute the broadcast
297
+ const result = await execute();
298
+ const hash = extractHash(result);
299
+
300
+ // For raw transactions, the nonce is already embedded, so no verification needed
301
+ // (wallet cannot override nonce in an already-signed transaction)
302
+
303
+ // Create tracked transaction record
304
+ const trackedTx = createTrackedTransaction(
305
+ hash,
306
+ from,
307
+ intendedNonce,
308
+ metadata,
309
+ {serializedTransaction},
310
+ );
311
+
312
+ // Emit transaction broadcasted event
313
+ emitter.emit('transaction:broadcasted', trackedTx);
314
+
315
+ return result;
316
+ }
317
+
318
+ return {
319
+ walletClient,
320
+ publicClient,
321
+
322
+ // ============================================
323
+ // Async methods (return hash)
324
+ // ============================================
325
+
326
+ async writeContract<
327
+ const TAbi extends Abi | readonly unknown[],
328
+ TFunctionName extends ContractFunctionName<
329
+ TAbi,
330
+ 'nonpayable' | 'payable'
331
+ >,
332
+ TArgs extends ContractFunctionArgs<
333
+ TAbi,
334
+ 'nonpayable' | 'payable',
335
+ TFunctionName
336
+ >,
337
+ TChainOverride extends Chain | undefined = undefined,
338
+ >(
339
+ args: TrackedWriteContractParameters<
340
+ TAbi,
341
+ TFunctionName,
342
+ TArgs,
343
+ TChain,
344
+ TAccount,
345
+ TChainOverride
346
+ >,
347
+ ): Promise<Hash> {
348
+ const {metadata, nonce, ...writeArgs} = args;
349
+
350
+ return executeTrackedTransaction({
351
+ account: normalizeAccount(args.account),
352
+ nonce,
353
+ metadata,
354
+ restArgs: writeArgs,
355
+ execute: (argsWithNonce) =>
356
+ walletClient.writeContract(argsWithNonce as any),
357
+ extractHash: (hash) => hash,
358
+ });
359
+ },
360
+
361
+ async sendTransaction<TChainOverride extends Chain | undefined = undefined>(
362
+ args: TrackedSendTransactionParameters<TChain, TAccount, TChainOverride>,
363
+ ): Promise<Hash> {
364
+ const {metadata, nonce, ...sendArgs} = args;
365
+
366
+ return executeTrackedTransaction({
367
+ account: normalizeAccount(args.account),
368
+ nonce,
369
+ metadata,
370
+ restArgs: sendArgs,
371
+ execute: (argsWithNonce) =>
372
+ walletClient.sendTransaction(argsWithNonce as any),
373
+ extractHash: (hash) => hash,
374
+ });
375
+ },
376
+
377
+ async sendRawTransaction(
378
+ args: TrackedRawTransactionParameters,
379
+ ): Promise<Hash> {
380
+ const {metadata, serializedTransaction} = args;
381
+
382
+ return executeTrackedRawTransaction({
383
+ serializedTransaction,
384
+ metadata,
385
+ execute: () => walletClient.sendRawTransaction({serializedTransaction}),
386
+ extractHash: (hash) => hash,
387
+ });
388
+ },
389
+
390
+ // ============================================
391
+ // Sync methods (return receipt, wait for confirmation)
392
+ // ============================================
393
+
394
+ async writeContractSync<
395
+ const TAbi extends Abi | readonly unknown[],
396
+ TFunctionName extends ContractFunctionName<
397
+ TAbi,
398
+ 'nonpayable' | 'payable'
399
+ >,
400
+ TArgs extends ContractFunctionArgs<
401
+ TAbi,
402
+ 'nonpayable' | 'payable',
403
+ TFunctionName
404
+ >,
405
+ TChainOverride extends Chain | undefined = undefined,
406
+ >(
407
+ args: TrackedWriteContractParameters<
408
+ TAbi,
409
+ TFunctionName,
410
+ TArgs,
411
+ TChain,
412
+ TAccount,
413
+ TChainOverride
414
+ >,
415
+ ): Promise<TransactionReceipt> {
416
+ const {metadata, nonce, ...writeArgs} = args;
417
+
418
+ return executeTrackedTransaction({
419
+ account: normalizeAccount(args.account),
420
+ nonce,
421
+ metadata,
422
+ restArgs: writeArgs,
423
+ execute: (argsWithNonce) =>
424
+ walletClient.writeContractSync(argsWithNonce as any),
425
+ extractHash: (receipt) => receipt.transactionHash,
426
+ });
427
+ },
428
+
429
+ async sendTransactionSync<
430
+ TChainOverride extends Chain | undefined = undefined,
431
+ >(
432
+ args: TrackedSendTransactionParameters<TChain, TAccount, TChainOverride>,
433
+ ): Promise<TransactionReceipt> {
434
+ const {metadata, nonce, ...sendArgs} = args;
435
+
436
+ return executeTrackedTransaction({
437
+ account: normalizeAccount(args.account),
438
+ nonce,
439
+ metadata,
440
+ restArgs: sendArgs,
441
+ execute: (argsWithNonce) =>
442
+ walletClient.sendTransactionSync(argsWithNonce as any),
443
+ extractHash: (receipt) => receipt.transactionHash,
444
+ });
445
+ },
446
+
447
+ async sendRawTransactionSync(
448
+ args: TrackedRawTransactionParameters,
449
+ ): Promise<TransactionReceipt> {
450
+ const {metadata, serializedTransaction} = args;
451
+
452
+ return executeTrackedRawTransaction({
453
+ serializedTransaction,
454
+ metadata,
455
+ execute: () =>
456
+ walletClient.sendRawTransactionSync({serializedTransaction}),
457
+ extractHash: (receipt) => receipt.transactionHash,
458
+ });
459
+ },
460
+
461
+ // ============================================
462
+ // Event subscription methods
463
+ // ============================================
464
+
465
+ onTransactionBroadcasted: (listener: (event: TrackedTransaction) => void) =>
466
+ emitter.on('transaction:broadcasted', listener),
467
+
468
+ offTransactionBroadcasted: (
469
+ listener: (event: TrackedTransaction) => void,
470
+ ) => emitter.off('transaction:broadcasted', listener),
471
+ };
472
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Types
2
+ export type {
3
+ BlockTag,
4
+ ExpectedEvent,
5
+ NonceOption,
6
+ TrackedRawTransactionParameters,
7
+ TrackedSendTransactionParameters,
8
+ TrackedTransaction,
9
+ TrackedWalletClient,
10
+ TrackedWriteContractParameters,
11
+ TransactionMetadata,
12
+ } from './types.js';
13
+
14
+ // Factory
15
+ export {createTrackedWalletClient} from './TrackedWalletClient.js';