#!/usr/bin/env node "use strict"; const fs = require("fs"); const path = require("path"); const readline = require("readline"); const BN = require("bn.js"); const { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction, clusterApiUrl, } = require("@solana/web3.js"); const { PROGRAM_VERSION_V3, Vote, YesNoVote, VoteType, InstructionData, AccountMetaData, withRevokeGoverningTokens, withCreateProposal, withInsertTransaction, withSignOffProposal, withCastVote, withExecuteTransaction, withFinalizeVote, getTokenOwnerRecordAddress, getProposalTransactionAddress, } = require("@solana/spl-governance"); function parseEnvConfig(configPath) { const raw = fs.readFileSync(configPath, "utf8"); const out = {}; for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); if (eq === -1) continue; const key = trimmed.slice(0, eq).trim(); let val = trimmed.slice(eq + 1).trim(); if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } val = val.replace(/\$HOME/g, process.env.HOME || ""); out[key] = val; } return out; } function loadKeypair(filePath) { const arr = JSON.parse(fs.readFileSync(filePath, "utf8")); return Keypair.fromSecretKey(Uint8Array.from(arr)); } function nowStamp() { const d = new Date(); const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p( d.getMinutes() )}-${p(d.getSeconds())}`; } async function askYes() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise((resolve) => rl.question("Введите YES для proposal->vote->execute revoke: ", resolve) ); rl.close(); return answer.trim() === "YES"; } function toGovernanceInstructionData(ix) { return new InstructionData({ programId: ix.programId, accounts: ix.keys.map( (k) => new AccountMetaData({ pubkey: k.pubkey, isSigner: !!k.isSigner, isWritable: !!k.isWritable, }) ), data: Uint8Array.from(ix.data), }); } function classifyExecuteError(msg) { const s = String(msg || "").toLowerCase(); if (s.includes("0x20d") || s.includes("hold up time")) { return "HOLD_UP_TIME"; } if (s.includes("0x21d") || s.includes("invalid mint authority")) { return "INVALID_MINT_AUTHORITY"; } return "OTHER"; } async function main() { const configPath = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(__dirname, "dao.config.env"); const realmStr = process.argv[3]; const governanceStr = process.argv[4]; const mintStr = process.argv[5]; const targetOwnerStr = process.argv[6]; const amountStr = process.argv[7] || "1"; if (!realmStr || !governanceStr || !mintStr || !targetOwnerStr) { throw new Error( "Использование: node scripts/dao/propose_vote_execute_revoke_full_exec.js [amount]" ); } const cfg = parseEnvConfig(configPath); const cluster = cfg.DAO_CLUSTER || "devnet"; const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID); const proposerKpPath = cfg.DAO_ISSUER_KEYPAIR; if (!proposerKpPath) throw new Error("В конфиге нет DAO_ISSUER_KEYPAIR"); const proposer = loadKeypair(path.resolve(proposerKpPath)); const realm = new PublicKey(realmStr); const governance = new PublicKey(governanceStr); const mint = new PublicKey(mintStr); const targetOwner = new PublicKey(targetOwnerStr); const amount = new BN(amountStr); if (amount.lten(0)) throw new Error("amount должен быть > 0"); const connection = new Connection(clusterApiUrl(cluster), "confirmed"); const proposerRecord = await getTokenOwnerRecordAddress( governanceProgramId, realm, mint, proposer.publicKey ); console.log("============================================================"); console.log("DAO REVOKE THROUGH VOTE"); console.log("------------------------------------------------------------"); console.log("Сеть: ", cluster); console.log("Governance program: ", governanceProgramId.toBase58()); console.log("Realm: ", realm.toBase58()); console.log("Governance: ", governance.toBase58()); console.log("Mint: ", mint.toBase58()); console.log("Target owner: ", targetOwner.toBase58()); console.log("Amount: ", amount.toString()); console.log("Proposer: ", proposer.publicKey.toBase58()); console.log("Proposer record: ", proposerRecord.toBase58()); console.log("============================================================"); const ok = await askYes(); if (!ok) { console.log("Отменено пользователем."); return; } const proposalName = `Revoke ${amount.toString()} from ${targetOwner .toBase58() .slice(0, 8)}...`; const proposalDescription = cfg.DAO_REVOKE_PROPOSAL_URI || cfg.DAO_GOV_TOKEN_METADATA_URI || "https://arweave.net/"; const ixCreateProposal = []; const proposalPk = await withCreateProposal( ixCreateProposal, governanceProgramId, PROGRAM_VERSION_V3, realm, governance, proposerRecord, proposalName, proposalDescription, mint, proposer.publicKey, undefined, VoteType.SINGLE_CHOICE, ["Approve"], true, proposer.publicKey ); const sigCreateProposal = await sendAndConfirmTransaction( connection, new Transaction().add(...ixCreateProposal), [proposer], { commitment: "confirmed" } ); const ixRawRevoke = []; await withRevokeGoverningTokens( ixRawRevoke, governanceProgramId, PROGRAM_VERSION_V3, realm, targetOwner, mint, governance, amount ); if (ixRawRevoke.length !== 1) throw new Error("Ожидалась одна инструкция revoke"); const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]); const ixInsert = []; const proposalTxPk = await withInsertTransaction( ixInsert, governanceProgramId, PROGRAM_VERSION_V3, governance, proposalPk, proposerRecord, proposer.publicKey, 0, 0, 0, [revokeInstructionData], proposer.publicKey ); const sigInsert = await sendAndConfirmTransaction(connection, new Transaction().add(...ixInsert), [proposer], { commitment: "confirmed", }); const ixSignOff = []; withSignOffProposal( ixSignOff, governanceProgramId, PROGRAM_VERSION_V3, realm, governance, proposalPk, proposer.publicKey, undefined, proposerRecord ); const sigSignOff = await sendAndConfirmTransaction(connection, new Transaction().add(...ixSignOff), [proposer], { commitment: "confirmed", }); const ixVote = []; const vote = Vote.fromYesNoVote(YesNoVote.Yes); const voteRecordPk = await withCastVote( ixVote, governanceProgramId, PROGRAM_VERSION_V3, realm, governance, proposalPk, proposerRecord, proposerRecord, proposer.publicKey, mint, vote, proposer.publicKey ); const sigVote = await sendAndConfirmTransaction(connection, new Transaction().add(...ixVote), [proposer], { commitment: "confirmed", }); const computedProposalTxPk = await getProposalTransactionAddress( governanceProgramId, PROGRAM_VERSION_V3, proposalPk, 0, 0 ); if (!computedProposalTxPk.equals(proposalTxPk)) { throw new Error("Несовпадение адреса proposal transaction"); } let sigFinalize = null; try { const ixFinalize = []; await withFinalizeVote( ixFinalize, governanceProgramId, PROGRAM_VERSION_V3, realm, governance, proposalPk, proposerRecord, mint ); sigFinalize = await sendAndConfirmTransaction(connection, new Transaction().add(...ixFinalize), [proposer], { commitment: "confirmed", }); } catch (_) { // Может быть уже tipped/succeeded без finalize. } let sigExecute = null; let executeError = null; let executeErrorKind = null; try { const ixExecute = []; await withExecuteTransaction( ixExecute, governanceProgramId, PROGRAM_VERSION_V3, governance, proposalPk, proposalTxPk, [revokeInstructionData] ); sigExecute = await sendAndConfirmTransaction(connection, new Transaction().add(...ixExecute), [proposer], { commitment: "confirmed", }); } catch (e) { executeError = e?.message || String(e); executeErrorKind = classifyExecuteError(executeError); } const report = { createdAt: new Date().toISOString(), cluster, configPath, governanceProgramId: governanceProgramId.toBase58(), realm: realm.toBase58(), governance: governance.toBase58(), mint: mint.toBase58(), targetOwner: targetOwner.toBase58(), amount: amount.toString(), proposer: proposer.publicKey.toBase58(), proposerRecord: proposerRecord.toBase58(), proposal: proposalPk.toBase58(), proposalTransaction: proposalTxPk.toBase58(), voteRecord: voteRecordPk.toBase58(), txCreateProposal: sigCreateProposal, txInsertTransaction: sigInsert, txSignOff: sigSignOff, txVote: sigVote, txFinalize: sigFinalize, txExecute: sigExecute, executeError, executeErrorKind, }; const reportDir = path.resolve(__dirname, "runs"); fs.mkdirSync(reportDir, { recursive: true }); const reportBaseName = `${nowStamp()}_revoke_${targetOwner.toBase58().slice(0, 10)}`; const reportJsonPath = path.join(reportDir, `${reportBaseName}.json`); const reportTxtPath = path.join(reportDir, `${reportBaseName}.txt`); fs.writeFileSync(reportJsonPath, JSON.stringify(report, null, 2)); fs.writeFileSync( reportTxtPath, [ `createdAt: ${report.createdAt}`, `cluster: ${report.cluster}`, `realm: ${report.realm}`, `governance: ${report.governance}`, `mint: ${report.mint}`, `targetOwner: ${report.targetOwner}`, `amount: ${report.amount}`, `proposer: ${report.proposer}`, `proposal: ${report.proposal}`, `proposalTransaction: ${report.proposalTransaction}`, `voteRecord: ${report.voteRecord}`, `txCreateProposal: ${report.txCreateProposal}`, `txInsertTransaction: ${report.txInsertTransaction}`, `txSignOff: ${report.txSignOff}`, `txVote: ${report.txVote}`, `txFinalize: ${report.txFinalize || "-"}`, `txExecute: ${report.txExecute || "-"}`, `executeError: ${report.executeError || "-"}`, `executeErrorKind: ${report.executeErrorKind || "-"}`, ].join("\n") + "\n" ); console.log("============================================================"); console.log("REVOKE ЧЕРЕЗ DAO ГОЛОСОВАНИЕ ВЫПОЛНЕН"); console.log("------------------------------------------------------------"); console.log("Proposal: ", proposalPk.toBase58()); console.log("Proposal Tx: ", proposalTxPk.toBase58()); console.log("Tx create proposal: ", sigCreateProposal); console.log("Tx insert revoke instruction: ", sigInsert); console.log("Tx sign off: ", sigSignOff); console.log("Tx cast vote: ", sigVote); if (sigFinalize) console.log("Tx finalize vote: ", sigFinalize); if (sigExecute) { console.log("Tx execute: ", sigExecute); } else { console.log("Execute сейчас не прошел (ожидание voting/hold-up):"); console.log("Ошибка execute: ", executeError); if (executeErrorKind === "HOLD_UP_TIME") { console.log("Причина: ", "слишком рано для execute (hold-up / окно голосования еще не завершено)"); } else if (executeErrorKind === "INVALID_MINT_AUTHORITY") { console.log("Причина: ", "community mint authority не передан на governance PDA при создании DAO"); } console.log("Повтор execute через время этой командой:"); console.log( `node scripts/dao/execute_revoke_transaction_full_exec.js ${configPath} ${realm.toBase58()} ${governance.toBase58()} ${proposalPk.toBase58()} ${proposalTxPk.toBase58()} ${mint.toBase58()} ${targetOwner.toBase58()} ${amount.toString()}` ); } console.log("Отчёт JSON: ", reportJsonPath); console.log("Отчёт TXT: ", reportTxtPath); console.log("============================================================"); } main().catch((e) => { console.error("Ошибка proposal/vote/execute revoke:", e?.message || e); process.exit(1); });