Skip to content
Merged
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 .github/actions/set-monero-env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ runs:
echo "DEPS_BUILD_LINUX=autoconf nsis mingw-w64 build-essential pkg-config libtool ccache make cmake gcc g++ git curl lbzip2 gperf g++-mingw-w64-x86-64" >> $GITHUB_ENV

# Build tooling (macos)
echo "DEPS_BUILD_MACOS=autoconf automake pkg-config libtool ccache make cmake gcc git curl lbzip2 gperf" >> $GITHUB_ENV
echo "DEPS_BUILD_MACOS=autoconf automake pkg-config libtool ccache make cmake gcc git curl gperf" >> $GITHUB_ENV

# APT configuration for better reliability
echo 'APT_SET_CONF_COMMAND<<EOF
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
- ASB: Fix a bug in the algorithm used to the select the Bitcoin output used for the swap.
- ASB+GUI: Fix a bug in the cross curve equality proof cryptography.

## [4.12.0] - 2026-07-18

Expand Down
3 changes: 3 additions & 0 deletions monero-sys/src/bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,9 @@ using StringVec = std::vector<String>;
using MoneroSysAccountTagsPair = std::pair<StringMap, StringVec>;
void monero_sys_account_tags_pair_dtor(MoneroSysAccountTagsPair* p) asm("_ZNSt6__ndk14pairINS_3mapINS_12basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEES7_NS_4lessIS7_EENS5_INS0_IKS7_S7_EEEEEENS_6vectorIS7_NS5_IS7_EEEEED1Ev");
__attribute__((weak)) void monero_sys_account_tags_pair_dtor(MoneroSysAccountTagsPair* p) { p->~MoneroSysAccountTagsPair(); }
#else
// The following is a hack to ensure the linker includes the pair destructor in the binary
static std::pair<StringMap, StringVec> _monero_sys_pair_instantiation;
#endif

