457 lines
17 KiB
JavaScript
Executable File
457 lines
17 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,
|
||
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);
|
||
});
|