references/attack-surface.md
# Attack surface by phase
Ordered by phase, because the phase decides the attacker's tools. Run each row against a fork with
funded actors; a row you cannot test is `unproven`, not clean.
---
## Phase A — pre-launch (sale deployed, not open)
The sale contract often sits live and funded before it opens. This is the highest-density window.
| # | Attack | Detect |
|---|---|---|
| A1 | Donate WBNB to the pair and `sync()` so the launch skim is skipped and the pool opens above the intended price | read the skim branch; is the ORE side computed from `lpBnb` or from reserves? |
| A2 | Donate the sale token directly to the pair to move the opening price down | same read, opposite side |
| A3 | Buy the entire early curve from one wallet before the UI opens | is there an `openTrading`/`start` flag, and can it be front-run? |
| A4 | Satisfy the stake gate with a large one-block stake before a top-up unlocks | is `stakeTime` reset on top-up, or weighted? |
| A5 | Pre-register identities after caps are computed but before they are frozen | when are caps snapshotted, and by whom? |
| A6 | `sync()` the pair repeatedly to prevent a skim from ever running | does the skim check `reserve == 0` only? |
| A7 | Place a dust bid to sit first in the book, then fill late | book ordering: price-time, or storage order? |
| A8 | Move the pair address to a wallet before launch via `setAmmPair` | is the setter restricted to factory pairs, one-shot, or live forever? |
## Phase B — during the sale
| # | Attack | Detect |
|---|---|---|
| B1 | Step-split purchases to pay below the integral | `cost(n)` vs `cost(n/2)*2`, and `curve_math.py --check` |
| B2 | Sandwhich any swap the sale performs with its own balance (`swapFees`) by triggering it when large | is the swap permissionless? is `amountOutMin` derived from reserves? |
| B3 | Reenter a refund or a bid cancel to double-count escrow | CEI, `nonReentrant`, refund accounting sides |
| B4 | Refund to a contract that reverts on receive, freezing the queue | does the refund loop isolate per-record, or revert all? |
| B5 | Refund to a contract that consumes all gas | is there a `try/catch` or a gas cap? |
| B6 | Cap snipe: fill the last unit in the final seconds to close a sale the team wanted open | intended? record it as behaviour |
| B7 | Buy at the cap to make every later purchase revert (overshoot rather than truncate) | cap boundary test |
| B8 | Use a smart wallet to dodge or exploit a `tx.origin` gate | EIP-7702 batching, 4337 bundlers |
| B9 | Overpay to push `raised` past the budget with a final purchase | last-unit budget check |
| B10 | Two identities, one wallet, to break a per-identity cap | book per identity and per wallet |
## Phase C — graduation
| # | Attack | Detect |
|---|---|---|
| C1 | Take the LP because it is custodied in an intermediate address | trace the LP holder at graduation; must be burn or verified locker |
| C2 | Prevent graduation by never calling launch, if it is owner-gated | `onlyOwner` on launch, or a registry read ahead of the deadline |
| C3 | Grace-period launch after a failed sale to mint into a pool at a stale price | is launch permissionless and unconditional after the deadline? |
| C4 | Mint into the pool above the budget because the seed is computed from live reserves | seed arithmetic |
| C5 | Leave `tradingOpen` false after graduation so nobody can sell into the pool | is the release in the same transaction? |
| C6 | Graduate while a token pause is active | pause interaction with launch |
## Phase D — post-launch coupling
| # | Attack | Detect |
|---|---|---|
| D1 | Owner mints the unissued supply in one transaction and dumps into the new pool | owner out of the minter set? minter list locked? |
| D2 | Off-chain reward amount over-issued until claims revert past the cap | on-chain budget, monotone carry-over, `totalAllocated` |
| D3 | Swap the minted token for a different one via a setter | immutable token? |
| D4 | Raise the submit/entry fee against unlimited approvals | `require` bound on the fee |
| D5 | Grow a reward array until a claim exceeds the block gas limit | cost per entry vs rounds per day |
| D6 | Mark an exchange deposit address as a pair to tax deposits | `setAmmPair` restricted to factory pairs |
| D7 | Pause transfers after the pool is seeded | live `paused`, and who can flip it |
| D8 | Change the tax to the cap right before a large sale | bound and timelock on the tax setter |
## Phase E — custody
| # | Condition | Why it is graded |
|---|---|---|
| E1 | Owner is an EOA across token, sale, pool | one key flips every path above |
| E2 | Proxy with an EOA admin, or a live `upgradeTo` | the audited code is not the deployed code tomorrow |
| E3 | `setMinter` still live after the list was "locked" | the lock is cosmetic |
| E4 | Multisig without a timelock | fast enough to front-run users |
| E5 | Fee recipient set to an EOA with no rotation path | stuck fees, no recovery |
## Scoring the surface
Count rows that fail, weighted: A1/A2 (opening price), B1 (undercharge), B3 (escrow), C1 (LP),
D1 (post-launch mint) are each sufficient for BLOCK or HOLD on their own. Everything else is
accumulative.
references/economics.md
# Pricing the curve
Read the parameters, write the intended model, then check the code against it. Do the arithmetic
outside the contract with `scripts/curve_math.py`; never trust a comment for a price.
All examples assume wei per whole token and a supply in whole tokens.
## Curve families and their failure modes
| Family | Intended shape | Where it breaks |
|---|---|---|
| Linear P→kP | `price(x) = P(1 + (k-1)x/S)` | rounding down on cost, step-splitting, endpoint drift |
| Exponential `P·r^x` | cheap at the start, asymptote at the cap | fixed-point exponent, overshoot past the cap in one buy |
| Tranche / tiered | flat price per bracket | bracket boundary off-by-one, buying into the next bracket at the old price |
| LBP / Dutch | price decays over time | clock manipulation, buying after the decay floor |
| Fixed-price with cap | flat, cap-limited | cap admission, last-unit overshoot, refund on overflow |
Whatever the family: the cost function must be an integral over the price, monotone, and bounded by
the budget.
## The four checks that pay
**1. Undercharge by splitting.** In an increasing curve, `cost(a) + cost(b)` at consecutive ranges
should never be less than `cost(a+b)` unless the design says so. Rounding in the wrong direction
turns this into free money.
```bash
python3 scripts/curve_math.py --p <P> --supply <S> --check 1e18,10e18
# inspect the delta between total and stepped
```
Also call the contract: `cost(n)` vs `cost(n/2)` twice, and compare against the closed form. If no
quote getter exists, price from `raised`/`sold` deltas across a real transaction, or fork-call the
payable buy with `value` and read the state change. A
contract that undercuts by even 0.1% is exploitable by a bot in the first block.
**2. Overflow.** Any `p * x` before a division, with `x` in wei, overflows 256 bits once supply
approaches `2^96`. Require a 512-bit `mulDiv` (OpenZeppelin `Math.mulDiv`, Solady `fullMulDiv`) or an
explicitly documented safe range. Test at `S = 2^96` and at the cap with the largest single purchase.
**3. Cap admission.** At the cap: does a purchase revert, truncate, or overshoot? The correct answers
are revert-or-truncate by a stated rule, with the last unit inside the budget. Check the boundary at
`cap-1`, `cap`, `cap+1`, and a purchase larger than the remaining supply.
**4. Budget bypass.** A per-identity cap must be booked per identity *and* per wallet, before any
transfer. Two identities, one wallet: buy under both caps. Then one identity, two wallets. Then a
contract that re-enters. If the cap is checked against a balance, a transfer mid-purchase resets it.
## Opening price
Graduation usually seeds a pool at a formula price, e.g. `20/3·P`. The seed must be computed from
*booked* values (`lpBnb`), not from `getReserves()`, because reserves can be moved before launch:
- send WBNB to the pair, call `sync()` → reserves read `(0, X)`, the skim branch is skipped, the pool
opens higher;
- send ORE directly to the pair → the ORE side is inflated and the opening price moves down.
Check: is the pair address derived from the factory, or set by an admin? Is the seed computed before
or after any reserve read? Is there a `skim` that a donation can disable?
## Deadline and graduation timing
- Launch must be permissionless after the deadline, including on an unsold, empty sale.
- A pause or an external registry read ahead of the deadline check can strand a sale forever.
- A deadline of `0` or `type(uint256).max` means "no deadline" — say so.
## Reading the numbers
Convert everything to BNB and to tokens. A price with 18 decimals is unreadable; a USD figure is
drift. Report:
- start and end price, and the multiple between them;
- the budget cap and the implied fully-diluted take;
- the largest single purchase the budget allows;
- the price at the current sold amount, and how far that is from the cap.
## Traps
- **"Curve" used for a fixed ladder.** Tranches are not a curve; the integral reasoning does not apply,
and a boundary bug does.
- **Rounding "in the protocol's favour" claimed.** Verify direction per function; one `floor` on the
wrong side is the whole bug.
- **A cap enforced only in the UI.** Call it: buy one unit past the per-wallet cap on a fork.
- **A `maxTx`-style limit presented as a curve property.** It is a separate, usually admin-mutable,
gate.
references/invariants.md
# Invariant template
Write these as Foundry invariants on a fork, with randomized call order, several funded actors, and
repeated identities. Replace `<sale>`, `<token>`, `<pool>` with the deployment.
An invariant that needs a specific caller is not an invariant — note the caller and move it to the
checklist.
---
## Supply and cap
- **INV-1** `totalSupply() <= maxSupply()` after any sequence of buys, mints, burns, penalties.
- **INV-2** The sale mints exactly the booked sold amount at graduation, and the unsold remainder is
burned or stays with the treasury by a stated rule.
- **INV-3** For any purchase range, `cost(a,b) >= closed_form(a,b)` — the buyer never pays less than
the integral.
- **INV-4** `raised <= hardCap` and `sold <= saleSupply`, with the last unit admitted.
- **INV-5** Price is monotone non-decreasing in `sold`, and the endpoint equals the stated multiple.
## Escrow (when a bid book exists)
- **INV-6** `escrowTotal + feesAccrued == address(this).balance` at every step.
- **INV-7** Booked is cumulative, not per-record: refunding one record decrements both sides once.
- **INV-8** A refund that reverts in the recipient cannot block the queue.
- **INV-9** Filled bids cannot be re-filled, re-refunded, or cancelled.
## Graduation
- **INV-10** After the deadline, launch succeeds for any caller, including on an empty sale.
- **INV-11** The opening price equals the formula price regardless of pre-launch donations (donate,
`sync()`, then compare).
- **INV-12** The LP is held by the burn address or a verified locker after launch, and no address can
move it.
- **INV-13** `tradingOpen` is true in the same transaction that seeds the pool.
- **INV-14** Launch cannot run twice, and cannot run before the deadline.
## Emission coupling
- **INV-15** `totalAllocated() <= emissionCap()` after any claim sequence, both reward paths included.
- **INV-16** `emissionCap()` carries unused budget forward and never decreases.
- **INV-17** `emissionCap() == 0` before the first period starts.
- **INV-18** Claim gas does not grow with the number of accumulated entries.
- **INV-19** `isMinter(owner) == false` and `mintersLocked() == true` after the lock, and
`setMinter` is inert.
## Token wiring
- **INV-20** Before launch, a transfer succeeds only wallet→wallet, and the pair holds no balance.
- **INV-21** `tax <= taxCap` for every transfer, both directions.
- **INV-22** A failing tax swap never reverts the user's transfer.
- **INV-23** `setAmmPair` accepts only factory-derived pairs, or is frozen after launch.
## Custody and config
- **INV-24** Owner is a Safe behind a timelock, or the delta is recorded as an accepted risk.
- **INV-25** The minted token address is immutable, or the setter is one-shot and already burned.
- **INV-26** Every user-facing parameter is `require`-bounded at set time.
---
## Reading a failure
- Holds on a fresh fork, fails after randomized calls → state accumulation; widen the run length.
- Fails only with a specific actor type (contract, smart wallet, multisig) → an access assumption, not
necessarily a bug; grade it.
- Needs a whitelisted caller → record the caller in the report; it is a trust dependency.
- Fails only at the cap boundary → re-test `cap-1`, `cap`, `cap+1`, and `remaining+1`.
## Do not assert these as bugs
- Tax charged on add/remove liquidity — usually by design.
- `tx.origin` gating blocking smart wallets — accepted trade-off, unless a multisig is meant to join.
- Permissionless trigger functions that only help (e.g. a public `swapFees`), as long as slippage is
protected.
- Book-only access for wallets without an identity, if the docs say so.
roles.example.env
# Copy to roles.env, fill what applies, then: RPC_URL=... ./scripts/snapshot.sh roles.env
# Leave a line out entirely if the deployment has no such contract.
TOKEN=
SALE=
QUIZ=
POOL=
BIDBOOK=
scripts/curve_math.py
#!/usr/bin/env python3
"""Reference linear launch curve: price P at zero sold, 5P at the supply cap.
Verifies the arithmetic a launch contract must satisfy, and optionally compares the
on-chain `cost`-style view against the closed form.
curve_math.py --p 1000000000000000 --supply 1050000e18 --check 1e18,10e18
curve_math.py --p ... --supply ... --rpc URL --to 0xSALE --sig "cost(uint256)" --check 1e18
Checks, in order:
1. monotonicity — every later unit is at least as expensive as the earlier one
2. integral consistency — sum of marginal prices never exceeds the closed-form total
3. rounding direction — total rounds up (protocol-favouring), never down
4. cap admission — the cumulative cost of the requested units stays under the budget
Exit 0 when all pass, 1 on any violation, 2 on input error.
The constants are a spec, not a claim about any deployment: read P and the supply cap from
the contract or its constructor args, then pass them here.
"""
import argparse
import sys
PRECISION = 10**18
def parse_amount(text):
"""Accept 1e18, 1_000e18, plain integers."""
t = text.strip().replace("_", "")
if "e" in t:
base, exp = t.split("e")
return int(float(base) * 10 ** int(exp))
return int(t)
def price_at(sold_units, p, supply_units):
"""Marginal price in wei per whole token: linear P -> 5P over the cap."""
if supply_units == 0:
return p
return p + (4 * p * sold_units) // supply_units
def cost_closed(a_units, b_units, p, supply_units):
"""Exact integral over [a, b] in whole tokens, rounded up, returned in wei.
price(x) = P + 4P·x/S -> ∫ = P(b-a) + 2P(b²-a²)/S, rational until the last step.
"""
num = p * (b_units - a_units) * supply_units + 2 * p * (b_units * b_units - a_units * a_units)
return (num + supply_units - 1) // supply_units
def cost_stepped(a, b, p, supply, step):
"""Discrete sum of marginal prices — the attacker's route to underpay."""
total = 0
for i in range(a, b, step):
chunk = min(step, b - i)
total += price_at(i, p, supply) * chunk
return total
def check(p, supply, amounts, budget, rpc=None, to=None, sig=None, dec=18):
fails = []
unit = 10 ** dec
supply_t = supply // unit
# 1. monotonicity
probes = [supply_t * i // 10 for i in range(0, 11)]
prices = [price_at(x, p, supply_t) for x in probes]
if any(b < a for a, b in zip(prices, prices[1:])):
fails.append("non-monotone: a later unit is cheaper than an earlier one")
print(f"price at 0%: {prices[0]}")
print(f"price at 100%: {prices[-1]}")
if prices[-1] != 5 * p:
fails.append(f"endpoint is {prices[-1]/p:.4f}x P, expected 5.0000x")
# 2/3/4. per-amount economics
sold = 0
for amount in amounts:
at = amount // unit
total = cost_closed(sold // unit, sold // unit + at, p, supply_t)
stepped = cost_stepped(sold // unit, sold // unit + at, p, supply_t, max(1, at // 100))
print(f"\namount {amount}: total {total}, stepped {stepped}")
if stepped > total:
fails.append(f"stepped sum exceeds integral for {amount} (payer undercharged)")
if budget is not None and total > budget:
fails.append(f"{amount} costs {total} which exceeds budget {budget}")
if rpc and to and sig:
onchain = rpc_cost(rpc, to, sig, amount)
if onchain is None:
print(" on-chain: unavailable")
else:
print(f" on-chain: {onchain} (delta {onchain - total})")
if onchain < total:
fails.append(f"on-chain cost under closed form by {total - onchain}")
sold += amount
if sold > supply:
fails.append(f"cumulative {sold} passes the cap {supply}")
break
print()
if fails:
for f in fails:
print("FAIL", f)
return 1
print("PASS monotone, rounding, no undercharge, inside budget")
return 0
def rpc_cost(url, to, sig, amount):
import json
import urllib.request
sel = sig.split("(")[0]
# cost(uint256) and quote(uint256) share this encoding: one uint argument
data = "0x" + _selector(sig) + format(amount, "x").rjust(64, "0")
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "eth_call",
"params": [{"to": to, "data": data}, "latest"]}).encode()
req = urllib.request.Request(url, body, {"content-type": "application/json",
"user-agent": "curve-math/1.0"})
try:
with urllib.request.urlopen(req, timeout=20) as resp:
out = json.load(resp)
except Exception:
return None
if "error" in out or not out.get("result") or out["result"] == "0x":
return None
return int(out["result"], 16)
_M64 = (1 << 64) - 1
_RC = [1, 0x8082, 0x800000000000808A, 0x8000000080008000, 0x808B, 0x80000001,
0x8000000080008081, 0x8000000000008009, 0x8A, 0x88, 0x80008009, 0x8000000A,
0x8000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
0x8000000000008002, 0x8000000000000080, 0x800A, 0x800000008000000A,
0x8000000080008081, 0x8000000000008080, 0x80000001, 0x8000000080008008]
_ROT = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61],
[28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]
def _rol(x, n):
return ((x << n) | (x >> (64 - n))) & _M64 if n else x
def _perm(st):
for rnd in range(24):
c = [st[x][0] ^ st[x][1] ^ st[x][2] ^ st[x][3] ^ st[x][4] for x in range(5)]
d = [c[(x - 1) % 5] ^ _rol(c[(x + 1) % 5], 1) for x in range(5)]
for x in range(5):
for y in range(5):
st[x][y] ^= d[x]
b = [[0] * 5 for _ in range(5)]
for x in range(5):
for y in range(5):
b[y][(2 * x + 3 * y) % 5] = _rol(st[x][y], _ROT[x][y])
for x in range(5):
for y in range(5):
st[x][y] = b[x][y] ^ ((~b[(x + 1) % 5][y]) & b[(x + 2) % 5][y])
st[0][0] ^= _RC[rnd]
def _selector(sig):
st = [[0] * 5 for _ in range(5)]
buf = bytearray(sig.encode()) + b"\x01"
while len(buf) % 136:
buf += b"\x00"
buf[-1] ^= 0x80
for off in range(0, len(buf), 136):
blk = buf[off:off + 136]
for i in range(17):
st[i % 5][i // 5] ^= int.from_bytes(blk[i * 8:i * 8 + 8], "little")
_perm(st)
return b"".join(st[i % 5][i // 5].to_bytes(8, "little") for i in range(4))[:4].hex()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--p", required=True, help="start price per token, wei")
ap.add_argument("--supply", required=True, help="sale cap in token base units")
ap.add_argument("--check", required=True, help="comma-separated amounts to price")
ap.add_argument("--budget", help="optional budget per amount, wei")
ap.add_argument("--rpc")
ap.add_argument("--to")
ap.add_argument("--sig", default="cost(uint256)")
ap.add_argument("--decimals", type=int, default=18)
a = ap.parse_args()
try:
p = parse_amount(a.p)
supply = parse_amount(a.supply)
amounts = [parse_amount(x) for x in a.check.split(",")]
budget = parse_amount(a.budget) if a.budget else None
except ValueError as exc:
print(f"bad number: {exc}")
return 2
if p <= 0 or supply <= 0:
return 2
return check(p, supply, amounts, budget, a.rpc, a.to, a.sig, a.decimals)
if __name__ == "__main__":
sys.exit(main())
scripts/rpc.py
#!/usr/bin/env python3
"""Dependency-free JSON-RPC helper (stdlib only) for the bsc-contract-audit skill.
Every on-chain read goes through here. Needs RPC_URL in the environment.
export RPC_URL=https://bsc-rpc.publicnode.com
rpc.py chain # must be 56
rpc.py block
rpc.py code 0xADDR
rpc.py storage 0xADDR 0x3608...bbc
rpc.py call 0xADDR "owner()"
rpc.py call 0xADDR "balanceOf(address)" 0xHOLDER --raw
rpc.py proxy 0xADDR
rpc.py scan 0xADDR ../references/selectors.md
rpc.py meta 0xADDR
rpc.py holders 0xADDR [topN] [fromBlock] # ERC20 concentration, no GoPlus (needs a premium RPC)
rpc.py nbal 0xADDR # native BNB balance in wei (escrow pool: held vs deposited)
`call` takes a Solidity signature (keccak computed locally) or a raw 0x selector.
Output is decoded per the declared return type: address, uint, bool, string, or a
comma-separated tuple such as uint112,uint112,uint32.
`--raw` prints the bare hex output.
Exit: 0 ok, 1 RPC/argument failure, 2 revert (missing function or missing arguments).
"""
import json
import os
import sys
import urllib.request
M64 = (1 << 64) - 1
RC = [1, 0x8082, 0x800000000000808A, 0x8000000080008000, 0x808B, 0x80000001,
0x8000000080008081, 0x8000000000008009, 0x8A, 0x88, 0x80008009, 0x8000000A,
0x8000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
0x8000000000008002, 0x8000000000000080, 0x800A, 0x800000008000000A,
0x8000000080008081, 0x8000000000008080, 0x80000001, 0x8000000080008008]
ROT = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61],
[28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]
def _rol(x, n):
return ((x << n) | (x >> (64 - n))) & M64 if n else x
def _perm(st):
for rnd in range(24):
c = [st[x][0] ^ st[x][1] ^ st[x][2] ^ st[x][3] ^ st[x][4] for x in range(5)]
d = [c[(x - 1) % 5] ^ _rol(c[(x + 1) % 5], 1) for x in range(5)]
for x in range(5):
for y in range(5):
st[x][y] ^= d[x]
b = [[0] * 5 for _ in range(5)]
for x in range(5):
for y in range(5):
b[y][(2 * x + 3 * y) % 5] = _rol(st[x][y], ROT[x][y])
for x in range(5):
for y in range(5):
st[x][y] = b[x][y] ^ ((~b[(x + 1) % 5][y]) & b[(x + 2) % 5][y])
st[0][0] ^= RC[rnd]
def keccak(data):
st = [[0] * 5 for _ in range(5)]
buf = bytearray(data) + b"\x01"
while len(buf) % 136:
buf += b"\x00"
buf[-1] ^= 0x80
for off in range(0, len(buf), 136):
blk = buf[off:off + 136]
for i in range(17):
st[i % 5][i // 5] ^= int.from_bytes(blk[i * 8:i * 8 + 8], "little")
_perm(st)
out = b"".join(st[i % 5][i // 5].to_bytes(8, "little") for i in range(4))
return out
def rpc(method, params):
url = os.environ.get("RPC_URL")
if not url:
sys.exit("RPC_URL is not set")
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode()
req = urllib.request.Request(
url, body, {"content-type": "application/json", "user-agent": "sc-audit/1.0"}
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.load(resp)
except Exception as exc: # noqa: BLE001
sys.exit(f"RPC failed: {exc}")
if "error" in payload:
print(f"REVERT: {payload['error'].get('message')}", file=sys.stderr)
sys.exit(2)
return payload["result"]
# keccak256("Transfer(address,address,uint256)")
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
def rpc_try(method, params):
"""Like rpc() but returns (ok, result_or_error) instead of exiting — for fallbacks."""
url = os.environ.get("RPC_URL")
if not url:
return False, "RPC_URL is not set"
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode()
req = urllib.request.Request(
url, body, {"content-type": "application/json", "user-agent": "sc-audit/1.0"}
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
payload = json.load(resp)
except Exception as exc: # noqa: BLE001
return False, str(exc)
if "error" in payload:
return False, payload["error"].get("message", "rpc error")
return True, payload["result"]
def selector(sig):
if sig.startswith("0x") and len(sig) == 10:
return sig[2:]
if "(" not in sig:
sys.exit(f"invalid signature: {sig}")
sig = sig.split(")(")[0] + ")" if ")(" in sig else sig
return keccak(sig.encode()).hex()[:8]
def pad_addr(a):
return a.lower().replace("0x", "").rjust(64, "0")
def pad_uint(v):
return format(int(str(v), 0), "x").rjust(64, "0")
def decode(hexdata, outype):
if not hexdata or hexdata == "0x":
return ""
body = hexdata[2:]
if "," in outype:
return ", ".join(
decode("0x" + body[i * 64:(i + 1) * 64], t)
for i, t in enumerate(outype.split(","))
)
word = body[:64]
if outype == "address":
return "0x" + word[-40:]
if outype == "bool":
return str(int(word, 16) != 0)
if outype.startswith("uint"):
return str(int(word, 16))
if outype == "string":
off = int(word, 16) * 2
ln = int(body[off:off + 64], 16) * 2
return bytes.fromhex(body[off + 64:off + 64 + ln]).decode(errors="replace")
return hexdata
def cmd_holders(addr, topn=10, from_block=0):
"""Reconstruct ERC20 holder balances from Transfer logs — a GoPlus-free concentration read.
Needs a premium/archive RPC: completeness requires every Transfer since deployment,
and public nodes cap eth_getLogs near 1000 blocks. Tries one full-range call, then
falls back to windowed scans (window = env LOGS_WINDOW, default 5000).
Caveat: reflection/rebase tokens (a `deliver()` selector) rewrite balances without a
Transfer event, so the log-sum diverges from live balanceOf — confirm top holders with
`rpc.py call 0xADDR "balanceOf(address)(uint256)" 0xHOLDER` when that is the case.
"""
total = int(rpc("eth_call", [{"to": addr, "data": "0x18160ddd"}, "latest"]), 16) # totalSupply()
latest = int(rpc("eth_blockNumber", []), 16)
bal = {}
def apply_logs(logs):
for lg in logs:
topics = lg.get("topics", [])
if len(topics) != 3: # ERC20 Transfer has exactly 3 topics; ERC721 has 4 (skip)
continue
frm = "0x" + topics[1][-40:]
to = "0x" + topics[2][-40:]
data = lg.get("data", "0x")
val = int(data[2:66], 16) if len(data) >= 66 else 0
if not val:
continue
bal[frm] = bal.get(frm, 0) - val
bal[to] = bal.get(to, 0) + val
flt = {"fromBlock": hex(from_block), "toBlock": hex(latest),
"address": addr, "topics": [TRANSFER_TOPIC]}
ok, res = rpc_try("eth_getLogs", [flt])
windowed = False
if ok:
apply_logs(res)
else:
windowed = True
win = int(os.environ.get("LOGS_WINDOW", "5000"))
print(f"# full-range getLogs failed ({str(res)[:60]}); windowing at {win} blocks",
file=sys.stderr)
start = from_block
while start <= latest:
end = min(start + win - 1, latest)
ok2, res2 = rpc_try("eth_getLogs", [{"fromBlock": hex(start), "toBlock": hex(end),
"address": addr, "topics": [TRANSFER_TOPIC]}])
if not ok2:
sys.exit(f"# window {start}-{end} failed: {str(res2)[:80]}\n"
f"# lower LOGS_WINDOW or use an archive RPC")
apply_logs(res2)
start = end + 1
zero = "0x0000000000000000000000000000000000000000"
dead = "0x000000000000000000000000000000000000dead"
holders = [(a, b) for a, b in bal.items() if b > 0 and a != zero]
holders.sort(key=lambda kv: kv[1], reverse=True)
print(f"total_supply {total}")
print(f"holder_count {len(holders)} (from Transfer logs{', windowed' if windowed else ''})")
print(f"range block {from_block} .. {latest}")
print(f"\ntop {topn} (strip LP pair / lockers / burn before judging concentration):")
for a, b in holders[:topn]:
pct = (b * 10000 // total) / 100 if total else 0
tag = " [burn]" if a == dead else ""
print(f" {a} {b} {pct}%{tag}")
def main(argv):
if len(argv) < 2:
sys.exit(__doc__)
cmd = argv[1]
if cmd == "chain":
print(int(rpc("eth_chainId", []), 16))
return
if cmd == "block":
print(int(rpc("eth_blockNumber", []), 16))
return
if len(argv) < 3:
sys.exit("need a contract address")
addr = argv[2]
if cmd == "code":
print(rpc("eth_getCode", [addr, "latest"]))
elif cmd == "nbal":
print(int(rpc("eth_getBalance", [addr, "latest"]), 16))
elif cmd == "meta":
for sig, typ in (("name()", "string"), ("symbol()", "string"),
("decimals()", "uint8"), ("totalSupply()", "uint256")):
try:
out = rpc("eth_call", [{"to": addr, "data": "0x" + selector(sig)}, "latest"])
print(f"{sig:14} {decode(out, typ)}")
except SystemExit:
print(f"{sig:14} (revert)")
elif cmd == "storage":
print(rpc("eth_getStorageAt", [addr, argv[3], "latest"]))
elif cmd == "proxy":
imp = rpc("eth_getStorageAt", [addr, "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", "latest"])[2:]
adm = rpc("eth_getStorageAt", [addr, "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103", "latest"])[2:]
bcn = rpc("eth_getStorageAt", [addr, "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50", "latest"])[2:]
zero = "0" * 64
if imp != zero:
print("implementation 0x" + imp[-40:])
if adm == zero:
print("admin 0x" + "0" * 40 + " (empty = UUPS pattern, admin lives in the implementation)")
else:
print("admin 0x" + adm[-40:])
elif bcn != zero:
print("beacon 0x" + bcn[-40:])
impl = rpc("eth_call", [{"to": "0x" + bcn[-40:], "data": "0x5c60da1b"}, "latest"])
print("implementation 0x" + impl[-40:])
else:
print("no EIP-1967 pattern (slots empty). Check Proxy/Implementation on the explorer,")
print("or for a custom pattern: look for delegatecall in the bytecode (rpc.py code 0xADDR)")
print("implementation 0x" + "0" * 40)
print("admin 0x" + "0" * 40)
elif cmd == "call":
if len(argv) < 4:
sys.exit("need a signature")
data = "0x" + selector(argv[3])
for arg in argv[4:]:
if arg.startswith("--"):
continue
data += pad_addr(arg) if (arg.startswith("0x") and len(arg) <= 42) else pad_uint(arg)
out = rpc("eth_call", [{"to": addr, "data": data}, "latest"])
if "--raw" in argv or ")(" not in argv[3]:
print(out)
return
print(decode(out, argv[3].split(")(")[1][:-1]))
elif cmd == "holders":
rest = [a for a in argv[3:] if not a.startswith("--")]
topn = int(rest[0]) if len(rest) >= 1 else 10
frm = int(rest[1], 0) if len(rest) >= 2 else 0
cmd_holders(addr, topn, frm)
elif cmd == "scan":
src = argv[3]
code = rpc("eth_getCode", [addr, "latest"]).lower()
hits = 0
with open(src) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t")
if len(parts) < 2:
continue
label, sel = parts[0].strip(), parts[1].strip()
n = code.count("63" + sel)
if n:
hits += 1
print(f"HIT {label:34} 0x{sel} x{n}")
print(f"\n{hits} selector hits (presence != live feature)")
else:
sys.exit(__doc__)
if __name__ == "__main__":
main(sys.argv)
scripts/snapshot.sh
#!/usr/bin/env bash
# Lifecycle and economic snapshot for a launch-curve deployment.
#
# ./snapshot.sh roles.env
#
# Prints every reading that grades a curve launch, marks absent calls as `absent`, and writes
# nothing. Record the block number with any verdict: the snapshot is invalid after a deploy, a
# launch, or an ownership change.
#
# roles.env — copy roles.example.env. RPC_URL comes from the environment.
set -uo pipefail
ROLES="${1:-roles.env}"
[ -f "$ROLES" ] || { echo "usage: $0 roles.env" >&2; exit 1; }
# shellcheck disable=SC1090
. "$ROLES"
: "${RPC_URL:?export RPC_URL first}"
: "${TOKEN:?TOKEN missing in $ROLES}"
SELDIR=$(cd "$(dirname "$0")" && pwd)
call() { # call <addr> <sig> [arg...]
local to="$1" orig_sig="$2" sig="$2"; shift 2
case "$sig" in *")("* ) sig="${sig%%)*})" ;; esac
local sel
sel=$(SELDIR="$SELDIR" python3 - "$sig" <<'PY'
import os, sys
sys.path.insert(0, os.environ["SELDIR"])
from curve_math import _selector
print(_selector(sys.argv[1]))
PY
)
local data="0x$sel"
for a in "$@"; do
case "$a" in
0x*) data="$data$(printf '%s' "${a#0x}" | tr 'A-F' 'a-f' | sed 's/^/000000000000000000000000/')" ;;
*) data="$data$(printf '%064x' "$a")" ;;
esac
done
local out
out=$(curl -s --max-time 20 -H 'content-type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":[{\"to\":\"$to\",\"data\":\"$data\"},\"latest\"]}" \
"$RPC_URL")
if printf '%s' "$out" | jq -e '.result and .result != "0x"' >/dev/null 2>&1; then
local r; r=$(printf '%s' "$out" | jq -r .result)
if [ "$orig_sig" != "$sig" ] && [[ "$orig_sig" == *")(address)"* ]]; then
printf '0x%s\n' "${r: -40}"
elif [ ${#r} -eq 66 ]; then
python3 -c "print(int('$r',16))"
else
printf '%s\n' "$r"
fi
else
echo absent
fi
}
balance() {
curl -s --max-time 20 -H 'content-type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBalance\",\"params\":[\"$1\",\"latest\"]}" "$RPC_URL" \
| jq -r '.result' | xargs -I{} python3 -c "print(int('{}',16)/1e18)"
}
block=$(curl -s --max-time 20 -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' "$RPC_URL" | jq -r '.result' | xargs -I{} python3 -c "print(int('{}',16))")
echo "block $block $(date -u +%FT%TZ)"
echo
echo "--- roles"
printf 'token %s\n' "$TOKEN"
for r in SALE POOL BIDBOOK; do [ -n "${!r:-}" ] && printf '%-10s %s\n' "$(echo "$r" | tr 'A-Z' 'a-z')" "${!r}"; done
owner=$(call "$TOKEN" "owner()(address)")
printf 'owner %s code=' "$owner"
if [ "$owner" != absent ]; then
code=$(curl -s -H 'content-type: application/json' -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getCode\",\"params\":[\"$owner\",\"latest\"]}" "$RPC_URL" | jq -r .result | tr -d '\n')
if [ "$code" = "0x" ]; then echo "0 (EOA)"; else echo "$(( (${#code} - 2) / 2 )) bytes (contract)"; fi
fi
echo
echo "--- lifecycle"
printf 'tradingOpen %s\n' "$(call "$TOKEN" 'tradingOpen()(bool)')"
[ -n "${SALE:-}" ] && {
printf 'launched %s\n' "$(call "$SALE" 'launched()(bool)')"
dl=$(call "$SALE" 'deadline()(uint256)')
printf 'deadline %s\n' "$dl"
[ "$dl" != absent ] && printf 'deadline UTC %s (%+d s)\n' \
"$(python3 -c "import datetime;print(datetime.datetime.fromtimestamp($dl, datetime.timezone.utc).isoformat())")" \
"$(( dl - $(date +%s) ))"
printf 'HARD_CAP wei %s\n' "$(call "$SALE" 'HARD_CAP()(uint256)')"
printf 'sold %s\n' "$(call "$SALE" 'sold()(uint256)')"
printf 'raised wei %s\n' "$(call "$SALE" 'raised()(uint256)')"
printf 'sale BNB balance %s\n' "$(balance "$SALE")"
}
[ -n "${BIDBOOK:-}" ] && printf 'bidbook BNB %s\n' "$(balance "$BIDBOOK")"
echo
echo "--- supply"
printf 'totalSupply %s\n' "$(call "$TOKEN" 'totalSupply()(uint256)')"
printf 'maxSupply %s\n' "$(call "$TOKEN" 'maxSupply()(uint256)')"
echo
echo "--- privilege"
printf 'isMinter(owner) %s\n' "$( [ "$owner" != absent ] && call "$TOKEN" 'isMinter(address)(bool)' "$owner")"
printf 'mintersLocked %s\n' "$(call "$TOKEN" 'mintersLocked()(bool)')"
printf 'paused %s\n' "$(call "$TOKEN" 'paused()(bool)')"
[ -n "${QUIZ:-}" ] && {
printf 'emissionCap %s\n' "$(call "$QUIZ" 'emissionCap()(uint256)')"
printf 'totalAllocated %s\n' "$(call "$QUIZ" 'totalAllocated()(uint256)')"
printf 'submitFee wei %s\n' "$(call "$QUIZ" 'submitFee()(uint256)')"
}
echo
echo "absent = selector not present or reverted; it is not proof of the feature's absence"
SKILL.md
---
name: launch-curve-audit
description: Method and tooling to audit a token launch curve on BSC — bonding-curve or tranche IEO, Agent-ID or allowlist gating, escrowed bids, graduation into a pool, coupled off-chain emissions. Models the price path, pins the deployment, runs the lifecycle invariants, prices the adversarial order book, and returns a ship/hold/block verdict with a finding register. Use for "audit this launch", "is this IEO safe", "review the bonding curve", "check the launch before it opens", "did they burn the LP", "can the owner change the curve". Trigger for any priced-curve / bonding-curve / tranche IEO / escrowed pre-launch sale on BSC, pre-launch or pre-investment; not for post-listing trading risk (use bsc-contract-audit).
metadata:
author: "setyamickala"
version: "1.0.1"
license: MIT
entrypoint: references/economics.md
testedAgainst: [linear P->5P curve, escrowed bid book]
whenToUse: A project sells or mines a token through a priced curve, tranche, or escrowed pre-launch book on BSC and the user wants it reviewed — as a pre-launch gate, a pre-investment read, or a post-deploy re-check. Not for plain fixed-price IDOs without a curve, and not for post-listing trading risk (use bsc-contract-audit).
companions:
note: >-
Pairs with bsc-contract-audit (run it on the launched token/NFT and any lock escrow) and
with the Binance agentic wallet skills as the pre-launch decision layer.
skills:
- bsc-contract-audit
- query-token-audit
- binance-agentic-wallet
disclaimer: >-
Independent community skill. Not affiliated with or endorsed by Binance or Anthropic.
---
# Launch Curve Audit
A curve launch fails in ways a token audit does not look for: the price path undercharges, the budget
is consumed out of order, graduation mints into a pool nobody can exit, or the opening price is moved
before anyone trades. The money is also not the holder's — it is the buyer's, and it leaves through a
sale contract, not a transfer.
So the object here is the *sale mechanism*: pricing, admission, escrow, graduation, coupling to
whatever emits the token afterwards.
Linear P→5P with Agent-ID gating on BSC is the reference shape it was built against, but the method is
not limited to that shape — read the parameters from the contract and model whatever curve it encodes.
## Scope
In: the sale or curve contract, its pricing library, admission control (allowlist, registry, Agent ID,
per-wallet and per-identity budgets), the escrow book if any, the graduation/launch path and LP
custody, the token's pre-launch transfer lock and the tax wiring, and the emission contract the launch
hands off to.
Out: backend award logic except as a trust assumption, frontend, deploy scripts, the AMM itself, and
post-listing market risk. Say which of these you skipped.
## Intake
Collect before touching the chain; ask for what is missing:
1. Chain and addresses (token, sale, pool, book, owner). On a SPA, addresses are in the JS bundle, not
the page — grep `0x[a-fA-F0-9]{40}` and read the surrounding config keys.
2. Curve parameters: start price, end price, supply cap, budget cap, per-identity cap, duration.
3. Admission model: open, allowlist, registry/Agent-ID, stake gate. Who can be admitted and who
decides.
4. Graduation: what happens at cap and at deadline, who can trigger it, where the LP goes.
5. What emits the token after launch, and who can call it.
6. Verified source (Sourcify first, Etherscan v2 `chainid=56` second) and the audited commit.
An address without parameters is still auditable — read them from the constructor args and the
bytecode. Do not accept them from the docs alone.
## Evidence rules
Highest first, and drop a level only when the one above fails:
1. Bytecode and on-chain state at a pinned block.
2. Verified source, `exact_match` only.
3. A forked simulation of the exact call sequence.
4. Constructor args and deployment transaction.
5. Docs, site, and medium posts as claims to be diffed.
Hard rules:
- Price every claim in BNB and token units. "Cheap" without a number is not a finding.
- A curve property proved only by reading the comment is unproven; call it.
- Never grade a sale by its homepage. The page and the bytecode diverge — that gap is a finding of its
own.
- Report absent functions as *absent*, not as missing, until you have the ABI. A selector can be
missing from a scan and still be reachable through a proxy or a library.
- Pin the block. A snapshot without a block is a rumour.
## Tooling
```bash
export RPC_URL=https://bsc-rpc.publicnode.com
./scripts/snapshot.sh roles.env # lifecycle, supply, privilege, balances
python3 scripts/curve_math.py --p <wei> --supply <units> --check 1e18,100e18 [--budget <wei>]
```
`scripts/rpc.py` is bundled for one-off calls. `snapshot.sh` also prints whether the owner is an EOA or a contract — the first thing that decides the
verdict. `curve_math.py` is a reference integral: it proves monotonicity, rounding direction, and that
no step-wise order pays less than the closed form. Both are generic; `roles.example.env` is the input
template.
## Flow
Model the curve first, then walk the gates. The four red gates are hard BLOCKs; owner custody is the
classic HOLD. Each gate maps to a Workflow step below.
```mermaid
flowchart TD
A[Intake & model curve<br/>pin block · owner type] --> G1{Gate 1 · exit<br/>buyers can exit / refund?}
G1 -->|no| B1[BLOCK · buyer funds trapped]
G1 -->|yes| G2{Gate 2 · curve<br/>cost = integral, cap safe?}
G2 -->|no| B2[BLOCK · undercharge / cap blowable]
G2 -->|yes| G3{Gate 3 · opening price<br/>not settable?}
G3 -->|no| B3[BLOCK · price settable: donate+sync]
G3 -->|yes| G4{Gate 4 · graduation<br/>anyone triggers, LP burned/locked?}
G4 -->|no| B4[BLOCK · LP recoverable]
G4 -->|yes| G5{Gate 5 · custody<br/>owner Safe, setters bounded?}
G5 -->|no| H1[HOLD · owner EOA / loose setters]
G5 -->|yes| D[Deep review<br/>coupling · admission · invariants]
D --> S[Verdict<br/>SHIP · HOLD · BLOCK]
S --> V[+ DECISION<br/>GO / CAUTION / BLOCK]
```
## Workflow
### 0. Model the economics before reading code
Write the intended curve as a formula, from the parameters, without the implementation:
`price(x)`, `cost(a,b) = ∫price`, the cap, the admission rule, the graduation trigger.
Then check the implementation against the model. A mismatch is either a documented different design
or the finding — decide which, and say so.
### 1. Pin the deployment
```bash
./scripts/snapshot.sh roles.env
python3 scripts/curve_math.py --p ... --supply ... --check ...
```
Record block, owner type, cap, sold, raised, deadline, and current state (`tradingOpen`, `launched`).
Three floor items, each of which alone caps the verdict: owner is an EOA, deadline already passed with
no launch, cap reached with no graduation.
### 2. Price the curve
Full catalogue in [references/economics.md](references/economics.md). The four that pay:
- **Undercharge.** Rounding down on cost, or splitting a purchase into steps, pays less than the
integral. Compare `cost(n)` against `cost(n/2) * 2` and against the closed form.
- **Overflow.** Multiplication before division without a 512-bit `mulDiv`, on a supply near `2^96`.
- **Cap admission.** A purchase at or beyond the cap must revert or truncate by rule, and the last
unit must stay inside the budget — not silently overshoot.
- **Budget bypass.** Per-identity caps that reset per wallet, or that are booked after the transfer.
Plenty of sales have no `cost()`/`quote()` getter — the price is computed inline in a payable buy. Then
price it by state delta (`raised`, `sold` before/after) or by a fork `eth_call` of the buy function with
`value`, and record which method you used.
### 3. Run the invariants
[references/invariants.md](references/invariants.md) is the generic list; write the project-specific
ones from step 0. Prove them on a fork, with adversarial call order, funded actors, and repeated
identities. A property that holds only in the happy path is not a property.
### 4. Play the adversary
[references/attack-surface.md](references/attack-surface.md) walks the phases in order. The launch
specifics a generic audit misses:
- Donate-then-`sync()` to move the opening price, or to skip a skim.
- Buy the first unit, then trigger the tax swap or fee conversion into the gap.
- `amountOutMin = 0` on any swap the sale performs with its own balance.
- Refund reentrancy in the escrow book, and refund accounting that decrements one side only.
- Front-run the cap: last-block sniping to close a sale the team wanted open.
- Claim paths whose gas grows with entries, so rewards become unclaimable.
### 5. Graduation and custody
At cap and at deadline, the code must do one defined thing, triggered by anyone, with the LP going to
a burn or a verified locker. Check: is launch gated behind the owner, a pause, or an external registry
read? Does an empty sale still graduate? Is the LP custodied in an intermediate address? Does the
token's trading lock release in the same transaction?
### 6. Coupling
The sale is half the system. If a second contract mints after launch:
- Is the post-launch mint bound to a budget, or only to `maxSupply`?
- Does the budget carry over monotonically, or reset each period?
- Is the minter list lockable, and is the owner out of it?
- What happens at the cap — does the claim path clamp, or revert everything?
- Can the sale's owner swap the token or the emission contract afterwards?
### 7. Admission and identity
Allowlist and registry gates are where insider allocation hides. Measure: how many identities, what
allocation each, when published, who can add. A 500-wallet allowlist on a 1,000-unit supply is insider
capture. `tx.origin` gating breaks smart wallets — record it as behaviour, not a bug, unless a proxy or
multisig is expected to participate.
### 8. Admin and parameters
Enumerate every setter on the path and ask whether the value is `require`-bounded. Unbounded:
submit fees, tax, per-wallet caps, curve endpoint, pair address, fee recipient. Accepted parameters:
an immutable token, a one-shot pool set, a locked minter list. Then the custody question: Safe with a
timelock, or one EOA? An EOA owner on a live sale is a cap, not a pass.
### 9. Verdict
| Verdict | Condition |
|---|---|
| **BLOCK** | Holder/buyer funds cannot exit, the cap can be blown, the opening price is settable, or the LP is recoverable |
| **HOLD** | Buyer exits work and the curve is sound, but an open admin path or an unproven invariant remains |
| **SHIP** | Curve, invariants, graduation, and custody all proven; residual items are Info |
Caps that hold the verdict down regardless of count: owner is an EOA (HOLD), deadline or launch is
owner-gated (HOLD), more than half the supply is insider-allocated (HOLD), any refund path is missing
where the docs promise one (BLOCK).
## Integration & gate decision
This skill is the pre-launch decision layer; it pairs with `bsc-contract-audit` and the Binance agentic wallet skills:
- **Hand off the asset.** Once the sale graduates, run `bsc-contract-audit` on the launched token/NFT (TOKEN/MINT track) and on any deposit-refund escrow (LOCK track). This skill covers the *sale mechanism*; that one covers the *asset and the lock*.
- **Gate the wallet.** When called before buying into a launch, end with the same machine-readable line the wallet reads:
```
DECISION: GO | CAUTION | BLOCK — <one-line reason>
```
Map it from the verdict: **SHIP → GO**, **HOLD → CAUTION** (proceed only after the user acknowledges the open caps), **BLOCK → BLOCK** (refuse). If this audit and the wallet's own `query-token-audit` disagree, the stricter result wins.
## Report format
```markdown
# Launch audit <SYMBOL> — curve launch, BSC 56 <ADDR>
Block <n> · source exact_match · owner EOA|Safe · cap <n> BNB · sold <n>/<cap>
Verdict: HOLD (caps: owner EOA, setAmmPair live)
DECISION: CAUTION — curve sound and buyer exits work, but owner is an EOA with a live setAmmPair
## Economics
price 0% <wei> -> 100% <wei> · integral vs stepped: <delta> · max purchase inside budget: yes/no
## Findings
| ID | Sev | Phase | Function | Trigger | Invariant |
|---|---|---|---|---|---|
| F-01 | High | curve | cost | step-split pays 0.4% less | INV-3 |
## Invariants
INV-1 ok · INV-3 FAIL · ...
## Unproven
## Recommendations
## Evidence log
E1 <command> -> <trimmed output>
## Snapshots
block, owner type, balances
```
Every finding names the phase from `attack-surface.md`, the function, and the invariant it breaks.
Severity: Critical = buyer funds exit-insecure or price settable; High = cap or budget blowable, LP
recoverable; Medium = value extraction under a realistic trigger, launch liveness; Low = wrong
behaviour, inaccurate comments; Info = hygiene.
Always close with `Reference only, not investment advice.`
## Common misreads
- **Sold out = healthy.** Check whether the last chunk was the team's own wallet, and whether the
curve actually reached its endpoint or just its budget.
- **Renounced owner = safe.** A locked minter list on an unlocked emission contract means nothing.
- **"Only 1% tax."** Percentages of what, charged when, and by whom — read `_transfer`.
- **Donation moves nothing.** It moves the opening price, and the donor's loss is not the finding.
- **Absent getter = absent feature.** `buyTax()` reverting usually means a different getter name.
- **Passing tests = sound curve.** Fork tests prove what they assert and nothing else; price the
arithmetic yourself.
- **Graduation looks automatic.** If launch is behind `onlyOwner`, the sale can be left open forever.
- **Snapshot reused after a deploy.** Re-read; record the block.