From 42c95df9c9cc66cd7f375abcc1e6e31a947861e6 Mon Sep 17 00:00:00 2001 From: api Date: Mon, 26 Oct 2026 22:38:43 -0700 Subject: [PATCH 01/16] 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 f9f7eaef192fd259b164823f5dc435cf3b79529c Mon Sep 17 00:00:00 2001 From: api Date: Mon, 26 Oct 2026 22:38:44 -0700 Subject: [PATCH 02/16] 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 08e1d421e34dfb5de215052e0abb475afb9ae52e Mon Sep 17 00:00:00 2001 From: api Date: Tue, 27 Oct 2026 22:54:43 -0700 Subject: [PATCH 03/16] 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 418ade483dccbbc4ffcec3a0b48d3d8a5bfaa631 Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:00:46 -0700 Subject: [PATCH 04/16] 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 e266ef6de9845723ab657b0964a7ddd4815fa92a Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:00:46 -0700 Subject: [PATCH 05/16] =?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 60c1e43be74297d1605d145a6bc21db87d1caac3 Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:05:36 -0700 Subject: [PATCH 06/16] 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 951b3317ddcdcc0473ece49f71fc7d3502e5e42d Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:23:48 -0700 Subject: [PATCH 07/16] 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 533b63775e507bb273aaa736cf84729a78ae616b Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:23:48 -0700 Subject: [PATCH 08/16] 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 0f67a277769aa9999ad934b985049baf788687de Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:23:48 -0700 Subject: [PATCH 09/16] 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 cd494e5b27de22ef5d91ee471c3d66c7196dfaac Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:23:48 -0700 Subject: [PATCH 10/16] 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 230f73355953fa37a5d0e676f6e258bf0f0ae93e Mon Sep 17 00:00:00 2001 From: Jay Berg Date: Wed, 23 Sep 2026 16:59:26 -0700 Subject: [PATCH 11/16] 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 03ba0fd3ffbbd6d23b89adbb1373a159ad1678e6 Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:26:23 -0700 Subject: [PATCH 12/16] 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 1a3b604acbf29aa7c20c8d7e4a92240149ea0775 Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:26:23 -0700 Subject: [PATCH 13/16] 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 878fb01754c4f6548e12e5c8b215654435a0063f Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:28:50 -0700 Subject: [PATCH 14/16] 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 31dbfb7e29edb37db5ae3c40498ea4781179ebf0 Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:28:50 -0700 Subject: [PATCH 15/16] 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 aaad65419e139e389113a5553aa045f34bbfcacc Mon Sep 17 00:00:00 2001 From: api Date: Wed, 28 Oct 2026 17:32:53 -0700 Subject: [PATCH 16/16] 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"})