Закоммичены все текущие изменения и добавлено правило русских commit message

This commit is contained in:
AidarKC 2026-05-15 15:29:24 +03:00
parent 9fd2f8f495
commit 61c6a3208a
51 changed files with 3835 additions and 325 deletions

10
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

18
.idea/gradle.xml generated Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/solana-shine-client-lib" />
<option name="gradleJvm" value="21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/solana-shine-client-lib" />
<option value="$PROJECT_DIR$/solana-shine-client-lib/solana-shine-lib" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

7
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/shine-solana.iml" filepath="$PROJECT_DIR$/.idea/shine-solana.iml" />
</modules>
</component>
</project>

9
.idea/shine-solana.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

View File

@ -0,0 +1,2 @@
#Mon May 11 16:21:48 MSK 2026
gradle.version=8.14.4

View File

@ -0,0 +1 @@
Subproject commit 3abc3959fa56192511bdc977615f500a55022e88

View File

@ -31,3 +31,18 @@
- `GITEA_TOKEN`
Push выполнять через `http.extraHeader` (Authorization) без вывода токена в логи.
## Rule: Commit Messages
Текст commit message писать на русском языке.
## Rule: UI Deploy
Деплой UI Shine Payments выполнять через Gradle из папки `shine`:
1. `gradle deployUi`
2. `gradle checkUiRemote`
Где смотреть детали (пути деплоя, путь Caddy, рабочие URL):
- комментарии в `build.gradle` (в корне `shine/`).

45
shine/build.gradle Normal file
View File

@ -0,0 +1,45 @@
/*
* Gradle-задачи для утилитного деплоя UI Shine Payments.
*
* Куда деплоим файлы UI:
* /home/player/sites/test-solana-tickets.shineup.me
*
* Где расположен Caddy-конфиг на сервере:
* /home/player/SHiNE/caddy/Caddyfile
*
* По каким URL должен работать UI:
* https://test-solana-tickets.shineup.me
* https://sol.shiningpeople.ru
*/
tasks.register("deployUi", Exec) {
group = "deploy"
description = "Деплой HTML UI Shine Payments на 45.136.124.227 в /home/player/sites/test-solana-tickets.shineup.me (URL: test-solana-tickets.shineup.me, sol.shiningpeople.ru)"
// Источник локальных UI-страниц:
// shine/programs/shine_payments/web/
def localUiDir = "${projectDir}/programs/shine_payments/web/"
// Целевая директория на сервере:
// /home/player/sites/test-solana-tickets.shineup.me
def remoteTarget = "player@45.136.124.227:/home/player/sites/test-solana-tickets.shineup.me/"
commandLine "rsync", "-av", "--delete", localUiDir, remoteTarget
}
tasks.register("checkUiRemote", Exec) {
group = "deploy"
description = "Проверка на сервере: Caddy-конфиг и наличие новых Program ID в UI"
commandLine "ssh", "-o", "StrictHostKeyChecking=no", "player@45.136.124.227",
"set -e; " +
"echo 'Caddy file:'; " +
"ls -la /home/player/SHiNE/caddy/Caddyfile; " +
"echo; " +
"echo 'Домены в Caddy:'; " +
"grep -n 'test-solana-tickets.shineup.me\\|sol.shiningpeople.ru' /home/player/SHiNE/caddy/Caddyfile; " +
"echo; " +
"echo 'Program ID в загруженных html:'; " +
"grep -R -n 'm48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR' /home/player/sites/test-solana-tickets.shineup.me/*.html"
}

View File

@ -0,0 +1 @@
[112,191,53,183,50,74,71,149,251,216,158,157,67,198,143,219,67,188,22,43,49,6,18,31,92,254,78,219,18,245,187,50,50,133,54,215,248,187,162,135,70,192,63,182,33,87,44,169,230,248,155,7,216,43,188,126,253,71,54,123,202,174,140,97]

View File

@ -0,0 +1 @@
[78,57,36,25,42,130,147,16,232,65,139,10,4,54,133,0,204,50,65,112,18,99,223,142,226,60,119,36,253,192,185,121,112,54,249,197,203,152,82,234,162,32,176,193,110,19,217,20,149,116,215,16,153,121,28,243,143,248,181,55,186,250,95,103]

View File

@ -1,14 +1,5 @@
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{
program::invoke_signed,
system_instruction,
system_program
};
use anchor_lang::solana_program::{program::invoke_signed, system_instruction, system_program};
/// сдесь коды всех ошибок
@ -40,7 +31,6 @@ pub enum ErrCode {
#[msg("PDA-аккаунт уже существует и не может быть создан повторно.")]
PdaAlreadyExists = 1009,
#[msg("Подписавший не совпадает с ожидаемым пользователем (это потому что пока временно можно регистрировать пользователя с другово аккаунта")]
InvalidSigner = 1005,
@ -86,29 +76,8 @@ pub enum ErrCode {
#[msg("Невалидная magic-сигнатура записи")]
InvalidRecordMagic = 1025,
}
///----------------------------------------------------------------------------------------------------------
/// Базовые функции для работы с PDA
///----------------------------------------------------------------------------------------------------------
@ -137,7 +106,7 @@ pub fn create_and_write_pda<'info>(
msg!("Создаём PDA с размером {} байт", space);
let space = space; //+ 128; // Добавляется запас под метаданные
// Вычисляем необходимую арендную плату
// Вычисляем необходимую арендную плату
let lamports = Rent::get()?.minimum_balance(space as usize);
// Формируем инструкцию
@ -152,11 +121,7 @@ pub fn create_and_write_pda<'info>(
// Выполняем инструкцию с подписью от PDA
invoke_signed(
&create_instr,
&[
signer.clone(),
pda_account.clone(),
system_program.clone(),
],
&[signer.clone(), pda_account.clone(), system_program.clone()],
&[&seeds],
)?;
}
@ -177,9 +142,6 @@ pub fn create_and_write_pda<'info>(
Ok(())
}
/// Создаёт PDA аккаунт (если его ещё нет).
///
/// ⚠️ Если аккаунт уже существует, выбрасывается ошибка.
@ -221,22 +183,18 @@ pub fn create_pda<'info>(
// ───────────────────────────────────────────────
// 3. Создаём инструкцию system_program для создания аккаунта
let create_instr = system_instruction::create_account(
signer.key, // от имени кого
pda_account.key, // для какого PDA
lamports, // сколько лампортов перевести
full_space, // сколько байт выделить
program_id, // кто будет владельцем PDA
signer.key, // от имени кого
pda_account.key, // для какого PDA
lamports, // сколько лампортов перевести
full_space, // сколько байт выделить
program_id, // кто будет владельцем PDA
);
// ───────────────────────────────────────────────
// 4. Выполняем инструкцию с подписью PDA (через сиды)
invoke_signed(
&create_instr,
&[
signer.clone(),
pda_account.clone(),
system_program.clone(),
],
&[signer.clone(), pda_account.clone(), system_program.clone()],
&[&seeds], // PDA сиды → для подписи
)?;
@ -251,10 +209,7 @@ pub fn create_pda<'info>(
/// Аргументы:
/// - `pda_account`: аккаунт, в который пишем (должен быть mut)
/// - `data`: бинарный массив, который нужно записать
pub fn write_to_pda<'info>(
pda_account: &AccountInfo<'info>,
data: &[u8],
) -> Result<()> {
pub fn write_to_pda<'info>(pda_account: &AccountInfo<'info>, data: &[u8]) -> Result<()> {
// ───────────────────────────────────────────────
// 1. Получаем доступ к данным PDA (на запись)
let mut account_data = pda_account.try_borrow_mut_data()?;
@ -274,15 +229,6 @@ pub fn write_to_pda<'info>(
Ok(())
}
/// ------------------------------------------------------------------------
/// safe_read_pda «безопасное чтение PDA»
/// ------------------------------------------------------------------------
@ -333,17 +279,15 @@ pub fn safe_read_pda<'info>(pda_account: &AccountInfo<'info>) -> Vec<u8> {
}
Err(e) => {
// Ошибка при borrow (например, уже есть активное мутабельное заимствование)
msg!("safe_read_pda: ошибка borrow_data ({:?}) возвращаем пустой массив", e);
msg!(
"safe_read_pda: ошибка borrow_data ({:?}) возвращаем пустой массив",
e
);
Vec::new()
}
}
}
/// ------------------------------------------------------------------------
/// delete_pda_with_assign — закрыть PDA, вернуть ренту и освободить адрес
/// ------------------------------------------------------------------------
@ -373,7 +317,10 @@ pub fn delete_pda_return_rent<'info>(
program_id: &Pubkey,
) -> Result<()> {
// 0) проверки
require!(pda_account.owner != &Pubkey::default(), ErrCode::EmptyPdaData);
require!(
pda_account.owner != &Pubkey::default(),
ErrCode::EmptyPdaData
);
require!(pda_account.owner == program_id, ErrCode::InvalidPdaAddress);
// 1) Переложить все лампорты с PDA на получателя (мы владелец, это разрешено)
@ -389,7 +336,9 @@ pub fn delete_pda_return_rent<'info>(
// 2) Нулим данные (если были)
if !pda_account.data_is_empty() {
let mut data = pda_account.try_borrow_mut_data()?;
for b in data.iter_mut() { *b = 0; }
for b in data.iter_mut() {
*b = 0;
}
}
// 3) Сжать до 0 байт
@ -399,4 +348,3 @@ pub fn delete_pda_return_rent<'info>(
msg!("PDA закрыт: рента отправлена на {}", recipient.key);
Ok(())
}