namespace Monero
Expand Down
104 changes: 77 additions & 27 deletions swap-core/src/bitcoin/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,37 +54,36 @@ impl TxLock {
B: PublicKey,
btc: Amount,
) -> Result<Self> {
let shared_output_candidate = match psbt.unsigned_tx.output.as_slice() {
[shared_output_candidate, _] if shared_output_candidate.value == btc => {
shared_output_candidate
}
[_, shared_output_candidate] if shared_output_candidate.value == btc => {
shared_output_candidate
}
// A single output is possible if Bob funds without any change necessary
[shared_output_candidate] if shared_output_candidate.value == btc => {
shared_output_candidate
}
[_, _] => {
bail!("Neither of the two provided outputs pays the right amount!");
}
[_] => {
bail!("The provided output does not pay the right amount!");
}
other => {
let num_outputs = other.len();
bail!(
"PSBT has {} outputs, expected one or two. Something is fishy!",
num_outputs
);
}
};
if psbt.unsigned_tx.output.len() > 2 {
bail!(
"PSBT has {} outputs, expected at most two",
psbt.unsigned_tx.output.len()
);
}

let descriptor = build_shared_output_descriptor(A.0, B.0)?;
let legit_shared_output_script = descriptor.script_pubkey();

if shared_output_candidate.script_pubkey != legit_shared_output_script {
bail!("Output script is not a shared output")
let shared_outputs = psbt
.unsigned_tx
.output
.iter()
.filter(|output| output.script_pubkey == legit_shared_output_script)
.collect::<Vec<_>>();

let [shared_output] = shared_outputs.as_slice() else {
bail!(
"PSBT must have exactly one output paying to the shared descriptor, found {}",
shared_outputs.len()
);
};

if shared_output.value != btc {
bail!(
"Shared output pays {} but the agreed amount is {}",
shared_output.value,
btc
);
}

Ok(TxLock {
Expand Down Expand Up @@ -253,6 +252,57 @@ mod tests {
result.expect_err("PSBT to be invalid");
}

#[tokio::test]
async fn given_two_outputs_pay_to_shared_descriptor_then_reconstructing_txlock_fails() {
let (A, B, wallet) = setup().await;
let agreed_amount = Amount::from_sat(10000);
let spending_fee = Amount::from_sat(1000);

let mut psbt = bob_make_psbt(A, B, &wallet, agreed_amount, spending_fee).await;

let descriptor = build_shared_output_descriptor(A.0, B.0).unwrap();
let dust_shared_output = TxOut {
value: Amount::from_sat(1),
script_pubkey: descriptor.script_pubkey(),
};
psbt.unsigned_tx.output.insert(0, dust_shared_output);

let result = TxLock::from_psbt(psbt, A, B, agreed_amount);

result.expect_err("PSBT with two shared outputs must be rejected");
}

#[test]
fn given_more_than_two_outputs_when_reconstructing_txlock_then_fails() {
let (A, B) = alice_and_bob();
let agreed_amount = Amount::from_sat(10000);
let descriptor = build_shared_output_descriptor(A.0, B.0).unwrap();
let psbt = Psbt::from_unsigned_tx(Transaction {
version: bitcoin::transaction::Version(2),
lock_time: PackedLockTime::ZERO,
input: vec![],
output: vec![
TxOut {
value: agreed_amount,
script_pubkey: descriptor.script_pubkey(),
},
TxOut {
value: Amount::from_sat(1),
script_pubkey: ScriptBuf::new(),
},
TxOut {
value: Amount::from_sat(1),
script_pubkey: ScriptBuf::new(),
},
],
})
.unwrap();

let result = TxLock::from_psbt(psbt, A, B, agreed_amount);

result.expect_err("PSBT with more than two outputs must be rejected");
}

#[tokio::test]
async fn given_bob_is_sending_to_a_bad_output_reconstructing_txlock_then_fails() {
let (A, B, wallet) = setup().await;
Expand Down
101 changes: 98 additions & 3 deletions swap-machine/src/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use async_trait::async_trait;
use libp2p::{Multiaddr, PeerId};
use rust_decimal::prelude::FromPrimitive;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use sha2::{Digest, Sha256};
use sigma_fun::HashTranscript;
use sigma_fun::ext::dl_secp256k1_ed25519_eq::{CrossCurveDLEQ, CrossCurveDLEQProof};
use std::convert::TryInto;
Expand All @@ -16,12 +16,30 @@ use swap_core::bitcoin;
use swap_core::monero::{self, MoneroAddressPool};
use uuid::Uuid;

/// BIP-341 NUMS point `H = lift_x(SHA256(uncompressed_encoding(G)))`: <https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs>
static PEDERSEN_BLINDING_H_SECP256K1: LazyLock<ecdsa_fun::fun::Point> = LazyLock::new(|| {
let generator = (*ecdsa_fun::fun::G).normalize().to_bytes_uncompressed();
let x_coordinate = Sha256::digest(generator).into();

ecdsa_fun::fun::Point::<ecdsa_fun::fun::marker::EvenY>::from_xonly_bytes(x_coordinate)
.expect("SHA-256 of the uncompressed secp256k1 generator is a valid x-coordinate")
.normalize()
});

fn pedersen_blinding_h_ed25519() -> curve25519_dalek_ng::edwards::EdwardsPoint {
curve25519_dalek_ng::edwards::CompressedEdwardsY::from_slice(
&monero_oxide_wallet::ed25519::CompressedPoint::H.to_bytes(),
)
.decompress()
.expect("Monero Pedersen H is a valid ed25519 point")
}

pub static CROSS_CURVE_PROOF_SYSTEM: LazyLock<
CrossCurveDLEQ<HashTranscript<Sha256, rand_chacha::ChaCha20Rng>>,
> = LazyLock::new(|| {
CrossCurveDLEQ::<HashTranscript<Sha256, rand_chacha::ChaCha20Rng>>::new(
(*ecdsa_fun::fun::G).normalize(),
curve25519_dalek_ng::constants::ED25519_BASEPOINT_POINT,
*PEDERSEN_BLINDING_H_SECP256K1,
pedersen_blinding_h_ed25519(),
)
});

Expand Down Expand Up @@ -346,6 +364,83 @@ pub trait Database {
mod tests {
use super::*;
use bitcoin_wallet::{MIN_ABSOLUTE_TX_FEE, MIN_ABSOLUTE_TX_FEE_SATS};
use rand::SeedableRng;

#[test]
fn pedersen_blinding_generators_are_not_the_curve_generators() {
let _force_init = &*CROSS_CURVE_PROOF_SYSTEM;

let secp_generator = (*ecdsa_fun::fun::G).normalize();
let ed25519_generator = curve25519_dalek_ng::constants::ED25519_BASEPOINT_POINT;
let blinding_h_ed25519 = pedersen_blinding_h_ed25519();

assert_ne!(
*PEDERSEN_BLINDING_H_SECP256K1, secp_generator,
"secp256k1 Pedersen blinding generator must differ from the curve generator"
);
assert_ne!(
blinding_h_ed25519, ed25519_generator,
"ed25519 Pedersen blinding generator must differ from the curve generator"
);
}

#[test]
fn secp256k1_pedersen_blinding_generator_matches_bip341() {
const BIP341_NUMS_X_COORDINATE: [u8; 32] = [
0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9,
0x7a, 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a,
0xce, 0x80, 0x3a, 0xc0,
];

assert_eq!(
PEDERSEN_BLINDING_H_SECP256K1.coordinates().0,
BIP341_NUMS_X_COORDINATE
);
assert!(PEDERSEN_BLINDING_H_SECP256K1.is_y_even());
}

#[test]
fn honest_cross_curve_proof_verifies() {
use curve25519_dalek_ng::scalar::Scalar;

let mut rng = rand_chacha::ChaCha20Rng::from_seed([7u8; 32]);
let secret = clamp_to_252_bits(Scalar::random(&mut rng));

let (proof, claim) = CROSS_CURVE_PROOF_SYSTEM.prove(&secret, &mut rng);

assert!(
CROSS_CURVE_PROOF_SYSTEM.verify(&proof, claim),
"an honestly generated cross-curve proof must verify"
);
}

#[test]
fn cross_curve_proof_does_not_verify_against_mismatched_ed25519_key() {
use curve25519_dalek_ng::constants::ED25519_BASEPOINT_TABLE;
use curve25519_dalek_ng::scalar::Scalar;

let mut rng = rand_chacha::ChaCha20Rng::from_seed([9u8; 32]);
let secret = clamp_to_252_bits(Scalar::random(&mut rng));

let (proof, (claim_secp, _claim_ed25519)) =
CROSS_CURVE_PROOF_SYSTEM.prove(&secret, &mut rng);

let unrelated_ed25519 =
&clamp_to_252_bits(Scalar::random(&mut rng)) * &ED25519_BASEPOINT_TABLE;

assert!(
!CROSS_CURVE_PROOF_SYSTEM.verify(&proof, (claim_secp, unrelated_ed25519)),
"a proof must not verify when the ed25519 key has a different discrete log"
);
}

fn clamp_to_252_bits(
scalar: curve25519_dalek_ng::scalar::Scalar,
) -> curve25519_dalek_ng::scalar::Scalar {
let mut bytes = scalar.to_bytes();
bytes[31] &= 0b0000_1111;
curve25519_dalek_ng::scalar::Scalar::from_bytes_mod_order(bytes)
}

/// 1 BTC lock amount.
const LOCK: bitcoin::Amount = bitcoin::Amount::from_sat(100_000_000);
Expand Down
Loading