diff --git a/scripts/utils/proposals.ts b/scripts/utils/proposals.ts new file mode 100644 index 00000000..612f91d1 --- /dev/null +++ b/scripts/utils/proposals.ts @@ -0,0 +1,100 @@ +import * as anchor from "@coral-xyz/anchor"; +import { InstructionUtils } from "@metadaoproject/programs"; +import { + FutarchyClient, + getProposalAddr, +} from "@metadaoproject/programs/futarchy"; +import { ComputeBudgetProgram, PublicKey } from "@solana/web3.js"; +import { sha256 } from "@noble/hashes/sha256"; + +const provider = anchor.AnchorProvider.env(); + +const futarchyClient = FutarchyClient.createClient({ provider }); + +export const initializeFutarchyProposal = async ( + daoAddress: PublicKey, + squadsProposalPda: PublicKey, +) => { + const dao = await futarchyClient.getDao(daoAddress); + const [proposal] = getProposalAddr( + futarchyClient.futarchy.programId, + squadsProposalPda, + ); + + const existing = await futarchyClient.fetchProposal(proposal); + if (existing) { + console.log(" Futarchy Proposal PDA:", proposal.toBase58()); + console.log(" ✓ Futarchy proposal already initialized"); + return proposal; + } + + const { question, baseVault, quoteVault } = futarchyClient.getProposalPdas( + proposal, + dao.baseMint, + dao.quoteMint, + daoAddress, + ); + + const questionAccount = + await futarchyClient.vaultClient.fetchQuestion(question); + if (!questionAccount) { + await futarchyClient.vaultClient.initializeQuestion( + sha256(`Will ${proposal} pass?/FAIL/PASS`), + proposal, + 2, + ); + console.log(" ✓ Question initialized"); + } else { + console.log(" ✓ Question already exists"); + } + + const [baseVaultAccount, quoteVaultAccount] = await Promise.all([ + futarchyClient.vaultClient.fetchVault(baseVault), + futarchyClient.vaultClient.fetchVault(quoteVault), + ]); + + if (!baseVaultAccount || !quoteVaultAccount) { + // Avoid starting vault init if only one side exists — that would fail mid-way. + if (baseVaultAccount || quoteVaultAccount) { + throw new Error( + `Partial vault state: base=${!!baseVaultAccount} quote=${!!quoteVaultAccount}`, + ); + } + await futarchyClient.vaultClient + .initializeVaultIx(question, dao.baseMint, 2) + .postInstructions( + await InstructionUtils.getInstructions( + futarchyClient.vaultClient.initializeVaultIx( + question, + dao.quoteMint, + 2, + ), + ), + ) + .rpc(); + console.log(" ✓ Vaults initialized"); + } else { + console.log(" ✓ Vaults already exist"); + } + + // initialize_proposal itself does not need the conditional ATAs; the SDK + // still creates them as preInstructions for later AMM use. Those CreateIdempotent + // calls require the conditional mints to already exist (vaults above). + await futarchyClient + .initializeProposalIx( + squadsProposalPda, + daoAddress, + dao.baseMint, + dao.quoteMint, + question, + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), + ]) + .rpc(); + + console.log(" Futarchy Proposal PDA:", proposal.toBase58()); + console.log(" ✓ Futarchy proposal initialized"); + + return proposal; +}; diff --git a/scripts/utils/squads.ts b/scripts/utils/squads.ts index 3b3cbb7d..d96d3629 100644 --- a/scripts/utils/squads.ts +++ b/scripts/utils/squads.ts @@ -1,6 +1,7 @@ import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; import { PublicKey, TransactionMessage } from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; +import * as anchor from "@coral-xyz/anchor"; // Returns the multisig, spending limit and 0th vault pda for a given dao address export const getSquadsPdasFromDao = async ( @@ -33,6 +34,18 @@ export const getSquadsPdasFromDao = async ( }; }; +export const getSquadsTxIndex = async ( + squadsMultisig: PublicKey, + provider: anchor.AnchorProvider, +) => { + const multisigAccountInfo = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, // TODO: Review if we want to instead offer connection class... + squadsMultisig, + ); + return Number(multisigAccountInfo.transactionIndex); +}; + export const createSquadsVaultTxAndProposal = async ( squadsMultisig: PublicKey, transactionIndex: bigint, diff --git a/scripts/utils/utils.ts b/scripts/utils/utils.ts index d8eaa193..cf0e18a5 100644 --- a/scripts/utils/utils.ts +++ b/scripts/utils/utils.ts @@ -2,10 +2,14 @@ import { BN } from "bn.js"; import { AddressLookupTableAccount, AddressLookupTableProgram, + ComputeBudgetProgram, Connection, Keypair, PublicKey, Transaction, + TransactionExpiredBlockheightExceededError, + TransactionExpiredTimeoutError, + TransactionInstruction, } from "@solana/web3.js"; export const TEN_SECONDS_IN_SLOTS = 25n; @@ -16,6 +20,74 @@ export const DAY_IN_SLOTS = HOUR_IN_SLOTS * 24n; export const toBN = (val: bigint): typeof BN.prototype => new BN(val.toString()); +/** Priority fee for lookup-table transactions (matches the rest of the tooling). */ +const LUT_PRIORITY_FEE_MICRO_LAMPORTS = parseInt( + process.env.PRIORITY_FEE_MICRO_LAMPORTS ?? "10000", + 10, +); + +/** Attempts per lookup-table transaction before giving up. */ +const LUT_TX_RETRIES = 3; + +/** + * Send one lookup-table transaction and wait for confirmation, throwing on an + * on-chain error. Confirmation matters here: extends fail if the create + * hasn't landed, and a silent drop would otherwise only surface as a generic + * timeout in the finalization check. + * + * Expired (dropped) transactions are retried with a fresh blockhash. That is + * safe for extends — a false expiry only appends duplicate addresses, which + * compileToV0Message handles fine and the finalization check ignores. + */ +async function sendAndConfirmLutTx( + connection: Connection, + instruction: TransactionInstruction, + payer: Keypair, + authority: Keypair, + label: string, +): Promise { + for (let attempt = 1; attempt <= LUT_TX_RETRIES; attempt++) { + const { blockhash, lastValidBlockHeight } = + await connection.getLatestBlockhash(); + + const tx = new Transaction().add( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: LUT_PRIORITY_FEE_MICRO_LAMPORTS, + }), + instruction, + ); + tx.recentBlockhash = blockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer, authority); + + const signature = await connection.sendRawTransaction(tx.serialize()); + try { + const confirmation = await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + "confirmed", + ); + if (confirmation.value.err) { + throw new Error( + `${label} failed on-chain: ${JSON.stringify(confirmation.value.err)} (tx ${signature})`, + ); + } + console.log(`${label} confirmed: ${signature}`); + return; + } catch (err) { + const isExpiry = + err instanceof TransactionExpiredBlockheightExceededError || + err instanceof TransactionExpiredTimeoutError; + if (isExpiry && attempt < LUT_TX_RETRIES) { + console.log( + `${label} expired — retrying with a fresh blockhash (attempt ${attempt}/${LUT_TX_RETRIES})`, + ); + continue; + } + throw err; + } + } +} + /** * Creates a lookup table for all unique accounts in a transaction * @param transaction - The transaction to create a lookup table for @@ -32,14 +104,6 @@ export async function createLookupTableForTransaction( // use a different authority for the lookup table to avoid conflicts const lookupAuthority = Keypair.generate(); // Should ^ be the payer so we can update it? - const slot = await connection.getSlot(); - - const [createTableIx, lookupTableAddress] = - AddressLookupTableProgram.createLookupTable({ - authority: lookupAuthority.publicKey, - payer: payer.publicKey, - recentSlot: slot - 1, - }); // Extract all unique accounts from the transaction const accountsToAdd = transaction.instructions.map((instruction) => @@ -52,21 +116,42 @@ export async function createLookupTableForTransaction( const allAddresses = [...uniqueAccounts, ...additionalAddresses]; const finalUniqueAddresses = [...new Set(allAddresses)] as PublicKey[]; - // Create the lookup table - let createLutTx = new Transaction().add(createTableIx); - let blockhash = await connection.getLatestBlockhash(); - - createLutTx.recentBlockhash = blockhash.blockhash; - createLutTx.feePayer = payer.publicKey; - createLutTx.sign(payer, lookupAuthority); - - await connection.sendRawTransaction(createLutTx.serialize(), { - skipPreflight: true, - }); - await new Promise((resolve) => setTimeout(resolve, 2000)); + // Create the lookup table — must be confirmed before any extend can land. + // Retried with a re-derived address: createLookupTable requires a slot that + // is still in the SlotHashes sysvar, so a create that failed (e.g. a stale + // getSlot from a lagging RPC) needs a fresh slot, which changes the derived + // table address. An abandoned attempt just strands dust rent. + let lookupTableAddress: PublicKey | undefined; + for (let attempt = 1; lookupTableAddress === undefined; attempt++) { + const slot = await connection.getSlot(); + const [createTableIx, tableAddress] = + AddressLookupTableProgram.createLookupTable({ + authority: lookupAuthority.publicKey, + payer: payer.publicKey, + recentSlot: slot - 1, + }); + try { + await sendAndConfirmLutTx( + connection, + createTableIx, + payer, + lookupAuthority, + "Create lookup table", + ); + lookupTableAddress = tableAddress; + } catch (err) { + if (attempt >= LUT_TX_RETRIES) throw err; + console.log( + `Create lookup table failed (${err instanceof Error ? err.message : String(err)}) — retrying with a fresh slot`, + ); + } + } // Extend the lookup table with all unique accounts const addressesPerExtend = 20; + const totalExtends = Math.ceil( + finalUniqueAddresses.length / addressesPerExtend, + ); for (let i = 0; i < finalUniqueAddresses.length; i += addressesPerExtend) { const batch = finalUniqueAddresses.slice(i, i + addressesPerExtend); @@ -77,42 +162,44 @@ export async function createLookupTableForTransaction( addresses: batch, }); - let extendLutTx = new Transaction().add(extendTableIx); - blockhash = await connection.getLatestBlockhash(); - extendLutTx.recentBlockhash = blockhash.blockhash; - extendLutTx.feePayer = payer.publicKey; - extendLutTx.sign(payer, lookupAuthority); + await sendAndConfirmLutTx( + connection, + extendTableIx, + payer, + lookupAuthority, + `Extend lookup table ${Math.floor(i / addressesPerExtend) + 1}/${totalExtends}`, + ); + } - await connection.sendRawTransaction(extendLutTx.serialize(), { - skipPreflight: true, - }); + // Wait until the FINALIZED view of the table contains every address. That + // guarantees both halves of usability: no create/extend was dropped (the + // table really holds everything we submitted), and any blockhash fetched + // from here on belongs to a slot after the table's lastExtendedSlot, so a + // v0 transaction referencing it is immediately valid. + const expected = finalUniqueAddresses.map((address) => address.toBase58()); + const maxAttempts = 45; + let missing: string[] = expected; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const lookupTableAccount = await connection.getAddressLookupTable( + lookupTableAddress, + { commitment: "finalized" }, + ); + const present = new Set( + (lookupTableAccount.value?.state.addresses ?? []).map((address) => + address.toBase58(), + ), + ); + missing = expected.filter((address) => !present.has(address)); + if (lookupTableAccount.value && missing.length === 0) { + console.log( + `Lookup table ${lookupTableAddress.toBase58()} finalized with ${present.size} addresses`, + ); + return lookupTableAccount.value; + } await new Promise((resolve) => setTimeout(resolve, 2000)); } - // Add a dummy account to ensure the lookup table has enough entries for all indexes - const dummyAccount = Keypair.generate().publicKey; - const extendTableIx = AddressLookupTableProgram.extendLookupTable({ - authority: lookupAuthority.publicKey, - payer: payer.publicKey, - lookupTable: lookupTableAddress, - addresses: [dummyAccount], - }); - - let extendLutTx = new Transaction().add(extendTableIx); - blockhash = await connection.getLatestBlockhash(); - extendLutTx.recentBlockhash = blockhash.blockhash; - extendLutTx.feePayer = payer.publicKey; - extendLutTx.sign(payer, lookupAuthority); - - await connection.sendRawTransaction(extendLutTx.serialize(), { - skipPreflight: true, - }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Fetch and return the lookup table account - let lookupTableAccount = - await connection.getAddressLookupTable(lookupTableAddress); - console.log("created lookupTableAccount", lookupTableAccount); - - return lookupTableAccount.value; + throw new Error( + `Lookup table ${lookupTableAddress.toBase58()} did not finalize with all addresses after ${maxAttempts} attempts (${missing.length}/${expected.length} still missing)`, + ); } diff --git a/scripts/v0.7/credible/allocation/.env.example b/scripts/v0.7/credible/allocation/.env.example new file mode 100644 index 00000000..dc094326 --- /dev/null +++ b/scripts/v0.7/credible/allocation/.env.example @@ -0,0 +1,8 @@ +# Solana RPC. Staging surfpool, or http://127.0.0.1:8899 for a local surfpool. +RPC_URL= + +# Indexer Postgres (read replica) for the boost's fund events. Read-only. +FUTARCHY_PG_URL= + +# Launch authority key (base58 or JSON byte array). Only needed for --execute. +CREDIBLE_AUTHORITY_KEY= diff --git a/scripts/v0.7/credible/allocation/.gitignore b/scripts/v0.7/credible/allocation/.gitignore new file mode 100644 index 00000000..b9ff171e --- /dev/null +++ b/scripts/v0.7/credible/allocation/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +.surfpool-run/ +allocation.out.json +laso-dryrun.out +allocation.csv diff --git a/scripts/v0.7/credible/allocation/README.md b/scripts/v0.7/credible/allocation/README.md new file mode 100644 index 00000000..50a0ce77 --- /dev/null +++ b/scripts/v0.7/credible/allocation/README.md @@ -0,0 +1,86 @@ +# credible + +Standalone one-off. Allocates a launch where a set list of wallets get a +**fixed pre-allocated amount off the top** (from a CSV), then everyone else +splits the remainder using the **same accumulator algorithm as the +accelerated-cranker**. + +Dry-run by default. With `--execute` it **confirms each on-chain step at the +terminal**: closes the launch if it's live+expired (freezing the funder set +first), then approves every funder (sets the allocation) and verifies the total. + +credible **stops after setting the allocation** — it does not `completeLaunch`. The +launch is left in `Closed` state; `completeLaunch` and the performance package +are done **manually** afterward by whoever holds the launch authority. + +## Layout + +- `ac/` — files copied **verbatim** from `apps/accelerated-cranker` (each differs + from its source only by import-path lines): + - `accumulator.ts` — the time-weighted allocation algorithm + - `fundingApproval.ts` — batched `setFundingRecordApproval` + launch reads + - `constants.ts` — shared constants +- Top level — code written/adapted for this script: + - `credible.ts` — entry point: config, state machine (close → approve → verify), prompts + - `allocation.ts` — the pre-allocated off-the-top split (the only bespoke logic; pure + unit-tested) + - `db.ts` — read-only fund-events query for the boost + - `utils.ts` — USDC formatting, key/clock loading, pre-alloc CSV loader, table printer, confirmation prompt + - `ico-pref.csv` — pre-allocations (`Address,Allocated`): each wallet + its fixed amount + - `logger.ts` — console logger with a pino-compatible surface + - `test.ts` — local surfpool end-to-end harness (setup → crank → per-funder verify) + - `test/allocation.test.ts` — unit tests for the allocation composition (`bun test`) + +## Setup + +```bash +bun install +cp .env.example .env # then fill it in +``` + +`.env`: + +- `RPC_URL` — Solana RPC (staging surfpool, or `http://127.0.0.1:8899` for a local one) +- `FUTARCHY_PG_URL` — indexer Postgres; read-only, for the boost's fund events +- `CREDIBLE_AUTHORITY_KEY` — launch authority key (base58 or JSON byte array). Only needed for `--execute`. + +Config block at the top of `credible.ts`: + +- `LAUNCH_ADDRESS` — the launch to allocate (preset to Credible Finance) +- `TOTAL_ALLOCATION` — total to allocate, in whole USDC (a config variable, + **not** the launch's `minimumRaiseAmount`) +- `BOOST_MULTIPLIER` / `BOOST_FILL_CEILING` / `BOOST_LOOK_AHEAD_HOURS` — + accumulator boost (preset to the cranker's prod defaults: 10 / 3 / 1) + +Pre-allocated wallets + their fixed amounts go in `ico-pref.csv` (header row, then +`Address,Allocated` — e.g. `GaDZ…,"$375,000 "`). Each amount must be ≤ that +wallet's on-chain commitment, or the run throws. + +## Run + +```bash +bun credible.ts # dry run — prints the allocation table + writes allocation.out.json, sends nothing +bun credible.ts --execute # set the allocation on-chain — confirms each step (requires CREDIBLE_AUTHORITY_KEY) +``` + +Each run writes `allocation.out.json` — the exact per-funder allocation it computed. +After `--execute` the launch is `Closed` with the allocation set; complete it manually. + +## Test + +```bash +bun test # unit tests for the allocation composition + edge cases +bun test.ts # end-to-end on a local surfpool: overrides authority, timetravels + # past close, cranks, then verifies every funder's on-chain amount +``` + +`test.ts` requires a **fresh** local surfpool forking mainnet and `.env` `RPC_URL` +pointed at it. See `TEST_REPORT.md` for the latest results. + +## How the allocation works + +1. Pre-allocated wallets → `approved = their fixed CSV amount` (a separate raise + off the top). Each is validated: must be a funder, amount ≤ committed. +2. `remaining = TOTAL_ALLOCATION − Σ(pre-allocated CSV amounts)`. +3. All other funders run through `calculateAccumulatorApprovedAmounts(remaining)`, + with the boost measured only among themselves (pre-allocated wallets excluded). +4. `Σ approved === TOTAL_ALLOCATION` (asserted before anything is sent). diff --git a/scripts/v0.7/credible/allocation/ac/accumulator.ts b/scripts/v0.7/credible/allocation/ac/accumulator.ts new file mode 100644 index 00000000..3272d057 --- /dev/null +++ b/scripts/v0.7/credible/allocation/ac/accumulator.ts @@ -0,0 +1,884 @@ +/** + * Accumulator-based funding approval: time-weighted allocation calculation. + * + * The v0.7 on-chain program adds a time-weighted accumulator to each + * FundingRecord: `accumulator += committed_amount * elapsed_seconds`. + * This rewards early, long-duration funders and penalizes late snipers. + * + * The on-chain program flushes the accumulator on every fund() call, but NOT + * when the launch closes. So when we compute approvals off-chain, we first + * need to "finalize" each record — add the unflushed tail from the last + * fund() call to the deterministic close time (start + duration). + * + * Algorithm overview: + * 1. Finalize accumulators — flush each record to close time + * 2. Accumulator-weighted allocation with cap — no funder gets more than committed + * 3. Redistribute surplus by unfilled commitment — capped records free up tokens + * 4. Dust distribution — guarantee sum(approved) === targetAmount + * + * Since minRaise = maxRaise and token supply is fixed (TOKENS_TO_PARTICIPANTS), + * the token price is deterministic: price = targetAmount / TOKENS_TO_PARTICIPANTS. + * + * Exported: + * - calculateAccumulatorApprovedAmounts: full allocation pipeline + * - AccumulatorFundingRecord: input record shape + */ +import { type PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; + +import type { FundingApproval } from "./fundingApproval"; +import { log } from "../logger"; + +/** + * Configuration for the optional fill-based boost. + * + * The boost rewards "finder" funders — those who committed when the pool + * was still sparse. Each funder's finalized accumulator is multiplied by: + * + * boost = 1 + (multiplier - 1) × max(0, 1 - delayedCum / (target × fillCeiling)) + * + * Where `delayedCum` is the total committed at `min(fundTime + lookAheadSeconds, closeTime)`. + * Looking ahead rather than measuring at fund time captures momentum: a funder + * followed by silence (pool stays sparse) gets a bigger boost than one who + * funds just before a rush. + * + * When boost is undefined or multiplier ≤ 1, the algorithm reduces to + * the plain accumulator-weighted allocation — zero behavioral change. + */ +export interface BoostConfig { + /** Peak multiplier when pool is empty. 1 = no boost (plain accumulator). Must be ≥ 1. */ + multiplier: number; + /** + * Cumulative/target ratio where boost decays to 1x. Must be > 0. + * 1 = boost ends at target. 2 = boost persists until 2x oversubscribed. + */ + fillCeiling: number; + /** Seconds after each funder's entry to measure fill level. 0 = measure at fund time. */ + lookAheadSeconds: number; +} + +/** + * Individual fund event from the indexer DB. + * + * Used to expand multi-contribution funders into per-transaction entries + * for a more accurate cumulative timeline in the boost calculation. + * Only used when: + * 1. A funder has more than one fund event + * 2. The sum of event amounts exactly matches the chain committedAmount + */ +export interface FundEvent { + /** Funder wallet address (base58). */ + funderAddr: string; + /** Amount funded in this specific transaction (USDC lamports). */ + amount: BN; + /** Unix timestamp of this fund transaction (seconds). */ + timestamp: BN; +} + +/** Cumulative timeline entry: total committed at a point in time. */ +interface TimelineEntry { + time: BN; + cumulative: BN; +} + +/** Result of attempting to build a DB-expanded timeline. */ +interface ExpandedTimelineResult { + timeline: TimelineEntry[]; + validatedMultiEvents: Map; +} + +/** + * Attempt to build a higher-resolution cumulative timeline from DB fund events. + * + * For multi-contribution funders (>1 DB event) where the sum matches the chain + * committedAmount exactly and all timestamps are valid, their single chain entry + * is expanded into per-transaction entries. This reveals when money actually + * arrived for a more accurate boost calculation. + * + * The expanded timeline is verified against the chain timeline: at every chain + * checkpoint the expanded cumulative must be >= chain cumulative, and final + * totals must match. On any verification failure, falls back to the chain timeline. + * + * @returns the timeline to use and the map of validated multi-contrib funders + */ +function buildExpandedTimeline( + fundEvents: FundEvent[], + sortedByTime: AccumulatorFundingRecord[], + chainTimeline: TimelineEntry[], +): ExpandedTimelineResult { + const validatedMultiEvents = new Map(); + + // Group fund events by funder. + const eventsByFunder = new Map(); + for (const e of fundEvents) { + if (!eventsByFunder.has(e.funderAddr)) { + eventsByFunder.set(e.funderAddr, []); + } + eventsByFunder.get(e.funderAddr)!.push(e); + } + + // Validate each multi-contribution funder against chain data. + for (const [addr, events] of eventsByFunder) { + // Per user requirement: only use DB data for funders with >1 event. + if (events.length <= 1) continue; + + // Find the matching chain record to validate sum. + const chainRecord = sortedByTime.find((r) => r.funder.toBase58() === addr); + if (!chainRecord) continue; + + let dbSum = new BN(0); + for (const e of events) { + dbSum = dbSum.add(e.amount); + } + + if (!dbSum.eq(chainRecord.committedAmount)) { + log.warn( + { + funder: addr, + dbSum: dbSum.toString(), + chainAmount: chainRecord.committedAmount.toString(), + dbEventCount: events.length, + }, + "Fund event sum mismatch — ignoring DB data for this funder's boost", + ); + continue; + } + + // Timestamp check: every DB event must be at or before the chain's + // lastAccumulatorUpdate (the funder's last fund() call). If any event + // claims a timestamp AFTER the chain's last known interaction, the DB + // data is inconsistent — reject it. + let timestampsValid = true; + for (const e of events) { + if (e.timestamp.gt(chainRecord.lastAccumulatorUpdate)) { + log.warn( + { + funder: addr, + eventTimestamp: e.timestamp.toString(), + chainLastUpdate: chainRecord.lastAccumulatorUpdate.toString(), + }, + "Fund event timestamp exceeds chain lastAccumulatorUpdate — ignoring DB data for this funder", + ); + timestampsValid = false; + break; + } + } + if (!timestampsValid) continue; + + // Sort events by timestamp ascending for consistent timeline insertion. + const sorted = [...events].sort((a, b) => a.timestamp.cmp(b.timestamp)); + validatedMultiEvents.set(addr, sorted); + } + + // No valid multi-contrib funders — chain timeline is already best. + if (validatedMultiEvents.size === 0) { + return { timeline: chainTimeline, validatedMultiEvents }; + } + + log.info( + { + multiContribFunders: validatedMultiEvents.size, + expandedEvents: [...validatedMultiEvents.values()].reduce( + (n, e) => n + e.length, + 0, + ), + }, + "Expanding multi-contribution funders into per-event timeline entries", + ); + + // Build expanded entries: individual DB events for validated funders, + // chain record for everyone else. + const expandedEntries: Array<{ time: BN; amount: BN }> = []; + for (const r of sortedByTime) { + const addr = r.funder.toBase58(); + const multiEvents = validatedMultiEvents.get(addr); + if (multiEvents) { + for (const e of multiEvents) { + expandedEntries.push({ time: e.timestamp, amount: e.amount }); + } + } else { + expandedEntries.push({ + time: r.lastAccumulatorUpdate, + amount: r.committedAmount, + }); + } + } + expandedEntries.sort((a, b) => a.time.cmp(b.time)); + + const expandedTimeline: TimelineEntry[] = []; + let expCum = new BN(0); + for (const entry of expandedEntries) { + expCum = expCum.add(entry.amount); + expandedTimeline.push({ time: entry.time, cumulative: expCum.clone() }); + } + + // --- Checkpoint verification: expanded curve vs chain curve --- + // At every chain checkpoint, expanded cumulative must be >= chain cumulative. + // Final totals must match exactly. + for (let i = 0; i < chainTimeline.length; i++) { + const checkpoint = chainTimeline[i]; + + let expAtCheckpoint = new BN(0); + for (const entry of expandedTimeline) { + if (entry.time.lte(checkpoint.time)) { + expAtCheckpoint = entry.cumulative; + } else { + break; + } + } + + if (expAtCheckpoint.lt(checkpoint.cumulative)) { + log.error( + { + checkpointTime: checkpoint.time.toString(), + chainCumulative: checkpoint.cumulative.toString(), + dbCumulative: expAtCheckpoint.toString(), + }, + "DB cumulative curve is below chain curve at checkpoint — " + + "falling back to chain-only timeline", + ); + return { timeline: chainTimeline, validatedMultiEvents: new Map() }; + } + } + + // Final total must match. + const expFinal = expandedTimeline[expandedTimeline.length - 1].cumulative; + const chainFinal = chainTimeline[chainTimeline.length - 1].cumulative; + if (!expFinal.eq(chainFinal)) { + log.error( + { + dbFinalTotal: expFinal.toString(), + chainTotal: chainFinal.toString(), + }, + "DB timeline final total ≠ chain total — falling back to chain-only timeline", + ); + return { timeline: chainTimeline, validatedMultiEvents: new Map() }; + } + + return { timeline: expandedTimeline, validatedMultiEvents }; +} + +/** + * Look up the cumulative committed amount at a given time in the timeline. + * Returns the cumulative at the last entry at or before `time`, or 0 if none. + */ +function cumulativeAtTime(timeline: TimelineEntry[], time: BN): BN { + let cum = new BN(0); + for (const entry of timeline) { + if (entry.time.lte(time)) { + cum = entry.cumulative; + } else { + break; + } + } + return cum; +} + +/** + * Compute the boost factor for a given delayed cumulative and fill target. + * + * boost = 1 + (multiplier - 1) × max(0, 1 - delayedCum / fillTarget) + * + * Returns a value between 1.0 (pool full) and multiplier (pool empty). + */ +function computeBoostFactor( + delayedCum: BN, + fillTarget: BN, + multiplier: number, +): number { + if (delayedCum.gte(fillTarget)) return 1.0; + const fillRatio = delayedCum.toNumber() / fillTarget.toNumber(); + return 1 + (multiplier - 1) * (1 - fillRatio); +} + +/** + * Apply fill-based boost to a single funder's finalized accumulator. + * + * Single-contrib funders: one boost based on pool fill at their fund time. + * Multi-contrib funders: each contribution gets its own boost based on pool + * fill when THAT contribution arrived. The accumulator is decomposed into + * per-event shares (amount × elapsed time to close), each boosted individually. + * + * @returns the boosted accumulator value + */ +function applyBoostFactor( + f: FinalizedRecord, + multiEvents: FundEvent[] | undefined, + sortedByTime: AccumulatorFundingRecord[], + timeline: TimelineEntry[], + fillTarget: BN, + multiplier: number, + lookAheadBN: BN, + launchCloseTime: BN, + activationTime: BN, +): BN { + const DENOM = 10_000; + + if (multiEvents) { + // Per-event boost: decompose accumulator into per-contribution shares, + // each boosted by the pool fill level when that contribution arrived. + // + // Each contribution's accumulator share: amount × (closeTime - max(eventTime, activationTime)) + // This decomposition is exact: sum of shares = finalized accumulator. + let boostedAccum = new BN(0); + for (const event of multiEvents) { + const periodStart = BN.max(event.timestamp, activationTime); + if (launchCloseTime.lte(periodStart)) continue; + + const eventAccum = event.amount.mul(launchCloseTime.sub(periodStart)); + const lookupTime = BN.min( + event.timestamp.add(lookAheadBN), + launchCloseTime, + ); + const delayedCum = cumulativeAtTime(timeline, lookupTime); + const boostFloat = computeBoostFactor(delayedCum, fillTarget, multiplier); + const numerator = Math.round(boostFloat * DENOM); + boostedAccum = boostedAccum.add(eventAccum.muln(numerator).divn(DENOM)); + } + return boostedAccum; + } + + // Single contribution: one boost for the whole accumulator. + const record = sortedByTime.find( + (r) => r.funder.toBase58() === f.funder.toBase58(), + ); + const fundTime = record ? record.lastAccumulatorUpdate : launchCloseTime; + const lookupTime = BN.min(fundTime.add(lookAheadBN), launchCloseTime); + const delayedCum = cumulativeAtTime(timeline, lookupTime); + const boostFloat = computeBoostFactor(delayedCum, fillTarget, multiplier); + const numerator = Math.round(boostFloat * DENOM); + return f.finalAccumulator.muln(numerator).divn(DENOM); +} + +/** Finalized record with mutable accumulator for boost application. */ +interface FinalizedRecord { + funder: PublicKey; + committedAmount: BN; + finalAccumulator: BN; +} + +/** + * Apply fill-based boost to finalized accumulators. + * + * Multiplies each funder's accumulator by a boost factor based on how full + * the pool was at (or shortly after) their fund time. Early funders who + * committed when the pool was sparse get a higher multiplier. + * + * boost = 1 + (multiplier - 1) × max(0, 1 - delayedCum / (target × fillCeiling)) + * + * When fundEvents are provided, multi-contribution funders (>1 DB event with + * sum matching chain) are expanded into per-transaction timeline entries. + * Each contribution gets its own boost based on the pool fill level when + * that specific contribution arrived — preventing gaming via small early seeds. + * + * Mutates `finalized[].finalAccumulator` in place. + * + * @returns new totalAccumulator (sum of all boosted accumulators) + */ +function applyFillBoost( + boost: BoostConfig, + records: AccumulatorFundingRecord[], + finalized: FinalizedRecord[], + targetAmount: BN, + launchCloseTime: BN, + activationTime: BN, + fundEvents?: FundEvent[], +): BN { + // --- Validate boost config --- + // Returns 0 on failure — caller uses max(boosted, original) as a sanity check. + if (boost.fillCeiling < 0.01) { + log.error( + { fillCeiling: boost.fillCeiling }, + "BoostConfig.fillCeiling must be >= 0.01 — skipping boost", + ); + return new BN(0); + } + if (boost.lookAheadSeconds < 0) { + log.error( + { lookAheadSeconds: boost.lookAheadSeconds }, + "BoostConfig.lookAheadSeconds must be >= 0 — skipping boost", + ); + return new BN(0); + } + + const lookAheadBN = new BN(boost.lookAheadSeconds); + + // fillTarget = targetAmount × fillCeiling (scaled for fractional ceilings) + const fillTarget = targetAmount + .muln(Math.round(boost.fillCeiling * 100)) + .divn(100); + + // Sort records by fund time to build cumulative timeline. + // Only include records that were actually funded (lastAccumulatorUpdate > 0). + const sortedByTime = [...records] + .filter((r) => !r.lastAccumulatorUpdate.isZero()) + .sort((a, b) => a.lastAccumulatorUpdate.cmp(b.lastAccumulatorUpdate)); + + // --- Build chain-only cumulative timeline (always available as baseline) --- + const chainTimeline: TimelineEntry[] = []; + let chainCum = new BN(0); + for (const r of sortedByTime) { + chainCum = chainCum.add(r.committedAmount); + chainTimeline.push({ + time: r.lastAccumulatorUpdate, + cumulative: chainCum.clone(), + }); + } + + // --- Attempt DB-expanded timeline for multi-contribution funders --- + // Returns chainTimeline unchanged if no valid DB events or verification fails. + const { timeline, validatedMultiEvents } = fundEvents?.length + ? buildExpandedTimeline(fundEvents, sortedByTime, chainTimeline) + : { + timeline: chainTimeline, + validatedMultiEvents: new Map(), + }; + + // Apply boost to each funder's accumulator individually. + // Multi-contrib funders get per-event boost; single-contrib get one boost. + let totalAccumulator = new BN(0); + for (const f of finalized) { + const multiEvents = validatedMultiEvents.get(f.funder.toBase58()); + f.finalAccumulator = applyBoostFactor( + f, + multiEvents, + sortedByTime, + timeline, + fillTarget, + boost.multiplier, + lookAheadBN, + launchCloseTime, + activationTime, + ); + totalAccumulator = totalAccumulator.add(f.finalAccumulator); + } + + log.info( + { + boostMultiplier: boost.multiplier, + fillCeiling: boost.fillCeiling, + lookAheadSeconds: boost.lookAheadSeconds, + multiContribFunders: validatedMultiEvents.size, + totalBoostedAccumulator: totalAccumulator.toString(), + }, + "Step 1.5 complete — fill boost applied", + ); + + return totalAccumulator; +} + +/** Input record shape for accumulator-based approval. */ +export interface AccumulatorFundingRecord { + /** The funder's wallet public key. */ + funder: PublicKey; + /** Amount committed in token atoms (USDC lamports). */ + committedAmount: BN; + /** On-chain accumulator value (u128). Updated on each fund() call. */ + committedAmountAccumulator: BN; + /** Unix timestamp (seconds) of the last accumulator flush. 0 if never funded. */ + lastAccumulatorUpdate: BN; +} + +/** + * Finalize a single record's accumulator to the launch close time. + * + * Why this is needed: + * The on-chain accumulator is only updated when fund() is called. + * Between the last fund() call and the launch close, the accumulator + * stops growing even though time continues to pass. We need to add + * this "unflushed tail" so the final value reflects the full duration. + * + * Formula: + * periodStart = max(lastAccumulatorUpdate, activationTime) + * finalAccumulator = accumulator + committedAmount * (closeTime - periodStart) + * + * Edge cases that return the accumulator as-is (no flush needed): + * - lastAccumulatorUpdate === 0: the record was never funded + * - closeTime <= activationTime: the launch closed before the + * activation delay elapsed, so no accumulator time was earned + * - closeTime <= periodStart: the last fund() happened at or after + * close time (shouldn't happen, but defensive) + * + * The activation delay prevents accumulator gaming during the initial + * funding rush — accumulators don't start counting until + * launchStartTime + accumulatorActivationDelaySeconds. + * + * @param record — single funding record from chain + * @param closeTime — deterministic close time (start + duration), NOT unix_timestamp_closed + * @param activationTime — launchStartTime + accumulatorActivationDelaySeconds + * @returns finalized accumulator value (BN, u128 scale) + */ +// Exported for direct unit testing of edge cases (not used outside this module). +export function finalizeAccumulator( + record: AccumulatorFundingRecord, + closeTime: BN, + activationTime: BN, +): BN { + // Record was never funded — nothing to flush. + if (record.lastAccumulatorUpdate.isZero()) { + return record.committedAmountAccumulator; + } + + // Launch closed before the activation delay elapsed — accumulator + // never started counting, so on-chain value is already correct. + if (closeTime.lte(activationTime)) { + return record.committedAmountAccumulator; + } + + // The accumulator only counts time after the activation delay. + // If the funder's last update was before activation, we count from + // activation time instead (they don't get credit for the delay period). + const periodStart = BN.max(record.lastAccumulatorUpdate, activationTime); + + // Last fund() was at or after close — accumulator is already fully flushed. + if (closeTime.lte(periodStart)) { + return record.committedAmountAccumulator; + } + + // Add the unflushed tail: committedAmount * seconds since last flush. + const elapsed = closeTime.sub(periodStart); + return record.committedAmountAccumulator.add( + record.committedAmount.mul(elapsed), + ); +} + +/** + * Calculate accumulator-weighted approved amounts for each funding record. + * + * Four-phase algorithm: + * 1. Finalize accumulators to close time + * 2. Accumulator-weighted allocation with per-funder cap (approved <= committed) + * 3. Redistribute surplus from capped funders by unfilled commitment + * 4. Dust distribution to guarantee exact sum + * + * @param records — funding records from chain + * @param targetAmount — the minimumRaiseAmount (= max goal for accelerated launches) + * @param launchStartTime — unix_timestamp_started (seconds), from Launch account + * @param secondsForLaunch — launch duration in seconds, from Launch account + * @param accumulatorActivationDelaySeconds — from Launch account + * @param boost — optional fill-based boost config. When undefined or multiplier ≤ 1, + * the algorithm is identical to the plain accumulator-weighted allocation. + * @param fundEvents — optional per-transaction fund events from the indexer DB. + * Used to expand multi-contribution funders into individual timeline entries + * for more accurate boost calculation. Only applied when a funder has >1 event + * AND the sum of event amounts exactly matches the chain committedAmount. + * Ignored for single-contribution funders or on sum mismatch. + * @returns array of { funder, approvedAmount } with guaranteed sum === targetAmount + * @throws if totalAccumulator is zero or targetAmount > totalCommitted + */ +export function calculateAccumulatorApprovedAmounts( + records: AccumulatorFundingRecord[], + targetAmount: BN, + launchStartTime: BN, + secondsForLaunch: BN, + accumulatorActivationDelaySeconds: BN, + boost?: BoostConfig, + fundEvents?: FundEvent[], +): FundingApproval[] { + // Close time is deterministic: start + duration. Do NOT use unix_timestamp_closed + // from the Launch account — that's the wall-clock time when closeLaunch was called + // by the cranker, which may be seconds/minutes after the actual expiry. + const launchCloseTime = launchStartTime.add(secondsForLaunch); + const activationTime = launchStartTime.add(accumulatorActivationDelaySeconds); + + // --- Log: Entry parameters --- + log.info( + { + launchStartTime: launchStartTime.toString(), + secondsForLaunch: secondsForLaunch.toString(), + accumulatorActivationDelaySeconds: + accumulatorActivationDelaySeconds.toString(), + launchCloseTime: launchCloseTime.toString(), + activationTime: activationTime.toString(), + targetAmount: targetAmount.toString(), + recordCount: records.length, + }, + "Accumulator approval — entry parameters", + ); + + // --- Step 1: Finalize accumulators --- + // The on-chain accumulator stops growing after the last fund() call. + // We add the unflushed tail (committedAmount * elapsed) to get the + // true time-weighted value at close. Also tally up totals in the + // same pass for the precondition checks below. + const finalized: FinalizedRecord[] = []; + let totalCommitted = new BN(0); + let totalAccumulator = new BN(0); + + for (const record of records) { + const finalAccumulator = finalizeAccumulator( + record, + launchCloseTime, + activationTime, + ); + finalized.push({ + funder: record.funder, + committedAmount: record.committedAmount, + finalAccumulator, + }); + totalCommitted = totalCommitted.add(record.committedAmount); + totalAccumulator = totalAccumulator.add(finalAccumulator); + } + + log.info( + { + totalCommitted: totalCommitted.toString(), + totalAccumulator: totalAccumulator.toString(), + }, + "Phase 1 complete — accumulators finalized", + ); + + // --- Precondition checks --- + if (targetAmount.gt(totalCommitted)) { + throw new Error( + `targetAmount (${targetAmount.toString()}) > totalCommitted (${totalCommitted.toString()}) — approvals would exceed committed amounts`, + ); + } + + // Precondition: totalAccumulator must be > 0 (guaranteed by on-chain program so redundant) + if (totalAccumulator.isZero()) { + throw new Error( + "totalAccumulator is zero — no funder had any time-weighted contribution", + ); + } + + // --- Step 1.5: Apply fill boost (optional) --- + // Mutates finalized[].finalAccumulator and returns new totalAccumulator. + if (boost && boost.multiplier > 1) { + const boostedTotal = applyFillBoost( + boost, + records, + finalized, + targetAmount, + launchCloseTime, + activationTime, + fundEvents, + ); + if (boostedTotal.lt(totalAccumulator)) { + log.error( + { + boosted: boostedTotal.toString(), + original: totalAccumulator.toString(), + }, + "Boost returned less than original — keeping original totalAccumulator", + ); + } else { + totalAccumulator = boostedTotal; + } + } + + // --- Step 2: Accumulator-weighted allocation with cap --- + // Each funder's share is proportional to their accumulator weight: + // uncapped = floor(finalAccumulator * targetAmount / totalAccumulator) + // + // But a funder can never receive more than they committed, so: + // approved = min(uncapped, committedAmount) + // + // This cap commonly triggers for small early funders whose time-weighted + // share exceeds their deposit (e.g. $5k committed but accumulator says + // they "deserve" $50k). The excess becomes surplus for Step 3. + const approvals: Array<{ + funder: PublicKey; + committedAmount: BN; + approvedAmount: BN; + capped: boolean; // true if uncapped > committedAmount (at capacity) + }> = []; + + for (const r of finalized) { + const uncapped = r.finalAccumulator.mul(targetAmount).div(totalAccumulator); + const capped = uncapped.gt(r.committedAmount); + const approvedAmount = capped ? r.committedAmount : uncapped; + approvals.push({ + funder: r.funder, + committedAmount: r.committedAmount, + approvedAmount, + capped, + }); + } + + // --- Step 3: Redistribute surplus by unfilled commitment --- + // Capping in Step 2 may have freed up tokens (sum(approved) < targetAmount). + // Distribute that surplus to uncapped funders, weighted by how much room + // each still has (unfilled = committed - approved). This ensures funders + // with more remaining capacity absorb proportionally more of the surplus. + // + // No new caps can occur here because each record receives at most its own + // unfilled amount — the weight IS the remaining capacity. + let approvedSum = new BN(0); + for (const a of approvals) { + approvedSum = approvedSum.add(a.approvedAmount); + } + const surplus = targetAmount.sub(approvedSum); + + log.info( + { + approvedSum: approvedSum.toString(), + surplus: surplus.toString(), + cappedCount: approvals.filter((a) => a.capped).length, + uncappedCount: approvals.filter((a) => !a.capped).length, + }, + "Phase 2 complete — weighted allocation", + ); + + if (surplus.gt(new BN(0))) { + // Only uncapped records have room for more tokens. + let unfilledTotal = new BN(0); + for (const a of approvals) { + if (!a.capped) { + unfilledTotal = unfilledTotal.add( + a.committedAmount.sub(a.approvedAmount), + ); + } + } + + // Redistribute proportional to each uncapped record's unfilled capacity. + if (unfilledTotal.gt(new BN(0))) { + for (const a of approvals) { + if (!a.capped) { + const unfilled = a.committedAmount.sub(a.approvedAmount); + const extra = surplus.mul(unfilled).div(unfilledTotal); + const newApproved = a.approvedAmount.add(extra); + + a.approvedAmount = newApproved; + } + } + } + + // Recompute sum after redistribution for the Phase 3 summary. + let postRedistSum = new BN(0); + for (const a of approvals) { + postRedistSum = postRedistSum.add(a.approvedAmount); + } + log.info( + { + unfilledTotal: unfilledTotal.toString(), + remainingDust: targetAmount.sub(postRedistSum).toString(), + }, + "Phase 3 complete — surplus redistributed", + ); + } else { + log.info("Phase 3 skipped — no surplus to redistribute"); + } + + // --- Step 4: Dust distribution --- + // Floor division in steps 2 and 3 may leave sum(approved) < targetAmount + // by a few atoms. Distribute one atom at a time to records with room. + let currentSum = new BN(0); + for (const a of approvals) { + currentSum = currentSum.add(a.approvedAmount); + } + let dust = targetAmount.sub(currentSum); + + log.info( + { dustAtoms: dust.toString() }, + dust.isZero() ? "Phase 4 skipped — no dust" : "Phase 4 — distributing dust", + ); + + // Loop until all dust is distributed. One pass is almost always enough, + // but a while guarantees correctness if dust exceeds records.length + // (theoretically possible from compounded floor divisions in steps 2+3). + // Dust is bounded by ~2 * records.length, so 3 passes is more than enough. + const MAX_DUST_PASSES = 3; + let dustPass = 0; + while (dust.gt(new BN(0)) && dustPass < MAX_DUST_PASSES) { + dustPass++; + let distributed = false; + for (let i = 0; dust.gt(new BN(0)) && i < approvals.length; i++) { + if (approvals[i].approvedAmount.lt(approvals[i].committedAmount)) { + approvals[i].approvedAmount = approvals[i].approvedAmount.add( + new BN(1), + ); + dust = dust.sub(new BN(1)); + distributed = true; + } + } + + // If no record had room, all are at their cap. Since targetAmount <= totalCommitted + // this means sum(approved) == totalCommitted >= targetAmount, so dust should be 0. + // If we're here, something is wrong upstream — fail loudly. + if (!distributed) { + throw new Error( + `Dust distribution failed: ${dust.toString()} atoms remain undistributed — no record has room`, + ); + } + } + + // If we exhausted max passes and still have dust, fail loudly. + if (dust.gt(new BN(0))) { + throw new Error( + `Dust distribution failed: ${dust.toString()} atoms remain after ${MAX_DUST_PASSES} passes`, + ); + } + + // --- Compact summary (replaces per-funder table) --- + let totalApproved = new BN(0); + let cappedCount = 0; + for (const a of approvals) { + totalApproved = totalApproved.add(a.approvedAmount); + if (a.capped) cappedCount++; + } + + log.info( + { + funders: approvals.length, + totalCommitted: totalCommitted.toString(), + totalApproved: totalApproved.toString(), + targetAmount: targetAmount.toString(), + cappedFunders: cappedCount, + dustPasses: dustPass, + }, + "Accumulator approval complete", + ); + + // --- Postcondition: verify all 5 mathematical invariants --- + // These are non-negotiable correctness guarantees from the fair-launch spec. + // A violation here means a bug in the algorithm above — fail loudly. + + let finalApprovedSum = new BN(0); + for (const a of approvals) { + finalApprovedSum = finalApprovedSum.add(a.approvedAmount); + } + + // Invariant 2: Raise Target Met — Σ approvedAmount = targetAmount + if (!finalApprovedSum.eq(targetAmount)) { + throw new Error( + `Invariant violation: Σ approvedAmount (${finalApprovedSum.toString()}) !== targetAmount (${targetAmount.toString()})`, + ); + } + + for (let i = 0; i < approvals.length; i++) { + const approved = approvals[i].approvedAmount; + const committed = approvals[i].committedAmount; + + // Invariant 3: Individual Caps — approvedAmount ≤ committedAmount + if (approved.gt(committed)) { + throw new Error( + `Invariant violation: funder ${approvals[i].funder.toBase58()} approved (${approved.toString()}) > committed (${committed.toString()})`, + ); + } + + // Invariant 4: Non-Negativity — approvedAmount ≥ 0 and refundAmount ≥ 0 + if (approved.isNeg()) { + throw new Error( + `Invariant violation: funder ${approvals[i].funder.toBase58()} approved amount is negative (${approved.toString()})`, + ); + } + } + + // Invariant 1: Total Conservation — Σ approved + Σ refund = totalCommitted + // Since refund_i = committed_i - approved_i: + // Σ approved + (totalCommitted - Σ approved) = totalCommitted + // This is tautologically true given the definition, but we verify the + // constituent parts: totalCommitted is correctly computed and finalApprovedSum ≤ totalCommitted. + if (finalApprovedSum.gt(totalCommitted)) { + throw new Error( + `Invariant violation: Σ approvedAmount (${finalApprovedSum.toString()}) > totalCommitted (${totalCommitted.toString()})`, + ); + } + + const result: FundingApproval[] = []; + for (const a of approvals) { + result.push({ funder: a.funder, approvedAmount: a.approvedAmount }); + } + return result; +} diff --git a/scripts/v0.7/credible/allocation/ac/constants.ts b/scripts/v0.7/credible/allocation/ac/constants.ts new file mode 100644 index 00000000..9d988111 --- /dev/null +++ b/scripts/v0.7/credible/allocation/ac/constants.ts @@ -0,0 +1,97 @@ +/** Number of decimal places for USDC on Solana. */ +export const USDC_DECIMALS = 6; + +/** Multiplier to convert whole USDC units to on-chain lamport amounts. */ +export const USDC_SCALAR = 10 ** USDC_DECIMALS; + +/** Number of decimal places for launch tokens (same as USDC). */ +export const TOKEN_DECIMALS = 6; + +/** Multiplier to convert whole token units to on-chain lamport amounts. */ +export const TOKEN_SCALAR = 10 ** TOKEN_DECIMALS; + +/** + * Total tokens allocated to participants in a launch. + * From the on-chain program: 10,000,000 * TOKEN_SCALE = 10,000,000,000,000 atoms. + * Used in claim.rs: token_amount = (approved_amount * TOKENS_TO_PARTICIPANTS) / total_approved_amount + */ +export const TOKENS_TO_PARTICIPANTS = 10_000_000 * TOKEN_SCALAR; + +/** + * Buffer (in seconds) added to time-based checks before sending + * on-chain instructions. Accounts for clock drift between our + * local time and the Solana cluster clock, avoiding premature + * tx failures that would burn retries. + */ +export const CLOCK_DRIFT_BUFFER_SECONDS = 10; + +/** + * Max funding record approvals per transaction batch. + * Each ix is small (~16k CUs), but each adds 2 unique accounts + * (funder + funding_record PDA) × 32 bytes. At batch size 10: + * ~4 shared + 20 unique = 24 accounts × 32 = 768 bytes, fitting + * comfortably within Solana's 1232-byte legacy tx limit. + */ +export const APPROVAL_BATCH_SIZE = 10; + +/** + * Priority fee in microlamports per compute unit. + * Configurable via PRIORITY_FEE_MICRO_LAMPORTS env var. + * Default 10,000 — enough to land during moderate congestion + * without burning SOL on high-batch-count launches (~0.13 SOL + * for a 725-funder launch across 73 approval batches + completeLaunch). + */ +export const PRIORITY_FEE_MICRO_LAMPORTS = parseInt( + process.env.PRIORITY_FEE_MICRO_LAMPORTS ?? "10000", + 10, +); + +// ── Boost configuration ── +// Fill-based boost for accumulator-weighted approvals. Rewards "finder" +// funders who committed when the pool was still sparse. +// Set BOOST_MULTIPLIER > 1 to enable. When 1, no boost is applied. + +/** + * Peak multiplier when pool is empty. 1 = no boost (plain accumulator). + * Example: 10 means early funders' accumulators are weighted up to 10x. + */ +export const BOOST_MULTIPLIER = parseFloat( + process.env.BOOST_MULTIPLIER ?? "10", +); + +/** + * Cumulative/target ratio where boost decays to 1x. Must be > 0. + * 1 = boost ends at target. 3 = boost persists until 3x oversubscribed. + */ +export const BOOST_FILL_CEILING = parseFloat( + process.env.BOOST_FILL_CEILING ?? "3", +); + +/** + * Hours after each funder's entry to measure fill level. 0 = measure at fund time. + * Converted to seconds when constructing BoostConfig. + * Example: 1 means fill is measured 1 hour after each funder's commitment. + */ +export const BOOST_LOOK_AHEAD_HOURS = parseFloat( + process.env.BOOST_LOOK_AHEAD_HOURS ?? "1", +); + +// ── Jito bundle retry constants ── + +/** Jito block engine base URL for bundle submission. */ +export const JITO_BLOCK_ENGINE_URL = + "https://mainnet.block-engine.jito.wtf/api/v1/bundles"; + +/** + * SOL tip sent to a random Jito tip account when retrying via bundle. + * Configurable via JITO_TIP_SOL env var. Default: 0.01 SOL. + */ +export const JITO_TIP_LAMPORTS = Math.round( + parseFloat(process.env.JITO_TIP_SOL ?? "0.01") * 1_000_000_000, +); + +/** Max attempts to poll Jito for bundle confirmation before giving up. */ +export const JITO_BUNDLE_POLL_ATTEMPTS = 30; + +/** Milliseconds between Jito bundle status polls. */ +export const JITO_BUNDLE_POLL_INTERVAL_MS = 2_000; diff --git a/scripts/v0.7/credible/allocation/ac/fundingApproval.ts b/scripts/v0.7/credible/allocation/ac/fundingApproval.ts new file mode 100644 index 00000000..97bfcb1b --- /dev/null +++ b/scripts/v0.7/credible/allocation/ac/fundingApproval.ts @@ -0,0 +1,308 @@ +/** + * Funding record approval: batch sending of setFundingRecordApproval ixs. + * + * After a launch moves to "Closed" state (minimum raise met), the cranker + * must approve each funder's allocation before calling completeLaunch. + * + * The approval amounts are calculated by fundingAccumulatorApproval.ts + * (accumulator-weighted allocation). This module handles the on-chain + * batch sending. + * + * Separated from chainLaunchWatcher because it's self-contained logic + * with no DB or watcher dependencies. + */ +import { + type Launch, + type LaunchpadClient, +} from "@metadaoproject/programs/launchpad/v0.7"; +import { + ComputeBudgetProgram, + type Connection, + type PublicKey, + Transaction, + TransactionExpiredBlockheightExceededError, + TransactionExpiredTimeoutError, +} from "@solana/web3.js"; +import type * as anchor from "@coral-xyz/anchor"; +import BN from "bn.js"; + +import { log } from "../logger"; +import { APPROVAL_BATCH_SIZE, PRIORITY_FEE_MICRO_LAMPORTS } from "./constants"; + +const logger = log.child({ module: "fundingApproval" }); + +/** Max retries per individual batch when a transaction expires. */ +const BATCH_SEND_RETRIES = 3; + +/** + * Delay (ms) between consecutive batch sends. + * Surfpool's validator can choke when processing hundreds of account + * writes in rapid succession. A small pause between batches lets the + * validator's transaction pipeline drain. 200ms × 73 batches = ~15s + * of added overhead — negligible for a launch with 725 funders. + */ +const INTER_BATCH_DELAY_MS = 200; + +/** A single funder's approved allocation. */ +export interface FundingApproval { + /** The funder's wallet public key. */ + funder: PublicKey; + /** Amount approved in token atoms (USDC lamports). Always <= committedAmount. */ + approvedAmount: BN; +} + +/** + * Send setFundingRecordApproval instructions in batches. + * + * Each batch contains up to APPROVAL_BATCH_SIZE (10) approvals with a + * compute budget set to `batchSize * 16_000` CUs. Batches are sent + * sequentially with per-batch retry on transaction expiry errors. + * + * Re-approval is safe — the Rust program uses delta accounting on + * `total_approved_amount`, so re-approving with the same amount is + * effectively a no-op. This makes crash recovery simple: on retry, + * the caller recalculates and re-sends all approvals. + * + * @param launchpad — SDK client for building instructions + * @param connection — Solana RPC connection for sending transactions + * @param payer — wallet that signs and pays for transactions + * @param launchAddr — the on-chain launch account address + * @param approvals — calculated approvals from calculateApprovedAmounts + * @returns the maximum slot any batch confirmed at — pin the post-approval + * verification read to this with `minContextSlot` so it reflects every batch + * (see {@link fetchLaunchAtSlot}). 0 when there are no approvals to send. + * @throws on any batch failure — caller is responsible for retry logic + */ +export async function approveFundingRecords( + launchpad: LaunchpadClient, + connection: Connection, + payer: anchor.Wallet, + launchAddr: PublicKey, + approvals: FundingApproval[], +): Promise<{ maxConfirmedSlot: number }> { + const addr = launchAddr.toBase58(); + + // Tracked for read-your-writes verification and forensic logging: the highest + // slot a batch confirmed at, and the running Σ of approved amounts sent. + let maxConfirmedSlot = 0; + let runningTotal = new BN(0); + + for (let i = 0; i < approvals.length; i += APPROVAL_BATCH_SIZE) { + const batch = approvals.slice(i, i + APPROVAL_BATCH_SIZE); + const batchNum = Math.floor(i / APPROVAL_BATCH_SIZE) + 1; + const totalBatches = Math.ceil(approvals.length / APPROVAL_BATCH_SIZE); + + const tx = new Transaction(); + + // Compute budget: 16k CUs per approval instruction. + // setFundingRecordApproval is lightweight — mostly account reads + // and a single delta update on total_approved_amount. + tx.add( + ComputeBudgetProgram.setComputeUnitLimit({ + units: batch.length * 16_000, + }), + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: PRIORITY_FEE_MICRO_LAMPORTS, + }), + ); + + // Build one setFundingRecordApprovalIx per funder in this batch. + for (const approval of batch) { + const ix = await launchpad + .setFundingRecordApprovalIx({ + launch: launchAddr, + funder: approval.funder, + approvedAmount: approval.approvedAmount, + }) + .instruction(); + tx.add(ix); + } + + // Sign, send, and confirm with per-batch retry. + // Under heavy load (e.g., 725 records = 73 batches), the RPC can drop + // transactions or fail to confirm within the blockhash validity window. + // Re-approval is safe (delta accounting), so retrying a batch is harmless. + const { txHash, confirmedSlot } = await sendBatchWithRetry( + connection, + payer, + tx, + batchNum, + totalBatches, + ); + + maxConfirmedSlot = Math.max(maxConfirmedSlot, confirmedSlot); + for (const approval of batch) + runningTotal = runningTotal.add(approval.approvedAmount); + + logger.info( + { + launchAddr: addr, + batch: batchNum, + totalBatches, + approvals: batch.length, + tx: txHash, + confirmedSlot, + runningTotal: runningTotal.toString(), + }, + "Approved funding record batch", + ); + + // Throttle between batches to prevent overwhelming the validator. + if (i + APPROVAL_BATCH_SIZE < approvals.length) { + await new Promise((r) => setTimeout(r, INTER_BATCH_DELAY_MS)); + } + } + + return { maxConfirmedSlot }; +} + +/** + * Send a single approval batch transaction with retry on expiry errors. + * + * Under heavy load (many batches in rapid succession), the RPC can drop + * transactions or fail to include them before the blockhash expires. + * This function retries with a fresh blockhash + signature on expiry, + * which is safe because re-approval uses delta accounting (no-op if + * already applied). + * + * Non-expiry errors (e.g., instruction failure) are thrown immediately + * since retrying won't help. + */ +async function sendBatchWithRetry( + connection: Connection, + payer: anchor.Wallet, + tx: Transaction, + batchNum: number, + totalBatches: number, +): Promise<{ txHash: string; confirmedSlot: number }> { + for (let attempt = 1; attempt <= BATCH_SEND_RETRIES; attempt++) { + // Fresh blockhash for each attempt — the previous one may have expired. + const { blockhash, lastValidBlockHeight } = + await connection.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = payer.publicKey; + tx.signatures = []; // Clear stale signatures before re-signing. + tx.sign(payer.payer); + + const txHash = await connection.sendRawTransaction(tx.serialize(), { + skipPreflight: true, // Reduce RPC load — we built the tx ourselves. + }); + + try { + const confirmation = await connection.confirmTransaction( + { signature: txHash, blockhash, lastValidBlockHeight }, + "confirmed", + ); + + // confirmTransaction resolves even if the tx failed on-chain. + // Check the err field — otherwise failed approvals are silently + // treated as successes and totalApprovedAmount stays at 0. + if (confirmation.value.err) { + throw new Error( + `Approval batch ${batchNum}/${totalBatches} failed on-chain: ${JSON.stringify(confirmation.value.err)} (tx: ${txHash})`, + ); + } + + // context.slot is the node's slot when it observed the confirmation — + // always >= the slot the tx landed in. Plumbed up so the verification + // read can pin minContextSlot to the max across batches. + return { txHash, confirmedSlot: confirmation.context.slot }; + } catch (err) { + // Retry on transaction expiry (dropped or not included in time). + const isExpiry = + err instanceof TransactionExpiredBlockheightExceededError || + err instanceof TransactionExpiredTimeoutError; + + if (isExpiry && attempt < BATCH_SEND_RETRIES) { + logger.warn( + { + batch: batchNum, + totalBatches, + attempt, + maxAttempts: BATCH_SEND_RETRIES, + }, + "Approval batch expired — retrying with fresh blockhash", + ); + continue; + } + throw err; + } + } + + throw new Error("unreachable"); +} + +/** Max attempts for {@link fetchLaunchAtSlot} when the replica is lagging. */ +const SLOT_PINNED_READ_ATTEMPTS = 5; + +/** + * Does this error mean the RPC node hasn't reached the requested minContextSlot? + * + * web3.js surfaces this as a SolanaJSONRPCError with code -32016 + * ("Minimum context slot has not been reached"). Duck-typed so we don't depend + * on the concrete error class. + */ +function isMinContextSlotError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + if (code === -32016) return true; + const message = (err as { message?: unknown }).message; + return ( + typeof message === "string" && message.includes("Minimum context slot") + ); +} + +/** + * Read the launch account pinned to a minimum slot — read-your-writes safe. + * + * The post-approval verification in chainLaunchWatcher must see every approval + * batch it just sent. A plain fetch can hit a load-balanced replica whose + * `confirmed` view lags 1–2 slots behind the node that confirmed the writes, + * returning a stale `totalApprovedAmount` and triggering a false "below minimum" + * failure. Pinning `minContextSlot` to the max confirmed batch slot + * makes a lagging replica error with -32016 instead of answering stale; we back + * off briefly and retry, converting a silent stale read into a correct one. + * + * @param minContextSlot max slot the approvals confirmed at (from + * {@link approveFundingRecords}). 0 ⇒ no batches sent ⇒ unpinned read. + */ +export async function fetchLaunchAtSlot( + connection: Connection, + launchpad: LaunchpadClient, + launchAddr: PublicKey, + minContextSlot: number, + opts?: { maxAttempts?: number; sleepMs?: (attempt: number) => number }, +): Promise { + const maxAttempts = opts?.maxAttempts ?? SLOT_PINNED_READ_ATTEMPTS; + const sleepMs = opts?.sleepMs ?? ((attempt: number) => 1000 * attempt); // 1s,2s,3s,4s + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const info = await connection.getAccountInfo(launchAddr, { + commitment: "confirmed", + // 0 ⇒ no batches sent (nothing to wait for); read without pinning. + ...(minContextSlot > 0 ? { minContextSlot } : {}), + }); + if (!info) { + throw new Error(`Launch account not found: ${launchAddr.toBase58()}`); + } + return await launchpad.deserializeLaunch(info); + } catch (err) { + if (isMinContextSlotError(err) && attempt < maxAttempts) { + logger.warn( + { + launchAddr: launchAddr.toBase58(), + minContextSlot, + attempt, + maxAttempts, + }, + "Replica behind minContextSlot — backing off before re-reading launch", + ); + await new Promise((r) => setTimeout(r, sleepMs(attempt))); + continue; + } + throw err; + } + } + + throw new Error("unreachable"); +} diff --git a/scripts/v0.7/credible/allocation/allocation.ts b/scripts/v0.7/credible/allocation/allocation.ts new file mode 100644 index 00000000..fac9c109 --- /dev/null +++ b/scripts/v0.7/credible/allocation/allocation.ts @@ -0,0 +1,174 @@ +/** + * Off-the-top allocation — the one piece of logic unique to credible (everything in + * ac/ is copied from the accelerated-cranker). Pure and side-effect-free so it + * can be unit-tested directly (see test/allocation.test.ts). + * + * Pre-allocated wallets get a FIXED amount (from the pre-allocation CSV), which + * must not exceed their commitment. The remaining budget (totalAllocation − Σ + * pre-allocated) is split among all the regular funders via the accumulator + * algorithm — identical to the accelerated-cranker. + */ +import type { PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; + +import { + calculateAccumulatorApprovedAmounts, + type AccumulatorFundingRecord, + type BoostConfig, + type FundEvent, +} from "./ac/accumulator"; +import type { FundingApproval } from "./ac/fundingApproval"; + +/** A funder's line in the allocation table. */ +export interface AllocationLine { + funder: PublicKey; + committedAmount: BN; + approvedAmount: BN; + /** "pre" (pre-allocated fixed amount) or "accum" (accumulator-allocated). */ + kind: "pre" | "accum"; + /** Unix timestamp (s) of the funder's last fund() call — the sort key for the display. */ + lastAccumulatorUpdate: BN; + /** + * Unix timestamp (s) of the funder's FIRST fund (from fund events). Equals + * lastAccumulatorUpdate for single-contribution funders; earlier for those who + * topped up (the display shows first→last only when they differ). + */ + firstFundTime: BN; +} + +/** Result of the off-the-top allocation. */ +export interface AllocationResult { + lines: AllocationLine[]; + approvals: FundingApproval[]; + preAllocatedTotal: BN; + remaining: BN; +} + +/** + * @param records — every funder's on-chain record (pre-allocated + regular) + * @param preAllocated — base58 wallet → fixed allocation (atoms). Each wallet must + * be a funder, and its amount must not exceed that funder's committed. + * @param totalAllocation — total to allocate across everyone (atoms) + * @param launchStartTime / secondsForLaunch / accumulatorActivationDelaySeconds — launch timing + * @param boost — accumulator boost config + * @param fundEvents — per-tx fund events for the boost (pre-allocated are filtered out) + * @returns per-funder lines + approvals, with Σ approved === totalAllocation + * @throws if a pre-allocated wallet isn't a funder, its amount exceeds committed, + * or the pre-allocated total exceeds totalAllocation + */ +export function computeAllocation( + records: AccumulatorFundingRecord[], + preAllocated: Map, + totalAllocation: BN, + launchStartTime: BN, + secondsForLaunch: BN, + accumulatorActivationDelaySeconds: BN, + boost: BoostConfig, + fundEvents: FundEvent[], +): AllocationResult { + const recordByAddr = new Map(records.map((r) => [r.funder.toBase58(), r])); + + // Validate every pre-allocated wallet against chain: it must be a funder, and its + // fixed amount must not exceed its commitment (can't approve more than committed). + for (const [addr, amount] of preAllocated) { + const rec = recordByAddr.get(addr); + if (!rec) + throw new Error( + `Pre-allocated wallet ${addr} has no funding record on this launch`, + ); + if (amount.gt(rec.committedAmount)) { + throw new Error( + `Pre-allocated amount for ${addr} (${amount.toString()}) exceeds its committed (${rec.committedAmount.toString()})`, + ); + } + } + + const preAllocatedRecords = records.filter((r) => + preAllocated.has(r.funder.toBase58()), + ); + const regularRecords = records.filter( + (r) => !preAllocated.has(r.funder.toBase58()), + ); + + // Earliest fund time per funder, for the display. The on-chain record stores + // only the LAST fund; the first comes from fund events. Falls back to the + // record's time for single-contribution funders (first == last, no arrow shown). + const eventCountByFunder = new Map(); + const firstFundByFunder = new Map(); + for (const e of fundEvents) { + eventCountByFunder.set( + e.funderAddr, + (eventCountByFunder.get(e.funderAddr) ?? 0) + 1, + ); + const cur = firstFundByFunder.get(e.funderAddr); + if (!cur || e.timestamp.lt(cur)) + firstFundByFunder.set(e.funderAddr, e.timestamp); + } + const firstFundOf = (funder: string, lastFund: BN): BN => + (eventCountByFunder.get(funder) ?? 0) > 1 + ? firstFundByFunder.get(funder)! + : lastFund; + + // Step A: pre-allocated wallets get their fixed amount. + const preAllocatedLines: AllocationLine[] = preAllocatedRecords.map((r) => ({ + funder: r.funder, + committedAmount: r.committedAmount, + approvedAmount: preAllocated.get(r.funder.toBase58())!, + kind: "pre", + lastAccumulatorUpdate: r.lastAccumulatorUpdate, + firstFundTime: firstFundOf(r.funder.toBase58(), r.lastAccumulatorUpdate), + })); + let preAllocatedTotal = new BN(0); + for (const l of preAllocatedLines) + preAllocatedTotal = preAllocatedTotal.add(l.approvedAmount); + + // Step B: the regular funders split the remainder via the accumulator algorithm. + const remaining = totalAllocation.sub(preAllocatedTotal); + if (remaining.isNeg()) { + throw new Error( + `Pre-allocated total ${preAllocatedTotal.toString()} > totalAllocation ${totalAllocation.toString()} — raise the total or reduce allocations`, + ); + } + if (remaining.gt(new BN(0)) && regularRecords.length === 0) { + throw new Error( + `Remaining ${remaining.toString()} to allocate but no regular funders exist — nobody can absorb it`, + ); + } + + // Regular funders split `remaining` via the accumulator (handles remaining === 0 + // by approving all of them zero). Only pass fund events for the regular funders — + // the pre-allocated wallets are out of the pool. + const regularFundEvents = fundEvents.filter( + (e) => !preAllocated.has(e.funderAddr), + ); + const regularApprovals = calculateAccumulatorApprovedAmounts( + regularRecords, + remaining, + launchStartTime, + secondsForLaunch, + accumulatorActivationDelaySeconds, + boost, + regularFundEvents, + ); + const regularLines: AllocationLine[] = regularApprovals.map((a) => { + const rec = recordByAddr.get(a.funder.toBase58())!; + return { + funder: a.funder, + committedAmount: rec.committedAmount, + approvedAmount: a.approvedAmount, + kind: "accum", + lastAccumulatorUpdate: rec.lastAccumulatorUpdate, + firstFundTime: firstFundOf( + a.funder.toBase58(), + rec.lastAccumulatorUpdate, + ), + }; + }); + + const lines = [...preAllocatedLines, ...regularLines]; + const approvals: FundingApproval[] = lines.map((l) => ({ + funder: l.funder, + approvedAmount: l.approvedAmount, + })); + return { lines, approvals, preAllocatedTotal, remaining }; +} diff --git a/scripts/v0.7/credible/allocation/bun.lock b/scripts/v0.7/credible/allocation/bun.lock new file mode 100644 index 00000000..5350c484 --- /dev/null +++ b/scripts/v0.7/credible/allocation/bun.lock @@ -0,0 +1,373 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "laso", + "dependencies": { + "@coral-xyz/anchor": "0.29.0", + "@metadaoproject/programs": "0.1.0-alpha.7", + "@solana/web3.js": "1.98.4", + "bn.js": "5.2.3", + "bs58": "6.0.0", + "dotenv": "16.6.1", + "pg": "8.18.0", + }, + "devDependencies": { + "@types/bn.js": "5.2.0", + "@types/bun": "1.3.14", + "@types/pg": "8.20.0", + "typescript": "5.9.3", + }, + }, + }, + "packages": { + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@coral-xyz/anchor": ["@coral-xyz/anchor@0.29.0", "", { "dependencies": { "@coral-xyz/borsh": "^0.29.0", "@noble/hashes": "^1.3.1", "@solana/web3.js": "^1.68.0", "bn.js": "^5.1.2", "bs58": "^4.0.1", "buffer-layout": "^1.2.2", "camelcase": "^6.3.0", "cross-fetch": "^3.1.5", "crypto-hash": "^1.3.0", "eventemitter3": "^4.0.7", "pako": "^2.0.3", "snake-case": "^3.0.4", "superstruct": "^0.15.4", "toml": "^3.0.0" } }, "sha512-eny6QNG0WOwqV0zQ7cs/b1tIuzZGmP7U7EcH+ogt4Gdbl8HDmIYVMh/9aTmYZPaFWjtUaI8qSn73uYEXWfATdA=="], + + "@coral-xyz/borsh": ["@coral-xyz/borsh@0.29.0", "", { "dependencies": { "bn.js": "^5.1.2", "buffer-layout": "^1.2.0" }, "peerDependencies": { "@solana/web3.js": "^1.68.0" } }, "sha512-s7VFVa3a0oqpkuRloWVPdCK7hMbAMY270geZOGfCnaqexrP5dTIpbEHL33req6IYPPJ0hYa71cdvJ1h6V55/oQ=="], + + "@metadaoproject/programs": ["@metadaoproject/programs@0.1.0-alpha.7", "", { "dependencies": { "@coral-xyz/anchor": "0.29.0", "@noble/hashes": "1.8.0", "@solana/spl-token": "0.3.11", "@solana/web3.js": "1.98.4", "@sqds/multisig": "2.1.4", "bn.js": "5.2.2" } }, "sha512-yaWRuOfdWiwVFmO0l6fSPJIP5K7KD6frVk0WIFz6H7CIu1hnZQcmM634R0LxW+PAlD7cHu9dliz1XKnOSUCFIg=="], + + "@metaplex-foundation/beet": ["@metaplex-foundation/beet@0.7.1", "", { "dependencies": { "ansicolors": "^0.3.2", "bn.js": "^5.2.0", "debug": "^4.3.3" } }, "sha512-hNCEnS2WyCiYyko82rwuISsBY3KYpe828ubsd2ckeqZr7tl0WVLivGkoyA/qdiaaHEBGdGl71OpfWa2rqL3DiA=="], + + "@metaplex-foundation/beet-solana": ["@metaplex-foundation/beet-solana@0.4.0", "", { "dependencies": { "@metaplex-foundation/beet": ">=0.1.0", "@solana/web3.js": "^1.56.2", "bs58": "^5.0.0", "debug": "^4.3.4" } }, "sha512-B1L94N3ZGMo53b0uOSoznbuM5GBNJ8LwSeznxBxJ+OThvfHQ4B5oMUqb+0zdLRfkKGS7Q6tpHK9P+QK0j3w2cQ=="], + + "@metaplex-foundation/cusper": ["@metaplex-foundation/cusper@0.0.2", "", {}, "sha512-S9RulC2fFCFOQraz61bij+5YCHhSO9llJegK8c8Y6731fSi6snUSQJdCUqYS8AIgR0TKbQvdvgSyIIdbDFZbBA=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@solana/buffer-layout": ["@solana/buffer-layout@4.0.1", "", { "dependencies": { "buffer": "~6.0.3" } }, "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA=="], + + "@solana/buffer-layout-utils": ["@solana/buffer-layout-utils@0.2.0", "", { "dependencies": { "@solana/buffer-layout": "^4.0.0", "@solana/web3.js": "^1.32.0", "bigint-buffer": "^1.1.5", "bignumber.js": "^9.0.1" } }, "sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g=="], + + "@solana/codecs": ["@solana/codecs@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/codecs-data-structures": "2.0.0-rc.1", "@solana/codecs-numbers": "2.0.0-rc.1", "@solana/codecs-strings": "2.0.0-rc.1", "@solana/options": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ=="], + + "@solana/codecs-core": ["@solana/codecs-core@2.3.0", "", { "dependencies": { "@solana/errors": "2.3.0" }, "peerDependencies": { "typescript": ">=5.3.3" } }, "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw=="], + + "@solana/codecs-data-structures": ["@solana/codecs-data-structures@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/codecs-numbers": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog=="], + + "@solana/codecs-numbers": ["@solana/codecs-numbers@2.3.0", "", { "dependencies": { "@solana/codecs-core": "2.3.0", "@solana/errors": "2.3.0" }, "peerDependencies": { "typescript": ">=5.3.3" } }, "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg=="], + + "@solana/codecs-strings": ["@solana/codecs-strings@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/codecs-numbers": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "fastestsmallesttextencoderdecoder": "^1.0.22", "typescript": ">=5" } }, "sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g=="], + + "@solana/errors": ["@solana/errors@2.3.0", "", { "dependencies": { "chalk": "^5.4.1", "commander": "^14.0.0" }, "peerDependencies": { "typescript": ">=5.3.3" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ=="], + + "@solana/options": ["@solana/options@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/codecs-data-structures": "2.0.0-rc.1", "@solana/codecs-numbers": "2.0.0-rc.1", "@solana/codecs-strings": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA=="], + + "@solana/spl-token": ["@solana/spl-token@0.3.11", "", { "dependencies": { "@solana/buffer-layout": "^4.0.0", "@solana/buffer-layout-utils": "^0.2.0", "@solana/spl-token-metadata": "^0.1.2", "buffer": "^6.0.3" }, "peerDependencies": { "@solana/web3.js": "^1.88.0" } }, "sha512-bvohO3rIMSVL24Pb+I4EYTJ6cL82eFpInEXD/I8K8upOGjpqHsKUoAempR/RnUlI1qSFNyFlWJfu6MNUgfbCQQ=="], + + "@solana/spl-token-metadata": ["@solana/spl-token-metadata@0.1.6", "", { "dependencies": { "@solana/codecs": "2.0.0-rc.1" }, "peerDependencies": { "@solana/web3.js": "^1.95.3" } }, "sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA=="], + + "@solana/web3.js": ["@solana/web3.js@1.98.4", "", { "dependencies": { "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", "@noble/hashes": "^1.4.0", "@solana/buffer-layout": "^4.0.1", "@solana/codecs-numbers": "^2.1.0", "agentkeepalive": "^4.5.0", "bn.js": "^5.2.1", "borsh": "^0.7.0", "bs58": "^4.0.1", "buffer": "6.0.3", "fast-stable-stringify": "^1.0.0", "jayson": "^4.1.1", "node-fetch": "^2.7.0", "rpc-websockets": "^9.0.2", "superstruct": "^2.0.2" } }, "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw=="], + + "@sqds/multisig": ["@sqds/multisig@2.1.4", "", { "dependencies": { "@metaplex-foundation/beet": "0.7.1", "@metaplex-foundation/beet-solana": "0.4.0", "@metaplex-foundation/cusper": "^0.0.2", "@solana/spl-token": "^0.3.6", "@solana/web3.js": "^1.70.3", "@types/bn.js": "^5.1.1", "assert": "^2.0.0", "bn.js": "^5.2.1", "buffer": "6.0.3", "invariant": "2.2.4" } }, "sha512-5w+NmwHOzl96nI50R/fjSD6uFydRLNUquhoEmmWbGepS4D9DnQyF2TKcUBfTyxV3sgJt00ypBt7SXB3y8WOzUQ=="], + + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + + "@types/bn.js": ["@types/bn.js@5.2.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], + + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + + "@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], + + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + + "ansicolors": ["ansicolors@0.3.2", "", {}, "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg=="], + + "assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bigint-buffer": ["bigint-buffer@1.1.5", "", { "dependencies": { "bindings": "^1.3.0" } }, "sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="], + + "borsh": ["borsh@0.7.0", "", { "dependencies": { "bn.js": "^5.2.0", "bs58": "^4.0.0", "text-encoding-utf-8": "^1.0.2" } }, "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA=="], + + "bs58": ["bs58@6.0.0", "", { "dependencies": { "base-x": "^5.0.0" } }, "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-layout": ["buffer-layout@1.2.2", "", {}, "sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA=="], + + "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], + + "crypto-hash": ["crypto-hash@1.3.0", "", {}, "sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "delay": ["delay@5.0.0", "", {}, "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw=="], + + "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + + "es6-promisify": ["es6-promisify@5.0.0", "", { "dependencies": { "es6-promise": "^4.0.3" } }, "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "eyes": ["eyes@0.1.8", "", {}, "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ=="], + + "fast-stable-stringify": ["fast-stable-stringify@1.0.0", "", {}, "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag=="], + + "fastestsmallesttextencoderdecoder": ["fastestsmallesttextencoderdecoder@1.0.22", "", {}, "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + + "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-nan": ["is-nan@1.3.2", "", { "dependencies": { "call-bind": "^1.0.0", "define-properties": "^1.1.3" } }, "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "isomorphic-ws": ["isomorphic-ws@4.0.1", "", { "peerDependencies": { "ws": "*" } }, "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w=="], + + "jayson": ["jayson@4.3.0", "", { "dependencies": { "@types/connect": "^3.4.33", "@types/node": "^12.12.54", "@types/ws": "^7.4.4", "commander": "^2.20.3", "delay": "^5.0.0", "es6-promisify": "^5.0.0", "eyes": "^0.1.8", "isomorphic-ws": "^4.0.1", "json-stringify-safe": "^5.0.1", "stream-json": "^1.9.1", "uuid": "^8.3.2", "ws": "^7.5.10" }, "bin": { "jayson": "bin/jayson.js" } }, "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="], + + "pg": ["pg@8.18.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.15.0", "", {}, "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "rpc-websockets": ["rpc-websockets@9.3.10", "", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/ws": "^8.2.2", "buffer": "^6.0.3", "eventemitter3": "^5.0.1", "ws": "^8.5.0" }, "optionalDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^6.0.0" } }, "sha512-QT5PQ6LiWhA5RCS93oWwgxU4XzQltkYm8C3aTmmKEgj0HolGRo3VbdzELw7CEV35l9T7Amha8Vnr4rCfSjVP+w=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "snake-case": ["snake-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="], + + "stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="], + + "superstruct": ["superstruct@0.15.5", "", {}, "sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ=="], + + "text-encoding-utf-8": ["text-encoding-utf-8@1.0.2", "", {}, "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg=="], + + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "utf-8-validate": ["utf-8-validate@6.0.6", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA=="], + + "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], + + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], + + "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "@coral-xyz/anchor/bs58": ["bs58@4.0.1", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw=="], + + "@metadaoproject/programs/bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="], + + "@metaplex-foundation/beet-solana/bs58": ["bs58@5.0.0", "", { "dependencies": { "base-x": "^4.0.0" } }, "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ=="], + + "@solana/codecs/@solana/codecs-core": ["@solana/codecs-core@2.0.0-rc.1", "", { "dependencies": { "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ=="], + + "@solana/codecs/@solana/codecs-numbers": ["@solana/codecs-numbers@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ=="], + + "@solana/codecs-data-structures/@solana/codecs-core": ["@solana/codecs-core@2.0.0-rc.1", "", { "dependencies": { "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ=="], + + "@solana/codecs-data-structures/@solana/codecs-numbers": ["@solana/codecs-numbers@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ=="], + + "@solana/codecs-data-structures/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/codecs-strings/@solana/codecs-core": ["@solana/codecs-core@2.0.0-rc.1", "", { "dependencies": { "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ=="], + + "@solana/codecs-strings/@solana/codecs-numbers": ["@solana/codecs-numbers@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ=="], + + "@solana/codecs-strings/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/errors/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "@solana/options/@solana/codecs-core": ["@solana/codecs-core@2.0.0-rc.1", "", { "dependencies": { "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ=="], + + "@solana/options/@solana/codecs-numbers": ["@solana/codecs-numbers@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ=="], + + "@solana/options/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/web3.js/bs58": ["bs58@4.0.1", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw=="], + + "@solana/web3.js/superstruct": ["superstruct@2.0.2", "", {}, "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A=="], + + "borsh/bs58": ["bs58@4.0.1", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw=="], + + "jayson/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], + + "rpc-websockets/@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "rpc-websockets/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "rpc-websockets/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "@coral-xyz/anchor/bs58/base-x": ["base-x@3.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA=="], + + "@metaplex-foundation/beet-solana/bs58/base-x": ["base-x@4.0.1", "", {}, "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw=="], + + "@solana/codecs-data-structures/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@solana/codecs-strings/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@solana/codecs/@solana/codecs-core/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/codecs/@solana/codecs-numbers/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/options/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@solana/web3.js/bs58/base-x": ["base-x@3.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA=="], + + "borsh/bs58/base-x": ["base-x@3.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA=="], + + "@solana/codecs/@solana/codecs-core/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@solana/codecs/@solana/codecs-numbers/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + } +} diff --git a/scripts/v0.7/credible/allocation/credible.ts b/scripts/v0.7/credible/allocation/credible.ts new file mode 100644 index 00000000..7b16a18f --- /dev/null +++ b/scripts/v0.7/credible/allocation/credible.ts @@ -0,0 +1,343 @@ +/** + * credible — allocate a launch with a set of full-allocation wallets off the top, + * then the accumulator algorithm for everyone else. + * + * Flow: + * 1. Fetch the launch. + * 2. --execute: if the launch is still live + expired, confirm and closeLaunch + * FIRST — this freezes the funder set so a late fund can't invalidate the + * allocation. (Dry-run never closes.) + * 3. Fetch funding records (now frozen) + fund events. + * 4. Allocate: pre-allocated wallets get approved = committed; the remainder + * (TOTAL_ALLOCATION − Σ pre-allocated) is split among everyone else by the + * accelerated-cranker accumulator. Print the table. + * 5. Dry-run stops here. --execute confirms + sends the approvals, then verifies + * total_approved == TOTAL_ALLOCATION on-chain. Each on-chain step is confirmed + * at the terminal. + * + * credible sets the allocation and stops — it does NOT completeLaunch. The launch is + * left in "Closed" state; completeLaunch and the performance package are done + * manually afterward (by whoever holds the launch authority). + * + * Run: + * bun install + * bun credible.ts # dry run (default) + * bun credible.ts --execute # set the allocation (requires CREDIBLE_AUTHORITY_KEY in .env) + */ +import * as anchor from "@coral-xyz/anchor"; +import { LaunchpadClient } from "@metadaoproject/programs/launchpad/v0.7"; +import { + ComputeBudgetProgram, + Connection, + Keypair, + PublicKey, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { config as loadDotenv } from "dotenv"; + +import { + type AccumulatorFundingRecord, + type BoostConfig, +} from "./ac/accumulator.js"; +import { + approveFundingRecords, + fetchLaunchAtSlot, +} from "./ac/fundingApproval.js"; +import { + CLOCK_DRIFT_BUFFER_SECONDS, + PRIORITY_FEE_MICRO_LAMPORTS, +} from "./ac/constants.js"; +import { computeAllocation } from "./allocation.js"; +import { fetchFundEvents } from "./db.js"; +import { log } from "./logger.js"; +import { + confirmYes, + fmtUsdc, + getSysvarClockTime, + loadPreAllocations, + loadKeypair, + printAllocationTable, + usdc, + writeAllocationJson, +} from "./utils.js"; + +/** Audit file: the exact per-funder allocation this run computed. */ +const ALLOCATION_OUT_FILE = `${import.meta.dir}/allocation.out.json`; + +// Load the .env sitting beside this script (not the repo-root prod .env). +// override: the file is the source of truth — beat any stale inherited env +// (e.g. when spawned by test.ts after it rewrote .env). +loadDotenv({ path: `${import.meta.dir}/.env`, override: true }); + +/************************************************* + * *********************************************** + * If you see the stars, it means... + ***********************************************/ +/** The launch to allocate (base58). Live (expired), Closed, or Complete. */ +/****************************************************** */ +const LAUNCH_ADDRESS = ""; // credible Finance ***** TRIPLE CHECK ***** +const TOTAL_ALLOCATION = usdc(); //************** KOLLAN CHANGE ME ***********/ + +// ── Accumulator boost same as accelerated-cranker +const BOOST_MULTIPLIER = 10; +const BOOST_FILL_CEILING = 3; +const BOOST_LOOK_AHEAD_HOURS = 1; + +/** Pre-allocation CSV (columns: Address, Allocated), beside this script. */ +const PRE_ALLOCATED_CSV = `${import.meta.dir}/ico-pref.csv`; + +// ───────────────────────────────────────────────────────────────────────────── + +const logger = log.child({ module: "credible" }); + +/** Boost config, matching the cranker's prod defaults. */ +const boostConfig: BoostConfig = { + multiplier: BOOST_MULTIPLIER, + fillCeiling: BOOST_FILL_CEILING, + lookAheadSeconds: BOOST_LOOK_AHEAD_HOURS * 3600, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +/** Decoded launch account — extended with the v0.7 accumulator field Anchor can't infer. */ +type LaunchAccount = Awaited< + ReturnType +> & { + accumulatorActivationDelaySeconds: number; +}; + +/** A funding record fetched via `.all()` — extended with accumulator fields. */ +type OnChainFundingRecord = Awaited< + ReturnType +>[number] & { + account: { + committedAmountAccumulator: BN; + lastAccumulatorUpdate: BN; + }; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Read a required env var or throw a clear error. */ +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required (.env)`); + return value; +} + +/** Launch authority when executing; an ephemeral read-only wallet for dry-run. */ +function loadPayer(): anchor.Wallet { + if (!EXECUTE) return new anchor.Wallet(Keypair.generate()); // never signs anything in dry-run + return new anchor.Wallet(loadKeypair(requireEnv("CREDIBLE_AUTHORITY_KEY"))); +} + +/** The launch's on-chain state as a string ("initialized" | "live" | "closed" | "complete" | "refunding"). */ +function launchState(account: LaunchAccount): string { + return Object.keys(account.state as Record)[0] ?? "unknown"; +} + +/** Fetch (and re-decode) the launch account. */ +async function fetchLaunch(): Promise { + return (await launchpad.launchpad.account.launch.fetch( + launchAddr, + )) as LaunchAccount; +} + +/** + * Close a live, expired launch. Permissionless on-chain; the program enforces + * the time check. Yields Closed (min raise met) or Refunding (min not met). + * Throws if the launch hasn't expired yet. + */ +async function closeExpiredLaunch(account: LaunchAccount): Promise { + const now = await getSysvarClockTime(connection); + const closeTime = + account.unixTimestampStarted!.toNumber() + account.secondsForLaunch; + if (now < closeTime + CLOCK_DRIFT_BUFFER_SECONDS) { + throw new Error( + `Launch not yet expired (clock ${now} < close ${closeTime}) — timetravel surfpool past close first`, + ); + } + const tx = await launchpad + .closeLaunchIx({ launch: launchAddr }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: PRIORITY_FEE_MICRO_LAMPORTS, + }), + ]) + .rpc(); + logger.info({ tx }, "Closed launch"); +} + +/** Log a clean abort (user declined a confirmation) and stop. */ +function abort(): void { + logger.info({}, "Aborted — no further transactions sent."); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Runtime setup (module-level). All config is validated here, before any +// on-chain action, so a missing env var / bad wallet file fails immediately. +// ───────────────────────────────────────────────────────────────────────────── + +const EXECUTE = process.argv.includes("--execute"); + +if (TOTAL_ALLOCATION.isZero()) + throw new Error("Set TOTAL_ALLOCATION at the top of this file"); + +const RPC_URL = requireEnv("RPC_URL"); +const PG_URL = requireEnv("FUTARCHY_PG_URL"); +const launchAddr = new PublicKey(LAUNCH_ADDRESS); +const payer = loadPayer(); +const connection = new Connection(RPC_URL, "confirmed"); +const provider = new anchor.AnchorProvider(connection, payer, { + commitment: "confirmed", +}); +const launchpad = LaunchpadClient.createClient({ provider }); + +// Loaded up front so a malformed CSV fails before any on-chain action. +const preAllocated = loadPreAllocations(PRE_ALLOCATED_CSV); + +async function main(): Promise { + logger.info( + { + mode: EXECUTE ? "EXECUTE" : "DRY-RUN", + wallet: payer.publicKey.toBase58(), + rpc: RPC_URL, + launch: LAUNCH_ADDRESS, + }, + "credible starting", + ); + + // ── Fetch launch ── + let account = await fetchLaunch(); + if (!account.unixTimestampStarted) + throw new Error("Launch has no start timestamp"); + logger.info( + { + state: launchState(account), + minimumRaiseAmount: account.minimumRaiseAmount.toString(), + totalApprovedAmount: account.totalApprovedAmount.toString(), + totalAllocation: TOTAL_ALLOCATION.toString(), + }, + "Launch fetched", + ); + + // ── Fund events for the boost. Fetched BEFORE any on-chain write so a DB + // failure aborts before we ever close the launch. ── + const fundEvents = await fetchFundEvents(PG_URL, LAUNCH_ADDRESS); + + // ── Close FIRST (execute only) so the funder set is frozen before we allocate ── + // Allocating over a still-open launch is wrong: a late fund would invalidate it. + if (EXECUTE && launchState(account) === "live") { + if ( + !confirmYes( + "Launch is not closed. Close it now? (freezes the funder set)", + ) + ) + return abort(); + await closeExpiredLaunch(account); + account = await fetchLaunch(); + logger.info({ state: launchState(account) }, "State after close"); + } + + // ── Fetch funding records (frozen post-close in --execute) ── + const allRecords = + (await launchpad.launchpad.account.fundingRecord.all()) as OnChainFundingRecord[]; + const records = allRecords.filter((r) => r.account.launch.equals(launchAddr)); + if (records.length === 0) + throw new Error("No funding records found for this launch"); + logger.info({ records: records.length }, "Funding records fetched"); + + // ── Compute + print allocation ── + const funderRecords: AccumulatorFundingRecord[] = records.map((r) => ({ + funder: r.account.funder, + committedAmount: r.account.committedAmount, + committedAmountAccumulator: r.account.committedAmountAccumulator, + lastAccumulatorUpdate: r.account.lastAccumulatorUpdate, + })); + const result = computeAllocation( + funderRecords, + preAllocated, + TOTAL_ALLOCATION, + account.unixTimestampStarted!, // guaranteed set — checked after fetch, immutable across close + new BN(account.secondsForLaunch), + new BN(account.accumulatorActivationDelaySeconds), + boostConfig, + fundEvents, + ); + printAllocationTable(result, TOTAL_ALLOCATION, account.unixTimestampStarted!); + writeAllocationJson(ALLOCATION_OUT_FILE, result); + logger.info( + { file: "allocation.out.json", funders: result.lines.length }, + "Wrote allocation audit file", + ); + + if (!EXECUTE) { + logger.info( + {}, + "Dry-run complete — no transactions sent. Re-run with --execute to crank on-chain.", + ); + return; + } + + // ── EXECUTE: launch must be Closed to set approvals ── + const state = launchState(account); + if (state !== "closed") { + throw new Error( + `Launch is "${state}", not "closed" (min raise not met?) — cannot approve.`, + ); + } + + // ── Approve (the allocation). credible stops here: the launch is left Closed with + // the allocation set. completeLaunch + performance package are done manually. ── + if ( + !confirmYes( + `Approve ${result.approvals.length} funders for ${fmtUsdc(TOTAL_ALLOCATION)}?`, + ) + ) + return abort(); + logger.info( + { approvals: result.approvals.length }, + "Sending approval batches", + ); + const { maxConfirmedSlot } = await approveFundingRecords( + launchpad, + connection, + payer, + launchAddr, + result.approvals, + ); + + // Verify total_approved == TOTAL_ALLOCATION on-chain (read-your-writes safe): + // probe unpinned first, then re-read pinned to the max confirmed batch slot if + // it looks short (a lagging replica under-reports but never over-reports). + let refreshed = await fetchLaunchAtSlot(connection, launchpad, launchAddr, 0); + if (refreshed.totalApprovedAmount.lt(TOTAL_ALLOCATION)) { + refreshed = await fetchLaunchAtSlot( + connection, + launchpad, + launchAddr, + maxConfirmedSlot, + ); + } + if (!refreshed.totalApprovedAmount.eq(TOTAL_ALLOCATION)) { + throw new Error( + `total_approved_amount (${refreshed.totalApprovedAmount.toString()}) != TOTAL_ALLOCATION (${TOTAL_ALLOCATION.toString()}) — allocation did not land exactly`, + ); + } + logger.info( + { totalApproved: refreshed.totalApprovedAmount.toString() }, + "Allocation set — launch left Closed for manual completeLaunch + performance package", + ); +} + +main().catch((err) => { + logger.error( + { err: err instanceof Error ? err.message : String(err) }, + "credible failed", + ); + process.exit(1); +}); diff --git a/scripts/v0.7/credible/allocation/db.ts b/scripts/v0.7/credible/allocation/db.ts new file mode 100644 index 00000000..106f0de3 --- /dev/null +++ b/scripts/v0.7/credible/allocation/db.ts @@ -0,0 +1,61 @@ +/** + * Fund events for the boost calculation, read directly from the indexer Postgres. + * + * Standalone equivalent of futarchyDb.accelerated.findFundingEvents for a single + * v0.7 launch — a plain `pg` query so this project carries no workspace deps. + * Returns the per-transaction fund events used to expand multi-contribution + * funders into an accurate cumulative timeline. Optional: the boost falls back + * to chain-only data if this can't run. + */ +import { Client } from "pg"; +import BN from "bn.js"; + +import type { FundEvent } from "./ac/accumulator"; +import { log } from "./logger"; + +const logger = log.child({ module: "db" }); + +interface FundRow { + funderAddr: string; + quoteAmount: string; + timestamp: Date; +} + +/** + * Fetch all fund events for a v0.7 launch, oldest first. + * + * @param pgUrl — Postgres connection string (FUTARCHY_PG_URL) + * @param launchAddr — base58 launch address + * @returns fund events in the shape the accumulator boost expects + */ +export async function fetchFundEvents( + pgUrl: string, + launchAddr: string, +): Promise { + const client = new Client({ connectionString: pgUrl }); + await client.connect(); + try { + const result = await client.query( + `SELECT funder_addr AS "funderAddr", + quote_amount::text AS "quoteAmount", + timestamp + FROM v0_7_funds + WHERE launch_addr = $1 + ORDER BY timestamp ASC`, + [launchAddr], + ); + + logger.info( + { launchAddr, rows: result.rows.length }, + "Fetched fund events", + ); + + return result.rows.map((r) => ({ + funderAddr: r.funderAddr, + amount: new BN(r.quoteAmount), + timestamp: new BN(Math.floor(r.timestamp.getTime() / 1000)), + })); + } finally { + await client.end(); + } +} diff --git a/scripts/v0.7/credible/allocation/logger.ts b/scripts/v0.7/credible/allocation/logger.ts new file mode 100644 index 00000000..327b568e --- /dev/null +++ b/scripts/v0.7/credible/allocation/logger.ts @@ -0,0 +1,44 @@ +/** + * Minimal console logger with a pino-compatible surface. + * + * The accumulator + crank code was copied out of apps/accelerated-cranker, + * which logs via pino as `log.info(obj, msg)` / `log.child({ module })`. + * This shim keeps those call sites unchanged while printing readable lines + * to the console for a standalone one-off run. + */ +type Fields = Record; + +interface Logger { + info(objOrMsg: Fields | string, msg?: string): void; + warn(objOrMsg: Fields | string, msg?: string): void; + error(objOrMsg: Fields | string, msg?: string): void; + child(bindings: Fields): Logger; +} + +function emit( + level: "info" | "warn" | "error", + bindings: Fields, + objOrMsg: Fields | string, + msg?: string, +): void { + const message = typeof objOrMsg === "string" ? objOrMsg : (msg ?? ""); + const fields = + typeof objOrMsg === "string" ? bindings : { ...bindings, ...objOrMsg }; + const suffix = + Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : ""; + const line = `[${level}] ${message}${suffix}`; + if (level === "error") console.error(line); + else if (level === "warn") console.warn(line); + else console.log(line); +} + +function make(bindings: Fields): Logger { + return { + info: (objOrMsg, msg) => emit("info", bindings, objOrMsg, msg), + warn: (objOrMsg, msg) => emit("warn", bindings, objOrMsg, msg), + error: (objOrMsg, msg) => emit("error", bindings, objOrMsg, msg), + child: (childBindings) => make({ ...bindings, ...childBindings }), + }; +} + +export const log: Logger = make({}); diff --git a/scripts/v0.7/credible/allocation/package.json b/scripts/v0.7/credible/allocation/package.json new file mode 100644 index 00000000..a3704e5e --- /dev/null +++ b/scripts/v0.7/credible/allocation/package.json @@ -0,0 +1,32 @@ +{ + "name": "credible", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Standalone one-off: allocate a launch with a set of full-allocation wallets off the top, then the accumulator algorithm for the rest.", + "module": "credible.ts", + "scripts": { + "start": "bun credible.ts", + "execute": "bun credible.ts --execute", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@coral-xyz/anchor": "0.29.0", + "@metadaoproject/programs": "0.1.0-alpha.7", + "@solana/web3.js": "1.98.4", + "bn.js": "5.2.3", + "bs58": "6.0.0", + "dotenv": "16.6.1", + "pg": "8.18.0" + }, + "devDependencies": { + "@types/bn.js": "5.2.0", + "@types/bun": "1.3.14", + "@types/pg": "8.20.0", + "typescript": "5.9.3" + }, + "engines": { + "bun": ">=1.0.0" + } +} diff --git a/scripts/v0.7/credible/allocation/test.ts b/scripts/v0.7/credible/allocation/test.ts new file mode 100644 index 00000000..78cd68b0 --- /dev/null +++ b/scripts/v0.7/credible/allocation/test.ts @@ -0,0 +1,279 @@ +/** + * Local surfpool test harness for credible.ts. One command, one matched run: + * setup (override authority + airdrop + timetravel) → credible.ts --execute → verify. + * + * Prerequisites: + * 1. A FRESH local surfpool forking mainnet, e.g.: + * SURFPOOL_DATASOURCE_RPC_URL= surfpool start --no-tui --no-deploy -y + * 2. .env RPC_URL pointed at that local surfpool (http://127.0.0.1:8899). + * + * Run: bun test.ts + * + * Because the allocation table and the crank both come from credible.ts's single + * fetch, they cannot diverge. This harness only sets up the launch so the crank + * can run (credible's real authority isn't held) and verifies the on-chain result. + */ +import { readFileSync, writeFileSync } from "node:fs"; + +import * as anchor from "@coral-xyz/anchor"; +import { LaunchpadClient } from "@metadaoproject/programs/launchpad/v0.7"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import bs58 from "bs58"; +import { config as loadDotenv } from "dotenv"; + +import { loadPreAllocations, type AllocationRow } from "./utils"; + +const ENV_PATH = `${import.meta.dir}/.env`; +loadDotenv({ path: ENV_PATH }); + +const RPC = process.env.RPC_URL!; +const LAUNCH = new PublicKey(""); +const CLOSE_TIME = 1783184423; // unixTimestampStarted + secondsForLaunch +const TARGET_ATOMS = new BN(""); // TOTAL_ALLOCATION (1M USDC), must match credible.ts +const PRE_ALLOCATED = loadPreAllocations(`${import.meta.dir}/ico-pref.csv`); // base58 → fixed atoms + +/** Guard: cheatcodes must only ever hit a local surfpool, never staging/mainnet. */ +function assertLocal(): void { + if (!RPC.includes("127.0.0.1") && !RPC.includes("localhost")) { + throw new Error( + `RPC_URL is not local surfpool (${RPC}) — refusing to run cheatcodes`, + ); + } +} + +async function cheat(method: string, params: unknown[]): Promise { + const r = await fetch(RPC, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const j = (await r.json()) as { error?: { message: string }; result: T }; + if (j.error) throw new Error(`${method}: ${j.error.message}`); + return j.result; +} + +const conn = new Connection(RPC, "confirmed"); +const launchpad = LaunchpadClient.createClient({ + provider: new anchor.AnchorProvider( + conn, + new anchor.Wallet(Keypair.generate()), + { commitment: "confirmed" }, + ), +}); + +/** + * Setup: credible's real launch authority isn't held, so override it to a throwaway + * key (byte-patched in place — the SDK's encoder truncates trailing fields, so + * decode/encode can't be used), airdrop it, timetravel past close, write the key. + */ +async function setup(): Promise { + assertLocal(); + const kp = Keypair.generate(); + console.log("throwaway authority:", kp.publicKey.toBase58()); + + await cheat("surfnet_setAccount", [ + kp.publicKey.toBase58(), + { lamports: 100_000_000_000 }, + ]); + + const info = await conn.getAccountInfo(LAUNCH); + if (!info) throw new Error("launch not found (is surfpool forking mainnet?)"); + // Patch whatever the CURRENT authority is (idempotent — works on a fresh fork + // or after a prior override), rather than assuming the real mainnet authority. + const current = (await launchpad.launchpad.account.launch.fetch( + LAUNCH, + )) as unknown as { launchAuthority: PublicKey }; + const authBytes = current.launchAuthority.toBuffer(); + const buf = Buffer.from(info.data); + const occurrences: number[] = []; + let idx = buf.indexOf(authBytes, 0); + while (idx !== -1) { + occurrences.push(idx); + idx = buf.indexOf(authBytes, idx + 1); + } + if (occurrences.length !== 1) + throw new Error( + `expected 1 authority occurrence, got ${occurrences.length}`, + ); + kp.publicKey.toBuffer().copy(buf, occurrences[0]!); + await cheat("surfnet_setAccount", [ + LAUNCH.toBase58(), + { + data: buf.toString("hex"), + owner: info.owner.toBase58(), + lamports: info.lamports, + executable: false, + }, + ]); + + const after = (await launchpad.launchpad.account.launch.fetch( + LAUNCH, + )) as unknown as { launchAuthority: PublicKey }; + if (!after.launchAuthority.equals(kp.publicKey)) + throw new Error("authority override failed"); + console.log("authority overridden ✓"); + + await cheat("surfnet_timeTravel", [ + { absoluteTimestamp: (CLOSE_TIME + 300) * 1000 }, + ]); + const clock = await cheat<{ + value: { data: { parsed: { info: { unixTimestamp: number } } } }; + }>("getAccountInfo", [ + "SysvarC1ock11111111111111111111111111111111", + { encoding: "jsonParsed" }, + ]); + const t = clock.value.data.parsed.info.unixTimestamp; + if (t <= CLOSE_TIME) + throw new Error(`timetravel failed: clock ${t} <= close ${CLOSE_TIME}`); + console.log(`timetravel ✓ (clock ${t} > close ${CLOSE_TIME})`); + + const env = readFileSync(ENV_PATH, "utf8").replace( + /^CREDIBLE_AUTHORITY_KEY=.*$/m, + `CREDIBLE_AUTHORITY_KEY=${bs58.encode(kp.secretKey)}`, + ); + writeFileSync(ENV_PATH, env); +} + +/** Verify the on-chain allocation invariants after the crank. */ +async function verify(): Promise { + const launch = (await launchpad.launchpad.account.launch.fetch( + LAUNCH, + )) as unknown as { + state: Record; + totalApprovedAmount: BN; + }; + // credible leaves the launch Closed (completeLaunch is manual), with the full allocation set. + const state = Object.keys(launch.state)[0]; + const totalOk = launch.totalApprovedAmount.eq(TARGET_ATOMS); + console.log( + `\nstate: ${state} ${state === "closed" ? "✓" : "✗ (expected closed)"} | ` + + `launch.totalApprovedAmount: ${launch.totalApprovedAmount.toString()} ${totalOk ? "✓" : "✗"}`, + ); + if (state !== "closed") + throw new Error( + `expected launch to be "closed" after credible, got "${state}"`, + ); + if (!totalOk) + throw new Error(`totalApprovedAmount != ${TARGET_ATOMS.toString()}`); + + const all = await launchpad.launchpad.account.fundingRecord.all(); + const recs = all.filter((r) => + (r.account as unknown as { launch: PublicKey }).launch.equals(LAUNCH), + ); + let sum = new BN(0); + let overCap = 0; + let negatives = 0; + for (const r of recs) { + const a = r.account as unknown as { + approvedAmount: BN; + committedAmount: BN; + }; + sum = sum.add(a.approvedAmount); + if (a.approvedAmount.gt(a.committedAmount)) overCap++; + if (a.approvedAmount.isNeg()) negatives++; + } + console.log( + `records: ${recs.length} | Σ approved: ${sum.toString()} ${sum.eq(TARGET_ATOMS) ? "✓" : "✗"} | over-cap: ${overCap} | negatives: ${negatives}`, + ); + + // Each pre-allocated wallet's on-chain approved must equal its CSV amount (capped at committed). + let preMismatch = 0; + for (const [addr, csvAmount] of PRE_ALLOCATED) { + const r = recs.find( + (x) => + (x.account as unknown as { funder: PublicKey }).funder.toBase58() === + addr, + ); + if (!r) { + console.log(` ${addr} NOT FOUND ✗`); + preMismatch++; + continue; + } + const a = r.account as unknown as { + approvedAmount: BN; + committedAmount: BN; + }; + const expected = BN.min(csvAmount, a.committedAmount); + if (!a.approvedAmount.eq(expected)) preMismatch++; + } + console.log( + `pre-alloc: ${PRE_ALLOCATED.size} wallets | approved == CSV amount (capped): ${preMismatch === 0 ? "✓" : `✗ ${preMismatch} off`}`, + ); + if (preMismatch > 0) + throw new Error( + `${preMismatch} pre-allocated wallets did not get their CSV amount`, + ); + + // Full per-funder read-back: every on-chain approvedAmount must equal exactly + // what credible computed (allocation.out.json). Catches a funder↔amount swap that + // Σ + caps alone would miss. + const audit = JSON.parse( + readFileSync(`${import.meta.dir}/allocation.out.json`, "utf8"), + ) as AllocationRow[]; + const onChainApproved = new Map( + recs.map((r) => { + const a = r.account as unknown as { + funder: PublicKey; + approvedAmount: BN; + }; + return [a.funder.toBase58(), a.approvedAmount.toString()]; + }), + ); + let mismatches = 0; + let missingOnChain = 0; + for (const row of audit) { + const onChain = onChainApproved.get(row.funder); + if (onChain === undefined) missingOnChain++; + else if (onChain !== row.approved) mismatches++; + } + const auditFunders = new Set(audit.map((r) => r.funder)); + const extraOnChain = [...onChainApproved.keys()].filter( + (f) => !auditFunders.has(f), + ).length; + console.log( + `per-funder read-back: ${audit.length} audited | mismatches: ${mismatches} | missing on-chain: ${missingOnChain} | on-chain not in audit (approved 0): ${extraOnChain}`, + ); + if (mismatches > 0 || missingOnChain > 0) { + throw new Error( + `per-funder allocation mismatch: ${mismatches} differ, ${missingOnChain} missing on-chain`, + ); + } +} + +async function main(): Promise { + console.log("── setup ──"); + await setup(); + + // --setup-only: just prep surfpool (override authority, timetravel, write key) + // so you can then drive `bun credible.ts --execute` by hand and hit the y/n prompts. + if (process.argv.includes("--setup-only")) { + console.log( + "\nSetup complete. Now run: bun credible.ts --execute (or: bun credible.ts for a dry run)", + ); + return; + } + + console.log("\n── credible.ts --execute ──"); + // --execute prompts before each on-chain step (close, approve); feed "yes" + // for each so the harness runs unattended. Extra lines are harmless. + const proc = Bun.spawnSync(["bun", "credible.ts", "--execute"], { + cwd: import.meta.dir, + stdin: Buffer.from("yes\nyes\n"), + stdout: "inherit", + stderr: "inherit", + }); + if (proc.exitCode !== 0) + throw new Error(`credible.ts --execute exited ${proc.exitCode}`); + + console.log("\n── verify ──"); + await verify(); +} + +main().catch((err) => { + console.error( + "test failed:", + err instanceof Error ? err.message : String(err), + ); + process.exit(1); +}); diff --git a/scripts/v0.7/credible/allocation/test/allocation.test.ts b/scripts/v0.7/credible/allocation/test/allocation.test.ts new file mode 100644 index 00000000..6833343d --- /dev/null +++ b/scripts/v0.7/credible/allocation/test/allocation.test.ts @@ -0,0 +1,161 @@ +/** + * Unit tests for computeAllocation — the off-the-top composition (the only + * bespoke allocation logic; the accumulator itself is byte-identical to the + * accelerated-cranker and trusted). Pre-allocated wallets get a FIXED amount + * (capped at committed); the remainder goes to the accumulator pool. + * + * Run: bun test + */ +import { test, expect } from "bun:test"; +import { Keypair, type PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; + +import { computeAllocation } from "../allocation"; +import type { AccumulatorFundingRecord, BoostConfig } from "../ac/accumulator"; + +// Timing: start=1000, duration=1000 → close=2000; activation delay 0 → activation=1000. +const START = new BN(1000); +const DURATION = new BN(1000); +const ACTIVATION_DELAY = new BN(0); + +// multiplier 1 disables the boost, so the accumulator is plain time-weighted. +const NO_BOOST: BoostConfig = { + multiplier: 1, + fillCeiling: 3, + lookAheadSeconds: 0, +}; + +/** A funded record. `lastUpdate` = 1000 (= activation) so it earns the full window. */ +function rec(funder: PublicKey, committed: number): AccumulatorFundingRecord { + return { + funder, + committedAmount: new BN(committed), + committedAmountAccumulator: new BN(0), + lastAccumulatorUpdate: new BN(1000), + }; +} + +const sumApproved = (lines: { approvedAmount: BN }[]): BN => + lines.reduce((s, l) => s.add(l.approvedAmount), new BN(0)); + +const allocate = ( + records: AccumulatorFundingRecord[], + preAllocated: Map, + total: number, +) => + computeAllocation( + records, + preAllocated, + new BN(total), + START, + DURATION, + ACTIVATION_DELAY, + NO_BOOST, + [], + ); + +test("pre-allocated wallets get their FIXED amount; a regular funder absorbs the remainder", () => { + const pre = Keypair.generate().publicKey; + const reg = Keypair.generate().publicKey; + const records = [rec(pre, 500), rec(reg, 1000)]; + + const result = allocate( + records, + new Map([[pre.toBase58(), new BN(100)]]), + 300, + ); + + expect(result.preAllocatedTotal.toString()).toBe("100"); + expect(result.remaining.toString()).toBe("200"); + const preLine = result.lines.find((l) => l.funder.equals(pre))!; + expect(preLine.kind).toBe("pre"); + expect(preLine.approvedAmount.toString()).toBe("100"); // fixed amount, NOT committed (500) + const regLine = result.lines.find((l) => l.funder.equals(reg))!; + expect(regLine.kind).toBe("accum"); + expect(regLine.approvedAmount.toString()).toBe("200"); // the remainder + expect(sumApproved(result.lines).toString()).toBe("300"); +}); + +test("remaining == 0: pre-alloc consume the whole total, every regular funder gets 0", () => { + const pre = Keypair.generate().publicKey; + const reg1 = Keypair.generate().publicKey; + const reg2 = Keypair.generate().publicKey; + const records = [rec(pre, 500), rec(reg1, 1000), rec(reg2, 500)]; + + const result = allocate( + records, + new Map([[pre.toBase58(), new BN(300)]]), + 300, + ); + + expect(result.remaining.toString()).toBe("0"); + expect( + result.lines.find((l) => l.funder.equals(reg1))!.approvedAmount.toString(), + ).toBe("0"); + expect( + result.lines.find((l) => l.funder.equals(reg2))!.approvedAmount.toString(), + ).toBe("0"); + expect(sumApproved(result.lines).toString()).toBe("300"); +}); + +test("pre-allocated total exceeds the total → throws", () => { + const pre = Keypair.generate().publicKey; + const reg = Keypair.generate().publicKey; + const records = [rec(pre, 500), rec(reg, 1000)]; + expect(() => + allocate(records, new Map([[pre.toBase58(), new BN(400)]]), 300), + ).toThrow(/> totalAllocation/); +}); + +test("a pre-allocated amount above that wallet's committed → throws", () => { + const pre = Keypair.generate().publicKey; + const reg = Keypair.generate().publicKey; + const records = [rec(pre, 100), rec(reg, 1000)]; + expect(() => + allocate(records, new Map([[pre.toBase58(), new BN(200)]]), 500), + ).toThrow(/exceeds its committed/); +}); + +test("a pre-allocated wallet that never funded → throws", () => { + const phantom = Keypair.generate().publicKey; + const reg = Keypair.generate().publicKey; + const records = [rec(reg, 1000)]; + expect(() => + allocate(records, new Map([[phantom.toBase58(), new BN(100)]]), 300), + ).toThrow(/no funding record/); +}); + +test("invariants on a mixed set: Σ == total, every approved ≤ committed, none negative, pre exact", () => { + const pre = Keypair.generate().publicKey; + const regs = [ + Keypair.generate().publicKey, + Keypair.generate().publicKey, + Keypair.generate().publicKey, + ]; + const records = [ + rec(pre, 500), + rec(regs[0]!, 1000), + rec(regs[1]!, 1000), + rec(regs[2]!, 1000), + ]; + + const result = allocate( + records, + new Map([[pre.toBase58(), new BN(300)]]), + 800, + ); + + expect(sumApproved(result.lines).toString()).toBe("800"); + const committedByFunder = new Map( + records.map((r) => [r.funder.toBase58(), r.committedAmount]), + ); + for (const l of result.lines) { + expect(l.approvedAmount.isNeg()).toBe(false); + expect( + l.approvedAmount.lte(committedByFunder.get(l.funder.toBase58())!), + ).toBe(true); + } + expect( + result.lines.find((l) => l.funder.equals(pre))!.approvedAmount.toString(), + ).toBe("300"); // exact fixed amount +}); diff --git a/scripts/v0.7/credible/allocation/tsconfig.json b/scripts/v0.7/credible/allocation/tsconfig.json new file mode 100644 index 00000000..40a86dfa --- /dev/null +++ b/scripts/v0.7/credible/allocation/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["*.ts", "ac/*.ts", "test/*.ts"] +} diff --git a/scripts/v0.7/credible/allocation/utils.ts b/scripts/v0.7/credible/allocation/utils.ts new file mode 100644 index 00000000..69483a84 --- /dev/null +++ b/scripts/v0.7/credible/allocation/utils.ts @@ -0,0 +1,197 @@ +/** + * Small helpers for credible.ts — USDC formatting, key/clock/wallet-file loading, + * and the allocation table printer. Pulled out to keep credible.ts focused on the + * allocation logic and crank flow. + */ +import { readFileSync, writeFileSync } from "node:fs"; + +import { + type Connection, + Keypair, + PublicKey, + SYSVAR_CLOCK_PUBKEY, +} from "@solana/web3.js"; +import BN from "bn.js"; +import bs58 from "bs58"; + +import type { AllocationResult } from "./allocation"; + +/** USDC has 6 decimals; committed/approved amounts are in atoms. */ +export const USDC_DECIMALS = 6; + +/** Whole USDC → atoms. */ +export const usdc = (whole: number): BN => + new BN(whole).mul(new BN(10).pow(new BN(USDC_DECIMALS))); + +/** Format USDC atoms as a human dollar string. */ +export function fmtUsdc(atoms: BN): string { + const divisor = new BN(10).pow(new BN(USDC_DECIMALS)); + const whole = atoms.div(divisor).toString(); + const frac = atoms + .mod(divisor) + .toString() + .padStart(USDC_DECIMALS, "0") + .slice(0, 2); + return `$${Number(whole).toLocaleString("en-US")}.${frac}`; +} + +/** An allocation row in the audit JSON (all amounts as atom strings). */ +export interface AllocationRow { + funder: string; + kind: "pre" | "accum"; + committed: string; + approved: string; +} + +/** Write the allocation to a JSON audit file — a record of exactly what was allocated. */ +export function writeAllocationJson( + filePath: string, + result: AllocationResult, +): void { + const rows: AllocationRow[] = result.lines.map((l) => ({ + funder: l.funder.toBase58(), + kind: l.kind, + committed: l.committedAmount.toString(), + approved: l.approvedAmount.toString(), + })); + writeFileSync(filePath, JSON.stringify(rows, null, 2)); +} + +/** Blocking terminal confirmation. Returns true only on an explicit yes/y. */ +export function confirmYes(message: string): boolean { + const answer = prompt(`${message} [yes/no]`); + const normalized = answer?.trim().toLowerCase(); + return normalized === "yes" || normalized === "y"; +} + +/** + * Parse a private key. Supports a JSON byte array ([1,2,...], 64 bytes) or a + * base58 string. Mirrors the accelerated-cranker loader. + */ +export function loadKeypair(raw: string): Keypair { + const trimmed = raw.trim(); + if (trimmed.startsWith("[")) { + const bytes = JSON.parse(trimmed) as number[]; + if (!Array.isArray(bytes) || bytes.length !== 64) { + throw new Error( + "CREDIBLE_AUTHORITY_KEY JSON array must contain exactly 64 bytes", + ); + } + return Keypair.fromSecretKey(new Uint8Array(bytes)); + } + return Keypair.fromSecretKey(bs58.decode(trimmed)); +} + +/** + * Read the on-chain unix time from the Clock sysvar — the SAME source the + * program uses to enforce launch expiry (and what surfpool's timeTravel moves). + * Copied from ChainLaunchWatcher.getSysvarClockTime. + */ +export async function getSysvarClockTime( + connection: Connection, +): Promise { + const info = await connection.getAccountInfo(SYSVAR_CLOCK_PUBKEY); + if (!info || !info.data || info.data.length < 40) { + throw new Error("Failed to read Clock sysvar"); + } + // Clock sysvar: unix_timestamp is i64 at offset 32. + return Number(info.data.readBigInt64LE(32)); +} + +/** + * Load pre-allocations from a CSV with a header row and columns `Address,Allocated` + * (e.g. `GaDZ…,"$375,000 "`). Returns a map of base58 wallet → allocation in USDC + * atoms. Each wallet gets this fixed amount off the top (capped at its committed). + * Throws on a malformed address or a non-positive amount. + */ +export function loadPreAllocations(filePath: string): Map { + const rows = readFileSync(filePath, "utf8").trim().split("\n").slice(1); // skip header + const out = new Map(); + for (const row of rows) { + if (!row.trim()) continue; + const comma = row.indexOf(","); + const address = row.slice(0, comma).trim(); + const dollars = parseInt(row.slice(comma + 1).replace(/[^0-9]/g, ""), 10); + try { + new PublicKey(address); + } catch { + throw new Error(`Invalid base58 address in ${filePath}: ${address}`); + } + if (!Number.isFinite(dollars) || dollars <= 0) { + throw new Error( + `Invalid allocation amount for ${address} in ${filePath}`, + ); + } + if (out.has(address)) + throw new Error(`Duplicate address in ${filePath}: ${address}`); + out.set( + address, + new BN(dollars).mul(new BN(10).pow(new BN(USDC_DECIMALS))), + ); + } + if (out.size === 0) + throw new Error(`No pre-allocations found in ${filePath}`); + return out; +} + +/** + * Print the allocation as a readable table, then assert Σ approved == target. + * Throws on mismatch so a dry-run surfaces a broken allocation before any send. + * + * Sorted by fund time (earliest first) so the time-weighting is visible going + * down the list. `hrs` = hours from launch start to the funder's LAST fund() + * call (the only fund timestamp the on-chain record stores). + */ +export function printAllocationTable( + result: AllocationResult, + totalAllocation: BN, + launchStartTime: BN, +): void { + // Pre-allocated ("pre") always on top; the rest time-sorted (earliest fund first). + const sorted = [...result.lines].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "pre" ? -1 : 1; + return a.lastAccumulatorUpdate.cmp(b.lastAccumulatorUpdate); + }); + + // Hours from launch start to a fund timestamp. + const hrsOf = (t: BN): string => + (t.sub(launchStartTime).toNumber() / 3600).toFixed(1); + + console.log( + "\n kind funder committed approved % commit hrs+start (first→last)", + ); + console.log(" ".padEnd(132, "─")); + let approvedTotal = new BN(0); + let committedTotal = new BN(0); + for (const l of sorted) { + approvedTotal = approvedTotal.add(l.approvedAmount); + committedTotal = committedTotal.add(l.committedAmount); + const pct = l.committedAmount.isZero() + ? "—" + : `${((l.approvedAmount.toNumber() / l.committedAmount.toNumber()) * 100).toFixed(1)}%`; + // Show first→last only for funders who topped up (first != last); otherwise the single hour. + const hrs = l.lastAccumulatorUpdate.isZero() + ? "—" + : l.firstFundTime.eq(l.lastAccumulatorUpdate) + ? hrsOf(l.lastAccumulatorUpdate) + : `${hrsOf(l.firstFundTime)}→${hrsOf(l.lastAccumulatorUpdate)}`; + console.log( + ` ${l.kind.padEnd(6)} ${l.funder.toBase58().padEnd(44)} ${fmtUsdc(l.committedAmount).padStart(15)} ${fmtUsdc(l.approvedAmount).padStart(15)} ${pct.padStart(8)} ${hrs.padEnd(14)}`, + ); + } + console.log(" ".padEnd(132, "─")); + console.log( + ` Funders: ${result.lines.length} (${result.lines.filter((l) => l.kind === "pre").length} pre-allocated) | ` + + `Committed: ${fmtUsdc(committedTotal)} | Pre-allocated off-top: ${fmtUsdc(result.preAllocatedTotal)} | ` + + `Accumulator remainder: ${fmtUsdc(result.remaining)}`, + ); + console.log( + ` Σ approved: ${fmtUsdc(approvedTotal)} (target ${fmtUsdc(totalAllocation)}) ${approvedTotal.eq(totalAllocation) ? "✓ matches" : "✗ MISMATCH"}\n`, + ); + + if (!approvedTotal.eq(totalAllocation)) { + throw new Error( + `Allocation invariant failed: Σ approved (${approvedTotal.toString()}) !== TOTAL_ALLOCATION (${totalAllocation.toString()})`, + ); + } +} diff --git a/scripts/v0.7/credible/claimAllLaunchCredible.ts b/scripts/v0.7/credible/claimAllLaunchCredible.ts index e69de29b..d8ddba71 100644 --- a/scripts/v0.7/credible/claimAllLaunchCredible.ts +++ b/scripts/v0.7/credible/claimAllLaunchCredible.ts @@ -0,0 +1,270 @@ +/// Claims tokens for every funder of the completed credible launch, and +/// refunds any unapproved USDC (committed minus approved) along the way. +/// Claims and refunds are both permissionless; the local wallet just pays fees +/// and ATA rent. +/// +/// Transactions are fired without per-batch confirmation (a compute-unit price +/// helps them land under congestion). Correctness comes from verification +/// passes over the funding records themselves: each pass re-fetches the +/// records and re-sends only what hasn't landed, which is safe because claims +/// and refunds are idempotent via the records' flags. + +import * as anchor from "@coral-xyz/anchor"; +import { + LaunchpadClient, + getLaunchAddr, +} from "@metadaoproject/programs/launchpad/v0.7"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; +import { + TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import BN from "bn.js"; +import { LAUNCH_AUTHORITY, TOKEN_SEED } from "./constants.js"; + +// Batches are homogeneous — claims-only or refunds-only — so 5 records fit the +// 1232-byte transaction limit (a mixed claim+refund batch only fits 3), and a +// failing refund can never revert co-batched claims. +const BATCH_SIZE = 5; + +// Matches the accelerated-cranker default; raise via env under congestion. +const PRIORITY_FEE_MICRO_LAMPORTS = parseInt( + process.env.PRIORITY_FEE_MICRO_LAMPORTS ?? "10000", + 10, +); + +// Fire passes until nothing is pending; each pass re-sends only what hasn't +// landed. Two passes suffice when everything lands first try. +const MAX_PASSES = 3; + +// Grace for fired transactions to land before the next verification pass. +const LAND_WAIT_MS = 15_000; + +// Static compute budgets per instruction, measured on a surfpool fork of the +// real launch (claim ~35k, refund ~31k, ATA create ~25k / ~5k when it already +// exists) and rounded up. Over-requesting only inflates the priority fee, +// which is priced per requested CU. +const CLAIM_CU = 45_000; +const REFUND_CU = 40_000; +const ATA_CREATE_CU = 30_000; + +const provider = anchor.AnchorProvider.env(); +const payer = ( + provider.wallet as anchor.Wallet & { payer: anchor.web3.Keypair } +).payer; + +const launchpad: LaunchpadClient = LaunchpadClient.createClient({ provider }); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Sign and fire one batch without waiting for confirmation — the next pass +// verifies against the records. Preflight stays on so a deterministic failure +// (e.g. a bad account) rejects at send time with logs instead of surfacing +// only as a still-pending record after the passes. +const sendBatch = async ( + batchIxs: TransactionInstruction[], + computeUnits: number, + label: string, +) => { + const { blockhash } = await provider.connection.getLatestBlockhash(); + + const tx = new Transaction(); + tx.add( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: PRIORITY_FEE_MICRO_LAMPORTS, + }), + ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }), + ...batchIxs, + ); + tx.recentBlockhash = blockhash; + tx.feePayer = payer.publicKey; + tx.partialSign(payer); + + const signature = await provider.connection.sendRawTransaction( + tx.serialize(), + ); + console.log(`${label}: ${signature}`); +}; + +/** + * One pass: fetch the records, work out what still needs a claim or refund, + * and fire the batches for it without waiting for confirmations. Returns the + * number of records that still needed work (0 means everything has landed). + * With countOnly, just reports that number without sending. + */ +const processPass = async ( + launch: PublicKey, + pass: number, + countOnly = false, +): Promise => { + const launchAccount = await launchpad.fetchLaunch(launch); + if (launchAccount === null) { + throw new Error("Launch account not found"); + } + + const state = Object.keys(launchAccount.state)[0]; + if (state !== "complete") { + throw new Error( + `Launch state is "${state}", claims require Complete (run complete.ts first)`, + ); + } + + const allFundingRecords = + await launchpad.launchpad.account.fundingRecord.all(); + const launchFundingRecords = allFundingRecords.filter((record) => + record.account.launch.equals(launch), + ); + console.log( + `Pass ${pass}: found ${launchFundingRecords.length} funding records`, + ); + + // On a fork, records cloned after the launch/vault froze can carry committed + // amounts the quote vault never received; skip refunds when the vault can't + // cover them (on a real cluster the vault always can). + const totalRefundable = launchFundingRecords.reduce( + (acc, record) => + record.account.isUsdcRefunded + ? acc + : acc.add( + record.account.committedAmount.sub(record.account.approvedAmount), + ), + new BN(0), + ); + const quoteVaultBalance = new BN( + ( + await provider.connection.getTokenAccountBalance( + launchAccount.launchQuoteVault, + ) + ).value.amount, + ); + const skipRefunds = totalRefundable.gt(quoteVaultBalance); + if (skipRefunds) { + console.log( + `Skipping refunds: refundable ${totalRefundable.toString()} exceeds quote vault balance ${quoteVaultBalance.toString()} (fork drift)`, + ); + } + + const needsRefund = (record: (typeof launchFundingRecords)[number]) => + !skipRefunds && + !record.account.isUsdcRefunded && + record.account.committedAmount.sub(record.account.approvedAmount).gtn(0); + + const claimRecords = launchFundingRecords.filter( + (record) => !record.account.isTokensClaimed, + ); + const refundRecords = launchFundingRecords.filter(needsRefund); + + const pendingRecords = launchFundingRecords.filter( + (record) => !record.account.isTokensClaimed || needsRefund(record), + ).length; + if (pendingRecords === 0 || countOnly) { + return pendingRecords; + } + + let batches = 0; + + // Phase 1: claims (claimIx prepends the funder's token ATA creation itself) + for (let i = 0; i < claimRecords.length; i += BATCH_SIZE) { + const batch = claimRecords.slice(i, i + BATCH_SIZE); + + const batchIxs: TransactionInstruction[] = []; + for (const record of batch) { + batchIxs.push( + ...( + await launchpad + .claimIx(launch, launchAccount.baseMint, record.account.funder) + .transaction() + ).instructions, + ); + } + + await sendBatch( + batchIxs, + batch.length * (ATA_CREATE_CU + CLAIM_CU), + `Pass ${pass} claim batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(claimRecords.length / BATCH_SIZE)}`, + ); + batches++; + } + + // Phase 2: refunds. The refund requires the funder's USDC ATA to exist; + // unlike claimIx, the SDK's refundIx does not create it (funders may have + // funded from a non-ATA account or closed their ATA since) + for (let i = 0; i < refundRecords.length; i += BATCH_SIZE) { + const batch = refundRecords.slice(i, i + BATCH_SIZE); + + const batchIxs: TransactionInstruction[] = []; + for (const record of batch) { + batchIxs.push( + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + getAssociatedTokenAddressSync( + launchAccount.quoteMint, + record.account.funder, + true, + ), + record.account.funder, + launchAccount.quoteMint, + ), + ...( + await launchpad + .refundIx({ + launch, + funder: record.account.funder, + quoteMint: launchAccount.quoteMint, + }) + .transaction() + ).instructions, + ); + } + + await sendBatch( + batchIxs, + batch.length * (ATA_CREATE_CU + REFUND_CU), + `Pass ${pass} refund batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(refundRecords.length / BATCH_SIZE)}`, + ); + batches++; + } + + console.log( + `Pass ${pass}: fired ${batches} transactions (${claimRecords.length} claims, ${refundRecords.length} refunds)`, + ); + return pendingRecords; +}; + +const main = async () => { + const tokenMint = await PublicKey.createWithSeed( + LAUNCH_AUTHORITY, + TOKEN_SEED, + TOKEN_PROGRAM_ID, + ); + const [launch] = getLaunchAddr(undefined, tokenMint); + console.log("Launch address:", launch.toBase58()); + + for (let pass = 1; pass <= MAX_PASSES; pass++) { + const pendingRecords = await processPass(launch, pass); + if (pendingRecords === 0) { + console.log("All records claimed and refunded!"); + return; + } + await sleep(LAND_WAIT_MS); + } + + const stillPending = await processPass(launch, MAX_PASSES + 1, true); + if (stillPending > 0) { + throw new Error( + `${stillPending} records still pending after ${MAX_PASSES} passes — rerun the script (safe: processed records are skipped)`, + ); + } + console.log("All records claimed and refunded!"); +}; + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/scripts/v0.7/credible/complete.ts b/scripts/v0.7/credible/complete.ts index c1a65aea..50b7a137 100644 --- a/scripts/v0.7/credible/complete.ts +++ b/scripts/v0.7/credible/complete.ts @@ -13,7 +13,9 @@ import { token } from "@coral-xyz/anchor/dist/cjs/utils/index.js"; import { TOKEN_SEED } from "./constants.js"; const provider = anchor.AnchorProvider.env(); -const payer = provider.wallet["payer"]; +const payer = ( + provider.wallet as anchor.Wallet & { payer: anchor.web3.Keypair } +).payer; const launchpad: LaunchpadClient = LaunchpadClient.createClient({ provider }); @@ -37,6 +39,10 @@ export const completeLaunch = async () => { let launchAccount = await launchpad.fetchLaunch(launch); + if (launchAccount === null) { + throw new Error("Launch account not found"); + } + const tx = await launchpad .completeLaunchIx({ launch: launch, @@ -75,6 +81,10 @@ export const completeLaunch = async () => { // Refresh launch account to get the updated base mint launchAccount = await launchpad.fetchLaunch(launch); + if (launchAccount === null) { + throw new Error("Launch account not found"); + } + // TODO: Review this as we will want to do this manually.. const initializePerformancePackageTxHash = await launchpad .initializePerformancePackageIx({ diff --git a/scripts/v0.7/credible/constants.ts b/scripts/v0.7/credible/constants.ts index 325e116f..62f7ef98 100644 --- a/scripts/v0.7/credible/constants.ts +++ b/scripts/v0.7/credible/constants.ts @@ -3,6 +3,10 @@ import { PublicKey } from "@solana/web3.js"; // Token Details export const TOKEN_SEED = "MFKO9GdJSBjq8CWO"; +export const LAUNCH_AUTHORITY = new PublicKey( + "LncCxu8MbQJpkdK522x22YWmpFTHShPHFVUbQb9BwuV", +); + // Team Config Details export const TEAM_ADDRESS = new PublicKey( "44dNkVJsWPZfh3tvRyqpnwgkoL5RYqi3cWsE1d8wfviV", diff --git a/tsconfig.json b/tsconfig.json index f7ad19bd..8209cade 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,9 @@ "./node_modules/@solana/spl-token" ] } - } + }, + "exclude": [ + "node_modules", + "scripts/v0.7/credible/allocation" + ] } \ No newline at end of file