/** * Anchor integration tests for the draft Science Markets programs. * * Requires a validator and the Anchor CLI (`anchor test`). Two groups: * * "science markets draft programs" — the positive path. It asserts the two * things the design rests on, on the published illustration (a 100-unit UP * position entered at 0.40 and closed at 0.72): * * 1. Conservation: opening locks exactly N; settling splits exactly N into * trader payout + close fee + dealer payout. * 2. The fee basis: a fee is a percentage of dollars per leg — the trader's * contribution and the gross proceeds — never a percentage of N. * * "adversarial cases" — the negative path. Each case is an attack or a way to * use the programs that must be refused. They exist because the first run of * this suite caught real bugs (a missing authority check on settlement, a * vault that under-funded the reserve, a break-even score that was wrong for * DOWN positions, and a dropped mint check on the fee vault), and the seeds * did not include the side, so two positions could collide. */ import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { ScienceMarket } from "../target/types/science_market"; import { ScienceDealer } from "../target/types/science_dealer"; import { createMint, getOrCreateAssociatedTokenAccount, mintTo, getAccount, } from "@solana/spl-token"; import { assert } from "chai"; const USDC = (whole: number) => new anchor.BN(Math.round(whole * 1_000_000)); /** 8-byte little-endian encoding of a u64 (Anchor's integer seed width). */ function u64le(n: anchor.BN): Buffer { return n.toArrayLike(Buffer, "le", 8); } function u16le(n: number): Buffer { const b = Buffer.alloc(2); b.writeUInt16LE(n, 0); return b; } /** * Seeds for a Position PDA, matching `OpenPosition` in science_market. Every * component is fixed width and `side`/`fee_bps` are included, so two distinct * opens never map to the same account. */ function positionSeeds( market: anchor.web3.PublicKey, trader: anchor.web3.PublicKey, side: number, entryScore: number, contribution: anchor.BN, feeBps: number ): Buffer[] { return [ Buffer.from("position"), market.toBuffer(), trader.toBuffer(), Buffer.from([side]), u16le(entryScore), u16le(feeBps), u64le(contribution), ]; } function derivePosition( programId: anchor.web3.PublicKey, market: anchor.web3.PublicKey, trader: anchor.web3.PublicKey, side: number, entryScore: number, contribution: anchor.BN, feeBps: number ): anchor.web3.PublicKey { return anchor.web3.PublicKey.findProgramAddressSync( positionSeeds(market, trader, side, entryScore, contribution, feeBps), programId )[0]; } describe("science markets draft programs", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const marketProgram = anchor.workspace.ScienceMarket as Program; const dealerProgram = anchor.workspace.ScienceDealer as Program; const conn = provider.connection; const authority = (provider.wallet as anchor.Wallet).payer; const dealerKey = anchor.web3.Keypair.generate(); const MARKET_ID = new anchor.BN(1); const FEE_BPS = 200; // 2% per leg const SCORE_40 = 4_000; // 0.40 const SCORE_72 = 7_200; // 0.72 const SIDE_UP = 0; const SIDE_DOWN = 1; let mint: anchor.web3.PublicKey; let authorityToken: anchor.web3.PublicKey; let feeVault: anchor.web3.PublicKey; let market: anchor.web3.PublicKey; let vault: anchor.web3.PublicKey; let dealerToken: anchor.web3.PublicKey; let dealerPda: anchor.web3.PublicKey; let reserve: anchor.web3.PublicKey; async function fund(kp: anchor.web3.Keypair, sol = 2) { const sig = await conn.requestAirdrop(kp.publicKey, sol * anchor.web3.LAMPORTS_PER_SOL); await conn.confirmTransaction(sig); } async function ata(owner: anchor.web3.PublicKey): Promise { return ( await getOrCreateAssociatedTokenAccount(conn, authority, mint, owner) ).address; } async function tokenAmount(account: anchor.web3.PublicKey): Promise { const { amount } = await getAccount(conn, account); return new anchor.BN(amount.toString()); } /** * A fresh trader with its own token account, funds, and eligibility * credential. Fresh traders keep position PDAs distinct between tests, which * is what lets several tests use the same simple parameters. */ async function makeTrader(tokens = 1_000): Promise<{ kp: anchor.web3.Keypair; token: anchor.web3.PublicKey; eligibility: anchor.web3.PublicKey; }> { const kp = anchor.web3.Keypair.generate(); await fund(kp); const token = await ata(kp.publicKey); await mintTo(conn, authority, mint, token, authority, USDC(tokens).toNumber()); const [eligibility] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("eligibility"), market.toBuffer(), kp.publicKey.toBuffer()], marketProgram.programId ); await marketProgram.methods .setEligibility(true, new anchor.BN(0)) .accountsPartial({ authority: authority.publicKey, market, trader: kp.publicKey, eligibility }) .rpc(); return { kp, token, eligibility }; } async function openPosition(params: { trader: anchor.web3.Keypair; traderToken: anchor.web3.PublicKey; eligibility: anchor.web3.PublicKey; side: number; score: number; contribution: anchor.BN; feeBps?: number; minContribution?: anchor.BN; position?: anchor.web3.PublicKey; marketAccount?: anchor.web3.PublicKey; vaultAccount?: anchor.web3.PublicKey; feeVaultAccount?: anchor.web3.PublicKey; dealerAccount?: anchor.web3.Keypair; }) { const feeBps = params.feeBps ?? FEE_BPS; const minContribution = params.minContribution ?? params.contribution; const position = params.position ?? derivePosition( marketProgram.programId, params.marketAccount ?? market, params.trader.publicKey, params.side, params.score, params.contribution, feeBps ); const dealerAccount = params.dealerAccount ?? dealerKey; return marketProgram.methods .openPosition(params.side, params.score, params.contribution, feeBps, minContribution) .accountsPartial({ market: params.marketAccount ?? market, trader: params.trader.publicKey, position, dealer: dealerAccount.publicKey, eligibility: params.eligibility, traderToken: params.traderToken, dealerToken: await ata(dealerAccount.publicKey), vault: params.vaultAccount ?? vault, feeVault: params.feeVaultAccount ?? feeVault, tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID, }) .signers([params.trader, dealerAccount]) .rpc(); } before(async () => { await fund(dealerKey); mint = await createMint(conn, authority, authority.publicKey, null, 6); authorityToken = await ata(authority.publicKey); feeVault = ( await getOrCreateAssociatedTokenAccount(conn, authority, mint, authority.publicKey, true) ).address; [market] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("market"), authority.publicKey.toBuffer(), u64le(MARKET_ID)], marketProgram.programId ); [vault] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("vault"), market.toBuffer()], marketProgram.programId ); await marketProgram.methods .initializeMarket(MARKET_ID, "Draft proposition for the test fixture.", 5_000) .accountsPartial({ authority: authority.publicKey, market, settlementMint: mint, feeVault, vault }) .rpc(); // Dealer program: one dealer owned by the provider wallet, plus its reserve. [dealerPda] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("dealer"), authority.publicKey.toBuffer()], dealerProgram.programId ); [reserve] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("dealer_reserve"), authority.publicKey.toBuffer()], dealerProgram.programId ); await dealerProgram.methods .initializeDealer(FEE_BPS, 100) .accountsPartial({ authority: authority.publicKey, feeAuthority: authority.publicKey, settlementMint: mint, dealer: dealerPda, reserve, }) .rpc(); await mintTo(conn, authority, mint, authorityToken, authority, USDC(10_000).toNumber()); await dealerProgram.methods .deposit(USDC(1_000)) .accountsPartial({ authority: authority.publicKey, dealer: dealerPda, authorityToken, reserve }) .rpc(); dealerToken = await ata(dealerKey.publicKey); await mintTo(conn, authority, mint, dealerToken, authority, USDC(5_000).toNumber()); }); it("opens a fully funded position and locks exactly N", async () => { const { kp, token, eligibility } = await makeTrader(); const contribution = USDC(40); const position = derivePosition( marketProgram.programId, market, kp.publicKey, SIDE_UP, SCORE_40, contribution, FEE_BPS ); const vaultBefore = await tokenAmount(vault); const feeBefore = await tokenAmount(feeVault); const reserveBefore = (await marketProgram.account.market.fetch(market)).reserveLocked; await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution }); const account = await marketProgram.account.position.fetch(position); assert.equal(account.maxPayout.toString(), USDC(100).toString(), "N is 100"); assert.equal(account.traderContribution.toString(), USDC(40).toString(), "trader posts 40"); assert.equal(account.dealerContribution.toString(), USDC(60).toString(), "dealer reserves 60"); assert.equal( account.traderContribution.add(account.dealerContribution).toString(), account.maxPayout.toString(), "invariant: the two legs equal N" ); // The vault holds exactly N more than before. The entry fee is charged on // dollars (2% of the 40 contribution) and goes to the fee vault, so the // vault's delta isolates the locked collateral. const vaultAccount = await tokenAmount(vault); assert.equal( vaultAccount.sub(vaultBefore).toString(), USDC(100).toString(), "vault gains exactly N" ); assert.equal(account.entryFee.toString(), USDC(0.8).toString(), "entry fee is 2% of 40"); assert.equal( (await tokenAmount(feeVault)).sub(feeBefore).toString(), USDC(0.8).toString(), "entry fee lands in the fee vault, not the collateral vault" ); const marketState = await marketProgram.account.market.fetch(market); assert.equal( marketState.reserveLocked.sub(reserveBefore).toString(), USDC(100).toString(), "reserve_locked rises by N" ); }); it("settles at a score and pays out exactly N, with fees on dollars", async () => { const { kp, token, eligibility } = await makeTrader(); const contribution = USDC(40); const position = derivePosition( marketProgram.programId, market, kp.publicKey, SIDE_UP, SCORE_40, contribution, FEE_BPS ); await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution }); const traderBefore = await tokenAmount(token); const dealerBefore = await tokenAmount(dealerToken); const feeBefore = await tokenAmount(feeVault); const reserveBefore = (await marketProgram.account.market.fetch(market)).reserveLocked; await marketProgram.methods .settlePosition(SCORE_72) .accountsPartial({ market, authority: authority.publicKey, position, owner: kp.publicKey, vault, traderToken: token, dealerToken, feeVault, tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID, }) .rpc(); const account = await marketProgram.account.position.fetch(position); assert.equal(account.traderGross.toString(), USDC(72).toString(), "gross is 72"); assert.equal(account.closeFee.toString(), USDC(1.44).toString(), "close fee is 2% of 72"); assert.equal(account.traderPayout.toString(), USDC(70.56).toString(), "trader nets 70.56"); assert.equal(account.dealerPayout.toString(), USDC(28).toString(), "dealer receives 28"); assert.equal( account.traderPayout.add(account.closeFee).add(account.dealerPayout).toString(), account.maxPayout.toString(), "invariant: the split consumes N exactly" ); // Money actually moved, in the right direction. assert.equal((await tokenAmount(token)).sub(traderBefore).toString(), USDC(70.56).toString()); assert.equal((await tokenAmount(dealerToken)).sub(dealerBefore).toString(), USDC(28).toString()); assert.equal((await tokenAmount(feeVault)).sub(feeBefore).toString(), USDC(1.44).toString()); const marketState = await marketProgram.account.market.fetch(market); assert.equal( reserveBefore.sub(marketState.reserveLocked).toString(), USDC(100).toString(), "settling releases exactly this position's reserve" ); }); it("refuses a second settlement of the same position", async () => { const { kp, token, eligibility } = await makeTrader(); const contribution = USDC(40); const position = derivePosition( marketProgram.programId, market, kp.publicKey, SIDE_UP, SCORE_40, contribution, FEE_BPS ); await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution }); const settle = () => marketProgram.methods .settlePosition(SCORE_72) .accountsPartial({ market, authority: authority.publicKey, position, owner: kp.publicKey, vault, traderToken: token, dealerToken, feeVault, tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID, }) .rpc(); await settle(); // first settlement succeeds let threw = false; try { await settle(); } catch (err) { threw = true; assert.match(String(err), /PositionNotOpen|already closed/i); } assert.isTrue(threw, "a closed position cannot pay out twice"); }); it("dealer: quotes the fee on dollars and reports the break-even score", async () => { const fees = await dealerProgram.methods .quoteFees(SIDE_UP, SCORE_40, USDC(40), USDC(72)) .accounts({ dealer: dealerPda }) .view(); assert.equal(fees.entryFee.toString(), USDC(0.8).toString(), "2% of the 40 contribution"); assert.equal(fees.closeFee.toString(), USDC(1.44).toString(), "2% of the 72 gross"); assert.equal(fees.traderNetOnClose.toString(), USDC(70.56).toString(), "trader nets 70.56"); // The published break-even identity is side-independent: // p * (1 + f) / (1 - f). Both sides report the same score for the same // entry, which is what the Django engine and the site disclose. const upFees = await dealerProgram.methods .quoteFees(SIDE_UP, 5_000, USDC(50), USDC(50)) .accounts({ dealer: dealerPda }) .view(); assert.equal(upFees.breakEvenScoreMillis, 5_204, "p=0.50 at 2% breaks even at 0.5204"); const downFees = await dealerProgram.methods .quoteFees(SIDE_DOWN, 5_000, USDC(50), USDC(50)) .accounts({ dealer: dealerPda }) .view(); assert.equal(downFees.breakEvenScoreMillis, 5_204, "break-even is side-independent per the published terms"); const quote = await dealerProgram.methods .quote(SIDE_UP, SCORE_40, USDC(100)) .accounts({ dealer: dealerPda }) .view(); assert.equal(quote.entryScoreMillis, SCORE_40); // "One point worse for the trader": an UP position exits at a lower score // (0.39), a DOWN position at a higher one (0.41). This matches the Django // engine's close_with_quote, which fills a 0.70 quote at 0.69 for UP. assert.equal(quote.exitScoreMillis, SCORE_40 - 100, "UP exit is one point lower for the trader"); assert.equal(quote.reserveRequired.toString(), USDC(60).toString(), "reserve is the complement"); const downQuote = await dealerProgram.methods .quote(SIDE_DOWN, SCORE_40, USDC(100)) .accounts({ dealer: dealerPda }) .view(); assert.equal(downQuote.exitScoreMillis, SCORE_40 + 100, "DOWN exit is one point higher for the trader"); }); it("dealer: committed reserve cannot be withdrawn", async () => { // Deposit is idempotent from the caller's view: reserve already holds 1000 // from the fixture, so top it up to a round figure and commit from there. const free = USDC(1_000); await dealerProgram.methods .commitReserve(free) .accountsPartial({ authority: authority.publicKey, dealer: dealerPda }) .rpc(); // Now committed == reserve_balance, so nothing is withdrawable. let threw = false; try { await dealerProgram.methods .withdrawFree(USDC(1)) .accountsPartial({ authority: authority.publicKey, dealer: dealerPda, authorityToken, reserve }) .rpc(); } catch (err) { threw = true; assert.match(String(err), /CommittedReserve|committed/i); } assert.isTrue(threw, "withdrawing past free reserve is refused"); await dealerProgram.methods .releaseReserve(free) .accountsPartial({ authority: authority.publicKey, dealer: dealerPda }) .rpc(); }); it("dealer: withdraw_free cannot route tokens to a third party", async () => { // Attack: sign `withdraw_free` but pass someone else's token account as // `authority_token`, to pay yourself out of the dealer reserve. The // `token::authority = authority` constraint must refuse it (Anchor // ConstraintTokenOwner, 2015). Confirmed against a real validator. const free = USDC(1_000); await dealerProgram.methods .commitReserve(free) .accountsPartial({ authority: authority.publicKey, dealer: dealerPda }) .rpc(); const stranger = anchor.web3.Keypair.generate(); const strangerToken = ( await getOrCreateAssociatedTokenAccount(conn, authority, mint, stranger.publicKey) ).address; let threw = false; try { await dealerProgram.methods .withdrawFree(USDC(500)) .accountsPartial({ authority: authority.publicKey, dealer: dealerPda, authorityToken: strangerToken, reserve }) .rpc(); } catch (err) { threw = true; assert.match(String(err), /ConstraintTokenOwner|token owner/i); } assert.isTrue(threw, "reserve cannot be routed to a token account the signer does not own"); assert.equal((await tokenAmount(strangerToken)).toString(), "0", "nothing reached the stranger"); await dealerProgram.methods .releaseReserve(free) .accountsPartial({ authority: authority.publicKey, dealer: dealerPda }) .rpc(); }); // ------------------------------------------------------------------------ // Adversarial cases. Each must be refused. // ------------------------------------------------------------------------ describe("adversarial cases", () => { it("rejects settlement signed by anyone other than the market authority", async () => { const { kp, token, eligibility } = await makeTrader(); const contribution = USDC(40); const position = derivePosition( marketProgram.programId, market, kp.publicKey, SIDE_UP, SCORE_40, contribution, FEE_BPS ); await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution }); const intruder = anchor.web3.Keypair.generate(); await fund(intruder); let threw = false; try { await marketProgram.methods .settlePosition(SCORE_72) .accountsPartial({ market, authority: intruder.publicKey, // not the market authority position, owner: kp.publicKey, vault, traderToken: token, dealerToken, feeVault, tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID, }) .signers([intruder]) .rpc(); } catch (err) { threw = true; assert.match(String(err), /Unauthorized|Authority/i); } assert.isTrue(threw, "a stranger cannot settle a position at a score of their choosing"); }); it("rejects spending another market's vault", async () => { // A second market with its own vault. Settling market #1's position while // passing market #2's vault must fail the vault seed constraint. const otherMarketId = new anchor.BN(777); const [market2] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("market"), authority.publicKey.toBuffer(), u64le(otherMarketId)], marketProgram.programId ); const [vault2] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("vault"), market2.toBuffer()], marketProgram.programId ); const feeVault2 = ( await getOrCreateAssociatedTokenAccount(conn, authority, mint, authority.publicKey, true) ).address; await marketProgram.methods .initializeMarket(otherMarketId, "A second market.", 5_000) .accountsPartial({ authority: authority.publicKey, market: market2, settlementMint: mint, feeVault: feeVault2, vault: vault2 }) .rpc(); const { kp, token, eligibility } = await makeTrader(); const contribution = USDC(40); const position = derivePosition( marketProgram.programId, market, kp.publicKey, SIDE_UP, SCORE_40, contribution, FEE_BPS ); await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution }); let threw = false; try { await marketProgram.methods .settlePosition(SCORE_72) .accountsPartial({ market, // market #1 authority: authority.publicKey, position, owner: kp.publicKey, vault: vault2, // but vault #2 traderToken: token, dealerToken, feeVault: feeVault2, tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID, }) .rpc(); } catch (err) { threw = true; } assert.isTrue(threw, "the vault is bound to its market by seed"); }); it("rejects a fee rate above the cap", async () => { const { kp, token, eligibility } = await makeTrader(); let threw = false; try { await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution: USDC(40), feeBps: 1_001, // one basis point over the 10% cap }); } catch (err) { threw = true; assert.match(String(err), /FeeTooHigh/i); } assert.isTrue(threw, "a fat-fingered fee is refused"); }); it("rejects a score at either excluded endpoint", async () => { const { kp, token, eligibility } = await makeTrader(); for (const badScore of [0, 10_000]) { let threw = false; try { await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: badScore, contribution: USDC(40), }); } catch (err) { threw = true; } assert.isTrue(threw, `score ${badScore} should be refused`); } }); it("rejects an opening below the caller's slippage floor", async () => { const { kp, token, eligibility } = await makeTrader(); let threw = false; try { await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution: USDC(40), minContribution: USDC(41), // floor above the computed 40 leg }); } catch (err) { threw = true; assert.match(String(err), /SlippageExceeded/i); } assert.isTrue(threw, "a worse-than-requested fill is refused"); }); it("rejects an opening with no eligibility credential", async () => { const stranger = anchor.web3.Keypair.generate(); await fund(stranger); const token = await ata(stranger.publicKey); await mintTo(conn, authority, mint, token, authority, USDC(100).toNumber()); // No set_eligibility call for this trader. The PDA does not exist. const [strangerEligibility] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("eligibility"), market.toBuffer(), stranger.publicKey.toBuffer()], marketProgram.programId ); const contribution = USDC(40); const position = derivePosition( marketProgram.programId, market, stranger.publicKey, SIDE_UP, SCORE_40, contribution, FEE_BPS ); let threw = false; try { await marketProgram.methods .openPosition(SIDE_UP, SCORE_40, contribution, FEE_BPS, contribution) .accountsPartial({ market, trader: stranger.publicKey, position, dealer: dealerKey.publicKey, eligibility: strangerEligibility, traderToken: token, dealerToken, vault, feeVault, tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID, }) .signers([stranger, dealerKey]) .rpc(); } catch (err) { threw = true; assert.match(String(err), /AccountNotInitialized|not exist|0xbc4/i); } assert.isTrue(threw, "a direct call with no credential is refused"); }); it("rejects a non-authority pausing the market", async () => { const intruder = anchor.web3.Keypair.generate(); await fund(intruder); let threw = false; try { await marketProgram.methods .setPaused(true) .accounts({ market, authority: intruder.publicKey }) .signers([intruder]) .rpc(); } catch (err) { threw = true; } assert.isTrue(threw, "only the market authority may pause"); }); it("lets one trader hold both sides at the same score and stake (no PDA collision)", async () => { // Regression: before `side` was a seed, an UP and a DOWN at the same score // and contribution derived the same PDA, so the second open failed. const { kp, token, eligibility } = await makeTrader(); // UP posts 0.40 * N and DOWN posts 0.60 * N; pick contributions that make // each leg exact so the default slippage floor (contribution) is met. await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: SCORE_40, contribution: USDC(40) }); await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_DOWN, score: SCORE_40, contribution: USDC(60) }); const up = derivePosition(marketProgram.programId, market, kp.publicKey, SIDE_UP, SCORE_40, USDC(40), FEE_BPS); const down = derivePosition(marketProgram.programId, market, kp.publicKey, SIDE_DOWN, SCORE_40, USDC(60), FEE_BPS); assert.notEqual(up.toString(), down.toString(), "UP and DOWN derive distinct accounts"); const upAccount = await marketProgram.account.position.fetch(up); const downAccount = await marketProgram.account.position.fetch(down); assert.equal(upAccount.side, SIDE_UP); assert.equal(downAccount.side, SIDE_DOWN); // DOWN posts the complement: (1 - 0.40) * 100 = 60. assert.equal(downAccount.traderContribution.toString(), USDC(60).toString()); assert.equal(downAccount.dealerContribution.toString(), USDC(40).toString()); }); it("rejects an open whose trader leg rounds to zero", async () => { // Regression: a one-base-unit contribution at the extreme score 0.9999 // gives N = 1 and a recomputed trader leg of 0, so the dealer would fund // the whole notional and every settlement would pay the trader out of the // dealer's money. `open_position` now requires both legs to be positive. const { kp, token, eligibility } = await makeTrader(); const contribution = new anchor.BN(1); // one base unit const position = derivePosition( marketProgram.programId, market, kp.publicKey, SIDE_UP, 9_999, contribution, FEE_BPS ); let threw = false; try { await openPosition({ trader: kp, traderToken: token, eligibility, side: SIDE_UP, score: 9_999, contribution, minContribution: new anchor.BN(0), position, }); } catch (err) { threw = true; assert.match(String(err), /ZeroAmount|zero/i); } assert.isTrue(threw, "a position with a zero trader leg cannot open"); }); }); });