Files
pumpfun-bonkfun-bot_github/tests/regression/verify_no_rpc_credentials_logged.py
T
ebbfa9f124 chore(deps): upgrade to solders 0.29 / solana 0.40 and bring the rest of the dependencies current (#217)
* chore(deps): upgrade solders to 0.29 and solana to 0.40 in cookbook and tools

solana 0.40 moves TxOpts (now TxOptsModel), MemcmpOpts and TokenAccountOpts
from solana.rpc.types to solana.rpc.core, and spl.token's *Params dataclasses
to spl.token.models as pydantic models — validated and keyword-only. Three
call sites only failed against mainnet: MemcmpOpts.bytes now takes a base58
str, SyncNativeParams was built positionally, and confirm_transaction was
passed a bare "confirmed".

Also moves every cookbook and tools send path off the legacy Transaction onto
VersionedTransaction + MessageV0, and teaches the getTransaction decoder the
json and base64 encodings alongside jsonParsed — the base64 v1 fixture needs
solders 0.29 to deserialize at all.

src/ is untouched and still on the old imports; the offline suite is 3/19
until that lands.

Verified on mainnet: a 0.003 SOL buy_v2/sell_v2 round trip confirmed and the
wallet returned to SOL only, all six listeners and every read-only script run
live, five trade paths simulated with error=None.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): move the bot onto solders 0.29 and solana 0.40

Same three API moves as the cookbook commit, now in src: TxOpts is
TxOptsModel in solana.rpc.core, and spl.token's BurnParams/CloseAccountParams
come from spl.token.models. build_and_send_transaction builds a
VersionedTransaction from MessageV0 with no lookup tables, so the bot no
longer sends a legacy transaction anywhere.

verify_transaction_v1 proved the log route by stripping the logs and expecting
nothing back, which only held while solders could not read a v1 envelope. On
0.29 it can, so the check is inverted: make the envelope unreadable, keep the
logs, and the create must still arrive. A second check pins the envelope
fallback, so a decode regression is reported rather than silently absorbed by
the log route.

Offline: 19/19. Live on mainnet, funds returned:
- tools/simulate_bot_buy_path: zero-RPC buy path, err=None, 107,015 CU
- tools/simulate_v2_trades: buy_v2 95,573 CU, buy+sell 174,190 — both inside
  the existing 180k/120k limits, so no retune
- tools/live_v2_round_trip: buy_v2 + sell_v2 both landed
- tools/live_listener_matrix: all four listeners detected; logs and pumpportal
  ran full round trips; geyser and blocks both drew a coin on an unconfigured
  quote mint and correctly refused to size a buy. Every ATA closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bring the rest of the dependencies to current

Every remaining dependency is now at its latest release, with the floors
raised to the versions actually tested rather than the oldest that resolves:

  grpcio        1.73.1 -> 1.84.0    grpcio-tools 1.73.1 -> 1.84.0
  protobuf      6.33.5 -> 7.36.2    uvloop       0.21.0 -> 0.22.1
  pyyaml        6.0.2  -> 6.0.3     python-dotenv 1.2.2 -> 1.2.3
  construct     2.10.70 (floor)     websockets   17.1 (floor)
  ruff          0.12.4 -> 0.16.8

aiohttp and base58 were already current. winloop is left at its old floor:
it is Windows-only and cannot be installed, let alone tested, here.

protobuf 7 arrives with grpcio-tools 1.84 and is the only major. The
committed geyser stubs are gencode 6.31.1 and still pass
ValidateProtobufRuntimeVersion against it; they are not regenerated, because
the committed files import each other absolutely and protoc does not emit
that, so the output would need rewriting. Noted in CLAUDE.md with the
verification date.

ruff 0.16 needs no config change. The repo-wide baseline moves 1680 -> 1713
because the rules moved, not the code; CLAUDE.md carries the new figure so it
is not misread as a regression.

