diff --git a/.github/workflows/ci-staging.yml b/.github/workflows/ci-staging.yml index 91bd798..69d4dac 100644 --- a/.github/workflows/ci-staging.yml +++ b/.github/workflows/ci-staging.yml @@ -99,7 +99,7 @@ jobs: docker logs bitscope-core-ci exit 1 - - name: Run deterministic live-node workflows + - name: Run deterministic live-node workflows and treasury proof run: python -m pytest tests/live_node -v - name: Show Bitcoin Core logs on failure diff --git a/.gitignore b/.gitignore index eea31f8..22c79d4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ venv/ # Environment .env +.env.local backend/.env backend/.env.docker .docker-local/ diff --git a/README.md b/README.md index 0ac3efc..79aa24b 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,20 @@ Regtest is the recommended development and demo network. txindex=1 ``` -2. Start Bitcoin Core. +2. Start Bitcoin Core. Use the first command for the normal regtest node, or the second for an RPC-only local node with inbound P2P disabled. ```bash bitcoind -regtest -daemon - bitcoin-cli -regtest getblockchaininfo + ``` + + ```bash + bitcoind -regtest -daemon -listen=0 + ``` + + Wait for RPC readiness and inspect the chain: + + ```bash + bitcoin-cli -regtest -rpcwait -rpcwaittimeout=30 getblockchaininfo ``` 3. Configure and run the backend. diff --git a/backend/.env.example b/backend/.env.example index 33f94d6..181dee3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,3 +15,4 @@ MAX_REQUEST_BODY_BYTES=1048576 # Generate a unique local value and use the same value in frontend/.env.local. BITSCOPE_LOCAL_ACCESS_TOKEN=replace-with-a-random-local-token LAB_SESSION_DATABASE_PATH=data/lab-sessions.sqlite3 +SCENARIO_ARTIFACT_ROOT=data/scenario-artifacts diff --git a/backend/app/config.py b/backend/app/config.py index 0e41a51..a0a0c36 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -29,6 +29,7 @@ class Settings(BaseSettings): max_request_body_bytes: int = Field(default=1_048_576, ge=1_024, le=10_485_760) bitscope_local_access_token: str = Field(default_factory=lambda: token_urlsafe(32), repr=False) lab_session_database_path: str = "data/lab-sessions.sqlite3" + scenario_artifact_root: str = "data/scenario-artifacts" model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/app/main.py b/backend/app/main.py index 5a88d5b..5f53881 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, 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, rpc_explorer, scenarios, scripts, taproot, timelocks, transactions, wallets def create_app(settings: Settings | None = None) -> FastAPI: @@ -57,6 +57,8 @@ 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(scenarios.catalog_router, prefix=settings.api_prefix) + app.include_router(scenarios.run_router, prefix=settings.api_prefix) return app diff --git a/backend/app/models/attack.py b/backend/app/models/attack.py new file mode 100644 index 0000000..9307c97 --- /dev/null +++ b/backend/app/models/attack.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import Field, JsonValue, field_validator, model_validator + +from app.models.scenario import ArtifactKey, FailureCategory, Identifier, StrictScenarioModel + + +class AttackType(StrEnum): + SIGNATURE_INSUFFICIENCY = "signature_insufficiency" + PSBT_INCOMPLETENESS = "psbt_incompleteness" + OUTPUT_MODIFICATION = "output_modification" + INPUT_MODIFICATION = "input_modification" + SEQUENCE_MODIFICATION = "sequence_modification" + LOCKTIME_MODIFICATION = "locktime_modification" + PREMATURE_TIMELOCK_EXECUTION = "premature_timelock_execution" + INVALID_SCRIPT_BRANCH = "invalid_script_branch" + DUST_OUTPUT = "dust_output" + FEE_POLICY_FAILURE = "fee_policy_failure" + MISSING_PARENT_TRANSACTION = "missing_parent_transaction" + DOUBLE_SPEND_ATTEMPT = "double_spend_attempt" + RBF_REPLACEMENT_POLICY_FAILURE = "rbf_replacement_policy_failure" + RUNTIME_NETWORK_MISMATCH = "runtime_network_mismatch" + + +class AttackFeature(StrEnum): + RAW_TRANSACTION = "raw_transaction" + WALLET_TRANSACTION = "wallet_transaction" + PSBT = "psbt" + THRESHOLD_POLICY = "threshold_policy" + MUTABLE_INPUTS = "mutable_inputs" + MUTABLE_OUTPUTS = "mutable_outputs" + ABSOLUTE_TIMELOCK = "absolute_timelock" + RELATIVE_TIMELOCK = "relative_timelock" + RBF_SIGNALING = "rbf_signaling" + KNOWN_PARENT = "known_parent" + MEMPOOL_PREFLIGHT = "mempool_preflight" + RPC_ERROR = "rpc_error" + + +class AttackApplicabilityStatus(StrEnum): + APPLICABLE = "applicable" + NOT_APPLICABLE = "not_applicable" + + +class AttackVerificationStatus(StrEnum): + EXPECTED_FAILURE = "expected_failure" + UNEXPECTED_FAILURE = "unexpected_failure" + SKIPPED = "skipped" + + +class RejectReasonMatch(StrEnum): + EXACT = "exact" + CONTAINS = "contains" + + +class AttackTypeProfile(StrictScenarioModel): + attack_type: AttackType + title: str = Field(min_length=1, max_length=120) + description: str = Field(min_length=1, max_length=1_000) + + +class MempoolRejectionExpectation(StrictScenarioModel): + kind: Literal["mempool_rejection"] = "mempool_rejection" + classification: FailureCategory + reject_reason: str = Field(min_length=1, max_length=240) + reason_match: RejectReasonMatch = RejectReasonMatch.EXACT + + +class PsbtIncompleteExpectation(StrictScenarioModel): + kind: Literal["psbt_incomplete"] = "psbt_incomplete" + classification: Literal[FailureCategory.PSBT_INCOMPLETE] = FailureCategory.PSBT_INCOMPLETE + require_no_transaction_hex: bool = True + observed_signature_count: int | None = Field(default=None, ge=0, le=64) + required_signature_count: int | None = Field(default=None, ge=1, le=64) + + @model_validator(mode="after") + def signature_threshold_is_coherent(self) -> "PsbtIncompleteExpectation": + if (self.observed_signature_count is None) != (self.required_signature_count is None): + raise ValueError("PSBT signature expectations require both observed and required counts.") + if ( + self.observed_signature_count is not None + and self.required_signature_count is not None + and self.observed_signature_count >= self.required_signature_count + ): + raise ValueError("An insufficient-signature expectation must remain below the threshold.") + return self + + +class RpcErrorExpectation(StrictScenarioModel): + kind: Literal["rpc_error"] = "rpc_error" + classification: FailureCategory + rpc_method: str = Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9]+$") + rpc_code: int + message_markers: list[str] = Field(default_factory=list, max_length=16) + + @field_validator("message_markers") + @classmethod + def markers_are_unique_and_bounded(cls, value: list[str]) -> list[str]: + if len(value) != len(set(value)): + raise ValueError("RPC message markers must be unique.") + if any(not marker or len(marker) > 120 for marker in value): + raise ValueError("RPC message markers must be non-empty and bounded.") + return value + + +AttackExpectation = Annotated[ + MempoolRejectionExpectation | PsbtIncompleteExpectation | RpcErrorExpectation, + Field(discriminator="kind"), +] + + +class AttackDefinition(StrictScenarioModel): + attack_id: ArtifactKey + attack_type: AttackType + title: str = Field(min_length=1, max_length=120) + description: str = Field(min_length=1, max_length=1_000) + scenario_ids: list[Identifier] = Field(min_length=1, max_length=32) + required_features: list[AttackFeature] = Field(default_factory=list, max_length=32) + expectation: AttackExpectation + + @field_validator("scenario_ids", "required_features") + @classmethod + def lists_are_unique(cls, value: list[object]) -> list[object]: + if len(value) != len(set(value)): + raise ValueError("Attack definition lists must not contain duplicates.") + return value + + +class AttackContext(StrictScenarioModel): + scenario_id: Identifier + available_features: list[AttackFeature] = Field(default_factory=list, max_length=32) + + @field_validator("available_features") + @classmethod + def features_are_unique(cls, value: list[AttackFeature]) -> list[AttackFeature]: + if len(value) != len(set(value)): + raise ValueError("Attack context features must be unique.") + return value + + +class AttackApplicabilityDecision(StrictScenarioModel): + attack_id: str | None = Field(default=None, min_length=2, max_length=96) + attack_type: AttackType + scenario_id: Identifier + status: AttackApplicabilityStatus + reason: str = Field(min_length=1, max_length=1_000) + missing_features: list[AttackFeature] = Field(default_factory=list, max_length=32) + + @model_validator(mode="after") + def decision_is_coherent(self) -> "AttackApplicabilityDecision": + if self.status == AttackApplicabilityStatus.APPLICABLE: + if self.attack_id is None or self.missing_features: + raise ValueError("Applicable attacks require an identifier and no missing features.") + return self + + +class MempoolAttackObservation(StrictScenarioModel): + kind: Literal["mempool_rejection"] = "mempool_rejection" + allowed: bool + reject_reason: str | None = Field(default=None, max_length=240) + raw_safe_details: JsonValue = None + + +class PsbtAttackObservation(StrictScenarioModel): + kind: Literal["psbt_incomplete"] = "psbt_incomplete" + complete: bool | None + transaction_hex_present: bool + signature_count: int | None = Field(default=None, ge=0, le=64) + raw_safe_details: JsonValue = None + + +class RpcErrorAttackObservation(StrictScenarioModel): + kind: Literal["rpc_error"] = "rpc_error" + rpc_method: str = Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9]+$") + rpc_code: int + rpc_message: str = Field(min_length=1, max_length=2_000) + raw_safe_details: JsonValue = None + + +AttackObservation = Annotated[ + MempoolAttackObservation | PsbtAttackObservation | RpcErrorAttackObservation, + Field(discriminator="kind"), +] + + +class AttackVerificationResult(StrictScenarioModel): + attack_id: str | None = Field(default=None, min_length=2, max_length=96) + attack_type: AttackType + scenario_id: Identifier + applicability: AttackApplicabilityStatus + status: AttackVerificationStatus + classification: FailureCategory | None = None + expected_classification: FailureCategory | None = None + safe_message: str = Field(min_length=1, max_length=2_000) + raw_safe_details: JsonValue = None + + @model_validator(mode="after") + def result_is_coherent(self) -> "AttackVerificationResult": + if self.status == AttackVerificationStatus.SKIPPED: + if self.applicability != AttackApplicabilityStatus.NOT_APPLICABLE: + raise ValueError("Skipped attacks must be explicitly not applicable.") + if self.classification is not None: + raise ValueError("Skipped attacks cannot claim a failure classification.") + elif self.applicability != AttackApplicabilityStatus.APPLICABLE: + raise ValueError("Executed attack results must have an applicable decision.") + return self diff --git a/backend/app/models/curriculum.py b/backend/app/models/curriculum.py new file mode 100644 index 0000000..91da9b2 --- /dev/null +++ b/backend/app/models/curriculum.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import Field, model_validator + +from app.models.scenario import ArtifactKey, EvidenceKind, Identifier, StrictScenarioModel + + +class CurriculumEntry(StrictScenarioModel): + chapter: int = Field(ge=3, le=13) + title: str = Field(min_length=1, max_length=120) + source_url: str = Field( + pattern=r"^https://github\.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line/blob/master/[0-9A-Za-z_.-]+\.md$" + ) + learning_objective: str = Field(min_length=1, max_length=1_000) + relevant_pages: list[str] = Field(min_length=1, max_length=16) + relevant_scenarios: list[Identifier] = Field(default_factory=list, max_length=16) + rpc_methods: list[str] = Field(min_length=1, max_length=32) + prerequisites: list[str] = Field(min_length=1, max_length=16) + guided_exercise: str = Field(min_length=1, max_length=1_500) + independent_challenge: str = Field(min_length=1, max_length=1_500) + verification_criteria: list[str] = Field(min_length=1, max_length=16) + implementation_note: str | None = Field(default=None, max_length=1_000) + + @model_validator(mode="after") + def collections_are_unique_and_pages_are_local(self) -> "CurriculumEntry": + for name in ( + "relevant_pages", + "relevant_scenarios", + "rpc_methods", + "prerequisites", + "verification_criteria", + ): + values = getattr(self, name) + if len(values) != len(set(values)): + raise ValueError(f"Curriculum {name} values must be unique.") + if any(not page.startswith("/") or ".." in page for page in self.relevant_pages): + raise ValueError("Curriculum pages must be normalized local application paths.") + return self + + +class CurriculumResponse(StrictScenarioModel): + schema_version: Literal[1] = 1 + course_title: Literal["Learning Bitcoin from the Command Line"] = "Learning Bitcoin from the Command Line" + course_url: Literal[ + "https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line" + ] = "https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line" + chapters: list[CurriculumEntry] = Field(min_length=11, max_length=11) + explanation: str = Field(min_length=1, max_length=1_000) + + @model_validator(mode="after") + def chapters_three_through_thirteen_are_complete(self) -> "CurriculumResponse": + if [entry.chapter for entry in self.chapters] != list(range(3, 14)): + raise ValueError("Curriculum must contain Chapters 3 through 13 in order.") + return self + + +class ChallengeDefinition(StrictScenarioModel): + challenge_id: Identifier + version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$") + title: str = Field(min_length=1, max_length=120) + difficulty: Literal["beginner", "intermediate", "advanced"] + objective: str = Field(min_length=1, max_length=1_000) + allowed_actions: list[str] = Field(min_length=1, max_length=16) + relevant_pages: list[str] = Field(min_length=1, max_length=16) + scenario_id: Identifier + hint_count: int = Field(ge=1, le=5) + verification_summary: str = Field(min_length=1, max_length=1_000) + solution_locked: Literal[True] = True + + +class ChallengeCatalogResponse(StrictScenarioModel): + schema_version: Literal[1] = 1 + challenges: list[ChallengeDefinition] = Field(min_length=4, max_length=32) + explanation: str = Field(min_length=1, max_length=1_000) + + +class ChallengeHint(StrictScenarioModel): + challenge_id: Identifier + level: int = Field(ge=1, le=5) + hint: str = Field(min_length=1, max_length=1_000) + remaining_hints: int = Field(ge=0, le=5) + reveals_solution: Literal[False] = False + + +class ChallengeVerificationRequest(StrictScenarioModel): + run_id: UUID + lab_session_id: str = Field(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$") + + +class ChallengeVerificationCheck(StrictScenarioModel): + check_id: ArtifactKey + passed: bool + explanation: str = Field(min_length=1, max_length=1_000) + evidence_ids: list[ArtifactKey] = Field(default_factory=list, max_length=32) + + +class ChallengeEvidenceReference(StrictScenarioModel): + evidence_id: ArtifactKey + kind: EvidenceKind + content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class ChallengeVerificationResult(StrictScenarioModel): + schema_version: Literal[1] = 1 + challenge_id: Identifier + challenge_version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$") + run_id: UUID + lab_session_id: str = Field(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$") + scenario_id: Identifier + scenario_version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$") + bitcoin_core_version: str | None = None + verified_at: datetime + completed: bool + validation_source: Literal[ + "persisted_bitcoin_core_scenario_evidence" + ] = "persisted_bitcoin_core_scenario_evidence" + checks: list[ChallengeVerificationCheck] = Field(min_length=1, max_length=64) + evidence: list[ChallengeEvidenceReference] = Field(default_factory=list, max_length=128) + final_explanation: str = Field(min_length=1, max_length=2_000) + solution_unlocked: bool diff --git a/backend/app/models/evidence.py b/backend/app/models/evidence.py new file mode 100644 index 0000000..b3b57af --- /dev/null +++ b/backend/app/models/evidence.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import Field, JsonValue, field_validator, model_validator + +from app.models.scenario import ( + ArtifactKey, + EvidenceKind, + EvidenceReference, + Identifier, + ScenarioRun, + StrictScenarioModel, +) + + +CommandArgument = Annotated[str, Field(min_length=1, max_length=8_192)] + + +class BitcoinCoreErrorEvidence(StrictScenarioModel): + code: int | str + message: str = Field(min_length=1, max_length=2_000) + + +class BitcoinCoreOutputEvidence(StrictScenarioModel): + rpc_method: str | None = Field(default=None, min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9]+$") + safe_parameters: JsonValue = None + result: JsonValue = None + error: BitcoinCoreErrorEvidence | None = None + run_specific_paths: list[str] = Field(default_factory=list, max_length=128) + + @field_validator("run_specific_paths") + @classmethod + def paths_are_unique_and_explicit(cls, value: list[str]) -> list[str]: + if len(value) != len(set(value)): + raise ValueError("Run-specific evidence paths must be unique.") + if any(not path.startswith("$") or len(path) > 240 for path in value): + raise ValueError("Run-specific evidence paths must be bounded paths beginning with '$'.") + return value + + +class EvidenceFact(StrictScenarioModel): + name: ArtifactKey + value: JsonValue + run_specific: bool = False + + +class BitScopeInterpretationEvidence(StrictScenarioModel): + summary: str = Field(min_length=1, max_length=2_000) + facts: list[EvidenceFact] = Field(default_factory=list, max_length=256) + limitations: list[str] = Field(default_factory=list, max_length=64) + + +class SafeBitcoinCliCommand(StrictScenarioModel): + executable: Literal["bitcoin-cli"] = "bitcoin-cli" + arguments: list[CommandArgument] = Field(min_length=1, max_length=128) + description: str = Field(min_length=1, max_length=500) + + @field_validator("arguments") + @classmethod + def arguments_exclude_credentials_and_shell_control(cls, value: list[str]) -> list[str]: + forbidden = ( + "rpcpassword", + "rpcuser", + "rpcauth", + "stdinrpcpass", + "cookiefile", + "authorization", + "xbitscopetoken", + ) + for argument in value: + normalized = argument.casefold().replace("_", "").replace("-", "") + if any(secret in normalized for secret in forbidden): + raise ValueError("Evidence commands cannot contain credential or authorization arguments.") + if any(character in argument for character in ("\x00", "\r", "\n")): + raise ValueError("Evidence command arguments cannot contain control characters.") + return value + + +class EvidenceRecord(StrictScenarioModel): + schema_version: Literal[1] = 1 + evidence_id: ArtifactKey + kind: EvidenceKind + label: str = Field(min_length=1, max_length=120) + scenario_id: Identifier + scenario_version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$") + run_id: UUID + lab_session_id: str = Field(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$") + step_id: Identifier | None = None + captured_at: datetime + core_output: BitcoinCoreOutputEvidence | None = None + bitscope_interpretation: BitScopeInterpretationEvidence + commands: list[SafeBitcoinCliCommand] = Field(default_factory=list, max_length=64) + + @field_validator("captured_at") + @classmethod + def timestamp_is_timezone_aware(cls, value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("Evidence capture timestamps must include a timezone.") + return value + + @model_validator(mode="after") + def record_has_relevant_content(self) -> EvidenceRecord: + if self.kind == EvidenceKind.RPC_RESULT and self.core_output is None: + raise ValueError("RPC result evidence requires a distinct Bitcoin Core output section.") + if self.kind == EvidenceKind.COMMANDS and not self.commands: + raise ValueError("Command evidence requires at least one safe bitcoin-cli command.") + return self + + +@dataclass(frozen=True) +class CapturedEvidence: + run: ScenarioRun + reference: EvidenceReference + record: EvidenceRecord + canonical_json: str diff --git a/backend/app/models/lifecycle.py b/backend/app/models/lifecycle.py new file mode 100644 index 0000000..a3cc6e4 --- /dev/null +++ b/backend/app/models/lifecycle.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from enum import StrEnum +from typing import Literal +from uuid import UUID + +from pydantic import Field, JsonValue, field_validator, model_validator + +from app.models.evidence import SafeBitcoinCliCommand +from app.models.scenario import ArtifactKey, Identifier, StrictScenarioModel + + +class LifecycleEventType(StrEnum): + WALLET_PREPARED = "wallet_prepared" + UTXO_SELECTED = "utxo_selected" + RAW_TRANSACTION_CREATED = "raw_transaction_created" + TRANSACTION_FUNDED = "transaction_funded" + PSBT_CREATED = "psbt_created" + PSBT_PARTIALLY_SIGNED = "psbt_partially_signed" + PSBT_COMPLETED = "psbt_completed" + TRANSACTION_FINALIZED = "transaction_finalized" + MEMPOOL_PREFLIGHT_COMPLETED = "mempool_preflight_completed" + TRANSACTION_BROADCAST = "transaction_broadcast" + TRANSACTION_ENTERED_MEMPOOL = "transaction_entered_mempool" + TRANSACTION_REPLACED = "transaction_replaced" + CHILD_TRANSACTION_CREATED = "child_transaction_created" + TRANSACTION_CONFIRMED = "transaction_confirmed" + TIMELOCK_MATURED = "timelock_matured" + SCENARIO_CLEANED_UP = "scenario_cleaned_up" + + +class TransactionLifecycleState(StrEnum): + WALLET_READY = "wallet_ready" + INPUT_SELECTED = "input_selected" + DRAFT = "draft" + FUNDED = "funded" + PARTIALLY_SIGNED = "partially_signed" + SIGNED = "signed" + FINALIZED = "finalized" + PREFLIGHTED = "preflighted" + BROADCAST = "broadcast" + IN_MEMPOOL = "in_mempool" + REPLACED = "replaced" + CHILD = "child" + CONFIRMED = "confirmed" + TIMELOCK_MATURE = "timelock_mature" + CLEANED = "cleaned" + + +class MempoolRelationshipType(StrEnum): + REPLACES = "replaces" + REPLACED_BY = "replaced_by" + CHILD_OF = "child_of" + PARENT_OF = "parent_of" + CONFLICTS_WITH = "conflicts_with" + + +class MempoolRelationship(StrictScenarioModel): + relationship_type: MempoolRelationshipType + related_txid: str + explanation: str = Field(min_length=1, max_length=1_000) + + @field_validator("related_txid") + @classmethod + def related_txid_is_hex(cls, value: str) -> str: + return _validate_txid(value) + + +class TransactionLifecycleEvent(StrictScenarioModel): + schema_version: Literal[1] = 1 + event_id: ArtifactKey + ordinal: int = Field(ge=1, le=10_000) + event_type: LifecycleEventType + timestamp: datetime + step_id: Identifier + track_id: ArtifactKey + transaction_state: TransactionLifecycleState + transaction_id: str | None = None + transaction_hex_ref: ArtifactKey | None = None + psbt_ref: ArtifactKey | None = None + fee_btc: Decimal | None = Field(default=None, ge=0, max_digits=16, decimal_places=8) + fee_rate_sat_vb: Decimal | None = Field(default=None, ge=0, max_digits=16, decimal_places=3) + locktime: int | None = Field(default=None, ge=0, le=0xFFFFFFFF) + sequence_values: list[int] = Field(default_factory=list, max_length=1_000) + relationship: MempoolRelationship | None = None + block_height: int | None = Field(default=None, ge=0) + explanation: str = Field(min_length=1, max_length=2_000) + rpc_method: str = Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9]+$") + cli_command: SafeBitcoinCliCommand + evidence_id: ArtifactKey + raw_safe_core_result: JsonValue = None + + @field_validator("transaction_id") + @classmethod + def transaction_id_is_hex(cls, value: str | None) -> str | None: + return _validate_txid(value) if value is not None else None + + @field_validator("sequence_values") + @classmethod + def sequences_are_uint32(cls, value: list[int]) -> list[int]: + if any(sequence < 0 or sequence > 0xFFFFFFFF for sequence in value): + raise ValueError("Lifecycle sequence values must be unsigned 32-bit integers.") + return value + + @model_validator(mode="after") + def relationships_have_a_transaction(self) -> "TransactionLifecycleEvent": + if self.relationship is not None and self.transaction_id is None: + raise ValueError("Mempool relationship events require their own transaction id.") + return self + + +class TransactionLifecycleTimeline(StrictScenarioModel): + schema_version: Literal[1] = 1 + run_id: UUID + scenario_id: Identifier + scenario_version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$") + lab_session_id: str = Field(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$") + generated_at: datetime + events: list[TransactionLifecycleEvent] = Field(default_factory=list, max_length=10_000) + + @model_validator(mode="after") + def event_order_and_identity_are_coherent(self) -> "TransactionLifecycleTimeline": + ids = [event.event_id for event in self.events] + ordinals = [event.ordinal for event in self.events] + if len(ids) != len(set(ids)): + raise ValueError("Lifecycle event identifiers must be unique.") + if len(ordinals) != len(set(ordinals)) or ordinals != sorted(ordinals): + raise ValueError("Lifecycle event ordinals must be unique and ordered.") + return self + + +def _validate_txid(value: str) -> str: + if len(value) != 64 or any(character not in "0123456789abcdefABCDEF" for character in value): + raise ValueError("Lifecycle transaction identifiers must be 64 hexadecimal characters.") + return value.lower() diff --git a/backend/app/models/proof.py b/backend/app/models/proof.py new file mode 100644 index 0000000..93d2103 --- /dev/null +++ b/backend/app/models/proof.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from pathlib import PurePosixPath +from typing import Literal +from uuid import UUID + +from pydantic import Field, field_validator + +from app.models.evidence import EvidenceRecord +from app.models.scenario import ScenarioFinalResult, ScenarioRunState, StrictScenarioModel +from app.models.treasury import TreasuryPolicyDecisionTree + + +class ProofFileManifestEntry(StrictScenarioModel): + path: str = Field(min_length=1, max_length=240) + content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + content_bytes: int = Field(ge=0) + media_type: str = Field(min_length=1, max_length=120) + + @field_validator("path") + @classmethod + def path_is_normalized_and_relative(cls, value: str) -> str: + path = PurePosixPath(value) + if "\\" in value or path.is_absolute() or ".." in path.parts or "." in path.parts: + raise ValueError("Proof file paths must be normalized relative paths.") + return value + + +class ProofManifest(StrictScenarioModel): + schema_version: Literal[1] = 1 + scenario_id: str + scenario_version: str + run_id: UUID + lab_session_id: str + generated_from_revision: int = Field(ge=0) + generated_at: datetime + run_state: ScenarioRunState + final_result: ScenarioFinalResult | None + hash_scope: Literal["all_bundle_files_except_manifest"] = "all_bundle_files_except_manifest" + files: list[ProofFileManifestEntry] + disclaimer: Literal[ + "This bundle is reproducible BitScope evidence, not a signature, attestation, formal proof, audit, or production approval." + ] = "This bundle is reproducible BitScope evidence, not a signature, attestation, formal proof, audit, or production approval." + + +class ScenarioEvidenceResponse(StrictScenarioModel): + run_id: UUID + revision: int = Field(ge=0) + evidence: list[EvidenceRecord] + + +class SpendabilityCheckStatus(StrEnum): + PASS = "PASS" + REJECTED_AS_EXPECTED = "REJECTED_AS_EXPECTED" + FAIL = "FAIL" + + +class TreasurySpendabilityCheck(StrictScenarioModel): + check_id: str = Field(min_length=2, max_length=96, pattern=r"^[a-z][a-z0-9_.-]*$") + label: str = Field(min_length=1, max_length=160) + status: SpendabilityCheckStatus + assertion_ids: list[str] = Field(default_factory=list, max_length=32) + expected_failure_code: str | None = Field(default=None, min_length=1, max_length=120) + evidence_ids: list[str] = Field(default_factory=list, max_length=32) + + +class TreasuryProofPolicy(StrictScenarioModel): + script_type: Literal["p2wsh"] = "p2wsh" + descriptor: str = Field(min_length=1, max_length=10_000) + address: str = Field(min_length=1, max_length=128) + recovery_delay_blocks: int = Field(ge=1, le=65_535) + emergency_delay_blocks: int = Field(ge=2, le=65_535) + decision_tree: TreasuryPolicyDecisionTree + + +class TreasuryProofOfSpendability(StrictScenarioModel): + schema_version: Literal[1] = 1 + scenario: Literal["Community Treasury Recovery"] = "Community Treasury Recovery" + scenario_id: Literal["community-treasury-recovery"] = "community-treasury-recovery" + scenario_version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$") + run_id: UUID + lab_session_id: str = Field(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$") + generated_at: datetime + result: Literal["VERIFIED", "INCOMPLETE", "FAILED"] + runtime_network: Literal["regtest"] = "regtest" + bitcoin_core_version: str | None = Field(default=None, max_length=120) + bitcoin_core_compatibility: Literal["verified", "unverified"] + policy: TreasuryProofPolicy | None = None + checks: list[TreasurySpendabilityCheck] = Field(min_length=1, max_length=64) + cleanup_status: str = Field(min_length=1, max_length=64) + evidence_ids: list[str] = Field(default_factory=list, max_length=256) + signer_model: Literal["isolated educational wallets in one local Bitcoin Core process"] = ( + "isolated educational wallets in one local Bitcoin Core process" + ) + limitations: list[str] = Field(min_length=1, max_length=32) + + +@dataclass(frozen=True) +class ProofBundle: + manifest: ProofManifest + report_markdown: str + proof_of_spendability: TreasuryProofOfSpendability | None + files: dict[str, bytes] + zip_bytes: bytes diff --git a/backend/app/models/scenario.py b/backend/app/models/scenario.py new file mode 100644 index 0000000..446e911 --- /dev/null +++ b/backend/app/models/scenario.py @@ -0,0 +1,1293 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from enum import StrEnum +from pathlib import PurePosixPath +from typing import Annotated, ClassVar, Literal +from uuid import UUID, uuid4 + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator + + +Identifier = Annotated[str, Field(min_length=2, max_length=64, pattern=r"^[a-z][a-z0-9_-]*$")] +ArtifactKey = Annotated[str, Field(min_length=2, max_length=96, pattern=r"^[a-z][a-z0-9_.-]*$")] +PositiveBtcAmount = Annotated[Decimal, Field(gt=0, max_digits=16, decimal_places=8)] + + +class StrictScenarioModel(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + +class ScenarioDifficulty(StrEnum): + BEGINNER = "beginner" + INTERMEDIATE = "intermediate" + ADVANCED = "advanced" + + +class ScenarioStepPhase(StrEnum): + SETUP = "setup" + EXECUTION = "execution" + ATTACK = "attack" + VERIFICATION = "verification" + EXPORT = "export" + CLEANUP = "cleanup" + + +class RpcCapability(StrEnum): + READ_ONLY = "read_only" + WALLET_READ = "wallet_read" + REGTEST_MUTATION = "regtest_mutation" + + +class ScenarioStepBase(StrictScenarioModel): + step_id: Identifier + type: str + phase: ScenarioStepPhase + title: str = Field(min_length=1, max_length=120) + description: str = Field(min_length=1, max_length=1_000) + depends_on: list[Identifier] = Field(default_factory=list, max_length=32) + evidence_required: bool = True + + @field_validator("depends_on") + @classmethod + def dependencies_are_unique(cls, value: list[str]) -> list[str]: + if len(value) != len(set(value)): + raise ValueError("Step dependencies must be unique.") + return value + + +class VerifyRuntimeChainStep(ScenarioStepBase): + type: Literal["verify_runtime_chain"] = "verify_runtime_chain" + phase: Literal[ScenarioStepPhase.SETUP] = ScenarioStepPhase.SETUP + required_network: Literal["regtest"] = "regtest" + output_context_ref: ArtifactKey + + +class PrepareIsolatedWalletStep(ScenarioStepBase): + type: Literal["prepare_isolated_wallet"] = "prepare_isolated_wallet" + phase: Literal[ScenarioStepPhase.SETUP] = ScenarioStepPhase.SETUP + wallet_role: Identifier + output_wallet_ref: ArtifactKey + + +class PrepareMultisigSignersStep(ScenarioStepBase): + type: Literal["prepare_multisig_signers"] = "prepare_multisig_signers" + phase: Literal[ScenarioStepPhase.SETUP] = ScenarioStepPhase.SETUP + signer_count: int = Field(ge=2, le=15) + legacy_wallets: Literal[True] = True + output_wallets_ref: ArtifactKey + + +class CreateMultisigAddressStep(ScenarioStepBase): + type: Literal["create_multisig_address"] = "create_multisig_address" + phase: Literal[ScenarioStepPhase.SETUP] = ScenarioStepPhase.SETUP + signer_wallets_ref: ArtifactKey + required_signatures: int = Field(ge=1, le=15) + address_type: Literal["legacy", "p2sh-segwit", "bech32"] = "bech32" + output_multisig_ref: ArtifactKey + + +class FundMultisigStep(ScenarioStepBase): + type: Literal["fund_multisig"] = "fund_multisig" + wallet_ref: ArtifactKey + multisig_ref: ArtifactKey + amount_btc: PositiveBtcAmount + fee_rate_sat_vb: Decimal = Field(gt=0, le=10_000, max_digits=16, decimal_places=3) + output_txid_ref: ArtifactKey + + +class PrepareTreasuryParticipantsStep(ScenarioStepBase): + type: Literal["prepare_treasury_participants"] = "prepare_treasury_participants" + phase: Literal[ScenarioStepPhase.SETUP] = ScenarioStepPhase.SETUP + signers_per_group: Literal[3] = 3 + required_signatures: Literal[2] = 2 + output_participants_ref: ArtifactKey + output_coordinator_wallet_ref: ArtifactKey + + +class MaterializeTreasuryPolicyStep(ScenarioStepBase): + type: Literal["materialize_treasury_policy"] = "materialize_treasury_policy" + phase: Literal[ScenarioStepPhase.SETUP] = ScenarioStepPhase.SETUP + participants_ref: ArtifactKey + coordinator_wallet_ref: ArtifactKey + recovery_delay_blocks: int = Field(ge=1, le=65_535) + emergency_delay_blocks: int = Field(ge=2, le=65_535) + output_policy_ref: ArtifactKey + output_address_ref: ArtifactKey + output_decision_tree_ref: ArtifactKey + + @model_validator(mode="after") + def delays_are_ordered(self) -> "MaterializeTreasuryPolicyStep": + if self.emergency_delay_blocks <= self.recovery_delay_blocks: + raise ValueError("The treasury emergency delay must be greater than the recovery delay.") + return self + + +class FundTreasuryPolicyStep(ScenarioStepBase): + type: Literal["fund_treasury_policy"] = "fund_treasury_policy" + wallet_ref: ArtifactKey + policy_ref: ArtifactKey + branch: Literal["immediate", "recovery", "emergency"] + amount_btc: PositiveBtcAmount + fee_rate_sat_vb: Decimal = Field(gt=0, le=10_000, max_digits=16, decimal_places=3) + output_funding_ref: ArtifactKey + + +class CreateTreasurySpendPsbtStep(ScenarioStepBase): + type: Literal["create_treasury_spend_psbt"] = "create_treasury_spend_psbt" + coordinator_wallet_ref: ArtifactKey + funding_ref: ArtifactKey + recipient_address_ref: ArtifactKey + branch: Literal["immediate", "recovery", "emergency"] + sequence: int = Field(ge=0, le=4_294_967_295) + fee_sats: int = Field(ge=1, le=10_000_000) + output_psbt_ref: ArtifactKey + + +class SignTreasuryPsbtStep(ScenarioStepBase): + type: Literal["sign_treasury_psbt"] = "sign_treasury_psbt" + participants_ref: ArtifactKey + psbt_ref: ArtifactKey + signer_role: Literal["operator", "recovery", "emergency"] + signer_positions: list[int] = Field(min_length=1, max_length=2) + output_psbt_ref: ArtifactKey + output_signature_count_ref: ArtifactKey + + @field_validator("signer_positions") + @classmethod + def signer_positions_are_supported_and_unique(cls, value: list[int]) -> list[int]: + if any(position < 1 or position > 3 for position in value): + raise ValueError("Treasury signer positions must be between 1 and 3.") + if len(value) != len(set(value)): + raise ValueError("Treasury signer positions must be unique within one signing step.") + return value + + +class PrepareCltvSignerStep(ScenarioStepBase): + type: Literal["prepare_cltv_signer"] = "prepare_cltv_signer" + phase: Literal[ScenarioStepPhase.SETUP] = ScenarioStepPhase.SETUP + signer_kind: Literal["ephemeral_software_key"] = "ephemeral_software_key" + output_signer_ref: ArtifactKey + + +class CreateCltvPolicyStep(ScenarioStepBase): + type: Literal["create_cltv_policy"] = "create_cltv_policy" + phase: Literal[ScenarioStepPhase.SETUP] = ScenarioStepPhase.SETUP + signer_ref: ArtifactKey + blocks_from_tip: int = Field(ge=2, le=144) + output_policy_ref: ArtifactKey + output_lock_height_ref: ArtifactKey + + +class FundCltvPolicyStep(ScenarioStepBase): + type: Literal["fund_cltv_policy"] = "fund_cltv_policy" + wallet_ref: ArtifactKey + policy_ref: ArtifactKey + amount_btc: PositiveBtcAmount + fee_rate_sat_vb: Decimal = Field(gt=0, le=10_000, max_digits=16, decimal_places=3) + output_funding_ref: ArtifactKey + + +class CreateCltvSpendStep(ScenarioStepBase): + type: Literal["create_cltv_spend"] = "create_cltv_spend" + signer_ref: ArtifactKey + policy_ref: ArtifactKey + funding_ref: ArtifactKey + recipient_address_ref: ArtifactKey + lock_height_adjustment: int = Field(default=0, ge=-1, le=0) + sequence: int = Field(ge=0, le=4_294_967_295) + fee_sats: int = Field(ge=1, le=10_000_000) + output_transaction_ref: ArtifactKey + + +class GenerateAddressStep(ScenarioStepBase): + type: Literal["generate_address"] = "generate_address" + wallet_ref: ArtifactKey + label: str = Field(default="bitscope-scenario", min_length=1, max_length=64) + address_type: Literal["legacy", "p2sh-segwit", "bech32", "bech32m"] = "bech32" + output_address_ref: ArtifactKey + + +class MineBlocksStep(ScenarioStepBase): + type: Literal["mine_blocks"] = "mine_blocks" + address_ref: ArtifactKey + blocks: int = Field(ge=1, le=500) + output_blocks_ref: ArtifactKey + + +class SelectUtxosStep(ScenarioStepBase): + type: Literal["select_utxos"] = "select_utxos" + wallet_ref: ArtifactKey + minimum_amount_btc: PositiveBtcAmount + minimum_confirmations: int = Field(default=1, ge=0, le=1_000_000) + output_utxos_ref: ArtifactKey + + +class TransactionOutputSpec(StrictScenarioModel): + address_ref: ArtifactKey + amount_btc: PositiveBtcAmount + + +class CreateRawTransactionStep(ScenarioStepBase): + type: Literal["create_raw_transaction"] = "create_raw_transaction" + inputs_ref: ArtifactKey + outputs: list[TransactionOutputSpec] = Field(min_length=1, max_length=32) + locktime: int = Field(default=0, ge=0, le=4_294_967_295) + replaceable: bool = False + output_transaction_ref: ArtifactKey + + +class CreateSelectedUtxoTransactionStep(ScenarioStepBase): + type: Literal["create_selected_utxo_transaction"] = "create_selected_utxo_transaction" + utxos_ref: ArtifactKey + selected_index: int = Field(default=0, ge=0, le=31) + recipient_address_ref: ArtifactKey + fee_sats: int = Field(ge=1, le=10_000_000) + output_transaction_ref: ArtifactKey + + +class CreateOverspendTransactionStep(ScenarioStepBase): + type: Literal["create_overspend_transaction"] = "create_overspend_transaction" + utxos_ref: ArtifactKey + selected_index: int = Field(ge=0, le=31) + recipient_address_ref: ArtifactKey + excess_sats: int = Field(default=1, ge=1, le=100_000) + output_transaction_ref: ArtifactKey + + +class SignRawTransactionStep(ScenarioStepBase): + type: Literal["sign_raw_transaction"] = "sign_raw_transaction" + wallet_ref: ArtifactKey + transaction_ref: ArtifactKey + output_transaction_ref: ArtifactKey + + +class CreateWalletRbfTransactionStep(ScenarioStepBase): + type: Literal["create_wallet_rbf_transaction"] = "create_wallet_rbf_transaction" + wallet_ref: ArtifactKey + recipient_address_ref: ArtifactKey + amount_btc: PositiveBtcAmount + initial_fee_rate_sat_vb: Decimal = Field(gt=0, le=10_000, max_digits=16, decimal_places=3) + output_transaction_ref: ArtifactKey + output_txid_ref: ArtifactKey + + +class BumpFeeStep(ScenarioStepBase): + type: Literal["bump_fee"] = "bump_fee" + wallet_ref: ArtifactKey + txid_ref: ArtifactKey + fee_rate_sat_vb: Decimal | None = Field(default=None, gt=0, le=100_000, max_digits=16, decimal_places=3) + add_to_observed_fee_rate_sat_vb: Decimal | None = Field( + default=None, + gt=0, + le=100_000, + max_digits=16, + decimal_places=3, + ) + output_replacement_ref: ArtifactKey + output_txid_ref: ArtifactKey | None = None + + @model_validator(mode="after") + def fee_strategy_is_explicit(self) -> "BumpFeeStep": + configured = [self.fee_rate_sat_vb is not None, self.add_to_observed_fee_rate_sat_vb is not None] + if sum(configured) != 1: + raise ValueError("A fee bump step requires exactly one explicit fee-rate strategy.") + return self + + +class CreatePsbtStep(ScenarioStepBase): + type: Literal["create_psbt"] = "create_psbt" + wallet_ref: ArtifactKey + recipient_address_ref: ArtifactKey + amount_btc: PositiveBtcAmount + output_psbt_ref: ArtifactKey + + +class CreateMultisigPsbtStep(ScenarioStepBase): + type: Literal["create_multisig_psbt"] = "create_multisig_psbt" + signer_wallets_ref: ArtifactKey + multisig_ref: ArtifactKey + recipient_address_ref: ArtifactKey + amount_btc: PositiveBtcAmount + fee_rate_sat_vb: Decimal = Field(gt=0, le=10_000, max_digits=16, decimal_places=3) + output_psbt_ref: ArtifactKey + + +class ProcessPsbtStep(ScenarioStepBase): + type: Literal["process_psbt"] = "process_psbt" + wallet_ref: ArtifactKey + psbt_ref: ArtifactKey + sign: bool = True + finalize: bool = True + output_psbt_ref: ArtifactKey + output_signature_count_ref: ArtifactKey | None = None + + +class FinalizePsbtStep(ScenarioStepBase): + type: Literal["finalize_psbt"] = "finalize_psbt" + psbt_ref: ArtifactKey + extract: bool = False + output_psbt_ref: ArtifactKey | None = None + output_transaction_ref: ArtifactKey | None = None + + @model_validator(mode="after") + def extraction_has_transaction_output(self) -> "FinalizePsbtStep": + if self.extract and self.output_transaction_ref is None: + raise ValueError("Extracting a PSBT requires an output transaction reference.") + if not self.extract and self.output_transaction_ref is not None: + raise ValueError("A transaction output reference is only valid when PSBT extraction is enabled.") + if not self.extract and self.output_psbt_ref is None: + raise ValueError("Finalizing without extraction requires an output PSBT reference.") + return self + + +class DecodeTransactionStep(ScenarioStepBase): + type: Literal["decode_transaction"] = "decode_transaction" + transaction_ref: ArtifactKey + output_decoded_ref: ArtifactKey + + +class TestMempoolAcceptStep(ScenarioStepBase): + type: Literal["test_mempool_accept"] = "test_mempool_accept" + transaction_ref: ArtifactKey + output_acceptance_ref: ArtifactKey + + +class BroadcastTransactionStep(ScenarioStepBase): + type: Literal["broadcast_transaction"] = "broadcast_transaction" + transaction_ref: ArtifactKey + output_txid_ref: ArtifactKey + + +class QueryMempoolEntryStep(ScenarioStepBase): + type: Literal["query_mempool_entry"] = "query_mempool_entry" + txid_ref: ArtifactKey + output_mempool_ref: ArtifactKey + + +class MineConfirmationBlocksStep(ScenarioStepBase): + type: Literal["mine_confirmation_blocks"] = "mine_confirmation_blocks" + address_ref: ArtifactKey + blocks: int = Field(default=1, ge=1, le=500) + output_blocks_ref: ArtifactKey + + +class AdvanceRelativeTimelockStep(ScenarioStepBase): + type: Literal["advance_relative_timelock"] = "advance_relative_timelock" + address_ref: ArtifactKey + blocks: int = Field(ge=1, le=65_535) + output_height_ref: ArtifactKey + + +class AdvanceAbsoluteTimelockStep(ScenarioStepBase): + type: Literal["advance_absolute_timelock"] = "advance_absolute_timelock" + address_ref: ArtifactKey + target_height: int | None = Field(default=None, ge=1, le=2_147_483_647) + target_height_ref: ArtifactKey | None = None + output_height_ref: ArtifactKey + + @model_validator(mode="after") + def target_is_explicit(self) -> "AdvanceAbsoluteTimelockStep": + if (self.target_height is None) == (self.target_height_ref is None): + raise ValueError("Absolute timelock advancement requires exactly one target height source.") + return self + + +class EvaluateAssertionsStep(ScenarioStepBase): + type: Literal["evaluate_assertions"] = "evaluate_assertions" + phase: Literal[ScenarioStepPhase.VERIFICATION] = ScenarioStepPhase.VERIFICATION + assertion_ids: list[Identifier] = Field(min_length=1, max_length=64) + + @field_validator("assertion_ids") + @classmethod + def assertion_ids_are_unique(cls, value: list[str]) -> list[str]: + if len(value) != len(set(value)): + raise ValueError("Assertion identifiers must be unique within an evaluation step.") + return value + + +class ExportEvidenceStep(ScenarioStepBase): + type: Literal["export_evidence"] = "export_evidence" + phase: Literal[ScenarioStepPhase.EXPORT] = ScenarioStepPhase.EXPORT + output_bundle_ref: ArtifactKey + + +class CleanupLabStep(ScenarioStepBase): + type: Literal["cleanup_lab"] = "cleanup_lab" + phase: Literal[ScenarioStepPhase.CLEANUP] = ScenarioStepPhase.CLEANUP + unload_owned_wallets: Literal[True] = True + + +ScenarioStep = Annotated[ + VerifyRuntimeChainStep + | PrepareIsolatedWalletStep + | PrepareMultisigSignersStep + | CreateMultisigAddressStep + | FundMultisigStep + | PrepareTreasuryParticipantsStep + | MaterializeTreasuryPolicyStep + | FundTreasuryPolicyStep + | CreateTreasurySpendPsbtStep + | SignTreasuryPsbtStep + | PrepareCltvSignerStep + | CreateCltvPolicyStep + | FundCltvPolicyStep + | CreateCltvSpendStep + | GenerateAddressStep + | MineBlocksStep + | SelectUtxosStep + | CreateRawTransactionStep + | CreateSelectedUtxoTransactionStep + | CreateOverspendTransactionStep + | SignRawTransactionStep + | CreateWalletRbfTransactionStep + | BumpFeeStep + | CreatePsbtStep + | CreateMultisigPsbtStep + | ProcessPsbtStep + | FinalizePsbtStep + | DecodeTransactionStep + | TestMempoolAcceptStep + | BroadcastTransactionStep + | QueryMempoolEntryStep + | MineConfirmationBlocksStep + | AdvanceRelativeTimelockStep + | AdvanceAbsoluteTimelockStep + | EvaluateAssertionsStep + | ExportEvidenceStep + | CleanupLabStep, + Field(discriminator="type"), +] + + +class FailureCategory(StrEnum): + BITSCOPE_VALIDATION = "bitscope_validation" + RUNTIME_NETWORK_SAFETY = "runtime_network_safety" + RPC_PARAMETER = "rpc_parameter" + SCRIPT_VERIFICATION = "script_verification" + CONSENSUS_VALIDATION = "consensus_validation" + MEMPOOL_POLICY = "mempool_policy" + PSBT_INCOMPLETE = "psbt_incomplete" + TRANSACTION_REPLACED = "transaction_replaced" + TRANSACTION_CONFLICT = "transaction_conflict" + UNEXPECTED_APPLICATION = "unexpected_application" + + +class AssertionBase(StrictScenarioModel): + assertion_id: Identifier + kind: str + after_step_id: Identifier + subject_ref: ArtifactKey + required: bool = True + description: str = Field(min_length=1, max_length=1_000) + + +class RpcSucceededAssertion(AssertionBase): + kind: Literal["rpc_succeeded"] = "rpc_succeeded" + + +class ExpectedFailureAssertion(AssertionBase): + kind: Literal["rpc_failed_with_category"] = "rpc_failed_with_category" + expected_category: FailureCategory + + +class TransactionStateAssertion(AssertionBase): + kind: Literal[ + "transaction_in_mempool", + "transaction_not_in_mempool", + "transaction_confirmed", + "transaction_replaced", + ] + + +class RbfSignalingAssertion(AssertionBase): + kind: Literal["rbf_signaled"] = "rbf_signaled" + + +class ChildSpendsParentAssertion(AssertionBase): + kind: Literal["child_spends_parent"] = "child_spends_parent" + parent_txid_ref: ArtifactKey + parent_vout: int = Field(ge=0) + + +class PsbtStateAssertion(AssertionBase): + kind: Literal["psbt_complete", "psbt_incomplete"] + + +class SignatureThresholdAssertion(AssertionBase): + kind: Literal["signature_threshold_met", "signature_threshold_not_met"] + required_signatures: int = Field(ge=1, le=15) + signature_count_ref: ArtifactKey + + +class TimelockStateAssertion(AssertionBase): + kind: Literal["timelock_mature", "timelock_immature"] + + +class MempoolPolicyAssertion(AssertionBase): + kind: Literal["mempool_policy_accepted", "mempool_policy_rejected"] + + +class OutputScriptAssertion(AssertionBase): + kind: Literal["output_script_matches"] = "output_script_matches" + output_index: int = Field(ge=0) + expected_script_hex: str = Field(min_length=2, max_length=20_000, pattern=r"^(?:[0-9a-fA-F]{2})+$") + + +class OutputAmountAssertion(AssertionBase): + kind: Literal["output_amount_matches"] = "output_amount_matches" + output_index: int = Field(ge=0) + expected_amount_btc: PositiveBtcAmount + + +class FeeRateAssertion(AssertionBase): + kind: Literal["fee_rate_at_least"] = "fee_rate_at_least" + minimum_sat_vb: Decimal = Field(gt=0, max_digits=16, decimal_places=3) + + +VerificationAssertion = Annotated[ + RpcSucceededAssertion + | ExpectedFailureAssertion + | TransactionStateAssertion + | RbfSignalingAssertion + | ChildSpendsParentAssertion + | PsbtStateAssertion + | SignatureThresholdAssertion + | TimelockStateAssertion + | MempoolPolicyAssertion + | OutputScriptAssertion + | OutputAmountAssertion + | FeeRateAssertion, + Field(discriminator="kind"), +] + + +class CleanupRules(StrictScenarioModel): + unload_owned_wallets: Literal[True] = True + preserve_unowned_wallets: Literal[True] = True + fail_run_on_cleanup_error: Literal[True] = True + + +MUTATING_STEP_TYPES = frozenset( + { + "prepare_isolated_wallet", + "prepare_multisig_signers", + "create_multisig_address", + "fund_multisig", + "prepare_treasury_participants", + "materialize_treasury_policy", + "fund_treasury_policy", + "create_treasury_spend_psbt", + "sign_treasury_psbt", + "prepare_cltv_signer", + "create_cltv_policy", + "fund_cltv_policy", + "create_cltv_spend", + "generate_address", + "mine_blocks", + "create_raw_transaction", + "create_selected_utxo_transaction", + "create_overspend_transaction", + "sign_raw_transaction", + "create_wallet_rbf_transaction", + "bump_fee", + "create_psbt", + "create_multisig_psbt", + "process_psbt", + "finalize_psbt", + "broadcast_transaction", + "mine_confirmation_blocks", + "advance_relative_timelock", + "advance_absolute_timelock", + "cleanup_lab", + } +) + + +class ScenarioDefinition(StrictScenarioModel): + scenario_id: Identifier + version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$") + name: str = Field(min_length=1, max_length=120) + summary: str = Field(min_length=1, max_length=2_000) + difficulty: ScenarioDifficulty + related_lbcli_chapters: list[int] = Field(default_factory=list, max_length=11) + concepts: list[str] = Field(min_length=1, max_length=32) + required_network: Literal["regtest"] = "regtest" + required_capabilities: list[RpcCapability] = Field(min_length=1, max_length=3) + estimated_run_steps: int = Field(ge=1, le=500) + steps: list[ScenarioStep] = Field(min_length=1, max_length=500) + assertions: list[VerificationAssertion] = Field(min_length=1, max_length=500) + cleanup_rules: CleanupRules = Field(default_factory=CleanupRules) + + @field_validator("related_lbcli_chapters") + @classmethod + def chapters_are_supported_and_unique(cls, value: list[int]) -> list[int]: + if any(chapter < 3 or chapter > 13 for chapter in value): + raise ValueError("LBCLI chapter references must be between 3 and 13.") + if len(value) != len(set(value)): + raise ValueError("LBCLI chapter references must be unique.") + return value + + @field_validator("concepts") + @classmethod + def concepts_are_unique(cls, value: list[str]) -> list[str]: + normalized = [concept.casefold() for concept in value] + if len(normalized) != len(set(normalized)): + raise ValueError("Scenario concepts must be unique.") + return value + + @field_validator("required_capabilities") + @classmethod + def capabilities_are_unique(cls, value: list[RpcCapability]) -> list[RpcCapability]: + if len(value) != len(set(value)): + raise ValueError("Required RPC capabilities must be unique.") + return value + + @model_validator(mode="after") + def definition_is_coherent(self) -> "ScenarioDefinition": + if self.estimated_run_steps < len(self.steps): + raise ValueError("Estimated run steps cannot be lower than the number of defined steps.") + if self.steps[0].type != "verify_runtime_chain": + raise ValueError("The first scenario step must verify the runtime chain.") + + required_phases = { + ScenarioStepPhase.SETUP, + ScenarioStepPhase.EXECUTION, + ScenarioStepPhase.VERIFICATION, + ScenarioStepPhase.EXPORT, + ScenarioStepPhase.CLEANUP, + } + missing_phases = required_phases - {step.phase for step in self.steps} + if missing_phases: + rendered = ", ".join(sorted(phase.value for phase in missing_phases)) + raise ValueError(f"Scenario definitions are missing required phases: {rendered}.") + + phase_order = { + ScenarioStepPhase.SETUP: 0, + ScenarioStepPhase.EXECUTION: 1, + ScenarioStepPhase.ATTACK: 2, + ScenarioStepPhase.VERIFICATION: 3, + ScenarioStepPhase.EXPORT: 4, + ScenarioStepPhase.CLEANUP: 5, + } + phases = [phase_order[step.phase] for step in self.steps] + if phases != sorted(phases): + raise ValueError("Scenario step phases must follow setup, execution, attack, verification, export, cleanup order.") + + seen_steps: set[str] = set() + step_positions: dict[str, int] = {} + artifact_positions: dict[str, int] = {} + for position, step in enumerate(self.steps): + if step.step_id in seen_steps: + raise ValueError(f"Duplicate scenario step identifier: {step.step_id}.") + missing_dependencies = [dependency for dependency in step.depends_on if dependency not in seen_steps] + if missing_dependencies: + raise ValueError( + f"Step {step.step_id} depends on missing or later steps: {', '.join(missing_dependencies)}." + ) + seen_steps.add(step.step_id) + step_positions[step.step_id] = position + + input_refs = self._step_input_refs(step) + unknown_refs = [reference for reference in input_refs if reference not in artifact_positions] + if unknown_refs: + raise ValueError( + f"Step {step.step_id} references artifacts not produced by earlier steps: {', '.join(unknown_refs)}." + ) + for output_ref in self._step_output_refs(step): + if output_ref in artifact_positions: + raise ValueError(f"Artifact reference {output_ref} is produced more than once.") + artifact_positions[output_ref] = position + + if self.steps[-1].type != "cleanup_lab": + raise ValueError("The final scenario step must clean up the lab.") + if any(step.phase == ScenarioStepPhase.CLEANUP for step in self.steps[:-1]): + raise ValueError("Cleanup may only appear as the final scenario step.") + + if any(step.type in MUTATING_STEP_TYPES for step in self.steps): + if RpcCapability.REGTEST_MUTATION not in self.required_capabilities: + raise ValueError("Mutating scenario steps require the regtest mutation RPC capability.") + + assertion_ids: set[str] = set() + for assertion in self.assertions: + if assertion.assertion_id in assertion_ids: + raise ValueError(f"Duplicate assertion identifier: {assertion.assertion_id}.") + if assertion.after_step_id not in seen_steps: + raise ValueError( + f"Assertion {assertion.assertion_id} references unknown step {assertion.after_step_id}." + ) + assertion_refs = self._assertion_input_refs(assertion) + unknown_refs = [reference for reference in assertion_refs if reference not in artifact_positions] + if unknown_refs: + raise ValueError( + f"Assertion {assertion.assertion_id} references unknown artifacts: {', '.join(unknown_refs)}." + ) + later_refs = [ + reference + for reference in assertion_refs + if artifact_positions[reference] > step_positions[assertion.after_step_id] + ] + if later_refs: + raise ValueError( + f"Assertion {assertion.assertion_id} references artifacts created after its source step: " + f"{', '.join(later_refs)}." + ) + assertion_ids.add(assertion.assertion_id) + + evaluated_assertions: set[str] = set() + for step in self.steps: + if isinstance(step, EvaluateAssertionsStep): + unknown = [assertion_id for assertion_id in step.assertion_ids if assertion_id not in assertion_ids] + if unknown: + raise ValueError( + f"Step {step.step_id} references unknown assertions: {', '.join(unknown)}." + ) + duplicates = [assertion_id for assertion_id in step.assertion_ids if assertion_id in evaluated_assertions] + if duplicates: + raise ValueError( + f"Assertions cannot be evaluated more than once: {', '.join(duplicates)}." + ) + for assertion_id in step.assertion_ids: + assertion = next(item for item in self.assertions if item.assertion_id == assertion_id) + if step_positions[assertion.after_step_id] >= step_positions[step.step_id]: + raise ValueError( + f"Assertion {assertion_id} must be evaluated after step {assertion.after_step_id}." + ) + evaluated_assertions.update(step.assertion_ids) + + missing_required = [ + assertion.assertion_id + for assertion in self.assertions + if assertion.required and assertion.assertion_id not in evaluated_assertions + ] + if missing_required: + raise ValueError( + f"Required assertions are not assigned to an evaluation step: {', '.join(missing_required)}." + ) + return self + + @staticmethod + def _step_input_refs(step: ScenarioStepBase) -> list[str]: + refs: list[str] = [] + for field_name, value in step.model_dump(mode="python").items(): + if field_name.startswith("output_"): + continue + if field_name.endswith("_ref") and isinstance(value, str): + refs.append(value) + if isinstance(step, CreateRawTransactionStep): + refs.extend(output.address_ref for output in step.outputs) + return refs + + @staticmethod + def _step_output_refs(step: ScenarioStepBase) -> list[str]: + return [ + value + for field_name, value in step.model_dump(mode="python").items() + if field_name.startswith("output_") and isinstance(value, str) + ] + + @staticmethod + def _assertion_input_refs(assertion: AssertionBase) -> list[str]: + return [ + value + for field_name, value in assertion.model_dump(mode="python").items() + if (field_name == "subject_ref" or field_name.endswith("_ref")) and isinstance(value, str) + ] + + +class EvidenceKind(StrEnum): + NODE_CONTEXT = "node_context" + RPC_RESULT = "rpc_result" + TRANSACTION = "transaction" + PSBT = "psbt" + ASSERTION = "assertion" + LIFECYCLE = "lifecycle" + REPORT = "report" + COMMANDS = "commands" + MANIFEST = "manifest" + + +class EvidenceReference(StrictScenarioModel): + evidence_id: ArtifactKey + kind: EvidenceKind + label: str = Field(min_length=1, max_length=120) + relative_path: str | None = Field(default=None, min_length=1, max_length=240) + content_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + redacted: Literal[True] = True + + @field_validator("relative_path") + @classmethod + def path_is_safe_and_relative(cls, value: str | None) -> str | None: + if value is None: + return None + if "\\" in value: + raise ValueError("Evidence paths must use forward slashes.") + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or "." in path.parts: + raise ValueError("Evidence paths must be normalized relative paths.") + return value + + +class ScenarioFailure(StrictScenarioModel): + failure_id: ArtifactKey + step_id: Identifier + category: FailureCategory + expected: bool + code: str = Field(min_length=1, max_length=120) + safe_message: str = Field(min_length=1, max_length=2_000) + rpc_code: int | None = None + attack_id: ArtifactKey | None = None + raw_safe_details: JsonValue = None + evidence_ids: list[ArtifactKey] = Field(default_factory=list, max_length=32) + + +class ScenarioStepResultStatus(StrEnum): + COMPLETED = "completed" + EXPECTED_FAILURE = "expected_failure" + UNEXPECTED_FAILURE = "unexpected_failure" + SKIPPED = "skipped" + + +class ScenarioStepResult(StrictScenarioModel): + step_id: Identifier + status: ScenarioStepResultStatus + started_at: datetime + completed_at: datetime + output_refs: list[ArtifactKey] = Field(default_factory=list, max_length=64) + evidence_ids: list[ArtifactKey] = Field(default_factory=list, max_length=64) + failure: ScenarioFailure | None = None + + @model_validator(mode="after") + def failure_matches_status(self) -> "ScenarioStepResult": + if self.completed_at < self.started_at: + raise ValueError("A scenario step cannot complete before it starts.") + if self.status == ScenarioStepResultStatus.EXPECTED_FAILURE: + if self.failure is None or not self.failure.expected: + raise ValueError("An expected-failure step requires an expected failure record.") + elif self.status == ScenarioStepResultStatus.UNEXPECTED_FAILURE: + if self.failure is None or self.failure.expected: + raise ValueError("An unexpected-failure step requires an unexpected failure record.") + elif self.failure is not None: + raise ValueError("Completed and skipped steps cannot carry failure records.") + if self.failure is not None and self.failure.step_id != self.step_id: + raise ValueError("A failure record must belong to the same scenario step.") + return self + + +class AssertionResultStatus(StrEnum): + PASSED = "passed" + FAILED = "failed" + SKIPPED = "skipped" + + +class AssertionResult(StrictScenarioModel): + assertion_id: Identifier + status: AssertionResultStatus + required: bool + expected_failure: bool = False + explanation: str = Field(min_length=1, max_length=2_000) + evidence_ids: list[ArtifactKey] = Field(default_factory=list, max_length=32) + + +class CleanupStatus(StrEnum): + NOT_STARTED = "not_started" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + + +class ScenarioRunState(StrEnum): + CREATED = "created" + READY = "ready" + RUNNING = "running" + VERIFYING = "verifying" + CLEANING = "cleaning" + VERIFIED = "verified" + VERIFIED_WITH_WARNINGS = "verified_with_warnings" + FAILED = "failed" + INCOMPLETE = "incomplete" + CLEANUP_FAILED = "cleanup_failed" + + +class ScenarioFinalResult(StrEnum): + VERIFIED = "verified" + VERIFIED_WITH_WARNINGS = "verified_with_warnings" + FAILED = "failed" + INCOMPLETE = "incomplete" + CLEANUP_FAILED = "cleanup_failed" + + +TERMINAL_RUN_STATES = frozenset( + { + ScenarioRunState.VERIFIED, + ScenarioRunState.VERIFIED_WITH_WARNINGS, + ScenarioRunState.FAILED, + ScenarioRunState.INCOMPLETE, + ScenarioRunState.CLEANUP_FAILED, + } +) + + +class ScenarioRun(StrictScenarioModel): + ALLOWED_TRANSITIONS: ClassVar[dict[ScenarioRunState, frozenset[ScenarioRunState]]] = { + ScenarioRunState.CREATED: frozenset( + {ScenarioRunState.READY, ScenarioRunState.CLEANING, ScenarioRunState.FAILED} + ), + ScenarioRunState.READY: frozenset( + {ScenarioRunState.RUNNING, ScenarioRunState.CLEANING, ScenarioRunState.FAILED} + ), + ScenarioRunState.RUNNING: frozenset( + {ScenarioRunState.VERIFYING, ScenarioRunState.CLEANING, ScenarioRunState.INCOMPLETE} + ), + ScenarioRunState.VERIFYING: frozenset( + {ScenarioRunState.CLEANING, ScenarioRunState.INCOMPLETE} + ), + ScenarioRunState.CLEANING: frozenset( + { + ScenarioRunState.VERIFIED, + ScenarioRunState.VERIFIED_WITH_WARNINGS, + ScenarioRunState.FAILED, + ScenarioRunState.INCOMPLETE, + ScenarioRunState.CLEANUP_FAILED, + } + ), + } + + run_id: UUID + scenario_id: Identifier + scenario_version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$") + lab_session_id: str = Field(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$") + runtime_chain: Literal["regtest"] + bitcoin_core_version: str | None = Field(default=None, max_length=120) + start_state: Literal[ScenarioRunState.CREATED] = ScenarioRunState.CREATED + current_state: ScenarioRunState = ScenarioRunState.CREATED + current_step_id: Identifier | None = None + revision: int = Field(default=0, ge=0) + defined_step_ids: list[Identifier] = Field(min_length=1, max_length=500) + required_assertion_ids: list[Identifier] = Field(default_factory=list, max_length=500) + completed_steps: list[Identifier] = Field(default_factory=list, max_length=500) + failed_steps: list[Identifier] = Field(default_factory=list, max_length=500) + skipped_steps: list[Identifier] = Field(default_factory=list, max_length=500) + step_results: list[ScenarioStepResult] = Field(default_factory=list, max_length=500) + assertion_results: list[AssertionResult] = Field(default_factory=list, max_length=500) + expected_failures: list[ScenarioFailure] = Field(default_factory=list, max_length=500) + unexpected_failures: list[ScenarioFailure] = Field(default_factory=list, max_length=500) + evidence: list[EvidenceReference] = Field(default_factory=list, max_length=2_000) + cleanup_status: CleanupStatus = CleanupStatus.NOT_STARTED + final_result: ScenarioFinalResult | None = None + created_at: datetime + updated_at: datetime + completed_at: datetime | None = None + + @classmethod + def create( + cls, + definition: ScenarioDefinition, + lab_session_id: str, + bitcoin_core_version: str | None = None, + now: datetime | None = None, + ) -> "ScenarioRun": + timestamp = now or datetime.now(UTC) + return cls( + run_id=uuid4(), + scenario_id=definition.scenario_id, + scenario_version=definition.version, + lab_session_id=lab_session_id, + runtime_chain="regtest", + bitcoin_core_version=bitcoin_core_version, + defined_step_ids=[step.step_id for step in definition.steps], + required_assertion_ids=[assertion.assertion_id for assertion in definition.assertions if assertion.required], + created_at=timestamp, + updated_at=timestamp, + ) + + @model_validator(mode="after") + def run_is_coherent(self) -> "ScenarioRun": + for label, values in ( + ("defined step", self.defined_step_ids), + ("required assertion", self.required_assertion_ids), + ("completed step", self.completed_steps), + ("failed step", self.failed_steps), + ("skipped step", self.skipped_steps), + ): + if len(values) != len(set(values)): + raise ValueError(f"Duplicate {label} identifiers are not allowed.") + + step_ids = [result.step_id for result in self.step_results] + if len(step_ids) != len(set(step_ids)): + raise ValueError("A scenario step cannot be recorded more than once.") + unknown_step_ids = set(step_ids) - set(self.defined_step_ids) + if unknown_step_ids: + raise ValueError( + f"Run results reference undefined scenario steps: {', '.join(sorted(unknown_step_ids))}." + ) + if self.current_step_id is not None and self.current_step_id not in self.defined_step_ids: + raise ValueError("The current step must belong to the scenario definition.") + assertion_ids = [result.assertion_id for result in self.assertion_results] + if len(assertion_ids) != len(set(assertion_ids)): + raise ValueError("An assertion cannot be recorded more than once.") + evidence_ids = [reference.evidence_id for reference in self.evidence] + if len(evidence_ids) != len(set(evidence_ids)): + raise ValueError("Evidence identifiers must be unique within a run.") + + if set(self.completed_steps) & set(self.failed_steps): + raise ValueError("A scenario step cannot be both completed and failed.") + if set(self.skipped_steps) & (set(self.completed_steps) | set(self.failed_steps)): + raise ValueError("A skipped scenario step cannot also be completed or failed.") + + expected_completed = { + result.step_id + for result in self.step_results + if result.status in {ScenarioStepResultStatus.COMPLETED, ScenarioStepResultStatus.EXPECTED_FAILURE} + } + expected_failed = { + result.step_id + for result in self.step_results + if result.status == ScenarioStepResultStatus.UNEXPECTED_FAILURE + } + expected_skipped = { + result.step_id for result in self.step_results if result.status == ScenarioStepResultStatus.SKIPPED + } + if set(self.completed_steps) != expected_completed: + raise ValueError("Completed step identifiers must match the recorded step results.") + if set(self.failed_steps) != expected_failed: + raise ValueError("Failed step identifiers must match the recorded step results.") + if set(self.skipped_steps) != expected_skipped: + raise ValueError("Skipped step identifiers must match the recorded step results.") + + if any(not failure.expected for failure in self.expected_failures): + raise ValueError("Expected failure records must be marked expected.") + if any(failure.expected for failure in self.unexpected_failures): + raise ValueError("Unexpected failure records cannot be marked expected.") + failure_ids = [ + failure.failure_id for failure in [*self.expected_failures, *self.unexpected_failures] + ] + if len(failure_ids) != len(set(failure_ids)): + raise ValueError("Failure identifiers must be unique within a run.") + expected_failure_ids = { + result.failure.failure_id + for result in self.step_results + if result.failure is not None and result.failure.expected + } + unexpected_failure_ids = { + result.failure.failure_id + for result in self.step_results + if result.failure is not None and not result.failure.expected + } + if {failure.failure_id for failure in self.expected_failures} != expected_failure_ids: + raise ValueError("Expected failures must match the recorded step results.") + if {failure.failure_id for failure in self.unexpected_failures} != unexpected_failure_ids: + raise ValueError("Unexpected failures must match the recorded step results.") + + required_results = { + result.assertion_id: result + for result in self.assertion_results + if result.assertion_id in self.required_assertion_ids + } + if any(not result.required for result in required_results.values()): + raise ValueError("Required assertion results must remain marked required.") + + referenced_evidence = { + evidence_id + for result in self.step_results + for evidence_id in result.evidence_ids + } + referenced_evidence.update( + evidence_id + for result in self.assertion_results + for evidence_id in result.evidence_ids + ) + referenced_evidence.update( + evidence_id + for failure in [*self.expected_failures, *self.unexpected_failures] + for evidence_id in failure.evidence_ids + ) + missing_evidence = referenced_evidence - set(evidence_ids) + if missing_evidence: + raise ValueError( + f"Run results reference unknown evidence: {', '.join(sorted(missing_evidence))}." + ) + + expected_final = ( + ScenarioFinalResult(self.current_state.value) if self.current_state in TERMINAL_RUN_STATES else None + ) + if self.final_result != expected_final: + raise ValueError("Final result must match the terminal run state and remain empty for active runs.") + if self.current_state in TERMINAL_RUN_STATES and self.completed_at is None: + raise ValueError("Terminal scenario runs require a completion timestamp.") + if self.current_state not in TERMINAL_RUN_STATES and self.completed_at is not None: + raise ValueError("Active scenario runs cannot have a completion timestamp.") + + if self.current_state in {ScenarioRunState.VERIFIED, ScenarioRunState.VERIFIED_WITH_WARNINGS}: + self._validate_verified_result() + if self.current_state == ScenarioRunState.CLEANUP_FAILED and self.cleanup_status != CleanupStatus.FAILED: + raise ValueError("A cleanup-failed run requires failed cleanup status.") + return self + + def transition_to( + self, + state: ScenarioRunState, + now: datetime | None = None, + evidence_reference: EvidenceReference | None = None, + ) -> "ScenarioRun": + allowed = self.ALLOWED_TRANSITIONS.get(self.current_state, frozenset()) + if state not in allowed: + raise ValueError(f"Invalid scenario run transition: {self.current_state.value} -> {state.value}.") + + timestamp = now or datetime.now(UTC) + data = self.model_dump(mode="python") + data["current_state"] = state + data["updated_at"] = timestamp + data["revision"] = self.revision + 1 + if evidence_reference is not None: + if any(existing.evidence_id == evidence_reference.evidence_id for existing in self.evidence): + raise ValueError(f"Evidence {evidence_reference.evidence_id} has already been recorded.") + data["evidence"].append(evidence_reference.model_dump(mode="python")) + if state in TERMINAL_RUN_STATES: + data["final_result"] = ScenarioFinalResult(state.value) + data["completed_at"] = timestamp + return ScenarioRun.model_validate(data) + + def checkpoint( + self, + *, + state: ScenarioRunState | None = None, + step_results: list[ScenarioStepResult] | None = None, + assertion_results: list[AssertionResult] | None = None, + evidence_references: list[EvidenceReference] | None = None, + cleanup_status: CleanupStatus | None = None, + now: datetime | None = None, + ) -> "ScenarioRun": + """Commit one append-only execution checkpoint and one state transition.""" + + target_state = state or self.current_state + if target_state != self.current_state: + allowed = self.ALLOWED_TRANSITIONS.get(self.current_state, frozenset()) + if target_state not in allowed: + raise ValueError( + f"Invalid scenario run transition: {self.current_state.value} -> {target_state.value}." + ) + + data = self.model_dump(mode="python") + known_evidence = {reference.evidence_id for reference in self.evidence} + for reference in evidence_references or []: + if reference.evidence_id in known_evidence: + raise ValueError(f"Evidence {reference.evidence_id} has already been recorded.") + known_evidence.add(reference.evidence_id) + data["evidence"].append(reference.model_dump(mode="python")) + + known_steps = {result.step_id for result in self.step_results} + for result in step_results or []: + if result.step_id in known_steps: + raise ValueError(f"Scenario step {result.step_id} has already been recorded.") + known_steps.add(result.step_id) + data["step_results"].append(result.model_dump(mode="python")) + if result.status in { + ScenarioStepResultStatus.COMPLETED, + ScenarioStepResultStatus.EXPECTED_FAILURE, + }: + data["completed_steps"].append(result.step_id) + elif result.status == ScenarioStepResultStatus.UNEXPECTED_FAILURE: + data["failed_steps"].append(result.step_id) + else: + data["skipped_steps"].append(result.step_id) + if result.failure is not None: + target = "expected_failures" if result.failure.expected else "unexpected_failures" + data[target].append(result.failure.model_dump(mode="python")) + + known_assertions = {result.assertion_id for result in self.assertion_results} + for result in assertion_results or []: + if result.assertion_id in known_assertions: + raise ValueError(f"Assertion {result.assertion_id} has already been recorded.") + known_assertions.add(result.assertion_id) + data["assertion_results"].append(result.model_dump(mode="python")) + + timestamp = now or datetime.now(UTC) + data["current_state"] = target_state + data["current_step_id"] = data["step_results"][-1]["step_id"] if data["step_results"] else None + data["cleanup_status"] = cleanup_status or self.cleanup_status + data["updated_at"] = timestamp + data["revision"] = self.revision + 1 + if target_state in TERMINAL_RUN_STATES: + data["final_result"] = ScenarioFinalResult(target_state.value) + data["completed_at"] = timestamp + return ScenarioRun.model_validate(data) + + def record_step_result(self, result: ScenarioStepResult, now: datetime | None = None) -> "ScenarioRun": + if result.step_id not in self.defined_step_ids: + raise ValueError(f"Scenario step {result.step_id} is not part of this run's definition.") + if any(existing.step_id == result.step_id for existing in self.step_results): + raise ValueError(f"Scenario step {result.step_id} has already been recorded.") + + data = self.model_dump(mode="python") + data["step_results"].append(result.model_dump(mode="python")) + if result.status in {ScenarioStepResultStatus.COMPLETED, ScenarioStepResultStatus.EXPECTED_FAILURE}: + data["completed_steps"].append(result.step_id) + elif result.status == ScenarioStepResultStatus.UNEXPECTED_FAILURE: + data["failed_steps"].append(result.step_id) + else: + data["skipped_steps"].append(result.step_id) + if result.failure is not None: + target = "expected_failures" if result.failure.expected else "unexpected_failures" + data[target].append(result.failure.model_dump(mode="python")) + data["current_step_id"] = result.step_id + data["updated_at"] = now or datetime.now(UTC) + data["revision"] = self.revision + 1 + return ScenarioRun.model_validate(data) + + def record_assertion_result(self, result: AssertionResult, now: datetime | None = None) -> "ScenarioRun": + if any(existing.assertion_id == result.assertion_id for existing in self.assertion_results): + raise ValueError(f"Assertion {result.assertion_id} has already been recorded.") + data = self.model_dump(mode="python") + data["assertion_results"].append(result.model_dump(mode="python")) + data["updated_at"] = now or datetime.now(UTC) + data["revision"] = self.revision + 1 + return ScenarioRun.model_validate(data) + + def record_evidence_reference( + self, + reference: EvidenceReference, + now: datetime | None = None, + ) -> "ScenarioRun": + if any(existing.evidence_id == reference.evidence_id for existing in self.evidence): + raise ValueError(f"Evidence {reference.evidence_id} has already been recorded.") + data = self.model_dump(mode="python") + data["evidence"].append(reference.model_dump(mode="python")) + data["updated_at"] = now or datetime.now(UTC) + data["revision"] = self.revision + 1 + return ScenarioRun.model_validate(data) + + def with_cleanup_status(self, status: CleanupStatus, now: datetime | None = None) -> "ScenarioRun": + data = self.model_dump(mode="python") + data["cleanup_status"] = status + data["updated_at"] = now or datetime.now(UTC) + data["revision"] = self.revision + 1 + return ScenarioRun.model_validate(data) + + def _validate_verified_result(self) -> None: + if self.cleanup_status != CleanupStatus.COMPLETED: + raise ValueError("A verified run requires completed cleanup.") + if self.unexpected_failures: + raise ValueError("A run with unexpected failures cannot be verified.") + results = {result.assertion_id: result for result in self.assertion_results} + missing = [assertion_id for assertion_id in self.required_assertion_ids if assertion_id not in results] + if missing: + raise ValueError(f"Required assertions were not evaluated: {', '.join(missing)}.") + unsuccessful = [ + assertion_id + for assertion_id in self.required_assertion_ids + if results[assertion_id].status != AssertionResultStatus.PASSED + ] + if unsuccessful: + raise ValueError(f"Required assertions did not pass: {', '.join(unsuccessful)}.") + if self.current_state == ScenarioRunState.VERIFIED: + if any(result.status != AssertionResultStatus.PASSED for result in self.assertion_results): + raise ValueError("A fully verified run cannot contain failed or skipped assertions.") + if self.skipped_steps: + raise ValueError("A fully verified run cannot contain skipped steps.") + incomplete_steps = set(self.defined_step_ids) - set(self.completed_steps) + if incomplete_steps: + raise ValueError( + f"Verified runs require every defined step to complete: {', '.join(sorted(incomplete_steps))}." + ) diff --git a/backend/app/models/scenario_api.py b/backend/app/models/scenario_api.py new file mode 100644 index 0000000..3f6e17d --- /dev/null +++ b/backend/app/models/scenario_api.py @@ -0,0 +1,50 @@ +from typing import Literal +from uuid import UUID + +from pydantic import Field + +from app.models.scenario import ScenarioDefinition, ScenarioDifficulty, ScenarioRun, StrictScenarioModel + + +class ScenarioCatalogEntry(StrictScenarioModel): + scenario_id: str + version: str + name: str + summary: str + difficulty: ScenarioDifficulty + related_lbcli_chapters: list[int] + concepts: list[str] + required_network: Literal["regtest"] + estimated_run_steps: int + step_count: int = Field(ge=1) + assertion_count: int = Field(ge=1) + available: bool + unavailable_reason: str | None = None + + +class ScenarioCatalogResponse(StrictScenarioModel): + scenarios: list[ScenarioCatalogEntry] + + +class ScenarioDetailResponse(StrictScenarioModel): + definition: ScenarioDefinition + available: bool + unavailable_reason: str | None = None + + +class ScenarioRunCreateRequest(StrictScenarioModel): + lab_session_id: str = Field(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$") + + +class ScenarioRunMutationRequest(ScenarioRunCreateRequest): + expected_revision: int = Field(ge=0) + + +class ScenarioRunResetResponse(StrictScenarioModel): + previous_run_id: UUID + run: ScenarioRun + + +class ScenarioRunDeleteResponse(StrictScenarioModel): + run_id: UUID + deleted: bool diff --git a/backend/app/models/treasury.py b/backend/app/models/treasury.py new file mode 100644 index 0000000..053251c --- /dev/null +++ b/backend/app/models/treasury.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import Field, field_validator, model_validator + +from app.models.scenario import Identifier, StrictScenarioModel + + +CompressedPublicKey = Annotated[ + str, + Field( + min_length=66, + max_length=66, + pattern=r"^(?:02|03)[0-9a-fA-F]{64}$", + ), +] +RelativeBlockDelay = Annotated[int, Field(ge=1, le=65_535)] + + +class TreasuryParticipantRole(StrEnum): + OPERATOR = "operator" + RECOVERY = "recovery" + EMERGENCY = "emergency" + + +class TreasurySpendPath(StrEnum): + IMMEDIATE = "immediate" + RECOVERY = "recovery" + EMERGENCY = "emergency" + + +class TreasuryParticipant(StrictScenarioModel): + """One public signer identity backed by an isolated Bitcoin Core wallet.""" + + participant_id: Identifier + role: TreasuryParticipantRole + position: int = Field(ge=1, le=3) + wallet_name: str = Field( + min_length=1, + max_length=128, + pattern=r"^[a-zA-Z0-9_-]+$", + ) + public_key: CompressedPublicKey + + @field_validator("public_key") + @classmethod + def public_key_is_canonical_lowercase(cls, value: str) -> str: + return value.lower() + + +class TreasuryParticipantGroup(StrictScenarioModel): + """The proven fixed-size 2-of-3 threshold for one treasury branch.""" + + role: TreasuryParticipantRole + required_signatures: Literal[2] = 2 + participants: list[TreasuryParticipant] = Field(min_length=3, max_length=3) + + @model_validator(mode="after") + def group_is_coherent(self) -> TreasuryParticipantGroup: + if any(participant.role != self.role for participant in self.participants): + raise ValueError("Every treasury participant must match the role of its signer group.") + if {participant.position for participant in self.participants} != {1, 2, 3}: + raise ValueError("Treasury signer positions must contain each value from 1 through 3 exactly once.") + if len({participant.participant_id for participant in self.participants}) != 3: + raise ValueError("Treasury participant identifiers must be unique within a signer group.") + if len({participant.wallet_name for participant in self.participants}) != 3: + raise ValueError("Treasury participant wallets must be unique within a signer group.") + if len({participant.public_key for participant in self.participants}) != 3: + raise ValueError("Treasury participant public keys must be unique within a signer group.") + return self + + def ordered_participants(self) -> tuple[TreasuryParticipant, ...]: + return tuple(sorted(self.participants, key=lambda participant: participant.position)) + + +class TreasuryPolicy(StrictScenarioModel): + """Public inputs for the reviewed Community Treasury Recovery policy.""" + + schema_version: Literal[1] = 1 + policy_id: Identifier = "community-treasury-recovery" + policy_version: Literal["1.0.0"] = "1.0.0" + script_type: Literal["p2wsh"] = "p2wsh" + delay_unit: Literal["blocks"] = "blocks" + recovery_delay_blocks: RelativeBlockDelay + emergency_delay_blocks: RelativeBlockDelay + operators: TreasuryParticipantGroup + recovery: TreasuryParticipantGroup + emergency: TreasuryParticipantGroup + + @model_validator(mode="after") + def policy_is_coherent(self) -> TreasuryPolicy: + expected_roles = ( + (self.operators, TreasuryParticipantRole.OPERATOR), + (self.recovery, TreasuryParticipantRole.RECOVERY), + (self.emergency, TreasuryParticipantRole.EMERGENCY), + ) + if any(group.role != expected for group, expected in expected_roles): + raise ValueError("Treasury signer groups must occupy their matching policy roles.") + if self.emergency_delay_blocks <= self.recovery_delay_blocks: + raise ValueError("The emergency delay must be greater than the recovery delay.") + + participants = [ + participant + for group, _ in expected_roles + for participant in group.participants + ] + if len({participant.participant_id for participant in participants}) != len(participants): + raise ValueError("Treasury participant identifiers must be unique across the policy.") + if len({participant.wallet_name for participant in participants}) != len(participants): + raise ValueError("Treasury participant wallets must be unique across the policy.") + if len({participant.public_key for participant in participants}) != len(participants): + raise ValueError("Treasury participant public keys must be unique across the policy.") + return self + + +class TreasuryPolicyBranch(StrictScenarioModel): + path: TreasurySpendPath + label: str = Field(min_length=1, max_length=120) + required_signatures: Literal[2] = 2 + participant_ids: list[Identifier] = Field(min_length=3, max_length=3) + relative_delay_blocks: RelativeBlockDelay | None = None + + +class TreasuryPolicyDecisionTree(StrictScenarioModel): + root_label: Literal["Treasury P2WSH output"] = "Treasury P2WSH output" + branches: list[TreasuryPolicyBranch] = Field(min_length=3, max_length=3) + + @model_validator(mode="after") + def tree_contains_each_proven_path(self) -> TreasuryPolicyDecisionTree: + if [branch.path for branch in self.branches] != [ + TreasurySpendPath.IMMEDIATE, + TreasurySpendPath.RECOVERY, + TreasurySpendPath.EMERGENCY, + ]: + raise ValueError("The treasury decision tree must contain the proven paths in canonical order.") + if self.branches[0].relative_delay_blocks is not None: + raise ValueError("The immediate treasury path cannot have a relative delay.") + if any(branch.relative_delay_blocks is None for branch in self.branches[1:]): + raise ValueError("Every delayed treasury path must declare its relative block delay.") + return self + + +class MaterializedTreasuryPolicy(StrictScenarioModel): + policy: TreasuryPolicy + miniscript: str = Field(min_length=1, max_length=10_000) + descriptor: str = Field(min_length=1, max_length=10_000) + normalized_descriptor: str = Field(min_length=1, max_length=10_000) + checksum: str = Field(min_length=8, max_length=8) + address: str = Field(min_length=1, max_length=128) + is_range: Literal[False] = False + is_solvable: Literal[True] = True + has_private_keys: Literal[False] = False + decision_tree: TreasuryPolicyDecisionTree + + +class TreasuryPolicyImportResult(StrictScenarioModel): + coordinator_wallet: str = Field( + min_length=1, + max_length=128, + pattern=r"^[a-zA-Z0-9_-]+$", + ) + descriptor: str = Field(min_length=1, max_length=10_000) + label: str = Field(min_length=1, max_length=128) + imported: Literal[True] = True + coordinator_can_sign: Literal[False] = False diff --git a/backend/app/routes/learning.py b/backend/app/routes/learning.py index 1cb9e40..df91d5a 100644 --- a/backend/app/routes/learning.py +++ b/backend/app/routes/learning.py @@ -1,11 +1,31 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from app.config import get_settings +from app.models.curriculum import ( + ChallengeCatalogResponse, + ChallengeHint, + ChallengeVerificationRequest, + ChallengeVerificationResult, + CurriculumResponse, +) from app.models.learning import LearningConceptsResponse, LearningRpcMethodsResponse +from app.services.challenge_service import ChallengeService +from app.services.curriculum_service import CurriculumService from app.services.learning_service import LearningService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_run_store import ScenarioRunStore router = APIRouter(prefix="/learn", tags=["learn"]) +def get_challenge_service() -> ChallengeService: + settings = get_settings() + return ChallengeService( + ScenarioRunStore(settings.lab_session_database_path), + ScenarioArtifactStore(settings.scenario_artifact_root), + ) + + @router.get("/concepts", response_model=LearningConceptsResponse) def list_concepts() -> LearningConceptsResponse: result = LearningService().list_concepts() @@ -16,3 +36,36 @@ def list_concepts() -> LearningConceptsResponse: def list_learning_rpc_methods() -> LearningRpcMethodsResponse: result = LearningService().list_rpc_methods() return LearningRpcMethodsResponse.model_validate(result) + + +@router.get("/curriculum", response_model=CurriculumResponse) +def get_curriculum() -> CurriculumResponse: + return CurriculumService().curriculum() + + +@router.get("/challenges", response_model=ChallengeCatalogResponse) +def list_challenges( + service: ChallengeService = Depends(get_challenge_service), +) -> ChallengeCatalogResponse: + return service.catalog() + + +@router.get("/challenges/{challenge_id}/hints/{level}", response_model=ChallengeHint) +def get_challenge_hint( + challenge_id: str, + level: int, + service: ChallengeService = Depends(get_challenge_service), +) -> ChallengeHint: + return service.hint(challenge_id, level) + + +@router.post( + "/challenges/{challenge_id}/verify", + response_model=ChallengeVerificationResult, +) +def verify_challenge( + challenge_id: str, + request: ChallengeVerificationRequest, + service: ChallengeService = Depends(get_challenge_service), +) -> ChallengeVerificationResult: + return service.verify(challenge_id, request.run_id, request.lab_session_id) diff --git a/backend/app/routes/scenarios.py b/backend/app/routes/scenarios.py new file mode 100644 index 0000000..106bded --- /dev/null +++ b/backend/app/routes/scenarios.py @@ -0,0 +1,203 @@ +from collections.abc import Iterator +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response +from fastapi.responses import StreamingResponse + +from app.config import get_settings +from app.errors import BitScopeError +from app.models.scenario import ScenarioRun +from app.models.scenario_api import ( + ScenarioCatalogResponse, + ScenarioDetailResponse, + ScenarioRunCreateRequest, + ScenarioRunDeleteResponse, + ScenarioRunMutationRequest, + ScenarioRunResetResponse, +) +from app.models.proof import ScenarioEvidenceResponse +from app.models.lifecycle import TransactionLifecycleTimeline +from app.rpc.client import BitcoinRpcClient +from app.security import require_mutation_access +from app.services.scenario_catalog import DEFAULT_SCENARIO_CATALOG, ScenarioCatalog +from app.services.evidence_service import EvidenceRedactor, EvidenceService +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_run_store import ScenarioRunStore +from app.services.scenario_service import ScenarioService + + +catalog_router = APIRouter(prefix="/scenarios", tags=["scenarios"]) +run_router = APIRouter(prefix="/scenario-runs", tags=["scenario-runs"]) + + +def get_scenario_catalog() -> ScenarioCatalog: + return DEFAULT_SCENARIO_CATALOG + + +def get_scenario_service( + catalog: ScenarioCatalog = Depends(get_scenario_catalog), +) -> Iterator[ScenarioService]: + client = BitcoinRpcClient() + settings = get_settings() + store = ScenarioRunStore(settings.lab_session_database_path) + with client: + yield ScenarioService( + client, + store, + catalog, + EvidenceService.from_settings(settings), + ScenarioArtifactStore(settings.scenario_artifact_root), + ) + + +def get_proof_bundle_service( + catalog: ScenarioCatalog = Depends(get_scenario_catalog), +) -> ProofBundleService: + settings = get_settings() + return ProofBundleService( + ScenarioRunStore(settings.lab_session_database_path), + ScenarioArtifactStore(settings.scenario_artifact_root), + catalog, + EvidenceRedactor( + ( + settings.bitcoin_rpc_user, + settings.bitcoin_rpc_password, + settings.bitscope_local_access_token, + ) + ), + ) + + +@catalog_router.get("", response_model=ScenarioCatalogResponse) +def list_scenarios( + catalog: ScenarioCatalog = Depends(get_scenario_catalog), +) -> ScenarioCatalogResponse: + return ScenarioCatalogResponse(scenarios=catalog.list()) + + +@catalog_router.get("/{scenario_id}", response_model=ScenarioDetailResponse) +def get_scenario( + scenario_id: str, + catalog: ScenarioCatalog = Depends(get_scenario_catalog), +) -> ScenarioDetailResponse: + return catalog.get(scenario_id).detail() + + +@catalog_router.post( + "/{scenario_id}/runs", + response_model=ScenarioRun, + dependencies=[Depends(require_mutation_access)], +) +def create_scenario_run( + scenario_id: str, + request: ScenarioRunCreateRequest, + service: ScenarioService = Depends(get_scenario_service), +) -> ScenarioRun: + return service.create_run(scenario_id, request.lab_session_id) + + +@run_router.get("/{run_id}", response_model=ScenarioRun) +def get_scenario_run( + run_id: UUID, + lab_session_id: str = Query(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$"), + service: ScenarioService = Depends(get_scenario_service), +) -> ScenarioRun: + return service.get_run(run_id, lab_session_id) + + +@run_router.get("/{run_id}/evidence", response_model=ScenarioEvidenceResponse) +def get_scenario_evidence( + run_id: UUID, + lab_session_id: str = Query(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$"), + service: ProofBundleService = Depends(get_proof_bundle_service), +) -> ScenarioEvidenceResponse: + return service.evidence(run_id, lab_session_id) + + +@run_router.get("/{run_id}/lifecycle", response_model=TransactionLifecycleTimeline) +def get_scenario_lifecycle( + run_id: UUID, + lab_session_id: str = Query(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$"), + service: ProofBundleService = Depends(get_proof_bundle_service), +) -> TransactionLifecycleTimeline: + return service.lifecycle(run_id, lab_session_id) + + +@run_router.get("/{run_id}/report") +def get_scenario_report( + run_id: UUID, + lab_session_id: str = Query(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$"), + service: ProofBundleService = Depends(get_proof_bundle_service), +) -> Response: + return Response(service.report(run_id, lab_session_id), media_type="text/markdown") + + +@run_router.get("/{run_id}/bundle") +def get_scenario_bundle( + run_id: UUID, + lab_session_id: str = Query(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$"), + service: ProofBundleService = Depends(get_proof_bundle_service), +) -> StreamingResponse: + bundle = service.bundle(run_id, lab_session_id) + + def chunks() -> Iterator[bytes]: + for offset in range(0, len(bundle.zip_bytes), 65_536): + yield bundle.zip_bytes[offset : offset + 65_536] + + return StreamingResponse( + chunks(), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="bitscope-proof-{run_id}.zip"', + }, + ) + + +@run_router.post( + "/{run_id}/advance", + response_model=ScenarioRun, + dependencies=[Depends(require_mutation_access)], +) +def advance_scenario_run( + run_id: UUID, + request: ScenarioRunMutationRequest, + service: ScenarioService = Depends(get_scenario_service), +) -> ScenarioRun: + return service.advance(run_id, request.lab_session_id, request.expected_revision) + + +@run_router.post( + "/{run_id}/reset", + response_model=ScenarioRunResetResponse, + dependencies=[Depends(require_mutation_access)], +) +def reset_scenario_run( + run_id: UUID, + request: ScenarioRunMutationRequest, + service: ScenarioService = Depends(get_scenario_service), +) -> ScenarioRunResetResponse: + replacement = service.reset(run_id, request.lab_session_id, request.expected_revision) + return ScenarioRunResetResponse(previous_run_id=run_id, run=replacement) + + +@run_router.delete( + "/{run_id}", + response_model=ScenarioRunDeleteResponse, + dependencies=[Depends(require_mutation_access)], +) +def delete_scenario_run( + run_id: UUID, + lab_session_id: str = Query(min_length=8, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$"), + expected_revision: int = Query(ge=0), + confirm: bool = Query(default=False), + service: ScenarioService = Depends(get_scenario_service), +) -> ScenarioRunDeleteResponse: + if not confirm: + raise BitScopeError( + code="SCENARIO_RUN_DELETE_CONFIRMATION_REQUIRED", + message="Set confirm=true to delete this scenario run.", + status_code=400, + ) + deleted = service.delete(run_id, lab_session_id, expected_revision) + return ScenarioRunDeleteResponse(run_id=run_id, deleted=deleted) diff --git a/backend/app/rpc/capabilities.py b/backend/app/rpc/capabilities.py index 60116be..d386597 100644 --- a/backend/app/rpc/capabilities.py +++ b/backend/app/rpc/capabilities.py @@ -74,6 +74,7 @@ REGTEST_MUTATION_METHODS = WALLET_READ_METHODS | { "addmultisigaddress", "bumpfee", + "createpsbt", "createmultisig", "createrawtransaction", "createwallet", @@ -81,6 +82,8 @@ "fundrawtransaction", "generatetoaddress", "getnewaddress", + "importaddress", + "importdescriptors", "loadwallet", "sendrawtransaction", "sendtoaddress", diff --git a/backend/app/services/attack_verification_service.py b/backend/app/services/attack_verification_service.py new file mode 100644 index 0000000..77a104f --- /dev/null +++ b/backend/app/services/attack_verification_service.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from pydantic import JsonValue + +from app.errors import BitScopeError +from app.models.attack import ( + AttackApplicabilityDecision, + AttackApplicabilityStatus, + AttackContext, + AttackDefinition, + AttackFeature, + AttackObservation, + AttackType, + AttackTypeProfile, + AttackVerificationResult, + AttackVerificationStatus, + MempoolAttackObservation, + MempoolRejectionExpectation, + PsbtAttackObservation, + PsbtIncompleteExpectation, + RejectReasonMatch, + RpcErrorAttackObservation, + RpcErrorExpectation, +) +from app.models.scenario import FailureCategory +from app.services.evidence_service import EvidenceRedactor + + +LOCKTIME_FAILURE_MARKER = "Locktime requirement not satisfied" + + +ATTACK_TYPE_PROFILES = tuple( + AttackTypeProfile(attack_type=attack_type, title=title, description=description) + for attack_type, title, description in ( + (AttackType.SIGNATURE_INSUFFICIENCY, "Signature insufficiency", "Attempt a threshold spend below its required signature count."), + (AttackType.PSBT_INCOMPLETENESS, "PSBT incompleteness", "Verify that an unsatisfied PSBT cannot be finalized or extracted."), + (AttackType.OUTPUT_MODIFICATION, "Output modification", "Modify transaction outputs and classify the structured validation result."), + (AttackType.INPUT_MODIFICATION, "Input modification", "Modify transaction inputs and classify the resulting validation state."), + (AttackType.SEQUENCE_MODIFICATION, "Sequence modification", "Change an input sequence so it cannot satisfy the intended policy."), + (AttackType.LOCKTIME_MODIFICATION, "Locktime modification", "Change transaction locktime below the script requirement."), + (AttackType.PREMATURE_TIMELOCK_EXECUTION, "Premature timelock execution", "Attempt a timelocked path before its declared maturity."), + (AttackType.INVALID_SCRIPT_BRANCH, "Invalid script branch", "Select or construct a script branch that cannot satisfy the policy."), + (AttackType.DUST_OUTPUT, "Dust output", "Construct an output below the active relay dust threshold."), + (AttackType.FEE_POLICY_FAILURE, "Fee-policy failure", "Submit a transaction that violates the active node fee policy."), + (AttackType.MISSING_PARENT_TRANSACTION, "Missing parent transaction", "Submit a child whose required parent is unavailable."), + (AttackType.DOUBLE_SPEND_ATTEMPT, "Double-spend attempt", "Submit a transaction that conflicts with an observed spend."), + (AttackType.RBF_REPLACEMENT_POLICY_FAILURE, "RBF replacement-policy failure", "Attempt a replacement that violates the node's replacement policy."), + (AttackType.RUNTIME_NETWORK_MISMATCH, "Runtime network mismatch", "Attempt mutation when configured and runtime networks do not match regtest."), + ) +) + + +def _mempool( + classification: FailureCategory, + reason: str, + match: RejectReasonMatch = RejectReasonMatch.EXACT, +) -> MempoolRejectionExpectation: + return MempoolRejectionExpectation( + classification=classification, + reject_reason=reason, + reason_match=match, + ) + + +DEFAULT_ATTACK_DEFINITIONS = ( + AttackDefinition( + attack_id="transaction-lifecycle.output-modification", + attack_type=AttackType.OUTPUT_MODIFICATION, + title="Overspend output modification", + description="Increase the output by one satoshi above its selected input and require Core's structured rejection.", + scenario_ids=["transaction-lifecycle"], + required_features=[AttackFeature.RAW_TRANSACTION, AttackFeature.MUTABLE_OUTPUTS, AttackFeature.MEMPOOL_PREFLIGHT], + expectation=_mempool(FailureCategory.CONSENSUS_VALIDATION, "bad-txns-in-belowout"), + ), + AttackDefinition( + attack_id="rbf-replacement.replacement-policy", + attack_type=AttackType.RBF_REPLACEMENT_POLICY_FAILURE, + title="Insufficient replacement fee", + description="Request the observed fee rate and require Core RPC -8 plus structured fee markers.", + scenario_ids=["rbf-replacement"], + required_features=[AttackFeature.WALLET_TRANSACTION, AttackFeature.RBF_SIGNALING, AttackFeature.RPC_ERROR], + expectation=RpcErrorExpectation( + classification=FailureCategory.MEMPOOL_POLICY, + rpc_method="bumpfee", + rpc_code=-8, + message_markers=["Insufficient total fee", "oldFee", "incrementalFee"], + ), + ), + AttackDefinition( + attack_id="multisig-psbt.signature-insufficiency", + attack_type=AttackType.SIGNATURE_INSUFFICIENCY, + title="One-of-three signer attempt", + description="Keep a 2-of-3 multisig PSBT below threshold with exactly one signature.", + scenario_ids=["multisig-psbt"], + required_features=[AttackFeature.PSBT, AttackFeature.THRESHOLD_POLICY], + expectation=PsbtIncompleteExpectation(observed_signature_count=1, required_signature_count=2), + ), + AttackDefinition( + attack_id="multisig-psbt.psbt-incompleteness", + attack_type=AttackType.PSBT_INCOMPLETENESS, + title="Incomplete multisig finalization", + description="Require finalizepsbt to remain incomplete and return no transaction hex.", + scenario_ids=["multisig-psbt"], + required_features=[AttackFeature.PSBT, AttackFeature.THRESHOLD_POLICY], + expectation=PsbtIncompleteExpectation(), + ), + AttackDefinition( + attack_id="cltv-timelock.premature-timelock", + attack_type=AttackType.PREMATURE_TIMELOCK_EXECUTION, + title="Premature CLTV spend", + description="Submit the correctly signed CLTV transaction below its absolute lock height.", + scenario_ids=["cltv-timelock"], + required_features=[AttackFeature.RAW_TRANSACTION, AttackFeature.ABSOLUTE_TIMELOCK, AttackFeature.MEMPOOL_PREFLIGHT], + expectation=_mempool(FailureCategory.MEMPOOL_POLICY, "non-final"), + ), + AttackDefinition( + attack_id="cltv-timelock.sequence-modification", + attack_type=AttackType.SEQUENCE_MODIFICATION, + title="Final-sequence CLTV spend", + description="Set the input sequence final so the transaction cannot activate nLockTime.", + scenario_ids=["cltv-timelock"], + required_features=[AttackFeature.RAW_TRANSACTION, AttackFeature.ABSOLUTE_TIMELOCK, AttackFeature.MUTABLE_INPUTS, AttackFeature.MEMPOOL_PREFLIGHT], + expectation=_mempool(FailureCategory.SCRIPT_VERIFICATION, LOCKTIME_FAILURE_MARKER, RejectReasonMatch.CONTAINS), + ), + AttackDefinition( + attack_id="cltv-timelock.locktime-modification", + attack_type=AttackType.LOCKTIME_MODIFICATION, + title="Low-locktime CLTV spend", + description="Set nLockTime one block below the script requirement.", + scenario_ids=["cltv-timelock"], + required_features=[AttackFeature.RAW_TRANSACTION, AttackFeature.ABSOLUTE_TIMELOCK, AttackFeature.MEMPOOL_PREFLIGHT], + expectation=_mempool(FailureCategory.SCRIPT_VERIFICATION, LOCKTIME_FAILURE_MARKER, RejectReasonMatch.CONTAINS), + ), + *tuple( + AttackDefinition( + attack_id=f"community-treasury-recovery.{branch}-signature-insufficiency", + attack_type=AttackType.SIGNATURE_INSUFFICIENCY, + title=f"Insufficient {branch} signatures", + description=f"Keep the {branch} treasury branch below its 2-of-3 threshold.", + scenario_ids=["community-treasury-recovery"], + required_features=[AttackFeature.PSBT, AttackFeature.THRESHOLD_POLICY], + expectation=PsbtIncompleteExpectation(observed_signature_count=1, required_signature_count=2), + ) + for branch in ("immediate", "recovery", "emergency") + ), + *tuple( + AttackDefinition( + attack_id=f"community-treasury-recovery.{branch}-psbt-incompleteness", + attack_type=AttackType.PSBT_INCOMPLETENESS, + title=f"Incomplete {branch} PSBT", + description=f"Require the one-signature {branch} PSBT to remain unextractable.", + scenario_ids=["community-treasury-recovery"], + required_features=[AttackFeature.PSBT, AttackFeature.THRESHOLD_POLICY], + expectation=PsbtIncompleteExpectation(), + ) + for branch in ("immediate", "recovery", "emergency") + ), + *tuple( + AttackDefinition( + attack_id=f"community-treasury-recovery.{branch}-premature-timelock", + attack_type=AttackType.PREMATURE_TIMELOCK_EXECUTION, + title=f"Premature {branch} spend", + description=f"Submit the fully signed {branch} branch before its relative delay.", + scenario_ids=["community-treasury-recovery"], + required_features=[AttackFeature.PSBT, AttackFeature.RELATIVE_TIMELOCK, AttackFeature.MEMPOOL_PREFLIGHT], + expectation=_mempool(FailureCategory.MEMPOOL_POLICY, "non-BIP68-final"), + ) + for branch in ("recovery", "emergency") + ), + AttackDefinition( + attack_id="community-treasury-recovery.sequence-modification", + attack_type=AttackType.SEQUENCE_MODIFICATION, + title="Recovery sequence below older(5)", + description="Set sequence four so Core's Miniscript finalizer cannot satisfy older(5).", + scenario_ids=["community-treasury-recovery"], + required_features=[AttackFeature.PSBT, AttackFeature.RELATIVE_TIMELOCK, AttackFeature.MUTABLE_INPUTS], + expectation=PsbtIncompleteExpectation(), + ), +) + + +class AttackCatalog: + def __init__( + self, + profiles: Iterable[AttackTypeProfile] = ATTACK_TYPE_PROFILES, + definitions: Iterable[AttackDefinition] = DEFAULT_ATTACK_DEFINITIONS, + ) -> None: + profile_items = tuple(profiles) + definition_items = tuple(definitions) + self._profiles = {profile.attack_type: profile for profile in profile_items} + self._definitions = {definition.attack_id: definition for definition in definition_items} + if set(self._profiles) != set(AttackType): + raise ValueError("The attack catalog must describe every typed attack category.") + if len(self._profiles) != len(profile_items): + raise ValueError("Attack type profiles must be unique.") + if len(self._definitions) != len(definition_items): + raise ValueError("Attack definition identifiers must be unique.") + + @property + def profiles(self) -> tuple[AttackTypeProfile, ...]: + return tuple(self._profiles[attack_type] for attack_type in AttackType) + + @property + def definitions(self) -> tuple[AttackDefinition, ...]: + return tuple(self._definitions[attack_id] for attack_id in sorted(self._definitions)) + + def get(self, attack_id: str) -> AttackDefinition: + try: + return self._definitions[attack_id] + except KeyError as exc: + raise BitScopeError( + "ATTACK_DEFINITION_NOT_FOUND", + "The requested reviewed attack definition does not exist.", + 404, + {"attack_id": attack_id}, + ) from exc + + def assess(self, attack_id: str, context: AttackContext) -> AttackApplicabilityDecision: + definition = self.get(attack_id) + if context.scenario_id not in definition.scenario_ids: + return AttackApplicabilityDecision( + attack_id=definition.attack_id, + attack_type=definition.attack_type, + scenario_id=context.scenario_id, + status=AttackApplicabilityStatus.NOT_APPLICABLE, + reason="The reviewed attack is not registered for this scenario.", + ) + available = set(context.available_features) + missing = [feature for feature in definition.required_features if feature not in available] + if missing: + return AttackApplicabilityDecision( + attack_id=definition.attack_id, + attack_type=definition.attack_type, + scenario_id=context.scenario_id, + status=AttackApplicabilityStatus.NOT_APPLICABLE, + reason="The scenario does not expose every feature required by this attack.", + missing_features=missing, + ) + return AttackApplicabilityDecision( + attack_id=definition.attack_id, + attack_type=definition.attack_type, + scenario_id=context.scenario_id, + status=AttackApplicabilityStatus.APPLICABLE, + reason="The scenario and its declared features satisfy the reviewed attack prerequisites.", + ) + + def assess_type( + self, + attack_type: AttackType, + context: AttackContext, + ) -> AttackApplicabilityDecision: + candidates = [ + definition + for definition in self.definitions + if definition.attack_type == attack_type and context.scenario_id in definition.scenario_ids + ] + if not candidates: + return AttackApplicabilityDecision( + attack_type=attack_type, + scenario_id=context.scenario_id, + status=AttackApplicabilityStatus.NOT_APPLICABLE, + reason="No reviewed definition of this attack type applies to the scenario.", + ) + return self.assess(candidates[0].attack_id, context) + + +DEFAULT_ATTACK_CATALOG = AttackCatalog() + + +class AttackVerificationService: + """Classify reviewed negative outcomes after an explicit applicability decision.""" + + def __init__( + self, + catalog: AttackCatalog = DEFAULT_ATTACK_CATALOG, + redactor: EvidenceRedactor | None = None, + ) -> None: + self.catalog = catalog + self.redactor = redactor or EvidenceRedactor() + + def assess(self, attack_id: str, context: AttackContext) -> AttackApplicabilityDecision: + return self.catalog.assess(attack_id, context) + + @staticmethod + def require_applicable( + decision: AttackApplicabilityDecision, + ) -> AttackApplicabilityDecision: + if decision.status == AttackApplicabilityStatus.APPLICABLE: + return decision + raise BitScopeError( + "SCENARIO_ATTACK_NOT_APPLICABLE", + "A required reviewed attack is not applicable to this scenario context.", + 409, + {"applicability": decision.model_dump(mode="json")}, + ) + + def skip(self, decision: AttackApplicabilityDecision) -> AttackVerificationResult: + if decision.status != AttackApplicabilityStatus.NOT_APPLICABLE: + raise ValueError("Only an explicitly not-applicable attack can be skipped.") + return AttackVerificationResult( + attack_id=decision.attack_id, + attack_type=decision.attack_type, + scenario_id=decision.scenario_id, + applicability=decision.status, + status=AttackVerificationStatus.SKIPPED, + safe_message=decision.reason, + raw_safe_details={"missing_features": [item.value for item in decision.missing_features]}, + ) + + def verify( + self, + decision: AttackApplicabilityDecision, + observation: AttackObservation, + ) -> AttackVerificationResult: + if decision.status != AttackApplicabilityStatus.APPLICABLE or decision.attack_id is None: + raise ValueError("Attack verification requires a prior applicable decision.") + definition = self.catalog.get(decision.attack_id) + expected = definition.expectation + matched = False + if isinstance(expected, MempoolRejectionExpectation) and isinstance( + observation, MempoolAttackObservation + ): + reason_matches = ( + observation.reject_reason == expected.reject_reason + if expected.reason_match == RejectReasonMatch.EXACT + else observation.reject_reason is not None + and expected.reject_reason in observation.reject_reason + ) + matched = observation.allowed is False and reason_matches + elif isinstance(expected, PsbtIncompleteExpectation) and isinstance( + observation, PsbtAttackObservation + ): + matched = observation.complete is False and ( + not expected.require_no_transaction_hex or not observation.transaction_hex_present + ) + if expected.observed_signature_count is not None: + matched = matched and observation.signature_count == expected.observed_signature_count + elif isinstance(expected, RpcErrorExpectation) and isinstance( + observation, RpcErrorAttackObservation + ): + normalized = observation.rpc_message.casefold().replace(" ", "") + matched = ( + observation.rpc_method == expected.rpc_method + and observation.rpc_code == expected.rpc_code + and all(marker.casefold().replace(" ", "") in normalized for marker in expected.message_markers) + ) + + raw = self._safe_details(observation.raw_safe_details) + if matched: + return AttackVerificationResult( + attack_id=definition.attack_id, + attack_type=definition.attack_type, + scenario_id=decision.scenario_id, + applicability=decision.status, + status=AttackVerificationStatus.EXPECTED_FAILURE, + classification=expected.classification, + expected_classification=expected.classification, + safe_message="The structured observation matched the reviewed expected failure.", + raw_safe_details=raw, + ) + return AttackVerificationResult( + attack_id=definition.attack_id, + attack_type=definition.attack_type, + scenario_id=decision.scenario_id, + applicability=decision.status, + status=AttackVerificationStatus.UNEXPECTED_FAILURE, + classification=FailureCategory.UNEXPECTED_APPLICATION, + expected_classification=expected.classification, + safe_message="The structured observation did not match the reviewed expected failure.", + raw_safe_details=raw, + ) + + @staticmethod + def require_expected( + result: AttackVerificationResult, + *, + mismatch_code: str, + safe_message: str, + ) -> AttackVerificationResult: + if result.status == AttackVerificationStatus.EXPECTED_FAILURE: + return result + raise BitScopeError( + mismatch_code, + safe_message, + 409, + {"attack_result": result.model_dump(mode="json")}, + ) + + def _safe_details(self, value: JsonValue) -> JsonValue: + return self._bound_json(self.redactor.redact(value), depth=0) + + @classmethod + def _bound_json(cls, value: JsonValue, *, depth: int) -> JsonValue: + if depth >= 6: + return "[TRUNCATED]" + if isinstance(value, str): + return value[:2_000] + if isinstance(value, list): + return [cls._bound_json(item, depth=depth + 1) for item in value[:64]] + if isinstance(value, dict): + return { + str(key)[:120]: cls._bound_json(item, depth=depth + 1) + for key, item in list(value.items())[:64] + } + return value diff --git a/backend/app/services/challenge_service.py b/backend/app/services/challenge_service.py new file mode 100644 index 0000000..2545a69 --- /dev/null +++ b/backend/app/services/challenge_service.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from app.errors import BitScopeError +from app.models.curriculum import ( + ChallengeCatalogResponse, + ChallengeDefinition, + ChallengeEvidenceReference, + ChallengeHint, + ChallengeVerificationCheck, + ChallengeVerificationResult, +) +from app.models.scenario import AssertionResultStatus, CleanupStatus, ScenarioFinalResult +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_run_store import ScenarioRunStore + + +@dataclass(frozen=True) +class ChallengeSpec: + definition: ChallengeDefinition + hints: tuple[str, ...] + required_assertion_ids: tuple[str, ...] + required_evidence_ids: tuple[str, ...] + completion_explanation: str + + +def _challenge( + challenge_id: str, + title: str, + difficulty: str, + objective: str, + allowed_actions: tuple[str, ...], + relevant_pages: tuple[str, ...], + scenario_id: str, + verification_summary: str, + hints: tuple[str, ...], + required_assertion_ids: tuple[str, ...], + required_evidence_ids: tuple[str, ...], + completion_explanation: str, +) -> ChallengeSpec: + return ChallengeSpec( + definition=ChallengeDefinition( + challenge_id=challenge_id, + version="1.0.0", + title=title, + difficulty=difficulty, + objective=objective, + allowed_actions=list(allowed_actions), + relevant_pages=list(relevant_pages), + scenario_id=scenario_id, + hint_count=len(hints), + verification_summary=verification_summary, + ), + hints=hints, + required_assertion_ids=required_assertion_ids, + required_evidence_ids=required_evidence_ids, + completion_explanation=completion_explanation, + ) + + +CHALLENGES: tuple[ChallengeSpec, ...] = ( + _challenge( + "signal-opt-in-rbf", + "Create an opt-in RBF transaction", + "intermediate", + "Produce a transaction whose inputs explicitly signal replaceability and prove its policy state through Bitcoin Core.", + ("Create and run a disposable regtest lab", "Use the reviewed RBF scenario", "Inspect transaction and mempool evidence"), + ("/tx-control", "/mempool", "/scenarios"), + "rbf-replacement", + "A completed run must contain the passed original_signaled_rbf assertion and its persisted original-transaction evidence.", + ( + "Start by distinguishing an input sequence from a transaction fee.", + "Inspect the original transaction's decoded input sequences and Core's bip125-replaceable mempool field.", + "Run the reviewed RBF scenario, then submit its run ID and owning lab session ID here.", + ), + ("original_signaled_rbf",), + ("rbf.original",), + "Bitcoin Core observed replaceable input signaling and reported the original transaction as BIP125-replaceable in the mempool evidence.", + ), + _challenge( + "replace-rbf-higher-fee", + "Replace a transaction with a higher fee", + "intermediate", + "Replace an opt-in transaction with a distinct higher-fee transaction, prove original eviction, and confirm the replacement.", + ("Use the reviewed RBF scenario", "Inspect fee and txid evidence", "Mine only on disposable regtest"), + ("/tx-control", "/mempool", "/fees", "/scenarios"), + "rbf-replacement", + "Core-backed assertions must prove original replacement, replacement mempool presence, and confirmation.", + ( + "A replacement must conflict with the original and pay enough additional fee for local policy.", + "Compare the original txid with bumpfee's txid, then inspect both mempool lookups.", + "The reviewed scenario first records an insufficient bump, then applies a higher requested fee rate.", + ), + ("original_replaced", "replacement_in_mempool", "replacement_confirmed"), + ("rbf.replacement", "rbf.confirmed"), + "The replacement has a distinct transaction ID, Core no longer finds the original in its mempool, and the higher-fee replacement confirms.", + ), + _challenge( + "complete-two-of-three-psbt", + "Complete a 2-of-3 PSBT", + "intermediate", + "Progress from an incomplete one-signature PSBT to a finalized and accepted 2-of-3 spend without exporting private keys.", + ("Use session-owned regtest wallets", "Use the reviewed multisig PSBT scenario", "Inspect PSBT and final transaction evidence"), + ("/multisig", "/psbt", "/scenarios"), + "multisig-psbt", + "Passed threshold, PSBT completion, mempool acceptance, and confirmation assertions are required.", + ( + "Keep PSBT signing separate from final extraction so partial signatures remain inspectable.", + "Check that one signer remains incomplete, then add a different signer before finalizepsbt.", + "Submit a verified multisig-psbt run after Core has accepted and confirmed the finalized spend.", + ), + ("threshold_met", "psbt_complete", "spend_accepted", "spend_confirmed"), + ("psbt.complete", "multisig.confirmed"), + "The evidence preserves the incomplete partial state, proves the 2-of-3 threshold, and shows Core accepting and confirming the finalized transaction.", + ), + _challenge( + "prove-premature-cltv-failure", + "Prove a premature CLTV failure", + "advanced", + "Create a correctly signed absolute-height CLTV spend and prove Core rejects it before the recorded maturity height.", + ("Use the reviewed CLTV scenario", "Inspect locktime and sequence", "Use testmempoolaccept before broadcast"), + ("/timelocks", "/script-lab", "/scenarios"), + "cltv-timelock", + "The premature_rejected and timelock_immature assertions must pass against persisted Core preflight evidence.", + ( + "A CLTV spend needs both an adequate nLockTime and a non-final input sequence.", + "Compare the current height with the policy lock height; do not alter the correctly signed transaction between tests.", + "Submit the completed CLTV scenario whose premature evidence contains Core's reviewed non-final result.", + ), + ("premature_rejected", "timelock_immature"), + ("cltv.premature",), + "Core rejected the correctly structured spend before its absolute lock height, and the scenario separately proved the unchanged transaction at maturity.", + ), + _challenge( + "diagnose-mempool-rejection", + "Diagnose a mempool rejection", + "intermediate", + "Use Core's structured testmempoolaccept result to identify a value-conservation failure rather than guessing from frontend state.", + ("Use the transaction lifecycle scenario", "Inspect only redacted Core evidence", "Compare input and output amounts"), + ("/transactions", "/mempool", "/scenarios"), + "transaction-lifecycle", + "The overspend_rejected assertion and transaction.overspend-rejection artifact must be present and valid.", + ( + "Start with the sum of inputs and outputs, including the implied fee.", + "Look for allowed=false and the reviewed reject-reason in testmempoolaccept evidence.", + "Submit a verified transaction-lifecycle run that includes the deliberately one-satoshi overspend.", + ), + ("overspend_rejected",), + ("transaction.overspend-rejection",), + "Core's preflight evidence identifies the deliberately invalid one-satoshi overspend, and the typed scenario assertion classifies that exact rejection.", + ), + _challenge( + "complete-treasury-recovery", + "Complete treasury recovery", + "advanced", + "Prove the Community Treasury Recovery branch is incomplete below threshold, rejected before its CSV delay, and spendable unchanged after maturity.", + ("Use the flagship Verified Scenario", "Use public policy and PSBT evidence", "Keep all activity on disposable regtest"), + ("/scenarios", "/multisig", "/psbt", "/timelocks"), + "community-treasury-recovery", + "Recovery threshold, premature rejection, maturity, acceptance, confirmation, and cleanup must all be proved.", + ( + "Treat signature threshold and relative timelock as independent requirements.", + "Inspect the recovery sequence and the funding output's confirmation age before the mature preflight.", + "Submit the verified flagship run after its Proof of Spendability reports the recovery branch and cleanup as successful.", + ), + ("recovery_threshold_met", "premature_recovery_rejected", "recovery_timelock_mature", "recovery_accepted", "recovery_confirmed"), + ("treasury.recovery-partial", "treasury.recovery-premature", "treasury.recovery-mature"), + "The public policy, threshold signatures, exact premature rejection, recorded CSV maturity, accepted unchanged spend, confirmation, and cleanup jointly prove the recovery path.", + ), +) + + +class ChallengeService: + def __init__(self, run_store: ScenarioRunStore, artifact_store: ScenarioArtifactStore) -> None: + self.run_store = run_store + self.artifact_store = artifact_store + + def catalog(self) -> ChallengeCatalogResponse: + return ChallengeCatalogResponse( + challenges=[spec.definition for spec in CHALLENGES], + explanation=( + "Challenge solutions stay locked until completion. Request hints one at a time, then submit a completed " + "Verified Scenario run for backend validation against persisted Bitcoin Core evidence." + ), + ) + + def hint(self, challenge_id: str, level: int) -> ChallengeHint: + spec = self._spec(challenge_id) + if level < 1 or level > len(spec.hints): + raise BitScopeError( + "CHALLENGE_HINT_NOT_FOUND", + "That progressive hint level is not available.", + 404, + {"challenge_id": challenge_id, "hint_count": len(spec.hints)}, + ) + return ChallengeHint( + challenge_id=challenge_id, + level=level, + hint=spec.hints[level - 1], + remaining_hints=len(spec.hints) - level, + ) + + def verify(self, challenge_id: str, run_id: UUID, lab_session_id: str) -> ChallengeVerificationResult: + spec = self._spec(challenge_id) + run = self.run_store.get_for_session(run_id, lab_session_id) + if run is None: + raise BitScopeError( + "SCENARIO_RUN_NOT_FOUND", + "The scenario run was not found for this lab session.", + 404, + {"run_id": str(run_id)}, + ) + if run.scenario_id != spec.definition.scenario_id: + raise BitScopeError( + "CHALLENGE_SCENARIO_MISMATCH", + "This challenge requires a different reviewed scenario.", + 409, + { + "challenge_id": challenge_id, + "required_scenario_id": spec.definition.scenario_id, + "submitted_scenario_id": run.scenario_id, + }, + ) + + records = self.artifact_store.list_evidence(run) + records_by_id = {record.evidence_id: record for record in records} + assertions = {result.assertion_id: result for result in run.assertion_results} + checks: list[ChallengeVerificationCheck] = [] + + run_verified = run.final_result == ScenarioFinalResult.VERIFIED + checks.append( + ChallengeVerificationCheck( + check_id="run.verified", + passed=run_verified, + explanation=( + "The reviewed scenario reached a verified terminal result." + if run_verified + else "The reviewed scenario has not reached a verified terminal result." + ), + ) + ) + cleanup_complete = run.cleanup_status == CleanupStatus.COMPLETED + checks.append( + ChallengeVerificationCheck( + check_id="run.cleanup", + passed=cleanup_complete, + explanation=( + "Session-owned cleanup completed." + if cleanup_complete + else "Session-owned cleanup has not completed." + ), + evidence_ids=["lifecycle.cleanup"] if "lifecycle.cleanup" in records_by_id else [], + ) + ) + core_identified = bool(run.bitcoin_core_version) and "node.context" in records_by_id + checks.append( + ChallengeVerificationCheck( + check_id="core.identified", + passed=core_identified, + explanation=( + f"The run identifies Bitcoin Core {run.bitcoin_core_version} and preserves node context." + if core_identified + else "The run does not contain both a Bitcoin Core version and node-context evidence." + ), + evidence_ids=["node.context"] if "node.context" in records_by_id else [], + ) + ) + + relevant_evidence_ids: set[str] = {"node.context", "lifecycle.cleanup"} + for assertion_id in spec.required_assertion_ids: + result = assertions.get(assertion_id) + passed = result is not None and result.status == AssertionResultStatus.PASSED + evidence_ids = result.evidence_ids if result is not None else [] + relevant_evidence_ids.update(evidence_ids) + checks.append( + ChallengeVerificationCheck( + check_id=f"assertion.{assertion_id}", + passed=passed, + explanation=( + f"Scenario assertion {assertion_id} passed using persisted evidence." + if passed + else f"Scenario assertion {assertion_id} has not passed." + ), + evidence_ids=evidence_ids, + ) + ) + + for evidence_id in spec.required_evidence_ids: + present = evidence_id in records_by_id + relevant_evidence_ids.add(evidence_id) + checks.append( + ChallengeVerificationCheck( + check_id=f"evidence.{evidence_id}", + passed=present, + explanation=( + f"The canonical {evidence_id} artifact was loaded and identity-checked." + if present + else f"The required {evidence_id} artifact is absent." + ), + evidence_ids=[evidence_id] if present else [], + ) + ) + + completed = all(check.passed for check in checks) + reference_by_id = {reference.evidence_id: reference for reference in run.evidence} + evidence = [ + ChallengeEvidenceReference( + evidence_id=evidence_id, + kind=reference_by_id[evidence_id].kind.value, + content_sha256=reference_by_id[evidence_id].content_sha256, + ) + for evidence_id in sorted(relevant_evidence_ids) + if evidence_id in records_by_id + and evidence_id in reference_by_id + and reference_by_id[evidence_id].content_sha256 is not None + ] + return ChallengeVerificationResult( + challenge_id=challenge_id, + challenge_version=spec.definition.version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + bitcoin_core_version=run.bitcoin_core_version, + verified_at=run.updated_at, + completed=completed, + checks=checks, + evidence=evidence, + final_explanation=( + spec.completion_explanation + if completed + else "Completion remains locked because one or more backend scenario, cleanup, Core identity, assertion, or evidence checks have not passed." + ), + solution_unlocked=completed, + ) + + @staticmethod + def _spec(challenge_id: str) -> ChallengeSpec: + for spec in CHALLENGES: + if spec.definition.challenge_id == challenge_id: + return spec + raise BitScopeError( + "CHALLENGE_NOT_FOUND", + "The requested learning challenge does not exist.", + 404, + {"challenge_id": challenge_id}, + ) diff --git a/backend/app/services/cltv_timelock_scenario.py b/backend/app/services/cltv_timelock_scenario.py new file mode 100644 index 0000000..c906367 --- /dev/null +++ b/backend/app/services/cltv_timelock_scenario.py @@ -0,0 +1,356 @@ +from app.models.scenario import ScenarioDefinition + + +CLTV_TIMELOCK_SCENARIO = ScenarioDefinition.model_validate( + { + "scenario_id": "cltv-timelock", + "version": "1.0.0", + "name": "Real CLTV timelocked spend", + "summary": ( + "Fund a native-SegWit OP_CHECKLOCKTIMEVERIFY policy, prove that its correctly signed spend is non-final " + "before the target height, prove that final sequence and low nLockTime variants fail script validation, " + "then advance regtest, broadcast the unchanged mature spend, and confirm it." + ), + "difficulty": "advanced", + "related_lbcli_chapters": [6, 7, 10], + "concepts": [ + "OP_CHECKLOCKTIMEVERIFY", + "Absolute block-height lock", + "nLockTime", + "Input sequence", + "P2WSH", + "BIP143 signing", + ], + "required_capabilities": ["read_only", "wallet_read", "regtest_mutation"], + "estimated_run_steps": 24, + "steps": [ + { + "step_id": "verify_chain", + "type": "verify_runtime_chain", + "phase": "setup", + "title": "Verify the runtime chain", + "description": "Require the configured node and live Bitcoin Core chain to agree on regtest.", + "output_context_ref": "node.context", + }, + { + "step_id": "prepare_funding_wallet", + "type": "prepare_isolated_wallet", + "phase": "setup", + "title": "Prepare the funding wallet", + "description": "Use only the active wallet owned by this run's lab session.", + "depends_on": ["verify_chain"], + "wallet_role": "funder", + "output_wallet_ref": "wallet.funder", + }, + { + "step_id": "prepare_cltv_signer", + "type": "prepare_cltv_signer", + "phase": "setup", + "title": "Prepare an ephemeral policy signer", + "description": "Create an in-memory secp256k1 signer that is never exported to Core, evidence, settings, or SQLite.", + "depends_on": ["prepare_funding_wallet"], + "signer_kind": "ephemeral_software_key", + "output_signer_ref": "signer.ephemeral", + }, + { + "step_id": "generate_mining_address", + "type": "generate_address", + "phase": "setup", + "title": "Generate a mining address", + "description": "Generate a fresh funding-wallet address for maturity and confirmation blocks.", + "depends_on": ["prepare_cltv_signer"], + "wallet_ref": "wallet.funder", + "label": "bitscope-cltv-mining", + "address_type": "bech32", + "output_address_ref": "address.mining", + }, + { + "step_id": "mine_mature_funds", + "type": "mine_blocks", + "phase": "setup", + "title": "Mine mature funding", + "description": "Mine 101 blocks in bounded batches so the funding wallet can spend.", + "depends_on": ["generate_mining_address"], + "address_ref": "address.mining", + "blocks": 101, + "output_blocks_ref": "blocks.maturity", + }, + { + "step_id": "create_cltv_policy", + "type": "create_cltv_policy", + "phase": "setup", + "title": "Create the CLTV policy", + "description": "Commit to a target four blocks beyond the current tip in a P2WSH witness script.", + "depends_on": ["mine_mature_funds"], + "signer_ref": "signer.ephemeral", + "blocks_from_tip": 4, + "output_policy_ref": "cltv.policy", + "output_lock_height_ref": "cltv.lock_height", + }, + { + "step_id": "fund_cltv_policy", + "type": "fund_cltv_policy", + "phase": "setup", + "title": "Fund the CLTV policy", + "description": "Send 0.5 BTC to the policy with an explicit 2 sat/vB fee rate and retain its exact outpoint.", + "depends_on": ["create_cltv_policy"], + "wallet_ref": "wallet.funder", + "policy_ref": "cltv.policy", + "amount_btc": "0.50000000", + "fee_rate_sat_vb": "2.000", + "output_funding_ref": "cltv.funding", + }, + { + "step_id": "confirm_cltv_funding", + "type": "mine_confirmation_blocks", + "phase": "setup", + "title": "Confirm the policy output", + "description": "Mine one block so the CLTV outpoint is confirmed while the target remains in the future.", + "depends_on": ["fund_cltv_policy"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.funding_confirmation", + }, + { + "step_id": "generate_destination", + "type": "generate_address", + "phase": "setup", + "title": "Generate the spend destination", + "description": "Generate a fresh funding-wallet destination for the policy spend.", + "depends_on": ["confirm_cltv_funding"], + "wallet_ref": "wallet.funder", + "label": "bitscope-cltv-destination", + "address_type": "bech32", + "output_address_ref": "address.destination", + }, + { + "step_id": "construct_premature_spend", + "type": "create_cltv_spend", + "phase": "execution", + "title": "Construct the valid CLTV spend", + "description": "Sign a one-input spend with nLockTime equal to the policy target and a non-final sequence.", + "depends_on": ["generate_destination"], + "signer_ref": "signer.ephemeral", + "policy_ref": "cltv.policy", + "funding_ref": "cltv.funding", + "recipient_address_ref": "address.destination", + "lock_height_adjustment": 0, + "sequence": 4294967294, + "fee_sats": 10000, + "output_transaction_ref": "spend.valid", + }, + { + "step_id": "reject_premature_spend", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Prove the spend is premature", + "description": "Require Core to reject the correctly signed transaction as non-final before the target height.", + "depends_on": ["construct_premature_spend"], + "transaction_ref": "spend.valid", + "output_acceptance_ref": "acceptance.premature", + }, + { + "step_id": "construct_final_sequence_spend", + "type": "create_cltv_spend", + "phase": "attack", + "title": "Construct a final-sequence variant", + "description": "Sign the same policy spend with sequence 0xffffffff, which disables transaction locktime semantics.", + "depends_on": ["reject_premature_spend"], + "signer_ref": "signer.ephemeral", + "policy_ref": "cltv.policy", + "funding_ref": "cltv.funding", + "recipient_address_ref": "address.destination", + "lock_height_adjustment": 0, + "sequence": 4294967295, + "fee_sats": 10000, + "output_transaction_ref": "spend.final_sequence", + }, + { + "step_id": "reject_final_sequence_spend", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Prove sequence is enforced", + "description": "Require CLTV script validation to reject the final-sequence variant.", + "depends_on": ["construct_final_sequence_spend"], + "transaction_ref": "spend.final_sequence", + "output_acceptance_ref": "acceptance.final_sequence", + }, + { + "step_id": "advance_to_maturity", + "type": "advance_absolute_timelock", + "phase": "attack", + "title": "Advance to the target height", + "description": "Mine only the bounded number of blocks required to reach the policy height.", + "depends_on": ["reject_final_sequence_spend"], + "address_ref": "address.mining", + "target_height_ref": "cltv.lock_height", + "output_height_ref": "cltv.mature_height", + }, + { + "step_id": "construct_low_locktime_spend", + "type": "create_cltv_spend", + "phase": "attack", + "title": "Construct a low-locktime variant", + "description": "Sign a variant whose nLockTime is one block below the script requirement.", + "depends_on": ["advance_to_maturity"], + "signer_ref": "signer.ephemeral", + "policy_ref": "cltv.policy", + "funding_ref": "cltv.funding", + "recipient_address_ref": "address.destination", + "lock_height_adjustment": -1, + "sequence": 4294967294, + "fee_sats": 10000, + "output_transaction_ref": "spend.low_locktime", + }, + { + "step_id": "reject_low_locktime_spend", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Prove nLockTime is enforced", + "description": "Require CLTV script validation to reject the low-locktime variant even after chain maturity.", + "depends_on": ["construct_low_locktime_spend"], + "transaction_ref": "spend.low_locktime", + "output_acceptance_ref": "acceptance.low_locktime", + }, + { + "step_id": "accept_mature_spend", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Preflight the mature spend", + "description": "Require Core to accept the unchanged transaction that was non-final before maturity.", + "depends_on": ["reject_low_locktime_spend"], + "transaction_ref": "spend.valid", + "output_acceptance_ref": "acceptance.mature", + }, + { + "step_id": "broadcast_mature_spend", + "type": "broadcast_transaction", + "phase": "attack", + "title": "Broadcast the mature spend", + "description": "Broadcast only the preflighted mature transaction.", + "depends_on": ["accept_mature_spend"], + "transaction_ref": "spend.valid", + "output_txid_ref": "spend.txid", + }, + { + "step_id": "inspect_spend_mempool", + "type": "query_mempool_entry", + "phase": "attack", + "title": "Inspect the spend in mempool", + "description": "Record the accepted CLTV spend's mempool entry.", + "depends_on": ["broadcast_mature_spend"], + "txid_ref": "spend.txid", + "output_mempool_ref": "spend.mempool", + }, + { + "step_id": "confirm_mature_spend", + "type": "mine_confirmation_blocks", + "phase": "attack", + "title": "Confirm the mature spend", + "description": "Mine one block containing the mature CLTV spend.", + "depends_on": ["inspect_spend_mempool"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.spend_confirmation", + }, + { + "step_id": "decode_confirmed_spend", + "type": "decode_transaction", + "phase": "attack", + "title": "Decode the confirmed spend", + "description": "Read and decode the confirmed transaction, preserving its locktime and witness commitment.", + "depends_on": ["confirm_mature_spend"], + "transaction_ref": "spend.valid", + "output_decoded_ref": "spend.confirmed", + }, + { + "step_id": "verify_results", + "type": "evaluate_assertions", + "phase": "verification", + "title": "Evaluate CLTV assertions", + "description": "Evaluate premature rejection, script constraints, maturity, acceptance, and confirmation.", + "depends_on": ["decode_confirmed_spend"], + "assertion_ids": [ + "premature_rejected", + "timelock_immature", + "final_sequence_rejected", + "low_locktime_rejected", + "timelock_mature", + "mature_spend_accepted", + "spend_confirmed", + ], + }, + { + "step_id": "export_proof", + "type": "export_evidence", + "phase": "export", + "title": "Export the proof bundle", + "description": "Expose the policy, signed variants, Core outcomes, assertions, commands, manifest, and ZIP export.", + "depends_on": ["verify_results"], + "output_bundle_ref": "proof.bundle", + }, + { + "step_id": "cleanup", + "type": "cleanup_lab", + "phase": "cleanup", + "title": "Clean up signer and wallet state", + "description": "Drop the ephemeral signer reference and unload only the wallet owned by this lab session.", + "depends_on": ["export_proof"], + }, + ], + "assertions": [ + { + "assertion_id": "premature_rejected", + "kind": "rpc_failed_with_category", + "after_step_id": "reject_premature_spend", + "subject_ref": "acceptance.premature", + "expected_category": "mempool_policy", + "description": "Core reports the valid signed transaction as non-final before its target height.", + }, + { + "assertion_id": "timelock_immature", + "kind": "timelock_immature", + "after_step_id": "reject_premature_spend", + "subject_ref": "acceptance.premature", + "description": "The current height is below the CLTV target when the valid spend is first tested.", + }, + { + "assertion_id": "final_sequence_rejected", + "kind": "rpc_failed_with_category", + "after_step_id": "reject_final_sequence_spend", + "subject_ref": "acceptance.final_sequence", + "expected_category": "script_verification", + "description": "CLTV rejects sequence 0xffffffff because locktime is disabled for that input.", + }, + { + "assertion_id": "low_locktime_rejected", + "kind": "rpc_failed_with_category", + "after_step_id": "reject_low_locktime_spend", + "subject_ref": "acceptance.low_locktime", + "expected_category": "script_verification", + "description": "CLTV rejects transaction nLockTime below the value committed in the witness script.", + }, + { + "assertion_id": "timelock_mature", + "kind": "timelock_mature", + "after_step_id": "advance_to_maturity", + "subject_ref": "cltv.mature_height", + "description": "The regtest tip reaches the absolute policy height before the valid spend is accepted.", + }, + { + "assertion_id": "mature_spend_accepted", + "kind": "mempool_policy_accepted", + "after_step_id": "accept_mature_spend", + "subject_ref": "acceptance.mature", + "description": "Core accepts the unchanged valid transaction once the chain reaches maturity.", + }, + { + "assertion_id": "spend_confirmed", + "kind": "transaction_confirmed", + "after_step_id": "decode_confirmed_spend", + "subject_ref": "spend.confirmed", + "description": "The mature CLTV spend confirms with a matching decoded txid and committed nLockTime.", + }, + ], + } +) diff --git a/backend/app/services/cltv_timelock_scenario_service.py b/backend/app/services/cltv_timelock_scenario_service.py new file mode 100644 index 0000000..340b79a --- /dev/null +++ b/backend/app/services/cltv_timelock_scenario_service.py @@ -0,0 +1,936 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime + +from app.errors import BitScopeError +from app.models.attack import AttackContext, AttackFeature, MempoolAttackObservation +from app.models.evidence import EvidenceRecord +from app.models.lab import LabAction, LabSession +from app.models.scenario import ( + AssertionResult, + AssertionResultStatus, + FailureCategory, + ScenarioDefinition, + ScenarioFailure, + ScenarioRun, + ScenarioStepResult, + ScenarioStepResultStatus, +) +from app.rpc.capabilities import RegtestMutationRpcClient, RpcTransport +from app.services.lab_session_service import LabSessionService +from app.services.attack_verification_service import AttackVerificationService +from app.services.lab_session_store import LabSessionStore +from app.services.network_safety import NetworkSafetyGuard +from app.services.scenario_execution import ScenarioExecution, ScenarioExecutionError +from app.services.timelock_service import TimelockService + + +class CltvTimelockScenarioService: + """Prove a real P2WSH CLTV policy before and after absolute-height maturity.""" + + def __init__(self, rpc_client: RpcTransport, lab_store: LabSessionStore) -> None: + self.rpc = RegtestMutationRpcClient(rpc_client) + self.timelock_service = TimelockService(rpc_client) + self.lab_store = lab_store + self.attacks = AttackVerificationService() + + def execute(self, run: ScenarioRun, definition: ScenarioDefinition) -> ScenarioExecution: + captured_at = datetime.now(UTC) + current_step = "prepare_funding_wallet" + try: + session = self._active_session(run) + funding_wallet = session.wallet_name + + current_step = "prepare_cltv_signer" + signer = {"kind": "ephemeral_software_key", "persistence": "memory_only"} + + current_step = "generate_mining_address" + mining_address = self._require_string( + self._mutate( + "getnewaddress", + ["bitscope-cltv-mining", "bech32"], + funding_wallet, + ), + "getnewaddress", + ) + + current_step = "mine_mature_funds" + maturity_hashes = self._mine_blocks(101, mining_address) + + current_step = "create_cltv_policy" + policy_tip = self._require_height(self.rpc.call("getblockcount"), "getblockcount") + lock_height = policy_tip + 4 + policy = self.timelock_service.create_cltv_policy(lock_height) + policy_address = self._require_string(policy.get("policy_address"), "decodescript") + witness_script = self._require_string(policy.get("witness_script"), "decodescript") + + current_step = "fund_cltv_policy" + funding = self.timelock_service.fund_cltv_policy( + funding_wallet, + policy_address, + 0.5, + 2.0, + ) + funding_txid = self._require_txid(funding.get("txid"), "sendtoaddress") + + current_step = "confirm_cltv_funding" + funding_confirmation_hashes = self._mine_blocks(1, mining_address) + + current_step = "generate_destination" + destination_address = self._require_string( + self._mutate( + "getnewaddress", + ["bitscope-cltv-destination", "bech32"], + funding_wallet, + ), + "getnewaddress", + ) + + attack_context = AttackContext( + scenario_id=run.scenario_id, + available_features=[ + AttackFeature.RAW_TRANSACTION, + AttackFeature.MUTABLE_INPUTS, + AttackFeature.ABSOLUTE_TIMELOCK, + AttackFeature.MEMPOOL_PREFLIGHT, + ], + ) + premature_decision = self.attacks.require_applicable( + self.attacks.assess("cltv-timelock.premature-timelock", attack_context) + ) + sequence_decision = self.attacks.require_applicable( + self.attacks.assess("cltv-timelock.sequence-modification", attack_context) + ) + locktime_decision = self.attacks.require_applicable( + self.attacks.assess("cltv-timelock.locktime-modification", attack_context) + ) + + current_step = "construct_premature_spend" + valid_spend = self.timelock_service.create_cltv_spend( + funding, + policy_address, + witness_script, + destination_address, + lock_height, + 0xFFFFFFFE, + 10_000, + ) + valid_hex = self._require_string(valid_spend.get("signed_hex"), "decoderawtransaction") + + current_step = "reject_premature_spend" + premature_height = self._require_height(self.rpc.call("getblockcount"), "getblockcount") + if premature_height >= lock_height: + raise BitScopeError( + "SCENARIO_CLTV_PREMATURE_HEIGHT_MISMATCH", + "The CLTV target was not in the future when the premature spend was tested.", + 409, + {"observed_height": premature_height, "observed_lock_height": lock_height}, + ) + premature_acceptance = self._single_acceptance( + self.rpc.call("testmempoolaccept", [[valid_hex]]) + ) + premature_attack = self.attacks.require_expected( + self.attacks.verify( + premature_decision, + MempoolAttackObservation( + allowed=premature_acceptance["allowed"], + reject_reason=self._safe_reject_reason(premature_acceptance), + raw_safe_details=premature_acceptance, + ), + ), + mismatch_code="SCENARIO_CLTV_PREMATURE_REJECTION_MISMATCH", + safe_message="Bitcoin Core did not reject the premature CLTV spend as non-final.", + ) + + current_step = "construct_final_sequence_spend" + final_sequence_spend = self.timelock_service.create_cltv_spend( + funding, + policy_address, + witness_script, + destination_address, + lock_height, + 0xFFFFFFFF, + 10_000, + ) + final_sequence_hex = self._require_string( + final_sequence_spend.get("signed_hex"), + "decoderawtransaction", + ) + + current_step = "reject_final_sequence_spend" + final_sequence_acceptance = self._single_acceptance( + self.rpc.call("testmempoolaccept", [[final_sequence_hex]]) + ) + sequence_attack = self.attacks.require_expected( + self.attacks.verify( + sequence_decision, + MempoolAttackObservation( + allowed=final_sequence_acceptance["allowed"], + reject_reason=self._safe_reject_reason(final_sequence_acceptance), + raw_safe_details=final_sequence_acceptance, + ), + ), + mismatch_code="SCENARIO_CLTV_FINAL_SEQUENCE_REJECTION_MISMATCH", + safe_message="Bitcoin Core did not reject the final-sequence CLTV variant for its locktime requirement.", + ) + + current_step = "advance_to_maturity" + height_before_advance = self._require_height( + self.rpc.call("getblockcount"), + "getblockcount", + ) + blocks_to_maturity = lock_height - height_before_advance + if blocks_to_maturity < 1 or blocks_to_maturity > 4: + raise BitScopeError( + "SCENARIO_CLTV_ADVANCE_OUT_OF_BOUNDS", + "The bounded CLTV maturity advance was outside the reviewed range.", + 409, + { + "observed_height": height_before_advance, + "observed_lock_height": lock_height, + "observed_blocks": blocks_to_maturity, + }, + ) + maturity_advance_hashes = self._mine_blocks(blocks_to_maturity, mining_address) + mature_height = self._require_height(self.rpc.call("getblockcount"), "getblockcount") + if mature_height != lock_height: + raise BitScopeError( + "SCENARIO_CLTV_MATURITY_HEIGHT_MISMATCH", + "The regtest tip did not reach the exact CLTV target height.", + 409, + {"observed_height": mature_height, "observed_lock_height": lock_height}, + ) + + current_step = "construct_low_locktime_spend" + low_locktime_spend = self.timelock_service.create_cltv_spend( + funding, + policy_address, + witness_script, + destination_address, + lock_height - 1, + 0xFFFFFFFE, + 10_000, + ) + low_locktime_hex = self._require_string( + low_locktime_spend.get("signed_hex"), + "decoderawtransaction", + ) + + current_step = "reject_low_locktime_spend" + low_locktime_acceptance = self._single_acceptance( + self.rpc.call("testmempoolaccept", [[low_locktime_hex]]) + ) + locktime_attack = self.attacks.require_expected( + self.attacks.verify( + locktime_decision, + MempoolAttackObservation( + allowed=low_locktime_acceptance["allowed"], + reject_reason=self._safe_reject_reason(low_locktime_acceptance), + raw_safe_details=low_locktime_acceptance, + ), + ), + mismatch_code="SCENARIO_CLTV_LOW_LOCKTIME_REJECTION_MISMATCH", + safe_message="Bitcoin Core did not reject the low-nLockTime CLTV variant.", + ) + + current_step = "accept_mature_spend" + mature_acceptance = self._single_acceptance( + self.rpc.call("testmempoolaccept", [[valid_hex]]) + ) + if mature_acceptance.get("allowed") is not True: + raise BitScopeError( + "SCENARIO_CLTV_MATURE_PREFLIGHT_REJECTED", + "Bitcoin Core rejected the unchanged CLTV spend at its mature height.", + 409, + {"observed_reject_reason": self._safe_reject_reason(mature_acceptance)}, + ) + + current_step = "broadcast_mature_spend" + spend_txid = self._require_txid( + self._mutate("sendrawtransaction", [valid_hex]), + "sendrawtransaction", + ) + decoded_valid = self._require_dict(valid_spend.get("decoded"), "decoderawtransaction") + if decoded_valid.get("txid") != spend_txid: + raise self._invalid_response( + "sendrawtransaction", + "The broadcast CLTV txid did not match the signed transaction.", + ) + + current_step = "inspect_spend_mempool" + spend_mempool = self._require_dict( + self.rpc.call("getmempoolentry", [spend_txid]), + "getmempoolentry", + ) + + current_step = "confirm_mature_spend" + spend_confirmation_hashes = self._mine_blocks(1, mining_address) + + current_step = "decode_confirmed_spend" + confirmed_wallet_transaction = self._require_dict( + self.rpc.call("gettransaction", [spend_txid], wallet_name=funding_wallet), + "gettransaction", + ) + confirmations = confirmed_wallet_transaction.get("confirmations") + if not isinstance(confirmations, int) or isinstance(confirmations, bool) or confirmations < 1: + raise self._invalid_response("gettransaction", "The CLTV spend is not confirmed.") + confirmed_hex = self._require_string( + confirmed_wallet_transaction.get("hex"), + "gettransaction", + ) + decoded_confirmed = self._require_dict( + self.rpc.call("decoderawtransaction", [confirmed_hex]), + "decoderawtransaction", + ) + if decoded_confirmed.get("txid") != spend_txid: + raise self._invalid_response( + "decoderawtransaction", + "The confirmed CLTV txid did not match.", + ) + if decoded_confirmed.get("locktime") != lock_height: + raise self._invalid_response( + "decoderawtransaction", + "The confirmed CLTV transaction did not retain the policy lock height.", + ) + + self._record_session_outputs( + session, + [mining_address, policy_address, destination_address], + [funding_txid, spend_txid], + [ + *maturity_hashes, + *funding_confirmation_hashes, + *maturity_advance_hashes, + *spend_confirmation_hashes, + ], + lock_height, + ) + except BitScopeError as exc: + raise ScenarioExecutionError(current_step, exc) from exc + + evidence_records = self._evidence_records( + run=run, + captured_at=captured_at, + funding_wallet=funding_wallet, + signer=signer, + mining_address=mining_address, + maturity_hashes=maturity_hashes, + policy_tip=policy_tip, + lock_height=lock_height, + policy=policy, + funding=funding, + funding_confirmation_hashes=funding_confirmation_hashes, + destination_address=destination_address, + valid_spend=valid_spend, + valid_hex=valid_hex, + premature_height=premature_height, + premature_acceptance=premature_acceptance, + final_sequence_spend=final_sequence_spend, + final_sequence_acceptance=final_sequence_acceptance, + height_before_advance=height_before_advance, + maturity_advance_hashes=maturity_advance_hashes, + mature_height=mature_height, + low_locktime_spend=low_locktime_spend, + low_locktime_acceptance=low_locktime_acceptance, + mature_acceptance=mature_acceptance, + spend_txid=spend_txid, + spend_mempool=spend_mempool, + spend_confirmation_hashes=spend_confirmation_hashes, + confirmed_wallet_transaction=confirmed_wallet_transaction, + decoded_confirmed=decoded_confirmed, + ) + return ScenarioExecution( + evidence_records=evidence_records, + step_results=self._step_results(captured_at), + assertion_results=self._assertion_results(), + attack_results=[premature_attack, sequence_attack, locktime_attack], + ) + + def cleanup(self, run: ScenarioRun) -> list[str]: + self.timelock_service.clear_ephemeral_cltv_keys() + _, unloaded = LabSessionService(self.rpc.transport, self.lab_store).cleanup(run.lab_session_id) + return unloaded + + def failure_evidence( + self, + run: ScenarioRun, + step_id: str, + error: BitScopeError, + captured_at: datetime, + ) -> EvidenceRecord: + observed_facts = [ + {"name": f"failure.{key}", "value": value} + for key, value in error.details.items() + if key.startswith("observed_") and isinstance(value, bool | int | float | str) + ] + return EvidenceRecord( + evidence_id=f"failure.{step_id}", + kind="rpc_result", + label=f"Unexpected failure at {step_id}", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output={ + "rpc_method": error.details.get("rpc_method"), + "safe_parameters": [], + "result": None, + "error": {"code": error.code, "message": error.message}, + }, + bitscope_interpretation={ + "summary": "The CLTV timelock scenario stopped on an unexpected failure.", + "facts": [{"name": "failure.category", "value": error.code}, *observed_facts], + "limitations": ["Only redacted, bounded error details are retained."], + }, + ) + + def _active_session(self, run: ScenarioRun) -> LabSession: + session = self.lab_store.get(run.lab_session_id) + if session is None: + raise BitScopeError("LAB_SESSION_NOT_FOUND", "The scenario's lab session does not exist.", 404) + if session.status != "active": + raise BitScopeError( + "LAB_SESSION_NOT_ACTIVE", + "The CLTV scenario requires an active lab session.", + 409, + {"lab_session_id": run.lab_session_id, "status": session.status}, + ) + if session.wallet_name not in session.owned_wallets: + raise BitScopeError( + "LAB_WALLET_OWNERSHIP_VIOLATION", + "The funding wallet is not recorded as owned by this session.", + 409, + ) + loaded = self._require_list(self.rpc.call("listwallets"), "listwallets") + if session.wallet_name not in loaded: + raise BitScopeError( + "SCENARIO_WALLET_NOT_LOADED", + "The session funding wallet must be loaded before this scenario can run.", + 409, + {"wallet_name": session.wallet_name}, + ) + return session + + def _mutate(self, method: str, params: object, wallet_name: str | None = None) -> object: + NetworkSafetyGuard(self.rpc).require_regtest() + return self.rpc.call(method, params, wallet_name=wallet_name) + + def _mine_blocks(self, blocks: int, address: str) -> list[str]: + hashes: list[str] = [] + remaining = blocks + while remaining: + batch = min(remaining, 20) + mined = self._require_list( + self._mutate("generatetoaddress", [batch, address]), + "generatetoaddress", + ) + if len(mined) != batch or any(not isinstance(item, str) or not item for item in mined): + raise self._invalid_response( + "generatetoaddress", + "Bitcoin Core returned invalid block hashes.", + ) + hashes.extend(str(item) for item in mined) + remaining -= batch + return hashes + + def _record_session_outputs( + self, + session: LabSession, + addresses: list[str], + txids: list[str], + block_hashes: list[str], + lock_height: int, + ) -> None: + session.created_addresses.extend(addresses) + session.transaction_ids.extend(txids) + session.block_hashes.extend(block_hashes) + session.actions.append( + LabAction( + sequence=len(session.actions) + 1, + kind="cltv_timelock_completed", + occurred_at=datetime.now(UTC), + details={ + "funding_txid": txids[0], + "spend_txid": txids[1], + "lock_height": lock_height, + }, + ) + ) + session.updated_at = datetime.now(UTC) + self.lab_store.save(session) + + def _evidence_records(self, **values: object) -> list[EvidenceRecord]: + run = values["run"] + captured_at = values["captured_at"] + funding_wallet = str(values["funding_wallet"]) + mining_address = str(values["mining_address"]) + destination_address = str(values["destination_address"]) + lock_height = int(values["lock_height"]) + policy = self._require_dict(values["policy"], "decodescript") + policy_address = self._require_string(policy.get("policy_address"), "decodescript") + valid_hex = str(values["valid_hex"]) + spend_txid = str(values["spend_txid"]) + + limitations = [ + "The policy uses an ephemeral local software key on regtest; it does not model production key custody.", + "This scenario proves an absolute block-height CLTV branch, not median-time-past CLTV or relative CSV.", + "Cleanup drops the signer reference, but Python does not guarantee immediate zeroization of released memory.", + ] + + def record( + evidence_id: str, + kind: str, + label: str, + step_id: str, + rpc_method: str, + result: object, + summary: str, + commands: list[dict[str, object]], + run_paths: list[str], + ) -> EvidenceRecord: + return EvidenceRecord( + evidence_id=evidence_id, + kind=kind, + label=label, + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output={ + "rpc_method": rpc_method, + "safe_parameters": [], + "result": result, + "run_specific_paths": run_paths, + }, + bitscope_interpretation={ + "summary": summary, + "facts": [], + "limitations": limitations, + }, + commands=commands, + ) + + return [ + record( + "cltv.setup", + "lifecycle", + "CLTV funding and signer setup", + "mine_mature_funds", + "generatetoaddress", + { + "funding_wallet": funding_wallet, + "signer": values["signer"], + "mining_address": mining_address, + "maturity_block_hashes": values["maturity_hashes"], + "policy_tip": values["policy_tip"], + }, + "The session prepared mature regtest funds and an ephemeral in-memory signer.", + [ + self._command( + ["-regtest", "generatetoaddress", "20", mining_address], + "Mine maturity blocks in bounded batches; repeat five times, then mine one more.", + ) + ], + [ + "$.result.funding_wallet", + "$.result.mining_address", + "$.result.maturity_block_hashes", + "$.result.policy_tip", + ], + ), + record( + "cltv.policy-funding", + "transaction", + "CLTV policy and confirmed funding", + "confirm_cltv_funding", + "sendtoaddress", + { + "policy": policy, + "funding": values["funding"], + "confirmation_block_hashes": values["funding_confirmation_hashes"], + }, + "A P2WSH output commits to the reviewed CLTV height and exact ephemeral public key before funding.", + [ + self._command( + ["-regtest", "decodescript", str(policy.get("witness_script"))], + "Decode the CLTV witness script and derive its native SegWit output.", + ), + self._command( + [ + "-regtest", + f"-rpcwallet={funding_wallet}", + "sendtoaddress", + policy_address, + "0.50000000", + ], + "Fund the fresh policy output.", + ), + ], + ["$.result.policy", "$.result.funding", "$.result.confirmation_block_hashes"], + ), + record( + "cltv.premature", + "assertion", + "Premature valid spend rejection", + "reject_premature_spend", + "testmempoolaccept", + { + "height": values["premature_height"], + "lock_height": lock_height, + "spend": values["valid_spend"], + "acceptance": values["premature_acceptance"], + }, + "Core rejected the correctly signed CLTV transaction as non-final while the tip was below its target.", + [ + self._command( + ["-regtest", "testmempoolaccept", json.dumps([valid_hex], separators=(",", ":"))], + "Test the valid signed spend before maturity.", + ) + ], + ["$.result.height", "$.result.lock_height", "$.result.spend", "$.result.acceptance"], + ), + record( + "cltv.invalid-sequence", + "assertion", + "Final-sequence CLTV rejection", + "reject_final_sequence_spend", + "testmempoolaccept", + { + "spend": values["final_sequence_spend"], + "acceptance": values["final_sequence_acceptance"], + }, + "Core's script interpreter rejected sequence 0xffffffff because CLTV requires a non-final input.", + [], + ["$.result.spend", "$.result.acceptance"], + ), + record( + "cltv.invalid-locktime", + "assertion", + "Low-nLockTime CLTV rejection", + "reject_low_locktime_spend", + "testmempoolaccept", + { + "spend": values["low_locktime_spend"], + "acceptance": values["low_locktime_acceptance"], + }, + "Core's script interpreter rejected nLockTime one block below the committed CLTV height.", + [], + ["$.result.spend", "$.result.acceptance"], + ), + record( + "cltv.mature", + "assertion", + "Mature CLTV spend acceptance", + "accept_mature_spend", + "testmempoolaccept", + { + "height_before_advance": values["height_before_advance"], + "mature_height": values["mature_height"], + "lock_height": lock_height, + "advance_block_hashes": values["maturity_advance_hashes"], + "acceptance": values["mature_acceptance"], + }, + "After the tip reached the exact target, Core accepted the unchanged transaction previously rejected as non-final.", + [ + self._command( + ["-regtest", "generatetoaddress", "", mining_address], + "Advance only to the absolute lock height.", + ), + self._command( + ["-regtest", "testmempoolaccept", json.dumps([valid_hex], separators=(",", ":"))], + "Retest the unchanged valid spend at maturity.", + ), + ], + [ + "$.result.height_before_advance", + "$.result.mature_height", + "$.result.lock_height", + "$.result.advance_block_hashes", + "$.result.acceptance", + ], + ), + record( + "cltv.confirmed", + "transaction", + "Confirmed mature CLTV spend", + "decode_confirmed_spend", + "gettransaction", + { + "txid": spend_txid, + "destination_address": destination_address, + "mempool_entry": values["spend_mempool"], + "confirmation_block_hashes": values["spend_confirmation_hashes"], + "wallet_transaction": values["confirmed_wallet_transaction"], + "decoded": values["decoded_confirmed"], + }, + "The mature CLTV spend entered the mempool and confirmed with its committed lock height intact.", + [ + self._command( + ["-regtest", "sendrawtransaction", valid_hex], + "Broadcast the preflighted mature spend.", + ), + self._command( + ["-regtest", "getmempoolentry", spend_txid], + "Inspect the mature spend in mempool.", + ), + self._command( + ["-regtest", "generatetoaddress", "1", mining_address], + "Mine its confirmation block.", + ), + ], + [ + "$.result.txid", + "$.result.destination_address", + "$.result.mempool_entry", + "$.result.confirmation_block_hashes", + "$.result.wallet_transaction", + "$.result.decoded", + ], + ), + ] + + @staticmethod + def _step_results(timestamp: datetime) -> list[ScenarioStepResult]: + completed_before_failures: list[tuple[str, list[str], list[str]]] = [ + ("verify_chain", ["node.context"], ["node.context"]), + ("prepare_funding_wallet", ["wallet.funder"], ["cltv.setup"]), + ("prepare_cltv_signer", ["signer.ephemeral"], ["cltv.setup"]), + ("generate_mining_address", ["address.mining"], ["cltv.setup"]), + ("mine_mature_funds", ["blocks.maturity"], ["cltv.setup"]), + ("create_cltv_policy", ["cltv.policy", "cltv.lock_height"], ["cltv.policy-funding"]), + ("fund_cltv_policy", ["cltv.funding"], ["cltv.policy-funding"]), + ("confirm_cltv_funding", ["blocks.funding_confirmation"], ["cltv.policy-funding"]), + ("generate_destination", ["address.destination"], ["cltv.policy-funding"]), + ("construct_premature_spend", ["spend.valid"], ["cltv.premature"]), + ] + results = [ + ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=outputs, + evidence_ids=evidence, + ) + for step_id, outputs, evidence in completed_before_failures + ] + + def expected_failure( + step_id: str, + output_ref: str, + evidence_id: str, + category: FailureCategory, + code: str, + message: str, + ) -> ScenarioStepResult: + failure = ScenarioFailure( + failure_id=f"failure.{code}", + step_id=step_id, + category=category, + expected=True, + code=code, + safe_message=message, + evidence_ids=[evidence_id], + ) + return ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.EXPECTED_FAILURE, + started_at=timestamp, + completed_at=timestamp, + output_refs=[output_ref], + evidence_ids=[evidence_id], + failure=failure, + ) + + results.append( + expected_failure( + "reject_premature_spend", + "acceptance.premature", + "cltv.premature", + FailureCategory.MEMPOOL_POLICY, + "non-final", + "Bitcoin Core rejected the correctly signed CLTV spend as non-final before maturity.", + ) + ) + results.append( + ScenarioStepResult( + step_id="construct_final_sequence_spend", + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=["spend.final_sequence"], + evidence_ids=["cltv.invalid-sequence"], + ) + ) + results.append( + expected_failure( + "reject_final_sequence_spend", + "acceptance.final_sequence", + "cltv.invalid-sequence", + FailureCategory.SCRIPT_VERIFICATION, + "cltv-final-sequence", + "Bitcoin Core rejected sequence 0xffffffff because the CLTV locktime requirement was not satisfied.", + ) + ) + for step_id, outputs, evidence in [ + ("advance_to_maturity", ["cltv.mature_height"], ["cltv.mature"]), + ("construct_low_locktime_spend", ["spend.low_locktime"], ["cltv.invalid-locktime"]), + ]: + results.append( + ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=outputs, + evidence_ids=evidence, + ) + ) + results.append( + expected_failure( + "reject_low_locktime_spend", + "acceptance.low_locktime", + "cltv.invalid-locktime", + FailureCategory.SCRIPT_VERIFICATION, + "cltv-low-locktime", + "Bitcoin Core rejected nLockTime below the height committed by the CLTV script.", + ) + ) + for step_id, outputs, evidence in [ + ("accept_mature_spend", ["acceptance.mature"], ["cltv.mature"]), + ("broadcast_mature_spend", ["spend.txid"], ["cltv.confirmed"]), + ("inspect_spend_mempool", ["spend.mempool"], ["cltv.confirmed"]), + ("confirm_mature_spend", ["blocks.spend_confirmation"], ["cltv.confirmed"]), + ("decode_confirmed_spend", ["spend.confirmed"], ["cltv.confirmed"]), + ]: + results.append( + ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=outputs, + evidence_ids=evidence, + ) + ) + return results + + @staticmethod + def _assertion_results() -> list[AssertionResult]: + evidence = { + "premature_rejected": ["cltv.premature"], + "timelock_immature": ["cltv.premature"], + "final_sequence_rejected": ["cltv.invalid-sequence"], + "low_locktime_rejected": ["cltv.invalid-locktime"], + "timelock_mature": ["cltv.mature"], + "mature_spend_accepted": ["cltv.mature"], + "spend_confirmed": ["cltv.confirmed"], + } + explanations = { + "premature_rejected": "Core returned allowed=false and reject-reason=non-final before maturity.", + "timelock_immature": "The observed tip was below the committed absolute lock height.", + "final_sequence_rejected": "Core's script interpreter rejected the final-sequence variant.", + "low_locktime_rejected": "Core's script interpreter rejected nLockTime one below the script requirement.", + "timelock_mature": "The bounded regtest advance reached the exact absolute lock height.", + "mature_spend_accepted": "Core returned allowed=true for the unchanged valid spend at maturity.", + "spend_confirmed": "Core returned confirmations >= 1, a matching txid, and the committed locktime.", + } + expected_failures = { + "premature_rejected", + "final_sequence_rejected", + "low_locktime_rejected", + } + return [ + AssertionResult( + assertion_id=assertion_id, + status=AssertionResultStatus.PASSED, + required=True, + expected_failure=assertion_id in expected_failures, + explanation=explanations[assertion_id], + evidence_ids=evidence[assertion_id], + ) + for assertion_id in explanations + ] + + @staticmethod + def _single_acceptance(value: object) -> dict[str, object]: + results = CltvTimelockScenarioService._require_list(value, "testmempoolaccept") + if ( + len(results) != 1 + or not isinstance(results[0], dict) + or not isinstance(results[0].get("allowed"), bool) + ): + raise CltvTimelockScenarioService._invalid_response( + "testmempoolaccept", + "Bitcoin Core returned an invalid preflight result.", + ) + return results[0] + + @staticmethod + def _safe_reject_reason(acceptance: dict[str, object]) -> str | None: + reason = acceptance.get("reject-reason") + return reason[:240] if isinstance(reason, str) and reason else None + + @staticmethod + def _require_height(value: object, rpc_method: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise CltvTimelockScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid block height.", + ) + return value + + @staticmethod + def _require_txid(value: object, rpc_method: str) -> str: + txid = CltvTimelockScenarioService._require_string(value, rpc_method) + if len(txid) != 64 or any(character not in "0123456789abcdefABCDEF" for character in txid): + raise CltvTimelockScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid txid.", + ) + return txid + + @staticmethod + def _require_string(value: object, rpc_method: str) -> str: + if not isinstance(value, str) or not value: + raise CltvTimelockScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid string response.", + ) + return value + + @staticmethod + def _require_dict(value: object, rpc_method: str) -> dict[str, object]: + if not isinstance(value, dict): + raise CltvTimelockScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid object response.", + ) + return value + + @staticmethod + def _require_list(value: object, rpc_method: str) -> list[object]: + if not isinstance(value, list): + raise CltvTimelockScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid list response.", + ) + return value + + @staticmethod + def _invalid_response(rpc_method: str, message: str) -> BitScopeError: + return BitScopeError( + "BITCOIN_CORE_INVALID_RESPONSE", + message, + 502, + {"rpc_method": rpc_method}, + ) + + @staticmethod + def _command(arguments: list[str], description: str) -> dict[str, object]: + return {"arguments": arguments, "description": description} diff --git a/backend/app/services/community_treasury_scenario.py b/backend/app/services/community_treasury_scenario.py new file mode 100644 index 0000000..469a091 --- /dev/null +++ b/backend/app/services/community_treasury_scenario.py @@ -0,0 +1,865 @@ +from app.models.scenario import ScenarioDefinition + + +COMMUNITY_TREASURY_SCENARIO = ScenarioDefinition.model_validate( + { + "scenario_id": "community-treasury-recovery", + "version": "1.0.0", + "name": "Community Treasury Recovery", + "summary": ( + "Create a public three-path P2WSH Miniscript treasury across isolated signer wallets, prove immediate " + "2-of-3 spending, reject insufficient and premature recovery attempts for their exact reasons, then " + "mature and confirm both the recovery and longer-delay emergency branches." + ), + "difficulty": "advanced", + "related_lbcli_chapters": [6, 7, 10], + "concepts": [ + "P2WSH Miniscript", + "2-of-3 threshold", + "PSBT", + "BIP68 relative timelock", + "Community treasury", + "Recovery policy", + "Independent signer contexts", + ], + "required_capabilities": ["read_only", "wallet_read", "regtest_mutation"], + "estimated_run_steps": 54, + "steps": [ + { + "step_id": "verify_chain", + "type": "verify_runtime_chain", + "phase": "setup", + "title": "Verify the runtime chain", + "description": "Require the configured node and live Bitcoin Core chain to agree on regtest.", + "output_context_ref": "node.context", + }, + { + "step_id": "prepare_funding_wallet", + "type": "prepare_isolated_wallet", + "phase": "setup", + "title": "Prepare the funding wallet", + "description": "Use only the active funding wallet owned by this run's lab session.", + "depends_on": ["verify_chain"], + "wallet_role": "funder", + "output_wallet_ref": "wallet.funder", + }, + { + "step_id": "prepare_participants", + "type": "prepare_treasury_participants", + "phase": "setup", + "title": "Prepare treasury participants", + "description": "Create nine signer wallets and one private-keys-disabled coordinator within the session namespace.", + "depends_on": ["prepare_funding_wallet"], + "signers_per_group": 3, + "required_signatures": 2, + "output_participants_ref": "participants.treasury", + "output_coordinator_wallet_ref": "wallet.coordinator", + }, + { + "step_id": "generate_mining_address", + "type": "generate_address", + "phase": "setup", + "title": "Generate a mining address", + "description": "Generate a fresh funding-wallet address for bounded maturity and confirmation mining.", + "depends_on": ["prepare_participants"], + "wallet_ref": "wallet.funder", + "label": "bitscope-treasury-mining", + "address_type": "bech32", + "output_address_ref": "address.mining", + }, + { + "step_id": "mine_mature_funds", + "type": "mine_blocks", + "phase": "setup", + "title": "Mine mature funding", + "description": "Mine 101 blocks in bounded batches so the session wallet can fund all policy branches.", + "depends_on": ["generate_mining_address"], + "address_ref": "address.mining", + "blocks": 101, + "output_blocks_ref": "blocks.maturity", + }, + { + "step_id": "materialize_policy", + "type": "materialize_treasury_policy", + "phase": "setup", + "title": "Materialize the treasury policy", + "description": "Normalize, derive, and import the public three-path descriptor into the non-signing coordinator.", + "depends_on": ["mine_mature_funds"], + "participants_ref": "participants.treasury", + "coordinator_wallet_ref": "wallet.coordinator", + "recovery_delay_blocks": 5, + "emergency_delay_blocks": 10, + "output_policy_ref": "treasury.policy", + "output_address_ref": "treasury.address", + "output_decision_tree_ref": "treasury.decision_tree", + }, + { + "step_id": "generate_destination", + "type": "generate_address", + "phase": "setup", + "title": "Generate the spend destination", + "description": "Generate one fresh funding-wallet destination for the three independent branch proofs.", + "depends_on": ["materialize_policy"], + "wallet_ref": "wallet.funder", + "label": "bitscope-treasury-destination", + "address_type": "bech32", + "output_address_ref": "address.destination", + }, + { + "step_id": "fund_immediate", + "type": "fund_treasury_policy", + "phase": "execution", + "title": "Fund the immediate branch proof", + "description": "Create a fresh 1 BTC policy output with an explicit 2 sat/vB funding fee rate.", + "depends_on": ["generate_destination"], + "wallet_ref": "wallet.funder", + "policy_ref": "treasury.policy", + "branch": "immediate", + "amount_btc": "1.00000000", + "fee_rate_sat_vb": "2.000", + "output_funding_ref": "funding.immediate", + }, + { + "step_id": "confirm_immediate_funding", + "type": "mine_confirmation_blocks", + "phase": "execution", + "title": "Confirm immediate funding", + "description": "Mine one block confirming the exact immediate-path policy outpoint.", + "depends_on": ["fund_immediate"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.immediate_funding", + }, + { + "step_id": "create_immediate_psbt", + "type": "create_treasury_spend_psbt", + "phase": "execution", + "title": "Create the immediate spend PSBT", + "description": "Construct and enrich a version-2 PSBT spending the confirmed policy output.", + "depends_on": ["confirm_immediate_funding"], + "coordinator_wallet_ref": "wallet.coordinator", + "funding_ref": "funding.immediate", + "recipient_address_ref": "address.destination", + "branch": "immediate", + "sequence": 4294967294, + "fee_sats": 10000, + "output_psbt_ref": "psbt.immediate.unsigned", + }, + { + "step_id": "sign_immediate_one", + "type": "sign_treasury_psbt", + "phase": "execution", + "title": "Add one operator signature", + "description": "Process the PSBT with only the first treasury operator.", + "depends_on": ["create_immediate_psbt"], + "participants_ref": "participants.treasury", + "psbt_ref": "psbt.immediate.unsigned", + "signer_role": "operator", + "signer_positions": [1], + "output_psbt_ref": "psbt.immediate.partial", + "output_signature_count_ref": "signatures.immediate.partial", + }, + { + "step_id": "finalize_immediate_incomplete", + "type": "finalize_psbt", + "phase": "attack", + "title": "Prove one operator is insufficient", + "description": "Require one operator signature to remain incomplete and unextractable.", + "depends_on": ["sign_immediate_one"], + "psbt_ref": "psbt.immediate.partial", + "extract": False, + "output_psbt_ref": "psbt.immediate.incomplete", + }, + { + "step_id": "sign_immediate_two", + "type": "sign_treasury_psbt", + "phase": "attack", + "title": "Reach the operator threshold", + "description": "Add the second independent operator signature without finalizing early.", + "depends_on": ["finalize_immediate_incomplete"], + "participants_ref": "participants.treasury", + "psbt_ref": "psbt.immediate.partial", + "signer_role": "operator", + "signer_positions": [2], + "output_psbt_ref": "psbt.immediate.threshold", + "output_signature_count_ref": "signatures.immediate.threshold", + }, + { + "step_id": "finalize_immediate", + "type": "finalize_psbt", + "phase": "attack", + "title": "Finalize the immediate spend", + "description": "Extract the threshold-complete immediate transaction.", + "depends_on": ["sign_immediate_two"], + "psbt_ref": "psbt.immediate.threshold", + "extract": True, + "output_transaction_ref": "transaction.immediate", + }, + { + "step_id": "preflight_immediate", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Preflight the immediate spend", + "description": "Require Core to accept the finalized immediate-path transaction.", + "depends_on": ["finalize_immediate"], + "transaction_ref": "transaction.immediate", + "output_acceptance_ref": "acceptance.immediate", + }, + { + "step_id": "broadcast_immediate", + "type": "broadcast_transaction", + "phase": "attack", + "title": "Broadcast the immediate spend", + "description": "Broadcast only the preflighted immediate transaction.", + "depends_on": ["preflight_immediate"], + "transaction_ref": "transaction.immediate", + "output_txid_ref": "txid.immediate", + }, + { + "step_id": "inspect_immediate_mempool", + "type": "query_mempool_entry", + "phase": "attack", + "title": "Inspect the immediate spend", + "description": "Record the immediate transaction's live mempool entry.", + "depends_on": ["broadcast_immediate"], + "txid_ref": "txid.immediate", + "output_mempool_ref": "mempool.immediate", + }, + { + "step_id": "confirm_immediate", + "type": "mine_confirmation_blocks", + "phase": "attack", + "title": "Confirm the immediate spend", + "description": "Mine one block containing the immediate spend.", + "depends_on": ["inspect_immediate_mempool"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.immediate_confirmation", + }, + { + "step_id": "decode_immediate", + "type": "decode_transaction", + "phase": "attack", + "title": "Decode the confirmed immediate spend", + "description": "Verify the confirmed transaction has the broadcast txid.", + "depends_on": ["confirm_immediate"], + "transaction_ref": "transaction.immediate", + "output_decoded_ref": "transaction.immediate.confirmed", + }, + { + "step_id": "fund_recovery", + "type": "fund_treasury_policy", + "phase": "attack", + "title": "Fund the recovery proof", + "description": "Create a second independently confirmed policy output for relative recovery.", + "depends_on": ["decode_immediate"], + "wallet_ref": "wallet.funder", + "policy_ref": "treasury.policy", + "branch": "recovery", + "amount_btc": "1.00000000", + "fee_rate_sat_vb": "2.000", + "output_funding_ref": "funding.recovery", + }, + { + "step_id": "confirm_recovery_funding", + "type": "mine_confirmation_blocks", + "phase": "attack", + "title": "Confirm recovery funding", + "description": "Mine one block establishing the recovery output's BIP68 age anchor.", + "depends_on": ["fund_recovery"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.recovery_funding", + }, + { + "step_id": "create_recovery_psbt", + "type": "create_treasury_spend_psbt", + "phase": "attack", + "title": "Create the recovery PSBT", + "description": "Set input sequence to the configured five-block recovery delay.", + "depends_on": ["confirm_recovery_funding"], + "coordinator_wallet_ref": "wallet.coordinator", + "funding_ref": "funding.recovery", + "recipient_address_ref": "address.destination", + "branch": "recovery", + "sequence": 5, + "fee_sats": 10000, + "output_psbt_ref": "psbt.recovery.unsigned", + }, + { + "step_id": "sign_recovery_one", + "type": "sign_treasury_psbt", + "phase": "attack", + "title": "Add one recovery signature", + "description": "Process the recovery PSBT with only the first recovery signer.", + "depends_on": ["create_recovery_psbt"], + "participants_ref": "participants.treasury", + "psbt_ref": "psbt.recovery.unsigned", + "signer_role": "recovery", + "signer_positions": [1], + "output_psbt_ref": "psbt.recovery.partial", + "output_signature_count_ref": "signatures.recovery.partial", + }, + { + "step_id": "finalize_recovery_incomplete", + "type": "finalize_psbt", + "phase": "attack", + "title": "Prove one recovery signer is insufficient", + "description": "Require the one-signature recovery PSBT to remain incomplete.", + "depends_on": ["sign_recovery_one"], + "psbt_ref": "psbt.recovery.partial", + "extract": False, + "output_psbt_ref": "psbt.recovery.incomplete", + }, + { + "step_id": "sign_recovery_two", + "type": "sign_treasury_psbt", + "phase": "attack", + "title": "Reach the recovery threshold", + "description": "Add the second recovery signature without finalizing early.", + "depends_on": ["finalize_recovery_incomplete"], + "participants_ref": "participants.treasury", + "psbt_ref": "psbt.recovery.partial", + "signer_role": "recovery", + "signer_positions": [2], + "output_psbt_ref": "psbt.recovery.threshold", + "output_signature_count_ref": "signatures.recovery.threshold", + }, + { + "step_id": "finalize_recovery", + "type": "finalize_psbt", + "phase": "attack", + "title": "Finalize the recovery spend", + "description": "Extract the threshold-complete recovery transaction before it is mature.", + "depends_on": ["sign_recovery_two"], + "psbt_ref": "psbt.recovery.threshold", + "extract": True, + "output_transaction_ref": "transaction.recovery", + }, + { + "step_id": "reject_premature_recovery", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Reject premature recovery", + "description": "Require the exact non-BIP68-final rejection before five relative blocks.", + "depends_on": ["finalize_recovery"], + "transaction_ref": "transaction.recovery", + "output_acceptance_ref": "acceptance.recovery.premature", + }, + { + "step_id": "create_wrong_sequence_psbt", + "type": "create_treasury_spend_psbt", + "phase": "attack", + "title": "Create an incorrect-sequence recovery PSBT", + "description": "Set sequence to four, which cannot satisfy the five-block Miniscript branch.", + "depends_on": ["reject_premature_recovery"], + "coordinator_wallet_ref": "wallet.coordinator", + "funding_ref": "funding.recovery", + "recipient_address_ref": "address.destination", + "branch": "recovery", + "sequence": 4, + "fee_sats": 10000, + "output_psbt_ref": "psbt.recovery.wrong_sequence", + }, + { + "step_id": "sign_wrong_sequence_psbt", + "type": "sign_treasury_psbt", + "phase": "attack", + "title": "Sign the incorrect-sequence PSBT", + "description": "Collect both recovery signatures so sequence remains the only unsatisfied policy condition.", + "depends_on": ["create_wrong_sequence_psbt"], + "participants_ref": "participants.treasury", + "psbt_ref": "psbt.recovery.wrong_sequence", + "signer_role": "recovery", + "signer_positions": [1, 2], + "output_psbt_ref": "psbt.recovery.wrong_sequence.signed", + "output_signature_count_ref": "signatures.recovery.wrong_sequence", + }, + { + "step_id": "finalize_wrong_sequence_incomplete", + "type": "finalize_psbt", + "phase": "attack", + "title": "Prove incorrect sequence is incomplete", + "description": "Require Core's Miniscript finalizer to return complete=false and no transaction hex.", + "depends_on": ["sign_wrong_sequence_psbt"], + "psbt_ref": "psbt.recovery.wrong_sequence.signed", + "extract": False, + "output_psbt_ref": "psbt.recovery.wrong_sequence.incomplete", + }, + { + "step_id": "advance_recovery_delay", + "type": "advance_relative_timelock", + "phase": "attack", + "title": "Advance the recovery delay", + "description": "Mine exactly five blocks to mature the unchanged recovery transaction.", + "depends_on": ["finalize_wrong_sequence_incomplete"], + "address_ref": "address.mining", + "blocks": 5, + "output_height_ref": "height.recovery.mature", + }, + { + "step_id": "preflight_mature_recovery", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Preflight mature recovery", + "description": "Require Core to accept the unchanged recovery transaction after maturity.", + "depends_on": ["advance_recovery_delay"], + "transaction_ref": "transaction.recovery", + "output_acceptance_ref": "acceptance.recovery.mature", + }, + { + "step_id": "broadcast_recovery", + "type": "broadcast_transaction", + "phase": "attack", + "title": "Broadcast mature recovery", + "description": "Broadcast the preflighted unchanged recovery transaction.", + "depends_on": ["preflight_mature_recovery"], + "transaction_ref": "transaction.recovery", + "output_txid_ref": "txid.recovery", + }, + { + "step_id": "inspect_recovery_mempool", + "type": "query_mempool_entry", + "phase": "attack", + "title": "Inspect the recovery spend", + "description": "Record the recovery transaction's mempool entry.", + "depends_on": ["broadcast_recovery"], + "txid_ref": "txid.recovery", + "output_mempool_ref": "mempool.recovery", + }, + { + "step_id": "confirm_recovery", + "type": "mine_confirmation_blocks", + "phase": "attack", + "title": "Confirm recovery", + "description": "Mine one block containing the mature recovery transaction.", + "depends_on": ["inspect_recovery_mempool"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.recovery_confirmation", + }, + { + "step_id": "decode_recovery", + "type": "decode_transaction", + "phase": "attack", + "title": "Decode confirmed recovery", + "description": "Verify the confirmed recovery transaction has the broadcast txid.", + "depends_on": ["confirm_recovery"], + "transaction_ref": "transaction.recovery", + "output_decoded_ref": "transaction.recovery.confirmed", + }, + { + "step_id": "fund_emergency", + "type": "fund_treasury_policy", + "phase": "attack", + "title": "Fund the emergency proof", + "description": "Create a third independently confirmed output for the longer-delay emergency branch.", + "depends_on": ["decode_recovery"], + "wallet_ref": "wallet.funder", + "policy_ref": "treasury.policy", + "branch": "emergency", + "amount_btc": "1.00000000", + "fee_rate_sat_vb": "2.000", + "output_funding_ref": "funding.emergency", + }, + { + "step_id": "confirm_emergency_funding", + "type": "mine_confirmation_blocks", + "phase": "attack", + "title": "Confirm emergency funding", + "description": "Mine one block establishing the emergency output's relative age anchor.", + "depends_on": ["fund_emergency"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.emergency_funding", + }, + { + "step_id": "create_emergency_psbt", + "type": "create_treasury_spend_psbt", + "phase": "attack", + "title": "Create the emergency PSBT", + "description": "Set input sequence to the configured ten-block emergency delay.", + "depends_on": ["confirm_emergency_funding"], + "coordinator_wallet_ref": "wallet.coordinator", + "funding_ref": "funding.emergency", + "recipient_address_ref": "address.destination", + "branch": "emergency", + "sequence": 10, + "fee_sats": 10000, + "output_psbt_ref": "psbt.emergency.unsigned", + }, + { + "step_id": "sign_emergency_one", + "type": "sign_treasury_psbt", + "phase": "attack", + "title": "Add one emergency signature", + "description": "Process the emergency PSBT with only the first emergency signer.", + "depends_on": ["create_emergency_psbt"], + "participants_ref": "participants.treasury", + "psbt_ref": "psbt.emergency.unsigned", + "signer_role": "emergency", + "signer_positions": [1], + "output_psbt_ref": "psbt.emergency.partial", + "output_signature_count_ref": "signatures.emergency.partial", + }, + { + "step_id": "finalize_emergency_incomplete", + "type": "finalize_psbt", + "phase": "attack", + "title": "Prove one emergency signer is insufficient", + "description": "Require the one-signature emergency PSBT to remain incomplete.", + "depends_on": ["sign_emergency_one"], + "psbt_ref": "psbt.emergency.partial", + "extract": False, + "output_psbt_ref": "psbt.emergency.incomplete", + }, + { + "step_id": "sign_emergency_two", + "type": "sign_treasury_psbt", + "phase": "attack", + "title": "Reach the emergency threshold", + "description": "Add the second emergency signature without finalizing early.", + "depends_on": ["finalize_emergency_incomplete"], + "participants_ref": "participants.treasury", + "psbt_ref": "psbt.emergency.partial", + "signer_role": "emergency", + "signer_positions": [2], + "output_psbt_ref": "psbt.emergency.threshold", + "output_signature_count_ref": "signatures.emergency.threshold", + }, + { + "step_id": "finalize_emergency", + "type": "finalize_psbt", + "phase": "attack", + "title": "Finalize the emergency spend", + "description": "Extract the threshold-complete emergency transaction before maturity.", + "depends_on": ["sign_emergency_two"], + "psbt_ref": "psbt.emergency.threshold", + "extract": True, + "output_transaction_ref": "transaction.emergency", + }, + { + "step_id": "reject_premature_emergency", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Reject premature emergency recovery", + "description": "Require the exact non-BIP68-final rejection before ten relative blocks.", + "depends_on": ["finalize_emergency"], + "transaction_ref": "transaction.emergency", + "output_acceptance_ref": "acceptance.emergency.premature", + }, + { + "step_id": "advance_emergency_delay", + "type": "advance_relative_timelock", + "phase": "attack", + "title": "Advance the emergency delay", + "description": "Mine exactly ten blocks to mature the unchanged emergency transaction.", + "depends_on": ["reject_premature_emergency"], + "address_ref": "address.mining", + "blocks": 10, + "output_height_ref": "height.emergency.mature", + }, + { + "step_id": "preflight_mature_emergency", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Preflight mature emergency recovery", + "description": "Require Core to accept the unchanged emergency transaction after maturity.", + "depends_on": ["advance_emergency_delay"], + "transaction_ref": "transaction.emergency", + "output_acceptance_ref": "acceptance.emergency.mature", + }, + { + "step_id": "broadcast_emergency", + "type": "broadcast_transaction", + "phase": "attack", + "title": "Broadcast mature emergency recovery", + "description": "Broadcast the preflighted unchanged emergency transaction.", + "depends_on": ["preflight_mature_emergency"], + "transaction_ref": "transaction.emergency", + "output_txid_ref": "txid.emergency", + }, + { + "step_id": "inspect_emergency_mempool", + "type": "query_mempool_entry", + "phase": "attack", + "title": "Inspect the emergency spend", + "description": "Record the emergency transaction's mempool entry.", + "depends_on": ["broadcast_emergency"], + "txid_ref": "txid.emergency", + "output_mempool_ref": "mempool.emergency", + }, + { + "step_id": "confirm_emergency", + "type": "mine_confirmation_blocks", + "phase": "attack", + "title": "Confirm emergency recovery", + "description": "Mine one block containing the mature emergency transaction.", + "depends_on": ["inspect_emergency_mempool"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.emergency_confirmation", + }, + { + "step_id": "decode_emergency", + "type": "decode_transaction", + "phase": "attack", + "title": "Decode confirmed emergency recovery", + "description": "Verify the confirmed emergency transaction has the broadcast txid.", + "depends_on": ["confirm_emergency"], + "transaction_ref": "transaction.emergency", + "output_decoded_ref": "transaction.emergency.confirmed", + }, + { + "step_id": "verify_results", + "type": "evaluate_assertions", + "phase": "verification", + "title": "Evaluate treasury assertions", + "description": "Evaluate threshold, exact rejection, maturity, acceptance, and confirmation evidence for all paths.", + "depends_on": ["decode_emergency"], + "assertion_ids": [ + "immediate_insufficient", + "immediate_psbt_incomplete", + "immediate_threshold_not_met", + "immediate_threshold_met", + "immediate_accepted", + "immediate_confirmed", + "recovery_insufficient", + "recovery_psbt_incomplete", + "recovery_threshold_not_met", + "recovery_threshold_met", + "premature_recovery_rejected", + "recovery_timelock_immature", + "wrong_sequence_incomplete", + "recovery_timelock_mature", + "recovery_accepted", + "recovery_confirmed", + "emergency_insufficient", + "emergency_psbt_incomplete", + "emergency_threshold_not_met", + "emergency_threshold_met", + "premature_emergency_rejected", + "emergency_timelock_immature", + "emergency_timelock_mature", + "emergency_accepted", + "emergency_confirmed", + ], + }, + { + "step_id": "export_proof", + "type": "export_evidence", + "phase": "export", + "title": "Export the treasury proof", + "description": ( + "Expose public policy, branch transactions, exact negative outcomes, assertions, " + "and deterministic artifacts." + ), + "depends_on": ["verify_results"], + "output_bundle_ref": "proof.bundle", + }, + { + "step_id": "cleanup", + "type": "cleanup_lab", + "phase": "cleanup", + "title": "Clean up all treasury wallets", + "description": "Unload only the funding, coordinator, and participant wallets owned by this lab session.", + "depends_on": ["export_proof"], + }, + ], + "assertions": [ + { + "assertion_id": "immediate_insufficient", + "kind": "rpc_failed_with_category", + "after_step_id": "finalize_immediate_incomplete", + "subject_ref": "psbt.immediate.incomplete", + "expected_category": "psbt_incomplete", + "description": "One operator signature cannot finalize or extract the immediate spend.", + }, + { + "assertion_id": "immediate_psbt_incomplete", + "kind": "psbt_incomplete", + "after_step_id": "finalize_immediate_incomplete", + "subject_ref": "psbt.immediate.incomplete", + "description": "Core reports the one-operator PSBT as incomplete.", + }, + { + "assertion_id": "immediate_threshold_not_met", + "kind": "signature_threshold_not_met", + "after_step_id": "finalize_immediate_incomplete", + "subject_ref": "psbt.immediate.partial", + "required_signatures": 2, + "signature_count_ref": "signatures.immediate.partial", + "description": "The immediate PSBT contains one signature, below threshold.", + }, + { + "assertion_id": "immediate_threshold_met", + "kind": "signature_threshold_met", + "after_step_id": "sign_immediate_two", + "subject_ref": "psbt.immediate.threshold", + "required_signatures": 2, + "signature_count_ref": "signatures.immediate.threshold", + "description": "Two operators satisfy the immediate threshold.", + }, + { + "assertion_id": "immediate_accepted", + "kind": "mempool_policy_accepted", + "after_step_id": "preflight_immediate", + "subject_ref": "acceptance.immediate", + "description": "Core accepts the immediate spend before broadcast.", + }, + { + "assertion_id": "immediate_confirmed", + "kind": "transaction_confirmed", + "after_step_id": "decode_immediate", + "subject_ref": "transaction.immediate.confirmed", + "description": "The immediate spend confirms with a matching txid.", + }, + { + "assertion_id": "recovery_insufficient", + "kind": "rpc_failed_with_category", + "after_step_id": "finalize_recovery_incomplete", + "subject_ref": "psbt.recovery.incomplete", + "expected_category": "psbt_incomplete", + "description": "One recovery signature cannot finalize the spend.", + }, + { + "assertion_id": "recovery_psbt_incomplete", + "kind": "psbt_incomplete", + "after_step_id": "finalize_recovery_incomplete", + "subject_ref": "psbt.recovery.incomplete", + "description": "Core reports the one-recovery-signature PSBT as incomplete.", + }, + { + "assertion_id": "recovery_threshold_not_met", + "kind": "signature_threshold_not_met", + "after_step_id": "finalize_recovery_incomplete", + "subject_ref": "psbt.recovery.partial", + "required_signatures": 2, + "signature_count_ref": "signatures.recovery.partial", + "description": "The recovery PSBT contains one signature, below threshold.", + }, + { + "assertion_id": "recovery_threshold_met", + "kind": "signature_threshold_met", + "after_step_id": "sign_recovery_two", + "subject_ref": "psbt.recovery.threshold", + "required_signatures": 2, + "signature_count_ref": "signatures.recovery.threshold", + "description": "Two recovery participants satisfy the signature threshold.", + }, + { + "assertion_id": "premature_recovery_rejected", + "kind": "rpc_failed_with_category", + "after_step_id": "reject_premature_recovery", + "subject_ref": "acceptance.recovery.premature", + "expected_category": "mempool_policy", + "description": "Core rejects the fully signed recovery transaction as non-BIP68-final.", + }, + { + "assertion_id": "recovery_timelock_immature", + "kind": "timelock_immature", + "after_step_id": "reject_premature_recovery", + "subject_ref": "acceptance.recovery.premature", + "description": "The recovery output has not reached five relative blocks.", + }, + { + "assertion_id": "wrong_sequence_incomplete", + "kind": "rpc_failed_with_category", + "after_step_id": "finalize_wrong_sequence_incomplete", + "subject_ref": "psbt.recovery.wrong_sequence.incomplete", + "expected_category": "psbt_incomplete", + "description": "Core refuses to finalize sequence four for older(5), even with two signatures.", + }, + { + "assertion_id": "recovery_timelock_mature", + "kind": "timelock_mature", + "after_step_id": "advance_recovery_delay", + "subject_ref": "height.recovery.mature", + "description": "The recovery output reaches the configured relative age.", + }, + { + "assertion_id": "recovery_accepted", + "kind": "mempool_policy_accepted", + "after_step_id": "preflight_mature_recovery", + "subject_ref": "acceptance.recovery.mature", + "description": "Core accepts the unchanged mature recovery transaction.", + }, + { + "assertion_id": "recovery_confirmed", + "kind": "transaction_confirmed", + "after_step_id": "decode_recovery", + "subject_ref": "transaction.recovery.confirmed", + "description": "The mature recovery transaction confirms with a matching txid.", + }, + { + "assertion_id": "emergency_insufficient", + "kind": "rpc_failed_with_category", + "after_step_id": "finalize_emergency_incomplete", + "subject_ref": "psbt.emergency.incomplete", + "expected_category": "psbt_incomplete", + "description": "One emergency signature cannot finalize the spend.", + }, + { + "assertion_id": "emergency_psbt_incomplete", + "kind": "psbt_incomplete", + "after_step_id": "finalize_emergency_incomplete", + "subject_ref": "psbt.emergency.incomplete", + "description": "Core reports the one-emergency-signature PSBT as incomplete.", + }, + { + "assertion_id": "emergency_threshold_not_met", + "kind": "signature_threshold_not_met", + "after_step_id": "finalize_emergency_incomplete", + "subject_ref": "psbt.emergency.partial", + "required_signatures": 2, + "signature_count_ref": "signatures.emergency.partial", + "description": "The emergency PSBT contains one signature, below threshold.", + }, + { + "assertion_id": "emergency_threshold_met", + "kind": "signature_threshold_met", + "after_step_id": "sign_emergency_two", + "subject_ref": "psbt.emergency.threshold", + "required_signatures": 2, + "signature_count_ref": "signatures.emergency.threshold", + "description": "Two emergency participants satisfy the signature threshold.", + }, + { + "assertion_id": "premature_emergency_rejected", + "kind": "rpc_failed_with_category", + "after_step_id": "reject_premature_emergency", + "subject_ref": "acceptance.emergency.premature", + "expected_category": "mempool_policy", + "description": "Core rejects the fully signed emergency transaction as non-BIP68-final.", + }, + { + "assertion_id": "emergency_timelock_immature", + "kind": "timelock_immature", + "after_step_id": "reject_premature_emergency", + "subject_ref": "acceptance.emergency.premature", + "description": "The emergency output has not reached ten relative blocks.", + }, + { + "assertion_id": "emergency_timelock_mature", + "kind": "timelock_mature", + "after_step_id": "advance_emergency_delay", + "subject_ref": "height.emergency.mature", + "description": "The emergency output reaches the configured relative age.", + }, + { + "assertion_id": "emergency_accepted", + "kind": "mempool_policy_accepted", + "after_step_id": "preflight_mature_emergency", + "subject_ref": "acceptance.emergency.mature", + "description": "Core accepts the unchanged mature emergency transaction.", + }, + { + "assertion_id": "emergency_confirmed", + "kind": "transaction_confirmed", + "after_step_id": "decode_emergency", + "subject_ref": "transaction.emergency.confirmed", + "description": "The mature emergency transaction confirms with a matching txid.", + }, + ], + } +) diff --git a/backend/app/services/community_treasury_scenario_service.py b/backend/app/services/community_treasury_scenario_service.py new file mode 100644 index 0000000..b7afd74 --- /dev/null +++ b/backend/app/services/community_treasury_scenario_service.py @@ -0,0 +1,1444 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from app.errors import BitScopeError +from app.models.attack import ( + AttackApplicabilityDecision, + AttackContext, + AttackFeature, + AttackVerificationResult, + MempoolAttackObservation, + PsbtAttackObservation, +) +from app.models.evidence import EvidenceRecord +from app.models.lab import LabAction, LabSession +from app.models.scenario import ( + AssertionResult, + AssertionResultStatus, + FailureCategory, + ScenarioDefinition, + ScenarioFailure, + ScenarioRun, + ScenarioStepResult, + ScenarioStepResultStatus, +) +from app.models.treasury import ( + MaterializedTreasuryPolicy, + TreasuryParticipant, + TreasuryParticipantGroup, + TreasuryParticipantRole, + TreasuryPolicy, +) +from app.rpc.capabilities import RegtestMutationRpcClient, RpcTransport +from app.services.lab_session_service import LabSessionService +from app.services.attack_verification_service import AttackVerificationService +from app.services.lab_session_store import LabSessionStore +from app.services.network_safety import NetworkSafetyGuard +from app.services.scenario_execution import ScenarioExecution, ScenarioExecutionError +from app.services.treasury_policy_service import TreasuryPolicyService + + +class CommunityTreasuryScenarioService: + """Execute the reviewed three-path public treasury policy on regtest.""" + + RECOVERY_DELAY = 5 + EMERGENCY_DELAY = 10 + FUNDING_AMOUNT = Decimal("1.00000000") + SPEND_FEE = Decimal("0.00010000") + + def __init__(self, rpc_client: RpcTransport, lab_store: LabSessionStore) -> None: + self.rpc = RegtestMutationRpcClient(rpc_client) + self.policy_service = TreasuryPolicyService(rpc_client) + self.lab_store = lab_store + self.attacks = AttackVerificationService() + + def execute(self, run: ScenarioRun, definition: ScenarioDefinition) -> ScenarioExecution: + captured_at = datetime.now(UTC) + current_step = "prepare_funding_wallet" + try: + session = self._active_session(run) + funding_wallet = session.wallet_name + + current_step = "prepare_participants" + coordinator_wallet, signer_wallets = self._prepare_wallets(session) + participant_groups, signer_addresses = self._participant_groups(signer_wallets) + + current_step = "generate_mining_address" + mining_address = self._require_string( + self._mutate( + "getnewaddress", + ["bitscope-treasury-mining", "bech32"], + funding_wallet, + ), + "getnewaddress", + ) + + current_step = "mine_mature_funds" + maturity_hashes = self._mine_blocks(101, mining_address) + self._require_funding_balance(funding_wallet) + + current_step = "materialize_policy" + policy = TreasuryPolicy( + recovery_delay_blocks=self.RECOVERY_DELAY, + emergency_delay_blocks=self.EMERGENCY_DELAY, + operators=participant_groups[TreasuryParticipantRole.OPERATOR], + recovery=participant_groups[TreasuryParticipantRole.RECOVERY], + emergency=participant_groups[TreasuryParticipantRole.EMERGENCY], + ) + materialized = self.policy_service.materialize(policy) + import_result = self.policy_service.import_into_coordinator( + materialized, + coordinator_wallet, + ) + + attack_context = AttackContext( + scenario_id=run.scenario_id, + available_features=[ + AttackFeature.PSBT, + AttackFeature.THRESHOLD_POLICY, + AttackFeature.MUTABLE_INPUTS, + AttackFeature.RELATIVE_TIMELOCK, + AttackFeature.MEMPOOL_PREFLIGHT, + ], + ) + attack_decisions = { + name: self.attacks.require_applicable( + self.attacks.assess( + f"community-treasury-recovery.{attack_id}", + attack_context, + ) + ) + for name, attack_id in { + "immediate_signature": "immediate-signature-insufficiency", + "immediate_psbt": "immediate-psbt-incompleteness", + "recovery_signature": "recovery-signature-insufficiency", + "recovery_psbt": "recovery-psbt-incompleteness", + "recovery_premature": "recovery-premature-timelock", + "recovery_sequence": "sequence-modification", + "emergency_signature": "emergency-signature-insufficiency", + "emergency_psbt": "emergency-psbt-incompleteness", + "emergency_premature": "emergency-premature-timelock", + }.items() + } + + current_step = "generate_destination" + destination_address = self._require_string( + self._mutate( + "getnewaddress", + ["bitscope-treasury-destination", "bech32"], + funding_wallet, + ), + "getnewaddress", + ) + + current_step = "fund_immediate" + immediate_funding = self._fund_policy( + funding_wallet, + materialized.address, + ) + current_step = "confirm_immediate_funding" + immediate_funding_blocks = self._mine_blocks(1, mining_address) + current_step = "create_immediate_psbt" + immediate_unsigned = self._create_policy_psbt( + coordinator_wallet, + immediate_funding, + destination_address, + sequence=0xFFFFFFFE, + ) + current_step = "sign_immediate_one" + immediate_partial = self._sign_psbt( + immediate_unsigned["psbt"], + [signer_wallets[TreasuryParticipantRole.OPERATOR][0]], + expected_signatures=None, + ) + immediate_signature_attack = self._verify_psbt_attack( + attack_decisions["immediate_signature"], + complete=self._last_wallet_complete(immediate_partial), + transaction_hex_present=False, + signature_count=immediate_partial["signature_count"], + mismatch_code="SCENARIO_TREASURY_SIGNATURE_COUNT_MISMATCH", + safe_message="The immediate branch did not remain below its 2-of-3 threshold.", + ) + current_step = "finalize_immediate_incomplete" + immediate_incomplete = self._finalize_attempt(immediate_partial["psbt"]) + immediate_psbt_attack = self._verify_psbt_attack( + attack_decisions["immediate_psbt"], + complete=self._optional_bool(immediate_incomplete.get("complete")), + transaction_hex_present=immediate_incomplete.get("hex") is not None, + signature_count=immediate_partial["signature_count"], + mismatch_code="SCENARIO_TREASURY_INCOMPLETE_FINALIZATION_MISMATCH", + safe_message="Bitcoin Core did not preserve the incomplete immediate PSBT state.", + ) + current_step = "sign_immediate_two" + immediate_threshold = self._sign_psbt( + immediate_partial["psbt"], + [signer_wallets[TreasuryParticipantRole.OPERATOR][1]], + expected_signatures=2, + ) + current_step = "finalize_immediate" + immediate_final = self._finalize(immediate_threshold["psbt"]) + current_step = "preflight_immediate" + immediate_acceptance = self._require_accepted(immediate_final["hex"]) + current_step = "broadcast_immediate" + immediate_txid = self._require_txid( + self._mutate("sendrawtransaction", [immediate_final["hex"]]), + "sendrawtransaction", + ) + current_step = "inspect_immediate_mempool" + immediate_mempool = self._require_dict( + self.rpc.call("getmempoolentry", [immediate_txid]), + "getmempoolentry", + ) + current_step = "confirm_immediate" + immediate_confirmation_blocks = self._mine_blocks(1, mining_address) + current_step = "decode_immediate" + immediate_confirmed = self._confirmed_transaction(funding_wallet, immediate_txid) + + current_step = "fund_recovery" + recovery_funding = self._fund_policy(funding_wallet, materialized.address) + current_step = "confirm_recovery_funding" + recovery_funding_blocks = self._mine_blocks(1, mining_address) + current_step = "create_recovery_psbt" + recovery_unsigned = self._create_policy_psbt( + coordinator_wallet, + recovery_funding, + destination_address, + sequence=self.RECOVERY_DELAY, + ) + current_step = "sign_recovery_one" + recovery_partial = self._sign_psbt( + recovery_unsigned["psbt"], + [signer_wallets[TreasuryParticipantRole.RECOVERY][0]], + expected_signatures=None, + ) + recovery_signature_attack = self._verify_psbt_attack( + attack_decisions["recovery_signature"], + complete=self._last_wallet_complete(recovery_partial), + transaction_hex_present=False, + signature_count=recovery_partial["signature_count"], + mismatch_code="SCENARIO_TREASURY_SIGNATURE_COUNT_MISMATCH", + safe_message="The recovery branch did not remain below its 2-of-3 threshold.", + ) + current_step = "finalize_recovery_incomplete" + recovery_incomplete = self._finalize_attempt(recovery_partial["psbt"]) + recovery_psbt_attack = self._verify_psbt_attack( + attack_decisions["recovery_psbt"], + complete=self._optional_bool(recovery_incomplete.get("complete")), + transaction_hex_present=recovery_incomplete.get("hex") is not None, + signature_count=recovery_partial["signature_count"], + mismatch_code="SCENARIO_TREASURY_INCOMPLETE_FINALIZATION_MISMATCH", + safe_message="Bitcoin Core did not preserve the incomplete recovery PSBT state.", + ) + current_step = "sign_recovery_two" + recovery_threshold = self._sign_psbt( + recovery_partial["psbt"], + [signer_wallets[TreasuryParticipantRole.RECOVERY][1]], + expected_signatures=2, + ) + current_step = "finalize_recovery" + recovery_final = self._finalize(recovery_threshold["psbt"]) + current_step = "reject_premature_recovery" + recovery_premature, recovery_premature_attack = self._verify_premature_attack( + recovery_final["hex"], + attack_decisions["recovery_premature"], + mismatch_code="SCENARIO_TREASURY_PREMATURE_REASON_MISMATCH", + safe_message=( + "Bitcoin Core did not reject the premature treasury spend as non-BIP68-final." + ), + ) + + current_step = "create_wrong_sequence_psbt" + wrong_sequence_unsigned = self._create_policy_psbt( + coordinator_wallet, + recovery_funding, + destination_address, + sequence=self.RECOVERY_DELAY - 1, + ) + current_step = "sign_wrong_sequence_psbt" + wrong_sequence_signed = self._sign_psbt( + wrong_sequence_unsigned["psbt"], + signer_wallets[TreasuryParticipantRole.RECOVERY][:2], + expected_signatures=2, + ) + current_step = "finalize_wrong_sequence_incomplete" + wrong_sequence_incomplete = self._finalize_attempt(wrong_sequence_signed["psbt"]) + recovery_sequence_attack = self._verify_psbt_attack( + attack_decisions["recovery_sequence"], + complete=self._optional_bool(wrong_sequence_incomplete.get("complete")), + transaction_hex_present=wrong_sequence_incomplete.get("hex") is not None, + signature_count=wrong_sequence_signed["signature_count"], + mismatch_code="SCENARIO_TREASURY_INCOMPLETE_FINALIZATION_MISMATCH", + safe_message="Sequence four unexpectedly satisfied the older(5) treasury branch.", + ) + + current_step = "advance_recovery_delay" + recovery_delay_blocks = self._mine_blocks(self.RECOVERY_DELAY, mining_address) + recovery_mature_height = self._require_height() + current_step = "preflight_mature_recovery" + recovery_mature_acceptance = self._require_accepted(recovery_final["hex"]) + current_step = "broadcast_recovery" + recovery_txid = self._require_txid( + self._mutate("sendrawtransaction", [recovery_final["hex"]]), + "sendrawtransaction", + ) + current_step = "inspect_recovery_mempool" + recovery_mempool = self._require_dict( + self.rpc.call("getmempoolentry", [recovery_txid]), + "getmempoolentry", + ) + current_step = "confirm_recovery" + recovery_confirmation_blocks = self._mine_blocks(1, mining_address) + current_step = "decode_recovery" + recovery_confirmed = self._confirmed_transaction(funding_wallet, recovery_txid) + + current_step = "fund_emergency" + emergency_funding = self._fund_policy(funding_wallet, materialized.address) + current_step = "confirm_emergency_funding" + emergency_funding_blocks = self._mine_blocks(1, mining_address) + current_step = "create_emergency_psbt" + emergency_unsigned = self._create_policy_psbt( + coordinator_wallet, + emergency_funding, + destination_address, + sequence=self.EMERGENCY_DELAY, + ) + current_step = "sign_emergency_one" + emergency_partial = self._sign_psbt( + emergency_unsigned["psbt"], + [signer_wallets[TreasuryParticipantRole.EMERGENCY][0]], + expected_signatures=None, + ) + emergency_signature_attack = self._verify_psbt_attack( + attack_decisions["emergency_signature"], + complete=self._last_wallet_complete(emergency_partial), + transaction_hex_present=False, + signature_count=emergency_partial["signature_count"], + mismatch_code="SCENARIO_TREASURY_SIGNATURE_COUNT_MISMATCH", + safe_message="The emergency branch did not remain below its 2-of-3 threshold.", + ) + current_step = "finalize_emergency_incomplete" + emergency_incomplete = self._finalize_attempt(emergency_partial["psbt"]) + emergency_psbt_attack = self._verify_psbt_attack( + attack_decisions["emergency_psbt"], + complete=self._optional_bool(emergency_incomplete.get("complete")), + transaction_hex_present=emergency_incomplete.get("hex") is not None, + signature_count=emergency_partial["signature_count"], + mismatch_code="SCENARIO_TREASURY_INCOMPLETE_FINALIZATION_MISMATCH", + safe_message="Bitcoin Core did not preserve the incomplete emergency PSBT state.", + ) + current_step = "sign_emergency_two" + emergency_threshold = self._sign_psbt( + emergency_partial["psbt"], + [signer_wallets[TreasuryParticipantRole.EMERGENCY][1]], + expected_signatures=2, + ) + current_step = "finalize_emergency" + emergency_final = self._finalize(emergency_threshold["psbt"]) + current_step = "reject_premature_emergency" + emergency_premature, emergency_premature_attack = self._verify_premature_attack( + emergency_final["hex"], + attack_decisions["emergency_premature"], + mismatch_code="SCENARIO_TREASURY_PREMATURE_REASON_MISMATCH", + safe_message=( + "Bitcoin Core did not reject the premature treasury spend as non-BIP68-final." + ), + ) + current_step = "advance_emergency_delay" + emergency_delay_blocks = self._mine_blocks(self.EMERGENCY_DELAY, mining_address) + emergency_mature_height = self._require_height() + current_step = "preflight_mature_emergency" + emergency_mature_acceptance = self._require_accepted(emergency_final["hex"]) + current_step = "broadcast_emergency" + emergency_txid = self._require_txid( + self._mutate("sendrawtransaction", [emergency_final["hex"]]), + "sendrawtransaction", + ) + current_step = "inspect_emergency_mempool" + emergency_mempool = self._require_dict( + self.rpc.call("getmempoolentry", [emergency_txid]), + "getmempoolentry", + ) + current_step = "confirm_emergency" + emergency_confirmation_blocks = self._mine_blocks(1, mining_address) + current_step = "decode_emergency" + emergency_confirmed = self._confirmed_transaction(funding_wallet, emergency_txid) + + self._record_session_outputs( + session, + addresses=[ + mining_address, + destination_address, + materialized.address, + *signer_addresses, + ], + txids=[ + immediate_funding["txid"], + immediate_txid, + recovery_funding["txid"], + recovery_txid, + emergency_funding["txid"], + emergency_txid, + ], + block_hashes=[ + *maturity_hashes, + *immediate_funding_blocks, + *immediate_confirmation_blocks, + *recovery_funding_blocks, + *recovery_delay_blocks, + *recovery_confirmation_blocks, + *emergency_funding_blocks, + *emergency_delay_blocks, + *emergency_confirmation_blocks, + ], + ) + except BitScopeError as exc: + raise ScenarioExecutionError(current_step, exc) from exc + + values = locals() + return ScenarioExecution( + evidence_records=self._evidence_records(run, captured_at, values), + step_results=self._step_results(captured_at), + assertion_results=self._assertion_results(), + attack_results=[ + immediate_signature_attack, + immediate_psbt_attack, + recovery_signature_attack, + recovery_psbt_attack, + recovery_premature_attack, + recovery_sequence_attack, + emergency_signature_attack, + emergency_psbt_attack, + emergency_premature_attack, + ], + ) + + def cleanup(self, run: ScenarioRun) -> list[str]: + _, unloaded = LabSessionService(self.rpc.transport, self.lab_store).cleanup(run.lab_session_id) + return unloaded + + def failure_evidence( + self, + run: ScenarioRun, + step_id: str, + error: BitScopeError, + captured_at: datetime, + ) -> EvidenceRecord: + rpc_method = error.details.get("rpc_method") + rpc_code = error.details.get("rpc_code") + rpc_message = error.details.get("rpc_message") + return EvidenceRecord( + evidence_id=f"failure.{step_id}", + kind="rpc_result", + label=f"Unexpected failure at {step_id}", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output={ + "rpc_method": rpc_method if isinstance(rpc_method, str) else None, + "safe_parameters": [], + "result": None, + "error": { + "code": rpc_code if isinstance(rpc_code, int | str) else error.code, + "message": rpc_message if isinstance(rpc_message, str) else error.message, + }, + }, + bitscope_interpretation={ + "summary": "The Community Treasury Recovery scenario stopped on an unexpected failure.", + "facts": [ + {"name": "failure.category", "value": error.code}, + *[ + {"name": f"failure.{key}", "value": value} + for key, value in error.details.items() + if key.startswith("observed_") and isinstance(value, bool | int | float | str) + ], + ], + "limitations": ["Only redacted, bounded failure details are retained."], + }, + ) + + def _active_session(self, run: ScenarioRun) -> LabSession: + session = self.lab_store.get(run.lab_session_id) + if session is None: + raise BitScopeError("LAB_SESSION_NOT_FOUND", "The scenario's lab session does not exist.", 404) + if session.status != "active": + raise BitScopeError( + "LAB_SESSION_NOT_ACTIVE", + "The treasury scenario requires an active lab session.", + 409, + {"lab_session_id": run.lab_session_id, "status": session.status}, + ) + if session.wallet_name not in session.owned_wallets: + raise BitScopeError( + "LAB_WALLET_OWNERSHIP_VIOLATION", + "The funding wallet is not recorded as owned by this session.", + 409, + ) + loaded = self._require_list(self.rpc.call("listwallets"), "listwallets") + if session.wallet_name not in loaded: + raise BitScopeError( + "SCENARIO_WALLET_NOT_LOADED", + "The session funding wallet must be loaded before this scenario can run.", + 409, + {"wallet_name": session.wallet_name}, + ) + return session + + def _prepare_wallets( + self, + session: LabSession, + ) -> tuple[str, dict[TreasuryParticipantRole, list[str]]]: + base = f"bitscope-session-{session.session_id}" + first_generation = session.wallet_generation + 1 + names = [f"{base}-r{first_generation + index}" for index in range(10)] + if any(name in session.owned_wallets for name in names): + raise BitScopeError( + "SCENARIO_TREASURY_WALLET_CONFLICT", + "The planned treasury wallet namespace is already owned by this session.", + 409, + ) + coordinator = names[0] + signer_wallets = { + TreasuryParticipantRole.OPERATOR: names[1:4], + TreasuryParticipantRole.RECOVERY: names[4:7], + TreasuryParticipantRole.EMERGENCY: names[7:10], + } + session.owned_wallets.extend(names) + session.actions.append( + LabAction( + sequence=len(session.actions) + 1, + kind="treasury_wallets_planned", + occurred_at=datetime.now(UTC), + details={"coordinator_wallet": coordinator, "signer_wallets": names[1:]}, + ) + ) + session.updated_at = datetime.now(UTC) + self.lab_store.save(session) + + self._mutate( + "createwallet", + [coordinator, True, True, "", False, True, False, False], + ) + for wallet_name in names[1:]: + self._mutate( + "createwallet", + [wallet_name, False, False, "", False, True, False, False], + ) + return coordinator, signer_wallets + + def _participant_groups( + self, + signer_wallets: dict[TreasuryParticipantRole, list[str]], + ) -> tuple[dict[TreasuryParticipantRole, TreasuryParticipantGroup], list[str]]: + groups: dict[TreasuryParticipantRole, TreasuryParticipantGroup] = {} + addresses: list[str] = [] + for role in ( + TreasuryParticipantRole.OPERATOR, + TreasuryParticipantRole.RECOVERY, + TreasuryParticipantRole.EMERGENCY, + ): + participants: list[TreasuryParticipant] = [] + for position, wallet_name in enumerate(signer_wallets[role], start=1): + address = self._require_string( + self._mutate( + "getnewaddress", + [f"bitscope-treasury-{role.value}-{position}", "bech32"], + wallet_name, + ), + "getnewaddress", + ) + info = self._require_dict( + self.rpc.call("getaddressinfo", [address], wallet_name=wallet_name), + "getaddressinfo", + ) + public_key = self._require_string(info.get("pubkey"), "getaddressinfo") + participants.append( + TreasuryParticipant( + participant_id=f"{role.value}-{position}", + role=role, + position=position, + wallet_name=wallet_name, + public_key=public_key, + ) + ) + addresses.append(address) + groups[role] = TreasuryParticipantGroup(role=role, participants=participants) + return groups, addresses + + def _fund_policy(self, funding_wallet: str, policy_address: str) -> dict[str, object]: + txid = self._require_txid( + self._mutate( + "sendtoaddress", + [ + policy_address, + float(self.FUNDING_AMOUNT), + "", + "", + False, + True, + None, + "unset", + None, + 2.0, + ], + funding_wallet, + ), + "sendtoaddress", + ) + transaction = self._require_dict( + self.rpc.call("gettransaction", [txid], wallet_name=funding_wallet), + "gettransaction", + ) + transaction_hex = self._require_string(transaction.get("hex"), "gettransaction") + decoded = self._require_dict( + self.rpc.call("decoderawtransaction", [transaction_hex]), + "decoderawtransaction", + ) + outputs = self._require_list(decoded.get("vout"), "decoderawtransaction") + for output in outputs: + if not isinstance(output, dict): + continue + script = output.get("scriptPubKey") + if isinstance(script, dict) and script.get("address") == policy_address: + vout = output.get("n") + value = output.get("value") + if isinstance(vout, int) and not isinstance(vout, bool) and isinstance(value, int | float): + return { + "txid": txid, + "vout": vout, + "amount": Decimal(str(value)), + } + raise self._invalid_response( + "decoderawtransaction", + "The treasury funding transaction did not contain the exact policy output.", + ) + + def _create_policy_psbt( + self, + coordinator_wallet: str, + funding: dict[str, object], + destination_address: str, + *, + sequence: int, + ) -> dict[str, object]: + amount = funding.get("amount") + if not isinstance(amount, Decimal): + raise self._invalid_response("createpsbt", "Treasury funding amount metadata is invalid.") + output_amount = amount - self.SPEND_FEE + psbt = self._require_string( + self._mutate( + "createpsbt", + [ + [{"txid": funding["txid"], "vout": funding["vout"], "sequence": sequence}], + [{destination_address: float(output_amount)}], + 0, + ], + ), + "createpsbt", + ) + processed = self._require_dict( + self._mutate( + "walletprocesspsbt", + [psbt, False, "ALL", True, False], + coordinator_wallet, + ), + "walletprocesspsbt", + ) + enriched = self._require_string(processed.get("psbt"), "walletprocesspsbt") + decoded = self._decode_psbt(enriched) + transaction = self._require_dict(decoded.get("tx"), "decodepsbt") + if transaction.get("version") != 2: + raise self._invalid_response("decodepsbt", "The treasury PSBT must use transaction version 2.") + inputs = self._require_list(transaction.get("vin"), "decodepsbt") + if len(inputs) != 1 or not isinstance(inputs[0], dict) or inputs[0].get("sequence") != sequence: + raise self._invalid_response("decodepsbt", "The treasury PSBT input sequence did not match.") + psbt_inputs = self._require_list(decoded.get("inputs"), "decodepsbt") + if len(psbt_inputs) != 1 or not isinstance(psbt_inputs[0], dict) or not psbt_inputs[0].get("witness_script"): + raise self._invalid_response("decodepsbt", "The coordinator did not add the treasury witness script.") + return {"psbt": enriched, "decoded": decoded, "sequence": sequence} + + def _sign_psbt( + self, + psbt: object, + wallets: list[str], + *, + expected_signatures: int | None, + ) -> dict[str, object]: + current = self._require_string(psbt, "walletprocesspsbt") + results: list[dict[str, object]] = [] + for wallet_name in wallets: + result = self._require_dict( + self._mutate( + "walletprocesspsbt", + [current, True, "ALL", True, False], + wallet_name, + ), + "walletprocesspsbt", + ) + if expected_signatures is not None and result.get("complete") is not False: + raise BitScopeError( + "SCENARIO_TREASURY_UNEXPECTED_EARLY_COMPLETION", + "A treasury signer unexpectedly finalized the PSBT during staged signing.", + 409, + {"observed_complete": result.get("complete")}, + ) + current = self._require_string(result.get("psbt"), "walletprocesspsbt") + results.append(result) + decoded = self._decode_psbt(current) + signature_count = self._signature_count(decoded) + if expected_signatures is not None and signature_count != expected_signatures: + raise BitScopeError( + "SCENARIO_TREASURY_SIGNATURE_COUNT_MISMATCH", + "The treasury PSBT did not contain the expected number of partial signatures.", + 409, + { + "observed_signature_count": signature_count, + "expected_signature_count": expected_signatures, + }, + ) + return { + "psbt": current, + "wallet_results": results, + "decoded": decoded, + "signature_count": signature_count, + } + + def _finalize_attempt(self, psbt: object) -> dict[str, object]: + return self._require_dict( + self._mutate("finalizepsbt", [self._require_string(psbt, "finalizepsbt"), True]), + "finalizepsbt", + ) + + def _finalize(self, psbt: object) -> dict[str, object]: + result = self._require_dict( + self._mutate("finalizepsbt", [self._require_string(psbt, "finalizepsbt"), True]), + "finalizepsbt", + ) + if result.get("complete") is not True: + raise self._invalid_response("finalizepsbt", "Bitcoin Core did not finalize the treasury PSBT.") + self._require_string(result.get("hex"), "finalizepsbt") + return result + + def _verify_premature_attack( + self, + transaction_hex: object, + decision: AttackApplicabilityDecision, + *, + mismatch_code: str, + safe_message: str, + ) -> tuple[dict[str, object], AttackVerificationResult]: + acceptance = self._single_acceptance( + self.rpc.call( + "testmempoolaccept", + [[self._require_string(transaction_hex, "testmempoolaccept")]], + ) + ) + reason = acceptance.get("reject-reason") + result = self.attacks.require_expected( + self.attacks.verify( + decision, + MempoolAttackObservation( + allowed=acceptance["allowed"], + reject_reason=reason if isinstance(reason, str) else None, + raw_safe_details=acceptance, + ), + ), + mismatch_code=mismatch_code, + safe_message=safe_message, + ) + return acceptance, result + + def _verify_psbt_attack( + self, + decision: AttackApplicabilityDecision, + *, + complete: bool | None, + transaction_hex_present: bool, + signature_count: object, + mismatch_code: str, + safe_message: str, + ) -> AttackVerificationResult: + normalized_count = signature_count if isinstance(signature_count, int) else None + return self.attacks.require_expected( + self.attacks.verify( + decision, + PsbtAttackObservation( + complete=complete, + transaction_hex_present=transaction_hex_present, + signature_count=normalized_count, + raw_safe_details={ + "complete": complete, + "transaction_hex_present": transaction_hex_present, + "signature_count": normalized_count, + }, + ), + ), + mismatch_code=mismatch_code, + safe_message=safe_message, + ) + + @staticmethod + def _last_wallet_complete(signing: dict[str, object]) -> bool | None: + results = signing.get("wallet_results") + if not isinstance(results, list) or not results or not isinstance(results[-1], dict): + return None + return CommunityTreasuryScenarioService._optional_bool(results[-1].get("complete")) + + @staticmethod + def _optional_bool(value: object) -> bool | None: + return value if isinstance(value, bool) else None + + def _require_accepted(self, transaction_hex: object) -> dict[str, object]: + acceptance = self._single_acceptance( + self.rpc.call( + "testmempoolaccept", + [[self._require_string(transaction_hex, "testmempoolaccept")]], + ) + ) + if acceptance.get("allowed") is not True: + raise BitScopeError( + "SCENARIO_TREASURY_PREFLIGHT_REJECTED", + "Bitcoin Core rejected a treasury transaction that should be spendable.", + 409, + {"observed_reject_reason": acceptance.get("reject-reason")}, + ) + return acceptance + + def _confirmed_transaction(self, wallet_name: str, txid: str) -> dict[str, object]: + transaction = self._require_dict( + self.rpc.call("gettransaction", [txid], wallet_name=wallet_name), + "gettransaction", + ) + confirmations = transaction.get("confirmations") + if not isinstance(confirmations, int) or isinstance(confirmations, bool) or confirmations < 1: + raise self._invalid_response("gettransaction", "The treasury spend is not confirmed.") + transaction_hex = self._require_string(transaction.get("hex"), "gettransaction") + decoded = self._require_dict( + self.rpc.call("decoderawtransaction", [transaction_hex]), + "decoderawtransaction", + ) + if decoded.get("txid") != txid: + raise self._invalid_response("decoderawtransaction", "The confirmed treasury txid did not match.") + return {"wallet_transaction": transaction, "decoded": decoded} + + def _require_funding_balance(self, wallet_name: str) -> None: + balances = self._require_dict( + self.rpc.call("getbalances", wallet_name=wallet_name), + "getbalances", + ) + mine = balances.get("mine") + trusted = mine.get("trusted") if isinstance(mine, dict) else None + if not isinstance(trusted, int | float) or isinstance(trusted, bool) or Decimal(str(trusted)) < Decimal("3.1"): + raise self._invalid_response( + "getbalances", + "The treasury funding wallet did not reach the required mature balance.", + ) + + def _mutate(self, method: str, params: object, wallet_name: str | None = None) -> object: + NetworkSafetyGuard(self.rpc).require_regtest() + return self.rpc.call(method, params, wallet_name=wallet_name) + + def _mine_blocks(self, blocks: int, address: str) -> list[str]: + hashes: list[str] = [] + remaining = blocks + while remaining: + batch = min(remaining, 20) + mined = self._require_list( + self._mutate("generatetoaddress", [batch, address]), + "generatetoaddress", + ) + if len(mined) != batch or any(not isinstance(item, str) or not item for item in mined): + raise self._invalid_response("generatetoaddress", "Bitcoin Core returned invalid block hashes.") + hashes.extend(mined) + remaining -= batch + return hashes + + def _require_height(self) -> int: + height = self.rpc.call("getblockcount") + if not isinstance(height, int) or isinstance(height, bool) or height < 0: + raise self._invalid_response("getblockcount", "Bitcoin Core returned an invalid block height.") + return height + + def _record_session_outputs( + self, + session: LabSession, + *, + addresses: list[str], + txids: list[object], + block_hashes: list[str], + ) -> None: + normalized_txids = [self._require_txid(txid, "session_record") for txid in txids] + session.created_addresses.extend(addresses) + session.transaction_ids.extend(normalized_txids) + session.block_hashes.extend(block_hashes) + session.actions.append( + LabAction( + sequence=len(session.actions) + 1, + kind="community_treasury_completed", + occurred_at=datetime.now(UTC), + details={ + "immediate_txid": normalized_txids[1], + "recovery_txid": normalized_txids[3], + "emergency_txid": normalized_txids[5], + }, + ) + ) + session.updated_at = datetime.now(UTC) + self.lab_store.save(session) + + def _evidence_records( + self, + run: ScenarioRun, + captured_at: datetime, + values: dict[str, object], + ) -> list[EvidenceRecord]: + materialized = values["materialized"] + assert isinstance(materialized, MaterializedTreasuryPolicy) + + def record( + evidence_id: str, + kind: str, + label: str, + step_id: str, + rpc_method: str, + result: object, + summary: str, + run_paths: list[str], + ) -> EvidenceRecord: + return EvidenceRecord( + evidence_id=evidence_id, + kind=kind, + label=label, + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output={ + "rpc_method": rpc_method, + "safe_parameters": [], + "result": result, + "run_specific_paths": run_paths, + }, + bitscope_interpretation={ + "summary": summary, + "facts": [], + "limitations": [ + "All educational signer wallets run in one local Bitcoin Core process and one BitScope session.", + "This proves regtest spendability and threshold mechanics, not production custody or a key ceremony.", + ], + }, + commands=[ + { + "arguments": ["-regtest", rpc_method, ""], + "description": f"Reproduce the reviewed {label.lower()} operation with the exported public inputs.", + } + ], + ) + + participant_groups = values["participant_groups"] + assert isinstance(participant_groups, dict) + public_groups = { + role.value: group.model_dump(mode="json") + for role, group in participant_groups.items() + if isinstance(role, TreasuryParticipantRole) and isinstance(group, TreasuryParticipantGroup) + } + return [ + record( + "treasury.participants", + "lifecycle", + "Treasury participant setup", + "prepare_participants", + "createwallet", + { + "coordinator_wallet": values["coordinator_wallet"], + "participant_groups": public_groups, + "signer_addresses": values["signer_addresses"], + "maturity_block_hashes": values["maturity_hashes"], + }, + "Nine public signer identities were isolated across three threshold groups and a non-signing coordinator.", + [ + "$.result.coordinator_wallet", + "$.result.participant_groups", + "$.result.signer_addresses", + "$.result.maturity_block_hashes", + ], + ), + record( + "treasury.policy", + "rpc_result", + "Public treasury policy", + "materialize_policy", + "importdescriptors", + { + "policy": materialized.model_dump(mode="json"), + "import": values["import_result"].model_dump(mode="json"), + }, + "Core confirmed the public three-path descriptor as solvable, non-ranged, and free of private keys.", + ["$.result.policy", "$.result.import.coordinator_wallet"], + ), + record( + "treasury.immediate", + "transaction", + "Immediate 2-of-3 spend", + "decode_immediate", + "gettransaction", + self._branch_evidence(values, "immediate"), + "One operator remained incomplete; two operators finalized, preflighted, broadcast, and confirmed the spend.", + ["$.result"], + ), + record( + "treasury.recovery-partial", + "psbt", + "Recovery threshold check", + "finalize_recovery_incomplete", + "finalizepsbt", + { + "funding": self._json_funding(values["recovery_funding"]), + "unsigned": values["recovery_unsigned"], + "partial": values["recovery_partial"], + "finalization": values["recovery_incomplete"], + }, + "One recovery signature remained incomplete and unextractable.", + ["$.result"], + ), + record( + "treasury.recovery-premature", + "assertion", + "Premature recovery rejection", + "reject_premature_recovery", + "testmempoolaccept", + { + "threshold": values["recovery_threshold"], + "finalized": values["recovery_final"], + "acceptance": values["recovery_premature"], + }, + "Core rejected the fully signed recovery transaction for the exact non-BIP68-final reason.", + ["$.result"], + ), + record( + "treasury.recovery-wrong-sequence", + "assertion", + "Incorrect recovery sequence", + "finalize_wrong_sequence_incomplete", + "finalizepsbt", + { + "unsigned": values["wrong_sequence_unsigned"], + "signed": values["wrong_sequence_signed"], + "finalization": values["wrong_sequence_incomplete"], + }, + "Core kept the two-signature sequence-four PSBT incomplete because it cannot satisfy older(5).", + ["$.result"], + ), + record( + "treasury.recovery-mature", + "transaction", + "Mature recovery spend", + "decode_recovery", + "gettransaction", + { + "delay_block_hashes": values["recovery_delay_blocks"], + "mature_height": values["recovery_mature_height"], + "acceptance": values["recovery_mature_acceptance"], + "txid": values["recovery_txid"], + "mempool": values["recovery_mempool"], + "confirmation_block_hashes": values["recovery_confirmation_blocks"], + "confirmed": values["recovery_confirmed"], + }, + "The unchanged recovery transaction became acceptable after five blocks and then confirmed.", + ["$.result"], + ), + record( + "treasury.emergency-partial", + "psbt", + "Emergency threshold check", + "finalize_emergency_incomplete", + "finalizepsbt", + { + "funding": self._json_funding(values["emergency_funding"]), + "unsigned": values["emergency_unsigned"], + "partial": values["emergency_partial"], + "finalization": values["emergency_incomplete"], + }, + "One emergency signature remained incomplete and unextractable.", + ["$.result"], + ), + record( + "treasury.emergency-premature", + "assertion", + "Premature emergency rejection", + "reject_premature_emergency", + "testmempoolaccept", + { + "threshold": values["emergency_threshold"], + "finalized": values["emergency_final"], + "acceptance": values["emergency_premature"], + }, + "Core rejected the fully signed emergency transaction for the exact non-BIP68-final reason.", + ["$.result"], + ), + record( + "treasury.emergency-mature", + "transaction", + "Mature emergency spend", + "decode_emergency", + "gettransaction", + { + "delay_block_hashes": values["emergency_delay_blocks"], + "mature_height": values["emergency_mature_height"], + "acceptance": values["emergency_mature_acceptance"], + "txid": values["emergency_txid"], + "mempool": values["emergency_mempool"], + "confirmation_block_hashes": values["emergency_confirmation_blocks"], + "confirmed": values["emergency_confirmed"], + }, + "The unchanged emergency transaction became acceptable after ten blocks and then confirmed.", + ["$.result"], + ), + ] + + @staticmethod + def _branch_evidence(values: dict[str, object], prefix: str) -> dict[str, object]: + return { + "funding": CommunityTreasuryScenarioService._json_funding(values[f"{prefix}_funding"]), + "unsigned": values[f"{prefix}_unsigned"], + "partial": values[f"{prefix}_partial"], + "incomplete": values[f"{prefix}_incomplete"], + "threshold": values[f"{prefix}_threshold"], + "finalized": values[f"{prefix}_final"], + "acceptance": values[f"{prefix}_acceptance"], + "txid": values[f"{prefix}_txid"], + "mempool": values[f"{prefix}_mempool"], + "confirmed": values[f"{prefix}_confirmed"], + } + + @staticmethod + def _json_funding(value: object) -> dict[str, object]: + if not isinstance(value, dict): + return {} + return { + key: str(item) if isinstance(item, Decimal) else item + for key, item in value.items() + } + + @staticmethod + def _step_results(timestamp: datetime) -> list[ScenarioStepResult]: + evidence_by_step = { + "verify_chain": "node.context", + "prepare_funding_wallet": "treasury.participants", + "prepare_participants": "treasury.participants", + "generate_mining_address": "treasury.participants", + "mine_mature_funds": "treasury.participants", + "materialize_policy": "treasury.policy", + "generate_destination": "treasury.policy", + **{ + step: "treasury.immediate" + for step in ( + "fund_immediate", + "confirm_immediate_funding", + "create_immediate_psbt", + "sign_immediate_one", + "finalize_immediate_incomplete", + "sign_immediate_two", + "finalize_immediate", + "preflight_immediate", + "broadcast_immediate", + "inspect_immediate_mempool", + "confirm_immediate", + "decode_immediate", + ) + }, + "fund_recovery": "treasury.recovery-partial", + "confirm_recovery_funding": "treasury.recovery-partial", + "create_recovery_psbt": "treasury.recovery-partial", + "sign_recovery_one": "treasury.recovery-partial", + "finalize_recovery_incomplete": "treasury.recovery-partial", + "sign_recovery_two": "treasury.recovery-premature", + "finalize_recovery": "treasury.recovery-premature", + "reject_premature_recovery": "treasury.recovery-premature", + "create_wrong_sequence_psbt": "treasury.recovery-wrong-sequence", + "sign_wrong_sequence_psbt": "treasury.recovery-wrong-sequence", + "finalize_wrong_sequence_incomplete": "treasury.recovery-wrong-sequence", + **{ + step: "treasury.recovery-mature" + for step in ( + "advance_recovery_delay", + "preflight_mature_recovery", + "broadcast_recovery", + "inspect_recovery_mempool", + "confirm_recovery", + "decode_recovery", + ) + }, + "fund_emergency": "treasury.emergency-partial", + "confirm_emergency_funding": "treasury.emergency-partial", + "create_emergency_psbt": "treasury.emergency-partial", + "sign_emergency_one": "treasury.emergency-partial", + "finalize_emergency_incomplete": "treasury.emergency-partial", + "sign_emergency_two": "treasury.emergency-premature", + "finalize_emergency": "treasury.emergency-premature", + "reject_premature_emergency": "treasury.emergency-premature", + **{ + step: "treasury.emergency-mature" + for step in ( + "advance_emergency_delay", + "preflight_mature_emergency", + "broadcast_emergency", + "inspect_emergency_mempool", + "confirm_emergency", + "decode_emergency", + ) + }, + } + outputs = { + "verify_chain": ["node.context"], + "prepare_funding_wallet": ["wallet.funder"], + "prepare_participants": ["participants.treasury", "wallet.coordinator"], + "generate_mining_address": ["address.mining"], + "mine_mature_funds": ["blocks.maturity"], + "materialize_policy": ["treasury.policy", "treasury.address", "treasury.decision_tree"], + "generate_destination": ["address.destination"], + "fund_immediate": ["funding.immediate"], + "confirm_immediate_funding": ["blocks.immediate_funding"], + "create_immediate_psbt": ["psbt.immediate.unsigned"], + "sign_immediate_one": ["psbt.immediate.partial", "signatures.immediate.partial"], + "finalize_immediate_incomplete": ["psbt.immediate.incomplete"], + "sign_immediate_two": ["psbt.immediate.threshold", "signatures.immediate.threshold"], + "finalize_immediate": ["transaction.immediate"], + "preflight_immediate": ["acceptance.immediate"], + "broadcast_immediate": ["txid.immediate"], + "inspect_immediate_mempool": ["mempool.immediate"], + "confirm_immediate": ["blocks.immediate_confirmation"], + "decode_immediate": ["transaction.immediate.confirmed"], + "fund_recovery": ["funding.recovery"], + "confirm_recovery_funding": ["blocks.recovery_funding"], + "create_recovery_psbt": ["psbt.recovery.unsigned"], + "sign_recovery_one": ["psbt.recovery.partial", "signatures.recovery.partial"], + "finalize_recovery_incomplete": ["psbt.recovery.incomplete"], + "sign_recovery_two": ["psbt.recovery.threshold", "signatures.recovery.threshold"], + "finalize_recovery": ["transaction.recovery"], + "reject_premature_recovery": ["acceptance.recovery.premature"], + "create_wrong_sequence_psbt": ["psbt.recovery.wrong_sequence"], + "sign_wrong_sequence_psbt": [ + "psbt.recovery.wrong_sequence.signed", + "signatures.recovery.wrong_sequence", + ], + "finalize_wrong_sequence_incomplete": ["psbt.recovery.wrong_sequence.incomplete"], + "advance_recovery_delay": ["height.recovery.mature"], + "preflight_mature_recovery": ["acceptance.recovery.mature"], + "broadcast_recovery": ["txid.recovery"], + "inspect_recovery_mempool": ["mempool.recovery"], + "confirm_recovery": ["blocks.recovery_confirmation"], + "decode_recovery": ["transaction.recovery.confirmed"], + "fund_emergency": ["funding.emergency"], + "confirm_emergency_funding": ["blocks.emergency_funding"], + "create_emergency_psbt": ["psbt.emergency.unsigned"], + "sign_emergency_one": ["psbt.emergency.partial", "signatures.emergency.partial"], + "finalize_emergency_incomplete": ["psbt.emergency.incomplete"], + "sign_emergency_two": ["psbt.emergency.threshold", "signatures.emergency.threshold"], + "finalize_emergency": ["transaction.emergency"], + "reject_premature_emergency": ["acceptance.emergency.premature"], + "advance_emergency_delay": ["height.emergency.mature"], + "preflight_mature_emergency": ["acceptance.emergency.mature"], + "broadcast_emergency": ["txid.emergency"], + "inspect_emergency_mempool": ["mempool.emergency"], + "confirm_emergency": ["blocks.emergency_confirmation"], + "decode_emergency": ["transaction.emergency.confirmed"], + } + expected_failures = { + "finalize_immediate_incomplete": ( + "insufficient-immediate-signatures", + FailureCategory.PSBT_INCOMPLETE, + "One operator signature remained incomplete.", + ), + "finalize_recovery_incomplete": ( + "insufficient-recovery-signatures", + FailureCategory.PSBT_INCOMPLETE, + "One recovery signature remained incomplete.", + ), + "reject_premature_recovery": ( + "non-BIP68-final", + FailureCategory.MEMPOOL_POLICY, + "Core rejected recovery before its relative delay.", + ), + "finalize_wrong_sequence_incomplete": ( + "incorrect-sequence-incomplete", + FailureCategory.PSBT_INCOMPLETE, + "Sequence four could not satisfy older(5).", + ), + "finalize_emergency_incomplete": ( + "insufficient-emergency-signatures", + FailureCategory.PSBT_INCOMPLETE, + "One emergency signature remained incomplete.", + ), + "reject_premature_emergency": ( + "non-BIP68-final-emergency", + FailureCategory.MEMPOOL_POLICY, + "Core rejected emergency recovery before its relative delay.", + ), + } + results: list[ScenarioStepResult] = [] + for step_id, evidence_id in evidence_by_step.items(): + failure_data = expected_failures.get(step_id) + failure = None + status = ScenarioStepResultStatus.COMPLETED + if failure_data is not None: + code, category, message = failure_data + failure = ScenarioFailure( + failure_id=f"failure.{step_id}", + step_id=step_id, + category=category, + expected=True, + code=code, + safe_message=message, + evidence_ids=[evidence_id], + ) + status = ScenarioStepResultStatus.EXPECTED_FAILURE + results.append( + ScenarioStepResult( + step_id=step_id, + status=status, + started_at=timestamp, + completed_at=timestamp, + output_refs=outputs[step_id], + evidence_ids=[evidence_id], + failure=failure, + ) + ) + return results + + @staticmethod + def _assertion_results() -> list[AssertionResult]: + evidence = { + **{ + assertion: "treasury.immediate" + for assertion in ( + "immediate_insufficient", + "immediate_psbt_incomplete", + "immediate_threshold_not_met", + "immediate_threshold_met", + "immediate_accepted", + "immediate_confirmed", + ) + }, + "recovery_insufficient": "treasury.recovery-partial", + "recovery_psbt_incomplete": "treasury.recovery-partial", + "recovery_threshold_not_met": "treasury.recovery-partial", + "recovery_threshold_met": "treasury.recovery-premature", + "premature_recovery_rejected": "treasury.recovery-premature", + "recovery_timelock_immature": "treasury.recovery-premature", + "wrong_sequence_incomplete": "treasury.recovery-wrong-sequence", + "recovery_timelock_mature": "treasury.recovery-mature", + "recovery_accepted": "treasury.recovery-mature", + "recovery_confirmed": "treasury.recovery-mature", + "emergency_insufficient": "treasury.emergency-partial", + "emergency_psbt_incomplete": "treasury.emergency-partial", + "emergency_threshold_not_met": "treasury.emergency-partial", + "emergency_threshold_met": "treasury.emergency-premature", + "premature_emergency_rejected": "treasury.emergency-premature", + "emergency_timelock_immature": "treasury.emergency-premature", + "emergency_timelock_mature": "treasury.emergency-mature", + "emergency_accepted": "treasury.emergency-mature", + "emergency_confirmed": "treasury.emergency-mature", + } + expected_failure_assertions = { + "immediate_insufficient", + "recovery_insufficient", + "premature_recovery_rejected", + "wrong_sequence_incomplete", + "emergency_insufficient", + "premature_emergency_rejected", + } + return [ + AssertionResult( + assertion_id=assertion_id, + status=AssertionResultStatus.PASSED, + required=True, + expected_failure=assertion_id in expected_failure_assertions, + explanation="The executor observed and validated the exact required treasury policy outcome.", + evidence_ids=[evidence_id], + ) + for assertion_id, evidence_id in evidence.items() + ] + + def _decode_psbt(self, psbt: str) -> dict[str, object]: + return self._require_dict(self.rpc.call("decodepsbt", [psbt]), "decodepsbt") + + @staticmethod + def _signature_count(decoded: dict[str, object]) -> int: + inputs = decoded.get("inputs") + if not isinstance(inputs, list) or len(inputs) != 1 or not isinstance(inputs[0], dict): + raise CommunityTreasuryScenarioService._invalid_response( + "decodepsbt", + "Bitcoin Core returned invalid one-input treasury PSBT metadata.", + ) + signatures = inputs[0].get("partial_signatures") + if signatures is None: + return 0 + if not isinstance(signatures, dict): + raise CommunityTreasuryScenarioService._invalid_response( + "decodepsbt", + "Bitcoin Core returned invalid partial signature metadata.", + ) + return len(signatures) + + @staticmethod + def _single_acceptance(value: object) -> dict[str, object]: + results = CommunityTreasuryScenarioService._require_list(value, "testmempoolaccept") + if len(results) != 1 or not isinstance(results[0], dict) or not isinstance(results[0].get("allowed"), bool): + raise CommunityTreasuryScenarioService._invalid_response( + "testmempoolaccept", + "Bitcoin Core returned an invalid treasury preflight result.", + ) + return results[0] + + @staticmethod + def _require_txid(value: object, rpc_method: str) -> str: + txid = CommunityTreasuryScenarioService._require_string(value, rpc_method) + if len(txid) != 64 or any(character not in "0123456789abcdefABCDEF" for character in txid): + raise CommunityTreasuryScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid txid.", + ) + return txid + + @staticmethod + def _require_string(value: object, rpc_method: str) -> str: + if not isinstance(value, str) or not value: + raise CommunityTreasuryScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid string response.", + ) + return value + + @staticmethod + def _require_dict(value: object, rpc_method: str) -> dict[str, object]: + if not isinstance(value, dict): + raise CommunityTreasuryScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid object response.", + ) + return value + + @staticmethod + def _require_list(value: object, rpc_method: str) -> list[object]: + if not isinstance(value, list): + raise CommunityTreasuryScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid list response.", + ) + return value + + @staticmethod + def _invalid_response(rpc_method: str, message: str) -> BitScopeError: + return BitScopeError( + "BITCOIN_CORE_INVALID_RESPONSE", + message, + 502, + {"rpc_method": rpc_method}, + ) diff --git a/backend/app/services/curriculum_service.py b/backend/app/services/curriculum_service.py new file mode 100644 index 0000000..d836b07 --- /dev/null +++ b/backend/app/services/curriculum_service.py @@ -0,0 +1,163 @@ +from app.models.curriculum import CurriculumResponse + + +COURSE_ROOT = "https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line/blob/master" + + +CURRICULUM = CurriculumResponse.model_validate( + { + "chapters": [ + { + "chapter": 3, + "title": "Understand the node, wallet, and descriptors", + "source_url": f"{COURSE_ROOT}/03_0_Understanding_Your_Bitcoin_Setup.md", + "learning_objective": "Inspect a local Bitcoin Core node, create an isolated regtest wallet, receive funds, and relate wallet state to descriptors and UTXOs.", + "relevant_pages": ["/", "/wallet", "/descriptors", "/regtest"], + "relevant_scenarios": ["transaction-lifecycle"], + "rpc_methods": ["getblockchaininfo", "createwallet", "getnewaddress", "listunspent", "listdescriptors"], + "prerequisites": ["A reachable Bitcoin Core node", "Regtest for mutation exercises", "Basic bitcoin-cli familiarity"], + "guided_exercise": "Create a disposable lab wallet, mine mature regtest funds, inspect its descriptor set, and identify one spendable outpoint.", + "independent_challenge": "Explain why the wallet balance is derived from controlled UTXOs rather than an account balance stored in the chain.", + "verification_criteria": ["Core reports the regtest chain", "The wallet is session-owned", "A mature UTXO is visible in listunspent"], + }, + { + "chapter": 4, + "title": "Construct and send transactions", + "source_url": f"{COURSE_ROOT}/04_0_Sending_Bitcoin_Transactions.md", + "learning_objective": "Follow inputs and outputs through raw construction, wallet signing, mempool preflight, broadcast, and confirmation.", + "relevant_pages": ["/transactions", "/regtest", "/mempool", "/scenarios"], + "relevant_scenarios": ["transaction-lifecycle"], + "rpc_methods": ["createrawtransaction", "fundrawtransaction", "signrawtransactionwithwallet", "testmempoolaccept", "sendrawtransaction"], + "prerequisites": ["Chapter 3 wallet and UTXO concepts", "A funded regtest wallet"], + "guided_exercise": "Run the transaction lifecycle scenario and inspect the backend-recorded transition from selected UTXO to confirmed transaction.", + "independent_challenge": "Diagnose the scenario's one-satoshi overspend using Core's structured mempool result.", + "verification_criteria": ["Preflight is accepted before broadcast", "The txid enters the mempool", "The same txid confirms", "The overspend rejection is recorded"], + }, + { + "chapter": 5, + "title": "Control unconfirmed transactions", + "source_url": f"{COURSE_ROOT}/05_0_Controlling_Bitcoin_Transactions.md", + "learning_objective": "Distinguish opt-in RBF replacement from CPFP package fee-bumping and inspect their mempool relationships.", + "relevant_pages": ["/tx-control", "/mempool", "/fees", "/scenarios"], + "relevant_scenarios": ["rbf-replacement"], + "rpc_methods": ["getmempoolentry", "bumpfee", "createrawtransaction", "fundrawtransaction", "testmempoolaccept"], + "prerequisites": ["Chapter 4 transaction lifecycle", "Mempool policy versus consensus"], + "guided_exercise": "Complete the reviewed RBF scenario, compare original and replacement fees, and inspect the explicit replaces relationship.", + "independent_challenge": "Use the CPFP construction page to create a child that spends a parent output, then explain what additional scenario evidence would be required before claiming a verified package result.", + "verification_criteria": ["Original inputs signal replacement", "Replacement has a distinct txid and higher fee", "Core evicts the original", "CPFP claims remain limited to implemented construction and preflight"], + "implementation_note": "RBF has a Verified Scenario. CPFP construction and lifecycle relationship rendering are implemented, but the optional CPFP Verified Scenario remains deferred.", + }, + { + "chapter": 6, + "title": "Build multisignature policies", + "source_url": f"{COURSE_ROOT}/06_0_Expanding_Bitcoin_Transactions_Multisigs.md", + "learning_objective": "Create a native-SegWit 2-of-3 policy and observe how threshold signing differs from single-key spending.", + "relevant_pages": ["/multisig", "/psbt", "/scenarios"], + "relevant_scenarios": ["multisig-psbt", "community-treasury-recovery"], + "rpc_methods": ["addmultisigaddress", "sendtoaddress", "decodepsbt", "walletprocesspsbt"], + "prerequisites": ["Chapter 4 transaction construction", "Public keys and script locking conditions"], + "guided_exercise": "Create the reviewed 2-of-3 policy, fund it, and compare one-signature and threshold-complete evidence.", + "independent_challenge": "Identify which artifacts must be preserved to recover a real multisig wallet without exposing private keys.", + "verification_criteria": ["Three public keys define the policy", "One signature is insufficient", "Two signatures satisfy the threshold", "The finalized spend confirms"], + }, + { + "chapter": 7, + "title": "Coordinate signing with PSBT", + "source_url": f"{COURSE_ROOT}/07_0_Expanding_Bitcoin_Transactions_PSBTs.md", + "learning_objective": "Separate PSBT creation, partial signing, threshold completion, finalization, and extraction while keeping private material inside signer contexts.", + "relevant_pages": ["/psbt", "/multisig", "/keys", "/scenarios"], + "relevant_scenarios": ["multisig-psbt", "community-treasury-recovery"], + "rpc_methods": ["walletcreatefundedpsbt", "decodepsbt", "walletprocesspsbt", "finalizepsbt"], + "prerequisites": ["Chapter 6 multisig", "Transaction inputs, outputs, and fees"], + "guided_exercise": "Inspect the unsigned, one-signature, threshold-signed, and finalized PSBT lifecycle events.", + "independent_challenge": "Complete the 2-of-3 PSBT challenge without requesting the final hint.", + "verification_criteria": ["Partial PSBT remains incomplete", "Threshold evidence contains the required signatures", "Core finalization produces transaction hex", "Core accepts the extracted transaction"], + }, + { + "chapter": 8, + "title": "Use locktime and data outputs", + "source_url": f"{COURSE_ROOT}/08_0_Expanding_Bitcoin_Transactions_Other.md", + "learning_objective": "Construct nLockTime transactions and small OP_RETURN data outputs, then preflight them against local Core policy.", + "relevant_pages": ["/timelocks", "/data-tx", "/transactions", "/script"], + "relevant_scenarios": ["cltv-timelock"], + "rpc_methods": ["createrawtransaction", "fundrawtransaction", "signrawtransactionwithwallet", "decoderawtransaction", "testmempoolaccept"], + "prerequisites": ["Chapter 4 raw transactions", "Hex-encoded transaction data"], + "guided_exercise": "Build a non-final regtest transaction and separately construct a bounded OP_RETURN output without broadcasting personal data.", + "independent_challenge": "Explain the difference between transaction nLockTime and a CLTV condition enforced inside a spent output.", + "verification_criteria": ["Decoded nLockTime matches the requested value", "At least one input sequence enables locktime", "OP_RETURN script is provably unspendable", "Core preflight result is shown"], + "implementation_note": "The OP_RETURN construction lab is implemented; its optional Verified Scenario remains deferred.", + }, + { + "chapter": 9, + "title": "Read and test Bitcoin Script", + "source_url": f"{COURSE_ROOT}/09_0_Introducing_Bitcoin_Scripts.md", + "learning_objective": "Decode locking scripts, relate stack conditions to transaction outputs, and test complete spends through Bitcoin Core.", + "relevant_pages": ["/script", "/script-lab", "/transactions"], + "relevant_scenarios": ["transaction-lifecycle"], + "rpc_methods": ["decodescript", "decoderawtransaction", "testmempoolaccept"], + "prerequisites": ["Chapter 4 transaction structure", "Basic stack notation"], + "guided_exercise": "Decode a standard output script, find it in a transaction, and inspect the safe Core preflight result for a complete spend.", + "independent_challenge": "Diagnose a rejected transaction by separating serialization, missing-input, script, and value-conservation failures.", + "verification_criteria": ["Script hex decodes through Core", "The locking condition is identified", "A full transaction is used for preflight", "The Core rejection reason is preserved"], + }, + { + "chapter": 10, + "title": "Wrap policies with P2SH and P2WSH", + "source_url": f"{COURSE_ROOT}/10_0_Embedding_Bitcoin_Scripts_in_P2SH_Transactions.md", + "learning_objective": "Compare redeem-script and witness-script commitments and trace a policy from public script to spend witness.", + "relevant_pages": ["/script-lab", "/multisig", "/transactions", "/scenarios"], + "relevant_scenarios": ["multisig-psbt", "cltv-timelock", "community-treasury-recovery"], + "rpc_methods": ["decodescript", "addmultisigaddress", "getdescriptorinfo", "decoderawtransaction"], + "prerequisites": ["Chapter 9 Script", "Hash commitments", "SegWit transaction structure"], + "guided_exercise": "Compare the multisig and CLTV P2WSH evidence, including witness script and final witness stack.", + "independent_challenge": "Explain what Core can verify from the final transaction and which policy metadata must be retained separately.", + "verification_criteria": ["Script commitment matches its wrapper", "Funding output uses the expected script type", "Final witness satisfies the committed script"], + }, + { + "chapter": 11, + "title": "Enforce CLTV and CSV timelocks", + "source_url": f"{COURSE_ROOT}/11_0_Empowering_Timelock_with_Bitcoin_Scripts.md", + "learning_objective": "Distinguish absolute CLTV from relative CSV and prove both premature rejection and mature acceptance.", + "relevant_pages": ["/timelocks", "/script-lab", "/scenarios"], + "relevant_scenarios": ["cltv-timelock", "community-treasury-recovery"], + "rpc_methods": ["getblockcount", "createrawtransaction", "testmempoolaccept", "generatetoaddress"], + "prerequisites": ["Chapter 8 nLockTime", "Chapter 10 P2WSH", "Sequence semantics"], + "guided_exercise": "Run the CLTV scenario, then compare its absolute maturity event with the treasury recovery branch's relative-delay maturity.", + "independent_challenge": "Demonstrate the premature CLTV failure and identify the exact unchanged transaction later accepted at maturity.", + "verification_criteria": ["Premature spend is rejected for the reviewed reason", "Locktime and sequence are preserved", "Maturity height is recorded", "The unchanged mature spend confirms"], + }, + { + "chapter": 12, + "title": "Compose conditions and advanced operations", + "source_url": f"{COURSE_ROOT}/12_0_Expanding_Bitcoin_Scripts.md", + "learning_objective": "Build conditional branches and hash or signature checks while keeping policy claims tied to scripts Core actually validates.", + "relevant_pages": ["/script-lab", "/script", "/scenarios"], + "relevant_scenarios": ["community-treasury-recovery"], + "rpc_methods": ["decodescript", "getdescriptorinfo", "deriveaddresses", "testmempoolaccept"], + "prerequisites": ["Chapters 9-11", "Boolean and stack reasoning"], + "guided_exercise": "Generate conditional templates in Script Lab and inspect the treasury policy's three public branches and satisfaction paths.", + "independent_challenge": "Trace which selector, signatures, and sequence value satisfy each treasury branch without using private key exports.", + "verification_criteria": ["Every claimed branch exists in the public policy", "Branch requirements match observed witness data", "Core validates each completed spend"], + }, + { + "chapter": 13, + "title": "Design and prove a real policy", + "source_url": f"{COURSE_ROOT}/13_0_Designing_Real_Bitcoin_Scripts.md", + "learning_objective": "Turn operational requirements into a reviewable spending policy and prove each supported branch, negative boundary, and cleanup result.", + "relevant_pages": ["/scenarios", "/script-lab", "/multisig", "/psbt", "/timelocks"], + "relevant_scenarios": ["community-treasury-recovery"], + "rpc_methods": ["getdescriptorinfo", "importdescriptors", "createpsbt", "walletprocesspsbt", "finalizepsbt", "testmempoolaccept"], + "prerequisites": ["Chapters 3-12", "Threat modeling", "Key and backup boundaries"], + "guided_exercise": "Run Community Treasury Recovery and review the operator, recovery, and emergency tracks plus the Proof of Spendability export.", + "independent_challenge": "Complete the treasury recovery challenge and defend which claims the regtest evidence proves and which production claims remain out of scope.", + "verification_criteria": ["All three branches match the public descriptor", "Threshold and premature failures are classified", "Mature recovery and emergency spends confirm", "Cleanup and deterministic proof export succeed"], + }, + ], + "explanation": "This mapping summarizes Chapters 3-13 and links to the original course. It includes only BitScope pages and Verified Scenarios present in this repository, with explicit notes where an optional scenario is still deferred.", + } +) + + +class CurriculumService: + def curriculum(self) -> CurriculumResponse: + return CURRICULUM.model_copy(deep=True) diff --git a/backend/app/services/evidence_service.py b/backend/app/services/evidence_service.py new file mode 100644 index 0000000..f1b2f1b --- /dev/null +++ b/backend/app/services/evidence_service.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import json +import re +from hashlib import sha256 +from uuid import UUID + +from pydantic import JsonValue + +from app.config import Settings +from app.errors import BitScopeError +from app.models.evidence import CapturedEvidence, EvidenceRecord +from app.models.scenario import EvidenceReference, ScenarioRun +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_run_store import ScenarioRunStore + + +REDACTED = "[REDACTED]" + +SENSITIVE_KEY_MARKERS = frozenset( + { + "authorization", + "cookie", + "cookiefile", + "env", + "environment", + "hdseed", + "mnemonic", + "passphrase", + "password", + "privatekey", + "privkey", + "rpcauth", + "rpcpassword", + "rpcuser", + "seed", + "secret", + "token", + "xbitscopetoken", + } +) + +EXTENDED_PRIVATE_KEY_PATTERN = re.compile(r"\b(?:xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{20,}\b") +WIF_PRIVATE_KEY_PATTERN = re.compile(r"(? None: + self.sensitive_values = tuple( + sorted({value for value in sensitive_values if value and value != REDACTED}, key=len, reverse=True) + ) + + def redact(self, value: JsonValue) -> JsonValue: + if isinstance(value, dict): + return { + key: REDACTED if self._is_sensitive_key(key) else self.redact(item) + for key, item in value.items() + } + if isinstance(value, list): + return [self.redact(item) for item in value] + if isinstance(value, str): + return self._redact_string(value) + return value + + @staticmethod + def _is_sensitive_key(key: str) -> bool: + normalized = "".join(character for character in key.casefold() if character.isalnum()) + return any( + normalized == marker or normalized.startswith(marker) or normalized.endswith(marker) + for marker in SENSITIVE_KEY_MARKERS + ) + + def _redact_string(self, value: str) -> str: + redacted = PRIVATE_KEY_BLOCK_PATTERN.sub(REDACTED, value) + redacted = EXTENDED_PRIVATE_KEY_PATTERN.sub(REDACTED, redacted) + redacted = WIF_PRIVATE_KEY_PATTERN.sub(REDACTED, redacted) + redacted = BASIC_AUTH_PATTERN.sub(REDACTED, redacted) + for secret in self.sensitive_values: + if redacted == secret: + return REDACTED + if len(secret) >= 4: + redacted = redacted.replace(secret, REDACTED) + else: + redacted = re.sub( + rf"(? None: + if max_content_bytes < 1_024: + raise ValueError("Evidence content limits must be at least 1024 bytes.") + self.redactor = redactor + self.max_content_bytes = max_content_bytes + + @classmethod + def from_settings(cls, settings: Settings, max_content_bytes: int = 1_048_576) -> EvidenceService: + return cls( + EvidenceRedactor( + ( + settings.bitcoin_rpc_user, + settings.bitcoin_rpc_password, + settings.bitscope_local_access_token, + ) + ), + max_content_bytes=max_content_bytes, + ) + + def capture(self, run: ScenarioRun, record: EvidenceRecord) -> CapturedEvidence: + mismatches = self._identity_mismatches(run, record) + if mismatches: + raise BitScopeError( + code="EVIDENCE_RUN_IDENTITY_MISMATCH", + message="Evidence identity must match the scenario run that owns it.", + status_code=409, + details={"run_id": str(run.run_id), "mismatched_fields": mismatches}, + ) + if record.step_id is not None and record.step_id not in run.defined_step_ids: + raise BitScopeError( + code="EVIDENCE_STEP_NOT_FOUND", + message="Evidence cannot reference a step outside its scenario run.", + status_code=409, + details={"run_id": str(run.run_id), "step_id": record.step_id}, + ) + + safe_record = EvidenceRecord.model_validate(self._redact_record_document(record)) + canonical_json = json.dumps( + safe_record.model_dump(mode="json"), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + "\n" + content = canonical_json.encode("utf-8") + if len(content) > self.max_content_bytes: + raise BitScopeError( + code="EVIDENCE_CONTENT_TOO_LARGE", + message="The redacted evidence record exceeds the configured content limit.", + status_code=413, + details={ + "evidence_id": record.evidence_id, + "content_bytes": len(content), + "max_content_bytes": self.max_content_bytes, + }, + ) + + reference = EvidenceReference( + evidence_id=safe_record.evidence_id, + kind=safe_record.kind, + label=safe_record.label, + relative_path=f"evidence/{safe_record.evidence_id}.json", + content_sha256=sha256(content).hexdigest(), + ) + updated_run = run.record_evidence_reference(reference, now=safe_record.captured_at) + return CapturedEvidence( + run=updated_run, + reference=reference, + record=safe_record, + canonical_json=canonical_json, + ) + + @staticmethod + def _identity_mismatches(run: ScenarioRun, record: EvidenceRecord) -> list[str]: + pairs = { + "run_id": (run.run_id, record.run_id), + "scenario_id": (run.scenario_id, record.scenario_id), + "scenario_version": (run.scenario_version, record.scenario_version), + "lab_session_id": (run.lab_session_id, record.lab_session_id), + } + return [field_name for field_name, (expected, submitted) in pairs.items() if expected != submitted] + + def _redact_record_document(self, record: EvidenceRecord) -> dict[str, object]: + """Redact content fields without rewriting trusted identity or schema fields.""" + + document = record.model_dump(mode="json") + document["label"] = self.redactor.redact(document["label"]) + + core_output = document.get("core_output") + if isinstance(core_output, dict): + core_output["safe_parameters"] = self.redactor.redact(core_output.get("safe_parameters")) + core_output["result"] = self.redactor.redact(core_output.get("result")) + error = core_output.get("error") + if isinstance(error, dict): + error["message"] = self.redactor.redact(error.get("message")) + + interpretation = document["bitscope_interpretation"] + if isinstance(interpretation, dict): + interpretation["summary"] = self.redactor.redact(interpretation["summary"]) + interpretation["limitations"] = self.redactor.redact(interpretation.get("limitations", [])) + facts = interpretation.get("facts", []) + if isinstance(facts, list): + for fact in facts: + if isinstance(fact, dict): + fact["value"] = self.redactor.redact(fact.get("value")) + + commands = document.get("commands", []) + if isinstance(commands, list): + for command in commands: + if isinstance(command, dict): + command["arguments"] = self.redactor.redact(command.get("arguments", [])) + command["description"] = self.redactor.redact(command.get("description")) + return document + + +class ScenarioEvidenceRecorder: + """Persist redacted content before committing its reference to the run.""" + + def __init__( + self, + evidence_service: EvidenceService, + artifact_store: ScenarioArtifactStore, + run_store: ScenarioRunStore, + ) -> None: + self.evidence_service = evidence_service + self.artifact_store = artifact_store + self.run_store = run_store + + def record( + self, + run_id: UUID, + lab_session_id: str, + expected_revision: int, + record: EvidenceRecord, + ) -> CapturedEvidence: + run = self.run_store.get_for_session(run_id, lab_session_id) + if run is None: + raise BitScopeError( + code="SCENARIO_RUN_NOT_FOUND", + message="The requested scenario run does not exist.", + status_code=404, + details={"run_id": str(run_id)}, + ) + if run.revision != expected_revision: + raise BitScopeError( + code="SCENARIO_RUN_REVISION_CONFLICT", + message="The scenario run changed after it was loaded. Reload it before recording evidence.", + status_code=409, + details={ + "run_id": str(run_id), + "expected_revision": expected_revision, + "actual_revision": run.revision, + }, + ) + + captured = self.evidence_service.capture(run, record) + created = self.artifact_store.write_evidence(captured) + try: + self.run_store.save(captured.run, expected_revision=expected_revision) + except Exception: + if created: + try: + self.artifact_store.delete_evidence(captured) + except (BitScopeError, OSError): + pass + raise + return captured diff --git a/backend/app/services/lifecycle_recorder.py b/backend/app/services/lifecycle_recorder.py new file mode 100644 index 0000000..bd19ca5 --- /dev/null +++ b/backend/app/services/lifecycle_recorder.py @@ -0,0 +1,600 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal, InvalidOperation +from typing import TypeAlias + +from pydantic import JsonValue + +from app.errors import BitScopeError +from app.models.evidence import EvidenceRecord, SafeBitcoinCliCommand +from app.models.lifecycle import ( + LifecycleEventType, + MempoolRelationship, + MempoolRelationshipType, + TransactionLifecycleEvent, + TransactionLifecycleState, + TransactionLifecycleTimeline, +) +from app.models.scenario import ScenarioRun +from app.services.evidence_service import EvidenceRedactor + + +PathPart: TypeAlias = str | int +JsonPath: TypeAlias = tuple[PathPart, ...] + + +@dataclass(frozen=True) +class LifecycleEventSpec: + evidence_id: str + event_type: LifecycleEventType + step_id: str + track_id: str + state: TransactionLifecycleState + explanation: str + rpc_method: str + cli_arguments: tuple[str, ...] + transaction_id_paths: tuple[JsonPath, ...] = () + transaction_hex_ref: str | None = None + psbt_ref: str | None = None + fee_paths: tuple[JsonPath, ...] = () + fee_rate_paths: tuple[JsonPath, ...] = () + locktime_paths: tuple[JsonPath, ...] = () + sequence_paths: tuple[JsonPath, ...] = () + block_height_paths: tuple[JsonPath, ...] = () + relationship_type: MempoolRelationshipType | None = None + related_txid_paths: tuple[JsonPath, ...] = () + + +def _spec( + evidence_id: str, + event_type: LifecycleEventType, + step_id: str, + track_id: str, + state: TransactionLifecycleState, + explanation: str, + rpc_method: str, + *cli_arguments: str, + **values: object, +) -> LifecycleEventSpec: + return LifecycleEventSpec( + evidence_id=evidence_id, + event_type=event_type, + step_id=step_id, + track_id=track_id, + state=state, + explanation=explanation, + rpc_method=rpc_method, + cli_arguments=tuple(cli_arguments), + **values, + ) + + +def _branch_specs(branch: str, *, immediate: bool) -> tuple[LifecycleEventSpec, ...]: + prefix = f"treasury.{branch}" + if immediate: + partial_evidence = mature_evidence = "treasury.immediate" + premature_evidence = "treasury.immediate" + else: + partial_evidence = f"treasury.{branch}-partial" + premature_evidence = f"treasury.{branch}-premature" + mature_evidence = f"treasury.{branch}-mature" + + funded = _spec( + partial_evidence, + LifecycleEventType.TRANSACTION_FUNDED, + f"fund_{branch}", + prefix, + TransactionLifecycleState.FUNDED, + f"Bitcoin Core confirmed the policy output used by the {branch} branch.", + "sendtoaddress", + "-regtest", + "sendtoaddress", + "", + "", + transaction_id_paths=(("funding", "txid"),), + ) + psbt_created = _spec( + partial_evidence, + LifecycleEventType.PSBT_CREATED, + f"create_{branch}_psbt", + prefix, + TransactionLifecycleState.FUNDED, + f"The coordinator created and enriched the public {branch} branch PSBT.", + "createpsbt", + "-regtest", + "createpsbt", + "", + "", + psbt_ref=f"psbt.{branch}.unsigned", + ) + partial = _spec( + partial_evidence, + LifecycleEventType.PSBT_PARTIALLY_SIGNED, + f"sign_{branch}_one", + prefix, + TransactionLifecycleState.PARTIALLY_SIGNED, + f"One isolated {branch} signer added a partial signature below the 2-of-3 threshold.", + "walletprocesspsbt", + "-regtest", + "walletprocesspsbt", + f"", + psbt_ref=f"psbt.{branch}.partial", + ) + if immediate: + completion_evidence = "treasury.immediate" + completion_step = "sign_immediate_two" + finalized_step = "finalize_immediate" + preflight_step = "preflight_immediate" + else: + completion_evidence = premature_evidence + completion_step = f"sign_{branch}_two" + finalized_step = f"finalize_{branch}" + preflight_step = f"reject_premature_{branch}" + completed = _spec( + completion_evidence, + LifecycleEventType.PSBT_COMPLETED, + completion_step, + prefix, + TransactionLifecycleState.SIGNED, + f"A second {branch} signer satisfied the threshold without exposing private material.", + "walletprocesspsbt", + "-regtest", + "walletprocesspsbt", + f"", + psbt_ref=f"psbt.{branch}.threshold", + ) + finalized = _spec( + completion_evidence, + LifecycleEventType.TRANSACTION_FINALIZED, + finalized_step, + prefix, + TransactionLifecycleState.FINALIZED, + f"Bitcoin Core finalized the threshold-complete {branch} PSBT.", + "finalizepsbt", + "-regtest", + "finalizepsbt", + f"", + transaction_hex_ref=f"transaction.{branch}", + psbt_ref=f"psbt.{branch}.threshold", + ) + preflight = _spec( + completion_evidence, + LifecycleEventType.MEMPOOL_PREFLIGHT_COMPLETED, + preflight_step, + prefix, + TransactionLifecycleState.PREFLIGHTED, + ( + f"Core accepted the immediate {branch} spend before broadcast." + if immediate + else f"Core recorded the premature {branch} rejection before its relative delay." + ), + "testmempoolaccept", + "-regtest", + "testmempoolaccept", + f"[]", + transaction_hex_ref=f"transaction.{branch}", + ) + post_maturity: list[LifecycleEventSpec] = [] + if not immediate: + post_maturity.append( + _spec( + mature_evidence, + LifecycleEventType.TIMELOCK_MATURED, + f"advance_{branch}_delay", + prefix, + TransactionLifecycleState.TIMELOCK_MATURE, + f"The recorded chain height reached the {branch} branch's relative-delay target.", + "generatetoaddress", + "-regtest", + "generatetoaddress", + "", + "", + block_height_paths=(("mature_height",),), + ) + ) + post_maturity.append( + _spec( + mature_evidence, + LifecycleEventType.MEMPOOL_PREFLIGHT_COMPLETED, + f"preflight_mature_{branch}", + prefix, + TransactionLifecycleState.PREFLIGHTED, + f"Core accepted the unchanged {branch} transaction after maturity.", + "testmempoolaccept", + "-regtest", + "testmempoolaccept", + f"[]", + transaction_hex_ref=f"transaction.{branch}", + block_height_paths=(("mature_height",),), + ) + ) + broadcast_evidence = mature_evidence + txid_paths = (("txid",),) + broadcast = _spec( + broadcast_evidence, + LifecycleEventType.TRANSACTION_BROADCAST, + f"broadcast_{branch}", + prefix, + TransactionLifecycleState.BROADCAST, + f"The finalized {branch} transaction was broadcast on isolated regtest.", + "sendrawtransaction", + "-regtest", + "sendrawtransaction", + f"", + transaction_id_paths=txid_paths, + transaction_hex_ref=f"transaction.{branch}", + ) + mempool = _spec( + broadcast_evidence, + LifecycleEventType.TRANSACTION_ENTERED_MEMPOOL, + f"inspect_{branch}_mempool", + prefix, + TransactionLifecycleState.IN_MEMPOOL, + f"Bitcoin Core returned a mempool entry for the {branch} spend.", + "getmempoolentry", + "-regtest", + "getmempoolentry", + "", + transaction_id_paths=txid_paths, + fee_paths=(("mempool", "fees", "base"),), + ) + confirmed = _spec( + broadcast_evidence, + LifecycleEventType.TRANSACTION_CONFIRMED, + f"decode_{branch}", + prefix, + TransactionLifecycleState.CONFIRMED, + f"The {branch} spend confirmed and retained its recorded transaction identity.", + "gettransaction", + "-regtest", + "gettransaction", + "", + transaction_id_paths=txid_paths, + ) + return ( + funded, + psbt_created, + partial, + completed, + finalized, + preflight, + *post_maturity, + broadcast, + mempool, + confirmed, + ) + + +SCENARIO_EVENT_SPECS: dict[str, tuple[LifecycleEventSpec, ...]] = { + "transaction-lifecycle": ( + _spec("lifecycle.setup", LifecycleEventType.WALLET_PREPARED, "prepare_wallet", "transaction.normal", TransactionLifecycleState.WALLET_READY, "The session-owned wallet was loaded before transaction work began.", "listwallets", "-regtest", "listwallets"), + _spec("lifecycle.setup", LifecycleEventType.UTXO_SELECTED, "select_utxos", "transaction.normal", TransactionLifecycleState.INPUT_SELECTED, "Two fresh mature UTXOs were selected from the recorded listunspent result.", "listunspent", "-regtest", "listunspent", "101", "9999999"), + _spec("transaction.constructed", LifecycleEventType.RAW_TRANSACTION_CREATED, "construct_transaction", "transaction.normal", TransactionLifecycleState.DRAFT, "A raw transaction was created from the selected input and reviewed output amount.", "createrawtransaction", "-regtest", "createrawtransaction", "", "", transaction_id_paths=(("decoded", "txid"),), transaction_hex_ref="transaction.unsigned", locktime_paths=(("decoded", "locktime"),)), + _spec("transaction.constructed", LifecycleEventType.TRANSACTION_FINALIZED, "sign_transaction", "transaction.normal", TransactionLifecycleState.FINALIZED, "The wallet completely signed the raw transaction.", "signrawtransactionwithwallet", "-regtest", "signrawtransactionwithwallet", "", transaction_id_paths=(("decoded", "txid"),), transaction_hex_ref="transaction.signed"), + _spec("transaction.constructed", LifecycleEventType.MEMPOOL_PREFLIGHT_COMPLETED, "preflight_transaction", "transaction.normal", TransactionLifecycleState.PREFLIGHTED, "Core returned allowed=true before the transaction was broadcast.", "testmempoolaccept", "-regtest", "testmempoolaccept", "[]", transaction_id_paths=(("decoded", "txid"),), transaction_hex_ref="transaction.signed"), + _spec("transaction.mempool", LifecycleEventType.TRANSACTION_BROADCAST, "broadcast_transaction", "transaction.normal", TransactionLifecycleState.BROADCAST, "The signed transaction was broadcast to the isolated node.", "sendrawtransaction", "-regtest", "sendrawtransaction", "", transaction_id_paths=(("txid",),), transaction_hex_ref="transaction.signed"), + _spec("transaction.mempool", LifecycleEventType.TRANSACTION_ENTERED_MEMPOOL, "inspect_mempool", "transaction.normal", TransactionLifecycleState.IN_MEMPOOL, "Core returned a live mempool entry for the broadcast txid.", "getmempoolentry", "-regtest", "getmempoolentry", "", transaction_id_paths=(("txid",),), fee_paths=(("entry", "fees", "base"),)), + _spec("transaction.confirmed", LifecycleEventType.TRANSACTION_CONFIRMED, "decode_confirmed_transaction", "transaction.normal", TransactionLifecycleState.CONFIRMED, "The transaction confirmed and decoded to the recorded txid.", "gettransaction", "-regtest", "gettransaction", "", transaction_id_paths=(("txid",),), locktime_paths=(("decoded", "locktime"),)), + ), + "rbf-replacement": ( + _spec("rbf.setup", LifecycleEventType.WALLET_PREPARED, "prepare_wallet", "rbf.original", TransactionLifecycleState.WALLET_READY, "The session-owned wallet prepared mature funds for the replacement demonstration.", "listwallets", "-regtest", "listwallets"), + _spec("rbf.original", LifecycleEventType.RAW_TRANSACTION_CREATED, "create_original", "rbf.original", TransactionLifecycleState.DRAFT, "The wallet created an opt-in replaceable transaction.", "sendtoaddress", "-regtest", "sendtoaddress", "", "0.10000000", transaction_id_paths=(("txid",),), transaction_hex_ref="original.transaction", fee_rate_paths=(("fee_rate_sat_vb",),), sequence_paths=(("sequences",),)), + _spec("rbf.original", LifecycleEventType.TRANSACTION_ENTERED_MEMPOOL, "inspect_original_mempool", "rbf.original", TransactionLifecycleState.IN_MEMPOOL, "Core reported the original transaction in mempool with BIP125 replacement signaling.", "getmempoolentry", "-regtest", "getmempoolentry", "", transaction_id_paths=(("txid",),), fee_paths=(("mempool_entry", "fees", "base"),), fee_rate_paths=(("fee_rate_sat_vb",),), sequence_paths=(("sequences",),)), + _spec("rbf.replacement", LifecycleEventType.TRANSACTION_REPLACED, "replace_transaction", "rbf.replacement", TransactionLifecycleState.REPLACED, "A higher-fee transaction replaced the original and has a distinct txid.", "bumpfee", "-regtest", "bumpfee", "", transaction_id_paths=(("replacement_txid",),), fee_paths=(("bumpfee", "replacement_fee_btc"),), fee_rate_paths=(("requested_fee_rate_sat_vb",),), relationship_type=MempoolRelationshipType.REPLACES, related_txid_paths=(("original_txid",),)), + _spec("rbf.replacement", LifecycleEventType.TRANSACTION_ENTERED_MEMPOOL, "inspect_replacement_mempool", "rbf.replacement", TransactionLifecycleState.IN_MEMPOOL, "Core returned a live mempool entry for the replacement transaction.", "getmempoolentry", "-regtest", "getmempoolentry", "", transaction_id_paths=(("replacement_txid",),), fee_paths=(("replacement_mempool", "fees", "base"),), fee_rate_paths=(("requested_fee_rate_sat_vb",),)), + _spec("rbf.confirmed", LifecycleEventType.TRANSACTION_CONFIRMED, "decode_confirmed_replacement", "rbf.replacement", TransactionLifecycleState.CONFIRMED, "The replacement confirmed and decoded to the bumpfee txid.", "gettransaction", "-regtest", "gettransaction", "", transaction_id_paths=(("replacement_txid",),)), + ), + "multisig-psbt": ( + _spec("multisig.setup", LifecycleEventType.WALLET_PREPARED, "prepare_signer_wallets", "multisig.spend", TransactionLifecycleState.WALLET_READY, "The funding wallet and three signer contexts were prepared.", "createwallet", "-regtest", "createwallet", ""), + _spec("multisig.policy-funding", LifecycleEventType.TRANSACTION_FUNDED, "confirm_multisig_funding", "multisig.spend", TransactionLifecycleState.FUNDED, "The 2-of-3 policy output was funded and confirmed.", "sendtoaddress", "-regtest", "sendtoaddress", "", "0.50000000", transaction_id_paths=(("funding", "txid"),)), + _spec("psbt.unsigned", LifecycleEventType.PSBT_CREATED, "create_spend_psbt", "multisig.spend", TransactionLifecycleState.FUNDED, "A one-input unsigned PSBT was created for the confirmed policy output.", "walletcreatefundedpsbt", "-regtest", "walletcreatefundedpsbt", "", "", psbt_ref="psbt.unsigned"), + _spec("psbt.partial", LifecycleEventType.PSBT_PARTIALLY_SIGNED, "sign_with_one", "multisig.spend", TransactionLifecycleState.PARTIALLY_SIGNED, "One signer added exactly one partial signature.", "walletprocesspsbt", "-regtest", "walletprocesspsbt", "", psbt_ref="psbt.partial"), + _spec("psbt.complete", LifecycleEventType.PSBT_COMPLETED, "sign_with_second", "multisig.spend", TransactionLifecycleState.SIGNED, "The second signer satisfied the 2-of-3 threshold.", "walletprocesspsbt", "-regtest", "walletprocesspsbt", "", psbt_ref="psbt.threshold"), + _spec("psbt.complete", LifecycleEventType.TRANSACTION_FINALIZED, "finalize_psbt", "multisig.spend", TransactionLifecycleState.FINALIZED, "Core finalized and extracted the threshold-complete PSBT.", "finalizepsbt", "-regtest", "finalizepsbt", "", transaction_hex_ref="transaction.signed", psbt_ref="psbt.threshold"), + _spec("psbt.complete", LifecycleEventType.MEMPOOL_PREFLIGHT_COMPLETED, "preflight_spend", "multisig.spend", TransactionLifecycleState.PREFLIGHTED, "Core accepted the finalized multisig transaction before broadcast.", "testmempoolaccept", "-regtest", "testmempoolaccept", "[]", transaction_hex_ref="transaction.signed"), + _spec("multisig.confirmed", LifecycleEventType.TRANSACTION_BROADCAST, "broadcast_spend", "multisig.spend", TransactionLifecycleState.BROADCAST, "The multisig spend was broadcast.", "sendrawtransaction", "-regtest", "sendrawtransaction", "", transaction_id_paths=(("txid",),), transaction_hex_ref="transaction.signed"), + _spec("multisig.confirmed", LifecycleEventType.TRANSACTION_ENTERED_MEMPOOL, "inspect_spend_mempool", "multisig.spend", TransactionLifecycleState.IN_MEMPOOL, "Core returned the spend's mempool entry.", "getmempoolentry", "-regtest", "getmempoolentry", "", transaction_id_paths=(("txid",),), fee_paths=(("mempool_entry", "fees", "base"),)), + _spec("multisig.confirmed", LifecycleEventType.TRANSACTION_CONFIRMED, "decode_confirmed_spend", "multisig.spend", TransactionLifecycleState.CONFIRMED, "The multisig spend confirmed with a matching decoded txid.", "gettransaction", "-regtest", "gettransaction", "", transaction_id_paths=(("txid",),)), + ), + "cltv-timelock": ( + _spec("cltv.setup", LifecycleEventType.WALLET_PREPARED, "prepare_funding_wallet", "cltv.spend", TransactionLifecycleState.WALLET_READY, "The funding wallet and ephemeral signer were prepared.", "listwallets", "-regtest", "listwallets"), + _spec("cltv.policy-funding", LifecycleEventType.TRANSACTION_FUNDED, "confirm_cltv_funding", "cltv.spend", TransactionLifecycleState.FUNDED, "The P2WSH CLTV output was funded and confirmed.", "sendtoaddress", "-regtest", "sendtoaddress", "", "0.50000000", transaction_id_paths=(("funding", "txid"),), locktime_paths=(("policy", "lock_height"),)), + _spec("cltv.premature", LifecycleEventType.RAW_TRANSACTION_CREATED, "construct_premature_spend", "cltv.spend", TransactionLifecycleState.DRAFT, "The correctly signed CLTV spend was constructed with its committed lock height.", "decoderawtransaction", "-regtest", "decoderawtransaction", "", transaction_id_paths=(("spend", "decoded", "txid"),), transaction_hex_ref="spend.valid", locktime_paths=(("lock_height",),)), + _spec("cltv.premature", LifecycleEventType.MEMPOOL_PREFLIGHT_COMPLETED, "reject_premature_spend", "cltv.spend", TransactionLifecycleState.PREFLIGHTED, "Core recorded the non-final result while the chain remained below the lock height.", "testmempoolaccept", "-regtest", "testmempoolaccept", "[]", transaction_id_paths=(("spend", "decoded", "txid"),), transaction_hex_ref="spend.valid", locktime_paths=(("lock_height",),), block_height_paths=(("height",),)), + _spec("cltv.mature", LifecycleEventType.TIMELOCK_MATURED, "advance_to_maturity", "cltv.spend", TransactionLifecycleState.TIMELOCK_MATURE, "The recorded chain height reached the exact absolute CLTV target.", "generatetoaddress", "-regtest", "generatetoaddress", "", "", locktime_paths=(("lock_height",),), block_height_paths=(("mature_height",),)), + _spec("cltv.mature", LifecycleEventType.MEMPOOL_PREFLIGHT_COMPLETED, "accept_mature_spend", "cltv.spend", TransactionLifecycleState.PREFLIGHTED, "Core accepted the unchanged spend at maturity.", "testmempoolaccept", "-regtest", "testmempoolaccept", "[]", transaction_hex_ref="spend.valid", locktime_paths=(("lock_height",),), block_height_paths=(("mature_height",),)), + _spec("cltv.confirmed", LifecycleEventType.TRANSACTION_BROADCAST, "broadcast_mature_spend", "cltv.spend", TransactionLifecycleState.BROADCAST, "The mature CLTV spend was broadcast.", "sendrawtransaction", "-regtest", "sendrawtransaction", "", transaction_id_paths=(("txid",),), transaction_hex_ref="spend.valid"), + _spec("cltv.confirmed", LifecycleEventType.TRANSACTION_ENTERED_MEMPOOL, "inspect_spend_mempool", "cltv.spend", TransactionLifecycleState.IN_MEMPOOL, "Core returned a mempool entry for the mature spend.", "getmempoolentry", "-regtest", "getmempoolentry", "", transaction_id_paths=(("txid",),), fee_paths=(("mempool_entry", "fees", "base"),)), + _spec("cltv.confirmed", LifecycleEventType.TRANSACTION_CONFIRMED, "decode_confirmed_spend", "cltv.spend", TransactionLifecycleState.CONFIRMED, "The mature CLTV spend confirmed with its committed lock height.", "gettransaction", "-regtest", "gettransaction", "", transaction_id_paths=(("txid",),), locktime_paths=(("decoded", "locktime"),)), + ), + "community-treasury-recovery": ( + _spec("treasury.participants", LifecycleEventType.WALLET_PREPARED, "prepare_participants", "treasury.policy", TransactionLifecycleState.WALLET_READY, "The funding wallet, non-signing coordinator, and nine signer contexts were prepared.", "createwallet", "-regtest", "createwallet", ""), + *_branch_specs("immediate", immediate=True), + *_branch_specs("recovery", immediate=False), + *_branch_specs("emergency", immediate=False), + ), +} + + +class LifecycleRecorder: + """Normalize only explicitly mapped scenario evidence into typed lifecycle events.""" + + def __init__(self, redactor: EvidenceRedactor | None = None) -> None: + self.redactor = redactor or EvidenceRedactor() + + def record( + self, + run: ScenarioRun, + records: list[EvidenceRecord], + ) -> list[TransactionLifecycleEvent]: + records_by_id = {record.evidence_id: record for record in records} + events: list[TransactionLifecycleEvent] = [] + for spec in SCENARIO_EVENT_SPECS.get(run.scenario_id, ()): + record = records_by_id.get(spec.evidence_id) + if record is None or record.core_output is None: + continue + events.append(self._event(spec, record, len(events) + 1)) + return events + + def evidence( + self, + run: ScenarioRun, + events: list[TransactionLifecycleEvent], + captured_at: datetime, + ) -> EvidenceRecord: + return EvidenceRecord( + evidence_id="lifecycle.timeline", + kind="lifecycle", + label="Backend-recorded transaction lifecycle", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id="verify_results", + captured_at=captured_at, + core_output={ + "safe_parameters": [], + "result": [event.model_dump(mode="json") for event in events], + }, + bitscope_interpretation={ + "summary": "The backend normalized only events supported by persisted scenario evidence.", + "facts": [{"name": "lifecycle.event_count", "value": len(events), "run_specific": True}], + "limitations": [ + "Absent events remain absent; clients must not infer unrecorded transaction states.", + "CPFP relationships are rendered only when a recorded child event names its parent txid.", + ], + }, + ) + + def cleanup_evidence(self, run: ScenarioRun, captured_at: datetime, ordinal: int) -> EvidenceRecord: + event = TransactionLifecycleEvent( + event_id=f"lifecycle.{ordinal:03d}", + ordinal=ordinal, + event_type=LifecycleEventType.SCENARIO_CLEANED_UP, + timestamp=captured_at, + step_id="cleanup", + track_id="scenario.cleanup", + transaction_state=TransactionLifecycleState.CLEANED, + explanation="BitScope unloaded session-owned wallets and completed the recorded cleanup step.", + rpc_method="unloadwallet", + cli_command=SafeBitcoinCliCommand( + arguments=["-regtest", "unloadwallet", ""], + description="Unload each wallet owned by the completed lab session.", + ), + evidence_id="lifecycle.cleanup", + raw_safe_core_result={"cleanup_status": "completed"}, + ) + return EvidenceRecord( + evidence_id="lifecycle.cleanup", + kind="lifecycle", + label="Scenario cleanup lifecycle event", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id="cleanup", + captured_at=captured_at, + core_output={"safe_parameters": [], "result": event.model_dump(mode="json")}, + bitscope_interpretation={ + "summary": "The session-owned scenario resources completed cleanup.", + "facts": [{"name": "lifecycle.cleanup_completed", "value": True}], + "limitations": [], + }, + ) + + def timeline( + self, + run: ScenarioRun, + records: list[EvidenceRecord], + ) -> TransactionLifecycleTimeline: + events: list[TransactionLifecycleEvent] = [] + for record in records: + if record.evidence_id not in {"lifecycle.timeline", "lifecycle.cleanup"}: + continue + result = record.core_output.result if record.core_output is not None else None + documents = result if isinstance(result, list) else [result] + for document in documents: + if not isinstance(document, dict): + raise BitScopeError( + "LIFECYCLE_EVIDENCE_INVALID", + "Persisted lifecycle evidence is not a typed event document.", + 500, + {"evidence_id": record.evidence_id}, + ) + events.append(TransactionLifecycleEvent.model_validate(document)) + events.sort(key=lambda event: event.ordinal) + return TransactionLifecycleTimeline( + run_id=run.run_id, + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + lab_session_id=run.lab_session_id, + generated_at=run.updated_at, + events=events, + ) + + def child_transaction_event( + self, + *, + ordinal: int, + timestamp: datetime, + step_id: str, + track_id: str, + child_txid: str, + parent_txid: str, + evidence_id: str, + raw_safe_core_result: JsonValue, + ) -> TransactionLifecycleEvent: + return TransactionLifecycleEvent( + event_id=f"lifecycle.{ordinal:03d}", + ordinal=ordinal, + event_type=LifecycleEventType.CHILD_TRANSACTION_CREATED, + timestamp=timestamp, + step_id=step_id, + track_id=track_id, + transaction_state=TransactionLifecycleState.CHILD, + transaction_id=child_txid, + relationship=MempoolRelationship( + relationship_type=MempoolRelationshipType.CHILD_OF, + related_txid=parent_txid, + explanation="The recorded child spends an output of this parent transaction.", + ), + explanation="A CPFP child transaction was created from a recorded parent output.", + rpc_method="createrawtransaction", + cli_command=SafeBitcoinCliCommand( + arguments=["-regtest", "createrawtransaction", "", ""], + description="Create the recorded child transaction from its parent output.", + ), + evidence_id=evidence_id, + raw_safe_core_result=self._safe_raw(raw_safe_core_result), + ) + + def _event( + self, + spec: LifecycleEventSpec, + record: EvidenceRecord, + ordinal: int, + ) -> TransactionLifecycleEvent: + result = record.core_output.result if record.core_output is not None else None + transaction_id = self._txid(result, spec.transaction_id_paths) + related_txid = self._txid(result, spec.related_txid_paths) + relationship = None + if spec.relationship_type is not None and transaction_id is not None and related_txid is not None: + relationship = MempoolRelationship( + relationship_type=spec.relationship_type, + related_txid=related_txid, + explanation=( + "This transaction replaces the recorded original transaction." + if spec.relationship_type == MempoolRelationshipType.REPLACES + else "This transaction has a recorded mempool relationship." + ), + ) + return TransactionLifecycleEvent( + event_id=f"lifecycle.{ordinal:03d}", + ordinal=ordinal, + event_type=spec.event_type, + timestamp=record.captured_at, + step_id=spec.step_id, + track_id=spec.track_id, + transaction_state=spec.state, + transaction_id=transaction_id, + transaction_hex_ref=spec.transaction_hex_ref, + psbt_ref=spec.psbt_ref, + fee_btc=self._decimal(result, spec.fee_paths, places=8), + fee_rate_sat_vb=self._decimal(result, spec.fee_rate_paths, places=3), + locktime=self._integer(result, spec.locktime_paths), + sequence_values=self._sequences(result, spec.sequence_paths), + relationship=relationship, + block_height=self._integer(result, spec.block_height_paths), + explanation=spec.explanation, + rpc_method=spec.rpc_method, + cli_command=SafeBitcoinCliCommand( + arguments=list(spec.cli_arguments), + description=spec.explanation, + ), + evidence_id=record.evidence_id, + raw_safe_core_result=self._safe_raw(result), + ) + + @staticmethod + def _value(document: object, path: JsonPath) -> object: + current = document + for part in path: + if isinstance(part, str) and isinstance(current, dict): + current = current.get(part) + elif isinstance(part, int) and isinstance(current, list) and 0 <= part < len(current): + current = current[part] + else: + return None + return current + + @classmethod + def _txid(cls, document: object, paths: tuple[JsonPath, ...]) -> str | None: + for path in paths: + value = cls._value(document, path) + if ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdefABCDEF" for character in value) + ): + return value.lower() + return None + + @classmethod + def _integer(cls, document: object, paths: tuple[JsonPath, ...]) -> int | None: + for path in paths: + value = cls._value(document, path) + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + return None + + @classmethod + def _decimal( + cls, + document: object, + paths: tuple[JsonPath, ...], + *, + places: int, + ) -> Decimal | None: + quantum = Decimal(1).scaleb(-places) + for path in paths: + value = cls._value(document, path) + if isinstance(value, bool) or not isinstance(value, int | float | str | Decimal): + continue + try: + decimal = Decimal(str(value)) + except InvalidOperation: + continue + if decimal >= 0: + return decimal.quantize(quantum) + return None + + @classmethod + def _sequences(cls, document: object, paths: tuple[JsonPath, ...]) -> list[int]: + for path in paths: + value = cls._value(document, path) + if isinstance(value, list) and all( + isinstance(item, int) and not isinstance(item, bool) and 0 <= item <= 0xFFFFFFFF + for item in value + ): + return value + return [] + + def _safe_raw(self, value: JsonValue) -> JsonValue: + return self._bound(self.redactor.redact(value), depth=0) + + @classmethod + def _bound(cls, value: JsonValue, *, depth: int) -> JsonValue: + if depth >= 8: + return "[TRUNCATED]" + if isinstance(value, str): + return value[:8_192] + if isinstance(value, list): + return [cls._bound(item, depth=depth + 1) for item in value[:256]] + if isinstance(value, dict): + return { + str(key)[:120]: cls._bound(item, depth=depth + 1) + for key, item in list(value.items())[:256] + } + return value diff --git a/backend/app/services/multisig_psbt_scenario.py b/backend/app/services/multisig_psbt_scenario.py new file mode 100644 index 0000000..4831de4 --- /dev/null +++ b/backend/app/services/multisig_psbt_scenario.py @@ -0,0 +1,332 @@ +from app.models.scenario import ScenarioDefinition + + +MULTISIG_PSBT_SCENARIO = ScenarioDefinition.model_validate( + { + "scenario_id": "multisig-psbt", + "version": "1.0.0", + "name": "Multisig PSBT threshold", + "summary": ( + "Create a 2-of-3 multisig policy across three session-owned legacy signer wallets, prove that one " + "signature cannot finalize the PSBT, add the second signature, then preflight, broadcast, and confirm the spend." + ), + "difficulty": "intermediate", + "related_lbcli_chapters": [6, 7], + "concepts": [ + "2-of-3 multisig", + "PSBT", + "Signature threshold", + "Finalization", + "Legacy wallet compatibility", + ], + "required_capabilities": ["read_only", "wallet_read", "regtest_mutation"], + "estimated_run_steps": 22, + "steps": [ + { + "step_id": "verify_chain", + "type": "verify_runtime_chain", + "phase": "setup", + "title": "Verify the runtime chain", + "description": "Require the configured node and live Bitcoin Core chain to agree on regtest.", + "output_context_ref": "node.context", + }, + { + "step_id": "prepare_funding_wallet", + "type": "prepare_isolated_wallet", + "phase": "setup", + "title": "Prepare the funding wallet", + "description": "Use only the active wallet owned by this run's lab session for funding and mining.", + "depends_on": ["verify_chain"], + "wallet_role": "funder", + "output_wallet_ref": "wallet.funder", + }, + { + "step_id": "prepare_signer_wallets", + "type": "prepare_multisig_signers", + "phase": "setup", + "title": "Prepare three signer wallets", + "description": "Create three session-owned legacy wallets with one signer key context each.", + "depends_on": ["prepare_funding_wallet"], + "signer_count": 3, + "legacy_wallets": True, + "output_wallets_ref": "wallets.signers", + }, + { + "step_id": "generate_mining_address", + "type": "generate_address", + "phase": "setup", + "title": "Generate a mining address", + "description": "Generate a fresh funding-wallet address for maturity and confirmation blocks.", + "depends_on": ["prepare_signer_wallets"], + "wallet_ref": "wallet.funder", + "label": "bitscope-multisig-mining", + "address_type": "bech32", + "output_address_ref": "address.mining", + }, + { + "step_id": "mine_mature_funds", + "type": "mine_blocks", + "phase": "setup", + "title": "Mine mature funding", + "description": "Mine 101 blocks in bounded batches so the funding wallet can spend.", + "depends_on": ["generate_mining_address"], + "address_ref": "address.mining", + "blocks": 101, + "output_blocks_ref": "blocks.maturity", + }, + { + "step_id": "create_multisig", + "type": "create_multisig_address", + "phase": "setup", + "title": "Create the 2-of-3 policy", + "description": "Collect one public key from each signer wallet and register the same native SegWit script.", + "depends_on": ["mine_mature_funds"], + "signer_wallets_ref": "wallets.signers", + "required_signatures": 2, + "address_type": "bech32", + "output_multisig_ref": "multisig.policy", + }, + { + "step_id": "fund_multisig", + "type": "fund_multisig", + "phase": "setup", + "title": "Fund the multisig output", + "description": "Send 0.5 BTC to the fresh multisig address with an explicit 2 sat/vB fee rate.", + "depends_on": ["create_multisig"], + "wallet_ref": "wallet.funder", + "multisig_ref": "multisig.policy", + "amount_btc": "0.50000000", + "fee_rate_sat_vb": "2.000", + "output_txid_ref": "funding.txid", + }, + { + "step_id": "confirm_multisig_funding", + "type": "mine_confirmation_blocks", + "phase": "setup", + "title": "Confirm the funding output", + "description": "Mine one block so every registered signer wallet can select the multisig UTXO.", + "depends_on": ["fund_multisig"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.funding_confirmation", + }, + { + "step_id": "generate_destination", + "type": "generate_address", + "phase": "setup", + "title": "Generate the spend destination", + "description": "Generate a fresh funding-wallet destination for the multisig spend.", + "depends_on": ["confirm_multisig_funding"], + "wallet_ref": "wallet.funder", + "label": "bitscope-multisig-destination", + "address_type": "bech32", + "output_address_ref": "address.destination", + }, + { + "step_id": "create_spend_psbt", + "type": "create_multisig_psbt", + "phase": "execution", + "title": "Create the unsigned spend PSBT", + "description": "Spend the confirmed multisig UTXO to the destination without signing it.", + "depends_on": ["generate_destination"], + "signer_wallets_ref": "wallets.signers", + "multisig_ref": "multisig.policy", + "recipient_address_ref": "address.destination", + "amount_btc": "0.25000000", + "fee_rate_sat_vb": "2.000", + "output_psbt_ref": "psbt.unsigned", + }, + { + "step_id": "sign_with_one", + "type": "process_psbt", + "phase": "execution", + "title": "Add one signature", + "description": "Process the PSBT with only the first signer wallet.", + "depends_on": ["create_spend_psbt"], + "wallet_ref": "wallets.signers", + "psbt_ref": "psbt.unsigned", + "sign": True, + "finalize": False, + "output_psbt_ref": "psbt.partial", + "output_signature_count_ref": "signatures.partial_count", + }, + { + "step_id": "verify_incomplete", + "type": "finalize_psbt", + "phase": "attack", + "title": "Prove insufficient signatures", + "description": "Require finalization with one signature to remain incomplete and unextractable.", + "depends_on": ["sign_with_one"], + "psbt_ref": "psbt.partial", + "extract": False, + "output_psbt_ref": "psbt.partial_finalized", + }, + { + "step_id": "sign_with_second", + "type": "process_psbt", + "phase": "attack", + "title": "Reach the signature threshold", + "description": "Process the partial PSBT with the second signer wallet.", + "depends_on": ["verify_incomplete"], + "wallet_ref": "wallets.signers", + "psbt_ref": "psbt.partial", + "sign": True, + "finalize": False, + "output_psbt_ref": "psbt.threshold", + "output_signature_count_ref": "signatures.threshold_count", + }, + { + "step_id": "finalize_psbt", + "type": "finalize_psbt", + "phase": "attack", + "title": "Finalize and extract", + "description": "Finalize the threshold-complete PSBT and extract raw transaction hex.", + "depends_on": ["sign_with_second"], + "psbt_ref": "psbt.threshold", + "extract": True, + "output_transaction_ref": "transaction.signed", + }, + { + "step_id": "preflight_spend", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Preflight the multisig spend", + "description": "Require Bitcoin Core to accept the finalized transaction before broadcast.", + "depends_on": ["finalize_psbt"], + "transaction_ref": "transaction.signed", + "output_acceptance_ref": "acceptance.spend", + }, + { + "step_id": "broadcast_spend", + "type": "broadcast_transaction", + "phase": "attack", + "title": "Broadcast the multisig spend", + "description": "Broadcast only the preflighted, threshold-complete transaction.", + "depends_on": ["preflight_spend"], + "transaction_ref": "transaction.signed", + "output_txid_ref": "spend.txid", + }, + { + "step_id": "inspect_spend_mempool", + "type": "query_mempool_entry", + "phase": "attack", + "title": "Inspect the spend in mempool", + "description": "Record the broadcast multisig spend's mempool entry.", + "depends_on": ["broadcast_spend"], + "txid_ref": "spend.txid", + "output_mempool_ref": "spend.mempool", + }, + { + "step_id": "confirm_spend", + "type": "mine_confirmation_blocks", + "phase": "attack", + "title": "Confirm the multisig spend", + "description": "Mine one block containing the multisig spend.", + "depends_on": ["inspect_spend_mempool"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.spend_confirmation", + }, + { + "step_id": "decode_confirmed_spend", + "type": "decode_transaction", + "phase": "attack", + "title": "Decode the confirmed spend", + "description": "Read and decode the confirmed multisig transaction.", + "depends_on": ["confirm_spend"], + "transaction_ref": "transaction.signed", + "output_decoded_ref": "spend.confirmed", + }, + { + "step_id": "verify_results", + "type": "evaluate_assertions", + "phase": "verification", + "title": "Evaluate multisig assertions", + "description": "Evaluate incomplete and threshold-complete PSBT states plus transaction acceptance.", + "depends_on": ["decode_confirmed_spend"], + "assertion_ids": [ + "insufficient_signatures", + "partial_psbt_incomplete", + "threshold_not_met", + "threshold_met", + "psbt_complete", + "spend_accepted", + "spend_confirmed", + ], + }, + { + "step_id": "export_proof", + "type": "export_evidence", + "phase": "export", + "title": "Export the proof bundle", + "description": "Expose PSBT stages, assertions, Core output, commands, manifest, and ZIP export.", + "depends_on": ["verify_results"], + "output_bundle_ref": "proof.bundle", + }, + { + "step_id": "cleanup", + "type": "cleanup_lab", + "phase": "cleanup", + "title": "Clean up all session wallets", + "description": "Unload only the funding and signer wallets recorded as owned by this lab session.", + "depends_on": ["export_proof"], + }, + ], + "assertions": [ + { + "assertion_id": "insufficient_signatures", + "kind": "rpc_failed_with_category", + "after_step_id": "verify_incomplete", + "subject_ref": "psbt.partial_finalized", + "expected_category": "psbt_incomplete", + "description": "One signature cannot finalize or extract the 2-of-3 spend.", + }, + { + "assertion_id": "partial_psbt_incomplete", + "kind": "psbt_incomplete", + "after_step_id": "verify_incomplete", + "subject_ref": "psbt.partial_finalized", + "description": "Bitcoin Core reports the one-signature PSBT as incomplete.", + }, + { + "assertion_id": "threshold_not_met", + "kind": "signature_threshold_not_met", + "after_step_id": "verify_incomplete", + "subject_ref": "psbt.partial", + "required_signatures": 2, + "signature_count_ref": "signatures.partial_count", + "description": "The partial PSBT contains one signature, below the required threshold of two.", + }, + { + "assertion_id": "threshold_met", + "kind": "signature_threshold_met", + "after_step_id": "sign_with_second", + "subject_ref": "psbt.threshold", + "required_signatures": 2, + "signature_count_ref": "signatures.threshold_count", + "description": "The second signer raises the PSBT to the required threshold.", + }, + { + "assertion_id": "psbt_complete", + "kind": "psbt_complete", + "after_step_id": "finalize_psbt", + "subject_ref": "transaction.signed", + "description": "Bitcoin Core finalized and extracted the threshold-complete PSBT.", + }, + { + "assertion_id": "spend_accepted", + "kind": "mempool_policy_accepted", + "after_step_id": "preflight_spend", + "subject_ref": "acceptance.spend", + "description": "Bitcoin Core accepted the finalized multisig spend during preflight.", + }, + { + "assertion_id": "spend_confirmed", + "kind": "transaction_confirmed", + "after_step_id": "decode_confirmed_spend", + "subject_ref": "spend.confirmed", + "description": "The multisig spend has at least one confirmation and a matching decoded txid.", + }, + ], + } +) diff --git a/backend/app/services/multisig_psbt_scenario_service.py b/backend/app/services/multisig_psbt_scenario_service.py new file mode 100644 index 0000000..d1abe2a --- /dev/null +++ b/backend/app/services/multisig_psbt_scenario_service.py @@ -0,0 +1,914 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime + +from app.errors import BitScopeError +from app.models.attack import ( + AttackContext, + AttackFeature, + PsbtAttackObservation, +) +from app.models.evidence import EvidenceRecord +from app.models.lab import LabAction, LabSession +from app.models.scenario import ( + AssertionResult, + AssertionResultStatus, + FailureCategory, + ScenarioDefinition, + ScenarioFailure, + ScenarioRun, + ScenarioStepResult, + ScenarioStepResultStatus, +) +from app.rpc.capabilities import RegtestMutationRpcClient, RpcTransport +from app.services.lab_session_service import LabSessionService +from app.services.attack_verification_service import AttackVerificationService +from app.services.lab_session_store import LabSessionStore +from app.services.multisig_service import MultisigService +from app.services.network_safety import NetworkSafetyGuard +from app.services.psbt_service import PsbtService +from app.services.scenario_execution import ScenarioExecution, ScenarioExecutionError + + +class MultisigPsbtScenarioService: + """Prove one-signature incompleteness and 2-of-3 PSBT completion.""" + + def __init__(self, rpc_client: RpcTransport, lab_store: LabSessionStore) -> None: + self.rpc = RegtestMutationRpcClient(rpc_client) + self.multisig_service = MultisigService(rpc_client) + self.psbt_service = PsbtService(rpc_client) + self.lab_store = lab_store + self.attacks = AttackVerificationService() + + def execute(self, run: ScenarioRun, definition: ScenarioDefinition) -> ScenarioExecution: + captured_at = datetime.now(UTC) + current_step = "prepare_funding_wallet" + try: + session = self._active_session(run) + funding_wallet = session.wallet_name + + current_step = "prepare_signer_wallets" + signer_wallets = self._prepare_signer_wallets(session, 3) + + current_step = "generate_mining_address" + mining_address = self._require_string( + self._mutate( + "getnewaddress", + ["bitscope-multisig-mining", "bech32"], + funding_wallet, + ), + "getnewaddress", + ) + + current_step = "mine_mature_funds" + maturity_hashes = self._mine_blocks(101, mining_address) + + current_step = "create_multisig" + multisig = self.multisig_service.create_from_signer_wallets( + signer_wallets, + 2, + "bech32", + ) + multisig_address = self._require_string( + multisig.get("multisig_address"), + "addmultisigaddress", + ) + + current_step = "fund_multisig" + funding = self.multisig_service.fund( + funding_wallet, + multisig_address, + 0.5, + False, + 2.0, + ) + funding_txid = self._require_txid(funding.get("txid"), "sendtoaddress") + + current_step = "confirm_multisig_funding" + funding_confirmation_hashes = self._mine_blocks(1, mining_address) + + current_step = "generate_destination" + destination_address = self._require_string( + self._mutate( + "getnewaddress", + ["bitscope-multisig-destination", "bech32"], + funding_wallet, + ), + "getnewaddress", + ) + + current_step = "create_spend_psbt" + unsigned = self.multisig_service.create_spend_psbt( + signer_wallets[0], + multisig_address, + destination_address, + 0.25, + 2.0, + ) + if unsigned.get("input_count") != 1: + raise self._invalid_response( + "walletcreatefundedpsbt", + "The foundational multisig scenario requires exactly one fresh funding input.", + ) + unsigned_psbt = self._require_string( + unsigned.get("psbt"), + "walletcreatefundedpsbt", + ) + + attack_context = AttackContext( + scenario_id=run.scenario_id, + available_features=[AttackFeature.PSBT, AttackFeature.THRESHOLD_POLICY], + ) + signature_decision = self.attacks.require_applicable( + self.attacks.assess( + "multisig-psbt.signature-insufficiency", + attack_context, + ) + ) + incomplete_decision = self.attacks.require_applicable( + self.attacks.assess( + "multisig-psbt.psbt-incompleteness", + attack_context, + ) + ) + + current_step = "sign_with_one" + partial = self.psbt_service.process( + signer_wallets[0], + unsigned_psbt, + True, + False, + ) + partial_psbt = self._require_string(partial.get("psbt"), "walletprocesspsbt") + partial_signature_count = self._signature_count(partial) + signature_attack = self.attacks.require_expected( + self.attacks.verify( + signature_decision, + PsbtAttackObservation( + complete=( + partial.get("complete") + if isinstance(partial.get("complete"), bool) + else None + ), + transaction_hex_present=partial.get("hex") is not None, + signature_count=partial_signature_count, + raw_safe_details={ + "complete": partial.get("complete"), + "transaction_hex_present": partial.get("hex") is not None, + "signature_count": partial_signature_count, + "required_signature_count": 2, + }, + ), + ), + mismatch_code="SCENARIO_MULTISIG_PARTIAL_STATE_MISMATCH", + safe_message=( + "The first signer did not produce the expected one-signature incomplete PSBT." + ), + ) + + current_step = "verify_incomplete" + incomplete_finalization = self.psbt_service.finalize(partial_psbt, False) + incomplete_attack = self.attacks.require_expected( + self.attacks.verify( + incomplete_decision, + PsbtAttackObservation( + complete=( + incomplete_finalization.get("complete") + if isinstance(incomplete_finalization.get("complete"), bool) + else None + ), + transaction_hex_present=incomplete_finalization.get("hex") is not None, + signature_count=partial_signature_count, + raw_safe_details={ + "complete": incomplete_finalization.get("complete"), + "transaction_hex_present": incomplete_finalization.get("hex") is not None, + "signature_count": partial_signature_count, + }, + ), + ), + mismatch_code="SCENARIO_MULTISIG_INCOMPLETE_FINALIZATION_MISMATCH", + safe_message="Bitcoin Core did not preserve the expected incomplete PSBT state.", + ) + + current_step = "sign_with_second" + threshold = self.psbt_service.process( + signer_wallets[1], + partial_psbt, + True, + False, + ) + threshold_psbt = self._require_string(threshold.get("psbt"), "walletprocesspsbt") + threshold_signature_count = self._signature_count(threshold) + if threshold.get("complete") is not False or threshold_signature_count < 2: + raise BitScopeError( + "SCENARIO_MULTISIG_THRESHOLD_STATE_MISMATCH", + "The second signer did not produce the expected two-signature unfinalized PSBT.", + 409, + { + "observed_complete": threshold.get("complete"), + "observed_signature_count": threshold_signature_count, + }, + ) + + current_step = "finalize_psbt" + finalized = self.psbt_service.finalize(threshold_psbt, True) + if finalized.get("complete") is not True: + raise self._invalid_response("finalizepsbt", "Bitcoin Core did not finalize the complete PSBT.") + signed_hex = self._require_string(finalized.get("hex"), "finalizepsbt") + + current_step = "preflight_spend" + acceptance = self._single_acceptance( + self.rpc.call("testmempoolaccept", [[signed_hex]]) + ) + if acceptance.get("allowed") is not True: + raise BitScopeError( + "SCENARIO_MULTISIG_PREFLIGHT_REJECTED", + "Bitcoin Core rejected the threshold-complete multisig transaction during preflight.", + 409, + {"reject_reason": self._safe_reject_reason(acceptance)}, + ) + + current_step = "broadcast_spend" + spend_txid = self._require_txid( + self._mutate("sendrawtransaction", [signed_hex]), + "sendrawtransaction", + ) + + current_step = "inspect_spend_mempool" + spend_mempool = self._require_dict( + self.rpc.call("getmempoolentry", [spend_txid]), + "getmempoolentry", + ) + + current_step = "confirm_spend" + spend_confirmation_hashes = self._mine_blocks(1, mining_address) + + current_step = "decode_confirmed_spend" + confirmed_wallet_transaction = self._require_dict( + self.rpc.call("gettransaction", [spend_txid], wallet_name=signer_wallets[0]), + "gettransaction", + ) + confirmations = confirmed_wallet_transaction.get("confirmations") + if not isinstance(confirmations, int) or isinstance(confirmations, bool) or confirmations < 1: + raise self._invalid_response("gettransaction", "The multisig spend is not confirmed.") + confirmed_hex = self._require_string( + confirmed_wallet_transaction.get("hex"), + "gettransaction", + ) + decoded_confirmed = self._require_dict( + self.rpc.call("decoderawtransaction", [confirmed_hex]), + "decoderawtransaction", + ) + if decoded_confirmed.get("txid") != spend_txid: + raise self._invalid_response("decoderawtransaction", "The confirmed multisig txid did not match.") + + self._record_session_outputs( + session, + [mining_address, destination_address, multisig_address], + [funding_txid, spend_txid], + [ + *maturity_hashes, + *funding_confirmation_hashes, + *spend_confirmation_hashes, + ], + ) + except BitScopeError as exc: + raise ScenarioExecutionError(current_step, exc) from exc + + evidence_records = self._evidence_records( + run=run, + captured_at=captured_at, + funding_wallet=funding_wallet, + signer_wallets=signer_wallets, + mining_address=mining_address, + maturity_hashes=maturity_hashes, + multisig=multisig, + multisig_address=multisig_address, + funding=funding, + funding_confirmation_hashes=funding_confirmation_hashes, + destination_address=destination_address, + unsigned=unsigned, + partial=partial, + partial_psbt=partial_psbt, + partial_signature_count=partial_signature_count, + incomplete_finalization=incomplete_finalization, + threshold=threshold, + threshold_psbt=threshold_psbt, + threshold_signature_count=threshold_signature_count, + finalized=finalized, + signed_hex=signed_hex, + acceptance=acceptance, + spend_txid=spend_txid, + spend_mempool=spend_mempool, + spend_confirmation_hashes=spend_confirmation_hashes, + confirmed_wallet_transaction=confirmed_wallet_transaction, + decoded_confirmed=decoded_confirmed, + ) + return ScenarioExecution( + evidence_records=evidence_records, + step_results=self._step_results(captured_at), + assertion_results=self._assertion_results(), + attack_results=[signature_attack, incomplete_attack], + ) + + def cleanup(self, run: ScenarioRun) -> list[str]: + _, unloaded = LabSessionService(self.rpc.transport, self.lab_store).cleanup(run.lab_session_id) + return unloaded + + def failure_evidence( + self, + run: ScenarioRun, + step_id: str, + error: BitScopeError, + captured_at: datetime, + ) -> EvidenceRecord: + rpc_method = error.details.get("rpc_method") + rpc_code = error.details.get("rpc_code") + rpc_message = error.details.get("rpc_message") + observed_facts = [ + {"name": f"failure.{key}", "value": value} + for key, value in error.details.items() + if key.startswith("observed_") + and isinstance(value, bool | int | float | str) + ] + return EvidenceRecord( + evidence_id=f"failure.{step_id}", + kind="rpc_result", + label=f"Unexpected failure at {step_id}", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output={ + "rpc_method": rpc_method if isinstance(rpc_method, str) else None, + "safe_parameters": [], + "result": None, + "error": { + "code": rpc_code if isinstance(rpc_code, int | str) else error.code, + "message": rpc_message if isinstance(rpc_message, str) else error.message, + }, + }, + bitscope_interpretation={ + "summary": "The multisig PSBT scenario stopped on an unexpected failure.", + "facts": [ + {"name": "failure.category", "value": error.code}, + *observed_facts, + ], + "limitations": ["Only redacted, bounded error details are retained."], + }, + ) + + def _active_session(self, run: ScenarioRun) -> LabSession: + session = self.lab_store.get(run.lab_session_id) + if session is None: + raise BitScopeError("LAB_SESSION_NOT_FOUND", "The scenario's lab session does not exist.", 404) + if session.status != "active": + raise BitScopeError( + "LAB_SESSION_NOT_ACTIVE", + "The multisig PSBT scenario requires an active lab session.", + 409, + {"lab_session_id": run.lab_session_id, "status": session.status}, + ) + if session.wallet_name not in session.owned_wallets: + raise BitScopeError( + "LAB_WALLET_OWNERSHIP_VIOLATION", + "The funding wallet is not recorded as owned by this session.", + 409, + ) + loaded = self._require_list(self.rpc.call("listwallets"), "listwallets") + if session.wallet_name not in loaded: + raise BitScopeError( + "SCENARIO_WALLET_NOT_LOADED", + "The session funding wallet must be loaded before this scenario can run.", + 409, + {"wallet_name": session.wallet_name}, + ) + return session + + def _prepare_signer_wallets(self, session: LabSession, signer_count: int) -> list[str]: + prefix = f"bitscope-session-{session.session_id}" + first_generation = session.wallet_generation + 1 + wallet_names = [ + f"{prefix}-r{first_generation + index}" + for index in range(signer_count) + ] + if any(wallet_name in session.owned_wallets for wallet_name in wallet_names): + raise BitScopeError( + "SCENARIO_SIGNER_WALLET_CONFLICT", + "The planned signer-wallet namespace is already owned by this session.", + 409, + ) + session.owned_wallets.extend(wallet_names) + session.actions.append( + LabAction( + sequence=len(session.actions) + 1, + kind="multisig_signer_wallets_planned", + occurred_at=datetime.now(UTC), + details={"wallet_names": wallet_names}, + ) + ) + session.updated_at = datetime.now(UTC) + self.lab_store.save(session) + for wallet_name in wallet_names: + self._mutate( + "createwallet", + [wallet_name, False, False, "", False, False, False], + ) + return wallet_names + + def _mutate(self, method: str, params: object, wallet_name: str | None = None) -> object: + NetworkSafetyGuard(self.rpc).require_regtest() + return self.rpc.call(method, params, wallet_name=wallet_name) + + def _mine_blocks(self, blocks: int, address: str) -> list[str]: + hashes: list[str] = [] + remaining = blocks + while remaining: + batch = min(remaining, 20) + mined = self._require_list( + self._mutate("generatetoaddress", [batch, address]), + "generatetoaddress", + ) + if len(mined) != batch or any(not isinstance(item, str) or not item for item in mined): + raise self._invalid_response("generatetoaddress", "Bitcoin Core returned invalid block hashes.") + hashes.extend(mined) + remaining -= batch + return hashes + + @staticmethod + def _signature_count(processed: dict[str, object]) -> int: + decoded = processed.get("decoded") + raw = decoded.get("raw") if isinstance(decoded, dict) else None + document = raw.get("decodepsbt") if isinstance(raw, dict) else None + inputs = document.get("inputs") if isinstance(document, dict) else None + if not isinstance(inputs, list) or len(inputs) != 1 or not isinstance(inputs[0], dict): + raise MultisigPsbtScenarioService._invalid_response( + "decodepsbt", + "Bitcoin Core returned an invalid one-input PSBT decoding.", + ) + signatures = inputs[0].get("partial_signatures") + if signatures is None: + return 0 + if not isinstance(signatures, dict): + raise MultisigPsbtScenarioService._invalid_response( + "decodepsbt", + "Bitcoin Core returned invalid partial signature metadata.", + ) + return len(signatures) + + def _record_session_outputs( + self, + session: LabSession, + addresses: list[str], + txids: list[str], + block_hashes: list[str], + ) -> None: + session.created_addresses.extend(addresses) + session.transaction_ids.extend(txids) + session.block_hashes.extend(block_hashes) + session.actions.append( + LabAction( + sequence=len(session.actions) + 1, + kind="multisig_psbt_completed", + occurred_at=datetime.now(UTC), + details={"funding_txid": txids[0], "spend_txid": txids[1]}, + ) + ) + session.updated_at = datetime.now(UTC) + self.lab_store.save(session) + + def _evidence_records(self, **values: object) -> list[EvidenceRecord]: + run = values["run"] + captured_at = values["captured_at"] + funding_wallet = str(values["funding_wallet"]) + signer_wallets = values["signer_wallets"] + mining_address = str(values["mining_address"]) + multisig_address = str(values["multisig_address"]) + destination_address = str(values["destination_address"]) + partial_psbt = str(values["partial_psbt"]) + threshold_psbt = str(values["threshold_psbt"]) + signed_hex = str(values["signed_hex"]) + spend_txid = str(values["spend_txid"]) + + def record( + evidence_id: str, + kind: str, + label: str, + step_id: str, + rpc_method: str, + result: object, + summary: str, + commands: list[dict[str, object]], + run_paths: list[str], + ) -> EvidenceRecord: + return EvidenceRecord( + evidence_id=evidence_id, + kind=kind, + label=label, + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output={ + "rpc_method": rpc_method, + "safe_parameters": [], + "result": result, + "run_specific_paths": run_paths, + }, + bitscope_interpretation={ + "summary": summary, + "facts": [], + "limitations": [ + "All signer wallets are controlled by one local Bitcoin Core process and BitScope session; " + "this demonstrates threshold mechanics, not independent signer custody.", + "The signer wallets use the pinned legacy-BDB compatibility path and are regtest-only.", + ], + }, + commands=commands, + ) + + return [ + record( + "multisig.setup", + "lifecycle", + "Funding and signer wallet setup", + "prepare_signer_wallets", + "createwallet", + { + "funding_wallet": funding_wallet, + "signer_wallets": signer_wallets, + "mining_address": mining_address, + "maturity_block_hashes": values["maturity_hashes"], + }, + "Bitcoin Core created three session-owned legacy signer contexts and mature funding.", + [ + self._command( + [ + "-regtest", + "-named", + "createwallet", + "wallet_name=", + "descriptors=false", + ], + "Create each legacy signer wallet on a node with create_bdb compatibility enabled.", + ), + self._command( + ["-regtest", "generatetoaddress", "20", mining_address], + "Mine maturity blocks in bounded batches; repeat five times, then mine one more.", + ), + ], + [ + "$.result.funding_wallet", + "$.result.signer_wallets", + "$.result.mining_address", + "$.result.maturity_block_hashes", + ], + ), + record( + "multisig.policy-funding", + "transaction", + "2-of-3 policy and confirmed funding", + "confirm_multisig_funding", + "sendtoaddress", + { + "multisig": values["multisig"], + "funding": values["funding"], + "confirmation_block_hashes": values["funding_confirmation_hashes"], + }, + "Three distinct signer wallets registered one 2-of-3 script before its funding output was confirmed.", + [ + self._command( + ["-regtest", "createmultisig", "2", "[]", "bech32"], + "Create the reviewed 2-of-3 native SegWit script.", + ), + self._command( + [ + "-regtest", + "-rpcwallet=", + "importaddress", + multisig_address, + "bitscope-multisig-watch", + "false", + ], + "Register the policy output as watch-only before funding it.", + ), + self._command( + [ + "-regtest", + f"-rpcwallet={funding_wallet}", + "sendtoaddress", + multisig_address, + "0.50000000", + ], + "Fund the multisig address from the isolated funding wallet.", + ), + ], + ["$.result.multisig", "$.result.funding", "$.result.confirmation_block_hashes"], + ), + record( + "psbt.unsigned", + "psbt", + "Unsigned multisig spend PSBT", + "create_spend_psbt", + "walletcreatefundedpsbt", + values["unsigned"], + "The first signer wallet selected the confirmed multisig input without signing it.", + [ + self._command( + ["-regtest", "decodepsbt", str(values["unsigned"].get("psbt"))], + "Decode the unsigned multisig PSBT.", + ), + ], + ["$.result.psbt", "$.result.inputs"], + ), + record( + "psbt.partial", + "assertion", + "One-signature incomplete PSBT", + "verify_incomplete", + "finalizepsbt", + { + "processed": values["partial"], + "signature_count": values["partial_signature_count"], + "finalization": values["incomplete_finalization"], + }, + "One signer added exactly one partial signature, and Bitcoin Core refused to complete extraction.", + [ + self._command( + [ + "-regtest", + f"-rpcwallet={signer_wallets[0]}", + "walletprocesspsbt", + str(values["unsigned"].get("psbt")), + "true", + "ALL", + "true", + "false", + ], + "Add the first signer wallet's signature.", + ), + self._command( + ["-regtest", "finalizepsbt", partial_psbt, "false"], + "Prove that one signature cannot finalize the 2-of-3 PSBT.", + ), + ], + ["$.result.processed.psbt", "$.result.signature_count", "$.result.finalization"], + ), + record( + "psbt.complete", + "psbt", + "Threshold-complete finalized PSBT", + "preflight_spend", + "testmempoolaccept", + { + "processed": values["threshold"], + "signature_count": values["threshold_signature_count"], + "finalized": values["finalized"], + "testmempoolaccept": values["acceptance"], + }, + "The second signer supplied the threshold signatures; Core then finalized, extracted, and accepted the transaction.", + [ + self._command( + [ + "-regtest", + f"-rpcwallet={signer_wallets[1]}", + "walletprocesspsbt", + partial_psbt, + "true", + "ALL", + "true", + "false", + ], + "Add the second required signature.", + ), + self._command( + ["-regtest", "finalizepsbt", threshold_psbt, "true"], + "Finalize and extract the threshold-complete PSBT.", + ), + self._command( + ["-regtest", "testmempoolaccept", json.dumps([signed_hex], separators=(",", ":"))], + "Preflight the finalized multisig spend.", + ), + ], + ["$.result.processed.psbt", "$.result.signature_count", "$.result.finalized.hex"], + ), + record( + "multisig.confirmed", + "transaction", + "Confirmed multisig spend", + "decode_confirmed_spend", + "gettransaction", + { + "txid": spend_txid, + "destination_address": destination_address, + "mempool_entry": values["spend_mempool"], + "confirmation_block_hashes": values["spend_confirmation_hashes"], + "wallet_transaction": values["confirmed_wallet_transaction"], + "decoded": values["decoded_confirmed"], + }, + "The threshold-complete spend entered the mempool and confirmed with a matching decoded txid.", + [ + self._command( + ["-regtest", "sendrawtransaction", signed_hex], + "Broadcast the preflighted multisig spend.", + ), + self._command( + ["-regtest", "getmempoolentry", spend_txid], + "Inspect the broadcast spend in the mempool.", + ), + self._command( + ["-regtest", "generatetoaddress", "1", mining_address], + "Mine its confirmation block.", + ), + ], + [ + "$.result.txid", + "$.result.destination_address", + "$.result.mempool_entry", + "$.result.confirmation_block_hashes", + "$.result.wallet_transaction", + "$.result.decoded.txid", + ], + ), + ] + + @staticmethod + def _step_results(timestamp: datetime) -> list[ScenarioStepResult]: + completed: list[tuple[str, list[str], list[str]]] = [ + ("verify_chain", ["node.context"], ["node.context"]), + ("prepare_funding_wallet", ["wallet.funder"], ["multisig.setup"]), + ("prepare_signer_wallets", ["wallets.signers"], ["multisig.setup"]), + ("generate_mining_address", ["address.mining"], ["multisig.setup"]), + ("mine_mature_funds", ["blocks.maturity"], ["multisig.setup"]), + ("create_multisig", ["multisig.policy"], ["multisig.policy-funding"]), + ("fund_multisig", ["funding.txid"], ["multisig.policy-funding"]), + ( + "confirm_multisig_funding", + ["blocks.funding_confirmation"], + ["multisig.policy-funding"], + ), + ("generate_destination", ["address.destination"], ["multisig.policy-funding"]), + ("create_spend_psbt", ["psbt.unsigned"], ["psbt.unsigned"]), + ( + "sign_with_one", + ["psbt.partial", "signatures.partial_count"], + ["psbt.partial"], + ), + ] + results = [ + ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=outputs, + evidence_ids=evidence, + ) + for step_id, outputs, evidence in completed + ] + failure = ScenarioFailure( + failure_id="failure.insufficient-signatures", + step_id="verify_incomplete", + category=FailureCategory.PSBT_INCOMPLETE, + expected=True, + code="insufficient-signatures", + safe_message="Bitcoin Core kept the one-signature 2-of-3 PSBT incomplete and unextractable.", + evidence_ids=["psbt.partial"], + ) + results.append( + ScenarioStepResult( + step_id="verify_incomplete", + status=ScenarioStepResultStatus.EXPECTED_FAILURE, + started_at=timestamp, + completed_at=timestamp, + output_refs=["psbt.partial_finalized"], + evidence_ids=["psbt.partial"], + failure=failure, + ) + ) + for step_id, outputs, evidence in [ + ( + "sign_with_second", + ["psbt.threshold", "signatures.threshold_count"], + ["psbt.complete"], + ), + ("finalize_psbt", ["transaction.signed"], ["psbt.complete"]), + ("preflight_spend", ["acceptance.spend"], ["psbt.complete"]), + ("broadcast_spend", ["spend.txid"], ["multisig.confirmed"]), + ("inspect_spend_mempool", ["spend.mempool"], ["multisig.confirmed"]), + ("confirm_spend", ["blocks.spend_confirmation"], ["multisig.confirmed"]), + ("decode_confirmed_spend", ["spend.confirmed"], ["multisig.confirmed"]), + ]: + results.append( + ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=outputs, + evidence_ids=evidence, + ) + ) + return results + + @staticmethod + def _assertion_results() -> list[AssertionResult]: + evidence = { + "insufficient_signatures": ["psbt.partial"], + "partial_psbt_incomplete": ["psbt.partial"], + "threshold_not_met": ["psbt.partial"], + "threshold_met": ["psbt.complete"], + "psbt_complete": ["psbt.complete"], + "spend_accepted": ["psbt.complete"], + "spend_confirmed": ["multisig.confirmed"], + } + explanations = { + "insufficient_signatures": "One signature did not finalize or extract the 2-of-3 PSBT.", + "partial_psbt_incomplete": "Bitcoin Core reported complete=false after the first signer.", + "threshold_not_met": "The partial PSBT contained exactly one signature; two are required.", + "threshold_met": "The second signer raised the PSBT signature count to at least two.", + "psbt_complete": "Bitcoin Core finalized and extracted the threshold-complete PSBT.", + "spend_accepted": "Bitcoin Core returned allowed=true before broadcast.", + "spend_confirmed": "Bitcoin Core returned confirmations >= 1 and a matching decoded txid.", + } + return [ + AssertionResult( + assertion_id=assertion_id, + status=AssertionResultStatus.PASSED, + required=True, + expected_failure=assertion_id == "insufficient_signatures", + explanation=explanations[assertion_id], + evidence_ids=evidence[assertion_id], + ) + for assertion_id in explanations + ] + + @staticmethod + def _single_acceptance(value: object) -> dict[str, object]: + results = MultisigPsbtScenarioService._require_list(value, "testmempoolaccept") + if len(results) != 1 or not isinstance(results[0], dict) or not isinstance(results[0].get("allowed"), bool): + raise MultisigPsbtScenarioService._invalid_response( + "testmempoolaccept", + "Bitcoin Core returned an invalid preflight result.", + ) + return results[0] + + @staticmethod + def _safe_reject_reason(acceptance: dict[str, object]) -> str | None: + reason = acceptance.get("reject-reason") + return reason[:240] if isinstance(reason, str) and reason else None + + @staticmethod + def _require_txid(value: object, rpc_method: str) -> str: + txid = MultisigPsbtScenarioService._require_string(value, rpc_method) + if len(txid) != 64 or any(character not in "0123456789abcdefABCDEF" for character in txid): + raise MultisigPsbtScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid txid.", + ) + return txid + + @staticmethod + def _require_string(value: object, rpc_method: str) -> str: + if not isinstance(value, str) or not value: + raise MultisigPsbtScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid string response.", + ) + return value + + @staticmethod + def _require_dict(value: object, rpc_method: str) -> dict[str, object]: + if not isinstance(value, dict): + raise MultisigPsbtScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid object response.", + ) + return value + + @staticmethod + def _require_list(value: object, rpc_method: str) -> list[object]: + if not isinstance(value, list): + raise MultisigPsbtScenarioService._invalid_response( + rpc_method, + "Bitcoin Core returned an invalid list response.", + ) + return value + + @staticmethod + def _invalid_response(rpc_method: str, message: str) -> BitScopeError: + return BitScopeError( + "BITCOIN_CORE_INVALID_RESPONSE", + message, + 502, + {"rpc_method": rpc_method}, + ) + + @staticmethod + def _command(arguments: list[str], description: str) -> dict[str, object]: + return {"arguments": arguments, "description": description} diff --git a/backend/app/services/multisig_service.py b/backend/app/services/multisig_service.py index b7ab15b..92f1a7a 100644 --- a/backend/app/services/multisig_service.py +++ b/backend/app/services/multisig_service.py @@ -78,7 +78,146 @@ def create(self, wallet_name: str, required_signatures: int, signer_count: int, "raw": {"getaddressinfo": raw_address_info, "createmultisig": created, "addmultisigaddress": added}, } - def fund(self, wallet_name: str, multisig_address: str, amount_btc: float, mine_confirmation: bool) -> dict[str, object]: + def create_from_signer_wallets( + self, + wallet_names: list[str], + required_signatures: int, + address_type: str, + ) -> dict[str, object]: + """Create one multisig script with exactly one signer key from each wallet.""" + + NetworkSafetyGuard(self.rpc_client).require_regtest() + clean_wallets = [self._clean(wallet_name, "signer wallet name") for wallet_name in wallet_names] + if len(clean_wallets) < 2 or len(clean_wallets) > 15 or len(clean_wallets) != len(set(clean_wallets)): + raise BitScopeError( + code="INVALID_MULTISIG_REQUEST", + message="Provide between 2 and 15 distinct signer wallets.", + status_code=400, + ) + if required_signatures < 1 or required_signatures > len(clean_wallets): + raise BitScopeError( + code="INVALID_MULTISIG_REQUEST", + message="Required signatures must be between 1 and the signer count.", + status_code=400, + ) + clean_type = self._address_type(address_type) + source_addresses: list[str] = [] + pubkeys: list[str] = [] + address_info: dict[str, object] = {} + for index, wallet_name in enumerate(clean_wallets): + address = self._require_str( + self.rpc_client.call( + "getnewaddress", + [f"bitscope-multisig-signer-{index + 1}", "bech32"], + wallet_name=wallet_name, + ), + "getnewaddress", + "Bitcoin Core did not return a signer address.", + ) + info = self._as_dict( + self.rpc_client.call("getaddressinfo", [address], wallet_name=wallet_name) + ) + pubkey = self._require_str( + info.get("pubkey"), + "getaddressinfo", + "Bitcoin Core did not return a public key for a signer address.", + ) + source_addresses.append(address) + pubkeys.append(pubkey) + address_info[wallet_name] = info + + created = self._as_dict( + self.rpc_client.call("createmultisig", [required_signatures, pubkeys, clean_type]) + ) + registrations: dict[str, object] = {} + for wallet_name in clean_wallets: + registrations[wallet_name] = self._as_dict( + self.rpc_client.call( + "addmultisigaddress", + [required_signatures, pubkeys, "bitscope-multisig", clean_type], + wallet_name=wallet_name, + ) + ) + first_registration = self._as_dict(registrations[clean_wallets[0]]) + multisig_address = self._require_str( + first_registration.get("address") or created.get("address"), + "addmultisigaddress", + "Bitcoin Core did not return a multisig address.", + ) + registered_addresses = { + self._optional_str(self._as_dict(value).get("address")) + for value in registrations.values() + } + if registered_addresses != {multisig_address}: + raise BitScopeError( + code="BITCOIN_CORE_INVALID_RESPONSE", + message="Signer wallets did not register the same multisig address.", + status_code=502, + details={"rpc_method": "addmultisigaddress"}, + ) + watch_imports: dict[str, object] = {} + for wallet_name in clean_wallets: + watch_imports[wallet_name] = self.rpc_client.call( + "importaddress", + [multisig_address, "bitscope-multisig-watch", False], + wallet_name=wallet_name, + ) + + return { + "signer_wallets": clean_wallets, + "required_signatures": required_signatures, + "signer_count": len(clean_wallets), + "address_type": clean_type, + "source_addresses": source_addresses, + "pubkeys": pubkeys, + "multisig_address": multisig_address, + "redeem_script": self._optional_str( + first_registration.get("redeemScript") or created.get("redeemScript") + ), + "descriptor": self._optional_str( + first_registration.get("descriptor") or created.get("descriptor") + ), + "cli_commands": [ + "bitcoin-cli -rpcwallet= getnewaddress bitscope-multisig-signer-1 bech32", + "bitcoin-cli -rpcwallet= getaddressinfo ", + f"bitcoin-cli createmultisig {required_signatures} '[]' {clean_type}", + ( + f"bitcoin-cli -rpcwallet= addmultisigaddress {required_signatures} " + f"'[]' bitscope-multisig {clean_type}" + ), + ( + "bitcoin-cli -rpcwallet= importaddress " + f"{multisig_address} bitscope-multisig-watch false" + ), + ], + "rpc_methods": [ + "getnewaddress", + "getaddressinfo", + "createmultisig", + "addmultisigaddress", + "importaddress", + ], + "concepts": ["Multisig", "PSBT", "Signing threshold", "Legacy wallet"], + "explanation": ( + "Each session-owned legacy wallet contributes one public key and registers the same multisig script. " + "The wallets remain local simulation contexts, not independent external custodians." + ), + "raw": { + "getaddressinfo": address_info, + "createmultisig": created, + "addmultisigaddress": registrations, + "importaddress": watch_imports, + }, + } + + def fund( + self, + wallet_name: str, + multisig_address: str, + amount_btc: float, + mine_confirmation: bool, + fee_rate_sat_vb: float | None = None, + ) -> dict[str, object]: NetworkSafetyGuard(self.rpc_client).require_regtest() clean_wallet = self._clean(wallet_name, "wallet name") clean_address = self._clean(multisig_address, "multisig address") @@ -98,8 +237,29 @@ def fund(self, wallet_name: str, multisig_address: str, amount_btc: float, mine_ "Mine enough regtest blocks to this wallet so coinbase rewards reach 101 confirmations, then retry." ), ) + send_parameters: list[object] = [clean_address, amount] + if fee_rate_sat_vb is not None: + fee_rate = round(float(fee_rate_sat_vb), 3) + if fee_rate <= 0: + raise BitScopeError( + code="INVALID_MULTISIG_REQUEST", + message="The funding fee rate must be greater than zero.", + status_code=400, + ) + send_parameters = [ + clean_address, + amount, + "", + "", + False, + True, + None, + "unset", + None, + fee_rate, + ] txid = self._require_str( - self.rpc_client.call("sendtoaddress", [clean_address, amount], wallet_name=clean_wallet), + self.rpc_client.call("sendtoaddress", send_parameters, wallet_name=clean_wallet), "sendtoaddress", "Bitcoin Core did not return a multisig funding transaction id.", ) @@ -143,6 +303,109 @@ def fund(self, wallet_name: str, multisig_address: str, amount_btc: float, mine_ "raw": raw, } + def create_spend_psbt( + self, + wallet_name: str, + multisig_address: str, + destination_address: str, + amount_btc: float, + fee_rate_sat_vb: float, + ) -> dict[str, object]: + """Construct a multisig PSBT without signing or finalizing it.""" + + NetworkSafetyGuard(self.rpc_client).require_regtest() + clean_wallet = self._clean(wallet_name, "signer wallet name") + clean_multisig = self._clean(multisig_address, "multisig address") + clean_destination = self._clean(destination_address, "destination address") + amount = self._amount(amount_btc) + fee_rate = round(float(fee_rate_sat_vb), 3) + if fee_rate <= 0: + raise BitScopeError( + code="INVALID_MULTISIG_REQUEST", + message="The PSBT fee rate must be greater than zero.", + status_code=400, + ) + preflight = SpendPreflight(self.rpc_client) + multisig_validation = preflight.validate_address( + clean_multisig, + "INVALID_MULTISIG_ADDRESS", + "Provide a valid multisig address from the current regtest node before creating a PSBT spend.", + ) + destination_validation = preflight.validate_address( + clean_destination, + "INVALID_MULTISIG_DESTINATION_ADDRESS", + "Provide a valid destination address from the current regtest node before creating a multisig PSBT spend.", + ) + utxos_value = self.rpc_client.call( + "listunspent", + [1, 9_999_999, [clean_multisig]], + wallet_name=clean_wallet, + ) + utxos = [item for item in utxos_value if isinstance(item, dict)] if isinstance(utxos_value, list) else [] + inputs = [ + {"txid": item["txid"], "vout": item["vout"]} + for item in utxos + if isinstance(item.get("txid"), str) + and isinstance(item.get("vout"), int) + and not isinstance(item.get("vout"), bool) + ] + if not inputs: + raise BitScopeError( + code="MULTISIG_UTXO_NOT_FOUND", + message="Bitcoin Core did not find a confirmed wallet-known multisig output.", + status_code=404, + details={"multisig_address": clean_multisig}, + ) + options = { + "includeWatching": True, + "changeAddress": clean_multisig, + "fee_rate": fee_rate, + } + created = self._as_dict( + self.rpc_client.call( + "walletcreatefundedpsbt", + [inputs, [{clean_destination: amount}], 0, options, True], + wallet_name=clean_wallet, + ) + ) + psbt = self._require_str( + created.get("psbt"), + "walletcreatefundedpsbt", + "Bitcoin Core did not return a multisig spend PSBT.", + ) + return { + "wallet_name": clean_wallet, + "multisig_address": clean_multisig, + "destination_address": clean_destination, + "amount_btc": amount, + "input_count": len(inputs), + "inputs": inputs, + "psbt": psbt, + "fee_btc": self._optional_float(created.get("fee")), + "change_position": self._optional_int(created.get("changepos")), + "cli_commands": [ + f"bitcoin-cli -rpcwallet={clean_wallet} listunspent 1 9999999 '[\"{clean_multisig}\"]'", + ( + f"bitcoin-cli -rpcwallet={clean_wallet} walletcreatefundedpsbt '[]' " + f"'[{{\"{clean_destination}\":{amount:.8f}}}]' 0 " + f"'{{\"includeWatching\":true,\"changeAddress\":\"{clean_multisig}\"," + f"\"fee_rate\":{fee_rate:.3f}}}' true" + ), + ], + "rpc_methods": ["validateaddress", "listunspent", "walletcreatefundedpsbt"], + "concepts": ["Multisig", "PSBT", "Signing threshold", "Wallet UTXO"], + "explanation": ( + "The first signer wallet constructs an unsigned funded PSBT from the confirmed multisig output. " + "Signing and finalization remain separate explicit actions." + ), + "raw": { + "validate_multisig_address": multisig_validation, + "validate_destination_address": destination_validation, + "listunspent": utxos, + "walletcreatefundedpsbt": created, + }, + } + def spend_psbt(self, wallet_name: str, multisig_address: str, destination_address: str, amount_btc: float, extract: bool) -> dict[str, object]: NetworkSafetyGuard(self.rpc_client).require_regtest() clean_wallet = self._clean(wallet_name, "wallet name") diff --git a/backend/app/services/network_safety.py b/backend/app/services/network_safety.py index d222116..5124caa 100644 --- a/backend/app/services/network_safety.py +++ b/backend/app/services/network_safety.py @@ -3,7 +3,7 @@ from app.config import BitcoinNetwork from app.errors import BitScopeError -from app.rpc.client import BitcoinRpcClient +from app.rpc.capabilities import RpcTransport RuntimeChain = Literal["main", "test", "signet", "regtest"] @@ -37,10 +37,14 @@ def matches_configuration(self) -> bool: class NetworkSafetyGuard: """Fail-closed safety checks based on Bitcoin Core's live chain identity.""" - def __init__(self, rpc_client: BitcoinRpcClient) -> None: + def __init__(self, rpc_client: RpcTransport) -> None: self.rpc_client = rpc_client def get_context(self) -> ChainContext: + context, _ = self.get_context_with_info() + return context + + def get_context_with_info(self) -> tuple[ChainContext, dict[str, object]]: info = self.rpc_client.call("getblockchaininfo") if not isinstance(info, dict): raise self._invalid_chain_response() @@ -65,10 +69,14 @@ def get_context(self) -> ChainContext: "runtime_chain": context.runtime_chain, }, ) - return context + return context, info def require_regtest(self) -> ChainContext: - context = self.get_context() + context, _ = self.require_regtest_with_info() + return context + + def require_regtest_with_info(self) -> tuple[ChainContext, dict[str, object]]: + context, info = self.get_context_with_info() if context.runtime_chain != "regtest": raise BitScopeError( code="REGTEST_ONLY", @@ -80,7 +88,7 @@ def require_regtest(self) -> ChainContext: "runtime_chain": context.runtime_chain, }, ) - return context + return context, info def require_read_only_network(self) -> ChainContext: return self.get_context() diff --git a/backend/app/services/proof_bundle_service.py b/backend/app/services/proof_bundle_service.py new file mode 100644 index 0000000..128a882 --- /dev/null +++ b/backend/app/services/proof_bundle_service.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +import io +import json +import re +import shlex +from hashlib import sha256 +from pathlib import PurePosixPath +from typing import Literal +from uuid import UUID +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +from app.errors import BitScopeError +from app.models.evidence import EvidenceRecord +from app.models.lifecycle import TransactionLifecycleTimeline +from app.models.proof import ( + ProofBundle, + ProofFileManifestEntry, + ProofManifest, + ScenarioEvidenceResponse, + SpendabilityCheckStatus, + TreasuryProofOfSpendability, + TreasuryProofPolicy, + TreasurySpendabilityCheck, +) +from app.models.scenario import ( + AssertionResultStatus, + CleanupStatus, + EvidenceKind, + ScenarioDefinition, + ScenarioFailure, + ScenarioFinalResult, + ScenarioRun, +) +from app.models.treasury import MaterializedTreasuryPolicy +from app.services.evidence_service import EvidenceRedactor +from app.services.lifecycle_recorder import LifecycleRecorder +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import ScenarioCatalog +from app.services.scenario_run_store import ScenarioRunStore + + +JSON_MEDIA_TYPE = "application/json" +MARKDOWN_MEDIA_TYPE = "text/markdown; charset=utf-8" +SHELL_MEDIA_TYPE = "text/x-shellscript; charset=utf-8" +ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) + + +class ProofBundleService: + """Read verified artifacts and render deterministic, ownership-scoped exports.""" + + def __init__( + self, + run_store: ScenarioRunStore, + artifact_store: ScenarioArtifactStore, + catalog: ScenarioCatalog, + redactor: EvidenceRedactor | None = None, + max_bundle_bytes: int = 10_485_760, + ) -> None: + if max_bundle_bytes < 1_024: + raise ValueError("Proof bundle limits must be at least 1024 bytes.") + self.run_store = run_store + self.artifact_store = artifact_store + self.catalog = catalog + self.redactor = redactor or EvidenceRedactor() + self.max_bundle_bytes = max_bundle_bytes + self.lifecycle_recorder = LifecycleRecorder(self.redactor) + + def evidence(self, run_id: UUID, lab_session_id: str) -> ScenarioEvidenceResponse: + run = self._get_run(run_id, lab_session_id) + return ScenarioEvidenceResponse( + run_id=run.run_id, + revision=run.revision, + evidence=self.artifact_store.list_evidence(run, self.max_bundle_bytes), + ) + + def report(self, run_id: UUID, lab_session_id: str) -> str: + run = self._get_run(run_id, lab_session_id) + definition = self.catalog.get_version(run.scenario_id, run.scenario_version) + records = self.artifact_store.list_evidence(run, self.max_bundle_bytes) + safe_run = self._redact_run(run) + proof = self._proof_of_spendability(safe_run, records) + if proof is not None: + return self._render_proof_of_spendability(proof) + return self._render_report(definition, safe_run, records) + + def lifecycle(self, run_id: UUID, lab_session_id: str) -> TransactionLifecycleTimeline: + run = self._get_run(run_id, lab_session_id) + records = self.artifact_store.list_evidence(run, self.max_bundle_bytes) + return self.lifecycle_recorder.timeline(self._redact_run(run), records) + + def bundle(self, run_id: UUID, lab_session_id: str) -> ProofBundle: + run = self._get_run(run_id, lab_session_id) + definition = self.catalog.get_version(run.scenario_id, run.scenario_version) + records = self.artifact_store.list_evidence(run, self.max_bundle_bytes) + safe_run = self._redact_run(run) + proof = self._proof_of_spendability(safe_run, records) + report = ( + self._render_proof_of_spendability(proof) + if proof is not None + else self._render_report(definition, safe_run, records) + ) + files = self._bundle_files(definition, safe_run, records, report, proof) + self._require_bundle_size(files) + + entries = [ + ProofFileManifestEntry( + path=path, + content_sha256=sha256(content).hexdigest(), + content_bytes=len(content), + media_type=self._media_type(path), + ) + for path, content in sorted(files.items()) + ] + manifest = ProofManifest( + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + generated_from_revision=run.revision, + generated_at=run.updated_at, + run_state=run.current_state, + final_result=run.final_result, + files=entries, + ) + files["manifest.json"] = self._canonical_json(manifest.model_dump(mode="json")) + self._require_bundle_size(files) + ordered_files = dict(sorted(files.items())) + zip_bytes = self._build_zip(ordered_files) + if len(zip_bytes) > self.max_bundle_bytes: + raise self._bundle_too_large(len(zip_bytes)) + return ProofBundle( + manifest=manifest, + report_markdown=report, + proof_of_spendability=proof, + files=ordered_files, + zip_bytes=zip_bytes, + ) + + def _redact_run(self, run: ScenarioRun) -> ScenarioRun: + document = run.model_dump(mode="json") + for result in document["step_results"]: + failure = result.get("failure") + if isinstance(failure, dict): + failure["safe_message"] = self.redactor.redact(failure["safe_message"]) + for result in document["assertion_results"]: + result["explanation"] = self.redactor.redact(result["explanation"]) + for collection in ("expected_failures", "unexpected_failures"): + for failure in document[collection]: + failure["safe_message"] = self.redactor.redact(failure["safe_message"]) + failure["raw_safe_details"] = self.redactor.redact( + failure.get("raw_safe_details") + ) + for reference in document["evidence"]: + reference["label"] = self.redactor.redact(reference["label"]) + return ScenarioRun.model_validate(document) + + def _get_run(self, run_id: UUID, lab_session_id: str) -> ScenarioRun: + run = self.run_store.get_for_session(run_id, lab_session_id) + if run is None: + raise BitScopeError( + code="SCENARIO_RUN_NOT_FOUND", + message="The requested scenario run does not exist.", + status_code=404, + details={"run_id": str(run_id)}, + ) + return run + + def _bundle_files( + self, + definition: ScenarioDefinition, + run: ScenarioRun, + records: list[EvidenceRecord], + report: str, + proof_of_spendability: TreasuryProofOfSpendability | None, + ) -> dict[str, bytes]: + files: dict[str, bytes] = { + "scenario.json": self._canonical_json(definition.model_dump(mode="json")), + "run.json": self._canonical_json(run.model_dump(mode="json")), + "report.md": report.encode("utf-8"), + } + if proof_of_spendability is not None: + files["proof-of-spendability.json"] = self._canonical_json( + proof_of_spendability.model_dump(mode="json") + ) + lifecycle = self.lifecycle_recorder.timeline(run, records) + if lifecycle.events: + files["lifecycle.json"] = self._canonical_json( + lifecycle.model_dump(mode="json") + ) + for record in records: + path = f"evidence/{record.evidence_id}.json" + self._require_safe_bundle_path(path) + files[path] = self._canonical_json(record.model_dump(mode="json")) + + commands = [command for record in records for command in record.commands] + if commands: + rendered = ["#!/usr/bin/env sh", "set -eu", ""] + for command in commands: + rendered.append(f"# {command.description}") + rendered.append(shlex.join([command.executable, *command.arguments])) + files["commands.sh"] = ("\n".join(rendered) + "\n").encode("utf-8") + + rpc_records = [record for record in records if record.core_output is not None] + if rpc_records: + files["rpc-transcript.json"] = self._canonical_json( + [ + { + "evidence_id": record.evidence_id, + "captured_at": record.captured_at.isoformat(), + "core_output": record.core_output.model_dump(mode="json"), + } + for record in rpc_records + ] + ) + + node_records = [record for record in records if record.kind == EvidenceKind.NODE_CONTEXT] + if node_records: + files["node-context.json"] = self._canonical_json( + [record.model_dump(mode="json") for record in node_records] + ) + + assertion_records = [record for record in records if record.kind == EvidenceKind.ASSERTION] + if run.assertion_results or assertion_records: + files["assertions.json"] = self._canonical_json( + { + "results": [result.model_dump(mode="json") for result in run.assertion_results], + "evidence": [record.model_dump(mode="json") for record in assertion_records], + } + ) + return files + + def _proof_of_spendability( + self, + run: ScenarioRun, + records: list[EvidenceRecord], + ) -> TreasuryProofOfSpendability | None: + if run.scenario_id != "community-treasury-recovery": + return None + + assertions = {result.assertion_id: result for result in run.assertion_results} + failures = {failure.code: failure for failure in run.expected_failures} + + def check( + check_id: str, + label: str, + assertion_ids: tuple[str, ...], + expected_failure_code: str | None = None, + ) -> TreasurySpendabilityCheck: + results = [assertions.get(assertion_id) for assertion_id in assertion_ids] + assertions_passed = all( + result is not None and result.status == AssertionResultStatus.PASSED + for result in results + ) + failure_observed = expected_failure_code is None or expected_failure_code in failures + passed = assertions_passed and failure_observed + if not passed: + status = SpendabilityCheckStatus.FAIL + elif expected_failure_code is not None: + status = SpendabilityCheckStatus.REJECTED_AS_EXPECTED + else: + status = SpendabilityCheckStatus.PASS + evidence_ids = sorted( + { + evidence_id + for result in results + if result is not None + for evidence_id in result.evidence_ids + } + ) + if expected_failure_code is not None and expected_failure_code in failures: + evidence_ids = sorted( + set(evidence_ids) | set(failures[expected_failure_code].evidence_ids) + ) + return TreasurySpendabilityCheck( + check_id=check_id, + label=label, + status=status, + assertion_ids=list(assertion_ids), + expected_failure_code=expected_failure_code, + evidence_ids=evidence_ids, + ) + + checks = [ + check( + "immediate.spend", + "Normal 2-of-3 operator spend", + ("immediate_threshold_met", "immediate_accepted", "immediate_confirmed"), + ), + check( + "immediate.insufficient-signatures", + "Insufficient operator signature attempt", + ("immediate_insufficient", "immediate_psbt_incomplete", "immediate_threshold_not_met"), + "insufficient-immediate-signatures", + ), + check( + "recovery.insufficient-signatures", + "Insufficient recovery signature attempt", + ("recovery_insufficient", "recovery_psbt_incomplete", "recovery_threshold_not_met"), + "insufficient-recovery-signatures", + ), + check( + "recovery.premature", + "Premature recovery attempt", + ("premature_recovery_rejected", "recovery_timelock_immature"), + "non-BIP68-final", + ), + check( + "recovery.incorrect-sequence", + "Incorrect recovery sequence", + ("wrong_sequence_incomplete",), + "incorrect-sequence-incomplete", + ), + check( + "recovery.mature-spend", + "Mature recovery path", + ( + "recovery_threshold_met", + "recovery_timelock_mature", + "recovery_accepted", + "recovery_confirmed", + ), + ), + check( + "emergency.insufficient-signatures", + "Insufficient emergency signature attempt", + ("emergency_insufficient", "emergency_psbt_incomplete", "emergency_threshold_not_met"), + "insufficient-emergency-signatures", + ), + check( + "emergency.premature", + "Premature emergency attempt", + ("premature_emergency_rejected", "emergency_timelock_immature"), + "non-BIP68-final-emergency", + ), + check( + "emergency.mature-spend", + "Mature emergency path", + ( + "emergency_threshold_met", + "emergency_timelock_mature", + "emergency_accepted", + "emergency_confirmed", + ), + ), + ] + cleanup_passed = run.cleanup_status == CleanupStatus.COMPLETED + checks.append( + TreasurySpendabilityCheck( + check_id="cleanup", + label="Session-owned cleanup", + status=( + SpendabilityCheckStatus.PASS + if cleanup_passed + else SpendabilityCheckStatus.FAIL + ), + ) + ) + + materialized = self._materialized_treasury_policy(records) + policy = None + if materialized is not None: + policy = TreasuryProofPolicy( + descriptor=materialized.normalized_descriptor, + address=materialized.address, + recovery_delay_blocks=materialized.policy.recovery_delay_blocks, + emergency_delay_blocks=materialized.policy.emergency_delay_blocks, + decision_tree=materialized.decision_tree, + ) + core_compatible = self._is_core_28_1(run.bitcoin_core_version) + all_checks_passed = all(check.status != SpendabilityCheckStatus.FAIL for check in checks) + verified = ( + run.final_result == ScenarioFinalResult.VERIFIED + and cleanup_passed + and all_checks_passed + and core_compatible + and policy is not None + ) + if verified: + result: Literal["VERIFIED", "INCOMPLETE", "FAILED"] = "VERIFIED" + elif run.final_result in {ScenarioFinalResult.FAILED, ScenarioFinalResult.CLEANUP_FAILED}: + result = "FAILED" + else: + result = "INCOMPLETE" + + return TreasuryProofOfSpendability( + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + generated_at=run.updated_at, + result=result, + bitcoin_core_version=run.bitcoin_core_version, + bitcoin_core_compatibility="verified" if core_compatible else "unverified", + policy=policy, + checks=checks, + cleanup_status=run.cleanup_status.value, + evidence_ids=sorted(record.evidence_id for record in records), + limitations=[ + "All participant wallets are controlled by one local Bitcoin Core process and one BitScope lab session.", + "The proof demonstrates regtest policy spendability, not independent custody, hardware-wallet isolation, or production safety.", + "The five-block and ten-block delays are bounded demonstration values, not production recommendations.", + "This report is reproducible evidence, not a signature, audit, attestation, or spend approval.", + ], + ) + + @staticmethod + def _materialized_treasury_policy( + records: list[EvidenceRecord], + ) -> MaterializedTreasuryPolicy | None: + record = next( + (record for record in records if record.evidence_id == "treasury.policy"), + None, + ) + result = record.core_output.result if record is not None and record.core_output is not None else None + policy = result.get("policy") if isinstance(result, dict) else None + if not isinstance(policy, dict): + return None + try: + return MaterializedTreasuryPolicy.model_validate(policy) + except ValueError: + return None + + @staticmethod + def _is_core_28_1(version: str | None) -> bool: + return isinstance(version, str) and ( + version == "280100" + or re.fullmatch(r"/Satoshi:28\.1(?:\.0)?/", version) is not None + ) + + @staticmethod + def _render_proof_of_spendability(proof: TreasuryProofOfSpendability) -> str: + lines = [ + "# Proof of Spendability: Community Treasury Recovery", + "", + f"Scenario: {proof.scenario}", + f"Result: {proof.result}", + "", + f"Runtime network: {proof.runtime_network}", + f"Bitcoin Core: {proof.bitcoin_core_version or 'unknown'}", + f"Bitcoin Core compatibility: {proof.bitcoin_core_compatibility}", + "", + "## Policy", + "", + ] + if proof.policy is None: + lines.append("The public treasury policy was not available in the captured evidence.") + else: + lines.extend( + [ + f"- Script type: `{proof.policy.script_type}`", + f"- Treasury address: `{proof.policy.address}`", + f"- Recovery delay: `{proof.policy.recovery_delay_blocks}` blocks", + f"- Emergency delay: `{proof.policy.emergency_delay_blocks}` blocks", + f"- Public descriptor: `{proof.policy.descriptor}`", + "", + "### Decision tree", + "", + proof.policy.decision_tree.root_label, + *[ + ( + f"- {branch.path.value}: {branch.required_signatures}-of-{len(branch.participant_ids)}" + + ( + f" after {branch.relative_delay_blocks} blocks" + if branch.relative_delay_blocks is not None + else " immediately" + ) + ) + for branch in proof.policy.decision_tree.branches + ], + ] + ) + lines.extend(["", "## Spendability checks", ""]) + lines.extend( + f"- {check.label}: **{check.status.value.replace('_', ' ')}**" + for check in proof.checks + ) + lines.extend( + [ + "", + "## Cleanup", + "", + f"Cleanup: **{proof.cleanup_status.upper().replace('_', ' ')}**", + "", + "## Educational signer model and limitations", + "", + f"Signer model: {proof.signer_model}.", + *[f"- {limitation}" for limitation in proof.limitations], + "", + "## Evidence inventory", + "", + *[f"- `{evidence_id}`" for evidence_id in proof.evidence_ids], + "", + ] + ) + return "\n".join(lines) + + @classmethod + def _render_report( + cls, + definition: ScenarioDefinition, + run: ScenarioRun, + records: list[EvidenceRecord], + ) -> str: + overall = run.final_result.value.upper().replace("_", " ") if run.final_result else run.current_state.value.upper() + lines = [ + f"# BitScope proof report: {definition.name}", + "", + "## Scenario objective", + "", + definition.summary, + "", + "## Runtime context", + "", + f"- Scenario: `{run.scenario_id}` version `{run.scenario_version}`", + f"- Run: `{run.run_id}`", + f"- Lab session: `{run.lab_session_id}`", + f"- Runtime network: `{run.runtime_chain}`", + f"- Bitcoin Core: `{run.bitcoin_core_version or 'unknown'}`", + f"- Run revision: `{run.revision}`", + "", + "## Actions performed", + "", + ] + if run.step_results: + lines.extend( + f"- `{result.step_id}`: **{result.status.value.upper().replace('_', ' ')}**" + for result in run.step_results + ) + else: + lines.append("No scenario steps have been recorded.") + + lines.extend(["", "## Assertions", ""]) + if run.assertion_results: + lines.extend( + f"- `{result.assertion_id}`: **{result.status.value.upper()}** - {result.explanation}" + for result in run.assertion_results + ) + else: + lines.append("No assertions have been recorded.") + + lines.extend(cls._failure_section("Expected failures", run.expected_failures)) + lines.extend(cls._failure_section("Unexpected failures", run.unexpected_failures)) + lines.extend(["", "## Bitcoin Core output", ""]) + core_records = [record for record in records if record.core_output is not None] + if core_records: + lines.extend( + f"- `{record.evidence_id}`: RPC `{record.core_output.rpc_method or 'not applicable'}`; " + "see the redacted RPC transcript and evidence record." + for record in core_records + ) + else: + lines.append("No Bitcoin Core output has been captured.") + + lines.extend(["", "## BitScope interpretation", ""]) + if records: + lines.extend( + f"- `{record.evidence_id}`: {record.bitscope_interpretation.summary}" + for record in records + ) + else: + lines.append("No BitScope interpretation has been captured.") + + transaction_records = [ + record for record in records if record.kind in {EvidenceKind.TRANSACTION, EvidenceKind.PSBT} + ] + lines.extend(["", "## Transaction, script, timelock, and mempool summary", ""]) + if transaction_records: + lines.extend(f"- `{record.evidence_id}`: {record.label}" for record in transaction_records) + else: + lines.append("No transaction-specific evidence has been captured.") + + lines.extend( + [ + "", + "## Cleanup result", + "", + f"- Cleanup status: **{run.cleanup_status.value.upper().replace('_', ' ')}**", + "", + "## Overall status", + "", + f"**{overall}**", + "", + "## Reproduction instructions", + "", + ] + ) + commands = [command for record in records for command in record.commands] + if commands: + lines.append("Run the reviewed commands in `commands.sh` against an isolated regtest node.") + else: + lines.append("No reproduction commands have been captured yet.") + lines.extend( + [ + "", + "## Known limitations", + "", + "- Generated transaction identifiers, addresses, block hashes, and wallet names can differ on another run.", + "- This bundle is reproducible BitScope evidence, not a signature, attestation, formal proof, audit, or production approval.", + "", + ] + ) + return "\n".join(lines) + + @staticmethod + def _failure_section(title: str, failures: list[ScenarioFailure]) -> list[str]: + lines = ["", f"## {title}", ""] + if failures: + lines.extend( + f"- `{failure.step_id}` / `{failure.code}`: {failure.safe_message}" + for failure in failures + ) + else: + lines.append(f"No {title.casefold()} were recorded.") + return lines + + @staticmethod + def _canonical_json(value: object) -> bytes: + return ( + json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n" + ).encode("utf-8") + + @staticmethod + def _media_type(path: str) -> str: + suffix = PurePosixPath(path).suffix + if suffix == ".json": + return JSON_MEDIA_TYPE + if suffix == ".md": + return MARKDOWN_MEDIA_TYPE + if suffix == ".sh": + return SHELL_MEDIA_TYPE + return "application/octet-stream" + + @staticmethod + def _require_safe_bundle_path(path: str) -> None: + candidate = PurePosixPath(path) + if "\\" in path or candidate.is_absolute() or ".." in candidate.parts or "." in candidate.parts: + raise BitScopeError( + code="PROOF_BUNDLE_PATH_INVALID", + message="Refusing to export an unsafe proof bundle path.", + status_code=409, + details={"path": path}, + ) + + def _require_bundle_size(self, files: dict[str, bytes]) -> None: + size = sum(len(content) for content in files.values()) + if size > self.max_bundle_bytes: + raise self._bundle_too_large(size) + + def _bundle_too_large(self, size: int) -> BitScopeError: + return BitScopeError( + code="PROOF_BUNDLE_TOO_LARGE", + message="The proof bundle exceeds the configured export limit.", + status_code=413, + details={"content_bytes": size, "max_content_bytes": self.max_bundle_bytes}, + ) + + @classmethod + def _build_zip(cls, files: dict[str, bytes]) -> bytes: + output = io.BytesIO() + with ZipFile(output, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive: + for path, content in sorted(files.items()): + cls._require_safe_bundle_path(path) + info = ZipInfo(f"bitscope-proof/{path}", date_time=ZIP_EPOCH) + info.compress_type = ZIP_DEFLATED + info.create_system = 3 + info.external_attr = 0o100644 << 16 + archive.writestr(info, content, compresslevel=9) + return output.getvalue() diff --git a/backend/app/services/psbt_service.py b/backend/app/services/psbt_service.py index 90d3a54..028fddf 100644 --- a/backend/app/services/psbt_service.py +++ b/backend/app/services/psbt_service.py @@ -94,13 +94,28 @@ def decode(self, psbt: str) -> dict[str, object]: "raw": {"decodepsbt": decoded}, } - def process(self, wallet_name: str, psbt: str, sign: bool = True) -> dict[str, object]: + def process( + self, + wallet_name: str, + psbt: str, + sign: bool = True, + finalize: bool = True, + ) -> dict[str, object]: clean_wallet = self._clean(wallet_name, "wallet name") clean_psbt = self._clean(psbt, "PSBT") if sign: NetworkSafetyGuard(self.rpc_client).require_regtest() - result = self._as_dict(self.rpc_client.call("walletprocesspsbt", [clean_psbt, sign], wallet_name=clean_wallet)) + parameters: list[object] = [clean_psbt, sign] + if not finalize: + parameters.extend(["ALL", True, False]) + result = self._as_dict( + self.rpc_client.call( + "walletprocesspsbt", + parameters, + wallet_name=clean_wallet, + ) + ) processed_psbt = self._optional_str(result.get("psbt")) if processed_psbt is None: raise BitScopeError( @@ -110,14 +125,21 @@ def process(self, wallet_name: str, psbt: str, sign: bool = True) -> dict[str, o details={"rpc_method": "walletprocesspsbt"}, ) decoded = self.decode(processed_psbt) + process_command = ( + f"bitcoin-cli -rpcwallet={clean_wallet} walletprocesspsbt " + f"{str(sign).lower()}" + ) + if not finalize: + process_command += " ALL true false" return { "wallet_name": clean_wallet, "psbt": processed_psbt, "complete": self._optional_bool(result.get("complete")) is True, "signed": sign, + "finalize_requested": finalize, "decoded": decoded, - "cli_commands": [f"bitcoin-cli -rpcwallet={clean_wallet} walletprocesspsbt {str(sign).lower()}"], + "cli_commands": [process_command], "rpc_methods": ["walletprocesspsbt", "decodepsbt"], "concepts": ["PSBT", "Signing", "Wallet", "Finalization"], "explanation": "Wallet processing updates the PSBT with wallet metadata and, when requested, signatures for wallet-owned inputs.", diff --git a/backend/app/services/rbf_scenario.py b/backend/app/services/rbf_scenario.py new file mode 100644 index 0000000..0b6e076 --- /dev/null +++ b/backend/app/services/rbf_scenario.py @@ -0,0 +1,250 @@ +from app.models.scenario import ScenarioDefinition + + +RBF_REPLACEMENT_SCENARIO = ScenarioDefinition.model_validate( + { + "scenario_id": "rbf-replacement", + "version": "1.0.0", + "name": "RBF replacement", + "summary": ( + "Create an explicitly replaceable regtest transaction, record its sequence and mempool policy, " + "prove that Bitcoin Core rejects a same-rate bump, then broadcast and confirm a higher-fee replacement." + ), + "difficulty": "intermediate", + "related_lbcli_chapters": [5, 7], + "concepts": [ + "Replace-by-fee", + "BIP125 signaling", + "Incremental relay fee", + "Mempool replacement", + "Confirmation", + ], + "required_capabilities": ["read_only", "wallet_read", "regtest_mutation"], + "estimated_run_steps": 17, + "steps": [ + { + "step_id": "verify_chain", + "type": "verify_runtime_chain", + "phase": "setup", + "title": "Verify the runtime chain", + "description": "Require the configured node and live Bitcoin Core chain to agree on regtest.", + "output_context_ref": "node.context", + }, + { + "step_id": "prepare_wallet", + "type": "prepare_isolated_wallet", + "phase": "setup", + "title": "Prepare the isolated wallet", + "description": "Verify and use only the wallet owned by this run's active lab session.", + "depends_on": ["verify_chain"], + "wallet_role": "operator", + "output_wallet_ref": "wallet.operator", + }, + { + "step_id": "generate_mining_address", + "type": "generate_address", + "phase": "setup", + "title": "Generate a mining address", + "description": "Generate a fresh bech32 address in the session-owned wallet.", + "depends_on": ["prepare_wallet"], + "wallet_ref": "wallet.operator", + "label": "bitscope-rbf-mining", + "address_type": "bech32", + "output_address_ref": "address.mining", + }, + { + "step_id": "mine_mature_funds", + "type": "mine_blocks", + "phase": "setup", + "title": "Mine mature funds", + "description": "Mine 101 blocks in bounded batches so the wallet can fund the original transaction.", + "depends_on": ["generate_mining_address"], + "address_ref": "address.mining", + "blocks": 101, + "output_blocks_ref": "blocks.maturity", + }, + { + "step_id": "generate_recipient", + "type": "generate_address", + "phase": "setup", + "title": "Generate the recipient", + "description": "Generate a fresh destination for the replaceable wallet transaction.", + "depends_on": ["mine_mature_funds"], + "wallet_ref": "wallet.operator", + "label": "bitscope-rbf-recipient", + "address_type": "bech32", + "output_address_ref": "address.recipient", + }, + { + "step_id": "create_original", + "type": "create_wallet_rbf_transaction", + "phase": "execution", + "title": "Create the opt-in RBF transaction", + "description": "Broadcast a 0.1 BTC wallet transaction with replaceable=true and a 2 sat/vB fee rate.", + "depends_on": ["generate_recipient"], + "wallet_ref": "wallet.operator", + "recipient_address_ref": "address.recipient", + "amount_btc": "0.10000000", + "initial_fee_rate_sat_vb": "2.000", + "output_transaction_ref": "original.transaction", + "output_txid_ref": "original.txid", + }, + { + "step_id": "decode_original", + "type": "decode_transaction", + "phase": "execution", + "title": "Record sequence signaling", + "description": "Decode the original transaction and retain every input sequence value.", + "depends_on": ["create_original"], + "transaction_ref": "original.transaction", + "output_decoded_ref": "original.decoded", + }, + { + "step_id": "inspect_original_mempool", + "type": "query_mempool_entry", + "phase": "execution", + "title": "Inspect the original mempool entry", + "description": "Require Bitcoin Core to report the original as BIP125 replaceable.", + "depends_on": ["decode_original"], + "txid_ref": "original.txid", + "output_mempool_ref": "original.mempool", + }, + { + "step_id": "reject_insufficient_bump", + "type": "bump_fee", + "phase": "attack", + "title": "Attempt an insufficient fee bump", + "description": "Request the original observed fee rate and require Core's incremental-fee rejection.", + "depends_on": ["inspect_original_mempool"], + "wallet_ref": "wallet.operator", + "txid_ref": "original.txid", + "fee_rate_sat_vb": "2.000", + "output_replacement_ref": "attack.insufficient_bump", + }, + { + "step_id": "replace_transaction", + "type": "bump_fee", + "phase": "attack", + "title": "Broadcast a higher-fee replacement", + "description": "Add 10 sat/vB to the observed original fee rate and require a new transaction id.", + "depends_on": ["reject_insufficient_bump"], + "wallet_ref": "wallet.operator", + "txid_ref": "original.txid", + "add_to_observed_fee_rate_sat_vb": "10.000", + "output_replacement_ref": "replacement.transaction", + "output_txid_ref": "replacement.txid", + }, + { + "step_id": "verify_original_evicted", + "type": "query_mempool_entry", + "phase": "attack", + "title": "Verify original eviction", + "description": "Require the original transaction to be absent from the mempool after replacement.", + "depends_on": ["replace_transaction"], + "txid_ref": "original.txid", + "output_mempool_ref": "original.evicted", + }, + { + "step_id": "inspect_replacement_mempool", + "type": "query_mempool_entry", + "phase": "attack", + "title": "Inspect the replacement mempool entry", + "description": "Record the replacement's live fee and mempool metadata.", + "depends_on": ["verify_original_evicted"], + "txid_ref": "replacement.txid", + "output_mempool_ref": "replacement.mempool", + }, + { + "step_id": "confirm_replacement", + "type": "mine_confirmation_blocks", + "phase": "attack", + "title": "Confirm the replacement", + "description": "Mine one block containing the higher-fee replacement.", + "depends_on": ["inspect_replacement_mempool"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.confirmation", + }, + { + "step_id": "decode_confirmed_replacement", + "type": "decode_transaction", + "phase": "attack", + "title": "Decode the confirmed replacement", + "description": "Read the replacement from the wallet and decode its confirmed serialization.", + "depends_on": ["confirm_replacement"], + "transaction_ref": "replacement.transaction", + "output_decoded_ref": "replacement.confirmed", + }, + { + "step_id": "verify_results", + "type": "evaluate_assertions", + "phase": "verification", + "title": "Evaluate RBF assertions", + "description": "Evaluate signaling, insufficient-fee rejection, replacement, and confirmation.", + "depends_on": ["decode_confirmed_replacement"], + "assertion_ids": [ + "original_signaled_rbf", + "insufficient_bump_rejected", + "original_replaced", + "replacement_in_mempool", + "replacement_confirmed", + ], + }, + { + "step_id": "export_proof", + "type": "export_evidence", + "phase": "export", + "title": "Export the proof bundle", + "description": "Expose deterministic evidence, assertions, commands, manifest, and ZIP export.", + "depends_on": ["verify_results"], + "output_bundle_ref": "proof.bundle", + }, + { + "step_id": "cleanup", + "type": "cleanup_lab", + "phase": "cleanup", + "title": "Clean up the isolated wallet", + "description": "Unload only wallets recorded as owned by this lab session.", + "depends_on": ["export_proof"], + }, + ], + "assertions": [ + { + "assertion_id": "original_signaled_rbf", + "kind": "rbf_signaled", + "after_step_id": "inspect_original_mempool", + "subject_ref": "original.decoded", + "description": "At least one input sequence signals opt-in RBF and Core reports BIP125 replaceability.", + }, + { + "assertion_id": "insufficient_bump_rejected", + "kind": "rpc_failed_with_category", + "after_step_id": "reject_insufficient_bump", + "subject_ref": "attack.insufficient_bump", + "expected_category": "mempool_policy", + "description": "Core rejected a same-rate bump because it did not pay the incremental replacement fee.", + }, + { + "assertion_id": "original_replaced", + "kind": "transaction_replaced", + "after_step_id": "verify_original_evicted", + "subject_ref": "original.evicted", + "description": "The original txid is absent from the mempool after the higher-fee replacement.", + }, + { + "assertion_id": "replacement_in_mempool", + "kind": "transaction_in_mempool", + "after_step_id": "inspect_replacement_mempool", + "subject_ref": "replacement.mempool", + "description": "Bitcoin Core returned a mempool entry for the replacement txid.", + }, + { + "assertion_id": "replacement_confirmed", + "kind": "transaction_confirmed", + "after_step_id": "decode_confirmed_replacement", + "subject_ref": "replacement.confirmed", + "description": "The replacement has at least one confirmation and a matching decoded txid.", + }, + ], + } +) diff --git a/backend/app/services/rbf_scenario_service.py b/backend/app/services/rbf_scenario_service.py new file mode 100644 index 0000000..cd19d08 --- /dev/null +++ b/backend/app/services/rbf_scenario_service.py @@ -0,0 +1,753 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime + +from app.errors import BitScopeError +from app.models.attack import ( + AttackApplicabilityDecision, + AttackContext, + AttackFeature, + AttackVerificationResult, + RpcErrorAttackObservation, +) +from app.models.evidence import EvidenceRecord +from app.models.lab import LabAction, LabSession +from app.models.scenario import ( + AssertionResult, + AssertionResultStatus, + FailureCategory, + ScenarioDefinition, + ScenarioFailure, + ScenarioRun, + ScenarioStepResult, + ScenarioStepResultStatus, +) +from app.rpc.capabilities import RegtestMutationRpcClient, RpcTransport +from app.rpc.errors import RpcError +from app.services.lab_session_service import LabSessionService +from app.services.attack_verification_service import AttackVerificationService +from app.services.lab_session_store import LabSessionStore +from app.services.network_safety import NetworkSafetyGuard +from app.services.scenario_execution import ScenarioExecution, ScenarioExecutionError +from app.services.transaction_service import TransactionService + + +INSUFFICIENT_BUMP_CODE = "insufficient-replacement-fee" + + +class RbfScenarioService: + """Execute and classify the reviewed wallet RBF replacement workflow.""" + + def __init__(self, rpc_client: RpcTransport, lab_store: LabSessionStore) -> None: + self.rpc = RegtestMutationRpcClient(rpc_client) + self.transaction_service = TransactionService(rpc_client) + self.lab_store = lab_store + self.attacks = AttackVerificationService() + + def execute(self, run: ScenarioRun, definition: ScenarioDefinition) -> ScenarioExecution: + captured_at = datetime.now(UTC) + current_step = "prepare_wallet" + try: + session = self._active_session(run) + wallet_name = session.wallet_name + + current_step = "generate_mining_address" + mining_address = self._require_string( + self._mutate("getnewaddress", ["bitscope-rbf-mining", "bech32"], wallet_name), + "getnewaddress", + ) + + current_step = "mine_mature_funds" + maturity_hashes = self._mine_blocks(101, mining_address) + + current_step = "generate_recipient" + recipient_address = self._require_string( + self._mutate("getnewaddress", ["bitscope-rbf-recipient", "bech32"], wallet_name), + "getnewaddress", + ) + + current_step = "create_original" + original = self.transaction_service.create_rbf_transaction( + wallet_name, + recipient_address, + 0.1, + 2.0, + ) + original_txid = self._require_txid(original.get("txid"), "sendtoaddress") + original_hex = self._require_string(original.get("hex"), "gettransaction") + sequences = original.get("sequences") + if ( + not isinstance(sequences, list) + or not sequences + or any(not isinstance(sequence, int) or isinstance(sequence, bool) for sequence in sequences) + or not any(sequence < 0xFFFFFFFE for sequence in sequences) + ): + raise BitScopeError( + "SCENARIO_RBF_SIGNAL_MISSING", + "The original transaction did not contain an input sequence that signals opt-in RBF.", + 409, + ) + original_mempool = self._require_dict(original.get("mempool_entry"), "getmempoolentry") + if original_mempool.get("bip125-replaceable") is not True: + raise BitScopeError( + "SCENARIO_RBF_MEMPOOL_SIGNAL_MISSING", + "Bitcoin Core did not report the original transaction as BIP125 replaceable.", + 409, + ) + observed_fee_rate = original.get("fee_rate_sat_vb") + if ( + not isinstance(observed_fee_rate, int | float) + or isinstance(observed_fee_rate, bool) + or observed_fee_rate <= 0 + ): + raise self._invalid_response("getmempoolentry", "Bitcoin Core returned an invalid original fee rate.") + + current_step = "reject_insufficient_bump" + insufficient_decision = self.attacks.require_applicable( + self.attacks.assess( + "rbf-replacement.replacement-policy", + AttackContext( + scenario_id=run.scenario_id, + available_features=[ + AttackFeature.WALLET_TRANSACTION, + AttackFeature.RBF_SIGNALING, + AttackFeature.RPC_ERROR, + ], + ), + ) + ) + insufficient_error, insufficient_attack = self._expect_insufficient_bump( + wallet_name, + original_txid, + float(observed_fee_rate), + insufficient_decision, + ) + + current_step = "replace_transaction" + replacement_fee_rate = round(float(observed_fee_rate) + 10.0, 3) + replacement = self.transaction_service.bump_rbf_transaction( + wallet_name, + original_txid, + replacement_fee_rate, + None, + ) + replacement_txid = self._require_txid(replacement.get("replacement_txid"), "bumpfee") + if replacement_txid == original_txid: + raise self._invalid_response("bumpfee", "Bitcoin Core returned the original txid as its replacement.") + original_fee = replacement.get("original_fee_btc") + replacement_fee = replacement.get("replacement_fee_btc") + if ( + not isinstance(original_fee, int | float) + or isinstance(original_fee, bool) + or not isinstance(replacement_fee, int | float) + or isinstance(replacement_fee, bool) + or replacement_fee <= original_fee + ): + raise self._invalid_response("bumpfee", "Bitcoin Core returned invalid replacement fee economics.") + + current_step = "verify_original_evicted" + eviction_error = self._expect_original_evicted(original_txid) + + current_step = "inspect_replacement_mempool" + replacement_mempool = self._require_dict( + self.rpc.call("getmempoolentry", [replacement_txid]), + "getmempoolentry", + ) + + current_step = "confirm_replacement" + confirmation_hashes = self._mine_blocks(1, mining_address) + + current_step = "decode_confirmed_replacement" + confirmed_wallet_transaction = self._require_dict( + self.rpc.call("gettransaction", [replacement_txid], wallet_name=wallet_name), + "gettransaction", + ) + confirmations = confirmed_wallet_transaction.get("confirmations") + if not isinstance(confirmations, int) or isinstance(confirmations, bool) or confirmations < 1: + raise self._invalid_response("gettransaction", "The RBF replacement is not confirmed.") + replacement_hex = self._require_string( + confirmed_wallet_transaction.get("hex"), + "gettransaction", + ) + decoded_replacement = self._require_dict( + self.rpc.call("decoderawtransaction", [replacement_hex]), + "decoderawtransaction", + ) + if decoded_replacement.get("txid") != replacement_txid: + raise self._invalid_response("decoderawtransaction", "The confirmed replacement txid did not match.") + + self._record_session_outputs( + session, + [mining_address, recipient_address], + [original_txid, replacement_txid], + [*maturity_hashes, *confirmation_hashes], + ) + except BitScopeError as exc: + raise ScenarioExecutionError(current_step, exc) from exc + + evidence_records = self._evidence_records( + run=run, + captured_at=captured_at, + wallet_name=wallet_name, + mining_address=mining_address, + recipient_address=recipient_address, + maturity_hashes=maturity_hashes, + original=original, + original_txid=original_txid, + original_hex=original_hex, + observed_fee_rate=observed_fee_rate, + insufficient_error=insufficient_error, + replacement=replacement, + replacement_fee_rate=replacement_fee_rate, + replacement_txid=replacement_txid, + eviction_error=eviction_error, + replacement_mempool=replacement_mempool, + confirmation_hashes=confirmation_hashes, + confirmed_wallet_transaction=confirmed_wallet_transaction, + decoded_replacement=decoded_replacement, + ) + return ScenarioExecution( + evidence_records=evidence_records, + step_results=self._step_results(captured_at, insufficient_error), + assertion_results=self._assertion_results(), + attack_results=[insufficient_attack], + ) + + def cleanup(self, run: ScenarioRun) -> list[str]: + _, unloaded = LabSessionService(self.rpc.transport, self.lab_store).cleanup(run.lab_session_id) + return unloaded + + def failure_evidence( + self, + run: ScenarioRun, + step_id: str, + error: BitScopeError, + captured_at: datetime, + ) -> EvidenceRecord: + rpc_method = error.details.get("rpc_method") + rpc_code = error.details.get("rpc_code") + rpc_message = error.details.get("rpc_message") + return EvidenceRecord( + evidence_id=f"failure.{step_id}", + kind="rpc_result", + label=f"Unexpected failure at {step_id}", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output={ + "rpc_method": rpc_method if isinstance(rpc_method, str) else None, + "safe_parameters": [], + "result": None, + "error": { + "code": rpc_code if isinstance(rpc_code, int | str) else error.code, + "message": rpc_message if isinstance(rpc_message, str) else error.message, + }, + }, + bitscope_interpretation={ + "summary": "The RBF scenario stopped on an unexpected application or Bitcoin Core failure.", + "facts": [{"name": "failure.category", "value": error.code}], + "limitations": ["Only redacted, bounded error details are retained."], + }, + ) + + def _active_session(self, run: ScenarioRun) -> LabSession: + session = self.lab_store.get(run.lab_session_id) + if session is None: + raise BitScopeError("LAB_SESSION_NOT_FOUND", "The scenario's lab session does not exist.", 404) + if session.status != "active": + raise BitScopeError( + "LAB_SESSION_NOT_ACTIVE", + "The RBF scenario requires an active lab session.", + 409, + {"lab_session_id": run.lab_session_id, "status": session.status}, + ) + if session.wallet_name not in session.owned_wallets: + raise BitScopeError( + "LAB_WALLET_OWNERSHIP_VIOLATION", + "The active lab wallet is not recorded as owned by this session.", + 409, + ) + loaded = self._require_list(self.rpc.call("listwallets"), "listwallets") + if session.wallet_name not in loaded: + raise BitScopeError( + "SCENARIO_WALLET_NOT_LOADED", + "The session-owned wallet must be loaded before this scenario can run.", + 409, + {"wallet_name": session.wallet_name}, + ) + return session + + def _expect_insufficient_bump( + self, + wallet_name: str, + txid: str, + observed_fee_rate: float, + decision: AttackApplicabilityDecision, + ) -> tuple[RpcError, AttackVerificationResult]: + try: + self.transaction_service.bump_rbf_transaction( + wallet_name, + txid, + observed_fee_rate, + None, + ) + except RpcError as exc: + rpc_code = exc.details.get("rpc_code") + message = exc.details.get("rpc_message") + result = self.attacks.require_expected( + self.attacks.verify( + decision, + RpcErrorAttackObservation( + rpc_method="bumpfee", + rpc_code=rpc_code if isinstance(rpc_code, int) else 0, + rpc_message=( + message if isinstance(message, str) else "No RPC message returned." + ), + raw_safe_details={ + "rpc_method": "bumpfee", + "rpc_code": rpc_code if isinstance(rpc_code, int) else None, + "rpc_message": ( + message + if isinstance(message, str) + else "No RPC message returned." + ), + }, + ), + ), + mismatch_code="SCENARIO_RBF_REJECTION_MISMATCH", + safe_message=( + "Bitcoin Core rejected the insufficient bump for a different reason than expected." + ), + ) + return exc, result + raise BitScopeError( + "SCENARIO_RBF_INSUFFICIENT_BUMP_ACCEPTED", + "Bitcoin Core unexpectedly accepted a replacement without the required fee increase.", + 409, + ) + + def _expect_original_evicted(self, txid: str) -> RpcError: + try: + self.rpc.call("getmempoolentry", [txid]) + except RpcError as exc: + rpc_code = exc.details.get("rpc_code") + message = exc.details.get("rpc_message") + if rpc_code == -5 and isinstance(message, str) and "not in mempool" in message.casefold(): + return exc + raise BitScopeError( + "SCENARIO_RBF_EVICTION_MISMATCH", + "Bitcoin Core did not prove original-transaction eviction with the expected result.", + 409, + { + "rpc_method": "getmempoolentry", + "rpc_code": rpc_code, + "rpc_message": message if isinstance(message, str) else "No RPC message returned.", + }, + ) from exc + raise BitScopeError( + "SCENARIO_RBF_ORIGINAL_STILL_IN_MEMPOOL", + "The original transaction remained in the mempool after replacement.", + 409, + {"txid": txid}, + ) + + def _mutate(self, method: str, params: object, wallet_name: str | None = None) -> object: + NetworkSafetyGuard(self.rpc).require_regtest() + return self.rpc.call(method, params, wallet_name=wallet_name) + + def _mine_blocks(self, blocks: int, address: str) -> list[str]: + hashes: list[str] = [] + remaining = blocks + while remaining: + batch = min(remaining, 20) + mined = self._require_list( + self._mutate("generatetoaddress", [batch, address]), + "generatetoaddress", + ) + if len(mined) != batch or any(not isinstance(item, str) or not item for item in mined): + raise self._invalid_response("generatetoaddress", "Bitcoin Core returned invalid block hashes.") + hashes.extend(mined) + remaining -= batch + return hashes + + def _record_session_outputs( + self, + session: LabSession, + addresses: list[str], + txids: list[str], + block_hashes: list[str], + ) -> None: + session.created_addresses.extend(addresses) + session.transaction_ids.extend(txids) + session.block_hashes.extend(block_hashes) + session.actions.append( + LabAction( + sequence=len(session.actions) + 1, + kind="rbf_replacement_completed", + occurred_at=datetime.now(UTC), + details={"original_txid": txids[0], "replacement_txid": txids[1]}, + ) + ) + session.updated_at = datetime.now(UTC) + self.lab_store.save(session) + + def _evidence_records(self, **values: object) -> list[EvidenceRecord]: + run = values["run"] + captured_at = values["captured_at"] + wallet_name = str(values["wallet_name"]) + mining_address = str(values["mining_address"]) + original_txid = str(values["original_txid"]) + replacement_txid = str(values["replacement_txid"]) + insufficient_error = values["insufficient_error"] + eviction_error = values["eviction_error"] + + def record( + evidence_id: str, + kind: str, + label: str, + step_id: str, + rpc_method: str, + result: object, + summary: str, + commands: list[dict[str, object]], + run_paths: list[str], + error: RpcError | None = None, + ) -> EvidenceRecord: + output: dict[str, object] = { + "rpc_method": rpc_method, + "safe_parameters": [], + "result": result, + "run_specific_paths": run_paths, + } + if error is not None: + output["error"] = { + "code": error.details.get("rpc_code", error.code), + "message": error.details.get("rpc_message", error.message), + } + return EvidenceRecord( + evidence_id=evidence_id, + kind=kind, + label=label, + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output=output, + bitscope_interpretation={ + "summary": summary, + "facts": [], + "limitations": [ + "RBF is mempool policy observed on isolated regtest, not a consensus guarantee " + "or production fee recommendation." + ], + }, + commands=commands, + ) + + original = values["original"] + replacement = values["replacement"] + return [ + record( + "rbf.setup", + "lifecycle", + "Isolated RBF wallet setup", + "mine_mature_funds", + "generatetoaddress", + { + "wallet_name": wallet_name, + "mining_address": mining_address, + "recipient_address": values["recipient_address"], + "maturity_block_hashes": values["maturity_hashes"], + }, + "Bitcoin Core produced a fresh recipient and mature funds in the session-owned wallet.", + [ + self._command( + ["-regtest", "generatetoaddress", "20", mining_address], + "Mine maturity blocks in bounded batches; repeat five times, then mine one more.", + ), + self._command( + ["-regtest", "generatetoaddress", "1", mining_address], + "Finish the 101-block maturity sequence.", + ), + ], + [ + "$.result.wallet_name", + "$.result.mining_address", + "$.result.recipient_address", + "$.result.maturity_block_hashes", + ], + ), + record( + "rbf.original", + "transaction", + "Original opt-in RBF transaction", + "inspect_original_mempool", + "getmempoolentry", + { + "txid": original_txid, + "hex": values["original_hex"], + "sequences": original.get("sequences"), + "mempool_entry": original.get("mempool_entry"), + "fee_rate_sat_vb": values["observed_fee_rate"], + }, + "Input sequence values and live mempool metadata independently show opt-in replacement signaling.", + [ + self._command( + [ + "-regtest", + "-named", + f"-rpcwallet={wallet_name}", + "sendtoaddress", + f"address={values['recipient_address']}", + "amount=0.10000000", + "replaceable=true", + "fee_rate=2.000", + ], + "Create the explicitly replaceable original transaction.", + ), + self._command( + ["-regtest", f"-rpcwallet={wallet_name}", "gettransaction", original_txid], + "Read the original wallet transaction.", + ), + self._command( + ["-regtest", "getmempoolentry", original_txid], + "Inspect original replacement policy metadata.", + ), + ], + ["$.result.txid", "$.result.hex", "$.result.sequences", "$.result.mempool_entry"], + ), + record( + "rbf.insufficient-fee", + "assertion", + "Expected insufficient replacement fee rejection", + "reject_insufficient_bump", + "bumpfee", + {"txid": original_txid, "requested_fee_rate_sat_vb": values["observed_fee_rate"]}, + "Bitcoin Core RPC -8 required the old fee plus its incremental relay fee before replacement.", + [ + self._command( + [ + "-regtest", + f"-rpcwallet={wallet_name}", + "bumpfee", + original_txid, + json.dumps( + {"fee_rate": values["observed_fee_rate"]}, + separators=(",", ":"), + ), + ], + "Reproduce the insufficient-fee bump rejection before replacing the transaction.", + ), + ], + ["$.result.txid", "$.result.requested_fee_rate_sat_vb"], + error=insufficient_error, + ), + record( + "rbf.replacement", + "transaction", + "Higher-fee replacement and original eviction", + "inspect_replacement_mempool", + "getmempoolentry", + { + "original_txid": original_txid, + "replacement_txid": replacement_txid, + "requested_fee_rate_sat_vb": values["replacement_fee_rate"], + "bumpfee": replacement, + "original_eviction": { + "rpc_code": eviction_error.details.get("rpc_code"), + "rpc_message": eviction_error.details.get("rpc_message"), + }, + "replacement_mempool": values["replacement_mempool"], + }, + "The higher-fee transaction has a distinct txid, the original is absent, and the " + "replacement is in the mempool.", + [ + self._command( + [ + "-regtest", + f"-rpcwallet={wallet_name}", + "bumpfee", + original_txid, + json.dumps( + {"fee_rate": values["replacement_fee_rate"]}, + separators=(",", ":"), + ), + ], + "Create and broadcast the sufficient replacement.", + ), + self._command( + ["-regtest", "getmempoolentry", replacement_txid], + "Inspect the replacement mempool entry.", + ), + ], + [ + "$.result.original_txid", + "$.result.replacement_txid", + "$.result.bumpfee", + "$.result.replacement_mempool", + ], + ), + record( + "rbf.confirmed", + "transaction", + "Confirmed RBF replacement", + "decode_confirmed_replacement", + "gettransaction", + { + "replacement_txid": replacement_txid, + "confirmation_block_hashes": values["confirmation_hashes"], + "wallet_transaction": values["confirmed_wallet_transaction"], + "decoded": values["decoded_replacement"], + }, + "A newly mined block confirmed the replacement, and its decoded txid matches the bumpfee result.", + [ + self._command( + ["-regtest", "generatetoaddress", "1", mining_address], + "Mine the replacement confirmation block.", + ), + self._command( + ["-regtest", f"-rpcwallet={wallet_name}", "gettransaction", replacement_txid], + "Read the confirmed replacement.", + ), + ], + [ + "$.result.replacement_txid", + "$.result.confirmation_block_hashes", + "$.result.wallet_transaction", + "$.result.decoded.txid", + ], + ), + ] + + @staticmethod + def _step_results(timestamp: datetime, insufficient_error: RpcError) -> list[ScenarioStepResult]: + completed: list[tuple[str, list[str], list[str]]] = [ + ("verify_chain", ["node.context"], ["node.context"]), + ("prepare_wallet", ["wallet.operator"], ["rbf.setup"]), + ("generate_mining_address", ["address.mining"], ["rbf.setup"]), + ("mine_mature_funds", ["blocks.maturity"], ["rbf.setup"]), + ("generate_recipient", ["address.recipient"], ["rbf.setup"]), + ("create_original", ["original.transaction", "original.txid"], ["rbf.original"]), + ("decode_original", ["original.decoded"], ["rbf.original"]), + ("inspect_original_mempool", ["original.mempool"], ["rbf.original"]), + ] + results = [ + ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=outputs, + evidence_ids=evidence, + ) + for step_id, outputs, evidence in completed + ] + failure = ScenarioFailure( + failure_id="failure.insufficient-replacement-fee", + step_id="reject_insufficient_bump", + category=FailureCategory.MEMPOOL_POLICY, + expected=True, + code=INSUFFICIENT_BUMP_CODE, + safe_message=( + "Bitcoin Core rejected the same-rate bump because it did not pay the incremental " + "replacement fee." + ), + rpc_code=insufficient_error.details.get("rpc_code"), + evidence_ids=["rbf.insufficient-fee"], + ) + results.append( + ScenarioStepResult( + step_id="reject_insufficient_bump", + status=ScenarioStepResultStatus.EXPECTED_FAILURE, + started_at=timestamp, + completed_at=timestamp, + output_refs=["attack.insufficient_bump"], + evidence_ids=["rbf.insufficient-fee"], + failure=failure, + ) + ) + for step_id, outputs, evidence in [ + ("replace_transaction", ["replacement.transaction", "replacement.txid"], ["rbf.replacement"]), + ("verify_original_evicted", ["original.evicted"], ["rbf.replacement"]), + ("inspect_replacement_mempool", ["replacement.mempool"], ["rbf.replacement"]), + ("confirm_replacement", ["blocks.confirmation"], ["rbf.confirmed"]), + ("decode_confirmed_replacement", ["replacement.confirmed"], ["rbf.confirmed"]), + ]: + results.append( + ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=outputs, + evidence_ids=evidence, + ) + ) + return results + + @staticmethod + def _assertion_results() -> list[AssertionResult]: + evidence = { + "original_signaled_rbf": ["rbf.original"], + "insufficient_bump_rejected": ["rbf.insufficient-fee"], + "original_replaced": ["rbf.replacement"], + "replacement_in_mempool": ["rbf.replacement"], + "replacement_confirmed": ["rbf.confirmed"], + } + explanations = { + "original_signaled_rbf": "An input sequence is below 0xfffffffe and Core reported bip125-replaceable=true.", + "insufficient_bump_rejected": "Core RPC -8 reported insufficient total fee including incrementalFee.", + "original_replaced": "The original getmempoolentry returned RPC -5 after bumpfee succeeded.", + "replacement_in_mempool": "Core returned a mempool entry for the distinct replacement txid.", + "replacement_confirmed": "Core returned confirmations >= 1 and a matching decoded replacement txid.", + } + return [ + AssertionResult( + assertion_id=assertion_id, + status=AssertionResultStatus.PASSED, + required=True, + expected_failure=assertion_id == "insufficient_bump_rejected", + explanation=explanations[assertion_id], + evidence_ids=evidence[assertion_id], + ) + for assertion_id in explanations + ] + + @staticmethod + def _require_txid(value: object, rpc_method: str) -> str: + txid = RbfScenarioService._require_string(value, rpc_method) + if len(txid) != 64 or any(character not in "0123456789abcdefABCDEF" for character in txid): + raise RbfScenarioService._invalid_response(rpc_method, "Bitcoin Core returned an invalid txid.") + return txid + + @staticmethod + def _require_string(value: object, rpc_method: str) -> str: + if not isinstance(value, str) or not value: + raise RbfScenarioService._invalid_response(rpc_method, "Bitcoin Core returned an invalid string response.") + return value + + @staticmethod + def _require_dict(value: object, rpc_method: str) -> dict[str, object]: + if not isinstance(value, dict): + raise RbfScenarioService._invalid_response(rpc_method, "Bitcoin Core returned an invalid object response.") + return value + + @staticmethod + def _require_list(value: object, rpc_method: str) -> list[object]: + if not isinstance(value, list): + raise RbfScenarioService._invalid_response(rpc_method, "Bitcoin Core returned an invalid list response.") + return value + + @staticmethod + def _invalid_response(rpc_method: str, message: str) -> BitScopeError: + return BitScopeError("BITCOIN_CORE_INVALID_RESPONSE", message, 502, {"rpc_method": rpc_method}) + + @staticmethod + def _command(arguments: list[str], description: str) -> dict[str, object]: + return {"arguments": arguments, "description": description} diff --git a/backend/app/services/scenario_artifact_store.py b/backend/app/services/scenario_artifact_store.py new file mode 100644 index 0000000..670a227 --- /dev/null +++ b/backend/app/services/scenario_artifact_store.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import json +import os +import shutil +from hashlib import sha256 +from pathlib import Path +from tempfile import NamedTemporaryFile + +from app.errors import BitScopeError +from app.models.evidence import CapturedEvidence, EvidenceRecord +from app.models.scenario import EvidenceReference, ScenarioRun + + +class ScenarioArtifactStore: + """Store redacted evidence beneath server-generated, run-scoped paths.""" + + def __init__(self, artifact_root: str, max_evidence_bytes: int = 1_048_576) -> None: + if max_evidence_bytes < 1_024: + raise ValueError("Evidence artifact limits must be at least 1024 bytes.") + self.root = Path(artifact_root).resolve() + self.max_evidence_bytes = max_evidence_bytes + + def write_evidence(self, captured: CapturedEvidence) -> bool: + reference = captured.reference + expected_path = self._expected_evidence_path(reference) + if reference.relative_path != expected_path: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_PATH_INVALID", + message="Evidence artifacts must use the server-generated run path.", + status_code=409, + details={"evidence_id": reference.evidence_id}, + ) + + content = captured.canonical_json.encode("utf-8") + self._validate_content(reference, content) + target = self._resolve_run_path(captured.run, expected_path) + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + existing = self._read_bounded(target, reference.evidence_id) + if existing == content: + return False + raise BitScopeError( + code="EVIDENCE_ARTIFACT_CONFLICT", + message="An evidence artifact with this identifier already contains different content.", + status_code=409, + details={"evidence_id": reference.evidence_id}, + ) + + temporary_name: str | None = None + created = False + try: + with NamedTemporaryFile("wb", dir=target.parent, delete=False) as temporary: + temporary.write(content) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_name = temporary.name + try: + os.link(temporary_name, target) + created = True + except FileExistsError: + existing = self._read_bounded(target, reference.evidence_id) + if existing != content: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_CONFLICT", + message="An evidence artifact with this identifier already contains different content.", + status_code=409, + details={"evidence_id": reference.evidence_id}, + ) + finally: + if temporary_name is not None: + Path(temporary_name).unlink(missing_ok=True) + return created + + def delete_evidence(self, captured: CapturedEvidence) -> None: + """Remove only the exact artifact created for a failed metadata commit.""" + + reference = captured.reference + expected_path = self._expected_evidence_path(reference) + if reference.relative_path != expected_path: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_PATH_INVALID", + message="Refusing to delete an evidence artifact outside its server-generated path.", + status_code=409, + details={"evidence_id": reference.evidence_id}, + ) + target = self._resolve_run_path(captured.run, expected_path) + if not target.exists(): + return + content = self._read_bounded(target, reference.evidence_id) + self._validate_content(reference, content) + target.unlink() + + def delete_run(self, run: ScenarioRun) -> None: + """Delete only the validated artifact directory owned by one confirmed run.""" + + run_root = (self.root / str(run.run_id)).resolve() + if self.root not in run_root.parents: + raise self._unsafe_path(run, ".") + if run_root.exists(): + shutil.rmtree(run_root) + + def read_evidence(self, run: ScenarioRun, reference: EvidenceReference) -> EvidenceRecord: + expected_path = self._expected_evidence_path(reference) + if reference.relative_path != expected_path: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_PATH_INVALID", + message="The evidence reference does not use its server-generated path.", + status_code=409, + details={"evidence_id": reference.evidence_id}, + ) + target = self._resolve_run_path(run, expected_path) + if not target.is_file(): + raise BitScopeError( + code="EVIDENCE_ARTIFACT_MISSING", + message="A referenced evidence artifact is missing.", + status_code=409, + details={"run_id": str(run.run_id), "evidence_id": reference.evidence_id}, + ) + content = self._read_bounded(target, reference.evidence_id) + self._validate_content(reference, content) + try: + record = EvidenceRecord.model_validate_json(content) + except ValueError as exc: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_INVALID", + message="A referenced evidence artifact is not a valid typed record.", + status_code=409, + details={"run_id": str(run.run_id), "evidence_id": reference.evidence_id}, + ) from exc + canonical = json.dumps( + record.model_dump(mode="json"), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + b"\n" + if content != canonical: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_NOT_CANONICAL", + message="A referenced evidence artifact is not in canonical JSON form.", + status_code=409, + details={"run_id": str(run.run_id), "evidence_id": reference.evidence_id}, + ) + mismatches = { + "run_id": str(record.run_id) != str(run.run_id), + "scenario_id": record.scenario_id != run.scenario_id, + "scenario_version": record.scenario_version != run.scenario_version, + "lab_session_id": record.lab_session_id != run.lab_session_id, + "evidence_id": record.evidence_id != reference.evidence_id, + "kind": record.kind != reference.kind, + } + changed = sorted(field for field, mismatch in mismatches.items() if mismatch) + if changed: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_IDENTITY_MISMATCH", + message="A stored evidence artifact does not match its owning run and reference.", + status_code=409, + details={"run_id": str(run.run_id), "evidence_id": reference.evidence_id, "fields": changed}, + ) + return record + + def list_evidence(self, run: ScenarioRun, max_total_bytes: int = 10_485_760) -> list[EvidenceRecord]: + records: list[EvidenceRecord] = [] + total_bytes = 0 + for reference in sorted(run.evidence, key=lambda item: item.evidence_id): + record = self.read_evidence(run, reference) + total_bytes += len(record.model_dump_json().encode("utf-8")) + if total_bytes > max_total_bytes: + raise BitScopeError( + code="EVIDENCE_COLLECTION_TOO_LARGE", + message="The run's evidence exceeds the configured read limit.", + status_code=413, + details={ + "run_id": str(run.run_id), + "content_bytes": total_bytes, + "max_content_bytes": max_total_bytes, + }, + ) + records.append(record) + return records + + @staticmethod + def _expected_evidence_path(reference: EvidenceReference) -> str: + return f"evidence/{reference.evidence_id}.json" + + def _resolve_run_path(self, run: ScenarioRun, relative_path: str) -> Path: + run_root = (self.root / str(run.run_id)).resolve() + if self.root not in run_root.parents: + raise self._unsafe_path(run, relative_path) + candidate = (run_root / relative_path).resolve() + if run_root not in candidate.parents: + raise self._unsafe_path(run, relative_path) + return candidate + + @staticmethod + def _unsafe_path(run: ScenarioRun, relative_path: str) -> BitScopeError: + return BitScopeError( + code="EVIDENCE_ARTIFACT_PATH_INVALID", + message="Refusing to access an evidence path outside its run directory.", + status_code=409, + details={"run_id": str(run.run_id), "relative_path": relative_path}, + ) + + def _read_bounded(self, path: Path, evidence_id: str) -> bytes: + with path.open("rb") as artifact: + content = artifact.read(self.max_evidence_bytes + 1) + if len(content) > self.max_evidence_bytes: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_TOO_LARGE", + message="A stored evidence artifact exceeds the configured content limit.", + status_code=413, + details={ + "evidence_id": evidence_id, + "content_bytes": len(content), + "max_content_bytes": self.max_evidence_bytes, + }, + ) + return content + + def _validate_content(self, reference: EvidenceReference, content: bytes) -> None: + if len(content) > self.max_evidence_bytes: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_TOO_LARGE", + message="An evidence artifact exceeds the configured content limit.", + status_code=413, + details={ + "evidence_id": reference.evidence_id, + "content_bytes": len(content), + "max_content_bytes": self.max_evidence_bytes, + }, + ) + digest = sha256(content).hexdigest() + if reference.content_sha256 != digest: + raise BitScopeError( + code="EVIDENCE_ARTIFACT_HASH_MISMATCH", + message="An evidence artifact does not match its recorded SHA-256 hash.", + status_code=409, + details={"evidence_id": reference.evidence_id}, + ) diff --git a/backend/app/services/scenario_catalog.py b/backend/app/services/scenario_catalog.py new file mode 100644 index 0000000..a38591b --- /dev/null +++ b/backend/app/services/scenario_catalog.py @@ -0,0 +1,125 @@ +from dataclasses import dataclass + +from app.errors import BitScopeError +from app.models.scenario import ScenarioDefinition +from app.models.scenario_api import ScenarioCatalogEntry, ScenarioDetailResponse +from app.services.cltv_timelock_scenario import CLTV_TIMELOCK_SCENARIO +from app.services.community_treasury_scenario import COMMUNITY_TREASURY_SCENARIO +from app.services.multisig_psbt_scenario import MULTISIG_PSBT_SCENARIO +from app.services.rbf_scenario import RBF_REPLACEMENT_SCENARIO +from app.services.transaction_lifecycle_scenario import TRANSACTION_LIFECYCLE_SCENARIO + + +@dataclass(frozen=True) +class RegisteredScenario: + definition: ScenarioDefinition + available: bool = True + unavailable_reason: str | None = None + + def __post_init__(self) -> None: + if self.available and self.unavailable_reason is not None: + raise ValueError("An available scenario cannot have an unavailable reason.") + if not self.available and not self.unavailable_reason: + raise ValueError("An unavailable scenario requires a reason.") + + def summary(self) -> ScenarioCatalogEntry: + definition = self.definition + return ScenarioCatalogEntry( + scenario_id=definition.scenario_id, + version=definition.version, + name=definition.name, + summary=definition.summary, + difficulty=definition.difficulty, + related_lbcli_chapters=definition.related_lbcli_chapters, + concepts=definition.concepts, + required_network=definition.required_network, + estimated_run_steps=definition.estimated_run_steps, + step_count=len(definition.steps), + assertion_count=len(definition.assertions), + available=self.available, + unavailable_reason=self.unavailable_reason, + ) + + def detail(self) -> ScenarioDetailResponse: + return ScenarioDetailResponse( + definition=self.definition, + available=self.available, + unavailable_reason=self.unavailable_reason, + ) + + +class ScenarioCatalog: + """Immutable registry of reviewed scenario definitions.""" + + def __init__(self, entries: tuple[RegisteredScenario, ...] = ()) -> None: + by_id: dict[str, RegisteredScenario] = {} + for entry in entries: + scenario_id = entry.definition.scenario_id + if scenario_id in by_id: + raise ValueError(f"Duplicate scenario identifier: {scenario_id}.") + by_id[scenario_id] = entry + self._entries = by_id + + def list(self) -> list[ScenarioCatalogEntry]: + return [self._entries[scenario_id].summary() for scenario_id in sorted(self._entries)] + + def get(self, scenario_id: str) -> RegisteredScenario: + entry = self._entries.get(scenario_id) + if entry is None: + raise BitScopeError( + code="SCENARIO_NOT_FOUND", + message="The requested scenario does not exist.", + status_code=404, + details={"scenario_id": scenario_id}, + ) + return entry + + def require_available(self, scenario_id: str) -> ScenarioDefinition: + entry = self.get(scenario_id) + if not entry.available: + raise BitScopeError( + code="SCENARIO_NOT_AVAILABLE", + message="The requested scenario is not available to run yet.", + status_code=409, + details={ + "scenario_id": scenario_id, + "reason": entry.unavailable_reason, + }, + ) + return entry.definition + + def require_version(self, scenario_id: str, version: str) -> ScenarioDefinition: + definition = self.require_available(scenario_id) + return self._require_matching_version(definition, version) + + def get_version(self, scenario_id: str, version: str) -> ScenarioDefinition: + """Resolve a historical run definition even when new runs are disabled.""" + + definition = self.get(scenario_id).definition + return self._require_matching_version(definition, version) + + @staticmethod + def _require_matching_version(definition: ScenarioDefinition, version: str) -> ScenarioDefinition: + if definition.version != version: + raise BitScopeError( + code="SCENARIO_VERSION_NOT_AVAILABLE", + message="The scenario version used by this run is no longer registered.", + status_code=409, + details={ + "scenario_id": definition.scenario_id, + "required_version": version, + "available_version": definition.version, + }, + ) + return definition + + +DEFAULT_SCENARIO_CATALOG = ScenarioCatalog( + ( + RegisteredScenario(CLTV_TIMELOCK_SCENARIO), + RegisteredScenario(COMMUNITY_TREASURY_SCENARIO), + RegisteredScenario(MULTISIG_PSBT_SCENARIO), + RegisteredScenario(RBF_REPLACEMENT_SCENARIO), + RegisteredScenario(TRANSACTION_LIFECYCLE_SCENARIO), + ) +) diff --git a/backend/app/services/scenario_execution.py b/backend/app/services/scenario_execution.py new file mode 100644 index 0000000..324caf2 --- /dev/null +++ b/backend/app/services/scenario_execution.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass, field +from datetime import datetime +from typing import Protocol + +from app.errors import BitScopeError +from app.models.attack import AttackVerificationResult +from app.models.evidence import EvidenceRecord +from app.models.scenario import AssertionResult, ScenarioDefinition, ScenarioRun, ScenarioStepResult + + +@dataclass(frozen=True) +class ScenarioExecution: + evidence_records: list[EvidenceRecord] + step_results: list[ScenarioStepResult] + assertion_results: list[AssertionResult] + attack_results: list[AttackVerificationResult] = field(default_factory=list) + + +class ScenarioExecutionError(Exception): + def __init__(self, step_id: str, cause: BitScopeError) -> None: + super().__init__(cause.message) + self.step_id = step_id + self.cause = cause + + +class ScenarioExecutor(Protocol): + def execute(self, run: ScenarioRun, definition: ScenarioDefinition) -> ScenarioExecution: ... + + def cleanup(self, run: ScenarioRun) -> list[str]: ... + + def failure_evidence( + self, + run: ScenarioRun, + step_id: str, + error: BitScopeError, + captured_at: datetime, + ) -> EvidenceRecord: ... diff --git a/backend/app/services/scenario_run_store.py b/backend/app/services/scenario_run_store.py new file mode 100644 index 0000000..3060769 --- /dev/null +++ b/backend/app/services/scenario_run_store.py @@ -0,0 +1,461 @@ +import sqlite3 +from pathlib import Path +from threading import RLock +from uuid import UUID + +from app.errors import BitScopeError +from app.models.lab import LabSession +from app.models.scenario import CleanupStatus, ScenarioRun, TERMINAL_RUN_STATES +from app.services.lab_session_store import LabSessionStore + + +SCHEMA_VERSION = 1 + + +class ScenarioRunStore: + """Persist scenario runs transactionally beside their owning lab sessions.""" + + def __init__(self, database_path: str) -> None: + self.database_path = database_path + self._lock = RLock() + Path(database_path).parent.mkdir(parents=True, exist_ok=True) + LabSessionStore(database_path) + with self._lock, self._connect() as connection: + self._create_schema(connection) + + def create(self, run: ScenarioRun) -> None: + if run.revision != 0: + raise BitScopeError( + code="SCENARIO_RUN_INVALID_REVISION", + message="A new scenario run must begin at revision zero.", + status_code=409, + details={"run_id": str(run.run_id), "revision": run.revision}, + ) + + with self._lock, self._connect() as connection: + self._require_active_lab_session(connection, run.lab_session_id) + try: + connection.execute( + """ + INSERT INTO scenario_runs( + run_id, + lab_session_id, + scenario_id, + scenario_version, + current_state, + revision, + created_at, + updated_at, + document + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + self._run_row(run), + ) + except sqlite3.IntegrityError as exc: + raise BitScopeError( + code="SCENARIO_RUN_ALREADY_EXISTS", + message="A scenario run with this identifier already exists.", + status_code=409, + details={"run_id": str(run.run_id)}, + ) from exc + self._replace_child_documents(connection, run) + + def get(self, run_id: UUID | str) -> ScenarioRun | None: + with self._lock, self._connect() as connection: + row = connection.execute( + "SELECT document FROM scenario_runs WHERE run_id = ?", + (str(run_id),), + ).fetchone() + return ScenarioRun.model_validate_json(row[0]) if row else None + + def get_for_session(self, run_id: UUID | str, lab_session_id: str) -> ScenarioRun | None: + with self._lock, self._connect() as connection: + row = connection.execute( + "SELECT document FROM scenario_runs WHERE run_id = ? AND lab_session_id = ?", + (str(run_id), lab_session_id), + ).fetchone() + return ScenarioRun.model_validate_json(row[0]) if row else None + + def list_for_session(self, lab_session_id: str) -> list[ScenarioRun]: + with self._lock, self._connect() as connection: + rows = connection.execute( + """ + SELECT document + FROM scenario_runs + WHERE lab_session_id = ? + ORDER BY created_at ASC, run_id ASC + """, + (lab_session_id,), + ).fetchall() + return [ScenarioRun.model_validate_json(row[0]) for row in rows] + + def save(self, run: ScenarioRun, expected_revision: int) -> None: + if expected_revision < 0 or run.revision != expected_revision + 1: + raise BitScopeError( + code="SCENARIO_RUN_INVALID_REVISION", + message="Scenario run updates must advance exactly one revision.", + status_code=409, + details={ + "run_id": str(run.run_id), + "expected_revision": expected_revision, + "submitted_revision": run.revision, + }, + ) + + with self._lock, self._connect() as connection: + existing = connection.execute( + """ + SELECT lab_session_id, scenario_id, scenario_version, revision, document + FROM scenario_runs + WHERE run_id = ? + """, + (str(run.run_id),), + ).fetchone() + if existing is None: + raise BitScopeError( + code="SCENARIO_RUN_NOT_FOUND", + message="The requested scenario run does not exist.", + status_code=404, + details={"run_id": str(run.run_id)}, + ) + + stored_session, stored_scenario, stored_version, stored_revision, stored_document = existing + if ( + stored_session != run.lab_session_id + or stored_scenario != run.scenario_id + or stored_version != run.scenario_version + ): + raise BitScopeError( + code="SCENARIO_RUN_IDENTITY_MISMATCH", + message="A persisted scenario run cannot change its session, scenario, or version.", + status_code=409, + details={"run_id": str(run.run_id)}, + ) + if stored_revision != expected_revision: + raise self._revision_conflict(run, expected_revision, int(stored_revision)) + stored_run = ScenarioRun.model_validate_json(stored_document) + self._validate_update(stored_run, run) + + updated = connection.execute( + """ + UPDATE scenario_runs + SET current_state = ?, revision = ?, updated_at = ?, document = ? + WHERE run_id = ? AND revision = ? + """, + ( + run.current_state.value, + run.revision, + run.updated_at.isoformat(), + run.model_dump_json(), + str(run.run_id), + expected_revision, + ), + ) + if updated.rowcount != 1: + current = connection.execute( + "SELECT revision FROM scenario_runs WHERE run_id = ?", + (str(run.run_id),), + ).fetchone() + actual_revision = int(current[0]) if current else -1 + raise self._revision_conflict(run, expected_revision, actual_revision) + self._replace_child_documents(connection, run) + + def delete(self, run_id: UUID | str, lab_session_id: str) -> bool: + with self._lock, self._connect() as connection: + deleted = connection.execute( + "DELETE FROM scenario_runs WHERE run_id = ? AND lab_session_id = ?", + (str(run_id), lab_session_id), + ) + return deleted.rowcount == 1 + + @staticmethod + def _run_row(run: ScenarioRun) -> tuple[object, ...]: + return ( + str(run.run_id), + run.lab_session_id, + run.scenario_id, + run.scenario_version, + run.current_state.value, + run.revision, + run.created_at.isoformat(), + run.updated_at.isoformat(), + run.model_dump_json(), + ) + + @staticmethod + def _replace_child_documents(connection: sqlite3.Connection, run: ScenarioRun) -> None: + run_id = str(run.run_id) + for table in ( + "scenario_step_runs", + "scenario_assertions", + "scenario_evidence", + "scenario_failures", + ): + connection.execute(f"DELETE FROM {table} WHERE run_id = ?", (run_id,)) + + connection.executemany( + """ + INSERT INTO scenario_step_runs(run_id, step_id, ordinal, status, document) + VALUES (?, ?, ?, ?, ?) + """, + [ + (run_id, result.step_id, ordinal, result.status.value, result.model_dump_json()) + for ordinal, result in enumerate(run.step_results, start=1) + ], + ) + connection.executemany( + """ + INSERT INTO scenario_assertions(run_id, assertion_id, status, document) + VALUES (?, ?, ?, ?) + """, + [ + (run_id, result.assertion_id, result.status.value, result.model_dump_json()) + for result in run.assertion_results + ], + ) + connection.executemany( + """ + INSERT INTO scenario_evidence(run_id, evidence_id, kind, document) + VALUES (?, ?, ?, ?) + """, + [ + (run_id, reference.evidence_id, reference.kind.value, reference.model_dump_json()) + for reference in run.evidence + ], + ) + failures = [*run.expected_failures, *run.unexpected_failures] + connection.executemany( + """ + INSERT INTO scenario_failures(run_id, failure_id, expected, category, document) + VALUES (?, ?, ?, ?, ?) + """, + [ + ( + run_id, + failure.failure_id, + int(failure.expected), + failure.category.value, + failure.model_dump_json(), + ) + for failure in failures + ], + ) + + @staticmethod + def _require_active_lab_session(connection: sqlite3.Connection, lab_session_id: str) -> LabSession: + row = connection.execute( + "SELECT document FROM lab_sessions WHERE session_id = ?", + (lab_session_id,), + ).fetchone() + if row is None: + raise BitScopeError( + code="LAB_SESSION_NOT_FOUND", + message="A scenario run requires an existing lab session.", + status_code=404, + details={"lab_session_id": lab_session_id}, + ) + session = LabSession.model_validate_json(row[0]) + if session.status != "active": + raise BitScopeError( + code="LAB_SESSION_NOT_ACTIVE", + message="A scenario run can only be created for an active lab session.", + status_code=409, + details={"lab_session_id": lab_session_id, "status": session.status}, + ) + return session + + @staticmethod + def _revision_conflict(run: ScenarioRun, expected_revision: int, actual_revision: int) -> BitScopeError: + return BitScopeError( + code="SCENARIO_RUN_REVISION_CONFLICT", + message="The scenario run changed after it was loaded. Reload it before advancing again.", + status_code=409, + details={ + "run_id": str(run.run_id), + "expected_revision": expected_revision, + "actual_revision": actual_revision, + }, + ) + + @staticmethod + def _validate_update(stored: ScenarioRun, submitted: ScenarioRun) -> None: + if stored.current_state in TERMINAL_RUN_STATES: + raise BitScopeError( + code="SCENARIO_RUN_TERMINAL", + message="A terminal scenario run cannot be modified.", + status_code=409, + details={"run_id": str(submitted.run_id), "state": stored.current_state.value}, + ) + + if submitted.current_state != stored.current_state: + allowed = ScenarioRun.ALLOWED_TRANSITIONS.get(stored.current_state, frozenset()) + if submitted.current_state not in allowed: + raise BitScopeError( + code="SCENARIO_RUN_INVALID_TRANSITION", + message="The submitted scenario run state does not follow the state machine.", + status_code=409, + details={ + "run_id": str(submitted.run_id), + "current_state": stored.current_state.value, + "submitted_state": submitted.current_state.value, + }, + ) + + immutable_fields = ( + "runtime_chain", + "bitcoin_core_version", + "start_state", + "defined_step_ids", + "required_assertion_ids", + "created_at", + ) + changed_fields = [ + field_name + for field_name in immutable_fields + if getattr(stored, field_name) != getattr(submitted, field_name) + ] + if changed_fields: + raise BitScopeError( + code="SCENARIO_RUN_IDENTITY_MISMATCH", + message="Persisted scenario run context cannot be rewritten.", + status_code=409, + details={"run_id": str(submitted.run_id), "changed_fields": changed_fields}, + ) + + append_only_fields = ( + "step_results", + "assertion_results", + "expected_failures", + "unexpected_failures", + "evidence", + ) + rewritten = [ + field_name + for field_name in append_only_fields + if not ScenarioRunStore._is_prefix(getattr(stored, field_name), getattr(submitted, field_name)) + ] + if rewritten: + raise BitScopeError( + code="SCENARIO_RUN_HISTORY_REWRITE", + message="Recorded scenario results and evidence are append-only.", + status_code=409, + details={"run_id": str(submitted.run_id), "rewritten_fields": rewritten}, + ) + + allowed_cleanup: dict[CleanupStatus, frozenset[CleanupStatus]] = { + CleanupStatus.NOT_STARTED: frozenset( + {CleanupStatus.NOT_STARTED, CleanupStatus.IN_PROGRESS, CleanupStatus.COMPLETED, CleanupStatus.FAILED} + ), + CleanupStatus.IN_PROGRESS: frozenset( + {CleanupStatus.IN_PROGRESS, CleanupStatus.COMPLETED, CleanupStatus.FAILED} + ), + CleanupStatus.COMPLETED: frozenset({CleanupStatus.COMPLETED}), + CleanupStatus.FAILED: frozenset({CleanupStatus.FAILED}), + } + if submitted.cleanup_status not in allowed_cleanup[stored.cleanup_status]: + raise BitScopeError( + code="SCENARIO_CLEANUP_INVALID_TRANSITION", + message="Scenario cleanup status cannot move backward.", + status_code=409, + details={ + "run_id": str(submitted.run_id), + "current_status": stored.cleanup_status.value, + "submitted_status": submitted.cleanup_status.value, + }, + ) + + @staticmethod + def _is_prefix(stored: list[object], submitted: list[object]) -> bool: + return len(submitted) >= len(stored) and submitted[: len(stored)] == stored + + @staticmethod + def _create_schema(connection: sqlite3.Connection) -> None: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS bitscope_schema_migrations ( + component TEXT PRIMARY KEY, + version INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS scenario_runs ( + run_id TEXT PRIMARY KEY, + lab_session_id TEXT NOT NULL, + scenario_id TEXT NOT NULL, + scenario_version TEXT NOT NULL, + current_state TEXT NOT NULL, + revision INTEGER NOT NULL CHECK(revision >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + document TEXT NOT NULL, + FOREIGN KEY(lab_session_id) REFERENCES lab_sessions(session_id) ON DELETE RESTRICT + ); + + CREATE INDEX IF NOT EXISTS scenario_runs_by_session + ON scenario_runs(lab_session_id, created_at, run_id); + CREATE INDEX IF NOT EXISTS scenario_runs_by_scenario + ON scenario_runs(scenario_id, scenario_version, current_state); + + CREATE TABLE IF NOT EXISTS scenario_step_runs ( + run_id TEXT NOT NULL, + step_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK(ordinal >= 1), + status TEXT NOT NULL, + document TEXT NOT NULL, + PRIMARY KEY(run_id, step_id), + FOREIGN KEY(run_id) REFERENCES scenario_runs(run_id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS scenario_assertions ( + run_id TEXT NOT NULL, + assertion_id TEXT NOT NULL, + status TEXT NOT NULL, + document TEXT NOT NULL, + PRIMARY KEY(run_id, assertion_id), + FOREIGN KEY(run_id) REFERENCES scenario_runs(run_id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS scenario_evidence ( + run_id TEXT NOT NULL, + evidence_id TEXT NOT NULL, + kind TEXT NOT NULL, + document TEXT NOT NULL, + PRIMARY KEY(run_id, evidence_id), + FOREIGN KEY(run_id) REFERENCES scenario_runs(run_id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS scenario_failures ( + run_id TEXT NOT NULL, + failure_id TEXT NOT NULL, + expected INTEGER NOT NULL CHECK(expected IN (0, 1)), + category TEXT NOT NULL, + document TEXT NOT NULL, + PRIMARY KEY(run_id, failure_id), + FOREIGN KEY(run_id) REFERENCES scenario_runs(run_id) ON DELETE CASCADE + ); + """ + ) + row = connection.execute( + "SELECT version FROM bitscope_schema_migrations WHERE component = ?", + ("scenario_runs",), + ).fetchone() + if row is not None and int(row[0]) > SCHEMA_VERSION: + raise BitScopeError( + code="SCENARIO_SCHEMA_TOO_NEW", + message="The scenario database schema is newer than this BitScope version supports.", + status_code=500, + details={"supported_version": SCHEMA_VERSION, "database_version": int(row[0])}, + ) + connection.execute( + """ + INSERT INTO bitscope_schema_migrations(component, version) + VALUES (?, ?) + ON CONFLICT(component) DO UPDATE SET version = excluded.version + """, + ("scenario_runs", SCHEMA_VERSION), + ) + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.database_path, timeout=10) + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA busy_timeout = 10000") + return connection diff --git a/backend/app/services/scenario_service.py b/backend/app/services/scenario_service.py new file mode 100644 index 0000000..0700c7c --- /dev/null +++ b/backend/app/services/scenario_service.py @@ -0,0 +1,622 @@ +from dataclasses import replace +from datetime import UTC, datetime +from uuid import UUID + +from app.errors import BitScopeError +from app.models.attack import AttackVerificationResult +from app.models.evidence import CapturedEvidence, EvidenceRecord +from app.models.scenario import ( + CleanupStatus, + FailureCategory, + ScenarioDefinition, + ScenarioFailure, + ScenarioRun, + ScenarioRunState, + ScenarioStepResult, + ScenarioStepResultStatus, + TERMINAL_RUN_STATES, +) +from app.rpc.capabilities import ReadOnlyRpcClient, RpcTransport +from app.services.cltv_timelock_scenario import CLTV_TIMELOCK_SCENARIO +from app.services.cltv_timelock_scenario_service import CltvTimelockScenarioService +from app.services.community_treasury_scenario import COMMUNITY_TREASURY_SCENARIO +from app.services.community_treasury_scenario_service import CommunityTreasuryScenarioService +from app.services.evidence_service import EvidenceService +from app.services.lab_session_store import LabSessionStore +from app.services.lifecycle_recorder import LifecycleRecorder +from app.services.multisig_psbt_scenario import MULTISIG_PSBT_SCENARIO +from app.services.multisig_psbt_scenario_service import MultisigPsbtScenarioService +from app.services.network_safety import NetworkSafetyGuard +from app.services.rbf_scenario import RBF_REPLACEMENT_SCENARIO +from app.services.rbf_scenario_service import RbfScenarioService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import ScenarioCatalog +from app.services.scenario_execution import ScenarioExecutionError, ScenarioExecutor +from app.services.scenario_run_store import ScenarioRunStore +from app.services.transaction_lifecycle_scenario import TRANSACTION_LIFECYCLE_SCENARIO +from app.services.transaction_lifecycle_service import TransactionLifecycleService + + +class ScenarioService: + """Coordinate safe run preparation without claiming scenario execution.""" + + def __init__( + self, + rpc_client: RpcTransport, + store: ScenarioRunStore, + catalog: ScenarioCatalog, + evidence_service: EvidenceService, + artifact_store: ScenarioArtifactStore, + lab_store: LabSessionStore | None = None, + ) -> None: + self.rpc = ReadOnlyRpcClient(rpc_client) + self.store = store + self.catalog = catalog + self.evidence_service = evidence_service + self.artifact_store = artifact_store + self.lifecycle_recorder = LifecycleRecorder(evidence_service.redactor) + owned_lab_store = lab_store or LabSessionStore(store.database_path) + self.lifecycle_service = TransactionLifecycleService( + rpc_client, + owned_lab_store, + ) + self.rbf_service = RbfScenarioService( + rpc_client, + owned_lab_store, + ) + self.multisig_psbt_service = MultisigPsbtScenarioService( + rpc_client, + owned_lab_store, + ) + self.cltv_timelock_service = CltvTimelockScenarioService( + rpc_client, + owned_lab_store, + ) + self.community_treasury_service = CommunityTreasuryScenarioService( + rpc_client, + owned_lab_store, + ) + + def create_run(self, scenario_id: str, lab_session_id: str) -> ScenarioRun: + definition = self.catalog.require_available(scenario_id) + NetworkSafetyGuard(self.rpc).require_regtest() + run = ScenarioRun.create( + definition, + lab_session_id, + bitcoin_core_version=self._bitcoin_core_version(), + ) + self.store.create(run) + return run + + def get_run(self, run_id: UUID, lab_session_id: str) -> ScenarioRun: + run = self.store.get_for_session(run_id, lab_session_id) + if run is None: + raise BitScopeError( + code="SCENARIO_RUN_NOT_FOUND", + message="The requested scenario run does not exist.", + status_code=404, + details={"run_id": str(run_id)}, + ) + return run + + def advance(self, run_id: UUID, lab_session_id: str, expected_revision: int) -> ScenarioRun: + run = self.get_run(run_id, lab_session_id) + self._require_revision(run, expected_revision) + if run.current_state == ScenarioRunState.READY: + definition = self.catalog.get_version(run.scenario_id, run.scenario_version) + if definition == TRANSACTION_LIFECYCLE_SCENARIO: + return self._execute_reviewed_scenario(run, definition, self.lifecycle_service) + if definition == RBF_REPLACEMENT_SCENARIO: + return self._execute_reviewed_scenario(run, definition, self.rbf_service) + if definition == MULTISIG_PSBT_SCENARIO: + return self._execute_reviewed_scenario( + run, + definition, + self.multisig_psbt_service, + ) + if definition == CLTV_TIMELOCK_SCENARIO: + return self._execute_reviewed_scenario( + run, + definition, + self.cltv_timelock_service, + ) + if definition == COMMUNITY_TREASURY_SCENARIO: + return self._execute_reviewed_scenario( + run, + definition, + self.community_treasury_service, + ) + raise self._execution_not_available(run) + if run.current_state != ScenarioRunState.CREATED: + raise self._execution_not_available(run) + + definition = self.catalog.get_version(run.scenario_id, run.scenario_version) + context, blockchain_info = NetworkSafetyGuard(self.rpc).require_regtest_with_info() + network_info = self.rpc.get_network_info() + block_height = self.rpc.get_block_count() + if not isinstance(network_info, dict): + raise self._invalid_readiness_response("getnetworkinfo") + if not isinstance(block_height, int) or isinstance(block_height, bool) or block_height < 0: + raise self._invalid_readiness_response("getblockcount") + + captured_at = datetime.now(UTC) + record = EvidenceRecord( + evidence_id="node.context", + kind="node_context", + label="Verified regtest node context", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=definition.steps[0].step_id, + captured_at=captured_at, + core_output={ + "safe_parameters": [], + "result": { + "getblockchaininfo": blockchain_info, + "getnetworkinfo": network_info, + "getblockcount": block_height, + }, + "run_specific_paths": ["$.result.getblockcount"], + }, + bitscope_interpretation={ + "summary": "Bitcoin Core and BitScope configuration agree on an isolated regtest runtime.", + "facts": [ + {"name": "node.configured_network", "value": context.configured_network}, + {"name": "node.runtime_chain", "value": context.runtime_chain}, + {"name": "node.block_height", "value": block_height, "run_specific": True}, + {"name": "node.core_version", "value": self._bitcoin_core_version_from(network_info)}, + ], + "limitations": [ + "Node readiness verifies chain identity and observable node context; it does not execute scenario steps." + ], + }, + commands=[ + {"arguments": ["-regtest", "getblockchaininfo"], "description": "Inspect the live chain identity."}, + {"arguments": ["-regtest", "getnetworkinfo"], "description": "Inspect the Bitcoin Core version."}, + {"arguments": ["-regtest", "getblockcount"], "description": "Inspect the current block height."}, + ], + ) + captured = self.evidence_service.capture(run, record) + ready = run.transition_to( + ScenarioRunState.READY, + now=captured_at, + evidence_reference=captured.reference, + ) + persisted = replace(captured, run=ready) + created = self.artifact_store.write_evidence(persisted) + try: + self.store.save(ready, expected_revision=expected_revision) + except Exception: + if created: + try: + self.artifact_store.delete_evidence(persisted) + except (BitScopeError, OSError): + pass + raise + return ready + + def _execute_reviewed_scenario( + self, + ready: ScenarioRun, + definition: ScenarioDefinition, + executor: ScenarioExecutor, + ) -> ScenarioRun: + timestamp = datetime.now(UTC) + running = ready.checkpoint(state=ScenarioRunState.RUNNING, now=timestamp) + self.store.save(running, expected_revision=ready.revision) + + try: + execution = executor.execute(running, definition) + except ScenarioExecutionError as exc: + return self._fail_scenario_execution(running, executor, exc) + except Exception: + return self._fail_scenario_execution( + running, + executor, + ScenarioExecutionError( + "prepare_wallet", + self._internal_execution_error(), + ), + ) + + try: + evidence_records = list(execution.evidence_records) + if execution.attack_results: + evidence_records.append( + self._attack_evidence(running, execution.attack_results, timestamp) + ) + lifecycle_events = self.lifecycle_recorder.record( + running, + execution.evidence_records, + ) + if lifecycle_events: + evidence_records.append( + self.lifecycle_recorder.evidence(running, lifecycle_events, timestamp) + ) + captured = [ + self.evidence_service.capture(running, record) + for record in evidence_records + ] + verifying = running.checkpoint( + state=ScenarioRunState.VERIFYING, + step_results=execution.step_results, + evidence_references=[item.reference for item in captured], + now=timestamp, + ) + self._persist_checkpoint_with_evidence(running, verifying, captured) + except Exception as exc: + error = exc if isinstance(exc, BitScopeError) else self._internal_execution_error() + return self._fail_scenario_execution( + running, + executor, + ScenarioExecutionError("export_proof", error), + ) + + verification_results = [ + ScenarioStepResult( + step_id="verify_results", + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + evidence_ids=[ + reference.evidence_id + for reference in verifying.evidence + if reference.evidence_id != "node.context" + ], + ), + ScenarioStepResult( + step_id="export_proof", + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=["proof.bundle"], + ), + ] + cleaning = verifying.checkpoint( + state=ScenarioRunState.CLEANING, + step_results=verification_results, + assertion_results=execution.assertion_results, + cleanup_status=CleanupStatus.IN_PROGRESS, + now=datetime.now(UTC), + ) + try: + self.store.save(cleaning, expected_revision=verifying.revision) + except Exception: + self._best_effort_cleanup(executor, verifying) + raise + + try: + executor.cleanup(cleaning) + except Exception as exc: + error = exc if isinstance(exc, BitScopeError) else self._internal_cleanup_error() + return self._finish_cleanup_failure(cleaning, error) + + completed_at = datetime.now(UTC) + cleanup_evidence = self.lifecycle_recorder.cleanup_evidence( + cleaning, + completed_at, + len(lifecycle_events) + 1, + ) + captured_cleanup = self.evidence_service.capture(cleaning, cleanup_evidence) + cleanup_result = ScenarioStepResult( + step_id="cleanup", + status=ScenarioStepResultStatus.COMPLETED, + started_at=completed_at, + completed_at=completed_at, + ) + verified = cleaning.checkpoint( + state=ScenarioRunState.VERIFIED, + step_results=[cleanup_result], + evidence_references=[captured_cleanup.reference], + cleanup_status=CleanupStatus.COMPLETED, + now=completed_at, + ) + self._persist_checkpoint_with_evidence(cleaning, verified, [captured_cleanup]) + return verified + + def _fail_scenario_execution( + self, + running: ScenarioRun, + executor: ScenarioExecutor, + execution_error: ScenarioExecutionError, + ) -> ScenarioRun: + timestamp = datetime.now(UTC) + error = execution_error.cause + failure_record = executor.failure_evidence( + running, + execution_error.step_id, + error, + timestamp, + ) + captured = self.evidence_service.capture(running, failure_record) + safe_message = self.evidence_service.redactor.redact( + error.details.get("rpc_message") if isinstance(error.details.get("rpc_message"), str) else error.message + ) + attack_result = error.details.get("attack_result") + attack_id = attack_result.get("attack_id") if isinstance(attack_result, dict) else None + raw_safe_details = ( + self.evidence_service.redactor.redact(attack_result.get("raw_safe_details")) + if isinstance(attack_result, dict) + else None + ) + failure = ScenarioFailure( + failure_id=f"failure.{execution_error.step_id}", + step_id=execution_error.step_id, + category=self._failure_category(error), + expected=False, + code=error.code, + safe_message=str(safe_message)[:2_000], + rpc_code=error.details.get("rpc_code") if isinstance(error.details.get("rpc_code"), int) else None, + attack_id=attack_id if isinstance(attack_id, str) else None, + raw_safe_details=raw_safe_details, + evidence_ids=[captured.reference.evidence_id], + ) + failed_step = ScenarioStepResult( + step_id=execution_error.step_id, + status=ScenarioStepResultStatus.UNEXPECTED_FAILURE, + started_at=timestamp, + completed_at=timestamp, + evidence_ids=[captured.reference.evidence_id], + failure=failure, + ) + cleaning = running.checkpoint( + state=ScenarioRunState.CLEANING, + step_results=[failed_step], + evidence_references=[captured.reference], + cleanup_status=CleanupStatus.IN_PROGRESS, + now=timestamp, + ) + try: + self._persist_checkpoint_with_evidence(running, cleaning, [captured]) + except Exception: + self._best_effort_cleanup(executor, running) + raise + try: + executor.cleanup(cleaning) + except Exception as exc: + cleanup_error = exc if isinstance(exc, BitScopeError) else self._internal_cleanup_error() + return self._finish_cleanup_failure(cleaning, cleanup_error) + + completed_at = datetime.now(UTC) + cleanup_evidence = self.lifecycle_recorder.cleanup_evidence( + cleaning, + completed_at, + 1, + ) + captured_cleanup = self.evidence_service.capture(cleaning, cleanup_evidence) + cleanup_result = ScenarioStepResult( + step_id="cleanup", + status=ScenarioStepResultStatus.COMPLETED, + started_at=completed_at, + completed_at=completed_at, + ) + failed = cleaning.checkpoint( + state=ScenarioRunState.FAILED, + step_results=[cleanup_result], + evidence_references=[captured_cleanup.reference], + cleanup_status=CleanupStatus.COMPLETED, + now=completed_at, + ) + self._persist_checkpoint_with_evidence(cleaning, failed, [captured_cleanup]) + return failed + + def _finish_cleanup_failure(self, cleaning: ScenarioRun, error: BitScopeError) -> ScenarioRun: + timestamp = datetime.now(UTC) + safe_message = self.evidence_service.redactor.redact( + error.details.get("rpc_message") if isinstance(error.details.get("rpc_message"), str) else error.message + ) + failure = ScenarioFailure( + failure_id="failure.cleanup", + step_id="cleanup", + category=self._failure_category(error), + expected=False, + code=error.code, + safe_message=str(safe_message)[:2_000], + rpc_code=error.details.get("rpc_code") if isinstance(error.details.get("rpc_code"), int) else None, + ) + cleanup_result = ScenarioStepResult( + step_id="cleanup", + status=ScenarioStepResultStatus.UNEXPECTED_FAILURE, + started_at=timestamp, + completed_at=timestamp, + failure=failure, + ) + failed = cleaning.checkpoint( + state=ScenarioRunState.CLEANUP_FAILED, + step_results=[cleanup_result], + cleanup_status=CleanupStatus.FAILED, + now=timestamp, + ) + self.store.save(failed, expected_revision=cleaning.revision) + return failed + + def _persist_checkpoint_with_evidence( + self, + previous: ScenarioRun, + checkpoint: ScenarioRun, + captured: list[CapturedEvidence], + ) -> None: + persisted = [replace(item, run=checkpoint) for item in captured] + created: list[CapturedEvidence] = [] + try: + for item in persisted: + if self.artifact_store.write_evidence(item): + created.append(item) + self.store.save(checkpoint, expected_revision=previous.revision) + except Exception: + for item in created: + try: + self.artifact_store.delete_evidence(item) + except (BitScopeError, OSError): + pass + raise + + @staticmethod + def _attack_evidence( + run: ScenarioRun, + results: list[AttackVerificationResult], + captured_at: datetime, + ) -> EvidenceRecord: + return EvidenceRecord( + evidence_id="attacks.summary", + kind="assertion", + label="Typed attack verification summary", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id="verify_results", + captured_at=captured_at, + core_output={ + "safe_parameters": [], + "result": [result.model_dump(mode="json") for result in results], + }, + bitscope_interpretation={ + "summary": "BitScope classified reviewed negative paths only after explicit applicability decisions.", + "facts": [ + { + "name": "attacks.result_count", + "value": len(results), + "run_specific": True, + }, + { + "name": "attacks.expected_failure_count", + "value": sum( + result.status.value == "expected_failure" for result in results + ), + "run_specific": True, + }, + ], + "limitations": [ + "Only reviewed typed attacks are executable; unsupported attacks are skipped with an explicit reason.", + "Raw details are bounded and recursively redacted before persistence.", + ], + }, + ) + + @staticmethod + def _failure_category(error: BitScopeError) -> FailureCategory: + if error.code in {"BITCOIN_NETWORK_MISMATCH", "REGTEST_ONLY", "BITCOIN_CHAIN_UNVERIFIED"}: + return FailureCategory.RUNTIME_NETWORK_SAFETY + if error.code in {"INVALID_RPC_PARAMETER", "RPC_CAPABILITY_VIOLATION"}: + return FailureCategory.RPC_PARAMETER + if error.code in {"TRANSACTION_REJECTED", "TRANSACTION_REJECTED_BY_POLICY"}: + return FailureCategory.MEMPOOL_POLICY + if error.code.startswith("SCENARIO_") or error.code.startswith("LAB_"): + return FailureCategory.BITSCOPE_VALIDATION + return FailureCategory.UNEXPECTED_APPLICATION + + @staticmethod + def _best_effort_cleanup(executor: ScenarioExecutor, run: ScenarioRun) -> None: + try: + executor.cleanup(run) + except Exception: + pass + + @staticmethod + def _internal_execution_error() -> BitScopeError: + return BitScopeError( + code="SCENARIO_EXECUTION_INTERNAL_ERROR", + message="The transaction lifecycle stopped because of an unexpected internal error.", + status_code=500, + ) + + @staticmethod + def _internal_cleanup_error() -> BitScopeError: + return BitScopeError( + code="SCENARIO_CLEANUP_INTERNAL_ERROR", + message="The transaction lifecycle could not complete isolated-wallet cleanup.", + status_code=500, + ) + + @staticmethod + def _execution_not_available(run: ScenarioRun) -> BitScopeError: + return BitScopeError( + code="SCENARIO_EXECUTION_NOT_AVAILABLE", + message="No reviewed executor is available for this scenario state and version.", + status_code=409, + details={"run_id": str(run.run_id), "state": run.current_state.value}, + ) + + def reset(self, run_id: UUID, lab_session_id: str, expected_revision: int) -> ScenarioRun: + previous = self.get_run(run_id, lab_session_id) + self._require_revision(previous, expected_revision) + if not self._can_discard(previous): + raise self._cleanup_required(previous) + + definition = self.catalog.require_version(previous.scenario_id, previous.scenario_version) + NetworkSafetyGuard(self.rpc).require_regtest() + replacement = ScenarioRun.create( + definition, + lab_session_id, + bitcoin_core_version=self._bitcoin_core_version(), + ) + self.store.create(replacement) + return replacement + + def delete(self, run_id: UUID, lab_session_id: str, expected_revision: int) -> bool: + run = self.get_run(run_id, lab_session_id) + self._require_revision(run, expected_revision) + if not self._can_discard(run): + raise self._cleanup_required(run) + deleted = self.store.delete(run_id, lab_session_id) + if deleted: + self.artifact_store.delete_run(run) + return deleted + + def _bitcoin_core_version(self) -> str | None: + info = self.rpc.get_network_info() + if not isinstance(info, dict): + return None + return self._bitcoin_core_version_from(info) + + @staticmethod + def _bitcoin_core_version_from(info: dict[str, object]) -> str | None: + subversion = info.get("subversion") + if isinstance(subversion, str) and subversion: + return subversion[:120] + version = info.get("version") + return str(version) if isinstance(version, int) else None + + @staticmethod + def _invalid_readiness_response(rpc_method: str) -> BitScopeError: + return BitScopeError( + code="BITCOIN_CORE_INVALID_RESPONSE", + message="Bitcoin Core returned invalid node-readiness evidence.", + status_code=502, + details={"rpc_method": rpc_method}, + ) + + @staticmethod + def _require_revision(run: ScenarioRun, expected_revision: int) -> None: + if run.revision != expected_revision: + raise BitScopeError( + code="SCENARIO_RUN_REVISION_CONFLICT", + message="The scenario run changed after it was loaded. Reload it before trying again.", + status_code=409, + details={ + "run_id": str(run.run_id), + "expected_revision": expected_revision, + "actual_revision": run.revision, + }, + ) + + @staticmethod + def _can_discard(run: ScenarioRun) -> bool: + if run.current_state in {ScenarioRunState.CREATED, ScenarioRunState.READY}: + return True + return run.current_state in TERMINAL_RUN_STATES and run.cleanup_status == CleanupStatus.COMPLETED + + @staticmethod + def _cleanup_required(run: ScenarioRun) -> BitScopeError: + return BitScopeError( + code="SCENARIO_RUN_CLEANUP_REQUIRED", + message="This scenario run must complete cleanup before it can be reset or deleted.", + status_code=409, + details={ + "run_id": str(run.run_id), + "state": run.current_state.value, + "cleanup_status": run.cleanup_status.value, + }, + ) diff --git a/backend/app/services/timelock_service.py b/backend/app/services/timelock_service.py index 6df25f3..e69cd88 100644 --- a/backend/app/services/timelock_service.py +++ b/backend/app/services/timelock_service.py @@ -1,13 +1,23 @@ +import hashlib +import struct +from decimal import Decimal + +from ecdsa import SECP256k1, SigningKey +from ecdsa.util import sigencode_der_canonize from app.errors import BitScopeError -from app.rpc.client import BitcoinRpcClient -from app.rpc.capabilities import RegtestMutationRpcClient +from app.rpc.capabilities import RegtestMutationRpcClient, RpcTransport from app.rpc.types import JsonValue from app.services.network_safety import NetworkSafetyGuard +from app.services.spend_preflight import SpendPreflight + + +SATOSHI = Decimal("0.00000001") class TimelockService: - def __init__(self, rpc_client: BitcoinRpcClient) -> None: + def __init__(self, rpc_client: RpcTransport) -> None: self.rpc_client = RegtestMutationRpcClient(rpc_client) + self._cltv_keys: dict[str, SigningKey] = {} def create_locktime_transaction( self, @@ -116,6 +126,289 @@ def create_locktime_transaction( }, } + def create_cltv_policy( + self, + lock_height: int, + ) -> dict[str, object]: + """Create a native-SegWit CLTV policy backed by an ephemeral in-memory key.""" + + NetworkSafetyGuard(self.rpc_client).require_regtest() + if lock_height < 1 or lock_height >= 500_000_000: + raise BitScopeError( + code="INVALID_TIMELOCK_REQUEST", + message="The CLTV lock must be an absolute block height below 500000000.", + status_code=400, + ) + signing_key = SigningKey.generate(curve=SECP256k1) + pubkey = self._compressed_pubkey(signing_key) + template = self.script_template("cltv", lock_height, pubkey) + segwit = self._as_dict(template.get("segwit")) + policy_address = self._require_str( + segwit.get("address"), + "decodescript", + "Bitcoin Core did not return a native-SegWit CLTV address.", + ) + script_pub_key = self._require_str( + segwit.get("hex"), + "decodescript", + "Bitcoin Core did not return the CLTV output script.", + ) + witness_script = self._require_str( + template.get("script_hex"), + "decodescript", + "Bitcoin Core did not return the CLTV witness script.", + ) + self._cltv_keys[policy_address] = signing_key + return { + "signer_kind": "ephemeral_software_key", + "lock_height": lock_height, + "pubkey": pubkey, + "policy_address": policy_address, + "script_pub_key": script_pub_key, + "witness_script": witness_script, + "template": template, + "cli_commands": [ + f"bitcoin-cli decodescript {witness_script}", + ], + "rpc_methods": ["decodescript"], + "concepts": ["CLTV", "P2WSH", "Absolute block height", "Ephemeral software key"], + "explanation": ( + "The witness script requires the transaction locktime to reach the reviewed block height, " + "drops that value, and then requires an ephemeral in-memory signer's signature." + ), + "raw": { + "decodescript": template["raw"], + }, + } + + def fund_cltv_policy( + self, + funding_wallet: str, + policy_address: str, + amount_btc: float, + fee_rate_sat_vb: float, + ) -> dict[str, object]: + """Fund a fresh CLTV policy and identify its exact output before confirmation.""" + + NetworkSafetyGuard(self.rpc_client).require_regtest() + clean_wallet = self._clean(funding_wallet, "funding wallet name") + clean_address = self._clean(policy_address, "CLTV policy address") + amount = self._amount(amount_btc) + fee_rate = round(float(fee_rate_sat_vb), 3) + if fee_rate <= 0: + raise BitScopeError( + code="INVALID_TIMELOCK_REQUEST", + message="The CLTV funding fee rate must be greater than zero.", + status_code=400, + ) + preflight = SpendPreflight(self.rpc_client) + validation = preflight.validate_address( + clean_address, + "INVALID_TIMELOCK_ADDRESS", + "Provide a valid CLTV policy address from the current regtest node.", + ) + balance = preflight.require_mature_balance( + clean_wallet, + amount, + "TIMELOCK_INSUFFICIENT_MATURE_FUNDS", + "The funding wallet does not have enough mature balance for the CLTV policy output.", + ) + txid = self._require_str( + self.rpc_client.call( + "sendtoaddress", + [clean_address, amount, "", "", False, True, None, "unset", None, fee_rate], + wallet_name=clean_wallet, + ), + "sendtoaddress", + "Bitcoin Core did not return the CLTV funding transaction id.", + ) + wallet_transaction = self._as_dict( + self.rpc_client.call("gettransaction", [txid], wallet_name=clean_wallet) + ) + raw_hex = self._require_str( + wallet_transaction.get("hex"), + "gettransaction", + "Bitcoin Core did not return the CLTV funding transaction hex.", + ) + decoded = self._as_dict(self.rpc_client.call("decoderawtransaction", [raw_hex])) + output = self._find_output(decoded, clean_address) + return { + "funding_wallet": clean_wallet, + "policy_address": clean_address, + "amount_btc": amount, + "txid": txid, + "vout": output["n"], + "output_amount_btc": output["value"], + "script_pub_key": output["script_pub_key"], + "fee_rate_sat_vb": fee_rate, + "wallet_transaction": wallet_transaction, + "decoded": decoded, + "cli_commands": [ + f"bitcoin-cli -rpcwallet={clean_wallet} sendtoaddress {clean_address} {amount:.8f}", + f"bitcoin-cli -rpcwallet={clean_wallet} gettransaction {txid}", + ], + "rpc_methods": ["validateaddress", "getbalances", "sendtoaddress", "gettransaction", "decoderawtransaction"], + "concepts": ["CLTV", "Funding output", "P2WSH", "Outpoint"], + "explanation": "The session funding wallet creates one policy output whose exact outpoint and amount are retained for the spend.", + "raw": { + "validateaddress": validation, + "getbalances": balance["getbalances"], + "sendtoaddress": txid, + "gettransaction": wallet_transaction, + "decoderawtransaction": decoded, + }, + } + + def create_cltv_spend( + self, + funding: dict[str, object], + policy_address: str, + witness_script: str, + destination_address: str, + locktime: int, + sequence: int, + fee_sats: int, + ) -> dict[str, object]: + """Construct and locally sign one CLTV branch spend without persisting its key.""" + + NetworkSafetyGuard(self.rpc_client).require_regtest() + clean_policy_address = self._clean(policy_address, "CLTV policy address") + signing_key = self._cltv_keys.get(clean_policy_address) + if signing_key is None: + raise BitScopeError( + code="CLTV_SIGNER_NOT_AVAILABLE", + message="The ephemeral CLTV signer is not available for this scenario run.", + status_code=409, + ) + clean_destination = self._clean(destination_address, "destination address") + clean_witness_script = witness_script.strip().lower() + self._validate_hex(clean_witness_script, "witness script") + if locktime < 0 or locktime >= 500_000_000: + raise BitScopeError( + code="INVALID_TIMELOCK_REQUEST", + message="The CLTV spend locktime must be an absolute block height.", + status_code=400, + ) + if sequence < 0 or sequence > 4_294_967_295: + raise BitScopeError( + code="INVALID_TIMELOCK_REQUEST", + message="Sequence must fit in uint32.", + status_code=400, + ) + if fee_sats < 1: + raise BitScopeError( + code="INVALID_TIMELOCK_REQUEST", + message="The CLTV spend fee must be positive.", + status_code=400, + ) + validation = self._as_dict(self.rpc_client.call("validateaddress", [clean_destination])) + if validation.get("isvalid") is not True: + raise BitScopeError( + code="INVALID_TIMELOCK_ADDRESS", + message="Provide a valid CLTV spend destination from the current regtest node.", + status_code=400, + ) + txid = self._require_str( + funding.get("txid"), + "gettransaction", + "The CLTV funding record does not contain a transaction id.", + ) + vout = self._require_int(funding.get("vout"), "gettransaction", "The CLTV funding record has no vout.") + input_amount = self._require_decimal( + funding.get("output_amount_btc"), + "gettransaction", + "The CLTV funding record has no output amount.", + ) + script_pub_key = self._require_str( + funding.get("script_pub_key"), + "decoderawtransaction", + "The CLTV funding record has no output script.", + ) + output_amount = input_amount - (Decimal(fee_sats) * SATOSHI) + if output_amount <= 0: + raise BitScopeError( + code="INVALID_TIMELOCK_REQUEST", + message="The CLTV funding output cannot cover the configured spend fee.", + status_code=400, + ) + input_ref = {"txid": txid, "vout": vout, "sequence": sequence} + unsigned_hex = self._require_str( + self.rpc_client.call( + "createrawtransaction", + [[input_ref], {clean_destination: float(output_amount)}, locktime], + ), + "createrawtransaction", + "Bitcoin Core did not return a CLTV spend transaction.", + ) + decoded_unsigned = self._as_dict( + self.rpc_client.call("decoderawtransaction", [unsigned_hex]) + ) + self._validate_unsigned_cltv( + decoded_unsigned, + txid, + vout, + clean_destination, + float(output_amount), + locktime, + sequence, + ) + destination_script = self._require_str( + validation.get("scriptPubKey"), + "validateaddress", + "Bitcoin Core did not return the destination scriptPubKey.", + ) + signed_hex = self._sign_cltv_transaction( + signing_key, + txid, + vout, + input_amount, + clean_witness_script, + destination_script, + output_amount, + locktime, + sequence, + ) + decoded = self._as_dict(self.rpc_client.call("decoderawtransaction", [signed_hex])) + return { + "signer_kind": "ephemeral_software_key", + "destination_address": clean_destination, + "funding_txid": txid, + "funding_vout": vout, + "input_amount_btc": float(input_amount), + "output_amount_btc": float(output_amount), + "fee_sats": fee_sats, + "locktime": locktime, + "sequence": sequence, + "unsigned_hex": unsigned_hex, + "signed_hex": signed_hex, + "complete": True, + "signing_errors": [], + "decoded_unsigned": decoded_unsigned, + "decoded": decoded, + "cli_commands": [ + ( + "bitcoin-cli createrawtransaction '[]' " + f"'{{\"{clean_destination}\":{float(output_amount):.8f}}}' {locktime}" + ), + "# BitScope signs the BIP143 digest with an ephemeral in-memory key; private material is never exported.", + ], + "rpc_methods": ["validateaddress", "createrawtransaction", "decoderawtransaction"], + "concepts": ["CLTV", "nLockTime", "Sequence", "P2WSH witness", "Wallet signature"], + "explanation": ( + "The transaction commits to an absolute locktime and sequence. BitScope signs its BIP143 digest with " + "an ephemeral key that is never serialized into evidence, settings, SQLite, or an RPC request." + ), + "raw": { + "validateaddress": validation, + "createrawtransaction": unsigned_hex, + "decoderawtransaction_unsigned": decoded_unsigned, + "decoderawtransaction": decoded, + }, + } + + def clear_ephemeral_cltv_keys(self) -> None: + self._cltv_keys.clear() + def script_template(self, mode: str, value: int, pubkey_hex: str) -> dict[str, object]: clean_mode = mode.strip().lower() if clean_mode not in {"cltv", "csv"}: @@ -160,6 +453,188 @@ def _script_number(value: int) -> str: result.append(0) return bytes(result).hex() + @staticmethod + def _compressed_pubkey(signing_key: SigningKey) -> str: + point = signing_key.verifying_key.pubkey.point + prefix = b"\x02" if point.y() % 2 == 0 else b"\x03" + return (prefix + int(point.x()).to_bytes(32, "big")).hex() + + @classmethod + def _sign_cltv_transaction( + cls, + signing_key: SigningKey, + txid: str, + vout: int, + input_amount: Decimal, + witness_script_hex: str, + destination_script_hex: str, + output_amount: Decimal, + locktime: int, + sequence: int, + ) -> str: + version = struct.pack(" None: + if transaction.get("version") != 2 or transaction.get("locktime") != locktime: + raise cls._invalid_response( + "decoderawtransaction", + "Bitcoin Core returned unexpected CLTV transaction version or locktime metadata.", + ) + inputs = transaction.get("vin") + if ( + not isinstance(inputs, list) + or len(inputs) != 1 + or not isinstance(inputs[0], dict) + or inputs[0].get("txid") != funding_txid + or inputs[0].get("vout") != funding_vout + or inputs[0].get("sequence") != sequence + ): + raise cls._invalid_response( + "decoderawtransaction", + "Bitcoin Core returned unexpected CLTV input metadata.", + ) + outputs = transaction.get("vout") + if not isinstance(outputs, list) or len(outputs) != 1 or not isinstance(outputs[0], dict): + raise cls._invalid_response( + "decoderawtransaction", + "Bitcoin Core returned unexpected CLTV output metadata.", + ) + script = outputs[0].get("scriptPubKey") + value = outputs[0].get("value") + if ( + not isinstance(script, dict) + or script.get("address") != destination_address + or not isinstance(value, int | float) + or isinstance(value, bool) + or Decimal(str(value)) != Decimal(str(output_amount)) + ): + raise cls._invalid_response( + "decoderawtransaction", + "Bitcoin Core returned unexpected CLTV destination metadata.", + ) + + @staticmethod + def _compact_size(value: int) -> bytes: + if value < 0xFD: + return bytes([value]) + if value <= 0xFFFF: + return b"\xfd" + struct.pack(" bytes: + return hashlib.sha256(hashlib.sha256(value).digest()).digest() + + @staticmethod + def _satoshis(value: Decimal) -> int: + satoshis = value * Decimal(100_000_000) + if satoshis != satoshis.to_integral_value(): + raise BitScopeError( + code="INVALID_TIMELOCK_REQUEST", + message="CLTV transaction amounts must resolve to whole satoshis.", + status_code=400, + ) + return int(satoshis) + + @classmethod + def _find_output(cls, transaction: dict[str, object], address: str) -> dict[str, object]: + outputs = transaction.get("vout") + if not isinstance(outputs, list): + raise BitScopeError( + code="BITCOIN_CORE_INVALID_RESPONSE", + message="Bitcoin Core returned a funding transaction without outputs.", + status_code=502, + details={"rpc_method": "decoderawtransaction"}, + ) + for output in outputs: + if not isinstance(output, dict): + continue + script = output.get("scriptPubKey") + if not isinstance(script, dict) or script.get("address") != address: + continue + return { + "n": cls._require_int(output.get("n"), "decoderawtransaction", "The CLTV output has no index."), + "value": float( + cls._require_decimal(output.get("value"), "decoderawtransaction", "The CLTV output has no amount.") + ), + "script_pub_key": cls._require_str( + script.get("hex"), + "decoderawtransaction", + "The CLTV output has no scriptPubKey.", + ), + } + raise BitScopeError( + code="TIMELOCK_OUTPUT_NOT_FOUND", + message="Bitcoin Core did not return the fresh CLTV policy output.", + status_code=502, + details={"rpc_method": "decoderawtransaction"}, + ) + @staticmethod def _validate_hex(value: str, label: str) -> None: if not value or len(value) % 2 != 0: @@ -222,3 +697,39 @@ def _optional_int(value: object) -> int | None: @staticmethod def _optional_str(value: object) -> str | None: return value if isinstance(value, str) and value else None + + @staticmethod + def _require_int(value: object, rpc_method: str, message: str) -> int: + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + raise BitScopeError( + code="BITCOIN_CORE_INVALID_RESPONSE", + message=message, + status_code=502, + details={"rpc_method": rpc_method}, + ) + + @staticmethod + def _require_decimal(value: object, rpc_method: str, message: str) -> Decimal: + if isinstance(value, int | float | str) and not isinstance(value, bool): + try: + parsed = Decimal(str(value)) + except ArithmeticError: + parsed = Decimal(0) + if parsed > 0: + return parsed + raise BitScopeError( + code="BITCOIN_CORE_INVALID_RESPONSE", + message=message, + status_code=502, + details={"rpc_method": rpc_method}, + ) + + @staticmethod + def _invalid_response(rpc_method: str, message: str) -> BitScopeError: + return BitScopeError( + code="BITCOIN_CORE_INVALID_RESPONSE", + message=message, + status_code=502, + details={"rpc_method": rpc_method}, + ) diff --git a/backend/app/services/transaction_lifecycle_scenario.py b/backend/app/services/transaction_lifecycle_scenario.py new file mode 100644 index 0000000..6e26903 --- /dev/null +++ b/backend/app/services/transaction_lifecycle_scenario.py @@ -0,0 +1,265 @@ +from app.models.scenario import ScenarioDefinition + + +TRANSACTION_LIFECYCLE_SCENARIO = ScenarioDefinition.model_validate( + { + "scenario_id": "transaction-lifecycle", + "version": "1.0.0", + "name": "Transaction lifecycle", + "summary": ( + "Follow two freshly matured regtest UTXOs through explicit selection, raw transaction " + "construction, wallet signing, mempool preflight, broadcast, mempool observation, confirmation, " + "and final decoding; then prove that Bitcoin Core rejects a signed overspend transaction." + ), + "difficulty": "beginner", + "related_lbcli_chapters": [3, 4, 5, 7], + "concepts": [ + "Transaction lifecycle", + "UTXO selection", + "Wallet signing", + "Mempool policy", + "Confirmation", + "Consensus rejection", + ], + "required_capabilities": ["read_only", "wallet_read", "regtest_mutation"], + "estimated_run_steps": 19, + "steps": [ + { + "step_id": "verify_chain", + "type": "verify_runtime_chain", + "phase": "setup", + "title": "Verify the runtime chain", + "description": "Require the configured node and live Bitcoin Core chain to agree on regtest.", + "output_context_ref": "node.context", + }, + { + "step_id": "prepare_wallet", + "type": "prepare_isolated_wallet", + "phase": "setup", + "title": "Prepare the isolated wallet", + "description": "Verify and use only the wallet owned by this run's active lab session.", + "depends_on": ["verify_chain"], + "wallet_role": "operator", + "output_wallet_ref": "wallet.operator", + }, + { + "step_id": "generate_mining_address", + "type": "generate_address", + "phase": "setup", + "title": "Generate a mining address", + "description": "Generate a fresh bech32 address in the session-owned wallet.", + "depends_on": ["prepare_wallet"], + "wallet_ref": "wallet.operator", + "label": "bitscope-lifecycle-mining", + "address_type": "bech32", + "output_address_ref": "address.mining", + }, + { + "step_id": "mine_mature_funds", + "type": "mine_blocks", + "phase": "setup", + "title": "Mine mature funds", + "description": "Mine 102 blocks so two new coinbase outputs reach 101 confirmations.", + "depends_on": ["generate_mining_address"], + "address_ref": "address.mining", + "blocks": 102, + "output_blocks_ref": "blocks.maturity", + }, + { + "step_id": "generate_recipient", + "type": "generate_address", + "phase": "setup", + "title": "Generate the recipient", + "description": "Generate a distinct fresh destination address for this run.", + "depends_on": ["mine_mature_funds"], + "wallet_ref": "wallet.operator", + "label": "bitscope-lifecycle-recipient", + "address_type": "bech32", + "output_address_ref": "address.recipient", + }, + { + "step_id": "select_utxos", + "type": "select_utxos", + "phase": "execution", + "title": "Select two mature UTXOs", + "description": "Select two distinct spendable coinbase outputs with at least 101 confirmations.", + "depends_on": ["generate_recipient"], + "wallet_ref": "wallet.operator", + "minimum_amount_btc": "0.00001000", + "minimum_confirmations": 101, + "output_utxos_ref": "utxos.selected", + }, + { + "step_id": "construct_transaction", + "type": "create_selected_utxo_transaction", + "phase": "execution", + "title": "Construct the transaction", + "description": "Spend the first selected UTXO and leave an explicit 10,000 satoshi fee.", + "depends_on": ["select_utxos"], + "utxos_ref": "utxos.selected", + "selected_index": 0, + "recipient_address_ref": "address.recipient", + "fee_sats": 10000, + "output_transaction_ref": "transaction.unsigned", + }, + { + "step_id": "sign_transaction", + "type": "sign_raw_transaction", + "phase": "execution", + "title": "Sign the transaction", + "description": "Ask the isolated wallet to sign the selected input completely.", + "depends_on": ["construct_transaction"], + "wallet_ref": "wallet.operator", + "transaction_ref": "transaction.unsigned", + "output_transaction_ref": "transaction.signed", + }, + { + "step_id": "preflight_transaction", + "type": "test_mempool_accept", + "phase": "execution", + "title": "Preflight mempool acceptance", + "description": "Use Bitcoin Core's structured testmempoolaccept result before broadcast.", + "depends_on": ["sign_transaction"], + "transaction_ref": "transaction.signed", + "output_acceptance_ref": "acceptance.normal", + }, + { + "step_id": "broadcast_transaction", + "type": "broadcast_transaction", + "phase": "execution", + "title": "Broadcast the transaction", + "description": "Broadcast only after the positive preflight succeeds.", + "depends_on": ["preflight_transaction"], + "transaction_ref": "transaction.signed", + "output_txid_ref": "transaction.txid", + }, + { + "step_id": "inspect_mempool", + "type": "query_mempool_entry", + "phase": "execution", + "title": "Inspect the mempool entry", + "description": "Record Bitcoin Core's live mempool metadata for the broadcast transaction.", + "depends_on": ["broadcast_transaction"], + "txid_ref": "transaction.txid", + "output_mempool_ref": "mempool.entry", + }, + { + "step_id": "confirm_transaction", + "type": "mine_confirmation_blocks", + "phase": "execution", + "title": "Confirm the transaction", + "description": "Mine one block to confirm the transaction on regtest.", + "depends_on": ["inspect_mempool"], + "address_ref": "address.mining", + "blocks": 1, + "output_blocks_ref": "blocks.confirmation", + }, + { + "step_id": "decode_confirmed_transaction", + "type": "decode_transaction", + "phase": "execution", + "title": "Decode the confirmed transaction", + "description": "Read the wallet transaction and decode its final confirmed serialization.", + "depends_on": ["confirm_transaction"], + "transaction_ref": "transaction.signed", + "output_decoded_ref": "transaction.confirmed", + }, + { + "step_id": "construct_overspend", + "type": "create_overspend_transaction", + "phase": "attack", + "title": "Construct an overspend transaction", + "description": "Create a valid serialization whose output exceeds the second selected input by one satoshi.", + "depends_on": ["decode_confirmed_transaction"], + "utxos_ref": "utxos.selected", + "selected_index": 1, + "recipient_address_ref": "address.recipient", + "excess_sats": 1, + "output_transaction_ref": "attack.unsigned", + }, + { + "step_id": "sign_overspend", + "type": "sign_raw_transaction", + "phase": "attack", + "title": "Sign the overspend transaction", + "description": "Prove that wallet signing can complete even though the transaction violates value conservation.", + "depends_on": ["construct_overspend"], + "wallet_ref": "wallet.operator", + "transaction_ref": "attack.unsigned", + "output_transaction_ref": "attack.signed", + }, + { + "step_id": "reject_overspend", + "type": "test_mempool_accept", + "phase": "attack", + "title": "Prove overspend rejection", + "description": "Require Bitcoin Core to reject the signed transaction with a structured consensus reason.", + "depends_on": ["sign_overspend"], + "transaction_ref": "attack.signed", + "output_acceptance_ref": "acceptance.overspend", + }, + { + "step_id": "verify_results", + "type": "evaluate_assertions", + "phase": "verification", + "title": "Evaluate lifecycle assertions", + "description": "Evaluate the positive lifecycle and expected negative-path results.", + "depends_on": ["reject_overspend"], + "assertion_ids": [ + "preflight_accepted", + "observed_in_mempool", + "transaction_confirmed", + "overspend_rejected", + ], + }, + { + "step_id": "export_proof", + "type": "export_evidence", + "phase": "export", + "title": "Export the proof bundle", + "description": "Expose deterministic evidence, report, transcript, commands, manifest, and ZIP export.", + "depends_on": ["verify_results"], + "output_bundle_ref": "proof.bundle", + }, + { + "step_id": "cleanup", + "type": "cleanup_lab", + "phase": "cleanup", + "title": "Clean up the isolated wallet", + "description": "Unload only wallets recorded as owned by this lab session.", + "depends_on": ["export_proof"], + }, + ], + "assertions": [ + { + "assertion_id": "preflight_accepted", + "kind": "mempool_policy_accepted", + "after_step_id": "preflight_transaction", + "subject_ref": "acceptance.normal", + "description": "Bitcoin Core accepted the signed transaction during preflight.", + }, + { + "assertion_id": "observed_in_mempool", + "kind": "transaction_in_mempool", + "after_step_id": "inspect_mempool", + "subject_ref": "mempool.entry", + "description": "The broadcast transaction was observed in Bitcoin Core's mempool.", + }, + { + "assertion_id": "transaction_confirmed", + "kind": "transaction_confirmed", + "after_step_id": "decode_confirmed_transaction", + "subject_ref": "transaction.confirmed", + "description": "The transaction has at least one confirmation and its final serialization decodes.", + }, + { + "assertion_id": "overspend_rejected", + "kind": "rpc_failed_with_category", + "after_step_id": "reject_overspend", + "subject_ref": "acceptance.overspend", + "expected_category": "consensus_validation", + "description": "Bitcoin Core rejected the signed overspend transaction for value-conservation failure.", + }, + ], + } +) diff --git a/backend/app/services/transaction_lifecycle_service.py b/backend/app/services/transaction_lifecycle_service.py new file mode 100644 index 0000000..b229c69 --- /dev/null +++ b/backend/app/services/transaction_lifecycle_service.py @@ -0,0 +1,684 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from decimal import Decimal + +from app.errors import BitScopeError +from app.models.attack import ( + AttackContext, + AttackFeature, + MempoolAttackObservation, +) +from app.models.evidence import EvidenceRecord +from app.models.lab import LabAction, LabSession +from app.models.scenario import ( + AssertionResult, + AssertionResultStatus, + FailureCategory, + ScenarioDefinition, + ScenarioFailure, + ScenarioRun, + ScenarioStepResult, + ScenarioStepResultStatus, +) +from app.rpc.capabilities import RegtestMutationRpcClient, RpcTransport +from app.services.lab_session_service import LabSessionService +from app.services.attack_verification_service import AttackVerificationService +from app.services.lab_session_store import LabSessionStore +from app.services.network_safety import NetworkSafetyGuard +from app.services.scenario_execution import ScenarioExecution, ScenarioExecutionError + + +SATOSHI = Decimal("0.00000001") +EXPECTED_OVERSPEND_REJECTION = "bad-txns-in-belowout" + + +class TransactionLifecycleService: + """Execute the reviewed transaction lifecycle against a session-owned regtest wallet.""" + + def __init__(self, rpc_client: RpcTransport, lab_store: LabSessionStore) -> None: + self.rpc = RegtestMutationRpcClient(rpc_client) + self.lab_store = lab_store + self.attacks = AttackVerificationService() + + def execute(self, run: ScenarioRun, definition: ScenarioDefinition) -> ScenarioExecution: + captured_at = datetime.now(UTC) + current_step = "prepare_wallet" + try: + session = self.lab_store.get(run.lab_session_id) + if session is None: + raise BitScopeError("LAB_SESSION_NOT_FOUND", "The scenario's lab session does not exist.", 404) + if session.status != "active": + raise BitScopeError( + "LAB_SESSION_NOT_ACTIVE", + "The transaction lifecycle requires an active lab session.", + 409, + {"lab_session_id": run.lab_session_id, "status": session.status}, + ) + wallet_name = session.wallet_name + if wallet_name not in session.owned_wallets: + raise BitScopeError( + "LAB_WALLET_OWNERSHIP_VIOLATION", + "The active lab wallet is not recorded as owned by this session.", + 409, + {"lab_session_id": run.lab_session_id}, + ) + loaded_wallets = self._require_list(self.rpc.call("listwallets"), "listwallets") + if wallet_name not in loaded_wallets: + raise BitScopeError( + "SCENARIO_WALLET_NOT_LOADED", + "The session-owned wallet must be loaded before this scenario can run.", + 409, + {"wallet_name": wallet_name}, + ) + + current_step = "generate_mining_address" + mining_address = self._require_string( + self._mutate("getnewaddress", ["bitscope-lifecycle-mining", "bech32"], wallet_name), + "getnewaddress", + ) + + current_step = "mine_mature_funds" + maturity_hashes = self._mine_blocks(102, mining_address) + + current_step = "generate_recipient" + recipient_address = self._require_string( + self._mutate("getnewaddress", ["bitscope-lifecycle-recipient", "bech32"], wallet_name), + "getnewaddress", + ) + + current_step = "select_utxos" + listed_utxos = self._require_list( + self.rpc.call("listunspent", [101, 9_999_999], wallet_name=wallet_name), + "listunspent", + ) + selected_utxos = self._select_two_utxos(listed_utxos) + + current_step = "construct_transaction" + normal_input = self._input_reference(selected_utxos[0]) + normal_input_amount = self._utxo_amount(selected_utxos[0]) + normal_output_amount = normal_input_amount - Decimal("0.00010000") + if normal_output_amount <= 0: + raise self._invalid_response("listunspent", "The selected UTXO cannot cover the scenario fee.") + unsigned_hex = self._require_string( + self._mutate( + "createrawtransaction", + [[normal_input], {recipient_address: float(normal_output_amount)}], + ), + "createrawtransaction", + ) + + current_step = "sign_transaction" + signed = self._require_dict( + self._mutate("signrawtransactionwithwallet", [unsigned_hex], wallet_name), + "signrawtransactionwithwallet", + ) + signed_hex = self._require_complete_signed_transaction(signed) + decoded_before_broadcast = self._require_dict( + self.rpc.call("decoderawtransaction", [signed_hex]), + "decoderawtransaction", + ) + + current_step = "preflight_transaction" + acceptance = self._single_acceptance(self.rpc.call("testmempoolaccept", [[signed_hex]])) + if acceptance.get("allowed") is not True: + raise BitScopeError( + "SCENARIO_PREFLIGHT_REJECTED", + "Bitcoin Core rejected the normal lifecycle transaction during preflight.", + 409, + {"reject_reason": self._safe_reject_reason(acceptance)}, + ) + + current_step = "broadcast_transaction" + txid = self._require_txid(self._mutate("sendrawtransaction", [signed_hex]), "sendrawtransaction") + + current_step = "inspect_mempool" + mempool_entry = self._require_dict(self.rpc.call("getmempoolentry", [txid]), "getmempoolentry") + + current_step = "confirm_transaction" + confirmation_hashes = self._require_string_list( + self._mutate("generatetoaddress", [1, mining_address]), + "generatetoaddress", + expected_length=1, + ) + + current_step = "decode_confirmed_transaction" + wallet_transaction = self._require_dict( + self.rpc.call("gettransaction", [txid], wallet_name=wallet_name), + "gettransaction", + ) + confirmations = wallet_transaction.get("confirmations") + if not isinstance(confirmations, int) or isinstance(confirmations, bool) or confirmations < 1: + raise self._invalid_response("gettransaction", "The lifecycle transaction is not confirmed.") + confirmed_hex = self._require_string(wallet_transaction.get("hex"), "gettransaction") + decoded_confirmed = self._require_dict( + self.rpc.call("decoderawtransaction", [confirmed_hex]), + "decoderawtransaction", + ) + if decoded_confirmed.get("txid") != txid: + raise self._invalid_response("decoderawtransaction", "The confirmed transaction id changed unexpectedly.") + + overspend_decision = self.attacks.require_applicable( + self.attacks.assess( + "transaction-lifecycle.output-modification", + AttackContext( + scenario_id=run.scenario_id, + available_features=[ + AttackFeature.RAW_TRANSACTION, + AttackFeature.MUTABLE_OUTPUTS, + AttackFeature.MEMPOOL_PREFLIGHT, + ], + ), + ) + ) + current_step = "construct_overspend" + attack_input = self._input_reference(selected_utxos[1]) + attack_output_amount = self._utxo_amount(selected_utxos[1]) + SATOSHI + attack_unsigned_hex = self._require_string( + self._mutate( + "createrawtransaction", + [[attack_input], {recipient_address: float(attack_output_amount)}], + ), + "createrawtransaction", + ) + + current_step = "sign_overspend" + attack_signed = self._require_dict( + self._mutate("signrawtransactionwithwallet", [attack_unsigned_hex], wallet_name), + "signrawtransactionwithwallet", + ) + attack_signed_hex = self._require_complete_signed_transaction(attack_signed) + + current_step = "reject_overspend" + attack_acceptance = self._single_acceptance( + self.rpc.call("testmempoolaccept", [[attack_signed_hex]]) + ) + reject_reason = self._safe_reject_reason(attack_acceptance) + overspend_attack = self.attacks.require_expected( + self.attacks.verify( + overspend_decision, + MempoolAttackObservation( + allowed=bool(attack_acceptance.get("allowed")), + reject_reason=reject_reason, + raw_safe_details=attack_acceptance, + ), + ), + mismatch_code="SCENARIO_NEGATIVE_ASSERTION_MISMATCH", + safe_message=( + "Bitcoin Core did not return the pinned overspend rejection expected by this scenario." + ) + ) + + self._record_session_outputs( + session, + [mining_address, recipient_address], + txid, + [*maturity_hashes, *confirmation_hashes], + selected_utxos, + ) + except BitScopeError as exc: + raise ScenarioExecutionError(current_step, exc) from exc + + evidence_records = self._evidence_records( + run=run, + captured_at=captured_at, + wallet_name=wallet_name, + mining_address=mining_address, + recipient_address=recipient_address, + maturity_hashes=maturity_hashes, + selected_utxos=selected_utxos, + unsigned_hex=unsigned_hex, + signed_hex=signed_hex, + decoded_before_broadcast=decoded_before_broadcast, + acceptance=acceptance, + txid=txid, + mempool_entry=mempool_entry, + confirmation_hashes=confirmation_hashes, + wallet_transaction=wallet_transaction, + decoded_confirmed=decoded_confirmed, + attack_unsigned_hex=attack_unsigned_hex, + attack_signed_hex=attack_signed_hex, + attack_acceptance=attack_acceptance, + ) + step_results = self._step_results(captured_at, reject_reason) + assertion_results = self._assertion_results() + return ScenarioExecution( + evidence_records, + step_results, + assertion_results, + attack_results=[overspend_attack], + ) + + def cleanup(self, run: ScenarioRun) -> list[str]: + _, unloaded = LabSessionService(self.rpc.transport, self.lab_store).cleanup(run.lab_session_id) + return unloaded + + def failure_evidence( + self, + run: ScenarioRun, + step_id: str, + error: BitScopeError, + captured_at: datetime, + ) -> EvidenceRecord: + rpc_method = error.details.get("rpc_method") + rpc_code = error.details.get("rpc_code") + rpc_message = error.details.get("rpc_message") + return EvidenceRecord( + evidence_id=f"failure.{step_id}", + kind="rpc_result", + label=f"Unexpected failure at {step_id}", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output={ + "rpc_method": rpc_method if isinstance(rpc_method, str) else None, + "safe_parameters": [], + "result": None, + "error": { + "code": rpc_code if isinstance(rpc_code, int | str) else error.code, + "message": rpc_message if isinstance(rpc_message, str) else error.message, + }, + }, + bitscope_interpretation={ + "summary": "The transaction lifecycle stopped on an unexpected application or Bitcoin Core failure.", + "facts": [{"name": "failure.category", "value": error.code}], + "limitations": ["Only redacted, bounded error details are retained."], + }, + ) + + def _mutate(self, method: str, params: object, wallet_name: str | None = None) -> object: + NetworkSafetyGuard(self.rpc).require_regtest() + return self.rpc.call(method, params, wallet_name=wallet_name) + + def _mine_blocks(self, blocks: int, address: str) -> list[str]: + """Keep individual RPC calls below the normal request timeout on slower hosts.""" + + hashes: list[str] = [] + remaining = blocks + while remaining: + batch = min(remaining, 20) + hashes.extend( + self._require_string_list( + self._mutate("generatetoaddress", [batch, address]), + "generatetoaddress", + expected_length=batch, + ) + ) + remaining -= batch + return hashes + + def _record_session_outputs( + self, + session: LabSession, + addresses: list[str], + txid: str, + block_hashes: list[str], + selected_utxos: list[dict[str, object]], + ) -> None: + session.created_addresses.extend(addresses) + session.transaction_ids.append(txid) + session.block_hashes.extend(block_hashes) + session.expected_utxos.extend(selected_utxos) + session.actions.append( + LabAction( + sequence=len(session.actions) + 1, + kind="transaction_lifecycle_completed", + occurred_at=datetime.now(UTC), + details={"txid": txid, "selected_utxo_count": len(selected_utxos)}, + ) + ) + session.updated_at = datetime.now(UTC) + self.lab_store.save(session) + + def _evidence_records(self, **values: object) -> list[EvidenceRecord]: + run = values["run"] + captured_at = values["captured_at"] + wallet_name = str(values["wallet_name"]) + mining_address = str(values["mining_address"]) + recipient_address = str(values["recipient_address"]) + signed_hex = str(values["signed_hex"]) + txid = str(values["txid"]) + attack_signed_hex = str(values["attack_signed_hex"]) + + def record( + evidence_id: str, + kind: str, + label: str, + step_id: str, + rpc_method: str, + result: object, + summary: str, + commands: list[dict[str, object]], + run_paths: list[str], + ) -> EvidenceRecord: + return EvidenceRecord( + evidence_id=evidence_id, + kind=kind, + label=label, + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id=step_id, + captured_at=captured_at, + core_output={ + "rpc_method": rpc_method, + "safe_parameters": [], + "result": result, + "run_specific_paths": run_paths, + }, + bitscope_interpretation={ + "summary": summary, + "facts": [], + "limitations": [ + "This evidence describes an isolated regtest run and is not production transaction approval." + ], + }, + commands=commands, + ) + + return [ + record( + "lifecycle.setup", + "lifecycle", + "Isolated wallet and mature UTXO setup", + "select_utxos", + "listunspent", + { + "wallet_name": wallet_name, + "mining_address": mining_address, + "recipient_address": recipient_address, + "maturity_block_hashes": values["maturity_hashes"], + "selected_utxos": values["selected_utxos"], + }, + "Bitcoin Core produced fresh addresses and two mature, run-selected UTXOs.", + [ + self._command(["-regtest", f"-rpcwallet={wallet_name}", "getnewaddress", "bitscope-lifecycle-mining", "bech32"], "Generate the mining address."), + self._command(["-regtest", "generatetoaddress", "20", mining_address], "Mine maturity blocks in bounded batches; repeat five times, then mine two more."), + self._command(["-regtest", "generatetoaddress", "2", mining_address], "Finish the 102-block maturity sequence."), + self._command(["-regtest", f"-rpcwallet={wallet_name}", "listunspent", "101", "9999999"], "List mature spendable outputs."), + ], + ["$.result.wallet_name", "$.result.mining_address", "$.result.recipient_address", "$.result.maturity_block_hashes", "$.result.selected_utxos"], + ), + record( + "transaction.constructed", + "transaction", + "Constructed and signed transaction", + "sign_transaction", + "decoderawtransaction", + { + "unsigned_hex": values["unsigned_hex"], + "signed_hex": signed_hex, + "decoded": values["decoded_before_broadcast"], + "testmempoolaccept": values["acceptance"], + }, + "The selected UTXO was explicitly constructed, signed completely, decoded, and accepted in preflight.", + [ + self._command(["-regtest", "decoderawtransaction", signed_hex], "Decode the signed transaction."), + self._command(["-regtest", "testmempoolaccept", json.dumps([signed_hex], separators=(",", ":"))], "Preflight the signed transaction."), + ], + ["$.result.unsigned_hex", "$.result.signed_hex", "$.result.decoded.txid", "$.result.testmempoolaccept.txid"], + ), + record( + "transaction.mempool", + "lifecycle", + "Broadcast transaction observed in mempool", + "inspect_mempool", + "getmempoolentry", + {"txid": txid, "entry": values["mempool_entry"]}, + "Bitcoin Core returned a live mempool entry for the broadcast transaction.", + [ + self._command(["-regtest", "sendrawtransaction", signed_hex], "Broadcast the preflighted transaction."), + self._command(["-regtest", "getmempoolentry", txid], "Inspect its mempool entry."), + ], + ["$.result.txid", "$.result.entry"], + ), + record( + "transaction.confirmed", + "transaction", + "Confirmed transaction decoding", + "decode_confirmed_transaction", + "gettransaction", + { + "txid": txid, + "confirmation_block_hashes": values["confirmation_hashes"], + "wallet_transaction": values["wallet_transaction"], + "decoded": values["decoded_confirmed"], + }, + "A newly mined block confirmed the transaction and Bitcoin Core decoded the final serialization.", + [ + self._command(["-regtest", "generatetoaddress", "1", mining_address], "Mine the confirmation block."), + self._command(["-regtest", f"-rpcwallet={wallet_name}", "gettransaction", txid], "Read the confirmed wallet transaction."), + ], + ["$.result.txid", "$.result.confirmation_block_hashes", "$.result.wallet_transaction", "$.result.decoded.txid"], + ), + record( + "transaction.overspend-rejection", + "assertion", + "Expected overspend consensus rejection", + "reject_overspend", + "testmempoolaccept", + { + "unsigned_hex": values["attack_unsigned_hex"], + "signed_hex": attack_signed_hex, + "testmempoolaccept": values["attack_acceptance"], + }, + "Wallet signing completed, but Bitcoin Core rejected the overspend for violating value conservation.", + [ + self._command(["-regtest", "testmempoolaccept", json.dumps([attack_signed_hex], separators=(",", ":"))], "Reproduce the expected overspend rejection."), + ], + ["$.result.unsigned_hex", "$.result.signed_hex", "$.result.testmempoolaccept.txid"], + ), + ] + + @staticmethod + def _step_results(timestamp: datetime, reject_reason: str) -> list[ScenarioStepResult]: + completed: list[tuple[str, list[str], list[str]]] = [ + ("verify_chain", ["node.context"], ["node.context"]), + ("prepare_wallet", ["wallet.operator"], ["lifecycle.setup"]), + ("generate_mining_address", ["address.mining"], ["lifecycle.setup"]), + ("mine_mature_funds", ["blocks.maturity"], ["lifecycle.setup"]), + ("generate_recipient", ["address.recipient"], ["lifecycle.setup"]), + ("select_utxos", ["utxos.selected"], ["lifecycle.setup"]), + ("construct_transaction", ["transaction.unsigned"], ["transaction.constructed"]), + ("sign_transaction", ["transaction.signed"], ["transaction.constructed"]), + ("preflight_transaction", ["acceptance.normal"], ["transaction.constructed"]), + ("broadcast_transaction", ["transaction.txid"], ["transaction.mempool"]), + ("inspect_mempool", ["mempool.entry"], ["transaction.mempool"]), + ("confirm_transaction", ["blocks.confirmation"], ["transaction.confirmed"]), + ("decode_confirmed_transaction", ["transaction.confirmed"], ["transaction.confirmed"]), + ("construct_overspend", ["attack.unsigned"], ["transaction.overspend-rejection"]), + ("sign_overspend", ["attack.signed"], ["transaction.overspend-rejection"]), + ] + results = [ + ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.COMPLETED, + started_at=timestamp, + completed_at=timestamp, + output_refs=outputs, + evidence_ids=evidence, + ) + for step_id, outputs, evidence in completed + ] + failure = ScenarioFailure( + failure_id="failure.overspend-rejected", + step_id="reject_overspend", + category=FailureCategory.CONSENSUS_VALIDATION, + expected=True, + code=EXPECTED_OVERSPEND_REJECTION, + safe_message=f"Bitcoin Core rejected the overspend transaction: {reject_reason}.", + evidence_ids=["transaction.overspend-rejection"], + ) + results.append( + ScenarioStepResult( + step_id="reject_overspend", + status=ScenarioStepResultStatus.EXPECTED_FAILURE, + started_at=timestamp, + completed_at=timestamp, + output_refs=["acceptance.overspend"], + evidence_ids=["transaction.overspend-rejection"], + failure=failure, + ) + ) + return results + + @staticmethod + def _assertion_results() -> list[AssertionResult]: + evidence = { + "preflight_accepted": ["transaction.constructed"], + "observed_in_mempool": ["transaction.mempool"], + "transaction_confirmed": ["transaction.confirmed"], + "overspend_rejected": ["transaction.overspend-rejection"], + } + explanations = { + "preflight_accepted": "Bitcoin Core returned allowed=true before broadcast.", + "observed_in_mempool": "Bitcoin Core returned a mempool entry for the broadcast txid.", + "transaction_confirmed": "Bitcoin Core returned confirmations >= 1 and a matching decoded txid.", + "overspend_rejected": "Bitcoin Core returned allowed=false and reject-reason bad-txns-in-belowout.", + } + return [ + AssertionResult( + assertion_id=assertion_id, + status=AssertionResultStatus.PASSED, + required=True, + expected_failure=assertion_id == "overspend_rejected", + explanation=explanations[assertion_id], + evidence_ids=evidence[assertion_id], + ) + for assertion_id in explanations + ] + + @staticmethod + def _select_two_utxos(value: list[object]) -> list[dict[str, object]]: + selected: list[dict[str, object]] = [] + for item in value: + if not isinstance(item, dict) or item.get("spendable") is not True: + continue + confirmations = item.get("confirmations") + amount = item.get("amount") + if ( + isinstance(confirmations, int) + and not isinstance(confirmations, bool) + and confirmations >= 101 + and isinstance(amount, int | float) + and not isinstance(amount, bool) + and Decimal(str(amount)) >= Decimal("0.00001000") + ): + TransactionLifecycleService._input_reference(item) + selected.append(item) + if len(selected) == 2: + return selected + raise BitScopeError( + "SCENARIO_MATURE_UTXOS_MISSING", + "Bitcoin Core did not return two mature spendable UTXOs after scenario mining.", + 409, + {"required_confirmations": 101, "required_count": 2}, + ) + + @staticmethod + def _input_reference(utxo: dict[str, object]) -> dict[str, object]: + txid = utxo.get("txid") + vout = utxo.get("vout") + if ( + not isinstance(txid, str) + or len(txid) != 64 + or any(character not in "0123456789abcdefABCDEF" for character in txid) + or not isinstance(vout, int) + or isinstance(vout, bool) + or vout < 0 + ): + raise TransactionLifecycleService._invalid_response( + "listunspent", "Bitcoin Core returned an invalid UTXO reference." + ) + return {"txid": txid, "vout": vout} + + @staticmethod + def _utxo_amount(utxo: dict[str, object]) -> Decimal: + amount = utxo.get("amount") + if not isinstance(amount, int | float) or isinstance(amount, bool): + raise TransactionLifecycleService._invalid_response( + "listunspent", "Bitcoin Core returned an invalid UTXO amount." + ) + return Decimal(str(amount)).quantize(SATOSHI) + + @staticmethod + def _single_acceptance(value: object) -> dict[str, object]: + results = TransactionLifecycleService._require_list(value, "testmempoolaccept") + if len(results) != 1 or not isinstance(results[0], dict) or not isinstance(results[0].get("allowed"), bool): + raise TransactionLifecycleService._invalid_response( + "testmempoolaccept", "Bitcoin Core returned an invalid preflight result." + ) + return results[0] + + @staticmethod + def _safe_reject_reason(acceptance: dict[str, object]) -> str | None: + reason = acceptance.get("reject-reason") + return reason[:240] if isinstance(reason, str) and reason else None + + @staticmethod + def _require_complete_signed_transaction(value: dict[str, object]) -> str: + if value.get("complete") is not True: + raise BitScopeError( + "SCENARIO_TRANSACTION_INCOMPLETE", + "Bitcoin Core did not completely sign the scenario transaction.", + 409, + ) + return TransactionLifecycleService._require_string(value.get("hex"), "signrawtransactionwithwallet") + + @staticmethod + def _require_txid(value: object, rpc_method: str) -> str: + txid = TransactionLifecycleService._require_string(value, rpc_method) + if len(txid) != 64 or any(character not in "0123456789abcdefABCDEF" for character in txid): + raise TransactionLifecycleService._invalid_response(rpc_method, "Bitcoin Core returned an invalid txid.") + return txid + + @staticmethod + def _require_string(value: object, rpc_method: str) -> str: + if not isinstance(value, str) or not value: + raise TransactionLifecycleService._invalid_response( + rpc_method, "Bitcoin Core returned an invalid string response." + ) + return value + + @staticmethod + def _require_dict(value: object, rpc_method: str) -> dict[str, object]: + if not isinstance(value, dict): + raise TransactionLifecycleService._invalid_response( + rpc_method, "Bitcoin Core returned an invalid object response." + ) + return value + + @staticmethod + def _require_list(value: object, rpc_method: str) -> list[object]: + if not isinstance(value, list): + raise TransactionLifecycleService._invalid_response( + rpc_method, "Bitcoin Core returned an invalid list response." + ) + return value + + @staticmethod + def _require_string_list(value: object, rpc_method: str, expected_length: int) -> list[str]: + items = TransactionLifecycleService._require_list(value, rpc_method) + if len(items) != expected_length or any(not isinstance(item, str) or not item for item in items): + raise TransactionLifecycleService._invalid_response( + rpc_method, "Bitcoin Core returned an invalid block hash list." + ) + return items + + @staticmethod + def _invalid_response(rpc_method: str, message: str) -> BitScopeError: + return BitScopeError( + "BITCOIN_CORE_INVALID_RESPONSE", + message, + 502, + {"rpc_method": rpc_method}, + ) + + @staticmethod + def _command(arguments: list[str], description: str) -> dict[str, object]: + return {"arguments": arguments, "description": description} diff --git a/backend/app/services/transaction_service.py b/backend/app/services/transaction_service.py index 5c6853e..5a7ff98 100644 --- a/backend/app/services/transaction_service.py +++ b/backend/app/services/transaction_service.py @@ -206,6 +206,126 @@ def bump_rbf_transaction( "raw": {"bumpfee": result}, } + def create_rbf_transaction( + self, + wallet_name: str, + address: str, + amount_btc: float, + fee_rate_sat_vb: float, + ) -> dict[str, object]: + """Create and broadcast an explicitly replaceable wallet transaction on regtest.""" + + NetworkSafetyGuard(self.rpc_client).require_regtest() + clean_wallet = self._clean(wallet_name, "wallet name") + clean_address = self._clean(address, "destination address") + clean_amount = self._clean_amount(amount_btc) + clean_fee_rate = round(float(fee_rate_sat_vb), 3) + if clean_fee_rate <= 0: + raise BitScopeError( + code="INVALID_RBF_FEE_RATE", + message="The initial RBF fee rate must be greater than zero.", + status_code=400, + ) + + preflight = SpendPreflight(self.rpc_client) + validation = preflight.validate_address( + clean_address, + "INVALID_RBF_DESTINATION_ADDRESS", + "Provide a fresh destination address from the current regtest node.", + ) + balance = preflight.require_mature_balance( + clean_wallet, + clean_amount, + "RBF_INSUFFICIENT_MATURE_FUNDS", + "Mine enough regtest blocks for mature wallet funds before creating the RBF transaction.", + ) + send_parameters: list[object] = [ + clean_address, + clean_amount, + "", + "", + False, + True, + None, + "unset", + None, + clean_fee_rate, + ] + txid = self._clean_txid( + self._require_str( + self.rpc_client.call("sendtoaddress", send_parameters, wallet_name=clean_wallet), + "sendtoaddress", + "Bitcoin Core did not return the original RBF transaction id.", + ) + ) + wallet_transaction = self._as_dict( + self.rpc_client.call("gettransaction", [txid], wallet_name=clean_wallet) + ) + transaction_hex = self._require_str( + wallet_transaction.get("hex"), + "gettransaction", + "Bitcoin Core did not return the original RBF transaction serialization.", + ) + decoded = self._as_dict(self.rpc_client.call("decoderawtransaction", [transaction_hex])) + mempool_entry = self._as_dict(self.rpc_client.call("getmempoolentry", [txid])) + inputs = decoded.get("vin") if isinstance(decoded.get("vin"), list) else [] + sequences = [ + item["sequence"] + for item in inputs + if isinstance(item, dict) + and isinstance(item.get("sequence"), int) + and not isinstance(item.get("sequence"), bool) + ] + fees = mempool_entry.get("fees") if isinstance(mempool_entry.get("fees"), dict) else {} + fee_btc = self._optional_float(fees.get("base") if isinstance(fees, dict) else None) + vsize = self._optional_int(mempool_entry.get("vsize")) + + return { + "wallet_name": clean_wallet, + "address": clean_address, + "amount_btc": clean_amount, + "requested_fee_rate_sat_vb": clean_fee_rate, + "txid": txid, + "hex": transaction_hex, + "sequences": sequences, + "signals_rbf": any(sequence < 0xFFFFFFFE for sequence in sequences), + "mempool_entry": mempool_entry, + "fee_btc": fee_btc, + "vsize": vsize, + "fee_rate_sat_vb": self._sat_vb(fee_btc, vsize), + "cli_commands": [ + f"bitcoin-cli validateaddress {clean_address}", + f"bitcoin-cli -rpcwallet={clean_wallet} getbalances", + ( + f"bitcoin-cli -rpcwallet={clean_wallet} sendtoaddress {clean_address} " + f"{clean_amount:.8f} '' '' false true null unset null {clean_fee_rate:.3f}" + ), + f"bitcoin-cli -rpcwallet={clean_wallet} gettransaction {txid}", + f"bitcoin-cli getmempoolentry {txid}", + ], + "rpc_methods": [ + "validateaddress", + "getbalances", + "sendtoaddress", + "gettransaction", + "decoderawtransaction", + "getmempoolentry", + ], + "concepts": ["RBF", "BIP125", "Sequence", "Mempool policy", "Fee rate"], + "explanation": ( + "Bitcoin Core created and broadcast a wallet transaction with replaceable=true and an explicit " + "initial fee rate. BitScope verifies both the input sequences and live mempool replaceability field." + ), + "raw": { + "validateaddress": validation, + "getbalances": balance["getbalances"], + "sendtoaddress": txid, + "gettransaction": wallet_transaction, + "decoderawtransaction": decoded, + "getmempoolentry": mempool_entry, + }, + } + def create_cpfp_child( self, wallet_name: str, diff --git a/backend/app/services/treasury_policy_service.py b/backend/app/services/treasury_policy_service.py new file mode 100644 index 0000000..d974dd6 --- /dev/null +++ b/backend/app/services/treasury_policy_service.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import re + +from app.errors import BitScopeError +from app.models.treasury import ( + MaterializedTreasuryPolicy, + TreasuryParticipantGroup, + TreasuryParticipantRole, + TreasuryPolicy, + TreasuryPolicyBranch, + TreasuryPolicyDecisionTree, + TreasuryPolicyImportResult, + TreasurySpendPath, +) +from app.rpc.capabilities import RegtestMutationRpcClient, RpcTransport +from app.rpc.types import JsonValue +from app.services.network_safety import NetworkSafetyGuard + + +class TreasuryPolicyService: + """Materialize and import the reviewed public policy; never create or use signer keys.""" + + DEFAULT_IMPORT_LABEL = "community-treasury-recovery" + + def __init__(self, rpc_client: RpcTransport) -> None: + self.rpc = RegtestMutationRpcClient(rpc_client) + self.network_guard = NetworkSafetyGuard(self.rpc) + + def materialize(self, policy: TreasuryPolicy) -> MaterializedTreasuryPolicy: + self.network_guard.require_regtest() + miniscript = self._miniscript(policy) + descriptor = f"wsh({miniscript})" + info = self._require_dict( + self.rpc.call("getdescriptorinfo", [descriptor]), + "getdescriptorinfo", + ) + self._verify_descriptor_info(info) + + normalized = self._require_string(info.get("descriptor"), "getdescriptorinfo") + checksum = self._require_string(info.get("checksum"), "getdescriptorinfo") + if len(checksum) != 8: + raise self._invalid_core_response( + "getdescriptorinfo", + "Bitcoin Core returned an invalid descriptor checksum.", + ) + + addresses = self.rpc.call("deriveaddresses", [normalized]) + if not isinstance(addresses, list) or len(addresses) != 1: + raise self._invalid_core_response( + "deriveaddresses", + "Bitcoin Core did not derive exactly one address for the non-ranged treasury policy.", + ) + address = self._require_string(addresses[0], "deriveaddresses") + + return MaterializedTreasuryPolicy( + policy=policy, + miniscript=miniscript, + descriptor=descriptor, + normalized_descriptor=normalized, + checksum=checksum, + address=address, + decision_tree=self._decision_tree(policy), + ) + + def import_into_coordinator( + self, + materialized: MaterializedTreasuryPolicy, + coordinator_wallet: str, + *, + label: str = DEFAULT_IMPORT_LABEL, + ) -> TreasuryPolicyImportResult: + clean_wallet = coordinator_wallet.strip() + clean_label = label.strip() + if not re.fullmatch(r"[a-zA-Z0-9_-]{1,128}", clean_wallet): + raise BitScopeError( + code="INVALID_TREASURY_COORDINATOR", + message="Provide a valid session-owned treasury coordinator wallet.", + status_code=400, + ) + has_control_character = any(character in clean_label for character in ("\x00", "\r", "\n")) + if not clean_label or len(clean_label) > 128 or has_control_character: + raise BitScopeError( + code="INVALID_TREASURY_POLICY_LABEL", + message="Provide a single-line treasury policy label containing at most 128 characters.", + status_code=400, + ) + + wallet_info = self._require_dict( + self.rpc.call("getwalletinfo", wallet_name=clean_wallet), + "getwalletinfo", + ) + if wallet_info.get("private_keys_enabled") is not False: + raise BitScopeError( + code="TREASURY_COORDINATOR_CAN_SIGN", + message="The treasury coordinator must be a wallet with private keys disabled.", + status_code=409, + details={"coordinator_wallet": clean_wallet}, + ) + + # This check is intentionally adjacent to the state-changing descriptor import. + self.network_guard.require_regtest() + result = self.rpc.call( + "importdescriptors", + [[{ + "desc": materialized.normalized_descriptor, + "timestamp": "now", + "active": False, + "label": clean_label, + }]], + wallet_name=clean_wallet, + ) + if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict): + raise self._invalid_core_response( + "importdescriptors", + "Bitcoin Core returned an invalid treasury descriptor import result.", + ) + imported = result[0] + if imported.get("success") is not True: + error = imported.get("error") + safe_error = error if isinstance(error, dict) else {} + raise BitScopeError( + code="TREASURY_POLICY_IMPORT_FAILED", + message="Bitcoin Core did not import the public treasury policy descriptor.", + status_code=409, + details={ + "coordinator_wallet": clean_wallet, + "rpc_code": safe_error.get("code"), + "rpc_message": safe_error.get("message"), + }, + ) + + return TreasuryPolicyImportResult( + coordinator_wallet=clean_wallet, + descriptor=materialized.normalized_descriptor, + label=clean_label, + ) + + @classmethod + def _miniscript(cls, policy: TreasuryPolicy) -> str: + operators = cls._multi(policy.operators) + recovery = cls._multi(policy.recovery) + emergency = cls._multi(policy.emergency) + return ( + "or_i(" + f"{operators}," + "or_i(" + f"and_v(v:older({policy.recovery_delay_blocks}),{recovery})," + f"and_v(v:older({policy.emergency_delay_blocks}),{emergency})" + ")" + ")" + ) + + @staticmethod + def _multi(group: TreasuryParticipantGroup) -> str: + keys = ",".join(participant.public_key for participant in group.ordered_participants()) + return f"multi({group.required_signatures},{keys})" + + @staticmethod + def _decision_tree(policy: TreasuryPolicy) -> TreasuryPolicyDecisionTree: + def participant_ids(group: TreasuryParticipantGroup) -> list[str]: + return [participant.participant_id for participant in group.ordered_participants()] + + return TreasuryPolicyDecisionTree( + branches=[ + TreasuryPolicyBranch( + path=TreasurySpendPath.IMMEDIATE, + label="Any 2 of 3 treasury operators may spend immediately.", + participant_ids=participant_ids(policy.operators), + ), + TreasuryPolicyBranch( + path=TreasurySpendPath.RECOVERY, + label="Any 2 of 3 recovery signers may spend after the recovery delay.", + participant_ids=participant_ids(policy.recovery), + relative_delay_blocks=policy.recovery_delay_blocks, + ), + TreasuryPolicyBranch( + path=TreasurySpendPath.EMERGENCY, + label="Any 2 of 3 emergency signers may spend after the emergency delay.", + participant_ids=participant_ids(policy.emergency), + relative_delay_blocks=policy.emergency_delay_blocks, + ), + ] + ) + + @classmethod + def _verify_descriptor_info(cls, info: dict[str, object]) -> None: + if info.get("hasprivatekeys") is not False: + raise BitScopeError( + code="TREASURY_POLICY_PRIVATE_KEYS_DETECTED", + message="Bitcoin Core did not confirm that the treasury descriptor contains public keys only.", + status_code=409, + ) + if info.get("issolvable") is not True: + raise BitScopeError( + code="TREASURY_POLICY_UNSOLVABLE", + message="Bitcoin Core did not recognize the treasury descriptor as solvable.", + status_code=409, + ) + if info.get("isrange") is not False: + raise BitScopeError( + code="TREASURY_POLICY_UNEXPECTED_RANGE", + message="The version 1 treasury policy must use one-time public keys, not a ranged descriptor.", + status_code=409, + ) + + @staticmethod + def _require_dict(value: JsonValue, method: str) -> dict[str, object]: + if isinstance(value, dict): + return value + raise TreasuryPolicyService._invalid_core_response( + method, + f"Bitcoin Core returned an invalid {method} result.", + ) + + @staticmethod + def _require_string(value: object, method: str) -> str: + if isinstance(value, str) and value: + return value + raise TreasuryPolicyService._invalid_core_response( + method, + f"Bitcoin Core did not return the expected string from {method}.", + ) + + @staticmethod + def _invalid_core_response(method: str, message: str) -> BitScopeError: + return BitScopeError( + code="TREASURY_POLICY_CORE_RESPONSE_INVALID", + message=message, + status_code=502, + details={"rpc_method": method}, + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 06b0f7d..8251163 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0 pydantic-settings==2.7.1 pytest==8.3.4 httpx==0.28.1 +ecdsa==0.19.1 diff --git a/backend/tests/live_node/conftest.py b/backend/tests/live_node/conftest.py index 211ae25..826243f 100644 --- a/backend/tests/live_node/conftest.py +++ b/backend/tests/live_node/conftest.py @@ -52,9 +52,15 @@ def ensure_mature_balance(client: BitcoinRpcClient, wallet_name: str, minimum_bt balances = client.call("getbalances", [], wallet_name=wallet_name) trusted = _wallet_balance(balances, "trusted") + additional_blocks = 0 + while trusted < minimum_btc and additional_blocks < 100: + client.call("generatetoaddress", [1, address]) + additional_blocks += 1 + balances = client.call("getbalances", [], wallet_name=wallet_name) + trusted = _wallet_balance(balances, "trusted") assert trusted >= minimum_btc, ( "Test wallet still has insufficient mature balance after mining. " - f"trusted={trusted}, required={minimum_btc}" + f"trusted={trusted}, required={minimum_btc}, additional_blocks={additional_blocks}" ) diff --git a/backend/tests/live_node/test_community_treasury_scenario_live.py b/backend/tests/live_node/test_community_treasury_scenario_live.py new file mode 100644 index 0000000..e99957b --- /dev/null +++ b/backend/tests/live_node/test_community_treasury_scenario_live.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from app.rpc.client import BitcoinRpcClient +from app.services.evidence_service import EvidenceService +from app.services.lab_session_service import LabSessionService +from app.services.lab_session_store import LabSessionStore +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import DEFAULT_SCENARIO_CATALOG +from app.services.scenario_run_store import ScenarioRunStore +from app.services.scenario_service import ScenarioService + + +def test_live_core_28_1_integrated_community_treasury_proof( + live_rpc_client: BitcoinRpcClient, + tmp_path: Path, +) -> None: + """Run the reviewed executor and export its complete Proof of Spendability.""" + + network = live_rpc_client.call("getnetworkinfo") + assert isinstance(network, dict) + assert network.get("version") == 280100 + + database = tmp_path / "community-treasury.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_service = LabSessionService(live_rpc_client, lab_store) + session = lab_service.create("community-treasury-recovery") + run_store = ScenarioRunStore(str(database)) + artifacts = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + scenario_service = ScenarioService( + live_rpc_client, + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(live_rpc_client.settings), + artifacts, + lab_store, + ) + + try: + created = scenario_service.create_run( + "community-treasury-recovery", + session.session_id, + ) + ready = scenario_service.advance( + created.run_id, + session.session_id, + expected_revision=0, + ) + verified = scenario_service.advance( + ready.run_id, + session.session_id, + expected_revision=1, + ) + + assert verified.current_state.value == "verified" + assert verified.cleanup_status.value == "completed" + assert len(verified.completed_steps) == 53 + assert len(verified.assertion_results) == 25 + assert all(result.status.value == "passed" for result in verified.assertion_results) + assert [failure.code for failure in verified.expected_failures] == [ + "insufficient-immediate-signatures", + "insufficient-recovery-signatures", + "non-BIP68-final", + "incorrect-sequence-incomplete", + "insufficient-emergency-signatures", + "non-BIP68-final-emergency", + ] + + persisted_session = lab_store.get(session.session_id) + assert persisted_session is not None + assert persisted_session.status == "cleaned" + assert len(persisted_session.owned_wallets) == 11 + assert len(persisted_session.transaction_ids) == 6 + assert len(persisted_session.block_hashes) == 122 + + bundle_service = ProofBundleService( + run_store, + artifacts, + DEFAULT_SCENARIO_CATALOG, + ) + first = bundle_service.bundle(verified.run_id, session.session_id) + second = bundle_service.bundle(verified.run_id, session.session_id) + assert first.zip_bytes == second.zip_bytes + assert first.manifest.final_result is not None + assert first.manifest.final_result.value == "verified" + assert first.proof_of_spendability is not None + assert first.proof_of_spendability.result == "VERIFIED" + assert first.proof_of_spendability.bitcoin_core_compatibility == "verified" + assert all( + check.status.value != "FAIL" + for check in first.proof_of_spendability.checks + ) + assert "proof-of-spendability.json" in first.files + proof_document = json.loads(first.files["proof-of-spendability.json"]) + assert proof_document["result"] == "VERIFIED" + assert proof_document["policy"]["recovery_delay_blocks"] == 5 + assert proof_document["policy"]["emergency_delay_blocks"] == 10 + lifecycle = json.loads(first.files["lifecycle.json"]) + assert len(lifecycle["events"]) == 33 + assert [event["event_type"] for event in lifecycle["events"]].count("timelock_matured") == 2 + assert lifecycle["events"][-1]["event_type"] == "scenario_cleaned_up" + assert {event["track_id"] for event in lifecycle["events"]} >= { + "treasury.immediate", + "treasury.recovery", + "treasury.emergency", + } + attack_document = json.loads(first.files["evidence/attacks.summary.json"]) + attack_results = attack_document["core_output"]["result"] + assert len(attack_results) == 9 + assert all(item["status"] == "expected_failure" for item in attack_results) + assert {item["attack_type"] for item in attack_results} == { + "signature_insufficiency", + "psbt_incompleteness", + "premature_timelock_execution", + "sequence_modification", + } + assert all(item["raw_safe_details"] for item in attack_results) + assert "Result: VERIFIED" in first.report_markdown + assert "Premature recovery attempt: **REJECTED AS EXPECTED**" in first.report_markdown + + secret = live_rpc_client.settings.bitcoin_rpc_password.encode("utf-8") + assert all(secret not in content for content in first.files.values()) + assert b"private_keys" not in first.files["proof-of-spendability.json"].lower() + finally: + persisted = lab_store.get(session.session_id) + if persisted is not None and persisted.status == "active": + lab_service.cleanup(session.session_id) diff --git a/backend/tests/live_node/test_regtest_lifecycle.py b/backend/tests/live_node/test_regtest_lifecycle.py index f722ad6..d6232fe 100644 --- a/backend/tests/live_node/test_regtest_lifecycle.py +++ b/backend/tests/live_node/test_regtest_lifecycle.py @@ -1,7 +1,13 @@ from pathlib import Path from app.rpc.client import BitcoinRpcClient +from app.services.evidence_service import EvidenceService from app.services.multisig_service import MultisigService +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import DEFAULT_SCENARIO_CATALOG +from app.services.scenario_run_store import ScenarioRunStore +from app.services.scenario_service import ScenarioService from app.services.lab_session_service import LabSessionService from app.services.lab_session_store import LabSessionStore from app.services.psbt_service import PsbtService @@ -55,6 +61,222 @@ def test_live_regtest_wallet_can_send_after_coinbase_maturity( assert len(block_hashes) == 1 +def test_live_regtest_verified_transaction_lifecycle( + live_rpc_client: BitcoinRpcClient, + tmp_path: Path, +) -> None: + database = tmp_path / "verified-lifecycle.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_service = LabSessionService(live_rpc_client, lab_store) + session = lab_service.create("transaction-lifecycle") + run_store = ScenarioRunStore(str(database)) + artifacts = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + scenario_service = ScenarioService( + live_rpc_client, + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(live_rpc_client.settings), + artifacts, + lab_store, + ) + + try: + created = scenario_service.create_run("transaction-lifecycle", session.session_id) + ready = scenario_service.advance(created.run_id, session.session_id, expected_revision=0) + verified = scenario_service.advance(ready.run_id, session.session_id, expected_revision=1) + + assert verified.current_state.value == "verified" + assert verified.cleanup_status.value == "completed" + assert [failure.code for failure in verified.expected_failures] == ["bad-txns-in-belowout"] + assert all(result.status.value == "passed" for result in verified.assertion_results) + + bundle = ProofBundleService( + run_store, + artifacts, + DEFAULT_SCENARIO_CATALOG, + ).bundle(verified.run_id, session.session_id) + assert bundle.manifest.final_result is not None + assert bundle.manifest.final_result.value == "verified" + assert "evidence/transaction.confirmed.json" in bundle.files + assert "evidence/transaction.overspend-rejection.json" in bundle.files + finally: + persisted = lab_store.get(session.session_id) + if persisted is not None and persisted.status == "active": + lab_service.cleanup(session.session_id) + + +def test_live_regtest_verified_rbf_replacement( + live_rpc_client: BitcoinRpcClient, + tmp_path: Path, +) -> None: + database = tmp_path / "verified-rbf.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_service = LabSessionService(live_rpc_client, lab_store) + session = lab_service.create("rbf-replacement") + run_store = ScenarioRunStore(str(database)) + artifacts = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + scenario_service = ScenarioService( + live_rpc_client, + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(live_rpc_client.settings), + artifacts, + lab_store, + ) + + try: + created = scenario_service.create_run("rbf-replacement", session.session_id) + ready = scenario_service.advance(created.run_id, session.session_id, expected_revision=0) + verified = scenario_service.advance(ready.run_id, session.session_id, expected_revision=1) + + assert verified.current_state.value == "verified" + assert verified.cleanup_status.value == "completed" + assert [failure.code for failure in verified.expected_failures] == [ + "insufficient-replacement-fee" + ] + assert verified.expected_failures[0].rpc_code == -8 + assert all(result.status.value == "passed" for result in verified.assertion_results) + + session_after = lab_store.get(session.session_id) + assert session_after is not None + assert len(session_after.transaction_ids) == 2 + assert session_after.transaction_ids[0] != session_after.transaction_ids[1] + + bundle = ProofBundleService( + run_store, + artifacts, + DEFAULT_SCENARIO_CATALOG, + ).bundle(verified.run_id, session.session_id) + assert bundle.manifest.final_result is not None + assert bundle.manifest.final_result.value == "verified" + assert "evidence/rbf.insufficient-fee.json" in bundle.files + assert "evidence/rbf.replacement.json" in bundle.files + assert "evidence/rbf.confirmed.json" in bundle.files + finally: + persisted = lab_store.get(session.session_id) + if persisted is not None and persisted.status == "active": + lab_service.cleanup(session.session_id) + + +def test_live_regtest_verified_multisig_psbt( + live_rpc_client: BitcoinRpcClient, + tmp_path: Path, +) -> None: + database = tmp_path / "verified-multisig-psbt.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_service = LabSessionService(live_rpc_client, lab_store) + session = lab_service.create("multisig-psbt") + run_store = ScenarioRunStore(str(database)) + artifacts = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + scenario_service = ScenarioService( + live_rpc_client, + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(live_rpc_client.settings), + artifacts, + lab_store, + ) + + try: + created = scenario_service.create_run("multisig-psbt", session.session_id) + ready = scenario_service.advance(created.run_id, session.session_id, expected_revision=0) + verified = scenario_service.advance(ready.run_id, session.session_id, expected_revision=1) + + assert verified.current_state.value == "verified" + assert verified.cleanup_status.value == "completed" + assert [failure.code for failure in verified.expected_failures] == [ + "insufficient-signatures" + ] + assert verified.expected_failures[0].category.value == "psbt_incomplete" + assert all(result.status.value == "passed" for result in verified.assertion_results) + + session_after = lab_store.get(session.session_id) + assert session_after is not None + assert session_after.status == "cleaned" + assert len(session_after.owned_wallets) == 4 + assert len(session_after.transaction_ids) == 2 + assert len(session_after.block_hashes) == 103 + + bundle = ProofBundleService( + run_store, + artifacts, + DEFAULT_SCENARIO_CATALOG, + ).bundle(verified.run_id, session.session_id) + assert bundle.manifest.final_result is not None + assert bundle.manifest.final_result.value == "verified" + assert "evidence/psbt.partial.json" in bundle.files + assert "evidence/psbt.complete.json" in bundle.files + assert "evidence/multisig.confirmed.json" in bundle.files + finally: + persisted = lab_store.get(session.session_id) + if persisted is not None and persisted.status == "active": + lab_service.cleanup(session.session_id) + + +def test_live_regtest_verified_cltv_timelock( + live_rpc_client: BitcoinRpcClient, + tmp_path: Path, +) -> None: + database = tmp_path / "verified-cltv-timelock.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_service = LabSessionService(live_rpc_client, lab_store) + session = lab_service.create("cltv-timelock") + run_store = ScenarioRunStore(str(database)) + artifacts = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + scenario_service = ScenarioService( + live_rpc_client, + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(live_rpc_client.settings), + artifacts, + lab_store, + ) + + try: + created = scenario_service.create_run("cltv-timelock", session.session_id) + ready = scenario_service.advance(created.run_id, session.session_id, expected_revision=0) + verified = scenario_service.advance(ready.run_id, session.session_id, expected_revision=1) + + assert verified.current_state.value == "verified" + assert verified.cleanup_status.value == "completed" + assert [failure.code for failure in verified.expected_failures] == [ + "non-final", + "cltv-final-sequence", + "cltv-low-locktime", + ] + assert [failure.category.value for failure in verified.expected_failures] == [ + "mempool_policy", + "script_verification", + "script_verification", + ] + assert all(result.status.value == "passed" for result in verified.assertion_results) + + session_after = lab_store.get(session.session_id) + assert session_after is not None + assert session_after.status == "cleaned" + assert session_after.transaction_ids[0] != session_after.transaction_ids[1] + assert len(session_after.block_hashes) == 106 + + bundle = ProofBundleService( + run_store, + artifacts, + DEFAULT_SCENARIO_CATALOG, + ).bundle(verified.run_id, session.session_id) + assert bundle.manifest.final_result is not None + assert bundle.manifest.final_result.value == "verified" + assert "evidence/cltv.premature.json" in bundle.files + assert "evidence/cltv.invalid-sequence.json" in bundle.files + assert "evidence/cltv.invalid-locktime.json" in bundle.files + assert "evidence/cltv.mature.json" in bundle.files + assert "evidence/cltv.confirmed.json" in bundle.files + assert b"private" not in bundle.files["evidence/cltv.policy-funding.json"].lower() + finally: + scenario_service.cltv_timelock_service.timelock_service.clear_ephemeral_cltv_keys() + persisted = lab_store.get(session.session_id) + if persisted is not None and persisted.status == "active": + lab_service.cleanup(session.session_id) + + def test_live_regtest_advanced_transaction_workflows( live_rpc_client: BitcoinRpcClient, mature_wallet: str, diff --git a/backend/tests/live_node/test_treasury_policy_poc.py b/backend/tests/live_node/test_treasury_policy_poc.py new file mode 100644 index 0000000..c0996cf --- /dev/null +++ b/backend/tests/live_node/test_treasury_policy_poc.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +from decimal import Decimal +from uuid import uuid4 + +from app.models.treasury import ( + TreasuryParticipant, + TreasuryParticipantGroup, + TreasuryParticipantRole, + TreasuryPolicy, +) +from app.rpc.client import BitcoinRpcClient +from app.services.treasury_policy_service import TreasuryPolicyService + + +RECOVERY_DELAY = 5 +EMERGENCY_DELAY = 10 +SPEND_FEE = Decimal("0.00010000") + + +def test_live_core_28_1_community_treasury_policy_poc( + live_rpc_client: BitcoinRpcClient, +) -> None: + """Prove all branches of the proposed public Miniscript policy on pinned Core.""" + + rpc = live_rpc_client + network = _dict(rpc.call("getnetworkinfo"), "getnetworkinfo") + assert network.get("version") == 280100 + + prefix = f"treasury-poc-{uuid4().hex[:8]}" + wallets: list[str] = [] + try: + funder = _create_wallet(rpc, f"{prefix}-funder", wallets) + coordinator = _create_wallet( + rpc, + f"{prefix}-coordinator", + wallets, + private_keys=False, + ) + operators, operator_keys = _create_signer_group( + rpc, + prefix, + "operator", + wallets, + ) + recoverers, recovery_keys = _create_signer_group( + rpc, + prefix, + "recovery", + wallets, + ) + emergencies, emergency_keys = _create_signer_group( + rpc, + prefix, + "emergency", + wallets, + ) + + policy = TreasuryPolicy( + recovery_delay_blocks=RECOVERY_DELAY, + emergency_delay_blocks=EMERGENCY_DELAY, + operators=_participant_group( + TreasuryParticipantRole.OPERATOR, + operators, + operator_keys, + ), + recovery=_participant_group( + TreasuryParticipantRole.RECOVERY, + recoverers, + recovery_keys, + ), + emergency=_participant_group( + TreasuryParticipantRole.EMERGENCY, + emergencies, + emergency_keys, + ), + ) + policy_service = TreasuryPolicyService(rpc) + materialized = policy_service.materialize(policy) + imported = policy_service.import_into_coordinator( + materialized, + coordinator, + label="treasury-policy", + ) + assert materialized.is_solvable is True + assert materialized.has_private_keys is False + assert imported.imported is True + policy_address = materialized.address + coordinator_info = _dict( + rpc.call("getwalletinfo", wallet_name=coordinator), + "getwalletinfo", + ) + assert coordinator_info.get("private_keys_enabled") is False + + mining_address = _string( + rpc.call("getnewaddress", ["treasury-mining", "bech32"], wallet_name=funder), + "getnewaddress", + ) + destination = _string( + rpc.call("getnewaddress", ["treasury-destination", "bech32"], wallet_name=funder), + "getnewaddress", + ) + _ensure_mature_balance(rpc, funder, mining_address, Decimal("1.10000000")) + + immediate_funding = _fund_policy(rpc, funder, mining_address, policy_address) + immediate_psbt = _policy_psbt( + rpc, + coordinator, + immediate_funding, + destination, + sequence=0xFFFFFFFE, + ) + one_operator_psbt, one_operator = _sign(rpc, immediate_psbt, operators[0]) + assert one_operator.get("complete") is False + assert _partial_signature_count(rpc, one_operator_psbt) == 1 + assert _dict(rpc.call("finalizepsbt", [one_operator_psbt, True]), "finalizepsbt").get("complete") is False + + immediate_signed_psbt, second_operator = _sign(rpc, one_operator_psbt, operators[1]) + assert second_operator.get("complete") is False + assert _partial_signature_count(rpc, immediate_signed_psbt) == 2 + immediate_final = _finalize(rpc, immediate_signed_psbt) + assert _acceptance(rpc, immediate_final["hex"]).get("allowed") is True + immediate_txid = _string( + rpc.call("sendrawtransaction", [immediate_final["hex"]]), + "sendrawtransaction", + ) + _confirm(rpc, funder, mining_address, immediate_txid) + + recovery_funding = _fund_policy(rpc, funder, mining_address, policy_address) + recovery_psbt = _policy_psbt( + rpc, + coordinator, + recovery_funding, + destination, + sequence=RECOVERY_DELAY, + ) + one_recovery_psbt, _ = _sign(rpc, recovery_psbt, recoverers[0]) + assert _partial_signature_count(rpc, one_recovery_psbt) == 1 + assert _dict(rpc.call("finalizepsbt", [one_recovery_psbt, True]), "finalizepsbt").get("complete") is False + recovery_signed_psbt, _ = _sign(rpc, one_recovery_psbt, recoverers[1]) + recovery_final = _finalize(rpc, recovery_signed_psbt) + premature = _acceptance(rpc, recovery_final["hex"]) + assert premature.get("allowed") is False + assert premature.get("reject-reason") == "non-BIP68-final" + + wrong_sequence_psbt = _policy_psbt( + rpc, + coordinator, + recovery_funding, + destination, + sequence=RECOVERY_DELAY - 1, + ) + wrong_sequence_psbt, _ = _sign(rpc, wrong_sequence_psbt, recoverers[0]) + wrong_sequence_psbt, _ = _sign(rpc, wrong_sequence_psbt, recoverers[1]) + wrong_decoded = _dict(rpc.call("decodepsbt", [wrong_sequence_psbt]), "decodepsbt") + assert wrong_decoded["tx"]["vin"][0]["sequence"] == RECOVERY_DELAY - 1 + wrong_final = _dict( + rpc.call("finalizepsbt", [wrong_sequence_psbt, True]), + "finalizepsbt", + ) + assert wrong_final.get("complete") is False + assert wrong_final.get("hex") is None + + _mine(rpc, RECOVERY_DELAY, mining_address) + assert _acceptance(rpc, recovery_final["hex"]).get("allowed") is True + recovery_txid = _string( + rpc.call("sendrawtransaction", [recovery_final["hex"]]), + "sendrawtransaction", + ) + _confirm(rpc, funder, mining_address, recovery_txid) + + emergency_funding = _fund_policy(rpc, funder, mining_address, policy_address) + emergency_psbt = _policy_psbt( + rpc, + coordinator, + emergency_funding, + destination, + sequence=EMERGENCY_DELAY, + ) + one_emergency_psbt, _ = _sign(rpc, emergency_psbt, emergencies[0]) + assert _partial_signature_count(rpc, one_emergency_psbt) == 1 + assert _dict(rpc.call("finalizepsbt", [one_emergency_psbt, True]), "finalizepsbt").get("complete") is False + emergency_signed_psbt, _ = _sign(rpc, one_emergency_psbt, emergencies[1]) + emergency_final = _finalize(rpc, emergency_signed_psbt) + emergency_premature = _acceptance(rpc, emergency_final["hex"]) + assert emergency_premature.get("allowed") is False + assert emergency_premature.get("reject-reason") == "non-BIP68-final" + _mine(rpc, EMERGENCY_DELAY, mining_address) + assert _acceptance(rpc, emergency_final["hex"]).get("allowed") is True + emergency_txid = _string( + rpc.call("sendrawtransaction", [emergency_final["hex"]]), + "sendrawtransaction", + ) + _confirm(rpc, funder, mining_address, emergency_txid) + finally: + for wallet in reversed(wallets): + try: + rpc.call("unloadwallet", [], wallet_name=wallet) + except Exception: + pass + + loaded = rpc.call("listwallets") + assert isinstance(loaded, list) + assert not set(wallets).intersection(loaded) + + +def _participant_group( + role: TreasuryParticipantRole, + wallets: list[str], + public_keys: list[str], +) -> TreasuryParticipantGroup: + assert len(wallets) == 3 and len(public_keys) == 3 + return TreasuryParticipantGroup( + role=role, + participants=[ + TreasuryParticipant( + participant_id=f"{role.value}-{position}", + role=role, + position=position, + wallet_name=wallet, + public_key=public_key, + ) + for position, (wallet, public_key) in enumerate( + zip(wallets, public_keys, strict=True), + start=1, + ) + ], + ) + + +def _create_wallet( + rpc: BitcoinRpcClient, + name: str, + wallets: list[str], + *, + private_keys: bool = True, +) -> str: + rpc.call( + "createwallet", + [name, not private_keys, not private_keys, "", False, True, False, False], + ) + wallets.append(name) + return name + + +def _create_signer_group( + rpc: BitcoinRpcClient, + prefix: str, + role: str, + wallets: list[str], +) -> tuple[list[str], list[str]]: + signer_wallets: list[str] = [] + pubkeys: list[str] = [] + for index in range(3): + wallet = _create_wallet(rpc, f"{prefix}-{role}-{index + 1}", wallets) + signer_wallets.append(wallet) + address = _string( + rpc.call("getnewaddress", [f"treasury-{role}-{index + 1}", "bech32"], wallet_name=wallet), + "getnewaddress", + ) + info = _dict(rpc.call("getaddressinfo", [address], wallet_name=wallet), "getaddressinfo") + pubkeys.append(_string(info.get("pubkey"), "getaddressinfo")) + return signer_wallets, pubkeys + + +def _fund_policy( + rpc: BitcoinRpcClient, + funder: str, + mining_address: str, + policy_address: str, +) -> dict[str, object]: + amount = Decimal("1.00000000") + txid = _string( + rpc.call( + "sendtoaddress", + [policy_address, float(amount), "", "", False, True, None, "unset", None, 2.0], + wallet_name=funder, + ), + "sendtoaddress", + ) + _mine(rpc, 1, mining_address) + transaction = _dict(rpc.call("gettransaction", [txid], wallet_name=funder), "gettransaction") + decoded = _dict(rpc.call("decoderawtransaction", [transaction["hex"]]), "decoderawtransaction") + for output in decoded.get("vout", []): + script = output.get("scriptPubKey") if isinstance(output, dict) else None + if isinstance(script, dict) and script.get("address") == policy_address: + return {"txid": txid, "vout": output["n"], "amount": Decimal(str(output["value"]))} + raise AssertionError("The confirmed treasury funding transaction did not contain the policy output.") + + +def _policy_psbt( + rpc: BitcoinRpcClient, + coordinator: str, + funding: dict[str, object], + destination: str, + *, + sequence: int, +) -> str: + output_amount = funding["amount"] - SPEND_FEE + assert isinstance(output_amount, Decimal) + psbt = _string( + rpc.call( + "createpsbt", + [ + [{"txid": funding["txid"], "vout": funding["vout"], "sequence": sequence}], + [{destination: float(output_amount)}], + 0, + ], + ), + "createpsbt", + ) + updated = _dict( + rpc.call("walletprocesspsbt", [psbt, False, "ALL", True, False], wallet_name=coordinator), + "walletprocesspsbt", + ) + enriched = _string(updated.get("psbt"), "walletprocesspsbt") + decoded = _dict(rpc.call("decodepsbt", [enriched]), "decodepsbt") + assert decoded["tx"]["version"] == 2 + assert decoded["inputs"][0].get("witness_script") + return enriched + + +def _sign(rpc: BitcoinRpcClient, psbt: str, wallet: str) -> tuple[str, dict[str, object]]: + result = _dict( + rpc.call("walletprocesspsbt", [psbt, True, "ALL", True, False], wallet_name=wallet), + "walletprocesspsbt", + ) + return _string(result.get("psbt"), "walletprocesspsbt"), result + + +def _partial_signature_count(rpc: BitcoinRpcClient, psbt: str) -> int: + decoded = _dict(rpc.call("decodepsbt", [psbt]), "decodepsbt") + signatures = decoded["inputs"][0].get("partial_signatures") + return len(signatures) if isinstance(signatures, dict) else 0 + + +def _finalize(rpc: BitcoinRpcClient, psbt: str) -> dict[str, object]: + finalized = _dict(rpc.call("finalizepsbt", [psbt, True]), "finalizepsbt") + assert finalized.get("complete") is True + _string(finalized.get("hex"), "finalizepsbt") + return finalized + + +def _acceptance(rpc: BitcoinRpcClient, tx_hex: object) -> dict[str, object]: + result = rpc.call("testmempoolaccept", [[_string(tx_hex, "testmempoolaccept")]]) + assert isinstance(result, list) and len(result) == 1 + return _dict(result[0], "testmempoolaccept") + + +def _confirm(rpc: BitcoinRpcClient, wallet: str, mining_address: str, txid: str) -> None: + _mine(rpc, 1, mining_address) + transaction = _dict(rpc.call("gettransaction", [txid], wallet_name=wallet), "gettransaction") + confirmations = transaction.get("confirmations") + assert isinstance(confirmations, int) and not isinstance(confirmations, bool) and confirmations >= 1 + + +def _mine(rpc: BitcoinRpcClient, blocks: int, address: str) -> list[str]: + hashes: list[str] = [] + remaining = blocks + while remaining: + count = min(remaining, 20) + batch = rpc.call("generatetoaddress", [count, address]) + assert isinstance(batch, list) and len(batch) == count + assert all(isinstance(item, str) and item for item in batch) + hashes.extend(batch) + remaining -= count + return hashes + + +def _ensure_mature_balance( + rpc: BitcoinRpcClient, + wallet: str, + mining_address: str, + minimum: Decimal, +) -> None: + _mine(rpc, 101, mining_address) + for _ in range(100): + balances = _dict(rpc.call("getbalances", wallet_name=wallet), "getbalances") + mine = balances.get("mine") + trusted = mine.get("trusted") if isinstance(mine, dict) else None + assert isinstance(trusted, int | float) and not isinstance(trusted, bool) + if Decimal(str(trusted)) >= minimum: + return + _mine(rpc, 1, mining_address) + raise AssertionError("The disposable treasury funder did not reach the required mature balance.") + + +def _dict(value: object, method: str) -> dict[str, object]: + assert isinstance(value, dict), f"{method} returned {type(value).__name__}" + return value + + +def _string(value: object, method: str) -> str: + assert isinstance(value, str) and value, f"{method} did not return a string" + return value diff --git a/backend/tests/test_attack_verification_service.py b/backend/tests/test_attack_verification_service.py new file mode 100644 index 0000000..23f4645 --- /dev/null +++ b/backend/tests/test_attack_verification_service.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import pytest + +from app.errors import BitScopeError +from app.models.attack import ( + AttackApplicabilityStatus, + AttackContext, + AttackFeature, + AttackType, + AttackVerificationStatus, + MempoolAttackObservation, + PsbtAttackObservation, + RpcErrorAttackObservation, +) +from app.models.scenario import FailureCategory +from app.services.attack_verification_service import ( + DEFAULT_ATTACK_CATALOG, + AttackVerificationService, +) + + +def context(scenario_id: str, *features: AttackFeature) -> AttackContext: + return AttackContext(scenario_id=scenario_id, available_features=list(features)) + + +def test_catalog_types_every_required_attack_and_reuses_four_types() -> None: + assert {profile.attack_type for profile in DEFAULT_ATTACK_CATALOG.profiles} == set(AttackType) + + scenarios_by_type: dict[AttackType, set[str]] = {} + for definition in DEFAULT_ATTACK_CATALOG.definitions: + scenarios_by_type.setdefault(definition.attack_type, set()).update(definition.scenario_ids) + + reused = { + attack_type + for attack_type, scenario_ids in scenarios_by_type.items() + if len(scenario_ids) >= 2 + } + assert { + AttackType.SIGNATURE_INSUFFICIENCY, + AttackType.PSBT_INCOMPLETENESS, + AttackType.SEQUENCE_MODIFICATION, + AttackType.PREMATURE_TIMELOCK_EXECUTION, + } <= reused + + +def test_unsupported_attack_is_skipped_with_an_explicit_reason() -> None: + service = AttackVerificationService() + decision = service.catalog.assess_type( + AttackType.DUST_OUTPUT, + context("community-treasury-recovery", AttackFeature.PSBT), + ) + + assert decision.status == AttackApplicabilityStatus.NOT_APPLICABLE + assert decision.reason + skipped = service.skip(decision) + assert skipped.status == AttackVerificationStatus.SKIPPED + assert skipped.classification is None + + +def test_missing_feature_prevents_attack_execution() -> None: + service = AttackVerificationService() + decision = service.assess( + "cltv-timelock.premature-timelock", + context("cltv-timelock", AttackFeature.RAW_TRANSACTION), + ) + + assert decision.status == AttackApplicabilityStatus.NOT_APPLICABLE + assert decision.missing_features == [ + AttackFeature.ABSOLUTE_TIMELOCK, + AttackFeature.MEMPOOL_PREFLIGHT, + ] + with pytest.raises(ValueError, match="prior applicable decision"): + service.verify( + decision, + MempoolAttackObservation(allowed=False, reject_reason="non-final"), + ) + + +def test_structured_mempool_rejection_is_classified_without_message_guessing() -> None: + service = AttackVerificationService() + decision = service.assess( + "transaction-lifecycle.output-modification", + context( + "transaction-lifecycle", + AttackFeature.RAW_TRANSACTION, + AttackFeature.MUTABLE_OUTPUTS, + AttackFeature.MEMPOOL_PREFLIGHT, + ), + ) + result = service.verify( + decision, + MempoolAttackObservation( + allowed=False, + reject_reason="bad-txns-in-belowout", + raw_safe_details={"allowed": False, "reject-reason": "bad-txns-in-belowout"}, + ), + ) + + assert result.status == AttackVerificationStatus.EXPECTED_FAILURE + assert result.classification == FailureCategory.CONSENSUS_VALIDATION + + +def test_psbt_and_rpc_classification_preserve_bounded_redacted_details() -> None: + service = AttackVerificationService() + psbt_decision = service.assess( + "multisig-psbt.signature-insufficiency", + context("multisig-psbt", AttackFeature.PSBT, AttackFeature.THRESHOLD_POLICY), + ) + psbt_result = service.verify( + psbt_decision, + PsbtAttackObservation( + complete=False, + transaction_hex_present=False, + signature_count=1, + raw_safe_details={"complete": False, "private_key": "not-safe"}, + ), + ) + assert psbt_result.status == AttackVerificationStatus.EXPECTED_FAILURE + assert psbt_result.raw_safe_details == {"complete": False, "private_key": "[REDACTED]"} + + rpc_decision = service.assess( + "rbf-replacement.replacement-policy", + context( + "rbf-replacement", + AttackFeature.WALLET_TRANSACTION, + AttackFeature.RBF_SIGNALING, + AttackFeature.RPC_ERROR, + ), + ) + rpc_result = service.verify( + rpc_decision, + RpcErrorAttackObservation( + rpc_method="bumpfee", + rpc_code=-8, + rpc_message="Insufficient total fee: oldFee and incrementalFee are required", + raw_safe_details={"rpc_code": -8, "rpc_message": "x" * 3_000}, + ), + ) + assert rpc_result.status == AttackVerificationStatus.EXPECTED_FAILURE + assert rpc_result.classification == FailureCategory.MEMPOOL_POLICY + assert len(rpc_result.raw_safe_details["rpc_message"]) == 2_000 + + +def test_mismatched_observation_is_unexpected_and_fails_closed() -> None: + service = AttackVerificationService() + decision = service.assess( + "cltv-timelock.premature-timelock", + context( + "cltv-timelock", + AttackFeature.RAW_TRANSACTION, + AttackFeature.ABSOLUTE_TIMELOCK, + AttackFeature.MEMPOOL_PREFLIGHT, + ), + ) + result = service.verify( + decision, + MempoolAttackObservation( + allowed=False, + reject_reason="missing-inputs", + raw_safe_details={"allowed": False, "reject-reason": "missing-inputs"}, + ), + ) + + assert result.status == AttackVerificationStatus.UNEXPECTED_FAILURE + assert result.classification == FailureCategory.UNEXPECTED_APPLICATION + with pytest.raises(BitScopeError, match="different reason") as captured: + service.require_expected( + result, + mismatch_code="SCENARIO_ATTACK_MISMATCH", + safe_message="Bitcoin Core rejected the transaction for a different reason.", + ) + assert captured.value.details["attack_result"]["raw_safe_details"] == { + "allowed": False, + "reject-reason": "missing-inputs", + } diff --git a/backend/tests/test_cltv_timelock_scenario.py b/backend/tests/test_cltv_timelock_scenario.py new file mode 100644 index 0000000..cbc6f8a --- /dev/null +++ b/backend/tests/test_cltv_timelock_scenario.py @@ -0,0 +1,320 @@ +import json +from datetime import UTC, datetime +from pathlib import Path + +from app.config import Settings +from app.models.lab import LabSession +from app.models.scenario import CleanupStatus, ScenarioRunState +from app.services.evidence_service import EvidenceService +from app.services.lab_session_store import LabSessionStore +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import DEFAULT_SCENARIO_CATALOG +from app.services.scenario_run_store import ScenarioRunStore +from app.services.scenario_service import ScenarioService + + +SESSION_ID = "cltv_session" +WALLET = "bitscope-session-cltv_session" +FUNDING_TXID = "33" * 32 +SPEND_TXID = "44" * 32 +NOW = datetime(2026, 7, 21, tzinfo=UTC) + + +class CltvRpcClient: + def __init__(self, premature_reason: str = "non-final") -> None: + self.settings = Settings( + bitcoin_network="regtest", + bitcoin_rpc_user="cltv-user", + bitcoin_rpc_password="cltv-secret", + bitscope_local_access_token="cltv-token", + ) + self.calls: list[tuple[str, object, str | None]] = [] + self.loaded_wallets = [WALLET] + self.height = 300 + self.target = 405 + self.premature_reason = premature_reason + self.confirmed = False + + def call(self, method: str, params: object = None, wallet_name: str | None = None) -> object: + self.calls.append((method, params, wallet_name)) + if method == "getblockchaininfo": + return {"chain": "regtest"} + if method == "getnetworkinfo": + return { + "version": 280100, + "subversion": "/Satoshi:28.1.0/", + "warnings": "canary cltv-secret must be redacted", + } + if method == "getblockcount": + return self.height + if method == "listwallets": + return list(self.loaded_wallets) + if method == "getnewaddress": + return ( + "bcrt1qcltvmining" + if params[0] == "bitscope-cltv-mining" + else "bcrt1qcltvdestination" + ) + if method == "generatetoaddress": + count = int(params[0]) + hashes = [] + for _ in range(count): + self.height += 1 + hashes.append(f"{self.height:064x}") + if count == 1 and self.height > self.target: + self.confirmed = True + return hashes + if method == "testmempoolaccept": + raw_hex = params[0][0] + if raw_hex == "validhex": + if self.height < self.target: + return [{"txid": SPEND_TXID, "allowed": False, "reject-reason": self.premature_reason}] + return [{"txid": SPEND_TXID, "allowed": True, "vsize": 199}] + return [ + { + "txid": "55" * 32, + "allowed": False, + "reject-reason": ( + "mandatory-script-verify-flag-failed " + "(Locktime requirement not satisfied)" + ), + } + ] + if method == "sendrawtransaction": + return SPEND_TXID + if method == "getmempoolentry": + return {"vsize": 199, "fees": {"base": 0.0001}} + if method == "gettransaction": + return { + "txid": SPEND_TXID, + "hex": "confirmedhex", + "confirmations": 1 if self.confirmed else 0, + "blockhash": "66" * 32 if self.confirmed else None, + } + if method == "decoderawtransaction": + return { + "txid": SPEND_TXID, + "hash": "77" * 32, + "locktime": self.target, + "vin": [{"txid": FUNDING_TXID, "vout": 0, "sequence": 0xFFFFFFFE}], + "vout": [{"n": 0, "value": 0.4999}], + } + if method == "unloadwallet": + if wallet_name in self.loaded_wallets: + self.loaded_wallets.remove(wallet_name) + return None + raise AssertionError(f"Unexpected RPC method: {method}") + + +class FakeTimelockService: + def __init__(self, rpc: CltvRpcClient) -> None: + self.rpc = rpc + self.clear_count = 0 + + def create_cltv_policy(self, lock_height: int) -> dict[str, object]: + self.rpc.target = lock_height + return { + "signer_kind": "ephemeral_software_key", + "lock_height": lock_height, + "pubkey": "02" + "aa" * 32, + "policy_address": "bcrt1qcltvpolicy", + "script_pub_key": "0020" + "bb" * 32, + "witness_script": "02a001b17521" + "02" + "aa" * 32 + "ac", + "template": {"mode": "cltv", "value": lock_height}, + } + + def fund_cltv_policy( + self, + wallet: str, + policy_address: str, + amount: float, + fee_rate: float, + ) -> dict[str, object]: + return { + "funding_wallet": wallet, + "policy_address": policy_address, + "amount_btc": amount, + "txid": FUNDING_TXID, + "vout": 0, + "output_amount_btc": 0.5, + "script_pub_key": "0020" + "bb" * 32, + "fee_rate_sat_vb": fee_rate, + } + + def create_cltv_spend( + self, + funding: dict[str, object], + policy_address: str, + witness_script: str, + destination: str, + locktime: int, + sequence: int, + fee_sats: int, + ) -> dict[str, object]: + raw_hex = ( + "finalhex" + if sequence == 0xFFFFFFFF + else "lowhex" + if locktime < self.rpc.target + else "validhex" + ) + return { + "signer_kind": "ephemeral_software_key", + "destination_address": destination, + "funding_txid": funding["txid"], + "funding_vout": funding["vout"], + "fee_sats": fee_sats, + "locktime": locktime, + "sequence": sequence, + "signed_hex": raw_hex, + "complete": True, + "signing_errors": [], + "decoded": { + "txid": SPEND_TXID if raw_hex == "validhex" else "55" * 32, + "locktime": locktime, + "vin": [{"sequence": sequence}], + }, + } + + def clear_ephemeral_cltv_keys(self) -> None: + self.clear_count += 1 + + +def build_service( + tmp_path: Path, + rpc: CltvRpcClient, +) -> tuple[ScenarioService, ScenarioRunStore, LabSessionStore, FakeTimelockService]: + database = tmp_path / "cltv.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_store.save( + LabSession( + session_id=SESSION_ID, + wallet_name=WALLET, + owned_wallets=[WALLET], + wallet_generation=0, + runtime_chain="regtest", + starting_height=300, + status="active", + created_at=NOW, + updated_at=NOW, + ) + ) + run_store = ScenarioRunStore(str(database)) + artifacts = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + service = ScenarioService( + rpc, # type: ignore[arg-type] + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(rpc.settings), + artifacts, + lab_store, + ) + timelock = FakeTimelockService(rpc) + service.cltv_timelock_service.timelock_service = timelock # type: ignore[assignment] + return service, run_store, lab_store, timelock + + +def test_cltv_scenario_rejects_variants_matures_confirms_and_cleans_up(tmp_path: Path) -> None: + rpc = CltvRpcClient() + service, run_store, lab_store, timelock = build_service(tmp_path, rpc) + created = service.create_run("cltv-timelock", SESSION_ID) + ready = service.advance(created.run_id, SESSION_ID, expected_revision=0) + + verified = service.advance(ready.run_id, SESSION_ID, expected_revision=1) + + assert verified.current_state == ScenarioRunState.VERIFIED + assert verified.cleanup_status == CleanupStatus.COMPLETED + assert verified.revision == 5 + assert len(verified.completed_steps) == 24 + assert len(verified.assertion_results) == 7 + assert all(result.status.value == "passed" for result in verified.assertion_results) + assert [failure.code for failure in verified.expected_failures] == [ + "non-final", + "cltv-final-sequence", + "cltv-low-locktime", + ] + assert [failure.category.value for failure in verified.expected_failures] == [ + "mempool_policy", + "script_verification", + "script_verification", + ] + assert verified.unexpected_failures == [] + assert run_store.get(verified.run_id) == verified + assert timelock.clear_count == 1 + + session = lab_store.get(SESSION_ID) + assert session is not None and session.status == "cleaned" + assert session.transaction_ids == [FUNDING_TXID, SPEND_TXID] + assert len(session.block_hashes) == 106 + assert WALLET not in rpc.loaded_wallets + + bundle_service = ProofBundleService( + run_store, + service.artifact_store, + DEFAULT_SCENARIO_CATALOG, + ) + first = bundle_service.bundle(verified.run_id, SESSION_ID) + second = bundle_service.bundle(verified.run_id, SESSION_ID) + assert first.zip_bytes == second.zip_bytes + assert first.manifest.final_result is not None + assert first.manifest.final_result.value == "verified" + for evidence_id in ( + "cltv.setup", + "cltv.policy-funding", + "cltv.premature", + "cltv.invalid-sequence", + "cltv.invalid-locktime", + "cltv.mature", + "cltv.confirmed", + ): + assert f"evidence/{evidence_id}.json" in first.files + assert b"cltv-final-sequence" in first.files["run.json"] + lifecycle = json.loads(first.files["lifecycle.json"]) + maturity = next(event for event in lifecycle["events"] if event["event_type"] == "timelock_matured") + assert maturity["track_id"] == "cltv.spend" + assert maturity["block_height"] == rpc.target + assert lifecycle["events"][-1]["event_type"] == "scenario_cleaned_up" + attacks = json.loads(first.files["evidence/attacks.summary.json"])["core_output"]["result"] + assert [item["attack_type"] for item in attacks] == [ + "premature_timelock_execution", + "sequence_modification", + "locktime_modification", + ] + assert all(b"cltv-secret" not in content for content in first.files.values()) + + +def test_cltv_scenario_fails_closed_on_different_premature_rejection(tmp_path: Path) -> None: + rpc = CltvRpcClient(premature_reason="missing-inputs") + service, _, lab_store, timelock = build_service(tmp_path, rpc) + created = service.create_run("cltv-timelock", SESSION_ID) + ready = service.advance(created.run_id, SESSION_ID, expected_revision=0) + + failed = service.advance(ready.run_id, SESSION_ID, expected_revision=1) + + assert failed.current_state == ScenarioRunState.FAILED + assert failed.cleanup_status == CleanupStatus.COMPLETED + assert failed.failed_steps == ["reject_premature_spend"] + assert failed.unexpected_failures[0].code == "SCENARIO_CLTV_PREMATURE_REJECTION_MISMATCH" + assert failed.unexpected_failures[0].attack_id == "cltv-timelock.premature-timelock" + assert failed.unexpected_failures[0].raw_safe_details["reject-reason"] == "missing-inputs" + assert timelock.clear_count == 1 + session = lab_store.get(SESSION_ID) + assert session is not None and session.status == "cleaned" + + +def test_default_catalog_exposes_reviewed_cltv_scenario() -> None: + entry = DEFAULT_SCENARIO_CATALOG.get("cltv-timelock") + + assert entry.available is True + assert len(entry.definition.steps) == 24 + assert entry.definition.steps[-1].type == "cleanup_lab" + assert {assertion.assertion_id for assertion in entry.definition.assertions} == { + "premature_rejected", + "timelock_immature", + "final_sequence_rejected", + "low_locktime_rejected", + "timelock_mature", + "mature_spend_accepted", + "spend_confirmed", + } diff --git a/backend/tests/test_community_treasury_scenario.py b/backend/tests/test_community_treasury_scenario.py new file mode 100644 index 0000000..69175e8 --- /dev/null +++ b/backend/tests/test_community_treasury_scenario.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from app.config import Settings +from app.models.lab import LabSession +from app.models.scenario import CleanupStatus, ScenarioDefinition, ScenarioRunState +from app.services.community_treasury_scenario import COMMUNITY_TREASURY_SCENARIO +from app.services.evidence_service import EvidenceService +from app.services.lab_session_store import LabSessionStore +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import DEFAULT_SCENARIO_CATALOG +from app.services.scenario_run_store import ScenarioRunStore +from app.services.scenario_service import ScenarioService + + +NOW = datetime(2026, 7, 21, 12, 0, tzinfo=UTC) +SESSION_ID = "treasury_session" +FUNDING_WALLET = f"bitscope-session-{SESSION_ID}" +TREASURY_WALLETS = [f"{FUNDING_WALLET}-r{index}" for index in range(1, 11)] +COORDINATOR = TREASURY_WALLETS[0] +SIGNERS = TREASURY_WALLETS[1:] +POLICY_ADDRESS = "bcrt1qtreasurypolicy" +FUNDING_TXIDS = ["a" * 64, "c" * 64, "e" * 64] +SPEND_TXIDS = ["b" * 64, "d" * 64, "f" * 64] + + +class TreasuryRpcClient: + def __init__(self, recovery_reject_reason: str = "non-BIP68-final") -> None: + self.settings = Settings( + bitcoin_network="regtest", + bitcoin_rpc_user="treasury-user", + bitcoin_rpc_password="treasury-secret", + bitscope_local_access_token="treasury-token", + ) + self.recovery_reject_reason = recovery_reject_reason + self.calls: list[tuple[str, object, str | None]] = [] + self.loaded_wallets = [FUNDING_WALLET] + self.height = 300 + self.block_index = 0 + self.funding_index = 0 + self.broadcasted: set[str] = set() + self.confirmed: set[str] = set() + self.recovery_mature = False + self.emergency_mature = False + + def call(self, method: str, params: object = None, wallet_name: str | None = None) -> object: + self.calls.append((method, params, wallet_name)) + if method == "getblockchaininfo": + return {"chain": "regtest"} + if method == "getnetworkinfo": + return { + "version": 280100, + "subversion": "/Satoshi:28.1.0/", + "warnings": "canary treasury-secret must be redacted", + } + if method == "getblockcount": + return self.height + if method == "listwallets": + return list(self.loaded_wallets) + if method == "createwallet": + name = str(params[0]) + self.loaded_wallets.append(name) + return {"name": name, "warning": ""} + if method == "unloadwallet": + if wallet_name in self.loaded_wallets: + self.loaded_wallets.remove(wallet_name) + return None + if method == "getnewaddress": + label = str(params[0]) + if label == "bitscope-treasury-mining": + return "bcrt1qtreasurymining" + if label == "bitscope-treasury-destination": + return "bcrt1qtreasurydestination" + return f"bcrt1qsigner{SIGNERS.index(str(wallet_name)) + 1}" + if method == "getaddressinfo": + signer_index = SIGNERS.index(str(wallet_name)) + 1 + return {"address": params[0], "pubkey": f"02{signer_index:064x}"} + if method == "getbalances": + return {"mine": {"trusted": 50.0, "untrusted_pending": 0.0, "immature": 0.0}} + if method == "getdescriptorinfo": + descriptor = str(params[0]) + return { + "descriptor": f"{descriptor}#deadbeef", + "checksum": "deadbeef", + "isrange": False, + "issolvable": True, + "hasprivatekeys": False, + } + if method == "deriveaddresses": + return [POLICY_ADDRESS] + if method == "getwalletinfo": + return {"private_keys_enabled": wallet_name != COORDINATOR} + if method == "importdescriptors": + return [{"success": True}] + if method == "sendtoaddress": + txid = FUNDING_TXIDS[self.funding_index] + self.funding_index += 1 + return txid + if method == "generatetoaddress": + count = int(params[0]) + self.height += count + if count == 5: + self.recovery_mature = True + if count == 10: + self.emergency_mature = True + if count == 1: + self.confirmed.update(self.broadcasted) + hashes = [] + for _ in range(count): + self.block_index += 1 + hashes.append(f"{self.block_index:064x}") + return hashes + if method == "gettransaction": + txid = str(params[0]) + if txid in FUNDING_TXIDS: + return {"txid": txid, "hex": f"funding-{FUNDING_TXIDS.index(txid)}", "confirmations": 0} + return { + "txid": txid, + "hex": self._spend_hex(txid), + "confirmations": 1 if txid in self.confirmed else 0, + "blockhash": "9" * 64 if txid in self.confirmed else None, + } + if method == "decoderawtransaction": + raw = str(params[0]) + if raw.startswith("funding-"): + return { + "txid": FUNDING_TXIDS[int(raw.removeprefix("funding-"))], + "vout": [ + { + "n": 0, + "value": 1.0, + "scriptPubKey": {"address": POLICY_ADDRESS}, + } + ], + } + return {"txid": self._txid_for_hex(raw), "vin": [{}], "vout": [{}, {}]} + if method == "createpsbt": + inputs = params[0] + txid = inputs[0]["txid"] + sequence = inputs[0]["sequence"] + branch = self._branch_for_funding(txid, sequence) + return f"unsigned-{branch}" + if method == "walletprocesspsbt": + psbt = str(params[0]) + sign = bool(params[1]) + if not sign: + return {"psbt": psbt.replace("unsigned-", "enriched-"), "complete": False} + branch = psbt.split("-", 1)[1] + prefix = "partial" if psbt.startswith("enriched-") else "threshold" + return {"psbt": f"{prefix}-{branch}", "complete": False} + if method == "decodepsbt": + psbt = str(params[0]) + sequence = 4 if "wrong" in psbt else 5 if "recovery" in psbt else 10 if "emergency" in psbt else 4294967294 + signature_count = 2 if psbt.startswith("threshold-") else 1 if psbt.startswith("partial-") else 0 + return { + "tx": {"version": 2, "vin": [{"sequence": sequence}]}, + "inputs": [ + { + "witness_script": "51", + "partial_signatures": { + f"pubkey-{index}": f"signature-{index}" + for index in range(signature_count) + }, + } + ], + "outputs": [{}], + } + if method == "finalizepsbt": + psbt = str(params[0]) + if psbt.startswith("partial-") or psbt == "threshold-wrong": + return {"psbt": psbt, "complete": False} + return {"hex": f"hex-{psbt.removeprefix('threshold-')}", "complete": True} + if method == "testmempoolaccept": + transaction_hex = str(params[0][0]) + if transaction_hex == "hex-recovery" and not self.recovery_mature: + return [{"txid": SPEND_TXIDS[1], "allowed": False, "reject-reason": self.recovery_reject_reason}] + if transaction_hex == "hex-emergency" and not self.emergency_mature: + return [{"txid": SPEND_TXIDS[2], "allowed": False, "reject-reason": "non-BIP68-final"}] + return [{"txid": self._txid_for_hex(transaction_hex), "allowed": True, "vsize": 300}] + if method == "sendrawtransaction": + txid = self._txid_for_hex(str(params[0])) + self.broadcasted.add(txid) + return txid + if method == "getmempoolentry": + return {"vsize": 300, "fees": {"base": 0.0001}} + raise AssertionError(f"Unexpected RPC method: {method}") + + @staticmethod + def _branch_for_funding(txid: str, sequence: int) -> str: + if txid == FUNDING_TXIDS[0]: + return "immediate" + if txid == FUNDING_TXIDS[1]: + return "wrong" if sequence == 4 else "recovery" + return "emergency" + + @staticmethod + def _txid_for_hex(transaction_hex: str) -> str: + return { + "hex-immediate": SPEND_TXIDS[0], + "hex-recovery": SPEND_TXIDS[1], + "hex-emergency": SPEND_TXIDS[2], + }[transaction_hex] + + @staticmethod + def _spend_hex(txid: str) -> str: + return { + SPEND_TXIDS[0]: "hex-immediate", + SPEND_TXIDS[1]: "hex-recovery", + SPEND_TXIDS[2]: "hex-emergency", + }[txid] + + +def build_service( + tmp_path: Path, + rpc: TreasuryRpcClient, +) -> tuple[ScenarioService, ScenarioRunStore, LabSessionStore]: + database = tmp_path / "labs.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_store.save( + LabSession( + session_id=SESSION_ID, + wallet_name=FUNDING_WALLET, + owned_wallets=[FUNDING_WALLET], + wallet_generation=0, + runtime_chain="regtest", + starting_height=300, + status="active", + created_at=NOW, + updated_at=NOW, + ) + ) + run_store = ScenarioRunStore(str(database)) + artifact_store = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + service = ScenarioService( + rpc, # type: ignore[arg-type] + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(rpc.settings), + artifact_store, + lab_store, + ) + return service, run_store, lab_store + + +def test_community_treasury_executes_all_branches_exports_and_cleans_up(tmp_path: Path) -> None: + rpc = TreasuryRpcClient() + service, run_store, lab_store = build_service(tmp_path, rpc) + created = service.create_run("community-treasury-recovery", SESSION_ID) + ready = service.advance(created.run_id, SESSION_ID, expected_revision=0) + + verified = service.advance(ready.run_id, SESSION_ID, expected_revision=1) + + assert verified.current_state == ScenarioRunState.VERIFIED + assert verified.cleanup_status == CleanupStatus.COMPLETED + assert verified.revision == 5 + assert len(verified.completed_steps) == 53 + assert len(verified.assertion_results) == 25 + assert all(result.status.value == "passed" for result in verified.assertion_results) + assert [failure.code for failure in verified.expected_failures] == [ + "insufficient-immediate-signatures", + "insufficient-recovery-signatures", + "non-BIP68-final", + "incorrect-sequence-incomplete", + "insufficient-emergency-signatures", + "non-BIP68-final-emergency", + ] + assert verified.unexpected_failures == [] + assert run_store.get(verified.run_id) == verified + + session = lab_store.get(SESSION_ID) + assert session is not None and session.status == "cleaned" + assert session.owned_wallets == [FUNDING_WALLET, *TREASURY_WALLETS] + assert session.transaction_ids == [ + FUNDING_TXIDS[0], + SPEND_TXIDS[0], + FUNDING_TXIDS[1], + SPEND_TXIDS[1], + FUNDING_TXIDS[2], + SPEND_TXIDS[2], + ] + assert len(session.block_hashes) == 122 + unloaded = [call[2] for call in rpc.calls if call[0] == "unloadwallet"] + assert unloaded == [FUNDING_WALLET, *TREASURY_WALLETS] + + bundle_service = ProofBundleService(run_store, service.artifact_store, DEFAULT_SCENARIO_CATALOG) + first = bundle_service.bundle(verified.run_id, SESSION_ID) + second = bundle_service.bundle(verified.run_id, SESSION_ID) + assert first.zip_bytes == second.zip_bytes + assert first.proof_of_spendability is not None + assert first.proof_of_spendability.result == "VERIFIED" + assert first.proof_of_spendability.bitcoin_core_compatibility == "verified" + assert first.proof_of_spendability.policy is not None + assert first.proof_of_spendability.policy.recovery_delay_blocks == 5 + assert first.proof_of_spendability.policy.emergency_delay_blocks == 10 + assert all(check.status.value != "FAIL" for check in first.proof_of_spendability.checks) + assert "proof-of-spendability.json" in first.files + proof_document = json.loads(first.files["proof-of-spendability.json"]) + assert proof_document["result"] == "VERIFIED" + assert proof_document["checks"][1]["status"] == "REJECTED_AS_EXPECTED" + assert first.report_markdown.startswith("# Proof of Spendability: Community Treasury Recovery") + assert "Normal 2-of-3 operator spend: **PASS**" in first.report_markdown + assert "Premature recovery attempt: **REJECTED AS EXPECTED**" in first.report_markdown + assert "Signer model: isolated educational wallets" in first.report_markdown + assert "evidence/treasury.participants.json" in first.files + assert "evidence/treasury.policy.json" in first.files + assert "evidence/treasury.immediate.json" in first.files + assert "evidence/treasury.recovery-premature.json" in first.files + assert "evidence/treasury.recovery-wrong-sequence.json" in first.files + assert "evidence/treasury.recovery-mature.json" in first.files + assert "evidence/treasury.emergency-premature.json" in first.files + assert "evidence/treasury.emergency-mature.json" in first.files + lifecycle = json.loads(first.files["lifecycle.json"]) + assert len(lifecycle["events"]) == 33 + assert {event["track_id"] for event in lifecycle["events"]} >= { + "treasury.policy", + "treasury.immediate", + "treasury.recovery", + "treasury.emergency", + "scenario.cleanup", + } + assert [event["event_type"] for event in lifecycle["events"]].count("timelock_matured") == 2 + assert lifecycle["events"][-1]["event_type"] == "scenario_cleaned_up" + attacks = json.loads(first.files["evidence/attacks.summary.json"])["core_output"]["result"] + assert len(attacks) == 9 + assert all(item["status"] == "expected_failure" for item in attacks) + assert {item["attack_type"] for item in attacks} == { + "signature_insufficiency", + "psbt_incompleteness", + "premature_timelock_execution", + "sequence_modification", + } + assert all(b"treasury-secret" not in content for content in first.files.values()) + + +def test_community_treasury_fails_closed_on_wrong_premature_reason_and_cleans_up(tmp_path: Path) -> None: + rpc = TreasuryRpcClient(recovery_reject_reason="missing-inputs") + service, run_store, lab_store = build_service(tmp_path, rpc) + created = service.create_run("community-treasury-recovery", SESSION_ID) + ready = service.advance(created.run_id, SESSION_ID, expected_revision=0) + + failed = service.advance(ready.run_id, SESSION_ID, expected_revision=1) + + assert failed.current_state == ScenarioRunState.FAILED + assert failed.cleanup_status == CleanupStatus.COMPLETED + assert failed.failed_steps == ["reject_premature_recovery"] + assert failed.unexpected_failures[0].code == "SCENARIO_TREASURY_PREMATURE_REASON_MISMATCH" + assert ( + failed.unexpected_failures[0].attack_id + == "community-treasury-recovery.recovery-premature-timelock" + ) + assert failed.unexpected_failures[0].raw_safe_details["reject-reason"] == "missing-inputs" + session = lab_store.get(SESSION_ID) + assert session is not None and session.status == "cleaned" + assert rpc.loaded_wallets == [] + + bundle = ProofBundleService( + run_store, + service.artifact_store, + DEFAULT_SCENARIO_CATALOG, + ).bundle(failed.run_id, SESSION_ID) + assert bundle.proof_of_spendability is not None + assert bundle.proof_of_spendability.result == "FAILED" + assert bundle.proof_of_spendability.policy is None + assert any(check.status.value == "FAIL" for check in bundle.proof_of_spendability.checks) + + +def test_default_catalog_exposes_the_typed_community_treasury_scenario() -> None: + entry = DEFAULT_SCENARIO_CATALOG.get("community-treasury-recovery") + + assert entry.available is True + assert len(entry.definition.steps) == 53 + assert len(entry.definition.assertions) == 25 + assert entry.definition.steps[2].type == "prepare_treasury_participants" + assert entry.definition.steps[5].type == "materialize_treasury_policy" + assert entry.definition.steps[-1].type == "cleanup_lab" + + +@pytest.mark.parametrize("version", ["/Satoshi:28.1.0/", "/Satoshi:28.1/", "280100"]) +def test_proof_of_spendability_accepts_only_pinned_core_version(version: str) -> None: + assert ProofBundleService._is_core_28_1(version) is True + assert ProofBundleService._is_core_28_1("/Satoshi:128.1.0/") is False + assert ProofBundleService._is_core_28_1("/Satoshi:28.10.0/") is False + + +def test_typed_treasury_steps_reject_delay_and_signer_selection_drift() -> None: + invalid_delay = COMMUNITY_TREASURY_SCENARIO.model_dump(mode="python") + materialize = next(step for step in invalid_delay["steps"] if step["step_id"] == "materialize_policy") + materialize["emergency_delay_blocks"] = materialize["recovery_delay_blocks"] + with pytest.raises(ValidationError, match="emergency delay must be greater"): + ScenarioDefinition.model_validate(invalid_delay) + + invalid_signers = COMMUNITY_TREASURY_SCENARIO.model_dump(mode="python") + signing = next(step for step in invalid_signers["steps"] if step["step_id"] == "sign_wrong_sequence_psbt") + signing["signer_positions"] = [1, 1] + with pytest.raises(ValidationError, match="signer positions must be unique"): + ScenarioDefinition.model_validate(invalid_signers) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index cbf47c9..8ae03fe 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -23,6 +23,7 @@ def test_public_config_does_not_expose_rpc_password() -> None: assert "bitcoin_rpc_password" not in public_config assert "bitscope_local_access_token" not in public_config + assert "scenario_artifact_root" not in public_config assert "super-secret" not in str(public_config) assert "local-secret" not in str(public_config) assert public_config["local_access_token_configured"] is True diff --git a/backend/tests/test_curriculum_service.py b/backend/tests/test_curriculum_service.py new file mode 100644 index 0000000..bd46232 --- /dev/null +++ b/backend/tests/test_curriculum_service.py @@ -0,0 +1,88 @@ +from datetime import UTC, datetime +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from app.errors import BitScopeError +from app.services.challenge_service import CHALLENGES, ChallengeService +from app.services.curriculum_service import CurriculumService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import DEFAULT_SCENARIO_CATALOG +from app.services.scenario_run_store import ScenarioRunStore + + +def test_curriculum_maps_every_chapter_to_only_implemented_features() -> None: + curriculum = CurriculumService().curriculum() + + assert [entry.chapter for entry in curriculum.chapters] == list(range(3, 14)) + assert all(entry.source_url.startswith(curriculum.course_url + "/blob/master/") for entry in curriculum.chapters) + assert all(entry.relevant_pages for entry in curriculum.chapters) + assert all(entry.rpc_methods for entry in curriculum.chapters) + registered = {entry.scenario_id for entry in DEFAULT_SCENARIO_CATALOG.list()} + assert all( + scenario_id in registered + for entry in curriculum.chapters + for scenario_id in entry.relevant_scenarios + ) + chapter_five = curriculum.chapters[2] + chapter_eight = curriculum.chapters[5] + assert chapter_five.implementation_note is not None and "deferred" in chapter_five.implementation_note + assert chapter_eight.implementation_note is not None and "deferred" in chapter_eight.implementation_note + + +def test_challenge_catalog_keeps_hints_and_solutions_out_of_public_definitions(tmp_path) -> None: + service = ChallengeService( + ScenarioRunStore(str(tmp_path / "challenges.sqlite3")), + ScenarioArtifactStore(str(tmp_path / "artifacts")), + ) + + catalog = service.catalog() + public_document = catalog.model_dump_json() + + assert len(catalog.challenges) == 6 + assert "Start by distinguishing an input sequence" not in public_document + assert "completion_explanation" not in public_document + assert all(challenge.solution_locked for challenge in catalog.challenges) + first_hint = service.hint("signal-opt-in-rbf", 1) + second_hint = service.hint("signal-opt-in-rbf", 2) + assert first_hint.level == 1 and first_hint.remaining_hints == 2 + assert second_hint.level == 2 and second_hint.remaining_hints == 1 + assert first_hint.reveals_solution is False + + with pytest.raises(BitScopeError) as missing: + service.hint("signal-opt-in-rbf", 4) + assert missing.value.code == "CHALLENGE_HINT_NOT_FOUND" + + +def test_challenge_assertions_are_declared_by_their_reviewed_scenarios() -> None: + for spec in CHALLENGES: + definition = DEFAULT_SCENARIO_CATALOG.get(spec.definition.scenario_id).definition + assertion_ids = {assertion.assertion_id for assertion in definition.assertions} + assert set(spec.required_assertion_ids) <= assertion_ids + + +def test_incomplete_challenge_keeps_completion_explanation_locked() -> None: + run_id = uuid4() + run = SimpleNamespace( + run_id=run_id, + lab_session_id="challenge_session", + scenario_id="rbf-replacement", + scenario_version="1.0.0", + bitcoin_core_version=None, + updated_at=datetime(2026, 7, 22, tzinfo=UTC), + final_result=None, + cleanup_status="not_started", + assertion_results=[], + evidence=[], + ) + run_store = SimpleNamespace(get_for_session=lambda submitted_id, session_id: run) + artifact_store = SimpleNamespace(list_evidence=lambda submitted_run: []) + service = ChallengeService(run_store, artifact_store) + + result = service.verify("signal-opt-in-rbf", run_id, "challenge_session") + + assert result.completed is False + assert result.solution_unlocked is False + assert "Bitcoin Core observed replaceable" not in result.final_explanation + assert "Completion remains locked" in result.final_explanation diff --git a/backend/tests/test_evidence_service.py b/backend/tests/test_evidence_service.py new file mode 100644 index 0000000..2f791c2 --- /dev/null +++ b/backend/tests/test_evidence_service.py @@ -0,0 +1,552 @@ +import json +from io import BytesIO +from datetime import UTC, datetime +from hashlib import sha256 +from pathlib import Path +from uuid import UUID +from zipfile import ZipFile + +import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from app.config import Settings +from app.errors import BitScopeError +from app.main import create_app +from app.models.evidence import EvidenceRecord, SafeBitcoinCliCommand +from app.models.lab import LabSession +from app.models.scenario import EvidenceReference, ScenarioDefinition, ScenarioRun +from app.routes.scenarios import get_proof_bundle_service +from app.services.evidence_service import REDACTED, EvidenceService, ScenarioEvidenceRecorder +from app.services.lab_session_store import LabSessionStore +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import RegisteredScenario, ScenarioCatalog +from app.services.scenario_run_store import ScenarioRunStore + + +NOW = datetime(2026, 7, 20, 16, 0, tzinfo=UTC) +RUN_ID = UUID("2f14cd37-5b52-4b5b-a11c-3e5971e98eb7") + + +def scenario_run() -> ScenarioRun: + return ScenarioRun( + run_id=RUN_ID, + scenario_id="transaction-lifecycle", + scenario_version="1.0.0", + lab_session_id="session_alpha", + runtime_chain="regtest", + bitcoin_core_version="/Satoshi:28.1.0/", + defined_step_ids=[ + "verify_chain", + "prepare_wallet", + "inspect_transaction", + "verify_transaction", + "export_proof", + "cleanup", + ], + created_at=NOW, + updated_at=NOW, + ) + + +def scenario_definition() -> ScenarioDefinition: + return ScenarioDefinition.model_validate( + { + "scenario_id": "transaction-lifecycle", + "version": "1.0.0", + "name": "Transaction lifecycle", + "summary": "Inspect a generated transaction value, preserve reproducible evidence, and clean up.", + "difficulty": "beginner", + "related_lbcli_chapters": [3, 4], + "concepts": ["Transactions", "Wallets", "Regtest"], + "required_capabilities": ["read_only", "regtest_mutation"], + "estimated_run_steps": 6, + "steps": [ + { + "step_id": "verify_chain", + "type": "verify_runtime_chain", + "phase": "setup", + "title": "Verify chain", + "description": "Verify that Bitcoin Core reports regtest.", + "output_context_ref": "node.context", + }, + { + "step_id": "prepare_wallet", + "type": "prepare_isolated_wallet", + "phase": "setup", + "title": "Prepare wallet", + "description": "Prepare a session-owned wallet.", + "depends_on": ["verify_chain"], + "wallet_role": "operator", + "output_wallet_ref": "wallet.operator", + }, + { + "step_id": "inspect_transaction", + "type": "generate_address", + "phase": "execution", + "title": "Generate public value", + "description": "Generate a safe run-specific public value for inspection.", + "depends_on": ["prepare_wallet"], + "wallet_ref": "wallet.operator", + "output_address_ref": "address.recipient", + }, + { + "step_id": "verify_transaction", + "type": "evaluate_assertions", + "phase": "verification", + "title": "Verify output", + "description": "Verify the generated public value.", + "depends_on": ["inspect_transaction"], + "assertion_ids": ["output_ready"], + }, + { + "step_id": "export_proof", + "type": "export_evidence", + "phase": "export", + "title": "Export evidence", + "description": "Export the redacted proof bundle.", + "depends_on": ["verify_transaction"], + "output_bundle_ref": "proof.bundle", + }, + { + "step_id": "cleanup", + "type": "cleanup_lab", + "phase": "cleanup", + "title": "Clean up", + "description": "Unload session-owned wallets.", + "depends_on": ["export_proof"], + }, + ], + "assertions": [ + { + "assertion_id": "output_ready", + "kind": "rpc_succeeded", + "after_step_id": "inspect_transaction", + "subject_ref": "address.recipient", + "description": "Bitcoin Core generated the public value.", + } + ], + } + ) + + +def rpc_record(**changes: object) -> EvidenceRecord: + payload: dict[str, object] = { + "evidence_id": "rpc.transaction", + "kind": "rpc_result", + "label": "Decoded transaction", + "scenario_id": "transaction-lifecycle", + "scenario_version": "1.0.0", + "run_id": RUN_ID, + "lab_session_id": "session_alpha", + "step_id": "inspect_transaction", + "captured_at": NOW, + "core_output": { + "rpc_method": "decoderawtransaction", + "safe_parameters": ["020000000001"], + "result": {"txid": "ab" * 32, "locktime": 0}, + "run_specific_paths": ["$.result.txid"], + }, + "bitscope_interpretation": { + "summary": "Bitcoin Core decoded the candidate transaction.", + "facts": [ + {"name": "transaction.locktime", "value": 0}, + {"name": "transaction.txid", "value": "ab" * 32, "run_specific": True}, + ], + }, + "commands": [ + { + "arguments": ["-regtest", "decoderawtransaction", "020000000001"], + "description": "Decode the candidate transaction on regtest.", + } + ], + } + payload.update(changes) + return EvidenceRecord.model_validate(payload) + + +def evidence_service() -> EvidenceService: + return EvidenceService.from_settings( + Settings( + bitcoin_rpc_user="scenario-rpc-user", + bitcoin_rpc_password="scenario-rpc-password", + bitscope_local_access_token="scenario-local-token", + ) + ) + + +def test_evidence_record_is_typed_and_separates_core_output() -> None: + record = rpc_record() + + assert record.core_output is not None + assert record.core_output.rpc_method == "decoderawtransaction" + assert record.bitscope_interpretation.facts[1].run_specific is True + + with pytest.raises(ValidationError, match="distinct Bitcoin Core output"): + rpc_record(core_output=None) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + EvidenceRecord.model_validate({**record.model_dump(mode="python"), "arbitrary_log": {"value": 1}}) + with pytest.raises(ValidationError, match="must include a timezone"): + rpc_record(captured_at=datetime(2026, 7, 20, 16, 0)) + + +def test_safe_commands_reject_credentials_and_control_characters() -> None: + for unsafe_argument in ( + "-rpcpassword=secret", + "-rpcuser=alice", + "--header=X-BitScope-Token: secret", + "getblockcount\nstop", + ): + with pytest.raises(ValidationError): + SafeBitcoinCliCommand( + arguments=["-regtest", unsafe_argument], + description="Unsafe command.", + ) + + +def test_capture_recursively_redacts_secrets_and_preserves_protocol_data() -> None: + extended_private_key = "zprv" + "1" * 40 + wif_private_key = "K" + "1" * 51 + safe_transaction_hex = "020000000001" + record = rpc_record( + core_output={ + "rpc_method": "decoderawtransaction", + "safe_parameters": { + "hex": safe_transaction_hex, + "rpc_password": "nested-secret", + "nested": { + "note": "token=scenario-local-token", + "authorization": "Basic YWxpY2U6c2VjcmV0", + "environment": {"BITCOIN_RPC_PASSWORD": "environment-secret"}, + }, + }, + "result": { + "txid": "ab" * 32, + "descriptor": f"wpkh({extended_private_key}/0/*)", + "private_material": wif_private_key, + }, + "run_specific_paths": ["$.result.txid"], + }, + bitscope_interpretation={ + "summary": "Observed with scenario-rpc-user using scenario-rpc-password.", + "facts": [{"name": "transaction.hex", "value": safe_transaction_hex}], + }, + ) + + captured = evidence_service().capture(scenario_run(), record) + serialized = captured.canonical_json + + for secret in ( + "scenario-rpc-user", + "scenario-rpc-password", + "scenario-local-token", + "nested-secret", + "environment-secret", + extended_private_key, + wif_private_key, + "YWxpY2U6c2VjcmV0", + ): + assert secret not in serialized + assert REDACTED in serialized + assert safe_transaction_hex in serialized + assert captured.record.core_output is not None + assert captured.record.core_output.safe_parameters["rpc_password"] == REDACTED + assert captured.run.revision == 1 + assert captured.run.evidence == [captured.reference] + assert captured.reference.content_sha256 == sha256(serialized.encode("utf-8")).hexdigest() + + +def test_capture_is_canonical_and_deterministic() -> None: + service = evidence_service() + first = service.capture(scenario_run(), rpc_record()) + second = service.capture(scenario_run(), rpc_record()) + + assert first.canonical_json == second.canonical_json + assert first.reference == second.reference + parsed = json.loads(first.canonical_json) + assert list(parsed) == sorted(parsed) + assert first.canonical_json.endswith("\n") + + +def test_even_short_configured_secret_values_are_not_leaked() -> None: + service = EvidenceService.from_settings( + Settings( + bitcoin_rpc_user="u", + bitcoin_rpc_password="pw", + bitscope_local_access_token="tok", + ) + ) + record = rpc_record( + bitscope_interpretation={ + "summary": "Credentials were u, pw, and tok.", + "facts": [], + } + ) + + captured = service.capture(scenario_run(), record) + + assert "Credentials were [REDACTED], [REDACTED], and [REDACTED]." in captured.canonical_json + + +def test_secret_value_collisions_do_not_rewrite_evidence_identity() -> None: + service = EvidenceService.from_settings( + Settings( + bitcoin_rpc_user="transaction-lifecycle", + bitcoin_rpc_password="rpc_result", + bitscope_local_access_token="session_alpha", + ) + ) + + captured = service.capture(scenario_run(), rpc_record()) + + assert captured.record.scenario_id == "transaction-lifecycle" + assert captured.record.kind == "rpc_result" + assert captured.record.lab_session_id == "session_alpha" + + +def test_capture_rejects_cross_run_identity_and_unknown_steps() -> None: + service = evidence_service() + + with pytest.raises(BitScopeError) as mismatch: + service.capture(scenario_run(), rpc_record(lab_session_id="session_other")) + assert mismatch.value.code == "EVIDENCE_RUN_IDENTITY_MISMATCH" + assert mismatch.value.details["mismatched_fields"] == ["lab_session_id"] + + with pytest.raises(BitScopeError) as unknown_step: + service.capture(scenario_run(), rpc_record(step_id="missing_step")) + assert unknown_step.value.code == "EVIDENCE_STEP_NOT_FOUND" + + +def test_capture_enforces_bounded_content() -> None: + service = EvidenceService.from_settings(Settings(), max_content_bytes=1_024) + oversized = rpc_record( + bitscope_interpretation={ + "summary": "x" * 2_000, + "facts": [], + } + ) + + with pytest.raises(BitScopeError) as too_large: + service.capture(scenario_run(), oversized) + assert too_large.value.code == "EVIDENCE_CONTENT_TOO_LARGE" + + +def test_captured_reference_persists_with_the_run(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + LabSessionStore(str(database)).save( + LabSession( + session_id="session_alpha", + wallet_name="bitscope-session-alpha", + owned_wallets=["bitscope-session-alpha"], + wallet_generation=0, + runtime_chain="regtest", + starting_height=200, + status="active", + created_at=NOW, + updated_at=NOW, + ) + ) + store = ScenarioRunStore(str(database)) + run = scenario_run() + store.create(run) + + captured = evidence_service().capture(run, rpc_record()) + store.save(captured.run, expected_revision=0) + + restored = store.get(run.run_id) + assert restored is not None + assert restored.evidence == [captured.reference] + assert restored.revision == 1 + + +def test_duplicate_evidence_identifiers_are_rejected() -> None: + captured = evidence_service().capture(scenario_run(), rpc_record()) + + with pytest.raises(ValueError, match="already been recorded"): + captured.run.record_evidence_reference(captured.reference, now=NOW) + + +def test_artifact_store_writes_idempotently_and_detects_conflicts_and_tampering(tmp_path: Path) -> None: + artifacts = ScenarioArtifactStore(str(tmp_path / "artifacts")) + captured = evidence_service().capture(scenario_run(), rpc_record()) + + artifacts.write_evidence(captured) + artifacts.write_evidence(captured) + target = tmp_path / "artifacts" / str(RUN_ID) / "evidence" / "rpc.transaction.json" + assert target.read_text(encoding="utf-8") == captured.canonical_json + assert artifacts.read_evidence(captured.run, captured.reference) == captured.record + + conflicting = evidence_service().capture( + scenario_run(), + rpc_record(label="Different content for the same identifier"), + ) + with pytest.raises(BitScopeError) as conflict: + artifacts.write_evidence(conflicting) + assert conflict.value.code == "EVIDENCE_ARTIFACT_CONFLICT" + + target.write_text(captured.canonical_json.replace("Decoded transaction", "Tampered transaction"), encoding="utf-8") + with pytest.raises(BitScopeError) as tampered: + artifacts.read_evidence(captured.run, captured.reference) + assert tampered.value.code == "EVIDENCE_ARTIFACT_HASH_MISMATCH" + + +def test_artifact_store_rejects_non_server_generated_paths(tmp_path: Path) -> None: + captured = evidence_service().capture(scenario_run(), rpc_record()) + unsafe_reference = EvidenceReference( + evidence_id=captured.reference.evidence_id, + kind=captured.reference.kind, + label=captured.reference.label, + relative_path="evidence/different.json", + content_sha256=captured.reference.content_sha256, + ) + unsafe_capture = type(captured)( + run=captured.run, + reference=unsafe_reference, + record=captured.record, + canonical_json=captured.canonical_json, + ) + + with pytest.raises(BitScopeError) as unsafe: + ScenarioArtifactStore(str(tmp_path / "artifacts")).write_evidence(unsafe_capture) + assert unsafe.value.code == "EVIDENCE_ARTIFACT_PATH_INVALID" + + +def build_persisted_evidence(tmp_path: Path) -> tuple[ScenarioRunStore, ScenarioArtifactStore, ScenarioRun]: + database = tmp_path / "proof.sqlite3" + LabSessionStore(str(database)).save( + LabSession( + session_id="session_alpha", + wallet_name="bitscope-session-alpha", + owned_wallets=["bitscope-session-alpha"], + wallet_generation=0, + runtime_chain="regtest", + starting_height=200, + status="active", + created_at=NOW, + updated_at=NOW, + ) + ) + run_store = ScenarioRunStore(str(database)) + run_store.create(scenario_run()) + artifact_store = ScenarioArtifactStore(str(tmp_path / "artifacts")) + recorder = ScenarioEvidenceRecorder(evidence_service(), artifact_store, run_store) + captured = recorder.record( + RUN_ID, + "session_alpha", + 0, + rpc_record( + bitscope_interpretation={ + "summary": "Decoded without retaining scenario-rpc-password.", + "facts": [], + } + ), + ) + return run_store, artifact_store, captured.run + + +def proof_service(tmp_path: Path) -> tuple[ProofBundleService, ScenarioRun]: + run_store, artifact_store, run = build_persisted_evidence(tmp_path) + catalog = ScenarioCatalog((RegisteredScenario(scenario_definition()),)) + return ProofBundleService(run_store, artifact_store, catalog), run + + +def test_recorder_persists_redacted_artifact_and_revisioned_reference(tmp_path: Path) -> None: + run_store, artifact_store, updated = build_persisted_evidence(tmp_path) + + restored = run_store.get(RUN_ID) + assert restored == updated + assert restored is not None + assert restored.revision == 1 + assert restored.evidence[0].relative_path == "evidence/rpc.transaction.json" + assert artifact_store.list_evidence(restored)[0].evidence_id == "rpc.transaction" + + recorder = ScenarioEvidenceRecorder(evidence_service(), artifact_store, run_store) + with pytest.raises(BitScopeError) as stale: + recorder.record(RUN_ID, "session_alpha", 0, rpc_record()) + assert stale.value.code == "SCENARIO_RUN_REVISION_CONFLICT" + with pytest.raises(BitScopeError) as hidden: + recorder.record(RUN_ID, "session_other", 1, rpc_record()) + assert hidden.value.code == "SCENARIO_RUN_NOT_FOUND" + + +def test_proof_bundle_is_deterministic_and_manifest_hashes_every_payload(tmp_path: Path) -> None: + service, run = proof_service(tmp_path) + + first = service.bundle(run.run_id, run.lab_session_id) + second = service.bundle(run.run_id, run.lab_session_id) + + assert first.zip_bytes == second.zip_bytes + assert "scenario-rpc-password" not in first.report_markdown + assert first.report_markdown == second.report_markdown + assert "## Bitcoin Core output" in first.report_markdown + assert "## BitScope interpretation" in first.report_markdown + assert "## Expected failures" in first.report_markdown + assert "## Unexpected failures" in first.report_markdown + assert set(entry.path for entry in first.manifest.files) == set(first.files) - {"manifest.json"} + for entry in first.manifest.files: + content = first.files[entry.path] + assert entry.content_bytes == len(content) + assert entry.content_sha256 == sha256(content).hexdigest() + + with ZipFile(BytesIO(first.zip_bytes)) as archive: + names = archive.namelist() + assert names == sorted(names) + assert names == [f"bitscope-proof/{path}" for path in sorted(first.files)] + assert all(info.date_time == (1980, 1, 1, 0, 0, 0) for info in archive.infolist()) + assert all(".." not in Path(name).parts for name in names) + assert all(b"scenario-rpc-password" not in archive.read(name) for name in names) + manifest = json.loads(archive.read("bitscope-proof/manifest.json")) + assert manifest["hash_scope"] == "all_bundle_files_except_manifest" + assert manifest["generated_from_revision"] == 1 + commands = archive.read("bitscope-proof/commands.sh").decode("utf-8") + assert "bitcoin-cli -regtest decoderawtransaction" in commands + assert "rpcpassword" not in commands.casefold() + + +def test_proof_reads_are_session_scoped(tmp_path: Path) -> None: + service, run = proof_service(tmp_path) + + evidence = service.evidence(run.run_id, "session_alpha") + assert evidence.revision == 1 + assert [record.evidence_id for record in evidence.evidence] == ["rpc.transaction"] + with pytest.raises(BitScopeError) as hidden: + service.bundle(run.run_id, "session_other") + assert hidden.value.code == "SCENARIO_RUN_NOT_FOUND" + + +def test_evidence_report_and_bundle_routes_stream_owned_redacted_artifacts(tmp_path: Path) -> None: + service, run = proof_service(tmp_path) + settings = Settings(app_environment="test") + app = create_app(settings) + app.dependency_overrides[get_proof_bundle_service] = lambda: service + client = TestClient(app) + query = {"lab_session_id": "session_alpha"} + + evidence = client.get(f"/api/scenario-runs/{run.run_id}/evidence", params=query) + assert evidence.status_code == 200 + assert evidence.json()["evidence"][0]["evidence_id"] == "rpc.transaction" + lifecycle = client.get(f"/api/scenario-runs/{run.run_id}/lifecycle", params=query) + assert lifecycle.status_code == 200 + assert lifecycle.json()["run_id"] == str(run.run_id) + assert lifecycle.json()["events"] == [] + report = client.get(f"/api/scenario-runs/{run.run_id}/report", params=query) + assert report.status_code == 200 + assert report.headers["content-type"].startswith("text/markdown") + bundle = client.get(f"/api/scenario-runs/{run.run_id}/bundle", params=query) + assert bundle.status_code == 200 + assert bundle.headers["content-type"] == "application/zip" + assert f"bitscope-proof-{run.run_id}.zip" in bundle.headers["content-disposition"] + with ZipFile(BytesIO(bundle.content)) as archive: + assert "bitscope-proof/manifest.json" in archive.namelist() + + hidden = client.get( + f"/api/scenario-runs/{run.run_id}/evidence", + params={"lab_session_id": "session_other"}, + ) + assert hidden.status_code == 404 + hidden_lifecycle = client.get( + f"/api/scenario-runs/{run.run_id}/lifecycle", + params={"lab_session_id": "session_other"}, + ) + assert hidden_lifecycle.status_code == 404 diff --git a/backend/tests/test_learning_routes.py b/backend/tests/test_learning_routes.py index b37c2d5..db8efbb 100644 --- a/backend/tests/test_learning_routes.py +++ b/backend/tests/test_learning_routes.py @@ -1,6 +1,10 @@ from fastapi.testclient import TestClient from app.main import create_app +from app.routes.learning import get_challenge_service +from app.services.challenge_service import ChallengeService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_run_store import ScenarioRunStore def test_learn_concepts_endpoint() -> None: @@ -22,3 +26,32 @@ def test_learn_rpc_methods_endpoint() -> None: assert response.status_code == 200 body = response.json() assert any(method["name"] == "getblockchaininfo" for method in body["methods"]) + + +def test_curriculum_endpoint_covers_chapters_three_through_thirteen() -> None: + client = TestClient(create_app()) + + response = client.get("/api/learn/curriculum") + + assert response.status_code == 200 + assert [entry["chapter"] for entry in response.json()["chapters"]] == list(range(3, 14)) + + +def test_challenge_catalog_and_progressive_hint_routes(tmp_path) -> None: + app = create_app() + service = ChallengeService( + ScenarioRunStore(str(tmp_path / "challenges.sqlite3")), + ScenarioArtifactStore(str(tmp_path / "artifacts")), + ) + app.dependency_overrides[get_challenge_service] = lambda: service + client = TestClient(app) + + catalog = client.get("/api/learn/challenges") + hint = client.get("/api/learn/challenges/signal-opt-in-rbf/hints/1") + + assert catalog.status_code == 200 + assert len(catalog.json()["challenges"]) == 6 + assert "hints" not in catalog.json()["challenges"][0] + assert hint.status_code == 200 + assert hint.json()["level"] == 1 + assert hint.json()["reveals_solution"] is False diff --git a/backend/tests/test_lifecycle_recorder.py b/backend/tests/test_lifecycle_recorder.py new file mode 100644 index 0000000..3406201 --- /dev/null +++ b/backend/tests/test_lifecycle_recorder.py @@ -0,0 +1,100 @@ +from datetime import UTC, datetime + +from app.models.evidence import EvidenceRecord +from app.models.lifecycle import LifecycleEventType, MempoolRelationshipType +from app.models.scenario import ScenarioRun +from app.services.evidence_service import EvidenceRedactor +from app.services.lifecycle_recorder import LifecycleRecorder +from app.services.rbf_scenario import RBF_REPLACEMENT_SCENARIO + + +NOW = datetime(2026, 7, 22, 10, 0, tzinfo=UTC) +ORIGINAL_TXID = "a" * 64 +REPLACEMENT_TXID = "b" * 64 + + +def evidence(run: ScenarioRun, evidence_id: str, result: object) -> EvidenceRecord: + return EvidenceRecord( + evidence_id=evidence_id, + kind="transaction", + label="Recorded Core result", + scenario_id=run.scenario_id, + scenario_version=run.scenario_version, + run_id=run.run_id, + lab_session_id=run.lab_session_id, + step_id="replace_transaction", + captured_at=NOW, + core_output={"safe_parameters": [], "result": result}, + bitscope_interpretation={"summary": "Recorded by the test.", "facts": [], "limitations": []}, + ) + + +def test_recorder_emits_only_evidence_backed_rbf_events_and_relationships() -> None: + run = ScenarioRun.create(RBF_REPLACEMENT_SCENARIO, "lifecycle_test", "/Satoshi:28.1.0/", NOW) + recorder = LifecycleRecorder(EvidenceRedactor(("do-not-export",))) + replacement = evidence( + run, + "rbf.replacement", + { + "original_txid": ORIGINAL_TXID, + "replacement_txid": REPLACEMENT_TXID, + "requested_fee_rate_sat_vb": 12.5, + "bumpfee": {"replacement_fee_btc": 0.00002}, + "password": "do-not-export", + }, + ) + + events = recorder.record(run, [replacement]) + + assert [event.event_type for event in events] == [ + LifecycleEventType.TRANSACTION_REPLACED, + LifecycleEventType.TRANSACTION_ENTERED_MEMPOOL, + ] + assert all(event.evidence_id == "rbf.replacement" for event in events) + replaced = events[0] + assert replaced.transaction_id == REPLACEMENT_TXID + assert replaced.relationship is not None + assert replaced.relationship.relationship_type == MempoolRelationshipType.REPLACES + assert replaced.relationship.related_txid == ORIGINAL_TXID + assert replaced.raw_safe_core_result["password"] == "[REDACTED]" + + +def test_persisted_timeline_orders_recorded_events_and_cleanup() -> None: + run = ScenarioRun.create(RBF_REPLACEMENT_SCENARIO, "lifecycle_test", "/Satoshi:28.1.0/", NOW) + recorder = LifecycleRecorder() + events = recorder.record( + run, + [ + evidence( + run, + "rbf.replacement", + {"original_txid": ORIGINAL_TXID, "replacement_txid": REPLACEMENT_TXID}, + ) + ], + ) + timeline_record = recorder.evidence(run, events, NOW) + cleanup_record = recorder.cleanup_evidence(run, NOW, len(events) + 1) + + timeline = recorder.timeline(run, [cleanup_record, timeline_record]) + + assert [event.ordinal for event in timeline.events] == [1, 2, 3] + assert timeline.events[-1].event_type == LifecycleEventType.SCENARIO_CLEANED_UP + assert timeline.events[-1].evidence_id == "lifecycle.cleanup" + + +def test_cpfp_child_is_explicitly_linked_to_its_recorded_parent() -> None: + event = LifecycleRecorder().child_transaction_event( + ordinal=4, + timestamp=NOW, + step_id="create_child", + track_id="cpfp.child", + child_txid=REPLACEMENT_TXID, + parent_txid=ORIGINAL_TXID, + evidence_id="cpfp.child-created", + raw_safe_core_result={"child_txid": REPLACEMENT_TXID}, + ) + + assert event.event_type == LifecycleEventType.CHILD_TRANSACTION_CREATED + assert event.relationship is not None + assert event.relationship.relationship_type == MempoolRelationshipType.CHILD_OF + assert event.relationship.related_txid == ORIGINAL_TXID diff --git a/backend/tests/test_multisig_psbt_scenario.py b/backend/tests/test_multisig_psbt_scenario.py new file mode 100644 index 0000000..7859f28 --- /dev/null +++ b/backend/tests/test_multisig_psbt_scenario.py @@ -0,0 +1,272 @@ +import json +from datetime import UTC, datetime +from pathlib import Path + +from app.config import Settings +from app.models.lab import LabSession +from app.models.scenario import CleanupStatus, ScenarioRunState +from app.services.evidence_service import EvidenceService +from app.services.lab_session_store import LabSessionStore +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import DEFAULT_SCENARIO_CATALOG +from app.services.scenario_run_store import ScenarioRunStore +from app.services.scenario_service import ScenarioService + + +NOW = datetime(2026, 7, 21, 0, 0, tzinfo=UTC) +SESSION_ID = "multisig_session" +FUNDING_WALLET = f"bitscope-session-{SESSION_ID}" +SIGNER_WALLETS = [f"{FUNDING_WALLET}-r{index}" for index in range(1, 4)] +FUNDING_TXID = "a" * 64 +SPEND_TXID = "b" * 64 +UNSIGNED_PSBT = "unsigned-psbt" +PARTIAL_PSBT = "partial-psbt" +THRESHOLD_PSBT = "threshold-psbt" + + +class MultisigPsbtRpcClient: + def __init__(self, first_signer_complete: bool = False) -> None: + self.settings = Settings( + bitcoin_network="regtest", + bitcoin_rpc_user="multisig-user", + bitcoin_rpc_password="multisig-secret", + bitscope_local_access_token="multisig-token", + ) + self.calls: list[tuple[str, object, str | None]] = [] + self.loaded_wallets = [FUNDING_WALLET] + self.first_signer_complete = first_signer_complete + self.spend_broadcast = False + self.spend_confirmed = False + self.block_index = 0 + + def call(self, method: str, params: object = None, wallet_name: str | None = None) -> object: + self.calls.append((method, params, wallet_name)) + if method == "getblockchaininfo": + return {"chain": "regtest"} + if method == "getnetworkinfo": + return { + "version": 280100, + "subversion": "/Satoshi:28.1.0/", + "warnings": "canary multisig-secret must be redacted", + } + if method == "getblockcount": + return 300 + if method == "listwallets": + return list(self.loaded_wallets) + if method == "createwallet": + name = str(params[0]) + self.loaded_wallets.append(name) + return {"name": name, "warning": ""} + if method == "unloadwallet": + if wallet_name in self.loaded_wallets: + self.loaded_wallets.remove(wallet_name) + return None + if method == "getnewaddress": + label = params[0] if isinstance(params, list) else "" + if label == "bitscope-multisig-mining": + return "bcrt1qmultisigmining" + if label == "bitscope-multisig-destination": + return "bcrt1qmultisigdestination" + signer_index = SIGNER_WALLETS.index(wallet_name) + 1 + return f"bcrt1qmultisigsigner{signer_index}" + if method == "getaddressinfo": + signer_index = SIGNER_WALLETS.index(wallet_name) + 1 + marker = ("aa", "bb", "cc")[signer_index - 1] + return {"address": params[0], "pubkey": "02" + marker * 32} + if method == "createmultisig": + return { + "address": "bcrt1qmultisigpolicy", + "redeemScript": "5221aa21bb21cc53ae", + "descriptor": "wsh(multi(2,...))#test", + } + if method == "addmultisigaddress": + return { + "address": "bcrt1qmultisigpolicy", + "redeemScript": "5221aa21bb21cc53ae", + "descriptor": "wsh(multi(2,...))#test", + "warnings": [], + } + if method == "importaddress": + return None + if method == "validateaddress": + return {"isvalid": True, "iswitness": True} + if method == "getbalances": + return {"mine": {"trusted": 50.0, "untrusted_pending": 0.0, "immature": 0.0}} + if method == "sendtoaddress": + return FUNDING_TXID + if method == "generatetoaddress": + count = int(params[0]) + hashes = [] + for _ in range(count): + self.block_index += 1 + hashes.append(f"{self.block_index:064x}") + if count == 1 and self.spend_broadcast: + self.spend_confirmed = True + return hashes + if method == "listunspent": + return [{"txid": FUNDING_TXID, "vout": 0, "amount": 0.5}] + if method == "walletcreatefundedpsbt": + return {"psbt": UNSIGNED_PSBT, "fee": 0.00000282, "changepos": 1} + if method == "walletprocesspsbt": + if wallet_name == SIGNER_WALLETS[0]: + return {"psbt": PARTIAL_PSBT, "complete": self.first_signer_complete} + if wallet_name == SIGNER_WALLETS[1]: + return {"psbt": THRESHOLD_PSBT, "complete": False} + if method == "decodepsbt": + psbt = params[0] + signature_count = 1 if psbt == PARTIAL_PSBT else 2 if psbt == THRESHOLD_PSBT else 0 + return { + "tx": {"txid": "c" * 64}, + "inputs": [ + { + "partial_signatures": { + f"pubkey-{index}": f"signature-{index}" + for index in range(signature_count) + } + } + ], + "outputs": [{}, {}], + "fee": 0.00000282, + } + if method == "finalizepsbt": + psbt = params[0] + if psbt == PARTIAL_PSBT: + return {"psbt": PARTIAL_PSBT, "complete": False} + return {"hex": "02000000000100", "complete": True} + if method == "testmempoolaccept": + return [{"txid": SPEND_TXID, "allowed": True, "vsize": 141, "fees": {"base": 0.00000282}}] + if method == "sendrawtransaction": + self.spend_broadcast = True + return SPEND_TXID + if method == "getmempoolentry": + return {"vsize": 141, "fees": {"base": 0.00000282}} + if method == "gettransaction": + return { + "txid": SPEND_TXID, + "hex": "02000000000100", + "confirmations": 1 if self.spend_confirmed else 0, + "blockhash": "d" * 64 if self.spend_confirmed else None, + } + if method == "decoderawtransaction": + return {"txid": SPEND_TXID, "vin": [{}], "vout": [{}, {}]} + raise AssertionError(f"Unexpected RPC method: {method}") + + +def build_service( + tmp_path: Path, + rpc: MultisigPsbtRpcClient, +) -> tuple[ScenarioService, ScenarioRunStore, LabSessionStore]: + database = tmp_path / "labs.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_store.save( + LabSession( + session_id=SESSION_ID, + wallet_name=FUNDING_WALLET, + owned_wallets=[FUNDING_WALLET], + wallet_generation=0, + runtime_chain="regtest", + starting_height=300, + status="active", + created_at=NOW, + updated_at=NOW, + ) + ) + run_store = ScenarioRunStore(str(database)) + artifacts = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + service = ScenarioService( + rpc, # type: ignore[arg-type] + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(rpc.settings), + artifacts, + lab_store, + ) + return service, run_store, lab_store + + +def test_multisig_psbt_scenario_proves_threshold_exports_and_cleans_up(tmp_path: Path) -> None: + rpc = MultisigPsbtRpcClient() + service, run_store, lab_store = build_service(tmp_path, rpc) + created = service.create_run("multisig-psbt", SESSION_ID) + ready = service.advance(created.run_id, SESSION_ID, expected_revision=0) + + verified = service.advance(ready.run_id, SESSION_ID, expected_revision=1) + + assert verified.current_state == ScenarioRunState.VERIFIED + assert verified.cleanup_status == CleanupStatus.COMPLETED + assert verified.revision == 5 + assert len(verified.completed_steps) == 22 + assert len(verified.assertion_results) == 7 + assert all(result.status.value == "passed" for result in verified.assertion_results) + assert [failure.code for failure in verified.expected_failures] == ["insufficient-signatures"] + assert verified.expected_failures[0].category.value == "psbt_incomplete" + assert verified.unexpected_failures == [] + assert run_store.get(verified.run_id) == verified + + session = lab_store.get(SESSION_ID) + assert session is not None and session.status == "cleaned" + assert session.owned_wallets == [FUNDING_WALLET, *SIGNER_WALLETS] + assert session.transaction_ids == [FUNDING_TXID, SPEND_TXID] + assert len(session.block_hashes) == 103 + unloaded = [call[2] for call in rpc.calls if call[0] == "unloadwallet"] + assert unloaded == [FUNDING_WALLET, *SIGNER_WALLETS] + + bundle_service = ProofBundleService(run_store, service.artifact_store, DEFAULT_SCENARIO_CATALOG) + first = bundle_service.bundle(verified.run_id, SESSION_ID) + second = bundle_service.bundle(verified.run_id, SESSION_ID) + assert first.zip_bytes == second.zip_bytes + assert first.manifest.final_result is not None + assert first.manifest.final_result.value == "verified" + assert "evidence/multisig.setup.json" in first.files + assert "evidence/multisig.policy-funding.json" in first.files + assert "evidence/psbt.unsigned.json" in first.files + assert "evidence/psbt.partial.json" in first.files + assert "evidence/psbt.complete.json" in first.files + assert "evidence/multisig.confirmed.json" in first.files + lifecycle = json.loads(first.files["lifecycle.json"]) + assert [event["event_type"] for event in lifecycle["events"]].count("psbt_partially_signed") == 1 + assert [event["event_type"] for event in lifecycle["events"]].count("psbt_completed") == 1 + assert lifecycle["events"][-1]["event_type"] == "scenario_cleaned_up" + attacks = json.loads(first.files["evidence/attacks.summary.json"])["core_output"]["result"] + assert [item["attack_type"] for item in attacks] == [ + "signature_insufficiency", + "psbt_incompleteness", + ] + assert b"insufficient-signatures" in first.files["run.json"] + assert all(b"multisig-secret" not in content for content in first.files.values()) + + +def test_multisig_psbt_scenario_fails_closed_if_first_signer_claims_completion(tmp_path: Path) -> None: + rpc = MultisigPsbtRpcClient(first_signer_complete=True) + service, _, lab_store = build_service(tmp_path, rpc) + created = service.create_run("multisig-psbt", SESSION_ID) + ready = service.advance(created.run_id, SESSION_ID, expected_revision=0) + + failed = service.advance(ready.run_id, SESSION_ID, expected_revision=1) + + assert failed.current_state == ScenarioRunState.FAILED + assert failed.cleanup_status == CleanupStatus.COMPLETED + assert failed.failed_steps == ["sign_with_one"] + assert failed.unexpected_failures[0].code == "SCENARIO_MULTISIG_PARTIAL_STATE_MISMATCH" + assert failed.unexpected_failures[0].attack_id == "multisig-psbt.signature-insufficiency" + assert failed.unexpected_failures[0].raw_safe_details["complete"] is True + session = lab_store.get(SESSION_ID) + assert session is not None and session.status == "cleaned" + + +def test_default_catalog_exposes_reviewed_multisig_psbt_scenario() -> None: + entry = DEFAULT_SCENARIO_CATALOG.get("multisig-psbt") + + assert entry.available is True + assert len(entry.definition.steps) == 22 + assert entry.definition.steps[-1].type == "cleanup_lab" + assert {assertion.assertion_id for assertion in entry.definition.assertions} == { + "insufficient_signatures", + "partial_psbt_incomplete", + "threshold_not_met", + "threshold_met", + "psbt_complete", + "spend_accepted", + "spend_confirmed", + } diff --git a/backend/tests/test_multisig_service.py b/backend/tests/test_multisig_service.py index 0b06a89..cd3898e 100644 --- a/backend/tests/test_multisig_service.py +++ b/backend/tests/test_multisig_service.py @@ -22,11 +22,17 @@ def call(self, method: str, params: list[object] | None = None, wallet_name: str return f"bcrt1q{len(self.calls):04d}" if method == "getaddressinfo": address = str(params[0]) if params else "unknown" - return {"address": address, "pubkey": "02" + "aa" * 32} + marker = {"signer-1": "aa", "signer-2": "bb", "signer-3": "cc"}.get( + wallet_name or "", + "aa", + ) + return {"address": address, "pubkey": "02" + marker * 32} if method == "createmultisig": return {"address": "bcrt1qmulti", "redeemScript": "5221aa52ae", "descriptor": "wsh(multi(...))#test"} if method == "addmultisigaddress": return {"address": "bcrt1qmulti", "redeemScript": "5221aa52ae", "descriptor": "wsh(multi(...))#test", "warnings": []} + if method == "importaddress": + return None if method == "validateaddress": return {"isvalid": True} if method == "getbalances": @@ -69,6 +75,54 @@ def test_fund_multisig_sends_and_mines_confirmation() -> None: assert ("sendtoaddress", ["bcrt1qmulti", 0.5], "demo") in rpc.calls +def test_create_multisig_from_distinct_signer_wallets_registers_same_policy() -> None: + rpc = FakeRpcClient() + + result = MultisigService(rpc).create_from_signer_wallets( + ["signer-1", "signer-2", "signer-3"], + 2, + "bech32", + ) # type: ignore[arg-type] + + assert result["multisig_address"] == "bcrt1qmulti" + assert result["required_signatures"] == 2 + assert len(result["pubkeys"]) == 3 + registrations = [ + call + for call in rpc.calls + if call[0] == "addmultisigaddress" + ] + assert [call[2] for call in registrations] == ["signer-1", "signer-2", "signer-3"] + assert all(call[1] == [2, result["pubkeys"], "bitscope-multisig", "bech32"] for call in registrations) + watch_imports = [call for call in rpc.calls if call[0] == "importaddress"] + assert [call[2] for call in watch_imports] == ["signer-1", "signer-2", "signer-3"] + assert all(call[1] == ["bcrt1qmulti", "bitscope-multisig-watch", False] for call in watch_imports) + + +def test_create_multisig_spend_psbt_does_not_sign() -> None: + rpc = FakeRpcClient() + + result = MultisigService(rpc).create_spend_psbt( + "signer-1", + "bcrt1qmulti", + "bcrt1qdest", + 0.25, + 2.0, + ) # type: ignore[arg-type] + + assert result["psbt"] == "cHNidP8BAHE=" + assert result["input_count"] == 1 + assert ("listunspent", [1, 9_999_999, ["bcrt1qmulti"]], "signer-1") in rpc.calls + funded_call = next(call for call in rpc.calls if call[0] == "walletcreatefundedpsbt") + assert funded_call[2] == "signer-1" + assert funded_call[1][3] == { + "includeWatching": True, + "changeAddress": "bcrt1qmulti", + "fee_rate": 2.0, + } + assert all(call[0] != "walletprocesspsbt" for call in rpc.calls) + + def test_spend_multisig_uses_wallet_psbt_flow() -> None: rpc = FakeRpcClient() diff --git a/backend/tests/test_mutation_route_security.py b/backend/tests/test_mutation_route_security.py index 0da6a31..2ff3ea8 100644 --- a/backend/tests/test_mutation_route_security.py +++ b/backend/tests/test_mutation_route_security.py @@ -16,6 +16,10 @@ ("POST", "/api/psbt/wallet-process"), ("POST", "/api/regtest/faucet"), ("POST", "/api/regtest/mine"), + ("POST", "/api/scenarios/{scenario_id}/runs"), + ("POST", "/api/scenario-runs/{run_id}/advance"), + ("POST", "/api/scenario-runs/{run_id}/reset"), + ("DELETE", "/api/scenario-runs/{run_id}"), ("POST", "/api/scripts/create-op-return"), ("POST", "/api/timelocks/transaction"), ("POST", "/api/transactions/cpfp-child"), diff --git a/backend/tests/test_psbt_service.py b/backend/tests/test_psbt_service.py index 1f5ed16..f74d582 100644 --- a/backend/tests/test_psbt_service.py +++ b/backend/tests/test_psbt_service.py @@ -85,6 +85,20 @@ def test_process_psbt_allows_mainnet_metadata_without_signing() -> None: assert rpc.calls[0] == ("walletprocesspsbt", ["psbt", False], "demo") +def test_process_psbt_can_preserve_partial_signatures_for_staged_signing() -> None: + rpc = FakeRpcClient() + + result = PsbtService(rpc).process("demo", "psbt", True, False) # type: ignore[arg-type] + + assert result["complete"] is True + assert result["finalize_requested"] is False + assert rpc.calls[0] == ( + "walletprocesspsbt", + ["psbt", True, "ALL", True, False], + "demo", + ) + + def test_finalize_psbt_returns_hex_when_extracting() -> None: rpc = FakeRpcClient() diff --git a/backend/tests/test_rbf_scenario.py b/backend/tests/test_rbf_scenario.py new file mode 100644 index 0000000..0701cbf --- /dev/null +++ b/backend/tests/test_rbf_scenario.py @@ -0,0 +1,265 @@ +import json +from datetime import UTC, datetime +from pathlib import Path + +from app.config import Settings +from app.models.lab import LabSession +from app.models.scenario import CleanupStatus, ScenarioRunState +from app.rpc.errors import RpcError +from app.services.evidence_service import EvidenceService +from app.services.challenge_service import ChallengeService +from app.services.lab_session_store import LabSessionStore +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import DEFAULT_SCENARIO_CATALOG +from app.services.scenario_run_store import ScenarioRunStore +from app.services.scenario_service import ScenarioService + + +NOW = datetime(2026, 7, 20, 20, 0, tzinfo=UTC) +WALLET = "bitscope-session-rbf_session" +ORIGINAL_TXID = "a" * 64 +REPLACEMENT_TXID = "b" * 64 +INSUFFICIENT_MESSAGE = ( + "Insufficient total fee 0.00000282, must be at least 0.00000423 " + "(oldFee 0.00000282 + incrementalFee 0.00000141)" +) + + +class RbfRpcClient: + def __init__(self, insufficient_message: str = INSUFFICIENT_MESSAGE) -> None: + self.settings = Settings( + bitcoin_network="regtest", + bitcoin_rpc_user="rbf-user", + bitcoin_rpc_password="rbf-secret", + bitscope_local_access_token="rbf-token", + ) + self.insufficient_message = insufficient_message + self.calls: list[tuple[str, object, str | None]] = [] + self.replaced = False + self.confirmed = False + + def call(self, method: str, params: object = None, wallet_name: str | None = None) -> object: + self.calls.append((method, params, wallet_name)) + if method == "getblockchaininfo": + return {"chain": "regtest"} + if method == "getnetworkinfo": + return { + "version": 280100, + "subversion": "/Satoshi:28.1.0/", + "warnings": "canary rbf-secret must be redacted", + } + if method == "getblockcount": + return 300 + if method == "listwallets": + return [WALLET] + if method == "getnewaddress": + label = params[0] if isinstance(params, list) else "" + return "bcrt1qrbfmining" if label == "bitscope-rbf-mining" else "bcrt1qrbfrecipient" + if method == "generatetoaddress": + count = int(params[0]) if isinstance(params, list) else 0 + if count == 1 and self.replaced: + self.confirmed = True + return [f"{index + 1:064x}" for index in range(count)] + if method == "validateaddress": + return {"isvalid": True, "iswitness": True} + if method == "getbalances": + return {"mine": {"trusted": 50.0, "untrusted_pending": 0.0, "immature": 0.0}} + if method == "sendtoaddress": + return ORIGINAL_TXID + if method == "gettransaction": + txid = params[0] if isinstance(params, list) else "" + if txid == ORIGINAL_TXID: + return {"txid": ORIGINAL_TXID, "hex": "00aa", "confirmations": 0} + return { + "txid": REPLACEMENT_TXID, + "hex": "00bb", + "confirmations": 1 if self.confirmed else 0, + "blockhash": "c" * 64 if self.confirmed else None, + } + if method == "decoderawtransaction": + raw = params[0] if isinstance(params, list) else "" + if raw == "00aa": + return {"txid": ORIGINAL_TXID, "vin": [{"sequence": 0xFFFFFFFD}], "vout": []} + return {"txid": REPLACEMENT_TXID, "vin": [{"sequence": 0xFFFFFFFD}], "vout": []} + if method == "getmempoolentry": + txid = params[0] if isinstance(params, list) else "" + if txid == ORIGINAL_TXID and self.replaced: + raise RpcError( + "BITCOIN_CORE_NOT_FOUND", + "Bitcoin Core could not find the requested transaction.", + 404, + { + "rpc_method": "getmempoolentry", + "rpc_code": -5, + "rpc_message": "Transaction not in mempool", + }, + ) + return { + "vsize": 141, + "fees": {"base": 0.00000282 if txid == ORIGINAL_TXID else 0.00001692}, + "bip125-replaceable": True, + } + if method == "bumpfee": + options = params[1] if isinstance(params, list) and len(params) > 1 else {} + fee_rate = options.get("fee_rate") if isinstance(options, dict) else None + if fee_rate == 2.0: + raise RpcError( + "INVALID_RPC_PARAMETER", + "Bitcoin Core rejected one or more RPC parameters.", + 400, + { + "rpc_method": "bumpfee", + "rpc_code": -8, + "rpc_message": self.insufficient_message, + }, + ) + self.replaced = True + return { + "txid": REPLACEMENT_TXID, + "origfee": 0.00000282, + "fee": 0.00001692, + "errors": [], + } + if method == "unloadwallet": + return None + raise AssertionError(f"Unexpected RPC method: {method}") + + +def build_service(tmp_path: Path, rpc: RbfRpcClient) -> tuple[ScenarioService, ScenarioRunStore, LabSessionStore]: + database = tmp_path / "labs.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_store.save( + LabSession( + session_id="rbf_session", + wallet_name=WALLET, + owned_wallets=[WALLET], + wallet_generation=0, + runtime_chain="regtest", + starting_height=300, + status="active", + created_at=NOW, + updated_at=NOW, + ) + ) + run_store = ScenarioRunStore(str(database)) + artifacts = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + service = ScenarioService( + rpc, + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(rpc.settings), + artifacts, + lab_store, + ) + return service, run_store, lab_store + + +def test_rbf_scenario_replaces_confirms_exports_and_cleans_up(tmp_path: Path) -> None: + rpc = RbfRpcClient() + service, run_store, lab_store = build_service(tmp_path, rpc) + created = service.create_run("rbf-replacement", "rbf_session") + ready = service.advance(created.run_id, "rbf_session", expected_revision=0) + + verified = service.advance(ready.run_id, "rbf_session", expected_revision=1) + + assert verified.current_state == ScenarioRunState.VERIFIED + assert verified.cleanup_status == CleanupStatus.COMPLETED + assert verified.revision == 5 + assert len(verified.completed_steps) == 17 + assert len(verified.assertion_results) == 5 + assert all(result.status.value == "passed" for result in verified.assertion_results) + assert [failure.code for failure in verified.expected_failures] == ["insufficient-replacement-fee"] + assert verified.expected_failures[0].rpc_code == -8 + assert verified.unexpected_failures == [] + assert run_store.get(verified.run_id) == verified + + session = lab_store.get("rbf_session") + assert session is not None and session.status == "cleaned" + assert session.transaction_ids == [ORIGINAL_TXID, REPLACEMENT_TXID] + assert len(session.block_hashes) == 102 + assert ("unloadwallet", [], WALLET) in rpc.calls + + proof_service = ProofBundleService( + run_store, + service.artifact_store, + DEFAULT_SCENARIO_CATALOG, + ) + first = proof_service.bundle(verified.run_id, "rbf_session") + second = proof_service.bundle(verified.run_id, "rbf_session") + assert first.zip_bytes == second.zip_bytes + assert first.manifest.final_result is not None + assert first.manifest.final_result.value == "verified" + assert "evidence/rbf.original.json" in first.files + assert "evidence/rbf.insufficient-fee.json" in first.files + assert "evidence/rbf.replacement.json" in first.files + assert "evidence/rbf.confirmed.json" in first.files + assert "evidence/lifecycle.timeline.json" in first.files + assert "evidence/lifecycle.cleanup.json" in first.files + lifecycle = json.loads(first.files["lifecycle.json"]) + replacement_event = next(event for event in lifecycle["events"] if event["event_type"] == "transaction_replaced") + assert replacement_event["transaction_id"] == REPLACEMENT_TXID + assert replacement_event["relationship"]["relationship_type"] == "replaces" + assert replacement_event["relationship"]["related_txid"] == ORIGINAL_TXID + assert lifecycle["events"][-1]["event_type"] == "scenario_cleaned_up" + assert proof_service.lifecycle(verified.run_id, "rbf_session").model_dump(mode="json") == lifecycle + attacks = json.loads(first.files["evidence/attacks.summary.json"])["core_output"]["result"] + assert attacks[0]["attack_type"] == "rbf_replacement_policy_failure" + assert attacks[0]["classification"] == "mempool_policy" + assert b"insufficient-replacement-fee" in first.files["run.json"] + assert all(b"rbf-secret" not in content for content in first.files.values()) + + challenge = ChallengeService(run_store, service.artifact_store).verify( + "replace-rbf-higher-fee", + verified.run_id, + "rbf_session", + ) + assert challenge.completed is True + assert challenge.solution_unlocked is True + assert challenge.validation_source == "persisted_bitcoin_core_scenario_evidence" + assert {reference.evidence_id for reference in challenge.evidence} >= { + "node.context", + "rbf.replacement", + "rbf.confirmed", + "lifecycle.cleanup", + } + assert all(reference.content_sha256 for reference in challenge.evidence) + + +def test_rbf_scenario_fails_closed_on_different_low_fee_rejection(tmp_path: Path) -> None: + rpc = RbfRpcClient(insufficient_message="Transaction has descendants in the wallet") + service, run_store, lab_store = build_service(tmp_path, rpc) + created = service.create_run("rbf-replacement", "rbf_session") + ready = service.advance(created.run_id, "rbf_session", expected_revision=0) + + failed = service.advance(ready.run_id, "rbf_session", expected_revision=1) + + assert failed.current_state == ScenarioRunState.FAILED + assert failed.cleanup_status == CleanupStatus.COMPLETED + assert failed.failed_steps == ["reject_insufficient_bump"] + assert failed.unexpected_failures[0].code == "SCENARIO_RBF_REJECTION_MISMATCH" + assert failed.unexpected_failures[0].attack_id == "rbf-replacement.replacement-policy" + assert failed.unexpected_failures[0].raw_safe_details["rpc_code"] == -8 + session = lab_store.get("rbf_session") + assert session is not None and session.status == "cleaned" + lifecycle = ProofBundleService( + run_store, + service.artifact_store, + DEFAULT_SCENARIO_CATALOG, + ).lifecycle(failed.run_id, "rbf_session") + assert [event.event_type.value for event in lifecycle.events] == ["scenario_cleaned_up"] + + +def test_default_catalog_exposes_reviewed_rbf_scenario() -> None: + entry = DEFAULT_SCENARIO_CATALOG.get("rbf-replacement") + + assert entry.available is True + assert len(entry.definition.steps) == 17 + assert entry.definition.steps[-1].type == "cleanup_lab" + assert {assertion.assertion_id for assertion in entry.definition.assertions} == { + "original_signaled_rbf", + "insufficient_bump_rejected", + "original_replaced", + "replacement_in_mempool", + "replacement_confirmed", + } diff --git a/backend/tests/test_rpc_capabilities.py b/backend/tests/test_rpc_capabilities.py index 1fa8e8e..bfe0aa4 100644 --- a/backend/tests/test_rpc_capabilities.py +++ b/backend/tests/test_rpc_capabilities.py @@ -59,6 +59,19 @@ def test_wallet_read_capability_allows_balance_but_rejects_mutation() -> None: assert transport.calls == [("getbalances", None, "demo")] +def test_regtest_mutation_capability_includes_reviewed_treasury_methods() -> None: + transport = RecordingTransport() + rpc = RegtestMutationRpcClient(transport) + + assert rpc.call("createpsbt", [[], []]) == {"method": "createpsbt"} + assert rpc.call("importdescriptors", [[]], wallet_name="coordinator") == {"method": "importdescriptors"} + + assert transport.calls == [ + ("createpsbt", [[], []], None), + ("importdescriptors", [[]], "coordinator"), + ] + + @pytest.mark.parametrize("method", sorted(FORBIDDEN_RPC_METHODS)) def test_forbidden_methods_are_rejected_by_most_powerful_capability(method: str) -> None: transport = RecordingTransport() diff --git a/backend/tests/test_scenario_models.py b/backend/tests/test_scenario_models.py new file mode 100644 index 0000000..9943369 --- /dev/null +++ b/backend/tests/test_scenario_models.py @@ -0,0 +1,521 @@ +from copy import deepcopy +from datetime import UTC, datetime, timedelta + +import pytest +from pydantic import ValidationError + +from app.models.scenario import ( + AssertionResult, + AssertionResultStatus, + CleanupStatus, + EvidenceReference, + FailureCategory, + ScenarioDefinition, + ScenarioFailure, + ScenarioRun, + ScenarioRunState, + ScenarioStepResult, + ScenarioStepResultStatus, +) + + +NOW = datetime(2026, 7, 20, 12, 0, tzinfo=UTC) + + +def valid_definition_data() -> dict[str, object]: + return { + "scenario_id": "transaction-lifecycle", + "version": "1.0.0", + "name": "Transaction lifecycle", + "summary": "Prepare an isolated wallet and verify that Bitcoin Core is ready for a transaction workflow.", + "difficulty": "beginner", + "related_lbcli_chapters": [3, 4], + "concepts": ["Transactions", "Wallets", "Regtest"], + "required_network": "regtest", + "required_capabilities": ["read_only", "regtest_mutation"], + "estimated_run_steps": 6, + "steps": [ + { + "step_id": "verify_chain", + "type": "verify_runtime_chain", + "phase": "setup", + "title": "Verify runtime chain", + "description": "Fail closed unless Bitcoin Core reports regtest.", + "output_context_ref": "node.context", + }, + { + "step_id": "prepare_wallet", + "type": "prepare_isolated_wallet", + "phase": "setup", + "title": "Prepare wallet", + "description": "Create a session-owned wallet for the run.", + "depends_on": ["verify_chain"], + "wallet_role": "operator", + "output_wallet_ref": "wallet.operator", + }, + { + "step_id": "generate_address", + "type": "generate_address", + "phase": "execution", + "title": "Generate address", + "description": "Generate a fresh address from the isolated wallet.", + "depends_on": ["prepare_wallet"], + "wallet_ref": "wallet.operator", + "output_address_ref": "address.recipient", + }, + { + "step_id": "verify_setup", + "type": "evaluate_assertions", + "phase": "verification", + "title": "Verify setup", + "description": "Evaluate the required setup assertion.", + "depends_on": ["generate_address"], + "assertion_ids": ["wallet_ready"], + }, + { + "step_id": "export_proof", + "type": "export_evidence", + "phase": "export", + "title": "Export evidence", + "description": "Export the evidence collected by the run.", + "depends_on": ["verify_setup"], + "output_bundle_ref": "proof.bundle", + }, + { + "step_id": "cleanup", + "type": "cleanup_lab", + "phase": "cleanup", + "title": "Clean up", + "description": "Unload only wallets owned by the lab session.", + "depends_on": ["export_proof"], + }, + ], + "assertions": [ + { + "assertion_id": "wallet_ready", + "kind": "rpc_succeeded", + "after_step_id": "generate_address", + "subject_ref": "address.recipient", + "description": "Bitcoin Core generated a fresh recipient address.", + } + ], + "cleanup_rules": { + "unload_owned_wallets": True, + "preserve_unowned_wallets": True, + "fail_run_on_cleanup_error": True, + }, + } + + +def valid_definition() -> ScenarioDefinition: + return ScenarioDefinition.model_validate(valid_definition_data()) + + +def record_all_defined_steps(run: ScenarioRun) -> ScenarioRun: + for step_id in run.defined_step_ids: + run = run.record_step_result( + ScenarioStepResult( + step_id=step_id, + status=ScenarioStepResultStatus.COMPLETED, + started_at=NOW, + completed_at=NOW, + ), + now=NOW, + ) + return run + + +def test_valid_scenario_definition_uses_closed_typed_steps() -> None: + definition = valid_definition() + + assert definition.required_network == "regtest" + assert [step.type for step in definition.steps] == [ + "verify_runtime_chain", + "prepare_isolated_wallet", + "generate_address", + "evaluate_assertions", + "export_evidence", + "cleanup_lab", + ] + assert definition.assertions[0].assertion_id == "wallet_ready" + + +def test_scenario_definition_rejects_unsupported_step_type() -> None: + payload = valid_definition_data() + payload["steps"][1] = { # type: ignore[index] + "step_id": "arbitrary", + "type": "arbitrary_rpc", + "phase": "execution", + "title": "Call anything", + "description": "This must never validate.", + "rpc_method": "stop", + } + + with pytest.raises(ValidationError, match="union_tag_invalid"): + ScenarioDefinition.model_validate(payload) + + +def test_scenario_step_rejects_arbitrary_rpc_fields() -> None: + payload = valid_definition_data() + payload["steps"][1]["rpc_method"] = "dumpprivkey" # type: ignore[index] + + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ScenarioDefinition.model_validate(payload) + + +def test_scenario_definition_rejects_non_regtest_network() -> None: + payload = valid_definition_data() + payload["required_network"] = "mainnet" + + with pytest.raises(ValidationError, match="regtest"): + ScenarioDefinition.model_validate(payload) + + +def test_scenario_definition_rejects_duplicate_steps() -> None: + payload = valid_definition_data() + payload["steps"][1]["step_id"] = "verify_chain" # type: ignore[index] + + with pytest.raises(ValidationError, match="Duplicate scenario step identifier"): + ScenarioDefinition.model_validate(payload) + + +def test_scenario_definition_rejects_missing_or_later_dependencies() -> None: + payload = valid_definition_data() + payload["steps"][1]["depends_on"] = ["export_proof"] # type: ignore[index] + + with pytest.raises(ValidationError, match="missing or later steps"): + ScenarioDefinition.model_validate(payload) + + +def test_scenario_definition_rejects_unknown_artifact_references() -> None: + payload = valid_definition_data() + payload["steps"][2]["wallet_ref"] = "wallet.missing" # type: ignore[index] + + with pytest.raises(ValidationError, match="not produced by earlier steps"): + ScenarioDefinition.model_validate(payload) + + +def test_scenario_definition_rejects_out_of_order_phases() -> None: + payload = valid_definition_data() + generate_address = payload["steps"].pop(2) # type: ignore[union-attr] + payload["steps"].insert(3, generate_address) # type: ignore[union-attr] + + with pytest.raises(ValidationError, match="must follow setup, execution, attack"): + ScenarioDefinition.model_validate(payload) + + +def test_scenario_definition_requires_cleanup_as_final_step() -> None: + payload = valid_definition_data() + payload["steps"] = payload["steps"][:-1] # type: ignore[index] + payload["estimated_run_steps"] = 5 + + with pytest.raises(ValidationError, match="cleanup"): + ScenarioDefinition.model_validate(payload) + + +def test_mutating_steps_require_regtest_mutation_capability() -> None: + payload = valid_definition_data() + payload["required_capabilities"] = ["read_only"] + + with pytest.raises(ValidationError, match="regtest mutation RPC capability"): + ScenarioDefinition.model_validate(payload) + + +def test_assertions_must_reference_known_steps() -> None: + payload = valid_definition_data() + payload["assertions"][0]["after_step_id"] = "missing_step" # type: ignore[index] + + with pytest.raises(ValidationError, match="references unknown step"): + ScenarioDefinition.model_validate(payload) + + +def test_evaluation_steps_must_reference_known_assertions() -> None: + payload = valid_definition_data() + payload["steps"][3]["assertion_ids"] = ["missing_assertion"] # type: ignore[index] + + with pytest.raises(ValidationError, match="references unknown assertions"): + ScenarioDefinition.model_validate(payload) + + +def test_required_assertions_must_be_assigned_to_an_evaluation_step() -> None: + payload = valid_definition_data() + payload["assertions"].append( # type: ignore[union-attr] + { + "assertion_id": "optional_wallet_note", + "kind": "rpc_succeeded", + "after_step_id": "generate_address", + "subject_ref": "address.recipient", + "required": False, + "description": "An optional wallet observation.", + } + ) + payload["steps"][3]["assertion_ids"] = ["optional_wallet_note"] # type: ignore[index] + + with pytest.raises(ValidationError, match="Required assertions are not assigned"): + ScenarioDefinition.model_validate(payload) + + +def test_finalize_psbt_extraction_requires_a_transaction_reference() -> None: + payload = valid_definition_data() + payload["steps"].insert(3, { # type: ignore[union-attr] + "step_id": "finalize", + "type": "finalize_psbt", + "phase": "execution", + "title": "Finalize PSBT", + "description": "Extract a complete transaction.", + "depends_on": ["generate_address"], + "psbt_ref": "psbt.processed", + "extract": True, + "output_psbt_ref": "psbt.finalized", + }) + payload["estimated_run_steps"] = 7 + + with pytest.raises(ValidationError, match="output transaction reference"): + ScenarioDefinition.model_validate(payload) + + +def test_evidence_references_reject_traversal_and_untyped_secret_content() -> None: + with pytest.raises(ValidationError, match="normalized relative paths"): + EvidenceReference( + evidence_id="node.context", + kind="node_context", + label="Node context", + relative_path="../rpc-password.json", + ) + + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + EvidenceReference.model_validate( + { + "evidence_id": "node.context", + "kind": "node_context", + "label": "Node context", + "rpc_password": "must-not-be-stored", + } + ) + + +def test_run_creation_requires_a_valid_lab_session_and_snapshots_assertions() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", "Bitcoin Core 28.1", now=NOW) + + assert run.runtime_chain == "regtest" + assert run.lab_session_id == "session_12345678" + assert run.defined_step_ids == [step.step_id for step in valid_definition().steps] + assert run.required_assertion_ids == ["wallet_ready"] + assert run.current_state == ScenarioRunState.CREATED + + with pytest.raises(ValidationError, match="at least 8 characters"): + ScenarioRun.create(valid_definition(), "short", now=NOW) + + +def test_run_state_machine_accepts_valid_transitions_and_rejects_invalid_ones() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", now=NOW) + + with pytest.raises(ValueError, match="Invalid scenario run transition"): + run.transition_to(ScenarioRunState.VERIFIED, now=NOW) + + run = run.transition_to(ScenarioRunState.READY, now=NOW) + run = run.transition_to(ScenarioRunState.RUNNING, now=NOW) + run = record_all_defined_steps(run) + run = run.transition_to(ScenarioRunState.VERIFYING, now=NOW) + run = run.record_assertion_result( + AssertionResult( + assertion_id="wallet_ready", + status=AssertionResultStatus.PASSED, + required=True, + explanation="Bitcoin Core created the wallet.", + ), + now=NOW, + ) + run = run.transition_to(ScenarioRunState.CLEANING, now=NOW) + run = run.with_cleanup_status(CleanupStatus.COMPLETED, now=NOW) + run = run.transition_to(ScenarioRunState.VERIFIED, now=NOW) + + assert run.final_result == "verified" + assert run.completed_at == NOW + + +def test_verified_run_requires_completed_cleanup() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", now=NOW) + run = run.transition_to(ScenarioRunState.READY, now=NOW) + run = run.transition_to(ScenarioRunState.RUNNING, now=NOW) + run = run.transition_to(ScenarioRunState.VERIFYING, now=NOW) + run = run.record_assertion_result( + AssertionResult( + assertion_id="wallet_ready", + status=AssertionResultStatus.PASSED, + required=True, + explanation="The assertion passed.", + ), + now=NOW, + ) + run = run.transition_to(ScenarioRunState.CLEANING, now=NOW) + + with pytest.raises(ValidationError, match="requires completed cleanup"): + run.transition_to(ScenarioRunState.VERIFIED, now=NOW) + + +def test_verified_run_rejects_missing_or_skipped_required_assertions() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", now=NOW) + run = run.transition_to(ScenarioRunState.READY, now=NOW) + run = run.transition_to(ScenarioRunState.RUNNING, now=NOW) + run = run.transition_to(ScenarioRunState.VERIFYING, now=NOW) + run = run.transition_to(ScenarioRunState.CLEANING, now=NOW) + run = run.with_cleanup_status(CleanupStatus.COMPLETED, now=NOW) + + with pytest.raises(ValidationError, match="Required assertions were not evaluated"): + run.transition_to(ScenarioRunState.VERIFIED, now=NOW) + + run = run.record_assertion_result( + AssertionResult( + assertion_id="wallet_ready", + status=AssertionResultStatus.SKIPPED, + required=True, + explanation="The required assertion was skipped.", + ), + now=NOW, + ) + with pytest.raises(ValidationError, match="Required assertions did not pass"): + run.transition_to(ScenarioRunState.VERIFIED, now=NOW) + + +def test_step_results_distinguish_expected_and_unexpected_failures() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", now=NOW) + expected = ScenarioFailure( + failure_id="failure.expected", + step_id="verify_chain", + category=FailureCategory.MEMPOOL_POLICY, + expected=True, + code="TRANSACTION_REJECTED_BY_POLICY", + safe_message="Bitcoin Core rejected the premature spend.", + rpc_code=-26, + ) + run = run.record_step_result( + ScenarioStepResult( + step_id="verify_chain", + status=ScenarioStepResultStatus.EXPECTED_FAILURE, + started_at=NOW, + completed_at=NOW + timedelta(seconds=1), + failure=expected, + ), + now=NOW, + ) + unexpected = ScenarioFailure( + failure_id="failure.unexpected", + step_id="prepare_wallet", + category=FailureCategory.UNEXPECTED_APPLICATION, + expected=False, + code="BITCOIN_CORE_OFFLINE", + safe_message="Bitcoin Core was unavailable.", + ) + run = run.record_step_result( + ScenarioStepResult( + step_id="prepare_wallet", + status=ScenarioStepResultStatus.UNEXPECTED_FAILURE, + started_at=NOW, + completed_at=NOW + timedelta(seconds=1), + failure=unexpected, + ), + now=NOW, + ) + + assert run.completed_steps == ["verify_chain"] + assert run.failed_steps == ["prepare_wallet"] + assert run.expected_failures == [expected] + assert run.unexpected_failures == [unexpected] + + +def test_duplicate_step_execution_is_rejected() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", now=NOW) + result = ScenarioStepResult( + step_id="verify_chain", + status=ScenarioStepResultStatus.COMPLETED, + started_at=NOW, + completed_at=NOW, + ) + run = run.record_step_result(result, now=NOW) + + with pytest.raises(ValueError, match="already been recorded"): + run.record_step_result(result, now=NOW) + + +def test_results_for_steps_outside_the_definition_are_rejected() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", now=NOW) + result = ScenarioStepResult( + step_id="arbitrary_step", + status=ScenarioStepResultStatus.COMPLETED, + started_at=NOW, + completed_at=NOW, + ) + + with pytest.raises(ValueError, match="not part of this run's definition"): + run.record_step_result(result, now=NOW) + + +def test_cleanup_failure_has_a_distinct_terminal_result() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", now=NOW) + run = run.transition_to(ScenarioRunState.CLEANING, now=NOW) + run = run.with_cleanup_status(CleanupStatus.FAILED, now=NOW) + run = run.transition_to(ScenarioRunState.CLEANUP_FAILED, now=NOW) + + assert run.final_result == "cleanup_failed" + assert run.cleanup_status == CleanupStatus.FAILED + + +def test_run_rejects_duplicate_evidence_references() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", now=NOW) + payload = run.model_dump(mode="python") + reference = { + "evidence_id": "node.context", + "kind": "node_context", + "label": "Node context", + "redacted": True, + } + payload["evidence"] = [deepcopy(reference), deepcopy(reference)] + + with pytest.raises(ValidationError, match="Evidence identifiers must be unique"): + ScenarioRun.model_validate(payload) + + +def test_run_rejects_duplicate_failure_identifiers() -> None: + run = ScenarioRun.create(valid_definition(), "session_12345678", now=NOW) + first = ScenarioFailure( + failure_id="failure.duplicate", + step_id="verify_chain", + category=FailureCategory.MEMPOOL_POLICY, + expected=True, + code="EXPECTED_REJECTION", + safe_message="The first expected rejection.", + ) + second = ScenarioFailure( + failure_id="failure.duplicate", + step_id="prepare_wallet", + category=FailureCategory.MEMPOOL_POLICY, + expected=True, + code="EXPECTED_REJECTION", + safe_message="The second expected rejection.", + ) + first_result = ScenarioStepResult( + step_id="verify_chain", + status=ScenarioStepResultStatus.EXPECTED_FAILURE, + started_at=NOW, + completed_at=NOW, + failure=first, + ) + second_result = ScenarioStepResult( + step_id="prepare_wallet", + status=ScenarioStepResultStatus.EXPECTED_FAILURE, + started_at=NOW, + completed_at=NOW, + failure=second, + ) + payload = run.model_dump(mode="python") + payload.update( + { + "completed_steps": ["verify_chain", "prepare_wallet"], + "step_results": [first_result.model_dump(mode="python"), second_result.model_dump(mode="python")], + "expected_failures": [first.model_dump(mode="python"), second.model_dump(mode="python")], + } + ) + + with pytest.raises(ValidationError, match="Failure identifiers must be unique"): + ScenarioRun.model_validate(payload) diff --git a/backend/tests/test_scenario_run_store.py b/backend/tests/test_scenario_run_store.py new file mode 100644 index 0000000..b5b3ab1 --- /dev/null +++ b/backend/tests/test_scenario_run_store.py @@ -0,0 +1,425 @@ +import sqlite3 +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from app.errors import BitScopeError +from app.models.lab import LabSession +from app.models.scenario import ( + AssertionResult, + AssertionResultStatus, + EvidenceReference, + FailureCategory, + ScenarioDefinition, + ScenarioFailure, + ScenarioRun, + ScenarioRunState, + ScenarioStepResult, + ScenarioStepResultStatus, +) +from app.services.lab_session_store import LabSessionStore +from app.services.scenario_run_store import ScenarioRunStore + + +NOW = datetime(2026, 7, 20, 14, 0, tzinfo=UTC) + + +def scenario_definition() -> ScenarioDefinition: + return ScenarioDefinition.model_validate( + { + "scenario_id": "transaction-lifecycle", + "version": "1.0.0", + "name": "Transaction lifecycle", + "summary": "Create an isolated wallet and a fresh address, then record evidence and clean up.", + "difficulty": "beginner", + "related_lbcli_chapters": [3, 4], + "concepts": ["Transactions", "Wallets", "Regtest"], + "required_network": "regtest", + "required_capabilities": ["read_only", "regtest_mutation"], + "estimated_run_steps": 6, + "steps": [ + { + "step_id": "verify_chain", + "type": "verify_runtime_chain", + "phase": "setup", + "title": "Verify chain", + "description": "Verify that Bitcoin Core reports regtest.", + "output_context_ref": "node.context", + }, + { + "step_id": "prepare_wallet", + "type": "prepare_isolated_wallet", + "phase": "setup", + "title": "Prepare wallet", + "description": "Prepare a session-owned wallet.", + "depends_on": ["verify_chain"], + "wallet_role": "operator", + "output_wallet_ref": "wallet.operator", + }, + { + "step_id": "generate_address", + "type": "generate_address", + "phase": "execution", + "title": "Generate address", + "description": "Generate a fresh address.", + "depends_on": ["prepare_wallet"], + "wallet_ref": "wallet.operator", + "output_address_ref": "address.recipient", + }, + { + "step_id": "verify_address", + "type": "evaluate_assertions", + "phase": "verification", + "title": "Verify address", + "description": "Verify address generation.", + "depends_on": ["generate_address"], + "assertion_ids": ["address_ready"], + }, + { + "step_id": "export_proof", + "type": "export_evidence", + "phase": "export", + "title": "Export evidence", + "description": "Export safe evidence.", + "depends_on": ["verify_address"], + "output_bundle_ref": "proof.bundle", + }, + { + "step_id": "cleanup", + "type": "cleanup_lab", + "phase": "cleanup", + "title": "Clean up", + "description": "Unload session-owned wallets.", + "depends_on": ["export_proof"], + }, + ], + "assertions": [ + { + "assertion_id": "address_ready", + "kind": "rpc_succeeded", + "after_step_id": "generate_address", + "subject_ref": "address.recipient", + "description": "Bitcoin Core generated a fresh address.", + } + ], + } + ) + + +def lab_session(session_id: str, status: str = "active") -> LabSession: + return LabSession.model_validate( + { + "session_id": session_id, + "wallet_name": f"bitscope-session-{session_id}", + "owned_wallets": [f"bitscope-session-{session_id}"], + "wallet_generation": 0, + "runtime_chain": "regtest", + "starting_height": 200, + "status": status, + "created_at": NOW, + "updated_at": NOW, + } + ) + + +def save_lab(database: Path, session_id: str, status: str = "active") -> LabSession: + session = lab_session(session_id, status) + LabSessionStore(str(database)).save(session) + return session + + +def new_run(session_id: str) -> ScenarioRun: + return ScenarioRun.create(scenario_definition(), session_id, "Bitcoin Core 28.1", now=NOW) + + +def test_store_persists_runs_and_recovers_after_restart(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + save_lab(database, "session_alpha") + run = new_run("session_alpha") + + ScenarioRunStore(str(database)).create(run) + restarted = ScenarioRunStore(str(database)) + + assert restarted.get(run.run_id) == run + assert restarted.get_for_session(run.run_id, "session_alpha") == run + assert restarted.list_for_session("session_alpha") == [run] + + +def test_store_preserves_existing_lab_documents_during_schema_setup(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + session = save_lab(database, "session_alpha") + + ScenarioRunStore(str(database)) + + assert LabSessionStore(str(database)).get(session.session_id) == session + + +def test_create_requires_an_existing_active_lab_session(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + store = ScenarioRunStore(str(database)) + + with pytest.raises(BitScopeError) as missing: + store.create(new_run("session_missing")) + assert missing.value.code == "LAB_SESSION_NOT_FOUND" + + save_lab(database, "session_cleaned", status="cleaned") + with pytest.raises(BitScopeError) as inactive: + store.create(new_run("session_cleaned")) + assert inactive.value.code == "LAB_SESSION_NOT_ACTIVE" + + +def test_duplicate_run_identifiers_are_rejected(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + save_lab(database, "session_alpha") + run = new_run("session_alpha") + store = ScenarioRunStore(str(database)) + store.create(run) + + with pytest.raises(BitScopeError) as duplicate: + store.create(run) + + assert duplicate.value.code == "SCENARIO_RUN_ALREADY_EXISTS" + + +def test_session_scoped_reads_lists_and_deletes_do_not_cross_sessions(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + first_session = save_lab(database, "session_alpha") + second_session = save_lab(database, "session_beta") + first = new_run(first_session.session_id) + second = new_run(second_session.session_id) + store = ScenarioRunStore(str(database)) + store.create(first) + store.create(second) + + assert store.get_for_session(first.run_id, second_session.session_id) is None + assert store.list_for_session(first_session.session_id) == [first] + assert store.list_for_session(second_session.session_id) == [second] + assert store.delete(first.run_id, second_session.session_id) is False + assert store.get(first.run_id) == first + assert store.delete(first.run_id, first_session.session_id) is True + assert store.get(first.run_id) is None + assert LabSessionStore(str(database)).get(first_session.session_id) == first_session + + +def test_save_uses_optimistic_revision_checks(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + save_lab(database, "session_alpha") + store = ScenarioRunStore(str(database)) + original = new_run("session_alpha") + store.create(original) + + first_reader = store.get(original.run_id) + second_reader = store.get(original.run_id) + assert first_reader is not None + assert second_reader is not None + first_update = first_reader.transition_to(ScenarioRunState.READY, now=NOW + timedelta(seconds=1)) + stale_update = second_reader.transition_to(ScenarioRunState.READY, now=NOW + timedelta(seconds=2)) + + store.save(first_update, expected_revision=0) + with pytest.raises(BitScopeError) as conflict: + store.save(stale_update, expected_revision=0) + + assert conflict.value.code == "SCENARIO_RUN_REVISION_CONFLICT" + assert conflict.value.details["actual_revision"] == 1 + assert store.get(original.run_id) == first_update + + +def test_save_requires_exactly_one_revision_increment(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + save_lab(database, "session_alpha") + store = ScenarioRunStore(str(database)) + run = new_run("session_alpha") + store.create(run) + payload = run.model_dump(mode="python") + payload["revision"] = 2 + payload["updated_at"] = NOW + timedelta(seconds=1) + invalid_update = ScenarioRun.model_validate(payload) + + with pytest.raises(BitScopeError) as invalid: + store.save(invalid_update, expected_revision=0) + + assert invalid.value.code == "SCENARIO_RUN_INVALID_REVISION" + assert store.get(run.run_id) == run + + +def test_save_rejects_run_identity_changes(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + save_lab(database, "session_alpha") + save_lab(database, "session_beta") + store = ScenarioRunStore(str(database)) + run = new_run("session_alpha") + store.create(run) + payload = run.model_dump(mode="python") + payload["lab_session_id"] = "session_beta" + payload["revision"] = 1 + payload["updated_at"] = NOW + timedelta(seconds=1) + moved = ScenarioRun.model_validate(payload) + + with pytest.raises(BitScopeError) as mismatch: + store.save(moved, expected_revision=0) + + assert mismatch.value.code == "SCENARIO_RUN_IDENTITY_MISMATCH" + assert store.get(run.run_id) == run + + +def test_save_rejects_state_machine_bypasses(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + save_lab(database, "session_alpha") + store = ScenarioRunStore(str(database)) + run = new_run("session_alpha") + store.create(run) + payload = run.model_dump(mode="python") + payload["current_state"] = "running" + payload["revision"] = 1 + payload["updated_at"] = NOW + timedelta(seconds=1) + bypassed = ScenarioRun.model_validate(payload) + + with pytest.raises(BitScopeError) as invalid: + store.save(bypassed, expected_revision=0) + + assert invalid.value.code == "SCENARIO_RUN_INVALID_TRANSITION" + assert store.get(run.run_id) == run + + +def test_child_records_are_replaced_transactionally_with_the_run(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + save_lab(database, "session_alpha") + store = ScenarioRunStore(str(database)) + run = new_run("session_alpha") + store.create(run) + evidence = EvidenceReference( + evidence_id="evidence.rejection", + kind="rpc_result", + label="Expected rejection", + relative_path="rpc/rejection.json", + content_sha256="1" * 64, + ) + failure = ScenarioFailure( + failure_id="failure.expected", + step_id="generate_address", + category=FailureCategory.MEMPOOL_POLICY, + expected=True, + code="TRANSACTION_REJECTED_BY_POLICY", + safe_message="Bitcoin Core rejected the candidate as expected.", + rpc_code=-26, + evidence_ids=[evidence.evidence_id], + ) + step_result = ScenarioStepResult( + step_id="generate_address", + status=ScenarioStepResultStatus.EXPECTED_FAILURE, + started_at=NOW, + completed_at=NOW + timedelta(seconds=1), + evidence_ids=[evidence.evidence_id], + failure=failure, + ) + assertion_result = AssertionResult( + assertion_id="address_ready", + status=AssertionResultStatus.PASSED, + required=True, + expected_failure=True, + explanation="The expected rejection category matched.", + evidence_ids=[evidence.evidence_id], + ) + payload = run.model_dump(mode="python") + payload.update( + { + "revision": 1, + "updated_at": NOW + timedelta(seconds=1), + "completed_steps": [step_result.step_id], + "step_results": [step_result.model_dump(mode="python")], + "assertion_results": [assertion_result.model_dump(mode="python")], + "expected_failures": [failure.model_dump(mode="python")], + "evidence": [evidence.model_dump(mode="python")], + } + ) + updated = ScenarioRun.model_validate(payload) + + store.save(updated, expected_revision=0) + + assert store.get(run.run_id) == updated + with sqlite3.connect(database) as connection: + counts = { + table: connection.execute( + f"SELECT COUNT(*) FROM {table} WHERE run_id = ?", + (str(run.run_id),), + ).fetchone()[0] + for table in ( + "scenario_step_runs", + "scenario_assertions", + "scenario_evidence", + "scenario_failures", + ) + } + assert counts == { + "scenario_step_runs": 1, + "scenario_assertions": 1, + "scenario_evidence": 1, + "scenario_failures": 1, + } + assert store.delete(run.run_id, run.lab_session_id) is True + with sqlite3.connect(database) as connection: + remaining = { + table: connection.execute( + f"SELECT COUNT(*) FROM {table} WHERE run_id = ?", + (str(run.run_id),), + ).fetchone()[0] + for table in counts + } + assert remaining == {table: 0 for table in counts} + + +def test_save_rejects_removal_of_persisted_history(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + save_lab(database, "session_alpha") + store = ScenarioRunStore(str(database)) + run = new_run("session_alpha") + store.create(run) + first_result = ScenarioStepResult( + step_id="verify_chain", + status=ScenarioStepResultStatus.COMPLETED, + started_at=NOW, + completed_at=NOW, + ) + updated = run.record_step_result(first_result, now=NOW + timedelta(seconds=1)) + store.save(updated, expected_revision=0) + payload = updated.model_dump(mode="python") + payload.update( + { + "revision": 2, + "updated_at": NOW + timedelta(seconds=2), + "completed_steps": [], + "step_results": [], + } + ) + rewritten = ScenarioRun.model_validate(payload) + + with pytest.raises(BitScopeError) as history: + store.save(rewritten, expected_revision=1) + + assert history.value.code == "SCENARIO_RUN_HISTORY_REWRITE" + assert store.get(run.run_id) == updated + with sqlite3.connect(database) as connection: + count = connection.execute( + "SELECT COUNT(*) FROM scenario_step_runs WHERE run_id = ?", + (str(run.run_id),), + ).fetchone()[0] + assert count == 1 + + +def test_store_rejects_a_database_schema_from_a_newer_version(tmp_path: Path) -> None: + database = tmp_path / "labs.sqlite3" + LabSessionStore(str(database)) + with sqlite3.connect(database) as connection: + connection.execute( + "CREATE TABLE bitscope_schema_migrations (component TEXT PRIMARY KEY, version INTEGER NOT NULL)" + ) + connection.execute( + "INSERT INTO bitscope_schema_migrations(component, version) VALUES (?, ?)", + ("scenario_runs", 999), + ) + + with pytest.raises(BitScopeError) as too_new: + ScenarioRunStore(str(database)) + + assert too_new.value.code == "SCENARIO_SCHEMA_TOO_NEW" diff --git a/backend/tests/test_scenario_service.py b/backend/tests/test_scenario_service.py new file mode 100644 index 0000000..0249697 --- /dev/null +++ b/backend/tests/test_scenario_service.py @@ -0,0 +1,382 @@ +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.config import Settings, get_settings +from app.errors import BitScopeError +from app.main import create_app +from app.models.lab import LabSession +from app.models.scenario import ScenarioDefinition, ScenarioRun, ScenarioRunState +from app.services.lab_session_store import LabSessionStore +from app.services.evidence_service import EvidenceService +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import RegisteredScenario, ScenarioCatalog +from app.services.scenario_run_store import ScenarioRunStore +from app.services.scenario_service import ScenarioService +from app.routes.scenarios import get_scenario_catalog, get_scenario_service + + +NOW = datetime(2026, 7, 20, 15, 0, tzinfo=UTC) + + +class FakeRpcClient: + def __init__(self, chain: str = "regtest", block_count: object = 200) -> None: + self.settings = Settings( + bitcoin_network="regtest", + bitcoin_rpc_user="readiness-user", + bitcoin_rpc_password="readiness-secret", + bitscope_local_access_token="readiness-token", + ) + self.chain = chain + self.block_count = block_count + self.calls: list[str] = [] + + def call(self, method: str, params: object = None, wallet_name: str | None = None) -> object: + self.calls.append(method) + if method == "getblockchaininfo": + return {"chain": self.chain} + if method == "getnetworkinfo": + return { + "version": 280100, + "subversion": "/Satoshi:28.1.0/", + "warnings": "Test canary readiness-secret must be redacted.", + } + if method == "getblockcount": + return self.block_count + raise AssertionError(f"Unexpected RPC method: {method}") + + +def scenario_definition(scenario_id: str = "transaction-lifecycle") -> ScenarioDefinition: + return ScenarioDefinition.model_validate( + { + "scenario_id": scenario_id, + "version": "1.0.0", + "name": "Transaction lifecycle", + "summary": "Create an isolated wallet and fresh address, verify the result, export proof, and clean up.", + "difficulty": "beginner", + "related_lbcli_chapters": [3, 4], + "concepts": ["Transactions", "Wallets", "Regtest"], + "required_capabilities": ["read_only", "regtest_mutation"], + "estimated_run_steps": 6, + "steps": [ + { + "step_id": "verify_chain", + "type": "verify_runtime_chain", + "phase": "setup", + "title": "Verify chain", + "description": "Verify that Bitcoin Core reports regtest.", + "output_context_ref": "node.context", + }, + { + "step_id": "prepare_wallet", + "type": "prepare_isolated_wallet", + "phase": "setup", + "title": "Prepare wallet", + "description": "Prepare a session-owned wallet.", + "depends_on": ["verify_chain"], + "wallet_role": "operator", + "output_wallet_ref": "wallet.operator", + }, + { + "step_id": "generate_address", + "type": "generate_address", + "phase": "execution", + "title": "Generate address", + "description": "Generate a fresh address.", + "depends_on": ["prepare_wallet"], + "wallet_ref": "wallet.operator", + "output_address_ref": "address.recipient", + }, + { + "step_id": "verify_address", + "type": "evaluate_assertions", + "phase": "verification", + "title": "Verify address", + "description": "Verify address generation.", + "depends_on": ["generate_address"], + "assertion_ids": ["address_ready"], + }, + { + "step_id": "export_proof", + "type": "export_evidence", + "phase": "export", + "title": "Export evidence", + "description": "Export safe evidence.", + "depends_on": ["verify_address"], + "output_bundle_ref": "proof.bundle", + }, + { + "step_id": "cleanup", + "type": "cleanup_lab", + "phase": "cleanup", + "title": "Clean up", + "description": "Unload session-owned wallets.", + "depends_on": ["export_proof"], + }, + ], + "assertions": [ + { + "assertion_id": "address_ready", + "kind": "rpc_succeeded", + "after_step_id": "generate_address", + "subject_ref": "address.recipient", + "description": "Bitcoin Core generated a fresh address.", + } + ], + } + ) + + +def save_lab(database: Path, session_id: str = "session_alpha") -> None: + LabSessionStore(str(database)).save( + LabSession( + session_id=session_id, + wallet_name=f"bitscope-{session_id}", + owned_wallets=[f"bitscope-{session_id}"], + wallet_generation=0, + runtime_chain="regtest", + starting_height=200, + status="active", + created_at=NOW, + updated_at=NOW, + ) + ) + + +def build_service(database: Path, rpc: FakeRpcClient | None = None) -> tuple[ScenarioService, ScenarioRunStore]: + save_lab(database) + store = ScenarioRunStore(str(database)) + catalog = ScenarioCatalog((RegisteredScenario(scenario_definition()),)) + client = rpc or FakeRpcClient() + artifacts = ScenarioArtifactStore(str(database.parent / "scenario-artifacts")) + return ScenarioService(client, store, catalog, EvidenceService.from_settings(client.settings), artifacts), store + + +def test_catalog_is_sorted_and_reports_availability() -> None: + preview = RegisteredScenario( + scenario_definition("z-preview"), + available=False, + unavailable_reason="Live-node proof is pending.", + ) + available = RegisteredScenario(scenario_definition("a-ready")) + catalog = ScenarioCatalog((preview, available)) + + assert [entry.scenario_id for entry in catalog.list()] == ["a-ready", "z-preview"] + assert catalog.get("z-preview").detail().unavailable_reason == "Live-node proof is pending." + with pytest.raises(BitScopeError) as unavailable: + catalog.require_available("z-preview") + assert unavailable.value.code == "SCENARIO_NOT_AVAILABLE" + + +def test_catalog_rejects_duplicates_and_unknown_identifiers() -> None: + entry = RegisteredScenario(scenario_definition()) + with pytest.raises(ValueError, match="Duplicate scenario identifier"): + ScenarioCatalog((entry, entry)) + + with pytest.raises(BitScopeError) as missing: + ScenarioCatalog().get("missing-scenario") + assert missing.value.code == "SCENARIO_NOT_FOUND" + + +def test_create_run_verifies_regtest_records_version_and_persists(tmp_path: Path) -> None: + rpc = FakeRpcClient() + service, store = build_service(tmp_path / "labs.sqlite3", rpc) + + run = service.create_run("transaction-lifecycle", "session_alpha") + + assert run.current_state == ScenarioRunState.CREATED + assert run.bitcoin_core_version == "/Satoshi:28.1.0/" + assert store.get(run.run_id) == run + assert rpc.calls == ["getblockchaininfo", "getnetworkinfo"] + + +def test_create_run_fails_closed_when_runtime_is_not_regtest(tmp_path: Path) -> None: + service, store = build_service(tmp_path / "labs.sqlite3", FakeRpcClient(chain="main")) + + with pytest.raises(BitScopeError) as mismatch: + service.create_run("transaction-lifecycle", "session_alpha") + + assert mismatch.value.code == "BITCOIN_NETWORK_MISMATCH" + assert store.list_for_session("session_alpha") == [] + + +def test_advance_is_owned_revisioned_and_limited_to_preparation(tmp_path: Path) -> None: + service, store = build_service(tmp_path / "labs.sqlite3") + run = service.create_run("transaction-lifecycle", "session_alpha") + + with pytest.raises(BitScopeError) as hidden: + service.advance(run.run_id, "session_other", expected_revision=0) + assert hidden.value.code == "SCENARIO_RUN_NOT_FOUND" + + with pytest.raises(BitScopeError) as stale: + service.advance(run.run_id, "session_alpha", expected_revision=1) + assert stale.value.code == "SCENARIO_RUN_REVISION_CONFLICT" + + ready = service.advance(run.run_id, "session_alpha", expected_revision=0) + assert ready.current_state == ScenarioRunState.READY + assert ready.revision == 1 + assert [reference.evidence_id for reference in ready.evidence] == ["node.context"] + assert ready.evidence[0].relative_path == "evidence/node.context.json" + + proof = ProofBundleService(store, service.artifact_store, service.catalog).bundle( + run.run_id, + "session_alpha", + ) + assert "node-context.json" in proof.files + assert "commands.sh" in proof.files + assert proof.manifest.generated_from_revision == 1 + assert all(b"readiness-secret" not in content for content in proof.files.values()) + + with pytest.raises(BitScopeError) as unavailable: + service.advance(run.run_id, "session_alpha", expected_revision=1) + assert unavailable.value.code == "SCENARIO_EXECUTION_NOT_AVAILABLE" + + +def test_advance_fails_closed_without_persisting_invalid_readiness_evidence(tmp_path: Path) -> None: + service, store = build_service(tmp_path / "labs.sqlite3", FakeRpcClient(block_count=-1)) + run = service.create_run("transaction-lifecycle", "session_alpha") + + with pytest.raises(BitScopeError) as invalid: + service.advance(run.run_id, "session_alpha", expected_revision=0) + + assert invalid.value.code == "BITCOIN_CORE_INVALID_RESPONSE" + assert store.get(run.run_id) == run + target = service.artifact_store.root / str(run.run_id) / "evidence" / "node.context.json" + assert not target.exists() + + +def test_advance_removes_new_artifact_when_run_commit_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + service, store = build_service(tmp_path / "labs.sqlite3") + run = service.create_run("transaction-lifecycle", "session_alpha") + + def reject_save(_: ScenarioRun, expected_revision: int) -> None: + raise BitScopeError( + "SCENARIO_RUN_REVISION_CONFLICT", + "Injected optimistic revision conflict.", + 409, + {"expected_revision": expected_revision}, + ) + + monkeypatch.setattr(store, "save", reject_save) + with pytest.raises(BitScopeError) as conflict: + service.advance(run.run_id, "session_alpha", expected_revision=0) + + assert conflict.value.code == "SCENARIO_RUN_REVISION_CONFLICT" + target = service.artifact_store.root / str(run.run_id) / "evidence" / "node.context.json" + assert not target.exists() + + +def test_reset_creates_a_new_run_without_rewriting_history(tmp_path: Path) -> None: + service, store = build_service(tmp_path / "labs.sqlite3") + previous = service.create_run("transaction-lifecycle", "session_alpha") + + replacement = service.reset(previous.run_id, "session_alpha", expected_revision=0) + + assert replacement.run_id != previous.run_id + assert replacement.scenario_id == previous.scenario_id + assert replacement.lab_session_id == previous.lab_session_id + assert store.get(previous.run_id) == previous + assert store.get(replacement.run_id) == replacement + + +def test_delete_requires_safe_state_and_correct_revision(tmp_path: Path) -> None: + service, store = build_service(tmp_path / "labs.sqlite3") + run = service.create_run("transaction-lifecycle", "session_alpha") + ready = service.advance(run.run_id, "session_alpha", expected_revision=0) + running = ready.transition_to(ScenarioRunState.RUNNING) + store.save(running, expected_revision=1) + + with pytest.raises(BitScopeError) as cleanup: + service.delete(run.run_id, "session_alpha", expected_revision=2) + assert cleanup.value.code == "SCENARIO_RUN_CLEANUP_REQUIRED" + + fresh = service.create_run("transaction-lifecycle", "session_alpha") + with pytest.raises(BitScopeError) as stale: + service.delete(fresh.run_id, "session_alpha", expected_revision=1) + assert stale.value.code == "SCENARIO_RUN_REVISION_CONFLICT" + assert service.delete(fresh.run_id, "session_alpha", expected_revision=0) is True + assert store.get(fresh.run_id) is None + + +def test_phase_one_routes_expose_catalogue_and_protect_run_mutations(tmp_path: Path) -> None: + settings = Settings( + app_environment="test", + bitscope_local_access_token="scenario-test-token", + lab_session_database_path=str(tmp_path / "routes.sqlite3"), + ) + service, _ = build_service(Path(settings.lab_session_database_path)) + app = create_app(settings) + app.dependency_overrides[get_settings] = lambda: settings + app.dependency_overrides[get_scenario_catalog] = lambda: service.catalog + app.dependency_overrides[get_scenario_service] = lambda: service + client = TestClient(app) + + catalogue = client.get("/api/scenarios") + assert catalogue.status_code == 200 + assert catalogue.json()["scenarios"][0]["scenario_id"] == "transaction-lifecycle" + detail = client.get("/api/scenarios/transaction-lifecycle") + assert detail.status_code == 200 + assert detail.json()["definition"]["version"] == "1.0.0" + + denied = client.post( + "/api/scenarios/transaction-lifecycle/runs", + json={"lab_session_id": "session_alpha"}, + ) + assert denied.status_code == 401 + headers = {"X-BitScope-Token": "scenario-test-token"} + created = client.post( + "/api/scenarios/transaction-lifecycle/runs", + headers=headers, + json={"lab_session_id": "session_alpha"}, + ) + assert created.status_code == 200 + run_id = created.json()["run_id"] + + hidden = client.get(f"/api/scenario-runs/{run_id}", params={"lab_session_id": "session_other"}) + assert hidden.status_code == 404 + fetched = client.get(f"/api/scenario-runs/{run_id}", params={"lab_session_id": "session_alpha"}) + assert fetched.status_code == 200 + + advanced = client.post( + f"/api/scenario-runs/{run_id}/advance", + headers=headers, + json={"lab_session_id": "session_alpha", "expected_revision": 0}, + ) + assert advanced.status_code == 200 + assert advanced.json()["current_state"] == "ready" + + unconfirmed = client.delete( + f"/api/scenario-runs/{run_id}", + headers=headers, + params={"lab_session_id": "session_alpha", "expected_revision": 1}, + ) + assert unconfirmed.status_code == 400 + deleted = client.delete( + f"/api/scenario-runs/{run_id}", + headers=headers, + params={ + "lab_session_id": "session_alpha", + "expected_revision": 1, + "confirm": "true", + }, + ) + assert deleted.status_code == 200 + assert deleted.json() == {"run_id": run_id, "deleted": True} + assert not (service.artifact_store.root / run_id).exists() + + reset_source = client.post( + "/api/scenarios/transaction-lifecycle/runs", + headers=headers, + json={"lab_session_id": "session_alpha"}, + ).json() + reset = client.post( + f"/api/scenario-runs/{reset_source['run_id']}/reset", + headers=headers, + json={"lab_session_id": "session_alpha", "expected_revision": 0}, + ) + assert reset.status_code == 200 + assert reset.json()["previous_run_id"] == reset_source["run_id"] + assert reset.json()["run"]["run_id"] != reset_source["run_id"] diff --git a/backend/tests/test_timelock_service.py b/backend/tests/test_timelock_service.py index c7ae71b..1647a27 100644 --- a/backend/tests/test_timelock_service.py +++ b/backend/tests/test_timelock_service.py @@ -19,7 +19,19 @@ def call(self, method: str, params: list[object] | None = None, wallet_name: str return {"chain": {"mainnet": "main", "testnet": "test"}.get(self.settings.bitcoin_network, self.settings.bitcoin_network)} self.calls.append((method, params, wallet_name)) if method == "validateaddress": - return {"isvalid": True} + return {"isvalid": True, "scriptPubKey": "0014" + "cc" * 20} + if method == "getnewaddress": + return "bcrt1qsigner" + if method == "getaddressinfo": + return {"address": "bcrt1qsigner", "pubkey": "02" + "aa" * 32} + if method == "importaddress": + return None + if method == "getbalances": + return {"mine": {"trusted": 50.0, "immature": 0.0}} + if method == "sendtoaddress": + return "33" * 32 + if method == "gettransaction": + return {"txid": "33" * 32, "hex": "funding-hex", "confirmations": 0} if method == "listunspent": return [{"txid": "11" * 32, "vout": 1, "amount": 2.0, "spendable": True, "safe": True, "confirmations": 101, "generated": True}] if method == "createrawtransaction": @@ -29,11 +41,51 @@ def call(self, method: str, params: list[object] | None = None, wallet_name: str if method == "signrawtransactionwithwallet": return {"hex": "00cc", "complete": True} if method == "decoderawtransaction": + raw_hex = params[0] if params else "" + if raw_hex == "funding-hex": + return { + "txid": "33" * 32, + "vout": [ + { + "n": 0, + "value": 0.5, + "scriptPubKey": { + "address": "bcrt1qcltvpolicy", + "hex": "0020" + "bb" * 32, + }, + } + ], + } + if raw_hex == "00aa" or (isinstance(raw_hex, str) and raw_hex.startswith("020000000001")): + return { + "txid": "44" * 32, + "version": 2, + "locktime": 505, + "vin": [{"txid": "33" * 32, "vout": 0, "sequence": 0xFFFFFFFE}], + "vout": [ + { + "n": 0, + "value": 0.4999, + "scriptPubKey": { + "address": "bcrt1qdest", + "hex": "0014" + "cc" * 20, + }, + } + ], + } return {"txid": "22" * 32, "locktime": 500, "vin": [{"sequence": 1}]} if method == "testmempoolaccept": return [{"txid": "22" * 32, "allowed": False, "reject-reason": "non-final"}] if method == "decodescript": - return {"asm": "500 OP_CHECKLOCKTIMEVERIFY OP_DROP 02aa OP_CHECKSIG", "type": "nonstandard"} + return { + "asm": "500 OP_CHECKLOCKTIMEVERIFY OP_DROP 02aa OP_CHECKSIG", + "type": "nonstandard", + "segwit": { + "address": "bcrt1qcltvpolicy", + "hex": "0020" + "bb" * 32, + "type": "witness_v0_scripthash", + }, + } raise AssertionError(f"unexpected method {method}") @@ -70,6 +122,80 @@ def test_script_template_builds_csv_script() -> None: assert result["script_hex"].endswith(f"b27521{pubkey}ac") +def test_create_cltv_policy_uses_ephemeral_public_key_without_exporting_private_key() -> None: + rpc = FakeRpcClient() + + result = TimelockService(rpc).create_cltv_policy(505) # type: ignore[arg-type] + + assert result["lock_height"] == 505 + assert result["policy_address"] == "bcrt1qcltvpolicy" + assert result["script_pub_key"] == "0020" + "bb" * 32 + assert str(result["witness_script"]).endswith("b17521" + str(result["pubkey"]) + "ac") + assert len(str(result["pubkey"])) == 66 + assert "private" not in str(result).lower() + assert all(call[0] not in {"dumpprivkey", "signrawtransactionwithkey"} for call in rpc.calls) + + +def test_fund_cltv_policy_returns_exact_policy_outpoint() -> None: + rpc = FakeRpcClient() + + result = TimelockService(rpc).fund_cltv_policy( + "funder", + "bcrt1qcltvpolicy", + 0.5, + 2.0, + ) # type: ignore[arg-type] + + assert result["txid"] == "33" * 32 + assert result["vout"] == 0 + assert result["output_amount_btc"] == 0.5 + assert result["script_pub_key"] == "0020" + "bb" * 32 + assert ( + "sendtoaddress", + ["bcrt1qcltvpolicy", 0.5, "", "", False, True, None, "unset", None, 2.0], + "funder", + ) in rpc.calls + + +def test_create_cltv_spend_commits_locktime_sequence_and_prevout_script() -> None: + rpc = FakeRpcClient() + service = TimelockService(rpc) # type: ignore[arg-type] + policy = service.create_cltv_policy(505) + funding = { + "txid": "33" * 32, + "vout": 0, + "output_amount_btc": 0.5, + "script_pub_key": "0020" + "bb" * 32, + } + witness_script = str(policy["witness_script"]) + + result = service.create_cltv_spend( + funding, + str(policy["policy_address"]), + witness_script, + "bcrt1qdest", + 505, + 0xFFFFFFFE, + 10_000, + ) # type: ignore[arg-type] + + assert result["locktime"] == 505 + assert result["sequence"] == 0xFFFFFFFE + assert result["output_amount_btc"] == 0.4999 + assert ( + "createrawtransaction", + [ + [{"txid": "33" * 32, "vout": 0, "sequence": 0xFFFFFFFE}], + {"bcrt1qdest": 0.4999}, + 505, + ], + None, + ) in rpc.calls + assert str(result["signed_hex"]).startswith("020000000001") + assert result["complete"] is True + assert all(call[0] not in {"dumpprivkey", "signrawtransactionwithkey"} for call in rpc.calls) + + def test_timelock_blocks_non_regtest_transaction_flow() -> None: with pytest.raises(BitScopeError) as exc_info: TimelockService(FakeRpcClient(network="mainnet")).create_locktime_transaction("demo", "bc1qdest", 0.5, 500, 1) # type: ignore[arg-type] diff --git a/backend/tests/test_transaction_lifecycle_scenario.py b/backend/tests/test_transaction_lifecycle_scenario.py new file mode 100644 index 0000000..8bac022 --- /dev/null +++ b/backend/tests/test_transaction_lifecycle_scenario.py @@ -0,0 +1,277 @@ +import json +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from app.config import Settings +from app.errors import BitScopeError +from app.models.lab import LabSession +from app.models.scenario import CleanupStatus, ScenarioRunState +from app.services.evidence_service import EvidenceService +from app.services.lab_session_store import LabSessionStore +from app.services.proof_bundle_service import ProofBundleService +from app.services.scenario_artifact_store import ScenarioArtifactStore +from app.services.scenario_catalog import DEFAULT_SCENARIO_CATALOG +from app.services.scenario_run_store import ScenarioRunStore +from app.services.scenario_service import ScenarioService + + +NOW = datetime(2026, 7, 20, 18, 0, tzinfo=UTC) +WALLET = "bitscope-session-lifecycle_session" +NORMAL_TXID = "a" * 64 +FIRST_UTXO = "b" * 64 +SECOND_UTXO = "c" * 64 + + +class LifecycleRpcClient: + def __init__( + self, + attack_reject_reason: str = "bad-txns-in-belowout", + fail_unload: bool = False, + ) -> None: + self.settings = Settings( + bitcoin_network="regtest", + bitcoin_rpc_user="lifecycle-user", + bitcoin_rpc_password="lifecycle-secret", + bitscope_local_access_token="lifecycle-token", + ) + self.attack_reject_reason = attack_reject_reason + self.fail_unload = fail_unload + self.calls: list[tuple[str, object, str | None]] = [] + self.created_transactions = 0 + + def call(self, method: str, params: object = None, wallet_name: str | None = None) -> object: + self.calls.append((method, params, wallet_name)) + if method == "getblockchaininfo": + return {"chain": "regtest"} + if method == "getnetworkinfo": + return { + "version": 280100, + "subversion": "/Satoshi:28.1.0/", + "warnings": "canary lifecycle-secret must be redacted", + } + if method == "getblockcount": + return 200 + if method == "listwallets": + return [WALLET] + if method == "getnewaddress": + label = params[0] if isinstance(params, list) else "" + return "bcrt1qmining" if label == "bitscope-lifecycle-mining" else "bcrt1qrecipient" + if method == "generatetoaddress": + count = params[0] if isinstance(params, list) else 0 + prefix = "d" if count == 102 else "e" + return [f"{index:064x}".replace("0", prefix)[:64] for index in range(int(count))] + if method == "listunspent": + return [ + {"txid": FIRST_UTXO, "vout": 0, "amount": 50.0, "confirmations": 102, "spendable": True}, + {"txid": SECOND_UTXO, "vout": 0, "amount": 50.0, "confirmations": 101, "spendable": True}, + ] + if method == "createrawtransaction": + self.created_transactions += 1 + return "00aa" if self.created_transactions == 1 else "00dd" + if method == "signrawtransactionwithwallet": + source = params[0] if isinstance(params, list) else "" + return {"hex": "00bb" if source == "00aa" else "00ee", "complete": True} + if method == "decoderawtransaction": + return {"txid": NORMAL_TXID, "vin": [], "vout": []} + if method == "testmempoolaccept": + candidate = params[0][0] if isinstance(params, list) else "" + if candidate == "00bb": + return [{"txid": NORMAL_TXID, "allowed": True, "vsize": 141}] + return [ + { + "txid": "f" * 64, + "allowed": False, + "reject-reason": self.attack_reject_reason, + } + ] + if method == "sendrawtransaction": + return NORMAL_TXID + if method == "getmempoolentry": + return {"vsize": 141, "fees": {"base": 0.0001}, "bip125-replaceable": False} + if method == "gettransaction": + return {"txid": NORMAL_TXID, "hex": "00cc", "confirmations": 1, "blockhash": "e" * 64} + if method == "unloadwallet": + if self.fail_unload: + raise BitScopeError( + "LAB_WALLET_CLEANUP_FAILED", + "Injected session wallet cleanup failure.", + 502, + ) + return None + raise AssertionError(f"Unexpected RPC method: {method}") + + +def build_service(tmp_path: Path, rpc: LifecycleRpcClient) -> tuple[ScenarioService, ScenarioRunStore, LabSessionStore]: + database = tmp_path / "labs.sqlite3" + lab_store = LabSessionStore(str(database)) + lab_store.save( + LabSession( + session_id="lifecycle_session", + wallet_name=WALLET, + owned_wallets=[WALLET], + wallet_generation=0, + runtime_chain="regtest", + starting_height=200, + status="active", + created_at=NOW, + updated_at=NOW, + ) + ) + run_store = ScenarioRunStore(str(database)) + artifact_store = ScenarioArtifactStore(str(tmp_path / "scenario-artifacts")) + service = ScenarioService( + rpc, + run_store, + DEFAULT_SCENARIO_CATALOG, + EvidenceService.from_settings(rpc.settings), + artifact_store, + lab_store, + ) + return service, run_store, lab_store + + +def test_transaction_lifecycle_runs_to_verified_bundle_and_cleans_up(tmp_path: Path) -> None: + rpc = LifecycleRpcClient() + service, run_store, lab_store = build_service(tmp_path, rpc) + created = service.create_run("transaction-lifecycle", "lifecycle_session") + ready = service.advance(created.run_id, "lifecycle_session", expected_revision=0) + + verified = service.advance(ready.run_id, "lifecycle_session", expected_revision=1) + + assert verified.current_state == ScenarioRunState.VERIFIED + assert verified.cleanup_status == CleanupStatus.COMPLETED + assert verified.revision == 5 + assert len(verified.completed_steps) == 19 + assert len(verified.assertion_results) == 4 + assert all(result.status.value == "passed" for result in verified.assertion_results) + assert [failure.code for failure in verified.expected_failures] == ["bad-txns-in-belowout"] + assert verified.unexpected_failures == [] + assert run_store.get(verified.run_id) == verified + + cleaned_session = lab_store.get("lifecycle_session") + assert cleaned_session is not None + assert cleaned_session.status == "cleaned" + assert cleaned_session.transaction_ids == [NORMAL_TXID] + assert len(cleaned_session.block_hashes) == 103 + assert ("unloadwallet", [], WALLET) in rpc.calls + + proof_service = ProofBundleService( + run_store, + service.artifact_store, + DEFAULT_SCENARIO_CATALOG, + ) + first = proof_service.bundle(verified.run_id, "lifecycle_session") + second = proof_service.bundle(verified.run_id, "lifecycle_session") + assert first.zip_bytes == second.zip_bytes + assert first.manifest.final_result.value == "verified" + assert "evidence/transaction.confirmed.json" in first.files + assert "evidence/transaction.overspend-rejection.json" in first.files + assert "evidence/attacks.summary.json" in first.files + lifecycle = json.loads(first.files["lifecycle.json"]) + event_types = [event["event_type"] for event in lifecycle["events"]] + assert "transaction_confirmed" in event_types + assert "transaction_replaced" not in event_types + assert "child_transaction_created" not in event_types + assert event_types[-1] == "scenario_cleaned_up" + attacks = json.loads(first.files["evidence/attacks.summary.json"])["core_output"]["result"] + assert [(item["attack_type"], item["status"]) for item in attacks] == [ + ("output_modification", "expected_failure") + ] + assert "assertions.json" in first.files + assert b"bad-txns-in-belowout" in first.files["assertions.json"] + assert all(b"lifecycle-secret" not in content for content in first.files.values()) + + +def test_transaction_lifecycle_fails_and_cleans_up_on_wrong_negative_result(tmp_path: Path) -> None: + rpc = LifecycleRpcClient(attack_reject_reason="missing-inputs") + service, _, lab_store = build_service(tmp_path, rpc) + created = service.create_run("transaction-lifecycle", "lifecycle_session") + ready = service.advance(created.run_id, "lifecycle_session", expected_revision=0) + + failed = service.advance(ready.run_id, "lifecycle_session", expected_revision=1) + + assert failed.current_state == ScenarioRunState.FAILED + assert failed.cleanup_status == CleanupStatus.COMPLETED + assert failed.revision == 4 + assert failed.failed_steps == ["reject_overspend"] + assert failed.unexpected_failures[0].code == "SCENARIO_NEGATIVE_ASSERTION_MISMATCH" + assert failed.unexpected_failures[0].safe_message == ( + "Bitcoin Core did not return the pinned overspend rejection expected by this scenario." + ) + assert failed.unexpected_failures[0].attack_id == "transaction-lifecycle.output-modification" + assert failed.unexpected_failures[0].raw_safe_details["reject-reason"] == "missing-inputs" + assert [reference.evidence_id for reference in failed.evidence] == [ + "node.context", + "failure.reject_overspend", + "lifecycle.cleanup", + ] + cleaned_session = lab_store.get("lifecycle_session") + assert cleaned_session is not None and cleaned_session.status == "cleaned" + + +def test_default_catalog_exposes_reviewed_transaction_lifecycle() -> None: + entry = DEFAULT_SCENARIO_CATALOG.get("transaction-lifecycle") + + assert entry.available is True + assert len(entry.definition.steps) == 19 + assert entry.definition.steps[-1].type == "cleanup_lab" + assert {assertion.assertion_id for assertion in entry.definition.assertions} == { + "preflight_accepted", + "observed_in_mempool", + "transaction_confirmed", + "overspend_rejected", + } + + +def test_transaction_lifecycle_never_verifies_when_cleanup_fails(tmp_path: Path) -> None: + rpc = LifecycleRpcClient(fail_unload=True) + service, _, lab_store = build_service(tmp_path, rpc) + created = service.create_run("transaction-lifecycle", "lifecycle_session") + ready = service.advance(created.run_id, "lifecycle_session", expected_revision=0) + + failed = service.advance(ready.run_id, "lifecycle_session", expected_revision=1) + + assert failed.current_state == ScenarioRunState.CLEANUP_FAILED + assert failed.cleanup_status == CleanupStatus.FAILED + assert failed.final_result is not None and failed.final_result.value == "cleanup_failed" + assert failed.unexpected_failures[-1].step_id == "cleanup" + persisted_session = lab_store.get("lifecycle_session") + assert persisted_session is not None and persisted_session.status == "active" + + +def test_transaction_lifecycle_cleans_up_after_evidence_checkpoint_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + rpc = LifecycleRpcClient() + service, run_store, lab_store = build_service(tmp_path, rpc) + original_save = run_store.save + + def fail_verifying(run: object, expected_revision: int) -> None: + if getattr(run, "current_state", None) == ScenarioRunState.VERIFYING: + raise BitScopeError( + "SCENARIO_RUN_REVISION_CONFLICT", + "Injected evidence checkpoint conflict.", + 409, + ) + original_save(run, expected_revision) + + monkeypatch.setattr(run_store, "save", fail_verifying) + created = service.create_run("transaction-lifecycle", "lifecycle_session") + ready = service.advance(created.run_id, "lifecycle_session", expected_revision=0) + + failed = service.advance(ready.run_id, "lifecycle_session", expected_revision=1) + + assert failed.current_state == ScenarioRunState.FAILED + assert failed.failed_steps == ["export_proof"] + assert failed.unexpected_failures[0].code == "SCENARIO_RUN_REVISION_CONFLICT" + cleaned_session = lab_store.get("lifecycle_session") + assert cleaned_session is not None and cleaned_session.status == "cleaned" + evidence_directory = service.artifact_store.root / str(failed.run_id) / "evidence" + assert sorted(path.name for path in evidence_directory.iterdir()) == [ + "failure.export_proof.json", + "lifecycle.cleanup.json", + "node.context.json", + ] diff --git a/backend/tests/test_transaction_service.py b/backend/tests/test_transaction_service.py index 96df04e..0fa96d8 100644 --- a/backend/tests/test_transaction_service.py +++ b/backend/tests/test_transaction_service.py @@ -104,7 +104,19 @@ def call(self, method: str, params: list[object] | None = None, wallet_name: str if method == "signrawtransactionwithwallet": return {"hex": "00cc", "complete": self.complete} if method == "decoderawtransaction": + if params == ["00dd"]: + return {"txid": "44" * 32, "vin": [{"sequence": 0xFFFFFFFD}], "vout": []} return {"txid": "11" * 32} + if method == "sendtoaddress": + return "44" * 32 + if method == "gettransaction": + return {"txid": "44" * 32, "hex": "00dd", "confirmations": 0} + if method == "getmempoolentry": + return { + "vsize": 141, + "fees": {"base": 0.00000282}, + "bip125-replaceable": True, + } if method == "sendrawtransaction": return "22" * 32 if method == "bumpfee": @@ -239,6 +251,27 @@ def test_bump_rbf_transaction_uses_wallet_bumpfee() -> None: assert rpc.calls[-1] == ("bumpfee", [TXID, {"fee_rate": 12.5, "conf_target": 3, "estimate_mode": "economical"}], "demo") +def test_create_rbf_transaction_sets_replaceable_and_records_live_policy() -> None: + rpc = FakeBuilderRpcClient() + + result = TransactionService(rpc).create_rbf_transaction( # type: ignore[arg-type] + "demo", + "bcrt1qdest", + 0.1, + 2.0, + ) + + assert result["txid"] == "44" * 32 + assert result["sequences"] == [0xFFFFFFFD] + assert result["signals_rbf"] is True + assert result["fee_rate_sat_vb"] == 2.0 + assert ( + "sendtoaddress", + ["bcrt1qdest", 0.1, "", "", False, True, None, "unset", None, 2.0], + "demo", + ) in rpc.calls + + def test_create_cpfp_child_builds_tests_and_broadcasts_child() -> None: rpc = FakeBuilderRpcClient() diff --git a/backend/tests/test_treasury_models.py b/backend/tests/test_treasury_models.py new file mode 100644 index 0000000..b1a2857 --- /dev/null +++ b/backend/tests/test_treasury_models.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.models.treasury import ( + TreasuryParticipant, + TreasuryParticipantGroup, + TreasuryParticipantRole, + TreasuryPolicy, +) + + +def public_key(index: int) -> str: + prefix = "02" if index % 2 else "03" + return f"{prefix}{index:064x}" + + +def signer_group(role: TreasuryParticipantRole, key_offset: int) -> TreasuryParticipantGroup: + # Deliberately supply non-canonical list order; position is the stable policy order. + return TreasuryParticipantGroup( + role=role, + participants=[ + TreasuryParticipant( + participant_id=f"{role.value}-{position}", + role=role, + position=position, + wallet_name=f"treasury-{role.value}-{position}", + public_key=public_key(key_offset + position).upper(), + ) + for position in (3, 1, 2) + ], + ) + + +def valid_policy() -> TreasuryPolicy: + return TreasuryPolicy( + recovery_delay_blocks=5, + emergency_delay_blocks=10, + operators=signer_group(TreasuryParticipantRole.OPERATOR, 0), + recovery=signer_group(TreasuryParticipantRole.RECOVERY, 10), + emergency=signer_group(TreasuryParticipantRole.EMERGENCY, 20), + ) + + +def test_policy_models_capture_the_proven_public_three_path_policy() -> None: + policy = valid_policy() + + assert policy.policy_id == "community-treasury-recovery" + assert policy.script_type == "p2wsh" + assert policy.delay_unit == "blocks" + assert policy.operators.required_signatures == 2 + assert [participant.position for participant in policy.operators.ordered_participants()] == [1, 2, 3] + assert all( + participant.public_key == participant.public_key.lower() + for participant in policy.operators.participants + ) + + +def test_participant_rejects_non_public_or_uncompressed_key_material() -> None: + with pytest.raises(ValidationError, match="public_key"): + TreasuryParticipant( + participant_id="operator-1", + role=TreasuryParticipantRole.OPERATOR, + position=1, + wallet_name="treasury-operator-1", + public_key="private-key-material", + ) + + +def test_group_requires_matching_roles_and_each_canonical_position() -> None: + group = signer_group(TreasuryParticipantRole.OPERATOR, 0) + invalid = group.model_dump() + invalid["participants"][0]["role"] = TreasuryParticipantRole.RECOVERY + invalid["participants"][1]["position"] = 3 + + with pytest.raises(ValidationError, match="match the role|positions"): + TreasuryParticipantGroup.model_validate(invalid) + + +def test_policy_requires_emergency_delay_after_recovery_delay() -> None: + invalid = valid_policy().model_dump() + invalid["emergency_delay_blocks"] = invalid["recovery_delay_blocks"] + + with pytest.raises(ValidationError, match="greater than the recovery delay"): + TreasuryPolicy.model_validate(invalid) + + +def test_policy_rejects_reused_wallets_and_public_keys_across_roles() -> None: + invalid = valid_policy().model_dump() + invalid["emergency"]["participants"][0]["wallet_name"] = invalid["operators"]["participants"][0]["wallet_name"] + invalid["emergency"]["participants"][0]["public_key"] = invalid["operators"]["participants"][0]["public_key"] + + with pytest.raises(ValidationError, match="wallets must be unique|public keys must be unique"): + TreasuryPolicy.model_validate(invalid) + + +def test_policy_rejects_delays_outside_block_based_bip68_range() -> None: + invalid = valid_policy().model_dump() + invalid["emergency_delay_blocks"] = 65_536 + + with pytest.raises(ValidationError, match="less than or equal to 65535"): + TreasuryPolicy.model_validate(invalid) + + +def test_policy_rejects_threshold_or_shape_drift_from_reviewed_version() -> None: + invalid_threshold = valid_policy().model_dump() + invalid_threshold["operators"]["required_signatures"] = 1 + with pytest.raises(ValidationError, match="Input should be 2"): + TreasuryPolicy.model_validate(invalid_threshold) + + invalid_extra = valid_policy().model_dump() + invalid_extra["unsupported_branch"] = "simulated" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + TreasuryPolicy.model_validate(invalid_extra) diff --git a/backend/tests/test_treasury_policy_service.py b/backend/tests/test_treasury_policy_service.py new file mode 100644 index 0000000..08cdfe2 --- /dev/null +++ b/backend/tests/test_treasury_policy_service.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import pytest + +from app.config import Settings +from app.errors import BitScopeError +from app.models.treasury import ( + MaterializedTreasuryPolicy, + TreasuryParticipant, + TreasuryParticipantGroup, + TreasuryParticipantRole, + TreasuryPolicy, + TreasurySpendPath, +) +from app.services.treasury_policy_service import TreasuryPolicyService + + +def public_key(index: int) -> str: + prefix = "02" if index % 2 else "03" + return f"{prefix}{index:064x}" + + +def signer_group(role: TreasuryParticipantRole, key_offset: int) -> TreasuryParticipantGroup: + return TreasuryParticipantGroup( + role=role, + participants=[ + TreasuryParticipant( + participant_id=f"{role.value}-{position}", + role=role, + position=position, + wallet_name=f"treasury-{role.value}-{position}", + public_key=public_key(key_offset + position), + ) + for position in (3, 1, 2) + ], + ) + + +def policy() -> TreasuryPolicy: + return TreasuryPolicy( + recovery_delay_blocks=5, + emergency_delay_blocks=10, + operators=signer_group(TreasuryParticipantRole.OPERATOR, 0), + recovery=signer_group(TreasuryParticipantRole.RECOVERY, 10), + emergency=signer_group(TreasuryParticipantRole.EMERGENCY, 20), + ) + + +class FakeRpcClient: + def __init__( + self, + *, + chain: str = "regtest", + descriptor_overrides: dict[str, object] | None = None, + private_keys_enabled: object = False, + import_result: object = None, + ) -> None: + self.settings = Settings(bitcoin_network="regtest") + self.chain = chain + self.descriptor_overrides = descriptor_overrides or {} + self.private_keys_enabled = private_keys_enabled + self.import_result = [{"success": True}] if import_result is None else import_result + self.calls: list[tuple[str, object, str | None]] = [] + + def call(self, method: str, params: object = None, wallet_name: str | None = None) -> object: + self.calls.append((method, params, wallet_name)) + if method == "getblockchaininfo": + return {"chain": self.chain} + if method == "getdescriptorinfo": + descriptor = params[0] if isinstance(params, list) else "" + return { + "descriptor": f"{descriptor}#deadbeef", + "checksum": "deadbeef", + "isrange": False, + "issolvable": True, + "hasprivatekeys": False, + **self.descriptor_overrides, + } + if method == "deriveaddresses": + return ["bcrt1qtreasurypolicy"] + if method == "getwalletinfo": + return {"private_keys_enabled": self.private_keys_enabled} + if method == "importdescriptors": + return self.import_result + raise AssertionError(f"Unexpected RPC method: {method}") + + +def materialize(rpc: FakeRpcClient) -> MaterializedTreasuryPolicy: + return TreasuryPolicyService(rpc).materialize(policy()) # type: ignore[arg-type] + + +def test_materialize_builds_the_canonical_descriptor_and_decision_tree() -> None: + rpc = FakeRpcClient() + + result = materialize(rpc) + + ordered_operator_keys = ",".join(public_key(index) for index in (1, 2, 3)) + ordered_recovery_keys = ",".join(public_key(index) for index in (11, 12, 13)) + ordered_emergency_keys = ",".join(public_key(index) for index in (21, 22, 23)) + expected_miniscript = ( + f"or_i(multi(2,{ordered_operator_keys})," + f"or_i(and_v(v:older(5),multi(2,{ordered_recovery_keys}))," + f"and_v(v:older(10),multi(2,{ordered_emergency_keys}))))" + ) + + assert result.miniscript == expected_miniscript + assert result.descriptor == f"wsh({expected_miniscript})" + assert result.normalized_descriptor == f"{result.descriptor}#deadbeef" + assert result.address == "bcrt1qtreasurypolicy" + assert [branch.path for branch in result.decision_tree.branches] == [ + TreasurySpendPath.IMMEDIATE, + TreasurySpendPath.RECOVERY, + TreasurySpendPath.EMERGENCY, + ] + assert [branch.relative_delay_blocks for branch in result.decision_tree.branches] == [None, 5, 10] + assert result.decision_tree.branches[0].participant_ids == ["operator-1", "operator-2", "operator-3"] + assert [call[0] for call in rpc.calls] == [ + "getblockchaininfo", + "getdescriptorinfo", + "deriveaddresses", + ] + + +@pytest.mark.parametrize( + ("overrides", "expected_code"), + [ + ({"hasprivatekeys": True}, "TREASURY_POLICY_PRIVATE_KEYS_DETECTED"), + ({"issolvable": False}, "TREASURY_POLICY_UNSOLVABLE"), + ({"isrange": True}, "TREASURY_POLICY_UNEXPECTED_RANGE"), + ], +) +def test_materialize_fails_closed_when_core_does_not_confirm_policy_properties( + overrides: dict[str, object], + expected_code: str, +) -> None: + rpc = FakeRpcClient(descriptor_overrides=overrides) + + with pytest.raises(BitScopeError) as exc_info: + materialize(rpc) + + assert exc_info.value.code == expected_code + assert [call[0] for call in rpc.calls] == ["getblockchaininfo", "getdescriptorinfo"] + + +def test_materialize_is_regtest_only() -> None: + rpc = FakeRpcClient(chain="main") + + with pytest.raises(BitScopeError) as exc_info: + materialize(rpc) + + assert exc_info.value.code == "BITCOIN_NETWORK_MISMATCH" + assert [call[0] for call in rpc.calls] == ["getblockchaininfo"] + + +def test_import_accepts_only_a_non_signing_coordinator_and_rechecks_regtest() -> None: + rpc = FakeRpcClient() + service = TreasuryPolicyService(rpc) # type: ignore[arg-type] + materialized = service.materialize(policy()) + rpc.calls.clear() + + result = service.import_into_coordinator(materialized, "treasury-coordinator") + + assert result.imported is True + assert result.coordinator_can_sign is False + assert [call[0] for call in rpc.calls] == [ + "getwalletinfo", + "getblockchaininfo", + "importdescriptors", + ] + assert rpc.calls[-1] == ( + "importdescriptors", + [[{ + "desc": materialized.normalized_descriptor, + "timestamp": "now", + "active": False, + "label": "community-treasury-recovery", + }]], + "treasury-coordinator", + ) + + +def test_import_rejects_a_coordinator_with_private_keys_before_mutation() -> None: + rpc = FakeRpcClient(private_keys_enabled=True) + service = TreasuryPolicyService(rpc) # type: ignore[arg-type] + materialized = service.materialize(policy()) + rpc.calls.clear() + + with pytest.raises(BitScopeError) as exc_info: + service.import_into_coordinator(materialized, "treasury-coordinator") + + assert exc_info.value.code == "TREASURY_COORDINATOR_CAN_SIGN" + assert [call[0] for call in rpc.calls] == ["getwalletinfo"] + + +def test_import_rejects_invalid_coordinator_or_label_before_rpc() -> None: + rpc = FakeRpcClient() + service = TreasuryPolicyService(rpc) # type: ignore[arg-type] + materialized = service.materialize(policy()) + rpc.calls.clear() + + with pytest.raises(BitScopeError) as invalid_wallet: + service.import_into_coordinator(materialized, "../not-session-owned") + assert invalid_wallet.value.code == "INVALID_TREASURY_COORDINATOR" + + with pytest.raises(BitScopeError) as invalid_label: + service.import_into_coordinator(materialized, "treasury-coordinator", label="unsafe\nlabel") + assert invalid_label.value.code == "INVALID_TREASURY_POLICY_LABEL" + assert rpc.calls == [] + + +def test_import_surfaces_a_bounded_core_failure_without_private_material() -> None: + rpc = FakeRpcClient(import_result=[{"success": False, "error": {"code": -5, "message": "Invalid descriptor"}}]) + service = TreasuryPolicyService(rpc) # type: ignore[arg-type] + materialized = service.materialize(policy()) + + with pytest.raises(BitScopeError) as exc_info: + service.import_into_coordinator(materialized, "treasury-coordinator") + + assert exc_info.value.code == "TREASURY_POLICY_IMPORT_FAILED" + assert exc_info.value.details == { + "coordinator_wallet": "treasury-coordinator", + "rpc_code": -5, + "rpc_message": "Invalid descriptor", + } diff --git a/capstone.md b/capstone.md new file mode 100644 index 0000000..d4a80ca --- /dev/null +++ b/capstone.md @@ -0,0 +1,1555 @@ +# BitScope Capstone Expansion Master Prompt + +You are working on the following repository: + +`https://github.com/comwanga/BitScope` + +Your objective is to evolve BitScope into: + +> **BitScope is a reproducible Bitcoin protocol laboratory that constructs, executes, attacks and verifies Bitcoin transactions against a real Bitcoin Core node.** + +This is an expansion of the existing BitScope architecture, not a rewrite. + +BitScope already contains a Python/FastAPI backend, Next.js/TypeScript frontend, direct Bitcoin Core JSON-RPC integration, Bitcoin Core regtest workflows, wallet operations, blocks, transactions, mempool, RBF, CPFP, multisig, PSBT, timelocks, descriptors, Taproot, scripts, OP_RETURN, persistent lab sessions, safety guards, Docker setup and real-node CI. + +Inspect the repository before making assumptions. Reuse existing models, services, routes, components, safety boundaries, tests and documentation wherever possible. + +The final project must demonstrate that BitScope can: + +1. Construct Bitcoin transactions and spending policies. +2. Execute valid transaction paths. +3. Attempt deliberately invalid or adversarial transaction paths. +4. Verify results using a real Bitcoin Core node. +5. Explain the relevant Bitcoin Core RPC calls and `bitcoin-cli` commands. +6. Export reproducible evidence showing exactly what happened. +7. Distinguish consensus rejection, script failure, timelock failure and mempool-policy rejection. +8. Let another developer reproduce the same experiment locally. + +--- + +# 1. Core operating rules + +## 1.1 Do not hallucinate + +Never assume that a route, service, model, dependency, Bitcoin Core RPC method, descriptor feature or frontend component exists. + +Before using anything: + +* Search the repository. +* Read the implementation. +* Read the existing tests. +* Check the pinned Bitcoin Core version. +* Check official Bitcoin Core behaviour when necessary. +* Run a small proof of concept against regtest when documentation is not enough. + +When uncertain, verify with code or a real-node test instead of guessing. + +Do not claim that a feature works until it has passed the relevant tests. + +## 1.2 Preserve the existing architecture + +Keep the current general architecture unless the repository provides strong evidence that a change is necessary: + +* FastAPI backend. +* Pydantic models. +* Bitcoin-aware service layer. +* Explicit RPC capability clients. +* Next.js and TypeScript frontend. +* Local-first deployment. +* Bitcoin Core as the source of truth. +* Docker-based regtest support. +* Unit tests and live Bitcoin Core integration tests. + +Do not replace the application with a new framework. + +Do not create a second parallel architecture for the same functions. + +## 1.3 Preserve the BitScope safety model + +The following rules are non-negotiable: + +* No hosted blockchain APIs. +* No third-party address-history APIs. +* No seed phrase collection. +* No WIF private-key input. +* No xprv input. +* No wallet password or hardware-wallet PIN input. +* No browser exposure of Bitcoin Core RPC credentials. +* No mainnet signing, spending, mining or broadcasting. +* State-changing operations must verify the live chain reported by Bitcoin Core. +* State-changing operations must remain restricted to regtest. +* State-changing routes must retain token and origin protection. +* Services must use the least-powerful RPC capability available. +* Forbidden Bitcoin Core RPC methods must remain forbidden. +* Sensitive values must never be written to reports, logs, screenshots or frontend responses. +* A scenario must fail closed when the runtime network cannot be verified. + +## 1.4 Avoid unnecessary scope + +Do not add: + +* Lightning. +* Hosted user accounts. +* Cloud-based custody. +* Mainnet wallets. +* Exchange prices. +* AI chat as a core feature. +* A public blockchain indexer. +* Seed management. +* Private-key export. +* Unrelated Bitcoin dashboards. +* Arbitrary RPC access beyond the existing safe RPC model. + +The objective is deeper protocol verification, not more unrelated pages. + +--- + +# 2. Development and commit rules + +## 2.1 Branching + +Read `CONTRIBUTING.md` and inspect the existing branch and pull-request conventions. + +Create a focused feature branch using the repository’s convention. A suitable name, when consistent with the repository, would be: + +`feature/verified-scenarios` + +Do not work directly on `main`. + +## 2.2 Commit style + +Make small, logically complete commits. + +Every commit message must: + +* Be written in simple English. +* Remain technically accurate. +* Use the imperative form. +* Describe one clear change. +* Avoid unnecessary jargon. +* Avoid vague words such as “stuff”, “misc”, “update things”, “final fix” or “improve app”. + +Good examples: + +* `Add verified scenario models` +* `Record Bitcoin Core evidence for scenario runs` +* `Verify premature CSV spends are rejected` +* `Add the community treasury recovery scenario` +* `Show transaction lifecycle events in the UI` +* `Add challenge validation for RBF transactions` +* `Document the verified scenario format` +* `Test scenario cleanup against Bitcoin Core` + +Bad examples: + +* `WIP` +* `More fixes` +* `Update project` +* `Final changes` +* `Improve scenarios` +* `Refactor stuff` + +Only commit when the logical change passes its relevant tests. + +A commit body may be used when necessary. Write it in clear English and explain: + +* Why the change was needed. +* Important technical decisions. +* Tests that were run. + +## 2.3 Phase gates + +Do not proceed to the next phase until the current phase meets its completion criteria. + +When a phase is complete: + +1. Run all relevant tests. +2. Review the diff. +3. Confirm that no safety invariant was weakened. +4. Update the implementation plan. +5. Commit the completed logical changes. +6. Record any limitation honestly. + +If a phase uncovers an architectural blocker, stop that phase, document the evidence and implement the smallest justified prerequisite before continuing. + +--- + +# Phase 0 — Audit the current repository + +Do not implement new features during the initial audit. + +## Tasks + +1. Read: + + * `README.md` + * `CONTRIBUTING.md` + * `docs/architecture.md` + * `docs/limitations.md` + * `docs/demo-script.md` + * `docs/live-rpc-testing.md` + * `docs/supported-bitcoin-core.md` + * Existing scenario, session, transaction, PSBT, multisig, timelock and script-related files. + +2. Inspect: + + * Backend routes. + * Backend services. + * Pydantic models. + * RPC capability restrictions. + * Mutation authentication. + * Network safety checks. + * Persistent lab sessions. + * Frontend API client. + * Shared learning components. + * Current frontend routes. + * CI workflows. + * Unit tests. + * Live Bitcoin Core tests. + +3. Run the current baseline: + + * Backend test suite. + * Frontend build. + * Docker Compose configuration validation. + * Existing live Bitcoin Core integration tests where supported. + +4. Create: + +`docs/capstone-expansion-plan.md` + +The plan must include: + +* Current implemented capabilities. +* Existing components that will be reused. +* Missing capabilities. +* Duplicate functionality that must not be added. +* Proposed data model. +* Proposed API changes. +* Proposed frontend pages and components. +* Proposed test strategy. +* Safety risks. +* Migration risks. +* A phase-by-phase implementation checklist. + +5. Identify whether the current multisig implementation depends on legacy BDB behaviour or deprecated Bitcoin Core compatibility. + +Do not remove working compatibility merely to modernise it. + +If a descriptor-wallet approach can replace a legacy dependency safely, document the proposed migration and prove it in an isolated test before changing the implementation. + +## Phase 0 completion criteria + +Do not proceed until: + +* Existing backend tests have been run. +* Existing frontend build has been run. +* Existing Docker configuration has been validated. +* Existing live-node test status has been recorded. +* The current architecture has been mapped. +* `docs/capstone-expansion-plan.md` exists. +* The plan identifies exactly which existing services and models will be reused. +* No new production feature has been added. +* Any pre-existing test failure is documented separately from new work. + +Suggested commit: + +`Document the BitScope capstone expansion plan` + +--- + +# Phase 1 — Build the Verified Scenarios domain model + +Create a first-class Verified Scenarios system. + +A Verified Scenario is a deterministic Bitcoin protocol experiment containing: + +* Scenario metadata. +* Preconditions. +* Setup operations. +* Bitcoin Core actions. +* Expected successful outcomes. +* Expected failure outcomes. +* Evidence requirements. +* Cleanup requirements. +* Final verification status. + +## Required scenario concepts + +Create clear backend domain models for: + +### Scenario definition + +Include fields such as: + +* Scenario identifier. +* Version. +* Name. +* Summary. +* Difficulty. +* Related LBCLI chapters. +* Related Bitcoin concepts. +* Required network. +* Required Bitcoin Core capabilities. +* Estimated run time expressed as steps, not an invented clock duration. +* Setup steps. +* Execution steps. +* Attack or negative-test steps. +* Verification assertions. +* Cleanup rules. + +### Scenario run + +Include: + +* Unique run identifier. +* Scenario identifier and version. +* Lab session identifier. +* Runtime chain. +* Bitcoin Core version when available. +* Start state. +* Current state. +* Completed steps. +* Failed steps. +* Expected failures. +* Unexpected failures. +* Evidence references. +* Cleanup status. +* Final result. + +### Scenario step + +Support explicit step types rather than arbitrary code execution. + +Examples: + +* Verify runtime chain. +* Create or load an isolated wallet. +* Generate an address. +* Mine blocks. +* Select UTXOs. +* Create a raw transaction. +* Create a PSBT. +* Process a PSBT. +* Finalize a PSBT. +* Decode a transaction. +* Run `testmempoolaccept`. +* Broadcast a transaction. +* Query a mempool entry. +* Mine confirmation blocks. +* Advance a relative timelock. +* Advance an absolute timelock. +* Assert transaction state. +* Assert expected Bitcoin Core rejection. +* Export evidence. +* Clean up the lab. + +Do not create a scenario format that permits arbitrary shell commands or unrestricted RPC calls. + +All operations must pass through existing BitScope service and capability boundaries. + +### Verification assertion + +Support assertions such as: + +* RPC call succeeded. +* RPC call failed with the expected error category. +* Transaction entered the mempool. +* Transaction did not enter the mempool. +* Transaction was confirmed. +* Transaction was replaced. +* Child transaction spent the intended parent output. +* PSBT is complete. +* PSBT remains incomplete. +* Required signature count was met. +* Required signature count was not met. +* Timelock is mature. +* Timelock is immature. +* Mempool policy accepted or rejected the transaction. +* An output script matches the expected script. +* An output amount matches the expected amount. +* Fee rate meets an explicitly configured threshold. + +Do not hardcode transaction IDs, addresses or block hashes across separate runs. + +## Storage + +Integrate scenario runs with the existing persistent lab-session architecture. + +Use the existing SQLite approach unless the repository audit proves another implementation is already preferred. + +Store metadata and evidence references without exposing private material. + +## API + +Add a cohesive API surface following existing route conventions. + +A reasonable shape is: + +* `GET /api/scenarios` +* `GET /api/scenarios/{scenario_id}` +* `POST /api/scenarios/{scenario_id}/runs` +* `GET /api/scenario-runs/{run_id}` +* `POST /api/scenario-runs/{run_id}/advance` +* `POST /api/scenario-runs/{run_id}/reset` +* `GET /api/scenario-runs/{run_id}/evidence` +* `GET /api/scenario-runs/{run_id}/report` +* `DELETE /api/scenario-runs/{run_id}?confirm=true` + +Adapt names when necessary to remain consistent with the existing project. + +Mutation routes must use the existing mutation-access protection. + +## Tests + +Add unit tests for: + +* Model validation. +* Invalid scenario definitions. +* Unsupported step types. +* Invalid network requirements. +* State transitions. +* Expected failures. +* Unexpected failures. +* Duplicate step execution. +* Incomplete cleanup. +* Session ownership. +* Evidence references. +* Secret redaction. + +## Phase 1 completion criteria + +Do not proceed until: + +* Scenario definitions are typed and validated. +* Scenario runs have an explicit state machine. +* Arbitrary RPC execution is impossible through scenario definitions. +* Persistent storage is integrated. +* Mutation protections are applied. +* Unit tests cover valid and invalid transitions. +* Existing tests still pass. +* Frontend build still passes, even if the frontend has not yet exposed scenarios. +* Architecture documentation has been updated. + +Suggested commits: + +* `Add verified scenario models` +* `Store verified scenario runs` +* `Protect verified scenario mutations` + +--- + +# Phase 2 — Add reproducible evidence collection + +A Verified Scenario must produce evidence, not merely a success message. + +## Required evidence + +Record, where relevant: + +* Scenario identifier and version. +* Run identifier. +* Lab session identifier. +* Runtime network. +* Configured network. +* Bitcoin Core version. +* Block height before and after the run. +* Wallet names created specifically for the run. +* Safe public addresses. +* UTXOs used. +* Raw unsigned transaction hex. +* Signed transaction hex. +* PSBT states. +* Decoded transactions. +* Transaction IDs. +* Fees. +* Fee rates. +* Input sequence values. +* Transaction locktime. +* ScriptPubKeys. +* Witness information when safe. +* `testmempoolaccept` results. +* Mempool entries. +* Confirmation block hashes. +* Equivalent `bitcoin-cli` commands. +* RPC methods and safe parameters. +* Bitcoin Core error codes and messages. +* Step assertions. +* Cleanup result. + +## Evidence rules + +* Never include RPC credentials. +* Never include the local access token. +* Never include wallet passphrases. +* Never include private keys. +* Never include seed material. +* Redact environment values that may contain secrets. +* Preserve technical details necessary to reproduce the experiment. +* Clearly label generated values that will differ on another run. +* Clearly distinguish raw Bitcoin Core output from BitScope interpretation. + +## Export format + +Produce an evidence bundle comparable to: + +```text +bitscope-proof/ +├── manifest.json +├── scenario.json +├── report.md +├── commands.sh +├── rpc-transcript.json +├── node-context.json +├── assertions.json +├── transactions/ +│ ├── funding.hex +│ ├── candidate.hex +│ └── confirmed.hex +└── psbts/ + ├── unsigned.psbt + ├── partially-signed.psbt + └── finalized.psbt +``` + +Only create files that are relevant to the scenario. + +The manifest must include hashes of the exported files so accidental modification is detectable. + +Do not describe the bundle as cryptographically trusted or independently attested unless an actual cryptographic signing system is implemented and tested. + +## Human-readable report + +Generate a Markdown report containing: + +* Scenario objective. +* Runtime context. +* Actions performed. +* Successful assertions. +* Expected failures. +* Unexpected failures. +* Transaction summary. +* Script and timelock summary. +* Mempool-policy summary. +* Cleanup result. +* Overall status. +* Reproduction instructions. +* Known limitations. + +Use statuses such as: + +* `VERIFIED` +* `VERIFIED WITH WARNINGS` +* `FAILED` +* `INCOMPLETE` +* `CLEANUP FAILED` + +Do not report `VERIFIED` when an expected assertion was skipped. + +## Phase 2 completion criteria + +Do not proceed until: + +* Evidence is captured through a reusable service. +* Export files are deterministic in structure. +* Sensitive values are redacted. +* File hashes are included in the manifest. +* Reports distinguish expected and unexpected failures. +* Reports distinguish Bitcoin Core output from BitScope explanations. +* Export tests verify contents and redaction. +* A simple existing workflow can produce a complete evidence bundle. +* All previous tests still pass. + +Suggested commits: + +* `Record evidence for verified scenario runs` +* `Export reproducible scenario proof bundles` +* `Redact secrets from scenario evidence` + +--- + +# Phase 3 — Implement the foundational Verified Scenarios + +Implement a small, high-quality scenario catalogue before the flagship scenario. + +Do not create many shallow scenarios. + +Each scenario must: + +* Use a real Bitcoin Core regtest node in integration testing. +* Create isolated wallets or sessions. +* Avoid stale addresses and transaction IDs. +* Clean up after itself. +* Include positive verification. +* Include at least one negative verification where technically meaningful. +* Export evidence. + +## Scenario 1: Transaction lifecycle + +Demonstrate: + +1. Wallet creation or isolated wallet loading. +2. Mining enough blocks for mature coinbase funds. +3. Address generation. +4. UTXO selection. +5. Transaction construction. +6. Signing. +7. `testmempoolaccept`. +8. Broadcasting. +9. Mempool inspection. +10. Confirmation. +11. Final transaction decoding. + +Negative test: + +* Attempt a technically valid but intentionally altered transaction state that Bitcoin Core should reject. +* Select the test based on actual Bitcoin Core behaviour and verify the expected rejection in a proof-of-concept test. + +## Scenario 2: RBF replacement + +Demonstrate: + +1. Create an opt-in RBF transaction. +2. Record its sequence values. +3. Broadcast it. +4. Inspect its mempool entry. +5. Create a higher-fee replacement. +6. Verify the original transaction was replaced. +7. Confirm the replacement. + +Negative tests: + +* Attempt replacement without a sufficient fee increase. +* Verify the node’s actual rejection reason. +* Do not invent policy rules from memory. + +## Scenario 3: CPFP rescue + +Demonstrate: + +1. Create a low-fee parent transaction. +2. Create a child spending an eligible parent output. +3. Calculate parent, child and package fee rates. +4. Broadcast or preflight the transactions in the correct order supported by the pinned Bitcoin Core version. +5. Confirm the package. + +Negative tests: + +* Attempt to spend an unavailable parent output. +* Attempt a child whose combined economics do not satisfy the scenario’s configured goal. +* Clearly separate BitScope’s educational threshold from Bitcoin Core’s actual acceptance rules. + +## Scenario 4: Multisig PSBT + +Demonstrate: + +1. Create an isolated multisig setup. +2. Fund the multisig output. +3. Create a PSBT spending it. +4. Sign with fewer than the required participants. +5. Verify that the PSBT remains incomplete. +6. Add the required signature. +7. Finalize and broadcast. +8. Confirm the transaction. + +Negative tests: + +* Insufficient signatures. +* Modified output after signing, when the selected sighash behaviour makes that modification invalid. +* Verify actual behaviour rather than assuming every modification invalidates every signature. + +## Scenario 5: Timelocked spend + +Demonstrate either CLTV or CSV first, using the most reliable path supported by the existing implementation. + +Include: + +1. Transaction or script construction. +2. Relevant locktime or sequence values. +3. Premature spend attempt. +4. Bitcoin Core rejection. +5. Block advancement. +6. Mature spend attempt. +7. Successful broadcast and confirmation. + +Negative tests: + +* Incorrect sequence configuration. +* Incorrect locktime configuration. +* Premature execution. + +## Scenario 6: OP_RETURN policy test + +Demonstrate: + +1. Construct an OP_RETURN transaction. +2. Show the encoded payload. +3. Decode the resulting output. +4. Use `testmempoolaccept`. +5. Broadcast and confirm the valid transaction. + +Negative test: + +* Create a payload or output form that violates the current node’s standardness rules. +* Derive the test from actual Bitcoin Core behaviour. + +## Phase 3 completion criteria + +Do not proceed until: + +* At least four foundational scenarios are complete. +* Transaction lifecycle, RBF, multisig PSBT and one timelock scenario are mandatory. +* Every completed scenario runs against a real regtest node. +* Every completed scenario exports evidence. +* Every completed scenario has at least one meaningful negative test. +* Failure messages preserve Bitcoin Core error details safely. +* Scenario cleanup is verified. +* Live-node integration tests cover the complete lifecycle. +* Documentation explains how to author additional scenarios. + +Suggested commits: + +* `Add the transaction lifecycle scenario` +* `Verify RBF replacement behaviour` +* `Add the CPFP rescue scenario` +* `Verify multisig PSBT completion` +* `Add the timelocked spend scenario` +* `Test OP_RETURN policy limits` + +--- + +# Phase 4 — Add the flagship Community Treasury Recovery scenario + +Implement the main capstone scenario: + +## Community Treasury Recovery + +The scenario should model a community treasury with: + +* A normal spending path. +* A delayed recovery path. +* An optional longer-delay emergency path when it can be implemented safely and verified. + +### Intended policy + +A preferred policy is: + +* Any 2 of 3 treasury operators may spend immediately. +* After a configurable relative delay, a recovery group may spend. +* After a longer configurable delay, an emergency recovery key or threshold may spend. + +Do not force this exact script structure without first proving that it is compatible with: + +* The pinned Bitcoin Core version. +* The current descriptor or script capabilities. +* The existing wallet architecture. +* PSBT signing. +* The current Python dependencies. +* Real regtest execution. + +## Required research step + +Before implementing the full scenario: + +1. Determine whether Bitcoin Core 28.1 supports the required descriptor or Miniscript expression for this policy. +2. Build a minimal isolated proof of concept. +3. Verify address derivation. +4. Verify funding. +5. Verify PSBT construction. +6. Verify signing by the required participants. +7. Verify the immediate path. +8. Verify the delayed path. +9. Verify the premature path fails. +10. Document any unsupported policy branch. + +If the three-path policy is not reliable with the current stack, implement a high-quality two-path version first: + +* 2-of-3 immediate spend. +* Delayed recovery threshold. + +Do not simulate an unsupported emergency branch. + +## Key handling + +Do not export private keys. + +Use isolated regtest wallets or descriptor-wallet mechanisms so each signer can participate through Bitcoin Core wallet functionality. + +Do not combine all signing authority into one hidden application key merely to make the demonstration easy. + +The report must explain the educational signer model and its limitations. + +## Required execution + +The scenario must: + +1. Verify the runtime network. +2. Start an isolated lab session. +3. Create participant wallets or signer contexts. +4. Generate public keys or descriptors safely. +5. Construct the treasury policy. +6. Show the policy as a decision tree. +7. Generate the treasury address. +8. Fund the treasury. +9. Build the normal-spend PSBT. +10. Sign with an insufficient number of signers. +11. Verify the PSBT remains incomplete. +12. Complete the required signing threshold. +13. Finalize the normal-spend transaction. +14. Preflight it with Bitcoin Core. +15. Broadcast and confirm it. +16. Recreate or fund the policy for the recovery test. +17. Attempt the recovery path before maturity. +18. Capture the actual Bitcoin Core rejection. +19. Advance the chain until the delay matures. +20. Complete the recovery signatures. +21. Broadcast and confirm the recovery transaction. +22. Export the complete Proof of Spendability bundle. +23. Clean up all test-owned resources. + +## Required attacks + +Include as many of the following as can be correctly implemented and verified: + +* Insufficient normal-path signatures. +* Insufficient recovery-path signatures. +* Recovery before CSV maturity. +* Incorrect sequence value. +* Incorrect locktime when CLTV is used. +* Modified transaction output after signing. +* Incorrect witness branch selection. +* Invalid or incomplete PSBT finalization. +* Dust output. +* Fee below the scenario’s explicitly configured minimum. +* Runtime network mismatch. +* Attempt to run the scenario outside regtest. + +Each attack must have: + +* A technical explanation. +* The exact expected category of failure. +* The actual Bitcoin Core or BitScope result. +* A pass only when rejection occurs for the expected reason. + +## Proof of Spendability report + +The report should contain a summary similar to: + +```text +Scenario: Community Treasury Recovery +Result: VERIFIED + +Runtime network: regtest +Bitcoin Core compatibility: verified + +Normal 2-of-3 spend: PASS +Insufficient signature attempt: REJECTED AS EXPECTED +Premature recovery attempt: REJECTED AS EXPECTED +Mature recovery path: PASS +Modified signed transaction: REJECTED AS EXPECTED +Mempool acceptance: PASS +Cleanup: PASS +``` + +Do not mark the scenario verified when: + +* A required branch was skipped. +* A negative test failed for an unrelated reason. +* Cleanup failed. +* Runtime network verification was missing. +* Evidence export was incomplete. + +## Phase 4 completion criteria + +Do not proceed until: + +* The final policy is documented. +* A proof-of-concept test confirms compatibility. +* Immediate spending is verified. +* Delayed recovery is verified. +* Premature recovery is rejected for the expected reason. +* Insufficient signatures are rejected or remain incomplete as expected. +* The policy decision tree is generated from real policy data. +* A complete Proof of Spendability bundle is exported. +* The scenario runs in live-node CI. +* Unsupported branches are documented honestly. +* No private material appears in evidence. + +Suggested commits: + +* `Prove the treasury policy on regtest` +* `Add the community treasury recovery scenario` +* `Verify delayed treasury recovery` +* `Export the treasury proof of spendability` + +--- + +# Phase 5 — Build the transaction attack and verification framework + +Generalise negative testing without allowing unsafe arbitrary execution. + +## Attack categories + +Create typed attack definitions for: + +* Signature insufficiency. +* PSBT incompleteness. +* Output modification. +* Input modification. +* Sequence modification. +* Locktime modification. +* Premature timelock execution. +* Invalid script branch. +* Dust output. +* Fee-policy failure. +* Missing parent transaction. +* Double-spend attempt. +* RBF replacement-policy failure. +* Runtime network mismatch. + +Not every attack applies to every transaction. + +The framework must declare applicability before attempting an attack. + +## Failure classification + +Classify outcomes into categories such as: + +* BitScope validation rejection. +* Runtime network safety rejection. +* Bitcoin Core RPC parameter rejection. +* Script verification failure. +* Consensus validation failure. +* Mempool-policy rejection. +* PSBT incomplete. +* Transaction replaced. +* Transaction conflict. +* Unexpected application failure. + +Do not classify an error only by matching a human-readable string when a reliable structured code or result is available. + +Preserve the raw safe error information. + +## Phase 5 completion criteria + +Do not proceed until: + +* Attacks are typed. +* Unsupported attacks are skipped with an explicit reason. +* Expected failures and unexpected failures are distinct. +* Bitcoin Core rejection details are preserved. +* At least four attack types are reused across multiple scenarios. +* Unit and live-node tests cover classification. +* The flagship scenario uses the general framework instead of custom one-off error logic. + +Suggested commits: + +* `Add typed transaction attacks` +* `Classify Bitcoin Core rejection results` +* `Reuse attack checks across verified scenarios` + +--- + +# Phase 6 — Add the transaction lifecycle recorder + +Create a reusable lifecycle recorder that shows how a Bitcoin transaction changes over time. + +## Required lifecycle events + +Support events such as: + +* Wallet prepared. +* UTXO selected. +* Raw transaction created. +* Transaction funded. +* PSBT created. +* PSBT partially signed. +* PSBT completed. +* Transaction finalized. +* Mempool preflight completed. +* Transaction broadcast. +* Transaction entered the mempool. +* Transaction replaced. +* Child transaction created. +* Transaction confirmed. +* Timelock matured. +* Scenario cleaned up. + +Each event should contain: + +* Timestamp. +* Step identifier. +* Transaction ID when available. +* Relevant transaction hex reference. +* Relevant PSBT reference. +* Fee. +* Fee rate. +* Locktime. +* Sequence values. +* Mempool relationship data. +* Block height. +* Explanation. +* Equivalent RPC method. +* Equivalent `bitcoin-cli` command. + +## Frontend + +Create a timeline or state-flow view. + +A suitable flow is: + +```text +UTXO selected + ↓ +Transaction created + ↓ +Transaction funded + ↓ +Transaction signed + ↓ +Mempool preflight + ↓ +Broadcast + ↓ +Mempool + ↓ +Replacement or child action + ↓ +Confirmation +``` + +The UI must allow users to inspect: + +* Human explanation. +* RPC details. +* Raw safe Bitcoin Core result. +* Transaction state. +* Evidence artifact. + +## Phase 6 completion criteria + +Do not proceed until: + +* Lifecycle events come from backend scenario data. +* The frontend does not invent missing states. +* RBF replacement is shown clearly. +* CPFP parent-child relationships are shown clearly. +* Timelock maturity is shown clearly. +* The view works for the flagship scenario. +* Frontend type checking and build pass. +* Backend tests still pass. + +Suggested commits: + +* `Record transaction lifecycle events` +* `Show verified scenario timelines` +* `Display RBF and CPFP relationships` + +--- + +# Phase 7 — Add curriculum mapping and Challenge Mode + +## Curriculum mapping + +Add a curriculum page that maps BitScope to Learning Bitcoin from the Command Line. + +At minimum, map: + +* Chapters 3–4: wallets and transactions. +* Chapter 5: RBF and CPFP. +* Chapter 6: multisig. +* Chapter 7: PSBT. +* Chapter 8: locktime and OP_RETURN. +* Chapters 9–10: Script and P2SH/P2WSH. +* Chapter 11: CLTV and CSV. +* Chapter 12: conditionals and advanced Script operations. +* Chapter 13: real Bitcoin Script design and verified policy scenarios. + +Each curriculum entry must contain: + +* Learning objective. +* Relevant BitScope pages. +* Relevant Verified Scenarios. +* Core RPC methods. +* Prerequisites. +* Guided exercise. +* Independent challenge. +* Verification criteria. + +Do not reproduce copyrighted course text unnecessarily. Summarise concepts and link to the original material. + +## Challenge Mode + +Create challenges where the learner must complete an objective without immediately receiving the full solution. + +Examples: + +* Create an opt-in RBF transaction. +* Replace it with a higher-fee transaction. +* Rescue a low-fee transaction with CPFP. +* Complete a 2-of-3 PSBT. +* Demonstrate a premature CSV failure. +* Create an OP_RETURN output within policy limits. +* Diagnose a `testmempoolaccept` rejection. +* Complete the treasury recovery scenario. + +Challenge Mode must: + +* Provide the objective. +* State allowed actions. +* Hide the solution initially. +* Validate the result using Bitcoin Core. +* Provide hints progressively. +* Explain the final result. +* Export completion evidence. + +Do not validate a challenge only through frontend state. + +## Phase 7 completion criteria + +Do not proceed until: + +* Curriculum mapping covers Chapters 3–13. +* Each chapter mapping points to real implemented features. +* At least four challenges are implemented. +* Challenge validation uses backend and Bitcoin Core evidence. +* Solutions are not shown before the learner requests them or completes the task. +* Challenge results are exportable. +* Accessibility and keyboard navigation are checked. + +Suggested commits: + +* `Map BitScope labs to LBCLI chapters` +* `Add verified Bitcoin challenges` +* `Validate challenge results with Bitcoin Core` + +--- + +# Phase 8 — Add policy comparison + +Add a focused comparison tool for Bitcoin spending policies. + +## Comparison fields + +Where technically supported, compare: + +* Output type. +* Required signatures. +* Total possible signers. +* Spending branches. +* Relative delays. +* Absolute delays. +* Script size. +* Estimated witness size. +* Estimated transaction weight. +* Estimated fee at a user-selected fee rate. +* Recovery options. +* Failure conditions. +* On-chain distinguishability. +* Privacy considerations. +* Operational complexity. +* Hardware-wallet or PSBT requirements. + +Clearly label values as: + +* Exact. +* Derived. +* Estimated. +* Unknown. +* Unsupported. + +Do not show meaningful regtest fee-market estimates when none exist. + +Allow a learner to compare examples such as: + +* Standard 2-of-3 multisig. +* 2-of-3 multisig with delayed recovery. +* Immediate key path versus delayed script path when Taproot support is implemented and verified. +* CLTV refund versus CSV refund. + +## Phase 8 completion criteria + +Do not proceed until: + +* Comparison calculations have unit tests. +* Exact and estimated values are clearly separated. +* Unsupported values are not invented. +* The treasury policy can be compared with a simple 2-of-3 policy. +* The comparison links to relevant scenarios. +* Frontend build passes. + +Suggested commits: + +* `Compare Bitcoin spending policies` +* `Label exact and estimated policy metrics` + +--- + +# Phase 9 — Add Reviewer Mode and deterministic capstone demo + +Create a dedicated reviewer experience. + +A suitable route is: + +`/capstone-demo` + +## Demo requirements + +The demo must: + +1. Verify the connected Bitcoin Core node. +2. Confirm the runtime chain is regtest. +3. Start a clean isolated lab session. +4. Show the selected Verified Scenario. +5. Show the policy or transaction objective. +6. Run a valid path. +7. Run at least one expected attack. +8. Show the actual Bitcoin Core response. +9. Complete the verified path. +10. Show the transaction lifecycle. +11. Export the proof bundle. +12. Show cleanup status. + +The default demo should be the Community Treasury Recovery scenario. + +Add a shorter transaction-lifecycle demo as a fallback. + +## Reliability + +The demo must: + +* Avoid stale wallet names. +* Avoid fixed addresses. +* Avoid fixed transaction IDs. +* Avoid depending on a previous regtest datadir. +* Provide a reset action. +* Recover gracefully from an interrupted run. +* Explain missing prerequisites. +* Never silently skip a failed step. + +Update `docs/demo-script.md` with: + +* A five-minute presentation flow. +* A longer technical walkthrough. +* Expected screenshots. +* Questions a reviewer may ask. +* The technical answer for each likely question. +* Known limitations. + +## Phase 9 completion criteria + +Do not proceed until: + +* The demo succeeds from a disposable regtest node. +* The demo succeeds twice against separate clean datadirs. +* An interrupted run can be reset. +* Expected failures are visible and explained. +* The proof bundle downloads correctly. +* The final cleanup state is visible. +* The reviewer can inspect equivalent commands and raw output. +* The frontend build and backend tests pass. + +Suggested commits: + +* `Add the BitScope capstone demo` +* `Make verified scenarios reset safely` +* `Document the reviewer walkthrough` + +--- + +# Phase 10 — Harden testing and CI + +## Backend tests + +Add coverage for: + +* Scenario parsing. +* Scenario state transitions. +* Evidence redaction. +* Evidence manifest hashing. +* Expected failure classification. +* Unexpected failure classification. +* Cleanup after success. +* Cleanup after failure. +* Network mismatch. +* Mutation token failure. +* Invalid origin. +* Unsupported scenario operation. +* Unsupported attack. +* Duplicate execution. +* Interrupted run recovery. +* Report generation. + +## Live Bitcoin Core tests + +Use a disposable Bitcoin Core regtest node. + +Cover at least: + +* Transaction lifecycle. +* RBF. +* Multisig PSBT. +* Timelock rejection and maturity. +* Community Treasury Recovery. +* One attack that reaches Bitcoin Core. +* Evidence export. +* Cleanup. + +Live tests must: + +* Verify regtest before mutation. +* Generate unique wallet names. +* Generate addresses during the test. +* Mine enough blocks for coinbase maturity. +* Avoid fixed transaction IDs. +* Unload or remove test-owned wallets when supported. +* Leave developer wallets untouched. +* Clean up even after failure. + +## Frontend verification + +Run: + +* Type checking. +* Linting if configured. +* Production build. +* Existing frontend tests. + +Only introduce a new end-to-end testing framework when justified by the current repository and maintenance cost. + +## CI + +Extend the existing CI rather than creating a separate overlapping workflow. + +Retain the pinned Bitcoin Core integration strategy. + +If upgrading the pinned Bitcoin Core version: + +* Prove compatibility. +* Update documentation. +* Record the reason. +* Avoid unnecessary version churn. + +Add scenario-related results to the release-readiness summary. + +## Phase 10 completion criteria + +Do not proceed until: + +* All unit tests pass. +* All live-node tests pass. +* Frontend build passes. +* Docker Compose validation passes. +* Cleanup tests pass. +* Security guard tests pass. +* Evidence-redaction tests pass. +* The flagship scenario passes in CI. +* Release readiness includes Verified Scenarios. + +Suggested commits: + +* `Test verified scenarios against Bitcoin Core` +* `Verify scenario cleanup after failures` +* `Add verified scenarios to release checks` + +--- + +# Phase 11 — Documentation, threat model and release preparation + +## Documentation + +Create or update: + +* `README.md` +* `docs/architecture.md` +* `docs/limitations.md` +* `docs/demo-script.md` +* `docs/live-rpc-testing.md` +* `docs/supported-bitcoin-core.md` +* `docs/verified-scenarios.md` +* `docs/scenario-authoring.md` +* `docs/proof-bundles.md` +* `docs/threat-model.md` +* `docs/curriculum-map.md` +* `CONTRIBUTING.md` + +## README positioning + +Use this identity prominently: + +> **BitScope is a reproducible Bitcoin protocol laboratory that constructs, executes, attacks and verifies Bitcoin transactions against a real Bitcoin Core node.** + +Explain that BitScope is not: + +* A hosted explorer. +* A mainnet wallet. +* A custody service. +* A replacement for Bitcoin Core. +* A production treasury coordinator. +* A guarantee that a policy is safe for real funds. + +Explain that BitScope is: + +* Local-first. +* Regtest-focused. +* Bitcoin Core-backed. +* Reproducible. +* Educational. +* Evidence-driven. +* Designed for protocol experimentation. + +## Threat model + +Document: + +* Malicious browser origins. +* Exposed local access token. +* Incorrect runtime network. +* Unsafe RPC methods. +* Secret leakage in logs. +* Stale regtest state. +* Scenario interruption. +* Unclean wallet state. +* Invalid policy assumptions. +* Bitcoin Core version differences. +* Misinterpretation of expected failures. +* False claims of proof or security. + +## Limitations + +State clearly: + +* Verified Scenarios prove behaviour only under the tested software version, configuration and scenario assumptions. +* Regtest does not reproduce a real fee market. +* A successful test does not make a policy production-ready. +* Hardware-wallet behaviour is not proven unless tested with actual supported devices. +* A BitScope evidence bundle is reproducible evidence, not an independent security audit. +* Bitcoin Core policy rules can change between versions. +* Script and policy privacy analysis may contain qualitative interpretation and must be labelled accordingly. + +## Release + +Determine the next semantic version by inspecting existing tags and release history. + +Do not invent a version number without checking. + +Prepare: + +* Release notes. +* Migration notes. +* Verification commands. +* Demo instructions. +* Known limitations. +* Screenshots. +* A release-readiness checklist. + +## Phase 11 completion criteria + +Do not declare the project complete until: + +* The README reflects the new identity. +* Verified Scenarios are documented. +* The flagship scenario is documented. +* Proof bundles are documented. +* The threat model is complete. +* Limitations are honest. +* All verification commands have been run. +* CI is green. +* The demonstration works from a clean environment. +* The release checklist is complete. +* No placeholder, fabricated result or unfinished claim remains. + +Suggested commits: + +* `Document verified Bitcoin scenarios` +* `Add the BitScope threat model` +* `Prepare the capstone release` + +--- + +# Final acceptance criteria + +The expansion is complete only when all the following are true. + +## Product + +* BitScope can construct Bitcoin transactions against a real Bitcoin Core node. +* BitScope can execute valid transaction paths. +* BitScope can attempt expected invalid paths. +* BitScope can classify the resulting failures. +* BitScope can verify successful and rejected outcomes. +* BitScope can export reproducible evidence. +* BitScope can clean up isolated lab state. +* BitScope includes the Community Treasury Recovery scenario. +* BitScope includes foundational RBF, multisig PSBT and timelock scenarios. +* BitScope includes curriculum mapping. +* BitScope includes Challenge Mode. +* BitScope includes a transaction lifecycle recorder. +* BitScope includes policy comparison. +* BitScope includes a deterministic reviewer demo. + +## Safety + +* Mainnet mutations remain impossible through normal application routes. +* Runtime chain verification occurs before mutation. +* Mutation token checks remain active. +* Origin checks remain active. +* RPC capabilities remain least-privilege. +* Forbidden RPC methods remain blocked. +* Evidence contains no secrets. +* Scenario definitions cannot execute arbitrary RPC methods or shell commands. +* All state-changing scenarios are restricted to regtest. + +## Testing + +* Backend unit tests pass. +* Frontend build passes. +* Docker configuration validation passes. +* Real Bitcoin Core integration tests pass. +* The flagship scenario passes against a disposable Bitcoin Core node. +* Negative tests fail for the expected reason. +* Cleanup works after both success and failure. +* Evidence export and redaction tests pass. + +## Documentation + +* Architecture is current. +* Threat model is current. +* Limitations are honest. +* Scenario authoring is documented. +* Proof bundle structure is documented. +* The reviewer demonstration is documented. +* The relationship with LBCLI Chapters 3–13 is documented. +* The repository contains no unsupported marketing claims. + +--- + +# Required final report from Codex + +After completing the implementation, provide a final report containing: + +1. Summary of what was implemented. +2. Files and major components added. +3. Existing components reused. +4. Architectural decisions. +5. Scenario catalogue. +6. Community Treasury Recovery policy used. +7. Attacks implemented. +8. Tests added. +9. Live Bitcoin Core workflows verified. +10. Commands used for final verification. +11. CI status. +12. Known limitations. +13. Deferred improvements. +14. Security assumptions. +15. Complete commit list in chronological order. + +Do not describe deferred work as completed. + +Do not hide test failures. + +Do not claim that BitScope provides production wallet security or a formal security audit. + +The completed system should prove the following identity through working code, real-node tests and reproducible evidence: + +> **BitScope is a reproducible Bitcoin protocol laboratory that constructs, executes, attacks and verifies Bitcoin transactions against a real Bitcoin Core node.** diff --git a/docker-compose.yml b/docker-compose.yml index e3be9af..6112a35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,7 @@ services: MAX_REQUEST_BODY_BYTES: "1048576" BITSCOPE_LOCAL_ACCESS_TOKEN: ${BITSCOPE_LOCAL_ACCESS_TOKEN:-replace-with-a-random-local-token} LAB_SESSION_DATABASE_PATH: /data/lab-sessions.sqlite3 + SCENARIO_ARTIFACT_ROOT: /data/scenario-artifacts volumes: - bitscope-sessions:/data ports: diff --git a/docs/architecture.md b/docs/architecture.md index 9bb49d4..84621db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,13 +80,28 @@ Services keep Bitcoin-specific behavior out of route handlers: - `ScriptService`: script decoding, script templates, transaction policy testing, and OP_RETURN transaction building. - `PsbtService`: PSBT creation, decode, wallet processing, signing, finalization, and extraction. - `MultisigService`: regtest multisig creation, funding, and PSBT-backed spending. -- `TimelockService`: nLockTime, CLTV, CSV, sequence, and mempool preflight. +- `TimelockService`: nLockTime and script templates plus real P2WSH CLTV policy funding, local BIP143 signing with an ephemeral in-memory key, and Core mempool preflight. - `DescriptorService`: descriptor checksums, normalization, address derivation, and wallet descriptors. - `TaprootService`: Taproot output and scriptPubKey inspection. - `IntegrationService`: JSON-RPC client examples, wallet RPC paths, SSE, and optional ZMQ configuration. - `KeyService`: public descriptor, xpub, derivation path, watch-only wallet, and hardware-wallet PSBT education. - `LearningService`: concept catalog and RPC method reference. - `LabSessionService`: SQLite-backed isolated lab ownership, resume, reset, export, and safe wallet cleanup. +- `ScenarioCatalog`: immutable registry of reviewed, versioned scenario definitions and their run availability. +- `ScenarioService`: ownership-scoped, optimistic-revision run creation, live regtest preparation, and dispatch to reviewed scenario-specific executors. +- `TransactionLifecycleService`: the first executable scenario adapter. It uses only the session-owned wallet, rechecks regtest before every mutation, records structured Core output, and proves both a confirmed spend and a value-conservation rejection. +- `RbfScenarioService`: proves opt-in sequence signaling, the original and replacement mempool states, Core's incremental-fee rejection, successful higher-fee replacement, original eviction, and replacement confirmation. +- `MultisigPsbtScenarioService`: creates three session-owned one-key legacy signer wallets, proves that one signature cannot finalize a 2-of-3 PSBT, completes it with a second signer, and verifies preflight, broadcast, confirmation, evidence export, and owned-wallet cleanup. +- `CltvTimelockScenarioService`: funds a real absolute-height P2WSH CLTV policy, pins premature and invalid-script rejections, advances regtest to the exact target, confirms the unchanged mature spend, drops its ephemeral signer reference, and exports redacted evidence. +- `EvidenceService`: typed evidence capture that keeps Bitcoin Core output separate from BitScope interpretation, recursively redacts credentials and private-key material, emits canonical JSON, and attaches a hash-backed reference to the owning run. +- `ScenarioArtifactStore`: bounded, run-scoped evidence files with server-generated paths, canonical-content checks, and SHA-256 verification on every read. +- `ProofBundleService`: deterministic Markdown reports, conditional transcript/command/assertion files, SHA-256 manifests, and ZIP exports with fixed metadata. Bundles are evidence, not attestations or audits. + +Scenario runs are stored transactionally beside their owning lab sessions. Their identity fields and recorded histories are append-only, state changes use explicit transitions and revision checks, and reset creates a new run rather than rewriting the old run. Runs that may own resources cannot be reset or deleted until cleanup is recorded as complete. + +Reviewed executors persist `RUNNING`, `VERIFYING`, `CLEANING`, and terminal checkpoints through a shared orchestration contract. Evidence artifacts are written before the checkpoint that references them and removed if that metadata commit loses an optimistic-revision race. Both successful and failed executions attempt session-owned wallet cleanup; a cleanup error produces `CLEANUP_FAILED`, never a verified result. + +Preparing a run reuses the live network-safety check and atomically moves `created` to `ready` with a redacted `node.context` artifact. The artifact records the Core-reported chain, version, block height, BitScope interpretation, and credential-free reproduction commands; it does not claim that later scenario steps have executed. ## API Surface @@ -101,6 +116,7 @@ All routes are prefixed with `/api`. | Addresses and indexing | `/addresses/{address}`, `/index/scan-address` | | Wallet and regtest | `/wallets`, `/wallets/create`, `/wallets/load`, `/wallets/{wallet_name}/balance`, `/wallets/{wallet_name}/address`, `/wallets/{wallet_name}/utxos`, `/wallets/{wallet_name}/transactions`, `/regtest/mine`, `/regtest/faucet`, `/demo/run` | | Persistent labs | `/labs`, `/labs/{session_id}`, `/labs/{session_id}/reset`, `/labs/{session_id}/export`, `/labs/{session_id}?confirm=true` | +| Verified scenarios | `/scenarios`, `/scenarios/{scenario_id}`, `/scenarios/{scenario_id}/runs`, `/scenario-runs/{run_id}`, `/scenario-runs/{run_id}/advance`, `/scenario-runs/{run_id}/reset`, `/scenario-runs/{run_id}/evidence`, `/scenario-runs/{run_id}/report`, `/scenario-runs/{run_id}/bundle`, `/scenario-runs/{run_id}?confirm=true` | | Scripts and data | `/scripts/decode`, `/scripts/template`, `/scripts/test-spend`, `/scripts/create-op-return` | | PSBT, multisig, timelocks | `/psbt/create`, `/psbt/decode`, `/psbt/wallet-process`, `/psbt/finalize`, `/multisig/create`, `/multisig/fund`, `/multisig/spend-psbt`, `/timelocks/transaction`, `/timelocks/script-template` | | Descriptors and Taproot | `/descriptors/analyze`, `/descriptors/wallet/{wallet_name}`, `/taproot/inspect` | diff --git a/docs/attack-verification.md b/docs/attack-verification.md new file mode 100644 index 0000000..2eab688 --- /dev/null +++ b/docs/attack-verification.md @@ -0,0 +1,47 @@ +# Typed Attack Verification + +BitScope attacks are reviewed negative-path definitions, not arbitrary transaction mutation or RPC execution. The framework declares applicability before an executor constructs or submits an attack and then classifies only bounded, redacted observations. + +## Typed categories + +`AttackType` covers signature insufficiency, PSBT incompleteness, output modification, input modification, sequence modification, locktime modification, premature timelock execution, invalid script branches, dust outputs, fee-policy failures, missing parents, double-spends, RBF replacement-policy failures, and runtime network mismatch. + +Every category has a catalog profile even when no current scenario can execute it. Scenario-specific definitions add required features and one structured expectation: + +- `mempool_rejection` requires `allowed=false` before comparing an exact or bounded reject-reason marker. +- `psbt_incomplete` requires `complete=false`, normally no extracted transaction hex, and optionally an exact below-threshold signature count. +- `rpc_error` requires the RPC method and numeric code before checking bounded supplemental message markers. + +Human-readable text is never the only classifier when Core supplies a reliable boolean or numeric result. + +## Applicability contract + +An executor builds an `AttackContext` from its scenario identifier and reviewed features, then calls `assess()` before performing the mutation or submission. A required attack must return `applicable`; otherwise execution fails closed. Exploratory catalog checks use `assess_type()` and persist or display `not_applicable` with an explicit reason and any missing features. They do not attempt the attack. + +No attack definition contains arbitrary RPC method names, caller-provided parameters, scripts, or executable expressions. The framework adds no RPC capability. + +## Results and evidence + +Classification produces one of: + +- `expected_failure` +- `unexpected_failure` +- `skipped` + +Results retain the attack type, scenario, applicability, expected and observed classification, a safe explanation, and bounded recursively redacted raw details. Successful scenario executions persist these typed results in `evidence/attacks.summary.json`. A mismatch raises the scenario's stable error code and copies its attack identifier and safe raw details into the unexpected `ScenarioFailure`; cleanup still runs. + +The current mandatory scenarios use the framework as follows: + +| Scenario | Attack types | +|---|---| +| Transaction lifecycle | Output modification | +| RBF replacement | RBF replacement-policy failure | +| Multisig PSBT | Signature insufficiency, PSBT incompleteness | +| CLTV timelock | Premature timelock execution, sequence modification, locktime modification | +| Community Treasury Recovery | Signature insufficiency, PSBT incompleteness, premature timelock execution, sequence modification | + +Signature insufficiency, PSBT incompleteness, premature timelock execution, and sequence modification are each reused across multiple scenarios. Dust, missing-parent, double-spend, invalid-branch, input-modification, generic fee-policy, and runtime-mismatch categories remain typed but are honestly not applicable until a reviewed scenario provides the necessary construction and proof. + +## Testing + +`tests/test_attack_verification_service.py` covers the complete category catalog, explicit skips, missing features, structured classification, redaction, bounds, mismatches, and cross-scenario reuse. Each migrated scenario unit test checks its exported summary and fail-closed mismatch details. `tests/live_node/test_community_treasury_scenario_live.py` requires all nine flagship classifications against pinned Core 28.1. diff --git a/docs/capstone-expansion-plan.md b/docs/capstone-expansion-plan.md new file mode 100644 index 0000000..8686dfc --- /dev/null +++ b/docs/capstone-expansion-plan.md @@ -0,0 +1,453 @@ +# BitScope Capstone Expansion Plan + +## Audit status + +This document records the Phase 0 repository audit required by `capstone.md`. It is an implementation plan, not a claim that the later capstone phases are complete. + +- Audit date: 2026-07-20 +- Audited branch: `feature/verified-scenarios` +- Audited commit: `8a40171` +- Production features added during Phase 0: none +- Reference Bitcoin Core version: 28.1 +- Working tree note: pre-existing user changes were present before this audit and were not modified by Phase 0. + +## Baseline verification + +| Check | Command or method | Result | Notes | +| --- | --- | --- | --- | +| Backend tests, initial local run | `cd backend; .\.venv\Scripts\python.exe -m pytest` | Environment-affected failure | 169 passed, 6 failed, 5 setup errors, and 3 live tests skipped. The local environment excluded `testserver` from trusted hosts, and pytest could not access the user's default temporary and cache directories. These failures pre-date this plan and are not caused by capstone code. | +| Backend tests, controlled rerun | Set `BACKEND_TRUSTED_HOSTS` to include `testserver`, then run `.\.venv\Scripts\python.exe -m pytest --basetemp C:\tmp\bitscope-phase0-pytest -p no:cacheprovider` | Pass | 180 passed and 3 opt-in live tests skipped in 13.68 seconds. | +| Frontend production build, initial run | `cd frontend; npm run build` | Timed out after 120 seconds | Compilation, TypeScript checking, and generation of all 22 static pages completed before the command timed out while collecting build traces. | +| Frontend production build, controlled rerun | `cd frontend; npm run build` with a 300-second command limit | Pass | Next.js 16.2.10 completed the production build and route generation in 113 seconds. | +| Docker Compose validation | `docker compose config` | Pass | The configuration resolved successfully. Docker also warned that the sandbox could not read the user's Docker client configuration. | +| Live Bitcoin Core tests | Ordinary suite collection plus runtime availability check | Not run against a node | All 3 live tests were collected and skipped because `BITSCOPE_LIVE_RPC_TESTS` was not enabled. No `bitcoind` process or Docker daemon was available. The installed local CLI is 31.0.0, which is not a substitute for the pinned 28.1 integration target. | +| Pinned CI definition | Review of `.github/workflows/ci-staging.yml` | Present | CI starts `bitcoin/bitcoin:28.1` on disposable regtest state and runs `tests/live_node`. The integration job is blocking for release readiness. | + +### Baseline interpretation + +The deterministic backend suite and frontend build pass after isolating local environment constraints. Docker Compose syntax is valid. The live-node result remains unverified locally because a pinned node was unavailable; this must remain an explicit limitation until the existing integration job is run successfully or a disposable 28.1 node is available locally. + +The worktree contains a pre-existing tracked change to `backend/.env.example` with credential-like RPC and local-access-token values. Those values are not reproduced here. They must be removed from the tracked example and rotated if they were ever used before any commit, push, screenshot, proof bundle, or release. The untracked `frontend/.env.local` must remain uncommitted. + +## Current architecture map + +### Runtime and framework + +BitScope is a local-first application with these established boundaries: + +- FastAPI routes under `backend/app/routes`. +- Pydantic request and response models under `backend/app/models`. +- Bitcoin-aware services under `backend/app/services`. +- A single Bitcoin Core JSON-RPC transport in `backend/app/rpc/client.py`. +- Explicit RPC capability wrappers in `backend/app/rpc/capabilities.py`. +- Next.js App Router pages, TypeScript, React, and Tailwind in `frontend`. +- A typed frontend client in `frontend/lib/api.ts`. +- SQLite JSON-document persistence for isolated labs. +- Docker Compose and CI integration pinned to Bitcoin Core 28.1. + +The capstone expansion must extend these boundaries. It must not create a second RPC transport, scenario-only wallet stack, separate frontend client, or unrelated persistence architecture. + +### Safety boundaries already implemented + +| Boundary | Current implementation | Required capstone use | +| --- | --- | --- | +| Runtime chain verification | `NetworkSafetyGuard` reads `getblockchaininfo`, verifies configured/runtime agreement, and fails closed on unknown responses. | Every state-changing scenario step and cleanup operation must call `require_regtest` immediately before mutation. A chain check recorded at run start is not sufficient by itself. | +| Mutation authorization | `require_mutation_access` validates `X-BitScope-Token` with constant-time comparison and rejects opaque or unapproved origins. | Apply the dependency to create, advance, reset, and delete scenario routes. Extend the exact-route security test whenever a mutation route is added. | +| RPC least privilege | `ReadOnlyRpcClient`, `WalletReadRpcClient`, and `RegtestMutationRpcClient` expose explicit method sets. | Scenario executors must receive the least-powerful client needed by each operation. New RPC methods require a capability and test review; scenario definitions must never select arbitrary methods. | +| Globally forbidden RPCs | The transport and capability wrapper both block key export/import, wallet unlock, seed replacement, backup, encryption, private-key signing, shutdown, and related methods. | Preserve the global list and its monotonicity tests. Scenario authoring must not bypass the transport. | +| HTTP hardening | Trusted hosts, restricted CORS, bounded request bodies, production docs disablement, and stable error handlers. | Reuse the middleware and bounded Pydantic fields for definitions, evidence queries, and report/export requests. | +| Secret ownership | RPC credentials remain in backend settings; `Settings.public_dict` omits secret values. | Evidence collection must use an allowlist plus recursive redaction and must never serialize settings or request headers wholesale. | +| Lab ownership | Lab cleanup verifies every wallet name is in the session namespace before unloading it. | Scenario runs must reference a lab session and may clean up only resources recorded as owned by that session/run. | + +### Existing capability catalogue + +| Capability | Existing implementation | What is already verified | Capstone reuse | +| --- | --- | --- | --- | +| Node and chain context | `NodeService`, `BlockchainService`, `NetworkSafetyGuard` | Chain data, height, block lookup, and mismatch/fail-closed unit tests | Run preconditions, node context, before/after height, confirmation assertions | +| Wallet and regtest setup | `WalletService`, `RegtestService`, `SpendPreflight` | Create/load wallets, fresh addresses, mature-balance checks, mining, faucet sends | Typed setup steps and isolated wallet preparation | +| Persistent lab sessions | `LabSession`, `LabSessionStore`, `LabSessionService`, `/api/labs` | Isolation, resume, reset, export, ownership checks, and unload cleanup | Parent ownership boundary for scenario runs; database and session namespace reuse | +| Transaction construction | `TransactionService` | Raw creation, funding, wallet signing, decode, broadcast, confirmation, transaction lookup | Transaction lifecycle scenario and reusable execution primitives | +| Mempool and policy | `MempoolService`, `TransactionService.transaction_policy`, `ScriptService.test_spend` | Entry inspection, RBF metadata, ancestors/descendants, `testmempoolaccept` | Acceptance assertions and raw policy evidence | +| RBF | `TransactionService.bump_rbf_transaction` | Wallet `bumpfee` path in unit and existing live test | Positive RBF foundation; insufficient-fee negative path still missing | +| CPFP | `TransactionService.create_cpfp_child` | Child construction, signing, preflight, optional broadcast, existing live construction test | Parent-child lifecycle and package evidence; package economics still missing | +| PSBT | `PsbtService` | Create, decode, wallet process with optional signing, finalize, and incomplete/error unit behavior | PSBT state capture and signer-step primitives | +| Multisig | `MultisigService` | Key generation, address registration, funding, wallet PSBT spend, and existing live test | Foundational multisig scenario only after legacy compatibility is preserved | +| Timelocks | `TimelockService`, `CltvTimelockScenarioService` | Transaction-level nLockTime, CLTV/CSV templates, real P2WSH CLTV funding, ephemeral BIP143 signing, exact premature/script rejection, maturity, and confirmation | Absolute-height CLTV is proved; median-time CLTV and relative CSV remain future variants | +| Script and OP_RETURN | `ScriptService` | Template construction, decoding, complete-transaction preflight, OP_RETURN build/sign/broadcast | Script evidence and OP_RETURN policy scenario | +| Descriptors and Taproot inspection | `DescriptorService`, `TaprootService` | Descriptor normalization/derivation/listing and output inspection | Policy research and public descriptor evidence; not yet a signer orchestration layer | +| Guided demo | `DemoService`, `DemoMode` | One-shot wallet/mine/send/decode flow and Markdown command log | UI patterns and command trail only; do not reuse it as a second scenario engine | +| Learning content | `LearningService`, `/learn`, shared learning components | Concept and RPC catalog tied to existing pages | Curriculum mapping and scenario cross-links | + +### Frontend reuse map + +- Add all scenario types and request helpers to `frontend/lib/api.ts`; do not add a second API module. +- Generalize the mutation request helper so dynamic scenario and lab mutation paths receive the local token. The current static `MUTATION_PATHS` does not include `/labs` and cannot represent future dynamic run routes safely. +- Reuse `CommandExplanationCard` for safe commands, RPC details, explanations, and raw Core output. +- Reuse `StatusCard` and `WarningBox` for assertions, run status, expected failures, cleanup, and prerequisite errors. +- Reuse the responsive page/component pattern established by current labs. +- Treat `frontend/lib/labContext.ts` as convenience-only browser state. Server-owned scenario progress must come from the backend and SQLite, never local storage. +- Reuse the Demo Mode step and export interaction patterns, but replace its one-shot, timestamp-based workflow with the persistent scenario-run domain rather than extending both systems independently. + +## Missing capabilities + +The repository does not currently contain a Verified Scenario definition, scenario registry, run state machine, typed step union, verification assertion model, evidence service, proof-bundle exporter, attack model, failure classifier, lifecycle event model, challenge validator, curriculum map, policy comparison engine, or reviewer route. + +Specific gaps in existing features are: + +- Lab sessions record creation/reset/cleanup, but transaction and script services do not append their results to the session. +- Lab actions accept a free-form `kind` and `details`; they are not safe executable scenario definitions. +- The SQLite store writes one full JSON document and uses only a process-local lock. It has no optimistic run version, step uniqueness constraint, queryable scenario table, or transactional claim for concurrent advance requests. +- The current error map preserves safe RPC code/message details but groups RPC `-26` as consensus or policy. It cannot yet distinguish script failure, consensus failure, policy rejection, PSBT incompleteness, conflict, or replacement reliably. +- Existing workflows return raw Core responses directly and independently. There is no centralized redaction, artifact naming, manifest hashing, or distinction between Core output and BitScope interpretation. +- Transaction construction does not consistently record lifecycle events. For example, confirmation is returned as hashes but not as a normalized event. +- RBF uses wallet `bumpfee`; it does not expose a deterministic insufficient-fee replacement attack with classified evidence. +- CPFP does not calculate or assert parent, child, and package fee rates. +- Multisig currently controls all signer keys in one wallet and does not demonstrate independent participant contexts or staged insufficient signatures. +- The foundational timelock scenario now funds and spends a real absolute-height CLTV policy before and after maturity. Relative CSV and median-time CLTV remain unproved, and the older transaction helper's `sequence_hex` field still contains full funded transaction hex rather than normalized sequence evidence. +- OP_RETURN supports bounded payload construction, but the negative standardness case has not been proved against the pinned node. +- Demo Mode does not use persistent lab sessions, does not run attacks, does not classify failures, and does not own or clean up its wallets. +- The frontend has no persistent-lab API types/helpers despite backend lab routes, and no scenarios, evidence, timeline, curriculum, challenge, policy comparison, or reviewer pages. +- Live tests are broad smoke workflows. They do not yet assert negative paths, rejection categories, proof bundle contents, interrupted recovery, or cleanup after failures. + +## Functionality that must not be duplicated + +- Do not add a generic RPC endpoint or allow a scenario definition to contain an RPC method name. +- Do not add another network guard, mutation token system, RPC transport, or Bitcoin Core error transport. +- Do not reimplement wallet creation, mining, transaction funding/signing, PSBT processing, RBF, CPFP, script decoding, or OP_RETURN inside a scenario executor. Wrap the existing services with typed adapters. +- Do not create a scenario-only session database. Add scenario tables to the configured SQLite database and reference the existing lab session. +- Do not create a second frontend fetch layer or duplicate command/status/warning cards. +- Do not extend current Demo Mode into a competing scenario framework. Eventually make reviewer flows consume Verified Scenario runs. +- Do not add an isolated script interpreter. Continue to validate complete transactions through Bitcoin Core. +- Do not invent address history, fee-market data, policy results, or transaction identifiers. + +## Proposed domain model + +### Scenario catalogue + +Scenario definitions should be immutable, versioned Pydantic models registered by backend code. Definitions should not be uploaded by browsers during the initial implementation. + +`ScenarioDefinition`: + +- `scenario_id`, `version`, `name`, `summary`, and `difficulty`. +- LBCLI chapters, Bitcoin concepts, required network, and required capabilities. +- Estimated step count. +- Ordered setup, execution, attack, verification, export, and cleanup step identifiers. +- Definition-level cleanup rules and evidence requirements. + +`ScenarioStepDefinition` should be a discriminated union keyed by a bounded `type` literal. Each variant contains only the parameters needed by an existing BitScope operation. Initial variants should cover chain verification, lab wallet preparation, address generation, mining, UTXO selection, raw transaction/PSBT operations, decoding, mempool preflight, broadcast, mempool lookup, confirmation mining, timelock advancement, assertions, evidence export, and cleanup. + +Definitions must not contain Python, shell, templates evaluated as code, arbitrary URLs, arbitrary RPC names, or unbounded parameter dictionaries. References to earlier outputs should use typed artifact keys validated against the scenario definition. + +`VerificationAssertionDefinition` should declare: + +- A typed assertion kind. +- The artifact or run state it evaluates. +- An expected value/category. +- Whether it is required. +- A stable educational explanation. + +### Scenario execution + +`ScenarioRun`: + +- UUID run identifier, scenario identifier/version, and lab session identifier. +- Runtime chain and Bitcoin Core version captured from Core. +- Start/current state and optimistic integer revision. +- Current step, completed/failed/skipped step identifiers, and timestamps. +- Expected and unexpected failure counts. +- Cleanup state and final result. + +Recommended run states: + +```text +created -> ready -> running -> verifying -> cleaning -> verified + | | | verified_with_warnings + | | | cleanup_failed + | | failed + | failed + failed +``` + +Terminal states should include `verified`, `verified_with_warnings`, `failed`, `incomplete`, and `cleanup_failed`. A required skipped assertion prevents `verified`. + +`ScenarioStepRun`: + +- Run/step identifiers, ordinal, step type, status, attempt count, and timestamps. +- Input artifact references and output artifact references. +- Safe RPC method, command reference, explanation, and error classification reference. +- A uniqueness constraint on `(run_id, step_id)` so duplicate advances are idempotent or rejected explicitly. + +`AssertionResult`: + +- Assertion identifier and kind. +- Expected and safe actual values. +- `passed`, `failed`, or `skipped` status. +- Expected-failure flag and explanation. +- Evidence references. + +`AttackResult` and `FailureRecord` should be introduced before attacks are generalized. They must preserve structured BitScope/RPC codes and safe raw messages, record applicability, and distinguish an expected rejection from an unrelated failure. + +`LifecycleEvent` should normalize transaction evolution with references to hex/PSBT artifacts, transaction identifiers, fees, sequences, locktime, block height, RPC method, safe command, and explanation. + +### Persistence + +Continue using the configured SQLite database. Preserve the existing `lab_sessions` table and add explicit tables for: + +- `scenario_runs` +- `scenario_step_runs` +- `scenario_assertions` +- `scenario_evidence` +- `scenario_lifecycle_events` +- `scenario_failures` + +Use foreign keys and unique constraints. Store compact metadata and artifact references in SQLite. Store large proof artifacts beneath a configured local artifact root using run-scoped, server-generated paths. Never accept filesystem paths from scenario definitions or API clients. + +Migrations should be explicit and transactional. Back up or copy the SQLite file before a schema migration in local development instructions. Existing lab documents must continue to load without rewriting them eagerly. + +### Evidence and export + +Introduce one evidence service used by every scenario adapter. It should: + +- Accept typed evidence records rather than arbitrary log dictionaries. +- Recursively redact known secret keys and values before persistence. +- Separate `core_output` from `bitscope_interpretation`. +- Preserve generated values while marking them run-specific. +- Store safe commands without RPC credentials or local tokens. +- Produce deterministic artifact names and ordering. +- Hash every exported file in `manifest.json` after final content is written. +- Stream a ZIP response without exposing arbitrary local paths. + +Proof bundles are reproducible evidence, not signatures, attestations, formal proofs, or audits. + +## Proposed API changes + +Follow the existing `/api` conventions: + +- `GET /api/scenarios` +- `GET /api/scenarios/{scenario_id}` +- `POST /api/scenarios/{scenario_id}/runs` +- `GET /api/scenario-runs/{run_id}` +- `POST /api/scenario-runs/{run_id}/advance` +- `POST /api/scenario-runs/{run_id}/reset` +- `GET /api/scenario-runs/{run_id}/evidence` +- `GET /api/scenario-runs/{run_id}/report` +- `GET /api/scenario-runs/{run_id}/bundle` +- `DELETE /api/scenario-runs/{run_id}?confirm=true` + +Create, advance, reset, and delete are mutation routes and require the existing token/origin dependency. Advance and cleanup must re-check the live chain. Read routes must enforce run/session association and return only redacted artifacts. + +Later phases may add `/api/challenges`, `/api/challenge-runs`, and `/api/policy-comparisons`, but these should consume the same assertion/evidence/run primitives. + +## Proposed frontend pages and components + +### Pages + +- `/scenarios`: catalogue with concepts, difficulty, prerequisites, and implementation status. +- `/scenarios/[scenarioId]`: definition, objective, branches, attacks, assertions, and start action. +- `/scenario-runs/[runId]`: persistent run control, current step, assertions, failures, evidence, lifecycle, cleanup, and download. +- `/curriculum`: LBCLI Chapters 3-13 mapped only to implemented pages/scenarios. +- `/challenges` and `/challenges/[challengeId]`: progressive hints and Core-backed validation. +- `/policies/compare`: exact/derived/estimated/unknown policy comparison. +- `/capstone-demo`: reviewer flow backed by a real scenario run. + +### Components + +- `ScenarioCatalogue`, `ScenarioOverview`, and `ScenarioRunControls`. +- `ScenarioStepList` and `AssertionResultCard`. +- `ExpectedFailureCard` and `FailureClassificationBadge`. +- `TransactionLifecycleTimeline` with explicit RBF, CPFP, and timelock relationships. +- `EvidenceArtifactViewer` and `ProofBundleDownload`. +- `PolicyDecisionTree` generated from typed policy data. +- `ChallengeWorkspace` and progressive `HintPanel`. + +All state shown as completed must come from the backend run. The UI may optimistically show a request in progress, but it must not invent successful steps or failure classifications. + +## Test strategy + +### Unit and route tests + +- Pydantic validation for every definition, step, assertion, attack, and run type. +- Rejection of unknown step types, arbitrary RPC fields, shell fields, invalid networks, duplicate IDs, invalid artifact references, and oversized values. +- State transition, optimistic revision, duplicate advance, interruption, reset, and cleanup tests. +- Session ownership and run/session association tests. +- Exact mutation-route protection tests and origin/token tests. +- Evidence allowlisting, recursive redaction, deterministic filenames, manifest hashing, ZIP traversal prevention, and safe command tests. +- Structured failure classification tests using RPC codes/results first and bounded message parsing only where unavoidable. +- Report-status tests ensuring skipped assertions or failed cleanup cannot report `VERIFIED`. +- Frontend TypeScript build plus focused component tests only if the current repository adopts a test framework with justified maintenance cost. + +### Live-node tests + +Extend `backend/tests/live_node` and the existing CI job rather than adding an overlapping workflow. Each live test must use a disposable 28.1 regtest node, unique wallets/addresses, dynamic transaction identifiers, 101-block coinbase maturity, and `finally` cleanup. + +Required progression: + +1. Transaction lifecycle with positive preflight/broadcast/confirmation and one proved rejection. +2. RBF success and insufficient-fee failure. +3. Multisig PSBT incomplete and complete states. +4. One real CLTV or CSV premature rejection followed by maturity and confirmation. +5. Evidence export and redaction. +6. Community Treasury Recovery normal and delayed branches. +7. Cleanup after both success and injected failure. + +Negative tests pass only if rejection occurs for the expected structured category. A rejection caused by a stale address, immature coinbase, wrong wallet, or network mismatch must not satisfy a script/policy assertion. + +### CI gates + +- Keep backend, pinned live-node, frontend, Compose, and release-readiness jobs. +- Add scenario tests to the existing backend and 28.1 jobs. +- Extend release readiness with required scenario documentation and proof-bundle checks. +- Publish only redacted summaries; never upload wallet/datadir state or unreviewed bundles as CI artifacts. + +## Multisig and legacy BDB compatibility + +The current multisig live workflow depends on legacy BDB compatibility: + +- `MultisigService.create` calls `addmultisigaddress` after generating all signer keys in one wallet. +- The live fixture calls `createwallet` with `descriptors=false`, creating a legacy wallet on the pinned version. +- The Bitcoin Core 28.1 CI process enables `-deprecatedrpc=create_bdb` explicitly. +- `docs/live-rpc-testing.md` documents this as a compatibility constraint. + +This compatibility path remains in use. The foundational scenario extends it with three session-owned one-key legacy wallets so incomplete and threshold-complete PSBT states are observable. The wallets still share one Bitcoin Core process and one BitScope session, so the result demonstrates staged signer contexts rather than independent custody. + +Before a descriptor-wallet migration: + +1. Start an isolated Bitcoin Core 28.1 node and disposable datadir. +2. Prove the exact descriptor or Miniscript expression with `getdescriptorinfo` and address derivation. +3. Prove funding and wallet discovery of the output. +4. Prove PSBT construction with the selected descriptor/watch-only arrangement. +5. Prove one signer leaves the PSBT incomplete and the threshold completes it. +6. Prove finalization, `testmempoolaccept`, broadcast, and confirmation. +7. Prove premature and mature recovery branches for any timelocked policy. +8. Document any required RPC method additions, especially because `importdescriptors` is not currently in an executable BitScope capability. +9. Keep the legacy path until the new path passes the pinned live test and migration documentation exists. + +Do not assume the flagship three-branch policy is supported. Implement the proved two-branch policy if the longer emergency branch cannot be completed by the current Core/Python/wallet stack. + +## Safety risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| Scenario format becomes arbitrary RPC or code execution | Use a closed discriminated union and backend-owned adapters; reject extra fields and unknown operations. | +| Mainnet or configured/runtime mismatch mutation | Re-run `NetworkSafetyGuard.require_regtest` at every mutation and cleanup boundary. | +| Mutation token omitted on dynamic frontend paths | Replace the brittle static path set with explicit mutation helpers and retain exact backend route-dependency tests. | +| Secrets enter evidence through raw dictionaries | Central allowlist/redaction service, secret canary tests, and no settings/header/environment serialization. | +| Filesystem traversal or artifact overwrite | Server-generated run directories, normalized relative artifact keys, atomic writes, and duplicate protection. | +| Duplicate or concurrent step execution | SQLite transaction, optimistic run revision, and unique `(run_id, step_id)` constraint. | +| Cleanup unloads another wallet | Preserve session namespace checks and require recorded ownership before every cleanup action. | +| Expected failure passes for an unrelated reason | Match structured category/code plus scenario context; preserve raw safe result for review. | +| Error strings change across Core versions | Prefer RPC codes and structured `testmempoolaccept` fields; pin live CI and keep message parsing narrow and tested. | +| Regtest results are presented as production security | Label version/configuration assumptions and state that bundles are evidence, not audits or production approval. | +| Credential-like values are committed from local examples | Restore placeholders, rotate potentially used values, keep `.env.local` ignored, and add secret scanning to release review. | +| Large PSBT/hex artifacts exhaust SQLite or HTTP limits | Store large content as bounded run artifacts, retain metadata in SQLite, and stream bounded exports. | +| Existing demo and scenarios diverge | Make reviewer/demo pages consumers of the scenario service after the foundational model exists. | + +## Migration risks + +- Existing `lab_sessions` documents have no schema version. Add one compatibly or use new tables without rewriting existing rows. +- SQLite writes currently replace an entire JSON document. Scenario advance needs transactional row-level claims and an optimistic revision to avoid lost updates. +- Persistent runs may outlive wallets or a regtest datadir reset. Resume must verify runtime chain, wallet availability, referenced transactions, and artifact integrity before continuing. +- Existing services return broad dictionaries with slightly different field names and raw layouts. Adapters should normalize them without changing current public responses in the same commit. +- Changing RPC capabilities can unintentionally widen every service using `RegtestMutationRpcClient`. Prefer narrower scenario-specific capability clients or split capabilities when justified. +- Legacy BDB removal would break the proved multisig live path. Descriptor migration requires a separate proof and rollback plan. +- Existing Demo Mode wallet naming uses second-resolution timestamps and leaves wallets loaded. It must not be used as the isolation model for reviewer scenarios. +- New frontend navigation can become unwieldy. Group scenario/curriculum/reviewer routes instead of continuing a flat sidebar indefinitely. +- Evidence schema changes can invalidate old bundles. Version manifests and scenario definitions from the first exported format. + +## Phase-by-phase implementation checklist + +### Phase 0 - Audit + +- [x] Read required architecture, limitation, demo, live-testing, support, and contribution documents. +- [x] Inspect routes, services, models, RPC boundaries, mutation protection, sessions, frontend client/components/routes, tests, and CI. +- [x] Run the controlled backend baseline. +- [x] Run the frontend production build. +- [x] Validate Docker Compose configuration. +- [x] Record pinned live-node status and the reason it was not run locally. +- [x] Identify legacy BDB multisig dependence. +- [x] Create this expansion plan without adding a production feature. +- [ ] Run the pinned 28.1 live tests when a disposable node or Docker daemon is available. + +### Phase 1 - Verified Scenarios domain + +- [x] Add closed, versioned scenario definition and step models. +- [x] Add explicit run state machine, revision, step uniqueness, and assertions. +- [x] Add SQLite scenario tables linked to lab sessions. +- [x] Add protected scenario/run routes and security tests. +- [x] Add unit tests for validation, transitions, ownership, interruption, and cleanup state. + +### Phase 2 - Evidence + +- [x] Add centralized typed evidence capture and recursive redaction. +- [x] Add deterministic artifact storage, reports, bundle generation, and hashes. +- [x] Distinguish Core output from BitScope interpretation. +- [x] Export one simple existing workflow with redaction tests. + +### Phase 3 - Foundational scenarios + +- [x] Implement transaction lifecycle, RBF, multisig PSBT, and one real timelock scenario. + - [x] Transaction lifecycle: confirmed positive path, pinned overspend rejection, deterministic evidence, and verified cleanup. + - [x] RBF replacement: opt-in signaling, exact insufficient-fee rejection, original eviction, replacement confirmation, evidence, and cleanup. + - [x] Multisig PSBT: one-key signer contexts, one-signature incompleteness, 2-of-3 completion, preflight, confirmation, deterministic evidence, and cleanup. + - [x] CLTV timelock: real P2WSH funding, ephemeral BIP143 signing, exact premature/final-sequence/low-nLockTime rejections, bounded maturity, confirmation, deterministic evidence, and cleanup. +- [ ] Add CPFP and OP_RETURN only after the mandatory four are complete or in parallel without weakening them. +- [x] Give every mandatory foundational scenario a meaningful proved negative path, live test, evidence, and cleanup. + +### Phase 4 - Community Treasury Recovery + +- [x] Research the exact three-path P2WSH Miniscript policy against Core 28.1 and current dependencies. +- [x] Prove immediate, premature, mature recovery, incorrect-sequence, and threshold-emergency branches in isolation. +- [x] Add typed public policy/participant models and a narrowly scoped materialization/import service. +- [x] Implement the typed three-path executor with independent signer-context limitations documented. +- [x] Export a complete Proof of Spendability bundle and add pinned live CI coverage. + +### Phase 5 - Attack and verification framework + +- [x] Generalize proved attacks into typed, applicability-aware definitions. +- [x] Add structured failure classification and preserve safe raw details. +- [x] Reuse at least four attack types across scenarios. +- [x] Migrate flagship checks away from one-off error logic. + +### Phase 6 - Lifecycle recorder + +- [x] Normalize backend lifecycle events from scenario evidence. +- [x] Add transaction timeline UI with RBF, CPFP, and timelock relationships. +- [x] Ensure the frontend never infers events absent from backend data. + +### Phase 7 - Curriculum and Challenge Mode + +- [x] Map LBCLI Chapters 3-13 only to implemented features. +- [x] Add at least four Core-validated challenges with progressive hints. +- [x] Reuse scenario assertions and evidence exports for challenge completion. +- [x] Implement accessible native controls, focus management, and keyboard navigation; rerun interactive browser automation when a browser surface is available. + +### Phase 8 - Policy comparison + +- [ ] Add typed exact/derived/estimated/unknown/unsupported metrics. +- [ ] Unit-test weight, witness, and fee calculations. +- [ ] Compare the proved treasury policy with simple 2-of-3 multisig. + +### Phase 9 - Reviewer Mode + +- [ ] Build `/capstone-demo` on the persistent scenario engine. +- [ ] Default to Community Treasury Recovery with transaction lifecycle fallback. +- [ ] Prove clean-start, second-run, interruption/reset, export, and cleanup behavior. +- [ ] Expand the reviewer demo script and expected questions. + +### Phase 10 - Testing and CI + +- [ ] Complete unit, security, interruption, cleanup, export, and report coverage. +- [ ] Complete disposable Core 28.1 live coverage for required scenarios and attacks. +- [ ] Extend the existing CI and release-readiness summary. +- [ ] Run backend, frontend, Compose, live-node, cleanup, and redaction gates together. + +### Phase 11 - Documentation and release + +- [ ] Update product identity, architecture, limitations, demo, live testing, and support docs. +- [ ] Add scenario authoring, proof bundle, threat model, curriculum, and verified-scenario docs. +- [ ] Inspect tags and release history before selecting a semantic version. +- [ ] Prepare release notes, migration notes, screenshots, verification commands, and limitations. + +## Current delivery status + +Phases 1 through 7 are implemented. Transaction lifecycle, RBF replacement, multisig PSBT, real absolute-height CLTV, and Community Treasury Recovery use typed definitions, persistent state, protected mutation routes, deterministic proof bundles, proved negative paths, session-owned cleanup, and disposable Core 28.1 live tests. The flagship treasury additionally exports a specialized Proof of Spendability with exact compatibility, policy, branch, expected-rejection, evidence, and cleanup checks. The shared attack and lifecycle frameworks preserve bounded Core evidence and render recorded relationships without frontend inference. Curriculum mapping covers LBCLI Chapters 3-13 using only implemented pages and scenarios. Six progressively hinted challenges validate owner-scoped scenario runs, assertions, cleanup, Core identity, and canonical artifact hashes before unlocking final explanations or completion exports. Optional CPFP and OP_RETURN scenarios remain deferred. Phase 8's typed policy comparison is next. diff --git a/docs/curriculum-and-challenge-mode.md b/docs/curriculum-and-challenge-mode.md new file mode 100644 index 0000000..4ede048 --- /dev/null +++ b/docs/curriculum-and-challenge-mode.md @@ -0,0 +1,45 @@ +# Curriculum Mapping and Challenge Mode + +BitScope maps Chapters 3 through 13 of [Learning Bitcoin from the Command Line](https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line) to implemented local pages, reviewed scenarios, and Bitcoin Core RPC methods. The mapping summarizes objectives and exercises; it links to the original chapter files instead of reproducing course text. + +## Curriculum contract + +`GET /api/learn/curriculum` returns exactly eleven ordered entries, one for each chapter from 3 through 13. Every entry contains a learning objective, real BitScope pages, applicable Verified Scenarios, RPC methods, prerequisites, a guided exercise, an independent challenge, and verification criteria. + +The mapping is capability-aware: + +- Chapter 5 links the proved RBF scenario and the implemented CPFP construction page, while stating that the optional CPFP Verified Scenario remains deferred. +- Chapter 8 links the implemented locktime and OP_RETURN labs, while stating that the optional OP_RETURN Verified Scenario remains deferred. +- Chapters 9 through 13 use Script Lab and the mandatory multisig, CLTV, and Community Treasury Recovery scenarios rather than claiming a general-purpose Script interpreter. + +## Challenge contract + +`GET /api/learn/challenges` returns public challenge definitions but never returns hint text, required internal assertion IDs, or completion explanations. Six challenges are currently implemented: + +1. Signal opt-in RBF. +2. Replace an RBF transaction with a higher fee. +3. Complete a 2-of-3 PSBT. +4. Prove a premature CLTV failure. +5. Diagnose a `testmempoolaccept` rejection. +6. Complete the treasury recovery path. + +Hints are requested progressively through `GET /api/learn/challenges/{challenge_id}/hints/{level}`. Each response contains only the requested level and never marks itself as a full solution. + +Challenge verification uses `POST /api/learn/challenges/{challenge_id}/verify` with a scenario run ID and its owning lab session ID. The backend: + +- resolves the owner-scoped persistent run; +- requires the challenge's reviewed scenario; +- loads and identity-checks canonical evidence artifacts; +- requires a verified terminal result and completed cleanup; +- requires recorded Bitcoin Core version and node-context evidence; +- reuses the scenario's passed typed assertions; +- verifies challenge-specific evidence artifacts exist; and +- returns hashes for the evidence supporting completion. + +The frontend cannot submit self-reported transaction state or assertion values. A result unlocks its final explanation only when every backend check passes. Completed results can be exported as JSON from `/curriculum`; the export contains the validation source, Core version, checks, evidence IDs, and artifact hashes. + +## Accessibility checklist + +The curriculum and challenge page uses native headings, links, buttons, labels, lists, `details`/`summary`, form submission, and focusable status output. Challenge selection exposes `aria-pressed`, errors use `role="alert"`, loading and verification updates use live regions, and newly requested hints or verification results receive programmatic focus. All actions are reachable through ordinary Tab, Shift+Tab, Enter, and Space behavior without custom key bindings. + +The production type check and static build cover the page. Interactive browser automation should be rerun whenever a browser surface is available; browser discovery was unavailable during the initial Phase 7 implementation pass. diff --git a/docs/live-rpc-testing.md b/docs/live-rpc-testing.md index 2669a71..d2ec785 100644 --- a/docs/live-rpc-testing.md +++ b/docs/live-rpc-testing.md @@ -36,6 +36,22 @@ The live fixtures and session tests must: The pinned integration job currently enables Bitcoin Core 28.1's `create_bdb` compatibility because the multisig lesson exercises `addmultisigaddress`. This is an explicit compatibility constraint, not a recommendation for new wallet designs. +The verified transaction-lifecycle test mines its 102 maturity blocks in bounded batches so each RPC stays within the normal request timeout. It selects two distinct mature outputs: one follows preflight, broadcast, mempool, confirmation, and decode; the other signs a one-satoshi overspend that pinned Core 28.1 rejects as `bad-txns-in-belowout`. + +The verified RBF test creates an original transaction at 2 sat/vB with `replaceable=true`, records its input sequences and `bip125-replaceable` mempool field, and asks `bumpfee` for the same observed rate. Pinned Core 28.1 returns RPC `-8` with structured old-fee and incremental-fee details. The scenario then adds 10 sat/vB, verifies the original is absent, observes the replacement, and confirms it. + +The verified multisig PSBT test creates three session-owned legacy wallets with one signer key each, registers the same native-SegWit 2-of-3 policy, and imports its address watch-only before funding. Both signer calls use `finalize=false`: the first must leave exactly one partial signature and no extracted transaction, while the second must expose two partial signatures. A separate finalizer call must return `complete=true` before Core accepts, broadcasts, and confirms the spend. The node must be started with `-deprecatedrpc=create_bdb`. These local wallet contexts demonstrate staged threshold mechanics, not independent custody or production key separation. + +The verified CLTV test funds a P2WSH ` OP_CHECKLOCKTIMEVERIFY OP_DROP OP_CHECKSIG` output and locally signs its BIP143 witness with an ephemeral in-memory key. Pinned Core 28.1 must report `non-final` before maturity, reject final-sequence and low-nLockTime variants with `Locktime requirement not satisfied`, accept the unchanged valid transaction at the exact target height, and confirm it. Cleanup drops the signer reference and no private key enters RPC or proof artifacts; Python does not guarantee immediate zeroization of released memory. + +The Community Treasury policy proof imports a public three-branch P2WSH Miniscript descriptor into a private-key-disabled coordinator wallet. Separate descriptor wallets contribute 2-of-3 operator, recovery, and emergency signatures through PSBTs. Core 28.1 must keep every one-signature PSBT incomplete, reject delayed transactions as `non-BIP68-final`, refuse finalization when sequence is below the script's `older()` value, and accept the unchanged recovery and emergency transactions after their relative block delays. + +The integrated `test_community_treasury_scenario_live.py` live test executes the registered 53-step scenario and exports the specialized Proof of Spendability twice. It requires 25 passed assertions, six precisely classified expected failures, complete session-owned cleanup, an exact Core 28.1 runtime, and byte-identical bundles. + +The same live test is the pinned attack-framework gate. Its proof bundle must contain nine `expected_failure` entries in `evidence/attacks.summary.json`, covering signature insufficiency, PSBT incompleteness, premature timelock execution, and sequence modification with bounded safe Core observations. + +It is also the pinned lifecycle-recorder gate. The deterministic bundle must contain 33 ordered lifecycle events: the policy setup, immediate/recovery/emergency transaction tracks, two independently recorded timelock maturities, and the final successful cleanup event. These events come from persisted backend evidence and are not reconstructed by the test or frontend. + ## Common Failures ### Insufficient or Immature Funds @@ -64,3 +80,4 @@ Before adding a live-node test: 4. Assert observable results rather than fixed txids, addresses, or block hashes. 5. Ensure cleanup still runs after failure. 6. Update [Supported Bitcoin Core Versions](supported-bitcoin-core.md) if the workflow changes version requirements. +7. Follow the closed-definition and evidence rules in [Authoring Verified Scenarios](verified-scenarios.md). diff --git a/docs/supported-bitcoin-core.md b/docs/supported-bitcoin-core.md index 4b4ae22..3404b25 100644 --- a/docs/supported-bitcoin-core.md +++ b/docs/supported-bitcoin-core.md @@ -14,13 +14,18 @@ The integration lifecycle uses a temporary container and datadir, creates a uniq - address validation, wallet sends, broadcast, and confirmation; - funded PSBT creation, wallet processing, and finalization; - multisig creation, funding, and PSBT spending; +- verified 2-of-3 multisig PSBT staging across three session-owned legacy wallets; - absolute-locktime transaction construction and mempool-policy inspection; +- verified P2WSH CLTV funding, premature `non-final` rejection, script-constraint rejection, maturity, broadcast, and confirmation; +- three-branch P2WSH Miniscript treasury policy derivation, watch-only import, participant-wallet PSBT signing, CSV rejection, and mature recovery; - OP_RETURN transaction construction; - RBF fee bumping; - CPFP child construction. The container and its wallet state are removed after every CI run, including failed runs. +The multisig paths require the pinned node's explicit `-deprecatedrpc=create_bdb` compatibility option. This is a tested legacy-wallet constraint, not support guidance for new production wallet designs. + ## Local command Start an isolated regtest node or the repository Compose stack, then run: diff --git a/docs/transaction-lifecycle-recorder.md b/docs/transaction-lifecycle-recorder.md new file mode 100644 index 0000000..eae0768 --- /dev/null +++ b/docs/transaction-lifecycle-recorder.md @@ -0,0 +1,33 @@ +# Transaction Lifecycle Recorder + +BitScope records transaction lifecycles as typed backend evidence. The frontend renders this evidence verbatim and does not reconstruct states from scenario steps, transaction presence, or neighboring events. + +## Evidence contract + +`LifecycleRecorder` maps reviewed scenario evidence IDs to ordered `TransactionLifecycleEvent` documents. A mapping emits an event only when that exact persisted evidence record exists. Each event includes its timestamp, scenario step and track, transaction state, optional txid, transaction-hex and PSBT references, fee and rate, locktime, sequences, relationship, height, explanation, equivalent RPC and safe `bitcoin-cli` command, source evidence ID, and bounded redacted Core result. + +The scenario service captures the ordered events as `evidence/lifecycle.timeline.json`. It adds `evidence/lifecycle.cleanup.json` only after session-owned cleanup succeeds. Proof bundles also contain a deterministic `lifecycle.json` assembled exclusively from those two persisted records. + +Use the owner-scoped read endpoint to retrieve the same typed document: + +```text +GET /api/scenario-runs/{run_id}/lifecycle?lab_session_id={lab_session_id} +``` + +The lab session ID is required and ownership is checked before artifacts are read. + +## Relationships and tracks + +- RBF replacement events carry `replaces` plus the original txid. Original and replacement activity use separate tracks. +- CPFP child events carry `child_of` plus the parent txid. The reusable model, recorder helper, and UI support this relationship, but no CPFP Verified Scenario is claimed yet; CPFP remains deferred to its recommended later stage. +- CLTV emits `timelock_matured` at the recorded target height. +- Community Treasury Recovery uses policy, immediate, recovery, and emergency tracks. Recovery and emergency each record their own relative-delay maturity before the unchanged spend is accepted. +- Cleanup is a distinct final track and never appears when cleanup did not complete. + +## Frontend behavior + +The `/scenarios` page requests a run ID and owning lab session ID, then renders events in backend ordinal order. It exposes the human explanation, state, evidence ID, RPC method, safe CLI command, transaction and PSBT references, relationship, height, and raw safe result. Replacement, parent-child, and maturity events receive explicit labels. If the backend returns no events, the view says so and does not fill gaps. + +## Authoring a mapping + +Add lifecycle mappings only for stable evidence emitted by a reviewed scenario executor. Prefer structured result paths over prose, use public artifact references instead of transaction hex or PSBT payloads, and keep explanations factual. A missing or malformed optional field must remain absent; it must not be guessed. Add fake-transport coverage for event order and relationships, plus live coverage when Core behavior is material. diff --git a/docs/treasury-policy-research.md b/docs/treasury-policy-research.md new file mode 100644 index 0000000..14c4b63 --- /dev/null +++ b/docs/treasury-policy-research.md @@ -0,0 +1,124 @@ +# Community Treasury Recovery Policy Research + +## Decision + +BitScope should implement the flagship policy as a native-SegWit P2WSH Miniscript descriptor with three independently signed branches: + +```text +wsh( + or_i( + multi(2, OPERATOR_1, OPERATOR_2, OPERATOR_3), + or_i( + and_v(v:older(RECOVERY_DELAY), multi(2, RECOVERY_1, RECOVERY_2, RECOVERY_3)), + and_v(v:older(EMERGENCY_DELAY), multi(2, EMERGENCY_1, EMERGENCY_2, EMERGENCY_3)) + ) + ) +) +``` + +The configurable delays use BIP68 block units. `RECOVERY_DELAY` must be positive, `EMERGENCY_DELAY` must be greater than `RECOVERY_DELAY`, and both must remain within the reviewed block-delay range. Demo values of 5 and 10 blocks keep live tests bounded; they are not production recommendations. + +The policy decision tree is: + +```text +Treasury P2WSH output +├── immediate: any 2 of 3 treasury operators +└── delayed paths + ├── recovery: any 2 of 3 recovery signers after RECOVERY_DELAY blocks + └── emergency: any 2 of 3 emergency signers after EMERGENCY_DELAY blocks +``` + +The third branch is technically reliable with the current stack. A threshold is preferred over the initially considered single emergency key because it avoids adding a deliberate single point of signing authority. + +## Compatibility basis + +Bitcoin Core 28.1's descriptor implementation accepts Miniscript expressions inside `wsh()`, including `multi`, `older`, `and_v`, and `or_i`. Its own pinned functional test exercises relative-timelock satisfaction, branch selection, wallet signing, and finalization. Core's PSBT workflow supplies script and UTXO metadata, accumulates participant signatures, and finalizes only when a valid satisfaction exists. + +Primary references: + +- [Bitcoin Core 28.1 descriptor reference](https://github.com/bitcoin/bitcoin/blob/v28.1/doc/descriptors.md) +- [Bitcoin Core 28.1 Miniscript wallet functional test](https://github.com/bitcoin/bitcoin/blob/v28.1/test/functional/wallet_miniscript.py) +- [Bitcoin Core 28.1 descriptor multisig PSBT example](https://github.com/bitcoin/bitcoin/blob/v28.1/test/functional/wallet_multisig_descriptor_psbt.py) +- [Bitcoin Core PSBT workflow](https://github.com/bitcoin/bitcoin/blob/v28.1/doc/psbt.md) +- [Bitcoin Core 28.0 `importdescriptors` RPC documentation used by the 28.1 runtime](https://bitcoincore.org/en/doc/28.0.0/rpc/wallet/importdescriptors/) +- [Bitcoin Core 28.0 `walletprocesspsbt` RPC](https://bitcoincore.org/en/doc/28.0.0/rpc/wallet/walletprocesspsbt/) +- [BIP68 relative lock-time semantics](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki) +- [BIP112 `OP_CHECKSEQUENCEVERIFY`](https://github.com/bitcoin/bips/blob/master/bip-0112.mediawiki) + +## Isolated Core 28.1 proof + +The reproducible proof is `backend/tests/live_node/test_treasury_policy_poc.py`. It requires the repository's opt-in live-node test flag and the pinned Core 28.1 runtime. + +The research run used the official `bitcoin-28.1-win64.zip` archive with SHA-256: + +```text +2d636ad562b347c96d36870d6ed810f4a364f446ca208258299f41048b35eab0 +``` + +The hash matched Bitcoin Core's published `SHA256SUMS`. The node reported version `280100` and subversion `/Satoshi:28.1.0/`. + +Observed results: + +| Check | Core 28.1 result | +|---|---| +| Public descriptor normalization | `getdescriptorinfo` returned `issolvable=true`, `hasprivatekeys=false` | +| Address derivation | `deriveaddresses` returned one regtest P2WSH address | +| Watch-only coordinator import | `importdescriptors` returned `success=true` | +| Funding | Three independently confirmed policy outputs were discovered by exact txid/vout | +| PSBT enrichment | Coordinator `walletprocesspsbt(sign=false)` added the witness script and UTXO metadata | +| Immediate, one operator | One partial signature; `finalizepsbt` returned `complete=false` | +| Immediate, two operators | Two signatures; finalization, mempool acceptance, broadcast, and confirmation succeeded | +| Recovery, one signer | One partial signature; finalization remained incomplete | +| Recovery before five blocks | Fully signed/finalized transaction rejected as `non-BIP68-final` | +| Recovery after five blocks | The unchanged transaction was accepted, broadcast, and confirmed | +| Recovery sequence set to four | Two signatures were present, but Core returned `complete=false` and no transaction hex | +| Emergency, one signer | One partial signature; finalization remained incomplete | +| Emergency before ten blocks | Fully signed/finalized transaction rejected as `non-BIP68-final` | +| Emergency after ten blocks | The unchanged transaction was accepted, broadcast, and confirmed | + +The incorrect-sequence behavior is important: Core's Miniscript finalizer refuses to create a witness when the transaction sequence cannot satisfy `older(5)`. The flagship should classify this as expected PSBT incompleteness, not manufacture a raw transaction merely to claim a script failure. + +## Signer and wallet architecture + +The implementation should use: + +1. One isolated descriptor wallet for each educational signer. +2. One blank descriptor wallet with private keys disabled as the policy coordinator. +3. One separate session funding wallet. +4. Fresh compressed public keys obtained from each signer wallet through `getaddressinfo`. +5. The full public Miniscript descriptor imported only into the coordinator. +6. A daisy-chained PSBT signing flow through the selected participant wallets. + +No private key export is required. The descriptor, evidence, API responses, and SQLite records contain only public keys and public policy data. The coordinator cannot sign. + +All wallets still run inside one local Bitcoin Core process and one BitScope lab session. This demonstrates independent key contexts and threshold mechanics, not independent organizations, hardware-wallet custody, air-gapped review, or a production key ceremony. + +## Current dependency fit + +No new Python or frontend dependency is needed. Bitcoin Core performs descriptor parsing, Miniscript sanity checks, witness construction, PSBT signing, and finalization. The existing Python RPC client, `Decimal`, Pydantic models, evidence store, and scenario engine are sufficient. The `ecdsa` package used by the foundational CLTV lesson is not needed for this policy. + +The production RPC capability allowlist must add only the reviewed methods required by the implementation: + +- `createpsbt` +- `importdescriptors` + +The existing allowlists already include `getdescriptorinfo`, `deriveaddresses`, `decodepsbt`, `walletprocesspsbt`, `finalizepsbt`, `testmempoolaccept`, broadcast, mining, wallet inspection, and cleanup. A daisy-chained flow avoids requiring `combinepsbt`. + +## Rejected or deferred alternatives + +- **Legacy `addmultisigaddress`:** cannot represent the conditional CSV branches and would preserve the unnecessary legacy-BDB dependency. +- **Application-held private keys:** rejected because they would collapse participant authority into BitScope and violate the key-handling requirement. +- **The foundational raw CLTV signer:** useful for the Phase 3 lesson but unsuitable here because it is application-held and does not demonstrate participant-wallet PSBT signing. +- **Taproot policy:** Core 28.1 supports Miniscript in Taproot script trees, but BitScope has not proved this exact treasury with the current UI and evidence model. P2WSH is simpler to inspect and already proves every required branch. Taproot is deferred, not represented as unsupported by Core. +- **Time-based CSV and CLTV variants:** not part of the proved policy. Version 1 uses relative block delays only. +- **Ranged xpub policy:** Core documents it, but the proof deliberately uses fresh one-time public keys to avoid descriptor parsing and derivation-origin complexity in the first flagship version. + +## Implementation foundation + +The full three-path P2WSH policy now has a typed public domain model in `backend/app/models/treasury.py`. It fixes version 1 to three independent 2-of-3 groups, validates compressed public keys and isolated wallet contexts, bounds both relative block delays to BIP68's 16-bit block range, requires the emergency delay to follow the recovery delay, and generates decision-tree branches in deterministic signer-position order. + +`TreasuryPolicyService` deliberately owns only public Miniscript composition, Core normalization and address derivation, and import into a private-keys-disabled coordinator wallet. It fails closed unless Core reports the descriptor as non-ranged, solvable, and free of private keys, and it repeats the live regtest check immediately before import. It does not create wallets, fund outputs, build or sign PSBTs, advance the chain, or clean up resources; those remain responsibilities of the scenario executor and session lifecycle. + +The typed `community-treasury-recovery` scenario definition and executor now wrap this service. They retain the exact `non-BIP68-final` classifier, incorrect-sequence PSBT-incomplete result, signer threshold checks, public decision-tree evidence, deterministic artifacts, and session-owned cleanup across all three branches. + +The integrated proof run is `backend/tests/live_node/test_community_treasury_scenario_live.py`. It passed against an isolated Core 28.1 node verified with the same official archive checksum above, completing all 53 steps, 25 assertions, six exact expected-failure classifications, cleanup, and two byte-identical Proof of Spendability exports. The specialized JSON and Markdown reports fail closed unless the runtime is exactly Core 28.1, the public materialized policy is present, every spendability check succeeds or is rejected as expected, and cleanup completes. diff --git a/docs/verified-scenarios.md b/docs/verified-scenarios.md new file mode 100644 index 0000000..56137db --- /dev/null +++ b/docs/verified-scenarios.md @@ -0,0 +1,74 @@ +# Authoring Verified Scenarios + +Verified Scenarios are reviewed backend workflows, not user-supplied RPC scripts. A definition may compose only the closed step and assertion unions in `app.models.scenario`; execution belongs in a backend-owned adapter with the narrowest RPC capability that can perform the workflow. + +## Authoring contract + +1. Define the objective, phases, typed artifact references, assertions, and cleanup as an immutable versioned `ScenarioDefinition`. +2. Begin with `verify_runtime_chain`, end with `cleanup_lab`, and declare every dependency and produced artifact. +3. Use an existing service primitive or a scenario-specific adapter. Never add arbitrary method names, shell commands, Python expressions, or client-provided RPC parameters to a definition. +4. Re-run `NetworkSafetyGuard.require_regtest()` at every mutation boundary. Resolve wallets from the owning active lab session and verify ownership before mutation or cleanup. +5. Generate wallets, addresses, UTXOs, transaction identifiers, and block hashes within the run. Do not reuse values from another regtest datadir. +6. Capture typed Core output separately from BitScope interpretation. Mark run-specific paths, recursively redact credentials, and persist deterministic artifacts before their run references. +7. Base negative assertions on a structured Core result or RPC code observed against the pinned Core version. A different rejection reason is an unexpected failure, even when Core still rejects the operation. +8. Persist explicit run-state checkpoints with optimistic revisions. Both normal and failure paths must enter cleanup, and cleanup failure must prevent a verified result. +9. Add fast fake-transport tests for success, rejection mismatch, redaction, deterministic export, and cleanup. Add one complete opt-in live-node test using a disposable session and pinned Core. +10. Register the definition only after its live behavior is proved. Keep historical version resolution available for exported runs. + +## Transaction lifecycle reference + +`transaction-lifecycle` version `1.0.0` is the reference implementation. It mines 102 blocks in bounded batches, selects two coinbase outputs with at least 101 confirmations, spends the first with an explicit 10,000-satoshi fee, and observes positive preflight, broadcast, mempool presence, confirmation, and final decoding. + +The negative path creates a valid serialized transaction spending the second UTXO but makes its output one satoshi larger than its input. Wallet signing must complete, while `testmempoolaccept` must return `allowed=false` and the pinned structured reason `bad-txns-in-belowout`. Any other result fails the scenario and still triggers cleanup. + +## RBF replacement reference + +`rbf-replacement` version `1.0.0` creates a wallet transaction with `replaceable=true` and an explicit 2 sat/vB fee rate. Verification requires both an input sequence below `0xfffffffe` and Bitcoin Core's live `bip125-replaceable=true` mempool field. + +The negative path asks `bumpfee` for the transaction's existing fee rate. On pinned Core 28.1 this must fail with RPC `-8`, and the bounded message classifier requires the `Insufficient total fee`, `oldFee`, and `incrementalFee` markers. A different error does not satisfy the assertion. The recovery path adds 10 sat/vB, requires a distinct replacement txid, proves the original is absent with `getmempoolentry` RPC `-5`, observes the replacement in the mempool, and mines its confirmation. + +The proof bundle separates original signaling, the insufficient-fee failure, replacement economics and eviction, and confirmed replacement decoding. RBF remains mempool policy: the evidence describes the tested Core version and node configuration, not a consensus rule or production fee recommendation. + +## Multisig PSBT reference + +`multisig-psbt` version `1.0.0` creates a native-SegWit 2-of-3 policy from three session-owned legacy wallets, each contributing one signer key. It funds and confirms one policy output, then constructs an unsigned one-input PSBT with an explicit fee rate. + +The negative path processes the PSBT with only the first signer. Verification requires exactly one partial signature, `complete=false`, and a non-extracting `finalizepsbt` result with no transaction hex. Both signing calls set `finalize=false` so signatures remain inspectable; after the second signer Core 28.1 therefore reports two partial signatures and `complete=false`. A separate `finalizepsbt` call must report `complete=true` and extract transaction hex before preflight, broadcast, observation, confirmation, and decoding. + +The pinned Core 28.1 node requires `-deprecatedrpc=create_bdb` because this compatibility path uses `addmultisigaddress` in legacy wallets. All three wallets are owned by the same BitScope lab session and controlled by one Core process. The scenario proves staged threshold behavior; it does not prove independent custody, hardware-wallet isolation, or a production multisig ceremony. + +## CLTV timelock reference + +`cltv-timelock` version `1.0.0` creates a fresh secp256k1 key in process memory and commits only its compressed public key to ` OP_CHECKLOCKTIMEVERIFY OP_DROP OP_CHECKSIG`. Core derives the native-SegWit P2WSH address, the isolated session wallet funds and confirms its exact outpoint, and BitScope signs the one-input spend with the standard BIP143 digest. The private scalar is never sent to RPC, persisted in SQLite, written to settings, or captured in evidence; cleanup drops the signer reference before unloading the session wallet. Python does not guarantee immediate zeroization of released memory. + +Before maturity, Core 28.1 must reject the correctly signed spend with `allowed=false` and `reject-reason=non-final`. A separately signed `0xffffffff` sequence variant and an nLockTime-one-block-low variant must both contain Core's `Locktime requirement not satisfied` script marker. Different rejections fail closed. The executor then advances by only the blocks needed to reach the exact target, requires Core to accept the unchanged originally premature transaction, broadcasts it, observes it in mempool, and confirms it. + +This proves an absolute block-height CLTV branch on regtest. It does not prove median-time-past CLTV, relative CSV, hardware custody, durable recovery-key backup, or production policy safety. + +Deterministic bundles contain node context, scenario evidence, Core output, assertions, safe reproduction commands, manifest hashes, and a run report. They are regtest evidence—not signatures, audits, or production-spend approvals. + +## Community Treasury Recovery executor + +`community-treasury-recovery` version `1.0.0` is a typed 53-step flagship definition built around the Core 28.1-proved P2WSH Miniscript policy. It creates nine session-owned descriptor signer wallets across operator, recovery, and emergency groups plus one private-keys-disabled coordinator. The coordinator imports only the public descriptor; no private key export or application-held signer is used. + +The immediate branch proves one-signature incompleteness followed by a finalized, preflighted, confirmed 2-of-3 operator spend. The five-block recovery branch proves one-signature incompleteness, exact premature `non-BIP68-final` rejection, and Core finalizer refusal for a fully signed sequence-four PSBT before confirming the unchanged mature transaction. The ten-block emergency branch independently proves its one-signature, premature, mature, and confirmed states. + +All six negative outcomes are recorded as expected failures only after exact classification. Any different premature reason, signature count, PSBT completion state, sequence, descriptor property, or transaction state fails the run and still invokes session-owned cleanup. + +The specialized export adds `proof-of-spendability.json` and a treasury-specific `report.md` to the deterministic bundle. It reports the public descriptor and decision tree, Core compatibility, ten typed spendability and cleanup checks, exact expected-rejection classifications, evidence references, and the educational signer-model limitations. `VERIFIED` requires a verified scenario result, complete cleanup, policy evidence, every check passing or being rejected as expected, and the exact pinned Core 28.1 runtime. + +`backend/tests/live_node/test_community_treasury_scenario_live.py` runs the registered integrated executor against a disposable Core 28.1 regtest node, verifies all 53 steps and 25 assertions, confirms the six classified negative outcomes, checks session-owned cleanup, and exports the proof bundle twice to prove byte-for-byte determinism. The existing blocking `tests/live_node` CI job includes this test. + +## Typed attack verification + +All mandatory scenarios now declare reviewed attack applicability before attempting their negative paths and export a shared `evidence/attacks.summary.json`. Transaction lifecycle classifies output modification; RBF classifies replacement-policy failure; multisig classifies signature insufficiency and PSBT incompleteness; CLTV classifies premature execution plus sequence and locktime modification; Community Treasury Recovery reuses signature insufficiency, PSBT incompleteness, premature execution, and sequence modification across its branches. + +Classification is driven first by structured Core fields such as `allowed`, `complete`, signature count, RPC method, and numeric RPC code. Bounded text markers are supplemental only where Core 28.1 provides no narrower machine field. Unsupported attack types return `not_applicable` with a reason and are not executed. Expected, unexpected, and skipped results are distinct, while safe raw details are recursively redacted and retained. See `docs/attack-verification.md` for the authoring and evidence contract. + +## Transaction lifecycle evidence + +All five mandatory scenarios export typed, ordered lifecycle evidence and a deterministic `lifecycle.json`. Events are emitted only from explicitly mapped persisted evidence; cleanup is appended only after successful cleanup. RBF records its replacement with a `replaces` relationship, CLTV records absolute maturity, and the treasury flagship separates immediate, recovery, and emergency tracks with independent maturity events. The shared schema and UI also render an explicitly recorded CPFP `child_of` relationship, while the optional CPFP scenario itself remains deferred. See [Transaction Lifecycle Recorder](transaction-lifecycle-recorder.md) for the evidence and frontend contract. + +## Challenge completion reuse + +Challenge Mode does not add a second transaction validator. Each challenge identifies one reviewed scenario plus a bounded subset of its typed assertions and canonical evidence. Completion requires the owner-scoped run to be verified, cleanup to be complete, Bitcoin Core identity to be recorded, every required assertion to have passed, and every required artifact to load with its stored SHA-256 identity. The final explanation remains locked until those checks pass, and the exported completion document cites the exact artifact hashes. See [Curriculum Mapping and Challenge Mode](curriculum-and-challenge-mode.md). diff --git a/frontend/app/curriculum/page.tsx b/frontend/app/curriculum/page.tsx new file mode 100644 index 0000000..8f3f2f5 --- /dev/null +++ b/frontend/app/curriculum/page.tsx @@ -0,0 +1,5 @@ +import { CurriculumChallengeHub } from "@/components/CurriculumChallengeHub"; + +export default function CurriculumPage() { + return ; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 1fe8720..0a80321 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { SidebarNavigation } from "@/components/SidebarNavigation"; import { ThemeToggle } from "@/components/ThemeToggle"; import "./globals.css"; @@ -8,34 +9,6 @@ export const metadata: Metadata = { description: "An interactive Bitcoin Core laboratory powered entirely by your own node." }; -const navItems = [ - { href: "/", label: "Dashboard" }, - { href: "/demo", label: "Demo" }, - { href: "/live", label: "Live" }, - { href: "/integrations", label: "Integrations" }, - { href: "/peers", label: "Peers" }, - { href: "/wallet", label: "Wallet" }, - { href: "/regtest", label: "Regtest" }, - { href: "/blocks", label: "Blocks" }, - { href: "/transactions", label: "Transactions" }, - { href: "/tx-control", label: "Tx Control" }, - { href: "/mempool", label: "Mempool" }, - { href: "/fees", label: "Fees" }, - { href: "/address", label: "Address" }, - { href: "/keys", label: "Keys" }, - { href: "/multisig", label: "Multisig" }, - { href: "/psbt", label: "PSBT" }, - { href: "/timelocks", label: "Timelocks" }, - { href: "/descriptors", label: "Descriptors" }, - { href: "/taproot", label: "Taproot" }, - { href: "/indexer", label: "Indexer" }, - { href: "/script", label: "Script" }, - { href: "/script-lab", label: "Script Lab" }, - { href: "/data-tx", label: "Data Tx" }, - { href: "/rpc", label: "RPC" }, - { href: "/learn", label: "Learn" } -]; - export default function RootLayout({ children }: Readonly<{ @@ -72,17 +45,7 @@ export default function RootLayout({ - +
{children}
diff --git a/frontend/app/scenarios/page.tsx b/frontend/app/scenarios/page.tsx new file mode 100644 index 0000000..a3a4dd0 --- /dev/null +++ b/frontend/app/scenarios/page.tsx @@ -0,0 +1,5 @@ +import { ScenarioLifecycleTimelineView } from "@/components/ScenarioLifecycleTimeline"; + +export default function ScenariosPage() { + return ; +} diff --git a/frontend/components/CurriculumChallengeHub.tsx b/frontend/components/CurriculumChallengeHub.tsx new file mode 100644 index 0000000..d9e5b87 --- /dev/null +++ b/frontend/components/CurriculumChallengeHub.tsx @@ -0,0 +1,235 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { + ChallengeDefinition, + ChallengeHint, + ChallengeVerificationResult, + CurriculumEntry, + fetchChallengeHint, + fetchChallenges, + fetchCurriculum, + verifyChallenge +} from "@/lib/api"; + +export function CurriculumChallengeHub() { + const [chapters, setChapters] = useState([]); + const [courseUrl, setCourseUrl] = useState(""); + const [curriculumExplanation, setCurriculumExplanation] = useState(""); + const [challenges, setChallenges] = useState([]); + const [challengeExplanation, setChallengeExplanation] = useState(""); + const [selectedId, setSelectedId] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + async function load() { + try { + const [curriculum, catalog] = await Promise.all([fetchCurriculum(), fetchChallenges()]); + if (!active) return; + setChapters(curriculum.chapters); + setCourseUrl(curriculum.course_url); + setCurriculumExplanation(curriculum.explanation); + setChallenges(catalog.challenges); + setChallengeExplanation(catalog.explanation); + setSelectedId(catalog.challenges[0]?.challenge_id ?? ""); + } catch (caught) { + if (active) setError(caught instanceof Error ? caught.message : "The curriculum could not be loaded."); + } finally { + if (active) setLoading(false); + } + } + void load(); + return () => { + active = false; + }; + }, []); + + const selectedChallenge = useMemo( + () => challenges.find((challenge) => challenge.challenge_id === selectedId) ?? challenges[0] ?? null, + [challenges, selectedId] + ); + + return ( +
+
+

Curriculum and Challenge Mode

+

Learn the concept, then prove the result

+

+ Chapters 3–13 are mapped only to BitScope features that exist. Challenges keep solutions locked and validate completed work from backend-owned Bitcoin Core evidence. +

+ +
+ + {error ?

{error}

: null} + {loading ?

Loading curriculum and challenges…

: null} + +
+
+

Learning path

+

Chapters 3–13

+ {curriculumExplanation ?

{curriculumExplanation}

: null} + {courseUrl ? Open the original course repository in a new tab : null} +
+
+ {chapters.map((entry) => )} +
+
+ +
+
+

Core-validated practice

+

Challenge Mode

+ {challengeExplanation ?

{challengeExplanation}

: null} +
+
+
+

Choose a challenge

+
+ {challenges.map((challenge) => ( + + ))} +
+
+ {selectedChallenge ? : null} +
+
+
+ ); +} + +function CurriculumChapter({ entry }: { entry: CurriculumEntry }) { + return ( +
+ +
+

Chapter {entry.chapter}

{entry.title}

{entry.learning_objective}

+ +
+
+
+ + + +

Implemented links

{entry.relevant_pages.map((page) => {page})}
{entry.relevant_scenarios.length ?

Verified Scenarios: {entry.relevant_scenarios.join(", ")}

: null}
+

Guided exercise

{entry.guided_exercise}

+

Independent challenge

{entry.independent_challenge}

+
+ {entry.implementation_note ?

Implementation boundary: {entry.implementation_note}

: null} + Read the original chapter in a new tab +
+ ); +} + +function ChapterList({ title, values, mono = false }: { title: string; values: string[]; mono?: boolean }) { + return

{title}

    {values.map((value) =>
  • {value}
  • )}
; +} + +function ChallengeWorkspace({ challenge }: { challenge: ChallengeDefinition }) { + const [hints, setHints] = useState([]); + const [runId, setRunId] = useState(""); + const [labSessionId, setLabSessionId] = useState(""); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const [working, setWorking] = useState<"hint" | "verify" | null>(null); + const statusRef = useRef(null); + + async function requestHint() { + if (hints.length >= challenge.hint_count) return; + setWorking("hint"); + setError(""); + try { + const hint = await fetchChallengeHint(challenge.challenge_id, hints.length + 1); + setHints((current) => [...current, hint]); + window.requestAnimationFrame(() => statusRef.current?.focus()); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "The hint could not be loaded."); + } finally { + setWorking(null); + } + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!runId.trim() || !labSessionId.trim()) { + setError("Provide both the completed scenario run ID and its lab session ID."); + return; + } + setWorking("verify"); + setError(""); + try { + setResult(await verifyChallenge(challenge.challenge_id, runId.trim(), labSessionId.trim())); + window.requestAnimationFrame(() => statusRef.current?.focus()); + } catch (caught) { + setResult(null); + setError(caught instanceof Error ? caught.message : "Challenge verification failed."); + } finally { + setWorking(null); + } + } + + return ( +
+
{challenge.difficulty}{challenge.scenario_id}

{challenge.title}

{challenge.objective}

+
+ +

Relevant pages

{challenge.relevant_pages.map((page) => {page})}
+
+

Verification boundary

{challenge.verification_summary}

The solution remains locked until backend completion.

+ +
+

Progressive hints

+ {hints.length ?
    {hints.map((hint) =>
  1. Hint {hint.level}: {hint.hint}
  2. )}
:

No hints revealed.

} +
+ +
+

Submit Core-backed evidence

+

Challenge validation reads the scenario run and canonical evidence artifacts from the backend. Browser state cannot complete a challenge.

+
+ +
+ + {error ?

{error}

: null} +
+ {result ? : hints.length ?

Hint {hints.length} is now available.

: null} +
+
+ ); +} + +function ChallengeResult({ result }: { result: ChallengeVerificationResult }) { + return ( +
+

Backend verification result

+

{result.completed ? "Challenge completed" : "Completion still locked"}

+

{result.final_explanation}

+
    {result.checks.map((check) =>
  • {check.check_id}{check.explanation}{check.passed ? "passed" : "not passed"}
  • )}
+ {result.completed ? : null} +
+ ); +} + +function downloadEvidence(result: ChallengeVerificationResult) { + const blob = new Blob([`${JSON.stringify(result, null, 2)}\n`], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `bitscope-challenge-${result.challenge_id}-${result.run_id}.json`; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/components/ScenarioLifecycleTimeline.tsx b/frontend/components/ScenarioLifecycleTimeline.tsx new file mode 100644 index 0000000..60e80f8 --- /dev/null +++ b/frontend/components/ScenarioLifecycleTimeline.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { + TransactionLifecycleEvent, + TransactionLifecycleTimeline, + fetchScenarioLifecycle +} from "@/lib/api"; + +const eventLabels: Record = { + wallet_prepared: "Wallet prepared", + utxo_selected: "UTXO selected", + raw_transaction_created: "Raw transaction created", + transaction_funded: "Transaction funded", + psbt_created: "PSBT created", + psbt_partially_signed: "PSBT partially signed", + psbt_completed: "PSBT completed", + transaction_finalized: "Transaction finalized", + mempool_preflight_completed: "Mempool preflight", + transaction_broadcast: "Transaction broadcast", + transaction_entered_mempool: "Entered mempool", + transaction_replaced: "Transaction replaced", + child_transaction_created: "Child transaction created", + transaction_confirmed: "Transaction confirmed", + timelock_matured: "Timelock matured", + scenario_cleaned_up: "Scenario cleaned up" +}; + +const relationshipLabels: Record["relationship_type"], string> = { + replaces: "Replaces", + replaced_by: "Replaced by", + child_of: "Child of", + parent_of: "Parent of", + conflicts_with: "Conflicts with" +}; + +export function ScenarioLifecycleTimelineView() { + const [runId, setRunId] = useState(""); + const [labSessionId, setLabSessionId] = useState(""); + const [timeline, setTimeline] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const query = new URLSearchParams(window.location.search); + setRunId(query.get("run_id") ?? ""); + setLabSessionId(query.get("lab_session_id") ?? ""); + }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!runId.trim() || !labSessionId.trim()) { + setError("Provide both the scenario run ID and its lab session ID."); + return; + } + setLoading(true); + setError(""); + try { + setTimeline(await fetchScenarioLifecycle(runId.trim(), labSessionId.trim())); + } catch (caught) { + setTimeline(null); + setError(caught instanceof Error ? caught.message : "The scenario lifecycle could not be loaded."); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Scenario evidence

+

Transaction lifecycle

+

+ Inspect the exact transaction states recorded by the backend. Missing states are left missing; this view never derives events from neighboring evidence. +

+
+ +
+ + + +
+ + {error ?

{error}

: null} + {timeline ? : null} +
+ ); +} + +function RecordedTimeline({ timeline }: { timeline: TransactionLifecycleTimeline }) { + return ( +
+
+

{timeline.scenario_id} · v{timeline.scenario_version}

+

Recorded events

+

{timeline.events.length} persisted event{timeline.events.length === 1 ? "" : "s"} for run {timeline.run_id}.

+
+ {timeline.events.length === 0 ? ( +

The backend recorded no lifecycle events. No missing states are inferred.

+ ) : ( +
    + {timeline.events.map((event) => )} +
+ )} +
+ ); +} + +function LifecycleCard({ event }: { event: TransactionLifecycleEvent }) { + const isMaturity = event.event_type === "timelock_matured"; + return ( +
  • +
    +
    +

    #{event.ordinal} · {event.track_id}

    +

    {eventLabels[event.event_type]}

    +
    +
    + {event.transaction_state} + {event.block_height !== null ? height {event.block_height} : null} +
    +
    +

    {event.explanation}

    + {event.relationship ? ( +
    +

    {relationshipLabels[event.relationship.relationship_type]} → {event.relationship.related_txid}

    +

    {event.relationship.explanation}

    +
    + ) : null} +
    + + + + + + + + + + +
    +
    + Inspect RPC, CLI, and safe raw evidence +
    +

    Step: {event.step_id}

    +
    {formatCommand(event)}
    +
    {JSON.stringify(event.raw_safe_core_result, null, 2)}
    +
    +
    +
  • + ); +} + +function Datum({ label, value, mono = false }: { label: string; value: string | null; mono?: boolean }) { + if (value === null) return null; + return
    {label}
    {value}
    ; +} + +function formatCommand(event: TransactionLifecycleEvent): string { + return [event.cli_command.executable, ...event.cli_command.arguments].join(" "); +} diff --git a/frontend/components/SidebarNavigation.tsx b/frontend/components/SidebarNavigation.tsx new file mode 100644 index 0000000..8f9db24 --- /dev/null +++ b/frontend/components/SidebarNavigation.tsx @@ -0,0 +1,186 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; + +type NavigationItem = { + href: string; + label: string; +}; + +type NavigationGroup = { + id: string; + label: string; + items: NavigationItem[]; +}; + +const START_ITEMS: NavigationItem[] = [ + { href: "/", label: "Dashboard" }, + { href: "/demo", label: "Demo" }, + { href: "/curriculum", label: "Learning Path" }, + { href: "/learn", label: "Concept Library" } +]; + +const NAVIGATION_GROUPS: NavigationGroup[] = [ + { + id: "core-basics", + label: "Bitcoin Core Basics", + items: [ + { href: "/live", label: "Node & Live Status" }, + { href: "/peers", label: "Peers" }, + { href: "/blocks", label: "Blocks" }, + { href: "/mempool", label: "Mempool" }, + { href: "/fees", label: "Fees" } + ] + }, + { + id: "wallets-funding", + label: "Wallets & Funding", + items: [ + { href: "/regtest", label: "Regtest" }, + { href: "/wallet", label: "Wallet" }, + { href: "/address", label: "Addresses" }, + { href: "/keys", label: "Keys" }, + { href: "/descriptors", label: "Descriptors" } + ] + }, + { + id: "transactions", + label: "Transactions", + items: [ + { href: "/transactions", label: "Transaction Explorer" }, + { href: "/tx-control", label: "Fee Bumping" }, + { href: "/multisig", label: "Multisig" }, + { href: "/psbt", label: "PSBT" }, + { href: "/timelocks", label: "Timelocks" }, + { href: "/data-tx", label: "OP_RETURN Data" } + ] + }, + { + id: "script", + label: "Bitcoin Script", + items: [ + { href: "/script", label: "Script Explorer" }, + { href: "/script-lab", label: "Script Lab" }, + { href: "/taproot", label: "Taproot" } + ] + }, + { + id: "practice-proof", + label: "Practice & Proof", + items: [{ href: "/scenarios", label: "Verified Scenarios" }] + }, + { + id: "advanced-tools", + label: "Advanced Tools", + items: [ + { href: "/rpc", label: "RPC Explorer" }, + { href: "/integrations", label: "Integrations" }, + { href: "/indexer", label: "Local Indexer" } + ] + } +]; + +export function SidebarNavigation() { + const pathname = usePathname(); + const currentGroupId = groupForPath(pathname)?.id ?? null; + const [openGroups, setOpenGroups] = useState>( + () => new Set(["core-basics", ...(currentGroupId ? [currentGroupId] : [])]) + ); + + useEffect(() => { + if (!currentGroupId) return; + setOpenGroups((current) => { + if (current.has(currentGroupId)) return current; + const next = new Set(current); + next.add(currentGroupId); + return next; + }); + }, [currentGroupId]); + + function toggleGroup(groupId: string) { + setOpenGroups((current) => { + if (groupId === currentGroupId && current.has(groupId)) return current; + const next = new Set(current); + if (next.has(groupId)) next.delete(groupId); + else next.add(groupId); + return next; + }); + } + + return ( + + ); +} + +function NavigationLink({ item, active }: { item: NavigationItem; active: boolean }) { + return ( + +