@etherkit/viem-tx-tracker 0.0.4 → 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,12 +17,15 @@ import {
17
17
  import {Emitter} from 'radiate';
18
18
  import type {
19
19
  BlockTag,
20
- MetadataField,
20
+ CreateTrackedWalletClientOptions,
21
21
  NonceOption,
22
+ PopulatedMetadata,
22
23
  TrackedRawTransactionParameters,
23
24
  TrackedSendTransactionParameters,
24
25
  TrackedTransaction,
25
26
  TrackedWalletClient,
27
+ TrackedWalletClientAutoPopulate,
28
+ TrackedWriteContractAutoPopulateParameters,
26
29
  TrackedWriteContractParameters,
27
30
  } from './types.js';
28
31
 
@@ -113,6 +116,31 @@ export interface TrackedWalletClientBuilder<TMetadata> {
113
116
  >;
114
117
  }
115
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
+
116
144
  /**
117
145
  * Create a tracked wallet client that wraps a viem WalletClient.
118
146
  *
@@ -127,18 +155,51 @@ export interface TrackedWalletClientBuilder<TMetadata> {
127
155
  *
128
156
  * @example
129
157
  * ```typescript
130
- * // With required metadata
158
+ * // Standard mode with required metadata
131
159
  * const tracked = createTrackedWalletClient<{purpose: string}>()
132
160
  * .using(walletClient, publicClient);
133
161
  *
134
- * // With optional metadata
162
+ * // Standard mode with optional metadata
135
163
  * const tracked = createTrackedWalletClient<{purpose: string} | undefined>()
136
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);
169
+ *
170
+ * // Auto-populate mode with extended metadata
171
+ * type MyMetadata = OperationMetadata & { purpose: string };
172
+ * const tracked = createTrackedWalletClient<MyMetadata>({ populateMetadata: true })
173
+ * .using(walletClient, publicClient);
137
174
  * ```
138
175
  */
176
+ // Overload 1: Standard mode, no options
139
177
  export function createTrackedWalletClient<
140
178
  TMetadata,
141
- >(): TrackedWalletClientBuilder<TMetadata> {
179
+ >(): TrackedWalletClientBuilder<TMetadata>;
180
+
181
+ // Overload 2: Auto-populate mode with default PopulatedMetadata
182
+ export function createTrackedWalletClient(options: {
183
+ populateMetadata: true;
184
+ }): TrackedWalletClientAutoPopulateBuilder<PopulatedMetadata>;
185
+
186
+ // Overload 3: Auto-populate mode with custom metadata (must allow FunctionCallMetadata)
187
+ export function createTrackedWalletClient<TMetadata>(options: {
188
+ populateMetadata: true;
189
+ }): TrackedWalletClientAutoPopulateBuilder<TMetadata>;
190
+
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>;
201
+ }
202
+
142
203
  return {
143
204
  using<TClient extends WalletClient>(
144
205
  walletClient: TClient,
@@ -552,3 +613,435 @@ export function createTrackedWalletClient<
552
613
  },
553
614
  };
554
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
+ }
733
+
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
+ }
848
+
849
+ return {
850
+ walletClient: walletClient as unknown as WalletClient<
851
+ TTransport,
852
+ TChain,
853
+ TAccount
854
+ >,
855
+ publicClient,
856
+
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
+ },
1046
+ };
1047
+ }
package/src/index.ts CHANGED
@@ -5,4 +5,5 @@ export type * from './types.js';
5
5
  export {
6
6
  createTrackedWalletClient,
7
7
  type TrackedWalletClientBuilder,
8
+ type TrackedWalletClientAutoPopulateBuilder,
8
9
  } from './TrackedWalletClient.js';