@etherkit/viem-tx-tracker 0.0.4 → 0.0.6

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.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  parseTransaction,
3
+ ParseTransactionReturnType,
3
4
  recoverTransactionAddress,
4
5
  type Abi,
5
6
  type Account,
@@ -9,6 +10,7 @@ import {
9
10
  type ContractFunctionName,
10
11
  type Hash,
11
12
  type PublicClient,
13
+ type Transaction,
12
14
  type TransactionReceipt,
13
15
  type TransactionSerialized,
14
16
  type Transport,
@@ -16,14 +18,20 @@ import {
16
18
  } from 'viem';
17
19
  import {Emitter} from 'radiate';
18
20
  import type {
21
+ AccessList,
19
22
  BlockTag,
20
- MetadataField,
23
+ CreateTrackedWalletClientOptions,
24
+ KnownTrackedTransaction,
21
25
  NonceOption,
26
+ PopulatedMetadata,
22
27
  TrackedRawTransactionParameters,
23
28
  TrackedSendTransactionParameters,
24
29
  TrackedTransaction,
25
30
  TrackedWalletClient,
31
+ TrackedWalletClientAutoPopulate,
32
+ TrackedWriteContractAutoPopulateParameters,
26
33
  TrackedWriteContractParameters,
34
+ UnknownTrackedTransaction,
27
35
  } from './types.js';
28
36
 
29
37
  /**
@@ -56,13 +64,6 @@ function normalizeAccount(
56
64
  return account === null ? undefined : account;
57
65
  }
58
66
 
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
  /**
67
68
  * Context for transaction tracking - common data extracted from request
68
69
  */
@@ -71,6 +72,253 @@ interface TransactionContext {
71
72
  intendedNonce: number;
72
73
  }
73
74
 
75
+ /**
76
+ * Parameters we can extract from writeContract/sendTransaction calls.
77
+ * These are the intended values - wallet may modify them.
78
+ */
79
+ interface IntendedTransactionParams {
80
+ to?: Address | null;
81
+ value?: bigint;
82
+ data?: `0x${string}`;
83
+ gas?: bigint;
84
+ gasPrice?: bigint;
85
+ maxFeePerGas?: bigint;
86
+ maxPriorityFeePerGas?: bigint;
87
+ accessList?: AccessList;
88
+ }
89
+
90
+ /**
91
+ * Infer transaction type from provided params.
92
+ * Returns undefined if can't be determined (wallet will decide).
93
+ */
94
+ function inferTxType(
95
+ params: IntendedTransactionParams,
96
+ ): 'eip1559' | 'legacy' | 'eip2930' | undefined {
97
+ if (params.maxFeePerGas !== undefined) {
98
+ return 'eip1559';
99
+ }
100
+ if (params.gasPrice !== undefined && params.accessList !== undefined) {
101
+ return 'eip2930';
102
+ }
103
+ if (params.gasPrice !== undefined) {
104
+ return 'legacy';
105
+ }
106
+ return undefined; // Wallet will determine
107
+ }
108
+
109
+ /**
110
+ * Create an UnknownTrackedTransaction for immediate emission.
111
+ * Populates all known intended values from the transaction parameters.
112
+ */
113
+ function createUnknownTrackedTransaction<TMetadata>(
114
+ hash: Hash,
115
+ from: Address,
116
+ nonce: number | undefined,
117
+ chainId: number | undefined,
118
+ metadata: TMetadata,
119
+ broadcastTimestampMs: number,
120
+ params: IntendedTransactionParams,
121
+ ): UnknownTrackedTransaction<TMetadata> {
122
+ const txType = inferTxType(params);
123
+ return {
124
+ known: false,
125
+ chainId,
126
+ hash,
127
+ from,
128
+ nonce,
129
+ broadcastTimestampMs,
130
+ metadata,
131
+ ...(txType !== undefined && {txType}),
132
+ ...(params.to !== undefined && {to: params.to}),
133
+ ...(params.value !== undefined && {value: params.value}),
134
+ ...(params.data !== undefined && {data: params.data}),
135
+ ...(params.gas !== undefined && {gas: params.gas}),
136
+ ...(params.gasPrice !== undefined && {gasPrice: params.gasPrice}),
137
+ ...(params.maxFeePerGas !== undefined && {
138
+ maxFeePerGas: params.maxFeePerGas,
139
+ }),
140
+ ...(params.maxPriorityFeePerGas !== undefined && {
141
+ maxPriorityFeePerGas: params.maxPriorityFeePerGas,
142
+ }),
143
+ ...(params.accessList !== undefined && {accessList: params.accessList}),
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Extract transaction type-specific fields from a fetched transaction.
149
+ */
150
+ function extractTransactionTypeFields(tx: Transaction): {
151
+ txType: 'eip1559' | 'legacy' | 'eip2930';
152
+ gasPrice?: bigint;
153
+ maxFeePerGas?: bigint;
154
+ maxPriorityFeePerGas?: bigint;
155
+ accessList?: AccessList;
156
+ } {
157
+ if (tx.type === 'eip1559') {
158
+ return {
159
+ txType: 'eip1559',
160
+ maxFeePerGas: tx.maxFeePerGas!,
161
+ maxPriorityFeePerGas: tx.maxPriorityFeePerGas!,
162
+ ...(tx.accessList && {accessList: tx.accessList as AccessList}),
163
+ };
164
+ } else if (tx.type === 'eip2930') {
165
+ return {
166
+ txType: 'eip2930',
167
+ gasPrice: tx.gasPrice!,
168
+ accessList: (tx.accessList ?? []) as AccessList,
169
+ };
170
+ } else {
171
+ // Legacy or unknown - treat as legacy
172
+ return {
173
+ txType: 'legacy',
174
+ gasPrice: tx.gasPrice!,
175
+ };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Create a KnownTrackedTransaction from a fetched transaction.
181
+ */
182
+ function createKnownTrackedTransaction<TMetadata>(
183
+ tx: Transaction,
184
+ metadata: TMetadata,
185
+ broadcastTimestampMs: number,
186
+ ): KnownTrackedTransaction<TMetadata> {
187
+ const typeFields = extractTransactionTypeFields(tx);
188
+
189
+ return {
190
+ known: true,
191
+ chainId: tx.chainId!,
192
+ hash: tx.hash,
193
+ from: tx.from,
194
+ to: tx.to,
195
+ nonce: tx.nonce,
196
+ value: tx.value,
197
+ data: tx.input,
198
+ gas: tx.gas,
199
+ broadcastTimestampMs,
200
+ metadata,
201
+ ...typeFields,
202
+ } as KnownTrackedTransaction<TMetadata>;
203
+ }
204
+
205
+ /**
206
+ * Create a KnownTrackedTransaction from a parsed raw transaction.
207
+ */
208
+ function createKnownTrackedTransactionFromRaw<TMetadata>(
209
+ parsedTx: ParseTransactionReturnType<`0x${string}`>,
210
+ from: `0x${string}`,
211
+ hash: Hash,
212
+ metadata: TMetadata,
213
+ chainId: number | undefined,
214
+ broadcastTimestampMs: number,
215
+ ): KnownTrackedTransaction<TMetadata> {
216
+ // Determine transaction type from parsed tx
217
+ let typeFields: {
218
+ txType: 'eip1559' | 'legacy' | 'eip2930';
219
+ gasPrice?: bigint;
220
+ maxFeePerGas?: bigint;
221
+ maxPriorityFeePerGas?: bigint;
222
+ accessList?: AccessList;
223
+ };
224
+
225
+ if ('maxFeePerGas' in parsedTx && parsedTx.maxFeePerGas !== undefined) {
226
+ typeFields = {
227
+ txType: 'eip1559',
228
+ maxFeePerGas: parsedTx.maxFeePerGas,
229
+ maxPriorityFeePerGas: parsedTx.maxPriorityFeePerGas!,
230
+ ...('accessList' in parsedTx &&
231
+ parsedTx.accessList && {
232
+ accessList: parsedTx.accessList as AccessList,
233
+ }),
234
+ };
235
+ } else if ('accessList' in parsedTx && parsedTx.accessList) {
236
+ typeFields = {
237
+ txType: 'eip2930',
238
+ gasPrice: parsedTx.gasPrice!,
239
+ accessList: parsedTx.accessList as AccessList,
240
+ };
241
+ } else {
242
+ typeFields = {
243
+ txType: 'legacy',
244
+ gasPrice: parsedTx.gasPrice!,
245
+ };
246
+ }
247
+
248
+ return {
249
+ known: true,
250
+ chainId: parsedTx.chainId ?? chainId!,
251
+ hash,
252
+ from,
253
+ to: parsedTx.to ?? null,
254
+ nonce: parsedTx.nonce!,
255
+ value: parsedTx.value ?? 0n,
256
+ data: parsedTx.data ?? '0x',
257
+ gas: parsedTx.gas!,
258
+ broadcastTimestampMs,
259
+ metadata,
260
+ ...typeFields,
261
+ } as KnownTrackedTransaction<TMetadata>;
262
+ }
263
+
264
+ /**
265
+ * Extract intended params from sendTransaction args.
266
+ * Uses 'unknown' for 'to' since viem's SendTransactionParameters has a complex type for it.
267
+ */
268
+ function extractIntendedParamsFromSendTransaction(args: {
269
+ to?: unknown;
270
+ value?: bigint;
271
+ data?: `0x${string}`;
272
+ gas?: bigint;
273
+ gasPrice?: bigint;
274
+ maxFeePerGas?: bigint;
275
+ maxPriorityFeePerGas?: bigint;
276
+ accessList?: AccessList;
277
+ }): IntendedTransactionParams {
278
+ // Normalize 'to' to either a hex address, null, or undefined
279
+ const to =
280
+ args.to === null || args.to === undefined
281
+ ? (args.to as null | undefined)
282
+ : typeof args.to === 'string'
283
+ ? (args.to as Address)
284
+ : undefined;
285
+
286
+ return {
287
+ to,
288
+ value: args.value ?? 0n,
289
+ data: args.data,
290
+ gas: args.gas,
291
+ gasPrice: args.gasPrice,
292
+ maxFeePerGas: args.maxFeePerGas,
293
+ maxPriorityFeePerGas: args.maxPriorityFeePerGas,
294
+ accessList: args.accessList,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Extract intended params from writeContract args.
300
+ */
301
+ function extractIntendedParamsFromWriteContract(args: {
302
+ address: Address;
303
+ value?: bigint;
304
+ gas?: bigint;
305
+ gasPrice?: bigint;
306
+ maxFeePerGas?: bigint;
307
+ maxPriorityFeePerGas?: bigint;
308
+ accessList?: AccessList;
309
+ }): IntendedTransactionParams {
310
+ return {
311
+ to: args.address,
312
+ value: args.value ?? 0n,
313
+ // Note: data could be encoded using encodeFunctionData(args) if desired
314
+ gas: args.gas,
315
+ gasPrice: args.gasPrice,
316
+ maxFeePerGas: args.maxFeePerGas,
317
+ maxPriorityFeePerGas: args.maxPriorityFeePerGas,
318
+ accessList: args.accessList,
319
+ };
320
+ }
321
+
74
322
  /**
75
323
  * Infer transport type from WalletClient
76
324
  */
@@ -113,6 +361,31 @@ export interface TrackedWalletClientBuilder<TMetadata> {
113
361
  >;
114
362
  }
115
363
 
364
+ /**
365
+ * Builder interface returned by createTrackedWalletClient with populateMetadata: true.
366
+ * This builder returns a TrackedWalletClientAutoPopulate that auto-populates operation, functionName and args.
367
+ * TMetadata must be a type where FunctionCallMetadata is assignable to it.
368
+ */
369
+ export interface TrackedWalletClientAutoPopulateBuilder<TMetadata> {
370
+ /**
371
+ * Create the tracked wallet client using the provided wallet and public clients.
372
+ * writeContract and writeContractSync will automatically populate operation, functionName and args.
373
+ *
374
+ * @param walletClient - The underlying viem WalletClient
375
+ * @param publicClient - A PublicClient for nonce fetching and tx verification
376
+ * @returns A TrackedWalletClientAutoPopulate instance
377
+ */
378
+ using<TClient extends WalletClient>(
379
+ walletClient: TClient,
380
+ publicClient: PublicClient,
381
+ ): TrackedWalletClientAutoPopulate<
382
+ TMetadata,
383
+ InferTransport<TClient>,
384
+ InferChain<TClient>,
385
+ InferAccount<TClient>
386
+ >;
387
+ }
388
+
116
389
  /**
117
390
  * Create a tracked wallet client that wraps a viem WalletClient.
118
391
  *
@@ -127,18 +400,54 @@ export interface TrackedWalletClientBuilder<TMetadata> {
127
400
  *
128
401
  * @example
129
402
  * ```typescript
130
- * // With required metadata
403
+ * // Standard mode with required metadata
131
404
  * const tracked = createTrackedWalletClient<{purpose: string}>()
132
405
  * .using(walletClient, publicClient);
133
406
  *
134
- * // With optional metadata
407
+ * // Standard mode with optional metadata
135
408
  * const tracked = createTrackedWalletClient<{purpose: string} | undefined>()
136
409
  * .using(walletClient, publicClient);
410
+ *
411
+ * // Auto-populate mode - functionName and args are auto-populated
412
+ * const tracked = createTrackedWalletClient({ populateMetadata: true })
413
+ * .using(walletClient, publicClient);
414
+ *
415
+ * // Auto-populate mode with extended metadata
416
+ * type MyMetadata = OperationMetadata & { purpose: string };
417
+ * const tracked = createTrackedWalletClient<MyMetadata>({ populateMetadata: true })
418
+ * .using(walletClient, publicClient);
137
419
  * ```
138
420
  */
421
+ // Overload 1: Standard mode, no options
139
422
  export function createTrackedWalletClient<
140
423
  TMetadata,
141
- >(): TrackedWalletClientBuilder<TMetadata> {
424
+ >(): TrackedWalletClientBuilder<TMetadata>;
425
+
426
+ // Overload 2: Auto-populate mode with default PopulatedMetadata
427
+ export function createTrackedWalletClient(options: {
428
+ populateMetadata: true;
429
+ }): TrackedWalletClientAutoPopulateBuilder<PopulatedMetadata>;
430
+
431
+ // Overload 3: Auto-populate mode with custom metadata (must allow FunctionCallMetadata)
432
+ export function createTrackedWalletClient<TMetadata>(options: {
433
+ populateMetadata: true;
434
+ }): TrackedWalletClientAutoPopulateBuilder<TMetadata>;
435
+
436
+ // Implementation
437
+ export function createTrackedWalletClient<TMetadata>(
438
+ options?: CreateTrackedWalletClientOptions<boolean>,
439
+ ):
440
+ | TrackedWalletClientBuilder<TMetadata>
441
+ | TrackedWalletClientAutoPopulateBuilder<TMetadata> {
442
+ const populateMetadata = options?.populateMetadata ?? false;
443
+ const clock = options?.clock ?? Date.now;
444
+
445
+ if (populateMetadata) {
446
+ return createAutoPopulateBuilder<TMetadata>(
447
+ clock,
448
+ ) as TrackedWalletClientAutoPopulateBuilder<TMetadata>;
449
+ }
450
+
142
451
  return {
143
452
  using<TClient extends WalletClient>(
144
453
  walletClient: TClient,
@@ -154,9 +463,10 @@ export function createTrackedWalletClient<
154
463
  type TChain = InferChain<TClient>;
155
464
  type TAccount = InferAccount<TClient>;
156
465
 
157
- // Create emitter for transaction broadcast events
466
+ // Create emitter for transaction events
158
467
  const emitter = new Emitter<{
159
468
  'transaction:broadcasted': TrackedTransaction<TMetadata>;
469
+ 'transaction:fetched': KnownTrackedTransaction<TMetadata>;
160
470
  }>();
161
471
 
162
472
  /**
@@ -212,106 +522,440 @@ export function createTrackedWalletClient<
212
522
  }
213
523
 
214
524
  /**
215
- * Extract transaction context from a serialized (signed) transaction.
216
- * Parses the transaction and recovers the sender address.
217
- *
218
- * @param serializedTransaction - The RLP-encoded signed transaction
219
- * @returns TransactionContext with from address and nonce
525
+ * Fetch full transaction data and emit transaction:fetched event.
526
+ * Non-blocking, runs in background. Does not throw.
220
527
  */
221
- async function extractRawTransactionContext(
222
- serializedTransaction: TransactionSerialized,
223
- ): Promise<TransactionContext> {
224
- // Parse the serialized transaction to get the nonce
225
- const parsedTx = parseTransaction(serializedTransaction);
226
-
227
- if (parsedTx.nonce === undefined) {
228
- throw new Error(
229
- '[TrackedWalletClient] Could not extract nonce from serialized transaction.',
528
+ async function fetchAndEmitFullData(
529
+ hash: Hash,
530
+ metadata: TMetadata,
531
+ broadcastTimestampMs: number,
532
+ ): Promise<void> {
533
+ try {
534
+ const tx = await publicClient.getTransaction({hash});
535
+ const knownTx = createKnownTrackedTransaction(
536
+ tx,
537
+ metadata,
538
+ broadcastTimestampMs,
539
+ );
540
+ emitter.emit('transaction:fetched', knownTx);
541
+ } catch (error) {
542
+ // Log but don't throw - transaction:fetched simply won't fire
543
+ console.warn(
544
+ `[TrackedWalletClient] Could not fetch tx ${hash}. ` +
545
+ `transaction:fetched event will not be emitted. Error: ${error}`,
230
546
  );
231
547
  }
548
+ }
549
+
550
+ /**
551
+ * Common wrapper for transaction methods that broadcast (sendTransaction, writeContract).
552
+ * Emits transaction:broadcasted immediately with intended values,
553
+ * then fetches and emits transaction:fetched with actual values.
554
+ */
555
+ async function executeTrackedTransaction<T, R>(args: {
556
+ account?: Account | Address;
557
+ nonce?: NonceOption;
558
+ metadata: TMetadata;
559
+ restArgs: T;
560
+ intendedParams: IntendedTransactionParams;
561
+ execute: (argsWithNonce: T & {nonce: number}) => Promise<R>;
562
+ extractHash: (result: R) => Hash;
563
+ }): Promise<R> {
564
+ const {metadata, restArgs, intendedParams, execute, extractHash} = args;
565
+ const broadcastTimestampMs = clock();
232
566
 
233
- // Recover the sender address from the signature
234
- const from = await recoverTransactionAddress({
235
- serializedTransaction,
567
+ // Extract common context
568
+ const {from, intendedNonce} = await extractTransactionContext(args);
569
+
570
+ // Execute the underlying transaction with nonce injected
571
+ const result = await execute({
572
+ ...restArgs,
573
+ nonce: intendedNonce,
574
+ } as T & {
575
+ nonce: number;
236
576
  });
577
+ const hash = extractHash(result);
237
578
 
238
- return {
579
+ // Emit transaction:broadcasted immediately with intended values
580
+ const unknownTx = createUnknownTrackedTransaction(
581
+ hash,
239
582
  from,
240
- intendedNonce: parsedTx.nonce,
241
- };
583
+ intendedNonce,
584
+ walletClient.chain?.id,
585
+ metadata,
586
+ broadcastTimestampMs,
587
+ intendedParams,
588
+ );
589
+ emitter.emit('transaction:broadcasted', unknownTx);
590
+
591
+ // Fire-and-forget: fetch full data and emit transaction:fetched
592
+ fetchAndEmitFullData(hash, metadata, broadcastTimestampMs);
593
+
594
+ return result;
242
595
  }
243
596
 
244
597
  /**
245
- * Fetch the transaction after broadcast to verify nonce.
246
- * Logs a warning if the nonce was overridden or if tx cannot be found.
247
- *
248
- * @param hash - The transaction hash
249
- * @param intendedNonce - The nonce we intended to use
250
- * @returns The actual nonce, or the intended nonce if fetch failed
598
+ * Common wrapper for raw transaction broadcasts (sendRawTransaction).
599
+ * For raw transactions, we can parse full data immediately.
600
+ * Emits KnownTrackedTransaction directly to transaction:broadcasted.
251
601
  */
252
- async function verifyTransactionNonce(
253
- hash: Hash,
254
- intendedNonce: number,
602
+ async function executeTrackedRawTransaction<R>(args: {
603
+ serializedTransaction: TransactionSerialized;
604
+ metadata: TMetadata;
605
+ execute: () => Promise<R>;
606
+ extractHash: (result: R) => Hash;
607
+ }): Promise<R> {
608
+ const {serializedTransaction, metadata, execute, extractHash} = args;
609
+ const broadcastTimestampMs = clock();
610
+
611
+ const from = await recoverTransactionAddress({serializedTransaction});
612
+
613
+ const parsedTx = parseTransaction(serializedTransaction);
614
+
615
+ // Execute the broadcast
616
+ const result = await execute();
617
+ const hash = extractHash(result);
618
+
619
+ // For raw transactions, we can parse full data immediately
620
+ const knownTx = createKnownTrackedTransactionFromRaw(
621
+ parsedTx,
622
+ from,
623
+ hash,
624
+ metadata,
625
+ walletClient.chain?.id,
626
+ broadcastTimestampMs,
627
+ );
628
+
629
+ // Emit as KnownTrackedTransaction since we have all data
630
+ emitter.emit('transaction:broadcasted', knownTx);
631
+
632
+ // Also emit to transaction:fetched for consistency
633
+ emitter.emit('transaction:fetched', knownTx);
634
+
635
+ return result;
636
+ }
637
+
638
+ return {
639
+ walletClient: walletClient as unknown as WalletClient<
640
+ TTransport,
641
+ TChain,
642
+ TAccount
643
+ >,
644
+ publicClient,
645
+
646
+ // ============================================
647
+ // Async methods (return hash)
648
+ // ============================================
649
+
650
+ async writeContract<
651
+ const TAbi extends Abi | readonly unknown[],
652
+ TFunctionName extends ContractFunctionName<
653
+ TAbi,
654
+ 'nonpayable' | 'payable'
655
+ >,
656
+ TArgs extends ContractFunctionArgs<
657
+ TAbi,
658
+ 'nonpayable' | 'payable',
659
+ TFunctionName
660
+ >,
661
+ TChainOverride extends Chain | undefined = undefined,
662
+ >(
663
+ args: TrackedWriteContractParameters<
664
+ TMetadata,
665
+ TAbi,
666
+ TFunctionName,
667
+ TArgs,
668
+ TChain,
669
+ TAccount,
670
+ TChainOverride
671
+ >,
672
+ ): Promise<Hash> {
673
+ const {metadata, nonce, ...writeArgs} = args;
674
+ const intendedParams = extractIntendedParamsFromWriteContract(args);
675
+
676
+ return executeTrackedTransaction({
677
+ account: normalizeAccount(args.account),
678
+ nonce,
679
+ metadata: metadata as TMetadata,
680
+ restArgs: writeArgs,
681
+ intendedParams,
682
+ execute: (argsWithNonce) =>
683
+ walletClient.writeContract(argsWithNonce as any),
684
+ extractHash: (hash) => hash,
685
+ });
686
+ },
687
+
688
+ async sendTransaction<
689
+ TChainOverride extends Chain | undefined = undefined,
690
+ >(
691
+ args: TrackedSendTransactionParameters<
692
+ TMetadata,
693
+ TChain,
694
+ TAccount,
695
+ TChainOverride
696
+ >,
697
+ ): Promise<Hash> {
698
+ const {metadata, nonce, ...sendArgs} = args;
699
+ const intendedParams = extractIntendedParamsFromSendTransaction(args);
700
+
701
+ return executeTrackedTransaction({
702
+ account: normalizeAccount(args.account),
703
+ nonce,
704
+ metadata: metadata as TMetadata,
705
+ restArgs: sendArgs,
706
+ intendedParams,
707
+ execute: (argsWithNonce) =>
708
+ walletClient.sendTransaction(argsWithNonce as any),
709
+ extractHash: (hash) => hash,
710
+ });
711
+ },
712
+
713
+ async sendRawTransaction(
714
+ args: TrackedRawTransactionParameters<TMetadata>,
715
+ ): Promise<Hash> {
716
+ const {metadata, serializedTransaction} = args;
717
+
718
+ return executeTrackedRawTransaction({
719
+ serializedTransaction,
720
+ metadata: metadata as TMetadata,
721
+ execute: () =>
722
+ walletClient.sendRawTransaction({serializedTransaction}),
723
+ extractHash: (hash) => hash,
724
+ });
725
+ },
726
+
727
+ // ============================================
728
+ // Sync methods (return receipt, wait for confirmation)
729
+ // ============================================
730
+
731
+ async writeContractSync<
732
+ const TAbi extends Abi | readonly unknown[],
733
+ TFunctionName extends ContractFunctionName<
734
+ TAbi,
735
+ 'nonpayable' | 'payable'
736
+ >,
737
+ TArgs extends ContractFunctionArgs<
738
+ TAbi,
739
+ 'nonpayable' | 'payable',
740
+ TFunctionName
741
+ >,
742
+ TChainOverride extends Chain | undefined = undefined,
743
+ >(
744
+ args: TrackedWriteContractParameters<
745
+ TMetadata,
746
+ TAbi,
747
+ TFunctionName,
748
+ TArgs,
749
+ TChain,
750
+ TAccount,
751
+ TChainOverride
752
+ >,
753
+ ): Promise<TransactionReceipt> {
754
+ const {metadata, nonce, ...writeArgs} = args;
755
+ const intendedParams = extractIntendedParamsFromWriteContract(args);
756
+
757
+ return executeTrackedTransaction({
758
+ account: normalizeAccount(args.account),
759
+ nonce,
760
+ metadata: metadata as TMetadata,
761
+ restArgs: writeArgs,
762
+ intendedParams,
763
+ execute: (argsWithNonce) =>
764
+ walletClient.writeContractSync(argsWithNonce as any),
765
+ extractHash: (receipt) => receipt.transactionHash,
766
+ });
767
+ },
768
+
769
+ async sendTransactionSync<
770
+ TChainOverride extends Chain | undefined = undefined,
771
+ >(
772
+ args: TrackedSendTransactionParameters<
773
+ TMetadata,
774
+ TChain,
775
+ TAccount,
776
+ TChainOverride
777
+ >,
778
+ ): Promise<TransactionReceipt> {
779
+ const {metadata, nonce, ...sendArgs} = args;
780
+ const intendedParams = extractIntendedParamsFromSendTransaction(args);
781
+
782
+ return executeTrackedTransaction({
783
+ account: normalizeAccount(args.account),
784
+ nonce,
785
+ metadata: metadata as TMetadata,
786
+ restArgs: sendArgs,
787
+ intendedParams,
788
+ execute: (argsWithNonce) =>
789
+ walletClient.sendTransactionSync(argsWithNonce as any),
790
+ extractHash: (receipt) => receipt.transactionHash,
791
+ });
792
+ },
793
+
794
+ async sendRawTransactionSync(
795
+ args: TrackedRawTransactionParameters<TMetadata>,
796
+ ): Promise<TransactionReceipt> {
797
+ const {metadata, serializedTransaction} = args;
798
+
799
+ return executeTrackedRawTransaction({
800
+ serializedTransaction,
801
+ metadata: metadata as TMetadata,
802
+ execute: () =>
803
+ walletClient.sendRawTransactionSync({serializedTransaction}),
804
+ extractHash: (receipt) => receipt.transactionHash,
805
+ });
806
+ },
807
+
808
+ // ============================================
809
+ // Event subscription methods
810
+ // ============================================
811
+
812
+ on: emitter.on.bind(emitter),
813
+ off: emitter.off.bind(emitter),
814
+ };
815
+ },
816
+ };
817
+ }
818
+
819
+ /**
820
+ * Create an auto-populate builder for TrackedWalletClient.
821
+ * This builder auto-populates operation, functionName and args in writeContract metadata.
822
+ */
823
+ function createAutoPopulateBuilder<TMetadata>(
824
+ clock: () => number,
825
+ ): TrackedWalletClientAutoPopulateBuilder<TMetadata> {
826
+ return {
827
+ using<TClient extends WalletClient>(
828
+ walletClient: TClient,
829
+ publicClient: PublicClient,
830
+ ): TrackedWalletClientAutoPopulate<
831
+ TMetadata,
832
+ InferTransport<TClient>,
833
+ InferChain<TClient>,
834
+ InferAccount<TClient>
835
+ > {
836
+ // Type aliases for internal use
837
+ type TTransport = InferTransport<TClient>;
838
+ type TChain = InferChain<TClient>;
839
+ type TAccount = InferAccount<TClient>;
840
+
841
+ // Create emitter for transaction events
842
+ const emitter = new Emitter<{
843
+ 'transaction:broadcasted': TrackedTransaction<TMetadata>;
844
+ 'transaction:fetched': KnownTrackedTransaction<TMetadata>;
845
+ }>();
846
+
847
+ /**
848
+ * Resolve the nonce to use for a transaction.
849
+ */
850
+ async function resolveNonce(
851
+ nonceOption: NonceOption | undefined,
852
+ from: Address,
255
853
  ): Promise<number> {
256
- try {
257
- const tx = await publicClient.getTransaction({hash});
258
- const actualNonce = tx.nonce;
854
+ if (typeof nonceOption === 'number') {
855
+ return nonceOption;
856
+ }
857
+ const blockTag = isBlockTag(nonceOption) ? nonceOption : 'pending';
858
+ return await publicClient.getTransactionCount({
859
+ address: from,
860
+ blockTag,
861
+ });
862
+ }
259
863
 
260
- if (actualNonce !== intendedNonce) {
261
- console.warn(
262
- `[TrackedWalletClient] Nonce mismatch: intended ${intendedNonce}, actual ${actualNonce}. ` +
263
- `Wallet may have overridden the nonce.`,
264
- );
265
- }
864
+ /**
865
+ * Extract common transaction context (account, nonce) from request args.
866
+ */
867
+ async function extractTransactionContext(args: {
868
+ account?: Account | Address;
869
+ nonce?: NonceOption;
870
+ }): Promise<TransactionContext> {
871
+ const account = args.account ?? walletClient.account;
872
+ const from = resolveAccountAddress(account);
266
873
 
267
- return actualNonce;
268
- } catch (fetchError) {
269
- // Transaction not found in mempool/chain yet
270
- console.warn(
271
- `[TrackedWalletClient] Could not fetch tx ${hash} after broadcast. ` +
272
- `It may not be in the mempool yet.`,
874
+ if (!from) {
875
+ throw new Error(
876
+ '[TrackedWalletClient] No account available. ' +
877
+ 'Provide an account in the request or configure the wallet client with an account.',
273
878
  );
274
- return intendedNonce;
275
879
  }
880
+
881
+ const intendedNonce = await resolveNonce(args.nonce, from);
882
+ return {from, intendedNonce};
276
883
  }
277
884
 
278
885
  /**
279
- * Create a tracked transaction record.
886
+ * Fetch full transaction data and emit transaction:fetched event.
887
+ * Non-blocking, runs in background. Does not throw.
280
888
  */
281
- function createTrackedTransactionRecord(
282
- txHash: Hash,
283
- from: Address,
284
- nonce: number,
889
+ async function fetchAndEmitFullData(
890
+ hash: Hash,
285
891
  metadata: TMetadata,
286
- ): TrackedTransaction<TMetadata> {
287
- return {
288
- hash: txHash,
289
- from,
290
- nonce,
291
- chainId: walletClient.chain?.id,
292
- metadata,
293
- broadcastTimestampMs: Date.now(),
294
- };
892
+ broadcastTimestampMs: number,
893
+ ): Promise<void> {
894
+ try {
895
+ const tx = await publicClient.getTransaction({hash});
896
+ const knownTx = createKnownTrackedTransaction(
897
+ tx,
898
+ metadata,
899
+ broadcastTimestampMs,
900
+ );
901
+ emitter.emit('transaction:fetched', knownTx);
902
+ } catch (error) {
903
+ // Log but don't throw - transaction:fetched simply won't fire
904
+ console.warn(
905
+ `[TrackedWalletClient] Could not fetch tx ${hash}. ` +
906
+ `transaction:fetched event will not be emitted. Error: ${error}`,
907
+ );
908
+ }
295
909
  }
296
910
 
297
911
  /**
298
- * Common wrapper for transaction methods that broadcast (sendTransaction, writeContract).
299
- * Handles nonce resolution, underlying call, post-broadcast verification, and tracking record creation.
912
+ * Validate that user didn't provide operation, functionName or args in metadata
913
+ * when populateMetadata is enabled.
914
+ */
915
+ function validateNoAutoPopulatedFieldsInMetadata(
916
+ userMetadata: unknown,
917
+ ): void {
918
+ if (userMetadata && typeof userMetadata === 'object') {
919
+ if ('type' in userMetadata) {
920
+ throw new Error(
921
+ '[TrackedWalletClient] Cannot specify type in metadata when populateMetadata is enabled. ' +
922
+ 'The type is automatically populated from the contract call.',
923
+ );
924
+ }
925
+ if ('functionName' in userMetadata) {
926
+ throw new Error(
927
+ '[TrackedWalletClient] Cannot specify functionName in metadata when populateMetadata is enabled. ' +
928
+ 'The functionName is automatically populated from the contract call.',
929
+ );
930
+ }
931
+ if ('args' in userMetadata) {
932
+ throw new Error(
933
+ '[TrackedWalletClient] Cannot specify args in metadata when populateMetadata is enabled. ' +
934
+ 'The args are automatically populated from the contract call.',
935
+ );
936
+ }
937
+ }
938
+ }
939
+
940
+ /**
941
+ * Common wrapper for transaction methods that broadcast.
942
+ * Emits transaction:broadcasted immediately with intended values,
943
+ * then fetches and emits transaction:fetched with actual values.
300
944
  */
301
945
  async function executeTrackedTransaction<T, R>(args: {
302
946
  account?: Account | Address;
303
947
  nonce?: NonceOption;
304
948
  metadata: TMetadata;
305
949
  restArgs: T;
950
+ intendedParams: IntendedTransactionParams;
306
951
  execute: (argsWithNonce: T & {nonce: number}) => Promise<R>;
307
952
  extractHash: (result: R) => Hash;
308
953
  }): Promise<R> {
309
- const {metadata, restArgs, execute, extractHash} = args;
954
+ const {metadata, restArgs, intendedParams, execute, extractHash} = args;
955
+ const broadcastTimestampMs = clock();
310
956
 
311
- // Extract common context
312
957
  const {from, intendedNonce} = await extractTransactionContext(args);
313
958
 
314
- // Execute the underlying transaction with nonce injected
315
959
  const result = await execute({
316
960
  ...restArgs,
317
961
  nonce: intendedNonce,
@@ -320,26 +964,28 @@ export function createTrackedWalletClient<
320
964
  });
321
965
  const hash = extractHash(result);
322
966
 
323
- // Verify transaction and get actual nonce
324
- const actualNonce = await verifyTransactionNonce(hash, intendedNonce);
325
-
326
- // Create tracked transaction record
327
- const trackedTx = createTrackedTransactionRecord(
967
+ // Emit transaction:broadcasted immediately with intended values
968
+ const unknownTx = createUnknownTrackedTransaction(
328
969
  hash,
329
970
  from,
330
- actualNonce,
971
+ intendedNonce,
972
+ walletClient.chain?.id,
331
973
  metadata,
974
+ broadcastTimestampMs,
975
+ intendedParams,
332
976
  );
977
+ emitter.emit('transaction:broadcasted', unknownTx);
333
978
 
334
- // Emit transaction broadcasted event
335
- emitter.emit('transaction:broadcasted', trackedTx);
979
+ // Fire-and-forget: fetch full data and emit transaction:fetched
980
+ fetchAndEmitFullData(hash, metadata, broadcastTimestampMs);
336
981
 
337
982
  return result;
338
983
  }
339
984
 
340
985
  /**
341
- * Common wrapper for raw transaction broadcasts (sendRawTransaction).
342
- * Decodes the transaction to extract from/nonce, broadcasts, and creates tracking record.
986
+ * Common wrapper for raw transaction broadcasts.
987
+ * For raw transactions, we can parse full data immediately.
988
+ * Emits KnownTrackedTransaction directly to transaction:broadcasted.
343
989
  */
344
990
  async function executeTrackedRawTransaction<R>(args: {
345
991
  serializedTransaction: TransactionSerialized;
@@ -348,29 +994,29 @@ export function createTrackedWalletClient<
348
994
  extractHash: (result: R) => Hash;
349
995
  }): Promise<R> {
350
996
  const {serializedTransaction, metadata, execute, extractHash} = args;
997
+ const broadcastTimestampMs = clock();
351
998
 
352
- // Extract context from the serialized transaction
353
- const {from, intendedNonce} = await extractRawTransactionContext(
354
- serializedTransaction,
355
- );
999
+ const from = await recoverTransactionAddress({serializedTransaction});
1000
+ const parsedTx = parseTransaction(serializedTransaction);
356
1001
 
357
1002
  // Execute the broadcast
358
1003
  const result = await execute();
359
1004
  const hash = extractHash(result);
360
1005
 
361
- // For raw transactions, the nonce is already embedded, so no verification needed
362
- // (wallet cannot override nonce in an already-signed transaction)
363
-
364
- // Create tracked transaction record
365
- const trackedTx = createTrackedTransactionRecord(
366
- hash,
1006
+ // For raw transactions, we can parse full data immediately
1007
+ const knownTx = createKnownTrackedTransactionFromRaw(
1008
+ parsedTx,
367
1009
  from,
368
- intendedNonce,
1010
+ hash,
369
1011
  metadata,
1012
+ walletClient.chain?.id,
1013
+ broadcastTimestampMs,
370
1014
  );
371
1015
 
372
- // Emit transaction broadcasted event
373
- emitter.emit('transaction:broadcasted', trackedTx);
1016
+ // Emit as KnownTrackedTransaction since we have all data
1017
+ emitter.emit('transaction:broadcasted', knownTx);
1018
+
1019
+ // We do not emit fetched as the tx is already known
374
1020
 
375
1021
  return result;
376
1022
  }
@@ -400,7 +1046,7 @@ export function createTrackedWalletClient<
400
1046
  >,
401
1047
  TChainOverride extends Chain | undefined = undefined,
402
1048
  >(
403
- args: TrackedWriteContractParameters<
1049
+ args: TrackedWriteContractAutoPopulateParameters<
404
1050
  TMetadata,
405
1051
  TAbi,
406
1052
  TFunctionName,
@@ -410,13 +1056,27 @@ export function createTrackedWalletClient<
410
1056
  TChainOverride
411
1057
  >,
412
1058
  ): Promise<Hash> {
413
- const {metadata, nonce, ...writeArgs} = args;
1059
+ const {metadata: userMetadata, nonce, ...writeArgs} = args;
1060
+
1061
+ // Validate that user didn't provide operation, functionName or args
1062
+ validateNoAutoPopulatedFieldsInMetadata(userMetadata);
1063
+
1064
+ // Auto-populate type, functionName and args
1065
+ const finalMetadata = {
1066
+ ...(userMetadata ?? {}),
1067
+ type: 'functionCall' as const,
1068
+ functionName: args.functionName as string,
1069
+ args: args.args as readonly unknown[],
1070
+ } as TMetadata;
1071
+
1072
+ const intendedParams = extractIntendedParamsFromWriteContract(args);
414
1073
 
415
1074
  return executeTrackedTransaction({
416
1075
  account: normalizeAccount(args.account),
417
1076
  nonce,
418
- metadata: metadata as TMetadata,
1077
+ metadata: finalMetadata,
419
1078
  restArgs: writeArgs,
1079
+ intendedParams,
420
1080
  execute: (argsWithNonce) =>
421
1081
  walletClient.writeContract(argsWithNonce as any),
422
1082
  extractHash: (hash) => hash,
@@ -434,12 +1094,14 @@ export function createTrackedWalletClient<
434
1094
  >,
435
1095
  ): Promise<Hash> {
436
1096
  const {metadata, nonce, ...sendArgs} = args;
1097
+ const intendedParams = extractIntendedParamsFromSendTransaction(args);
437
1098
 
438
1099
  return executeTrackedTransaction({
439
1100
  account: normalizeAccount(args.account),
440
1101
  nonce,
441
1102
  metadata: metadata as TMetadata,
442
1103
  restArgs: sendArgs,
1104
+ intendedParams,
443
1105
  execute: (argsWithNonce) =>
444
1106
  walletClient.sendTransaction(argsWithNonce as any),
445
1107
  extractHash: (hash) => hash,
@@ -477,7 +1139,7 @@ export function createTrackedWalletClient<
477
1139
  >,
478
1140
  TChainOverride extends Chain | undefined = undefined,
479
1141
  >(
480
- args: TrackedWriteContractParameters<
1142
+ args: TrackedWriteContractAutoPopulateParameters<
481
1143
  TMetadata,
482
1144
  TAbi,
483
1145
  TFunctionName,
@@ -487,13 +1149,27 @@ export function createTrackedWalletClient<
487
1149
  TChainOverride
488
1150
  >,
489
1151
  ): Promise<TransactionReceipt> {
490
- const {metadata, nonce, ...writeArgs} = args;
1152
+ const {metadata: userMetadata, nonce, ...writeArgs} = args;
1153
+
1154
+ // Validate that user didn't provide operation, functionName or args
1155
+ validateNoAutoPopulatedFieldsInMetadata(userMetadata);
1156
+
1157
+ // Auto-populate type, functionName and args
1158
+ const finalMetadata = {
1159
+ ...(userMetadata ?? {}),
1160
+ type: 'functionCall' as const,
1161
+ functionName: args.functionName as string,
1162
+ args: args.args as readonly unknown[],
1163
+ } as TMetadata;
1164
+
1165
+ const intendedParams = extractIntendedParamsFromWriteContract(args);
491
1166
 
492
1167
  return executeTrackedTransaction({
493
1168
  account: normalizeAccount(args.account),
494
1169
  nonce,
495
- metadata: metadata as TMetadata,
1170
+ metadata: finalMetadata,
496
1171
  restArgs: writeArgs,
1172
+ intendedParams,
497
1173
  execute: (argsWithNonce) =>
498
1174
  walletClient.writeContractSync(argsWithNonce as any),
499
1175
  extractHash: (receipt) => receipt.transactionHash,
@@ -511,12 +1187,14 @@ export function createTrackedWalletClient<
511
1187
  >,
512
1188
  ): Promise<TransactionReceipt> {
513
1189
  const {metadata, nonce, ...sendArgs} = args;
1190
+ const intendedParams = extractIntendedParamsFromSendTransaction(args);
514
1191
 
515
1192
  return executeTrackedTransaction({
516
1193
  account: normalizeAccount(args.account),
517
1194
  nonce,
518
1195
  metadata: metadata as TMetadata,
519
1196
  restArgs: sendArgs,
1197
+ intendedParams,
520
1198
  execute: (argsWithNonce) =>
521
1199
  walletClient.sendTransactionSync(argsWithNonce as any),
522
1200
  extractHash: (receipt) => receipt.transactionHash,
@@ -541,13 +1219,8 @@ export function createTrackedWalletClient<
541
1219
  // Event subscription methods
542
1220
  // ============================================
543
1221
 
544
- onTransactionBroadcasted: (
545
- listener: (event: TrackedTransaction<TMetadata>) => void,
546
- ) => emitter.on('transaction:broadcasted', listener),
547
-
548
- offTransactionBroadcasted: (
549
- listener: (event: TrackedTransaction<TMetadata>) => void,
550
- ) => emitter.off('transaction:broadcasted', listener),
1222
+ on: emitter.on.bind(emitter),
1223
+ off: emitter.off.bind(emitter),
551
1224
  };
552
1225
  },
553
1226
  };