Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 55 additions & 4 deletions .github/workflows/ci-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,41 @@ jobs:
- name: Install dependencies
run: python -m pip install -r requirements.txt

- name: Run tests
run: python -m pytest
- name: Run backend unit and service tests
run: python -m pytest --ignore=tests/live_node

- name: Run scenario security, cleanup, redaction, and export gates
run: >-
python -m pytest
tests/test_security.py
tests/test_mutation_route_security.py
tests/test_network_safety.py
tests/test_scenario_models.py
tests/test_scenario_service.py
tests/test_transaction_lifecycle_scenario.py
tests/test_attack_verification_service.py
tests/test_evidence_service.py
tests/test_reviewer_demo_service.py

- name: Summarize backend scenario gates
shell: bash
run: |
{
echo "## Verified Scenarios backend gates"
echo "- Unit and service suite passed"
echo "- Mutation token, origin, network, and route guards passed"
echo "- Success, failure, interruption, and cleanup paths passed"
echo "- Evidence redaction, manifest hashing, report, and bundle export passed"
} >> "$GITHUB_STEP_SUMMARY"

bitcoin-core-integration:
name: Bitcoin Core 28.1 regtest integration
name: Bitcoin Core 28.1 regtest integration (${{ matrix.clean_datadir }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
clean_datadir: [first, second]
defaults:
run:
working-directory: backend
Expand Down Expand Up @@ -102,6 +130,18 @@ jobs:
- name: Run deterministic live-node workflows and treasury proof
run: python -m pytest tests/live_node -v

- name: Summarize live Verified Scenarios
shell: bash
working-directory: .
run: |
{
echo "## Verified Scenarios on Bitcoin Core 28.1"
echo "- Clean datadir pass: ${{ matrix.clean_datadir }}"
echo "- Transaction lifecycle, RBF, multisig PSBT, and CLTV passed"
echo "- Community Treasury Recovery and Core-reaching attacks passed"
echo "- Deterministic evidence export and session-owned cleanup passed"
} >> "$GITHUB_STEP_SUMMARY"

- name: Show Bitcoin Core logs on failure
if: failure()
shell: bash
Expand Down Expand Up @@ -134,6 +174,12 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Type check
run: npm run typecheck

- name: Lint
run: npm run lint

- name: Build
run: npm run build

Expand Down Expand Up @@ -172,14 +218,19 @@ jobs:
test -f docs/limitations.md
test -f docs/docker-regtest.md
test -f docs/supported-bitcoin-core.md
test -f docs/verified-scenarios.md
test -f docs/testing-and-ci.md

- name: Summarize release gate
shell: bash
run: |
{
echo "## BitScope release readiness"
echo "- Backend tests passed"
echo "- Frontend build passed"
echo "- Verified Scenarios security, cleanup, redaction, report, and export gates passed"
echo "- Verified Scenarios passed twice against disposable Bitcoin Core 28.1 datadirs"
echo "- Community Treasury Recovery flagship and Core-reaching attacks passed"
echo "- Frontend type check, lint, and production build passed"
echo "- Docker Compose config validated"
echo "- Required teaching and operations docs are present"
} >> "$GITHUB_STEP_SUMMARY"
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,19 @@ Backend:

```bash
cd backend
pytest
python -m pytest --ignore=tests/live_node
```

Frontend:

```bash
cd frontend
npm run typecheck
npm run lint
npm run build
```

CI runs backend tests, frontend build, and Docker Compose config validation through [CI / Staging](.github/workflows/ci-staging.yml).
CI runs backend hardening gates, the complete live suite twice against clean disposable Bitcoin Core 28.1 datadirs, frontend type-check/lint/build, and Docker Compose validation through [CI / Staging](.github/workflows/ci-staging.yml).

## Documentation

Expand All @@ -193,13 +195,14 @@ CI runs backend tests, frontend build, and Docker Compose config validation thro
- [Regtest guide](docs/regtest-guide.md): mining, coinbase maturity, and demo flow.
- [Demo script](docs/demo-script.md): reviewer-facing walkthrough.
- [Live RPC testing](docs/live-rpc-testing.md): isolated live-node pytest lifecycle and regtest failure mitigations.
- [Testing and CI](docs/testing-and-ci.md): Phase 10 commands, coverage map, and release-readiness gates.
- [Supported Bitcoin Core](docs/supported-bitcoin-core.md): pinned CI version, support policy, and deterministic regtest coverage.
- [Limitations](docs/limitations.md): no hosted APIs, address-history limits, mainnet safety.
- [Contributing](CONTRIBUTING.md): development workflow, verification commands, safety invariants, and pull-request expectations.

## Demo Story

The intended demo starts from node status, creates or loads a regtest wallet, mines spendable coins, inspects blocks and transactions, explores mempool policy, exercises multisig/PSBT/timelock/script/data workflows, then closes with Integrations, Keys, and Learn to connect the UI back to `bitcoin-cli` and Bitcoin Core concepts.
The canonical reviewer demo is `/capstone-demo`. It creates a clean persistent lab, verifies Core and regtest, runs Community Treasury Recovery by default (with a shorter transaction-lifecycle fallback), shows expected attacks and raw bounded Core evidence, renders the recorded lifecycle, downloads the deterministic proof bundle, and closes on explicit cleanup status. See [the reviewer demo script](docs/demo-script.md) for the five-minute and technical walkthroughs.

## License

Expand Down
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from app.config import Settings, get_settings
from app.errors import BitScopeError, bitscope_error_handler, http_exception_handler
from app.middleware import RequestBodyLimitMiddleware
from app.routes import addresses, blocks, demo, descriptors, fees, health, indexer, integrations, keys, labs, learning, live, mempool, multisig, node, peers, psbt, regtest, rpc_explorer, scenarios, scripts, taproot, timelocks, transactions, wallets
from app.routes import addresses, blocks, demo, descriptors, fees, health, indexer, integrations, keys, labs, learning, live, mempool, multisig, node, peers, psbt, regtest, reviewer, rpc_explorer, scenarios, scripts, taproot, timelocks, transactions, wallets


def create_app(settings: Settings | None = None) -> FastAPI:
Expand Down Expand Up @@ -57,6 +57,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(keys.router, prefix=settings.api_prefix)
app.include_router(live.router, prefix=settings.api_prefix)
app.include_router(labs.router, prefix=settings.api_prefix)
app.include_router(reviewer.router, prefix=settings.api_prefix)
app.include_router(scenarios.catalog_router, prefix=settings.api_prefix)
app.include_router(scenarios.run_router, prefix=settings.api_prefix)

Expand Down
90 changes: 90 additions & 0 deletions backend/app/models/policy_comparison.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from __future__ import annotations

from enum import StrEnum
from typing import Literal

from pydantic import Field, JsonValue, model_validator

from app.models.scenario import Identifier, StrictScenarioModel


class PolicyMetricCertainty(StrEnum):
EXACT = "exact"
DERIVED = "derived"
ESTIMATED = "estimated"
UNKNOWN = "unknown"
UNSUPPORTED = "unsupported"


class PolicyMetric(StrictScenarioModel):
metric_id: Identifier
label: str = Field(min_length=1, max_length=120)
certainty: PolicyMetricCertainty
value: JsonValue | None = None
unit: str | None = Field(default=None, min_length=1, max_length=32)
explanation: str = Field(min_length=1, max_length=1_000)

@model_validator(mode="after")
def certainty_controls_value(self) -> PolicyMetric:
unavailable = self.certainty in {
PolicyMetricCertainty.UNKNOWN,
PolicyMetricCertainty.UNSUPPORTED,
}
if unavailable and self.value is not None:
raise ValueError("Unknown and unsupported policy metrics cannot contain invented values.")
if not unavailable and self.value is None:
raise ValueError("Exact, derived, and estimated policy metrics must contain a value.")
return self


class PolicyScenarioLink(StrictScenarioModel):
scenario_id: Identifier
label: str = Field(min_length=1, max_length=120)
href: str = Field(min_length=1, max_length=256, pattern=r"^/scenarios(?:[/?#].*)?$")


class PolicyBranchEstimate(StrictScenarioModel):
branch_id: Identifier
label: str = Field(min_length=1, max_length=120)
required_signatures: int = Field(ge=1, le=15)
eligible_signers: int = Field(ge=1, le=15)
relative_delay_blocks: int | None = Field(default=None, ge=1, le=65_535)
absolute_delay: Literal[None] = None
script_size_bytes: int = Field(ge=1, le=10_000)
estimated_witness_bytes: int = Field(ge=1, le=100_000)
estimated_transaction_weight_wu: int = Field(ge=1, le=400_000)
estimated_transaction_vbytes: int = Field(ge=1, le=100_000)
estimated_fee_sats: int = Field(ge=1)
estimate_basis: str = Field(min_length=1, max_length=1_000)


class PolicyComparisonEntry(StrictScenarioModel):
policy_id: Identifier
title: str = Field(min_length=1, max_length=120)
summary: str = Field(min_length=1, max_length=1_000)
metrics: list[PolicyMetric] = Field(min_length=1, max_length=32)
branches: list[PolicyBranchEstimate] = Field(min_length=1, max_length=16)
scenario_links: list[PolicyScenarioLink] = Field(min_length=1, max_length=16)


class PolicyCatalogItem(StrictScenarioModel):
policy_id: Identifier
title: str = Field(min_length=1, max_length=120)
summary: str = Field(min_length=1, max_length=1_000)


class PolicyCatalogResponse(StrictScenarioModel):
policies: list[PolicyCatalogItem] = Field(min_length=2, max_length=32)
default_left_policy_id: Identifier
default_right_policy_id: Identifier


class PolicyComparisonResponse(StrictScenarioModel):
schema_version: Literal[1] = 1
selected_fee_rate_sat_vb: str = Field(pattern=r"^[0-9]+(?:\.[0-9]{1,3})?$")
fee_rate_source: Literal["user_selected"] = "user_selected"
market_fee_estimate: PolicyMetric
transaction_template: str = Field(min_length=1, max_length=1_000)
metric_order: list[Identifier] = Field(min_length=1, max_length=32)
policies: list[PolicyComparisonEntry] = Field(min_length=2, max_length=2)
warnings: list[str] = Field(min_length=1, max_length=16)
57 changes: 57 additions & 0 deletions backend/app/models/reviewer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from __future__ import annotations

from typing import Literal
from uuid import UUID

from pydantic import Field

from app.models.lab import LabSession
from app.models.scenario import Identifier, ScenarioRun, ScenarioRunState, StrictScenarioModel
from app.models.scenario_api import ScenarioCatalogEntry, ScenarioDetailResponse


class ReviewerWalkthroughStep(StrictScenarioModel):
ordinal: int = Field(ge=1, le=20)
step_id: Identifier
title: str = Field(min_length=1, max_length=120)
explanation: str = Field(min_length=1, max_length=1_000)
evidence_source: str = Field(min_length=1, max_length=256)


class ReviewerDemoDefinition(StrictScenarioModel):
schema_version: Literal[1] = 1
default_scenario_id: Literal["community-treasury-recovery"] = "community-treasury-recovery"
fallback_scenario_id: Literal["transaction-lifecycle"] = "transaction-lifecycle"
scenarios: list[ScenarioCatalogEntry] = Field(min_length=2, max_length=2)
prerequisites: list[str] = Field(min_length=1, max_length=16)
walkthrough: list[ReviewerWalkthroughStep] = Field(min_length=12, max_length=12)
limitations: list[str] = Field(min_length=1, max_length=16)


class ReviewerDemoStartRequest(StrictScenarioModel):
scenario_id: Identifier = "community-treasury-recovery"


class ReviewerDemoMutationRequest(StrictScenarioModel):
lab_session_id: str = Field(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$")
expected_revision: int = Field(ge=0)
replacement_scenario_id: Identifier | None = None


class ReviewerDemoRun(StrictScenarioModel):
definition: ScenarioDetailResponse
lab: LabSession
run: ScenarioRun
proof_bundle_url: str = Field(min_length=1, max_length=512)
report_url: str = Field(min_length=1, max_length=512)
lifecycle_url: str = Field(min_length=1, max_length=512)
can_advance: bool
can_export: bool
can_reset: Literal[True] = True


class ReviewerDemoResetResponse(StrictScenarioModel):
previous_run_id: UUID
previous_final_state: ScenarioRunState
interrupted_run_recovered: bool
replacement: ReviewerDemoRun
20 changes: 19 additions & 1 deletion backend/app/routes/learning.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends
from decimal import Decimal

from fastapi import APIRouter, Depends, Query

from app.config import get_settings
from app.models.curriculum import (
Expand All @@ -9,9 +11,11 @@
CurriculumResponse,
)
from app.models.learning import LearningConceptsResponse, LearningRpcMethodsResponse
from app.models.policy_comparison import PolicyCatalogResponse, PolicyComparisonResponse
from app.services.challenge_service import ChallengeService
from app.services.curriculum_service import CurriculumService
from app.services.learning_service import LearningService
from app.services.policy_comparison_service import PolicyComparisonService
from app.services.scenario_artifact_store import ScenarioArtifactStore
from app.services.scenario_run_store import ScenarioRunStore

Expand Down Expand Up @@ -43,6 +47,20 @@ def get_curriculum() -> CurriculumResponse:
return CurriculumService().curriculum()


@router.get("/policies", response_model=PolicyCatalogResponse)
def list_comparison_policies() -> PolicyCatalogResponse:
return PolicyComparisonService().catalog()


@router.get("/policies/compare", response_model=PolicyComparisonResponse)
def compare_policies(
left_policy_id: str = PolicyComparisonService.SIMPLE_POLICY_ID,
right_policy_id: str = PolicyComparisonService.TREASURY_POLICY_ID,
fee_rate_sat_vb: Decimal = Query(default=Decimal("2"), ge=Decimal("0.1"), le=Decimal("10000")),
) -> PolicyComparisonResponse:
return PolicyComparisonService().compare(left_policy_id, right_policy_id, fee_rate_sat_vb)


@router.get("/challenges", response_model=ChallengeCatalogResponse)
def list_challenges(
service: ChallengeService = Depends(get_challenge_service),
Expand Down
Loading
Loading