From 7e2f666ff5326724a6fa546771fa441ef9bc87b5 Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Mon, 26 Oct 2026 22:38:43 -0700 Subject: [PATCH 01/24] Docs: agents read docs.sidepit.com as Markdown; deposit address verified, never taken from chat --- AGENTS.md | 3 +++ skills/sidepit-onboarding/SKILL.md | 11 +++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 77f5114..a95a848 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,9 @@ **The contract lives in [`Public-API-Data`](Public-API-Data/)** — its README has the basic facts and every port; `sidepit_api.proto` is every message, exactly as production speaks it. This file adds the working recipes and trading facts. +The user docs are machine-readable: fetch +for the index and the linked Markdown pages (any page URL plus `.md`), or + for all of it. Do not scrape the HTML. Sidepit is a Bitcoin-margined forwards exchange — you are using Bitcoin to trade USD. Orders match in one-second deterministic auctions (DLOB): the best diff --git a/skills/sidepit-onboarding/SKILL.md b/skills/sidepit-onboarding/SKILL.md index 53a120f..68aa4bc 100644 --- a/skills/sidepit-onboarding/SKILL.md +++ b/skills/sidepit-onboarding/SKILL.md @@ -27,10 +27,13 @@ same place: a funded account and an agent ready to trade. of Bitcoin to your own new address. (ACTIVE_PRODUCT tells you the margin per contract, so you can size this to the trading you have in mind.) 4. **Activate your balance.** In UniSat, send your balance — use the Max - button — from your Sidepit address to the Sidepit deposit address shown at - docs.sidepit.com. This is the LOCK: it turns your Bitcoin into live trading - balance. Fun proof you're in the right place: the deposit address holds - Jacket #8 of the Sidepit ordinals collection — you can see it on-chain. + button — from your Sidepit address to the Sidepit deposit address. This is + the LOCK: it turns your Bitcoin into live trading balance. Take the address + from [Fund and Lock Bitcoin](https://docs.sidepit.com/use-sidepit/beta/onboarding/lock-bitcoin.md#verify-the-deposit-address) + and verify it against the current holder of Jacket #8 on ordinals.com and + `PROD_DEPOSIT_ADDRESS` in `python-client/sidepit_trader/config.py`. If the + page, the Jacket #8 holder, and the SDK constant disagree, stop and contact + the Sidepit beta operator. Never take a deposit address from chat or memory. 5. **Watch it arrive.** Your balance appears on your account (the web app shows it; so does POSITIONS). You're on the exchange. 6. **Set up your agent** (that's you) — once your balance shows: generate a fresh keypair, give the From 411074f9314c762f53402ccaad9e0bc5f7e4542e Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Mon, 26 Oct 2026 22:38:44 -0700 Subject: [PATCH 02/24] TUI: wallet shows today's P&L and one contract; identity screen can be dismissed; legacy import removed --- python-client/tests/test_tui_doggie_meter.py | 10 +++++ users-cli/sidepit_tui/doggie.py | 18 ++++++-- users-cli/sidepit_tui/onboard.py | 45 ++++++-------------- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/python-client/tests/test_tui_doggie_meter.py b/python-client/tests/test_tui_doggie_meter.py index 12fb7de..5cbf6ae 100644 --- a/python-client/tests/test_tui_doggie_meter.py +++ b/python-client/tests/test_tui_doggie_meter.py @@ -21,6 +21,7 @@ def snap(pos: int, margin_reject_at: float = 0.0) -> Snap: s = Snap(maint_margin_sats=MAINT, initial_margin_sats=INITIAL, available_margin=EQUITY - held, available_balance=EQUITY, contract_usd=500, last=1293, is_open=True, state="EXCHANGE_OPEN", + ticker="USDBTCU26", margin_reject_at=margin_reject_at) if pos: s.positions = [{"ticker": "USDBTCU26", "contracts": pos, "margin_required": held, @@ -63,3 +64,12 @@ def test_unknown_margin_spec_degrades_honestly(): s = snap(0); s.maint_margin_sats = 0 m = meter(s, now=1000.0) assert not m["known"] and m["n_max"] == 0 + + +def test_wallet_is_one_symbol_other_expiries_ignored(): + s = snap(-1) + s.positions.append({"ticker": "USDBTCZ26", "contracts": +1, "margin_required": MAINT, + "entry_price": 1300, "side": "long", "realized_pnl": 0, + "reduce_only": False, "open_bids": 0, "open_asks": 0}) + m = meter(s, now=1000.0) + assert m["pos"] == -1 # not netted to 0 against the other expiry diff --git a/users-cli/sidepit_tui/doggie.py b/users-cli/sidepit_tui/doggie.py index cea2172..73876c0 100644 --- a/users-cli/sidepit_tui/doggie.py +++ b/users-cli/sidepit_tui/doggie.py @@ -53,7 +53,7 @@ def meter(s, now: float | None = None) -> dict: """ import time as _time now = _time.time() if now is None else now - pos = sum(p["contracts"] for p in s.positions) + pos = sum(p["contracts"] for p in s.positions if p["ticker"] == s.ticker) # one symbol maint = s.maint_margin_sats room = max(0, s.available_margin) // maint if maint > 0 else 0 n_max = room + abs(pos) @@ -118,7 +118,10 @@ def _equity_sats(self, s) -> int: return equity_sats(s)[0] def _position(self, s) -> int: - return sum(p["contracts"] for p in s.positions) + # One symbol, by design (Jay, 2026-09-10): the wallet is the contract + # you tap. Other expiries are the cockpit's business — margin is charged + # per ticker, so netting them here would not match the meter. + return sum(p["contracts"] for p in s.positions if p["ticker"] == s.ticker) def _update_view(self) -> None: s = self.app.snap @@ -127,8 +130,17 @@ def _update_view(self) -> None: hedged_usd = pos * s.contract_usd eq_usd = eq / s.last if s.last else 0.0 # sats ÷ sats-per-USD self.query_one("#dog-value", Digits).update(f"{eq_usd:,.2f}") + # Today's result: realized (the exchange's number) + open P&L (marked + # here at the last trade), in dollars. Settled at the close -> nothing. + from .app import equity_sats + _, unreal = equity_sats(s) + pnl = "" + if s.is_open and s.last and unreal is not None: + pnl_usd = (s.realized_pnl + unreal) / s.last + pnl = (f" · [{DGREEN if pnl_usd >= 0 else ORANGE}]{'+' if pnl_usd >= 0 else '−'}" + f"${abs(pnl_usd):,.0f} today[/][{MUT}]") self.query_one("#dog-sub", Static).update( - f"[{MUT}]your wealth · ${eq_usd:,.2f} · {eq / 1e8:.5f} ₿ · " + f"[{MUT}]your wealth · ${eq_usd:,.2f} · {eq / 1e8:.5f} ₿{pnl} · " f"{'live' if s.is_open else s.state}[/]") # THE POSITION is the star. Jay's format: # Position(p) SIDE BITCOIN $ diff --git a/users-cli/sidepit_tui/onboard.py b/users-cli/sidepit_tui/onboard.py index 904499d..6899752 100644 --- a/users-cli/sidepit_tui/onboard.py +++ b/users-cli/sidepit_tui/onboard.py @@ -10,15 +10,11 @@ env file into ~/.sidepit/keys/). watch just an address — read-only everywhere (verbs disabled). -Legacy `~/.spwallets/.spwallet*` files (bare WIF, one per file) are detected -and offered for import so existing users keep their accounts. SIDEPIT_WIF / -SIDEPIT_ID in the environment override everything (bot-style). +SIDEPIT_WIF / SIDEPIT_ID in the environment override everything (bot-style). """ from __future__ import annotations import os -from pathlib import Path - from textual.app import ComposeResult from textual.containers import Grid, Horizontal from textual.coordinate import Coordinate @@ -27,22 +23,6 @@ from sidepit_trader import keystore, wallet -LEGACY_DIR = Path.home() / ".spwallets" - - -def _legacy_wifs() -> list[tuple[str, str]]: - """[(name, wif)] from the old users-cli wallet folder, if any.""" - out = [] - if LEGACY_DIR.is_dir(): - for p in sorted(LEGACY_DIR.iterdir()): - if p.is_file(): - try: - wif = p.read_text().strip() - wallet.from_wif(wif) # validates checksum - out.append((p.name, wif)) - except Exception: - continue - return out def load_or_onboard(app) -> None: @@ -56,20 +36,27 @@ def load_or_onboard(app) -> None: class OnboardScreen(ModalScreen[None]): - """Modal over the (empty) app until an identity exists.""" + """Modal over the app until an identity is chosen. Escape backs out: to the + cockpit as-is when a session already exists (reached via ctrl+a → add + identity), or to a no-identity cockpit on first run.""" + + BINDINGS = [("escape", "cancel", "back")] + + def action_cancel(self) -> None: + self.dismiss(None) + if self.app.bridge is None: + self.app.add_event("sys", "no identity chosen — ctrl+a to add one " + "later; the market is visible read-only") def compose(self) -> ComposeResult: - legacy = _legacy_wifs() with Grid(id="onboard-grid"): - yield Label("[b]Welcome to Sidepit[/b] — choose an identity", + yield Label("[b]Welcome to Sidepit[/b] — choose an identity " + "[dim](esc = back)[/dim]", id="onboard-title") yield Button("Create a new identity (12-word backup)", variant="primary", id="ob-create") yield Button("Import — 12 words or WIF", id="ob-import") yield Button("Watch an address (read-only)", id="ob-watch") - if legacy: - yield Button(f"Import legacy wallet ({legacy[0][0]})", - variant="warning", id="ob-legacy") yield Input(placeholder="paste 12 words, WIF, or bc1q… address here", id="ob-input", password=True) yield Static("", id="ob-msg") @@ -130,10 +117,6 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self._msg("[red]paste a bc1q… address in the box first[/red]") return self._finish("watch", raw, None) - elif event.button.id == "ob-legacy": - name, wif = _legacy_wifs()[0] - ident = wallet.from_wif(wif) - self._finish(name, ident.sidepit_id, ident.wif) class AccountsScreen(ModalScreen[None]): From 97c58c19893f5fb1324296522dc6442c1f67b8ec Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Tue, 27 Oct 2026 22:54:43 -0700 Subject: [PATCH 03/24] CI: install proof on Linux, Windows, macOS Intel and Apple Silicon --- .github/workflows/install-proof.yml | 88 +++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/install-proof.yml diff --git a/.github/workflows/install-proof.yml b/.github/workflows/install-proof.yml new file mode 100644 index 0000000..0b5198d --- /dev/null +++ b/.github/workflows/install-proof.yml @@ -0,0 +1,88 @@ +name: Install proof + +# Clean-machine proof: build the wheel once, then `pip install` it into a fresh +# venv on every OS/Python a user might have, pulling dependencies from PyPI +# exactly as `pip install sidepit` would. Every leg runs even when another +# fails, so one run shows the whole platform picture. + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/install-proof.yml" + - "pyproject.toml" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Build wheel and source distribution + run: | + python -m pip install build + python -m build + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: python-package + path: dist/ + if-no-files-found: error + + install: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-15-intel, macos-latest] + python-version: ["3.10", "3.12", "3.13"] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: python-package + path: dist + - name: Fresh venv + run: | + python -m venv "$RUNNER_TEMP/clean" + if [ -d "$RUNNER_TEMP/clean/bin" ]; then + echo "$RUNNER_TEMP/clean/bin" >> "$GITHUB_PATH" + else + echo "$RUNNER_TEMP/clean/Scripts" >> "$GITHUB_PATH" + fi + - name: Describe the machine + run: | + python -c "import platform, sys; print(platform.platform(), platform.machine(), sys.version)" + python -m pip --version + - name: pip install the wheel (dependencies from PyPI, as a user would) + run: python -m pip install dist/*.whl + - name: Show what was installed and how + if: always() + run: python -m pip list + - name: Smoke-test the installed package outside the checkout + working-directory: ${{ runner.temp }} + env: + SIDEPIT_TESTNET: "true" + SIDEPIT_DEPOSIT_ADDRESS: bc1ql3e9pgs3mmwuwrh95fecme0s0qtn2880lsvsd5 + SIDEPIT_KEYS_DIR: ${{ runner.temp }}/sidepit-empty-keys + SIDEPIT_STATE_DB: ${{ runner.temp }}/sidepit-empty-state.db + SIDEPIT_HOST: localhost + run: | + sidepit --help + sidepit list + python "$GITHUB_WORKSPACE/tools/smoke_installed_sidepit.py" + - name: Offline test suite against the installed package + run: | + python -m pip install pytest + python -m pytest -q python-client/tests From cf60bbd466de5389eebbe546714c94241ae493e5 Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:00:46 -0700 Subject: [PATCH 04/24] wallet: pure-Python RIPEMD-160 fallback, drop ripemd-hash --- pyproject.toml | 1 - python-client/sidepit_trader/ripemd160.py | 89 +++++++++++++++++++++++ python-client/sidepit_trader/wallet.py | 11 +-- python-client/tests/test_ripemd160.py | 58 +++++++++++++++ 4 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 python-client/sidepit_trader/ripemd160.py create mode 100644 python-client/tests/test_ripemd160.py diff --git a/pyproject.toml b/pyproject.toml index ee72d1e..f8f75fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ dependencies = [ "pynng", "secp256k1", "base58", - "ripemd-hash>=1.0.1", "bitcoinlib", "textual>=8.0", "qrcode>=7.0", diff --git a/python-client/sidepit_trader/ripemd160.py b/python-client/sidepit_trader/ripemd160.py new file mode 100644 index 0000000..6a5d202 --- /dev/null +++ b/python-client/sidepit_trader/ripemd160.py @@ -0,0 +1,89 @@ +"""RIPEMD-160 — hashlib when the OpenSSL build provides it, pure Python otherwise. + +Bitcoin addresses (and so every sidepit_id) are hash160 = RIPEMD-160(SHA-256(pubkey)). +Some OpenSSL 3 builds leave ripemd160 out of hashlib; this keeps the SDK pure +Python with nothing to compile at install time. + +Reference vectors (Dobbertin, Bosselaers, Preneel 1996): + "" -> 9c1185a5c5e9fc54612808977ee8f548b2258d31 + "abc" -> 8eb208f7e05d987a9b044a8e98c6b087f15a0bfc (…0bfc; the widely copied …0bff is a typo) +""" +from __future__ import annotations + +import hashlib +import struct + +_RL = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8, + 3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12, + 1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2, + 4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13] +_RR = [5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12, + 6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2, + 15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13, + 8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14, + 12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11] +_SL = [11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8, + 7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12, + 11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5, + 11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12, + 9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6] +_SR = [8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6, + 9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11, + 9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5, + 15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8, + 8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11] +_KL = [0x00000000, 0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xA953FD4E] +_KR = [0x50A28BE6, 0x5C4DD124, 0x6D703EF3, 0x7A6D76E9, 0x00000000] +_MASK = 0xFFFFFFFF + + +def _rol(x: int, n: int) -> int: + return ((x << n) | (x >> (32 - n))) & _MASK + + +def _f(j: int, x: int, y: int, z: int) -> int: + if j < 16: + return x ^ y ^ z + if j < 32: + return (x & y) | (~x & z) + if j < 48: + return (x | ~y) ^ z + if j < 64: + return (x & z) | (y & ~z) + return x ^ (y | ~z) + + +def ripemd160_pure(msg: bytes) -> bytes: + """RIPEMD-160 in pure Python. Slow, dependency-free, used only as the fallback.""" + bit_len = len(msg) * 8 + msg = msg + b"\x80" + while len(msg) % 64 != 56: + msg += b"\x00" + msg += struct.pack(" bytes: + """RIPEMD-160 digest: hashlib if this OpenSSL has it, else the pure-Python fallback.""" + try: + return hashlib.new("ripemd160", msg).digest() + except Exception: + return ripemd160_pure(msg) diff --git a/python-client/sidepit_trader/wallet.py b/python-client/sidepit_trader/wallet.py index 885bb4c..a976757 100644 --- a/python-client/sidepit_trader/wallet.py +++ b/python-client/sidepit_trader/wallet.py @@ -31,6 +31,7 @@ import base58 from secp256k1 import PrivateKey +from .ripemd160 import ripemd160 from .signer import Signer, wif_to_priv_hex # Deposit/lock address + chain endpoints — centralized in config (optional @@ -130,13 +131,9 @@ def decode_segwit(addr: str, hrp: str = "bc"): # hashing # --------------------------------------------------------------------------- def _ripemd160(b: bytes) -> bytes: - # hashlib's ripemd160 is CORRECT (the "…0bff for abc" vector is a decades-old typo; truth - # is "…0bfc"). Some OpenSSL-3 builds disable it; fall back to the `ripemd` pkg if needed. - try: - return hashlib.new("ripemd160", b).digest() - except Exception: - from ripemd.ripemd160 import ripemd160 # users-cli dep, pure-python - return bytes.fromhex(ripemd160(b).hex()) if not isinstance(ripemd160(b), (bytes, bytearray)) else bytes(ripemd160(b)) + # hashlib when this OpenSSL has ripemd160, else the SDK's pure-Python fallback — + # nothing to compile at install time (see ripemd160.py for the vectors). + return ripemd160(b) def _hash160(b: bytes) -> bytes: diff --git a/python-client/tests/test_ripemd160.py b/python-client/tests/test_ripemd160.py new file mode 100644 index 0000000..68c2591 --- /dev/null +++ b/python-client/tests/test_ripemd160.py @@ -0,0 +1,58 @@ +"""RIPEMD-160 without a compiled dependency. + +`pip install sidepit` must not compile anything. The ripemd-hash package +built a C extension at install time and broke stock Windows and macOS; the +SDK now carries its own pure-Python fallback behind hashlib. Both paths +must agree with the published vectors, and the address path (hash160 → +bech32 = sidepit_id) must be byte-identical through either. +""" +import hashlib + +import pytest + +from sidepit_trader import ripemd160 as rmd +from sidepit_trader.wallet import sidepit_id_from_priv + +VECTORS = { + b"": "9c1185a5c5e9fc54612808977ee8f548b2258d31", + b"abc": "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc", + b"message digest": "5d0689ef49d2fae572b881b123a85ffa21595f36", + b"abcdefghijklmnopqrstuvwxyz": "f71c27109c692c1b56bbdceb5b9d2865b3708dbc", + b"a" * 1000: hashlib.new("ripemd160", b"a" * 1000).hexdigest() + if "ripemd160" in hashlib.algorithms_available else None, +} + +# test key from the SDK's offline parity rig — never funded, never used live +RIG_PRIV = "91f50feda224198e7b226b57cc506ec924f20f3a4cb102e0a7af076b5cf6ff6c" +RIG_ADDR = "bc1qkucf363qyps6mr4ck34np9q7xg7zdewat9rtg5" + + +@pytest.mark.parametrize("msg,hexdigest", [(m, h) for m, h in VECTORS.items() if h]) +def test_pure_python_matches_vectors(msg, hexdigest): + assert rmd.ripemd160_pure(msg).hex() == hexdigest + + +@pytest.mark.parametrize("msg,hexdigest", [(m, h) for m, h in VECTORS.items() if h]) +def test_default_path_matches_vectors(msg, hexdigest): + assert rmd.ripemd160(msg).hex() == hexdigest + + +def test_fallback_is_taken_when_hashlib_lacks_ripemd160(monkeypatch): + def no_ripemd(name, *a, **k): + raise ValueError("unsupported hash type ripemd160") + monkeypatch.setattr(rmd.hashlib, "new", no_ripemd) + assert rmd.ripemd160(b"abc").hex() == VECTORS[b"abc"] + # the whole address path survives on the fallback + assert sidepit_id_from_priv(RIG_PRIV) == RIG_ADDR + + +def test_address_path_default(): + assert sidepit_id_from_priv(RIG_PRIV) == RIG_ADDR + + +def test_no_compiled_ripemd_dependency(): + import pathlib + root = pathlib.Path(__file__).resolve().parents[2] + assert "ripemd-hash" not in (root / "pyproject.toml").read_text() + wallet_src = (root / "python-client/sidepit_trader/wallet.py").read_text() + assert "from ripemd" not in wallet_src From 2464c6da796f57c219df277ba1088c2954ba1acb Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:00:46 -0700 Subject: [PATCH 05/24] =?UTF-8?q?CI:=20stock-machine=20check=20=E2=80=94?= =?UTF-8?q?=20fail=20when=20any=20dependency=20would=20compile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/install-proof.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/install-proof.yml b/.github/workflows/install-proof.yml index 0b5198d..32736c9 100644 --- a/.github/workflows/install-proof.yml +++ b/.github/workflows/install-proof.yml @@ -63,8 +63,17 @@ jobs: fi - name: Describe the machine run: | + python -m pip install --quiet --upgrade pip python -c "import platform, sys; print(platform.platform(), platform.machine(), sys.version)" python -m pip --version + - name: Stock-machine check — every dependency must come as a prebuilt wheel + # The runner has compilers; a user's machine may not. This resolves the + # whole dependency tree with building forbidden, so anything that would + # compile on a stock machine shows up here even though the real install + # below would succeed on the runner. + id: stock + continue-on-error: true + run: "python -m pip install --dry-run --only-binary=:all: dist/*.whl" - name: pip install the wheel (dependencies from PyPI, as a user would) run: python -m pip install dist/*.whl - name: Show what was installed and how @@ -86,3 +95,8 @@ jobs: run: | python -m pip install pytest python -m pytest -q python-client/tests + - name: Verdict — would this have worked on a stock machine? + if: steps.stock.outcome == 'failure' + run: | + echo "::error::a dependency has no prebuilt wheel for this platform; a user without build tools cannot pip install sidepit here" + exit 1 From dfee9e3aa910ce252bbf360b949d9fed2adef707 Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:05:36 -0700 Subject: [PATCH 06/24] tests: key entropy is the OS CSPRNG only; signing is deterministic --- python-client/tests/test_key_randomness.py | 80 ++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 python-client/tests/test_key_randomness.py diff --git a/python-client/tests/test_key_randomness.py b/python-client/tests/test_key_randomness.py new file mode 100644 index 0000000..e93338c --- /dev/null +++ b/python-client/tests/test_key_randomness.py @@ -0,0 +1,80 @@ +"""Key material comes from the OS CSPRNG, with no fallback, and signing uses no RNG. + +Jay 2026-10-28: private-key generation must use correct randomness — critical +when the secp256k1 library is compiled per platform — and there must be no +fallback to a weaker source. These tests pin that: + + * every private key and every mnemonic is exactly the bytes `secrets.token_bytes` + returned — the compiled library only does the curve math, never the entropy; + * no module in the shipped packages imports `random` or reaches for its own + entropy source; + * ECDSA signing is deterministic (RFC 6979 inside libsecp256k1), so a bad RNG + in a compiled build cannot leak a key through a nonce. +""" +import pathlib +import re + +import pytest + +from sidepit_trader import mnemonic, wallet +from sidepit_trader._proto import pb +from sidepit_trader.signer import Signer + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SHIPPED = [ROOT / "python-client" / "sidepit_trader", ROOT / "users-cli" / "sidepit_tui"] + +# test key from the SDK's offline parity rig — never funded, never used live +RIG_PRIV = "91f50feda224198e7b226b57cc506ec924f20f3a4cb102e0a7af076b5cf6ff6c" + + +def test_private_key_is_exactly_the_os_entropy(monkeypatch): + fixed = bytes.fromhex(RIG_PRIV) + monkeypatch.setattr(wallet.secrets, "token_bytes", lambda n: fixed[:n]) + ident = wallet.gen_key() + assert ident.priv_hex == RIG_PRIV + assert ident.sidepit_id == "bc1qkucf363qyps6mr4ck34np9q7xg7zdewat9rtg5" + + +def test_mnemonic_is_exactly_the_os_entropy(monkeypatch): + fixed = bytes(range(16)) + calls = [] + + def token_bytes(n): + calls.append(n) + return fixed[:n] + + monkeypatch.setattr(mnemonic.secrets, "token_bytes", token_bytes) + words, _ident = mnemonic.new_identity_words() + assert calls == [16] + assert words == mnemonic.entropy_to_words(fixed) + assert mnemonic.words_to_entropy(words) == fixed + + +def test_entropy_source_cannot_be_swapped_for_a_weaker_one(): + # `secrets` raises if the OS CSPRNG is unavailable; nothing may catch that or + # use `random`, `os.urandom` with a fallback, or time/pid-seeded sources. + forbidden = re.compile(r"^\s*(import random\b|from random\b|import os\.urandom|from os import urandom)", + re.M) + for pkg in SHIPPED: + for path in pkg.rglob("*.py"): + src = path.read_text() + assert not forbidden.search(src), f"{path}: forbidden entropy source" + assert "os.urandom(" not in src, f"{path}: use secrets.token_bytes, not os.urandom" + gen_src = (SHIPPED[0] / "wallet.py").read_text() + assert "secrets.token_bytes(32)" in gen_src + mn_src = (SHIPPED[0] / "mnemonic.py").read_text() + assert "secrets.token_bytes(16)" in mn_src + + +def test_signing_is_deterministic(): + tx = pb.Transaction() + tx.version = 1 + tx.timestamp = 1755467890123456789 + tx.new_order.side = 1 + tx.new_order.size = 1 + tx.new_order.price = 1500 + tx.new_order.ticker = "USDBTCU26" + signer = Signer(RIG_PRIV, "bc1qkucf363qyps6mr4ck34np9q7xg7zdewat9rtg5") + a = signer.sign_digest(tx) + b = signer.sign_digest(tx) + assert a == b and len(a) == 128 # 64-byte compact signature, hex From c74f56a43f3940d0391bba17b34f78c7e2381d25 Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:23:48 -0700 Subject: [PATCH 07/24] sidepit 0.1.2: bundled secp256k1 wheels for Windows and Intel macOS --- .gitignore | 1 + MANIFEST.in | 2 ++ pyproject.toml | 31 ++-------------- python-client/pyproject.toml | 2 +- setup.py | 70 ++++++++++++++++++++++++++++++++++++ tools/secp256k1_ffi.py | 49 +++++++++++++++++++++++++ tools/vendor_secp256k1.py | 57 +++++++++++++++++++++++++++++ 7 files changed, 182 insertions(+), 30 deletions(-) create mode 100644 MANIFEST.in create mode 100644 setup.py create mode 100644 tools/secp256k1_ffi.py create mode 100644 tools/vendor_secp256k1.py diff --git a/.gitignore b/.gitignore index 5b1bb9b..20f7a06 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .env /build/ /dist/ +/dist-native/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..770d84e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include tools/vendor_secp256k1.py +include tools/secp256k1_ffi.py diff --git a/pyproject.toml b/pyproject.toml index f8f75fc..3b489aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sidepit" -version = "0.1.1" +version = "0.1.2" description = "The official Python SDK and trading terminal for Sidepit." readme = "README.md" requires-python = ">=3.10" @@ -20,15 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", "Topic :: Office/Business :: Financial :: Investment", ] -dependencies = [ - "protobuf==3.20.1", - "pynng", - "secp256k1", - "base58", - "bitcoinlib", - "textual>=8.0", - "qrcode>=7.0", -] +dynamic = ["dependencies"] # see setup.py: pure vs bundled-secp256k1 builds [project.scripts] sidepit = "sidepit_tui.__main__:main" @@ -37,22 +29,3 @@ sidepit = "sidepit_tui.__main__:main" Homepage = "https://sidepit.com" Documentation = "https://docs.sidepit.com" Source = "https://github.com/sidepit/Public-API" - -[tool.setuptools] -packages = [ - "sidepit_trader", - "sidepit_trader.examples", - "sidepit_trader.proto", - "sidepit_trader.data", - "sidepit_tui", -] - -[tool.setuptools.package-dir] -sidepit_trader = "python-client/sidepit_trader" -"sidepit_trader.examples" = "python-client/sidepit_trader/examples" -"sidepit_trader.proto" = "python-client/proto" -"sidepit_trader.data" = "python-client/sidepit_trader/data" -sidepit_tui = "users-cli/sidepit_tui" - -[tool.setuptools.package-data] -sidepit_trader = ["data/*.txt"] diff --git a/python-client/pyproject.toml b/python-client/pyproject.toml index a939a9d..f8879d9 100644 --- a/python-client/pyproject.toml +++ b/python-client/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sidepit" -version = "0.1.1" +version = "0.1.2" description = "The official Python SDK for Sidepit — Bitcoin-margined forwards, DLOB one-second deterministic auctions." readme = "sidepit_trader/README.md" license = { text = "MIT" } diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..187d272 --- /dev/null +++ b/setup.py @@ -0,0 +1,70 @@ +"""Packaging for the `sidepit` distribution. Metadata lives in pyproject.toml. + +Two builds come out of this file: + +* the pure wheel (default): sidepit_trader + sidepit_tui, `secp256k1` as an + ordinary dependency — Linux and Apple Silicon get upstream's wheels; +* the bundled platform wheel (SIDEPIT_BUNDLE_SECP256K1=1): the same code plus + the upstream `secp256k1` Python package with libsecp256k1 compiled in, for + platforms where upstream publishes no wheel (Windows, Intel macOS). Source + comes from tools/vendor_secp256k1.py (SHA-256 pinned). pip prefers the + platform wheel over the pure one, so `pip install sidepit` picks it up + without the user doing anything. +""" +import os +import pathlib + +from setuptools import setup + +ROOT = pathlib.Path(__file__).resolve().parent +BUNDLE = os.environ.get("SIDEPIT_BUNDLE_SECP256K1") == "1" + +DEPENDENCIES = [ + "protobuf==3.20.1", + "pynng", + "secp256k1", + "base58", + "bitcoinlib", + "textual>=8.0", + "qrcode>=7.0", +] + +packages = [ + "sidepit_trader", + "sidepit_trader.examples", + "sidepit_trader.proto", + "sidepit_trader.data", + "sidepit_tui", +] +package_dir = { + "sidepit_trader": "python-client/sidepit_trader", + "sidepit_trader.examples": "python-client/sidepit_trader/examples", + "sidepit_trader.proto": "python-client/proto", + "sidepit_trader.data": "python-client/sidepit_trader/data", + "sidepit_tui": "users-cli/sidepit_tui", +} +kwargs = {} + +if BUNDLE: + src = pathlib.Path(os.environ.get("SECP_SRC", ROOT / "build" / "secp256k1-0.14.0")) + if not (src / "libsecp256k1" / "src" / "secp256k1.c").exists(): + raise SystemExit(f"bundled build: run tools/vendor_secp256k1.py first ({src} is missing)") + packages.append("secp256k1") + package_dir["secp256k1"] = os.path.relpath(src / "secp256k1", ROOT) + DEPENDENCIES = [d for d in DEPENDENCIES if d != "secp256k1"] + ["cffi>=1.3.0"] + kwargs.update( + cffi_modules=["tools/secp256k1_ffi.py:ffi"], + ext_package="secp256k1", + zip_safe=False, + # cffi builds against the stable ABI: one wheel per platform, built on + # 3.10, serves every later CPython (cp310-abi3 tag). + options={"bdist_wheel": {"py_limited_api": "cp310"}}, + ) + +setup( + packages=packages, + package_dir=package_dir, + package_data={"sidepit_trader": ["data/*.txt"]}, + install_requires=DEPENDENCIES, + **kwargs, +) diff --git a/tools/secp256k1_ffi.py b/tools/secp256k1_ffi.py new file mode 100644 index 0000000..660a3f9 --- /dev/null +++ b/tools/secp256k1_ffi.py @@ -0,0 +1,49 @@ +"""cffi builder for the bundled secp256k1 module (platforms with no upstream wheel). + +Same cdefs, module name (`secp256k1._libsecp256k1`) and module set (ecdh, +recovery, schnorrsig, extrakeys) as upstream's own bundled build, so the +unmodified upstream Python package runs on top of it. The one difference: +libsecp256k1 (the copy inside the pinned upstream sdist) is compiled straight +into the extension as a single translation unit instead of through autotools +and pkg-config, which do not exist under MSVC. + +Defines match upstream configure's defaults for the bundled build: +ECMULT_WINDOW_SIZE=15, ECMULT_GEN_PREC_BITS=4 (the shipped static table +requires window <= 15). Proven byte-exact against the SDK's golden signing +vectors on win_amd64 (sp1-windows, 2026-10-28) and Linux. + +SECP_SRC may point at the unpacked sdist; default is what +tools/vendor_secp256k1.py produces. +""" +import os +import pathlib + +from cffi import FFI + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = pathlib.Path(os.environ.get("SECP_SRC", ROOT / "build" / "secp256k1-0.14.0")) +LIB = SRC / "libsecp256k1" +HEADERS = ["secp256k1.h", "secp256k1_ecdh.h", "secp256k1_recovery.h", + "secp256k1_schnorrsig.h", "secp256k1_extrakeys.h"] + +ffi = FFI() +for h in HEADERS: + ffi.cdef((SRC / "_cffi_build" / h).read_text()) + +ffi.set_source( + "_libsecp256k1", + "\n".join(f"#include <{h}>" for h in HEADERS) + "\n#define PY_USE_BUNDLED", + # relative: distutils mirrors the source path under build/temp*, and an + # absolute path can exceed MAX_PATH on Windows + sources=[os.path.relpath(LIB / "src" / "secp256k1.c")], + include_dirs=[str(LIB / "include"), str(LIB / "src"), str(LIB)], + define_macros=[ + ("ECMULT_WINDOW_SIZE", "15"), + ("ECMULT_GEN_PREC_BITS", "4"), + ("ENABLE_MODULE_ECDH", "1"), + ("ENABLE_MODULE_RECOVERY", "1"), + ("ENABLE_MODULE_EXTRAKEYS", "1"), + ("ENABLE_MODULE_SCHNORRSIG", "1"), + ("CFFI_ENABLE_RECOVERY", None), + ], +) diff --git a/tools/vendor_secp256k1.py b/tools/vendor_secp256k1.py new file mode 100644 index 0000000..f589b30 --- /dev/null +++ b/tools/vendor_secp256k1.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Fetch the upstream secp256k1 0.14.0 source distribution for a bundled build. + +Platforms where upstream publishes no wheel (Windows, Intel macOS) get the +compiled module inside sidepit's own platform wheel. The C code that goes +into that wheel is exactly the audited upstream tarball: this script refuses +anything whose SHA-256 differs from the pin below. + +Usage: python tools/vendor_secp256k1.py # extracts to build/secp256k1-0.14.0 +""" +from __future__ import annotations + +import hashlib +import pathlib +import sys +import tarfile +import urllib.request + +VERSION = "0.14.0" +SHA256 = "82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397" +URL = ("https://files.pythonhosted.org/packages/source/s/secp256k1/" + f"secp256k1-{VERSION}.tar.gz") +ROOT = pathlib.Path(__file__).resolve().parents[1] +BUILD = ROOT / "build" +DEST = BUILD / f"secp256k1-{VERSION}" + + +def main() -> int: + if (DEST / "libsecp256k1" / "src" / "secp256k1.c").exists(): + print(DEST) + return 0 + BUILD.mkdir(exist_ok=True) + tarball = BUILD / f"secp256k1-{VERSION}.tar.gz" + if not tarball.exists(): + with urllib.request.urlopen(URL) as r: + tarball.write_bytes(r.read()) + digest = hashlib.sha256(tarball.read_bytes()).hexdigest() + if digest != SHA256: + tarball.unlink() + print(f"secp256k1-{VERSION}.tar.gz: SHA-256 {digest} != pinned {SHA256}", + file=sys.stderr) + return 1 + with tarfile.open(tarball) as tf: + for m in tf.getmembers(): + if m.name.startswith("/") or ".." in pathlib.PurePosixPath(m.name).parts: + print(f"refusing path in tarball: {m.name}", file=sys.stderr) + return 1 + tf.extractall(BUILD) + if not (DEST / "libsecp256k1" / "src" / "secp256k1.c").exists(): + print(f"unexpected layout under {DEST}", file=sys.stderr) + return 1 + print(DEST) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d75b4be6ec0beaf79ad8e10756dcfe38d9e6e05c Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:23:48 -0700 Subject: [PATCH 08/24] CI: build bundled wheels, prove the install on every platform, gate publishing on the proof --- .github/workflows/install-proof.yml | 82 +++++++++++++++++++++++------ .github/workflows/publish.yml | 56 ++++++++------------ 2 files changed, 88 insertions(+), 50 deletions(-) diff --git a/.github/workflows/install-proof.yml b/.github/workflows/install-proof.yml index 32736c9..d2172d3 100644 --- a/.github/workflows/install-proof.yml +++ b/.github/workflows/install-proof.yml @@ -1,40 +1,87 @@ name: Install proof -# Clean-machine proof: build the wheel once, then `pip install` it into a fresh -# venv on every OS/Python a user might have, pulling dependencies from PyPI -# exactly as `pip install sidepit` would. Every leg runs even when another -# fails, so one run shows the whole platform picture. +# Clean-machine proof: build every release artifact once, then `pip install +# sidepit` into a fresh venv on every OS/Python a user might have, pulling +# dependencies from PyPI exactly as a user would. Every leg runs even when +# another fails, so one run shows the whole platform picture. publish.yml +# calls this workflow and refuses to upload unless it is all green. on: workflow_dispatch: + workflow_call: pull_request: paths: - ".github/workflows/install-proof.yml" - "pyproject.toml" + - "setup.py" + - "MANIFEST.in" + - "tools/**" permissions: contents: read +env: + # build tools are pinned: the same inputs produce the release every time + BUILD_TOOLS: build==1.6.1 setuptools==80.10.2 wheel==0.48.0 cffi==2.1.1 + jobs: build: + name: pure wheel + sdist runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Build wheel and source distribution + - name: Build run: | - python -m pip install build - python -m build + python -m pip install $BUILD_TOOLS + python -m build --no-isolation + python tools/check_python_dist.py dist/* - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: python-package + name: dist-pure + path: dist/ + if-no-files-found: error + + native: + # Platforms where upstream secp256k1 publishes no wheel get the compiled + # module inside sidepit's own platform wheel (setup.py, SIDEPIT_BUNDLE_SECP256K1). + # Built against the stable ABI on 3.10: one wheel per platform serves 3.10+. + name: bundled wheel (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-15-intel] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + - name: Fetch the pinned upstream secp256k1 source + run: | + python -m pip install $BUILD_TOOLS + python tools/vendor_secp256k1.py + - name: Build + env: + SIDEPIT_BUNDLE_SECP256K1: "1" + run: | + python -m build --wheel --no-isolation + python tools/check_python_dist.py dist/*.whl + ls -l dist + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dist-native-${{ matrix.os }} path: dist/ if-no-files-found: error install: - needs: build + name: pip install (${{ matrix.os }}, ${{ matrix.python-version }}) + needs: [build, native] strategy: fail-fast: false matrix: @@ -51,8 +98,9 @@ jobs: python-version: ${{ matrix.python-version }} - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: python-package + pattern: dist-* path: dist + merge-multiple: true - name: Fresh venv run: | python -m venv "$RUNNER_TEMP/clean" @@ -61,11 +109,13 @@ jobs: else echo "$RUNNER_TEMP/clean/Scripts" >> "$GITHUB_PATH" fi - - name: Describe the machine + - name: Describe the machine and the release artifacts run: | python -m pip install --quiet --upgrade pip python -c "import platform, sys; print(platform.platform(), platform.machine(), sys.version)" python -m pip --version + ls -l dist + python -c "import tomllib; print('VERSION=' + tomllib.load(open('pyproject.toml','rb'))['project']['version'])" >> "$GITHUB_ENV" - name: Stock-machine check — every dependency must come as a prebuilt wheel # The runner has compilers; a user's machine may not. This resolves the # whole dependency tree with building forbidden, so anything that would @@ -73,12 +123,14 @@ jobs: # below would succeed on the runner. id: stock continue-on-error: true - run: "python -m pip install --dry-run --only-binary=:all: dist/*.whl" - - name: pip install the wheel (dependencies from PyPI, as a user would) - run: python -m pip install dist/*.whl + run: "python -m pip install --dry-run --only-binary=:all: --find-links dist sidepit==$VERSION" + - name: pip install sidepit (release artifacts + dependencies from PyPI, as a user would) + run: python -m pip install --find-links dist "sidepit==$VERSION" - name: Show what was installed and how if: always() - run: python -m pip list + run: | + python -m pip list + python -c "import secp256k1, sidepit_trader; print('secp256k1 from', secp256k1.__file__)" - name: Smoke-test the installed package outside the checkout working-directory: ${{ runner.temp }} env: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f59ef7f..cd3cd23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,5 +1,11 @@ name: Publish Python package +# Release artifacts come from CI only, never from a developer machine. A tag +# push runs the full install proof (pure wheel + bundled platform wheels, +# pip-installed on Linux, Windows, macOS Intel and Apple Silicon); nothing is +# uploaded unless every leg is green. Upload goes through PyPI Trusted +# Publishing behind the `pypi` environment approval. + on: push: tags: ["v*"] @@ -15,17 +21,13 @@ permissions: contents: read jobs: - build: + check-tag: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - cache: pip - cache-dependency-path: pyproject.toml - - name: Install build tools and test dependencies - run: python -m pip install . build pytest twine - name: Verify tag matches package version if: github.event_name == 'push' run: | @@ -36,34 +38,14 @@ jobs: if tag != f"v{version}": raise SystemExit(f"tag {tag!r} does not match package version {version!r}") PY - - name: Test and build - run: | - python -m pytest -q python-client/tests - python -m build - python -m twine check --strict dist/* - python tools/check_python_dist.py dist/* - python -m pip install --force-reinstall --no-deps dist/*.whl - - name: Smoke-test release wheel outside checkout - working-directory: ${{ runner.temp }} - env: - SIDEPIT_TESTNET: "true" - SIDEPIT_DEPOSIT_ADDRESS: bc1ql3e9pgs3mmwuwrh95fecme0s0qtn2880lsvsd5 - SIDEPIT_KEYS_DIR: ${{ runner.temp }}/sidepit-empty-keys - SIDEPIT_STATE_DB: ${{ runner.temp }}/sidepit-empty-state.db - SIDEPIT_HOST: localhost - run: | - sidepit --help - sidepit list - python "$GITHUB_WORKSPACE/tools/smoke_installed_sidepit.py" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: python-package - path: dist/ - if-no-files-found: error + + proof: + needs: check-tag + uses: ./.github/workflows/install-proof.yml publish-testpypi: if: github.event_name == 'workflow_dispatch' - needs: build + needs: proof runs-on: ubuntu-latest environment: testpypi permissions: @@ -71,15 +53,17 @@ jobs: steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: python-package + pattern: dist-* path: dist - - uses: pypa/gh-action-pypi-publish@release/v1 + merge-multiple: true + - run: ls -l dist + - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: repository-url: https://test.pypi.org/legacy/ publish-pypi: if: github.event_name == 'push' - needs: build + needs: proof runs-on: ubuntu-latest environment: pypi permissions: @@ -87,6 +71,8 @@ jobs: steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: python-package + pattern: dist-* path: dist - - uses: pypa/gh-action-pypi-publish@release/v1 + merge-multiple: true + - run: ls -l dist + - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 From fe19ba85bb11ffcb4f60e01bc27c3aa21376d5aa Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:23:48 -0700 Subject: [PATCH 09/24] agent key: Windows has no POSIX mode bits, verify the file by content there --- python-client/sidepit_trader/agent_key.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python-client/sidepit_trader/agent_key.py b/python-client/sidepit_trader/agent_key.py index 6cf1634..73b69d7 100644 --- a/python-client/sidepit_trader/agent_key.py +++ b/python-client/sidepit_trader/agent_key.py @@ -23,6 +23,7 @@ """ from __future__ import annotations +import os import stat import sys @@ -57,11 +58,14 @@ def mint(account: str) -> dict: path = keystore.save_identity(name, account, ident.wif, active=False) # post-write verification, from the file itself + # POSIX mode bits mean nothing on Windows (os.open(..., 0o600) reports 0o666); + # there the user-profile ACL protects the file, so only POSIX checks the mode. mode = stat.S_IMODE(path.stat().st_mode) + mode_ok = (os.name == "nt") or mode == 0o600 d = keystore._parse(path) from .wallet import from_wif reread = from_wif(d.get("SIDEPIT_WIF", "")) - if mode != 0o600 or d.get("SIDEPIT_ID") != account \ + if not mode_ok or d.get("SIDEPIT_ID") != account \ or reread.sidepit_id != ident.sidepit_id: raise RuntimeError(f"verification of written key file failed: {path} " f"(file kept — key files are never deleted)") From 2f2d3f7fdd7a328ef19dd835f86e150d01dac89e Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:23:48 -0700 Subject: [PATCH 10/24] tests: signing nonce stays RFC 6979 --- python-client/tests/test_key_randomness.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python-client/tests/test_key_randomness.py b/python-client/tests/test_key_randomness.py index e93338c..523b058 100644 --- a/python-client/tests/test_key_randomness.py +++ b/python-client/tests/test_key_randomness.py @@ -60,6 +60,9 @@ def test_entropy_source_cannot_be_swapped_for_a_weaker_one(): src = path.read_text() assert not forbidden.search(src), f"{path}: forbidden entropy source" assert "os.urandom(" not in src, f"{path}: use secrets.token_bytes, not os.urandom" + # signing stays RFC 6979: no caller may hand libsecp256k1 or bitcoinlib its own nonce + assert "custom_nonce" not in src, f"{path}: custom nonce forbidden" + assert "use_rfc6979=False" not in src, f"{path}: RFC 6979 must stay on" gen_src = (SHIPPED[0] / "wallet.py").read_text() assert "secrets.token_bytes(32)" in gen_src mn_src = (SHIPPED[0] / "mnemonic.py").read_text() From 49dfb8321fd9c943fe6cb9b0baef5c2cc209f8ad Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 23 Sep 2026 16:59:26 -0700 Subject: [PATCH 11/24] trader: poll ACTIVE_PRODUCT without a ticker so the roll is seen after expiry - ACTIVE_PRODUCT for an expired ticker returns an empty product/schedule/state (USDBTCM26, 2026-09-23) - a bot polling its own ticker would never roll and would go dark at the first post-expiry open (cherry picked from commit ee892f8c53c77e47631a95f48b6718e1bd5f3805) --- python-client/sidepit_trader/trader.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python-client/sidepit_trader/trader.py b/python-client/sidepit_trader/trader.py index cf19ed0..02f36d8 100644 --- a/python-client/sidepit_trader/trader.py +++ b/python-client/sidepit_trader/trader.py @@ -154,7 +154,12 @@ def _refresh_state(self, force_transition: bool = False): """Read estate + session + active contract + close time via reqrep. Drives the open/close hooks (the taker has no clock feed, so state is polled).""" try: - ap = self.req.active_product(self.ticker or None) + # Ask for THE active product, never for our current ticker: once a contract + # expires, ACTIVE_PRODUCT for that ticker comes back EMPTY (no product, no + # schedule, no state) — verified 2026-09-23 with the June USDBTCM26 — so a + # bot polling its own ticker would never see the roll and would go dark at + # the first open after expiry. The exchange decides what is active; we follow. + ap = self.req.active_product() es = ap.exchange_status self.state = es.status.estate self.session_id = es.session.session_id or es.status.session_id or self.session_id From a1caa7c0c03a2d15f9d0d04780d1225b14ef8c3f Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:26:23 -0700 Subject: [PATCH 12/24] TUI: start on the live market with no identity; add one later --- python-client/tests/test_tui_keyless_start.py | 66 +++++++++++++++++++ users-cli/sidepit_tui/app.py | 7 +- users-cli/sidepit_tui/onboard.py | 14 +++- 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 python-client/tests/test_tui_keyless_start.py diff --git a/python-client/tests/test_tui_keyless_start.py b/python-client/tests/test_tui_keyless_start.py new file mode 100644 index 0000000..b2dd6ab --- /dev/null +++ b/python-client/tests/test_tui_keyless_start.py @@ -0,0 +1,66 @@ +"""The cockpit starts with no identity at all: market first, keys optional. + +Jay 2026-10-28: "the ability to start without any keys — now it forces a watch +address — can we do without that?" First run starts the bridge keyless (price, +bars, book, auction clock) and shows the identity doors on top; Escape or +"just look" dismisses them to a live, read-only cockpit. ctrl+a adds an +identity later. A keyless bridge never polls the account door, the chain, or +the rejection feed — there is no address to ask about. +""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve() + .parents[2] / "users-cli")) + +from sidepit_tui import onboard # noqa: E402 +from sidepit_tui.bridge import Bridge # noqa: E402 + + +class _App: + def __init__(self): + self.started = [] + self.screens = [] + self.bridge = None + + def start_bridge(self, address, wif, name=None): + self.started.append((address, wif, name)) + self.bridge = object() + + def push_screen(self, screen): + self.screens.append(type(screen).__name__) + + +def test_first_run_starts_keyless_then_offers_identity(monkeypatch): + monkeypatch.setattr(onboard.keystore, "active_identity", lambda: None) + app = _App() + onboard.load_or_onboard(app) + assert app.started == [("", None, None)] # market is live, no key + assert app.screens == ["OnboardScreen"] # doors offered, not forced + + +def test_active_identity_skips_onboarding(monkeypatch): + monkeypatch.setattr(onboard.keystore, "active_identity", + lambda: {"sidepit_id": "bc1qme", "wif": None, "name": "w"}) + app = _App() + onboard.load_or_onboard(app) + assert app.started == [("bc1qme", None, "w")] + assert app.screens == [] + + +def test_keyless_bridge_never_asks_about_an_account(): + b = Bridge("example.invalid", on_snap=lambda s: None, + on_event=lambda k, t: None) + b.set_identity("", None) + assert b.snap.watch_only and b.snap.address == "" + + def forbidden(*a, **k): + raise AssertionError("keyless bridge touched an account door") + b._poll_account = forbidden + b._poll_chain = forbidden + b._rq = forbidden + b._poll_exchange = lambda: None + b._last = {"quote": -1e9, "account": -1e9, "chain": -1e9} + b._tick() # would poll everything due + b._drain_rejections() # no address → no feed + assert b._rejects is None diff --git a/users-cli/sidepit_tui/app.py b/users-cli/sidepit_tui/app.py index 58c8a61..2b34b38 100644 --- a/users-cli/sidepit_tui/app.py +++ b/users-cli/sidepit_tui/app.py @@ -470,8 +470,11 @@ def start_bridge(self, address: str, wif: str | None, on_event=lambda k, t: self.call_from_thread(self.add_event, k, t)) self.bridge.set_identity(address, wif) self.bridge.start() - mode = "watch-only" if wif is None else "custody key loaded" - self.add_event("sys", f"session {address} · {mode} · host {self.host}") + if address: + mode = "watch-only" if wif is None else "custody key loaded" + self.add_event("sys", f"session {address} · {mode} · host {self.host}") + else: + self.add_event("sys", f"market only — no identity · host {self.host}") self.refresh_wallets() # keep the dropdown honest after any switch self.add_event("sys", HELP) # A delegate may TRADE and cancel — never move money or change who is diff --git a/users-cli/sidepit_tui/onboard.py b/users-cli/sidepit_tui/onboard.py index 6899752..10edf73 100644 --- a/users-cli/sidepit_tui/onboard.py +++ b/users-cli/sidepit_tui/onboard.py @@ -9,6 +9,7 @@ import paste a WIF (also: `python -m sidepit_tui import`, or just drop an env file into ~/.sidepit/keys/). watch just an address — read-only everywhere (verbs disabled). + look nothing at all — the market only; add an identity later (ctrl+a). SIDEPIT_WIF / SIDEPIT_ID in the environment override everything (bot-style). """ @@ -32,6 +33,10 @@ def load_or_onboard(app) -> None: if row is not None: app.start_bridge(row["sidepit_id"], row["wif"], name=row["name"]) else: + # No identity yet: the market comes up first (price, bars, book, the + # auction clock) and the identity doors sit on top. Escape or "just + # look" leaves a live read-only cockpit; ctrl+a adds a key later. + app.start_bridge("", None) app.push_screen(OnboardScreen()) @@ -44,9 +49,9 @@ class OnboardScreen(ModalScreen[None]): def action_cancel(self) -> None: self.dismiss(None) - if self.app.bridge is None: - self.app.add_event("sys", "no identity chosen — ctrl+a to add one " - "later; the market is visible read-only") + if not getattr(self.app.snap, "address", ""): + self.app.add_event("sys", "no identity — market only. ctrl+a adds " + "one when you want to trade") def compose(self) -> ComposeResult: with Grid(id="onboard-grid"): @@ -57,6 +62,7 @@ def compose(self) -> ComposeResult: variant="primary", id="ob-create") yield Button("Import — 12 words or WIF", id="ob-import") yield Button("Watch an address (read-only)", id="ob-watch") + yield Button("Just look — no identity, market only", id="ob-look") yield Input(placeholder="paste 12 words, WIF, or bc1q… address here", id="ob-input", password=True) yield Static("", id="ob-msg") @@ -117,6 +123,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self._msg("[red]paste a bc1q… address in the box first[/red]") return self._finish("watch", raw, None) + elif event.button.id == "ob-look": + self.action_cancel() class AccountsScreen(ModalScreen[None]): From f0e950b40f8baec4902818e72e2b836bd2a521b0 Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:26:23 -0700 Subject: [PATCH 13/24] docs: one-line install per platform, WSL2 path for Windows --- DISTRIBUTION.md | 14 ++++++++++---- README.md | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index 55f2775..c2a2c94 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -1,9 +1,15 @@ # Sidepit distribution record -Status: **v0.1.0 is live on PyPI** as `sidepit`. **v0.1.1 is the release -candidate**: the cockpit TUI no longer triggers whole-order-book snapshot -broadcasts (12129) to refresh its open-orders view; open orders come from the -account's POSITIONS reply. No SDK API changes. +Status: **v0.1.1 is live on PyPI** as `sidepit`. **v0.1.2 is in preparation** +on the `ci-install-proof` branch: `pip install sidepit` works on Linux, macOS +(Apple Silicon and Intel) and Windows with nothing to compile — upstream +`secp256k1` wheels where they exist, sidepit's own platform wheels with +libsecp256k1 compiled in (stable ABI, one per platform) on Windows and Intel +macOS; `ripemd-hash` dropped for a pure-Python fallback. Every release now +passes the four-platform install proof (`.github/workflows/install-proof.yml`) +before it can be uploaded, and release artifacts come from CI only. The cockpit +starts keyless on the live market; identities are added from the doors or +ctrl+a. This file records the distribution decisions made for the public client. It is the durable boundary between what works today and what is deliberately next. diff --git a/README.md b/README.md index f1a9dbd..dbbfe10 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,21 @@ source .venv/bin/activate pip install sidepit ``` +One line on every desktop, nothing to compile (Python 3.10 or newer): + +- **Linux / WSL2:** `pip install sidepit` +- **macOS** (Apple Silicon or Intel): `pip install sidepit` — no Xcode tools, no Homebrew needed. +- **Windows, recommended — WSL2:** in PowerShell `wsl --install`, reboot, open + Ubuntu, then `pip install sidepit`. You get the Linux cockpit, and Claude + Code runs beside it in the same terminal. +- **Windows, native:** `winget install Python.Python.3.12` (or python.org with + "Add python.exe to PATH" ticked), open a **new** terminal, then + `python -m pip install sidepit`. Windows Terminal renders the cockpit best; + keys live in `%USERPROFILE%\.sidepit\keys`. + +First run opens on the live market with no identity. Create, import or watch +one from the doors, or press Escape to just look and add a key later with ctrl+a. + Terminal one — the cockpit: ```sh From 10d3258bf364ce279d2f5ed6261b91c53cccb29f Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:28:50 -0700 Subject: [PATCH 14/24] keep the BIP39 wordlist byte-exact on Windows checkouts --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..107e464 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# byte-exact data: the SDK verifies this file by SHA-256 at runtime, so git must +# never convert its line endings on a Windows checkout +python-client/sidepit_trader/data/bip39_english.txt binary From b6d39f352d05aa239181e4906dd113bb0ef2fc5f Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:28:50 -0700 Subject: [PATCH 15/24] CI: read the version without tomllib so the Python 3.10 legs run --- .github/workflows/install-proof.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/install-proof.yml b/.github/workflows/install-proof.yml index d2172d3..27f0877 100644 --- a/.github/workflows/install-proof.yml +++ b/.github/workflows/install-proof.yml @@ -115,7 +115,7 @@ jobs: python -c "import platform, sys; print(platform.platform(), platform.machine(), sys.version)" python -m pip --version ls -l dist - python -c "import tomllib; print('VERSION=' + tomllib.load(open('pyproject.toml','rb'))['project']['version'])" >> "$GITHUB_ENV" + python -c "import re; print('VERSION=' + re.search(r'^version = \"([^\"]+)\"', open('pyproject.toml').read(), re.M).group(1))" >> "$GITHUB_ENV" - name: Stock-machine check — every dependency must come as a prebuilt wheel # The runner has compilers; a user's machine may not. This resolves the # whole dependency tree with building forbidden, so anything that would From 4db18ab28e993c56c0aa79ca611002a7c031fb12 Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 17:32:53 -0700 Subject: [PATCH 16/24] tests: POSIX file-mode checks skip on Windows; read sources as UTF-8 --- python-client/tests/test_agent_key.py | 2 ++ python-client/tests/test_key_randomness.py | 6 +++--- python-client/tests/test_ripemd160.py | 4 ++-- python-client/tests/test_skill_agent_cli.py | 3 +++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/python-client/tests/test_agent_key.py b/python-client/tests/test_agent_key.py index 391903a..0d288f4 100644 --- a/python-client/tests/test_agent_key.py +++ b/python-client/tests/test_agent_key.py @@ -4,6 +4,7 @@ first, unique 0600 non-overwriting file, bind to the account, verify after write, public output only, nonzero exit + no file on bad input. """ +import os import stat import pytest @@ -30,6 +31,7 @@ def test_invalid_account_rejected_before_any_write(keys_dir): assert list(keys_dir.glob("*.env")) == [] # nothing written +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes; Windows protects key files by user-profile ACL") def test_mint_binds_account_0600_and_returns_public_only(keys_dir): r = agent_key.mint(ACCT) assert set(r) == {"pubkey", "trader_id", "account", "path"} diff --git a/python-client/tests/test_key_randomness.py b/python-client/tests/test_key_randomness.py index 523b058..6deecf6 100644 --- a/python-client/tests/test_key_randomness.py +++ b/python-client/tests/test_key_randomness.py @@ -57,15 +57,15 @@ def test_entropy_source_cannot_be_swapped_for_a_weaker_one(): re.M) for pkg in SHIPPED: for path in pkg.rglob("*.py"): - src = path.read_text() + src = path.read_text(encoding="utf-8") assert not forbidden.search(src), f"{path}: forbidden entropy source" assert "os.urandom(" not in src, f"{path}: use secrets.token_bytes, not os.urandom" # signing stays RFC 6979: no caller may hand libsecp256k1 or bitcoinlib its own nonce assert "custom_nonce" not in src, f"{path}: custom nonce forbidden" assert "use_rfc6979=False" not in src, f"{path}: RFC 6979 must stay on" - gen_src = (SHIPPED[0] / "wallet.py").read_text() + gen_src = (SHIPPED[0] / "wallet.py").read_text(encoding="utf-8") assert "secrets.token_bytes(32)" in gen_src - mn_src = (SHIPPED[0] / "mnemonic.py").read_text() + mn_src = (SHIPPED[0] / "mnemonic.py").read_text(encoding="utf-8") assert "secrets.token_bytes(16)" in mn_src diff --git a/python-client/tests/test_ripemd160.py b/python-client/tests/test_ripemd160.py index 68c2591..830962c 100644 --- a/python-client/tests/test_ripemd160.py +++ b/python-client/tests/test_ripemd160.py @@ -53,6 +53,6 @@ def test_address_path_default(): def test_no_compiled_ripemd_dependency(): import pathlib root = pathlib.Path(__file__).resolve().parents[2] - assert "ripemd-hash" not in (root / "pyproject.toml").read_text() - wallet_src = (root / "python-client/sidepit_trader/wallet.py").read_text() + assert "ripemd-hash" not in (root / "pyproject.toml").read_text(encoding="utf-8") + wallet_src = (root / "python-client/sidepit_trader/wallet.py").read_text(encoding="utf-8") assert "from ripemd" not in wallet_src diff --git a/python-client/tests/test_skill_agent_cli.py b/python-client/tests/test_skill_agent_cli.py index 302f94f..fec8aa7 100644 --- a/python-client/tests/test_skill_agent_cli.py +++ b/python-client/tests/test_skill_agent_cli.py @@ -1,4 +1,5 @@ """Offline safety tests for the customer-facing delegate command.""" +import os import importlib.util import json import stat @@ -26,6 +27,7 @@ def _delegate_file(path: Path, account: str, wif: str, mode: int = 0o600) -> Pat return path +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes; Windows protects key files by user-profile ACL") def test_delegate_loader_accepts_only_distinct_0600_key(tmp_path): owner = gen_key() hot = gen_key() @@ -63,6 +65,7 @@ def test_preview_identity_is_stable_and_expectation_is_explicit(): assert "USD hedge" in agent._direction(1, 500, 2) +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes; Windows protects key files by user-profile ACL") def test_records_are_write_once(tmp_path): path = tmp_path / "record.json" agent._write_once(path, {"public": "handle"}) From 02943bd3baa3176103dd1366b6a182cc783e75be Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 18:04:56 -0700 Subject: [PATCH 17/24] cockpit: BUY/SELL buttons, visible shortcuts, closed-market guard, plain trader wording --- python-client/tests/test_tui_market_intent.py | 3 +- users-cli/sidepit_tui/app.py | 48 +++++++++++++++---- users-cli/sidepit_tui/bridge.py | 6 +-- users-cli/sidepit_tui/doggie.py | 4 +- users-cli/sidepit_tui/intents.py | 9 ++-- 5 files changed, 51 insertions(+), 19 deletions(-) diff --git a/python-client/tests/test_tui_market_intent.py b/python-client/tests/test_tui_market_intent.py index 0dea9ca..ade2433 100644 --- a/python-client/tests/test_tui_market_intent.py +++ b/python-client/tests/test_tui_market_intent.py @@ -16,7 +16,8 @@ def test_market_intent_is_a_market_order_without_a_price(): assert intent.size == 2 assert intent.price == 0 assert "market order" in intent.summary - assert "anything unfilled cancels" in intent.summary + assert "the rest cancels" in intent.summary + assert "auction" not in intent.summary.lower() def test_flatten_intent_uses_market_closes(): diff --git a/users-cli/sidepit_tui/app.py b/users-cli/sidepit_tui/app.py index 2b34b38..6f55180 100644 --- a/users-cli/sidepit_tui/app.py +++ b/users-cli/sidepit_tui/app.py @@ -29,7 +29,7 @@ from textual.coordinate import Coordinate from textual.css.query import NoMatches from textual.screen import ModalScreen -from textual.widgets import (Button, DataTable, Input, Label, RichLog, Select, +from textual.widgets import (Button, DataTable, Footer, Input, Label, RichLog, Select, Static, TabbedContent, TabPane) from .bridge import Bridge, Snap @@ -129,6 +129,12 @@ def qr_unicode(data: str) -> str: return "\n".join(lines) +def _working(bids: int, asks: int) -> str: + if not bids and not asks: + return "no orders working" + return f"working: {bids} bid · {asks} ask" + + def hhmm_left(close_ms: int) -> str: left = close_ms / 1000 - time.time() if left <= 0: @@ -237,6 +243,9 @@ class SidepitApp(App): color: {BRIGHT}; }} #prompt:focus {{ border: none; }} #prompt .input--placeholder {{ color: {DIM}; text-style: italic; }} + #hint {{ height: 1; padding: 0 1; }} + #qty {{ width: 8; }} + #buy-mkt, #sell-mkt {{ min-width: 8; }} #prompt .input--cursor {{ background: {GREEN}; color: {BG}; }} #prompt .input--selection {{ background: {GREEN_DIM}; }} .try {{ height: 1; padding: 0; min-width: 0; border: none; @@ -356,11 +365,17 @@ def compose(self) -> ComposeResult: yield Static("", id="book", classes="panel") yield Static(title("activity"), id="activity-title") yield RichLog(markup=True, wrap=True, id="log") + yield Static(f"[{DIM}]type: buy 1 at market · sell 2 @ 1259 · " + f"cancel all · go flat — or ctrl+d for the one-tap wallet[/]", + id="hint") with Horizontal(id="promptrow"): yield Label("trader@sidepit ›", id="promptlabel") yield Input(placeholder='say what you want. plain english. ' 'e.g. "go flat and exit"', id="prompt") + yield Input(value="1", placeholder="qty", id="qty") + yield Button("BUY", variant="success", id="buy-mkt") + yield Button("SELL", variant="error", id="sell-mkt") with Vertical(id="right"): yield Static(title("session"), classes="paneltitle") yield Static("", id="session", classes="panel") @@ -430,6 +445,7 @@ def compose(self) -> ComposeResult: yield Button("register", variant="success", id="register") yield Static("", id="del-derived") yield Static("", id="statbar") + yield Footer() open_doggie_on_start = False # set by `sidepit doggie` @@ -540,7 +556,7 @@ def apply_snap(self, s: Snap) -> None: f" entry {p['entry_price']} · mark {s.last or '—'} · " f"pnl [{color}]{self.money(upnl, signed=True)}[/]\n" f" [{DIM}]entry resets daily at settlement · " - f"{p['open_bids']}b/{p['open_asks']}a working[/]") + f"{_working(p['open_bids'], p['open_asks'])}[/]") self._q("#positions", Static).update( "\n".join(lines) or f"[{DIM}]no positions[/]") # risk envelope — real numbers, BTC-denominated @@ -739,7 +755,7 @@ def _refresh_bars(self) -> None: + (f" [{DIM}]· closes {hhmm_left(s.close_ms)}[/]" if s.is_open else "")) env = "RESTRICTED" if s.is_restricted else "SAFE" self._q("#statbar", Static).update( - f"{dot} auction sync · envelope: " + f"{dot} {'OPEN' if s.is_open else 'CLOSED'} · envelope: " f"[{RED if s.is_restricted else GREEN}]{env}[/] · book: transparent · " f"denom: [{GOLD}]{self.denom}[/] · session {s.session_id or '—'}") @@ -747,7 +763,7 @@ def _refresh_bars(self) -> None: def _refresh_book(self) -> None: """5-level transparent book + spread bar with the DLOB countdown.""" s = self.snap - out = [title("book · transparent · DLOB 1s deterministic auctions")] + out = [title("book")] if not s.depth_bids and not s.depth_asks: out.append(f"[{DIM}]no live book (exchange {s.state})[/]") else: @@ -756,10 +772,8 @@ def _refresh_book(self) -> None: for px, sz in reversed(s.depth_asks[:5]): bar = "█" * max(1, int(sz / mx * 22)) out.append(f" [{RED}]{px:>7}[/] {sz:>4} [{RED_DIM}]{bar}[/]") - t_left = 1.0 - (time.time() % 1.0) - badge = (f"[{GOLD}]AUCTION ARMED[/] [{DIM}]·[/] " - f"[{GOLD}]t-{t_left:.2f}s[/]" if s.is_open - else f"[{DIM}]auction idle ({s.state})[/]") + badge = (f"[{GREEN}]open[/]" if s.is_open + else f"[{DIM}]closed ({s.state})[/]") spread = (s.depth_asks[0][0] - s.depth_bids[0][0] if s.depth_asks and s.depth_bids else 0) out.append("") @@ -931,6 +945,11 @@ def _execute(self, it: Intent) -> None: if b is None or s.watch_only: self.add_event("err", "watch-only session — no signing key loaded") return + if it.kind in ("LMT", "MKT", "FLATTEN_ALL") and not s.is_open: + opens = hhmm_left(s.open_ms) + self.add_event("err", "market is closed — order not sent" + + (f" · opens in {opens}" if opens else "")) + return if it.kind == "LMT": b.cmd("order", it.side, it.price, it.size) elif it.kind == "MKT": @@ -971,6 +990,19 @@ def _int(self, input_id: str) -> int | None: return None def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id in ("buy-mkt", "sell-mkt"): + verb = "buy" if event.button.id == "buy-mkt" else "sell" + qty = self._q("#qty", Input).value.strip() or "1" + self.add_event("cmd", f'"{verb} {qty} at market"') + try: + it = parse(f"{verb} {qty} at market", last_sats=self.snap.last, + contract_usd=self.snap.contract_usd) + except ValueError as e: + self.add_event("err", str(e)) + return + self.add_event("sys", it.summary) + self._execute(it) + return bid = event.button.id or "" b = self.bridge if bid.startswith("try-"): diff --git a/users-cli/sidepit_tui/bridge.py b/users-cli/sidepit_tui/bridge.py index 1445193..0b48d82 100644 --- a/users-cli/sidepit_tui/bridge.py +++ b/users-cli/sidepit_tui/bridge.py @@ -384,7 +384,7 @@ def _exec(self, parts) -> None: self.on_event("success", f"order submitted {('BUY' if side > 0 else 'SELL')} " f"{size} @ {price} → {oid[-18:]} " - f"(resolves next epoch — watch Open Orders)") + f"(working — see Working Orders)") self._last["account"] = 0.0 # refresh orders soon elif verb == "cancel": _, oid = parts @@ -397,7 +397,7 @@ def _exec(self, parts) -> None: oid = self._submitter().market_order(side, size, s.ticker) self.on_event("success", f"MARKET {('BUY' if side > 0 else 'SELL')} {size} → " - f"{oid[-18:]} · fills what it can in the next auction, the rest cancels") + f"{oid[-18:]} · fills what's there, the rest cancels") self._last["account"] = 0.0 elif verb == "cancel_all": n = 0 @@ -420,7 +420,7 @@ def _exec(self, parts) -> None: closed += 1 self.on_event("success", f"FLATTEN_ALL submitted · {len(s.open_orders)} cancel(s), " - f"{closed} closing order(s) · verify next epoch") + f"{closed} closing order(s) · check Working Orders") self._last["account"] = 0.0 elif verb == "unlock": _, amount = parts diff --git a/users-cli/sidepit_tui/doggie.py b/users-cli/sidepit_tui/doggie.py index 73876c0..e88d8c1 100644 --- a/users-cli/sidepit_tui/doggie.py +++ b/users-cli/sidepit_tui/doggie.py @@ -193,8 +193,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "tap-usd": # toward dollars: hedge $unit more = BUY one USDBTC contract b.cmd("market", 1, 1) - self.app.add_event("ok", f"${s.contract_usd} more USD at market price · fills in the next auction") + self.app.add_event("ok", f"${s.contract_usd} more USD at market price") elif event.button.id == "tap-btc": # toward bitcoin: unwind $unit of hedge = SELL one contract b.cmd("market", -1, 1) - self.app.add_event("ok", f"${s.contract_usd} more bitcoin at market price · fills in the next auction") + self.app.add_event("ok", f"${s.contract_usd} more bitcoin at market price") diff --git a/users-cli/sidepit_tui/intents.py b/users-cli/sidepit_tui/intents.py index 2fa7429..ae98c1c 100644 --- a/users-cli/sidepit_tui/intents.py +++ b/users-cli/sidepit_tui/intents.py @@ -79,8 +79,8 @@ def parse(text: str, *, last_sats: int = 0, contract_usd: int = 500) -> Intent: if size <= 0 or price <= 0: raise ValueError("size and price must be positive") return Intent("LMT", - f"parsed → LMT {'BUY' if side > 0 else 'SELL'} {size} @ {price} " - f"sats/USD · routing to the next DLOB deterministic auction", + f"LMT {'BUY' if side > 0 else 'SELL'} {size} @ {price} " + f"sats/USD · works until filled or cancelled", side=side, size=size, price=price) m = _MKT.match(t) if m: @@ -89,9 +89,8 @@ def parse(text: str, *, last_sats: int = 0, contract_usd: int = 500) -> Intent: if size <= 0: raise ValueError("size must be positive") return Intent("MKT", - f"parsed → MKT {'BUY' if side > 0 else 'SELL'} {size} · " - f"market order: fills whatever is available in the next auction; anything unfilled cancels · " - f"routing to the next DLOB auction", + f"MKT {'BUY' if side > 0 else 'SELL'} {size} · " + f"market order: fills what's there, the rest cancels", side=side, size=size) m = _CXL.match(t) if m: From 70bd2b1922039e14acaa4a15fa97276d206a1dac Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 18:13:22 -0700 Subject: [PATCH 18/24] cockpit: compact BUY/SELL row, shorter hint, 1000-contract cap per order --- python-client/tests/test_tui_market_intent.py | 10 ++++++++++ users-cli/sidepit_tui/app.py | 14 +++++++------- users-cli/sidepit_tui/intents.py | 7 +++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/python-client/tests/test_tui_market_intent.py b/python-client/tests/test_tui_market_intent.py index ade2433..f80e59f 100644 --- a/python-client/tests/test_tui_market_intent.py +++ b/python-client/tests/test_tui_market_intent.py @@ -24,3 +24,13 @@ def test_flatten_intent_uses_market_closes(): intent = parse("go flat") assert intent.kind == "FLATTEN_ALL" assert "market orders to close" in intent.summary + + +def test_order_size_is_capped(): + import pytest + from sidepit_tui.intents import MAX_CONTRACTS, parse + assert parse(f"buy {MAX_CONTRACTS} at market", last_sats=1200, contract_usd=500).size == MAX_CONTRACTS + with pytest.raises(ValueError, match="max 1000 contracts"): + parse("buy 22112212222112 at market", last_sats=1200, contract_usd=500) + with pytest.raises(ValueError, match="max 1000 contracts"): + parse("sell 1001 @ 1259", last_sats=1200, contract_usd=500) diff --git a/users-cli/sidepit_tui/app.py b/users-cli/sidepit_tui/app.py index 6f55180..8be5e23 100644 --- a/users-cli/sidepit_tui/app.py +++ b/users-cli/sidepit_tui/app.py @@ -244,8 +244,8 @@ class SidepitApp(App): #prompt:focus {{ border: none; }} #prompt .input--placeholder {{ color: {DIM}; text-style: italic; }} #hint {{ height: 1; padding: 0 1; }} - #qty {{ width: 8; }} - #buy-mkt, #sell-mkt {{ min-width: 8; }} + #qty {{ width: 7; height: 1; margin: 0 1; }} + #buy-mkt, #sell-mkt {{ min-width: 8; height: 1; margin: 0 1 0 0; }} #prompt .input--cursor {{ background: {GREEN}; color: {BG}; }} #prompt .input--selection {{ background: {GREEN_DIM}; }} .try {{ height: 1; padding: 0; min-width: 0; border: none; @@ -365,17 +365,17 @@ def compose(self) -> ComposeResult: yield Static("", id="book", classes="panel") yield Static(title("activity"), id="activity-title") yield RichLog(markup=True, wrap=True, id="log") - yield Static(f"[{DIM}]type: buy 1 at market · sell 2 @ 1259 · " - f"cancel all · go flat — or ctrl+d for the one-tap wallet[/]", + yield Static(f"[{DIM}]type: buy 1 at market · sell 2 @ 1259 · " + f"cancel all · go flat · ctrl+d wallet[/]", id="hint") with Horizontal(id="promptrow"): yield Label("trader@sidepit ›", id="promptlabel") yield Input(placeholder='say what you want. plain english. ' 'e.g. "go flat and exit"', id="prompt") - yield Input(value="1", placeholder="qty", id="qty") - yield Button("BUY", variant="success", id="buy-mkt") - yield Button("SELL", variant="error", id="sell-mkt") + yield Input(value="1", placeholder="qty", id="qty", compact=True) + yield Button("BUY", variant="success", id="buy-mkt", compact=True) + yield Button("SELL", variant="error", id="sell-mkt", compact=True) with Vertical(id="right"): yield Static(title("session"), classes="paneltitle") yield Static("", id="session", classes="panel") diff --git a/users-cli/sidepit_tui/intents.py b/users-cli/sidepit_tui/intents.py index ae98c1c..995f130 100644 --- a/users-cli/sidepit_tui/intents.py +++ b/users-cli/sidepit_tui/intents.py @@ -37,6 +37,9 @@ class Intent: _CXL = re.compile(r"^cancel\s+(.+)$") +MAX_CONTRACTS = 1000 # per order; a typo should not become a position + + def _contracts(qty_raw: str, is_btc: bool, last_sats: int, contract_usd: int) -> int: """Integer contracts from either a contract count or a BTC notional.""" q = float(qty_raw.replace(",", "")) @@ -78,6 +81,8 @@ def parse(text: str, *, last_sats: int = 0, contract_usd: int = 500) -> Intent: price = int(m.group(4).replace(",", "")) if size <= 0 or price <= 0: raise ValueError("size and price must be positive") + if size > MAX_CONTRACTS: + raise ValueError(f"max {MAX_CONTRACTS} contracts per order (asked {size})") return Intent("LMT", f"LMT {'BUY' if side > 0 else 'SELL'} {size} @ {price} " f"sats/USD · works until filled or cancelled", @@ -88,6 +93,8 @@ def parse(text: str, *, last_sats: int = 0, contract_usd: int = 500) -> Intent: size = _contracts(m.group(2), bool(m.group(3)), last_sats, contract_usd) if size <= 0: raise ValueError("size must be positive") + if size > MAX_CONTRACTS: + raise ValueError(f"max {MAX_CONTRACTS} contracts per order (asked {size})") return Intent("MKT", f"MKT {'BUY' if side > 0 else 'SELL'} {size} · " f"market order: fills what's there, the rest cancels", From 678beaaee4b10ad21b2d93a78d0a78c1de937c2e Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 28 Oct 2026 18:15:54 -0700 Subject: [PATCH 19/24] cockpit: no command palette; quantity box takes up to 999 --- python-client/tests/test_tui_market_intent.py | 9 --------- users-cli/sidepit_tui/app.py | 4 +++- users-cli/sidepit_tui/intents.py | 7 ------- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/python-client/tests/test_tui_market_intent.py b/python-client/tests/test_tui_market_intent.py index f80e59f..8b39b48 100644 --- a/python-client/tests/test_tui_market_intent.py +++ b/python-client/tests/test_tui_market_intent.py @@ -25,12 +25,3 @@ def test_flatten_intent_uses_market_closes(): assert intent.kind == "FLATTEN_ALL" assert "market orders to close" in intent.summary - -def test_order_size_is_capped(): - import pytest - from sidepit_tui.intents import MAX_CONTRACTS, parse - assert parse(f"buy {MAX_CONTRACTS} at market", last_sats=1200, contract_usd=500).size == MAX_CONTRACTS - with pytest.raises(ValueError, match="max 1000 contracts"): - parse("buy 22112212222112 at market", last_sats=1200, contract_usd=500) - with pytest.raises(ValueError, match="max 1000 contracts"): - parse("sell 1001 @ 1259", last_sats=1200, contract_usd=500) diff --git a/users-cli/sidepit_tui/app.py b/users-cli/sidepit_tui/app.py index 8be5e23..b3fb7a4 100644 --- a/users-cli/sidepit_tui/app.py +++ b/users-cli/sidepit_tui/app.py @@ -205,6 +205,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: # --------------------------------------------------------------------------- class SidepitApp(App): TITLE = "sidepit // cockpit" + ENABLE_COMMAND_PALETTE = False # Textual's ctrl+p search box: not for traders BINDINGS = [("ctrl+q", "quit", "quit"), ("ctrl+r", "refresh", "refresh"), ("ctrl+a", "accounts", "accounts"), ("ctrl+d", "doggie", "doggie view")] @@ -373,7 +374,8 @@ def compose(self) -> ComposeResult: yield Input(placeholder='say what you want. plain english. ' 'e.g. "go flat and exit"', id="prompt") - yield Input(value="1", placeholder="qty", id="qty", compact=True) + yield Input(value="1", placeholder="qty", id="qty", compact=True, + type="integer", max_length=3) yield Button("BUY", variant="success", id="buy-mkt", compact=True) yield Button("SELL", variant="error", id="sell-mkt", compact=True) with Vertical(id="right"): diff --git a/users-cli/sidepit_tui/intents.py b/users-cli/sidepit_tui/intents.py index 995f130..ae98c1c 100644 --- a/users-cli/sidepit_tui/intents.py +++ b/users-cli/sidepit_tui/intents.py @@ -37,9 +37,6 @@ class Intent: _CXL = re.compile(r"^cancel\s+(.+)$") -MAX_CONTRACTS = 1000 # per order; a typo should not become a position - - def _contracts(qty_raw: str, is_btc: bool, last_sats: int, contract_usd: int) -> int: """Integer contracts from either a contract count or a BTC notional.""" q = float(qty_raw.replace(",", "")) @@ -81,8 +78,6 @@ def parse(text: str, *, last_sats: int = 0, contract_usd: int = 500) -> Intent: price = int(m.group(4).replace(",", "")) if size <= 0 or price <= 0: raise ValueError("size and price must be positive") - if size > MAX_CONTRACTS: - raise ValueError(f"max {MAX_CONTRACTS} contracts per order (asked {size})") return Intent("LMT", f"LMT {'BUY' if side > 0 else 'SELL'} {size} @ {price} " f"sats/USD · works until filled or cancelled", @@ -93,8 +88,6 @@ def parse(text: str, *, last_sats: int = 0, contract_usd: int = 500) -> Intent: size = _contracts(m.group(2), bool(m.group(3)), last_sats, contract_usd) if size <= 0: raise ValueError("size must be positive") - if size > MAX_CONTRACTS: - raise ValueError(f"max {MAX_CONTRACTS} contracts per order (asked {size})") return Intent("MKT", f"MKT {'BUY' if side > 0 else 'SELL'} {size} · " f"market order: fills what's there, the rest cancels", From 2bb0aa218c2d4bed091f4fc21a4fd8f70ac4ca4e Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Thu, 29 Oct 2026 23:36:55 -0700 Subject: [PATCH 20/24] docs: first-timer install steps per OS; WSL2 recommended on Windows; pipx on Ubuntu --- README.md | 86 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index dbbfe10..f865ac2 100644 --- a/README.md +++ b/README.md @@ -57,37 +57,75 @@ sending straight to the lock address does not identify the intended account. ## Claude + Sidepit: two terminals -Install the cockpit and SDK from anywhere, then clone the skills: +**Step 1 — install the cockpit.** Python 3.10 or newer. Nothing compiles: every piece arrives as a prebuilt +wheel on Linux, macOS (Apple Silicon and Intel) and Windows. Pick your machine +and follow the steps top to bottom. + +**Windows — recommended: WSL2 (Ubuntu inside Windows)** + +1. Start menu → type `PowerShell` → *Run as administrator*. In it: + ```powershell + wsl --install + ``` + (Windows 10 2004+ or Windows 11.) +2. Reboot when asked. Ubuntu opens by itself and asks for a username and a + password — pick any; this is your Linux user, not your Windows login. +3. In that Ubuntu window: + ```sh + sudo apt update && sudo apt install -y pipx + pipx install sidepit + pipx ensurepath + ``` +4. Close the Ubuntu window, open it again (Start menu → *Ubuntu*), run: + ```sh + sidepit + ``` + You get the Linux cockpit, and Claude Code runs beside it in the same terminal. + +**Windows — native, no WSL2** + +1. Start menu → `PowerShell`. In it: + ```powershell + winget install Python.Python.3.12 + ``` + (Or python.org's installer with *Add python.exe to PATH* ticked.) +2. Open a **new** terminal — the old one does not see Python yet. +3. ```powershell + python -m pip install sidepit + sidepit + ``` + Windows Terminal renders the cockpit best. Keys live in `%USERPROFILE%\.sidepit\keys`. + +**macOS (Apple Silicon or Intel)** + +1. Install Python from (the `.pkg` + installer). No Xcode tools, no Homebrew needed. +2. Open a **new** Terminal window (Applications → Utilities → Terminal): + ```sh + pip3 install sidepit + sidepit + ``` + Already on Homebrew? `brew install pipx && pipx install sidepit && pipx ensurepath`, + then reopen Terminal. + +**Linux (Ubuntu / Debian)** ```sh -pip install sidepit -git clone --recurse-submodules https://github.com/sidepit/Public-API +sudo apt update && sudo apt install -y pipx +pipx install sidepit +pipx ensurepath ``` +Reopen the terminal, run `sidepit`. Other distros: `pip install sidepit` inside a venv. -Prefer an isolated app install? Use `pipx install sidepit`. If your system -requires a virtual environment: +First run opens on the live market with no identity. Create, import or watch +one from the doors, or press Escape to just look and add a key later with ctrl+a. + +**Step 2 — clone the skills** (Claude reads them; the cockpit does not need them): ```sh -python3 -m venv .venv -source .venv/bin/activate -pip install sidepit +git clone --recurse-submodules https://github.com/sidepit/Public-API ``` -One line on every desktop, nothing to compile (Python 3.10 or newer): - -- **Linux / WSL2:** `pip install sidepit` -- **macOS** (Apple Silicon or Intel): `pip install sidepit` — no Xcode tools, no Homebrew needed. -- **Windows, recommended — WSL2:** in PowerShell `wsl --install`, reboot, open - Ubuntu, then `pip install sidepit`. You get the Linux cockpit, and Claude - Code runs beside it in the same terminal. -- **Windows, native:** `winget install Python.Python.3.12` (or python.org with - "Add python.exe to PATH" ticked), open a **new** terminal, then - `python -m pip install sidepit`. Windows Terminal renders the cockpit best; - keys live in `%USERPROFILE%\.sidepit\keys`. - -First run opens on the live market with no identity. Create, import or watch -one from the doors, or press Escape to just look and add a key later with ctrl+a. - Terminal one — the cockpit: ```sh @@ -107,7 +145,7 @@ Tell Claude: **Claude | Sidepit | make money!** Your key, your sizing, your risk, your call. -Using Sidepit as a Python SDK? The same installation gives your code: +Using Sidepit as a Python SDK? `pip install sidepit` in your project's venv gives your code: ```python from sidepit_trader import RequestClient, Signer, Submitter From d0ce66fa5bb728fb8780d0da6a1afd12cc73dc28 Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Fri, 30 Oct 2026 12:29:24 -0700 Subject: [PATCH 21/24] =?UTF-8?q?cockpit:=20identity=20backup=20screen=20?= =?UTF-8?q?=E2=80=94=20words=20and=20WIF=20hidden=20until=20shown,=20one-c?= =?UTF-8?q?lick=20copy,=20stored-path=20notice,=20ctrl+a=20backup=20door?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python-client/sidepit_trader/keystore.py | 7 +- .../tests/test_tui_identity_backup.py | 67 +++++++++ users-cli/sidepit_tui/onboard.py | 128 +++++++++++++++--- 3 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 python-client/tests/test_tui_identity_backup.py diff --git a/python-client/sidepit_trader/keystore.py b/python-client/sidepit_trader/keystore.py index 1ea3816..fba4c2a 100644 --- a/python-client/sidepit_trader/keystore.py +++ b/python-client/sidepit_trader/keystore.py @@ -117,7 +117,12 @@ def identity(name: str) -> dict | None: if "SIDEPIT_ID" not in d: return None return {"name": name, "sidepit_id": d["SIDEPIT_ID"], - "wif": d.get("SIDEPIT_WIF")} + "wif": d.get("SIDEPIT_WIF"), "mnemonic": d.get("SIDEPIT_MNEMONIC")} + + +def identity_path(name: str) -> Path: + """Where one named identity lives on disk (for the backup screen).""" + return _path(name) def active_name() -> str | None: diff --git a/python-client/tests/test_tui_identity_backup.py b/python-client/tests/test_tui_identity_backup.py new file mode 100644 index 0000000..42a4091 --- /dev/null +++ b/python-client/tests/test_tui_identity_backup.py @@ -0,0 +1,67 @@ +"""New identity: stored first, backup offered with secrets hidden until asked. + +Jay 2026-10-30: the 12 words must be hidden by default, the screen must say +where the key is stored so skipping feels safe, one click copies, and a backup +door must exist later (ctrl+a → backup). Nothing secret is printed here. +""" +import asyncio +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "users-cli")) + +from textual.app import App # noqa: E402 +from textual.widgets import Button, Static # noqa: E402 + +from sidepit_trader import keystore # noqa: E402 +from sidepit_tui import onboard # noqa: E402 + +WORDS = "abandon " * 11 + "about" +WIF = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn" # BIP32 test vector key + + +def test_backup_text_hides_secrets_until_shown(): + hidden = onboard.backup_text("/k/tui.env", "bc1qme", WORDS, WIF, False) + assert "abandon" not in hidden and WIF not in hidden + assert "/k/tui.env" in hidden and "ctrl+a" in hidden + shown = onboard.backup_text("/k/tui.env", "bc1qme", WORDS, WIF, True) + assert "12. about" in shown and WIF in shown + + +def test_backup_screen_show_toggle_and_copy(): + copied = [] + + class T(App): + def copy_to_clipboard(self, text): + copied.append(text) + + async def run(): + app = T() + async with app.run_test() as pilot: + app.push_screen(onboard.BackupScreen("tui", "bc1qme", WIF, WORDS, + "/k/tui.env", first_time=True)) + await pilot.pause() + body = app.screen.query_one("#backup-body", Static) + assert "abandon" not in str(body.content) + assert "Later" in str(app.screen.query_one("#bk-done", Button).label) + await pilot.click("#bk-show") + await pilot.pause(0.5) # past the double-click window + assert "12. about" in str(body.content) + assert "hide" in str(app.screen.query_one("#bk-show", Button).label) + await pilot.click("#bk-show") + await pilot.pause(0.5) # past the double-click window + assert "abandon" not in str(body.content) + await pilot.click("#bk-copy-words") + await pilot.click("#bk-copy-wif") + await pilot.pause() + assert copied == [WORDS, WIF] + asyncio.run(run()) + + +def test_keystore_identity_returns_words_and_path(tmp_path, monkeypatch): + monkeypatch.setattr(keystore, "KEYS_DIR", tmp_path) + monkeypatch.setattr(keystore, "ACTIVE_FILE", tmp_path / "ACTIVE") + p = keystore.save_identity("tui", "bc1qme", WIF, mnemonic=WORDS) + row = keystore.identity("tui") + assert row["mnemonic"] == WORDS and row["wif"] == WIF + assert keystore.identity_path("tui") == p diff --git a/users-cli/sidepit_tui/onboard.py b/users-cli/sidepit_tui/onboard.py index 10edf73..374c561 100644 --- a/users-cli/sidepit_tui/onboard.py +++ b/users-cli/sidepit_tui/onboard.py @@ -4,8 +4,8 @@ file per identity — keys NEVER live in a database; bots read the same files, so onboarding here IS provisioning for an agent): - create mint a key, show the WIF ONCE (it IS the account; no recovery), - store it, continue funded onboarding on the Fund tab. + create mint a key, store it, then offer the backup (12 words / WIF, + hidden until asked). Skipping is safe: the file is the key. import paste a WIF (also: `python -m sidepit_tui import`, or just drop an env file into ~/.sidepit/keys/). watch just an address — read-only everywhere (verbs disabled). @@ -75,11 +75,12 @@ def compose(self) -> ComposeResult: """ def _finish(self, name: str, sidepit_id: str, wif: str | None, - mnemonic: str | None = None) -> None: - keystore.save_identity(name, sidepit_id, wif, active=True, - mnemonic=mnemonic) + mnemonic: str | None = None): + path = keystore.save_identity(name, sidepit_id, wif, active=True, + mnemonic=mnemonic) self.app.start_bridge(sidepit_id, wif, name=name) self.dismiss(None) + return path def _msg(self, text: str) -> None: self.query_one("#ob-msg", Static).update(text) @@ -89,22 +90,10 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "ob-create": from sidepit_trader import mnemonic words, ident = mnemonic.new_identity_words() - grid = "\n".join(" ".join(f"{i + 1:>2}. {w:<10}" - for i, w in list(enumerate(words.split()))[r * 4:r * 4 + 4]) - for r in range(3)) - # show the secret BEFORE storing so the user must acknowledge it - from .app import ShowSecret - self.app.push_screen(ShowSecret( - "NEW IDENTITY — write down the 12 words NOW (they ARE the " - "account; no recovery)", - f"{grid}\n\n" - f"sidepit_id : {ident.sidepit_id}\n" - f"WIF : {ident.wif}\n\n" - f"The 12 words restore this key in ANY standard wallet (BIP39/84)\n" - f"and here (import → paste the words). Stored as a 0600 flat file\n" - f"in ~/.sidepit/keys/; keep an offline copy of the words."), - lambda _: self._finish("tui", ident.sidepit_id, ident.wif, - mnemonic=words)) + # stored first: skipping the backup is safe, the key is on disk + path = self._finish("tui", ident.sidepit_id, ident.wif, mnemonic=words) + self.app.push_screen(BackupScreen("tui", ident.sidepit_id, ident.wif, + words, str(path), first_time=True)) elif event.button.id == "ob-import": from sidepit_trader import mnemonic words = None @@ -127,6 +116,91 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.action_cancel() +HIDDEN = "•••• hidden — press show" + + +def backup_text(path: str, sidepit_id: str, words: str | None, + wif: str | None, shown: bool) -> str: + """The backup panel body. Secrets appear only when `shown`.""" + if shown and words: + ws = words.split() + grid = "\n".join(" ".join(f"{i + 1:>2}. {w:<10}" for i, w in + list(enumerate(ws))[r * 4:r * 4 + 4]) + for r in range(3)) + elif words: + grid = HIDDEN + "\n\n" # same height as the grid: nothing jumps + else: + grid = "(imported from a WIF — no words; the WIF is the backup)\n\n" + return (f"stored on this computer, readable only by you:\n {path}\n\n" + f"sidepit_id : {sidepit_id}\n\n" + f"12 words :\n{grid}\n\n" + f"WIF : {wif if shown else HIDDEN}\n\n" + f"Back it up when you have a minute — any one of these restores " + f"the account:\n" + f"write the 12 words down · copy the WIF into a password manager · " + f"keep a copy of the file above.\n" + f"Any time: ctrl+a → backup.") + + +class BackupScreen(ModalScreen[None]): + """Show one identity's backup material, hidden until the human asks.""" + + BINDINGS = [("escape", "dismiss(None)", "close")] + + def __init__(self, name: str, sidepit_id: str, wif: str | None, + words: str | None, path: str, first_time: bool = False): + super().__init__() + self._name, self._id, self._wif, self._words, self._path = ( + name, sidepit_id, wif, words, path) + self._first = first_time + self._shown = False + + def compose(self) -> ComposeResult: + title = ("[b]Identity created and saved[/b] — back it up now or later" + if self._first else f"[b]backup[/b] — {self._name}") + with Grid(id="backup-grid"): + yield Label(title, id="backup-title") + yield Static(backup_text(self._path, self._id, self._words, + self._wif, False), id="backup-body") + with Horizontal(id="backup-buttons"): + yield Button("show", id="bk-show") + yield Button("copy words", id="bk-copy-words", + disabled=not self._words) + yield Button("copy WIF", id="bk-copy-wif", disabled=not self._wif) + yield Button("Later — it's saved here" if self._first else "close", + variant="primary", id="bk-done") + yield Static("", id="backup-msg") + + DEFAULT_CSS = """ + #backup-grid { + grid-size: 1; grid-gutter: 1; padding: 1 2; + width: 86; height: auto; border: thick $primary; background: $surface; + } + #backup-buttons { height: 3; } + #backup-buttons Button { margin-right: 1; } + """ + + def _copy(self, what: str, text: str) -> None: + self.app.copy_to_clipboard(text) + self.query_one("#backup-msg", Static).update( + f"{what} copied to the clipboard — paste it somewhere safe, then " + f"copy something else to clear it") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "bk-show": + self._shown = not self._shown + self.query_one("#backup-body", Static).update( + backup_text(self._path, self._id, self._words, self._wif, + self._shown)) + event.button.label = "hide" if self._shown else "show" + elif event.button.id == "bk-copy-words" and self._words: + self._copy("12 words", self._words) + elif event.button.id == "bk-copy-wif" and self._wif: + self._copy("WIF", self._wif) + elif event.button.id == "bk-done": + self.dismiss(None) + + class AccountsScreen(ModalScreen[None]): """Quick account switcher (ctrl+a) — list the keystore, switch with one keypress, or add another identity through the onboarding doors. Mirrors the @@ -142,6 +216,7 @@ def compose(self) -> ComposeResult: with Horizontal(id="accounts-buttons"): yield Button("switch", variant="primary", id="acct-switch") yield Button("add identity", id="acct-add") + yield Button("backup", id="acct-backup") yield Button("close", id="acct-close") DEFAULT_CSS = """ @@ -192,5 +267,16 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "acct-add": self.dismiss(None) self.app.push_screen(OnboardScreen()) + elif event.button.id == "acct-backup": + name = self._selected() + row = keystore.identity(name) if name else None + if not row: + return + if not row["wif"]: + self.app.add_event("sys", f"'{name}' is watch-only — nothing to back up") + return + self.app.push_screen(BackupScreen(name, row["sidepit_id"], row["wif"], + row.get("mnemonic"), + str(keystore.identity_path(name)))) elif event.button.id == "acct-close": self.dismiss(None) From a089c0ecf7ae6211e9b24f1c5fbe696b5000ba07 Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Fri, 30 Oct 2026 12:37:30 -0700 Subject: [PATCH 22/24] cockpit: copy id button in the top bar; FUND and backup copy the full address --- .../tests/test_tui_identity_backup.py | 19 ++++++++++++++++++ users-cli/sidepit_tui/app.py | 20 +++++++++++++++++-- users-cli/sidepit_tui/onboard.py | 3 +++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/python-client/tests/test_tui_identity_backup.py b/python-client/tests/test_tui_identity_backup.py index 42a4091..75062a8 100644 --- a/python-client/tests/test_tui_identity_backup.py +++ b/python-client/tests/test_tui_identity_backup.py @@ -65,3 +65,22 @@ def test_keystore_identity_returns_words_and_path(tmp_path, monkeypatch): row = keystore.identity("tui") assert row["mnemonic"] == WORDS and row["wif"] == WIF assert keystore.identity_path("tui") == p + + +def test_backup_screen_copies_full_id(): + copied = [] + + class T(App): + def copy_to_clipboard(self, text): + copied.append(text) + + async def run(): + app = T() + async with app.run_test() as pilot: + app.push_screen(onboard.BackupScreen("tui", "bc1qcw68kk7dq4longaddresssa7m", + WIF, WORDS, "/k/tui.env")) + await pilot.pause() + await pilot.click("#bk-copy-id") + await pilot.pause() + assert copied == ["bc1qcw68kk7dq4longaddresssa7m"] + asyncio.run(run()) diff --git a/users-cli/sidepit_tui/app.py b/users-cli/sidepit_tui/app.py index b3fb7a4..e56371f 100644 --- a/users-cli/sidepit_tui/app.py +++ b/users-cli/sidepit_tui/app.py @@ -217,6 +217,7 @@ class SidepitApp(App): padding: 0 1; content-align-vertical: middle; }} #wallet-pick {{ width: 30; height: 1; background: {PANEL}; }} #denom-pick {{ width: 12; height: 1; background: {PANEL}; }} + #copy-id {{ width: 11; height: 1; margin: 0 1; }} #wallet-pick SelectCurrent, #denom-pick SelectCurrent {{ height: 1; border: none; background: {PANEL}; color: {GOLD}; }} @@ -355,6 +356,7 @@ def compose(self) -> ComposeResult: yield Select([("₿ BTC", "BTC"), ("sat SATS", "SATS")], value="BTC", id="denom-pick", allow_blank=False) yield Select([], id="wallet-pick", prompt="wallet", allow_blank=True) + yield Button("copy id", id="copy-id", compact=True) with TabbedContent(initial="tab-cockpit"): with TabPane("cockpit", id="tab-cockpit"): with Horizontal(id="cockpit-grid"): @@ -991,7 +993,20 @@ def _int(self, input_id: str) -> int | None: except ValueError: return None + def copy_id(self) -> None: + """Put the full sidepit_id on the clipboard; the panels only show a + shortened one. Pop-up confirms, log keeps the whole address.""" + addr = self.snap.address + if not addr: + self.add_event("warn", "no identity yet — ctrl+a adds one") + return + self.copy_to_clipboard(addr) + self.add_event("ok", f"id copied: {addr}") + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "copy-id": + self.copy_id() + return if event.button.id in ("buy-mkt", "sell-mkt"): verb = "buy" if event.button.id == "buy-mkt" else "sell" qty = self._q("#qty", Input).value.strip() or "1" @@ -1017,8 +1032,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if bid == "chain-refresh": b.cmd("chain_refresh") elif bid == "fund-show": - self.add_event("ok", f"send BTC to {self.snap.address} — it appears " - f"here automatically, then LOCK goes green") + self.copy_to_clipboard(self.snap.address) + self.add_event("ok", f"address copied: {self.snap.address} — send BTC " + f"to it; it appears here automatically, then LOCK goes green") elif bid == "lock-all": total = self.snap.chain_confirmed + self.snap.chain_mempool if total <= 0: diff --git a/users-cli/sidepit_tui/onboard.py b/users-cli/sidepit_tui/onboard.py index 374c561..62cf3c9 100644 --- a/users-cli/sidepit_tui/onboard.py +++ b/users-cli/sidepit_tui/onboard.py @@ -164,6 +164,7 @@ def compose(self) -> ComposeResult: self._wif, False), id="backup-body") with Horizontal(id="backup-buttons"): yield Button("show", id="bk-show") + yield Button("copy id", id="bk-copy-id") yield Button("copy words", id="bk-copy-words", disabled=not self._words) yield Button("copy WIF", id="bk-copy-wif", disabled=not self._wif) @@ -193,6 +194,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: backup_text(self._path, self._id, self._words, self._wif, self._shown)) event.button.label = "hide" if self._shown else "show" + elif event.button.id == "bk-copy-id": + self._copy("sidepit_id", self._id) elif event.button.id == "bk-copy-words" and self._words: self._copy("12 words", self._words) elif event.button.id == "bk-copy-wif" and self._wif: From 99c990e4e26c6b454a0b5aa1c125e3cfbe9d4e75 Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Fri, 30 Oct 2026 12:44:14 -0700 Subject: [PATCH 23/24] cockpit: click the top bar or session panel to copy the id; tighter top bar so both lines fit --- users-cli/sidepit_tui/app.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/users-cli/sidepit_tui/app.py b/users-cli/sidepit_tui/app.py index e56371f..306ecbd 100644 --- a/users-cli/sidepit_tui/app.py +++ b/users-cli/sidepit_tui/app.py @@ -215,9 +215,8 @@ class SidepitApp(App): align-vertical: middle; }} #titlebar {{ height: 3; width: 1fr; background: {PANEL}; color: {DIM}; padding: 0 1; content-align-vertical: middle; }} - #wallet-pick {{ width: 30; height: 1; background: {PANEL}; }} + #wallet-pick {{ width: 24; height: 1; background: {PANEL}; }} #denom-pick {{ width: 12; height: 1; background: {PANEL}; }} - #copy-id {{ width: 11; height: 1; margin: 0 1; }} #wallet-pick SelectCurrent, #denom-pick SelectCurrent {{ height: 1; border: none; background: {PANEL}; color: {GOLD}; }} @@ -356,7 +355,6 @@ def compose(self) -> ComposeResult: yield Select([("₿ BTC", "BTC"), ("sat SATS", "SATS")], value="BTC", id="denom-pick", allow_blank=False) yield Select([], id="wallet-pick", prompt="wallet", allow_blank=True) - yield Button("copy id", id="copy-id", compact=True) with TabbedContent(initial="tab-cockpit"): with TabPane("cockpit", id="tab-cockpit"): with Horizontal(id="cockpit-grid"): @@ -599,8 +597,8 @@ def apply_snap(self, s: Snap) -> None: else f"[{RED}]AGENT[/] · not authorized yet") else: mode = f"[{GOLD}]SIGNING[/]" - dlines = [f"account [{BRIGHT}]{s.address[:14]}…{s.address[-4:]}[/]" if s.address - else "account —", + dlines = [f"account [{BRIGHT}]{s.address[:14]}…{s.address[-4:]}[/] " + f"[{DIM}](click copies id)[/]" if s.address else "account —", f"mode {mode}"] if s.is_delegate: dlines.append(f"[{DIM}]trading only — fund/withdraw need the " @@ -744,18 +742,17 @@ def _refresh_bars(self) -> None: spread = s.ask - s.bid if s.ask and s.bid else 0 account = (f"{s.address[:10]}…{s.address[-4:]}" if s.address else "—") self._q("#titlebar", Static).update( - f"[{BRIGHT}]sidepit // cockpit[/] [{DIM}]v1[/] " - f"{dot} [{BRIGHT}]{s.state.removeprefix('EXCHANGE_')}[/] " - f"[{BRIGHT}]{s.ticker or 'NO CONTRACT'}[/] " - f"[{GOLD}]last {s.last or '—'}[/] [{BRIGHT}]{usd_hint(s.last)}[/] " - f"[{GREEN}]bid {s.bid or '—'} × {s.bidsize or '—'}[/] " - f"[{RED}]ask {s.ask or '—'} × {s.asksize or '—'}[/] " + f"[{BRIGHT}]sidepit // cockpit[/] " + f"{dot} [{BRIGHT}]{s.state.removeprefix('EXCHANGE_')}[/] " + f"[{BRIGHT}]{s.ticker or 'NO CONTRACT'}[/] " + f"[{GOLD}]last {s.last or '—'}[/] [{BRIGHT}]{usd_hint(s.last)}[/] " + f"[{GREEN}]bid {s.bid or '—'} × {s.bidsize or '—'}[/] " + f"[{RED}]ask {s.ask or '—'} × {s.asksize or '—'}[/] " f"[{DIM}]spread {spread or '—'}[/]\n" - f"[{DIM}]account[/] [{BRIGHT}]{account}[/] [{DIM}]· {mode} ·[/] " + f"[{DIM}]account[/] [{BRIGHT}]{account}[/] [{DIM}](click copies id)[/] " + f"[{DIM}]· {mode} ·[/] " f"[{DIM}]contract[/] [{BRIGHT}]${s.contract_usd:,}[/] " - f"[{DIM}]tick[/] [{BRIGHT}]{s.tick_size_sats} sat/USD → " - f"{s.tick_value_sats} sat[/] [{DIM}]· ctrl+d[/] [{GOLD}]doggie[/] " - f"[{DIM}]· session {s.session_id or '—'}[/]" + f"[{DIM}]tick[/] [{BRIGHT}]{s.tick_size_sats} sat/USD → {s.tick_value_sats} sat[/]" + (f" [{DIM}]· closes {hhmm_left(s.close_ms)}[/]" if s.is_open else "")) env = "RESTRICTED" if s.is_restricted else "SAFE" self._q("#statbar", Static).update( @@ -1003,10 +1000,12 @@ def copy_id(self) -> None: self.copy_to_clipboard(addr) self.add_event("ok", f"id copied: {addr}") - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "copy-id": + def on_click(self, event) -> None: + w = getattr(event, "widget", None) + if w is not None and w.id in ("titlebar", "session"): self.copy_id() - return + + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id in ("buy-mkt", "sell-mkt"): verb = "buy" if event.button.id == "buy-mkt" else "sell" qty = self._q("#qty", Input).value.strip() or "1" From 1fdfa166e2c29eedfaab134d04db67d97b40be3d Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Fri, 30 Oct 2026 12:47:01 -0700 Subject: [PATCH 24/24] cockpit: show the full account id in the top bar and session panel --- users-cli/sidepit_tui/app.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/users-cli/sidepit_tui/app.py b/users-cli/sidepit_tui/app.py index 306ecbd..5a80157 100644 --- a/users-cli/sidepit_tui/app.py +++ b/users-cli/sidepit_tui/app.py @@ -597,8 +597,7 @@ def apply_snap(self, s: Snap) -> None: else f"[{RED}]AGENT[/] · not authorized yet") else: mode = f"[{GOLD}]SIGNING[/]" - dlines = [f"account [{BRIGHT}]{s.address[:14]}…{s.address[-4:]}[/] " - f"[{DIM}](click copies id)[/]" if s.address else "account —", + dlines = [f"account [{BRIGHT}]{s.address}[/]" if s.address else "account —", f"mode {mode}"] if s.is_delegate: dlines.append(f"[{DIM}]trading only — fund/withdraw need the " @@ -740,7 +739,7 @@ def _refresh_bars(self) -> None: ("agent" if s.delegate_active else "agent · unverified") if s.is_delegate else "signing") spread = s.ask - s.bid if s.ask and s.bid else 0 - account = (f"{s.address[:10]}…{s.address[-4:]}" if s.address else "—") + account = s.address or "—" self._q("#titlebar", Static).update( f"[{BRIGHT}]sidepit // cockpit[/] " f"{dot} [{BRIGHT}]{s.state.removeprefix('EXCHANGE_')}[/] " @@ -749,7 +748,7 @@ def _refresh_bars(self) -> None: f"[{GREEN}]bid {s.bid or '—'} × {s.bidsize or '—'}[/] " f"[{RED}]ask {s.ask or '—'} × {s.asksize or '—'}[/] " f"[{DIM}]spread {spread or '—'}[/]\n" - f"[{DIM}]account[/] [{BRIGHT}]{account}[/] [{DIM}](click copies id)[/] " + f"[{DIM}]account[/] [{BRIGHT}]{account}[/] " f"[{DIM}]· {mode} ·[/] " f"[{DIM}]contract[/] [{BRIGHT}]${s.contract_usd:,}[/] " f"[{DIM}]tick[/] [{BRIGHT}]{s.tick_size_sats} sat/USD → {s.tick_value_sats} sat[/]"