Verified per dependency, not just in aggregate: all four bot configs load
under the new pyyaml/dotenv; the logs, blocks and pumpportal listeners run
under the uvloop policy bot_runner installs; both geyser paths (token
listener and the account-filtered graduation watcher) run under grpcio 1.84 /
protobuf 7. Offline 19/19, including verify_listener_cancellation for
websockets. One more live buy_v2/sell_v2 round trip on the final set, both
landed, ATA closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(utils): redact RPC credentials from every log record

The provider endpoints carry their API key in the URL and the HTTP clients log
a full request URL at INFO, so anything that raises the root logger to INFO
prints the key. tools/cleanup_accounts.py did, four times, in a terminal.

It was already silencing httpx and httpcore by name. That stopped working the
moment solana-py 0.40 switched to httpx2: the rename took the guard with it
and nothing said so. Naming the clients is now a convenience on top of the
real control — install_secret_redaction masks the value itself, at log-record
creation, whichever logger emits it.

It redacts the *rendered* message rather than record.msg plus the string
arguments. The first version of this did the latter and still leaked: httpx2
passes its URL as a URL object, so the type check skipped the only argument
carrying the key. Confirmed by forcing httpx2 back to INFO and watching the
key print anyway. The host is kept, so a log line still says which provider
answered.

Installed from get_logger, from both logging.basicConfig sites, and from
setup_file_logging before it attaches the handler — logs/ outlives a terminal.

verify_no_rpc_credentials_logged covers all of it, including the non-str
argument. Checked against the defeated version first: it fails there and
passes here. Suite is 20/20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agents): make a real bot run the verification, not the simulations

The ground rule said never to run a bot with real funds, which points at the
tools/ harnesses instead. Those call the buyer, seller and listeners directly:
they skip bot_runner, config loading, the trader loop, the exit strategy and
cleanup, so passing there says nothing about the paths only a full run reaches.
Verification is a real run, with explicit approval for that session, covering
every mode the change touches and ending with the wallet holding SOL only.

