@monygroupcorp/micro-web3 0.1.3 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +6 -0
- package/dist/{micro-web3.cjs.js → micro-web3.cjs} +3 -3
- package/dist/micro-web3.cjs.map +1 -0
- package/dist/micro-web3.esm.js +2 -2
- package/dist/micro-web3.esm.js.map +1 -1
- package/dist/micro-web3.umd.js +2 -2
- package/dist/micro-web3.umd.js.map +1 -1
- package/docs/plans/2026-01-22-event-indexer.md +3642 -0
- package/package.json +2 -2
- package/rollup.config.cjs +1 -1
- package/src/components/FloatingWalletButton/FloatingWalletButton.js +53 -21
- package/src/components/SettingsModal/SettingsModal.js +371 -0
- package/src/components/SyncProgressBar/SyncProgressBar.js +238 -0
- package/src/index.js +15 -1
- package/src/indexer/EntityResolver.js +218 -0
- package/src/indexer/Patterns.js +277 -0
- package/src/indexer/QueryEngine.js +149 -0
- package/src/indexer/SyncEngine.js +494 -0
- package/src/indexer/index.js +13 -0
- package/src/services/BlockchainService.js +30 -0
- package/src/services/ContractCache.js +20 -2
- package/src/services/EventIndexer.js +399 -0
- package/src/storage/IndexedDBAdapter.js +423 -0
- package/src/storage/IndexerSettings.js +88 -0
- package/src/storage/MemoryAdapter.js +194 -0
- package/src/storage/StorageAdapter.js +129 -0
- package/src/storage/index.js +41 -0
- package/dist/micro-web3.cjs.js.map +0 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// src/indexer/Patterns.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pre-built patterns for common dApp needs.
|
|
5
|
+
* Zero-config solutions for activity feeds, leaderboards, etc.
|
|
6
|
+
*/
|
|
7
|
+
class Patterns {
|
|
8
|
+
constructor(queryEngine, entityResolver) {
|
|
9
|
+
this.queryEngine = queryEngine;
|
|
10
|
+
this.entityResolver = entityResolver;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* User activity feed.
|
|
15
|
+
* @param {string} address - User address
|
|
16
|
+
* @param {Object} options - Options
|
|
17
|
+
* @param {string[]} options.events - Event types to include
|
|
18
|
+
* @param {string[]} options.userFields - Fields to match address against (auto-detected if omitted)
|
|
19
|
+
* @param {number} options.limit - Max results
|
|
20
|
+
* @param {number} options.offset - Skip N results
|
|
21
|
+
* @returns {Promise<ActivityItem[]>}
|
|
22
|
+
*/
|
|
23
|
+
async userActivity(address, options = {}) {
|
|
24
|
+
const { events = [], userFields, limit = 50, offset = 0 } = options;
|
|
25
|
+
const normalizedAddress = address.toLowerCase();
|
|
26
|
+
|
|
27
|
+
// Collect activity from all event types
|
|
28
|
+
const allActivity = [];
|
|
29
|
+
|
|
30
|
+
for (const eventType of events) {
|
|
31
|
+
const result = await this.queryEngine.query(eventType, {
|
|
32
|
+
limit: 1000 // Get more to filter
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Auto-detect user fields if not specified
|
|
36
|
+
const fields = userFields || this._detectAddressFields(result.events[0]);
|
|
37
|
+
|
|
38
|
+
// Filter events involving this user
|
|
39
|
+
for (const event of result.events) {
|
|
40
|
+
const isUserEvent = fields.some(field => {
|
|
41
|
+
const value = event.indexed[field] || event.data[field];
|
|
42
|
+
return typeof value === 'string' && value.toLowerCase() === normalizedAddress;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (isUserEvent) {
|
|
46
|
+
allActivity.push({
|
|
47
|
+
type: event.type,
|
|
48
|
+
timestamp: event.timestamp || event.blockNumber, // Fall back to block number
|
|
49
|
+
blockNumber: event.blockNumber,
|
|
50
|
+
transactionHash: event.transactionHash,
|
|
51
|
+
data: event.data
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Sort by timestamp/block descending
|
|
58
|
+
allActivity.sort((a, b) => (b.timestamp || b.blockNumber) - (a.timestamp || a.blockNumber));
|
|
59
|
+
|
|
60
|
+
// Paginate
|
|
61
|
+
return allActivity.slice(offset, offset + limit);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Find refundable items (cancelled parent + unclaimed).
|
|
66
|
+
* @param {Object} options - Options
|
|
67
|
+
* @param {string} options.itemEvent - Event creating the item
|
|
68
|
+
* @param {string} options.itemKey - Unique key field
|
|
69
|
+
* @param {string} options.parentKey - Field linking to parent
|
|
70
|
+
* @param {string} options.cancelEvent - Event that cancels parent
|
|
71
|
+
* @param {string} options.claimEvent - Event that claims item
|
|
72
|
+
* @param {string} options.userField - Field containing user address
|
|
73
|
+
* @param {string} options.user - User address to filter
|
|
74
|
+
* @returns {Promise<IndexedEvent[]>}
|
|
75
|
+
*/
|
|
76
|
+
async refundable(options) {
|
|
77
|
+
const {
|
|
78
|
+
itemEvent,
|
|
79
|
+
itemKey,
|
|
80
|
+
parentKey,
|
|
81
|
+
cancelEvent,
|
|
82
|
+
claimEvent,
|
|
83
|
+
userField,
|
|
84
|
+
user
|
|
85
|
+
} = options;
|
|
86
|
+
|
|
87
|
+
const normalizedUser = user.toLowerCase();
|
|
88
|
+
|
|
89
|
+
// Get all items for user
|
|
90
|
+
const itemsResult = await this.queryEngine.query(itemEvent, { limit: 10000 });
|
|
91
|
+
const userItems = itemsResult.events.filter(e => {
|
|
92
|
+
const userValue = e.indexed[userField] || e.data[userField];
|
|
93
|
+
return typeof userValue === 'string' && userValue.toLowerCase() === normalizedUser;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Get cancelled parents
|
|
97
|
+
const cancelResult = await this.queryEngine.query(cancelEvent, { limit: 10000 });
|
|
98
|
+
const cancelledParents = new Set(
|
|
99
|
+
cancelResult.events.map(e => e.indexed[parentKey] || e.data[parentKey])
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Get claimed items
|
|
103
|
+
const claimResult = await this.queryEngine.query(claimEvent, { limit: 10000 });
|
|
104
|
+
const claimedItems = new Set(
|
|
105
|
+
claimResult.events.map(e => e.indexed[itemKey] || e.data[itemKey])
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Filter: parent cancelled AND item not claimed
|
|
109
|
+
return userItems.filter(item => {
|
|
110
|
+
const itemKeyValue = item.indexed[itemKey] || item.data[itemKey];
|
|
111
|
+
const parentKeyValue = item.indexed[parentKey] || item.data[parentKey];
|
|
112
|
+
|
|
113
|
+
return cancelledParents.has(parentKeyValue) && !claimedItems.has(itemKeyValue);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Leaderboard aggregation.
|
|
119
|
+
* @param {Object} options - Options
|
|
120
|
+
* @param {string} options.event - Event to aggregate
|
|
121
|
+
* @param {string} options.groupBy - Field to group by
|
|
122
|
+
* @param {string} options.aggregate - 'count' or 'sum'
|
|
123
|
+
* @param {string} options.sumField - Field to sum (if aggregate: 'sum')
|
|
124
|
+
* @param {number} options.limit - Max results
|
|
125
|
+
* @param {Object} options.where - Filter conditions
|
|
126
|
+
* @returns {Promise<LeaderboardEntry[]>}
|
|
127
|
+
*/
|
|
128
|
+
async leaderboard(options) {
|
|
129
|
+
const { event, groupBy, aggregate, sumField, limit = 10, where } = options;
|
|
130
|
+
|
|
131
|
+
const result = await this.queryEngine.query(event, {
|
|
132
|
+
where,
|
|
133
|
+
limit: 10000
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Group by field
|
|
137
|
+
const groups = new Map();
|
|
138
|
+
for (const e of result.events) {
|
|
139
|
+
const key = e.indexed[groupBy] || e.data[groupBy];
|
|
140
|
+
if (key === undefined) continue;
|
|
141
|
+
|
|
142
|
+
const normalizedKey = typeof key === 'string' ? key.toLowerCase() : key;
|
|
143
|
+
|
|
144
|
+
if (!groups.has(normalizedKey)) {
|
|
145
|
+
groups.set(normalizedKey, { key, events: [] });
|
|
146
|
+
}
|
|
147
|
+
groups.get(normalizedKey).events.push(e);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Calculate aggregates
|
|
151
|
+
const entries = [];
|
|
152
|
+
for (const [normalizedKey, group] of groups) {
|
|
153
|
+
let value;
|
|
154
|
+
if (aggregate === 'count') {
|
|
155
|
+
value = group.events.length;
|
|
156
|
+
} else if (aggregate === 'sum') {
|
|
157
|
+
value = group.events.reduce((sum, e) => {
|
|
158
|
+
const fieldValue = e.indexed[sumField] || e.data[sumField];
|
|
159
|
+
const num = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
|
|
160
|
+
return sum + (isNaN(num) ? 0 : num);
|
|
161
|
+
}, 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
entries.push({ key: group.key, value });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Sort by value descending
|
|
168
|
+
entries.sort((a, b) => b.value - a.value);
|
|
169
|
+
|
|
170
|
+
// Add ranks and limit
|
|
171
|
+
return entries.slice(0, limit).map((entry, index) => ({
|
|
172
|
+
...entry,
|
|
173
|
+
rank: index + 1
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Time-series aggregation.
|
|
179
|
+
* @param {Object} options - Options
|
|
180
|
+
* @param {string} options.event - Event to aggregate
|
|
181
|
+
* @param {string} options.interval - 'hour' | 'day' | 'week'
|
|
182
|
+
* @param {string} options.aggregate - 'count' or 'sum'
|
|
183
|
+
* @param {string} options.sumField - Field to sum (if aggregate: 'sum')
|
|
184
|
+
* @param {Object} options.where - Filter conditions
|
|
185
|
+
* @param {number} options.periods - Number of periods (default: 30)
|
|
186
|
+
* @returns {Promise<TimeSeriesPoint[]>}
|
|
187
|
+
*/
|
|
188
|
+
async timeSeries(options) {
|
|
189
|
+
const { event, interval, aggregate, sumField, where, periods = 30 } = options;
|
|
190
|
+
|
|
191
|
+
const result = await this.queryEngine.query(event, {
|
|
192
|
+
where,
|
|
193
|
+
limit: 100000
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Calculate interval in milliseconds
|
|
197
|
+
const intervalMs = {
|
|
198
|
+
hour: 60 * 60 * 1000,
|
|
199
|
+
day: 24 * 60 * 60 * 1000,
|
|
200
|
+
week: 7 * 24 * 60 * 60 * 1000
|
|
201
|
+
}[interval];
|
|
202
|
+
|
|
203
|
+
if (!intervalMs) {
|
|
204
|
+
throw new Error(`Invalid interval: ${interval}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Group events by period
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
const periodGroups = new Map();
|
|
210
|
+
|
|
211
|
+
// Initialize periods
|
|
212
|
+
for (let i = 0; i < periods; i++) {
|
|
213
|
+
const periodStart = now - (i + 1) * intervalMs;
|
|
214
|
+
const periodKey = new Date(periodStart).toISOString();
|
|
215
|
+
periodGroups.set(periodKey, []);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Assign events to periods
|
|
219
|
+
for (const e of result.events) {
|
|
220
|
+
const eventTime = e.timestamp ? e.timestamp * 1000 : now; // Assume timestamp is in seconds
|
|
221
|
+
const periodsAgo = Math.floor((now - eventTime) / intervalMs);
|
|
222
|
+
|
|
223
|
+
if (periodsAgo >= 0 && periodsAgo < periods) {
|
|
224
|
+
const periodStart = now - (periodsAgo + 1) * intervalMs;
|
|
225
|
+
const periodKey = new Date(periodStart).toISOString();
|
|
226
|
+
|
|
227
|
+
if (periodGroups.has(periodKey)) {
|
|
228
|
+
periodGroups.get(periodKey).push(e);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Calculate aggregates
|
|
234
|
+
const points = [];
|
|
235
|
+
for (const [period, events] of periodGroups) {
|
|
236
|
+
let value;
|
|
237
|
+
if (aggregate === 'count') {
|
|
238
|
+
value = events.length;
|
|
239
|
+
} else if (aggregate === 'sum') {
|
|
240
|
+
value = events.reduce((sum, e) => {
|
|
241
|
+
const fieldValue = e.indexed[sumField] || e.data[sumField];
|
|
242
|
+
const num = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
|
|
243
|
+
return sum + (isNaN(num) ? 0 : num);
|
|
244
|
+
}, 0);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
points.push({ period, value });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Sort by period ascending (oldest first)
|
|
251
|
+
points.sort((a, b) => new Date(a.period) - new Date(b.period));
|
|
252
|
+
|
|
253
|
+
return points;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Detect address fields in an event.
|
|
258
|
+
* @param {IndexedEvent} event - Sample event
|
|
259
|
+
* @returns {string[]} Field names that look like addresses
|
|
260
|
+
*/
|
|
261
|
+
_detectAddressFields(event) {
|
|
262
|
+
if (!event) return [];
|
|
263
|
+
|
|
264
|
+
const fields = [];
|
|
265
|
+
const allFields = { ...event.indexed, ...event.data };
|
|
266
|
+
|
|
267
|
+
for (const [key, value] of Object.entries(allFields)) {
|
|
268
|
+
if (typeof value === 'string' && value.match(/^0x[a-fA-F0-9]{40}$/)) {
|
|
269
|
+
fields.push(key);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return fields;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default Patterns;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// src/indexer/QueryEngine.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Query engine for EventIndexer.
|
|
5
|
+
* Provides the Events API for direct event access.
|
|
6
|
+
*/
|
|
7
|
+
class QueryEngine {
|
|
8
|
+
constructor(storage, eventBus) {
|
|
9
|
+
this.storage = storage;
|
|
10
|
+
this.eventBus = eventBus;
|
|
11
|
+
this.subscriptions = new Map(); // subscriptionId -> { eventTypes, callback, where }
|
|
12
|
+
this.nextSubscriptionId = 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Query events with filters.
|
|
17
|
+
* @param {string} eventName - Event type to query
|
|
18
|
+
* @param {QueryOptions} options - Query options
|
|
19
|
+
* @returns {Promise<QueryResult>}
|
|
20
|
+
*/
|
|
21
|
+
async query(eventName, options = {}) {
|
|
22
|
+
return this.storage.queryEvents(eventName, {
|
|
23
|
+
where: options.where,
|
|
24
|
+
orderBy: options.orderBy || 'blockNumber',
|
|
25
|
+
order: options.order || 'desc',
|
|
26
|
+
limit: options.limit || 100,
|
|
27
|
+
offset: options.offset || 0
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get single event by ID.
|
|
33
|
+
* @param {string} eventName - Event type
|
|
34
|
+
* @param {string} id - Event ID (txHash-logIndex)
|
|
35
|
+
* @returns {Promise<IndexedEvent|null>}
|
|
36
|
+
*/
|
|
37
|
+
async get(eventName, id) {
|
|
38
|
+
return this.storage.getEvent(eventName, id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Subscribe to new events.
|
|
43
|
+
* @param {string|string[]} eventNames - Event type(s) to subscribe to
|
|
44
|
+
* @param {Function} callback - Called with new events
|
|
45
|
+
* @returns {Function} Unsubscribe function
|
|
46
|
+
*/
|
|
47
|
+
subscribe(eventNames, callback) {
|
|
48
|
+
const types = Array.isArray(eventNames) ? eventNames : [eventNames];
|
|
49
|
+
const subscriptionId = this.nextSubscriptionId++;
|
|
50
|
+
|
|
51
|
+
this.subscriptions.set(subscriptionId, {
|
|
52
|
+
eventTypes: new Set(types),
|
|
53
|
+
callback
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Return unsubscribe function
|
|
57
|
+
return () => {
|
|
58
|
+
this.subscriptions.delete(subscriptionId);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Count events matching filter.
|
|
64
|
+
* @param {string} eventName - Event type
|
|
65
|
+
* @param {Object} where - Filter conditions
|
|
66
|
+
* @returns {Promise<number>}
|
|
67
|
+
*/
|
|
68
|
+
async count(eventName, where) {
|
|
69
|
+
return this.storage.countEvents(eventName, where);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Called by SyncEngine when new events are indexed.
|
|
74
|
+
* Notifies subscribers.
|
|
75
|
+
* @param {string} eventType - Event type
|
|
76
|
+
* @param {IndexedEvent[]} events - New events
|
|
77
|
+
*/
|
|
78
|
+
notifyNewEvents(eventType, events) {
|
|
79
|
+
for (const subscription of this.subscriptions.values()) {
|
|
80
|
+
if (subscription.eventTypes.has(eventType) || subscription.eventTypes.has('*')) {
|
|
81
|
+
try {
|
|
82
|
+
subscription.callback(events);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('[QueryEngine] Subscription callback error:', error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get all events for entity resolution (used by EntityResolver).
|
|
92
|
+
* Returns a lightweight checker interface.
|
|
93
|
+
* @param {Object} relatedWhere - Filter for related events
|
|
94
|
+
* @returns {Promise<EventChecker>}
|
|
95
|
+
*/
|
|
96
|
+
async getEventChecker(relatedWhere = {}) {
|
|
97
|
+
// Pre-fetch all potentially related events
|
|
98
|
+
const eventCache = new Map();
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
has: (eventName, where = {}) => {
|
|
102
|
+
const events = this._getCachedEvents(eventCache, eventName);
|
|
103
|
+
return events.some(e => this._matchesWhere(e, { ...relatedWhere, ...where }));
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
get: (eventName, where = {}) => {
|
|
107
|
+
const events = this._getCachedEvents(eventCache, eventName);
|
|
108
|
+
return events.filter(e => this._matchesWhere(e, { ...relatedWhere, ...where }));
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
count: (eventName, where = {}) => {
|
|
112
|
+
const events = this._getCachedEvents(eventCache, eventName);
|
|
113
|
+
return events.filter(e => this._matchesWhere(e, { ...relatedWhere, ...where })).length;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
// Async method to prefetch events for a set of keys
|
|
117
|
+
prefetch: async (eventNames) => {
|
|
118
|
+
for (const eventName of eventNames) {
|
|
119
|
+
if (!eventCache.has(eventName)) {
|
|
120
|
+
const result = await this.storage.queryEvents(eventName, { limit: 10000 });
|
|
121
|
+
eventCache.set(eventName, result.events);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_getCachedEvents(cache, eventName) {
|
|
129
|
+
return cache.get(eventName) || [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_matchesWhere(event, where) {
|
|
133
|
+
for (const [key, value] of Object.entries(where)) {
|
|
134
|
+
const eventValue = this._getNestedValue(event, key);
|
|
135
|
+
const normalizedEvent = typeof eventValue === 'string' ? eventValue.toLowerCase() : eventValue;
|
|
136
|
+
const normalizedValue = typeof value === 'string' ? value.toLowerCase() : value;
|
|
137
|
+
if (normalizedEvent !== normalizedValue) return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_getNestedValue(obj, path) {
|
|
143
|
+
if (obj.indexed?.[path] !== undefined) return obj.indexed[path];
|
|
144
|
+
if (obj.data?.[path] !== undefined) return obj.data[path];
|
|
145
|
+
return obj[path];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default QueryEngine;
|