286 lines
10 KiB
JavaScript
286 lines
10 KiB
JavaScript
const anchor = require("@coral-xyz/anchor");
|
||
const {
|
||
Connection,
|
||
PublicKey,
|
||
SystemProgram,
|
||
Transaction,
|
||
TransactionInstruction,
|
||
} = require("@solana/web3.js");
|
||
const {
|
||
getAssociatedTokenAddress,
|
||
createAssociatedTokenAccountInstruction,
|
||
TOKEN_PROGRAM_ID,
|
||
ASSOCIATED_TOKEN_PROGRAM_ID,
|
||
createMint,
|
||
getAccount,
|
||
} = require("@solana/spl-token");
|
||
const crypto = require("crypto");
|
||
|
||
// Адрес программы метаданных Metaplex (фиксированный)
|
||
const METAPLEX_TOKEN_METADATA_PROGRAM_ID = new PublicKey(
|
||
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"
|
||
);
|
||
|
||
// ────────────────────────────────
|
||
// Утилиты
|
||
// ────────────────────────────────
|
||
const BASE58_RE = /[1-9A-HJ-NP-Za-km-z]{32,}/g;
|
||
|
||
function mustEnv(name) {
|
||
const v = (process.env[name] || "").trim();
|
||
if (!v) throw new Error(`Переменная окружения ${name} не задана`);
|
||
return v;
|
||
}
|
||
|
||
function pickBase58(raw, name) {
|
||
const m = (raw || "").toString().match(BASE58_RE);
|
||
if (!m) throw new Error(`${name} не найден/невалиден: "${raw}"`);
|
||
return m[0];
|
||
}
|
||
|
||
// Anchor discriminator: sha256("global:<ix_name>") первые 8 байт
|
||
function disc8(ixName) {
|
||
const preimage = `global:${ixName}`;
|
||
const h = crypto.createHash("sha256").update(preimage).digest();
|
||
return h.subarray(0, 8);
|
||
}
|
||
|
||
function u64le(n) {
|
||
const bn = BigInt(n.toString());
|
||
const buf = Buffer.alloc(8);
|
||
buf.writeBigUInt64LE(bn);
|
||
return buf;
|
||
}
|
||
|
||
// Надёжная отправка транзакций с ретраями при «Blockhash not found»
|
||
async function sendTx(provider, tx, signers = []) {
|
||
const conn = provider.connection;
|
||
let lastErr;
|
||
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash(
|
||
"confirmed"
|
||
);
|
||
tx.recentBlockhash = blockhash;
|
||
tx.feePayer = provider.wallet.publicKey;
|
||
|
||
for (const s of signers) tx.partialSign(s);
|
||
|
||
const signed = await provider.wallet.signTransaction(tx);
|
||
|
||
const sig = await conn.sendRawTransaction(signed.serialize(), {
|
||
skipPreflight: false,
|
||
preflightCommitment: "confirmed",
|
||
maxRetries: 3,
|
||
});
|
||
|
||
await conn.confirmTransaction(
|
||
{ signature: sig, blockhash, lastValidBlockHeight },
|
||
"confirmed"
|
||
);
|
||
|
||
return sig;
|
||
} catch (e) {
|
||
lastErr = e;
|
||
const msg = String(e?.message || e).toLowerCase();
|
||
if (msg.includes("blockhash not found") || msg.includes("expired")) {
|
||
// пробуем ещё раз со свежим blockhash
|
||
continue;
|
||
}
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
throw lastErr;
|
||
}
|
||
|
||
// ────────────────────────────────
|
||
// Основной сценарий
|
||
// ────────────────────────────────
|
||
(async () => {
|
||
// Провайдер / окружение
|
||
const RPC = mustEnv("ANCHOR_PROVIDER_URL");
|
||
const WALLET_PATH = mustEnv("ANCHOR_WALLET");
|
||
const PROGRAM_ID = new PublicKey(
|
||
pickBase58(mustEnv("PROGRAM_ID"), "PROGRAM_ID")
|
||
);
|
||
const COLLECTION_MINT = new PublicKey(
|
||
pickBase58(mustEnv("COLLECTION_MINT"), "COLLECTION_MINT")
|
||
);
|
||
|
||
const provider = anchor.AnchorProvider.env(); // читает из ENV
|
||
anchor.setProvider(provider);
|
||
const conn = provider.connection;
|
||
const wallet = provider.wallet;
|
||
|
||
console.log("────────────────────────────────────────────────────────");
|
||
console.log("RPC :", RPC);
|
||
console.log("Wallet :", wallet.publicKey.toBase58());
|
||
console.log("Program ID :", PROGRAM_ID.toBase58());
|
||
console.log("Collection mint :", COLLECTION_MINT.toBase58());
|
||
console.log(
|
||
"TokenMetadata PID :",
|
||
METAPLEX_TOKEN_METADATA_PROGRAM_ID.toBase58()
|
||
);
|
||
console.log("ATA Program PID :", ASSOCIATED_TOKEN_PROGRAM_ID.toBase58());
|
||
console.log("────────────────────────────────────────────────────────");
|
||
|
||
// 1) INIT (создаёт PDA состояния)
|
||
const [statePda] = PublicKey.findProgramAddressSync(
|
||
[Buffer.from("shine_investments_state")],
|
||
PROGRAM_ID
|
||
);
|
||
|
||
const initIx = new TransactionInstruction({
|
||
programId: PROGRAM_ID,
|
||
keys: [
|
||
{ pubkey: wallet.publicKey, isSigner: true, isWritable: true }, // payer
|
||
{ pubkey: statePda, isSigner: false, isWritable: true }, // state_pda
|
||
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
||
],
|
||
data: Buffer.from([...disc8("init")]), // без аргументов
|
||
});
|
||
|
||
try {
|
||
const sigInit = await sendTx(provider, new Transaction().add(initIx));
|
||
console.log(
|
||
"init() tx:",
|
||
sigInit,
|
||
`https://explorer.solana.com/tx/${sigInit}?cluster=devnet`
|
||
);
|
||
} catch (e) {
|
||
console.log("init(): возможно уже выполнен ->", e.message);
|
||
}
|
||
|
||
// 2) Локально создаём mint нового NFT и ATA получателя
|
||
const mintPubkey = await createMint(
|
||
conn,
|
||
wallet.payer,
|
||
wallet.publicKey,
|
||
wallet.publicKey,
|
||
0
|
||
);
|
||
console.log("NFT mint:", mintPubkey.toBase58());
|
||
|
||
const recipientOwner = wallet.publicKey;
|
||
const recipientAta = await getAssociatedTokenAddress(
|
||
mintPubkey,
|
||
recipientOwner
|
||
);
|
||
const ataInfo = await conn.getAccountInfo(recipientAta);
|
||
if (!ataInfo) {
|
||
const createAtaIx = createAssociatedTokenAccountInstruction(
|
||
wallet.publicKey,
|
||
recipientAta,
|
||
recipientOwner,
|
||
mintPubkey
|
||
);
|
||
const sigAta = await sendTx(
|
||
provider,
|
||
new Transaction().add(createAtaIx)
|
||
);
|
||
console.log("Created ATA:", recipientAta.toBase58(), sigAta);
|
||
} else {
|
||
console.log("ATA exists:", recipientAta.toBase58());
|
||
}
|
||
|
||
// PDA для metadata/master edition нашего нового NFT
|
||
const [metadataPda] = PublicKey.findProgramAddressSync(
|
||
[
|
||
Buffer.from("metadata"),
|
||
METAPLEX_TOKEN_METADATA_PROGRAM_ID.toBuffer(),
|
||
mintPubkey.toBuffer(),
|
||
],
|
||
METAPLEX_TOKEN_METADATA_PROGRAM_ID
|
||
);
|
||
const [masterEditionPda] = PublicKey.findProgramAddressSync(
|
||
[
|
||
Buffer.from("metadata"),
|
||
METAPLEX_TOKEN_METADATA_PROGRAM_ID.toBuffer(),
|
||
mintPubkey.toBuffer(),
|
||
Buffer.from("edition"),
|
||
],
|
||
METAPLEX_TOKEN_METADATA_PROGRAM_ID
|
||
);
|
||
|
||
// PDA коллекции (metadata/master edition)
|
||
const [collectionMetadataPda] = PublicKey.findProgramAddressSync(
|
||
[
|
||
Buffer.from("metadata"),
|
||
METAPLEX_TOKEN_METADATA_PROGRAM_ID.toBuffer(),
|
||
COLLECTION_MINT.toBuffer(),
|
||
],
|
||
METAPLEX_TOKEN_METADATA_PROGRAM_ID
|
||
);
|
||
const [collectionMasterEditionPda] = PublicKey.findProgramAddressSync(
|
||
[
|
||
Buffer.from("metadata"),
|
||
METAPLEX_TOKEN_METADATA_PROGRAM_ID.toBuffer(),
|
||
COLLECTION_MINT.toBuffer(),
|
||
Buffer.from("edition"),
|
||
],
|
||
METAPLEX_TOKEN_METADATA_PROGRAM_ID
|
||
);
|
||
|
||
// 3) add_bonus(investor: Pubkey, amount: u64) — raw-инструкция
|
||
// Порядок аккаунтов должен совпасть с #[derive(Accounts)] AddBonusCtx:
|
||
// signer(Signer), state_pda(mut), mint_pda(mut), recipient_ata(mut), recipient_owner,
|
||
// collection_mint, collection_metadata_pda(mut), collection_master_edition_pda(mut),
|
||
// collection_update_authority(Signer), metadata_pda(mut), master_edition_pda(mut),
|
||
// token_metadata_program, token_program, associated_token_program, system_program
|
||
const investor = recipientOwner;
|
||
const amount = 123_000_000n; // u64
|
||
|
||
const addBonusData = Buffer.concat([
|
||
disc8("add_bonus"),
|
||
investor.toBuffer(),
|
||
u64le(amount),
|
||
]);
|
||
|
||
const addBonusIx = new TransactionInstruction({
|
||
programId: PROGRAM_ID,
|
||
keys: [
|
||
{ pubkey: wallet.publicKey, isSigner: true, isWritable: false }, // signer
|
||
{ pubkey: statePda, isSigner: false, isWritable: true }, // state_pda
|
||
{ pubkey: mintPubkey, isSigner: false, isWritable: true }, // mint_pda
|
||
{ pubkey: recipientAta, isSigner: false, isWritable: true }, // recipient_ata
|
||
{ pubkey: recipientOwner, isSigner: false, isWritable: false }, // recipient_owner
|
||
{ pubkey: COLLECTION_MINT, isSigner: false, isWritable: false }, // collection_mint
|
||
{ pubkey: collectionMetadataPda, isSigner: false, isWritable: true }, // collection_metadata_pda
|
||
{ pubkey: collectionMasterEditionPda, isSigner: false, isWritable: true }, // collection_master_edition_pda
|
||
{ pubkey: wallet.publicKey, isSigner: true, isWritable: false }, // collection_update_authority
|
||
{ pubkey: metadataPda, isSigner: false, isWritable: true }, // metadata_pda
|
||
{ pubkey: masterEditionPda, isSigner: false, isWritable: true }, // master_edition_pda
|
||
{ pubkey: METAPLEX_TOKEN_METADATA_PROGRAM_ID, isSigner: false, isWritable: false },
|
||
{ pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
|
||
{ pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
|
||
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
||
],
|
||
data: addBonusData,
|
||
});
|
||
|
||
const sig = await sendTx(provider, new Transaction().add(addBonusIx));
|
||
console.log(
|
||
"add_bonus() tx:",
|
||
sig,
|
||
`https://explorer.solana.com/tx/${sig}?cluster=devnet`
|
||
);
|
||
|
||
// 4) простые проверки
|
||
const acc = await getAccount(conn, recipientAta);
|
||
console.log("isFrozen (ATA):", acc.isFrozen);
|
||
if (!acc.isFrozen) throw new Error("Ожидали заморозку ATA после add_bonus()");
|
||
|
||
const mdInfo = await conn.getAccountInfo(metadataPda);
|
||
if (!mdInfo || mdInfo.data.length === 0)
|
||
throw new Error("Metadata PDA отсутствует или пуст");
|
||
|
||
console.log(
|
||
"Готово: raw-инструкции прошли, NFT создан/верифицирован и ATA заморожен"
|
||
);
|
||
})().catch((e) => {
|
||
console.error("Ошибка e2e:", e);
|
||
process.exit(1);
|
||
});
|