From c1926136fbef14647a7f61e15702c66292ecd222 Mon Sep 17 00:00:00 2001 From: oib Date: Sat, 27 Sep 2025 06:05:25 +0200 Subject: [PATCH] chore: initialize monorepo with project scaffolding, configs, and CI setup --- .editorconfig | 13 + .github/workflows/python-tests.yml | 34 + .gitignore | 31 + .windsurf/workflows/docs.md | 8 + .windsurf/workflows/ns.md | 10 + .windsurf/workflows/roadmap.md | 6 + LICENSE | 21 + README.md | 16 + apps/blockchain-node/README.md | 25 + apps/blockchain-node/alembic.ini | 147 ++ apps/blockchain-node/data/chain.db | Bin 0 -> 73728 bytes apps/blockchain-node/migrations/README | 1 + apps/blockchain-node/migrations/env.py | 85 + .../blockchain-node/migrations/script.py.mako | 28 + .../80bc0020bde2_add_block_relationships.py | 34 + .../versions/e31f486f1484_baseline.py | 103 + apps/blockchain-node/poetry.lock | 1673 +++++++++++++++++ apps/blockchain-node/pyproject.toml | 37 + apps/blockchain-node/scripts/devnet_up.sh | 36 + apps/blockchain-node/scripts/keygen.py | 46 + apps/blockchain-node/scripts/make_genesis.py | 96 + .../scripts/mock_coordinator.py | 38 + .../src/aitbc_chain/__init__.py | 5 + apps/blockchain-node/src/aitbc_chain/app.py | 33 + .../blockchain-node/src/aitbc_chain/config.py | 30 + .../src/aitbc_chain/consensus/__init__.py | 5 + .../src/aitbc_chain/consensus/poa.py | 140 ++ .../src/aitbc_chain/database.py | 20 + .../src/aitbc_chain/logging.py | 71 + apps/blockchain-node/src/aitbc_chain/main.py | 72 + .../src/aitbc_chain/mempool.py | 47 + .../src/aitbc_chain/metrics.py | 40 + .../blockchain-node/src/aitbc_chain/models.py | 116 ++ .../src/aitbc_chain/rpc/router.py | 184 ++ apps/client-web/README.md | 9 + apps/coordinator-api/README.md | 34 + apps/coordinator-api/pyproject.toml | 33 + apps/coordinator-api/src/app/__init__.py | 1 + apps/coordinator-api/src/app/config.py | 32 + apps/coordinator-api/src/app/deps.py | 26 + .../src/app/domain/__init__.py | 7 + apps/coordinator-api/src/app/domain/job.py | 30 + .../src/app/domain/job_receipt.py | 15 + apps/coordinator-api/src/app/domain/miner.py | 25 + apps/coordinator-api/src/app/main.py | 34 + apps/coordinator-api/src/app/models.py | 78 + .../src/app/routers/__init__.py | 1 + apps/coordinator-api/src/app/routers/admin.py | 69 + .../coordinator-api/src/app/routers/client.py | 97 + apps/coordinator-api/src/app/routers/miner.py | 110 ++ .../src/app/services/__init__.py | 6 + apps/coordinator-api/src/app/services/jobs.py | 156 ++ .../src/app/services/miners.py | 110 ++ .../src/app/services/receipts.py | 79 + .../src/app/storage/__init__.py | 5 + apps/coordinator-api/src/app/storage/db.py | 42 + .../tests/test_client_receipts.py | 77 + apps/coordinator-api/tests/test_jobs.py | 57 + .../tests/test_miner_service.py | 258 +++ apps/explorer-web/README.md | 158 ++ apps/explorer-web/package.json | 15 + apps/explorer-web/public/css/base.css | 82 + apps/explorer-web/public/css/layout.css | 229 +++ apps/explorer-web/public/css/theme.css | 38 + apps/explorer-web/public/mock/addresses.json | 14 + apps/explorer-web/public/mock/blocks.json | 23 + apps/explorer-web/public/mock/receipts.json | 18 + .../public/mock/transactions.json | 18 + .../src/components/dataModeToggle.js | 33 + .../src/components/dataModeToggle.ts | 45 + .../explorer-web/src/components/siteFooter.js | 7 + .../explorer-web/src/components/siteFooter.ts | 10 + .../explorer-web/src/components/siteHeader.js | 6 + .../explorer-web/src/components/siteHeader.ts | 20 + apps/explorer-web/src/config.js | 10 + apps/explorer-web/src/config.ts | 14 + apps/explorer-web/src/lib/mockData.js | 207 ++ apps/explorer-web/src/lib/mockData.ts | 112 ++ apps/explorer-web/src/lib/models.js | 2 + apps/explorer-web/src/lib/models.ts | 57 + apps/explorer-web/src/main.js | 63 + apps/explorer-web/src/main.ts | 84 + apps/explorer-web/src/pages/addresses.js | 72 + apps/explorer-web/src/pages/addresses.ts | 72 + apps/explorer-web/src/pages/blocks.js | 74 + apps/explorer-web/src/pages/blocks.ts | 65 + apps/explorer-web/src/pages/overview.js | 93 + apps/explorer-web/src/pages/overview.ts | 92 + apps/explorer-web/src/pages/receipts.js | 72 + apps/explorer-web/src/pages/receipts.ts | 76 + apps/explorer-web/src/pages/transactions.js | 72 + apps/explorer-web/src/pages/transactions.ts | 68 + apps/explorer-web/tsconfig.json | 14 + apps/explorer-web/vite.config.ts | 7 + apps/marketplace-web/README.md | 15 + apps/miner-node/README.md | 27 + apps/miner-node/pyproject.toml | 30 + apps/miner-node/src/aitbc_miner/__init__.py | 1 + .../src/aitbc_miner/agent/__init__.py | 1 + .../src/aitbc_miner/agent/control.py | 127 ++ apps/miner-node/src/aitbc_miner/config.py | 40 + .../miner-node/src/aitbc_miner/coordinator.py | 76 + apps/miner-node/src/aitbc_miner/logging.py | 25 + apps/miner-node/src/aitbc_miner/main.py | 51 + .../src/aitbc_miner/runners/__init__.py | 18 + .../src/aitbc_miner/runners/base.py | 17 + .../src/aitbc_miner/runners/cli/simple.py | 62 + .../src/aitbc_miner/runners/python/noop.py | 20 + .../src/aitbc_miner/util/backoff.py | 19 + apps/miner-node/src/aitbc_miner/util/fs.py | 15 + apps/miner-node/src/aitbc_miner/util/probe.py | 91 + apps/miner-node/tests/test_runners.py | 37 + apps/pool-hub/README.md | 11 + apps/wallet-daemon/README.md | 32 + apps/wallet-daemon/src/app/__init__.py | 5 + apps/wallet-daemon/src/app/api_jsonrpc.py | 49 + apps/wallet-daemon/src/app/api_rest.py | 49 + .../src/app/crypto/encryption.py | 62 + apps/wallet-daemon/src/app/deps.py | 26 + .../wallet-daemon/src/app/keystore/service.py | 51 + apps/wallet-daemon/src/app/main.py | 17 + apps/wallet-daemon/src/app/models/__init__.py | 45 + .../src/app/receipts/__init__.py | 5 + .../wallet-daemon/src/app/receipts/service.py | 58 + apps/wallet-daemon/src/app/settings.py | 23 + apps/wallet-daemon/tests/test_receipts.py | 81 + configs/systemd/aitbc-miner.service | 25 + docs/blockchain_node.md | 41 + docs/bootstrap/aitbc_tech_plan.md | 600 ++++++ docs/bootstrap/blockchain_node.md | 392 ++++ docs/bootstrap/coordinator_api.md | 438 +++++ docs/bootstrap/dirs.md | 133 ++ docs/bootstrap/examples.md | 235 +++ docs/bootstrap/explorer_web.md | 322 ++++ docs/bootstrap/layout.md | 468 +++++ docs/bootstrap/marketplace_web.md | 237 +++ docs/bootstrap/miner.md | 423 +++++ docs/bootstrap/miner_node.md | 412 ++++ docs/bootstrap/pool_hub.md | 314 ++++ docs/bootstrap/wallet_daemon.md | 335 ++++ docs/coordinator_api.md | 47 + docs/done.md | 48 + docs/explorer_web.md | 36 + docs/marketplace_web.md | 42 + docs/miner.md | 34 + docs/miner_node.md | 46 + docs/pool_hub.md | 44 + docs/roadmap.md | 115 ++ docs/run.md | 193 ++ docs/wallet_daemon.md | 39 + examples/receipts-sign-verify/README.md | 39 + .../receipts-sign-verify/fetch_and_verify.py | 78 + packages/py/aitbc-crypto/pyproject.toml | 13 + packages/py/aitbc-crypto/src/__init__.py | 4 + .../aitbc-crypto/src/aitbc_crypto/__init__.py | 11 + .../aitbc-crypto/src/aitbc_crypto/receipt.py | 23 + .../aitbc-crypto/src/aitbc_crypto/signing.py | 51 + packages/py/aitbc-crypto/src/receipt.py | 47 + packages/py/aitbc-crypto/src/signing.py | 40 + .../tests/test_receipt_signing.py | 45 + packages/py/aitbc-sdk/pyproject.toml | 14 + .../py/aitbc-sdk/src/aitbc_sdk/__init__.py | 17 + .../py/aitbc-sdk/src/aitbc_sdk/receipts.py | 95 + packages/py/aitbc-sdk/tests/test_receipts.py | 141 ++ .../samples/sample_receipt_signed.json | 28 + protocols/receipts/spec.md | 95 + pyproject.toml | 7 + scripts/ci/run_python_tests.sh | 30 + scripts/ops/install_miner_systemd.sh | 33 + windsurf/README.md | 3 + windsurf/settings.json | 5 + 171 files changed, 13708 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/python-tests.yml create mode 100644 .gitignore create mode 100644 .windsurf/workflows/docs.md create mode 100644 .windsurf/workflows/ns.md create mode 100644 .windsurf/workflows/roadmap.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apps/blockchain-node/README.md create mode 100644 apps/blockchain-node/alembic.ini create mode 100644 apps/blockchain-node/data/chain.db create mode 100644 apps/blockchain-node/migrations/README create mode 100644 apps/blockchain-node/migrations/env.py create mode 100644 apps/blockchain-node/migrations/script.py.mako create mode 100644 apps/blockchain-node/migrations/versions/80bc0020bde2_add_block_relationships.py create mode 100644 apps/blockchain-node/migrations/versions/e31f486f1484_baseline.py create mode 100644 apps/blockchain-node/poetry.lock create mode 100644 apps/blockchain-node/pyproject.toml create mode 100644 apps/blockchain-node/scripts/devnet_up.sh create mode 100644 apps/blockchain-node/scripts/keygen.py create mode 100644 apps/blockchain-node/scripts/make_genesis.py create mode 100644 apps/blockchain-node/scripts/mock_coordinator.py create mode 100644 apps/blockchain-node/src/aitbc_chain/__init__.py create mode 100644 apps/blockchain-node/src/aitbc_chain/app.py create mode 100644 apps/blockchain-node/src/aitbc_chain/config.py create mode 100644 apps/blockchain-node/src/aitbc_chain/consensus/__init__.py create mode 100644 apps/blockchain-node/src/aitbc_chain/consensus/poa.py create mode 100644 apps/blockchain-node/src/aitbc_chain/database.py create mode 100644 apps/blockchain-node/src/aitbc_chain/logging.py create mode 100644 apps/blockchain-node/src/aitbc_chain/main.py create mode 100644 apps/blockchain-node/src/aitbc_chain/mempool.py create mode 100644 apps/blockchain-node/src/aitbc_chain/metrics.py create mode 100644 apps/blockchain-node/src/aitbc_chain/models.py create mode 100644 apps/blockchain-node/src/aitbc_chain/rpc/router.py create mode 100644 apps/client-web/README.md create mode 100644 apps/coordinator-api/README.md create mode 100644 apps/coordinator-api/pyproject.toml create mode 100644 apps/coordinator-api/src/app/__init__.py create mode 100644 apps/coordinator-api/src/app/config.py create mode 100644 apps/coordinator-api/src/app/deps.py create mode 100644 apps/coordinator-api/src/app/domain/__init__.py create mode 100644 apps/coordinator-api/src/app/domain/job.py create mode 100644 apps/coordinator-api/src/app/domain/job_receipt.py create mode 100644 apps/coordinator-api/src/app/domain/miner.py create mode 100644 apps/coordinator-api/src/app/main.py create mode 100644 apps/coordinator-api/src/app/models.py create mode 100644 apps/coordinator-api/src/app/routers/__init__.py create mode 100644 apps/coordinator-api/src/app/routers/admin.py create mode 100644 apps/coordinator-api/src/app/routers/client.py create mode 100644 apps/coordinator-api/src/app/routers/miner.py create mode 100644 apps/coordinator-api/src/app/services/__init__.py create mode 100644 apps/coordinator-api/src/app/services/jobs.py create mode 100644 apps/coordinator-api/src/app/services/miners.py create mode 100644 apps/coordinator-api/src/app/services/receipts.py create mode 100644 apps/coordinator-api/src/app/storage/__init__.py create mode 100644 apps/coordinator-api/src/app/storage/db.py create mode 100644 apps/coordinator-api/tests/test_client_receipts.py create mode 100644 apps/coordinator-api/tests/test_jobs.py create mode 100644 apps/coordinator-api/tests/test_miner_service.py create mode 100644 apps/explorer-web/README.md create mode 100644 apps/explorer-web/package.json create mode 100644 apps/explorer-web/public/css/base.css create mode 100644 apps/explorer-web/public/css/layout.css create mode 100644 apps/explorer-web/public/css/theme.css create mode 100644 apps/explorer-web/public/mock/addresses.json create mode 100644 apps/explorer-web/public/mock/blocks.json create mode 100644 apps/explorer-web/public/mock/receipts.json create mode 100644 apps/explorer-web/public/mock/transactions.json create mode 100644 apps/explorer-web/src/components/dataModeToggle.js create mode 100644 apps/explorer-web/src/components/dataModeToggle.ts create mode 100644 apps/explorer-web/src/components/siteFooter.js create mode 100644 apps/explorer-web/src/components/siteFooter.ts create mode 100644 apps/explorer-web/src/components/siteHeader.js create mode 100644 apps/explorer-web/src/components/siteHeader.ts create mode 100644 apps/explorer-web/src/config.js create mode 100644 apps/explorer-web/src/config.ts create mode 100644 apps/explorer-web/src/lib/mockData.js create mode 100644 apps/explorer-web/src/lib/mockData.ts create mode 100644 apps/explorer-web/src/lib/models.js create mode 100644 apps/explorer-web/src/lib/models.ts create mode 100644 apps/explorer-web/src/main.js create mode 100644 apps/explorer-web/src/main.ts create mode 100644 apps/explorer-web/src/pages/addresses.js create mode 100644 apps/explorer-web/src/pages/addresses.ts create mode 100644 apps/explorer-web/src/pages/blocks.js create mode 100644 apps/explorer-web/src/pages/blocks.ts create mode 100644 apps/explorer-web/src/pages/overview.js create mode 100644 apps/explorer-web/src/pages/overview.ts create mode 100644 apps/explorer-web/src/pages/receipts.js create mode 100644 apps/explorer-web/src/pages/receipts.ts create mode 100644 apps/explorer-web/src/pages/transactions.js create mode 100644 apps/explorer-web/src/pages/transactions.ts create mode 100644 apps/explorer-web/tsconfig.json create mode 100644 apps/explorer-web/vite.config.ts create mode 100644 apps/marketplace-web/README.md create mode 100644 apps/miner-node/README.md create mode 100644 apps/miner-node/pyproject.toml create mode 100644 apps/miner-node/src/aitbc_miner/__init__.py create mode 100644 apps/miner-node/src/aitbc_miner/agent/__init__.py create mode 100644 apps/miner-node/src/aitbc_miner/agent/control.py create mode 100644 apps/miner-node/src/aitbc_miner/config.py create mode 100644 apps/miner-node/src/aitbc_miner/coordinator.py create mode 100644 apps/miner-node/src/aitbc_miner/logging.py create mode 100644 apps/miner-node/src/aitbc_miner/main.py create mode 100644 apps/miner-node/src/aitbc_miner/runners/__init__.py create mode 100644 apps/miner-node/src/aitbc_miner/runners/base.py create mode 100644 apps/miner-node/src/aitbc_miner/runners/cli/simple.py create mode 100644 apps/miner-node/src/aitbc_miner/runners/python/noop.py create mode 100644 apps/miner-node/src/aitbc_miner/util/backoff.py create mode 100644 apps/miner-node/src/aitbc_miner/util/fs.py create mode 100644 apps/miner-node/src/aitbc_miner/util/probe.py create mode 100644 apps/miner-node/tests/test_runners.py create mode 100644 apps/pool-hub/README.md create mode 100644 apps/wallet-daemon/README.md create mode 100644 apps/wallet-daemon/src/app/__init__.py create mode 100644 apps/wallet-daemon/src/app/api_jsonrpc.py create mode 100644 apps/wallet-daemon/src/app/api_rest.py create mode 100644 apps/wallet-daemon/src/app/crypto/encryption.py create mode 100644 apps/wallet-daemon/src/app/deps.py create mode 100644 apps/wallet-daemon/src/app/keystore/service.py create mode 100644 apps/wallet-daemon/src/app/main.py create mode 100644 apps/wallet-daemon/src/app/models/__init__.py create mode 100644 apps/wallet-daemon/src/app/receipts/__init__.py create mode 100644 apps/wallet-daemon/src/app/receipts/service.py create mode 100644 apps/wallet-daemon/src/app/settings.py create mode 100644 apps/wallet-daemon/tests/test_receipts.py create mode 100644 configs/systemd/aitbc-miner.service create mode 100644 docs/blockchain_node.md create mode 100644 docs/bootstrap/aitbc_tech_plan.md create mode 100644 docs/bootstrap/blockchain_node.md create mode 100644 docs/bootstrap/coordinator_api.md create mode 100644 docs/bootstrap/dirs.md create mode 100644 docs/bootstrap/examples.md create mode 100644 docs/bootstrap/explorer_web.md create mode 100644 docs/bootstrap/layout.md create mode 100644 docs/bootstrap/marketplace_web.md create mode 100644 docs/bootstrap/miner.md create mode 100644 docs/bootstrap/miner_node.md create mode 100644 docs/bootstrap/pool_hub.md create mode 100644 docs/bootstrap/wallet_daemon.md create mode 100644 docs/coordinator_api.md create mode 100644 docs/done.md create mode 100644 docs/explorer_web.md create mode 100644 docs/marketplace_web.md create mode 100644 docs/miner.md create mode 100644 docs/miner_node.md create mode 100644 docs/pool_hub.md create mode 100644 docs/roadmap.md create mode 100644 docs/run.md create mode 100644 docs/wallet_daemon.md create mode 100644 examples/receipts-sign-verify/README.md create mode 100644 examples/receipts-sign-verify/fetch_and_verify.py create mode 100644 packages/py/aitbc-crypto/pyproject.toml create mode 100644 packages/py/aitbc-crypto/src/__init__.py create mode 100644 packages/py/aitbc-crypto/src/aitbc_crypto/__init__.py create mode 100644 packages/py/aitbc-crypto/src/aitbc_crypto/receipt.py create mode 100644 packages/py/aitbc-crypto/src/aitbc_crypto/signing.py create mode 100644 packages/py/aitbc-crypto/src/receipt.py create mode 100644 packages/py/aitbc-crypto/src/signing.py create mode 100644 packages/py/aitbc-crypto/tests/test_receipt_signing.py create mode 100644 packages/py/aitbc-sdk/pyproject.toml create mode 100644 packages/py/aitbc-sdk/src/aitbc_sdk/__init__.py create mode 100644 packages/py/aitbc-sdk/src/aitbc_sdk/receipts.py create mode 100644 packages/py/aitbc-sdk/tests/test_receipts.py create mode 100644 protocols/receipts/samples/sample_receipt_signed.json create mode 100644 protocols/receipts/spec.md create mode 100644 pyproject.toml create mode 100755 scripts/ci/run_python_tests.sh create mode 100755 scripts/ops/install_miner_systemd.sh create mode 100644 windsurf/README.md create mode 100644 windsurf/settings.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6670ff2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration for AITBC monorepo +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,js,ts,tsx,json,yaml,yml,md}] +indent_size = 2 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..06cc430 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,34 @@ +name: Python Project Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: '1.7.1' + + - name: Install dependencies + run: | + poetry install --with dev + + - name: Run Python test suites + run: | + chmod +x scripts/ci/run_python_tests.sh + ./scripts/ci/run_python_tests.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9faf6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# AITBC Monorepo ignore rules + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +*.so +.venv/ +venv/ +.env +.env.* + +# Node / JS +node_modules/ +dist/ +build/ +.npm/ +yarn.lock +package-lock.json +pnpm-lock.yaml + +# Editor +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/.windsurf/workflows/docs.md b/.windsurf/workflows/docs.md new file mode 100644 index 0000000..9e52c50 --- /dev/null +++ b/.windsurf/workflows/docs.md @@ -0,0 +1,8 @@ +--- +description: docs/done.md docs/roadmap.md +auto_execution_mode: 3 +--- + +update docs/done.md docs/roadmap.md first +and after all others in docs/ +but not in docs/bootstrap/ \ No newline at end of file diff --git a/.windsurf/workflows/ns.md b/.windsurf/workflows/ns.md new file mode 100644 index 0000000..ce44a72 --- /dev/null +++ b/.windsurf/workflows/ns.md @@ -0,0 +1,10 @@ +--- +description: Identify the most important first step and do it. +auto_execution_mode: 3 +--- + +Identify the most important first step and do it. +if +No tasks are currently in progress. +then +Check docs/roadmap.md and carry out the next recommended step. \ No newline at end of file diff --git a/.windsurf/workflows/roadmap.md b/.windsurf/workflows/roadmap.md new file mode 100644 index 0000000..8f78428 --- /dev/null +++ b/.windsurf/workflows/roadmap.md @@ -0,0 +1,6 @@ +--- +description: docs/roadmap.md +auto_execution_mode: 3 +--- + +Check docs/roadmap.md and carry out the next recommended step. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d3f7fae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 AITBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6c6527 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# AITBC Monorepo + +This repository houses all components of the Artificial Intelligence Token Blockchain (AITBC) stack, including coordinator services, blockchain node, miner daemon, client-facing web apps, SDKs, and documentation. + +## Repository Layout + +Refer to `docs/bootstrap/dirs.md` for the authoritative directory breakdown and follow-up implementation tasks. + +## Getting Started + +1. Review the bootstrap documents under `docs/bootstrap/` to understand stage-specific goals. +2. Fill in service-specific READMEs located under `apps/` and `packages/` as the implementations progress. +3. Use the provided directory scaffold as the starting point for coding each subsystem. +4. Explore the new Python receipt SDK under `packages/py/aitbc-sdk/` for helpers to fetch and verify coordinator receipts (see `docs/run.md` for examples). +5. Run `scripts/ci/run_python_tests.sh` (via Poetry) to execute coordinator, SDK, miner-node, and wallet-daemon test suites before submitting changes. +6. GitHub Actions (`.github/workflows/python-tests.yml`) automatically runs the same script on pushes and pull requests targeting `main`. diff --git a/apps/blockchain-node/README.md b/apps/blockchain-node/README.md new file mode 100644 index 0000000..fac8e04 --- /dev/null +++ b/apps/blockchain-node/README.md @@ -0,0 +1,25 @@ +# Blockchain Node + +## Purpose & Scope + +Minimal asset-backed blockchain node that validates compute receipts and mints AIT tokens as described in `docs/bootstrap/blockchain_node.md`. + +## Status + +Scaffolded. Implementation pending per staged roadmap. + +## Devnet Tooling + +- `scripts/make_genesis.py` — Generate a deterministic devnet genesis file (`data/devnet/genesis.json`). +- `scripts/keygen.py` — Produce throwaway devnet keypairs (printed or written to disk). +- `scripts/devnet_up.sh` — Launch the blockchain node and RPC API with a freshly generated genesis file. + +### Quickstart + +```bash +cd apps/blockchain-node +python scripts/make_genesis.py --force +bash scripts/devnet_up.sh +``` + +The script sets `PYTHONPATH=src` and starts the proposer loop plus the FastAPI app (via `uvicorn`). Press `Ctrl+C` to stop the devnet. diff --git a/apps/blockchain-node/alembic.ini b/apps/blockchain-node/alembic.ini new file mode 100644 index 0000000..035f57b --- /dev/null +++ b/apps/blockchain-node/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/blockchain-node/data/chain.db b/apps/blockchain-node/data/chain.db new file mode 100644 index 0000000000000000000000000000000000000000..eeb1f9c86cdeaf69fba57e10860d187785b905b7 GIT binary patch literal 73728 zcmeI)T~p&!7{KvncWEi_pleiIJ+ll08GP9aqw6@nRJOWkw@4|gJ6>c$8z8&1DQT*x z7Y?|0ehI&Wegtp!)*HR?JNOYiNmDo}4a23A`F9rDCMV}P=l480X{N(&Z@56P2BE`E@at z-?a{{R^1jGm0IbuQdQh5eOlZp*TmADrC7U`dzDt*cEj@RhAFQy_vBD&8xKmcUcRhu zJSbKli7!fz#Bww&W6U@)W^($X?oeoIX6|8vJ1BC8kY6KCA3L9-AQ+>D&WXfTnzS9AJ%^V-QsDB~QP zo?W+{wjVsZUL&lMXwy`b(v8Z!($`{mQ?XeQQFF1ZbYVERa{Bu+PJS@X?x99vaW-P>fGmR}B+kf&~Vu0;`}r?IHN-VzdR49+Pz z&i)Fat{zdCVGGOoRQe}j>;zO@`&|d?QQP|d(6t)k^KF@LHB{4S*`C>P_FIaMIRNA7h!x&HWe$G3dBdUP(flwF03R5MsS)!=f>{mQ(;`sFm_a?pJ#r+>7do!so_ z$@i>Q$EpXR&AMkt%b~J0KbSLRpK?TyyW7gH+hX~=JLAU}bNadrduv!&)mG_Y^s>O} zF+)PDyN!-6&ukf79~Rs{HV>?hJmbz7a#7sDb3tihMn?^a9PKeqXI{wZ3k%xM`(fQG z2P-eLvHBh0ORVC4@L^@FPFyXOjxE=JZ)j=DDstM6ElX=yhlvG#p53d~-d8uXdO_E^ z{WSE(>O0M#_M7eS?Z{x#2O0(9;U=+scA&BOd{$r5wca&NF2}uNg0zOOZX_i1yqVH# zaFnt0TvlJvweR91?JdpWQP(f;GF-#ePQwkx>Ec{Y*LCfw5!OW5Q~2vztPa9Wm#kvF zarCnrTXllbC#2Dn84gQ*_CZ7UL;wK<5I_I{1Q0*~0R#|0 zfWQBz1`t310R#|0009ILKmY**5JEVKmY** a5I_I{1Q0*~0R#|0AQG6qzL;0<|NjFNO)8)O literal 0 HcmV?d00001 diff --git a/apps/blockchain-node/migrations/README b/apps/blockchain-node/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/apps/blockchain-node/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/apps/blockchain-node/migrations/env.py b/apps/blockchain-node/migrations/env.py new file mode 100644 index 0000000..ad699af --- /dev/null +++ b/apps/blockchain-node/migrations/env.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from sqlmodel import SQLModel + +from alembic import context + +from aitbc_chain.config import settings +from aitbc_chain import models # noqa: F401 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Ensure the database path exists and propagate URL to Alembic config +settings.db_path.parent.mkdir(parents=True, exist_ok=True) +config.set_main_option("sqlalchemy.url", f"sqlite:///{settings.db_path}") + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Use SQLModel metadata for autogeneration. +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/blockchain-node/migrations/script.py.mako b/apps/blockchain-node/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/apps/blockchain-node/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/apps/blockchain-node/migrations/versions/80bc0020bde2_add_block_relationships.py b/apps/blockchain-node/migrations/versions/80bc0020bde2_add_block_relationships.py new file mode 100644 index 0000000..69797d8 --- /dev/null +++ b/apps/blockchain-node/migrations/versions/80bc0020bde2_add_block_relationships.py @@ -0,0 +1,34 @@ +"""add block relationships + +Revision ID: 80bc0020bde2 +Revises: e31f486f1484 +Create Date: 2025-09-27 06:02:11.656859 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '80bc0020bde2' +down_revision: Union[str, Sequence[str], None] = 'e31f486f1484' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_foreign_key(None, 'receipt', 'block', ['block_height'], ['height']) + op.create_foreign_key(None, 'transaction', 'block', ['block_height'], ['height']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'transaction', type_='foreignkey') + op.drop_constraint(None, 'receipt', type_='foreignkey') + # ### end Alembic commands ### diff --git a/apps/blockchain-node/migrations/versions/e31f486f1484_baseline.py b/apps/blockchain-node/migrations/versions/e31f486f1484_baseline.py new file mode 100644 index 0000000..5f835d0 --- /dev/null +++ b/apps/blockchain-node/migrations/versions/e31f486f1484_baseline.py @@ -0,0 +1,103 @@ +"""baseline + +Revision ID: e31f486f1484 +Revises: +Create Date: 2025-09-27 05:58:27.490151 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "e31f486f1484" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + + op.create_table( + "block", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("height", sa.Integer(), nullable=False), + sa.Column("hash", sa.String(), nullable=False), + sa.Column("parent_hash", sa.String(), nullable=False), + sa.Column("proposer", sa.String(), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("tx_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("state_root", sa.String(), nullable=True), + ) + op.create_index("ix_block_height", "block", ["height"], unique=True) + op.create_index("ix_block_hash", "block", ["hash"], unique=True) + op.create_index("ix_block_timestamp", "block", ["timestamp"], unique=False) + + op.create_table( + "transaction", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("tx_hash", sa.String(), nullable=False), + sa.Column("block_height", sa.Integer(), nullable=True), + sa.Column("sender", sa.String(), nullable=False), + sa.Column("recipient", sa.String(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_transaction_tx_hash", "transaction", ["tx_hash"], unique=True) + op.create_index( + "ix_transaction_block_height", "transaction", ["block_height"], unique=False + ) + op.create_index( + "ix_transaction_created_at", "transaction", ["created_at"], unique=False + ) + + op.create_table( + "receipt", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("job_id", sa.String(), nullable=False), + sa.Column("receipt_id", sa.String(), nullable=False), + sa.Column("block_height", sa.Integer(), nullable=True), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("miner_signature", sa.JSON(), nullable=False), + sa.Column("coordinator_attestations", sa.JSON(), nullable=False), + sa.Column("minted_amount", sa.Integer(), nullable=True), + sa.Column("recorded_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_receipt_job_id", "receipt", ["job_id"], unique=False) + op.create_index("ix_receipt_receipt_id", "receipt", ["receipt_id"], unique=True) + op.create_index("ix_receipt_block_height", "receipt", ["block_height"], unique=False) + op.create_index("ix_receipt_recorded_at", "receipt", ["recorded_at"], unique=False) + + op.create_table( + "account", + sa.Column("address", sa.String(), nullable=False), + sa.Column("balance", sa.Integer(), nullable=False, server_default="0"), + sa.Column("nonce", sa.Integer(), nullable=False, server_default="0"), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("address"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + + op.drop_table("account") + + op.drop_index("ix_receipt_recorded_at", table_name="receipt") + op.drop_index("ix_receipt_block_height", table_name="receipt") + op.drop_index("ix_receipt_receipt_id", table_name="receipt") + op.drop_index("ix_receipt_job_id", table_name="receipt") + op.drop_table("receipt") + + op.drop_index("ix_transaction_created_at", table_name="transaction") + op.drop_index("ix_transaction_block_height", table_name="transaction") + op.drop_index("ix_transaction_tx_hash", table_name="transaction") + op.drop_table("transaction") + + op.drop_index("ix_block_timestamp", table_name="block") + op.drop_index("ix_block_hash", table_name="block") + op.drop_index("ix_block_height", table_name="block") + op.drop_table("block") diff --git a/apps/blockchain-node/poetry.lock b/apps/blockchain-node/poetry.lock new file mode 100644 index 0000000..3a8fcea --- /dev/null +++ b/apps/blockchain-node/poetry.lock @@ -0,0 +1,1673 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "aiosqlite" +version = "0.20.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"}, + {file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"}, +] + +[package.dependencies] +typing_extensions = ">=4.0" + +[package.extras] +dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"] + +[[package]] +name = "alembic" +version = "1.16.5" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3"}, + {file = "alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.4.0" +typing-extensions = ">=4.12" + +[package.extras] +tz = ["tzdata"] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.11.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0)"] + +[[package]] +name = "certifi" +version = "2025.8.3" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, + {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "click" +version = "8.3.0" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, + {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "cryptography" +version = "42.0.8" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, + {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, + {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, + {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, + {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, + {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, + {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "dnspython" +version = "2.8.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, +] + +[package.extras] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] + +[[package]] +name = "email-validator" +version = "2.3.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + +[[package]] +name = "fastapi" +version = "0.111.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.111.1-py3-none-any.whl", hash = "sha256:4f51cfa25d72f9fbc3280832e84b32494cf186f50158d364a8765aabf22587bf"}, + {file = "fastapi-0.111.1.tar.gz", hash = "sha256:ddd1ac34cb1f76c2e2d7f8545a4bcb5463bce4834e81abf0b189e0c359ab2413"}, +] + +[package.dependencies] +email_validator = ">=2.0.0" +fastapi-cli = ">=0.0.2" +httpx = ">=0.23.0" +jinja2 = ">=2.11.2" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +python-multipart = ">=0.0.7" +starlette = ">=0.37.2,<0.38.0" +typing-extensions = ">=4.8.0" +uvicorn = {version = ">=0.12.0", extras = ["standard"]} + +[package.extras] +all = ["email_validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "fastapi-cli" +version = "0.0.13" +description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi_cli-0.0.13-py3-none-any.whl", hash = "sha256:219b73ccfde7622559cef1d43197da928516acb4f21f2ec69128c4b90057baba"}, + {file = "fastapi_cli-0.0.13.tar.gz", hash = "sha256:312addf3f57ba7139457cf0d345c03e2170cc5a034057488259c33cd7e494529"}, +] + +[package.dependencies] +rich-toolkit = ">=0.14.8" +typer = ">=0.15.1" +uvicorn = {version = ">=0.15.0", extras = ["standard"]} + +[package.extras] +standard = ["fastapi-cloud-cli (>=0.1.1)", "uvicorn[standard] (>=0.15.0)"] +standard-no-fastapi-cloud-cli = ["uvicorn[standard] (>=0.15.0)"] + +[[package]] +name = "greenlet" +version = "3.2.4" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" +files = [ + {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, + {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, + {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, + {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, + {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, + {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, + {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, + {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httptools" +version = "0.6.4" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0"}, + {file = "httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da"}, + {file = "httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1"}, + {file = "httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50"}, + {file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959"}, + {file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4"}, + {file = "httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c"}, + {file = "httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069"}, + {file = "httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a"}, + {file = "httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975"}, + {file = "httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636"}, + {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721"}, + {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988"}, + {file = "httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17"}, + {file = "httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2"}, + {file = "httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44"}, + {file = "httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1"}, + {file = "httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2"}, + {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81"}, + {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f"}, + {file = "httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970"}, + {file = "httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660"}, + {file = "httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083"}, + {file = "httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3"}, + {file = "httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071"}, + {file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5"}, + {file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0"}, + {file = "httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8"}, + {file = "httptools-0.6.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d3f0d369e7ffbe59c4b6116a44d6a8eb4783aae027f2c0b366cf0aa964185dba"}, + {file = "httptools-0.6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:94978a49b8f4569ad607cd4946b759d90b285e39c0d4640c6b36ca7a3ddf2efc"}, + {file = "httptools-0.6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40dc6a8e399e15ea525305a2ddba998b0af5caa2566bcd79dcbe8948181eeaff"}, + {file = "httptools-0.6.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab9ba8dcf59de5181f6be44a77458e45a578fc99c31510b8c65b7d5acc3cf490"}, + {file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fc411e1c0a7dcd2f902c7c48cf079947a7e65b5485dea9decb82b9105ca71a43"}, + {file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:d54efd20338ac52ba31e7da78e4a72570cf729fac82bc31ff9199bedf1dc7440"}, + {file = "httptools-0.6.4-cp38-cp38-win_amd64.whl", hash = "sha256:df959752a0c2748a65ab5387d08287abf6779ae9165916fe053e68ae1fbdc47f"}, + {file = "httptools-0.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85797e37e8eeaa5439d33e556662cc370e474445d5fab24dcadc65a8ffb04003"}, + {file = "httptools-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db353d22843cf1028f43c3651581e4bb49374d85692a85f95f7b9a130e1b2cab"}, + {file = "httptools-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ffd262a73d7c28424252381a5b854c19d9de5f56f075445d33919a637e3547"}, + {file = "httptools-0.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c346571fa50d2e9856a37d7cd9435a25e7fd15e236c397bf224afaa355fe9"}, + {file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aafe0f1918ed07b67c1e838f950b1c1fabc683030477e60b335649b8020e1076"}, + {file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0e563e54979e97b6d13f1bbc05a96109923e76b901f786a5eae36e99c01237bd"}, + {file = "httptools-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b799de31416ecc589ad79dd85a0b2657a8fe39327944998dea368c1d4c9e55e6"}, + {file = "httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c"}, +] + +[package.extras] +test = ["Cython (>=0.29.24)"] + +[[package]] +name = "httpx" +version = "0.27.2" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "mako" +version = "1.3.10" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, + {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "orjson" +version = "3.11.3" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "orjson-3.11.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:29cb1f1b008d936803e2da3d7cba726fc47232c45df531b29edf0b232dd737e7"}, + {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97dceed87ed9139884a55db8722428e27bd8452817fbf1869c58b49fecab1120"}, + {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58533f9e8266cb0ac298e259ed7b4d42ed3fa0b78ce76860626164de49e0d467"}, + {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c212cfdd90512fe722fa9bd620de4d46cda691415be86b2e02243242ae81873"}, + {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff835b5d3e67d9207343effb03760c00335f8b5285bfceefd4dc967b0e48f6a"}, + {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5aa4682912a450c2db89cbd92d356fef47e115dffba07992555542f344d301b"}, + {file = "orjson-3.11.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d18dd34ea2e860553a579df02041845dee0af8985dff7f8661306f95504ddf"}, + {file = "orjson-3.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8b11701bc43be92ea42bd454910437b355dfb63696c06fe953ffb40b5f763b4"}, + {file = "orjson-3.11.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:90368277087d4af32d38bd55f9da2ff466d25325bf6167c8f382d8ee40cb2bbc"}, + {file = "orjson-3.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd7ff459fb393358d3a155d25b275c60b07a2c83dcd7ea962b1923f5a1134569"}, + {file = "orjson-3.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8d902867b699bcd09c176a280b1acdab57f924489033e53d0afe79817da37e6"}, + {file = "orjson-3.11.3-cp310-cp310-win32.whl", hash = "sha256:bb93562146120bb51e6b154962d3dadc678ed0fce96513fa6bc06599bb6f6edc"}, + {file = "orjson-3.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:976c6f1975032cc327161c65d4194c549f2589d88b105a5e3499429a54479770"}, + {file = "orjson-3.11.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d2ae0cc6aeb669633e0124531f342a17d8e97ea999e42f12a5ad4adaa304c5f"}, + {file = "orjson-3.11.3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ba21dbb2493e9c653eaffdc38819b004b7b1b246fb77bfc93dc016fe664eac91"}, + {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00f1a271e56d511d1569937c0447d7dce5a99a33ea0dec76673706360a051904"}, + {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b67e71e47caa6680d1b6f075a396d04fa6ca8ca09aafb428731da9b3ea32a5a6"}, + {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7d012ebddffcce8c85734a6d9e5f08180cd3857c5f5a3ac70185b43775d043d"}, + {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd759f75d6b8d1b62012b7f5ef9461d03c804f94d539a5515b454ba3a6588038"}, + {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6890ace0809627b0dff19cfad92d69d0fa3f089d3e359a2a532507bb6ba34efb"}, + {file = "orjson-3.11.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9d4a5e041ae435b815e568537755773d05dac031fee6a57b4ba70897a44d9d2"}, + {file = "orjson-3.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d68bf97a771836687107abfca089743885fb664b90138d8761cce61d5625d55"}, + {file = "orjson-3.11.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bfc27516ec46f4520b18ef645864cee168d2a027dbf32c5537cb1f3e3c22dac1"}, + {file = "orjson-3.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f66b001332a017d7945e177e282a40b6997056394e3ed7ddb41fb1813b83e824"}, + {file = "orjson-3.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:212e67806525d2561efbfe9e799633b17eb668b8964abed6b5319b2f1cfbae1f"}, + {file = "orjson-3.11.3-cp311-cp311-win32.whl", hash = "sha256:6e8e0c3b85575a32f2ffa59de455f85ce002b8bdc0662d6b9c2ed6d80ab5d204"}, + {file = "orjson-3.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:6be2f1b5d3dc99a5ce5ce162fc741c22ba9f3443d3dd586e6a1211b7bc87bc7b"}, + {file = "orjson-3.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:fafb1a99d740523d964b15c8db4eabbfc86ff29f84898262bf6e3e4c9e97e43e"}, + {file = "orjson-3.11.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8c752089db84333e36d754c4baf19c0e1437012242048439c7e80eb0e6426e3b"}, + {file = "orjson-3.11.3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:9b8761b6cf04a856eb544acdd82fc594b978f12ac3602d6374a7edb9d86fd2c2"}, + {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b13974dc8ac6ba22feaa867fc19135a3e01a134b4f7c9c28162fed4d615008a"}, + {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f83abab5bacb76d9c821fd5c07728ff224ed0e52d7a71b7b3de822f3df04e15c"}, + {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6fbaf48a744b94091a56c62897b27c31ee2da93d826aa5b207131a1e13d4064"}, + {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc779b4f4bba2847d0d2940081a7b6f7b5877e05408ffbb74fa1faf4a136c424"}, + {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd4b909ce4c50faa2192da6bb684d9848d4510b736b0611b6ab4020ea6fd2d23"}, + {file = "orjson-3.11.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:524b765ad888dc5518bbce12c77c2e83dee1ed6b0992c1790cc5fb49bb4b6667"}, + {file = "orjson-3.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:84fd82870b97ae3cdcea9d8746e592b6d40e1e4d4527835fc520c588d2ded04f"}, + {file = "orjson-3.11.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fbecb9709111be913ae6879b07bafd4b0785b44c1eb5cac8ac76da048b3885a1"}, + {file = "orjson-3.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9dba358d55aee552bd868de348f4736ca5a4086d9a62e2bfbbeeb5629fe8b0cc"}, + {file = "orjson-3.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eabcf2e84f1d7105f84580e03012270c7e97ecb1fb1618bda395061b2a84a049"}, + {file = "orjson-3.11.3-cp312-cp312-win32.whl", hash = "sha256:3782d2c60b8116772aea8d9b7905221437fdf53e7277282e8d8b07c220f96cca"}, + {file = "orjson-3.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:79b44319268af2eaa3e315b92298de9a0067ade6e6003ddaef72f8e0bedb94f1"}, + {file = "orjson-3.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:0e92a4e83341ef79d835ca21b8bd13e27c859e4e9e4d7b63defc6e58462a3710"}, + {file = "orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810"}, + {file = "orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43"}, + {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27"}, + {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f"}, + {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c"}, + {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be"}, + {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d"}, + {file = "orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2"}, + {file = "orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f"}, + {file = "orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee"}, + {file = "orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e"}, + {file = "orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633"}, + {file = "orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b"}, + {file = "orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae"}, + {file = "orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce"}, + {file = "orjson-3.11.3-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cf4b81227ec86935568c7edd78352a92e97af8da7bd70bdfdaa0d2e0011a1ab4"}, + {file = "orjson-3.11.3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:bc8bc85b81b6ac9fc4dae393a8c159b817f4c2c9dee5d12b773bddb3b95fc07e"}, + {file = "orjson-3.11.3-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:88dcfc514cfd1b0de038443c7b3e6a9797ffb1b3674ef1fd14f701a13397f82d"}, + {file = "orjson-3.11.3-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d61cd543d69715d5fc0a690c7c6f8dcc307bc23abef9738957981885f5f38229"}, + {file = "orjson-3.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2b7b153ed90ababadbef5c3eb39549f9476890d339cf47af563aea7e07db2451"}, + {file = "orjson-3.11.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7909ae2460f5f494fecbcd10613beafe40381fd0316e35d6acb5f3a05bfda167"}, + {file = "orjson-3.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2030c01cbf77bc67bee7eef1e7e31ecf28649353987775e3583062c752da0077"}, + {file = "orjson-3.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0169ebd1cbd94b26c7a7ad282cf5c2744fce054133f959e02eb5265deae1872"}, + {file = "orjson-3.11.3-cp314-cp314-win32.whl", hash = "sha256:0c6d7328c200c349e3a4c6d8c83e0a5ad029bdc2d417f234152bf34842d0fc8d"}, + {file = "orjson-3.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:317bbe2c069bbc757b1a2e4105b64aacd3bc78279b66a6b9e51e846e4809f804"}, + {file = "orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc"}, + {file = "orjson-3.11.3-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:56afaf1e9b02302ba636151cfc49929c1bb66b98794291afd0e5f20fecaf757c"}, + {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913f629adef31d2d350d41c051ce7e33cf0fd06a5d1cb28d49b1899b23b903aa"}, + {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0a23b41f8f98b4e61150a03f83e4f0d566880fe53519d445a962929a4d21045"}, + {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d721fee37380a44f9d9ce6c701b3960239f4fb3d5ceea7f31cbd43882edaa2f"}, + {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b92a5b69f31b1a58c0c7e31080aeaec49c6e01b9522e71ff38d08f15aa56de"}, + {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2489b241c19582b3f1430cc5d732caefc1aaf378d97e7fb95b9e56bed11725f"}, + {file = "orjson-3.11.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5189a5dab8b0312eadaf9d58d3049b6a52c454256493a557405e77a3d67ab7f"}, + {file = "orjson-3.11.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9d8787bdfbb65a85ea76d0e96a3b1bed7bf0fbcb16d40408dc1172ad784a49d2"}, + {file = "orjson-3.11.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:8e531abd745f51f8035e207e75e049553a86823d189a51809c078412cefb399a"}, + {file = "orjson-3.11.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8ab962931015f170b97a3dd7bd933399c1bae8ed8ad0fb2a7151a5654b6941c7"}, + {file = "orjson-3.11.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:124d5ba71fee9c9902c4a7baa9425e663f7f0aecf73d31d54fe3dd357d62c1a7"}, + {file = "orjson-3.11.3-cp39-cp39-win32.whl", hash = "sha256:22724d80ee5a815a44fc76274bb7ba2e7464f5564aacb6ecddaa9970a83e3225"}, + {file = "orjson-3.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:215c595c792a87d4407cb72dd5e0f6ee8e694ceeb7f9102b533c5a9bf2a916bb"}, + {file = "orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + +[[package]] +name = "pydantic" +version = "2.11.9" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"}, + {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, + {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-multipart" +version = "0.0.20" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, + {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "rich" +version = "13.9.4" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rich-toolkit" +version = "0.15.1" +description = "Rich toolkit for building command-line applications" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478"}, + {file = "rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a"}, +] + +[package.dependencies] +click = ">=8.1.7" +rich = ">=13.7.1" +typing-extensions = ">=4.12.2" + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.43" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "SQLAlchemy-2.0.43-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win32.whl", hash = "sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win_amd64.whl", hash = "sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win32.whl", hash = "sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win_amd64.whl", hash = "sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win32.whl", hash = "sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win_amd64.whl", hash = "sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win32.whl", hash = "sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win_amd64.whl", hash = "sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b"}, + {file = "sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc"}, + {file = "sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "sqlmodel" +version = "0.0.16" +description = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness." +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "sqlmodel-0.0.16-py3-none-any.whl", hash = "sha256:b972f5d319580d6c37ecc417881f6ec4d1ad3ed3583d0ac0ed43234a28bf605a"}, + {file = "sqlmodel-0.0.16.tar.gz", hash = "sha256:966656f18a8e9a2d159eb215b07fb0cf5222acfae3362707ca611848a8a06bd1"}, +] + +[package.dependencies] +pydantic = ">=1.10.13,<3.0.0" +SQLAlchemy = ">=2.0.0,<2.1.0" + +[[package]] +name = "starlette" +version = "0.37.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, + {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + +[[package]] +name = "typer" +version = "0.19.2" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9"}, + {file = "typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "uvicorn" +version = "0.30.6" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, + {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.19.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" or extra == \"uvloop\"" +files = [ + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, + {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, +] + +[package.extras] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0) ; python_version >= \"3.12\"", "aiohttp (>=3.8.1) ; python_version < \"3.12\"", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[[package]] +name = "watchfiles" +version = "1.1.0" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"}, + {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9"}, + {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72"}, + {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc"}, + {file = "watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587"}, + {file = "watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82"}, + {file = "watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2"}, + {file = "watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"}, + {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4"}, + {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d"}, + {file = "watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2"}, + {file = "watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12"}, + {file = "watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a"}, + {file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"}, + {file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"}, + {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"}, + {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"}, + {file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"}, + {file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"}, + {file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"}, + {file = "watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30"}, + {file = "watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c"}, + {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b"}, + {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb"}, + {file = "watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9"}, + {file = "watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7"}, + {file = "watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5"}, + {file = "watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1"}, + {file = "watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20"}, + {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef"}, + {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb"}, + {file = "watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297"}, + {file = "watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e"}, + {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b"}, + {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259"}, + {file = "watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f"}, + {file = "watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147"}, + {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8"}, + {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db"}, + {file = "watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa"}, + {file = "watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e"}, + {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86"}, + {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f"}, + {file = "watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267"}, + {file = "watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277"}, + {file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "12.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, + {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, + {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, + {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, + {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, + {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, + {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, + {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, + {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, + {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, + {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, + {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, + {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, + {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, +] + +[extras] +uvloop = ["uvloop"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.11" +content-hash = "33819cf3339aa86ece81c03c9f7dce1b3f32eca66ca8b864af1e6b10c8f5b82d" diff --git a/apps/blockchain-node/pyproject.toml b/apps/blockchain-node/pyproject.toml new file mode 100644 index 0000000..2009e6e --- /dev/null +++ b/apps/blockchain-node/pyproject.toml @@ -0,0 +1,37 @@ +[tool.poetry] +name = "aitbc-blockchain-node" +version = "0.1.0" +description = "AITBC blockchain node service" +authors = ["AITBC Team"] +packages = [ + { include = "aitbc_chain", from = "src" } +] + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.111.0" +uvicorn = { extras = ["standard"], version = "^0.30.0" } +sqlmodel = "^0.0.16" +sqlalchemy = "^2.0.30" +alembic = "^1.13.1" +aiosqlite = "^0.20.0" +websockets = "^12.0" +pydantic = "^2.7.0" +pydantic-settings = "^2.2.1" +orjson = "^3.10.0" +python-dotenv = "^1.0.1" +httpx = "^0.27.0" +uvloop = { version = "^0.19.0", optional = true } +rich = "^13.7.1" +cryptography = "^42.0.5" + +[tool.poetry.extras] +uvloop = ["uvloop"] + +[tool.poetry.group.dev.dependencies] +pytest = "^8.2.0" +pytest-asyncio = "^0.23.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/apps/blockchain-node/scripts/devnet_up.sh b/apps/blockchain-node/scripts/devnet_up.sh new file mode 100644 index 0000000..81ff420 --- /dev/null +++ b/apps/blockchain-node/scripts/devnet_up.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export PYTHONPATH="${ROOT_DIR}/src:${ROOT_DIR}/scripts:${PYTHONPATH:-}" + +GENESIS_PATH="${ROOT_DIR}/data/devnet/genesis.json" +python "${ROOT_DIR}/scripts/make_genesis.py" --output "${GENESIS_PATH}" --force + +echo "[devnet] Generated genesis at ${GENESIS_PATH}" + +declare -a CHILD_PIDS=() +cleanup() { + for pid in "${CHILD_PIDS[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done +} +trap cleanup EXIT + +python -m aitbc_chain.main & +CHILD_PIDS+=($!) +echo "[devnet] Blockchain node started (PID ${CHILD_PIDS[-1]})" + +sleep 1 + +python -m uvicorn aitbc_chain.app:app --host 127.0.0.1 --port 8080 --log-level info & +CHILD_PIDS+=($!) +echo "[devnet] RPC API serving at http://127.0.0.1:8080" + +python -m uvicorn mock_coordinator:app --host 127.0.0.1 --port 8090 --log-level info & +CHILD_PIDS+=($!) +echo "[devnet] Mock coordinator serving at http://127.0.0.1:8090" + +wait diff --git a/apps/blockchain-node/scripts/keygen.py b/apps/blockchain-node/scripts/keygen.py new file mode 100644 index 0000000..230a629 --- /dev/null +++ b/apps/blockchain-node/scripts/keygen.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Generate a pseudo devnet key pair for blockchain components.""" + +from __future__ import annotations + +import argparse +import json +import secrets +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate a devnet key pair") + parser.add_argument( + "--output", + type=Path, + help="Optional path to write the keypair JSON (prints to stdout if omitted)", + ) + return parser.parse_args() + + +def generate_keypair() -> dict: + private_key = secrets.token_hex(32) + public_key = secrets.token_hex(32) + address = "ait1" + secrets.token_hex(20) + return { + "private_key": private_key, + "public_key": public_key, + "address": address, + } + + +def main() -> None: + args = parse_args() + keypair = generate_keypair() + payload = json.dumps(keypair, indent=2) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload + "\n", encoding="utf-8") + print(f"[keygen] wrote keypair to {args.output}") + else: + print(payload) + + +if __name__ == "__main__": + main() diff --git a/apps/blockchain-node/scripts/make_genesis.py b/apps/blockchain-node/scripts/make_genesis.py new file mode 100644 index 0000000..033ea6a --- /dev/null +++ b/apps/blockchain-node/scripts/make_genesis.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Generate a deterministic devnet genesis file for the blockchain node.""" + +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path + +DEFAULT_GENESIS = { + "chain_id": "ait-devnet", + "timestamp": None, # populated at runtime + "params": { + "mint_per_unit": 1000, + "coordinator_ratio": 0.05, + "base_fee": 10, + "fee_per_byte": 1, + }, + "accounts": [ + { + "address": "ait1faucet000000000000000000000000000000000", + "balance": 1_000_000_000, + "nonce": 0, + } + ], + "authorities": [ + { + "address": "ait1devproposer000000000000000000000000000000", + "weight": 1, + } + ], +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate devnet genesis data") + parser.add_argument( + "--output", + type=Path, + default=Path("data/devnet/genesis.json"), + help="Path to write the generated genesis file (default: data/devnet/genesis.json)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite the genesis file if it already exists.", + ) + parser.add_argument( + "--faucet-address", + default="ait1faucet000000000000000000000000000000000", + help="Address seeded with devnet funds.", + ) + parser.add_argument( + "--faucet-balance", + type=int, + default=1_000_000_000, + help="Faucet balance in smallest units.", + ) + parser.add_argument( + "--authorities", + nargs="*", + default=["ait1devproposer000000000000000000000000000000"], + help="Authority addresses included in the genesis file.", + ) + return parser.parse_args() + + +def build_genesis(args: argparse.Namespace) -> dict: + genesis = json.loads(json.dumps(DEFAULT_GENESIS)) # deep copy via JSON + genesis["timestamp"] = int(time.time()) + genesis["accounts"][0]["address"] = args.faucet_address + genesis["accounts"][0]["balance"] = args.faucet_balance + genesis["authorities"] = [ + {"address": address, "weight": 1} + for address in args.authorities + ] + return genesis + + +def write_genesis(path: Path, data: dict, force: bool) -> None: + if path.exists() and not force: + raise SystemExit(f"Genesis file already exists at {path}. Use --force to overwrite.") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"[genesis] wrote genesis file to {path}") + + +def main() -> None: + args = parse_args() + genesis = build_genesis(args) + write_genesis(args.output, genesis, args.force) + + +if __name__ == "__main__": + main() diff --git a/apps/blockchain-node/scripts/mock_coordinator.py b/apps/blockchain-node/scripts/mock_coordinator.py new file mode 100644 index 0000000..2eeb8bd --- /dev/null +++ b/apps/blockchain-node/scripts/mock_coordinator.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Mock coordinator API for devnet testing.""" + +from __future__ import annotations + +from typing import Dict + +from fastapi import FastAPI + +app = FastAPI(title="Mock Coordinator API", version="0.1.0") + +MOCK_JOBS: Dict[str, Dict[str, str]] = { + "job_1": {"status": "complete", "price": "50000", "compute_units": 2500}, + "job_2": {"status": "complete", "price": "25000", "compute_units": 1200}, +} + + +@app.get("/health") +def health() -> Dict[str, str]: + return {"status": "ok"} + + +@app.post("/attest/receipt") +def attest_receipt(payload: Dict[str, str]) -> Dict[str, str | bool]: + job_id = payload.get("job_id") + if job_id in MOCK_JOBS: + return { + "exists": True, + "paid": True, + "not_double_spent": True, + "quote": MOCK_JOBS[job_id], + } + return { + "exists": False, + "paid": False, + "not_double_spent": False, + "quote": {}, + } diff --git a/apps/blockchain-node/src/aitbc_chain/__init__.py b/apps/blockchain-node/src/aitbc_chain/__init__.py new file mode 100644 index 0000000..926cdde --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/__init__.py @@ -0,0 +1,5 @@ +"""AITBC blockchain node package.""" + +from .app import create_app + +__all__ = ["create_app"] diff --git a/apps/blockchain-node/src/aitbc_chain/app.py b/apps/blockchain-node/src/aitbc_chain/app.py new file mode 100644 index 0000000..f7b1732 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/app.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import APIRouter, FastAPI +from fastapi.responses import PlainTextResponse + +from .config import settings +from .database import init_db +from .metrics import metrics_registry +from .rpc.router import router as rpc_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + +def create_app() -> FastAPI: + app = FastAPI(title="AITBC Blockchain Node", version="0.1.0", lifespan=lifespan) + app.include_router(rpc_router, prefix="/rpc", tags=["rpc"]) + + metrics_router = APIRouter() + + @metrics_router.get("/metrics", response_class=PlainTextResponse, tags=["metrics"], summary="Prometheus metrics") + async def metrics() -> str: + return metrics_registry.render_prometheus() + + app.include_router(metrics_router) + + return app + + +app = create_app() diff --git a/apps/blockchain-node/src/aitbc_chain/config.py b/apps/blockchain-node/src/aitbc_chain/config.py new file mode 100644 index 0000000..874c272 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/config.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class ChainSettings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False) + + chain_id: str = "ait-devnet" + db_path: Path = Path("./data/chain.db") + + rpc_bind_host: str = "127.0.0.1" + rpc_bind_port: int = 8080 + + p2p_bind_host: str = "0.0.0.0" + p2p_bind_port: int = 7070 + + proposer_id: str = "ait-devnet-proposer" + proposer_key: Optional[str] = None + + mint_per_unit: int = 1000 + coordinator_ratio: float = 0.05 + + block_time_seconds: int = 2 + + +settings = ChainSettings() diff --git a/apps/blockchain-node/src/aitbc_chain/consensus/__init__.py b/apps/blockchain-node/src/aitbc_chain/consensus/__init__.py new file mode 100644 index 0000000..055b700 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/consensus/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .poa import PoAProposer, ProposerConfig + +__all__ = ["PoAProposer", "ProposerConfig"] diff --git a/apps/blockchain-node/src/aitbc_chain/consensus/poa.py b/apps/blockchain-node/src/aitbc_chain/consensus/poa.py new file mode 100644 index 0000000..56ddad5 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/consensus/poa.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import asyncio +import hashlib +from dataclasses import dataclass +from datetime import datetime +from typing import Callable, ContextManager, Optional + +from sqlmodel import Session, select + +from ..logging import get_logger +from ..metrics import metrics_registry +from ..models import Block + + +@dataclass +class ProposerConfig: + chain_id: str + proposer_id: str + interval_seconds: int + + +class PoAProposer: + def __init__( + self, + *, + config: ProposerConfig, + session_factory: Callable[[], ContextManager[Session]], + ) -> None: + self._config = config + self._session_factory = session_factory + self._logger = get_logger(__name__) + self._stop_event = asyncio.Event() + self._task: Optional[asyncio.Task[None]] = None + + async def start(self) -> None: + if self._task is not None: + return + self._logger.info("Starting PoA proposer loop", extra={"interval": self._config.interval_seconds}) + self._ensure_genesis_block() + self._stop_event.clear() + self._task = asyncio.create_task(self._run_loop(), name="poa-proposer-loop") + + async def stop(self) -> None: + if self._task is None: + return + self._logger.info("Stopping PoA proposer loop") + self._stop_event.set() + await self._task + self._task = None + + async def _run_loop(self) -> None: + while not self._stop_event.is_set(): + await self._wait_until_next_slot() + if self._stop_event.is_set(): + break + try: + self._propose_block() + except Exception as exc: # pragma: no cover - defensive logging + self._logger.exception("Failed to propose block", extra={"error": str(exc)}) + + async def _wait_until_next_slot(self) -> None: + head = self._fetch_chain_head() + if head is None: + return + now = datetime.utcnow() + elapsed = (now - head.timestamp).total_seconds() + sleep_for = max(self._config.interval_seconds - elapsed, 0) + if sleep_for <= 0: + return + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=sleep_for) + except asyncio.TimeoutError: + return + + def _propose_block(self) -> None: + with self._session_factory() as session: + head = session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first() + next_height = 0 + parent_hash = "0x00" + if head is not None: + next_height = head.height + 1 + parent_hash = head.hash + + timestamp = datetime.utcnow() + block_hash = self._compute_block_hash(next_height, parent_hash, timestamp) + + block = Block( + height=next_height, + hash=block_hash, + parent_hash=parent_hash, + proposer=self._config.proposer_id, + timestamp=timestamp, + tx_count=0, + state_root=None, + ) + session.add(block) + session.commit() + + metrics_registry.increment("blocks_proposed_total") + metrics_registry.set_gauge("chain_head_height", float(next_height)) + + self._logger.info( + "Proposed block", + extra={ + "height": next_height, + "hash": block_hash, + "parent_hash": parent_hash, + "timestamp": timestamp.isoformat(), + }, + ) + + def _ensure_genesis_block(self) -> None: + with self._session_factory() as session: + head = session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first() + if head is not None: + return + + timestamp = datetime.utcnow() + genesis_hash = self._compute_block_hash(0, "0x00", timestamp) + genesis = Block( + height=0, + hash=genesis_hash, + parent_hash="0x00", + proposer=self._config.proposer_id, + timestamp=timestamp, + tx_count=0, + state_root=None, + ) + session.add(genesis) + session.commit() + self._logger.info("Created genesis block", extra={"hash": genesis_hash}) + + def _fetch_chain_head(self) -> Optional[Block]: + with self._session_factory() as session: + return session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first() + + def _compute_block_hash(self, height: int, parent_hash: str, timestamp: datetime) -> str: + payload = f"{self._config.chain_id}|{height}|{parent_hash}|{timestamp.isoformat()}".encode() + return "0x" + hashlib.sha256(payload).hexdigest() diff --git a/apps/blockchain-node/src/aitbc_chain/database.py b/apps/blockchain-node/src/aitbc_chain/database.py new file mode 100644 index 0000000..0f4128c --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/database.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from contextlib import contextmanager + +from sqlmodel import Session, SQLModel, create_engine + +from .config import settings + +_engine = create_engine(f"sqlite:///{settings.db_path}", echo=False) + + +def init_db() -> None: + settings.db_path.parent.mkdir(parents=True, exist_ok=True) + SQLModel.metadata.create_all(_engine) + + +@contextmanager +def session_scope() -> Session: + with Session(_engine) as session: + yield session diff --git a/apps/blockchain-node/src/aitbc_chain/logging.py b/apps/blockchain-node/src/aitbc_chain/logging.py new file mode 100644 index 0000000..d2da63a --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/logging.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any, Optional + +import json + + +class JsonFormatter(logging.Formatter): + RESERVED = { + "name", + "msg", + "args", + "levelname", + "levelno", + "pathname", + "filename", + "module", + "exc_info", + "exc_text", + "stack_info", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "process", + "processName", + } + + def format(self, record: logging.LogRecord) -> str: # type: ignore[override] + payload: dict[str, Any] = { + "timestamp": datetime.utcnow().isoformat() + "Z", + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + + for key, value in record.__dict__.items(): + if key in self.RESERVED or key.startswith("_"): + continue + payload[key] = value + + if record.exc_info: + payload["exc_info"] = self.formatException(record.exc_info) + if record.stack_info: + payload["stack"] = record.stack_info + + return json.dumps(payload, default=str) + + +def configure_logging(level: Optional[str] = None) -> None: + log_level = getattr(logging, (level or "INFO").upper(), logging.INFO) + root = logging.getLogger() + if root.handlers: + return + + handler = logging.StreamHandler() + formatter = JsonFormatter() + handler.setFormatter(formatter) + root.addHandler(handler) + root.setLevel(log_level) + + +def get_logger(name: str) -> logging.Logger: + if not logging.getLogger().handlers: + configure_logging() + return logging.getLogger(name) diff --git a/apps/blockchain-node/src/aitbc_chain/main.py b/apps/blockchain-node/src/aitbc_chain/main.py new file mode 100644 index 0000000..48dda15 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/main.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import Optional + +from .config import settings +from .consensus import PoAProposer, ProposerConfig +from .database import init_db, session_scope +from .logging import get_logger + +logger = get_logger(__name__) + + +class BlockchainNode: + def __init__(self) -> None: + self._stop_event = asyncio.Event() + self._proposer: Optional[PoAProposer] = None + + async def start(self) -> None: + logger.info("Starting blockchain node", extra={"chain_id": settings.chain_id}) + init_db() + self._start_proposer() + try: + await self._stop_event.wait() + finally: + await self._shutdown() + + async def stop(self) -> None: + logger.info("Stopping blockchain node") + self._stop_event.set() + await self._shutdown() + + def _start_proposer(self) -> None: + if self._proposer is not None: + return + + proposer_config = ProposerConfig( + chain_id=settings.chain_id, + proposer_id=settings.proposer_id, + interval_seconds=settings.block_time_seconds, + ) + self._proposer = PoAProposer(config=proposer_config, session_factory=session_scope) + asyncio.create_task(self._proposer.start()) + + async def _shutdown(self) -> None: + if self._proposer is None: + return + await self._proposer.stop() + self._proposer = None + + +@asynccontextmanager +async def node_app() -> asyncio.AbstractAsyncContextManager[BlockchainNode]: # type: ignore[override] + node = BlockchainNode() + try: + yield node + finally: + await node.stop() + + +def run() -> None: + asyncio.run(_run()) + + +async def _run() -> None: + async with node_app() as node: + await node.start() + + +if __name__ == "__main__": # pragma: no cover + run() diff --git a/apps/blockchain-node/src/aitbc_chain/mempool.py b/apps/blockchain-node/src/aitbc_chain/mempool.py new file mode 100644 index 0000000..7b69008 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/mempool.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import hashlib +import json +import time +from dataclasses import dataclass +from threading import Lock +from typing import Any, Dict, List + +from .metrics import metrics_registry + + +@dataclass(frozen=True) +class PendingTransaction: + tx_hash: str + content: Dict[str, Any] + received_at: float + + +class InMemoryMempool: + def __init__(self) -> None: + self._lock = Lock() + self._transactions: Dict[str, PendingTransaction] = {} + + def add(self, tx: Dict[str, Any]) -> str: + tx_hash = self._compute_hash(tx) + entry = PendingTransaction(tx_hash=tx_hash, content=tx, received_at=time.time()) + with self._lock: + self._transactions[tx_hash] = entry + metrics_registry.set_gauge("mempool_size", float(len(self._transactions))) + return tx_hash + + def list_transactions(self) -> List[PendingTransaction]: + with self._lock: + return list(self._transactions.values()) + + def _compute_hash(self, tx: Dict[str, Any]) -> str: + canonical = json.dumps(tx, sort_keys=True, separators=(",", ":")).encode() + digest = hashlib.sha256(canonical).hexdigest() + return f"0x{digest}" + + +_MEMPOOL = InMemoryMempool() + + +def get_mempool() -> InMemoryMempool: + return _MEMPOOL diff --git a/apps/blockchain-node/src/aitbc_chain/metrics.py b/apps/blockchain-node/src/aitbc_chain/metrics.py new file mode 100644 index 0000000..49cddd8 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/metrics.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass +from threading import Lock +from typing import Dict + + +@dataclass +class MetricValue: + name: str + value: float + + +class MetricsRegistry: + def __init__(self) -> None: + self._counters: Dict[str, float] = {} + self._gauges: Dict[str, float] = {} + self._lock = Lock() + + def increment(self, name: str, amount: float = 1.0) -> None: + with self._lock: + self._counters[name] = self._counters.get(name, 0.0) + amount + + def set_gauge(self, name: str, value: float) -> None: + with self._lock: + self._gauges[name] = value + + def render_prometheus(self) -> str: + with self._lock: + lines: list[str] = [] + for name, value in sorted(self._counters.items()): + lines.append(f"# TYPE {name} counter") + lines.append(f"{name} {value}") + for name, value in sorted(self._gauges.items()): + lines.append(f"# TYPE {name} gauge") + lines.append(f"{name} {value}") + return "\n".join(lines) + "\n" + + +metrics_registry = MetricsRegistry() diff --git a/apps/blockchain-node/src/aitbc_chain/models.py b/apps/blockchain-node/src/aitbc_chain/models.py new file mode 100644 index 0000000..f7461df --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/models.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from datetime import datetime +import re +from typing import List, Optional + +from pydantic import field_validator +from sqlalchemy import Column +from sqlalchemy.types import JSON +from sqlmodel import Field, Relationship, SQLModel + +_HEX_PATTERN = re.compile(r"^(0x)?[0-9a-fA-F]+$") + + +def _validate_hex(value: str, field_name: str) -> str: + if not _HEX_PATTERN.fullmatch(value): + raise ValueError(f"{field_name} must be a hex-encoded string") + return value.lower() + + +def _validate_optional_hex(value: Optional[str], field_name: str) -> Optional[str]: + if value is None: + return value + return _validate_hex(value, field_name) + + +class Block(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + height: int = Field(index=True, unique=True) + hash: str = Field(index=True, unique=True) + parent_hash: str + proposer: str + timestamp: datetime = Field(default_factory=datetime.utcnow, index=True) + tx_count: int = 0 + state_root: Optional[str] = None + + transactions: List["Transaction"] = Relationship(back_populates="block") + receipts: List["Receipt"] = Relationship(back_populates="block") + + @field_validator("hash", mode="before") + @classmethod + def _hash_is_hex(cls, value: str) -> str: + return _validate_hex(value, "Block.hash") + + @field_validator("parent_hash", mode="before") + @classmethod + def _parent_hash_is_hex(cls, value: str) -> str: + return _validate_hex(value, "Block.parent_hash") + + @field_validator("state_root", mode="before") + @classmethod + def _state_root_is_hex(cls, value: Optional[str]) -> Optional[str]: + return _validate_optional_hex(value, "Block.state_root") + + +class Transaction(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + tx_hash: str = Field(index=True, unique=True) + block_height: Optional[int] = Field( + default=None, + index=True, + foreign_key="block.height", + ) + sender: str + recipient: str + payload: dict = Field( + default_factory=dict, + sa_column=Column(JSON, nullable=False), + ) + created_at: datetime = Field(default_factory=datetime.utcnow, index=True) + + block: Optional[Block] = Relationship(back_populates="transactions") + + @field_validator("tx_hash", mode="before") + @classmethod + def _tx_hash_is_hex(cls, value: str) -> str: + return _validate_hex(value, "Transaction.tx_hash") + + +class Receipt(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + job_id: str = Field(index=True) + receipt_id: str = Field(index=True, unique=True) + block_height: Optional[int] = Field( + default=None, + index=True, + foreign_key="block.height", + ) + payload: dict = Field( + default_factory=dict, + sa_column=Column(JSON, nullable=False), + ) + miner_signature: dict = Field( + default_factory=dict, + sa_column=Column(JSON, nullable=False), + ) + coordinator_attestations: list[dict] = Field( + default_factory=list, + sa_column=Column(JSON, nullable=False), + ) + minted_amount: Optional[int] = None + recorded_at: datetime = Field(default_factory=datetime.utcnow, index=True) + + block: Optional[Block] = Relationship(back_populates="receipts") + + @field_validator("receipt_id", mode="before") + @classmethod + def _receipt_id_is_hex(cls, value: str) -> str: + return _validate_hex(value, "Receipt.receipt_id") + + +class Account(SQLModel, table=True): + address: str = Field(primary_key=True) + balance: int = 0 + nonce: int = 0 + updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/router.py b/apps/blockchain-node/src/aitbc_chain/rpc/router.py new file mode 100644 index 0000000..e6890e9 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/router.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, Field, model_validator +from sqlmodel import select + +from ..database import session_scope +from ..mempool import get_mempool +from ..metrics import metrics_registry +from ..models import Account, Block, Receipt, Transaction + +router = APIRouter() + + +def _serialize_receipt(receipt: Receipt) -> Dict[str, Any]: + return { + "receipt_id": receipt.receipt_id, + "job_id": receipt.job_id, + "payload": receipt.payload, + "miner_signature": receipt.miner_signature, + "coordinator_attestations": receipt.coordinator_attestations, + "minted_amount": receipt.minted_amount, + "recorded_at": receipt.recorded_at.isoformat(), + } + + +class TransactionRequest(BaseModel): + type: str = Field(description="Transaction type, e.g. TRANSFER or RECEIPT_CLAIM") + sender: str + nonce: int + fee: int = Field(ge=0) + payload: Dict[str, Any] + sig: Optional[str] = Field(default=None, description="Signature payload") + + @model_validator(mode="after") + def normalize_type(self) -> "TransactionRequest": # type: ignore[override] + normalized = self.type.upper() + if normalized not in {"TRANSFER", "RECEIPT_CLAIM"}: + raise ValueError(f"unsupported transaction type: {self.type}") + self.type = normalized + return self + + +class ReceiptSubmissionRequest(BaseModel): + sender: str + nonce: int + fee: int = Field(ge=0) + payload: Dict[str, Any] + sig: Optional[str] = None + + +class EstimateFeeRequest(BaseModel): + type: Optional[str] = None + payload: Dict[str, Any] = Field(default_factory=dict) + + +class MintFaucetRequest(BaseModel): + address: str + amount: int = Field(gt=0) + + +@router.get("/head", summary="Get current chain head") +async def get_head() -> Dict[str, Any]: + with session_scope() as session: + result = session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first() + if result is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no blocks yet") + return { + "height": result.height, + "hash": result.hash, + "timestamp": result.timestamp.isoformat(), + "tx_count": result.tx_count, + } + + +@router.get("/blocks/{height}", summary="Get block by height") +async def get_block(height: int) -> Dict[str, Any]: + with session_scope() as session: + block = session.exec(select(Block).where(Block.height == height)).first() + if block is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="block not found") + return { + "height": block.height, + "hash": block.hash, + "parent_hash": block.parent_hash, + "timestamp": block.timestamp.isoformat(), + "tx_count": block.tx_count, + "state_root": block.state_root, + } + + +@router.get("/tx/{tx_hash}", summary="Get transaction by hash") +async def get_transaction(tx_hash: str) -> Dict[str, Any]: + with session_scope() as session: + tx = session.exec(select(Transaction).where(Transaction.tx_hash == tx_hash)).first() + if tx is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="transaction not found") + return { + "tx_hash": tx.tx_hash, + "block_height": tx.block_height, + "sender": tx.sender, + "recipient": tx.recipient, + "payload": tx.payload, + "created_at": tx.created_at.isoformat(), + } + + +@router.get("/receipts/{receipt_id}", summary="Get receipt by ID") +async def get_receipt(receipt_id: str) -> Dict[str, Any]: + with session_scope() as session: + receipt = session.exec(select(Receipt).where(Receipt.receipt_id == receipt_id)).first() + if receipt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="receipt not found") + return _serialize_receipt(receipt) + + +@router.get("/getBalance/{address}", summary="Get account balance") +async def get_balance(address: str) -> Dict[str, Any]: + with session_scope() as session: + account = session.get(Account, address) + if account is None: + return {"address": address, "balance": 0, "nonce": 0} + return { + "address": account.address, + "balance": account.balance, + "nonce": account.nonce, + "updated_at": account.updated_at.isoformat(), + } + + +@router.post("/sendTx", summary="Submit a new transaction") +async def send_transaction(request: TransactionRequest) -> Dict[str, Any]: + mempool = get_mempool() + tx_dict = request.model_dump() + tx_hash = mempool.add(tx_dict) + metrics_registry.increment("rpc_send_tx_total") + return {"tx_hash": tx_hash} + + +@router.post("/submitReceipt", summary="Submit receipt claim transaction") +async def submit_receipt(request: ReceiptSubmissionRequest) -> Dict[str, Any]: + tx_payload = { + "type": "RECEIPT_CLAIM", + "sender": request.sender, + "nonce": request.nonce, + "fee": request.fee, + "payload": request.payload, + "sig": request.sig, + } + tx_request = TransactionRequest.model_validate(tx_payload) + metrics_registry.increment("rpc_submit_receipt_total") + return await send_transaction(tx_request) + + +@router.post("/estimateFee", summary="Estimate transaction fee") +async def estimate_fee(request: EstimateFeeRequest) -> Dict[str, Any]: + base_fee = 10 + per_byte = 1 + payload_bytes = len(json.dumps(request.payload, sort_keys=True, separators=(",", ":")).encode()) + estimated_fee = base_fee + per_byte * payload_bytes + tx_type = (request.type or "TRANSFER").upper() + return { + "type": tx_type, + "base_fee": base_fee, + "payload_bytes": payload_bytes, + "estimated_fee": estimated_fee, + } + + +@router.post("/admin/mintFaucet", summary="Mint devnet funds to an address") +async def mint_faucet(request: MintFaucetRequest) -> Dict[str, Any]: + with session_scope() as session: + account = session.get(Account, request.address) + if account is None: + account = Account(address=request.address, balance=request.amount) + session.add(account) + else: + account.balance += request.amount + session.commit() + updated_balance = account.balance + return {"address": request.address, "balance": updated_balance} diff --git a/apps/client-web/README.md b/apps/client-web/README.md new file mode 100644 index 0000000..1013233 --- /dev/null +++ b/apps/client-web/README.md @@ -0,0 +1,9 @@ +# Client Web + +## Purpose & Scope + +Front-end application that allows users to submit compute jobs, monitor status, and interact with AITBC services. See `docs/bootstrap/dirs.md` and `docs/bootstrap/examples.md` for guidance. + +## Development Setup + +Implementation pending. Recommended stack: lightweight web framework (per bootstrap doc) without heavy front-end frameworks. diff --git a/apps/coordinator-api/README.md b/apps/coordinator-api/README.md new file mode 100644 index 0000000..1568d86 --- /dev/null +++ b/apps/coordinator-api/README.md @@ -0,0 +1,34 @@ +# Coordinator API + +## Purpose & Scope + +FastAPI service that accepts client compute jobs, matches miners, and tracks job lifecycle for the AITBC network. + +## Development Setup + +1. Create a virtual environment in `apps/coordinator-api/.venv`. +2. Install dependencies listed in `pyproject.toml` once added. +3. Run the FastAPI app via `uvicorn app.main:app --reload`. + +## Configuration + +Expects environment variables defined in `.env` (see `docs/bootstrap/coordinator_api.md`). + +### Signed receipts (optional) + +- Generate an Ed25519 key: + ```bash + python - <<'PY' + from nacl.signing import SigningKey + sk = SigningKey.generate() + print(sk.encode().hex()) + PY + ``` +- Set `RECEIPT_SIGNING_KEY_HEX` in the `.env` file to the printed hex string to enable signed receipts returned by `/v1/miners/{job_id}/result` and retrievable via `/v1/jobs/{job_id}/receipt`. +- Receipt history is available at `/v1/jobs/{job_id}/receipts` (requires client API key) and returns all stored signed payloads. +- To enable coordinator attestations, set `RECEIPT_ATTESTATION_KEY_HEX` to a separate Ed25519 private key; responses include an `attestations` array alongside the miner signature. +- Clients can verify `signature` objects using the `aitbc_crypto` package (see `protocols/receipts/spec.md`). + +## Systemd + +Service name: `aitbc-coordinator-api` (to be defined under `configs/systemd/`). diff --git a/apps/coordinator-api/pyproject.toml b/apps/coordinator-api/pyproject.toml new file mode 100644 index 0000000..8d498ae --- /dev/null +++ b/apps/coordinator-api/pyproject.toml @@ -0,0 +1,33 @@ +[tool.poetry] +name = "aitbc-coordinator-api" +version = "0.1.0" +description = "AITBC Coordinator API service" +authors = ["AITBC Team"] +packages = [ + { include = "app", from = "src" } +] + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.111.0" +uvicorn = { extras = ["standard"], version = "^0.30.0" } +pydantic = "^2.7.0" +pydantic-settings = "^2.2.1" +sqlalchemy = "^2.0.30" +aiosqlite = "^0.20.0" +sqlmodel = "^0.0.16" +httpx = "^0.27.0" +python-dotenv = "^1.0.1" +slowapi = "^0.1.8" +orjson = "^3.10.0" +gunicorn = "^22.0.0" +aitbc-crypto = {path = "../../packages/py/aitbc-crypto"} + +[tool.poetry.group.dev.dependencies] +pytest = "^8.2.0" +pytest-asyncio = "^0.23.0" +httpx = {extras=["cli"], version="^0.27.0"} + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/apps/coordinator-api/src/app/__init__.py b/apps/coordinator-api/src/app/__init__.py new file mode 100644 index 0000000..413b051 --- /dev/null +++ b/apps/coordinator-api/src/app/__init__.py @@ -0,0 +1 @@ +"""AITBC Coordinator API package.""" diff --git a/apps/coordinator-api/src/app/config.py b/apps/coordinator-api/src/app/config.py new file mode 100644 index 0000000..5a4aea2 --- /dev/null +++ b/apps/coordinator-api/src/app/config.py @@ -0,0 +1,32 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import List, Optional + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False) + + app_env: str = "dev" + app_host: str = "127.0.0.1" + app_port: int = 8011 + + database_url: str = "sqlite:///./coordinator.db" + + client_api_keys: List[str] = ["client_dev_key_1"] + miner_api_keys: List[str] = ["miner_dev_key_1"] + admin_api_keys: List[str] = ["admin_dev_key_1"] + + hmac_secret: Optional[str] = None + allow_origins: List[str] = ["*"] + + job_ttl_seconds: int = 900 + heartbeat_interval_seconds: int = 10 + heartbeat_timeout_seconds: int = 30 + + rate_limit_requests: int = 60 + rate_limit_window_seconds: int = 60 + + receipt_signing_key_hex: Optional[str] = None + receipt_attestation_key_hex: Optional[str] = None + + +settings = Settings() diff --git a/apps/coordinator-api/src/app/deps.py b/apps/coordinator-api/src/app/deps.py new file mode 100644 index 0000000..6a4336a --- /dev/null +++ b/apps/coordinator-api/src/app/deps.py @@ -0,0 +1,26 @@ +from typing import Callable +from fastapi import Depends, Header, HTTPException + +from .config import settings + + +class APIKeyValidator: + def __init__(self, allowed_keys: list[str]): + self.allowed_keys = {key.strip() for key in allowed_keys if key} + + def __call__(self, api_key: str | None = Header(default=None, alias="X-Api-Key")) -> str: + if not api_key or api_key not in self.allowed_keys: + raise HTTPException(status_code=401, detail="invalid api key") + return api_key + + +def require_client_key() -> Callable[[str | None], str]: + return APIKeyValidator(settings.client_api_keys) + + +def require_miner_key() -> Callable[[str | None], str]: + return APIKeyValidator(settings.miner_api_keys) + + +def require_admin_key() -> Callable[[str | None], str]: + return APIKeyValidator(settings.admin_api_keys) diff --git a/apps/coordinator-api/src/app/domain/__init__.py b/apps/coordinator-api/src/app/domain/__init__.py new file mode 100644 index 0000000..0e3bc00 --- /dev/null +++ b/apps/coordinator-api/src/app/domain/__init__.py @@ -0,0 +1,7 @@ +"""Domain models for the coordinator API.""" + +from .job import Job +from .miner import Miner +from .job_receipt import JobReceipt + +__all__ = ["Job", "Miner", "JobReceipt"] diff --git a/apps/coordinator-api/src/app/domain/job.py b/apps/coordinator-api/src/app/domain/job.py new file mode 100644 index 0000000..52487a4 --- /dev/null +++ b/apps/coordinator-api/src/app/domain/job.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +from sqlalchemy import Column, JSON +from sqlmodel import Field, SQLModel + +from ..models import JobState + + +class Job(SQLModel, table=True): + id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True, index=True) + client_id: str = Field(index=True) + + state: JobState = Field(default=JobState.queued, sa_column_kwargs={"nullable": False}) + payload: dict = Field(sa_column=Column(JSON, nullable=False)) + constraints: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False)) + + ttl_seconds: int = Field(default=900) + requested_at: datetime = Field(default_factory=datetime.utcnow) + expires_at: datetime = Field(default_factory=datetime.utcnow) + + assigned_miner_id: Optional[str] = Field(default=None, index=True) + + result: Optional[dict] = Field(default=None, sa_column=Column(JSON, nullable=True)) + receipt: Optional[dict] = Field(default=None, sa_column=Column(JSON, nullable=True)) + receipt_id: Optional[str] = Field(default=None, index=True) + error: Optional[str] = None diff --git a/apps/coordinator-api/src/app/domain/job_receipt.py b/apps/coordinator-api/src/app/domain/job_receipt.py new file mode 100644 index 0000000..be37065 --- /dev/null +++ b/apps/coordinator-api/src/app/domain/job_receipt.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import Column, JSON +from sqlmodel import Field, SQLModel + + +class JobReceipt(SQLModel, table=True): + id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True, index=True) + job_id: str = Field(index=True, foreign_key="job.id") + receipt_id: str = Field(index=True) + payload: dict = Field(sa_column=Column(JSON, nullable=False)) + created_at: datetime = Field(default_factory=datetime.utcnow, index=True) diff --git a/apps/coordinator-api/src/app/domain/miner.py b/apps/coordinator-api/src/app/domain/miner.py new file mode 100644 index 0000000..6a8d5e0 --- /dev/null +++ b/apps/coordinator-api/src/app/domain/miner.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import Column, JSON +from sqlmodel import Field, SQLModel + + +class Miner(SQLModel, table=True): + id: str = Field(primary_key=True, index=True) + region: Optional[str] = Field(default=None, index=True) + capabilities: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False)) + concurrency: int = Field(default=1) + status: str = Field(default="ONLINE", index=True) + inflight: int = Field(default=0) + extra_metadata: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False)) + last_heartbeat: datetime = Field(default_factory=datetime.utcnow, index=True) + session_token: Optional[str] = None + last_job_at: Optional[datetime] = Field(default=None, index=True) + jobs_completed: int = Field(default=0) + jobs_failed: int = Field(default=0) + total_job_duration_ms: int = Field(default=0) + average_job_duration_ms: float = Field(default=0.0) + last_receipt_id: Optional[str] = Field(default=None, index=True) diff --git a/apps/coordinator-api/src/app/main.py b/apps/coordinator-api/src/app/main.py new file mode 100644 index 0000000..141450e --- /dev/null +++ b/apps/coordinator-api/src/app/main.py @@ -0,0 +1,34 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import settings +from .routers import client, miner, admin + + +def create_app() -> FastAPI: + app = FastAPI( + title="AITBC Coordinator API", + version="0.1.0", + description="Stage 1 coordinator service handling job orchestration between clients and miners.", + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.allow_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"] + ) + + app.include_router(client.router, prefix="/v1") + app.include_router(miner.router, prefix="/v1") + app.include_router(admin.router, prefix="/v1") + + @app.get("/v1/health", tags=["health"], summary="Service healthcheck") + async def health() -> dict[str, str]: + return {"status": "ok", "env": settings.app_env} + + return app + + +app = create_app() diff --git a/apps/coordinator-api/src/app/models.py b/apps/coordinator-api/src/app/models.py new file mode 100644 index 0000000..24f1c69 --- /dev/null +++ b/apps/coordinator-api/src/app/models.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + + +class JobState(str, Enum): + queued = "QUEUED" + running = "RUNNING" + completed = "COMPLETED" + failed = "FAILED" + canceled = "CANCELED" + expired = "EXPIRED" + + +class Constraints(BaseModel): + gpu: Optional[str] = None + cuda: Optional[str] = None + min_vram_gb: Optional[int] = None + models: Optional[list[str]] = None + region: Optional[str] = None + max_price: Optional[float] = None + + +class JobCreate(BaseModel): + payload: Dict[str, Any] + constraints: Constraints = Field(default_factory=Constraints) + ttl_seconds: int = 900 + + +class JobView(BaseModel): + job_id: str + state: JobState + assigned_miner_id: Optional[str] = None + requested_at: datetime + expires_at: datetime + error: Optional[str] = None + + +class JobResult(BaseModel): + result: Optional[Dict[str, Any]] = None + receipt: Optional[Dict[str, Any]] = None + + +class MinerRegister(BaseModel): + capabilities: Dict[str, Any] + concurrency: int = 1 + region: Optional[str] = None + + +class MinerHeartbeat(BaseModel): + inflight: int = 0 + status: str = "ONLINE" + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class PollRequest(BaseModel): + max_wait_seconds: int = 15 + + +class AssignedJob(BaseModel): + job_id: str + payload: Dict[str, Any] + constraints: Constraints + + +class JobResultSubmit(BaseModel): + result: Dict[str, Any] + metrics: Dict[str, Any] = Field(default_factory=dict) + + +class JobFailSubmit(BaseModel): + error_code: str + error_message: str + metrics: Dict[str, Any] = Field(default_factory=dict) diff --git a/apps/coordinator-api/src/app/routers/__init__.py b/apps/coordinator-api/src/app/routers/__init__.py new file mode 100644 index 0000000..5f1a6bb --- /dev/null +++ b/apps/coordinator-api/src/app/routers/__init__.py @@ -0,0 +1 @@ +"""Router modules for the coordinator API.""" diff --git a/apps/coordinator-api/src/app/routers/admin.py b/apps/coordinator-api/src/app/routers/admin.py new file mode 100644 index 0000000..de0fd8d --- /dev/null +++ b/apps/coordinator-api/src/app/routers/admin.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from ..deps import require_admin_key +from ..services import JobService, MinerService +from ..storage import SessionDep + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/stats", summary="Get coordinator stats") +async def get_stats(session: SessionDep, admin_key: str = Depends(require_admin_key())) -> dict[str, int]: # type: ignore[arg-type] + service = JobService(session) + from sqlmodel import func, select + from ..domain import Job + + total_jobs = session.exec(select(func.count()).select_from(Job)).one() + active_jobs = session.exec(select(func.count()).select_from(Job).where(Job.state.in_(["QUEUED", "RUNNING"]))).one() + + miner_service = MinerService(session) + miners = miner_service.list_records() + avg_job_duration = ( + sum(miner.average_job_duration_ms for miner in miners if miner.average_job_duration_ms) / max(len(miners), 1) + ) + return { + "total_jobs": int(total_jobs or 0), + "active_jobs": int(active_jobs or 0), + "online_miners": miner_service.online_count(), + "avg_miner_job_duration_ms": avg_job_duration, + } + + +@router.get("/jobs", summary="List jobs") +async def list_jobs(session: SessionDep, admin_key: str = Depends(require_admin_key())) -> dict[str, list[dict]]: # type: ignore[arg-type] + from ..domain import Job + + jobs = session.exec(select(Job).order_by(Job.requested_at.desc()).limit(100)).all() + return { + "items": [ + { + "job_id": job.id, + "state": job.state, + "client_id": job.client_id, + "assigned_miner_id": job.assigned_miner_id, + "requested_at": job.requested_at.isoformat(), + } + for job in jobs + ] + } + + +@router.get("/miners", summary="List miners") +async def list_miners(session: SessionDep, admin_key: str = Depends(require_admin_key())) -> dict[str, list[dict]]: # type: ignore[arg-type] + miner_service = MinerService(session) + miners = [ + { + "miner_id": record.miner_id, + "status": record.status, + "inflight": record.inflight, + "concurrency": record.concurrency, + "region": record.region, + "last_heartbeat": record.last_heartbeat.isoformat(), + "average_job_duration_ms": record.average_job_duration_ms, + "jobs_completed": record.jobs_completed, + "jobs_failed": record.jobs_failed, + "last_receipt_id": record.last_receipt_id, + } + for record in miner_service.list_records() + ] + return {"items": miners} diff --git a/apps/coordinator-api/src/app/routers/client.py b/apps/coordinator-api/src/app/routers/client.py new file mode 100644 index 0000000..8da5088 --- /dev/null +++ b/apps/coordinator-api/src/app/routers/client.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from ..deps import require_client_key +from ..models import JobCreate, JobView, JobResult +from ..services import JobService +from ..storage import SessionDep + +router = APIRouter(tags=["client"]) + +@router.post("/jobs", response_model=JobView, status_code=status.HTTP_201_CREATED, summary="Submit a job") +async def submit_job( + req: JobCreate, + session: SessionDep, + client_id: str = Depends(require_client_key()), +) -> JobView: # type: ignore[arg-type] + service = JobService(session) + job = service.create_job(client_id, req) + return service.to_view(job) + + +@router.get("/jobs/{job_id}", response_model=JobView, summary="Get job status") +async def get_job( + job_id: str, + session: SessionDep, + client_id: str = Depends(require_client_key()), +) -> JobView: # type: ignore[arg-type] + service = JobService(session) + try: + job = service.get_job(job_id, client_id=client_id) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="job not found") + return service.to_view(job) + + +@router.get("/jobs/{job_id}/result", response_model=JobResult, summary="Get job result") +async def get_job_result( + job_id: str, + session: SessionDep, + client_id: str = Depends(require_client_key()), +) -> JobResult: # type: ignore[arg-type] + service = JobService(session) + try: + job = service.get_job(job_id, client_id=client_id) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="job not found") + + if job.state not in {JobState.completed, JobState.failed, JobState.canceled, JobState.expired}: + raise HTTPException(status_code=status.HTTP_425_TOO_EARLY, detail="job not ready") + if job.result is None and job.receipt is None: + raise HTTPException(status_code=status.HTTP_425_TOO_EARLY, detail="job not ready") + return service.to_result(job) + + +@router.post("/jobs/{job_id}/cancel", response_model=JobView, summary="Cancel job") +async def cancel_job( + job_id: str, + session: SessionDep, + client_id: str = Depends(require_client_key()), +) -> JobView: # type: ignore[arg-type] + service = JobService(session) + try: + job = service.get_job(job_id, client_id=client_id) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="job not found") + + if job.state not in {JobState.queued, JobState.running}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="job not cancelable") + + job = service.cancel_job(job) + return service.to_view(job) + + +@router.get("/jobs/{job_id}/receipt", summary="Get latest signed receipt") +async def get_job_receipt( + job_id: str, + session: SessionDep, + client_id: str = Depends(require_client_key()), +) -> dict: # type: ignore[arg-type] + service = JobService(session) + try: + job = service.get_job(job_id, client_id=client_id) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="job not found") + if not job.receipt: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="receipt not available") + return job.receipt + + +@router.get("/jobs/{job_id}/receipts", summary="List signed receipts") +async def list_job_receipts( + job_id: str, + session: SessionDep, + client_id: str = Depends(require_client_key()), +) -> dict: # type: ignore[arg-type] + service = JobService(session) + receipts = service.list_receipts(job_id, client_id=client_id) + return {"items": [row.payload for row in receipts]} diff --git a/apps/coordinator-api/src/app/routers/miner.py b/apps/coordinator-api/src/app/routers/miner.py new file mode 100644 index 0000000..db57dfd --- /dev/null +++ b/apps/coordinator-api/src/app/routers/miner.py @@ -0,0 +1,110 @@ +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Response, status + +from ..deps import require_miner_key +from ..models import AssignedJob, JobFailSubmit, JobResultSubmit, JobState, MinerHeartbeat, MinerRegister, PollRequest +from ..services import JobService, MinerService +from ..services.receipts import ReceiptService +from ..storage import SessionDep + +router = APIRouter(tags=["miner"]) + + +@router.post("/miners/register", summary="Register or update miner") +async def register( + req: MinerRegister, + session: SessionDep, + miner_id: str = Depends(require_miner_key()), +) -> dict[str, Any]: # type: ignore[arg-type] + service = MinerService(session) + record = service.register(miner_id, req) + return {"status": "ok", "session_token": record.session_token} + +@router.post("/miners/heartbeat", summary="Send miner heartbeat") +async def heartbeat( + req: MinerHeartbeat, + session: SessionDep, + miner_id: str = Depends(require_miner_key()), +) -> dict[str, str]: # type: ignore[arg-type] + try: + MinerService(session).heartbeat(miner_id, req) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="miner not registered") + return {"status": "ok"} + + +# NOTE: until scheduling is fully implemented the poll endpoint performs a simple FIFO assignment. +@router.post("/miners/poll", response_model=AssignedJob, summary="Poll for next job") +async def poll( + req: PollRequest, + session: SessionDep, + miner_id: str = Depends(require_miner_key()), +) -> AssignedJob | Response: # type: ignore[arg-type] + job = MinerService(session).poll(miner_id, req.max_wait_seconds) + if job is None: + return Response(status_code=status.HTTP_204_NO_CONTENT) + return job + + +@router.post("/miners/{job_id}/result", summary="Submit job result") +async def submit_result( + job_id: str, + req: JobResultSubmit, + session: SessionDep, + miner_id: str = Depends(require_miner_key()), +) -> dict[str, Any]: # type: ignore[arg-type] + job_service = JobService(session) + miner_service = MinerService(session) + receipt_service = ReceiptService(session) + try: + job = job_service.get_job(job_id) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="job not found") + + job.result = req.result + job.state = JobState.completed + job.error = None + + metrics = dict(req.metrics or {}) + duration_ms = metrics.get("duration_ms") + if duration_ms is None and job.requested_at: + duration_ms = int((datetime.utcnow() - job.requested_at).total_seconds() * 1000) + metrics["duration_ms"] = duration_ms + + receipt = receipt_service.create_receipt(job, miner_id, req.result, metrics) + job.receipt = receipt + job.receipt_id = receipt["receipt_id"] if receipt else None + session.add(job) + session.commit() + miner_service.release( + miner_id, + success=True, + duration_ms=duration_ms, + receipt_id=receipt["receipt_id"] if receipt else None, + ) + return {"status": "ok", "receipt": receipt} + + +@router.post("/miners/{job_id}/fail", summary="Submit job failure") +async def submit_failure( + job_id: str, + req: JobFailSubmit, + session: SessionDep, + miner_id: str = Depends(require_miner_key()), +) -> dict[str, str]: # type: ignore[arg-type] + job_service = JobService(session) + miner_service = MinerService(session) + try: + job = job_service.get_job(job_id) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="job not found") + + job.state = JobState.failed + job.error = f"{req.error_code}: {req.error_message}" + job.assigned_miner_id = miner_id + session.add(job) + session.commit() + miner_service.release(miner_id, success=False) + return {"status": "ok"} diff --git a/apps/coordinator-api/src/app/services/__init__.py b/apps/coordinator-api/src/app/services/__init__.py new file mode 100644 index 0000000..806019b --- /dev/null +++ b/apps/coordinator-api/src/app/services/__init__.py @@ -0,0 +1,6 @@ +"""Service layer for coordinator business logic.""" + +from .jobs import JobService +from .miners import MinerService + +__all__ = ["JobService", "MinerService"] diff --git a/apps/coordinator-api/src/app/services/jobs.py b/apps/coordinator-api/src/app/services/jobs.py new file mode 100644 index 0000000..e0f1949 --- /dev/null +++ b/apps/coordinator-api/src/app/services/jobs.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Optional + +from sqlmodel import Session, select + +from ..domain import Job, Miner, JobReceipt +from ..models import AssignedJob, Constraints, JobCreate, JobResult, JobState, JobView + + +class JobService: + def __init__(self, session: Session): + self.session = session + + def create_job(self, client_id: str, req: JobCreate) -> Job: + ttl = max(req.ttl_seconds, 1) + now = datetime.utcnow() + job = Job( + client_id=client_id, + payload=req.payload, + constraints=req.constraints.model_dump(exclude_none=True), + ttl_seconds=ttl, + requested_at=now, + expires_at=now + timedelta(seconds=ttl), + ) + self.session.add(job) + self.session.commit() + self.session.refresh(job) + return job + + def get_job(self, job_id: str, client_id: Optional[str] = None) -> Job: + query = select(Job).where(Job.id == job_id) + if client_id: + query = query.where(Job.client_id == client_id) + job = self.session.exec(query).one_or_none() + if not job: + raise KeyError("job not found") + return self._ensure_not_expired(job) + + def list_receipts(self, job_id: str, client_id: Optional[str] = None) -> list[JobReceipt]: + job = self.get_job(job_id, client_id=client_id) + receipts = self.session.exec( + select(JobReceipt) + .where(JobReceipt.job_id == job.id) + .order_by(JobReceipt.created_at.asc()) + ).all() + return receipts + + def cancel_job(self, job: Job) -> Job: + if job.state not in {JobState.queued, JobState.running}: + return job + job.state = JobState.canceled + job.error = "canceled by client" + job.assigned_miner_id = None + self.session.add(job) + self.session.commit() + self.session.refresh(job) + return job + + def to_view(self, job: Job) -> JobView: + return JobView( + job_id=job.id, + state=job.state, + assigned_miner_id=job.assigned_miner_id, + requested_at=job.requested_at, + expires_at=job.expires_at, + error=job.error, + ) + + def to_result(self, job: Job) -> JobResult: + return JobResult(result=job.result, receipt=job.receipt) + + def to_assigned(self, job: Job) -> AssignedJob: + constraints = Constraints(**job.constraints) if isinstance(job.constraints, dict) else Constraints() + return AssignedJob(job_id=job.id, payload=job.payload, constraints=constraints) + + def acquire_next_job(self, miner: Miner) -> Optional[Job]: + now = datetime.utcnow() + statement = ( + select(Job) + .where(Job.state == JobState.queued) + .order_by(Job.requested_at.asc()) + ) + + jobs = self.session.exec(statement).all() + for job in jobs: + job = self._ensure_not_expired(job) + if job.state != JobState.queued: + continue + if job.expires_at <= now: + continue + if not self._satisfies_constraints(job, miner): + continue + job.state = JobState.running + job.assigned_miner_id = miner.id + self.session.add(job) + self.session.commit() + self.session.refresh(job) + return job + return None + + def _ensure_not_expired(self, job: Job) -> Job: + if job.state == JobState.queued and job.expires_at <= datetime.utcnow(): + job.state = JobState.expired + job.error = "job expired" + self.session.add(job) + self.session.commit() + self.session.refresh(job) + return job + + def _satisfies_constraints(self, job: Job, miner: Miner) -> bool: + if not job.constraints: + return True + constraints = Constraints(**job.constraints) + capabilities = miner.capabilities or {} + + # Region matching + if constraints.region and constraints.region != miner.region: + return False + + gpu_specs = capabilities.get("gpus", []) or [] + has_gpu = bool(gpu_specs) + + if constraints.gpu: + if not has_gpu: + return False + names = [gpu.get("name") for gpu in gpu_specs] + if constraints.gpu not in names: + return False + + if constraints.min_vram_gb: + required_mb = constraints.min_vram_gb * 1024 + if not any((gpu.get("memory_mb") or 0) >= required_mb for gpu in gpu_specs): + return False + + if constraints.cuda: + cuda_info = capabilities.get("cuda") + if not cuda_info or constraints.cuda not in str(cuda_info): + return False + + if constraints.models: + available_models = capabilities.get("models", []) + if not set(constraints.models).issubset(set(available_models)): + return False + + if constraints.max_price is not None: + price = capabilities.get("price") + try: + price_value = float(price) + except (TypeError, ValueError): + return False + if price_value > constraints.max_price: + return False + + return True diff --git a/apps/coordinator-api/src/app/services/miners.py b/apps/coordinator-api/src/app/services/miners.py new file mode 100644 index 0000000..a225ab2 --- /dev/null +++ b/apps/coordinator-api/src/app/services/miners.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +from sqlmodel import Session, select + +from ..domain import Miner +from ..models import AssignedJob, MinerHeartbeat, MinerRegister +from .jobs import JobService + + +class MinerService: + def __init__(self, session: Session): + self.session = session + + def register(self, miner_id: str, payload: MinerRegister) -> Miner: + miner = self.session.get(Miner, miner_id) + session_token = uuid4().hex + if miner is None: + miner = Miner( + id=miner_id, + capabilities=payload.capabilities, + concurrency=payload.concurrency, + region=payload.region, + session_token=session_token, + ) + self.session.add(miner) + else: + miner.capabilities = payload.capabilities + miner.concurrency = payload.concurrency + miner.region = payload.region + miner.session_token = session_token + miner.last_heartbeat = datetime.utcnow() + miner.status = "ONLINE" + self.session.commit() + self.session.refresh(miner) + return miner + + def heartbeat(self, miner_id: str, payload: MinerHeartbeat | dict) -> Miner: + if not isinstance(payload, MinerHeartbeat): + payload = MinerHeartbeat.model_validate(payload) + miner = self.session.get(Miner, miner_id) + if miner is None: + raise KeyError("miner not registered") + miner.inflight = payload.inflight + miner.status = payload.status + miner.extra_metadata = payload.metadata + miner.last_heartbeat = datetime.utcnow() + self.session.add(miner) + self.session.commit() + self.session.refresh(miner) + return miner + + def poll(self, miner_id: str, max_wait_seconds: int) -> Optional[AssignedJob]: + miner = self.session.get(Miner, miner_id) + if miner is None: + raise KeyError("miner not registered") + if miner.concurrency and miner.inflight >= miner.concurrency: + return None + + job_service = JobService(self.session) + job = job_service.acquire_next_job(miner) + if not job: + return None + + miner.inflight += 1 + miner.last_heartbeat = datetime.utcnow() + miner.last_job_at = datetime.utcnow() + self.session.add(miner) + self.session.commit() + return job_service.to_assigned(job) + + def release( + self, + miner_id: str, + success: bool | None = None, + duration_ms: int | None = None, + receipt_id: str | None = None, + ) -> None: + miner = self.session.get(Miner, miner_id) + if miner: + miner.inflight = max(0, miner.inflight - 1) + if success is True: + miner.jobs_completed += 1 + if duration_ms is not None: + miner.total_job_duration_ms += duration_ms + miner.average_job_duration_ms = ( + miner.total_job_duration_ms / max(miner.jobs_completed, 1) + ) + elif success is False: + miner.jobs_failed += 1 + if receipt_id: + miner.last_receipt_id = receipt_id + self.session.add(miner) + self.session.commit() + + def get(self, miner_id: str) -> Miner: + miner = self.session.get(Miner, miner_id) + if miner is None: + raise KeyError("miner not registered") + return miner + + def list_records(self) -> list[Miner]: + return list(self.session.exec(select(Miner)).all()) + + def online_count(self) -> int: + result = self.session.exec(select(Miner).where(Miner.status == "ONLINE")) + return len(result.all()) diff --git a/apps/coordinator-api/src/app/services/receipts.py b/apps/coordinator-api/src/app/services/receipts.py new file mode 100644 index 0000000..c79cedb --- /dev/null +++ b/apps/coordinator-api/src/app/services/receipts.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional +from secrets import token_hex +from datetime import datetime + +from aitbc_crypto.signing import ReceiptSigner + +from sqlmodel import Session + +from ..config import settings +from ..domain import Job, JobReceipt + + +class ReceiptService: + def __init__(self, session: Session) -> None: + self.session = session + self._signer: Optional[ReceiptSigner] = None + self._attestation_signer: Optional[ReceiptSigner] = None + if settings.receipt_signing_key_hex: + key_bytes = bytes.fromhex(settings.receipt_signing_key_hex) + self._signer = ReceiptSigner(key_bytes) + if settings.receipt_attestation_key_hex: + attest_bytes = bytes.fromhex(settings.receipt_attestation_key_hex) + self._attestation_signer = ReceiptSigner(attest_bytes) + + def create_receipt( + self, + job: Job, + miner_id: str, + job_result: Dict[str, Any] | None, + result_metrics: Dict[str, Any] | None, + ) -> Dict[str, Any] | None: + if self._signer is None: + return None + payload = { + "version": "1.0", + "receipt_id": token_hex(16), + "job_id": job.id, + "provider": miner_id, + "client": job.client_id, + "units": _first_present([ + (result_metrics or {}).get("units"), + (job_result or {}).get("units"), + ], default=0.0), + "unit_type": _first_present([ + (result_metrics or {}).get("unit_type"), + (job_result or {}).get("unit_type"), + ], default="gpu_seconds"), + "price": _first_present([ + (result_metrics or {}).get("price"), + (job_result or {}).get("price"), + ]), + "started_at": int(job.requested_at.timestamp()) if job.requested_at else int(datetime.utcnow().timestamp()), + "completed_at": int(datetime.utcnow().timestamp()), + "metadata": { + "job_payload": job.payload, + "job_constraints": job.constraints, + "result": job_result, + "metrics": result_metrics, + }, + } + payload["signature"] = self._signer.sign(payload) + if self._attestation_signer: + payload.setdefault("attestations", []) + attestation_payload = dict(payload) + attestation_payload.pop("attestations", None) + attestation_payload.pop("signature", None) + payload["attestations"].append(self._attestation_signer.sign(attestation_payload)) + receipt_row = JobReceipt(job_id=job.id, receipt_id=payload["receipt_id"], payload=payload) + self.session.add(receipt_row) + return payload + + +def _first_present(values: list[Optional[Any]], default: Optional[Any] = None) -> Optional[Any]: + for value in values: + if value is not None: + return value + return default diff --git a/apps/coordinator-api/src/app/storage/__init__.py b/apps/coordinator-api/src/app/storage/__init__.py new file mode 100644 index 0000000..77eef44 --- /dev/null +++ b/apps/coordinator-api/src/app/storage/__init__.py @@ -0,0 +1,5 @@ +"""Persistence helpers for the coordinator API.""" + +from .db import SessionDep, get_session, init_db + +__all__ = ["SessionDep", "get_session", "init_db"] diff --git a/apps/coordinator-api/src/app/storage/db.py b/apps/coordinator-api/src/app/storage/db.py new file mode 100644 index 0000000..8e5ed21 --- /dev/null +++ b/apps/coordinator-api/src/app/storage/db.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import Annotated, Generator + +from fastapi import Depends +from sqlalchemy.engine import Engine +from sqlmodel import Session, SQLModel, create_engine + +from ..config import settings +from ..domain import Job, Miner + +_engine: Engine | None = None + + +def get_engine() -> Engine: + global _engine + + if _engine is None: + connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} + _engine = create_engine(settings.database_url, echo=False, connect_args=connect_args) + return _engine + + +def init_db() -> None: + engine = get_engine() + SQLModel.metadata.create_all(engine) + + +@contextmanager +def session_scope() -> Generator[Session, None, None]: + engine = get_engine() + with Session(engine) as session: + yield session + + +def get_session() -> Generator[Session, None, None]: + with session_scope() as session: + yield session + + +SessionDep = Annotated[Session, Depends(get_session)] diff --git a/apps/coordinator-api/tests/test_client_receipts.py b/apps/coordinator-api/tests/test_client_receipts.py new file mode 100644 index 0000000..856fc07 --- /dev/null +++ b/apps/coordinator-api/tests/test_client_receipts.py @@ -0,0 +1,77 @@ +import pytest +from fastapi.testclient import TestClient +from nacl.signing import SigningKey + +from app.main import create_app +from app.models import JobCreate, MinerRegister, JobResultSubmit +from app.storage.db import init_db +from app.config import settings + + +@pytest.fixture(scope="module", autouse=True) +def test_client(tmp_path_factory): + db_file = tmp_path_factory.mktemp("data") / "client_receipts.db" + settings.database_url = f"sqlite:///{db_file}" + init_db() + app = create_app() + with TestClient(app) as client: + yield client + + +def test_receipt_endpoint_returns_signed_receipt(test_client: TestClient): + signing_key = SigningKey.generate() + settings.receipt_signing_key_hex = signing_key.encode().hex() + + # register miner + resp = test_client.post( + "/v1/miners/register", + json={"capabilities": {"price": 1}, "concurrency": 1}, + headers={"X-Api-Key": "miner_dev_key_1"}, + ) + assert resp.status_code == 200 + + # submit job + job_payload = { + "payload": {"task": "receipt"}, + } + resp = test_client.post( + "/v1/jobs", + json=job_payload, + headers={"X-Api-Key": "client_dev_key_1"}, + ) + assert resp.status_code == 201 + job_id = resp.json()["job_id"] + + # poll for job assignment + poll_resp = test_client.post( + "/v1/miners/poll", + json={"max_wait_seconds": 1}, + headers={"X-Api-Key": "miner_dev_key_1"}, + ) + assert poll_resp.status_code in (200, 204) + + # submit result + result_payload = { + "result": {"units": 1, "unit_type": "gpu_seconds", "price": 1}, + "metrics": {"units": 1, "duration_ms": 500} + } + result_resp = test_client.post( + f"/v1/miners/{job_id}/result", + json=result_payload, + headers={"X-Api-Key": "miner_dev_key_1"}, + ) + assert result_resp.status_code == 200 + signed_receipt = result_resp.json()["receipt"] + assert signed_receipt["signature"]["alg"] == "Ed25519" + + # fetch receipt via client endpoint + receipt_resp = test_client.get( + f"/v1/jobs/{job_id}/receipt", + headers={"X-Api-Key": "client_dev_key_1"}, + ) + assert receipt_resp.status_code == 200 + payload = receipt_resp.json() + assert payload["receipt_id"] == signed_receipt["receipt_id"] + assert payload["signature"]["alg"] == "Ed25519" + + settings.receipt_signing_key_hex = None diff --git a/apps/coordinator-api/tests/test_jobs.py b/apps/coordinator-api/tests/test_jobs.py new file mode 100644 index 0000000..7eeb094 --- /dev/null +++ b/apps/coordinator-api/tests/test_jobs.py @@ -0,0 +1,57 @@ +import pytest +from sqlmodel import Session, delete + +from app.domain import Job, Miner +from app.models import JobCreate +from app.services.jobs import JobService +from app.storage.db import init_db, session_scope + + +@pytest.fixture(scope="module", autouse=True) +def _init_db(tmp_path_factory): + db_file = tmp_path_factory.mktemp("data") / "test.db" + # override settings dynamically + from app.config import settings + + settings.database_url = f"sqlite:///{db_file}" + init_db() + yield + + +@pytest.fixture() +def session(): + with session_scope() as sess: + sess.exec(delete(Job)) + sess.exec(delete(Miner)) + sess.commit() + yield sess + + +def test_create_and_fetch_job(session: Session): + svc = JobService(session) + job = svc.create_job("client1", JobCreate(payload={"task": "noop"})) + fetched = svc.get_job(job.id, client_id="client1") + assert fetched.id == job.id + assert fetched.payload["task"] == "noop" + + +def test_acquire_next_job(session: Session): + svc = JobService(session) + job1 = svc.create_job("client1", JobCreate(payload={"n": 1})) + job2 = svc.create_job("client1", JobCreate(payload={"n": 2})) + + miner = Miner(id="miner1", capabilities={}, concurrency=1) + session.add(miner) + session.commit() + + next_job = svc.acquire_next_job(miner) + assert next_job is not None + assert next_job.id == job1.id + assert next_job.state == "RUNNING" + + next_job2 = svc.acquire_next_job(miner) + assert next_job2 is not None + assert next_job2.id == job2.id + + # No more jobs + assert svc.acquire_next_job(miner) is None diff --git a/apps/coordinator-api/tests/test_miner_service.py b/apps/coordinator-api/tests/test_miner_service.py new file mode 100644 index 0000000..d6292cd --- /dev/null +++ b/apps/coordinator-api/tests/test_miner_service.py @@ -0,0 +1,258 @@ +import pytest +from sqlmodel import Session +from nacl.signing import SigningKey + +from aitbc_crypto.signing import ReceiptVerifier + +from app.models import MinerRegister, JobCreate, Constraints +from app.services.jobs import JobService +from app.services.miners import MinerService +from app.services.receipts import ReceiptService +from app.storage.db import init_db, session_scope +from app.config import settings +from app.domain import JobReceipt +from sqlmodel import select + + +@pytest.fixture(scope="module", autouse=True) +def _init_db(tmp_path_factory): + db_file = tmp_path_factory.mktemp("data") / "miner.db" + from app.config import settings + + settings.database_url = f"sqlite:///{db_file}" + init_db() + yield + + +@pytest.fixture() +def session(): + with session_scope() as sess: + yield sess + + +def test_register_and_poll_inflight(session: Session): + miner_service = MinerService(session) + job_service = JobService(session) + + miner_service.register( + "miner-1", + MinerRegister( + capabilities={"gpu": False}, + concurrency=1, + ), + ) + + job_service.create_job("client-a", JobCreate(payload={"task": "demo"})) + assigned = miner_service.poll("miner-1", max_wait_seconds=1) + assert assigned is not None + + miner = miner_service.get("miner-1") + assert miner.inflight == 1 + + miner_service.release("miner-1") + miner = miner_service.get("miner-1") + assert miner.inflight == 0 + + +def test_heartbeat_updates_metadata(session: Session): + miner_service = MinerService(session) + + miner_service.register( + "miner-2", + MinerRegister( + capabilities={"gpu": True}, + concurrency=2, + ), + ) + + miner_service.heartbeat( + "miner-2", + payload=dict(inflight=3, status="BUSY", metadata={"load": 0.9}), + ) + + miner = miner_service.get("miner-2") + assert miner.status == "BUSY" + assert miner.inflight == 3 + assert miner.extra_metadata.get("load") == 0.9 + + +def test_capability_constrained_assignment(session: Session): + miner_service = MinerService(session) + job_service = JobService(session) + + miner = miner_service.register( + "miner-cap", + MinerRegister( + capabilities={ + "gpus": [{"name": "NVIDIA RTX 4090", "memory_mb": 24576}], + "models": ["stable-diffusion", "llama"] + }, + concurrency=1, + region="eu-west", + ), + ) + + job_service.create_job( + "client-x", + JobCreate( + payload={"task": "render"}, + constraints=Constraints(region="us-east"), + ), + ) + job_service.create_job( + "client-x", + JobCreate( + payload={"task": "render-hf"}, + constraints=Constraints( + region="eu-west", + gpu="NVIDIA RTX 4090", + min_vram_gb=12, + models=["stable-diffusion"], + ), + ), + ) + + assigned = miner_service.poll("miner-cap", max_wait_seconds=1) + assert assigned is not None + assert assigned.job_id is not None + assert assigned.payload["task"] == "render-hf" + + miner_state = miner_service.get("miner-cap") + assert miner_state.inflight == 1 + + miner_service.release("miner-cap") + + +def test_price_constraint(session: Session): + miner_service = MinerService(session) + job_service = JobService(session) + + miner_service.register( + "miner-price", + MinerRegister( + capabilities={ + "gpus": [{"name": "NVIDIA RTX 3070", "memory_mb": 8192}], + "models": [], + "price": 3.5, + }, + concurrency=1, + ), + ) + + job_service.create_job( + "client-y", + JobCreate( + payload={"task": "cheap"}, + constraints=Constraints(max_price=2.0), + ), + ) + job_service.create_job( + "client-y", + JobCreate( + payload={"task": "fair"}, + constraints=Constraints(max_price=4.0), + ), + ) + + assigned = miner_service.poll("miner-price", max_wait_seconds=1) + assert assigned is not None + assert assigned.payload["task"] == "fair" + + miner_service.release("miner-price") + + +def test_receipt_signing(session: Session): + signing_key = SigningKey.generate() + settings.receipt_signing_key_hex = signing_key.encode().hex() + + job_service = JobService(session) + miner_service = MinerService(session) + receipt_service = ReceiptService(session) + + miner_service.register( + "miner-r", + MinerRegister( + capabilities={"price": 1.0}, + concurrency=1, + ), + ) + + job = job_service.create_job( + "client-r", + JobCreate(payload={"task": "sign"}), + ) + + receipt = receipt_service.create_receipt( + job, + "miner-r", + {"units": 1.0, "unit_type": "gpu_seconds", "price": 1.2}, + {"units": 1.0}, + ) + + assert receipt is not None + signature = receipt.get("signature") + assert signature is not None + assert signature["alg"] == "Ed25519" + + miner_service.release("miner-r", success=True, duration_ms=500, receipt_id=receipt["receipt_id"]) + miner_state = miner_service.get("miner-r") + assert miner_state.jobs_completed == 1 + assert miner_state.total_job_duration_ms == 500 + assert miner_state.average_job_duration_ms == 500 + assert miner_state.last_receipt_id == receipt["receipt_id"] + + verifier = ReceiptVerifier(signing_key.verify_key.encode()) + payload = {k: v for k, v in receipt.items() if k not in {"signature", "attestations"}} + assert verifier.verify(payload, receipt["signature"]) is True + + # Reset signing key for subsequent tests + settings.receipt_signing_key_hex = None + + +def test_receipt_signing_with_attestation(session: Session): + signing_key = SigningKey.generate() + attest_key = SigningKey.generate() + settings.receipt_signing_key_hex = signing_key.encode().hex() + settings.receipt_attestation_key_hex = attest_key.encode().hex() + + job_service = JobService(session) + miner_service = MinerService(session) + receipt_service = ReceiptService(session) + + miner_service.register( + "miner-attest", + MinerRegister(capabilities={"price": 1.0}, concurrency=1), + ) + + job = job_service.create_job( + "client-attest", + JobCreate(payload={"task": "attest"}), + ) + + receipt = receipt_service.create_receipt( + job, + "miner-attest", + {"units": 1.0, "unit_type": "gpu_seconds", "price": 2.0}, + {"units": 1.0}, + ) + + assert receipt is not None + assert receipt.get("signature") is not None + attestations = receipt.get("attestations") + assert attestations is not None and len(attestations) == 1 + + stored_receipts = session.exec(select(JobReceipt).where(JobReceipt.job_id == job.id)).all() + assert len(stored_receipts) == 1 + assert stored_receipts[0].receipt_id == receipt["receipt_id"] + + payload = {k: v for k, v in receipt.items() if k not in {"signature", "attestations"}} + + miner_verifier = ReceiptVerifier(signing_key.verify_key.encode()) + assert miner_verifier.verify(payload, receipt["signature"]) is True + + attest_verifier = ReceiptVerifier(attest_key.verify_key.encode()) + assert attest_verifier.verify(payload, attestations[0]) is True + + settings.receipt_signing_key_hex = None + settings.receipt_attestation_key_hex = None + diff --git a/apps/explorer-web/README.md b/apps/explorer-web/README.md new file mode 100644 index 0000000..89fb6c1 --- /dev/null +++ b/apps/explorer-web/README.md @@ -0,0 +1,158 @@ +# Explorer Web + +## Purpose & Scope + +Static web explorer for the AITBC blockchain node, displaying blocks, transactions, and receipts as outlined in `docs/bootstrap/explorer_web.md`. + +## Development Setup + +- Install dependencies: + ```bash + npm install + ``` +- Start the dev server (Vite): + ```bash + npm run dev + ``` +- The explorer ships with mock data in `public/mock/` that powers the tables by default. + +### Data Mode Toggle + +- Configuration lives in `src/config.ts` and can be overridden with environment variables. +- Use `VITE_DATA_MODE` to choose between `mock` (default) and `live`. +- When switching to live data, set `VITE_COORDINATOR_API` to the coordinator base URL (e.g. `http://localhost:8000`). +- Example `.env` snippet: + ```bash + VITE_DATA_MODE=live + VITE_COORDINATOR_API=https://coordinator.dev.internal + ``` + With live mode enabled, the SPA will request `/v1/` routes from the coordinator instead of the bundled mock JSON. + +## Next Steps + +- Build out responsive styling and navigation interactions. +- Extend the data layer to support coordinator authentication and pagination when live endpoints are ready. +- Document coordinator API assumptions once the backend contracts stabilize. + +## Coordinator API Contracts (Draft) + +- **Blocks** (`GET /v1/blocks?limit=&offset=`) + - Expected payload: + ```json + { + "items": [ + { + "height": 12045, + "hash": "0x...", + "timestamp": "2025-09-27T01:58:12Z", + "tx_count": 8, + "proposer": "miner-alpha" + } + ], + "next_offset": 12040 + } + ``` + - TODO: confirm pagination fields and proposer metadata. + +- **Transactions** (`GET /v1/transactions?limit=&offset=`) + - Expected payload: + ```json + { + "items": [ + { + "hash": "0x...", + "block": 12045, + "from": "0x...", + "to": "0x...", + "value": "12.5", + "status": "Succeeded" + } + ], + "next_offset": "0x..." + } + ``` + - TODO: finalize value units (AIT vs wei) and status enum. + +- **Addresses** (`GET /v1/addresses/{address}`) + - Expected payload: + ```json + { + "address": "0x...", + "balance": "1450.25", + "tx_count": 42, + "last_active": "2025-09-27T01:48:00Z", + "recent_transactions": ["0x..."] + } + ``` + - TODO: detail pagination for recent transactions and add receipt summary references. + +- **Receipts** (`GET /v1/jobs/{job_id}/receipts`) + - Expected payload: + ```json + { + "job_id": "job-0001", + "items": [ + { + "receipt_id": "rcpt-123", + "miner": "miner-alpha", + "coordinator": "coordinator-001", + "issued_at": "2025-09-27T01:52:22Z", + "status": "Attested", + "payload": { + "miner_signature": "0x...", + "coordinator_signature": "0x..." + } + } + ] + } + ``` + - TODO: confirm signature payload structure and include attestation metadata. + +## Styling Guide + +- **`public/css/base.css`** + - Defines global typography, color scheme, and utility classes (tables, placeholders, code tags). + - Use this file for cross-page primitives and reset/normalization rules. + - When adding new utilities (e.g., badges, alerts), document them in this section and keep naming consistent with the existing BEM-lite approach. + +- **`public/css/layout.css`** + - Contains structural styles for the Explorer shell (header, footer, cards, forms, grids). + - Encapsulate component-specific classes with a predictable prefix, such as `.blocks__table`, `.addresses__input-group`, or `.receipts__controls`. + - Prefer utility classes from `base.css` when possible, and only introduce new layout classes when a component requires dedicated styling. + +- **Adding New Components** + - Create semantic markup first in `src/pages/` or `src/components/`, using descriptive class names that map to the page or component (`.transactions__filter`, `.overview__chart`). + - Extend `layout.css` with matching selectors to style the new elements; keep related rules grouped together for readability. + - For reusable widgets across multiple pages, consider extracting shared styles into a dedicated section or introducing a new partial CSS file when the component becomes complex. + +## Deployment Notes + +- **Environment Variables** + - `VITE_DATA_MODE`: `mock` (default) or `live`. + - `VITE_COORDINATOR_API`: Base URL for coordinator API when `live` mode is enabled. + - Additional Vite variables can be added following the `VITE_*` naming convention. + +- **Mock vs Live** + - In non-production environments, keep `VITE_DATA_MODE=mock` to serve the static JSON under `public/mock/` for quick demos. + - For staging/production deployments, set `VITE_DATA_MODE=live` and ensure the coordinator endpoint is reachable from the frontend origin; configure CORS accordingly on the backend. + - Consider serving mock JSON from a CDN or static bucket if you want deterministic demos while backend dependencies are under development. + +- **Build & Deploy** + - Build command: `npm run build` (outputs to `dist/`). + - Preview locally with `npm run preview` before publishing. + - Deploy the `dist/` contents to your static host (e.g., Nginx, S3 + CloudFront, Vercel). Ensure environment variables are injected at build time or through runtime configuration mechanisms supported by your hosting provider. + +## Error Handling (Live Mode) + +- **Status Codes** + - `2xx`: Treat as success; map response bodies into the typed models in `src/lib/models.ts`. + - `4xx`: Surface actionable messages to the user (e.g., invalid job ID). For `404`, show “not found” states in the relevant page. For `429`, display a rate-limit notice and back off. + - `5xx`: Show a generic coordinator outage message and trigger retry logic. + +- **Retry Strategy** + - Use an exponential backoff with jitter when retrying `5xx` or network failures (suggested base delay 500 ms, max 5 attempts). + - Do not retry on `4xx` except `429`; instead, display feedback. + +- **Telemetry & Logging** + - Consider emitting console warnings or hooking into an analytics layer when retries occur, noting the endpoint and status code. + - Bubble critical errors via a shared notification component so users understand whether data is stale or unavailable. diff --git a/apps/explorer-web/package.json b/apps/explorer-web/package.json new file mode 100644 index 0000000..ab45602 --- /dev/null +++ b/apps/explorer-web/package.json @@ -0,0 +1,15 @@ +{ + "name": "aitbc-explorer-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^5.4.0", + "vite": "^5.2.0" + } +} diff --git a/apps/explorer-web/public/css/base.css b/apps/explorer-web/public/css/base.css new file mode 100644 index 0000000..12b0253 --- /dev/null +++ b/apps/explorer-web/public/css/base.css @@ -0,0 +1,82 @@ +:root { + color-scheme: dark; + font-family: var(--font-base); + font-size: 16px; + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background-color: var(--color-bg); + color: var(--color-text-primary); +} + +a { + color: var(--color-primary); + text-decoration: none; +} + +a:hover, +a:focus { + text-decoration: underline; +} + +p { + margin: 0 0 1rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0 0 0.75rem; + line-height: 1.2; +} + +code { + font-family: var(--font-mono); + font-size: 0.95em; + background: var(--color-table-head); + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); +} + +.table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; +} + +.table thead { + background: var(--color-table-head); +} + +.table th, +.table td { + padding: 0.75rem; + text-align: left; +} + +.table tbody tr:nth-child(even) { + background: var(--color-table-even); +} + +.table tbody tr:hover { + background: var(--color-primary-hover); +} + +.placeholder { + color: var(--color-placeholder); + font-style: italic; +} + +.lead { + font-size: 1.05rem; + color: var(--color-text-secondary); +} diff --git a/apps/explorer-web/public/css/layout.css b/apps/explorer-web/public/css/layout.css new file mode 100644 index 0000000..e3a6bf9 --- /dev/null +++ b/apps/explorer-web/public/css/layout.css @@ -0,0 +1,229 @@ +.site-header { + background: rgba(22, 27, 34, 0.95); + border-bottom: 1px solid rgba(125, 196, 255, 0.2); + position: sticky; + top: 0; + z-index: 1000; +} + +.site-header__inner { + margin: 0 auto; + max-width: 1200px; + padding: 0.75rem 1.5rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; +} + +.site-header__brand { + font-weight: 600; + font-size: 1.15rem; +} + +.site-header__title { + flex: 1 1 auto; + font-size: 1.25rem; + color: rgba(244, 246, 251, 0.92); +} + +.site-header__controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.data-mode-toggle { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; +} + +.data-mode-toggle select { + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: inherit; + padding: 0.25rem 0.5rem; +} + +.data-mode-toggle small { + color: var(--color-text-muted); +} + +.site-header__nav { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.site-header__nav a { + padding: 0.35rem 0.75rem; + border-radius: 999px; + transition: background 150ms ease; + outline: none; +} + +.site-header__nav a:hover, +.site-header__nav a:focus { + background: rgba(125, 196, 255, 0.15); +} + +.site-header__nav a:focus-visible { + box-shadow: 0 0 0 2px rgba(125, 196, 255, 0.7); +} + +.page { + margin: 0 auto; + max-width: 1200px; + padding: 2rem 1.5rem 4rem; +} + +@media (max-width: 768px) { + .site-header__inner { + justify-content: space-between; + } + + .site-header__controls { + width: 100%; + justify-content: flex-start; + } + + .site-header__nav { + width: 100%; + justify-content: space-between; + } + + .site-header__nav a { + flex: 1 1 auto; + text-align: center; + } +} + +.section-header { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.addresses__table, +.blocks__table, +.transactions__table, +.receipts__table { + background: rgba(18, 22, 29, 0.85); + border-radius: 0.75rem; + overflow: hidden; + border: 1px solid rgba(125, 196, 255, 0.12); +} + +.overview__grid { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.card { + background: rgba(18, 22, 29, 0.85); + border: 1px solid rgba(125, 196, 255, 0.12); + border-radius: 0.75rem; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat-list { + list-style: none; + margin: 0; + padding: 0; +} + +.stat-list li + li { + margin-top: 0.35rem; +} + +.addresses__search { + display: grid; + gap: 0.75rem; + margin-bottom: 1.5rem; + background: rgba(18, 22, 29, 0.7); + border-radius: 0.5rem; + padding: 1rem 1.25rem; + border: 1px solid rgba(125, 196, 255, 0.12); +} + +.addresses__input-group { + display: flex; + gap: 0.75rem; +} + +.addresses__input-group input, +.addresses__input-group button { + border-radius: 0.5rem; + border: 1px solid rgba(125, 196, 255, 0.25); + padding: 0.5rem 0.75rem; + background: rgba(12, 15, 20, 0.85); + color: inherit; + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.addresses__input-group input:focus-visible { + border-color: rgba(125, 196, 255, 0.6); + box-shadow: 0 0 0 2px rgba(125, 196, 255, 0.3); +} + +.addresses__input-group button { + cursor: not-allowed; +} + +.receipts__controls { + display: grid; + gap: 0.75rem; + margin-bottom: 1.5rem; + background: rgba(18, 22, 29, 0.7); + border-radius: 0.5rem; + padding: 1rem 1.25rem; + border: 1px solid rgba(125, 196, 255, 0.12); +} + +.receipts__input-group { + display: flex; + gap: 0.75rem; +} + +.receipts__input-group input, +.receipts__input-group button { + border-radius: 0.5rem; + border: 1px solid rgba(125, 196, 255, 0.25); + padding: 0.5rem 0.75rem; + background: rgba(12, 15, 20, 0.85); + color: inherit; + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.receipts__input-group input:focus-visible { + border-color: rgba(125, 196, 255, 0.6); + box-shadow: 0 0 0 2px rgba(125, 196, 255, 0.3); +} + +.receipts__input-group button { + cursor: not-allowed; +} + +.site-footer { + margin: 0; + border-top: 1px solid rgba(125, 196, 255, 0.2); + background: rgba(22, 27, 34, 0.95); +} + +.site-footer__inner { + margin: 0 auto; + max-width: 1200px; + padding: 1.25rem 1.5rem; + color: rgba(244, 246, 251, 0.7); + font-size: 0.9rem; +} diff --git a/apps/explorer-web/public/css/theme.css b/apps/explorer-web/public/css/theme.css new file mode 100644 index 0000000..49ad912 --- /dev/null +++ b/apps/explorer-web/public/css/theme.css @@ -0,0 +1,38 @@ +:root { + color-scheme: dark; + --font-base: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "Fira Code", "Source Code Pro", Menlo, Consolas, monospace; + + --color-bg: #0b0d10; + --color-surface: rgba(18, 22, 29, 0.85); + --color-surface-muted: rgba(18, 22, 29, 0.7); + --color-border: rgba(125, 196, 255, 0.12); + --color-border-strong: rgba(125, 196, 255, 0.2); + --color-text-primary: #f4f6fb; + --color-text-secondary: rgba(244, 246, 251, 0.7); + --color-text-muted: rgba(244, 246, 251, 0.6); + --color-primary: #7dc4ff; + --color-primary-hover: rgba(125, 196, 255, 0.15); + --color-focus-ring: rgba(125, 196, 255, 0.7); + --color-placeholder: rgba(244, 246, 251, 0.7); + --color-table-even: rgba(255, 255, 255, 0.02); + --color-table-head: rgba(255, 255, 255, 0.06); + --color-shadow-soft: rgba(0, 0, 0, 0.35); + + --space-xs: 0.35rem; + --space-sm: 0.5rem; + --space-md: 0.75rem; + --space-lg: 1.25rem; + --space-xl: 2rem; + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; +} + +:root[data-mode="live"] { + --color-primary: #8ef9d0; + --color-primary-hover: rgba(142, 249, 208, 0.18); + --color-border: rgba(142, 249, 208, 0.12); + --color-border-strong: rgba(142, 249, 208, 0.24); + --color-focus-ring: rgba(142, 249, 208, 0.65); +} diff --git a/apps/explorer-web/public/mock/addresses.json b/apps/explorer-web/public/mock/addresses.json new file mode 100644 index 0000000..6d0aaa9 --- /dev/null +++ b/apps/explorer-web/public/mock/addresses.json @@ -0,0 +1,14 @@ +[ + { + "address": "0xfeedfacefeedfacefeedfacefeedfacefeedface", + "balance": "1450.25 AIT", + "txCount": 42, + "lastActive": "2025-09-27T01:48:00Z" + }, + { + "address": "0xcafebabecafebabecafebabecafebabecafebabe", + "balance": "312.00 AIT", + "txCount": 9, + "lastActive": "2025-09-27T01:25:34Z" + } +] diff --git a/apps/explorer-web/public/mock/blocks.json b/apps/explorer-web/public/mock/blocks.json new file mode 100644 index 0000000..9c4b32b --- /dev/null +++ b/apps/explorer-web/public/mock/blocks.json @@ -0,0 +1,23 @@ +[ + { + "height": 12045, + "hash": "0x7a3f5bf5c3b8ed5d6f77a42b8ab9a421e91e23f4d2a3f6a1d4b5c6d7e8f90123", + "timestamp": "2025-09-27T01:58:12Z", + "txCount": 8, + "proposer": "miner-alpha" + }, + { + "height": 12044, + "hash": "0x5dd4e7a2b88c56f4cbb8f6e21d332e2f1a765e8d9c0b12a34567890abcdef012", + "timestamp": "2025-09-27T01:56:43Z", + "txCount": 11, + "proposer": "miner-beta" + }, + { + "height": 12043, + "hash": "0x1b9d2c3f4e5a67890b12c34d56e78f90a1b2c3d4e5f60718293a4b5c6d7e8f90", + "timestamp": "2025-09-27T01:54:16Z", + "txCount": 4, + "proposer": "miner-gamma" + } +] diff --git a/apps/explorer-web/public/mock/receipts.json b/apps/explorer-web/public/mock/receipts.json new file mode 100644 index 0000000..6d05a4a --- /dev/null +++ b/apps/explorer-web/public/mock/receipts.json @@ -0,0 +1,18 @@ +[ + { + "jobId": "job-0001", + "receiptId": "rcpt-123", + "miner": "miner-alpha", + "coordinator": "coordinator-001", + "issuedAt": "2025-09-27T01:52:22Z", + "status": "Attested" + }, + { + "jobId": "job-0002", + "receiptId": "rcpt-124", + "miner": "miner-beta", + "coordinator": "coordinator-001", + "issuedAt": "2025-09-27T01:45:18Z", + "status": "Pending" + } +] diff --git a/apps/explorer-web/public/mock/transactions.json b/apps/explorer-web/public/mock/transactions.json new file mode 100644 index 0000000..b56227b --- /dev/null +++ b/apps/explorer-web/public/mock/transactions.json @@ -0,0 +1,18 @@ +[ + { + "hash": "0xabc1230000000000000000000000000000000000000000000000000000000001", + "block": 12045, + "from": "0xfeedfacefeedfacefeedfacefeedfacefeedface", + "to": "0xcafebabecafebabecafebabecafebabecafebabe", + "value": "12.5 AIT", + "status": "Succeeded" + }, + { + "hash": "0xabc1230000000000000000000000000000000000000000000000000000000002", + "block": 12044, + "from": "0xdeadc0dedeadc0dedeadc0dedeadc0dedeadc0de", + "to": "0x8badf00d8badf00d8badf00d8badf00d8badf00d", + "value": "3.1 AIT", + "status": "Pending" + } +] diff --git a/apps/explorer-web/src/components/dataModeToggle.js b/apps/explorer-web/src/components/dataModeToggle.js new file mode 100644 index 0000000..9987cb5 --- /dev/null +++ b/apps/explorer-web/src/components/dataModeToggle.js @@ -0,0 +1,33 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.initDataModeToggle = initDataModeToggle; +var config_1 = require("../config"); +var mockData_1 = require("../lib/mockData"); +var LABELS = { + mock: "Mock Data", + live: "Live API", +}; +function initDataModeToggle(onChange) { + var container = document.querySelector("[data-role='data-mode-toggle']"); + if (!container) { + return; + } + container.innerHTML = renderControls((0, mockData_1.getDataMode)()); + var select = container.querySelector("select[data-mode-select]"); + if (!select) { + return; + } + select.value = (0, mockData_1.getDataMode)(); + select.addEventListener("change", function (event) { + var value = event.target.value; + (0, mockData_1.setDataMode)(value); + document.documentElement.dataset.mode = value; + onChange(); + }); +} +function renderControls(mode) { + var options = Object.keys(LABELS) + .map(function (id) { return ""); }) + .join(""); + return "\n \n "); +} diff --git a/apps/explorer-web/src/components/dataModeToggle.ts b/apps/explorer-web/src/components/dataModeToggle.ts new file mode 100644 index 0000000..85cf15a --- /dev/null +++ b/apps/explorer-web/src/components/dataModeToggle.ts @@ -0,0 +1,45 @@ +import { CONFIG, type DataMode } from "../config"; +import { getDataMode, setDataMode } from "../lib/mockData"; + +const LABELS: Record = { + mock: "Mock Data", + live: "Live API", +}; + +export function initDataModeToggle(onChange: () => void): void { + const container = document.querySelector("[data-role='data-mode-toggle']"); + if (!container) { + return; + } + + container.innerHTML = renderControls(getDataMode()); + + const select = container.querySelector("select[data-mode-select]"); + if (!select) { + return; + } + + select.value = getDataMode(); + select.addEventListener("change", (event) => { + const value = (event.target as HTMLSelectElement).value as DataMode; + setDataMode(value); + document.documentElement.dataset.mode = value; + onChange(); + }); +} + +function renderControls(mode: DataMode): string { + const options = (Object.keys(LABELS) as DataMode[]) + .map((id) => ``) + .join(""); + + return ` + + `; +} diff --git a/apps/explorer-web/src/components/siteFooter.js b/apps/explorer-web/src/components/siteFooter.js new file mode 100644 index 0000000..d94b911 --- /dev/null +++ b/apps/explorer-web/src/components/siteFooter.js @@ -0,0 +1,7 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.siteFooter = siteFooter; +function siteFooter() { + var year = new Date().getFullYear(); + return "\n
\n \n
\n "); +} diff --git a/apps/explorer-web/src/components/siteFooter.ts b/apps/explorer-web/src/components/siteFooter.ts new file mode 100644 index 0000000..ae49bbc --- /dev/null +++ b/apps/explorer-web/src/components/siteFooter.ts @@ -0,0 +1,10 @@ +export function siteFooter(): string { + const year = new Date().getFullYear(); + return ` +
+ +
+ `; +} diff --git a/apps/explorer-web/src/components/siteHeader.js b/apps/explorer-web/src/components/siteHeader.js new file mode 100644 index 0000000..6c94c96 --- /dev/null +++ b/apps/explorer-web/src/components/siteHeader.js @@ -0,0 +1,6 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.siteHeader = siteHeader; +function siteHeader(title) { + return "\n
\n
\n AITBC Explorer\n

".concat(title, "

\n
\n
\n
\n \n
\n
\n "); +} diff --git a/apps/explorer-web/src/components/siteHeader.ts b/apps/explorer-web/src/components/siteHeader.ts new file mode 100644 index 0000000..b238603 --- /dev/null +++ b/apps/explorer-web/src/components/siteHeader.ts @@ -0,0 +1,20 @@ +export function siteHeader(title: string): string { + return ` + + `; +} diff --git a/apps/explorer-web/src/config.js b/apps/explorer-web/src/config.js new file mode 100644 index 0000000..9975a39 --- /dev/null +++ b/apps/explorer-web/src/config.js @@ -0,0 +1,10 @@ +"use strict"; +var _a, _b, _c, _d; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CONFIG = void 0; +exports.CONFIG = { + // Toggle between "mock" (static JSON under public/mock/) and "live" coordinator APIs. + dataMode: (_b = (_a = import.meta.env) === null || _a === void 0 ? void 0 : _a.VITE_DATA_MODE) !== null && _b !== void 0 ? _b : "mock", + mockBasePath: "/mock", + apiBaseUrl: (_d = (_c = import.meta.env) === null || _c === void 0 ? void 0 : _c.VITE_COORDINATOR_API) !== null && _d !== void 0 ? _d : "http://localhost:8000", +}; diff --git a/apps/explorer-web/src/config.ts b/apps/explorer-web/src/config.ts new file mode 100644 index 0000000..18d02aa --- /dev/null +++ b/apps/explorer-web/src/config.ts @@ -0,0 +1,14 @@ +export type DataMode = "mock" | "live"; + +export interface ExplorerConfig { + dataMode: DataMode; + mockBasePath: string; + apiBaseUrl: string; +} + +export const CONFIG: ExplorerConfig = { + // Toggle between "mock" (static JSON under public/mock/) and "live" coordinator APIs. + dataMode: (import.meta.env?.VITE_DATA_MODE as DataMode) ?? "mock", + mockBasePath: "/mock", + apiBaseUrl: import.meta.env?.VITE_COORDINATOR_API ?? "http://localhost:8000", +}; diff --git a/apps/explorer-web/src/lib/mockData.js b/apps/explorer-web/src/lib/mockData.js new file mode 100644 index 0000000..74feede --- /dev/null +++ b/apps/explorer-web/src/lib/mockData.js @@ -0,0 +1,207 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getDataMode = getDataMode; +exports.setDataMode = setDataMode; +exports.fetchBlocks = fetchBlocks; +exports.fetchTransactions = fetchTransactions; +exports.fetchAddresses = fetchAddresses; +exports.fetchReceipts = fetchReceipts; +var config_1 = require("../config"); +var currentMode = config_1.CONFIG.dataMode; +function getDataMode() { + return currentMode; +} +function setDataMode(mode) { + currentMode = mode; +} +function fetchBlocks() { + return __awaiter(this, void 0, void 0, function () { + var data, response, data, error_1; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!(getDataMode() === "mock")) return [3 /*break*/, 2]; + return [4 /*yield*/, fetchMock("blocks")]; + case 1: + data = _a.sent(); + return [2 /*return*/, data.items]; + case 2: + _a.trys.push([2, 5, , 6]); + return [4 /*yield*/, fetch("".concat(config_1.CONFIG.apiBaseUrl, "/v1/blocks"))]; + case 3: + response = _a.sent(); + if (!response.ok) { + throw new Error("Failed to fetch blocks: ".concat(response.status)); + } + return [4 /*yield*/, response.json()]; + case 4: + data = (_a.sent()); + return [2 /*return*/, data.items]; + case 5: + error_1 = _a.sent(); + console.warn("[Explorer] Failed to fetch live block data", error_1); + return [2 /*return*/, []]; + case 6: return [2 /*return*/]; + } + }); + }); +} +function fetchTransactions() { + return __awaiter(this, void 0, void 0, function () { + var data, response, data, error_2; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!(getDataMode() === "mock")) return [3 /*break*/, 2]; + return [4 /*yield*/, fetchMock("transactions")]; + case 1: + data = _a.sent(); + return [2 /*return*/, data.items]; + case 2: + _a.trys.push([2, 5, , 6]); + return [4 /*yield*/, fetch("".concat(config_1.CONFIG.apiBaseUrl, "/v1/transactions"))]; + case 3: + response = _a.sent(); + if (!response.ok) { + throw new Error("Failed to fetch transactions: ".concat(response.status)); + } + return [4 /*yield*/, response.json()]; + case 4: + data = (_a.sent()); + return [2 /*return*/, data.items]; + case 5: + error_2 = _a.sent(); + console.warn("[Explorer] Failed to fetch live transaction data", error_2); + return [2 /*return*/, []]; + case 6: return [2 /*return*/]; + } + }); + }); +} +function fetchAddresses() { + return __awaiter(this, void 0, void 0, function () { + var data, response, data, error_3; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!(getDataMode() === "mock")) return [3 /*break*/, 2]; + return [4 /*yield*/, fetchMock("addresses")]; + case 1: + data = _a.sent(); + return [2 /*return*/, Array.isArray(data) ? data : [data]]; + case 2: + _a.trys.push([2, 5, , 6]); + return [4 /*yield*/, fetch("".concat(config_1.CONFIG.apiBaseUrl, "/v1/addresses"))]; + case 3: + response = _a.sent(); + if (!response.ok) { + throw new Error("Failed to fetch addresses: ".concat(response.status)); + } + return [4 /*yield*/, response.json()]; + case 4: + data = (_a.sent()); + return [2 /*return*/, Array.isArray(data) ? data : data.items]; + case 5: + error_3 = _a.sent(); + console.warn("[Explorer] Failed to fetch live address data", error_3); + return [2 /*return*/, []]; + case 6: return [2 /*return*/]; + } + }); + }); +} +function fetchReceipts() { + return __awaiter(this, void 0, void 0, function () { + var data, response, data, error_4; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!(getDataMode() === "mock")) return [3 /*break*/, 2]; + return [4 /*yield*/, fetchMock("receipts")]; + case 1: + data = _a.sent(); + return [2 /*return*/, data.items]; + case 2: + _a.trys.push([2, 5, , 6]); + return [4 /*yield*/, fetch("".concat(config_1.CONFIG.apiBaseUrl, "/v1/receipts"))]; + case 3: + response = _a.sent(); + if (!response.ok) { + throw new Error("Failed to fetch receipts: ".concat(response.status)); + } + return [4 /*yield*/, response.json()]; + case 4: + data = (_a.sent()); + return [2 /*return*/, data.items]; + case 5: + error_4 = _a.sent(); + console.warn("[Explorer] Failed to fetch live receipt data", error_4); + return [2 /*return*/, []]; + case 6: return [2 /*return*/]; + } + }); + }); +} +function fetchMock(resource) { + return __awaiter(this, void 0, void 0, function () { + var url, response, error_5; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + url = "".concat(config_1.CONFIG.mockBasePath, "/").concat(resource, ".json"); + _a.label = 1; + case 1: + _a.trys.push([1, 4, , 5]); + return [4 /*yield*/, fetch(url)]; + case 2: + response = _a.sent(); + if (!response.ok) { + throw new Error("Request failed with status ".concat(response.status)); + } + return [4 /*yield*/, response.json()]; + case 3: return [2 /*return*/, (_a.sent())]; + case 4: + error_5 = _a.sent(); + console.warn("[Explorer] Failed to fetch mock data from ".concat(url), error_5); + return [2 /*return*/, []]; + case 5: return [2 /*return*/]; + } + }); + }); +} diff --git a/apps/explorer-web/src/lib/mockData.ts b/apps/explorer-web/src/lib/mockData.ts new file mode 100644 index 0000000..57d3d33 --- /dev/null +++ b/apps/explorer-web/src/lib/mockData.ts @@ -0,0 +1,112 @@ +import { CONFIG, type DataMode } from "../config"; +import type { + BlockListResponse, + TransactionListResponse, + AddressDetailResponse, + ReceiptListResponse, + BlockSummary, + TransactionSummary, + AddressSummary, + ReceiptSummary, +} from "./models.ts"; + +let currentMode: DataMode = CONFIG.dataMode; + +export function getDataMode(): DataMode { + return currentMode; +} + +export function setDataMode(mode: DataMode): void { + currentMode = mode; +} + +export async function fetchBlocks(): Promise { + if (getDataMode() === "mock") { + const data = await fetchMock("blocks"); + return data.items; + } + + try { + const response = await fetch(`${CONFIG.apiBaseUrl}/v1/blocks`); + if (!response.ok) { + throw new Error(`Failed to fetch blocks: ${response.status}`); + } + const data = (await response.json()) as BlockListResponse; + return data.items; + } catch (error) { + console.warn("[Explorer] Failed to fetch live block data", error); + return []; + } +} + +export async function fetchTransactions(): Promise { + if (getDataMode() === "mock") { + const data = await fetchMock("transactions"); + return data.items; + } + + try { + const response = await fetch(`${CONFIG.apiBaseUrl}/v1/transactions`); + if (!response.ok) { + throw new Error(`Failed to fetch transactions: ${response.status}`); + } + const data = (await response.json()) as TransactionListResponse; + return data.items; + } catch (error) { + console.warn("[Explorer] Failed to fetch live transaction data", error); + return []; + } +} + +export async function fetchAddresses(): Promise { + if (getDataMode() === "mock") { + const data = await fetchMock("addresses"); + return Array.isArray(data) ? data : [data]; + } + + try { + const response = await fetch(`${CONFIG.apiBaseUrl}/v1/addresses`); + if (!response.ok) { + throw new Error(`Failed to fetch addresses: ${response.status}`); + } + const data = (await response.json()) as { items: AddressDetailResponse[] } | AddressDetailResponse[]; + return Array.isArray(data) ? data : data.items; + } catch (error) { + console.warn("[Explorer] Failed to fetch live address data", error); + return []; + } +} + +export async function fetchReceipts(): Promise { + if (getDataMode() === "mock") { + const data = await fetchMock("receipts"); + return data.items; + } + + try { + const response = await fetch(`${CONFIG.apiBaseUrl}/v1/receipts`); + if (!response.ok) { + throw new Error(`Failed to fetch receipts: ${response.status}`); + } + const data = (await response.json()) as ReceiptListResponse; + return data.items; + } catch (error) { + console.warn("[Explorer] Failed to fetch live receipt data", error); + return []; + } +} + +async function fetchMock(resource: string): Promise { + const url = `${CONFIG.mockBasePath}/${resource}.json`; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + return (await response.json()) as T; + } catch (error) { + console.warn(`[Explorer] Failed to fetch mock data from ${url}`, error); + return [] as unknown as T; + } +} diff --git a/apps/explorer-web/src/lib/models.js b/apps/explorer-web/src/lib/models.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/apps/explorer-web/src/lib/models.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/apps/explorer-web/src/lib/models.ts b/apps/explorer-web/src/lib/models.ts new file mode 100644 index 0000000..527b4a4 --- /dev/null +++ b/apps/explorer-web/src/lib/models.ts @@ -0,0 +1,57 @@ +export interface BlockSummary { + height: number; + hash: string; + timestamp: string; + txCount: number; + proposer: string; +} + +export interface BlockListResponse { + items: BlockSummary[]; + next_offset?: number | string | null; +} + +export interface TransactionSummary { + hash: string; + block: number | string; + from: string; + to: string | null; + value: string; + status: string; +} + +export interface TransactionListResponse { + items: TransactionSummary[]; + next_offset?: number | string | null; +} + +export interface AddressSummary { + address: string; + balance: string; + txCount: number; + lastActive: string; + recentTransactions?: string[]; +} + +export interface AddressDetailResponse extends AddressSummary {} +export interface AddressListResponse { + items: AddressSummary[]; + next_offset?: number | string | null; +} + +export interface ReceiptSummary { + receiptId: string; + miner: string; + coordinator: string; + issuedAt: string; + status: string; + payload?: { + minerSignature?: string; + coordinatorSignature?: string; + }; +} + +export interface ReceiptListResponse { + jobId: string; + items: ReceiptSummary[]; +} diff --git a/apps/explorer-web/src/main.js b/apps/explorer-web/src/main.js new file mode 100644 index 0000000..48c2590 --- /dev/null +++ b/apps/explorer-web/src/main.js @@ -0,0 +1,63 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +require("../public/css/theme.css"); +require("../public/css/base.css"); +require("../public/css/layout.css"); +var siteHeader_1 = require("./components/siteHeader"); +var siteFooter_1 = require("./components/siteFooter"); +var overview_1 = require("./pages/overview"); +var blocks_1 = require("./pages/blocks"); +var transactions_1 = require("./pages/transactions"); +var addresses_1 = require("./pages/addresses"); +var receipts_1 = require("./pages/receipts"); +var dataModeToggle_1 = require("./components/dataModeToggle"); +var mockData_1 = require("./lib/mockData"); +var overviewConfig = { + title: overview_1.overviewTitle, + render: overview_1.renderOverviewPage, + init: overview_1.initOverviewPage, +}; +var routes = { + "/": overviewConfig, + "/index.html": overviewConfig, + "/blocks": { + title: blocks_1.blocksTitle, + render: blocks_1.renderBlocksPage, + init: blocks_1.initBlocksPage, + }, + "/transactions": { + title: transactions_1.transactionsTitle, + render: transactions_1.renderTransactionsPage, + init: transactions_1.initTransactionsPage, + }, + "/addresses": { + title: addresses_1.addressesTitle, + render: addresses_1.renderAddressesPage, + init: addresses_1.initAddressesPage, + }, + "/receipts": { + title: receipts_1.receiptsTitle, + render: receipts_1.renderReceiptsPage, + init: receipts_1.initReceiptsPage, + }, +}; +function render() { + var _a, _b, _c; + var root = document.querySelector("#app"); + if (!root) { + console.warn("[Explorer] Missing #app root element"); + return; + } + document.documentElement.dataset.mode = (0, mockData_1.getDataMode)(); + var currentPath = window.location.pathname.replace(/\/$/, ""); + var normalizedPath = currentPath === "" ? "/" : currentPath; + var page = (_a = routes[normalizedPath]) !== null && _a !== void 0 ? _a : null; + root.innerHTML = "\n ".concat((0, siteHeader_1.siteHeader)((_b = page === null || page === void 0 ? void 0 : page.title) !== null && _b !== void 0 ? _b : "Explorer"), "\n
").concat((page !== null && page !== void 0 ? page : notFoundPageConfig).render(), "
\n ").concat((0, siteFooter_1.siteFooter)(), "\n "); + (0, dataModeToggle_1.initDataModeToggle)(render); + void ((_c = page === null || page === void 0 ? void 0 : page.init) === null || _c === void 0 ? void 0 : _c.call(page)); +} +var notFoundPageConfig = { + title: "Not Found", + render: function () { return "\n
\n

Page Not Found

\n

The requested view is not available yet.

\n
\n "; }, +}; +document.addEventListener("DOMContentLoaded", render); diff --git a/apps/explorer-web/src/main.ts b/apps/explorer-web/src/main.ts new file mode 100644 index 0000000..46c4efd --- /dev/null +++ b/apps/explorer-web/src/main.ts @@ -0,0 +1,84 @@ +import "../public/css/theme.css"; +import "../public/css/base.css"; +import "../public/css/layout.css"; +import { siteHeader } from "./components/siteHeader"; +import { siteFooter } from "./components/siteFooter"; +import { overviewTitle, renderOverviewPage, initOverviewPage } from "./pages/overview"; +import { blocksTitle, renderBlocksPage, initBlocksPage } from "./pages/blocks"; +import { transactionsTitle, renderTransactionsPage, initTransactionsPage } from "./pages/transactions"; +import { addressesTitle, renderAddressesPage, initAddressesPage } from "./pages/addresses"; +import { receiptsTitle, renderReceiptsPage, initReceiptsPage } from "./pages/receipts"; +import { initDataModeToggle } from "./components/dataModeToggle"; +import { getDataMode } from "./lib/mockData"; + +type PageConfig = { + title: string; + render: () => string; + init?: () => void | Promise; +}; + +const overviewConfig: PageConfig = { + title: overviewTitle, + render: renderOverviewPage, + init: initOverviewPage, +}; + +const routes: Record = { + "/": overviewConfig, + "/index.html": overviewConfig, + "/blocks": { + title: blocksTitle, + render: renderBlocksPage, + init: initBlocksPage, + }, + "/transactions": { + title: transactionsTitle, + render: renderTransactionsPage, + init: initTransactionsPage, + }, + "/addresses": { + title: addressesTitle, + render: renderAddressesPage, + init: initAddressesPage, + }, + "/receipts": { + title: receiptsTitle, + render: renderReceiptsPage, + init: initReceiptsPage, + }, +}; + +function render(): void { + const root = document.querySelector("#app"); + if (!root) { + console.warn("[Explorer] Missing #app root element"); + return; + } + + document.documentElement.dataset.mode = getDataMode(); + + const currentPath = window.location.pathname.replace(/\/$/, ""); + const normalizedPath = currentPath === "" ? "/" : currentPath; + const page = routes[normalizedPath] ?? null; + + root.innerHTML = ` + ${siteHeader(page?.title ?? "Explorer")} +
${(page ?? notFoundPageConfig).render()}
+ ${siteFooter()} + `; + + initDataModeToggle(render); + void page?.init?.(); +} + +const notFoundPageConfig: PageConfig = { + title: "Not Found", + render: () => ` +
+

