diff --git a/packages/connect-core/src/index.ts b/packages/connect-core/src/index.ts index 13b330f0..66111250 100644 --- a/packages/connect-core/src/index.ts +++ b/packages/connect-core/src/index.ts @@ -22,6 +22,7 @@ export { ConnectionContext, IpfsResolver, AppData, + AppMethod, ForwardingPathData, PermissionData, RepoData, diff --git a/packages/connect-core/src/types.ts b/packages/connect-core/src/types.ts index 30c36730..d7042e27 100644 --- a/packages/connect-core/src/types.ts +++ b/packages/connect-core/src/types.ts @@ -137,6 +137,7 @@ export type Metadata = (AragonArtifact | AragonManifest)[] export interface AppMethod { roles: string[] sig: string + params?: any[] /** * This field might not be able if the contract does not use * conventional solidity syntax and Aragon naming standards diff --git a/packages/connect-core/src/utils/app.ts b/packages/connect-core/src/utils/app.ts index 79e1b2ea..f670f559 100644 --- a/packages/connect-core/src/utils/app.ts +++ b/packages/connect-core/src/utils/app.ts @@ -8,6 +8,10 @@ export const apmAppId = (appName: string): string => ethersUtils.namehash(`${appName}.aragonpm.eth`) function signatureFromAbi(signature: string, abi: Abi): string { + if (signature === 'fallback') { + return 'fallback()' + } + const matches = signature.match(/(.*)\((.*)\)/m) if (!matches) { @@ -44,7 +48,7 @@ function findAppMethod( if (Array.isArray(functions)) { method = functions .map((f) => { - return { ...f, sig: signatureFromAbi(f.sig, app.abi) } + return { ...f, sig: signatureFromAbi(f.sig, app.abi), params: [] } }) .find(methodTestFn) } @@ -79,12 +83,21 @@ export function findAppMethodFromData( { allowDeprecated = true } = {} ): AppMethod | undefined { const methodId = data.substring(0, 10) - return findAppMethod( + const appMethod = findAppMethod( app, (method: AppMethod) => ethersUtils.id(method.sig).substring(0, 10) === methodId, { allowDeprecated } ) + + // Decode method's parameters + if (appMethod?.abi) { + const inputTypes = appMethod.abi.inputs.map(({ type }) => type) + + appMethod.params = [...ethersUtils.defaultAbiCoder.decode(inputTypes, `0x${data.slice(10)}`)] + } + + return appMethod } /** diff --git a/packages/connect-voting/src/__test__/votes.test.ts b/packages/connect-voting/src/__test__/votes.test.ts index 2907ad63..1082bcb1 100644 --- a/packages/connect-voting/src/__test__/votes.test.ts +++ b/packages/connect-voting/src/__test__/votes.test.ts @@ -1,11 +1,19 @@ +import { BigNumber } from 'ethers' +import { App, connect } from '@aragon/connect' import { VotingConnectorTheGraph, Vote, Cast } from '../../src' +import { Action, VoteStatus } from '../types' const VOTING_SUBGRAPH_URL = - 'https://api.thegraph.com/subgraphs/name/aragon/aragon-voting-rinkeby-staging' + 'https://api.thegraph.com/subgraphs/name/aragon/aragon-voting-rinkeby' + const VOTING_APP_ADDRESS = '0x37187b0f2089b028482809308e776f92eeb7334e' +// For testing vote action functionality +const ACTIONS_ORG_ADDRESS = "0x63210F64Ef6F4EBB9727F6c5665CB8bbeDf20480" +const ACTIONS_VOTING_APP_ADDRESS = '0x9943c2f55d91308b8ddbc58b6e70d1774ace125e' describe('when connecting to a voting app', () => { let connector: VotingConnectorTheGraph + let votes: Vote[] beforeAll(() => { connector = new VotingConnectorTheGraph({ @@ -18,8 +26,6 @@ describe('when connecting to a voting app', () => { }) describe('when querying for all the votes of a voting app', () => { - let votes: Vote[] - beforeAll(async () => { votes = await connector.votesForApp(VOTING_APP_ADDRESS, 1000, 0) }) @@ -87,6 +93,18 @@ describe('when connecting to a voting app', () => { expect(vote.startDate).toEqual('1599675534') }) + test('should have a valid endDate', () => { + expect(vote.endDate).toEqual('1600280334') + }) + + test('should have not be accepted', () => { + expect(vote.isAccepted).toBe(false) + }) + + test('should have a valid status', () => { + expect(vote.status).toEqual(VoteStatus.Rejected) + }) + describe('when querying for the casts of a vote', () => { let casts: Cast[] @@ -100,4 +118,76 @@ describe('when connecting to a voting app', () => { }) }) }) + + describe("when looking at the votes actions of a voting app", () => { + let installedApps: App[] + let signallingVoteActions: Action[] + let codeExecutionVoteActions: Action[] + let voteActions: Action[] + + beforeAll(async () => { + const org = await connect(ACTIONS_ORG_ADDRESS, "thegraph", { network: 4 }) + installedApps = await org.apps() + connector = new VotingConnectorTheGraph({ + subgraphUrl: VOTING_SUBGRAPH_URL, + }) + votes = await connector.votesForApp(ACTIONS_VOTING_APP_ADDRESS, 1000, 0) + + codeExecutionVoteActions = votes[0].getActions(installedApps) + signallingVoteActions = votes[1].getActions(installedApps) + voteActions = votes[4].getActions(installedApps) + }) + + test("should return a list of actions", () => { + expect(voteActions.length).toBeGreaterThan(0) + }) + + test("shouldn't return anything when getting actions from a signaling vote", () => { + expect(signallingVoteActions).toEqual([]) + }) + + test("shouldn't return rewards when getting actions from a vote that only executes code", () => { + const action = codeExecutionVoteActions[0] + expect(action.rewards).toEqual([]) + }) + + describe("when looking at a specific vote's action and reward", () => { + let rewardedAction: Action + + beforeAll(() => { + rewardedAction = voteActions[0] + }) + + test('should have a valid to (target contract address)', () => { + expect(rewardedAction.to).toEqual("0xcaa6526abb106ff5c5f937e3ea9499243df86b7a") + }) + + test("should have a valid fnData", () => { + const { abi, notice, params, roles, sig } = rewardedAction.fnData! + + expect(Object.keys(abi!).length).toBeGreaterThan(0) + expect(notice).toEqual("Create a new payment of `@tokenAmount(_token, _amount)` to `_receiver` for '`_reference`'") + expect(params!).toEqual(['0x0000000000000000000000000000000000000000', + '0x9943c2f55D91308B8DDbc58B6e70d1774AcE125e', BigNumber.from('3000000000000000000'), "\"reference\""]) + expect(roles).toEqual([ 'CREATE_PAYMENTS_ROLE' ]) + expect(sig).toEqual("newImmediatePayment(address,address,uint256,string)") + }) + + test("should have a list of rewards", () => { + expect(rewardedAction.rewards.length).toBeGreaterThan(0) + }) + + test("should have a valid reward", () => { + const reward = rewardedAction.rewards[0] + const { amount, token, receiver } = reward + const ETH = '0x0000000000000000000000000000000000000000' + + expect(amount).toEqual('3000000000000000000') + expect(token).toEqual(ETH) + expect(receiver).toEqual('0x9943c2f55D91308B8DDbc58B6e70d1774AcE125e') + }) + }) + + }) + }) diff --git a/packages/connect-voting/src/helpers/actions.ts b/packages/connect-voting/src/helpers/actions.ts new file mode 100644 index 00000000..33490ceb --- /dev/null +++ b/packages/connect-voting/src/helpers/actions.ts @@ -0,0 +1,46 @@ +import { utils } from 'ethers' +import { AppMethod } from "@aragon/connect" +import { Reward } from '../types' + +export const getRewards = (appId: string, fnData: AppMethod): Reward[] => { + const {params, sig } = fnData + + if (!params || !params.length) { + return [] + } + + const sigHash = utils.id(sig).substring(0, 10) + + switch (appId) { + // finance.aragonpm.eth + case '0xbf8491150dafc5dcaee5b861414dca922de09ccffa344964ae167212e8c673ae': { + switch (sigHash) { + // newImmediatePayment(address,address,uint256,string) + case '0xf6364846': + return [{ + receiver: params[1], + token: params[0], + amount: params[2].toString() + }] + } + break + } + // agent.aragonpm.eth + case '0x9ac98dc5f995bf0211ed589ef022719d1487e5cb2bab505676f0d084c07cf89a': + // vault.aragonpm.eth + // eslint-disable-next-line no-fallthrough + case '0x7e852e0fcfce6551c13800f1e7476f982525c2b5277ba14b24339c68416336d1': + switch (sigHash) { + // transfer(address,address,uint256) + case '0xbeabacc8': + return [{ + receiver: params[1], + token: params[0], + amount: params[2].toString(), + }] + } + break + } + + return [] +} diff --git a/packages/connect-voting/src/helpers/index.ts b/packages/connect-voting/src/helpers/index.ts new file mode 100644 index 00000000..cd066d7c --- /dev/null +++ b/packages/connect-voting/src/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './numbers' +export * from './actions' +export * from './time' diff --git a/packages/connect-voting/src/helpers/numbers.ts b/packages/connect-voting/src/helpers/numbers.ts new file mode 100644 index 00000000..3d9e50ee --- /dev/null +++ b/packages/connect-voting/src/helpers/numbers.ts @@ -0,0 +1,3 @@ +import { BigNumber } from 'ethers' + +export const bn = (x: string | number): BigNumber => BigNumber.from(x.toString()) diff --git a/packages/connect-voting/src/helpers/time.ts b/packages/connect-voting/src/helpers/time.ts new file mode 100644 index 00000000..ae6ad5ca --- /dev/null +++ b/packages/connect-voting/src/helpers/time.ts @@ -0,0 +1,4 @@ +import { BigNumber } from 'ethers' +import { bn } from './numbers' + +export const currentTimestampEvm = (): BigNumber => bn(Math.floor(Date.now() / 1000)) diff --git a/packages/connect-voting/src/models/Vote.ts b/packages/connect-voting/src/models/Vote.ts index 8b20b96c..6be1806c 100644 --- a/packages/connect-voting/src/models/Vote.ts +++ b/packages/connect-voting/src/models/Vote.ts @@ -1,7 +1,9 @@ +import { decodeCallScript, findAppMethodFromData, App } from '@aragon/connect' +import { addressesEqual, subscription } from '@aragon/connect-core' import { SubscriptionCallback, SubscriptionResult } from '@aragon/connect-types' -import { subscription } from '@aragon/connect-core' -import { IVotingConnector, VoteData } from '../types' import Cast from './Cast' +import { Action, IVotingConnector, VoteData, VoteStatus } from '../types' +import { bn, currentTimestampEvm, getRewards } from '../helpers' export default class Vote { #connector: IVotingConnector @@ -13,6 +15,7 @@ export default class Vote { readonly executed: boolean readonly executedAt: string readonly startDate: string + readonly endDate: string readonly snapshotBlock: string readonly supportRequiredPct: string readonly minAcceptQuorum: string @@ -20,6 +23,8 @@ export default class Vote { readonly nay: string readonly votingPower: string readonly script: string + readonly isAccepted: boolean + constructor(data: VoteData, connector: IVotingConnector) { this.#connector = connector @@ -31,6 +36,7 @@ export default class Vote { this.executed = data.executed this.executedAt = data.executedAt this.startDate = data.startDate + this.endDate = data.endDate this.snapshotBlock = data.snapshotBlock this.supportRequiredPct = data.supportRequiredPct this.minAcceptQuorum = data.minAcceptQuorum @@ -38,6 +44,44 @@ export default class Vote { this.nay = data.nay this.votingPower = data.votingPower this.script = data.script + this.isAccepted = data.isAccepted + } + + get status(): VoteStatus { + const currentTimestamp = currentTimestampEvm() + + if (!this.executed) { + if (currentTimestamp.gte(bn(this.endDate))) { + return this.isAccepted ? VoteStatus.Accepted : VoteStatus.Rejected + } + + return VoteStatus.Ongoing + } + + return VoteStatus.Executed + } + + getActions(installedApps: App[]): Action[] { + const rawActions = decodeCallScript(this.script) + + return rawActions.map(({ to, data}): Action => { + const targetApp = installedApps.find(app => addressesEqual(app.address, to)) + const fnData = targetApp ? findAppMethodFromData(targetApp, data) : undefined + + // Check targetApp again to avoid typescript undefined warnings below + if (!targetApp || !fnData) { + return { + to, + rewards: [] + } + } + + return { + to, + fnData: findAppMethodFromData(targetApp, data), + rewards: getRewards(targetApp.appId, fnData) + } + }) } async casts({ first = 1000, skip = 0 } = {}): Promise { diff --git a/packages/connect-voting/src/thegraph/queries/index.ts b/packages/connect-voting/src/thegraph/queries/index.ts index a38aea9b..2bce0bbf 100644 --- a/packages/connect-voting/src/thegraph/queries/index.ts +++ b/packages/connect-voting/src/thegraph/queries/index.ts @@ -14,12 +14,14 @@ export const ALL_VOTES = (type: string) => gql` executed executedAt startDate + endDate snapshotBlock supportRequiredPct minAcceptQuorum yea nay votingPower + isAccepted script } } @@ -39,12 +41,14 @@ export const CASTS_FOR_VOTE = (type: string) => gql` executed executedAt startDate + endDate snapshotBlock supportRequiredPct minAcceptQuorum yea nay votingPower + isAccepted script } voter { diff --git a/packages/connect-voting/src/types.ts b/packages/connect-voting/src/types.ts index e818c9a6..50d15ecd 100644 --- a/packages/connect-voting/src/types.ts +++ b/packages/connect-voting/src/types.ts @@ -1,10 +1,19 @@ +import { AppMethod } from '@aragon/connect-core' import { + Address, SubscriptionCallback, SubscriptionHandler, } from '@aragon/connect-types' import Vote from './models/Vote' import Cast from './models/Cast' +export enum VoteStatus { + Accepted = "Accepted", + Executed = "Executed", + Ongoing = "Ongoing", + Rejected = "Rejected", +} + export interface VoteData { id: string creator: string @@ -13,6 +22,7 @@ export interface VoteData { executed: boolean executedAt: string startDate: string + endDate: string snapshotBlock: string supportRequiredPct: string minAcceptQuorum: string @@ -20,6 +30,7 @@ export interface VoteData { nay: string votingPower: string script: string + isAccepted: boolean } export interface CastData { @@ -36,6 +47,18 @@ export interface VoterData { address: string } +export interface Reward { + receiver: Address + token: Address + amount: string +} + +export interface Action { + to: Address + fnData?: AppMethod + rewards: Reward[] +} + export interface IVotingConnector { disconnect(): Promise votesForApp(appAddress: string, first: number, skip: number): Promise diff --git a/packages/connect-voting/subgraph/schema.graphql b/packages/connect-voting/subgraph/schema.graphql index a58832f1..d975b317 100644 --- a/packages/connect-voting/subgraph/schema.graphql +++ b/packages/connect-voting/subgraph/schema.graphql @@ -8,6 +8,7 @@ type Vote @entity { executed: Boolean! executedAt: BigInt! startDate: BigInt! + endDate: BigInt! snapshotBlock: BigInt! supportRequiredPct: BigInt! minAcceptQuorum: BigInt! @@ -16,6 +17,7 @@ type Vote @entity { votingPower: BigInt! script: String! voteNum: BigInt! + isAccepted: Boolean! castVotes: [Cast!] @derivedFrom(field: "vote") } diff --git a/packages/connect-voting/subgraph/src/Voting.ts b/packages/connect-voting/subgraph/src/Voting.ts index 72b4b7ac..d6692d01 100644 --- a/packages/connect-voting/subgraph/src/Voting.ts +++ b/packages/connect-voting/subgraph/src/Voting.ts @@ -25,6 +25,7 @@ export function handleStartVote(event: StartVoteEvent): void { vote.metadata = event.params.metadata vote.voteNum = event.params.voteId vote.startDate = voteData.value2 + vote.endDate = vote.startDate.plus(voting.voteTime()) vote.snapshotBlock = voteData.value3 vote.supportRequiredPct = voteData.value4 vote.minAcceptQuorum = voteData.value5 @@ -35,7 +36,7 @@ export function handleStartVote(event: StartVoteEvent): void { vote.orgAddress = voting.kernel() vote.executedAt = BigInt.fromI32(0) vote.executed = false - + vote.isAccepted = isAccepted(vote.yea, vote.nay, vote.votingPower, vote.supportRequiredPct, vote.minAcceptQuorum, voting.PCT_BASE()) vote.save() } @@ -118,6 +119,16 @@ export function updateVoteState(votingAddress: Address, voteId: BigInt): void { const vote = VoteEntity.load(buildVoteEntityId(votingAddress, voteId))! vote.yea = voteData.value6 vote.nay = voteData.value7 + vote.isAccepted = isAccepted(vote.yea, vote.nay, vote.votingPower, vote.supportRequiredPct, vote.minAcceptQuorum, votingApp.PCT_BASE()) vote.save() } + +function isAccepted(yeas: BigInt, nays: BigInt, votingPower: BigInt, supportRequiredPct: BigInt, minimumAcceptanceQuorumPct: BigInt, pctBase: BigInt): boolean { + return hasReachedValuePct(yeas, yeas.plus(nays), supportRequiredPct, pctBase) && + hasReachedValuePct(yeas, votingPower, minimumAcceptanceQuorumPct, pctBase) +} + +function hasReachedValuePct(value: BigInt, total: BigInt, pct: BigInt, pctBase: BigInt): boolean { + return total.notEqual(BigInt.fromI32(0)) && (value.times(pctBase).div(total)).gt(pct) +}