๐ src/web3/session/mutations.ts
D-OPEN SOVEREIGN
Documentation & Insights
Assembles the caller's own locally-authored patches since their last save
(getNbPatches/serializePatches, models_v2) and pushes them to BackupStorage
via writeOperations.save โ the pipeline apps used to assemble by hand.
Returns false (no-op, no transaction sent) if there's nothing new to save.1import type { TransactionOptions, WalletType } from '../client/types';
2import type { EvmWriteClient } from '../client/EvmWriteClient';
3import { WalletEvents } from '../events/wallet_events';
4import { WalletState, WalletError as WalletErrorReason } from '../events/enums';
5import { getConnector } from '../wallet/registry';
6import { normalizeError } from '../utils/errors';
7import { createWriteClient } from './writeClient';
8import { syncAccount } from './sync';
9import { sessionStore } from './store';
10import { serializePatches, getLevel } from '@the_library/public/models';
11import { getNbPatches } from '../../models/indexdb/patch_storage';
12
13const running = { lastRun: 0, running: false };
14
15export const resetTransactionState = (): void => {
16 running.running = false;
17 running.lastRun = 0;
18};
19
20const checkRunning = (): boolean => {
21 if (running.running && Date.now() - running.lastRun < 1000 * 60) {
22 return false;
23 }
24 running.lastRun = Date.now();
25 running.running = true;
26 return true;
27};
28
29export const handleWriteOperationErrors = (wallet: WalletType, chainId: number, e: any): void => {
30 console.error(`[session/mutations] error while transaction wallet=${wallet} chainId=${chainId}`, e);
31
32 resetTransactionState();
33
34 // Delegates classification to utils/errors.ts's normalizeError() instead of
35 // re-implementing it โ this classifier had previously drifted to a
36 // case-sensitive, narrower check that missed e.g. MetaMask's actual
37 // "User rejected the request." wording on any provider that doesn't also
38 // set code 4001/ACTION_REJECTED.
39 const isUserRejection = normalizeError(e).code === 'USER_REJECTED';
40
41 WalletEvents.emitWalletError({
42 wallet,
43 chainId,
44 timestamp: Date.now(),
45 reason: isUserRejection ? WalletErrorReason.USER_REJECTED : e?.code ?? WalletErrorReason.UNKNOWN_ERROR,
46 args: e,
47 });
48};
49
50const transactionStarted = (wallet: WalletType, chainId: number) => {
51 WalletEvents.emitWalletStateEvent({ wallet, chainId, event: WalletState.TRANSACTION_STARTED, timestamp: Date.now() });
52};
53
54const transactionDone = (wallet: WalletType, chainId: number) => {
55 WalletEvents.emitWalletStateEvent({ wallet, chainId, event: WalletState.TRANSACTION_DONE, timestamp: Date.now() });
56};
57
58const preTransaction = async (wallet: WalletType, chainId: number): Promise<boolean> => {
59 if (!checkRunning()) {
60 console.log('[session/mutations] preTransaction skipped โ another transaction is running, wait 1min');
61 return false;
62 }
63 return true;
64};
65
66const postTransaction = async (wallet: WalletType, chainId: number, account: string): Promise<boolean> => {
67 running.running = false;
68 running.lastRun = 0;
69
70 WalletEvents.emitWalletStateEvent({
71 wallet,
72 chainId,
73 event: WalletState.UPDATING_STATE_STARTED,
74 functionName: 'syncAccount',
75 timestamp: Date.now(),
76 });
77
78 try {
79 await syncAccount(account, chainId, wallet, 'production', true);
80 } catch (e) {
81 WalletEvents.emitWalletStateEvent({
82 wallet,
83 chainId,
84 event: WalletState.UPDATING_STATE_FAILED,
85 functionName: 'syncAccount',
86 timestamp: Date.now(),
87 });
88 console.error(e);
89 return false;
90 }
91
92 WalletEvents.emitWalletStateEvent({
93 wallet,
94 chainId,
95 event: WalletState.UPDATING_STATE_DONE,
96 functionName: 'syncAccount',
97 timestamp: Date.now(),
98 });
99 return true;
100};
101
102async function getWriteClient(wallet: WalletType, chainId: number) {
103 const connector = getConnector(wallet);
104 if (!connector) {
105 throw new Error(`No connector registered for wallet '${wallet}' โ bootstrapWebSDK() must run first`);
106 }
107 return createWriteClient(connector, chainId);
108}
109
110// Shared skeleton for every write operation below: preTransaction guard โ
111// transactionStarted โ send the write โ handleWriteOperationErrors on failure
112// โ transactionDone โ optional local-state update โ postTransaction (which
113// re-syncs the account, including subscription sync per spec ยง4.7/ยง15.8).
114// Collapses what used to be ~10 repeated lines per operation.
115async function runWriteOperation(
116 wallet: WalletType,
117 chainId: number,
118 account: string,
119 fn: (client: EvmWriteClient) => Promise<any>,
120 afterWrite?: () => void
121): Promise<boolean> {
122 if (!(await preTransaction(wallet, chainId))) return false;
123 transactionStarted(wallet, chainId);
124
125 try {
126 const client = await getWriteClient(wallet, chainId);
127 await fn(client);
128 } catch (e) {
129 handleWriteOperationErrors(wallet, chainId, e);
130 throw e;
131 }
132 transactionDone(wallet, chainId);
133 afterWrite?.();
134 return postTransaction(wallet, chainId, account);
135}
136
137export const writeOperations = {
138 register: (wallet: WalletType, chainId: number, account: string, amount: number): Promise<boolean> => {
139 // Prefer geo-detected location from index.html (__userGeo uses ipapi.co) so VPN
140 // locale doesn't corrupt the country/language stored on-chain.
141 const userGeo = typeof window !== 'undefined' ? (window as any).__userGeo : undefined;
142 let countryCode: string;
143 let languageCode: string;
144 if (userGeo?.location && userGeo?.locale) {
145 countryCode = userGeo.location.toUpperCase();
146 languageCode = userGeo.locale.split('-')[0];
147 } else {
148 const defaultLanguage = 'en';
149 const defaultCountry = '';
150 const hasNavigator = typeof navigator !== 'undefined';
151 const parts = hasNavigator ? navigator.language.split('-') : [defaultLanguage];
152 languageCode = parts[0];
153 countryCode = parts.length > 1 ? parts[1].toUpperCase() : defaultCountry;
154 }
155
156 return runWriteOperation(wallet, chainId, account, (client) =>
157 client.factory.register(countryCode, languageCode, amount, { value: amount })
158 );
159 },
160
161 retire: (wallet: WalletType, chainId: number, account: string, options?: TransactionOptions): Promise<boolean> =>
162 runWriteOperation(wallet, chainId, account, (client) => client.factory.unregister(options)),
163
164 vote: (
165 wallet: WalletType,
166 chainId: number,
167 account: string,
168 projectId: number,
169 lang: string,
170 amount: number,
171 options?: TransactionOptions
172 ): Promise<boolean> =>
173 runWriteOperation(wallet, chainId, account, (client) => client.factory.donateToProject(projectId, lang, amount, options)),
174
175 save: (
176 wallet: WalletType,
177 chainId: number,
178 account: string,
179 compressedActions: Array<bigint>,
180 textIndexes: Array<number>,
181 textData: Array<string>,
182 level: number,
183 options?: TransactionOptions & { skipCounterUpdate?: boolean }
184 ): Promise<boolean> =>
185 runWriteOperation(
186 wallet,
187 chainId,
188 account,
189 (client) => client.factory.save(compressedActions, level, textIndexes, textData, options),
190 () => {
191 if (!options?.skipCounterUpdate) {
192 const { lastPatchIndex } = sessionStore.lastSyncCounters(account, chainId);
193 sessionStore.setLastSavedPatchIndex(account, chainId, lastPatchIndex + compressedActions.length);
194 }
195 }
196 ),
197
198 // postTransaction's syncAccount() refresh also runs SubscriptionClient.syncSince()
199 // (session/sync.ts) โ the graph change from these two writes is picked up
200 // automatically, no bespoke "refresh subscriptions" step needed (spec ยง4.7/ยง15.8).
201 subscribe: (wallet: WalletType, chainId: number, account: string, subscriber: number, target: number): Promise<boolean> =>
202 runWriteOperation(wallet, chainId, account, (client) => client.subscriptionManager.subscribeTo(subscriber, target)),
203
204 unsubscribe: (wallet: WalletType, chainId: number, account: string, subscriber: number, target: number): Promise<boolean> =>
205 runWriteOperation(wallet, chainId, account, (client) => client.subscriptionManager.unsubscribeFrom(subscriber, target)),
206};
207
208/**
209 * Assembles the caller's own locally-authored patches since their last save
210 * (getNbPatches/serializePatches, models_v2) and pushes them to BackupStorage
211 * via writeOperations.save โ the pipeline apps used to assemble by hand.
212 * Returns false (no-op, no transaction sent) if there's nothing new to save.
213 */
214export async function saveMyLevel(
215 wallet: WalletType,
216 chainId: number,
217 account: string,
218 options?: TransactionOptions & { skipCounterUpdate?: boolean }
219): Promise<boolean> {
220 const { lastPatchIndex } = sessionStore.lastSyncCounters(account, chainId);
221 const nbPatches = await getNbPatches('mine');
222 if (nbPatches <= lastPatchIndex) return false;
223
224 const payload = await serializePatches(lastPatchIndex, nbPatches);
225 const level = getLevel('mine').level;
226
227 return writeOperations.save(wallet, chainId, account, payload.patches, payload.textIndex, payload.textData, level, options);
228}
229