@snapshot-labs/snapshot.js 0.9.0 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapshot-labs/snapshot.js",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "repository": "snapshot-labs/snapshot.js",
5
5
  "license": "MIT",
6
6
  "main": "dist/snapshot.cjs.js",
@@ -4,7 +4,7 @@
4
4
  "sequencer": "https://seq.snapshot.org"
5
5
  },
6
6
  "testnet": {
7
- "hub": "https://testnet.snapshot.org",
7
+ "hub": "https://testnet.hub.snapshot.org",
8
8
  "sequencer": "https://testnet.seq.snapshot.org"
9
9
  },
10
10
  "local": {
@@ -0,0 +1,475 @@
1
+ import { describe, test, expect, vi, afterEach } from 'vitest';
2
+ import * as crossFetch from 'cross-fetch';
3
+ import { validate, getScores, getVp } from './utils';
4
+
5
+ vi.mock('cross-fetch', async () => {
6
+ const actual = await vi.importActual('cross-fetch');
7
+
8
+ return {
9
+ ...actual,
10
+ default: vi.fn()
11
+ };
12
+ });
13
+ const fetch = vi.mocked(crossFetch.default);
14
+
15
+ describe('utils', () => {
16
+ afterEach(() => {
17
+ vi.resetAllMocks();
18
+ });
19
+
20
+ describe('validate', () => {
21
+ const payload = {
22
+ validation: 'basic',
23
+ author: '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7',
24
+ space: 'fabien.eth',
25
+ network: '1',
26
+ snapshot: 7929876,
27
+ params: {
28
+ minScore: 0.9,
29
+ strategies: [
30
+ {
31
+ name: 'eth-balance',
32
+ params: {}
33
+ }
34
+ ]
35
+ }
36
+ };
37
+
38
+ function _validate({
39
+ validation,
40
+ author,
41
+ space,
42
+ network,
43
+ snapshot,
44
+ params,
45
+ options
46
+ }) {
47
+ return validate(
48
+ validation ?? payload.validation,
49
+ author ?? payload.author,
50
+ space ?? payload.space,
51
+ network ?? payload.network,
52
+ snapshot ?? payload.snapshot,
53
+ params ?? payload.params,
54
+ options ?? {}
55
+ );
56
+ }
57
+
58
+ describe('when passing invalid args', () => {
59
+ const cases = [
60
+ [
61
+ 'author is an invalid address',
62
+ { author: 'test-address' },
63
+ /invalid author/i
64
+ ],
65
+ ['network is not valid', { network: 'mainnet' }, /invalid network/i],
66
+ ['network is empty', { network: '' }, /invalid network/i],
67
+ [
68
+ 'snapshot is smaller than start block',
69
+ { snapshot: 1234 },
70
+ /snapshot \([0-9]+\) must be 'latest' or greater than network start block/i
71
+ ]
72
+ ];
73
+
74
+ test.each(cases)('throw an error when %s', async (title, args, err) => {
75
+ await expect(_validate(args)).rejects.toMatch(err);
76
+ });
77
+ });
78
+
79
+ describe('when passing valid args', () => {
80
+ test('send a JSON-RPC payload to score-api', async () => {
81
+ fetch.mockReturnValue({
82
+ json: () => new Promise((resolve) => resolve({ result: 'OK' }))
83
+ });
84
+
85
+ expect(_validate({})).resolves;
86
+ expect(fetch).toHaveBeenCalledWith(
87
+ 'https://score.snapshot.org',
88
+ expect.objectContaining({
89
+ body: JSON.stringify({
90
+ jsonrpc: '2.0',
91
+ method: 'validate',
92
+ params: payload
93
+ })
94
+ })
95
+ );
96
+ });
97
+
98
+ test('send a POST request with JSON content-type', async () => {
99
+ fetch.mockReturnValue({
100
+ json: () => new Promise((resolve) => resolve({ result: 'OK' }))
101
+ });
102
+
103
+ expect(_validate({})).resolves;
104
+ expect(fetch).toHaveBeenCalledWith(
105
+ 'https://score.snapshot.org',
106
+ expect.objectContaining({
107
+ method: 'POST',
108
+ headers: {
109
+ Accept: 'application/json',
110
+ 'Content-Type': 'application/json'
111
+ }
112
+ })
113
+ );
114
+ });
115
+
116
+ test('can customize the score-api url', () => {
117
+ fetch.mockReturnValue({
118
+ json: () => new Promise((resolve) => resolve({ result: 'OK' }))
119
+ });
120
+
121
+ expect(
122
+ _validate({ options: { url: 'https://snapshot.org/?apiKey=xxx' } })
123
+ ).resolves;
124
+ expect(fetch).toHaveBeenCalledWith(
125
+ 'https://snapshot.org/?apiKey=xxx',
126
+ expect.anything()
127
+ );
128
+ });
129
+
130
+ test('returns the JSON-RPC result property', () => {
131
+ const result = { result: 'OK' };
132
+ fetch.mockReturnValue({
133
+ json: () => new Promise((resolve) => resolve(result))
134
+ });
135
+
136
+ expect(_validate({})).resolves.toEqual('OK');
137
+ });
138
+ });
139
+
140
+ describe('when score-api is sending a JSON-RPC error', () => {
141
+ test('rejects with the JSON-RPC error object', () => {
142
+ const result = { error: { message: 'Oh no' } };
143
+ fetch.mockReturnValue({
144
+ json: () => new Promise((resolve) => resolve(result))
145
+ });
146
+
147
+ expect(_validate({})).rejects.toEqual(result.error);
148
+ });
149
+ });
150
+
151
+ describe('when the fetch request is failing with not network error', () => {
152
+ test('rejects with the error', () => {
153
+ const result = new Error('Oh no');
154
+ fetch.mockReturnValue({
155
+ json: () => {
156
+ throw result;
157
+ }
158
+ });
159
+
160
+ expect(_validate({})).rejects.toEqual(result);
161
+ });
162
+ });
163
+ });
164
+ describe('getScores', () => {
165
+ const payload = {
166
+ space: 'test.eth',
167
+ network: '1',
168
+ snapshot: 7929876,
169
+ strategies: [
170
+ {
171
+ name: 'erc20-balance-of',
172
+ params: {
173
+ symbol: 'TEST',
174
+ address: '0xc23F41519D7DFaDf9eed53c00f08C06CD5cDde54',
175
+ network: '1',
176
+ decimals: 18
177
+ },
178
+ network: '1'
179
+ }
180
+ ],
181
+ addresses: ['0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11']
182
+ };
183
+
184
+ function _getScores({
185
+ space,
186
+ strategies,
187
+ network,
188
+ addresses,
189
+ snapshot,
190
+ scoreApiUrl,
191
+ options
192
+ }) {
193
+ return getScores(
194
+ space ?? payload.space,
195
+ strategies ?? payload.strategies,
196
+ network ?? payload.network,
197
+ addresses ?? payload.addresses,
198
+ snapshot ?? payload.snapshot,
199
+ scoreApiUrl ?? 'https://score.snapshot.org',
200
+ options ?? {}
201
+ );
202
+ }
203
+
204
+ describe('when passing invalid args', () => {
205
+ const cases = [
206
+ [
207
+ 'addresses contains invalid address',
208
+ { addresses: ['test-address'] },
209
+ /invalid address/i
210
+ ],
211
+ [
212
+ 'addresses is not an array',
213
+ { addresses: 'test-address' },
214
+ /addresses should be an array/i
215
+ ],
216
+ [
217
+ 'addresses is an empty array',
218
+ { addresses: [] },
219
+ /addresses can not be empty/i
220
+ ],
221
+ ['network is not valid', { network: 'mainnet' }, /invalid network/i],
222
+ ['network is empty', { network: '' }, /invalid network/i],
223
+ [
224
+ 'snapshot is smaller than start block',
225
+ { snapshot: 1234 },
226
+ /snapshot \([0-9]+\) must be 'latest' or greater than network start block/i
227
+ ],
228
+ [
229
+ 'strategy contains invalid network',
230
+ { strategies: [{ name: '', network: 'test' }] },
231
+ /invalid network \(.*\) in strategy/i
232
+ ]
233
+ ];
234
+
235
+ test.each(cases)('throw an error when %s', async (title, args, err) => {
236
+ await expect(_getScores(args)).rejects.toMatch(err);
237
+ });
238
+ });
239
+
240
+ describe('when passing valid args', () => {
241
+ test('send a JSON-RPC payload to score-api', async () => {
242
+ fetch.mockReturnValue({
243
+ json: () => new Promise((resolve) => resolve({ result: 'OK' }))
244
+ });
245
+
246
+ expect(_getScores({})).resolves;
247
+ expect(fetch).toHaveBeenCalledWith(
248
+ 'https://score.snapshot.org/api/scores',
249
+ expect.objectContaining({
250
+ body: JSON.stringify({ params: payload })
251
+ })
252
+ );
253
+ });
254
+
255
+ test('send a POST request with JSON content-type', async () => {
256
+ fetch.mockReturnValue({
257
+ json: () => new Promise((resolve) => resolve({ result: 'OK' }))
258
+ });
259
+
260
+ expect(_getScores({})).resolves;
261
+ expect(fetch).toHaveBeenCalledWith(
262
+ 'https://score.snapshot.org/api/scores',
263
+ expect.objectContaining({
264
+ method: 'POST',
265
+ headers: {
266
+ Accept: 'application/json',
267
+ 'Content-Type': 'application/json'
268
+ }
269
+ })
270
+ );
271
+ });
272
+
273
+ test('can customize the score-api url', () => {
274
+ fetch.mockReturnValue({
275
+ json: () => new Promise((resolve) => resolve({ result: 'OK' }))
276
+ });
277
+
278
+ expect(_getScores({ scoreApiUrl: 'https://snapshot.org/?apiKey=xxx' }))
279
+ .resolves;
280
+ expect(fetch).toHaveBeenCalledWith(
281
+ 'https://snapshot.org/api/scores?apiKey=xxx',
282
+ expect.anything()
283
+ );
284
+ });
285
+
286
+ test('returns the JSON-RPC result scores property', () => {
287
+ const result = { scores: 'SCORES', other: 'Other' };
288
+ fetch.mockReturnValue({
289
+ json: () => new Promise((resolve) => resolve({ result }))
290
+ });
291
+
292
+ expect(_getScores({})).resolves.toEqual('SCORES');
293
+ });
294
+
295
+ test('returns the JSON-RPC all properties', () => {
296
+ const result = { scores: 'SCORES', other: 'Other' };
297
+ fetch.mockReturnValue({
298
+ json: () => new Promise((resolve) => resolve({ result }))
299
+ });
300
+
301
+ expect(
302
+ _getScores({ options: { returnValue: 'all' } })
303
+ ).resolves.toEqual(result);
304
+ });
305
+ });
306
+
307
+ describe('when score-api is sending a JSON-RPC error', () => {
308
+ test('rejects with the JSON-RPC error object', () => {
309
+ const result = { error: { message: 'Oh no' } };
310
+ fetch.mockReturnValue({
311
+ json: () => new Promise((resolve) => resolve(result))
312
+ });
313
+
314
+ expect(_getScores({})).rejects.toEqual(result.error);
315
+ });
316
+ });
317
+
318
+ describe('when the fetch request is failing with not network error', () => {
319
+ test('rejects with the error', () => {
320
+ const result = new Error('Oh no');
321
+ fetch.mockReturnValue({
322
+ json: () => {
323
+ throw result;
324
+ }
325
+ });
326
+
327
+ expect(_getScores({})).rejects.toEqual(result);
328
+ });
329
+ });
330
+ });
331
+ describe('getVp', () => {
332
+ const payload = {
333
+ address: '0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11',
334
+ network: '1',
335
+ strategies: [
336
+ {
337
+ name: 'erc20-balance-of',
338
+ params: {
339
+ symbol: 'TEST',
340
+ address: '0xc23F41519D7DFaDf9eed53c00f08C06CD5cDde54',
341
+ network: '1',
342
+ decimals: 18
343
+ },
344
+ network: '1'
345
+ }
346
+ ],
347
+ snapshot: 7929876,
348
+ space: 'test.eth',
349
+ delegation: false,
350
+ options: undefined
351
+ };
352
+
353
+ function _getVp({ voter, network, strategies, snapshot, options }) {
354
+ return getVp(
355
+ voter ?? payload.address,
356
+ network ?? payload.network,
357
+ strategies ?? payload.strategies,
358
+ snapshot ?? payload.snapshot,
359
+ 'test.eth' ?? payload.space,
360
+ false ?? payload.delegation,
361
+ options ?? payload.options
362
+ );
363
+ }
364
+
365
+ describe('when passing invalid args', () => {
366
+ const cases = [
367
+ [
368
+ 'voter is not a valid address',
369
+ { voter: 'test-address' },
370
+ /invalid voter address/i
371
+ ],
372
+ ['voter is empty', { voter: '' }, /invalid voter address/i],
373
+ ['network is not valid', { network: 'mainnet' }, /invalid network/i],
374
+ ['network is empty', { network: '' }, /invalid network/i],
375
+ [
376
+ 'snapshot is smaller than start block',
377
+ { snapshot: 1234 },
378
+ /snapshot \([0-9]+\) must be 'latest' or greater than network start block/i
379
+ ],
380
+ [
381
+ 'strategy contains invalid network',
382
+ { strategies: [{ name: '', network: 'test' }] },
383
+ /invalid network \(.*\) in strategy/i
384
+ ]
385
+ ];
386
+
387
+ test.each(cases)('throw an error when %s', async (title, args, err) => {
388
+ await expect(_getVp(args)).rejects.toMatch(err);
389
+ });
390
+ });
391
+
392
+ describe('when passing valid args', () => {
393
+ test('send a JSON-RPC payload to score-api', async () => {
394
+ fetch.mockReturnValue({
395
+ json: () => new Promise((resolve) => resolve({ result: 'OK' }))
396
+ });
397
+
398
+ expect(_getVp({})).resolves;
399
+ expect(fetch).toHaveBeenCalledWith(
400
+ 'https://score.snapshot.org',
401
+ expect.objectContaining({
402
+ body: JSON.stringify({
403
+ jsonrpc: '2.0',
404
+ method: 'get_vp',
405
+ params: payload
406
+ })
407
+ })
408
+ );
409
+ });
410
+
411
+ test('send a POST request with JSON content-type', async () => {
412
+ fetch.mockReturnValue({
413
+ json: () => new Promise((resolve) => resolve({ result: 'OK' }))
414
+ });
415
+
416
+ expect(_getVp({})).resolves;
417
+ expect(fetch).toHaveBeenCalledWith(
418
+ 'https://score.snapshot.org',
419
+ expect.objectContaining({
420
+ method: 'POST',
421
+ headers: {
422
+ Accept: 'application/json',
423
+ 'Content-Type': 'application/json'
424
+ }
425
+ })
426
+ );
427
+ });
428
+
429
+ test('can customize the score-api url', () => {
430
+ fetch.mockReturnValue({
431
+ json: () => new Promise((resolve) => resolve({ result: 'OK' }))
432
+ });
433
+
434
+ expect(_getVp({ options: { url: 'https://snapshot.org' } })).resolves;
435
+ expect(fetch).toHaveBeenCalledWith(
436
+ 'https://snapshot.org',
437
+ expect.anything()
438
+ );
439
+ });
440
+
441
+ test('returns the JSON-RPC result property', () => {
442
+ const result = { data: 'OK' };
443
+ fetch.mockReturnValue({
444
+ json: () => new Promise((resolve) => resolve({ result }))
445
+ });
446
+
447
+ expect(_getVp({})).resolves.toEqual(result);
448
+ });
449
+ });
450
+
451
+ describe('when score-api is sending a JSON-RPC error', () => {
452
+ test('rejects with the JSON-RPC error object', () => {
453
+ const result = { error: { message: 'Oh no' } };
454
+ fetch.mockReturnValue({
455
+ json: () => new Promise((resolve) => resolve(result))
456
+ });
457
+
458
+ expect(_getVp({})).rejects.toEqual(result.error);
459
+ });
460
+ });
461
+
462
+ describe('when the fetch request is failing with not network error', () => {
463
+ test('rejects with the error', () => {
464
+ const result = new Error('Oh no');
465
+ fetch.mockReturnValue({
466
+ json: () => {
467
+ throw result;
468
+ }
469
+ });
470
+
471
+ expect(_getVp({})).rejects.toEqual(result);
472
+ });
473
+ });
474
+ });
475
+ });
package/src/utils.ts CHANGED
@@ -31,6 +31,7 @@ export const SNAPSHOT_SUBGRAPH_URL = delegationSubgraphs;
31
31
  const ENS_RESOLVER_ABI = [
32
32
  'function text(bytes32 node, string calldata key) external view returns (string memory)'
33
33
  ];
34
+ const EMPTY_ADDRESS = '0x0000000000000000000000000000000000000000';
34
35
 
35
36
  const scoreApiHeaders = {
36
37
  Accept: 'application/json',
@@ -249,6 +250,33 @@ export async function getScores(
249
250
  scoreApiUrl = 'https://score.snapshot.org',
250
251
  options: any = {}
251
252
  ) {
253
+ if (!Array.isArray(addresses)) {
254
+ return inputError('addresses should be an array of addresses');
255
+ }
256
+ if (addresses.length === 0) {
257
+ return inputError('addresses can not be empty');
258
+ }
259
+ const invalidAddress = addresses.find((address) => !isValidAddress(address));
260
+ if (invalidAddress) {
261
+ return inputError(`Invalid address: ${invalidAddress}`);
262
+ }
263
+ if (!isValidNetwork(network)) {
264
+ return inputError(`Invalid network: ${network}`);
265
+ }
266
+ const invalidStrategy = strategies.find(
267
+ (strategy) => strategy.network && !isValidNetwork(strategy.network)
268
+ );
269
+ if (invalidStrategy) {
270
+ return inputError(
271
+ `Invalid network (${invalidStrategy.network}) in strategy ${invalidStrategy.name}`
272
+ );
273
+ }
274
+ if (!isValidSnapshot(snapshot, network)) {
275
+ return inputError(
276
+ `Snapshot (${snapshot}) must be 'latest' or greater than network start block (${networks[network].start})`
277
+ );
278
+ }
279
+
252
280
  const url = new URL(scoreApiUrl);
253
281
  url.pathname = '/api/scores';
254
282
  scoreApiUrl = url.toString();
@@ -294,6 +322,27 @@ export async function getVp(
294
322
  ) {
295
323
  if (!options) options = {};
296
324
  if (!options.url) options.url = 'https://score.snapshot.org';
325
+ if (!isValidAddress(address)) {
326
+ return inputError(`Invalid voter address: ${address}`);
327
+ }
328
+ if (!isValidNetwork(network)) {
329
+ return inputError(`Invalid network: ${network}`);
330
+ }
331
+ const invalidStrategy = strategies.find(
332
+ (strategy) => strategy.network && !isValidNetwork(strategy.network)
333
+ );
334
+
335
+ if (invalidStrategy) {
336
+ return inputError(
337
+ `Invalid network (${invalidStrategy.network}) in strategy ${invalidStrategy.name}`
338
+ );
339
+ }
340
+ if (!isValidSnapshot(snapshot, network)) {
341
+ return inputError(
342
+ `Snapshot (${snapshot}) must be 'latest' or greater than network start block (${networks[network].start})`
343
+ );
344
+ }
345
+
297
346
  const init = {
298
347
  method: 'POST',
299
348
  headers: scoreApiHeaders,
@@ -333,6 +382,19 @@ export async function validate(
333
382
  params: any,
334
383
  options: any
335
384
  ) {
385
+ if (!isValidAddress(author)) {
386
+ return inputError(`Invalid author: ${author}`);
387
+ }
388
+
389
+ if (!isValidNetwork(network)) {
390
+ return inputError(`Invalid network: ${network}`);
391
+ }
392
+ if (!isValidSnapshot(snapshot, network)) {
393
+ return inputError(
394
+ `Snapshot (${snapshot}) must be 'latest' or greater than network start block (${networks[network].start})`
395
+ );
396
+ }
397
+
336
398
  if (!options) options = {};
337
399
  if (!options.url) options.url = 'https://score.snapshot.org';
338
400
  const init = {
@@ -530,6 +592,25 @@ export function getNumberWithOrdinal(n) {
530
592
  return n + (s[(v - 20) % 10] || s[v] || s[0]);
531
593
  }
532
594
 
595
+ function isValidNetwork(network: string) {
596
+ return !!networks[network];
597
+ }
598
+
599
+ function isValidAddress(address: string) {
600
+ return isAddress(address) && address !== EMPTY_ADDRESS;
601
+ }
602
+
603
+ function isValidSnapshot(snapshot: number | string, network: string) {
604
+ return (
605
+ snapshot === 'latest' ||
606
+ (typeof snapshot === 'number' && snapshot >= networks[network].start)
607
+ );
608
+ }
609
+
610
+ function inputError(message: string) {
611
+ return Promise.reject(new Error(message));
612
+ }
613
+
533
614
  export default {
534
615
  call,
535
616
  multicall,