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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions scripts/assets/CARS/CARS.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "Rip Cars",
"symbol": "CARS",
"description": "The world’s first Hot Wheels gacha platform",
"image": "https://raw.githubusercontent.com/metaDAOproject/futarchy/refs/heads/develop/scripts/assets/CARS/CARS.png"
}
Binary file added scripts/assets/CARS/CARS.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions scripts/v0.7/rip-cars/allocation/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Solana RPC. Staging surfpool, or http://127.0.0.1:8899 for a local surfpool.
RPC_URL=

# Indexer Postgres (read replica) for fund events + ownership scores. Read-only.
FUTARCHY_PG_URL=

# Launch authority key (base58 or JSON byte array). Only needed for --execute.
RIPCARS_AUTHORITY_KEY=

# Nash congestion game (defaults match nash_equilibrium_sim.html):
OWNERSHIP_SPLIT=0.5
NASH_EPSILON=1
NASH_REACT=0.40
NASH_START=rand
NASH_SEED=20260723
SCORE_COLUMN=ownership_points
BOOST_MULTIPLIER=10
BOOST_FILL_CEILING=3
BOOST_LOOK_AHEAD_HOURS=1
PRIORITY_FEE_MICRO_LAMPORTS=10000
3 changes: 3 additions & 0 deletions scripts/v0.7/rip-cars/allocation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.env
allocation.out.json
71 changes: 71 additions & 0 deletions scripts/v0.7/rip-cars/allocation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Rip Cars allocation

Standalone one-off (same CLI shape as laso / credible). Allocates the Rip Cars
launch via a **congestion-game Nash equilibrium** (ownership road vs accumulator
road — same model as `nash_equilibrium_sim.ts` / `nash_equilibrium_live.ts`),
then optionally approves every funding record on-chain.

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 and verifies the total.

Stops after setting the allocation — does **not** `completeLaunch`. The launch
is left `Closed`; complete it manually afterward.

## Layout

- `ripcars.ts` — entry point: config, close → allocate → approve → verify
- `allocation.ts` — pure Nash solver + accumulator weights
- `ac/fundingApproval.ts` — batched `setFundingRecordApproval` + launch reads
- `db.ts` — fund events + ownership scores (read-only Postgres)
- `utils.ts` — USDC formatting, table printer, `allocation.out.json`, prompts

## Setup

```bash
bun install
cp .env.example .env # then fill it in
```

`.env`:

- `RPC_URL` — Solana RPC
- `FUTARCHY_PG_URL` — indexer Postgres (read-only; fund events + scores)
- `RIPCARS_AUTHORITY_KEY` — launch authority (base58 or JSON byte array). Only for `--execute`.

Optional Nash knobs (HTML-sim defaults):

| Env | Default | Meaning |
|-----|---------|---------|
| `OWNERSHIP_SPLIT` | `0.5` | Fraction of pool on the ownership road |
| `NASH_EPSILON` | `1` | Min $ gain before an agent switches |
| `NASH_REACT` | `0.40` | Flip probability for unhappy agents each round |
| `NASH_START` | `rand` | Initial roads: `rand` \| `acc` \| `own` |
| `NASH_SEED` | `20260723` | RNG seed (start + stochastic flips) |
| `SCORE_COLUMN` | `ownership_points` | Score column for the ownership road |

Config at the top of `ripcars.ts` pulls `LAUNCH_ADDRESS` / pool size from
`../constants.ts` — triple-check before `--execute`.

## Run

```bash
bun ripcars.ts # dry run — prints CLI table + writes allocation.out.json
bun ripcars.ts --execute # approve on-chain (requires RIPCARS_AUTHORITY_KEY)
```

Each run writes `allocation.out.json` — same core shape as credible/laso
(`funder`, `kind`, `committed`, `approved` atoms). `kind` is the Nash road:
`ownership` | `accumulator`.

## How the allocation works

1. **Accumulator weights** — run the accelerated-cranker over the full pool;
each funder's approved amount becomes its weight on the accumulator road.
2. **Two roads** — ownership road water-fills `OWNERSHIP_SPLIT` of the pool by
score; accumulator road splits the rest by weight. Both capped at committed.
3. **Best response** — same as the HTML "Solve to Nash": unhappy agents flip
simultaneously with probability `NASH_REACT` each round until ε-Nash.
4. **Fill** — unused budget from commit-caps is redistributed by headroom so
`Σ approved === TOTAL_ALLOCATION`, then converted to atoms.
5. `--execute` batches `setFundingRecordApproval` for those amounts.
26 changes: 26 additions & 0 deletions scripts/v0.7/rip-cars/allocation/ac/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** 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;

