Skip to content

Commit

Permalink
feat: move offchain space VP computation to sx.js (#115)
Browse files Browse the repository at this point in the history
This is still a bit hacky as we have to work within constrains of
some assumptions from SX (per-strategy validation, multiple proposal
validation strategies), so I tried to make it "fit".

As strategies are handled at Score API, sx.js's strategies
are abstract wrappers to different types of validations, for example:
1. remote-vp: validates VP using get_vp call for all enabled strategies
2. remote-validate: validates proposal using validate call
3. only-members: not really abstract, performs validation offline
  • Loading branch information
Sekhmet authored Mar 5, 2024
1 parent 7b3f7c9 commit bb1234d
Show file tree
Hide file tree
Showing 10 changed files with 148 additions and 68 deletions.
5 changes: 5 additions & 0 deletions .changeset/tasty-pets-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@snapshot-labs/sx": patch
---

add getOffchainStrategy function for computing voting power
69 changes: 31 additions & 38 deletions apps/ui/src/networks/offchain/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { OffchainNetworkConfig, clients, offchainGoerli, offchainMainnet } from '@snapshot-labs/sx';
import { fetchScoreApi, getSdkChoice } from './helpers';
import { EDITOR_APP_NAME, EDITOR_SNAPSHOT_OFFSET, PROPOSAL_VALIDATIONS } from './constants';
import {
OffchainNetworkConfig,
clients,
getOffchainStrategy,
offchainGoerli,
offchainMainnet
} from '@snapshot-labs/sx';
import { getSdkChoice } from './helpers';
import { EDITOR_APP_NAME, EDITOR_SNAPSHOT_OFFSET } from './constants';
import { getUrl } from '@/helpers/utils';
import { getProvider } from '@/helpers/provider';
import { getSwapLink } from '@/helpers/link';
Expand Down Expand Up @@ -141,65 +147,52 @@ export function createActions(
},
send: (envelope: any) => client.send(envelope),
getVotingPower: async (
strategiesAddresses: string[],
strategiesParams: any[],
strategiesNames: string[],
strategiesOrValidationParams: any[],
strategiesMetadata: StrategyParsedMetadata[],
voterAddress: string,
snapshotInfo: SnapshotInfo
): Promise<VotingPower[]> => {
if (Object.keys(PROPOSAL_VALIDATIONS).includes(strategiesAddresses[0])) {
const strategyName = strategiesAddresses[0];
const strategyParams = strategiesParams[0];
let isValid = false;

if (strategyName === 'only-members') {
isValid = strategyParams.addresses
.map((address: string) => address.toLowerCase())
.includes(voterAddress.toLowerCase());
} else {
isValid = await fetchScoreApi('validate', {
validation: strategyName,
author: voterAddress,
space: '',
network: snapshotInfo.chainId,
snapshot: snapshotInfo.at ?? 'latest',
params: strategyParams
});
}
// This is bit hacky at the moment as for offchain spaces we validate all strategies at once instead of per-strategy validation.
// Additionally there is only one proposal validation strategy where on SX there could be multiple (underlying) strategies.
// This means this function will be a bit of mess until getVotingPower function become more generic (can it?).

const name = strategiesNames[0];
const strategy = getOffchainStrategy(name);
if (!strategy) return [{ address: name, value: 0n, decimals: 0, token: null, symbol: '' }];

const result = await strategy.getVotingPower(
voterAddress,
strategiesOrValidationParams,
snapshotInfo
);

if (strategy.type !== 'remote-vp') {
return [
{
address: strategyName,
address: strategiesNames[0],
decimals: 0,
symbol: '',
token: '',
chainId: snapshotInfo.chainId,
value: isValid ? 1n : 0n
value: result[0]
}
];
}

const result = await fetchScoreApi('get_vp', {
address: voterAddress,
space: '',
strategies: strategiesParams,
network: snapshotInfo.chainId ?? chainId,
snapshot: snapshotInfo.at ?? 'latest'
});

return result.vp_by_strategy.map((vp: number, index: number) => {
const strategy = strategiesParams[index];
return result.map((value: bigint, index: number) => {
const strategy = strategiesOrValidationParams[index];
const decimals = parseInt(strategy.params.decimals || 0);

return {
address: strategy.name,
value: BigInt(vp * 10 ** decimals),
value,
decimals,
symbol: strategy.params.symbol,
token: strategy.params.address,
chainId: strategy.network ? parseInt(strategy.network) : undefined,
swapLink: getSwapLink(strategy.name, strategy.params.address, strategy.network)
} as VotingPower;
};
});
}
};
Expand Down
30 changes: 0 additions & 30 deletions apps/ui/src/networks/offchain/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { Choice } from '@/types';

const SCORE_URL = 'https://score.snapshot.org';

export function getSdkChoice(type: string, choice: Choice): number | number[] {
if (type === 'basic') {
if (choice === 'for') return 1;
Expand All @@ -19,31 +17,3 @@ export function getSdkChoice(type: string, choice: Choice): number | number[] {

throw new Error('Vote type not supported');
}

export async function fetchScoreApi(
method: 'validate' | 'get_vp',
params: Record<string, any>
): Promise<any> {
try {
const response = await fetch(SCORE_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method,
params
})
});

const body = await response.json();

if (body.error) throw new Error(body.error.message);

return body.result;
} catch (e) {
throw new Error('Failed to fetch score API');
}
}
10 changes: 10 additions & 0 deletions packages/sx.js/src/clients/offchain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export type StrategyConfig = {
metadata?: Record<string, any>;
};

export type SnapshotInfo = {
at: number | null;
chainId?: number;
};

export type Strategy = {
type: string;
getVotingPower(voterAddress: string, params: any, snapshotInfo: SnapshotInfo): Promise<bigint[]>;
};

export type EIP712VoteMessage = {
space: string;
proposal: string;
Expand Down
1 change: 1 addition & 0 deletions packages/sx.js/src/strategies/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { getStrategy as getEvmStrategy } from './evm';
export { getStrategy as getStarknetStrategy } from './starknet';
export { getStrategy as getOffchainStrategy } from './offchain';
14 changes: 14 additions & 0 deletions packages/sx.js/src/strategies/offchain/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import createOnlyMembersStrategy from './only-members';
import createRemoteValidateStrategy from './remote-validate';
import createRemoteVpStrategy from './remote-vp';
import { Strategy } from '../../clients/offchain/types';

export function getStrategy(name: string): Strategy | null {
if (name === 'only-members') return createOnlyMembersStrategy();

if (['any', 'basic', 'passport-gated', 'arbitrum', 'karma-eas-attestation'].includes(name)) {
return createRemoteValidateStrategy(name);
}

return createRemoteVpStrategy();
}
14 changes: 14 additions & 0 deletions packages/sx.js/src/strategies/offchain/only-members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Strategy } from '../../clients/offchain/types';

export default function createOnlyMembersStrategy(): Strategy {
return {
type: 'only-members',
async getVotingPower(voterAddress: string, params: any) {
const isValid = params[0].addresses
.map((address: string) => address.toLowerCase())
.includes(voterAddress.toLowerCase());

return [isValid ? 1n : 0n];
}
};
}
20 changes: 20 additions & 0 deletions packages/sx.js/src/strategies/offchain/remote-validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { fetchScoreApi } from './utils';
import { Strategy, SnapshotInfo } from '../../clients/offchain/types';

export default function createRemoteValidateStrategy(type: string): Strategy {
return {
type,
async getVotingPower(voterAddress: string, params: any, snapshotInfo: SnapshotInfo) {
const isValid = await fetchScoreApi('validate', {
validation: type,
author: voterAddress,
space: '',
network: snapshotInfo.chainId,
snapshot: snapshotInfo.at ?? 'latest',
params: params[0]
});

return [isValid ? 1n : 0n];
}
};
}
24 changes: 24 additions & 0 deletions packages/sx.js/src/strategies/offchain/remote-vp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { fetchScoreApi } from './utils';
import { Strategy, SnapshotInfo } from '../../clients/offchain/types';

export default function createRemoteVpStrategy(): Strategy {
return {
type: 'remote-vp',
async getVotingPower(voterAddress: string, params: any, snapshotInfo: SnapshotInfo) {
const result = await fetchScoreApi('get_vp', {
address: voterAddress,
space: '',
strategies: params,
network: snapshotInfo.chainId,
snapshot: snapshotInfo.at ?? 'latest'
});

return result.vp_by_strategy.map((vp: number, i: number) => {
const strategy = params[i];
const decimals = parseInt(strategy.params.decimals || 0);

return BigInt(vp * 10 ** decimals);
});
}
};
}
29 changes: 29 additions & 0 deletions packages/sx.js/src/strategies/offchain/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const SCORE_URL = 'https://score.snapshot.org';

export async function fetchScoreApi(
method: 'validate' | 'get_vp',
params: Record<string, any>
): Promise<any> {
try {
const response = await fetch(SCORE_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method,
params
})
});

const body = await response.json();

if (body.error) throw new Error(body.error.message);

return body.result;
} catch (e) {
throw new Error('Failed to fetch score API');
}
}

0 comments on commit bb1234d

Please sign in to comment.