๐Ÿ“„ 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