View File

@ -0,0 +1,19 @@
# Oracle Check
Мини-страница диагностики оракула Pyth для `SOL/USD`.
Файл:
- `index.html`
Что проверяет:
1. Чтение oracle account через RPC (`devnet`, `mainnet-beta`, `testnet`).
2. Парсинг по текущим оффсетам из UI (`74/90/94`).
3. Альтернативный парсинг (`73/89/93`) для проверки сдвига формата.
4. Сравнение с Hermes API (эталонный источник цены по feed id).
Запуск:
Открыть `index.html` в браузере и нажать кнопку «Проверить все сети».

View File

@ -0,0 +1,255 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Проверка оракула Pyth (Devnet/Mainnet/Testnet)</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1218;
--panel: #171b24;
--text: #e8edf6;
--muted: #97a3b8;
--line: #2a3242;
--ok: #55d48a;
--warn: #ffbf5e;
--err: #ff7d7d;
--btn: #273247;
--btn-hover: #32415c;
--code: #1e2633;
}
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; margin: 20px; background: var(--bg); color: var(--text); }
.wrap { width: 100%; max-width: 1800px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 9px 10px; min-width: 340px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
button:hover { background: var(--btn-hover); }
.muted { color: var(--muted); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.err { color: var(--err); white-space: pre-wrap; }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { border: 1px solid var(--line); padding: 6px; text-align: left; vertical-align: top; }
</style>
</head>
<body>
<div class="wrap">
<h1>Диагностика оракула Pyth (SOL/USD)</h1>
<div class="panel">
<div class="muted">
Страница нужна, чтобы проверить, что именно возвращает аккаунт оракула в разных сетях и где ломается парсинг.
</div>
<div class="muted">
Проверяются три сети: <code>devnet</code>, <code>mainnet-beta</code>, <code>testnet</code>.
</div>
</div>
<div class="panel">
<h3>Настройки</h3>
<div class="row">
<label>Feed ID (hex с 0x):<br /><input id="feedId" value="0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d" /></label>
</div>
<div class="row">
<label>Oracle account (devnet):<br /><input id="oracleDevnet" value="7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE" /></label>
</div>
<div class="row">
<label>Oracle account (mainnet-beta):<br /><input id="oracleMainnet" value="7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE" /></label>
</div>
<div class="row">
<label>Oracle account (testnet):<br /><input id="oracleTestnet" value="7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE" /></label>
</div>
<div class="row">
<button id="runBtn">Проверить все сети</button>
</div>
<div class="muted">
Если для сети аккаунт не существует, это тоже покажется в отчёте.
</div>
</div>
<div class="panel">
<h3>Результаты</h3>
<div id="out" class="muted">Нажмите «Проверить все сети».</div>
</div>
</div>
<script>
const NETWORKS = [
{ name: "devnet", rpc: "https://api.devnet.solana.com", inputId: "oracleDevnet" },
{ name: "mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", inputId: "oracleMainnet" },
{ name: "testnet", rpc: "https://api.testnet.solana.com", inputId: "oracleTestnet" },
];
function readU64(data, offset) {
let x = 0n;
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x;
}
function readI64(data, offset) {
let x = readU64(data, offset);
if (x > 0x7fffffffffffffffn) x -= 0x10000000000000000n;
return x;
}
function readU32(data, offset) {
return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
}
function readI32(data, offset) {
let x = readU32(data, offset);
if (x > 0x7fffffff) x -= 0x100000000;
return x;
}
function toHex(bytes) {
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
}
function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
}
function priceToFloatStr(price, exponent) {
const p = Number(price);
if (!Number.isFinite(p)) return "NaN";
return trimZeros((p * Math.pow(10, exponent)).toFixed(12));
}
function fmtErr(e) {
const s = String(e?.message || e || "unknown error");
return s.length > 350 ? s.slice(0, 350) + "..." : s;
}
function parseWithOffsets(data, priceOffset, exponentOffset, publishOffset) {
const price = readI64(data, priceOffset);
const exponent = readI32(data, exponentOffset);
const publishTime = readI64(data, publishOffset);
return { price, exponent, publishTime, valueStr: priceToFloatStr(price, exponent) };
}
function parseFeedIdFromLikelyPosition(data) {
// Для текущего PriceUpdateV2 в аккаунте receiver:
// 0..7 discriminator, 8..39 write_authority, 40..40 verification enum, 41..72 feed_id.
if (data.length < 73) return null;
const feedRaw = data.slice(41, 73);
return "0x" + toHex(feedRaw);
}
async function rpcGetAccountInfo(rpcUrl, pubkey) {
const body = {
jsonrpc: "2.0",
id: 1,
method: "getAccountInfo",
params: [pubkey, { encoding: "base64", commitment: "confirmed" }],
};
const res = await fetch(rpcUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`RPC HTTP ${res.status}`);
const json = await res.json();
if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
return json.result?.value || null;
}
async function fetchHermesPrice(feedIdHex) {
const url = `https://hermes.pyth.network/v2/updates/price/latest?ids[]=${encodeURIComponent(feedIdHex)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Hermes HTTP ${res.status}`);
const json = await res.json();
const p = json?.parsed?.[0]?.price;
if (!p) throw new Error("Hermes: price not found");
return {
price: BigInt(p.price),
expo: Number(p.expo),
publishTime: BigInt(p.publish_time),
valueStr: priceToFloatStr(BigInt(p.price), Number(p.expo)),
};
}
async function runCheck() {
const out = document.getElementById("out");
out.textContent = "Проверка...";
const feedId = document.getElementById("feedId").value.trim().toLowerCase();
const rows = [];
let hermes = null;
let hermesErr = null;
try {
hermes = await fetchHermesPrice(feedId);
} catch (e) {
hermesErr = fmtErr(e);
}
for (const n of NETWORKS) {
const oracle = document.getElementById(n.inputId).value.trim();
try {
const ai = await rpcGetAccountInfo(n.rpc, oracle);
if (!ai) {
rows.push({
network: n.name,
status: "account not found",
details: "Аккаунт оракула не найден в этой сети",
});
continue;
}
const data = Uint8Array.from(atob(ai.data[0]), c => c.charCodeAt(0));
const feedFromData = parseFeedIdFromLikelyPosition(data);
const parsedCurrentUi = parseWithOffsets(data, 74, 90, 94); // как сейчас в UI проекта
const parsedShiftMinus1 = parseWithOffsets(data, 73, 89, 93); // диагностический вариант
const currentUiValid = parsedCurrentUi.price > 0n;
const shiftValid = parsedShiftMinus1.price > 0n;
const feedMatch = feedFromData === feedId;
rows.push({
network: n.name,
status: "ok",
details: `
<div>RPC: <code>${n.rpc}</code></div>
<div>Account owner: <code>${ai.owner}</code>, data len: <b>${data.length}</b></div>
<div>Feed ID (из аккаунта): <code>${feedFromData || "n/a"}</code> ${feedMatch ? '<span class="ok">совпадает</span>' : '<span class="warn">НЕ совпадает</span>'}</div>
<div>Парсер UI (offsets 74/90/94): price=<code>${parsedCurrentUi.price.toString()}</code>, exp=<code>${parsedCurrentUi.exponent}</code>, value=<b>${parsedCurrentUi.valueStr}</b> ${currentUiValid ? '<span class="ok">valid</span>' : '<span class="err">invalid</span>'}</div>
<div>Альт. парсер (offsets 73/89/93): price=<code>${parsedShiftMinus1.price.toString()}</code>, exp=<code>${parsedShiftMinus1.exponent}</code>, value=<b>${parsedShiftMinus1.valueStr}</b> ${shiftValid ? '<span class="ok">valid</span>' : '<span class="err">invalid</span>'}</div>
${hermes
? `<div>Hermes (эталон): price=<code>${hermes.price.toString()}</code>, exp=<code>${hermes.expo}</code>, value=<b>${hermes.valueStr}</b></div>`
: `<div class="warn">Hermes недоступен: ${hermesErr}</div>`
}
`,
});
} catch (e) {
rows.push({
network: n.name,
status: "error",
details: `<span class="err">${fmtErr(e)}</span>`,
});
}
}
out.innerHTML = `
<table>
<thead>
<tr>
<th>Сеть</th>
<th>Статус</th>
<th>Детали</th>
</tr>
</thead>
<tbody>
${rows.map((r) => `
<tr>
<td><b>${r.network}</b></td>
<td>${r.status === "ok" ? '<span class="ok">ok</span>' : (r.status === "account not found" ? '<span class="warn">not found</span>' : '<span class="err">error</span>')}</td>
<td>${r.details}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
document.getElementById("runBtn").addEventListener("click", runCheck);
</script>
</body>
</html>

View File

@ -148,9 +148,9 @@
return BigInt(Math.round(v * 1_000_000_000));
}
function parsePythPriceUpdateV2(data) {
const price = readI64(data, 74);
const exponent = readI32(data, 90);
const publishTime = readI64(data, 94);
const price = readI64(data, 73);
const exponent = readI32(data, 89);
const publishTime = readI64(data, 93);
if (price <= 0n) throw new Error("Оракул вернул некорректную цену");
let num = price * 100n;
let den = 1n;

View File

@ -0,0 +1,342 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DAO revoke vote — Shine Payments Devnet</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1218;
--panel: #171b24;
--text: #e8edf6;
--muted: #97a3b8;
--line: #2a3242;
--ok: #55d48a;
--warn: #ffbf5e;
--err: #ff7d7d;
--btn: #273247;
--btn-hover: #32415c;
--code: #1e2633;
}
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; margin: 20px; background: var(--bg); color: var(--text); }
.wrap { width: 100%; max-width: 1200px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
label { display: inline-flex; flex-direction: column; gap: 6px; color: var(--muted); min-width: 280px; }
input { padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
button:hover { background: var(--btn-hover); }
.muted { color: var(--muted); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.err { color: var(--err); white-space: pre-wrap; }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
</style>
</head>
<body>
<div class="wrap">
<div style="margin-bottom: 12px;"><a class="back" href="./index.html">На главную</a></div>
<h1>DAO: голосование на revoke/burn membership token (Devnet)</h1>
<div class="muted">Governance program: <code id="govPid"></code></div>
<div class="panel">
<div class="row">
<button id="connectBtn">Подключить Phantom</button>
</div>
<div id="walletInfo" class="muted">Кошелек: не подключен</div>
</div>
<div class="panel">
<div class="row">
<label>Realm
<input id="realm" placeholder="Realm pubkey" />
</label>
<label>Governance
<input id="governance" placeholder="Governance pubkey" />
</label>
<label>Community mint
<input id="mint" placeholder="Mint pubkey" />
</label>
</div>
<div class="row">
<label>Target owner
<input id="targetOwner" placeholder="Кого лишаем governance token" />
</label>
<label>Amount
<input id="amount" value="1" />
</label>
</div>
<div class="row">
<button id="createVoteBtn">Create + SignOff + Vote</button>
</div>
<div id="proposalResult" class="muted"></div>
</div>
<div class="panel">
<div class="row">
<label>Proposal
<input id="proposal" placeholder="Proposal pubkey" />
</label>
<label>Proposal transaction
<input id="proposalTx" placeholder="ProposalTransaction pubkey" />
</label>
</div>
<div class="row">
<button id="executeBtn">Execute revoke</button>
</div>
<div class="muted">Если получите hold-up (`0x20d`) — дождитесь конца voting window/hold-up и повторите execute.</div>
<div id="executeResult" class="muted"></div>
</div>
</div>
<script type="module">
import BN from "https://esm.sh/bn.js@5.2.1";
import {
Connection,
PublicKey,
Transaction,
clusterApiUrl
} from "https://esm.sh/@solana/web3.js@1.95.3";
import {
PROGRAM_VERSION_V3,
Vote,
YesNoVote,
VoteType,
InstructionData,
AccountMetaData,
withRevokeGoverningTokens,
withCreateProposal,
withInsertTransaction,
withSignOffProposal,
withCastVote,
withExecuteTransaction,
getTokenOwnerRecordAddress
} from "https://esm.sh/@solana/spl-governance@0.3.28";
const GOVERNANCE_PROGRAM_ID = new PublicKey("GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw");
const connection = new Connection(clusterApiUrl("devnet"), "confirmed");
document.getElementById("govPid").textContent = GOVERNANCE_PROGRAM_ID.toBase58();
let wallet = null;
let walletPubkey = null;
function out(id, html, cls = "muted") {
const el = document.getElementById(id);
el.className = cls;
el.innerHTML = html;
}
function mustPubkey(id) {
const raw = document.getElementById(id).value.trim();
if (!raw) throw new Error(`Пустое поле: ${id}`);
return new PublicKey(raw);
}
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 connect() {
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
wallet = window.solana;
const res = await wallet.connect();
walletPubkey = new PublicKey(res.publicKey.toString());
out("walletInfo", `Кошелек: <code>${walletPubkey.toBase58()}</code>`, "muted");
}
async function sendIxs(ixs) {
if (!walletPubkey) await connect();
const tx = new Transaction().add(...ixs);
tx.feePayer = walletPubkey;
const bh = await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = bh.blockhash;
const signed = await wallet.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
await connection.confirmTransaction({ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, "confirmed");
return sig;
}
async function createSignVote() {
out("proposalResult", "Выполняю...", "muted");
try {
const realm = mustPubkey("realm");
const governance = mustPubkey("governance");
const mint = mustPubkey("mint");
const targetOwner = mustPubkey("targetOwner");
const amount = new BN(document.getElementById("amount").value.trim() || "1");
if (amount.lten(0)) throw new Error("Amount должен быть > 0");
const proposerRecord = await getTokenOwnerRecordAddress(
GOVERNANCE_PROGRAM_ID,
realm,
mint,
walletPubkey
);
const proposalName = `Revoke ${amount.toString()} from ${targetOwner.toBase58().slice(0, 8)}...`;
const proposalDescription = "https://arweave.net/";
const ixCreateProposal = [];
const proposalPk = await withCreateProposal(
ixCreateProposal,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
governance,
proposerRecord,
proposalName,
proposalDescription,
mint,
walletPubkey,
undefined,
VoteType.SINGLE_CHOICE,
["Approve"],
true,
walletPubkey
);
const txCreateProposal = await sendIxs(ixCreateProposal);
const ixRawRevoke = [];
await withRevokeGoverningTokens(
ixRawRevoke,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
targetOwner,
mint,
governance,
amount
);
const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]);
const ixInsert = [];
const proposalTxPk = await withInsertTransaction(
ixInsert,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
governance,
proposalPk,
proposerRecord,
walletPubkey,
0,
0,
0,
[revokeInstructionData],
walletPubkey
);
const txInsert = await sendIxs(ixInsert);
const ixSignOff = [];
withSignOffProposal(
ixSignOff,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
governance,
proposalPk,
walletPubkey,
undefined,
proposerRecord
);
const txSignOff = await sendIxs(ixSignOff);
const ixVote = [];
const vote = Vote.fromYesNoVote(YesNoVote.Yes);
const voteRecordPk = await withCastVote(
ixVote,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
governance,
proposalPk,
proposerRecord,
proposerRecord,
walletPubkey,
mint,
vote,
walletPubkey
);
const txVote = await sendIxs(ixVote);
document.getElementById("proposal").value = proposalPk.toBase58();
document.getElementById("proposalTx").value = proposalTxPk.toBase58();
out(
"proposalResult",
`Proposal: <code>${proposalPk.toBase58()}</code><br/>` +
`ProposalTx: <code>${proposalTxPk.toBase58()}</code><br/>` +
`VoteRecord: <code>${voteRecordPk.toBase58()}</code><br/>` +
`Tx create: <code>${txCreateProposal}</code><br/>` +
`Tx insert: <code>${txInsert}</code><br/>` +
`Tx signOff: <code>${txSignOff}</code><br/>` +
`Tx vote: <code>${txVote}</code>`,
"ok"
);
} catch (e) {
out("proposalResult", String(e?.message || e), "err");
}
}
async function executeRevoke() {
out("executeResult", "Выполняю execute...", "muted");
try {
const governance = mustPubkey("governance");
const proposal = mustPubkey("proposal");
const proposalTx = mustPubkey("proposalTx");
const realm = mustPubkey("realm");
const mint = mustPubkey("mint");
const targetOwner = mustPubkey("targetOwner");
const amount = new BN(document.getElementById("amount").value.trim() || "1");
const ixRawRevoke = [];
await withRevokeGoverningTokens(
ixRawRevoke,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
realm,
targetOwner,
mint,
governance,
amount
);
const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]);
const ixExecute = [];
await withExecuteTransaction(
ixExecute,
GOVERNANCE_PROGRAM_ID,
PROGRAM_VERSION_V3,
governance,
proposal,
proposalTx,
[revokeInstructionData]
);
const sig = await sendIxs(ixExecute);
out("executeResult", `Execute success. Tx: <code>${sig}</code>`, "ok");
} catch (e) {
const msg = String(e?.message || e);
out("executeResult", msg, "err");
}
}
document.getElementById("connectBtn").addEventListener("click", async () => {
try { await connect(); } catch (e) { out("walletInfo", String(e?.message || e), "err"); }
});
document.getElementById("createVoteBtn").addEventListener("click", createSignVote);
document.getElementById("executeBtn").addEventListener("click", executeRevoke);
</script>
</body>
</html>

