From e62e9c30a9bac2bf971866afe59bd4fdc48b8f1d Mon Sep 17 00:00:00 2001 From: eigmax Date: Wed, 11 Feb 2026 13:30:35 +0000 Subject: [PATCH 1/7] Bump to v0.3.3 --- Cargo.lock | 34 +++++++++---------- Cargo.toml | 2 +- ...0260211100000_drop_graph_ipfs_base_url.sql | 2 ++ crates/store/src/localdb.rs | 9 ----- crates/store/src/schema.rs | 13 ------- .../graph_maintenance_tasks.rs | 3 -- 6 files changed, 20 insertions(+), 43 deletions(-) create mode 100644 crates/store/migrations/20260211100000_drop_graph_ipfs_base_url.sql diff --git a/Cargo.lock b/Cargo.lock index 686e63f6..bdcee9a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2392,7 +2392,7 @@ checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin-light-client-circuit" -version = "0.3.2" +version = "0.3.3" dependencies = [ "alloy-primitives", "base64 0.21.7", @@ -2571,7 +2571,7 @@ dependencies = [ [[package]] name = "bitvm2-lib" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "ark-bn254", @@ -2603,7 +2603,7 @@ dependencies = [ [[package]] name = "bitvm2-noded" -version = "0.3.2" +version = "0.3.3" dependencies = [ "alloy", "anyhow", @@ -2927,7 +2927,7 @@ dependencies = [ [[package]] name = "cbft-rpc" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "base64 0.21.7", @@ -3079,7 +3079,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "client" -version = "0.3.2" +version = "0.3.3" dependencies = [ "alloy", "anyhow", @@ -3168,7 +3168,7 @@ dependencies = [ [[package]] name = "commit-chain" -version = "0.3.2" +version = "0.3.3" dependencies = [ "alloy-primitives", "base64 0.21.7", @@ -3193,7 +3193,7 @@ dependencies = [ [[package]] name = "commit-chain-proof" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "bincode", @@ -5307,7 +5307,7 @@ dependencies = [ [[package]] name = "header-chain" -version = "0.3.2" +version = "0.3.3" dependencies = [ "bitcoin 0.32.8", "borsh", @@ -5321,7 +5321,7 @@ dependencies = [ [[package]] name = "header-chain-proof" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "ark-bn254", @@ -7803,7 +7803,7 @@ dependencies = [ [[package]] name = "operator-proof" -version = "0.3.2" +version = "0.3.3" dependencies = [ "alloy-primitives", "alloy-provider 1.0.41", @@ -8902,7 +8902,7 @@ dependencies = [ [[package]] name = "proof-builder" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "bitcoin 0.32.8", @@ -8921,7 +8921,7 @@ dependencies = [ [[package]] name = "proof-builder-rpc" -version = "0.3.2" +version = "0.3.3" dependencies = [ "alloy-primitives", "anyhow", @@ -11453,7 +11453,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "state-chain" -version = "0.3.2" +version = "0.3.3" dependencies = [ "alloy-consensus 1.0.41", "alloy-primitives", @@ -11483,7 +11483,7 @@ dependencies = [ [[package]] name = "state-chain-proof" -version = "0.3.2" +version = "0.3.3" dependencies = [ "alloy-consensus 1.0.41", "alloy-primitives", @@ -11535,7 +11535,7 @@ checksum = "4af28eeb7c18ac2dbdb255d40bee63f203120e1db6b0024b177746ebec7049c1" [[package]] name = "store" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "bitcoin 0.32.8", @@ -12813,7 +12813,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" -version = "0.3.2" +version = "0.3.3" dependencies = [ "bitcoin 0.32.8", "hex", @@ -13025,7 +13025,7 @@ dependencies = [ [[package]] name = "watchtower-proof" -version = "0.3.2" +version = "0.3.3" dependencies = [ "alloy-primitives", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 2606958d..7d6a2fe7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.3.2" +version = "0.3.3" edition = "2024" [workspace] diff --git a/crates/store/migrations/20260211100000_drop_graph_ipfs_base_url.sql b/crates/store/migrations/20260211100000_drop_graph_ipfs_base_url.sql new file mode 100644 index 00000000..31a3a60e --- /dev/null +++ b/crates/store/migrations/20260211100000_drop_graph_ipfs_base_url.sql @@ -0,0 +1,2 @@ +-- Drop deprecated column graph_ipfs_base_url from graph table +ALTER TABLE graph DROP COLUMN `graph_ipfs_base_url`; diff --git a/crates/store/src/localdb.rs b/crates/store/src/localdb.rs index a1f3ba9d..6fd564c3 100644 --- a/crates/store/src/localdb.rs +++ b/crates/store/src/localdb.rs @@ -501,7 +501,6 @@ pub struct GraphUpdate { pub graph_id: Uuid, pub status: Option, pub sub_status: Option, - pub ipfs_base_url: Option, pub challenge_txid: Option, pub disprove_txid: Option, pub bridge_out_start_at: Option, @@ -516,7 +515,6 @@ impl GraphUpdate { graph_id, status: None, sub_status: None, - ipfs_base_url: None, challenge_txid: None, disprove_txid: None, bridge_out_start_at: None, @@ -536,12 +534,6 @@ impl GraphUpdate { self } - /// Set IPFS base URL - pub fn with_ipfs_base_url(mut self, ipfs_base_url: String) -> Self { - self.ipfs_base_url = Some(ipfs_base_url); - self - } - /// Set challenge transaction ID pub fn with_challenge_txid(mut self, challenge_txid: SerializableTxid) -> Self { self.challenge_txid = Some(challenge_txid); @@ -576,7 +568,6 @@ impl GraphUpdate { pub fn has_updates(&self) -> bool { self.status.is_some() || self.sub_status.is_some() - || self.ipfs_base_url.is_some() || self.challenge_txid.is_some() || self.disprove_txid.is_some() || self.bridge_out_start_at.is_some() diff --git a/crates/store/src/schema.rs b/crates/store/src/schema.rs index 28a98b67..d37426d1 100644 --- a/crates/store/src/schema.rs +++ b/crates/store/src/schema.rs @@ -374,19 +374,6 @@ impl GraphStatus { } /// graph detail -/// Field `graph_ipfs_base_url` is the IFPS address, which serves as a directory address containing the following files within that directory. -/// ├── assert-commit0.hex -/// ├── assert-commit1.hex -/// ├── assert-commit2.hex -/// ├── assert-commit3.hex -/// ├── assert-final.hex -/// ├── assert-init.hex -/// ├── challenge.hex -/// ├── disprove.hex -/// ├── kickoff.hex -/// ├── pegin.hex -/// ├── take1.hex -/// └── take2.hex #[derive(Clone, FromRow, Debug, Serialize, Deserialize, Default)] pub struct Graph { pub graph_id: Uuid, diff --git a/node/src/scheduled_tasks/graph_maintenance_tasks.rs b/node/src/scheduled_tasks/graph_maintenance_tasks.rs index 35c2ba4f..f9838e26 100644 --- a/node/src/scheduled_tasks/graph_maintenance_tasks.rs +++ b/node/src/scheduled_tasks/graph_maintenance_tasks.rs @@ -161,8 +161,6 @@ pub struct WTInitTxVoutMonitorData { pub data_map: IndexMap, pub require_disproved_indexes: Vec, pub commit_blockhash_status: CommitBlockHashStatus, - #[deprecated] - pub is_challenge_timeout_sent: bool, // deprecated } impl WTInitTxVoutMonitorData { @@ -175,7 +173,6 @@ impl WTInitTxVoutMonitorData { data_map, require_disproved_indexes: vec![], commit_blockhash_status: CommitBlockHashStatus::None, - is_challenge_timeout_sent: false, } } pub async fn monitor_vout( From f3e61623df5b238a9308b0622c7936e76f08b58e Mon Sep 17 00:00:00 2001 From: eigmax Date: Wed, 11 Feb 2026 14:46:58 +0000 Subject: [PATCH 2/7] feat: add skills for node --- .claude/commands/challenge.md | 45 ++ .claude/commands/install-bitvm2.sh | 186 ++++++ .claude/commands/pegin-request.md | 64 ++ .claude/commands/pegout.md | 62 ++ .claude/commands/run-challenger-node.md | 57 ++ .claude/commands/run-operator-node.md | 60 ++ .claude/commands/upgrade.md | 19 + node/src/bin/send_challenge.rs | 89 ++- node/src/bin/send_pegout.rs | 596 ++++++------------ node/src/rpc_service/bitvm2.rs | 22 + .../src/rpc_service/handler/bitvm2_handler.rs | 273 +++++++- node/src/rpc_service/mod.rs | 9 +- node/src/rpc_service/routes.rs | 2 + 13 files changed, 1008 insertions(+), 476 deletions(-) create mode 100644 .claude/commands/challenge.md create mode 100755 .claude/commands/install-bitvm2.sh create mode 100644 .claude/commands/pegin-request.md create mode 100644 .claude/commands/pegout.md create mode 100644 .claude/commands/run-challenger-node.md create mode 100644 .claude/commands/run-operator-node.md create mode 100644 .claude/commands/upgrade.md diff --git a/.claude/commands/challenge.md b/.claude/commands/challenge.md new file mode 100644 index 00000000..b0360404 --- /dev/null +++ b/.claude/commands/challenge.md @@ -0,0 +1,45 @@ +Broadcast a Challenge transaction for a graph via the `challenge` binary. + +The challenge binary calls the node's REST API, so a **Challenger node** must be running and reachable. + +## Prerequisites — Running a Challenger Node + +Before using this command, ensure a Challenger role node (`ACTOR=Challenger`) is running. + +Use the `/run-challenger-node` skill to start one, or see `deployment/README.md` (section **Challenger**) for full details. + +## Instructions + +1. Ask the user for the following parameters (skip any already provided as arguments: $ARGUMENTS): + - **graph_id**: Required. Graph UUID to challenge + - **api_url**: Node API base URL. Default: `http://localhost:8080` + +2. Confirm that the Challenger node is running and reachable at the given `api_url`. If the user hasn't started a node yet, walk them through the `/run-challenger-node` skill. + +3. **Verify the graph is synced** on the local challenger node. The challenger syncs graph data via P2P from other nodes, so the target graph must exist locally before a challenge can be sent. Check by calling: + ```bash + curl -s /v1/graphs/ | jq . + ``` + - If the response contains a non-null `"graph"` field, the graph is synced and ready. + - If `"graph"` is null or the request fails, the node has not yet synced this graph. Ask the user to wait for P2P sync to complete and retry. The node must be connected to the network (correct `BOOTNODES`, `PROTO_NAME`) and the graph must exist on peer nodes. + +4. Check if the `challenge` binary exists at `./bin/challenge`. If not, run: + ```bash + .claude/commands/install-bitvm2.sh install + ``` + +5. Run the command using the pre-built binary: + +```bash +./bin/challenge --graph-id [--api-url ] +``` + +### Example commands + +```bash +# Default API URL (http://localhost:8080) +./bin/challenge --graph-id 6ba7b810-9dad-11d1-80b4-00c04fd430c8 + +# Custom API URL (e.g. challenger node on port 8906) +./bin/challenge --graph-id 6ba7b810-9dad-11d1-80b4-00c04fd430c8 --api-url http://127.0.0.1:8906 +``` diff --git a/.claude/commands/install-bitvm2.sh b/.claude/commands/install-bitvm2.sh new file mode 100755 index 00000000..2628dbd4 --- /dev/null +++ b/.claude/commands/install-bitvm2.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="GOATNetwork/bitvm2-node" +API_URL="https://api.github.com/repos/${REPO}/releases" +INSTALL_DIR="./bin" +VERSION_FILE=".bitvm2-version" + +usage() { + cat < [options] + +Commands: + install [VERSION] Install binaries (default: latest release) + upgrade [VERSION] Upgrade binaries if a newer version is available + version Show currently installed version + +Options: + --dir DIR Install directory (default: ./bin) + +Examples: + $(basename "$0") install # Install latest release + $(basename "$0") install v0.3.2 # Install specific version + $(basename "$0") upgrade # Upgrade to latest release + $(basename "$0") upgrade v0.3.2 # Upgrade to specific version + $(basename "$0") version # Show installed version +EOF + exit 1 +} + +# ── Helpers ────────────────────────────────────────────────────────── + +detect_platform() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + case "${os}-${arch}" in + Linux-x86_64) echo "x86_64-linux" ;; + Darwin-arm64) echo "aarch64-macos" ;; + Darwin-aarch64) echo "aarch64-macos" ;; + *) echo "Unsupported platform: ${os}-${arch}" >&2; exit 1 ;; + esac +} + +get_latest_version() { + local tag + tag="$(curl -fsSL "${API_URL}/latest" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name".*"\(v[^"]*\)".*/\1/')" + if [ -z "${tag}" ]; then + echo "Failed to fetch latest version" >&2 + exit 1 + fi + echo "${tag}" +} + +get_installed_version() { + if [ -f "${INSTALL_DIR}/${VERSION_FILE}" ]; then + cat "${INSTALL_DIR}/${VERSION_FILE}" + else + echo "" + fi +} + +# Strip leading 'v' and compare semver: returns 0 if $1 > $2 +version_gt() { + local v1="${1#v}" v2="${2#v}" + [ "$(printf '%s\n%s' "${v1}" "${v2}" | sort -V | tail -1)" != "${v2}" ] +} + +do_install() { + local version="$1" + local platform + platform="$(detect_platform)" + + local tarball="bitvm2-node-${version}-${platform}.tar.gz" + local base_url="https://github.com/${REPO}/releases/download/${version}" + local url="${base_url}/${tarball}" + local sha_url="${url}.sha256" + + echo "Platform : $(uname -s) $(uname -m) -> ${platform}" + echo "Version : ${version}" + echo "Install : ${INSTALL_DIR}" + echo "" + echo "Downloading ${tarball} ..." + + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "${tmpdir}"' EXIT + + curl -fSL -o "${tmpdir}/${tarball}" "${url}" + curl -fSL -o "${tmpdir}/${tarball}.sha256" "${sha_url}" + + echo "Verifying checksum ..." + cd "${tmpdir}" + if command -v sha256sum &>/dev/null; then + sha256sum -c "${tarball}.sha256" + elif command -v shasum &>/dev/null; then + shasum -a 256 -c "${tarball}.sha256" + else + echo "Warning: no sha256sum or shasum found, skipping checksum verification" >&2 + fi + + mkdir -p "${INSTALL_DIR}" + echo "Installing to ${INSTALL_DIR} ..." + tar xzf "${tarball}" -C "${INSTALL_DIR}" + chmod +x "${INSTALL_DIR}"/* + + # Record installed version + echo "${version}" > "${INSTALL_DIR}/${VERSION_FILE}" + + echo "" + echo "Done. Installed ${version}:" + ls -1 "${INSTALL_DIR}" | grep -v '\.sh$' | grep -v "${VERSION_FILE}" +} + +# ── Commands ───────────────────────────────────────────────────────── + +cmd_install() { + local version="${1:-}" + if [ -z "${version}" ]; then + echo "Fetching latest version ..." + version="$(get_latest_version)" + fi + do_install "${version}" +} + +cmd_upgrade() { + local target="${1:-}" + if [ -z "${target}" ]; then + echo "Fetching latest version ..." + target="$(get_latest_version)" + fi + + local current + current="$(get_installed_version)" + + if [ -z "${current}" ]; then + echo "No installed version found. Installing ${target} ..." + do_install "${target}" + return + fi + + echo "Installed : ${current}" + echo "Target : ${target}" + + if [ "${current}" = "${target}" ]; then + echo "Already up to date." + return + fi + + if version_gt "${target}" "${current}"; then + echo "Upgrading ${current} -> ${target} ..." + do_install "${target}" + else + echo "Installed version ${current} is newer than or equal to ${target}. Nothing to do." + fi +} + +cmd_version() { + local current + current="$(get_installed_version)" + if [ -z "${current}" ]; then + echo "Not installed. Run '$(basename "$0") install' first." + else + echo "${current}" + fi +} + +# ── Main ───────────────────────────────────────────────────────────── + +# Parse --dir option +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) INSTALL_DIR="$2"; shift 2 ;; + *) break ;; + esac +done + +COMMAND="${1:-}" +shift || true + +case "${COMMAND}" in + install) cmd_install "$@" ;; + upgrade) cmd_upgrade "$@" ;; + version) cmd_version ;; + *) usage ;; +esac diff --git a/.claude/commands/pegin-request.md b/.claude/commands/pegin-request.md new file mode 100644 index 00000000..041b4abc --- /dev/null +++ b/.claude/commands/pegin-request.md @@ -0,0 +1,64 @@ +Send a pegin request to GoatChain via the `pegin-request` binary. + +## Instructions + +1. Ask the user for the following parameters (skip any already provided as arguments: $ARGUMENTS): + - **subcommand**: Which action to perform? One of: + - `request` - Post a pegin request to GoatChain + - `prepare` - Build, sign and broadcast the pegin deposit tx on Bitcoin + - `request-prepare` - Request then auto-prepare after waiting ~12 minutes + - `cancel` - Build, sign and broadcast the pegin refund tx on Bitcoin + - **network**: Bitcoin network (`bitcoin`, `testnet`, `testnet4`, `signet`, `regtest`). Default: `testnet4` + - **esplora_url**: Esplora API URL. Default: `https://mempool.space/testnet4/api` + - For `request` / `request-prepare`: + - **pegin_amount_sats**: Amount in satoshis. Default: `1000000` (0.01 BTC) + - **instance_id**: Optional UUID (auto-generated if omitted) + - **fee_rate**: Optional fee rate in sat/vbyte (fetched from API if omitted) + - **receiver_evm_address**: Optional EVM address (read from `GOAT_ADDRESS` env if omitted) + - For `prepare` / `cancel`: + - **instance_id**: Required UUID from a previous request + +2. Ensure the `.env` file (in `node/` directory) has the required environment variables: + - `BITVM_SECRET` - Node BTC private key (hex or `seed:...`) + - `GOAT_PRIVATE_KEY` - Node GoatNetwork private key + - `BITCOIN_NETWORK` - Bitcoin network name + - `GOAT_CHAIN_URL` - GoatNetwork RPC URL + - `GOAT_GATEWAY_CONTRACT_ADDRESS` - Gateway contract address + +3. Check if the `pegin-request` binary exists at `./bin/pegin-request`. If not, run the install script to download it: + ```bash + .claude/commands/install-bitvm2.sh install + ``` + To upgrade to the latest version: + ```bash + .claude/commands/install-bitvm2.sh upgrade + ``` + The script auto-detects the platform (x86_64-linux / aarch64-macos), downloads from GitHub Releases, verifies the sha256 checksum, and installs all binaries to `./bin/`. + +4. Run the command using the pre-built binary: + +```bash +./bin/pegin-request [options] +``` + +### Example commands + +Request: +```bash +./bin/pegin-request --network testnet4 --esplora-url https://mempool.space/testnet4/api request --pegin-amount-sats 1000000 +``` + +Prepare (after request): +```bash +./bin/pegin-request --network testnet4 prepare --instance-id +``` + +Request + auto-prepare: +```bash +./bin/pegin-request --network testnet4 request-prepare --pegin-amount-sats 1000000 --wait-minutes 12 +``` + +Cancel: +```bash +./bin/pegin-request --network testnet4 cancel --instance-id +``` diff --git a/.claude/commands/pegout.md b/.claude/commands/pegout.md new file mode 100644 index 00000000..85897fad --- /dev/null +++ b/.claude/commands/pegout.md @@ -0,0 +1,62 @@ +Initiate operator pegout (Gateway.initWithdraw) via the `pegout` binary. + +The pegout binary calls the operator node's REST API, so an **Operator node** must be running and reachable. + +## Prerequisites — Running an Operator Node + +Before using this command, ensure an Operator role node (`ACTOR=Operator`) is running. + +Use the `/run-operator-node` skill to start one, or see `deployment/README.md` (section **Operator**) for full details. + +## Instructions + +1. Ask the user for the following parameters (skip any already provided as arguments: $ARGUMENTS): + - **subcommand**: Which mode to use? + - `once` - Single pegout for one graph + - `batch` - Repeat until balance insufficient or target reached + - **api_url**: Node API base URL. Default: `http://localhost:8080` + - For `once`: + - **graph_id**: Optional UUID of the graph to pegout (auto-selects if omitted) + - **dry_run**: Whether to skip the actual Gateway.initWithdraw call. Default: false + - For `batch`: + - **max_total_amount_sats**: Required. Stop after total pegout amount reaches this target (sats) + - **max_count**: Optional limit on number of pegouts. Default: 0 (unlimited) + - **dry_run**: Whether to skip the actual calls. Default: false + - **poll_interval_secs**: Poll interval between pegouts. Default: 300 + - **max_wait_secs**: Max wait for a graph to be ready. Default: 36000 + +2. Confirm that the Operator node is running and reachable at the given `api_url`. If the user hasn't started a node yet, walk them through the `/run-operator-node` skill. + +3. **Verify the node has synced eligible graphs.** The operator node syncs graph data via P2P. Check by calling: + ```bash + curl -s /v1/graphs/ready-to-kickoff?btc_pub_key= | jq . + ``` + If `"graph"` is null, the node may not have synced yet or there are no eligible graphs. + +4. Check if the `pegout` binary exists at `./bin/pegout`. If not, run: + ```bash + .claude/commands/install-bitvm2.sh install + ``` + +5. Run the command using the pre-built binary: + +```bash +./bin/pegout [--api-url ] [options] +``` + +### Example commands + +Single pegout (auto-select graph): +```bash +./bin/pegout --api-url http://127.0.0.1:8902 once +``` + +Single pegout (specific graph, dry run): +```bash +./bin/pegout --api-url http://127.0.0.1:8902 once --graph-id --dry-run +``` + +Batch pegout: +```bash +./bin/pegout --api-url http://127.0.0.1:8902 batch --max-total-amount-sats 10000000 --max-count 5 +``` diff --git a/.claude/commands/run-challenger-node.md b/.claude/commands/run-challenger-node.md new file mode 100644 index 00000000..7bb347c5 --- /dev/null +++ b/.claude/commands/run-challenger-node.md @@ -0,0 +1,57 @@ +Run a BitVM2 Challenger node locally. + +The Challenger verifies operator operations and submits challenges if necessary. + +## Instructions + +1. Ask the user for the following parameters (skip any already provided as arguments: $ARGUMENTS): + - **network**: Which network? `testnet4` or `regtest` + - **rpc_addr**: RPC listen address. Default: `127.0.0.1:8906` + - **p2p_port**: P2P listen port. Default: `8449` (testnet4) or `8450` (regtest) + - **db_path**: SQLite database path. Default: `sqlite:$PWD/bitvm2-node.db` + +2. Ensure the `.env` file exists in the working directory. Template configs are at: + - **testnet4**: `deployment/testnet4/bitvm2-nodes/challenge_0/.env.challenge_0` + - **regtest**: `deployment/regtest/bitvm2-nodes/challenge_0/.env.challenge_0` + + The user **must** fill in these required secrets: + - `BITVM_SECRET` - Node BTC key (hex or `seed:...`) + - `GOAT_ADDRESS` - Challenger's EVM address + - `PEER_KEY` - libp2p private key for node identity + + Copy the template if needed: + ```bash + cp deployment//bitvm2-nodes/challenge_0/.env.challenge_0 .env + ``` + +3. Check if the `bitvm2-noded` binary exists at `./bin/bitvm2-noded`. If not, run: + ```bash + .claude/commands/install-bitvm2.sh install + ``` + +4. Start the challenger node: + +```bash +./bin/bitvm2-noded --rpc-addr --db-path --p2p-port --bootnodes "$BOOTNODES" +``` + +To run in the background: +```bash +nohup ./bin/bitvm2-noded --rpc-addr --db-path --p2p-port --bootnodes "$BOOTNODES" >challenger_$(date +'%Y%m%d').log 2>&1 & +``` + +5. Verify the node is running: +```bash +curl -s http:/// +``` +Should return `Hello, World!`. + +### Example (testnet4) + +```bash +cp deployment/testnet4/bitvm2-nodes/challenge_0/.env.challenge_0 .env +# Edit .env to fill in BITVM_SECRET, GOAT_ADDRESS, PEER_KEY +./bin/bitvm2-noded --rpc-addr 127.0.0.1:8906 --db-path sqlite:$PWD/bitvm2-node.db --p2p-port 8449 --bootnodes /ip4/34.215.238.232/tcp/8445/p2p/12D3KooWCrPTAmhFdC5DBGgkxZvJi6iuSeiDWKRL87isrt4iMHXv +``` + +For full deployment documentation, see `deployment/README.md` (section **Challenger**). diff --git a/.claude/commands/run-operator-node.md b/.claude/commands/run-operator-node.md new file mode 100644 index 00000000..79323c65 --- /dev/null +++ b/.claude/commands/run-operator-node.md @@ -0,0 +1,60 @@ +Run a BitVM2 Operator node locally. + +The Operator manages bridge operations, kickoff processing, and pegout (Gateway.initWithdraw). + +## Instructions + +1. Ask the user for the following parameters (skip any already provided as arguments: $ARGUMENTS): + - **network**: Which network? `testnet4` or `regtest` + - **rpc_addr**: RPC listen address. Default: `127.0.0.1:8902` + - **p2p_port**: P2P listen port. Default: `8445` (testnet4) or `8446` (regtest) + - **db_path**: SQLite database path. Default: `sqlite:$PWD/bitvm2-node.db` + +2. Ensure the `.env` file exists in the working directory. Template configs are at: + - **testnet4**: `deployment/testnet4/bitvm2-nodes/operator_0/.env.operator_0` + - **regtest**: `deployment/regtest/bitvm2-nodes/operator_0/.env.operator_0` + + The user **must** fill in these required secrets: + - `BITVM_SECRET` - Operator BTC private key (hex) + - `GOAT_ADDRESS` - Operator's EVM address + - `PEER_KEY` - libp2p private key for node identity + + For pegout operations, the node also needs: + - `GOAT_PRIVATE_KEY` - Operator's GoatNetwork private key (for signing initWithdraw) + + Copy the template if needed: + ```bash + cp deployment//bitvm2-nodes/operator_0/.env.operator_0 .env + ``` + +3. Check if the `bitvm2-noded` binary exists at `./bin/bitvm2-noded`. If not, run: + ```bash + .claude/commands/install-bitvm2.sh install + ``` + +4. Start the operator node: + +```bash +./bin/bitvm2-noded --rpc-addr --db-path --p2p-port --bootnodes "$BOOTNODES" +``` + +To run in the background: +```bash +nohup ./bin/bitvm2-noded --rpc-addr --db-path --p2p-port --bootnodes "$BOOTNODES" >operator_$(date +'%Y%m%d').log 2>&1 & +``` + +5. Verify the node is running: +```bash +curl -s http:/// +``` +Should return `Hello, World!`. + +### Example (testnet4) + +```bash +cp deployment/testnet4/bitvm2-nodes/operator_0/.env.operator_0 .env +# Edit .env to fill in BITVM_SECRET, GOAT_ADDRESS, PEER_KEY, GOAT_PRIVATE_KEY +./bin/bitvm2-noded --rpc-addr 127.0.0.1:8902 --db-path sqlite:$PWD/bitvm2-node.db --p2p-port 8445 --bootnodes /ip4/34.215.238.232/tcp/8445/p2p/12D3KooWCrPTAmhFdC5DBGgkxZvJi6iuSeiDWKRL87isrt4iMHXv +``` + +For full deployment documentation, see `deployment/README.md` (section **Operator**). diff --git a/.claude/commands/upgrade.md b/.claude/commands/upgrade.md new file mode 100644 index 00000000..29de3063 --- /dev/null +++ b/.claude/commands/upgrade.md @@ -0,0 +1,19 @@ +Upgrade or install bitvm2-node binaries via `install-bitvm2.sh`. + +## Instructions + +1. Check the currently installed version: + ```bash + .claude/commands/install-bitvm2.sh version + ``` + +2. If $ARGUMENTS contains a target version (e.g. `v0.3.2`), use that version. Otherwise, upgrade to the latest release. + +3. Run the upgrade: + ```bash + .claude/commands/install-bitvm2.sh upgrade $ARGUMENTS + ``` + +4. If the script is missing or not executable, inform the user that `.claude/commands/install-bitvm2.sh` is required and offer to check if it exists. + +5. Report the result to the user: what version was installed before, what version is installed now, and list the installed binaries. diff --git a/node/src/bin/send_challenge.rs b/node/src/bin/send_challenge.rs index 2b00c4d1..bf462551 100644 --- a/node/src/bin/send_challenge.rs +++ b/node/src/bin/send_challenge.rs @@ -1,84 +1,71 @@ -//! challenge: broadcast a Challenge transaction for a graph. +//! challenge: broadcast a Challenge transaction for a graph via the node API. //! //! Purpose: -//! - Read a finalized graph from the local DB, rebuild the full BitVM2 graph, -//! and broadcast the Challenge transaction on Bitcoin. +//! - Call the node's send-challenge API endpoint to broadcast the Challenge +//! transaction on Bitcoin. No direct DB access is needed. //! //! Env: -//! - BITVM_SECRET: node BTC key (hex or seed:...) for signing and fee inputs -//! - GOAT_PRIVATE_KEY or GOAT_ADDRESS: EVM identity for OP_RETURN -//! - BITCOIN_NETWORK: bitcoin | testnet | testnet4 | signet | regtest -//! - GOAT_CHAIN_URL: GoatNetwork RPC URL (if required by client helpers) -//! - GOAT_GATEWAY_CONTRACT_ADDRESS: Gateway contract address (if required by helpers) +//! - (Environment variables like BITVM_SECRET, GOAT_PRIVATE_KEY, etc. are +//! required on the **node** side, not on this client.) //! //! Args: -//! - --db-path: local SQLite path (e.g., sqlite:/tmp/bitvm2-node.db) -//! - --instance-id / --graph-id: target graph -//! - --esplora-url: optional Esplora override +//! - --api-url: node API base URL (default: http://localhost:8080) +//! - --graph-id: target graph UUID //! //! Example: //! - cargo run -p bitvm2-noded --bin challenge -- \ -//! db-path sqlite:/tmp/bitvm2-node.db \ -//! --instance-id \ +//! --api-url http://localhost:8080 \ //! --graph-id use anyhow::{Context, Result}; -use bitvm2_lib::types::Bitvm2Graph; -use bitvm2_noded::env::get_network; use clap::Parser; -use client::btc_chain::BTCClient; -use dotenv::dotenv; -use tracing_subscriber::EnvFilter; -use uuid::Uuid; - -use bitvm2_noded::utils::get_graph; -use bitvm2_noded::utils::send_challenge_tx; -use store::create_local_db; +use serde::Deserialize; #[derive(Debug, Parser)] #[command( name = "send-challenge", version, - about = "Broadcast a Challenge transaction for a graph (reads graph from local DB)", - long_about = "Broadcast a Challenge transaction for a graph (reads graph from local DB).\n\nENV required:\n - BITVM_SECRET: node BTC key (hex) or seed:...\n - GOAT_PRIVATE_KEY or GOAT_ADDRESS: challenger EVM identity for OP_RETURN\n - BITCOIN_NETWORK: bitcoin | testnet | signet | regtest (default: testnet)" + about = "Broadcast a Challenge transaction for a graph (via node API)", + long_about = "Broadcast a Challenge transaction for a graph via the node's REST API.\n\nThe node must be running and reachable at the given --api-url." )] struct Args { - /// Instance UUID of the graph (for sanity; not used for lookup strictly) - #[arg(long)] - instance_id: Uuid, - /// Graph UUID to challenge #[arg(long)] - graph_id: Uuid, + graph_id: uuid::Uuid, - /// Local Sqlite database file path - #[arg(long, default_value = "sqlite:/tmp/bitvm2-node.db")] - db_path: String, + /// Node API base URL + #[arg(long, default_value = "http://localhost:8080")] + api_url: String, +} - /// Esplora base URL (optional override for Bitcoin RPC via Esplora) - #[arg(long, default_value = "https://mempool.space/testnet4/api")] - esplora_url: String, +#[derive(Debug, Deserialize)] +struct SendChallengeResponse { + challenge_txid: String, } #[tokio::main] async fn main() -> Result<()> { - dotenv().ok(); - let _ = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).try_init(); - let args = Args::parse(); - let network = get_network(); - let btc_client = BTCClient::new(network, Some(&args.esplora_url)); + let url = format!( + "{}/v1/graphs/{}/send-challenge", + args.api_url.trim_end_matches('/'), + args.graph_id + ); + + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .send() + .await + .with_context(|| format!("failed to reach node API at {url}"))?; - // Open local DB and load graph - let local_db = create_local_db(&args.db_path).await; - let simple = - get_graph(&local_db, args.instance_id, args.graph_id).await?.with_context(|| { - format!("graph {} not found in local DB at {}", args.graph_id, args.db_path) - })?; - let graph = Bitvm2Graph::from_simplified(&simple) - .context("failed to rebuild full graph from simplified data in DB")?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("API returned {status}: {body}"); + } - let txid = send_challenge_tx(&btc_client, &graph).await?; - println!("Challenge tx broadcasted: {txid}"); + let body: SendChallengeResponse = resp.json().await.context("failed to parse API response")?; + println!("Challenge tx broadcasted: {}", body.challenge_txid); Ok(()) } diff --git a/node/src/bin/send_pegout.rs b/node/src/bin/send_pegout.rs index 68dfa901..9de5a3b3 100644 --- a/node/src/bin/send_pegout.rs +++ b/node/src/bin/send_pegout.rs @@ -1,61 +1,37 @@ -//! pegout: operator pegout helper (Gateway.initWithdraw). -//! -//! Flow: -//! 1) Read local DB and select the minimal kickoff-index graph with status OperatorDataPushed -//! 2) Check L2 status: pegin is Withdrawable and withdraw status is None -//! 3) Call Gateway.initWithdraw (ensure pegBTC allowance in advance) +//! pegout: operator pegout helper (Gateway.initWithdraw) via the node API. //! //! Modes: //! - once: single pegout for one graph -//! - batch: repeat until balance insufficient or target reached +//! - batch: repeat until target amount reached or no eligible graphs +//! +//! The operator node must be running and reachable at the given --api-url. +//! All env-based secrets (BITVM_SECRET, GOAT_PRIVATE_KEY, etc.) are required +//! on the **node** side, not on this client. //! -//! Env: -//! - BITVM_SECRET: node BTC private key (used to derive operator pubkey) -//! - GOAT_PRIVATE_KEY: node GoatNetwork private key -//! - BITCOIN_NETWORK: bitcoin | testnet | testnet4 | signet | regtest -//! - GOAT_CHAIN_URL: GoatNetwork RPC URL -//! - GOAT_GATEWAY_CONTRACT_ADDRESS: Gateway contract address +//! Args: +//! - --api-url: node API base URL (default: http://localhost:8080) //! //! Example: -//! - cargo run -p bitvm2-noded --bin pegout -- once \ -//! -db-path sqlite:/tmp/bitvm2-node.db \ -//! -operator-pubkey +//! - cargo run -p bitvm2-noded --bin pegout -- \ +//! --api-url http://localhost:8080 once --graph-id -use std::str::FromStr; - -use alloy::primitives::U256; -use anyhow::{Result, anyhow, bail}; -use bitcoin::PublicKey; +use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; -use dotenv::dotenv; +use serde::{Deserialize, Serialize}; use tokio::time::{Duration, sleep}; -use tracing::{info, warn}; -use tracing_subscriber::EnvFilter; use uuid::Uuid; -use bitvm2_noded::env::{ - get_goat_gateway_contract_from_env, get_goat_network, get_node_goat_address, get_node_pubkey, - goat_config_from_env, -}; -use client::goat_chain::{GOATClient, PeginStatus, WithdrawStatus}; -use store::localdb::{GraphQuery, LocalDB}; -use store::{Graph, GraphStatus, create_local_db}; - #[derive(Parser, Debug)] #[command( name = "send-pegout", version, - about = "Operator pegout helper", - long_about = "Initiate pegout by calling Gateway.initWithdraw for eligible graphs" + about = "Operator pegout helper (via node API)", + long_about = "Initiate pegout by calling the node's REST API.\n\nThe operator node must be running and reachable at the given --api-url." )] struct Args { - /// Local Sqlite database file path - #[arg(long, default_value = "sqlite:/tmp/bitvm2-node.db")] - db_path: String, - - /// Optional operator BTC pubkey (override env-derived key) - #[arg(long)] - operator_pubkey: Option, + /// Node API base URL + #[arg(long, default_value = "http://localhost:8080")] + api_url: String, #[command(subcommand)] command: Commands, @@ -65,15 +41,15 @@ struct Args { enum Commands { /// Initiate a single pegout on the next eligible graph Once { - /// Optional explicit graph id; if not provided, pick minimal kickoff-index + /// Optional explicit graph id; if not provided, the node picks the next eligible graph #[arg(long)] graph_id: Option, - /// Dry run (do not call Gateway.initWithdraw) + /// Dry run (validate but do not call Gateway.initWithdraw) #[arg(long, default_value_t = false)] dry_run: bool, }, - /// Initiate pegouts repeatedly until balance insufficient or target reached + /// Initiate pegouts repeatedly until target amount reached Batch { /// Stop after total pegout amount reaches this target (sats) #[arg(long)] @@ -83,421 +59,131 @@ enum Commands { #[arg(long, default_value_t = 0)] max_count: u32, - /// Dry run (do not call Gateway.initWithdraw) + /// Dry run (validate but do not call Gateway.initWithdraw) #[arg(long, default_value_t = false)] dry_run: bool, - /// Poll interval seconds for checking readiness between pegouts + /// Poll interval seconds for checking graph readiness between pegouts #[arg(long, default_value_t = 300)] poll_interval_secs: u64, - /// Max wait seconds for a graph to be ready before stopping + /// Max wait seconds for a graph to become ready before stopping #[arg(long, default_value_t = 36000)] max_wait_secs: u64, }, } -#[tokio::main] -async fn main() -> Result<()> { - dotenv().ok(); - let _ = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).try_init(); - - let args = Args::parse(); - let local_db = create_local_db(&args.db_path).await; - let goat_client = GOATClient::new(goat_config_from_env().await, get_goat_network()); - - let operator_pubkey = match args.operator_pubkey { - Some(v) => validate_operator_pubkey(&v)?, - None => get_node_pubkey()?.to_string(), - }; - let operator_goat_addr = get_node_goat_address().ok_or_else(|| { - anyhow!("missing operator goat address; set GOAT_PRIVATE_KEY or GOAT_ADDRESS") - })?; - let gateway_addr = get_goat_gateway_contract_from_env(); - - let balance = goat_client.peg_btc_balance(&operator_goat_addr.0).await?; - info!("pegBTC balance for operator {}: {}", operator_goat_addr, balance); - - match args.command { - Commands::Once { graph_id, dry_run } => { - let graph = select_graph(&local_db, &operator_pubkey, graph_id).await?; - if let Some(graph) = graph { - let _ = init_withdraw_for_graph( - &goat_client, - &graph, - balance, - &operator_goat_addr, - &gateway_addr, - true, - dry_run, - ) - .await?; - } else { - info!("no eligible graph found for operator {}", operator_pubkey); - } - } - Commands::Batch { - max_total_amount_sats, - max_count, - dry_run, - poll_interval_secs, - max_wait_secs, - } => { - if max_total_amount_sats == 0 { - bail!("max_total_amount_sats must be > 0 for batch mode"); - } - run_batch( - &local_db, - &goat_client, - &operator_pubkey, - balance, - &operator_goat_addr, - &gateway_addr, - max_total_amount_sats, - max_count, - dry_run, - poll_interval_secs, - max_wait_secs, - ) - .await?; - } - } +#[derive(Debug, Serialize)] +struct PegoutApiRequest { + #[serde(skip_serializing_if = "Option::is_none")] + graph_id: Option, + dry_run: bool, +} - Ok(()) +#[derive(Debug, Deserialize)] +struct PegoutApiResponse { + graph_id: String, + instance_id: String, + kickoff_index: i64, + amount: i64, + tx_hash: Option, + dry_run: bool, } -async fn select_graph( - local_db: &LocalDB, - operator_pubkey: &str, - graph_id: Option, -) -> Result> { - info!("select_graph start: operator_pubkey {}, graph_id {:?}", operator_pubkey, graph_id); - let mut storage_processor = local_db.acquire().await?; - if let Some(graph_id) = graph_id { - let graph = storage_processor.find_graph(&graph_id).await?; - if let Some(graph) = graph { - if graph.operator_pubkey != operator_pubkey { - bail!("graph {graph_id} not owned by operator {operator_pubkey}"); - } - if graph.status != GraphStatus::OperatorDataPushed.to_string() { - bail!("graph {} status {} is not OperatorDataPushed", graph_id, graph.status); - } - if graph.init_withdraw_tx_hash.is_some() { - bail!("graph {graph_id} already initialized withdraw"); - } - if !is_graph_ready_by_previous(&mut storage_processor, &graph).await? { - bail!("graph {graph_id} not ready due to previous graph status"); - } - info!( - "select_graph picked explicit graph {} kickoff_index {} amount {}", - graph.graph_id, graph.kickoff_index, graph.amount - ); - return Ok(Some(graph)); - } - info!("select_graph: graph {} not found", graph_id); - return Ok(None); - } +#[derive(Debug, Deserialize)] +struct GraphApiResponse { + graph: Option, +} - let graphs = storage_processor - .get_operator_graphs( - GraphQuery::default() - .with_operator_pubkey(operator_pubkey.to_string()) - .with_status(GraphStatus::OperatorDataPushed.to_string()) - .with_raw_condition("init_withdraw_tx_hash IS NULL".to_string()) - .with_order("kickoff_index ASC".to_string()) - .with_limit(1), - ) - .await?; - if let Some(graph) = graphs.into_iter().next() { - if !is_graph_ready_by_previous(&mut storage_processor, &graph).await? { - info!("select_graph: graph {} not ready due to previous status", graph.graph_id); - return Ok(None); - } - info!( - "select_graph picked graph {} kickoff_index {} amount {}", - graph.graph_id, graph.kickoff_index, graph.amount - ); - Ok(Some(graph)) - } else { - info!("select_graph: no graph matched query"); - Ok(None) - } +#[derive(Debug, Deserialize)] +struct GraphData { + status: String, } -async fn is_graph_ready_by_previous( - storage_processor: &mut store::localdb::StorageProcessor<'_>, - graph: &Graph, -) -> Result { - if graph.kickoff_index <= 0 { - return Ok(true); - } - let pre_graphs = storage_processor - .get_operator_graphs( - GraphQuery::default() - .with_operator_pubkey(graph.operator_pubkey.clone()) - .with_kickoff_index(graph.kickoff_index - 1), - ) - .await?; - if pre_graphs.is_empty() { - return Ok(true); - } - let pre_status = &pre_graphs[0].status; - Ok(![ - GraphStatus::OperatorDataPushed.to_string(), - GraphStatus::OperatorKickOff.to_string(), - GraphStatus::Challenge.to_string(), - ] - .contains(pre_status)) +#[derive(Debug, Deserialize)] +struct ApiError { + error: String, + message: String, } -async fn init_withdraw_for_graph( - goat_client: &GOATClient, - graph: &Graph, - balance: U256, - operator_goat_addr: &alloy::primitives::Address, - gateway_addr: &alloy::primitives::Address, - ensure_allowance: bool, +async fn call_pegout( + client: &reqwest::Client, + base_url: &str, + graph_id: Option, dry_run: bool, -) -> Result { - let amount = if graph.amount > 0 { - graph.amount as u64 - } else { - bail!("graph {} amount {} is invalid", graph.graph_id, graph.amount); - }; - - let amount_u256 = U256::from(amount); - if balance < amount_u256 { - bail!("insufficient pegBTC balance: need {amount}, available {balance}"); - } - - let pegin_data = goat_client.gateway_get_pegin_data(&graph.instance_id).await?; - if pegin_data.status != PeginStatus::Withdrawable { - bail!( - "graph {} instance {} pegin status is {:?}, not Withdrawable", - graph.graph_id, - graph.instance_id, - pegin_data.status - ); - } - let withdraw_data = goat_client.gateway_get_withdraw_data(&graph.graph_id).await?; - if withdraw_data.status != WithdrawStatus::None { - bail!("graph {} withdraw status is {:?}, not None", graph.graph_id, withdraw_data.status); - } - - info!( - "initWithdraw graph {} (instance {} kickoff_index {}) amount {} sats", - graph.graph_id, graph.instance_id, graph.kickoff_index, amount - ); - - if dry_run { - info!("dry-run enabled: skip Gateway.initWithdraw call"); - return Ok(balance - amount_u256); - } - - if ensure_allowance { - ensure_peg_btc_allowance(goat_client, operator_goat_addr, gateway_addr, amount_u256) - .await?; +) -> Result { + let url = format!("{}/v1/pegout", base_url.trim_end_matches('/')); + let body = PegoutApiRequest { graph_id: graph_id.map(|id| id.to_string()), dry_run }; + + let resp = client + .post(&url) + .json(&body) + .send() + .await + .with_context(|| format!("failed to reach node API at {url}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if let Ok(api_err) = serde_json::from_str::(&body) { + bail!("API error ({}): {}", api_err.error, api_err.message); + } + bail!("API returned {status}: {body}"); } - let tx_hash = goat_client.gateway_init_withdraw(&graph.instance_id, &graph.graph_id).await?; - info!("initWithdraw submitted: graph {} tx_hash {}", graph.graph_id, tx_hash); - - Ok(balance - amount_u256) + resp.json().await.context("failed to parse pegout API response") } -async fn ensure_peg_btc_allowance( - goat_client: &GOATClient, - owner: &alloy::primitives::Address, - spender: &alloy::primitives::Address, - amount: U256, -) -> Result<()> { - let allowance = goat_client.peg_btc_allowance(&owner.0, &spender.0).await?; - if allowance >= amount { - return Ok(()); - } - info!("pegBTC allowance {} < required {}, approving spender {}", allowance, amount, spender); - let tx_hash = goat_client.peg_btc_approve(&spender.0, amount).await?; - info!("pegBTC approve submitted: {tx_hash}"); - - let mut retries = 0u32; - loop { - let current = goat_client.peg_btc_allowance(&owner.0, &spender.0).await?; - if current >= amount { - info!("pegBTC allowance updated: {}", current); - return Ok(()); - } - retries += 1; - if retries >= 30 { - bail!("pegBTC allowance not updated after approve"); - } - sleep(Duration::from_secs(5)).await; +async fn get_graph_status( + client: &reqwest::Client, + base_url: &str, + graph_id: &str, +) -> Result> { + let url = format!("{}/v1/graphs/{}", base_url.trim_end_matches('/'), graph_id); + let resp = client.get(&url).send().await?; + if !resp.status().is_success() { + return Ok(None); } + let data: GraphApiResponse = resp.json().await?; + Ok(data.graph.map(|g| g.status)) } -#[allow(clippy::too_many_arguments)] -async fn run_batch( - local_db: &LocalDB, - goat_client: &GOATClient, - operator_pubkey: &str, - balance: U256, - operator_goat_addr: &alloy::primitives::Address, - gateway_addr: &alloy::primitives::Address, - max_total_amount_sats: u64, - max_count: u32, - dry_run: bool, - poll_interval_secs: u64, - max_wait_secs: u64, -) -> Result<()> { - info!( - "batch start: operator_pubkey {}, max_total_amount_sats {}, max_count {}, dry_run {}", - operator_pubkey, max_total_amount_sats, max_count, dry_run - ); - let mut remaining_balance = balance; - let mut total_sent: u64 = 0; - let mut count: u32 = 0; - - if !dry_run { - let target_allowance = U256::from(max_total_amount_sats); - ensure_peg_btc_allowance(goat_client, operator_goat_addr, gateway_addr, target_allowance) - .await?; - } - - loop { - if max_count > 0 && count >= max_count { - info!("batch stop: reached max_count {max_count}"); - break; - } - if total_sent >= max_total_amount_sats { - info!("batch stop: reached target total {} sats", max_total_amount_sats); - break; - } - - let graph = select_graph(local_db, operator_pubkey, None).await?; - let Some(graph) = graph else { - if dry_run { - info!("batch stop: no eligible graph found (dry-run)"); - break; - } - if let Some(in_progress) = find_in_progress_graph(local_db, operator_pubkey).await? { - info!( - "batch found in-progress graph {}, waiting before next pegout", - in_progress.graph_id - ); - wait_until_next_ready( - local_db, - in_progress.graph_id, - poll_interval_secs, - max_wait_secs, - ) - .await?; - continue; - } - info!("batch stop: no eligible graph found"); - break; - }; - - let amount = if graph.amount > 0 { graph.amount as u64 } else { 0 }; - if amount == 0 { - warn!("skip graph {} with invalid amount {}", graph.graph_id, graph.amount); - break; - } - if total_sent + amount > max_total_amount_sats { - info!( - "batch stop: next amount {} would exceed target {}", - amount, max_total_amount_sats - ); - break; - } - - info!( - "batch initWithdraw: graph {} amount {} remaining_balance {}", - graph.graph_id, amount, remaining_balance +fn print_pegout_result(resp: &PegoutApiResponse) { + if resp.dry_run { + println!( + "dry-run: graph {} (instance {} kickoff_index {}) amount {} sats", + resp.graph_id, resp.instance_id, resp.kickoff_index, resp.amount ); - - let new_balance = init_withdraw_for_graph( - goat_client, - &graph, - remaining_balance, - operator_goat_addr, - gateway_addr, - false, - dry_run, - ) - .await?; - - remaining_balance = new_balance; - total_sent = total_sent.saturating_add(amount); - count = count.saturating_add(1); - - info!( - "batch progress: count {}, total {} sats, remaining balance {}", - count, total_sent, remaining_balance + } else { + println!( + "initWithdraw submitted: graph {} tx_hash {}", + resp.graph_id, + resp.tx_hash.as_deref().unwrap_or("N/A") ); - - if !dry_run { - info!("batch waiting for graph {} to be ready before next pegout", graph.graph_id); - wait_until_next_ready(local_db, graph.graph_id, poll_interval_secs, max_wait_secs) - .await?; - } } - - Ok(()) } -async fn find_in_progress_graph( - local_db: &LocalDB, - operator_pubkey: &str, -) -> Result> { - let mut storage_processor = local_db.acquire().await?; - let in_progress_condition = format!( - "status IN ('{}','{}','{}') AND init_withdraw_tx_hash IS NOT NULL", - GraphStatus::OperatorDataPushed, - GraphStatus::OperatorKickOff, - GraphStatus::Challenge - ); - let graphs = storage_processor - .get_operator_graphs( - GraphQuery::default() - .with_operator_pubkey(operator_pubkey.to_string()) - .with_raw_condition(in_progress_condition) - .with_order("kickoff_index DESC".to_string()) - .with_limit(1), - ) - .await?; - Ok(graphs.into_iter().next()) -} +const IN_PROGRESS_STATUSES: &[&str] = &["OperatorDataPushed", "OperatorKickOff", "Challenge"]; -async fn wait_until_next_ready( - local_db: &LocalDB, - graph_id: Uuid, +async fn wait_for_graph_ready( + client: &reqwest::Client, + base_url: &str, + graph_id: &str, poll_interval_secs: u64, max_wait_secs: u64, ) -> Result<()> { let mut waited = 0u64; - let mut last_status: Option = None; loop { - let mut storage_processor = local_db.acquire().await?; - let graph = storage_processor.find_graph(&graph_id).await?; - if let Some(graph) = graph { - if ![ - GraphStatus::OperatorDataPushed.to_string(), - GraphStatus::OperatorKickOff.to_string(), - GraphStatus::Challenge.to_string(), - ] - .contains(&graph.status) - { - info!("graph {} ready for next pegout, status {}", graph_id, graph.status); + if let Some(status) = get_graph_status(client, base_url, graph_id).await? { + if !IN_PROGRESS_STATUSES.contains(&status.as_str()) { + eprintln!("graph {} ready, status: {}", graph_id, status); return Ok(()); } - if last_status.as_deref() != Some(graph.status.as_str()) { - info!("waiting graph {} to be ready, current status {}", graph_id, graph.status); - last_status = Some(graph.status.clone()); - } + eprintln!("waiting for graph {} (status: {})", graph_id, status); } else { - warn!("graph {} not found while waiting, proceed", graph_id); + eprintln!("graph {} not found, proceeding", graph_id); return Ok(()); } - if waited >= max_wait_secs { bail!("waited {max_wait_secs}s but graph {graph_id} still not ready"); } @@ -506,8 +192,78 @@ async fn wait_until_next_ready( } } -fn validate_operator_pubkey(pubkey: &str) -> Result { - let parsed = PublicKey::from_str(pubkey) - .map_err(|e| anyhow!("invalid operator_pubkey '{pubkey}': {e}"))?; - Ok(parsed.to_string()) +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + let client = reqwest::Client::new(); + let base_url = &args.api_url; + + match args.command { + Commands::Once { graph_id, dry_run } => { + let resp = call_pegout(&client, base_url, graph_id, dry_run).await?; + print_pegout_result(&resp); + } + Commands::Batch { + max_total_amount_sats, + max_count, + dry_run, + poll_interval_secs, + max_wait_secs, + } => { + if max_total_amount_sats == 0 { + bail!("max_total_amount_sats must be > 0 for batch mode"); + } + + let mut total_sent: u64 = 0; + let mut count: u32 = 0; + + loop { + if max_count > 0 && count >= max_count { + eprintln!("batch stop: reached max_count {max_count}"); + break; + } + if total_sent >= max_total_amount_sats { + eprintln!("batch stop: reached target total {} sats", max_total_amount_sats); + break; + } + + match call_pegout(&client, base_url, None, dry_run).await { + Ok(resp) => { + let amount = resp.amount as u64; + if total_sent + amount > max_total_amount_sats { + eprintln!( + "batch stop: next amount {} would exceed target {}", + amount, max_total_amount_sats + ); + break; + } + + print_pegout_result(&resp); + total_sent = total_sent.saturating_add(amount); + count = count.saturating_add(1); + eprintln!("batch progress: count {}, total {} sats", count, total_sent); + + if !dry_run { + wait_for_graph_ready( + &client, + base_url, + &resp.graph_id, + poll_interval_secs, + max_wait_secs, + ) + .await?; + } + } + Err(e) => { + eprintln!("batch stop: {e}"); + break; + } + } + } + + eprintln!("batch complete: {} pegouts, {} total sats", count, total_sent); + } + } + + Ok(()) } diff --git a/node/src/rpc_service/bitvm2.rs b/node/src/rpc_service/bitvm2.rs index 29eb8acc..9f3933cd 100644 --- a/node/src/rpc_service/bitvm2.rs +++ b/node/src/rpc_service/bitvm2.rs @@ -87,6 +87,28 @@ pub struct InstanceSettingResponse { pub bridge_in_amount: Vec, } +#[derive(Debug, Deserialize, Serialize)] +pub struct SendChallengeResponse { + pub challenge_txid: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PegoutRequest { + pub graph_id: Option, + #[serde(default)] + pub dry_run: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PegoutResponse { + pub graph_id: String, + pub instance_id: String, + pub kickoff_index: i64, + pub amount: i64, + pub tx_hash: Option, + pub dry_run: bool, +} + #[derive(Debug, Deserialize)] pub struct GraphTxGetParams { pub tx_name: String, diff --git a/node/src/rpc_service/handler/bitvm2_handler.rs b/node/src/rpc_service/handler/bitvm2_handler.rs index 4715193a..2fd5870c 100644 --- a/node/src/rpc_service/handler/bitvm2_handler.rs +++ b/node/src/rpc_service/handler/bitvm2_handler.rs @@ -1,6 +1,7 @@ use crate::env::{ ENV_GOAT_GATEWAY_CONTRACT_ADDRESS, ENV_GOAT_SWAP_CONTRACT_ADDRESS, GraphBtcTxName, - get_goat_address_from_env, get_network, + get_goat_address_from_env, get_goat_gateway_contract_from_env, get_network, + get_node_goat_address, get_node_pubkey, }; use crate::rpc_service::bitvm2::*; use crate::rpc_service::node::ALIVE_TIME_JUDGE_THRESHOLD; @@ -14,14 +15,14 @@ use crate::scheduled_tasks::graph_maintenance_tasks::{ }; use crate::utils::{ find_instances_by_escrow_hash, gen_instance_parameters_local, get_bridge_out_global_stats, - parse_graph_raw_data, + parse_graph_raw_data, send_challenge_tx, }; use alloy::primitives::{Address, U256}; use axum::Json; use axum::extract::{Path, Query, State}; use bitcoin::consensus::encode::serialize_hex; use bitvm2_lib::types::{Bitvm2Graph, SimplifiedBitvm2Graph}; -use client::goat_chain::DisproveTxType; +use client::goat_chain::{DisproveTxType, PeginStatus, WithdrawStatus}; use goat::transactions::pre_signed::PreSignedTransaction; use http::StatusCode; use std::default::Default; @@ -1693,6 +1694,272 @@ pub async fn get_unsigned_pegin_txn( ok_response(res) } +/// Send challenge transaction for a graph +/// +/// Loads the graph from DB, rebuilds the full BitVM2 graph, and broadcasts +/// the Challenge transaction on Bitcoin. +/// +/// # Path Parameters +/// +/// - `graph_id`: UUID of the BitVM2 graph to challenge +/// +/// # Returns +/// +/// - `200 OK`: Challenge transaction broadcasted successfully, returns txid +/// - `500 Internal Server Error`: Graph not found or broadcast failed +#[axum::debug_handler] +pub async fn send_challenge( + Path(graph_id): Path, + State(app_state): State>, +) -> ApiResult { + let graph_id_uuid = InputValidator::validate_uuid(&graph_id, "graph_id")?; + + let mut storage_process = + app_state.local_db.acquire().await.api_error("SEND_CHALLENGE_ERROR")?; + + let graph_raw_data = storage_process + .find_graph_raw_data(&graph_id_uuid) + .await + .api_error("SEND_CHALLENGE_ERROR")? + .ok_or_else(|| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "SEND_CHALLENGE_ERROR".to_string(), + message: format!("graph:{graph_id} raw data not found in db"), + }), + ) + })?; + + let simplified_bitvm2_graph: SimplifiedBitvm2Graph = + parse_graph_raw_data(graph_raw_data.raw_data, graph_id_uuid) + .await + .api_error("SEND_CHALLENGE_ERROR")?; + + let bitvm2_graph: Bitvm2Graph = + Bitvm2Graph::from_simplified(&simplified_bitvm2_graph).api_error("SEND_CHALLENGE_ERROR")?; + + let txid = send_challenge_tx(&app_state.btc_client, &bitvm2_graph) + .await + .api_error("SEND_CHALLENGE_ERROR")?; + + ok_response(SendChallengeResponse { challenge_txid: txid.to_string() }) +} + +/// Initiate operator pegout (Gateway.initWithdraw) +/// +/// Selects an eligible graph (or uses the provided graph_id), checks L2 pegin/withdraw +/// status, ensures pegBTC allowance, and calls Gateway.initWithdraw. +/// +/// # Request Body +/// +/// - `graph_id`: Optional UUID of the graph to pegout (auto-selects if omitted) +/// - `dry_run`: If true, validate but skip the actual initWithdraw call (default: false) +/// +/// # Returns +/// +/// - `200 OK`: Pegout initiated (or validated in dry-run mode) +/// - `500 Internal Server Error`: No eligible graph, L2 status invalid, or initWithdraw failed +#[axum::debug_handler] +pub async fn pegout( + State(app_state): State>, + Json(payload): Json, +) -> ApiResult { + let operator_pubkey = get_node_pubkey().api_error("PEGOUT_ERROR")?.to_string(); + let operator_goat_addr = get_node_goat_address() + .ok_or_else(|| { + anyhow::anyhow!("missing operator goat address; set GOAT_PRIVATE_KEY or GOAT_ADDRESS") + }) + .api_error("PEGOUT_ERROR")?; + let gateway_addr = get_goat_gateway_contract_from_env(); + + let mut storage_process = app_state.local_db.acquire().await.api_error("PEGOUT_ERROR")?; + + // Select graph + let graph = if let Some(ref graph_id_str) = payload.graph_id { + let graph_id_uuid = InputValidator::validate_uuid(graph_id_str, "graph_id")?; + let graph = storage_process + .find_graph(&graph_id_uuid) + .await + .api_error("PEGOUT_ERROR")? + .ok_or_else(|| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "PEGOUT_ERROR".to_string(), + message: format!("graph {graph_id_str} not found"), + }), + ) + })?; + if graph.operator_pubkey != operator_pubkey { + return error_response( + "PEGOUT_ERROR".to_string(), + format!("graph {} not owned by this operator", graph.graph_id), + ); + } + if graph.status != GraphStatus::OperatorDataPushed.to_string() { + return error_response( + "PEGOUT_ERROR".to_string(), + format!( + "graph {} status {} is not OperatorDataPushed", + graph.graph_id, graph.status + ), + ); + } + if graph.init_withdraw_tx_hash.is_some() { + return error_response( + "PEGOUT_ERROR".to_string(), + format!("graph {} already initialized withdraw", graph.graph_id), + ); + } + graph + } else { + // Auto-select: minimal kickoff-index graph with OperatorDataPushed, no init_withdraw + let graphs = storage_process + .get_operator_graphs( + GraphQuery::default() + .with_operator_pubkey(operator_pubkey.clone()) + .with_status(GraphStatus::OperatorDataPushed.to_string()) + .with_raw_condition("init_withdraw_tx_hash IS NULL".to_string()) + .with_order("kickoff_index ASC".to_string()) + .with_limit(1), + ) + .await + .api_error("PEGOUT_ERROR")?; + graphs.into_iter().next().ok_or_else(|| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "PEGOUT_ERROR".to_string(), + message: format!("no eligible graph found for operator {operator_pubkey}"), + }), + ) + })? + }; + + // Check previous graph readiness + if graph.kickoff_index > 0 { + let pre_graphs = storage_process + .get_operator_graphs( + GraphQuery::default() + .with_operator_pubkey(graph.operator_pubkey.clone()) + .with_kickoff_index(graph.kickoff_index - 1), + ) + .await + .api_error("PEGOUT_ERROR")?; + if !pre_graphs.is_empty() + && [ + GraphStatus::OperatorDataPushed.to_string(), + GraphStatus::OperatorKickOff.to_string(), + GraphStatus::Challenge.to_string(), + ] + .contains(&pre_graphs[0].status) + { + return error_response( + "PEGOUT_ERROR".to_string(), + format!( + "graph {} not ready: previous graph {} still in status {}", + graph.graph_id, pre_graphs[0].graph_id, pre_graphs[0].status + ), + ); + } + } + + let amount = graph.amount; + if amount <= 0 { + return error_response( + "PEGOUT_ERROR".to_string(), + format!("graph {} has invalid amount {}", graph.graph_id, amount), + ); + } + + // Check L2 pegin status + let pegin_data = app_state + .goat_client + .gateway_get_pegin_data(&graph.instance_id) + .await + .api_error("PEGOUT_ERROR")?; + if pegin_data.status != PeginStatus::Withdrawable { + return error_response( + "PEGOUT_ERROR".to_string(), + format!( + "graph {} instance {} pegin status is {:?}, not Withdrawable", + graph.graph_id, graph.instance_id, pegin_data.status + ), + ); + } + + // Check L2 withdraw status + let withdraw_data = app_state + .goat_client + .gateway_get_withdraw_data(&graph.graph_id) + .await + .api_error("PEGOUT_ERROR")?; + if withdraw_data.status != WithdrawStatus::None { + return error_response( + "PEGOUT_ERROR".to_string(), + format!( + "graph {} withdraw status is {:?}, not None", + graph.graph_id, withdraw_data.status + ), + ); + } + + if payload.dry_run { + return ok_response(PegoutResponse { + graph_id: graph.graph_id.to_string(), + instance_id: graph.instance_id.to_string(), + kickoff_index: graph.kickoff_index, + amount, + tx_hash: None, + dry_run: true, + }); + } + + // Ensure pegBTC allowance + let amount_u256 = U256::from(amount as u64); + let balance = app_state + .goat_client + .peg_btc_balance(&operator_goat_addr.0) + .await + .api_error("PEGOUT_ERROR")?; + if balance < amount_u256 { + return error_response( + "PEGOUT_ERROR".to_string(), + format!("insufficient pegBTC balance: need {amount}, available {balance}"), + ); + } + + let allowance = app_state + .goat_client + .peg_btc_allowance(&operator_goat_addr.0, &gateway_addr.0) + .await + .api_error("PEGOUT_ERROR")?; + if allowance < amount_u256 { + app_state + .goat_client + .peg_btc_approve(&gateway_addr.0, amount_u256) + .await + .api_error("PEGOUT_ERROR")?; + } + + // Call Gateway.initWithdraw + let tx_hash = app_state + .goat_client + .gateway_init_withdraw(&graph.instance_id, &graph.graph_id) + .await + .api_error("PEGOUT_ERROR")?; + + ok_response(PegoutResponse { + graph_id: graph.graph_id.to_string(), + instance_id: graph.instance_id.to_string(), + kickoff_index: graph.kickoff_index, + amount, + tx_hash: Some(tx_hash), + dry_run: false, + }) +} + // fn is_segwit_address(address: &str, network: &str) -> anyhow::Result { // let addr: Address = Address::from_str(address)?; // let addr = addr.require_network(Network::from_str(network)?)?; diff --git a/node/src/rpc_service/mod.rs b/node/src/rpc_service/mod.rs index 01a42333..770a8e06 100644 --- a/node/src/rpc_service/mod.rs +++ b/node/src/rpc_service/mod.rs @@ -15,14 +15,17 @@ use crate::rpc_service::handler::{ get_graph_neighbor_ids, get_graph_tx, get_graph_txn, get_graphs, get_instance, get_instance_escrow_data, get_instances, get_instances_overview, get_node, get_nodes, get_nodes_overview, get_operator_proof_desc, get_ready_to_kickoff_graph, - get_unsigned_pegin_txn, instance_settings, + get_unsigned_pegin_txn, instance_settings, pegout, send_challenge, }; use axum::body::Body; use axum::extract::Request; use axum::middleware::Next; use axum::response::Response; use axum::routing::put; -use axum::{Router, middleware, routing::get}; +use axum::{ + Router, middleware, + routing::{get, post}, +}; use bitvm2_lib::actors::Actor; use client::btc_chain::BTCClient; use client::goat_chain::GOATClient; @@ -139,6 +142,8 @@ pub async fn serve( .route(routes::v1::GRAPHS_TXN_BY_ID, get(get_graph_txn)) .route(routes::v1::GRAPHS_TX_BY_ID, get(get_graph_tx)) .route(routes::v1::GRAPHS_NEIGHBOR_IDS, get(get_graph_neighbor_ids)) + .route(routes::v1::GRAPHS_SEND_CHALLENGE, post(send_challenge)) + .route(routes::v1::PEGOUT, post(pegout)) .route(routes::v1::PROOFS_CHAIN_PROOFS_DESC, get(get_chain_proof_desc)) .route(routes::v1::PROOFS_OPERATOR_PROOF_DESC, get(get_operator_proof_desc)) .route(routes::METRICS, get(metrics_handler)) diff --git a/node/src/rpc_service/routes.rs b/node/src/rpc_service/routes.rs index 18067f35..d5fb547b 100644 --- a/node/src/rpc_service/routes.rs +++ b/node/src/rpc_service/routes.rs @@ -20,6 +20,8 @@ pub(crate) mod v1 { pub const GRAPHS_TXN_BY_ID: &str = "/v1/graphs/{:id}/txn"; pub const GRAPHS_NEIGHBOR_IDS: &str = "/v1/graphs/{:id}/neighbor-ids"; pub const GRAPHS_TX_BY_ID: &str = "/v1/graphs/{:id}/tx"; + pub const GRAPHS_SEND_CHALLENGE: &str = "/v1/graphs/{:id}/send-challenge"; + pub const PEGOUT: &str = "/v1/pegout"; // pub const PROOFS_BASE: &str = "/v1/proofs"; pub const PROOFS_CHAIN_PROOFS_DESC: &str = "/v1/proofs/chain_proofs_desc"; pub const NODES_WATCHTOWER_BASE: &str = "/v1/proofs/watchtower_proofs"; From 19f8787b331d0b54f4738454788617be096002f5 Mon Sep 17 00:00:00 2001 From: eigmax Date: Thu, 12 Feb 2026 01:16:01 +0000 Subject: [PATCH 3/7] feat: add skills for node --- node/src/bin/send_challenge.rs | 12 +- node/src/bin/send_pegout.rs | 12 +- node/src/rpc_service/auth.rs | 179 ++++++++++++++++++ .../src/rpc_service/handler/bitvm2_handler.rs | 7 +- node/src/rpc_service/mod.rs | 1 + 5 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 node/src/rpc_service/auth.rs diff --git a/node/src/bin/send_challenge.rs b/node/src/bin/send_challenge.rs index bf462551..6610af8a 100644 --- a/node/src/bin/send_challenge.rs +++ b/node/src/bin/send_challenge.rs @@ -5,8 +5,7 @@ //! transaction on Bitcoin. No direct DB access is needed. //! //! Env: -//! - (Environment variables like BITVM_SECRET, GOAT_PRIVATE_KEY, etc. are -//! required on the **node** side, not on this client.) +//! - BITVM_SECRET: the node's secret key, used to sign the auth headers //! //! Args: //! - --api-url: node API base URL (default: http://localhost:8080) @@ -18,6 +17,10 @@ //! --graph-id use anyhow::{Context, Result}; +use bitvm2_noded::env::get_bitvm_key; +use bitvm2_noded::rpc_service::auth::{ + AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, sign_request_auth, +}; use clap::Parser; use serde::Deserialize; @@ -52,9 +55,14 @@ async fn main() -> Result<()> { args.graph_id ); + let keypair = get_bitvm_key().context("failed to load BITVM_SECRET")?; + let (timestamp, signature) = sign_request_auth(&keypair); + let client = reqwest::Client::new(); let resp = client .post(&url) + .header(AUTH_TIMESTAMP_HEADER, ×tamp) + .header(AUTH_SIGNATURE_HEADER, &signature) .send() .await .with_context(|| format!("failed to reach node API at {url}"))?; diff --git a/node/src/bin/send_pegout.rs b/node/src/bin/send_pegout.rs index 9de5a3b3..802ecdb8 100644 --- a/node/src/bin/send_pegout.rs +++ b/node/src/bin/send_pegout.rs @@ -5,8 +5,7 @@ //! - batch: repeat until target amount reached or no eligible graphs //! //! The operator node must be running and reachable at the given --api-url. -//! All env-based secrets (BITVM_SECRET, GOAT_PRIVATE_KEY, etc.) are required -//! on the **node** side, not on this client. +//! BITVM_SECRET must be set on this client to sign the auth headers. //! //! Args: //! - --api-url: node API base URL (default: http://localhost:8080) @@ -16,6 +15,10 @@ //! --api-url http://localhost:8080 once --graph-id use anyhow::{Context, Result, bail}; +use bitvm2_noded::env::get_bitvm_key; +use bitvm2_noded::rpc_service::auth::{ + AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, sign_request_auth, +}; use clap::{Parser, Subcommand}; use serde::{Deserialize, Serialize}; use tokio::time::{Duration, sleep}; @@ -115,8 +118,13 @@ async fn call_pegout( let url = format!("{}/v1/pegout", base_url.trim_end_matches('/')); let body = PegoutApiRequest { graph_id: graph_id.map(|id| id.to_string()), dry_run }; + let keypair = get_bitvm_key().context("failed to load BITVM_SECRET")?; + let (timestamp, signature) = sign_request_auth(&keypair); + let resp = client .post(&url) + .header(AUTH_TIMESTAMP_HEADER, ×tamp) + .header(AUTH_SIGNATURE_HEADER, &signature) .json(&body) .send() .await diff --git a/node/src/rpc_service/auth.rs b/node/src/rpc_service/auth.rs new file mode 100644 index 00000000..0a5dc339 --- /dev/null +++ b/node/src/rpc_service/auth.rs @@ -0,0 +1,179 @@ +use crate::env::get_bitvm_key; +use crate::rpc_service::response::ErrorResponse; +use axum::Json; +use http::{HeaderMap, StatusCode}; +use secp256k1::schnorr::Signature; +use secp256k1::{Keypair, Message, SECP256K1}; +use sha2::{Digest, Sha256}; + +pub const AUTH_TIMESTAMP_HEADER: &str = "x-auth-timestamp"; +pub const AUTH_SIGNATURE_HEADER: &str = "x-auth-signature"; +const AUTH_WINDOW_SECS: i64 = 300; +const AUTH_DOMAIN: &[u8] = b"bitvm2-auth"; + +/// Verify that the request carries a valid Schnorr signature produced by the +/// same `BITVM_SECRET` this node is configured with. +/// +/// Expected headers: +/// - `X-Auth-Timestamp`: current unix epoch seconds (string) +/// - `X-Auth-Signature`: hex-encoded 64-byte Schnorr signature of +/// `SHA256(b"bitvm2-auth" || timestamp_str)` +pub fn verify_request_auth( + headers: &HeaderMap, +) -> Result<(), (StatusCode, Json)> { + let timestamp_str = headers + .get(AUTH_TIMESTAMP_HEADER) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| auth_error("missing X-Auth-Timestamp header"))?; + + let signature_hex = headers + .get(AUTH_SIGNATURE_HEADER) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| auth_error("missing X-Auth-Signature header"))?; + + // Check timestamp freshness + let timestamp: i64 = timestamp_str.parse().map_err(|_| auth_error("invalid timestamp"))?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + if (now - timestamp).abs() > AUTH_WINDOW_SECS { + return Err(auth_error("timestamp expired")); + } + + // Reconstruct message hash + let mut hasher = Sha256::new(); + hasher.update(AUTH_DOMAIN); + hasher.update(timestamp_str.as_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + + // Decode signature + let sig_bytes = + hex::decode(signature_hex).map_err(|_| auth_error("invalid signature hex encoding"))?; + let signature = + Signature::from_slice(&sig_bytes).map_err(|_| auth_error("invalid signature format"))?; + + // Get node's x-only public key from BITVM_SECRET + let keypair = + get_bitvm_key().map_err(|_| auth_error("server key configuration error (BITVM_SECRET)"))?; + let (x_only_pubkey, _) = keypair.x_only_public_key(); + + // Verify + SECP256K1 + .verify_schnorr(&signature, &msg, &x_only_pubkey) + .map_err(|_| auth_error("signature verification failed"))?; + + Ok(()) +} + +/// Build the `(x-auth-timestamp, x-auth-signature)` header values for a request. +/// +/// Signs `SHA256(b"bitvm2-auth" || timestamp_str)` with the provided keypair using +/// Schnorr and returns `(timestamp_str, hex_signature)`. +pub fn sign_request_auth(keypair: &Keypair) -> (String, String) { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string(); + + let mut hasher = Sha256::new(); + hasher.update(AUTH_DOMAIN); + hasher.update(timestamp.as_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + + let signature = SECP256K1.sign_schnorr(&msg, keypair); + (timestamp, hex::encode(signature.as_ref())) +} + +fn auth_error(msg: &str) -> (StatusCode, Json) { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { error: "AUTH_ERROR".to_string(), message: msg.to_string() }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::HeaderMap; + + fn make_headers(timestamp: &str, signature: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(AUTH_TIMESTAMP_HEADER, timestamp.parse().unwrap()); + headers.insert(AUTH_SIGNATURE_HEADER, signature.parse().unwrap()); + headers + } + + fn test_keypair() -> Keypair { + // Fixed 32-byte secret for deterministic tests + let secret = [0x42u8; 32]; + Keypair::from_seckey_slice(SECP256K1, &secret).unwrap() + } + + #[test] + fn sign_then_verify_ok() { + let keypair = test_keypair(); + unsafe { std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) }; + + let (ts, sig) = sign_request_auth(&keypair); + let headers = make_headers(&ts, &sig); + assert!(verify_request_auth(&headers).is_ok()); + } + + #[test] + fn verify_missing_timestamp_fails() { + let mut headers = HeaderMap::new(); + headers.insert(AUTH_SIGNATURE_HEADER, "aabbcc".parse().unwrap()); + let err = verify_request_auth(&headers).unwrap_err(); + assert_eq!(err.0, StatusCode::UNAUTHORIZED); + } + + #[test] + fn verify_missing_signature_fails() { + let mut headers = HeaderMap::new(); + headers.insert(AUTH_TIMESTAMP_HEADER, "1234567890".parse().unwrap()); + let err = verify_request_auth(&headers).unwrap_err(); + assert_eq!(err.0, StatusCode::UNAUTHORIZED); + } + + #[test] + fn verify_expired_timestamp_fails() { + let keypair = test_keypair(); + unsafe { std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) }; + + // Timestamp 10 minutes in the past + let old_ts = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + - 600) + .to_string(); + + let mut hasher = Sha256::new(); + hasher.update(AUTH_DOMAIN); + hasher.update(old_ts.as_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let sig = SECP256K1.sign_schnorr(&msg, &keypair); + + let headers = make_headers(&old_ts, &hex::encode(sig.as_ref())); + let err = verify_request_auth(&headers).unwrap_err(); + assert_eq!(err.0, StatusCode::UNAUTHORIZED); + } + + #[test] + fn verify_wrong_signature_fails() { + let keypair = test_keypair(); + unsafe { std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) }; + + let (ts, _) = sign_request_auth(&keypair); + // Corrupt the signature + let bad_sig = "ff".repeat(64); + let headers = make_headers(&ts, &bad_sig); + let err = verify_request_auth(&headers).unwrap_err(); + assert_eq!(err.0, StatusCode::UNAUTHORIZED); + } +} diff --git a/node/src/rpc_service/handler/bitvm2_handler.rs b/node/src/rpc_service/handler/bitvm2_handler.rs index 2fd5870c..208eeb48 100644 --- a/node/src/rpc_service/handler/bitvm2_handler.rs +++ b/node/src/rpc_service/handler/bitvm2_handler.rs @@ -3,6 +3,7 @@ use crate::env::{ get_goat_address_from_env, get_goat_gateway_contract_from_env, get_network, get_node_goat_address, get_node_pubkey, }; +use crate::rpc_service::auth::verify_request_auth; use crate::rpc_service::bitvm2::*; use crate::rpc_service::node::ALIVE_TIME_JUDGE_THRESHOLD; use crate::rpc_service::response::{ @@ -24,7 +25,7 @@ use bitcoin::consensus::encode::serialize_hex; use bitvm2_lib::types::{Bitvm2Graph, SimplifiedBitvm2Graph}; use client::goat_chain::{DisproveTxType, PeginStatus, WithdrawStatus}; use goat::transactions::pre_signed::PreSignedTransaction; -use http::StatusCode; +use http::{HeaderMap, StatusCode}; use std::default::Default; use std::str::FromStr; use std::sync::Arc; @@ -1709,9 +1710,11 @@ pub async fn get_unsigned_pegin_txn( /// - `500 Internal Server Error`: Graph not found or broadcast failed #[axum::debug_handler] pub async fn send_challenge( + headers: HeaderMap, Path(graph_id): Path, State(app_state): State>, ) -> ApiResult { + verify_request_auth(&headers)?; let graph_id_uuid = InputValidator::validate_uuid(&graph_id, "graph_id")?; let mut storage_process = @@ -1762,9 +1765,11 @@ pub async fn send_challenge( /// - `500 Internal Server Error`: No eligible graph, L2 status invalid, or initWithdraw failed #[axum::debug_handler] pub async fn pegout( + headers: HeaderMap, State(app_state): State>, Json(payload): Json, ) -> ApiResult { + verify_request_auth(&headers)?; let operator_pubkey = get_node_pubkey().api_error("PEGOUT_ERROR")?.to_string(); let operator_goat_addr = get_node_goat_address() .ok_or_else(|| { diff --git a/node/src/rpc_service/mod.rs b/node/src/rpc_service/mod.rs index 770a8e06..8e261b0d 100644 --- a/node/src/rpc_service/mod.rs +++ b/node/src/rpc_service/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; mod bitvm2; mod cors_config; pub mod handler; From b2f2710beb7cfcf9221fb55fc33cd5ac5435f7c7 Mon Sep 17 00:00:00 2001 From: eigmax Date: Thu, 12 Feb 2026 01:19:23 +0000 Subject: [PATCH 4/7] feat: add skills for node --- .claude/commands/challenge.md | 14 ++++++++++---- .claude/commands/pegout.md | 8 +++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.claude/commands/challenge.md b/.claude/commands/challenge.md index b0360404..fe726e9e 100644 --- a/.claude/commands/challenge.md +++ b/.claude/commands/challenge.md @@ -14,21 +14,27 @@ Use the `/run-challenger-node` skill to start one, or see `deployment/README.md` - **graph_id**: Required. Graph UUID to challenge - **api_url**: Node API base URL. Default: `http://localhost:8080` -2. Confirm that the Challenger node is running and reachable at the given `api_url`. If the user hasn't started a node yet, walk them through the `/run-challenger-node` skill. +2. **Check that `BITVM_SECRET` is set** in the current shell environment. The binary uses this key to sign the request — it must match the secret configured on the target node. + ```bash + echo "BITVM_SECRET is ${BITVM_SECRET:+set}" + ``` + If it is not set, ask the user to export it before continuing. + +3. Confirm that the Challenger node is running and reachable at the given `api_url`. If the user hasn't started a node yet, walk them through the `/run-challenger-node` skill. -3. **Verify the graph is synced** on the local challenger node. The challenger syncs graph data via P2P from other nodes, so the target graph must exist locally before a challenge can be sent. Check by calling: +4. **Verify the graph is synced** on the local challenger node. The challenger syncs graph data via P2P from other nodes, so the target graph must exist locally before a challenge can be sent. Check by calling: ```bash curl -s /v1/graphs/ | jq . ``` - If the response contains a non-null `"graph"` field, the graph is synced and ready. - If `"graph"` is null or the request fails, the node has not yet synced this graph. Ask the user to wait for P2P sync to complete and retry. The node must be connected to the network (correct `BOOTNODES`, `PROTO_NAME`) and the graph must exist on peer nodes. -4. Check if the `challenge` binary exists at `./bin/challenge`. If not, run: +5. Check if the `challenge` binary exists at `./bin/challenge`. If not, run: ```bash .claude/commands/install-bitvm2.sh install ``` -5. Run the command using the pre-built binary: +6. Run the command using the pre-built binary: ```bash ./bin/challenge --graph-id [--api-url ] diff --git a/.claude/commands/pegout.md b/.claude/commands/pegout.md index 85897fad..a2dcabf9 100644 --- a/.claude/commands/pegout.md +++ b/.claude/commands/pegout.md @@ -25,7 +25,13 @@ Use the `/run-operator-node` skill to start one, or see `deployment/README.md` ( - **poll_interval_secs**: Poll interval between pegouts. Default: 300 - **max_wait_secs**: Max wait for a graph to be ready. Default: 36000 -2. Confirm that the Operator node is running and reachable at the given `api_url`. If the user hasn't started a node yet, walk them through the `/run-operator-node` skill. +2. **Check that `BITVM_SECRET` is set** in the current shell environment. The binary uses this key to sign the request — it must match the secret configured on the target node. + ```bash + echo "BITVM_SECRET is ${BITVM_SECRET:+set}" + ``` + If it is not set, ask the user to export it before continuing. + +3. Confirm that the Operator node is running and reachable at the given `api_url`. If the user hasn't started a node yet, walk them through the `/run-operator-node` skill. 3. **Verify the node has synced eligible graphs.** The operator node syncs graph data via P2P. Check by calling: ```bash From 0da3bf01463217c58e408ab0b82c83ec1784b4e0 Mon Sep 17 00:00:00 2001 From: eigmax Date: Thu, 12 Feb 2026 01:28:42 +0000 Subject: [PATCH 5/7] chore: change graph url --- .claude/commands/pegout.md | 2 ++ node/src/bin/send_pegout.rs | 2 +- node/src/rpc_service/routes.rs | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/commands/pegout.md b/.claude/commands/pegout.md index a2dcabf9..65986595 100644 --- a/.claude/commands/pegout.md +++ b/.claude/commands/pegout.md @@ -66,3 +66,5 @@ Batch pegout: ```bash ./bin/pegout --api-url http://127.0.0.1:8902 batch --max-total-amount-sats 10000000 --max-count 5 ``` + +> The binary calls `POST /v1/graphs/pegout` on the node API. diff --git a/node/src/bin/send_pegout.rs b/node/src/bin/send_pegout.rs index 802ecdb8..d431f915 100644 --- a/node/src/bin/send_pegout.rs +++ b/node/src/bin/send_pegout.rs @@ -115,7 +115,7 @@ async fn call_pegout( graph_id: Option, dry_run: bool, ) -> Result { - let url = format!("{}/v1/pegout", base_url.trim_end_matches('/')); + let url = format!("{}/v1/graphs/pegout", base_url.trim_end_matches('/')); let body = PegoutApiRequest { graph_id: graph_id.map(|id| id.to_string()), dry_run }; let keypair = get_bitvm_key().context("failed to load BITVM_SECRET")?; diff --git a/node/src/rpc_service/routes.rs b/node/src/rpc_service/routes.rs index d5fb547b..a6463f5f 100644 --- a/node/src/rpc_service/routes.rs +++ b/node/src/rpc_service/routes.rs @@ -21,7 +21,7 @@ pub(crate) mod v1 { pub const GRAPHS_NEIGHBOR_IDS: &str = "/v1/graphs/{:id}/neighbor-ids"; pub const GRAPHS_TX_BY_ID: &str = "/v1/graphs/{:id}/tx"; pub const GRAPHS_SEND_CHALLENGE: &str = "/v1/graphs/{:id}/send-challenge"; - pub const PEGOUT: &str = "/v1/pegout"; + pub const PEGOUT: &str = "/v1/graphs/pegout"; // pub const PROOFS_BASE: &str = "/v1/proofs"; pub const PROOFS_CHAIN_PROOFS_DESC: &str = "/v1/proofs/chain_proofs_desc"; pub const NODES_WATCHTOWER_BASE: &str = "/v1/proofs/watchtower_proofs"; From 0f6b2396bc61c41235500c02926ac85dd4ff295a Mon Sep 17 00:00:00 2001 From: eigmax Date: Thu, 12 Feb 2026 01:44:37 +0000 Subject: [PATCH 6/7] chore: change rpc url --- .claude/commands/challenge.md | 6 +++--- .claude/commands/pegout.md | 6 +++--- node/src/bin/send_challenge.rs | 6 +++--- node/src/bin/send_pegout.rs | 6 +++--- node/src/rpc_service/auth.rs | 32 ++++++++++++++++---------------- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.claude/commands/challenge.md b/.claude/commands/challenge.md index fe726e9e..043d5d2a 100644 --- a/.claude/commands/challenge.md +++ b/.claude/commands/challenge.md @@ -12,7 +12,7 @@ Use the `/run-challenger-node` skill to start one, or see `deployment/README.md` 1. Ask the user for the following parameters (skip any already provided as arguments: $ARGUMENTS): - **graph_id**: Required. Graph UUID to challenge - - **api_url**: Node API base URL. Default: `http://localhost:8080` + - **rpc_url**: Node API base URL. Default: `http://localhost:8080` 2. **Check that `BITVM_SECRET` is set** in the current shell environment. The binary uses this key to sign the request — it must match the secret configured on the target node. ```bash @@ -20,11 +20,11 @@ Use the `/run-challenger-node` skill to start one, or see `deployment/README.md` ``` If it is not set, ask the user to export it before continuing. -3. Confirm that the Challenger node is running and reachable at the given `api_url`. If the user hasn't started a node yet, walk them through the `/run-challenger-node` skill. +3. Confirm that the Challenger node is running and reachable at the given `rpc_url`. If the user hasn't started a node yet, walk them through the `/run-challenger-node` skill. 4. **Verify the graph is synced** on the local challenger node. The challenger syncs graph data via P2P from other nodes, so the target graph must exist locally before a challenge can be sent. Check by calling: ```bash - curl -s /v1/graphs/ | jq . + curl -s /v1/graphs/ | jq . ``` - If the response contains a non-null `"graph"` field, the graph is synced and ready. - If `"graph"` is null or the request fails, the node has not yet synced this graph. Ask the user to wait for P2P sync to complete and retry. The node must be connected to the network (correct `BOOTNODES`, `PROTO_NAME`) and the graph must exist on peer nodes. diff --git a/.claude/commands/pegout.md b/.claude/commands/pegout.md index 65986595..83dbe98c 100644 --- a/.claude/commands/pegout.md +++ b/.claude/commands/pegout.md @@ -14,7 +14,7 @@ Use the `/run-operator-node` skill to start one, or see `deployment/README.md` ( - **subcommand**: Which mode to use? - `once` - Single pegout for one graph - `batch` - Repeat until balance insufficient or target reached - - **api_url**: Node API base URL. Default: `http://localhost:8080` + - **rpc_url**: Node API base URL. Default: `http://localhost:8080` - For `once`: - **graph_id**: Optional UUID of the graph to pegout (auto-selects if omitted) - **dry_run**: Whether to skip the actual Gateway.initWithdraw call. Default: false @@ -31,11 +31,11 @@ Use the `/run-operator-node` skill to start one, or see `deployment/README.md` ( ``` If it is not set, ask the user to export it before continuing. -3. Confirm that the Operator node is running and reachable at the given `api_url`. If the user hasn't started a node yet, walk them through the `/run-operator-node` skill. +3. Confirm that the Operator node is running and reachable at the given `rpc_url`. If the user hasn't started a node yet, walk them through the `/run-operator-node` skill. 3. **Verify the node has synced eligible graphs.** The operator node syncs graph data via P2P. Check by calling: ```bash - curl -s /v1/graphs/ready-to-kickoff?btc_pub_key= | jq . + curl -s /v1/graphs/ready-to-kickoff?btc_pub_key= | jq . ``` If `"graph"` is null, the node may not have synced yet or there are no eligible graphs. diff --git a/node/src/bin/send_challenge.rs b/node/src/bin/send_challenge.rs index 6610af8a..dacbde79 100644 --- a/node/src/bin/send_challenge.rs +++ b/node/src/bin/send_challenge.rs @@ -29,7 +29,7 @@ use serde::Deserialize; name = "send-challenge", version, about = "Broadcast a Challenge transaction for a graph (via node API)", - long_about = "Broadcast a Challenge transaction for a graph via the node's REST API.\n\nThe node must be running and reachable at the given --api-url." + long_about = "Broadcast a Challenge transaction for a graph via the node's REST API.\n\nThe node must be running and reachable at the given --rpc-url." )] struct Args { /// Graph UUID to challenge @@ -38,7 +38,7 @@ struct Args { /// Node API base URL #[arg(long, default_value = "http://localhost:8080")] - api_url: String, + rpc_url: String, } #[derive(Debug, Deserialize)] @@ -51,7 +51,7 @@ async fn main() -> Result<()> { let args = Args::parse(); let url = format!( "{}/v1/graphs/{}/send-challenge", - args.api_url.trim_end_matches('/'), + args.rpc_url.trim_end_matches('/'), args.graph_id ); diff --git a/node/src/bin/send_pegout.rs b/node/src/bin/send_pegout.rs index d431f915..8126d0a3 100644 --- a/node/src/bin/send_pegout.rs +++ b/node/src/bin/send_pegout.rs @@ -29,12 +29,12 @@ use uuid::Uuid; name = "send-pegout", version, about = "Operator pegout helper (via node API)", - long_about = "Initiate pegout by calling the node's REST API.\n\nThe operator node must be running and reachable at the given --api-url." + long_about = "Initiate pegout by calling the node's REST API.\n\nThe operator node must be running and reachable at the given --rpc-url." )] struct Args { /// Node API base URL #[arg(long, default_value = "http://localhost:8080")] - api_url: String, + rpc_url: String, #[command(subcommand)] command: Commands, @@ -204,7 +204,7 @@ async fn wait_for_graph_ready( async fn main() -> Result<()> { let args = Args::parse(); let client = reqwest::Client::new(); - let base_url = &args.api_url; + let base_url = &args.rpc_url; match args.command { Commands::Once { graph_id, dry_run } => { diff --git a/node/src/rpc_service/auth.rs b/node/src/rpc_service/auth.rs index 0a5dc339..25d47067 100644 --- a/node/src/rpc_service/auth.rs +++ b/node/src/rpc_service/auth.rs @@ -18,9 +18,7 @@ const AUTH_DOMAIN: &[u8] = b"bitvm2-auth"; /// - `X-Auth-Timestamp`: current unix epoch seconds (string) /// - `X-Auth-Signature`: hex-encoded 64-byte Schnorr signature of /// `SHA256(b"bitvm2-auth" || timestamp_str)` -pub fn verify_request_auth( - headers: &HeaderMap, -) -> Result<(), (StatusCode, Json)> { +pub fn verify_request_auth(headers: &HeaderMap) -> Result<(), (StatusCode, Json)> { let timestamp_str = headers .get(AUTH_TIMESTAMP_HEADER) .and_then(|v| v.to_str().ok()) @@ -33,10 +31,8 @@ pub fn verify_request_auth( // Check timestamp freshness let timestamp: i64 = timestamp_str.parse().map_err(|_| auth_error("invalid timestamp"))?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + as i64; if (now - timestamp).abs() > AUTH_WINDOW_SECS { return Err(auth_error("timestamp expired")); } @@ -116,7 +112,9 @@ mod tests { #[test] fn sign_then_verify_ok() { let keypair = test_keypair(); - unsafe { std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) }; + unsafe { + std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) + }; let (ts, sig) = sign_request_auth(&keypair); let headers = make_headers(&ts, &sig); @@ -142,15 +140,15 @@ mod tests { #[test] fn verify_expired_timestamp_fails() { let keypair = test_keypair(); - unsafe { std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) }; + unsafe { + std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) + }; // Timestamp 10 minutes in the past - let old_ts = (std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - - 600) - .to_string(); + let old_ts = + (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + - 600) + .to_string(); let mut hasher = Sha256::new(); hasher.update(AUTH_DOMAIN); @@ -167,7 +165,9 @@ mod tests { #[test] fn verify_wrong_signature_fails() { let keypair = test_keypair(); - unsafe { std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) }; + unsafe { + std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) + }; let (ts, _) = sign_request_auth(&keypair); // Corrupt the signature From 28f2a2dba637dc1ef14a14b4086ebd85803c3c64 Mon Sep 17 00:00:00 2001 From: eigmax Date: Thu, 12 Feb 2026 01:54:00 +0000 Subject: [PATCH 7/7] chore: change rpc url --- .claude/commands/challenge.md | 4 ++-- .claude/commands/pegout.md | 8 ++++---- node/src/bin/send_challenge.rs | 4 ++-- node/src/bin/send_pegout.rs | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.claude/commands/challenge.md b/.claude/commands/challenge.md index 043d5d2a..ad4a4bf9 100644 --- a/.claude/commands/challenge.md +++ b/.claude/commands/challenge.md @@ -37,7 +37,7 @@ Use the `/run-challenger-node` skill to start one, or see `deployment/README.md` 6. Run the command using the pre-built binary: ```bash -./bin/challenge --graph-id [--api-url ] +./bin/challenge --graph-id [--rpc-url ] ``` ### Example commands @@ -47,5 +47,5 @@ Use the `/run-challenger-node` skill to start one, or see `deployment/README.md` ./bin/challenge --graph-id 6ba7b810-9dad-11d1-80b4-00c04fd430c8 # Custom API URL (e.g. challenger node on port 8906) -./bin/challenge --graph-id 6ba7b810-9dad-11d1-80b4-00c04fd430c8 --api-url http://127.0.0.1:8906 +./bin/challenge --graph-id 6ba7b810-9dad-11d1-80b4-00c04fd430c8 --rpc-url http://127.0.0.1:8906 ``` diff --git a/.claude/commands/pegout.md b/.claude/commands/pegout.md index 83dbe98c..f8af3609 100644 --- a/.claude/commands/pegout.md +++ b/.claude/commands/pegout.md @@ -47,24 +47,24 @@ Use the `/run-operator-node` skill to start one, or see `deployment/README.md` ( 5. Run the command using the pre-built binary: ```bash -./bin/pegout [--api-url ] [options] +./bin/pegout [--rpc-url ] [options] ``` ### Example commands Single pegout (auto-select graph): ```bash -./bin/pegout --api-url http://127.0.0.1:8902 once +./bin/pegout --rpc-url http://127.0.0.1:8902 once ``` Single pegout (specific graph, dry run): ```bash -./bin/pegout --api-url http://127.0.0.1:8902 once --graph-id --dry-run +./bin/pegout --rpc-url http://127.0.0.1:8902 once --graph-id --dry-run ``` Batch pegout: ```bash -./bin/pegout --api-url http://127.0.0.1:8902 batch --max-total-amount-sats 10000000 --max-count 5 +./bin/pegout --rpc-url http://127.0.0.1:8902 batch --max-total-amount-sats 10000000 --max-count 5 ``` > The binary calls `POST /v1/graphs/pegout` on the node API. diff --git a/node/src/bin/send_challenge.rs b/node/src/bin/send_challenge.rs index dacbde79..e70b72ca 100644 --- a/node/src/bin/send_challenge.rs +++ b/node/src/bin/send_challenge.rs @@ -8,12 +8,12 @@ //! - BITVM_SECRET: the node's secret key, used to sign the auth headers //! //! Args: -//! - --api-url: node API base URL (default: http://localhost:8080) +//! - --rpc-url: node API base URL (default: http://localhost:8080) //! - --graph-id: target graph UUID //! //! Example: //! - cargo run -p bitvm2-noded --bin challenge -- \ -//! --api-url http://localhost:8080 \ +//! --rpc-url http://localhost:8080 \ //! --graph-id use anyhow::{Context, Result}; diff --git a/node/src/bin/send_pegout.rs b/node/src/bin/send_pegout.rs index 8126d0a3..0d2ec1e9 100644 --- a/node/src/bin/send_pegout.rs +++ b/node/src/bin/send_pegout.rs @@ -4,15 +4,15 @@ //! - once: single pegout for one graph //! - batch: repeat until target amount reached or no eligible graphs //! -//! The operator node must be running and reachable at the given --api-url. +//! The operator node must be running and reachable at the given --rpc-url. //! BITVM_SECRET must be set on this client to sign the auth headers. //! //! Args: -//! - --api-url: node API base URL (default: http://localhost:8080) +//! - --rpc-url: node API base URL (default: http://localhost:8080) //! //! Example: //! - cargo run -p bitvm2-noded --bin pegout -- \ -//! --api-url http://localhost:8080 once --graph-id +//! --rpc-url http://localhost:8080 once --graph-id use anyhow::{Context, Result, bail}; use bitvm2_noded::env::get_bitvm_key;