diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..85cfb681e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Tests CI + +on: [push] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: ['16', '20'] + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - name: yarn install, build and run tests + run: | + yarn install --frozen-lockfile + yarn build + yarn test --coverage + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 112e93edf..478a66171 100755 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ .DS_Store node_modules dist +coverage # Remove some common IDE working directories .idea .vscode .env -.history \ No newline at end of file +.history diff --git a/package.json b/package.json index 9b86799f8..12e155d00 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,11 @@ "license": "MIT", "scripts": { "build": "tsc -p .", - "test": "jest -i strategy.test.ts", + "test": "yarn test:unit && jest -i strategy.test.ts", "test:vp": "jest -i vp.test.ts", "test:delegation": "jest -i delegation.test.ts", "test:validation": "jest -i validation.test.ts", + "test:unit": "jest -i test/unit/", "prepublishOnly": "npm run build", "postinstall": "npm run build", "postbuild": "copyfiles -u 1 \"src/**/*.md\" dist/ && copyfiles -u 1 \"src/**/*.json\" dist/", diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 000000000..4c1bdb3ab --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,4 @@ +import { Protocol } from './types'; + +export const DEFAULT_SUPPORTED_PROTOCOLS: Protocol[] = ['evm']; +export const VALID_PROTOCOLS: Protocol[] = ['evm', 'starknet']; diff --git a/src/strategies/index.ts b/src/strategies/index.ts index 1d322fbe3..dc67a5046 100644 --- a/src/strategies/index.ts +++ b/src/strategies/index.ts @@ -487,6 +487,8 @@ import * as prlInSpRL2Balance from './prl-in-sprl2-balance'; import * as edenOnlineOverride from './eden-online-override'; import * as forteStaking from './forte-staking'; +import { DEFAULT_SUPPORTED_PROTOCOLS } from '../constants'; + const strategies = { 'shroomy-voting-power': shroomyVotingPower, 'apecoin-staking': apecoinStaking, @@ -1018,6 +1020,7 @@ Object.keys(strategies).forEach(function (strategyName) { strategies[strategyName].examples = examples; strategies[strategyName].schema = schema; strategies[strategyName].about = about; + strategies[strategyName].supportedProtocols ||= DEFAULT_SUPPORTED_PROTOCOLS; }); export default strategies; diff --git a/src/strategies/math/index.ts b/src/strategies/math/index.ts index 6d6444832..1f10e616f 100644 --- a/src/strategies/math/index.ts +++ b/src/strategies/math/index.ts @@ -13,7 +13,8 @@ import { } from './options'; export const author = 'xJonathanLEI'; -export const version = '0.2.2'; +export const version = '0.2.3'; +export const supportedProtocols = ['evm', 'starknet']; export async function strategy( space, diff --git a/src/strategies/ocean-dao-brightid/index.ts b/src/strategies/ocean-dao-brightid/index.ts index b62449ead..abb485cdd 100644 --- a/src/strategies/ocean-dao-brightid/index.ts +++ b/src/strategies/ocean-dao-brightid/index.ts @@ -81,7 +81,7 @@ export async function strategy( .filter((address, index, self) => self.indexOf(address) === index); // Remove duplicates for (const chain of Object.keys(options.strategies)) { - let scores = await getScoresDirect( + const scores = await getScoresDirect( space, options.strategies[chain], chain, @@ -92,7 +92,7 @@ export async function strategy( // [{ address: '0x...', score: 0.5 },{ address: '0x...', score: 0.5 }] // sum scores for each address and return - scores = scores.reduce((finalScores: any, score: any) => { + const addressScores = scores.reduce((finalScores: any, score: any) => { for (const [address, value] of Object.entries(score)) { if (!finalScores[address]) { finalScores[address] = 0; @@ -105,19 +105,19 @@ export async function strategy( // sum delegations addresses.forEach((address) => { - if (!scores[address]) scores[address] = 0; + if (!addressScores[address]) addressScores[address] = 0; if (delegations[address]) { delegations[address].forEach((delegator: string) => { - scores[address] += scores[delegator] ?? 0; // add delegator score - scores[delegator] = 0; // set delegator score to 0 + addressScores[address] += addressScores[delegator] ?? 0; // add delegator score + addressScores[delegator] = 0; // set delegator score to 0 }); } }); - for (const key of Object.keys(scores)) { + for (const key of Object.keys(addressScores)) { totalScores[key] = totalScores[key] - ? totalScores[key] + scores[key] - : scores[key]; + ? totalScores[key] + addressScores[key] + : addressScores[key]; } } diff --git a/src/strategies/rocketpool-node-operator-delegate-v8/index.ts b/src/strategies/rocketpool-node-operator-delegate-v8/index.ts index 65350c473..4f0a8a1fa 100644 --- a/src/strategies/rocketpool-node-operator-delegate-v8/index.ts +++ b/src/strategies/rocketpool-node-operator-delegate-v8/index.ts @@ -12,9 +12,11 @@ const signerRegistryAbi = [ 'function signerToNode(address) external view returns (address)' ]; -const snapshotSecretHeader = sha256( - `https://api.rocketpool.net/mainnet/delegates/block/${process.env.SNAPSHOT_API_STRATEGY_SALT}` -); +function getSnapshotSecretHeader() { + return sha256( + `https://api.rocketpool.net/mainnet/delegates/block/${process.env.SNAPSHOT_API_STRATEGY_SALT}` + ); +} export async function strategy( space, @@ -42,7 +44,7 @@ export async function strategy( 'https://api.rocketpool.net/mainnet/delegates/block/' + blockTag, { headers: { - 'X-Snapshot-API-Secret': snapshotSecretHeader + 'X-Snapshot-API-Secret': getSnapshotSecretHeader() } } ); diff --git a/src/strategies/subgraph-split-delegation/index.ts b/src/strategies/subgraph-split-delegation/index.ts index 2895e16cf..39f583893 100644 --- a/src/strategies/subgraph-split-delegation/index.ts +++ b/src/strategies/subgraph-split-delegation/index.ts @@ -1,6 +1,7 @@ import { getAddress } from '@ethersproject/address'; import { subgraphRequest, getScoresDirect } from '../../utils'; import { Strategy } from '@snapshot-labs/snapshot.js/dist/src/voting/types'; +import { Snapshot } from '../../types'; export const author = 'aragon'; export const version = '0.1.0'; @@ -35,7 +36,7 @@ export async function strategy( subgraphUrl: DEFAULT_BACKEND_URL, strategies: [] }, - snapshot: string | number + snapshot: Snapshot ) { const blockTag = typeof snapshot === 'number' ? snapshot : 'latest'; const block = await provider.getBlock(blockTag); diff --git a/src/strategies/ticket/index.ts b/src/strategies/ticket/index.ts index 31c2dfcf8..614132bc6 100644 --- a/src/strategies/ticket/index.ts +++ b/src/strategies/ticket/index.ts @@ -1,6 +1,8 @@ export const author = 'bonustrack'; export const version = '0.1.0'; +export const supportedProtocols = ['evm', 'starknet']; + export async function strategy(space, network, provider, addresses, options) { return Object.fromEntries( addresses.map((address) => [address, options.value || 1]) diff --git a/src/strategies/whitelist-weighted/index.ts b/src/strategies/whitelist-weighted/index.ts index 3f41e16c3..f9a5d894c 100644 --- a/src/strategies/whitelist-weighted/index.ts +++ b/src/strategies/whitelist-weighted/index.ts @@ -1,6 +1,8 @@ export const author = 'vsergeev'; export const version = '0.1.0'; +export const supportedProtocols = ['evm', 'starknet']; + export async function strategy(space, network, provider, addresses, options) { const whitelist = Object.fromEntries( Object.entries(options?.addresses).map(([addr, weight]) => [ diff --git a/src/strategies/whitelist/index.ts b/src/strategies/whitelist/index.ts index 35967da2f..e89d21aba 100644 --- a/src/strategies/whitelist/index.ts +++ b/src/strategies/whitelist/index.ts @@ -1,6 +1,8 @@ export const author = 'bonustrack'; export const version = '0.1.0'; +export const supportedProtocols = ['evm', 'starknet']; + export async function strategy(space, network, provider, addresses, options) { const whitelist = options?.addresses.map((address) => address.toLowerCase()); return Object.fromEntries( diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..45732bb86 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,9 @@ +export type VpState = 'final' | 'pending'; +export type Score = Record; +export type VotingPower = { + vp: number; + vp_by_strategy: number[]; + vp_state: VpState; +}; +export type Snapshot = number | 'latest'; +export type Protocol = 'evm' | 'starknet'; diff --git a/src/utils.ts b/src/utils.ts index e6d9d15c9..f0ef69faf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,24 +4,33 @@ import snapshot from '@snapshot-labs/snapshot.js'; import { getDelegations } from './utils/delegation'; import { getVp, getDelegations as getCoreDelegations } from './utils/vp'; import { createHash } from 'crypto'; +import { Protocol, Score, Snapshot } from './types'; +import { VALID_PROTOCOLS } from './constants'; export function sha256(str) { return createHash('sha256').update(str).digest('hex'); } -async function callStrategy(space, network, addresses, strategy, snapshot) { +async function callStrategy( + space: string, + network, + addresses: string[], + strategy, + snapshot: Snapshot +): Promise { if ( (snapshot !== 'latest' && strategy.params?.start > snapshot) || (strategy.params?.end && (snapshot === 'latest' || snapshot > strategy.params?.end)) - ) + ) { return {}; + } if (!_strategies.hasOwnProperty(strategy.name)) { throw new Error(`Invalid strategy: ${strategy.name}`); } - const score: any = await _strategies[strategy.name].strategy( + const score: Score = await _strategies[strategy.name].strategy( space, network, getProvider(network), @@ -32,8 +41,7 @@ async function callStrategy(space, network, addresses, strategy, snapshot) { const addressesLc = addresses.map((address) => address.toLowerCase()); return Object.fromEntries( Object.entries(score).filter( - ([address, vp]: any[]) => - vp > 0 && addressesLc.includes(address.toLowerCase()) + ([address, vp]) => vp > 0 && addressesLc.includes(address.toLowerCase()) ) ); } @@ -44,8 +52,8 @@ export async function getScoresDirect( network: string, provider, addresses: string[], - snapshot: number | string = 'latest' -) { + snapshot: Snapshot +): Promise { try { const networks = strategies.map((s) => s.network || network); const snapshots = await getSnapshots(network, snapshot, provider, networks); @@ -92,6 +100,68 @@ export function customFetch( ]); } +/** + * Validates that protocols are non-empty and contain only valid protocol names. + * + * @param protocols - Array of protocol names to validate + */ +function validateProtocols(protocols: Protocol[]): void { + if (!protocols.length) { + throw new Error('At least one protocol must be specified'); + } + + const invalidProtocols = protocols.filter( + (p) => !VALID_PROTOCOLS.includes(p) + ); + if (invalidProtocols.length > 0) { + throw new Error(`Invalid protocol(s): ${invalidProtocols.join(', ')}`); + } +} + +/** + * Formats addresses according to the specified blockchain protocols. + * + * This function takes a list of addresses and formats them according to the provided + * protocols. It prioritizes EVM formatting when multiple protocols are specified and + * an address is valid for both. If EVM formatting fails but Starknet is supported, + * it falls back to Starknet formatting. Throws an error if any address cannot be + * formatted according to the specified protocols. + * + * @param addresses - Array of blockchain addresses to format + * @param protocols - Array of protocol names to validate against. Defaults to ['evm']. + * Valid protocols are 'evm' and 'starknet'. + * + * @returns Array of formatted addresses in the same order as input + */ +export function getFormattedAddressesByProtocol( + addresses: string[], + protocols: Protocol[] = ['evm'] +): string[] { + validateProtocols(protocols); + + return addresses.map((address) => { + if (protocols.includes('evm')) { + try { + return snapshot.utils.getFormattedAddress(address, 'evm'); + } catch (e) { + // Continue to starknet if evm formatting fails and starknet is supported + } + } + + if (protocols.includes('starknet')) { + try { + return snapshot.utils.getFormattedAddress(address, 'starknet'); + } catch (e) { + // Address format not supported by any protocol + } + } + + throw new Error( + `Address "${address}" is not a valid ${protocols.join(' or ')} address` + ); + }); +} + export const { multicall, Multicaller, @@ -107,7 +177,10 @@ export const { } = snapshot.utils; export default { + sha256, getScoresDirect, + customFetch, + getFormattedAddressesByProtocol, multicall, Multicaller, subgraphRequest, diff --git a/src/utils/delegation.ts b/src/utils/delegation.ts index b48a34665..99795fd7d 100644 --- a/src/utils/delegation.ts +++ b/src/utils/delegation.ts @@ -1,10 +1,16 @@ import { getAddress } from '@ethersproject/address'; import { getDelegatesBySpace } from '../utils'; +import { Snapshot } from '../types'; const DELEGATION_DATA_CACHE = {}; // delegations with overrides -export async function getDelegations(space, network, addresses, snapshot) { +export async function getDelegations( + space, + network, + addresses: string[], + snapshot: Snapshot +) { const addressesLc = addresses.map((address) => address.toLowerCase()); const delegatesBySpace = await getDelegatesBySpace(network, space, snapshot); @@ -46,7 +52,12 @@ function getDelegationReverseData(delegation) { }; } -export async function getDelegationsData(space, network, addresses, snapshot) { +export async function getDelegationsData( + space, + network, + addresses: string[], + snapshot: Snapshot +) { const cacheKey = `${space}-${network}-${snapshot}`; let delegationsReverse = DELEGATION_DATA_CACHE[cacheKey]; diff --git a/src/utils/vp.ts b/src/utils/vp.ts index 6bd74f2af..9066f4270 100644 --- a/src/utils/vp.ts +++ b/src/utils/vp.ts @@ -2,12 +2,15 @@ import { formatBytes32String } from '@ethersproject/strings'; import { getAddress } from '@ethersproject/address'; import subgraphs from '@snapshot-labs/snapshot.js/src/delegationSubgraphs.json'; import { + getFormattedAddressesByProtocol, getProvider, getSnapshots, Multicaller, subgraphRequest } from '../utils'; import _strategies from '../strategies'; +import { Score, Snapshot, VotingPower } from '../types'; +import { DEFAULT_SUPPORTED_PROTOCOLS } from '../constants'; const DELEGATION_CONTRACT = '0x469788fE6E9E9681C6ebF3bF78e7Fd26Fc015446'; const EMPTY_ADDRESS = '0x0000000000000000000000000000000000000000'; @@ -23,10 +26,10 @@ export async function getVp( address: string, network: string, strategies: any[], - snapshot: number | 'latest', + snapshot: Snapshot, space: string, delegation?: boolean -) { +): Promise { const networks = [...new Set(strategies.map((s) => s.network || network))]; const snapshots = await getSnapshots( network, @@ -43,7 +46,7 @@ export async function getVp( ds.forEach((d, i) => (delegations[networks[i]] = d)); } - const p = strategies.map((strategy) => { + const p: Score[] = strategies.map((strategy) => { const n = strategy.network || network; let addresses = [address]; @@ -54,7 +57,10 @@ export async function getVp( if (addresses.length === 0) return {}; } - addresses = addresses.map(getAddress); + addresses = getFormattedAddressesByProtocol( + addresses, + strategy.supportedProtocols ?? DEFAULT_SUPPORTED_PROTOCOLS + ); return _strategies[strategy.name].strategy( space, n, @@ -76,12 +82,14 @@ export async function getVp( addresses = [...new Set(addresses)]; } - addresses = addresses.map(getAddress); + addresses = getFormattedAddressesByProtocol( + addresses, + strategies[i].supportedProtocols + ); return addresses.reduce((a, b) => a + (score[b] || 0), 0); }); const vp = vpByStrategy.reduce((a, b) => a + b, 0); - let vpState = 'final'; - if (snapshot === 'latest') vpState = 'pending'; + const vpState = snapshot === 'latest' ? 'pending' : 'final'; return { vp, @@ -93,7 +101,7 @@ export async function getVp( export async function getDelegationsOut( addresses: string[], network: string, - snapshot: number | 'latest', + snapshot: Snapshot, space: string ) { if (!subgraphs[network]) @@ -127,7 +135,7 @@ export async function getDelegationsOut( export async function getDelegationOut( address: string, network: string, - snapshot: number | 'latest', + snapshot: Snapshot, space: string ): Promise { const usersDelegationOut = await getDelegationsOut( @@ -142,7 +150,7 @@ export async function getDelegationOut( export async function getDelegationsIn( address: string, network: string, - snapshot: number | 'latest', + snapshot: Snapshot, space: string ): Promise { if (!subgraphs[network]) return []; @@ -206,7 +214,7 @@ export async function getDelegationsIn( export async function getDelegations( address: string, network: string, - snapshot: number | 'latest', + snapshot: Snapshot, space: string ): Promise { const [delegationOut, delegationsIn] = await Promise.all([ diff --git a/src/validations/arbitrum/index.ts b/src/validations/arbitrum/index.ts index c5141e4a5..5e9f4e2f9 100644 --- a/src/validations/arbitrum/index.ts +++ b/src/validations/arbitrum/index.ts @@ -16,44 +16,42 @@ export default class extends Validation { public description = 'Use with erc20-votes to validate by percentage of votable supply.'; public proposalValidationOnly = true; + public hasInnerStrategies = true; - async validate(): Promise { + protected async doValidate(): Promise { const minBps = this.params.minBps; const decimals = this.params.decimals; const excludeaddr = this.params.excludeaddr ?? '0x00000000000000000000000000000000000A4B86'; - if (minBps) { - const scores = await getScoresDirect( - this.space, - this.params.strategies, - this.network, - getProvider(this.network), - [this.author], - this.snapshot || 'latest' - ); - const totalScore: any = scores - .map((score: any) => - Object.values(score).reduce((a, b: any) => a + b, 0) - ) - .reduce((a, b: any) => a + b, 0); - const [[totalSupply], [excludedSupply]] = await multicall( - this.network, - getProvider(this.network), - abi, - [ - [this.params.address, 'totalSupply', []], - [this.params.address, 'getVotes', [excludeaddr]] - ], - { blockTag: this.snapshot || 'latest' } - ); - const votableSupply = parseFloat( - formatUnits(totalSupply.sub(excludedSupply).toString(), decimals) - ); - const bpsOfVotable = (totalScore * 10000) / votableSupply; - if (bpsOfVotable < minBps) return false; - } + if (!minBps) return true; - return true; + const scores = await getScoresDirect( + this.space, + this.params.strategies, + this.network, + getProvider(this.network), + [this.author], + this.snapshot || 'latest' + ); + const totalScore: any = scores + .map((score: any) => Object.values(score).reduce((a, b: any) => a + b, 0)) + .reduce((a, b: any) => a + b, 0); + const [[totalSupply], [excludedSupply]] = await multicall( + this.network, + getProvider(this.network), + abi, + [ + [this.params.address, 'totalSupply', []], + [this.params.address, 'getVotes', [excludeaddr]] + ], + { blockTag: this.snapshot || 'latest' } + ); + const votableSupply = parseFloat( + formatUnits(totalSupply.sub(excludedSupply).toString(), decimals) + ); + const bpsOfVotable = (totalScore * 10000) / votableSupply; + + return bpsOfVotable >= minBps; } } diff --git a/src/validations/basic/index.ts b/src/validations/basic/index.ts index 6c3ccb0df..551e8e383 100644 --- a/src/validations/basic/index.ts +++ b/src/validations/basic/index.ts @@ -1,5 +1,6 @@ import Validation from '../validation'; import { getProvider, getScoresDirect } from '../../utils'; +import { Protocol } from '../../types'; export default class extends Validation { public id = 'basic'; @@ -7,27 +8,26 @@ export default class extends Validation { public version = '0.2.0'; public title = 'Basic'; public description = 'Use any strategy to determine if a user can vote.'; + public supportedProtocols: Protocol[] = ['evm', 'starknet']; + public hasInnerStrategies = true; - async validate(): Promise { + protected async doValidate(): Promise { const minScore = this.params.minScore; - if (minScore) { - const scores = await getScoresDirect( - this.space, - this.params.strategies, - this.network, - getProvider(this.network), - [this.author], - this.snapshot || 'latest' - ); - const totalScore: any = scores - .map((score: any) => - Object.values(score).reduce((a, b: any) => a + b, 0) - ) - .reduce((a, b: any) => a + b, 0); - if (totalScore < minScore) return false; - } + if (!minScore) return true; - return true; + const scores = await getScoresDirect( + this.space, + this.params.strategies, + this.network, + getProvider(this.network), + [this.author], + this.snapshot || 'latest' + ); + const totalScore: any = scores + .map((score: any) => Object.values(score).reduce((a, b: any) => a + b, 0)) + .reduce((a, b: any) => a + b, 0); + + return totalScore >= minScore; } } diff --git a/src/validations/index.ts b/src/validations/index.ts index 3cdc99c23..f66f532e8 100644 --- a/src/validations/index.ts +++ b/src/validations/index.ts @@ -60,7 +60,8 @@ Object.keys(validationClasses).forEach(function (validationName) { title: validationInstance.title, description: validationInstance.description, proposalValidationOnly: validationInstance.proposalValidationOnly, - votingValidationOnly: validationInstance.votingValidationOnly + votingValidationOnly: validationInstance.votingValidationOnly, + supportedProtocols: validationInstance.supportedProtocols }; }); diff --git a/src/validations/karma-eas-attestation/index.ts b/src/validations/karma-eas-attestation/index.ts index f4d4d24dc..4cca6b2d7 100644 --- a/src/validations/karma-eas-attestation/index.ts +++ b/src/validations/karma-eas-attestation/index.ts @@ -70,7 +70,8 @@ export default class extends Validation { public description = 'Use EAS attest.sh to determine if user can create a proposal.'; public proposalValidationOnly = true; - async validate(): Promise { + + protected async doValidate(): Promise { const schemaId = this.params.schemaId; if (!schemaId) throw new Error(`Attestation schema not provided`); diff --git a/src/validations/passport-gated/index.ts b/src/validations/passport-gated/index.ts index a9aad2924..7b664751d 100644 --- a/src/validations/passport-gated/index.ts +++ b/src/validations/passport-gated/index.ts @@ -170,7 +170,7 @@ export default class extends Validation { public description = 'Protect your proposals from spam and vote manipulation by requiring users to have a valid Gitcoin Passport.'; - async validate(currentAddress = this.author): Promise { + protected async doValidate(customAuthor: string): Promise { const requiredStamps = this.params.stamps || []; const operator = this.params.operator; const scoreThreshold = this.params.scoreThreshold || 0; @@ -181,7 +181,7 @@ export default class extends Validation { const provider = snapshot.utils.getProvider(this.network); const proposalTs = (await provider.getBlock(this.snapshot)).timestamp; const validStamps = await validateStamps( - currentAddress, + customAuthor, operator, proposalTs, requiredStamps @@ -192,7 +192,7 @@ export default class extends Validation { } const validScore = await validatePassportScore( - currentAddress, + customAuthor, scoreThreshold ); diff --git a/src/validations/validation.ts b/src/validations/validation.ts index 48913bcba..ad75c1708 100644 --- a/src/validations/validation.ts +++ b/src/validations/validation.ts @@ -1,21 +1,27 @@ +import snapshot from '@snapshot-labs/snapshot.js'; +import { DEFAULT_SUPPORTED_PROTOCOLS } from '../constants'; +import { Protocol, Snapshot } from '../types'; + export default class Validation { public id = ''; public github = ''; public version = ''; public title = ''; public description = ''; + public supportedProtocols: Protocol[] = DEFAULT_SUPPORTED_PROTOCOLS; + public hasInnerStrategies = false; public author: string; public space: string; public network: string; - public snapshot: number | 'latest'; + public snapshot: Snapshot; public params: any; constructor( author: string, space: string, network: string, - snapshot: number | 'latest', + snapshot: Snapshot, params: any ) { this.author = author; @@ -25,7 +31,43 @@ export default class Validation { this.params = params; } - async validate(): Promise { + async validate(customAuthor = this.author): Promise { + try { + this.validateAddressType(customAuthor); + } catch (e) { + return false; + } + + return this.doValidate(customAuthor); + } + + // Abstract method to be implemented by subclasses + // This contains the actual validation logic without global/commons validation + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async doValidate(_customAuthor: string): Promise { return true; } + + private validateAddressType(address: string): boolean { + try { + const formattedAddress = snapshot.utils.getFormattedAddress(address); + + if ( + (snapshot.utils.isEvmAddress(formattedAddress) && + this.supportedProtocols.includes('evm')) || + (snapshot.utils.isStarknetAddress(formattedAddress) && + this.supportedProtocols.includes('starknet')) + ) { + return true; + } + } catch (error) { + // If isStarknetAddress throws an error, fall through to the standard error + } + + throw new Error( + `Address "${address}" is not a valid ${this.supportedProtocols.join( + ' or ' + )} address` + ); + } } diff --git a/test/strategy-with-params.test.ts b/test/strategy-with-params.test.ts index 3ebdc1065..a63d4d629 100644 --- a/test/strategy-with-params.test.ts +++ b/test/strategy-with-params.test.ts @@ -1,5 +1,6 @@ // To test strategies by copy pasting score API body here import snapshot from '../src'; +import { Snapshot } from '../src/types'; const scoreAPIObj = { params: { @@ -1346,7 +1347,7 @@ snapshot.utils scoreAPIObj.params.network, provider, scoreAPIObj.params.addresses, - scoreAPIObj.params.snapshot + scoreAPIObj.params.snapshot as Snapshot ) .then(console.log) .then(() => { diff --git a/test/strategy.test.ts b/test/strategy.test.ts index 5e729838a..59192cc99 100644 --- a/test/strategy.test.ts +++ b/test/strategy.test.ts @@ -172,7 +172,7 @@ describe.each(examples)( expect(Array.isArray(scores)).toBe(true); // Check array contains a object expect(typeof scores[0]).toBe('object'); - // Check object contains atleast one address from example.json + // Check object contains at least one address from example.json expect(Object.keys(scores[0]).length).toBeGreaterThanOrEqual(1); expect( Object.keys(scores[0]).some((address) => diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts new file mode 100644 index 000000000..ba6759643 --- /dev/null +++ b/test/unit/utils.test.ts @@ -0,0 +1,156 @@ +import { getFormattedAddressesByProtocol } from '../../src/utils'; + +describe('utils', () => { + const VALID_EVM_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; + const VALID_FORMATTED_EVM_ADDRESS = + '0x1234567890AbcdEF1234567890aBcdef12345678'; + const VALID_STARKNET_ADDRESS = + '0x07f71118e351c02f6EC7099C8CDf93AED66CEd8406E94631cC91637f7D7F203A'; + const VALID_FORMATTED_STARKNET_ADDRESS = + '0x07f71118e351c02f6ec7099c8cdf93aed66ced8406e94631cc91637f7d7f203a'; + + describe('getFormattedAddressesByProtocol()', () => { + // Test data constants + const INVALID_ADDRESS = 'invalidAddress'; + const EMPTY_ADDRESS = ''; + const STARKNET_ONLY_ADDRESS = VALID_STARKNET_ADDRESS; + const EVM_ONLY_ADDRESS = VALID_EVM_ADDRESS; + + describe('Basic functionality', () => { + it('should return an empty array when no addresses are provided', () => { + const result = getFormattedAddressesByProtocol([]); + expect(result).toEqual([]); + }); + + it('should use evm as default protocol when no protocols provided', () => { + const result = getFormattedAddressesByProtocol([EVM_ONLY_ADDRESS]); + expect(result).toEqual([VALID_FORMATTED_EVM_ADDRESS]); + }); + }); + + describe('Protocol validation', () => { + it('should throw an error when no protocols are provided', () => { + expect(() => { + getFormattedAddressesByProtocol([], []); + }).toThrow('At least one protocol must be specified'); + }); + + it('should throw an error for single invalid protocol', () => { + expect(() => { + getFormattedAddressesByProtocol( + [EVM_ONLY_ADDRESS], + [ + // @ts-ignore + 'invalidProtocol' + ] + ); + }).toThrow('Invalid protocol(s): invalidProtocol'); + }); + + it('should throw an error for multiple invalid protocols', () => { + expect(() => { + getFormattedAddressesByProtocol( + [EVM_ONLY_ADDRESS], + [ + // @ts-ignore + 'invalidProtocol1', + // @ts-ignore + 'invalidProtocol2' + ] + ); + }).toThrow('Invalid protocol(s): invalidProtocol1, invalidProtocol2'); + }); + }); + + describe('Single protocol formatting', () => { + it('should format EVM addresses correctly', () => { + const result = getFormattedAddressesByProtocol( + [EVM_ONLY_ADDRESS], + ['evm'] + ); + expect(result).toEqual([VALID_FORMATTED_EVM_ADDRESS]); + }); + + it('should format Starknet addresses correctly', () => { + const result = getFormattedAddressesByProtocol( + [STARKNET_ONLY_ADDRESS], + ['starknet'] + ); + expect(result).toEqual([VALID_FORMATTED_STARKNET_ADDRESS]); + }); + }); + + describe('Multiple protocol formatting', () => { + it('should prioritize EVM when address is valid for both protocols', () => { + const result = getFormattedAddressesByProtocol( + [EVM_ONLY_ADDRESS], + ['evm', 'starknet'] + ); + expect(result).toEqual([VALID_FORMATTED_EVM_ADDRESS]); + }); + + it('should fall back to Starknet when EVM formatting fails', () => { + const result = getFormattedAddressesByProtocol( + [STARKNET_ONLY_ADDRESS], + ['evm', 'starknet'] + ); + expect(result).toEqual([VALID_FORMATTED_STARKNET_ADDRESS]); + }); + + it('should format addresses from different protocols correctly', () => { + const result = getFormattedAddressesByProtocol( + [EVM_ONLY_ADDRESS, STARKNET_ONLY_ADDRESS], + ['evm', 'starknet'] + ); + expect(result).toEqual([ + VALID_FORMATTED_EVM_ADDRESS, + VALID_FORMATTED_STARKNET_ADDRESS + ]); + }); + + it('should maintain protocol order independence for multiple valid protocols', () => { + const result1 = getFormattedAddressesByProtocol( + [EVM_ONLY_ADDRESS], + ['evm', 'starknet'] + ); + const result2 = getFormattedAddressesByProtocol( + [EVM_ONLY_ADDRESS], + ['starknet', 'evm'] + ); + expect(result1).toEqual(result2); + }); + }); + + describe('Error handling', () => { + it('should throw an error for completely invalid addresses', () => { + expect(() => { + getFormattedAddressesByProtocol([INVALID_ADDRESS], ['evm']); + }).toThrow('is not a valid evm address'); + }); + + it('should throw an error for empty string addresses', () => { + expect(() => { + getFormattedAddressesByProtocol([EMPTY_ADDRESS], ['evm']); + }).toThrow('is not a valid evm address'); + }); + + it('should throw an error when address is invalid for all specified protocols', () => { + expect(() => { + getFormattedAddressesByProtocol( + [INVALID_ADDRESS], + ['evm', 'starknet'] + ); + }).toThrow('is not a valid evm or starknet address'); + }); + + it('should throw an error on first invalid address in mixed array', () => { + expect(() => { + getFormattedAddressesByProtocol( + [EVM_ONLY_ADDRESS, INVALID_ADDRESS, STARKNET_ONLY_ADDRESS], + ['evm', 'starknet'] + ); + }).toThrow('is not a valid evm or starknet address'); + }); + }); + }); +}); diff --git a/test/unit/validation.test.ts b/test/unit/validation.test.ts new file mode 100644 index 000000000..d4bc998b7 --- /dev/null +++ b/test/unit/validation.test.ts @@ -0,0 +1,313 @@ +import Validation from '../../src/validations/validation'; + +class TestValidation extends Validation {} + +describe('Validation', () => { + describe('constructor', () => { + it('should initialize all properties correctly', () => { + const author = '0x1234567890abcdef1234567890abcdef12345678'; + const space = 'test-space'; + const network = '1'; + const snapshot = 123456; + const params = { test: 'value' }; + + const validation = new TestValidation( + author, + space, + network, + snapshot, + params + ); + + expect(validation.author).toBe(author); + expect(validation.space).toBe(space); + expect(validation.network).toBe(network); + expect(validation.snapshot).toBe(snapshot); + expect(validation.params).toBe(params); + expect(validation.id).toBe(''); + expect(validation.github).toBe(''); + expect(validation.version).toBe(''); + expect(validation.title).toBe(''); + expect(validation.description).toBe(''); + expect(validation.supportedProtocols).toEqual(['evm']); + expect(validation.hasInnerStrategies).toBe(false); + }); + }); + + describe('validate() method', () => { + it('should call doValidate with default author when no custom author provided', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + {} + ); + const doValidateSpy = jest.spyOn(validation, 'doValidate' as any); + + await validation.validate(); + + expect(doValidateSpy).toHaveBeenCalledWith( + '0x1234567890abcdef1234567890abcdef12345678' + ); + }); + + it('should call doValidate with custom author when provided', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + {} + ); + const customAuthor = '0xabcdef1234567890abcdef1234567890abcdef12'; + const doValidateSpy = jest.spyOn(validation, 'doValidate' as any); + + await validation.validate(customAuthor); + + expect(doValidateSpy).toHaveBeenCalledWith(customAuthor); + }); + + it('should return false for invalid custom author address type', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['evm']; + const starknetAddress = + '0x07f71118e351c02f6EC7099C8CDf93AED66CEd8406E94631cC91637f7D7F203A'; + + const result = await validation.validate(starknetAddress); + expect(result).toBe(false); + }); + + it('should return the result from doValidate', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + {} + ); + const mockResult = false; + jest.spyOn(validation, 'doValidate' as any).mockResolvedValue(mockResult); + + const result = await validation.validate(); + + expect(result).toBe(mockResult); + }); + }); + + describe('doValidate() method', () => { + it('should return true by default in base class', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + {} + ); + + const result = await validation.validate(); + expect(result).toBe(true); + }); + }); + + describe('validateStrategiesLength()', () => { + it('should not throw error when hasInnerStrategies is false', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + { strategies: new Array(10).fill({}) } + ); + validation.hasInnerStrategies = false; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + + it('should not throw error when hasInnerStrategies is true and strategies length is within limit', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + { strategies: new Array(5).fill({}) } + ); + validation.hasInnerStrategies = true; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + + it('should not throw error when hasInnerStrategies is true and strategies length equals limit', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + { strategies: new Array(8).fill({}) } + ); + validation.hasInnerStrategies = true; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + + it('should not throw error when hasInnerStrategies is true and strategies is undefined', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + {} + ); + validation.hasInnerStrategies = true; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + + it('should not throw error when hasInnerStrategies is true and strategies is empty', async () => { + const validation = new TestValidation( + '0x1234567890abcdef1234567890abcdef12345678', + 'test-space', + '1', + 123456, + { strategies: [] } + ); + validation.hasInnerStrategies = true; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + }); + + describe('validateAddressType()', () => { + const VALID_EVM_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; + const VALID_STARKNET_ADDRESS = + '0x07f71118e351c02f6EC7099C8CDf93AED66CEd8406E94631cC91637f7D7F203A'; + const INVALID_ADDRESS = 'invalidAddress'; + + it('should not throw error for valid EVM address when evm protocol is supported', async () => { + const validation = new TestValidation( + VALID_EVM_ADDRESS, + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['evm']; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + + it('should not throw error for valid Starknet address when starknet protocol is supported', async () => { + const validation = new TestValidation( + VALID_STARKNET_ADDRESS, + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['starknet']; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + + it('should not throw error for valid EVM address when both protocols are supported', async () => { + const validation = new TestValidation( + VALID_EVM_ADDRESS, + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['evm', 'starknet']; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + + it('should not throw error for valid Starknet address when both protocols are supported', async () => { + const validation = new TestValidation( + VALID_STARKNET_ADDRESS, + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['evm', 'starknet']; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + + it('should not throw error for valid EVM address when only starknet protocol is supported', async () => { + const validation = new TestValidation( + VALID_EVM_ADDRESS, + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['starknet']; + + await expect(validation.validate()).resolves.not.toThrow(); + }); + + it('should return false for valid Starknet address when only evm protocol is supported', async () => { + const validation = new TestValidation( + VALID_STARKNET_ADDRESS, + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['evm']; + + const result = await validation.validate(); + expect(result).toBe(false); + }); + + it('should return false for invalid address when evm protocol is supported', async () => { + const validation = new TestValidation( + INVALID_ADDRESS, + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['evm']; + + const result = await validation.validate(); + expect(result).toBe(false); + }); + + it('should return false for invalid address when starknet protocol is supported', async () => { + const validation = new TestValidation( + INVALID_ADDRESS, + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['starknet']; + + const result = await validation.validate(); + expect(result).toBe(false); + }); + + it('should return false for invalid address when both protocols are supported', async () => { + const validation = new TestValidation( + INVALID_ADDRESS, + 'test-space', + '1', + 123456, + {} + ); + validation.supportedProtocols = ['evm', 'starknet']; + + const result = await validation.validate(); + expect(result).toBe(false); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 709472bf3..cc30f96b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,6 @@ "allowSyntheticDefaultImports": true, "skipLibCheck": true }, - "include": ["src"], + "include": ["src", "test"], "files": ["./src/typings.d.ts"] }