View File

@ -61,6 +61,11 @@
<div class="muted">Выдача лимитов менеджерам в USD для добавления билетов в очередь 1/2.</div>
</a>
<a class="card" href="./dao_revoke_vote.html">
<h3>DAO revoke governance token</h3>
<div class="muted">UI для proposal/vote/execute на отзыв (burn/revoke) membership governance токенов.</div>
</a>
<a class="card" href="./manager_tools.html">
<h3>Инструменты менеджера</h3>
<div class="muted">Показ лимитов менеджера и создание билетов в очередь 1/2 в USD.</div>

View File

@ -142,9 +142,9 @@
return s.includes("notenoughinflowforstep") || s.includes("0x177a");
}
function parsePythPriceUpdateV2(data) {
const price = readI64(data, 74);
const exponent = readI32(data, 90);
const publishTime = readI64(data, 94);
const price = readI64(data, 73);
const exponent = readI32(data, 89);
const publishTime = readI64(data, 93);
if (price <= 0n) throw new Error("Оракул вернул некорректную цену");
let num = price * 100n;
let den = 1n;

View File

@ -3,7 +3,7 @@ pub const USER_PDA_SEED_PREFIX: &str = "login=";
// (в частности, сценарии ротации root key с дополнительной подписью старого ключа).
pub const USER_PDA_SPACE: usize = 768;
pub const REGISTRATION_FEE_RECEIVER: &str = "6bFc5Gz5qF172GQhK5HpDbWs8F6qcSxdHn5XqAstf1fY";
pub const REGISTRATION_FEE_RECEIVER: &str = "9vXFoN9ngfN1gpqQ3HT5n3y9Wp2r7HnSQckirgwVwWwb";
pub const REGISTRATION_FEE_LAMPORTS: u64 = 10_000_000; // 0.01 SOL
pub const LIMIT_STEP: u64 = 10_000;

View File

@ -5,8 +5,8 @@ use anchor_lang::solana_program::{
hash::hashv,
instruction::Instruction,
program::invoke,
sysvar::instructions::{load_current_index_checked, load_instruction_at_checked},
system_instruction,
sysvar::instructions::{load_current_index_checked, load_instruction_at_checked},
};
use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode};
use std::str::FromStr;
@ -221,7 +221,11 @@ pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) ->
old_record.created_at_ms == args.created_at_ms,
ErrCode::ImmutableFieldChanged
);
require_keys_eq!(old_record.root_key, args.root_key, ErrCode::ImmutableFieldChanged);
require_keys_eq!(
old_record.root_key,
args.root_key,
ErrCode::ImmutableFieldChanged
);
require!(
args.version == old_record.version.saturating_add(1),
ErrCode::InvalidVersion
@ -455,18 +459,18 @@ fn verify_record_signature(
let provided_sig = vec_to_signature(signature)?;
let msg_hash = hashv(&[unsigned]);
let current_ix_index =
load_current_index_checked(instructions_sysvar).map_err(|_| error!(ErrCode::InvalidSignature))?;
let current_ix_index = load_current_index_checked(instructions_sysvar)
.map_err(|_| error!(ErrCode::InvalidSignature))?;
require!(current_ix_index > 0, ErrCode::InvalidSignature);
let ed_ix = load_instruction_at_checked(
(current_ix_index - 1) as usize,
instructions_sysvar,
)
.map_err(|_| error!(ErrCode::InvalidSignature))?;
let ed_ix = load_instruction_at_checked((current_ix_index - 1) as usize, instructions_sysvar)
.map_err(|_| error!(ErrCode::InvalidSignature))?;
let parsed = parse_ed25519_ix(&ed_ix)?;
require_keys_eq!(parsed.pubkey, *root_key, ErrCode::InvalidSignature);
require!(parsed.message == msg_hash.as_ref(), ErrCode::InvalidSignature);
require!(
parsed.message == msg_hash.as_ref(),
ErrCode::InvalidSignature
);
require!(parsed.signature == provided_sig, ErrCode::InvalidSignature);
Ok(parsed.signature)
@ -479,7 +483,11 @@ struct ParsedEd25519 {
}
fn parse_ed25519_ix(ix: &Instruction) -> Result<ParsedEd25519> {
require_keys_eq!(ix.program_id, ed25519_program::id(), ErrCode::InvalidSignature);
require_keys_eq!(
ix.program_id,
ed25519_program::id(),
ErrCode::InvalidSignature
);
let data = &ix.data;
require!(data.len() >= 16, ErrCode::InvalidSignature);
@ -554,7 +562,10 @@ fn validate_login(login: &str) -> Result<()> {
fn validate_fields(fields: &UserMutableFields) -> Result<()> {
if fields.is_server {
require!(!fields.server_address.is_empty(), ErrCode::InvalidRecordData);
require!(
!fields.server_address.is_empty(),
ErrCode::InvalidRecordData
);
require!(
fields.server_address.as_bytes().len() <= u8::MAX as usize,
ErrCode::InvalidRecordData
@ -568,7 +579,10 @@ fn validate_fields(fields: &UserMutableFields) -> Result<()> {
);
for login in &fields.connection_servers {
require!(!login.is_empty(), ErrCode::InvalidRecordData);
require!(login.as_bytes().len() <= u8::MAX as usize, ErrCode::InvalidRecordData);
require!(
login.as_bytes().len() <= u8::MAX as usize,
ErrCode::InvalidRecordData
);
}
Ok(())
}
@ -590,7 +604,10 @@ fn transfer_lamports<'info>(
return Ok(());
}
let ix = system_instruction::transfer(payer.key, recipient.key, lamports);
invoke(&ix, &[payer.clone(), recipient.clone(), system_program.clone()])?;
invoke(
&ix,
&[payer.clone(), recipient.clone(), system_program.clone()],
)?;
Ok(())
}
@ -629,7 +646,9 @@ fn vec_to_hash32(input: &[u8]) -> Result<[u8; 32]> {
}
fn read_u8(data: &[u8], cursor: &mut usize) -> Result<u8> {
let v = *data.get(*cursor).ok_or(error!(ErrCode::InvalidRecordData))?;
let v = *data
.get(*cursor)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor += 1;
Ok(v)
}
@ -638,7 +657,9 @@ fn read_u16(data: &[u8], cursor: &mut usize) -> Result<u16> {
let end = cursor
.checked_add(2)
.ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end;
Ok(u16::from_le_bytes([slice[0], slice[1]]))
}
@ -647,7 +668,9 @@ fn read_u32(data: &[u8], cursor: &mut usize) -> Result<u32> {
let end = cursor
.checked_add(4)
.ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end;
Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
}
@ -656,7 +679,9 @@ fn read_u64(data: &[u8], cursor: &mut usize) -> Result<u64> {
let end = cursor
.checked_add(8)
.ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end;
Ok(u64::from_le_bytes([
slice[0], slice[1], slice[2], slice[3], slice[4], slice[5], slice[6], slice[7],
@ -667,7 +692,9 @@ fn read_fixed_32(data: &[u8], cursor: &mut usize) -> Result<[u8; 32]> {
let end = cursor
.checked_add(32)
.ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end;
let mut out = [0u8; 32];
out.copy_from_slice(slice);
@ -678,7 +705,9 @@ fn read_fixed_64(data: &[u8], cursor: &mut usize) -> Result<[u8; 64]> {
let end = cursor
.checked_add(64)
.ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end;
let mut out = [0u8; 64];
out.copy_from_slice(slice);
@ -690,7 +719,9 @@ fn read_len_prefixed_string(data: &[u8], cursor: &mut usize) -> Result<String> {
let end = cursor
.checked_add(len)
.ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data.get(*cursor..end).ok_or(error!(ErrCode::InvalidRecordData))?;
let slice = data
.get(*cursor..end)
.ok_or(error!(ErrCode::InvalidRecordData))?;
*cursor = end;
let value = std::str::from_utf8(slice).map_err(|_| error!(ErrCode::InvalidRecordData))?;
Ok(value.to_string())

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 оставлен как технический инструмент.

View File

@ -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);
});

View File

@ -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"

View File

@ -0,0 +1,37 @@
# Конфиг создания DAO (Devnet/Mainnet)
# ВАЖНО: перед каждым новым тестом меняйте DAO_REALM_NAME на новый.
# devnet | mainnet-beta
DAO_CLUSTER="devnet"
# Человекочитаемое имя DAO (должно быть новым для каждого теста)
DAO_REALM_NAME="Devnet DAO Full-1 2026-05-09"
# Название и символ governance NFT
DAO_GOV_NFT_NAME="Shine Governance NFT"
DAO_GOV_NFT_SYMBOL="SHINE-GOV"
DAO_GOV_TOKEN_DESCRIPTION="Governance token for SHiNE DAO"
DAO_GOV_TOKEN_IMAGE_URL="https://arweave.net/6cMl-qV-7vpgXSdOR5MVnMEPMHgLkRdfgdO43I9z5ek"
DAO_GOV_TOKEN_METADATA_URI="https://arweave.net/6cMl-qV-7vpgXSdOR5MVnMEPMHgLkRdfgdO43I9z5ek"
# Количество стартовых governance NFT
DAO_GOV_NFT_SUPPLY="10"
# Время голосования в секундах (для теста 1 час = 3600)
DAO_VOTING_TIME_SEC="3600"
# Минимальный порог "за" в процентах (больше половины = 51)
DAO_APPROVAL_THRESHOLD_PERCENT="51"
# Кошелек эмиттера/инициализатора DAO
DAO_ISSUER_KEYPAIR="$HOME/.config/solana/phantomWallet.json"
# Кто имеет право на force revoke/burn membership-токенов через RevokeGoverningTokens.
# На тестовом этапе можно оставить тот же кошелек, что и issuer.
# В production обычно передают управление через governance-процедуры DAO.
DAO_REVOKE_AUTHORITY_KEYPAIR="$HOME/.config/solana/phantomWallet.json"
# Текущий governance program (Realms, SPL Governance)
# Devnet/Mainnet обычно используют одинаковый ID программы.
SPL_GOVERNANCE_PROGRAM_ID="GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw"

View File

@ -0,0 +1,108 @@
#!/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);
});

View File

@ -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);
});

View File

@ -0,0 +1,112 @@
#!/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);
});

View File

@ -0,0 +1,31 @@
{
"createdAt": "2026-05-08T23:21:53.557Z",
"cluster": "devnet",
"configPath": "/home/ai/work/SOLANA/shine-solana/shine/scripts/dao/dao.config.env",
"realmName": "Devnet DAO Full-1 2026-05-09",
"governanceProgramId": "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw",
"issuer": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"communityMint": "AKKhTDHWbDZy39f3UKeiP2z1rFmbv5K4DvxVL7KTQy8b",
"issuerAta": "CHfjpcyHotgczBErH7CCW9QMp8AeaGGyJxAtgV7ZyEwA",
"realm": "4vPg3A7uPZ4gZkNGy6vbDwsqYLwPgeciYqRbfpfuRa7A",
"tokenOwnerRecord": "97anU7DvmdpsQUY8ScmxswSPaGmMMw2FnNESiDQwzMjr",
"governance": "3NS5DT83nxh88xcndCPNrziFMvZNAjCSRu5jF1SpPRRt",
"nativeTreasury": "9vXFoN9ngfN1gpqQ3HT5n3y9Wp2r7HnSQckirgwVwWwb",
"metadataUri": "https://arweave.net/6cMl-qV-7vpgXSdOR5MVnMEPMHgLkRdfgdO43I9z5ek",
"imageUrl": "https://arweave.net/6cMl-qV-7vpgXSdOR5MVnMEPMHgLkRdfgdO43I9z5ek",
"txMint": "47cWAoXbDHYpW74exnBRma9QBoa3NMVx7iEU9mZsn74NYy3AK4tPptM1oPbaW91Mw3nZBTgxQtzgKSKP2Gdr8Hyh",
"txMetadata": "50d05LiBAXFmbt+jGZpw5tzvvNPAgwy/SiSmzb9l/v73GDFc2eN6+udw0fd9HdjYlP6t4oKYcsZB/kTRzqzMBg==",
"txRealm": "4v22Zmsm5qkoDQaeNuzeziH7GjxiUDSDRVeXLDYwgrXF3Ltw5xSkZFrVwPAiTZRPXzrm5bhbtSHd2rA7xPq9v4kG",
"txDeposit": "2gvWE3iA4yXhx3z1yshRRQgNEHcQgskmEsHSCo2X5WnvyXMzx8jtfyWZosvH7GWMmmtQegdaaTdtrAWeLXjiHtLw",
"txGovernanceTreasury": "4mArdxprVHpFtLMwyxmz9E99J8jwrGSsYvFmjR1SCMeWPim3HMZZQai9zrwBBn461axx5QDWiN7c9YVP7hXFmBmi",
"txSetRealmAuthority": "39qz1J4R63hxQQRrJ1EgdFG45PofBmtguP8V9Xx35FtPoo4APe5kPXhqyMKRK2o61iYHG5zvFob9bgUuzwvYPzaG",
"votingTimeSec": 3600,
"thresholdPercent": 51,
"tokenSupply": 10,
"startBalanceLamports": 19996489120,
"endBalanceLamports": 19963662080,
"spentLamports": 32827040,
"startBalanceSol": 19.99648912,
"endBalanceSol": 19.96366208,
"spentSol": 0.03282704
}

View File

@ -0,0 +1,26 @@
createdAt: 2026-05-08T23:21:53.557Z
cluster: devnet
realmName: Devnet DAO Full-1 2026-05-09
governanceProgramId: GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw
issuer: FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P
communityMint: AKKhTDHWbDZy39f3UKeiP2z1rFmbv5K4DvxVL7KTQy8b
issuerAta: CHfjpcyHotgczBErH7CCW9QMp8AeaGGyJxAtgV7ZyEwA
realm: 4vPg3A7uPZ4gZkNGy6vbDwsqYLwPgeciYqRbfpfuRa7A
tokenOwnerRecord: 97anU7DvmdpsQUY8ScmxswSPaGmMMw2FnNESiDQwzMjr
governance: 3NS5DT83nxh88xcndCPNrziFMvZNAjCSRu5jF1SpPRRt
nativeTreasury: 9vXFoN9ngfN1gpqQ3HT5n3y9Wp2r7HnSQckirgwVwWwb
metadataUri: https://arweave.net/6cMl-qV-7vpgXSdOR5MVnMEPMHgLkRdfgdO43I9z5ek
imageUrl: https://arweave.net/6cMl-qV-7vpgXSdOR5MVnMEPMHgLkRdfgdO43I9z5ek
txMint: 47cWAoXbDHYpW74exnBRma9QBoa3NMVx7iEU9mZsn74NYy3AK4tPptM1oPbaW91Mw3nZBTgxQtzgKSKP2Gdr8Hyh
txMetadata: 50d05LiBAXFmbt+jGZpw5tzvvNPAgwy/SiSmzb9l/v73GDFc2eN6+udw0fd9HdjYlP6t4oKYcsZB/kTRzqzMBg==
txRealm: 4v22Zmsm5qkoDQaeNuzeziH7GjxiUDSDRVeXLDYwgrXF3Ltw5xSkZFrVwPAiTZRPXzrm5bhbtSHd2rA7xPq9v4kG
txDeposit: 2gvWE3iA4yXhx3z1yshRRQgNEHcQgskmEsHSCo2X5WnvyXMzx8jtfyWZosvH7GWMmmtQegdaaTdtrAWeLXjiHtLw
txGovernanceTreasury: 4mArdxprVHpFtLMwyxmz9E99J8jwrGSsYvFmjR1SCMeWPim3HMZZQai9zrwBBn461axx5QDWiN7c9YVP7hXFmBmi
txSetRealmAuthority: 39qz1J4R63hxQQRrJ1EgdFG45PofBmtguP8V9Xx35FtPoo4APe5kPXhqyMKRK2o61iYHG5zvFob9bgUuzwvYPzaG
tokenSupply: 10
votingTimeSec: 3600
thresholdPercent: 51
startBalanceSol: 19.99648912
endBalanceSol: 19.96366208
spentSol: 0.03282704
configPath: /home/ai/work/SOLANA/shine-solana/shine/scripts/dao/dao.config.env

View File

@ -0,0 +1,23 @@
{
"createdAt": "2026-05-08T23:24:43.905Z",
"cluster": "devnet",
"configPath": "/home/ai/work/SOLANA/shine-solana/shine/scripts/dao/dao.config.env",
"governanceProgramId": "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw",
"realm": "4vPg3A7uPZ4gZkNGy6vbDwsqYLwPgeciYqRbfpfuRa7A",
"governance": "3NS5DT83nxh88xcndCPNrziFMvZNAjCSRu5jF1SpPRRt",
"mint": "AKKhTDHWbDZy39f3UKeiP2z1rFmbv5K4DvxVL7KTQy8b",
"targetOwner": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"amount": "1",
"proposer": "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P",
"proposerRecord": "97anU7DvmdpsQUY8ScmxswSPaGmMMw2FnNESiDQwzMjr",
"proposal": "8A9cthr1PG6t2xdUyC928KZVpTzZPW7YoJsYPYjw8o2E",
"proposalTransaction": "krWkKxVaQDd3C6pdcJmH7Wu1tgk6RtS2AcoJba4VZGq",
"voteRecord": "EyWuYWt5jhCoySPWvFcm7DBaCVM5v4Y9N8Kcd3LCf24D",
"txCreateProposal": "kt4vtQbwcXY4KF4SKCHkzv86qxYXaA5Wapjug4VtB6xi6WmwxURzn9az41gb9sYKrhZ1U9qdgePUr4jLt7iTCkZ",
"txInsertTransaction": "47KrAnsj7whE7GyeQZzKoc1ciUfy6r3YFZFsF7huorzewx1SSxCXCF2QaJCRXdBw87u53CLrS7JM1D4BDXUZWbsA",
"txSignOff": "5X69zNCHrQAcSR5guneKh7NG5qfMvaYtvH9vz4pvwY4B2zQs3NhnMk47X5xF25EV6Cf2fANkpjkNdkqSmVZU9U2E",
"txVote": "5Dwnp7568BaRQJ67FHJC46KT9Ym4RSNyZWSSSM9KyFx5U1fSwsejBkvEstQQ4wuoBtcpNgsTcwjdMPUMrw5N3Haf",
"txFinalize": null,
"txExecute": null,
"executeError": "Simulation failed. \nMessage: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x20d. \nLogs: \n[\n \"Program GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw invoke [1]\",\n \"Program log: VERSION:\\\"3.1.2\\\"\",\n \"Program log: GOVERNANCE-INSTRUCTION: ExecuteTransaction\",\n \"Program log: GOVERNANCE-ERROR: Can't execute transaction within its hold up time\",\n \"Program GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw consumed 10045 of 200000 compute units\",\n \"Program GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw failed: custom program error: 0x20d\"\n]. \nCatch the `SendTransactionError` and call `getLogs()` on it for full details."
}

View File

@ -0,0 +1,29 @@
createdAt: 2026-05-08T23:24:43.905Z
cluster: devnet
realm: 4vPg3A7uPZ4gZkNGy6vbDwsqYLwPgeciYqRbfpfuRa7A
governance: 3NS5DT83nxh88xcndCPNrziFMvZNAjCSRu5jF1SpPRRt
mint: AKKhTDHWbDZy39f3UKeiP2z1rFmbv5K4DvxVL7KTQy8b
targetOwner: FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P
amount: 1
proposer: FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P
proposal: 8A9cthr1PG6t2xdUyC928KZVpTzZPW7YoJsYPYjw8o2E
proposalTransaction: krWkKxVaQDd3C6pdcJmH7Wu1tgk6RtS2AcoJba4VZGq
voteRecord: EyWuYWt5jhCoySPWvFcm7DBaCVM5v4Y9N8Kcd3LCf24D
txCreateProposal: kt4vtQbwcXY4KF4SKCHkzv86qxYXaA5Wapjug4VtB6xi6WmwxURzn9az41gb9sYKrhZ1U9qdgePUr4jLt7iTCkZ
txInsertTransaction: 47KrAnsj7whE7GyeQZzKoc1ciUfy6r3YFZFsF7huorzewx1SSxCXCF2QaJCRXdBw87u53CLrS7JM1D4BDXUZWbsA
txSignOff: 5X69zNCHrQAcSR5guneKh7NG5qfMvaYtvH9vz4pvwY4B2zQs3NhnMk47X5xF25EV6Cf2fANkpjkNdkqSmVZU9U2E
txVote: 5Dwnp7568BaRQJ67FHJC46KT9Ym4RSNyZWSSSM9KyFx5U1fSwsejBkvEstQQ4wuoBtcpNgsTcwjdMPUMrw5N3Haf
txFinalize: -
txExecute: -
executeError: Simulation failed.
Message: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x20d.
Logs:
[
"Program GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw invoke [1]",
"Program log: VERSION:\"3.1.2\"",
"Program log: GOVERNANCE-INSTRUCTION: ExecuteTransaction",
"Program log: GOVERNANCE-ERROR: Can't execute transaction within its hold up time",
"Program GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw consumed 10045 of 200000 compute units",
"Program GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw failed: custom program error: 0x20d"
].
Catch the `SendTransactionError` and call `getLogs()` on it for full details.

View File

@ -0,0 +1,7 @@
# LEGACY
Эта папка содержит старые варианты скриптов создания DAO.
Их оставили только для истории и сравнения.
Актуальные скрипты находятся в `scripts/dao`.

View File

@ -0,0 +1,379 @@
#!/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);
});

