📄 src/web3/client/EvmWriteClient.ts
D-OPEN SOVEREIGN
1import {
2  type Address,
3  type Chain,
4  type Hex,
5  parseEther,
6  type PublicClient,
7  type TransactionReceipt,
8  type WalletClient,
9} from 'viem';
10import {
11  type ContractAddresses,
12  type FactoryWriteAPI,
13  type TransactionOptions,
14  type WriteOnlyDomainAPI,
15  type SubscriptionManagerWriteAPI,
16  type AddressRegistryWriteAPI,
17  type LibraryRegistryStorageWriteAPI,
18  type CollectionInput,
19  type PublicLibraryStorageWriteAPI,
20  type PublicLibraryInput,
21} from './types';
22import { addressLoader } from '../registry/loader';
23import { pythAbi } from '../contracts/pythAbi';
24import { addressRegistryAbi } from '../contracts/addressRegistryAbi';
25import { stringToBytes4 } from '../utils/strings';
26import { normalizeError } from '../utils/errors';
27import { type EvmClient } from './EvmClient';
28
29export class EvmWriteClient implements WriteOnlyDomainAPI {
30  public readonly readonly: EvmClient;
31  public readonly writeOnly: WriteOnlyDomainAPI;
32  private readonly publicClient: PublicClient;
33  private readonly chain: Chain;
34
35  public readonly config;
36  public readonly wallet: string;
37  private readonly environment: 'production' | 'staging';
38  private readonly _account: string;
39
40  constructor(
41    wallet: string,
42    readonlyApi: EvmClient,
43    walletClient: WalletClient,
44    account: string
45  ) {
46    this.wallet = wallet;
47    this.readonly = readonlyApi;
48    this.config = readonlyApi.config;
49    this.environment = readonlyApi.environment;
50    this.chain = readonlyApi.chain;
51    this.publicClient = readonlyApi.publicClient;
52    this._account = account;
53
54    this.writeOnly = {
55      factory: this.createFactoryWriteAPI(walletClient),
56      subscriptionManager: this.createSubscriptionManagerWriteAPI(walletClient),
57      addressRegistry: this.createAddressRegistryWriteAPI(walletClient),
58      libraryRegistryStorage: this.createLibraryRegistryStorageWriteAPI(walletClient),
59      publicLibraryStorage: this.createPublicLibraryStorageWriteAPI(walletClient),
60    };
61  }
62
63  public get factory(): FactoryWriteAPI {
64    return this.writeOnly.factory;
65  }
66
67  public get subscriptionManager(): SubscriptionManagerWriteAPI {
68    return this.writeOnly.subscriptionManager;
69  }
70
71  public get addressRegistry(): AddressRegistryWriteAPI {
72    return this.writeOnly.addressRegistry;
73  }
74
75  public get libraryRegistryStorage(): LibraryRegistryStorageWriteAPI {
76    return this.writeOnly.libraryRegistryStorage;
77  }
78
79  public get publicLibraryStorage(): PublicLibraryStorageWriteAPI {
80    return this.writeOnly.publicLibraryStorage;
81  }
82
83  public getChainId(): number {
84    return this.readonly.chainId;
85  }
86
87  public account(): string {
88    return this._account;
89  }
90
91  public getNormalizedAddress(address: string): string {
92    return address.toLowerCase();
93  }
94
95  private requireAbi(contractName: string) {
96    const abi = addressLoader.getAbi(this.readonly.chainId, contractName, this.environment);
97    if (!abi) {
98      throw new Error(
99        `[EvmWriteClient] Registry ABI for '${contractName}' on chain ${this.readonly.chainId} ` +
100          `is not loaded. The AddressRegistry may be offline, incomplete, or the chain ` +
101          `has not finished syncing. Write operations are blocked until the registry is ready.`
102      );
103    }
104    return abi;
105  }
106
107  // Was duplicated identically inside createFactoryWriteAPI and
108  // createSubscriptionManagerWriteAPI as a local closure each — hoisted here
109  // once consolidation made the duplication visible in one file.
110  private getFactoryAddress(): Address {
111    const dynamic = addressLoader.getAddress(this.readonly.chainId, 'factory', this.environment);
112    if (!dynamic) {
113      throw new Error(
114        `Factory address not resolved for chain ${this.readonly.chainId} (${this.environment}) — ` +
115          `await client.ready before writing.`
116      );
117    }
118    return dynamic as Address;
119  }
120
121  // AddressRegistry's own ABI is static (see requireAbi's comment on EvmClient's
122  // bootstrap exception) and so is its address per environment — mirrors
123  // EvmClient's private activeRegistryAddress getter.
124  private getRegistryAddress(): Address {
125    const address = this.environment === 'staging' && this.config.addresses?.stagingAddressRegistry
126      ? this.config.addresses.stagingAddressRegistry
127      : this.config.addresses?.addressRegistry;
128    if (!address) {
129      throw new Error(`AddressRegistry address not configured for chain ${this.readonly.chainId} (${this.environment})`);
130    }
131    return address as Address;
132  }
133
134  private createFactoryWriteAPI(walletClient: WalletClient): FactoryWriteAPI {
135    const pushPythPrice = async (): Promise<void> => {
136      if (!this.config.main) return; // Only push oracle updates on mainnets where price feeds are live
137
138      const { price } = this.config;
139      if (price.stable) return;
140
141      const { pythOracle, usdPriceId } = price;
142      if (!pythOracle || !usdPriceId) return;
143
144      const res = await fetch(
145        `https://hermes.pyth.network/v2/updates/price/latest?ids[]=${usdPriceId}&encoding=hex`
146      );
147      if (!res.ok) throw new Error(`Hermes price fetch failed: ${res.status}`);
148      const data = await res.json();
149      const hexItems: string[] = data.binary?.data ?? [];
150      if (hexItems.length === 0) throw new Error('No price update data from Hermes');
151      const updateData = hexItems.map((h: string) => `0x${h}` as Hex);
152
153      const fee = (await this.publicClient.readContract({
154        address: pythOracle as Address,
155        abi: pythAbi,
156        functionName: 'getUpdateFee',
157        args: [updateData],
158      })) as bigint;
159
160      const account = walletClient.account!;
161      const hash = await walletClient.writeContract({
162        address: pythOracle as Address,
163        abi: pythAbi,
164        functionName: 'updatePriceFeeds',
165        args: [updateData],
166        account,
167        chain: this.chain,
168        value: fee,
169      });
170      await this.publicClient.waitForTransactionReceipt({ hash });
171    };
172
173    return {
174      donateToProject: async (
175        projectId: number,
176        lang: string,
177        amount: number,
178        options?: TransactionOptions
179      ): Promise<TransactionReceipt> => {
180        try {
181          const account = walletClient.account;
182          if (!account) {
183            throw new Error('No account connected to wallet');
184          }
185
186          const address = this.getFactoryAddress();
187          const votes = BigInt(Math.round(amount));
188
189          const hash = await walletClient.writeContract({
190            address,
191            abi: this.requireAbi('Factory'),
192            functionName: 'voteForProject',
193            args: [projectId, stringToBytes4(lang), votes],
194            account,
195            chain: this.chain,
196            gas: options?.gasLimit,
197          });
198          options?.onAccepted?.(hash);
199          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
200          if (typeof window !== 'undefined') {
201            window.dispatchEvent(new Event('wallet_transaction_accepted'));
202          }
203          return receipt;
204        } catch (error) {
205          throw normalizeError(error);
206        }
207      },
208      register: async (
209        country: string,
210        language: string,
211        paymentValue: number,
212        options?: TransactionOptions
213      ) => {
214        try {
215          const account = walletClient.account;
216          if (!account) {
217            throw new Error('No account connected to wallet');
218          }
219
220          await walletClient.getAddresses();
221          const address = this.getFactoryAddress();
222          const valueInWei = parseEther(String(paymentValue));
223
224          await pushPythPrice();
225
226          const hash = await walletClient.writeContract({
227            address,
228            abi: this.requireAbi('Factory'),
229            functionName: 'register',
230            args: [country, language, 0n],
231            account,
232            chain: this.chain,
233            value: valueInWei,
234            gas: options?.gasLimit,
235          });
236          options?.onAccepted?.(hash);
237          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
238          if (typeof window !== 'undefined') {
239            window.dispatchEvent(new Event('wallet_transaction_accepted'));
240          }
241          return receipt;
242        } catch (error) {
243          throw normalizeError(error);
244        }
245      },
246      unregister: async (options?: TransactionOptions) => {
247        try {
248          const account = walletClient.account;
249          if (!account) {
250            throw new Error('No account connected to wallet');
251          }
252
253          const address = this.getFactoryAddress();
254          const hash = await walletClient.writeContract({
255            address,
256            abi: this.requireAbi('Factory'),
257            functionName: 'unregister',
258            args: [],
259            account,
260            chain: this.chain,
261            gas: options?.gasLimit,
262          });
263          options?.onAccepted?.(hash);
264          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
265          if (typeof window !== 'undefined') {
266            window.dispatchEvent(new Event('wallet_transaction_accepted'));
267          }
268          return receipt;
269        } catch (error) {
270          throw normalizeError(error);
271        }
272      },
273      save: async (
274        patches: Array<bigint>,
275        level: number,
276        indexes: Array<number>,
277        text: Array<string>,
278        options?: TransactionOptions
279      ) => {
280        try {
281          const account = walletClient.account;
282          if (!account) {
283            throw new Error('No account connected to wallet');
284          }
285
286          const address = this.getFactoryAddress();
287          const hash = await walletClient.writeContract({
288            address,
289            abi: this.requireAbi('Factory'),
290            functionName: 'save',
291            args: [patches, level, indexes, text],
292            account,
293            chain: this.chain,
294            gas: options?.gasLimit,
295          });
296
297          options?.onAccepted?.(hash);
298          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
299          if (typeof window !== 'undefined') {
300            window.dispatchEvent(new Event('wallet_transaction_accepted'));
301          }
302          return receipt;
303        } catch (error) {
304          throw normalizeError(error);
305        }
306      },
307      createProject: async (
308        lang: string,
309        uri: string,
310        publicationDate: number,
311        name: string,
312        headline: string,
313        arweaveAddressLogo: string,
314        arweaveAddressBanner: string,
315        descriptionMarkdown: string,
316        minimumFundingBeforeStartWork: number,
317        maximumFunding: number,
318        tags: number[],
319        complexity: number,
320        options?: TransactionOptions
321      ) => {
322        try {
323          const account = walletClient.account;
324          if (!account) {
325            throw new Error('No account connected to wallet');
326          }
327
328          const address = this.getFactoryAddress();
329          const hash = await walletClient.writeContract({
330            address,
331            abi: this.requireAbi('Factory'),
332            functionName: 'createProject',
333            args: [
334              stringToBytes4(lang),
335              uri,
336              publicationDate,
337              name,
338              headline,
339              arweaveAddressLogo,
340              arweaveAddressBanner,
341              descriptionMarkdown,
342              minimumFundingBeforeStartWork,
343              maximumFunding,
344              tags,
345              complexity,
346            ],
347            account,
348            chain: this.chain,
349            gas: options?.gasLimit,
350          });
351
352          options?.onAccepted?.(hash);
353          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
354          if (typeof window !== 'undefined') {
355            window.dispatchEvent(new Event('wallet_transaction_accepted'));
356          }
357          return receipt;
358        } catch (error) {
359          throw normalizeError(error);
360        }
361      },
362      registerStripe: async (
363        country: string,
364        language: string,
365        paymentIntentId: string,
366        votes: bigint,
367        signature: string,
368        options?: TransactionOptions
369      ) => {
370        try {
371          const account = walletClient.account;
372          if (!account) throw new Error('No account connected to wallet');
373          const address = this.getFactoryAddress();
374          const hash = await walletClient.writeContract({
375            address,
376            abi: this.requireAbi('Factory'),
377            functionName: 'registerStripe',
378            args: [country, language, paymentIntentId, votes, 0, signature as Hex],
379            account,
380            chain: this.chain,
381            gas: options?.gasLimit,
382          });
383          options?.onAccepted?.(hash);
384          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
385          if (typeof window !== 'undefined') {
386            window.dispatchEvent(new Event('wallet_transaction_accepted'));
387          }
388          return receipt;
389        } catch (error) {
390          throw normalizeError(error);
391        }
392      },
393      fundStripe: async (
394        paymentIntentId: string,
395        votes: bigint,
396        signature: string,
397        options?: TransactionOptions
398      ) => {
399        try {
400          const account = walletClient.account;
401          if (!account) throw new Error('No account connected to wallet');
402          const address = this.getFactoryAddress();
403          const hash = await walletClient.writeContract({
404            address,
405            abi: this.requireAbi('Factory'),
406            functionName: 'fundStripe',
407            args: [paymentIntentId, votes, signature as Hex],
408            account,
409            chain: this.chain,
410            gas: options?.gasLimit,
411          });
412          options?.onAccepted?.(hash);
413          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
414          if (typeof window !== 'undefined') {
415            window.dispatchEvent(new Event('wallet_transaction_accepted'));
416          }
417          return receipt;
418        } catch (error) {
419          throw normalizeError(error);
420        }
421      },
422      addProjectVersion: async (
423        projectId: number,
424        lang: string,
425        publicationDate: number,
426        uri: string,
427        name: string,
428        headline: string,
429        arweaveAddressLogo: string,
430        arweaveAddressBanner: string,
431        descriptionMarkdown: string,
432        minimumFundingBeforeStartWork: number,
433        maximumFunding: number,
434        tags: number[],
435        status: number,
436        complexity: number,
437        options?: TransactionOptions
438      ) => {
439        try {
440          const account = walletClient.account;
441          if (!account) {
442            throw new Error('No account connected to wallet');
443          }
444
445          const address = this.getFactoryAddress();
446          const hash = await walletClient.writeContract({
447            address,
448            abi: this.requireAbi('Factory'),
449            functionName: 'addProjectVersion',
450            args: [
451              BigInt(projectId),
452              stringToBytes4(lang),
453              publicationDate,
454              uri,
455              name,
456              headline,
457              arweaveAddressLogo,
458              arweaveAddressBanner,
459              descriptionMarkdown,
460              minimumFundingBeforeStartWork,
461              maximumFunding,
462              tags,
463              status,
464              complexity,
465            ],
466            account,
467            chain: this.chain,
468            gas: options?.gasLimit,
469          });
470          options?.onAccepted?.(hash);
471          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
472          if (typeof window !== 'undefined') {
473            window.dispatchEvent(new Event('wallet_transaction_accepted'));
474          }
475          return receipt;
476        } catch (error) {
477          throw normalizeError(error);
478        }
479      },
480      deleteProject: async (
481        projectId: number,
482        lang: string,
483        options?: TransactionOptions
484      ) => {
485        try {
486          const account = walletClient.account;
487          if (!account) {
488            throw new Error('No account connected to wallet');
489          }
490
491          const address = this.getFactoryAddress();
492          const hash = await walletClient.writeContract({
493            address,
494            abi: this.requireAbi('Factory'),
495            functionName: 'deleteProject',
496            args: [projectId, stringToBytes4(lang)],
497            account,
498            chain: this.chain,
499            gas: options?.gasLimit,
500          });
501          options?.onAccepted?.(hash);
502          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
503          if (typeof window !== 'undefined') {
504            window.dispatchEvent(new Event('wallet_transaction_accepted'));
505          }
506          return receipt;
507        } catch (error) {
508          throw normalizeError(error);
509        }
510      },
511    };
512  }
513
514  private createSubscriptionManagerWriteAPI(walletClient: WalletClient): SubscriptionManagerWriteAPI {
515    // Writes go through Factory's public subscribeTo/unsubscribeFrom(username) facade,
516    // which internally calls the onlyAuthorized SubscriptionManager.subscribeTo/unsubscribeFrom(ids) —
517    // reads target SubscriptionManager directly (contract layout, not a bug: see Factory.sol:302-316).
518    return {
519      subscribeTo: async (subscriber: number, target: number, options?: TransactionOptions): Promise<any> => {
520        try {
521          const account = walletClient.account;
522          if (!account) {
523            throw new Error('No account connected to wallet');
524          }
525
526          const targetUsername = await this.readonly.bouncerStorage.userIdToUsername(target);
527          if (!targetUsername) {
528            throw new Error(`Target user ID ${target} not registered (no username found)`);
529          }
530
531          const address = this.getFactoryAddress();
532          const hash = await walletClient.writeContract({
533            address,
534            abi: this.requireAbi('Factory'),
535            functionName: 'subscribeTo',
536            args: [targetUsername],
537            account,
538            chain: this.chain,
539            gas: options?.gasLimit,
540          });
541          options?.onAccepted?.(hash);
542          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
543          if (typeof window !== 'undefined') {
544            window.dispatchEvent(new Event('wallet_transaction_accepted'));
545          }
546          return receipt;
547        } catch (error) {
548          throw normalizeError(error);
549        }
550      },
551      unsubscribeFrom: async (subscriber: number, target: number, options?: TransactionOptions): Promise<any> => {
552        try {
553          const account = walletClient.account;
554          if (!account) {
555            throw new Error('No account connected to wallet');
556          }
557
558          const targetUsername = await this.readonly.bouncerStorage.userIdToUsername(target);
559          if (!targetUsername) {
560            throw new Error(`Target user ID ${target} not registered (no username found)`);
561          }
562
563          const address = this.getFactoryAddress();
564          const hash = await walletClient.writeContract({
565            address,
566            abi: this.requireAbi('Factory'),
567            functionName: 'unsubscribeFrom',
568            args: [targetUsername],
569            account,
570            chain: this.chain,
571            gas: options?.gasLimit,
572          });
573          options?.onAccepted?.(hash);
574          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
575          if (typeof window !== 'undefined') {
576            window.dispatchEvent(new Event('wallet_transaction_accepted'));
577          }
578          return receipt;
579        } catch (error) {
580          throw normalizeError(error);
581        }
582      },
583    };
584  }
585
586  private createAddressRegistryWriteAPI(walletClient: WalletClient): AddressRegistryWriteAPI {
587    return {
588      addData: async (key: string, value: string, options?: TransactionOptions) => {
589        try {
590          const account = walletClient.account;
591          if (!account) {
592            throw new Error('No account connected to wallet');
593          }
594
595          const address = this.getRegistryAddress();
596          const hash = await walletClient.writeContract({
597            address,
598            abi: addressRegistryAbi,
599            functionName: 'addData',
600            args: [key, value],
601            account,
602            chain: this.chain,
603            gas: options?.gasLimit,
604          });
605          options?.onAccepted?.(hash);
606          const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
607          if (typeof window !== 'undefined') {
608            window.dispatchEvent(new Event('wallet_transaction_accepted'));
609          }
610          return receipt;
611        } catch (error) {
612          throw normalizeError(error);
613        }
614      },
615    };
616  }
617
618  private getLibraryRegistryStorageAddress(): Address {
619    const dynamic = addressLoader.getAddress(this.readonly.chainId, 'libraryRegistryStorage', this.environment);
620    if (!dynamic) {
621      throw new Error(
622        `LibraryRegistryStorage address not resolved for chain ${this.readonly.chainId} (${this.environment}) — ` +
623          `await client.ready before writing.`
624      );
625    }
626    return dynamic as Address;
627  }
628
629  private createLibraryRegistryStorageWriteAPI(walletClient: WalletClient): LibraryRegistryStorageWriteAPI {
630    const executeWrite = async (functionName: string, args: any[], options?: TransactionOptions) => {
631      try {
632        const account = walletClient.account;
633        if (!account) throw new Error('No account connected to wallet');
634        const address = this.getLibraryRegistryStorageAddress();
635        const hash = await walletClient.writeContract({
636          address,
637          abi: this.requireAbi('LibraryRegistryStorage'),
638          functionName,
639          args,
640          account,
641          chain: this.chain,
642          gas: options?.gasLimit,
643        });
644        options?.onAccepted?.(hash);
645        const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
646        if (typeof window !== 'undefined') {
647          window.dispatchEvent(new Event('wallet_transaction_accepted'));
648        }
649        return receipt;
650      } catch (error) {
651        throw normalizeError(error);
652      }
653    };
654
655    return {
656      addUnverifiedCollection: (input: CollectionInput, options?: TransactionOptions) =>
657        executeWrite('addUnverifiedCollection', [input], options),
658      verifyCollection: (id: string, options?: TransactionOptions) =>
659        executeWrite('verifyCollection', [id], options),
660      rejectCollection: (id: string, options?: TransactionOptions) =>
661        executeWrite('rejectCollection', [id], options),
662      updateCollectionStats: (id: string, initialBookCount: number, dSafeBookCount: number, options?: TransactionOptions) =>
663        executeWrite('updateCollectionStats', [id, BigInt(initialBookCount), BigInt(dSafeBookCount)], options),
664      setImportStatus: (id: string, status: number, options?: TransactionOptions) =>
665        executeWrite('setImportStatus', [id, status], options),
666      setPublished: (id: string, isPublished: boolean, options?: TransactionOptions) =>
667        executeWrite('setPublished', [id, isPublished], options),
668      setCollectionPublicLibrary: (id: string, publicLibraryId: number, options?: TransactionOptions) =>
669        executeWrite('setCollectionPublicLibrary', [id, BigInt(publicLibraryId)], options),
670      setPublicLibraryStorage: (storageAddr: string, options?: TransactionOptions) =>
671        executeWrite('setPublicLibraryStorage', [storageAddr as Address], options),
672    };
673  }
674
675  private getPublicLibraryStorageAddress(): Address {
676    const dynamic = addressLoader.getAddress(this.readonly.chainId, 'publicLibraryStorage', this.environment);
677    if (!dynamic) {
678      throw new Error(
679        `PublicLibraryStorage address not resolved for chain ${this.readonly.chainId} (${this.environment}) — ` +
680          `await client.ready before writing.`
681      );
682    }
683    return dynamic as Address;
684  }
685
686  private createPublicLibraryStorageWriteAPI(walletClient: WalletClient): PublicLibraryStorageWriteAPI {
687    const executeWrite = async (functionName: string, args: any[], options?: TransactionOptions) => {
688      try {
689        const account = walletClient.account;
690        if (!account) throw new Error('No account connected to wallet');
691        const address = this.getPublicLibraryStorageAddress();
692        const hash = await walletClient.writeContract({
693          address,
694          abi: this.requireAbi('PublicLibraryStorage'),
695          functionName,
696          args,
697          account,
698          chain: this.chain,
699          gas: options?.gasLimit,
700        });
701        options?.onAccepted?.(hash);
702        const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
703        if (typeof window !== 'undefined') {
704          window.dispatchEvent(new Event('wallet_transaction_accepted'));
705        }
706        return receipt;
707      } catch (error) {
708        throw normalizeError(error);
709      }
710    };
711
712    return {
713      addPublicLibrary: (input: PublicLibraryInput, options?: TransactionOptions) =>
714        executeWrite('addPublicLibrary', [input], options),
715      updatePublicLibrary: (id: number, input: PublicLibraryInput, options?: TransactionOptions) =>
716        executeWrite('updatePublicLibrary', [BigInt(id), input], options),
717    };
718  }
719}
720