Page Not Found

+

The requested view is not available yet.

+
+ `, +}; + +document.addEventListener("DOMContentLoaded", render); diff --git a/apps/explorer-web/src/pages/addresses.js b/apps/explorer-web/src/pages/addresses.js new file mode 100644 index 0000000..1043b3f --- /dev/null +++ b/apps/explorer-web/src/pages/addresses.js @@ -0,0 +1,72 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addressesTitle = void 0; +exports.renderAddressesPage = renderAddressesPage; +exports.initAddressesPage = initAddressesPage; +var mockData_1 = require("../lib/mockData"); +exports.addressesTitle = "Addresses"; +function renderAddressesPage() { + return "\n
\n
\n

Address Lookup

\n

Enter an account address to view recent transactions, balances, and receipt history (mock results shown below).

\n
\n
\n \n
\n \n \n
\n

Searching will be enabled after integrating the coordinator/blockchain node endpoints.

\n
\n
\n

Recent Activity

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
AddressBalanceTx CountLast Active
Loading addresses\u2026
\n
\n
\n "; +} +function initAddressesPage() { + return __awaiter(this, void 0, void 0, function () { + var tbody, addresses; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + tbody = document.querySelector("#addresses-table-body"); + if (!tbody) { + return [2 /*return*/]; + } + return [4 /*yield*/, (0, mockData_1.fetchAddresses)()]; + case 1: + addresses = _a.sent(); + if (addresses.length === 0) { + tbody.innerHTML = "\n \n No mock addresses available.\n \n "; + return [2 /*return*/]; + } + tbody.innerHTML = addresses.map(renderAddressRow).join(""); + return [2 /*return*/]; + } + }); + }); +} +function renderAddressRow(address) { + return "\n \n ".concat(address.address, "\n ").concat(address.balance, "\n ").concat(address.txCount, "\n ").concat(new Date(address.lastActive).toLocaleString(), "\n \n "); +} diff --git a/apps/explorer-web/src/pages/addresses.ts b/apps/explorer-web/src/pages/addresses.ts new file mode 100644 index 0000000..e9c6092 --- /dev/null +++ b/apps/explorer-web/src/pages/addresses.ts @@ -0,0 +1,72 @@ +import { fetchAddresses, type AddressSummary } from "../lib/mockData"; + +export const addressesTitle = "Addresses"; + +export function renderAddressesPage(): string { + return ` +
+
+

