diff --git a/.github/workflows/refresh-smart-contracts.yml b/.github/workflows/refresh-smart-contracts.yml new file mode 100644 index 0000000..8933840 --- /dev/null +++ b/.github/workflows/refresh-smart-contracts.yml @@ -0,0 +1,88 @@ +name: Refresh Smart Contracts + +on: + schedule: + # Every Wednesday at 14:00 UTC + - cron: '0 14 * * 3' + workflow_dispatch: # Allow manual trigger + +jobs: + refresh-smart-contracts: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Python dependencies + run: pip install requests + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Get checksum before update + id: before + run: | + echo "checksum=$(sha256sum data/smart_contracts.json | cut -d ' ' -f 1)" >> $GITHUB_OUTPUT + + - name: Run update script + run: python3 scripts/update_smart_contracts.py + + - name: Get checksum after update + id: after + run: | + echo "checksum=$(sha256sum data/smart_contracts.json | cut -d ' ' -f 1)" >> $GITHUB_OUTPUT + + - name: Check for changes + id: changes + run: | + if [ "${{ steps.before.outputs.checksum }}" != "${{ steps.after.outputs.checksum }}" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No changes detected in smart_contracts.json" + fi + + - name: Create branch and PR + if: steps.changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH_NAME="chore/refresh-smart-contracts-$(date +%Y%m%d)" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout -b "$BRANCH_NAME" + git add data/smart_contracts.json + git commit -m "chore(smart_contracts): refresh smart contracts data" + git push origin "$BRANCH_NAME" + + gh pr create \ + --base dev \ + --head "$BRANCH_NAME" \ + --title "chore(smart_contracts): refresh smart contracts data" \ + --body "$(cat <<'EOF' + ## Summary + - Automated weekly refresh of smart contracts data + - Generated by scheduled workflow + + ## Changes + Updated \`data/smart_contracts.json\` with latest contract data from GitHub. + + --- + 🤖 This PR was automatically created by the refresh-smart-contracts workflow. + EOF + )" diff --git a/CHANGELOG.md b/CHANGELOG.md index e923261..72881cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# [1.11.0-rc.1](https://github.com/qubic/static/compare/v1.10.0...v1.11.0-rc.1) (2025-12-27) + + +### Bug Fixes + +* **smart_contracts:** fail script if current epoch fetch fails ([eb361fc](https://github.com/qubic/static/commit/eb361fc92811d44302a38c593d73b54ab23091e6)) + + +### Features + +* **ci:** add weekly smart contracts refresh workflow ([7a301b2](https://github.com/qubic/static/commit/7a301b2c92e020608b9dc7d51a87c509c04e1711)) +* **smart_contracts:** ignore contracts with future constructionEpoch on refresh ([835eb31](https://github.com/qubic/static/commit/835eb31c0d298664d7e05c4d3efa9e4463f201ee)) + # [1.10.0](https://github.com/qubic/static/compare/v1.9.0...v1.10.0) (2025-12-17) diff --git a/package.json b/package.json index 8bf32f7..80b468c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qubic-static-api", - "version": "1.10.0", + "version": "1.11.0-rc.1", "description": "Static data and assets for Qubic blockchain", "private": true, "packageManager": "pnpm@9.11.0", diff --git a/scripts/update_smart_contracts.py b/scripts/update_smart_contracts.py index 67c1dfd..6513192 100644 --- a/scripts/update_smart_contracts.py +++ b/scripts/update_smart_contracts.py @@ -37,6 +37,7 @@ RAW_BASE_CONTRACTS = "https://raw.githubusercontent.com/qubic/core/main/src/contracts/" RAW_CONTRACT_DEF = "https://raw.githubusercontent.com/qubic/core/main/src/contract_core/contract_def.h" +QUBIC_STATS_API = "https://rpc.qubic.org/v1/latest-stats" # ---------------------------- Regexes --------------------------------------- @@ -148,6 +149,18 @@ def fetch_text(url: str) -> Optional[str]: print(f"Warning: GET {url} failed: {e}") return None +def fetch_current_epoch() -> Optional[int]: + """Fetch the current epoch from the Qubic stats API.""" + try: + resp = requests.get(QUBIC_STATS_API, timeout=30) + if resp.status_code == 200: + data = resp.json() + return data.get("data", {}).get("epoch") + print(f"Warning: GET {QUBIC_STATS_API} -> {resp.status_code}") + except Exception as e: + print(f"Warning: Failed to fetch current epoch: {e}") + return None + # ---------------------------- Parse contract_def.h -------------------------- def parse_contract_def_from_raw(raw_text: str, known_contract_basenames: Optional[set] = None) -> Dict[str, int]: @@ -171,7 +184,12 @@ def parse_contract_def_from_raw(raw_text: str, known_contract_basenames: Optiona mapping[basename] = cidx return mapping -def extract_contract_names_from_descriptions(raw_text: str) -> Dict[int, str]: +def extract_contract_names_from_descriptions(raw_text: str) -> Dict[int, Dict[str, Any]]: + """ + Extract contract info from contractDescriptions array. + Each entry is like: {"QX", 66, 10000, sizeof(QX)} + Returns: {contractIndex: {"name": str, "constructionEpoch": int}} + """ text = strip_comments(raw_text) token = "contractDescriptions" pos = text.find(token) @@ -222,16 +240,20 @@ def extract_contract_names_from_descriptions(raw_text: str) -> Dict[int, str]: break i += 1 - names: Dict[int, str] = {} + # Regex to parse: {"NAME", constructionEpoch, destructionEpoch, sizeof(...)} + item_re = re.compile(r'"([^"]+)"\s*,\s*(\d+)') + + contracts: Dict[int, Dict[str, Any]] = {} for idx1, item in enumerate(items, start=0): if idx1 == 0: continue - m = FIRST_QUOTED_STRING_RE.search(item) + m = item_re.search(item) if not m: continue name = m.group(1) - names[idx1] = name - return names + construction_epoch = int(m.group(2)) + contracts[idx1] = {"name": name, "constructionEpoch": construction_epoch} + return contracts # ---------------------------- Header scanning ------------------------------- @@ -449,13 +471,19 @@ def main(): if not contract_def_text: raise SystemExit("Could not fetch contract_def.h") + # Fetch current epoch to filter contracts + current_epoch = fetch_current_epoch() + if current_epoch is None: + raise SystemExit("Could not fetch current epoch from API") + print(f"Current epoch: {current_epoch}") + stripped = strip_comments(contract_def_text) all_basenames = set(Path(m.group("path")).name for m in INCLUDE_RE.finditer(stripped)) basenames = {b for b in all_basenames if not should_skip_filename(b)} idx_map = parse_contract_def_from_raw(stripped, basenames) - idx_to_name = extract_contract_names_from_descriptions(stripped) + idx_to_info = extract_contract_names_from_descriptions(stripped) fresh_entries: List[Dict[str, Any]] = [] for basename in sorted(basenames): @@ -463,6 +491,14 @@ def main(): if cidx is None: continue + # Skip contracts with constructionEpoch > currentEpoch + contract_info = idx_to_info.get(cidx, {}) + construction_epoch = contract_info.get("constructionEpoch") + if current_epoch is not None and construction_epoch is not None: + if construction_epoch > current_epoch: + print(f"Skipping {basename}: constructionEpoch {construction_epoch} > current epoch {current_epoch}") + continue + url = RAW_BASE_CONTRACTS + basename text = fetch_text(url) @@ -482,7 +518,7 @@ def main(): stem = Path(basename).stem label = label_from_filename_with_q_rule(stem) - name_value = idx_to_name.get(cidx, stem.upper()) + name_value = contract_info.get("name", stem.upper()) addr: Optional[str] = None identity = run_js_get_identity_from_index(cidx, js_lib_path)