diff --git a/.github/workflows/basic_bitcoin.yml b/.github/workflows/basic_bitcoin.yml index 227a86f070..a753705cc8 100644 --- a/.github/workflows/basic_bitcoin.yml +++ b/.github/workflows/basic_bitcoin.yml @@ -6,6 +6,7 @@ on: pull_request: paths: - motoko/basic_bitcoin/** + - rust/basic_bitcoin/** - .github/workflows/basic_bitcoin.yml concurrency: @@ -41,3 +42,38 @@ jobs: icp network start -d icp deploy --cycles 30t bash test.sh + + rust-basic_bitcoin: + # Run directly on the host (no container:) so that icp-cli can bind-mount + # the status directory into our custom Docker image. When icp-cli runs inside + # a container, the tmpdir it creates is invisible to the host Docker daemon. + runs-on: ubuntu-24.04 + env: + ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Install icp-cli and ic-wasm + run: npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - name: Install candid-extractor + run: cargo install --locked candid-extractor + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + - name: Build network launcher image + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + with: + context: rust/basic_bitcoin + push: false + load: true + tags: icp-cli-network-launcher-bitcoin:latest + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Deploy and test + working-directory: rust/basic_bitcoin + run: | + icp network start -d + icp deploy --cycles 30t + bash test.sh diff --git a/rust/basic_bitcoin/.gitignore b/rust/basic_bitcoin/.gitignore index f8454968c7..1ee740a699 100644 --- a/rust/basic_bitcoin/.gitignore +++ b/rust/basic_bitcoin/.gitignore @@ -16,4 +16,7 @@ target/ # bitcoin bitcoin_data +# ord +ord-db/ + canister_ids.json diff --git a/rust/basic_bitcoin/Cargo.lock b/rust/basic_bitcoin/Cargo.lock index 4c20c411bd..62add5671d 100644 --- a/rust/basic_bitcoin/Cargo.lock +++ b/rust/basic_bitcoin/Cargo.lock @@ -27,17 +27,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] -name = "base58ck" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" -dependencies = [ - "bitcoin-internals", - "bitcoin_hashes", -] - -[[package]] -name = "basic_bitcoin" +name = "backend" version = "0.1.0" dependencies = [ "bitcoin", @@ -46,9 +36,17 @@ dependencies = [ "ic-cdk", "ic-cdk-bitcoin-canister", "ic-cdk-management-canister", - "leb128", "serde", - "serde_bytes", +] + +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", ] [[package]] @@ -348,9 +346,9 @@ dependencies = [ [[package]] name = "ic-cdk" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057912339f889013f42b36cc0623585949ed278457efb32aef041bdc48acb111" +checksum = "6a7971f4983db147afbbc4e7f87f60b09fcd60ac707af37ff3d2468dcddbf551" dependencies = [ "candid", "ic-cdk-executor", @@ -386,9 +384,9 @@ dependencies = [ [[package]] name = "ic-cdk-macros" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b140627c01710ac185fbc984ab1fda1781ffef4abbd952e07383350899b0952b" +checksum = "a7c20c002200c720958f321bb78b4d4987fc2c380bf9ef69ed4404730810f45f" dependencies = [ "candid", "darling", @@ -435,9 +433,9 @@ dependencies = [ [[package]] name = "ic0" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1499d08fd5be8f790d477e1865d63bab6a8d748300e141270c4296e6d5fdd6bc" +checksum = "c77c8932bff1f09502d0d8c079d5a206a06afe523e35e816162cf4d30b5bf80d" [[package]] name = "ic_principal" diff --git a/rust/basic_bitcoin/Cargo.toml b/rust/basic_bitcoin/Cargo.toml index fe2838e81d..d1e49e317a 100644 --- a/rust/basic_bitcoin/Cargo.toml +++ b/rust/basic_bitcoin/Cargo.toml @@ -1,20 +1,3 @@ -[package] -name = "basic_bitcoin" -version = "0.1.0" -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[lib] -crate-type = ["cdylib"] - -[dependencies] -hex = "0.4.3" -bitcoin = "0.32.7" -candid = "0.10.19" -ic-cdk = "0.20.0" -ic-cdk-bitcoin-canister = "0.2" -ic-cdk-management-canister = "0.1" -serde = "1.0.132" -serde_bytes = "0.11.15" -leb128 = "0.2.5" +[workspace] +members = ["backend"] +resolver = "2" diff --git a/rust/basic_bitcoin/Dockerfile b/rust/basic_bitcoin/Dockerfile new file mode 100644 index 0000000000..47bd891983 --- /dev/null +++ b/rust/basic_bitcoin/Dockerfile @@ -0,0 +1,32 @@ +# Always use the latest network launcher image +# Before building we must pull to pick up new releases +# For real world usage, consider pinning the version instead of using :latest +FROM ghcr.io/dfinity/icp-cli-network-launcher:latest + +ARG TARGETARCH +ARG BITCOIN_VERSION=27.2 + +RUN apt-get update && apt-get install -y --no-install-recommends curl && \ + case "${TARGETARCH}" in \ + "amd64") \ + BITCOIN_TARBALL="bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz" ; \ + BITCOIN_SHA256="acc223af46c178064c132b235392476f66d486453ddbd6bca6f1f8411547da78" ;; \ + "arm64") \ + BITCOIN_TARBALL="bitcoin-${BITCOIN_VERSION}-aarch64-linux-gnu.tar.gz" ; \ + BITCOIN_SHA256="154c9b9e6e17136edc8f20fda5d252fb339e727e4a85ef49e7d8facb9085f2d3" ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \ + esac && \ + curl -fsSL "https://bitcoincore.org/bin/bitcoin-core-${BITCOIN_VERSION}/${BITCOIN_TARBALL}" \ + -o /tmp/bitcoin.tar.gz && \ + echo "${BITCOIN_SHA256} /tmp/bitcoin.tar.gz" | sha256sum -c && \ + tar xzf /tmp/bitcoin.tar.gz --strip-components=2 \ + -C /usr/local/bin \ + "bitcoin-${BITCOIN_VERSION}/bin/bitcoind" \ + "bitcoin-${BITCOIN_VERSION}/bin/bitcoin-cli" && \ + rm /tmp/bitcoin.tar.gz && \ + apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* + +COPY docker/start.sh /app/start.sh +RUN chmod +x /app/start.sh + +ENTRYPOINT ["/app/start.sh"] diff --git a/rust/basic_bitcoin/Makefile b/rust/basic_bitcoin/Makefile deleted file mode 100644 index da0e4d3a86..0000000000 --- a/rust/basic_bitcoin/Makefile +++ /dev/null @@ -1,42 +0,0 @@ -.PHONY: all -all: deploy - -.PHONY: deploy -.SILENT: deploy -deploy: - dfx deploy basic_bitcoin --argument '(variant { regtest })' - -.PHONY: regtest_topup -.SILENT: regtest_topup -regtest_topup: - P2PKH_ADDR=$(shell dfx canister call basic_bitcoin get_p2pkh_address | tr -d '()') && \ - P2TR_ADDR=$(shell dfx canister call basic_bitcoin get_p2tr_address | tr -d '()') && \ - P2TR_KEY_ONLY_ADDR=$(shell dfx canister call basic_bitcoin get_p2tr_key_only_address | tr -d '()') && \ - TOPUP_CMD_P2PKH_ADDR="bitcoin-cli -regtest -rpcport=8333 sendtoaddress $${P2PKH_ADDR} 1" && \ - TOPUP_CMD_P2TR_ADDR="bitcoin-cli -regtest -rpcport=8333 sendtoaddress $${P2TR_ADDR} 1" && \ - TOPUP_CMD_P2TR_KEY_ONLY_ADDR="bitcoin-cli -regtest -rpcport=8333 sendtoaddress $${P2TR_KEY_ONLY_ADDR} 1" && \ - eval "$${TOPUP_CMD_P2PKH_ADDR}" && \ - eval "$${TOPUP_CMD_P2PKH_ADDR}" && \ - eval "$${TOPUP_CMD_P2PKH_ADDR}" && \ - eval "$${TOPUP_CMD_P2TR_ADDR}" && \ - eval "$${TOPUP_CMD_P2TR_ADDR}" && \ - eval "$${TOPUP_CMD_P2TR_ADDR}" && \ - eval "$${TOPUP_CMD_P2TR_KEY_ONLY_ADDR}" && \ - eval "$${TOPUP_CMD_P2TR_KEY_ONLY_ADDR}" && \ - eval "$${TOPUP_CMD_P2TR_KEY_ONLY_ADDR}" && \ - bitcoin-cli -regtest -rpcport=8333 -generate 6 - -.PHONY: test -.SILENT: test -# No tests yet. This target exists so CI doesn't fail when it runs `make test`. -test: - -.PHONY: clean -.SILENT: clean -clean: - rm -rf .dfx - rm -rf dist - rm -rf node_modules - rm -rf src/declarations - rm -f .env - cargo clean diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index eb22617a98..5dba5ed09f 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -1,100 +1,71 @@ # Basic Bitcoin -This example demonstrates how to deploy a smart contract on the Internet Computer that can receive and send bitcoin, including support for legacy (P2PKH), SegWit (P2WPKH), and Taproot (P2TR) address types. - -This example also includes how to work with Bitcoin assets such as Ordinals, Runes, and BRC-20 tokens. - -## Table of contents - -* [Architecture](#architecture) -* [Deploying from ICP Ninja](#deploying-from-icp-ninja) -* [Building and deploying the smart contract locally](#building-and-deploying-the-smart-contract-locally) - * [1. Prerequisites](#1-prerequisites) - * [2. Clone the examples repo](#2-clone-the-examples-repo) - * [3. Start the ICP execution environment](#3-start-the-icp-execution-environment) - * [4. Start Bitcoin regtest](#4-start-bitcoin-regtest) - * [5. Deploy the smart contract](#4-deploy-the-smart-contract) -* [Generating Bitcoin addresses](#generating-bitcoin-addresses) -* [Receiving bitcoin](#receiving-bitcoin) -* [Prerequisites](#prerequisites) -* [Checking balance](#checking-balance) -* [Sending bitcoin](#sending-bitcoin) -* [Retrieving blockchain info](#retrieving-blockchain-info) -* [Retrieving block headers](#retrieving-block-headers) -* [Bitcoin assets](#bitcoin-assets) - - * [Prerequisites for Bitcoin assets](#prerequisites-for-bitcoin-assets) -* [Inscribe an Ordinal](#inscribe-an-ordinal) -* [Etch a Rune](#etch-a-rune) -* [Deploy a BRC-20 token](#deploy-a-brc-20-token) -* [Notes on implementation](#notes-on-implementation) -* [Security considerations and best practices](#security-considerations-and-best-practices) +This example demonstrates how a canister can receive and send bitcoin on the Internet Computer, including support for legacy (P2PKH), SegWit (P2WPKH), and Taproot (P2TR) address types. ## Architecture -This example integrates with the Internet Computer's built-in: - -* [ECDSA API](https://internetcomputer.org/docs/current/references/ic-interface-spec/#ic-ecdsa_public_key) -* [Schnorr API](https://internetcomputer.org/docs/current/references/ic-interface-spec/#ic-sign_with_schnorr) -* [Bitcoin API](https://github.com/dfinity/bitcoin-canister/blob/master/INTERFACE_SPECIFICATION.md) - -For background on the ICP<>BTC integration, refer to the [Learn Hub](https://learn.internetcomputer.org/hc/en-us/articles/34211154520084-Bitcoin-Integration). - - -## Deploying from ICP Ninja +For a deeper understanding of the ICP ↔ Bitcoin integration, see the [Bitcoin integration concepts](https://docs.internetcomputer.org/concepts/chain-fusion/bitcoin). -This example can be deployed directly to the Internet Computer using ICP Ninja, where it connects to Bitcoin **testnet4**. Note: Canisters deployed via ICP Ninja remain live for 50 minutes after signing in with your Internet Identity. - -[![](https://icp.ninja/assets/open.svg)](https://icp.ninja/editor?g=https://github.com/dfinity/examples/tree/master/rust/basic_bitcoin) +This example integrates with the Internet Computer's built-in: -## Building and deploying the smart contract locally +- Threshold ECDSA ([`ecdsa_public_key`](https://docs.internetcomputer.org/references/ic-interface-spec/management-canister/#ic-ecdsa_public_key), [`sign_with_ecdsa`](https://docs.internetcomputer.org/references/ic-interface-spec/management-canister/#ic-sign_with_ecdsa)) — derives P2PKH addresses and signs transactions spending from them +- Threshold Schnorr ([`schnorr_public_key`](https://docs.internetcomputer.org/references/ic-interface-spec/management-canister/#ic-schnorr_public_key), [`sign_with_schnorr`](https://docs.internetcomputer.org/references/ic-interface-spec/management-canister/#ic-sign_with_schnorr)) — derives P2TR addresses (BIP340/341) and signs Taproot transactions +- [Bitcoin canister](https://docs.internetcomputer.org/references/protocol-canisters/#bitcoin-canisters) — queries balances, UTXOs, fee percentiles, and block data; submits signed transactions to the Bitcoin network -### 1. Prerequisites +## Build and deploy from the command line -* [x] [Rust toolchain](https://www.rust-lang.org/tools/install) -* [x] [Internet Computer SDK](https://internetcomputer.org/docs/building-apps/getting-started/install) -* [x] [Local Bitcoin testnet (regtest)](https://internetcomputer.org/docs/build-on-btc/btc-dev-env#create-a-local-bitcoin-testnet-regtest-with-bitcoind) -* [x] On macOS, an `llvm` version that supports the `wasm32-unknown-unknown` target is required. The Rust `bitcoin` library relies on the `secp256k1-sys` crate, which requires `llvm` to build. The default `llvm` version provided by XCode does not meet this requirement. Install the [Homebrew version](https://formulae.brew.sh/formula/llvm) using `brew install llvm`. +### Prerequisites +- [Node.js](https://nodejs.org/) v18+ +- [icp-cli](https://cli.internetcomputer.org/): `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` +- [Rust](https://www.rust-lang.org/tools/install) v1.85+ with `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown` +- [Docker](https://docs.docker.com/get-docker/) (required to run the custom network launcher image that bundles bitcoind) +- On macOS, a `clang` with WASM support is required to compile the `secp256k1-sys` C library for the `wasm32-unknown-unknown` target. Xcode's bundled clang does not include the WASM backend. Install the [Homebrew LLVM](https://formulae.brew.sh/formula/llvm) and add it to your PATH: + ```bash + brew install llvm + export PATH="$(brew --prefix llvm)/bin:$PATH" + ``` + Add the `export` line to your shell profile (`~/.zshrc` or `~/.bashrc`) to make it permanent. -### 2. Clone the examples repo +### Install ```bash git clone https://github.com/dfinity/examples cd examples/rust/basic_bitcoin ``` -### 3. Start the ICP execution environment +### Build the network launcher image +The local network bundles bitcoind inside a custom Docker image. Build it once before starting the network: -Open a terminal window (terminal 1) and run the following: ```bash -dfx start --enable-bitcoin --bitcoin-node 127.0.0.1:18444 +./build-image.sh ``` -This starts a local canister execution environment with Bitcoin support enabled. - -### 4. Start Bitcoin regtest -Open another terminal window (terminal 2) and run the following to start the local Bitcoin regtest network: +### Deploy locally and test ```bash -bitcoind -conf=$(pwd)/bitcoin.conf -datadir=$(pwd)/bitcoin_data --port=18444 +icp network start -d +icp deploy --cycles 30t +bash test.sh +icp network stop ``` -### 5. Deploy the smart contract +> If tests fail with an out-of-cycles error, top up the canister and retry: +> ```bash +> icp canister top-up --amount 30t backend +> ``` + +### Deploy to the IC network -Open a third terminal (terminal 3) and run the following to deploy the smart contract: +The `ic` environment deploys to IC mainnet connected to Bitcoin testnet4, using `test_key_1`: ```bash -dfx deploy basic_bitcoin --argument '(variant { regtest })' +icp deploy -e ic --cycles 30t ``` -What this does: - -- `dfx deploy` tells the command line interface to `deploy` the smart contract. -- `--argument '(variant { regtest })'` passes the argument `regtest` to initialize the smart contract, telling it to connect to the local Bitcoin regtest network. +> To deploy to Bitcoin mainnet, change the `init_args` for the `ic` environment in `icp.yaml` from `testnet` to `mainnet`. The canister automatically selects `key_1` (the production threshold signing key) when initialized with the `mainnet` variant. -Your smart contract is live and ready to use! You can interact with it using either the command line or the Candid UI (the link you see in the terminal). ## Generating Bitcoin addresses The example demonstrates how to generate and use the following address types: @@ -103,226 +74,117 @@ The example demonstrates how to generate and use the following address types: 2. **P2WPKH (SegWit v0)** using ECDSA and `sign_with_ecdsa` 3. **P2TR (Taproot, key-path-only)** using Schnorr keys and `sign_with_schnorr` 4. **P2TR (Taproot, script-path-enabled)** commits to a script allowing both key path and script path spending -Use the Candid UI or CLI to generate: ```bash -dfx canister call basic_bitcoin get_p2pkh_address +icp canister call backend get_p2pkh_address '()' # or: get_p2wpkh_address, get_p2tr_key_path_only_address, get_p2tr_script_path_enabled_address ``` -## Receiving bitcoin +## Funding and sending bitcoin: a complete walkthrough -Use the `bitcoin-cli` to mine a Bitcoin block and send the block reward in the form of local testnet bitcoin to one of the smart contract addresses. -```bash -bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 1 -``` +This walkthrough shows how to fund an address, check its balance, send bitcoin to another address, and confirm the transfer — using the bundled `bitcoind` in regtest mode. + +> **Coinbase maturity:** In Bitcoin, newly mined block rewards (coinbase UTXOs) cannot be spent until 100 more blocks have been mined on top. Mine at least 101 blocks upfront so the first reward is immediately spendable. -## Checking balance +### Step 1 — Get the canister's address and the container ID -Check the balance of any Bitcoin address: ```bash -dfx canister call basic_bitcoin get_balance '("")' +CONTAINER=$(docker ps --filter "ancestor=icp-cli-network-launcher-bitcoin" --format "{{.ID}}" | head -1) +ADDR=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') +echo "Address: $ADDR" ``` -This uses `bitcoin_get_balance` and works for any supported address type. The balance requires at least one confirmation to be reflected. -## Sending bitcoin +### Step 2 — Mine 101 blocks to fund the address -You can send bitcoin using the following endpoints: -Endpoints: - -- `send_from_p2pkh_address` -- `send_from_p2wpkh_address` -- `send_from_p2tr_key_path_only_address` -- `send_from_p2tr_script_path_enabled_address_key_spend` -- `send_from_p2tr_script_path_enabled_address_script_spend` - -Each endpoint internally: - -1. Estimates fees -2. Looks up spendable UTXOs -3. Builds a transaction to the target address -4. Signs using ECDSA or Schnorr, depending on address type -5. Broadcasts the transaction using `bitcoin_send_transaction` - -Example: +Mining 101 blocks ensures the first block reward (50 BTC) is past the coinbase maturity threshold and immediately spendable. ```bash -dfx canister call basic_bitcoin send_from_p2pkh_address '(record { - destination_address = "bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt"; - amount_in_satoshi = 4321; -})' +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 101 "$ADDR" ``` -> [!IMPORTANT] -> Newly mined bitcoin, like those you created with the above `bitcoin-cli` command, cannot be spent until 100 additional blocks have been added to the chain. To make your bitcoin spendable, create 100 additional blocks. Choose one of the smart contract addresses as receiver of the block reward or use any valid Bitcoin dummy address. -> -> ```bash -> bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 100 -> ``` - -The function returns the transaction ID. When interacting with the contract deployed on IC mainnet, you can track testnet transactions on [mempool.space](https://mempool.space/testnet4/). +### Step 3 — Check the balance -## Retrieving blockchain info - -You can query the current state of the Bitcoin blockchain: +The IC Bitcoin integration syncs new blocks continuously. If the balance shows 0, wait a few seconds and retry. ```bash -dfx canister call basic_bitcoin get_blockchain_info +icp canister call backend get_balance "(\"$ADDR\")" +# Expected: (505_000_000_000 : nat64) — 101 blocks × 50 BTC each ``` -This calls `get_blockchain_info` on the Bitcoin canister and returns the tip height, block hash, timestamp, difficulty, and total UTXO count. It is useful for monitoring the state of the Bitcoin network from your smart contract. - -## Retrieving block headers - -You can query historical block headers: +### Step 4 — Send bitcoin ```bash -dfx canister call basic_bitcoin get_block_headers '(10: nat32, null)' -# or a range: -dfx canister call basic_bitcoin get_block_headers '(10: nat32, opt (11: nat32))' +DEST="bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" +icp canister call backend send_from_p2pkh_address "(record { + destination_address = \"$DEST\"; + amount_in_satoshi = 4321; +})" +# Returns the transaction ID ``` -This calls `bitcoin_get_block_headers`, which is useful for blockchain validation or light client logic. -## Bitcoin assets - -Bitcoin's scripting capabilities enable various digital assets beyond simple transfers. This example demonstrates how to create and interact with three major Bitcoin asset protocols from an ICP smart contract: - -- **Ordinals**: Inscribe arbitrary data onto individual satoshis -- **Runes**: Create fungible tokens using `OP_RETURN` outputs -- **BRC-20**: Build fungible tokens on top of Ordinals using JSON +The transaction is now broadcast to `bitcoind`'s mempool. The destination balance will remain 0 until it is confirmed in a block. -### Prerequisites for Bitcoin assets - -All Bitcoin assets rely on off-chain indexing since the Bitcoin protocol doesn't natively support querying these assets. The `ord` CLI tool is the standard indexer for Bitcoin assets like Ordinals and Runes. - -Install `ord` using a package manager. For example, on macOS: +### Step 5 — Mine a confirmation block ```bash -brew install ord +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" ``` -For other platforms, see the [ord repository](https://github.com/ordinals/ord) for installation instructions. - -> [!NOTE] -> This repository includes a [default ord config file](./ord.yaml) that matches the also provided [bitcoin config file](./bitcoin.conf). - -> [!IMPORTANT] -> **Bitcoin Configuration**: To work with Bitcoin assets, make sure bitcoind is configured to accept non-standard transactions by including this setting in your `bitcoin.conf`: -> -> ``` -> acceptnonstdtxn=1 -> ``` - -## Inscribe an Ordinal - -[Ordinals](https://ordinals.com) is a protocol that allows inscribing arbitrary data (text, images, etc.) onto individual satoshis, creating unique digital artifacts on Bitcoin. Each inscription is permanently stored in the Bitcoin blockchain using a two-transaction commit/reveal process. - -### Step-by-step process: - -1. **Start the ord server** to index transactions: - ```bash - ord --config-dir . server - ``` +### Step 6 — Verify the destination received the funds -2. **Get a Taproot address** for funding the inscription: - ```bash - dfx canister call basic_bitcoin get_p2tr_key_path_only_address '()' - ``` - -3. **Fund the address** with sufficient bitcoin (100 blocks ensures spendability): - ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 100 - ``` - -4. **Create the inscription** with your desired text: - ```bash - dfx canister call basic_bitcoin inscribe_ordinal '("Hello Bitcoin")' - ``` - -5. **Mine a block** to confirm the transactions: - ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 1 - ``` - -The function returns the reveal transaction ID. Your inscription is now permanently stored on Bitcoin and can be viewed using ord or other Ordinals explorers. The default address of the local `ord` server is `http://127.0.0.1:80/`. - -## Etch a Rune - -[Runes](https://docs.ordinals.com/runes.html) is a fungible token protocol that embeds token metadata directly into Bitcoin transactions using `OP_RETURN` outputs. Unlike Ordinals, Runes are created in a single transaction and support standard fungible token operations. - -### Step-by-step process: - -1. **Start the ord server** to track Rune balances: - ```bash - ord --config-dir . server - ``` +```bash +icp canister call backend get_balance "(\"$DEST\")" +# Expected: (4_321 : nat64) +``` -2. **Get a Taproot address** for the Rune etching: - ```bash - dfx canister call basic_bitcoin get_p2tr_key_path_only_address '()' - ``` +## Other send endpoints -3. **Fund the address** with bitcoin to pay for the etching: - ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 100 - ``` +The same pattern (fund → send → mine confirmation block → verify) applies to the other address types: -4. **Etch the Rune** with an uppercase name (maximum 28 characters): - ```bash - dfx canister call basic_bitcoin etch_rune '("ICPRUNE")' - ``` +- `send_from_p2wpkh_address` +- `send_from_p2tr_key_path_only_address` +- `send_from_p2tr_script_path_enabled_address_key_spend` +- `send_from_p2tr_script_path_enabled_address_script_spend` -5. **Mine a block** to confirm the etching: - ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 1 - ``` +Each endpoint internally estimates fees, selects UTXOs, builds a transaction, signs it using ECDSA or Schnorr, and broadcasts it via `bitcoin_send_transaction`. -6. **Decode the Runestone** to verify the etching: - ```bash - ord --config-dir . decode --txid - ``` +When the canister is deployed on IC mainnet, you can track testnet transactions on [mempool.space](https://mempool.space/testnet4/). -The Rune is now etched with 1_000_000 tokens minted to your address. The tokens can be transferred using standard Bitcoin transactions with Runestone data. +## Querying UTXOs -## Deploy a BRC-20 token +You can inspect the UTXOs held at any Bitcoin address: -[BRC-20](https://domo-2.gitbook.io/brc-20-experiment/) is a token standard built on top of Ordinals that uses structured JSON payloads to create fungible tokens. BRC-20 tokens follow the same inscription process as Ordinals but with standardized JSON formats. +```bash +icp canister call backend get_utxos "(\"$ADDR\")" +``` -### Step-by-step process: +This returns all unspent outputs at the address — useful for verifying that funds arrived or for debugging balance issues. The response includes each outpoint (txid + vout index), value in satoshis, and confirmation height. -1. **Start the ord server** to index BRC-20 inscriptions: - ```bash - ord --config-dir . server - ``` +## Retrieving blockchain info -2. **Get a Taproot address** for the token deployment: - ```bash - dfx canister call basic_bitcoin get_p2tr_key_path_only_address '()' - ``` +You can query the current state of the Bitcoin blockchain: -3. **Fund the address** with bitcoin: - ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 100 - ``` +```bash +icp canister call backend get_blockchain_info '()' +``` -4. **Deploy the BRC-20 token** with a 4-character ticker: - ```bash - dfx canister call basic_bitcoin inscribe_brc20 '("DEMO")' - ``` +This calls `get_blockchain_info` on the Bitcoin canister and returns the tip height, block hash, timestamp, difficulty, and total UTXO count. It is useful for monitoring the state of the Bitcoin network from your canister. -5. **Mine a block** to confirm the deployment: - ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 1 - ``` +## Retrieving block headers -This creates a BRC-20 token with: -- Ticker: "DEMO" -- Max supply: 21_000_000 tokens -- Mint limit: 1_000 tokens per mint +You can query historical block headers: -The deployment inscription contains JSON metadata that BRC-20 indexers use to track token balances and transfers. Additional mint and transfer operations require separate inscriptions following the BRC-20 protocol. +```bash +icp canister call backend get_block_headers '(10: nat32, null)' +# or a range: +icp canister call backend get_block_headers '(10: nat32, opt (11: nat32))' +``` -To view the deployed BRC-20 token, use the local `ord` explorer at `http://127.0.0.1:80/`. +This calls `bitcoin_get_block_headers`, which is useful for blockchain validation or light client logic. ## Notes on implementation @@ -332,19 +194,14 @@ This example implements several important patterns for Bitcoin integration: - **Key caching**: Optimization is used to avoid repeated calls to `get_ecdsa_public_key` and `get_schnorr_public_key`. - **Manual transaction construction**: Transactions are assembled and signed manually, ensuring maximum flexibility in construction and fee estimation. - **Cost optimization**: When testing on mainnet, the [chain-key testing canister](https://github.com/dfinity/chainkey-testing-canister) can be used to save on costs for calling the threshold signing APIs. -- **Asset protocols**: Bitcoin assets (Ordinals, Runes, BRC-20) demonstrate advanced scripting capabilities and witness data usage. ## Security considerations and best practices This example is provided for educational purposes and is not production-ready. It is important to consider security implications when developing applications that interact with Bitcoin or other cryptocurrencies. The code has **not been audited** and may contain vulnerabilities or security issues. -If you base your application on this example, we recommend you familiarize yourself with and adhere to the [security best practices](https://internetcomputer.org/docs/current/references/security/) for developing on the Internet Computer. This example may not implement all the best practices. +If you base your application on this example, we recommend you familiarize yourself with and adhere to the [security best practices](https://docs.internetcomputer.org/guides/security/overview) for developing on the Internet Computer. This example may not implement all the best practices. For example, the following aspects are particularly relevant for this app: -- [Certify query responses if they are relevant for security](https://internetcomputer.org/docs/building-apps/security/data-integrity-and-authenticity#using-certified-variables-for-secure-queries), since the app e.g. offers a method to read balances. -- [Use a decentralized governance system like SNS to make a smart contract have a decentralized controller](https://internetcomputer.org/docs/building-apps/security/decentralization), since decentralized control may be essential for smart contracts holding bitcoins on behalf of users. - ---- - -*Last updated: July 2025* +- Certify query responses if they are relevant for security, since the app offers a method to read balances. +- Use a decentralized governance system like SNS to give a canister a decentralized controller, since decentralized control may be essential for canisters holding bitcoins on behalf of users. diff --git a/rust/basic_bitcoin/backend/Cargo.toml b/rust/basic_bitcoin/backend/Cargo.toml new file mode 100644 index 0000000000..929a632173 --- /dev/null +++ b/rust/basic_bitcoin/backend/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "backend" +version = "0.1.0" +edition = "2024" + +[lib] +# cdylib produces a .wasm binary suitable for deployment as an ICP canister. +crate-type = ["cdylib"] + +[dependencies] +bitcoin = "0.32" +candid = "0.10" +hex = "0.4" +ic-cdk = "0.20.2" +ic-cdk-bitcoin-canister = "0.2" +ic-cdk-management-canister = "0.1.1" +serde = "1.0" diff --git a/rust/basic_bitcoin/src/common.rs b/rust/basic_bitcoin/backend/src/common.rs similarity index 84% rename from rust/basic_bitcoin/src/common.rs rename to rust/basic_bitcoin/backend/src/common.rs index a2a339803c..eb52188d4c 100644 --- a/rust/basic_bitcoin/src/common.rs +++ b/rust/basic_bitcoin/backend/src/common.rs @@ -26,9 +26,9 @@ pub fn select_utxos_greedy( ) -> Result, String> { // Greedily select UTXOs in reverse order (oldest last) until we cover amount + fee. let mut utxos_to_spend = vec![]; - let mut total_spent = 0; + let mut total_spent: u64 = 0; for utxo in own_utxos.iter().rev() { - total_spent += utxo.value; + total_spent = total_spent.saturating_add(utxo.value); utxos_to_spend.push(utxo); if total_spent >= amount + fee { break; @@ -48,9 +48,9 @@ pub fn select_utxos_greedy( /// Selects a single UTXO that can cover the required amount plus fee. /// -/// This function is used when you need to tie a specific operation to a single UTXO, -/// such as with Bitcoin inscriptions where the asset must be associated with specific -/// satoshis. It searches for the first UTXO (in reverse order) that has sufficient value. +/// Use this when an operation must be tied to a specific UTXO — for example, +/// protocols that track individual satoshis through the UTXO graph require that +/// the relevant satoshi remains the first satoshi of a single-input transaction. /// /// Returns an error if no single UTXO has enough value to cover the payment and fee. pub fn select_one_utxo(own_utxos: &[Utxo], amount: u64, fee: u64) -> Result, String> { @@ -67,15 +67,8 @@ pub fn select_one_utxo(own_utxos: &[Utxo], amount: u64, fee: u64) -> Result outputs.push(TxOut { - script_pubkey: script.clone(), - value: Amount::from_sat(0), // OP_RETURN outputs carry no bitcoin value - }), } // Calculate change and add change output if above dust threshold. @@ -326,3 +315,63 @@ impl fmt::Display for DerivationPath { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use ic_cdk_bitcoin_canister::{OutPoint, Txid, Utxo}; + + fn utxo(value: u64) -> Utxo { + Utxo { + outpoint: OutPoint { + txid: Txid::from([0u8; 32]), + vout: 0, + }, + value, + height: 0, + } + } + + // --- select_utxos_greedy --- + + #[test] + fn greedy_selects_multiple_small_utxos() { + let utxos = vec![utxo(1_000), utxo(2_000), utxo(3_000)]; + // Need 4_500 sat + 0 fee → must pick the two largest (3_000 + 2_000) + let selected = select_utxos_greedy(&utxos, 4_500, 0).unwrap(); + assert_eq!(selected.len(), 2); + let total: u64 = selected.iter().map(|u| u.value).sum(); + assert!(total >= 4_500); + } + + #[test] + fn greedy_succeeds_with_exact_single_utxo() { + let utxos = vec![utxo(5_000)]; + let selected = select_utxos_greedy(&utxos, 4_000, 500).unwrap(); + assert_eq!(selected.len(), 1); + } + + #[test] + fn greedy_returns_error_when_insufficient_funds() { + let utxos = vec![utxo(100), utxo(200)]; + assert!(select_utxos_greedy(&utxos, 1_000, 0).is_err()); + } + + // --- select_one_utxo --- + + #[test] + fn single_picks_a_utxo_large_enough_on_its_own() { + let utxos = vec![utxo(500), utxo(10_000), utxo(200)]; + let selected = select_one_utxo(&utxos, 8_000, 500).unwrap(); + // Must be exactly one UTXO and it must cover amount + fee + assert_eq!(selected.len(), 1); + assert!(selected[0].value >= 8_500); + } + + #[test] + fn single_returns_error_when_no_utxo_is_large_enough() { + // Two UTXOs that together cover the amount, but neither alone does + let utxos = vec![utxo(3_000), utxo(3_000)]; + assert!(select_one_utxo(&utxos, 5_000, 0).is_err()); + } +} diff --git a/rust/basic_bitcoin/src/ecdsa.rs b/rust/basic_bitcoin/backend/src/ecdsa.rs similarity index 100% rename from rust/basic_bitcoin/src/ecdsa.rs rename to rust/basic_bitcoin/backend/src/ecdsa.rs diff --git a/rust/basic_bitcoin/src/lib.rs b/rust/basic_bitcoin/backend/src/lib.rs similarity index 86% rename from rust/basic_bitcoin/src/lib.rs rename to rust/basic_bitcoin/backend/src/lib.rs index bd558cc7aa..c1c303e12f 100644 --- a/rust/basic_bitcoin/src/lib.rs +++ b/rust/basic_bitcoin/backend/src/lib.rs @@ -1,16 +1,15 @@ -mod brc20; mod common; mod ecdsa; -mod ordinals; mod p2pkh; mod p2tr; mod p2wpkh; -mod runes; mod schnorr; mod service; use ic_cdk::{init, post_upgrade}; -use ic_cdk_bitcoin_canister::Network; +use ic_cdk_bitcoin_canister::{ + BlockchainInfo, GetBlockHeadersResponse, GetUtxosResponse, MillisatoshiPerByte, Network, +}; use std::cell::Cell; /// Runtime configuration shared across all Bitcoin-related operations. @@ -32,7 +31,7 @@ pub struct BitcoinContext { } // Global, thread-local instance of the Bitcoin context. -// This is initialized at smart contract init/upgrade time and reused across all API calls. +// Initialized at canister init/upgrade time and reused across all API calls. thread_local! { static BTC_CONTEXT: Cell = const { Cell::new(BitcoinContext { @@ -46,8 +45,8 @@ thread_local! { /// Internal shared init logic used both by init and post-upgrade hooks. fn init_upgrade(network: Network) { let key_name = match network { - Network::Regtest => "dfx_test_key", - Network::Mainnet | Network::Testnet => "test_key_1", + Network::Regtest | Network::Testnet => "test_key_1", + Network::Mainnet => "key_1", }; let bitcoin_network = match network { @@ -65,7 +64,7 @@ fn init_upgrade(network: Network) { }); } -/// Smart contract init hook. +/// Canister init hook. /// Sets up the BitcoinContext based on the given IC Bitcoin network. #[init] pub fn init(network: Network) { @@ -86,3 +85,5 @@ pub struct SendRequest { pub destination_address: String, pub amount_in_satoshi: u64, } + +ic_cdk::export_candid!(); diff --git a/rust/basic_bitcoin/src/p2pkh.rs b/rust/basic_bitcoin/backend/src/p2pkh.rs similarity index 94% rename from rust/basic_bitcoin/src/p2pkh.rs rename to rust/basic_bitcoin/backend/src/p2pkh.rs index e1dad47970..7b23520b1b 100644 --- a/rust/basic_bitcoin/src/p2pkh.rs +++ b/rust/basic_bitcoin/backend/src/p2pkh.rs @@ -10,7 +10,6 @@ use bitcoin::{ sighash::{EcdsaSighashType, SighashCache}, Address, AddressType, PublicKey, Transaction, Witness, }; -use ic_cdk::trap; use ic_cdk_bitcoin_canister::{MillisatoshiPerByte, Utxo}; use std::convert::TryFrom; @@ -33,10 +32,8 @@ pub async fn build_transaction( // and sign a transaction, see what its size is, and then update the fee, // rebuild the transaction, until the fee is set to the correct amount. - let amount = match primary_output { - PrimaryOutput::Address(_, amt) => *amt, // grab the amount - PrimaryOutput::OpReturn(_) => trap("expected an address output, got OP_RETURN"), - }; + let PrimaryOutput::Address(_, amount) = primary_output; + let amount = *amount; let mut fee = 0; loop { diff --git a/rust/basic_bitcoin/src/p2tr.rs b/rust/basic_bitcoin/backend/src/p2tr.rs similarity index 89% rename from rust/basic_bitcoin/src/p2tr.rs rename to rust/basic_bitcoin/backend/src/p2tr.rs index bd46a971cf..f68d6a925d 100644 --- a/rust/basic_bitcoin/src/p2tr.rs +++ b/rust/basic_bitcoin/backend/src/p2tr.rs @@ -61,6 +61,14 @@ pub fn create_spend_script(script_key_bytes: &[u8]) -> ScriptBuf { .into_script() } +/// Controls how UTXOs are selected when building a transaction. +/// +/// - `Greedy`: accumulates UTXOs (oldest-first) until the required amount plus fee is covered. +/// Best for normal sends where you want to consolidate smaller UTXOs. +/// - `Single`: requires a single UTXO large enough to cover amount plus fee on its own. +/// Useful when an operation must be tied to a specific UTXO — for example, protocols +/// that track individual satoshis through the UTXO graph require that the target satoshi +/// enters as the first input of a single-UTXO transaction. pub enum SelectUtxosMode { Greedy, Single, @@ -84,10 +92,8 @@ pub(crate) async fn build_transaction( // We solve this problem iteratively. We start with a fee of zero, build // and sign a transaction, see what its size is, and then update the fee, // rebuild the transaction, until the fee is set to the correct amount. - let amount = match primary_output { - PrimaryOutput::Address(_, amount) => *amount, - PrimaryOutput::OpReturn(_) => 0, - }; + let PrimaryOutput::Address(_, amount) = primary_output; + let amount = *amount; let mut total_fee = 0; loop { let utxos_to_spend = match utxos_mode { @@ -260,3 +266,17 @@ where transaction } + +#[cfg(test)] +mod tests { + use super::SelectUtxosMode; + + #[test] + fn select_utxos_mode_variants_exist() { + // Ensure both variants are reachable so that callers using Single + // (e.g. for single-UTXO operations that track specific satoshis) + // have a clear pattern to follow. + let _greedy = SelectUtxosMode::Greedy; + let _single = SelectUtxosMode::Single; + } +} diff --git a/rust/basic_bitcoin/src/p2wpkh.rs b/rust/basic_bitcoin/backend/src/p2wpkh.rs similarity index 100% rename from rust/basic_bitcoin/src/p2wpkh.rs rename to rust/basic_bitcoin/backend/src/p2wpkh.rs diff --git a/rust/basic_bitcoin/src/schnorr.rs b/rust/basic_bitcoin/backend/src/schnorr.rs similarity index 100% rename from rust/basic_bitcoin/src/schnorr.rs rename to rust/basic_bitcoin/backend/src/schnorr.rs diff --git a/rust/basic_bitcoin/src/service.rs b/rust/basic_bitcoin/backend/src/service.rs similarity index 88% rename from rust/basic_bitcoin/src/service.rs rename to rust/basic_bitcoin/backend/src/service.rs index aa368c909a..4e21a681a3 100644 --- a/rust/basic_bitcoin/src/service.rs +++ b/rust/basic_bitcoin/backend/src/service.rs @@ -1,4 +1,3 @@ -pub mod etch_rune; pub mod get_balance; pub mod get_blockchain_info; pub mod get_block_headers; @@ -8,8 +7,6 @@ pub mod get_p2tr_key_path_only_address; pub mod get_p2tr_script_path_enabled_address; pub mod get_p2wpkh_address; pub mod get_utxos; -pub mod inscribe_brc20; -pub mod inscribe_ordinal; pub mod send_from_p2pkh_address; pub mod send_from_p2tr_key_path_only_address; pub mod send_from_p2tr_script_path_enabled_address_key_spend; diff --git a/rust/basic_bitcoin/src/service/get_balance.rs b/rust/basic_bitcoin/backend/src/service/get_balance.rs similarity index 100% rename from rust/basic_bitcoin/src/service/get_balance.rs rename to rust/basic_bitcoin/backend/src/service/get_balance.rs diff --git a/rust/basic_bitcoin/src/service/get_block_headers.rs b/rust/basic_bitcoin/backend/src/service/get_block_headers.rs similarity index 100% rename from rust/basic_bitcoin/src/service/get_block_headers.rs rename to rust/basic_bitcoin/backend/src/service/get_block_headers.rs diff --git a/rust/basic_bitcoin/src/service/get_blockchain_info.rs b/rust/basic_bitcoin/backend/src/service/get_blockchain_info.rs similarity index 100% rename from rust/basic_bitcoin/src/service/get_blockchain_info.rs rename to rust/basic_bitcoin/backend/src/service/get_blockchain_info.rs diff --git a/rust/basic_bitcoin/src/service/get_current_fee_percentiles.rs b/rust/basic_bitcoin/backend/src/service/get_current_fee_percentiles.rs similarity index 100% rename from rust/basic_bitcoin/src/service/get_current_fee_percentiles.rs rename to rust/basic_bitcoin/backend/src/service/get_current_fee_percentiles.rs diff --git a/rust/basic_bitcoin/src/service/get_p2pkh_address.rs b/rust/basic_bitcoin/backend/src/service/get_p2pkh_address.rs similarity index 100% rename from rust/basic_bitcoin/src/service/get_p2pkh_address.rs rename to rust/basic_bitcoin/backend/src/service/get_p2pkh_address.rs diff --git a/rust/basic_bitcoin/src/service/get_p2tr_key_path_only_address.rs b/rust/basic_bitcoin/backend/src/service/get_p2tr_key_path_only_address.rs similarity index 100% rename from rust/basic_bitcoin/src/service/get_p2tr_key_path_only_address.rs rename to rust/basic_bitcoin/backend/src/service/get_p2tr_key_path_only_address.rs diff --git a/rust/basic_bitcoin/src/service/get_p2tr_script_path_enabled_address.rs b/rust/basic_bitcoin/backend/src/service/get_p2tr_script_path_enabled_address.rs similarity index 100% rename from rust/basic_bitcoin/src/service/get_p2tr_script_path_enabled_address.rs rename to rust/basic_bitcoin/backend/src/service/get_p2tr_script_path_enabled_address.rs diff --git a/rust/basic_bitcoin/src/service/get_p2wpkh_address.rs b/rust/basic_bitcoin/backend/src/service/get_p2wpkh_address.rs similarity index 100% rename from rust/basic_bitcoin/src/service/get_p2wpkh_address.rs rename to rust/basic_bitcoin/backend/src/service/get_p2wpkh_address.rs diff --git a/rust/basic_bitcoin/src/service/get_utxos.rs b/rust/basic_bitcoin/backend/src/service/get_utxos.rs similarity index 100% rename from rust/basic_bitcoin/src/service/get_utxos.rs rename to rust/basic_bitcoin/backend/src/service/get_utxos.rs diff --git a/rust/basic_bitcoin/src/service/send_from_p2pkh_address.rs b/rust/basic_bitcoin/backend/src/service/send_from_p2pkh_address.rs similarity index 100% rename from rust/basic_bitcoin/src/service/send_from_p2pkh_address.rs rename to rust/basic_bitcoin/backend/src/service/send_from_p2pkh_address.rs diff --git a/rust/basic_bitcoin/src/service/send_from_p2tr_key_path_only_address.rs b/rust/basic_bitcoin/backend/src/service/send_from_p2tr_key_path_only_address.rs similarity index 100% rename from rust/basic_bitcoin/src/service/send_from_p2tr_key_path_only_address.rs rename to rust/basic_bitcoin/backend/src/service/send_from_p2tr_key_path_only_address.rs diff --git a/rust/basic_bitcoin/src/service/send_from_p2tr_script_path_enabled_address_key_spend.rs b/rust/basic_bitcoin/backend/src/service/send_from_p2tr_script_path_enabled_address_key_spend.rs similarity index 100% rename from rust/basic_bitcoin/src/service/send_from_p2tr_script_path_enabled_address_key_spend.rs rename to rust/basic_bitcoin/backend/src/service/send_from_p2tr_script_path_enabled_address_key_spend.rs diff --git a/rust/basic_bitcoin/src/service/send_from_p2tr_script_path_enabled_address_script_spend.rs b/rust/basic_bitcoin/backend/src/service/send_from_p2tr_script_path_enabled_address_script_spend.rs similarity index 100% rename from rust/basic_bitcoin/src/service/send_from_p2tr_script_path_enabled_address_script_spend.rs rename to rust/basic_bitcoin/backend/src/service/send_from_p2tr_script_path_enabled_address_script_spend.rs diff --git a/rust/basic_bitcoin/src/service/send_from_p2wpkh_address.rs b/rust/basic_bitcoin/backend/src/service/send_from_p2wpkh_address.rs similarity index 100% rename from rust/basic_bitcoin/src/service/send_from_p2wpkh_address.rs rename to rust/basic_bitcoin/backend/src/service/send_from_p2wpkh_address.rs diff --git a/rust/basic_bitcoin/basic_bitcoin.did b/rust/basic_bitcoin/basic_bitcoin.did deleted file mode 100644 index 7e5fc372cd..0000000000 --- a/rust/basic_bitcoin/basic_bitcoin.did +++ /dev/null @@ -1,110 +0,0 @@ -type satoshi = nat64; - -type millisatoshi_per_vbyte = nat64; - -type bitcoin_address = text; - -type transaction_id = text; - -type block_hash = blob; - -type network = variant { - regtest; - testnet; - mainnet; -}; - -type outpoint = record { - txid : blob; - vout : nat32; -}; - -type utxo = record { - outpoint : outpoint; - value : satoshi; - height : nat32; -}; - -type get_utxos_response = record { - utxos : vec utxo; - tip_block_hash : block_hash; - tip_height : nat32; - next_page : opt blob; -}; - -type block_header = blob; -type block_height = nat32; - -type get_block_headers_response = record { - tip_height : block_height; - block_headers : vec block_header; -}; - -type blockchain_info = record { - height : block_height; - block_hash : block_hash; - timestamp : nat32; - difficulty : nat; - utxos_length : nat64; -}; - -service : (network) -> { - "etch_rune" : (ticker: text) -> (transaction_id); - - "get_p2pkh_address" : () -> (bitcoin_address); - - "get_p2wpkh_address" : () -> (bitcoin_address); - - "get_p2tr_script_path_enabled_address" : () -> (bitcoin_address); - - "get_p2tr_key_path_only_address" : () -> (bitcoin_address); - - "get_balance" : (address : bitcoin_address) -> (satoshi); - - "get_utxos" : (bitcoin_address) -> (get_utxos_response); - - "get_blockchain_info" : () -> (blockchain_info); - - "get_block_headers" : (start_height : block_height, end_height : opt block_height) -> (get_block_headers_response); - - "get_current_fee_percentiles" : () -> (vec millisatoshi_per_vbyte); - - "inscribe_brc20": (ticker: text) -> (transaction_id); - - "inscribe_ordinal": (text: text) -> (transaction_id); - - "send_from_p2pkh_address" : ( - record { - destination_address : bitcoin_address; - amount_in_satoshi : satoshi; - } - ) -> (transaction_id); - - "send_from_p2wpkh_address" : ( - record { - destination_address : bitcoin_address; - amount_in_satoshi : satoshi; - } - ) -> (transaction_id); - - "send_from_p2tr_key_path_only_address" : ( - record { - destination_address : bitcoin_address; - amount_in_satoshi : satoshi; - } - ) -> (transaction_id); - - "send_from_p2tr_script_path_enabled_address_key_spend" : ( - record { - destination_address : bitcoin_address; - amount_in_satoshi : satoshi; - } - ) -> (transaction_id); - - "send_from_p2tr_script_path_enabled_address_script_spend" : ( - record { - destination_address : bitcoin_address; - amount_in_satoshi : satoshi; - } - ) -> (transaction_id); -}; diff --git a/rust/basic_bitcoin/bitcoin.conf b/rust/basic_bitcoin/bitcoin.conf index c85104ea89..28a812e7b4 100644 --- a/rust/basic_bitcoin/bitcoin.conf +++ b/rust/basic_bitcoin/bitcoin.conf @@ -2,4 +2,4 @@ regtest=1 acceptnonstdtxn=1 txindex=1 rpcuser=ic-btc-integration -rpcpassword=QPQiNaph19FqUsCrBRN0FII7lyM26B51fAMeBQzCb-E= +rpcpassword=ic-btc-integration diff --git a/rust/basic_bitcoin/build-image.sh b/rust/basic_bitcoin/build-image.sh new file mode 100755 index 0000000000..31f95a1136 --- /dev/null +++ b/rust/basic_bitcoin/build-image.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e +# Uses --pull to always fetch the latest base image. +# Remove --pull if the Dockerfile is updated to pin the base image version. +docker build --pull -t icp-cli-network-launcher-bitcoin . diff --git a/rust/basic_bitcoin/build.sh b/rust/basic_bitcoin/build.sh deleted file mode 100755 index aa67ef942b..0000000000 --- a/rust/basic_bitcoin/build.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="wasm32-unknown-unknown" -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" - -# Change to the script directory -cd "$SCRIPT_DIR" - -# Build based on the platform. On MacOS, use LLVM's clang and llvm-ar -# to avoid issues with the default clang and ar. See more info in the README. -if [ "$(uname)" == "Darwin" ]; then - LLVM_PATH=$(brew --prefix llvm) - AR="${LLVM_PATH}/bin/llvm-ar" CC="${LLVM_PATH}/bin/clang" cargo build --target $TARGET --release -else - cargo build --target $TARGET --release -fi \ No newline at end of file diff --git a/rust/basic_bitcoin/dfx.json b/rust/basic_bitcoin/dfx.json deleted file mode 100644 index ca7f1864a3..0000000000 --- a/rust/basic_bitcoin/dfx.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "version": 1, - "canisters": { - "basic_bitcoin": { - "build": "build.sh", - "candid": "basic_bitcoin.did", - "gzip": true, - "metadata": [ - { - "name": "candid:service", - "path": "basic_bitcoin.did", - "visibility": "public" - } - ], - "package": "basic_bitcoin", - "type": "custom", - "wasm": "target/wasm32-unknown-unknown/release/basic_bitcoin.wasm", - "init_arg": "(variant { testnet })" - } - }, - "defaults": { - "bitcoin": { - "enabled": true, - "nodes": ["127.0.0.1:18444"] - } - }, - "networks": { - "local": { - "bind": "127.0.0.1:4943" - } - } -} diff --git a/rust/basic_bitcoin/docker/start.sh b/rust/basic_bitcoin/docker/start.sh new file mode 100644 index 0000000000..9db7d5c4bb --- /dev/null +++ b/rust/basic_bitcoin/docker/start.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Start bitcoind in regtest mode, then hand off to the IC network launcher. +# bitcoind runs in the background; the launcher becomes PID 1 via exec. + +bitcoind \ + -regtest -server \ + -rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0 \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + -fallbackfee=0.00001 -txindex=1 & + +# Wait for bitcoind to accept RPC connections +until bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + getblockcount >/dev/null 2>&1; do + sleep 0.5 +done + +echo "bitcoind ready on regtest" + +# Hand off to the IC network launcher. +# --bitcoind-addr wires the IC Bitcoin subnet to our local bitcoind. +# Port 18443 is the RPC port (used by bitcoin-cli inside the container). +# Port 18444 is the P2P port (used by the launcher for block discovery). +exec /app/icp-cli-network-launcher \ + --status-dir=/app/status \ + --config-port 4942 \ + --gateway-port 4943 \ + --bind 0.0.0.0 \ + --bitcoind-addr=127.0.0.1:18444 \ + "$@" diff --git a/rust/basic_bitcoin/icp.yaml b/rust/basic_bitcoin/icp.yaml new file mode 100644 index 0000000000..341ab9a7bb --- /dev/null +++ b/rust/basic_bitcoin/icp.yaml @@ -0,0 +1,25 @@ +canisters: + - name: backend + recipe: + type: "@dfinity/rust@v3.3.0" + +networks: + - name: local + mode: managed + image: icp-cli-network-launcher-bitcoin + port-mapping: + - 0:4943 # IC gateway (dynamic) + +environments: + - name: local + network: local + init_args: + backend: "(variant { regtest })" + + - name: ic + network: ic + # Deploys to Bitcoin testnet4 using test_key_1 by default. + # For Bitcoin mainnet, change to "(variant { mainnet })" — the canister + # automatically switches to key_1 when initialized with the mainnet variant. + init_args: + backend: "(variant { testnet })" diff --git a/rust/basic_bitcoin/ord.yaml b/rust/basic_bitcoin/ord.yaml deleted file mode 100644 index a0f57be67d..0000000000 --- a/rust/basic_bitcoin/ord.yaml +++ /dev/null @@ -1,9 +0,0 @@ -bitcoin_data_dir: ./bitcoin_data -bitcoin_rpc_password: QPQiNaph19FqUsCrBRN0FII7lyM26B51fAMeBQzCb-E= -bitcoin_rpc_url: http://127.0.0.1:18443 -bitcoin_rpc_username: ic-btc-integration -chain: regtest -index_addresses: true -index_runes: true -index_sats: true -index_transactions: true diff --git a/rust/basic_bitcoin/src/brc20.rs b/rust/basic_bitcoin/src/brc20.rs deleted file mode 100644 index caeece45a8..0000000000 --- a/rust/basic_bitcoin/src/brc20.rs +++ /dev/null @@ -1,48 +0,0 @@ -use bitcoin::{ - opcodes::{all::*, OP_FALSE}, - script::{Builder, PushBytesBuf}, - ScriptBuf, XOnlyPublicKey, -}; - -/// Builds the BRC-20 reveal script that contains the JSON token deployment data. -/// -/// The reveal script follows the same Ordinals protocol format as text inscriptions, -/// but uses "application/json" as the content type and structures the data according -/// to the BRC-20 specification for fungible tokens. -/// -/// The script has two execution paths: -/// 1. Normal path: Verify signature against internal_key (for spending authorization) -/// 2. BRC-20 path: Never executes (inside OP_FALSE OP_IF), but stores JSON data -/// -/// The inscription envelope (OP_FALSE OP_IF ... OP_ENDIF) ensures the BRC-20 -/// JSON data is included in the witness but never actually executed, preventing -/// script errors while still making the token data permanently part of the blockchain. -/// -/// Script structure: -/// - internal_key (32 bytes) + OP_CHECKSIG: Enables spending with signature -/// - OP_FALSE + OP_IF: Begin unexecuted inscription envelope -/// - "ord" + field markers: Ordinals protocol identification -/// - "application/json": Content type for BRC-20 data -/// - JSON payload: The actual BRC-20 token deployment data -/// - OP_ENDIF: Close inscription envelope -pub fn build_brc20_reveal_script(internal_key: &XOnlyPublicKey, brc20_json: &str) -> ScriptBuf { - // Convert the BRC-20 JSON string to bytes for embedding in the script. - // Bitcoin scripts work with raw bytes, not strings, so we need this conversion. - let mut inscription_payload = PushBytesBuf::new(); - inscription_payload - .extend_from_slice(brc20_json.as_bytes()) - .unwrap(); - - Builder::new() - .push_slice(internal_key.serialize()) // 32-byte x-only public key - .push_opcode(OP_CHECKSIG) // Verify signature for spending authorization - .push_opcode(OP_FALSE) // Push false to ensure inscription data is never executed - .push_opcode(OP_IF) // Begin inscription envelope (unreachable code) - .push_slice(b"ord") // Ordinals protocol marker - identifies this as an inscription - .push_int(1) // Content type field number (standardized in Ordinals protocol) - .push_slice(b"application/json") // MIME type indicating BRC-20 JSON content - .push_int(0) // Data field number (standardized in Ordinals protocol) - .push_slice(&inscription_payload) // The actual BRC-20 JSON token data - .push_opcode(OP_ENDIF) // End inscription envelope - .into_script() -} diff --git a/rust/basic_bitcoin/src/ordinals.rs b/rust/basic_bitcoin/src/ordinals.rs deleted file mode 100644 index 9279f052a3..0000000000 --- a/rust/basic_bitcoin/src/ordinals.rs +++ /dev/null @@ -1,226 +0,0 @@ -use crate::{schnorr::sign_with_schnorr, BitcoinContext}; -use bitcoin::{ - absolute::LockTime, - hashes::Hash, - opcodes::{all::*, OP_FALSE}, - script::{Builder, PushBytesBuf}, - sighash::{Prevouts, SighashCache}, - taproot::{ControlBlock, LeafVersion}, - transaction::Version, - Address, Amount, OutPoint, ScriptBuf, Sequence, TapLeafHash, TapSighashType, Transaction, TxIn, - TxOut, Txid, Witness, XOnlyPublicKey, -}; -use ic_cdk_bitcoin_canister::MillisatoshiPerByte; - -// Placeholder for the 64-byte Schnorr signature during fee estimation. -// We need this because Bitcoin transaction fees depend on transaction size, -// but we can't sign until we know the fee. This creates a chicken-and-egg problem -// that we solve by using a placeholder of the correct size. -const SCHNORR_SIGNATURE_PLACEHOLDER: [u8; 64] = [0u8; 64]; - -// The amount of satoshis to lock in the inscription output. -// This value must be high enough to ensure the UTXO isn't considered "dust" -// by Bitcoin nodes (typically around 546 sats minimum). -// We use 15,000 sats to be safely above dust limits and account for fees. -pub const INSCRIPTION_OUTPUT_VALUE: u64 = 15_000; - -/// Builds the reveal transaction that spends the commit output. -/// -/// This function handles the complexity of calculating proper fees for the reveal -/// transaction. Since fees depend on transaction size, and size depends on witness -/// data (including signatures), we use an iterative approach to find the right fee. -pub(crate) async fn build_reveal_transaction( - destination_address: &Address, - reveal_script: &ScriptBuf, - control_block: &ControlBlock, - commit_tx_id: &Txid, - fee_per_byte: MillisatoshiPerByte, -) -> Transaction { - // Fee calculation requires knowing transaction size, but size depends on fee - // (since fee affects output amount). This creates a circular dependency. - // - // Solution: Start with fee=0, build transaction with placeholder witness, - // calculate actual size, update fee, and repeat until fee stabilizes. - // This converges quickly (usually 1-2 iterations) because each change in fee - // only slightly affects the output value encoding. - let mut fee = 0; - loop { - let transaction = build_reveal_transaction_with_fee( - reveal_script, - control_block, - commit_tx_id, - destination_address, - fee, - ) - .unwrap(); - - // Calculate fee based on virtual size (vsize accounts for witness discount) - let virtual_size = transaction.vsize() as u64; - if (virtual_size * fee_per_byte) / 1000 == fee { - // Fee calculation has stabilized - return transaction; - } else { - // Update fee and try again - fee = (virtual_size * fee_per_byte) / 1000; - } - } -} - -/// Constructs a reveal transaction with a specific fee amount. -/// -/// The reveal transaction has a simple structure: -/// - Input: Spends the commit output from the commit transaction -/// - Output: Sends remaining value (after fee) to the destination address -/// - Witness: Contains the inscription script and proof of authorization -pub fn build_reveal_transaction_with_fee( - reveal_script: &ScriptBuf, - control_block: &ControlBlock, - commit_tx_id: &Txid, - destination_address: &Address, - fee: u64, -) -> Result { - // Create input that spends the commit output. - // The commit transaction created an output at index 0 that we now spend. - let input = TxIn { - previous_output: OutPoint { - txid: commit_tx_id.to_owned(), - vout: 0, // Output index 0 from commit transaction - }, - script_sig: ScriptBuf::new(), // Empty for Taproot (uses witness instead) - sequence: Sequence::MAX, // No relative timelock constraints - witness: Witness::new(), // Will be populated with actual witness data - }; - - // Create output that sends remaining funds (minus fee) to destination. - // The inscription is now "bound" to these satoshis according to ordinal theory. - // In production: Ensure the fee is smaller than the output value to avoid - // underflow scenarios. - let output = TxOut { - value: Amount::from_sat(INSCRIPTION_OUTPUT_VALUE - fee), - script_pubkey: destination_address.script_pubkey(), - }; - - // Construct the transaction with witness data. - // Version 2 is standard for Taproot transactions. - let mut transaction = Transaction { - version: Version::TWO, - lock_time: LockTime::ZERO, // No absolute timelock - input: vec![input], - output: vec![output.clone()], - }; - - // Add placeholder witness stack for size calculation. - // The actual signature will replace the placeholder later. - // Witness stack order matters for script execution: - // - Stack bottom: control block (proves script validity) - // - Stack middle: reveal script (the code to execute) - // - Stack top: signature (satisfies OP_CHECKSIG in script) - transaction.input[0] - .witness - .push(SCHNORR_SIGNATURE_PLACEHOLDER); - transaction.input[0].witness.push(reveal_script.to_bytes()); - transaction.input[0].witness.push(control_block.serialize()); - - Ok(transaction) -} - -/// Builds the Ordinals reveal script that contains the inscription data. -/// -/// The reveal script follows the Ordinals protocol format: -/// - Starts with key verification (internal_key + OP_CHECKSIG) -/// - Contains inscription envelope (OP_FALSE OP_IF ... OP_ENDIF) -/// - The envelope ensures data is in the witness but never executed -pub fn build_ordinal_reveal_script( - internal_key: &XOnlyPublicKey, - inscription_payload: &PushBytesBuf, -) -> ScriptBuf { - Builder::new() - .push_slice(internal_key.serialize()) // 32-byte x-only key - .push_opcode(OP_CHECKSIG) // Verify signature for spending - .push_opcode(OP_FALSE) // Push false to skip inscription data - .push_opcode(OP_IF) // Begin inscription envelope - .push_slice(b"ord") // Ordinals protocol marker - .push_int(1) // Content type field number - .push_slice(b"text/plain") // MIME type of the inscription - .push_int(0) // Data push field number - .push_slice(inscription_payload) // The actual inscription content - .push_opcode(OP_ENDIF) // End inscription envelope - .into_script() -} - -/// Creates the witness stack for Taproot script-path spending. -/// -/// This function handles the complex process of: -/// 1. Computing the signature hash for script-path spending -/// 2. Signing with the appropriate private key -/// 3. Constructing the witness stack in the correct order -/// -/// The witness stack must be ordered correctly for script validation: -/// - Signature (top of stack, consumed by OP_CHECKSIG) -/// - Script (the code to execute) -/// - Control block (proves the script is part of the Taproot commitment) -pub async fn create_script_path_witness( - ctx: &BitcoinContext, - reveal_transaction: &mut Transaction, - commit_output: &TxOut, - reveal_script: &ScriptBuf, - control_block: &ControlBlock, - internal_key_path: Vec>, -) { - // Calculate the signature hash for Taproot script-path spending. - // This is different from key-path spending and commits to the specific script. - let mut sighash_cache = SighashCache::new(reveal_transaction); - let leaf_hash = TapLeafHash::from_script(reveal_script, LeafVersion::TapScript); - - // Compute the signature hash that we need to sign. - // This hash commits to: - // - The transaction being signed (amounts, outputs, etc.) - // - The specific script being executed - // - The input being spent - // This prevents signature reuse attacks and ensures the signature is only valid - // for this exact transaction and script. - let sighash = sighash_cache - .taproot_script_spend_signature_hash( - 0, // Input index we're signing - &Prevouts::All(&[commit_output.clone()]), // Previous outputs being spent - leaf_hash, // Hash of the script being executed - TapSighashType::Default, // Sign all inputs and outputs - ) - .unwrap() - .as_byte_array() - .to_vec(); - - // Sign the computed hash using our private key. - // This proves we have the authority to spend these funds according to - // the rules in our inscription script (which requires a valid signature). - let schnorr_signature_bytes = sign_with_schnorr( - ctx.key_name.to_string(), // Key identifier in the IC canister - internal_key_path, // Same derivation path as commit tx - None, // No merkle root for script-path signing - sighash, - ) - .await; - - // Wrap the raw signature with its sighash type. - // The sighash type tells verifiers what parts of the transaction this - // signature commits to (in our case, everything). - let taproot_script_signature = bitcoin::taproot::Signature { - signature: bitcoin::secp256k1::schnorr::Signature::from_slice(&schnorr_signature_bytes) - .unwrap(), - sighash_type: TapSighashType::Default, - }; - - // Construct the witness stack for script-path spending. - // The witness contains all data needed to validate the script execution: - // 1. Signature: Proves we can satisfy the script's OP_CHECKSIG - // 2. Script: The actual inscription script to execute - // 3. Control block: Proves this script is committed to by the address - // - // When validators process this transaction, they'll execute our script - // with the signature, revealing our inscription data in the process. - let witness_stack = sighash_cache.witness_mut(0).unwrap(); - witness_stack.clear(); - witness_stack.push(taproot_script_signature.to_vec()); - witness_stack.push(reveal_script.to_bytes()); - witness_stack.push(control_block.serialize()); -} diff --git a/rust/basic_bitcoin/src/runes.rs b/rust/basic_bitcoin/src/runes.rs deleted file mode 100644 index e203fceb70..0000000000 --- a/rust/basic_bitcoin/src/runes.rs +++ /dev/null @@ -1,232 +0,0 @@ -// This module implements Bitcoin Runes protocol functionality. -// Runes are a fungible token protocol built on Bitcoin that creates tokens -// using OP_RETURN outputs with OP_13 markers for on-chain metadata storage. - -use bitcoin::{ - opcodes::all::*, - script::{Builder, PushBytesBuf}, - ScriptBuf, -}; -use leb128::write; - -#[allow(dead_code)] -const MAX_DIVISIBILITY: u8 = 38; -#[allow(dead_code)] -const MAX_SPACERS: u32 = 0b00000111111111111111111111111111; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Tag { - #[allow(dead_code)] - Body = 0, - Flags = 2, - Rune = 4, - Premine = 6, - Cap = 8, - Amount = 10, - HeightStart = 12, - HeightEnd = 14, - OffsetStart = 16, - OffsetEnd = 18, - #[allow(dead_code)] - Mint = 20, - #[allow(dead_code)] - Pointer = 22, - #[allow(dead_code)] - Cenotaph = 126, - // Odd tags - Divisibility = 1, - Spacers = 3, - Symbol = 5, - #[allow(dead_code)] - Nop = 127, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Flag { - Etching = 0, - Terms = 1, - Turbo = 2, - #[allow(dead_code)] - Cenotaph = 127, -} - -impl Flag { - fn mask(self) -> u128 { - let position = match self { - Flag::Etching => 0, - Flag::Terms => 1, - Flag::Turbo => 2, - Flag::Cenotaph => 127, - }; - 1 << position - } -} - -/// Encodes a u128 as LEB128 (Little Endian Base 128). -/// -/// The Runes protocol uses LEB128 encoding for all integer values in the runestone -/// to create compact, variable-length representations that minimize transaction size. -pub fn encode_leb128(value: u64) -> Vec { - let mut buf = Vec::new(); - write::unsigned(&mut buf, value).unwrap(); - buf -} - -/// Encodes a rune name into its numeric representation. -/// -/// Runes use a modified base-26 encoding where A=0, B=1, ... Z=25. -/// Names are encoded with A as the least significant digit for compact storage. -pub fn encode_rune_name(name: &str) -> Result { - if name.is_empty() { - return Err("Rune name cannot be empty".to_string()); - } - - let mut value = 0u64; - for (i, ch) in name.chars().enumerate() { - if i >= 28 { - return Err("Rune name cannot exceed 28 characters".to_string()); - } - if !ch.is_ascii_uppercase() { - return Err("Rune name must contain only uppercase letters A-Z".to_string()); - } - - let digit = (ch as u8 - b'A') as u64; - if i == 0 { - value = digit; - } else { - // Multiply previous value by 26 and add new digit - value = value - .checked_add(1) - .and_then(|v| v.checked_mul(26)) - .and_then(|v| v.checked_add(digit)) - .ok_or("Rune name value overflow")?; - } - } - - Ok(value) -} - -/// Represents a rune etching configuration. -/// -/// An etching defines all the parameters for creating a new rune token, -/// including supply, divisibility, and minting terms. -pub struct Etching { - pub divisibility: u8, - pub premine: u128, - pub rune_name: String, - pub symbol: Option, - pub terms: Option, - pub turbo: bool, - pub spacers: u32, -} - -/// Defines the terms for open minting of rune tokens. -/// -/// Terms specify when and how much additional supply can be minted -/// after the initial etching through public mint operations. -pub struct Terms { - pub amount: Option, // Amount per mint - pub cap: Option, // Maximum number of mints - pub height: (Option, Option), // Absolute block height range - pub offset: (Option, Option), // Relative block height range -} - -/// Builds a runestone script for etching a new rune token. -/// -/// The runestone is encoded as an OP_RETURN output with the format: -/// OP_RETURN OP_13 [LEB128 encoded tag-value pairs...] -/// -/// All rune metadata is encoded as alternating tags and values using LEB128, -/// creating a compact binary representation of the token parameters. -pub fn build_etching_script(etching: &Etching) -> Result { - let mut payload = Vec::new(); - - // Encode rune name into numeric format for storage. - let encoded_name = encode_rune_name(&etching.rune_name)?; - - // Build flags bitmask to indicate which features are enabled. - let mut flags = Flag::Etching.mask(); // Mark this as an etching operation - if etching.terms.is_some() { - flags |= Flag::Terms.mask(); - } - if etching.turbo { - flags |= Flag::Turbo.mask(); - } - - // Tag 1: Divisibility (odd tag) - if etching.divisibility > 0 { - payload.extend_from_slice(&encode_leb128(Tag::Divisibility as u64)); - payload.extend_from_slice(&encode_leb128(etching.divisibility as u64)); - } - - // Tag 2: Flags - payload.extend_from_slice(&encode_leb128(Tag::Flags as u64)); - payload.extend_from_slice(&encode_leb128(flags as u64)); - - // Tag 3: Spacers (odd tag) - if etching.spacers > 0 { - payload.extend_from_slice(&encode_leb128(Tag::Spacers as u64)); - payload.extend_from_slice(&encode_leb128(etching.spacers as u64)); - } - - // Tag 4: Rune name - payload.extend_from_slice(&encode_leb128(Tag::Rune as u64)); - payload.extend_from_slice(&encode_leb128(encoded_name as u64)); - - // Tag 5: Symbol (odd tag) - if let Some(symbol) = etching.symbol { - payload.extend_from_slice(&encode_leb128(Tag::Symbol as u64)); - payload.extend_from_slice(&encode_leb128(symbol as u64)); - } - - // Tag 6: Premine - if etching.premine > 0 { - payload.extend_from_slice(&encode_leb128(Tag::Premine as u64)); - payload.extend_from_slice(&encode_leb128(etching.premine as u64)); - } - - // Add mint terms if present - if let Some(terms) = &etching.terms { - if let Some(amount) = terms.amount { - payload.extend_from_slice(&encode_leb128(Tag::Amount as u64)); - payload.extend_from_slice(&encode_leb128(amount as u64)); - } - if let Some(cap) = terms.cap { - payload.extend_from_slice(&encode_leb128(Tag::Cap as u64)); - payload.extend_from_slice(&encode_leb128(cap as u64)); - } - if let Some(start) = terms.height.0 { - payload.extend_from_slice(&encode_leb128(Tag::HeightStart as u64)); - payload.extend_from_slice(&encode_leb128(start)); - } - if let Some(end) = terms.height.1 { - payload.extend_from_slice(&encode_leb128(Tag::HeightEnd as u64)); - payload.extend_from_slice(&encode_leb128(end)); - } - if let Some(start) = terms.offset.0 { - payload.extend_from_slice(&encode_leb128(Tag::OffsetStart as u64)); - payload.extend_from_slice(&encode_leb128(start)); - } - if let Some(end) = terms.offset.1 { - payload.extend_from_slice(&encode_leb128(Tag::OffsetEnd as u64)); - payload.extend_from_slice(&encode_leb128(end)); - } - } - - // Build the OP_RETURN script - let mut builder = Builder::new().push_opcode(OP_RETURN); - - // Add OP_13 marker - builder = builder.push_opcode(OP_PUSHNUM_13); - - // Add the entire payload as a single data push. - // Critical: All runestone data must be in one push after OP_13, - // not split into multiple chunks, per the Runes protocol specification. - let mut push_bytes = PushBytesBuf::new(); - push_bytes - .extend_from_slice(&payload) - .map_err(|_| "Failed to create push bytes - payload may be too large")?; - builder = builder.push_slice(&push_bytes); - - Ok(builder.into_script()) -} diff --git a/rust/basic_bitcoin/src/service/etch_rune.rs b/rust/basic_bitcoin/src/service/etch_rune.rs deleted file mode 100644 index f2fdeb1bc9..0000000000 --- a/rust/basic_bitcoin/src/service/etch_rune.rs +++ /dev/null @@ -1,131 +0,0 @@ -// This module implements Bitcoin Runes token etching functionality. -// Runes are fungible tokens on Bitcoin that use OP_RETURN outputs -// with OP_13 markers to store token metadata directly on-chain. - -use crate::{ - common::{get_fee_per_byte, DerivationPath, PrimaryOutput}, - p2tr, - runes::{build_etching_script, Etching}, - schnorr::{get_schnorr_public_key, sign_with_schnorr}, - BTC_CONTEXT, -}; -use bitcoin::{ - consensus::serialize, - secp256k1::{PublicKey, Secp256k1}, - Address, XOnlyPublicKey, -}; -use ic_cdk::{trap, update}; -use ic_cdk_bitcoin_canister::{ - bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, -}; - -/// Creates a new Rune token on the Bitcoin blockchain. -/// -/// Runes work by embedding token metadata directly into Bitcoin transactions using -/// OP_RETURN outputs with OP_13 markers. Unlike Ordinals, which require a two-transaction -/// commit/reveal process, Runes are etched in a single transaction - the token is -/// created immediately when the transaction is confirmed. -/// -/// For simplicity, this implementation creates a basic rune with fixed parameters: -/// - No divisibility (whole units only, no decimal places) -/// - A premine of 1,000,000 units (all supply minted to the etcher) -/// - No open minting terms (supply is fixed at creation) -/// - Unicode coin symbol (🪙) for display purposes -/// -/// The rune metadata becomes permanently recorded in the Bitcoin blockchain -/// as part of the OP_RETURN output, creating a new fungible token. -#[update] -pub async fn etch_rune(name: String) -> String { - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - - // Validate rune name according to protocol rules. - // Runes use strict naming conventions for consistency. - if name.is_empty() { - trap("Rune name cannot be empty"); - } - - if name.len() > 28 { - trap("Rune name cannot exceed 28 characters"); - } - - if !name.chars().all(|c| c.is_ascii_uppercase()) { - trap("Rune name must contain only uppercase letters A-Z"); - } - - // Derive the internal key for our Taproot address. - // Since rune data goes in OP_RETURN (not script), we use simple key-path spending. - let internal_key_path = DerivationPath::p2tr(0, 0); - let internal_key = get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; - let internal_key = XOnlyPublicKey::from(PublicKey::from_slice(&internal_key).unwrap()); - - // Create our Taproot address for funding the rune etching. - // No script commitments needed since rune data goes in OP_RETURN output. - let secp256k1_engine = Secp256k1::new(); - let own_address = Address::p2tr(&secp256k1_engine, internal_key, None, ctx.bitcoin_network); - - // Query for available funds (UTXOs) to pay for the rune etching. - // We need existing bitcoin to cover transaction fees and any change. - let own_utxos = bitcoin_get_utxos(&GetUtxosRequest { - address: own_address.to_string(), - network: ctx.network.into(), - filter: None, - }) - .await - .unwrap() - .utxos; - - // Create the rune etching configuration with fixed parameters. - // This defines all the token properties that will be permanently recorded. - let etching = Etching { - divisibility: 0, // No decimal places (whole units only) - premine: 1_000_000, // Mint 1M units to the etcher (fixed supply) - rune_name: name.clone(), - symbol: Some('🪙'), // Unicode coin symbol for display - terms: None, // No open minting allowed - turbo: false, // Standard etching mode - spacers: 0, // No visual spacers in the name - }; - - // Build the runestone script containing the rune metadata. - // This creates the OP_RETURN output that defines the new token. - let runestone_script = build_etching_script(&etching) - .unwrap_or_else(|e| trap(format!("Failed to build runestone: {}", e))); - - // Build the rune etching transaction. - // The transaction includes an OP_RETURN output with the encoded runestone. - let fee_per_byte = get_fee_per_byte(&ctx).await; - let (transaction, prevouts) = p2tr::build_transaction( - &ctx, - &own_address, - &own_utxos, - p2tr::SelectUtxosMode::Single, - &PrimaryOutput::OpReturn(runestone_script), - fee_per_byte, - ) - .await; - - // Sign the rune etching transaction using key-path spending. - // Simple signature since we're not using any script commitments. - let signed_transaction = p2tr::sign_transaction_key_spend( - &ctx, - &own_address, - transaction, - prevouts.as_slice(), - internal_key_path.to_vec_u8_path(), - vec![], - sign_with_schnorr, - ) - .await; - - // Broadcast the transaction to the Bitcoin network. - // Once confirmed, the rune is permanently etched and the tokens are minted. - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&signed_transaction), - }) - .await - .unwrap(); - - // Return the transaction ID so users can track their rune etching. - signed_transaction.compute_txid().to_string() -} diff --git a/rust/basic_bitcoin/src/service/inscribe_brc20.rs b/rust/basic_bitcoin/src/service/inscribe_brc20.rs deleted file mode 100644 index ea3e26999b..0000000000 --- a/rust/basic_bitcoin/src/service/inscribe_brc20.rs +++ /dev/null @@ -1,208 +0,0 @@ -// This module implements BRC-20 token deployment inscription functionality. -// BRC-20 is a fungible token standard built on top of Bitcoin Ordinals that uses -// structured JSON payloads to represent tokens on the Bitcoin blockchain. - -use crate::{ - brc20::build_brc20_reveal_script, - common::{get_fee_per_byte, DerivationPath, PrimaryOutput}, - ordinals::{build_reveal_transaction, create_script_path_witness, INSCRIPTION_OUTPUT_VALUE}, - p2tr::{self}, - schnorr::{get_schnorr_public_key, sign_with_schnorr}, - BTC_CONTEXT, -}; -use bitcoin::{ - consensus::serialize, - secp256k1::{PublicKey, Secp256k1}, - taproot::{LeafVersion, TaprootBuilder}, - Address, XOnlyPublicKey, -}; -use ic_cdk::{trap, update}; -use ic_cdk_bitcoin_canister::{ - bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, -}; - -/// Creates a BRC-20 token deployment inscription on the Bitcoin blockchain. -/// -/// BRC-20 tokens work by embedding structured JSON data in a witness script that's -/// revealed when spent, similar to regular Ordinals but with a standardized token format. -/// This requires the same two-transaction process as Ordinals: -/// 1. Commit transaction: Sends sats to a Taproot address that commits to the BRC-20 script -/// 2. Reveal transaction: Spends those sats, revealing the BRC-20 JSON data in the witness -/// -/// For simplicity, this implementation deploys tokens with fixed parameters: -/// - Maximum supply: 21,000,000 tokens -/// - Mint limit: 1,000 tokens per mint operation -/// -/// The BRC-20 JSON becomes permanently associated with those specific satoshis, -/// creating the first inscription for that ticker symbol according to BRC-20 rules. -/// In BRC-20, the first deployment of a ticker symbol is considered the canonical one. -#[update] -pub async fn inscribe_brc20(tick: String) -> String { - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - - if tick.is_empty() { - trap("BRC-20 ticker cannot be empty"); - } - - if tick.len() != 4 { - trap("BRC-20 ticker must be exactly 4 characters"); - } - - // Derive the internal key for our Taproot address. - // In Taproot, every address has an "internal key" that can be used for key-path spending - // (direct signature) or combined with scripts for script-path spending. - // We'll use the same key for both the commit and reveal transactions. - let internal_key_path = DerivationPath::p2tr(0, 0); - let internal_key = get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; - let internal_key = XOnlyPublicKey::from(PublicKey::from_slice(&internal_key).unwrap()); - - // Convert ticker to uppercase as per BRC-20 convention and create the deployment JSON. - // BRC-20 uses a specific JSON structure for token operations: - // - "p": Protocol identifier (always "brc-20") - // - "op": Operation type ("deploy", "mint", or "transfer") - // - "tick": 4-character ticker symbol - // - "max": Maximum supply for deploy operations - // - "lim": Mint limit per operation for deploy operations - let tick = tick.to_uppercase(); - let brc20_json = format!( - r#"{{"p":"brc-20","op":"deploy","tick":"{}","max":"21000000","lim":"1000"}}"#, - tick - ); - - // Build the BRC-20 reveal script according to the Ordinals protocol. - // This script has two execution paths: - // 1. Normal path: Verify signature against internal_key (for spending) - // 2. BRC-20 path: Never executes (inside OP_FALSE OP_IF), but stores our JSON data - // - // The inscription envelope (OP_FALSE OP_IF ... OP_ENDIF) ensures the BRC-20 - // JSON data is included in the witness but never actually executed, preventing errors - // while still making the token data permanently part of the blockchain. - let reveal_script = build_brc20_reveal_script(&internal_key, &brc20_json); - - // Create the Taproot commitment that includes our BRC-20 script. - // Taproot addresses can commit to multiple spending conditions in a Merkle tree. - // When spending via script path, only the used script needs to be revealed, - // keeping other scripts private. Here we have just one script (the BRC-20 deployment). - let secp256k1_engine = Secp256k1::new(); - let taproot_spend_info = TaprootBuilder::new() - .add_leaf(0, reveal_script.clone()) // Add BRC-20 script at depth 0 - .unwrap() - .finalize(&secp256k1_engine, internal_key) // Compute the final tweaked key - .unwrap(); - - // Create the commit address from our Taproot commitment. - // This address secretly commits to our BRC-20 script - no one can tell - // it contains a token deployment just by looking at the address. - let commit_address = - Address::p2tr_tweaked(taproot_spend_info.output_key(), ctx.bitcoin_network); - - // Create a simple key-path-only address for funding. - // We need existing funds to pay for the BRC-20 deployment. This address uses the same - // internal key but without any script commitments, making it cheaper to spend from. - let funding_key = get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; - let funding_key = XOnlyPublicKey::from(PublicKey::from_slice(&funding_key).unwrap()); - let funding_address = Address::p2tr(&secp256k1_engine, funding_key, None, ctx.bitcoin_network); - - // Query for available funds (UTXOs) at our funding address. - // UTXOs (Unspent Transaction Outputs) are like "coins" in Bitcoin - - // each represents some amount of bitcoin that hasn't been spent yet. - let own_utxos = bitcoin_get_utxos(&GetUtxosRequest { - address: funding_address.to_string(), - network: ctx.network.into(), - filter: None, - }) - .await - .unwrap() - .utxos; - - // Build the commit transaction. - // This transaction sends funds to the commit address, "committing" to - // the BRC-20 deployment without revealing it yet. The token data remains - // hidden until we spend these funds in the reveal transaction. - let fee_per_byte = get_fee_per_byte(&ctx).await; - let (transaction, prevouts) = p2tr::build_transaction( - &ctx, - &funding_address, - &own_utxos, - p2tr::SelectUtxosMode::Single, // A BRC-20 token needs to be tied to a single UTXO - &PrimaryOutput::Address(commit_address, INSCRIPTION_OUTPUT_VALUE), - fee_per_byte, - ) - .await; - - // Sign the commit transaction using key-path spending. - // Since we're spending from a simple Taproot address (no scripts), - // we can use the more efficient key-path spend with just a signature. - let signed_transaction = p2tr::sign_transaction_key_spend( - &ctx, - &funding_address, - transaction, - prevouts.as_slice(), - internal_key_path.to_vec_u8_path(), - vec![], // No additional script data needed for key-path spend - sign_with_schnorr, - ) - .await; - - // Broadcast the commit transaction to the Bitcoin network. - // Once confirmed, our funds will be locked at the commit address. - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&signed_transaction), - }) - .await - .unwrap(); - - // --- Begin Reveal Transaction --- - // Now we build the transaction that spends the committed funds and reveals - // the BRC-20 token deployment. This is where the token data becomes visible on-chain. - - // Get the control block - this proves our script is part of the Taproot commitment. - // The control block contains the Merkle proof showing our script's position - // in the Taproot tree, allowing verifiers to confirm the script is valid. - let control_block = taproot_spend_info - .control_block(&(reveal_script.clone(), LeafVersion::TapScript)) - .unwrap(); - - // Build the reveal transaction structure. - // This transaction spends the output we just created in the commit transaction, - // revealing the BRC-20 script in the process. - let mut reveal_transaction = build_reveal_transaction( - &funding_address, // Where to send remaining funds after BRC-20 deployment - &reveal_script, - &control_block, - &signed_transaction.compute_txid(), - fee_per_byte, - ) - .await; - - // Create the script-path witness for the reveal transaction. - // This involves calculating the signature hash, signing it, and constructing - // the witness stack with the signature, script, and control block. - let commit_output = signed_transaction.output[0].clone(); - create_script_path_witness( - &ctx, - &mut reveal_transaction, - &commit_output, - &reveal_script, - &control_block, - internal_key_path.to_vec_u8_path(), - ) - .await; - - // Broadcast the reveal transaction. - // Once confirmed, the BRC-20 token is permanently deployed on Bitcoin. - // The token JSON data is now associated with the satoshis that were - // sent to the funding address, creating the canonical deployment for this ticker. - // According to BRC-20 rules, this becomes the authoritative token definition - // if it's the first deployment inscription for this ticker symbol. - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&reveal_transaction), - }) - .await - .unwrap(); - - // Return the reveal transaction ID so users can track their BRC-20 deployment - reveal_transaction.compute_txid().to_string() -} diff --git a/rust/basic_bitcoin/src/service/inscribe_ordinal.rs b/rust/basic_bitcoin/src/service/inscribe_ordinal.rs deleted file mode 100644 index 3b4c372442..0000000000 --- a/rust/basic_bitcoin/src/service/inscribe_ordinal.rs +++ /dev/null @@ -1,193 +0,0 @@ -// This module implements Bitcoin Ordinals inscription functionality. -// Ordinals allow arbitrary data to be inscribed on individual satoshis, -// creating unique digital artifacts on the Bitcoin blockchain. - -use crate::{ - common::{get_fee_per_byte, DerivationPath, PrimaryOutput}, - ordinals::{ - build_ordinal_reveal_script, build_reveal_transaction, create_script_path_witness, - INSCRIPTION_OUTPUT_VALUE, - }, - p2tr::{self}, - schnorr::{get_schnorr_public_key, sign_with_schnorr}, - BTC_CONTEXT, -}; -use bitcoin::{ - consensus::serialize, - script::PushBytesBuf, - secp256k1::{PublicKey, Secp256k1}, - taproot::{LeafVersion, TaprootBuilder}, - Address, XOnlyPublicKey, -}; -use ic_cdk::{trap, update}; -use ic_cdk_bitcoin_canister::{ - bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, -}; - -/// Creates an Ordinal inscription on the Bitcoin blockchain. -/// -/// Ordinals work by embedding data in a witness script that's revealed when spent. -/// This requires a two-transaction process: -/// 1. Commit transaction: Sends sats to a Taproot address that commits to the inscription script -/// 2. Reveal transaction: Spends those sats, revealing the inscription data in the witness -/// -/// The inscription becomes permanently associated with those specific satoshis, -/// which can then be tracked and traded as unique digital artifacts. -#[update] -pub async fn inscribe_ordinal(text: String) -> String { - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - - if text.is_empty() { - trap("Inscription text cannot be empty"); - } - - // Derive the internal key for our Taproot address. - // In Taproot, every address has an "internal key" that can be used for key-path spending - // (direct signature) or combined with scripts for script-path spending. - // We'll use the same key for both the commit and reveal transactions. - let internal_key_path = DerivationPath::p2tr(0, 0); - let internal_key = get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; - let internal_key = XOnlyPublicKey::from(PublicKey::from_slice(&internal_key).unwrap()); - - // Convert the inscription text to bytes for embedding in the script. - // Bitcoin scripts work with raw bytes, not strings, so we need this conversion. - let mut inscription_payload = PushBytesBuf::new(); - inscription_payload - .extend_from_slice(text.as_bytes()) - .unwrap(); - - // Build the inscription reveal script according to the Ordinals protocol. - // This script has two execution paths: - // 1. Normal path: Verify signature against internal_key (for spending) - // 2. Inscription path: Never executes (inside OP_FALSE OP_IF), but stores our data - // - // The inscription envelope (OP_FALSE OP_IF ... OP_ENDIF) ensures the inscription - // data is included in the witness but never actually executed, preventing errors - // while still making the data permanently part of the blockchain. - let reveal_script = build_ordinal_reveal_script(&internal_key, &inscription_payload); - - // Create the Taproot commitment that includes our inscription script. - // Taproot addresses can commit to multiple spending conditions in a Merkle tree. - // When spending via script path, only the used script needs to be revealed, - // keeping other scripts private. Here we have just one script (the inscription). - let secp256k1_engine = Secp256k1::new(); - let taproot_spend_info = TaprootBuilder::new() - .add_leaf(0, reveal_script.clone()) // Add inscription script at depth 0 - .unwrap() - .finalize(&secp256k1_engine, internal_key) // Compute the final tweaked key - .unwrap(); - - // Create the commit address from our Taproot commitment. - // This address secretly commits to our inscription script - no one can tell - // it contains an inscription just by looking at the address. - let commit_address = - Address::p2tr_tweaked(taproot_spend_info.output_key(), ctx.bitcoin_network); - - // Create a simple key-path-only address for funding. - // We need existing funds to pay for the inscription. This address uses the same - // internal key but without any script commitments, making it cheaper to spend from. - let funding_key = get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; - let funding_key = XOnlyPublicKey::from(PublicKey::from_slice(&funding_key).unwrap()); - let funding_address = Address::p2tr(&secp256k1_engine, funding_key, None, ctx.bitcoin_network); - - // Query for available funds (UTXOs) at our funding address. - // UTXOs (Unspent Transaction Outputs) are like "coins" in Bitcoin - - // each represents some amount of bitcoin that hasn't been spent yet. - let own_utxos = bitcoin_get_utxos(&GetUtxosRequest { - address: funding_address.to_string(), - network: ctx.network.into(), - filter: None, - }) - .await - .unwrap() - .utxos; - - // Build the commit transaction. - // This transaction sends funds to the commit address, "committing" to - // the inscription without revealing it yet. The inscription data remains - // hidden until we spend these funds in the reveal transaction. - let fee_per_byte = get_fee_per_byte(&ctx).await; - let (transaction, prevouts) = p2tr::build_transaction( - &ctx, - &funding_address, - &own_utxos, - p2tr::SelectUtxosMode::Single, // An inscription needs to be tied to a single UTXO - &PrimaryOutput::Address(commit_address, INSCRIPTION_OUTPUT_VALUE), - fee_per_byte, - ) - .await; - - // Sign the commit transaction using key-path spending. - // Since we're spending from a simple Taproot address (no scripts), - // we can use the more efficient key-path spend with just a signature. - let signed_transaction = p2tr::sign_transaction_key_spend( - &ctx, - &funding_address, - transaction, - prevouts.as_slice(), - internal_key_path.to_vec_u8_path(), - vec![], // No additional script data needed for key-path spend - sign_with_schnorr, - ) - .await; - - // Broadcast the commit transaction to the Bitcoin network. - // Once confirmed, our funds will be locked at the commit address. - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&signed_transaction), - }) - .await - .unwrap(); - - // --- Begin Reveal Transaction --- - // Now we build the transaction that spends the committed funds and reveals - // the inscription. This is where the inscription data becomes visible on-chain. - - // Get the control block - this proves our script is part of the Taproot commitment. - // The control block contains the Merkle proof showing our script's position - // in the Taproot tree, allowing verifiers to confirm the script is valid. - let control_block = taproot_spend_info - .control_block(&(reveal_script.clone(), LeafVersion::TapScript)) - .unwrap(); - - // Build the reveal transaction structure. - // This transaction spends the output we just created in the commit transaction, - // revealing the inscription script in the process. - let mut reveal_transaction = build_reveal_transaction( - &funding_address, // Where to send remaining funds after inscription - &reveal_script, - &control_block, - &signed_transaction.compute_txid(), - fee_per_byte, - ) - .await; - - // Create the script-path witness for the reveal transaction. - // This involves calculating the signature hash, signing it, and constructing - // the witness stack with the signature, script, and control block. - let commit_output = signed_transaction.output[0].clone(); - create_script_path_witness( - &ctx, - &mut reveal_transaction, - &commit_output, - &reveal_script, - &control_block, - internal_key_path.to_vec_u8_path(), - ) - .await; - - // Broadcast the reveal transaction. - // Once confirmed, the inscription is permanently recorded on Bitcoin. - // The inscription data is now associated with the satoshis that were - // sent to the funding address, creating a unique digital artifact. - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&reveal_transaction), - }) - .await - .unwrap(); - - // Return the reveal transaction ID so users can track their inscription - reveal_transaction.compute_txid().to_string() -} diff --git a/rust/basic_bitcoin/test.sh b/rust/basic_bitcoin/test.sh new file mode 100755 index 0000000000..d4d958ea7c --- /dev/null +++ b/rust/basic_bitcoin/test.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -e + +IMAGE_NAME=icp-cli-network-launcher-bitcoin +# Find the running container built from our custom image +BITCOIN_CONTAINER=$(docker ps --filter "ancestor=${IMAGE_NAME}" --format "{{.ID}}" | head -1) + +echo "=== Test 1: get_p2pkh_address returns a valid Bitcoin address ===" +result=$(icp canister call backend get_p2pkh_address '()') && \ + echo "$result" && \ + echo "$result" | grep -q '"' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 2: get_p2wpkh_address returns a valid Bitcoin address ===" +result=$(icp canister call backend get_p2wpkh_address '()') && \ + echo "$result" && \ + echo "$result" | grep -q '"' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 3: get_p2tr_key_path_only_address returns a valid Bitcoin address ===" +result=$(icp canister call backend get_p2tr_key_path_only_address '()') && \ + echo "$result" && \ + echo "$result" | grep -q '"' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 4: get_p2tr_script_path_enabled_address returns a valid Bitcoin address ===" +result=$(icp canister call backend get_p2tr_script_path_enabled_address '()') && \ + echo "$result" && \ + echo "$result" | grep -q '"' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 5: get_current_fee_percentiles returns a vec ===" +result=$(icp canister call backend get_current_fee_percentiles '()') && \ + echo "$result" && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Mining 101 blocks to fund test address ===" +[ -n "$BITCOIN_CONTAINER" ] || (echo "ERROR: network launcher container not running — run 'icp network start -d' first" && exit 1) +addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ + docker exec "$BITCOIN_CONTAINER" bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 101 "$addr" > /dev/null && \ + echo "mined 101 blocks to $addr" + +echo "=== Waiting for IC to sync Bitcoin blocks ===" +_sync_addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') +# Poll get_utxos until it succeeds AND reports a non-zero tip_height. +# Using get_utxos (not get_balance) because it reflects the definitive sync state: +# get_balance can return stale non-zero values from previous runs while the bitcoin +# integration canister is still syncing new blocks and rejecting all fresh calls. +# The || { sleep; continue; } pattern retries on both rejection errors and zero height. +_synced=false +for _i in $(seq 1 60); do + _result=$(icp canister call backend get_utxos "(\"$_sync_addr\")" 2>/dev/null) \ + || { sleep 1; continue; } + echo "$_result" | grep -qE 'tip_height = [1-9][0-9]*' \ + || { sleep 1; continue; } + echo "IC synced after ${_i}s" + _synced=true + break +done +[ "$_synced" = true ] || { echo "FAIL: IC did not sync within 60s"; exit 1; } + +echo "=== Test 6: get_balance returns non-zero after mining ===" +addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ + result=$(icp canister call backend get_balance "(\"$addr\")") && \ + echo "$result" && \ + echo "$result" | grep -qE '^\([1-9]' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 7: get_utxos returns synced chain state after mining ===" +addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ + result=$(icp canister call backend get_utxos "(\"$addr\")") && \ + echo "$result" && \ + echo "$result" | grep -qE 'tip_height = [1-9][0-9]*' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 8: get_blockchain_info returns tip_height ===" +result=$(icp canister call backend get_blockchain_info '()') && \ + echo "$result" && \ + echo "$result" | grep -q 'height' && \ + echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 9: get_block_headers returns headers ===" +result=$(icp canister call backend get_block_headers '(0: nat32, null)') && \ + echo "$result" && \ + echo "$result" | grep -q 'tip_height' && \ + echo "PASS" || (echo "FAIL" && exit 1)