From 4a42767723101715accb118b021c26b969ce24d3 Mon Sep 17 00:00:00 2001 From: Coderdan Date: Mon, 20 Apr 2026 10:27:46 +0800 Subject: [PATCH] Add AGT treasury withdrawal vote script --- scripts/aragon/agtTreasuryWithdrawalVote.ts | 134 ++++++++++++++++ .../aragon/createTreasuryWithdrawalVote.ts | 136 +++++++++++++++++ test/agtTreasuryWithdrawalVoteScript.test.ts | 144 ++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 scripts/aragon/agtTreasuryWithdrawalVote.ts create mode 100644 scripts/aragon/createTreasuryWithdrawalVote.ts create mode 100644 test/agtTreasuryWithdrawalVoteScript.test.ts diff --git a/scripts/aragon/agtTreasuryWithdrawalVote.ts b/scripts/aragon/agtTreasuryWithdrawalVote.ts new file mode 100644 index 00000000..784e344a --- /dev/null +++ b/scripts/aragon/agtTreasuryWithdrawalVote.ts @@ -0,0 +1,134 @@ +import { BigNumberish, Signer, providers, utils } from "ethers"; + +export interface CallScriptAction { + to: string; + data: string; +} + +export interface AgtTreasuryDaoConfig { + tokenManager: string; + voting: string; + finance: string; + vault: string; + dai: string; + agt: string; +} + +export interface BuildAgtTreasuryWithdrawalVoteParams { + recipient: string; + amount: BigNumberish; + metadata: string; + reference: string; + token?: string; +} + +export interface BuiltAgtTreasuryWithdrawalVote { + paymentCalldata: string; + executionScript: string; + newVoteCalldata: string; + forwardScript: string; + tx: providers.TransactionRequest; +} + +export const CALLS_SCRIPT_SPEC_ID = "0x00000001"; + +export const AGT_TREASURY_DAO: AgtTreasuryDaoConfig = { + tokenManager: utils.getAddress("0x8eFea71B63DB02C00229794d90ec4ba8ecD4Ea81"), + voting: utils.getAddress("0x93CF86a83bAA323407857C0A25e768869E12C721"), + finance: utils.getAddress("0x4f5762334100f6a80d35a616ef9df71b0452cf44"), + vault: utils.getAddress("0xFFE6280ae4E864D9aF836B562359FD828EcE8020"), + dai: utils.getAddress("0x6B175474E89094C44Da98b954EedeAC495271d0F"), + agt: utils.getAddress("0xb677136aC25e5e8763Eaf7f80D54de7D86313546"), +}; + +const financeInterface = new utils.Interface([ + "function newImmediatePayment(address,address,uint256,string)", +]); +const votingInterface = new utils.Interface([ + "function newVote(bytes,string,bool,bool)", +]); +const tokenManagerInterface = new utils.Interface([ + "function forward(bytes evmScript)", +]); + +export function encodeCallScript(actions: CallScriptAction[]): string { + const encodedActions = actions.map((action) => { + const target = utils.getAddress(action.to).slice(2).toLowerCase(); + const dataLength = utils + .hexZeroPad(utils.hexlify(utils.hexDataLength(action.data)), 4) + .slice(2); + + return `${target}${dataLength}${action.data.slice(2)}`; + }); + + return `${CALLS_SCRIPT_SPEC_ID}${encodedActions.join("")}`; +} + +export function decodeCallScript(script: string): CallScriptAction[] { + if (!script.startsWith(CALLS_SCRIPT_SPEC_ID)) { + throw new Error("Unsupported EVMScript spec"); + } + + const actions: CallScriptAction[] = []; + let offset = CALLS_SCRIPT_SPEC_ID.length; + + while (offset < script.length) { + const to = utils.getAddress(`0x${script.slice(offset, offset + 40)}`); + offset += 40; + + const length = parseInt(script.slice(offset, offset + 8), 16); + offset += 8; + + const data = `0x${script.slice(offset, offset + length * 2)}`; + offset += length * 2; + + actions.push({ to, data }); + } + + return actions; +} + +export function buildAgtTreasuryWithdrawalVote( + params: BuildAgtTreasuryWithdrawalVoteParams +): BuiltAgtTreasuryWithdrawalVote { + const token = utils.getAddress(params.token ?? AGT_TREASURY_DAO.dai); + const recipient = utils.getAddress(params.recipient); + + const paymentCalldata = financeInterface.encodeFunctionData( + "newImmediatePayment(address,address,uint256,string)", + [token, recipient, params.amount, params.reference] + ); + + const executionScript = encodeCallScript([ + { to: AGT_TREASURY_DAO.finance, data: paymentCalldata }, + ]); + + const newVoteCalldata = votingInterface.encodeFunctionData( + "newVote(bytes,string,bool,bool)", + [executionScript, params.metadata, false, true] + ); + + const forwardScript = encodeCallScript([ + { to: AGT_TREASURY_DAO.voting, data: newVoteCalldata }, + ]); + + return { + paymentCalldata, + executionScript, + newVoteCalldata, + forwardScript, + tx: { + to: AGT_TREASURY_DAO.tokenManager, + data: tokenManagerInterface.encodeFunctionData("forward", [forwardScript]), + value: 0, + }, + }; +} + +export async function createAgtTreasuryWithdrawalVote( + signer: Signer, + params: BuildAgtTreasuryWithdrawalVoteParams +) { + const built = buildAgtTreasuryWithdrawalVote(params); + return signer.sendTransaction(built.tx); +} diff --git a/scripts/aragon/createTreasuryWithdrawalVote.ts b/scripts/aragon/createTreasuryWithdrawalVote.ts new file mode 100644 index 00000000..9964d49c --- /dev/null +++ b/scripts/aragon/createTreasuryWithdrawalVote.ts @@ -0,0 +1,136 @@ +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +import { + AGT_TREASURY_DAO, + buildAgtTreasuryWithdrawalVote, + createAgtTreasuryWithdrawalVote, +} from "./agtTreasuryWithdrawalVote"; + +dotenv.config(); + +interface CliArgs { + [key: string]: string | boolean | undefined; +} + +function parseArgs(argv: string[]): CliArgs { + const parsed: CliArgs = {}; + + for (let i = 0; i < argv.length; i++) { + const current = argv[i]; + if (!current.startsWith("--")) continue; + + const key = current.slice(2); + const next = argv[i + 1]; + + if (!next || next.startsWith("--")) { + parsed[key] = true; + continue; + } + + parsed[key] = next; + i += 1; + } + + return parsed; +} + +function requireStringArg(args: CliArgs, key: string): string { + const value = args[key]; + if (!value || typeof value !== "string") { + throw new Error(`Missing required argument --${key}`); + } + return value; +} + +function getPrivateKey(args: CliArgs): string { + const fromArgs = args["private-key"]; + const raw = + (typeof fromArgs === "string" ? fromArgs : undefined) || + process.env.PRIVATE_KEY || + process.env.SECRET; + + if (!raw) { + throw new Error("Missing private key. Pass --private-key or set PRIVATE_KEY/SECRET."); + } + + return raw.startsWith("0x") ? raw : `0x${raw}`; +} + +function getAmount(args: CliArgs) { + const amountWei = args["amount-wei"]; + if (typeof amountWei === "string") { + return ethers.BigNumber.from(amountWei); + } + + const amount = requireStringArg(args, "amount"); + return ethers.utils.parseUnits(amount, 18); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const recipient = requireStringArg(args, "recipient"); + const amount = getAmount(args); + const token = + typeof args.token === "string" ? args.token : AGT_TREASURY_DAO.dai; + const metadata = + typeof args.metadata === "string" + ? args.metadata + : `Withdraw ${ethers.utils.formatUnits(amount, 18)} DAI from Treasury 2`; + const reference = + typeof args.reference === "string" ? args.reference : metadata; + + const built = buildAgtTreasuryWithdrawalVote({ + recipient, + amount, + metadata, + reference, + token, + }); + + console.log("Treasury withdrawal vote payload"); + console.log(JSON.stringify({ + tokenManager: AGT_TREASURY_DAO.tokenManager, + voting: AGT_TREASURY_DAO.voting, + finance: AGT_TREASURY_DAO.finance, + token, + recipient, + amountWei: amount.toString(), + metadata, + reference, + tx: built.tx, + executionScript: built.executionScript, + forwardScript: built.forwardScript, + }, null, 2)); + + if (!args.broadcast) { + console.log("\nDry run only. Re-run with --broadcast to submit the vote."); + return; + } + + const rpcUrl = + (typeof args["rpc-url"] === "string" ? args["rpc-url"] : undefined) || + process.env.MAINNET_URL; + if (!rpcUrl) { + throw new Error("Missing rpc url. Pass --rpc-url or set MAINNET_URL."); + } + + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + const signer = new ethers.Wallet(getPrivateKey(args), provider); + + const tx = await createAgtTreasuryWithdrawalVote(signer, { + recipient, + amount, + metadata, + reference, + token, + }); + + console.log(`\nSubmitted transaction: ${tx.hash}`); + const receipt = await tx.wait(); + console.log(`Mined in block ${receipt.blockNumber}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/agtTreasuryWithdrawalVoteScript.test.ts b/test/agtTreasuryWithdrawalVoteScript.test.ts new file mode 100644 index 00000000..410d7708 --- /dev/null +++ b/test/agtTreasuryWithdrawalVoteScript.test.ts @@ -0,0 +1,144 @@ +import { ethers, network } from "hardhat"; +import { expect } from "chai"; +import { + AGT_TREASURY_DAO, + buildAgtTreasuryWithdrawalVote, + createAgtTreasuryWithdrawalVote, + decodeCallScript, +} from "../scripts/aragon/agtTreasuryWithdrawalVote"; + +const PROPOSER = "0x027ffd3c119567e85998f4e6b9c3d83d5702660c"; +const RECIPIENT = "0x1111111111111111111111111111111111111111"; +const AMOUNT = "275000"; +const METADATA = "Withdraw DAI from Treasury 2"; +const REFERENCE = "Treasury 2 DAI withdrawal"; + +const votingAbi = [ + "event StartVote(uint256 indexed voteId, address indexed creator, string metadata)", + "function votesLength() view returns (uint256)", + "function getVote(uint256 voteId) view returns (bool open, bool executed, uint64 startDate, uint64 snapshotBlock, uint64 supportRequired, uint64 minAcceptQuorum, uint256 yea, uint256 nay, uint256 votingPower, bytes script)", +]; + +const tokenManagerAbi = [ + "function canForward(address sender, bytes evmScript) view returns (bool)", +]; + +describe("AGT treasury withdrawal vote script", function () { + this.timeout(300000); + + it("builds the expected nested Aragon scripts", async function () { + const built = buildAgtTreasuryWithdrawalVote({ + recipient: RECIPIENT, + amount: ethers.utils.parseUnits(AMOUNT, 18), + metadata: METADATA, + reference: REFERENCE, + }); + + expect(built.tx.to).to.equal(AGT_TREASURY_DAO.tokenManager); + + const [outerAction] = decodeCallScript(built.forwardScript); + expect(outerAction.to).to.equal(AGT_TREASURY_DAO.voting); + + const votingInterface = new ethers.utils.Interface([ + "function newVote(bytes,string,bool,bool)", + ]); + const [innerScript, metadata, castVote, executesIfDecided] = + votingInterface.decodeFunctionData("newVote(bytes,string,bool,bool)", outerAction.data); + + expect(metadata).to.equal(METADATA); + expect(castVote).to.equal(false); + expect(executesIfDecided).to.equal(true); + expect(innerScript).to.equal(built.executionScript); + + const [financeAction] = decodeCallScript(built.executionScript); + expect(financeAction.to).to.equal(AGT_TREASURY_DAO.finance); + + const financeInterface = new ethers.utils.Interface([ + "function newImmediatePayment(address,address,uint256,string)", + ]); + const [token, receiver, amount, reference] = financeInterface.decodeFunctionData( + "newImmediatePayment(address,address,uint256,string)", + financeAction.data + ); + + expect(token).to.equal(AGT_TREASURY_DAO.dai); + expect(receiver).to.equal(RECIPIENT); + expect(amount).to.equal(ethers.utils.parseUnits(AMOUNT, 18)); + expect(reference).to.equal(REFERENCE); + }); + + it("creates a real AGT treasury vote through the token manager on an Ethereum fork", async function () { + if (!process.env.MAINNET_URL) { + throw new Error("MAINNET_URL is required for the AGT treasury vote fork test"); + } + + const forking: { jsonRpcUrl: string; blockNumber?: number } = { + jsonRpcUrl: process.env.MAINNET_URL, + }; + if (process.env.MAINNET_FORK_BLOCK) { + forking.blockNumber = Number(process.env.MAINNET_FORK_BLOCK); + } + + await network.provider.request({ + method: "hardhat_reset", + params: [{ forking }], + }); + + await network.provider.request({ + method: "hardhat_impersonateAccount", + params: [PROPOSER], + }); + await network.provider.request({ + method: "hardhat_setBalance", + params: [PROPOSER, "0x1000000000000000000000"], + }); + + const signer = await ethers.getSigner(PROPOSER); + const voting = new ethers.Contract(AGT_TREASURY_DAO.voting, votingAbi, ethers.provider); + const tokenManager = new ethers.Contract( + AGT_TREASURY_DAO.tokenManager, + tokenManagerAbi, + ethers.provider + ); + + const built = buildAgtTreasuryWithdrawalVote({ + recipient: RECIPIENT, + amount: ethers.utils.parseUnits(AMOUNT, 18), + metadata: METADATA, + reference: REFERENCE, + }); + + expect(await tokenManager.canForward(PROPOSER, built.forwardScript)).to.equal(true); + + const beforeVotes = await voting.votesLength(); + const receipt = await ( + await createAgtTreasuryWithdrawalVote(signer, { + recipient: RECIPIENT, + amount: ethers.utils.parseUnits(AMOUNT, 18), + metadata: METADATA, + reference: REFERENCE, + }) + ).wait(); + const afterVotes = await voting.votesLength(); + + expect(afterVotes).to.equal(beforeVotes.add(1)); + + const voteId = afterVotes.sub(1); + const startVoteLog = receipt.logs + .map((log: any) => { + try { + return voting.interface.parseLog(log); + } catch (error) { + return undefined; + } + }) + .find((parsed: any) => parsed && parsed.name === "StartVote"); + + expect(startVoteLog?.args.voteId).to.equal(voteId); + expect(startVoteLog?.args.creator).to.equal(AGT_TREASURY_DAO.tokenManager); + expect(startVoteLog?.args.metadata).to.equal(METADATA); + + const storedVote = await voting.getVote(voteId); + expect(storedVote.script).to.equal(built.executionScript); + }); +});