Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions rs/Anchor.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ bunkercash = "Fp8b6p287TL5oPMwLVdwGys5phHNLcmTKNvVoGdCJS6g"
url = "https://api.apr.dev"

[provider]
cluster = "devnet"
cluster = "testnet"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Can the intent of this PR be reconstructed from an empty description? The body is empty for a 79-file, +6409/-3021 change spanning the Anchor program cluster config, two Workers' deploy configs (routes, KV/D1 bindings, RPC cluster), a rewritten account decoder, and admin auth code. A reviewer cannot tell an intentional testnet cutover from an accidental local-dev leftover. Fix: a PR description enumerating the deploy/governance/decoder changes, or split them out. Umbra gate: verified major.

wallet = "~/.config/solana/id.json"

[scripts]
e2e = "bash -lc 'export USDC_MINT=4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU; npx ts-node -P tsconfig.json scripts/e2e-buy-sell.ts'"
e2e = "bash -lc 'export USDC_MINT=DCM1oDQRSv9dn4xpZrXuX23yvivoFHSKKF1mjuCEcjdQ; npx ts-node -P tsconfig.json scripts/e2e-buy-sell.ts'"
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
19 changes: 10 additions & 9 deletions rs/programs/bunkercash/.env.example
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
# ─────────────────────────────────────────────────────────────────────────────
# BunkerCash program — compile-time governance keys (EXAMPLE — safe to commit)
# Copy this file to .env and fill in your real values before building.
# BunkerCash program — compile-time testnet governance keys (public addresses)
# Copy this file to .env before reproducing the current testnet build.
# ─────────────────────────────────────────────────────────────────────────────

# Squads v4 multisig address (created at https://devnet.squads.so)
SQUADS_MULTISIG_PUBKEY=<YOUR_SQUADS_MULTISIG_ADDRESS>
# Squads v4 multisig address on Solana testnet.
SQUADS_MULTISIG_PUBKEY=GD3jX4ixATyMN5QZGfhGZeYgz3b6bgPjSBF3qtzEfYew

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Is the on-chain governance cutover meant to land in this PR? .env.example swaps the Squads multisig/vault/member placeholders for real base58 keys and Anchor.toml flips the cluster devnet -> testnet. These are public keys (no secret leak), and a testnet cutover is a legitimate step, but bundling a fund-custody/governance config change into a 79-file design diff means it gets zero governance-focused review. Fix: split the deploy/governance cutover into its own reviewed PR. Umbra gate: verified major (downgraded from blocker: public keys, template file).

# Squads v4 Vault PDA (vaultIndex = 0) — this is the signer used when executing
# multisig transactions, and is what you set as pool.admin after governance transfer.
# Derived by `@sqds/multisig` as: getVaultPda({ multisigPda, index: 0 }) with programId=SQDS4...
SQUADS_VAULT_PUBKEY=<YOUR_SQUADS_VAULT_ADDRESS>
SQUADS_VAULT_PUBKEY=FbNDhcb4hwa8bVDMptJrCZho8sCv4pzVqR1EVHKFza8m

# ── 4 Squads member signers ───────────────────────────────────────────────────
# The 4 wallet addresses that are members of the Squads multisig.
SQUADS_MEMBER_1=<MEMBER_1_WALLET>
SQUADS_MEMBER_2=<MEMBER_2_WALLET>
SQUADS_MEMBER_3=<MEMBER_3_WALLET>
SQUADS_MEMBER_4=<MEMBER_4_WALLET>
SQUADS_MEMBER_1=3BXEsRgUmrTudbZDzQjDpA2mvwV7vDC73WGjhHPRGBee
SQUADS_MEMBER_2=8NSGVEq9CY25Fy6ujBMa9CRsmWz9AWhGV7URQF8Acof7
SQUADS_MEMBER_3=Hmod5q5Egi1yqiRCAAgZBh1iD8o8kALVQV8WKBM84JhK
# Unused fourth slot; this is a valid public key with no signer in the multisig.
SQUADS_MEMBER_4=11111111111111111111111111111112
128 changes: 128 additions & 0 deletions rs/scripts/approve-and-execute-mint-proposal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Approve (if needed) and execute a pending Squads v4 vault transaction.
*
* Intended for the second multisig member to approve the create_bunkercash_mint
* proposal created by propose-create-mint.ts, and execute it once the 2-of-3
* threshold is met. Safe to run with the original proposer's key too — it skips
* the vote if the member already approved and just attempts execution.
*
* Run:
* cd ts/apps/web && TX_INDEX=<index> KEYPAIR_PATH=<member-keypair.json> \
* npx tsx ../../../rs/scripts/approve-and-execute-mint-proposal.ts
* Env: RPC_URL (default testnet), TX_INDEX (required), KEYPAIR_PATH
*/
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
import * as multisig from "@sqds/multisig";
import bs58 from "bs58";

