diff --git a/.github/workflows/acquire-sky-supply-basis.yml b/.github/workflows/acquire-sky-supply-basis.yml index eb8ca6f..bdc0708 100644 --- a/.github/workflows/acquire-sky-supply-basis.yml +++ b/.github/workflows/acquire-sky-supply-basis.yml @@ -13,10 +13,25 @@ name: "Acquire Sky SupplyBasisSnapshot (Milestone 1)" # - must be removed once the normal acquisition path (PR #110) can run in # the supported execution environment. # -# Idempotency: the Python step checks whether an authoritative -# SupplyBasisSnapshot for Sky already exists in the canonical database before -# attempting acquisition. If a record is found, the step exits early with no -# database writes. Re-running the workflow is therefore safe. +# Acquisition logic lives in scripts/acquire_sky_supply_basis.py on `main` +# (ruff/black/mypy/pytest-covered — see tests/test_acquire_sky_supply_basis.py), +# not embedded in this workflow. The checkout below targets the PR #110 +# branch (it owns the canonical database this workflow must commit to), so a +# dedicated step below materializes the reviewed copy of the script from +# `main` into that checkout without committing it there — only +# data/data_ops.sqlite is ever committed back to the PR branch. +# +# Idempotency: the script checks whether an authoritative SupplyBasisSnapshot +# for Sky already exists (via the repository's own strict_known_supply +# lookup — no hand-rolled logical-ID reimplementation) before attempting +# acquisition. If found, it exits successfully with no database writes. +# Re-running the workflow is therefore safe. +# +# Database safety: the script captures record_type row counts before and +# after acquisition and fails closed if any type other than "snapshot" +# changed, so an unrelated code path writing to the canonical database +# (as happened once before with tests/test_data_ops.py's hardcoded path) +# cannot be silently committed by this workflow. # # Signing key lifecycle: # HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID and HUNTER_VALUE_CAPTURE_SIGNING_KEY @@ -26,7 +41,7 @@ name: "Acquire Sky SupplyBasisSnapshot (Milestone 1)" # # Requires two repository secrets (see above for lifecycle): # HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID -# HUNTER_VALUE_CAPTURE_SIGNING_KEY (hex-encoded 32-byte key) +# HUNTER_VALUE_CAPTURE_SIGNING_KEY (hex-encoded, at least 32 bytes) # # Run manually via Actions → "Run workflow" on branch # claude/issue-107-milestone-1-entity-registration. @@ -71,6 +86,14 @@ jobs: set -euo pipefail python -m pip install --disable-pip-version-check -c requirements/ci-constraints.txt -e ".[dev]" + - name: Fetch acquisition script from main + run: | + set -euo pipefail + git fetch --depth=1 origin main + mkdir -p scripts + git show origin/main:scripts/acquire_sky_supply_basis.py > scripts/acquire_sky_supply_basis.py + python -m py_compile scripts/acquire_sky_supply_basis.py + - name: Acquire Sky market facts and SupplyBasisSnapshot env: HUNTER_APPLICATION_ROOT: ${{ github.workspace }} @@ -78,200 +101,7 @@ jobs: HUNTER_VALUE_CAPTURE_SIGNING_KEY: ${{ secrets.HUNTER_VALUE_CAPTURE_SIGNING_KEY }} run: | set -euo pipefail - python3 - <<'PYEOF' - import hashlib, os, sys - from datetime import datetime, timezone - from pathlib import Path - - from hunter.market_facts.models import MarketFactIdentity, MarketFactRequest - from hunter.market_facts.providers import CoinGeckoObservedMarketFactProvider - from hunter.market_facts.registry import MarketFactSourceRegistry - from hunter.market_facts.repository import ObservedMarketFactRepository - from hunter.market_facts.service import ObservedMarketFactService - from hunter.value_capture.models import EconomicClaimIdentity - from hunter.value_capture.providers import ( - RegisteredValueCaptureProvider, - ValueCaptureVerificationKeyRegistry, - ) - from hunter.value_capture.registry import ValueCaptureSourceRegistry - from hunter.value_capture.repository import SupplyAndValueCaptureRepository - from hunter.value_capture.service import SupplyAndValueCaptureService - - APP_ROOT = Path(os.environ["HUNTER_APPLICATION_ROOT"]) - DB_PATH = APP_ROOT / "data" / "data_ops.sqlite" - SIGNING_KEY_ID = os.environ["HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID"] - SIGNING_KEY = bytes.fromhex(os.environ["HUNTER_VALUE_CAPTURE_SIGNING_KEY"]) - now = datetime.now(timezone.utc) - print(f"Acquisition timestamp: {now.isoformat()}", flush=True) - - # ── Idempotency guard ─────────────────────────────────────────────── - # Compute the canonical logical_id for Sky's circulating-supply - # snapshot and check whether any record already exists. This mirrors - # the _logical_id() function in hunter.value_capture.service so that - # the check is stable across runs regardless of acquisition timestamp. - _raw = "|".join(( - "entity:sky", - "economic-claim:sky-smart-burn-engine", - "asset:sky", - "representation:sky-ethereum", - "sky", - "ethereum", - "0x56072c95faa701256059aa122697b133aded9279", - "supply:circulating_supply", - )) - _logical_id = hashlib.sha256(_raw.encode()).hexdigest() - vc_repository_check = SupplyAndValueCaptureRepository(DB_PATH) - existing_supply = vc_repository_check.supply_history(_logical_id) - if existing_supply: - print( - f"SupplyBasisSnapshot already exists ({len(existing_supply)} record(s))" - f" for logical_id={_logical_id[:16]}... — skipping acquisition (idempotent).", - flush=True, - ) - sys.exit(0) - print("No existing SupplyBasisSnapshot found — proceeding with acquisition.", flush=True) - - # ── Step 1: CoinGecko market facts for Sky ────────────────────────── - mf_registry = MarketFactSourceRegistry.from_file( - APP_ROOT / "configs" / "market_fact_sources.yaml" - ) - sky_mf_identity = MarketFactIdentity( - entity_id="entity:sky", - asset_id="asset:sky", - representation_id="representation:sky-ethereum", - chain="ethereum", - contract_address="0x56072c95faa701256059aa122697b133aded9279", - provider_listing_id="sky", - ) - mf_request = MarketFactRequest( - source_id="coingecko-coin-market-facts", - provider_id="coingecko", - identity=sky_mf_identity, - quote_currency="usd", - requested_fact_types=( - "spot_price", - "circulating_supply", - "total_supply", - "max_supply", - ), - requested_at=now, - ) - mf_provider = CoinGeckoObservedMarketFactProvider(mf_registry) - mf_result = mf_provider.collect(mf_request) - if mf_result.status != "success": - print( - f"CoinGecko acquisition failed: status={mf_result.status!r}" - f" reason={mf_result.failure_reason!r}", - file=sys.stderr, - ) - sys.exit(1) - - mf_repository = ObservedMarketFactRepository(DB_PATH) - mf_service = ObservedMarketFactService(mf_repository, mf_registry) - mf_records = mf_service.ingest(mf_request, mf_result, recorded_at=now) - print(f"Market facts persisted: {len(mf_records)} records", flush=True) - by_type = {} - for r in mf_records: - print(f" {r.fact_type}: {r.value} {r.unit} [{r.record_id[:16]}...]", flush=True) - by_type[r.fact_type] = r - - circulating = by_type.get("circulating_supply") - total = by_type.get("total_supply") - max_sup = by_type.get("max_supply") - if not circulating: - print("No circulating_supply record acquired — cannot build SupplyBasisSnapshot.", file=sys.stderr) - sys.exit(1) - - components = [["circulating_supply", circulating.value]] - if total: - components.append(["total_supply", total.value]) - if max_sup: - components.append(["fully_diluted_supply", max_sup.value]) - - # ── Step 2: SupplyBasisSnapshot via sky-protocol-tokenomics-disclosure ─ - vc_registry = ValueCaptureSourceRegistry.from_yaml( - APP_ROOT / "configs" / "value_capture_sources.yaml" - ) - vc_repository = SupplyAndValueCaptureRepository(DB_PATH) - verification_keys = ValueCaptureVerificationKeyRegistry({SIGNING_KEY_ID: SIGNING_KEY}) - vc_service = SupplyAndValueCaptureService( - registry=vc_registry, - repository=vc_repository, - verification_keys=verification_keys, - market_fact_repository=mf_repository, - ) - vc_source = vc_registry.require("sky-protocol-tokenomics-disclosure") - vc_provider = RegisteredValueCaptureProvider( - vc_source, signing_key_id=SIGNING_KEY_ID, signing_key=SIGNING_KEY - ) - supply_identity = EconomicClaimIdentity( - entity_id="entity:sky", - economic_claim_id="economic-claim:sky-smart-burn-engine", - asset_id="asset:sky", - representation_id="representation:sky-ethereum", - token_id="sky", - chain="ethereum", - contract_address="0x56072c95faa701256059aa122697b133aded9279", - ) - - # Look up the existing evidence record to reference its ID. - evidence = vc_repository.strict_known_evidence( - entity_id="entity:sky", - economic_claim_id="economic-claim:sky-smart-burn-engine", - representation_id="representation:sky-ethereum", - evidence_type="official_disclosure", - effective_as_of=now, - known_by=now, - ) - if evidence is None: - print("Sky evidence record not found in database.", file=sys.stderr) - sys.exit(1) - print(f"Referencing evidence: {evidence.record_id[:16]}...", flush=True) - - acquisition_id = f"sky-supply-basis-milestone-1-{now.strftime('%Y%m%d%H%M%S')}" - supply_payload = { - "supply_basis_type": "circulating_supply", - "quantity": circulating.value, - "unit": "native_units", - "denominator_meaning": ( - "Provider-observed circulating SKY units (CoinGecko), cross-referenced" - " with Sky Protocol official tokenomics disclosure." - ), - "supply_policy_id": "canonical-token-supply-v1", - "supply_policy_version": "1.0.0", - "quantity_components": components, - "observed_market_fact_ids": [r.record_id for r in mf_records], - "observed_market_fact_versions": [r.semantic_version for r in mf_records], - "source_record_id": f"sky-tokenomics-disclosure-supply-{now.strftime('%Y-%m-%d')}", - "source_record_version": now.strftime("%Y-%m-%d"), - "confidence": "0.80", - "uncertainty": "0.20", - "effective_at": now, - "evidence_record_ids": [evidence.record_id], - "quality_state": "accepted", - "conflict_state": "none", - } - supply_acq = vc_provider.acquisition( - kind="supply", - capability="supply:circulating_supply", - endpoint="https://developers.sky.money/core-protocol/smart-burn-engine/", - acquisition_id=acquisition_id, - acquired_at=now, - identity=supply_identity, - payload=supply_payload, - ) - supply_record = vc_service.ingest_supply(vc_provider, supply_acq) - print(f"\nSupplyBasisSnapshot persisted:", flush=True) - print(f" record_id: {supply_record.record_id}", flush=True) - print(f" logical_id: {supply_record.logical_id}", flush=True) - print(f" quality_state: {supply_record.quality_state}", flush=True) - print(f" conflict_state: {supply_record.conflict_state}", flush=True) - print( - "\nMilestone 1 evidence chain complete:" - " FundamentalEvidenceRecord + ValueCaptureRuleSnapshot + SupplyBasisSnapshot all persisted.", - flush=True, - ) - PYEOF + python scripts/acquire_sky_supply_basis.py - name: Commit updated database run: | diff --git a/pyproject.toml b/pyproject.toml index 28af4f6..91286fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ whale-intelligence = "hunter.intelligence.engines.whale:create_plugin" hunter = "hunter.__main__:main" [tool.pytest.ini_options] -pythonpath = ["src"] +pythonpath = ["src", "scripts"] testpaths = ["tests"] [tool.ruff] @@ -56,7 +56,7 @@ target-version = ["py311"] [tool.mypy] python_version = "3.11" -files = ["src", "tests"] +files = ["src", "tests", "scripts"] warn_unused_configs = true disable_error_code = [ "arg-type", diff --git a/scripts/acquire_sky_supply_basis.py b/scripts/acquire_sky_supply_basis.py new file mode 100644 index 0000000..15a2ad5 --- /dev/null +++ b/scripts/acquire_sky_supply_basis.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import os +import sqlite3 +import sys +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from hunter.market_facts.models import MarketFactIdentity, MarketFactRequest, ObservedMarketFactRecord +from hunter.market_facts.providers import CoinGeckoObservedMarketFactProvider, JsonTransport +from hunter.market_facts.registry import MarketFactSourceRegistry +from hunter.market_facts.repository import ObservedMarketFactRepository +from hunter.market_facts.service import ObservedMarketFactService +from hunter.value_capture.models import EconomicClaimIdentity, SupplyBasisSnapshot +from hunter.value_capture.providers import RegisteredValueCaptureProvider, ValueCaptureVerificationKeyRegistry +from hunter.value_capture.registry import ValueCaptureSourceRegistry +from hunter.value_capture.repository import SupplyAndValueCaptureRepository +from hunter.value_capture.service import SupplyAndValueCaptureService + +_ENTITY_ID = "entity:sky" +_ECONOMIC_CLAIM_ID = "economic-claim:sky-smart-burn-engine" +_ASSET_ID = "asset:sky" +_REPRESENTATION_ID = "representation:sky-ethereum" +_TOKEN_ID = "sky" +_CHAIN = "ethereum" +_CONTRACT_ADDRESS = "0x56072c95faa701256059aa122697b133aded9279" +_PROVIDER_LISTING_ID = "sky" + +_MARKET_FACT_SOURCE_ID = "coingecko-coin-market-facts" +_MARKET_FACT_PROVIDER_ID = "coingecko" +_MARKET_FACT_TYPES = ("circulating_supply", "total_supply", "max_supply") + +_VALUE_CAPTURE_SOURCE_ID = "sky-protocol-tokenomics-disclosure" +_VALUE_CAPTURE_ENDPOINT = "https://developers.sky.money/core-protocol/smart-burn-engine/" + +_APPLICATION_ROOT_ENV = "HUNTER_APPLICATION_ROOT" +_SIGNING_KEY_ID_ENV = "HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID" +_SIGNING_KEY_ENV = "HUNTER_VALUE_CAPTURE_SIGNING_KEY" + +# Row types this script is authorized to change. Any other record_type +# changing between the "before" and "after" snapshot indicates an unrelated +# code path wrote to the canonical database and must fail the run rather than +# be committed. +_ALLOWED_CHANGED_RECORD_TYPES = frozenset({"snapshot"}) + + +class AcquisitionError(RuntimeError): + """Raised for any expected, actionable failure in this script.""" + + +@dataclass(frozen=True) +class DatabaseRowCounts: + counts: dict[str, int] + + @classmethod + def capture(cls, db_path: Path) -> DatabaseRowCounts: + connection = sqlite3.connect(db_path) + try: + rows = connection.execute( + "SELECT record_type, COUNT(*) FROM persistence_records GROUP BY record_type" + ).fetchall() + finally: + connection.close() + return cls(counts=dict(rows)) + + def unexpected_changes(self, after: DatabaseRowCounts) -> dict[str, tuple[int, int]]: + changed: dict[str, tuple[int, int]] = {} + for record_type in sorted(set(self.counts) | set(after.counts)): + if record_type in _ALLOWED_CHANGED_RECORD_TYPES: + continue + before_count = self.counts.get(record_type, 0) + after_count = after.counts.get(record_type, 0) + if before_count != after_count: + changed[record_type] = (before_count, after_count) + return changed + + +def application_root() -> Path: + configured = os.environ.get(_APPLICATION_ROOT_ENV, "").strip() + if not configured: + raise AcquisitionError(f"{_APPLICATION_ROOT_ENV} must identify the approved Hunter application root") + root = Path(configured).expanduser() + if not root.is_absolute(): + raise AcquisitionError(f"{_APPLICATION_ROOT_ENV} must be an absolute path") + return root.resolve() + + +def signing_key() -> tuple[str, bytes]: + key_id = os.environ.get(_SIGNING_KEY_ID_ENV, "").strip() + key_hex = os.environ.get(_SIGNING_KEY_ENV, "").strip() + if not key_id: + raise AcquisitionError(f"{_SIGNING_KEY_ID_ENV} must identify the value-capture provider signing key") + if not key_hex: + raise AcquisitionError(f"{_SIGNING_KEY_ENV} must supply the value-capture provider signing key as hex") + try: + key = bytes.fromhex(key_hex) + except ValueError as exc: + raise AcquisitionError(f"{_SIGNING_KEY_ENV} must be a hex-encoded byte string") from exc + if len(key) < 32: + raise AcquisitionError(f"{_SIGNING_KEY_ENV} must decode to at least 32 bytes (got {len(key)})") + return key_id, key + + +def _market_fact_identity() -> MarketFactIdentity: + return MarketFactIdentity( + entity_id=_ENTITY_ID, + asset_id=_ASSET_ID, + representation_id=_REPRESENTATION_ID, + chain=_CHAIN, + contract_address=_CONTRACT_ADDRESS, + provider_listing_id=_PROVIDER_LISTING_ID, + ) + + +def _value_capture_identity() -> EconomicClaimIdentity: + return EconomicClaimIdentity( + entity_id=_ENTITY_ID, + economic_claim_id=_ECONOMIC_CLAIM_ID, + asset_id=_ASSET_ID, + representation_id=_REPRESENTATION_ID, + token_id=_TOKEN_ID, + chain=_CHAIN, + contract_address=_CONTRACT_ADDRESS, + ) + + +def existing_supply_snapshot( + repository: SupplyAndValueCaptureRepository, *, now: datetime +) -> SupplyBasisSnapshot | None: + """Canonical idempotency check: use the repository's own strict-known lookup, + never a hand-rolled reimplementation of its logical-ID hash.""" + return repository.strict_known_supply( + entity_id=_ENTITY_ID, + economic_claim_id=_ECONOMIC_CLAIM_ID, + representation_id=_REPRESENTATION_ID, + effective_as_of=now, + known_by=now, + supply_basis_type="circulating_supply", + ) + + +def required_evidence_record_id(repository: SupplyAndValueCaptureRepository, *, now: datetime) -> str: + evidence = repository.strict_known_evidence( + entity_id=_ENTITY_ID, + economic_claim_id=_ECONOMIC_CLAIM_ID, + representation_id=_REPRESENTATION_ID, + effective_as_of=now, + known_by=now, + evidence_type="official_disclosure", + ) + if evidence is None: + raise AcquisitionError( + "no accepted Sky FundamentalEvidenceRecord found via strict_known_evidence — " + "Milestone 1's evidence leg must already be persisted before this script runs" + ) + return evidence.record_id + + +def require_rule_precondition(repository: SupplyAndValueCaptureRepository, *, now: datetime) -> None: + rule = repository.strict_known_rule( + entity_id=_ENTITY_ID, + economic_claim_id=_ECONOMIC_CLAIM_ID, + representation_id=_REPRESENTATION_ID, + effective_as_of=now, + known_by=now, + rule_type="buyback_and_burn", + ) + if rule is None: + raise AcquisitionError( + "no accepted Sky ValueCaptureRuleSnapshot found via strict_known_rule — " + "Milestone 1's rule leg must already be persisted before this script runs" + ) + + +def acquire_market_facts( + *, + registry: MarketFactSourceRegistry, + repository: ObservedMarketFactRepository, + now: datetime, + transport: JsonTransport | None, +) -> tuple[ObservedMarketFactRecord, ...]: + # requested_at must precede the provider's own acquisition clock (checked + # by the service as result.acquired_at/known_at >= request.requested_at), + # so it is anchored a safety margin before `now` rather than at `now` + # itself, which could otherwise race a fast in-process provider call. + request = MarketFactRequest( + source_id=_MARKET_FACT_SOURCE_ID, + provider_id=_MARKET_FACT_PROVIDER_ID, + identity=_market_fact_identity(), + quote_currency="usd", + requested_fact_types=_MARKET_FACT_TYPES, + requested_at=now - timedelta(minutes=5), + ) + provider = ( + CoinGeckoObservedMarketFactProvider(registry, transport=transport) + if transport is not None + else CoinGeckoObservedMarketFactProvider(registry) + ) + result = provider.collect(request) + if result.status != "success": + raise AcquisitionError( + f"market fact acquisition did not succeed: status={result.status!r} failure_reason={result.failure_reason!r}" + ) + service = ObservedMarketFactService(repository, registry) + # recorded_at must not precede the provider's own known_at; the provider's + # real acquisition clock is authoritative here, not the caller's `now`. + records = service.ingest(request, result, recorded_at=result.known_at) + if not records: + raise AcquisitionError("market fact acquisition returned a success status but produced no records") + return records + + +def build_supply_payload( + *, + records: tuple[ObservedMarketFactRecord, ...], + evidence_record_id: str, + now: datetime, +) -> dict[str, Any]: + by_type = {record.fact_type: record for record in records} + circulating = by_type.get("circulating_supply") + if circulating is None: + raise AcquisitionError("circulating_supply was not returned by market fact acquisition") + + quantity_components = [["circulating_supply", circulating.value]] + total = by_type.get("total_supply") + if total is not None: + quantity_components.append(["total_supply", total.value]) + max_supply = by_type.get("max_supply") + if max_supply is not None: + quantity_components.append(["fully_diluted_supply", max_supply.value]) + + return { + "supply_basis_type": "circulating_supply", + "quantity": circulating.value, + "unit": "native_units", + "denominator_meaning": ( + "Provider-observed circulating SKY units (CoinGecko), cross-referenced with" + " Sky Protocol's official tokenomics disclosure." + ), + "supply_policy_id": "canonical-token-supply-v1", + "supply_policy_version": "1.0.0", + "quantity_components": quantity_components, + "observed_market_fact_ids": [record.record_id for record in records], + "observed_market_fact_versions": [record.semantic_version for record in records], + "source_record_id": f"sky-tokenomics-disclosure-supply-{now.date().isoformat()}", + "source_record_version": now.date().isoformat(), + "confidence": "0.80", + "uncertainty": "0.20", + "effective_at": now, + "evidence_record_ids": [evidence_record_id], + "quality_state": "accepted", + "conflict_state": "none", + } + + +def run( + *, + root: Path, + transport: JsonTransport | None = None, + now: datetime | None = None, +) -> str | None: + """Run the full Sky SupplyBasisSnapshot acquisition. + + Returns the persisted record_id, or None if an accepted SupplyBasisSnapshot + already existed and no new acquisition was performed (idempotent no-op). + """ + now = now or datetime.now(UTC) + db_path = root / "data" / "data_ops.sqlite" + key_id, key = signing_key() + + mf_registry = MarketFactSourceRegistry.from_file(root / "configs" / "market_fact_sources.yaml") + mf_repository = ObservedMarketFactRepository(db_path) + vc_registry = ValueCaptureSourceRegistry.from_yaml(root / "configs" / "value_capture_sources.yaml") + vc_repository = SupplyAndValueCaptureRepository(db_path) + + existing = existing_supply_snapshot(vc_repository, now=now) + if existing is not None: + print(f"SupplyBasisSnapshot already exists (record_id={existing.record_id}) — idempotent no-op.") + return None + + require_rule_precondition(vc_repository, now=now) + evidence_record_id = required_evidence_record_id(vc_repository, now=now) + + before = DatabaseRowCounts.capture(db_path) + + market_fact_records = acquire_market_facts( + registry=mf_registry, repository=mf_repository, now=now, transport=transport + ) + for record in market_fact_records: + print(f" market fact: {record.fact_type}={record.value} {record.unit} [{record.record_id}]") + + # The supply-basis snapshot's timestamp must not precede any market fact + # it references (checked by _require_observed_market_facts as + # effective_at/recorded_at/known_at ordering). Market facts are recorded + # against the provider's own real acquisition clock (see + # acquire_market_facts), which can run later than the `now` captured at + # the top of this function, so the supply timestamp is anchored to + # whichever is later. + supply_acquired_at = max(now, *(record.known_at for record in market_fact_records)) + + verification_keys = ValueCaptureVerificationKeyRegistry({key_id: key}) + vc_service = SupplyAndValueCaptureService( + registry=vc_registry, + repository=vc_repository, + verification_keys=verification_keys, + market_fact_repository=mf_repository, + ) + source = vc_registry.require(_VALUE_CAPTURE_SOURCE_ID) + provider = RegisteredValueCaptureProvider(source, signing_key_id=key_id, signing_key=key) + payload = build_supply_payload( + records=market_fact_records, evidence_record_id=evidence_record_id, now=supply_acquired_at + ) + acquisition_id = f"sky-supply-basis-milestone-1-{supply_acquired_at.strftime('%Y%m%dT%H%M%SZ')}" + acquisition = provider.acquisition( + kind="supply", + capability="supply:circulating_supply", + endpoint=_VALUE_CAPTURE_ENDPOINT, + acquisition_id=acquisition_id, + acquired_at=supply_acquired_at, + identity=_value_capture_identity(), + payload=payload, + ) + record = vc_service.ingest_supply(provider, acquisition) + + after = DatabaseRowCounts.capture(db_path) + unexpected = before.unexpected_changes(after) + if unexpected: + raise AcquisitionError( + "database diff touched record types outside this script's authorized scope" + f" (record_type: (before, after)): {unexpected}" + ) + + print(f"SupplyBasisSnapshot persisted: record_id={record.record_id} logical_id={record.logical_id}") + print(f" quality_state={record.quality_state} conflict_state={record.conflict_state}") + return record.record_id + + +def main(argv: list[str]) -> int: + del argv + try: + result = run(root=application_root()) + except AcquisitionError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + if result is None: + print("No new SupplyBasisSnapshot was required.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_acquire_sky_supply_basis.py b/tests/test_acquire_sky_supply_basis.py new file mode 100644 index 0000000..d574a2b --- /dev/null +++ b/tests/test_acquire_sky_supply_basis.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import secrets +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import acquire_sky_supply_basis as script +import pytest +import yaml + +from hunter.market_facts.registry import MarketFactSourceRegistry +from hunter.value_capture.models import EconomicClaimIdentity +from hunter.value_capture.providers import RegisteredValueCaptureProvider, ValueCaptureVerificationKeyRegistry +from hunter.value_capture.registry import ValueCaptureSourceRegistry +from hunter.value_capture.repository import SupplyAndValueCaptureRepository +from hunter.value_capture.service import SupplyAndValueCaptureService + +# This script is intentionally Sky-specific (temporary one-shot escape hatch, +# not the generic entity-agnostic CLI), so the fixture below reproduces the +# real production Sky identity binding and value-capture source verbatim +# rather than depending on the PR #110 branch's config content being fetched +# into the local git state — that branch is not guaranteed to be a locally +# available ref during a normal CI checkout of this branch. +_MARKET_FACT_SOURCES_YAML = """ +sources: + - source_id: coingecko-coin-market-facts + provider_id: coingecko + endpoint_template: https://api.coingecko.com/api/v3/coins/{listing_id} + allowed_hosts: + - api.coingecko.com + parser_version: coingecko-coin-v1 + enabled: true + capabilities: + - spot_price + - circulating_supply + - total_supply + - max_supply + - market_capitalization + - fully_diluted_valuation + - trading_volume + quote_currencies: + - usd + units: + spot_price: quote_currency_per_unit + circulating_supply: native_units + total_supply: native_units + max_supply: native_units + market_capitalization: quote_currency + fully_diluted_valuation: quote_currency + trading_volume: quote_currency_per_24h + supported_entity_scope: + - canonical_asset_representation + identity_bindings: + - entity_id: entity:sky + asset_id: asset:sky + representation_id: representation:sky-ethereum + chain: ethereum + contract_address: "0x56072c95faa701256059aa122697b133aded9279" + provider_listing_id: sky + freshness_seconds: 3600 + observation_confidence: "0.80" + historical_support: current endpoint with provider last_updated timestamp only + limitations: Provider-observed market facts only. +""" + +_VALUE_CAPTURE_SOURCES_YAML = """ +sources: + - source_id: sky-protocol-tokenomics-disclosure + authority_tier: official + source_type: official_disclosure + allowed_hosts: + - developers.sky.money + endpoint_patterns: + - https://developers.sky.money/ + parser_version: official-tokenomics-v1 + capabilities: + - evidence:official_disclosure + - supply:circulating_supply + - supply:total_supply + - supply:burned_supply + - rule:buyback_and_burn + enabled: true +""" + +SIGNING_KEY_ID = "test-sky-supply-basis-key-v1" +SIGNING_KEY_HEX = secrets.token_hex(32) + + +class StaticTransport: + def __init__(self, payload: object) -> None: + self.payload = payload + self.urls: list[str] = [] + + def get_json(self, url: str, timeout_seconds: float) -> object: + assert timeout_seconds > 0 + self.urls.append(url) + return self.payload + + +def _application_root(tmp_path: Path) -> Path: + root = tmp_path / "application-root" + configs = root / "configs" + configs.mkdir(parents=True) + (configs / "market_fact_sources.yaml").write_text(_MARKET_FACT_SOURCES_YAML, encoding="utf-8") + (configs / "value_capture_sources.yaml").write_text(_VALUE_CAPTURE_SOURCES_YAML, encoding="utf-8") + return root + + +def test_fixture_configs_match_real_yaml_shape() -> None: + """Guard against the fixture silently drifting from valid YAML.""" + market_facts = yaml.safe_load(_MARKET_FACT_SOURCES_YAML) + value_capture = yaml.safe_load(_VALUE_CAPTURE_SOURCES_YAML) + assert market_facts["sources"][0]["source_id"] == "coingecko-coin-market-facts" + assert value_capture["sources"][0]["source_id"] == "sky-protocol-tokenomics-disclosure" + + +def _coingecko_payload(*, now: datetime) -> dict[str, object]: + return { + "id": "sky", + "last_updated": now.isoformat(), + "market_data": { + "circulating_supply": 1_000_000_000, + "total_supply": 1_200_000_000, + "max_supply": None, + }, + } + + +def _seed_evidence_and_rule(root: Path, *, now: datetime) -> None: + """Seed the two prerequisite records this script requires to already exist, + using the exact same real production services this script itself uses.""" + db_path = root / "data" / "data_ops.sqlite" + vc_registry = ValueCaptureSourceRegistry.from_yaml(root / "configs" / "value_capture_sources.yaml") + vc_repository = SupplyAndValueCaptureRepository(db_path) + verification_keys = ValueCaptureVerificationKeyRegistry({SIGNING_KEY_ID: bytes.fromhex(SIGNING_KEY_HEX)}) + service = SupplyAndValueCaptureService( + registry=vc_registry, + repository=vc_repository, + verification_keys=verification_keys, + ) + source = vc_registry.require("sky-protocol-tokenomics-disclosure") + provider = RegisteredValueCaptureProvider( + source, signing_key_id=SIGNING_KEY_ID, signing_key=bytes.fromhex(SIGNING_KEY_HEX) + ) + identity = EconomicClaimIdentity( + entity_id="entity:sky", + economic_claim_id="economic-claim:sky-smart-burn-engine", + asset_id="asset:sky", + representation_id="representation:sky-ethereum", + token_id="sky", + chain="ethereum", + contract_address="0x56072c95faa701256059aa122697b133aded9279", + ) + evidence_acq = provider.acquisition( + kind="evidence", + capability="evidence:official_disclosure", + endpoint="https://developers.sky.money/core-protocol/smart-burn-engine/", + acquisition_id="test-sky-evidence", + acquired_at=now, + identity=identity, + payload={ + "evidence_type": "official_disclosure", + "source_reference": "https://developers.sky.money/core-protocol/smart-burn-engine/", + "extracted_claim": "Sky Protocol operates the Smart Burn Engine.", + "accounting_period_start": (now - timedelta(days=30)).isoformat(), + "accounting_period_end": now.isoformat(), + "attribution_rule_id": "sky-smart-burn-engine-buyback-and-burn-v1", + "source_methodology": "official-disclosure-manual-attestation-v1", + "source_record_id": "developers.sky.money/core-protocol/smart-burn-engine", + "source_record_version": now.date().isoformat(), + "entity_link_confidence": "1", + "evidence_confidence": "0.9", + "uncertainty": "0.1", + "effective_at": now.isoformat(), + "quality_state": "accepted", + "conflict_state": "none", + }, + ) + evidence = service.ingest_evidence(provider, evidence_acq) + + rule_acq = provider.acquisition( + kind="rule", + capability="rule:buyback_and_burn", + endpoint="https://developers.sky.money/core-protocol/smart-burn-engine/", + acquisition_id="test-sky-rule", + acquired_at=now + timedelta(minutes=1), + identity=identity, + payload={ + "rule_type": "buyback_and_burn", + "entitlement_scope": "Protocol-directed buyback-and-burn of circulating SKY.", + "beneficiary_scope": "All circulating SKY holders.", + "source_economic_flow": "Protocol surplus (USDS).", + "destination_economic_flow": "Permanent SKY token burn.", + "trigger_condition": "Governance-ratified Smart Burn Engine parameters.", + "distribution_formula": "Governance-set periodic USDS allocation converted to SKY buybacks.", + "governance_or_contract_authority": "Ratified by Sky governance (Maker Core).", + "mechanism_policy_id": "sky-smart-burn-engine-v1", + "mechanism_policy_version": "1.0.0", + "dilution_treatment": "Burned SKY permanently reduces circulating and total supply.", + "claim_seniority": "No priority claim.", + "applicability_start": now.isoformat(), + "applicability_end": (now + timedelta(days=365)).isoformat(), + "limitations": ["Rate is governance-directed and may change."], + "evidence_record_ids": [evidence.record_id], + "evidence_record_versions": [evidence.semantic_version], + "source_record_id": "sky-governance-forum-smart-burn-engine-proposal", + "source_record_version": now.date().isoformat(), + "confidence": "0.9", + "uncertainty": "0.1", + "effective_at": now.isoformat(), + "quality_state": "accepted", + "conflict_state": "none", + }, + ) + service.ingest_rule(provider, rule_acq) + + +def _set_env(monkeypatch: pytest.MonkeyPatch, root: Path) -> None: + monkeypatch.setenv("HUNTER_APPLICATION_ROOT", str(root)) + monkeypatch.setenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID", SIGNING_KEY_ID) + monkeypatch.setenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY", SIGNING_KEY_HEX) + + +def test_signing_key_rejects_missing_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID", raising=False) + monkeypatch.delenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY", raising=False) + with pytest.raises(script.AcquisitionError, match="HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID"): + script.signing_key() + + +def test_signing_key_rejects_invalid_hex(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID", "k") + monkeypatch.setenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY", "not-hex!!") + with pytest.raises(script.AcquisitionError, match="hex-encoded"): + script.signing_key() + + +def test_signing_key_rejects_short_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID", "k") + monkeypatch.setenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY", "aa" * 8) + with pytest.raises(script.AcquisitionError, match="at least 32 bytes"): + script.signing_key() + + +def test_signing_key_accepts_valid_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY_ID", SIGNING_KEY_ID) + monkeypatch.setenv("HUNTER_VALUE_CAPTURE_SIGNING_KEY", SIGNING_KEY_HEX) + key_id, key = script.signing_key() + assert key_id == SIGNING_KEY_ID + assert key == bytes.fromhex(SIGNING_KEY_HEX) + + +def test_application_root_requires_absolute_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HUNTER_APPLICATION_ROOT", "relative/path") + with pytest.raises(script.AcquisitionError, match="absolute"): + script.application_root() + + +def test_acquire_market_facts_uses_real_success_literal(tmp_path: Path) -> None: + # This test would have failed against the original workflow's `status != "ok"` + # check: the provider's real successful status is "success", never "ok". + root = _application_root(tmp_path) + registry = MarketFactSourceRegistry.from_file(root / "configs" / "market_fact_sources.yaml") + repository = script.ObservedMarketFactRepository(root / "data" / "data_ops.sqlite") + now = datetime.now(UTC) + transport = StaticTransport(_coingecko_payload(now=now)) + + records = script.acquire_market_facts(registry=registry, repository=repository, now=now, transport=transport) + + fact_types = {record.fact_type for record in records} + assert fact_types == {"circulating_supply", "total_supply"} + assert transport.urls == ["https://api.coingecko.com/api/v3/coins/sky"] + + +def test_acquire_market_facts_raises_on_non_success_status(tmp_path: Path) -> None: + root = _application_root(tmp_path) + registry = MarketFactSourceRegistry.from_file(root / "configs" / "market_fact_sources.yaml") + repository = script.ObservedMarketFactRepository(root / "data" / "data_ops.sqlite") + now = datetime.now(UTC) + transport = StaticTransport({"id": "sky"}) # malformed: no market_data + + with pytest.raises(script.AcquisitionError, match="did not succeed"): + script.acquire_market_facts(registry=registry, repository=repository, now=now, transport=transport) + + +def test_required_evidence_record_id_raises_when_absent(tmp_path: Path) -> None: + root = _application_root(tmp_path) + repository = SupplyAndValueCaptureRepository(root / "data" / "data_ops.sqlite") + with pytest.raises(script.AcquisitionError, match="FundamentalEvidenceRecord"): + script.required_evidence_record_id(repository, now=datetime.now(UTC)) + + +def test_require_rule_precondition_raises_when_absent(tmp_path: Path) -> None: + root = _application_root(tmp_path) + repository = SupplyAndValueCaptureRepository(root / "data" / "data_ops.sqlite") + with pytest.raises(script.AcquisitionError, match="ValueCaptureRuleSnapshot"): + script.require_rule_precondition(repository, now=datetime.now(UTC)) + + +def test_run_end_to_end_persists_and_links_supply_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + now = datetime.now(UTC) + root = _application_root(tmp_path) + _set_env(monkeypatch, root) + _seed_evidence_and_rule(root, now=now) + transport = StaticTransport(_coingecko_payload(now=now)) + + record_id = script.run(root=root, transport=transport, now=now + timedelta(minutes=5)) + + assert record_id is not None + repository = SupplyAndValueCaptureRepository(root / "data" / "data_ops.sqlite") + persisted = repository.supply(record_id) + assert persisted is not None + assert persisted.identity.entity_id == "entity:sky" + assert persisted.identity.representation_id == "representation:sky-ethereum" + assert persisted.quality_state == "accepted" + assert persisted.conflict_state in {"none", "resolved"} + + # Deterministic cross-link check: entity/representation, evidence linkage, + # observed-market-fact linkage, and valid/recorded/known time ordering. + evidence = repository.strict_known_evidence( + entity_id="entity:sky", + economic_claim_id="economic-claim:sky-smart-burn-engine", + representation_id="representation:sky-ethereum", + effective_as_of=now + timedelta(days=1), + known_by=now + timedelta(days=1), + evidence_type="official_disclosure", + ) + assert evidence is not None + assert evidence.record_id in persisted.evidence_record_ids + assert persisted.effective_at <= persisted.recorded_at <= persisted.known_at + + market_fact_repository = script.ObservedMarketFactRepository(root / "data" / "data_ops.sqlite") + for fact_id, fact_version in zip( + persisted.observed_market_fact_ids, persisted.observed_market_fact_versions, strict=True + ): + fact = market_fact_repository.record(fact_id) + assert fact is not None + assert fact.semantic_version == fact_version + assert fact.identity.entity_id == persisted.identity.entity_id + assert fact.identity.representation_id == persisted.identity.representation_id + assert fact.effective_at <= persisted.effective_at + assert fact.recorded_at <= persisted.recorded_at + assert fact.known_at <= persisted.known_at + + +def test_run_is_idempotent_on_rerun(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + now = datetime.now(UTC) + root = _application_root(tmp_path) + _set_env(monkeypatch, root) + _seed_evidence_and_rule(root, now=now) + transport = StaticTransport(_coingecko_payload(now=now)) + + first = script.run(root=root, transport=transport, now=now + timedelta(minutes=5)) + assert first is not None + + before_counts = script.DatabaseRowCounts.capture(root / "data" / "data_ops.sqlite") + second = script.run(root=root, transport=transport, now=now + timedelta(minutes=10)) + after_counts = script.DatabaseRowCounts.capture(root / "data" / "data_ops.sqlite") + + assert second is None + assert before_counts.counts == after_counts.counts + + +def test_database_row_counts_detects_unexpected_record_type_changes() -> None: + before = script.DatabaseRowCounts(counts={"snapshot": 5, "automation-job": 10, "automation-run": 20}) + after_clean = script.DatabaseRowCounts(counts={"snapshot": 9, "automation-job": 10, "automation-run": 20}) + after_polluted = script.DatabaseRowCounts(counts={"snapshot": 9, "automation-job": 13, "automation-run": 20}) + + assert before.unexpected_changes(after_clean) == {} + assert before.unexpected_changes(after_polluted) == {"automation-job": (10, 13)} + + +def test_build_supply_payload_references_exactly_the_returned_market_facts(tmp_path: Path) -> None: + root = _application_root(tmp_path) + registry = MarketFactSourceRegistry.from_file(root / "configs" / "market_fact_sources.yaml") + repository = script.ObservedMarketFactRepository(root / "data" / "data_ops.sqlite") + now = datetime.now(UTC) + transport = StaticTransport(_coingecko_payload(now=now)) + records = script.acquire_market_facts(registry=registry, repository=repository, now=now, transport=transport) + + payload = script.build_supply_payload(records=records, evidence_record_id="evidence-record-id", now=now) + + assert payload["supply_basis_type"] == "circulating_supply" + assert payload["evidence_record_ids"] == ["evidence-record-id"] + assert set(payload["observed_market_fact_ids"]) == {record.record_id for record in records} + assert len(payload["observed_market_fact_ids"]) == len(payload["observed_market_fact_versions"]) + component_types = {component[0] for component in payload["quantity_components"]} + assert component_types == {"circulating_supply", "total_supply"}