diff --git a/packages/ethernaut-optigov/README.md b/packages/ethernaut-optigov/README.md index efa40c2..080d11a 100644 --- a/packages/ethernaut-optigov/README.md +++ b/packages/ethernaut-optigov/README.md @@ -33,6 +33,10 @@ This plugin doesn't depend on any other plugins. This plugin adds the tasks listed below. - login Logs in to the Agora RetroPGF API with SIWE (Sign in with Ethereum) +- projects Prints a list of projects registered in RetroPGF, given specified filters +- proposals Prints a list of proposals registered in RetroPGF, given specified filters +- delegate Prints a list of delegates on Agora, or details a specific delegate, with optional related data (votes or delegators) + ## Environment extensions diff --git a/packages/ethernaut-optigov/src/internal/agora/Delegates.js b/packages/ethernaut-optigov/src/internal/agora/Delegates.js new file mode 100644 index 0000000..23c359b --- /dev/null +++ b/packages/ethernaut-optigov/src/internal/agora/Delegates.js @@ -0,0 +1,132 @@ +const debug = require('ethernaut-common/src/ui/debug') + +class Delegates { + constructor(agora) { + this.agora = agora + } + + // Get a list of delegates with pagination + async getDelegates({ limit = 10, offset = 0, sort } = {}) { + try { + const axiosInstance = this.agora.createAxiosInstance() + const response = await axiosInstance.get('/delegates', { + params: { limit, offset, sort }, + }) + + debug.log(`Delegates: ${response.data.data}`, 'ethernaut-optigov') + return response.data.data.map((delegate) => ({ + address: delegate.address, + votingPower: delegate.votingPower?.total, + twitter: delegate.statement?.payload?.twitter, + discord: delegate.statement?.payload?.discord, + delegateStatement: + delegate.statement?.payload?.delegateStatement?.substring(0, 100), + })) + } catch (error) { + this.agora.handleError(error) + } + } + + // Get a specific delegate by address or ENS name + async getDelegateById(addressOrEnsName) { + try { + const axiosInstance = this.agora.createAxiosInstance() + const response = await axiosInstance.get(`/delegates/${addressOrEnsName}`) + + debug.log(`Delegate: ${response.data}`, 'ethernaut-optigov') + const data = response.data + return { + address: data.address, + votingPower: { + advanced: data.votingPower.advanced, + direct: data.votingPower.direct, + total: data.votingPower.total, + }, + votingPowerRelativeToVotableSupply: + data.votingPowerRelativeToVotableSupply, + votingPowerRelativeToQuorum: data.votingPowerRelativeToQuorum, + proposalsCreated: data.proposalsCreated, + proposalsVotedOn: data.proposalsVotedOn, + votedFor: data.votedFor, + votedAgainst: data.votedAgainst, + votedAbstain: data.votedAbstain, + votingParticipation: data.votingParticipation, + lastTenProps: data.lastTenProps, + numOfDelegators: data.numOfDelegators, + } + } catch (error) { + this.agora.handleError(error) + } + } + + // Get a paginated list of votes for a specific delegate + async getDelegateVotes({ + addressOrEnsName, + limit = 10, + offset = 0, + sort, + } = {}) { + try { + const axiosInstance = this.agora.createAxiosInstance() + const response = await axiosInstance.get( + `/delegates/${addressOrEnsName}/votes`, + { + params: { limit, offset, sort }, + }, + ) + + debug.log( + `Votes for Delegate ${addressOrEnsName}: ${response.data.data}`, + 'ethernaut-optigov', + ) + return response.data.data.map((vote) => ({ + transactionHash: vote.transactionHash, + proposalId: vote.proposalId, + address: vote.address, + support: vote.support, + reason: vote.reason, + weight: vote.weight, + proposalValue: vote.proposalValue, + proposalTitle: vote.proposalTitle, + proposalType: vote.proposalType, + timestamp: vote.timestamp, + })) + } catch (error) { + this.agora.handleError(error) + } + } + + // Get a paginated list of delegators for a specific delegate + async getDelegateDelegators({ + addressOrEnsName, + limit = 10, + offset = 0, + sort, + } = {}) { + try { + const axiosInstance = this.agora.createAxiosInstance() + const response = await axiosInstance.get( + `/delegates/${addressOrEnsName}/delegators`, + { + params: { limit, offset, sort }, + }, + ) + + debug.log( + `Delegators for Delegate ${addressOrEnsName}: ${response.data.data}`, + 'ethernaut-optigov', + ) + return response.data.data.map((delegator) => ({ + from: delegator.from, + allowance: delegator.allowance, + timestamp: delegator.timestamp, + type: delegator.type, + amount: delegator.amount, + })) + } catch (error) { + this.agora.handleError(error) + } + } +} + +module.exports = Delegates diff --git a/packages/ethernaut-optigov/src/tasks/Delegates.js b/packages/ethernaut-optigov/src/tasks/Delegates.js new file mode 100644 index 0000000..0b1c9ec --- /dev/null +++ b/packages/ethernaut-optigov/src/tasks/Delegates.js @@ -0,0 +1,143 @@ +const types = require('ethernaut-common/src/validation/types') +const output = require('ethernaut-common/src/ui/output') +const Delegates = require('../internal/agora/Delegates') +const Agora = require('../internal/agora/Agora') + +const RELATED_DATA = { + votes: 'votes', + delegators: 'delegators', + none: 'none', +} + +require('../scopes/optigov') + .task( + 'delegates', + 'Prints a list of delegates on Agora, or a specific delegate, with optional votes or delegator related data', + ) + .addOptionalParam( + 'limit', + 'The maximum number of delegates to fetch. Defaults to 10.', + 10, + types.int, + ) + .addOptionalParam( + 'offset', + 'The number of delegates to skip before starting to fetch. Defaults to 0.', + 0, + types.int, + ) + .addOptionalParam( + 'address', + 'The address or ENS name of a specific delegate to query.', + undefined, + types.string, + ) + .addOptionalParam( + 'relatedData', + 'If specified, fetch additional related data such as votes or delegators for the given address or ENS name.', + RELATED_DATA.none, + types.string, + ) + .setAction(async ({ limit, offset, address, relatedData }) => { + try { + const agora = new Agora() + const delegates = new Delegates(agora) + + if (address) { + if (relatedData === RELATED_DATA.votes) { + // Get votes + const delegateVotes = await delegates.getDelegateVotes({ + addressOrEnsName: address, + limit, + offset, + }) + return output.resultBox( + printVotes(delegateVotes), + `Votes for Delegate ${address}`, + ) + } else if (relatedData === RELATED_DATA.delegators) { + // Get delegators + const delegateDelegators = await delegates.getDelegateDelegators({ + addressOrEnsName: address, + limit, + offset, + }) + return output.resultBox( + printDelegators(delegateDelegators), + `Delegators for Delegate ${address}`, + ) + } else { + // Get the specific delegate + const delegate = await delegates.getDelegateById(address) + return output.resultBox( + printDelegate(delegate), + `Delegate ${address}`, + ) + } + } + + // If no specific address or ENS is given, fetch the list of delegates + const delegateList = await delegates.getDelegates({ limit, offset }) + + return output.resultBox(printDelegates(delegateList), 'Delegates') + } catch (err) { + return output.errorBox(err) + } + }) + +function printDelegates(delegates) { + const strs = [] + + for (const delegate of delegates) { + strs.push( + `Address: ${delegate.address} + Voting Power: ${delegate.votingPower} + Twitter: ${delegate.twitter} + Discord: ${delegate.discord} + Statement: ${delegate.delegateStatement}`, + ) + } + + return strs.join('\n\n') +} + +function printDelegate(delegate) { + return `Address: ${delegate.address} + Voting Power (Advanced): ${delegate.votingPower.advanced} + Voting Power (Direct): ${delegate.votingPower.direct} + Voting Power (Total): ${delegate.votingPower.total} + Voting Power Relative to Votable Supply: ${delegate.votingPowerRelativeToVotableSupply} + Voting Power Relative to Quorum: ${delegate.votingPowerRelativeToQuorum} + Proposals Created: ${delegate.proposalsCreated} + Proposals Voted On: ${delegate.proposalsVotedOn} + Voted For: ${delegate.votedFor} + Voted Against: ${delegate.votedAgainst} + Voted Abstain: ${delegate.votedAbstain} + Voting Participation: ${delegate.votingParticipation} + Last Ten Proposals: ${delegate.lastTenProps} + Number of Delegators: ${delegate.numOfDelegators}` +} + +function printVotes(votes) { + const strs = [] + + for (const vote of votes) { + strs.push( + ` - Support: ${vote.support}, Weight: ${vote.weight}, Proposal: ${vote.proposalTitle} (ID: ${vote.proposalId}), Timestamp: ${vote.timestamp}`, + ) + } + + return strs.join('\n\n') +} + +function printDelegators(delegators) { + const strs = [] + + for (const delegator of delegators) { + strs.push( + ` - From: ${delegator.from}, Allowance: ${delegator.allowance}, Type: ${delegator.type}, Amount: ${delegator.amount}, Timestamp: ${delegator.timestamp}`, + ) + } + + return strs.join('\n\n') +} diff --git a/packages/ethernaut-optigov/test/tasks/delegates.test.js b/packages/ethernaut-optigov/test/tasks/delegates.test.js new file mode 100644 index 0000000..950688f --- /dev/null +++ b/packages/ethernaut-optigov/test/tasks/delegates.test.js @@ -0,0 +1,175 @@ +const assert = require('assert') +const Delegates = require('../../src/internal/agora/Delegates') +const hre = require('hardhat') +const output = require('ethernaut-common/src/ui/output') + +describe('delegates task', function () { + let originalGetDelegates, + originalGetDelegateById, + originalGetDelegateVotes, + originalGetDelegateDelegators, + originalOutputResultBox, + originalOutputErrorBox + + beforeEach(function () { + // Mock Delegates class methods + originalGetDelegates = Delegates.prototype.getDelegates + originalGetDelegateById = Delegates.prototype.getDelegateById + originalGetDelegateVotes = Delegates.prototype.getDelegateVotes + originalGetDelegateDelegators = Delegates.prototype.getDelegateDelegators + + Delegates.prototype.getDelegates = async function ({ + limit: _limit, + offset: _offset, + }) { + return [ + { + address: '0xabc123', + votingPower: 1000, + twitter: '@delegate1', + discord: 'delegate1#1234', + delegateStatement: 'This is a delegate statement...', + }, + { + address: '0xdef456', + votingPower: 2000, + twitter: '@delegate2', + discord: 'delegate2#5678', + delegateStatement: 'Another delegate statement...', + }, + ] + } + + Delegates.prototype.getDelegateById = async function (address) { + return { + address: address, + votingPower: { advanced: 500, direct: 300, total: 800 }, + votingPowerRelativeToVotableSupply: 0.1, + votingPowerRelativeToQuorum: 0.05, + proposalsCreated: 5, + proposalsVotedOn: 10, + votedFor: 7, + votedAgainst: 2, + votedAbstain: 1, + votingParticipation: 90, + lastTenProps: 10, + numOfDelegators: 15, + } + } + + Delegates.prototype.getDelegateVotes = async function ({ + address, + limit: _limit, + offset: _offset, + }) { + return [ + { + transactionHash: '0x123', + proposalId: '1', + address: address, + support: 'FOR', + reason: 'Supportive vote', + weight: '100', + proposalValue: '500', + proposalTitle: 'Proposal Title', + proposalType: 'STANDARD', + timestamp: '2024-10-28T10:57:57.005Z', + }, + ] + } + + Delegates.prototype.getDelegateDelegators = async function ({ + address: _address, + limit: _limit, + offset: _offset, + }) { + return [ + { + from: '0xaaa111', + allowance: '100000000000000000000000', + timestamp: '2024-01-17T19:37:15.983Z', + type: 'DIRECT', + amount: 'FULL', + }, + ] + } + + // Mock the output methods + originalOutputResultBox = output.resultBox + originalOutputErrorBox = output.errorBox + + output.resultBox = (content, title) => `${title}: ${content}` + output.errorBox = (error) => `Error: ${error.message}` + }) + + afterEach(function () { + // Restore the original methods after each test + Delegates.prototype.getDelegates = originalGetDelegates + Delegates.prototype.getDelegateById = originalGetDelegateById + Delegates.prototype.getDelegateVotes = originalGetDelegateVotes + Delegates.prototype.getDelegateDelegators = originalGetDelegateDelegators + + output.resultBox = originalOutputResultBox + output.errorBox = originalOutputErrorBox + }) + + it('fetches a list of delegates with limit and offset', async function () { + const result = await hre.run( + { scope: 'optigov', task: 'delegates' }, + { limit: 2, offset: 0 }, + ) + assert.equal( + result, + 'Delegates: Address: 0xabc123\n Voting Power: 1000\n Twitter: @delegate1\n Discord: delegate1#1234\n Statement: This is a delegate statement...\n\nAddress: 0xdef456\n Voting Power: 2000\n Twitter: @delegate2\n Discord: delegate2#5678\n Statement: Another delegate statement...', + ) + }) + + it('fetches a specific delegate by address or ENS name', async function () { + const address = '0xabc123' + const result = await hre.run( + { scope: 'optigov', task: 'delegates' }, + { address }, + ) + assert.equal( + result, + `Delegate ${address}: Address: 0xabc123\n Voting Power (Advanced): 500\n Voting Power (Direct): 300\n Voting Power (Total): 800\n Voting Power Relative to Votable Supply: 0.1\n Voting Power Relative to Quorum: 0.05\n Proposals Created: 5\n Proposals Voted On: 10\n Voted For: 7\n Voted Against: 2\n Voted Abstain: 1\n Voting Participation: 90\n Last Ten Proposals: 10\n Number of Delegators: 15`, + ) + }) + + it('fetches votes for a specific delegate when relatedData is set to votes', async function () { + const address = '0xabc123' + const result = await hre.run( + { scope: 'optigov', task: 'delegates' }, + { address, relatedData: 'votes' }, + ) + assert.equal( + result, + 'Votes for Delegate 0xabc123: - Support: FOR, Weight: 100, Proposal: Proposal Title (ID: 1), Timestamp: 2024-10-28T10:57:57.005Z', + ) + }) + + it('fetches delegators for a specific delegate when relatedData is set to delegators', async function () { + const address = '0xabc123' + const result = await hre.run( + { scope: 'optigov', task: 'delegates' }, + { address, relatedData: 'delegators' }, + ) + assert.equal( + result, + 'Delegators for Delegate 0xabc123: - From: 0xaaa111, Allowance: 100000000000000000000000, Type: DIRECT, Amount: FULL, Timestamp: 2024-01-17T19:37:15.983Z', + ) + }) + + it('handles errors gracefully when fetching delegates fails', async function () { + Delegates.prototype.getDelegates = async function () { + throw new Error('Failed to fetch delegates') + } + + const result = await hre.run( + { scope: 'optigov', task: 'delegates' }, + { limit: 2, offset: 0 }, + ) + + assert.equal(result, 'Error: Failed to fetch delegates') + }) +})