Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/commands/pegin-request.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Send a pegin request to GoatChain via the `pegin-request` binary.
- `prepare` - Build, sign and broadcast the pegin deposit tx on Bitcoin
- `request-prepare` - Request then auto-prepare after waiting ~12 minutes
- `cancel` - Build, sign and broadcast the pegin refund tx on Bitcoin
- **network**: Bitcoin network (`bitcoin`, `testnet`, `testnet4`, `signet`, `regtest`). Default: `testnet4`
- **network**: Bitcoin network (`bitcoin`, `testnet4`, `signet`, `regtest`). Default: `BITCOIN_NETWORK`, or `testnet4`. Must match `BITCOIN_NETWORK` when both are set
- **esplora_url**: Esplora API URL. Default: `https://mempool.space/testnet4/api`
- For `request` / `request-prepare`:
- **pegin_amount_sats**: Amount in satoshis. Default: `1000000` (0.01 BTC)
Expand Down
90 changes: 77 additions & 13 deletions crates/bitvm-gc/src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,56 @@ use rand::{RngCore, rngs::OsRng};
use secp256k1::schnorr::Signature as SchnorrSignature;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{fs, io::Write, path::Path};
use uuid::Uuid;

/// Writes `contents` to `path` so that after a crash the file is either complete or absent.
/// The bytes go to a 0600 temporary file in the same directory, which is synced, renamed
/// over `path`, and the directory is synced so the rename itself survives power loss.
/// The instance key is answered on chain right after this returns; it is the only copy.
fn write_file_durably(path: &Path, contents: &[u8]) -> anyhow::Result<()> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.with_context(|| format!("envelope path has no directory: {}", path.display()))?;
fs::create_dir_all(parent)
.with_context(|| format!("create envelope directory failed: {}", parent.display()))?;
let file_name = path
.file_name()
.with_context(|| format!("envelope path has no file name: {}", path.display()))?;
let mut temp_name = file_name.to_os_string();
temp_name.push(".tmp");
let temp_path = parent.join(temp_name);

let result = (|| {
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&temp_path)
.with_context(|| format!("create envelope file failed: {}", temp_path.display()))?;
// `mode` only applies to a new file; a leftover temporary file keeps its old mode.
file.set_permissions(fs::Permissions::from_mode(0o600))
.with_context(|| format!("set envelope permissions failed: {}", temp_path.display()))?;
file.write_all(contents)
.with_context(|| format!("write envelope failed: {}", temp_path.display()))?;
file.sync_all()
.with_context(|| format!("sync envelope failed: {}", temp_path.display()))?;
fs::rename(&temp_path, path)
.with_context(|| format!("rename envelope failed: {}", path.display()))?;
fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.with_context(|| format!("sync envelope directory failed: {}", parent.display()))
})();
if result.is_err() {
let _ = fs::remove_file(&temp_path);
}
result
}

pub type OperatorAssertWotsSecretKey = OperatorAssertSecretKey;
pub type OperatorAssertWotsPublicKey = OperatorAssertPublicKey;
pub type OperatorAssertWotsKeypair = (OperatorAssertWotsSecretKey, OperatorAssertWotsPublicKey);
Expand Down Expand Up @@ -248,18 +293,7 @@ impl CommitteeMasterKey {

let serialized = serde_json::to_vec_pretty(&envelope)
.context("serialize committee instance key envelope")?;
if let Some(parent) = envelope_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("create envelope directory failed: {}", parent.display())
})?;
}
let mut file = fs::File::create(envelope_path)
.with_context(|| format!("create envelope file failed: {}", envelope_path.display()))?;
file.write_all(&serialized)
.with_context(|| format!("write envelope failed: {}", envelope_path.display()))?;
fs::set_permissions(envelope_path, fs::Permissions::from_mode(0o600)).with_context(
|| format!("set envelope permissions failed: {}", envelope_path.display()),
)?;
write_file_durably(envelope_path, &serialized)?;
Ok(committee_pubkey)
}

Expand Down Expand Up @@ -372,6 +406,13 @@ impl CommitteeMasterKey {
Ok(self.nonces_for_graph_context_with_keypair(context, signer_keypair))
}

