#!/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); });