From 1c0f53dd8e390b07823b192aa79487e7d3c3d810 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 16 Jun 2026 19:50:00 +0200 Subject: [PATCH 01/33] chore: migrate rust/basic_bitcoin to icp-cli - Replace dfx.json with icp.yaml (Bitcoin integration via custom Docker image bundling bitcoind, matching motoko/basic_bitcoin pattern) - Move Rust source from src/ into backend/ subdirectory - Replace root Cargo.toml with workspace + backend/Cargo.toml (name=backend) - Copy Dockerfile and docker/start.sh from motoko/basic_bitcoin - Rewrite Makefile with icp-cli commands and 9 numbered tests - Update README with icp-cli instructions, preserve all domain content - Add rust-basic_bitcoin CI job to .github/workflows/basic_bitcoin.yml - Delete dfx.json, build.sh, basic_bitcoin.did, old src/ Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/basic_bitcoin.yml | 34 ++++ rust/basic_bitcoin/Cargo.lock | 2 +- rust/basic_bitcoin/Cargo.toml | 23 +-- rust/basic_bitcoin/Dockerfile | 32 +++ rust/basic_bitcoin/Makefile | 118 +++++++---- rust/basic_bitcoin/README.md | 184 +++++++----------- rust/basic_bitcoin/backend/Cargo.toml | 18 ++ rust/basic_bitcoin/{ => backend}/src/brc20.rs | 0 .../basic_bitcoin/{ => backend}/src/common.rs | 0 rust/basic_bitcoin/{ => backend}/src/ecdsa.rs | 0 rust/basic_bitcoin/{ => backend}/src/lib.rs | 0 .../{ => backend}/src/ordinals.rs | 0 rust/basic_bitcoin/{ => backend}/src/p2pkh.rs | 0 rust/basic_bitcoin/{ => backend}/src/p2tr.rs | 0 .../basic_bitcoin/{ => backend}/src/p2wpkh.rs | 0 rust/basic_bitcoin/{ => backend}/src/runes.rs | 0 .../{ => backend}/src/schnorr.rs | 0 .../{ => backend}/src/service.rs | 0 .../{ => backend}/src/service/etch_rune.rs | 0 .../{ => backend}/src/service/get_balance.rs | 0 .../src/service/get_block_headers.rs | 0 .../src/service/get_blockchain_info.rs | 0 .../service/get_current_fee_percentiles.rs | 0 .../src/service/get_p2pkh_address.rs | 0 .../service/get_p2tr_key_path_only_address.rs | 0 .../get_p2tr_script_path_enabled_address.rs | 0 .../src/service/get_p2wpkh_address.rs | 0 .../{ => backend}/src/service/get_utxos.rs | 0 .../src/service/inscribe_brc20.rs | 0 .../src/service/inscribe_ordinal.rs | 0 .../src/service/send_from_p2pkh_address.rs | 0 .../send_from_p2tr_key_path_only_address.rs | 0 ...r_script_path_enabled_address_key_spend.rs | 0 ...cript_path_enabled_address_script_spend.rs | 0 .../src/service/send_from_p2wpkh_address.rs | 0 rust/basic_bitcoin/basic_bitcoin.did | 110 ----------- rust/basic_bitcoin/build.sh | 17 -- rust/basic_bitcoin/dfx.json | 32 --- rust/basic_bitcoin/docker/start.sh | 30 +++ rust/basic_bitcoin/icp.yaml | 27 +++ 40 files changed, 294 insertions(+), 333 deletions(-) create mode 100644 rust/basic_bitcoin/Dockerfile create mode 100644 rust/basic_bitcoin/backend/Cargo.toml rename rust/basic_bitcoin/{ => backend}/src/brc20.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/common.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/ecdsa.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/lib.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/ordinals.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/p2pkh.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/p2tr.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/p2wpkh.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/runes.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/schnorr.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/etch_rune.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/get_balance.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/get_block_headers.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/get_blockchain_info.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/get_current_fee_percentiles.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/get_p2pkh_address.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/get_p2tr_key_path_only_address.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/get_p2tr_script_path_enabled_address.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/get_p2wpkh_address.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/get_utxos.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/inscribe_brc20.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/inscribe_ordinal.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/send_from_p2pkh_address.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/send_from_p2tr_key_path_only_address.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/send_from_p2tr_script_path_enabled_address_key_spend.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/send_from_p2tr_script_path_enabled_address_script_spend.rs (100%) rename rust/basic_bitcoin/{ => backend}/src/service/send_from_p2wpkh_address.rs (100%) delete mode 100644 rust/basic_bitcoin/basic_bitcoin.did delete mode 100755 rust/basic_bitcoin/build.sh delete mode 100644 rust/basic_bitcoin/dfx.json create mode 100644 rust/basic_bitcoin/docker/start.sh create mode 100644 rust/basic_bitcoin/icp.yaml diff --git a/.github/workflows/basic_bitcoin.yml b/.github/workflows/basic_bitcoin.yml index 4733e1fa08..3f43fab4da 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,36 @@ jobs: icp network start -d icp deploy --cycles 30t make test + + 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: 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 + make test diff --git a/rust/basic_bitcoin/Cargo.lock b/rust/basic_bitcoin/Cargo.lock index 4c20c411bd..d4bea5ad2c 100644 --- a/rust/basic_bitcoin/Cargo.lock +++ b/rust/basic_bitcoin/Cargo.lock @@ -37,7 +37,7 @@ dependencies = [ ] [[package]] -name = "basic_bitcoin" +name = "backend" version = "0.1.0" dependencies = [ "bitcoin", 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 index da0e4d3a86..99a65f4402 100644 --- a/rust/basic_bitcoin/Makefile +++ b/rust/basic_bitcoin/Makefile @@ -1,42 +1,80 @@ -.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`. +IMAGE_NAME = icp-cli-network-launcher-bitcoin +# Find the running container built from our custom image +BITCOIN_CONTAINER = $(shell docker ps --filter "ancestor=$(IMAGE_NAME)" --format "{{.ID}}" | head -1) + +.PHONY: build-image test topup + +build-image: + # Because we're building off of :latest, use --pull to fetch the latest image. + # Remove `--pull` if the Dockerfile is updated to pin the base image version. + docker build --pull -t $(IMAGE_NAME) . + +topup: + icp canister top-up --amount 30t backend + test: + @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 ===" + @sleep 5 + + @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 -q 'tip_height = 101' && \ + 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) -.PHONY: clean -.SILENT: clean -clean: - rm -rf .dfx - rm -rf dist - rm -rf node_modules - rm -rf src/declarations - rm -f .env - cargo clean + @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) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index eb22617a98..b35f90f197 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -4,31 +4,7 @@ This example demonstrates how to deploy a smart contract on the Internet Compute 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) +See also the [Motoko version](../../motoko/basic_bitcoin). ## Architecture @@ -40,61 +16,41 @@ This example integrates with the Internet Computer's built-in: For background on the ICP<>BTC integration, refer to the [Learn Hub](https://learn.internetcomputer.org/hc/en-us/articles/34211154520084-Bitcoin-Integration). +## Build and deploy from the command line -## Deploying from ICP Ninja +### Prerequisites -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. +- [Rust toolchain](https://www.rust-lang.org/tools/install) +- icp-cli: `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` +- Docker (required to run the custom network launcher image that bundles bitcoind) +- 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`. -[![](https://icp.ninja/assets/open.svg)](https://icp.ninja/editor?g=https://github.com/dfinity/examples/tree/master/rust/basic_bitcoin) - -## Building and deploying the smart contract locally - -### 1. Prerequisites - -* [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`. - - -### 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 - - -Open a terminal window (terminal 1) and run the following: -```bash -dfx start --enable-bitcoin --bitcoin-node 127.0.0.1:18444 -``` -This starts a local canister execution environment with Bitcoin support enabled. - -### 4. Start Bitcoin regtest +### Build the network launcher image -Open another terminal window (terminal 2) and run the following to start the local Bitcoin regtest network: +The local network bundles bitcoind inside a custom Docker image. Build it once before starting the network: ```bash -bitcoind -conf=$(pwd)/bitcoin.conf -datadir=$(pwd)/bitcoin_data --port=18444 +make build-image ``` -### 5. Deploy the smart contract - -Open a third terminal (terminal 3) and run the following to deploy the smart contract: +### Deploy and test ```bash -dfx deploy basic_bitcoin --argument '(variant { regtest })' +icp network start -d +icp deploy --cycles 30t +make test +icp network stop ``` -What this does: +> If tests fail with an out-of-cycles error, run `make topup` to add 30 trillion cycles to the backend canister and retry. -- `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. - -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,32 +59,42 @@ 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 -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. +Use `bitcoin-cli` inside the running network container to mine blocks and send the block reward to a smart contract address: + ```bash -bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 1 +# Get the container ID of the running network launcher +CONTAINER=$(docker ps --filter "ancestor=icp-cli-network-launcher-bitcoin" --format "{{.ID}}" | head -1) + +# Get an address from the canister +ADDR=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') + +# Mine 1 block to that address +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" ``` ## Checking balance Check the balance of any Bitcoin address: + ```bash -dfx canister call basic_bitcoin get_balance '("")' +icp canister call backend get_balance '("")' ``` This uses `bitcoin_get_balance` and works for any supported address type. The balance requires at least one confirmation to be reflected. + ## Sending bitcoin You can send bitcoin using the following endpoints: -Endpoints: - `send_from_p2pkh_address` - `send_from_p2wpkh_address` @@ -147,18 +113,13 @@ Each endpoint internally: Example: ```bash -dfx canister call basic_bitcoin send_from_p2pkh_address '(record { +icp canister call backend send_from_p2pkh_address '(record { destination_address = "bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt"; amount_in_satoshi = 4321; })' ``` -> [!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 -> ``` +> **Important:** Newly mined bitcoin cannot be spent until 100 additional blocks have been added to the chain. To make your bitcoin spendable, create 100 additional blocks with any valid address as the recipient. 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/). @@ -167,7 +128,7 @@ The function returns the transaction ID. When interacting with the contract depl You can query the current state of the Bitcoin blockchain: ```bash -dfx canister call basic_bitcoin get_blockchain_info +icp canister call backend get_blockchain_info '()' ``` 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. @@ -177,12 +138,13 @@ This calls `get_blockchain_info` on the Bitcoin canister and returns the tip hei You can query historical block headers: ```bash -dfx canister call basic_bitcoin get_block_headers '(10: nat32, null)' +icp canister call backend get_block_headers '(10: nat32, null)' # or a range: -dfx canister call basic_bitcoin get_block_headers '(10: nat32, opt (11: nat32))' +icp canister call backend get_block_headers '(10: nat32, opt (11: nat32))' ``` 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: @@ -203,11 +165,9 @@ brew install ord 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). +> **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`: +> **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 @@ -226,22 +186,26 @@ For other platforms, see the [ord repository](https://github.com/ordinals/ord) f 2. **Get a Taproot address** for funding the inscription: ```bash - dfx canister call basic_bitcoin get_p2tr_key_path_only_address '()' + icp canister call backend get_p2tr_key_path_only_address '()' ``` -3. **Fund the address** with sufficient bitcoin (100 blocks ensures spendability): +3. **Fund the address** with sufficient bitcoin (101 blocks ensures spendability): ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 100 + docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 101 ``` 4. **Create the inscription** with your desired text: ```bash - dfx canister call basic_bitcoin inscribe_ordinal '("Hello Bitcoin")' + icp canister call backend inscribe_ordinal '("Hello Bitcoin")' ``` 5. **Mine a block** to confirm the transactions: ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 1 + docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + 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/`. @@ -259,22 +223,26 @@ The function returns the reveal transaction ID. Your inscription is now permanen 2. **Get a Taproot address** for the Rune etching: ```bash - dfx canister call basic_bitcoin get_p2tr_key_path_only_address '()' + icp canister call backend get_p2tr_key_path_only_address '()' ``` 3. **Fund the address** with bitcoin to pay for the etching: ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 100 + docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 101 ``` 4. **Etch the Rune** with an uppercase name (maximum 28 characters): ```bash - dfx canister call basic_bitcoin etch_rune '("ICPRUNE")' + icp canister call backend etch_rune '("ICPRUNE")' ``` 5. **Mine a block** to confirm the etching: ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 1 + docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 ``` 6. **Decode the Runestone** to verify the etching: @@ -282,7 +250,7 @@ The function returns the reveal transaction ID. Your inscription is now permanen ord --config-dir . decode --txid ``` -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. +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. ## Deploy a BRC-20 token @@ -295,30 +263,24 @@ The Rune is now etched with 1_000_000 tokens minted to your address. The tokens ord --config-dir . server ``` -2. **Get a Taproot address** for the token deployment: - ```bash - dfx canister call basic_bitcoin get_p2tr_key_path_only_address '()' - ``` - -3. **Fund the address** with bitcoin: - ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 100 - ``` +2. **Get a Taproot address** and fund it with bitcoin. -4. **Deploy the BRC-20 token** with a 4-character ticker: +3. **Deploy the BRC-20 token** with a 4-character ticker: ```bash - dfx canister call basic_bitcoin inscribe_brc20 '("DEMO")' + icp canister call backend inscribe_brc20 '("DEMO")' ``` -5. **Mine a block** to confirm the deployment: +4. **Mine a block** to confirm the deployment: ```bash - bitcoin-cli -conf=$(pwd)/bitcoin.conf generatetoaddress 1 + docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 ``` This creates a BRC-20 token with: - Ticker: "DEMO" -- Max supply: 21_000_000 tokens -- Mint limit: 1_000 tokens per mint +- Max supply: 21,000,000 tokens +- Mint limit: 1,000 tokens per mint 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. @@ -338,13 +300,9 @@ This example implements several important patterns for Bitcoin integration: 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 make a smart contract have a decentralized controller, since decentralized control may be essential for smart contracts 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..505af7a408 --- /dev/null +++ b/rust/basic_bitcoin/backend/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "backend" +version = "0.1.0" +edition = "2021" + +[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" diff --git a/rust/basic_bitcoin/src/brc20.rs b/rust/basic_bitcoin/backend/src/brc20.rs similarity index 100% rename from rust/basic_bitcoin/src/brc20.rs rename to rust/basic_bitcoin/backend/src/brc20.rs diff --git a/rust/basic_bitcoin/src/common.rs b/rust/basic_bitcoin/backend/src/common.rs similarity index 100% rename from rust/basic_bitcoin/src/common.rs rename to rust/basic_bitcoin/backend/src/common.rs 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 100% rename from rust/basic_bitcoin/src/lib.rs rename to rust/basic_bitcoin/backend/src/lib.rs diff --git a/rust/basic_bitcoin/src/ordinals.rs b/rust/basic_bitcoin/backend/src/ordinals.rs similarity index 100% rename from rust/basic_bitcoin/src/ordinals.rs rename to rust/basic_bitcoin/backend/src/ordinals.rs diff --git a/rust/basic_bitcoin/src/p2pkh.rs b/rust/basic_bitcoin/backend/src/p2pkh.rs similarity index 100% rename from rust/basic_bitcoin/src/p2pkh.rs rename to rust/basic_bitcoin/backend/src/p2pkh.rs diff --git a/rust/basic_bitcoin/src/p2tr.rs b/rust/basic_bitcoin/backend/src/p2tr.rs similarity index 100% rename from rust/basic_bitcoin/src/p2tr.rs rename to rust/basic_bitcoin/backend/src/p2tr.rs 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/runes.rs b/rust/basic_bitcoin/backend/src/runes.rs similarity index 100% rename from rust/basic_bitcoin/src/runes.rs rename to rust/basic_bitcoin/backend/src/runes.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 100% rename from rust/basic_bitcoin/src/service.rs rename to rust/basic_bitcoin/backend/src/service.rs diff --git a/rust/basic_bitcoin/src/service/etch_rune.rs b/rust/basic_bitcoin/backend/src/service/etch_rune.rs similarity index 100% rename from rust/basic_bitcoin/src/service/etch_rune.rs rename to rust/basic_bitcoin/backend/src/service/etch_rune.rs 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/inscribe_brc20.rs b/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs similarity index 100% rename from rust/basic_bitcoin/src/service/inscribe_brc20.rs rename to rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs diff --git a/rust/basic_bitcoin/src/service/inscribe_ordinal.rs b/rust/basic_bitcoin/backend/src/service/inscribe_ordinal.rs similarity index 100% rename from rust/basic_bitcoin/src/service/inscribe_ordinal.rs rename to rust/basic_bitcoin/backend/src/service/inscribe_ordinal.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/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..4b9f6a676f --- /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=127.0.0.1 -rpcallowip=127.0.0.1 \ + -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..547ecc917c --- /dev/null +++ b/rust/basic_bitcoin/icp.yaml @@ -0,0 +1,27 @@ +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: staging + network: ic + init_args: + backend: "(variant { testnet })" + + - name: production + network: ic + init_args: + backend: "(variant { mainnet })" From ccdf49941a2a4fe6541255562c09decdf9d8aaa1 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 17 Jun 2026 19:00:28 +0200 Subject: [PATCH 02/33] chore(rust/basic_bitcoin): test.sh over Makefile, bump CI image to 1.0.1 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/basic_bitcoin.yml | 2 +- rust/basic_bitcoin/Makefile | 80 ----------------------------- rust/basic_bitcoin/README.md | 2 +- rust/basic_bitcoin/test.sh | 72 ++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 82 deletions(-) delete mode 100644 rust/basic_bitcoin/Makefile create mode 100755 rust/basic_bitcoin/test.sh diff --git a/.github/workflows/basic_bitcoin.yml b/.github/workflows/basic_bitcoin.yml index 3f43fab4da..a7b9b126ce 100644 --- a/.github/workflows/basic_bitcoin.yml +++ b/.github/workflows/basic_bitcoin.yml @@ -74,4 +74,4 @@ jobs: run: | icp network start -d icp deploy --cycles 30t - make test + bash test.sh diff --git a/rust/basic_bitcoin/Makefile b/rust/basic_bitcoin/Makefile deleted file mode 100644 index 99a65f4402..0000000000 --- a/rust/basic_bitcoin/Makefile +++ /dev/null @@ -1,80 +0,0 @@ -IMAGE_NAME = icp-cli-network-launcher-bitcoin -# Find the running container built from our custom image -BITCOIN_CONTAINER = $(shell docker ps --filter "ancestor=$(IMAGE_NAME)" --format "{{.ID}}" | head -1) - -.PHONY: build-image test topup - -build-image: - # Because we're building off of :latest, use --pull to fetch the latest image. - # Remove `--pull` if the Dockerfile is updated to pin the base image version. - docker build --pull -t $(IMAGE_NAME) . - -topup: - icp canister top-up --amount 30t backend - -test: - @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 ===" - @sleep 5 - - @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 -q 'tip_height = 101' && \ - 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) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index b35f90f197..aede454054 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -45,7 +45,7 @@ make build-image ```bash icp network start -d icp deploy --cycles 30t -make test +bash test.sh icp network stop ``` diff --git a/rust/basic_bitcoin/test.sh b/rust/basic_bitcoin/test.sh new file mode 100755 index 0000000000..16cd292ea1 --- /dev/null +++ b/rust/basic_bitcoin/test.sh @@ -0,0 +1,72 @@ +#!/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 ===" +sleep 5 + +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 -q 'tip_height = 101' && \ + 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) From 67462c9cdcf3c4adc6048f6c2d5b1d6c52abf1ec Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 7 Jul 2026 16:53:32 +0200 Subject: [PATCH 03/33] fix(rust/basic_bitcoin): update deps, add Makefile, fix README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/Cargo.toml: edition 2021→2024; bump bitcoin 0.32.7→0.32, candid 0.10.19→0.10, ic-cdk 0.20.0→0.20, ic-cdk-management-canister 0.1→0.1.1, serde/serde_bytes/leb128/hex to range specifiers; add cdylib comment - Add Makefile with build-image and topup targets (matches motoko/basic_bitcoin); README referenced make build-image but no Makefile existed - README: Node.js v18+, Rust v1.85+ with wasm32-unknown-unknown target; update Architecture links to docs.internetcomputer.org; replace "smart contract" with "canister" throughout Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/Makefile | 11 +++++++++++ rust/basic_bitcoin/README.md | 23 ++++++++++++----------- rust/basic_bitcoin/backend/Cargo.toml | 19 ++++++++++--------- 3 files changed, 33 insertions(+), 20 deletions(-) create mode 100644 rust/basic_bitcoin/Makefile diff --git a/rust/basic_bitcoin/Makefile b/rust/basic_bitcoin/Makefile new file mode 100644 index 0000000000..669177d0ee --- /dev/null +++ b/rust/basic_bitcoin/Makefile @@ -0,0 +1,11 @@ +IMAGE_NAME = icp-cli-network-launcher-bitcoin + +.PHONY: build-image topup + +build-image: + # Because we're building off of :latest, use --pull to fetch the latest image. + # Remove `--pull` if the Dockerfile is updated to pin the base image version. + docker build --pull -t $(IMAGE_NAME) . + +topup: + icp canister top-up --amount 30t backend diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index aede454054..219a718db5 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -1,6 +1,6 @@ # 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 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. This example also includes how to work with Bitcoin assets such as Ordinals, Runes, and BRC-20 tokens. @@ -10,9 +10,9 @@ See also the [Motoko version](../../motoko/basic_bitcoin). 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) +* [ECDSA API](https://docs.internetcomputer.org/references/management-canister/#ic-ecdsa_public_key) +* [Schnorr API](https://docs.internetcomputer.org/references/management-canister/#ic-sign_with_schnorr) +* [Bitcoin API](https://docs.internetcomputer.org/references/protocol-canisters/#bitcoin-canisters) For background on the ICP<>BTC integration, refer to the [Learn Hub](https://learn.internetcomputer.org/hc/en-us/articles/34211154520084-Bitcoin-Integration). @@ -20,9 +20,10 @@ For background on the ICP<>BTC integration, refer to the [Learn Hub](https://lea ### Prerequisites -- [Rust toolchain](https://www.rust-lang.org/tools/install) -- icp-cli: `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` -- Docker (required to run the custom network launcher image that bundles bitcoind) +- [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, 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`. ### Install @@ -67,7 +68,7 @@ icp canister call backend get_p2pkh_address '()' ## Receiving bitcoin -Use `bitcoin-cli` inside the running network container to mine blocks and send the block reward to a smart contract address: +Use `bitcoin-cli` inside the running network container to mine blocks and send the block reward to a canister address: ```bash # Get the container ID of the running network launcher @@ -131,7 +132,7 @@ You can query the current state of the Bitcoin blockchain: icp canister call backend get_blockchain_info '()' ``` -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. +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. ## Retrieving block headers @@ -147,7 +148,7 @@ This calls `bitcoin_get_block_headers`, which is useful for blockchain validatio ## 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: +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 canister: - **Ordinals**: Inscribe arbitrary data onto individual satoshis - **Runes**: Create fungible tokens using `OP_RETURN` outputs @@ -305,4 +306,4 @@ If you base your application on this example, we recommend you familiarize yours For example, the following aspects are particularly relevant for this app: - 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 make a smart contract have a decentralized controller, since decentralized control may be essential for smart contracts holding bitcoins on behalf of users. +- 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 index 505af7a408..8207b9ade0 100644 --- a/rust/basic_bitcoin/backend/Cargo.toml +++ b/rust/basic_bitcoin/backend/Cargo.toml @@ -1,18 +1,19 @@ [package] name = "backend" version = "0.1.0" -edition = "2021" +edition = "2024" [lib] +# cdylib produces a .wasm binary suitable for deployment as an ICP canister. crate-type = ["cdylib"] [dependencies] -hex = "0.4.3" -bitcoin = "0.32.7" -candid = "0.10.19" -ic-cdk = "0.20.0" +bitcoin = "0.32" +candid = "0.10" +hex = "0.4" +ic-cdk = "0.20" 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" +ic-cdk-management-canister = "0.1.1" +leb128 = "0.2" +serde = "1.0" +serde_bytes = "0.11" From ddeeeae392586af459f2213eb50c5812fa953ed0 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 11:58:58 +0200 Subject: [PATCH 04/33] chore: add ic env + build-image.sh to rust/basic_bitcoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace staging/production environments with a single `ic` environment (Bitcoin testnet4, test_key_1) — the default for IC network deployments - Add build-image.sh as a standalone alternative to `make build-image` - Fix key_name mapping: Network::Mainnet now correctly resolves to "key_1" instead of "test_key_1" (was a bug — mainnet and testnet used the same key) - Update README to reflect new environment structure and document how to switch to Bitcoin mainnet for production deployments - Remove remaining "smart contract"/"contract" jargon Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 17 ++++++++++++++--- rust/basic_bitcoin/backend/src/lib.rs | 7 ++++--- rust/basic_bitcoin/build-image.sh | 5 +++++ rust/basic_bitcoin/icp.yaml | 10 ++++------ 4 files changed, 27 insertions(+), 12 deletions(-) create mode 100755 rust/basic_bitcoin/build-image.sh diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 219a718db5..ac8d1207e1 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -38,10 +38,11 @@ cd examples/rust/basic_bitcoin The local network bundles bitcoind inside a custom Docker image. Build it once before starting the network: ```bash -make build-image +./build-image.sh +# or: make build-image ``` -### Deploy and test +### Deploy locally and test ```bash icp network start -d @@ -52,6 +53,16 @@ icp network stop > If tests fail with an out-of-cycles error, run `make topup` to add 30 trillion cycles to the backend canister and retry. +### Deploy to the IC network + +The `ic` environment deploys to IC mainnet connected to Bitcoin testnet4, using `test_key_1`: + +```bash +icp deploy -e ic --cycles 30t +``` + +> 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. + ## Generating Bitcoin addresses The example demonstrates how to generate and use the following address types: @@ -122,7 +133,7 @@ icp canister call backend send_from_p2pkh_address '(record { > **Important:** Newly mined bitcoin cannot be spent until 100 additional blocks have been added to the chain. To make your bitcoin spendable, create 100 additional blocks with any valid address as the recipient. -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/). +The function returns the transaction ID. When the canister is deployed on IC mainnet, you can track testnet transactions on [mempool.space](https://mempool.space/testnet4/). ## Retrieving blockchain info diff --git a/rust/basic_bitcoin/backend/src/lib.rs b/rust/basic_bitcoin/backend/src/lib.rs index bd558cc7aa..4d2dd6c5b6 100644 --- a/rust/basic_bitcoin/backend/src/lib.rs +++ b/rust/basic_bitcoin/backend/src/lib.rs @@ -32,7 +32,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 { @@ -47,7 +47,8 @@ thread_local! { fn init_upgrade(network: Network) { let key_name = match network { Network::Regtest => "dfx_test_key", - Network::Mainnet | Network::Testnet => "test_key_1", + Network::Testnet => "test_key_1", + Network::Mainnet => "key_1", }; let bitcoin_network = match network { @@ -65,7 +66,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) { 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/icp.yaml b/rust/basic_bitcoin/icp.yaml index 547ecc917c..4b3283b699 100644 --- a/rust/basic_bitcoin/icp.yaml +++ b/rust/basic_bitcoin/icp.yaml @@ -16,12 +16,10 @@ environments: init_args: backend: "(variant { regtest })" - - name: staging + - 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 })" - - - name: production - network: ic - init_args: - backend: "(variant { mainnet })" From d8b3dc39068c57490b8c7f1300c0ab71f4cd439c Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 12:11:25 +0200 Subject: [PATCH 05/33] fix: add export_candid!() and bump ic-cdk to 0.20.2 in rust/basic_bitcoin - Add ic_cdk::export_candid!() to lib.rs so candid-extractor can find the get_candid_pointer export during the icp deploy build step - Bump ic-cdk to 0.20.2 (latest patch) Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/Cargo.lock | 32 +++++++++++++-------------- rust/basic_bitcoin/backend/Cargo.toml | 2 +- rust/basic_bitcoin/backend/src/lib.rs | 2 ++ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/rust/basic_bitcoin/Cargo.lock b/rust/basic_bitcoin/Cargo.lock index d4bea5ad2c..cbd21edc69 100644 --- a/rust/basic_bitcoin/Cargo.lock +++ b/rust/basic_bitcoin/Cargo.lock @@ -26,16 +26,6 @@ version = "1.1.0" 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 = "backend" version = "0.1.0" @@ -51,6 +41,16 @@ dependencies = [ "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]] name = "bech32" version = "0.11.0" @@ -348,9 +348,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 +386,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 +435,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/backend/Cargo.toml b/rust/basic_bitcoin/backend/Cargo.toml index 8207b9ade0..66f20ffe91 100644 --- a/rust/basic_bitcoin/backend/Cargo.toml +++ b/rust/basic_bitcoin/backend/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] bitcoin = "0.32" candid = "0.10" hex = "0.4" -ic-cdk = "0.20" +ic-cdk = "0.20.2" ic-cdk-bitcoin-canister = "0.2" ic-cdk-management-canister = "0.1.1" leb128 = "0.2" diff --git a/rust/basic_bitcoin/backend/src/lib.rs b/rust/basic_bitcoin/backend/src/lib.rs index 4d2dd6c5b6..f6219a2f38 100644 --- a/rust/basic_bitcoin/backend/src/lib.rs +++ b/rust/basic_bitcoin/backend/src/lib.rs @@ -87,3 +87,5 @@ pub struct SendRequest { pub destination_address: String, pub amount_in_satoshi: u64, } + +ic_cdk::export_candid!(); From 191952f3209000e87feea580e0ed34e2cd979379 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 12:12:38 +0200 Subject: [PATCH 06/33] fix: import missing types in lib.rs for export_candid!() export_candid!() generates Candid bindings using the types returned by all endpoint functions, so those types must be in scope at the crate root. Import GetBlockHeadersResponse, BlockchainInfo, GetUtxosResponse, and MillisatoshiPerByte from ic_cdk_bitcoin_canister. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/backend/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rust/basic_bitcoin/backend/src/lib.rs b/rust/basic_bitcoin/backend/src/lib.rs index f6219a2f38..1af9919109 100644 --- a/rust/basic_bitcoin/backend/src/lib.rs +++ b/rust/basic_bitcoin/backend/src/lib.rs @@ -10,7 +10,9 @@ 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. From fb1fff7198b8e94519c0d94fb7bc0363b2adba67 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 12:16:07 +0200 Subject: [PATCH 07/33] chore: remove Makefile, document topup command in README build-image is now covered by build-image.sh; the topup command is documented inline in the README instead of requiring make. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/Makefile | 11 ----------- rust/basic_bitcoin/README.md | 6 ++++-- 2 files changed, 4 insertions(+), 13 deletions(-) delete mode 100644 rust/basic_bitcoin/Makefile diff --git a/rust/basic_bitcoin/Makefile b/rust/basic_bitcoin/Makefile deleted file mode 100644 index 669177d0ee..0000000000 --- a/rust/basic_bitcoin/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -IMAGE_NAME = icp-cli-network-launcher-bitcoin - -.PHONY: build-image topup - -build-image: - # Because we're building off of :latest, use --pull to fetch the latest image. - # Remove `--pull` if the Dockerfile is updated to pin the base image version. - docker build --pull -t $(IMAGE_NAME) . - -topup: - icp canister top-up --amount 30t backend diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index ac8d1207e1..2cdb79246f 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -39,7 +39,6 @@ The local network bundles bitcoind inside a custom Docker image. Build it once b ```bash ./build-image.sh -# or: make build-image ``` ### Deploy locally and test @@ -51,7 +50,10 @@ bash test.sh icp network stop ``` -> If tests fail with an out-of-cycles error, run `make topup` to add 30 trillion cycles to the backend canister and retry. +> 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 From 570c2e8eacb53739601972f8c7555dd34ac7670b Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 12:23:18 +0200 Subject: [PATCH 08/33] fix: expose bitcoind RPC port and align passwords for ord - docker/start.sh: bind bitcoind on 0.0.0.0 so the host can reach it through Docker's port mapping - icp.yaml: add 18443:18443 port mapping for bitcoind RPC - ord.yaml + bitcoin.conf: fix password to ic-btc-integration (was a different value, causing authentication failures when running ord) Run ./build-image.sh after pulling these changes to rebuild the image. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/bitcoin.conf | 2 +- rust/basic_bitcoin/docker/start.sh | 2 +- rust/basic_bitcoin/icp.yaml | 1 + rust/basic_bitcoin/ord.yaml | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) 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/docker/start.sh b/rust/basic_bitcoin/docker/start.sh index 4b9f6a676f..9db7d5c4bb 100644 --- a/rust/basic_bitcoin/docker/start.sh +++ b/rust/basic_bitcoin/docker/start.sh @@ -4,7 +4,7 @@ bitcoind \ -regtest -server \ - -rpcbind=127.0.0.1 -rpcallowip=127.0.0.1 \ + -rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0 \ -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ -fallbackfee=0.00001 -txindex=1 & diff --git a/rust/basic_bitcoin/icp.yaml b/rust/basic_bitcoin/icp.yaml index 4b3283b699..2b13ed08c1 100644 --- a/rust/basic_bitcoin/icp.yaml +++ b/rust/basic_bitcoin/icp.yaml @@ -9,6 +9,7 @@ networks: image: icp-cli-network-launcher-bitcoin port-mapping: - 0:4943 # IC gateway (dynamic) + - 18443:18443 # bitcoind RPC (required for ord) environments: - name: local diff --git a/rust/basic_bitcoin/ord.yaml b/rust/basic_bitcoin/ord.yaml index a0f57be67d..40e0353f6b 100644 --- a/rust/basic_bitcoin/ord.yaml +++ b/rust/basic_bitcoin/ord.yaml @@ -1,5 +1,5 @@ bitcoin_data_dir: ./bitcoin_data -bitcoin_rpc_password: QPQiNaph19FqUsCrBRN0FII7lyM26B51fAMeBQzCb-E= +bitcoin_rpc_password: ic-btc-integration bitcoin_rpc_url: http://127.0.0.1:18443 bitcoin_rpc_username: ic-btc-integration chain: regtest From 407d187a4599d2cfe6441ee1fb2a459e237f517e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 12:33:31 +0200 Subject: [PATCH 09/33] docs: rewrite receiving/sending sections as a step-by-step walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old structure was confusing: it showed sending before explaining coinbase maturity, and omitted the confirmation block step. The new walkthrough guides the reader through the full flow: fund (101 blocks) → check balance → send → mine confirmation block → verify destination. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 80 +++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 2cdb79246f..8be0d4d6e2 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -79,63 +79,79 @@ 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 `bitcoin-cli` inside the running network container to mine blocks and send the block reward to a canister address: +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. + +### Step 1 — Get the canister's address and the container ID ```bash -# Get the container ID of the running network launcher CONTAINER=$(docker ps --filter "ancestor=icp-cli-network-launcher-bitcoin" --format "{{.ID}}" | head -1) - -# Get an address from the canister ADDR=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') +echo "Address: $ADDR" +``` -# Mine 1 block to that address +### Step 2 — Mine 101 blocks to fund the address + +Mining 101 blocks ensures the first block reward (50 BTC) is past the coinbase maturity threshold and immediately spendable. + +```bash docker exec $CONTAINER bitcoin-cli -regtest \ -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$ADDR" + generatetoaddress 101 "$ADDR" ``` -## Checking balance +### Step 3 — Check the balance -Check the balance of any Bitcoin address: +The IC Bitcoin integration syncs new blocks continuously. If the balance shows 0, wait a few seconds and retry. ```bash -icp canister call backend get_balance '("")' +icp canister call backend get_balance "(\"$ADDR\")" +# Expected: (5_000_000_000 : nat64) — 50 BTC in satoshis ``` -This uses `bitcoin_get_balance` and works for any supported address type. The balance requires at least one confirmation to be reflected. +### Step 4 — Send bitcoin -## Sending bitcoin +```bash +DEST="bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" +icp canister call backend send_from_p2pkh_address "(record { + destination_address = \"$DEST\"; + amount_in_satoshi = 4321; +})" +# Returns the transaction ID +``` -You can send bitcoin using the following endpoints: +The transaction is now broadcast to `bitcoind`'s mempool. The destination balance will remain 0 until it is confirmed in a block. -- `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` +### Step 5 — Mine a confirmation block -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` +```bash +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" +``` -Example: +### Step 6 — Verify the destination received the funds ```bash -icp canister call backend send_from_p2pkh_address '(record { - destination_address = "bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt"; - amount_in_satoshi = 4321; -})' +icp canister call backend get_balance "(\"$DEST\")" +# Expected: (4_321 : nat64) ``` -> **Important:** Newly mined bitcoin cannot be spent until 100 additional blocks have been added to the chain. To make your bitcoin spendable, create 100 additional blocks with any valid address as the recipient. +## Other send endpoints + +The same pattern (fund → send → mine confirmation block → verify) applies to the other address types: + +- `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 estimates fees, selects UTXOs, builds a transaction, signs it using ECDSA or Schnorr, and broadcasts it via `bitcoin_send_transaction`. -The function returns the transaction ID. When the canister is deployed on IC mainnet, you can track testnet transactions on [mempool.space](https://mempool.space/testnet4/). +When the canister is deployed on IC mainnet, you can track testnet transactions on [mempool.space](https://mempool.space/testnet4/). ## Retrieving blockchain info From e42a54ee5b1fbfbe6acde6981766e5455dd1be50 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 12:36:20 +0200 Subject: [PATCH 10/33] =?UTF-8?q?docs:=20fix=20expected=20balance=20commen?= =?UTF-8?q?t=20(101=20blocks=20=C3=97=2050=20BTC=20=3D=20505=20BTC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 8be0d4d6e2..270a5b8104 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -109,7 +109,7 @@ The IC Bitcoin integration syncs new blocks continuously. If the balance shows 0 ```bash icp canister call backend get_balance "(\"$ADDR\")" -# Expected: (5_000_000_000 : nat64) — 50 BTC in satoshis +# Expected: (505_000_000_000 : nat64) — 101 blocks × 50 BTC each ``` ### Step 4 — Send bitcoin From 697bddf37eff2d9a57676cfbebed79ab2e5d2a2c Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 12:43:21 +0200 Subject: [PATCH 11/33] docs: point to /inscriptions instead of homepage for ord explorer The ord homepage (/) has a "Latest Inscriptions" section that appears empty; /inscriptions correctly lists all indexed inscriptions. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 270a5b8104..71382bba06 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -238,7 +238,14 @@ For other platforms, see the [ord repository](https://github.com/ordinals/ord) f 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/`. +The function returns the reveal transaction ID. Your inscription is now permanently stored on Bitcoin and can be viewed in the local `ord` explorer: + +- All inscriptions: `http://127.0.0.1/inscriptions` +- Direct lookup by reveal transaction ID: `http://127.0.0.1/inscription/i0` + +> **Note:** The homepage at `http://127.0.0.1/` shows a "Latest Inscriptions" section that may appear empty — use `/inscriptions` instead. + +> **Note:** The `ord` server builds its index asynchronously as it scans blocks from genesis. The "Latest Inscriptions" landing page may appear empty until indexing catches up — this usually takes a few seconds after blocks are mined. You can navigate directly to your inscription using the reveal transaction ID: `http://127.0.0.1/inscription/i0`. ## Etch a Rune From bde22c6838cd5915629ff9d32b76f1370a2a43fc Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 12:45:10 +0200 Subject: [PATCH 12/33] docs: use env variables in ordinals/runes/brc-20 walkthroughs Store addresses and transaction IDs in variables so each step can reference them directly rather than requiring manual copy-paste of placeholder values. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 193 +++++++++++++++++++---------------- 1 file changed, 105 insertions(+), 88 deletions(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 71382bba06..423d843bc5 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -207,85 +207,87 @@ For other platforms, see the [ord repository](https://github.com/ordinals/ord) f [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 - ``` - -2. **Get a Taproot address** for funding the inscription: - ```bash - icp canister call backend get_p2tr_key_path_only_address '()' - ``` - -3. **Fund the address** with sufficient bitcoin (101 blocks ensures spendability): - ```bash - docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 101 - ``` - -4. **Create the inscription** with your desired text: - ```bash - icp canister call backend inscribe_ordinal '("Hello Bitcoin")' - ``` - -5. **Mine a block** to confirm the transactions: - ```bash - docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 - ``` - -The function returns the reveal transaction ID. Your inscription is now permanently stored on Bitcoin and can be viewed in the local `ord` explorer: +### Step 1 — Start the ord server (separate terminal) + +```bash +ord --config-dir . server +``` + +### Step 2 — Fund a Taproot address + +```bash +ADDR=$(icp canister call backend get_p2tr_key_path_only_address '()' | grep -o '"[^"]*"' | tr -d '"') +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 101 "$ADDR" +``` + +### Step 3 — Create the inscription + +```bash +TXID=$(icp canister call backend inscribe_ordinal '("Hello Bitcoin")' | grep -o '"[^"]*"' | tr -d '"') +echo "Reveal txid: $TXID" +``` + +### Step 4 — Mine a confirmation block + +```bash +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" +``` + +### Step 5 — View in the ord explorer + +```bash +echo "http://127.0.0.1/inscription/${TXID}i0" +``` - All inscriptions: `http://127.0.0.1/inscriptions` -- Direct lookup by reveal transaction ID: `http://127.0.0.1/inscription/i0` > **Note:** The homepage at `http://127.0.0.1/` shows a "Latest Inscriptions" section that may appear empty — use `/inscriptions` instead. -> **Note:** The `ord` server builds its index asynchronously as it scans blocks from genesis. The "Latest Inscriptions" landing page may appear empty until indexing catches up — this usually takes a few seconds after blocks are mined. You can navigate directly to your inscription using the reveal transaction ID: `http://127.0.0.1/inscription/i0`. - ## 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 - ``` - -2. **Get a Taproot address** for the Rune etching: - ```bash - icp canister call backend get_p2tr_key_path_only_address '()' - ``` - -3. **Fund the address** with bitcoin to pay for the etching: - ```bash - docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 101 - ``` - -4. **Etch the Rune** with an uppercase name (maximum 28 characters): - ```bash - icp canister call backend etch_rune '("ICPRUNE")' - ``` - -5. **Mine a block** to confirm the etching: - ```bash - docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 - ``` - -6. **Decode the Runestone** to verify the etching: - ```bash - ord --config-dir . decode --txid - ``` +### Step 1 — Start the ord server (separate terminal) + +```bash +ord --config-dir . server +``` + +### Step 2 — Fund a Taproot address + +```bash +ADDR=$(icp canister call backend get_p2tr_key_path_only_address '()' | grep -o '"[^"]*"' | tr -d '"') +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 101 "$ADDR" +``` + +### Step 3 — Etch the Rune + +The name must be uppercase, maximum 28 characters: + +```bash +TXID=$(icp canister call backend etch_rune '("ICPRUNE")' | grep -o '"[^"]*"' | tr -d '"') +echo "Rune txid: $TXID" +``` + +### Step 4 — Mine a confirmation block + +```bash +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" +``` + +### Step 5 — Decode the Runestone to verify + +```bash +ord --config-dir . decode --txid "$TXID" +``` 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. @@ -293,35 +295,50 @@ The Rune is now etched with 1,000,000 tokens minted to your address. The tokens [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. -### Step-by-step process: +### Step 1 — Start the ord server (separate terminal) + +```bash +ord --config-dir . server +``` + +### Step 2 — Fund a Taproot address -1. **Start the ord server** to index BRC-20 inscriptions: - ```bash - ord --config-dir . server - ``` +```bash +ADDR=$(icp canister call backend get_p2tr_key_path_only_address '()' | grep -o '"[^"]*"' | tr -d '"') +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 101 "$ADDR" +``` + +### Step 3 — Deploy the BRC-20 token -2. **Get a Taproot address** and fund it with bitcoin. +The ticker must be exactly 4 characters: + +```bash +TXID=$(icp canister call backend inscribe_brc20 '("DEMO")' | grep -o '"[^"]*"' | tr -d '"') +echo "BRC-20 deploy txid: $TXID" +``` -3. **Deploy the BRC-20 token** with a 4-character ticker: - ```bash - icp canister call backend inscribe_brc20 '("DEMO")' - ``` +### Step 4 — Mine a confirmation block -4. **Mine a block** to confirm the deployment: - ```bash - docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 - ``` +```bash +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" +``` This creates a BRC-20 token with: - Ticker: "DEMO" - Max supply: 21,000,000 tokens - Mint limit: 1,000 tokens per mint -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. +The deployment inscription contains JSON metadata that BRC-20 indexers use to track token balances and transfers. View it in the ord explorer: + +```bash +echo "http://127.0.0.1/inscription/${TXID}i0" +``` -To view the deployed BRC-20 token, use the local `ord` explorer at `http://127.0.0.1:80/`. +Additional mint and transfer operations require separate inscriptions following the BRC-20 protocol. ## Notes on implementation From a87f62dccb79212a119d2a2eb8f53f791675bafd Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 12:45:57 +0200 Subject: [PATCH 13/33] docs: add direct rune URL hint in Etch a Rune section Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 423d843bc5..e4dbdeb3fc 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -289,6 +289,16 @@ docker exec $CONTAINER bitcoin-cli -regtest \ ord --config-dir . decode --txid "$TXID" ``` +### Step 6 — View in the ord explorer + +```bash +echo "http://127.0.0.1/rune/ICPRUNE" +``` + +- All runes: `http://127.0.0.1/runes` + +> **Note:** The `/runes` listing page may appear empty for the same reason as `/` for inscriptions — use the direct URL above instead. + 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. ## Deploy a BRC-20 token From 8dc2338bb249ccc7431f140744c96c04d6af43c9 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 13:01:07 +0200 Subject: [PATCH 14/33] fix: use turbo: true in etch_rune to bypass name unlock schedule Without turbo mode, short rune names (< 13 chars) are reserved until a high future block height (e.g. "ICPRUNE" unlocks at block 99,547 in regtest). Turbo mode makes the rune immediately active regardless of name length or current chain height. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/backend/src/service/etch_rune.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust/basic_bitcoin/backend/src/service/etch_rune.rs b/rust/basic_bitcoin/backend/src/service/etch_rune.rs index f2fdeb1bc9..211c68fe60 100644 --- a/rust/basic_bitcoin/backend/src/service/etch_rune.rs +++ b/rust/basic_bitcoin/backend/src/service/etch_rune.rs @@ -31,6 +31,7 @@ use ic_cdk_bitcoin_canister::{ /// - 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 +/// - Turbo mode enabled so any name is immediately active (no unlock height) /// /// The rune metadata becomes permanently recorded in the Bitcoin blockchain /// as part of the OP_RETURN output, creating a new fungible token. @@ -82,7 +83,10 @@ pub async fn etch_rune(name: String) -> String { rune_name: name.clone(), symbol: Some('🪙'), // Unicode coin symbol for display terms: None, // No open minting allowed - turbo: false, // Standard etching mode + // Turbo mode bypasses the name unlock schedule so any name can be etched + // immediately, regardless of the current block height. Without this flag, + // short names (< 13 chars) are reserved until a high future block height. + turbo: true, spacers: 0, // No visual spacers in the name }; From b37986ce26ab9d400a538de565456c7bc17801e1 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 14:30:15 +0200 Subject: [PATCH 15/33] feat(rust/basic_bitcoin): add rune transfer, BRC-20 mint/transfer and complete README walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add transfer_rune endpoint: builds a Runestone with an edict to move rune tokens between addresses; verifiable via ord decode without the ord UI - Add mint_brc20 endpoint: creates a BRC-20 mint inscription at the canister's address - Add transfer_brc20 endpoint: chains commit → reveal to self → send inscription UTXO to recipient, following the BRC-20 two-step transfer spec - Extract commit_and_reveal helper in brc20.rs; refactor inscribe_brc20 to use it, eliminating code duplication across deploy/mint/transfer - Update README: complete deploy → mint → transfer walkthroughs for Runes and BRC-20; fix architecture links to use /ic-interface-spec/ paths matching the Motoko README; add get_utxos section; fix missing $CONTAINER setup in Bitcoin assets prerequisites Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 155 +++++++++++-- rust/basic_bitcoin/backend/src/brc20.rs | 119 +++++++++- rust/basic_bitcoin/backend/src/lib.rs | 24 ++ rust/basic_bitcoin/backend/src/runes.rs | 68 ++++++ rust/basic_bitcoin/backend/src/service.rs | 3 + .../backend/src/service/inscribe_brc20.rs | 182 ++------------- .../backend/src/service/mint_brc20.rs | 59 +++++ .../backend/src/service/transfer_brc20.rs | 168 ++++++++++++++ .../backend/src/service/transfer_rune.rs | 213 ++++++++++++++++++ 9 files changed, 808 insertions(+), 183 deletions(-) create mode 100644 rust/basic_bitcoin/backend/src/service/mint_brc20.rs create mode 100644 rust/basic_bitcoin/backend/src/service/transfer_brc20.rs create mode 100644 rust/basic_bitcoin/backend/src/service/transfer_rune.rs diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index e4dbdeb3fc..2c68737d48 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -8,13 +8,13 @@ See also the [Motoko version](../../motoko/basic_bitcoin). ## Architecture -This example integrates with the Internet Computer's built-in: +For a deeper understanding of the ICP ↔ Bitcoin integration, see the [Bitcoin integration concepts](https://docs.internetcomputer.org/concepts/chain-fusion/bitcoin). -* [ECDSA API](https://docs.internetcomputer.org/references/management-canister/#ic-ecdsa_public_key) -* [Schnorr API](https://docs.internetcomputer.org/references/management-canister/#ic-sign_with_schnorr) -* [Bitcoin API](https://docs.internetcomputer.org/references/protocol-canisters/#bitcoin-canisters) +This example integrates with the Internet Computer's built-in: -For background on the ICP<>BTC integration, refer to the [Learn Hub](https://learn.internetcomputer.org/hc/en-us/articles/34211154520084-Bitcoin-Integration). +- 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 ## Build and deploy from the command line @@ -153,6 +153,16 @@ Each endpoint internally estimates fees, selects UTXOs, builds a transaction, si When the canister is deployed on IC mainnet, you can track testnet transactions on [mempool.space](https://mempool.space/testnet4/). +## Querying UTXOs + +You can inspect the UTXOs held at any Bitcoin address: + +```bash +icp canister call backend get_utxos "(\"$ADDR\")" +``` + +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. + ## Retrieving blockchain info You can query the current state of the Bitcoin blockchain: @@ -195,6 +205,12 @@ brew install ord For other platforms, see the [ord repository](https://github.com/ordinals/ord) for installation instructions. +Make sure the `$CONTAINER` variable is set before running any of the steps below. If you haven't done so yet from the Bitcoin walkthrough above: + +```bash +CONTAINER=$(docker ps --filter "ancestor=icp-cli-network-launcher-bitcoin" --format "{{.ID}}" | head -1) +``` + > **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`: @@ -299,19 +315,67 @@ echo "http://127.0.0.1/rune/ICPRUNE" > **Note:** The `/runes` listing page may appear empty for the same reason as `/` for inscriptions — use the direct URL above instead. -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. +The Rune is now etched with 1,000,000 tokens minted to your address. -## Deploy a BRC-20 token +## Transfer a Rune -[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. +This section shows how to transfer rune tokens from the canister's address to another address, proving the rune is functional and balances update on-chain. -### Step 1 — Start the ord server (separate terminal) +### Step 1 — Look up the rune ID + +The rune ID (block height : transaction index) is required to identify which rune to transfer. Fetch it from the ord server: + +```bash +RUNE_ID=$(curl -s -H "Accept: application/json" http://127.0.0.1/rune/ICPRUNE | grep -o '"id":"[^"]*"' | cut -d'"' -f4) +RUNE_BLOCK=$(echo "$RUNE_ID" | cut -d: -f1) +RUNE_TX=$(echo "$RUNE_ID" | cut -d: -f2) +echo "Rune ID: $RUNE_BLOCK:$RUNE_TX" +``` + +### Step 2 — Transfer rune tokens + +```bash +DEST="bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" +TRANSFER_TXID=$(icp canister call backend transfer_rune "(record { + rune_id_block = ${RUNE_BLOCK}: nat64; + rune_id_tx = ${RUNE_TX}: nat32; + amount = 100000: nat64; + destination_address = \"$DEST\"; +})" | grep -o '"[^"]*"' | tr -d '"') +echo "Transfer txid: $TRANSFER_TXID" +``` + +### Step 3 — Mine a confirmation block + +```bash +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" +``` + +### Step 4 — Verify the transfer + +Decode the transfer transaction to confirm the Runestone edict is correct: + +```bash +ord --config-dir . decode --txid "$TRANSFER_TXID" +``` + +The output should contain a Runestone with an edict assigning 100,000 tokens to output 0 (the recipient). The remaining 900,000 tokens stay in the change output at the canister's address. + +## BRC-20 tokens + +[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. Three operations make up the full lifecycle: **deploy** → **mint** → **transfer**. + +### Deploy a BRC-20 token + +#### Step 1 — Start the ord server (separate terminal) ```bash ord --config-dir . server ``` -### Step 2 — Fund a Taproot address +#### Step 2 — Fund a Taproot address ```bash ADDR=$(icp canister call backend get_p2tr_key_path_only_address '()' | grep -o '"[^"]*"' | tr -d '"') @@ -320,16 +384,16 @@ docker exec $CONTAINER bitcoin-cli -regtest \ generatetoaddress 101 "$ADDR" ``` -### Step 3 — Deploy the BRC-20 token +#### Step 3 — Deploy the BRC-20 token The ticker must be exactly 4 characters: ```bash -TXID=$(icp canister call backend inscribe_brc20 '("DEMO")' | grep -o '"[^"]*"' | tr -d '"') -echo "BRC-20 deploy txid: $TXID" +DEPLOY_TXID=$(icp canister call backend inscribe_brc20 '("DEMO")' | grep -o '"[^"]*"' | tr -d '"') +echo "BRC-20 deploy txid: $DEPLOY_TXID" ``` -### Step 4 — Mine a confirmation block +#### Step 4 — Mine a confirmation block ```bash docker exec $CONTAINER bitcoin-cli -regtest \ @@ -342,13 +406,70 @@ This creates a BRC-20 token with: - Max supply: 21,000,000 tokens - Mint limit: 1,000 tokens per mint -The deployment inscription contains JSON metadata that BRC-20 indexers use to track token balances and transfers. View it in the ord explorer: +View the deploy inscription in the ord explorer: ```bash -echo "http://127.0.0.1/inscription/${TXID}i0" +echo "http://127.0.0.1/inscription/${DEPLOY_TXID}i0" +``` + +### Mint BRC-20 tokens + +Minting claims tokens from the deployed supply and credits them to the canister's address. The amount must not exceed the per-mint limit (1,000 in this example). + +#### Step 1 — Mint tokens + +```bash +MINT_TXID=$(icp canister call backend mint_brc20 '("DEMO", 1000: nat64)' | grep -o '"[^"]*"' | tr -d '"') +echo "BRC-20 mint txid: $MINT_TXID" +``` + +#### Step 2 — Mine a confirmation block + +```bash +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" +``` + +#### Step 3 — View the mint inscription + +```bash +echo "http://127.0.0.1/inscription/${MINT_TXID}i0" +``` + +### Transfer BRC-20 tokens + +A BRC-20 transfer uses three transactions: create a transfer inscription at the sender's (canister's) address, then move that inscription UTXO to the recipient. This two-step handoff is how BRC-20 indexers track balance changes. + +#### Step 1 — Transfer tokens + +```bash +DEST="bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" +TRANSFER_TXID=$(icp canister call backend transfer_brc20 '(record { + tick = "DEMO"; + amount = 500: nat64; + destination_address = "'"$DEST"'"; +})' | grep -o '"[^"]*"' | tr -d '"') +echo "BRC-20 transfer txid: $TRANSFER_TXID" +``` + +#### Step 2 — Mine a confirmation block + +```bash +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" +``` + +#### Step 3 — Verify the transfer inscription + +```bash +ord --config-dir . decode --txid "$TRANSFER_TXID" +# The inscription content is the transfer JSON: +# {"p":"brc-20","op":"transfer","tick":"DEMO","amt":"500"} ``` -Additional mint and transfer operations require separate inscriptions following the BRC-20 protocol. +The recipient now holds the BRC-20 transfer inscription. BRC-20 indexers credit 500 DEMO to the recipient's balance and debit it from the canister's balance. ## Notes on implementation diff --git a/rust/basic_bitcoin/backend/src/brc20.rs b/rust/basic_bitcoin/backend/src/brc20.rs index caeece45a8..dd46d904b4 100644 --- a/rust/basic_bitcoin/backend/src/brc20.rs +++ b/rust/basic_bitcoin/backend/src/brc20.rs @@ -1,7 +1,20 @@ +use crate::{ + common::{get_fee_per_byte, DerivationPath, PrimaryOutput}, + ordinals::{build_reveal_transaction, create_script_path_witness, INSCRIPTION_OUTPUT_VALUE}, + p2tr, + schnorr::{get_schnorr_public_key, sign_with_schnorr}, + BitcoinContext, +}; use bitcoin::{ + consensus::serialize, opcodes::{all::*, OP_FALSE}, script::{Builder, PushBytesBuf}, - ScriptBuf, XOnlyPublicKey, + secp256k1::{PublicKey, Secp256k1}, + taproot::{LeafVersion, TaprootBuilder}, + Address, ScriptBuf, XOnlyPublicKey, +}; +use ic_cdk_bitcoin_canister::{ + bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, }; /// Builds the BRC-20 reveal script that contains the JSON token deployment data. @@ -46,3 +59,107 @@ pub fn build_brc20_reveal_script(internal_key: &XOnlyPublicKey, brc20_json: &str .push_opcode(OP_ENDIF) // End inscription envelope .into_script() } + +/// Builds and broadcasts a BRC-20 commit + reveal transaction pair. +/// +/// This handles the two-transaction pattern shared by all BRC-20 operations +/// (deploy, mint, transfer-inscription): +/// 1. Commit: Send funds to a Taproot address that commits to the BRC-20 script +/// 2. Reveal: Spend from that address, revealing the BRC-20 JSON in the witness +/// +/// The reveal output (the inscription UTXO) is sent to `destination_address`. +/// Returns the reveal transaction ID. +pub(crate) async fn commit_and_reveal( + ctx: &BitcoinContext, + brc20_json: &str, + destination_address: &Address, +) -> String { + 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()); + + let reveal_script = build_brc20_reveal_script(&internal_key, brc20_json); + + let secp256k1_engine = Secp256k1::new(); + let taproot_spend_info = TaprootBuilder::new() + .add_leaf(0, reveal_script.clone()) + .unwrap() + .finalize(&secp256k1_engine, internal_key) + .unwrap(); + + let commit_address = + Address::p2tr_tweaked(taproot_spend_info.output_key(), ctx.bitcoin_network); + let funding_address = + Address::p2tr(&secp256k1_engine, internal_key, None, ctx.bitcoin_network); + + let own_utxos = bitcoin_get_utxos(&GetUtxosRequest { + address: funding_address.to_string(), + network: ctx.network.into(), + filter: None, + }) + .await + .unwrap() + .utxos; + + let fee_per_byte = get_fee_per_byte(ctx).await; + + let (commit_tx, prevouts) = p2tr::build_transaction( + ctx, + &funding_address, + &own_utxos, + p2tr::SelectUtxosMode::Single, + &PrimaryOutput::Address(commit_address, INSCRIPTION_OUTPUT_VALUE), + fee_per_byte, + ) + .await; + + let signed_commit = p2tr::sign_transaction_key_spend( + ctx, + &funding_address, + commit_tx, + prevouts.as_slice(), + internal_key_path.to_vec_u8_path(), + vec![], + sign_with_schnorr, + ) + .await; + + bitcoin_send_transaction(&SendTransactionRequest { + network: ctx.network.into(), + transaction: serialize(&signed_commit), + }) + .await + .unwrap(); + + let control_block = taproot_spend_info + .control_block(&(reveal_script.clone(), LeafVersion::TapScript)) + .unwrap(); + + let mut reveal_tx = build_reveal_transaction( + destination_address, + &reveal_script, + &control_block, + &signed_commit.compute_txid(), + fee_per_byte, + ) + .await; + + create_script_path_witness( + ctx, + &mut reveal_tx, + &signed_commit.output[0], + &reveal_script, + &control_block, + internal_key_path.to_vec_u8_path(), + ) + .await; + + bitcoin_send_transaction(&SendTransactionRequest { + network: ctx.network.into(), + transaction: serialize(&reveal_tx), + }) + .await + .unwrap(); + + reveal_tx.compute_txid().to_string() +} diff --git a/rust/basic_bitcoin/backend/src/lib.rs b/rust/basic_bitcoin/backend/src/lib.rs index 1af9919109..47daefad3f 100644 --- a/rust/basic_bitcoin/backend/src/lib.rs +++ b/rust/basic_bitcoin/backend/src/lib.rs @@ -90,4 +90,28 @@ pub struct SendRequest { pub amount_in_satoshi: u64, } +/// Input structure for transferring Rune tokens to another address. +#[derive(candid::CandidType, candid::Deserialize)] +pub struct TransferRuneRequest { + /// Block height of the etching transaction (first component of the rune ID). + pub rune_id_block: u64, + /// Transaction index within that block (second component of the rune ID). + pub rune_id_tx: u32, + /// Number of rune tokens to send. + pub amount: u64, + /// Bitcoin address of the recipient. + pub destination_address: String, +} + +/// Input structure for transferring BRC-20 tokens to another address. +#[derive(candid::CandidType, candid::Deserialize)] +pub struct TransferBrc20Request { + /// 4-character BRC-20 ticker symbol (e.g. "DEMO"). + pub tick: String, + /// Number of tokens to transfer. + pub amount: u64, + /// Bitcoin address of the recipient. + pub destination_address: String, +} + ic_cdk::export_candid!(); diff --git a/rust/basic_bitcoin/backend/src/runes.rs b/rust/basic_bitcoin/backend/src/runes.rs index e203fceb70..0a5768607a 100644 --- a/rust/basic_bitcoin/backend/src/runes.rs +++ b/rust/basic_bitcoin/backend/src/runes.rs @@ -131,6 +131,74 @@ pub struct Terms { pub offset: (Option, Option), // Relative block height range } +/// An edict in a Runestone that transfers rune tokens to a specific transaction output. +/// +/// Edicts describe how to allocate rune balances among transaction outputs. +/// The rune ID (block + tx) uniquely identifies which rune to transfer. +pub struct Edict { + /// Block height where the rune was etched (first component of rune ID). + pub rune_id_block: u64, + /// Transaction index within that block (second component of rune ID). + pub rune_id_tx: u32, + /// Number of rune tokens to move to the specified output. + pub amount: u64, + /// Index into the transaction's vout array that receives the rune tokens. + /// OP_RETURN outputs count toward the index but cannot hold rune balances. + pub output: u32, +} + +/// Builds a runestone script for transferring rune tokens between addresses. +/// +/// A transfer runestone contains only edicts — no etching fields. The edicts +/// direct the protocol to move tokens from input UTXOs holding rune balances +/// to specific transaction outputs. Any balance not explicitly allocated by an +/// edict flows to the last non-OP_RETURN output by default. +pub fn build_transfer_script(edicts: &[Edict]) -> Result { + if edicts.is_empty() { + return Err("At least one edict is required".to_string()); + } + + let mut payload = Vec::new(); + + // Tag 0 (Body): separator signalling that edicts follow. + // A pure transfer runestone has no named fields before this. + payload.extend_from_slice(&encode_leb128(Tag::Body as u64)); + + // Edicts are encoded as groups of 4 LEB128 integers: [block, tx, amount, output]. + // Block and tx use delta encoding relative to the previous edict's rune ID. + let mut prev_block: u64 = 0; + let mut prev_tx: u32 = 0; + for edict in edicts { + let block_delta = edict.rune_id_block - prev_block; + // When the block delta is 0 (same block), tx is a delta from the previous tx. + // When the block delta is non-zero (different block), tx is an absolute value. + let tx_encoded = if block_delta == 0 { + (edict.rune_id_tx - prev_tx) as u64 + } else { + edict.rune_id_tx as u64 + }; + + payload.extend_from_slice(&encode_leb128(block_delta)); + payload.extend_from_slice(&encode_leb128(tx_encoded)); + payload.extend_from_slice(&encode_leb128(edict.amount)); + payload.extend_from_slice(&encode_leb128(edict.output as u64)); + + prev_block = edict.rune_id_block; + prev_tx = edict.rune_id_tx; + } + + let mut builder = Builder::new().push_opcode(OP_RETURN); + builder = builder.push_opcode(OP_PUSHNUM_13); + + 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()) +} + /// Builds a runestone script for etching a new rune token. /// /// The runestone is encoded as an OP_RETURN output with the format: diff --git a/rust/basic_bitcoin/backend/src/service.rs b/rust/basic_bitcoin/backend/src/service.rs index aa368c909a..887e4304c2 100644 --- a/rust/basic_bitcoin/backend/src/service.rs +++ b/rust/basic_bitcoin/backend/src/service.rs @@ -1,4 +1,5 @@ pub mod etch_rune; +pub mod transfer_rune; pub mod get_balance; pub mod get_blockchain_info; pub mod get_block_headers; @@ -9,6 +10,8 @@ pub mod get_p2tr_script_path_enabled_address; pub mod get_p2wpkh_address; pub mod get_utxos; pub mod inscribe_brc20; +pub mod mint_brc20; +pub mod transfer_brc20; pub mod inscribe_ordinal; pub mod send_from_p2pkh_address; pub mod send_from_p2tr_key_path_only_address; diff --git a/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs b/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs index ea3e26999b..2e4077dcac 100644 --- a/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs +++ b/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs @@ -3,23 +3,14 @@ // 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}, + brc20::commit_and_reveal, + common::DerivationPath, + schnorr::get_schnorr_public_key, BTC_CONTEXT, }; -use bitcoin::{ - consensus::serialize, - secp256k1::{PublicKey, Secp256k1}, - taproot::{LeafVersion, TaprootBuilder}, - Address, XOnlyPublicKey, -}; +use bitcoin::secp256k1::{PublicKey, Secp256k1}; +use bitcoin::{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. /// @@ -48,161 +39,22 @@ pub async fn inscribe_brc20(tick: String) -> String { 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(); + + // BRC-20 deploy JSON: defines the token with max supply and per-mint limit. 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() + // Derive the funding address that receives the inscription output. + let internal_key_path = DerivationPath::p2tr(0, 0); + let internal_key_bytes = + get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; + let internal_key = + XOnlyPublicKey::from(PublicKey::from_slice(&internal_key_bytes).unwrap()); + let secp = Secp256k1::new(); + let funding_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); + + commit_and_reveal(&ctx, &brc20_json, &funding_address).await } diff --git a/rust/basic_bitcoin/backend/src/service/mint_brc20.rs b/rust/basic_bitcoin/backend/src/service/mint_brc20.rs new file mode 100644 index 0000000000..66a7e0d1b2 --- /dev/null +++ b/rust/basic_bitcoin/backend/src/service/mint_brc20.rs @@ -0,0 +1,59 @@ +// This module implements BRC-20 token minting functionality. +// A mint inscription claims a portion of the token supply (up to the per-mint limit +// set in the deploy inscription) and assigns it to the address that holds the inscription. + +use crate::{ + brc20::commit_and_reveal, + common::DerivationPath, + schnorr::get_schnorr_public_key, + BTC_CONTEXT, +}; +use bitcoin::secp256k1::{PublicKey, Secp256k1}; +use bitcoin::{Address, XOnlyPublicKey}; +use ic_cdk::{trap, update}; + +/// Mints BRC-20 tokens by inscribing a mint operation on the Bitcoin blockchain. +/// +/// A mint inscription claims tokens from the deployed supply by creating an inscription +/// with the BRC-20 mint JSON at the canister's address. BRC-20 indexers recognise this +/// and credit the canister's balance with the specified amount (subject to the deploy's +/// per-mint limit). +/// +/// The minted tokens are credited to the address that holds the mint inscription — +/// in this case, the canister's P2TR key-path address. Tokens must be minted before +/// they can be transferred. +#[update] +pub async fn mint_brc20(tick: String, amount: u64) -> 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"); + } + + if amount == 0 { + trap("Amount must be greater than 0"); + } + + let tick = tick.to_uppercase(); + + // BRC-20 mint JSON: claims `amount` tokens from the deployed supply. + let brc20_json = format!( + r#"{{"p":"brc-20","op":"mint","tick":"{}","amt":"{}"}}"#, + tick, amount + ); + + // Mint inscription goes to the canister's own P2TR address to credit its balance. + let internal_key_path = DerivationPath::p2tr(0, 0); + let internal_key_bytes = + get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; + let internal_key = + XOnlyPublicKey::from(PublicKey::from_slice(&internal_key_bytes).unwrap()); + let secp = Secp256k1::new(); + let own_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); + + commit_and_reveal(&ctx, &brc20_json, &own_address).await +} diff --git a/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs b/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs new file mode 100644 index 0000000000..ccd90f6e66 --- /dev/null +++ b/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs @@ -0,0 +1,168 @@ +// This module implements BRC-20 token transfer functionality. +// +// A BRC-20 transfer requires two steps per the protocol specification: +// 1. Inscribe a transfer: Create an inscription with the transfer JSON at the SENDER's +// address. This "locks" the specified token amount from the sender's balance. +// 2. Send the inscription: Move the UTXO holding that inscription to the RECIPIENT's +// address. BRC-20 indexers credit the recipient when they see the inscription move. +// +// This function chains three Bitcoin transactions in a single canister call: +// Commit TX: Funds the Taproot commit address (contains the BRC-20 transfer script). +// Reveal TX: Spends the commit output, revealing the JSON and outputting the inscription +// to the canister's own address (i.e. the sender). +// Send TX: Spends the inscription UTXO from the canister's address to the recipient. + +use crate::{ + brc20::commit_and_reveal, + common::{get_fee_per_byte, DerivationPath}, + p2tr, + schnorr::{get_schnorr_public_key, mock_sign_with_schnorr, sign_with_schnorr}, + TransferBrc20Request, BTC_CONTEXT, +}; +use bitcoin::{ + absolute::LockTime, + blockdata::witness::Witness, + consensus::serialize, + + secp256k1::{PublicKey, Secp256k1}, + transaction::Version, + Address, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, XOnlyPublicKey, +}; +use ic_cdk::{trap, update}; +use ic_cdk_bitcoin_canister::{bitcoin_send_transaction, SendTransactionRequest}; +use std::str::FromStr; + +/// Transfers BRC-20 tokens from the canister's address to a recipient. +/// +/// Three transactions are chained within this call: +/// 1. Commit: Sends funds to a Taproot address committed to the transfer script. +/// 2. Reveal: Reveals the transfer JSON at the canister's own address, locking +/// `amount` tokens from the canister's BRC-20 balance into the inscription. +/// 3. Send: Moves the inscription UTXO to the recipient, crediting their balance. +/// +/// BRC-20 indexers recognise the inscription travelling from sender → recipient as +/// a token transfer and update balances accordingly. +/// +/// The canister must have enough minted balance before calling this function. Use +/// `mint_brc20` to acquire tokens after deploying with `inscribe_brc20`. +#[update] +pub async fn transfer_brc20(request: TransferBrc20Request) -> String { + let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); + + if request.tick.len() != 4 { + trap("BRC-20 ticker must be exactly 4 characters"); + } + + if request.amount == 0 { + trap("Amount must be greater than 0"); + } + + let recipient = Address::from_str(&request.destination_address) + .unwrap() + .require_network(ctx.bitcoin_network) + .unwrap(); + + let tick = request.tick.to_uppercase(); + + // Derive the canister's P2TR funding address (the "sender" whose balance is debited). + let internal_key_path = DerivationPath::p2tr(0, 0); + let internal_key_bytes = + get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; + let internal_key = + XOnlyPublicKey::from(PublicKey::from_slice(&internal_key_bytes).unwrap()); + let secp = Secp256k1::new(); + let own_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); + + // BRC-20 transfer JSON: locks `amount` tokens from the sender's balance. + let brc20_json = format!( + r#"{{"p":"brc-20","op":"transfer","tick":"{}","amt":"{}"}}"#, + tick, request.amount + ); + + // --- Step 1 & 2: Commit + Reveal --- + // The reveal output goes to the canister's own address, placing the transfer + // inscription at the sender's address as required by the BRC-20 spec. + // `commit_and_reveal` returns the reveal txid; the inscription is at vout 0. + let reveal_txid_str = commit_and_reveal(&ctx, &brc20_json, &own_address).await; + + // --- Step 3: Send the inscription UTXO to the recipient --- + // Spending reveal_txid:0 moves the inscription from sender to recipient, + // which BRC-20 indexers interpret as the token transfer completing. + let reveal_txid = Txid::from_str(&reveal_txid_str).unwrap(); + + // The reveal output value was set by ordinals::INSCRIPTION_OUTPUT_VALUE minus reveal fee. + // We query it from the reveal transaction's known constant rather than waiting for + // confirmation — Bitcoin allows spending unconfirmed outputs (CPFP). + // Use a conservative estimate: INSCRIPTION_OUTPUT_VALUE (15_000 sat) minus a + // generous max reveal fee of 2_000 sat leaves at least 13_000 sat for the send step. + // The iterative fee loop below adjusts the exact send output value. + const MIN_INSCRIPTION_VALUE: u64 = 13_000; + + let send_prevout = TxOut { + value: Amount::from_sat(MIN_INSCRIPTION_VALUE), + script_pubkey: own_address.script_pubkey(), + }; + + let fee_per_byte = get_fee_per_byte(&ctx).await; + let mut send_fee = 0u64; + + let signed_send_tx = loop { + let output_value = MIN_INSCRIPTION_VALUE + .checked_sub(send_fee) + .unwrap_or_else(|| trap("inscription value insufficient to cover send fee")); + + let send_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: reveal_txid, + vout: 0, + }, + sequence: Sequence::MAX, + witness: Witness::new(), + script_sig: ScriptBuf::new(), + }], + output: vec![TxOut { + script_pubkey: recipient.script_pubkey(), + value: Amount::from_sat(output_value), + }], + }; + + let mock_signed = p2tr::sign_transaction_key_spend( + &ctx, + &own_address, + send_tx.clone(), + &[send_prevout.clone()], + vec![], + vec![], + mock_sign_with_schnorr, + ) + .await; + + let new_fee = (mock_signed.vsize() as u64 * fee_per_byte) / 1000; + if new_fee == send_fee { + let signed = p2tr::sign_transaction_key_spend( + &ctx, + &own_address, + send_tx, + &[send_prevout.clone()], + internal_key_path.to_vec_u8_path(), + vec![], + sign_with_schnorr, + ) + .await; + break signed; + } + send_fee = new_fee; + }; + + bitcoin_send_transaction(&SendTransactionRequest { + network: ctx.network.into(), + transaction: serialize(&signed_send_tx), + }) + .await + .unwrap(); + + signed_send_tx.compute_txid().to_string() +} diff --git a/rust/basic_bitcoin/backend/src/service/transfer_rune.rs b/rust/basic_bitcoin/backend/src/service/transfer_rune.rs new file mode 100644 index 0000000000..a7909250b8 --- /dev/null +++ b/rust/basic_bitcoin/backend/src/service/transfer_rune.rs @@ -0,0 +1,213 @@ +// This module implements Bitcoin Runes token transfer functionality. +// A rune transfer creates a Bitcoin transaction with a Runestone in an OP_RETURN +// output. The Runestone contains an edict that tells the protocol how to allocate +// rune balances from the spent UTXOs to the transaction outputs. + +use crate::{ + common::{get_fee_per_byte, select_utxos_greedy, DerivationPath}, + p2tr, + runes::{build_transfer_script, Edict}, + schnorr::{get_schnorr_public_key, mock_sign_with_schnorr, sign_with_schnorr}, + TransferRuneRequest, BTC_CONTEXT, +}; +use bitcoin::{ + absolute::LockTime, + blockdata::witness::Witness, + consensus::serialize, + hashes::Hash, + secp256k1::{PublicKey, Secp256k1}, + transaction::Version, + Address, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, XOnlyPublicKey, +}; +use ic_cdk::{trap, update}; +use ic_cdk_bitcoin_canister::{ + bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, Utxo, +}; +use std::str::FromStr; + +// Minimum satoshi value attached to the recipient output. +// Bitcoin nodes reject outputs below the dust threshold (~330–546 sat depending on type). +// 1000 sat is a safe round number above all thresholds. +const DUST_AMOUNT: u64 = 1_000; + +/// Transfers rune tokens from the canister's P2TR address to a recipient address. +/// +/// The Runes protocol tracks token balances per UTXO. This function builds a +/// Bitcoin transaction whose Runestone directs the rune balance from the spent +/// input UTXOs to the specified recipient output. +/// +/// Transaction output layout (vout indices matter for the edict): +/// vout[0] — recipient: receives the rune tokens + DUST_AMOUNT satoshis +/// vout[1] — OP_RETURN: Runestone with the transfer edict (no bitcoin value) +/// vout[2] — change: receives unallocated rune tokens + remaining satoshis +/// +/// The rune ID (`rune_id_block` : `rune_id_tx`) can be found in the ord explorer +/// at http://127.0.0.1/rune/ after starting `ord --config-dir . server`. +#[update] +pub async fn transfer_rune(request: TransferRuneRequest) -> String { + let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); + + if request.amount == 0 { + trap("Amount must be greater than 0"); + } + + // Parse and validate the destination address for the current network. + let destination_address = Address::from_str(&request.destination_address) + .unwrap() + .require_network(ctx.bitcoin_network) + .unwrap(); + + // Derive the P2TR key used in etch_rune — same path means same address and rune balance. + 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()); + + let secp256k1_engine = Secp256k1::new(); + let own_address = Address::p2tr(&secp256k1_engine, internal_key, None, ctx.bitcoin_network); + + // Fetch UTXOs holding the rune balance at the canister's P2TR address. + let own_utxos = bitcoin_get_utxos(&GetUtxosRequest { + address: own_address.to_string(), + network: ctx.network.into(), + filter: None, + }) + .await + .unwrap() + .utxos; + + // Build the Runestone: a single edict sends `amount` tokens of `rune_id` to vout[0]. + // Unallocated tokens (remainder after the edict) flow to the last non-OP_RETURN output + // (vout[2], the change output) by default. + let runestone_script = build_transfer_script(&[Edict { + rune_id_block: request.rune_id_block, + rune_id_tx: request.rune_id_tx, + amount: request.amount, + output: 0, // vout[0] is the recipient + }]) + .unwrap_or_else(|e| trap(format!("Failed to build runestone: {}", e))); + + // Iterative fee estimation: mock-sign to measure vsize, adjust fee, repeat until stable. + let fee_per_byte = get_fee_per_byte(&ctx).await; + let mut total_fee = 0u64; + let (transaction, prevouts) = loop { + let utxos_to_spend = + select_utxos_greedy(&own_utxos, DUST_AMOUNT, total_fee).unwrap_or_else(|e| trap(e)); + + let (tx, prevouts) = build_rune_transfer_tx( + &utxos_to_spend, + &own_address, + &destination_address, + &runestone_script, + total_fee, + ); + + let signed_tx = p2tr::sign_transaction_key_spend( + &ctx, + &own_address, + tx.clone(), + &prevouts, + vec![], + vec![], + mock_sign_with_schnorr, + ) + .await; + + let new_fee = (signed_tx.vsize() as u64 * fee_per_byte) / 1000; + if new_fee == total_fee { + break (tx, prevouts); + } + total_fee = new_fee; + }; + + // Sign with the real Schnorr key and broadcast. + 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; + + bitcoin_send_transaction(&SendTransactionRequest { + network: ctx.network.into(), + transaction: serialize(&signed_transaction), + }) + .await + .unwrap(); + + signed_transaction.compute_txid().to_string() +} + +/// Constructs the unsigned transaction for a rune transfer. +/// +/// Output order is significant: the edict in the Runestone references outputs by +/// their vout index, so recipient must be at vout[0] for `output: 0` to be correct. +fn build_rune_transfer_tx( + utxos_to_spend: &[&Utxo], + own_address: &Address, + destination_address: &Address, + runestone_script: &ScriptBuf, + fee: u64, +) -> (Transaction, Vec) { + const DUST_THRESHOLD: u64 = 1_000; + + let inputs: Vec = utxos_to_spend + .iter() + .map(|utxo| TxIn { + previous_output: OutPoint { + txid: Txid::from_raw_hash(Hash::from_slice(utxo.outpoint.txid.as_ref()).unwrap()), + vout: utxo.outpoint.vout, + }, + sequence: Sequence::MAX, + witness: Witness::new(), + script_sig: ScriptBuf::new(), + }) + .collect(); + + let prevouts: Vec = utxos_to_spend + .iter() + .map(|utxo| TxOut { + value: Amount::from_sat(utxo.value), + script_pubkey: own_address.script_pubkey(), + }) + .collect(); + + let total_in: u64 = utxos_to_spend.iter().map(|u| u.value).sum(); + let change = total_in + .checked_sub(DUST_AMOUNT + fee) + .unwrap_or_else(|| trap("fee exceeds inputs")); + + // vout[0]: recipient — edict output 0, receives rune tokens + dust satoshis + // vout[1]: OP_RETURN — Runestone data, carries no bitcoin value + // vout[2]: change — receives unallocated rune tokens and remaining satoshis + let mut outputs = vec![ + TxOut { + script_pubkey: destination_address.script_pubkey(), + value: Amount::from_sat(DUST_AMOUNT), + }, + TxOut { + script_pubkey: runestone_script.clone(), + value: Amount::from_sat(0), + }, + ]; + + if change >= DUST_THRESHOLD { + outputs.push(TxOut { + script_pubkey: own_address.script_pubkey(), + value: Amount::from_sat(change), + }); + } + + ( + Transaction { + input: inputs, + output: outputs, + lock_time: LockTime::ZERO, + version: Version::TWO, + }, + prevouts, + ) +} From 5c55af4c0ec0dafae5803eaba95c4ecebe8d4c77 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 14:35:19 +0200 Subject: [PATCH 16/33] docs(rust/basic_bitcoin): fix macOS LLVM prerequisite to include PATH setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brew install step alone is not enough — Homebrew installs LLVM as keg-only, so clang is not on PATH until explicitly added. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 2c68737d48..184b14831e 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -24,7 +24,12 @@ This example integrates with the Internet Computer's built-in: - [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, 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`. +- 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. ### Install From 71a8bdf4a2584b7d591c4cdd287ef4c35d692ae7 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 14:37:12 +0200 Subject: [PATCH 17/33] docs(rust/basic_bitcoin): restructure Runes into single section with subsections Matches the BRC-20 section structure so etch and transfer read as one cohesive flow rather than two disconnected top-level sections. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 184b14831e..bfd23060ea 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -268,17 +268,19 @@ echo "http://127.0.0.1/inscription/${TXID}i0" > **Note:** The homepage at `http://127.0.0.1/` shows a "Latest Inscriptions" section that may appear empty — use `/inscriptions` instead. -## Etch a Rune +## Runes -[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. +[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. Two operations make up the full lifecycle: **etch** → **transfer**. -### Step 1 — Start the ord server (separate terminal) +### Etch a Rune + +#### Step 1 — Start the ord server (separate terminal) ```bash ord --config-dir . server ``` -### Step 2 — Fund a Taproot address +#### Step 2 — Fund a Taproot address ```bash ADDR=$(icp canister call backend get_p2tr_key_path_only_address '()' | grep -o '"[^"]*"' | tr -d '"') @@ -287,7 +289,7 @@ docker exec $CONTAINER bitcoin-cli -regtest \ generatetoaddress 101 "$ADDR" ``` -### Step 3 — Etch the Rune +#### Step 3 — Etch the Rune The name must be uppercase, maximum 28 characters: @@ -296,7 +298,7 @@ TXID=$(icp canister call backend etch_rune '("ICPRUNE")' | grep -o '"[^"]*"' | t echo "Rune txid: $TXID" ``` -### Step 4 — Mine a confirmation block +#### Step 4 — Mine a confirmation block ```bash docker exec $CONTAINER bitcoin-cli -regtest \ @@ -304,13 +306,13 @@ docker exec $CONTAINER bitcoin-cli -regtest \ generatetoaddress 1 "$ADDR" ``` -### Step 5 — Decode the Runestone to verify +#### Step 5 — Decode the Runestone to verify ```bash ord --config-dir . decode --txid "$TXID" ``` -### Step 6 — View in the ord explorer +#### Step 6 — View in the ord explorer ```bash echo "http://127.0.0.1/rune/ICPRUNE" @@ -322,11 +324,9 @@ echo "http://127.0.0.1/rune/ICPRUNE" The Rune is now etched with 1,000,000 tokens minted to your address. -## Transfer a Rune - -This section shows how to transfer rune tokens from the canister's address to another address, proving the rune is functional and balances update on-chain. +### Transfer a Rune -### Step 1 — Look up the rune ID +#### Step 1 — Look up the rune ID The rune ID (block height : transaction index) is required to identify which rune to transfer. Fetch it from the ord server: @@ -337,7 +337,7 @@ RUNE_TX=$(echo "$RUNE_ID" | cut -d: -f2) echo "Rune ID: $RUNE_BLOCK:$RUNE_TX" ``` -### Step 2 — Transfer rune tokens +#### Step 2 — Transfer rune tokens ```bash DEST="bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" @@ -350,7 +350,7 @@ TRANSFER_TXID=$(icp canister call backend transfer_rune "(record { echo "Transfer txid: $TRANSFER_TXID" ``` -### Step 3 — Mine a confirmation block +#### Step 3 — Mine a confirmation block ```bash docker exec $CONTAINER bitcoin-cli -regtest \ @@ -358,7 +358,7 @@ docker exec $CONTAINER bitcoin-cli -regtest \ generatetoaddress 1 "$ADDR" ``` -### Step 4 — Verify the transfer +#### Step 4 — Verify the transfer Decode the transfer transaction to confirm the Runestone edict is correct: From 6edb7f524686cbc8227bf3c0b9037fa330c76838 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 14:39:26 +0200 Subject: [PATCH 18/33] docs(rust/basic_bitcoin): fix rune ID extraction from ord JSON response The ord server returns pretty-printed JSON ("id": "204:1" with a space), so strip whitespace before grepping. Also tighten the pattern to [0-9]*:[0-9]* to avoid false matches against other id fields in the response. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index bfd23060ea..7067dab6cf 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -331,7 +331,8 @@ The Rune is now etched with 1,000,000 tokens minted to your address. The rune ID (block height : transaction index) is required to identify which rune to transfer. Fetch it from the ord server: ```bash -RUNE_ID=$(curl -s -H "Accept: application/json" http://127.0.0.1/rune/ICPRUNE | grep -o '"id":"[^"]*"' | cut -d'"' -f4) +RUNE_ID=$(curl -s -H "Accept: application/json" http://127.0.0.1/rune/ICPRUNE | \ + tr -d '[:space:]' | grep -o '"id":"[0-9]*:[0-9]*"' | cut -d'"' -f4) RUNE_BLOCK=$(echo "$RUNE_ID" | cut -d: -f1) RUNE_TX=$(echo "$RUNE_ID" | cut -d: -f2) echo "Rune ID: $RUNE_BLOCK:$RUNE_TX" From 1c818a8a5c65e42658465458ae4cf6e712478de6 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 14:50:57 +0200 Subject: [PATCH 19/33] fix(rust/basic_bitcoin): use 12-char rune name to avoid unlock height in regtest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short rune names (< 12 chars) have unlock heights of 17,500–105,000+ blocks in regtest. ICPRUNE (7 chars) won't get an active rune ID until block ~87,500, making the transfer walkthrough impossible. BASICBITCOIN (12 chars) is valid from block 0, so ord assigns a rune ID immediately after confirmation. Also corrects the turbo mode comment in etch_rune.rs: turbo opts into future protocol upgrades, it does not bypass the name unlock schedule. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 14 +++++++++----- .../basic_bitcoin/backend/src/service/etch_rune.rs | 6 +++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 7067dab6cf..3c5d5a6a75 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -291,10 +291,12 @@ docker exec $CONTAINER bitcoin-cli -regtest \ #### Step 3 — Etch the Rune -The name must be uppercase, maximum 28 characters: +The name must be uppercase and between 1–28 characters. The Runes protocol reserves short names until a future Bitcoin block height — names with 12 or more characters are available immediately in regtest. Using a shorter name (e.g. 7 characters) means the rune won't receive an active ID until block ~87,500+ is mined, making it unusable for transfers. + +> **Turbo mode**: The `etch_rune` implementation sets `turbo: true`, which opts the rune into future ord protocol upgrades. It does **not** bypass the name unlock schedule. ```bash -TXID=$(icp canister call backend etch_rune '("ICPRUNE")' | grep -o '"[^"]*"' | tr -d '"') +TXID=$(icp canister call backend etch_rune '("BASICBITCOIN")' | grep -o '"[^"]*"' | tr -d '"') echo "Rune txid: $TXID" ``` @@ -315,7 +317,7 @@ ord --config-dir . decode --txid "$TXID" #### Step 6 — View in the ord explorer ```bash -echo "http://127.0.0.1/rune/ICPRUNE" +echo "http://127.0.0.1/rune/BASICBITCOIN" ``` - All runes: `http://127.0.0.1/runes` @@ -328,16 +330,18 @@ The Rune is now etched with 1,000,000 tokens minted to your address. #### Step 1 — Look up the rune ID -The rune ID (block height : transaction index) is required to identify which rune to transfer. Fetch it from the ord server: +The rune ID (block height : transaction index) is required to identify which rune to transfer. Fetch it from the ord JSON API: ```bash -RUNE_ID=$(curl -s -H "Accept: application/json" http://127.0.0.1/rune/ICPRUNE | \ +RUNE_ID=$(curl -s -H "Accept: application/json" http://127.0.0.1/rune/BASICBITCOIN | \ tr -d '[:space:]' | grep -o '"id":"[0-9]*:[0-9]*"' | cut -d'"' -f4) RUNE_BLOCK=$(echo "$RUNE_ID" | cut -d: -f1) RUNE_TX=$(echo "$RUNE_ID" | cut -d: -f2) echo "Rune ID: $RUNE_BLOCK:$RUNE_TX" ``` +> **Why 12+ characters?** The ord indexer only assigns a rune ID once the rune's name reaches its unlock height. Names shorter than 12 characters have unlock heights of 17,500–105,000+ blocks in regtest, which is impractical. With a 12-character name like `BASICBITCOIN`, the rune is immediately active and the JSON response includes its `"id"` field. + #### Step 2 — Transfer rune tokens ```bash diff --git a/rust/basic_bitcoin/backend/src/service/etch_rune.rs b/rust/basic_bitcoin/backend/src/service/etch_rune.rs index 211c68fe60..0d593dc440 100644 --- a/rust/basic_bitcoin/backend/src/service/etch_rune.rs +++ b/rust/basic_bitcoin/backend/src/service/etch_rune.rs @@ -83,9 +83,9 @@ pub async fn etch_rune(name: String) -> String { rune_name: name.clone(), symbol: Some('🪙'), // Unicode coin symbol for display terms: None, // No open minting allowed - // Turbo mode bypasses the name unlock schedule so any name can be etched - // immediately, regardless of the current block height. Without this flag, - // short names (< 13 chars) are reserved until a high future block height. + // Turbo mode opts this rune into future protocol upgrades proposed by the + // ord developers. It does NOT bypass the name unlock schedule — use a + // name of 12+ characters to ensure the rune is active from block 0 in regtest. turbo: true, spacers: 0, // No visual spacers in the name }; From 40fb6319f294365faa2c729ccfa4175a06e0b0eb Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 15:12:44 +0200 Subject: [PATCH 20/33] fix(rust/basic_bitcoin): correct BRC-20 content type and rune name length Two protocol correctness fixes: 1. BRC-20 inscriptions must use `text/plain;charset=utf-8` as content type (not `application/json`). BRC-20 indexers check the content type field and will ignore inscriptions with the wrong MIME type. 2. Rune names need 13+ characters for immediate activation in regtest. The ord `minimum_at_height` formula uses linear interpolation between STEPS[] boundary values. All 13-char names have values above STEPS[12] (= 99,246,114,928,149,462), the highest minimum the formula produces, so they are active from block 0. 12-char names like BASICBITCOIN have their own unlock height (e.g. 16,808) that must be mined past first. Updated README to use BASICBITCOINS (13 chars) and corrected the note. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 10 +++++----- rust/basic_bitcoin/backend/src/brc20.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 3c5d5a6a75..ba8aa71ecf 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -291,12 +291,12 @@ docker exec $CONTAINER bitcoin-cli -regtest \ #### Step 3 — Etch the Rune -The name must be uppercase and between 1–28 characters. The Runes protocol reserves short names until a future Bitcoin block height — names with 12 or more characters are available immediately in regtest. Using a shorter name (e.g. 7 characters) means the rune won't receive an active ID until block ~87,500+ is mined, making it unusable for transfers. +The name must be uppercase and between 1–28 characters. The Runes protocol uses a block-height-based unlock schedule that interpolates over a 210,000-block window — **names with 13 or more characters are immediately active** in regtest at any block height. Names shorter than 13 characters have an unlock height that must be mined past before the rune receives an ID and can be transferred. > **Turbo mode**: The `etch_rune` implementation sets `turbo: true`, which opts the rune into future ord protocol upgrades. It does **not** bypass the name unlock schedule. ```bash -TXID=$(icp canister call backend etch_rune '("BASICBITCOIN")' | grep -o '"[^"]*"' | tr -d '"') +TXID=$(icp canister call backend etch_rune '("BASICBITCOINSS")' | grep -o '"[^"]*"' | tr -d '"') echo "Rune txid: $TXID" ``` @@ -317,7 +317,7 @@ ord --config-dir . decode --txid "$TXID" #### Step 6 — View in the ord explorer ```bash -echo "http://127.0.0.1/rune/BASICBITCOIN" +echo "http://127.0.0.1/rune/BASICBITCOINS" ``` - All runes: `http://127.0.0.1/runes` @@ -333,14 +333,14 @@ The Rune is now etched with 1,000,000 tokens minted to your address. The rune ID (block height : transaction index) is required to identify which rune to transfer. Fetch it from the ord JSON API: ```bash -RUNE_ID=$(curl -s -H "Accept: application/json" http://127.0.0.1/rune/BASICBITCOIN | \ +RUNE_ID=$(curl -s -H "Accept: application/json" http://127.0.0.1/rune/BASICBITCOINS | \ tr -d '[:space:]' | grep -o '"id":"[0-9]*:[0-9]*"' | cut -d'"' -f4) RUNE_BLOCK=$(echo "$RUNE_ID" | cut -d: -f1) RUNE_TX=$(echo "$RUNE_ID" | cut -d: -f2) echo "Rune ID: $RUNE_BLOCK:$RUNE_TX" ``` -> **Why 12+ characters?** The ord indexer only assigns a rune ID once the rune's name reaches its unlock height. Names shorter than 12 characters have unlock heights of 17,500–105,000+ blocks in regtest, which is impractical. With a 12-character name like `BASICBITCOIN`, the rune is immediately active and the JSON response includes its `"id"` field. +> **Why 13+ characters?** The ord indexer uses linear interpolation to derive a minimum rune value per block. Every 13-char name has a numeric value above `STEPS[12] = 99,246,114,928,149,462` — the highest minimum the formula ever produces — so 13-char names are immediately active at any block height. Shorter names have unlock heights that must be mined past (12-char names unlock across blocks 0–17,499; 7-char names across 87,500–104,999). #### Step 2 — Transfer rune tokens diff --git a/rust/basic_bitcoin/backend/src/brc20.rs b/rust/basic_bitcoin/backend/src/brc20.rs index dd46d904b4..82ccda0d72 100644 --- a/rust/basic_bitcoin/backend/src/brc20.rs +++ b/rust/basic_bitcoin/backend/src/brc20.rs @@ -53,7 +53,7 @@ pub fn build_brc20_reveal_script(internal_key: &XOnlyPublicKey, brc20_json: &str .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_slice(b"text/plain;charset=utf-8") // BRC-20 spec requires text/plain, not application/json .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 From 38f55cfa1a5d593ad4929e3c14983d23b8e0a05e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 15:23:35 +0200 Subject: [PATCH 21/33] fix(rust/basic_bitcoin): store ord index in project directory Add data_dir: . to ord.yaml so the ord index (index.redb) is written into the example directory rather than ~/Library/Application Support/ord. This keeps each example's index isolated and makes cleanup simple: delete index.redb to start fresh. Also gitignore index.redb and update the README note with the correct path. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/.gitignore | 3 +++ rust/basic_bitcoin/README.md | 2 ++ rust/basic_bitcoin/ord.yaml | 1 + 3 files changed, 6 insertions(+) diff --git a/rust/basic_bitcoin/.gitignore b/rust/basic_bitcoin/.gitignore index f8454968c7..1b2d498568 100644 --- a/rust/basic_bitcoin/.gitignore +++ b/rust/basic_bitcoin/.gitignore @@ -16,4 +16,7 @@ target/ # bitcoin bitcoin_data +# ord +index.redb + canister_ids.json diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index ba8aa71ecf..79c61ca4ed 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -218,6 +218,8 @@ CONTAINER=$(docker ps --filter "ancestor=icp-cli-network-launcher-bitcoin" --for > **Note:** This repository includes a [default ord config file](./ord.yaml) that matches the also provided [bitcoin config file](./bitcoin.conf). +> **Fresh network:** When you restart with `icp network start`, the ord server may show stale data from a previous session. Ord stores its index in `index.redb` (inside the `basic_bitcoin` directory) and does not clear it automatically when the Bitcoin node resets. Ord detects the chain change and reindexes as soon as the first block is mined in the new instance — the mining step in the walkthrough below takes care of this. To start completely clean, delete `index.redb` before starting the ord server. + > **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`: > > ``` diff --git a/rust/basic_bitcoin/ord.yaml b/rust/basic_bitcoin/ord.yaml index 40e0353f6b..dd404c0ba1 100644 --- a/rust/basic_bitcoin/ord.yaml +++ b/rust/basic_bitcoin/ord.yaml @@ -3,6 +3,7 @@ bitcoin_rpc_password: ic-btc-integration bitcoin_rpc_url: http://127.0.0.1:18443 bitcoin_rpc_username: ic-btc-integration chain: regtest +data_dir: . index_addresses: true index_runes: true index_sats: true From 2927358692ea31ed7b9e3ed0a7120c6fbc138e19 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 15:27:14 +0200 Subject: [PATCH 22/33] fix(rust/basic_bitcoin): use ord-db/ as named data directory Change data_dir from . to ./ord-db so the index lands in a clearly named directory (ord-db/regtest/, ord-db/testnet4/, etc. per chain). Gitignore the directory instead of the individual index.redb file and update the README note accordingly. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/.gitignore | 2 +- rust/basic_bitcoin/README.md | 2 +- rust/basic_bitcoin/ord.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/basic_bitcoin/.gitignore b/rust/basic_bitcoin/.gitignore index 1b2d498568..1ee740a699 100644 --- a/rust/basic_bitcoin/.gitignore +++ b/rust/basic_bitcoin/.gitignore @@ -17,6 +17,6 @@ target/ bitcoin_data # ord -index.redb +ord-db/ canister_ids.json diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 79c61ca4ed..b3801eb549 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -218,7 +218,7 @@ CONTAINER=$(docker ps --filter "ancestor=icp-cli-network-launcher-bitcoin" --for > **Note:** This repository includes a [default ord config file](./ord.yaml) that matches the also provided [bitcoin config file](./bitcoin.conf). -> **Fresh network:** When you restart with `icp network start`, the ord server may show stale data from a previous session. Ord stores its index in `index.redb` (inside the `basic_bitcoin` directory) and does not clear it automatically when the Bitcoin node resets. Ord detects the chain change and reindexes as soon as the first block is mined in the new instance — the mining step in the walkthrough below takes care of this. To start completely clean, delete `index.redb` before starting the ord server. +> **Fresh network:** When you restart with `icp network start`, the ord server may show stale data from a previous session. Ord stores its index in `ord-db/regtest/` (inside the `basic_bitcoin` directory) and does not clear it automatically when the Bitcoin node resets. Ord detects the chain change and reindexes as soon as the first block is mined in the new instance — the mining step in the walkthrough below takes care of this. To start completely clean, delete the `ord-db/` directory before starting the ord server. > **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`: > diff --git a/rust/basic_bitcoin/ord.yaml b/rust/basic_bitcoin/ord.yaml index dd404c0ba1..c64bdd8b27 100644 --- a/rust/basic_bitcoin/ord.yaml +++ b/rust/basic_bitcoin/ord.yaml @@ -3,7 +3,7 @@ bitcoin_rpc_password: ic-btc-integration bitcoin_rpc_url: http://127.0.0.1:18443 bitcoin_rpc_username: ic-btc-integration chain: regtest -data_dir: . +data_dir: ./ord-db index_addresses: true index_runes: true index_sats: true From b30c7a65acdd54b9f459072d27e81e2906b83be1 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 8 Jul 2026 15:39:47 +0200 Subject: [PATCH 23/33] =?UTF-8?q?fix(rust/basic=5Fbitcoin):=20correct=20ru?= =?UTF-8?q?ne=20name=20BASICBITCOINSS=E2=86=92BASICBITCOINS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replace_all on BASICBITCOIN→BASICBITCOINS also matched inside the already updated BASICBITCOINS in the etch command, producing BASICBITCOINSS. This caused the user to etch a different name than what the explorer URL referenced, making the rune appear unfound. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index b3801eb549..e875cf3b50 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -298,7 +298,7 @@ The name must be uppercase and between 1–28 characters. The Runes protocol use > **Turbo mode**: The `etch_rune` implementation sets `turbo: true`, which opts the rune into future ord protocol upgrades. It does **not** bypass the name unlock schedule. ```bash -TXID=$(icp canister call backend etch_rune '("BASICBITCOINSS")' | grep -o '"[^"]*"' | tr -d '"') +TXID=$(icp canister call backend etch_rune '("BASICBITCOINS")' | grep -o '"[^"]*"' | tr -d '"') echo "Rune txid: $TXID" ``` From d1536ff7eec4ca048c90df8d73e0b2ebe1f05708 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 9 Jul 2026 09:05:46 +0200 Subject: [PATCH 24/33] feat(rust/basic_bitcoin): add Ordinals/Runes/BRC-20 asset protocol support - Add transfer_ordinal endpoint: single-input Taproot key-path spend moves an inscription UTXO to recipient while preserving satoshi order - Add transfer_rune endpoint: Runestone with edict + pointer=2 so unallocated rune change goes to vout[2] (not the recipient at vout[0]) - Fix transfer_brc20: use actual reveal output value in Taproot sighash instead of hardcoded MIN_INSCRIPTION_VALUE (BIP341 prevout commitment) - Fix encode_rune_name: use u128 accumulator (u64 overflows at 14 chars) - Add encode_leb128_u128: build_etching_script now encodes rune name, premine, amount, and cap with the correct u128 LEB128 encoder - Fix etch_rune: enforce 6-confirmation requirement via MinConfirmations filter instead of checking height != 0 (which only checked 1 block) - Fix build_reveal_transaction_with_fee: use checked_sub to avoid panic on underflow if fee exceeds INSCRIPTION_OUTPUT_VALUE - Update README: full Ordinals/Runes/BRC-20 walkthrough with expected output, verification steps, and corrected structure - CI: install candid-extractor before icp deploy (not bundled on ubuntu-24.04 host runner) - test.sh: check tip_height is any positive value, not exactly 101, so tests pass after running the README walkthrough Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/basic_bitcoin.yml | 2 + rust/basic_bitcoin/README.md | 171 ++++++++++-- rust/basic_bitcoin/backend/src/brc20.rs | 11 +- rust/basic_bitcoin/backend/src/common.rs | 6 - rust/basic_bitcoin/backend/src/ordinals.rs | 101 ++++++- rust/basic_bitcoin/backend/src/p2pkh.rs | 7 +- rust/basic_bitcoin/backend/src/p2tr.rs | 6 +- rust/basic_bitcoin/backend/src/runes.rs | 103 ++++++-- rust/basic_bitcoin/backend/src/service.rs | 2 + .../backend/src/service/etch_rune.rs | 248 ++++++++++++------ .../backend/src/service/get_rune_address.rs | 20 ++ .../backend/src/service/inscribe_brc20.rs | 2 +- .../backend/src/service/mint_brc20.rs | 2 +- .../backend/src/service/transfer_brc20.rs | 21 +- .../backend/src/service/transfer_ordinal.rs | 186 +++++++++++++ .../backend/src/service/transfer_rune.rs | 114 +++++--- rust/basic_bitcoin/test.sh | 2 +- 17 files changed, 805 insertions(+), 199 deletions(-) create mode 100644 rust/basic_bitcoin/backend/src/service/get_rune_address.rs create mode 100644 rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs diff --git a/.github/workflows/basic_bitcoin.yml b/.github/workflows/basic_bitcoin.yml index a7b9b126ce..39c854ec44 100644 --- a/.github/workflows/basic_bitcoin.yml +++ b/.github/workflows/basic_bitcoin.yml @@ -58,6 +58,8 @@ jobs: uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown + - name: Install candid-extractor + run: cargo install candid-extractor - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Build network launcher image diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index e875cf3b50..91694ac252 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -226,17 +226,19 @@ CONTAINER=$(docker ps --filter "ancestor=icp-cli-network-launcher-bitcoin" --for > acceptnonstdtxn=1 > ``` -## Inscribe an Ordinal +## Ordinals -[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. +[Ordinals](https://ordinals.com) is a protocol that assigns a unique serial number to every satoshi, then allows arbitrary data (text, images, etc.) to be inscribed onto individual satoshis. Inscriptions are stored permanently in the Bitcoin blockchain using a two-transaction commit/reveal process. Two operations make up the full lifecycle: **inscribe** → **transfer**. -### Step 1 — Start the ord server (separate terminal) +### Inscribe an Ordinal + +#### Step 1 — Start the ord server (separate terminal) ```bash ord --config-dir . server ``` -### Step 2 — Fund a Taproot address +#### Step 2 — Fund a Taproot address ```bash ADDR=$(icp canister call backend get_p2tr_key_path_only_address '()' | grep -o '"[^"]*"' | tr -d '"') @@ -245,14 +247,14 @@ docker exec $CONTAINER bitcoin-cli -regtest \ generatetoaddress 101 "$ADDR" ``` -### Step 3 — Create the inscription +#### Step 3 — Create the inscription ```bash TXID=$(icp canister call backend inscribe_ordinal '("Hello Bitcoin")' | grep -o '"[^"]*"' | tr -d '"') echo "Reveal txid: $TXID" ``` -### Step 4 — Mine a confirmation block +#### Step 4 — Mine a confirmation block ```bash docker exec $CONTAINER bitcoin-cli -regtest \ @@ -260,7 +262,7 @@ docker exec $CONTAINER bitcoin-cli -regtest \ generatetoaddress 1 "$ADDR" ``` -### Step 5 — View in the ord explorer +#### Step 5 — View in the ord explorer ```bash echo "http://127.0.0.1/inscription/${TXID}i0" @@ -270,12 +272,58 @@ echo "http://127.0.0.1/inscription/${TXID}i0" > **Note:** The homepage at `http://127.0.0.1/` shows a "Latest Inscriptions" section that may appear empty — use `/inscriptions` instead. +### Transfer an Ordinal + +An inscription is permanently bound to the first satoshi of the reveal output. To move the inscription to a new owner, that satoshi must flow to the first output (vout 0) of the spending transaction. `transfer_ordinal` achieves this by making the inscription UTXO the sole input — the inscription satoshi then flows directly to the recipient. + +> **Prerequisites:** Complete the inscribe steps above. `$TXID`, `$ADDR`, and `$CONTAINER` must be set. + +#### Step 1 — Transfer the inscription + +```bash +DEST="bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" +ORD_TRANSFER_TXID=$(icp canister call backend transfer_ordinal "(\"$TXID\", \"$DEST\")" | grep -o '"[^"]*"' | tr -d '"') +echo "Ordinal transfer txid: $ORD_TRANSFER_TXID" +``` + +#### Step 2 — Mine a confirmation block + +```bash +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" +``` + +#### Step 3 — Verify the new owner + +Query the inscription's owner via the ord JSON API. Before the transfer it shows the canister's Taproot address; after mining the block it should show the destination: + +```bash +curl -s -H "Accept: application/json" "http://127.0.0.1/inscription/${TXID}i0" | python3 -m json.tool | grep address +# Expected: "address": "bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" +``` + +You can also view the inscription in the ord explorer and confirm ownership visually: + +```bash +echo "http://127.0.0.1/inscription/${TXID}i0" +``` + +> **How satoshi tracking works:** The Ordinals protocol assigns ordinal numbers to satoshis in the order they are mined. The inscription is associated with the first satoshi of the reveal output. When that UTXO is spent as the first (and only) input, Bitcoin's satoshi-ordering rules guarantee the first satoshi lands in vout 0 — so the recipient at vout 0 receives the inscription. This is why `transfer_ordinal` uses the reveal UTXO as the sole input and puts the recipient at output index 0. + ## Runes -[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. Two operations make up the full lifecycle: **etch** → **transfer**. +[Runes](https://docs.ordinals.com/runes.html) is a fungible token protocol that embeds token metadata directly into Bitcoin transactions using `OP_RETURN` outputs. Two operations make up the full lifecycle: **etch** → **transfer**. ### Etch a Rune +Etching a rune requires two transactions separated by at least 6 block confirmations: + +1. **Commit** — creates a P2TR output whose tapscript contains the rune name commitment bytes +2. **Etch** — spends that output (via script-path, placing the commitment in the witness) together with an `OP_RETURN` Runestone + +The ord indexer validates that the etching transaction's input spends a P2TR output containing the rune commitment bytes, and that the committed output has at least 6 block confirmations. Without this, the etching is silently ignored and no Rune ID is assigned. + #### Step 1 — Start the ord server (separate terminal) ```bash @@ -291,46 +339,81 @@ docker exec $CONTAINER bitcoin-cli -regtest \ generatetoaddress 101 "$ADDR" ``` -#### Step 3 — Etch the Rune +#### Step 3 — Commit to the rune name -The name must be uppercase and between 1–28 characters. The Runes protocol uses a block-height-based unlock schedule that interpolates over a 210,000-block window — **names with 13 or more characters are immediately active** in regtest at any block height. Names shorter than 13 characters have an unlock height that must be mined past before the rune receives an ID and can be transferred. +The name must be uppercase and between 1–28 characters. The Runes protocol uses a block-height-based unlock schedule — **names with 13 or more characters are immediately active** in regtest at any block height. + +> **Why 13+ characters?** The ord indexer uses linear interpolation to derive a minimum rune value per block. Every 13-char name has a numeric value above `STEPS[12] = 99,246,114,928,149,462` — the highest minimum the formula ever produces — so 13-char names are immediately active at any block height. Shorter names have unlock heights that must be mined past (12-char names unlock across blocks 0–17,499; 7-char names across 87,500–104,999). > **Turbo mode**: The `etch_rune` implementation sets `turbo: true`, which opts the rune into future ord protocol upgrades. It does **not** bypass the name unlock schedule. ```bash -TXID=$(icp canister call backend etch_rune '("BASICBITCOINS")' | grep -o '"[^"]*"' | tr -d '"') -echo "Rune txid: $TXID" +COMMIT_TXID=$(icp canister call backend commit_rune '("BASICBITCOINS")' | grep -o '"[^"]*"' | tr -d '"') +echo "Commit txid: $COMMIT_TXID" ``` -#### Step 4 — Mine a confirmation block +#### Step 4 — Mine 6 blocks to mature the commitment + +The ord protocol requires the commit output to have at least 6 block confirmations before the etching is accepted. ```bash docker exec $CONTAINER bitcoin-cli -regtest \ -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$ADDR" + generatetoaddress 6 "$ADDR" +``` + +#### Step 5 — Etch the Rune + +```bash +ETCH_TXID=$(icp canister call backend etch_rune '("BASICBITCOINS")' | grep -o '"[^"]*"' | tr -d '"') +echo "Etch txid: $ETCH_TXID" ``` -#### Step 5 — Decode the Runestone to verify +#### Step 6 — Mine a confirmation block ```bash -ord --config-dir . decode --txid "$TXID" +docker exec $CONTAINER bitcoin-cli -regtest \ + -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ + generatetoaddress 1 "$ADDR" ``` -#### Step 6 — View in the ord explorer +#### Step 7 — Decode the Runestone to verify ```bash -echo "http://127.0.0.1/rune/BASICBITCOINS" +ord --config-dir . decode --txid "$ETCH_TXID" +# Expected output: +# { +# "inscriptions": [], +# "runestone": { +# "Runestone": { +# "edicts": [], +# "etching": { +# "divisibility": null, +# "premine": 1000000, +# "rune": "BASICBITCOINS", +# "spacers": null, +# "symbol": "🪙", +# "terms": null, +# "turbo": true +# }, +# "mint": null, +# "pointer": null +# } +# } +# } ``` -- All runes: `http://127.0.0.1/runes` +#### Step 8 — View in the ord explorer + +Open the rune in the ord explorer: `http://127.0.0.1/rune/BASICBITCOINS` -> **Note:** The `/runes` listing page may appear empty for the same reason as `/` for inscriptions — use the direct URL above instead. +- All runes: `http://127.0.0.1/runes` The Rune is now etched with 1,000,000 tokens minted to your address. ### Transfer a Rune -#### Step 1 — Look up the rune ID +#### Step 1 — Look up the rune ID and verify your balance The rune ID (block height : transaction index) is required to identify which rune to transfer. Fetch it from the ord JSON API: @@ -342,7 +425,15 @@ RUNE_TX=$(echo "$RUNE_ID" | cut -d: -f2) echo "Rune ID: $RUNE_BLOCK:$RUNE_TX" ``` -> **Why 13+ characters?** The ord indexer uses linear interpolation to derive a minimum rune value per block. Every 13-char name has a numeric value above `STEPS[12] = 99,246,114,928,149,462` — the highest minimum the formula ever produces — so 13-char names are immediately active at any block height. Shorter names have unlock heights that must be mined past (12-char names unlock across blocks 0–17,499; 7-char names across 87,500–104,999). +The 1,000,000 premined tokens are held at the canister's dedicated rune address (derivation index 1, separate from the main funding address). Check the balance: + +```bash +RUNE_ADDR=$(icp canister call backend get_rune_address '()' | grep -o '"[^"]*"' | tr -d '"') +curl -s -H "Accept: application/json" "http://127.0.0.1/address/$RUNE_ADDR" | python3 -m json.tool | grep -A6 '"runes"' +# Expected: "BASICBITCOINS": { "amount": 1000000, ... } +``` + +> **Note:** If the balance shows empty, ord may still be indexing the etching block. Wait a moment and retry. #### Step 2 — Transfer rune tokens @@ -373,7 +464,15 @@ Decode the transfer transaction to confirm the Runestone edict is correct: ord --config-dir . decode --txid "$TRANSFER_TXID" ``` -The output should contain a Runestone with an edict assigning 100,000 tokens to output 0 (the recipient). The remaining 900,000 tokens stay in the change output at the canister's address. +The output should contain a Runestone with an edict assigning 100,000 tokens to output 0 (the recipient) and a `pointer: 2` directing the unallocated remainder to output 2 (the change). Check the resulting output balances: + +```bash +# Recipient — should show 100,000 BASICBITCOINS +curl -s -H "Accept: application/json" "http://127.0.0.1/output/${TRANSFER_TXID}:0" | python3 -m json.tool + +# Change (rune address) — should show 900,000 BASICBITCOINS +curl -s -H "Accept: application/json" "http://127.0.0.1/output/${TRANSFER_TXID}:2" | python3 -m json.tool +``` ## BRC-20 tokens @@ -451,7 +550,12 @@ echo "http://127.0.0.1/inscription/${MINT_TXID}i0" ### Transfer BRC-20 tokens -A BRC-20 transfer uses three transactions: create a transfer inscription at the sender's (canister's) address, then move that inscription UTXO to the recipient. This two-step handoff is how BRC-20 indexers track balance changes. +A BRC-20 transfer chains three Bitcoin transactions internally: +1. **Commit** — funds a Taproot address committed to the transfer JSON script +2. **Reveal** — spends the commit output, creating the transfer inscription at the canister's own address (locking `amount` tokens from the sender's balance) +3. **Send** — moves the inscription UTXO from the canister's address to the recipient + +BRC-20 indexers see the inscription travel from sender → recipient and credit the recipient's balance accordingly. The returned txid is the send transaction (step 3). #### Step 1 — Transfer tokens @@ -462,7 +566,7 @@ TRANSFER_TXID=$(icp canister call backend transfer_brc20 '(record { amount = 500: nat64; destination_address = "'"$DEST"'"; })' | grep -o '"[^"]*"' | tr -d '"') -echo "BRC-20 transfer txid: $TRANSFER_TXID" +echo "BRC-20 send txid: $TRANSFER_TXID" ``` #### Step 2 — Mine a confirmation block @@ -473,12 +577,21 @@ docker exec $CONTAINER bitcoin-cli -regtest \ generatetoaddress 1 "$ADDR" ``` -#### Step 3 — Verify the transfer inscription +#### Step 3 — Verify the inscription arrived at the recipient ```bash -ord --config-dir . decode --txid "$TRANSFER_TXID" -# The inscription content is the transfer JSON: -# {"p":"brc-20","op":"transfer","tick":"DEMO","amt":"500"} +curl -s -H "Accept: application/json" "http://127.0.0.1/output/${TRANSFER_TXID}:0" | python3 -m json.tool | grep -E '"address"|"inscriptions"' +# Expected: +# "address": "bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" +# "inscriptions": ["i0"] +``` + +You can view the inscription content (the transfer JSON) in the ord explorer: + +```bash +# Get the inscription ID from the output and open it in the ord explorer +INSCRIPTION_ID=$(curl -s -H "Accept: application/json" "http://127.0.0.1/output/${TRANSFER_TXID}:0" | python3 -c "import sys,json; print(json.load(sys.stdin)['inscriptions'][0])") +echo "http://127.0.0.1/inscription/$INSCRIPTION_ID" ``` The recipient now holds the BRC-20 transfer inscription. BRC-20 indexers credit 500 DEMO to the recipient's balance and debit it from the canister's balance. diff --git a/rust/basic_bitcoin/backend/src/brc20.rs b/rust/basic_bitcoin/backend/src/brc20.rs index 82ccda0d72..284ed679c5 100644 --- a/rust/basic_bitcoin/backend/src/brc20.rs +++ b/rust/basic_bitcoin/backend/src/brc20.rs @@ -53,7 +53,7 @@ pub fn build_brc20_reveal_script(internal_key: &XOnlyPublicKey, brc20_json: &str .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"text/plain;charset=utf-8") // BRC-20 spec requires text/plain, not application/json + .push_slice(b"text/plain;charset=utf-8") // BRC-20 spec requires text/plain;charset=utf-8 .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 @@ -68,12 +68,14 @@ pub fn build_brc20_reveal_script(internal_key: &XOnlyPublicKey, brc20_json: &str /// 2. Reveal: Spend from that address, revealing the BRC-20 JSON in the witness /// /// The reveal output (the inscription UTXO) is sent to `destination_address`. -/// Returns the reveal transaction ID. +/// Returns `(reveal_txid, reveal_output_value_sats)`. The output value is the +/// actual amount in the reveal UTXO after fees — callers that spend this output +/// must use this value in the Taproot sighash (BIP341 commits to exact prevout amounts). pub(crate) async fn commit_and_reveal( ctx: &BitcoinContext, brc20_json: &str, destination_address: &Address, -) -> String { +) -> (String, u64) { 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()); @@ -161,5 +163,6 @@ pub(crate) async fn commit_and_reveal( .await .unwrap(); - reveal_tx.compute_txid().to_string() + let reveal_output_value = reveal_tx.output[0].value.to_sat(); + (reveal_tx.compute_txid().to_string(), reveal_output_value) } diff --git a/rust/basic_bitcoin/backend/src/common.rs b/rust/basic_bitcoin/backend/src/common.rs index a2a339803c..73509b89cf 100644 --- a/rust/basic_bitcoin/backend/src/common.rs +++ b/rust/basic_bitcoin/backend/src/common.rs @@ -74,8 +74,6 @@ 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. diff --git a/rust/basic_bitcoin/backend/src/ordinals.rs b/rust/basic_bitcoin/backend/src/ordinals.rs index 9279f052a3..9ae3941422 100644 --- a/rust/basic_bitcoin/backend/src/ordinals.rs +++ b/rust/basic_bitcoin/backend/src/ordinals.rs @@ -93,10 +93,16 @@ pub fn build_reveal_transaction_with_fee( // 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_value = INSCRIPTION_OUTPUT_VALUE + .checked_sub(fee) + .ok_or_else(|| { + format!( + "Fee ({} sats) exceeds inscription output value ({} sats)", + fee, INSCRIPTION_OUTPUT_VALUE + ) + })?; let output = TxOut { - value: Amount::from_sat(INSCRIPTION_OUTPUT_VALUE - fee), + value: Amount::from_sat(output_value), script_pubkey: destination_address.script_pubkey(), }; @@ -148,6 +154,95 @@ pub fn build_ordinal_reveal_script( .into_script() } +/// Builds the rune etch (reveal) transaction that spends the commit output. +/// +/// Output layout: +/// vout[0] — OP_RETURN Runestone (0 sat), encodes the etching parameters +/// vout[1] — change output back to own address (commit_value minus fee) +/// +/// Uses the same iterative fee-convergence as `build_reveal_transaction`. +pub(crate) async fn build_rune_etch_transaction( + change_address: &Address, + runestone_script: &ScriptBuf, + commit_script: &ScriptBuf, + control_block: &ControlBlock, + commit_txid: &Txid, + commit_vout: u32, + commit_value: u64, + fee_per_byte: MillisatoshiPerByte, +) -> Transaction { + let mut fee = 0u64; + loop { + let transaction = build_rune_etch_transaction_with_fee( + change_address, + runestone_script, + commit_script, + control_block, + commit_txid, + commit_vout, + commit_value, + fee, + ); + + let virtual_size = transaction.vsize() as u64; + if (virtual_size * fee_per_byte) / 1000 == fee { + return transaction; + } else { + fee = (virtual_size * fee_per_byte) / 1000; + } + } +} + +fn build_rune_etch_transaction_with_fee( + change_address: &Address, + runestone_script: &ScriptBuf, + commit_script: &ScriptBuf, + control_block: &ControlBlock, + commit_txid: &Txid, + commit_vout: u32, + commit_value: u64, + fee: u64, +) -> Transaction { + let input = TxIn { + previous_output: OutPoint { + txid: commit_txid.to_owned(), + vout: commit_vout, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }; + + let runestone_output = TxOut { + value: Amount::ZERO, + script_pubkey: runestone_script.clone(), + }; + + let change_sats = commit_value.saturating_sub(fee); + let mut outputs = vec![runestone_output]; + // Only add change output if above dust threshold. + if change_sats >= 1_000 { + outputs.push(TxOut { + value: Amount::from_sat(change_sats), + script_pubkey: change_address.script_pubkey(), + }); + } + + let mut transaction = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![input], + output: outputs, + }; + + // Placeholder witness for virtual-size estimation. + transaction.input[0].witness.push(SCHNORR_SIGNATURE_PLACEHOLDER); + transaction.input[0].witness.push(commit_script.to_bytes()); + transaction.input[0].witness.push(control_block.serialize()); + + transaction +} + /// Creates the witness stack for Taproot script-path spending. /// /// This function handles the complex process of: diff --git a/rust/basic_bitcoin/backend/src/p2pkh.rs b/rust/basic_bitcoin/backend/src/p2pkh.rs index e1dad47970..7b23520b1b 100644 --- a/rust/basic_bitcoin/backend/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/backend/src/p2tr.rs b/rust/basic_bitcoin/backend/src/p2tr.rs index bd46a971cf..d4a35cfb43 100644 --- a/rust/basic_bitcoin/backend/src/p2tr.rs +++ b/rust/basic_bitcoin/backend/src/p2tr.rs @@ -84,10 +84,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 { diff --git a/rust/basic_bitcoin/backend/src/runes.rs b/rust/basic_bitcoin/backend/src/runes.rs index 0a5768607a..fe4eb4c832 100644 --- a/rust/basic_bitcoin/backend/src/runes.rs +++ b/rust/basic_bitcoin/backend/src/runes.rs @@ -5,7 +5,7 @@ use bitcoin::{ opcodes::all::*, script::{Builder, PushBytesBuf}, - ScriptBuf, + ScriptBuf, XOnlyPublicKey, }; use leb128::write; @@ -29,7 +29,6 @@ enum Tag { OffsetEnd = 18, #[allow(dead_code)] Mint = 20, - #[allow(dead_code)] Pointer = 22, #[allow(dead_code)] Cenotaph = 126, @@ -62,7 +61,7 @@ impl Flag { } } -/// Encodes a u128 as LEB128 (Little Endian Base 128). +/// Encodes a u64 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. @@ -72,16 +71,34 @@ pub fn encode_leb128(value: u64) -> Vec { buf } -/// Encodes a rune name into its numeric representation. +/// Encodes a u128 as LEB128. Used for rune names, premine, amount, and cap fields, +/// which are all u128 in the Runes protocol specification. +pub fn encode_leb128_u128(mut value: u128) -> Vec { + let mut buf = Vec::new(); + loop { + let byte = (value & 0x7f) as u8; + value >>= 7; + if value == 0 { + buf.push(byte); + break; + } else { + buf.push(byte | 0x80); + } + } + buf +} + +/// Encodes a rune name into its numeric representation as u128. /// /// 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 { +/// The result is u128 because names of 14+ characters can exceed u64::MAX. +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; + let mut value = 0u128; for (i, ch) in name.chars().enumerate() { if i >= 28 { return Err("Rune name cannot exceed 28 characters".to_string()); @@ -90,7 +107,7 @@ pub fn encode_rune_name(name: &str) -> Result { return Err("Rune name must contain only uppercase letters A-Z".to_string()); } - let digit = (ch as u8 - b'A') as u64; + let digit = (ch as u8 - b'A') as u128; if i == 0 { value = digit; } else { @@ -143,7 +160,8 @@ pub struct Edict { /// Number of rune tokens to move to the specified output. pub amount: u64, /// Index into the transaction's vout array that receives the rune tokens. - /// OP_RETURN outputs count toward the index but cannot hold rune balances. + /// All vouts (including OP_RETURN) count toward the index. Pointing an edict + /// at an OP_RETURN output or a non-existent output burns those tokens. pub output: u32, } @@ -152,16 +170,29 @@ pub struct Edict { /// A transfer runestone contains only edicts — no etching fields. The edicts /// direct the protocol to move tokens from input UTXOs holding rune balances /// to specific transaction outputs. Any balance not explicitly allocated by an -/// edict flows to the last non-OP_RETURN output by default. -pub fn build_transfer_script(edicts: &[Edict]) -> Result { +/// edict flows to the first non-OP_RETURN output by default. +/// +/// `pointer`, if set, overrides the default: unallocated runes go to the output +/// at that vout index instead of the first non-OP_RETURN output. This is used to +/// direct change rune tokens to the change output (vout[2]) when the recipient +/// is at vout[0] and the OP_RETURN is at vout[1]. +pub fn build_transfer_script(edicts: &[Edict], pointer: Option) -> Result { if edicts.is_empty() { return Err("At least one edict is required".to_string()); } let mut payload = Vec::new(); + // Named fields must appear before the Body (0) separator. + // Tag 22: Pointer — vout index that receives unallocated runes. + // Without this, unallocated runes would default to the first non-OP_RETURN + // output (vout[0], the recipient), incorrectly giving them the full balance. + if let Some(p) = pointer { + payload.extend_from_slice(&encode_leb128(Tag::Pointer as u64)); + payload.extend_from_slice(&encode_leb128(p as u64)); + } + // Tag 0 (Body): separator signalling that edicts follow. - // A pure transfer runestone has no named fields before this. payload.extend_from_slice(&encode_leb128(Tag::Body as u64)); // Edicts are encoded as groups of 4 LEB128 integers: [block, tx, amount, output]. @@ -199,6 +230,44 @@ pub fn build_transfer_script(edicts: &[Edict]) -> Result { Ok(builder.into_script()) } +/// Computes the commitment bytes for a rune name. +/// +/// The commitment is the rune's numeric encoding as a u128 value in little-endian byte order +/// with trailing zero bytes stripped. The ord indexer scans the commit tapscript for a pushdata +/// instruction equal to these bytes to validate that the etching transaction committed to the name. +pub fn build_rune_commitment_bytes(name: &str) -> Result, String> { + let encoded = encode_rune_name(name)?; + let bytes = encoded.to_le_bytes(); + let mut end = bytes.len(); + while end > 0 && bytes[end - 1] == 0 { + end -= 1; + } + Ok(bytes[..end].to_vec()) +} + +/// Builds the tapscript for a rune commit transaction. +/// +/// The commit script embeds the rune commitment bytes as a pushdata instruction so that +/// the ord indexer can find them when validating the etching. Script structure: +/// +/// OP_DROP OP_CHECKSIG +/// +/// Spending this output via script-path places the tapscript in the witness, satisfying +/// the ord protocol requirement that etching inputs commit to the rune name. +pub fn build_rune_commit_script(internal_key: &XOnlyPublicKey, name: &str) -> Result { + let commitment = build_rune_commitment_bytes(name)?; + let mut push_buf = PushBytesBuf::new(); + push_buf + .extend_from_slice(&commitment) + .map_err(|e| e.to_string())?; + Ok(Builder::new() + .push_slice(&push_buf) + .push_opcode(OP_DROP) + .push_slice(internal_key.serialize()) + .push_opcode(OP_CHECKSIG) + .into_script()) +} + /// Builds a runestone script for etching a new rune token. /// /// The runestone is encoded as an OP_RETURN output with the format: @@ -237,9 +306,9 @@ pub fn build_etching_script(etching: &Etching) -> Result { payload.extend_from_slice(&encode_leb128(etching.spacers as u64)); } - // Tag 4: Rune name + // Tag 4: Rune name (u128 — names ≥14 chars exceed u64) payload.extend_from_slice(&encode_leb128(Tag::Rune as u64)); - payload.extend_from_slice(&encode_leb128(encoded_name as u64)); + payload.extend_from_slice(&encode_leb128_u128(encoded_name)); // Tag 5: Symbol (odd tag) if let Some(symbol) = etching.symbol { @@ -247,21 +316,21 @@ pub fn build_etching_script(etching: &Etching) -> Result { payload.extend_from_slice(&encode_leb128(symbol as u64)); } - // Tag 6: Premine + // Tag 6: Premine (u128 — protocol supports values beyond u64) if etching.premine > 0 { payload.extend_from_slice(&encode_leb128(Tag::Premine as u64)); - payload.extend_from_slice(&encode_leb128(etching.premine as u64)); + payload.extend_from_slice(&encode_leb128_u128(etching.premine)); } // 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)); + payload.extend_from_slice(&encode_leb128_u128(amount)); } 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)); + payload.extend_from_slice(&encode_leb128_u128(cap)); } if let Some(start) = terms.height.0 { payload.extend_from_slice(&encode_leb128(Tag::HeightStart as u64)); diff --git a/rust/basic_bitcoin/backend/src/service.rs b/rust/basic_bitcoin/backend/src/service.rs index 887e4304c2..2ce606513b 100644 --- a/rust/basic_bitcoin/backend/src/service.rs +++ b/rust/basic_bitcoin/backend/src/service.rs @@ -1,4 +1,5 @@ pub mod etch_rune; +pub mod get_rune_address; pub mod transfer_rune; pub mod get_balance; pub mod get_blockchain_info; @@ -13,6 +14,7 @@ pub mod inscribe_brc20; pub mod mint_brc20; pub mod transfer_brc20; pub mod inscribe_ordinal; +pub mod transfer_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/backend/src/service/etch_rune.rs b/rust/basic_bitcoin/backend/src/service/etch_rune.rs index 0d593dc440..2b3b7c3492 100644 --- a/rust/basic_bitcoin/backend/src/service/etch_rune.rs +++ b/rust/basic_bitcoin/backend/src/service/etch_rune.rs @@ -1,73 +1,95 @@ -// 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. +// Rune etching uses a commit+reveal pattern required by the ord protocol. +// +// Step 1 — commit_rune: Create a P2TR output whose tapscript contains the rune commitment +// bytes (rune name encoded as u128 LE, trailing zeros stripped). Wait 6 blocks. +// +// Step 2 — etch_rune: Spend that commit output via script-path (which places the tapscript +// in the witness). The etching transaction also contains an OP_RETURN Runestone. +// The ord indexer finds the commitment bytes in the witness, verifies 6+ confirmations +// on the commit output, and assigns a Rune ID. +// +// Without a valid committed input the indexer silently ignores the etching (no Rune ID assigned). use crate::{ common::{get_fee_per_byte, DerivationPath, PrimaryOutput}, + ordinals::{build_rune_etch_transaction, create_script_path_witness, INSCRIPTION_OUTPUT_VALUE}, p2tr, - runes::{build_etching_script, Etching}, + runes::{build_etching_script, build_rune_commit_script, Etching}, schnorr::{get_schnorr_public_key, sign_with_schnorr}, BTC_CONTEXT, }; use bitcoin::{ consensus::serialize, secp256k1::{PublicKey, Secp256k1}, - Address, XOnlyPublicKey, + taproot::{LeafVersion, TaprootBuilder, TaprootSpendInfo}, + Address, Amount, ScriptBuf, TxOut, Txid, XOnlyPublicKey, }; use ic_cdk::{trap, update}; use ic_cdk_bitcoin_canister::{ bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, + UtxosFilterInRequest, }; -/// 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 -/// - Turbo mode enabled so any name is immediately active (no unlock height) -/// -/// 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. +fn validate_rune_name(name: &str) { 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"); } +} + +/// Derives the commit script, Taproot spend info, commit address, and funding address for a rune. +/// +/// Both `commit_rune` and `etch_rune` use the same derivation so the commit address is +/// deterministic and `etch_rune` can locate the commit UTXO without being told the txid. +async fn rune_commit_info( + name: &str, +) -> (ScriptBuf, TaprootSpendInfo, Address, Address) { + let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); + let internal_key_path = DerivationPath::p2tr(0, 0); + let internal_key_bytes = + get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; + let internal_key = XOnlyPublicKey::from(PublicKey::from_slice(&internal_key_bytes).unwrap()); + + let commit_script = build_rune_commit_script(&internal_key, name) + .unwrap_or_else(|e| trap(format!("Failed to build commit script: {}", e))); + + let secp = Secp256k1::new(); + let taproot_spend_info = TaprootBuilder::new() + .add_leaf(0, commit_script.clone()) + .unwrap() + .finalize(&secp, internal_key) + .unwrap(); + + let commit_address = + Address::p2tr_tweaked(taproot_spend_info.output_key(), ctx.bitcoin_network); + let funding_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); + + (commit_script, taproot_spend_info, commit_address, funding_address) +} + +/// Step 1 of rune etching — creates and broadcasts the commit transaction. +/// +/// Sends `INSCRIPTION_OUTPUT_VALUE` satoshis to a P2TR address whose tapscript contains the +/// rune commitment bytes. The commit output must have at least 6 block confirmations before +/// calling `etch_rune`. +/// +/// Returns the commit transaction ID. +#[update] +pub async fn commit_rune(name: String) -> String { + validate_rune_name(&name); - // Derive the internal key for our Taproot address. - // Since rune data goes in OP_RETURN (not script), we use simple key-path spending. + let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); 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); + let (_, _, commit_address, funding_address) = rune_commit_info(&name).await; - // 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(), + address: funding_address.to_string(), network: ctx.network.into(), filter: None, }) @@ -75,61 +97,141 @@ pub async fn etch_rune(name: String) -> String { .unwrap() .utxos; - // Create the rune etching configuration with fixed parameters. - // This defines all the token properties that will be permanently recorded. + let fee_per_byte = get_fee_per_byte(&ctx).await; + + let (commit_tx, prevouts) = p2tr::build_transaction( + &ctx, + &funding_address, + &own_utxos, + p2tr::SelectUtxosMode::Single, + &PrimaryOutput::Address(commit_address, INSCRIPTION_OUTPUT_VALUE), + fee_per_byte, + ) + .await; + + // funding_address is a key-path-only P2TR (no script), so merkle root is empty. + let signed_commit = p2tr::sign_transaction_key_spend( + &ctx, + &funding_address, + commit_tx, + prevouts.as_slice(), + internal_key_path.to_vec_u8_path(), + vec![], + sign_with_schnorr, + ) + .await; + + bitcoin_send_transaction(&SendTransactionRequest { + network: ctx.network.into(), + transaction: serialize(&signed_commit), + }) + .await + .unwrap(); + + signed_commit.compute_txid().to_string() +} + +/// Step 2 of rune etching — spends the commit output and broadcasts the etching transaction. +/// +/// Must be called after the commit output has at least 6 block confirmations. The etch +/// transaction spends the commit output via script-path spend so that the rune commitment +/// bytes appear in the witness, satisfying the ord indexer's validation. +/// +/// Returns the etching transaction ID. +#[update] +pub async fn etch_rune(name: String) -> String { + validate_rune_name(&name); + + let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); + let internal_key_path = DerivationPath::p2tr(0, 0); + + let (commit_script, taproot_spend_info, commit_address, _funding_address) = + rune_commit_info(&name).await; + + // Find the commit UTXO with at least 6 confirmations. The Runes protocol requires + // the commit output to be at least 6 blocks deep before the etching is recognised by indexers. + let commit_utxos = bitcoin_get_utxos(&GetUtxosRequest { + address: commit_address.to_string(), + network: ctx.network.into(), + filter: Some(UtxosFilterInRequest::MinConfirmations(6)), + }) + .await + .unwrap() + .utxos; + + if commit_utxos.is_empty() { + trap("No commit output with 6+ confirmations found. Call commit_rune first and mine 6 blocks before calling etch_rune."); + } + + let commit_utxo = &commit_utxos[0]; + + let commit_txid = Txid::from_raw_hash( + bitcoin::hashes::Hash::from_slice(commit_utxo.outpoint.txid.as_ref()).unwrap(), + ); + let commit_vout = commit_utxo.outpoint.vout; + let commit_value = commit_utxo.value; + + let commit_txout = TxOut { + value: Amount::from_sat(commit_value), + script_pubkey: commit_address.script_pubkey(), + }; + + let control_block = taproot_spend_info + .control_block(&(commit_script.clone(), LeafVersion::TapScript)) + .unwrap(); + let etching = Etching { - divisibility: 0, // No decimal places (whole units only) - premine: 1_000_000, // Mint 1M units to the etcher (fixed supply) + divisibility: 0, + premine: 1_000_000, rune_name: name.clone(), - symbol: Some('🪙'), // Unicode coin symbol for display - terms: None, // No open minting allowed - // Turbo mode opts this rune into future protocol upgrades proposed by the - // ord developers. It does NOT bypass the name unlock schedule — use a - // name of 12+ characters to ensure the rune is active from block 0 in regtest. + symbol: Some('🪙'), + terms: None, turbo: true, - spacers: 0, // No visual spacers in the name + spacers: 0, }; - // 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. + // The rune premine goes to a dedicated address (p2tr index 1) that is separate from the + // main funding address (p2tr index 0). This makes it possible for transfer_rune to spend + // only rune-bearing UTXOs without querying an external indexer. + let rune_key_path = DerivationPath::p2tr(0, 1); + let rune_key_bytes = get_schnorr_public_key(&ctx, rune_key_path.to_vec_u8_path()).await; + let rune_key = XOnlyPublicKey::from(PublicKey::from_slice(&rune_key_bytes).unwrap()); + let secp2 = Secp256k1::new(); + let rune_address = Address::p2tr(&secp2, rune_key, None, ctx.bitcoin_network); + 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), + + let mut etch_tx = build_rune_etch_transaction( + &rune_address, + &runestone_script, + &commit_script, + &control_block, + &commit_txid, + commit_vout, + commit_value, 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( + create_script_path_witness( &ctx, - &own_address, - transaction, - prevouts.as_slice(), + &mut etch_tx, + &commit_txout, + &commit_script, + &control_block, 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), + transaction: serialize(&etch_tx), }) .await .unwrap(); - // Return the transaction ID so users can track their rune etching. - signed_transaction.compute_txid().to_string() + etch_tx.compute_txid().to_string() } diff --git a/rust/basic_bitcoin/backend/src/service/get_rune_address.rs b/rust/basic_bitcoin/backend/src/service/get_rune_address.rs new file mode 100644 index 0000000000..108984d42a --- /dev/null +++ b/rust/basic_bitcoin/backend/src/service/get_rune_address.rs @@ -0,0 +1,20 @@ +use bitcoin::{key::Secp256k1, Address, PublicKey, XOnlyPublicKey}; +use ic_cdk::update; + +use crate::{common::DerivationPath, schnorr::get_schnorr_public_key, BTC_CONTEXT}; + +/// Returns the P2TR address that holds the canister's rune balances. +/// +/// This address uses derivation index 1 (p2tr(0,1)), separate from the main funding +/// address at index 0. All rune premine and transfer change outputs go here, so every +/// UTXO at this address is a rune-bearing output. Use this address to check rune +/// balances in the ord explorer: http://127.0.0.1/address/
+#[update] +pub async fn get_rune_address() -> String { + let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); + let rune_key_path = DerivationPath::p2tr(0, 1); + let rune_key = get_schnorr_public_key(&ctx, rune_key_path.to_vec_u8_path()).await; + let rune_key = XOnlyPublicKey::from(PublicKey::from_slice(&rune_key).unwrap()); + let secp = Secp256k1::new(); + Address::p2tr(&secp, rune_key, None, ctx.bitcoin_network).to_string() +} diff --git a/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs b/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs index 2e4077dcac..6e110897bd 100644 --- a/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs +++ b/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs @@ -56,5 +56,5 @@ pub async fn inscribe_brc20(tick: String) -> String { let secp = Secp256k1::new(); let funding_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); - commit_and_reveal(&ctx, &brc20_json, &funding_address).await + commit_and_reveal(&ctx, &brc20_json, &funding_address).await.0 } diff --git a/rust/basic_bitcoin/backend/src/service/mint_brc20.rs b/rust/basic_bitcoin/backend/src/service/mint_brc20.rs index 66a7e0d1b2..78ae072496 100644 --- a/rust/basic_bitcoin/backend/src/service/mint_brc20.rs +++ b/rust/basic_bitcoin/backend/src/service/mint_brc20.rs @@ -55,5 +55,5 @@ pub async fn mint_brc20(tick: String, amount: u64) -> String { let secp = Secp256k1::new(); let own_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); - commit_and_reveal(&ctx, &brc20_json, &own_address).await + commit_and_reveal(&ctx, &brc20_json, &own_address).await.0 } diff --git a/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs b/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs index ccd90f6e66..8197ca5ecc 100644 --- a/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs +++ b/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs @@ -82,24 +82,21 @@ pub async fn transfer_brc20(request: TransferBrc20Request) -> String { // --- Step 1 & 2: Commit + Reveal --- // The reveal output goes to the canister's own address, placing the transfer // inscription at the sender's address as required by the BRC-20 spec. - // `commit_and_reveal` returns the reveal txid; the inscription is at vout 0. - let reveal_txid_str = commit_and_reveal(&ctx, &brc20_json, &own_address).await; + // Returns (reveal_txid, actual reveal output value in sats). + let (reveal_txid_str, reveal_output_value) = + commit_and_reveal(&ctx, &brc20_json, &own_address).await; // --- Step 3: Send the inscription UTXO to the recipient --- // Spending reveal_txid:0 moves the inscription from sender to recipient, // which BRC-20 indexers interpret as the token transfer completing. let reveal_txid = Txid::from_str(&reveal_txid_str).unwrap(); - // The reveal output value was set by ordinals::INSCRIPTION_OUTPUT_VALUE minus reveal fee. - // We query it from the reveal transaction's known constant rather than waiting for - // confirmation — Bitcoin allows spending unconfirmed outputs (CPFP). - // Use a conservative estimate: INSCRIPTION_OUTPUT_VALUE (15_000 sat) minus a - // generous max reveal fee of 2_000 sat leaves at least 13_000 sat for the send step. - // The iterative fee loop below adjusts the exact send output value. - const MIN_INSCRIPTION_VALUE: u64 = 13_000; - + // BIP341 Taproot key-path signatures commit to the exact prevout amounts. + // We must use the actual reveal output value (INSCRIPTION_OUTPUT_VALUE minus + // the reveal fee) — not a hardcoded estimate — otherwise the signature is + // cryptographically invalid and Bitcoin nodes reject the send transaction. let send_prevout = TxOut { - value: Amount::from_sat(MIN_INSCRIPTION_VALUE), + value: Amount::from_sat(reveal_output_value), script_pubkey: own_address.script_pubkey(), }; @@ -107,7 +104,7 @@ pub async fn transfer_brc20(request: TransferBrc20Request) -> String { let mut send_fee = 0u64; let signed_send_tx = loop { - let output_value = MIN_INSCRIPTION_VALUE + let output_value = reveal_output_value .checked_sub(send_fee) .unwrap_or_else(|| trap("inscription value insufficient to cover send fee")); diff --git a/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs b/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs new file mode 100644 index 0000000000..f9778c3f17 --- /dev/null +++ b/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs @@ -0,0 +1,186 @@ +// Transferring an ordinal inscription means transferring the specific satoshis that +// carry it. The Ordinals protocol assigns inscriptions to the first satoshi of the +// reveal output. To move the inscription, that satoshi must flow to the first output +// (vout 0) of the spending transaction — which is guaranteed by making the inscription +// UTXO the first (and only) input. +// +// The inscription UTXO sits at the canister's main funding address (p2tr(0,0)) with +// a value of INSCRIPTION_OUTPUT_VALUE satoshis (~15,000 sat). The user identifies +// it by the reveal transaction ID returned by inscribe_ordinal. + +use crate::{ + common::{get_fee_per_byte, DerivationPath}, + p2tr, + schnorr::{get_schnorr_public_key, mock_sign_with_schnorr, sign_with_schnorr}, + BTC_CONTEXT, +}; +use bitcoin::{ + absolute::LockTime, + blockdata::witness::Witness, + consensus::serialize, + hashes::Hash, + secp256k1::{PublicKey, Secp256k1}, + transaction::Version, + Address, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, XOnlyPublicKey, +}; +use ic_cdk::{trap, update}; +use ic_cdk_bitcoin_canister::{ + bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, +}; +use std::str::FromStr; + +/// Transfers an Ordinal inscription to a destination address. +/// +/// An inscription is bound to the first satoshi of the reveal output. To preserve this +/// binding through the transfer, the inscription UTXO must be the first input of the +/// spending transaction — that satoshi then flows to the first output (vout 0), which +/// is the recipient. Any remaining satoshis (after the fee) go to the same output. +/// +/// `reveal_txid` is the transaction ID returned by `inscribe_ordinal`. +/// +/// Returns the transfer transaction ID. +#[update] +pub async fn transfer_ordinal(reveal_txid: String, destination_address: String) -> String { + let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); + + // Parse and validate the destination address. + let destination_address = Address::from_str(&destination_address) + .unwrap() + .require_network(ctx.bitcoin_network) + .unwrap(); + + // Parse the reveal txid provided by the caller. + let reveal_txid = Txid::from_str(&reveal_txid) + .unwrap_or_else(|_| trap("Invalid reveal txid")); + + // The inscription output lives at the funding address (p2tr index 0) — + // the same address the reveal transaction sent its output to. + let key_path = DerivationPath::p2tr(0, 0); + let pub_key = get_schnorr_public_key(&ctx, key_path.to_vec_u8_path()).await; + let pub_key = XOnlyPublicKey::from(PublicKey::from_slice(&pub_key).unwrap()); + let secp = Secp256k1::new(); + let funding_address = Address::p2tr(&secp, pub_key, None, ctx.bitcoin_network); + + // Find the inscription UTXO: the output at vout 0 of the reveal transaction. + let utxos = bitcoin_get_utxos(&GetUtxosRequest { + address: funding_address.to_string(), + network: ctx.network.into(), + filter: None, + }) + .await + .unwrap() + .utxos; + + let inscription_utxo = utxos + .iter() + .find(|u| { + u.outpoint.txid.as_ref() + == reveal_txid.as_raw_hash().as_byte_array().as_slice() + && u.outpoint.vout == 0 + }) + .unwrap_or_else(|| { + trap("Inscription UTXO not found. Check the reveal txid and ensure it is confirmed.") + }); + + let inscription_value = inscription_utxo.value; + + // Build the transfer transaction using an iterative fee loop. + // The inscription UTXO is the sole input so its value must cover the fee. + let fee_per_byte = get_fee_per_byte(&ctx).await; + let mut total_fee = 0u64; + let (transaction, prevouts) = loop { + if total_fee >= inscription_value { + trap("Fee exceeds inscription output value"); + } + + let tx = build_ordinal_transfer_tx( + &reveal_txid, + inscription_value, + &funding_address, + &destination_address, + total_fee, + ); + + // Sign with a dummy key to get the correct virtual size for fee estimation. + let signed_tx = p2tr::sign_transaction_key_spend( + &ctx, + &funding_address, + tx.clone(), + &[TxOut { + value: Amount::from_sat(inscription_value), + script_pubkey: funding_address.script_pubkey(), + }], + vec![], + vec![], + mock_sign_with_schnorr, + ) + .await; + + let new_fee = (signed_tx.vsize() as u64 * fee_per_byte) / 1000; + if new_fee == total_fee { + let prevouts = vec![TxOut { + value: Amount::from_sat(inscription_value), + script_pubkey: funding_address.script_pubkey(), + }]; + break (tx, prevouts); + } + total_fee = new_fee; + }; + + // Sign with the real key and broadcast. + let signed_transaction = p2tr::sign_transaction_key_spend( + &ctx, + &funding_address, + transaction, + prevouts.as_slice(), + key_path.to_vec_u8_path(), + vec![], + sign_with_schnorr, + ) + .await; + + bitcoin_send_transaction(&SendTransactionRequest { + network: ctx.network.into(), + transaction: serialize(&signed_transaction), + }) + .await + .unwrap(); + + signed_transaction.compute_txid().to_string() +} + +/// Builds the unsigned ordinal transfer transaction. +/// +/// The inscription UTXO is the sole input. The inscription satoshi (the first sat of +/// the input) flows to vout 0 because Bitcoin assigns satoshis to outputs in order. +/// Any value left after the fee follows the same satoshi ordering, so vout 0 receives +/// everything — the inscription and the remaining satoshis. +fn build_ordinal_transfer_tx( + reveal_txid: &Txid, + inscription_value: u64, + _own_address: &Address, + destination_address: &Address, + fee: u64, +) -> Transaction { + // INSCRIPTION_OUTPUT_VALUE (15,000 sat) is safely above dust even after fees; + // the caller already traps if fee >= inscription_value. + let output_value = inscription_value.saturating_sub(fee); + + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: reveal_txid.to_owned(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(output_value), + script_pubkey: destination_address.script_pubkey(), + }], + } +} diff --git a/rust/basic_bitcoin/backend/src/service/transfer_rune.rs b/rust/basic_bitcoin/backend/src/service/transfer_rune.rs index a7909250b8..d8fc433d31 100644 --- a/rust/basic_bitcoin/backend/src/service/transfer_rune.rs +++ b/rust/basic_bitcoin/backend/src/service/transfer_rune.rs @@ -4,7 +4,7 @@ // rune balances from the spent UTXOs to the transaction outputs. use crate::{ - common::{get_fee_per_byte, select_utxos_greedy, DerivationPath}, + common::{get_fee_per_byte, DerivationPath}, p2tr, runes::{build_transfer_script, Edict}, schnorr::{get_schnorr_public_key, mock_sign_with_schnorr, sign_with_schnorr}, @@ -36,11 +36,18 @@ const DUST_AMOUNT: u64 = 1_000; /// Bitcoin transaction whose Runestone directs the rune balance from the spent /// input UTXOs to the specified recipient output. /// -/// Transaction output layout (vout indices matter for the edict): -/// vout[0] — recipient: receives the rune tokens + DUST_AMOUNT satoshis -/// vout[1] — OP_RETURN: Runestone with the transfer edict (no bitcoin value) +/// Transaction output layout (vout indices matter for the edict and pointer): +/// vout[0] — recipient: receives exactly `amount` rune tokens + DUST_AMOUNT satoshis +/// vout[1] — OP_RETURN: Runestone with the transfer edict and pointer (no bitcoin value) /// vout[2] — change: receives unallocated rune tokens + remaining satoshis /// +/// The Runestone contains: +/// - edict: send `amount` tokens of the given rune ID to vout[0] +/// - pointer: 2 — unallocated tokens (the remainder) go to vout[2] +/// +/// Without the pointer, unallocated runes would default to the first non-OP_RETURN +/// output (vout[0]), giving the recipient the entire balance instead of just `amount`. +/// /// The rune ID (`rune_id_block` : `rune_id_tx`) can be found in the ord explorer /// at http://127.0.0.1/rune/ after starting `ord --config-dir . server`. #[update] @@ -57,17 +64,19 @@ pub async fn transfer_rune(request: TransferRuneRequest) -> String { .require_network(ctx.bitcoin_network) .unwrap(); - // Derive the P2TR key used in etch_rune — same path means same address and rune balance. - 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()); + // Rune balances are held at the dedicated rune address (p2tr index 1), separate from the + // main funding address (p2tr index 0). All UTXOs there are rune-bearing outputs created by + // etch_rune and previous transfer change outputs — so spending all of them is correct. + let rune_key_path = DerivationPath::p2tr(0, 1); + let rune_key = get_schnorr_public_key(&ctx, rune_key_path.to_vec_u8_path()).await; + let rune_key = XOnlyPublicKey::from(PublicKey::from_slice(&rune_key).unwrap()); let secp256k1_engine = Secp256k1::new(); - let own_address = Address::p2tr(&secp256k1_engine, internal_key, None, ctx.bitcoin_network); + let rune_address = Address::p2tr(&secp256k1_engine, rune_key, None, ctx.bitcoin_network); - // Fetch UTXOs holding the rune balance at the canister's P2TR address. - let own_utxos = bitcoin_get_utxos(&GetUtxosRequest { - address: own_address.to_string(), + // Fetch all UTXOs at the rune address — every one of them holds rune balance. + let rune_utxos = bitcoin_get_utxos(&GetUtxosRequest { + address: rune_address.to_string(), network: ctx.network.into(), filter: None, }) @@ -75,27 +84,36 @@ pub async fn transfer_rune(request: TransferRuneRequest) -> String { .unwrap() .utxos; - // Build the Runestone: a single edict sends `amount` tokens of `rune_id` to vout[0]. - // Unallocated tokens (remainder after the edict) flow to the last non-OP_RETURN output - // (vout[2], the change output) by default. - let runestone_script = build_transfer_script(&[Edict { - rune_id_block: request.rune_id_block, - rune_id_tx: request.rune_id_tx, - amount: request.amount, - output: 0, // vout[0] is the recipient - }]) + if rune_utxos.is_empty() { + trap("No rune UTXOs found. Etch a rune first."); + } + + // Build the Runestone: + // - edict: send `amount` tokens to vout[0] (recipient) + // - pointer: 2 — unallocated tokens (the remainder) go to vout[2] (change) + // + // Without the pointer, unallocated runes default to the first non-OP_RETURN + // output (vout[0]), which would give the recipient the entire rune balance + // instead of just the requested amount. + let runestone_script = build_transfer_script( + &[Edict { + rune_id_block: request.rune_id_block, + rune_id_tx: request.rune_id_tx, + amount: request.amount, + output: 0, // vout[0] is the recipient + }], + Some(2), // pointer: unallocated runes go to vout[2] (change) + ) .unwrap_or_else(|e| trap(format!("Failed to build runestone: {}", e))); - // Iterative fee estimation: mock-sign to measure vsize, adjust fee, repeat until stable. + let utxos_to_spend: Vec<&_> = rune_utxos.iter().collect(); + let fee_per_byte = get_fee_per_byte(&ctx).await; let mut total_fee = 0u64; let (transaction, prevouts) = loop { - let utxos_to_spend = - select_utxos_greedy(&own_utxos, DUST_AMOUNT, total_fee).unwrap_or_else(|e| trap(e)); - let (tx, prevouts) = build_rune_transfer_tx( &utxos_to_spend, - &own_address, + &rune_address, &destination_address, &runestone_script, total_fee, @@ -103,7 +121,7 @@ pub async fn transfer_rune(request: TransferRuneRequest) -> String { let signed_tx = p2tr::sign_transaction_key_spend( &ctx, - &own_address, + &rune_address, tx.clone(), &prevouts, vec![], @@ -122,10 +140,10 @@ pub async fn transfer_rune(request: TransferRuneRequest) -> String { // Sign with the real Schnorr key and broadcast. let signed_transaction = p2tr::sign_transaction_key_spend( &ctx, - &own_address, + &rune_address, transaction, prevouts.as_slice(), - internal_key_path.to_vec_u8_path(), + rune_key_path.to_vec_u8_path(), vec![], sign_with_schnorr, ) @@ -143,8 +161,16 @@ pub async fn transfer_rune(request: TransferRuneRequest) -> String { /// Constructs the unsigned transaction for a rune transfer. /// -/// Output order is significant: the edict in the Runestone references outputs by -/// their vout index, so recipient must be at vout[0] for `output: 0` to be correct. +/// Output order is significant because the Runestone's edict and pointer reference +/// outputs by their vout index: +/// vout[0] — recipient (edict `output: 0`) +/// vout[1] — OP_RETURN / Runestone +/// vout[2] — change (Runestone `pointer: 2`) +/// +/// vout[2] must always exist: if it were absent the pointer would reference a +/// non-existent output, which the ord protocol treats as a cenotaph and burns +/// all rune tokens in the transaction. We trap early if the fee is so large +/// that no change satoshis remain. fn build_rune_transfer_tx( utxos_to_spend: &[&Utxo], own_address: &Address, @@ -152,8 +178,6 @@ fn build_rune_transfer_tx( runestone_script: &ScriptBuf, fee: u64, ) -> (Transaction, Vec) { - const DUST_THRESHOLD: u64 = 1_000; - let inputs: Vec = utxos_to_spend .iter() .map(|utxo| TxIn { @@ -180,10 +204,17 @@ fn build_rune_transfer_tx( .checked_sub(DUST_AMOUNT + fee) .unwrap_or_else(|| trap("fee exceeds inputs")); - // vout[0]: recipient — edict output 0, receives rune tokens + dust satoshis - // vout[1]: OP_RETURN — Runestone data, carries no bitcoin value - // vout[2]: change — receives unallocated rune tokens and remaining satoshis - let mut outputs = vec![ + // vout[2] must always be present for the Runestone pointer to be valid. + // In practice the rune UTXOs always have thousands of satoshis, so this + // threshold is only hit if something is severely wrong. + if change < DUST_AMOUNT { + trap("Insufficient satoshis for change output; fee too high"); + } + + // vout[0]: recipient — edict `output: 0`, receives `amount` rune tokens + // vout[1]: OP_RETURN — Runestone (edict + pointer), carries no bitcoin value + // vout[2]: change — Runestone `pointer: 2`, receives unallocated rune tokens + let outputs = vec![ TxOut { script_pubkey: destination_address.script_pubkey(), value: Amount::from_sat(DUST_AMOUNT), @@ -192,14 +223,11 @@ fn build_rune_transfer_tx( script_pubkey: runestone_script.clone(), value: Amount::from_sat(0), }, - ]; - - if change >= DUST_THRESHOLD { - outputs.push(TxOut { + TxOut { script_pubkey: own_address.script_pubkey(), value: Amount::from_sat(change), - }); - } + }, + ]; ( Transaction { diff --git a/rust/basic_bitcoin/test.sh b/rust/basic_bitcoin/test.sh index 16cd292ea1..16fcfbc09e 100755 --- a/rust/basic_bitcoin/test.sh +++ b/rust/basic_bitcoin/test.sh @@ -56,7 +56,7 @@ 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 -q 'tip_height = 101' && \ + 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 ===" From b9d7f4e5149a9134d6890da8f2a50027cd4a0f3c Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 9 Jul 2026 09:22:32 +0200 Subject: [PATCH 25/33] fix(rust/basic_bitcoin): final review corrections - runes.rs: encode flags field with encode_leb128_u128 (was silently truncated by `flags as u64`; Cenotaph flag at bit 127 would vanish) - runes.rs: use checked_sub for edict delta encoding so out-of-order edicts return Err instead of panicking or wrapping - ordinals.rs: replace saturating_sub with checked_sub + trap in build_rune_etch_transaction_with_fee to avoid silently burning the rune premine when fee exceeds commit output value - common.rs: type-annotate total_spent as u64 and use saturating_add to prevent theoretical overflow in UTXO accumulation - README.md: remove stale link to Motoko version Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/README.md | 2 -- rust/basic_bitcoin/backend/src/common.rs | 4 ++-- rust/basic_bitcoin/backend/src/ordinals.rs | 8 +++++++- rust/basic_bitcoin/backend/src/runes.rs | 16 ++++++++++++---- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 91694ac252..2518789c7d 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -4,8 +4,6 @@ This example demonstrates how a canister can receive and send bitcoin on the Int This example also includes how to work with Bitcoin assets such as Ordinals, Runes, and BRC-20 tokens. -See also the [Motoko version](../../motoko/basic_bitcoin). - ## Architecture For a deeper understanding of the ICP ↔ Bitcoin integration, see the [Bitcoin integration concepts](https://docs.internetcomputer.org/concepts/chain-fusion/bitcoin). diff --git a/rust/basic_bitcoin/backend/src/common.rs b/rust/basic_bitcoin/backend/src/common.rs index 73509b89cf..1c0e0ef5ba 100644 --- a/rust/basic_bitcoin/backend/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; diff --git a/rust/basic_bitcoin/backend/src/ordinals.rs b/rust/basic_bitcoin/backend/src/ordinals.rs index 9ae3941422..ca35089a33 100644 --- a/rust/basic_bitcoin/backend/src/ordinals.rs +++ b/rust/basic_bitcoin/backend/src/ordinals.rs @@ -1,4 +1,5 @@ use crate::{schnorr::sign_with_schnorr, BitcoinContext}; +use ic_cdk::trap; use bitcoin::{ absolute::LockTime, hashes::Hash, @@ -218,7 +219,12 @@ fn build_rune_etch_transaction_with_fee( script_pubkey: runestone_script.clone(), }; - let change_sats = commit_value.saturating_sub(fee); + let change_sats = commit_value.checked_sub(fee).unwrap_or_else(|| { + trap(format!( + "Etch fee ({} sats) exceeds commit output value ({} sats)", + fee, commit_value + )) + }); let mut outputs = vec![runestone_output]; // Only add change output if above dust threshold. if change_sats >= 1_000 { diff --git a/rust/basic_bitcoin/backend/src/runes.rs b/rust/basic_bitcoin/backend/src/runes.rs index fe4eb4c832..78846c847b 100644 --- a/rust/basic_bitcoin/backend/src/runes.rs +++ b/rust/basic_bitcoin/backend/src/runes.rs @@ -197,14 +197,22 @@ pub fn build_transfer_script(edicts: &[Edict], pointer: Option) -> Result Result { payload.extend_from_slice(&encode_leb128(etching.divisibility as u64)); } - // Tag 2: Flags + // Tag 2: Flags (u128 — Cenotaph flag sits at bit 127) payload.extend_from_slice(&encode_leb128(Tag::Flags as u64)); - payload.extend_from_slice(&encode_leb128(flags as u64)); + payload.extend_from_slice(&encode_leb128_u128(flags)); // Tag 3: Spacers (odd tag) if etching.spacers > 0 { From ba2683d52980a6cbb8f1eae58fd922de3ae3d691 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 9 Jul 2026 09:44:58 +0200 Subject: [PATCH 26/33] fix(rust/basic_bitcoin): address Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace unwrap() with descriptive trap() on user-supplied address parsing in transfer_ordinal, transfer_brc20, and transfer_rune - Replace fixed sleep 5 with a 30s polling loop in test.sh so CI doesn't race against the IC↔Bitcoin sync - Use cargo install --locked candid-extractor for reproducible builds Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/basic_bitcoin.yml | 2 +- .../backend/src/service/transfer_brc20.rs | 4 ++-- .../backend/src/service/transfer_ordinal.rs | 4 ++-- .../backend/src/service/transfer_rune.rs | 4 ++-- rust/basic_bitcoin/test.sh | 11 ++++++++++- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/basic_bitcoin.yml b/.github/workflows/basic_bitcoin.yml index 39c854ec44..a7d9107cc6 100644 --- a/.github/workflows/basic_bitcoin.yml +++ b/.github/workflows/basic_bitcoin.yml @@ -59,7 +59,7 @@ jobs: with: targets: wasm32-unknown-unknown - name: Install candid-extractor - run: cargo 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 diff --git a/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs b/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs index 8197ca5ecc..35e49ef7c2 100644 --- a/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs +++ b/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs @@ -58,9 +58,9 @@ pub async fn transfer_brc20(request: TransferBrc20Request) -> String { } let recipient = Address::from_str(&request.destination_address) - .unwrap() + .unwrap_or_else(|e| trap(format!("Invalid destination address: {}", e))) .require_network(ctx.bitcoin_network) - .unwrap(); + .unwrap_or_else(|e| trap(format!("Address is for wrong network: {}", e))); let tick = request.tick.to_uppercase(); diff --git a/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs b/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs index f9778c3f17..532a29c569 100644 --- a/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs +++ b/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs @@ -45,9 +45,9 @@ pub async fn transfer_ordinal(reveal_txid: String, destination_address: String) // Parse and validate the destination address. let destination_address = Address::from_str(&destination_address) - .unwrap() + .unwrap_or_else(|e| trap(format!("Invalid destination address: {}", e))) .require_network(ctx.bitcoin_network) - .unwrap(); + .unwrap_or_else(|e| trap(format!("Address is for wrong network: {}", e))); // Parse the reveal txid provided by the caller. let reveal_txid = Txid::from_str(&reveal_txid) diff --git a/rust/basic_bitcoin/backend/src/service/transfer_rune.rs b/rust/basic_bitcoin/backend/src/service/transfer_rune.rs index d8fc433d31..5d5c07640e 100644 --- a/rust/basic_bitcoin/backend/src/service/transfer_rune.rs +++ b/rust/basic_bitcoin/backend/src/service/transfer_rune.rs @@ -60,9 +60,9 @@ pub async fn transfer_rune(request: TransferRuneRequest) -> String { // Parse and validate the destination address for the current network. let destination_address = Address::from_str(&request.destination_address) - .unwrap() + .unwrap_or_else(|e| trap(format!("Invalid destination address: {}", e))) .require_network(ctx.bitcoin_network) - .unwrap(); + .unwrap_or_else(|e| trap(format!("Address is for wrong network: {}", e))); // Rune balances are held at the dedicated rune address (p2tr index 1), separate from the // main funding address (p2tr index 0). All UTXOs there are rune-bearing outputs created by diff --git a/rust/basic_bitcoin/test.sh b/rust/basic_bitcoin/test.sh index 16fcfbc09e..7b6bb6bd1a 100755 --- a/rust/basic_bitcoin/test.sh +++ b/rust/basic_bitcoin/test.sh @@ -43,7 +43,16 @@ addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr echo "mined 101 blocks to $addr" echo "=== Waiting for IC to sync Bitcoin blocks ===" -sleep 5 +_sync_addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') +for _i in $(seq 1 30); do + _bal=$(icp canister call backend get_balance "(\"$_sync_addr\")" 2>/dev/null) + if echo "$_bal" | grep -qE '[1-9]'; then + echo "IC synced after ${_i}s" + break + fi + [ "$_i" -eq 30 ] && echo "FAIL: IC did not sync within 30s" && exit 1 + sleep 1 +done echo "=== Test 6: get_balance returns non-zero after mining ===" addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') && \ From f5d0b51b1054f38acd72a9eb90d44589d9878803 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 9 Jul 2026 10:02:49 +0200 Subject: [PATCH 27/33] fix(rust/basic_bitcoin): fix IC sync polling in test.sh Two bugs in the sync wait loop: - Missing || true caused set -e to silently exit the script when the bitcoin integration canister rejected calls during its sync window ("Canister state is not fully synced") - grep -qE '[1-9]' false-positively matched the type annotation 'nat64' in responses like '(0 : nat64)', causing the loop to exit immediately even when the balance was still zero Fix: add || true to survive rejection, and use ^\([1-9] which only matches when the Candid value itself is non-zero. Apply the same pattern fix to test 6. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/test.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/rust/basic_bitcoin/test.sh b/rust/basic_bitcoin/test.sh index 7b6bb6bd1a..d9bd3ef335 100755 --- a/rust/basic_bitcoin/test.sh +++ b/rust/basic_bitcoin/test.sh @@ -45,8 +45,12 @@ addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr echo "=== Waiting for IC to sync Bitcoin blocks ===" _sync_addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') for _i in $(seq 1 30); do - _bal=$(icp canister call backend get_balance "(\"$_sync_addr\")" 2>/dev/null) - if echo "$_bal" | grep -qE '[1-9]'; then + # || true prevents set -e from exiting when the bitcoin canister rejects the call + # during its sync window ("Canister state is not fully synced"). + _bal=$(icp canister call backend get_balance "(\"$_sync_addr\")" 2>/dev/null) || true + # ^\([1-9] matches only a non-zero Candid value: "(500000..." — not "(0 : nat64)" + # and not error messages that start with "Error:". + if echo "$_bal" | grep -qE '^\([1-9]'; then echo "IC synced after ${_i}s" break fi @@ -58,7 +62,7 @@ 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 "$result" | grep -qE '^\([1-9]' && \ echo "PASS" || (echo "FAIL" && exit 1) echo "=== Test 7: get_utxos returns synced chain state after mining ===" From 849bd1e556c2c821e045b9599ea663e31c01cae9 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 9 Jul 2026 10:05:56 +0200 Subject: [PATCH 28/33] fix(rust/basic_bitcoin): poll get_utxos for IC sync detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_balance can return stale non-zero values from a previous run while the bitcoin integration canister is still syncing new blocks (and rejecting all fresh calls). Polling it gave a false positive, causing subsequent tests to hit "Canister state is not fully synced". Poll get_utxos instead: it throws on any transient sync window (the || { sleep; continue; } retries automatically), and only exits the loop once tip_height is genuinely non-zero — definitive proof that the IC has fully processed the newly mined blocks. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/test.sh | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/rust/basic_bitcoin/test.sh b/rust/basic_bitcoin/test.sh index d9bd3ef335..d4d958ea7c 100755 --- a/rust/basic_bitcoin/test.sh +++ b/rust/basic_bitcoin/test.sh @@ -44,19 +44,22 @@ addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr echo "=== Waiting for IC to sync Bitcoin blocks ===" _sync_addr=$(icp canister call backend get_p2pkh_address '()' | grep -o '"[^"]*"' | tr -d '"') -for _i in $(seq 1 30); do - # || true prevents set -e from exiting when the bitcoin canister rejects the call - # during its sync window ("Canister state is not fully synced"). - _bal=$(icp canister call backend get_balance "(\"$_sync_addr\")" 2>/dev/null) || true - # ^\([1-9] matches only a non-zero Candid value: "(500000..." — not "(0 : nat64)" - # and not error messages that start with "Error:". - if echo "$_bal" | grep -qE '^\([1-9]'; then - echo "IC synced after ${_i}s" - break - fi - [ "$_i" -eq 30 ] && echo "FAIL: IC did not sync within 30s" && exit 1 - sleep 1 +# 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 '"') && \ From 8467e878075445c7f8aa77ed6b2f3cb5e8d51a32 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 9 Jul 2026 10:11:58 +0200 Subject: [PATCH 29/33] fix(rust/basic_bitcoin): use test_key_1 for regtest instead of dfx_test_key test_key_1 works for both regtest and testnet. dfx_test_key is dfx-specific and should not be used or advertised in icp-cli examples. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/backend/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rust/basic_bitcoin/backend/src/lib.rs b/rust/basic_bitcoin/backend/src/lib.rs index 47daefad3f..1e7d3fe332 100644 --- a/rust/basic_bitcoin/backend/src/lib.rs +++ b/rust/basic_bitcoin/backend/src/lib.rs @@ -48,8 +48,7 @@ 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::Testnet => "test_key_1", + Network::Regtest | Network::Testnet => "test_key_1", Network::Mainnet => "key_1", }; From d5746d187a6b181fc94f71c80938f55b9bf5ca3f Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 10 Jul 2026 11:53:42 +0200 Subject: [PATCH 30/33] chore(rust/basic_bitcoin): drop Bitcoin asset protocols Removes Ordinals, Runes, and BRC-20 code from the migrated example. These were incomplete in the original example and the team lacks the domain expertise to maintain them long-term. The full implementation is preserved on the chore/migrate-rust-basic-bitcoin-to-icp-cli-with-assets branch. Also removes the now-dead select_one_utxo / SelectUtxosMode::Single and cleans up related Cargo deps (leb128, serde_bytes) and icp.yaml. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/Cargo.lock | 2 - rust/basic_bitcoin/README.md | 409 ------------------ rust/basic_bitcoin/backend/Cargo.toml | 2 - rust/basic_bitcoin/backend/src/brc20.rs | 168 ------- rust/basic_bitcoin/backend/src/common.rs | 25 -- rust/basic_bitcoin/backend/src/lib.rs | 27 -- rust/basic_bitcoin/backend/src/ordinals.rs | 327 -------------- rust/basic_bitcoin/backend/src/p2tr.rs | 14 +- rust/basic_bitcoin/backend/src/runes.rs | 377 ---------------- rust/basic_bitcoin/backend/src/service.rs | 8 - .../backend/src/service/etch_rune.rs | 237 ---------- .../backend/src/service/get_rune_address.rs | 20 - .../backend/src/service/inscribe_brc20.rs | 60 --- .../backend/src/service/inscribe_ordinal.rs | 193 --------- .../backend/src/service/mint_brc20.rs | 59 --- .../send_from_p2tr_key_path_only_address.rs | 1 - ...r_script_path_enabled_address_key_spend.rs | 1 - ...cript_path_enabled_address_script_spend.rs | 1 - .../backend/src/service/transfer_brc20.rs | 165 ------- .../backend/src/service/transfer_ordinal.rs | 186 -------- .../backend/src/service/transfer_rune.rs | 241 ----------- rust/basic_bitcoin/icp.yaml | 3 +- rust/basic_bitcoin/ord.yaml | 10 - 23 files changed, 3 insertions(+), 2533 deletions(-) delete mode 100644 rust/basic_bitcoin/backend/src/brc20.rs delete mode 100644 rust/basic_bitcoin/backend/src/ordinals.rs delete mode 100644 rust/basic_bitcoin/backend/src/runes.rs delete mode 100644 rust/basic_bitcoin/backend/src/service/etch_rune.rs delete mode 100644 rust/basic_bitcoin/backend/src/service/get_rune_address.rs delete mode 100644 rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs delete mode 100644 rust/basic_bitcoin/backend/src/service/inscribe_ordinal.rs delete mode 100644 rust/basic_bitcoin/backend/src/service/mint_brc20.rs delete mode 100644 rust/basic_bitcoin/backend/src/service/transfer_brc20.rs delete mode 100644 rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs delete mode 100644 rust/basic_bitcoin/backend/src/service/transfer_rune.rs delete mode 100644 rust/basic_bitcoin/ord.yaml diff --git a/rust/basic_bitcoin/Cargo.lock b/rust/basic_bitcoin/Cargo.lock index cbd21edc69..62add5671d 100644 --- a/rust/basic_bitcoin/Cargo.lock +++ b/rust/basic_bitcoin/Cargo.lock @@ -36,9 +36,7 @@ dependencies = [ "ic-cdk", "ic-cdk-bitcoin-canister", "ic-cdk-management-canister", - "leb128", "serde", - "serde_bytes", ] [[package]] diff --git a/rust/basic_bitcoin/README.md b/rust/basic_bitcoin/README.md index 2518789c7d..5dba5ed09f 100644 --- a/rust/basic_bitcoin/README.md +++ b/rust/basic_bitcoin/README.md @@ -2,8 +2,6 @@ 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. -This example also includes how to work with Bitcoin assets such as Ordinals, Runes, and BRC-20 tokens. - ## Architecture For a deeper understanding of the ICP ↔ Bitcoin integration, see the [Bitcoin integration concepts](https://docs.internetcomputer.org/concepts/chain-fusion/bitcoin). @@ -188,412 +186,6 @@ icp canister call backend get_block_headers '(10: nat32, opt (11: nat32))' 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 canister: - -- **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 - -### 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: - -```bash -brew install ord -``` - -For other platforms, see the [ord repository](https://github.com/ordinals/ord) for installation instructions. - -Make sure the `$CONTAINER` variable is set before running any of the steps below. If you haven't done so yet from the Bitcoin walkthrough above: - -```bash -CONTAINER=$(docker ps --filter "ancestor=icp-cli-network-launcher-bitcoin" --format "{{.ID}}" | head -1) -``` - -> **Note:** This repository includes a [default ord config file](./ord.yaml) that matches the also provided [bitcoin config file](./bitcoin.conf). - -> **Fresh network:** When you restart with `icp network start`, the ord server may show stale data from a previous session. Ord stores its index in `ord-db/regtest/` (inside the `basic_bitcoin` directory) and does not clear it automatically when the Bitcoin node resets. Ord detects the chain change and reindexes as soon as the first block is mined in the new instance — the mining step in the walkthrough below takes care of this. To start completely clean, delete the `ord-db/` directory before starting the ord server. - -> **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 -> ``` - -## Ordinals - -[Ordinals](https://ordinals.com) is a protocol that assigns a unique serial number to every satoshi, then allows arbitrary data (text, images, etc.) to be inscribed onto individual satoshis. Inscriptions are stored permanently in the Bitcoin blockchain using a two-transaction commit/reveal process. Two operations make up the full lifecycle: **inscribe** → **transfer**. - -### Inscribe an Ordinal - -#### Step 1 — Start the ord server (separate terminal) - -```bash -ord --config-dir . server -``` - -#### Step 2 — Fund a Taproot address - -```bash -ADDR=$(icp canister call backend get_p2tr_key_path_only_address '()' | grep -o '"[^"]*"' | tr -d '"') -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 101 "$ADDR" -``` - -#### Step 3 — Create the inscription - -```bash -TXID=$(icp canister call backend inscribe_ordinal '("Hello Bitcoin")' | grep -o '"[^"]*"' | tr -d '"') -echo "Reveal txid: $TXID" -``` - -#### Step 4 — Mine a confirmation block - -```bash -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$ADDR" -``` - -#### Step 5 — View in the ord explorer - -```bash -echo "http://127.0.0.1/inscription/${TXID}i0" -``` - -- All inscriptions: `http://127.0.0.1/inscriptions` - -> **Note:** The homepage at `http://127.0.0.1/` shows a "Latest Inscriptions" section that may appear empty — use `/inscriptions` instead. - -### Transfer an Ordinal - -An inscription is permanently bound to the first satoshi of the reveal output. To move the inscription to a new owner, that satoshi must flow to the first output (vout 0) of the spending transaction. `transfer_ordinal` achieves this by making the inscription UTXO the sole input — the inscription satoshi then flows directly to the recipient. - -> **Prerequisites:** Complete the inscribe steps above. `$TXID`, `$ADDR`, and `$CONTAINER` must be set. - -#### Step 1 — Transfer the inscription - -```bash -DEST="bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" -ORD_TRANSFER_TXID=$(icp canister call backend transfer_ordinal "(\"$TXID\", \"$DEST\")" | grep -o '"[^"]*"' | tr -d '"') -echo "Ordinal transfer txid: $ORD_TRANSFER_TXID" -``` - -#### Step 2 — Mine a confirmation block - -```bash -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$ADDR" -``` - -#### Step 3 — Verify the new owner - -Query the inscription's owner via the ord JSON API. Before the transfer it shows the canister's Taproot address; after mining the block it should show the destination: - -```bash -curl -s -H "Accept: application/json" "http://127.0.0.1/inscription/${TXID}i0" | python3 -m json.tool | grep address -# Expected: "address": "bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" -``` - -You can also view the inscription in the ord explorer and confirm ownership visually: - -```bash -echo "http://127.0.0.1/inscription/${TXID}i0" -``` - -> **How satoshi tracking works:** The Ordinals protocol assigns ordinal numbers to satoshis in the order they are mined. The inscription is associated with the first satoshi of the reveal output. When that UTXO is spent as the first (and only) input, Bitcoin's satoshi-ordering rules guarantee the first satoshi lands in vout 0 — so the recipient at vout 0 receives the inscription. This is why `transfer_ordinal` uses the reveal UTXO as the sole input and puts the recipient at output index 0. - -## Runes - -[Runes](https://docs.ordinals.com/runes.html) is a fungible token protocol that embeds token metadata directly into Bitcoin transactions using `OP_RETURN` outputs. Two operations make up the full lifecycle: **etch** → **transfer**. - -### Etch a Rune - -Etching a rune requires two transactions separated by at least 6 block confirmations: - -1. **Commit** — creates a P2TR output whose tapscript contains the rune name commitment bytes -2. **Etch** — spends that output (via script-path, placing the commitment in the witness) together with an `OP_RETURN` Runestone - -The ord indexer validates that the etching transaction's input spends a P2TR output containing the rune commitment bytes, and that the committed output has at least 6 block confirmations. Without this, the etching is silently ignored and no Rune ID is assigned. - -#### Step 1 — Start the ord server (separate terminal) - -```bash -ord --config-dir . server -``` - -#### Step 2 — Fund a Taproot address - -```bash -ADDR=$(icp canister call backend get_p2tr_key_path_only_address '()' | grep -o '"[^"]*"' | tr -d '"') -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 101 "$ADDR" -``` - -#### Step 3 — Commit to the rune name - -The name must be uppercase and between 1–28 characters. The Runes protocol uses a block-height-based unlock schedule — **names with 13 or more characters are immediately active** in regtest at any block height. - -> **Why 13+ characters?** The ord indexer uses linear interpolation to derive a minimum rune value per block. Every 13-char name has a numeric value above `STEPS[12] = 99,246,114,928,149,462` — the highest minimum the formula ever produces — so 13-char names are immediately active at any block height. Shorter names have unlock heights that must be mined past (12-char names unlock across blocks 0–17,499; 7-char names across 87,500–104,999). - -> **Turbo mode**: The `etch_rune` implementation sets `turbo: true`, which opts the rune into future ord protocol upgrades. It does **not** bypass the name unlock schedule. - -```bash -COMMIT_TXID=$(icp canister call backend commit_rune '("BASICBITCOINS")' | grep -o '"[^"]*"' | tr -d '"') -echo "Commit txid: $COMMIT_TXID" -``` - -#### Step 4 — Mine 6 blocks to mature the commitment - -The ord protocol requires the commit output to have at least 6 block confirmations before the etching is accepted. - -```bash -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 6 "$ADDR" -``` - -#### Step 5 — Etch the Rune - -```bash -ETCH_TXID=$(icp canister call backend etch_rune '("BASICBITCOINS")' | grep -o '"[^"]*"' | tr -d '"') -echo "Etch txid: $ETCH_TXID" -``` - -#### Step 6 — Mine a confirmation block - -```bash -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$ADDR" -``` - -#### Step 7 — Decode the Runestone to verify - -```bash -ord --config-dir . decode --txid "$ETCH_TXID" -# Expected output: -# { -# "inscriptions": [], -# "runestone": { -# "Runestone": { -# "edicts": [], -# "etching": { -# "divisibility": null, -# "premine": 1000000, -# "rune": "BASICBITCOINS", -# "spacers": null, -# "symbol": "🪙", -# "terms": null, -# "turbo": true -# }, -# "mint": null, -# "pointer": null -# } -# } -# } -``` - -#### Step 8 — View in the ord explorer - -Open the rune in the ord explorer: `http://127.0.0.1/rune/BASICBITCOINS` - -- All runes: `http://127.0.0.1/runes` - -The Rune is now etched with 1,000,000 tokens minted to your address. - -### Transfer a Rune - -#### Step 1 — Look up the rune ID and verify your balance - -The rune ID (block height : transaction index) is required to identify which rune to transfer. Fetch it from the ord JSON API: - -```bash -RUNE_ID=$(curl -s -H "Accept: application/json" http://127.0.0.1/rune/BASICBITCOINS | \ - tr -d '[:space:]' | grep -o '"id":"[0-9]*:[0-9]*"' | cut -d'"' -f4) -RUNE_BLOCK=$(echo "$RUNE_ID" | cut -d: -f1) -RUNE_TX=$(echo "$RUNE_ID" | cut -d: -f2) -echo "Rune ID: $RUNE_BLOCK:$RUNE_TX" -``` - -The 1,000,000 premined tokens are held at the canister's dedicated rune address (derivation index 1, separate from the main funding address). Check the balance: - -```bash -RUNE_ADDR=$(icp canister call backend get_rune_address '()' | grep -o '"[^"]*"' | tr -d '"') -curl -s -H "Accept: application/json" "http://127.0.0.1/address/$RUNE_ADDR" | python3 -m json.tool | grep -A6 '"runes"' -# Expected: "BASICBITCOINS": { "amount": 1000000, ... } -``` - -> **Note:** If the balance shows empty, ord may still be indexing the etching block. Wait a moment and retry. - -#### Step 2 — Transfer rune tokens - -```bash -DEST="bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" -TRANSFER_TXID=$(icp canister call backend transfer_rune "(record { - rune_id_block = ${RUNE_BLOCK}: nat64; - rune_id_tx = ${RUNE_TX}: nat32; - amount = 100000: nat64; - destination_address = \"$DEST\"; -})" | grep -o '"[^"]*"' | tr -d '"') -echo "Transfer txid: $TRANSFER_TXID" -``` - -#### Step 3 — Mine a confirmation block - -```bash -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$ADDR" -``` - -#### Step 4 — Verify the transfer - -Decode the transfer transaction to confirm the Runestone edict is correct: - -```bash -ord --config-dir . decode --txid "$TRANSFER_TXID" -``` - -The output should contain a Runestone with an edict assigning 100,000 tokens to output 0 (the recipient) and a `pointer: 2` directing the unallocated remainder to output 2 (the change). Check the resulting output balances: - -```bash -# Recipient — should show 100,000 BASICBITCOINS -curl -s -H "Accept: application/json" "http://127.0.0.1/output/${TRANSFER_TXID}:0" | python3 -m json.tool - -# Change (rune address) — should show 900,000 BASICBITCOINS -curl -s -H "Accept: application/json" "http://127.0.0.1/output/${TRANSFER_TXID}:2" | python3 -m json.tool -``` - -## BRC-20 tokens - -[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. Three operations make up the full lifecycle: **deploy** → **mint** → **transfer**. - -### Deploy a BRC-20 token - -#### Step 1 — Start the ord server (separate terminal) - -```bash -ord --config-dir . server -``` - -#### Step 2 — Fund a Taproot address - -```bash -ADDR=$(icp canister call backend get_p2tr_key_path_only_address '()' | grep -o '"[^"]*"' | tr -d '"') -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 101 "$ADDR" -``` - -#### Step 3 — Deploy the BRC-20 token - -The ticker must be exactly 4 characters: - -```bash -DEPLOY_TXID=$(icp canister call backend inscribe_brc20 '("DEMO")' | grep -o '"[^"]*"' | tr -d '"') -echo "BRC-20 deploy txid: $DEPLOY_TXID" -``` - -#### Step 4 — Mine a confirmation block - -```bash -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$ADDR" -``` - -This creates a BRC-20 token with: -- Ticker: "DEMO" -- Max supply: 21,000,000 tokens -- Mint limit: 1,000 tokens per mint - -View the deploy inscription in the ord explorer: - -```bash -echo "http://127.0.0.1/inscription/${DEPLOY_TXID}i0" -``` - -### Mint BRC-20 tokens - -Minting claims tokens from the deployed supply and credits them to the canister's address. The amount must not exceed the per-mint limit (1,000 in this example). - -#### Step 1 — Mint tokens - -```bash -MINT_TXID=$(icp canister call backend mint_brc20 '("DEMO", 1000: nat64)' | grep -o '"[^"]*"' | tr -d '"') -echo "BRC-20 mint txid: $MINT_TXID" -``` - -#### Step 2 — Mine a confirmation block - -```bash -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$ADDR" -``` - -#### Step 3 — View the mint inscription - -```bash -echo "http://127.0.0.1/inscription/${MINT_TXID}i0" -``` - -### Transfer BRC-20 tokens - -A BRC-20 transfer chains three Bitcoin transactions internally: -1. **Commit** — funds a Taproot address committed to the transfer JSON script -2. **Reveal** — spends the commit output, creating the transfer inscription at the canister's own address (locking `amount` tokens from the sender's balance) -3. **Send** — moves the inscription UTXO from the canister's address to the recipient - -BRC-20 indexers see the inscription travel from sender → recipient and credit the recipient's balance accordingly. The returned txid is the send transaction (step 3). - -#### Step 1 — Transfer tokens - -```bash -DEST="bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" -TRANSFER_TXID=$(icp canister call backend transfer_brc20 '(record { - tick = "DEMO"; - amount = 500: nat64; - destination_address = "'"$DEST"'"; -})' | grep -o '"[^"]*"' | tr -d '"') -echo "BRC-20 send txid: $TRANSFER_TXID" -``` - -#### Step 2 — Mine a confirmation block - -```bash -docker exec $CONTAINER bitcoin-cli -regtest \ - -rpcuser=ic-btc-integration -rpcpassword=ic-btc-integration \ - generatetoaddress 1 "$ADDR" -``` - -#### Step 3 — Verify the inscription arrived at the recipient - -```bash -curl -s -H "Accept: application/json" "http://127.0.0.1/output/${TRANSFER_TXID}:0" | python3 -m json.tool | grep -E '"address"|"inscriptions"' -# Expected: -# "address": "bcrt1qg8qknn6f3txqg97gt8ca0ctya0vw7ep6d02qmt" -# "inscriptions": ["i0"] -``` - -You can view the inscription content (the transfer JSON) in the ord explorer: - -```bash -# Get the inscription ID from the output and open it in the ord explorer -INSCRIPTION_ID=$(curl -s -H "Accept: application/json" "http://127.0.0.1/output/${TRANSFER_TXID}:0" | python3 -c "import sys,json; print(json.load(sys.stdin)['inscriptions'][0])") -echo "http://127.0.0.1/inscription/$INSCRIPTION_ID" -``` - -The recipient now holds the BRC-20 transfer inscription. BRC-20 indexers credit 500 DEMO to the recipient's balance and debit it from the canister's balance. - ## Notes on implementation This example implements several important patterns for Bitcoin integration: @@ -602,7 +194,6 @@ 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 diff --git a/rust/basic_bitcoin/backend/Cargo.toml b/rust/basic_bitcoin/backend/Cargo.toml index 66f20ffe91..929a632173 100644 --- a/rust/basic_bitcoin/backend/Cargo.toml +++ b/rust/basic_bitcoin/backend/Cargo.toml @@ -14,6 +14,4 @@ hex = "0.4" ic-cdk = "0.20.2" ic-cdk-bitcoin-canister = "0.2" ic-cdk-management-canister = "0.1.1" -leb128 = "0.2" serde = "1.0" -serde_bytes = "0.11" diff --git a/rust/basic_bitcoin/backend/src/brc20.rs b/rust/basic_bitcoin/backend/src/brc20.rs deleted file mode 100644 index 284ed679c5..0000000000 --- a/rust/basic_bitcoin/backend/src/brc20.rs +++ /dev/null @@ -1,168 +0,0 @@ -use crate::{ - common::{get_fee_per_byte, DerivationPath, PrimaryOutput}, - ordinals::{build_reveal_transaction, create_script_path_witness, INSCRIPTION_OUTPUT_VALUE}, - p2tr, - schnorr::{get_schnorr_public_key, sign_with_schnorr}, - BitcoinContext, -}; -use bitcoin::{ - consensus::serialize, - opcodes::{all::*, OP_FALSE}, - script::{Builder, PushBytesBuf}, - secp256k1::{PublicKey, Secp256k1}, - taproot::{LeafVersion, TaprootBuilder}, - Address, ScriptBuf, XOnlyPublicKey, -}; -use ic_cdk_bitcoin_canister::{ - bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, -}; - -/// 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"text/plain;charset=utf-8") // BRC-20 spec requires text/plain;charset=utf-8 - .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() -} - -/// Builds and broadcasts a BRC-20 commit + reveal transaction pair. -/// -/// This handles the two-transaction pattern shared by all BRC-20 operations -/// (deploy, mint, transfer-inscription): -/// 1. Commit: Send funds to a Taproot address that commits to the BRC-20 script -/// 2. Reveal: Spend from that address, revealing the BRC-20 JSON in the witness -/// -/// The reveal output (the inscription UTXO) is sent to `destination_address`. -/// Returns `(reveal_txid, reveal_output_value_sats)`. The output value is the -/// actual amount in the reveal UTXO after fees — callers that spend this output -/// must use this value in the Taproot sighash (BIP341 commits to exact prevout amounts). -pub(crate) async fn commit_and_reveal( - ctx: &BitcoinContext, - brc20_json: &str, - destination_address: &Address, -) -> (String, u64) { - 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()); - - let reveal_script = build_brc20_reveal_script(&internal_key, brc20_json); - - let secp256k1_engine = Secp256k1::new(); - let taproot_spend_info = TaprootBuilder::new() - .add_leaf(0, reveal_script.clone()) - .unwrap() - .finalize(&secp256k1_engine, internal_key) - .unwrap(); - - let commit_address = - Address::p2tr_tweaked(taproot_spend_info.output_key(), ctx.bitcoin_network); - let funding_address = - Address::p2tr(&secp256k1_engine, internal_key, None, ctx.bitcoin_network); - - let own_utxos = bitcoin_get_utxos(&GetUtxosRequest { - address: funding_address.to_string(), - network: ctx.network.into(), - filter: None, - }) - .await - .unwrap() - .utxos; - - let fee_per_byte = get_fee_per_byte(ctx).await; - - let (commit_tx, prevouts) = p2tr::build_transaction( - ctx, - &funding_address, - &own_utxos, - p2tr::SelectUtxosMode::Single, - &PrimaryOutput::Address(commit_address, INSCRIPTION_OUTPUT_VALUE), - fee_per_byte, - ) - .await; - - let signed_commit = p2tr::sign_transaction_key_spend( - ctx, - &funding_address, - commit_tx, - prevouts.as_slice(), - internal_key_path.to_vec_u8_path(), - vec![], - sign_with_schnorr, - ) - .await; - - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&signed_commit), - }) - .await - .unwrap(); - - let control_block = taproot_spend_info - .control_block(&(reveal_script.clone(), LeafVersion::TapScript)) - .unwrap(); - - let mut reveal_tx = build_reveal_transaction( - destination_address, - &reveal_script, - &control_block, - &signed_commit.compute_txid(), - fee_per_byte, - ) - .await; - - create_script_path_witness( - ctx, - &mut reveal_tx, - &signed_commit.output[0], - &reveal_script, - &control_block, - internal_key_path.to_vec_u8_path(), - ) - .await; - - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&reveal_tx), - }) - .await - .unwrap(); - - let reveal_output_value = reveal_tx.output[0].value.to_sat(); - (reveal_tx.compute_txid().to_string(), reveal_output_value) -} diff --git a/rust/basic_bitcoin/backend/src/common.rs b/rust/basic_bitcoin/backend/src/common.rs index 1c0e0ef5ba..7fe06add6a 100644 --- a/rust/basic_bitcoin/backend/src/common.rs +++ b/rust/basic_bitcoin/backend/src/common.rs @@ -46,33 +46,8 @@ pub fn select_utxos_greedy( Ok(utxos_to_spend) } -/// 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. -/// -/// 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> { - for utxo in own_utxos.iter().rev() { - if utxo.value >= amount + fee { - return Ok(vec![&utxo]); - } - } - - Err(format!( - "No sufficiently large utxo found: amount {} satoshi, fee {}", - amount, fee - )) -} - /// Represents the primary output type for a Bitcoin transaction. -/// -/// This enum allows transaction builders to specify whether they want to send -/// bitcoin to an address (normal payment) or embed data using OP_RETURN (for -/// protocols like Runes that store metadata on-chain). pub enum PrimaryOutput { - /// Pay someone (spendable output). Address(Address, u64), // destination address, amount in satoshis } diff --git a/rust/basic_bitcoin/backend/src/lib.rs b/rust/basic_bitcoin/backend/src/lib.rs index 1e7d3fe332..c1c303e12f 100644 --- a/rust/basic_bitcoin/backend/src/lib.rs +++ b/rust/basic_bitcoin/backend/src/lib.rs @@ -1,11 +1,8 @@ -mod brc20; mod common; mod ecdsa; -mod ordinals; mod p2pkh; mod p2tr; mod p2wpkh; -mod runes; mod schnorr; mod service; @@ -89,28 +86,4 @@ pub struct SendRequest { pub amount_in_satoshi: u64, } -/// Input structure for transferring Rune tokens to another address. -#[derive(candid::CandidType, candid::Deserialize)] -pub struct TransferRuneRequest { - /// Block height of the etching transaction (first component of the rune ID). - pub rune_id_block: u64, - /// Transaction index within that block (second component of the rune ID). - pub rune_id_tx: u32, - /// Number of rune tokens to send. - pub amount: u64, - /// Bitcoin address of the recipient. - pub destination_address: String, -} - -/// Input structure for transferring BRC-20 tokens to another address. -#[derive(candid::CandidType, candid::Deserialize)] -pub struct TransferBrc20Request { - /// 4-character BRC-20 ticker symbol (e.g. "DEMO"). - pub tick: String, - /// Number of tokens to transfer. - pub amount: u64, - /// Bitcoin address of the recipient. - pub destination_address: String, -} - ic_cdk::export_candid!(); diff --git a/rust/basic_bitcoin/backend/src/ordinals.rs b/rust/basic_bitcoin/backend/src/ordinals.rs deleted file mode 100644 index ca35089a33..0000000000 --- a/rust/basic_bitcoin/backend/src/ordinals.rs +++ /dev/null @@ -1,327 +0,0 @@ -use crate::{schnorr::sign_with_schnorr, BitcoinContext}; -use ic_cdk::trap; -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. - let output_value = INSCRIPTION_OUTPUT_VALUE - .checked_sub(fee) - .ok_or_else(|| { - format!( - "Fee ({} sats) exceeds inscription output value ({} sats)", - fee, INSCRIPTION_OUTPUT_VALUE - ) - })?; - let output = TxOut { - value: Amount::from_sat(output_value), - 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() -} - -/// Builds the rune etch (reveal) transaction that spends the commit output. -/// -/// Output layout: -/// vout[0] — OP_RETURN Runestone (0 sat), encodes the etching parameters -/// vout[1] — change output back to own address (commit_value minus fee) -/// -/// Uses the same iterative fee-convergence as `build_reveal_transaction`. -pub(crate) async fn build_rune_etch_transaction( - change_address: &Address, - runestone_script: &ScriptBuf, - commit_script: &ScriptBuf, - control_block: &ControlBlock, - commit_txid: &Txid, - commit_vout: u32, - commit_value: u64, - fee_per_byte: MillisatoshiPerByte, -) -> Transaction { - let mut fee = 0u64; - loop { - let transaction = build_rune_etch_transaction_with_fee( - change_address, - runestone_script, - commit_script, - control_block, - commit_txid, - commit_vout, - commit_value, - fee, - ); - - let virtual_size = transaction.vsize() as u64; - if (virtual_size * fee_per_byte) / 1000 == fee { - return transaction; - } else { - fee = (virtual_size * fee_per_byte) / 1000; - } - } -} - -fn build_rune_etch_transaction_with_fee( - change_address: &Address, - runestone_script: &ScriptBuf, - commit_script: &ScriptBuf, - control_block: &ControlBlock, - commit_txid: &Txid, - commit_vout: u32, - commit_value: u64, - fee: u64, -) -> Transaction { - let input = TxIn { - previous_output: OutPoint { - txid: commit_txid.to_owned(), - vout: commit_vout, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::new(), - }; - - let runestone_output = TxOut { - value: Amount::ZERO, - script_pubkey: runestone_script.clone(), - }; - - let change_sats = commit_value.checked_sub(fee).unwrap_or_else(|| { - trap(format!( - "Etch fee ({} sats) exceeds commit output value ({} sats)", - fee, commit_value - )) - }); - let mut outputs = vec![runestone_output]; - // Only add change output if above dust threshold. - if change_sats >= 1_000 { - outputs.push(TxOut { - value: Amount::from_sat(change_sats), - script_pubkey: change_address.script_pubkey(), - }); - } - - let mut transaction = Transaction { - version: Version::TWO, - lock_time: LockTime::ZERO, - input: vec![input], - output: outputs, - }; - - // Placeholder witness for virtual-size estimation. - transaction.input[0].witness.push(SCHNORR_SIGNATURE_PLACEHOLDER); - transaction.input[0].witness.push(commit_script.to_bytes()); - transaction.input[0].witness.push(control_block.serialize()); - - transaction -} - -/// 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/backend/src/p2tr.rs b/rust/basic_bitcoin/backend/src/p2tr.rs index d4a35cfb43..b7e90d8ab6 100644 --- a/rust/basic_bitcoin/backend/src/p2tr.rs +++ b/rust/basic_bitcoin/backend/src/p2tr.rs @@ -1,5 +1,5 @@ use crate::{ - common::{build_transaction_with_fee, select_one_utxo, select_utxos_greedy, PrimaryOutput}, + common::{build_transaction_with_fee, select_utxos_greedy, PrimaryOutput}, schnorr::mock_sign_with_schnorr, BitcoinContext, }; @@ -61,18 +61,12 @@ pub fn create_spend_script(script_key_bytes: &[u8]) -> ScriptBuf { .into_script() } -pub enum SelectUtxosMode { - Greedy, - Single, -} - // Builds a P2TR transaction to send the given `amount` of satoshis to the // destination address. pub(crate) async fn build_transaction( ctx: &BitcoinContext, own_address: &Address, own_utxos: &[Utxo], - utxos_mode: SelectUtxosMode, primary_output: &PrimaryOutput, fee_per_byte: MillisatoshiPerByte, ) -> (Transaction, Vec) { @@ -88,11 +82,7 @@ pub(crate) async fn build_transaction( let amount = *amount; let mut total_fee = 0; loop { - let utxos_to_spend = match utxos_mode { - SelectUtxosMode::Greedy => select_utxos_greedy(own_utxos, amount, total_fee), - SelectUtxosMode::Single => select_one_utxo(own_utxos, amount, total_fee), - } - .unwrap(); + let utxos_to_spend = select_utxos_greedy(own_utxos, amount, total_fee).unwrap(); let (transaction, prevouts) = build_transaction_with_fee(utxos_to_spend, own_address, primary_output, total_fee) diff --git a/rust/basic_bitcoin/backend/src/runes.rs b/rust/basic_bitcoin/backend/src/runes.rs deleted file mode 100644 index 78846c847b..0000000000 --- a/rust/basic_bitcoin/backend/src/runes.rs +++ /dev/null @@ -1,377 +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, XOnlyPublicKey, -}; -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, - 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 u64 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 u128 as LEB128. Used for rune names, premine, amount, and cap fields, -/// which are all u128 in the Runes protocol specification. -pub fn encode_leb128_u128(mut value: u128) -> Vec { - let mut buf = Vec::new(); - loop { - let byte = (value & 0x7f) as u8; - value >>= 7; - if value == 0 { - buf.push(byte); - break; - } else { - buf.push(byte | 0x80); - } - } - buf -} - -/// Encodes a rune name into its numeric representation as u128. -/// -/// 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. -/// The result is u128 because names of 14+ characters can exceed u64::MAX. -pub fn encode_rune_name(name: &str) -> Result { - if name.is_empty() { - return Err("Rune name cannot be empty".to_string()); - } - - let mut value = 0u128; - 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 u128; - 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 -} - -/// An edict in a Runestone that transfers rune tokens to a specific transaction output. -/// -/// Edicts describe how to allocate rune balances among transaction outputs. -/// The rune ID (block + tx) uniquely identifies which rune to transfer. -pub struct Edict { - /// Block height where the rune was etched (first component of rune ID). - pub rune_id_block: u64, - /// Transaction index within that block (second component of rune ID). - pub rune_id_tx: u32, - /// Number of rune tokens to move to the specified output. - pub amount: u64, - /// Index into the transaction's vout array that receives the rune tokens. - /// All vouts (including OP_RETURN) count toward the index. Pointing an edict - /// at an OP_RETURN output or a non-existent output burns those tokens. - pub output: u32, -} - -/// Builds a runestone script for transferring rune tokens between addresses. -/// -/// A transfer runestone contains only edicts — no etching fields. The edicts -/// direct the protocol to move tokens from input UTXOs holding rune balances -/// to specific transaction outputs. Any balance not explicitly allocated by an -/// edict flows to the first non-OP_RETURN output by default. -/// -/// `pointer`, if set, overrides the default: unallocated runes go to the output -/// at that vout index instead of the first non-OP_RETURN output. This is used to -/// direct change rune tokens to the change output (vout[2]) when the recipient -/// is at vout[0] and the OP_RETURN is at vout[1]. -pub fn build_transfer_script(edicts: &[Edict], pointer: Option) -> Result { - if edicts.is_empty() { - return Err("At least one edict is required".to_string()); - } - - let mut payload = Vec::new(); - - // Named fields must appear before the Body (0) separator. - // Tag 22: Pointer — vout index that receives unallocated runes. - // Without this, unallocated runes would default to the first non-OP_RETURN - // output (vout[0], the recipient), incorrectly giving them the full balance. - if let Some(p) = pointer { - payload.extend_from_slice(&encode_leb128(Tag::Pointer as u64)); - payload.extend_from_slice(&encode_leb128(p as u64)); - } - - // Tag 0 (Body): separator signalling that edicts follow. - payload.extend_from_slice(&encode_leb128(Tag::Body as u64)); - - // Edicts are encoded as groups of 4 LEB128 integers: [block, tx, amount, output]. - // Block and tx use delta encoding relative to the previous edict's rune ID. - // Edicts must arrive in ascending rune-ID order; underflow means caller violated that invariant. - let mut prev_block: u64 = 0; - let mut prev_tx: u32 = 0; - for edict in edicts { - let block_delta = edict - .rune_id_block - .checked_sub(prev_block) - .ok_or("Edicts must be sorted by rune ID in ascending order")?; - // When the block delta is 0 (same block), tx is a delta from the previous tx. - // When the block delta is non-zero (different block), tx is an absolute value. - let tx_encoded = if block_delta == 0 { - edict - .rune_id_tx - .checked_sub(prev_tx) - .ok_or("Edicts within the same block must be sorted by tx index in ascending order")? - as u64 - } else { - edict.rune_id_tx as u64 - }; - - payload.extend_from_slice(&encode_leb128(block_delta)); - payload.extend_from_slice(&encode_leb128(tx_encoded)); - payload.extend_from_slice(&encode_leb128(edict.amount)); - payload.extend_from_slice(&encode_leb128(edict.output as u64)); - - prev_block = edict.rune_id_block; - prev_tx = edict.rune_id_tx; - } - - let mut builder = Builder::new().push_opcode(OP_RETURN); - builder = builder.push_opcode(OP_PUSHNUM_13); - - 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()) -} - -/// Computes the commitment bytes for a rune name. -/// -/// The commitment is the rune's numeric encoding as a u128 value in little-endian byte order -/// with trailing zero bytes stripped. The ord indexer scans the commit tapscript for a pushdata -/// instruction equal to these bytes to validate that the etching transaction committed to the name. -pub fn build_rune_commitment_bytes(name: &str) -> Result, String> { - let encoded = encode_rune_name(name)?; - let bytes = encoded.to_le_bytes(); - let mut end = bytes.len(); - while end > 0 && bytes[end - 1] == 0 { - end -= 1; - } - Ok(bytes[..end].to_vec()) -} - -/// Builds the tapscript for a rune commit transaction. -/// -/// The commit script embeds the rune commitment bytes as a pushdata instruction so that -/// the ord indexer can find them when validating the etching. Script structure: -/// -/// OP_DROP OP_CHECKSIG -/// -/// Spending this output via script-path places the tapscript in the witness, satisfying -/// the ord protocol requirement that etching inputs commit to the rune name. -pub fn build_rune_commit_script(internal_key: &XOnlyPublicKey, name: &str) -> Result { - let commitment = build_rune_commitment_bytes(name)?; - let mut push_buf = PushBytesBuf::new(); - push_buf - .extend_from_slice(&commitment) - .map_err(|e| e.to_string())?; - Ok(Builder::new() - .push_slice(&push_buf) - .push_opcode(OP_DROP) - .push_slice(internal_key.serialize()) - .push_opcode(OP_CHECKSIG) - .into_script()) -} - -/// 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 (u128 — Cenotaph flag sits at bit 127) - payload.extend_from_slice(&encode_leb128(Tag::Flags as u64)); - payload.extend_from_slice(&encode_leb128_u128(flags)); - - // 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 (u128 — names ≥14 chars exceed u64) - payload.extend_from_slice(&encode_leb128(Tag::Rune as u64)); - payload.extend_from_slice(&encode_leb128_u128(encoded_name)); - - // 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 (u128 — protocol supports values beyond u64) - if etching.premine > 0 { - payload.extend_from_slice(&encode_leb128(Tag::Premine as u64)); - payload.extend_from_slice(&encode_leb128_u128(etching.premine)); - } - - // 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_u128(amount)); - } - if let Some(cap) = terms.cap { - payload.extend_from_slice(&encode_leb128(Tag::Cap as u64)); - payload.extend_from_slice(&encode_leb128_u128(cap)); - } - 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/backend/src/service.rs b/rust/basic_bitcoin/backend/src/service.rs index 2ce606513b..4e21a681a3 100644 --- a/rust/basic_bitcoin/backend/src/service.rs +++ b/rust/basic_bitcoin/backend/src/service.rs @@ -1,6 +1,3 @@ -pub mod etch_rune; -pub mod get_rune_address; -pub mod transfer_rune; pub mod get_balance; pub mod get_blockchain_info; pub mod get_block_headers; @@ -10,11 +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 mint_brc20; -pub mod transfer_brc20; -pub mod inscribe_ordinal; -pub mod transfer_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/backend/src/service/etch_rune.rs b/rust/basic_bitcoin/backend/src/service/etch_rune.rs deleted file mode 100644 index 2b3b7c3492..0000000000 --- a/rust/basic_bitcoin/backend/src/service/etch_rune.rs +++ /dev/null @@ -1,237 +0,0 @@ -// Rune etching uses a commit+reveal pattern required by the ord protocol. -// -// Step 1 — commit_rune: Create a P2TR output whose tapscript contains the rune commitment -// bytes (rune name encoded as u128 LE, trailing zeros stripped). Wait 6 blocks. -// -// Step 2 — etch_rune: Spend that commit output via script-path (which places the tapscript -// in the witness). The etching transaction also contains an OP_RETURN Runestone. -// The ord indexer finds the commitment bytes in the witness, verifies 6+ confirmations -// on the commit output, and assigns a Rune ID. -// -// Without a valid committed input the indexer silently ignores the etching (no Rune ID assigned). - -use crate::{ - common::{get_fee_per_byte, DerivationPath, PrimaryOutput}, - ordinals::{build_rune_etch_transaction, create_script_path_witness, INSCRIPTION_OUTPUT_VALUE}, - p2tr, - runes::{build_etching_script, build_rune_commit_script, Etching}, - schnorr::{get_schnorr_public_key, sign_with_schnorr}, - BTC_CONTEXT, -}; -use bitcoin::{ - consensus::serialize, - secp256k1::{PublicKey, Secp256k1}, - taproot::{LeafVersion, TaprootBuilder, TaprootSpendInfo}, - Address, Amount, ScriptBuf, TxOut, Txid, XOnlyPublicKey, -}; -use ic_cdk::{trap, update}; -use ic_cdk_bitcoin_canister::{ - bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, - UtxosFilterInRequest, -}; - -fn validate_rune_name(name: &str) { - 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"); - } -} - -/// Derives the commit script, Taproot spend info, commit address, and funding address for a rune. -/// -/// Both `commit_rune` and `etch_rune` use the same derivation so the commit address is -/// deterministic and `etch_rune` can locate the commit UTXO without being told the txid. -async fn rune_commit_info( - name: &str, -) -> (ScriptBuf, TaprootSpendInfo, Address, Address) { - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - let internal_key_path = DerivationPath::p2tr(0, 0); - let internal_key_bytes = - get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; - let internal_key = XOnlyPublicKey::from(PublicKey::from_slice(&internal_key_bytes).unwrap()); - - let commit_script = build_rune_commit_script(&internal_key, name) - .unwrap_or_else(|e| trap(format!("Failed to build commit script: {}", e))); - - let secp = Secp256k1::new(); - let taproot_spend_info = TaprootBuilder::new() - .add_leaf(0, commit_script.clone()) - .unwrap() - .finalize(&secp, internal_key) - .unwrap(); - - let commit_address = - Address::p2tr_tweaked(taproot_spend_info.output_key(), ctx.bitcoin_network); - let funding_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); - - (commit_script, taproot_spend_info, commit_address, funding_address) -} - -/// Step 1 of rune etching — creates and broadcasts the commit transaction. -/// -/// Sends `INSCRIPTION_OUTPUT_VALUE` satoshis to a P2TR address whose tapscript contains the -/// rune commitment bytes. The commit output must have at least 6 block confirmations before -/// calling `etch_rune`. -/// -/// Returns the commit transaction ID. -#[update] -pub async fn commit_rune(name: String) -> String { - validate_rune_name(&name); - - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - let internal_key_path = DerivationPath::p2tr(0, 0); - - let (_, _, commit_address, funding_address) = rune_commit_info(&name).await; - - let own_utxos = bitcoin_get_utxos(&GetUtxosRequest { - address: funding_address.to_string(), - network: ctx.network.into(), - filter: None, - }) - .await - .unwrap() - .utxos; - - let fee_per_byte = get_fee_per_byte(&ctx).await; - - let (commit_tx, prevouts) = p2tr::build_transaction( - &ctx, - &funding_address, - &own_utxos, - p2tr::SelectUtxosMode::Single, - &PrimaryOutput::Address(commit_address, INSCRIPTION_OUTPUT_VALUE), - fee_per_byte, - ) - .await; - - // funding_address is a key-path-only P2TR (no script), so merkle root is empty. - let signed_commit = p2tr::sign_transaction_key_spend( - &ctx, - &funding_address, - commit_tx, - prevouts.as_slice(), - internal_key_path.to_vec_u8_path(), - vec![], - sign_with_schnorr, - ) - .await; - - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&signed_commit), - }) - .await - .unwrap(); - - signed_commit.compute_txid().to_string() -} - -/// Step 2 of rune etching — spends the commit output and broadcasts the etching transaction. -/// -/// Must be called after the commit output has at least 6 block confirmations. The etch -/// transaction spends the commit output via script-path spend so that the rune commitment -/// bytes appear in the witness, satisfying the ord indexer's validation. -/// -/// Returns the etching transaction ID. -#[update] -pub async fn etch_rune(name: String) -> String { - validate_rune_name(&name); - - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - let internal_key_path = DerivationPath::p2tr(0, 0); - - let (commit_script, taproot_spend_info, commit_address, _funding_address) = - rune_commit_info(&name).await; - - // Find the commit UTXO with at least 6 confirmations. The Runes protocol requires - // the commit output to be at least 6 blocks deep before the etching is recognised by indexers. - let commit_utxos = bitcoin_get_utxos(&GetUtxosRequest { - address: commit_address.to_string(), - network: ctx.network.into(), - filter: Some(UtxosFilterInRequest::MinConfirmations(6)), - }) - .await - .unwrap() - .utxos; - - if commit_utxos.is_empty() { - trap("No commit output with 6+ confirmations found. Call commit_rune first and mine 6 blocks before calling etch_rune."); - } - - let commit_utxo = &commit_utxos[0]; - - let commit_txid = Txid::from_raw_hash( - bitcoin::hashes::Hash::from_slice(commit_utxo.outpoint.txid.as_ref()).unwrap(), - ); - let commit_vout = commit_utxo.outpoint.vout; - let commit_value = commit_utxo.value; - - let commit_txout = TxOut { - value: Amount::from_sat(commit_value), - script_pubkey: commit_address.script_pubkey(), - }; - - let control_block = taproot_spend_info - .control_block(&(commit_script.clone(), LeafVersion::TapScript)) - .unwrap(); - - let etching = Etching { - divisibility: 0, - premine: 1_000_000, - rune_name: name.clone(), - symbol: Some('🪙'), - terms: None, - turbo: true, - spacers: 0, - }; - - let runestone_script = build_etching_script(&etching) - .unwrap_or_else(|e| trap(format!("Failed to build runestone: {}", e))); - - // The rune premine goes to a dedicated address (p2tr index 1) that is separate from the - // main funding address (p2tr index 0). This makes it possible for transfer_rune to spend - // only rune-bearing UTXOs without querying an external indexer. - let rune_key_path = DerivationPath::p2tr(0, 1); - let rune_key_bytes = get_schnorr_public_key(&ctx, rune_key_path.to_vec_u8_path()).await; - let rune_key = XOnlyPublicKey::from(PublicKey::from_slice(&rune_key_bytes).unwrap()); - let secp2 = Secp256k1::new(); - let rune_address = Address::p2tr(&secp2, rune_key, None, ctx.bitcoin_network); - - let fee_per_byte = get_fee_per_byte(&ctx).await; - - let mut etch_tx = build_rune_etch_transaction( - &rune_address, - &runestone_script, - &commit_script, - &control_block, - &commit_txid, - commit_vout, - commit_value, - fee_per_byte, - ) - .await; - - create_script_path_witness( - &ctx, - &mut etch_tx, - &commit_txout, - &commit_script, - &control_block, - internal_key_path.to_vec_u8_path(), - ) - .await; - - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&etch_tx), - }) - .await - .unwrap(); - - etch_tx.compute_txid().to_string() -} diff --git a/rust/basic_bitcoin/backend/src/service/get_rune_address.rs b/rust/basic_bitcoin/backend/src/service/get_rune_address.rs deleted file mode 100644 index 108984d42a..0000000000 --- a/rust/basic_bitcoin/backend/src/service/get_rune_address.rs +++ /dev/null @@ -1,20 +0,0 @@ -use bitcoin::{key::Secp256k1, Address, PublicKey, XOnlyPublicKey}; -use ic_cdk::update; - -use crate::{common::DerivationPath, schnorr::get_schnorr_public_key, BTC_CONTEXT}; - -/// Returns the P2TR address that holds the canister's rune balances. -/// -/// This address uses derivation index 1 (p2tr(0,1)), separate from the main funding -/// address at index 0. All rune premine and transfer change outputs go here, so every -/// UTXO at this address is a rune-bearing output. Use this address to check rune -/// balances in the ord explorer: http://127.0.0.1/address/
-#[update] -pub async fn get_rune_address() -> String { - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - let rune_key_path = DerivationPath::p2tr(0, 1); - let rune_key = get_schnorr_public_key(&ctx, rune_key_path.to_vec_u8_path()).await; - let rune_key = XOnlyPublicKey::from(PublicKey::from_slice(&rune_key).unwrap()); - let secp = Secp256k1::new(); - Address::p2tr(&secp, rune_key, None, ctx.bitcoin_network).to_string() -} diff --git a/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs b/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs deleted file mode 100644 index 6e110897bd..0000000000 --- a/rust/basic_bitcoin/backend/src/service/inscribe_brc20.rs +++ /dev/null @@ -1,60 +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::commit_and_reveal, - common::DerivationPath, - schnorr::get_schnorr_public_key, - BTC_CONTEXT, -}; -use bitcoin::secp256k1::{PublicKey, Secp256k1}; -use bitcoin::{Address, XOnlyPublicKey}; -use ic_cdk::{trap, update}; - -/// 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"); - } - - let tick = tick.to_uppercase(); - - // BRC-20 deploy JSON: defines the token with max supply and per-mint limit. - let brc20_json = format!( - r#"{{"p":"brc-20","op":"deploy","tick":"{}","max":"21000000","lim":"1000"}}"#, - tick - ); - - // Derive the funding address that receives the inscription output. - let internal_key_path = DerivationPath::p2tr(0, 0); - let internal_key_bytes = - get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; - let internal_key = - XOnlyPublicKey::from(PublicKey::from_slice(&internal_key_bytes).unwrap()); - let secp = Secp256k1::new(); - let funding_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); - - commit_and_reveal(&ctx, &brc20_json, &funding_address).await.0 -} diff --git a/rust/basic_bitcoin/backend/src/service/inscribe_ordinal.rs b/rust/basic_bitcoin/backend/src/service/inscribe_ordinal.rs deleted file mode 100644 index 3b4c372442..0000000000 --- a/rust/basic_bitcoin/backend/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/backend/src/service/mint_brc20.rs b/rust/basic_bitcoin/backend/src/service/mint_brc20.rs deleted file mode 100644 index 78ae072496..0000000000 --- a/rust/basic_bitcoin/backend/src/service/mint_brc20.rs +++ /dev/null @@ -1,59 +0,0 @@ -// This module implements BRC-20 token minting functionality. -// A mint inscription claims a portion of the token supply (up to the per-mint limit -// set in the deploy inscription) and assigns it to the address that holds the inscription. - -use crate::{ - brc20::commit_and_reveal, - common::DerivationPath, - schnorr::get_schnorr_public_key, - BTC_CONTEXT, -}; -use bitcoin::secp256k1::{PublicKey, Secp256k1}; -use bitcoin::{Address, XOnlyPublicKey}; -use ic_cdk::{trap, update}; - -/// Mints BRC-20 tokens by inscribing a mint operation on the Bitcoin blockchain. -/// -/// A mint inscription claims tokens from the deployed supply by creating an inscription -/// with the BRC-20 mint JSON at the canister's address. BRC-20 indexers recognise this -/// and credit the canister's balance with the specified amount (subject to the deploy's -/// per-mint limit). -/// -/// The minted tokens are credited to the address that holds the mint inscription — -/// in this case, the canister's P2TR key-path address. Tokens must be minted before -/// they can be transferred. -#[update] -pub async fn mint_brc20(tick: String, amount: u64) -> 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"); - } - - if amount == 0 { - trap("Amount must be greater than 0"); - } - - let tick = tick.to_uppercase(); - - // BRC-20 mint JSON: claims `amount` tokens from the deployed supply. - let brc20_json = format!( - r#"{{"p":"brc-20","op":"mint","tick":"{}","amt":"{}"}}"#, - tick, amount - ); - - // Mint inscription goes to the canister's own P2TR address to credit its balance. - let internal_key_path = DerivationPath::p2tr(0, 0); - let internal_key_bytes = - get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; - let internal_key = - XOnlyPublicKey::from(PublicKey::from_slice(&internal_key_bytes).unwrap()); - let secp = Secp256k1::new(); - let own_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); - - commit_and_reveal(&ctx, &brc20_json, &own_address).await.0 -} diff --git a/rust/basic_bitcoin/backend/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 index 9a49078b1a..a1a1a021f4 100644 --- a/rust/basic_bitcoin/backend/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 @@ -69,7 +69,6 @@ pub async fn send_from_p2tr_key_path_only_address(request: SendRequest) -> Strin &ctx, &own_address, &own_utxos, - p2tr::SelectUtxosMode::Greedy, &PrimaryOutput::Address(dst_address, request.amount_in_satoshi), fee_per_byte, ) diff --git a/rust/basic_bitcoin/backend/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 index 7971ad509e..fb38e7f010 100644 --- a/rust/basic_bitcoin/backend/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 @@ -75,7 +75,6 @@ pub async fn send_from_p2tr_script_path_enabled_address_key_spend(request: SendR &ctx, &own_address, &own_utxos, - p2tr::SelectUtxosMode::Greedy, &PrimaryOutput::Address(dst_address, request.amount_in_satoshi), fee_per_byte, ) diff --git a/rust/basic_bitcoin/backend/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 index 218cbe005f..ba3d81857b 100644 --- a/rust/basic_bitcoin/backend/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 @@ -87,7 +87,6 @@ pub async fn send_from_p2tr_script_path_enabled_address_script_spend( &ctx, &own_address, &own_utxos, - p2tr::SelectUtxosMode::Greedy, &PrimaryOutput::Address(dst_address, request.amount_in_satoshi), fee_per_byte, ) diff --git a/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs b/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs deleted file mode 100644 index 35e49ef7c2..0000000000 --- a/rust/basic_bitcoin/backend/src/service/transfer_brc20.rs +++ /dev/null @@ -1,165 +0,0 @@ -// This module implements BRC-20 token transfer functionality. -// -// A BRC-20 transfer requires two steps per the protocol specification: -// 1. Inscribe a transfer: Create an inscription with the transfer JSON at the SENDER's -// address. This "locks" the specified token amount from the sender's balance. -// 2. Send the inscription: Move the UTXO holding that inscription to the RECIPIENT's -// address. BRC-20 indexers credit the recipient when they see the inscription move. -// -// This function chains three Bitcoin transactions in a single canister call: -// Commit TX: Funds the Taproot commit address (contains the BRC-20 transfer script). -// Reveal TX: Spends the commit output, revealing the JSON and outputting the inscription -// to the canister's own address (i.e. the sender). -// Send TX: Spends the inscription UTXO from the canister's address to the recipient. - -use crate::{ - brc20::commit_and_reveal, - common::{get_fee_per_byte, DerivationPath}, - p2tr, - schnorr::{get_schnorr_public_key, mock_sign_with_schnorr, sign_with_schnorr}, - TransferBrc20Request, BTC_CONTEXT, -}; -use bitcoin::{ - absolute::LockTime, - blockdata::witness::Witness, - consensus::serialize, - - secp256k1::{PublicKey, Secp256k1}, - transaction::Version, - Address, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, XOnlyPublicKey, -}; -use ic_cdk::{trap, update}; -use ic_cdk_bitcoin_canister::{bitcoin_send_transaction, SendTransactionRequest}; -use std::str::FromStr; - -/// Transfers BRC-20 tokens from the canister's address to a recipient. -/// -/// Three transactions are chained within this call: -/// 1. Commit: Sends funds to a Taproot address committed to the transfer script. -/// 2. Reveal: Reveals the transfer JSON at the canister's own address, locking -/// `amount` tokens from the canister's BRC-20 balance into the inscription. -/// 3. Send: Moves the inscription UTXO to the recipient, crediting their balance. -/// -/// BRC-20 indexers recognise the inscription travelling from sender → recipient as -/// a token transfer and update balances accordingly. -/// -/// The canister must have enough minted balance before calling this function. Use -/// `mint_brc20` to acquire tokens after deploying with `inscribe_brc20`. -#[update] -pub async fn transfer_brc20(request: TransferBrc20Request) -> String { - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - - if request.tick.len() != 4 { - trap("BRC-20 ticker must be exactly 4 characters"); - } - - if request.amount == 0 { - trap("Amount must be greater than 0"); - } - - let recipient = Address::from_str(&request.destination_address) - .unwrap_or_else(|e| trap(format!("Invalid destination address: {}", e))) - .require_network(ctx.bitcoin_network) - .unwrap_or_else(|e| trap(format!("Address is for wrong network: {}", e))); - - let tick = request.tick.to_uppercase(); - - // Derive the canister's P2TR funding address (the "sender" whose balance is debited). - let internal_key_path = DerivationPath::p2tr(0, 0); - let internal_key_bytes = - get_schnorr_public_key(&ctx, internal_key_path.to_vec_u8_path()).await; - let internal_key = - XOnlyPublicKey::from(PublicKey::from_slice(&internal_key_bytes).unwrap()); - let secp = Secp256k1::new(); - let own_address = Address::p2tr(&secp, internal_key, None, ctx.bitcoin_network); - - // BRC-20 transfer JSON: locks `amount` tokens from the sender's balance. - let brc20_json = format!( - r#"{{"p":"brc-20","op":"transfer","tick":"{}","amt":"{}"}}"#, - tick, request.amount - ); - - // --- Step 1 & 2: Commit + Reveal --- - // The reveal output goes to the canister's own address, placing the transfer - // inscription at the sender's address as required by the BRC-20 spec. - // Returns (reveal_txid, actual reveal output value in sats). - let (reveal_txid_str, reveal_output_value) = - commit_and_reveal(&ctx, &brc20_json, &own_address).await; - - // --- Step 3: Send the inscription UTXO to the recipient --- - // Spending reveal_txid:0 moves the inscription from sender to recipient, - // which BRC-20 indexers interpret as the token transfer completing. - let reveal_txid = Txid::from_str(&reveal_txid_str).unwrap(); - - // BIP341 Taproot key-path signatures commit to the exact prevout amounts. - // We must use the actual reveal output value (INSCRIPTION_OUTPUT_VALUE minus - // the reveal fee) — not a hardcoded estimate — otherwise the signature is - // cryptographically invalid and Bitcoin nodes reject the send transaction. - let send_prevout = TxOut { - value: Amount::from_sat(reveal_output_value), - script_pubkey: own_address.script_pubkey(), - }; - - let fee_per_byte = get_fee_per_byte(&ctx).await; - let mut send_fee = 0u64; - - let signed_send_tx = loop { - let output_value = reveal_output_value - .checked_sub(send_fee) - .unwrap_or_else(|| trap("inscription value insufficient to cover send fee")); - - let send_tx = Transaction { - version: Version::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: reveal_txid, - vout: 0, - }, - sequence: Sequence::MAX, - witness: Witness::new(), - script_sig: ScriptBuf::new(), - }], - output: vec![TxOut { - script_pubkey: recipient.script_pubkey(), - value: Amount::from_sat(output_value), - }], - }; - - let mock_signed = p2tr::sign_transaction_key_spend( - &ctx, - &own_address, - send_tx.clone(), - &[send_prevout.clone()], - vec![], - vec![], - mock_sign_with_schnorr, - ) - .await; - - let new_fee = (mock_signed.vsize() as u64 * fee_per_byte) / 1000; - if new_fee == send_fee { - let signed = p2tr::sign_transaction_key_spend( - &ctx, - &own_address, - send_tx, - &[send_prevout.clone()], - internal_key_path.to_vec_u8_path(), - vec![], - sign_with_schnorr, - ) - .await; - break signed; - } - send_fee = new_fee; - }; - - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&signed_send_tx), - }) - .await - .unwrap(); - - signed_send_tx.compute_txid().to_string() -} diff --git a/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs b/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs deleted file mode 100644 index 532a29c569..0000000000 --- a/rust/basic_bitcoin/backend/src/service/transfer_ordinal.rs +++ /dev/null @@ -1,186 +0,0 @@ -// Transferring an ordinal inscription means transferring the specific satoshis that -// carry it. The Ordinals protocol assigns inscriptions to the first satoshi of the -// reveal output. To move the inscription, that satoshi must flow to the first output -// (vout 0) of the spending transaction — which is guaranteed by making the inscription -// UTXO the first (and only) input. -// -// The inscription UTXO sits at the canister's main funding address (p2tr(0,0)) with -// a value of INSCRIPTION_OUTPUT_VALUE satoshis (~15,000 sat). The user identifies -// it by the reveal transaction ID returned by inscribe_ordinal. - -use crate::{ - common::{get_fee_per_byte, DerivationPath}, - p2tr, - schnorr::{get_schnorr_public_key, mock_sign_with_schnorr, sign_with_schnorr}, - BTC_CONTEXT, -}; -use bitcoin::{ - absolute::LockTime, - blockdata::witness::Witness, - consensus::serialize, - hashes::Hash, - secp256k1::{PublicKey, Secp256k1}, - transaction::Version, - Address, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, XOnlyPublicKey, -}; -use ic_cdk::{trap, update}; -use ic_cdk_bitcoin_canister::{ - bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, -}; -use std::str::FromStr; - -/// Transfers an Ordinal inscription to a destination address. -/// -/// An inscription is bound to the first satoshi of the reveal output. To preserve this -/// binding through the transfer, the inscription UTXO must be the first input of the -/// spending transaction — that satoshi then flows to the first output (vout 0), which -/// is the recipient. Any remaining satoshis (after the fee) go to the same output. -/// -/// `reveal_txid` is the transaction ID returned by `inscribe_ordinal`. -/// -/// Returns the transfer transaction ID. -#[update] -pub async fn transfer_ordinal(reveal_txid: String, destination_address: String) -> String { - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - - // Parse and validate the destination address. - let destination_address = Address::from_str(&destination_address) - .unwrap_or_else(|e| trap(format!("Invalid destination address: {}", e))) - .require_network(ctx.bitcoin_network) - .unwrap_or_else(|e| trap(format!("Address is for wrong network: {}", e))); - - // Parse the reveal txid provided by the caller. - let reveal_txid = Txid::from_str(&reveal_txid) - .unwrap_or_else(|_| trap("Invalid reveal txid")); - - // The inscription output lives at the funding address (p2tr index 0) — - // the same address the reveal transaction sent its output to. - let key_path = DerivationPath::p2tr(0, 0); - let pub_key = get_schnorr_public_key(&ctx, key_path.to_vec_u8_path()).await; - let pub_key = XOnlyPublicKey::from(PublicKey::from_slice(&pub_key).unwrap()); - let secp = Secp256k1::new(); - let funding_address = Address::p2tr(&secp, pub_key, None, ctx.bitcoin_network); - - // Find the inscription UTXO: the output at vout 0 of the reveal transaction. - let utxos = bitcoin_get_utxos(&GetUtxosRequest { - address: funding_address.to_string(), - network: ctx.network.into(), - filter: None, - }) - .await - .unwrap() - .utxos; - - let inscription_utxo = utxos - .iter() - .find(|u| { - u.outpoint.txid.as_ref() - == reveal_txid.as_raw_hash().as_byte_array().as_slice() - && u.outpoint.vout == 0 - }) - .unwrap_or_else(|| { - trap("Inscription UTXO not found. Check the reveal txid and ensure it is confirmed.") - }); - - let inscription_value = inscription_utxo.value; - - // Build the transfer transaction using an iterative fee loop. - // The inscription UTXO is the sole input so its value must cover the fee. - let fee_per_byte = get_fee_per_byte(&ctx).await; - let mut total_fee = 0u64; - let (transaction, prevouts) = loop { - if total_fee >= inscription_value { - trap("Fee exceeds inscription output value"); - } - - let tx = build_ordinal_transfer_tx( - &reveal_txid, - inscription_value, - &funding_address, - &destination_address, - total_fee, - ); - - // Sign with a dummy key to get the correct virtual size for fee estimation. - let signed_tx = p2tr::sign_transaction_key_spend( - &ctx, - &funding_address, - tx.clone(), - &[TxOut { - value: Amount::from_sat(inscription_value), - script_pubkey: funding_address.script_pubkey(), - }], - vec![], - vec![], - mock_sign_with_schnorr, - ) - .await; - - let new_fee = (signed_tx.vsize() as u64 * fee_per_byte) / 1000; - if new_fee == total_fee { - let prevouts = vec![TxOut { - value: Amount::from_sat(inscription_value), - script_pubkey: funding_address.script_pubkey(), - }]; - break (tx, prevouts); - } - total_fee = new_fee; - }; - - // Sign with the real key and broadcast. - let signed_transaction = p2tr::sign_transaction_key_spend( - &ctx, - &funding_address, - transaction, - prevouts.as_slice(), - key_path.to_vec_u8_path(), - vec![], - sign_with_schnorr, - ) - .await; - - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&signed_transaction), - }) - .await - .unwrap(); - - signed_transaction.compute_txid().to_string() -} - -/// Builds the unsigned ordinal transfer transaction. -/// -/// The inscription UTXO is the sole input. The inscription satoshi (the first sat of -/// the input) flows to vout 0 because Bitcoin assigns satoshis to outputs in order. -/// Any value left after the fee follows the same satoshi ordering, so vout 0 receives -/// everything — the inscription and the remaining satoshis. -fn build_ordinal_transfer_tx( - reveal_txid: &Txid, - inscription_value: u64, - _own_address: &Address, - destination_address: &Address, - fee: u64, -) -> Transaction { - // INSCRIPTION_OUTPUT_VALUE (15,000 sat) is safely above dust even after fees; - // the caller already traps if fee >= inscription_value. - let output_value = inscription_value.saturating_sub(fee); - - Transaction { - version: Version::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: reveal_txid.to_owned(), - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(output_value), - script_pubkey: destination_address.script_pubkey(), - }], - } -} diff --git a/rust/basic_bitcoin/backend/src/service/transfer_rune.rs b/rust/basic_bitcoin/backend/src/service/transfer_rune.rs deleted file mode 100644 index 5d5c07640e..0000000000 --- a/rust/basic_bitcoin/backend/src/service/transfer_rune.rs +++ /dev/null @@ -1,241 +0,0 @@ -// This module implements Bitcoin Runes token transfer functionality. -// A rune transfer creates a Bitcoin transaction with a Runestone in an OP_RETURN -// output. The Runestone contains an edict that tells the protocol how to allocate -// rune balances from the spent UTXOs to the transaction outputs. - -use crate::{ - common::{get_fee_per_byte, DerivationPath}, - p2tr, - runes::{build_transfer_script, Edict}, - schnorr::{get_schnorr_public_key, mock_sign_with_schnorr, sign_with_schnorr}, - TransferRuneRequest, BTC_CONTEXT, -}; -use bitcoin::{ - absolute::LockTime, - blockdata::witness::Witness, - consensus::serialize, - hashes::Hash, - secp256k1::{PublicKey, Secp256k1}, - transaction::Version, - Address, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, XOnlyPublicKey, -}; -use ic_cdk::{trap, update}; -use ic_cdk_bitcoin_canister::{ - bitcoin_get_utxos, bitcoin_send_transaction, GetUtxosRequest, SendTransactionRequest, Utxo, -}; -use std::str::FromStr; - -// Minimum satoshi value attached to the recipient output. -// Bitcoin nodes reject outputs below the dust threshold (~330–546 sat depending on type). -// 1000 sat is a safe round number above all thresholds. -const DUST_AMOUNT: u64 = 1_000; - -/// Transfers rune tokens from the canister's P2TR address to a recipient address. -/// -/// The Runes protocol tracks token balances per UTXO. This function builds a -/// Bitcoin transaction whose Runestone directs the rune balance from the spent -/// input UTXOs to the specified recipient output. -/// -/// Transaction output layout (vout indices matter for the edict and pointer): -/// vout[0] — recipient: receives exactly `amount` rune tokens + DUST_AMOUNT satoshis -/// vout[1] — OP_RETURN: Runestone with the transfer edict and pointer (no bitcoin value) -/// vout[2] — change: receives unallocated rune tokens + remaining satoshis -/// -/// The Runestone contains: -/// - edict: send `amount` tokens of the given rune ID to vout[0] -/// - pointer: 2 — unallocated tokens (the remainder) go to vout[2] -/// -/// Without the pointer, unallocated runes would default to the first non-OP_RETURN -/// output (vout[0]), giving the recipient the entire balance instead of just `amount`. -/// -/// The rune ID (`rune_id_block` : `rune_id_tx`) can be found in the ord explorer -/// at http://127.0.0.1/rune/ after starting `ord --config-dir . server`. -#[update] -pub async fn transfer_rune(request: TransferRuneRequest) -> String { - let ctx = BTC_CONTEXT.with(|ctx| ctx.get()); - - if request.amount == 0 { - trap("Amount must be greater than 0"); - } - - // Parse and validate the destination address for the current network. - let destination_address = Address::from_str(&request.destination_address) - .unwrap_or_else(|e| trap(format!("Invalid destination address: {}", e))) - .require_network(ctx.bitcoin_network) - .unwrap_or_else(|e| trap(format!("Address is for wrong network: {}", e))); - - // Rune balances are held at the dedicated rune address (p2tr index 1), separate from the - // main funding address (p2tr index 0). All UTXOs there are rune-bearing outputs created by - // etch_rune and previous transfer change outputs — so spending all of them is correct. - let rune_key_path = DerivationPath::p2tr(0, 1); - let rune_key = get_schnorr_public_key(&ctx, rune_key_path.to_vec_u8_path()).await; - let rune_key = XOnlyPublicKey::from(PublicKey::from_slice(&rune_key).unwrap()); - - let secp256k1_engine = Secp256k1::new(); - let rune_address = Address::p2tr(&secp256k1_engine, rune_key, None, ctx.bitcoin_network); - - // Fetch all UTXOs at the rune address — every one of them holds rune balance. - let rune_utxos = bitcoin_get_utxos(&GetUtxosRequest { - address: rune_address.to_string(), - network: ctx.network.into(), - filter: None, - }) - .await - .unwrap() - .utxos; - - if rune_utxos.is_empty() { - trap("No rune UTXOs found. Etch a rune first."); - } - - // Build the Runestone: - // - edict: send `amount` tokens to vout[0] (recipient) - // - pointer: 2 — unallocated tokens (the remainder) go to vout[2] (change) - // - // Without the pointer, unallocated runes default to the first non-OP_RETURN - // output (vout[0]), which would give the recipient the entire rune balance - // instead of just the requested amount. - let runestone_script = build_transfer_script( - &[Edict { - rune_id_block: request.rune_id_block, - rune_id_tx: request.rune_id_tx, - amount: request.amount, - output: 0, // vout[0] is the recipient - }], - Some(2), // pointer: unallocated runes go to vout[2] (change) - ) - .unwrap_or_else(|e| trap(format!("Failed to build runestone: {}", e))); - - let utxos_to_spend: Vec<&_> = rune_utxos.iter().collect(); - - let fee_per_byte = get_fee_per_byte(&ctx).await; - let mut total_fee = 0u64; - let (transaction, prevouts) = loop { - let (tx, prevouts) = build_rune_transfer_tx( - &utxos_to_spend, - &rune_address, - &destination_address, - &runestone_script, - total_fee, - ); - - let signed_tx = p2tr::sign_transaction_key_spend( - &ctx, - &rune_address, - tx.clone(), - &prevouts, - vec![], - vec![], - mock_sign_with_schnorr, - ) - .await; - - let new_fee = (signed_tx.vsize() as u64 * fee_per_byte) / 1000; - if new_fee == total_fee { - break (tx, prevouts); - } - total_fee = new_fee; - }; - - // Sign with the real Schnorr key and broadcast. - let signed_transaction = p2tr::sign_transaction_key_spend( - &ctx, - &rune_address, - transaction, - prevouts.as_slice(), - rune_key_path.to_vec_u8_path(), - vec![], - sign_with_schnorr, - ) - .await; - - bitcoin_send_transaction(&SendTransactionRequest { - network: ctx.network.into(), - transaction: serialize(&signed_transaction), - }) - .await - .unwrap(); - - signed_transaction.compute_txid().to_string() -} - -/// Constructs the unsigned transaction for a rune transfer. -/// -/// Output order is significant because the Runestone's edict and pointer reference -/// outputs by their vout index: -/// vout[0] — recipient (edict `output: 0`) -/// vout[1] — OP_RETURN / Runestone -/// vout[2] — change (Runestone `pointer: 2`) -/// -/// vout[2] must always exist: if it were absent the pointer would reference a -/// non-existent output, which the ord protocol treats as a cenotaph and burns -/// all rune tokens in the transaction. We trap early if the fee is so large -/// that no change satoshis remain. -fn build_rune_transfer_tx( - utxos_to_spend: &[&Utxo], - own_address: &Address, - destination_address: &Address, - runestone_script: &ScriptBuf, - fee: u64, -) -> (Transaction, Vec) { - let inputs: Vec = utxos_to_spend - .iter() - .map(|utxo| TxIn { - previous_output: OutPoint { - txid: Txid::from_raw_hash(Hash::from_slice(utxo.outpoint.txid.as_ref()).unwrap()), - vout: utxo.outpoint.vout, - }, - sequence: Sequence::MAX, - witness: Witness::new(), - script_sig: ScriptBuf::new(), - }) - .collect(); - - let prevouts: Vec = utxos_to_spend - .iter() - .map(|utxo| TxOut { - value: Amount::from_sat(utxo.value), - script_pubkey: own_address.script_pubkey(), - }) - .collect(); - - let total_in: u64 = utxos_to_spend.iter().map(|u| u.value).sum(); - let change = total_in - .checked_sub(DUST_AMOUNT + fee) - .unwrap_or_else(|| trap("fee exceeds inputs")); - - // vout[2] must always be present for the Runestone pointer to be valid. - // In practice the rune UTXOs always have thousands of satoshis, so this - // threshold is only hit if something is severely wrong. - if change < DUST_AMOUNT { - trap("Insufficient satoshis for change output; fee too high"); - } - - // vout[0]: recipient — edict `output: 0`, receives `amount` rune tokens - // vout[1]: OP_RETURN — Runestone (edict + pointer), carries no bitcoin value - // vout[2]: change — Runestone `pointer: 2`, receives unallocated rune tokens - let outputs = vec![ - TxOut { - script_pubkey: destination_address.script_pubkey(), - value: Amount::from_sat(DUST_AMOUNT), - }, - TxOut { - script_pubkey: runestone_script.clone(), - value: Amount::from_sat(0), - }, - TxOut { - script_pubkey: own_address.script_pubkey(), - value: Amount::from_sat(change), - }, - ]; - - ( - Transaction { - input: inputs, - output: outputs, - lock_time: LockTime::ZERO, - version: Version::TWO, - }, - prevouts, - ) -} diff --git a/rust/basic_bitcoin/icp.yaml b/rust/basic_bitcoin/icp.yaml index 2b13ed08c1..341ab9a7bb 100644 --- a/rust/basic_bitcoin/icp.yaml +++ b/rust/basic_bitcoin/icp.yaml @@ -8,8 +8,7 @@ networks: mode: managed image: icp-cli-network-launcher-bitcoin port-mapping: - - 0:4943 # IC gateway (dynamic) - - 18443:18443 # bitcoind RPC (required for ord) + - 0:4943 # IC gateway (dynamic) environments: - name: local diff --git a/rust/basic_bitcoin/ord.yaml b/rust/basic_bitcoin/ord.yaml deleted file mode 100644 index c64bdd8b27..0000000000 --- a/rust/basic_bitcoin/ord.yaml +++ /dev/null @@ -1,10 +0,0 @@ -bitcoin_data_dir: ./bitcoin_data -bitcoin_rpc_password: ic-btc-integration -bitcoin_rpc_url: http://127.0.0.1:18443 -bitcoin_rpc_username: ic-btc-integration -chain: regtest -data_dir: ./ord-db -index_addresses: true -index_runes: true -index_sats: true -index_transactions: true From 930c4f3a424599b660d04405f0e0cfcddd04cc9c Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 10 Jul 2026 11:59:02 +0200 Subject: [PATCH 31/33] chore(rust/basic_bitcoin): restore SelectUtxosMode and select_one_utxo These are educational: they demonstrate different UTXO selection strategies (greedy accumulation vs. single large UTXO) which are relevant to Bitcoin development regardless of asset protocols. The Single variant is kept even though no current service uses it, so callers that need single-UTXO semantics have a clear pattern to follow. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/backend/src/common.rs | 20 ++++++++++++++++++ rust/basic_bitcoin/backend/src/p2tr.rs | 21 +++++++++++++++++-- .../send_from_p2tr_key_path_only_address.rs | 1 + ...r_script_path_enabled_address_key_spend.rs | 1 + ...cript_path_enabled_address_script_spend.rs | 1 + 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/rust/basic_bitcoin/backend/src/common.rs b/rust/basic_bitcoin/backend/src/common.rs index 7fe06add6a..1a37027a7a 100644 --- a/rust/basic_bitcoin/backend/src/common.rs +++ b/rust/basic_bitcoin/backend/src/common.rs @@ -46,6 +46,26 @@ pub fn select_utxos_greedy( Ok(utxos_to_spend) } +/// Selects a single UTXO that can cover the required amount plus fee. +/// +/// 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> { + for utxo in own_utxos.iter().rev() { + if utxo.value >= amount + fee { + return Ok(vec![&utxo]); + } + } + + Err(format!( + "No sufficiently large utxo found: amount {} satoshi, fee {}", + amount, fee + )) +} + /// Represents the primary output type for a Bitcoin transaction. pub enum PrimaryOutput { Address(Address, u64), // destination address, amount in satoshis diff --git a/rust/basic_bitcoin/backend/src/p2tr.rs b/rust/basic_bitcoin/backend/src/p2tr.rs index b7e90d8ab6..5846241316 100644 --- a/rust/basic_bitcoin/backend/src/p2tr.rs +++ b/rust/basic_bitcoin/backend/src/p2tr.rs @@ -1,5 +1,5 @@ use crate::{ - common::{build_transaction_with_fee, select_utxos_greedy, PrimaryOutput}, + common::{build_transaction_with_fee, select_one_utxo, select_utxos_greedy, PrimaryOutput}, schnorr::mock_sign_with_schnorr, BitcoinContext, }; @@ -61,12 +61,25 @@ 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 +/// like Ordinals inscriptions that track individual satoshis through the UTXO graph. +pub enum SelectUtxosMode { + Greedy, + Single, +} + // Builds a P2TR transaction to send the given `amount` of satoshis to the // destination address. pub(crate) async fn build_transaction( ctx: &BitcoinContext, own_address: &Address, own_utxos: &[Utxo], + utxos_mode: SelectUtxosMode, primary_output: &PrimaryOutput, fee_per_byte: MillisatoshiPerByte, ) -> (Transaction, Vec) { @@ -82,7 +95,11 @@ pub(crate) async fn build_transaction( let amount = *amount; let mut total_fee = 0; loop { - let utxos_to_spend = select_utxos_greedy(own_utxos, amount, total_fee).unwrap(); + let utxos_to_spend = match utxos_mode { + SelectUtxosMode::Greedy => select_utxos_greedy(own_utxos, amount, total_fee), + SelectUtxosMode::Single => select_one_utxo(own_utxos, amount, total_fee), + } + .unwrap(); let (transaction, prevouts) = build_transaction_with_fee(utxos_to_spend, own_address, primary_output, total_fee) diff --git a/rust/basic_bitcoin/backend/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 index a1a1a021f4..9a49078b1a 100644 --- a/rust/basic_bitcoin/backend/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 @@ -69,6 +69,7 @@ pub async fn send_from_p2tr_key_path_only_address(request: SendRequest) -> Strin &ctx, &own_address, &own_utxos, + p2tr::SelectUtxosMode::Greedy, &PrimaryOutput::Address(dst_address, request.amount_in_satoshi), fee_per_byte, ) diff --git a/rust/basic_bitcoin/backend/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 index fb38e7f010..7971ad509e 100644 --- a/rust/basic_bitcoin/backend/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 @@ -75,6 +75,7 @@ pub async fn send_from_p2tr_script_path_enabled_address_key_spend(request: SendR &ctx, &own_address, &own_utxos, + p2tr::SelectUtxosMode::Greedy, &PrimaryOutput::Address(dst_address, request.amount_in_satoshi), fee_per_byte, ) diff --git a/rust/basic_bitcoin/backend/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 index ba3d81857b..218cbe005f 100644 --- a/rust/basic_bitcoin/backend/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 @@ -87,6 +87,7 @@ pub async fn send_from_p2tr_script_path_enabled_address_script_spend( &ctx, &own_address, &own_utxos, + p2tr::SelectUtxosMode::Greedy, &PrimaryOutput::Address(dst_address, request.amount_in_satoshi), fee_per_byte, ) From 292e35a18b17b43c1ec6531f7893b7b82491d1e2 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 10 Jul 2026 12:02:29 +0200 Subject: [PATCH 32/33] test(rust/basic_bitcoin): add unit tests for UTXO selection modes Tests both SelectUtxosMode::Greedy (accumulate smallest UTXOs until covered) and SelectUtxosMode::Single (require one UTXO large enough on its own), including error cases for each. Also eliminates the dead-code warning on the Single variant. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/backend/src/common.rs | 60 ++++++++++++++++++++++++ rust/basic_bitcoin/backend/src/p2tr.rs | 14 ++++++ 2 files changed, 74 insertions(+) diff --git a/rust/basic_bitcoin/backend/src/common.rs b/rust/basic_bitcoin/backend/src/common.rs index 1a37027a7a..eb52188d4c 100644 --- a/rust/basic_bitcoin/backend/src/common.rs +++ b/rust/basic_bitcoin/backend/src/common.rs @@ -315,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/backend/src/p2tr.rs b/rust/basic_bitcoin/backend/src/p2tr.rs index 5846241316..db43086b05 100644 --- a/rust/basic_bitcoin/backend/src/p2tr.rs +++ b/rust/basic_bitcoin/backend/src/p2tr.rs @@ -265,3 +265,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; + } +} From 35b540137ee69ac53cab4eca7be61ed540e6b237 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 10 Jul 2026 12:24:58 +0200 Subject: [PATCH 33/33] docs(basic_bitcoin): remove Ordinals reference from SelectUtxosMode::Single doc comment The Ordinals implementation was dropped from this PR; the doc comment now uses the same generic wording as select_one_utxo() in common.rs. Co-Authored-By: Claude Sonnet 4.6 --- rust/basic_bitcoin/backend/src/p2tr.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/basic_bitcoin/backend/src/p2tr.rs b/rust/basic_bitcoin/backend/src/p2tr.rs index db43086b05..f68d6a925d 100644 --- a/rust/basic_bitcoin/backend/src/p2tr.rs +++ b/rust/basic_bitcoin/backend/src/p2tr.rs @@ -67,7 +67,8 @@ pub fn create_spend_script(script_key_bytes: &[u8]) -> ScriptBuf { /// 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 -/// like Ordinals inscriptions that track individual satoshis through the UTXO graph. +/// 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,