const MULTISIG_PDA = new PublicKey("GD3jX4ixATyMN5QZGfhGZeYgz3b6bgPjSBF3qtzEfYew");
const RPC_URL = process.env.RPC_URL ?? "https://api.testnet.solana.com";
const KEYPAIR_PATH = process.env.KEYPAIR_PATH ?? "~/.config/solana/id.json";

/** Accepts a solana-keygen JSON array file OR a base58 private-key string
* (the format Phantom/Solflare export) on a single line. */
function loadKeypair(path: string): Keypair {
const file = readFileSync(
path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path,
"utf8",
).trim();
if (file.startsWith("[")) {
return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(file) as number[]));
}
return Keypair.fromSecretKey(bs58.decode(file));
}

async function main() {
const txIndexRaw = process.env.TX_INDEX;
if (!txIndexRaw) throw new Error("TX_INDEX env var is required");
const transactionIndex = BigInt(txIndexRaw);

const keypair = loadKeypair(KEYPAIR_PATH);
const connection = new Connection(RPC_URL, "confirmed");

const [proposalPda] = multisig.getProposalPda({
multisigPda: MULTISIG_PDA,
transactionIndex,
});
const proposal = await multisig.accounts.Proposal.fromAccountAddress(
connection,
proposalPda,
);
const approved = proposal.approved.map((k) => k.toBase58());
console.log("Proposal:", proposalPda.toBase58());
console.log("Current approvals:", approved.join(", ") || "(none)");

const ms = await multisig.accounts.Multisig.fromAccountAddress(connection, MULTISIG_PDA);
const threshold = ms.threshold;

if (!approved.includes(keypair.publicKey.toBase58())) {
const approveIx = multisig.instructions.proposalApprove({
multisigPda: MULTISIG_PDA,
transactionIndex,
member: keypair.publicKey,
});
const tx = new Transaction().add(approveIx);
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
tx.recentBlockhash = blockhash;
tx.lastValidBlockHeight = lastValidBlockHeight;
tx.feePayer = keypair.publicKey;
tx.sign(keypair);
const sig = await connection.sendRawTransaction(tx.serialize());
await connection.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight });
console.log("Approved. Signature:", sig);
} else {
console.log("This member already approved — skipping vote.");
}

const refreshed = await multisig.accounts.Proposal.fromAccountAddress(
connection,
proposalPda,
);
if (refreshed.approved.length < threshold) {
console.log(
`Approvals ${refreshed.approved.length}/${threshold} — threshold not met yet, cannot execute.`,
);
return;
}

const { instruction: executeIx, lookupTableAccounts } =
await multisig.instructions.vaultTransactionExecute({
connection,
multisigPda: MULTISIG_PDA,
transactionIndex,
member: keypair.publicKey,
});

const tx = new Transaction().add(executeIx);
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
tx.recentBlockhash = blockhash;
tx.lastValidBlockHeight = lastValidBlockHeight;
tx.feePayer = keypair.publicKey;
tx.sign(keypair);
void lookupTableAccounts;
const sig = await connection.sendRawTransaction(tx.serialize());
await connection.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight });
console.log("Executed. Signature:", sig);

const idlJson = require("../../ts/apps/web/lib/bunkercash.fixed.idl.json") as {
address: string;
};
const [mintPda] = PublicKey.findProgramAddressSync(
[Buffer.from("bunkercash_mint")],
new PublicKey(idlJson.address),
);
const mintInfo = await connection.getAccountInfo(mintPda);
console.log(
"Mint PDA",
mintPda.toBase58(),
mintInfo ? "now exists — buy flow is unblocked." : "still missing (execution may have failed).",
);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
136 changes: 136 additions & 0 deletions rs/scripts/propose-create-mint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Propose `create_bunkercash_mint` through the Squads v4 vault that owns the pool.
*
* The pool's master_wallet on testnet is the Squads vault PDA (threshold 2-of-3),
* so the mint can only be created via a vault transaction. This script:
* 1. builds the create_bunkercash_mint instruction with the vault as admin
* 2. creates the vault transaction + proposal
* 3. approves it with the local keypair (1 of 2 required votes)
*
* After a second member approves (approve-and-execute-mint-proposal.ts),
* anyone can execute.
*
* Run:
* cd ts/apps/web && npx tsx ../../../rs/scripts/propose-create-mint.ts
* Env (optional): RPC_URL, KEYPAIR_PATH
*/
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { AnchorProvider, Program, Wallet, type Idl } from "@coral-xyz/anchor";
import {
Connection,
Keypair,
PublicKey,
SystemProgram,
Transaction,
TransactionMessage,
} from "@solana/web3.js";
import * as multisig from "@sqds/multisig";

