// mint-and-redeem.ts
import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
import { AnchorProvider, BN, Program, Wallet, type Idl } from "@coral-xyz/anchor";
import {
TOKEN_PROGRAM_ID,
getAssociatedTokenAddressSync,
createAssociatedTokenAccountIdempotentInstruction,
} from "@solana/spl-token";
import fs from "fs";
import os from "os";
import path from "path";
const RPC_URL = process.env.RPC_URL ?? "https://api.devnet.solana.com";
const WALLET_PATH =
process.env.WALLET_PATH ?? path.join(os.homedir(), ".config/solana/id.json");
const IDL_BASE =
"https://raw.githubusercontent.com/continuum-markets/continuum-docs/main/idl";
const SYMBOL = process.env.SYMBOL ?? "QQQ";
const CUSDC_MINT = new PublicKey("B1c5xBYkp7AAemYhcu4VuH4CU4sPJDDuG2iuv6ts38uE");
const AMOUNT = 100_000_000n; // 100 cUSDC, 6 decimals
const MAX_AGE = 300; // the programs reject prices older than this (seconds)
const MARGIN = 30; // time left for the transaction to land
const E6 = 1_000_000n;
const ONE = 1_000_000_000n; // γ scale
const big = (x: { toString(): string }) => BigInt(x.toString());
const fmt = (x: bigint) => (Number(x) / 1e6).toFixed(6);
async function loadIdl(name: string): Promise<Idl> {
const res = await fetch(`${IDL_BASE}/${name}.json`);
if (!res.ok) throw new Error(`IDL ${name}: HTTP ${res.status}`);
return (await res.json()) as Idl;
}
// Risk-state multiplier in percent (150 = 1.5x). Recovery falls linearly
// from the Stress value to the Normal value over one hour.
function stateMult(m: any, normal: number, proxy: number, stress: number, now: number) {
const state = Object.keys(m.riskState)[0];
if (state === "normal") return BigInt(normal);
if (state === "proxyMode") return BigInt(proxy);
if (state === "stress") return BigInt(stress);
const t = Math.min(Math.max(now - m.riskStateUpdatedAt.toNumber(), 0), 3600);
return BigInt(stress - Math.floor(((stress - normal) * t) / 3600));
}
// Worst-case NAVs (1e6) and fee rates, computed the way mint-redeem does.
function quote(m: any, now: number) {
const P = big(m.userTwapPrice);
const K = big(m.initialLPrice) * big(m.initialSPrice);
const g = big(m.sGammaIndex);
const gamma = g === 0n || g > ONE ? ONE : g; // 0 means unset: read as 1.0
const sNav = (price: bigint) => ((K / price) * gamma) / ONE;
const wc = m.worstCaseConfig;
const c = wc.enabled
? (big(m.oracleConfidence) *
stateMult(m, wc.normalConfidenceMultiplier, wc.proxyConfidenceMultiplier,
wc.stressConfidenceMultiplier, now)) / 100n
: 0n;
const feeMult = stateMult(m, 100, m.riskFeeConfig.proxyFeeMultiplier,
m.riskFeeConfig.stressFeeMultiplier, now);
return {
gamma,
mint: { l: P + c, s: sNav(P - c), feeBps: (BigInt(m.mintFeeBps) * feeMult) / 100n },
redeem: { l: P - c, s: sNav(P + c), feeBps: (BigInt(m.redeemFeeBps) * feeMult) / 100n },
};
}
async function main() {
const connection = new Connection(RPC_URL, "confirmed");
const secret = JSON.parse(fs.readFileSync(WALLET_PATH, "utf8"));
const wallet = new Wallet(Keypair.fromSecretKey(Uint8Array.from(secret)));
const provider = new AnchorProvider(connection, wallet, { commitment: "confirmed" });
const user = wallet.publicKey;
// A fetched IDL has no static types, so the clients are `any` to TypeScript.
const [mr, faucet]: any[] = await Promise.all(
["mint_redeem", "faucet"].map(async (n) => new Program(await loadIdl(n), provider)));
// 1. Load the market and check the price before spending anything
const [market] = PublicKey.findProgramAddressSync(
[Buffer.from("market"), Buffer.from(SYMBOL)], mr.programId);
const m = await mr.account.market.fetch(market);
const now = Math.floor(Date.now() / 1000);
const age = now - m.twapUpdatedAt.toNumber();
const state = Object.keys(m.riskState)[0];
console.log(`${SYMBOL} risk=${state} price=${fmt(big(m.userTwapPrice))} age=${age}s`);
if (m.userTwapPrice.isZero() || age > MAX_AGE - MARGIN) {
throw new Error(`price is ${age}s old; mint and redeem revert after ${MAX_AGE}s`);
}
if (state === "stress" &&
!(m.riskFeeConfig.allowMintInStress && m.riskFeeConfig.allowRedeemInStress)) {
throw new Error("market is in Stress; mint and redeem are blocked");
}
// 2. Estimate the mint at the worst-case NAVs
const q = quote(m, now);
const fee = (AMOUNT * q.mint.feeBps) / 10_000n;
const half = (AMOUNT - fee) / 2n;
console.log(`mint NAVs L ${fmt(q.mint.l)} S ${fmt(q.mint.s)} ` +
`gamma ${(Number(q.gamma) / 1e9).toFixed(9)} fee ${q.mint.feeBps} bps`);
console.log(`expect ~${fmt((half * E6) / q.mint.l)} L + ` +
`${fmt(((AMOUNT - fee - half) * E6) / q.mint.s)} S`);
// 3. Token accounts. The fee recipient can be a PDA, hence `true`.
const userCollateral = getAssociatedTokenAddressSync(CUSDC_MINT, user);
const userLongAccount = getAssociatedTokenAddressSync(m.longMint, user);
const userShortAccount = getAssociatedTokenAddressSync(m.shortMint, user);
const devTokenAccount = getAssociatedTokenAddressSync(CUSDC_MINT, m.feeRecipient, true);
const collateralAtaIx =
createAssociatedTokenAccountIdempotentInstruction(user, userCollateral, user, CUSDC_MINT);
const ataIxs = [
collateralAtaIx,
createAssociatedTokenAccountIdempotentInstruction(user, userLongAccount, user, m.longMint),
createAssociatedTokenAccountIdempotentInstruction(user, userShortAccount, user, m.shortMint),
createAssociatedTokenAccountIdempotentInstruction(
user, devTokenAccount, m.feeRecipient, CUSDC_MINT),
];
const balance = (a: PublicKey) =>
connection.getTokenAccountBalance(a).then((r) => BigInt(r.value.amount), () => 0n);
// 4. Drip 1,000 cUSDC if the wallet is short (one drip per wallet per 24 h)
if ((await balance(userCollateral)) < AMOUNT) {
const dripIx = await faucet.methods
.drip()
.accounts({ mint: CUSDC_MINT, recipient: user, recipientAta: userCollateral })
.instruction(); // faucetState, mintAuthority and dripRecord are derived PDAs
const sig = await provider.sendAndConfirm(new Transaction().add(collateralAtaIx, dripIx));
console.log(`drip ${sig}`);
}
// 5. Mint the pair
const accounts = {
market, user, userCollateral, userLongAccount, userShortAccount,
longMint: m.longMint, shortMint: m.shortMint,
collateralVault: m.collateralVault, devTokenAccount,
oracle: m.oracleAddress, tokenProgram: TOKEN_PROGRAM_ID,
};
const [l0, s0] = await Promise.all([balance(userLongAccount), balance(userShortAccount)]);
const mintIx = await mr.methods
.mintPaired(new BN(AMOUNT.toString()))
.accounts(accounts)
.instruction();
const mintSig = await provider.sendAndConfirm(new Transaction().add(...ataIxs, mintIx));
const [l1, s1] = await Promise.all([balance(userLongAccount), balance(userShortAccount)]);
console.log(`mint_paired ${mintSig}`);
console.log(` +${fmt(l1 - l0)} ${SYMBOL} L +${fmt(s1 - s0)} ${SYMBOL} S`);
// 6. Redeem what was just minted. Both amounts must be > 0 and are maxima.
const nominal = ((l1 - l0) * q.redeem.l) / E6 + ((s1 - s0) * q.redeem.s) / E6;
console.log(`expect ~${fmt(nominal - (nominal * q.redeem.feeBps) / 10_000n)} cUSDC back`);
const redeemIx = await mr.methods
.redeemPaired(new BN((l1 - l0).toString()), new BN((s1 - s0).toString()))
.accounts({ ...accounts, collateralAuthority: null }) // ignored slot; null sends mint-redeem's program ID
.instruction();
const c0 = await balance(userCollateral);
const redeemSig = await provider.sendAndConfirm(new Transaction().add(redeemIx));
console.log(`redeem_paired ${redeemSig}`);
console.log(` +${fmt((await balance(userCollateral)) - c0)} cUSDC`);
}
main().catch((e) => { console.error(e); process.exit(1); });