Address Lookup

+

Enter an account address to view recent transactions, balances, and receipt history (mock results shown below).

+
+ +
+

Recent Activity

+ + + + + + + + + + + + + + +
AddressBalanceTx CountLast Active
Loading addresses…
+
+
+ `; +} + +export async function initAddressesPage(): Promise { + const tbody = document.querySelector( + "#addresses-table-body", + ); + if (!tbody) { + return; + } + + const addresses = await fetchAddresses(); + if (addresses.length === 0) { + tbody.innerHTML = ` + + No mock addresses available. + + `; + return; + } + + tbody.innerHTML = addresses.map(renderAddressRow).join(""); +} + +function renderAddressRow(address: AddressSummary): string { + return ` + + ${address.address} + ${address.balance} + ${address.txCount} + ${new Date(address.lastActive).toLocaleString()} + + `; +} diff --git a/apps/explorer-web/src/pages/blocks.js b/apps/explorer-web/src/pages/blocks.js new file mode 100644 index 0000000..a189aa2 --- /dev/null +++ b/apps/explorer-web/src/pages/blocks.js @@ -0,0 +1,74 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.blocksTitle = void 0; +exports.renderBlocksPage = renderBlocksPage; +exports.initBlocksPage = initBlocksPage; +var mockData_1 = require("../lib/mockData"); +exports.blocksTitle = "Blocks"; +function renderBlocksPage() { + return "\n
\n
\n

Recent Blocks

\n

This view lists blocks pulled from the coordinator or blockchain node (mock data shown for now).

\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
HeightBlock HashTimestampTx CountProposer
Loading blocks\u2026
\n
\n "; +} +function initBlocksPage() { + return __awaiter(this, void 0, void 0, function () { + var tbody, blocks; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + tbody = document.querySelector("#blocks-table-body"); + if (!tbody) { + return [2 /*return*/]; + } + return [4 /*yield*/, (0, mockData_1.fetchBlocks)()]; + case 1: + blocks = _a.sent(); + if (blocks.length === 0) { + tbody.innerHTML = "\n \n No mock blocks available.\n \n "; + return [2 /*return*/]; + } + tbody.innerHTML = blocks + .map(function (block) { return renderBlockRow(block); }) + .join(""); + return [2 /*return*/]; + } + }); + }); +} +function renderBlockRow(block) { + return "\n \n ".concat(block.height, "\n ").concat(block.hash.slice(0, 18), "\u2026\n ").concat(new Date(block.timestamp).toLocaleString(), "\n ").concat(block.txCount, "\n ").concat(block.proposer, "\n \n "); +} diff --git a/apps/explorer-web/src/pages/blocks.ts b/apps/explorer-web/src/pages/blocks.ts new file mode 100644 index 0000000..6c52fa1 --- /dev/null +++ b/apps/explorer-web/src/pages/blocks.ts @@ -0,0 +1,65 @@ +import { fetchBlocks, type BlockSummary } from "../lib/mockData"; + +export const blocksTitle = "Blocks"; + +export function renderBlocksPage(): string { + return ` +
+
+