/**
* Buffer (in seconds) added to time-based checks before sending
* on-chain instructions. Accounts for clock drift.
*/
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.
*/
export const APPROVAL_BATCH_SIZE = 10;

/**
* Priority fee in microlamports per compute unit.
* Configurable via PRIORITY_FEE_MICRO_LAMPORTS env var.
*/
export const PRIORITY_FEE_MICRO_LAMPORTS = parseInt(
process.env.PRIORITY_FEE_MICRO_LAMPORTS ?? "10000",
10,
);
218 changes: 218 additions & 0 deletions scripts/v0.7/rip-cars/allocation/ac/fundingApproval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* 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.
*
* Copied from laso/allocation (accelerated-cranker). Re-approval is safe —
* the Rust program uses delta accounting on total_approved_amount.
*/
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" });

const BATCH_SEND_RETRIES = 3;
const INTER_BATCH_DELAY_MS = 200;

/** A single funder's approved allocation. */
export interface FundingApproval {
funder: PublicKey;
/** Amount approved in token atoms (USDC lamports). Always <= committedAmount. */
approvedAmount: BN;
}

/**
* Send setFundingRecordApproval instructions in batches.
*
* @returns the maximum slot any batch confirmed at — pin post-approval
* verification reads with `minContextSlot`. 0 when there are no approvals.
*/
export async function approveFundingRecords(
launchpad: LaunchpadClient,
connection: Connection,
payer: anchor.Wallet,
launchAddr: PublicKey,
approvals: FundingApproval[],
): Promise<{ maxConfirmedSlot: number }> {
const addr = launchAddr.toBase58();
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();
tx.add(
ComputeBudgetProgram.setComputeUnitLimit({
units: batch.length * 16_000,
}),
ComputeBudgetProgram.setComputeUnitPrice({
microLamports: PRIORITY_FEE_MICRO_LAMPORTS,
}),
);

for (const approval of batch) {
const ix = await launchpad
.setFundingRecordApprovalIx({
launch: launchAddr,
funder: approval.funder,
approvedAmount: approval.approvedAmount,
})
.instruction();
tx.add(ix);
}

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",
);

if (i + APPROVAL_BATCH_SIZE < approvals.length) {
await new Promise((r) => setTimeout(r, INTER_BATCH_DELAY_MS));
}
}

return { maxConfirmedSlot };
}

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++) {
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash();
tx.recentBlockhash = blockhash;
tx.feePayer = payer.publicKey;
tx.signatures = [];
tx.sign(payer.payer);

const txHash = await connection.sendRawTransaction(tx.serialize(), {
skipPreflight: true,
});

try {
const confirmation = await connection.confirmTransaction(
{ signature: txHash, blockhash, lastValidBlockHeight },
"confirmed",
);
if (confirmation.value.err) {
throw new Error(
`Approval batch ${batchNum}/${totalBatches} failed on-chain: ${JSON.stringify(confirmation.value.err)} (tx: ${txHash})`,
);
}
return { txHash, confirmedSlot: confirmation.context.slot };
} catch (err) {
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");
}

const SLOT_PINNED_READ_ATTEMPTS = 5;

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.
*/
export async function fetchLaunchAtSlot(
connection: Connection,
launchpad: LaunchpadClient,
launchAddr: PublicKey,
minContextSlot: number,
opts?: { maxAttempts?: number; sleepMs?: (attempt: number) => number },
): Promise<Launch> {
const maxAttempts = opts?.maxAttempts ?? SLOT_PINNED_READ_ATTEMPTS;
const sleepMs = opts?.sleepMs ?? ((attempt: number) => 1000 * attempt);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const info = await connection.getAccountInfo(launchAddr, {
commitment: "confirmed",
...(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");
}
25 changes: 25 additions & 0 deletions scripts/v0.7/rip-cars/allocation/allocate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @deprecated Use `bun ripcars.ts` instead.
*
* The Rip Cars allocation CLI (Nash congestion game → allocation.out.json →
* --execute setFundingRecordApproval) lives in this folder.
*
* bun install
* bun ripcars.ts # dry-run: CLI table + allocation.out.json
* bun ripcars.ts --execute # approve funding records on-chain
*
* Interactive explorer: `bun nash_equilibrium_live.ts`
* See README.md.
*/
console.error(
[
"allocate.ts has been replaced by the Rip Cars Nash allocation CLI.",
"",
" bun install",
" bun ripcars.ts # dry-run: CLI table + allocation.out.json",
" bun ripcars.ts --execute # approve funding records on-chain",
"",
"See scripts/v0.7/rip-cars/allocation/README.md",
].join("\n"),
);
process.exit(1);
Loading