View File

@ -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_without_burn_build_exec.js scripts/dao/dao.config.env"

View File

@ -0,0 +1,379 @@
#!/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);
});

View File

@ -1,3 +1,11 @@
⚠️ УСТАРЕВШЕЕ (LEGACY)
Папка `scripts/devnet` относится к старой логике (legacy) и для текущей версии `shine_payments` больше не используется.
Актуальные скрипты по DAO и новой схеме находятся в `scripts/dao`.
---
Devnet E2E тест: NFT-модуль + add_bonus
Ветка содержит скрипты для проверки (NFT + add_bonus) в devnet.

View File

@ -0,0 +1,16 @@
{
"createdAt": "2026-05-15T12:20:22.373Z",
"prefix": "DAo",
"count": 1,
"ignoreCase": false,
"runsDir": "/home/ai/work/SOLANA/shine-solana/shine/scripts/governance_token/runs",
"avgExpectedTriesPerMatch": 195112,
"attemptsObserved": 0,
"foundHintsInOutput": 1,
"command": "solana-keygen grind --starts-with DAo:1",
"outputLog": [
"Searching with 16 threads for:",
"1 pubkey that starts with 'DAo' and ends with ''",
"Wrote keypair to DAou7SeaykoMooghA5SURLYhkkU8NEhV5Y2T6fsXD7rn.json"
]
}

