📄 src/web3/session/store.ts
D-OPEN SOVEREIGN
Documentation & Insights
Returns all known accounts that have completed on-chain registration
(i.e. `hasAccount === true`). This is a plain (non-reactive) snapshot —
wrap in `computed()` at the call site if reactivity is needed.1import { type Web3SessionAccount, type SessionState, type SessionStore } from './types';
2
3export function createSessionStore(): SessionStore {
4 const PREFIX = '@the_library/web3-session:';
5 const NEW_SEG = 'infos_web3_';
6 const ACTIVE_KEY = 'active_session';
7
8 const state: SessionState = {
9 active: null,
10 known: [],
11 };
12
13 const changeListeners = new Set<() => void>();
14 const notify = () => changeListeners.forEach((l) => l());
15
16 const getLocalStorageKey = (address: string, chainId: number): string => {
17 return `${NEW_SEG}${chainId}_${address.toLowerCase()}`;
18 };
19
20 const persistAccount = (account: Web3SessionAccount) => {
21 if (typeof localStorage === 'undefined') return;
22 const key = getLocalStorageKey(account.account, account.chainId);
23 localStorage.setItem(PREFIX + key, JSON.stringify(account));
24 };
25
26 const removePersistedAccount = (address: string, chainId: number) => {
27 if (typeof localStorage === 'undefined') return;
28 const key = getLocalStorageKey(address, chainId);
29 localStorage.removeItem(PREFIX + key);
30 };
31
32 const persistActive = () => {
33 if (typeof localStorage === 'undefined') return;
34 if (state.active) {
35 localStorage.setItem(
36 PREFIX + ACTIVE_KEY,
37 JSON.stringify({ address: state.active.account, chainId: state.active.chainId })
38 );
39 } else {
40 localStorage.removeItem(PREFIX + ACTIVE_KEY);
41 }
42 };
43
44 const loadActive = (): Web3SessionAccount | null => {
45 if (typeof localStorage === 'undefined') return null;
46 const raw = localStorage.getItem(PREFIX + ACTIVE_KEY);
47 if (!raw) return null;
48 try {
49 const parsed = JSON.parse(raw);
50 return (
51 state.known.find(
52 (a) => a.account.toLowerCase() === parsed.address.toLowerCase() && a.chainId === parsed.chainId
53 ) || null
54 );
55 } catch {
56 return null;
57 }
58 };
59
60 function migrateStorageKeys(): void {
61 if (typeof localStorage === 'undefined') return;
62 const OLD_SEG = 'infos_evm_';
63 for (let i = 0; i < localStorage.length; i++) {
64 const raw = localStorage.key(i);
65 if (!raw) continue;
66 // Strip prefix to get the bare key, check segment
67 const bare = raw.startsWith(PREFIX) ? raw.slice(PREFIX.length) : null;
68 if (bare && bare.startsWith(OLD_SEG)) {
69 const newRaw = PREFIX + NEW_SEG + bare.slice(OLD_SEG.length);
70 localStorage.setItem(newRaw, localStorage.getItem(raw)!);
71 localStorage.removeItem(raw);
72 i--; // recheck index after removal
73 }
74 }
75 }
76
77 const store: SessionStore = {
78 getState() {
79 return state;
80 },
81 setActive(account: Web3SessionAccount | null) {
82 state.active = account;
83 if (account) {
84 // Add to known if not already present
85 const exists = state.known.some(
86 (a) => a.account.toLowerCase() === account.account.toLowerCase() && a.chainId === account.chainId
87 );
88 if (!exists) {
89 state.known.push(account);
90 persistAccount(account);
91 }
92 }
93 persistActive();
94 notify();
95 },
96 addKnown(account: Web3SessionAccount) {
97 const index = state.known.findIndex(
98 (a) => a.account.toLowerCase() === account.account.toLowerCase() && a.chainId === account.chainId
99 );
100 if (index >= 0) {
101 state.known[index] = account;
102 } else {
103 state.known.push(account);
104 }
105 persistAccount(account);
106 notify();
107 },
108 updateAccount(address: string, chainId: number, patch: Partial<Web3SessionAccount>) {
109 const account = state.known.find(
110 (a) => a.account.toLowerCase() === address.toLowerCase() && a.chainId === chainId
111 );
112 if (account) {
113 Object.assign(account, patch);
114 persistAccount(account);
115 if (
116 state.active &&
117 state.active.account.toLowerCase() === address.toLowerCase() &&
118 state.active.chainId === chainId
119 ) {
120 Object.assign(state.active, patch);
121 persistActive();
122 }
123 notify();
124 }
125 },
126 removeAccount(address: string, chainId: number) {
127 state.known = state.known.filter(
128 (a) => !(a.account.toLowerCase() === address.toLowerCase() && a.chainId === chainId)
129 );
130 removePersistedAccount(address, chainId);
131 if (
132 state.active &&
133 state.active.account.toLowerCase() === address.toLowerCase() &&
134 state.active.chainId === chainId
135 ) {
136 state.active = null;
137 persistActive();
138 }
139 notify();
140 },
141 clearAll() {
142 if (typeof localStorage !== 'undefined') {
143 const keysToRemove: string[] = [];
144 for (let i = 0; i < localStorage.length; i++) {
145 const key = localStorage.key(i);
146 if (key && key.startsWith(PREFIX)) {
147 keysToRemove.push(key);
148 }
149 }
150 for (const k of keysToRemove) {
151 localStorage.removeItem(k);
152 }
153 }
154 state.active = null;
155 state.known = [];
156 notify();
157 },
158 setLastSavedPatchIndex(address: string, chainId: number, index: number) {
159 this.updateAccount(address, chainId, { lastPatchIndex: index });
160 },
161 setLastRestorationIndex(address: string, chainId: number, index: number) {
162 this.updateAccount(address, chainId, { lastRestorationIndex: index });
163 },
164 lastSyncCounters(address: string, chainId: number) {
165 const account = state.known.find(
166 (a) => a.account.toLowerCase() === address.toLowerCase() && a.chainId === chainId
167 );
168 return {
169 lastPatchIndex: account?.lastPatchIndex ?? 0,
170 lastRestorationIndex: account?.lastRestorationIndex ?? 0,
171 };
172 },
173 updateProjectVotes(address: string, chainId: number, projectId: number, amount: number) {
174 const account = state.known.find(
175 (a) => a.account.toLowerCase() === address.toLowerCase() && a.chainId === chainId
176 );
177 if (account) {
178 const index = projectId - 1;
179 if (index < 0) return;
180 const votes = [...(account.donationsPerProject || [])];
181 while (votes.length <= index) {
182 votes.push(0);
183 }
184 votes[index] = (votes[index] || 0) + amount;
185 this.updateAccount(address, chainId, { donationsPerProject: votes });
186 }
187 },
188 persist() {
189 for (const a of state.known) {
190 persistAccount(a);
191 }
192 persistActive();
193 },
194 load() {
195 if (typeof localStorage === 'undefined') return;
196 migrateStorageKeys();
197
198 const loadedKnown: Web3SessionAccount[] = [];
199 for (let i = 0; i < localStorage.length; i++) {
200 const rawKey = localStorage.key(i);
201 if (rawKey && rawKey.startsWith(PREFIX + NEW_SEG)) {
202 try {
203 const rawVal = localStorage.getItem(rawKey);
204 if (rawVal) {
205 const account = JSON.parse(rawVal) as Web3SessionAccount;
206 loadedKnown.push(account);
207 }
208 } catch (e) {
209 console.warn('Failed to parse loaded session account:', e);
210 }
211 }
212 }
213 state.known = loadedKnown;
214 state.active = loadActive();
215 notify();
216 },
217 subscribe(listener: () => void) {
218 changeListeners.add(listener);
219 return () => changeListeners.delete(listener);
220 },
221 };
222
223 return store;
224}
225
226export const sessionStore: SessionStore = createSessionStore();
227
228/**
229 * Returns all known accounts that have completed on-chain registration
230 * (i.e. `hasAccount === true`). This is a plain (non-reactive) snapshot —
231 * wrap in `computed()` at the call site if reactivity is needed.
232 */
233export const allRegisteredAccounts = (): Web3SessionAccount[] =>
234 sessionStore.getState().known.filter((a) => a.hasAccount);
235