brainblast 0.7.5 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/{chunk-HFYBK2VA.js → chunk-A3U6JDMN.js} +64 -12
  2. package/dist/{chunk-S7X53LWF.js → chunk-CSYGLMZR.js} +19 -19
  3. package/dist/{chunk-V4XS5DKD.js → chunk-Q5DMQN67.js} +19 -5
  4. package/dist/cli.js +60 -24
  5. package/dist/feeConfigs-S4E5GQ7Q.js +19 -0
  6. package/dist/index.d.ts +21 -13
  7. package/dist/index.js +19 -15
  8. package/dist/{mcp-LITBQHBF.js → mcp-O45PSOVU.js} +1 -1
  9. package/dist/packs/README.md +33 -0
  10. package/dist/packs/jito-bundle-zero-tip/README.md +33 -0
  11. package/dist/packs/jito-bundle-zero-tip/brainblast-pack.yaml +5 -0
  12. package/dist/packs/jito-bundle-zero-tip/fixtures/jito-bundle-zero-tip/fixed/bundle.ts +13 -0
  13. package/dist/packs/jito-bundle-zero-tip/fixtures/jito-bundle-zero-tip/vulnerable/bundle.ts +16 -0
  14. package/dist/packs/jito-bundle-zero-tip/rules/jito-bundle-zero-tip.yaml +54 -0
  15. package/dist/packs/jupiter-quote-zero-slippage/brainblast-pack.yaml +5 -0
  16. package/dist/packs/jupiter-quote-zero-slippage/fixtures/jupiter-quote-zero-slippage/fixed/arb.ts +17 -0
  17. package/dist/packs/jupiter-quote-zero-slippage/fixtures/jupiter-quote-zero-slippage/vulnerable/arb.ts +17 -0
  18. package/dist/packs/jupiter-quote-zero-slippage/rules/jupiter-quote-zero-slippage.yaml +51 -0
  19. package/dist/packs/metaplex-nft-royalty-zero/README.md +39 -0
  20. package/dist/packs/metaplex-nft-royalty-zero/brainblast-pack.yaml +5 -0
  21. package/dist/packs/metaplex-nft-royalty-zero/fixtures/metaplex-nft-royalty-zero/fixed/mint.ts +11 -0
  22. package/dist/packs/metaplex-nft-royalty-zero/fixtures/metaplex-nft-royalty-zero/vulnerable/mint.ts +11 -0
  23. package/dist/packs/metaplex-nft-royalty-zero/rules/metaplex-nft-royalty-zero.yaml +39 -0
  24. package/dist/packs/meteora-dlmm-zero-min-out/README.md +32 -0
  25. package/dist/packs/meteora-dlmm-zero-min-out/brainblast-pack.yaml +5 -0
  26. package/dist/packs/meteora-dlmm-zero-min-out/fixtures/meteora-dlmm-zero-min-out/fixed/swap.ts +19 -0
  27. package/dist/packs/meteora-dlmm-zero-min-out/fixtures/meteora-dlmm-zero-min-out/vulnerable/swap.ts +19 -0
  28. package/dist/packs/meteora-dlmm-zero-min-out/rules/meteora-dlmm-zero-min-out.yaml +47 -0
  29. package/dist/packs/pyth-price-unchecked-staleness/README.md +34 -0
  30. package/dist/packs/pyth-price-unchecked-staleness/brainblast-pack.yaml +5 -0
  31. package/dist/packs/pyth-price-unchecked-staleness/fixtures/pyth-price-unchecked-staleness/fixed/price.ts +14 -0
  32. package/dist/packs/pyth-price-unchecked-staleness/fixtures/pyth-price-unchecked-staleness/vulnerable/price.ts +14 -0
  33. package/dist/packs/pyth-price-unchecked-staleness/rules/pyth-price-unchecked-staleness.yaml +47 -0
  34. package/dist/packs/raydium-compute-zero-slippage/README.md +43 -0
  35. package/dist/packs/raydium-compute-zero-slippage/brainblast-pack.yaml +5 -0
  36. package/dist/packs/raydium-compute-zero-slippage/fixtures/raydium-compute-zero-slippage/fixed/swap.ts +12 -0
  37. package/dist/packs/raydium-compute-zero-slippage/fixtures/raydium-compute-zero-slippage/vulnerable/swap.ts +12 -0
  38. package/dist/packs/raydium-compute-zero-slippage/rules/raydium-compute-zero-slippage.yaml +40 -0
  39. package/dist/packs/solana-sendtx-unconfirmed/README.md +34 -0
  40. package/dist/packs/solana-sendtx-unconfirmed/brainblast-pack.yaml +5 -0
  41. package/dist/packs/solana-sendtx-unconfirmed/fixtures/solana-sendtx-unconfirmed/fixed/payment.ts +10 -0
  42. package/dist/packs/solana-sendtx-unconfirmed/fixtures/solana-sendtx-unconfirmed/vulnerable/payment.ts +10 -0
  43. package/dist/packs/solana-sendtx-unconfirmed/rules/solana-sendtx-unconfirmed.yaml +40 -0
  44. package/dist/packs/spl-transfer-not-checked-in-payout/brainblast-pack.yaml +5 -0
  45. package/dist/packs/spl-transfer-not-checked-in-payout/fixtures/spl-transfer-not-checked-in-payout/fixed/payout-processor.ts +29 -0
  46. package/dist/packs/spl-transfer-not-checked-in-payout/fixtures/spl-transfer-not-checked-in-payout/vulnerable/payout-processor.ts +22 -0
  47. package/dist/packs/spl-transfer-not-checked-in-payout/rules/spl-transfer-not-checked-in-payout.yaml +49 -0
  48. package/dist/rules/metaplex-seller-fee-zero.yaml +2 -2
  49. package/package.json +1 -1
  50. package/dist/tokenEconomics-HBF3DYNH.js +0 -19
