From 3dcccbe9992baf444256d1d646c22031ae094b02 Mon Sep 17 00:00:00 2001 From: OxToF <160028560+OxToF@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:43:41 +0200 Subject: [PATCH 1/3] test(amm): LP reward regression coverage for the 2026-07-21 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Isolated rewards-enabled pool (two throwaway mints). Covers, in-epoch: - happy path: a real depositor claims continuous oSOLA (was uncovered) - Finding B guard: a fresh wallet holding TRANSFERRED LP reverts NothingToClaim - Finding A guard: a fresh wallet banks zero checkpoint weight Full epoch-emission path (emit_pool_rewards -> claim_lp_emissions) needs a 7-day epoch warp this mocha/validator harness can't do — documented gap (bankrun). 37 passing on localnet. Co-Authored-By: Claude Opus 4.8 --- tests/soladrome.ts | 152 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/tests/soladrome.ts b/tests/soladrome.ts index d8315f2..fcd409d 100644 --- a/tests/soladrome.ts +++ b/tests/soladrome.ts @@ -4,6 +4,9 @@ import { Soladrome } from "../target/types/soladrome"; import { createMint, getOrCreateAssociatedTokenAccount, + getAssociatedTokenAddressSync, + createAssociatedTokenAccountInstruction, + createTransferInstruction, mintTo, getAccount, TOKEN_PROGRAM_ID, @@ -2062,4 +2065,153 @@ describe("soladrome", () => { // life of the protocol — unlike the virtual reserves, which drift with every buy. console.log(`✅ [curve] k = ${st.k.toString()} | ×2 needs ~414k USDC of buys`); }); + + // ── LP reward accounting (regression for the 2026-07-21 findings) ────────── + // Best run on localnet: `anchor test --provider.cluster localnet`. Uses an isolated + // pool with two throwaway mints so nothing here touches the SOLA/USDC pool or the + // curve/POL/invariant tests above. + // + // The full epoch-emission path (emit_pool_rewards → claim_lp_emissions, Finding A) needs + // a 7-day epoch boundary crossed, which this mocha/validator harness can't warp — that + // stays a documented gap (needs a bankrun-style clock). What IS covered here, in-epoch: + // • the continuous path end-to-end (Finding B), including the exact exploit as a guard; + // • the checkpoint weight basis (Finding A), where a fresh wallet must bank zero. + const LP_DEAD = anchor.web3.SystemProgram.programId; // = LP_DEAD_PUBKEY + let lpPool: anchor.web3.PublicKey; + let lpMintX: anchor.web3.PublicKey; + let mintX: anchor.web3.PublicKey; + let mintY: anchor.web3.PublicKey; + let lpVaultA: anchor.web3.PublicKey; + let lpVaultB: anchor.web3.PublicKey; + + const lpUserInfoPda = (pool: anchor.web3.PublicKey, u: anchor.web3.PublicKey) => + anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("lp_user"), pool.toBuffer(), u.toBuffer()], program.programId)[0]; + + it("[lp-reward] sets up an isolated rewards-enabled pool with liquidity", async () => { + // Two fresh mints, wallet-funded on both sides. + mintX = await createMint(connection, wallet.payer, wallet.publicKey, null, DECIMALS); + mintY = await createMint(connection, wallet.payer, wallet.publicKey, null, DECIMALS); + const [ma, mb] = Buffer.compare(mintX.toBuffer(), mintY.toBuffer()) < 0 ? [mintX, mintY] : [mintY, mintX]; + + [lpPool] = anchor.web3.PublicKey.findProgramAddressSync([Buffer.from("amm_pool"), ma.toBuffer(), mb.toBuffer()], program.programId); + [lpMintX] = anchor.web3.PublicKey.findProgramAddressSync([Buffer.from("lp_mint"), lpPool.toBuffer()], program.programId); + [lpVaultA] = anchor.web3.PublicKey.findProgramAddressSync([Buffer.from("vault_a"), lpPool.toBuffer()], program.programId); + [lpVaultB] = anchor.web3.PublicKey.findProgramAddressSync([Buffer.from("vault_b"), lpPool.toBuffer()], program.programId); + + await program.methods.createPool(30, 2000).accounts({ + creator: wallet.publicKey, tokenAMint: ma, tokenBMint: mb, pool: lpPool, lpMint: lpMintX, + tokenAVault: lpVaultA, tokenBVault: lpVaultB, tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, + } as any).rpc(); + + // Fund the wallet's X/Y ATAs and provide liquidity. + const ax = await getOrCreateAssociatedTokenAccount(connection, wallet.payer, ma, wallet.publicKey); + const ay = await getOrCreateAssociatedTokenAccount(connection, wallet.payer, mb, wallet.publicKey); + await mintTo(connection, wallet.payer, ma, ax.address, wallet.payer, 1_000_000_000); + await mintTo(connection, wallet.payer, mb, ay.address, wallet.payer, 1_000_000_000); + + const userLp = getAssociatedTokenAddressSync(lpMintX, wallet.publicKey); + const deadLp = getAssociatedTokenAddressSync(lpMintX, LP_DEAD, true); + const userOSola = getAssociatedTokenAddressSync(oSolaM, wallet.publicKey); + await program.methods.addLiquidity(HUNDRED, HUNDRED, new BN(0)).accounts({ + user: wallet.publicKey, pool: lpPool, lpMint: lpMintX, tokenAVault: lpVaultA, tokenBVault: lpVaultB, + userTokenA: ax.address, userTokenB: ay.address, userLp, lpDeadAta: deadLp, lpDead: LP_DEAD, + lpUserInfo: lpUserInfoPda(lpPool, wallet.publicKey), protocolState: statePda, oSolaMint: oSolaM, + userOSola, rent: anchor.web3.SYSVAR_RENT_PUBKEY, tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: anchor.web3.SystemProgram.programId, + } as any).rpc(); + + const info = await program.account.lpUserInfo.fetch(lpUserInfoPda(lpPool, wallet.publicKey)); + const lpBal = await getTokenBalance(connection, userLp); + assert.isTrue(lpBal > 0n, "wallet received LP"); + assert.equal(info.lpAmount.toString(), lpBal.toString(), "lp_amount tracks the recorded deposit"); + + // Arm the continuous stream and enable this pool. + await program.methods.configureContinuousEmissions(new BN(1_000_000), new BN(100)).accounts({ + authority: wallet.publicKey, protocolState: statePda } as any).rpc(); + await program.methods.setPoolRewards(true).accounts({ + authority: wallet.publicKey, protocolState: statePda, pool: lpPool } as any).rpc(); + console.log(`✅ [lp-reward] pool ${lpPool.toBase58().slice(0, 8)}… — LP=${lpBal}, lp_amount recorded, rewards armed`); + }); + + it("[lp-reward] a real depositor claims continuous oSOLA", async () => { + const userLp = getAssociatedTokenAddressSync(lpMintX, wallet.publicKey); + const userOSola = getAssociatedTokenAddressSync(oSolaM, wallet.publicKey); + await waitForNewSlot(connection); // let osola_reward_per_lp accrue + const before = await getTokenBalance(connection, userOSola); + await program.methods.claimLpRewards().accounts({ + user: wallet.publicKey, pool: lpPool, lpMint: lpMintX, userLp, + lpUserInfo: lpUserInfoPda(lpPool, wallet.publicKey), protocolState: statePda, oSolaMint: oSolaM, + userOSola, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, + } as any).rpc(); + const gained = (await getTokenBalance(connection, userOSola)) - before; + assert.isTrue(gained > 0n, "legit LP earns continuous oSOLA"); + console.log(`✅ [lp-reward] real depositor claimed ${gained} oSOLA`); + }); + + it("[lp-reward][security] a fresh wallet holding TRANSFERRED LP cannot claim (Finding B)", async () => { + // The exact confirmed exploit: move LP to a wallet that never deposited, then claim. + // Pre-fix this minted the whole accumulator since pool creation; now reward_basis = + // min(lp_amount, wallet_lp) = min(0, x) = 0 → require!(pending > 0) rejects it. + const F = anchor.web3.Keypair.generate(); + await provider.sendAndConfirm(new anchor.web3.Transaction().add( + anchor.web3.SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: F.publicKey, lamports: 50_000_000 }))); + + const walletLp = getAssociatedTokenAddressSync(lpMintX, wallet.publicKey); + const fLp = getAssociatedTokenAddressSync(lpMintX, F.publicKey); + await provider.sendAndConfirm(new anchor.web3.Transaction() + .add(createAssociatedTokenAccountInstruction(wallet.publicKey, fLp, F.publicKey, lpMintX)) + .add(createTransferInstruction(walletLp, fLp, wallet.publicKey, 10_000_000))); + assert.isTrue((await getTokenBalance(connection, fLp)) > 0n, "F holds transferred LP"); + + const fOSola = getAssociatedTokenAddressSync(oSolaM, F.publicKey); + await waitForNewSlot(connection); // ensure a non-zero accumulator exists + let reverted = false; + try { + await program.methods.claimLpRewards().accounts({ + user: F.publicKey, pool: lpPool, lpMint: lpMintX, userLp: fLp, + lpUserInfo: lpUserInfoPda(lpPool, F.publicKey), protocolState: statePda, oSolaMint: oSolaM, + userOSola: fOSola, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, + } as any).signers([F]).rpc(); + } catch (e: any) { + reverted = true; + assert.match(e.toString(), /NothingToClaim/, "must reject with NothingToClaim, not another error"); + } + assert.isTrue(reverted, "fresh-wallet transfer-based claim MUST revert (Finding B closed)"); + console.log("✅ [lp-reward][security] transfer-based claim rejected — Finding B closed"); + }); + + it("[lp-emission][security] a fresh wallet banks zero checkpoint weight (Finding A)", async () => { + // checkpoint_lp weight basis is now reward_basis, not the wallet balance. A fresh wallet + // that was transferred LP has lp_amount = 0 → weighted_balance stays 0, so the same LP + // walked through N wallets can no longer inflate the epoch pot. + const F2 = anchor.web3.Keypair.generate(); + await provider.sendAndConfirm(new anchor.web3.Transaction().add( + anchor.web3.SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: F2.publicKey, lamports: 50_000_000 }))); + const walletLp = getAssociatedTokenAddressSync(lpMintX, wallet.publicKey); + const f2Lp = getAssociatedTokenAddressSync(lpMintX, F2.publicKey); + await provider.sendAndConfirm(new anchor.web3.Transaction() + .add(createAssociatedTokenAccountInstruction(wallet.publicKey, f2Lp, F2.publicKey, lpMintX)) + .add(createTransferInstruction(walletLp, f2Lp, wallet.publicKey, 10_000_000))); + + const nowEpoch = new BN(Math.floor(Date.now() / 1000 / 604800)); + const [ckptF2] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("lp_ckpt"), lpPool.toBuffer(), F2.publicKey.toBuffer()], program.programId); + const [accum] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("lp_pool_epoch"), lpPool.toBuffer(), nowEpoch.toArrayLike(Buffer, "le", 8)], program.programId); + + await waitForNewSlot(connection); + await program.methods.checkpointLp(nowEpoch).accounts({ + user: F2.publicKey, protocolState: statePda, pool: lpPool, lpMint: lpMintX, userLp: f2Lp, + lpUserInfo: lpUserInfoPda(lpPool, F2.publicKey), lpUserCheckpoint: ckptF2, poolEpochAccum: accum, + systemProgram: anchor.web3.SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, + } as any).signers([F2]).rpc(); + + const ck = await program.account.lpUserCheckpoint.fetch(ckptF2); + assert.equal(ck.weightedBalance.toString(), "0", "fresh wallet must bank zero weight (Finding A closed)"); + console.log("✅ [lp-emission][security] fresh-wallet checkpoint weight = 0 — Finding A closed"); + }); }); From 8021a2e8da11425d382a0273050f9cf9f34a6865 Mon Sep 17 00:00:00 2001 From: OxToF <160028560+OxToF@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:56:07 +0200 Subject: [PATCH 2/3] test(amm): bankrun harness closing the per-epoch LP emission gate The mocha suite runs against a live cluster (Anchor.toml pins devnet), so it is stuck with the real clock and could only cover the in-epoch half of the LP reward system. The per-epoch half (checkpoint_lp -> emit_pool_rewards -> claim_lp_emissions) needs a 7-day boundary crossed, which is the documented gate on arming configure_emissions with initial > 0 on mainnet. bankrun's setClock() moves the epoch on demand. 7 tests, ~1s, no validator: - full cycle across a real epoch boundary: allocation is the undecayed emission in epoch 0, then decayed 1% in epoch 1, which pins decayed_emission too - replay of the same (user, pool, epoch) rejected. Worth noting the LpEpochClaim `init` is an ACCOUNT CONSTRAINT, so it fires during validation, before the body's NothingToClaim. The opposite assumption was made and disproved by the test. - invariant sum(claims) <= osola_allocated over a 2-LP epoch. The clamp itself does not bind: two honest LPs under-subscribe the pot, which is the safe direction. - a late depositor cannot bank a full epoch of weight (Finding A) - withdrawal decrements lp_amount, so it shrinks the reward basis - reward_basis follows the wallet balance DOWN, the reverse leg of Finding B that nothing covered: high recorded deposit but LP moved out must bank zero weight - a position recorded at lp_amount = 0 (legacy or transfer-acquired) can still withdraw Verified load-bearing by mutation: putting window_start back to epoch_start in checkpoint_lp makes the late-depositor test fail (weight/LP 1114620 vs 1114560, equality being the back-credit signature) while the other 6 stay green. A cycle test asserting only "gained > 0" passes on the bug. Tooling trap: bankrun cannot load an SBPFv3 binary. cargo build-sbf --arch v3, required for a devnet deploy, yields a .so rejected with the misleading "Program is not deployed". Build plain to test, re-add --arch v3 to deploy. Co-Authored-By: Claude Opus 4.8 --- package.json | 5 +- tests/lp_emissions_bankrun.ts | 631 ++++++++++++++++++++++++++++++++++ yarn.lock | 46 ++- 3 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 tests/lp_emissions_bankrun.ts diff --git a/package.json b/package.json index adbb0bc..2edd6ba 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "license": "ISC", "scripts": { "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", - "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" + "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check", + "test:bankrun": "ts-mocha -p ./tsconfig.json -t 120000 tests/lp_emissions_bankrun.ts" }, "dependencies": { "@coral-xyz/anchor": "^0.32.1", @@ -12,9 +13,11 @@ "@types/bn.js": "^5.1.0", "@types/chai": "^4.3.0", "@types/mocha": "^9.0.0", + "anchor-bankrun": "^0.5.0", "chai": "^4.3.4", "mocha": "^9.0.3", "prettier": "^2.6.2", + "solana-bankrun": "^0.4.0", "ts-mocha": "^10.0.0", "typescript": "^5.7.3" } diff --git a/tests/lp_emissions_bankrun.ts b/tests/lp_emissions_bankrun.ts new file mode 100644 index 0000000..663d89d --- /dev/null +++ b/tests/lp_emissions_bankrun.ts @@ -0,0 +1,631 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Soladrome Labs +// +// LP emission coverage that the mocha/validator suite structurally cannot provide. +// +// `tests/soladrome.ts` runs against a live cluster (Anchor.toml pins devnet), so it is +// stuck with the real clock and can only exercise the *in-epoch* half of the LP reward +// system. The per-epoch half — `checkpoint_lp` → `emit_pool_rewards` → +// `claim_lp_emissions`, plus the `osola_claimed` ceiling — needs a 7-day boundary to be +// crossed, which is what this bankrun harness buys: `context.setClock()` moves the epoch +// on demand. +// +// That gap is the documented gate on arming mainnet emissions (`configure_emissions` +// with `initial > 0`). This file closes it, and covers three further paths around +// `LpUserInfo.lp_amount` that the 2026-07-21 fix introduced and nothing asserted: +// withdrawal shrinking the reward basis, the `min(lp_amount, wallet_lp)` floor when LP +// leaves the wallet, and a zero-`lp_amount` position still being able to withdraw. +// +// Run: yarn test:bankrun (no validator, no airdrops, ~1 s) +// +// ⚠️ BUILD ARCH — bankrun cannot load an SBPFv3 binary. `cargo build-sbf --arch v3`, which +// CLAUDE.md requires for a DEVNET deploy, produces a .so this harness rejects with the +// misleading "Program is not deployed". Build plain (`anchor build` / `cargo build-sbf`) +// to run the tests, and re-add `--arch v3` when you deploy. Same source, different target. +// +// Verified load-bearing: putting `window_start` in checkpoint_lp back to `epoch_start` +// (the Finding A bug) makes the late-depositor test at the bottom of this file fail. +// Do not weaken that assertion — the other tests stay green on that mutation. + +import * as anchor from "@coral-xyz/anchor"; +import { Program, BN } from "@coral-xyz/anchor"; +import { startAnchor, BankrunProvider } from "anchor-bankrun"; +import { Clock, ProgramTestContext, BanksClient } from "solana-bankrun"; +import { + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + MINT_SIZE, + AccountLayout, + createInitializeMint2Instruction, + createAssociatedTokenAccountIdempotentInstruction, + createMintToInstruction, + createTransferInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { assert } from "chai"; +import { Soladrome } from "../target/types/soladrome"; + +const { + Keypair, + PublicKey, + SystemProgram, + Transaction, + SYSVAR_RENT_PUBKEY, +} = anchor.web3; +type Kp = anchor.web3.Keypair; +type Pk = anchor.web3.PublicKey; + +const DECIMALS = 6; +const ONE = new BN(1_000_000); +const EPOCH_DURATION = 604_800n; // 7 days, must match lib.rs +const LP_DEAD = SystemProgram.programId; // = LP_DEAD_PUBKEY +// One epoch's emission, deliberately round: with a single gauge taking every vote, +// `emit_pool_rewards` must allocate exactly this to the pool in the first epoch +// (decay elapsed = 0), which makes the allocation assertion exact rather than fuzzy. +const EMISSION_INITIAL = new BN(100_000_000); // 100 oSOLA + +describe("lp-emissions (bankrun)", () => { + let context: ProgramTestContext; + let client: BanksClient; + let provider: BankrunProvider; + let program: Program; + let payer: Kp; + + let statePda: Pk, solaM: Pk, hiSolaM: Pk, oSolaM: Pk, floorV: Pk, marketV: Pk, solaVault: Pk; + let usdcMint: Pk; + let pool: Pk, lpMint: Pk, vaultA: Pk, vaultB: Pk, mintA: Pk, mintB: Pk; + let epoch0: bigint; + + // ── bankrun plumbing ────────────────────────────────────────────────────── + + const pda = (seeds: (Buffer | Uint8Array)[]) => + PublicKey.findProgramAddressSync(seeds, program.programId)[0]; + const epochSeed = (e: bigint) => new BN(e.toString()).toArrayLike(Buffer, "le", 8); + + const lpUserInfoPda = (p: Pk, u: Pk) => pda([Buffer.from("lp_user"), p.toBuffer(), u.toBuffer()]); + const lpCkptPda = (p: Pk, u: Pk) => pda([Buffer.from("lp_ckpt"), p.toBuffer(), u.toBuffer()]); + const accumPda = (p: Pk, e: bigint) => pda([Buffer.from("lp_pool_epoch"), p.toBuffer(), epochSeed(e)]); + const gaugePda = (p: Pk, e: bigint) => pda([Buffer.from("gauge"), p.toBuffer(), epochSeed(e)]); + const globalVotesPda = (e: bigint) => pda([Buffer.from("epoch_votes"), epochSeed(e)]); + const userVotePda = (u: Pk, p: Pk, e: bigint) => + pda([Buffer.from("vote"), u.toBuffer(), p.toBuffer(), epochSeed(e)]); + const userEpochVotesPda = (u: Pk, e: bigint) => pda([Buffer.from("uev"), u.toBuffer(), epochSeed(e)]); + const lpEpochClaimPda = (u: Pk, p: Pk, e: bigint) => + pda([Buffer.from("lp_claim"), u.toBuffer(), p.toBuffer(), epochSeed(e)]); + const positionPda = (u: Pk) => pda([Buffer.from("position"), u.toBuffer()]); + + /** Token-account amount straight from the bank — no Connection in bankrun. */ + async function balance(ata: Pk): Promise { + const acc = await client.getAccount(ata); + return acc ? AccountLayout.decode(Buffer.from(acc.data)).amount : 0n; + } + + async function send(ixs: anchor.web3.TransactionInstruction[], signers: Kp[] = []) { + const tx = new Transaction().add(...ixs); + return provider.sendAndConfirm!(tx, signers); + } + + /** SPL helpers rebuilt on raw instructions: the spl-token helpers all want a Connection. */ + async function createMint(authority: Pk): Promise { + const mint = Keypair.generate(); + const rent = await client.getRent(); + await send( + [ + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint.publicKey, + space: MINT_SIZE, + lamports: Number(rent.minimumBalance(BigInt(MINT_SIZE))), + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction(mint.publicKey, DECIMALS, authority, null), + ], + [mint], + ); + return mint.publicKey; + } + + async function mintTo(mint: Pk, owner: Pk, amount: number | BN) { + const ata = getAssociatedTokenAddressSync(mint, owner); + await send([ + createAssociatedTokenAccountIdempotentInstruction(payer.publicKey, ata, owner, mint), + createMintToInstruction(mint, ata, payer.publicKey, BigInt(amount.toString())), + ]); + return ata; + } + + async function fundSol(to: Pk, lamports = 5_000_000_000) { + await send([SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: to, lamports })]); + } + + /** + * Move the validator clock to an absolute unix timestamp. + * + * The slot is bumped alongside it: bankrun derives the blockhash from the slot, and two + * transactions sent at the same slot with identical instructions would collide as + * duplicates. Bumping also keeps `last_change_ts`/`last_update_ts` deltas honest. + */ + async function warpTo(unixTs: bigint) { + const c = await client.getClock(); + context.warpToSlot(c.slot + 100n); + context.setClock(new Clock(c.slot + 100n, c.epochStartTimestamp, c.epoch, c.leaderScheduleEpoch, unixTs)); + } + + const epochStart = (e: bigint) => e * EPOCH_DURATION; + const epochEnd = (e: bigint) => (e + 1n) * EPOCH_DURATION; + + // ── protocol setup ──────────────────────────────────────────────────────── + + before(async () => { + context = await startAnchor(".", [], []); + client = context.banksClient; + provider = new BankrunProvider(context); + anchor.setProvider(provider); + payer = context.payer; + program = new Program(require("../target/idl/soladrome.json"), provider); + + statePda = pda([Buffer.from("state")]); + solaM = pda([Buffer.from("sola_mint")]); + hiSolaM = pda([Buffer.from("hi_sola_mint")]); + oSolaM = pda([Buffer.from("o_sola_mint")]); + floorV = pda([Buffer.from("floor_vault")]); + marketV = pda([Buffer.from("market_vault")]); + solaVault = pda([Buffer.from("sola_vault")]); + + // Land early inside a fresh epoch so checkpoints have the whole epoch ahead of them. + const genesis = await client.getClock(); + epoch0 = genesis.unixTimestamp / EPOCH_DURATION + 1n; + await warpTo(epochStart(epoch0) + 60n); + + usdcMint = await createMint(payer.publicKey); + + await program.methods + .initialize() + .accounts({ + authority: payer.publicKey, protocolState: statePda, usdcMint, + solaMint: solaM, hiSolaMint: hiSolaM, oSolaMint: oSolaM, + floorVault: floorV, marketVault: marketV, solaVault, + tokenProgram: TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY, + } as any) + .rpc(); + + await program.methods + .setPhaseFlags(true, true, true, true, true) + .accounts({ authority: payer.publicKey, protocolState: statePda } as any) + .rpc(); + + // Arm per-epoch emissions. start_epoch = current epoch → decay elapsed = 0 in epoch0, + // so the first epoch allocates exactly EMISSION_INITIAL. + await program.methods + .configureEmissions(EMISSION_INITIAL, 9_900, 1_000) + .accounts({ authority: payer.publicKey, protocolState: statePda } as any) + .rpc(); + + // Isolated pool on two throwaway mints — nothing here touches SOLA/USDC. + const m1 = await createMint(payer.publicKey); + const m2 = await createMint(payer.publicKey); + [mintA, mintB] = Buffer.compare(m1.toBuffer(), m2.toBuffer()) < 0 ? [m1, m2] : [m2, m1]; + pool = pda([Buffer.from("amm_pool"), mintA.toBuffer(), mintB.toBuffer()]); + lpMint = pda([Buffer.from("lp_mint"), pool.toBuffer()]); + vaultA = pda([Buffer.from("vault_a"), pool.toBuffer()]); + vaultB = pda([Buffer.from("vault_b"), pool.toBuffer()]); + + await program.methods + .createPool(30, 2000) + .accounts({ + creator: payer.publicKey, protocolState: statePda, tokenAMint: mintA, tokenBMint: mintB, + pool, lpMint, tokenAVault: vaultA, tokenBVault: vaultB, + tokenProgram: TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: SYSVAR_RENT_PUBKEY, + } as any) + .rpc(); + + await mintTo(mintA, payer.publicKey, 1_000_000_000); + await mintTo(mintB, payer.publicKey, 1_000_000_000); + await addLiquidity(payer, ONE.muln(100), ONE.muln(100)); + + await program.methods + .setPoolRewards(true) + .accounts({ authority: payer.publicKey, protocolState: statePda, pool } as any) + .rpc(); + + // Voting power: emit_pool_rewards divides by gauge votes, so the pool needs a vote. + // USDC in → SOLA on the curve → hiSOLA staked → vote the gauge. + await mintTo(usdcMint, payer.publicKey, 10_000_000_000); + await program.methods + .buySola(ONE.muln(1000), new BN(0)) + .accounts({ + user: payer.publicKey, protocolState: statePda, solaMint: solaM, + userUsdc: getAssociatedTokenAddressSync(usdcMint, payer.publicKey), + userSola: getAssociatedTokenAddressSync(solaM, payer.publicKey), + floorVault: floorV, marketVault: marketV, tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, + } as any) + .rpc(); + + await program.methods + .stakeSola(ONE.muln(100)) + .accounts({ + user: payer.publicKey, protocolState: statePda, solaMint: solaM, hiSolaMint: hiSolaM, + userSola: getAssociatedTokenAddressSync(solaM, payer.publicKey), + userHiSola: getAssociatedTokenAddressSync(hiSolaM, payer.publicKey), + solaVault, marketVault: marketV, usdcMint, + userUsdc: getAssociatedTokenAddressSync(usdcMint, payer.publicKey), + userPosition: positionPda(payer.publicKey), tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, + } as any) + .rpc(); + + // 25 hiSOLA, not 50: VOTE_WEIGHT_CAP_BPS caps any single address at 30% of + // total_hi_sola, and this harness is the only staker. The exact figure is + // irrelevant to the allocation — one gauge holding every vote takes the whole pot. + await voteGauge(payer, epoch0, ONE.muln(25)); + }); + + // ── instruction wrappers ────────────────────────────────────────────────── + + async function addLiquidity(user: Kp, a: BN, b: BN) { + await program.methods + .addLiquidity(a, b, new BN(0)) + .accounts({ + user: user.publicKey, pool, lpMint, tokenAVault: vaultA, tokenBVault: vaultB, + userTokenA: getAssociatedTokenAddressSync(mintA, user.publicKey), + userTokenB: getAssociatedTokenAddressSync(mintB, user.publicKey), + userLp: getAssociatedTokenAddressSync(lpMint, user.publicKey), + lpDeadAta: getAssociatedTokenAddressSync(lpMint, LP_DEAD, true), lpDead: LP_DEAD, + lpUserInfo: lpUserInfoPda(pool, user.publicKey), protocolState: statePda, oSolaMint: oSolaM, + userOSola: getAssociatedTokenAddressSync(oSolaM, user.publicKey), + rent: SYSVAR_RENT_PUBKEY, tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, + } as any) + .signers(user.publicKey.equals(payer.publicKey) ? [] : [user]) + .rpc(); + } + + async function removeLiquidity(user: Kp, lp: BN) { + await program.methods + .removeLiquidity(lp, new BN(0), new BN(0)) + .accounts({ + user: user.publicKey, pool, lpMint, tokenAVault: vaultA, tokenBVault: vaultB, + userLp: getAssociatedTokenAddressSync(lpMint, user.publicKey), + userTokenA: getAssociatedTokenAddressSync(mintA, user.publicKey), + userTokenB: getAssociatedTokenAddressSync(mintB, user.publicKey), + lpUserInfo: lpUserInfoPda(pool, user.publicKey), protocolState: statePda, oSolaMint: oSolaM, + userOSola: getAssociatedTokenAddressSync(oSolaM, user.publicKey), + rent: SYSVAR_RENT_PUBKEY, tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, + } as any) + .signers(user.publicKey.equals(payer.publicKey) ? [] : [user]) + .rpc(); + } + + async function checkpoint(user: Kp, e: bigint) { + await program.methods + .checkpointLp(new BN(e.toString())) + .accounts({ + user: user.publicKey, protocolState: statePda, pool, lpMint, + userLp: getAssociatedTokenAddressSync(lpMint, user.publicKey), + lpUserInfo: lpUserInfoPda(pool, user.publicKey), + lpUserCheckpoint: lpCkptPda(pool, user.publicKey), + poolEpochAccum: accumPda(pool, e), + systemProgram: SystemProgram.programId, rent: SYSVAR_RENT_PUBKEY, + } as any) + .signers(user.publicKey.equals(payer.publicKey) ? [] : [user]) + .rpc(); + } + + async function voteGauge(user: Kp, e: bigint, votes: BN) { + await program.methods + .voteGauge(new BN(e.toString()), votes) + .accounts({ + user: user.publicKey, poolId: pool, protocolState: statePda, hiSolaMint: hiSolaM, + userHiSola: getAssociatedTokenAddressSync(hiSolaM, user.publicKey), + // UncheckedAccount: "pass any account when not using a ve lock" (lib.rs). + lockPosition: SystemProgram.programId, + gaugeState: gaugePda(pool, e), + userVoteReceipt: userVotePda(user.publicKey, pool, e), + userEpochVotes: userEpochVotesPda(user.publicKey, e), + globalEpochVotes: globalVotesPda(e), + systemProgram: SystemProgram.programId, rent: SYSVAR_RENT_PUBKEY, + } as any) + .signers(user.publicKey.equals(payer.publicKey) ? [] : [user]) + .rpc(); + } + + async function emitRewards(e: bigint) { + await program.methods + .emitPoolRewards(new BN(e.toString())) + .accounts({ + caller: payer.publicKey, protocolState: statePda, pool, lpMint, + gaugeState: gaugePda(pool, e), globalEpochVotes: globalVotesPda(e), + poolEpochAccum: accumPda(pool, e), + systemProgram: SystemProgram.programId, rent: SYSVAR_RENT_PUBKEY, + } as any) + .rpc(); + } + + async function claimEmissions(user: Kp, e: bigint) { + await program.methods + .claimLpEmissions(new BN(e.toString())) + .accounts({ + user: user.publicKey, pool, protocolState: statePda, oSolaMint: oSolaM, + userOSola: getAssociatedTokenAddressSync(oSolaM, user.publicKey), + poolEpochAccum: accumPda(pool, e), + lpUserCheckpoint: lpCkptPda(pool, user.publicKey), + lpEpochClaim: lpEpochClaimPda(user.publicKey, pool, e), + tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, rent: SYSVAR_RENT_PUBKEY, + } as any) + .signers(user.publicKey.equals(payer.publicKey) ? [] : [user]) + .rpc(); + } + + // ── the gate: the full per-epoch cycle ──────────────────────────────────── + + it("[lp-emission] runs the whole per-epoch cycle across a real epoch boundary", async () => { + // Two checkpoints inside the epoch: weight accrues only between them (never back to + // epoch_start), which is the Finding A fix. + await checkpoint(payer, epoch0); + await warpTo(epochStart(epoch0) + 3n * 86_400n); + await checkpoint(payer, epoch0); + + const ckMid = await program.account.lpUserCheckpoint.fetch(lpCkptPda(pool, payer.publicKey)); + assert.isTrue(ckMid.weightedBalance.gt(new BN(0)), "holding across the epoch must bank weight"); + + // Cross the boundary — the step no live-cluster suite can take. + await warpTo(epochEnd(epoch0) + 60n); + + await emitRewards(epoch0); + const pa = await program.account.lpPoolEpochAccum.fetch(accumPda(pool, epoch0)); + assert.isTrue(pa.finalized, "epoch must be finalized"); + assert.equal( + pa.osolaAllocated.toString(), EMISSION_INITIAL.toString(), + "sole gauge with every vote takes the whole epoch emission, undecayed in epoch 0", + ); + + const userOSola = getAssociatedTokenAddressSync(oSolaM, payer.publicKey); + const before = await balance(userOSola); + await claimEmissions(payer, epoch0); + const gained = (await balance(userOSola)) - before; + + const after = await program.account.lpPoolEpochAccum.fetch(accumPda(pool, epoch0)); + assert.isTrue(gained > 0n, "the sole LP must receive oSOLA for the epoch"); + // A partial hold cannot draw the whole pot. Weak on its own — see the late-depositor + // test at the end of this file for the assertion that actually discriminates against + // the Finding A regression. + assert.isTrue( + gained < BigInt(EMISSION_INITIAL.toString()), + `a partial hold cannot draw the whole allocation (got ${Number(gained) / 1e6})`, + ); + assert.equal(after.osolaClaimed.toString(), gained.toString(), "osola_claimed tracks what was minted"); + assert.isTrue( + after.osolaClaimed.lte(after.osolaAllocated), + "claimed can never exceed the epoch allocation", + ); + + const ck = await program.account.lpUserCheckpoint.fetch(lpCkptPda(pool, payer.publicKey)); + assert.equal(ck.weightedBalance.toString(), "0", "weight is reset after a claim (M-01)"); + + console.log( + `✅ [lp-emission] epoch ${epoch0}: allocated ${pa.osolaAllocated.toNumber() / 1e6} oSOLA, ` + + `claimed ${Number(gained) / 1e6}`, + ); + }); + + it("[lp-emission][security] the same epoch cannot be claimed twice", async () => { + let reverted = false; + try { + await claimEmissions(payer, epoch0); + } catch (e: any) { + reverted = true; + // Two guards stand in the way, and the order matters for anyone reading a failed + // replay in the wild: `init` on LpEpochClaim is an ACCOUNT CONSTRAINT, so it runs + // during validation and fires before the body is ever entered — the replay dies on + // SystemProgram AccountAlreadyInUse (0x0), not on the body's NothingToClaim. The + // weight reset (M-01) is the second line of defence, reached only if the PDA is + // ever made non-`init`. + assert.match( + String(e), /custom program error: 0x0|already in use/i, + `expected the LpEpochClaim collision on replay, got: ${e}`, + ); + } + assert.isTrue(reverted, "a second claim for the same (user, pool, epoch) MUST revert"); + console.log("✅ [lp-emission][security] replay rejected at validation by the LpEpochClaim PDA"); + }); + + it("[lp-emission][security] two LPs together never mint more than the epoch allocation", async () => { + // Guards the invariant the `osola_claimed` ceiling exists to hold: Σ claims ≤ + // allocation. Note the clamp itself does not bind here — with the fix in place two + // honest LPs under-subscribe the pot (the denominator counts the whole epoch, they + // only checkpoint part of it), which is the safe direction. This is a regression + // guard on the invariant, not coverage of the clamp branch. + const epoch1 = epoch0 + 1n; + const lp2 = Keypair.generate(); + await fundSol(lp2.publicKey); + await mintTo(mintA, lp2.publicKey, 500_000_000); + await mintTo(mintB, lp2.publicKey, 500_000_000); + + await warpTo(epochStart(epoch1) + 60n); + await voteGauge(payer, epoch1, ONE.muln(25)); // gauge needs votes in the new epoch too + await addLiquidity(lp2, ONE.muln(100), ONE.muln(100)); + + await checkpoint(payer, epoch1); + await checkpoint(lp2, epoch1); + await warpTo(epochStart(epoch1) + 5n * 86_400n); + await checkpoint(payer, epoch1); + await checkpoint(lp2, epoch1); + + await warpTo(epochEnd(epoch1) + 60n); + await emitRewards(epoch1); + + const o1 = getAssociatedTokenAddressSync(oSolaM, payer.publicKey); + const o2 = getAssociatedTokenAddressSync(oSolaM, lp2.publicKey); + const b1 = await balance(o1); + const b2 = await balance(o2); + await claimEmissions(payer, epoch1); + await claimEmissions(lp2, epoch1); + const g1 = (await balance(o1)) - b1; + const g2 = (await balance(o2)) - b2; + + const pa = await program.account.lpPoolEpochAccum.fetch(accumPda(pool, epoch1)); + // One epoch of decay has elapsed since start_epoch: 100 × 9_900/10_000 = 99 oSOLA. + // Pins decayed_emission as well as the split, so a change to either breaks here. + assert.equal( + pa.osolaAllocated.toString(), + EMISSION_INITIAL.muln(9_900).divn(10_000).toString(), + "epoch 1 must allocate the once-decayed emission", + ); + assert.isTrue(g1 > 0n && g2 > 0n, "both real LPs must be paid"); + assert.isTrue( + pa.osolaClaimed.lte(pa.osolaAllocated), + `Σ claims (${pa.osolaClaimed}) must stay under the allocation (${pa.osolaAllocated})`, + ); + assert.equal( + pa.osolaClaimed.toString(), (g1 + g2).toString(), + "the running total must equal what was actually minted", + ); + console.log( + `✅ [lp-emission][security] epoch ${epoch1}: ${Number(g1) / 1e6} + ${Number(g2) / 1e6} ` + + `= ${pa.osolaClaimed.toNumber() / 1e6} ≤ ${pa.osolaAllocated.toNumber() / 1e6} allocated`, + ); + }); + + // ── the reward basis around LpUserInfo.lp_amount ────────────────────────── + + it("[lp-reward] withdrawing shrinks the recorded deposit, so it shrinks the basis", async () => { + // The migration path testers are told to walk (remove then add). Nothing asserted that + // a withdrawal actually decrements lp_amount — the mirror image of Finding B, and the + // difference between "stops earning" and "keeps earning on capital it no longer has". + const info0 = await program.account.lpUserInfo.fetch(lpUserInfoPda(pool, payer.publicKey)); + const half = info0.lpAmount.divn(2); + + await removeLiquidity(payer, half); + + const info1 = await program.account.lpUserInfo.fetch(lpUserInfoPda(pool, payer.publicKey)); + assert.equal( + info1.lpAmount.toString(), info0.lpAmount.sub(half).toString(), + "lp_amount must fall by exactly what was withdrawn", + ); + assert.equal( + info1.lpAmount.toString(), (await balance(getAssociatedTokenAddressSync(lpMint, payer.publicKey))).toString(), + "recorded deposit stays equal to the wallet balance for an honest LP", + ); + console.log(`✅ [lp-reward] withdrawal cut lp_amount to ${info1.lpAmount.toNumber() / 1e6}`); + }); + + it("[lp-reward][security] LP leaving the wallet caps the basis at the wallet balance", async () => { + // Finding B covers lp_amount = 0 with a positive balance. This is the reverse leg of + // `reward_basis = min(lp_amount, wallet_lp)`: a real depositor keeps a large lp_amount + // but moves the LP out. The basis must follow the balance DOWN, or the position earns + // on capital it has handed to someone else. + const epoch2 = epoch0 + 2n; + await warpTo(epochStart(epoch2) + 60n); + + const sink = Keypair.generate(); + await fundSol(sink.publicKey); + const myLp = getAssociatedTokenAddressSync(lpMint, payer.publicKey); + const sinkLp = getAssociatedTokenAddressSync(lpMint, sink.publicKey); + const held = await balance(myLp); + + const info = await program.account.lpUserInfo.fetch(lpUserInfoPda(pool, payer.publicKey)); + await send([ + createAssociatedTokenAccountIdempotentInstruction(payer.publicKey, sinkLp, sink.publicKey, lpMint), + createTransferInstruction(myLp, sinkLp, payer.publicKey, held), // move ALL of it out + ]); + assert.isTrue(info.lpAmount.gt(new BN(0)), "lp_amount is still recorded after the transfer"); + assert.equal((await balance(myLp)).toString(), "0", "wallet is empty"); + + // Weight banked from here on must be zero: min(lp_amount > 0, balance = 0) = 0. + await checkpoint(payer, epoch2); + await warpTo(epochStart(epoch2) + 2n * 86_400n); + await checkpoint(payer, epoch2); + + const ck = await program.account.lpUserCheckpoint.fetch(lpCkptPda(pool, payer.publicKey)); + assert.equal( + ck.weightedBalance.toString(), "0", + "a position whose LP left the wallet must bank zero weight, whatever lp_amount says", + ); + console.log("✅ [lp-reward][security] basis followed the wallet balance down to zero"); + }); + + it("[lp-reward] a position recorded at zero can still withdraw its LP", async () => { + // Legacy positions (created before lp_amount existed) and transfer-acquired LP both + // read lp_amount = 0. `remove_liquidity` uses saturating_sub, so withdrawal must still + // work — otherwise the fix would have stranded every pre-fix LP. + const holder = Keypair.generate(); + await fundSol(holder.publicKey); + const holderLp = getAssociatedTokenAddressSync(lpMint, holder.publicKey); + + // Give it LP by transfer only: no add_liquidity, so lp_user_info lands on lp_amount = 0. + const sinkLpOwner = holder.publicKey; + await send([ + createAssociatedTokenAccountIdempotentInstruction(payer.publicKey, holderLp, sinkLpOwner, lpMint), + ]); + const lp2Ata = getAssociatedTokenAddressSync(lpMint, payer.publicKey); + // top the payer back up so there is LP to hand over + await addLiquidity(payer, ONE.muln(10), ONE.muln(10)); + const give = (await balance(lp2Ata)) / 2n; + await send([createTransferInstruction(lp2Ata, holderLp, payer.publicKey, give)]); + + await mintTo(mintA, holder.publicKey, 1); // ensure destination ATAs exist + await mintTo(mintB, holder.publicKey, 1); + + await removeLiquidity(holder, new BN(give.toString())); + + const info = await program.account.lpUserInfo.fetch(lpUserInfoPda(pool, holder.publicKey)); + assert.equal(info.lpAmount.toString(), "0", "saturating_sub floors the recorded deposit at zero"); + assert.equal((await balance(holderLp)).toString(), "0", "the LP was actually burned on withdrawal"); + console.log("✅ [lp-reward] zero-recorded position withdrew without reverting"); + }); + it("[lp-emission][security] a late depositor cannot bank a full epoch of weight (Finding A)", async () => { + // THE regression test for Finding A, and the one that earns its keep: it was verified + // to FAIL when `window_start` in checkpoint_lp is put back to `epoch_start`. + // + // The bug is invisible to a long-standing position (back-crediting an LP that held all + // epoch changes almost nothing). It only shows on the exploit shape: deposit at T−ε, + // checkpoint, and bill the whole epoch. So compare weight PER LP UNIT between someone + // who held ~7 days and someone who held ~0.9 — under the fix the early LP must be + // multiples ahead; under the bug the two converge. + const epochN = epoch0 + 5n; + const early = Keypair.generate(); + const late = Keypair.generate(); + for (const kp of [early, late]) { + await fundSol(kp.publicKey); + await mintTo(mintA, kp.publicKey, 500_000_000); + await mintTo(mintB, kp.publicKey, 500_000_000); + } + + await warpTo(epochStart(epochN) + 60n); + await addLiquidity(early, ONE.muln(50), ONE.muln(50)); + await checkpoint(early, epochN); // first checkpoint banks nothing: it opens the window + + await warpTo(epochStart(epochN) + 6n * 86_400n); + await addLiquidity(late, ONE.muln(50), ONE.muln(50)); + await checkpoint(early, epochN); + await checkpoint(late, epochN); + + await warpTo(epochStart(epochN) + 6n * 86_400n + 77_760n); // +0.9 d + await checkpoint(early, epochN); + await checkpoint(late, epochN); + + const ckE = await program.account.lpUserCheckpoint.fetch(lpCkptPda(pool, early.publicKey)); + const ckL = await program.account.lpUserCheckpoint.fetch(lpCkptPda(pool, late.publicKey)); + const lpE = await balance(getAssociatedTokenAddressSync(lpMint, early.publicKey)); + const lpL = await balance(getAssociatedTokenAddressSync(lpMint, late.publicKey)); + assert.isTrue(lpE > 0n && lpL > 0n, "both wallets hold LP"); + + // Normalised so the comparison is time, not deposit size (LP minted differs slightly + // as reserves move between the two deposits). + const perLpE = BigInt(ckE.weightedBalance.toString()) / lpE; + const perLpL = BigInt(ckL.weightedBalance.toString()) / lpL; + assert.isTrue( + perLpE > perLpL * 5n, + `the ~7-day LP must bank multiples of the ~0.9-day LP (got ${perLpE} vs ${perLpL}) — ` + + `equal weights mean checkpoint_lp is back-crediting to epoch_start`, + ); + console.log( + `✅ [lp-emission][security] weight/LP: early ${perLpE} vs late ${perLpL} ` + + `(ratio ${Number(perLpE / (perLpL === 0n ? 1n : perLpL))}×) — no back-credit`, + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 39a5661..2000f10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -179,7 +179,7 @@ "@solana/spl-token-metadata" "^0.1.6" buffer "^6.0.3" -"@solana/web3.js@^1.32.0", "@solana/web3.js@^1.69.0": +"@solana/web3.js@^1.32.0", "@solana/web3.js@^1.68.0", "@solana/web3.js@^1.69.0": version "1.98.4" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.98.4.tgz#df51d78be9d865181ec5138b4e699d48e6895bbe" integrity sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw== @@ -279,6 +279,11 @@ agentkeepalive@^4.5.0: dependencies: humanize-ms "^1.2.1" +anchor-bankrun@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/anchor-bankrun/-/anchor-bankrun-0.5.0.tgz#62b5905f6f0ed3799d4a37e6be045887c13d4f33" + integrity sha512-cNTRv7pN9dy+kiyJ3UlNVTg9hAXhY2HtNVNXJbP/2BkS9nOdLV0qKWhgW8UR9Go0gYuEOLKuPzrGL4HFAZPsVw== + ansi-colors@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" @@ -1011,6 +1016,45 @@ serialize-javascript@6.0.0: dependencies: randombytes "^2.1.0" +solana-bankrun-darwin-arm64@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-darwin-arm64/-/solana-bankrun-darwin-arm64-0.4.0.tgz#eb0f3dfffb1675f6329a1e026b12d09222b33986" + integrity sha512-6dz78Teoz7ez/3lpRLDjktYLJb79FcmJk2me4/YaB8WiO6W43OdExU4h+d2FyuAryO2DgBPXaBoBNY/8J1HJmw== + +solana-bankrun-darwin-universal@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-darwin-universal/-/solana-bankrun-darwin-universal-0.4.0.tgz#0ac13ec7637b334b1030e6f51abecc50a254b5de" + integrity sha512-zSSw/Jx3KNU42pPMmrEWABd0nOwGJfsj7nm9chVZ3ae7WQg3Uty0hHAkn5NSDCj3OOiN0py9Dr1l9vmRJpOOxg== + +solana-bankrun-darwin-x64@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-darwin-x64/-/solana-bankrun-darwin-x64-0.4.0.tgz#f863c5a668858b7c44be51376bd05fb077c11c99" + integrity sha512-LWjs5fsgHFtyr7YdJR6r0Ho5zrtzI6CY4wvwPXr8H2m3b4pZe6RLIZjQtabCav4cguc14G0K8yQB2PTMuGub8w== + +solana-bankrun-linux-x64-gnu@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-linux-x64-gnu/-/solana-bankrun-linux-x64-gnu-0.4.0.tgz#30fd7edaf3ff6585468138d3bed6eaed37878d9e" + integrity sha512-SrlVrb82UIxt21Zr/XZFHVV/h9zd2/nP25PMpLJVLD7Pgl2yhkhfi82xj3OjxoQqWe+zkBJ+uszA0EEKr67yNw== + +solana-bankrun-linux-x64-musl@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-linux-x64-musl/-/solana-bankrun-linux-x64-musl-0.4.0.tgz#3c870218140b1307dc44b51d2282697c99f2e1e4" + integrity sha512-Nv328ZanmURdYfcLL+jwB1oMzX4ZzK57NwIcuJjGlf0XSNLq96EoaO5buEiUTo4Ls7MqqMyLbClHcrPE7/aKyA== + +solana-bankrun@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/solana-bankrun/-/solana-bankrun-0.4.0.tgz#a48a7a74ce6c56be4ec7e200336026f65e90b8dc" + integrity sha512-NMmXUipPBkt8NgnyNO3SCnPERP6xT/AMNMBooljGA3+rG6NN8lmXJsKeLqQTiFsDeWD74U++QM/DgcueSWvrIg== + dependencies: + "@solana/web3.js" "^1.68.0" + bs58 "^4.0.1" + optionalDependencies: + solana-bankrun-darwin-arm64 "0.4.0" + solana-bankrun-darwin-universal "0.4.0" + solana-bankrun-darwin-x64 "0.4.0" + solana-bankrun-linux-x64-gnu "0.4.0" + solana-bankrun-linux-x64-musl "0.4.0" + source-map-support@^0.5.6: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" From a9fff435cdfc3a07dd184786494bf0b399364193 Mon Sep 17 00:00:00 2001 From: OxToF <160028560+OxToF@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:58:13 +0200 Subject: [PATCH 3/3] =?UTF-8?q?docs(security-watch):=202026-07-22=20?= =?UTF-8?q?=E2=80=94=20Finding=20A=20test=20gate=20closed,=20IDL=20desync?= =?UTF-8?q?=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two entries. The missing per-epoch coverage is written (bankrun), and a delivery defect was found on the way: the committed IDL carried lp_user_info for add_liquidity, claim_lp_rewards and remove_liquidity but NOT for checkpoint_lp, precisely the subject of 8e4454d. The production bundle was inspected and serves checkpoint_lp with 9 accounts while the deployed program expects 10. Nothing caught it because LpEmissions.tsx passes lpUserInfo through an `as any` cast, and the component is not mounted in page.tsx, so no tester could reach the path. Same failure mode as the "always rebuild the IDL" rule, one notch more devious: the IDL had been regenerated and partially committed. Check the IDL that is SERVED, not the local one. Co-Authored-By: Claude Opus 4.8 --- SECURITY_WATCH.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/SECURITY_WATCH.md b/SECURITY_WATCH.md index 042f7b8..f827dcc 100644 --- a/SECURITY_WATCH.md +++ b/SECURITY_WATCH.md @@ -1189,3 +1189,27 @@ Reprise après deux runs planifiés avortés (interrompus avant production de ra - **Low non corrigé** : `lib.rs:~1731` `claim_partner_allocation` fait `lock.permanent_amount = base_vested` (affectation), alors que team/contributeur font `+=`. Si l'authority enregistre un jour le même wallet comme partenaire **et** contributeur, le claim partenaire écrase le verrou permanent de l'autre tranche → libérable. À corriger si le cas devient possible. **Conclusion du jour : 1 Critical trouvé, confirmé par PoC on-chain, corrigé et déployé le jour même ; 1 défaut latent de même classe corrigé au passage. Protocole unpaused, fix live, exploit neutralisé (preuve inverse `NothingToClaim`).** + +### 2026-07-22 — Fermeture de la gate de test Finding A (harness bankrun) + IDL frontend désynchronisé + +Suite directe de l'entrée du 21-07. Deux points : la couverture manquante sur le cycle par epoch est écrite, et un défaut de livraison a été trouvé au passage sur l'IDL servi en production. + +#### Gate levée — le cycle par epoch est désormais couvert + +- **Le blocage était structurel** : `Anchor.toml` épingle `cluster = "devnet"`, donc la suite mocha tourne contre l'horloge réelle et ne peut pas franchir une frontière d'epoch de 7 jours. Le chemin `checkpoint_lp` → `emit_pool_rewards` → `claim_lp_emissions` restait donc non testé, ce qui interdisait d'armer `configure_emissions` avec `initial > 0` en mainnet. +- **Correctif** : `tests/lp_emissions_bankrun.ts` (nouveau, `yarn test:bankrun`, ~1 s, sans validateur). `context.setClock()` déplace l'epoch à la demande. 7 tests verts : + - cycle complet sur une vraie frontière d'epoch : allocation = émission non décrue en epoch 0, puis décrue de 1 % en epoch 1 (épingle aussi `decayed_emission`) ; + - replay du même `(user, pool, epoch)` rejeté — et **la PDA `LpEpochClaim` (`init`) tranche en premier, à la validation des comptes**, pas le `NothingToClaim` du corps. L'hypothèse inverse a été posée puis démentie par le test ; + - invariant Σ claims ≤ `osola_allocated` sur un epoch à 2 LPs (le clamp lui-même ne mord pas : deux LPs honnêtes sous-souscrivent le pot, direction sûre) ; + - un retrait décrémente bien `lp_amount` (chemin de migration `remove` puis `add`) ; + - `reward_basis` suit le solde du wallet **à la baisse** — jambe inverse de Finding B, jamais couverte : dépôt tracké élevé mais LP sortis du wallet → poids banqué nul ; + - une position à `lp_amount = 0` (legacy ou LP reçus par transfert) peut toujours retirer. +- **Vérifié porteur par mutation** : remettre `window_start = epoch_start` dans `checkpoint_lp` (le bug Finding A) et rebuild → le test « late depositor » échoue (poids/LP 1114620 vs 1114560, soit égalité = signature du back-crédit). **Les 6 autres tests restent verts sur cette mutation** — un test de cycle qui se contente d'asserter « gained > 0 » ne discrimine pas ; c'est l'écart entre un LP à ~7 jours et un LP à ~0,9 jour qui le fait (ratio 7,7× attendu). +- **Piège d'outillage** : bankrun **ne charge pas** un binaire SBPFv3. `cargo build-sbf --arch v3`, obligatoire pour un deploy devnet, produit un `.so` rejeté avec le message trompeur « Program is not deployed ». Builder sans `--arch v3` pour tester, le remettre pour déployer. + +#### IDL frontend désynchronisé sur `checkpoint_lp` — livraison, pas exploitation + +- **Constat** : `app/lib/soladrome.json` committé le 21-07 contenait `lp_user_info` pour `add_liquidity`, `claim_lp_rewards` et `remove_liquidity` — **mais pas pour `checkpoint_lp`**, précisément l'objet du commit `8e4454d`. Le bundle servi en production a été inspecté : `checkpoint_lp` y expose 9 comptes, sans `lp_user_info`, alors que le programme déployé (slot 477943093) en attend 10. +- **Pourquoi rien n'a bronché** : `LpEmissions.tsx` passe `lpUserInfo` dans un objet casté `as any`, donc TypeScript ne voit pas le compte manquant, et le composant n'est monté nulle part dans `page.tsx` — aucun testeur ne pouvait déclencher le chemin. Impact réel nul à ce jour, cassé dès que le composant est remonté. +- **Correctif** : `app/lib/soladrome.json` resynchronisé depuis `target/idl/soladrome.json`. **L'IDL publié on-chain est stale lui aussi** (`checkpoint_lp` à 9 comptes) — cosmétique, n'affecte que les explorateurs et clients tiers, à remettre d'équerre par `anchor idl upgrade`. +- **Leçon** : c'est le même mode de défaillance que la note « toujours rebuild l'IDL après un changement de struct », mais d'un cran plus fourbe — l'IDL avait été régénéré et partiellement committé. Vérifier l'IDL **servi**, pas l'IDL local.