/// The nonces are derived from the key and the signed context, so the node needs no
/// nonce state of its own. That is safe only while the record of what it signed is never
/// rolled back: a MuSig2 partial signature also depends on the other signers' nonces, so
/// after a rollback a co-signer can answer the same context with a new nonce, and this node
/// would sign again with the same secret nonce. Two partial signatures under one nonce reveal
/// the key. Keep the database and the key envelopes on storage that is not restored from
/// older snapshots.
fn nonces_for_graph_context_with_keypair(
&self,
context: CommitteeGraphNonceContext,
Expand Down Expand Up @@ -531,6 +572,29 @@ mod tests {
assert!(!envelope_path.exists(), "envelope file should be removed");
}

#[test]
fn committee_instance_key_envelope_is_written_whole_with_owner_only_access() {
let instance_id = Uuid::new_v4();
let master = CommitteeMasterKey::new(test_master_keypair("seed:test-committee-master"));
let directory = std::env::temp_dir().join(format!("bitvm-gc-envelope-dir-{instance_id}"));
let envelope_path = directory.join("nested").join(format!("{instance_id}.json"));
let temp_path = envelope_path.with_file_name(format!("{instance_id}.json.tmp"));
// A temporary file left by an interrupted write is replaced, not trusted.
fs::create_dir_all(temp_path.parent().unwrap()).unwrap();
fs::write(&temp_path, b"partial").unwrap();
fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o644)).unwrap();

let created = master.create_instance_keypair_envelope(instance_id, &envelope_path).unwrap();
assert!(!temp_path.exists(), "the temporary file is renamed into place");
let mode = fs::metadata(&envelope_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let loaded: PublicKey =
master.load_instance_keypair(instance_id, &envelope_path).unwrap().public_key().into();
assert_eq!(created, loaded);

fs::remove_dir_all(&directory).unwrap();
}

#[test]
fn committee_instance_key_envelope_load_mismatch_instance_id_should_fail() {
let instance_id = Uuid::new_v4();
Expand Down
4 changes: 4 additions & 0 deletions deployment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ cargo build -r -p bitvm-noded --bin bitvm-noded
Bitcoin and GOAT accounts.
3. Choose the proof service and peer network shared by the participants.
4. Give each node isolated persistent state and distinct listening addresses.
A Committee node keeps its instance keys in `cache/committee-instance-keys`
next to its database file, or in `COMMITTEE_INSTANCE_KEYS_DIR`. Keep that
directory on the same persistent storage as the database, and never restore
either from an older snapshot.
5. Start the nodes and confirm they are ready before initiating bridge activity.

All participants must agree on the graph parameters, Verifier slot count, and
Expand Down
25 changes: 9 additions & 16 deletions node/src/bin/send_pegin_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

use alloy::primitives::Address as EvmAddress;
use alloy::providers::{Provider, ProviderBuilder};
use anyhow::{Result, anyhow};
use anyhow::{Result, anyhow, bail};
use bitcoin::hashes::Hash;
use bitcoin::sighash::EcdsaSighashType;
use bitcoin::{Address, Amount, key::Keypair};
Expand All @@ -43,7 +43,7 @@ use tracing_subscriber::EnvFilter;

use bitvm_noded::env::{
ENV_BITCOIN_NETWORK, ENV_BITVM_SECRET, ENV_GOAT_ADDRESS, get_goat_network,
get_node_goat_address, goat_config_from_env,
get_node_goat_address, goat_config_from_env, parse_bitcoin_network,
};
use bitvm_noded::utils::{
broadcast_tx, get_fee_rate, get_proper_utxo_set, node_p2wsh_address, node_sign,
Expand Down Expand Up @@ -148,20 +148,13 @@ async fn main() -> Result<()> {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
let args = Args::parse();
let network = args.network.as_str();
let network = match network {
"bitcoin" => Network::Bitcoin,
"testnet" => Network::Testnet,
"testnet4" => Network::Testnet4,
"signet" => Network::Signet,
"regtest" => Network::Regtest,
_ => {
tracing::warn!(
"Unknown BTC network: {network}, expect bitcoin, testnet, signet or regtest, return testnet by default"
);
Network::Testnet4
}
};
let network = parse_bitcoin_network(&args.network)?;
// Addresses are also checked by node code that reads BITCOIN_NETWORK; both must agree.
if let Ok(configured) = std::env::var(ENV_BITCOIN_NETWORK)
&& parse_bitcoin_network(&configured)? != network
{
bail!("--network {} disagrees with {ENV_BITCOIN_NETWORK}={configured}", args.network);
}
let btc_client = BTCClient::new(network, Some(&args.esplora_url));

let goat_client = {
Expand Down
Loading
Loading