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 diff --git a/.github/workflows/install-proof.yml b/.github/workflows/install-proof.yml new file mode 100644 index 0000000..27f0877 --- /dev/null +++ b/.github/workflows/install-proof.yml @@ -0,0 +1,154 @@ +name: Install proof + +# 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 + run: | + 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: 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: + name: pip install (${{ matrix.os }}, ${{ matrix.python-version }}) + needs: [build, native] + 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: + pattern: dist-* + path: dist + merge-multiple: true + - 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 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 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 + # 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: --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 + 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: + 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 + - 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 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 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/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/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/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/README.md b/README.md index f1a9dbd..f865ac2 100644 --- a/README.md +++ b/README.md @@ -57,20 +57,73 @@ 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. + +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. -Prefer an isolated app install? Use `pipx install sidepit`. If your system -requires a virtual environment: +**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 ``` Terminal one — the cockpit: @@ -92,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 diff --git a/pyproject.toml b/pyproject.toml index ee72d1e..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,16 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", "Topic :: Office/Business :: Financial :: Investment", ] -dependencies = [ - "protobuf==3.20.1", - "pynng", - "secp256k1", - "base58", - "ripemd-hash>=1.0.1", - "bitcoinlib", - "textual>=8.0", - "qrcode>=7.0", -] +dynamic = ["dependencies"] # see setup.py: pure vs bundled-secp256k1 builds [project.scripts] sidepit = "sidepit_tui.__main__:main" @@ -38,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/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)") 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/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/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 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_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 new file mode 100644 index 0000000..6deecf6 --- /dev/null +++ b/python-client/tests/test_key_randomness.py @@ -0,0 +1,83 @@ +"""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(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(encoding="utf-8") + assert "secrets.token_bytes(32)" in gen_src + mn_src = (SHIPPED[0] / "mnemonic.py").read_text(encoding="utf-8") + 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 diff --git a/python-client/tests/test_ripemd160.py b/python-client/tests/test_ripemd160.py new file mode 100644 index 0000000..830962c --- /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(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"}) 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/python-client/tests/test_tui_identity_backup.py b/python-client/tests/test_tui_identity_backup.py new file mode 100644 index 0000000..75062a8 --- /dev/null +++ b/python-client/tests/test_tui_identity_backup.py @@ -0,0 +1,86 @@ +"""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 + + +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/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/python-client/tests/test_tui_market_intent.py b/python-client/tests/test_tui_market_intent.py index 0dea9ca..8b39b48 100644 --- a/python-client/tests/test_tui_market_intent.py +++ b/python-client/tests/test_tui_market_intent.py @@ -16,10 +16,12 @@ 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(): intent = parse("go flat") assert intent.kind == "FLATTEN_ALL" assert "market orders to close" in intent.summary + 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/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 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()) diff --git a/users-cli/sidepit_tui/app.py b/users-cli/sidepit_tui/app.py index 58c8a61..5a80157 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: @@ -199,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")] @@ -208,7 +215,7 @@ 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}; }} #wallet-pick SelectCurrent, #denom-pick SelectCurrent {{ height: 1; border: none; background: {PANEL}; @@ -237,6 +244,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: 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; @@ -356,11 +366,18 @@ 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 · 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", 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"): yield Static(title("session"), classes="paneltitle") yield Static("", id="session", classes="panel") @@ -430,6 +447,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` @@ -470,8 +488,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 @@ -537,7 +558,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 @@ -576,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:]}[/]" 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 " @@ -719,24 +739,23 @@ 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[/] [{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}[/] " + 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( - 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 '—'}") @@ -744,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: @@ -753,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("") @@ -928,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": @@ -967,7 +989,35 @@ 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_click(self, event) -> None: + w = getattr(event, "widget", None) + if w is not None and w.id in ("titlebar", "session"): + self.copy_id() + 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-"): @@ -980,8 +1030,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/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 cea2172..e88d8c1 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 $ @@ -181,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: diff --git a/users-cli/sidepit_tui/onboard.py b/users-cli/sidepit_tui/onboard.py index 904499d..62cf3c9 100644 --- a/users-cli/sidepit_tui/onboard.py +++ b/users-cli/sidepit_tui/onboard.py @@ -4,21 +4,18 @@ 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). + look nothing at all — the market only; add an identity later (ctrl+a). -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 +24,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: @@ -52,24 +33,36 @@ 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()) 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 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: - 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 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") @@ -82,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) @@ -96,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 @@ -130,10 +112,96 @@ 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) + elif event.button.id == "ob-look": + 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 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) + 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-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: + self._copy("WIF", self._wif) + elif event.button.id == "bk-done": + self.dismiss(None) class AccountsScreen(ModalScreen[None]): @@ -151,6 +219,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 = """ @@ -201,5 +270,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)