const TOKEN_2022_PROGRAM_ID = new PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
const MULTISIG_PDA = new PublicKey("GD3jX4ixATyMN5QZGfhGZeYgz3b6bgPjSBF3qtzEfYew");
const RPC_URL = process.env.RPC_URL ?? "https://api.testnet.solana.com";
const KEYPAIR_PATH = process.env.KEYPAIR_PATH ?? "~/.config/solana/id.json";

// eslint-disable-next-line @typescript-eslint/no-var-requires
const idlJson = require("../../ts/apps/web/lib/bunkercash.fixed.idl.json") as {
address: string;
} & Idl;
const PROGRAM_ID = new PublicKey(idlJson.address);

function loadKeypair(path: string): Keypair {
const file = readFileSync(
path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path,
"utf8",
);
return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(file) as number[]));
}

async function main() {
const keypair = loadKeypair(KEYPAIR_PATH);
const connection = new Connection(RPC_URL, "confirmed");
const provider = new AnchorProvider(connection, new Wallet(keypair), {
commitment: "confirmed",
});
const program = new Program(idlJson as unknown as Idl, provider);

const [poolPda] = PublicKey.findProgramAddressSync([Buffer.from("pool")], PROGRAM_ID);
const [mintPda] = PublicKey.findProgramAddressSync(
[Buffer.from("bunkercash_mint")],
PROGRAM_ID,
);
const [vaultPda] = multisig.getVaultPda({ multisigPda: MULTISIG_PDA, index: 0 });

const existing = await connection.getAccountInfo(mintPda);
if (existing) {
console.log("Mint already exists:", mintPda.toBase58());
return;
}

const ms = await multisig.accounts.Multisig.fromAccountAddress(connection, MULTISIG_PDA);
const transactionIndex = BigInt(ms.transactionIndex.toString()) + 1n;
console.log("Multisig:", MULTISIG_PDA.toBase58());
console.log("Vault (pool admin):", vaultPda.toBase58());
console.log("New transaction index:", transactionIndex.toString());

const createMintIx = await (program.methods as any)
.createBunkercashMint()
.accounts({
pool: poolPda,
bunkercashMint: mintPda,
admin: vaultPda,
tokenProgram: TOKEN_2022_PROGRAM_ID,
systemProgram: SystemProgram.programId,
})
.instruction();

const { blockhash } = await connection.getLatestBlockhash();
const vaultMessage = new TransactionMessage({
payerKey: vaultPda,
recentBlockhash: blockhash,
instructions: [createMintIx],
});

const createVaultTxIx = multisig.instructions.vaultTransactionCreate({
multisigPda: MULTISIG_PDA,
transactionIndex,
creator: keypair.publicKey,
vaultIndex: 0,
ephemeralSigners: 0,
transactionMessage: vaultMessage,
memo: "create_bunkercash_mint (testnet bootstrap)",
});
const proposalCreateIx = multisig.instructions.proposalCreate({
multisigPda: MULTISIG_PDA,
transactionIndex,
creator: keypair.publicKey,
});
const approveIx = multisig.instructions.proposalApprove({
multisigPda: MULTISIG_PDA,
transactionIndex,
member: keypair.publicKey,
});

const tx = new Transaction().add(createVaultTxIx, proposalCreateIx, approveIx);
const sig = await provider.sendAndConfirm(tx, [keypair]);

const [proposalPda] = multisig.getProposalPda({
multisigPda: MULTISIG_PDA,
transactionIndex,
});
console.log("\nProposal created and approved by", keypair.publicKey.toBase58());
console.log("Signature:", sig);
console.log("Proposal PDA:", proposalPda.toBase58());
console.log("Transaction index:", transactionIndex.toString());
console.log(
"\nNext: a second multisig member must approve, then execute:\n" +
` RPC_URL=${RPC_URL} TX_INDEX=${transactionIndex} KEYPAIR_PATH=<member-keypair.json> \\\n` +
" npx tsx ../../../rs/scripts/approve-and-execute-mint-proposal.ts",
);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
Loading