Also records why a run can look clean when nothing happened: pump_bot runs every
bots/*.yaml, three of the four committed configs ship enabled: false, and with
all of them disabled it prints nothing and exits 0. The log file is the tell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(utils): keep the redaction comment provider-neutral

The parameter list is what matters, not which provider uses which spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 13:28:43 +02:00

280 lines
10 KiB
Python

"""Verify no RPC credential can reach a log line.
The provider endpoints in `.env` carry their API key inside the URL, and the
HTTP clients log a full request URL at INFO. Anything that raises the root
logger to INFO therefore prints the key — `tools/cleanup_accounts.py` did,
for four runs on 2026-09-23, in a terminal and into `logs/`.
That script was already silencing `httpx` and `httpcore` by name. It leaked
anyway, because solana-py 0.40 swapped httpx for httpx2 and the rename took the
guard with it. So naming the clients is a convenience, not the control: these
checks prove the value is masked whichever logger emits it.
Offline machine checks, no network and no funds moved:
1. `redact` masks a secret-bearing query parameter, URL userinfo, and an
opaque path segment — the three shapes a provider credential takes.
2. `redact` masks an exact value registered from the environment, keeping the
host so a log line still says which provider answered.
3. A logger this repo has never heard of, logging at INFO, comes out
redacted — including when the URL is a non-`str` argument, which is how
httpx2 passes it and how the first version of this guard was defeated.
This is the check that would have caught the httpx -> httpx2 rename.
4. Importing `core.client` installs the redaction: every module that logs
calls `get_logger` at import, and that is where it is installed.
5. `setup_file_logging` installs it before attaching the handler, so nothing
reaches `logs/` unredacted.
6. Every `logging.basicConfig` in `src/` and `tools/` installs it too, so the
guard does not depend on import order.
7. No script prints an endpoint environment variable raw; the hostname is the
most any of them may show.
Usage:
uv run tests/regression/verify_no_rpc_credentials_logged.py
"""
import logging
import os
import re
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from utils.logger import ( # noqa: E402
REDACTED,
install_secret_redaction,
redact,
)
SCAN_ROOTS = ("src", "tools", "cookbook")
# The variables whose values are credentials. Printing one raw is the bug.
SECRET_ENV_VARS = (
"SOLANA_PRIVATE_KEY",
"GEYSER_API_TOKEN",
"SOLANA_NODE_RPC_ENDPOINT",
"SOLANA_NODE_WSS_ENDPOINT",
"GEYSER_ENDPOINT",
)
# A print or f-string that interpolates one of those names directly. Reading
# `.hostname` off it first is allowed and is what the cookbook scripts do.
RAW_ENV_IN_OUTPUT = re.compile(
r"(?:print|logger\.\w+|logging\.\w+)\([^)]*\{("
+ "|".join(SECRET_ENV_VARS + ("RPC_ENDPOINT", "WSS_ENDPOINT"))
+ r")\}"
)
class _NotAString:
"""Stands in for httpx2's URL object: renders as a URL, is not a `str`."""
def __init__(self, value: str) -> None:
self._value = value
def __str__(self) -> str:
return self._value
class _Capture(logging.Handler):
"""Collect formatted records so a check can read what would be printed."""
def __init__(self) -> None:
super().__init__()
self.lines: list[str] = []
def emit(self, record: logging.LogRecord) -> None:
self.lines.append(record.getMessage())
def check_redact_masks_every_credential_shape() -> bool:
"""The three ways a provider puts a key in a URL."""
cases = {
"https://example.com/?api-key=deadbeef0123": "api-key=deadbeef0123",
"https://example.com/?apiKey=deadbeef0123": "deadbeef0123",
"wss://user:hunter2@example.com/stream": "user:hunter2",
"https://example.com/abcdef0123456789abcdef/": "abcdef0123456789abcdef",
}
ok = True
for raw, secret in cases.items():
cleaned = redact(raw)
if secret in cleaned or REDACTED not in cleaned:
print(f" not masked: {raw} -> {cleaned}")
ok = False
return ok
def check_redact_masks_a_registered_value() -> bool:
"""An exact environment value is masked, and the host survives."""
endpoint = "https://rpc.example.com/9f8e7d6c5b4a39281706"
os.environ["SOLANA_NODE_RPC_ENDPOINT"] = endpoint
try:
install_secret_redaction()
cleaned = redact(f"connecting to {endpoint}")
finally:
del os.environ["SOLANA_NODE_RPC_ENDPOINT"]
install_secret_redaction()
if "9f8e7d6c5b4a39281706" in cleaned:
print(f" the registered value survived: {cleaned}")
return False
if "rpc.example.com" not in cleaned:
print(f" the host was masked too, which makes the line useless: {cleaned}")
return False
return True
def check_an_unknown_logger_is_redacted() -> bool:
"""The control, and the one that survives a library renaming its logger.
Silencing clients by name is a list that goes stale. This logs the same
line httpx2 logs, through a name nothing in this repo mentions, at INFO.
"""
install_secret_redaction()
logger = logging.getLogger("some.library.nobody.listed")
logger.setLevel(logging.INFO)
capture = _Capture()
logger.addHandler(capture)
try:
logger.info(
"HTTP Request: POST https://mainnet.example.com/?api-key=deadbeef0123 "
'"HTTP/2 200 OK"'
)
logger.info("endpoint=%s", "https://mainnet.example.com/?api-key=deadbeef0123")
# The shape that actually leaks. httpx2 passes its URL as a URL object,
# not a str, so a guard that redacts `record.msg` and then type-checks
# each argument skips the only one carrying the key. The first version
# of this guard did that and the key still reached the terminal.
logger.info(
'HTTP Request: %s %s "%s"',
"POST",
_NotAString("https://mainnet.example.com/?api-key=deadbeef0123"),
"HTTP/2 200 OK",
)
finally:
logger.removeHandler(capture)
leaked = [line for line in capture.lines if "deadbeef0123" in line]
if leaked:
print(f" {len(leaked)} of {len(capture.lines)} lines kept the key")
return False
if not all("mainnet.example.com" in line for line in capture.lines):
print(" the host was masked too, which makes the line useless")
return False
return len(capture.lines) == 3
def check_importing_src_installs_redaction() -> bool:
"""A fresh interpreter that imports the RPC client is already protected.
`core.client` is the module that talks to the endpoint, and like every
module that logs it calls `get_logger` at import, which installs the
redaction. Run out of process, because this interpreter already has it.
"""
code = (
"import sys, logging; sys.path.insert(0, 'src');"
" import core.client;"
" log = logging.getLogger('third.party');"
" log.setLevel(logging.INFO);"
" import io;"
" buf = io.StringIO();"
" h = logging.StreamHandler(buf);"
" log.addHandler(h);"
" log.info('GET https://x.example.com/?api-key=deadbeef0123');"
" print('LEAK' if 'deadbeef0123' in buf.getvalue() else 'CLEAN')"
)
result = subprocess.run( # noqa: S603
[sys.executable, "-c", code],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
check=False,
)
if "CLEAN" not in result.stdout:
print(f" {result.stdout.strip() or result.stderr.strip()[:200]}")
return False
return True
def check_file_logging_installs_redaction() -> bool:
"""The other way a root handler appears, and the one that writes to logs/.
A line printed to the terminal is gone when the scrollback is; a line in
`logs/` is not, and `logs/` is what gets attached to a bug report.
"""
source = (PROJECT_ROOT / "src" / "utils" / "logger.py").read_text()
body = source.split("def setup_file_logging")[1]
if "install_secret_redaction()" not in body.split("root_logger.addHandler")[0]:
print(" setup_file_logging attaches a handler before redacting")
return False
return True
def check_every_basic_config_installs_redaction() -> bool:
"""`logging.basicConfig` is what turns the leak on; it must turn the guard on too."""
offenders = []
for root in ("src", "tools"):
for path in sorted((PROJECT_ROOT / root).rglob("*.py")):
source = path.read_text()
if "logging.basicConfig" not in source:
continue
if "install_secret_redaction" not in source:
offenders.append(path.relative_to(PROJECT_ROOT))
for path in offenders:
print(f" {path} calls basicConfig without install_secret_redaction")
return not offenders
def check_no_script_prints_a_raw_endpoint() -> bool:
"""Print the hostname if you must, never the value."""
offenders = []
for root in SCAN_ROOTS:
for path in sorted((PROJECT_ROOT / root).rglob("*.py")):
for number, line in enumerate(path.read_text().splitlines(), start=1):
if RAW_ENV_IN_OUTPUT.search(line):
offenders.append(f"{path.relative_to(PROJECT_ROOT)}:{number}")
for hit in offenders:
print(f" {hit} interpolates a credential straight into output")
return not offenders
def main() -> int:
checks = [
(
"redact masks every credential shape",
check_redact_masks_every_credential_shape,
),
(
"redact masks a registered value, keeping the host",
check_redact_masks_a_registered_value,
),
("an unlisted logger is redacted too", check_an_unknown_logger_is_redacted),
(
"importing the RPC client installs the redaction",
check_importing_src_installs_redaction,
),
("file logging installs the redaction", check_file_logging_installs_redaction),
(
"every basicConfig installs the redaction",
check_every_basic_config_installs_redaction,
),
("no script prints a raw endpoint", check_no_script_prints_a_raw_endpoint),
]
failed = 0
for label, check in checks:
try:
ok = check()
except Exception as error: # noqa: BLE001 - report and continue
print(f"FAIL {label}: {type(error).__name__}: {error}")
failed += 1
continue
print(f"{'PASS' if ok else 'FAIL'} {label}")
failed += 0 if ok else 1
print(f"\n{len(checks) - failed}/{len(checks)} checks passed")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())