brainblast 0.7.4 → 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.
- package/dist/{chunk-5VYTURTO.js → chunk-A3U6JDMN.js} +64 -12
- package/dist/chunk-CSYGLMZR.js +129 -0
- package/dist/{chunk-5H5JQXXU.js → chunk-Q5DMQN67.js} +109 -25
- package/dist/cli.js +84 -5
- package/dist/feeConfigs-S4E5GQ7Q.js +19 -0
- package/dist/index.d.ts +39 -1
- package/dist/index.js +27 -7
- package/dist/{mcp-EOTWSQK7.js → mcp-O45PSOVU.js} +1 -1
- package/dist/packs/README.md +33 -0
- package/dist/packs/jito-bundle-zero-tip/README.md +33 -0
- package/dist/packs/jito-bundle-zero-tip/brainblast-pack.yaml +5 -0
- package/dist/packs/jito-bundle-zero-tip/fixtures/jito-bundle-zero-tip/fixed/bundle.ts +13 -0
- package/dist/packs/jito-bundle-zero-tip/fixtures/jito-bundle-zero-tip/vulnerable/bundle.ts +16 -0
- package/dist/packs/jito-bundle-zero-tip/rules/jito-bundle-zero-tip.yaml +54 -0
- package/dist/packs/jupiter-quote-zero-slippage/brainblast-pack.yaml +5 -0
- package/dist/packs/jupiter-quote-zero-slippage/fixtures/jupiter-quote-zero-slippage/fixed/arb.ts +17 -0
- package/dist/packs/jupiter-quote-zero-slippage/fixtures/jupiter-quote-zero-slippage/vulnerable/arb.ts +17 -0
- package/dist/packs/jupiter-quote-zero-slippage/rules/jupiter-quote-zero-slippage.yaml +51 -0
- package/dist/packs/metaplex-nft-royalty-zero/README.md +39 -0
- package/dist/packs/metaplex-nft-royalty-zero/brainblast-pack.yaml +5 -0
- package/dist/packs/metaplex-nft-royalty-zero/fixtures/metaplex-nft-royalty-zero/fixed/mint.ts +11 -0
- package/dist/packs/metaplex-nft-royalty-zero/fixtures/metaplex-nft-royalty-zero/vulnerable/mint.ts +11 -0
- package/dist/packs/metaplex-nft-royalty-zero/rules/metaplex-nft-royalty-zero.yaml +39 -0
- package/dist/packs/meteora-dlmm-zero-min-out/README.md +32 -0
- package/dist/packs/meteora-dlmm-zero-min-out/brainblast-pack.yaml +5 -0
- package/dist/packs/meteora-dlmm-zero-min-out/fixtures/meteora-dlmm-zero-min-out/fixed/swap.ts +19 -0
- package/dist/packs/meteora-dlmm-zero-min-out/fixtures/meteora-dlmm-zero-min-out/vulnerable/swap.ts +19 -0
- package/dist/packs/meteora-dlmm-zero-min-out/rules/meteora-dlmm-zero-min-out.yaml +47 -0
- package/dist/packs/pyth-price-unchecked-staleness/README.md +34 -0
- package/dist/packs/pyth-price-unchecked-staleness/brainblast-pack.yaml +5 -0
- package/dist/packs/pyth-price-unchecked-staleness/fixtures/pyth-price-unchecked-staleness/fixed/price.ts +14 -0
- package/dist/packs/pyth-price-unchecked-staleness/fixtures/pyth-price-unchecked-staleness/vulnerable/price.ts +14 -0
- package/dist/packs/pyth-price-unchecked-staleness/rules/pyth-price-unchecked-staleness.yaml +47 -0
- package/dist/packs/raydium-compute-zero-slippage/README.md +43 -0
- package/dist/packs/raydium-compute-zero-slippage/brainblast-pack.yaml +5 -0
- package/dist/packs/raydium-compute-zero-slippage/fixtures/raydium-compute-zero-slippage/fixed/swap.ts +12 -0
- package/dist/packs/raydium-compute-zero-slippage/fixtures/raydium-compute-zero-slippage/vulnerable/swap.ts +12 -0
- package/dist/packs/raydium-compute-zero-slippage/rules/raydium-compute-zero-slippage.yaml +40 -0
- package/dist/packs/solana-sendtx-unconfirmed/README.md +34 -0
- package/dist/packs/solana-sendtx-unconfirmed/brainblast-pack.yaml +5 -0
- package/dist/packs/solana-sendtx-unconfirmed/fixtures/solana-sendtx-unconfirmed/fixed/payment.ts +10 -0
- package/dist/packs/solana-sendtx-unconfirmed/fixtures/solana-sendtx-unconfirmed/vulnerable/payment.ts +10 -0
- package/dist/packs/solana-sendtx-unconfirmed/rules/solana-sendtx-unconfirmed.yaml +40 -0
- package/dist/packs/spl-transfer-not-checked-in-payout/brainblast-pack.yaml +5 -0
- package/dist/packs/spl-transfer-not-checked-in-payout/fixtures/spl-transfer-not-checked-in-payout/fixed/payout-processor.ts +29 -0
- package/dist/packs/spl-transfer-not-checked-in-payout/fixtures/spl-transfer-not-checked-in-payout/vulnerable/payout-processor.ts +22 -0
- package/dist/packs/spl-transfer-not-checked-in-payout/rules/spl-transfer-not-checked-in-payout.yaml +49 -0
- package/dist/rules/metaplex-seller-fee-zero.yaml +41 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# raydium-compute-zero-slippage
|
|
2
|
+
|
|
3
|
+
**Severity:** HIGH
|
|
4
|
+
|
|
5
|
+
## What's the trap?
|
|
6
|
+
|
|
7
|
+
`@raydium-io/raydium-sdk-v2`'s `computeAmountOut()` takes a `slippage` parameter (a decimal fraction, e.g. `0.5` = 0.5%). When `slippage: 0`, the SDK sets `minAmountOut === amountOut` — the swap will only succeed if the received amount is exactly equal to the computed output with zero tolerance.
|
|
8
|
+
|
|
9
|
+
In practice this means:
|
|
10
|
+
|
|
11
|
+
- Any price movement between compute and on-chain execution causes the tx to fail (not protected, just fails)
|
|
12
|
+
- A sandwich attack can front-run the swap and move the price; at `slippage: 0` there is no minimum-out floor, so the swap either fails with no protection or (depending on pool type) executes at a worse effective rate
|
|
13
|
+
|
|
14
|
+
AI-generated swap bots frequently default to `slippage: 0` to "avoid slippage" without understanding that they're actually removing all minimum-output guarantees.
|
|
15
|
+
|
|
16
|
+
## Why it's silent
|
|
17
|
+
|
|
18
|
+
`computeAmountOut()` succeeds with `slippage: 0`. The route is computed, the tx is built, and the swap may execute. The trap only becomes apparent at the wallet level when MEV bots consistently extract value from the unprotected path.
|
|
19
|
+
|
|
20
|
+
## The fix
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// BEFORE (vulnerable — no minimum output protection)
|
|
24
|
+
return raydium.liquidity.computeAmountOut({
|
|
25
|
+
poolInfo: pool.poolInfo,
|
|
26
|
+
amountIn,
|
|
27
|
+
mintInfo: pool.mintInfos,
|
|
28
|
+
slippage: 0, // ← trap
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// AFTER (fixed — 0.5% tolerance)
|
|
32
|
+
return raydium.liquidity.computeAmountOut({
|
|
33
|
+
poolInfo: pool.poolInfo,
|
|
34
|
+
amountIn,
|
|
35
|
+
mintInfo: pool.mintInfos,
|
|
36
|
+
slippage: 0.5, // 0.5% — adjust to suit trade size and volatility
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## References
|
|
41
|
+
|
|
42
|
+
- [Raydium SDK v2 GitHub](https://github.com/raydium-io/raydium-sdk-v2)
|
|
43
|
+
- `ComputeAmountOutParam.slippage: number` — confirmed in `src/raydium/liquidity/type.ts`
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Raydium } from "@raydium-io/raydium-sdk-v2";
|
|
2
|
+
|
|
3
|
+
export async function getSwapOutput(raydium: Raydium, poolId: string, amountIn: bigint) {
|
|
4
|
+
const pool = await raydium.liquidity.getPoolInfoFromRpc(poolId);
|
|
5
|
+
// FIXED: 0.5% slippage tolerance enforces a minimum output floor
|
|
6
|
+
return raydium.liquidity.computeAmountOut({
|
|
7
|
+
poolInfo: pool.poolInfo,
|
|
8
|
+
amountIn,
|
|
9
|
+
mintInfo: pool.mintInfos,
|
|
10
|
+
slippage: 0.5,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Raydium } from "@raydium-io/raydium-sdk-v2";
|
|
2
|
+
|
|
3
|
+
export async function getSwapOutput(raydium: Raydium, poolId: string, amountIn: bigint) {
|
|
4
|
+
const pool = await raydium.liquidity.getPoolInfoFromRpc(poolId);
|
|
5
|
+
// VULNERABLE: slippage: 0 means minAmountOut === amountOut — no sandwich protection
|
|
6
|
+
return raydium.liquidity.computeAmountOut({
|
|
7
|
+
poolInfo: pool.poolInfo,
|
|
8
|
+
amountIn,
|
|
9
|
+
mintInfo: pool.mintInfos,
|
|
10
|
+
slippage: 0,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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: raydium-compute-zero-slippage
|
|
4
|
+
severity: high
|
|
5
|
+
title: "Raydium computeAmountOut called with slippage: 0 — no minimum output
|
|
6
|
+
protection against sandwich attacks"
|
|
7
|
+
component:
|
|
8
|
+
name: "@raydium-io/raydium-sdk-v2"
|
|
9
|
+
type: Blockchain
|
|
10
|
+
version: ">=0.1.0"
|
|
11
|
+
sourceUrl: https://github.com/raydium-io/raydium-sdk-v2
|
|
12
|
+
detect:
|
|
13
|
+
modules:
|
|
14
|
+
- "@raydium-io/raydium-sdk-v2"
|
|
15
|
+
nameRegex: swap|arb|trade|exchange|buy|sell
|
|
16
|
+
triggerCalls:
|
|
17
|
+
- computeAmountOut
|
|
18
|
+
- computeAmountIn
|
|
19
|
+
check:
|
|
20
|
+
kind: object-arg-property-forbidden-literal
|
|
21
|
+
params:
|
|
22
|
+
call: computeAmountOut
|
|
23
|
+
argIndex: 0
|
|
24
|
+
propName: slippage
|
|
25
|
+
forbiddenValue: 0
|
|
26
|
+
passDetail: computeAmountOut is called with a nonzero slippage value — the swap
|
|
27
|
+
has minimum output protection against sandwich attacks.
|
|
28
|
+
failDetail: "computeAmountOut is called with slippage: 0, which sets
|
|
29
|
+
minAmountOut equal to amountOut with zero tolerance. Any price movement
|
|
30
|
+
between compute and execution — including a sandwich attack — will cause
|
|
31
|
+
the swap to execute at a worse price with no revert protection. Set
|
|
32
|
+
slippage to a nonzero value (e.g. 0.5 = 0.5%) to enforce a minAmountOut
|
|
33
|
+
floor."
|
|
34
|
+
absentCallDetail: Handler is in the swap scope but does not call
|
|
35
|
+
computeAmountOut(); this rule does not apply here.
|
|
36
|
+
absentArgDetail: computeAmountOut does not include a slippage literal in its
|
|
37
|
+
options object; the slippage tolerance cannot be confirmed statically.
|
|
38
|
+
Verify the resolved value is nonzero.
|
|
39
|
+
test:
|
|
40
|
+
kind: none
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# solana-sendtx-unconfirmed
|
|
2
|
+
|
|
3
|
+
**Severity:** HIGH
|
|
4
|
+
|
|
5
|
+
## What's the trap?
|
|
6
|
+
|
|
7
|
+
`@solana/web3.js` exposes two ways to send a transaction:
|
|
8
|
+
|
|
9
|
+
| Call | Behaviour |
|
|
10
|
+
|------|-----------|
|
|
11
|
+
| `connection.sendTransaction(tx, signers)` | Submits to the cluster and returns a signature **immediately** — fire-and-forget. |
|
|
12
|
+
| `sendAndConfirmTransaction(connection, tx, signers)` | Waits until the transaction is **confirmed** before returning; throws if it fails or is dropped. |
|
|
13
|
+
|
|
14
|
+
AI code generators routinely emit `sendTransaction()` because it matches the shape of "send a thing and get a result." The transaction returns a signature that *looks* like success, but the transaction may still be dropped due to network congestion, a validator restart, or blockhash expiry.
|
|
15
|
+
|
|
16
|
+
## Why it's silent
|
|
17
|
+
|
|
18
|
+
`sendTransaction()` resolves as soon as the RPC node accepts the transaction — not when it lands on-chain. The returned signature is valid regardless of whether the transaction was ever included in a block. Code that credits a user, debits inventory, or records a transfer immediately after this call will think it succeeded even when the on-chain state was never changed.
|
|
19
|
+
|
|
20
|
+
## The fix
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// BEFORE (vulnerable)
|
|
24
|
+
const sig = await connection.sendTransaction(tx, [signer]);
|
|
25
|
+
|
|
26
|
+
// AFTER (fixed)
|
|
27
|
+
import { sendAndConfirmTransaction } from "@solana/web3.js";
|
|
28
|
+
const sig = await sendAndConfirmTransaction(connection, tx, [signer]);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## References
|
|
32
|
+
|
|
33
|
+
- [Solana web3.js docs — sendAndConfirmTransaction](https://solana-labs.github.io/solana-web3.js/)
|
|
34
|
+
- Solana Cookbook: "Sending Transactions" — always confirm before crediting
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
id: solana-sendtx-unconfirmed
|
|
2
|
+
name: Solana sendTransaction without confirmation
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
author: DSB-117
|
|
5
|
+
description: Detects connection.sendTransaction() used in value-bearing paths without confirmation — transactions may silently drop on congestion or blockhash expiry
|
package/dist/packs/solana-sendtx-unconfirmed/fixtures/solana-sendtx-unconfirmed/fixed/payment.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Connection, Transaction, Keypair, sendAndConfirmTransaction } from "@solana/web3.js";
|
|
2
|
+
|
|
3
|
+
export async function sendPayment(
|
|
4
|
+
connection: Connection,
|
|
5
|
+
tx: Transaction,
|
|
6
|
+
signer: Keypair,
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
// FIXED: blocks until the transaction is confirmed by the cluster
|
|
9
|
+
return sendAndConfirmTransaction(connection, tx, [signer]);
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Connection, Transaction, Keypair } from "@solana/web3.js";
|
|
2
|
+
|
|
3
|
+
export async function sendPayment(
|
|
4
|
+
connection: Connection,
|
|
5
|
+
tx: Transaction,
|
|
6
|
+
signer: Keypair,
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
// VULNERABLE: fire-and-forget — no confirmation that the transaction landed
|
|
9
|
+
return connection.sendTransaction(tx, [signer]);
|
|
10
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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: solana-sendtx-unconfirmed
|
|
4
|
+
severity: high
|
|
5
|
+
title: sendTransaction used instead of sendAndConfirmTransaction — transaction
|
|
6
|
+
may silently drop
|
|
7
|
+
component:
|
|
8
|
+
name: "@solana/web3.js"
|
|
9
|
+
type: Blockchain
|
|
10
|
+
version: ">=1.0.0"
|
|
11
|
+
sourceUrl: https://solana-labs.github.io/solana-web3.js/
|
|
12
|
+
detect:
|
|
13
|
+
modules:
|
|
14
|
+
- "@solana/web3.js"
|
|
15
|
+
nameRegex: send|transfer|execute|submit|payout|swap
|
|
16
|
+
triggerCalls:
|
|
17
|
+
- sendTransaction
|
|
18
|
+
- sendAndConfirmTransaction
|
|
19
|
+
check:
|
|
20
|
+
kind: forbidden-call-replacement
|
|
21
|
+
params:
|
|
22
|
+
forbiddenCalls:
|
|
23
|
+
- sendTransaction
|
|
24
|
+
saferCalls:
|
|
25
|
+
- sendAndConfirmTransaction
|
|
26
|
+
passDetail: Handler calls sendAndConfirmTransaction, which waits for the cluster
|
|
27
|
+
to confirm the transaction before returning and propagates any on-chain
|
|
28
|
+
error as a thrown exception.
|
|
29
|
+
failDetail: Handler calls connection.sendTransaction() but not
|
|
30
|
+
sendAndConfirmTransaction(). sendTransaction() submits the transaction and
|
|
31
|
+
returns a signature immediately, with no confirmation that it landed. If
|
|
32
|
+
the transaction is dropped (congestion, validator restart, blockhash
|
|
33
|
+
expiry) the code continues as if it succeeded — tokens are not moved but
|
|
34
|
+
the caller assumes they were. Use sendAndConfirmTransaction(connection,
|
|
35
|
+
tx, signers) to block until confirmation.
|
|
36
|
+
absentDetail: Handler is in the send/transfer scope but calls neither
|
|
37
|
+
sendTransaction nor sendAndConfirmTransaction; this rule does not apply
|
|
38
|
+
here.
|
|
39
|
+
test:
|
|
40
|
+
kind: none
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Connection, Keypair, Transaction } from "@solana/web3.js";
|
|
2
|
+
import { createTransferCheckedInstruction, getAssociatedTokenAddress } from "@solana/spl-token";
|
|
3
|
+
|
|
4
|
+
const TOKEN_DECIMALS = 6;
|
|
5
|
+
|
|
6
|
+
export async function executeSolanaPayout(
|
|
7
|
+
connection: Connection,
|
|
8
|
+
payer: Keypair,
|
|
9
|
+
mintAddress: string,
|
|
10
|
+
destinationOwner: string,
|
|
11
|
+
amountTokens: number,
|
|
12
|
+
) {
|
|
13
|
+
const sourceAta = await getAssociatedTokenAddress(mintAddress as any, payer.publicKey);
|
|
14
|
+
const destinationAta = await getAssociatedTokenAddress(mintAddress as any, destinationOwner as any);
|
|
15
|
+
|
|
16
|
+
const amount = amountTokens * 10 ** TOKEN_DECIMALS;
|
|
17
|
+
|
|
18
|
+
const transferIx = createTransferCheckedInstruction(
|
|
19
|
+
sourceAta,
|
|
20
|
+
mintAddress as any,
|
|
21
|
+
destinationAta,
|
|
22
|
+
payer.publicKey,
|
|
23
|
+
amount,
|
|
24
|
+
TOKEN_DECIMALS,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const tx = new Transaction().add(transferIx);
|
|
28
|
+
return connection.sendTransaction(tx, [payer]);
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Connection, Keypair, Transaction } from "@solana/web3.js";
|
|
2
|
+
import { createTransferInstruction, getAssociatedTokenAddress } from "@solana/spl-token";
|
|
3
|
+
|
|
4
|
+
const TOKEN_DECIMALS = 6;
|
|
5
|
+
|
|
6
|
+
export async function executeSolanaPayout(
|
|
7
|
+
connection: Connection,
|
|
8
|
+
payer: Keypair,
|
|
9
|
+
mintAddress: string,
|
|
10
|
+
destinationOwner: string,
|
|
11
|
+
amountTokens: number,
|
|
12
|
+
) {
|
|
13
|
+
const sourceAta = await getAssociatedTokenAddress(mintAddress as any, payer.publicKey);
|
|
14
|
+
const destinationAta = await getAssociatedTokenAddress(mintAddress as any, destinationOwner as any);
|
|
15
|
+
|
|
16
|
+
const amount = amountTokens * 10 ** TOKEN_DECIMALS;
|
|
17
|
+
|
|
18
|
+
const transferIx = createTransferInstruction(sourceAta, destinationAta, payer.publicKey, amount);
|
|
19
|
+
|
|
20
|
+
const tx = new Transaction().add(transferIx);
|
|
21
|
+
return connection.sendTransaction(tx, [payer]);
|
|
22
|
+
}
|
package/dist/packs/spl-transfer-not-checked-in-payout/rules/spl-transfer-not-checked-in-payout.yaml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Pure-data rule (facts). Binds to the vetted `forbidden-call-replacement`
|
|
2
|
+
# checker template.
|
|
3
|
+
#
|
|
4
|
+
# Real-world instance of this exact pattern:
|
|
5
|
+
# - elizaOS/eliza, payout-processor.ts — executeSolanaPayout() builds the
|
|
6
|
+
# transfer instruction with `createTransferInstruction(sourceAta,
|
|
7
|
+
# destinationAta, payer, amount)`, the legacy SPL Token instruction that
|
|
8
|
+
# does NOT validate the mint or decimals of the accounts it's given.
|
|
9
|
+
# `createTransferCheckedInstruction` is the drop-in replacement that adds
|
|
10
|
+
# mint+decimals validation, closing a class of bugs where a malicious or
|
|
11
|
+
# mismatched token account causes funds to move on the wrong mint or with
|
|
12
|
+
# the wrong decimal scaling.
|
|
13
|
+
id: spl-transfer-not-checked-in-payout
|
|
14
|
+
severity: high
|
|
15
|
+
title: Token payout uses createTransferInstruction instead of createTransferCheckedInstruction
|
|
16
|
+
component:
|
|
17
|
+
name: SPL Token
|
|
18
|
+
type: Blockchain
|
|
19
|
+
version: unversioned
|
|
20
|
+
sourceUrl: https://spl.solana.com/token
|
|
21
|
+
detect:
|
|
22
|
+
modules: ["@solana/spl-token"]
|
|
23
|
+
nameRegex: "payout|withdraw|disburse|transfer|distribute"
|
|
24
|
+
triggerCalls: [createTransferInstruction, createTransferCheckedInstruction]
|
|
25
|
+
check:
|
|
26
|
+
kind: forbidden-call-replacement
|
|
27
|
+
params:
|
|
28
|
+
forbiddenCalls: [createTransferInstruction]
|
|
29
|
+
saferCalls: [createTransferCheckedInstruction]
|
|
30
|
+
passDetail: >-
|
|
31
|
+
Payout uses createTransferCheckedInstruction, which validates the mint
|
|
32
|
+
and decimals of the source/destination token accounts before building
|
|
33
|
+
the transfer.
|
|
34
|
+
failDetail: >-
|
|
35
|
+
Payout builds its transfer with createTransferInstruction, the legacy
|
|
36
|
+
SPL Token instruction. It performs no on-chain validation that the
|
|
37
|
+
source and destination accounts belong to the expected mint or that the
|
|
38
|
+
amount is scaled to the correct decimals. If a caller (or a chain of
|
|
39
|
+
account-derivation bugs) supplies a token account for the wrong mint,
|
|
40
|
+
the transfer can move the wrong asset or move the right asset at the
|
|
41
|
+
wrong decimal scale, with no protocol-level check to stop it. Replace
|
|
42
|
+
with createTransferCheckedInstruction(source, mint, destination, owner,
|
|
43
|
+
amount, decimals).
|
|
44
|
+
absentDetail: >-
|
|
45
|
+
Handler is in the payout/transfer scope but neither
|
|
46
|
+
createTransferInstruction nor createTransferCheckedInstruction was
|
|
47
|
+
found; this rule does not apply here.
|
|
48
|
+
test:
|
|
49
|
+
kind: none
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Fee Config Validator (v0.7.5) — the generalized Bags exploit.
|
|
2
|
+
# A revenue-bearing field omitted from a config object silently defaults to
|
|
3
|
+
# zero: the call succeeds, nothing is collected, forever. Here: Metaplex
|
|
4
|
+
# `sellerFeeBasisPoints` — the on-chain royalty creators earn on secondary
|
|
5
|
+
# sales. Omit it (or set 0) and the creators earn nothing, permanently.
|
|
6
|
+
id: metaplex-seller-fee-zero
|
|
7
|
+
severity: high
|
|
8
|
+
title: Metaplex token created with sellerFeeBasisPoints omitted or zero — creators earn no royalties
|
|
9
|
+
component:
|
|
10
|
+
name: Metaplex Token Metadata
|
|
11
|
+
type: Blockchain
|
|
12
|
+
version: unversioned
|
|
13
|
+
sourceUrl: https://developers.metaplex.com/token-metadata/mint
|
|
14
|
+
detect:
|
|
15
|
+
modules:
|
|
16
|
+
- "@metaplex-foundation/mpl-token-metadata"
|
|
17
|
+
- "@metaplex-foundation/js"
|
|
18
|
+
nameRegex: "metaplex|mpl|createNft|mint"
|
|
19
|
+
triggerCalls: [create, createV1, createNft, createFungible, createAndMint]
|
|
20
|
+
check:
|
|
21
|
+
kind: fee-configs-zero-or-missing
|
|
22
|
+
params:
|
|
23
|
+
calls: [create, createV1, createNft, createFungible, createAndMint]
|
|
24
|
+
field: sellerFeeBasisPoints
|
|
25
|
+
omittedDetail: >-
|
|
26
|
+
The Metaplex create call omits sellerFeeBasisPoints, which defaults to 0.
|
|
27
|
+
The token mints fine, but creators earn ZERO royalties on every secondary
|
|
28
|
+
sale — permanently and silently. There is no after-the-fact fix once
|
|
29
|
+
minted. Set sellerFeeBasisPoints explicitly (e.g. 500 = 5%).
|
|
30
|
+
zeroDetail: >-
|
|
31
|
+
sellerFeeBasisPoints is set to a literal 0 — creators will earn no royalty
|
|
32
|
+
on secondary sales. If this token is meant to be royalty-free that's fine;
|
|
33
|
+
otherwise set a non-zero basis-points value.
|
|
34
|
+
passDetail: >-
|
|
35
|
+
sellerFeeBasisPoints is set to a non-zero value — secondary-sale royalties
|
|
36
|
+
are configured.
|
|
37
|
+
absentDetail: >-
|
|
38
|
+
Handler is in Metaplex scope but does not call a token-create function;
|
|
39
|
+
the royalty-allocation rule does not apply here.
|
|
40
|
+
test:
|
|
41
|
+
kind: none
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainblast",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic auditor for catastrophic AI-integration bugs: scan a repo, find the silent money/auth traps, and generate the behavioral test that proves they're fixed.",
|
|
6
6
|
"keywords": [
|