diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94110674..008c8307 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,8 @@ on: jobs: build: + # only run in forks — non-fork PRs get a build via preview-deployment.yml + if: github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/deploy-ic.yml b/.github/workflows/deploy-ic.yml index a46a6f40..66098346 100644 --- a/.github/workflows/deploy-ic.yml +++ b/.github/workflows/deploy-ic.yml @@ -38,7 +38,7 @@ jobs: - run: npm run build - name: Install icp-cli - run: npm i -g @icp-sdk/icp-cli@0.2.0 @icp-sdk/ic-wasm + run: npm i -g @icp-sdk/icp-cli@0.2.6 @icp-sdk/ic-wasm - name: Import deploy identity run: | diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml new file mode 100644 index 00000000..64b19f97 --- /dev/null +++ b/.github/workflows/pr-cleanup.yml @@ -0,0 +1,34 @@ +name: PR Cleanup +on: + pull_request: + types: [closed] + +jobs: + release_preview_canister: + # do not run in forks + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + concurrency: + group: pr-${{ github.event.pull_request.number || github.event.number }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1 + with: + python-version: "3.10" + - run: | + pip install icp-py-core "cbor2<6" + python3 .github/workflows/scripts/release-canister.py ${{ github.event.pull_request.number }} + env: + POOL_CONTROLLER_IDENTITY: ${{ secrets.POOL_CONTROLLER_IDENTITY }} + POOL_CANISTER_ID: ${{ secrets.POOL_CANISTER_ID }} + + - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + with: + script: | + const comments = require('./.github/workflows/scripts/comments.cjs'); + const maybeComment = await comments.get(context, github); + if (maybeComment) { + await comments.delete(context, github, maybeComment.id); + } diff --git a/.github/workflows/preview-deployment.yml b/.github/workflows/preview-deployment.yml new file mode 100644 index 00000000..60d53160 --- /dev/null +++ b/.github/workflows/preview-deployment.yml @@ -0,0 +1,95 @@ +name: PR Preview Deployment +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build_and_deploy: + # do not run in forks + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + concurrency: + group: pr-${{ github.event.pull_request.number || github.event.number }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Initialize examples submodule + run: | + git config --global url."https://github.com/".insteadOf "git@github.com:" + git submodule update --init --depth 1 .sources/examples + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + + - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + with: + script: | + const comments = require('./.github/workflows/scripts/comments.cjs'); + const maybeComment = await comments.get(context, github); + if (maybeComment) { + await comments.update(context, github, maybeComment.id, `🤖 Your PR preview is being built...`); + } else { + await comments.create(context, github, `🤖 Your PR preview is being built...`); + } + + - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1 + with: + python-version: "3.10" + + - name: Install icp-cli + run: npm i -g @icp-sdk/icp-cli@0.2.6 @icp-sdk/ic-wasm + + - run: npm ci + + - name: Build & Deploy + run: | + # Setup identity + mkdir -p ~/.local/share/icp-cli/identity/keys + echo $POOL_CONTROLLER_IDENTITY | base64 -d > ~/.local/share/icp-cli/identity/keys/preview-deploy.pem + sed -i 's/\\r\\n/\r\n/g' ~/.local/share/icp-cli/identity/keys/preview-deploy.pem + icp identity import preview-deploy --from-pem ~/.local/share/icp-cli/identity/keys/preview-deploy.pem --storage plaintext + icp identity default preview-deploy + + # Request preview canister from the pool + pip install icp-py-core "cbor2<6" + canister_id=$(python3 .github/workflows/scripts/request-canister.py ${{ github.event.pull_request.number }}) + + # Override canister ID mapping for ic environment + echo "{\"frontend\":\"$canister_id\"}" > .icp/data/mappings/ic.ids.json + + echo "PREVIEW_CANISTER_ID=$canister_id" >> $GITHUB_ENV + + # Deploy (icp.yaml recipe handles the build) + icp deploy frontend -e ic --mode reinstall + + env: + POOL_CONTROLLER_IDENTITY: ${{ secrets.POOL_CONTROLLER_IDENTITY }} + POOL_CANISTER_ID: ${{ secrets.POOL_CANISTER_ID }} + + - name: Report build error + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + if: ${{ failure() }} + with: + script: | + const comments = require('./.github/workflows/scripts/comments.cjs'); + const maybeComment = await comments.get(context, github); + if (maybeComment) { + await comments.update(context, github, maybeComment.id, `🤖 Preview build failed.`); + } else { + await comments.create(context, github, `🤖 Preview build failed.`); + } + + - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + with: + script: | + const comments = require('./.github/workflows/scripts/comments.cjs'); + const maybeComment = await comments.get(context, github); + if (maybeComment) { + await comments.update(context, github, maybeComment.id, `🤖 Here's your preview: https://${process.env.PREVIEW_CANISTER_ID}.icp0.io`); + } else { + await comments.create(context, github, `🤖 Here's your preview: https://${process.env.PREVIEW_CANISTER_ID}.icp0.io`); + } diff --git a/.github/workflows/scripts/comments.cjs b/.github/workflows/scripts/comments.cjs new file mode 100644 index 00000000..1909c1c1 --- /dev/null +++ b/.github/workflows/scripts/comments.cjs @@ -0,0 +1,39 @@ +const MARKER = ''; + +exports.get = async function (context, github) { + const comments = await github.rest.issues.listComments({ + issue_number: context.issue.number, + repo: context.repo.repo, + owner: context.repo.owner, + }); + + return comments.data.find( + (c) => c.user.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes(MARKER) + ); +}; + +exports.create = function (context, github, body) { + return github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `${MARKER}\n${body}`, + }); +}; + +exports.update = function (context, github, id, body) { + return github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: id, + body: `${MARKER}\n${body}`, + }); +}; + +exports.delete = function (context, github, id) { + return github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: id, + }); +}; diff --git a/.github/workflows/scripts/pool.py b/.github/workflows/scripts/pool.py new file mode 100644 index 00000000..db1f5fd9 --- /dev/null +++ b/.github/workflows/scripts/pool.py @@ -0,0 +1,30 @@ +from icp_core import Agent, Client, Identity, encode, Types +import os +import sys +import base64 + + +# +# Interact with preview canister pool: https://github.com/dfinity/preview-canister-pool +# + +private_key = base64.b64decode(os.environ["POOL_CONTROLLER_IDENTITY"]).decode("utf-8") +pool_id = os.environ["POOL_CANISTER_ID"] + +identity = Identity.from_pem(private_key) +client = Client() +agent = Agent(identity, client) + +def release_canister(): + res = agent.update_raw( + pool_id, "release_canister", encode([{'type': Types.Text, 'value': sys.argv[1]}]), + verify_certificate=False) + return res + + +def request_canister(): + res = agent.update_raw( + pool_id, "request_canister", encode([{'type': Types.Text, 'value': sys.argv[1]}]), + return_type=Types.Principal, + verify_certificate=False) + return res diff --git a/.github/workflows/scripts/release-canister.py b/.github/workflows/scripts/release-canister.py new file mode 100644 index 00000000..350d4205 --- /dev/null +++ b/.github/workflows/scripts/release-canister.py @@ -0,0 +1,22 @@ +import os +import sys +import traceback + +if len(sys.argv) != 2: + print("Usage: python3 release-canister.py ") + exit(1) + +for v in ["POOL_CONTROLLER_IDENTITY","POOL_CANISTER_ID"]: + if not v in os.environ: + print(f"release-canister.py: {v} env variable missing") + exit(1) + + +from pool import release_canister + +try: + release_canister() +except Exception as e: + print(f"release-canister.py: failed to release canister: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + exit(1) diff --git a/.github/workflows/scripts/request-canister.py b/.github/workflows/scripts/request-canister.py new file mode 100644 index 00000000..b0a4194c --- /dev/null +++ b/.github/workflows/scripts/request-canister.py @@ -0,0 +1,25 @@ +import os +import sys +import traceback + +if len(sys.argv) != 2: + print("Usage: python3 request_canister.py ") + exit(1) + +for v in ["POOL_CONTROLLER_IDENTITY","POOL_CANISTER_ID"]: + if not v in os.environ: + print(f"request-canister.py: {v} env variable missing") + exit(1) + +from pool import request_canister + +try: + result = request_canister() + canister_id = result[0]['value'].to_str() + print(canister_id) +except Exception as e: + print(f"request-canister.py: failed to request canister: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + if 'result' in dir(): + print(f"request-canister.py: raw result: {result}", file=sys.stderr) + exit(1) diff --git a/docs/references/protocol-canisters.md b/docs/references/protocol-canisters.md index 085990e9..59d09355 100644 --- a/docs/references/protocol-canisters.md +++ b/docs/references/protocol-canisters.md @@ -38,8 +38,38 @@ The Bitcoin integration canisters connect ICP to the Bitcoin network. They track - `bitcoin_send_transaction`: submits a signed Bitcoin transaction - `bitcoin_get_current_fee_percentiles`: returns fee percentiles in millisatoshi/vbyte - `bitcoin_get_block_headers`: returns block headers for a range of heights +- `get_blockchain_info`: returns chain tip height, block hash, timestamp, difficulty, and UTXO count -For integration patterns, see the [Bitcoin guide](../guides/chain-fusion/bitcoin.md). +### Cycle costs + +All Bitcoin canister calls require cycles attached. In Rust, the `ic-cdk-bitcoin-canister` crate handles this automatically. In Motoko, attach cycles explicitly with `(with cycles = amount)`. + +| Endpoint | Testnet / Regtest | Mainnet | +|---|---|---| +| `bitcoin_get_balance` | 40,000,000 | 100,000,000 | +| `bitcoin_get_utxos` | 4,000,000,000 | 10,000,000,000 | +| `bitcoin_send_transaction` (base) | 2,000,000,000 | 5,000,000,000 | +| `bitcoin_send_transaction` (per byte) | 8,000,000 | 20,000,000 | +| `bitcoin_get_current_fee_percentiles` | 40,000,000 | 100,000,000 | +| `bitcoin_get_block_headers` | 4,000,000,000 | 10,000,000,000 | +| `get_blockchain_info` | 40,000,000 | 100,000,000 | + +For integration patterns and code examples, see the [Bitcoin guide](../guides/chain-fusion/bitcoin.md). + +## Dogecoin canister + +The Dogecoin canister is a system-level canister that connects ICP to the Dogecoin network using the same architecture as the Bitcoin integration. It syncs blocks from the Dogecoin peer-to-peer network, maintains the UTXO set, and exposes an API for querying Dogecoin state and submitting transactions. + +For the current canister ID, see the [Dogecoin canister repository](https://github.com/dfinity/dogecoin-canister). + +### Key endpoints + +- `dogecoin_get_utxos`: returns UTXOs for a Dogecoin address +- `dogecoin_get_balance`: returns the balance of a Dogecoin address in koinu (1 DOGE = 100,000,000 koinu) +- `dogecoin_get_current_fee_percentiles`: returns fee percentiles from recent Dogecoin transactions +- `dogecoin_send_transaction`: submits a signed transaction to the Dogecoin network + +For integration patterns, see the [Dogecoin guide](../guides/chain-fusion/dogecoin.md). ## ckBTC minter @@ -69,6 +99,16 @@ For canister IDs, see [Chain-Key Token Canister IDs: ckBTC](chain-key-canister-i - `get_minter_info`: returns current minter parameters - `get_events(start, length)`: returns the minter's internal event log +### Withdrawal fee + +The minter fee for a Bitcoin withdrawal transaction is `146 × inputs + 4 × outputs + 26` satoshi. This formula covers the cost of threshold ECDSA signatures and Bitcoin transaction broadcasting. When multiple withdrawal requests are batched into one transaction, the fee is split among all outputs. + + + +### UTXO consolidation + +As deposits accumulate, the minter manages a growing set of UTXOs. If the UTXO count exceeds 10,000, the minter periodically creates consolidation transactions that merge the 1,000 smallest UTXOs into 2 new outputs, funded from the minter's fee subaccount. This prevents the UTXO set from growing large enough to make withdrawals impossible (a Bitcoin transaction is limited to 100 KB). + ### KYT checker The ckBTC checker canister (`oltsj-fqaaa-aaaar-qal5q-cai`) performs know-your-transaction compliance checks on incoming Bitcoin UTXOs. It is called internally by the minter on deposit and is not part of the developer-facing API. @@ -191,6 +231,28 @@ By default, the canister requires all providers to agree (`Equality` consensus). For integration examples, see the [Ethereum guide](../guides/chain-fusion/ethereum.md). +## SOL RPC canister + +The SOL RPC canister proxies JSON-RPC calls to the Solana network via HTTPS outcalls. It follows the same pattern as the EVM RPC canister: each request is forwarded to multiple independent RPC providers and the results are compared for consensus before being returned to the caller. No API keys are required. + +| Field | Value | +|---|---| +| Canister ID | [`2xib7-jqaaa-aaaar-qai6q-cai`](https://dashboard.internetcomputer.org/canister/2xib7-jqaaa-aaaar-qai6q-cai) | +| Source | [dfinity/sol-rpc-canister](https://github.com/dfinity/sol-rpc-canister) | + +### Built-in RPC providers + +| Provider | +|---| +| [Alchemy](https://www.alchemy.com/) | +| [Ankr](https://www.ankr.com/) | +| [Chainstack](https://chainstack.com/) | +| [dRPC](https://drpc.org/) | +| [Helius](https://www.helius.dev/) | +| [PublicNode](https://publicnode.com/) | + +For integration examples, see the [Solana guide](../guides/chain-fusion/solana.md). + ## Exchange rate canister (XRC) The exchange rate canister (XRC) uses HTTPS outcalls to fetch cryptocurrency and foreign exchange rates from major exchanges. It runs on the `uzr34` system subnet and is used by the cycles minting canister (CMC) to convert ICP to cycles at a stable XDR-pegged price. @@ -293,6 +355,7 @@ For governance context, see the [SNS documentation](https://learn.internetcomput | ckDOGE Minter | `eqltq-xqaaa-aaaar-qb3vq-cai` | DOGE ↔ ckDOGE minting and burning | | ckSOL Minter | `lh22c-kyaaa-aaaar-qb5nq-cai` | SOL ↔ ckSOL minting and burning | | EVM RPC | `7hfb6-caaaa-aaaar-qadga-cai` | Ethereum JSON-RPC proxy | +| SOL RPC | `2xib7-jqaaa-aaaar-qai6q-cai` | Solana JSON-RPC proxy | | Exchange Rate (XRC) | `uf6dk-hyaaa-aaaaq-qaaaq-cai` | Crypto and forex exchange rates | | SNS-W | `qaa6y-5yaaa-aaaaa-aaafa-cai` | SNS deployment and upgrades |