📄 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