Skip to content

feat: validate space strategies #513

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 58 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
fac6311
feat: validate space strategies
wa0x6e Mar 9, 2025
09cde68
fix: duplicate request preventor for EIP-1271 requests (#515)
ChaituVR Mar 11, 2025
611e081
chore: increase follow limit for Turbo users (#516)
bonustrack Mar 11, 2025
4d8f1ad
fix: allow copeland on production (#517)
ChaituVR Mar 14, 2025
ae2a23f
feat: use unified API to check if SX space exists (#520)
Sekhmet Mar 24, 2025
badd12a
feat: add mnt, ape, curtis to networks whitelist (#521)
Sekhmet Mar 25, 2025
fd8ab6f
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.54 to 0.12.55 …
dependabot[bot] Mar 26, 2025
f30be53
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.55 to 0.12.56 …
dependabot[bot] Mar 26, 2025
3104246
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.56 to 0.12.57 …
dependabot[bot] Mar 28, 2025
c8121e9
feat: sync spaces custom domain with walletconnect allowed origins (#…
wa0x6e Mar 31, 2025
a1dee26
fix: prevent non-premium networks (#522)
ChaituVR Apr 2, 2025
a21aaca
fix: allow turbo spaces to create proposals with non-premuim networks…
ChaituVR Apr 3, 2025
da6af19
feat: use correct network when fetching shib space controller (#527)
wa0x6e Apr 14, 2025
ec30ecd
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.57 to 0.12.58 …
dependabot[bot] Apr 14, 2025
b6d764d
feat: add turbo tracking (#523)
pscott Apr 15, 2025
cf3719a
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.58 to 0.12.59 …
dependabot[bot] Apr 16, 2025
589cf8b
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.59 to 0.12.60 …
dependabot[bot] Apr 22, 2025
61578e2
chore: use pineapple gateway to get JSON on poke (#535)
ChaituVR Apr 24, 2025
dcde46a
fix: skip indexing from outdated schnaps api (#537)
wa0x6e Apr 25, 2025
5adf223
fix: assign fallback value (#538)
wa0x6e Apr 28, 2025
815b897
fix: infer turbo status from turbo expiration date (#539)
wa0x6e May 1, 2025
fa1b786
chore: mark eden-online-override strategy as overriding
bonustrack May 20, 2025
03b1ad7
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.60 to 0.12.62 …
dependabot[bot] May 21, 2025
35375df
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.62 to 0.12.63 …
dependabot[bot] Jun 4, 2025
1fc2775
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.63 to 0.12.64 …
dependabot[bot] Jun 10, 2025
94b079f
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.64 to 0.12.65 …
dependabot[bot] Jun 13, 2025
c81c533
chore(deps): bump @snapshot-labs/snapshot.js from 0.12.65 to 0.13.0 (…
dependabot[bot] Jun 18, 2025
155fe15
feat: add delay before finalizing proposal scores (#541)
wa0x6e Jun 22, 2025
f4d464f
refactor: remove unused requestEonKey function from shutter helper (#…
bigint Jun 29, 2025
b2a3b0c
feat: allow vote and proposal handling from starknet aliases (#542)
wa0x6e Jun 30, 2025
4321bc9
chore(deps): bump @snapshot-labs/snapshot.js from 0.13.0 to 0.14.1 (#…
dependabot[bot] Jun 30, 2025
190484e
fix: remove duplicate promisification of Pool and Connection in mysql…
bigint Jun 30, 2025
71535a7
feat: allow starknet aliases to flag proposals (#550)
wa0x6e Jun 30, 2025
b7ea17c
fix: add split-delegation to list of override strategies (#547)
wa0x6e Jun 30, 2025
7b25718
refactor: simplify retry logic in fetchWithRetry method (#553)
bigint Jul 1, 2025
2df80e6
chore(deps): bump @snapshot-labs/snapshot.js from 0.14.1 to 0.14.2 (#…
dependabot[bot] Jul 1, 2025
181ca38
fix: update `network` column to support starknet chain id (#558)
wa0x6e Jul 8, 2025
e8c87a2
chore(deps): bump @snapshot-labs/snapshot.js from 0.14.2 to 0.14.4 (#…
dependabot[bot] Jul 15, 2025
4dbcfeb
feat: prevent spaces from setting themselves as parent or child (#560)
ChaituVR Jul 16, 2025
1d1a24d
chore(deps): bump @snapshot-labs/snapshot.js from 0.14.4 to 0.14.5 (#…
dependabot[bot] Jul 17, 2025
c36e9d8
feat: add support for sonic domain name (#562)
wa0x6e Jul 21, 2025
271152c
chore: add sonic-staked-balance to override strategy (#568)
ChaituVR Aug 5, 2025
89b2188
chore: remove matic from sx (#573)
wa0x6e Aug 7, 2025
a45151a
chore(deps): bump @snapshot-labs/snapshot.js from 0.14.5 to 0.14.6 (#…
dependabot[bot] Aug 8, 2025
5e73cc2
chore(deps): bump @snapshot-labs/pineapple from 1.1.0 to 1.2.0 (#564)
dependabot[bot] Aug 8, 2025
ca67b26
chore(deps): bump @snapshot-labs/snapshot.js from 0.14.6 to 0.14.7 (#…
dependabot[bot] Aug 9, 2025
6195859
feat: validate space strategies
wa0x6e Mar 9, 2025
7eee9a6
chore: lint fix
wa0x6e Aug 12, 2025
ff9cae4
feat: validate space strategies
wa0x6e Mar 9, 2025
b17284f
chore: lint fix
wa0x6e Aug 12, 2025
ec91679
Merge branch 'master' into feat-validate-space-strategies
wa0x6e Aug 12, 2025
49162ef
refactor: better strategies handling
wa0x6e Aug 14, 2025
18c9919
fix: stop infinite loop on app shutdown
wa0x6e Aug 14, 2025
2eb90a5
refactor: remove magic number
wa0x6e Aug 14, 2025
e12e40c
fix: update error message to reflect disabled status
wa0x6e Aug 14, 2025
a8b3668
refactor: remove useless try/catch
wa0x6e Aug 15, 2025
c214894
test: reset mock after each test
wa0x6e Aug 15, 2025
6c57cdf
feat: only allow override strategies on pro spaces (#581)
wa0x6e Aug 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/helpers/strategies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { URL } from 'url';
import { capture } from '@snapshot-labs/snapshot-sentry';
import snapshot from '@snapshot-labs/snapshot.js';
import log from './log';

type Strategy = {
id: string;
override: boolean;
disabled: boolean;
};

const RUN_INTERVAL = 60e3;
const MAX_CONSECUTIVE_FAILS = 3;
const URI = new URL(
'/api/strategies',
process.env.SCORE_API_URL ?? 'https://score.snapshot.org'
).toString();

let consecutiveFailsCount = 0;
let shouldStop = false;
export let strategies: Record<Strategy['id'], Strategy> = {};

async function loadStrategies() {
const res = await snapshot.utils.getJSON(URI);

if (res.hasOwnProperty('error')) {
capture(new Error('Failed to load strategies'), {
contexts: { input: { uri: URI }, res }
});
return true;
}

const strat = Object.values(res).map((strategy: any) => {
strategy.id = strategy.key;
strategy.override = strategy.dependOnOtherAddress || false;
strategy.disabled = strategy.disabled || false;
return strategy;
});

strategies = Object.fromEntries(strat.map(strategy => [strategy.id, strategy]));
}

export async function run() {
while (!shouldStop) {
try {
log.info('[strategies] Start strategies refresh');
await loadStrategies();
consecutiveFailsCount = 0;
log.info('[strategies] End strategies refresh');
} catch (e: any) {
consecutiveFailsCount++;

if (consecutiveFailsCount >= MAX_CONSECUTIVE_FAILS) {
capture(e);
}
log.error(`[strategies] failed to load ${JSON.stringify(e)}`);
}

// if stop() has been called after sleep started,
// the loop will exit only after the sleep has completed
await snapshot.utils.sleep(RUN_INTERVAL);
}
}

export function stop() {
log.info('[strategies] Stopping strategies refresh');
shouldStop = true;
}
80 changes: 80 additions & 0 deletions src/helpers/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import snapshot from '@snapshot-labs/snapshot.js';
import log from './log';
import { strategies } from './strategies';

const DEFAULT_SNAPSHOT_ENV: string = 'testnet';

export async function validateSpaceSettings(
originalSpace: any,
snapshotEnv = DEFAULT_SNAPSHOT_ENV
): Promise<void> {
const spaceType = originalSpace.turbo ? 'turbo' : 'default';
const space = snapshot.utils.clone(originalSpace);

if (space?.deleted) return Promise.reject('space deleted, contact admin');

delete space.deleted;
delete space.flagged;
delete space.verified;
delete space.turbo;
delete space.hibernated;
delete space.id;

if (space.parent && space.parent === originalSpace.id) {
return Promise.reject('space cannot be its own parent');
}

if (
space.children &&
Array.isArray(space.children) &&
space.children.includes(originalSpace.id)
) {
return Promise.reject('space cannot be its own child');
}

const schemaIsValid: any = snapshot.utils.validateSchema(snapshot.schemas.space, space, {
spaceType,
snapshotEnv
});

if (schemaIsValid !== true) {
log.warn('[writer] Wrong space format', schemaIsValid);
const firstErrorObject: any = Object.values(schemaIsValid)[0];
if (firstErrorObject.message === 'network not allowed') {
return Promise.reject(firstErrorObject.message);
}
return Promise.reject('wrong space format');
}

const strategiesIds: string[] = space.strategies.map((strategy: any) => strategy.name);
if (snapshotEnv !== 'testnet') {
const hasTicket = strategiesIds.includes('ticket');
const hasVotingValidation =
space.voteValidation?.name && !['any'].includes(space.voteValidation.name);

if (hasTicket && !hasVotingValidation) {
return Promise.reject('space with ticket requires voting validation');
}

const hasProposalValidation =
(space.validation?.name && space.validation.name !== 'any') ||
space.filters?.minScore ||
space.filters?.onlyMembers;

if (!hasProposalValidation) {
return Promise.reject('space missing proposal validation');
}
}

for (const id of strategiesIds) {
const strategy = strategies[id];

if (!strategy) {
return Promise.reject(`strategy "${id}" is not a valid strategy`);
}

if (strategy.disabled) {
return Promise.reject(`strategy "${id}" is not available anymore`);
}
}
}
23 changes: 22 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import initMetrics from './helpers/metrics';
import refreshModeration from './helpers/moderation';
import rateLimit from './helpers/rateLimit';
import shutter from './helpers/shutter';
import { run as refreshStrategies, stop as stopStrategies } from './helpers/strategies';
import { trackTurboStatuses } from './helpers/turbo';

const app = express();

initLogger(app);
refreshModeration();
refreshStrategies();
initMetrics(app);
trackTurboStatuses();

Expand All @@ -29,4 +31,23 @@ app.use('/shutter', shutter);
fallbackLogger(app);

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => log.info(`Started on: http://localhost:${PORT}`));
const server = app.listen(PORT, () => log.info(`Started on: http://localhost:${PORT}`));

const gracefulShutdown = (signal: string) => {
log.info(`Received ${signal}, shutting down gracefully...`);

stopStrategies();

server.close(() => {
log.info('Server closed');
process.exit(0);
});

setTimeout(() => {
log.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 10000);
};

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
4 changes: 2 additions & 2 deletions src/writer/proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { capture } from '@snapshot-labs/snapshot-sentry';
import snapshot from '@snapshot-labs/snapshot.js';
import networks from '@snapshot-labs/snapshot.js/src/networks.json';
import { uniq } from 'lodash';
import { validateSpaceSettings } from './settings';
import { getPremiumNetworkIds, getSpace } from '../helpers/actions';
import log from '../helpers/log';
import { containsFlaggedLinks, flaggedAddresses } from '../helpers/moderation';
import { isMalicious } from '../helpers/monitoring';
import db from '../helpers/mysql';
import { getLimits, getSpaceType } from '../helpers/options';
import { captureError, getQuorum, jsonParse, validateChoices } from '../helpers/utils';
import { validateSpaceSettings } from '../helpers/validation';

const scoreAPIUrl = process.env.SCORE_API_URL || 'https://score.snapshot.org';
const broviderUrl = process.env.BROVIDER_URL || 'https://rpc.snapshot.org';
Expand Down Expand Up @@ -66,7 +66,7 @@ async function validateSpace(space: any) {
}

try {
await validateSpaceSettings(space);
await validateSpaceSettings(space, process.env.NETWORK);
} catch (e) {
return Promise.reject(e);
}
Expand Down
76 changes: 10 additions & 66 deletions src/writer/settings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { capture } from '@snapshot-labs/snapshot-sentry';
import snapshot from '@snapshot-labs/snapshot.js';
import isEqual from 'lodash/isEqual';
import { addOrUpdateSpace, getSpace } from '../helpers/actions';
import log from '../helpers/log';
Expand All @@ -12,68 +11,10 @@ import {
jsonParse,
removeFromWalletConnectWhitelist
} from '../helpers/utils';
import { validateSpaceSettings } from '../helpers/validation';

const SNAPSHOT_ENV = process.env.NETWORK || 'testnet';

export async function validateSpaceSettings(originalSpace: any) {
const spaceType = originalSpace.turbo ? 'turbo' : 'default';
const space = snapshot.utils.clone(originalSpace);

if (space?.deleted) return Promise.reject('space deleted, contact admin');

delete space.deleted;
delete space.flagged;
delete space.verified;
delete space.turbo;
delete space.hibernated;
delete space.id;

if (space.parent && space.parent === originalSpace.id) {
return Promise.reject('space cannot be its own parent');
}

if (
space.children &&
Array.isArray(space.children) &&
space.children.includes(originalSpace.id)
) {
return Promise.reject('space cannot be its own child');
}

const schemaIsValid: any = snapshot.utils.validateSchema(snapshot.schemas.space, space, {
spaceType,
snapshotEnv: SNAPSHOT_ENV
});

if (schemaIsValid !== true) {
log.warn('[writer] Wrong space format', schemaIsValid);
const firstErrorObject: any = Object.values(schemaIsValid)[0];
if (firstErrorObject.message === 'network not allowed') {
return Promise.reject(firstErrorObject.message);
}
return Promise.reject('wrong space format');
}

if (SNAPSHOT_ENV !== 'testnet') {
const hasTicket = space.strategies.some(strategy => strategy.name === 'ticket');
const hasVotingValidation =
space.voteValidation?.name && !['any'].includes(space.voteValidation.name);

if (hasTicket && !hasVotingValidation) {
return Promise.reject('space with ticket requires voting validation');
}

const hasProposalValidation =
(space.validation?.name && space.validation.name !== 'any') ||
space.filters?.minScore ||
space.filters?.onlyMembers;

if (!hasProposalValidation) {
return Promise.reject('space missing proposal validation');
}
}
}

export async function verify(body): Promise<any> {
const msg = jsonParse(body.msg);
if (msg.space.length > 64) {
Expand All @@ -82,12 +23,15 @@ export async function verify(body): Promise<any> {
const space = await getSpace(msg.space, true);

try {
await validateSpaceSettings({
...msg.payload,
id: msg.space,
deleted: space?.deleted,
turbo: space?.turbo
});
await validateSpaceSettings(
{
...msg.payload,
id: msg.space,
deleted: space?.deleted,
turbo: space?.turbo
},
process.env.NETWORK
);
} catch (e) {
return Promise.reject(e);
}
Expand Down
12 changes: 11 additions & 1 deletion test/integration/ingestor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import cloneDeep from 'lodash/cloneDeep';
import omit from 'lodash/omit';
import db, { sequencerDB } from '../../src/helpers/mysql';
import relayer from '../../src/helpers/relayer';
import { run, stop } from '../../src/helpers/strategies';
import ingestor from '../../src/ingestor';
import proposalInput from '../fixtures/ingestor-payload/proposal.json';
import voteInput from '../fixtures/ingestor-payload/vote.json';
Expand Down Expand Up @@ -37,6 +38,7 @@ const LIMITS = {
'user.default.follow.limit': 25
};
const ECOSYSTEM_LIST = ['test.eth', 'snapshot.eth'];

jest.mock('../../src/helpers/options', () => {
const originalModule = jest.requireActual('../../src/helpers/options');

Expand Down Expand Up @@ -129,7 +131,13 @@ function cloneWithNewMessage(data: Record<string, any>) {
}

describe('ingestor', () => {
beforeAll(() => {
beforeAll(async () => {
// Start the strategies loader (runs in background)
run();

// Wait a bit for the first load to complete
await new Promise(resolve => setTimeout(resolve, 2000));

proposalInput.data.message.timestamp = Math.floor(Date.now() / 1e3) - 60;
proposalInput.data.message.end = Math.floor(Date.now() / 1e3) + 60;
voteInput.data.message.timestamp = Math.floor(Date.now() / 1e3) - 60;
Expand All @@ -142,6 +150,8 @@ describe('ingestor', () => {
});

afterAll(async () => {
// Stop any running strategies loader
stop();
await db.queryAsync('DELETE FROM snapshot_sequencer_test.proposals;');
await db.queryAsync('DELETE FROM snapshot_sequencer_test.messages;');
await db.endAsync();
Expand Down
Loading