Recent Blocks

+

This view lists blocks pulled from the coordinator or blockchain node (mock data shown for now).

+
+ + + + + + + + + + + + + + + +
HeightBlock HashTimestampTx CountProposer
Loading blocks…
+
+ `; +} + +export async function initBlocksPage(): Promise { + const tbody = document.querySelector( + "#blocks-table-body", + ); + if (!tbody) { + return; + } + + const blocks = await fetchBlocks(); + if (blocks.length === 0) { + tbody.innerHTML = ` + + No mock blocks available. + + `; + return; + } + + tbody.innerHTML = blocks + .map((block) => renderBlockRow(block)) + .join(""); +} + +function renderBlockRow(block: BlockSummary): string { + return ` + + ${block.height} + ${block.hash.slice(0, 18)}… + ${new Date(block.timestamp).toLocaleString()} + ${block.txCount} + ${block.proposer} + + `; +} diff --git a/apps/explorer-web/src/pages/overview.js b/apps/explorer-web/src/pages/overview.js new file mode 100644 index 0000000..d65fbcc --- /dev/null +++ b/apps/explorer-web/src/pages/overview.js @@ -0,0 +1,93 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.overviewTitle = void 0; +exports.renderOverviewPage = renderOverviewPage; +exports.initOverviewPage = initOverviewPage; +var mockData_1 = require("../lib/mockData"); +exports.overviewTitle = "Network Overview"; +function renderOverviewPage() { + return "\n
\n

High-level summaries of recent blocks, transactions, and receipts will appear here.

\n
\n
\n

Latest Block

\n
    \n
  • Loading block data\u2026
  • \n
\n
\n
\n

Recent Transactions

\n
    \n
  • Loading transaction data\u2026
  • \n
\n
\n
\n

Receipt Metrics

\n
    \n
  • Loading receipt data\u2026
  • \n
\n
\n
\n
\n "; +} +function initOverviewPage() { + return __awaiter(this, void 0, void 0, function () { + var _a, blocks, transactions, receipts, blockStats, latest, txStats, succeeded, receiptStats, attested; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, Promise.all([ + (0, mockData_1.fetchBlocks)(), + (0, mockData_1.fetchTransactions)(), + (0, mockData_1.fetchReceipts)(), + ])]; + case 1: + _a = _b.sent(), blocks = _a[0], transactions = _a[1], receipts = _a[2]; + blockStats = document.querySelector("#overview-block-stats"); + if (blockStats) { + if (blocks.length > 0) { + latest = blocks[0]; + blockStats.innerHTML = "\n
  • Height: ".concat(latest.height, "
  • \n
  • Hash: ").concat(latest.hash.slice(0, 18), "\u2026
  • \n
  • Proposer: ").concat(latest.proposer, "
  • \n
  • Time: ").concat(new Date(latest.timestamp).toLocaleString(), "
  • \n "); + } + else { + blockStats.innerHTML = "
  • No mock block data available.
  • "; + } + } + txStats = document.querySelector("#overview-transaction-stats"); + if (txStats) { + if (transactions.length > 0) { + succeeded = transactions.filter(function (tx) { return tx.status === "Succeeded"; }); + txStats.innerHTML = "\n
  • Total Mock Tx: ".concat(transactions.length, "
  • \n
  • Succeeded: ").concat(succeeded.length, "
  • \n
  • Pending: ").concat(transactions.length - succeeded.length, "
  • \n "); + } + else { + txStats.innerHTML = "
  • No mock transaction data available.
  • "; + } + } + receiptStats = document.querySelector("#overview-receipt-stats"); + if (receiptStats) { + if (receipts.length > 0) { + attested = receipts.filter(function (receipt) { return receipt.status === "Attested"; }); + receiptStats.innerHTML = "\n
  • Total Receipts: ".concat(receipts.length, "
  • \n
  • Attested: ").concat(attested.length, "
  • \n
  • Pending: ").concat(receipts.length - attested.length, "
  • \n "); + } + else { + receiptStats.innerHTML = "
  • No mock receipt data available.
  • "; + } + } + return [2 /*return*/]; + } + }); + }); +} diff --git a/apps/explorer-web/src/pages/overview.ts b/apps/explorer-web/src/pages/overview.ts new file mode 100644 index 0000000..a00c82d --- /dev/null +++ b/apps/explorer-web/src/pages/overview.ts @@ -0,0 +1,92 @@ +import { + fetchBlocks, + fetchTransactions, + fetchReceipts, +} from "../lib/mockData"; + +export const overviewTitle = "Network Overview"; + +export function renderOverviewPage(): string { + return ` +
    +

    High-level summaries of recent blocks, transactions, and receipts will appear here.

    +
    +
    +

    Latest Block

    +
      +
    • Loading block data…
    • +
    +
    +
    +

    Recent Transactions

    +
      +
    • Loading transaction data…
    • +
    +
    +
    +

    Receipt Metrics

    +
      +
    • Loading receipt data…
    • +
    +
    +
    +
    + `; +} + +export async function initOverviewPage(): Promise { + const [blocks, transactions, receipts] = await Promise.all([ + fetchBlocks(), + fetchTransactions(), + fetchReceipts(), + ]); + + const blockStats = document.querySelector( + "#overview-block-stats", + ); + if (blockStats) { + if (blocks.length > 0) { + const latest = blocks[0]; + blockStats.innerHTML = ` +
  • Height: ${latest.height}
  • +
  • Hash: ${latest.hash.slice(0, 18)}…
  • +
  • Proposer: ${latest.proposer}
  • +
  • Time: ${new Date(latest.timestamp).toLocaleString()}
  • + `; + } else { + blockStats.innerHTML = `
  • No mock block data available.
  • `; + } + } + + const txStats = document.querySelector( + "#overview-transaction-stats", + ); + if (txStats) { + if (transactions.length > 0) { + const succeeded = transactions.filter((tx) => tx.status === "Succeeded"); + txStats.innerHTML = ` +
  • Total Mock Tx: ${transactions.length}
  • +
  • Succeeded: ${succeeded.length}
  • +
  • Pending: ${transactions.length - succeeded.length}
  • + `; + } else { + txStats.innerHTML = `
  • No mock transaction data available.
  • `; + } + } + + const receiptStats = document.querySelector( + "#overview-receipt-stats", + ); + if (receiptStats) { + if (receipts.length > 0) { + const attested = receipts.filter((receipt) => receipt.status === "Attested"); + receiptStats.innerHTML = ` +
  • Total Receipts: ${receipts.length}
  • +
  • Attested: ${attested.length}
  • +
  • Pending: ${receipts.length - attested.length}
  • + `; + } else { + receiptStats.innerHTML = `
  • No mock receipt data available.
  • `; + } + } +} diff --git a/apps/explorer-web/src/pages/receipts.js b/apps/explorer-web/src/pages/receipts.js new file mode 100644 index 0000000..f44a330 --- /dev/null +++ b/apps/explorer-web/src/pages/receipts.js @@ -0,0 +1,72 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.receiptsTitle = void 0; +exports.renderReceiptsPage = renderReceiptsPage; +exports.initReceiptsPage = initReceiptsPage; +var mockData_1 = require("../lib/mockData"); +exports.receiptsTitle = "Receipts"; +function renderReceiptsPage() { + return "\n
    \n
    \n

    Receipt History

    \n

    Mock receipts from the coordinator history are displayed below; live lookup will arrive with API wiring.

    \n
    \n
    \n \n
    \n \n \n
    \n

    Receipt lookup will be enabled after wiring to /v1/jobs/{job_id}/receipts.

    \n
    \n
    \n

    Recent Receipts

    \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    Job IDReceipt IDMinerCoordinatorIssuedStatus
    Loading receipts\u2026
    \n
    \n
    \n "; +} +function initReceiptsPage() { + return __awaiter(this, void 0, void 0, function () { + var tbody, receipts; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + tbody = document.querySelector("#receipts-table-body"); + if (!tbody) { + return [2 /*return*/]; + } + return [4 /*yield*/, (0, mockData_1.fetchReceipts)()]; + case 1: + receipts = _a.sent(); + if (receipts.length === 0) { + tbody.innerHTML = "\n \n No mock receipts available.\n \n "; + return [2 /*return*/]; + } + tbody.innerHTML = receipts.map(renderReceiptRow).join(""); + return [2 /*return*/]; + } + }); + }); +} +function renderReceiptRow(receipt) { + return "\n \n ".concat(receipt.jobId, "\n ").concat(receipt.receiptId, "\n ").concat(receipt.miner, "\n ").concat(receipt.coordinator, "\n ").concat(new Date(receipt.issuedAt).toLocaleString(), "\n ").concat(receipt.status, "\n \n "); +} diff --git a/apps/explorer-web/src/pages/receipts.ts b/apps/explorer-web/src/pages/receipts.ts new file mode 100644 index 0000000..6bb7f70 --- /dev/null +++ b/apps/explorer-web/src/pages/receipts.ts @@ -0,0 +1,76 @@ +import { fetchReceipts, type ReceiptSummary } from "../lib/mockData"; + +export const receiptsTitle = "Receipts"; + +export function renderReceiptsPage(): string { + return ` +
    +
    +

    Receipt History

    +

    Mock receipts from the coordinator history are displayed below; live lookup will arrive with API wiring.

    +
    +
    + +
    + + +
    +

    Receipt lookup will be enabled after wiring to /v1/jobs/{job_id}/receipts.

    +
    +
    +

    Recent Receipts

    + + + + + + + + + + + + + + + + +
    Job IDReceipt IDMinerCoordinatorIssuedStatus
    Loading receipts…
    +
    +
    + `; +} + +export async function initReceiptsPage(): Promise { + const tbody = document.querySelector( + "#receipts-table-body", + ); + if (!tbody) { + return; + } + + const receipts = await fetchReceipts(); + if (receipts.length === 0) { + tbody.innerHTML = ` + + No mock receipts available. + + `; + return; + } + + tbody.innerHTML = receipts.map(renderReceiptRow).join(""); +} + +function renderReceiptRow(receipt: ReceiptSummary): string { + return ` + + ${receipt.jobId} + ${receipt.receiptId} + ${receipt.miner} + ${receipt.coordinator} + ${new Date(receipt.issuedAt).toLocaleString()} + ${receipt.status} + + `; +} diff --git a/apps/explorer-web/src/pages/transactions.js b/apps/explorer-web/src/pages/transactions.js new file mode 100644 index 0000000..244fa32 --- /dev/null +++ b/apps/explorer-web/src/pages/transactions.js @@ -0,0 +1,72 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.transactionsTitle = void 0; +exports.renderTransactionsPage = renderTransactionsPage; +exports.initTransactionsPage = initTransactionsPage; +var mockData_1 = require("../lib/mockData"); +exports.transactionsTitle = "Transactions"; +function renderTransactionsPage() { + return "\n
    \n
    \n

    Recent Transactions

    \n

    Mock data is shown below until coordinator or node APIs are wired up.

    \n
    \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    HashBlockFromToValueStatus
    Loading transactions\u2026
    \n
    \n "; +} +function initTransactionsPage() { + return __awaiter(this, void 0, void 0, function () { + var tbody, transactions; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + tbody = document.querySelector("#transactions-table-body"); + if (!tbody) { + return [2 /*return*/]; + } + return [4 /*yield*/, (0, mockData_1.fetchTransactions)()]; + case 1: + transactions = _a.sent(); + if (transactions.length === 0) { + tbody.innerHTML = "\n \n No mock transactions available.\n \n "; + return [2 /*return*/]; + } + tbody.innerHTML = transactions.map(renderTransactionRow).join(""); + return [2 /*return*/]; + } + }); + }); +} +function renderTransactionRow(tx) { + return "\n \n ".concat(tx.hash.slice(0, 18), "\u2026\n ").concat(tx.block, "\n ").concat(tx.from.slice(0, 12), "\u2026\n ").concat(tx.to.slice(0, 12), "\u2026\n ").concat(tx.value, "\n ").concat(tx.status, "\n \n "); +} diff --git a/apps/explorer-web/src/pages/transactions.ts b/apps/explorer-web/src/pages/transactions.ts new file mode 100644 index 0000000..f4fef27 --- /dev/null +++ b/apps/explorer-web/src/pages/transactions.ts @@ -0,0 +1,68 @@ +import { + fetchTransactions, + type TransactionSummary, +} from "../lib/mockData"; + +export const transactionsTitle = "Transactions"; + +export function renderTransactionsPage(): string { + return ` +
    +
    +

    Recent Transactions

    +

    Mock data is shown below until coordinator or node APIs are wired up.

    +
    + + + + + + + + + + + + + + + + +
    HashBlockFromToValueStatus
    Loading transactions…
    +
    + `; +} + +export async function initTransactionsPage(): Promise { + const tbody = document.querySelector( + "#transactions-table-body", + ); + if (!tbody) { + return; + } + + const transactions = await fetchTransactions(); + if (transactions.length === 0) { + tbody.innerHTML = ` + + No mock transactions available. + + `; + return; + } + + tbody.innerHTML = transactions.map(renderTransactionRow).join(""); +} + +function renderTransactionRow(tx: TransactionSummary): string { + return ` + + ${tx.hash.slice(0, 18)}… + ${tx.block} + ${tx.from.slice(0, 12)}… + ${tx.to.slice(0, 12)}… + ${tx.value} + ${tx.status} + + `; +} diff --git a/apps/explorer-web/tsconfig.json b/apps/explorer-web/tsconfig.json new file mode 100644 index 0000000..213d628 --- /dev/null +++ b/apps/explorer-web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "lib": ["ESNext", "DOM"], + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/apps/explorer-web/vite.config.ts b/apps/explorer-web/vite.config.ts new file mode 100644 index 0000000..ba4699c --- /dev/null +++ b/apps/explorer-web/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + server: { + port: 4173, + }, +}); diff --git a/apps/marketplace-web/README.md b/apps/marketplace-web/README.md new file mode 100644 index 0000000..fc601c9 --- /dev/null +++ b/apps/marketplace-web/README.md @@ -0,0 +1,15 @@ +# Marketplace Web + +## Purpose & Scope + +Vite-powered vanilla TypeScript app for listing compute offers, placing bids, and showing market analytics. Follow the implementation blueprint in `docs/bootstrap/marketplace_web.md`. + +## Development Setup + +- Install dependencies with `npm install` once `package.json` is defined. +- Run the dev server via `npm run dev`. +- Build for production with `npm run build` and preview using `npm run preview`. + +## Notes + +Works against mock API responses initially; switch to real coordinator/pool-hub endpoints later via `VITE_API_BASE`. diff --git a/apps/miner-node/README.md b/apps/miner-node/README.md new file mode 100644 index 0000000..deecb64 --- /dev/null +++ b/apps/miner-node/README.md @@ -0,0 +1,27 @@ +# Miner Node + +## Purpose & Scope + +Worker daemon responsible for executing compute jobs on CPU/GPU hardware, reporting telemetry, and submitting proofs back to the coordinator. See `docs/bootstrap/miner_node.md` for the detailed implementation roadmap. + +## Development Setup + +- Create a Python virtual environment under `apps/miner-node/.venv`. +- Install dependencies (FastAPI optional for health endpoint, `httpx`, `pydantic`, `psutil`). +- Implement the package structure described in the bootstrap guide. + +## Production Deployment (systemd) + +1. Copy the project to `/opt/aitbc/apps/miner-node/` on the target host. +2. Create a virtual environment and install dependencies as needed. +3. Populate `.env` with coordinator URL/API token settings. +4. Run the installer script from repo root: + ```bash + sudo scripts/ops/install_miner_systemd.sh + ``` + This installs `configs/systemd/aitbc-miner.service`, reloads systemd, and enables the service. +5. Check status/logs: + ```bash + sudo systemctl status aitbc-miner + journalctl -u aitbc-miner -f + ``` diff --git a/apps/miner-node/pyproject.toml b/apps/miner-node/pyproject.toml new file mode 100644 index 0000000..0be65c2 --- /dev/null +++ b/apps/miner-node/pyproject.toml @@ -0,0 +1,30 @@ +[tool.poetry] +name = "aitbc-miner-node" +version = "0.1.0" +description = "AITBC miner node daemon" +authors = ["AITBC Team"] +packages = [ + { include = "aitbc_miner", from = "src" } +] + +[tool.poetry.dependencies] +python = "^3.11" +httpx = "^0.27.0" +pydantic = "^2.7.0" +pyyaml = "^6.0.1" +psutil = "^5.9.8" +aiosignal = "^1.3.1" +uvloop = { version = "^0.19.0", optional = true } +asyncio = { version = "^3.4.3", optional = true } +rich = "^13.7.1" + +[tool.poetry.extras] +uvloop = ["uvloop"] + +[tool.poetry.group.dev.dependencies] +pytest = "^8.2.0" +pytest-asyncio = "^0.23.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/apps/miner-node/src/aitbc_miner/__init__.py b/apps/miner-node/src/aitbc_miner/__init__.py new file mode 100644 index 0000000..dfeb630 --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/__init__.py @@ -0,0 +1 @@ +"""AITBC miner node package.""" diff --git a/apps/miner-node/src/aitbc_miner/agent/__init__.py b/apps/miner-node/src/aitbc_miner/agent/__init__.py new file mode 100644 index 0000000..5a42071 --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/agent/__init__.py @@ -0,0 +1 @@ +"""Control loop and background tasks for the miner node.""" diff --git a/apps/miner-node/src/aitbc_miner/agent/control.py b/apps/miner-node/src/aitbc_miner/agent/control.py new file mode 100644 index 0000000..ba92c8d --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/agent/control.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable +from typing import Optional + +from ..config import settings +from ..logging import get_logger +from ..coordinator import CoordinatorClient +from ..util.probe import collect_capabilities, collect_runtime_metrics +from ..util.backoff import compute_backoff +from ..util.fs import ensure_workspace, write_json +from ..runners import get_runner + +logger = get_logger(__name__) + + +class MinerControlLoop: + def __init__(self) -> None: + self._tasks: list[asyncio.Task[None]] = [] + self._stop_event = asyncio.Event() + self._coordinator = CoordinatorClient() + self._capabilities_snapshot = collect_capabilities(settings.max_concurrent_cpu, settings.max_concurrent_gpu) + self._current_backoff = settings.poll_interval_seconds + + async def start(self) -> None: + logger.info("Starting miner control loop", extra={"node_id": settings.node_id}) + await self._register() + self._tasks.append(asyncio.create_task(self._heartbeat_loop())) + self._tasks.append(asyncio.create_task(self._poll_loop())) + + async def stop(self) -> None: + logger.info("Stopping miner control loop") + self._stop_event.set() + for task in self._tasks: + task.cancel() + await asyncio.gather(*self._tasks, return_exceptions=True) + await self._coordinator.aclose() + + async def _register(self) -> None: + payload = { + "capabilities": self._capabilities_snapshot.capabilities, + "concurrency": self._capabilities_snapshot.concurrency, + "region": settings.region, + } + try: + resp = await self._coordinator.register(payload) + logger.info("Registered miner", extra={"resp": resp}) + except Exception as exc: + logger.exception("Failed to register miner", exc_info=exc) + raise + + async def _heartbeat_loop(self) -> None: + interval = settings.heartbeat_interval_seconds + while not self._stop_event.is_set(): + payload = { + "inflight": 0, + "status": "ONLINE", + "metadata": collect_runtime_metrics(), + } + try: + await self._coordinator.heartbeat(payload) + logger.debug("heartbeat sent") + except Exception as exc: + logger.warning("heartbeat failed", exc_info=exc) + await asyncio.sleep(interval) + + async def _poll_loop(self) -> None: + interval = settings.poll_interval_seconds + while not self._stop_event.is_set(): + payload = {"max_wait_seconds": interval} + try: + job = await self._coordinator.poll(payload) + if job: + logger.info("received job", extra={"job_id": job.get("job_id")}) + self._current_backoff = settings.poll_interval_seconds + await self._handle_job(job) + else: + interval = min(compute_backoff(interval, 2.0, settings.heartbeat_jitter_pct, settings.max_backoff_seconds), settings.max_backoff_seconds) + logger.debug("no job; next poll interval=%s", interval) + except Exception as exc: + logger.warning("poll failed", exc_info=exc) + interval = min(compute_backoff(interval, 2.0, settings.heartbeat_jitter_pct, settings.max_backoff_seconds), settings.max_backoff_seconds) + await asyncio.sleep(interval) + + async def _handle_job(self, job: dict) -> None: + job_id = job.get("job_id", "unknown") + workspace = ensure_workspace(settings.workspace_root, job_id) + runner_kind = job.get("runner", {}).get("kind", "noop") + runner = get_runner(runner_kind) + + try: + result = await runner.run(job, workspace) + except Exception as exc: + logger.exception("runner crashed", extra={"job_id": job_id, "runner": runner_kind}) + await self._coordinator.submit_failure( + job_id, + { + "error_code": "RUNTIME_ERROR", + "error_message": str(exc), + "metrics": {}, + }, + ) + return + + if result.ok: + write_json(workspace / "result.json", result.output) + try: + await self._coordinator.submit_result( + job_id, + { + "result": result.output, + "metrics": {"workspace": str(workspace)}, + }, + ) + except Exception as exc: + logger.warning("failed to submit result", extra={"job_id": job_id}, exc_info=exc) + else: + await self._coordinator.submit_failure( + job_id, + { + "error_code": result.output.get("error_code", "FAILED"), + "error_message": result.output.get("error_message", "Job failed"), + "metrics": result.output.get("metrics", {}), + }, + ) diff --git a/apps/miner-node/src/aitbc_miner/config.py b/apps/miner-node/src/aitbc_miner/config.py new file mode 100644 index 0000000..ebfea2b --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/config.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class MinerSettings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False) + + node_id: str = "node-dev-1" + coordinator_base_url: str = "http://127.0.0.1:8011/v1" + auth_token: str = "miner_dev_key_1" + region: Optional[str] = None + + workspace_root: Path = Field(default=Path("/var/lib/aitbc/miner/jobs")) + cache_root: Path = Field(default=Path("/var/lib/aitbc/miner/cache")) + + heartbeat_interval_seconds: int = 15 + heartbeat_jitter_pct: int = 10 + heartbeat_timeout_seconds: int = 60 + + poll_interval_seconds: int = 3 + max_backoff_seconds: int = 60 + + max_concurrent_cpu: int = 1 + max_concurrent_gpu: int = 1 + + enable_cli_runner: bool = True + enable_python_runner: bool = True + + allowlist_dir: Path = Field(default=Path("/etc/aitbc/miner/allowlist.d")) + + log_level: str = "INFO" + log_path: Optional[Path] = None + + +settings = MinerSettings() diff --git a/apps/miner-node/src/aitbc_miner/coordinator.py b/apps/miner-node/src/aitbc_miner/coordinator.py new file mode 100644 index 0000000..dcc1f21 --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/coordinator.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Dict, Optional + +import httpx + +from .config import MinerSettings, settings +from .logging import get_logger + +logger = get_logger(__name__) + + +class CoordinatorClient: + """Async HTTP client for interacting with the coordinator API.""" + + def __init__(self, cfg: MinerSettings | None = None) -> None: + self.cfg = cfg or settings + self._client: Optional[httpx.AsyncClient] = None + + @property + def client(self) -> httpx.AsyncClient: + if self._client is None: + headers = { + "Authorization": f"Bearer {self.cfg.auth_token}", + "User-Agent": f"aitbc-miner/{self.cfg.node_id}", + } + timeout = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=None) + self._client = httpx.AsyncClient(base_url=self.cfg.coordinator_base_url.rstrip("/"), headers=headers, timeout=timeout) + return self._client + + async def aclose(self) -> None: + if self._client: + await self._client.aclose() + self._client = None + + async def register(self, payload: Dict[str, Any]) -> Dict[str, Any]: + logger.debug("registering miner", extra={"payload": payload}) + resp = await self.client.post("/miners/register", json=payload) + resp.raise_for_status() + return resp.json() + + async def heartbeat(self, payload: Dict[str, Any]) -> Dict[str, Any]: + resp = await self.client.post("/miners/heartbeat", json=payload) + resp.raise_for_status() + return resp.json() + + async def poll(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + resp = await self.client.post("/miners/poll", json=payload) + if resp.status_code == 204: + logger.debug("no job available") + return None + resp.raise_for_status() + return resp.json() + + async def submit_result(self, job_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: + resp = await self.client.post(f"/miners/{job_id}/result", json=payload) + resp.raise_for_status() + return resp.json() + + async def submit_failure(self, job_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: + resp = await self.client.post(f"/miners/{job_id}/fail", json=payload) + resp.raise_for_status() + return resp.json() + + async def __aenter__(self) -> "CoordinatorClient": + _ = self.client + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + +async def backoff(base: float, max_seconds: float) -> float: + await asyncio.sleep(base) + return min(base * 2, max_seconds) diff --git a/apps/miner-node/src/aitbc_miner/logging.py b/apps/miner-node/src/aitbc_miner/logging.py new file mode 100644 index 0000000..6ef0b99 --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/logging.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import logging +from typing import Optional + +from .config import settings + + +def configure_logging(level: Optional[str] = None, log_path: Optional[str] = None) -> None: + log_level = getattr(logging, (level or settings.log_level).upper(), logging.INFO) + handlers: list[logging.Handler] = [logging.StreamHandler()] + if log_path: + handlers.append(logging.FileHandler(log_path)) + + logging.basicConfig( + level=log_level, + format="%(asctime)s %(levelname)s %(name)s :: %(message)s", + handlers=handlers, + ) + + +def get_logger(name: str) -> logging.Logger: + if not logging.getLogger().handlers: + configure_logging(settings.log_level, settings.log_path.as_posix() if settings.log_path else None) + return logging.getLogger(name) diff --git a/apps/miner-node/src/aitbc_miner/main.py b/apps/miner-node/src/aitbc_miner/main.py new file mode 100644 index 0000000..2d04575 --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/main.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import asyncio +import signal +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from .config import settings +from .logging import get_logger + +logger = get_logger(__name__) + + +class MinerApplication: + def __init__(self) -> None: + self._stop_event = asyncio.Event() + + async def start(self) -> None: + logger.info("Miner node starting", extra={"node_id": settings.node_id}) + # TODO: initialize capability probe, register with coordinator, start heartbeat and poll loops + await self._stop_event.wait() + + async def stop(self) -> None: + logger.info("Miner node shutting down") + self._stop_event.set() + + +@asynccontextmanager +async def miner_app() -> AsyncIterator[MinerApplication]: + app = MinerApplication() + try: + yield app + finally: + await app.stop() + + +def run() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def _run() -> None: + async with miner_app() as app: + loop.add_signal_handler(signal.SIGINT, lambda: asyncio.create_task(app.stop())) + loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.create_task(app.stop())) + await app.start() + + loop.run_until_complete(_run()) + + +if __name__ == "__main__": # pragma: no cover + run() diff --git a/apps/miner-node/src/aitbc_miner/runners/__init__.py b/apps/miner-node/src/aitbc_miner/runners/__init__.py new file mode 100644 index 0000000..e3f4d75 --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/runners/__init__.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Dict + +from .base import BaseRunner +from .cli.simple import CLIRunner +from .python.noop import PythonNoopRunner + + +_RUNNERS: Dict[str, BaseRunner] = { + "cli": CLIRunner(), + "python": PythonNoopRunner(), + "noop": PythonNoopRunner(), +} + + +def get_runner(kind: str) -> BaseRunner: + return _RUNNERS.get(kind, _RUNNERS["noop"]) diff --git a/apps/miner-node/src/aitbc_miner/runners/base.py b/apps/miner-node/src/aitbc_miner/runners/base.py new file mode 100644 index 0000000..7fa3ecf --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/runners/base.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict + + +@dataclass +class RunnerResult: + ok: bool + output: Dict[str, Any] + artifacts: Dict[str, Path] | None = None + + +class BaseRunner: + async def run(self, job: Dict[str, Any], workspace: Path) -> RunnerResult: + raise NotImplementedError diff --git a/apps/miner-node/src/aitbc_miner/runners/cli/simple.py b/apps/miner-node/src/aitbc_miner/runners/cli/simple.py new file mode 100644 index 0000000..b2eab84 --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/runners/cli/simple.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any, Dict, List + +from ..base import BaseRunner, RunnerResult + + +class CLIRunner(BaseRunner): + async def run(self, job: Dict[str, Any], workspace: Path) -> RunnerResult: + runner_cfg = job.get("runner", {}) + command: List[str] = runner_cfg.get("command", []) + if not command: + return RunnerResult( + ok=False, + output={ + "error_code": "INVALID_COMMAND", + "error_message": "runner.command is required for CLI jobs", + "metrics": {}, + }, + ) + + stdout_path = workspace / "stdout.log" + stderr_path = workspace / "stderr.log" + + process = await asyncio.create_subprocess_exec( + *command, + cwd=str(workspace), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + stdout_bytes, stderr_bytes = await process.communicate() + stdout_path.write_bytes(stdout_bytes) + stderr_path.write_bytes(stderr_bytes) + + if process.returncode == 0: + return RunnerResult( + ok=True, + output={ + "exit_code": 0, + "stdout": stdout_path.name, + "stderr": stderr_path.name, + }, + artifacts={ + "stdout": stdout_path, + "stderr": stderr_path, + }, + ) + + return RunnerResult( + ok=False, + output={ + "error_code": "PROCESS_FAILED", + "error_message": f"command exited with code {process.returncode}", + "metrics": { + "exit_code": process.returncode, + "stderr": stderr_path.name, + }, + }, + ) diff --git a/apps/miner-node/src/aitbc_miner/runners/python/noop.py b/apps/miner-node/src/aitbc_miner/runners/python/noop.py new file mode 100644 index 0000000..b8aaa33 --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/runners/python/noop.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any, Dict + +from ..base import BaseRunner, RunnerResult + + +class PythonNoopRunner(BaseRunner): + async def run(self, job: Dict[str, Any], workspace: Path) -> RunnerResult: + await asyncio.sleep(0) + payload = job.get("payload", {}) + return RunnerResult( + ok=True, + output={ + "echo": payload, + "message": "python noop runner executed", + }, + ) diff --git a/apps/miner-node/src/aitbc_miner/util/backoff.py b/apps/miner-node/src/aitbc_miner/util/backoff.py new file mode 100644 index 0000000..96fc56c --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/util/backoff.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import asyncio +import random + + +def compute_backoff(base: float, factor: float, jitter_pct: float, max_seconds: float) -> float: + backoff = min(base * factor, max_seconds) + jitter = backoff * (jitter_pct / 100.0) + return max(0.0, random.uniform(backoff - jitter, backoff + jitter)) + + +def next_backoff(current: float, factor: float, jitter_pct: float, max_seconds: float) -> float: + return compute_backoff(current, factor, jitter_pct, max_seconds) + + +async def sleep_with_backoff(delay: float, factor: float, jitter_pct: float, max_seconds: float) -> float: + await asyncio.sleep(delay) + return next_backoff(delay, factor, jitter_pct, max_seconds) diff --git a/apps/miner-node/src/aitbc_miner/util/fs.py b/apps/miner-node/src/aitbc_miner/util/fs.py new file mode 100644 index 0000000..07e5d63 --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/util/fs.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + + +def ensure_workspace(root: Path, job_id: str) -> Path: + path = root / job_id + path.mkdir(parents=True, exist_ok=True) + return path + + +def write_json(path: Path, data: dict) -> None: + import json + + path.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/apps/miner-node/src/aitbc_miner/util/probe.py b/apps/miner-node/src/aitbc_miner/util/probe.py new file mode 100644 index 0000000..f44077b --- /dev/null +++ b/apps/miner-node/src/aitbc_miner/util/probe.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import platform +import shutil +import subprocess +import time +from dataclasses import dataclass +from typing import Any, Dict, List + +import psutil + + +@dataclass +class CapabilitySnapshot: + capabilities: Dict[str, Any] + concurrency: int + region: str | None = None + + +def collect_capabilities(max_cpu_concurrency: int, max_gpu_concurrency: int) -> CapabilitySnapshot: + cpu_count = psutil.cpu_count(logical=True) or 1 + total_mem = psutil.virtual_memory().total + gpu_info = _detect_gpus() + + capabilities: Dict[str, Any] = { + "node": platform.node(), + "python_version": platform.python_version(), + "platform": platform.platform(), + "cpu": { + "logical_cores": cpu_count, + "model": platform.processor(), + }, + "memory": { + "total_bytes": total_mem, + "total_gb": round(total_mem / (1024**3), 2), + }, + "runners": { + "cli": True, + "python": True, + }, + } + + if gpu_info: + capabilities["gpus"] = gpu_info + + concurrency = max(1, max_cpu_concurrency, max_gpu_concurrency if gpu_info else 0) + return CapabilitySnapshot(capabilities=capabilities, concurrency=concurrency) + + +def collect_runtime_metrics() -> Dict[str, Any]: + vm = psutil.virtual_memory() + load_avg = psutil.getloadavg() if hasattr(psutil, "getloadavg") else (0, 0, 0) + return { + "cpu_percent": psutil.cpu_percent(interval=None), + "load_avg": load_avg, + "memory_percent": vm.percent, + "timestamp": time.time(), + } + + +def _detect_gpus() -> List[Dict[str, Any]]: + nvidia_smi = shutil.which("nvidia-smi") + if not nvidia_smi: + return [] + try: + output = subprocess.check_output( + [ + nvidia_smi, + "--query-gpu=name,memory.total", + "--format=csv,noheader" + ], + stderr=subprocess.DEVNULL, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return [] + + gpus: List[Dict[str, Any]] = [] + for line in output.strip().splitlines(): + parts = [p.strip() for p in line.split(",")] + if not parts: + continue + name = parts[0] + mem_mb = None + if len(parts) > 1 and parts[1].lower().endswith(" mib"): + try: + mem_mb = int(float(parts[1].split()[0])) + except ValueError: + mem_mb = None + gpus.append({"name": name, "memory_mb": mem_mb}) + return gpus diff --git a/apps/miner-node/tests/test_runners.py b/apps/miner-node/tests/test_runners.py new file mode 100644 index 0000000..f013495 --- /dev/null +++ b/apps/miner-node/tests/test_runners.py @@ -0,0 +1,37 @@ +import asyncio +from pathlib import Path + +import pytest + +from aitbc_miner.runners.cli.simple import CLIRunner +from aitbc_miner.runners.python.noop import PythonNoopRunner + + +@pytest.mark.asyncio +async def test_python_noop_runner(tmp_path: Path): + runner = PythonNoopRunner() + job = {"payload": {"value": 42}} + result = await runner.run(job, tmp_path) + assert result.ok + assert result.output["echo"] == job["payload"] + + +@pytest.mark.asyncio +async def test_cli_runner_success(tmp_path: Path): + runner = CLIRunner() + job = {"runner": {"command": ["echo", "hello"]}} + result = await runner.run(job, tmp_path) + assert result.ok + assert result.artifacts is not None + stdout_path = result.artifacts["stdout"] + assert stdout_path.exists() + assert stdout_path.read_text().strip() == "hello" + + +@pytest.mark.asyncio +async def test_cli_runner_invalid_command(tmp_path: Path): + runner = CLIRunner() + job = {"runner": {}} + result = await runner.run(job, tmp_path) + assert not result.ok + assert result.output["error_code"] == "INVALID_COMMAND" diff --git a/apps/pool-hub/README.md b/apps/pool-hub/README.md new file mode 100644 index 0000000..96e8389 --- /dev/null +++ b/apps/pool-hub/README.md @@ -0,0 +1,11 @@ +# Pool Hub + +## Purpose & Scope + +Matchmaking gateway between coordinator job requests and available miners. See `docs/bootstrap/pool_hub.md` for architectural guidance. + +## Development Setup + +- Create a Python virtual environment under `apps/pool-hub/.venv`. +- Install FastAPI, Redis (optional), and PostgreSQL client dependencies once requirements are defined. +- Implement routers and registry as described in the bootstrap document. diff --git a/apps/wallet-daemon/README.md b/apps/wallet-daemon/README.md new file mode 100644 index 0000000..5aadabb --- /dev/null +++ b/apps/wallet-daemon/README.md @@ -0,0 +1,32 @@ +# Wallet Daemon + +## Purpose & Scope + +Local FastAPI service that manages encrypted keys, signs transactions/receipts, and exposes wallet RPC endpoints. Reference `docs/bootstrap/wallet_daemon.md` for the implementation plan. + +## Development Setup + +- Create a Python virtual environment under `apps/wallet-daemon/.venv` or use Poetry. +- Install dependencies via Poetry (preferred): + ```bash + poetry install + ``` +- Copy/create `.env` and configure coordinator access: + ```bash + cp .env.example .env # create file if missing + ``` + - `COORDINATOR_BASE_URL` (default `http://localhost:8011`) + - `COORDINATOR_API_KEY` (development key to verify receipts) +- Run the service locally: + ```bash + poetry run uvicorn app.main:app --host 0.0.0.0 --port 8071 --reload + ``` +- REST receipt endpoints: + - `GET /v1/receipts/{job_id}` (latest receipt + signature validations) + - `GET /v1/receipts/{job_id}/history` (full history + validations) +- JSON-RPC interface (`POST /rpc`): + - Method `receipts.verify_latest` + - Method `receipts.verify_history` +- Keystore scaffolding: + - `KeystoreService` uses Argon2id + XChaCha20-Poly1305 via `app/crypto/encryption.py` (in-memory for now). + - Future milestones will add persistent storage and wallet lifecycle routes. diff --git a/apps/wallet-daemon/src/app/__init__.py b/apps/wallet-daemon/src/app/__init__.py new file mode 100644 index 0000000..a9615d5 --- /dev/null +++ b/apps/wallet-daemon/src/app/__init__.py @@ -0,0 +1,5 @@ +"""Wallet daemon FastAPI application package.""" + +from .main import create_app + +__all__ = ["create_app"] diff --git a/apps/wallet-daemon/src/app/api_jsonrpc.py b/apps/wallet-daemon/src/app/api_jsonrpc.py new file mode 100644 index 0000000..41dc2f0 --- /dev/null +++ b/apps/wallet-daemon/src/app/api_jsonrpc.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends + +from .deps import get_receipt_service +from .models import ReceiptVerificationModel, from_validation_result +from .receipts.service import ReceiptVerifierService + +router = APIRouter(tags=["jsonrpc"]) + + +def _response(result: Optional[Dict[str, Any]] = None, error: Optional[Dict[str, Any]] = None, *, request_id: Any = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"jsonrpc": "2.0", "id": request_id} + if error is not None: + payload["error"] = error + else: + payload["result"] = result + return payload + + +@router.post("/rpc", summary="JSON-RPC endpoint") +def handle_jsonrpc( + request: Dict[str, Any], + service: ReceiptVerifierService = Depends(get_receipt_service), +) -> Dict[str, Any]: + method = request.get("method") + params = request.get("params") or {} + request_id = request.get("id") + + if method == "receipts.verify_latest": + job_id = params.get("job_id") + if not job_id: + return _response(error={"code": -32602, "message": "job_id required"}, request_id=request_id) + result = service.verify_latest(str(job_id)) + if result is None: + return _response(error={"code": -32004, "message": "receipt not found"}, request_id=request_id) + model = from_validation_result(result) + return _response(result=model.model_dump(), request_id=request_id) + + if method == "receipts.verify_history": + job_id = params.get("job_id") + if not job_id: + return _response(error={"code": -32602, "message": "job_id required"}, request_id=request_id) + results = [from_validation_result(item).model_dump() for item in service.verify_history(str(job_id))] + return _response(result={"items": results}, request_id=request_id) + + return _response(error={"code": -32601, "message": "Method not found"}, request_id=request_id) diff --git a/apps/wallet-daemon/src/app/api_rest.py b/apps/wallet-daemon/src/app/api_rest.py new file mode 100644 index 0000000..048f277 --- /dev/null +++ b/apps/wallet-daemon/src/app/api_rest.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from .deps import get_receipt_service +from .models import ( + ReceiptVerificationListResponse, + ReceiptVerificationModel, + ReceiptVerifyResponse, + SignatureValidationModel, + from_validation_result, +) +from .receipts.service import ReceiptValidationResult, ReceiptVerifierService + +router = APIRouter(prefix="/v1", tags=["receipts"]) + + +def _result_to_response(result: ReceiptValidationResult) -> ReceiptVerifyResponse: + payload = from_validation_result(result) + return ReceiptVerifyResponse(result=payload) + + +@router.get( + "/receipts/{job_id}", + response_model=ReceiptVerifyResponse, + summary="Verify latest receipt for a job", +) +def verify_latest_receipt( + job_id: str, + service: ReceiptVerifierService = Depends(get_receipt_service), +) -> ReceiptVerifyResponse: + result = service.verify_latest(job_id) + if result is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="receipt not found") + return _result_to_response(result) + + +@router.get( + "/receipts/{job_id}/history", + response_model=ReceiptVerificationListResponse, + summary="Verify all historical receipts for a job", +) +def verify_receipt_history( + job_id: str, + service: ReceiptVerifierService = Depends(get_receipt_service), +) -> ReceiptVerificationListResponse: + results = service.verify_history(job_id) + items = [from_validation_result(result) for result in results] + return ReceiptVerificationListResponse(items=items) diff --git a/apps/wallet-daemon/src/app/crypto/encryption.py b/apps/wallet-daemon/src/app/crypto/encryption.py new file mode 100644 index 0000000..4d787fa --- /dev/null +++ b/apps/wallet-daemon/src/app/crypto/encryption.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from argon2.low_level import Type as Argon2Type, hash_secret_raw +from nacl.bindings import ( + crypto_aead_xchacha20poly1305_ietf_decrypt, + crypto_aead_xchacha20poly1305_ietf_encrypt, +) + + +class EncryptionError(Exception): + """Raised when encryption or decryption fails.""" + + +@dataclass +class EncryptionSuite: + """Argon2id + XChaCha20-Poly1305 helper functions.""" + + salt_bytes: int = 16 + nonce_bytes: int = 24 + key_bytes: int = 32 + argon_time_cost: int = 3 + argon_memory_cost: int = 64 * 1024 # kibibytes + argon_parallelism: int = 2 + + def _derive_key(self, *, password: str, salt: bytes) -> bytes: + password_bytes = password.encode("utf-8") + return hash_secret_raw( + secret=password_bytes, + salt=salt, + time_cost=self.argon_time_cost, + memory_cost=self.argon_memory_cost, + parallelism=self.argon_parallelism, + hash_len=self.key_bytes, + type=Argon2Type.ID, + ) + + def encrypt(self, *, password: str, plaintext: bytes, salt: bytes, nonce: bytes) -> bytes: + key = self._derive_key(password=password, salt=salt) + try: + return crypto_aead_xchacha20poly1305_ietf_encrypt( + message=plaintext, + aad=b"", + nonce=nonce, + key=key, + ) + except Exception as exc: # pragma: no cover + raise EncryptionError("encryption failed") from exc + + def decrypt(self, *, password: str, ciphertext: bytes, salt: bytes, nonce: bytes) -> bytes: + key = self._derive_key(password=password, salt=salt) + try: + return crypto_aead_xchacha20poly1305_ietf_decrypt( + ciphertext=ciphertext, + aad=b"", + nonce=nonce, + key=key, + ) + except Exception as exc: + raise EncryptionError("decryption failed") from exc diff --git a/apps/wallet-daemon/src/app/deps.py b/apps/wallet-daemon/src/app/deps.py new file mode 100644 index 0000000..71f0c34 --- /dev/null +++ b/apps/wallet-daemon/src/app/deps.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from functools import lru_cache + +from fastapi import Depends + +from .keystore.service import KeystoreService +from .receipts.service import ReceiptVerifierService +from .settings import Settings, settings + + +@lru_cache +def get_settings() -> Settings: + return settings + + +def get_receipt_service(config: Settings = Depends(get_settings)) -> ReceiptVerifierService: + return ReceiptVerifierService( + coordinator_url=config.coordinator_base_url, + api_key=config.coordinator_api_key, + ) + + +@lru_cache +def get_keystore() -> KeystoreService: + return KeystoreService() diff --git a/apps/wallet-daemon/src/app/keystore/service.py b/apps/wallet-daemon/src/app/keystore/service.py new file mode 100644 index 0000000..7b8053e --- /dev/null +++ b/apps/wallet-daemon/src/app/keystore/service.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional + +from secrets import token_bytes + +from ..crypto.encryption import EncryptionSuite, EncryptionError + + +@dataclass +class WalletRecord: + wallet_id: str + salt: bytes + nonce: bytes + ciphertext: bytes + metadata: Dict[str, str] + + +class KeystoreService: + """In-memory keystore with Argon2id + XChaCha20-Poly1305 encryption.""" + + def __init__(self, encryption: Optional[EncryptionSuite] = None) -> None: + self._wallets: Dict[str, WalletRecord] = {} + self._encryption = encryption or EncryptionSuite() + + def list_wallets(self) -> List[str]: + return list(self._wallets.keys()) + + def get_wallet(self, wallet_id: str) -> Optional[WalletRecord]: + return self._wallets.get(wallet_id) + + def create_wallet(self, wallet_id: str, password: str, plaintext: bytes, metadata: Optional[Dict[str, str]] = None) -> WalletRecord: + salt = token_bytes(self._encryption.salt_bytes) + nonce = token_bytes(self._encryption.nonce_bytes) + ciphertext = self._encryption.encrypt(password=password, plaintext=plaintext, salt=salt, nonce=nonce) + record = WalletRecord(wallet_id=wallet_id, salt=salt, nonce=nonce, ciphertext=ciphertext, metadata=metadata or {}) + self._wallets[wallet_id] = record + return record + + def unlock_wallet(self, wallet_id: str, password: str) -> bytes: + record = self._wallets.get(wallet_id) + if record is None: + raise KeyError("wallet not found") + try: + return self._encryption.decrypt(password=password, ciphertext=record.ciphertext, salt=record.salt, nonce=record.nonce) + except EncryptionError as exc: + raise ValueError("failed to decrypt wallet") from exc + + def delete_wallet(self, wallet_id: str) -> bool: + return self._wallets.pop(wallet_id, None) is not None diff --git a/apps/wallet-daemon/src/app/main.py b/apps/wallet-daemon/src/app/main.py new file mode 100644 index 0000000..fe5ec34 --- /dev/null +++ b/apps/wallet-daemon/src/app/main.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from fastapi import FastAPI + +from .api_jsonrpc import router as jsonrpc_router +from .api_rest import router as receipts_router +from .settings import settings + + +def create_app() -> FastAPI: + app = FastAPI(title=settings.app_name, debug=settings.debug) + app.include_router(receipts_router) + app.include_router(jsonrpc_router) + return app + + +app = create_app() diff --git a/apps/wallet-daemon/src/app/models/__init__.py b/apps/wallet-daemon/src/app/models/__init__.py new file mode 100644 index 0000000..49151fb --- /dev/null +++ b/apps/wallet-daemon/src/app/models/__init__.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import List + +from aitbc_sdk import SignatureValidation + +from pydantic import BaseModel + + +class SignatureValidationModel(BaseModel): + key_id: str + alg: str = "Ed25519" + valid: bool + + +class ReceiptVerificationModel(BaseModel): + job_id: str + receipt_id: str + miner_signature: SignatureValidationModel + coordinator_attestations: List[SignatureValidationModel] + all_valid: bool + + +class ReceiptVerifyResponse(BaseModel): + result: ReceiptVerificationModel + + +def _signature_to_model(sig: SignatureValidation | SignatureValidationModel) -> SignatureValidationModel: + if isinstance(sig, SignatureValidationModel): + return sig + return SignatureValidationModel(key_id=sig.key_id, alg=sig.algorithm, valid=sig.valid) + + +def from_validation_result(result) -> ReceiptVerificationModel: + return ReceiptVerificationModel( + job_id=result.job_id, + receipt_id=result.receipt_id, + miner_signature=_signature_to_model(result.miner_signature), + coordinator_attestations=[_signature_to_model(att) for att in result.coordinator_attestations], + all_valid=result.all_valid, + ) + + +class ReceiptVerificationListResponse(BaseModel): + items: List[ReceiptVerificationModel] diff --git a/apps/wallet-daemon/src/app/receipts/__init__.py b/apps/wallet-daemon/src/app/receipts/__init__.py new file mode 100644 index 0000000..1da3668 --- /dev/null +++ b/apps/wallet-daemon/src/app/receipts/__init__.py @@ -0,0 +1,5 @@ +"""Receipt verification helpers for the wallet daemon.""" + +from .service import ReceiptValidationResult, ReceiptVerifierService + +__all__ = ["ReceiptValidationResult", "ReceiptVerifierService"] diff --git a/apps/wallet-daemon/src/app/receipts/service.py b/apps/wallet-daemon/src/app/receipts/service.py new file mode 100644 index 0000000..ae82f8d --- /dev/null +++ b/apps/wallet-daemon/src/app/receipts/service.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +from aitbc_sdk import ( + CoordinatorReceiptClient, + ReceiptVerification, + SignatureValidation, + verify_receipt, + verify_receipts, +) + + +@dataclass +class ReceiptValidationResult: + job_id: str + receipt_id: str + receipt: dict + miner_signature: SignatureValidation + coordinator_attestations: List[SignatureValidation] + + @property + def miner_valid(self) -> bool: + return self.miner_signature.valid + + @property + def all_valid(self) -> bool: + return self.miner_signature.valid and all(att.valid for att in self.coordinator_attestations) + + +class ReceiptVerifierService: + """Wraps `aitbc_sdk` receipt verification for wallet daemon workflows.""" + + def __init__(self, coordinator_url: str, api_key: str, timeout: float = 10.0) -> None: + self.client = CoordinatorReceiptClient(coordinator_url, api_key, timeout=timeout) + + def verify_latest(self, job_id: str) -> Optional[ReceiptValidationResult]: + receipt = self.client.fetch_latest(job_id) + if receipt is None: + return None + verification = verify_receipt(receipt) + return self._to_result(verification) + + def verify_history(self, job_id: str) -> List[ReceiptValidationResult]: + receipts = self.client.fetch_history(job_id) + verifications = verify_receipts(receipts) + return [self._to_result(item) for item in verifications] + + @staticmethod + def _to_result(verification: ReceiptVerification) -> ReceiptValidationResult: + return ReceiptValidationResult( + job_id=str(verification.receipt.get("job_id")), + receipt_id=str(verification.receipt.get("receipt_id")), + receipt=verification.receipt, + miner_signature=verification.miner_signature, + coordinator_attestations=list(verification.coordinator_attestations), + ) diff --git a/apps/wallet-daemon/src/app/settings.py b/apps/wallet-daemon/src/app/settings.py new file mode 100644 index 0000000..e5f97e4 --- /dev/null +++ b/apps/wallet-daemon/src/app/settings.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Runtime configuration for the wallet daemon service.""" + + app_name: str = Field(default="AITBC Wallet Daemon") + debug: bool = Field(default=False) + + coordinator_base_url: str = Field(default="http://localhost:8011", alias="COORDINATOR_BASE_URL") + coordinator_api_key: str = Field(default="client_dev_key_1", alias="COORDINATOR_API_KEY") + + rest_prefix: str = Field(default="/v1", alias="REST_PREFIX") + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() diff --git a/apps/wallet-daemon/tests/test_receipts.py b/apps/wallet-daemon/tests/test_receipts.py new file mode 100644 index 0000000..b808f22 --- /dev/null +++ b/apps/wallet-daemon/tests/test_receipts.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import pytest +from nacl.signing import SigningKey + +from app.receipts import ReceiptValidationResult, ReceiptVerifierService + + +@pytest.fixture() +def sample_receipt() -> dict: + return { + "version": "1.0", + "receipt_id": "rcpt-1", + "job_id": "job-123", + "provider": "miner-abc", + "client": "client-xyz", + "units": 1.0, + "unit_type": "gpu_seconds", + "price": 3.5, + "started_at": 1700000000, + "completed_at": 1700000005, + "metadata": {}, + } + + +class _DummyClient: + def __init__(self, latest=None, history=None): + self.latest = latest + self.history = history or [] + + def fetch_latest(self, job_id: str): + return self.latest + + def fetch_history(self, job_id: str): + return list(self.history) + + +@pytest.fixture() +def signer(): + return SigningKey.generate() + + +@pytest.fixture() +def signed_receipt(sample_receipt: dict, signer: SigningKey) -> dict: + from aitbc_crypto.signing import ReceiptSigner + + receipt = dict(sample_receipt) + receipt["signature"] = ReceiptSigner(signer.encode()).sign(sample_receipt) + return receipt + + +def test_verify_latest_success(monkeypatch, signed_receipt: dict): + service = ReceiptVerifierService("http://coordinator", "api-key") + client = _DummyClient(latest=signed_receipt) + monkeypatch.setattr(service, "client", client) + + result = service.verify_latest("job-123") + assert isinstance(result, ReceiptValidationResult) + assert result.job_id == "job-123" + assert result.receipt_id == "rcpt-1" + assert result.miner_valid is True + assert result.all_valid is True + + +def test_verify_latest_none(monkeypatch): + service = ReceiptVerifierService("http://coordinator", "api-key") + client = _DummyClient(latest=None) + monkeypatch.setattr(service, "client", client) + + assert service.verify_latest("job-123") is None + + +def test_verify_history(monkeypatch, signed_receipt: dict): + service = ReceiptVerifierService("http://coordinator", "api-key") + client = _DummyClient(history=[signed_receipt]) + monkeypatch.setattr(service, "client", client) + + results = service.verify_history("job-123") + assert len(results) == 1 + assert results[0].miner_valid is True + assert results[0].job_id == "job-123" diff --git a/configs/systemd/aitbc-miner.service b/configs/systemd/aitbc-miner.service new file mode 100644 index 0000000..32696bd --- /dev/null +++ b/configs/systemd/aitbc-miner.service @@ -0,0 +1,25 @@ +[Unit] +Description=AITBC Miner Node +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=aitbc +Group=aitbc +WorkingDirectory=/opt/aitbc/apps/miner-node +EnvironmentFile=/opt/aitbc/apps/miner-node/.env +ExecStart=/opt/aitbc/apps/miner-node/.venv/bin/python -m aitbc_miner.main +Restart=always +RestartSec=3 +Nice=5 +IOSchedulingClass=best-effort +IOSchedulingPriority=6 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=/opt/aitbc/apps/miner-node /var/log/aitbc + +[Install] +WantedBy=multi-user.target diff --git a/docs/blockchain_node.md b/docs/blockchain_node.md new file mode 100644 index 0000000..e5b6639 --- /dev/null +++ b/docs/blockchain_node.md @@ -0,0 +1,41 @@ +# Blockchain Node – Task Breakdown + +## Status (2025-09-27) + +- **Stage 1**: Design and scaffolding remain TODO; no implementation committed yet. Coordinator receipts now include historical persistence and attestations, so blockchain receipt ingestion should align with this schema when development begins. + + +## Stage 1 (MVP) + +- **Project Scaffolding** + - Create `apps/blockchain-node/src/` module layout (`types.py`, `state.py`, `blocks.py`, `mempool.py`, `consensus.py`, `rpc.py`, `p2p.py`, `receipts.py`, `settings.py`). + - Add `requirements.txt` with FastAPI, SQLModel, websockets, orjson, python-dotenv. + - Provide `.env.example` with `CHAIN_ID`, `DB_PATH`, bind addresses, proposer key. + +- **State & Persistence** + - Implement SQLModel tables for blocks, transactions, accounts, receipts, peers, params. + - Set up database initialization and genesis loading. + - Provide migration or reset script under `scripts/`. + +- **RPC Layer** + - Build FastAPI app exposing `/rpc/*` endpoints (sendTx, getTx, getBlock, getHead, getBalance, submitReceipt, metrics). + - Implement admin endpoints for devnet (`mintFaucet`, `paramSet`, `peers/add`). + +- **Consensus & Block Production** + - Implement PoA proposer loop producing blocks at fixed interval. + - Integrate mempool selection, receipt validation, and block broadcasting. + - Add basic P2P gossip (websocket) for blocks/txs. + +- **Receipts & Minting** + - Wire `receipts.py` to coordinator attestation mock. + - Mint tokens to miners based on compute_units with configurable ratios. + +- **Devnet Tooling** + - Provide `scripts/devnet_up.sh` launching bootstrap node and mocks. + - Document curl commands for faucet, transfer, receipt submission. + +## Stage 2+ + +- Upgrade consensus to compute-backed proof (CBP) with work score weighting. +- Introduce staking/slashing, replace SQLite with PostgreSQL, add snapshots/fast sync. +- Implement light client support and metrics dashboard. diff --git a/docs/bootstrap/aitbc_tech_plan.md b/docs/bootstrap/aitbc_tech_plan.md new file mode 100644 index 0000000..3946887 --- /dev/null +++ b/docs/bootstrap/aitbc_tech_plan.md @@ -0,0 +1,600 @@ +# AITBC – Artificial Intelligence Token Blockchain + +## Overview (Recovered) + +- AITBC couples decentralized blockchain control with asset‑backed value derived from AI computation. +- No pre‑mint: tokens are minted by providers **only after** serving compute; prices are set by providers (can be free at bootstrap). + +## Staged Development Roadmap + +### Stage 1: Client–Server Prototype (no blockchain, no hub) + +- Direct client → server API. +- API‑key auth; local job logging. +- Goal: validate AI service loop and throughput. + +### Stage 2: Blockchain Integration + +- Introduce **AIToken** and minimal smart contracts for minting + accounting. +- Mint = amount of compute successfully served; no premint. + +### Stage 3: AI Pool Hub + +- Hub matches requests to multiple servers (sharding/parallelization), verifies outputs, and accounts contributions. +- Distributes payments/minted tokens proportionally to work. + +### Stage 4: Marketplace + +- Web DEX/market to buy/sell AITokens; price discovery; reputation and SLAs. + +## System Architecture: Actors + +- **Client** – requests AI jobs (e.g., image/video generation). +- **Server/Provider** – runs models (Stable Diffusion, PyTorch, etc.). +- **Blockchain Node** – ledger + minting rules. +- **AI Pool Hub** – orchestration, metering, payouts. + +## Token Minting Logic (Genesis‑less) + +- No tokens at boot. +- Provider advertises price/unit (e.g., 1 AIToken per image or per N GPU‑seconds). +- After successful job → provider mints that amount. Free jobs mint 0. + +--- + +# Stage 1 – Technischer Implementierungsplan (Detail) + +## Ziele + +- Funktionierender End‑to‑End‑Pfad: Prompt → Inferenz → Ergebnis. +- Authentifizierung, Rate‑Limit, Logging. + +## Architektur + +``` +[ Client ] ⇄ HTTP/JSON ⇄ [ FastAPI AI‑Server (GPU) ] +``` + +- Server hostet Inferenz-Endpunkte; Client sendet Aufträge. +- Optional: WebSocket für Streaming‑Logs/Progress. + +## Technologie‑Stack + +- **Server**: Python 3.10+, FastAPI, Uvicorn, PyTorch, diffusers (Stable Diffusion), PIL. +- **Client**: Python CLI (requests / httpx) oder schlankes Web‑UI. +- **Persistenz**: SQLite oder JSON Log; Artefakte auf Disk/S3‑ähnlich. +- **Sicherheit**: API‑Key (env/secret file), CORS policy, Rate‑Limit (slowapi), timeouts. + +## API‑Spezifikation (v0) + +### POST `/v1/generate-image` + +Request JSON: + +```json +{ + "api_key": "", + "prompt": "a futuristic city skyline at night", + "steps": 30, + "guidance": 7.5, + "width": 512, + "height": 512, + "seed": 12345 +} +``` + +Response JSON: + +```json +{ + "status": "ok", + "job_id": "2025-09-26-000123", + "image_base64": "data:image/png;base64,....", + "duration_ms": 2180, + "gpu_seconds": 1.9 +} +``` + +### GET `/v1/health` + +- Rückgabe von `{ "status": "ok", "gpu": "RTX 2060", "model": "SD1.5" }`. + +## Server‑Ablauf (Pseudocode) + +```python +@app.post("/v1/generate-image") +def gen(req: Request): + assert check_api_key(req.api_key) + rate_limit(req.key) + t0 = now() + img = stable_diffusion.generate(prompt=req.prompt, ...) + log_job(user=req.key, gpu_seconds=measure_gpu(), ok=True) + return {"status":"ok", "image_base64": b64(img), "duration_ms": ms_since(t0)} +``` + +## Betriebliche Aspekte + +- **Logging**: strukturierte Logs (JSON) inkl. Prompt‑Hash, Laufzeit, GPU‑Sekunden, Exit‑Code. +- **Observability**: Prometheus‑/OpenTelemetry‑Metriken (req/sec, p95 Latenz, VRAM‑Nutzung). +- **Fehler**: Retry‑Policy (idempotent), Graceful shutdown, Max batch/queue size. +- **Sicherheit**: Input‑Sanitization, Upload‑Limits, tmp‑Verzeichnis säubern. + +## Setup‑Schritte (Linux, NVIDIA RTX 2060) + +```bash +sudo apt update && sudo apt install -y python3-venv git +python3 -m venv venv && source venv/bin/activate +pip install fastapi uvicorn[standard] torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 +pip install diffusers transformers accelerate pillow safetensors xformers slowapi httpx +# Start +uvicorn app:app --host 0.0.0.0 --port 8000 --workers 1 +``` + +## Akzeptanzkriterien + +- `GET /v1/health` liefert GPU/Model‑Infos. +- `POST /v1/generate-image` liefert innerhalb < 5s ein 512×512 PNG (bei RTX 2060, SD‑1.5, \~30 steps). +- Logs enthalten pro Job mindestens: job\_id, duration\_ms, gpu\_seconds, bytes\_out. + +## Nächste Schritte zu Stage 2 + +- Job‑Quittungsschema definieren (hashbare Receipt für On‑Chain‑Mint später). +- Einheit „Compute‑Einheit“ festlegen (z. B. GPU‑Sekunden, Token/Prompt). +- Nonce/Signatur im Request zur späteren On‑Chain‑Verifikation. + +--- + +## Kurze Stage‑2/3/4‑Vorschau (Implementierungsnotizen) + +- **Stage 2 (Blockchain)**: Smart Contract mit `mint(provider, units, receipt_hash)`, Off‑Chain‑Orakel/Attester. +- **Stage 3 (Hub)**: Scheduler (priority, price, reputation), Sharding großer Jobs, Konsistenz‑Checks, Reward‑Split. +- **Stage 4 (Marketplace)**: Orderbook, KYC/Compliance Layer (jurisdictions), Custody‑freie Wallet‑Anbindung. + +## Quellen (Auszug) + +- Ethereum Smart Contracts: [https://ethereum.org/en/smart-contracts/](https://ethereum.org/en/smart-contracts/) +- PoS Überblick: [https://ethereum.org/en/developers/docs/consensus-mechanisms/pos/](https://ethereum.org/en/developers/docs/consensus-mechanisms/pos/) +- PyTorch Deploy: [https://pytorch.org/tutorials/](https://pytorch.org/tutorials/) +- FastAPI Docs: [https://fastapi.tiangolo.com/](https://fastapi.tiangolo.com/) + +--- + +# Stage 1 – Referenz‑Implementierung (Code) + +## `.env` + +``` +API_KEY=CHANGE_ME_SUPERSECRET +MODEL_ID=runwayml/stable-diffusion-v1-5 +BIND_HOST=0.0.0.0 +BIND_PORT=8000 +``` + +## `requirements.txt` + +``` +fastapi +uvicorn[standard] +httpx +pydantic +python-dotenv +slowapi +pillow +torch +torchvision +torchaudio +transformers +diffusers +accelerate +safetensors +xformers +``` + +## `server.py` + +```python +import base64, io, os, time, hashlib +from functools import lru_cache +from typing import Optional +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel, Field +from slowapi import Limiter +from slowapi.util import get_remote_address +from PIL import Image + +load_dotenv() +API_KEY = os.getenv("API_KEY", "CHANGE_ME_SUPERSECRET") +MODEL_ID = os.getenv("MODEL_ID", "runwayml/stable-diffusion-v1-5") + +app = FastAPI(title="AITBC Stage1 Server", version="0.1.0") +limiter = Limiter(key_func=get_remote_address) + +class GenRequest(BaseModel): + api_key: str + prompt: str + steps: int = Field(30, ge=5, le=100) + guidance: float = Field(7.5, ge=0, le=25) + width: int = Field(512, ge=256, le=1024) + height: int = Field(512, ge=256, le=1024) + seed: Optional[int] = None + +@lru_cache(maxsize=1) +def load_pipeline(): + from diffusers import StableDiffusionPipeline + import torch + pipe = StableDiffusionPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.float16, safety_checker=None) + pipe = pipe.to("cuda" if torch.cuda.is_available() else "cpu") + pipe.enable_attention_slicing() + return pipe + +@app.get("/v1/health") +def health(): + gpu = os.getenv("NVIDIA_VISIBLE_DEVICES", "auto") + return {"status": "ok", "gpu": gpu, "model": MODEL_ID} + +@app.post("/v1/generate-image") +@limiter.limit("10/minute") +def generate(req: GenRequest, request: Request): + if req.api_key != API_KEY: + raise HTTPException(status_code=401, detail="invalid api_key") + t0 = time.time() + pipe = load_pipeline() + generator = None + if req.seed is not None: + import torch + generator = torch.Generator(device=pipe.device).manual_seed(int(req.seed)) + result = pipe(req.prompt, num_inference_steps=req.steps, guidance_scale=req.guidance, width=req.width, height=req.height, generator=generator) + img: Image.Image = result.images[0] + buf = io.BytesIO() + img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + dur_ms = int((time.time() - t0) * 1000) + job_id = hashlib.sha256(f"{t0}-{req.prompt[:64]}".encode()).hexdigest()[:16] + log_line = {"job_id": job_id, "duration_ms": dur_ms, "bytes_out": len(b64), "prompt_hash": hashlib.sha256(req.prompt.encode()).hexdigest()} + print(log_line, flush=True) + return {"status": "ok", "job_id": job_id, "image_base64": f"data:image/png;base64,{b64}", "duration_ms": dur_ms} + +if __name__ == "__main__": + import uvicorn, os + uvicorn.run("server:app", host=os.getenv("BIND_HOST", "0.0.0.0"), port=int(os.getenv("BIND_PORT", "8000")), reload=False) +``` + +## `client.py` + +```python +import base64, json, os +import httpx + +API = os.getenv("API", "http://localhost:8000") +API_KEY = os.getenv("API_KEY", "CHANGE_ME_SUPERSECRET") + +payload = { + "api_key": API_KEY, + "prompt": "a futuristic city skyline at night, ultra detailed, neon", + "steps": 30, + "guidance": 7.5, + "width": 512, + "height": 512, +} + +r = httpx.post(f"{API}/v1/generate-image", json=payload, timeout=120) +r.raise_for_status() +resp = r.json() +print("job:", resp.get("job_id"), "duration_ms:", resp.get("duration_ms")) +img_b64 = resp["image_base64"].split(",",1)[1] +open("out.png","wb").write(base64.b64decode(img_b64)) +print("saved out.png") +``` + +--- + +# OpenAPI 3.1 Spezifikation (Stage 1) + +```yaml +openapi: 3.1.0 +info: + title: AITBC Stage1 Server + version: 0.1.0 +servers: + - url: http://localhost:8000 +paths: + /v1/health: + get: + summary: Health check + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: { type: string } + gpu: { type: string } + model: { type: string } + /v1/generate-image: + post: + summary: Generate image from text prompt + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [api_key, prompt] + properties: + api_key: { type: string } + prompt: { type: string } + steps: { type: integer, minimum: 5, maximum: 100, default: 30 } + guidance: { type: number, minimum: 0, maximum: 25, default: 7.5 } + width: { type: integer, minimum: 256, maximum: 1024, default: 512 } + height: { type: integer, minimum: 256, maximum: 1024, default: 512 } + seed: { type: integer, nullable: true } + responses: + '200': + description: Image generated + content: + application/json: + schema: + type: object + properties: + status: { type: string } + job_id: { type: string } + image_base64: { type: string } + duration_ms: { type: integer } +``` + +--- + +# Stage 2 – Receipt‑/Quittungs‑Schema & Hashing + +## JSON Receipt (off‑chain, signierbar) + +```json +{ + "job_id": "2025-09-26-000123", + "provider": "0xProviderAddress", + "client": "client_public_key_or_id", + "units": 1.90, + "unit_type": "gpu_seconds", + "model": "runwayml/stable-diffusion-v1-5", + "prompt_hash": "sha256:...", + "started_at": 1695720000, + "finished_at": 1695720002, + "artifact_sha256": "...", + "nonce": "b7f3...", + "hub_id": "optional-hub", + "chain_id": 11155111 +} +``` + +## Hashing + +- Kanonische Serialisierung (minified JSON, Felder in alphabetischer Reihenfolge). +- `receipt_hash = keccak256(bytes(serialized))` (für EVM‑Kompatibilität) **oder** `sha256` falls kettenagnostisch. + +## Signatur + +- Signatur über `receipt_hash`: + - **secp256k1/ECDSA** (Ethereum‑kompatibel, EIP‑191/EIP‑712) **oder** Ed25519 (falls Off‑Chain‑Attester bevorzugt). +- Felder zur Verifikation on‑chain: `provider`, `units`, `receipt_hash`, `signature`. + +## Double‑Mint‑Prevention + +- Smart Contract speichert `used[receipt_hash] = true` nach erfolgreichem Mint. + +--- + +# Stage 2 – Smart‑Contract‑Skeleton (Solidity) + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface IERC20Mint { + function mint(address to, uint256 amount) external; +} + +contract AITokenMinter { + IERC20Mint public token; + address public attester; // Off‑chain Hub/Oracle, darf Quittungen bescheinigen + mapping(bytes32 => bool) public usedReceipt; // receipt_hash → consumed + + event Minted(address indexed provider, uint256 units, bytes32 receiptHash); + event AttesterChanged(address indexed oldA, address indexed newA); + + constructor(address _token, address _attester) { + token = IERC20Mint(_token); + attester = _attester; + } + + function setAttester(address _attester) external /* add access control */ { + emit AttesterChanged(attester, _attester); + attester = _attester; + } + + function mintWithReceipt( + address provider, + uint256 units, + bytes32 receiptHash, + bytes calldata attesterSig + ) external { + require(!usedReceipt[receiptHash], "receipt used"); + // Verify attester signature over EIP‑191 style message: keccak256(abi.encode(provider, units, receiptHash)) + bytes32 msgHash = keccak256(abi.encode(provider, units, receiptHash)); + require(_recover(msgHash, attesterSig) == attester, "bad sig"); + usedReceipt[receiptHash] = true; + token.mint(provider, units); + emit Minted(provider, units, receiptHash); + } + + function _recover(bytes32 msgHash, bytes memory sig) internal pure returns (address) { + bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", msgHash)); + (bytes32 r, bytes32 s, uint8 v) = _split(sig); + return ecrecover(ethHash, v, r, s); + } + + function _split(bytes memory sig) internal pure returns (bytes32 r, bytes32 s, uint8 v) { + require(sig.length == 65, "sig len"); + assembly { + r := mload(add(sig, 32)) + s := mload(add(sig, 64)) + v := byte(0, mload(add(sig, 96))) + } + } +} +``` + +> Hinweis: In Produktion Access‑Control (Ownable/Role‑based), Pausable, Reentrancy‑Guard und EIP‑712‑Typed‑Data einführen. + +--- + +# Stage 3 – Hub‑Spezifikation (Kurz) + +- **Scheduler**: Round‑robin + Preis/VRAM‑Filter; optional Reputation. +- **Split**: Große Jobs shard‑en; `units` aus Subjobs aggregieren. +- **Verification**: Stichprobenhafte Re‑Auswertung / Konsistenz‑Hashes. +- **Payout**: Proportionale Verteilung; ein Receipt je Gesamtjob. + +# Stage 4 – Marketplace (Kurz) + +- **Orderbook** (limit/market), **Wallet‑Connect**, Non‑custodial. +- **KYC/Compliance** optional je Jurisdiktion. +- **Reputation/SLAs** on‑/off‑chain verknüpfbar. + + + +--- + +# Deployment ohne Docker (Bare‑Metal / VM) + +## Voraussetzungen + +- Ubuntu/Debian mit NVIDIA Treiber (535+) und CUDA/CuDNN passend zur PyTorch‑Version. +- Python 3.10+ und `python3-venv`. +- Öffentliche Ports: **8000/tcp** (API) – optional Reverse‑Proxy auf 80/443. + +## Treiber & CUDA (Kurz) + +```bash +# NVIDIA Treiber (Beispiel Ubuntu) +sudo apt update && sudo apt install -y nvidia-driver-535 +# Nach Reboot: nvidia-smi prüfen +# PyTorch bringt eigenes CUDA-Toolkit über Wheels (empfohlen). Kein System-CUDA zwingend nötig. +``` + +## Benutzer & Verzeichnisstruktur + +```bash +sudo useradd -m -r -s /bin/bash aitbc +sudo -u aitbc mkdir -p /opt/aitbc/app /opt/aitbc/logs +# Code nach /opt/aitbc/app kopieren +``` + +## Virtualenv & Abhängigkeiten + +```bash +sudo -u aitbc bash -lc ' + cd /opt/aitbc/app && python3 -m venv venv && source venv/bin/activate && \ + pip install --upgrade pip && pip install -r requirements.txt +' +``` + +## Konfiguration (.env) + +``` +API_KEY= +MODEL_ID=runwayml/stable-diffusion-v1-5 +BIND_HOST=127.0.0.1 # hinter Reverse Proxy +BIND_PORT=8000 +``` + +## Systemd‑Unit (Uvicorn) + +`/etc/systemd/system/aitbc.service` + +```ini +[Unit] +Description=AITBC Stage1 FastAPI Server +After=network-online.target +Wants=network-online.target + +[Service] +User=aitbc +Group=aitbc +WorkingDirectory=/opt/aitbc/app +EnvironmentFile=/opt/aitbc/app/.env +ExecStart=/opt/aitbc/app/venv/bin/python -m uvicorn server:app --host ${BIND_HOST} --port ${BIND_PORT} --workers 1 +Restart=always +RestartSec=3 +# GPU/VRAM limits optional per nvidia-visible-devices +StandardOutput=append:/opt/aitbc/logs/stdout.log +StandardError=append:/opt/aitbc/logs/stderr.log + +[Install] +WantedBy=multi-user.target +``` + +Aktivieren & Starten: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now aitbc.service +sudo systemctl status aitbc.service +``` + +## Reverse Proxy (optional, ohne Docker) + +### Nginx (TLS via Certbot) + +```bash +sudo apt install -y nginx certbot python3-certbot-nginx +sudo tee /etc/nginx/sites-available/aitbc <<'NG' +server { + listen 80; server_name example.com; + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +NG +sudo ln -s /etc/nginx/sites-available/aitbc /etc/nginx/sites-enabled/aitbc +sudo nginx -t && sudo systemctl reload nginx +sudo certbot --nginx -d example.com +``` + +## Firewall/Netzwerk + +```bash +sudo ufw allow OpenSSH +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +## Monitoring ohne Docker + +- **systemd**: `journalctl -u aitbc -f` +- **Metriken**: Prometheus Node Exporter, `nvtop`/`nvidia-smi dmon` für GPU. +- **Alerts**: systemd `Restart=always`, optional Monit. + +## Zero‑Downtime Update (Rolling ohne Container) + +```bash +sudo systemctl stop aitbc +sudo -u aitbc bash -lc 'cd /opt/aitbc/app && git pull && source venv/bin/activate && pip install -r requirements.txt' +sudo systemctl start aitbc +``` + +## Härtung & Best Practices + +- Starker API‑Key, IP‑basierte Allow‑List am Reverse‑Proxy. +- Rate‑Limit (slowapi) aktivieren; Request‑Body‑Limits setzen (`client_max_body_size`). +- Temporäre Dateien regelmäßig bereinigen (systemd tmpfiles). +- Separate GPU‑Workstation vs. Edge‑Expose (API hinter Proxy). + +> Hinweis: Diese Anleitung vermeidet bewusst jeglichen Docker‑Einsatz und nutzt **systemd + venv** für einen reproduzierbaren, schlanken Betrieb. + diff --git a/docs/bootstrap/blockchain_node.md b/docs/bootstrap/blockchain_node.md new file mode 100644 index 0000000..5ccd851 --- /dev/null +++ b/docs/bootstrap/blockchain_node.md @@ -0,0 +1,392 @@ +# blockchain-node/ — Minimal Chain (asset-backed by compute) + +## 0) TL;DR boot path for Windsurf +1. Create the service: `apps/blockchain-node` (Python, FastAPI, asyncio, uvicorn). +2. Data layer: `sqlite` via `SQLModel` (later: PostgreSQL). +3. P2P: WebSocket gossip (lib: `websockets`) with a simple overlay (peer table + heartbeats). +4. Consensus (MVP): **PoA single-author** (devnet) → upgrade to **Compute-Backed Proof (CBP)** after coordinator & miner telemetry are wired. +5. Block content: **ComputeReceipts** = “proofs of delivered AI work” signed by miners, plus standard transfers. +6. Minting: AIToken minted per verified compute unit (e.g., `1 AIT = 1,000 token-ops` — calibrate later). +7. REST RPC: `/rpc/*` for clients & coordinator; `/p2p/*` for peers; `/admin/*` for node ops. +8. Ship a `devnet` script that starts: 1 bootstrap node, 1 coordinator-api mock, 1 miner mock, 1 client demo. + +--- + +## 1) Goal & Scope +- Provide a **minimal, testable blockchain node** that issues AITokens **only** when real compute was delivered (asset-backed). +- Easy to run, easy to reset, deterministic devnet. +- Strong boundaries so **coordinator-api** (job orchestration) and **miner-node** (workers) can integrate quickly. + +Out of scope (MVP): +- Smart contracts VM. +- Sharding/advanced networking. +- Custodial wallets. (Use local keypairs for dev.) + +--- + +## 2) Core Concepts + +### 2.1 Actors +- **Client**: pays AITokens to request compute jobs. +- **Coordinator**: matches jobs ↔ miners; returns signed receipts. +- **Miner**: executes jobs; produces **ComputeReceipt** signed with miner key. +- **Blockchain Node**: validates receipts, mints AIT for miners, tracks balances, finalizes blocks. + +### 2.2 Asset-Backed Minting +- Unit of account: **AIToken (AIT)**. +- A miner earns AIT when a **ComputeReceipt** is included in a block. +- A receipt is valid iff: + 1) Its `job_id` exists in coordinator logs, + 2) `client_payment_tx` covers the quoted price, + 3) `miner_sig` over `(job_id, hash(output_meta), compute_units, price, nonce)` is valid, + 4) Not previously claimed (`receipt_id` unique). + +--- + +## 3) Minimal Architecture + +``` +blockchain-node/ + ├─ src/ + │ ├─ main.py # FastAPI entry + │ ├─ p2p.py # WS gossip, peer table, block relay + │ ├─ consensus.py # PoA/CBP state machine + │ ├─ types.py # dataclasses / pydantic models + │ ├─ state.py # DB access (SQLModel), UTXO/Account + │ ├─ mempool.py # tx pool (transfers + receipts) + │ ├─ crypto.py # ed25519 keys, signatures, hashing + │ ├─ receipts.py # receipt validation (with coordinator) + │ ├─ blocks.py # block build/verify, difficulty stub + │ ├─ rpc.py # REST/RPC routes for clients & ops + │ └─ settings.py # env config + ├─ tests/ + │ └─ ... # unit & integration tests + ├─ scripts/ + │ ├─ devnet_up.sh # run bootstrap node + mocks + │ └─ keygen.py # create node/miner/client keys + ├─ README.md + └─ requirements.txt +``` + +--- + +## 4) Data Model (SQLModel) + +### 4.1 Tables +- `blocks(id, parent_id, height, timestamp, proposer, tx_count, hash, state_root, sig)` +- `tx(id, block_id, type, payload_json, sender, nonce, fee, sig, hash, status)` +- `accounts(address, balance, nonce, pubkey)` +- `receipts(receipt_id, job_id, client_addr, miner_addr, compute_units, price, output_hash, miner_sig, status)` +- `peers(node_id, addr, last_seen, score)` +- `params(key, value)` — chain config (mint ratios, fee rate, etc.) + +### 4.2 TX Types +- `TRANSFER`: move AIT from A → B +- `RECEIPT_CLAIM`: include a **ComputeReceipt**; mints to miner and settles client escrow +- `STAKE/UNSTAKE` (later) +- `PARAM_UPDATE` (PoA only, gated by admin key for devnet) + +--- + +## 5) Block Format (JSON) +```json +{ + "parent": "", + "height": 123, + "timestamp": 1699999999, + "proposer": "", + "txs": ["", "..."], + "stateRoot": "", + "sig": "" +} +``` + +Header sign bytes = `hash(parent|height|timestamp|proposer|stateRoot)` + +--- + +## 6) Consensus + +### 6.1 MVP: PoA (Single Author) +- One configured `PROPOSER_KEY` creates blocks at fixed interval (e.g., 2s). +- Honest mode only for devnet; finality by canonical longest/height rule. + +### 6.2 Upgrade: **Compute-Backed Proof (CBP)** +- Each block’s **work score** = total `compute_units` in included receipts. +- Proposer election = weighted round-robin by recent work score and stake (later). +- Slashing: submitting invalid receipts reduces score; repeated offenses → temp ban. + +--- + +## 7) Receipt Validation (Coordinator Check) + +`receipts.py` performs: +1) **Coordinator attestation** (HTTP call to coordinator-api): + - `/attest/receipt` with `job_id`, `client`, `miner`, `price`, `compute_units`, `output_hash`. + - Returns `{exists: bool, paid: bool, not_double_spent: bool, quote: {...}}`. +2) **Signature check**: verify `miner_sig` with miner’s `pubkey`. +3) **Economic checks**: ensure `client_payment_tx` exists & covers `price + fee`. + +> For devnet without live coordinator, ship a **mock** that returns deterministic attestation for known `job_id` ranges. + +--- + +## 8) Fees & Minting + +- **Fee model (MVP)**: `fee = base_fee + k * payload_size`. +- **Minting**: + - Miner gets: `mint = compute_units * MINT_PER_UNIT`. + - Coordinator gets: `coord_cut = mint * COORDINATOR_RATIO`. + - Chain treasury (optional): small %, configurable in `params`. + +--- + +## 9) RPC Surface (FastAPI) + +### 9.1 Public +- `POST /rpc/sendTx` → `{txHash}` +- `GET /rpc/getTx/{txHash}` → `{status, receipt}` +- `GET /rpc/getBlock/{heightOrHash}` +- `GET /rpc/getHead` → `{height, hash}` +- `GET /rpc/getBalance/{address}` → `{balance, nonce}` +- `POST /rpc/estimateFee` → `{fee}` + +### 9.2 Coordinator-facing +- `POST /rpc/submitReceipt` (alias of `sendTx` with type `RECEIPT_CLAIM`) +- `POST /rpc/attest` (devnet mock only) + +### 9.3 Admin (devnet) +- `POST /admin/paramSet` (PoA only) +- `POST /admin/peers/add` `{addr}` +- `POST /admin/mintFaucet` `{address, amount}` (devnet) + +### 9.4 P2P (WS) +- `GET /p2p/peers` → list +- `WS /p2p/ws` → subscribe to gossip: `{"type":"block"|"tx"|"peer","data":...}` + +--- + +## 10) Keys & Crypto +- **ed25519** for account & node keys. +- Address = `bech32(hrp="ait", sha256(pubkey)[0:20])`. +- Sign bytes: + - TX: `hash(type|sender|nonce|fee|payload_json_canonical)` + - Block: header hash as above. + +Ship `scripts/keygen.py` for dev use. + +--- + +## 11) Mempool Rules +- Accept if: + - `sig` valid, + - `nonce == account.nonce + 1`, + - `fee >= minFee`, + - For `RECEIPT_CLAIM`: passes `receipts.validate()` *optimistically* (soft-accept), then **revalidate** at block time. + +Replacement: higher-fee replaces same `(sender, nonce)`. + +--- + +## 12) Node Lifecycle + +**Start:** +1) Load config, open DB, ensure genesis. +2) Connect to bootstrap peers (if any). +3) Start RPC (FastAPI) + P2P WS server. +4) Start block proposer (if PoA key present). +5) Start peer heartbeats + gossip loops. + +**Shutdown:** +- Graceful: flush mempool snapshot, close DB. + +--- + +## 13) Genesis +- `genesis.json`: + - `chain_id`, `timestamp`, `accounts` (faucet), `params` (mint ratios, base fee), `authorities` (PoA keys). + +Provide `scripts/make_genesis.py`. + +--- + +## 14) Devnet: End-to-End Demo + +### 14.1 Components +- **blockchain-node** (this repo) +- **coordinator-api (mock)**: `/attest/receipt` returns valid for `job_id` in `[1..1_000_000]` +- **miner-node (mock)**: posts `RECEIPT_CLAIM` for synthetic jobs +- **client-web (demo)**: sends `TRANSFER` & displays balances + +### 14.2 Flow +1) Client pays `price` to escrow address (coordinator). +2) Miner executes job; coordinator verifies output. +3) Miner submits **ComputeReceipt** → included in next block. +4) Mint AIT to miner; escrow settles; client charged. + +--- + +## 15) Testing Strategy + +### 15.1 Unit +- `crypto`: keygen, sign/verify, address derivation +- `state`: balances, nonce, persistence +- `receipts`: signature + coordinator mock +- `blocks`: header hash, stateRoot + +### 15.2 Integration +- Single node PoA: produce N blocks; submit transfers/receipts; assert balances. +- Two nodes P2P: block/tx relay; head convergence. + +### 15.3 Property tests +- Nonce monotonicity; no double-spend; unique receipts. + +--- + +## 16) Observability +- Structured logs (JSON) with `component`, `event`, `height`, `latency_ms`. +- `/rpc/metrics` (Prometheus format) — block time, mempool size, peers. + +--- + +## 17) Configuration (ENV) +- `CHAIN_ID=ait-devnet` +- `DB_PATH=./data/chain.db` +- `P2P_BIND=0.0.0.0:7070` +- `RPC_BIND=0.0.0.0:8080` +- `BOOTSTRAP_PEERS=ws://host:7070,...` +- `PROPOSER_KEY=...` (optional for non-authors) +- `MINT_PER_UNIT=1000` +- `COORDINATOR_RATIO=0.05` + +Provide `.env.example`. + +--- + +## 18) Minimal API Payloads + +### 18.1 TRANSFER +```json +{ + "type": "TRANSFER", + "sender": "ait1...", + "nonce": 1, + "fee": 10, + "payload": {"to":"ait1...","amount":12345}, + "sig": "" +} +``` + +### 18.2 RECEIPT_CLAIM +```json +{ + "type": "RECEIPT_CLAIM", + "sender": "ait1miner...", + "nonce": 7, + "fee": 50, + "payload": { + "receipt_id": "rcpt_7f3a...", + "job_id": "job_42", + "client_addr": "ait1client...", + "miner_addr": "ait1miner...", + "compute_units": 2500, + "price": 50000, + "output_hash": "sha256:abcd...", + "miner_sig": "" + }, + "sig": "" +} +``` + +--- + +## 19) Security Notes (MVP) +- Devnet PoA means trust in proposer; do **not** expose to internet without firewall. +- Enforce coordinator host allowlist for attest calls. +- Rate-limit `/rpc/sendTx`. + +--- + +## 20) Roadmap +1) ✅ PoA devnet with receipts. +2) 🔜 CBP proposer selection from rolling work score. +3) 🔜 Stake & slashing. +4) 🔜 Replace SQLite with PostgreSQL. +5) 🔜 Snapshots & fast-sync. +6) 🔜 Light client (SPV of receipts & balances). + +--- + +## 21) Developer Tasks (Windsurf Order) + +1) **Scaffold** project & `requirements.txt`: + - `fastapi`, `uvicorn[standard]`, `sqlmodel`, `pydantic`, `websockets`, `pyyaml`, `python-dotenv`, `ed25519`, `orjson`. + +2) **Implement**: + - `crypto.py`, `types.py`, `state.py`. + - `rpc.py` (public routes). + - `mempool.py`. + - `blocks.py` (build/validate). + - `consensus.py` (PoA tick). + - `p2p.py` (WS server + simple gossip). + - `receipts.py` (mock coordinator). + +3) **Wire** `main.py`: + - Start RPC, P2P, PoA loops. + +4) **Scripts**: + - `scripts/keygen.py`, `scripts/make_genesis.py`, `scripts/devnet_up.sh`. + +5) **Tests**: + - Add unit + an integration test that mints on a receipt. + +6) **Docs**: + - Update `README.md` with curl examples. + +--- + +## 22) Curl Snippets (Dev) + +- Faucet (dev only): +```bash +curl -sX POST localhost:8080/admin/mintFaucet -H 'content-type: application/json' \ + -d '{"address":"ait1client...","amount":1000000}' +``` + +- Transfer: +```bash +curl -sX POST localhost:8080/rpc/sendTx -H 'content-type: application/json' \ + -d @transfer.json +``` + +- Submit Receipt: +```bash +curl -sX POST localhost:8080/rpc/submitReceipt -H 'content-type: application/json' \ + -d @receipt_claim.json +``` + +--- + +## 23) Definition of Done (MVP) +- Node produces blocks on PoA. +- Can transfer AIT between accounts. +- Can submit a valid **ComputeReceipt** → miner balance increases; escrow decreases. +- Two nodes converge on same head via P2P. +- Basic metrics exposed. + +--- + +## 24) Next Files to Create +- `src/main.py` +- `src/crypto.py` +- `src/types.py` +- `src/state.py` +- `src/mempool.py` +- `src/blocks.py` +- `src/consensus.py` +- `src/p2p.py` +- `src/receipts.py` +- `src/rpc.py` +- `scripts/keygen.py`, `scripts/devnet_up.sh` +- `.env.example`, `README.md`, `requirements.txt` + diff --git a/docs/bootstrap/coordinator_api.md b/docs/bootstrap/coordinator_api.md new file mode 100644 index 0000000..c283865 --- /dev/null +++ b/docs/bootstrap/coordinator_api.md @@ -0,0 +1,438 @@ +# coordinator-api.md + +Central API that orchestrates **jobs** from clients to **miners**, tracks lifecycle, validates results, and (later) settles AITokens. +**Stage 1 (MVP):** no blockchain, no pool hub — just client ⇄ coordinator ⇄ miner. + +## 1) Goals & Non-Goals + +**Goals (MVP)** +- Accept computation jobs from clients. +- Match jobs to eligible miners. +- Track job state machine (QUEUED → RUNNING → COMPLETED/FAILED/CANCELED/EXPIRED). +- Stream results back to clients; store minimal metadata. +- Provide a clean, typed API (OpenAPI/Swagger). +- Simple auth (API keys) + idempotency + rate limits. +- Minimal persistence (SQLite/Postgres) with straightforward SQL (no migrations tooling). + +**Non-Goals (MVP)** +- Token minting/settlement (stub hooks only). +- Miner marketplace, staking, slashing, reputation (placeholders). +- Pool hub coordination (future stage). + +--- + +## 2) Tech Stack + +- **Python 3.12**, **FastAPI**, **Uvicorn** +- **Pydantic** for schemas +- **SQL** via `sqlite3` or Postgres (user can switch later) +- **Redis (optional)** for queueing; MVP can start with in-DB FIFO +- **HTTP + WebSocket** (for miner heartbeats / job streaming) + +> Debian 12 target. Run under **systemd** later. + +--- + +## 3) Directory Layout (WindSurf Workspace) + +``` +coordinator-api/ +├─ app/ +│ ├─ main.py # FastAPI init, lifespan, routers +│ ├─ config.py # env parsing +│ ├─ deps.py # auth, rate-limit deps +│ ├─ db.py # simple DB layer (sqlite/postgres) +│ ├─ matching.py # job→miner selection +│ ├─ queue.py # enqueue/dequeue logic +│ ├─ settlement.py # stubs for token accounting +│ ├─ models.py # Pydantic request/response schemas +│ ├─ states.py # state machine + transitions +│ ├─ routers/ +│ │ ├─ client.py # /v1/jobs (submit/status/result/cancel) +│ │ ├─ miner.py # /v1/miners (register/heartbeat/poll/submit/fail) +│ │ └─ admin.py # /v1/admin (stats) +│ └─ ws/ +│ ├─ miner.py # WS for miner heartbeats / job stream (optional) +│ └─ client.py # WS for client result stream (optional) +├─ tests/ +│ ├─ test_client_flow.http # REST client flow (HTTP file) +│ └─ test_miner_flow.http # REST miner flow +├─ .env.example +├─ pyproject.toml +└─ README.md +``` + +--- + +## 4) Environment (.env) + +``` +APP_ENV=dev +APP_HOST=127.0.0.1 +APP_PORT=8011 +DATABASE_URL=sqlite:///./coordinator.db +# or: DATABASE_URL=postgresql://user:pass@localhost:5432/aitbc + +# Auth +CLIENT_API_KEYS=client_dev_key_1,client_dev_key_2 +MINER_API_KEYS=miner_dev_key_1,miner_dev_key_2 +ADMIN_API_KEYS=admin_dev_key_1 + +# Security +HMAC_SECRET=change_me +ALLOW_ORIGINS=* + +# Queue +JOB_TTL_SECONDS=900 +HEARTBEAT_INTERVAL_SECONDS=10 +HEARTBEAT_TIMEOUT_SECONDS=30 +``` + +--- + +## 5) Core Data Model (conceptual) + +**Job** +- `job_id` (uuid) +- `client_id` (from API key) +- `requested_at`, `expires_at` +- `payload` (opaque JSON / bytes ref) +- `constraints` (gpu, cuda, mem, model, max_price, region) +- `state` (QUEUED|RUNNING|COMPLETED|FAILED|CANCELED|EXPIRED) +- `assigned_miner_id` (nullable) +- `result_ref` (blob path / inline json) +- `error` (nullable) +- `cost_estimate` (optional) + +**Miner** +- `miner_id` (from API key) +- `capabilities` (gpu, cuda, vram, models[], region) +- `heartbeat_at` +- `status` (ONLINE|OFFLINE|DRAINING) +- `concurrency` (int), `inflight` (int) + +**WorkerSession** +- `session_id`, `miner_id`, `job_id`, `started_at`, `ended_at`, `exit_reason` + +--- + +## 6) State Machine + +``` +QUEUED + -> RUNNING (assigned to miner) + -> CANCELED (client) + -> EXPIRED (ttl) + +RUNNING + -> COMPLETED (miner submit_result) + -> FAILED (miner fail / timeout) + -> CANCELED (client) +``` + +--- + +## 7) Matching (MVP) + +- Filter ONLINE miners by **capabilities** & **region** +- Prefer lowest `inflight` (simple load) +- Tiebreak by earliest `heartbeat_at` or random +- Lock job row → assign → return to miner + +--- + +## 8) Auth & Rate Limits + +- **API keys** via `X-Api-Key` header for `client`, `miner`, `admin`. +- Optional **HMAC** (`X-Signature`) over body with `HMAC_SECRET`. +- **Idempotency**: clients send `Idempotency-Key` on **POST /jobs**. +- **Rate limiting**: naive per-key window (e.g., 60 req / 60 s). + +--- + +## 9) REST API + +### Client + +- `POST /v1/jobs` + - Create job. Returns `job_id`. +- `GET /v1/jobs/{job_id}` + - Job status & metadata. +- `GET /v1/jobs/{job_id}/result` + - Result (200 when ready, 425 if not ready). +- `POST /v1/jobs/{job_id}/cancel` + - Cancel if QUEUED or RUNNING (best effort). + +### Miner + +- `POST /v1/miners/register` + - Upsert miner capabilities; set ONLINE. +- `POST /v1/miners/heartbeat` + - Touch `heartbeat_at`, report `inflight`. +- `POST /v1/miners/poll` + - Long-poll for next job → returns a job or 204. +- `POST /v1/miners/{job_id}/start` + - Confirm start (optional if `poll` implies start). +- `POST /v1/miners/{job_id}/result` + - Submit result; transitions to COMPLETED. +- `POST /v1/miners/{job_id}/fail` + - Submit failure; transitions to FAILED. +- `POST /v1/miners/drain` + - Graceful stop accepting new jobs. + +### Admin + +- `GET /v1/admin/stats` + - Queue depth, miners online, success rates, avg latency. +- `GET /v1/admin/jobs?state=&limit=...` +- `GET /v1/admin/miners` + +**Error Shape** +```json +{ "error": { "code": "STRING_CODE", "message": "human readable", "details": {} } } +``` + +Common codes: `UNAUTHORIZED_KEY`, `RATE_LIMITED`, `INVALID_PAYLOAD`, `NO_ELIGIBLE_MINER`, `JOB_NOT_FOUND`, `JOB_NOT_READY`, `CONFLICT_STATE`. + +--- + +## 10) WebSockets (optional MVP+) + +- `WS /v1/ws/miner?api_key=...` + - Server → miner: `job.assigned` + - Miner → server: `heartbeat`, `result`, `fail` +- `WS /v1/ws/client?job_id=...&api_key=...` + - Server → client: `state.changed`, `result.ready` + +Fallback remains HTTP long-polling. + +--- + +## 11) Result Storage + +- **Inline JSON** if ≤ 1 MB. +- For larger payloads: store to disk path (e.g., `/var/lib/coordinator/results/{job_id}`) and return `result_ref`. + +--- + +## 12) Settlement Hooks (stub) + +`settlement.py` exposes: +- `record_usage(job, miner)` +- `quote_cost(job)` +Later wired to **AIToken** mint/transfer when blockchain lands. + +--- + +## 13) Minimal FastAPI Skeleton + +```python +# app/main.py +from fastapi import FastAPI +from app.routers import client, miner, admin + +def create_app(): + app = FastAPI(title="AITBC Coordinator API", version="0.1.0") + app.include_router(client.router, prefix="/v1") + app.include_router(miner.router, prefix="/v1") + app.include_router(admin.router, prefix="/v1") + return app + +app = create_app() +``` + +```python +# app/models.py +from pydantic import BaseModel, Field +from typing import Any, Dict, List, Optional + +class Constraints(BaseModel): + gpu: Optional[str] = None + cuda: Optional[str] = None + min_vram_gb: Optional[int] = None + models: Optional[List[str]] = None + region: Optional[str] = None + max_price: Optional[float] = None + +class JobCreate(BaseModel): + payload: Dict[str, Any] + constraints: Constraints = Constraints() + ttl_seconds: int = 900 + +class JobView(BaseModel): + job_id: str + state: str + assigned_miner_id: Optional[str] = None + requested_at: str + expires_at: str + error: Optional[str] = None + +class MinerRegister(BaseModel): + capabilities: Dict[str, Any] + concurrency: int = 1 + region: Optional[str] = None + +class PollRequest(BaseModel): + max_wait_seconds: int = 15 + +class AssignedJob(BaseModel): + job_id: str + payload: Dict[str, Any] +``` + +```python +# app/routers/client.py +from fastapi import APIRouter, Depends, HTTPException +from app.models import JobCreate, JobView +from app.deps import require_client_key + +router = APIRouter(tags=["client"]) + +@router.post("/jobs", response_model=JobView) +def submit_job(req: JobCreate, client_id: str = Depends(require_client_key)): + # enqueue + return JobView + ... + +@router.get("/jobs/{job_id}", response_model=JobView) +def get_job(job_id: str, client_id: str = Depends(require_client_key)): + ... +``` + +```python +# app/routers/miner.py +from fastapi import APIRouter, Depends +from app.models import MinerRegister, PollRequest, AssignedJob +from app.deps import require_miner_key + +router = APIRouter(tags=["miner"]) + +@router.post("/miners/register") +def register(req: MinerRegister, miner_id: str = Depends(require_miner_key)): + ... + +@router.post("/miners/poll", response_model=AssignedJob, status_code=200) +def poll(req: PollRequest, miner_id: str = Depends(require_miner_key)): + # try dequeue, else 204 + ... +``` + +Run: +```bash +uvicorn app.main:app --host 127.0.0.1 --port 8011 --reload +``` + +OpenAPI: `http://127.0.0.1:8011/docs` + +--- + +## 14) Matching & Queue Pseudocode + +```python +def match_next_job(miner): + eligible = db.jobs.filter( + state="QUEUED", + constraints.satisfied_by(miner.capabilities) + ).order_by("requested_at").first() + if not eligible: + return None + db.txn(lambda: + db.jobs.assign(eligible.job_id, miner.id) and + db.states.transition(eligible.job_id, "RUNNING") + ) + return eligible +``` + +--- + +## 15) CURL Examples + +**Client creates a job** +```bash +curl -sX POST http://127.0.0.1:8011/v1/jobs \ + -H 'X-Api-Key: client_dev_key_1' \ + -H 'Idempotency-Key: 7d4a...' \ + -H 'Content-Type: application/json' \ + -d '{ + "payload": {"task":"sum","a":2,"b":3}, + "constraints": {"gpu": null, "region": "eu-central"} + }' +``` + +**Miner registers + polls** +```bash +curl -sX POST http://127.0.0.1:8011/v1/miners/register \ + -H 'X-Api-Key: miner_dev_key_1' \ + -H 'Content-Type: application/json' \ + -d '{"capabilities":{"gpu":"RTX4060Ti","cuda":"12.3","vram_gb":16},"concurrency":2,"region":"eu-central"}' + +curl -i -sX POST http://127.0.0.1:8011/v1/miners/poll \ + -H 'X-Api-Key: miner_dev_key_1' \ + -H 'Content-Type: application/json' \ + -d '{"max_wait_seconds":10}' +``` + +**Miner submits result** +```bash +curl -sX POST http://127.0.0.1:8011/v1/miners//result \ + -H 'X-Api-Key: miner_dev_key_1' \ + -H 'Content-Type: application/json' \ + -d '{"result":{"sum":5},"metrics":{"latency_ms":42}}' +``` + +**Client fetches result** +```bash +curl -s http://127.0.0.1:8011/v1/jobs//result \ + -H 'X-Api-Key: client_dev_key_1' +``` + +--- + +## 16) Timeouts & Health + +- **Job TTL**: auto-expire QUEUED after `JOB_TTL_SECONDS`. +- **Heartbeat**: miners post every `HEARTBEAT_INTERVAL_SECONDS`. +- **Miner OFFLINE** if no heartbeat for `HEARTBEAT_TIMEOUT_SECONDS`. +- **Requeue**: RUNNING jobs from OFFLINE miners → back to QUEUED. + +--- + +## 17) Security Notes + +- Validate `payload` size & type; enforce max 1 MB inline. +- Optional **HMAC** signature for tamper detection. +- Sanitize/validate miner-reported capabilities. +- Log every state transition (append-only). + +--- + +## 18) Admin Metrics (MVP) + +- Queue depth, running count +- Miner online/offline, inflight +- P50/P95 job latency +- Success/fail/cancel rates (windowed) + +--- + +## 19) Future Stages + +- **Blockchain layer**: mint on verified compute; tie to `record_usage`. +- **Pool hub**: multi-coordinator balancing; marketplace. +- **Reputation**: miner scoring, penalty, slashing. +- **Bidding**: price discovery; client max price. + +--- + +## 20) Checklist (WindSurf) + +1. Create repo structure from section **3**. +2. Implement `.env` & `config.py` keys from **4**. +3. Add `models.py`, `states.py`, `deps.py` (auth, rate limit). +4. Implement DB tables for Job, Miner, WorkerSession. +5. Implement `queue.py` and `matching.py`. +6. Wire **client** and **miner** routers (MVP endpoints). +7. Add admin stats (basic counts). +8. Add OpenAPI tags, descriptions. +9. Add curl `.http` test files. +10. Systemd unit + Nginx proxy (later). + diff --git a/docs/bootstrap/dirs.md b/docs/bootstrap/dirs.md new file mode 100644 index 0000000..5c687fa --- /dev/null +++ b/docs/bootstrap/dirs.md @@ -0,0 +1,133 @@ +# AITBC Monorepo Directory Layout (Windsurf Workspace) + +> One workspace for **all** AITBC elements (client · coordinator · miner · blockchain · pool‑hub · marketplace · wallet · docs · ops). No Docker required. + +``` +aitbc/ +├─ .editorconfig +├─ .gitignore +├─ README.md # Top‑level overview, quickstart, workspace tasks +├─ LICENSE +├─ windsurf/ # Windsurf prompts, tasks, run configurations +│ ├─ prompts/ # High‑level task prompts for WS agents +│ ├─ tasks/ # Saved task flows / playbooks +│ └─ settings.json # Editor/workbench preferences for this repo +├─ scripts/ # CLI scripts (bash/python); dev + ops helpers +│ ├─ env/ # venv helpers (create, activate, pin) +│ ├─ dev/ # codegen, lint, format, typecheck wrappers +│ ├─ ops/ # backup, rotate logs, journalctl, users +│ └─ ci/ # sanity checks usable by CI (no runners assumed) +├─ configs/ # Centralized *.conf used by services +│ ├─ nginx/ # (optional) reverse proxy snippets (host‑level) +│ ├─ systemd/ # unit files for host services (no docker) +│ ├─ security/ # fail2ban, firewall/ipset lists, tls policy +│ └─ app/ # app‑level INI/YAML/TOML configs shared across apps +├─ docs/ # Markdown docs (specs, ADRs, guides) +│ ├─ 00-index.md +│ ├─ adr/ # Architecture Decision Records +│ ├─ specs/ # Protocol, API, tokenomics, flows +│ ├─ runbooks/ # Ops runbooks (rotate keys, restore, etc.) +│ └─ diagrams/ # draw.io/mermaid sources + exported PNG/SVG +├─ packages/ # Shared libraries (language‑specific) +│ ├─ py/ # Python packages (FastAPI, utils, protocol) +│ │ ├─ aitbc-core/ # Protocol models, validation, common types +│ │ ├─ aitbc-crypto/ # Key mgmt, signing, wallet primitives +│ │ ├─ aitbc-p2p/ # Node discovery, gossip, transport +│ │ ├─ aitbc-scheduler/ # Task slicing/merging, scoring, QoS +│ │ └─ aitbc-sdk/ # Client SDK for Python integrations +│ └─ js/ # Browser/Node shared libs +│ ├─ aitbc-sdk/ # Client SDK (fetch/ws), typings +│ └─ ui-widgets/ # Reusable UI bits for web apps +├─ apps/ # First‑class runnable services & UIs +│ ├─ client-web/ # Browser UI for users (requests, wallet, status) +│ │ ├─ public/ # static assets +│ │ ├─ src/ +│ │ │ ├─ pages/ +│ │ │ ├─ components/ +│ │ │ ├─ lib/ # uses packages/js/aitbc-sdk +│ │ │ └─ styles/ +│ │ └─ README.md +│ ├─ coordinator-api/ # Central API orchestrating jobs ↔ miners +│ │ ├─ src/ +│ │ │ ├─ main.py # FastAPI entrypoint +│ │ │ ├─ routes/ +│ │ │ ├─ services/ # matchmaking, accounting, rate‑limits +│ │ │ ├─ domain/ # job models, receipts, accounting entities +│ │ │ └─ storage/ # adapters (postgres, files, kv) +│ │ ├─ migrations/ # SQL snippets (no migration framework forced) +│ │ └─ README.md +│ ├─ miner-node/ # Worker node daemon for GPU/CPU tasks +│ │ ├─ src/ +│ │ │ ├─ agent/ # job runner, sandbox mgmt, health probes +│ │ │ ├─ gpu/ # CUDA/OpenCL bindings (optional) +│ │ │ ├─ plugins/ # task kinds (LLM, ASR, vision, etc.) +│ │ │ └─ telemetry/ # metrics, logs, heartbeat +│ │ └─ README.md +│ ├─ wallet-daemon/ # Local wallet service (keys, signing, RPC) +│ │ ├─ src/ +│ │ └─ README.md +│ ├─ blockchain-node/ # Minimal chain (asset‑backed by compute) +│ │ ├─ src/ +│ │ │ ├─ consensus/ +│ │ │ ├─ mempool/ +│ │ │ ├─ ledger/ # state, balances, receipts linkage +│ │ │ └─ rpc/ +│ │ └─ README.md +│ ├─ pool-hub/ # Client↔miners pool + matchmaking gateway +│ │ ├─ src/ +│ │ └─ README.md +│ ├─ marketplace-web/ # Web app for offers, bids, stats +│ │ ├─ public/ +│ │ ├─ src/ +│ │ └─ README.md +│ └─ explorer-web/ # Chain explorer (blocks, tx, receipts) +│ ├─ public/ +│ ├─ src/ +│ └─ README.md +├─ protocols/ # Canonical protocol definitions +│ ├─ api/ # OpenAPI/JSON‑Schema for REST/WebSocket +│ ├─ receipts/ # Job receipt schema, signing rules +│ ├─ payouts/ # Mint/burn, staking, fees logic (spec) +│ └─ README.md +├─ data/ # Local dev datasets (small, sample only) +│ ├─ fixtures/ # seed users, nodes, jobs +│ └─ samples/ +├─ tests/ # Cross‑project test harness +│ ├─ e2e/ # end‑to‑end flows (client→coord→miner→wallet) +│ ├─ load/ # coordinator & miner stress scripts +│ └─ security/ # key rotation, signature verif, replay tests +├─ tools/ # Small CLIs, generators, mermaid->svg, etc. +│ └─ mkdiagram +└─ examples/ # Minimal runnable examples for integrators + ├─ quickstart-client-python/ + ├─ quickstart-client-js/ + └─ receipts-sign-verify/ +``` + +## Conventions + +- **Languages**: FastAPI/Python for backends; plain JS/TS for web; no Docker. +- **No global venvs**: each `apps/*` and `packages/py/*` can have its own `.venv/` (created by `scripts/env/*`). +- **Systemd over Docker**: unit files live under `configs/systemd/`, with service‑specific overrides documented in `docs/runbooks/`. +- **Static assets** belong to each web app under `public/`. Shared UI in `packages/js/ui-widgets`. +- **SQL**: keep raw SQL snippets in `apps/*/migrations/` (aligned with your “no migration framework” preference). Use `psqln` alias. +- **Security**: central policy under `configs/security/` (fail2ban, ipset lists, TLS ciphers). Keys never committed. + +## Minimal READMEs to create next + +Create a short `README.md` in each `apps/*` and `packages/*` with: + +1. Purpose & scope +2. How to run (dev) +3. Dependencies +4. Configs consumed (from `/configs/app`) +5. Systemd unit name & port (if applicable) + +## Suggested first tasks (Way of least resistance) + +1. **Bootstrap coordinator-api**: scaffold FastAPI `main.py`, `/health`, `/jobs`, `/miners` routes. +2. **SDKs**: implement `packages/py/aitbc-sdk` & `packages/js/aitbc-sdk` with basic auth + job submit. +3. **miner-node prototype**: heartbeat to coordinator and no‑GPU "echo" job plugin. +4. **client-web**: basic UI to submit a test job and watch status stream. +5. **receipts spec**: draft `protocols/receipts` and a sign/verify example in `examples/`. + diff --git a/docs/bootstrap/examples.md b/docs/bootstrap/examples.md new file mode 100644 index 0000000..c8c3a75 --- /dev/null +++ b/docs/bootstrap/examples.md @@ -0,0 +1,235 @@ +# examples/ — Minimal runnable examples for integrators + +This folder contains three self-contained, copy-pasteable starters that demonstrate how to talk to the coordinator API, submit jobs, poll status, and (optionally) verify signed receipts. + +``` +examples/ +├─ explorer-webexplorer-web/ # (docs live elsewhere; not a runnable example) +├─ quickstart-client-python/ # Minimal Python client +├─ quickstart-client-js/ # Minimal Node/Browser client +└─ receipts-sign-verify/ # Receipt format + sign/verify demos +``` + +> Conventions: Debian 12/13, zsh, no sudo, run as root if you like. Keep env in a `.env` file. Replace example URLs/tokens with your own. + +--- + +## 1) quickstart-client-python/ + +### What this shows +- Create a job request +- Submit to `COORDINATOR_URL` +- Poll job status until `succeeded|failed|timeout` +- Fetch the result payload +- (Optional) Save the receipt JSON for later verification + +### Files Windsurf should ensure exist +- `main.py` — the tiny client (≈ 80–120 LOC) +- `requirements.txt` — `httpx`, `python-dotenv` (and `pydantic` if you want models) +- `.env.example` — `COORDINATOR_URL`, `API_TOKEN` +- `README.md` — one-screen run guide + +### How to run +```sh +cd examples/quickstart-client-python +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt + +# Prepare environment +cp .env.example .env +# edit .env → set COORDINATOR_URL=https://api.local.test:8443, API_TOKEN=xyz + +# Run +python main.py --prompt "hello compute" --timeout 60 + +# Outputs: +# - logs to stdout +# - writes ./out/result.json +# - writes ./out/receipt.json (if provided by the coordinator) +``` + +### Coordinator endpoints the code should touch +- `POST /v1/jobs` → returns `{ job_id }` +- `GET /v1/jobs/{job_id}` → returns `{ status, progress?, result? }` +- `GET /v1/jobs/{job_id}/receipt` → returns `{ receipt }` (optional) + +Keep the client resilient: exponential backoff (100ms → 2s), total wall-time cap from `--timeout`. + +--- + +## 2) quickstart-client-js/ + +### What this shows +- Identical flow to the Python quickstart +- Two variants: Node (fetch via `undici`) and Browser (native `fetch`) + +### Files Windsurf should ensure exist +- `node/` + - `package.json` — `undici`, `dotenv` + - `index.js` — Node example client + - `.env.example` + - `README.md` +- `browser/` + - `index.html` — minimal UI with a Prompt box + “Run” button + - `app.js` — client logic (no build step) + - `README.md` + +### How to run (Node) +```sh +cd examples/quickstart-client-js/node +npm i +cp .env.example .env +# edit .env → set COORDINATOR_URL, API_TOKEN +node index.js "hello compute" +``` + +### How to run (Browser) +```sh +cd examples/quickstart-client-js/browser +# Serve statically (choose one) +python3 -m http.server 8080 +# or +busybox httpd -f -p 8080 +``` +Open `http://localhost:8080` and paste your coordinator URL + token in the form. +The app: +- `POST /v1/jobs` +- polls `GET /v1/jobs/{id}` every 1s (with a 60s guard) +- downloads `receipt.json` if available + +--- + +## 3) receipts-sign-verify/ + +### What this shows +- Receipt JSON structure used by AITBC examples +- Deterministic signing over a canonicalized JSON (RFC 8785-style or stable key order) +- Ed25519 signing & verifying in Python and JS +- CLI snippets to verify receipts offline + +> If the project standardizes on another curve, swap the libs accordingly. For Ed25519: +> - Python: `pynacl` +> - JS: `@noble/ed25519` + +### Files Windsurf should ensure exist +- `spec.md` — human-readable schema (see below) +- `python/` + - `verify.py` — `python verify.py ./samples/receipt.json ./pubkeys/poolhub_ed25519.pub` + - `requirements.txt` — `pynacl` +- `js/` + - `verify.mjs` — `node js/verify.mjs ./samples/receipt.json ./pubkeys/poolhub_ed25519.pub` + - `package.json` — `@noble/ed25519` +- `samples/receipt.json` — realistic sample +- `pubkeys/poolhub_ed25519.pub` — PEM or raw 32-byte hex + +### Minimal receipt schema (for `spec.md`) +```jsonc +{ + "version": "1", + "job_id": "string", + "client_id": "string", + "miner_id": "string", + "started_at": "2025-09-26T14:00:00Z", + "completed_at": "2025-09-26T14:00:07Z", + "units_billed": 123, // e.g., “AIToken compute units” + "result_hash": "sha256:…", // hex + "metadata": { "model": "…" }, // optional, stable ordering for signing + "signature": { + "alg": "Ed25519", + "key_id": "poolhub-ed25519-2025-09", + "sig": "base64url…" // signature over canonicalized receipt WITHOUT this signature object + } +} +``` + +### CLI usage + +**Python** +```sh +cd examples/receipts-sign-verify/python +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +python verify.py ../samples/receipt.json ../pubkeys/poolhub_ed25519.pub +# exit code 0 = valid, non-zero = invalid +``` + +**Node** +```sh +cd examples/receipts-sign-verify/js +npm i +node verify.mjs ../samples/receipt.json ../pubkeys/poolhub_ed25519.pub +``` + +**Implementation notes for Windsurf** +- Canonicalize JSON before hashing/signing (stable key order, UTF-8, no trailing spaces). +- Sign bytes of `sha256(canonical_json_without_signature_block)`. +- Reject if `completed_at < started_at`, unknown `alg`, or mismatched `result_hash`. + +--- + +## Shared environment + +All quickstarts read the following from `.env` or in-page form fields: + +``` +COORDINATOR_URL=https://api.local.test:8443 +API_TOKEN=replace-me +# Optional: REQUEST_TIMEOUT_SEC=60 +``` + +HTTP headers to include: +``` +Authorization: Bearer +Content-Type: application/json +``` + +--- + +## Windsurf checklist (do this automatically) + +1. **Create folders & files** + - `quickstart-client-python/{main.py,requirements.txt,.env.example,README.md}` + - `quickstart-client-js/node/{index.js,package.json,.env.example,README.md}` + - `quickstart-client-js/browser/{index.html,app.js,README.md}` + - `receipts-sign-verify/{spec.md,samples/receipt.json,pubkeys/poolhub_ed25519.pub}` + - `receipts-sign-verify/python/{verify.py,requirements.txt}` + - `receipts-sign-verify/js/{verify.mjs,package.json}` + +2. **Fill templates** + - Implement `POST /v1/jobs`, `GET /v1/jobs/{id}`, `GET /v1/jobs/{id}/receipt` calls. + - Poll with backoff; stop at terminal states; write `out/result.json` & `out/receipt.json`. + +3. **Wire Ed25519 libs** + - Python: `pynacl` verify(`public_key`, `message`, `signature`) + - JS: `@noble/ed25519` verifySync + +4. **Add DX niceties** + - `.env.example` everywhere + - `README.md` with copy-paste run steps (no global installs) + - Minimal logging and clear non-zero exit on failure + +5. **Smoke tests** + - Python quickstart runs end-to-end with a mock coordinator (use our tiny FastAPI mock if available). + - JS Node client runs with `.env`. + - Browser client works via `http://localhost:8080`. + +--- + +## Troubleshooting + +- **401 Unauthorized** → check `API_TOKEN`, CORS (browser), or missing `Authorization` header. +- **CORS in browser** → coordinator must set: + - `Access-Control-Allow-Origin: *` (or your host) + - `Access-Control-Allow-Headers: Authorization, Content-Type` + - `Access-Control-Allow-Methods: GET, POST, OPTIONS` +- **Receipt verify fails** → most often due to non-canonical JSON or wrong public key. + +--- + +## License & reuse + +Keep examples MIT-licensed. Add a short header to each file: +``` +MIT © AITBC Examples — This is demo code; use at your own risk. +``` + diff --git a/docs/bootstrap/explorer_web.md b/docs/bootstrap/explorer_web.md new file mode 100644 index 0000000..ec95bf5 --- /dev/null +++ b/docs/bootstrap/explorer_web.md @@ -0,0 +1,322 @@ +# explorer-web.md +Chain Explorer (blocks · tx · receipts) + +## 0) Purpose +A lightweight, fast, dark-themed web UI to browse a minimal AITBC blockchain: +- Latest blocks & block detail +- Transaction list & detail +- Address detail (balance, nonce, tx history) +- Receipt / logs view +- Simple search (block #, hash, tx hash, address) + +MVP reads from **blockchain-node** (HTTP/WS API). +No write operations. + +--- + +## 1) Tech & Conventions +- **Pure frontend** (no backend rendering): static HTML + JS modules + CSS. +- **No frameworks** (keep it portable and fast). +- **Files split**: HTML, CSS, JS in separate files (user preference). +- **ES Modules** with strict typing via JSDoc or TypeScript (optional). +- **Dark theme** with orange/ice accents (brand). +- **No animations** unless explicitly requested. +- **Time format**: UTC ISO + relative (e.g., “2m ago”). + +--- + +## 2) Folder Layout (within workspace) +``` +explorer-web/ +├─ public/ +│ ├─ index.html # routes: / (latest blocks) +│ ├─ block.html # /block?hash=... or /block?number=... +│ ├─ tx.html # /tx?hash=... +│ ├─ address.html # /address?addr=... +│ ├─ receipts.html # /receipts?tx=... +│ ├─ 404.html +│ ├─ assets/ +│ │ ├─ logo.svg +│ │ └─ icons/*.svg +│ ├─ css/ +│ │ ├─ base.css +│ │ ├─ layout.css +│ │ └─ theme-dark.css +│ └─ js/ +│ ├─ config.js # API endpoint(s) +│ ├─ api.js # fetch helpers +│ ├─ store.js # simple state cache +│ ├─ utils.js # formatters (hex, time, numbers) +│ ├─ components/ +│ │ ├─ header.js +│ │ ├─ footer.js +│ │ ├─ searchbox.js +│ │ ├─ block-table.js +│ │ ├─ tx-table.js +│ │ ├─ pager.js +│ │ └─ keyvalue.js +│ ├─ pages/ +│ │ ├─ home.js # latest blocks + mempool/heads +│ │ ├─ block.js +│ │ ├─ tx.js +│ │ ├─ address.js +│ │ └─ receipts.js +│ └─ vendors/ # (empty; we keep it native for now) +├─ docs/ +│ └─ explorer-web.md # this file +└─ README.md +``` + +--- + +## 3) API Contracts (read-only) +Assume **blockchain-node** exposes: + +### 3.1 REST (HTTP) +- `GET /api/chain/head` → `{ number, hash, timestamp }` +- `GET /api/blocks?limit=25&before=` → `[{number,hash,parentHash,timestamp,txCount,miner,size,gasUsed}]` +- `GET /api/block/by-number/:n` → `{ ...fullBlock }` +- `GET /api/block/by-hash/:h` → `{ ...fullBlock }` +- `GET /api/tx/:hash` → `{ hash, from, to, nonce, value, fee, gas, gasPrice, blockHash, blockNumber, timestamp, input }` +- `GET /api/address/:addr` → `{ address, balance, nonce, txCount }` +- `GET /api/address/:addr/tx?limit=25&before=` → `[{hash,blockNumber,from,to,value,fee,timestamp}]` +- `GET /api/tx/:hash/receipt` → `{ status, gasUsed, logs: [{address, topics:[...], data, index}], cumulativeGasUsed }` +- `GET /api/search?q=...` + - Accepts block number, block hash, tx hash, or address + - Returns a typed result: `{ type: "block"|"tx"|"address" , key: ... }` + +### 3.2 WebSocket (optional, later) +- `ws://.../api/stream/heads` → emits new head `{number,hash,timestamp}` +- `ws://.../api/stream/mempool` → emits tx previews `{hash, from, to, value, timestamp}` + +> If the node isn’t ready, create a tiny mock server (FastAPI) consistent with these shapes (already planned in other modules). + +--- + +## 4) Pages & UX + +### 4.1 Header (every page) +- Left: logo + “AITBC Explorer” +- Center: search box (accepts block#, block/tx hash, address) +- Right: network tag (e.g., “Local Dev”) + head block# (live) + +### 4.2 Home `/` +- **Latest Blocks** (table) + - columns: `#`, `Hash (short)`, `Tx`, `Miner`, `GasUsed`, `Time` + - infinite scroll / “Load older” +- (optional) **Mempool feed** (compact list, toggleable) +- Empty state: helpful instructions + sample query strings + +### 4.3 Block Detail `/block?...` +- Top summary (KeyValue component) + - `Number, Hash, Parent, Miner, Timestamp, Size, GasUsed, Difficulty?` +- Transactions table (paginated) +- “Navigate”: Parent ↖, Next ↗, View in raw JSON (debug) + +### 4.4 Tx Detail `/tx?hash=...` +- Summary: `Hash, Status, Block, From, To, Value, Fee, Nonce, Gas(gasPrice)` +- Receipt section (logs rendered as topics/data, collapsible) +- Input data: hex preview + decode attempt (if ABI registry exists – later) + +### 4.5 Address `/address?addr=...` +- Summary: `Address, Balance, Nonce, TxCount` +- Transactions list (sent/received filter) +- (later) Token balances when chain supports it + +### 4.6 Receipts `/receipts?tx=...` +- Focused receipts + logs view with copy buttons + +### 4.7 404 +- Friendly message + search + +--- + +## 5) Components (JS modules) +- `header.js` : builds header + binds search submit. +- `searchbox.js` : debounced input, detects type (see utils). +- `block-table.js` : render rows, short-hash, time-ago. +- `tx-table.js` : similar render with direction arrows. +- `pager.js` : simple “Load more” with event callback. +- `keyvalue.js` : `
    ` key/value grid for details. +- `footer.js` : version, links. + +--- + +## 6) Utils +- `formatHexShort(hex, bytes=4)` → `0x1234…abcd` +- `formatNumber(n)` with thin-space groupings +- `formatValueWei(wei)` → AIT units when available (or plain wei) +- `timeAgo(ts)` + `formatUTC(ts)` +- `parseQuery()` helpers for `?hash=...` +- `detectSearchType(q)`: + - `0x` + 66 chars → tx/block hash + - numeric → block number + - `0x` + 42 → address + - fallback → “unknown” + +--- + +## 7) State (store.js) +- `state.head` (number/hash/timestamp) +- `state.cache.blocks[number] = block` +- `state.cache.txs[hash] = tx` +- `state.cache.address[addr] = {balance, nonce, txCount}` +- Simple in-memory LRU eviction (optional). + +--- + +## 8) Styling +- `base.css`: resets, typography, links, buttons, tables. +- `layout.css`: header/footer, grid, content widths (max 960px desktop). +- `theme-dark.css`: colors: + - bg: `#0b0f14`, surface: `#11161c` + - text: `#e6eef7` + - accent-orange: `#ff8a00` + - accent-ice: `#a8d8ff` +- Focus states visible. High contrast table rows on hover. + +--- + +## 9) Error & Loading UX +- Loading spinners (minimal). +- Network errors: inline banner with retry. +- Empty: clear messages & how to search. + +--- + +## 10) Security & Hardening +- Treat inputs as untrusted. +- Only GETs; block any attempt to POST. +- Strict `Content-Security-Policy` sample (for hosting): + - `default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self' https://blockchain-node.local;` +- Avoid third-party CDNs. + +--- + +## 11) Test Plan (manual first) +1. Home loads head + 25 latest blocks. +2. Scroll/pager loads older batches. +3. Block search by number + by hash. +4. Tx search → detail + receipt. +5. Address search → tx list. +6. Error states when node is offline. +7. Timezones: display UTC consistently. + +--- + +## 12) Dev Tasks (Windsurf order of work) +1. **Scaffold** folders & empty files. +2. Implement `config.js` with `API_BASE`. +3. Implement `api.js` (fetch JSON + error handling). +4. Build `utils.js` (formatters + search detect). +5. Build `header.js` + `footer.js`. +6. Home page: blocks list + pager. +7. Block detail page. +8. Tx detail + receipts. +9. Address page with tx list. +10. 404 + polish (copy buttons, tiny helpers). +11. CSS pass (dark theme). +12. Final QA. + +--- + +## 13) Mock Data (for offline dev) +Place under `public/js/vendors/mock.js` (opt-in): +- Export functions that resolve Promises with static JSON fixtures in `public/mock/*.json`. +- Toggle via `config.js` flag `USE_MOCK=true`. + +--- + +## 14) Minimal HTML Skeleton (example: index.html) +```html + + + + + + AITBC Explorer + + + + + +
    +
    +
    + + + +``` + +--- + +## 15) config.js (example) +```js +export const CONFIG = { + API_BASE: 'http://localhost:8545', // adapt to blockchain-node + USE_MOCK: false, + PAGE_SIZE: 25, + NETWORK_NAME: 'Local Dev', +}; +``` + +--- + +## 16) API Helpers (api.js — sketch) +```js +import { CONFIG } from './config.js'; + +async function jget(path) { + const res = await fetch(`${CONFIG.API_BASE}${path}`, { method: 'GET' }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); + return res.json(); +} + +export const api = { + head: () => jget('/api/chain/head'), + blocks: (limit, before) => jget(`/api/blocks?limit=${limit}&before=${before ?? ''}`), + blockByNo: (n) => jget(`/api/block/by-number/${n}`), + blockByHash: (h) => jget(`/api/block/by-hash/${h}`), + tx: (hash) => jget(`/api/tx/${hash}`), + receipt: (hash) => jget(`/api/tx/${hash}/receipt`), + address: (addr) => jget(`/api/address/${addr}`), + addressTx: (addr, limit, before) => jget(`/api/address/${addr}/tx?limit=${limit}&before=${before ?? ''}`), + search: (q) => jget(`/api/search?q=${encodeURIComponent(q)}`), +}; +``` + +--- + +## 17) Performance Checklist +- Use pagination/infinite scroll (no huge payloads). +- Cache recent blocks/tx in-memory (store.js). +- Avoid layout thrash: table builds via DocumentFragment. +- Defer non-critical fetches (e.g., mempool). +- Keep CSS small and critical. + +--- + +## 18) Deployment +- Serve `public/` via Nginx under `/explorer/` or own domain. +- Set correct `connect-src` in CSP to point to blockchain-node. +- Ensure CORS on blockchain-node for the explorer origin (read-only). + +--- + +## 19) Roadmap (post-MVP) +- Live head updates via WS. +- Mempool stream view. +- ABI registry + input decoding. +- Token balances (when chain supports). +- Export to CSV / JSON for tables. +- Theming switch (dark/light). + +--- + diff --git a/docs/bootstrap/layout.md b/docs/bootstrap/layout.md new file mode 100644 index 0000000..ca73dd5 --- /dev/null +++ b/docs/bootstrap/layout.md @@ -0,0 +1,468 @@ +# Layout & Frontend Guidelines (Windsurf) + +Target: **mobile‑first**, dark theme, max content width **960px** on desktop. Reference device: **Nothing Phone 2a**. + +--- + +## 1) Design System + +### 1.1 Color (Dark Theme) +- `--bg-0: #0b0f14` (page background) +- `--bg-1: #11161c` (cards/sections) +- `--tx-0: #e6edf3` (primary text) +- `--tx-1: #a7b3be` (muted) +- `--pri: #ff7a1a` (accent orange) +- `--ice: #b9ecff` (ice accent) +- `--ok: #3ddc97` +- `--warn: #ffcc00` +- `--err: #ff4d4d` + +### 1.2 Typography +- Base font-size: **16px** (mobile), scale up at desktop. +- Font stack: System UI (`-apple-system, Segoe UI, Roboto, Inter, Arial, sans-serif`). +- Line-height: 1.5 body, 1.2 headings. + +### 1.3 Spacing (8‑pt grid) +- `--s-1: 4px`, `--s-2: 8px`, `--s-3: 12px`, `--s-4: 16px`, `--s-5: 24px`, `--s-6: 32px`, `--s-7: 48px`, `--s-8: 64px`. + +### 1.4 Radius & Shadow +- Radius: `--r-1: 8px`, `--r-2: 16px`. +- Shadow (subtle): `0 4px 20px rgba(0,0,0,.25)`. + +--- + +## 2) Grid & Layout + +### 2.1 Container +- **Mobile‑first**: full‑bleed padding. +- Desktop container: **max‑width: 960px**, centered. +- Side gutters: 16px (mobile), 24px (tablet), 32px (desktop). + +**Breakpoint summary** +| Token | Min width | Container behaviour | Notes | +| --- | --- | --- | --- | +| `--bp-sm` | 360px | Fluid | Single-column layouts prioritise readability. +| `--bp-md` | 480px | Fluid | Allow two-up cards or media/text pairings when needed. +| `--bp-lg` | 768px | Max-width 90% (capped at 960px) | Stage tablet/landscape experiences before full desktop. +| `--bp-xl` | 960px | Fixed 960px max width | Full desktop grid, persistent side rails allowed. + +Always respect `env(safe-area-inset-*)` for notch devices (use helpers like `.safe-b`). + +### 2.2 Columns +- 12‑column grid on screens ≥ **960px**. +- Column gutter: 16px (mobile), 24px (≥960px). +- Utility classes (examples): + - `.row { display:grid; grid-template-columns: repeat(12, 1fr); gap: var(--gutter); }` + - `.col-12, .col-6, .col-4, .col-3` for common spans on desktop. + - Mobile stacks by default; use responsive helpers at breakpoints. + +### 2.3 Breakpoints (Nothing Phone 2a aware) +- `--bp-sm: 360px` (small phones) +- `--bp-md: 480px` (Nothing 2a width in portrait ~ 412–480 CSS px) +- `--bp-lg: 768px` (tablets / landscape phones) +- `--bp-xl: 960px` (desktop container) + +**Mobile layout rules:** +- Navigation collapses to icon buttons with overflow menu at `--bp-sm`. +- Multi-column sections stack; keep vertical rhythm using `var(--s-6)`. +- Sticky headers take 56px height; ensure content uses `.safe-b` for bottom insets. + +**Desktop enhancements:** +- Activate `.row` grid with `.col-*` spans at `--bp-xl`. +- Introduce side rail for filters or secondary nav (span 3 or 4 columns). +- Increase typographic scale by 1 step (`clamp` already handles this). + +> **Rule:** Build for < `--bp-lg` first; enhance progressively at `--bp-lg` and `--bp-xl`. + +--- + +## 3) Page Chrome + +### 3.1 Header +- Sticky top, height 56–64px. +- Left: brand; Right: primary action or menu. +- Translucent on scroll (backdrop‑filter), solid at top. + +### 3.2 Footer +- Thin bar; meta links. Uses ice accent for separators. + +### 3.3 Main +- Vertical rhythm: sections spaced by `var(--s-7)` (mobile) / `var(--s-8)` (desktop). +- Cards: background `var(--bg-1)`, radius `var(--r-2)`, padding `var(--s-6)`. + +--- + +## 4) CSS File Strategy (per page) + +**Every HTML page ships its own CSS file**, plus shared layers: + +- `/css/base.css` — resets, variables, typography, utility helpers. +- `/css/components.css` — buttons, inputs, cards, modals, toast. +- `/css/layout.css` — grid/container/header/footer. +- `/css/pages/.css` — page‑specific rules (one per HTML page). + +**Naming** (BEM‑ish): `block__elem--mod`. Avoid nesting >2 levels. + +**Example includes:** +```html + + + + +``` + +--- + +## 5) Utilities (recommended) +- Spacing: `.mt-4`, `.mb-6`, `.px-4`, `.py-6` (map to spacing scale). +- Flex/Grid: `.flex`, `.grid`, `.ai-c`, `.jc-b`. +- Display: `.hide-sm`, `.hide-lg` using media queries. + +--- + +## 6) Toast Messages (center bottom) + +**Position:** centered at bottom above safe‑area insets. + +**Behavior:** +- Appear for 3–5s; pause on hover; dismiss on click. +- Max width 90% on mobile, 420px desktop. +- Elevation + subtle slide/fade. + +**Structure:** +```html +
    +``` + +**Styles (concept):** +```css +#toast-root { position: fixed; left: 50%; bottom: max(16px, env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 9999; } +.toast { background: var(--bg-1); color: var(--tx-0); border: 1px solid rgba(185,236,255,.2); padding: var(--s-5) var(--s-6); border-radius: var(--r-2); box-shadow: 0 10px 30px rgba(0,0,0,.35); margin-bottom: var(--s-4); max-width: min(420px, 90vw); } +.toast--ok { border-color: rgba(61,220,151,.35); } +.toast--warn { border-color: rgba(255,204,0,.35); } +.toast--err { border-color: rgba(255,77,77,.35); } +``` + +**JS API (minimal):** +```js +function showToast(msg, type = "ok", ms = 3500) { + const root = document.getElementById("toast-root"); + const el = document.createElement("div"); + el.className = `toast toast--${type}`; + el.role = "status"; + el.textContent = msg; + root.appendChild(el); + const t = setTimeout(() => el.remove(), ms); + el.addEventListener("mouseenter", () => clearTimeout(t)); + el.addEventListener("click", () => el.remove()); +} +``` + +--- + +## 7) Browser Notifications (system tray) + +**When to use:** Only for important, user‑initiated events (e.g., a new match, message, or scheduled session start). Always provide an in‑app alternative (toast/modal) for users who deny permission. + +**Permission flow:** +```js +async function ensureNotifyPermission() { + if (!("Notification" in window)) return false; + if (Notification.permission === "granted") return true; + if (Notification.permission === "denied") return false; + const res = await Notification.requestPermission(); + return res === "granted"; +} +``` + +**Send notification:** +```js +function notify(opts) { + if (Notification.permission !== "granted") return; + const n = new Notification(opts.title || "Update", { + body: opts.body || "", + icon: opts.icon || "/icons/notify.png", + tag: opts.tag || "app-event", + requireInteraction: !!opts.sticky + }); + if (opts.onclick) n.addEventListener("click", opts.onclick); +} +``` + +**Pattern:** +```js +const ok = await ensureNotifyPermission(); +if (ok) notify({ title: "Match window opens soon", body: "Starts in 10 min" }); +else showToast("Enable notifications in settings to get alerts", "warn"); +``` + +--- + +## 8) Forms & Inputs +- Hit target ≥ 44×44px, labels always visible. +- Focus ring: `outline: 2px solid var(--ice)`. +- Validation: inline text in `--warn/--err`; never only color. + +--- + +## 9) Performance +- CSS: ship **only** what a page uses (per‑page CSS). Avoid giant bundles. +- Images: `loading="lazy"`, responsive sizes; WebP/AVIF first. +- Fonts: use system fonts; if custom, `font-display: swap`. + +--- + +## 10) Accessibility +- Ensure color contrast ratios meet WCAG AA standards (e.g., 4.5:1 for text and 3:1 for large text). +- Use semantic HTML elements (
    ,