View File

@ -0,0 +1 @@
[115,67,66,30,87,145,246,163,61,151,201,124,183,214,51,151,22,218,111,91,138,240,184,170,169,117,123,68,99,10,100,161,180,207,127,168,102,124,209,83,46,144,109,253,200,122,20,82,223,74,69,105,53,218,226,231,88,238,93,98,54,161,167,31]

View File

@ -0,0 +1,16 @@
{
"createdAt": "2026-05-15T12:20:09.856Z",
"prefix": "DAo",
"count": 1,
"ignoreCase": false,
"runsDir": "/home/ai/work/SOLANA/shine-solana/shine/scripts/governance_token/scripts/governance_token/runs",
"avgExpectedTriesPerMatch": 195112,
"attemptsObserved": 0,
"foundHintsInOutput": 1,
"command": "solana-keygen grind --starts-with DAo:1",
"outputLog": [
"Searching with 16 threads for:",
"1 pubkey that starts with 'DAo' and ends with ''",
"Wrote keypair to DAoxa7YRxa8Se5KFQmkBbxBJ3Fo8FeY8N9td6NGz38zu.json"
]
}

View File

@ -0,0 +1 @@
[127,127,232,76,177,128,31,8,169,68,116,117,202,252,44,203,63,250,250,210,30,186,201,66,110,211,193,137,111,186,165,157,180,207,144,236,191,129,155,243,98,167,69,154,219,76,79,141,228,101,60,132,255,6,252,105,99,29,143,220,127,120,236,26]

