diff --git a/.claude/commands/bridge-out.md b/.claude/commands/bridge-out.md deleted file mode 100644 index 743a35685..000000000 --- a/.claude/commands/bridge-out.md +++ /dev/null @@ -1,82 +0,0 @@ -Initiate Bridge Out via the `bridge-out` binary (payInvoice quote -> swap initialize -> init-tag). - -## Instructions - -1. Ask the user for the following parameters (skip any already provided as arguments: $ARGUMENTS): - - **rpc_url**: Node API base URL. Default: `http://localhost:8080` - - **from_addr**: Source GOAT address (used by `init-tag`) - - **to_addr**: Destination BTC address (used by `init-tag`) - - **pay_invoice_url**: Quote endpoint. Default: `https://152-32-185-32.nodes.atomiq.exchange:8443/tobtc/payInvoice?chain=GOAT` - - **amount**: Amount in pegBTC (human-readable). Example: `0.0015` - - **exact_in**: Whether quote is exact-in (`true`/`false`). Default: `true` - - **confirmation_target**: Bitcoin confirmation target. Default: `3` - - **confirmations**: Required confirmations. Default: `2` - - **token**: Token address - - **offerer**: Offerer GOAT address - - **additional_params_json**: Optional JSON object merged into payInvoice body - - **contract_address**: Optional (fallback to `GOAT_SWAP_CONTRACT_ADDRESS`) - - **max_wait_secs**: Max wait for tx receipt. Default: `60` - -2. Ensure required environment and runtime prerequisites are ready: - - `GOAT_PRIVATE_KEY` must be set (unless user passes `--goat-private-key`) - - `GOAT_CHAIN_URL` and chain config must be valid for on-chain calls - - `GOAT_SWAP_CONTRACT_ADDRESS` should be set if `--contract-address` is omitted - - Optional but recommended: use `node/.env` to manage the above variables consistently - -3. Check if the `bridge-out` binary exists at `./bin/bridge-out`. If not, run the install script to download it: - ```bash - .claude/commands/install-bitvm.sh install - ``` - To upgrade to the latest version: - ```bash - .claude/commands/install-bitvm.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 swap initialize (this step calls payInvoice first, then sends token approve if needed, then swap `initialize` tx): - ```bash - ./bin/bridge-out --rpc-url swap-initialize \ - --pay-invoice-url \ - --btc-address \ - --amount \ - --exact-in \ - --confirmation-target \ - --confirmations \ - --token \ - --offerer \ - --max-wait-secs - ``` - - If needed, append: - ```bash - --additional-params-json '' - --contract-address - --goat-private-key - ``` - -5. Parse output from step 4: - - `swap initialize submitted: ` - - `escrow_hash (from Initialize log): ` - Save `` for the next step. - -6. Submit bridge-out init-tag to node API: - ```bash - ./bin/bridge-out --rpc-url init-tag \ - --from-addr \ - --to-addr \ - --escrow-hash \ - --contract-address - ``` - - Alternative: derive escrow hash from tx logs directly: - ```bash - ./bin/bridge-out --rpc-url init-tag \ - --from-addr \ - --to-addr \ - --swap-init-tx-hash \ - --contract-address - ``` - -7. Optional verification (if instance id is known): - ```bash - ./bin/bridge-out --rpc-url escrow-data --instance-id - ``` diff --git a/.claude/commands/rpc-health-monitor.py b/.claude/commands/rpc-health-monitor.py index f2684c5ed..626eaa6ac 100755 --- a/.claude/commands/rpc-health-monitor.py +++ b/.claude/commands/rpc-health-monitor.py @@ -3,7 +3,7 @@ Collects and summarizes: 1) graph status counts -2) instance status counts (bridge-in/out) +2) instance status counts (bridge-in) and swap escrow status counts (bridge-out) 3) node online or offline status 4) overall service health verdict """ @@ -482,7 +482,7 @@ def summarize_once( "instance_wraps", timeout, page_size, - fixed_params={"is_bridge_in": "true"}, + fixed_params={}, ) checks.extend(in_checks) except Exception as err: # pylint: disable=broad-except @@ -493,11 +493,11 @@ def summarize_once( try: instance_out_items, instance_out_total, out_checks = fetch_pages( base_url, - "/v1/instances", - "instance_wraps", + "/v1/swaps", + "swaps", timeout, page_size, - fixed_params={"is_bridge_in": "false"}, + fixed_params={}, ) checks.extend(out_checks) except Exception as err: # pylint: disable=broad-except @@ -562,8 +562,8 @@ def summarize_once( instance_out_status_counts = count_by( instance_out_items, lambda x: ( - (x.get("instance") or {}).get("status") - if isinstance(x, dict) and isinstance(x.get("instance"), dict) + (x.get("swap") or {}).get("status") + if isinstance(x, dict) and isinstance(x.get("swap"), dict) else "MISSING_INSTANCE" ), ) diff --git a/.claude/commands/run-challenger-node.md b/.claude/commands/run-challenger-node.md index fbf803454..c3b3a93a1 100644 --- a/.claude/commands/run-challenger-node.md +++ b/.claude/commands/run-challenger-node.md @@ -7,6 +7,7 @@ The Challenger verifies operator operations and submits challenges if necessary. 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` + - **metrics_addr**: Prometheus listen address. Default: `127.0.0.1:9902` - **p2p_port**: P2P listen port. Default: `8449` (testnet4) or `8450` (regtest) - **db_path**: SQLite database path. Default: `sqlite:$PWD/bitvm-node.db` @@ -32,26 +33,28 @@ The Challenger verifies operator operations and submits challenges if necessary. 4. Start the challenger node: ```bash -./bin/bitvm-noded --rpc-addr --db-path --p2p-port --bootnodes "$BOOTNODES" +./bin/bitvm-noded --rpc-addr --metrics-addr --db-path --p2p-port --bootnodes "$BOOTNODES" ``` To run in the background: ```bash -nohup ./bin/bitvm-noded --rpc-addr --db-path --p2p-port --bootnodes "$BOOTNODES" >challenger_$(date +'%Y%m%d').log 2>&1 & +nohup ./bin/bitvm-noded --rpc-addr --metrics-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:/// +curl -s http:///metrics ``` -Should return `Hello, World!`. +The first request should return `Hello, World!`; the second should return +Prometheus metrics in OpenMetrics text format. ### Example (testnet4) ```bash cp deployment/testnet4/bitvm-noded/challenge_0/.env.challenge_0 .env # Edit .env to fill in BITVM_SECRET, GOAT_ADDRESS, PEER_KEY -./bin/bitvm-noded --rpc-addr 127.0.0.1:8906 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8449 --bootnodes /ip4/34.215.238.232/tcp/8445/p2p/12D3KooWCrPTAmhFdC5DBGgkxZvJi6iuSeiDWKRL87isrt4iMHXv +./bin/bitvm-noded --rpc-addr 127.0.0.1:8906 --metrics-addr 127.0.0.1:9902 --db-path sqlite:$PWD/bitvm-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 index b82f3c840..90b68bff2 100644 --- a/.claude/commands/run-operator-node.md +++ b/.claude/commands/run-operator-node.md @@ -7,6 +7,7 @@ The Operator manages bridge operations, kickoff processing, and pegout (Gateway. 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` + - **metrics_addr**: Prometheus listen address. Default: `127.0.0.1:9903` - **p2p_port**: P2P listen port. Default: `8445` (testnet4) or `8446` (regtest) - **db_path**: SQLite database path. Default: `sqlite:$PWD/bitvm-node.db` @@ -35,26 +36,28 @@ The Operator manages bridge operations, kickoff processing, and pegout (Gateway. 4. Start the operator node: ```bash -./bin/bitvm-noded --rpc-addr --db-path --p2p-port --bootnodes "$BOOTNODES" +./bin/bitvm-noded --rpc-addr --metrics-addr --db-path --p2p-port --bootnodes "$BOOTNODES" ``` To run in the background: ```bash -nohup ./bin/bitvm-noded --rpc-addr --db-path --p2p-port --bootnodes "$BOOTNODES" >operator_$(date +'%Y%m%d').log 2>&1 & +nohup ./bin/bitvm-noded --rpc-addr --metrics-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:/// +curl -s http:///metrics ``` -Should return `Hello, World!`. +The first request should return `Hello, World!`; the second should return +Prometheus metrics in OpenMetrics text format. ### Example (testnet4) ```bash cp deployment/testnet4/bitvm-noded/operator_0/.env.operator_0 .env # Edit .env to fill in BITVM_SECRET, GOAT_ADDRESS, PEER_KEY, GOAT_PRIVATE_KEY -./bin/bitvm-noded --rpc-addr 127.0.0.1:8902 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8445 --bootnodes /ip4/34.215.238.232/tcp/8445/p2p/12D3KooWCrPTAmhFdC5DBGgkxZvJi6iuSeiDWKRL87isrt4iMHXv +./bin/bitvm-noded --rpc-addr 127.0.0.1:8902 --metrics-addr 127.0.0.1:9903 --db-path sqlite:$PWD/bitvm-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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0213728c8..d45f79201 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,8 @@ jobs: pass | MessageStateRaceFixed.cfg | MessageStateRace.tla | Finding 7 fix - terminal-guarded resurrect pass | Take1ChallengeRaceFixed.cfg | Take1ChallengeRace.tla | Finding 9 fix - connector_a margin check added pass | VerifierKickoffFailOpenFixed.cfg | VerifierKickoffFailOpen.tla | Issue #429 fix design - defer/retry mirrors committee - pass | KickoffScanCoverageFixed.cfg | KickoffScanCoverage.tla | Issue #431 fix - scan_kickoff_chain with adequate depth + pass | KickoffScanCoverageFixed.cfg | KickoffScanCoverage.tla | Issue #431 fix (#451) - root-only chain walk with adequate depth + pass | KickoffScanCoverageAllEntries.cfg | KickoffScanCoverage.tla | Issue #431 residual fix - every pending graph is a scan entry (holds with ScanDepth=0) historical | GraphLifecycle.cfg | GraphLifecycle.tla | Finding 1: Graph.status race (fixed 991faaa) historical | GraphLifecycleFineGrained.cfg | GraphLifecycleFineGrained.tla | Finding 1b: naive guard still unsafe (fixed 991faaa) historical | InstancePresignedBug.cfg | InstancePresigned.tla | Finding 2: Instance.status regression past Presigned (fixed 991faaa) @@ -124,8 +125,8 @@ jobs: historical | MessageStateRace.cfg | MessageStateRace.tla | Finding 7: MessageState resurrection (fixed 991faaa) historical | Take1ChallengeRace.cfg | Take1ChallengeRace.tla | Finding 9: connector_a has no margin check (fixed 991faaa) historical | KickoffScanCoverage.cfg | KickoffScanCoverage.tla | Issue #431: detect_kickoff one-per-operator coverage gap (fixed by #451) - live | VerifierKickoffFailOpen.cfg | VerifierKickoffFailOpen.tla | Issue #429: verifier KickoffSent fail-open (SPV lag skips Challenge, never retried) - live | KickoffScanCoverageResidual.cfg | KickoffScanCoverage.tla | Issue #431 residual: MAX_PREKICKOFF_SUCCESSORS_PER_SCAN=32 depth cap leaves deeper decoy chains uncovered + historical | VerifierKickoffFailOpen.cfg | VerifierKickoffFailOpen.tla | Issue #429: verifier KickoffSent fail-open (SPV lag skipped Challenge, never retried) (fixed by #455) + historical | KickoffScanCoverageResidual.cfg | KickoffScanCoverage.tla | Issue #431 residual: root-only walk capped at MAX_PREKICKOFF_SUCCESSORS_PER_SCAN=32 (fixed: all pending graphs are scan entries) SPECS { echo diff --git a/.gitignore b/.gitignore index af69b8b73..a222e2112 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,9 @@ circuits/*/*/*.bin.in **/*.out **/*/output.data* proof-builder-rpc/*.ckpt -node/tla/states/ \ No newline at end of file +node/tla/states/ + +local_docs/ +scripts/testnet/ +scripts/devnet/ +*.DS_Store \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 66e18e4bd..d45f8b19c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2874,7 +2874,7 @@ dependencies = [ [[package]] name = "bitcoin-script" version = "0.4.0" -source = "git+https://github.com/BitVM/rust-bitcoin-script#01b4cb66cbf5b525079cabe006f9f99627da97cd" +source = "git+https://github.com/BitVM/rust-bitcoin-script#124b561ed75ac3ec4c6ad99207d8dcdd3bc67180" dependencies = [ "bitcoin", "script-macro", @@ -5569,7 +5569,7 @@ dependencies = [ [[package]] name = "garbled-snark-verifier" version = "0.1.0" -source = "git+https://github.com/GOATNetwork/bitvm-gc?branch=feat%2Fgoat-bitvm3#65b523681ac5dc8f1464602f0e38803b9055ccf7" +source = "git+https://github.com/GOATNetwork/bitvm-gc?branch=feat%2Fgoat-bitvm3#f8980142fe5b3f3a9d6adcb7014c2299484d5633" dependencies = [ "aes", "ark-bn254", @@ -11562,7 +11562,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "script-macro" version = "0.4.0" -source = "git+https://github.com/BitVM/rust-bitcoin-script#01b4cb66cbf5b525079cabe006f9f99627da97cd" +source = "git+https://github.com/BitVM/rust-bitcoin-script#124b561ed75ac3ec4c6ad99207d8dcdd3bc67180" dependencies = [ "bitcoin", "proc-macro-error", @@ -12195,7 +12195,7 @@ dependencies = [ [[package]] name = "soldering-host" version = "1.1.0" -source = "git+https://github.com/GOATNetwork/bitvm-gc?branch=feat%2Fgoat-bitvm3#65b523681ac5dc8f1464602f0e38803b9055ccf7" +source = "git+https://github.com/GOATNetwork/bitvm-gc?branch=feat%2Fgoat-bitvm3#f8980142fe5b3f3a9d6adcb7014c2299484d5633" dependencies = [ "ark-bn254", "ark-crypto-primitives", @@ -13896,7 +13896,7 @@ dependencies = [ [[package]] name = "verifiable-circuit-babe" version = "0.0.1" -source = "git+https://github.com/GOATNetwork/bitvm-gc?branch=feat%2Fgoat-bitvm3#65b523681ac5dc8f1464602f0e38803b9055ccf7" +source = "git+https://github.com/GOATNetwork/bitvm-gc?branch=feat%2Fgoat-bitvm3#f8980142fe5b3f3a9d6adcb7014c2299484d5633" dependencies = [ "aes", "ark-bn254", diff --git a/circuits/operator-proof/host/src/lib.rs b/circuits/operator-proof/host/src/lib.rs index eca6e2195..841867884 100644 --- a/circuits/operator-proof/host/src/lib.rs +++ b/circuits/operator-proof/host/src/lib.rs @@ -206,7 +206,7 @@ pub async fn fetch_target_block_and_watchtower_tx( esplora_url: &str, latest_sequencer_commit_txid: &str, operator_committed_blockhash: &str, - watchtower_challenge_init_txid: &String, + watchtower_challenge_init_txid: &str, watchtower_challenge_txids: &str, watchtower_public_keys: &str, bitcoin_network: Network, diff --git a/circuits/proof-builder/src/api_auth.rs b/circuits/proof-builder/src/api_auth.rs index 19cb70fd4..2f958621d 100644 --- a/circuits/proof-builder/src/api_auth.rs +++ b/circuits/proof-builder/src/api_auth.rs @@ -176,7 +176,7 @@ fn sort_json_value(value: &mut Value) { } /// Adds one length-prefixed field to the authentication digest. -fn hash_field(hasher: &mut Sha256, field: &[u8]) { +pub fn hash_field(hasher: &mut Sha256, field: &[u8]) { hasher.update((field.len() as u64).to_be_bytes()); hasher.update(field); } diff --git a/crates/bitcoin-light-client-circuit/src/lib.rs b/crates/bitcoin-light-client-circuit/src/lib.rs index 7f58c96f3..1882d06d5 100644 --- a/crates/bitcoin-light-client-circuit/src/lib.rs +++ b/crates/bitcoin-light-client-circuit/src/lib.rs @@ -33,6 +33,12 @@ pub const VK_HASH_SIZE: usize = 66; pub const TOTAL_WORK_SIZE: usize = 32; pub const CONSENSUS_BLOCK_HEIGHT_SIZE: usize = 4; +// 0xc3d8382a8efed79d564f8ec6da4dd00a4e77572d1e02f5f81dda441a36fdc9d8 +pub const ROOT_COMMIT_PROGRAM_ID: verifier::ProgramId = [ + 195, 216, 56, 42, 142, 254, 215, 157, 86, 79, 142, 198, 218, 77, 208, 10, 78, 119, 87, 45, 30, + 2, 245, 248, 29, 218, 68, 26, 54, 253, 201, 216, +]; + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WatchtowerPublicOutputs { pub total_work: [u8; TOTAL_WORK_SIZE], @@ -74,6 +80,11 @@ fn checked_history_root( } /// Verifies that the latest Bitcoin commitment authorizes every supplied circuit proof. +/// +/// `root_commit_program_id` is the pinned anchor; it is the only identity here that does not +/// come from the proofs being checked, so it is asserted before anything reads the +/// Commit-chain output. See [`ROOT_COMMIT_PROGRAM_ID`]. +#[allow(clippy::too_many_arguments)] fn verify_commitment_authorization( commit_chain_output: &CommitChainCircuitOutput, header_chain_output: &BlockHeaderCircuitOutput, @@ -81,8 +92,13 @@ fn verify_commitment_authorization( header_program_id: verifier::ProgramId, state_program_id: verifier::ProgramId, commit_program_id: verifier::ProgramId, + root_commit_program_id: verifier::ProgramId, sequencer_set_hash: [u8; 32], ) -> AuthorizedProgramIds { + assert_eq!( + commit_program_id, root_commit_program_id, + "commit proof program id does not match the pinned root anchor" + ); let authorized = commit_chain_output.chain_state.authorized_program_ids; authorized.validate().expect("invalid authorized ProgramIds"); check_program_id(commit_program_id, commit_chain_output.self_program_id, authorized.commit); @@ -215,6 +231,7 @@ pub fn watch_longest_chain( header_program_id, state_program_id, commit_program_id, + ROOT_COMMIT_PROGRAM_ID, sequencer_set_hash, ); } else { @@ -382,6 +399,7 @@ pub fn propose_longest_chain( header_program_id, state_program_id, commit_program_id, + ROOT_COMMIT_PROGRAM_ID, sequencer_set_hash, ) } else { diff --git a/crates/bitvm-gc/src/babe_adapter.rs b/crates/bitvm-gc/src/babe_adapter.rs index ab9c6ce02..64e05dbf3 100644 --- a/crates/bitvm-gc/src/babe_adapter.rs +++ b/crates/bitvm-gc/src/babe_adapter.rs @@ -25,6 +25,7 @@ pub use verifiable_circuit_babe::cac::{CACSetupPackage, FinalizedInstanceData}; use verifiable_circuit_babe::dre::N_PADDED; use verifiable_circuit_babe::gc::{SGC_PART1_CONSTANT_SIZE, SparseAdaptorTable}; pub use verifiable_circuit_babe::instance::commit::CACInstanceCommit; +pub use verifiable_circuit_babe::instance::secret::Seed; use verifiable_circuit_babe::prover::BABEProver; use verifiable_circuit_babe::soldering::{ SolderedLabelsData, SolderingData as RealSolderingData, SolderingProof as RealSolderingProof, @@ -39,7 +40,7 @@ pub const WOTS_SIG_COUNT: usize = Wots96::TOTAL_DIGIT_LEN as usize; pub const BABE_N_CC: usize = 181; pub const BABE_M_CC: usize = 7; -pub type OpenedInstanceSeeds = Vec<(usize, u64)>; +pub type OpenedInstanceSeeds = Vec<(usize, Seed)>; pub type FinalizedInstances = Vec; pub type SetupAndSolderingData = (OpenedInstanceSeeds, FinalizedInstances, SolderingData); @@ -65,7 +66,7 @@ pub struct SolderingData { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BabeVerifierPrivateState { - pub instance_seeds: Vec, + pub instance_seeds: Vec, pub statement_digest: [u8; 32], } @@ -210,7 +211,7 @@ pub fn open_real_setup_and_solder( pub fn verify_real_setup( soldering_builder: &BabeBundleBuilder, package: &CACSetupPackage, - opened: &[(usize, u64)], + opened: &[(usize, Seed)], finalized: &[FinalizedInstanceData], soldering: &SolderingData, vk: &Groth16VerifyingKey, @@ -235,7 +236,7 @@ pub fn verify_real_setup( /// Removes setup-derived public fields from the Verifier-to-Operator soldering proof payload. pub fn compact_soldering_proof_payload( - opened: &[(usize, u64)], + opened: &[(usize, Seed)], finalized: &[FinalizedInstanceData], soldering: &SolderingData, ) -> Result { @@ -291,7 +292,7 @@ pub fn open_and_solder( /// Validates placeholder opened, finalized, and soldering data consistency. pub fn verify_setup( package: &CACSetupPackage, - opened: &[(usize, u64)], + opened: &[(usize, Seed)], finalized: &[FinalizedInstanceData], soldering: &SolderingData, ) -> Result<()> { @@ -623,8 +624,8 @@ fn recover_a_valid_finalized_messages( }) } -fn deterministic_seed(index: usize) -> u64 { - u64::from_le_bytes(hash32(&(index as u64).to_le_bytes())[0..8].try_into().expect("8 bytes")) +fn deterministic_seed(index: usize) -> Seed { + hash32(&(index as u64).to_le_bytes()) } fn hash20(data: &[u8]) -> [u8; 20] { diff --git a/crates/bitvm-gc/src/committee/api.rs b/crates/bitvm-gc/src/committee/api.rs index dfbcc8b29..4cbfc3df3 100644 --- a/crates/bitvm-gc/src/committee/api.rs +++ b/crates/bitvm-gc/src/committee/api.rs @@ -2,6 +2,7 @@ use anyhow::{Result, bail, ensure}; use bitcoin::XOnlyPublicKey; use bitcoin::{ PublicKey, Script, TapLeafHash, TapSighash, TapSighashType, Transaction, TxOut, + hashes::Hash, key::Keypair, sighash::{Prevouts, SighashCache}, taproot::{ControlBlock, LeafVersion, Signature as TaprootSignature}, @@ -15,11 +16,16 @@ use musig2::secp::Point; use musig2::{AggNonce, KeyAggContext, PartialSignature, PubNonce, SecNonce, verify_partial}; use secp256k1::{Message, SECP256K1, schnorr::Signature as SchnorrSignature}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::keys::hkdf_derive_bytes; use crate::types::{BitvmGcGraph, BitvmGcInstanceParameters}; const COMMITTEE_NONCE_HKDF_SALT: &[u8] = b"bitvm-gc/committee-nonce/v1"; +const COMMITTEE_PRESIGN_SIGHASH_DOMAIN: &[u8] = b"GOAT_BITVM_GC_COMMITTEE_PRESIGN_SIGHASHES_V1"; +const COMMITTEE_NONCE_CONSENSUS_DOMAIN: &[u8] = b"GOAT_BITVM_GC_COMMITTEE_NONCE_CONSENSUS_V1"; +const PEGIN_CONFIRM_NONCE_CONSENSUS_DOMAIN: &[u8] = + b"GOAT_BITVM_GC_PEGIN_CONFIRM_NONCE_CONSENSUS_V1"; pub fn take1_pre_sign_num() -> usize { 2 @@ -57,6 +63,55 @@ pub fn sign_pegin_confirm( .map_err(|e| anyhow::anyhow!("fail to sign pegin confirm {}: {e}", pegin_confirm.name())) } +/// Return the exact MuSig2 message signed by PeginConfirm input 0. +pub fn pegin_confirm_sighash(instance_parameters: &BitvmGcInstanceParameters) -> Result<[u8; 32]> { + let pegin_confirm = instance_parameters.build_pegin_tx()?.1; + let script = pegin_confirm + .prev_scripts() + .first() + .ok_or_else(|| anyhow::anyhow!("{} input 0 script is missing", pegin_confirm.name()))?; + let leaf_hash = TapLeafHash::from_script(script, LeafVersion::TapScript); + Ok(taproot_script_spend_sighash( + pegin_confirm.tx(), + 0, + pegin_confirm.prev_outs(), + leaf_hash, + TapSighashType::All, + )? + .to_byte_array()) +} + +/// Build the transcript committee members must agree on before PeginConfirm signing. +/// Aggregate nonce is derived internally from canonical public nonces. +pub fn pegin_confirm_nonce_consensus_hash( + instance_parameters: &BitvmGcInstanceParameters, + committee_pubkeys: &[PublicKey], + ordered_pub_nonces: &[PubNonce], +) -> Result<[u8; 32]> { + ensure!( + committee_pubkeys.len() == ordered_pub_nonces.len(), + "committee public key and nonce counts differ" + ); + + let agg_nonce = nonce_aggregation(&ordered_pub_nonces.to_vec()); + let mut hasher = Sha256::new(); + hasher.update(PEGIN_CONFIRM_NONCE_CONSENSUS_DOMAIN); + hasher.update(instance_parameters.instance_id.as_bytes()); + hasher.update(instance_parameters.parameters_hash()?); + hasher.update(pegin_confirm_sighash(instance_parameters)?); + hasher.update((committee_pubkeys.len() as u32).to_be_bytes()); + for (committee_pubkey, pub_nonce) in committee_pubkeys.iter().zip(ordered_pub_nonces) { + hasher.update(committee_pubkey.to_bytes()); + let encoded = serde_json::to_vec(pub_nonce)?; + hasher.update((encoded.len() as u64).to_be_bytes()); + hasher.update(encoded); + } + let encoded_agg_nonce = serde_json::to_vec(&agg_nonce)?; + hasher.update((encoded_agg_nonce.len() as u64).to_be_bytes()); + hasher.update(encoded_agg_nonce); + Ok(hasher.finalize().into()) +} + pub fn agg_and_push_pegin_confirm_sigs( graph: &BitvmGcGraph, partial_sigs: Vec, @@ -135,13 +190,152 @@ pub fn key_aggregation(pubkeys: &[PublicKey]) -> PublicKey { generate_n_of_n_public_key(pubkeys).0 } +/// Hash every MuSig2 signing context used by committee pre-signing in nonce-slot order. +/// +/// This is deliberately independent of witnesses and partial signatures. It commits to the +/// exact taproot sighashes that deterministic committee nonces will be used to sign. +pub fn committee_presign_sighash_digest(graph: &BitvmGcGraph) -> Result<[u8; 32]> { + fn add_sighash( + hasher: &mut Sha256, + slot: &[u8], + tx: &T, + input_index: usize, + sighash_type: TapSighashType, + ) -> Result<()> { + let script = tx.prev_scripts().get(input_index).ok_or_else(|| { + anyhow::anyhow!("{} input {input_index} script is missing", tx.name()) + })?; + let leaf_hash = TapLeafHash::from_script(script, LeafVersion::TapScript); + let sighash = taproot_script_spend_sighash( + tx.tx(), + input_index, + tx.prev_outs(), + leaf_hash, + sighash_type, + )?; + + hasher.update((slot.len() as u32).to_be_bytes()); + hasher.update(slot); + hasher.update(sighash.to_byte_array()); + Ok(()) + } + + let mut hasher = Sha256::new(); + hasher.update(COMMITTEE_PRESIGN_SIGHASH_DOMAIN); + add_sighash(&mut hasher, b"take1/0/all", &graph.take1, 0, TapSighashType::All)?; + add_sighash(&mut hasher, b"take1/3/all", &graph.take1, 3, TapSighashType::All)?; + add_sighash(&mut hasher, b"take2/0/all", &graph.take2, 0, TapSighashType::All)?; + add_sighash( + &mut hasher, + b"challenge/0/single_anyonecanpay", + &graph.challenge, + 0, + TapSighashType::SinglePlusAnyoneCanPay, + )?; + for (index, tx) in graph.watchtower_challenge_timeouts.iter().enumerate() { + add_sighash( + &mut hasher, + format!("watchtower_challenge_timeout/{index}/1/none").as_bytes(), + tx, + 1, + TapSighashType::None, + )?; + } + for (index, tx) in graph.operator_challenge_nacks.iter().enumerate() { + add_sighash( + &mut hasher, + format!("operator_challenge_nack/{index}/0/all").as_bytes(), + tx, + 0, + TapSighashType::All, + )?; + add_sighash( + &mut hasher, + format!("operator_challenge_nack/{index}/1/all").as_bytes(), + tx, + 1, + TapSighashType::All, + )?; + } + add_sighash( + &mut hasher, + b"operator_commit_timeout/0/all", + &graph.operator_commit_timeout, + 0, + TapSighashType::All, + )?; + add_sighash( + &mut hasher, + b"operator_commit_timeout/1/all", + &graph.operator_commit_timeout, + 1, + TapSighashType::All, + )?; + for (index, tx) in graph.disproves.iter().enumerate() { + add_sighash( + &mut hasher, + format!("disprove/{index}/0/none").as_bytes(), + tx, + 0, + TapSighashType::None, + )?; + add_sighash( + &mut hasher, + format!("disprove/{index}/1/none").as_bytes(), + tx, + 1, + TapSighashType::None, + )?; + } + Ok(hasher.finalize().into()) +} + +/// Build the transcript every committee member must confirm before generating a partial +/// signature. Aggregate nonces are derived from the ordered public nonces here so callers +/// cannot supply a different aggregation to the consensus transcript. +pub fn committee_nonce_consensus_hash( + graph: &BitvmGcGraph, + committee_pubkeys: &[PublicKey], + ordered_pub_nonces: &[CommitteePubNonces], +) -> Result<[u8; 32]> { + ensure!( + committee_pubkeys.len() == ordered_pub_nonces.len(), + "committee public key and nonce counts differ" + ); + let watchtower_num = graph.parameters.watchtower_pubkeys.len(); + let verifier_num = graph.parameters.gc_data.len(); + for pub_nonces in ordered_pub_nonces { + pub_nonces.validate_length(watchtower_num, verifier_num)?; + } + let agg_nonces = nonces_aggregation(ordered_pub_nonces)?; + agg_nonces.validate_length(watchtower_num, verifier_num)?; + + let mut hasher = Sha256::new(); + hasher.update(COMMITTEE_NONCE_CONSENSUS_DOMAIN); + hasher.update(graph.parameters.instance_parameters.instance_id.as_bytes()); + hasher.update(graph.parameters.graph_id.as_bytes()); + hasher.update(graph.parameters.parameters_hash()?); + hasher.update(committee_presign_sighash_digest(graph)?); + hasher.update((committee_pubkeys.len() as u32).to_be_bytes()); + for (committee_pubkey, pub_nonces) in committee_pubkeys.iter().zip(ordered_pub_nonces) { + hasher.update(committee_pubkey.to_bytes()); + let encoded = serde_json::to_vec(pub_nonces)?; + hasher.update((encoded.len() as u64).to_be_bytes()); + hasher.update(encoded); + } + let encoded_agg_nonces = serde_json::to_vec(&agg_nonces)?; + hasher.update((encoded_agg_nonces.len() as u64).to_be_bytes()); + hasher.update(encoded_agg_nonces); + Ok(hasher.finalize().into()) +} + pub fn committee_pre_sign( committee_member_keypair: Keypair, committee_member_sec_nonce: CommitteeSecNonces, committee_agg_nonce: CommitteeAggNonces, graph: &mut BitvmGcGraph, ) -> Result { - let verifier_num = graph.verifier_asserts.len(); + let verifier_num = graph.parameters.gc_data.len(); let watchtower_num = graph.parameters.watchtower_pubkeys.len(); committee_member_sec_nonce.validate_length(watchtower_num, verifier_num)?; committee_agg_nonce.validate_length(watchtower_num, verifier_num)?; @@ -295,7 +489,7 @@ pub fn signature_aggregation( graph: &BitvmGcGraph, ) -> Result { let context = graph.parameters.get_base_context(); - let verifier_num = graph.verifier_asserts.len(); + let verifier_num = graph.parameters.gc_data.len(); let watchtower_num = graph.parameters.watchtower_pubkeys.len(); agg_nonces.validate_length(watchtower_num, verifier_num)?; for r in partial_sigs { @@ -583,7 +777,7 @@ pub fn verify_graph_committee_partial_sigs( committee_pubkeys.contains(committee_pubkey), "committee pubkey {committee_pubkey} is not in the committee set" ); - let verifier_num = graph.verifier_asserts.len(); + let verifier_num = graph.parameters.gc_data.len(); let watchtower_num = graph.parameters.watchtower_pubkeys.len(); pub_nonces.validate_length(watchtower_num, verifier_num)?; agg_nonces.validate_length(watchtower_num, verifier_num)?; @@ -786,7 +980,7 @@ pub fn push_committee_pre_signatures( graph: &mut BitvmGcGraph, sigs: &CommitteeSignatures, ) -> Result<()> { - let verifier_num = graph.verifier_asserts.len(); + let verifier_num = graph.parameters.gc_data.len(); let watchtower_num = graph.parameters.watchtower_pubkeys.len(); if graph.committee_pre_signed { bail!("already pre-signed by committee".to_string()) diff --git a/crates/bitvm-gc/src/keys.rs b/crates/bitvm-gc/src/keys.rs index 6da15bdd6..237cd3c78 100644 --- a/crates/bitvm-gc/src/keys.rs +++ b/crates/bitvm-gc/src/keys.rs @@ -1,8 +1,10 @@ use crate::committee::{ - CommitteeNonceSignatures, CommitteePubNonces, CommitteeSecNonces, generate_nonce, - generate_nonce_from_seed, + CommitteeNonceSignatures, CommitteePubNonces, CommitteeSecNonces, + committee_presign_sighash_digest, generate_nonce, generate_nonce_from_seed, + pegin_confirm_sighash, }; use crate::operator::{generate_assert_wots_key, generate_commit_pubin_wots_key}; +use crate::types::{BitvmGcGraph, BitvmGcInstanceParameters}; use anyhow::{Context, bail}; use bitcoin::{ Network, PublicKey, @@ -188,6 +190,22 @@ pub struct CommitteeInstanceKeyEnvelope { } pub struct CommitteeMasterKey(Keypair); + +struct CommitteeGraphNonceContext { + instance_id: Uuid, + graph_id: Uuid, + graph_parameters_hash: [u8; 32], + presign_sighash_digest: [u8; 32], + watchtower_num: usize, + verifier_num: usize, +} + +struct CommitteeInstanceNonceContext { + instance_id: Uuid, + instance_parameters_hash: [u8; 32], + pegin_confirm_sighash: [u8; 32], +} + impl CommitteeMasterKey { pub fn new(inner: Keypair) -> Self { CommitteeMasterKey(inner) @@ -340,40 +358,66 @@ impl CommitteeMasterKey { pub fn nonces_for_graph_job_with_keypair( &self, - instance_id: Uuid, - graph_id: Uuid, - graph_parameters_hash: [u8; 32], - watchtower_num: usize, - verifier_num: usize, + graph: &BitvmGcGraph, + signer_keypair: Keypair, + ) -> anyhow::Result<(CommitteePubNonces, CommitteeSecNonces, CommitteeNonceSignatures)> { + let context = CommitteeGraphNonceContext { + instance_id: graph.parameters.instance_parameters.instance_id, + graph_id: graph.parameters.graph_id, + graph_parameters_hash: graph.parameters.parameters_hash()?, + presign_sighash_digest: committee_presign_sighash_digest(graph)?, + watchtower_num: graph.parameters.watchtower_pubkeys.len(), + verifier_num: graph.parameters.gc_data.len(), + }; + Ok(self.nonces_for_graph_context_with_keypair(context, signer_keypair)) + } + + fn nonces_for_graph_context_with_keypair( + &self, + context: CommitteeGraphNonceContext, signer_keypair: Keypair, ) -> (CommitteePubNonces, CommitteeSecNonces, CommitteeNonceSignatures) { let domain = [ - b"committee_bitvm_graph_nonces_v2".to_vec(), - instance_id.as_bytes().to_vec(), - graph_id.as_bytes().to_vec(), - graph_parameters_hash.to_vec(), + b"committee_bitvm_graph_nonces_v3".to_vec(), + context.instance_id.as_bytes().to_vec(), + context.graph_id.as_bytes().to_vec(), + context.graph_parameters_hash.to_vec(), + context.presign_sighash_digest.to_vec(), ] .concat(); let nonce_seed = derive_secret(&signer_keypair, &domain); generate_nonce_from_seed( nonce_seed, - graph_id.as_u128() as usize, + context.graph_id.as_u128() as usize, signer_keypair, - watchtower_num, - verifier_num, + context.watchtower_num, + context.verifier_num, ) } pub fn nonce_for_instance_job_with_keypair( &self, - instance_id: Uuid, - instance_parameters_hash: [u8; 32], + instance_parameters: &BitvmGcInstanceParameters, + signer_keypair: Keypair, + ) -> anyhow::Result<(SecNonce, PubNonce, SchnorrSignature)> { + let context = CommitteeInstanceNonceContext { + instance_id: instance_parameters.instance_id, + instance_parameters_hash: instance_parameters.parameters_hash()?, + pegin_confirm_sighash: pegin_confirm_sighash(instance_parameters)?, + }; + Ok(self.nonce_for_instance_context_with_keypair(context, signer_keypair)) + } + + fn nonce_for_instance_context_with_keypair( + &self, + context: CommitteeInstanceNonceContext, signer_keypair: Keypair, ) -> (SecNonce, PubNonce, SchnorrSignature) { let domain = [ - b"committee_bitvm_instance_nonce_v2".to_vec(), - instance_id.as_bytes().to_vec(), - instance_parameters_hash.to_vec(), + b"committee_bitvm_instance_nonce_v3".to_vec(), + context.instance_id.as_bytes().to_vec(), + context.instance_parameters_hash.to_vec(), + context.pegin_confirm_sighash.to_vec(), ] .concat(); let nonce_seed = derive_secret(&signer_keypair, &domain); diff --git a/crates/bitvm-gc/tests/babe_adapter.rs b/crates/bitvm-gc/tests/babe_adapter.rs index eed8d2500..6fe76a8e4 100644 --- a/crates/bitvm-gc/tests/babe_adapter.rs +++ b/crates/bitvm-gc/tests/babe_adapter.rs @@ -171,11 +171,11 @@ fn verify_setup_accepts_valid_opening_and_rejects_invalid_shapes() { assert!(verify_setup(&package, &opened, &duplicate_finalized, &soldering).is_err()); let mut overlapping_opened = opened.clone(); - overlapping_opened.push((finalized[0].index, 0)); + overlapping_opened.push((finalized[0].index, [0u8; 32])); assert!(verify_setup(&package, &overlapping_opened, &finalized, &soldering).is_err()); let mut wrong_seed_opened = opened.clone(); - wrong_seed_opened[0].1 ^= 1; + wrong_seed_opened[0].1[0] ^= 1; assert!(verify_setup(&package, &wrong_seed_opened, &finalized, &soldering).is_err()); let mut mismatched_soldering = soldering.clone(); diff --git a/crates/bitvm-gc/tests/regtest.rs b/crates/bitvm-gc/tests/regtest.rs index 57b804155..129de5a4e 100644 --- a/crates/bitvm-gc/tests/regtest.rs +++ b/crates/bitvm-gc/tests/regtest.rs @@ -12,7 +12,7 @@ use bitcoin::{ }; use bitcoin_script::script; use bitvm_gc::babe_adapter::{ - BABE_M_CC, BabeProverState, CACSetupPackage, TxAssertWitness, assert_wots_message, + BABE_M_CC, BabeProverState, CACSetupPackage, Seed, TxAssertWitness, assert_wots_message, build_assert_witness, build_setup_package, derive_finalized_indices, extract_gc_circuit_data, open_and_solder, recover_operator_proof_from_assert_witness, verify_setup, }; @@ -82,7 +82,7 @@ static MOCK_FIXTURE: OnceLock> = struct MockProofGcFixture { version: u32, setup_package: CACSetupPackage, - opened: Vec<(usize, u64)>, + opened: Vec<(usize, Seed)>, prover_state: BabeProverState, gc_data: BitvmGcCircuitData, proof_bytes: Vec, @@ -554,13 +554,11 @@ fn committee_pre_sign_graph(graph: &mut BitvmGcGraph, keys: &TestKeys) -> Result } fn committee_sign_pegin(graph: &BitvmGcGraph, keys: &TestKeys) -> Result { - let parameters_hash = graph.parameters.instance_parameters.parameters_hash()?; - let instance_id = graph.parameters.instance_parameters.instance_id; let mut pub_nonces = Vec::with_capacity(keys.committee.len()); let mut sec_nonces = Vec::with_capacity(keys.committee.len()); for keypair in &keys.committee { let (sec_nonce, pub_nonce, _) = CommitteeMasterKey::new(*keypair) - .nonce_for_instance_job_with_keypair(instance_id, parameters_hash, *keypair); + .nonce_for_instance_job_with_keypair(&graph.parameters.instance_parameters, *keypair)?; pub_nonces.push(pub_nonce); sec_nonces.push(sec_nonce); } diff --git a/crates/header-chain/src/header_chain.rs b/crates/header-chain/src/header_chain.rs index 94fdb9ff4..ffac7e2f8 100644 --- a/crates/header-chain/src/header_chain.rs +++ b/crates/header-chain/src/header_chain.rs @@ -3,6 +3,7 @@ use bincode::Options as BincodeOptions; use bitcoin::{ BlockHash, CompactTarget, Network, TxMerkleNode, block::{Header, Version}, + consensus::Params, hashes::Hash, }; use borsh::{BorshDeserialize, BorshSerialize}; @@ -100,10 +101,6 @@ pub const NETWORK_CONSTANTS: NetworkConstants = { } }; -/// An epoch should be two weeks (represented as number of seconds) -/// seconds/minute * minutes/hour * hours/day * 14 days -const EXPECTED_EPOCH_TIMESPAN: u32 = 60 * 60 * 24 * 14; - /// Number of blocks per epoch const BLOCKS_PER_EPOCH: u32 = 2016; @@ -278,12 +275,12 @@ impl ChainState { } if !IS_REGTEST && self.block_height % BLOCKS_PER_EPOCH == BLOCKS_PER_EPOCH - 1 { - current_target_bytes = calculate_new_difficulty( + self.current_target_bits = calculate_new_difficulty( self.epoch_start_time, block_header.time, self.current_target_bits, ); - self.current_target_bits = target_to_bits(¤t_target_bytes); + current_target_bytes = bits_to_target(self.current_target_bits); } } @@ -320,39 +317,18 @@ pub fn bits_to_target(bits: u32) -> [u8; 32] { target.to_be_bytes() } -fn target_to_bits(target: &[u8; 32]) -> u32 { - let target_u256 = U256::from_be_slice(target); - let target_bits = target_u256.bits(); - let size = (263 - target_bits) / 8; - let mut compact_target = [0u8; 4]; - compact_target[0] = 33 - size as u8; - compact_target[1] = target[size - 1_usize]; - compact_target[2] = target[size]; - compact_target[3] = target[size + 1_usize]; - u32::from_be_bytes(compact_target) -} - fn calculate_new_difficulty( epoch_start_time: u32, last_timestamp: u32, current_target: u32, -) -> [u8; 32] { - let mut actual_timespan = last_timestamp - epoch_start_time; - if actual_timespan < EXPECTED_EPOCH_TIMESPAN / 4 { - actual_timespan = EXPECTED_EPOCH_TIMESPAN / 4; - } else if actual_timespan > EXPECTED_EPOCH_TIMESPAN * 4 { - actual_timespan = EXPECTED_EPOCH_TIMESPAN * 4; - } - - let new_target_bytes = bits_to_target(current_target); - let mut new_target = U256::from_be_bytes(new_target_bytes) - .wrapping_mul(&U256::from(actual_timespan)) - .wrapping_div(&U256::from(EXPECTED_EPOCH_TIMESPAN)); - - if new_target > NETWORK_CONSTANTS.max_target { - new_target = NETWORK_CONSTANTS.max_target; - } - new_target.to_be_bytes() +) -> u32 { + let actual_timespan = last_timestamp.saturating_sub(epoch_start_time); + CompactTarget::from_next_work_required( + CompactTarget::from_consensus(current_target), + u64::from(actual_timespan), + Params::new(NETWORK), + ) + .to_consensus() } fn check_hash_valid(hash: &[u8; 32], target_bytes: &[u8; 32]) { @@ -1024,15 +1000,6 @@ mod tests { } } - #[test] - fn test_target_conversion() { - for (_, _, bits, _) in DIFFICULTY_ADJUSTMENTS { - let compact_target = bits_to_target(bits); - let nbits = target_to_bits(&compact_target); - assert_eq!(nbits, bits); - } - } - #[test] fn test_bits_to_target() { // https://learnmeabitcoin.com/explorer/block/00000000000000000002ebe388cb8fa0683fc34984cfc2d7d3b3f99bc0d51bfd @@ -1041,21 +1008,27 @@ mod tests { let bits: u32 = 0x1702f128; let target = bits_to_target(bits); assert_eq!(target, expected_target); - - let converted_bits = target_to_bits(&target); - - assert_eq!(converted_bits, bits); } #[test] fn test_difficulty_adjustments() { for (start_time, end_time, start_target, end_target) in DIFFICULTY_ADJUSTMENTS { - let new_target_bytes = calculate_new_difficulty(start_time, end_time, start_target); - let bits = target_to_bits(&new_target_bytes); - assert_eq!(bits, end_target); + let new_target = calculate_new_difficulty(start_time, end_time, start_target); + assert_eq!(new_target, end_target); } } + #[test] + fn test_difficulty_adjustment_handles_reversed_timestamps() { + let epoch_start_time = 1_700_002_000; + let current_target = 0x1d00ffff; + let expected = calculate_new_difficulty(epoch_start_time, epoch_start_time, current_target); + + let actual = calculate_new_difficulty(epoch_start_time, 1_700_001_000, current_target); + + assert_eq!(actual, expected); + } + #[test] fn test_bridge_block_header_from_header() { let header = Header { diff --git a/crates/state-chain/src/cbft.rs b/crates/state-chain/src/cbft.rs index 569235ee3..43a56c044 100644 --- a/crates/state-chain/src/cbft.rs +++ b/crates/state-chain/src/cbft.rs @@ -120,11 +120,14 @@ pub fn check_el_block_from_payload( txs: &[Vec], actual_data_hash: &[u8; 32], ) { - if let Some(payload) = parse_cbft_tx_payload(&txs[0]) { - assert_eq!(payload.block_number, el_block_number); - assert_eq!(payload.block_hash, el_block_hash); - assert_eq!(payload.parent_hash, el_parent_block_hash); - } + // The payload is what binds this EVM block to the chain we are extending, so + // it must be present: a `Tx` encoded without a body decodes to `body: None`, + // which used to skip all three assertions and leave only the merkle root. + assert!(!txs.is_empty(), "cosmos block carries no txs"); + let payload = parse_cbft_tx_payload(&txs[0]).expect("missing MsgNewEthBlock payload"); + assert_eq!(payload.block_number, el_block_number); + assert_eq!(payload.block_hash, el_block_hash); + assert_eq!(payload.parent_hash, el_parent_block_hash); let computed_data_hash = merkle_root_from_base64_txns(txs); assert_eq!(*actual_data_hash, computed_data_hash); } diff --git a/crates/state-chain/src/state_chain.rs b/crates/state-chain/src/state_chain.rs index b5ea9f66a..652ac85ac 100644 --- a/crates/state-chain/src/state_chain.rs +++ b/crates/state-chain/src/state_chain.rs @@ -111,6 +111,15 @@ impl StateChainState { let cosmos_block: LightBlock = serde_json::from_slice(&block.cosmos_block) .expect("failed to deserialize cosmos block"); if current_block_hash != self.genesis_evm_block_hash { + // Link this block to the one we last applied. Both ends of the + // chain are committed on Bitcoin - the genesis hash through the + // commit-chain digest, the final block through the signed cosmos + // header whose payload names it - so an unforgeable parent chain + // between them authenticates every intermediate block. + assert_eq!( + evm_header.parent_hash.0, self.latest_evm_block_hash, + "evm block does not extend the current chain tip" + ); let data_hash: [u8; 32] = cosmos_block .signed_header .header diff --git a/crates/store/.sqlx/query-0771a794f73d56d8f1583f6af496009b4031922b68743ecd10f6062b680618b5.json b/crates/store/.sqlx/query-0771a794f73d56d8f1583f6af496009b4031922b68743ecd10f6062b680618b5.json deleted file mode 100644 index 8a3084eda..000000000 --- a/crates/store/.sqlx/query-0771a794f73d56d8f1583f6af496009b4031922b68743ecd10f6062b680618b5.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n UPDATE graph_raw_data\n SET raw_data = ?, updated_at = ?\n WHERE graph_id = ?\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "0771a794f73d56d8f1583f6af496009b4031922b68743ecd10f6062b680618b5" -} diff --git a/crates/store/.sqlx/query-0b91f08a5464a247f099d4b933ee65345ab3f1b9fe9f80899c0d23056772e094.json b/crates/store/.sqlx/query-0b91f08a5464a247f099d4b933ee65345ab3f1b9fe9f80899c0d23056772e094.json deleted file mode 100644 index 9c06252b4..000000000 --- a/crates/store/.sqlx/query-0b91f08a5464a247f099d4b933ee65345ab3f1b9fe9f80899c0d23056772e094.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "DELETE FROM graph_raw_data WHERE graph_id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "0b91f08a5464a247f099d4b933ee65345ab3f1b9fe9f80899c0d23056772e094" -} diff --git a/crates/store/.sqlx/query-0d833cf6fdebd05b4c34f57325c7114ea87946f233e33ddda62b376f4ca8c289.json b/crates/store/.sqlx/query-0d833cf6fdebd05b4c34f57325c7114ea87946f233e33ddda62b376f4ca8c289.json deleted file mode 100644 index a04047693..000000000 --- a/crates/store/.sqlx/query-0d833cf6fdebd05b4c34f57325c7114ea87946f233e33ddda62b376f4ca8c289.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT OR\n REPLACE INTO instance (instance_id, is_bridge_in, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height,\n user_xonly_pubkey, user_change_addr, user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers,\n pegin_data_tx_hash, btc_height, parameters, status_updated_at, escrow_hash, bridge_out_lock_time, post_pegin_txhash, bridge_out_amount, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,?)", - "describe": { - "columns": [], - "parameters": { - "Right": 28 - }, - "nullable": [] - }, - "hash": "0d833cf6fdebd05b4c34f57325c7114ea87946f233e33ddda62b376f4ca8c289" -} diff --git a/crates/store/.sqlx/query-139b72c815d071e58c04a9dfdfabdebf0ee70d7238941466eb7dac759326facd.json b/crates/store/.sqlx/query-139b72c815d071e58c04a9dfdfabdebf0ee70d7238941466eb7dac759326facd.json new file mode 100644 index 000000000..d01918eef --- /dev/null +++ b/crates/store/.sqlx/query-139b72c815d071e58c04a9dfdfabdebf0ee70d7238941466eb7dac759326facd.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO swap_escrow (escrow_hash, network, status, offerer_addr, claimer_addr, btc_addr, token, amount, refund_deadline, escrow_data, init_tx_hash, init_tx_height, claim_tx_hash, claim_btc_txid, refund_tx_hash, status_updated_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(escrow_hash) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Right": 18 + }, + "nullable": [] + }, + "hash": "139b72c815d071e58c04a9dfdfabdebf0ee70d7238941466eb7dac759326facd" +} diff --git a/crates/store/.sqlx/query-1beb2f77f969f063b5a0b2b2440e38ab5619517e69a0833d2bb9a0895bb89d28.json b/crates/store/.sqlx/query-1beb2f77f969f063b5a0b2b2440e38ab5619517e69a0833d2bb9a0895bb89d28.json new file mode 100644 index 000000000..fcb2694d5 --- /dev/null +++ b/crates/store/.sqlx/query-1beb2f77f969f063b5a0b2b2440e38ab5619517e69a0833d2bb9a0895bb89d28.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE swap_escrow SET status = ?, status_updated_at = ?, updated_at = ? WHERE status = ? AND refund_deadline > 0 AND refund_deadline < ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "1beb2f77f969f063b5a0b2b2440e38ab5619517e69a0833d2bb9a0895bb89d28" +} diff --git a/crates/store/.sqlx/query-1cbf508764b393270348c8a5c3e1cfb72ffc566f60ea777067a9a346d127c646.json b/crates/store/.sqlx/query-1cbf508764b393270348c8a5c3e1cfb72ffc566f60ea777067a9a346d127c646.json deleted file mode 100644 index 7c409afe0..000000000 --- a/crates/store/.sqlx/query-1cbf508764b393270348c8a5c3e1cfb72ffc566f60ea777067a9a346d127c646.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT OR REPLACE INTO graph_raw_data (graph_id, raw_data, created_at, updated_at)\n VALUES (?, ?, ?, ?)\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "1cbf508764b393270348c8a5c3e1cfb72ffc566f60ea777067a9a346d127c646" -} diff --git a/crates/store/.sqlx/query-266a306f7600058eb99a5ae3399d7790ae96754a0a5890524b3cd79325d0acff.json b/crates/store/.sqlx/query-266a306f7600058eb99a5ae3399d7790ae96754a0a5890524b3cd79325d0acff.json deleted file mode 100644 index 11d5ae9fb..000000000 --- a/crates/store/.sqlx/query-266a306f7600058eb99a5ae3399d7790ae96754a0a5890524b3cd79325d0acff.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "Update message Set state = ?, updated_at = ? WHERE business_id = ? AND state = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "266a306f7600058eb99a5ae3399d7790ae96754a0a5890524b3cd79325d0acff" -} diff --git a/crates/store/.sqlx/query-292ff9e09542309a9675b94debe3e6448e5f68b063790542db1c15d0be2a3720.json b/crates/store/.sqlx/query-292ff9e09542309a9675b94debe3e6448e5f68b063790542db1c15d0be2a3720.json deleted file mode 100644 index 7a335a19b..000000000 --- a/crates/store/.sqlx/query-292ff9e09542309a9675b94debe3e6448e5f68b063790542db1c15d0be2a3720.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE graph\n SET status = ?,\n status_updated_at = ?,\n updated_at = ?\n WHERE instance_id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "292ff9e09542309a9675b94debe3e6448e5f68b063790542db1c15d0be2a3720" -} diff --git a/crates/store/.sqlx/query-320fa33b8cca81e01065c39a357eb85dcba4bae69851f645309213da033b0473.json b/crates/store/.sqlx/query-320fa33b8cca81e01065c39a357eb85dcba4bae69851f645309213da033b0473.json deleted file mode 100644 index 9967a4a64..000000000 --- a/crates/store/.sqlx/query-320fa33b8cca81e01065c39a357eb85dcba4bae69851f645309213da033b0473.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO message (message_id, business_id, from_peer, actor, msg_type, content, state, message_version, lock_time_until, weight, updated_at, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(message_id) DO UPDATE SET business_id = excluded.business_id,\n from_peer = excluded.from_peer,\n actor = excluded.actor,\n msg_type = excluded.msg_type,\n content = excluded.content,\n state = excluded.state,\n message_version = message.message_version + 1,\n lock_time_until = excluded.lock_time_until,\n weight = excluded.weight,\n updated_at = excluded.updated_at", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "320fa33b8cca81e01065c39a357eb85dcba4bae69851f645309213da033b0473" -} diff --git a/crates/store/.sqlx/query-372f1a46cbff240ee0146e23597d5926ec488b778d078cf4eded3075dfeff11f.json b/crates/store/.sqlx/query-372f1a46cbff240ee0146e23597d5926ec488b778d078cf4eded3075dfeff11f.json deleted file mode 100644 index 237c755c8..000000000 --- a/crates/store/.sqlx/query-372f1a46cbff240ee0146e23597d5926ec488b778d078cf4eded3075dfeff11f.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT message_id,\n business_id AS \"business_id:Uuid\",\n from_peer,\n actor,\n msg_type,\n content,\n message_version,\n state,\n weight,\n lock_time_until\n FROM message\n WHERE business_id = ? AND msg_type = ?", - "describe": { - "columns": [ - { - "name": "message_id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "business_id:Uuid", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "from_peer", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "actor", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "msg_type", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "content", - "ordinal": 5, - "type_info": "Blob" - }, - { - "name": "message_version", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "state", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "weight", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "lock_time_until", - "ordinal": 9, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "372f1a46cbff240ee0146e23597d5926ec488b778d078cf4eded3075dfeff11f" -} diff --git a/crates/store/.sqlx/query-42fb787e5cae022245ddb43b569e74bcef7fb613b18ac7663319c8f821f9296d.json b/crates/store/.sqlx/query-42fb787e5cae022245ddb43b569e74bcef7fb613b18ac7663319c8f821f9296d.json deleted file mode 100644 index 61d0aabc2..000000000 --- a/crates/store/.sqlx/query-42fb787e5cae022245ddb43b569e74bcef7fb613b18ac7663319c8f821f9296d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "Update message Set state = ?, updated_at = ? WHERE message_id = ? AND message_version = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "42fb787e5cae022245ddb43b569e74bcef7fb613b18ac7663319c8f821f9296d" -} diff --git a/crates/store/.sqlx/query-5c56418dfb5c977395eb0fe75bdc9b5feeffbf5abe927b474186070c1624dd13.json b/crates/store/.sqlx/query-5c56418dfb5c977395eb0fe75bdc9b5feeffbf5abe927b474186070c1624dd13.json deleted file mode 100644 index f7136fd92..000000000 --- a/crates/store/.sqlx/query-5c56418dfb5c977395eb0fe75bdc9b5feeffbf5abe927b474186070c1624dd13.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE graph\n SET status = ?,\n status_updated_at = ?,\n updated_at = ?\n WHERE instance_id = ?\n AND graph_id != ?", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "5c56418dfb5c977395eb0fe75bdc9b5feeffbf5abe927b474186070c1624dd13" -} diff --git a/crates/store/.sqlx/query-8c69b8b08d7321de8160a9a61a66ec87546965ff35877dff9d8cc394a4ac89e1.json b/crates/store/.sqlx/query-8c69b8b08d7321de8160a9a61a66ec87546965ff35877dff9d8cc394a4ac89e1.json new file mode 100644 index 000000000..006fde062 --- /dev/null +++ b/crates/store/.sqlx/query-8c69b8b08d7321de8160a9a61a66ec87546965ff35877dff9d8cc394a4ac89e1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR\n REPLACE INTO instance (instance_id, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height,\n user_xonly_pubkey, user_change_addr, user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers,\n pegin_data_tx_hash, btc_height, parameters, status_updated_at, post_pegin_txhash, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 24 + }, + "nullable": [] + }, + "hash": "8c69b8b08d7321de8160a9a61a66ec87546965ff35877dff9d8cc394a4ac89e1" +} diff --git a/crates/store/.sqlx/query-bb8e6031d9255b7a1195aadadfc9b70b205939583c6cacd012fcc2cede7cc6aa.json b/crates/store/.sqlx/query-bb8e6031d9255b7a1195aadadfc9b70b205939583c6cacd012fcc2cede7cc6aa.json deleted file mode 100644 index cbbd379b9..000000000 --- a/crates/store/.sqlx/query-bb8e6031d9255b7a1195aadadfc9b70b205939583c6cacd012fcc2cede7cc6aa.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT message_id,\n business_id AS \"business_id:Uuid\",\n from_peer,\n actor,\n msg_type,\n content,\n message_version,\n state,\n weight,\n lock_time_until\n FROM message\n WHERE message_id = ?", - "describe": { - "columns": [ - { - "name": "message_id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "business_id:Uuid", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "from_peer", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "actor", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "msg_type", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "content", - "ordinal": 5, - "type_info": "Blob" - }, - { - "name": "message_version", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "state", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "weight", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "lock_time_until", - "ordinal": 9, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "bb8e6031d9255b7a1195aadadfc9b70b205939583c6cacd012fcc2cede7cc6aa" -} diff --git a/crates/store/.sqlx/query-c9f0cf6af20db541f198fcbb8f10ba7b11ebd7601ca2468461db518db2d5f494.json b/crates/store/.sqlx/query-c9f0cf6af20db541f198fcbb8f10ba7b11ebd7601ca2468461db518db2d5f494.json deleted file mode 100644 index 36c667c53..000000000 --- a/crates/store/.sqlx/query-c9f0cf6af20db541f198fcbb8f10ba7b11ebd7601ca2468461db518db2d5f494.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "Update message Set state = ?, updated_at = ? WHERE business_id = ? AND msg_type = ? AND state = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "c9f0cf6af20db541f198fcbb8f10ba7b11ebd7601ca2468461db518db2d5f494" -} diff --git a/crates/store/.sqlx/query-cc6525abb198055f787dd0bfcc0d0095ecb645fdfd91d202ce6aba0220349737.json b/crates/store/.sqlx/query-cc6525abb198055f787dd0bfcc0d0095ecb645fdfd91d202ce6aba0220349737.json deleted file mode 100644 index f9b16ce88..000000000 --- a/crates/store/.sqlx/query-cc6525abb198055f787dd0bfcc0d0095ecb645fdfd91d202ce6aba0220349737.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT OR\n REPLACE INTO graph (graph_id, instance_id, kickoff_index, from_addr, to_addr, amount, challenge_amount,\n status, sub_status, operator_pubkey, cur_prekickoff_txid, next_prekickoff, force_skip_kickoff_txid,\n quick_challenge_txid, challenge_incomplete_kickoff_txid, pegin_txid, kickoff_txid, take1_txid,\n challenge_txid, take2_txid, watchtower_challenge_init_txid, operator_assert_txid, verifier_assert_txids, disprove_txids,\n watchtower_challenge_timeout_txids, operator_challenge_nack_txids, operator_commit_timeout_txid,\n init_withdraw_tx_hash,\n bridge_out_start_at, status_updated_at, proceed_withdraw_height, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 33 - }, - "nullable": [] - }, - "hash": "cc6525abb198055f787dd0bfcc0d0095ecb645fdfd91d202ce6aba0220349737" -} diff --git a/crates/store/.sqlx/query-f182e4c5bd4f23846671dc7a9db745dbb8f8bed0b1f33d56e8a1b6c4028c9617.json b/crates/store/.sqlx/query-f182e4c5bd4f23846671dc7a9db745dbb8f8bed0b1f33d56e8a1b6c4028c9617.json deleted file mode 100644 index a682875e3..000000000 --- a/crates/store/.sqlx/query-f182e4c5bd4f23846671dc7a9db745dbb8f8bed0b1f33d56e8a1b6c4028c9617.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT message_id,\n business_id AS \"business_id:Uuid\",\n from_peer,\n actor,\n msg_type,\n content,\n message_version,\n state,\n weight,\n lock_time_until\n FROM message\n WHERE state = ?\n AND weight >= ?\n AND lock_time_until <= ?\n AND updated_at >= ?\n ORDER BY created_at ASC\n LIMIT ? OFFSET ?", - "describe": { - "columns": [ - { - "name": "message_id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "business_id:Uuid", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "from_peer", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "actor", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "msg_type", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "content", - "ordinal": 5, - "type_info": "Blob" - }, - { - "name": "message_version", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "state", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "weight", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "lock_time_until", - "ordinal": 9, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 6 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "f182e4c5bd4f23846671dc7a9db745dbb8f8bed0b1f33d56e8a1b6c4028c9617" -} diff --git a/crates/store/migrations/20250814115805_create_instance_table.sql b/crates/store/migrations/20250814115805_create_instance_table.sql index f283a98ec..c563492b2 100644 --- a/crates/store/migrations/20250814115805_create_instance_table.sql +++ b/crates/store/migrations/20250814115805_create_instance_table.sql @@ -3,7 +3,6 @@ DROP TABLE IF EXISTS `instance`; CREATE TABLE instance ( `instance_id` TEXT NOT NULL DEFAULT '', - `is_bridge_in` BOOL NOT NULL DEFAULT 1, `network` TEXT NOT NULL DEFAULT 'test', `from_addr` TEXT NOT NULL DEFAULT '', `to_addr` TEXT NOT NULL DEFAULT '', @@ -23,9 +22,6 @@ CREATE TABLE instance `pegin_data_tx_hash` TEXT NOT NULL DEFAULT '', `btc_height` BIGINT UNSIGNED NOT NULL DEFAULT 0, `parameters` TEXT, - `escrow_hash` TEXT, - `bridge_out_amount` TEXT NOT NULL DEFAULT '0', - `bridge_out_lock_time` BIGINT NOT NULL DEFAULT 0, `post_pegin_txhash` TEXT, `status_updated_at` BIGINT NOT NULL DEFAULT 0, `created_at` BIGINT NOT NULL DEFAULT 0, diff --git a/crates/store/migrations/20260827120000_add_p2p_outbox_retry_window.sql b/crates/store/migrations/20260827120000_add_p2p_outbox_retry_window.sql new file mode 100644 index 000000000..7f027539f --- /dev/null +++ b/crates/store/migrations/20260827120000_add_p2p_outbox_retry_window.sql @@ -0,0 +1,6 @@ +ALTER TABLE p2p_outbox ADD COLUMN retry_until BIGINT NOT NULL DEFAULT 0; +ALTER TABLE p2p_outbox ADD COLUMN retry_interval_secs BIGINT NOT NULL DEFAULT 0; +ALTER TABLE p2p_outbox ADD COLUMN ack_peer_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_p2p_outbox_retry_window + ON p2p_outbox (state, retry_until, next_retry_at); diff --git a/crates/store/migrations/20260827130000_create_swap_escrow_table.sql b/crates/store/migrations/20260827130000_create_swap_escrow_table.sql new file mode 100644 index 000000000..812d611c9 --- /dev/null +++ b/crates/store/migrations/20260827130000_create_swap_escrow_table.sql @@ -0,0 +1,25 @@ +-- Swap-based bridge-out escrows, keyed by the on-chain escrow hash. +-- Fully independent of the BitVM `instance`/`graph` flow. +DROP TABLE IF EXISTS `swap_escrow`; +CREATE TABLE swap_escrow +( + `escrow_hash` TEXT NOT NULL, -- 0x-prefixed 32-byte hex, lowercase + `network` TEXT NOT NULL DEFAULT '', + `status` TEXT NOT NULL DEFAULT '', -- SwapEscrowStatus + `offerer_addr` TEXT NOT NULL DEFAULT '', -- GOAT account funding the escrow + `claimer_addr` TEXT NOT NULL DEFAULT '', -- GOAT account entitled to claim + `btc_addr` TEXT NOT NULL DEFAULT '', -- BTC address receiving the payout + `token` TEXT NOT NULL DEFAULT '', -- escrow token contract address + `amount` TEXT NOT NULL DEFAULT '0', -- escrow amount, U256 decimal string + `refund_deadline` BIGINT NOT NULL DEFAULT 0, -- unix secs after which refund is possible + `escrow_data` TEXT, -- hex abi-encoded EscrowData from Initialize tx + `init_tx_hash` TEXT NOT NULL DEFAULT '', -- swap Initialize GOAT tx + `init_tx_height` BIGINT NOT NULL DEFAULT 0, + `claim_tx_hash` TEXT NOT NULL DEFAULT '', -- swap Claim GOAT tx + `claim_btc_txid` TEXT, -- BTC payout tx committed by the claim + `refund_tx_hash` TEXT NOT NULL DEFAULT '', -- swap Refund GOAT tx + `status_updated_at` BIGINT NOT NULL DEFAULT 0, + `created_at` BIGINT NOT NULL DEFAULT 0, + `updated_at` BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (`escrow_hash`) +); diff --git a/crates/store/src/localdb.rs b/crates/store/src/localdb.rs index 73d25120b..aa8e2f9b9 100644 --- a/crates/store/src/localdb.rs +++ b/crates/store/src/localdb.rs @@ -5,8 +5,8 @@ use crate::{ LongRunningTaskProof, Message, MessageDebugOverview, MessageDebugReason, MetricsStateCount, Node, NodeAlertMetricsSnapshot, NodesOverview, OperatorProof, P2pInboxMessage, P2pOutboxMessage, PeginGraphProcessData, PeginInstanceProcessData, PendingGraphInit, - SequencerSetHashChange, SequencerSetScanState, SerializableTxid, WatchContract, - WatchtowerProof, + SequencerSetHashChange, SequencerSetScanState, SerializableTxid, SwapEscrow, SwapEscrowStatus, + WatchContract, WatchtowerProof, }; use indexmap::IndexMap; @@ -69,6 +69,9 @@ fn p2p_outbox_message_from_row(row: &SqliteRow) -> Result, - pub escrow_hash: Option, pub statuses: Vec, pub earliest_updated: Option, pub pegin_request_height_threshold: Option, @@ -176,11 +177,6 @@ pub struct InstanceQuery { } impl InstanceQuery { - pub fn with_is_bridge_in(mut self, is_bridge_in: bool) -> Self { - self.is_bridge_in = is_bridge_in; - self - } - pub fn with_from_addr(mut self, from_addr: String) -> Self { self.from_addr = Some(from_addr); self @@ -201,11 +197,6 @@ impl InstanceQuery { self } - pub fn with_escrow_hash(mut self, escrow_hash: String) -> Self { - self.escrow_hash = Some(escrow_hash); - self - } - pub fn with_earliest_updated(mut self, earliest_updated: i64) -> Self { self.earliest_updated = Some(earliest_updated); self @@ -244,16 +235,10 @@ impl InstanceQuery { pub fn get_query_builder(&self, base_sql: &str) -> QueryBuilder { let mut query_builder = QueryBuilder::new(base_sql); - query_builder.and_where("is_bridge_in = ?", Some(QueryParam::Bool(self.is_bridge_in))); - if let Some(from_addr) = &self.from_addr { query_builder.and_where("from_addr = ?", Some(QueryParam::Text(from_addr.clone()))); } - if let Some(escrow_hash) = &self.escrow_hash { - query_builder.and_where("escrow_hash = ?", Some(QueryParam::Text(escrow_hash.clone()))); - } - if !self.statuses.is_empty() { query_builder.and_where_in("status", &self.statuses, false); } @@ -283,8 +268,7 @@ impl InstanceQuery { /// Provides a more elegant way to specify which instance fields to update #[derive(Debug, Clone)] pub struct InstanceUpdate { - pub instance_id: Option, - pub escrow_hash: Option, + pub instance_id: Uuid, pub from_addr: Option, pub to_addr: Option, pub btc_txid: Option, @@ -294,22 +278,14 @@ pub struct InstanceUpdate { pub post_pegin_txhash: Option, pub btc_height: Option, pub committees_answers: Option>>, - pub bridge_out_lock_time: Option, - pub bridge_out_amount: Option, - pub goat_tx_hash: Option, - pub goat_tx_height: Option, - pub user_change_addr: Option, - pub user_refund_addr: Option, pub only_if_status_in: Option>, - pub only_if_is_bridge_in: Option, - pub only_if_goat_tx_hash: Option, } impl InstanceUpdate { - fn empty() -> Self { + /// Create new update parameters + pub fn new_with_instance_id(instance_id: Uuid) -> Self { Self { - instance_id: None, - escrow_hash: None, + instance_id, from_addr: None, to_addr: None, btc_txid: None, @@ -319,26 +295,10 @@ impl InstanceUpdate { post_pegin_txhash: None, btc_height: None, committees_answers: None, - bridge_out_lock_time: None, - bridge_out_amount: None, - goat_tx_hash: None, - goat_tx_height: None, - user_change_addr: None, - user_refund_addr: None, only_if_status_in: None, - only_if_is_bridge_in: None, - only_if_goat_tx_hash: None, } } - /// Create new update parameters - pub fn new_with_instance_id(instance_id: Uuid) -> Self { - Self { instance_id: Some(instance_id), ..Self::empty() } - } - pub fn new_with_escrow_hash(escrow_hash: String) -> Self { - Self { escrow_hash: Some(escrow_hash), ..Self::empty() } - } - /// Set from_addr pub fn with_from_addr(mut self, from_addr: String) -> Self { self.from_addr = Some(from_addr); @@ -395,37 +355,6 @@ impl InstanceUpdate { self } - /// Set bridge out lock time - pub fn with_bridge_out_lock_time(mut self, bridge_out_lock_time: i64) -> Self { - self.bridge_out_lock_time = Some(bridge_out_lock_time); - self - } - - pub fn with_bridge_out_amount(mut self, bridge_out_amount: String) -> Self { - self.bridge_out_amount = Some(bridge_out_amount); - self - } - - pub fn with_goat_tx_hash(mut self, goat_tx_hash: String) -> Self { - self.goat_tx_hash = Some(goat_tx_hash); - self - } - - pub fn with_goat_tx_height(mut self, goat_tx_height: i64) -> Self { - self.goat_tx_height = Some(goat_tx_height); - self - } - - pub fn with_user_change_addr(mut self, user_change_addr: String) -> Self { - self.user_change_addr = Some(user_change_addr); - self - } - - pub fn with_user_refund_addr(mut self, user_refund_addr: String) -> Self { - self.user_refund_addr = Some(user_refund_addr); - self - } - /// Apply this update only while the instance is still in one of the /// expected states. The condition is folded into the UPDATE statement. pub fn with_only_if_status_in(mut self, statuses: Vec) -> Self { @@ -433,23 +362,9 @@ impl InstanceUpdate { self } - /// Apply this update only to the expected bridge direction. - pub fn with_only_if_is_bridge_in(mut self, is_bridge_in: bool) -> Self { - self.only_if_is_bridge_in = Some(is_bridge_in); - self - } - - /// Apply this update only when the existing Goat transaction hash is the - /// expected value. Used to make swap initialization idempotent. - pub fn with_only_if_goat_tx_hash(mut self, goat_tx_hash: String) -> Self { - self.only_if_goat_tx_hash = Some(goat_tx_hash); - self - } - /// Check if any fields need to be updated pub fn has_updates(&self) -> bool { - self.escrow_hash.is_some() - || self.from_addr.is_some() + self.from_addr.is_some() || self.to_addr.is_some() || self.btc_txid.is_some() || self.status.is_some() @@ -458,12 +373,6 @@ impl InstanceUpdate { || self.post_pegin_txhash.is_some() || self.btc_height.is_some() || self.committees_answers.is_some() - || self.bridge_out_lock_time.is_some() - || self.bridge_out_amount.is_some() - || self.goat_tx_hash.is_some() - || self.goat_tx_height.is_some() - || self.user_change_addr.is_some() - || self.user_refund_addr.is_some() } pub fn get_query_builder(&self, base_sql: &str) -> QueryBuilder { @@ -503,31 +412,6 @@ impl InstanceUpdate { query_builder.set_field("btc_txid", QueryParam::BTCTxid(btc_txid.clone())); } - if let Some(bridge_out_lock_time) = self.bridge_out_lock_time { - query_builder.set_field("bridge_out_lock_time", QueryParam::Int(bridge_out_lock_time)); - } - - if let Some(ref bridge_out_amount) = self.bridge_out_amount { - query_builder - .set_field("bridge_out_amount", QueryParam::Text(bridge_out_amount.clone())); - } - - if let Some(ref goat_tx_hash) = self.goat_tx_hash { - query_builder.set_field("goat_tx_hash", QueryParam::Text(goat_tx_hash.clone())); - } - - if let Some(goat_tx_height) = self.goat_tx_height { - query_builder.set_field("goat_tx_height", QueryParam::Int(goat_tx_height)); - } - - if let Some(ref user_change_addr) = self.user_change_addr { - query_builder.set_field("user_change_addr", QueryParam::Text(user_change_addr.clone())); - } - - if let Some(ref user_refund_addr) = self.user_refund_addr { - query_builder.set_field("user_refund_addr", QueryParam::Text(user_refund_addr.clone())); - } - if let Some(ref committees_answers) = self.committees_answers { let committees_answers = serde_json::to_string(committees_answers) .expect("IndexMap> serialization is infallible"); @@ -539,16 +423,10 @@ impl InstanceUpdate { query_builder.set_field("updated_at", QueryParam::Int(current_time)); // Add WHERE clause - if let Some(ref instance_id) = self.instance_id { - query_builder.and_where( - "hex(instance_id) = ? COLLATE NOCASE ", - Some(QueryParam::Text(hex::encode(instance_id))), - ); - } - if let Some(ref escrow_hash) = self.escrow_hash { - query_builder - .and_where("escrow_hash = ? ", Some(QueryParam::Text(escrow_hash.clone()))); - } + query_builder.and_where( + "hex(instance_id) = ? COLLATE NOCASE ", + Some(QueryParam::Text(hex::encode(self.instance_id))), + ); if let Some(ref statuses) = self.only_if_status_in { if statuses.is_empty() { @@ -560,15 +438,200 @@ impl InstanceUpdate { } } - if let Some(is_bridge_in) = self.only_if_is_bridge_in { - query_builder.and_where("is_bridge_in = ?", Some(QueryParam::Bool(is_bridge_in))); + query_builder + } +} + +/// Filter parameters for listing swap escrows. +#[derive(Clone, Debug, Default)] +pub struct SwapEscrowQuery { + pub offerer_addr: Option, + pub statuses: Vec, + pub order: Option, + pub offset: Option, + pub limit: Option, +} + +impl SwapEscrowQuery { + pub fn with_offerer_addr(mut self, offerer_addr: String) -> Self { + self.offerer_addr = Some(offerer_addr); + self + } + + pub fn with_status(mut self, status: String) -> Self { + self.statuses.push(status); + self + } + + pub fn with_order(mut self, order: String) -> Self { + self.order = Some(order); + self + } + + pub fn with_pagination(mut self, offset: u32, limit: u32) -> Self { + self.offset = Some(offset); + self.limit = Some(limit); + self + } + + pub fn get_query_builder(&self, base_sql: &str) -> QueryBuilder { + let mut query_builder = QueryBuilder::new(base_sql); + if let Some(offerer_addr) = &self.offerer_addr { + query_builder + .and_where("offerer_addr = ?", Some(QueryParam::Text(offerer_addr.clone()))); + } + if !self.statuses.is_empty() { + query_builder.and_where_in("status", &self.statuses, false); } + if let Some(order) = &self.order { + query_builder.apply_order(order); + } + query_builder.apply_pagination(self.limit, self.offset); + query_builder + } +} + +/// Swap escrow field update parameters, keyed by escrow hash. +#[derive(Debug, Clone, Default)] +pub struct SwapEscrowUpdate { + pub escrow_hash: String, + pub status: Option, + pub claimer_addr: Option, + pub btc_addr: Option, + pub token: Option, + pub amount: Option, + pub refund_deadline: Option, + pub escrow_data: Option, + pub init_tx_hash: Option, + pub init_tx_height: Option, + pub claim_tx_hash: Option, + pub claim_btc_txid: Option, + pub refund_tx_hash: Option, + pub only_if_status_in: Option>, +} + +impl SwapEscrowUpdate { + pub fn new(escrow_hash: String) -> Self { + Self { escrow_hash, ..Self::default() } + } + + pub fn with_status(mut self, status: String) -> Self { + self.status = Some(status); + self + } - if let Some(ref goat_tx_hash) = self.only_if_goat_tx_hash { + pub fn with_claimer_addr(mut self, claimer_addr: String) -> Self { + self.claimer_addr = Some(claimer_addr); + self + } + + pub fn with_btc_addr(mut self, btc_addr: String) -> Self { + self.btc_addr = Some(btc_addr); + self + } + + pub fn with_token(mut self, token: String) -> Self { + self.token = Some(token); + self + } + + pub fn with_amount(mut self, amount: String) -> Self { + self.amount = Some(amount); + self + } + + pub fn with_refund_deadline(mut self, refund_deadline: i64) -> Self { + self.refund_deadline = Some(refund_deadline); + self + } + + pub fn with_escrow_data(mut self, escrow_data: String) -> Self { + self.escrow_data = Some(escrow_data); + self + } + + pub fn with_init_tx(mut self, tx_hash: String, tx_height: i64) -> Self { + self.init_tx_hash = Some(tx_hash); + self.init_tx_height = Some(tx_height); + self + } + + pub fn with_claim_tx_hash(mut self, tx_hash: String) -> Self { + self.claim_tx_hash = Some(tx_hash); + self + } + + pub fn with_claim_btc_txid(mut self, txid: SerializableTxid) -> Self { + self.claim_btc_txid = Some(txid); + self + } + + pub fn with_refund_tx_hash(mut self, tx_hash: String) -> Self { + self.refund_tx_hash = Some(tx_hash); + self + } + + /// Apply this update only while the escrow is still in one of the + /// expected states. The condition is folded into the UPDATE statement. + pub fn with_only_if_status_in(mut self, statuses: Vec) -> Self { + self.only_if_status_in = Some(statuses); + self + } + + pub fn get_query_builder(&self, base_sql: &str) -> QueryBuilder { + let mut query_builder = QueryBuilder::update(base_sql); + if let Some(ref status) = self.status { + query_builder.set_field("status", QueryParam::Text(status.clone())); query_builder - .and_where("goat_tx_hash = ?", Some(QueryParam::Text(goat_tx_hash.clone()))); + .set_field("status_updated_at", QueryParam::Int(get_current_timestamp_secs())); + } + if let Some(ref claimer_addr) = self.claimer_addr { + query_builder.set_field("claimer_addr", QueryParam::Text(claimer_addr.clone())); + } + if let Some(ref btc_addr) = self.btc_addr { + query_builder.set_field("btc_addr", QueryParam::Text(btc_addr.clone())); + } + if let Some(ref token) = self.token { + query_builder.set_field("token", QueryParam::Text(token.clone())); + } + if let Some(ref amount) = self.amount { + query_builder.set_field("amount", QueryParam::Text(amount.clone())); + } + if let Some(refund_deadline) = self.refund_deadline { + query_builder.set_field("refund_deadline", QueryParam::Int(refund_deadline)); + } + if let Some(ref escrow_data) = self.escrow_data { + query_builder.set_field("escrow_data", QueryParam::Text(escrow_data.clone())); + } + if let Some(ref init_tx_hash) = self.init_tx_hash { + query_builder.set_field("init_tx_hash", QueryParam::Text(init_tx_hash.clone())); + } + if let Some(init_tx_height) = self.init_tx_height { + query_builder.set_field("init_tx_height", QueryParam::Int(init_tx_height)); + } + if let Some(ref claim_tx_hash) = self.claim_tx_hash { + query_builder.set_field("claim_tx_hash", QueryParam::Text(claim_tx_hash.clone())); + } + if let Some(ref claim_btc_txid) = self.claim_btc_txid { + query_builder.set_field("claim_btc_txid", QueryParam::BTCTxid(claim_btc_txid.clone())); + } + if let Some(ref refund_tx_hash) = self.refund_tx_hash { + query_builder.set_field("refund_tx_hash", QueryParam::Text(refund_tx_hash.clone())); } + query_builder.set_field("updated_at", QueryParam::Int(get_current_timestamp_secs())); + + query_builder + .and_where("escrow_hash = ?", Some(QueryParam::Text(self.escrow_hash.clone()))); + if let Some(ref statuses) = self.only_if_status_in { + if statuses.is_empty() { + // An empty allow-list must reject the update rather than + // silently dropping the compare-and-swap guard. + query_builder.and_where("1 = 0", None); + } else { + query_builder.and_where_in("status", statuses, false); + } + } query_builder } } @@ -901,13 +964,22 @@ impl<'a> StorageProcessor<'a> { let counts = sqlx::query_as::<_, MetricsStateCount>( r#" SELECT - CASE WHEN is_bridge_in THEN 'instance_bridge_in' ELSE 'instance_bridge_out' END AS category, + 'instance' AS category, status AS state, COUNT(*) AS count, MIN(created_at) AS oldest_created_at, NULL AS last_success_at FROM instance - GROUP BY is_bridge_in, status + GROUP BY status + UNION ALL + SELECT + 'swap_escrow' AS category, + status AS state, + COUNT(*) AS count, + MIN(created_at) AS oldest_created_at, + NULL AS last_success_at + FROM swap_escrow + GROUP BY status UNION ALL SELECT 'graph' AS category, @@ -942,30 +1014,31 @@ impl<'a> StorageProcessor<'a> { let snapshot = sqlx::query_as::<_, NodeAlertMetricsSnapshot>( r#" SELECT - MIN(CASE - WHEN is_bridge_in = 1 - AND status NOT IN ( + ( + SELECT MIN(status_updated_at) + FROM instance + WHERE status NOT IN ( 'RelayerL2Minted', 'PresignedFailed', 'RelayerL2MintedFailed', 'Timeout', 'UserCanceled', 'NoEnoughCommitteesAnswered', 'UserDiscarded', 'Failed', 'Success', 'Canceled' - ) - THEN status_updated_at - END) AS pegin_oldest_active_status_updated_at, - MIN(CASE - WHEN is_bridge_in = 1 AND status = 'UserInited' - THEN status_updated_at - END) AS pegin_oldest_committee_wait_status_updated_at, - MIN(CASE - WHEN is_bridge_in = 0 AND status NOT IN ('Claim', 'Timeout', 'Refund') - THEN status_updated_at - END) AS pegout_oldest_active_status_updated_at, + ) + ) AS pegin_oldest_active_status_updated_at, + ( + SELECT MIN(status_updated_at) + FROM instance + WHERE status = 'UserInited' + ) AS pegin_oldest_committee_wait_status_updated_at, + ( + SELECT MIN(status_updated_at) + FROM swap_escrow + WHERE status NOT IN ('Claim', 'Timeout', 'Refund') + ) AS pegout_oldest_active_status_updated_at, ( SELECT available_peg_btc FROM node WHERE peer_id = ? AND actor = 'Operator' LIMIT 1 ) AS operator_available_pegbtc - FROM instance "#, ) .bind(local_peer_id) @@ -1052,12 +1125,11 @@ impl<'a> StorageProcessor<'a> { let committees_answers_json = serde_json::to_string(&instance.committees_answers)?; let res = sqlx::query!( "INSERT OR - REPLACE INTO instance (instance_id, is_bridge_in, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height, + REPLACE INTO instance (instance_id, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height, user_xonly_pubkey, user_change_addr, user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers, - pegin_data_tx_hash, btc_height, parameters, status_updated_at, escrow_hash, bridge_out_lock_time, post_pegin_txhash, bridge_out_amount, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,?)", + pegin_data_tx_hash, btc_height, parameters, status_updated_at, post_pegin_txhash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", instance.instance_id, - instance.is_bridge_in, instance.network, instance.from_addr, instance.to_addr, @@ -1078,10 +1150,7 @@ impl<'a> StorageProcessor<'a> { instance.btc_height, instance.parameters, instance.status_updated_at, - instance.escrow_hash, - instance.bridge_out_lock_time, instance.post_pegin_txhash, - instance.bridge_out_amount, instance.created_at, instance.updated_at ) @@ -1090,53 +1159,92 @@ impl<'a> StorageProcessor<'a> { Ok(res.rows_affected() > 0) } - /// Insert an instance only when its ID is not already present. + /// Create a bridge-in instance from a pegin request, or refresh one that + /// has not moved past the pegin-request stage yet. + /// + /// `PeginRequest` is a re-deliverable P2P message, so `upsert_instance` is + /// unsafe here: its `INSERT OR REPLACE` lets a replayed or forged request + /// roll a live instance back to its initial row and clear everything the + /// later stages wrote. The write is therefore a compare-and-swap over the + /// current status, and it only touches the columns a pegin request owns: + /// committee answers, instance parameters and the BTC-side fields are never + /// overwritten. The caller is responsible for passing canonical request + /// metadata - `goat_tx_hash`/`goat_tx_height` are refreshed from it, so that + /// a re-indexed event can still correct them. /// - /// Creation paths that race with status transitions must use this instead - /// of `upsert_instance`, whose `INSERT OR REPLACE` semantics can restore - /// a stale full row over a newer terminal status. - pub async fn insert_instance_if_absent(&mut self, instance: &Instance) -> anyhow::Result { + /// Returns: + /// - Ok(true) if the row was inserted or updated + /// - Ok(false) if an existing row was left untouched because its status + /// is outside `allowed_statuses` + pub async fn upsert_pegin_request_instance( + &mut self, + instance: &Instance, + allowed_statuses: &[String], + ) -> anyhow::Result { let committees_answers_json = serde_json::to_string(&instance.committees_answers)?; - let res = sqlx::query( + // An empty allow-list must reject the update rather than silently + // dropping the compare-and-swap guard. + let status_guard = if allowed_statuses.is_empty() { + "1 = 0".to_string() + } else { + let placeholders = allowed_statuses.iter().map(|_| "?").collect::>().join(", "); + format!("instance.status IN ({placeholders})") + }; + let sql = format!( "INSERT INTO instance \ - (instance_id, is_bridge_in, network, from_addr, to_addr, amount, fees, input_utxos, \ + (instance_id, network, from_addr, to_addr, amount, fees, input_utxos, \ status, goat_tx_hash, goat_tx_height, user_xonly_pubkey, user_change_addr, \ user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers, \ - pegin_data_tx_hash, btc_height, parameters, status_updated_at, escrow_hash, \ - bridge_out_lock_time, post_pegin_txhash, bridge_out_amount, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ - ON CONFLICT(instance_id) DO NOTHING", - ) - .bind(instance.instance_id) - .bind(instance.is_bridge_in) - .bind(&instance.network) - .bind(&instance.from_addr) - .bind(&instance.to_addr) - .bind(instance.amount) - .bind(instance.fees) - .bind(&instance.input_utxos) - .bind(&instance.status) - .bind(&instance.goat_tx_hash) - .bind(instance.goat_tx_height) - .bind(instance.user_xonly_pubkey) - .bind(&instance.user_change_addr) - .bind(&instance.user_refund_addr) - .bind(&instance.btc_txid) - .bind(&instance.pegin_confirm_txid) - .bind(&instance.pegin_cancel_txid) - .bind(committees_answers_json) - .bind(&instance.pegin_data_tx_hash) - .bind(instance.btc_height) - .bind(&instance.parameters) - .bind(instance.status_updated_at) - .bind(&instance.escrow_hash) - .bind(instance.bridge_out_lock_time) - .bind(&instance.post_pegin_txhash) - .bind(&instance.bridge_out_amount) - .bind(instance.created_at) - .bind(instance.updated_at) - .execute(self.conn()) - .await?; + pegin_data_tx_hash, btc_height, parameters, status_updated_at, \ + post_pegin_txhash, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(instance_id) DO UPDATE SET \ + network = excluded.network, \ + from_addr = CASE WHEN excluded.from_addr = '' \ + THEN instance.from_addr ELSE excluded.from_addr END, \ + to_addr = excluded.to_addr, \ + amount = excluded.amount, \ + fees = excluded.fees, \ + input_utxos = excluded.input_utxos, \ + status = excluded.status, \ + goat_tx_hash = excluded.goat_tx_hash, \ + goat_tx_height = excluded.goat_tx_height, \ + user_xonly_pubkey = excluded.user_xonly_pubkey, \ + user_change_addr = excluded.user_change_addr, \ + user_refund_addr = excluded.user_refund_addr, \ + status_updated_at = excluded.status_updated_at, \ + updated_at = excluded.updated_at \ + WHERE {status_guard}" + ); + let mut query = sqlx::query(&sql) + .bind(instance.instance_id) + .bind(&instance.network) + .bind(&instance.from_addr) + .bind(&instance.to_addr) + .bind(instance.amount) + .bind(instance.fees) + .bind(&instance.input_utxos) + .bind(&instance.status) + .bind(&instance.goat_tx_hash) + .bind(instance.goat_tx_height) + .bind(instance.user_xonly_pubkey) + .bind(&instance.user_change_addr) + .bind(&instance.user_refund_addr) + .bind(&instance.btc_txid) + .bind(&instance.pegin_confirm_txid) + .bind(&instance.pegin_cancel_txid) + .bind(committees_answers_json) + .bind(&instance.pegin_data_tx_hash) + .bind(instance.btc_height) + .bind(&instance.parameters) + .bind(instance.status_updated_at) + .bind(&instance.post_pegin_txhash) + .bind(instance.created_at) + .bind(instance.updated_at); + for status in allowed_statuses { + query = query.bind(status); + } + let res = query.execute(self.conn()).await?; Ok(res.rows_affected() > 0) } @@ -1216,6 +1324,111 @@ impl<'a> StorageProcessor<'a> { } } + /// Insert a swap escrow only when its escrow hash is not already present. + /// + /// The chain-event watcher is the sole writer for Initialize records; + /// the escrow-hash primary key makes event replays idempotent. + pub async fn insert_swap_escrow_if_absent( + &mut self, + escrow: &SwapEscrow, + ) -> anyhow::Result { + let res = sqlx::query!( + "INSERT INTO swap_escrow \ + (escrow_hash, network, status, offerer_addr, claimer_addr, btc_addr, token, amount, \ + refund_deadline, escrow_data, init_tx_hash, init_tx_height, claim_tx_hash, \ + claim_btc_txid, refund_tx_hash, status_updated_at, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(escrow_hash) DO NOTHING", + escrow.escrow_hash, + escrow.network, + escrow.status, + escrow.offerer_addr, + escrow.claimer_addr, + escrow.btc_addr, + escrow.token, + escrow.amount, + escrow.refund_deadline, + escrow.escrow_data, + escrow.init_tx_hash, + escrow.init_tx_height, + escrow.claim_tx_hash, + escrow.claim_btc_txid, + escrow.refund_tx_hash, + escrow.status_updated_at, + escrow.created_at, + escrow.updated_at + ) + .execute(self.conn()) + .await?; + Ok(res.rows_affected() > 0) + } + + /// Find a swap escrow by its (normalized) escrow hash. + pub async fn find_swap_escrow( + &mut self, + escrow_hash: &str, + ) -> anyhow::Result> { + let row = + sqlx::query_as::<_, SwapEscrow>("SELECT * FROM swap_escrow WHERE escrow_hash = ?") + .bind(escrow_hash) + .fetch_optional(self.conn()) + .await?; + Ok(row) + } + + /// Find swap escrows with filtering and pagination. + /// + /// Returns the matching page together with the total match count. + pub async fn find_swap_escrows( + &mut self, + params: SwapEscrowQuery, + ) -> anyhow::Result<(Vec, i64)> { + let mut count_params = params.clone(); + (count_params.order, count_params.offset) = (None, None); + let escrows_query_builder = params.get_query_builder("SELECT * FROM swap_escrow"); + let count_query_builder = + count_params.get_query_builder("SELECT count(*) as total_escrows FROM swap_escrow"); + let sql = escrows_query_builder.get_sql(); + let mut data_query = sqlx::query_as::<_, SwapEscrow>(&sql); + data_query = escrows_query_builder.query_as(data_query); + let count_sql = count_query_builder.get_sql(); + let mut count_query = sqlx::query(&count_sql); + count_query = count_query_builder.query(count_query); + Ok(( + data_query.fetch_all(self.conn()).await?, + count_query.fetch_one(self.conn()).await?.get::("total_escrows"), + )) + } + + /// Update swap escrow fields using the builder pattern. + pub async fn update_swap_escrow(&mut self, params: &SwapEscrowUpdate) -> anyhow::Result { + let query_builder = params.get_query_builder("swap_escrow"); + let update_sql = query_builder.get_sql(); + let mut query = sqlx::query(&update_sql); + query = query_builder.query(query); + let result = query.execute(self.conn()).await?; + Ok(result.rows_affected() > 0) + } + + /// Mark still-initializing swap escrows whose refund deadline has passed + /// as timed out. Returns the number of escrows transitioned. + pub async fn timeout_expired_swap_escrows(&mut self, current_time: i64) -> anyhow::Result { + let initialize_status = SwapEscrowStatus::Initialize.to_string(); + let timeout_status = SwapEscrowStatus::Timeout.to_string(); + let row = sqlx::query!( + "UPDATE swap_escrow SET status = ?, status_updated_at = ?, updated_at = ? \ + WHERE status = ? AND refund_deadline > 0 AND refund_deadline < ?", + timeout_status, + current_time, + current_time, + initialize_status, + current_time + ) + .execute(self.conn()) + .await?; + Ok(row.rows_affected()) + } + /// Update expired instances status /// /// Updates the status of instances that have expired based on a time threshold. @@ -1344,9 +1557,6 @@ impl<'a> StorageProcessor<'a> { if !params.has_updates() { return Ok(false); } - if params.instance_id.is_none() && params.escrow_hash.is_none() { - anyhow::bail!("instance update requires instance_id or escrow_hash"); - } let query_builder = params.get_query_builder("instance"); // Get SQL and parameters let update_sql = query_builder.get_sql(); @@ -2168,9 +2378,8 @@ impl<'a> StorageProcessor<'a> { Ok(res) } - pub async fn get_sum_bridge_txn( + pub async fn get_sum_bridge_in_txn( &mut self, - is_bridge_in: bool, statuses: &[String], ) -> anyhow::Result<(i64, i64)> { #[derive(sqlx::FromRow)] @@ -2182,8 +2391,7 @@ impl<'a> StorageProcessor<'a> { let query_str = format!( "SELECT SUM(amount) AS total, COUNT(*) AS tx_count FROM instance - WHERE is_bridge_in = {} AND status IN ({})", - is_bridge_in, + WHERE status IN ({})", create_place_holders(statuses) ); let mut query = sqlx::query_as::<_, BridgeInRow>(&query_str); @@ -2864,6 +3072,40 @@ impl<'a> StorageProcessor<'a> { Ok(result.rows_affected() > 0) } + /// Queue a graph-setup notification for a bounded retransmission window. + /// + /// A non-empty `ack_peer_id` is the only ACK that may stop this retry + /// loop early. Broadcast messages without an authenticated recipient keep + /// publishing until their window expires. + pub async fn enqueue_p2p_outbox_retry_message( + &mut self, + message_id: &str, + msg_type: &str, + content: &[u8], + retry_until: i64, + retry_interval_secs: i64, + ack_peer_id: Option<&str>, + ) -> anyhow::Result { + let now = get_current_timestamp_secs(); + let result = sqlx::query( + "INSERT INTO p2p_outbox \ + (message_id, msg_type, content, state, attempt_count, next_retry_at, lease_until, retry_until, retry_interval_secs, ack_peer_id, created_at, updated_at) \ + VALUES (?, ?, ?, 'Pending', 0, 0, 0, ?, ?, ?, ?, ?) \ + ON CONFLICT(message_id) DO NOTHING", + ) + .bind(message_id) + .bind(msg_type) + .bind(content) + .bind(retry_until) + .bind(retry_interval_secs) + .bind(ack_peer_id.unwrap_or_default()) + .bind(now) + .bind(now) + .execute(self.conn()) + .await?; + Ok(result.rows_affected() > 0) + } + pub async fn claim_p2p_outbox_messages( &mut self, now: i64, @@ -2871,14 +3113,16 @@ impl<'a> StorageProcessor<'a> { limit: i64, ) -> anyhow::Result> { let rows = sqlx::query( - "SELECT message_id, msg_type, content, state, attempt_count, next_retry_at, lease_until, last_error, created_at \ + "SELECT message_id, msg_type, content, state, attempt_count, next_retry_at, lease_until, last_error, retry_until, retry_interval_secs, ack_peer_id, created_at \ FROM p2p_outbox \ - WHERE (state = 'Pending' AND next_retry_at <= ?) \ + WHERE ((state = 'Pending' AND next_retry_at <= ?) \ OR (state = 'Processing' AND lease_until <= ?) \ + ) AND (retry_until = 0 OR retry_until > ?) \ ORDER BY created_at ASC LIMIT ?", ) .bind(now) .bind(now) + .bind(now) .bind(limit) .fetch_all(self.conn()) .await?; @@ -2907,6 +3151,71 @@ impl<'a> StorageProcessor<'a> { Ok(claimed) } + /// End a bounded-retry message once its delivery window is exhausted. + pub async fn expire_p2p_outbox_retry_messages(&mut self, now: i64) -> anyhow::Result { + let result = sqlx::query( + "UPDATE p2p_outbox SET state = 'RetryExhausted', content = X'', lease_until = 0, next_retry_at = 0, \ + retry_until = 0, retry_interval_secs = 0, ack_peer_id = '', \ + last_error = 'retry window expired without expected ACK', updated_at = ? \ + WHERE retry_until > 0 AND retry_until <= ? AND state IN ('Pending', 'Processing')", + ) + .bind(now) + .bind(now) + .execute(self.conn()) + .await?; + Ok(result.rows_affected()) + } + + pub async fn schedule_p2p_outbox_retry( + &mut self, + message_id: &str, + next_retry_at: i64, + ) -> anyhow::Result { + let result = sqlx::query( + "UPDATE p2p_outbox SET state = 'Pending', lease_until = 0, next_retry_at = ?, updated_at = ? \ + WHERE message_id = ? AND state = 'Processing' AND retry_until > 0", + ) + .bind(next_retry_at) + .bind(get_current_timestamp_secs()) + .bind(message_id) + .execute(self.conn()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn acknowledge_p2p_outbox_message( + &mut self, + message_id: &str, + peer_id: &str, + ) -> anyhow::Result { + let result = sqlx::query( + "UPDATE p2p_outbox SET state = 'Processed', content = X'', lease_until = 0, next_retry_at = 0, updated_at = ? \ + WHERE message_id = ? AND retry_until > 0 AND ack_peer_id = ? \ + AND state IN ('Pending', 'Processing')", + ) + .bind(get_current_timestamp_secs()) + .bind(message_id) + .bind(peer_id) + .execute(self.conn()) + .await?; + Ok(result.rows_affected() > 0) + } + + /// Stop a local setup notification once a later protocol step makes it + /// obsolete and release its potentially large payload immediately. + pub async fn cancel_p2p_outbox_message(&mut self, message_id: &str) -> anyhow::Result { + let result = sqlx::query( + "UPDATE p2p_outbox SET state = 'Cancelled', content = X'', lease_until = 0, next_retry_at = 0, \ + updated_at = ? \ + WHERE message_id = ? AND state IN ('Pending', 'Processing')", + ) + .bind(get_current_timestamp_secs()) + .bind(message_id) + .execute(self.conn()) + .await?; + Ok(result.rows_affected() > 0) + } + pub async fn complete_p2p_outbox_message(&mut self, message_id: &str) -> anyhow::Result { let result = sqlx::query( "UPDATE p2p_outbox SET state = 'Processed', content = X'', lease_until = 0, next_retry_at = 0, updated_at = ? \ @@ -4179,13 +4488,140 @@ mod tests { create_local_db("sqlite::memory:").await } + fn pegin_instance(instance_id: Uuid, status: &str) -> Instance { + Instance { + instance_id, + network: "testnet".to_string(), + input_utxos: "[]".to_string(), + status: status.to_string(), + created_at: 100, + updated_at: 100, + ..Default::default() + } + } + + fn pegin_request_statuses() -> Vec { + vec![ + crate::InstanceBridgeInStatus::UserIniting.to_string(), + crate::InstanceBridgeInStatus::UserInited.to_string(), + ] + } + + #[tokio::test] + async fn test_upsert_pegin_request_instance_initializes_and_promotes() { + let db = setup_db().await; + let mut s = db.acquire().await.unwrap(); + let instance_id = Uuid::new_v4(); + + let mut initing = pegin_instance(instance_id, "UserIniting"); + initing.to_addr = "0xuser".to_string(); + initing.from_addr = "bcrt1quser".to_string(); + assert!(s.upsert_instance(&initing).await.unwrap()); + + let mut request = pegin_instance(instance_id, "UserInited"); + request.to_addr = "0xuser".to_string(); + request.amount = 10_000; + request.goat_tx_hash = "0xrequest".to_string(); + request.goat_tx_height = 500; + request.status_updated_at = 200; + request.created_at = 999; + request.updated_at = 999; + assert!( + s.upsert_pegin_request_instance(&request, &pegin_request_statuses()).await.unwrap() + ); + + let stored = s.find_instance(&instance_id).await.unwrap().unwrap(); + assert_eq!(stored.status, "UserInited"); + assert_eq!(stored.amount, 10_000); + assert_eq!(stored.goat_tx_hash, "0xrequest"); + assert_eq!(stored.goat_tx_height, 500); + assert_eq!(stored.status_updated_at, 200); + // An undecodable from_addr must not erase the one the request tag stored. + assert_eq!(stored.from_addr, "bcrt1quser"); + // created_at belongs to whoever created the row. + assert_eq!(stored.created_at, 100); + } + + #[tokio::test] + async fn test_upsert_pegin_request_instance_replay_keeps_accrued_state() { + let db = setup_db().await; + let mut s = db.acquire().await.unwrap(); + let instance_id = Uuid::new_v4(); + + let mut inited = pegin_instance(instance_id, "UserInited"); + inited.goat_tx_hash = "0xrequest".to_string(); + inited.goat_tx_height = 500; + inited.committees_answers = IndexMap::from([("0xcommittee".to_string(), vec![1u8, 2, 3])]); + inited.parameters = Some("{}".to_string()); + assert!(s.upsert_instance(&inited).await.unwrap()); + + // A re-delivered request refreshes the row in place; everything the + // instance accrued after the request must survive it. + let mut replay = pegin_instance(instance_id, "UserInited"); + replay.goat_tx_hash = "0xrequest".to_string(); + replay.goat_tx_height = 500; + assert!(s.upsert_pegin_request_instance(&replay, &pegin_request_statuses()).await.unwrap()); + let stored = s.find_instance(&instance_id).await.unwrap().unwrap(); + assert_eq!(stored.committees_answers.get("0xcommittee"), Some(&vec![1u8, 2, 3])); + assert_eq!(stored.parameters, Some("{}".to_string())); + assert_eq!(stored.goat_tx_height, 500); + } + + #[tokio::test] + async fn test_upsert_pegin_request_instance_rejects_regression() { + let db = setup_db().await; + let mut s = db.acquire().await.unwrap(); + let instance_id = Uuid::new_v4(); + + let mut minted = pegin_instance(instance_id, "RelayerL2Minted"); + minted.goat_tx_hash = "0xrequest".to_string(); + minted.goat_tx_height = 500; + minted.parameters = Some("{}".to_string()); + minted.post_pegin_txhash = Some("0xmint".to_string()); + assert!(s.upsert_instance(&minted).await.unwrap()); + + // Once the instance moves on, a re-delivered request may not pull it back. + let replay = pegin_instance(instance_id, "UserInited"); + assert!( + !s.upsert_pegin_request_instance(&replay, &pegin_request_statuses()).await.unwrap() + ); + let stored = s.find_instance(&instance_id).await.unwrap().unwrap(); + assert_eq!(stored.status, "RelayerL2Minted"); + assert_eq!(stored.parameters, Some("{}".to_string())); + assert_eq!(stored.post_pegin_txhash, Some("0xmint".to_string())); + assert_eq!(stored.goat_tx_height, 500); + } + + #[tokio::test] + async fn test_upsert_pegin_request_instance_rejects_bridge_out_collision() { + let db = setup_db().await; + let mut s = db.acquire().await.unwrap(); + let instance_id = Uuid::new_v4(); + + let bridge_out = pegin_instance(instance_id, "Initialize"); + assert!(s.upsert_instance(&bridge_out).await.unwrap()); + + let request = pegin_instance(instance_id, "UserInited"); + assert!( + !s.upsert_pegin_request_instance(&request, &pegin_request_statuses()).await.unwrap() + ); + let stored = s.find_instance(&instance_id).await.unwrap().unwrap(); + assert_eq!(stored.status, "Initialize"); + } + #[tokio::test] async fn test_node_metrics_state_counts() { let db = setup_db().await; let mut s = db.acquire().await.unwrap(); sqlx::query( - "INSERT INTO instance (instance_id, is_bridge_in, status, created_at, updated_at) VALUES ('in-1', 1, 'Pending', 20, 20), ('in-2', 1, 'Pending', 10, 10), ('out-1', 0, 'Completed', 30, 30)", + "INSERT INTO instance (instance_id, status, created_at, updated_at) VALUES ('in-1', 'Pending', 20, 20), ('in-2', 'Pending', 10, 10)", + ) + .execute(s.conn()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO swap_escrow (escrow_hash, status, created_at, updated_at) VALUES ('0xaa', 'Initialize', 30, 30)", ) .execute(s.conn()) .await @@ -4205,17 +4641,23 @@ mod tests { let counts = s.node_metrics_state_counts().await.unwrap(); assert_eq!( - counts.iter().find(|count| { - count.category == "instance_bridge_in" && count.state == "Pending" - }), + counts.iter().find(|count| count.category == "instance" && count.state == "Pending"), Some(&MetricsStateCount { - category: "instance_bridge_in".to_string(), + category: "instance".to_string(), state: "Pending".to_string(), count: 2, oldest_created_at: Some(10), last_success_at: None, }) ); + assert_eq!( + counts + .iter() + .find(|count| count.category == "swap_escrow" && count.state == "Initialize") + .unwrap() + .count, + 1 + ); assert_eq!( counts .iter() diff --git a/crates/store/src/schema.rs b/crates/store/src/schema.rs index 73c4d82d6..21319df28 100644 --- a/crates/store/src/schema.rs +++ b/crates/store/src/schema.rs @@ -219,8 +219,13 @@ pub enum InstanceBridgeInStatus { Canceled, // UserCanceled } +/// Lifecycle of a swap-based bridge-out escrow. +/// +/// `Initialize`, `Claim`, and `Refund` mirror the swap contract events; +/// `Timeout` is derived locally when the refund deadline passes without an +/// observed claim. A later on-chain Claim/Refund event still overrides it. #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Display, EnumString)] -pub enum InstanceBridgeOutStatus { +pub enum SwapEscrowStatus { #[default] Initialize, Claim, @@ -228,23 +233,56 @@ pub enum InstanceBridgeOutStatus { Refund, } +/// Normalize an escrow hash to its canonical storage form: +/// lowercase, 0x-prefixed 32-byte hex. +pub fn normalize_escrow_hash(escrow_hash: &str) -> String { + let stripped = escrow_hash + .strip_prefix("0x") + .or_else(|| escrow_hash.strip_prefix("0X")) + .unwrap_or(escrow_hash); + format!("0x{}", stripped.to_ascii_lowercase()) +} + +/// A swap-based bridge-out escrow, keyed by the on-chain escrow hash. +/// Fully independent of the BitVM `Instance`/`Graph` flow. +#[derive(Clone, FromRow, Debug, Serialize, Deserialize, Default)] +pub struct SwapEscrow { + pub escrow_hash: String, // 0x-prefixed 32-byte hex, lowercase + pub network: String, + pub status: String, // SwapEscrowStatus + pub offerer_addr: String, // goat account funding the escrow + pub claimer_addr: String, // goat account entitled to claim + pub btc_addr: String, // btc address receiving the payout + pub token: String, // escrow token contract address + pub amount: String, // escrow amount, U256 decimal string + pub refund_deadline: i64, // unix secs after which refund is possible + pub escrow_data: Option, // hex abi-encoded EscrowData from the Initialize tx + pub init_tx_hash: String, // swap Initialize goat tx + pub init_tx_height: i64, + pub claim_tx_hash: String, // swap Claim goat tx + pub claim_btc_txid: Option, // btc payout tx committed by the claim + pub refund_tx_hash: String, // swap Refund goat tx + pub status_updated_at: i64, + pub created_at: i64, + pub updated_at: i64, +} + #[derive(Clone, FromRow, Debug, Serialize, Deserialize, Default)] pub struct Instance { pub instance_id: Uuid, - pub is_bridge_in: bool, pub network: String, pub from_addr: String, pub to_addr: String, // goat deposit addr pub amount: i64, pub fees: UInt64Array3, pub input_utxos: String, // init should been [] - pub status: String, // InstanceBridgeInStatus | InstanceBridgeOutStatus - pub goat_tx_hash: String, // bridgeIn:pegin Request tx || bridgeOut goat tx + pub status: String, // InstanceBridgeInStatus + pub goat_tx_hash: String, // pegin Request tx pub goat_tx_height: i64, pub user_xonly_pubkey: ByteArray32, pub user_change_addr: String, pub user_refund_addr: String, - pub btc_txid: Option, // bridgeIn: Pegin Prepare Request tx || bridgeOut Btc tx + pub btc_txid: Option, // Pegin Prepare Request tx pub btc_height: i64, pub pegin_confirm_txid: Option, // btc txid pub pegin_cancel_txid: Option, // btc txid @@ -252,10 +290,7 @@ pub struct Instance { pub committees_answers: IndexMap>, pub pegin_data_tx_hash: String, pub parameters: Option, - pub escrow_hash: Option, - pub bridge_out_lock_time: i64, pub post_pegin_txhash: Option, - pub bridge_out_amount: String, pub status_updated_at: i64, pub created_at: i64, pub updated_at: i64, @@ -565,6 +600,9 @@ pub struct P2pOutboxMessage { pub next_retry_at: i64, pub lease_until: i64, pub last_error: Option, + pub retry_until: i64, + pub retry_interval_secs: i64, + pub ack_peer_id: String, pub created_at: i64, } @@ -659,10 +697,12 @@ pub enum MessageType { SolderingProof, VerifierGraphParamsEndorsement, NonceGeneration, + AggNonceConsensus, CommitteePresign, GraphFinalize, EndorseGraph, PeginConfirmNonce, + PeginConfirmNonceConsensus, PeginConfirmPartialSig, PostReady, KickoffReady, @@ -728,10 +768,6 @@ pub enum GoatTxType { WithdrawHappyPath, WithdrawUnhappyPath, WithdrawDisproved, - // swap - SwapInitialize, - SwapClaim, - SwapRefund, } #[derive(Clone, Debug, Serialize, Deserialize, Default, Display, EnumString)] diff --git a/deployment/README.md b/deployment/README.md index 2126dd236..62b453515 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -69,10 +69,29 @@ Configure the `.env` file for the specific role and run the node. **Common Arguments:** - `--rpc-addr `: Address for the RPC server (default: `0.0.0.0:8080`) +- `--metrics-addr `: Address for the dedicated Prometheus listener (disabled when omitted) +- `--metrics-path `: Path served by the metrics listener (default: `/metrics`) - `--db-path `: Path to the SQLite database (default: `sqlite:/tmp/bitvm-node.db`) - `--p2p-port `: Port for P2P communication (default: `0`) - `--bootnodes `: Bootnodes to connect to +Bind metrics to a private interface and use a unique port for every node running +on the same host. The business RPC listener does not serve metrics, so +`GET /metrics` on `--rpc-addr` returns `404`. For remote Prometheus servers, +expose the dedicated listener through a private network, proxy, or access-control +layer instead of binding it directly to a public interface. + +The provided Docker Compose deployment enables the listener on port `9108` +inside each container and publishes it only on the host loopback interface: + +| Service | Host metrics endpoint | +|---------|-----------------------| +| `committee_0` | `http://127.0.0.1:9900/metrics` | +| `committee_1` | `http://127.0.0.1:9901/metrics` | +| `verifier_0` | `http://127.0.0.1:9902/metrics` | +| `operator_0` | `http://127.0.0.1:9903/metrics` | +| `watchtower_0` | `http://127.0.0.1:9904/metrics` | + ### Committee The Committee is responsible for signing presign transactions. @@ -113,6 +132,7 @@ GOAT_SWAP_EVENT_FILTER_FROM=9368978 ```bash ./target/release/bitvm-noded \ --rpc-addr 0.0.0.0:9100 \ + --metrics-addr 127.0.0.1:9108 \ --p2p-port 8443 \ --db-path ./committee.db ``` @@ -148,6 +168,7 @@ BOOTNODES=... # empty if this is the first node ```bash ./target/release/bitvm-noded \ --rpc-addr 0.0.0.0:9100 \ + --metrics-addr 127.0.0.1:9108 \ --p2p-port 8443 \ --db-path ./operator.db ``` @@ -183,6 +204,7 @@ BOOTNODES=... # empty if this is the first node ```bash ./target/release/bitvm-noded \ --rpc-addr 0.0.0.0:9100 \ + --metrics-addr 127.0.0.1:9108 \ --p2p-port 8443 \ --db-path ./challenger.db ``` @@ -217,6 +239,7 @@ BOOTNODES=... # empty if this is the first node ```bash ./target/release/bitvm-noded \ --rpc-addr 0.0.0.0:9100 \ + --metrics-addr 127.0.0.1:9108 \ --p2p-port 8443 \ --db-path ./watchtower.db ``` diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 3ec15d301..e2333c25e 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -8,12 +8,15 @@ services: volumes: - ./committee_0:/var/data - ./committee_0/.env:/app/.env - ports: - - "8900:9100" - - "8443:8443" env_file: - ./committee_0/.env + environment: + METRICS_ADDR: "0.0.0.0:9108" restart: unless-stopped + ports: + - "8900:9100" + - "8443:8443" + - "127.0.0.1:9900:9108" committee_1: image: ghcr.io/goatnetwork/bitvm-node:${TAG:-0.1.0} @@ -24,8 +27,11 @@ services: ports: - "8901:9100" - "8444:8443" + - "127.0.0.1:9901:9108" env_file: - ./committee_1/.env + environment: + METRICS_ADDR: "0.0.0.0:9108" restart: unless-stopped depends_on: - committee_0 @@ -39,8 +45,11 @@ services: ports: - "8902:9100" - "8445:8443" + - "127.0.0.1:9902:9108" env_file: - ./verifier_0/.env + environment: + METRICS_ADDR: "0.0.0.0:9108" restart: unless-stopped depends_on: - committee_0 @@ -54,8 +63,11 @@ services: ports: - "8903:9100" - "8456:8443" + - "127.0.0.1:9903:9108" env_file: - ./operator_0/.env + environment: + METRICS_ADDR: "0.0.0.0:9108" restart: unless-stopped depends_on: - committee_0 @@ -69,8 +81,11 @@ services: ports: - "8904:9100" - "8457:8443" + - "127.0.0.1:9904:9108" env_file: - ./watchtower_0/.env + environment: + METRICS_ADDR: "0.0.0.0:9108" restart: unless-stopped depends_on: - - committee_0 \ No newline at end of file + - committee_0 diff --git a/deployment/regtest/bitvm-noded/committee_0/run.sh b/deployment/regtest/bitvm-noded/committee_0/run.sh index 0ce409bb3..a074600cb 100644 --- a/deployment/regtest/bitvm-noded/committee_0/run.sh +++ b/deployment/regtest/bitvm-noded/committee_0/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 0.0.0.0:8900 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8444 >$PWD/$(date +'%Y%m%d').log 2>&1 & \ No newline at end of file +nohup ../bitvm-noded --rpc-addr 0.0.0.0:8900 --metrics-addr 127.0.0.1:9900 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8444 >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/deployment/regtest/bitvm-noded/committee_1/run.sh b/deployment/regtest/bitvm-noded/committee_1/run.sh index 73c64c84d..5dd31c89f 100644 --- a/deployment/regtest/bitvm-noded/committee_1/run.sh +++ b/deployment/regtest/bitvm-noded/committee_1/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 127.0.0.1:8901 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8445 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & \ No newline at end of file +nohup ../bitvm-noded --rpc-addr 127.0.0.1:8901 --metrics-addr 127.0.0.1:9901 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8445 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/deployment/regtest/bitvm-noded/operator_0/run.sh b/deployment/regtest/bitvm-noded/operator_0/run.sh index fe8e5c7e7..e0c2a412a 100644 --- a/deployment/regtest/bitvm-noded/operator_0/run.sh +++ b/deployment/regtest/bitvm-noded/operator_0/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 127.0.0.1:8902 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8446 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & \ No newline at end of file +nohup ../bitvm-noded --rpc-addr 127.0.0.1:8902 --metrics-addr 127.0.0.1:9903 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8446 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/deployment/regtest/bitvm-noded/verifier_0/run.sh b/deployment/regtest/bitvm-noded/verifier_0/run.sh index cb09ad860..e0fc8a417 100644 --- a/deployment/regtest/bitvm-noded/verifier_0/run.sh +++ b/deployment/regtest/bitvm-noded/verifier_0/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 127.0.0.1:8906 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8450 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & \ No newline at end of file +nohup ../bitvm-noded --rpc-addr 127.0.0.1:8906 --metrics-addr 127.0.0.1:9902 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8450 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/deployment/regtest/bitvm-noded/watchtower_0/run.sh b/deployment/regtest/bitvm-noded/watchtower_0/run.sh index 740b59475..3cbc2b737 100644 --- a/deployment/regtest/bitvm-noded/watchtower_0/run.sh +++ b/deployment/regtest/bitvm-noded/watchtower_0/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 127.0.0.1:8904 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8448 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & \ No newline at end of file +nohup ../bitvm-noded --rpc-addr 127.0.0.1:8904 --metrics-addr 127.0.0.1:9904 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8448 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/deployment/testnet4/bitvm-noded/committee_0/run.sh b/deployment/testnet4/bitvm-noded/committee_0/run.sh index eea944bf2..cbac102c5 100644 --- a/deployment/testnet4/bitvm-noded/committee_0/run.sh +++ b/deployment/testnet4/bitvm-noded/committee_0/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 127.0.0.1:8902 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8445 >$PWD/$(date +'%Y%m%d').log 2>&1 & +nohup ../bitvm-noded --rpc-addr 127.0.0.1:8902 --metrics-addr 127.0.0.1:9900 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8445 >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/deployment/testnet4/bitvm-noded/committee_1/run.sh b/deployment/testnet4/bitvm-noded/committee_1/run.sh index b5ac2a6f9..15356a59f 100644 --- a/deployment/testnet4/bitvm-noded/committee_1/run.sh +++ b/deployment/testnet4/bitvm-noded/committee_1/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 127.0.0.1:8901 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8444 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & \ No newline at end of file +nohup ../bitvm-noded --rpc-addr 127.0.0.1:8901 --metrics-addr 127.0.0.1:9901 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8444 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/deployment/testnet4/bitvm-noded/operator_0/run.sh b/deployment/testnet4/bitvm-noded/operator_0/run.sh index 2d7c33397..85e6f32e4 100644 --- a/deployment/testnet4/bitvm-noded/operator_0/run.sh +++ b/deployment/testnet4/bitvm-noded/operator_0/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 127.0.0.1:8902 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8445 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & \ No newline at end of file +nohup ../bitvm-noded --rpc-addr 127.0.0.1:8902 --metrics-addr 127.0.0.1:9903 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8445 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/deployment/testnet4/bitvm-noded/verifier_0/run.sh b/deployment/testnet4/bitvm-noded/verifier_0/run.sh index 2d0b213c1..e977a1557 100644 --- a/deployment/testnet4/bitvm-noded/verifier_0/run.sh +++ b/deployment/testnet4/bitvm-noded/verifier_0/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 127.0.0.1:8906 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8449 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & +nohup ../bitvm-noded --rpc-addr 127.0.0.1:8906 --metrics-addr 127.0.0.1:9902 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8449 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/deployment/testnet4/bitvm-noded/watchtower_0/run.sh b/deployment/testnet4/bitvm-noded/watchtower_0/run.sh index a83ceea0f..03c9dec1b 100644 --- a/deployment/testnet4/bitvm-noded/watchtower_0/run.sh +++ b/deployment/testnet4/bitvm-noded/watchtower_0/run.sh @@ -1 +1 @@ -nohup ../bitvm-noded --rpc-addr 127.0.0.1:8904 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8447 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & \ No newline at end of file +nohup ../bitvm-noded --rpc-addr 127.0.0.1:8904 --metrics-addr 127.0.0.1:9904 --db-path sqlite:$PWD/bitvm-node.db --p2p-port 8447 --bootnodes $bootnode_urls >$PWD/$(date +'%Y%m%d').log 2>&1 & diff --git a/node/Cargo.toml b/node/Cargo.toml index e34be8c1b..e1359f364 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -18,6 +18,7 @@ path = "src/bin/send_challenge.rs" [[bin]] name = "verifier-challenge" path = "src/bin/send_verifier_challenge.rs" +required-features = ["rpc-debug-endpoints"] [[bin]] name = "sequencer-set-publish" @@ -31,10 +32,6 @@ path = "src/bin/send_rbf.rs" name = "pegout" path = "src/bin/send_pegout.rs" -[[bin]] -name = "bridge-out" -path = "src/bin/send_bridge_out.rs" - [[bin]] name = "update-db" path = "src/bin/db_inject.rs" @@ -43,6 +40,10 @@ path = "src/bin/db_inject.rs" name = "mock-rpc" path = "src/bin/mock_rpc.rs" +[features] +default = [] +rpc-debug-endpoints = [] + [dependencies] libp2p = { workspace = true, features = [ "autonat", diff --git a/node/README.md b/node/README.md index c5dbef5f5..8cff4628b 100644 --- a/node/README.md +++ b/node/README.md @@ -707,7 +707,7 @@ flowchart TB IM1["instance_answers_monitor"] IM2["instance_window_expiration_monitor"] IM3["instance_btc_tx_monitor"] - IM4["instance_bridge_out_monitor"] + IM4["swap_escrow_timeout_monitor"] IM5["instance_committee_key_cleanup_monitor"] end @@ -734,7 +734,7 @@ flowchart TB | `instance_answers_monitor` | 20s | Track committee responses | | `instance_window_expiration_monitor` | 20s | Handle response window timeouts | | `instance_btc_tx_monitor` | 20s | Track pegin/confirm/cancel BTC transaction confirmations | -| `instance_bridge_out_monitor` | 20s | Track bridge-out deadlines and timeout transitions | +| `swap_escrow_timeout_monitor` | 20s | Mark swap escrows past their refund deadline as timed out | | `instance_committee_key_cleanup_monitor` | 20s | Scan `cache/committee-instance-keys/` and delete expired key envelopes after configurable pegin-confirm timelock | | `spv_header_hash_update` | Periodic | Update SPV header hashes | @@ -814,6 +814,7 @@ bitvm-noded key funding-address ```bash bitvm-noded \ --rpc-addr 0.0.0.0:8080 \ + --metrics-addr 127.0.0.1:9108 \ --db-path ./node.db \ --p2p-port 4001 \ --bootnodes /ip4/x.x.x.x/tcp/4001/p2p/ @@ -833,7 +834,8 @@ Useful test calls: ```bash curl http://127.0.0.1:18080/v1/nodes/overview -curl 'http://127.0.0.1:18080/v1/instances?is_bridge_in=true' +curl http://127.0.0.1:18080/v1/instances +curl http://127.0.0.1:18080/v1/swaps curl http://127.0.0.1:18080/v1/graphs ``` @@ -849,7 +851,8 @@ real graph raw data in the database. | `--db-path` | SQLite database path | `sqlite:/tmp/bitvm-node.db` | | `--p2p-port` | P2P listen port | `0` (random) | | `--bootnodes` | Bootstrap node addresses | - | -| `--metrics-path` | Prometheus metrics endpoint | `/metrics` | +| `--metrics-addr` | Dedicated Prometheus listener address | Disabled | +| `--metrics-path` | Path served by the dedicated metrics listener | `/metrics` | | `--enable-kademlia` | Enable Kademlia DHT | `true` | --- @@ -898,6 +901,10 @@ Relayer nodes should: ## RPC API +Prometheus metrics are not served by the business RPC listener. Set +`--metrics-addr` to a private, process-unique address and scrape +`http:///metrics`; `GET /metrics` on `--rpc-addr` returns `404`. + ### Endpoints | Endpoint | Method | Description | @@ -914,9 +921,10 @@ Relayer nodes should: | `/v1/graphs/:id/tx?tx_name=cur-pre-kickoff.hex` | GET | Get specific transaction hex | | `/v1/graphs/ready-to-kickoff` | GET | Get graphs ready for kickoff | | `/bridge_in_request` | POST | Initiate Bridge-In request | -| `/bridge_out_init` | POST | Initiate Bridge-Out request | +| `/v1/swaps` | GET | List swap bridge-out escrows | +| `/v1/swaps/:escrow_hash` | GET | Get swap escrow details | | `/challenge` | POST | Submit challenge | -| `/metrics` | GET | Prometheus metrics | +| `/metrics` | GET | Prometheus metrics (dedicated metrics listener only) | --- diff --git a/node/entrypoint.sh b/node/entrypoint.sh index 4e4d6b66c..b165f698d 100755 --- a/node/entrypoint.sh +++ b/node/entrypoint.sh @@ -1,8 +1,13 @@ #!/bin/bash -bn="" -if [ -n "$BOOTNODES" ]; then - bn="--bootnodes $BOOTNODES" +args=(--rpc-addr 0.0.0.0:9100 --db-path /var/data/bitvm-node-0.db --p2p-port 8443) + +if [ -n "${BOOTNODES:-}" ]; then + args+=(--bootnodes "$BOOTNODES") +fi + +if [ -n "${METRICS_ADDR:-}" ]; then + args+=(--metrics-addr "$METRICS_ADDR") fi -bitvm-noded --rpc-addr 0.0.0.0:9100 --db-path /var/data/bitvm-node-0.db --p2p-port 8443 $bn +exec bitvm-noded "${args[@]}" diff --git a/node/src/action.rs b/node/src/action.rs index eee7d4322..7628f734b 100644 --- a/node/src/action.rs +++ b/node/src/action.rs @@ -2,7 +2,10 @@ #![allow(clippy::single_match)] #![allow(clippy::collapsible_else_if)] -use crate::env::{get_local_node_info, get_p2p_inbox_batch_size, get_p2p_outbox_batch_size}; +use crate::env::{ + get_local_node_info, get_p2p_graph_setup_retry_interval_secs, + get_p2p_graph_setup_retry_window_secs, get_p2p_inbox_batch_size, get_p2p_outbox_batch_size, +}; use crate::handle::{ HandlerContext, HeavyTaskContext, dispatch as handle_dispatch, heavy_task_from_content, is_heavy_task_message_type, run_heavy_task, @@ -13,7 +16,7 @@ use crate::rpc_service::current_time_secs; use crate::utils::*; use alloy::primitives::Address as EvmAddress; use anyhow::{Context, Result, anyhow, bail}; -use bitcoin::{PublicKey, Txid}; +use bitcoin::{PublicKey, Txid, XOnlyPublicKey}; use bitvm_lib::actors::Actor; use bitvm_lib::babe_adapter::{BabeBundleBuilder, CACSetupPackage}; use bitvm_lib::committee::*; @@ -27,8 +30,11 @@ use client::{ use libp2p::gossipsub::MessageId; use libp2p::{PeerId, Swarm, gossipsub}; use musig2::{PartialSignature, PubNonce}; -use secp256k1::schnorr::Signature as SchnorrSignature; +use secp256k1::{ + Keypair, Message as SecpMessage, SECP256K1, schnorr::Signature as SchnorrSignature, +}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::fmt; use std::str::FromStr; use std::sync::{Arc, LazyLock, Mutex}; @@ -221,6 +227,7 @@ pub enum MessageDeferReason { RecoveryRepublish, PreviousGraphPending, CommitteeNoncesPending, + CommitteeNonceConsensusPending, CommitteeEndorsementsPending, BitcoinTransactionPending, BitcoinConfirmationPending, @@ -242,6 +249,7 @@ impl MessageDeferReason { Self::RecoveryRepublish => "recovery_republish", Self::PreviousGraphPending => "previous_graph_pending", Self::CommitteeNoncesPending => "committee_nonces_pending", + Self::CommitteeNonceConsensusPending => "committee_nonce_consensus_pending", Self::CommitteeEndorsementsPending => "committee_endorsements_pending", Self::BitcoinTransactionPending => "bitcoin_transaction_pending", Self::BitcoinConfirmationPending => "bitcoin_confirmation_pending", @@ -267,12 +275,15 @@ pub enum GOATMessageContent { GenCircuits(GenCircuits), CutCircuits(CutCircuits), SolderingProofReady(SolderingProofReady), + GraphSetupAck(GraphSetupAck), VerifierGraphParamsEndorsement(VerifierGraphParamsEndorsement), NonceGeneration(NonceGeneration), + AggNonceConsensus(AggNonceConsensus), CommitteePresign(CommitteePresign), EndorseGraph(EndorseGraph), GraphFinalize(GraphFinalize), PeginConfirmNonce(PeginConfirmNonce), + PeginConfirmNonceConsensus(PeginConfirmNonceConsensus), PeginConfirmPartialSig(PeginConfirmPartialSig), PostReady(PostReady), KickoffReady(KickoffReady), @@ -310,7 +321,8 @@ impl GOATMessageContent { Self::RequestNodeInfo(_) | Self::ResponseNodeInfo(_) | Self::SyncGraphRequest(_) - | Self::SyncGraph(_) => P2PMessageDelivery::Immediate, + | Self::SyncGraph(_) + | Self::GraphSetupAck(_) => P2PMessageDelivery::Immediate, _ => P2PMessageDelivery::Inbox, } } @@ -326,12 +338,15 @@ impl GOATMessageContent { Self::GenCircuits(_) => "GenCircuits", Self::CutCircuits(_) => "CutCircuits", Self::SolderingProofReady(_) => "SolderingProofReady", + Self::GraphSetupAck(_) => "GraphSetupAck", Self::VerifierGraphParamsEndorsement(_) => "VerifierGraphParamsEndorsement", Self::NonceGeneration(_) => "NonceGeneration", + Self::AggNonceConsensus(_) => "AggNonceConsensus", Self::CommitteePresign(_) => "CommitteePresign", Self::EndorseGraph(_) => "EndorseGraph", Self::GraphFinalize(_) => "GraphFinalize", Self::PeginConfirmNonce(_) => "PeginConfirmNonce", + Self::PeginConfirmNonceConsensus(_) => "PeginConfirmNonceConsensus", Self::PeginConfirmPartialSig(_) => "PeginConfirmPartialSig", Self::PostReady(_) => "PostReady", Self::KickoffReady(_) => "KickoffReady", @@ -373,10 +388,12 @@ impl GOATMessageContent { Self::SolderingProofReady(message) => Some(message.graph_id), Self::VerifierGraphParamsEndorsement(message) => Some(message.graph_id), Self::NonceGeneration(message) => Some(message.graph_id), + Self::AggNonceConsensus(message) => Some(message.graph_id), Self::CommitteePresign(message) => Some(message.graph_id), Self::EndorseGraph(message) => Some(message.graph_id), Self::GraphFinalize(message) => Some(message.graph_id), Self::PeginConfirmNonce(message) => Some(message.instance_id), + Self::PeginConfirmNonceConsensus(message) => Some(message.instance_id), Self::PeginConfirmPartialSig(message) => Some(message.instance_id), Self::PostReady(message) => Some(message.instance_id), _ => None, @@ -406,10 +423,12 @@ fn is_pegin_message_type(message_type: &str) -> bool { | "SolderingProofReady" | "VerifierGraphParamsEndorsement" | "NonceGeneration" + | "AggNonceConsensus" | "CommitteePresign" | "EndorseGraph" | "GraphFinalize" | "PeginConfirmNonce" + | "PeginConfirmNonceConsensus" | "PeginConfirmPartialSig" | "PostReady" ) @@ -432,6 +451,56 @@ pub struct ConfirmInstance { pub struct InitGraph { pub instance_id: Uuid, pub graph_id: Uuid, + pub operator_pubkey: PublicKey, + pub operator_peer_id: Vec, + pub signature: SchnorrSignature, +} + +const INIT_GRAPH_SIGNATURE_DOMAIN: &[u8] = b"bitvm2-node/init-graph/v1"; + +fn init_graph_signature_message( + instance_id: Uuid, + graph_id: Uuid, + operator_pubkey: &PublicKey, + operator_peer_id: &[u8], +) -> SecpMessage { + let mut hasher = Sha256::new(); + hasher.update(INIT_GRAPH_SIGNATURE_DOMAIN); + hasher.update(instance_id.as_bytes()); + hasher.update(graph_id.as_bytes()); + hasher.update(operator_pubkey.to_bytes()); + hasher.update((operator_peer_id.len() as u32).to_be_bytes()); + hasher.update(operator_peer_id); + SecpMessage::from_digest(hasher.finalize().into()) +} + +pub fn sign_init_graph( + instance_id: Uuid, + graph_id: Uuid, + operator_keypair: &Keypair, + operator_peer_id: Vec, +) -> InitGraph { + let operator_pubkey = operator_keypair.public_key().into(); + let signature = SECP256K1.sign_schnorr( + &init_graph_signature_message(instance_id, graph_id, &operator_pubkey, &operator_peer_id), + operator_keypair, + ); + InitGraph { instance_id, graph_id, operator_pubkey, operator_peer_id, signature } +} + +pub fn verify_init_graph_signature(message: &InitGraph) -> bool { + SECP256K1 + .verify_schnorr( + &message.signature, + &init_graph_signature_message( + message.instance_id, + message.graph_id, + &message.operator_pubkey, + &message.operator_peer_id, + ), + &XOnlyPublicKey::from(message.operator_pubkey), + ) + .is_ok() } #[derive(Serialize, Deserialize, Clone)] pub struct GenCircuits { @@ -445,17 +514,100 @@ pub struct CutCircuits { pub instance_id: Uuid, pub graph_id: Uuid, pub verifier_pubkey: PublicKey, - pub verifier_index: usize, + pub candidate_index: usize, pub selected_circuit_indexes: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct SolderingProofReady { pub instance_id: Uuid, pub graph_id: Uuid, - pub verifier_index: usize, + pub candidate_index: usize, pub payload_hash: [u8; 32], pub total_len: usize, } + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +pub enum GraphSetupStage { + GenCircuits, + CutCircuits, + SolderingProofReady, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GraphSetupAck { + pub outbox_id: String, + pub instance_id: Uuid, + pub graph_id: Uuid, + pub stage: GraphSetupStage, + pub acknowledger_peer_id: String, +} + +pub fn graph_setup_outbox_id(content: &GOATMessageContent) -> Option { + match content { + GOATMessageContent::InitGraph(message) => Some(format!("init-graph:{}", message.graph_id)), + GOATMessageContent::GenCircuits(message) => Some(format!( + "gen-circuits:{}:{}:{}", + message.instance_id, message.graph_id, message.verifier_pubkey + )), + GOATMessageContent::CutCircuits(message) => Some(format!( + "cut-circuits:{}:{}:{}", + message.instance_id, message.graph_id, message.verifier_pubkey + )), + GOATMessageContent::SolderingProofReady(message) => Some(format!( + "soldering-proof-ready:{}:{}:{}", + message.graph_id, + message.candidate_index, + hex::encode(message.payload_hash), + )), + _ => None, + } +} + +fn graph_setup_ack(content: &GOATMessageContent) -> Option { + let (instance_id, graph_id, stage) = match content { + GOATMessageContent::GenCircuits(message) => { + (message.instance_id, message.graph_id, GraphSetupStage::GenCircuits) + } + GOATMessageContent::CutCircuits(message) => { + (message.instance_id, message.graph_id, GraphSetupStage::CutCircuits) + } + GOATMessageContent::SolderingProofReady(message) => { + (message.instance_id, message.graph_id, GraphSetupStage::SolderingProofReady) + } + _ => return None, + }; + Some(GraphSetupAck { + outbox_id: graph_setup_outbox_id(content)?, + instance_id, + graph_id, + stage, + acknowledger_peer_id: get_local_node_info().peer_id, + }) +} + +pub async fn enqueue_graph_setup_outbox_message( + local_db: &LocalDB, + message: GOATMessage, + ack_peer_id: Option<&str>, +) -> Result { + let outbox_id = graph_setup_outbox_id(&message.content) + .ok_or_else(|| anyhow!("not a graph setup message"))?; + let serialized = message.serialize_message().await?; + let now = current_time_secs(); + local_db + .acquire() + .await? + .enqueue_p2p_outbox_retry_message( + &outbox_id, + message.content.event_type(), + &serialized, + now + get_p2p_graph_setup_retry_window_secs(), + get_p2p_graph_setup_retry_interval_secs(), + ack_peer_id, + ) + .await?; + Ok(outbox_id) +} #[derive(Serialize, Deserialize, Clone)] pub struct VerifierGraphParamsEndorsement { pub instance_id: Uuid, @@ -481,6 +633,14 @@ pub struct NonceGeneration { pub nonce_sigs: CommitteeNonceSignatures, } #[derive(Serialize, Deserialize, Clone)] +pub struct AggNonceConsensus { + pub instance_id: Uuid, + pub graph_id: Uuid, + pub committee_pubkey: PublicKey, + pub consensus_hash: [u8; 32], + pub signature: SchnorrSignature, +} +#[derive(Serialize, Deserialize, Clone)] pub struct CommitteePresign { pub instance_id: Uuid, pub graph_id: Uuid, @@ -514,6 +674,13 @@ pub struct PeginConfirmNonce { pub nonce_sig: SchnorrSignature, } #[derive(Serialize, Deserialize, Clone)] +pub struct PeginConfirmNonceConsensus { + pub instance_id: Uuid, + pub committee_pubkey: PublicKey, + pub consensus_hash: [u8; 32], + pub signature: SchnorrSignature, +} +#[derive(Serialize, Deserialize, Clone)] pub struct PeginConfirmPartialSig { pub instance_id: Uuid, pub committee_pubkey: PublicKey, @@ -750,7 +917,26 @@ pub async fn handle_inbound_p2p_message( match decoded.content.p2p_delivery() { P2PMessageDelivery::Inbox => { - enqueue_p2p_message(local_db, actor, from_peer_id, id, message, &decoded).await + enqueue_p2p_message(local_db, actor, from_peer_id, id, message, &decoded).await?; + if let Some(ack) = graph_setup_ack(&decoded.content) { + // ACKs are deliberately ephemeral. A duplicate delivery is + // acknowledged again, which lets the sender recover when its + // previous ACK was dropped. + if let Err(error) = send_to_peer( + swarm, + GOATMessage::new(Actor::All, GOATMessageContent::GraphSetupAck(ack)), + ) + .await + { + tracing::debug!( + event = "p2p_graph_setup_ack", + outcome = "publish_failed", + error = %error, + "inbound graph-setup message remains durable; sender will retry" + ); + } + } + Ok(()) } P2PMessageDelivery::Immediate => { tracing::debug!( @@ -1275,11 +1461,21 @@ async fn handle_p2p_outbox_messages( ) -> Result<()> { let now = current_time_secs(); let mut storage = local_db.start_immediate_transaction().await?; + let expired = storage.expire_p2p_outbox_retry_messages(now).await?; let messages = storage .claim_p2p_outbox_messages(now, now + P2P_INBOX_LEASE_SECS, get_p2p_outbox_batch_size()) .await?; storage.commit().await?; + if expired > 0 { + tracing::warn!( + event = "p2p_outbox", + outcome = "retry_window_expired", + expired, + "graph-setup outbound messages reached their retry window without the expected ACK" + ); + } + for message in messages { let outbound = match GOATMessage::deserialize_message(&message.content).await { Ok(message) => message, @@ -1304,14 +1500,21 @@ async fn handle_p2p_outbox_messages( let mut storage = local_db.acquire().await?; match result { Ok(_) => { - storage.complete_p2p_outbox_message(&message.message_id).await?; - tracing::info!( - event = "p2p_outbox", - outcome = "published", - message_id = %message.message_id, - message_type = %message.msg_type, - "published durable outbound P2P message" - ); + if message.retry_until > 0 { + let next_retry_at = current_time_secs() + message.retry_interval_secs; + storage.schedule_p2p_outbox_retry(&message.message_id, next_retry_at).await?; + tracing::info!( + event = "p2p_outbox", + outcome = "published_retry_window", + message_id = %message.message_id, + message_type = %message.msg_type, + next_retry_at, + retry_until = message.retry_until, + "published graph-setup P2P message; awaiting ACK or retry window expiry" + ); + } else { + storage.complete_p2p_outbox_message(&message.message_id).await?; + } } Err(error) => { let retry_after_secs = p2p_retry_delay_secs(message.attempt_count); @@ -1872,7 +2075,7 @@ mod tests { let ready = SolderingProofReady { instance_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(), graph_id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(), - verifier_index: 3, + candidate_index: 3, payload_hash: [0xabu8; 32], total_len: 1024, }; @@ -1882,7 +2085,7 @@ mod tests { assert!(object.contains_key("instance_id")); assert!(object.contains_key("graph_id")); - assert!(object.contains_key("verifier_index")); + assert!(object.contains_key("candidate_index")); assert!(object.contains_key("payload_hash")); assert!(object.contains_key("total_len")); assert!(!object.contains_key("payload_path")); diff --git a/node/src/bin/db_inject.rs b/node/src/bin/db_inject.rs index 442fdca3b..0dc8b9970 100644 --- a/node/src/bin/db_inject.rs +++ b/node/src/bin/db_inject.rs @@ -84,13 +84,16 @@ fn infer_business_id(content: &GOATMessageContent) -> Option { GOATMessageContent::GenCircuits(v) => Some(v.graph_id), GOATMessageContent::CutCircuits(v) => Some(v.graph_id), GOATMessageContent::SolderingProofReady(v) => Some(v.graph_id), + GOATMessageContent::GraphSetupAck(_) => None, GOATMessageContent::VerifierGraphParamsEndorsement(v) => Some(v.graph_id), GOATMessageContent::CreateGraph(v) => Some(v.graph_id), GOATMessageContent::NonceGeneration(v) => Some(v.graph_id), + GOATMessageContent::AggNonceConsensus(v) => Some(v.graph_id), GOATMessageContent::CommitteePresign(v) => Some(v.graph_id), GOATMessageContent::EndorseGraph(v) => Some(v.graph_id), GOATMessageContent::GraphFinalize(v) => Some(v.graph_id), GOATMessageContent::PeginConfirmNonce(v) => Some(v.instance_id), + GOATMessageContent::PeginConfirmNonceConsensus(v) => Some(v.instance_id), GOATMessageContent::PeginConfirmPartialSig(v) => Some(v.instance_id), GOATMessageContent::PostReady(v) => Some(v.instance_id), GOATMessageContent::KickoffReady(v) => Some(v.graph_id), diff --git a/node/src/bin/mock_rpc.rs b/node/src/bin/mock_rpc.rs index 105066264..6f011f30d 100644 --- a/node/src/bin/mock_rpc.rs +++ b/node/src/bin/mock_rpc.rs @@ -24,9 +24,8 @@ use prometheus_client::registry::Registry; use secp256k1::Secp256k1; use store::localdb::{GraphRuntimeUpdate, StorageProcessor}; use store::{ - BridgeOutGlobalStats, GoatTxProcessingStatus, GoatTxRecord, GoatTxType, Graph, GraphStatus, - GraphStatusSource, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, Node, - UInt64Array3, create_local_db, + BridgeOutGlobalStats, Graph, GraphStatus, GraphStatusSource, Instance, InstanceBridgeInStatus, + Node, SwapEscrow, SwapEscrowStatus, UInt64Array3, create_local_db, }; use tokio::signal; use tokio_util::sync::CancellationToken; @@ -55,7 +54,7 @@ struct Opts { #[derive(Debug)] struct MockSeedSummary { bridge_in_instance_id: Uuid, - bridge_out_instance_id: Uuid, + swap_escrow_hash: String, ready_graph_id: Uuid, challenge_graph_id: Uuid, operator_pubkey: String, @@ -147,7 +146,6 @@ fn seeded_bridge_in_instance( let utxo = vec![Utxo { txid: [utxo_byte; 32], vout: 0, amount_sats: amount as u64 }]; Ok(Instance { instance_id, - is_bridge_in: true, network: Network::Testnet4.to_string(), from_addr: get_rand_btc_address_p2wpkh(Network::Testnet4), to_addr: mock_goat_addr(0x31), @@ -161,7 +159,6 @@ fn seeded_bridge_in_instance( user_refund_addr: get_rand_btc_address_p2wpkh(Network::Testnet4), pegin_data_tx_hash: mock_tx_hash(0xa2), post_pegin_txhash: Some(mock_tx_hash(0xa3)), - bridge_out_amount: "0".to_string(), status_updated_at: now - 120, created_at: now - 3_600, updated_at: now, @@ -169,21 +166,20 @@ fn seeded_bridge_in_instance( }) } -fn seeded_bridge_out_instance(instance_id: Uuid, now: i64, escrow_hash: String) -> Instance { - Instance { - instance_id, - is_bridge_in: false, +fn seeded_swap_escrow(escrow_hash: String, now: i64) -> SwapEscrow { + SwapEscrow { + escrow_hash, network: Network::Testnet4.to_string(), - from_addr: mock_goat_addr(0x41), - to_addr: get_rand_btc_address_p2wpkh(Network::Testnet4), - amount: 0, - input_utxos: "[]".to_string(), - status: InstanceBridgeOutStatus::Initialize.to_string(), - goat_tx_hash: mock_tx_hash(0xb1), - goat_tx_height: 123_500, - escrow_hash: Some(escrow_hash), - bridge_out_amount: "25000000".to_string(), - bridge_out_lock_time: now + 3_600, + status: SwapEscrowStatus::Initialize.to_string(), + offerer_addr: mock_goat_addr(0x41), + claimer_addr: mock_goat_addr(0x42), + btc_addr: get_rand_btc_address_p2wpkh(Network::Testnet4), + token: mock_goat_addr(0x43), + amount: "25000000".to_string(), + refund_deadline: now + 3_600, + escrow_data: Some(hex::encode([0xe1; 64])), + init_tx_hash: mock_tx_hash(0xb1), + init_tx_height: 123_500, status_updated_at: now - 60, created_at: now - 900, updated_at: now, @@ -281,7 +277,6 @@ async fn seed_mock_data( let graph_from_addr = mock_goat_addr(0x51); let bridge_in_instance_id = mock_uuid("11111111-1111-1111-1111-111111111111"); let bridge_in_pending_id = mock_uuid("11111111-1111-1111-1111-111111111112"); - let bridge_out_instance_id = mock_uuid("22222222-2222-2222-2222-222222222222"); let ready_graph_id = mock_uuid("33333333-3333-3333-3333-333333333333"); let challenge_graph_id = mock_uuid("33333333-3333-3333-3333-333333333334"); let escrow_hash = mock_tx_hash(0xc1); @@ -309,7 +304,7 @@ async fn seed_mock_data( now, 0x12, )?; - let bridge_out = seeded_bridge_out_instance(bridge_out_instance_id, now, escrow_hash.clone()); + let swap_escrow = seeded_swap_escrow(escrow_hash.clone(), now); let none_sub_status = r#"{"watchtower_challenge_status":[],"verifier_challenge_status":[],"disprove_type":null,"disprove_index":-1}"#.to_string(); let challenge_sub_status = r#"{"watchtower_challenge_status":[true,false],"verifier_challenge_status":["None"],"disprove_type":null,"disprove_index":-1}"#.to_string(); @@ -340,24 +335,13 @@ async fn seed_mock_data( for node in [current_node, committee_node, operator_node, watchtower_node] { tx.upsert_node(&node).await?; } - for instance in [bridge_in_success, bridge_in_pending, bridge_out] { + for instance in [bridge_in_success, bridge_in_pending] { tx.upsert_instance(&instance).await?; } + tx.insert_swap_escrow_if_absent(&swap_escrow).await?; for graph in [ready_graph, challenge_graph] { seed_graph_runtime(&mut tx, &graph).await?; } - tx.upsert_goat_tx_record(&GoatTxRecord { - instance_id: bridge_out_instance_id, - graph_id: Uuid::nil(), - tx_type: GoatTxType::SwapInitialize.to_string(), - tx_hash: mock_tx_hash(0xe1), - height: 123_501, - is_local: true, - processing_status: GoatTxProcessingStatus::Processed.to_string(), - extra: Some(escrow_hash), - created_at: now, - }) - .await?; tx.upsert_bridge_out_global_stats(&BridgeOutGlobalStats { id: 1, initial_txn: 1, @@ -374,7 +358,7 @@ async fn seed_mock_data( Ok(MockSeedSummary { bridge_in_instance_id, - bridge_out_instance_id, + swap_escrow_hash: escrow_hash, ready_graph_id, challenge_graph_id, operator_pubkey, @@ -417,7 +401,7 @@ async fn main() -> Result<()> { println!("Gateway contract: {MOCK_GATEWAY_CONTRACT}"); println!("Swap contract: {MOCK_SWAP_CONTRACT}"); println!("Bridge-in instance: {}", seed_summary.bridge_in_instance_id); - println!("Bridge-out instance: {}", seed_summary.bridge_out_instance_id); + println!("Swap escrow: {}", seed_summary.swap_escrow_hash); println!("Ready graph: {}", seed_summary.ready_graph_id); println!("Challenge graph: {}", seed_summary.challenge_graph_id); println!("Operator pubkey: {}", seed_summary.operator_pubkey); diff --git a/node/src/bin/send_bridge_out.rs b/node/src/bin/send_bridge_out.rs deleted file mode 100644 index 389301500..000000000 --- a/node/src/bin/send_bridge_out.rs +++ /dev/null @@ -1,1050 +0,0 @@ -//! bridge-out: user bridge-out helper (init-tag + escrow-data + swap-initialize) via the node API. -//! -//! Modes: -//! - init-tag: initialize a bridge-out instance record -//! - escrow-data: query escrow data by instance id -//! - swap-initialize: call swap contract initialize and derive escrow hash from tx logs -//! -//! The node API must be reachable at --rpc-url for init-tag/escrow-data. -//! The GOAT RPC/private key environment must be configured for swap-initialize. - -use alloy::eips::BlockNumberOrTag; -use alloy::primitives::{Address as EvmAddress, Bytes, U256}; -use alloy::providers::{Provider, ProviderBuilder}; -use anyhow::{Context, Result, anyhow, bail}; -use bitvm_noded::env::{ - ENV_GOAT_PRIVATE_KEY, ENV_GOAT_SWAP_CONTRACT_ADDRESS, get_goat_network, goat_config_from_env, -}; -use clap::{Parser, Subcommand}; -use client::goat_chain::{GOATClient, SwapEscrowData}; -use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; -use std::str::FromStr; -use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::Uuid; -const PAY_IN_FLAG: u64 = 0x02; -const ZERO_B256_HEX: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; -const DEFAULT_NONCE_TIME_OFFSET_SECS: u64 = 700_000_000; -const DEFAULT_NONCE_RANDOM_BITS: u32 = 24; -const DEFAULT_PRIORITY_FEE_WEI: u64 = 5_000_000; -const DEFAULT_MAX_BASE_FEE_WEI: u64 = 2_000_000_000; -const DEFAULT_BASE_FEE_MULTIPLIER_NUM: u64 = 125; -const DEFAULT_BASE_FEE_MULTIPLIER_DEN: u64 = 100; -const PEG_BTC_DECIMALS: usize = 18; - -#[derive(Parser, Debug)] -#[command( - name = "bridge-out", - version, - about = "Bridge-out helper (via node API and GOAT RPC)", - long_about = "Initialize bridge-out tag, query escrow data, and call swap initialize with escrow hash extraction from tx logs." -)] -struct Args { - /// Node API base URL - #[arg(long, default_value = "http://localhost:8080")] - rpc_url: String, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// Initialize bridge-out tag - InitTag { - /// Source GOAT address - #[arg(long)] - from_addr: String, - - /// Destination BTC address - #[arg(long)] - to_addr: String, - - /// Escrow hash (0x-prefixed 32-byte hex) - #[arg(long)] - escrow_hash: Option, - - /// Swap initialize tx hash (0x-prefixed 32-byte hex), used to auto-derive escrow hash from logs - #[arg(long)] - swap_init_tx_hash: Option, - - /// GOAT swap contract address; fallback to GOAT_SWAP_CONTRACT_ADDRESS - #[arg(long)] - contract_address: Option, - }, - - /// Query escrow data by instance id - EscrowData { - /// Instance id - #[arg(long)] - instance_id: Uuid, - }, - - /// Fetch escrow params from payInvoice, call swap initialize, and print escrow hash from tx logs - SwapInitialize { - /// payInvoice endpoint URL (chain query included) - #[arg( - long, - default_value = "https://152-32-185-32.nodes.atomiq.exchange:8443/tobtc/payInvoice?chain=GOAT" - )] - pay_invoice_url: String, - - /// BTC receive address, sent to payInvoice as `address` - #[arg(long)] - btc_address: String, - - /// Requested amount in pegBTC (human-readable, e.g. 0.01). Converted to base units automatically. - #[arg(long)] - amount: String, - - /// Whether this quote is exact-in - #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] - exact_in: bool, - - /// Bitcoin confirmation target - #[arg(long, default_value_t = 3)] - confirmation_target: u32, - - /// Required confirmations - #[arg(long, default_value_t = 2)] - confirmations: u32, - - /// Token address - #[arg(long)] - token: String, - - /// Offerer GOAT address - #[arg(long)] - offerer: String, - - /// Additional params JSON object merged into payInvoice body - #[arg(long)] - additional_params_json: Option, - - /// GOAT private key; fallback to GOAT_PRIVATE_KEY - #[arg(long, env = ENV_GOAT_PRIVATE_KEY)] - goat_private_key: Option, - - /// GOAT swap contract address; fallback to GOAT_SWAP_CONTRACT_ADDRESS - #[arg(long)] - contract_address: Option, - - /// Max seconds to wait for tx receipt - #[arg(long, default_value_t = 60)] - max_wait_secs: u64, - }, -} - -#[derive(Debug, Serialize)] -struct BridgeOutInitTagApiRequest { - contract_address: String, - from_addr: String, - to_addr: String, - escrow_hash: String, -} - -#[derive(Debug, Deserialize)] -struct EscrowDataApiResponse { - instance_id: String, - escrow: Option, - error: Option, -} - -#[derive(Debug, Deserialize)] -struct ApiError { - error: String, - message: String, -} - -#[derive(Debug, Deserialize)] -struct PayInvoiceEnvelope { - code: i64, - msg: String, - data: Option, -} - -#[derive(Debug, Deserialize)] -struct PayInvoiceResponseData { - data: PayInvoiceEscrowRaw, - prefix: String, - timeout: String, - signature: String, -} - -#[derive(Debug, Deserialize)] -struct PayInvoiceEscrowRaw { - offerer: String, - claimer: String, - token: String, - #[serde(rename = "refundHandler")] - refund_handler: String, - #[serde(rename = "claimHandler")] - claim_handler: String, - #[serde(rename = "payOut")] - pay_out: bool, - #[serde(rename = "payIn")] - pay_in: bool, - reputation: bool, - sequence: JsonValue, - #[serde(rename = "claimData")] - claim_data: String, - #[serde(rename = "refundData")] - refund_data: String, - amount: JsonValue, - #[serde(rename = "depositToken")] - deposit_token: String, - #[serde(rename = "securityDeposit")] - security_deposit: JsonValue, - #[serde(rename = "claimerBounty")] - claimer_bounty: JsonValue, - #[allow(dead_code)] - kind: Option, - #[serde(rename = "extraData")] - extra_data: Option, - #[serde(rename = "successActionCommitment")] - success_action_commitment: Option, -} - -#[derive(Debug)] -enum EscrowHashInput { - Provided(String), - FromSwapInitTx(String), -} - -fn join_base_path(base_url: &str, path: &str) -> String { - format!("{}{}", base_url.trim_end_matches('/'), path) -} - -fn bridge_out_init_tag_url(base_url: &str) -> String { - join_base_path(base_url, "/v1/instances/bridge-out-init-tag") -} - -fn escrow_data_url(base_url: &str, instance_id: &Uuid) -> String { - join_base_path(base_url, &format!("/v1/instances/{instance_id}/escrow-data")) -} - -fn parse_api_error(body: &str) -> Option { - serde_json::from_str::(body).ok() -} - -fn resolve_contract_address(contract_address: Option) -> Result { - if let Some(addr) = contract_address { - return Ok(addr); - } - - std::env::var(ENV_GOAT_SWAP_CONTRACT_ADDRESS).map_err(|_| { - anyhow!( - "missing --contract-address and env {ENV_GOAT_SWAP_CONTRACT_ADDRESS}, one of them is required" - ) - }) -} - -fn normalize_32byte_hex(value: &str, field_name: &str) -> Result { - let hex = value.strip_prefix("0x").unwrap_or(value); - let bytes = - hex::decode(hex).map_err(|e| anyhow!("{field_name} is not a valid hex string: {e}"))?; - if bytes.len() != 32 { - bail!("{field_name} must be exactly 32 bytes, got {}", bytes.len()); - } - Ok(format!("0x{}", hex::encode(bytes))) -} - -fn parse_hex_bytes(value: &str, field_name: &str) -> Result> { - let hex = value.strip_prefix("0x").unwrap_or(value); - if hex.is_empty() { - return Ok(vec![]); - } - let bytes = - hex::decode(hex).map_err(|e| anyhow!("{field_name} is not a valid hex string: {e}"))?; - Ok(bytes) -} - -fn parse_bytes32(value: &str, field_name: &str) -> Result<[u8; 32]> { - let normalized = normalize_32byte_hex(value, field_name)?; - let hex = normalized.trim_start_matches("0x"); - let bytes = hex::decode(hex).map_err(|e| anyhow!("{field_name} decode failed: {e}"))?; - let mut out = [0u8; 32]; - out.copy_from_slice(&bytes); - Ok(out) -} - -fn parse_u256(value: &str, field_name: &str) -> Result { - if let Some(hex) = value.strip_prefix("0x") { - let bytes = - hex::decode(hex).map_err(|e| anyhow!("{field_name} is not valid hex u256: {e}"))?; - if bytes.len() > 32 { - bail!("{field_name} exceeds 32 bytes for u256"); - } - let mut padded = [0u8; 32]; - padded[32 - bytes.len()..].copy_from_slice(&bytes); - return Ok(U256::from_be_bytes(padded)); - } - - U256::from_str(value).map_err(|e| anyhow!("{field_name} is not valid decimal u256: {e}")) -} - -fn parse_u256_from_json_value(value: &JsonValue, field_name: &str) -> Result { - match value { - JsonValue::String(v) => parse_u256(v, field_name), - JsonValue::Number(v) => parse_u256(&v.to_string(), field_name), - _ => bail!("{field_name} must be a string or number"), - } -} - -fn convert_pegbtc_amount_to_base_units(amount: &str) -> Result { - let amount = amount.trim(); - if amount.is_empty() { - bail!("amount must not be empty"); - } - if amount.starts_with('-') { - bail!("amount must be non-negative"); - } - - let mut parts = amount.split('.'); - let int_part = parts.next().unwrap_or_default(); - let frac_part = parts.next(); - if parts.next().is_some() { - bail!("amount must be a valid decimal number"); - } - - let int_part = if int_part.is_empty() { "0" } else { int_part }; - if !int_part.chars().all(|c| c.is_ascii_digit()) { - bail!("amount integer part must contain digits only"); - } - - let frac_part = frac_part.unwrap_or(""); - if !frac_part.chars().all(|c| c.is_ascii_digit()) { - bail!("amount fractional part must contain digits only"); - } - if frac_part.len() > PEG_BTC_DECIMALS { - bail!("amount has too many decimal places, max {PEG_BTC_DECIMALS}"); - } - - let mut base_units = String::with_capacity(int_part.len() + PEG_BTC_DECIMALS); - base_units.push_str(int_part); - base_units.push_str(frac_part); - base_units.push_str(&"0".repeat(PEG_BTC_DECIMALS - frac_part.len())); - let normalized = base_units.trim_start_matches('0'); - let normalized = if normalized.is_empty() { "0" } else { normalized }; - - U256::from_str(normalized) - .map_err(|e| anyhow!("amount out of range for u256 after conversion: {e}"))?; - Ok(normalized.to_string()) -} - -fn generate_default_nonce(unix_seconds: u64, random_24_bits: u32) -> String { - let timestamp_part = unix_seconds.saturating_sub(DEFAULT_NONCE_TIME_OFFSET_SECS); - let random_part = u64::from(random_24_bits & 0x00ff_ffff); - ((timestamp_part << DEFAULT_NONCE_RANDOM_BITS) | random_part).to_string() -} - -fn generate_default_fee_rate(base_fee_per_gas: U256) -> String { - let mut adjusted_base_fee = (base_fee_per_gas * U256::from(DEFAULT_BASE_FEE_MULTIPLIER_NUM)) - / U256::from(DEFAULT_BASE_FEE_MULTIPLIER_DEN); - let max_base_fee = U256::from(DEFAULT_MAX_BASE_FEE_WEI); - if adjusted_base_fee > max_base_fee { - adjusted_base_fee = max_base_fee; - } - format!("{},{}", adjusted_base_fee, U256::from(DEFAULT_PRIORITY_FEE_WEI)) -} - -async fn generate_default_nonce_and_fee_rate() -> Result<(String, String)> { - let unix_seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before UNIX_EPOCH")? - .as_secs(); - let nonce = generate_default_nonce(unix_seconds, rand::random::()); - - let cfg = goat_config_from_env().await; - let provider = ProviderBuilder::new().connect_http(cfg.rpc_url.clone()); - let block = provider - .get_block_by_number(BlockNumberOrTag::Latest) - .await - .with_context(|| format!("failed to get latest block from GOAT RPC {}", cfg.rpc_url))? - .ok_or_else(|| anyhow!("latest block not found on GOAT RPC {}", cfg.rpc_url))?; - let base_fee_per_gas = block.header.base_fee_per_gas.ok_or_else(|| { - anyhow!("latest block missing base_fee_per_gas on GOAT RPC {}", cfg.rpc_url) - })?; - let fee_rate = generate_default_fee_rate(U256::from(base_fee_per_gas)); - Ok((nonce, fee_rate)) -} - -fn parse_json_object(value: &str, field_name: &str) -> Result> { - let parsed: JsonValue = - serde_json::from_str(value).map_err(|e| anyhow!("{field_name} is not valid JSON: {e}"))?; - match parsed { - JsonValue::Object(map) => Ok(map), - _ => bail!("{field_name} must be a JSON object"), - } -} - -fn parse_extra_data_from_json(value: Option<&JsonValue>) -> Result { - match value { - None | Some(JsonValue::Null) => Ok(Bytes::default()), - Some(JsonValue::String(v)) => Ok(Bytes::from(parse_hex_bytes(v, "extraData")?)), - _ => bail!("extraData must be null or hex string"), - } -} - -fn build_flags(sequence: U256, pay_out: bool, pay_in: bool, reputation: bool) -> Result { - if sequence > (U256::MAX >> 64) { - bail!("sequence is too large, must fit within 192 bits"); - } - let mut flags = sequence << 64; - if pay_out { - flags += U256::from(1u64); - } - if pay_in { - flags += U256::from(PAY_IN_FLAG); - } - if reputation { - flags += U256::from(4u64); - } - Ok(flags) -} - -fn should_pay_in(flags: U256) -> bool { - (flags & U256::from(PAY_IN_FLAG)) == U256::from(PAY_IN_FLAG) -} - -fn requires_token_approval(sender: EvmAddress, escrow: &SwapEscrowData) -> bool { - let native_token = EvmAddress::ZERO; - should_pay_in(escrow.flags) && escrow.offerer == sender && escrow.token != native_token -} - -fn compute_initialize_value_wei(sender: EvmAddress, escrow: &SwapEscrowData) -> U256 { - let native_token = EvmAddress::ZERO; - let mut value = U256::ZERO; - if should_pay_in(escrow.flags) && escrow.offerer == sender && escrow.token == native_token { - value += escrow.amount; - } - if escrow.deposit_token == native_token { - let total_deposit = if escrow.security_deposit > escrow.claimer_bounty { - escrow.security_deposit - } else { - escrow.claimer_bounty - }; - value += total_deposit; - } - value -} - -async fn ensure_token_approval( - goat_client: &GOATClient, - sender: EvmAddress, - swap_contract: EvmAddress, - escrow: &SwapEscrowData, -) -> Result<()> { - if !requires_token_approval(sender, escrow) { - return Ok(()); - } - - let owner = sender.into_array(); - let spender = swap_contract.into_array(); - let allowance = goat_client - .peg_btc_allowance(&owner, &spender) - .await - .context("failed to query token allowance before swap initialize")?; - if allowance >= escrow.amount { - eprintln!( - "token allowance already sufficient, allowance={}, required={}", - allowance, escrow.amount - ); - return Ok(()); - } - - eprintln!( - "token allowance insufficient, approving token spend now, allowance={}, required={}", - allowance, escrow.amount - ); - let approve_tx_hash = goat_client - .peg_btc_approve(&spender, escrow.amount) - .await - .context("failed to send token approve tx before swap initialize")?; - eprintln!("token approve submitted: {approve_tx_hash}"); - - let latest_allowance = goat_client - .peg_btc_allowance(&owner, &spender) - .await - .context("failed to re-check token allowance after approve")?; - if latest_allowance < escrow.amount { - bail!( - "token allowance still insufficient after approve, allowance={}, required={}", - latest_allowance, - escrow.amount - ); - } - eprintln!("token allowance after approve: {}, required={}", latest_allowance, escrow.amount); - Ok(()) -} - -fn resolve_escrow_hash_input( - escrow_hash: Option, - swap_init_tx_hash: Option, -) -> Result { - match (escrow_hash, swap_init_tx_hash) { - (Some(escrow_hash), _) => { - Ok(EscrowHashInput::Provided(normalize_32byte_hex(&escrow_hash, "escrow_hash")?)) - } - (None, Some(tx_hash)) => Ok(EscrowHashInput::FromSwapInitTx(normalize_32byte_hex( - &tx_hash, - "swap_init_tx_hash", - )?)), - (None, None) => { - bail!("one of --escrow-hash or --swap-init-tx-hash must be provided") - } - } -} - -async fn derive_escrow_hash_from_swap_init_tx( - swap_init_tx_hash: &str, - contract_address: &str, -) -> Result { - let swap_contract_address = EvmAddress::from_str(contract_address) - .map_err(|e| anyhow!("invalid swap contract address {contract_address}: {e}"))?; - let goat_client = GOATClient::new(goat_config_from_env().await, get_goat_network()); - let escrow_hash = goat_client - .extract_initialize_escrow_hash_from_tx(swap_init_tx_hash, swap_contract_address) - .await - .with_context(|| format!("failed to parse initialize log from tx {swap_init_tx_hash}"))?; - escrow_hash.ok_or_else(|| { - anyhow!( - "Initialize event with escrowHash not found in tx {swap_init_tx_hash} logs for contract {contract_address}" - ) - }) -} - -async fn submit_swap_initialize_tx( - goat_private_key: Option, - contract_address: Option, - max_wait_secs: u64, - escrow: SwapEscrowData, - signature: Bytes, - timeout: U256, - extra_data: Bytes, -) -> Result<(String, String)> { - let contract_address = resolve_contract_address(contract_address)?; - let swap_contract = EvmAddress::from_str(&contract_address) - .map_err(|e| anyhow!("invalid swap contract address {contract_address}: {e}"))?; - - let mut cfg = goat_config_from_env().await; - if goat_private_key.is_none() && cfg.private_key.is_none() { - bail!("missing GOAT private key (--goat-private-key or GOAT_PRIVATE_KEY)"); - } - if goat_private_key.is_some() { - cfg = cfg.with_private_key(goat_private_key); - } - cfg = cfg.with_peg_btc_address(Some(escrow.token)); - let goat_client = GOATClient::new(cfg, get_goat_network()); - let sender = goat_client.get_default_signer_address(); - ensure_token_approval(&goat_client, sender, swap_contract, &escrow).await?; - let value_wei = compute_initialize_value_wei(sender, &escrow); - let result = goat_client - .swap_initialize( - swap_contract, - escrow, - signature, - timeout, - extra_data, - value_wei, - max_wait_secs, - ) - .await?; - Ok((result.tx_hash, result.escrow_hash)) -} - -async fn call_pay_invoice( - client: &reqwest::Client, - args: &Commands, -) -> Result<(PayInvoiceResponseData, Option, Option, u64)> { - let Commands::SwapInitialize { - pay_invoice_url, - btc_address, - amount, - exact_in, - confirmation_target, - confirmations, - token, - offerer, - additional_params_json, - goat_private_key, - contract_address, - max_wait_secs, - } = args - else { - bail!("invalid command for call_pay_invoice"); - }; - - let (nonce, fee_rate) = generate_default_nonce_and_fee_rate().await?; - let amount_base_units = convert_pegbtc_amount_to_base_units(amount)?; - eprintln!("auto-generated nonce: {nonce}"); - eprintln!("auto-generated feeRate: {fee_rate}"); - eprintln!("converted amount (pegBTC -> base units): {amount_base_units}"); - - let mut body = serde_json::Map::new(); - if let Some(params_json) = additional_params_json { - body.extend(parse_json_object(params_json, "additional_params_json")?); - } - body.insert("address".to_string(), JsonValue::String(btc_address.clone())); - body.insert("amount".to_string(), JsonValue::String(amount_base_units)); - body.insert("exactIn".to_string(), JsonValue::Bool(*exact_in)); - body.insert("confirmationTarget".to_string(), JsonValue::from(*confirmation_target)); - body.insert("confirmations".to_string(), JsonValue::from(*confirmations)); - body.insert("nonce".to_string(), JsonValue::String(nonce)); - body.insert("token".to_string(), JsonValue::String(token.clone())); - body.insert("offerer".to_string(), JsonValue::String(offerer.clone())); - body.insert("feeRate".to_string(), JsonValue::String(fee_rate)); - - let resp = client - .post(pay_invoice_url) - .json(&JsonValue::Object(body)) - .send() - .await - .with_context(|| format!("failed to reach payInvoice API at {pay_invoice_url}"))?; - - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - if let Some(api_err) = parse_api_error(&text) { - bail!("payInvoice API error ({}): {}", api_err.error, api_err.message); - } - bail!("payInvoice API returned {status}: {text}"); - } - - let envelope: PayInvoiceEnvelope = - serde_json::from_str(&text).context("failed to parse payInvoice response body")?; - if envelope.code != 20000 { - bail!("payInvoice business error (code {}): {}", envelope.code, envelope.msg); - } - - let data = envelope.data.ok_or_else(|| anyhow!("payInvoice response missing data"))?; - - Ok((data, goat_private_key.clone(), contract_address.clone(), *max_wait_secs)) -} - -fn parse_escrow_from_pay_invoice( - quote: &PayInvoiceResponseData, -) -> Result<(SwapEscrowData, Bytes, U256, Bytes)> { - let escrow_raw = "e.data; - let sequence = parse_u256_from_json_value(&escrow_raw.sequence, "sequence")?; - let flags = - build_flags(sequence, escrow_raw.pay_out, escrow_raw.pay_in, escrow_raw.reputation)?; - - let success_action_commitment = - escrow_raw.success_action_commitment.as_deref().unwrap_or(ZERO_B256_HEX); - let escrow = SwapEscrowData { - offerer: EvmAddress::from_str(&escrow_raw.offerer) - .map_err(|e| anyhow!("invalid offerer address {}: {e}", escrow_raw.offerer))?, - claimer: EvmAddress::from_str(&escrow_raw.claimer) - .map_err(|e| anyhow!("invalid claimer address {}: {e}", escrow_raw.claimer))?, - amount: parse_u256_from_json_value(&escrow_raw.amount, "amount")?, - token: EvmAddress::from_str(&escrow_raw.token) - .map_err(|e| anyhow!("invalid token address {}: {e}", escrow_raw.token))?, - flags, - claim_handler: EvmAddress::from_str(&escrow_raw.claim_handler).map_err(|e| { - anyhow!("invalid claimHandler address {}: {e}", escrow_raw.claim_handler) - })?, - claim_data: parse_bytes32(&escrow_raw.claim_data, "claimData")?, - refund_handler: EvmAddress::from_str(&escrow_raw.refund_handler).map_err(|e| { - anyhow!("invalid refundHandler address {}: {e}", escrow_raw.refund_handler) - })?, - refund_data: parse_bytes32(&escrow_raw.refund_data, "refundData")?, - security_deposit: parse_u256_from_json_value( - &escrow_raw.security_deposit, - "securityDeposit", - )?, - claimer_bounty: parse_u256_from_json_value(&escrow_raw.claimer_bounty, "claimerBounty")?, - deposit_token: EvmAddress::from_str(&escrow_raw.deposit_token).map_err(|e| { - anyhow!("invalid depositToken address {}: {e}", escrow_raw.deposit_token) - })?, - success_action_commitment: parse_bytes32( - success_action_commitment, - "successActionCommitment", - )?, - }; - let signature = Bytes::from(parse_hex_bytes("e.signature, "signature")?); - let timeout = parse_u256("e.timeout, "timeout")?; - let extra_data = parse_extra_data_from_json(escrow_raw.extra_data.as_ref())?; - Ok((escrow, signature, timeout, extra_data)) -} - -async fn run_swap_initialize_from_pay_invoice( - client: &reqwest::Client, - args: &Commands, -) -> Result<(String, String)> { - let (quote, goat_private_key, contract_address, max_wait_secs) = - call_pay_invoice(client, args).await?; - eprintln!("payInvoice prefix: {}", quote.prefix); - let (escrow, signature, timeout, extra_data) = parse_escrow_from_pay_invoice("e)?; - submit_swap_initialize_tx( - goat_private_key, - contract_address, - max_wait_secs, - escrow, - signature, - timeout, - extra_data, - ) - .await -} - -async fn call_bridge_out_init_tag( - client: &reqwest::Client, - base_url: &str, - body: &BridgeOutInitTagApiRequest, -) -> Result<()> { - let url = bridge_out_init_tag_url(base_url); - let resp = client - .put(&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 text = resp.text().await.unwrap_or_default(); - if let Some(api_err) = parse_api_error(&text) { - bail!("API error ({}): {}", api_err.error, api_err.message); - } - bail!("API returned {status}: {text}"); - } - - Ok(()) -} - -async fn call_get_escrow_data( - client: &reqwest::Client, - base_url: &str, - instance_id: &Uuid, -) -> Result { - let url = escrow_data_url(base_url, instance_id); - let resp = client - .get(&url) - .send() - .await - .with_context(|| format!("failed to reach node API at {url}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if let Some(api_err) = parse_api_error(&text) { - bail!("API error ({}): {}", api_err.error, api_err.message); - } - bail!("API returned {status}: {text}"); - } - - resp.json().await.context("failed to parse escrow data response") -} - -#[tokio::main] -async fn main() -> Result<()> { - dotenv::dotenv().ok(); - let args = Args::parse(); - let client = reqwest::Client::new(); - - match &args.command { - Commands::InitTag { - from_addr, - to_addr, - escrow_hash, - swap_init_tx_hash, - contract_address, - } => { - let contract_address = resolve_contract_address(contract_address.clone())?; - if escrow_hash.is_some() && swap_init_tx_hash.is_some() { - eprintln!( - "both --escrow-hash and --swap-init-tx-hash provided, using --escrow-hash" - ); - } - let escrow_hash = - match resolve_escrow_hash_input(escrow_hash.clone(), swap_init_tx_hash.clone())? { - EscrowHashInput::Provided(escrow_hash) => escrow_hash, - EscrowHashInput::FromSwapInitTx(swap_init_tx_hash) => { - let tx_hash = - normalize_32byte_hex(&swap_init_tx_hash, "swap_init_tx_hash")?; - let derived = - derive_escrow_hash_from_swap_init_tx(&tx_hash, &contract_address) - .await?; - eprintln!("derived escrow hash from swap init tx {tx_hash}: {derived}"); - derived - } - }; - - let body = BridgeOutInitTagApiRequest { - contract_address, - from_addr: from_addr.clone(), - to_addr: to_addr.clone(), - escrow_hash, - }; - - call_bridge_out_init_tag(&client, &args.rpc_url, &body).await?; - println!("bridge-out init-tag submitted successfully"); - } - Commands::EscrowData { instance_id } => { - let resp = call_get_escrow_data(&client, &args.rpc_url, instance_id).await?; - println!("instance_id: {}", resp.instance_id); - println!("escrow: {}", resp.escrow.as_deref().unwrap_or("null")); - println!("error: {}", resp.error.as_deref().unwrap_or("null")); - } - Commands::SwapInitialize { .. } => { - let (tx_hash, escrow_hash) = - run_swap_initialize_from_pay_invoice(&client, &args.command).await?; - println!("swap initialize submitted: {tx_hash}"); - println!("escrow_hash (from Initialize log): {escrow_hash}"); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn test_bridge_out_init_tag_url() { - assert_eq!( - bridge_out_init_tag_url("http://localhost:8080/"), - "http://localhost:8080/v1/instances/bridge-out-init-tag" - ); - } - - #[test] - fn test_escrow_data_url() { - let instance_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").unwrap(); - assert_eq!( - escrow_data_url("http://localhost:8080", &instance_id), - "http://localhost:8080/v1/instances/123e4567-e89b-12d3-a456-426614174000/escrow-data" - ); - } - - #[test] - fn test_parse_api_error() { - let body = r#"{"error":"PUT_BRIDGE_OUT_INIT_TAG_ERROR","message":"invalid"}"#; - let parsed = parse_api_error(body); - assert!(parsed.is_some()); - let parsed = parsed.unwrap(); - assert_eq!(parsed.error, "PUT_BRIDGE_OUT_INIT_TAG_ERROR"); - assert_eq!(parsed.message, "invalid"); - } - - #[test] - fn test_resolve_contract_address_arg_priority() { - let _guard = ENV_LOCK.lock().unwrap(); - unsafe { - std::env::set_var( - ENV_GOAT_SWAP_CONTRACT_ADDRESS, - "0x2222222222222222222222222222222222222222", - ); - } - let value = resolve_contract_address(Some( - "0x1111111111111111111111111111111111111111".to_string(), - )) - .unwrap(); - assert_eq!(value, "0x1111111111111111111111111111111111111111"); - } - - #[test] - fn test_resolve_contract_address_from_env() { - let _guard = ENV_LOCK.lock().unwrap(); - unsafe { - std::env::set_var( - ENV_GOAT_SWAP_CONTRACT_ADDRESS, - "0x3333333333333333333333333333333333333333", - ); - } - let value = resolve_contract_address(None).unwrap(); - assert_eq!(value, "0x3333333333333333333333333333333333333333"); - } - - #[test] - fn test_resolve_contract_address_missing() { - let _guard = ENV_LOCK.lock().unwrap(); - unsafe { - std::env::remove_var(ENV_GOAT_SWAP_CONTRACT_ADDRESS); - } - let err = resolve_contract_address(None).unwrap_err().to_string(); - assert!(err.contains("GOAT_SWAP_CONTRACT_ADDRESS")); - } - - #[test] - fn test_resolve_escrow_hash_input_uses_provided() { - let input = resolve_escrow_hash_input( - Some(format!("0x{}", "11".repeat(32))), - Some(format!("0x{}", "22".repeat(32))), - ) - .unwrap(); - match input { - EscrowHashInput::Provided(v) => assert_eq!(v, format!("0x{}", "11".repeat(32))), - EscrowHashInput::FromSwapInitTx(_) => panic!("expected provided escrow hash"), - } - } - - #[test] - fn test_resolve_escrow_hash_input_from_tx() { - let input = - resolve_escrow_hash_input(None, Some(format!("0x{}", "aa".repeat(32)))).unwrap(); - match input { - EscrowHashInput::FromSwapInitTx(v) => assert_eq!(v, format!("0x{}", "aa".repeat(32))), - EscrowHashInput::Provided(_) => panic!("expected tx hash source"), - } - } - - #[test] - fn test_resolve_escrow_hash_input_missing() { - let err = resolve_escrow_hash_input(None, None).unwrap_err().to_string(); - assert!(err.contains("--escrow-hash")); - } - - #[test] - fn test_resolve_escrow_hash_input_invalid_len() { - let err = - resolve_escrow_hash_input(Some("0x12".to_string()), None).unwrap_err().to_string(); - assert!(err.contains("exactly 32 bytes")); - } - - #[test] - fn test_parse_u256_decimal_and_hex() { - assert_eq!(parse_u256("10", "amount").unwrap(), U256::from(10u64)); - assert_eq!(parse_u256("0x0a", "amount").unwrap(), U256::from(10u64)); - } - - #[test] - fn test_parse_u256_from_json_value() { - assert_eq!( - parse_u256_from_json_value(&JsonValue::String("10".to_string()), "amount").unwrap(), - U256::from(10u64) - ); - assert_eq!( - parse_u256_from_json_value(&JsonValue::from(15u64), "amount").unwrap(), - U256::from(15u64) - ); - } - - #[test] - fn test_build_flags() { - let flags = build_flags(U256::from(7u64), true, true, false).unwrap(); - let expected = (U256::from(7u64) << 64) + U256::from(3u64); - assert_eq!(flags, expected); - assert!(should_pay_in(flags)); - } - - #[test] - fn test_generate_default_nonce() { - let nonce = generate_default_nonce(700_000_001, 0x12_34_56); - let expected = ((1u64 << 24) | 0x12_34_56u64).to_string(); - assert_eq!(nonce, expected); - } - - #[test] - fn test_generate_default_nonce_masks_high_bits() { - let nonce = generate_default_nonce(700_000_001, 0xab_cd_ef_12); - let expected = ((1u64 << 24) | 0xcd_ef_12u64).to_string(); - assert_eq!(nonce, expected); - } - - #[test] - fn test_generate_default_fee_rate() { - let fee_rate = generate_default_fee_rate(U256::from(100u64)); - assert_eq!(fee_rate, "125,5000000"); - } - - #[test] - fn test_generate_default_fee_rate_with_cap() { - let fee_rate = generate_default_fee_rate(U256::from(1_000_000_000_000u64)); - assert_eq!(fee_rate, "2000000000,5000000"); - } - - #[test] - fn test_convert_pegbtc_amount_to_base_units() { - assert_eq!(convert_pegbtc_amount_to_base_units("0.01").unwrap(), "10000000000000000"); - assert_eq!(convert_pegbtc_amount_to_base_units("1").unwrap(), "1000000000000000000"); - assert_eq!(convert_pegbtc_amount_to_base_units(".5").unwrap(), "500000000000000000"); - assert_eq!(convert_pegbtc_amount_to_base_units("0").unwrap(), "0"); - } - - #[test] - fn test_convert_pegbtc_amount_to_base_units_invalid_precision() { - let err = - convert_pegbtc_amount_to_base_units("0.1234567890123456789").unwrap_err().to_string(); - assert!(err.contains("too many decimal places")); - } - - #[test] - fn test_compute_initialize_value_wei() { - let offerer = EvmAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(); - let claimer = EvmAddress::from_str("0x2222222222222222222222222222222222222222").unwrap(); - let escrow = SwapEscrowData { - offerer, - claimer, - amount: U256::from(100u64), - token: EvmAddress::ZERO, - flags: U256::from(PAY_IN_FLAG), - claim_handler: EvmAddress::from_str("0x3333333333333333333333333333333333333333") - .unwrap(), - claim_data: [0u8; 32], - refund_handler: EvmAddress::from_str("0x4444444444444444444444444444444444444444") - .unwrap(), - refund_data: [0u8; 32], - security_deposit: U256::from(8u64), - claimer_bounty: U256::from(5u64), - deposit_token: EvmAddress::ZERO, - success_action_commitment: [0u8; 32], - }; - assert_eq!(compute_initialize_value_wei(offerer, &escrow), U256::from(108u64)); - } - - #[test] - fn test_requires_token_approval_true_for_payin_erc20_offerer() { - let sender = EvmAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(); - let escrow = SwapEscrowData { - offerer: sender, - claimer: EvmAddress::from_str("0x2222222222222222222222222222222222222222").unwrap(), - amount: U256::from(100u64), - token: EvmAddress::from_str("0x3333333333333333333333333333333333333333").unwrap(), - flags: U256::from(PAY_IN_FLAG), - claim_handler: EvmAddress::from_str("0x4444444444444444444444444444444444444444") - .unwrap(), - claim_data: [0u8; 32], - refund_handler: EvmAddress::from_str("0x5555555555555555555555555555555555555555") - .unwrap(), - refund_data: [0u8; 32], - security_deposit: U256::ZERO, - claimer_bounty: U256::ZERO, - deposit_token: EvmAddress::ZERO, - success_action_commitment: [0u8; 32], - }; - assert!(requires_token_approval(sender, &escrow)); - } - - #[test] - fn test_requires_token_approval_false_for_native_or_non_payin() { - let sender = EvmAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(); - let mut escrow = SwapEscrowData { - offerer: sender, - claimer: EvmAddress::from_str("0x2222222222222222222222222222222222222222").unwrap(), - amount: U256::from(100u64), - token: EvmAddress::ZERO, - flags: U256::from(PAY_IN_FLAG), - claim_handler: EvmAddress::from_str("0x4444444444444444444444444444444444444444") - .unwrap(), - claim_data: [0u8; 32], - refund_handler: EvmAddress::from_str("0x5555555555555555555555555555555555555555") - .unwrap(), - refund_data: [0u8; 32], - security_deposit: U256::ZERO, - claimer_bounty: U256::ZERO, - deposit_token: EvmAddress::ZERO, - success_action_commitment: [0u8; 32], - }; - assert!(!requires_token_approval(sender, &escrow)); - - escrow.token = EvmAddress::from_str("0x3333333333333333333333333333333333333333").unwrap(); - escrow.flags = U256::ZERO; - assert!(!requires_token_approval(sender, &escrow)); - } -} diff --git a/node/src/bin/send_challenge.rs b/node/src/bin/send_challenge.rs index c283e1fe3..072daffa5 100644 --- a/node/src/bin/send_challenge.rs +++ b/node/src/bin/send_challenge.rs @@ -19,9 +19,10 @@ use anyhow::{Context, Result}; use bitvm_noded::env::get_bitvm_key; use bitvm_noded::rpc_service::auth::{ - AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, sign_request_auth, + AUTH_NONCE_HEADER, AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, sign_request_auth, }; use clap::Parser; +use http::Method; use serde::Deserialize; #[derive(Debug, Parser)] @@ -50,19 +51,18 @@ struct SendChallengeResponse { async fn main() -> Result<()> { dotenv::dotenv().ok(); let args = Args::parse(); - let url = format!( - "{}/v1/graphs/{}/send-challenge", - args.rpc_url.trim_end_matches('/'), - args.graph_id - ); + let request_target = format!("/v1/graphs/{}/send-challenge", args.graph_id); + let url = format!("{}{}", args.rpc_url.trim_end_matches('/'), request_target); let keypair = get_bitvm_key().context("failed to load BITVM_SECRET")?; - let (timestamp, signature) = sign_request_auth(&keypair); + let (timestamp, nonce, signature) = + sign_request_auth(&keypair, &Method::POST, &request_target, &[]); let client = reqwest::Client::new(); let resp = client .post(&url) .header(AUTH_TIMESTAMP_HEADER, ×tamp) + .header(AUTH_NONCE_HEADER, &nonce) .header(AUTH_SIGNATURE_HEADER, &signature) .send() .await diff --git a/node/src/bin/send_pegout.rs b/node/src/bin/send_pegout.rs index 9df6cea25..744de9c12 100644 --- a/node/src/bin/send_pegout.rs +++ b/node/src/bin/send_pegout.rs @@ -17,9 +17,10 @@ use anyhow::{Context, Result, bail}; use bitvm_noded::env::get_bitvm_key; use bitvm_noded::rpc_service::auth::{ - AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, sign_request_auth, + AUTH_NONCE_HEADER, AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, sign_request_auth, }; use clap::{Parser, Subcommand}; +use http::Method; use serde::{Deserialize, Serialize}; use tokio::time::{Duration, sleep}; use uuid::Uuid; @@ -125,18 +126,23 @@ async fn call_pegout( dry_run: bool, skip_locked: bool, ) -> Result { - let url = format!("{}/v1/graphs/pegout", base_url.trim_end_matches('/')); + let request_target = "/v1/graphs/pegout"; + let url = format!("{}{}", base_url.trim_end_matches('/'), request_target); let body = PegoutApiRequest { graph_id: graph_id.map(|id| id.to_string()), dry_run, skip_locked }; + let body = serde_json::to_vec(&body).context("failed to serialize pegout API request")?; let keypair = get_bitvm_key().context("failed to load BITVM_SECRET")?; - let (timestamp, signature) = sign_request_auth(&keypair); + let (timestamp, nonce, signature) = + sign_request_auth(&keypair, &Method::POST, request_target, &body); let resp = client .post(&url) .header(AUTH_TIMESTAMP_HEADER, ×tamp) + .header(AUTH_NONCE_HEADER, &nonce) .header(AUTH_SIGNATURE_HEADER, &signature) - .json(&body) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body) .send() .await .with_context(|| format!("failed to reach node API at {url}"))?; diff --git a/node/src/bin/send_verifier_challenge.rs b/node/src/bin/send_verifier_challenge.rs index 5ba8dbb5e..7f6137c21 100644 --- a/node/src/bin/send_verifier_challenge.rs +++ b/node/src/bin/send_verifier_challenge.rs @@ -19,9 +19,10 @@ use anyhow::{Context, Result}; use bitvm_noded::env::get_bitvm_key; use bitvm_noded::rpc_service::auth::{ - AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, sign_request_auth, + AUTH_NONCE_HEADER, AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, sign_request_auth, }; use clap::Parser; +use http::Method; use serde::Deserialize; #[derive(Debug, Parser)] @@ -51,19 +52,18 @@ struct SendVerifierChallengeResponse { async fn main() -> Result<()> { dotenv::dotenv().ok(); let args = Args::parse(); - let url = format!( - "{}/v1/graphs/{}/send-verifier-challenge", - args.rpc_url.trim_end_matches('/'), - args.graph_id - ); + let request_target = format!("/v1/graphs/{}/send-verifier-challenge", args.graph_id); + let url = format!("{}{}", args.rpc_url.trim_end_matches('/'), request_target); let keypair = get_bitvm_key().context("failed to load BITVM_SECRET")?; - let (timestamp, signature) = sign_request_auth(&keypair); + let (timestamp, nonce, signature) = + sign_request_auth(&keypair, &Method::POST, &request_target, &[]); let client = reqwest::Client::new(); let resp = client .post(&url) .header(AUTH_TIMESTAMP_HEADER, ×tamp) + .header(AUTH_NONCE_HEADER, &nonce) .header(AUTH_SIGNATURE_HEADER, &signature) .send() .await diff --git a/node/src/env.rs b/node/src/env.rs index 97011bf21..adb399bc4 100644 --- a/node/src/env.rs +++ b/node/src/env.rs @@ -61,6 +61,11 @@ pub const ENV_ACTOR: &str = "ACTOR"; pub const ENV_IPFS_ENDPOINT: &str = "IPFS_ENDPOINT"; pub const ENV_COMMITTEE_NUM: &str = "COMMITTEE_NUM"; pub const ENV_MIN_REQUIRED_VERIFIER: &str = "MIN_REQUIRED_VERIFIER"; +pub const ENV_VERIFIER_CANDIDATE_BACKUP_COUNT: &str = "VERIFIER_CANDIDATE_BACKUP_COUNT"; +pub const ENV_VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS: &str = + "VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS"; +pub const ENV_P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS: &str = "P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS"; +pub const ENV_P2P_GRAPH_SETUP_RETRY_WINDOW_SECS: &str = "P2P_GRAPH_SETUP_RETRY_WINDOW_SECS"; pub const ENV_EXTERNAL_SOCKET_ADDR: &str = "EXTERNAL_SOCKET_ADDR"; pub const COMMITTEE_INSTANCE_KEYS_DIR: &str = "cache/committee-instance-keys/"; pub const SCRIPT_CACHE_FILE_NAME: &str = "cache/partial_script.bin"; @@ -71,6 +76,10 @@ pub const MAX_CUSTOM_INPUTS: usize = 100; pub const DEFAULT_CONFIRMATION_TARGET: u16 = 1; pub const DEFAULT_MIN_REQUIRED_VERIFIER: usize = 1; +pub const DEFAULT_VERIFIER_CANDIDATE_BACKUP_COUNT: usize = 2; +pub const DEFAULT_VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS: i64 = 15; +pub const DEFAULT_P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS: i64 = 10; +pub const DEFAULT_P2P_GRAPH_SETUP_RETRY_WINDOW_SECS: i64 = 180; pub const ENV_BITCOIN_NETWORK: &str = "BITCOIN_NETWORK"; pub const ENV_GOAT_NETWORK: &str = "GOAT_NETWORK"; @@ -589,6 +598,37 @@ pub fn get_min_required_verifier() -> anyhow::Result { } } +pub fn get_verifier_candidate_backup_count() -> usize { + std::env::var(ENV_VERIFIER_CANDIDATE_BACKUP_COUNT) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_VERIFIER_CANDIDATE_BACKUP_COUNT) +} + +pub fn get_verifier_candidate_collection_window_secs() -> i64 { + std::env::var(ENV_VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS) + .ok() + .and_then(|value| value.parse::().ok()) + .map(|value| value.clamp(1, 300)) + .unwrap_or(DEFAULT_VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS) +} + +pub fn get_p2p_graph_setup_retry_interval_secs() -> i64 { + std::env::var(ENV_P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS) + .ok() + .and_then(|value| value.parse::().ok()) + .map(|value| value.clamp(1, 300)) + .unwrap_or(DEFAULT_P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS) +} + +pub fn get_p2p_graph_setup_retry_window_secs() -> i64 { + std::env::var(ENV_P2P_GRAPH_SETUP_RETRY_WINDOW_SECS) + .ok() + .and_then(|value| value.parse::().ok()) + .map(|value| value.clamp(10, 3600)) + .unwrap_or(DEFAULT_P2P_GRAPH_SETUP_RETRY_WINDOW_SECS) +} + pub fn get_soldering_proof_payload_store_path() -> anyhow::Result { let value = std::env::var(ENV_SOLDERING_PROOF_PAYLOAD_STORE_PATH) .map_err(|_| anyhow::anyhow!("{ENV_SOLDERING_PROOF_PAYLOAD_STORE_PATH} needs to be set"))?; diff --git a/node/src/handle.rs b/node/src/handle.rs index 10a61c095..89d3a2021 100644 --- a/node/src/handle.rs +++ b/node/src/handle.rs @@ -1,19 +1,21 @@ use crate::action::*; use crate::env::{ COMMITTEE_INSTANCE_KEYS_DIR, get_babe_gc_asset_paths, get_bitvm_key, get_node_goat_address, - get_soldering_proof_payload_store_path, is_relayer, + get_peer_id, get_soldering_proof_payload_store_path, get_verifier_candidate_backup_count, + get_verifier_candidate_collection_window_secs, is_relayer, }; use crate::error::SpecialError; use crate::metrics_service::MetricsState; use crate::middleware::AllBehaviours; +use crate::rpc_service::current_time_secs; use crate::scheduled_tasks::graph_maintenance_tasks::ChallengeSubStatus; use crate::soldering_payload_store::{ read_soldering_proof_store_payload, soldering_proof_payload_store_path, }; use crate::utils::*; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, anyhow, bail, ensure}; use ark_serialize::CanonicalSerialize; -use bitcoin::{Amount, OutPoint, Txid, hashes::Hash}; +use bitcoin::{Amount, OutPoint, Txid, hashes::Hash, key::Keypair}; use bitcoin::{PublicKey, XOnlyPublicKey}; use bitvm_lib::actors::Actor; use bitvm_lib::babe_adapter::{ @@ -28,9 +30,12 @@ use bitvm_lib::babe_adapter::{ use bitvm_lib::committee::*; use bitvm_lib::keys::*; use bitvm_lib::operator::*; -use bitvm_lib::types::{BitvmGcCircuitData, BitvmGcGraph, SimplifiedBitvmGcGraph}; +use bitvm_lib::types::{ + BitvmGcCircuitData, BitvmGcGraph, BitvmGcInstanceParameters, SimplifiedBitvmGcGraph, +}; use bitvm_lib::verifier::*; use client::goat_chain::{DisproveTxType, PeginStatus, WithdrawStatus}; +use client::graphs::graph_query::BridgeInRequestEvent; use client::http_client::async_client::HttpAsyncClient; use client::{btc_chain::BTCClient, goat_chain::GOATClient}; use goat::transactions::base::output_topology; @@ -39,10 +44,12 @@ use goat::transactions::pre_signed_musig2::verify_public_nonce; use goat::wots::{Wots, Wots96}; use libp2p::gossipsub::MessageId; use libp2p::{PeerId, Swarm}; +use secp256k1::{Message as SecpMessage, SECP256K1}; +use std::str::FromStr; use std::sync::Arc; use std::time::Instant; use store::localdb::LocalDB; -use store::{GraphStatus, SerializableTxid}; +use store::{GoatTxType, GraphStatus, SerializableTxid}; use uuid::Uuid; pub struct HandlerContext<'a> { @@ -140,7 +147,7 @@ pub(crate) fn is_heavy_task_message_type(message_type: &str, actor: &Actor) -> b pub(crate) async fn run_heavy_task(context: &HeavyTaskContext, task: HeavyTask) -> Result<()> { match task { HeavyTask::GenerateVerifierSetup(message) => { - handle_init_graph_verifier(context, message.instance_id, message.graph_id).await + handle_init_graph_verifier(context, message).await } HeavyTask::GenerateSolderingProof(message) => { handle_cut_circuits_verifier( @@ -148,7 +155,7 @@ pub(crate) async fn run_heavy_task(context: &HeavyTaskContext, task: HeavyTask) message.instance_id, message.graph_id, &message.verifier_pubkey, - message.verifier_index, + message.candidate_index, &message.selected_circuit_indexes, ) .await @@ -168,7 +175,7 @@ pub(crate) async fn run_heavy_task(context: &HeavyTaskContext, task: HeavyTask) context, message.instance_id, message.graph_id, - message.verifier_index, + message.candidate_index, message.payload_hash, message.total_len, ) @@ -238,12 +245,43 @@ fn load_or_create_committee_instance_keypair( pub async fn dispatch(ctx: &mut HandlerContext<'_>, content: &GOATMessageContent) -> Result<()> { match (content, &ctx.actor) { + ( + GOATMessageContent::GraphSetupAck(GraphSetupAck { + outbox_id, + acknowledger_peer_id, + .. + }), + _, + ) => { + if acknowledger_peer_id != &ctx.from_peer_id.to_string() { + tracing::warn!( + outbox_id, + from_peer_id = %ctx.from_peer_id, + acknowledger_peer_id, + "Ignore GraphSetupAck with mismatched source peer" + ); + return Ok(()); + } + let acknowledged = ctx + .local_db + .acquire() + .await? + .acknowledge_p2p_outbox_message(outbox_id, acknowledger_peer_id) + .await?; + tracing::debug!( + outbox_id, + from_peer_id = %ctx.from_peer_id, + acknowledged, + "processed GraphSetupAck" + ); + Ok(()) + } ( GOATMessageContent::PeginRequest(PeginRequest { instance_id, pegin_request_tx_hash, pegin_request_height, - pegin_timestamp, + .. }), Actor::Committee, ) => { @@ -252,7 +290,6 @@ pub async fn dispatch(ctx: &mut HandlerContext<'_>, content: &GOATMessageContent *instance_id, pegin_request_tx_hash, *pegin_request_height, - *pegin_timestamp, ) .await } @@ -261,7 +298,7 @@ pub async fn dispatch(ctx: &mut HandlerContext<'_>, content: &GOATMessageContent instance_id, pegin_request_tx_hash, pegin_request_height, - pegin_timestamp, + .. }), _, ) => { @@ -270,7 +307,6 @@ pub async fn dispatch(ctx: &mut HandlerContext<'_>, content: &GOATMessageContent *instance_id, pegin_request_tx_hash, *pegin_request_height, - *pegin_timestamp, ) .await } @@ -384,6 +420,27 @@ pub async fn dispatch(ctx: &mut HandlerContext<'_>, content: &GOATMessageContent ) .await } + ( + GOATMessageContent::AggNonceConsensus(AggNonceConsensus { + instance_id, + graph_id, + committee_pubkey: received_committee_pubkey, + consensus_hash, + signature, + }), + Actor::Committee, + ) => { + handle_agg_nonce_consensus_committee( + ctx, + *instance_id, + *graph_id, + received_committee_pubkey, + *consensus_hash, + signature, + content, + ) + .await + } ( GOATMessageContent::CommitteePresign(CommitteePresign { instance_id, @@ -511,6 +568,25 @@ pub async fn dispatch(ctx: &mut HandlerContext<'_>, content: &GOATMessageContent ) .await } + ( + GOATMessageContent::PeginConfirmNonceConsensus(PeginConfirmNonceConsensus { + instance_id, + committee_pubkey: received_committee_pubkey, + consensus_hash, + signature, + }), + Actor::Committee, + ) => { + handle_pegin_confirm_nonce_consensus_committee( + ctx, + *instance_id, + received_committee_pubkey, + *consensus_hash, + signature, + content, + ) + .await + } ( GOATMessageContent::PeginConfirmPartialSig(PeginConfirmPartialSig { instance_id, @@ -740,6 +816,17 @@ fn make_message(ctx: &HandlerContext<'_>, content: &GOATMessageContent) -> GOATM GOATMessage::new(ctx.actor.clone(), content.clone()) } +fn build_signed_init_graph( + instance_id: Uuid, + graph_id: Uuid, + operator_keypair: &Keypair, +) -> Result { + let operator_peer_id = PeerId::from_str(&get_peer_id()) + .context("decode local operator peer id for InitGraph")? + .to_bytes(); + Ok(sign_init_graph(instance_id, graph_id, operator_keypair, operator_peer_id)) +} + /// A graph-bearing message has two identity representations: its envelope and /// the signed graph parameters. Never use one to read/write local state while /// using the other to reconstruct or scan the graph. @@ -766,9 +853,10 @@ fn message_identity_matches( false } -/// Freezes the first accepted Verifiers and assigns deterministic graph slots. +/// Closes a bounded candidate collection window and assigns provisional slots. +/// Final graph slots are assigned only after valid soldering proofs are collected. fn freeze_operator_candidates(state: &mut OperatorBabeSetupState) -> Result<()> { - if state.frozen_verifier_pubkeys.is_some() { + if state.candidate_verifier_pubkeys.is_some() { return Ok(()); } if state.candidates.len() < min_required_verifier() { @@ -784,38 +872,92 @@ fn freeze_operator_candidates(state: &mut OperatorBabeSetupState) -> Result<()> bail!("cannot freeze duplicate verifier peer id"); } } - state.candidates.truncate(min_required_verifier()); state.candidates.sort_by_key(|candidate| candidate.verifier_pubkey.to_bytes()); - for (verifier_index, candidate) in state.candidates.iter_mut().enumerate() { - candidate.verifier_index = Some(verifier_index); + for (candidate_index, candidate) in state.candidates.iter_mut().enumerate() { + candidate.candidate_index = Some(candidate_index); candidate.selected_circuit_indexes = derive_finalized_indices(&candidate.setup_package, BABE_M_CC)?; } - state.frozen_verifier_pubkeys = + state.candidate_verifier_pubkeys = Some(state.candidates.iter().map(|candidate| candidate.verifier_pubkey).collect()); Ok(()) } -/// Stores one selected Verifier slot and emits ordered graph data when complete. +fn seal_selected_verifiers( + state: &mut OperatorBabeSetupState, + selected_verifier_pubkeys: Vec, +) -> Result<()> { + if let Some(existing) = &state.selected_verifier_pubkeys { + if existing != &selected_verifier_pubkeys { + bail!("refuse to replace the sealed verifier selection"); + } + return Ok(()); + } + if selected_verifier_pubkeys.len() != min_required_verifier() { + bail!( + "selected verifier count {} does not match required {}", + selected_verifier_pubkeys.len(), + min_required_verifier() + ); + } + if selected_verifier_pubkeys.windows(2).any(|pair| pair[0].to_bytes() >= pair[1].to_bytes()) { + bail!("selected verifier public keys must be unique and canonically ordered"); + } + if selected_verifier_pubkeys.iter().any(|selected| { + !state.candidates.iter().any(|candidate| candidate.verifier_pubkey == *selected) + }) { + bail!("sealed verifier selection contains a non-candidate public key"); + } + state.selected_verifier_pubkeys = Some(selected_verifier_pubkeys); + Ok(()) +} + +fn selected_gc_data(state: &OperatorBabeSetupState) -> Result> { + let selected = state + .selected_verifier_pubkeys + .as_ref() + .ok_or_else(|| anyhow!("verifier selection is not sealed"))?; + selected + .iter() + .map(|verifier_pubkey| { + state + .candidates + .iter() + .find(|candidate| candidate.verifier_pubkey == *verifier_pubkey) + .and_then(|candidate| candidate.gc_data.clone()) + .ok_or_else(|| { + anyhow!("missing verified GC data for sealed verifier {verifier_pubkey}") + }) + }) + .collect() +} + +fn selected_candidate_for_graph_index( + state: &OperatorBabeSetupState, + verifier_index: usize, +) -> Result<&OperatorVerifierCandidate> { + let verifier_pubkey = state + .selected_verifier_pubkeys + .as_ref() + .and_then(|selected| selected.get(verifier_index)) + .ok_or_else(|| anyhow!("missing sealed verifier slot {verifier_index}"))?; + state + .candidates + .iter() + .find(|candidate| candidate.verifier_pubkey == *verifier_pubkey) + .ok_or_else(|| anyhow!("missing candidate for sealed verifier slot {verifier_index}")) +} + +/// Stores verified proof data against its immutable candidate slot. fn record_candidate_gc_data( state: &mut OperatorBabeSetupState, verifier_pubkey: PublicKey, - verifier_index: usize, + candidate_index: usize, setup_package: &CACSetupPackage, gc_data: BitvmGcCircuitData, prover_state: &BabeProverState, soldering_proof_ready: SolderingProofReady, -) -> Result>> { - let frozen = state - .frozen_verifier_pubkeys - .as_ref() - .ok_or_else(|| anyhow!("operator verifier membership is not frozen"))?; - let expected_pubkey = frozen - .get(verifier_index) - .ok_or_else(|| anyhow!("verifier index {verifier_index} out of frozen slot range"))?; - if expected_pubkey != &verifier_pubkey { - bail!("verifier public key does not own slot {verifier_index}"); - } +) -> Result<()> { if gc_data.verifier_pubkey != verifier_pubkey { bail!("GC slot owner does not match soldering proof verifier"); } @@ -824,8 +966,8 @@ fn record_candidate_gc_data( .iter_mut() .find(|candidate| candidate.verifier_pubkey == verifier_pubkey) .ok_or_else(|| anyhow!("selected verifier candidate is missing"))?; - if candidate.verifier_index != Some(verifier_index) { - bail!("candidate verifier index does not match soldering proof slot"); + if candidate.candidate_index != Some(candidate_index) { + bail!("candidate index does not match soldering proof slot"); } if candidate.setup_package != *setup_package { bail!("soldering proof setup package does not match selected verifier candidate"); @@ -836,8 +978,8 @@ fn record_candidate_gc_data( if candidate.selected_circuit_indexes != prover_state.soldering.finalized_indices { bail!("BABE prover state finalized indices do not match selected verifier cut"); } - if soldering_proof_ready.verifier_index != verifier_index { - bail!("soldering proof reference verifier index does not match selected verifier slot"); + if soldering_proof_ready.candidate_index != candidate_index { + bail!("soldering proof reference candidate index does not match selected verifier"); } if prover_state.finalized.len() != BABE_M_CC || prover_state.h_msgs.len() != BABE_M_CC { bail!("BABE prover state must contain exactly {BABE_M_CC} finalized instances and hashes"); @@ -848,12 +990,12 @@ fn record_candidate_gc_data( if let Some(existing) = &candidate.gc_data && existing != &gc_data { - bail!("conflicting GC slot received for selected verifier"); + bail!("conflicting GC data received for verifier candidate"); } if let Some(existing) = &candidate.soldering_proof_ready && existing != &soldering_proof_ready { - bail!("conflicting soldering proof reference received for selected verifier"); + bail!("conflicting soldering proof reference received for verifier candidate"); } if candidate.gc_data.is_none() { candidate.gc_data = Some(gc_data); @@ -861,10 +1003,7 @@ fn record_candidate_gc_data( if candidate.soldering_proof_ready.is_none() { candidate.soldering_proof_ready = Some(soldering_proof_ready); } - if state.candidates.iter().any(|candidate| candidate.gc_data.is_none()) { - return Ok(None); - } - Ok(Some(state.candidates.iter().map(|candidate| candidate.gc_data.clone().unwrap()).collect())) + Ok(()) } fn build_babe_prover_state( @@ -978,6 +1117,63 @@ fn validate_expected_challenge_assert_txid( Ok(()) } +/// Resolve the request metadata a `PeginRequest` carries from the canonical +/// `BridgeInRequest` event this node indexed itself. +/// +/// The message is gossiped, so its tx hash, height and timestamp are all +/// sender-controlled, and the height alone decides +/// whether the instance ever matches the response-window query in +/// `instance_window_expiration_monitor`. The event watcher already stores the +/// canonical request transaction in `goat_tx_record`, so take all three values +/// from there and never from the message. +/// +/// Returns `None` when the event watcher has not indexed the request yet. The +/// current trigger is ignored in that case: once the watcher observes the +/// event, `instance_answers_monitor` creates a canonical local `PeginRequest`. +async fn resolve_pegin_request_metadata( + local_db: &LocalDB, + instance_id: Uuid, + pegin_request_tx_hash: &str, + pegin_request_height: i64, +) -> Result> { + let tx_record = { + let mut storage_processor = local_db.acquire().await?; + storage_processor + .find_graph_goat_tx_record( + &instance_id, + &Uuid::nil(), + &GoatTxType::BridgeInRequest.to_string(), + ) + .await? + }; + let Some(tx_record) = tx_record else { + tracing::info!( + "Ignore PeginRequest for {instance_id}: no local BridgeInRequest event record yet; \ + the event watcher will enqueue the canonical request" + ); + return Ok(None); + }; + if tx_record.tx_hash != pegin_request_tx_hash || tx_record.height != pegin_request_height { + // Not fatal - the canonical record wins either way - but a mismatch is + // the signature of a forged or stale request, so make it visible. + tracing::warn!( + "PeginRequest for {instance_id} claims tx {pegin_request_tx_hash} at height \ + {pegin_request_height}, the indexed BridgeInRequest is tx {} at height {}", + tx_record.tx_hash, + tx_record.height + ); + } + // The block timestamp comes from the same indexed event; fall back to now + // rather than to anything the sender supplied. + let timestamp = tx_record + .extra + .as_deref() + .and_then(|extra| serde_json::from_str::(extra).ok()) + .and_then(|event| event.block_timestamp.parse::().ok()) + .unwrap_or_else(current_time_secs); + Ok(Some((tx_record.tx_hash, tx_record.height, timestamp))) +} + fn should_ignore_invalid_pegin_request(e: &anyhow::Error, instance_id: Uuid) -> bool { if let Some(SpecialError::InvalidPeginRequest(err_msg)) = e.downcast_ref::() { tracing::warn!("Ignore PeginRequest for {instance_id}: {err_msg}"); @@ -1213,11 +1409,22 @@ async fn handle_pegin_request_committee( instance_id: Uuid, pegin_request_tx_hash: &str, pegin_request_height: i64, - pegin_timestamp: i64, ) -> Result<()> { // triggered by BridgeInRequest event - // 1. read & check the pegin request data - let (user_info, pegin_amount) = + // 1. bind the request metadata to the event this node indexed itself + let Some((pegin_request_tx_hash, pegin_request_height, pegin_timestamp)) = + resolve_pegin_request_metadata( + ctx.local_db, + instance_id, + pegin_request_tx_hash, + pegin_request_height, + ) + .await? + else { + return Ok(()); + }; + // 2. read & check the pegin request data + let (user_info, pegin_amount, answered_by) = match read_pegin_request(ctx.btc_client, ctx.goat_client, instance_id).await { Ok(v) => v, Err(e) => { @@ -1228,21 +1435,39 @@ async fn handle_pegin_request_committee( } }; - // 2. save the pegin request data to local db - store_pegin_request( + // 3. save the pegin request data to local db + let stored = store_pegin_request( ctx.btc_client, ctx.local_db, GenerateInstanceParams { instance_id, user_info, pegin_amount, - pegin_request_tx_hash: pegin_request_tx_hash.to_string(), + pegin_request_tx_hash, pegin_request_height, pegin_timestamp, }, ) .await?; - // 3. call Gateway.answerPeginRequest + if !stored { + // The instance has already moved on, so answering now would be a + // duplicate on-chain call for a request this node handled long ago. + return Ok(()); + } + // 4. call Gateway.answerPeginRequest + // + // PeginRequest is gossiped and re-deliverable, and the pegin data stays + // Pending while answers accumulate, so nothing above notices a replay. + // Gateway.answerPeginRequest does not reject a repeat answer either - it + // overwrites the stored pubkey and re-emits CommitteeResponse - so without + // this every re-delivery spends another transaction. The contract keys + // answers on msg.sender, so compare against the signer that sends it. + if answered_by.contains(&ctx.goat_client.get_default_signer_address()) { + tracing::info!( + "Skip answerPeginRequest for {instance_id}: this committee already answered" + ); + return Ok(()); + } let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); let instance_keypair = load_or_create_committee_instance_keypair(&committee_master_key, instance_id)?; @@ -1257,11 +1482,22 @@ async fn handle_pegin_request_default( instance_id: Uuid, pegin_request_tx_hash: &str, pegin_request_height: i64, - pegin_timestamp: i64, ) -> Result<()> { // triggered by BridgeInRequest event - // 1. read & check the pegin request data - let (user_info, pegin_amount) = + // 1. bind the request metadata to the event this node indexed itself + let Some((pegin_request_tx_hash, pegin_request_height, pegin_timestamp)) = + resolve_pegin_request_metadata( + ctx.local_db, + instance_id, + pegin_request_tx_hash, + pegin_request_height, + ) + .await? + else { + return Ok(()); + }; + // 2. read & check the pegin request data + let (user_info, pegin_amount, _) = match read_pegin_request(ctx.btc_client, ctx.goat_client, instance_id).await { Ok(v) => v, Err(e) => { @@ -1271,7 +1507,7 @@ async fn handle_pegin_request_default( bail!(e) } }; - // 2. save the pegin request data to local db + // 3. save the pegin request data to local db store_pegin_request( ctx.btc_client, ctx.local_db, @@ -1279,7 +1515,7 @@ async fn handle_pegin_request_default( instance_id, user_info, pegin_amount, - pegin_request_tx_hash: pegin_request_tx_hash.to_string(), + pegin_request_tx_hash, pegin_request_height, pegin_timestamp, }, @@ -1438,8 +1674,17 @@ async fn handle_confirm_instance_operator( return Ok(()); } tracing::info!("Resume pending graph setup for {instance_id}, graph_id: {graph_id}"); - let message_content = GOATMessageContent::InitGraph(InitGraph { instance_id, graph_id }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::Verifier, message_content)).await?; + let message_content = GOATMessageContent::InitGraph(build_signed_init_graph( + instance_id, + graph_id, + &operator_master_key.master_keypair(), + )?); + enqueue_graph_setup_outbox_message( + ctx.local_db, + GOATMessage::new(Actor::Verifier, message_content), + None, + ) + .await?; return Ok(()); } @@ -1483,26 +1728,74 @@ async fn handle_confirm_instance_operator( storage .upsert_pending_graph_init(&instance_id, &local_operator_pubkey.to_string(), &graph_id) .await?; - let message_content = GOATMessageContent::InitGraph(InitGraph { instance_id, graph_id }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::Verifier, message_content)).await?; + let message_content = GOATMessageContent::InitGraph(build_signed_init_graph( + instance_id, + graph_id, + &operator_master_key.master_keypair(), + )?); + enqueue_graph_setup_outbox_message( + ctx.local_db, + GOATMessage::new(Actor::Verifier, message_content), + None, + ) + .await?; Ok(()) } // Generate garbled circuits and enqueue GenCircuits without blocking the swarm. -#[tracing::instrument(level = "info", skip_all, fields(instance_id = %instance_id, graph_id = %graph_id))] -async fn handle_init_graph_verifier( - context: &HeavyTaskContext, - instance_id: Uuid, - graph_id: Uuid, -) -> Result<()> { +#[tracing::instrument(level = "info", skip_all, fields(instance_id = %message.instance_id, graph_id = %message.graph_id))] +async fn handle_init_graph_verifier(context: &HeavyTaskContext, message: InitGraph) -> Result<()> { + let InitGraph { instance_id, graph_id, operator_pubkey, operator_peer_id, .. } = &message; + if operator_peer_id != &context.from_peer_id.to_bytes() { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + from_peer_id = %context.from_peer_id, + "Ignore InitGraph whose signed operator peer id differs from its P2P source" + ); + return Ok(()); + } + if !verify_init_graph_signature(&message) { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + operator_pubkey = %operator_pubkey, + "Ignore InitGraph with invalid operator signature" + ); + return Ok(()); + } + if let Err(error) = validate_operator_stake(&context.goat_client, operator_pubkey).await { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + operator_pubkey = %operator_pubkey, + error = %error, + "Ignore InitGraph from an operator that does not meet stake requirements" + ); + return Ok(()); + } + let verifier_master_key = VerifierMasterKey::new(get_bitvm_key()?); let verifier_pubkey = verifier_master_key.master_keypair().public_key().into(); - let saved_verifier_state = load_babe_setup_state(&context.local_db, instance_id, graph_id)? + let saved_verifier_state = load_babe_setup_state(&context.local_db, *instance_id, *graph_id)? .and_then(|state| state.verifier) .filter(|state| state.verifier_pubkey == verifier_pubkey); let verifier_state = if let Some(saved) = saved_verifier_state { + if saved.operator_pubkey != *operator_pubkey || saved.operator_peer_id != *operator_peer_id + { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + expected_operator_peer = %PeerId::from_bytes(&saved.operator_peer_id) + .map(|peer_id| peer_id.to_string()) + .unwrap_or_else(|_| "invalid".to_owned()), + from_peer_id = %context.from_peer_id, + "Ignore InitGraph with a different authenticated operator identity for an existing verifier setup" + ); + return Ok(()); + } saved } else { get_babe_gc_asset_paths()?; @@ -1516,6 +1809,8 @@ async fn handle_init_graph_verifier( tracing::info!("Verifier setup done."); VerifierBabeSetupState { verifier_pubkey, + operator_pubkey: *operator_pubkey, + operator_peer_id: operator_peer_id.clone(), setup_package, private_state, finalized_indices: vec![], @@ -1524,23 +1819,23 @@ async fn handle_init_graph_verifier( }; let setup_package = verifier_state.setup_package.clone(); - update_babe_setup_state(&context.local_db, instance_id, graph_id, |state| { + let operator_peer_id = PeerId::from_bytes(&verifier_state.operator_peer_id) + .map(|peer_id| peer_id.to_string()) + .context("decode operator peer id saved from InitGraph")?; + update_babe_setup_state(&context.local_db, *instance_id, *graph_id, |state| { state.verifier = Some(verifier_state); })?; - let gen_circuits = GenCircuits { instance_id, graph_id, verifier_pubkey, setup_package }; - let outbox_id = format!( - "gen-circuits:{}:{}:{}", - gen_circuits.instance_id, gen_circuits.graph_id, gen_circuits.verifier_pubkey, - ); + let gen_circuits = GenCircuits { + instance_id: *instance_id, + graph_id: *graph_id, + verifier_pubkey, + setup_package, + }; let message = GOATMessage::new(Actor::Operator, GOATMessageContent::GenCircuits(gen_circuits)); - let serialized = message.serialize_message().await?; - context - .local_db - .acquire() - .await? - .enqueue_p2p_outbox_message(&outbox_id, message.content.event_type(), &serialized) - .await?; + let outbox_id = + enqueue_graph_setup_outbox_message(&context.local_db, message, Some(&operator_peer_id)) + .await?; tracing::info!( event = "verifier_gc_setup", outcome = "enqueued", @@ -1599,12 +1894,15 @@ async fn handle_gen_circuits_operator( let mut state = load_babe_setup_state(ctx.local_db, instance_id, graph_id)?.unwrap_or_default(); let operator_state = state.operator.get_or_insert_with(|| OperatorBabeSetupState { - frozen_verifier_pubkeys: None, + candidate_verifier_pubkeys: None, candidates: vec![], + candidate_collection_started_at: None, + proof_collection_started_at: None, + selected_verifier_pubkeys: None, asserted_operator_proof: None, }); - let was_frozen = operator_state.frozen_verifier_pubkeys.is_some(); + let was_frozen = operator_state.candidate_verifier_pubkeys.is_some(); if let Some(existing) = operator_state .candidates .iter() @@ -1636,49 +1934,74 @@ async fn handle_gen_circuits_operator( return Ok(()); } else { + if operator_state.candidates.len() + >= min_required_verifier().saturating_add(get_verifier_candidate_backup_count()) + { + tracing::debug!( + "Ignore GenCircuits for {instance_id}:{graph_id}: candidate collection reached its configured limit" + ); + return Ok(()); + } operator_state.candidates.push(OperatorVerifierCandidate { verifier_peer_id, verifier_pubkey: *verifier_pubkey, setup_package: setup_package.clone(), - verifier_index: None, + candidate_index: None, selected_circuit_indexes: vec![], gc_data: None, soldering_proof_ready: None, }); + operator_state.candidate_collection_started_at.get_or_insert_with(current_time_secs); + } + + if operator_state.candidate_verifier_pubkeys.is_none() { + let started_at = + operator_state.candidate_collection_started_at.get_or_insert_with(current_time_secs); + let elapsed = current_time_secs() - *started_at; + let window = get_verifier_candidate_collection_window_secs(); + let reached_limit = operator_state.candidates.len() + >= min_required_verifier().saturating_add(get_verifier_candidate_backup_count()); + if operator_state.candidates.len() < min_required_verifier() + || (!reached_limit && elapsed < window) + { + let retry_after_secs = (window - elapsed).max(1); + let candidate_count = operator_state.candidates.len(); + save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state)?; + return Err(retryable_dispatch_error( + RetryableDispatchReason::DependencyPending, + Some(retry_after_secs), + format!( + "collecting verifier candidates: {}/{} received", + candidate_count, + min_required_verifier(), + ), + )); + } } - if operator_state.frozen_verifier_pubkeys.is_none() - && operator_state.candidates.len() < min_required_verifier() - { - save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state)?; - - return Ok(()); + if operator_state.candidate_verifier_pubkeys.is_none() { + freeze_operator_candidates(operator_state)?; } - - freeze_operator_candidates(operator_state)?; - let cut_candidates = if was_frozen { - operator_state - .candidates - .iter() - .filter(|candidate| candidate.verifier_pubkey == *verifier_pubkey) - .cloned() - .collect::>() - } else { - operator_state.candidates.clone() - }; + let cut_candidates = if was_frozen { Vec::new() } else { operator_state.candidates.clone() }; save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state)?; for candidate in cut_candidates { - let message_content = GOATMessageContent::CutCircuits(CutCircuits { - instance_id, - graph_id, - verifier_pubkey: candidate.verifier_pubkey, - verifier_index: candidate.verifier_index.unwrap(), - selected_circuit_indexes: candidate.selected_circuit_indexes, - }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::Verifier, message_content)).await?; + let message = GOATMessage::new( + Actor::Verifier, + GOATMessageContent::CutCircuits(CutCircuits { + instance_id, + graph_id, + verifier_pubkey: candidate.verifier_pubkey, + candidate_index: candidate + .candidate_index + .expect("candidate slot assigned before CutCircuits"), + selected_circuit_indexes: candidate.selected_circuit_indexes, + }), + ); + let ack_peer_id = + PeerId::from_bytes(&candidate.verifier_peer_id).map(|peer_id| peer_id.to_string()).ok(); + enqueue_graph_setup_outbox_message(ctx.local_db, message, ack_peer_id.as_deref()).await?; } - Ok(()) } @@ -1689,7 +2012,7 @@ async fn handle_cut_circuits_verifier( instance_id: Uuid, graph_id: Uuid, verifier_pubkey: &PublicKey, - verifier_index: usize, + candidate_index: usize, selected_circuit_indexes: &Vec, ) -> Result<()> { let verifier_master_key = VerifierMasterKey::new(get_bitvm_key()?); @@ -1718,18 +2041,42 @@ async fn handle_cut_circuits_verifier( return Ok(()); } + if verifier_state.operator_peer_id != context.from_peer_id.to_bytes() { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + from_peer_id = %context.from_peer_id, + "Ignore CutCircuits from a peer other than the operator that initiated verifier setup" + ); + return Ok(()); + } if let Some(soldering_proof_ready) = verifier_state.soldering_proof_ready.clone() { - if soldering_proof_ready.verifier_index != verifier_index { + if soldering_proof_ready.candidate_index != candidate_index { bail!( - "CutCircuits verifier index {verifier_index} conflicts with persisted slot {}", - soldering_proof_ready.verifier_index + "CutCircuits candidate index {candidate_index} conflicts with persisted slot {}", + soldering_proof_ready.candidate_index ); } if verifier_state.finalized_indices != *selected_circuit_indexes { bail!("CutCircuits finalized indices conflict with persisted selection"); } - enqueue_soldering_proof_ready(context, soldering_proof_ready).await?; + let message = GOATMessage::new( + Actor::Operator, + GOATMessageContent::SolderingProofReady(soldering_proof_ready), + ); + let operator_peer_id = PeerId::from_bytes(&verifier_state.operator_peer_id) + .map(|peer_id| peer_id.to_string()) + .context("decode operator peer id saved from InitGraph")?; + let outbox_id = + enqueue_graph_setup_outbox_message(&context.local_db, message, Some(&operator_peer_id)) + .await?; + tracing::info!( + event = "verifier_soldering_proof", + outcome = "enqueued", + outbox_id, + "enqueued SolderingProofReady for swarm publication" + ); return Ok(()); } @@ -1764,7 +2111,7 @@ async fn handle_cut_circuits_verifier( let soldering_proof_ready = save_soldering_proof_payload( instance_id, graph_id, - verifier_index, + candidate_index, &opened, &finalized, &soldering, @@ -1773,42 +2120,27 @@ async fn handle_cut_circuits_verifier( verifier_state.finalized_indices = selected_circuit_indexes.clone(); verifier_state.soldering_proof_ready = Some(soldering_proof_ready.clone()); + let operator_peer_id = PeerId::from_bytes(&verifier_state.operator_peer_id) + .map(|peer_id| peer_id.to_string()) + .context("decode operator peer id saved from InitGraph")?; update_babe_setup_state(&context.local_db, instance_id, graph_id, |state| { state.verifier = Some(verifier_state); })?; - enqueue_soldering_proof_ready(context, soldering_proof_ready).await?; - - Ok(()) -} - -async fn enqueue_soldering_proof_ready( - context: &HeavyTaskContext, - soldering_proof_ready: SolderingProofReady, -) -> Result<()> { - let outbox_id = format!( - "soldering-proof-ready:{}:{}:{}", - soldering_proof_ready.graph_id, - soldering_proof_ready.verifier_index, - hex::encode(soldering_proof_ready.payload_hash), - ); let message = GOATMessage::new( Actor::Operator, GOATMessageContent::SolderingProofReady(soldering_proof_ready), ); - let serialized = message.serialize_message().await?; - context - .local_db - .acquire() - .await? - .enqueue_p2p_outbox_message(&outbox_id, message.content.event_type(), &serialized) - .await?; + let outbox_id = + enqueue_graph_setup_outbox_message(&context.local_db, message, Some(&operator_peer_id)) + .await?; tracing::info!( event = "verifier_soldering_proof", outcome = "enqueued", outbox_id, "enqueued SolderingProofReady for swarm publication" ); + Ok(()) } @@ -1817,7 +2149,7 @@ pub(crate) async fn handle_soldering_proof_ready_operator( context: &HeavyTaskContext, instance_id: Uuid, graph_id: Uuid, - verifier_index: usize, + candidate_index: usize, payload_hash: [u8; 32], total_len: usize, ) -> Result<()> { @@ -1825,7 +2157,7 @@ pub(crate) async fn handle_soldering_proof_ready_operator( bail!("SolderingProofReady total_len must be greater than zero"); } let soldering_proof_ready = - SolderingProofReady { instance_id, graph_id, verifier_index, payload_hash, total_len }; + SolderingProofReady { instance_id, graph_id, candidate_index, payload_hash, total_len }; let operator_master_key = OperatorMasterKey::new(get_bitvm_key()?); let local_operator_pubkey = operator_master_key.master_keypair().public_key().into(); if !pending_graph_belongs_to_operator( @@ -1858,28 +2190,40 @@ pub(crate) async fn handle_soldering_proof_ready_operator( format!("missing operator BABE setup state for pending graph {graph_id}"), ) })?; - let frozen = operator_state.frozen_verifier_pubkeys.as_ref().ok_or_else(|| { + let frozen = operator_state.candidate_verifier_pubkeys.as_ref().ok_or_else(|| { retryable_dispatch_error( RetryableDispatchReason::DependencyPending, Some(30), "operator verifier membership is not frozen", ) })?; - let verifier_pubkey = frozen.get(verifier_index).ok_or_else(|| { - anyhow!("SolderingProofReady verifier index {verifier_index} out of range") + let verifier_pubkey = frozen.get(candidate_index).ok_or_else(|| { + anyhow!("SolderingProofReady candidate index {candidate_index} out of range") })?; let candidate = operator_state .candidates .iter() .find(|candidate| candidate.verifier_pubkey == *verifier_pubkey) .ok_or_else(|| anyhow!("selected verifier candidate is missing"))?; - if candidate.verifier_index != Some(verifier_index) { + if candidate.candidate_index != Some(candidate_index) { bail!("selected verifier candidate index does not match SolderingProofReady slot"); } + if operator_state.selected_verifier_pubkeys.as_ref().is_some_and(|selected| { + !selected.iter().any(|selected_pubkey| selected_pubkey == verifier_pubkey) + }) { + tracing::info!( + instance_id = %instance_id, + graph_id = %graph_id, + candidate_index, + verifier_pubkey = %verifier_pubkey, + "Ignore late SolderingProofReady from verifier outside the sealed graph selection" + ); + return Ok(()); + } let verifier_peer_id = context.from_peer_id.to_bytes(); if candidate.verifier_peer_id != verifier_peer_id { tracing::warn!( - "Ignore SolderingProofReady for {instance_id}:{graph_id}: sender {} does not own verifier slot {verifier_index}", + "Ignore SolderingProofReady for {instance_id}:{graph_id}: sender {} does not own verifier candidate {candidate_index}", context.from_peer_id ); return Ok(()); @@ -1890,14 +2234,14 @@ pub(crate) async fn handle_soldering_proof_ready_operator( &store_base_path, instance_id, graph_id, - verifier_index, + candidate_index, &payload_hash, )?; tracing::info!( from_peer_id = %context.from_peer_id, instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, total_len, payload_hash = %soldering_payload_hash_hex(&payload_hash), payload_path = %payload_path, @@ -1909,7 +2253,7 @@ pub(crate) async fn handle_soldering_proof_ready_operator( tracing::error!( instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, payload_path = %payload_path, error = %err, "failed to read soldering proof payload from store" @@ -1924,7 +2268,7 @@ pub(crate) async fn handle_soldering_proof_ready_operator( tracing::info!( instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, bytes = payload.len(), total_len, payload_hash = %soldering_payload_hash_hex(&payload_hash), @@ -1941,13 +2285,13 @@ fn decode_soldering_proof_payload( soldering_proof_ready: &SolderingProofReady, payload: &[u8], ) -> Result { - let SolderingProofReady { instance_id, graph_id, verifier_index, payload_hash, total_len } = + let SolderingProofReady { instance_id, graph_id, candidate_index, payload_hash, total_len } = soldering_proof_ready; if payload.len() != *total_len { tracing::warn!( instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, actual_len = payload.len(), total_len, payload_hash = %soldering_payload_hash_hex(payload_hash), @@ -1963,7 +2307,7 @@ fn decode_soldering_proof_payload( tracing::warn!( instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, expected_hash = %soldering_payload_hash_hex(payload_hash), actual_hash = %soldering_payload_hash_hex(&actual_hash), "SolderingProof payload hash mismatch" @@ -1983,7 +2327,7 @@ pub(crate) async fn handle_soldering_proof_payload_operator( event = "operator_soldering_proof", stage = "payload_decode", outcome = "started", - verifier_index = soldering_proof_ready.verifier_index, + candidate_index = soldering_proof_ready.candidate_index, payload_len = payload.len(), "decoding soldering proof payload" ); @@ -1992,7 +2336,7 @@ pub(crate) async fn handle_soldering_proof_payload_operator( event = "operator_soldering_proof", stage = "payload_decode", outcome = "completed", - verifier_index = soldering_proof_ready.verifier_index, + candidate_index = soldering_proof_ready.candidate_index, elapsed_ms = decode_started_at.elapsed().as_millis(), "decoded soldering proof payload" ); @@ -2008,7 +2352,7 @@ async fn handle_compact_soldering_proof_operator( ) -> Result<()> { let instance_id = soldering_proof_ready.instance_id; let graph_id = soldering_proof_ready.graph_id; - let verifier_index = soldering_proof_ready.verifier_index; + let candidate_index = soldering_proof_ready.candidate_index; let operator_master_key = OperatorMasterKey::new(get_bitvm_key()?); let local_operator_pubkey = operator_master_key.master_keypair().public_key().into(); if !pending_graph_belongs_to_operator( @@ -2041,7 +2385,7 @@ async fn handle_compact_soldering_proof_operator( format!("missing operator BABE setup state for pending graph {graph_id}"), ) })?; - let frozen = operator_state.frozen_verifier_pubkeys.as_ref().ok_or_else(|| { + let frozen = operator_state.candidate_verifier_pubkeys.as_ref().ok_or_else(|| { retryable_dispatch_error( RetryableDispatchReason::DependencyPending, Some(30), @@ -2049,15 +2393,15 @@ async fn handle_compact_soldering_proof_operator( ) })?; let verifier_pubkey = *frozen - .get(verifier_index) - .ok_or_else(|| anyhow!("SolderingProof verifier index {verifier_index} out of range"))?; + .get(candidate_index) + .ok_or_else(|| anyhow!("SolderingProof candidate index {candidate_index} out of range"))?; let candidate = operator_state .candidates .iter() .find(|candidate| candidate.verifier_pubkey == verifier_pubkey) .ok_or_else(|| anyhow!("selected verifier candidate is missing"))?; - if candidate.verifier_index != Some(verifier_index) { + if candidate.candidate_index != Some(candidate_index) { bail!("selected verifier candidate index does not match SolderingProof slot"); } let setup_package = candidate.setup_package.clone(); @@ -2067,7 +2411,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "payload_expand", outcome = "started", - verifier_index, + candidate_index, "expanding compact soldering proof" ); let (opened, finalized, soldering) = expand_compact_soldering_proof_payload(payload) @@ -2076,7 +2420,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "payload_expand", outcome = "completed", - verifier_index, + candidate_index, opened_instances = opened.len(), finalized_instances = finalized.len(), elapsed_ms = expand_started_at.elapsed().as_millis(), @@ -2101,7 +2445,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "setup_verify", outcome = "started", - verifier_index, + candidate_index, opened_instances = opened.len(), finalized_instances = finalized.len(), "verifying verifier soldering proof" @@ -2124,7 +2468,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "setup_verify", outcome = "completed", - verifier_index, + candidate_index, elapsed_ms = verification_started_at.elapsed().as_millis(), "verified verifier soldering proof" ); @@ -2132,7 +2476,7 @@ async fn handle_compact_soldering_proof_operator( tracing::info!( event = "operator_graph_creation", outcome = "soldering_verified", - verifier_index, + candidate_index, verifier_pubkey = %verifier_pubkey, finalized_instances = finalized.len(), payload_hash = %hex::encode(soldering_proof_ready.payload_hash), @@ -2148,7 +2492,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "gc_data_extract", outcome = "started", - verifier_index, + candidate_index, "building BABE prover state and extracting GC data" ); let prover_state = build_babe_prover_state(&setup_package, finalized, soldering)?; @@ -2157,33 +2501,34 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "gc_data_extract", outcome = "completed", - verifier_index, + candidate_index, elapsed_ms = gc_data_started_at.elapsed().as_millis(), "extracted GC data from soldering proof" ); - let Some(bitvm_gc_circuit_datas) = record_candidate_gc_data( + record_candidate_gc_data( operator_state, verifier_pubkey, - verifier_index, + candidate_index, &setup_package, gc_data, &prover_state, soldering_proof_ready.clone(), - )? - else { - let completed_slots = operator_state - .candidates - .iter() - .filter(|candidate| candidate.gc_data.is_some()) - .count(); - let expected_slots = operator_state.candidates.len(); + )?; + let completed_slots = + operator_state.candidates.iter().filter(|candidate| candidate.gc_data.is_some()).count(); + let required_slots = min_required_verifier(); + let proof_window_started_at = + operator_state.proof_collection_started_at.get_or_insert_with(current_time_secs); + let elapsed = current_time_secs() - *proof_window_started_at; + let collection_window = get_verifier_candidate_collection_window_secs(); + if completed_slots < required_slots || elapsed < collection_window { if let Err(error) = save_babe_setup_state(&context.local_db, instance_id, graph_id, &state) { tracing::error!( event = "operator_graph_creation", outcome = "failed", stage = "babe_state_persist", - verifier_index, + candidate_index, error = %error, "failed to persist incomplete operator BABE setup state" ); @@ -2192,19 +2537,45 @@ async fn handle_compact_soldering_proof_operator( tracing::info!( event = "operator_graph_creation", outcome = "waiting_for_soldering", - verifier_index, + candidate_index, completed_slots, - expected_slots, - "persisted verifier soldering proof; waiting for remaining graph slots" + required_slots, + retry_after_secs = (collection_window - elapsed).max(1), + "persisted verified soldering proof; waiting for the proof selection window" ); - return Ok(()); - }; + return Err(retryable_dispatch_error( + RetryableDispatchReason::DependencyPending, + Some((collection_window - elapsed).max(1)), + format!("collecting verified soldering proofs: {completed_slots}/{required_slots}"), + )); + } + + let selected_verifier_pubkeys = + if let Some(selected) = &operator_state.selected_verifier_pubkeys { + selected.clone() + } else { + let mut selected = operator_state + .candidates + .iter() + .filter(|candidate| candidate.gc_data.is_some()) + .map(|candidate| candidate.verifier_pubkey) + .collect::>(); + selected.sort_by_key(|candidate| candidate.to_bytes()); + selected.truncate(required_slots); + selected + }; + seal_selected_verifiers(operator_state, selected_verifier_pubkeys)?; + let bitvm_gc_circuit_datas = selected_gc_data(operator_state)?; + let mut obsolete_setup_outbox_ids = vec![format!("init-graph:{graph_id}")]; + obsolete_setup_outbox_ids.extend(operator_state.candidates.iter().map(|candidate| { + format!("cut-circuits:{instance_id}:{graph_id}:{}", candidate.verifier_pubkey) + })); if let Err(error) = save_babe_setup_state(&context.local_db, instance_id, graph_id, &state) { tracing::error!( event = "operator_graph_creation", outcome = "failed", stage = "babe_state_persist", - verifier_index, + candidate_index, error = %error, "failed to persist complete operator BABE setup state" ); @@ -2314,6 +2685,11 @@ async fn handle_compact_soldering_proof_operator( storage .insert_p2p_outbox_message(&outbox_id, message.content.event_type(), &serialized) .await?; + let mut cancelled_setup_messages = 0; + for setup_outbox_id in obsolete_setup_outbox_ids { + cancelled_setup_messages += + storage.cancel_p2p_outbox_message(&setup_outbox_id).await? as u64; + } drop(storage); tracing::info!( event = "operator_graph_creation", @@ -2322,6 +2698,7 @@ async fn handle_compact_soldering_proof_operator( graph_nonce, definition_hash = %definition_hash, outbox_id, + cancelled_setup_messages, "enqueued CreateGraph for swarm publication" ); @@ -2426,19 +2803,12 @@ async fn handle_create_graph_verifier( ); return Ok(()); }; - if soldering_proof_ready.verifier_index != verifier_index { - bail!( - "CreateGraph verifier index {verifier_index} conflicts with persisted slot {}", - soldering_proof_ready.verifier_index - ); - } - let store_base_path = get_soldering_proof_payload_store_path()?; let payload_path = soldering_proof_payload_store_path( &store_base_path, instance_id, graph_id, - verifier_index, + soldering_proof_ready.candidate_index, &soldering_proof_ready.payload_hash, )?; let payload = read_soldering_proof_store_payload(&payload_path) @@ -2478,16 +2848,26 @@ async fn handle_create_graph_verifier( let serialized = message.serialize_message().await?; let endorsement_outbox_id = format!("verifier-graph-params-endorsement:{graph_id}:{verifier_index}"); - context - .local_db - .acquire() - .await? + let mut storage = context.local_db.acquire().await?; + storage .enqueue_p2p_outbox_message( &endorsement_outbox_id, message.content.event_type(), &serialized, ) .await?; + let cancelled_gen_circuits = storage + .cancel_p2p_outbox_message(&format!( + "gen-circuits:{instance_id}:{graph_id}:{local_verifier_pubkey}" + )) + .await?; + let cancelled_soldering_proof = storage + .cancel_p2p_outbox_message(&format!( + "soldering-proof-ready:{graph_id}:{}:{}", + soldering_proof_ready.candidate_index, + hex::encode(soldering_proof_ready.payload_hash), + )) + .await?; tracing::info!( event = "verifier_graph_validation", @@ -2496,6 +2876,8 @@ async fn handle_create_graph_verifier( graph_id = %graph_id, verifier_index, endorsement_outbox_id, + cancelled_gen_circuits, + cancelled_soldering_proof, "validated CreateGraph and enqueued verifier graph params endorsement" ); Ok(()) @@ -2537,17 +2919,9 @@ async fn try_start_graph_committee_setup( // generate Musig2 nonces & broadcast NonceGeneration let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; - let verifier_num = graph.parameters.gc_data.len(); - let watchtower_num = graph.parameters.watchtower_pubkeys.len(); - let graph_parameters_hash = graph.parameters_hash()?; - let (pub_nonces, _, nonce_sigs) = committee_master_key.nonces_for_graph_job_with_keypair( - instance_id, - graph_id, - graph_parameters_hash, - watchtower_num, - verifier_num, - instance_keypair, - ); + let full_graph = BitvmGcGraph::from_simplified(graph)?; + let (pub_nonces, _, nonce_sigs) = + committee_master_key.nonces_for_graph_job_with_keypair(&full_graph, instance_keypair)?; let local_committee_pubkey = instance_keypair.public_key().into(); if get_committee_partial_sigs_for_graph_member( ctx.local_db, @@ -2579,72 +2953,199 @@ async fn try_start_graph_committee_setup( pub_nonces, ) .await?; - // if collected enough pub_nonces, generate partial signatures & broadcast CommitteePresign + maybe_vote_and_presign_graph(ctx, instance_id, graph_id, graph).await +} + +async fn graph_nonce_consensus_context( + ctx: &HandlerContext<'_>, + instance_id: Uuid, + graph_id: Uuid, + graph: &SimplifiedBitvmGcGraph, +) -> Result, CommitteeAggNonces, [u8; 32])>> { let committee_pubkeys = ctx.goat_client.gateway_get_committee_pubkeys(&instance_id).await?; let pub_nonces_unchecked = get_committee_pub_nonces_for_graph(ctx.local_db, instance_id, graph_id).await?; - if pub_nonces_unchecked.len() == committee_pubkeys.len() { - let mut graph = BitvmGcGraph::from_simplified(graph)?; - let verifier_num = graph.verifier_asserts.len(); - let watchtower_num = graph.parameters.watchtower_pubkeys.len(); - let mut checked_pub_nonces = Vec::with_capacity(pub_nonces_unchecked.len()); - for (pk, pn) in pub_nonces_unchecked.iter() { - if let Err(e) = pn.validate_length(watchtower_num, verifier_num) { - tracing::warn!("PubNonces from {} has invalid length: {e}", pk.to_string()); - return Ok(()); - } - checked_pub_nonces.push((*pk, pn.clone())); - } - let pub_nonces = order_committee_values( - &committee_pubkeys, - checked_pub_nonces, - "graph committee pub nonces", - )?; - let agg_nonces = nonces_aggregation(&pub_nonces)?; - let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); - let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; - let local_committee_pubkey = instance_keypair.public_key().into(); - if get_committee_partial_sigs_for_graph_member( - ctx.local_db, - instance_id, - graph_id, - &local_committee_pubkey, - ) - .await? - .is_some() - { - tracing::warn!( - "Skip committee pre-sign for {instance_id}:{graph_id}: local committee partial signatures already exist" - ); - return Ok(()); - } - let (_, sec_nonces, _) = committee_master_key.nonces_for_graph_job_with_keypair( - instance_id, - graph_id, - graph_parameters_hash, - watchtower_num, - verifier_num, - instance_keypair, - ); - let committee_partial_sigs = - committee_pre_sign(instance_keypair, sec_nonces, agg_nonces.clone(), &mut graph)?; - store_committee_partial_sigs_for_graph( + if pub_nonces_unchecked.len() < committee_pubkeys.len() { + return Ok(None); + } + + let full_graph = BitvmGcGraph::from_simplified(graph)?; + let verifier_num = full_graph.parameters.gc_data.len(); + let watchtower_num = full_graph.parameters.watchtower_pubkeys.len(); + let mut checked_pub_nonces = Vec::with_capacity(pub_nonces_unchecked.len()); + for (pubkey, pub_nonces) in pub_nonces_unchecked { + pub_nonces.validate_length(watchtower_num, verifier_num).map_err(|error| { + anyhow!("committee public nonces from {pubkey} have invalid length: {error}") + })?; + checked_pub_nonces.push((pubkey, pub_nonces)); + } + let ordered_pub_nonces = order_committee_values( + &committee_pubkeys, + checked_pub_nonces, + "graph committee pub nonces", + )?; + let agg_nonces = nonces_aggregation(&ordered_pub_nonces)?; + let consensus_hash = + committee_nonce_consensus_hash(&full_graph, &committee_pubkeys, &ordered_pub_nonces)?; + Ok(Some((full_graph, committee_pubkeys, agg_nonces, consensus_hash))) +} + +async fn maybe_vote_and_presign_graph( + ctx: &mut HandlerContext<'_>, + instance_id: Uuid, + graph_id: Uuid, + graph: &SimplifiedBitvmGcGraph, +) -> Result<()> { + let Some((mut full_graph, committee_pubkeys, agg_nonces, consensus_hash)) = + graph_nonce_consensus_context(ctx, instance_id, graph_id, graph).await? + else { + return Ok(()); + }; + + let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let local_committee_pubkey = instance_keypair.public_key().into(); + if get_committee_partial_sigs_for_graph_member( + ctx.local_db, + instance_id, + graph_id, + &local_committee_pubkey, + ) + .await? + .is_some() + { + return endorse_graph_if_presigned(ctx, instance_id, graph_id, &full_graph).await; + } + + let consensus_votes = + get_committee_agg_nonce_consensus_for_graph(ctx.local_db, instance_id, graph_id).await?; + if let Some((_, stored_hash, _)) = consensus_votes + .iter() + .find(|(committee_pubkey, _, _)| *committee_pubkey == local_committee_pubkey) + && *stored_hash != consensus_hash + { + bail!(SpecialError::InvalidGraph(format!( + "local committee agg nonce consensus differs for graph {graph_id}" + ))); + } + if !consensus_votes + .iter() + .any(|(committee_pubkey, _, _)| *committee_pubkey == local_committee_pubkey) + { + let signature = + SECP256K1.sign_schnorr(&SecpMessage::from_digest(consensus_hash), &instance_keypair); + store_committee_agg_nonce_consensus_for_graph( ctx.local_db, instance_id, graph_id, local_committee_pubkey, - committee_partial_sigs.clone(), + consensus_hash, + signature, ) .await?; - let message_content = GOATMessageContent::CommitteePresign(CommitteePresign { + let message_content = GOATMessageContent::AggNonceConsensus(AggNonceConsensus { instance_id, graph_id, committee_pubkey: local_committee_pubkey, - committee_partial_sigs, - agg_nonces, + consensus_hash, + signature, }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::All, message_content)).await?; + send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, message_content)).await?; + } + + let consensus_votes = + get_committee_agg_nonce_consensus_for_graph(ctx.local_db, instance_id, graph_id).await?; + if consensus_votes.len() < committee_pubkeys.len() { + tracing::info!( + "Defer committee pre-sign for {instance_id}:{graph_id}: waiting for agg nonce consensus ({}/{})", + consensus_votes.len(), + committee_pubkeys.len(), + ); + return Ok(()); + } + let ordered_votes = order_committee_values( + &committee_pubkeys, + consensus_votes + .into_iter() + .map(|(committee_pubkey, consensus_hash, signature)| { + (committee_pubkey, (consensus_hash, signature)) + }) + .collect(), + "graph committee agg nonce consensus", + )?; + for (committee_pubkey, (received_hash, signature)) in + committee_pubkeys.iter().zip(ordered_votes) + { + if received_hash != consensus_hash { + bail!(SpecialError::InvalidGraph(format!( + "committee agg nonce consensus differs for graph {graph_id} and committee {committee_pubkey}" + ))); + } + SECP256K1 + .verify_schnorr( + &signature, + &SecpMessage::from_digest(consensus_hash), + &XOnlyPublicKey::from(*committee_pubkey), + ) + .map_err(|error| { + SpecialError::InvalidGraph(format!( + "invalid agg nonce consensus signature for graph {graph_id} and committee {committee_pubkey}: {error}" + )) + })?; + } + + let (_, sec_nonces, _) = + committee_master_key.nonces_for_graph_job_with_keypair(&full_graph, instance_keypair)?; + let committee_partial_sigs = + committee_pre_sign(instance_keypair, sec_nonces, agg_nonces.clone(), &mut full_graph)?; + store_committee_partial_sigs_for_graph( + ctx.local_db, + instance_id, + graph_id, + local_committee_pubkey, + committee_partial_sigs.clone(), + ) + .await?; + let message_content = GOATMessageContent::CommitteePresign(CommitteePresign { + instance_id, + graph_id, + committee_pubkey: local_committee_pubkey, + committee_partial_sigs, + agg_nonces, + }); + send_to_peer(ctx.swarm, GOATMessage::new(Actor::All, message_content)).await?; + endorse_graph_if_presigned(ctx, instance_id, graph_id, &full_graph).await +} + +async fn endorse_graph_if_presigned( + ctx: &mut HandlerContext<'_>, + instance_id: Uuid, + graph_id: Uuid, + graph: &BitvmGcGraph, +) -> Result<()> { + let committee_pubkeys = ctx.goat_client.gateway_get_committee_pubkeys(&instance_id).await?; + let committee_partial_sigs = + get_committee_partial_sigs_for_graph(ctx.local_db, instance_id, graph_id).await?; + if committee_partial_sigs.len() != committee_pubkeys.len() { + return Ok(()); } + + let committee_sig_for_graph = endorse_graph(ctx.goat_client, graph).await?; + let committee_sig_for_params = endorse_graph_params(graph).await?; + let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let local_committee_pubkey = instance_keypair.public_key().into(); + let committee_evm_address = get_node_goat_address() + .ok_or_else(|| anyhow!("failed to get node goat address".to_string()))?; + let message_content = GOATMessageContent::EndorseGraph(EndorseGraph { + instance_id, + graph_id, + committee_pubkey: local_committee_pubkey, + committee_sig_for_graph: committee_sig_for_graph.as_bytes().to_vec(), + committee_sig_for_params: committee_sig_for_params.as_bytes().to_vec(), + committee_evm_address, + }); + send_to_peer(ctx.swarm, GOATMessage::new(Actor::All, message_content)).await?; Ok(()) } @@ -2885,8 +3386,7 @@ async fn handle_nonce_generation_committee( ); return Ok(()); } - // TODO: deal with the case that one committee member sends different pub_nonces for the same graph - // 2. save the pub_nonces to local db + // 2. Save immutable pub_nonces for this committee member and graph. store_committee_pub_nonces_for_graph( ctx.local_db, instance_id, @@ -2895,96 +3395,93 @@ async fn handle_nonce_generation_committee( pub_nonces.clone(), ) .await?; - // 3. if received enough pub_nonces, generate partial signatures & broadcast CommitteePresign - let committee_pubkeys = ctx.goat_client.gateway_get_committee_pubkeys(&instance_id).await?; - let pub_nonces_unchecked = - get_committee_pub_nonces_for_graph(ctx.local_db, instance_id, graph_id).await?; - if pub_nonces_unchecked.len() == committee_pubkeys.len() { - let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); - let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; - let local_committee_pubkey = instance_keypair.public_key().into(); - if get_committee_partial_sigs_for_graph_member( + maybe_vote_and_presign_graph(ctx, instance_id, graph_id, &graph).await +} + +#[tracing::instrument(level = "info", skip_all, fields(instance_id = %instance_id, graph_id = %graph_id))] +async fn handle_agg_nonce_consensus_committee( + ctx: &mut HandlerContext<'_>, + instance_id: Uuid, + graph_id: Uuid, + received_committee_pubkey: &PublicKey, + received_consensus_hash: [u8; 32], + signature: &secp256k1::schnorr::Signature, + content: &GOATMessageContent, +) -> Result<()> { + if !ensure_self_or_valid_committee( + ctx, + instance_id, + Some(graph_id), + received_committee_pubkey, + "AggNonceConsensus", + ) + .await? + { + return Ok(()); + } + + let message = make_message(ctx, content); + let graph = match get_graph_or_defer( + ctx.swarm, + ctx.local_db, + ctx.goat_client, + instance_id, + graph_id, + &message, + ) + .await? + { + Some(graph) => graph, + None => return Ok(()), + }; + let Some((_, _, _, expected_consensus_hash)) = + graph_nonce_consensus_context(ctx, instance_id, graph_id, &graph).await? + else { + push_local_unhandled_messages_with_reason( ctx.local_db, - instance_id, graph_id, - &local_committee_pubkey, + &message, + 30, + MessageDeferReason::CommitteeNoncesPending, + "waiting for committee public nonces before validating agg nonce consensus", ) - .await? - .is_some() - { - tracing::warn!( - "Skip committee pre-sign for {instance_id}:{graph_id}: local committee partial signatures already exist" - ); - return Ok(()); - } - let mut graph = BitvmGcGraph::from_simplified(&graph)?; - let verifier_num = graph.verifier_asserts.len(); - let watchtower_num = graph.parameters.watchtower_pubkeys.len(); - let mut checked_pub_nonces = Vec::with_capacity(pub_nonces_unchecked.len()); - for (pk, pn) in pub_nonces_unchecked.iter() { - if let Err(e) = pn.validate_length(watchtower_num, verifier_num) { - tracing::warn!("PubNonces from {} has invalid length: {e}", pk.to_string()); - return Ok(()); - } - checked_pub_nonces.push((*pk, pn.clone())); - } - let pub_nonces = order_committee_values( - &committee_pubkeys, - checked_pub_nonces, - "graph committee pub nonces", - )?; - let agg_nonces = nonces_aggregation(&pub_nonces)?; - let graph_parameters_hash = graph.parameters.parameters_hash()?; - let (_, sec_nonces, _) = committee_master_key.nonces_for_graph_job_with_keypair( - instance_id, - graph_id, - graph_parameters_hash, - watchtower_num, - verifier_num, - instance_keypair, + .await?; + tracing::info!( + "Defer AggNonceConsensus for {instance_id}:{graph_id}: waiting for committee pub nonces" ); - // 4. if received enough valid committee partial sigs, endorse the graph - let committee_partial_sigs = - committee_pre_sign(instance_keypair, sec_nonces, agg_nonces.clone(), &mut graph)?; - store_committee_partial_sigs_for_graph( - ctx.local_db, - instance_id, - graph_id, - local_committee_pubkey, - committee_partial_sigs.clone(), + return Ok(()); + }; + if received_consensus_hash != expected_consensus_hash { + tracing::warn!( + "Ignore AggNonceConsensus for {instance_id}:{graph_id} from {}: consensus hash mismatch", + received_committee_pubkey, + ); + return Ok(()); + } + if SECP256K1 + .verify_schnorr( + signature, + &SecpMessage::from_digest(expected_consensus_hash), + &XOnlyPublicKey::from(*received_committee_pubkey), ) - .await?; - let message_content = GOATMessageContent::CommitteePresign(CommitteePresign { - instance_id, - graph_id, - committee_pubkey: local_committee_pubkey, - committee_partial_sigs: committee_partial_sigs.clone(), - agg_nonces, - }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::All, message_content)).await?; - let committee_partial_sigs = - get_committee_partial_sigs_for_graph(ctx.local_db, instance_id, graph_id) - .await? - .into_iter() - .map(|(_, ps)| ps) - .collect::>(); - if committee_partial_sigs.len() == committee_pubkeys.len() { - let committee_sig_for_graph = endorse_graph(ctx.goat_client, &graph).await?; - let committee_sig_for_params = endorse_graph_params(&graph).await?; - let committee_evm_address = get_node_goat_address() - .ok_or_else(|| anyhow::anyhow!("failed to get node goat address".to_string()))?; - let message_content = GOATMessageContent::EndorseGraph(EndorseGraph { - instance_id, - graph_id, - committee_pubkey: local_committee_pubkey, - committee_sig_for_graph: committee_sig_for_graph.as_bytes().to_vec(), - committee_sig_for_params: committee_sig_for_params.as_bytes().to_vec(), - committee_evm_address, - }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::All, message_content)).await?; - } + .is_err() + { + tracing::warn!( + "Ignore AggNonceConsensus for {instance_id}:{graph_id} from {}: invalid signature", + received_committee_pubkey, + ); + return Ok(()); } - Ok(()) + store_committee_agg_nonce_consensus_for_graph( + ctx.local_db, + instance_id, + graph_id, + *received_committee_pubkey, + received_consensus_hash, + *signature, + ) + .await?; + maybe_vote_and_presign_graph(ctx, instance_id, graph_id, &graph).await } #[tracing::instrument(level = "info", skip_all, fields(instance_id = %instance_id, graph_id = %graph_id))] @@ -3042,8 +3539,7 @@ async fn handle_nonce_generation_operator( ); return Ok(()); } - // TODO: deal with the case that one committee member sends different pub_nonces for the same graph - // 2. save the pub_nonces to local db + // 2. Save immutable pub_nonces for this committee member and graph. store_committee_pub_nonces_for_graph( ctx.local_db, instance_id, @@ -3211,33 +3707,9 @@ async fn handle_committee_presign_committee( committee_partial_sigs.clone(), ) .await?; - // 2. if received enough valid committee partial sigs, endorse the graph - let committee_pubkeys = ctx.goat_client.gateway_get_committee_pubkeys(&instance_id).await?; - let committee_partial_sigs = - get_committee_partial_sigs_for_graph(ctx.local_db, instance_id, graph_id) - .await? - .into_iter() - .map(|(_, ps)| ps) - .collect::>(); - if committee_partial_sigs.len() == committee_pubkeys.len() { - let committee_sig_for_graph = endorse_graph(ctx.goat_client, &graph).await?; - let committee_sig_for_params = endorse_graph_params(&graph).await?; - let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); - let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; - let local_committee_pubkey = instance_keypair.public_key().into(); - let committee_evm_address = get_node_goat_address() - .ok_or_else(|| anyhow::anyhow!("failed to get node goat address".to_string()))?; - let message_content = GOATMessageContent::EndorseGraph(EndorseGraph { - instance_id, - graph_id, - committee_pubkey: local_committee_pubkey, - committee_sig_for_graph: committee_sig_for_graph.as_bytes().to_vec(), - committee_sig_for_params: committee_sig_for_params.as_bytes().to_vec(), - committee_evm_address, - }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::All, message_content)).await?; - } - Ok(()) + // 2. This also covers the case where the local partial signature was the last one and is + // not delivered back by gossip. + endorse_graph_if_presigned(ctx, instance_id, graph_id, &graph).await } #[tracing::instrument(level = "info", skip_all, fields(instance_id = %instance_id, graph_id = %graph_id))] @@ -3487,31 +3959,30 @@ async fn handle_graph_finalize_committee( tracing::warn!( "Skip PeginConfirm nonce for {instance_id}: local committee partial signature already exists" ); - return Ok(()); - } - let instance_parameters_hash = - graph.parameters.instance_parameters.parameters_hash()?; - let (_, pub_nonce, nonce_sig) = committee_master_key - .nonce_for_instance_job_with_keypair( + } else { + let (_, pub_nonce, nonce_sig) = committee_master_key + .nonce_for_instance_job_with_keypair( + &graph.parameters.instance_parameters, + instance_keypair, + )?; + let message_content = GOATMessageContent::PeginConfirmNonce(PeginConfirmNonce { instance_id, - instance_parameters_hash, - instance_keypair, - ); - let message_content = GOATMessageContent::PeginConfirmNonce(PeginConfirmNonce { - instance_id, - committee_pubkey: local_committee_pubkey, - pub_nonce: pub_nonce.clone(), - nonce_sig, - }); - store_committee_pub_nonce_for_instance( - ctx.local_db, - instance_id, - local_committee_pubkey, - pub_nonce, - ) - .await?; - send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, message_content)).await?; + committee_pubkey: local_committee_pubkey, + pub_nonce: pub_nonce.clone(), + nonce_sig, + }); + store_committee_pub_nonce_for_instance( + ctx.local_db, + instance_id, + local_committee_pubkey, + pub_nonce, + ) + .await?; + send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, message_content)) + .await?; + } } + maybe_vote_and_sign_pegin_confirm(ctx, instance_id).await?; } // 4. (Relayer) try to call Gateway.postGraphData // GraphFinalize may come after PostReady, so we need to check it here @@ -3581,21 +4052,243 @@ async fn handle_graph_finalize_default( } bail!(e) } - // 2. Store a finalized graph only when it upgrades the local graph. - let _ = store_finalized_graph_if_needed(ctx.local_db, graph).await?; - store_committee_endorsements_for_graph( + // 2. Store a finalized graph only when it upgrades the local graph. + let _ = store_finalized_graph_if_needed(ctx.local_db, graph).await?; + store_committee_endorsements_for_graph( + ctx.local_db, + instance_id, + graph_id, + endorse_sigs.to_owned(), + params_endorse_sigs.to_owned(), + ) + .await?; + mark_graph_as_endorsed(ctx.local_db, instance_id, graph_id).await?; + try_transition_instance_to_presigned(ctx.local_db, instance_id).await?; + let finalized_graph = BitvmGcGraph::from_simplified(graph)?; + refresh_newly_finalized_graph(ctx, instance_id, graph_id, &finalized_graph).await?; + Ok(()) +} + +async fn pegin_confirm_nonce_consensus_context( + ctx: &HandlerContext<'_>, + instance_id: Uuid, +) -> Result< + Option<( + BitvmGcInstanceParameters, + Vec, + Vec, + musig2::AggNonce, + [u8; 32], + )>, +> { + let instance_parameters = get_instance_parameters(ctx.local_db, instance_id) + .await? + .ok_or_else(|| anyhow!("Instance parameters not found for {instance_id}"))?; + let committee_pubkeys = ctx.goat_client.gateway_get_committee_pubkeys(&instance_id).await?; + let pub_nonces = get_committee_pub_nonces_for_instance(ctx.local_db, instance_id).await?; + if pub_nonces.len() < committee_pubkeys.len() { + return Ok(None); + } + let ordered_pub_nonces = + order_committee_values(&committee_pubkeys, pub_nonces, "pegin committee pub nonces")?; + let agg_nonce = nonce_aggregation(&ordered_pub_nonces); + let consensus_hash = pegin_confirm_nonce_consensus_hash( + &instance_parameters, + &committee_pubkeys, + &ordered_pub_nonces, + )?; + Ok(Some(( + instance_parameters, + committee_pubkeys, + ordered_pub_nonces, + agg_nonce, + consensus_hash, + ))) +} + +async fn has_complete_pegin_confirm_nonce_consensus( + ctx: &HandlerContext<'_>, + instance_id: Uuid, + committee_pubkeys: &[PublicKey], + consensus_hash: [u8; 32], +) -> Result { + let consensus_votes = + get_committee_agg_nonce_consensus_for_instance(ctx.local_db, instance_id).await?; + if consensus_votes.len() != committee_pubkeys.len() { + return Ok(false); + } + let ordered_votes = order_committee_values( + committee_pubkeys, + consensus_votes + .into_iter() + .map(|(committee_pubkey, vote_hash, signature)| { + (committee_pubkey, (vote_hash, signature)) + }) + .collect(), + "PeginConfirm agg nonce consensus", + )?; + for (committee_pubkey, (received_hash, signature)) in + committee_pubkeys.iter().zip(ordered_votes) + { + ensure!( + received_hash == consensus_hash, + "committee PeginConfirm nonce consensus differs for instance {instance_id} and committee {committee_pubkey}" + ); + SECP256K1 + .verify_schnorr( + &signature, + &SecpMessage::from_digest(consensus_hash), + &XOnlyPublicKey::from(*committee_pubkey), + ) + .map_err(|error| { + SpecialError::InvalidPeginData(format!( + "invalid PeginConfirm nonce consensus signature for instance {instance_id} and committee {committee_pubkey}: {error}" + )) + })?; + } + Ok(true) +} + +async fn broadcast_pegin_confirm_if_presigned( + ctx: &mut HandlerContext<'_>, + instance_id: Uuid, + instance_parameters: &BitvmGcInstanceParameters, + committee_pubkeys: &[PublicKey], +) -> Result<()> { + if !is_relayer() { + return Ok(()); + } + let pub_nonces = get_committee_pub_nonces_for_instance(ctx.local_db, instance_id).await?; + let partial_sigs = get_committee_partial_sigs_for_instance(ctx.local_db, instance_id).await?; + if pub_nonces.len() != committee_pubkeys.len() || partial_sigs.len() != committee_pubkeys.len() + { + return Ok(()); + } + let pub_nonces = + order_committee_values(committee_pubkeys, pub_nonces, "pegin committee pub nonces")?; + let partial_sigs = + order_committee_values(committee_pubkeys, partial_sigs, "pegin committee partial sigs")?; + let mut pegin_confirm = instance_parameters.build_pegin_tx()?.1; + let agg_nonce = nonce_aggregation(&pub_nonces); + let context = instance_parameters.get_base_context(); + let full_sig = pegin_confirm + .aggregate_input_0_musig2_signatures(&context, partial_sigs, &agg_nonce) + .map_err(|error| { + anyhow!("Failed to aggregate PeginConfirm signatures for {instance_id}: {error}") + })?; + let connector_z = instance_parameters.connector_z(); + pegin_confirm.push_input_0_signature(&connector_z, full_sig); + let result = broadcast_tx(ctx.btc_client, pegin_confirm.tx()).await; + ctx.metrics_state.record_pegin_confirm(result.is_ok()); + result?; + Ok(()) +} + +async fn maybe_vote_and_sign_pegin_confirm( + ctx: &mut HandlerContext<'_>, + instance_id: Uuid, +) -> Result<()> { + let Some((instance_parameters, committee_pubkeys, _pub_nonces, agg_nonce, consensus_hash)) = + pegin_confirm_nonce_consensus_context(ctx, instance_id).await? + else { + return Ok(()); + }; + let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let local_committee_pubkey = instance_keypair.public_key().into(); + if get_committee_partial_sig_for_instance(ctx.local_db, instance_id, &local_committee_pubkey) + .await? + .is_some() + { + return broadcast_pegin_confirm_if_presigned( + ctx, + instance_id, + &instance_parameters, + &committee_pubkeys, + ) + .await; + } + + let consensus_votes = + get_committee_agg_nonce_consensus_for_instance(ctx.local_db, instance_id).await?; + if let Some((_, stored_hash, _)) = consensus_votes + .iter() + .find(|(committee_pubkey, _, _)| *committee_pubkey == local_committee_pubkey) + && *stored_hash != consensus_hash + { + bail!(SpecialError::InvalidPeginData(format!( + "local PeginConfirm nonce consensus differs for instance {instance_id}" + ))); + } + if !consensus_votes + .iter() + .any(|(committee_pubkey, _, _)| *committee_pubkey == local_committee_pubkey) + { + let signature = + SECP256K1.sign_schnorr(&SecpMessage::from_digest(consensus_hash), &instance_keypair); + store_committee_agg_nonce_consensus_for_instance( + ctx.local_db, + instance_id, + local_committee_pubkey, + consensus_hash, + signature, + ) + .await?; + let message_content = + GOATMessageContent::PeginConfirmNonceConsensus(PeginConfirmNonceConsensus { + instance_id, + committee_pubkey: local_committee_pubkey, + consensus_hash, + signature, + }); + send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, message_content)).await?; + } + if !has_complete_pegin_confirm_nonce_consensus( + ctx, + instance_id, + &committee_pubkeys, + consensus_hash, + ) + .await? + { + tracing::info!( + "Defer PeginConfirm partial signature for {instance_id}: waiting for agg nonce consensus" + ); + return Ok(()); + } + + let (sec_nonce, _, _) = committee_master_key + .nonce_for_instance_job_with_keypair(&instance_parameters, instance_keypair)?; + let mut pegin_confirm = instance_parameters.build_pegin_tx()?.1; + let committee_context = instance_parameters.get_committee_context(instance_keypair)?; + let partial_sig = pegin_confirm + .sign_input_0_musig2(&committee_context, &sec_nonce, &agg_nonce) + .map_err(|error| anyhow!("Failed to sign PeginConfirm for {instance_id}: {error}"))?; + let endorse_sig = + endorse_pegin(ctx.goat_client, instance_id, &pegin_confirm.tx().compute_txid()).await?; + store_committee_partial_sig_for_instance( ctx.local_db, instance_id, - graph_id, - endorse_sigs.to_owned(), - params_endorse_sigs.to_owned(), + local_committee_pubkey, + partial_sig, ) .await?; - mark_graph_as_endorsed(ctx.local_db, instance_id, graph_id).await?; - try_transition_instance_to_presigned(ctx.local_db, instance_id).await?; - let finalized_graph = BitvmGcGraph::from_simplified(graph)?; - refresh_newly_finalized_graph(ctx, instance_id, graph_id, &finalized_graph).await?; - Ok(()) + store_committee_endorse_sig_for_pegin( + ctx.local_db, + instance_id, + local_committee_pubkey, + endorse_sig.as_bytes().to_vec(), + ) + .await?; + let message_content = GOATMessageContent::PeginConfirmPartialSig(PeginConfirmPartialSig { + instance_id, + committee_pubkey: local_committee_pubkey, + partial_sig, + endorse_sig: endorse_sig.as_bytes().to_vec(), + }); + send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, message_content)).await?; + broadcast_pegin_confirm_if_presigned(ctx, instance_id, &instance_parameters, &committee_pubkeys) + .await } #[tracing::instrument(level = "info", skip_all, fields(instance_id = %instance_id))] @@ -3639,93 +4332,78 @@ async fn handle_pegin_confirm_nonce_committee( if ctx.id == GOATMessage::default_message_id() { send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, content.clone())).await?; } - // 3. if received enough pub_nonces, generate partial signature & broadcast PeginConfirmPartialSig - let committee_pubkeys = ctx.goat_client.gateway_get_committee_pubkeys(&instance_id).await?; - let pub_nonces = get_committee_pub_nonces_for_instance(ctx.local_db, instance_id).await?; - if pub_nonces.len() == committee_pubkeys.len() { - let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); - let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; - let local_committee_pubkey = instance_keypair.public_key().into(); - if get_committee_partial_sig_for_instance( - ctx.local_db, - instance_id, - &local_committee_pubkey, - ) - .await? - .is_some() - { - tracing::warn!( - "Skip PeginConfirm partial signature for {instance_id}: local committee partial signature already exists" - ); - return Ok(()); - } - let pub_nonces = - order_committee_values(&committee_pubkeys, pub_nonces, "pegin committee pub nonces")?; - let agg_nonce = nonce_aggregation(&pub_nonces); - let instance_params = get_instance_parameters(ctx.local_db, instance_id) - .await? - .ok_or_else(|| anyhow!("Instance parameters not found for {instance_id}"))?; - let instance_parameters_hash = instance_params.parameters_hash()?; - let (sec_nonce, _, _) = committee_master_key.nonce_for_instance_job_with_keypair( - instance_id, - instance_parameters_hash, - instance_keypair, - ); - let mut pegin_confirm = instance_params.build_pegin_tx()?.1; - let context = instance_params.get_committee_context(instance_keypair)?; - let partial_sig = pegin_confirm - .sign_input_0_musig2(&context, &sec_nonce, &agg_nonce) - .map_err(|e| anyhow!("Failed to sign pegin confirm for {instance_id}: {e}"))?; - let endorse_sig = - endorse_pegin(ctx.goat_client, instance_id, &pegin_confirm.tx().compute_txid()).await?; - store_committee_partial_sig_for_instance( + // 3. Agree on the full nonce transcript before generating a partial signature. + maybe_vote_and_sign_pegin_confirm(ctx, instance_id).await +} + +#[tracing::instrument(level = "info", skip_all, fields(instance_id = %instance_id))] +async fn handle_pegin_confirm_nonce_consensus_committee( + ctx: &mut HandlerContext<'_>, + instance_id: Uuid, + received_committee_pubkey: &PublicKey, + received_consensus_hash: [u8; 32], + signature: &secp256k1::schnorr::Signature, + content: &GOATMessageContent, +) -> Result<()> { + if !ensure_self_or_valid_committee( + ctx, + instance_id, + None, + received_committee_pubkey, + "PeginConfirmNonceConsensus", + ) + .await? + { + return Ok(()); + } + let message = make_message(ctx, content); + let Some((_, _, _, _, expected_consensus_hash)) = + pegin_confirm_nonce_consensus_context(ctx, instance_id).await? + else { + push_local_unhandled_messages_with_reason( ctx.local_db, instance_id, - local_committee_pubkey, - partial_sig, + &message, + 30, + MessageDeferReason::CommitteeNoncesPending, + "waiting for committee public nonces before validating PeginConfirm nonce consensus", ) .await?; - store_committee_endorse_sig_for_pegin( - ctx.local_db, - instance_id, - local_committee_pubkey, - endorse_sig.as_bytes().to_vec(), + tracing::info!( + "Defer PeginConfirmNonceConsensus for {instance_id}: waiting for committee pub nonces" + ); + return Ok(()); + }; + if received_consensus_hash != expected_consensus_hash { + tracing::warn!( + "Ignore PeginConfirmNonceConsensus for {instance_id} from {}: consensus hash mismatch", + received_committee_pubkey + ); + return Ok(()); + } + if SECP256K1 + .verify_schnorr( + signature, + &SecpMessage::from_digest(expected_consensus_hash), + &XOnlyPublicKey::from(*received_committee_pubkey), ) - .await?; - let message_content = GOATMessageContent::PeginConfirmPartialSig(PeginConfirmPartialSig { - instance_id, - committee_pubkey: local_committee_pubkey, - partial_sig, - endorse_sig: endorse_sig.as_bytes().to_vec(), - }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, message_content)).await?; - // 4. (Relayer) if received enough partial signatures, aggregate the sigs - if is_relayer() { - let partial_sigs = - get_committee_partial_sigs_for_instance(ctx.local_db, instance_id).await?; - let context = instance_params.get_base_context(); - if partial_sigs.len() == committee_pubkeys.len() { - let partial_sigs = order_committee_values( - &committee_pubkeys, - partial_sigs, - "pegin committee partial sigs", - )?; - let full_sig = pegin_confirm - .aggregate_input_0_musig2_signatures(&context, partial_sigs, &agg_nonce) - .map_err(|e| { - anyhow!( - "Failed to aggregate Pegin-Confirm's signatures for {instance_id}: {e}" - ) - })?; - let connector_z = instance_params.connector_z(); - pegin_confirm.push_input_0_signature(&connector_z, full_sig); - let result = broadcast_tx(ctx.btc_client, pegin_confirm.tx()).await; - ctx.metrics_state.record_pegin_confirm(result.is_ok()); - result?; - } - } + .is_err() + { + tracing::warn!( + "Ignore PeginConfirmNonceConsensus for {instance_id} from {}: invalid signature", + received_committee_pubkey + ); + return Ok(()); } - Ok(()) + store_committee_agg_nonce_consensus_for_instance( + ctx.local_db, + instance_id, + *received_committee_pubkey, + received_consensus_hash, + *signature, + ) + .await?; + maybe_vote_and_sign_pegin_confirm(ctx, instance_id).await } #[tracing::instrument(level = "info", skip_all, fields(instance_id = %instance_id))] @@ -3800,6 +4478,30 @@ async fn handle_pegin_confirm_partial_sig_committee( let instance_params = get_instance_parameters(ctx.local_db, instance_id) .await? .ok_or_else(|| anyhow!("Instance parameters not found for {instance_id}"))?; + let consensus_hash = + pegin_confirm_nonce_consensus_hash(&instance_params, &committee_pubkeys, &pub_nonces)?; + if !has_complete_pegin_confirm_nonce_consensus( + ctx, + instance_id, + &committee_pubkeys, + consensus_hash, + ) + .await? + { + push_local_unhandled_messages_with_reason( + ctx.local_db, + instance_id, + &message, + 30, + MessageDeferReason::CommitteeNonceConsensusPending, + "waiting for committee PeginConfirm agg nonce consensus", + ) + .await?; + tracing::info!( + "Defer PeginConfirmPartialSig for {instance_id}: waiting for agg nonce consensus" + ); + return Ok(()); + } if let Err(e) = verify_pegin_confirm_partial_sig( &instance_params, &committee_pubkeys, @@ -3868,40 +4570,8 @@ async fn handle_pegin_confirm_partial_sig_committee( if ctx.id == GOATMessage::default_message_id() { send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, content.clone())).await?; } - // 3. (Relayer) if received enough partial signatures, aggregate the sigs - if is_relayer() { - let pub_nonces = get_committee_pub_nonces_for_instance(ctx.local_db, instance_id).await?; - let partial_sigs = - get_committee_partial_sigs_for_instance(ctx.local_db, instance_id).await?; - if pub_nonces.len() == committee_pubkeys.len() - && partial_sigs.len() == committee_pubkeys.len() - { - let mut pegin_confirm = instance_params.build_pegin_tx()?.1; - let pub_nonces = order_committee_values( - &committee_pubkeys, - pub_nonces, - "pegin committee pub nonces", - )?; - let partial_sigs = order_committee_values( - &committee_pubkeys, - partial_sigs, - "pegin committee partial sigs", - )?; - let agg_nonce = nonce_aggregation(&pub_nonces); - let context = instance_params.get_base_context(); - let full_sig = pegin_confirm - .aggregate_input_0_musig2_signatures(&context, partial_sigs, &agg_nonce) - .map_err(|e| { - anyhow!("Failed to aggregate Pegin-Confirm's signatures for {instance_id}: {e}") - })?; - let connector_z = instance_params.connector_z(); - pegin_confirm.push_input_0_signature(&connector_z, full_sig); - let result = broadcast_tx(ctx.btc_client, pegin_confirm.tx()).await; - ctx.metrics_state.record_pegin_confirm(result.is_ok()); - result?; - } - } - Ok(()) + broadcast_pegin_confirm_if_presigned(ctx, instance_id, &instance_params, &committee_pubkeys) + .await } #[tracing::instrument(level = "info", skip_all, fields(instance_id = %instance_id))] @@ -4403,16 +5073,27 @@ async fn handle_kickoff_sent_verifier( let goat_confirmed_btc_height = ctx.goat_client.btc_spv_latest_height().await? as u32; if [WithdrawStatus::None, WithdrawStatus::Canceled].contains(&withdraw_status) { if kickoff_height >= goat_confirmed_btc_height { - tracing::warn!( - "Ignore KickoffSent for {instance_id}:{graph_id}: kickoff tx not confirmed by goat spv yet" + let delay_secs = avg_block_time_secs(ctx.btc_client.network()) + * (kickoff_height - goat_confirmed_btc_height) as u64; + push_local_unhandled_messages_with_reason( + ctx.local_db, + graph_id, + &message, + delay_secs as usize, + MessageDeferReason::GoatSpvPending, + "kickoff block is not available through GOAT SPV", + ) + .await?; + tracing::info!( + "Retry Challenge later for {instance_id}:{graph_id}: kickoff tx block is not posted to GOAT SPV yet" ); return Ok(()); - } else { - let (challenge_tx, _) = export_challenge_tx(&graph).unwrap(); - let challenge_txid = challenge_tx.compute_txid(); - if ctx.btc_client.get_tx(&challenge_txid).await?.is_none() { - send_challenge_tx(ctx.btc_client, &graph).await?; - } + } + + let (challenge_tx, _) = export_challenge_tx(&graph).unwrap(); + let challenge_txid = challenge_tx.compute_txid(); + if ctx.btc_client.get_tx(&challenge_txid).await?.is_none() { + send_challenge_tx(ctx.btc_client, &graph).await?; } } else { tracing::info!( @@ -5342,33 +6023,86 @@ async fn handle_assert_sent_verifier( }; validate_verifier_slot(&graph, verifier_index)?; - // check pubin first, if invalid, directly send PubinDisprove without building ChallengeAssert transaction - if let Ok(connector_e_input) = graph.watchtower_challenge_init.connector_e_input() { - let outpoint = connector_e_input.outpoint; - if let Some(commit_pubin_txid) = - outpoint_spent_txid(ctx.btc_client, &outpoint.txid, outpoint.vout as u64).await? - && let Some(commit_pubin_tx) = ctx.btc_client.get_tx(&commit_pubin_txid).await? - && let Some(commit_pubin_txin) = commit_pubin_tx.input.first() - { - let assert_txin = assert_tx - .input - .first() - .ok_or_else(|| anyhow!("operator assert transaction has no input"))?; - let wci_txid = graph.watchtower_challenge_init.tx().compute_txid(); - let watchtower_timeout_txids = graph - .watchtower_challenge_timeouts - .iter() - .map(|tx| tx.tx().compute_txid()) - .collect::>(); - let ack_txins = match collect_ack_txins( - ctx.btc_client, - &wci_txid, - &watchtower_timeout_txids, + // ChallengeAssert may only be skipped after this graph's commit-pubin is + // on-chain and proves consistent with the assert witness. OperatorAssert + // can otherwise be broadcast before connector-E is spent. + let connector_e_input = graph + .watchtower_challenge_init + .connector_e_input() + .map_err(|error| anyhow!("failed to resolve connector-E input: {error}"))?; + let connector_e_outpoint = connector_e_input.outpoint; + let Some(connector_e_spent_txid) = outpoint_spent_txid( + ctx.btc_client, + &connector_e_outpoint.txid, + connector_e_outpoint.vout as u64, + ) + .await? + else { + let delay_secs = avg_block_time_secs(ctx.btc_client.network()); + let message = make_message(ctx, content); + push_local_unhandled_messages_with_reason( + ctx.local_db, + graph_id, + &message, + delay_secs as usize, + MessageDeferReason::ProtocolInputsPending, + "operator commit-pubin is not on chain yet", + ) + .await?; + tracing::info!( + "Retry AssertSent later for {instance_id}:{graph_id}: operator commit-pubin is not on chain yet" + ); + return Ok(()); + }; + + let mut pubin_consistent = false; + if connector_e_spent_txid != graph.operator_commit_timeout.tx().compute_txid() { + if !ctx.btc_client.get_tx_status(&connector_e_spent_txid).await?.confirmed { + let delay_secs = avg_block_time_secs(ctx.btc_client.network()); + let message = make_message(ctx, content); + push_local_unhandled_messages_with_reason( + ctx.local_db, + graph_id, + &message, + delay_secs as usize, + MessageDeferReason::ProtocolInputsPending, + "operator commit-pubin transaction is not confirmed yet", ) - .await - { + .await?; + return Ok(()); + } + let Some(commit_pubin_tx) = ctx.btc_client.get_tx(&connector_e_spent_txid).await? else { + let delay_secs = avg_block_time_secs(ctx.btc_client.network()); + let message = make_message(ctx, content); + push_local_unhandled_messages_with_reason( + ctx.local_db, + graph_id, + &message, + delay_secs as usize, + MessageDeferReason::ProtocolInputsPending, + "operator commit-pubin transaction is not available yet", + ) + .await?; + return Ok(()); + }; + let assert_txin = assert_tx + .input + .first() + .ok_or_else(|| anyhow!("operator assert transaction has no input"))?; + let commit_pubin_txin = commit_pubin_tx + .input + .first() + .ok_or_else(|| anyhow!("operator commit-pubin transaction has no input"))?; + let wci_txid = graph.watchtower_challenge_init.tx().compute_txid(); + let watchtower_timeout_txids = graph + .watchtower_challenge_timeouts + .iter() + .map(|tx| tx.tx().compute_txid()) + .collect::>(); + let ack_txins = + match collect_ack_txins(ctx.btc_client, &wci_txid, &watchtower_timeout_txids).await { Ok(txins) => txins, - Err(e) => { + Err(error) => { let delay_secs = avg_block_time_secs(ctx.btc_client.network()); let message = make_message(ctx, content); push_local_unhandled_messages_with_reason( @@ -5377,74 +6111,55 @@ async fn handle_assert_sent_verifier( &message, delay_secs as usize, MessageDeferReason::ProtocolInputsPending, - &format!("operator ACK inputs are not ready: {e}"), + &format!("operator ACK inputs are not ready: {error}"), ) .await?; - tracing::info!( - "Retry AssertSent later for {instance_id}:{graph_id}: ACK inputs are not ready: {e}" - ); return Ok(()); } }; - match validate_pubin_disprove(&graph, commit_pubin_txin, assert_txin, &ack_txins) { - Ok(Some((witness_data, _))) => { - // PubinDisprove is not pre-signed, so fund it directly rather - // than relying on CPFP. - let pubin_disprove_tx_total_input_amount = graph - .operator_assert - .connector_d_input() - .map_err(|e| anyhow!("failed to get connector-d input: {e}"))? - .amount; - let pubin_disprove_txin = build_pubin_disprove_txin(&graph, witness_data)?; - let pubin_disprove_tx = bitcoin::Transaction { - version: bitcoin::transaction::Version(2), - lock_time: bitcoin::absolute::LockTime::ZERO, - input: vec![pubin_disprove_txin], - output: vec![ - goat::scripts::p2a_output(), - // Keep the transaction above Bitcoin Core's minimum - // non-witness relay size. - bitcoin::TxOut { - value: Amount::ZERO, - script_pubkey: goat::scripts::generate_opreturn_script( - PUBIN_DISPROVE_OP_RETURN_DATA.to_vec(), - ), - }, - ], - }; - let verifier_keypair = - VerifierMasterKey::new(get_bitvm_key()?).master_keypair(); - build_sign_and_broadcast_tx( - ctx.btc_client, - verifier_keypair, - pubin_disprove_tx.input, - pubin_disprove_tx_total_input_amount, - pubin_disprove_tx.output, - ) - .await?; - return Ok(()); - } - Ok(None) => tracing::debug!( - "PubinDisprove invalid for {instance_id}:{graph_id}: operator pubin consistent, proceeding to ChallengeAssert" - ), - Err(e) => { - let delay_secs = avg_block_time_secs(ctx.btc_client.network()); - let message = make_message(ctx, content); - push_local_unhandled_messages_with_reason( - ctx.local_db, - graph_id, - &message, - delay_secs as usize, - MessageDeferReason::ValidationRetry, - &format!("PubinDisprove validation could not complete: {e}"), - ) - .await?; - tracing::warn!( - "Retry AssertSent later for {instance_id}:{graph_id}: PubinDisprove check failed: {e}" - ); - return Ok(()); - } + match validate_pubin_disprove(&graph, commit_pubin_txin, assert_txin, &ack_txins) { + Ok(Some((witness_data, _))) => { + // PubinDisprove is not pre-signed, so fund it directly rather + // than relying on CPFP. + let pubin_disprove_tx_total_input_amount = graph + .operator_assert + .connector_d_input() + .map_err(|e| anyhow!("failed to get connector-d input: {e}"))? + .amount; + let pubin_disprove_txin = build_pubin_disprove_txin(&graph, witness_data)?; + let pubin_disprove_tx = bitcoin::Transaction { + version: bitcoin::transaction::Version(2), + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![pubin_disprove_txin], + output: vec![ + goat::scripts::p2a_output(), + // Keep the transaction above Bitcoin Core's minimum + // non-witness relay size. + bitcoin::TxOut { + value: Amount::ZERO, + script_pubkey: goat::scripts::generate_opreturn_script( + PUBIN_DISPROVE_OP_RETURN_DATA.to_vec(), + ), + }, + ], + }; + let verifier_keypair = VerifierMasterKey::new(get_bitvm_key()?).master_keypair(); + build_sign_and_broadcast_tx( + ctx.btc_client, + verifier_keypair, + pubin_disprove_tx.input, + pubin_disprove_tx_total_input_amount, + pubin_disprove_tx.output, + ) + .await?; + return Ok(()); } + Ok(None) => { + pubin_consistent = true; + } + Err(error) => tracing::warn!( + "PubinDisprove validation failed for {instance_id}:{graph_id}: {error}; proceeding to ChallengeAssert" + ), } } @@ -5457,7 +6172,7 @@ async fn handle_assert_sent_verifier( .map_err(|e| anyhow!("failed to extract operator assert witness: {e}"))?; let vk = crate::vk::get_vk().await.context("load Groth16 verifying key for operator assert")?; let static_input = derive_operator_static_input()?; - if assert_witness.verify_groth16_proof(&vk, &[static_input]) { + if pubin_consistent && assert_witness.verify_groth16_proof(&vk, &[static_input]) { tracing::info!( "Skip ChallengeAssert for {instance_id}:{graph_id}: operator assert proof is valid" ); @@ -5525,7 +6240,7 @@ pub async fn broadcast_verifier_challenge_assert_tx( ); return Ok(None); }; - let Some(soldering_proof_ready) = saved_verifier_state.soldering_proof_ready.as_ref() else { + if saved_verifier_state.soldering_proof_ready.is_none() { if strict { bail!("missing soldering proof reference for {instance_id}:{graph_id}"); } @@ -5533,15 +6248,6 @@ pub async fn broadcast_verifier_challenge_assert_tx( "Ignore AssertSent for {instance_id}:{graph_id}: missing soldering proof reference" ); return Ok(None); - }; - if soldering_proof_ready.verifier_index != verifier_index { - if strict { - bail!("local setup slot does not match graph owner slot for {instance_id}:{graph_id}"); - } - tracing::warn!( - "Ignore AssertSent for {instance_id}:{graph_id}: local setup slot does not match graph owner slot" - ); - return Ok(None); } let challenge_witness = build_real_challenge_assert_witness( &saved_verifier_state.private_state, @@ -5672,11 +6378,7 @@ async fn handle_challenge_assert_sent_operator( let operator_state = setup_state .operator .ok_or_else(|| anyhow!("missing operator BABE setup state for graph {graph_id}"))?; - let candidate = operator_state - .candidates - .iter() - .find(|candidate| candidate.verifier_index == Some(verifier_index)) - .ok_or_else(|| anyhow!("missing BABE prover state for verifier slot {verifier_index}"))?; + let candidate = selected_candidate_for_graph_index(&operator_state, verifier_index)?; let soldering_proof_ready = candidate.soldering_proof_ready.as_ref().ok_or_else(|| { anyhow!("missing soldering proof reference for verifier slot {verifier_index}") })?; @@ -5685,7 +6387,7 @@ async fn handle_challenge_assert_sent_operator( &store_base_path, instance_id, graph_id, - verifier_index, + soldering_proof_ready.candidate_index, &soldering_proof_ready.payload_hash, )?; let payload = read_soldering_proof_store_payload(&payload_path) @@ -6498,11 +7200,34 @@ async fn handle_sync_graph( Ok(()) } +/// A `NodeInfo` payload is entirely self-declared, so only accept it as the +/// sender's own record: the registry is keyed by `peer_id`, and without this +/// check any peer could overwrite any other node's row. +fn accept_node_info(ctx: &HandlerContext<'_>, node_info: &NodeInfo, message_kind: &str) -> bool { + if !node_info_matches_sender(node_info, &ctx.from_peer_id) { + tracing::warn!( + "Ignore {message_kind} from {}: payload claims peer_id {}", + ctx.from_peer_id, + node_info.peer_id + ); + return false; + } + if let Err(reason) = validate_node_info_payload(node_info) { + tracing::warn!("Ignore {message_kind} from {}: {reason}", ctx.from_peer_id); + return false; + } + true +} + async fn handle_request_node_info( ctx: &mut HandlerContext<'_>, node_info: &NodeInfo, ) -> Result<()> { - save_node_info(ctx.local_db, node_info).await?; + if accept_node_info(ctx, node_info, "RequestNodeInfo") { + save_node_info(ctx.local_db, node_info).await?; + } + // Answer regardless: the response only carries this node's own public info, + // and staying silent would break discovery for a misconfigured peer. let message_content = GOATMessageContent::ResponseNodeInfo(crate::env::get_local_node_info()); send_to_peer(ctx.swarm, GOATMessage::new(Actor::All, message_content)).await?; Ok(()) @@ -6512,7 +7237,9 @@ async fn handle_response_node_info( ctx: &mut HandlerContext<'_>, node_info: &NodeInfo, ) -> Result<()> { - save_node_info(ctx.local_db, node_info).await?; + if accept_node_info(ctx, node_info, "ResponseNodeInfo") { + save_node_info(ctx.local_db, node_info).await?; + } Ok(()) } @@ -6526,6 +7253,25 @@ mod tests { use super::*; + #[tokio::test] + async fn missing_pegin_event_is_ignored_without_enqueuing_retry() { + let local_db = store::create_local_db("sqlite::memory:").await; + let instance_id = Uuid::new_v4(); + + let metadata = + resolve_pegin_request_metadata(&local_db, instance_id, "0xuntrusted", i64::MAX) + .await + .unwrap(); + + assert!(metadata.is_none()); + let mut storage_processor = local_db.acquire().await.unwrap(); + let queued = storage_processor + .find_message_by_business_id(&instance_id, "PeginRequest") + .await + .unwrap(); + assert!(queued.is_none()); + } + fn verifier_pubkey() -> PublicKey { PublicKey::from_str("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") .unwrap() @@ -6552,7 +7298,7 @@ mod tests { SolderingProofReady { instance_id: Uuid::nil(), graph_id: Uuid::nil(), - verifier_index: 0, + candidate_index: 0, payload_hash: [hash_byte; 32], total_len: 1, } @@ -6560,16 +7306,19 @@ mod tests { fn operator_state(package: CACSetupPackage) -> OperatorBabeSetupState { OperatorBabeSetupState { - frozen_verifier_pubkeys: Some(vec![verifier_pubkey()]), + candidate_verifier_pubkeys: Some(vec![verifier_pubkey()]), candidates: vec![OperatorVerifierCandidate { verifier_peer_id: vec![1], verifier_pubkey: verifier_pubkey(), setup_package: package, - verifier_index: Some(0), + candidate_index: Some(0), selected_circuit_indexes: (0..BABE_M_CC).collect(), gc_data: None, soldering_proof_ready: None, }], + candidate_collection_started_at: None, + proof_collection_started_at: None, + selected_verifier_pubkeys: None, asserted_operator_proof: None, } } @@ -6578,16 +7327,19 @@ mod tests { fn freeze_operator_candidate_selects_protocol_finalized_count() { let package = build_setup_package(BABE_M_CC + 1).unwrap(); let mut state = OperatorBabeSetupState { - frozen_verifier_pubkeys: None, + candidate_verifier_pubkeys: None, candidates: vec![OperatorVerifierCandidate { verifier_peer_id: vec![1], verifier_pubkey: verifier_pubkey(), setup_package: package, - verifier_index: None, + candidate_index: None, selected_circuit_indexes: vec![], gc_data: None, soldering_proof_ready: None, }], + candidate_collection_started_at: None, + proof_collection_started_at: None, + selected_verifier_pubkeys: None, asserted_operator_proof: None, }; @@ -6602,7 +7354,7 @@ mod tests { let mut state = operator_state(package.clone()); let soldering_proof_ready = soldering_proof_ready(1); - let graph_data = record_candidate_gc_data( + record_candidate_gc_data( &mut state, verifier_pubkey(), 0, @@ -6611,17 +7363,18 @@ mod tests { &prover_state, soldering_proof_ready.clone(), ) - .unwrap() .unwrap(); - assert_eq!(graph_data.len(), 1); - assert_eq!(graph_data[0].final_msg_hashlocks.len(), BABE_M_CC); + assert_eq!( + state.candidates[0].gc_data.as_ref().unwrap().final_msg_hashlocks.len(), + BABE_M_CC + ); assert_eq!( state.candidates[0].soldering_proof_ready.as_ref(), Some(&soldering_proof_ready) ); - let duplicate = record_candidate_gc_data( + record_candidate_gc_data( &mut state, verifier_pubkey(), 0, @@ -6630,9 +7383,7 @@ mod tests { &prover_state, soldering_proof_ready.clone(), ) - .unwrap() .unwrap(); - assert_eq!(duplicate.len(), 1); let mut conflict = prover_state; conflict.h_msgs[0][0] ^= 1; diff --git a/node/src/main.rs b/node/src/main.rs index daffed120..b19d273b8 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -53,7 +53,14 @@ struct Opts { #[arg(long)] bootnodes: Vec, - /// Metric endpoint path. + /// Dedicated Prometheus metrics listener address, e.g. `10.0.0.7:9108`. + /// + /// Unset disables the metrics listener entirely. Bind a private address and a + /// port unique to this process; the business RPC never serves metrics. + #[arg(long)] + metrics_addr: Option, + + /// Metric endpoint path served by the dedicated metrics listener. #[arg(long, default_value = "/metrics")] metrics_path: String, @@ -143,6 +150,16 @@ async fn main() -> Result<(), Box> { ) .into()); } + // Bind the metrics listener before any background task starts, so an invalid path + // or an unavailable metrics port aborts startup with an explicit error. + let metrics_listener = match opt.metrics_addr.as_deref() { + Some(metrics_addr) => { + rpc_service::validate_metrics_path(&opt.metrics_path)?; + Some(rpc_service::bind_metrics_listener(metrics_addr).await?) + } + None => None, + }; + let mut metric_registry = Registry::default(); // Create cancellation token for graceful shutdown @@ -203,19 +220,17 @@ async fn main() -> Result<(), Box> { bitcoin_network = ?get_network(), version = env!("CARGO_PKG_VERSION"), rpc_addr = %opt.rpc_addr, + metrics_addr = opt.metrics_addr.as_deref().unwrap_or("disabled"), min_required_verifier, "node initialization completed" ); - let actor_clone1 = actor.clone(); let actor_clone2 = actor.clone(); let actor_clone3 = actor.clone(); - let local_db_clone1 = local_db.clone(); let local_db_clone2 = local_db.clone(); let local_db_clone3 = local_db.clone(); let local_db_clone4 = local_db.clone(); let opt_rpc_addr = opt.rpc_addr.clone(); - let peer_id_string_clone = peer_id_string.clone(); tracing::debug!("RPC service listening on {}", &opt.rpc_addr); if actor == Actor::Operator { @@ -226,21 +241,23 @@ async fn main() -> Result<(), Box> { save_local_info(&local_db).await; metrics_state.mark_startup_ready(); + // Both listeners share the same application and metrics state. + let app_state = rpc_service::AppState::create_arc_app_state( + local_db.clone(), + actor.clone(), + peer_id_string.clone(), + metrics_state.clone(), + ) + .await?; + // Spawn RPC service task with cancellation support let cancel_token_clone = cancellation_token.clone(); let rpc_task_span = node_span.clone(); - let rpc_metrics = metrics_state.clone(); + let rpc_app_state = app_state.clone(); task_handles.push(tokio::spawn( async move { - match rpc_service::serve( - opt_rpc_addr, - local_db_clone1, - actor_clone1, - peer_id_string_clone, - rpc_metrics, - cancel_token_clone, - ) - .await + match rpc_service::serve_with_app_state(opt_rpc_addr, rpc_app_state, cancel_token_clone) + .await { Ok(tag) => Ok(tag), Err(error) => { @@ -260,6 +277,41 @@ async fn main() -> Result<(), Box> { .instrument(rpc_task_span), )); task_names.push("rpc_service"); + + if let Some(listener) = metrics_listener { + let cancel_token_clone = cancellation_token.clone(); + let metrics_task_span = node_span.clone(); + let metrics_app_state = app_state.clone(); + let metrics_path = opt.metrics_path.clone(); + task_handles.push(tokio::spawn( + async move { + match rpc_service::serve_metrics( + listener, + metrics_path, + metrics_app_state, + cancel_token_clone, + ) + .await + { + Ok(tag) => Ok(tag), + Err(error) => { + tracing::error!( + event = "core_task_wrapper_error", + service = "bitvm-noded", + task = "metrics_service", + outcome = "failed", + error_class = "metrics", + error = %error, + "metrics service task exited with an error" + ); + Err("metrics_error".to_string()) + } + } + } + .instrument(metrics_task_span), + )); + task_names.push("metrics_service"); + } // if actor == Actor::Committee || actor == Actor::Operator { let cancel_token_clone = cancellation_token.clone(); let event_watcher_task_span = node_span.clone(); diff --git a/node/src/metrics_service.rs b/node/src/metrics_service.rs index f23fc75fd..d4ca387ce 100644 --- a/node/src/metrics_service.rs +++ b/node/src/metrics_service.rs @@ -15,7 +15,7 @@ use prometheus_client::metrics::counter::Counter; use prometheus_client::metrics::family::Family; use prometheus_client::metrics::gauge::Gauge; use prometheus_client::metrics::histogram::{Histogram, exponential_buckets}; -use store::{GraphStatus, InstanceBridgeInStatus, InstanceBridgeOutStatus, MessageState}; +use store::{GraphStatus, InstanceBridgeInStatus, MessageState, SwapEscrowStatus}; use tokio::time::Instant; const METRICS_CONTENT_TYPE: &str = "application/openmetrics-text;charset=utf-8;version=1.0.0"; @@ -689,14 +689,14 @@ impl MetricsState { for count in counts { match count.category.as_str() { - "instance_bridge_in" => { + "instance" => { let status = known_status::(&count.state); self.instances .get_or_create(&InstanceLabels { flow: "bridge_in".to_string(), status }) .inc_by(count.count); } - "instance_bridge_out" => { - let status = known_status::(&count.state); + "swap_escrow" => { + let status = known_status::(&count.state); self.instances .get_or_create(&InstanceLabels { flow: "bridge_out".to_string(), status }) .inc_by(count.count); diff --git a/node/src/middleware/behaviour.rs b/node/src/middleware/behaviour.rs index 3dcc17af0..6e20aac43 100644 --- a/node/src/middleware/behaviour.rs +++ b/node/src/middleware/behaviour.rs @@ -47,8 +47,10 @@ pub fn get_topic_name(topic: &str) -> String { format!("{}/topic/{}", crate::env::get_proto_base(), topic) } -pub fn split_topic_name(topic_hash: &str) -> (&str, &str) { - topic_hash.split_once("/topic/").expect("should be $proto/topic/$actor") +pub fn split_topic_name(topic_hash: &str) -> anyhow::Result<(&str, &str)> { + topic_hash + .split_once("/topic/") + .ok_or_else(|| anyhow::anyhow!("topic should be $proto/topic/$actor")) } #[cfg(test)] @@ -58,7 +60,8 @@ mod tests { fn test_split_topic_name() { let topic_short = "hello"; let topic_full = get_topic_name(topic_short); - let topic_split = split_topic_name(&topic_full); + let topic_split = split_topic_name(&topic_full).unwrap(); assert_eq!(topic_split.1, topic_short); + assert!(split_topic_name(topic_short).is_err()); } } diff --git a/node/src/middleware/swarm.rs b/node/src/middleware/swarm.rs index aadd43018..1a01c3ef7 100644 --- a/node/src/middleware/swarm.rs +++ b/node/src/middleware/swarm.rs @@ -18,7 +18,7 @@ use std::time::Duration; use tokio::select; use tokio::time::interval; use tokio_util::sync::CancellationToken; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use zeroize::Zeroizing; pub struct BitvmSwarmWrapper(pub Swarm); @@ -261,7 +261,19 @@ impl BitvmNetworkManager { } SwarmEvent::Behaviour(AllBehavioursEvent::Gossipsub(gossipsub::Event::Subscribed { peer_id, topic})) => { debug!("subscribing: {:?}, {:?}", peer_id, topic); - let topic_limb = split_topic_name(topic.as_str());//topic.as_str().split_once("/topic/").expect("should be $proto/topic/$actor"); + let topic_limb = match split_topic_name(topic.as_str()) { + Ok(topic_limb) => topic_limb, + Err(error) => { + warn!( + %peer_id, + topic = %topic, + %error, + "Disconnecting peer subscribed to malformed topic" + ); + let _ = self.swarm.disconnect_peer_id(peer_id); + continue; + } + }; if topic_limb.0 != env::get_proto_base() { continue; } diff --git a/node/src/rpc_service/auth.rs b/node/src/rpc_service/auth.rs index 4d5db476e..8499134dd 100644 --- a/node/src/rpc_service/auth.rs +++ b/node/src/rpc_service/auth.rs @@ -1,179 +1,397 @@ use crate::env::get_bitvm_key; use crate::rpc_service::response::ErrorResponse; +use crate::rpc_service::{AppState, current_time_secs}; use axum::Json; -use http::{HeaderMap, StatusCode}; +use axum::body::{Body, to_bytes}; +use axum::extract::{Request, State}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use http::{HeaderMap, Method, StatusCode}; +use proof_builder::api_auth::hash_field; use secp256k1::schnorr::Signature; -use secp256k1::{Keypair, Message, SECP256K1}; +use secp256k1::{Keypair, Message, SECP256K1, XOnlyPublicKey}; use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; pub const AUTH_TIMESTAMP_HEADER: &str = "x-auth-timestamp"; +pub const AUTH_NONCE_HEADER: &str = "x-auth-nonce"; pub const AUTH_SIGNATURE_HEADER: &str = "x-auth-signature"; const AUTH_WINDOW_SECS: i64 = 300; -const AUTH_DOMAIN: &[u8] = b"bitvm-auth"; +const AUTH_DOMAIN: &[u8] = b"bitvm-rpc-auth-v2"; +const MAX_AUTH_BODY_BYTES: usize = 2 * 1024 * 1024; -/// Verify that the request carries a valid Schnorr signature produced by the -/// same `BITVM_SECRET` this node is configured with. +struct ParsedAuth { + timestamp_str: String, + timestamp: i64, + nonce: String, + signature: Signature, +} + +/// Require a fresh, request-bound Schnorr signature and atomically consume its nonce. +pub async fn require_request_auth( + State(app_state): State>, + request: Request, + next: Next, +) -> Response { + let (parts, body) = request.into_parts(); + let now = current_time_secs(); + let parsed_auth = match parse_auth_headers(&parts.headers, now) { + Ok(auth) => auth, + Err(error) => return error.into_response(), + }; + + let body = match to_bytes(body, MAX_AUTH_BODY_BYTES).await { + Ok(body) => body, + Err(_) => { + return auth_error( + StatusCode::PAYLOAD_TOO_LARGE, + "PAYLOAD_TOO_LARGE", + "request body exceeds 2 MiB limit", + ) + .into_response(); + } + }; + let request_target = + parts.uri.path_and_query().map(|value| value.as_str()).unwrap_or_else(|| parts.uri.path()); + + let keypair = match get_bitvm_key() { + Ok(keypair) => keypair, + Err(_) => { + return auth_error( + StatusCode::UNAUTHORIZED, + "AUTH_ERROR", + "server key configuration error (BITVM_SECRET)", + ) + .into_response(); + } + }; + let (x_only_pubkey, _) = keypair.x_only_public_key(); + if let Err(error) = + verify_auth_signature(&parsed_auth, &parts.method, request_target, &body, &x_only_pubkey) + { + return error.into_response(); + } + + let expires_at = parsed_auth.timestamp + AUTH_WINDOW_SECS; + match consume_auth_nonce(&app_state.rpc_auth_nonces, &parsed_auth.nonce, expires_at, now) { + Ok(true) => next.run(Request::from_parts(parts, Body::from(body))).await, + Ok(false) => auth_error( + StatusCode::CONFLICT, + "AUTH_REPLAY", + "authentication nonce has already been used", + ) + .into_response(), + Err(()) => { + tracing::error!( + event = "rpc_auth_nonce_cache_failed", + "RPC authentication nonce cache lock is poisoned" + ); + auth_error( + StatusCode::SERVICE_UNAVAILABLE, + "AUTH_SERVICE_UNAVAILABLE", + "authentication service unavailable", + ) + .into_response() + } + } +} + +/// Build the authentication headers for one exact HTTP request. /// -/// Expected headers: -/// - `X-Auth-Timestamp`: current unix epoch seconds (string) -/// - `X-Auth-Signature`: hex-encoded 64-byte Schnorr signature of -/// `SHA256(b"bitvm-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"))?; +/// The caller must send the same method, request target, and body bytes that are +/// supplied here. Returns `(timestamp, nonce, hex_signature)`. +pub fn sign_request_auth( + keypair: &Keypair, + method: &Method, + request_target: &str, + body: &[u8], +) -> (String, String, String) { + let timestamp = current_time_secs().to_string(); + let nonce = Uuid::new_v4().to_string(); let signature = - Signature::from_slice(&sig_bytes).map_err(|_| auth_error("invalid signature format"))?; + sign_request_auth_values(keypair, ×tamp, &nonce, method, request_target, body); + (timestamp, nonce, signature) +} - // 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(); +fn parse_auth_headers( + headers: &HeaderMap, + now: i64, +) -> Result)> { + let timestamp_str = header_value(headers, AUTH_TIMESTAMP_HEADER)?.to_string(); + let timestamp = timestamp_str + .parse() + .map_err(|_| auth_error(StatusCode::UNAUTHORIZED, "AUTH_ERROR", "invalid timestamp"))?; + if now.abs_diff(timestamp) > AUTH_WINDOW_SECS as u64 { + return Err(auth_error(StatusCode::UNAUTHORIZED, "AUTH_ERROR", "timestamp expired")); + } - // Verify - SECP256K1 - .verify_schnorr(&signature, &msg, &x_only_pubkey) - .map_err(|_| auth_error("signature verification failed"))?; + let nonce = header_value(headers, AUTH_NONCE_HEADER)?.to_string(); + let parsed_nonce = Uuid::parse_str(&nonce) + .map_err(|_| auth_error(StatusCode::UNAUTHORIZED, "AUTH_ERROR", "invalid nonce"))?; + if parsed_nonce.to_string() != nonce { + return Err(auth_error(StatusCode::UNAUTHORIZED, "AUTH_ERROR", "invalid nonce")); + } + + let signature_hex = header_value(headers, AUTH_SIGNATURE_HEADER)?; + let sig_bytes = hex::decode(signature_hex).map_err(|_| { + auth_error(StatusCode::UNAUTHORIZED, "AUTH_ERROR", "invalid signature hex encoding") + })?; + let signature = Signature::from_slice(&sig_bytes).map_err(|_| { + auth_error(StatusCode::UNAUTHORIZED, "AUTH_ERROR", "invalid signature format") + })?; - Ok(()) + Ok(ParsedAuth { timestamp_str, timestamp, nonce, signature }) } -/// Build the `(x-auth-timestamp, x-auth-signature)` header values for a request. -/// -/// Signs `SHA256(b"bitvm-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(); +fn header_value<'a>( + headers: &'a HeaderMap, + name: &str, +) -> Result<&'a str, (StatusCode, Json)> { + headers.get(name).and_then(|value| value.to_str().ok()).ok_or_else(|| { + auth_error(StatusCode::UNAUTHORIZED, "AUTH_ERROR", &format!("missing {name} header")) + }) +} +fn verify_auth_signature( + auth: &ParsedAuth, + method: &Method, + request_target: &str, + body: &[u8], + x_only_pubkey: &XOnlyPublicKey, +) -> Result<(), (StatusCode, Json)> { + let digest = + request_auth_digest(&auth.timestamp_str, &auth.nonce, method, request_target, body); + let message = Message::from_digest(digest); + SECP256K1.verify_schnorr(&auth.signature, &message, x_only_pubkey).map_err(|_| { + auth_error(StatusCode::UNAUTHORIZED, "AUTH_ERROR", "signature verification failed") + }) +} + +fn sign_request_auth_values( + keypair: &Keypair, + timestamp: &str, + nonce: &str, + method: &Method, + request_target: &str, + body: &[u8], +) -> String { + let message = + Message::from_digest(request_auth_digest(timestamp, nonce, method, request_target, body)); + hex::encode(SECP256K1.sign_schnorr(&message, keypair).as_ref()) +} + +fn request_auth_digest( + timestamp: &str, + nonce: &str, + method: &Method, + request_target: &str, + body: &[u8], +) -> [u8; 32] { + let body_digest = Sha256::digest(body); 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); + for field in [ + AUTH_DOMAIN, + timestamp.as_bytes(), + nonce.as_bytes(), + method.as_str().as_bytes(), + request_target.as_bytes(), + ] { + hash_field(&mut hasher, field); + } + hash_field(&mut hasher, &body_digest); + hasher.finalize().into() +} - let signature = SECP256K1.sign_schnorr(&msg, keypair); - (timestamp, hex::encode(signature.as_ref())) +fn consume_auth_nonce( + nonces: &Mutex>, + nonce: &str, + expires_at: i64, + now: i64, +) -> Result { + let mut nonces = nonces.lock().map_err(|_| ())?; + nonces.retain(|_, expires_at| *expires_at >= now); + Ok(nonces.insert(nonce.to_string(), expires_at).is_none()) } -fn auth_error(msg: &str) -> (StatusCode, Json) { - ( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse { error: "AUTH_ERROR".to_string(), message: msg.to_string() }), - ) +fn auth_error(status: StatusCode, error: &str, message: &str) -> (StatusCode, Json) { + (status, Json(ErrorResponse { error: error.to_string(), message: message.to_string() })) } #[cfg(test)] mod tests { use super::*; - use http::HeaderMap; - fn make_headers(timestamp: &str, signature: &str) -> HeaderMap { + const TIMESTAMP: &str = "2000000000"; + const NONCE: &str = "01234567-89ab-4def-8123-456789abcdef"; + const OTHER_NONCE: &str = "fedcba98-7654-4abc-8123-456789abcdef"; + const TARGET: &str = "/v1/graphs/pegout"; + const BODY: &[u8] = br#"{"graph_id":null,"dry_run":false,"skip_locked":false}"#; + + fn test_keypair() -> Keypair { + Keypair::from_seckey_slice(SECP256K1, &[0x42; 32]).unwrap() + } + + fn signed_headers( + keypair: &Keypair, + timestamp: &str, + nonce: &str, + method: &Method, + target: &str, + body: &[u8], + ) -> HeaderMap { + let signature = sign_request_auth_values(keypair, timestamp, nonce, method, target, body); let mut headers = HeaderMap::new(); headers.insert(AUTH_TIMESTAMP_HEADER, timestamp.parse().unwrap()); + headers.insert(AUTH_NONCE_HEADER, nonce.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() + fn verify( + headers: &HeaderMap, + method: &Method, + target: &str, + body: &[u8], + now: i64, + ) -> Result<(), (StatusCode, Json)> { + let auth = parse_auth_headers(headers, now)?; + let (x_only_pubkey, _) = test_keypair().x_only_public_key(); + verify_auth_signature(&auth, method, target, body, &x_only_pubkey) } #[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 headers = signed_headers(&keypair, TIMESTAMP, NONCE, &Method::POST, TARGET, BODY); + let non_v4_headers = signed_headers( + &keypair, + TIMESTAMP, + "01234567-89ab-7def-8123-456789abcdef", + &Method::POST, + TARGET, + BODY, + ); - let (ts, sig) = sign_request_auth(&keypair); - let headers = make_headers(&ts, &sig); - assert!(verify_request_auth(&headers).is_ok()); + assert!(verify(&headers, &Method::POST, TARGET, BODY, 2_000_000_000).is_ok()); + assert!(verify(&non_v4_headers, &Method::POST, TARGET, BODY, 2_000_000_000).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); + fn signature_is_bound_to_the_entire_request() { + let keypair = test_keypair(); + let headers = signed_headers(&keypair, TIMESTAMP, NONCE, &Method::POST, TARGET, BODY); + + assert!(verify(&headers, &Method::PUT, TARGET, BODY, 2_000_000_000).is_err()); + assert!( + verify(&headers, &Method::POST, "/v1/graphs/not-pegout", BODY, 2_000_000_000,).is_err() + ); + assert!( + verify(&headers, &Method::POST, "/v1/graphs/pegout?dry_run=true", BODY, 2_000_000_000,) + .is_err() + ); + assert!( + verify( + &headers, + &Method::POST, + TARGET, + br#"{"graph_id":null,"dry_run":true,"skip_locked":false}"#, + 2_000_000_000, + ) + .is_err() + ); + + let mut changed_nonce = headers.clone(); + changed_nonce.insert(AUTH_NONCE_HEADER, OTHER_NONCE.parse().unwrap()); + assert!(verify(&changed_nonce, &Method::POST, TARGET, BODY, 2_000_000_000).is_err()); + + let mut changed_timestamp = headers; + changed_timestamp.insert(AUTH_TIMESTAMP_HEADER, "2000000001".parse().unwrap()); + assert!(verify(&changed_timestamp, &Method::POST, TARGET, BODY, 2_000_000_001).is_err()); } #[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); + fn missing_or_invalid_headers_fail() { + let keypair = test_keypair(); + let headers = signed_headers(&keypair, TIMESTAMP, NONCE, &Method::POST, TARGET, BODY); + + for missing in [AUTH_TIMESTAMP_HEADER, AUTH_NONCE_HEADER, AUTH_SIGNATURE_HEADER] { + let mut incomplete = headers.clone(); + incomplete.remove(missing); + assert_eq!( + verify(&incomplete, &Method::POST, TARGET, BODY, 2_000_000_000).unwrap_err().0, + StatusCode::UNAUTHORIZED + ); + } + + for nonce in [ + "not-a-uuid".to_string(), + NONCE.replace('-', ""), + format!("{{{NONCE}}}"), + NONCE.to_uppercase(), + ] { + let mut invalid_headers = headers.clone(); + invalid_headers.insert(AUTH_NONCE_HEADER, nonce.parse().unwrap()); + assert!(verify(&invalid_headers, &Method::POST, TARGET, BODY, 2_000_000_000).is_err()); + } } #[test] - fn verify_expired_timestamp_fails() { + fn nonce_cache_rejects_replay_and_removes_expired_entries() { + let nonces = Mutex::new(HashMap::new()); + + assert!(consume_auth_nonce(&nonces, NONCE, 400, 100).unwrap()); + assert!(!consume_auth_nonce(&nonces, NONCE, 400, 100).unwrap()); + assert!(consume_auth_nonce(&nonces, OTHER_NONCE, 800, 401).unwrap()); + assert!(!nonces.lock().unwrap().contains_key(NONCE)); + } + + #[test] + fn nonce_cache_consumption_is_atomic() { + let nonces = Mutex::new(HashMap::new()); + let (first, second) = std::thread::scope(|scope| { + let first = scope.spawn(|| consume_auth_nonce(&nonces, NONCE, 400, 100).unwrap()); + let second = scope.spawn(|| consume_auth_nonce(&nonces, NONCE, 400, 100).unwrap()); + (first.join().unwrap(), second.join().unwrap()) + }); + + assert_ne!(first, second); + } + + #[test] + fn expired_timestamp_fails() { let keypair = test_keypair(); - unsafe { - std::env::set_var("BITVM_SECRET", hex::encode(keypair.secret_key().secret_bytes())) - }; + let headers = signed_headers(&keypair, TIMESTAMP, NONCE, &Method::POST, TARGET, BODY); - // 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(); + assert_eq!( + verify(&headers, &Method::POST, TARGET, BODY, 2_000_000_301).unwrap_err().0, + StatusCode::UNAUTHORIZED + ); + } - 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); + #[test] + fn wrong_signature_fails() { + let keypair = test_keypair(); + let mut headers = signed_headers(&keypair, TIMESTAMP, NONCE, &Method::POST, TARGET, BODY); + headers.insert(AUTH_SIGNATURE_HEADER, "ff".repeat(64).parse().unwrap()); - 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); + assert!(verify(&headers, &Method::POST, TARGET, BODY, 2_000_000_000).is_err()); } #[test] - fn verify_wrong_signature_fails() { + fn legacy_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); + let mut hasher = Sha256::new(); + hasher.update(b"bitvm-auth"); + hasher.update(TIMESTAMP.as_bytes()); + let message = Message::from_digest(hasher.finalize().into()); + let signature = hex::encode(SECP256K1.sign_schnorr(&message, &keypair).as_ref()); + let mut headers = HeaderMap::new(); + headers.insert(AUTH_TIMESTAMP_HEADER, TIMESTAMP.parse().unwrap()); + headers.insert(AUTH_NONCE_HEADER, NONCE.parse().unwrap()); + headers.insert(AUTH_SIGNATURE_HEADER, signature.parse().unwrap()); + + assert!(verify(&headers, &Method::POST, TARGET, BODY, 2_000_000_000).is_err()); } } diff --git a/node/src/rpc_service/bitvm.rs b/node/src/rpc_service/bitvm.rs index c352ae99f..b73057bf5 100644 --- a/node/src/rpc_service/bitvm.rs +++ b/node/src/rpc_service/bitvm.rs @@ -15,31 +15,13 @@ use std::default::Default; use std::str::FromStr; use store::localdb::GraphQuery; use store::{ - ByteArray32, Graph, GraphStatus, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, - ProofState, SerializableTxid, UInt64Array3, + ByteArray32, Graph, GraphStatus, Instance, InstanceBridgeInStatus, ProofState, + SerializableTxid, UInt64Array3, }; use strum::{Display, EnumString}; use tracing::warn; use uuid::Uuid; -#[allow(dead_code)] -pub const WATCHTOWER_CHALLENGE_STEP_INIT: &str = "Watchtower Challenge init"; -#[allow(dead_code)] -pub const WATCHTOWER_CHALLENGE_STEP_CHALLENGE: &str = "Watchtower Challenge"; -#[allow(dead_code)] -pub const WATCHTOWER_CHALLENGE_STEP_CHALLENGE_TIMEOUT: &str = "Watchtower Challenge Timeout"; -#[allow(dead_code)] -pub const WATCHTOWER_CHALLENGE_STEP_ACK: &str = "Operator Challenge ACK"; -#[allow(dead_code)] -pub const WATCHTOWER_CHALLENGE_STEP_COMMIT_BLOCKHASH: &str = "Operator Commit BlockHash"; -#[allow(dead_code)] -pub const WATCHTOWER_CHALLENGE_STEP_COMMIT_BLOCKHASH_TIMEOUT: &str = - "Operator Commit BlockHash Timeout"; -#[allow(dead_code)] -pub const ASSERT_STEP_INIT: &str = "Assert init"; -#[allow(dead_code)] -pub const ASSERT_STEP_COMMIT: &str = "Assert Commit"; - const BRIDGE_IN_FAIL_AS_UTXO_BEEN_SPENT: &str = "Your UTXO has already been spent."; // const BRIDGE_IN_FAIL_AS_NO_ENOUGH_COMMITTEES: &str = "Unfortunately, no enough committee answered."; const BRIDGE_IN_FAIL_AS_PRESIGNED_FAILED: &str = "Unfortunately, the presigned failed."; @@ -48,48 +30,17 @@ const BRIDGE_IN_FAIL_AS_VERIFICATION_FAILED: &str = "Unfortunately, the verifica const BRIDGE_IN_FAIL_AS_L2_MINTED_FAILED: &str = "Unfortunately, PegBTC minted failed."; const BRIDGE_IN_FAIL_AS_TIMEOUT: &str = "The operation timed out. Please try again."; -const _BRIDGE_OUT_FAIL_AS_CLAIM_TIMEOUT: &str = - "Claim timed out. Please initiate a new transaction."; const _BRIDGE_IN_FAIL_AS_L1_LOCK_TIMEOUT: &str = "The operator timed out and failed to lock BTC. Please cancel the transaction."; pub const BRIDGE_IN_AMOUNTS: [f32; 2] = [0.1, 0.01]; const GOAT_BLOCK_INTERVAL_SECS: i64 = 3; -const INSTANCE_BRIDGE_OUT_INIT_STATUS_DURATION_SECS: i64 = 3600; // todo update const INSTANCE_USER_BROADCAST_PREPARE_STATUS_DURATION_SECS: i64 = 3600 * 3; const INSTANCE_RELAYER_L1_BROADCAST_STATUS_DURATION_SECS: i64 = 3600 * 3; const GRAPH_OPERATOR_KICKOFFING_STATUS_DURATION_SECS: i64 = 1800; const GRAPH_OPERATOR_KICKOFF_STATUS_DURATION_SECS: i64 = 3600 * 3; const GRAPH_OPERATOR_CHALLENGE_STATUS_DURATION_SECS: i64 = 3600 * 9; -#[derive(Debug, Deserialize, Serialize)] -pub struct BridgeInPrepareRequest { - pub instance_id: String, // UUID - pub contract_address: String, // gateway address - pub from_addr: String, // BTC /charge - pub to_addr: String, // BTC /charge - pub bridge_request_tx_hash: String, // goat tx hash -} -#[derive(Debug, Deserialize, Serialize)] -pub struct BridgeInPrepareResponse {} - -#[derive(Debug, Deserialize, Serialize)] -pub struct BridgeOutInitTagRequest { - pub contract_address: String, // gateway address - pub from_addr: String, // goat addr - pub to_addr: String, // btc addr - pub escrow_hash: String, // goat tx hash -} -#[derive(Debug, Deserialize, Serialize)] -pub struct BridgeOutInitTagResponse {} - -#[derive(Debug, Deserialize, Serialize)] -pub struct EscrowDataResponse { - pub instance_id: String, - pub escrow: Option, - pub error: Option, -} - #[derive(Debug, Deserialize, Serialize)] pub struct InstanceSettingResponse { pub bridge_in_amount: Vec, @@ -100,6 +51,7 @@ pub struct SendChallengeResponse { pub challenge_txid: String, } +#[cfg(feature = "rpc-debug-endpoints")] #[derive(Debug, Deserialize, Serialize)] pub struct SendVerifierChallengeResponse { pub challenge_assert_txid: String, @@ -140,7 +92,6 @@ pub struct GraphTxnGetParams { /// get tx detail #[derive(Debug, Deserialize)] pub struct InstanceListRequest { - pub is_bridge_in: bool, pub from_addr: Option, pub offset: Option, pub limit: Option, @@ -164,7 +115,6 @@ pub struct StatusExtra { #[derive(Deserialize, Serialize, Default)] pub struct InstanceDisplay { pub instance_id: Uuid, - pub is_bridge_in: bool, pub network: String, pub from_addr: String, pub to_addr: String, // goat deposit addr @@ -172,21 +122,19 @@ pub struct InstanceDisplay { pub amount: U256, pub fees: UInt64Array3, pub input_utxos: String, // init should been [] - pub status: String, // InstanceBridgeInStatus | InstanceBridgeOutStatus - pub goat_tx_hash: String, // bridgeIn:pegin Request tx || bridgeOut goat tx + pub status: String, // InstanceBridgeInStatus + pub goat_tx_hash: String, // pegin Request tx pub goat_tx_height: i64, pub user_xonly_pubkey: ByteArray32, pub user_change_addr: String, pub user_refund_addr: String, - pub btc_txid: Option, // bridgeIn: Pegin Prepare Request tx || bridgeOut Btc tx + pub btc_txid: Option, // Pegin Prepare Request tx pub btc_height: i64, pub pegin_confirm_txid: Option, // btc txid pub pegin_cancel_txid: Option, // btc txid pub committees_answers: IndexMap>, pub pegin_data_tx_hash: String, pub parameters: Option, - pub escrow_hash: Option, - pub bridge_out_lock_time: i64, pub post_pegin_txhash: Option, pub status_updated_at: i64, pub created_at: i64, @@ -194,18 +142,12 @@ pub struct InstanceDisplay { } impl From for InstanceDisplay { fn from(instance: Instance) -> Self { - let amount = if instance.is_bridge_in { - U256::from(instance.amount) - } else { - U256::from_str(&instance.bridge_out_amount).unwrap_or_default() - }; Self { instance_id: instance.instance_id, - is_bridge_in: instance.is_bridge_in, network: instance.network, from_addr: instance.from_addr, to_addr: instance.to_addr, - amount, + amount: U256::from(instance.amount), fees: instance.fees, input_utxos: instance.input_utxos, status: instance.status, @@ -221,8 +163,6 @@ impl From for InstanceDisplay { committees_answers: instance.committees_answers, pegin_data_tx_hash: instance.pegin_data_tx_hash, parameters: instance.parameters, - escrow_hash: instance.escrow_hash, - bridge_out_lock_time: instance.bridge_out_lock_time, post_pegin_txhash: instance.post_pegin_txhash, status_updated_at: instance.status_updated_at, created_at: instance.created_at, @@ -245,7 +185,6 @@ pub struct InstanceExtended { impl InstanceExtended { pub async fn convert_from_instance( btc_client: &BTCClient, - btc_current_height: u32, response_window_blocks: i64, instance: Instance, ) -> anyhow::Result { @@ -255,17 +194,9 @@ impl InstanceExtended { instance.input_utxos, e )) })?; - let (confirmations, target_confirmations) = get_instance_block_confirm_progress( - btc_client, - btc_current_height, - instance.is_bridge_in, - instance.btc_txid.clone(), - ) - .await?; let status_extra = get_instance_status_extra( btc_client, instance.instance_id, - instance.is_bridge_in, instance.status.clone(), instance.btc_txid.clone().map(|v| v.0.to_string()), &utxos, @@ -274,20 +205,18 @@ impl InstanceExtended { .await?; let (current_status_waiting_time_in_secs, waiting_time_in_secs) = get_instance_waiting_times( - instance.is_bridge_in, &instance.status, instance.created_at, instance.status_updated_at, response_window_blocks, - instance.bridge_out_lock_time, ); // instance.status = instance.convert_to_display_status(); Ok(Self { waiting_time_in_secs, current_status_waiting_time_in_secs, - confirmations, - target_confirmations, + confirmations: 0, + target_confirmations: 0, status_extra, utxo: utxos, instance: instance.into(), @@ -298,14 +227,13 @@ impl InstanceExtended { async fn get_instance_status_extra( btc_client: &BTCClient, instance_id: Uuid, - is_bridge_in: bool, status: String, target_txid: Option, utxos: &[Utxo], pegin_cancel_txid: Option, ) -> anyhow::Result { let mut status_extra = StatusExtra::default(); - if is_bridge_in && let Ok(bridge_in_status) = InstanceBridgeInStatus::from_str(&status) { + if let Ok(bridge_in_status) = InstanceBridgeInStatus::from_str(&status) { match bridge_in_status { InstanceBridgeInStatus::UserInited => { if !check_bridge_in_uxto_available_or_self_spent(btc_client, target_txid, utxos) @@ -369,40 +297,12 @@ async fn get_instance_status_extra( } _ => {} } - } else if !is_bridge_in - && let Ok(bridge_out_status) = InstanceBridgeOutStatus::from_str(&status) - { - if bridge_out_status == InstanceBridgeOutStatus::Timeout { - status_extra.is_failed = true; - status_extra.error = Some(BRIDGE_IN_FAIL_AS_TIMEOUT.to_string()); - status_extra.user_action = StatusUserAction::Refund; - } } else { - warn!("instance:with {instance_id}, is_bridge_in:{is_bridge_in} has wrong status:{status}"); + warn!("instance:with {instance_id} has wrong status:{status}"); } Ok(status_extra) } -async fn get_instance_block_confirm_progress( - btc_client: &BTCClient, - current_height: u32, - is_bridge_in: bool, - txid: Option, -) -> anyhow::Result<(u32, u32)> { - if !is_bridge_in { - if let Some(txid) = txid - && let Ok(tx_status) = btc_client.get_tx_status(&txid.into()).await - && let Some(height) = tx_status.block_height - { - Ok((current_height + 1_u32 - height, 6_u32)) - } else { - Ok((0, 6)) - } - } else { - Ok((0, 0)) - } -} - fn get_bridge_in_status_time_window_secs(status: &str, response_window_blocks: i64) -> (i64, i64) { let response_window_blocks_with_margin = response_window_blocks * 105 / 100; let total_time = response_window_blocks_with_margin * GOAT_BLOCK_INTERVAL_SECS @@ -435,41 +335,18 @@ fn get_bridge_in_status_time_window_secs(status: &str, response_window_blocks: i } } -// dead time -fn get_bridge_out_status_time_window_secs(status: &str, bridge_out_lock_time: i64) -> (i64, i64) { - let deadline_time = if bridge_out_lock_time > 0 { - bridge_out_lock_time - } else { - // for history data not set bridge_out_lock_time - INSTANCE_BRIDGE_OUT_INIT_STATUS_DURATION_SECS - }; - - match InstanceBridgeOutStatus::from_str(status) { - Ok(InstanceBridgeOutStatus::Initialize) => (deadline_time, deadline_time), - Ok(_) | Err(_) => (0, 0), - } -} - fn get_instance_waiting_times( - is_bridge_in: bool, status: &str, created_at: i64, last_updated: i64, response_window_blocks: i64, - bridge_out_lock_time: i64, ) -> (i64, i64) { let current_time = current_time_secs(); let current_state_past = current_time - last_updated; let total_past = current_time - created_at; - if is_bridge_in { - let (current_status_window, total_window) = - get_bridge_in_status_time_window_secs(status, response_window_blocks); - ((current_status_window - current_state_past).max(0), (total_window - total_past).max(0)) - } else { - let (current_status_deadline, status_deadline) = - get_bridge_out_status_time_window_secs(status, bridge_out_lock_time); - ((current_status_deadline - current_time).max(0), (status_deadline - current_time).max(0)) - } + let (current_status_window, total_window) = + get_bridge_in_status_time_window_secs(status, response_window_blocks); + ((current_status_window - current_state_past).max(0), (total_window - total_past).max(0)) } #[derive(Deserialize, Serialize, Default)] @@ -797,9 +674,6 @@ impl DisplayStatusConvert for Graph { impl DisplayStatusConvert for Instance { fn convert_to_display_status(&self) -> String { - if !self.is_bridge_in { - return self.status.clone(); - } match InstanceBridgeInStatus::from_str(&self.status) { Ok(InstanceBridgeInStatus::UserInited) => InstanceBridgeInStatus::Initiated.to_string(), Ok(InstanceBridgeInStatus::CommitteesAnswered) => { @@ -892,7 +766,6 @@ mod tests { get_instance_status_extra( btc_client, Uuid::new_v4(), - true, InstanceBridgeInStatus::Timeout.to_string(), None, &[], diff --git a/node/src/rpc_service/handler/bitvm_handler.rs b/node/src/rpc_service/handler/bitvm_handler.rs index 05719b3d2..64da9d0c3 100644 --- a/node/src/rpc_service/handler/bitvm_handler.rs +++ b/node/src/rpc_service/handler/bitvm_handler.rs @@ -1,51 +1,34 @@ use crate::env::{ - ENV_GOAT_GATEWAY_CONTRACT_ADDRESS, ENV_GOAT_SWAP_CONTRACT_ADDRESS, GraphBtcTxName, - get_goat_address_from_env, get_goat_gateway_contract_from_env, get_network, - get_node_goat_address, get_node_pubkey, + GraphBtcTxName, get_goat_gateway_contract_from_env, get_node_goat_address, get_node_pubkey, }; +#[cfg(feature = "rpc-debug-endpoints")] use crate::handle::broadcast_verifier_challenge_assert_tx; -use crate::rpc_service::auth::verify_request_auth; +use crate::rpc_service::AppState; use crate::rpc_service::bitvm::*; use crate::rpc_service::node::ALIVE_TIME_JUDGE_THRESHOLD; use crate::rpc_service::response::{ ApiErrorExt, ApiResult, ErrorResponse, error_response, ok_response, }; use crate::rpc_service::validation::InputValidator; -use crate::rpc_service::{AppState, current_time_secs}; use crate::utils::{ - bridge_out_instance_id_from_escrow_hash, find_instances_by_escrow_hash, gen_instance_parameters_local, get_bridge_out_global_stats, load_validated_graph_definition, obsolete_graph, send_challenge_tx, }; -use alloy::primitives::{Address, U256}; +use alloy::primitives::U256; use axum::Json; use axum::extract::{Path, Query, State}; use bitcoin::consensus::encode::serialize_hex; use bitvm_lib::types::BitvmGcGraph; use client::goat_chain::{PeginStatus, WithdrawStatus}; use goat::transactions::pre_signed::PreSignedTransaction; -use http::{HeaderMap, StatusCode}; +use http::StatusCode; use std::collections::HashSet; use std::default::Default; use std::str::FromStr; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; -use store::localdb::{GraphQuery, InstanceQuery, InstanceUpdate, StorageProcessor}; -use store::{ - GoatTxType, Graph, GraphStatus, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, -}; -use tokio::time::{Duration, sleep}; +use store::localdb::{GraphQuery, InstanceQuery, StorageProcessor}; +use store::{Graph, GraphStatus, InstanceBridgeInStatus}; use tracing::{info, warn}; -use uuid::Uuid; - -fn bridge_out_retry_jitter_ms(attempt: u32) -> u64 { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.subsec_millis() as u64) - .unwrap_or(0); - let base = 5u64.saturating_mul(1u64 << attempt.min(4)); - base + (now % 25) -} fn graph_tx_index_error( tx_name: GraphBtcTxName, @@ -94,390 +77,14 @@ pub async fn instance_settings( ok_response(InstanceSettingResponse { bridge_in_amount: BRIDGE_IN_AMOUNTS.to_vec() }) } -/// Prepare bridge-in request -/// -/// Validates user-provided data against the allowed bridge-in options returned by -/// [`instance_settings`](routes::v1::INSTANCES_SETTINGS). Clients should first call -/// `instance_settings` to know the supported `bridge_in_amount` list and then submit a -/// bridge-in request using one of those amounts. -/// -/// This function creates or updates a bridge-in instance record with status `UserIniting`. -/// The network configuration is read from the environment variable `ENV_BTC_NETWORK`, not -/// from the request body. -/// -/// # Request Body -/// -/// - `instance_id`: UUID of the bridge-in request (must be a valid UUID format) -/// - `contract_address`: GOAT chain contract address (gateway contract address). Must match -/// the gateway contract address configured in environment variables. -/// - `network`: Target Bitcoin network (e.g. `testnet3`, `mainnet`). Note: This field is -/// present in the request but the actual network is determined from environment configuration. -/// - `from_addr`: Funding Bitcoin address selected by the user (must be a valid BTC address) -/// - `to_addr`: Destination address on GOAT chain that receives bridged assets (must be a valid GOAT address) -/// - `bridge_request_tx_hash`: GOAT chain transaction hash referencing the bridge intent -/// -/// # Validation -/// -/// The function validates: -/// - `contract_address` matches the configured gateway contract address -/// - `from_addr` is a valid Bitcoin address -/// - `to_addr` is a valid GOAT chain address -/// - `instance_id` is a valid UUID format -/// -/// # Returns -/// -/// - `200 OK`: Request is valid and instance record created/updated successfully -/// - `500 Internal Server Error`: Validation failed, contract address mismatch, or database error -/// - Response body is an empty object `{}` -/// -/// # Example -/// -/// ```http -/// PUT /v1/instances/bridge-in-request-tag -/// { -/// "instance_id": "123e4567-e89b-12d3-a456-426614174000", -/// "contract_address": "0xabcdef1234567890abcdef1234567890abcdef12", -/// "from_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", -/// "to_addr": "0x1234567890abcdef1234567890abcdef12345678", -/// "bridge_request_tx_hash": "0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e" -/// } -/// ``` -/// -/// Response example: -/// ```json -/// {} -/// ``` -#[axum::debug_handler] -// TODO(auth): Add caller authorization or rate limiting before exposing this write endpoint publicly. -pub async fn bridge_in_request_tag( - State(app_state): State>, - Json(payload): Json, -) -> ApiResult { - InputValidator::validate_btc_address(&payload.from_addr, None, "from_addr")?; - let to_addr = InputValidator::validate_goat_address(&payload.to_addr, "to_addr")?; - let instance_id = InputValidator::validate_uuid(&payload.instance_id, "instance_id")?; - let contract_address = - InputValidator::validate_goat_address(&payload.contract_address, "contract_address")?; - let gateway_contract: Address = get_goat_address_from_env(ENV_GOAT_GATEWAY_CONTRACT_ADDRESS) - .ok_or(anyhow::anyhow!("need to set swap contract address")) - .api_error("PUT_BRIDGE_IN_REQUEST_TAG_ERROR")?; - if contract_address != gateway_contract.to_string() { - return error_response( - format!( - "Invalid contract address: input: {contract_address}, expect:{gateway_contract}" - ), - format!( - "Invalid contract address: input: {contract_address}, expect:{gateway_contract}" - ), - ); - } - let mut storage_process = - app_state.local_db.acquire().await.api_error("PUT_BRIDGE_IN_REQUEST_TAG_ERROR")?; - let current_time = current_time_secs(); - if let Some(instance) = storage_process - .find_instance(&instance_id) - .await - .api_error("PUT_BRIDGE_IN_REQUEST_TAG_ERROR")? - { - let network = get_network().to_string(); - let has_conflict = !instance.is_bridge_in - || (!instance.network.is_empty() && instance.network != network) - || (!instance.from_addr.is_empty() && instance.from_addr != payload.from_addr) - || (!instance.to_addr.is_empty() && instance.to_addr != to_addr); - if has_conflict { - return error_response( - "BRIDGE_IN_INSTANCE_ID_CONFLICT".to_string(), - format!("instance_id {instance_id} already exists with different request data"), - ); - } - return ok_response(BridgeInPrepareResponse {}); - } - - let instance = Instance { - instance_id, - is_bridge_in: true, - network: get_network().to_string(), - from_addr: payload.from_addr, - to_addr, - input_utxos: "[]".to_string(), - status: InstanceBridgeInStatus::UserIniting.to_string(), - bridge_out_amount: "0".to_string(), - status_updated_at: current_time, - created_at: current_time, - updated_at: current_time, - ..Default::default() - }; - - storage_process - .upsert_instance(&instance) - .await - .api_error("PUT_BRIDGE_IN_REQUEST_TAG_ERROR")?; - ok_response(BridgeInPrepareResponse {}) -} - -/// Initialize bridge-out request -/// -/// Validates user-provided bridge-out data and creates a bridge-out instance record. This is the first step -/// in the bridge-out workflow, used to prepare bridging assets from L2 (GOAT) to L1 (Bitcoin). Clients should -/// provide an escrow hash (`escrow_hash`) to associate with an escrow contract already created on the GOAT chain. -/// -/// If an instance with the same `escrow_hash` already exists, the function updates the existing instance -/// if it is in `Initialize` status. Otherwise, a new instance is created with an auto-generated UUID. -/// The network configuration is read from the environment variable `ENV_BTC_NETWORK`. -/// -/// # Request Body -/// -/// - `contract_address`: GOAT chain contract address (swap contract address). Must match the swap contract -/// address configured in environment variables. -/// - `from_addr`: User's source address on the GOAT chain (must be a valid GOAT address) -/// - `to_addr`: Bitcoin destination address that receives the bridged assets (must be a valid BTC address) -/// - `escrow_hash`: Hash of the escrow contract on the GOAT chain (32-byte hex string), referencing the escrow -/// transaction for the bridge intent. Used to identify existing instances. -/// -/// # Validation -/// -/// The function validates: -/// - `contract_address` matches the configured swap contract address -/// - `from_addr` is a valid GOAT chain address -/// - `to_addr` is a valid Bitcoin address -/// - `escrow_hash` is a valid 32-byte hex string -/// -/// # Returns -/// -/// - `200 OK`: Request is valid and instance record created/updated successfully -/// - `500 Internal Server Error`: Validation failed, contract address mismatch, or database error -/// - Response body is an empty object `{}` -/// -/// # Use Case -/// -/// Frontend applications use this endpoint to initiate the bridge-out flow, recording the user's intent to bridge -/// from L2 to L1 into the system. The endpoint handles both new instance creation and updates to existing instances -/// based on the escrow hash. -/// -/// # Example -/// -/// ```http -/// PUT /v1/instances/bridge-out-init-tag -/// { -/// "contract_address": "0xabcdef1234567890abcdef1234567890abcdef12", -/// "from_addr": "0x1234567890abcdef1234567890abcdef12345678", -/// "to_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", -/// "escrow_hash": "0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e" -/// } -/// ``` -/// -/// Response example: -/// ```json -/// {} -/// ``` -#[axum::debug_handler] -// TODO(auth): Add caller authorization or rate limiting before exposing this write endpoint publicly. -pub async fn bridge_out_init_tag( - State(app_state): State>, - Json(payload): Json, -) -> ApiResult { - const MAX_BRIDGE_OUT_INIT_RETRIES: u32 = 3; - - let from_addr = InputValidator::validate_goat_address(&payload.from_addr, "from_addr")?; - InputValidator::validate_btc_address(&payload.to_addr, None, "network and to_addr")?; - let contract_address = - InputValidator::validate_goat_address(&payload.contract_address, "contract_address")?; - let swap_contract: Address = get_goat_address_from_env(ENV_GOAT_SWAP_CONTRACT_ADDRESS) - .ok_or(anyhow::anyhow!("need to set swap contract address")) - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - if contract_address != swap_contract.to_string() { - return error_response( - format!("Invalid contract address: input: {contract_address}, expect:{swap_contract}"), - format!("Invalid contract address: input: {contract_address}, expect:{swap_contract}"), - ); - } - let escrow_hash = InputValidator::validate_hex(&payload.escrow_hash, true, 32, "escrow_hash")?; - let mut storage_process = - app_state.local_db.acquire().await.api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - let current_time = current_time_secs(); - - for attempt in 0..=MAX_BRIDGE_OUT_INIT_RETRIES { - if let Some(instance) = find_instances_by_escrow_hash(&mut storage_process, &escrow_hash) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")? - { - let updated = storage_process - .update_instance( - &InstanceUpdate::new_with_instance_id(instance.instance_id) - .with_to_addr(payload.to_addr.clone()) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false), - ) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - if !updated { - warn!( - "bridge_out_init_tag ignored for resolved instance {} with status {}", - instance.instance_id, instance.status - ); - } - return ok_response(BridgeOutInitTagResponse {}); - } - - let candidate_instance_id = bridge_out_instance_id_from_escrow_hash(&escrow_hash); - let mut instance = Instance { - instance_id: candidate_instance_id, - from_addr: from_addr.clone(), - network: get_network().to_string(), - input_utxos: "[]".to_string(), - escrow_hash: Some(escrow_hash.clone()), - status: InstanceBridgeOutStatus::Initialize.to_string(), - bridge_out_amount: "0".to_string(), - status_updated_at: current_time, - created_at: current_time, - ..Default::default() - }; - instance.to_addr = payload.to_addr.clone(); - - match storage_process.insert_instance_if_absent(&instance).await { - Ok(true) => return ok_response(BridgeOutInitTagResponse {}), - Ok(false) => { - let Some(existing) = storage_process - .find_instance(&candidate_instance_id) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")? - else { - if attempt < MAX_BRIDGE_OUT_INIT_RETRIES { - sleep(Duration::from_millis(bridge_out_retry_jitter_ms(attempt))).await; - continue; - } - return error_response( - "BRIDGE_OUT_INSTANCE_ID_CONFLICT".to_string(), - "bridge-out instance disappeared while being created".to_string(), - ); - }; - let escrow_hash_matches = existing - .escrow_hash - .as_ref() - .map(|hash| hash.eq_ignore_ascii_case(&escrow_hash)) - .unwrap_or(false); - if existing.is_bridge_in || !escrow_hash_matches { - return error_response( - "BRIDGE_OUT_INSTANCE_ID_CONFLICT".to_string(), - "failed to allocate bridge-out instance_id for escrow_hash".to_string(), - ); - } - - let updated = storage_process - .update_instance( - &InstanceUpdate::new_with_instance_id(existing.instance_id) - .with_to_addr(payload.to_addr.clone()) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false), - ) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - if !updated { - warn!( - "bridge_out_init_tag ignored for concurrently resolved instance {} with status {}", - existing.instance_id, existing.status - ); - } - return ok_response(BridgeOutInitTagResponse {}); - } - Err(err) => { - if attempt < MAX_BRIDGE_OUT_INIT_RETRIES { - warn!( - "bridge_out_init_tag insert failed at attempt {}, retrying: {}", - attempt, err - ); - sleep(Duration::from_millis(bridge_out_retry_jitter_ms(attempt))).await; - continue; - } - return Err(err).api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR"); - } - } - } - - error_response( - "BRIDGE_OUT_INSTANCE_ID_CONFLICT".to_string(), - "failed to initialize bridge-out instance after retries".to_string(), - ) -} - -/// Get instance escrow data -/// -/// Returns escrow hash information for a specified bridge instance. Escrow data is used in the bridge-out workflow, -/// containing information about the escrow contract created on the GOAT chain. This endpoint allows clients to query -/// the escrow hash associated with a specific instance for verification and tracking of bridge status. -/// -/// # Path Parameters -/// -/// - `instance_id`: UUID of the bridge instance to query -/// -/// # Returns -/// -/// - `200 OK`: Successfully returns escrow data information -/// - `500 Internal Server Error`: Server internal error or database operation failed -/// - Response includes instance ID, escrow hash (if present), and optional error information -/// -/// # Use Case -/// -/// Frontend applications use this endpoint to query the escrow hash for a bridge-out instance, to verify escrow -/// contract status and track bridge progress. -/// -/// # Example -/// -/// ```http -/// GET /v1/instances/123e4567-e89b-12d3-a456-426614174000/escrow-data -/// ``` -/// -/// Response example: -/// ```json -/// { -/// "instance_id": "123e4567-e89b-12d3-a456-426614174000", -/// "escrow": "0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e", -/// "error": null -/// } -/// ``` -#[axum::debug_handler] -pub async fn get_instance_escrow_data( - State(app_state): State>, - Path(instance_id): Path, -) -> ApiResult { - let instance_id = InputValidator::validate_uuid(&instance_id, "instance_id")?; - let mut storage_process = - app_state.local_db.acquire().await.api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - match storage_process - .find_graph_goat_tx_record( - &instance_id, - &Uuid::nil(), - &GoatTxType::SwapInitialize.to_string(), - ) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")? - { - Some(tx_record) => ok_response(EscrowDataResponse { - instance_id: instance_id.to_string(), - escrow: tx_record.extra, - error: None, - }), - None => ok_response(EscrowDataResponse { - instance_id: instance_id.to_string(), - escrow: None, - error: Some("no escrow record in db".to_string()), - }), - } -} - /// Get instance list /// -/// Returns a paginated list of bridge instances based on query parameters. Supports filtering by -/// source address and bridge direction (bridge-in or bridge-out). +/// Returns a paginated list of bridge-in instances based on query parameters. Supports filtering by +/// source address. /// /// # Query Parameters /// /// - `from_addr`: Source address filter (optional) - filters instances by source Bitcoin address -/// - `is_bridge_in`: Bridge direction filter (required) - true for bridge-in, false for bridge-out /// - `offset`: Pagination offset (default: 0) - number of items to skip /// - `limit`: Items per page (default: 10) - maximum number of items to return /// @@ -494,7 +101,7 @@ pub async fn get_instance_escrow_data( /// # Example /// /// ```http -/// GET /v1/instances?is_bridge_in=true&offset=0&limit=10 +/// GET /v1/instances?offset=0&limit=10 /// ``` /// /// Response example: @@ -504,7 +111,6 @@ pub async fn get_instance_escrow_data( /// { /// "instance": { /// "instance_id": "123e4567-e89b-12d3-a456-426614174000", -/// "is_bridge_in": true, /// "network": "testnet", /// "from_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", /// "to_addr": "0x1234567890abcdef1234567890abcdef12345678", @@ -524,9 +130,7 @@ pub async fn get_instance_escrow_data( /// "committees_answers": {}, /// "pegin_data_tx_hash": "0x...", /// "parameters": null, -/// "escrow_hash": null, /// "status_updated_at": 1699123456, -/// "bridge_out_lock_time": 0, /// "created_at": 1699123456, /// "updated_at": 1699123456 /// }, @@ -564,19 +168,10 @@ pub async fn get_instances( let mut query = InstanceQuery::default(); if let Some(from_addr) = params.from_addr { - // Validate from_addr format (if provided) - let from_addr = if params.is_bridge_in { - InputValidator::validate_btc_address(&from_addr, None, "bridge in from_addr")?; - from_addr - } else { - InputValidator::validate_goat_address(&from_addr, "Bridge out from_addr")?.to_string() - }; + InputValidator::validate_btc_address(&from_addr, None, "bridge in from_addr")?; query = query.with_from_addr(from_addr); } - query = query - .with_pagination(offset, limit) - .with_order("created_at DESC".to_string()) - .with_is_bridge_in(params.is_bridge_in); + query = query.with_pagination(offset, limit).with_order("created_at DESC".to_string()); let (instances, total) = storage_process.find_instances(query).await.api_error("GET_INSTANCE_ERROR")?; @@ -585,8 +180,6 @@ pub async fn get_instances( warn!("get_instances instance is empty: total {}", total); return ok_response(InstanceListResponse::default()); } - let btc_current_height = - app_state.btc_client.get_height().await.api_error("GET_INSTANCE_ERROR")?; let response_window_blocks = app_state .goat_client .gateway_get_response_window_blocks() @@ -596,7 +189,6 @@ pub async fn get_instances( for instance in instances { let item = InstanceExtended::convert_from_instance( &app_state.btc_client, - btc_current_height, response_window_blocks as i64, instance, ) @@ -640,7 +232,6 @@ pub async fn get_instances( /// "instance_wrap": { /// "instance": { /// "instance_id": "123e4567-e89b-12d3-a456-426614174000", -/// "is_bridge_in": true, /// "network": "testnet", /// "from_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", /// "to_addr": "0x1234567890abcdef1234567890abcdef12345678", @@ -660,8 +251,6 @@ pub async fn get_instances( /// "committees_answers": {}, /// "pegin_data_tx_hash": "0x...", /// "parameters": null, -/// "escrow_hash": null, -/// "bridge_out_lock_time": 0, /// "post_pegin_txhash": "0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e", /// "status_updated_at": 1699123456, /// "created_at": 1699123456, @@ -699,8 +288,6 @@ pub async fn get_instance( if let Some(instance) = storage_process.find_instance(&instance_id_uuid).await.api_error("GET_INSTANCE_ERROR")? { - let btc_current_height = - app_state.btc_client.get_height().await.api_error("GET_INSTANCE_ERROR")?; let response_window_blocks = app_state .goat_client .gateway_get_response_window_blocks() @@ -710,7 +297,6 @@ pub async fn get_instance( let instance_wrap = Some( InstanceExtended::convert_from_instance( &app_state.btc_client, - btc_current_height, response_window_blocks as i64, instance, ) @@ -770,7 +356,7 @@ pub async fn get_instances_overview( app_state.local_db.acquire().await.api_error("INSTANCE_OVERVIEW_ERROR")?; let (bridge_in_sum, bridge_in_count) = storage_process - .get_sum_bridge_txn(true, &[InstanceBridgeInStatus::RelayerL2Minted.to_string()]) + .get_sum_bridge_in_txn(&[InstanceBridgeInStatus::RelayerL2Minted.to_string()]) .await .api_error("INSTANCE_OVERVIEW_ERROR")?; @@ -1627,12 +1213,9 @@ 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 { - // TODO(auth): Replace shared node-key authentication with caller-scoped authorization. - verify_request_auth(&headers)?; let graph_id_uuid = InputValidator::validate_uuid(&graph_id, "graph_id")?; let mut storage_process = @@ -1680,14 +1263,12 @@ pub async fn send_challenge( /// /// This is a test endpoint. Unlike the normal AssertSent verifier flow, it does /// not skip ChallengeAssert when the operator assert proof is valid. +#[cfg(feature = "rpc-debug-endpoints")] #[axum::debug_handler] pub async fn send_verifier_challenge( - headers: HeaderMap, Path(graph_id): Path, State(app_state): State>, ) -> ApiResult { - // TODO(auth): Replace shared node-key authentication with caller-scoped authorization. - verify_request_auth(&headers)?; let graph_id_uuid = InputValidator::validate_uuid(&graph_id, "graph_id")?; let mut storage_process = @@ -1799,12 +1380,9 @@ fn sats_to_token_amount(amount_sats: u64, token_decimals: u8) -> U256 { /// - `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 { - // TODO(auth): Replace shared node-key authentication with caller-scoped authorization. - 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/handler/mod.rs b/node/src/rpc_service/handler/mod.rs index db8d4420e..81da5ae97 100644 --- a/node/src/rpc_service/handler/mod.rs +++ b/node/src/rpc_service/handler/mod.rs @@ -1,10 +1,14 @@ pub mod bitvm_handler; +#[cfg(feature = "rpc-debug-endpoints")] pub mod debug_handler; pub mod node_handler; pub mod proof_handler; +pub mod swap_handler; // Re-export all handler functions for better documentation visibility pub use bitvm_handler::*; +#[cfg(feature = "rpc-debug-endpoints")] pub use debug_handler::*; pub use node_handler::*; pub use proof_handler::*; +pub use swap_handler::*; diff --git a/node/src/rpc_service/handler/swap_handler.rs b/node/src/rpc_service/handler/swap_handler.rs new file mode 100644 index 000000000..d427a1d3f --- /dev/null +++ b/node/src/rpc_service/handler/swap_handler.rs @@ -0,0 +1,106 @@ +use crate::rpc_service::AppState; +use crate::rpc_service::response::{ApiErrorExt, ApiResult, ok_response}; +use crate::rpc_service::swap::*; +use crate::rpc_service::validation::InputValidator; +use axum::extract::{Path, Query, State}; +use std::sync::Arc; +use store::localdb::SwapEscrowQuery; +use store::normalize_escrow_hash; +use tracing::info; + +/// Get swap escrow list +/// +/// Returns a paginated list of swap-based bridge-out escrows, newest first. +/// +/// # Query Parameters +/// +/// - `from_addr`: offerer GOAT address filter (optional) +/// - `offset`: pagination offset (default: 0) +/// - `limit`: items per page (default: 10) +/// +/// # Example +/// +/// ```http +/// GET /v1/swaps?offset=0&limit=10 +/// ``` +#[axum::debug_handler] +pub async fn get_swaps( + Query(params): Query, + State(app_state): State>, +) -> ApiResult { + let (offset, limit) = InputValidator::validate_pagination(params.offset, params.limit)?; + let mut storage_process = app_state.local_db.acquire().await.api_error("GET_SWAPS_ERROR")?; + + let mut query = SwapEscrowQuery::default() + .with_order("created_at DESC".to_string()) + .with_pagination(offset, limit); + if let Some(from_addr) = params.from_addr { + let from_addr = InputValidator::validate_goat_address(&from_addr, "from_addr")?; + query = query.with_offerer_addr(from_addr); + } + + let (escrows, total) = + storage_process.find_swap_escrows(query).await.api_error("GET_SWAPS_ERROR")?; + if escrows.is_empty() { + return ok_response(SwapListResponse::default()); + } + + let btc_current_height = + app_state.btc_client.get_height().await.api_error("GET_SWAPS_ERROR")?; + let mut swaps = Vec::with_capacity(escrows.len()); + for escrow in escrows { + swaps.push( + SwapEscrowExtended::convert_from_swap_escrow( + &app_state.btc_client, + btc_current_height, + escrow, + ) + .await, + ); + } + ok_response(SwapListResponse { swaps, total }) +} + +/// Get swap escrow by escrow hash +/// +/// Returns the full record for one swap escrow, including the hex abi-encoded +/// EscrowData captured from its on-chain Initialize transaction (if the event +/// has been observed). +/// +/// # Path Parameters +/// +/// - `escrow_hash`: 0x-prefixed 32-byte hex escrow hash +/// +/// # Example +/// +/// ```http +/// GET /v1/swaps/0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e +/// ``` +#[axum::debug_handler] +pub async fn get_swap( + Path(escrow_hash): Path, + State(app_state): State>, +) -> ApiResult { + let escrow_hash = normalize_escrow_hash(&InputValidator::validate_hex( + &escrow_hash, + true, + 32, + "escrow_hash", + )?); + let mut storage_process = app_state.local_db.acquire().await.api_error("GET_SWAP_ERROR")?; + + let Some(escrow) = + storage_process.find_swap_escrow(&escrow_hash).await.api_error("GET_SWAP_ERROR")? + else { + info!("swap escrow {escrow_hash} has no record in database"); + return ok_response(SwapGetResponse { swap: None }); + }; + let btc_current_height = app_state.btc_client.get_height().await.api_error("GET_SWAP_ERROR")?; + let swap = SwapEscrowExtended::convert_from_swap_escrow( + &app_state.btc_client, + btc_current_height, + escrow, + ) + .await; + ok_response(SwapGetResponse { swap: Some(swap) }) +} diff --git a/node/src/rpc_service/mod.rs b/node/src/rpc_service/mod.rs index 2387b379f..a16185ef0 100644 --- a/node/src/rpc_service/mod.rs +++ b/node/src/rpc_service/mod.rs @@ -5,25 +5,29 @@ pub mod handler; mod node; mod response; pub mod routes; +mod swap; pub(super) mod utils; pub mod validation; use crate::env::{get_btc_url_from_env, get_goat_network, get_network, goat_config_from_env}; use crate::metrics_service::{MetricsState, metrics_handler, metrics_middleware}; +use crate::rpc_service::auth::require_request_auth; use crate::rpc_service::cors_config::CorsConfig; use crate::rpc_service::handler::{ - bridge_in_request_tag, bridge_out_init_tag, get_chain_proof_desc, get_debug_message_details, - get_debug_status, get_graph, get_graph_debug_messages, get_graph_neighbor_ids, get_graph_tx, - get_graph_txn, get_graphs, get_instance, get_instance_debug_messages, 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, - pegout, send_challenge, send_verifier_challenge, + get_chain_proof_desc, get_graph, get_graph_neighbor_ids, get_graph_tx, get_graph_txn, + get_graphs, get_instance, get_instances, get_instances_overview, get_node, get_nodes, + get_nodes_overview, get_operator_proof_desc, get_ready_to_kickoff_graph, get_swap, get_swaps, + get_unsigned_pegin_txn, instance_settings, pegout, send_challenge, +}; +#[cfg(feature = "rpc-debug-endpoints")] +use crate::rpc_service::handler::{ + get_debug_message_details, get_debug_status, get_graph_debug_messages, + get_instance_debug_messages, send_verifier_challenge, }; use anyhow::Context; use axum::body::Body; use axum::extract::Request; use axum::response::Response; -use axum::routing::put; use axum::{ Router, middleware, routing::{get, post}, @@ -32,7 +36,8 @@ use bitvm_lib::actors::Actor; use client::btc_chain::BTCClient; use client::goat_chain::GOATClient; use client::http_client::async_client::HttpAsyncClient; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use std::time::{Duration, UNIX_EPOCH}; use store::localdb::LocalDB; use tokio::net::TcpListener; @@ -67,6 +72,7 @@ pub struct AppState { pub metrics_state: MetricsState, pub actor: Actor, pub peer_id: String, + rpc_auth_nonces: Mutex>, } impl AppState { @@ -87,6 +93,7 @@ impl AppState { actor, peer_id, http_client, + rpc_auth_nonces: Mutex::new(HashMap::new()), })) } @@ -111,6 +118,7 @@ impl AppState { actor, peer_id, http_client: HttpAsyncClient::new(None), + rpc_auth_nonces: Mutex::new(HashMap::new()), })) } } @@ -135,42 +143,56 @@ async fn root() -> &'static str { "Hello, World!" } -pub async fn serve_with_app_state( - addr: String, - app_state: Arc, - cancellation_token: CancellationToken, -) -> anyhow::Result { - let node_span = tracing::Span::current(); - let server = Router::new() +pub(crate) fn build_business_router(app_state: Arc) -> Router { + let public_routes = Router::new() .route(routes::ROOT, get(root)) .route(routes::v1::NODES_BASE, get(get_nodes)) .route(routes::v1::NODES_BY_ID, get(get_node)) .route(routes::v1::NODES_OVERVIEW, get(get_nodes_overview)) .route(routes::v1::INSTANCES_SETTINGS, get(instance_settings)) - .route(routes::v1::INSTANCES_BRIDGE_IN_REQUEST_TAG, put(bridge_in_request_tag)) - .route(routes::v1::INSTANCES_BRIDGE_OUT_INIT_TAG, put(bridge_out_init_tag)) .route(routes::v1::INSTANCES_BASE, get(get_instances)) .route(routes::v1::INSTANCES_BY_ID, get(get_instance)) .route(routes::v1::INSTANCES_OVERVIEW, get(get_instances_overview)) .route(routes::v1::INSTANCES_UNSIGNED_PEGIN_TXN, get(get_unsigned_pegin_txn)) - .route(routes::v1::INSTANCES_ESCROW_DATA, get(get_instance_escrow_data)) + .route(routes::v1::SWAPS_BASE, get(get_swaps)) + .route(routes::v1::SWAPS_BY_ESCROW_HASH, get(get_swap)) .route(routes::v1::GRAPHS_BY_ID, get(get_graph)) .route(routes::v1::GRAPHS_BASE, get(get_graphs)) .route(routes::v1::GRAPHS_READY_TO_KICKOFF, get(get_ready_to_kickoff_graph)) .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)) - // TODO(auth): Restrict debug endpoints before exposing the RPC outside trusted operators. + .route(routes::v1::PROOFS_CHAIN_PROOFS_DESC, get(get_chain_proof_desc)) + .route(routes::v1::PROOFS_OPERATOR_PROOF_DESC, get(get_operator_proof_desc)); + + #[cfg(feature = "rpc-debug-endpoints")] + let public_routes = public_routes .route(routes::v1::DEBUG_STATUS, get(get_debug_status)) .route(routes::v1::DEBUG_GRAPH_MESSAGES, get(get_graph_debug_messages)) .route(routes::v1::DEBUG_INSTANCE_MESSAGES, get(get_instance_debug_messages)) - .route(routes::v1::DEBUG_MESSAGE_DETAILS, get(get_debug_message_details)) + .route(routes::v1::DEBUG_MESSAGE_DETAILS, get(get_debug_message_details)); + + let signed_routes = Router::new() .route(routes::v1::GRAPHS_SEND_CHALLENGE, post(send_challenge)) - .route(routes::v1::GRAPHS_SEND_VERIFIER_CHALLENGE, post(send_verifier_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)) - .layer(create_secure_cors_layer()) + .route(routes::v1::PEGOUT, post(pegout)); + + #[cfg(feature = "rpc-debug-endpoints")] + let signed_routes = signed_routes + .route(routes::v1::GRAPHS_SEND_VERIFIER_CHALLENGE, post(send_verifier_challenge)); + + let signed_routes = signed_routes + .route_layer(middleware::from_fn_with_state(app_state.clone(), require_request_auth)); + + public_routes.merge(signed_routes).layer(create_secure_cors_layer()).with_state(app_state) +} + +pub async fn serve_with_app_state( + addr: String, + app_state: Arc, + cancellation_token: CancellationToken, +) -> anyhow::Result { + let node_span = tracing::Span::current(); + let server = build_business_router(app_state.clone()) .layer( TraceLayer::new_for_http() .make_span_with(move |request: &Request| { @@ -212,9 +234,7 @@ pub async fn serve_with_app_state( }, ), ) - .layer(middleware::from_fn_with_state(app_state.clone(), metrics_middleware)) - .route(routes::METRICS, get(metrics_handler)) - .with_state(app_state); + .layer(middleware::from_fn_with_state(app_state, metrics_middleware)); let listener = TcpListener::bind(&addr) .await @@ -256,12 +276,86 @@ pub async fn serve( serve_with_app_state(addr, app_state, cancellation_token).await } +/// Validates the path the dedicated metrics listener will expose. +/// +/// Rejects anything `axum` cannot register as a literal route, so a bad +/// `--metrics-path` fails at startup instead of panicking inside the router. +pub fn validate_metrics_path(path: &str) -> anyhow::Result<()> { + if !path.starts_with('/') { + anyhow::bail!("metrics path must start with '/', got {path:?}"); + } + if path.contains(['?', '#', '{', '}', '*', ':']) { + anyhow::bail!( + "metrics path must not contain query, fragment or route parameter characters, got {path:?}" + ); + } + if path.chars().any(char::is_whitespace) { + anyhow::bail!("metrics path must not contain whitespace, got {path:?}"); + } + Ok(()) +} + +/// Binds the dedicated Prometheus metrics listener. +/// +/// Binding happens before the node spawns its background tasks so a misconfigured +/// or already used metrics port aborts startup with an explicit error. +pub async fn bind_metrics_listener(addr: &str) -> anyhow::Result { + TcpListener::bind(addr) + .await + .with_context(|| format!("failed to bind metrics listener to {addr}")) +} + +/// Serves the metrics-only router on an already bound listener. +/// +/// The router exposes `metrics_path` and nothing else: no business or debug +/// routes, no CORS and no request metrics middleware, so scrapes never show up +/// in the HTTP request metrics they read. +pub async fn serve_metrics( + listener: TcpListener, + metrics_path: String, + app_state: Arc, + cancellation_token: CancellationToken, +) -> anyhow::Result { + validate_metrics_path(&metrics_path)?; + let router = + Router::new().route(metrics_path.as_str(), get(metrics_handler)).with_state(app_state); + + let listening_addr = + listener.local_addr().context("failed to determine metrics listener address")?; + tracing::info!( + event = "metrics_listening", + address = %listening_addr, + path = %metrics_path, + "metrics listener started" + ); + + tokio::select! { + result = axum::serve(listener, router) => { + match result { + Ok(_) => Ok("metrics server finished normally".to_string()), + Err(e) => { + tracing::error!("metrics server error: {}", e); + Err(anyhow::anyhow!("metrics server error: {e}")) + } + } + } + _ = cancellation_token.cancelled() => { + tracing::info!("metrics service received shutdown signal"); + Ok("metrics_shutdown".to_string()) + } + } +} + #[cfg(test)] mod tests { use crate::env::{ - ENV_GOAT_CHAIN_URL, ENV_GOAT_GATEWAY_CONTRACT_ADDRESS, ENV_PROOF_SEVER_URL, get_network, + ENV_BITVM_SECRET, ENV_GOAT_CHAIN_URL, ENV_GOAT_GATEWAY_CONTRACT_ADDRESS, + ENV_PROOF_SEVER_URL, get_network, }; use crate::metrics_service::MetricsState; + use crate::rpc_service::auth::{ + AUTH_NONCE_HEADER, AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, sign_request_auth, + }; use crate::rpc_service::bitvm::{ BRIDGE_IN_AMOUNTS, GraphGetResponse, GraphListResponse, InstanceGetResponse, InstanceListResponse, InstanceOverviewResponse, InstanceSettingResponse, @@ -277,8 +371,8 @@ mod tests { use http::Method; use prometheus_client::registry::Registry; use reqwest::Client; - use secp256k1::Secp256k1; - use serde_json::{Value, json}; + use secp256k1::{Keypair, SECP256K1, Secp256k1}; + use serde_json::Value; use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -383,52 +477,252 @@ mod tests { listener.local_addr().unwrap().to_string() } - #[tokio::test(flavor = "multi_thread")] - async fn metrics_use_route_templates_and_exclude_scrapes() - -> Result<(), Box> { - let addr = available_addr(); + async fn spawn_metrics_listener( + app_state: Arc, + metrics_path: &str, + cancellation_token: CancellationToken, + ) -> anyhow::Result<(String, tokio::task::JoinHandle>)> { + let listener = rpc_service::bind_metrics_listener("127.0.0.1:0").await?; + let addr = listener.local_addr()?.to_string(); + let metrics_path = metrics_path.to_string(); + let handle = tokio::spawn(rpc_service::serve_metrics( + listener, + metrics_path, + app_state, + cancellation_token, + )); + Ok((addr, handle)) + } + + async fn mock_app_state() -> anyhow::Result> { let local_db = create_local_db(&temp_sqlite_db_path()).await; let metrics_state = MetricsState::new(Arc::new(Mutex::new(Registry::default()))); - let app_state = rpc_service::AppState::create_arc_mock_app_state( + rpc_service::AppState::create_arc_mock_app_state( local_db, Actor::Verifier, generate_local_key().public().to_peer_id().to_string(), metrics_state, ) - .await?; + .await + } + + fn set_test_auth_key() -> Keypair { + let keypair = Keypair::from_seckey_slice(SECP256K1, &[0x42; 32]).unwrap(); + unsafe { + std::env::set_var(ENV_BITVM_SECRET, hex::encode(keypair.secret_key().secret_bytes())) + }; + keypair + } + + async fn spawn_business_listener( + cancellation_token: CancellationToken, + ) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> { + let app_state = mock_app_state().await?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?.to_string(); + let server = rpc_service::build_business_router(app_state); + let handle = tokio::spawn(async move { + let _ = tokio::select! { + result = axum::serve(listener, server) => result, + _ = cancellation_token.cancelled() => Ok(()), + }; + }); + Ok((addr, handle)) + } + + #[tokio::test(flavor = "multi_thread")] + async fn signed_request_nonce_is_rejected_after_first_use() + -> Result<(), Box> { + let keypair = set_test_auth_key(); + let cancellation_token = CancellationToken::new(); + let (addr, server) = spawn_business_listener(cancellation_token.clone()).await?; + let graph_id = Uuid::new_v4(); + let request_target = format!("/v1/graphs/{graph_id}/send-challenge"); + let url = format!("http://{addr}{request_target}"); + let (timestamp, nonce, signature) = + sign_request_auth(&keypair, &Method::POST, &request_target, &[]); + let client = Client::new(); + + let first = client + .post(&url) + .header(AUTH_TIMESTAMP_HEADER, ×tamp) + .header(AUTH_NONCE_HEADER, &nonce) + .header(AUTH_SIGNATURE_HEADER, &signature) + .send() + .await?; + assert_eq!(first.status().as_u16(), 500); + + let replay = client + .post(&url) + .header(AUTH_TIMESTAMP_HEADER, ×tamp) + .header(AUTH_NONCE_HEADER, &nonce) + .header(AUTH_SIGNATURE_HEADER, &signature) + .send() + .await?; + assert_eq!(replay.status().as_u16(), 409); + let replay_body: Value = replay.json().await?; + assert_eq!(replay_body["error"], "AUTH_REPLAY"); + + cancellation_token.cancel(); + server.await?; + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn debug_routes_follow_rpc_debug_endpoints_feature() + -> Result<(), Box> { + let cancellation_token = CancellationToken::new(); + let (addr, server) = spawn_business_listener(cancellation_token.clone()).await?; + let client = Client::new(); + let id = Uuid::new_v4(); + + let status = client.get(format!("http://{addr}/v1/debug/status")).send().await?; + let graph_messages = + client.get(format!("http://{addr}/v1/debug/graphs/{id}/messages")).send().await?; + let instance_messages = + client.get(format!("http://{addr}/v1/debug/instances/{id}/messages")).send().await?; + let message_details = + client.get(format!("http://{addr}/v1/debug/messages/missing")).send().await?; + let verifier_challenge = client + .post(format!("http://{addr}/v1/graphs/{id}/send-verifier-challenge")) + .send() + .await?; + + if cfg!(feature = "rpc-debug-endpoints") { + assert_eq!(status.status().as_u16(), 200); + assert_eq!(graph_messages.status().as_u16(), 200); + assert_eq!(instance_messages.status().as_u16(), 200); + assert_eq!(message_details.status().as_u16(), 404); + let message_body: Value = message_details.json().await?; + assert_eq!(message_body["error"], "DEBUG_MESSAGE_NOT_FOUND"); + assert_eq!(verifier_challenge.status().as_u16(), 401); + } else { + assert_eq!(status.status().as_u16(), 404); + assert_eq!(graph_messages.status().as_u16(), 404); + assert_eq!(instance_messages.status().as_u16(), 404); + assert_eq!(message_details.status().as_u16(), 404); + assert_eq!(verifier_challenge.status().as_u16(), 404); + } + + cancellation_token.cancel(); + server.await?; + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn metrics_are_served_only_by_the_dedicated_listener() + -> Result<(), Box> { + let addr = available_addr(); + let app_state = mock_app_state().await?; let cancellation_token = CancellationToken::new(); - let server_token = cancellation_token.clone(); - let server = - tokio::spawn(rpc_service::serve_with_app_state(addr.clone(), app_state, server_token)); + let server = tokio::spawn(rpc_service::serve_with_app_state( + addr.clone(), + app_state.clone(), + cancellation_token.clone(), + )); + let (metrics_addr, metrics_server) = + spawn_metrics_listener(app_state, "/metrics", cancellation_token.clone()).await?; sleep(Duration::from_millis(100)).await; let client = Client::new(); client.get(format!("http://{addr}/")).send().await?.error_for_status()?; let unmatched = client.get(format!("http://{addr}/missing")).send().await?; assert_eq!(unmatched.status().as_u16(), 404); - let first_scrape = - client.get(format!("http://{addr}/metrics")).send().await?.text().await?; + + // The business listener no longer exposes metrics at all. + let business_metrics = client.get(format!("http://{addr}/metrics")).send().await?; + assert_eq!(business_metrics.status().as_u16(), 404); + + // The metrics listener exposes nothing but the metrics path. + for path in ["/", "/v1/nodes", "/v1/debug/status"] { + let response = client.get(format!("http://{metrics_addr}{path}")).send().await?; + assert_eq!( + response.status().as_u16(), + 404, + "unexpected route on metrics listener: {path}" + ); + } + + let first = client.get(format!("http://{metrics_addr}/metrics")).send().await?; + assert_eq!(first.status().as_u16(), 200); + assert_eq!( + first.headers().get(http::header::CONTENT_TYPE).unwrap(), + "application/openmetrics-text;charset=utf-8;version=1.0.0" + ); + let first_scrape = first.text().await?; let second_scrape = - client.get(format!("http://{addr}/metrics")).send().await?.text().await?; + client.get(format!("http://{metrics_addr}/metrics")).send().await?.text().await?; assert!( first_scrape .contains("http_requests_total{method=\"GET\",route=\"/\",status=\"200\"} 1") ); + // `/missing` and the rejected `/metrics` request on the business listener. assert!( first_scrape.contains( - "http_requests_total{method=\"GET\",route=\"unmatched\",status=\"404\"} 1" + "http_requests_total{method=\"GET\",route=\"unmatched\",status=\"404\"} 2" ) ); assert!(first_scrape.contains("http_requests_in_flight 0")); + // Requests served by the metrics listener are not counted at all. assert!(!first_scrape.contains("route=\"/metrics\"")); assert_eq!(first_scrape, second_scrape); cancellation_token.cancel(); server.await??; + assert_eq!(metrics_server.await??, "metrics_shutdown"); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn metrics_listener_honors_custom_path() -> Result<(), Box> { + let app_state = mock_app_state().await?; + let cancellation_token = CancellationToken::new(); + let (metrics_addr, metrics_server) = + spawn_metrics_listener(app_state, "/internal/metrics", cancellation_token.clone()) + .await?; + sleep(Duration::from_millis(100)).await; + + let client = Client::new(); + let response = client.get(format!("http://{metrics_addr}/internal/metrics")).send().await?; + assert_eq!(response.status().as_u16(), 200); + assert!(response.text().await?.contains("http_requests_in_flight")); + let default_path = client.get(format!("http://{metrics_addr}/metrics")).send().await?; + assert_eq!(default_path.status().as_u16(), 404); + + cancellation_token.cancel(); + metrics_server.await??; Ok(()) } + #[tokio::test] + async fn metrics_listener_reports_bind_conflicts() -> Result<(), Box> { + let occupied = std::net::TcpListener::bind("127.0.0.1:0")?; + let addr = occupied.local_addr()?.to_string(); + let error = rpc_service::bind_metrics_listener(&addr).await.unwrap_err(); + assert!( + error.to_string().contains(&format!("failed to bind metrics listener to {addr}")), + "unexpected error: {error}" + ); + Ok(()) + } + + #[test] + fn metrics_path_validation_rejects_unusable_paths() { + for path in ["/metrics", "/", "/internal/metrics"] { + assert!( + rpc_service::validate_metrics_path(path).is_ok(), + "rejected valid path: {path}" + ); + } + for path in ["metrics", "", "/metrics?scrape=1", "/metrics#frag", "/{id}", "/met rics"] { + assert!( + rpc_service::validate_metrics_path(path).is_err(), + "accepted invalid path: {path}" + ); + } + } + async fn init_nodes_data(local_db: &LocalDB, nodes: &[Node]) -> anyhow::Result<()> { let mut tx = local_db.start_transaction().await?; for node in nodes { @@ -607,7 +901,6 @@ mod tests { let mut graphs = Vec::::new(); instances.push(Instance { instance_id: bridge_in_instance_id, - is_bridge_in: true, network: get_network().to_string(), from_addr: bridge_in_from.clone(), to_addr: bridge_in_to.clone(), @@ -627,10 +920,7 @@ mod tests { committees_answers: Default::default(), pegin_data_tx_hash: format!("0x{}", hex::encode(generate_random_bytes(32))), parameters: None, - escrow_hash: None, - bridge_out_lock_time: 0, post_pegin_txhash: None, - bridge_out_amount: "0".to_string(), status_updated_at: current_time_secs(), created_at: current_time_secs(), updated_at: current_time_secs(), @@ -638,7 +928,6 @@ mod tests { instances.push(Instance { instance_id: Uuid::new_v4(), - is_bridge_in: true, network: get_network().to_string(), from_addr: bridge_in_from.clone(), to_addr: bridge_in_to.clone(), @@ -658,11 +947,8 @@ mod tests { committees_answers: Default::default(), pegin_data_tx_hash: format!("0x{}", hex::encode(generate_random_bytes(32))), parameters: None, - escrow_hash: None, - bridge_out_lock_time: 0, post_pegin_txhash: None, status_updated_at: current_time_secs(), - bridge_out_amount: "0".to_string(), created_at: current_time_secs(), updated_at: current_time_secs(), }); @@ -757,8 +1043,6 @@ mod tests { )); sleep(Duration::from_secs(3)).await; - let bridge_in_request_tag_id = Uuid::new_v4(); - let target_instance_id = bridge_in_instance_id; let api_test_items = vec![ ApiTestItem { @@ -774,32 +1058,6 @@ mod tests { ) })), }, - ApiTestItem { - tag: routes::v1::INSTANCES_BRIDGE_IN_REQUEST_TAG.to_string(), - url: format!("http://{addr}{}", routes::v1::INSTANCES_BRIDGE_IN_REQUEST_TAG), - json_payload: Some(json!({ - "instance_id": bridge_in_request_tag_id, - "contract_address": "0x21f619040AC2eAcacEF8Fe17Ae8bDF53ec69C66f", - "bridge_request_tx_hash": format!("0x{}", hex::encode(generate_random_bytes(32))), - "from_addr": get_rand_btc_address_p2wpkh(get_network()), - "to_addr": format!("0x{}", hex::encode(generate_random_bytes(20))) - })), - method: Method::PUT, - expe_res: true, - resp_validation: None, - }, - ApiTestItem { - tag: format!("{} for bridge in request tag", routes::v1::INSTANCES_BY_ID), - url: format!( - "http://{addr}{}/{}", - routes::v1::INSTANCES_BASE, - bridge_in_request_tag_id - ), - json_payload: None, - method: Method::GET, - expe_res: true, - resp_validation: None, - }, ApiTestItem { tag: routes::v1::INSTANCES_BY_ID.to_string(), url: format!( @@ -824,7 +1082,7 @@ mod tests { ApiTestItem { tag: format!("{} get instances", routes::v1::INSTANCES_BASE), url: format!( - "http://{addr}{}?is_bridge_in=true&from_addr={}", + "http://{addr}{}?from_addr={}", routes::v1::INSTANCES_BASE, bridge_in_from, ), diff --git a/node/src/rpc_service/routes.rs b/node/src/rpc_service/routes.rs index 62d75ad97..61e8fe180 100644 --- a/node/src/rpc_service/routes.rs +++ b/node/src/rpc_service/routes.rs @@ -1,5 +1,4 @@ pub(crate) const ROOT: &str = "/"; -pub(crate) const METRICS: &str = "/metrics"; pub(crate) mod v1 { pub const NODES_BASE: &str = "/v1/nodes"; @@ -8,32 +7,34 @@ pub(crate) mod v1 { pub const INSTANCES_BASE: &str = "/v1/instances"; pub const INSTANCES_SETTINGS: &str = "/v1/instances/settings"; - pub const INSTANCES_BRIDGE_IN_REQUEST_TAG: &str = "/v1/instances/bridge-in-request-tag"; - pub const INSTANCES_BRIDGE_OUT_INIT_TAG: &str = "/v1/instances/bridge-out-init-tag"; pub const INSTANCES_BY_ID: &str = "/v1/instances/{:id}"; pub const INSTANCES_OVERVIEW: &str = "/v1/instances/overview"; // TODO(auth): Restrict access before returning locally constructed cancellation PSBTs. pub const INSTANCES_UNSIGNED_PEGIN_TXN: &str = "/v1/instances/{:id}/unsigned-pegin-txn"; - pub const INSTANCES_ESCROW_DATA: &str = "/v1/instances/{:id}/escrow-data"; + + pub const SWAPS_BASE: &str = "/v1/swaps"; + pub const SWAPS_BY_ESCROW_HASH: &str = "/v1/swaps/{:escrow_hash}"; pub const GRAPHS_BASE: &str = "/v1/graphs"; pub const GRAPHS_BY_ID: &str = "/v1/graphs/{:id}"; pub const GRAPHS_READY_TO_KICKOFF: &str = "/v1/graphs/ready-to-kickoff"; 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"; - // TODO(auth): Restrict this transaction-broadcasting endpoint to authorized operators. pub const GRAPHS_SEND_CHALLENGE: &str = "/v1/graphs/{:id}/send-challenge"; - // TODO(auth): Restrict this test transaction-broadcasting endpoint to authorized operators. + #[cfg(feature = "rpc-debug-endpoints")] pub const GRAPHS_SEND_VERIFIER_CHALLENGE: &str = "/v1/graphs/{:id}/send-verifier-challenge"; - // TODO(auth): Restrict this transaction-broadcasting endpoint to authorized operators. pub const PEGOUT: &str = "/v1/graphs/pegout"; // TODO(auth): Restrict debug endpoints before exposing the RPC outside trusted operators. + #[cfg(feature = "rpc-debug-endpoints")] pub const DEBUG_STATUS: &str = "/v1/debug/status"; // TODO(auth): Restrict debug endpoints before exposing the RPC outside trusted operators. + #[cfg(feature = "rpc-debug-endpoints")] pub const DEBUG_GRAPH_MESSAGES: &str = "/v1/debug/graphs/{:id}/messages"; // TODO(auth): Restrict debug endpoints before exposing the RPC outside trusted operators. + #[cfg(feature = "rpc-debug-endpoints")] pub const DEBUG_INSTANCE_MESSAGES: &str = "/v1/debug/instances/{:id}/messages"; // TODO(auth): Restrict debug endpoints before exposing the RPC outside trusted operators. + #[cfg(feature = "rpc-debug-endpoints")] pub const DEBUG_MESSAGE_DETAILS: &str = "/v1/debug/messages/{:id}"; // pub const PROOFS_BASE: &str = "/v1/proofs"; pub const PROOFS_CHAIN_PROOFS_DESC: &str = "/v1/proofs/chain_proofs_desc"; diff --git a/node/src/rpc_service/swap.rs b/node/src/rpc_service/swap.rs new file mode 100644 index 000000000..c00b9789c --- /dev/null +++ b/node/src/rpc_service/swap.rs @@ -0,0 +1,139 @@ +use super::utils::{deserialize_u256, serialize_u256}; +use crate::rpc_service::bitvm::{StatusExtra, StatusUserAction}; +use crate::rpc_service::current_time_secs; +use alloy::primitives::U256; +use client::btc_chain::BTCClient; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use store::{SerializableTxid, SwapEscrow, SwapEscrowStatus}; + +const SWAP_CLAIM_BTC_TARGET_CONFIRMATIONS: u32 = 6; +const SWAP_TIMEOUT_ERROR: &str = "The operation timed out. Please try again."; + +#[derive(Debug, Deserialize)] +pub struct SwapListRequest { + pub from_addr: Option, // offerer goat address + pub offset: Option, + pub limit: Option, +} + +#[derive(Deserialize, Serialize, Default)] +pub struct SwapEscrowDisplay { + pub escrow_hash: String, + pub network: String, + pub status: String, + pub from_addr: String, // offerer goat address + pub to_addr: String, // btc payout address + pub claimer_addr: String, // goat address entitled to claim + pub token: String, + #[serde(serialize_with = "serialize_u256", deserialize_with = "deserialize_u256")] + pub amount: U256, + pub refund_deadline: i64, + pub init_tx_hash: String, + pub init_tx_height: i64, + pub claim_tx_hash: String, + pub claim_btc_txid: Option, + pub refund_tx_hash: String, + pub status_updated_at: i64, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for SwapEscrowDisplay { + fn from(escrow: SwapEscrow) -> Self { + Self { + escrow_hash: escrow.escrow_hash, + network: escrow.network, + status: escrow.status, + from_addr: escrow.offerer_addr, + to_addr: escrow.btc_addr, + claimer_addr: escrow.claimer_addr, + token: escrow.token, + amount: U256::from_str(&escrow.amount).unwrap_or_default(), + refund_deadline: escrow.refund_deadline, + init_tx_hash: escrow.init_tx_hash, + init_tx_height: escrow.init_tx_height, + claim_tx_hash: escrow.claim_tx_hash, + claim_btc_txid: escrow.claim_btc_txid, + refund_tx_hash: escrow.refund_tx_hash, + status_updated_at: escrow.status_updated_at, + created_at: escrow.created_at, + updated_at: escrow.updated_at, + } + } +} + +#[derive(Deserialize, Serialize, Default)] +pub struct SwapEscrowExtended { + pub swap: SwapEscrowDisplay, + /// Hex abi-encoded EscrowData captured from the Initialize tx, if seen. + pub escrow_data: Option, + pub waiting_time_in_secs: i64, + pub confirmations: u32, + pub target_confirmations: u32, + pub status_extra: StatusExtra, +} + +impl SwapEscrowExtended { + pub async fn convert_from_swap_escrow( + btc_client: &BTCClient, + btc_current_height: u32, + mut escrow: SwapEscrow, + ) -> Self { + let (confirmations, target_confirmations) = + get_claim_btc_confirm_progress(btc_client, btc_current_height, &escrow.claim_btc_txid) + .await; + Self { + waiting_time_in_secs: get_swap_waiting_time(&escrow.status, escrow.refund_deadline), + confirmations, + target_confirmations, + status_extra: get_swap_status_extra(&escrow.status), + escrow_data: escrow.escrow_data.take(), + swap: escrow.into(), + } + } +} + +/// Seconds until the refund deadline while the escrow is still initializing. +fn get_swap_waiting_time(status: &str, refund_deadline: i64) -> i64 { + match SwapEscrowStatus::from_str(status) { + Ok(SwapEscrowStatus::Initialize) => (refund_deadline - current_time_secs()).max(0), + _ => 0, + } +} + +fn get_swap_status_extra(status: &str) -> StatusExtra { + let mut status_extra = StatusExtra::default(); + if matches!(SwapEscrowStatus::from_str(status), Ok(SwapEscrowStatus::Timeout)) { + status_extra.is_failed = true; + status_extra.error = Some(SWAP_TIMEOUT_ERROR.to_string()); + status_extra.user_action = StatusUserAction::Refund; + } + status_extra +} + +async fn get_claim_btc_confirm_progress( + btc_client: &BTCClient, + current_height: u32, + claim_btc_txid: &Option, +) -> (u32, u32) { + if let Some(txid) = claim_btc_txid + && let Ok(tx_status) = btc_client.get_tx_status(&txid.0).await + && let Some(height) = tx_status.block_height + { + (current_height + 1 - height, SWAP_CLAIM_BTC_TARGET_CONFIRMATIONS) + } else { + (0, SWAP_CLAIM_BTC_TARGET_CONFIRMATIONS) + } +} + +#[derive(Deserialize, Serialize, Default)] +pub struct SwapListResponse { + pub swaps: Vec, + pub total: i64, +} + +#[derive(Deserialize, Serialize)] +pub struct SwapGetResponse { + pub swap: Option, +} diff --git a/node/src/scheduled_tasks/event_watch_task.rs b/node/src/scheduled_tasks/event_watch_task.rs index e631c2689..2fd39bc98 100644 --- a/node/src/scheduled_tasks/event_watch_task.rs +++ b/node/src/scheduled_tasks/event_watch_task.rs @@ -9,15 +9,14 @@ use crate::env::{ use crate::metrics_service::{EventWatchState, MetricsState}; use crate::rpc_service::current_time_secs; use crate::scheduled_tasks::get_timestamp_from_contract_data; -use crate::utils::evm_swap_utils::IEscrowManager::EscrowData; use crate::utils::evm_swap_utils::{extract_claim_data_from_tx, extract_escrow_data_from_tx}; use crate::utils::{ - GenerateInstanceParams, bridge_out_instance_id_from_escrow_hash, find_instances_by_escrow_hash, - generate_instance, get_bridge_out_global_stats, obsolete_instance_graphs_except, - outpoint_available, reflect_goat_address, strip_hex_prefix_owned, + GenerateInstanceParams, generate_instance, get_bridge_out_global_stats, + obsolete_instance_graphs_except, outpoint_available, reflect_goat_address, + strip_hex_prefix_owned, }; use alloy::primitives::{Address as EvmAddress, U256}; -use alloy::sol_types::{SolType, SolValue}; +use alloy::sol_types::SolValue; use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, OutPoint, Txid}; @@ -41,11 +40,13 @@ use std::ops::AddAssign; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; -use store::localdb::{GraphRuntimeUpdate, InstanceUpdate, LocalDB, NodeQuery, StorageProcessor}; +use store::localdb::{ + GraphRuntimeUpdate, InstanceUpdate, LocalDB, NodeQuery, StorageProcessor, SwapEscrowUpdate, +}; use store::{ GoatTxProcessingStatus, GoatTxRecord, GoatTxType, GraphStatus, GraphStatusSource, - GraphStatusTransitionOutcome, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, - MessageState, WatchContract, WatchContractStatus, + GraphStatusTransitionOutcome, Instance, InstanceBridgeInStatus, MessageState, SwapEscrow, + SwapEscrowStatus, WatchContract, WatchContractStatus, normalize_escrow_hash, }; use tokio::time::sleep; use tokio_util::sync::CancellationToken; @@ -785,7 +786,7 @@ async fn handle_swap_init_events<'a>( init_events: Vec, ) -> anyhow::Result<()> { for event in init_events { - if let Some(escrow_data) = extract_escrow_data_from_tx( + let Some(escrow_data) = extract_escrow_data_from_tx( &goat_client, &event.transaction_hash, swap_contract_address, @@ -794,152 +795,59 @@ async fn handle_swap_init_events<'a>( )?, ) .await? - { - let create_time = event.block_timestamp.parse::()?; - let event_height = event.block_number.parse::()?; - let (instance_id, initialized) = if let Some(instance) = - find_instances_by_escrow_hash(storage_processor, &event.escrow_hash).await? - { - let initialized = storage_processor - .update_instance( - &swap_init_instance_update( - instance.instance_id, - &escrow_data, - &event, - event_height, - ) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false) - .with_only_if_goat_tx_hash(String::new()), - ) - .await?; - (instance.instance_id, initialized) - } else { - let instance_id = bridge_out_instance_id_from_escrow_hash(&event.escrow_hash); - let instance = Instance { - instance_id, - is_bridge_in: false, - network: get_network().to_string(), - from_addr: escrow_data.offerer.to_string(), - input_utxos: "[]".to_string(), - status: InstanceBridgeOutStatus::Initialize.to_string(), - escrow_hash: Some(event.escrow_hash.clone()), - bridge_out_amount: escrow_data.amount.to_string(), - goat_tx_hash: event.transaction_hash.clone(), - goat_tx_height: event_height, - user_change_addr: escrow_data.claimer.to_string(), - user_refund_addr: escrow_data.claimer.to_string(), - bridge_out_lock_time: get_timestamp_from_contract_data( - &escrow_data.refundData.0, - ), - status_updated_at: create_time, - created_at: create_time, - ..Default::default() - }; - let initialized = if storage_processor.insert_instance_if_absent(&instance).await? { - true - } else if let Some(existing) = storage_processor.find_instance(&instance_id).await? - { - let escrow_hash_matches = existing - .escrow_hash - .as_ref() - .map(|hash| hash.eq_ignore_ascii_case(&event.escrow_hash)) - .unwrap_or(false); - if existing.is_bridge_in || !escrow_hash_matches { - anyhow::bail!( - "bridge-out instance ID collision for escrow hash {}", - event.escrow_hash - ); - } - storage_processor - .update_instance( - &swap_init_instance_update( - existing.instance_id, - &escrow_data, - &event, - event_height, - ) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false) - .with_only_if_goat_tx_hash(String::new()), - ) - .await? - } else { - anyhow::bail!( - "bridge-out instance {} disappeared during creation", - instance_id - ); - }; - (instance_id, initialized) - }; - if initialized && escrow_data.token == *gateway_peg_btc_address { - let mut bridge_out_global_stats = - get_bridge_out_global_stats(storage_processor).await?; - let mut initial_amount = - U256::from_str(&bridge_out_global_stats.initial_amount).unwrap_or_default(); - initial_amount.add_assign(&escrow_data.amount); - bridge_out_global_stats.initial_amount = initial_amount.to_string(); - bridge_out_global_stats.initial_txn += 1; - storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; - info!( - "swap initialize stats included: tx_hash={}, escrow_hash={}, token={}, amount={}", - event.transaction_hash, - event.escrow_hash, - escrow_data.token, - escrow_data.amount, - ); - } else if initialized { - info!( - "swap initialize stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}, token={}, expect_token={}", - event.transaction_hash, - event.escrow_hash, - escrow_data.token, - gateway_peg_btc_address, - ); - } else { - info!( - "swap initialize ignored for resolved or previously initialized instance {instance_id}" - ); - } - storage_processor - .upsert_goat_tx_record(&GoatTxRecord { - instance_id, - graph_id: Uuid::nil(), - tx_type: GoatTxType::SwapInitialize.to_string(), - tx_hash: event.transaction_hash.clone(), - height: event_height, - is_local: false, - processing_status: GoatTxProcessingStatus::Skipped.to_string(), - extra: Some(hex::encode(escrow_data.abi_encode())), - created_at: create_time, - }) - .await?; - } else { + else { warn!("failed to parse escrow_data for event:{event:?}"); + continue; + }; + let escrow_hash = normalize_escrow_hash(&event.escrow_hash); + let create_time = event.block_timestamp.parse::()?; + let event_height = event.block_number.parse::()?; + let escrow = SwapEscrow { + escrow_hash: escrow_hash.clone(), + network: get_network().to_string(), + status: SwapEscrowStatus::Initialize.to_string(), + offerer_addr: escrow_data.offerer.to_string(), + claimer_addr: escrow_data.claimer.to_string(), + token: escrow_data.token.to_string(), + amount: escrow_data.amount.to_string(), + refund_deadline: get_timestamp_from_contract_data(&escrow_data.refundData.0), + escrow_data: Some(hex::encode(escrow_data.abi_encode())), + init_tx_hash: event.transaction_hash.clone(), + init_tx_height: event_height, + status_updated_at: create_time, + created_at: create_time, + ..Default::default() + }; + // Swap records are derived exclusively from the on-chain Initialize + // event; duplicate event scans must not overwrite the first record. + let initialized = storage_processor.insert_swap_escrow_if_absent(&escrow).await?; + if initialized && escrow_data.token == *gateway_peg_btc_address { + let mut bridge_out_global_stats = + get_bridge_out_global_stats(storage_processor).await?; + let mut initial_amount = + U256::from_str(&bridge_out_global_stats.initial_amount).unwrap_or_default(); + initial_amount.add_assign(&escrow_data.amount); + bridge_out_global_stats.initial_amount = initial_amount.to_string(); + bridge_out_global_stats.initial_txn += 1; + storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; + info!( + "swap initialize stats included: tx_hash={}, escrow_hash={}, token={}, amount={}", + event.transaction_hash, escrow_hash, escrow_data.token, escrow_data.amount, + ); + } else if initialized { + info!( + "swap initialize stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}, token={}, expect_token={}", + event.transaction_hash, escrow_hash, escrow_data.token, gateway_peg_btc_address, + ); + } else { + info!( + "swap initialize ignored for resolved or previously initialized escrow {escrow_hash}" + ); } } Ok(()) } -fn swap_init_instance_update( - instance_id: Uuid, - escrow_data: &EscrowData, - event: &SwapInitializeEvent, - event_height: i64, -) -> InstanceUpdate { - InstanceUpdate::new_with_instance_id(instance_id) - .with_bridge_out_amount(escrow_data.amount.to_string()) - .with_goat_tx_hash(event.transaction_hash.clone()) - .with_goat_tx_height(event_height) - .with_user_change_addr(escrow_data.claimer.to_string()) - .with_user_refund_addr(escrow_data.claimer.to_string()) - .with_bridge_out_lock_time(get_timestamp_from_contract_data(&escrow_data.refundData.0)) -} - async fn handle_swap_claim_events<'a>( storage_processor: &mut StorageProcessor<'a>, goat_client: Arc, @@ -948,7 +856,7 @@ async fn handle_swap_claim_events<'a>( claim_events: Vec, ) -> anyhow::Result<()> { for event in claim_events { - if let Some(claim_data) = extract_claim_data_from_tx( + let Some(claim_data) = extract_claim_data_from_tx( &goat_client, &event.transaction_hash, swap_contract_address, @@ -957,78 +865,73 @@ async fn handle_swap_claim_events<'a>( )?, ) .await? - && let Some(instance) = - find_instances_by_escrow_hash(storage_processor, &event.escrow_hash).await? - { - let instance_id = instance.instance_id; - let to_addr = Address::from_script( - bitcoin::Script::from_bytes(&claim_data.output_script), - get_network(), - )?; - let transitioned = storage_processor - .update_instance( - &InstanceUpdate::new_with_instance_id(instance_id) - .with_status(InstanceBridgeOutStatus::Claim.to_string()) - .with_btc_txid(claim_data.txid.into()) - .with_to_addr(to_addr.to_string()) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false), - ) - .await?; - if !transitioned { - info!( - "swap claim ignored for resolved instance {instance_id} with status {}", - instance.status - ); - continue; - } - storage_processor - .upsert_goat_tx_record(&GoatTxRecord { - instance_id, - graph_id: Uuid::nil(), - tx_type: GoatTxType::SwapClaim.to_string(), - tx_hash: event.transaction_hash.clone(), - height: event.block_number.parse::()?, - is_local: false, - processing_status: GoatTxProcessingStatus::Skipped.to_string(), - extra: Some(claim_data.witness), - created_at: current_time_secs(), - }) - .await?; - - let is_peg_btc_swap = is_gateway_peg_btc_swap_instance( - storage_processor, - &instance_id, - gateway_peg_btc_address, + else { + warn!("failed to parse claim_data for event:{event:?}"); + continue; + }; + let escrow_hash = normalize_escrow_hash(&event.escrow_hash); + let Some(escrow) = storage_processor.find_swap_escrow(&escrow_hash).await? else { + warn!( + "swap claim ignored for unknown escrow {escrow_hash}, tx_hash={}", + event.transaction_hash + ); + continue; + }; + let btc_addr = Address::from_script( + bitcoin::Script::from_bytes(&claim_data.output_script), + get_network(), + ) + .map(|address| address.to_string()) + .unwrap_or_else(|error| { + warn!( + escrow_hash = %escrow_hash, + tx_hash = %event.transaction_hash, + output_script = %hex::encode(&claim_data.output_script), + %error, + "swap claim output script is not a Bitcoin address; storing an empty display address" + ); + String::new() + }); + // The claim event is authoritative: it also overrides a locally + // derived Timeout. + let transitioned = storage_processor + .update_swap_escrow( + &SwapEscrowUpdate::new(escrow_hash.clone()) + .with_status(SwapEscrowStatus::Claim.to_string()) + .with_claim_tx_hash(event.transaction_hash.clone()) + .with_claim_btc_txid(claim_data.txid.into()) + .with_btc_addr(btc_addr) + .with_only_if_status_in(vec![ + SwapEscrowStatus::Initialize.to_string(), + SwapEscrowStatus::Timeout.to_string(), + ]), ) .await?; - if is_peg_btc_swap { - let mut bridge_out_global_stats = - get_bridge_out_global_stats(storage_processor).await?; - let mut claim_amount = - U256::from_str(&bridge_out_global_stats.claim_amount).unwrap_or_default(); - claim_amount - .add_assign(&U256::from_str(&instance.bridge_out_amount).unwrap_or_default()); - bridge_out_global_stats.claim_amount = claim_amount.to_string(); - bridge_out_global_stats.claim_txn += 1; - storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; - info!( - "swap claim stats included: tx_hash={}, escrow_hash={}, instance_id={}, amount={}", - event.transaction_hash, - event.escrow_hash, - instance_id, - instance.bridge_out_amount, - ); - } else { - info!( - "swap claim stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}, instance_id={}", - event.transaction_hash, event.escrow_hash, instance_id, - ); - } + if !transitioned { + info!( + "swap claim ignored for resolved escrow {escrow_hash} with status {}", + escrow.status + ); + continue; + } + if escrow.token == gateway_peg_btc_address.to_string() { + let mut bridge_out_global_stats = + get_bridge_out_global_stats(storage_processor).await?; + let mut claim_amount = + U256::from_str(&bridge_out_global_stats.claim_amount).unwrap_or_default(); + claim_amount.add_assign(&U256::from_str(&escrow.amount).unwrap_or_default()); + bridge_out_global_stats.claim_amount = claim_amount.to_string(); + bridge_out_global_stats.claim_txn += 1; + storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; + info!( + "swap claim stats included: tx_hash={}, escrow_hash={}, amount={}", + event.transaction_hash, escrow_hash, escrow.amount, + ); } else { - warn!("failed to parse claim_data for event:{event:?}"); + info!( + "swap claim stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}", + event.transaction_hash, escrow_hash, + ); } } Ok(()) @@ -1040,82 +943,57 @@ async fn handle_swap_refund_events<'a>( refund_events: Vec, ) -> anyhow::Result<()> { for event in refund_events { - if let Some(instance) = - find_instances_by_escrow_hash(storage_processor, &event.escrow_hash).await? - { - let transitioned = storage_processor - .update_instance( - &InstanceUpdate::new_with_instance_id(instance.instance_id) - .with_status(InstanceBridgeOutStatus::Refund.to_string()) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false), - ) - .await?; - if !transitioned { - info!( - "swap refund ignored for resolved instance {} with status {}", - instance.instance_id, instance.status - ); - continue; - } - let is_peg_btc_swap = is_gateway_peg_btc_swap_instance( - storage_processor, - &instance.instance_id, - gateway_peg_btc_address, + let escrow_hash = normalize_escrow_hash(&event.escrow_hash); + let Some(escrow) = storage_processor.find_swap_escrow(&escrow_hash).await? else { + warn!( + "swap refund ignored for unknown escrow {escrow_hash}, tx_hash={}", + event.transaction_hash + ); + continue; + }; + // The refund event is authoritative: it also overrides a locally + // derived Timeout. + let transitioned = storage_processor + .update_swap_escrow( + &SwapEscrowUpdate::new(escrow_hash.clone()) + .with_status(SwapEscrowStatus::Refund.to_string()) + .with_refund_tx_hash(event.transaction_hash.clone()) + .with_only_if_status_in(vec![ + SwapEscrowStatus::Initialize.to_string(), + SwapEscrowStatus::Timeout.to_string(), + ]), ) .await?; - if is_peg_btc_swap { - let mut bridge_out_global_stats = - get_bridge_out_global_stats(storage_processor).await?; - let mut refund_amount = - U256::from_str(&bridge_out_global_stats.refund_amount).unwrap_or_default(); - refund_amount - .add_assign(&U256::from_str(&instance.bridge_out_amount).unwrap_or_default()); - bridge_out_global_stats.refund_amount = refund_amount.to_string(); - bridge_out_global_stats.refund_txn += 1; - storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; - info!( - "swap refund stats included: tx_hash={}, escrow_hash={}, instance_id={}, amount={}", - event.transaction_hash, - event.escrow_hash, - instance.instance_id, - instance.bridge_out_amount, - ); - } else { - info!( - "swap refund stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}, instance_id={}", - event.transaction_hash, event.escrow_hash, instance.instance_id, - ); - } + if !transitioned { + info!( + "swap refund ignored for resolved escrow {escrow_hash} with status {}", + escrow.status + ); + continue; + } + if escrow.token == gateway_peg_btc_address.to_string() { + let mut bridge_out_global_stats = + get_bridge_out_global_stats(storage_processor).await?; + let mut refund_amount = + U256::from_str(&bridge_out_global_stats.refund_amount).unwrap_or_default(); + refund_amount.add_assign(&U256::from_str(&escrow.amount).unwrap_or_default()); + bridge_out_global_stats.refund_amount = refund_amount.to_string(); + bridge_out_global_stats.refund_txn += 1; + storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; + info!( + "swap refund stats included: tx_hash={}, escrow_hash={}, amount={}", + event.transaction_hash, escrow_hash, escrow.amount, + ); + } else { + info!( + "swap refund stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}", + event.transaction_hash, escrow_hash, + ); } } Ok(()) } -async fn is_gateway_peg_btc_swap_instance( - storage_processor: &mut StorageProcessor<'_>, - instance_id: &Uuid, - gateway_peg_btc_address: &EvmAddress, -) -> anyhow::Result { - if let Some(tx_record) = storage_processor - .find_graph_goat_tx_record( - instance_id, - &Uuid::nil(), - &GoatTxType::SwapInitialize.to_string(), - ) - .await? - && let Some(encode_escrow_data) = tx_record.extra - && let Ok(escrow_data_bytes) = hex::decode(&encode_escrow_data) - && let Ok(escrow_data) = ::abi_decode(&escrow_data_bytes) - { - return Ok(escrow_data.token == *gateway_peg_btc_address); - } - - Ok(false) -} - pub(super) async fn generate_instance_from_bridge_in_request_event( btc_client: &BTCClient, goat_client: &GOATClient, diff --git a/node/src/scheduled_tasks/graph_maintenance_tasks.rs b/node/src/scheduled_tasks/graph_maintenance_tasks.rs index 1cd3b5bc0..960ac8521 100644 --- a/node/src/scheduled_tasks/graph_maintenance_tasks.rs +++ b/node/src/scheduled_tasks/graph_maintenance_tasks.rs @@ -6,9 +6,7 @@ use crate::action::{ }; use crate::env::get_network; use crate::rpc_service::current_time_secs; -use crate::scheduled_tasks::{ - fetch_all_graphs_by_status, fetch_first_graph_per_operator_by_status, -}; +use crate::scheduled_tasks::fetch_all_graphs_by_status; use crate::utils::{ SELF_SENDER, load_validated_graph_definition, outpoint_spent_txid, upsert_message, }; @@ -21,9 +19,11 @@ use bitvm_lib::timelocks::{ }; use client::btc_chain::BTCClient; use client::goat_chain::DisproveTxType; +use futures::StreamExt; use goat::{constants::TimelockConfig, transactions::base::output_topology}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; +use std::sync::Mutex; use store::localdb::{LocalDB, StorageProcessor}; use store::{ GoatTxProcessingStatus, GoatTxType, Graph, GraphBtcTxVoutMonitor, GraphStatus, SerializableTxid, @@ -37,6 +37,10 @@ const MONITE_BTC_TX_NAME_WATCHTOWER_INIT: &str = "watchtower_init"; const MONITE_BTC_TX_NAME_PROVER_ASSERT: &str = "prover_assert"; const MONITE_BTC_TX_NAME_VERIFIER_ASSERT: &str = "verifier_assert"; const MAX_PREKICKOFF_SUCCESSORS_PER_SCAN: usize = 32; +/// Upper bound on kickoff scan entries checked against Bitcoin at the same +/// time. Every pending graph is an entry, so the per-tick cost is bounded by +/// the number of pending graphs rather than by chain depth. +const KICKOFF_SCAN_CONCURRENCY: usize = 8; #[derive(Clone, Debug, Eq, PartialEq, Display, EnumString)] enum OperatorWithdrawType { @@ -329,6 +333,12 @@ fn is_kickoff_pending_status(status: &str) -> bool { || status == GraphStatus::PreKickoff.to_string() } +/// Whether `txid` is confirmed. Unlike `get_tx_status`, an unknown txid is +/// reported as not confirmed instead of as an error. +async fn tx_confirmed_on_chain(btc_client: &BTCClient, txid: &Txid) -> anyhow::Result { + Ok(btc_client.get_tx_info(txid).await?.map(|tx| tx.status.confirmed).unwrap_or(false)) +} + async fn detect_graph_kickoff( local_db: &LocalDB, btc_client: &BTCClient, @@ -343,8 +353,9 @@ async fn detect_graph_kickoff( return Ok(()); }; let kickoff_txid: Txid = kickoff_txid.into(); - let tx_status = btc_client.get_tx_status(&kickoff_txid).await?; - if tx_status.confirmed { + // A kickoff that has not been broadcast yet is the normal state of a + // pending graph; esplora answers 404, which get_tx_info maps to None. + if tx_confirmed_on_chain(btc_client, &kickoff_txid).await? { enqueue_kickoff_sent(local_db, graph).await?; } else { trace!(graph_id = %graph.graph_id, kickoff_txid = %kickoff_txid, "kickoff is not confirmed yet"); @@ -361,7 +372,7 @@ async fn confirmed_prekickoff_successor( return Ok(None); }; let next_prekickoff_txid: Txid = next_prekickoff.clone().into(); - if !btc_client.get_tx_status(&next_prekickoff_txid).await?.confirmed { + if !tx_confirmed_on_chain(btc_client, &next_prekickoff_txid).await? { return Ok(None); } @@ -402,11 +413,18 @@ async fn scan_kickoff_chain( local_db: &LocalDB, btc_client: &BTCClient, start_graph: Graph, - visited_graph_ids: &mut HashSet, + visited_graph_ids: &Mutex>, ) -> anyhow::Result<()> { let mut graph = start_graph; for successor_depth in 0..=MAX_PREKICKOFF_SUCCESSORS_PER_SCAN { - if !visited_graph_ids.insert(graph.graph_id) { + // Entries are scanned concurrently; whichever walker claims a graph + // first scans it, the others stop at it. The lock is never held + // across an await. + let first_visit = visited_graph_ids + .lock() + .map_err(|_| anyhow::anyhow!("kickoff scan visited set poisoned"))? + .insert(graph.graph_id); + if !first_visit { return Ok(()); } @@ -424,16 +442,17 @@ async fn scan_kickoff_chain( return Ok(()); }; + // Enqueue before checking the depth limit so the successor at the + // boundary still gets its PreKickoffSent this tick. + enqueue_prekickoff_sent(local_db, &successor).await?; if successor_depth == MAX_PREKICKOFF_SUCCESSORS_PER_SCAN { - warn!( + info!( graph_id = %graph.graph_id, max_depth = MAX_PREKICKOFF_SUCCESSORS_PER_SCAN, - "stopped pre-kickoff successor scan at configured depth limit" + "stopped this pre-kickoff walk at the depth limit; later graphs are scanned as their own entries" ); return Ok(()); } - - enqueue_prekickoff_sent(local_db, &successor).await?; graph = successor; } Ok(()) @@ -441,14 +460,18 @@ async fn scan_kickoff_chain( /// May trigger PreKickoffSent and KickoffSent. /// -/// The first OperatorDataPushed graph is the normal scan entry point for an -/// operator. A confirmed `next_prekickoff` proves that the next graph has -/// started, so follow that chain instead of letting the earlier graph hide it. +/// Every OperatorDataPushed or PreKickoff graph is a scan entry, so a kickoff +/// on any stored graph is observed in the tick it confirms, regardless of how +/// deep it sits in the operator's pre-kickoff chain or whether the earlier +/// graphs' messages have been handled. The chain walk from each entry only +/// propagates PreKickoffSent to confirmed successors (which drives the +/// force-skip of the graph before each of them); it is not what guarantees +/// coverage, so its depth limit only bounds a single walk. pub async fn detect_kickoff(local_db: &LocalDB, btc_client: &BTCClient) -> anyhow::Result<()> { trace!("start tick action: detect_kickoff"); let graphs = { let mut storage_processor = local_db.acquire().await?; - let mut graphs = fetch_first_graph_per_operator_by_status( + let mut graphs = fetch_all_graphs_by_status( &mut storage_processor, &GraphStatus::OperatorDataPushed.to_string(), ) @@ -463,17 +486,22 @@ pub async fn detect_kickoff(local_db: &LocalDB, btc_client: &BTCClient) -> anyho ); graphs }; - info!("start tick action: detect_kickoff, roots: {}", graphs.len()); - - let mut visited_graph_ids = HashSet::new(); - for graph in graphs { - let graph_id = graph.graph_id; - if let Err(error) = - scan_kickoff_chain(local_db, btc_client, graph, &mut visited_graph_ids).await - { - warn!(graph_id = %graph_id, error = %error, "failed to scan kickoff chain"); - } - } + info!("start tick action: detect_kickoff, entries: {}", graphs.len()); + + let visited_graph_ids = Mutex::new(HashSet::new()); + futures::stream::iter(graphs) + .for_each_concurrent(KICKOFF_SCAN_CONCURRENCY, |graph| { + let visited_graph_ids = &visited_graph_ids; + async move { + let graph_id = graph.graph_id; + if let Err(error) = + scan_kickoff_chain(local_db, btc_client, graph, visited_graph_ids).await + { + warn!(graph_id = %graph_id, error = %error, "failed to scan kickoff chain"); + } + } + }) + .await; Ok(()) } diff --git a/node/src/scheduled_tasks/instance_maintenance_tasks.rs b/node/src/scheduled_tasks/instance_maintenance_tasks.rs index e5195e941..0126560ac 100644 --- a/node/src/scheduled_tasks/instance_maintenance_tasks.rs +++ b/node/src/scheduled_tasks/instance_maintenance_tasks.rs @@ -1,6 +1,7 @@ use crate::action::{ ConfirmInstance, GOATMessage, GOATMessageContent, MessageDeferReason, PeginConfirmNonce, - PeginConfirmPartialSig, PeginRequest, PostReady, push_local_unhandled_messages_with_reason, + PeginConfirmNonceConsensus, PeginConfirmPartialSig, PeginRequest, PostReady, + push_local_unhandled_messages_with_reason, }; use crate::env::{ COMMITTEE_INSTANCE_KEYS_DIR, get_bitvm_key, get_committee_instance_key_delete_timelock_blocks, @@ -9,15 +10,12 @@ use crate::env::{ }; use crate::rpc_service::current_time_secs; use crate::scheduled_tasks::event_watch_task::generate_instance_from_bridge_in_request_event; -use crate::scheduled_tasks::get_timestamp_from_contract_data; -use crate::utils::evm_swap_utils::IEscrowManager::EscrowData; use crate::utils::{ SELF_SENDER, check_bridge_in_uxto_available_or_self_spent, gen_instance_parameters_local, - get_committee_endorse_sigs_for_pegin, get_committee_partial_sig_for_instance, - get_committee_pub_nonce_for_instance, load_committee_instance_keypair, - store_committee_pub_nonce_for_instance, upsert_message, + get_committee_agg_nonce_consensus_for_instance, get_committee_endorse_sigs_for_pegin, + get_committee_partial_sig_for_instance, get_committee_pub_nonce_for_instance, + load_committee_instance_keypair, store_committee_pub_nonce_for_instance, upsert_message, }; -use alloy::sol_types::SolType; use bitvm_lib::actors::Actor; use bitvm_lib::keys::CommitteeMasterKey; use bitvm_lib::timelocks::default_connector_z_timelock_blocks; @@ -33,16 +31,13 @@ use std::str::FromStr; use std::sync::{LazyLock, Mutex}; use std::vec; use store::localdb::{InstanceQuery, InstanceUpdate, LocalDB, StorageProcessor}; -use store::{ - GoatTxProcessingStatus, GoatTxType, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, -}; +use store::{GoatTxProcessingStatus, GoatTxType, Instance, InstanceBridgeInStatus}; use tracing::{info, warn}; use uuid::Uuid; const TASK_KEY_INSTANCE_WINDOW_EXPIRATION: &str = "instance_window_expiration_monitor"; const TASK_KEY_INSTANCE_EXPIRATION: &str = "instance_expiration_monitor"; const TASK_KEY_INSTANCE_BTC_TX: &str = "instance_btc_tx_monitor"; -const TASK_KEY_INSTANCE_BRIDGE_OUT: &str = "instance_bridge_out_monitor"; const TASK_KEY_PEGIN_CONFIRM_RECOVERY: &str = "pegin_confirm_recovery_monitor"; #[derive(Clone, Debug)] @@ -242,7 +237,6 @@ pub async fn instance_window_expiration_monitor( local_db, TASK_KEY_INSTANCE_WINDOW_EXPIRATION, InstanceQuery::default() - .with_is_bridge_in(true) .with_status(InstanceBridgeInStatus::UserInited.to_string()) .with_pegin_request_height_threshold(current_height - window_blocks), batch_size, @@ -303,8 +297,7 @@ pub async fn instance_window_expiration_monitor( let mut update = InstanceUpdate::new_with_instance_id(instance.instance_id) .with_status(next_status.to_string()) .with_committees_answers(instance.committees_answers.clone()) - .with_only_if_status_in(vec![InstanceBridgeInStatus::UserInited.to_string()]) - .with_only_if_is_bridge_in(true); + .with_only_if_status_in(vec![InstanceBridgeInStatus::UserInited.to_string()]); if reached_quorum { if let Some(btc_txid) = instance.btc_txid.clone() { update = update.with_btc_txid(btc_txid); @@ -357,7 +350,7 @@ pub async fn instance_expiration_monitor( find_one_instance_page( local_db, TASK_KEY_INSTANCE_EXPIRATION, - InstanceQuery::default().with_is_bridge_in(true).with_statuses(vec![ + InstanceQuery::default().with_statuses(vec![ InstanceBridgeInStatus::Presigned.to_string(), InstanceBridgeInStatus::PresignedFailed.to_string(), ]), @@ -394,7 +387,7 @@ pub async fn instance_btc_tx_monitor( let instances = find_one_instance_page( local_db, TASK_KEY_INSTANCE_BTC_TX, - InstanceQuery::default().with_is_bridge_in(true).with_statuses(vec![ + InstanceQuery::default().with_statuses(vec![ InstanceBridgeInStatus::UserInited.to_string(), InstanceBridgeInStatus::CommitteesAnswered.to_string(), InstanceBridgeInStatus::Presigned.to_string(), @@ -527,9 +520,7 @@ pub async fn pegin_confirm_recovery_monitor( let instances = find_one_instance_page( local_db, TASK_KEY_PEGIN_CONFIRM_RECOVERY, - InstanceQuery::default() - .with_is_bridge_in(true) - .with_status(InstanceBridgeInStatus::Presigned.to_string()), + InstanceQuery::default().with_status(InstanceBridgeInStatus::Presigned.to_string()), get_instance_maintenance_batch_size(), ) .await?; @@ -607,13 +598,8 @@ pub async fn pegin_confirm_recovery_monitor( continue; } - let instance_parameters_hash = instance_parameters.parameters_hash()?; let (_, derived_pub_nonce, nonce_sig) = committee_master_key - .nonce_for_instance_job_with_keypair( - instance_id, - instance_parameters_hash, - instance_keypair, - ); + .nonce_for_instance_job_with_keypair(&instance_parameters, instance_keypair)?; let pub_nonce = match get_committee_pub_nonce_for_instance( local_db, instance_id, @@ -664,68 +650,49 @@ pub async fn pegin_confirm_recovery_monitor( instance_id = %instance_id, "queued persisted PeginConfirm nonce for re-publication" ); + if let Some((_, consensus_hash, signature)) = + get_committee_agg_nonce_consensus_for_instance(local_db, instance_id) + .await? + .into_iter() + .find(|(pubkey, _, _)| *pubkey == local_committee_pubkey) + { + let message = GOATMessage::new( + Actor::Committee, + GOATMessageContent::PeginConfirmNonceConsensus(PeginConfirmNonceConsensus { + instance_id, + committee_pubkey: local_committee_pubkey, + consensus_hash, + signature, + }), + ); + push_local_unhandled_messages_with_reason( + local_db, + instance_id, + &message, + 0, + MessageDeferReason::RecoveryRepublish, + "re-publishing persisted PeginConfirm nonce consensus", + ) + .await?; + tracing::info!( + event = "pegin_confirm_recovery", + action = "republish_nonce_consensus", + instance_id = %instance_id, + "queued persisted PeginConfirm nonce consensus for re-publication" + ); + } } Ok(()) } -pub async fn get_bridge_out_deadline<'a>( - storage_processor: &mut StorageProcessor<'a>, - instance_id: &Uuid, -) -> anyhow::Result { - let deadline = if let Some(tx_record) = storage_processor - .find_graph_goat_tx_record( - instance_id, - &Uuid::nil(), - &GoatTxType::SwapInitialize.to_string(), - ) - .await? - && let Some(encode_escrow_data) = tx_record.extra - && let Ok(escrow_data_bytes) = hex::decode(&encode_escrow_data) - && let Ok(escrow_data) = EscrowData::abi_decode(&escrow_data_bytes) - { - get_timestamp_from_contract_data(&escrow_data.refundData.0) - } else { - 0 - }; - Ok(deadline) -} - -pub async fn instance_bridge_out_monitor(local_db: &LocalDB) -> anyhow::Result<()> { - let batch_size = get_instance_maintenance_batch_size(); - let current_time = current_time_secs(); - let instances = find_one_instance_page( - local_db, - TASK_KEY_INSTANCE_BRIDGE_OUT, - InstanceQuery::default() - .with_is_bridge_in(false) - .with_status(InstanceBridgeOutStatus::Initialize.to_string()) - .with_raw_condition("escrow_hash IS NOT NULL".to_string()), - batch_size, - ) - .await?; +/// Mark still-initializing swap escrows whose refund deadline has passed as +/// timed out. +pub async fn swap_escrow_timeout_monitor(local_db: &LocalDB) -> anyhow::Result<()> { let mut storage_processor = local_db.acquire().await?; - for instance in instances { - let mut instance_update = InstanceUpdate::new_with_instance_id(instance.instance_id) - .with_only_if_status_in(vec![InstanceBridgeOutStatus::Initialize.to_string()]) - .with_only_if_is_bridge_in(false); - let lock_time = if instance.bridge_out_lock_time == 0 { - let lock_time = - get_bridge_out_deadline(&mut storage_processor, &instance.instance_id).await?; - instance_update = instance_update.with_bridge_out_lock_time(lock_time); - lock_time - } else { - instance.bridge_out_lock_time - }; - if lock_time < current_time && lock_time > 0 { - instance_update = - instance_update.with_status(InstanceBridgeOutStatus::Timeout.to_string()); - } - - if instance_update.has_updates() { - storage_processor.update_instance(&instance_update).await?; - } + let timed_out = storage_processor.timeout_expired_swap_escrows(current_time_secs()).await?; + if timed_out > 0 { + info!("marked {timed_out} swap escrows as timed out"); } - Ok(()) } diff --git a/node/src/scheduled_tasks/mod.rs b/node/src/scheduled_tasks/mod.rs index fd04fa343..1cb7e0f2e 100644 --- a/node/src/scheduled_tasks/mod.rs +++ b/node/src/scheduled_tasks/mod.rs @@ -18,9 +18,9 @@ use crate::scheduled_tasks::graph_maintenance_tasks::{ detect_init_withdraw_call, detect_kickoff, detect_take1_or_challenge, process_graph_challenge, }; use crate::scheduled_tasks::instance_maintenance_tasks::{ - instance_answers_monitor, instance_bridge_out_monitor, instance_btc_tx_monitor, - instance_committee_key_cleanup_monitor, instance_expiration_monitor, - instance_window_expiration_monitor, pegin_confirm_recovery_monitor, + instance_answers_monitor, instance_btc_tx_monitor, instance_committee_key_cleanup_monitor, + instance_expiration_monitor, instance_window_expiration_monitor, + pegin_confirm_recovery_monitor, swap_escrow_timeout_monitor, }; use crate::scheduled_tasks::node_maintenance_tasks::node_available_pbtc_update_monitor; use crate::scheduled_tasks::spv_maintenance_tasks::spv_header_hash_update; @@ -157,7 +157,8 @@ async fn refresh_alert_health( } } -/// Return every graph in the requested status. Time-sensitive flows must not +/// Return every graph in the requested status, ordered by operator and +/// kickoff index. Time-sensitive flows (including the kickoff scan) must not /// let a lower-index graph hide another graph owned by the same operator. pub(super) async fn fetch_all_graphs_by_status<'a>( storage_processor: &mut StorageProcessor<'a>, @@ -166,24 +167,6 @@ pub(super) async fn fetch_all_graphs_by_status<'a>( storage_processor.find_graphs_by_status_group_by_operator(graph_status).await } -/// Return the lowest-index graph for each operator in the requested status. -/// This is only suitable as the entry point for a chain-aware scan. -pub(super) async fn fetch_first_graph_per_operator_by_status<'a>( - storage_processor: &mut StorageProcessor<'a>, - graph_status: &str, -) -> anyhow::Result> { - let graphs_ori = fetch_all_graphs_by_status(storage_processor, graph_status).await?; - let mut graphs: Vec = vec![]; - let mut pre_operator_pubkey = "".to_string(); - for graph in graphs_ori { - if graph.operator_pubkey != pre_operator_pubkey { - pre_operator_pubkey = graph.operator_pubkey.clone(); - graphs.push(graph); - } - } - Ok(graphs) -} - async fn run_maintenance_subtask( metrics_state: &MetricsState, task: &'static str, @@ -312,8 +295,8 @@ async fn run( .await; run_maintenance_subtask( metrics_state, - "instance_bridge_out_monitor", - instance_bridge_out_monitor(local_db), + "swap_escrow_timeout_monitor", + swap_escrow_timeout_monitor(local_db), ) .await; run_maintenance_subtask( @@ -496,14 +479,19 @@ pub fn get_goat_message_content_type(content: &GOATMessageContent) -> MessageTyp GOATMessageContent::GenCircuits(_) => MessageType::GenCircuits, GOATMessageContent::CutCircuits(_) => MessageType::CutCircuits, GOATMessageContent::SolderingProofReady(_) => MessageType::SolderingProof, + GOATMessageContent::GraphSetupAck(_) => MessageType::None, GOATMessageContent::VerifierGraphParamsEndorsement(_) => { MessageType::VerifierGraphParamsEndorsement } GOATMessageContent::NonceGeneration(_) => MessageType::NonceGeneration, + GOATMessageContent::AggNonceConsensus(_) => MessageType::AggNonceConsensus, GOATMessageContent::CommitteePresign(_) => MessageType::CommitteePresign, GOATMessageContent::GraphFinalize(_) => MessageType::GraphFinalize, GOATMessageContent::EndorseGraph(_) => MessageType::EndorseGraph, GOATMessageContent::PeginConfirmNonce(_) => MessageType::PeginConfirmNonce, + GOATMessageContent::PeginConfirmNonceConsensus(_) => { + MessageType::PeginConfirmNonceConsensus + } GOATMessageContent::PeginConfirmPartialSig(_) => MessageType::PeginConfirmPartialSig, GOATMessageContent::PostReady(_) => MessageType::PostReady, GOATMessageContent::KickoffReady(_) => MessageType::KickoffReady, diff --git a/node/src/utils.rs b/node/src/utils.rs index f9f5868a3..bb7f85463 100644 --- a/node/src/utils.rs +++ b/node/src/utils.rs @@ -71,7 +71,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use store::localdb::{GraphQuery, GraphRuntimeUpdate, InstanceQuery, LocalDB, StorageProcessor}; +use store::localdb::{GraphQuery, GraphRuntimeUpdate, LocalDB, StorageProcessor}; use crate::env; use crate::rpc_service::routes::v1::{ @@ -84,8 +84,8 @@ use crate::scheduled_tasks::graph_maintenance_tasks::{ }; use bitcoin_light_client_circuit::hash_operator_constant; use bitvm_lib::babe_adapter::{ - BABE_M_CC, BabeVerifierPrivateState, CACSetupPackage, FinalizedInstanceData, SolderingData, - compact_soldering_proof_payload, + BABE_M_CC, BabeVerifierPrivateState, CACSetupPackage, FinalizedInstanceData, Seed, + SolderingData, compact_soldering_proof_payload, }; use bitvm_lib::transactions::base::BaseTransaction; use client::goat_chain::{DisproveTxType, GraphData, PeginStatus, WithdrawStatus}; @@ -114,7 +114,6 @@ use zkm_verifier::{ }; pub const SELF_SENDER: &str = "self"; -const BRIDGE_OUT_INSTANCE_ID_PREFIX: [u8; 4] = *b"BOID"; const BABE_SETUP_STATE_ORPHAN_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); pub(crate) fn load_committee_instance_keypair( @@ -132,26 +131,6 @@ pub(crate) fn load_committee_instance_keypair( }) } -/// Derive the shared bridge-out instance ID from its escrow hash. -/// -/// Both the RPC tag endpoint and the chain-event watcher must use this ID so -/// their concurrent create attempts collide on the primary key instead of -/// creating two rows for one escrow. -pub(crate) fn bridge_out_instance_id_from_escrow_hash(escrow_hash: &str) -> Uuid { - let normalized_escrow_hash = escrow_hash - .strip_prefix("0x") - .or_else(|| escrow_hash.strip_prefix("0X")) - .unwrap_or(escrow_hash); - let mut hasher = Sha256::new(); - hasher.update(b"bridge-out:"); - hasher.update(normalized_escrow_hash.to_ascii_lowercase().as_bytes()); - let digest = hasher.finalize(); - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&digest[..16]); - bytes[..4].copy_from_slice(&BRIDGE_OUT_INSTANCE_ID_PREFIX); - Uuid::from_bytes(bytes) -} - pub(crate) const BRIDGE_OUT_GLOBAL_STATS_ID: i64 = 1; pub type VerifyingKey = ark_groth16::VerifyingKey; @@ -469,29 +448,38 @@ pub async fn validate_init_graph_base( graph.parameters.pubin_disprove_constant, )?; - // 5) Operator stake sanity: verify operator is registered and has enough locked stake - let op_pk_bytes = graph.parameters.operator_pubkey.to_bytes(); - let xonly: [u8; 32] = op_pk_bytes[1..33] - .try_into() - .map_err(|_| SpecialError::InvalidGraph("invalid operator pubkey".to_string()))?; - let operator_addr = goat_client.stake_mana_pubkey_to_address(&xonly).await.map_err(|e| { - SpecialError::InvalidGraph(format!("failed to query operator address: {e}")) - })?; - if operator_addr == [0u8; 20] { - bail!(SpecialError::InvalidGraph("operator not registered".to_string())); - } - let min_stake_amount = goat_client.gateway_get_min_stake_amount().await.map_err(|e| { - SpecialError::InvalidGraph(format!("failed to query min stake amount: {e}")) - })?; - let locked_stake = goat_client.stake_mana_lock_stake_of(&operator_addr).await.map_err(|e| { - SpecialError::InvalidGraph(format!("failed to query operator locked stake: {e}")) - })?; + // 5) Operator stake sanity: verify operator is registered and has enough locked stake. + validate_operator_stake(goat_client, &graph.parameters.operator_pubkey) + .await + .map_err(|error| SpecialError::InvalidGraph(error.to_string()))?; + + Ok(()) +} + +/// Verify the stake conditions required for an operator to create a graph. +/// This mirrors the Gateway graph-posting requirement and is shared by graph +/// validation and the early InitGraph admission check. +pub async fn validate_operator_stake( + goat_client: &GOATClient, + operator_pubkey: &PublicKey, +) -> Result<()> { + let operator_xonly_pubkey = XOnlyPublicKey::from(*operator_pubkey).serialize(); + let operator_addr = goat_client + .stake_mana_pubkey_to_address(&operator_xonly_pubkey) + .await + .context("query operator address")?; + if operator_addr == [0; 20] { + bail!("operator not registered"); + } + let min_stake_amount = + goat_client.gateway_get_min_stake_amount().await.context("query minimum operator stake")?; + let locked_stake = goat_client + .stake_mana_lock_stake_of(&operator_addr) + .await + .context("query operator locked stake")?; if locked_stake < min_stake_amount { - bail!(SpecialError::InvalidGraph(format!( - "insufficient operator stake: locked={locked_stake}, min={min_stake_amount}" - ))); + bail!("insufficient operator stake: locked={locked_stake}, min={min_stake_amount}"); } - Ok(()) } @@ -2094,7 +2082,7 @@ pub async fn read_pegin_request( btc_client: &BTCClient, goat_client: &GOATClient, instance_id: Uuid, -) -> Result<(UserInfo, Amount)> { +) -> Result<(UserInfo, Amount, Vec)> { let pegin_data = goat_client.gateway_get_pegin_data(&instance_id).await?; if pegin_data.status != PeginStatus::Pending { bail!("Invalid PeginRequest: expired or already processed"); @@ -2142,7 +2130,7 @@ pub async fn read_pegin_request( user_refund_address, user_xonly_pubkey, }; - Ok((user_info, Amount::from_sat(pegin_data.pegin_amount_sats))) + Ok((user_info, Amount::from_sat(pegin_data.pegin_amount_sats), pegin_data.committee_addresses)) } pub async fn read_instance_info_from_goat( @@ -4033,6 +4021,77 @@ pub mod defer { } } +/// Upper bound for the operator-supplied node name, which `GET /v1/nodes` +/// returns verbatim. +const MAX_NODE_NAME_LEN: usize = 64; + +/// Upper bound for the advertised `host:port` string. +const MAX_SOCKET_ADDR_LEN: usize = 128; + +/// Every `NodeInfo` field is self-declared by the sender, yet the node registry +/// is keyed by `peer_id`. The one identity a peer cannot forge is the gossipsub +/// message source: the swarm runs with `MessageAuthenticity::Signed` and strict +/// validation, so `message.source` is bound to the sender's libp2p key. Key the +/// registry row on that identity and never on the `peer_id` inside the payload, +/// otherwise any peer can overwrite any other node's record. +pub fn node_info_matches_sender(node_info: &NodeInfo, from_peer_id: &PeerId) -> bool { + node_info.peer_id == from_peer_id.to_string() +} + +/// Reject payloads that cannot be persisted safely. These fields are served +/// verbatim by `GET /v1/nodes` and drive on-chain lookups and reward +/// attribution in `node_available_pbtc_update_monitor` and `add_node_reward`. +pub fn validate_node_info_payload(node_info: &NodeInfo) -> std::result::Result<(), String> { + if PeerId::from_str(&node_info.peer_id).is_err() { + return Err(format!("invalid peer_id {}", node_info.peer_id)); + } + if Actor::from_str(&node_info.actor).is_err() { + return Err(format!("invalid actor {}", node_info.actor)); + } + if !node_info.goat_addr.is_empty() && EvmAddress::from_str(&node_info.goat_addr).is_err() { + return Err(format!("invalid goat_addr {}", node_info.goat_addr)); + } + if !node_info.btc_pub_key.is_empty() && PublicKey::from_str(&node_info.btc_pub_key).is_err() { + return Err(format!("invalid btc_pub_key {}", node_info.btc_pub_key)); + } + if !node_info.available_peg_btc.is_empty() + && alloy::primitives::U256::from_str(&node_info.available_peg_btc).is_err() + { + return Err(format!("invalid available_peg_btc {}", node_info.available_peg_btc)); + } + if !node_info.service_fee_rate.is_finite() || !(0.0..=1.0).contains(&node_info.service_fee_rate) + { + return Err(format!("invalid service_fee_rate {}", node_info.service_fee_rate)); + } + if node_info.node_name.chars().count() > MAX_NODE_NAME_LEN + || node_info.node_name.chars().any(char::is_control) + { + return Err(format!("invalid node_name of {} chars", node_info.node_name.chars().count())); + } + validate_node_socket_addr(&node_info.socket_addr) +} + +/// The advertised socket address is only ever displayed and dialed by clients, +/// so require a plain `host:port` string that cannot smuggle control characters +/// into API responses. +fn validate_node_socket_addr(socket_addr: &str) -> std::result::Result<(), String> { + if socket_addr.is_empty() { + return Ok(()); + } + if socket_addr.chars().count() > MAX_SOCKET_ADDR_LEN + || socket_addr.chars().any(|c| c.is_control() || c.is_whitespace()) + { + return Err(format!("invalid socket_addr of {} chars", socket_addr.chars().count())); + } + let Some((host, port)) = socket_addr.rsplit_once(':') else { + return Err(format!("socket_addr {socket_addr} is missing a port")); + }; + if host.is_empty() || port.parse::().is_err() { + return Err(format!("socket_addr {socket_addr} is not a valid host:port")); + } + Ok(()) +} + pub async fn save_node_info(local_db: &LocalDB, node_info: &NodeInfo) -> Result<()> { info!("save_node_info for {}", node_info.peer_id); let current_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; @@ -4057,6 +4116,14 @@ pub async fn save_node_info(local_db: &LocalDB, node_info: &NodeInfo) -> Result< pub async fn save_local_info(local_db: &LocalDB) { let node = get_local_node_info(); + // Peers apply the same validation to the NodeInfo this node broadcasts, so a + // malformed local config would silently drop this node from every peer's + // registry. Surface it here instead, where the operator reads the logs. + if let Err(reason) = validate_node_info_payload(&node) { + tracing::error!( + "local node info will be rejected by peers: {reason}; fix the node configuration" + ); + } match save_node_info(local_db, &node).await { Ok(_) => {} Err(err) => tracing::error!("save local node err: {err}"), @@ -4209,9 +4276,11 @@ pub fn temp_sqlite_db_path() -> String { // contract calls -#[derive(Clone, Serialize, Deserialize, Debug)] +#[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct InstanceProcessDataItem { pub pub_nonce: Option, + pub agg_nonce_consensus_hash: Option<[u8; 32]>, + pub agg_nonce_consensus_signature: Option, pub partial_sign: Option, pub endorse_signature: Vec, } @@ -4220,6 +4289,8 @@ pub type InstanceProcessDataMap = IndexMap; #[derive(Clone, Serialize, Deserialize, Default)] pub struct GraphProcessDataItem { pub committee_pub_nonce: Option, + pub agg_nonce_consensus_hash: Option<[u8; 32]>, + pub agg_nonce_consensus_signature: Option, pub partial_sigs: Option, pub committee_evm_address: Option, pub endorse_signature: Vec, @@ -4330,7 +4401,6 @@ pub async fn generate_instance( Ok(Instance { instance_id: params.instance_id, - is_bridge_in: true, network: get_network().to_string(), from_addr, to_addr: EvmAddress::from(¶ms.user_info.depositor_evm_address).to_string(), @@ -4350,26 +4420,49 @@ pub async fn generate_instance( pegin_data_tx_hash: "".to_string(), btc_height: 0, parameters: None, - escrow_hash: None, - bridge_out_lock_time: 0, post_pegin_txhash: None, - bridge_out_amount: "0".to_string(), status_updated_at: params.pegin_timestamp, created_at: current_time, updated_at: current_time, }) } +/// Store the pegin request against the local instance row. +/// +/// Returns: +/// - Ok(true) if the instance was created, or refreshed while still in a +/// pegin-request stage +/// - Ok(false) if the request was not applied because the instance has moved +/// past the pegin-request stage, or the id belongs to a bridge-out instance pub async fn store_pegin_request( btc_client: &BTCClient, local_db: &LocalDB, params: GenerateInstanceParams, -) -> Result<()> { +) -> Result { // store instance info to local db + let instance_id = params.instance_id; let mut storage_processor = local_db.acquire().await?; let instance = generate_instance(btc_client, params).await?; - storage_processor.upsert_instance(&instance).await?; - Ok(()) + // A PeginRequest only initializes an instance. Guarding the write on the + // pre-pegin statuses keeps a replayed or forged request from rolling a live + // instance back to UserInited and clearing what later stages stored. + let stored = storage_processor + .upsert_pegin_request_instance( + &instance, + &[ + InstanceBridgeInStatus::UserIniting.to_string(), + InstanceBridgeInStatus::UserInited.to_string(), + ], + ) + .await?; + if !stored { + warn!( + "ignore PeginRequest for instance {instance_id}: it is not a bridge-in instance in a pegin-request stage" + ); + return Ok(false); + } + info!("stored PeginRequest for instance {instance_id}"); + Ok(true) } pub async fn store_instance_parameters( @@ -5088,6 +5181,75 @@ pub async fn get_committee_pub_nonces_for_graph( .filter_map(|(k, v)| v.committee_pub_nonce.as_ref().map(|nonce| (*k, nonce.clone()))) .collect::>()) } +pub async fn store_committee_agg_nonce_consensus_for_graph( + local_db: &LocalDB, + instance_id: Uuid, + graph_id: Uuid, + committee_pubkey: PublicKey, + consensus_hash: [u8; 32], + signature: SchnorrSignature, +) -> Result<()> { + let mut storage_processor = local_db.acquire().await?; + let (is_endorsed, mut process_data) = + find_pegin_graph_process_data(&mut storage_processor, graph_id).await?; + if let Some((existing_hash, existing_signature)) = process_data + .get(&committee_pubkey) + .and_then(|item| item.agg_nonce_consensus_hash.zip(item.agg_nonce_consensus_signature)) + && (existing_hash != consensus_hash || existing_signature != signature) + { + tracing::error!( + event = "committee_agg_nonce_consensus_conflict", + %instance_id, + %graph_id, + %committee_pubkey, + existing_consensus_hash = %hex::encode(existing_hash), + existing_signature = %existing_signature, + conflicting_consensus_hash = %hex::encode(consensus_hash), + conflicting_signature = %signature, + "detected conflicting committee agg nonce consensus signatures" + ); + bail!(SpecialError::InvalidGraph(format!( + "committee agg nonce consensus changed for graph {graph_id} and committee {committee_pubkey}" + ))); + } + process_data + .entry(committee_pubkey) + .and_modify(|item| { + item.agg_nonce_consensus_hash = Some(consensus_hash); + item.agg_nonce_consensus_signature = Some(signature); + }) + .or_insert_with(|| GraphProcessDataItem { + agg_nonce_consensus_hash: Some(consensus_hash), + agg_nonce_consensus_signature: Some(signature), + ..Default::default() + }); + upsert_pegin_graph_process_data( + &mut storage_processor, + graph_id, + instance_id, + is_endorsed, + &process_data, + ) + .await?; + Ok(()) +} +pub async fn get_committee_agg_nonce_consensus_for_graph( + local_db: &LocalDB, + _instance_id: Uuid, + graph_id: Uuid, +) -> Result> { + let mut storage_processor = local_db.acquire().await?; + let (_is_endorsed, process_data) = + find_pegin_graph_process_data(&mut storage_processor, graph_id).await?; + Ok(process_data + .iter() + .filter_map(|(pubkey, item)| { + item.agg_nonce_consensus_hash + .zip(item.agg_nonce_consensus_signature) + .map(|(consensus_hash, signature)| (*pubkey, consensus_hash, signature)) + }) + .collect()) +} pub async fn store_committee_partial_sigs_for_graph( local_db: &LocalDB, instance_id: Uuid, @@ -5408,8 +5570,7 @@ pub async fn store_committee_pub_nonce_for_instance( .and_modify(|v| v.pub_nonce = Some(pub_nonce.clone())) .or_insert_with(|| InstanceProcessDataItem { pub_nonce: Some(pub_nonce), - partial_sign: None, - endorse_signature: vec![], + ..Default::default() }); upsert_pegin_instance_process_data(&mut storage_processor, instance_id, &process_data).await?; Ok(()) @@ -5436,6 +5597,66 @@ pub async fn get_committee_pub_nonces_for_instance( .filter_map(|(k, v)| v.pub_nonce.as_ref().map(|pub_nonce| (*k, pub_nonce.clone()))) .collect()) } +pub async fn store_committee_agg_nonce_consensus_for_instance( + local_db: &LocalDB, + instance_id: Uuid, + committee_pubkey: PublicKey, + consensus_hash: [u8; 32], + signature: SchnorrSignature, +) -> Result<()> { + let mut storage_processor = local_db.acquire().await?; + let mut process_data = + find_pegin_instance_process_data(&mut storage_processor, instance_id).await?; + if let Some((existing_hash, existing_signature)) = process_data + .get(&committee_pubkey) + .and_then(|item| item.agg_nonce_consensus_hash.zip(item.agg_nonce_consensus_signature)) + && (existing_hash != consensus_hash || existing_signature != signature) + { + tracing::error!( + event = "committee_pegin_confirm_nonce_consensus_conflict", + %instance_id, + %committee_pubkey, + existing_consensus_hash = %hex::encode(existing_hash), + existing_signature = %existing_signature, + conflicting_consensus_hash = %hex::encode(consensus_hash), + conflicting_signature = %signature, + "detected conflicting committee PeginConfirm nonce consensus signatures" + ); + bail!(SpecialError::InvalidPeginData(format!( + "committee PeginConfirm nonce consensus changed for instance {instance_id} and committee {committee_pubkey}" + ))); + } + process_data + .entry(committee_pubkey) + .and_modify(|item| { + item.agg_nonce_consensus_hash = Some(consensus_hash); + item.agg_nonce_consensus_signature = Some(signature); + }) + .or_insert_with(|| InstanceProcessDataItem { + agg_nonce_consensus_hash: Some(consensus_hash), + agg_nonce_consensus_signature: Some(signature), + ..Default::default() + }); + upsert_pegin_instance_process_data(&mut storage_processor, instance_id, &process_data).await?; + Ok(()) +} + +pub async fn get_committee_agg_nonce_consensus_for_instance( + local_db: &LocalDB, + instance_id: Uuid, +) -> Result> { + let mut storage_processor = local_db.acquire().await?; + let process_data = + find_pegin_instance_process_data(&mut storage_processor, instance_id).await?; + Ok(process_data + .iter() + .filter_map(|(pubkey, item)| { + item.agg_nonce_consensus_hash + .zip(item.agg_nonce_consensus_signature) + .map(|(consensus_hash, signature)| (*pubkey, consensus_hash, signature)) + }) + .collect()) +} pub async fn store_committee_partial_sig_for_instance( local_db: &LocalDB, instance_id: Uuid, @@ -5457,9 +5678,8 @@ pub async fn store_committee_partial_sig_for_instance( .entry(committee_pubkey) .and_modify(|v| v.partial_sign = Some(partial_sigs)) .or_insert_with(|| InstanceProcessDataItem { - pub_nonce: None, partial_sign: Some(partial_sigs), - endorse_signature: vec![], + ..Default::default() }); upsert_pegin_instance_process_data(&mut storage_processor, instance_id, &process_data).await?; Ok(()) @@ -5511,9 +5731,8 @@ pub async fn store_committee_endorse_sig_for_pegin( .entry(committee_pubkey) .and_modify(|v| v.endorse_signature = endorse_sig.clone()) .or_insert_with(|| InstanceProcessDataItem { - pub_nonce: None, - partial_sign: None, endorse_signature: endorse_sig, + ..Default::default() }); upsert_pegin_instance_process_data(&mut storage_processor, instance_id, &process_data).await?; Ok(()) @@ -5757,21 +5976,6 @@ pub async fn check_bridge_in_uxto_available_or_self_spent( Ok(true) } -pub(super) async fn find_instances_by_escrow_hash<'a>( - storage_processor: &mut StorageProcessor<'a>, - escrow_hash: &str, -) -> anyhow::Result> { - let (instances, size) = storage_processor - .find_instances( - InstanceQuery::default() - .with_is_bridge_in(false) - .with_escrow_hash(escrow_hash.to_string()) - .with_order("escrow_hash, created_at ASC".to_string()), - ) - .await?; - if size > 0 { Ok(Some(instances[0].clone())) } else { Ok(None) } -} - /// Computes the graph constant committed by the Operator guest and Connector D. pub fn get_guest_constant_value( graph_id: Uuid, @@ -5902,8 +6106,8 @@ pub(crate) async fn pending_graph_belongs_to_operator( pub(crate) async fn save_soldering_proof_payload( instance_id: Uuid, graph_id: Uuid, - verifier_index: usize, - opened: &[(usize, u64)], + candidate_index: usize, + opened: &[(usize, Seed)], finalized: &[FinalizedInstanceData], soldering: &SolderingData, ) -> Result { @@ -5917,7 +6121,7 @@ pub(crate) async fn save_soldering_proof_payload( &store_base_path, instance_id, graph_id, - verifier_index, + candidate_index, &payload_hash, )?; let store_mode = if is_soldering_proof_s3_path(&store_base_path) { "s3" } else { "local" }; @@ -5943,7 +6147,7 @@ pub(crate) async fn save_soldering_proof_payload( payload_path = %payload_path, "saved compact soldering proof to payload store" ); - Ok(SolderingProofReady { instance_id, graph_id, verifier_index, payload_hash, total_len }) + Ok(SolderingProofReady { instance_id, graph_id, candidate_index, payload_hash, total_len }) } fn load_babe_setup_state_from_path(path: &Path) -> Result> { @@ -6107,7 +6311,7 @@ pub(crate) async fn cleanup_babe_setup_states( payload_store, instance_id, graph_id, - ready.verifier_index, + ready.candidate_index, &ready.payload_hash, )?; delete_soldering_proof_store_payload(&payload_path).await?; @@ -6129,6 +6333,10 @@ pub struct BabeSetupState { #[derive(Clone, Serialize, Deserialize)] pub struct VerifierBabeSetupState { pub verifier_pubkey: PublicKey, + /// Authenticated operator identity that initiated this setup. + pub operator_pubkey: PublicKey, + /// Signed operator P2P identity used for directed setup replies and ACKs. + pub operator_peer_id: Vec, pub setup_package: CACSetupPackage, pub private_state: BabeVerifierPrivateState, pub finalized_indices: Vec, @@ -6140,7 +6348,8 @@ pub struct OperatorVerifierCandidate { pub verifier_peer_id: Vec, pub verifier_pubkey: PublicKey, pub setup_package: CACSetupPackage, - pub verifier_index: Option, + /// Immutable slot within the candidate pool and proof payload store. + pub candidate_index: Option, pub selected_circuit_indexes: Vec, pub gc_data: Option, pub soldering_proof_ready: Option, @@ -6148,9 +6357,17 @@ pub struct OperatorVerifierCandidate { #[derive(Clone, Serialize, Deserialize)] pub struct OperatorBabeSetupState { - pub frozen_verifier_pubkeys: Option>, + /// Candidate pool closed before CutCircuits. This is not the final graph + /// verifier set; `selected_verifier_pubkeys` is set after proof validation. + pub candidate_verifier_pubkeys: Option>, pub candidates: Vec, #[serde(default)] + pub candidate_collection_started_at: Option, + #[serde(default)] + pub proof_collection_started_at: Option, + #[serde(default)] + pub selected_verifier_pubkeys: Option>, + #[serde(default)] pub asserted_operator_proof: Option>, } @@ -6344,3 +6561,83 @@ mod commit_pubin_tests { assert_eq!(&bitmap[1..], &[0u8; 31]); } } + +#[cfg(test)] +mod node_info_tests { + use super::*; + + /// One field mutation applied to an otherwise valid payload. + type NodeInfoMutation = fn(&mut NodeInfo); + + fn valid_node_info(peer_id: &str) -> NodeInfo { + NodeInfo { + peer_id: peer_id.to_string(), + actor: Actor::Operator.to_string(), + goat_addr: "0x1234567890abcdef1234567890abcdef12345678".to_string(), + btc_pub_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .to_string(), + socket_addr: "127.0.0.1:8080".to_string(), + node_name: "zkm".to_string(), + service_fee_rate: 0.001, + available_peg_btc: "1000".to_string(), + } + } + + #[test] + fn node_info_is_accepted_only_for_its_own_sender() { + let sender = PeerId::random(); + let other = PeerId::random(); + let node_info = valid_node_info(&sender.to_string()); + + assert!(node_info_matches_sender(&node_info, &sender)); + assert!(!node_info_matches_sender(&node_info, &other)); + } + + #[test] + fn valid_node_info_payload_is_accepted() { + let node_info = valid_node_info(&PeerId::random().to_string()); + assert!(validate_node_info_payload(&node_info).is_ok()); + } + + #[test] + fn optional_node_info_fields_may_be_empty() { + let mut node_info = valid_node_info(&PeerId::random().to_string()); + node_info.goat_addr = String::new(); + node_info.btc_pub_key = String::new(); + node_info.socket_addr = String::new(); + node_info.available_peg_btc = String::new(); + node_info.node_name = String::new(); + assert!(validate_node_info_payload(&node_info).is_ok()); + } + + #[test] + fn malformed_node_info_fields_are_rejected() { + let peer_id = PeerId::random().to_string(); + let cases: Vec<(&str, NodeInfoMutation)> = vec![ + ("peer_id", |n| n.peer_id = "not-a-peer-id".to_string()), + ("actor", |n| n.actor = "Auditor".to_string()), + ("goat_addr", |n| n.goat_addr = "0xdeadbeef".to_string()), + ("btc_pub_key", |n| n.btc_pub_key = "02zz".to_string()), + ("available_peg_btc", |n| n.available_peg_btc = "lots".to_string()), + ("service_fee_rate NaN", |n| n.service_fee_rate = f64::NAN), + ("service_fee_rate range", |n| n.service_fee_rate = 42.0), + ("node_name length", |n| n.node_name = "a".repeat(MAX_NODE_NAME_LEN + 1)), + ("node_name control", |n| n.node_name = "a\nb".to_string()), + ("socket_addr length", |n| { + n.socket_addr = format!("{}:8080", "a".repeat(MAX_SOCKET_ADDR_LEN)) + }), + ("socket_addr port", |n| n.socket_addr = "host".to_string()), + ("socket_addr host", |n| n.socket_addr = ":8080".to_string()), + ("socket_addr control", |n| n.socket_addr = "127.0.0.1 :8080".to_string()), + ]; + + for (label, mutate) in cases { + let mut node_info = valid_node_info(&peer_id); + mutate(&mut node_info); + assert!( + validate_node_info_payload(&node_info).is_err(), + "expected {label} to be rejected" + ); + } + } +} diff --git a/node/tla/KickoffScanCoverage.cfg b/node/tla/KickoffScanCoverage.cfg index c4382ae34..ba58ad643 100644 --- a/node/tla/KickoffScanCoverage.cfg +++ b/node/tla/KickoffScanCoverage.cfg @@ -1,7 +1,7 @@ \* ORIGINAL #431 @ f2f0285e: detect_kickoff watches only the lowest-nonce \* graph per operator (no chain walk). ScanDepth=0. Operator has 3 graphs. \* Expected to FAIL: kicking nonce 1 or 2 escapes coverage. -CONSTANTS NumGraphs = 3 ScanDepth = 0 +CONSTANTS NumGraphs = 3 ScanDepth = 0 ScanAllPending = FALSE SPECIFICATION Spec INVARIANT TypeOK INVARIANT KickoffAlwaysCovered diff --git a/node/tla/KickoffScanCoverage.tla b/node/tla/KickoffScanCoverage.tla index 3e1467ba5..4c784abd0 100644 --- a/node/tla/KickoffScanCoverage.tla +++ b/node/tla/KickoffScanCoverage.tla @@ -1,11 +1,11 @@ ---- MODULE KickoffScanCoverage ---- -(***************************************************************************) -(* Model of GOATNetwork/bitvm-node issue #431: *) -(* "detect_kickoff watches only the lowest-nonce graph per operator, so *) +(************************************************************************** *) +(* Model of GOATNetwork/bitvm-node issue #431: *) +(* "detect_kickoff watches only the lowest-nonce graph per operator, so *) (* a kickoff on a later graph is never Challenged (unauthorized Take1)". *) -(* *) -(* THE ORIGINAL BUG (gc-v2 @ f2f0285e): detect_kickoff sourced its graphs *) -(* from fetch_on_turn_graph_by_status, which keeps ONE row per *) +(* *) +(* THE ORIGINAL BUG (gc-v2 @ f2f0285e): detect_kickoff sourced its graphs *) +(* from fetch_on_turn_graph_by_status, which keeps ONE row per *) (* operator_pubkey - the lowest kickoff_index still at OperatorDataPushed *) (* (SQL: ORDER BY operator_pubkey, kickoff_index; Rust keeps the first per *) (* operator). An operator with two posted graphs leaves nonce 0 idle and *) @@ -13,46 +13,64 @@ (* KickoffSent / Challenge is ever created for it, and after the ConnectorA *) (* CSV the operator Take1s with pegBTC never burned. Distinct from #429 *) (* (there the message exists but the verifier skips it on SPV lag). *) -(* *) +(* *) (* THE FIX (commit 2bce25d, "Fix graph maintenance logic" #451, on current *) (* dev): detect_kickoff now runs scan_kickoff_chain from each root, which *) (* walks confirmed_prekickoff_successor forward - following each graph's *) (* on-chain-confirmed next_prekickoff to the successor (validated *) (* kickoff_index == prev+1) - so the idle lowest-nonce decoy no longer *) (* hides a kicked successor. This closes the filed 2-graph attack. *) -(* *) -(* THE RESIDUAL: the walk is capped at MAX_PREKICKOFF_SUCCESSORS_PER_SCAN *) -(* = 32 (graph_maintenance_tasks.rs). The perpetually-idle root (never *) -(* kicked, so never advancing out of OperatorDataPushed) means the scan *) -(* window never slides; a chain of > 32 idle decoys with the kicked graph *) -(* beyond depth 32 is never reached on any tick. Expensive (33+ confirmed *) -(* on-chain prekickoffs, capital-locked) but structurally open. *) -(* *) +(* *) +(* THE RESIDUAL (of #451): the walk is capped at *) +(* MAX_PREKICKOFF_SUCCESSORS_PER_SCAN = 32 (graph_maintenance_tasks.rs). *) +(* With a single fixed root per operator, a chain of > 32 idle decoys with *) +(* the kicked graph beyond depth 32 is not reached WITHIN ONE TICK. In the *) +(* real node the window does slide across ticks - each PreKickoffSent the *) +(* walk enqueues moves its graph to PreKickoff (already an entry) and *) +(* force-skips the one before it - but only if verifiers keep handling *) +(* those messages; single-tick coverage depended on that liveness. *) +(* *) +(* STATUS: FIXED - detect_kickoff now takes EVERY OperatorDataPushed / *) +(* PreKickoff graph as a scan entry (fetch_all_graphs_by_status) and *) +(* checks each entry's own kickoff directly. The chain walk (still capped *) +(* at 32) only propagates PreKickoffSent; coverage no longer depends on *) +(* walk depth, the root, or message handling. Modeled by ScanAllPending. *) +(* *) (* This spec is a COVERAGE abstraction (same static-Init idiom as *) (* Take2DisproveRace.tla): the operator kicks graph `kicked`, keeping the *) (* root idle; detect_kickoff covers exactly the confirmed chain reachable *) -(* from the root within ScanDepth. Property: the kicked graph is covered *) -(* (hence Challenged). ScanDepth=0 models the original no-walk selection; *) -(* ScanDepth>=NumGraphs-1 models the fix with an adequate depth budget; *) -(* 0=NumGraphs-1 models #451 with an adequate depth budget; *) +(* 0= NumGraphs-1 = shipped fix with adequate budget (real MAX = 32) - \* 0 < d < NumGraphs-1 = depth-limit residual + NumGraphs, \* graphs this operator has posted (all OperatorDataPushed, nonce 0..NumGraphs-1) + ScanDepth, \* how many confirmed prekickoff successors a walk follows from its entry + \* 0 = no chain walk + \* >= NumGraphs-1 = adequate budget for this chain (real MAX = 32) + \* 0 < d < NumGraphs-1 = depth-limit residual + ScanAllPending \* FALSE = only the lowest-nonce root is a scan entry (original / #451) + \* TRUE = every pending graph is a scan entry (current code) + +ASSUME ScanAllPending \in BOOLEAN Graphs == 0 .. (NumGraphs - 1) -\* The operator keeps the lowest-nonce graph idle as the decoy; it is the -\* single root detect_kickoff selects per operator (fetch_first_graph_per_ -\* operator_by_status). Because it is never kicked it never leaves -\* OperatorDataPushed, so the scan window never advances past it. +\* The operator keeps the lowest-nonce graph idle as the decoy. Under the +\* original / #451 selection (fetch_first_graph_per_operator_by_status) it is +\* the single root detect_kickoff selects per operator; because it is never +\* kicked it never leaves OperatorDataPushed within the modeled tick. Root == 0 +\* Graphs from which detect_kickoff starts a walk this tick. +Entries == IF ScanAllPending THEN Graphs ELSE {Root} + VARIABLE kicked \* the graph whose kickoff the operator broadcasts (no L2 initWithdraw) vars == <> @@ -61,11 +79,14 @@ TypeOK == kicked \in Graphs \* To broadcast graph `kicked`'s kickoff the operator must have confirmed the \* prekickoff chain Root..kicked on Bitcoin (each successor's prekickoff spends \* the prior next_prekickoff). scan_kickoff_chain therefore CAN follow that -\* confirmed chain from Root - but only up to ScanDepth successors deep. A -\* graph g is covered (its kickoff observed -> KickoffSent -> Challenge) iff -\* the walk reaches it: it lies on the confirmed chain (g <= kicked) and within -\* the depth budget (g <= ScanDepth). -WalkedSet == { g \in Graphs : g <= kicked /\ g <= ScanDepth } +\* confirmed chain from any entry - but only up to ScanDepth successors deep. +\* A graph g is covered (its kickoff observed -> KickoffSent -> Challenge) iff +\* some walk reaches it: it lies on the confirmed chain (g <= kicked) and is +\* within the depth budget of some entry at or below it. With the root as the +\* only entry this is g <= ScanDepth; with every graph an entry, g itself is +\* an entry at distance 0. +WalkedSet == { g \in Graphs : + g <= kicked /\ \E e \in Entries : e <= g /\ g - e <= ScanDepth } Init == kicked \in Graphs Next == UNCHANGED vars \* exhaustive over the Init choice of `kicked` @@ -74,7 +95,8 @@ Spec == Init /\ [][Next]_vars -------------------------------------------------------------------------- \* Safety: every unauthorized kickoff is covered by detect_kickoff (so a \* Challenge can fire before the operator's uncontested Take1). -\* kicked \in WalkedSet <=> kicked <= ScanDepth. +\* Root-only entries: kicked \in WalkedSet <=> kicked <= ScanDepth. +\* All-pending entries: always true (kicked is its own entry). KickoffAlwaysCovered == kicked \in WalkedSet ==== diff --git a/node/tla/KickoffScanCoverageAllEntries.cfg b/node/tla/KickoffScanCoverageAllEntries.cfg new file mode 100644 index 000000000..8d1179356 --- /dev/null +++ b/node/tla/KickoffScanCoverageAllEntries.cfg @@ -0,0 +1,9 @@ +\* CURRENT CODE (#431 residual fix) : every OperatorDataPushed / PreKickoff +\* graph is a scan entry, so each graph's kickoff is checked directly. +\* ScanDepth = 0 deliberately disables the chain walk in the model: coverage +\* must hold without any walk, so the real 32-step cap cannot matter. +\* Expected to PASS for every choice of the kicked graph. +CONSTANTS NumGraphs = 4 ScanDepth = 0 ScanAllPending = TRUE +SPECIFICATION Spec +INVARIANT TypeOK +INVARIANT KickoffAlwaysCovered diff --git a/node/tla/KickoffScanCoverageFixed.cfg b/node/tla/KickoffScanCoverageFixed.cfg index 7299a8b01..d21eb5a4d 100644 --- a/node/tla/KickoffScanCoverageFixed.cfg +++ b/node/tla/KickoffScanCoverageFixed.cfg @@ -1,8 +1,8 @@ -\* SHIPPED FIX (commit 2bce25d / #451): scan_kickoff_chain walks confirmed -\* prekickoff successors from the root. Modeled with a depth budget covering -\* the whole chain (real cap MAX_PREKICKOFF_SUCCESSORS_PER_SCAN=32). -\* Expected to PASS: every kicked graph on the confirmed chain is covered. -CONSTANTS NumGraphs = 4 ScanDepth = 3 +\* #451 FIX (commit 2bce25d), root-only entry: scan_kickoff_chain walks +\* confirmed prekickoff successors from the root. Modeled with a depth budget +\* covering the whole chain (real cap MAX_PREKICKOFF_SUCCESSORS_PER_SCAN=32). +\* Expected to PASS: every kicked graph within the depth budget is covered. +CONSTANTS NumGraphs = 4 ScanDepth = 3 ScanAllPending = FALSE SPECIFICATION Spec INVARIANT TypeOK INVARIANT KickoffAlwaysCovered diff --git a/node/tla/KickoffScanCoverageResidual.cfg b/node/tla/KickoffScanCoverageResidual.cfg index 47e535fe7..bc23def10 100644 --- a/node/tla/KickoffScanCoverageResidual.cfg +++ b/node/tla/KickoffScanCoverageResidual.cfg @@ -1,7 +1,10 @@ -\* DEPTH-LIMIT RESIDUAL: a chain of idle decoys deeper than the scan cap. -\* ScanDepth=2 with 4 graphs (real analogue: >32 decoys, kick beyond depth 32). -\* Expected to FAIL: kicking the graph beyond the depth budget still escapes. -CONSTANTS NumGraphs = 4 ScanDepth = 2 +\* DEPTH-LIMIT RESIDUAL of #451 (root-only entry): a chain of idle decoys +\* deeper than the walk cap. ScanDepth=2 with 4 graphs (real analogue: >32 +\* decoys, kick beyond depth 32). Expected to FAIL: kicking the graph beyond +\* the depth budget escapes the single walk from the root. Superseded by the +\* all-pending-entries scan (see KickoffScanCoverageAllEntries.cfg); frozen +\* as the regression record of the root-only design. +CONSTANTS NumGraphs = 4 ScanDepth = 2 ScanAllPending = FALSE SPECIFICATION Spec INVARIANT TypeOK INVARIANT KickoffAlwaysCovered diff --git a/node/tla/VerifierKickoffFailOpen.cfg b/node/tla/VerifierKickoffFailOpen.cfg index c8f239c04..95520600b 100644 --- a/node/tla/VerifierKickoffFailOpen.cfg +++ b/node/tla/VerifierKickoffFailOpen.cfg @@ -1,7 +1,9 @@ -\* Models the CURRENT gc-v2 code (issue #429): the verifier consumes the -\* KickoffSent message and skips the Challenge when GOAT SPV lags the kickoff -\* height, with no defer/retry - expected to FAIL (fail-open). This is a live -\* bug, not a historical artifact. +\* ORIGINAL #429 @ c15f1714: the verifier consumes the KickoffSent message +\* and skips the Challenge when GOAT SPV lags the kickoff height, with no +\* defer/retry - expected to FAIL (fail-open). Fixed by #455 (978f4201): +\* handle_kickoff_sent_verifier now defers via +\* push_local_unhandled_messages_with_reason(GoatSpvPending). Frozen as a +\* regression record; see VerifierKickoffFailOpenFixed.cfg for the fix design. SPECIFICATION FairSpec CHECK_DEADLOCK FALSE INVARIANT TypeOK diff --git a/node/tla/VerifierKickoffFailOpen.tla b/node/tla/VerifierKickoffFailOpen.tla index 30bdcf9a5..fe324b240 100644 --- a/node/tla/VerifierKickoffFailOpen.tla +++ b/node/tla/VerifierKickoffFailOpen.tla @@ -4,15 +4,22 @@ (* "[Security] Verifier KickoffSent challenge is one-shot and fail-open *) (* (unauthorized Take1)" - checked on gc-v2 @ f2f0285e. *) (* *) -(* BRANCH STATUS: dev (gc-v2) IS the default/shipping branch and it *) -(* CONTAINS this bug - verified live at handle.rs handle_kickoff_sent_ *) -(* verifier (the goat_confirmed_btc_height SPV-lag branch returns Ok(()) *) -(* with no push_local_unhandled; should_always_challenge is never called; *) -(* detect_kickoff only scans OperatorDataPushed). The stale origin/HEAD *) -(* points at main (d74bf3c, 218 commits behind), whose older Actor:: *) -(* Challenger KickoffSent handler defers via save_unhandle_message and *) -(* calls should_challenge - i.e. main lacks this code path; it is a *) -(* gc-v2 regression that is live on the branch actually shipped. *) +(* STATUS: FIXED on dev by #455 (978f4201) - handle_kickoff_sent_verifier *) +(* now defers the SPV-lag case via push_local_unhandled_messages_with_ *) +(* reason(GoatSpvPending), mirroring the committee handler (the design *) +(* checked by VerifierKickoffFailOpenFixed.cfg). This module is frozen as *) +(* the regression record of the ORIGINAL bug, described below as it stood *) +(* at c15f1714. *) +(* *) +(* ORIGINAL BRANCH STATUS: dev (gc-v2) was the default/shipping branch and *) +(* CONTAINED this bug - verified at handle.rs handle_kickoff_sent_verifier *) +(* (the goat_confirmed_btc_height SPV-lag branch returned Ok(()) with no *) +(* push_local_unhandled; should_always_challenge was never called; *) +(* detect_kickoff only scanned OperatorDataPushed). The stale origin/HEAD *) +(* points at main (d74bf3c, 218 commits behind), whose older Actor:: *) +(* Challenger KickoffSent handler defers via save_unhandle_message and *) +(* calls should_challenge - i.e. main lacks this code path; it was a *) +(* gc-v2 regression on the branch actually shipped. *) (* *) (* Threat: an operator broadcasts a kickoff with NO L2 initWithdraw (no *) (* pegBTC burned). The automated defense is the verifier's *) diff --git a/proof-builder-rpc/README.md b/proof-builder-rpc/README.md index b2c1eb0fb..b7f43bc14 100644 --- a/proof-builder-rpc/README.md +++ b/proof-builder-rpc/README.md @@ -52,6 +52,23 @@ To enable multiple Gateways without interrupting proof polling, first upgrade Pr single Gateway, then upgrade all Nodes to send `gateway_address`, and finally configure the comma-separated Gateway list. +## Metrics + +Prometheus metrics use a dedicated listener and are disabled unless `--metrics-addr` is set. The +business RPC listener does not serve metrics, so `GET /metrics` on `--rpc-addr` returns `404`. + +```bash +proof-builder-rpc \ + --rpc-addr 0.0.0.0:7777 \ + --metrics-addr 10.42.0.8:9109 \ + --config proof-builder.toml +``` + +The dedicated listener serves only `/metrics`. Bind it to a private interface and allow only the +Prometheus source network to reach it. The listener does not provide TLS or authentication and +should not be exposed directly to the public internet. An unavailable metrics port causes startup +to fail. + ## Failure recovery Long-running proof tasks (stored in the `long_running_task_proof` table) — such as header-chain, commit-chain, and state-chain proofs — can be recovered from the database. Recovery notes: diff --git a/proof-builder-rpc/src/api/mod.rs b/proof-builder-rpc/src/api/mod.rs index 1758e4d2d..262116d1f 100644 --- a/proof-builder-rpc/src/api/mod.rs +++ b/proof-builder-rpc/src/api/mod.rs @@ -12,6 +12,7 @@ use crate::api::proof_handler::{ post_watchtower_proof_task, update_operator_proof_task_timeout, update_watchtower_proof_task_timeout, }; +use anyhow::Context; use axum::http::{Method, StatusCode}; use axum::routing::{get, post}; use axum::{Router, middleware}; @@ -23,15 +24,15 @@ use tower_http::cors::{Any, CorsLayer}; pub(crate) use auth::{AuthorizationChain, AuthorizationChains}; -struct ApiState { +pub(crate) struct ApiState { pub local_db: LocalDB, pub metrics_state: ApiMetricsState, - pub auth: RequestAuthorizer, + auth: RequestAuthorizer, } impl ApiState { /// Creates shared API state from the database, metrics, and live authorization chain. - fn new( + pub(crate) fn new( local_db: LocalDB, metrics_state: ApiMetricsState, authorization_chains: AuthorizationChains, @@ -43,14 +44,13 @@ impl ApiState { }) } } -pub(crate) async fn serve( + +/// Serves the business RPC routes at `addr` with shared state until cancellation. +pub(crate) async fn serve_with_app_state( addr: String, - local_db: LocalDB, - metrics_state: ApiMetricsState, - authorization_chains: AuthorizationChains, + api_state: Arc, cancellation_token: CancellationToken, ) -> anyhow::Result { - let api_state = ApiState::new(local_db, metrics_state, authorization_chains); let instrumented_routes = Router::new() .route(routes::ROOT, get(root)) .route(routes::v1::PROOFS_CHAIN_PROOFS_DESC, get(get_chain_proof_task_desc)) @@ -64,9 +64,7 @@ pub(crate) async fn serve( .route(routes::v1::PROOFS_OPERATOR_PROOF_DESC, get(get_operator_proof_task_desc)) .fallback(|| async { StatusCode::NOT_FOUND }) .layer(middleware::from_fn_with_state(api_state.clone(), metrics_middleware)); - let server = Router::new() - .route(routes::METRICS, get(metrics_handler)) - .merge(instrumented_routes) + let server = instrumented_routes .layer(CorsLayer::new().allow_headers(Any).allow_origin(Any).allow_methods(vec![ Method::GET, Method::POST, @@ -93,6 +91,45 @@ pub(crate) async fn serve( } } } + +/// Binds the dedicated Prometheus metrics listener before background tasks start. +pub(crate) async fn bind_metrics_listener(addr: &str) -> anyhow::Result { + TcpListener::bind(addr) + .await + .with_context(|| format!("failed to bind metrics listener to {addr}")) +} + +/// Serves only `/metrics` from the pre-bound listener with the shared API state. +pub(crate) async fn serve_metrics( + listener: TcpListener, + api_state: Arc, + cancellation_token: CancellationToken, +) -> anyhow::Result { + let router = Router::new().route(routes::METRICS, get(metrics_handler)).with_state(api_state); + let listening_addr = + listener.local_addr().context("failed to determine metrics listener address")?; + tracing::info!( + address = %listening_addr, + path = routes::METRICS, + "Metrics listener started" + ); + + tokio::select! { + result = axum::serve(listener, router) => { + match result { + Ok(_) => Ok("Metrics server finished normally".to_string()), + Err(e) => { + tracing::error!("Metrics server error: {}", e); + Err(anyhow::anyhow!("Metrics server error: {e}")) + } + } + } + _ = cancellation_token.cancelled() => { + tracing::info!("Metrics service received shutdown signal"); + Ok("metrics_shutdown".to_string()) + } + } +} async fn root() -> &'static str { "Hello, World!" } @@ -178,32 +215,59 @@ mod tests { std::collections::HashMap::from([(gateway, chain)]) } + async fn spawn_metrics_listener( + api_state: Arc, + cancellation_token: CancellationToken, + ) -> anyhow::Result<(String, tokio::task::JoinHandle>)> { + let listener = bind_metrics_listener("127.0.0.1:0").await?; + let addr = listener.local_addr()?.to_string(); + let handle = tokio::spawn(serve_metrics(listener, api_state, cancellation_token)); + Ok((addr, handle)) + } + #[tokio::test] - async fn metrics_use_route_templates_and_exclude_scrapes() -> anyhow::Result<()> { + async fn metrics_are_served_only_by_the_dedicated_listener() -> anyhow::Result<()> { let addr = available_addr(); let cancellation_token = CancellationToken::new(); - let server_token = cancellation_token.clone(); - let server = tokio::spawn(serve( - addr.clone(), + let api_state = ApiState::new( store::create_local_db("sqlite::memory:").await, ApiMetricsState::new(), authorization_chains(gateway(1), Arc::new(TestAuthorizationChain::default())), - server_token, + ); + let server = tokio::spawn(serve_with_app_state( + addr.clone(), + api_state.clone(), + cancellation_token.clone(), )); + let (metrics_addr, metrics_server) = + spawn_metrics_listener(api_state, cancellation_token.clone()).await?; tokio::time::sleep(std::time::Duration::from_millis(100)).await; assert!(get(&addr, "/").await?.starts_with("HTTP/1.1 200")); assert!(get(&addr, "/missing").await?.starts_with("HTTP/1.1 404")); - let first_scrape = get(&addr, "/metrics").await?; - let second_scrape = get(&addr, "/metrics").await?; + assert!(get(&addr, "/metrics").await?.starts_with("HTTP/1.1 404")); + assert!(get(&metrics_addr, "/").await?.starts_with("HTTP/1.1 404")); + assert!( + get(&metrics_addr, routes::v1::PROOFS_CHAIN_PROOFS_DESC) + .await? + .starts_with("HTTP/1.1 404") + ); + let first_scrape = get(&metrics_addr, "/metrics").await?; + let second_scrape = get(&metrics_addr, "/metrics").await?; + assert!(first_scrape.starts_with("HTTP/1.1 200")); + assert!( + first_scrape + .to_ascii_lowercase() + .contains("content-type: application/openmetrics-text;charset=utf-8;version=1.0.0") + ); assert!( first_scrape .contains("http_requests_total{method=\"GET\",route=\"/\",status=\"200\"} 1") ); assert!( first_scrape.contains( - "http_requests_total{method=\"GET\",route=\"unmatched\",status=\"404\"} 1" + "http_requests_total{method=\"GET\",route=\"unmatched\",status=\"404\"} 2" ) ); assert!(first_scrape.contains("http_requests_in_flight 0")); @@ -212,6 +276,19 @@ mod tests { cancellation_token.cancel(); server.await??; + assert_eq!(metrics_server.await??, "metrics_shutdown"); + Ok(()) + } + + #[tokio::test] + async fn metrics_listener_reports_bind_conflicts() -> anyhow::Result<()> { + let occupied = std::net::TcpListener::bind("127.0.0.1:0")?; + let addr = occupied.local_addr()?.to_string(); + let error = bind_metrics_listener(&addr).await.unwrap_err(); + assert!( + error.to_string().contains(&format!("failed to bind metrics listener to {addr}")), + "unexpected error: {error}" + ); Ok(()) } @@ -233,14 +310,13 @@ mod tests { let gateway_address = gateway(1); let addr = available_addr(); let cancellation_token = CancellationToken::new(); - let server_token = cancellation_token.clone(); - let server = tokio::spawn(serve( - addr.clone(), + let api_state = ApiState::new( store::create_local_db("sqlite::memory:").await, ApiMetricsState::new(), authorization_chains(gateway_address, authorization_chain), - server_token, - )); + ); + let server = + tokio::spawn(serve_with_app_state(addr.clone(), api_state, cancellation_token.clone())); tokio::time::sleep(std::time::Duration::from_millis(100)).await; let operator_submit = OperatorProofRequest { @@ -418,14 +494,18 @@ mod tests { std::collections::HashMap::from([(gateway_a, chain_a), (gateway_b, chain_b)]); let addr = available_addr(); let cancellation_token = CancellationToken::new(); - let server_token = cancellation_token.clone(); - let server = tokio::spawn(serve( - addr.clone(), + let api_state = ApiState::new( store::create_local_db("sqlite::memory:").await, ApiMetricsState::new(), authorization_chains, - server_token, + ); + let server = tokio::spawn(serve_with_app_state( + addr.clone(), + api_state.clone(), + cancellation_token.clone(), )); + let (metrics_addr, metrics_server) = + spawn_metrics_listener(api_state, cancellation_token.clone()).await?; tokio::time::sleep(std::time::Duration::from_millis(100)).await; for (gateway_address, expected_status) in [ @@ -453,12 +533,13 @@ mod tests { assert!(response.starts_with(&format!("HTTP/1.1 {expected_status}"))); } - let metrics = get(&addr, "/metrics").await?; + let metrics = get(&metrics_addr, "/metrics").await?; assert!(metrics.contains("operation=\"operator_timeout\",result=\"invalid\"")); assert!(metrics.contains("operation=\"operator_timeout\",result=\"unauthorized\"")); cancellation_token.cancel(); server.await??; + metrics_server.await??; Ok(()) } @@ -471,14 +552,18 @@ mod tests { let gateway_address = gateway(1); let addr = available_addr(); let cancellation_token = CancellationToken::new(); - let server_token = cancellation_token.clone(); - let server = tokio::spawn(serve( - addr.clone(), + let api_state = ApiState::new( store::create_local_db("sqlite::memory:").await, ApiMetricsState::new(), authorization_chains(gateway_address, authorization_chain), - server_token, + ); + let server = tokio::spawn(serve_with_app_state( + addr.clone(), + api_state.clone(), + cancellation_token.clone(), )); + let (metrics_addr, metrics_server) = + spawn_metrics_listener(api_state, cancellation_token.clone()).await?; tokio::time::sleep(std::time::Duration::from_millis(100)).await; let request = OperatorProofTimeoutUpdateRequest { @@ -498,11 +583,12 @@ mod tests { post(&addr, routes::v1::PROOFS_OPERATOR_PROOF_TIMEOUT, &body, Some(&auth)).await?; assert!(response.starts_with("HTTP/1.1 503")); - let metrics = get(&addr, "/metrics").await?; + let metrics = get(&metrics_addr, "/metrics").await?; assert!(metrics.contains("operation=\"operator_timeout\",result=\"unavailable\"")); cancellation_token.cancel(); server.await??; + metrics_server.await??; Ok(()) } } diff --git a/proof-builder-rpc/src/main.rs b/proof-builder-rpc/src/main.rs index e76972d79..0e7fd814a 100644 --- a/proof-builder-rpc/src/main.rs +++ b/proof-builder-rpc/src/main.rs @@ -27,6 +27,12 @@ struct Opts { #[arg(long, default_value = "0.0.0.0:7777")] pub rpc_addr: String, + /// Dedicated Prometheus metrics listener address. + /// + /// Unset disables metrics; the business RPC never serves them. + #[arg(long)] + pub metrics_addr: Option, + /// Local Sqlite database file path #[arg(long, env, default_value = "sqlite:/tmp/bitvm-node.db")] pub database_url: String, @@ -44,28 +50,29 @@ async fn main() -> anyhow::Result<()> { println!("proof builder config: {:?}", cfg); let _ = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).try_init(); + let metrics_listener = match opt.metrics_addr.as_deref() { + Some(metrics_addr) => Some(api::bind_metrics_listener(metrics_addr).await?), + None => None, + }; let authorization_chains = goat_clients_from_env().await?; // Create cancellation token for graceful shutdown let cancellation_token = CancellationToken::new(); info!("load db {}", opt.database_url); let local_db = store::create_local_db(&opt.database_url).await; let metrics_state = ApiMetricsState::new(); - let local_db_clone1 = local_db.clone(); - let api_metrics_state = metrics_state.clone(); + let api_state = + api::ApiState::new(local_db.clone(), metrics_state.clone(), authorization_chains); let mut task_handles: Vec>> = vec![]; let cancel_token_clone = cancellation_token.clone(); let opt_rpc_addr = opt.rpc_addr.clone(); - info!("start api server"); + let rpc_api_state = api_state.clone(); + info!( + rpc_addr = %opt.rpc_addr, + metrics_addr = opt.metrics_addr.as_deref().unwrap_or("disabled"), + "start api server" + ); task_handles.push(tokio::spawn(async move { - match api::serve( - opt_rpc_addr, - local_db_clone1, - api_metrics_state, - authorization_chains, - cancel_token_clone, - ) - .await - { + match api::serve_with_app_state(opt_rpc_addr, rpc_api_state, cancel_token_clone).await { Ok(tag) => Ok(tag), Err(e) => { tracing::error!("RPC service error: {}", e); @@ -73,6 +80,18 @@ async fn main() -> anyhow::Result<()> { } } })); + if let Some(listener) = metrics_listener { + let cancel_token_clone = cancellation_token.clone(); + task_handles.push(tokio::spawn(async move { + match api::serve_metrics(listener, api_state, cancel_token_clone).await { + Ok(tag) => Ok(tag), + Err(e) => { + tracing::error!("Metrics service error: {}", e); + Err("metrics_error".to_string()) + } + } + })); + } if is_start_generate_proof_tasks(&cfg) { info!("start generate proof tasks"); let cancel_token_clone = cancellation_token.clone();