@etherkit/viem-tx-tracker 0.0.3 → 0.0.5

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.
@@ -17,13 +17,16 @@ import {
17
17
  import {Emitter} from 'radiate';
18
18
  import type {
19
19
  BlockTag,
20
+ CreateTrackedWalletClientOptions,
20
21
  NonceOption,
22
+ PopulatedMetadata,
21
23
  TrackedRawTransactionParameters,
22
24
  TrackedSendTransactionParameters,
23
25
  TrackedTransaction,
24
26
  TrackedWalletClient,
27
+ TrackedWalletClientAutoPopulate,
28
+ TrackedWriteContractAutoPopulateParameters,
25
29
  TrackedWriteContractParameters,
26
- TransactionMetadata,
27
30
  } from './types.js';
28
31
 
29
32
  /**
@@ -71,400 +74,974 @@ interface TransactionContext {
71
74
  intendedNonce: number;
72
75
  }
73
76
 
77
+ /**
78
+ * Infer transport type from WalletClient
79
+ */
80
+ type InferTransport<T> =
81
+ T extends WalletClient<infer TTransport, any, any> ? TTransport : Transport;
82
+
83
+ /**
84
+ * Infer chain type from WalletClient
85
+ */
86
+ type InferChain<T> =
87
+ T extends WalletClient<any, infer TChain, any> ? TChain : Chain | undefined;
88
+
89
+ /**
90
+ * Infer account type from WalletClient
91
+ */
92
+ type InferAccount<T> =
93
+ T extends WalletClient<any, any, infer TAccount>
94
+ ? TAccount
95
+ : Account | undefined;
96
+
97
+ /**
98
+ * Builder interface returned by createTrackedWalletClient for the curried API.
99
+ */
100
+ export interface TrackedWalletClientBuilder<TMetadata> {
101
+ /**
102
+ * Create the tracked wallet client using the provided wallet and public clients.
103
+ *
104
+ * @param walletClient - The underlying viem WalletClient
105
+ * @param publicClient - A PublicClient for nonce fetching and tx verification
106
+ * @returns A TrackedWalletClient instance
107
+ */
108
+ using<TClient extends WalletClient>(
109
+ walletClient: TClient,
110
+ publicClient: PublicClient,
111
+ ): TrackedWalletClient<
112
+ TMetadata,
113
+ InferTransport<TClient>,
114
+ InferChain<TClient>,
115
+ InferAccount<TClient>
116
+ >;
117
+ }
118
+
119
+ /**
120
+ * Builder interface returned by createTrackedWalletClient with populateMetadata: true.
121
+ * This builder returns a TrackedWalletClientAutoPopulate that auto-populates operation, functionName and args.
122
+ * TMetadata must be a type where FunctionCallMetadata is assignable to it.
123
+ */
124
+ export interface TrackedWalletClientAutoPopulateBuilder<TMetadata> {
125
+ /**
126
+ * Create the tracked wallet client using the provided wallet and public clients.
127
+ * writeContract and writeContractSync will automatically populate operation, functionName and args.
128
+ *
129
+ * @param walletClient - The underlying viem WalletClient
130
+ * @param publicClient - A PublicClient for nonce fetching and tx verification
131
+ * @returns A TrackedWalletClientAutoPopulate instance
132
+ */
133
+ using<TClient extends WalletClient>(
134
+ walletClient: TClient,
135
+ publicClient: PublicClient,
136
+ ): TrackedWalletClientAutoPopulate<
137
+ TMetadata,
138
+ InferTransport<TClient>,
139
+ InferChain<TClient>,
140
+ InferAccount<TClient>
141
+ >;
142
+ }
143
+
74
144
  /**
75
145
  * Create a tracked wallet client that wraps a viem WalletClient.
76
146
  *
77
147
  * The tracked client provides the same API as WalletClient but with:
78
- * - Optional metadata field for transaction tracking
148
+ * - Metadata field for transaction tracking (required unless TMetadata includes undefined)
79
149
  * - Automatic nonce fetching (with 'pending' by default)
80
150
  * - Post-broadcast transaction verification
81
- * - TODO: Event emission for tracking
151
+ * - Event emission for tracking
152
+ *
153
+ * @typeParam TMetadata - The metadata type. Use `MyMeta | undefined` to make metadata optional.
154
+ * @returns A builder with a `.using()` method to provide the wallet and public clients
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * // Standard mode with required metadata
159
+ * const tracked = createTrackedWalletClient<{purpose: string}>()
160
+ * .using(walletClient, publicClient);
161
+ *
162
+ * // Standard mode with optional metadata
163
+ * const tracked = createTrackedWalletClient<{purpose: string} | undefined>()
164
+ * .using(walletClient, publicClient);
165
+ *
166
+ * // Auto-populate mode - functionName and args are auto-populated
167
+ * const tracked = createTrackedWalletClient({ populateMetadata: true })
168
+ * .using(walletClient, publicClient);
82
169
  *
83
- * @param walletClient - The underlying viem WalletClient
84
- * @param publicClient - A PublicClient for nonce fetching and tx verification
85
- * @returns A TrackedWalletClient instance
170
+ * // Auto-populate mode with extended metadata
171
+ * type MyMetadata = OperationMetadata & { purpose: string };
172
+ * const tracked = createTrackedWalletClient<MyMetadata>({ populateMetadata: true })
173
+ * .using(walletClient, publicClient);
174
+ * ```
86
175
  */
176
+ // Overload 1: Standard mode, no options
87
177
  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
- }>();
178
+ TMetadata,
179
+ >(): TrackedWalletClientBuilder<TMetadata>;
99
180
 
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
- }
181
+ // Overload 2: Auto-populate mode with default PopulatedMetadata
182
+ export function createTrackedWalletClient(options: {
183
+ populateMetadata: true;
184
+ }): TrackedWalletClientAutoPopulateBuilder<PopulatedMetadata>;
123
185
 
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
- }
186
+ // Overload 3: Auto-populate mode with custom metadata (must allow FunctionCallMetadata)
187
+ export function createTrackedWalletClient<TMetadata>(options: {
188
+ populateMetadata: true;
189
+ }): TrackedWalletClientAutoPopulateBuilder<TMetadata>;
151
190
 
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
- };
191
+ // Implementation
192
+ export function createTrackedWalletClient<TMetadata>(
193
+ options?: CreateTrackedWalletClientOptions<boolean>,
194
+ ):
195
+ | TrackedWalletClientBuilder<TMetadata>
196
+ | TrackedWalletClientAutoPopulateBuilder<TMetadata> {
197
+ const populateMetadata = options?.populateMetadata ?? false;
198
+
199
+ if (populateMetadata) {
200
+ return createAutoPopulateBuilder<TMetadata>() as TrackedWalletClientAutoPopulateBuilder<TMetadata>;
180
201
  }
181
202
 
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.`,
203
+ return {
204
+ using<TClient extends WalletClient>(
205
+ walletClient: TClient,
206
+ publicClient: PublicClient,
207
+ ): TrackedWalletClient<
208
+ TMetadata,
209
+ InferTransport<TClient>,
210
+ InferChain<TClient>,
211
+ InferAccount<TClient>
212
+ > {
213
+ // Type aliases for internal use
214
+ type TTransport = InferTransport<TClient>;
215
+ type TChain = InferChain<TClient>;
216
+ type TAccount = InferAccount<TClient>;
217
+
218
+ // Create emitter for transaction broadcast events
219
+ const emitter = new Emitter<{
220
+ 'transaction:broadcasted': TrackedTransaction<TMetadata>;
221
+ }>();
222
+
223
+ /**
224
+ * Resolve the nonce to use for a transaction.
225
+ *
226
+ * @param nonceOption - The nonce option provided by the caller
227
+ * @param from - The sender address
228
+ * @returns The resolved nonce number
229
+ */
230
+ async function resolveNonce(
231
+ nonceOption: NonceOption | undefined,
232
+ from: Address,
233
+ ): Promise<number> {
234
+ if (typeof nonceOption === 'number') {
235
+ // Explicit number - use as-is
236
+ return nonceOption;
237
+ }
238
+
239
+ // Block tag (string) or undefined - fetch from chain
240
+ const blockTag = isBlockTag(nonceOption) ? nonceOption : 'pending';
241
+ return await publicClient.getTransactionCount({
242
+ address: from,
243
+ blockTag,
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Extract common transaction context (account, nonce) from request args.
249
+ * This is the shared logic between all transaction methods.
250
+ *
251
+ * @param args - The transaction args containing account and nonce options
252
+ * @returns TransactionContext with resolved from address and nonce
253
+ */
254
+ async function extractTransactionContext(args: {
255
+ account?: Account | Address;
256
+ nonce?: NonceOption;
257
+ }): Promise<TransactionContext> {
258
+ // Get account/from address
259
+ const account = args.account ?? walletClient.account;
260
+ const from = resolveAccountAddress(account);
261
+
262
+ if (!from) {
263
+ throw new Error(
264
+ '[TrackedWalletClient] No account available. ' +
265
+ 'Provide an account in the request or configure the wallet client with an account.',
266
+ );
267
+ }
268
+
269
+ // Resolve nonce
270
+ const intendedNonce = await resolveNonce(args.nonce, from);
271
+
272
+ return {from, intendedNonce};
273
+ }
274
+
275
+ /**
276
+ * Extract transaction context from a serialized (signed) transaction.
277
+ * Parses the transaction and recovers the sender address.
278
+ *
279
+ * @param serializedTransaction - The RLP-encoded signed transaction
280
+ * @returns TransactionContext with from address and nonce
281
+ */
282
+ async function extractRawTransactionContext(
283
+ serializedTransaction: TransactionSerialized,
284
+ ): Promise<TransactionContext> {
285
+ // Parse the serialized transaction to get the nonce
286
+ const parsedTx = parseTransaction(serializedTransaction);
287
+
288
+ if (parsedTx.nonce === undefined) {
289
+ throw new Error(
290
+ '[TrackedWalletClient] Could not extract nonce from serialized transaction.',
291
+ );
292
+ }
293
+
294
+ // Recover the sender address from the signature
295
+ const from = await recoverTransactionAddress({
296
+ serializedTransaction,
297
+ });
298
+
299
+ return {
300
+ from,
301
+ intendedNonce: parsedTx.nonce,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Fetch the transaction after broadcast to verify nonce.
307
+ * Logs a warning if the nonce was overridden or if tx cannot be found.
308
+ *
309
+ * @param hash - The transaction hash
310
+ * @param intendedNonce - The nonce we intended to use
311
+ * @returns The actual nonce, or the intended nonce if fetch failed
312
+ */
313
+ async function verifyTransactionNonce(
314
+ hash: Hash,
315
+ intendedNonce: number,
316
+ ): Promise<number> {
317
+ try {
318
+ const tx = await publicClient.getTransaction({hash});
319
+ const actualNonce = tx.nonce;
320
+
321
+ if (actualNonce !== intendedNonce) {
322
+ console.warn(
323
+ `[TrackedWalletClient] Nonce mismatch: intended ${intendedNonce}, actual ${actualNonce}. ` +
324
+ `Wallet may have overridden the nonce.`,
325
+ );
326
+ }
327
+
328
+ return actualNonce;
329
+ } catch (fetchError) {
330
+ // Transaction not found in mempool/chain yet
331
+ console.warn(
332
+ `[TrackedWalletClient] Could not fetch tx ${hash} after broadcast. ` +
333
+ `It may not be in the mempool yet.`,
334
+ );
335
+ return intendedNonce;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Create a tracked transaction record.
341
+ */
342
+ function createTrackedTransactionRecord(
343
+ txHash: Hash,
344
+ from: Address,
345
+ nonce: number,
346
+ metadata: TMetadata,
347
+ ): TrackedTransaction<TMetadata> {
348
+ return {
349
+ hash: txHash,
350
+ from,
351
+ nonce,
352
+ chainId: walletClient.chain?.id,
353
+ metadata,
354
+ broadcastTimestampMs: Date.now(),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Common wrapper for transaction methods that broadcast (sendTransaction, writeContract).
360
+ * Handles nonce resolution, underlying call, post-broadcast verification, and tracking record creation.
361
+ */
362
+ async function executeTrackedTransaction<T, R>(args: {
363
+ account?: Account | Address;
364
+ nonce?: NonceOption;
365
+ metadata: TMetadata;
366
+ restArgs: T;
367
+ execute: (argsWithNonce: T & {nonce: number}) => Promise<R>;
368
+ extractHash: (result: R) => Hash;
369
+ }): Promise<R> {
370
+ const {metadata, restArgs, execute, extractHash} = args;
371
+
372
+ // Extract common context
373
+ const {from, intendedNonce} = await extractTransactionContext(args);
374
+
375
+ // Execute the underlying transaction with nonce injected
376
+ const result = await execute({
377
+ ...restArgs,
378
+ nonce: intendedNonce,
379
+ } as T & {
380
+ nonce: number;
381
+ });
382
+ const hash = extractHash(result);
383
+
384
+ // Verify transaction and get actual nonce
385
+ const actualNonce = await verifyTransactionNonce(hash, intendedNonce);
386
+
387
+ // Create tracked transaction record
388
+ const trackedTx = createTrackedTransactionRecord(
389
+ hash,
390
+ from,
391
+ actualNonce,
392
+ metadata,
202
393
  );
394
+
395
+ // Emit transaction broadcasted event
396
+ emitter.emit('transaction:broadcasted', trackedTx);
397
+
398
+ return result;
203
399
  }
204
400
 
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
- }
401
+ /**
402
+ * Common wrapper for raw transaction broadcasts (sendRawTransaction).
403
+ * Decodes the transaction to extract from/nonce, broadcasts, and creates tracking record.
404
+ */
405
+ async function executeTrackedRawTransaction<R>(args: {
406
+ serializedTransaction: TransactionSerialized;
407
+ metadata: TMetadata;
408
+ execute: () => Promise<R>;
409
+ extractHash: (result: R) => Hash;
410
+ }): Promise<R> {
411
+ const {serializedTransaction, metadata, execute, extractHash} = args;
215
412
 
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
- hash: txHash,
228
- from,
229
- nonce,
230
- chainId: walletClient.chain?.id,
231
- metadata: (metadata ?? {}) as M,
232
- broadcastTimestampMs: Date.now(),
233
- };
234
- }
413
+ // Extract context from the serialized transaction
414
+ const {from, intendedNonce} = await extractRawTransactionContext(
415
+ serializedTransaction,
416
+ );
235
417
 
236
- /**
237
- * Common wrapper for transaction methods that broadcast (sendTransaction, writeContract).
238
- * Handles nonce resolution, underlying call, post-broadcast verification, and tracking record creation.
239
- */
240
- async function executeTrackedTransaction<T, R>(args: {
241
- account?: Account | Address;
242
- nonce?: NonceOption;
243
- metadata?: TransactionMetadata;
244
- restArgs: T;
245
- execute: (argsWithNonce: T & {nonce: number}) => Promise<R>;
246
- extractHash: (result: R) => Hash;
247
- }): Promise<R> {
248
- const {metadata, restArgs, execute, extractHash} = args;
249
-
250
- // Extract common context
251
- const {from, intendedNonce} = await extractTransactionContext(args);
252
-
253
- // Execute the underlying transaction with nonce injected
254
- const result = await execute({...restArgs, nonce: intendedNonce} as T & {
255
- nonce: number;
256
- });
257
- const hash = extractHash(result);
258
-
259
- // Verify transaction and get actual nonce
260
- const actualNonce = await verifyTransactionNonce(hash, intendedNonce);
261
-
262
- // Create tracked transaction record
263
- const trackedTx = createTrackedTransaction(
264
- hash,
265
- from,
266
- actualNonce,
267
- metadata,
268
- restArgs,
269
- );
270
-
271
- // Emit transaction broadcasted event
272
- emitter.emit('transaction:broadcasted', trackedTx);
273
-
274
- return result;
275
- }
418
+ // Execute the broadcast
419
+ const result = await execute();
420
+ const hash = extractHash(result);
276
421
 
277
- /**
278
- * Common wrapper for raw transaction broadcasts (sendRawTransaction).
279
- * Decodes the transaction to extract from/nonce, broadcasts, and creates tracking record.
280
- */
281
- async function executeTrackedRawTransaction<R>(args: {
282
- serializedTransaction: TransactionSerialized;
283
- metadata?: TransactionMetadata;
284
- execute: () => Promise<R>;
285
- extractHash: (result: R) => Hash;
286
- }): Promise<R> {
287
- const {serializedTransaction, metadata, execute, extractHash} = args;
288
-
289
- // Extract context from the serialized transaction
290
- const {from, intendedNonce} = await extractRawTransactionContext(
291
- serializedTransaction,
292
- );
293
-
294
- // Execute the broadcast
295
- const result = await execute();
296
- const hash = extractHash(result);
297
-
298
- // For raw transactions, the nonce is already embedded, so no verification needed
299
- // (wallet cannot override nonce in an already-signed transaction)
300
-
301
- // Create tracked transaction record
302
- const trackedTx = createTrackedTransaction(
303
- hash,
304
- from,
305
- intendedNonce,
306
- metadata,
307
- {serializedTransaction},
308
- );
309
-
310
- // Emit transaction broadcasted event
311
- emitter.emit('transaction:broadcasted', trackedTx);
312
-
313
- return result;
314
- }
422
+ // For raw transactions, the nonce is already embedded, so no verification needed
423
+ // (wallet cannot override nonce in an already-signed transaction)
315
424
 
316
- return {
317
- walletClient,
318
- publicClient,
319
-
320
- // ============================================
321
- // Async methods (return hash)
322
- // ============================================
323
-
324
- async writeContract<
325
- const TAbi extends Abi | readonly unknown[],
326
- TFunctionName extends ContractFunctionName<
327
- TAbi,
328
- 'nonpayable' | 'payable'
329
- >,
330
- TArgs extends ContractFunctionArgs<
331
- TAbi,
332
- 'nonpayable' | 'payable',
333
- TFunctionName
334
- >,
335
- TChainOverride extends Chain | undefined = undefined,
336
- >(
337
- args: TrackedWriteContractParameters<
338
- TAbi,
339
- TFunctionName,
340
- TArgs,
341
- TChain,
342
- TAccount,
343
- TChainOverride
344
- >,
345
- ): Promise<Hash> {
346
- const {metadata, nonce, ...writeArgs} = args;
347
-
348
- return executeTrackedTransaction({
349
- account: normalizeAccount(args.account),
350
- nonce,
351
- metadata,
352
- restArgs: writeArgs,
353
- execute: (argsWithNonce) =>
354
- walletClient.writeContract(argsWithNonce as any),
355
- extractHash: (hash) => hash,
356
- });
357
- },
425
+ // Create tracked transaction record
426
+ const trackedTx = createTrackedTransactionRecord(
427
+ hash,
428
+ from,
429
+ intendedNonce,
430
+ metadata,
431
+ );
358
432
 
359
- async sendTransaction<TChainOverride extends Chain | undefined = undefined>(
360
- args: TrackedSendTransactionParameters<TChain, TAccount, TChainOverride>,
361
- ): Promise<Hash> {
362
- const {metadata, nonce, ...sendArgs} = args;
363
-
364
- return executeTrackedTransaction({
365
- account: normalizeAccount(args.account),
366
- nonce,
367
- metadata,
368
- restArgs: sendArgs,
369
- execute: (argsWithNonce) =>
370
- walletClient.sendTransaction(argsWithNonce as any),
371
- extractHash: (hash) => hash,
372
- });
373
- },
433
+ // Emit transaction broadcasted event
434
+ emitter.emit('transaction:broadcasted', trackedTx);
374
435
 
375
- async sendRawTransaction(
376
- args: TrackedRawTransactionParameters,
377
- ): Promise<Hash> {
378
- const {metadata, serializedTransaction} = args;
379
-
380
- return executeTrackedRawTransaction({
381
- serializedTransaction,
382
- metadata,
383
- execute: () => walletClient.sendRawTransaction({serializedTransaction}),
384
- extractHash: (hash) => hash,
385
- });
386
- },
436
+ return result;
437
+ }
387
438
 
388
- // ============================================
389
- // Sync methods (return receipt, wait for confirmation)
390
- // ============================================
391
-
392
- async writeContractSync<
393
- const TAbi extends Abi | readonly unknown[],
394
- TFunctionName extends ContractFunctionName<
395
- TAbi,
396
- 'nonpayable' | 'payable'
397
- >,
398
- TArgs extends ContractFunctionArgs<
399
- TAbi,
400
- 'nonpayable' | 'payable',
401
- TFunctionName
402
- >,
403
- TChainOverride extends Chain | undefined = undefined,
404
- >(
405
- args: TrackedWriteContractParameters<
406
- TAbi,
407
- TFunctionName,
408
- TArgs,
409
- TChain,
410
- TAccount,
411
- TChainOverride
412
- >,
413
- ): Promise<TransactionReceipt> {
414
- const {metadata, nonce, ...writeArgs} = args;
415
-
416
- return executeTrackedTransaction({
417
- account: normalizeAccount(args.account),
418
- nonce,
419
- metadata,
420
- restArgs: writeArgs,
421
- execute: (argsWithNonce) =>
422
- walletClient.writeContractSync(argsWithNonce as any),
423
- extractHash: (receipt) => receipt.transactionHash,
424
- });
425
- },
439
+ return {
440
+ walletClient: walletClient as unknown as WalletClient<
441
+ TTransport,
442
+ TChain,
443
+ TAccount
444
+ >,
445
+ publicClient,
426
446
 
427
- async sendTransactionSync<
428
- TChainOverride extends Chain | undefined = undefined,
429
- >(
430
- args: TrackedSendTransactionParameters<TChain, TAccount, TChainOverride>,
431
- ): Promise<TransactionReceipt> {
432
- const {metadata, nonce, ...sendArgs} = args;
433
-
434
- return executeTrackedTransaction({
435
- account: normalizeAccount(args.account),
436
- nonce,
437
- metadata,
438
- restArgs: sendArgs,
439
- execute: (argsWithNonce) =>
440
- walletClient.sendTransactionSync(argsWithNonce as any),
441
- extractHash: (receipt) => receipt.transactionHash,
442
- });
443
- },
447
+ // ============================================
448
+ // Async methods (return hash)
449
+ // ============================================
450
+
451
+ async writeContract<
452
+ const TAbi extends Abi | readonly unknown[],
453
+ TFunctionName extends ContractFunctionName<
454
+ TAbi,
455
+ 'nonpayable' | 'payable'
456
+ >,
457
+ TArgs extends ContractFunctionArgs<
458
+ TAbi,
459
+ 'nonpayable' | 'payable',
460
+ TFunctionName
461
+ >,
462
+ TChainOverride extends Chain | undefined = undefined,
463
+ >(
464
+ args: TrackedWriteContractParameters<
465
+ TMetadata,
466
+ TAbi,
467
+ TFunctionName,
468
+ TArgs,
469
+ TChain,
470
+ TAccount,
471
+ TChainOverride
472
+ >,
473
+ ): Promise<Hash> {
474
+ const {metadata, nonce, ...writeArgs} = args;
475
+
476
+ return executeTrackedTransaction({
477
+ account: normalizeAccount(args.account),
478
+ nonce,
479
+ metadata: metadata as TMetadata,
480
+ restArgs: writeArgs,
481
+ execute: (argsWithNonce) =>
482
+ walletClient.writeContract(argsWithNonce as any),
483
+ extractHash: (hash) => hash,
484
+ });
485
+ },
486
+
487
+ async sendTransaction<
488
+ TChainOverride extends Chain | undefined = undefined,
489
+ >(
490
+ args: TrackedSendTransactionParameters<
491
+ TMetadata,
492
+ TChain,
493
+ TAccount,
494
+ TChainOverride
495
+ >,
496
+ ): Promise<Hash> {
497
+ const {metadata, nonce, ...sendArgs} = args;
498
+
499
+ return executeTrackedTransaction({
500
+ account: normalizeAccount(args.account),
501
+ nonce,
502
+ metadata: metadata as TMetadata,
503
+ restArgs: sendArgs,
504
+ execute: (argsWithNonce) =>
505
+ walletClient.sendTransaction(argsWithNonce as any),
506
+ extractHash: (hash) => hash,
507
+ });
508
+ },
509
+
510
+ async sendRawTransaction(
511
+ args: TrackedRawTransactionParameters<TMetadata>,
512
+ ): Promise<Hash> {
513
+ const {metadata, serializedTransaction} = args;
514
+
515
+ return executeTrackedRawTransaction({
516
+ serializedTransaction,
517
+ metadata: metadata as TMetadata,
518
+ execute: () =>
519
+ walletClient.sendRawTransaction({serializedTransaction}),
520
+ extractHash: (hash) => hash,
521
+ });
522
+ },
444
523
 
445
- async sendRawTransactionSync(
446
- args: TrackedRawTransactionParameters,
447
- ): Promise<TransactionReceipt> {
448
- const {metadata, serializedTransaction} = args;
449
-
450
- return executeTrackedRawTransaction({
451
- serializedTransaction,
452
- metadata,
453
- execute: () =>
454
- walletClient.sendRawTransactionSync({serializedTransaction}),
455
- extractHash: (receipt) => receipt.transactionHash,
456
- });
524
+ // ============================================
525
+ // Sync methods (return receipt, wait for confirmation)
526
+ // ============================================
527
+
528
+ async writeContractSync<
529
+ const TAbi extends Abi | readonly unknown[],
530
+ TFunctionName extends ContractFunctionName<
531
+ TAbi,
532
+ 'nonpayable' | 'payable'
533
+ >,
534
+ TArgs extends ContractFunctionArgs<
535
+ TAbi,
536
+ 'nonpayable' | 'payable',
537
+ TFunctionName
538
+ >,
539
+ TChainOverride extends Chain | undefined = undefined,
540
+ >(
541
+ args: TrackedWriteContractParameters<
542
+ TMetadata,
543
+ TAbi,
544
+ TFunctionName,
545
+ TArgs,
546
+ TChain,
547
+ TAccount,
548
+ TChainOverride
549
+ >,
550
+ ): Promise<TransactionReceipt> {
551
+ const {metadata, nonce, ...writeArgs} = args;
552
+
553
+ return executeTrackedTransaction({
554
+ account: normalizeAccount(args.account),
555
+ nonce,
556
+ metadata: metadata as TMetadata,
557
+ restArgs: writeArgs,
558
+ execute: (argsWithNonce) =>
559
+ walletClient.writeContractSync(argsWithNonce as any),
560
+ extractHash: (receipt) => receipt.transactionHash,
561
+ });
562
+ },
563
+
564
+ async sendTransactionSync<
565
+ TChainOverride extends Chain | undefined = undefined,
566
+ >(
567
+ args: TrackedSendTransactionParameters<
568
+ TMetadata,
569
+ TChain,
570
+ TAccount,
571
+ TChainOverride
572
+ >,
573
+ ): Promise<TransactionReceipt> {
574
+ const {metadata, nonce, ...sendArgs} = args;
575
+
576
+ return executeTrackedTransaction({
577
+ account: normalizeAccount(args.account),
578
+ nonce,
579
+ metadata: metadata as TMetadata,
580
+ restArgs: sendArgs,
581
+ execute: (argsWithNonce) =>
582
+ walletClient.sendTransactionSync(argsWithNonce as any),
583
+ extractHash: (receipt) => receipt.transactionHash,
584
+ });
585
+ },
586
+
587
+ async sendRawTransactionSync(
588
+ args: TrackedRawTransactionParameters<TMetadata>,
589
+ ): Promise<TransactionReceipt> {
590
+ const {metadata, serializedTransaction} = args;
591
+
592
+ return executeTrackedRawTransaction({
593
+ serializedTransaction,
594
+ metadata: metadata as TMetadata,
595
+ execute: () =>
596
+ walletClient.sendRawTransactionSync({serializedTransaction}),
597
+ extractHash: (receipt) => receipt.transactionHash,
598
+ });
599
+ },
600
+
601
+ // ============================================
602
+ // Event subscription methods
603
+ // ============================================
604
+
605
+ onTransactionBroadcasted: (
606
+ listener: (event: TrackedTransaction<TMetadata>) => void,
607
+ ) => emitter.on('transaction:broadcasted', listener),
608
+
609
+ offTransactionBroadcasted: (
610
+ listener: (event: TrackedTransaction<TMetadata>) => void,
611
+ ) => emitter.off('transaction:broadcasted', listener),
612
+ };
457
613
  },
614
+ };
615
+ }
616
+
617
+ /**
618
+ * Create an auto-populate builder for TrackedWalletClient.
619
+ * This builder auto-populates operation, functionName and args in writeContract metadata.
620
+ */
621
+ function createAutoPopulateBuilder<
622
+ TMetadata,
623
+ >(): TrackedWalletClientAutoPopulateBuilder<TMetadata> {
624
+ return {
625
+ using<TClient extends WalletClient>(
626
+ walletClient: TClient,
627
+ publicClient: PublicClient,
628
+ ): TrackedWalletClientAutoPopulate<
629
+ TMetadata,
630
+ InferTransport<TClient>,
631
+ InferChain<TClient>,
632
+ InferAccount<TClient>
633
+ > {
634
+ // Type aliases for internal use
635
+ type TTransport = InferTransport<TClient>;
636
+ type TChain = InferChain<TClient>;
637
+ type TAccount = InferAccount<TClient>;
638
+
639
+ // Create emitter for transaction broadcast events
640
+ const emitter = new Emitter<{
641
+ 'transaction:broadcasted': TrackedTransaction<TMetadata>;
642
+ }>();
643
+
644
+ /**
645
+ * Resolve the nonce to use for a transaction.
646
+ */
647
+ async function resolveNonce(
648
+ nonceOption: NonceOption | undefined,
649
+ from: Address,
650
+ ): Promise<number> {
651
+ if (typeof nonceOption === 'number') {
652
+ return nonceOption;
653
+ }
654
+ const blockTag = isBlockTag(nonceOption) ? nonceOption : 'pending';
655
+ return await publicClient.getTransactionCount({
656
+ address: from,
657
+ blockTag,
658
+ });
659
+ }
660
+
661
+ /**
662
+ * Extract common transaction context (account, nonce) from request args.
663
+ */
664
+ async function extractTransactionContext(args: {
665
+ account?: Account | Address;
666
+ nonce?: NonceOption;
667
+ }): Promise<TransactionContext> {
668
+ const account = args.account ?? walletClient.account;
669
+ const from = resolveAccountAddress(account);
670
+
671
+ if (!from) {
672
+ throw new Error(
673
+ '[TrackedWalletClient] No account available. ' +
674
+ 'Provide an account in the request or configure the wallet client with an account.',
675
+ );
676
+ }
677
+
678
+ const intendedNonce = await resolveNonce(args.nonce, from);
679
+ return {from, intendedNonce};
680
+ }
681
+
682
+ /**
683
+ * Extract transaction context from a serialized (signed) transaction.
684
+ */
685
+ async function extractRawTransactionContext(
686
+ serializedTransaction: TransactionSerialized,
687
+ ): Promise<TransactionContext> {
688
+ const parsedTx = parseTransaction(serializedTransaction);
689
+
690
+ if (parsedTx.nonce === undefined) {
691
+ throw new Error(
692
+ '[TrackedWalletClient] Could not extract nonce from serialized transaction.',
693
+ );
694
+ }
695
+
696
+ const from = await recoverTransactionAddress({
697
+ serializedTransaction,
698
+ });
699
+
700
+ return {
701
+ from,
702
+ intendedNonce: parsedTx.nonce,
703
+ };
704
+ }
705
+
706
+ /**
707
+ * Fetch the transaction after broadcast to verify nonce.
708
+ */
709
+ async function verifyTransactionNonce(
710
+ hash: Hash,
711
+ intendedNonce: number,
712
+ ): Promise<number> {
713
+ try {
714
+ const tx = await publicClient.getTransaction({hash});
715
+ const actualNonce = tx.nonce;
716
+
717
+ if (actualNonce !== intendedNonce) {
718
+ console.warn(
719
+ `[TrackedWalletClient] Nonce mismatch: intended ${intendedNonce}, actual ${actualNonce}. ` +
720
+ `Wallet may have overridden the nonce.`,
721
+ );
722
+ }
723
+
724
+ return actualNonce;
725
+ } catch (fetchError) {
726
+ console.warn(
727
+ `[TrackedWalletClient] Could not fetch tx ${hash} after broadcast. ` +
728
+ `It may not be in the mempool yet.`,
729
+ );
730
+ return intendedNonce;
731
+ }
732
+ }
458
733
 
459
- // ============================================
460
- // Event subscription methods
461
- // ============================================
734
+ /**
735
+ * Create a tracked transaction record.
736
+ */
737
+ function createTrackedTransactionRecord(
738
+ txHash: Hash,
739
+ from: Address,
740
+ nonce: number,
741
+ metadata: TMetadata,
742
+ ): TrackedTransaction<TMetadata> {
743
+ return {
744
+ hash: txHash,
745
+ from,
746
+ nonce,
747
+ chainId: walletClient.chain?.id,
748
+ metadata,
749
+ broadcastTimestampMs: Date.now(),
750
+ };
751
+ }
752
+
753
+ /**
754
+ * Validate that user didn't provide operation, functionName or args in metadata
755
+ * when populateMetadata is enabled.
756
+ */
757
+ function validateNoAutoPopulatedFieldsInMetadata(
758
+ userMetadata: unknown,
759
+ ): void {
760
+ if (userMetadata && typeof userMetadata === 'object') {
761
+ if ('type' in userMetadata) {
762
+ throw new Error(
763
+ '[TrackedWalletClient] Cannot specify type in metadata when populateMetadata is enabled. ' +
764
+ 'The type is automatically populated from the contract call.',
765
+ );
766
+ }
767
+ if ('functionName' in userMetadata) {
768
+ throw new Error(
769
+ '[TrackedWalletClient] Cannot specify functionName in metadata when populateMetadata is enabled. ' +
770
+ 'The functionName is automatically populated from the contract call.',
771
+ );
772
+ }
773
+ if ('args' in userMetadata) {
774
+ throw new Error(
775
+ '[TrackedWalletClient] Cannot specify args in metadata when populateMetadata is enabled. ' +
776
+ 'The args are automatically populated from the contract call.',
777
+ );
778
+ }
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Common wrapper for transaction methods that broadcast.
784
+ */
785
+ async function executeTrackedTransaction<T, R>(args: {
786
+ account?: Account | Address;
787
+ nonce?: NonceOption;
788
+ metadata: TMetadata;
789
+ restArgs: T;
790
+ execute: (argsWithNonce: T & {nonce: number}) => Promise<R>;
791
+ extractHash: (result: R) => Hash;
792
+ }): Promise<R> {
793
+ const {metadata, restArgs, execute, extractHash} = args;
794
+
795
+ const {from, intendedNonce} = await extractTransactionContext(args);
796
+
797
+ const result = await execute({
798
+ ...restArgs,
799
+ nonce: intendedNonce,
800
+ } as T & {
801
+ nonce: number;
802
+ });
803
+ const hash = extractHash(result);
804
+
805
+ const actualNonce = await verifyTransactionNonce(hash, intendedNonce);
806
+
807
+ const trackedTx = createTrackedTransactionRecord(
808
+ hash,
809
+ from,
810
+ actualNonce,
811
+ metadata,
812
+ );
813
+
814
+ emitter.emit('transaction:broadcasted', trackedTx);
815
+
816
+ return result;
817
+ }
818
+
819
+ /**
820
+ * Common wrapper for raw transaction broadcasts.
821
+ */
822
+ async function executeTrackedRawTransaction<R>(args: {
823
+ serializedTransaction: TransactionSerialized;
824
+ metadata: TMetadata;
825
+ execute: () => Promise<R>;
826
+ extractHash: (result: R) => Hash;
827
+ }): Promise<R> {
828
+ const {serializedTransaction, metadata, execute, extractHash} = args;
829
+
830
+ const {from, intendedNonce} = await extractRawTransactionContext(
831
+ serializedTransaction,
832
+ );
833
+
834
+ const result = await execute();
835
+ const hash = extractHash(result);
836
+
837
+ const trackedTx = createTrackedTransactionRecord(
838
+ hash,
839
+ from,
840
+ intendedNonce,
841
+ metadata,
842
+ );
843
+
844
+ emitter.emit('transaction:broadcasted', trackedTx);
845
+
846
+ return result;
847
+ }
462
848
 
463
- onTransactionBroadcasted: (listener: (event: TrackedTransaction) => void) =>
464
- emitter.on('transaction:broadcasted', listener),
849
+ return {
850
+ walletClient: walletClient as unknown as WalletClient<
851
+ TTransport,
852
+ TChain,
853
+ TAccount
854
+ >,
855
+ publicClient,
465
856
 
466
- offTransactionBroadcasted: (
467
- listener: (event: TrackedTransaction) => void,
468
- ) => emitter.off('transaction:broadcasted', listener),
857
+ // ============================================
858
+ // Async methods (return hash)
859
+ // ============================================
860
+
861
+ async writeContract<
862
+ const TAbi extends Abi | readonly unknown[],
863
+ TFunctionName extends ContractFunctionName<
864
+ TAbi,
865
+ 'nonpayable' | 'payable'
866
+ >,
867
+ TArgs extends ContractFunctionArgs<
868
+ TAbi,
869
+ 'nonpayable' | 'payable',
870
+ TFunctionName
871
+ >,
872
+ TChainOverride extends Chain | undefined = undefined,
873
+ >(
874
+ args: TrackedWriteContractAutoPopulateParameters<
875
+ TMetadata,
876
+ TAbi,
877
+ TFunctionName,
878
+ TArgs,
879
+ TChain,
880
+ TAccount,
881
+ TChainOverride
882
+ >,
883
+ ): Promise<Hash> {
884
+ const {metadata: userMetadata, nonce, ...writeArgs} = args;
885
+
886
+ // Validate that user didn't provide operation, functionName or args
887
+ validateNoAutoPopulatedFieldsInMetadata(userMetadata);
888
+
889
+ // Auto-populate type, functionName and args
890
+ const finalMetadata = {
891
+ ...(userMetadata ?? {}),
892
+ type: 'functionCall' as const,
893
+ functionName: args.functionName as string,
894
+ args: args.args as readonly unknown[],
895
+ } as TMetadata;
896
+
897
+ return executeTrackedTransaction({
898
+ account: normalizeAccount(args.account),
899
+ nonce,
900
+ metadata: finalMetadata,
901
+ restArgs: writeArgs,
902
+ execute: (argsWithNonce) =>
903
+ walletClient.writeContract(argsWithNonce as any),
904
+ extractHash: (hash) => hash,
905
+ });
906
+ },
907
+
908
+ async sendTransaction<
909
+ TChainOverride extends Chain | undefined = undefined,
910
+ >(
911
+ args: TrackedSendTransactionParameters<
912
+ TMetadata,
913
+ TChain,
914
+ TAccount,
915
+ TChainOverride
916
+ >,
917
+ ): Promise<Hash> {
918
+ const {metadata, nonce, ...sendArgs} = args;
919
+
920
+ return executeTrackedTransaction({
921
+ account: normalizeAccount(args.account),
922
+ nonce,
923
+ metadata: metadata as TMetadata,
924
+ restArgs: sendArgs,
925
+ execute: (argsWithNonce) =>
926
+ walletClient.sendTransaction(argsWithNonce as any),
927
+ extractHash: (hash) => hash,
928
+ });
929
+ },
930
+
931
+ async sendRawTransaction(
932
+ args: TrackedRawTransactionParameters<TMetadata>,
933
+ ): Promise<Hash> {
934
+ const {metadata, serializedTransaction} = args;
935
+
936
+ return executeTrackedRawTransaction({
937
+ serializedTransaction,
938
+ metadata: metadata as TMetadata,
939
+ execute: () =>
940
+ walletClient.sendRawTransaction({serializedTransaction}),
941
+ extractHash: (hash) => hash,
942
+ });
943
+ },
944
+
945
+ // ============================================
946
+ // Sync methods (return receipt, wait for confirmation)
947
+ // ============================================
948
+
949
+ async writeContractSync<
950
+ const TAbi extends Abi | readonly unknown[],
951
+ TFunctionName extends ContractFunctionName<
952
+ TAbi,
953
+ 'nonpayable' | 'payable'
954
+ >,
955
+ TArgs extends ContractFunctionArgs<
956
+ TAbi,
957
+ 'nonpayable' | 'payable',
958
+ TFunctionName
959
+ >,
960
+ TChainOverride extends Chain | undefined = undefined,
961
+ >(
962
+ args: TrackedWriteContractAutoPopulateParameters<
963
+ TMetadata,
964
+ TAbi,
965
+ TFunctionName,
966
+ TArgs,
967
+ TChain,
968
+ TAccount,
969
+ TChainOverride
970
+ >,
971
+ ): Promise<TransactionReceipt> {
972
+ const {metadata: userMetadata, nonce, ...writeArgs} = args;
973
+
974
+ // Validate that user didn't provide operation, functionName or args
975
+ validateNoAutoPopulatedFieldsInMetadata(userMetadata);
976
+
977
+ // Auto-populate type, functionName and args
978
+ const finalMetadata = {
979
+ ...(userMetadata ?? {}),
980
+ type: 'functionCall' as const,
981
+ functionName: args.functionName as string,
982
+ args: args.args as readonly unknown[],
983
+ } as TMetadata;
984
+
985
+ return executeTrackedTransaction({
986
+ account: normalizeAccount(args.account),
987
+ nonce,
988
+ metadata: finalMetadata,
989
+ restArgs: writeArgs,
990
+ execute: (argsWithNonce) =>
991
+ walletClient.writeContractSync(argsWithNonce as any),
992
+ extractHash: (receipt) => receipt.transactionHash,
993
+ });
994
+ },
995
+
996
+ async sendTransactionSync<
997
+ TChainOverride extends Chain | undefined = undefined,
998
+ >(
999
+ args: TrackedSendTransactionParameters<
1000
+ TMetadata,
1001
+ TChain,
1002
+ TAccount,
1003
+ TChainOverride
1004
+ >,
1005
+ ): Promise<TransactionReceipt> {
1006
+ const {metadata, nonce, ...sendArgs} = args;
1007
+
1008
+ return executeTrackedTransaction({
1009
+ account: normalizeAccount(args.account),
1010
+ nonce,
1011
+ metadata: metadata as TMetadata,
1012
+ restArgs: sendArgs,
1013
+ execute: (argsWithNonce) =>
1014
+ walletClient.sendTransactionSync(argsWithNonce as any),
1015
+ extractHash: (receipt) => receipt.transactionHash,
1016
+ });
1017
+ },
1018
+
1019
+ async sendRawTransactionSync(
1020
+ args: TrackedRawTransactionParameters<TMetadata>,
1021
+ ): Promise<TransactionReceipt> {
1022
+ const {metadata, serializedTransaction} = args;
1023
+
1024
+ return executeTrackedRawTransaction({
1025
+ serializedTransaction,
1026
+ metadata: metadata as TMetadata,
1027
+ execute: () =>
1028
+ walletClient.sendRawTransactionSync({serializedTransaction}),
1029
+ extractHash: (receipt) => receipt.transactionHash,
1030
+ });
1031
+ },
1032
+
1033
+ // ============================================
1034
+ // Event subscription methods
1035
+ // ============================================
1036
+
1037
+ onTransactionBroadcasted: (
1038
+ listener: (event: TrackedTransaction<TMetadata>) => void,
1039
+ ) => emitter.on('transaction:broadcasted', listener),
1040
+
1041
+ offTransactionBroadcasted: (
1042
+ listener: (event: TrackedTransaction<TMetadata>) => void,
1043
+ ) => emitter.off('transaction:broadcasted', listener),
1044
+ };
1045
+ },
469
1046
  };
470
1047
  }