@@ -0,0 +1,33 @@
1
+ # Protocol Pack Library
2
+
3
+ Every Solana app is built on some combination of Jupiter, Raydium, Pyth, Meteora, Jito, … — each with its own silent footguns. A **pack per protocol** means you opt into research-and-enforcement for the exact stack you build on, before a line is written:
4
+
5
+ ```bash
6
+ brainblast --packs jupiter,pyth .
7
+ ```
8
+
9
+ Packs are **opt-in** (not loaded by default), **pure data** (rules bind only to brainblast's vetted checker templates — no executable code ships in a pack), and **proven** (every rule ships `vulnerable/` + `fixed/` fixtures that must go RED → GREEN). List them anytime:
10
+
11
+ ```bash
12
+ brainblast packs # the library, by protocol name
13
+ brainblast pack validate <dir> # re-prove a pack RED → GREEN
14
+ ```
15
+
16
+ ## Bundled packs
17
+
18
+ | Protocol | Pack | Trap |
19
+ |----------|------|------|
20
+ | **Jupiter** | `jupiter-quote-zero-slippage` | `quoteGet({ slippageBps: 0 })` — MEV/sandwich exposure |
21
+ | **Raydium** | `raydium-compute-zero-slippage` | `computeAmountOut({ slippage: 0 })` — no min-out floor |
22
+ | **Pyth** | `pyth-price-unchecked-staleness` | `getPriceUnchecked()` — trades on a stale oracle |
23
+ | **Meteora** | `meteora-dlmm-zero-min-out` | `swap({ minOutAmount: new BN(0) })` — no slippage floor |
24
+ | **Jito** | `jito-bundle-zero-tip` | `sendBundle({ tipLamports: new BN(0) })` — bundle never lands |
25
+ | **Metaplex** | `metaplex-nft-royalty-zero` | `create({ sellerFeeBasisPoints: 0 })` — zero royalties |
26
+ | **Solana** | `solana-sendtx-unconfirmed` | `sendTransaction()` without confirmation — silent drop |
27
+ | **SPL** | `spl-transfer-not-checked-in-payout` | `createTransferInstruction` vs `…Checked` |
28
+
29
+ `--packs <name>` resolves a protocol name (e.g. `pyth`) to its pack, or takes an explicit pack directory.
30
+
31
+ ## The compounding moat
32
+
33
+ Each pack someone contributes makes brainblast more valuable for the next dev building on that protocol. To add one: `brainblast pack init packs/<id> --id <id> …`, write a rule that binds to a vetted checker, add `fixtures/<id>/{vulnerable,fixed}/`, and `brainblast pack validate packs/<id>` until it proves RED → GREEN. See any pack here as a template.
@@ -0,0 +1,33 @@
1
+ # jito-bundle-zero-tip
2
+
3
+ **Severity:** HIGH
4
+
5
+ ## What's the trap?
6
+
7
+ Jito bundles are ordered by their **tip**. The block engine prioritizes bundles that pay more; a bundle that tips **0** is deprioritized and, under any competition, simply never lands.
8
+
9
+ The trap is that sending a zero-tip bundle still *succeeds* at the API level — you get a bundle id back — so the code proceeds as if the transactions submitted. They didn't. For an arbitrage or liquidation bot, "the bundle silently never landed" is the difference between a profit and a missed opportunity (or a half-executed position).
10
+
11
+ ## Why it's silent
12
+
13
+ `sendBundle(...)` returns a bundle id regardless of the tip. Nothing throws. The bundle just isn't included, and you only notice when on-chain state never reflects your transactions.
14
+
15
+ ## The fix
16
+
17
+ ```typescript
18
+ // BEFORE (vulnerable — no tip, bundle won't land)
19
+ return sendBundle({ transactions, tipLamports: new BN(0) });
20
+
21
+ // AFTER (fixed — nonzero tip the block engine can prioritize)
22
+ return sendBundle({ transactions, tipLamports: new BN(100_000) });
23
+ ```
24
+
25
+ In production, **scale the tip with competition** rather than hardcoding it.
26
+
27
+ ## Scope
28
+
29
+ This rule (`object-arg-property-forbidden-literal`, `BN(0)`-aware) targets the common wrapper signature `sendBundle({ ..., tipLamports })` and fires only in files that import a Jito SDK. The positional `Bundle.addTipTx(payer, lamports, tipAccount, blockhash)` form isn't matched by a single object-field rule — verify that path manually, or add a project-local rule for your exact tip-builder signature.
30
+
31
+ ## References
32
+
33
+ - [Jito — low-latency transaction send](https://docs.jito.wtf/lowlatencytxnsend/)
@@ -0,0 +1,5 @@
1
+ id: jito-bundle-zero-tip
2
+ name: Jito bundle zero tip
3
+ version: 0.1.0
4
+ author: DSB-117
5
+ description: "Flags Jito bundle sends with a tip of 0 (including new BN(0)) — a bundle with no tip is deprioritized by the block engine and typically never lands."
@@ -0,0 +1,13 @@
1
+ import { searcherClient } from "jito-ts/dist/sdk/block-engine/searcher.js";
2
+ import BN from "bn.js";
3
+
4
+ async function sendBundle(opts: { transactions: any[]; tipLamports: BN }) {
5
+ const client = searcherClient("https://mainnet.block-engine.jito.wtf");
6
+ return (client as any).sendBundle(opts.transactions, opts.tipLamports);
7
+ }
8
+
9
+ // FIXED — a nonzero tip (100,000 lamports) so the block engine can prioritize
10
+ // the bundle. In production, scale the tip with observed competition.
11
+ export async function submitArb(transactions: any[]) {
12
+ return sendBundle({ transactions, tipLamports: new BN(100_000) });
13
+ }
@@ -0,0 +1,16 @@
1
+ import { searcherClient } from "jito-ts/dist/sdk/block-engine/searcher.js";
2
+ import BN from "bn.js";
3
+
4
+ // A thin project wrapper around the Jito searcher client, as most teams write.
5
+ async function sendBundle(opts: { transactions: any[]; tipLamports: BN }) {
6
+ const client = searcherClient("https://mainnet.block-engine.jito.wtf");
7
+ // ... build bundle with the tip, then client.sendBundle(...)
8
+ return (client as any).sendBundle(opts.transactions, opts.tipLamports);
9
+ }
10
+
11
+ // VULNERABLE — tipLamports: new BN(0). The bundle has no tip, so the Jito block
12
+ // engine deprioritizes it and it never lands under competition. sendBundle
13
+ // still returns a bundle id, so the caller assumes the transactions submitted.
14
+ export async function submitArb(transactions: any[]) {
15
+ return sendBundle({ transactions, tipLamports: new BN(0) });
16
+ }
@@ -0,0 +1,54 @@
1
+ # Pure-data rule. Binds to the vetted `object-arg-property-forbidden-literal`
2
+ # checker (BN(0)-aware as of brainblast v0.7.6).
3
+ #
4
+ # Jito bundles are ordered by their tip. A bundle that tips 0 is deprioritized
5
+ # by the block engine and, under any competition, simply never lands — yet the
6
+ # send call returns a bundle id and the code proceeds as if it submitted.
7
+ # This rule targets the common wrapper signature `sendBundle({ ..., tipLamports })`
8
+ # (and `new BN(0)`). The positional `Bundle.addTipTx(payer, lamports, ...)` form
9
+ # is a complementary manual check — see the pack README.
10
+ #
11
+ # Scope note: this fires only in files that import a Jito SDK (detect.modules),
12
+ # so a zero-amount transfer elsewhere won't trip it.
13
+ id: jito-bundle-zero-tip
14
+ severity: high
15
+ title: Jito bundle sent with a tip of 0 — bundle is deprioritized and will not land
16
+ component:
17
+ name: Jito (block engine / bundles)
18
+ type: Blockchain
19
+ version: unversioned
20
+ sourceUrl: https://docs.jito.wtf/lowlatencytxnsend/
21
+ detect:
22
+ modules:
23
+ - "jito-ts"
24
+ - "jito-js-rpc"
25
+ - "@jito-foundation/sdk"
26
+ nameRegex: bundle|jito|send|submit|tip|mev
27
+ triggerCalls:
28
+ - sendBundle
29
+ - sendJitoBundle
30
+ check:
31
+ kind: object-arg-property-forbidden-literal
32
+ params:
33
+ call: sendBundle
34
+ argIndex: 0
35
+ propName: tipLamports
36
+ forbiddenValue: 0
37
+ passDetail: >-
38
+ sendBundle is called with a nonzero tipLamports — the bundle has a tip the
39
+ block engine can prioritize.
40
+ failDetail: >-
41
+ sendBundle is called with tipLamports of 0 (or new BN(0)). A Jito bundle
42
+ with no tip is deprioritized by the block engine and, under any
43
+ competition, never lands — but the call still returns a bundle id, so the
44
+ code proceeds as if the transactions submitted. Set a nonzero tip (and
45
+ ideally scale it with priority/competition).
46
+ absentCallDetail: >-
47
+ Handler is in Jito scope but does not call sendBundle; the zero-tip rule
48
+ does not apply here.
49
+ absentArgDetail: >-
50
+ sendBundle's options object does not set tipLamports as an inline literal;
51
+ the tip cannot be confirmed statically. Verify it resolves to a nonzero
52
+ value.
53
+ test:
54
+ kind: none
@@ -0,0 +1,5 @@
1
+ id: jupiter-quote-zero-slippage
2
+ name: Jupiter zero-slippage quote
3
+ version: 0.1.0
4
+ author: DSB-117
5
+ description: "Flags Jupiter aggregator quote/swap calls that hardcode slippageBps: 0, disabling sandwich/MEV protection."
@@ -0,0 +1,17 @@
1
+ import { createJupiterApiClient } from "@jup-ag/api";
2
+
3
+ const jupiterQuoteApi = createJupiterApiClient();
4
+
5
+ const wSolMint = "So11111111111111111111111111111111111111112";
6
+ const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
7
+
8
+ export async function getArbQuote(amountInJSBI: number) {
9
+ return jupiterQuoteApi.quoteGet({
10
+ inputMint: wSolMint,
11
+ outputMint: usdcMint,
12
+ amount: amountInJSBI,
13
+ onlyDirectRoutes: false,
14
+ slippageBps: 50,
15
+ maxAccounts: 20,
16
+ });
17
+ }
@@ -0,0 +1,17 @@
1
+ import { createJupiterApiClient } from "@jup-ag/api";
2
+
3
+ const jupiterQuoteApi = createJupiterApiClient();
4
+
5
+ const wSolMint = "So11111111111111111111111111111111111111112";
6
+ const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
7
+
8
+ export async function getArbQuote(amountInJSBI: number) {
9
+ return jupiterQuoteApi.quoteGet({
10
+ inputMint: wSolMint,
11
+ outputMint: usdcMint,
12
+ amount: amountInJSBI,
13
+ onlyDirectRoutes: false,
14
+ slippageBps: 0,
15
+ maxAccounts: 20,
16
+ });
17
+ }
@@ -0,0 +1,51 @@
1
+ # Pure-data rule (facts). Binds to the vetted `object-arg-property-forbidden-literal`
2
+ # checker template.
3
+ #
4
+ # Real-world instances of this exact pattern:
5
+ # - WSOL12/Solana-Arbitrage-Bot (510 stars, 208 forks), src/index.ts —
6
+ # `quoteUrl` fetch params include `slippageBps: 0`.
7
+ # - Forked verbatim into ChainBuff/sol-arb-bot, src/index.ts (same lines).
8
+ # - ARBProtocol/solana-jupiter-bot, src/bot/setup.js — same field on the
9
+ # Jupiter quote call.
10
+ id: jupiter-quote-zero-slippage
11
+ severity: high
12
+ title: Jupiter quote/swap call hardcodes slippageBps to 0
13
+ component:
14
+ name: Jupiter Aggregator API
15
+ type: Blockchain
16
+ version: unversioned
17
+ sourceUrl: https://station.jup.ag/docs/apis/swap-api
18
+ detect:
19
+ modules: ["@jup-ag/api"]
20
+ # Keep broad enough to catch arb/swap bots that wrap the Jupiter quote
21
+ # call in their own helper (getQuote, fetchQuote, getArbQuote, swap, ...).
22
+ nameRegex: "quote|swap|arb"
23
+ triggerCalls: [quoteGet]
24
+ check:
25
+ kind: object-arg-property-forbidden-literal
26
+ params:
27
+ call: quoteGet
28
+ argIndex: 0
29
+ propName: slippageBps
30
+ forbiddenValue: 0
31
+ passDetail: >-
32
+ quoteGet's slippageBps is a nonzero literal — the swap has explicit
33
+ slippage protection.
34
+ failDetail: >-
35
+ quoteGet is called with slippageBps: 0, which disables slippage
36
+ protection entirely. Any price movement between the quote and the
37
+ on-chain swap (including a sandwich attack) executes the trade at
38
+ whatever price results, with no minimum-output guarantee. Set
39
+ slippageBps to a nonzero value (e.g. 50 = 0.5%) or use
40
+ `dynamicSlippage` so the swap reverts if the price moves too far.
41
+ absentCallDetail: >-
42
+ Handler is in the Jupiter-quote scope but does not call quoteGet; the
43
+ zero-slippage rule does not apply here.
44
+ absentArgDetail: >-
45
+ quoteGet's options object does not set slippageBps as an inline
46
+ literal — cannot determine statically whether slippage protection is
47
+ disabled. If slippageBps is omitted entirely, the Jupiter API defaults
48
+ to 50 bps (0.5%), which is safe; if it's a non-literal expression,
49
+ verify the resolved value is nonzero.
50
+ test:
51
+ kind: none
@@ -0,0 +1,39 @@
1
+ # metaplex-nft-royalty-zero
2
+
3
+ **Severity:** HIGH
4
+
5
+ ## What's the trap?
6
+
7
+ When minting an NFT with `@metaplex-foundation/js`, the `sellerFeeBasisPoints` field in `create()` sets the on-chain royalty percentage for the NFT's entire lifetime. A value of `0` means:
8
+
9
+ - Creators earn **zero** royalties on every secondary sale
10
+ - This is **immutable** — Metaplex token-metadata cannot be changed after mint without burning the NFT and reminting it
11
+
12
+ AI code generators frequently use `0` as a "fill in later" placeholder, and launch teams sometimes leave it in to appear collection-friendly. Either way, the economic harm is permanent.
13
+
14
+ ## Why it's silent
15
+
16
+ The `create()` call succeeds with `sellerFeeBasisPoints: 0` — there is no warning, no validation error, no on-chain check. The NFT mints normally and is immediately tradeable. The royalty loss only becomes apparent when secondary sales generate no creator income.
17
+
18
+ ## The fix
19
+
20
+ ```typescript
21
+ // BEFORE (vulnerable — zero royalties, permanent)
22
+ return metaplex.nfts().create({
23
+ uri,
24
+ name: "My NFT",
25
+ sellerFeeBasisPoints: 0, // ← trap
26
+ });
27
+
28
+ // AFTER (fixed — 5% royalties)
29
+ return metaplex.nfts().create({
30
+ uri,
31
+ name: "My NFT",
32
+ sellerFeeBasisPoints: 500, // 500 basis points = 5%
33
+ });
34
+ ```
35
+
36
+ ## References
37
+
38
+ - [Metaplex Token Metadata — Mint docs](https://developers.metaplex.com/token-metadata/mint)
39
+ - `CreateNftInput.sellerFeeBasisPoints` — required field, immutable after mint
@@ -0,0 +1,5 @@
1
+ id: metaplex-nft-royalty-zero
2
+ name: Metaplex NFT minted with zero royalties
3
+ version: 0.1.0
4
+ author: DSB-117
5
+ description: "Detects metaplex.nfts().create() called with sellerFeeBasisPoints: 0 — royalties are immutably burned to zero at mint time"
@@ -0,0 +1,11 @@
1
+ import { Metaplex } from "@metaplex-foundation/js";
2
+
3
+ export async function mintNft(metaplex: Metaplex, uri: string) {
4
+ // FIXED: 5% royalty on secondary sales
5
+ return metaplex.nfts().create({
6
+ uri,
7
+ name: "My NFT",
8
+ sellerFeeBasisPoints: 500,
9
+ symbol: "MNFT",
10
+ });
11
+ }
@@ -0,0 +1,11 @@
1
+ import { Metaplex } from "@metaplex-foundation/js";
2
+
3
+ export async function mintNft(metaplex: Metaplex, uri: string) {
4
+ // VULNERABLE: royalties are permanently set to zero — cannot be changed after mint
5
+ return metaplex.nfts().create({
6
+ uri,
7
+ name: "My NFT",
8
+ sellerFeeBasisPoints: 0,
9
+ symbol: "MNFT",
10
+ });
11
+ }
@@ -0,0 +1,39 @@
1
+ # Auto-synthesized from a research Finding (proof-as-classifier loop).
2
+ # Pure data — every kind below resolves to a HUMAN-VETTED template in core.
3
+ id: metaplex-nft-royalty-zero
4
+ severity: high
5
+ title: "NFT minted with sellerFeeBasisPoints: 0 — creators receive zero
6
+ royalties on secondary sales"
7
+ component:
8
+ name: "@metaplex-foundation/js"
9
+ type: Blockchain
10
+ version: ">=0.18.0"
11
+ sourceUrl: https://developers.metaplex.com/token-metadata/mint
12
+ detect:
13
+ modules:
14
+ - "@metaplex-foundation/js"
15
+ nameRegex: mint|create|nft|deploy
16
+ triggerCalls:
17
+ - create
18
+ check:
19
+ kind: object-arg-property-forbidden-literal
20
+ params:
21
+ call: create
22
+ argIndex: 0
23
+ propName: sellerFeeBasisPoints
24
+ forbiddenValue: 0
25
+ passDetail: create() sets sellerFeeBasisPoints to a nonzero literal — creators
26
+ will receive royalties on secondary sales.
27
+ failDetail: "create() is called with sellerFeeBasisPoints: 0, which bakes zero
28
+ royalties into the NFT's on-chain metadata. Secondary marketplaces that
29
+ honour on-chain royalties will pay creators nothing. Metaplex
30
+ token-metadata is immutable after mint — this cannot be corrected without
31
+ burning and reminting. Set sellerFeeBasisPoints to the intended basis
32
+ points (e.g. 500 = 5%)."
33
+ absentCallDetail: Handler is in the NFT-mint scope but does not call create();
34
+ this rule does not apply here.
35
+ absentArgDetail: create() does not include a sellerFeeBasisPoints literal in its
36
+ options object; the royalty rate cannot be confirmed statically. Verify
37
+ the resolved value is the intended nonzero royalty.
38
+ test:
39
+ kind: none
@@ -0,0 +1,32 @@
1
+ # meteora-dlmm-zero-min-out
2
+
3
+ **Severity:** HIGH
4
+
5
+ ## What's the trap?
6
+
7
+ Meteora DLMM's `swap()` takes a `minOutAmount` — the minimum number of output tokens the swap must return, or it reverts. Passing `new BN(0)` (or `0`) removes that floor entirely.
8
+
9
+ With no floor, any price movement between when you quoted and when the swap lands on-chain — including a deliberate **sandwich attack** — fills the swap at whatever price results. There is no protection.
10
+
11
+ AI-written swap bots reach for `minOutAmount: new BN(0)` to "just make the swap go through," not realizing they've disabled all slippage protection.
12
+
13
+ ## Why it's silent
14
+
15
+ `swap()` with `minOutAmount: new BN(0)` builds and submits successfully. The trade executes. The loss only shows up as consistently worse-than-quoted fills — value quietly extracted by MEV.
16
+
17
+ ## The fix
18
+
19
+ ```typescript
20
+ // BEFORE (vulnerable — no floor)
21
+ return dlmmPool.swap({ /* ... */, minOutAmount: new BN(0) });
22
+
23
+ // AFTER (fixed — floor from the quote + slippage tolerance)
24
+ const quote = dlmmPool.swapQuote(inAmount, swapForY, new BN(50), binArrays); // 0.5%
25
+ return dlmmPool.swap({ /* ... */, minOutAmount: quote.minOutAmount });
26
+ ```
27
+
28
+ > This rule is `BN(0)`-aware (brainblast v0.7.6): it flags `minOutAmount: 0` **and** the idiomatic `minOutAmount: new BN(0)`.
29
+
30
+ ## References
31
+
32
+ - [Meteora DLMM overview](https://docs.meteora.ag/product-overview/meteora-liquidity-pools/dlmm-overview)
@@ -0,0 +1,5 @@
1
+ id: meteora-dlmm-zero-min-out
2
+ name: Meteora DLMM zero minimum-out
3
+ version: 0.1.0
4
+ author: DSB-117
5
+ description: "Flags Meteora DLMM swap() calls with minOutAmount of 0 (including new BN(0)) — no minimum-output floor, so a sandwich attack executes the swap at any price."
@@ -0,0 +1,19 @@
1
+ import DLMM from "@meteora-ag/dlmm";
2
+ import BN from "bn.js";
3
+
4
+ // FIXED — minOutAmount is derived from the on-chain quote and a 0.5% slippage
5
+ // tolerance, so the swap reverts if it would return less than expected.
6
+ export async function swapExactIn(dlmmPool: DLMM, user: any, inAmount: BN) {
7
+ const swapForY = true;
8
+ const binArrays = await dlmmPool.getBinArrayForSwap(swapForY);
9
+ const quote = dlmmPool.swapQuote(inAmount, swapForY, new BN(50), binArrays); // 50 bps = 0.5%
10
+ return dlmmPool.swap({
11
+ inToken: (dlmmPool as any).tokenX.publicKey,
12
+ outToken: (dlmmPool as any).tokenY.publicKey,
13
+ inAmount,
14
+ minOutAmount: quote.minOutAmount,
15
+ lbPair: (dlmmPool as any).pubkey,
16
+ user,
17
+ binArraysPubkey: binArrays.map((b: any) => b.publicKey),
18
+ });
19
+ }
@@ -0,0 +1,19 @@
1
+ import DLMM from "@meteora-ag/dlmm";
2
+ import BN from "bn.js";
3
+
4
+ // VULNERABLE — minOutAmount: new BN(0) removes the slippage floor. The swap
5
+ // will fill at any price; a sandwich bot can move the pool and extract value
6
+ // with no revert protection.
7
+ export async function swapExactIn(dlmmPool: DLMM, user: any, inAmount: BN) {
8
+ const swapForY = true;
9
+ const binArrays = await dlmmPool.getBinArrayForSwap(swapForY);
10
+ return dlmmPool.swap({
11
+ inToken: (dlmmPool as any).tokenX.publicKey,
12
+ outToken: (dlmmPool as any).tokenY.publicKey,
13
+ inAmount,
14
+ minOutAmount: new BN(0),
15
+ lbPair: (dlmmPool as any).pubkey,
16
+ user,
17
+ binArraysPubkey: binArrays.map((b: any) => b.publicKey),
18
+ });
19
+ }
@@ -0,0 +1,47 @@
1
+ # Pure-data rule. Binds to the vetted `object-arg-property-forbidden-literal`
2
+ # checker (BN(0)-aware as of brainblast v0.7.6).
3
+ #
4
+ # Meteora DLMM's swap() takes `minOutAmount` — the floor the swap must return or
5
+ # it reverts. Passing `new BN(0)` (or 0) removes that floor entirely: any price
6
+ # movement between quote and execution, including a sandwich, fills the swap at
7
+ # whatever price results. AI-written swap bots reach for minOutAmount: new BN(0)
8
+ # to "just make the swap go through," silently disabling slippage protection.
9
+ id: meteora-dlmm-zero-min-out
10
+ severity: high
11
+ title: Meteora DLMM swap called with minOutAmount of 0 — no slippage floor
12
+ component:
13
+ name: "@meteora-ag/dlmm"
14
+ type: Blockchain
15
+ version: unversioned
16
+ sourceUrl: https://docs.meteora.ag/product-overview/meteora-liquidity-pools/dlmm-overview
17
+ detect:
18
+ modules:
19
+ - "@meteora-ag/dlmm"
20
+ nameRegex: swap|trade|exchange|buy|sell|arb
21
+ triggerCalls:
22
+ - swap
23
+ check:
24
+ kind: object-arg-property-forbidden-literal
25
+ params:
26
+ call: swap
27
+ argIndex: 0
28
+ propName: minOutAmount
29
+ forbiddenValue: 0
30
+ passDetail: >-
31
+ swap() is called with a nonzero minOutAmount — the swap reverts if it
32
+ would return less, enforcing a minimum-output floor.
33
+ failDetail: >-
34
+ swap() is called with minOutAmount of 0 (or new BN(0)), which removes the
35
+ minimum-output floor. Any price movement between quote and on-chain
36
+ execution — including a sandwich attack — fills the swap at whatever price
37
+ results, with no revert protection. Compute minOutAmount from the quote
38
+ and a slippage tolerance (e.g. quote.minOutAmount or quote * (1 - 0.005)).
39
+ absentCallDetail: >-
40
+ Handler is in the Meteora DLMM swap scope but does not call swap(); this
41
+ rule does not apply here.
42
+ absentArgDetail: >-
43
+ swap()'s options object does not set minOutAmount as an inline literal;
44
+ the minimum-output floor cannot be confirmed statically. Verify it is
45
+ derived from the quote and a nonzero slippage tolerance.
46
+ test:
47
+ kind: none
@@ -0,0 +1,34 @@
1
+ # pyth-price-unchecked-staleness
2
+
3
+ **Severity:** HIGH
4
+
5
+ ## What's the trap?
6
+
7
+ Pyth `PriceFeed` objects expose two ways to read a price:
8
+
9
+ - **`getPriceUnchecked()`** — returns the most recent price **regardless of how old it is**.
10
+ - **`getPriceNoOlderThan(maxAgeSeconds)`** — returns the price only if it's fresh, otherwise `undefined`.
11
+
12
+ Using `getPriceUnchecked()` means that if the feed stops updating — a publisher outage, network congestion, a halted market — your protocol keeps pricing swaps, loans, and liquidations against a **stale price** with no guard.
13
+
14
+ ## Why it's silent
15
+
16
+ `getPriceUnchecked()` always succeeds and always returns a number. Nothing throws. The bug only surfaces when the feed goes stale at exactly the wrong moment — and by then someone has been liquidated at a wrong price, or drained an over-valued position.
17
+
18
+ ## The fix
19
+
20
+ ```typescript
21
+ // BEFORE (vulnerable — ignores staleness)
22
+ const price = feed.getPriceUnchecked();
23
+
24
+ // AFTER (fixed — refuse stale prices)
25
+ const price = feed.getPriceNoOlderThan(60); // 60s max age
26
+ if (!price) throw new Error("Pyth price is stale — refusing to trade");
27
+ ```
28
+
29
+ Pyth's own best-practices doc says to **never** use `getPriceUnchecked()` in production.
30
+
31
+ ## References
32
+
33
+ - [Pyth — price availability best practices](https://docs.pyth.network/price-feeds/best-practices#price-availability)
34
+ - Companion: `brainblast oracle <account>` checks the same staleness question live, on-chain.
@@ -0,0 +1,5 @@
1
+ id: pyth-price-unchecked-staleness
2
+ name: Pyth unchecked price staleness
3
+ version: 0.1.0
4
+ author: DSB-117
5
+ description: "Flags Pyth price reads via getPriceUnchecked() (ignores staleness) instead of getPriceNoOlderThan(maxAge), which can return an arbitrarily old price."
@@ -0,0 +1,14 @@
1
+ import { PriceServiceConnection } from "@pythnetwork/price-service-client";
2
+
3
+ const connection = new PriceServiceConnection("https://hermes.pyth.network");
4
+ const SOL_USD = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
5
+
6
+ // FIXED — getPriceNoOlderThan(60) returns undefined when the feed is more than
7
+ // 60 seconds old, so we refuse to price against a stale oracle.
8
+ export async function getSolPrice() {
9
+ const feeds = await connection.getLatestPriceFeeds([SOL_USD]);
10
+ const feed = feeds![0];
11
+ const price = feed.getPriceNoOlderThan(60);
12
+ if (!price) throw new Error("Pyth SOL/USD price is stale (>60s) — refusing to trade");
13
+ return Number(price.price) * 10 ** price.expo;
14
+ }
@@ -0,0 +1,14 @@
1
+ import { PriceServiceConnection } from "@pythnetwork/price-service-client";
2
+
3
+ const connection = new PriceServiceConnection("https://hermes.pyth.network");
4
+ const SOL_USD = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
5
+
6
+ // VULNERABLE — getPriceUnchecked() ignores staleness. If the SOL/USD feed
7
+ // stops updating, this returns the last (possibly very old) price and the
8
+ // caller trades against it with no guard.
9
+ export async function getSolPrice() {
10
+ const feeds = await connection.getLatestPriceFeeds([SOL_USD]);
11
+ const feed = feeds![0];
12
+ const price = feed.getPriceUnchecked();
13
+ return Number(price.price) * 10 ** price.expo;
14
+ }
@@ -0,0 +1,47 @@
1
+ # Pure-data rule. Binds to the vetted `forbidden-call-replacement` checker.
2
+ #
3
+ # Pyth's own docs are explicit: NEVER use getPriceUnchecked() in production —
4
+ # it returns the last price regardless of how old it is. A feed can stop
5
+ # updating (publisher outage, network congestion) and getPriceUnchecked() will
6
+ # happily hand you a stale price, which a protocol then prices liquidations /
7
+ # swaps against. Use getPriceNoOlderThan(maxAge), which returns undefined when
8
+ # the price is older than maxAge so the caller can refuse to trade.
9
+ id: pyth-price-unchecked-staleness
10
+ severity: high
11
+ title: Pyth price read with getPriceUnchecked() instead of getPriceNoOlderThan()
12
+ component:
13
+ name: Pyth Network price feeds
14
+ type: Blockchain
15
+ version: unversioned
16
+ sourceUrl: https://docs.pyth.network/price-feeds/best-practices#price-availability
17
+ detect:
18
+ modules:
19
+ - "@pythnetwork/price-service-client"
20
+ - "@pythnetwork/pyth-solana-receiver"
21
+ - "@pythnetwork/hermes-client"
22
+ nameRegex: price|oracle|quote|value|fetch|get
23
+ triggerCalls:
24
+ - getPriceUnchecked
25
+ - getPriceNoOlderThan
26
+ check:
27
+ kind: forbidden-call-replacement
28
+ params:
29
+ forbiddenCalls:
30
+ - getPriceUnchecked
31
+ saferCalls:
32
+ - getPriceNoOlderThan
33
+ passDetail: >-
34
+ Handler reads the Pyth price via getPriceNoOlderThan(maxAge), which
35
+ returns no price when the feed is older than maxAge — the caller can
36
+ refuse to trade on a stale oracle.
37
+ failDetail: >-
38
+ Handler calls getPriceUnchecked(), which returns the most recent Pyth
39
+ price REGARDLESS of how old it is. If the feed stops updating (publisher
40
+ outage, congestion), the protocol prices swaps/liquidations against a
41
+ stale value with no guard. Use getPriceNoOlderThan(maxAge) (e.g. 60s) and
42
+ handle the undefined/stale case explicitly.
43
+ absentDetail: >-
44
+ Handler is in Pyth scope but calls neither getPriceUnchecked nor
45
+ getPriceNoOlderThan; the staleness rule does not apply here.
46
+ test:
47
+ kind: none