#!/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, getMintLen, createInitializeMintInstruction, getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, createMintToInstruction, } = require("@solana/spl-token"); const { MintMaxVoteWeightSource, VoteThreshold, VoteThresholdType, VoteTipping, GovernanceConfig, withCreateRealm, withDepositGoverningTokens, withCreateGovernance, withCreateNativeTreasury, PROGRAM_VERSION_V3, } = 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 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"; } 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", ].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 <= 0) { throw new Error("DAO_VOTING_TIME_SEC должен быть целым > 0"); } 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 уже существует для имени '${cfg.DAO_REALM_NAME}': ${realmPda.toBase58()}` ); } const startBalance = await connection.getBalance(issuer.publicKey, "confirmed"); console.log("============================================================"); console.log("СОЗДАНИЕ DAO (EXEC)"); 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("NFT symbol/name: ", `${cfg.DAO_GOV_NFT_SYMBOL} / ${cfg.DAO_GOV_NFT_NAME}`); console.log("NFT supply: ", supply); console.log("Voting time sec: ", votingTimeSec); console.log("Threshold %: ", thresholdPct); console.log("Конфиг: ", configPath); 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 programVersion = PROGRAM_VERSION_V3; const ixRealm = []; 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) ); const txRealm = new Transaction().add(...ixRealm); const sigRealm = await sendAndConfirmTransaction(connection, txRealm, [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 txDeposit = new Transaction().add(...ixDeposit); const sigDeposit = await sendAndConfirmTransaction(connection, txDeposit, [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.Strict, 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 txGov = new Transaction().add(...ixGov); const sigGov = await sendAndConfirmTransaction(connection, txGov, [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(), txMint: sigMint, txRealm: sigRealm, txDeposit: sigDeposit, txGovernanceTreasury: sigGov, votingTimeSec, thresholdPercent: thresholdPct, nftSupply: 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)}`; 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}`, `txMint: ${report.txMint}`, `txRealm: ${report.txRealm}`, `txDeposit: ${report.txDeposit}`, `txGovernanceTreasury: ${report.txGovernanceTreasury}`, `nftSupply: ${report.nftSupply}`, `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 СОЗДАНО"); console.log("------------------------------------------------------------"); console.log("Community mint (SPL Token, transferable): ", mintKeypair.publicKey.toBase58()); console.log("Issuer ATA: ", issuerAta.toBase58()); console.log("Realm: ", realmPk.toBase58()); console.log("TokenOwnerRecord: ", tokenOwnerRecordPk.toBase58()); console.log("Governance: ", governancePk.toBase58()); console.log("Native treasury PDA: ", treasuryPk.toBase58()); console.log("Tx mint: ", sigMint); console.log("Tx realm: ", sigRealm); console.log("Tx deposit: ", sigDeposit); console.log("Tx governance+treasury: ", sigGov); 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:", e?.message || e); process.exit(1); });