2
shine/settings.gradle Normal file
View File

@ -0,0 +1,2 @@
rootProject.name = "shine-tools"

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +1,58 @@
# Кошельки Devnet (тестовые)
# Кошельки Devnet (тестовые, приватные ключи в Base58)
> Только для тестового этапа. Не использовать в production.
> Только для тестов. Не использовать в production.
>
> Балансы ниже актуальны на 2026-05-08 (Devnet), могут меняться после транзакций.
## DAO (текущий)
## DAO (текущий для Shine Payments)
- public: `6bFc5Gz5qF172GQhK5HpDbWs8F6qcSxdHn5XqAstf1fY`
- private (base58): `3VYfYZZ3ugmgwisiQQAfcimX9T65AE9BmwmYVixAUj4jyneccSE9rzbC3g5twvH7ECZ8xgp7emJo3pR4yQqCwjGn`
- роль: основной DAO-кошелёк в `shine_payments` (получает DAO-часть выплат, выдаёт лимиты менеджерам)
## Manager для coef/limit (текущий)
## Manager / deploy wallet (текущий системный)
- public: `4yzHKs2zFXpyqqCETe8KpAs4xhEo4QhJ2ybyTgRZphZv`
- private (json array):
```json
[90,184,152,226,36,29,0,20,192,35,239,186,138,46,197,219,65,216,46,150,150,113,95,216,66,95,68,79,178,239,166,133,59,44,90,189,77,253,249,234,240,215,66,104,14,194,200,186,203,176,232,154,245,165,226,23,127,115,246,181,134,24,148,45]
```
- private (base58): `2pCe8GWpXL7hYf3tFQZg4azfPw3hm1UwvybRbJ6j5rKBvTjBme2DuWESKM5e5jd2JLw7b4D5sWno4YbxRdot4Gap`
- роль:
- upgrade authority и кошелёк деплоя программ;
- `MANAGER_WALLET` в настройках (имеет право менять `coef/limit`).
## Тестовый кошелёк key1
- public: `HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA`
- private (base58): `5pbFo9Zq1VsNheHwbEp6AZKa6R62CZHoGkJFZnugpMEtCmkQFjuUP7TgA5hSPqv4NABGmPP62qVnDPHmRqEAwvJc`
- роль: дополнительный тестовый пользователь (покупка билетов, получение выплат)
## Тестовый кошелёк key2
- public: `E3ZDHbWv1qiFvDTmaRc9wjFCgbQw6UmKJLJYbaTNvjAh`
- private (base58): `5qm1GJGXB1fFJ3YsU5Y3XXgTiQfaimqBWk79oEveFASH9D2of3jqUoT7dumBvS449fW5j5Sw8MgAMH2QBMmFPdry`
- роль: дополнительный тестовый пользователь (второй участник сценариев)
## Тестовый кошелёк phantomWallet
## Тестовый кошелёк phantomWallet (твой основной рабочий)
- private (json array):
- public: `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P`
- private (base58): `5RpEoxRKSr2norQP3vEnq9XokQGh9EbGN8q8xUUVAdm1M5mTD1vMuyJPYJfViMWFf6c8qT5mj2bt64gLE2zm6VG3`
- роль:
- основной кошелёк для ручного тестирования через UI;
- можно использовать как менеджер очередей (после выдачи лимитов DAO через `grant_manager_limits`).
```json
[221,119,143,125,90,136,155,115,191,198,210,85,228,111,251,118,168,138,27,60,249,62,247,24,121,228,139,112,218,69,55,143,215,21,229,69,219,1,74,36,10,239,63,163,48,240,58,208,237,251,209,37,17,202,215,77,13,165,178,18,141,21,193,64]
```
## testDaoCreator (Phantom-совместимый)
- public: `A9AP6TMUuxbXwR8H2xN8hA7SXddnnxRH1vhP2qwEuG2r`
- private (base58): `5JR37dCQUB4jtjSzoLp3pkGMkCoTpNLxZLXJDrGZnYCDcBgX9cmqmHVbQz2VbYgGZnG2StNSQ5cMgJf1PSa8gvpv`
- роль: резервный тестовый кошелёк (можно использовать как отдельный DAO/менеджер в экспериментах)
## Program keypair (для деплоя)
- `shine_payments`
- program id: `4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE`
- private key (base58): `3Fk2zYKTfoLMoU1tqMypGBsxj9zFF7PdXNEtodamv9aqtm8KWJNqsmT5DE9Z8pyDLCSqwdLM59LxbfdYp99b4xGg`
- роль: ключ адреса программы (не пользовательский кошелёк)
- баланс Devnet (program account): `0.00114144 SOL`
- `shine_users`
- program id: `8Z3HQizFRhyVu5cNBwWNBXZHTpu89VMkn7Wuk1oCtkeJ`
- private key (base58): `2Zi4zNSFv69s3PPWr2vCtySiKSxiLA37TSSvHHVTygEQrehHScPoDkjKXYjVXDAHXi1Kg6LiZvYAq1ftV6aPSdrN`
- роль: ключ адреса программы (не пользовательский кошелёк)
- баланс Devnet (program account): `0.00114144 SOL`