diff --git a/.changeset/tasty-pets-battle.md b/.changeset/tasty-pets-battle.md new file mode 100644 index 000000000..b4c95a821 --- /dev/null +++ b/.changeset/tasty-pets-battle.md @@ -0,0 +1,5 @@ +--- +"@snapshot-labs/sx": patch +--- + +add getOffchainStrategy function for computing voting power diff --git a/apps/ui/src/networks/offchain/actions.ts b/apps/ui/src/networks/offchain/actions.ts index 0c16f5919..dd7ea966e 100644 --- a/apps/ui/src/networks/offchain/actions.ts +++ b/apps/ui/src/networks/offchain/actions.ts @@ -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'; @@ -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 => { - 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; + }; }); } }; diff --git a/apps/ui/src/networks/offchain/helpers.ts b/apps/ui/src/networks/offchain/helpers.ts index c649ad7d5..07d015a83 100644 --- a/apps/ui/src/networks/offchain/helpers.ts +++ b/apps/ui/src/networks/offchain/helpers.ts @@ -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; @@ -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 -): Promise { - 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'); - } -} diff --git a/packages/sx.js/src/clients/offchain/types.ts b/packages/sx.js/src/clients/offchain/types.ts index ef15cd1b0..0b77cc2ac 100644 --- a/packages/sx.js/src/clients/offchain/types.ts +++ b/packages/sx.js/src/clients/offchain/types.ts @@ -21,6 +21,16 @@ export type StrategyConfig = { metadata?: Record; }; +export type SnapshotInfo = { + at: number | null; + chainId?: number; +}; + +export type Strategy = { + type: string; + getVotingPower(voterAddress: string, params: any, snapshotInfo: SnapshotInfo): Promise; +}; + export type EIP712VoteMessage = { space: string; proposal: string; diff --git a/packages/sx.js/src/strategies/index.ts b/packages/sx.js/src/strategies/index.ts index ca9b64056..724348941 100644 --- a/packages/sx.js/src/strategies/index.ts +++ b/packages/sx.js/src/strategies/index.ts @@ -1,2 +1,3 @@ export { getStrategy as getEvmStrategy } from './evm'; export { getStrategy as getStarknetStrategy } from './starknet'; +export { getStrategy as getOffchainStrategy } from './offchain'; diff --git a/packages/sx.js/src/strategies/offchain/index.ts b/packages/sx.js/src/strategies/offchain/index.ts new file mode 100644 index 000000000..624c83840 --- /dev/null +++ b/packages/sx.js/src/strategies/offchain/index.ts @@ -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(); +} diff --git a/packages/sx.js/src/strategies/offchain/only-members.ts b/packages/sx.js/src/strategies/offchain/only-members.ts new file mode 100644 index 000000000..76d3bc5fa --- /dev/null +++ b/packages/sx.js/src/strategies/offchain/only-members.ts @@ -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]; + } + }; +} diff --git a/packages/sx.js/src/strategies/offchain/remote-validate.ts b/packages/sx.js/src/strategies/offchain/remote-validate.ts new file mode 100644 index 000000000..59ccefd4b --- /dev/null +++ b/packages/sx.js/src/strategies/offchain/remote-validate.ts @@ -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]; + } + }; +} diff --git a/packages/sx.js/src/strategies/offchain/remote-vp.ts b/packages/sx.js/src/strategies/offchain/remote-vp.ts new file mode 100644 index 000000000..b8021c3b8 --- /dev/null +++ b/packages/sx.js/src/strategies/offchain/remote-vp.ts @@ -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); + }); + } + }; +} diff --git a/packages/sx.js/src/strategies/offchain/utils.ts b/packages/sx.js/src/strategies/offchain/utils.ts new file mode 100644 index 000000000..6fbd0b947 --- /dev/null +++ b/packages/sx.js/src/strategies/offchain/utils.ts @@ -0,0 +1,29 @@ +const SCORE_URL = 'https://score.snapshot.org'; + +export async function fetchScoreApi( + method: 'validate' | 'get_vp', + params: Record +): Promise { + 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'); + } +}