Отключить репосты и добавить Solana-модуль

This commit is contained in:
AidarKC
2026-05-24 12:16:39 +03:00
parent abdce05136
commit 56cd90a197
95 changed files with 24261 additions and 64 deletions
+60
View File
@@ -0,0 +1,60 @@
# DAO scripts (актуальные)
## 1) Проверка конфигурации
```bash
scripts/dao/create_realm_dao_full_test.sh scripts/dao/dao.config.env
```
## 2) Реальное создание FULL DAO
```bash
node scripts/dao/create_realm_dao_full_build_exec.js scripts/dao/dao.config.env
```
Что делает:
1. Создает governance mint (SPL, decimals=0, supply из конфига).
2. Добавляет on-chain metadata для mint (URI и картинка из Arweave).
3. Создает Realm / Governance / Native Treasury.
4. Депозитит governance токены в Realm.
5. Пишет отчеты в `scripts/dao/runs/*.json` и `*.txt`.
## 3) Revoke/Burn membership токенов
### Вариант A (рекомендуется): через DAO голосование
```bash
node scripts/dao/propose_vote_execute_revoke_full_exec.js \
scripts/dao/dao.config.env \
<REALM_PUBKEY> \
<GOVERNANCE_PUBKEY> \
<MINT_PUBKEY> \
<TARGET_OWNER_PUBKEY> \
[AMOUNT]
```
Скрипт делает полный цикл:
1. `create proposal`
2. `insert revoke instruction`
3. `sign off`
4. `cast vote`
5. `execute`
### Вариант B (технический/админский): прямой revoke
```bash
node scripts/dao/revoke_member_token_full_exec.js \
scripts/dao/dao.config.env \
<REALM_PUBKEY> \
<MINT_PUBKEY> \
<TARGET_OWNER_PUBKEY> \
[AMOUNT]
```
Важное:
1. Для `RevokeGoverningTokens` токен должен быть membership-типом (в full-скрипте это уже так).
2. Для сценария “только DAO голосованием” используйте вариант A.
3. Вариант B оставлен как технический инструмент.
@@ -0,0 +1,456 @@
#!/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,
SystemProgram,
Transaction,
sendAndConfirmTransaction,
clusterApiUrl,
} = require("@solana/web3.js");
const {
TOKEN_PROGRAM_ID,
AuthorityType,
getMintLen,
createInitializeMintInstruction,
getAssociatedTokenAddressSync,
createAssociatedTokenAccountIdempotentInstruction,
createMintToInstruction,
createSetAuthorityInstruction,
} = require("@solana/spl-token");
const {
MintMaxVoteWeightSource,
VoteThreshold,
VoteThresholdType,
VoteTipping,
GovernanceConfig,
PROGRAM_VERSION_V3,
GoverningTokenConfigAccountArgs,
GoverningTokenType,
withCreateRealm,
withDepositGoverningTokens,
withCreateGovernance,
withCreateNativeTreasury,
withSetRealmAuthority,
SetRealmAuthorityAction,
} = require("@solana/spl-governance");
const { createUmi } = require("@metaplex-foundation/umi-bundle-defaults");
const {
createSignerFromKeypair,
signerIdentity,
percentAmount,
none,
some,
} = require("@metaplex-foundation/umi");
const { fromWeb3JsKeypair, fromWeb3JsPublicKey } = require("@metaplex-foundation/umi-web3js-adapters");
const { mplTokenMetadata, createV1, TokenStandard } = require("@metaplex-foundation/mpl-token-metadata");
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 assertRequired(cfg, key) {
if (!cfg[key]) throw new Error(`В конфиге отсутствует обязательный параметр: ${key}`);
}
function loadKeypair(filePath) {
const arr = JSON.parse(fs.readFileSync(filePath, "utf8"));
return Keypair.fromSecretKey(Uint8Array.from(arr));
}
function lamportsToSol(lamports) {
return Number(lamports) / 1_000_000_000;
}
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 для реального создания ПОЛНОГО DAO: ", resolve)
);
rl.close();
return answer.trim() === "YES";
}
function ensureArweaveUri(name, uri) {
if (!uri) throw new Error(`${name} пустой`);
if (!(uri.startsWith("https://arweave.net/") || uri.startsWith("ar://"))) {
throw new Error(`${name} должен указывать на Arweave (https://arweave.net/... или ar://...)`);
}
}
async function attachTokenMetadataViaUmi(cfg, cluster, issuer, mintPubkey, mintKeypair) {
ensureArweaveUri("DAO_GOV_TOKEN_METADATA_URI", cfg.DAO_GOV_TOKEN_METADATA_URI);
ensureArweaveUri("DAO_GOV_TOKEN_IMAGE_URL", cfg.DAO_GOV_TOKEN_IMAGE_URL);
const umi = createUmi(clusterApiUrl(cluster));
const umiSigner = createSignerFromKeypair(umi, fromWeb3JsKeypair(issuer));
const umiMintSigner = createSignerFromKeypair(umi, fromWeb3JsKeypair(mintKeypair));
umi.use(signerIdentity(umiSigner));
umi.use(mplTokenMetadata());
const builder = createV1(umi, {
mint: umiMintSigner,
authority: umiSigner,
payer: umiSigner,
updateAuthority: umiSigner,
name: cfg.DAO_GOV_NFT_NAME,
symbol: cfg.DAO_GOV_NFT_SYMBOL,
uri: cfg.DAO_GOV_TOKEN_METADATA_URI,
sellerFeeBasisPoints: percentAmount(0),
tokenStandard: TokenStandard.Fungible,
decimals: some(0),
creators: none(),
collection: none(),
uses: none(),
collectionDetails: none(),
ruleSet: none(),
printSupply: none(),
primarySaleHappened: false,
isMutable: true,
isCollection: false,
splTokenProgram: fromWeb3JsPublicKey(TOKEN_PROGRAM_ID),
});
const res = await builder.sendAndConfirm(umi);
const sig = Buffer.from(res.signature).toString("base64");
return sig;
}
async function main() {
const configPath = process.argv[2]
? path.resolve(process.argv[2])
: path.resolve(__dirname, "dao.config.env");
if (!fs.existsSync(configPath)) throw new Error(`Конфиг не найден: ${configPath}`);
const cfg = parseEnvConfig(configPath);
[
"DAO_CLUSTER",
"DAO_REALM_NAME",
"DAO_GOV_NFT_NAME",
"DAO_GOV_NFT_SYMBOL",
"DAO_GOV_NFT_SUPPLY",
"DAO_VOTING_TIME_SEC",
"DAO_APPROVAL_THRESHOLD_PERCENT",
"DAO_ISSUER_KEYPAIR",
"SPL_GOVERNANCE_PROGRAM_ID",
"DAO_GOV_TOKEN_METADATA_URI",
"DAO_GOV_TOKEN_IMAGE_URL",
].forEach((k) => assertRequired(cfg, k));
const cluster = cfg.DAO_CLUSTER;
const connection = new Connection(clusterApiUrl(cluster), "confirmed");
const issuer = loadKeypair(path.resolve(cfg.DAO_ISSUER_KEYPAIR));
const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
const supply = Number(cfg.DAO_GOV_NFT_SUPPLY);
const votingTimeSec = Number(cfg.DAO_VOTING_TIME_SEC);
const thresholdPct = Number(cfg.DAO_APPROVAL_THRESHOLD_PERCENT);
if (!Number.isInteger(supply) || supply <= 0) throw new Error("DAO_GOV_NFT_SUPPLY должен быть целым > 0");
if (!Number.isInteger(votingTimeSec) || votingTimeSec < 3600)
throw new Error("DAO_VOTING_TIME_SEC должен быть >= 3600 (ограничение Realms)");
if (!Number.isInteger(thresholdPct) || thresholdPct < 51 || thresholdPct > 100)
throw new Error("DAO_APPROVAL_THRESHOLD_PERCENT должен быть в диапазоне 51..100");
const [realmPda] = PublicKey.findProgramAddressSync(
[Buffer.from("governance"), Buffer.from(cfg.DAO_REALM_NAME, "utf8")],
governanceProgramId
);
const realmExists = (await connection.getAccountInfo(realmPda)) !== null;
if (realmExists) throw new Error(`Realm уже существует: ${realmPda.toBase58()}`);
const startBalance = await connection.getBalance(issuer.publicKey, "confirmed");
console.log("============================================================");
console.log("СОЗДАНИЕ DAO (FULL)");
console.log("------------------------------------------------------------");
console.log("Сеть: ", cluster);
console.log("Realm name: ", cfg.DAO_REALM_NAME);
console.log("Realm PDA: ", realmPda.toBase58());
console.log("Governance program: ", governanceProgramId.toBase58());
console.log("Issuer: ", issuer.publicKey.toBase58());
console.log("Баланс до старта: ", `${lamportsToSol(startBalance)} SOL`);
console.log("Token name/symbol: ", `${cfg.DAO_GOV_NFT_NAME} / ${cfg.DAO_GOV_NFT_SYMBOL}`);
console.log("Token supply: ", supply);
console.log("Voting time sec: ", votingTimeSec);
console.log("Threshold %: ", thresholdPct);
console.log("Arweave metadata URI:", cfg.DAO_GOV_TOKEN_METADATA_URI);
console.log("Arweave image URL: ", cfg.DAO_GOV_TOKEN_IMAGE_URL);
console.log("============================================================");
const ok = await askYes();
if (!ok) {
console.log("Отменено пользователем.");
return;
}
const mintKeypair = Keypair.generate();
const mintLen = getMintLen([]);
const mintRent = await connection.getMinimumBalanceForRentExemption(mintLen);
const issuerAta = getAssociatedTokenAddressSync(mintKeypair.publicKey, issuer.publicKey, false, TOKEN_PROGRAM_ID);
const txMint = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: issuer.publicKey,
newAccountPubkey: mintKeypair.publicKey,
space: mintLen,
lamports: mintRent,
programId: TOKEN_PROGRAM_ID,
}),
createInitializeMintInstruction(mintKeypair.publicKey, 0, issuer.publicKey, issuer.publicKey, TOKEN_PROGRAM_ID),
createAssociatedTokenAccountIdempotentInstruction(
issuer.publicKey,
issuerAta,
issuer.publicKey,
mintKeypair.publicKey,
TOKEN_PROGRAM_ID
),
createMintToInstruction(mintKeypair.publicKey, issuerAta, issuer.publicKey, supply, [], TOKEN_PROGRAM_ID)
);
const sigMint = await sendAndConfirmTransaction(connection, txMint, [issuer, mintKeypair], {
commitment: "confirmed",
});
const sigMetadata = await attachTokenMetadataViaUmi(
cfg,
cluster,
issuer,
mintKeypair.publicKey,
mintKeypair
);
const programVersion = PROGRAM_VERSION_V3;
const ixRealm = [];
const communityTokenConfig = new GoverningTokenConfigAccountArgs({
voterWeightAddin: undefined,
maxVoterWeightAddin: undefined,
tokenType: GoverningTokenType.Membership,
});
const realmPk = await withCreateRealm(
ixRealm,
governanceProgramId,
programVersion,
cfg.DAO_REALM_NAME,
issuer.publicKey,
mintKeypair.publicKey,
issuer.publicKey,
undefined,
MintMaxVoteWeightSource.FULL_SUPPLY_FRACTION,
new BN(1),
communityTokenConfig,
undefined
);
const sigRealm = await sendAndConfirmTransaction(connection, new Transaction().add(...ixRealm), [issuer], {
commitment: "confirmed",
});
const ixDeposit = [];
const tokenOwnerRecordPk = await withDepositGoverningTokens(
ixDeposit,
governanceProgramId,
programVersion,
realmPk,
issuerAta,
mintKeypair.publicKey,
issuer.publicKey,
issuer.publicKey,
issuer.publicKey,
new BN(supply),
true
);
const sigDeposit = await sendAndConfirmTransaction(connection, new Transaction().add(...ixDeposit), [issuer], {
commitment: "confirmed",
});
const governanceConfig = new GovernanceConfig({
communityVoteThreshold: new VoteThreshold({ type: VoteThresholdType.YesVotePercentage, value: thresholdPct }),
minCommunityTokensToCreateProposal: new BN(1),
minInstructionHoldUpTime: 0,
baseVotingTime: votingTimeSec,
communityVoteTipping: VoteTipping.Early,
minCouncilTokensToCreateProposal: new BN(0),
councilVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
councilVetoVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
communityVetoVoteThreshold: new VoteThreshold({ type: VoteThresholdType.Disabled }),
councilVoteTipping: VoteTipping.Disabled,
votingCoolOffTime: 0,
depositExemptProposalCount: 0,
});
const ixGov = [];
const governancePk = await withCreateGovernance(
ixGov,
governanceProgramId,
programVersion,
realmPk,
realmPk,
governanceConfig,
tokenOwnerRecordPk,
issuer.publicKey,
issuer.publicKey
);
const treasuryPk = await withCreateNativeTreasury(ixGov, governanceProgramId, programVersion, governancePk, issuer.publicKey);
const sigGov = await sendAndConfirmTransaction(connection, new Transaction().add(...ixGov), [issuer], {
commitment: "confirmed",
});
// Для DAO revoke governing tokens mint authority должен быть у governance PDA.
const ixSetMintAuthority = [
createSetAuthorityInstruction(
mintKeypair.publicKey,
issuer.publicKey,
AuthorityType.MintTokens,
governancePk,
[],
TOKEN_PROGRAM_ID
),
];
const sigSetMintAuthority = await sendAndConfirmTransaction(
connection,
new Transaction().add(...ixSetMintAuthority),
[issuer],
{ commitment: "confirmed" }
);
const ixRealmAuthority = [];
withSetRealmAuthority(
ixRealmAuthority,
governanceProgramId,
programVersion,
realmPk,
issuer.publicKey,
governancePk,
SetRealmAuthorityAction.SetChecked
);
const sigSetRealmAuthority = await sendAndConfirmTransaction(
connection,
new Transaction().add(...ixRealmAuthority),
[issuer],
{ commitment: "confirmed" }
);
const endBalance = await connection.getBalance(issuer.publicKey, "confirmed");
const spentLamports = startBalance - endBalance;
const report = {
createdAt: new Date().toISOString(),
cluster,
configPath,
realmName: cfg.DAO_REALM_NAME,
governanceProgramId: governanceProgramId.toBase58(),
issuer: issuer.publicKey.toBase58(),
communityMint: mintKeypair.publicKey.toBase58(),
issuerAta: issuerAta.toBase58(),
realm: realmPk.toBase58(),
tokenOwnerRecord: tokenOwnerRecordPk.toBase58(),
governance: governancePk.toBase58(),
nativeTreasury: treasuryPk.toBase58(),
metadataUri: cfg.DAO_GOV_TOKEN_METADATA_URI,
imageUrl: cfg.DAO_GOV_TOKEN_IMAGE_URL,
txMint: sigMint,
txMetadata: sigMetadata,
txRealm: sigRealm,
txDeposit: sigDeposit,
txGovernanceTreasury: sigGov,
txSetMintAuthorityToGovernance: sigSetMintAuthority,
txSetRealmAuthority: sigSetRealmAuthority,
votingTimeSec,
thresholdPercent: thresholdPct,
tokenSupply: supply,
startBalanceLamports: startBalance,
endBalanceLamports: endBalance,
spentLamports,
startBalanceSol: lamportsToSol(startBalance),
endBalanceSol: lamportsToSol(endBalance),
spentSol: lamportsToSol(spentLamports),
};
const reportDir = path.resolve(__dirname, "runs");
fs.mkdirSync(reportDir, { recursive: true });
const reportBaseName = `${nowStamp()}_${cfg.DAO_REALM_NAME.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80)}_full`;
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}`,
`realmName: ${report.realmName}`,
`governanceProgramId: ${report.governanceProgramId}`,
`issuer: ${report.issuer}`,
`communityMint: ${report.communityMint}`,
`issuerAta: ${report.issuerAta}`,
`realm: ${report.realm}`,
`tokenOwnerRecord: ${report.tokenOwnerRecord}`,
`governance: ${report.governance}`,
`nativeTreasury: ${report.nativeTreasury}`,
`metadataUri: ${report.metadataUri}`,
`imageUrl: ${report.imageUrl}`,
`txMint: ${report.txMint}`,
`txMetadata: ${report.txMetadata}`,
`txRealm: ${report.txRealm}`,
`txDeposit: ${report.txDeposit}`,
`txGovernanceTreasury: ${report.txGovernanceTreasury}`,
`txSetMintAuthorityToGovernance: ${report.txSetMintAuthorityToGovernance}`,
`txSetRealmAuthority: ${report.txSetRealmAuthority}`,
`tokenSupply: ${report.tokenSupply}`,
`votingTimeSec: ${report.votingTimeSec}`,
`thresholdPercent: ${report.thresholdPercent}`,
`startBalanceSol: ${report.startBalanceSol}`,
`endBalanceSol: ${report.endBalanceSol}`,
`spentSol: ${report.spentSol}`,
`configPath: ${report.configPath}`,
].join("\n") + "\n"
);
console.log("============================================================");
console.log("DAO FULL СОЗДАНО");
console.log("------------------------------------------------------------");
console.log("Community mint (SPL + metadata): ", mintKeypair.publicKey.toBase58());
console.log("Realm: ", realmPk.toBase58());
console.log("Governance: ", governancePk.toBase58());
console.log("Native treasury PDA: ", treasuryPk.toBase58());
console.log("Tx mint: ", sigMint);
console.log("Tx metadata: ", sigMetadata);
console.log("Tx realm: ", sigRealm);
console.log("Tx deposit: ", sigDeposit);
console.log("Tx governance+treasury: ", sigGov);
console.log("Tx set mint authority -> governance: ", sigSetMintAuthority);
console.log("Tx set realm authority -> governance: ", sigSetRealmAuthority);
console.log("Баланс после: ", `${lamportsToSol(endBalance)} SOL`);
console.log("Потрачено: ", `${lamportsToSol(spentLamports)} SOL`);
console.log("Отчёт JSON: ", reportJsonPath);
console.log("Отчёт TXT: ", reportTxtPath);
console.log("============================================================");
}
main().catch((e) => {
console.error("Ошибка создания DAO FULL:", e?.message || e);
process.exit(1);
});
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="${1:-$SCRIPT_DIR/dao.config.env}"
if [[ ! -f "$CONFIG_PATH" ]]; then
echo "Ошибка: не найден конфиг $CONFIG_PATH"
exit 1
fi
# shellcheck disable=SC1090
source "$CONFIG_PATH"
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Ошибка: команда '$1' не найдена"
exit 1
fi
}
require_cmd solana
require_cmd solana-keygen
require_cmd node
if [[ -z "${DAO_REALM_NAME:-}" || -z "${DAO_CLUSTER:-}" || -z "${DAO_ISSUER_KEYPAIR:-}" || -z "${SPL_GOVERNANCE_PROGRAM_ID:-}" ]]; then
echo "Ошибка: обязательные поля конфига пустые"
exit 1
fi
if [[ ! -f "$DAO_ISSUER_KEYPAIR" ]]; then
echo "Ошибка: keypair не найден: $DAO_ISSUER_KEYPAIR"
exit 1
fi
if [[ "${DAO_REALM_NAME}" == *"TEMPLATE"* || "${DAO_REALM_NAME}" == *"CHANGE_ME"* ]]; then
echo "Ошибка: похоже, не заменили тестовое имя DAO_REALM_NAME"
exit 1
fi
if ! [[ "${DAO_VOTING_TIME_SEC}" =~ ^[0-9]+$ ]] || ! [[ "${DAO_GOV_NFT_SUPPLY}" =~ ^[0-9]+$ ]] || ! [[ "${DAO_APPROVAL_THRESHOLD_PERCENT}" =~ ^[0-9]+$ ]]; then
echo "Ошибка: числовые параметры заданы некорректно"
exit 1
fi
if (( DAO_APPROVAL_THRESHOLD_PERCENT < 51 || DAO_APPROVAL_THRESHOLD_PERCENT > 100 )); then
echo "Ошибка: DAO_APPROVAL_THRESHOLD_PERCENT должен быть в диапазоне 51..100"
exit 1
fi
ISSUER_PUBKEY="$(solana-keygen pubkey "$DAO_ISSUER_KEYPAIR")"
ISSUER_BALANCE="$(solana balance "$ISSUER_PUBKEY" --url "$DAO_CLUSTER" 2>/dev/null || true)"
REALM_PDA="$(node - "$DAO_REALM_NAME" "$SPL_GOVERNANCE_PROGRAM_ID" <<'NODE'
const { PublicKey } = require("@solana/web3.js");
const realmName = process.argv[2];
const programId = new PublicKey(process.argv[3]);
const [pda] = PublicKey.findProgramAddressSync(
[Buffer.from("governance"), Buffer.from(realmName, "utf8")],
programId
);
console.log(pda.toBase58());
NODE
)"
if [[ -z "$REALM_PDA" ]]; then
echo "Ошибка: не удалось вычислить PDA realm."
exit 1
fi
REALM_EXISTS="no"
if solana account "$REALM_PDA" --url "$DAO_CLUSTER" >/dev/null 2>&1; then
REALM_EXISTS="yes"
fi
cat <<EOF
============================================================
ПРЕДСТАРТОВАЯ ПРОВЕРКА DAO (Realms)
------------------------------------------------------------
Сеть: $DAO_CLUSTER
Realm name: $DAO_REALM_NAME
Realm PDA: $REALM_PDA
Realm уже существует: $REALM_EXISTS
Governance program: $SPL_GOVERNANCE_PROGRAM_ID
Эмиттер (issuer): $ISSUER_PUBKEY
Баланс эмиттера: ${ISSUER_BALANCE:-unknown}
NFT name: $DAO_GOV_NFT_NAME
NFT symbol: $DAO_GOV_NFT_SYMBOL
NFT supply: $DAO_GOV_NFT_SUPPLY
Voting time (sec): $DAO_VOTING_TIME_SEC
Threshold %: $DAO_APPROVAL_THRESHOLD_PERCENT
Конфиг: $CONFIG_PATH
============================================================
EOF
if [[ "$REALM_EXISTS" == "yes" ]]; then
echo "Стоп: realm с таким именем уже существует в этой сети."
echo "Поменяйте DAO_REALM_NAME в конфиге и запустите снова."
exit 1
fi
echo
echo "Проверка пройдена."
echo "Этот скрипт делает только preflight-валидацию."
echo "Для реального создания DAO запускайте исполняющий скрипт:"
echo "node scripts/dao/create_realm_dao_full_build_exec.js scripts/dao/dao.config.env"
@@ -0,0 +1,107 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const BN = require("bn.js");
const { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction, clusterApiUrl } = require("@solana/web3.js");
const {
PROGRAM_VERSION_V3,
InstructionData,
AccountMetaData,
withRevokeGoverningTokens,
withExecuteTransaction,
} = 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 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),
});
}
async function main() {
const configPath = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(__dirname, "dao.config.env");
const realm = new PublicKey(process.argv[3]);
const governance = new PublicKey(process.argv[4]);
const proposal = new PublicKey(process.argv[5]);
const proposalTx = new PublicKey(process.argv[6]);
const mint = new PublicKey(process.argv[7]);
const targetOwner = new PublicKey(process.argv[8]);
const amount = new BN(process.argv[9] || "1");
if (!process.argv[8]) {
throw new Error(
"Использование: node scripts/dao/execute_revoke_transaction_full_exec.js <config.env> <realm> <governance> <proposal> <proposal_tx> <mint> <target_owner> [amount]"
);
}
const cfg = parseEnvConfig(configPath);
const cluster = cfg.DAO_CLUSTER || "devnet";
const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID);
const signer = loadKeypair(path.resolve(cfg.DAO_ISSUER_KEYPAIR));
const connection = new Connection(clusterApiUrl(cluster), "confirmed");
const ixRawRevoke = [];
await withRevokeGoverningTokens(
ixRawRevoke,
governanceProgramId,
PROGRAM_VERSION_V3,
realm,
targetOwner,
mint,
governance,
amount
);
const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]);
const ixExecute = [];
await withExecuteTransaction(
ixExecute,
governanceProgramId,
PROGRAM_VERSION_V3,
governance,
proposal,
proposalTx,
[revokeInstructionData]
);
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ixExecute), [signer], {
commitment: "confirmed",
});
console.log("Execute success. Tx:", sig);
}
main().catch((e) => {
console.error("Ошибка execute revoke:", e?.message || e);
process.exit(1);
});
@@ -0,0 +1,399 @@
#!/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);
});
@@ -0,0 +1,111 @@
#!/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, withRevokeGoverningTokens } = 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));
}
async function askYes() {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) =>
rl.question("Введите YES для отзыва (burn/revoke) governance токенов: ", resolve)
);
rl.close();
return answer.trim() === "YES";
}
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 mintStr = process.argv[4];
const targetOwnerStr = process.argv[5];
const amountStr = process.argv[6] || "1";
if (!realmStr || !mintStr || !targetOwnerStr) {
throw new Error(
"Использование: node scripts/dao/revoke_member_token_full_exec.js <config.env> <realm> <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 revokeKpPath = cfg.DAO_REVOKE_AUTHORITY_KEYPAIR || cfg.DAO_ISSUER_KEYPAIR;
if (!revokeKpPath) throw new Error("В конфиге нет DAO_REVOKE_AUTHORITY_KEYPAIR и DAO_ISSUER_KEYPAIR");
const revokeAuthority = loadKeypair(path.resolve(revokeKpPath));
const realm = new PublicKey(realmStr);
const mint = new PublicKey(mintStr);
const targetOwner = new PublicKey(targetOwnerStr);
const amount = new BN(amountStr);
if (amount.lten(0)) throw new Error("amount должен быть > 0");
console.log("============================================================");
console.log("REVOKE/BURN GOVERNANCE TOKENS");
console.log("------------------------------------------------------------");
console.log("Сеть: ", cluster);
console.log("Governance program: ", governanceProgramId.toBase58());
console.log("Realm: ", realm.toBase58());
console.log("Mint: ", mint.toBase58());
console.log("Target owner: ", targetOwner.toBase58());
console.log("Amount: ", amount.toString());
console.log("Revoke authority: ", revokeAuthority.publicKey.toBase58());
console.log("============================================================");
const ok = await askYes();
if (!ok) {
console.log("Отменено пользователем.");
return;
}
const connection = new Connection(clusterApiUrl(cluster), "confirmed");
const ix = [];
await withRevokeGoverningTokens(
ix,
governanceProgramId,
PROGRAM_VERSION_V3,
realm,
targetOwner,
mint,
revokeAuthority.publicKey,
amount
);
const sig = await sendAndConfirmTransaction(connection, new Transaction().add(...ix), [revokeAuthority], {
commitment: "confirmed",
});
console.log("Готово. Tx:", sig);
}
main().catch((e) => {
console.error("Ошибка revoke:", e?.message || e);
process.exit(1);
});