400 lines
13 KiB
JavaScript
Executable File
400 lines
13 KiB
JavaScript
Executable File
#!/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 <config.env> <realm> <governance> <mint> <target_owner_pubkey> [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);
|
||
});
|