diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..462677f --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,24 @@ +name: 🚀 Build and publish Docker image + +on: + push: + tags: + - 'moc-*' + - 'latest' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Build Docker image + run: docker compose build base + + - name: Push Docker image + run: docker compose push base diff --git a/.github/workflows/verifyV3.yml b/.github/workflows/verifyV3.yml new file mode 100644 index 0000000..3b9ae69 --- /dev/null +++ b/.github/workflows/verifyV3.yml @@ -0,0 +1,121 @@ +name: Verify Black Hole Canister V3 + +on: + - push + - pull_request + - workflow_dispatch + +jobs: + build-and-test: + runs-on: ubuntu-latest + + # This sets the default working directory for ALL run steps in this job + defaults: + run: + working-directory: blackholes/v3 + + steps: + # Checkout happens from the repo root (this is a "uses" step, so the default doesn't affect it) + - name: Check out repository + uses: actions/checkout@v3 + + - name: Set up Docker Buildx (optional, but recommended) + uses: docker/setup-buildx-action@v2 + + - name: Build base image + run: | + docker compose build base + + - name: Build wasm image + run: | + docker compose build wasm + + - name: Run build script inside wasm container and extract hash + id: build_wasm + run: | + # Run docker compose and store the entire output in a variable + OUTPUT=$(docker compose run --rm wasm) + + echo "===== Docker Compose Output =====" + echo "$OUTPUT" + echo "=================================" + + # 2) Look for the line with "out/out_Linux_x86_64.wasm" + # e.g. "79b15176dc613860f35867828f40e7d6... out/out_Linux_x86_64.wasm" + HASH_LINE=$(echo "$OUTPUT" | grep 'out/out_Linux_x86_64.wasm' || true) + + if [ -z "$HASH_LINE" ]; then + echo "No line with out/out_Linux_x86_64.wasm found!" + exit 1 + fi + + # 3) Extract the hash (first space-delimited token) + DOCKER_HASH=$(echo "$HASH_LINE" | awk '{print $1}') + echo "docker_hash=$DOCKER_HASH" >> $GITHUB_OUTPUT + - name: Print build hash + run: | + echo "Docker hash: ${{ steps.build_wasm.outputs.docker_hash }}" + + - name: Install dfx + uses: dfinity/setup-dfx@main + with: + dfx-version: 0.26.0-beta.1 + + - name: Import the buildtest identity + run: | + dfx identity import buildtest ./build-test.pem --disable-encryption --quiet + + - name: Gather canister info + id: gather_info + run: | + # 1) Set the environment variable to disable the plaintext warning + export DFX_WARNING=-mainnet_plaintext_identity + + # 2) Query dfx for info + CANISTER_ID=$(dfx canister --ic id blackhole) + INFO=$(dfx canister --ic info blackhole) + + echo "===== dfx canister --ic info blackhole =====" + echo "$INFO" + echo "========================================" + + # 3) Extract the "Controllers:" line (may be empty if 0 controllers) + CONTROLLERS_LINE=$(echo "$INFO" | grep '^Controllers:' || true) + # Remove "Controllers:" prefix + any trailing spaces to get the controller principals + controllers_str=$(echo "$CONTROLLERS_LINE" | sed -E 's/^Controllers:\s*//') + + # 4) Extract the "Module hash:" line, then parse out just the hex string + MODULE_HASH_LINE=$(echo "$INFO" | grep '^Module hash:' || true) + MODULE_HASH=$(echo "$MODULE_HASH_LINE" | sed -E 's/^Module hash:\s*0x([A-Za-z0-9]+)/\1/') + + # 5) Expose these values as step outputs + echo "controllers_str=$controllers_str" >> $GITHUB_OUTPUT + echo "module_hash=$MODULE_HASH" >> $GITHUB_OUTPUT + echo "canister_id=$CANISTER_ID" >> $GITHUB_OUTPUT + + - name: Compare hashes + run: | + DOCKER_HASH="${{ steps.build_wasm.outputs.docker_hash }}" + MODULE_HASH="${{ steps.gather_info.outputs.module_hash }}" + + echo "Docker hash: $DOCKER_HASH" + echo "Canister hash: $MODULE_HASH" + + if [ "$DOCKER_HASH" = "$MODULE_HASH" ]; then + echo "✓ SUCCESS: Hashes match for canister ${{ steps.gather_info.outputs.canister_id }}." + echo "View this at https://dashboard.internetcomputer.org/canister/${{ steps.gather_info.outputs.canister_id }}" + else + echo "✘ ERROR: Hash mismatch!" + exit 1 + fi + + - name: Ensure zero controllers + run: | + if [ -n "${{ steps.gather_info.outputs.controllers_str }}" ]; then + echo "✘ ERROR: Found controllers, but expected none." + echo "✘ Controllers: ${{ steps.gather_info.outputs.controllers_str }}" + exit 1 + else + echo "✓ No controllers found for canister ${{ steps.gather_info.outputs.canister_id }}." + echo "View this at https://dashboard.internetcomputer.org/canister/${{ steps.gather_info.outputs.canister_id }}" + fi diff --git a/.gitignore b/.gitignore index d099a3e..b0471ba 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # dfx temporary files .dfx/ .vessel +.mops # generated files src/declarations/ diff --git a/blackholes/v3/Dockerfile b/blackholes/v3/Dockerfile new file mode 100644 index 0000000..42bcc34 --- /dev/null +++ b/blackholes/v3/Dockerfile @@ -0,0 +1,23 @@ +ARG IMAGE +FROM --platform=linux/amd64 ${IMAGE} + +WORKDIR /project + +COPY mops.toml ./ + +# Let mops-cli install the dependencies defined in mops.toml and create +# mops.lock. +# Note: We trick mops-cli into not downloading binaries and not compiling +# anything. We also make it use the moc version from the base image. +RUN mkdir -p ~/.mops/bin \ + && ln -s /usr/local/bin/moc ~/.mops/bin/moc \ + && touch ~/.mops/bin/mo-fmt \ + && echo "actor {}" >tmp.mo \ + && mops-cli build tmp.mo -- --check \ + && rm -r tmp.mo target/tmp + +COPY src /project/src/ +COPY di[d] /project/did/ +COPY build.sh /project + +CMD ["/bin/bash"] diff --git a/blackholes/v3/Dockerfile.base b/blackholes/v3/Dockerfile.base new file mode 100644 index 0000000..be02ea4 --- /dev/null +++ b/blackholes/v3/Dockerfile.base @@ -0,0 +1,37 @@ +ARG PLATFORM=linux/amd64 +FROM --platform=${PLATFORM} alpine:latest AS build + +RUN apk add --no-cache curl ca-certificates tar \ + && update-ca-certificates + +RUN mkdir -p /install/bin + +# Install ic-wasm +ARG IC_WASM_VERSION +RUN curl -L https://github.com/research-ag/ic-wasm/releases/download/${IC_WASM_VERSION}/ic-wasm-x86_64-unknown-linux-musl.tar.gz -o ic-wasm.tgz \ + && tar xzf ic-wasm.tgz \ + && install ic-wasm /install/bin + +# Install mops-cli +ARG MOPS_CLI_VERSION +RUN curl -L https://github.com/dfinity/mops-cli/releases/download/${MOPS_CLI_VERSION}/mops-cli-linux64 -o mops-cli \ + && install mops-cli /install/bin + +# Install moc +ARG MOC_VERSION +RUN if dpkg --compare-versions "${MOC_VERSION}" lt "0.9.5"; then \ + curl -L https://github.com/dfinity/motoko/releases/download/${MOC_VERSION}/motoko-linux64-${MOC_VERSION}.tar.gz -o motoko.tgz; \ + else \ + curl -L https://github.com/dfinity/motoko/releases/download/${MOC_VERSION}/motoko-Linux-x86_64-${MOC_VERSION}.tar.gz -o motoko.tgz; \ + fi \ + && tar xzf motoko.tgz \ + && install moc /install/bin + +# If dpkg is not available then use this line above: +# RUN if [ "$(printf '%s\n' "${MOC_VERSION}" "0.9.4" | sort -V | head -n 1)" = "${MOC_VERSION}" ]; then \ + + + +FROM --platform=${PLATFORM} alpine:latest +RUN apk add bash +COPY --from=build /install/bin/* /usr/local/bin/ diff --git a/blackholes/v3/README.md b/blackholes/v3/README.md new file mode 100644 index 0000000..7cb4fca --- /dev/null +++ b/blackholes/v3/README.md @@ -0,0 +1,28 @@ +## Introduction + +- This repository contains the [source code](blackhole.mo) for canister `cpbhu-5iaaa-aaaad-aalta-cai`, version 3 (V3) of the the CycleOps balance checker canister +- It uses the [Reproducible Build Template from research-ag](https://github.com/research-ag/motoko-build-template), allowing anyone to build this canister and verify its module hash locally. +- You can also run the V3 GitHub action attached to this repository in order to verify that `cpbhu-5iaaa-aaaad-aalta-cai` is: + - blackholed, with 0 controllers + - running the same wasm binary generated from ([./blackhole.mo](blackhole.mo)) + +
+ +# Verification of the Blackholed Balance Checker canister + +## Easy Mode: Use a GH Action + +This will only take a few clicks! + +1. Fork this repository. +2. Navigate to the actions tab of your new repo. +3. Select the "Verify Black Hole Canister V3" action on the left. +4. Click the "Run workflow" button to dispatch the action. + +You can view the output of this action to confirm that 1) the canister has no controllers, 2) the mainnet wasm matches the contents of this repository. The final step to be 100% confident that this canister can do no harm is to audit the source code of this repo. + +## Alternative: Verify on your local machine + +This repository uses the [Reproducible Build Motoko Template Standard from research-ag](https://github.com/research-ag/motoko-build-template). In this folder, follow the instructions listed there in order to spin up docker and build the [canister code](./src/blackhole.mo) with the correct version of dfx and the motoko compiler inside of a linux x86_64 environment. + +The resulting hash should match `dfcf37d4ee18bfe77a12197feaf4686f160d717383f0cf525f9d30e60fec204d` \ No newline at end of file diff --git a/blackholes/v3/build-test.pem b/blackholes/v3/build-test.pem new file mode 100644 index 0000000..d13108f --- /dev/null +++ b/blackholes/v3/build-test.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHQCAQEEIMDTUL1yDOa3xo5Wf4bUUxKPUamRA0GWEByV7zLUdUROoAcGBSuBBAAK +oUQDQgAEbSRh8i80GyA2h+8q/PJ0N6HFQnqKCp4DYJPOmUQXdolnhqQ/IRBxm0u8 +Rzx1AqqPpzOG2sp7AGMxwF1lciKpAw== +-----END EC PRIVATE KEY----- \ No newline at end of file diff --git a/blackholes/v3/build.sh b/blackholes/v3/build.sh new file mode 100755 index 0000000..a9b1a9e --- /dev/null +++ b/blackholes/v3/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +MOC_GC_FLAGS="" ## place any additional flags like compacting-gc, incremental-gc here +MOC_FLAGS="$MOC_GC_FLAGS -no-check-ir --release --public-metadata candid:service --public-metadata candid:args" +OUT=out/out_$(uname -s)_$(uname -m).wasm +mops-cli build --lock --name out src/blackhole.mo -- $MOC_FLAGS +cp target/out/out.wasm $OUT +ic-wasm $OUT -o $OUT shrink +if [ -f did/service.did ]; then + echo "Adding service.did to metadata section." + ic-wasm $OUT -o $OUT metadata candid:service -f did/service.did -v public +else + echo "service.did not found. Skipping metadata update." +fi +if [ "$compress" == "yes" ] || [ "$compress" == "y" ]; then + gzip -nf $OUT + sha256sum $OUT.gz +else + sha256sum $OUT +fi diff --git a/blackholes/v3/canister_ids.json b/blackholes/v3/canister_ids.json new file mode 100644 index 0000000..ba85bb4 --- /dev/null +++ b/blackholes/v3/canister_ids.json @@ -0,0 +1,5 @@ +{ + "blackhole": { + "ic": "cpbhu-5iaaa-aaaad-aalta-cai" + } +} \ No newline at end of file diff --git a/blackholes/v3/dfx.json b/blackholes/v3/dfx.json new file mode 100644 index 0000000..63b4565 --- /dev/null +++ b/blackholes/v3/dfx.json @@ -0,0 +1,21 @@ +{ + "canisters": { + "blackhole": { + "main": "./src/blackhole.mo", + "type": "motoko" + } + }, + "defaults": { + "build": { + "args": "-no-check-ir", + "packtool": "mops sources" + } + }, + "dfx": "0.26.0-beta.1", + "networks": { + "local": { + "bind": "127.0.0.1:8089" + } + }, + "version": 1 +} diff --git a/blackholes/v3/did/service.did b/blackholes/v3/did/service.did new file mode 100644 index 0000000..890e6fe --- /dev/null +++ b/blackholes/v3/did/service.did @@ -0,0 +1,74 @@ +type StatusChecker = + service { + canisterStatus: (principal) -> (CanisterStatusResult); + /// checks the canister status of itself or anything it is the controller of + canisterStatuses: (vec principal, nat) -> (Result); + }; +type Result = + variant { + err: text; + ok: vec CanisterStatusResult; + }; +type QueryStats = + record { + num_calls_total: nat; + num_instructions_total: nat; + request_payload_bytes_total: nat; + response_payload_bytes_total: nat; + }; +type MemoryMetrics = + record { + canister_history_size: nat; + custom_sections_size: nat; + global_memory_size: nat; + snapshots_size: nat; + stable_memory_size: nat; + wasm_binary_size: nat; + wasm_chunk_store_size: nat; + wasm_memory_size: nat; + }; +type DefiniteCanisterSettings = + record { + compute_allocation: nat; + controllers: vec principal; + freezing_threshold: nat; + log_visibility: + variant { + allowed_viewers: vec principal; + controllers; + public; + }; + memory_allocation: nat; + reserved_cycles_limit: nat; + wasm_memory_limit: nat; + }; +type CanisterStatusResult = + variant { + err: text; + ok: CanisterStatus; + }; +type CanisterStatus = + record { + cycles: nat; + idle_cycles_burned_per_day: nat; + memory_metrics: MemoryMetrics; + memory_size: nat; + module_hash: opt blob; + query_stats: QueryStats; + reserved_cycles: nat; + settings: DefiniteCanisterSettings; + status: variant { + running; + stopped; + stopping; + }; + }; +/// CycleOps Status Checker V3 Implementation +/// The canister status checker actor responsible for monitoring the cycles balances of customer-owned canisters +/// CycleOps spins up a blackholed version of this canister. +/// +/// The caller is the original creator of this service. This cannot change throughout the lifecycle of the canister +/// as it can only be created once. Any subsequent upgrades by other controllers cannot change this field. +/// The access control implemented in this canister's public APIs means that the CycleOps Service is the only +/// caller with the ability to invoke the public methods of this canister. +service : (principal) -> StatusChecker diff --git a/blackholes/v3/docker-compose.yml b/blackholes/v3/docker-compose.yml new file mode 100644 index 0000000..484634d --- /dev/null +++ b/blackholes/v3/docker-compose.yml @@ -0,0 +1,27 @@ +x-base-image: + versions: + moc: &moc 0.14.3 + ic-wasm: &ic_wasm 0.9.3 + mops-cli: &mops-cli 0.2.0 + name: &base_name "ghcr.io/research-ag/motoko-build:moc-0.14.3" + +services: + base: + build: + context: . + dockerfile: Dockerfile.base + args: + MOC_VERSION: *moc + IC_WASM_VERSION: *ic_wasm + MOPS_CLI_VERSION: *mops-cli + image: *base_name + wasm: + build: + context: . + args: + IMAGE: *base_name + volumes: + - ./out:/project/out + environment: + compress : no + command: bash --login build.sh diff --git a/blackholes/v3/mops.toml b/blackholes/v3/mops.toml new file mode 100644 index 0000000..0da5f0f --- /dev/null +++ b/blackholes/v3/mops.toml @@ -0,0 +1,5 @@ +[dependencies] +base = "0.14.3" + +[toolchain] +moc = "0.14.3" diff --git a/blackholes/v3/out/out_Linux_x86_64.wasm b/blackholes/v3/out/out_Linux_x86_64.wasm new file mode 100644 index 0000000..8c6de5f Binary files /dev/null and b/blackholes/v3/out/out_Linux_x86_64.wasm differ diff --git a/blackholes/v3/package-lock.json b/blackholes/v3/package-lock.json new file mode 100644 index 0000000..6b04955 --- /dev/null +++ b/blackholes/v3/package-lock.json @@ -0,0 +1,212 @@ +{ + "name": "v3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@types/node": "^22.13.14", + "ts-node": "^10.9.2" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.13.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", + "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", + "dev": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/blackholes/v3/package.json b/blackholes/v3/package.json new file mode 100644 index 0000000..c46c549 --- /dev/null +++ b/blackholes/v3/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@types/node": "^22.13.14", + "ts-node": "^10.9.2" + } +} diff --git a/blackholes/v3/src/blackhole.mo b/blackholes/v3/src/blackhole.mo new file mode 100644 index 0000000..cacc14b --- /dev/null +++ b/blackholes/v3/src/blackhole.mo @@ -0,0 +1,217 @@ +/// CycleOps Status Checker V3 Implementation + +import Array "mo:base/Array"; +import Buffer "mo:base/Buffer"; +import { print; trap } "mo:base/Debug"; +import Error "mo:base/Error"; +import Principal "mo:base/Principal"; +import Result "mo:base/Result"; + +/// The canister status checker actor responsible for monitoring the cycles balances of customer-owned canisters +/// CycleOps spins up a blackholed version of this canister. +/// +/// The caller is the original creator of this service. This cannot change throughout the lifecycle of the canister +/// as it can only be created once. Any subsequent upgrades by other controllers cannot change this field. +/// The access control implemented in this canister's public APIs means that the CycleOps Service is the only +/// caller with the ability to invoke the public methods of this canister. +shared actor class StatusChecker(CYCLEOPS_CANISTER_ID : Principal) = this { + ///////////////////////////////////////// + // Required Management Canister Types // + /////////////////////////////////////// + + // The status checker calls the canister_status endpoint of the IC management canister + // in order to retrieve cycles balances from customer canisters. + type ManagementCanisterActor = actor { + canister_status : shared ({ canister_id : Principal }) -> async CanisterStatus; + }; + type MemoryMetrics = { + wasm_memory_size : Nat; + stable_memory_size : Nat; + global_memory_size : Nat; + wasm_binary_size : Nat; + custom_sections_size : Nat; + canister_history_size : Nat; + wasm_chunk_store_size : Nat; + snapshots_size : Nat; + }; + // The management canister's canister_status response type + type CanisterStatus = { + status : { #stopped; #stopping; #running }; + settings : DefiniteCanisterSettings; + module_hash : ?Blob; + memory_size : Nat; + memory_metrics : MemoryMetrics; + cycles : Nat; + idle_cycles_burned_per_day : Nat; + // New fields tracked in V2 of the blackhole + reserved_cycles : Nat; + query_stats : QueryStats; + }; + type DefiniteCanisterSettings = { + controllers : [Principal]; + compute_allocation : Nat; + memory_allocation : Nat; + freezing_threshold : Nat; + // New fields tracked in V2 of the blackhole + reserved_cycles_limit : Nat; + log_visibility : { #controllers; #allowed_viewers : [Principal]; #public_ }; + wasm_memory_limit : Nat; + }; + type QueryStats = { + num_calls_total : Nat; + num_instructions_total : Nat; + request_payload_bytes_total : Nat; + response_payload_bytes_total : Nat; + }; + + // Instantiate the management canister actor + let ic : ManagementCanisterActor = actor ("aaaaa-aa"); + + /////////////////////////////////////////////////////////// + // Public APIs, gated by the CYCLEOPS_SERVICE_PRINCIPAL // + ///////////////////////////////////////////////////////// + + // A result type for handling canister status failures + type CanisterStatusResult = Result.Result; + // Attempts to return the controller, and cycles balance of a single canister principal + // If the canister is not controlled by the cycleops service, then the controllers will be + // returned by the error message, but the cycles balance returned will not be available + public shared ({ caller }) func canisterStatus( + canisterId : Principal + ) : async CanisterStatusResult { + trapIfNotCycleOps(caller); + + try { + let statusResponse = await ic.canister_status({ + canister_id = canisterId; + }); + #ok(statusResponse); + } catch (error) { + #err(Error.message(error)); + }; + }; + + /// checks the canister status of itself or anything it is the controller of + public shared ({ caller }) func canisterStatuses(canisterPrincipals : [Principal], batchSize : Nat) : async Result.Result<[CanisterStatusResult], Text> { + // only allow the service that created the status checker to call canister_status + trapIfNotCycleOps(caller); + + try { + let statusResults = await* batchCanisterStatusCalls(canisterPrincipals, batchSize); + #ok(statusResults); + } catch (error) { + #err(Error.message(error)); + }; + }; + + ///////////////////////////////////////////////// + // Private Helper Functions (Not public-APIs) // + /////////////////////////////////////////////// + + /// Traps if the caller is not the CycleOps Service + /// Used to prevent unauthorized access to sensitive canister metric data + private func trapIfNotCycleOps(caller : Principal) : () { + if (caller != CYCLEOPS_CANISTER_ID) trap("Not Authorized"); + }; + + /// Takes in an array of canister principals and a batch size, and executes canister status calls + /// in batches, such that the size of each batch is <= batchSize + /// returns a list of CanisterStatusResult (CanisterStatus or error message) + /// + /// Concurrently request the canister statuses of all principals passed, breaking the concurrent canister status + /// requests into batches in order to get around the following issue + /// > `"Canister trapped explicitly: could not perform self call" issue at around 500` + /// See https://forum.dfinity.org/t/canister-output-message-queue-limits-and-ic-management-canister-throttling-limits/15972 + /// + /// Note: The shifting between an Array and a Buffer right now is because in Motoko async functions cannot accept + /// var parameters (needs an Array), and appending to an Array is very inefficient (use Buffer instead) + private func batchCanisterStatusCalls( + canisterPrincipals : [Principal], + batchSize : Nat, + ) : async* [CanisterStatusResult] { + let size = canisterPrincipals.size(); + var batchNumber = 0; + var canisterStatuses = Buffer.Buffer(size); + + while (batchNumber * batchSize < size) { + let startIndex = batchNumber * batchSize; + let batchLength = if (startIndex + batchSize > size) { + size - startIndex : Nat; + } else { batchSize }; + print( + "batch checking statuses of canisters, batch=" # debug_show (batchNumber) # " from: " # debug_show (startIndex) # " - " # debug_show (startIndex + batchLength) + ); + + // from the larger canisterPrincipals subarray, creates a subArray of principals + // starting at the startIndex (inclusive) and going until size batchLength is reached + let subArrayCanisterPrincipals = Array.subArray(canisterPrincipals, startIndex, batchLength); + // get all canister statuses for each principal in the subArray + let statusesInBatch = await* awaitAllCanisterStatuses(subArrayCanisterPrincipals); + // the batch is finished, append all canisters statuses to the larger canisterStatuses Buffer + canisterStatuses.append(Buffer.fromArray(statusesInBatch)); + // increment the batch number + batchNumber += 1; + }; + + print("finished fetching canister statuses for " # debug_show (canisterStatuses.size()) # " canisters"); + // return the array of canister statuses + Buffer.toArray(canisterStatuses); + }; + + // Concurrently (in parallel) request the canister statuses of all principals passed + // + // Note: The shifting between an Array and a Buffer right now is because in Motoko async functions cannot accept + // var parameters (needs an Array), and appending to an Array is very inefficient (use Buffer instead) + // + private func awaitAllCanisterStatuses(canisterPrincipals : [Principal]) : async* [CanisterStatusResult] { + let ids = Buffer.fromArray(canisterPrincipals); + let calls = Buffer.Buffer>(canisterPrincipals.size()); + var i = 0; + + // Use a loop to initiate each asynchronous call without waiting for it to complete + label l loop { + if (i >= ids.size()) { break l }; + + let res = try { + #ok(ic.canister_status({ canister_id = canisterPrincipals[i] })); + } catch (error) { #err(Error.message(error)) }; // catch synchronous errors (i.e. canister output queue) + + calls.add(res); + i += 1; + }; + + i := 0; + let awaitedCalls = Buffer.Buffer(calls.size()); + + // Use a loop to await each initiated asynchronous call that was made to getCanisterStatus in the previous loop + label l loop { + if (i >= ids.size()) { break l }; + + switch (calls.get(i)) { + case (#ok(call)) { + let res = try { #ok(await call) } + // Catch asynchronous errors (i.e. not controller of canister) + catch (err) { #err(Error.message(err)) }; + + awaitedCalls.add(res); + }; + case (#err(message)) { awaitedCalls.add(#err(message)) }; + }; + i += 1; + }; + + // return array of awaited canister_status calls + Buffer.toArray(awaitedCalls); + }; + + /// Simple inspect message blocker to prevent ingress requests (outside the IC) other than the creator from calling this canister + /// (helps to prevent DDOS or cycle drain attacks) + system func inspect({ + caller : Principal; + msg : { + #canisterStatus : () -> (Principal); + #canisterStatuses : () -> ([Principal], Nat); + }; + }) : Bool { caller == CYCLEOPS_CANISTER_ID }; +}; + diff --git a/blackholes/v3/tsconfig.json b/blackholes/v3/tsconfig.json new file mode 100644 index 0000000..1dc4437 --- /dev/null +++ b/blackholes/v3/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ESNext"], + "moduleResolution": "node", + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "useDefineForClassFields": true, + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/readme.md b/readme.md index 820c43c..fd28fe5 100644 --- a/readme.md +++ b/readme.md @@ -5,17 +5,23 @@ This repository contains the source code, and steps to reproduce builds for diff ### CycleOps Blackhole Versions -There are currently 2 CycleOps blackholes. Each new version provides additional canister metrics. +There are currently 3 CycleOps blackholes. Each new version provides additional canister metrics. -* V1 +* V1 - original * Canister - [5vdms-kaaaa-aaaap-aa3uq-cai](https://dashboard.internetcomputer.org/canister/5vdms-kaaaa-aaaap-aa3uq-cai) * Code - [blackholes/v1/blackhole.mo](./blackholes/v1/blackhole.mo) * Verification * [With a GitHub Action](./blackholes/v1/readme.md#easy-mode-use-a-gh-action) * [On your local Machine](./blackholes/v1/readme.md#alternative-verify-on-your-local-machine) -* **V2 (latest)** +* V2 - V1 + reserved cycles & query call metrics * Canister - [2daxo-giaaa-aaaap-anvca-cai](https://dashboard.internetcomputer.org/canister/2daxo-giaaa-aaaap-anvca-cai) * Code - [blackholes/v2/blackhole.mo](./blackholes/v2/blackhole.mo) * Verification * [With a GitHub Action](./blackholes/v2/readme.md#easy-mode-use-a-gh-action) - * [On your local Machine](./blackholes/v2/readme.md#alternative-verify-on-your-local-machine) \ No newline at end of file + * [On your local Machine](./blackholes/v2/readme.md#alternative-verify-on-your-local-machine) +* **V3 (latest, V2 + detailed canister memory metrics e.g. heap/stable/snapshot)** + * Canister - [cpbhu-5iaaa-aaaad-aalta-cai](https://dashboard.internetcomputer.org/canister/cpbhu-5iaaa-aaaad-aalta-cai) + * Code - [blackholes/v3/src/blackhole.mo](./blackholes/v3/src/blackhole.mo) + * Verification + * [With a GitHub Action](./blackholes/v3/readme.md#easy-mode-use-a-gh-action) + * [On your local Machine](./blackholes/v3/readme.md#alternative-verify-on-your-local-machine) \ No newline at end of file