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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ search:
- Functions `prepare_r0_groth16_proof()` and `prepare_r0_succinct_witness()` exposed to Python. Convert a RISC Zero receipt into the proof / witness pushes for composing sig scripts by hand.
- Exception `ZkError` added to `kaspa.exceptions`, raised by the ZK bindings.
- Example under `examples/zk/` demonstrating a fully on-chain Groth16 commit→redeem round-trip.
- Function `compute_sighash()` exposed to Python. Computes the signature hash (sighash) for a transaction input.

### Fixed
- `requires-python` upper bound changed from `<=3.14` to `<3.15`. Under PEP 440 version ordering `<=3.14` excludes every 3.14 patch release (`3.14.1` and later).
Expand Down
31 changes: 31 additions & 0 deletions docs/learn/transactions/signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,37 @@ The same method exists on [`PendingTransaction`](../../reference/Classes/Pending
([`pending.create_input_signature(...)`](../../reference/Classes/PendingTransaction.md)); write the resulting script
back with [`pending.fill_input(...)`](../../reference/Classes/PendingTransaction.md).

## Compute a sighash without signing

When the key isn't available in-process — an external or hardware
signer, multisig digest distribution, or verifying a signature
against the exact digest the node checks — use
[`compute_sighash`](../../reference/Functions/compute_sighash.md) to
get the signature hash for an input without signing it:

```python
from kaspa import compute_sighash, sign_script_hash

digest = compute_sighash(tx, input_index=0) # Hash, SighashType.All
sig_hex = sign_script_hash(digest.to_hex(), private_key)
```

Every input must carry its UTXO entry — the sighash commits to each
input's script public key and amount. Schnorr is the default; pass
`ecdsa=True` for inputs locked to ECDSA addresses (an extra hash
round over the Schnorr digest).

[`sign_script_hash`](../../reference/Functions/sign_script_hash.md)
always signs Schnorr and appends the `SighashType.All` hashtype
byte, so it only composes with digests computed with the defaults
shown above. For any other `sighash_type`, or for a digest computed
with `ecdsa=True`, sign the digest with your external signer and
assemble the signature script yourself: the signature followed by
the hashtype byte matching the digest. The blob from
`sign_script_hash` is a complete signature script for a single-key
(P2PK) input; for script-hash lockups, wrap it with
`pay_to_script_hash_signature_script`.

## Multisig and sig_op_count

Two fields interact with mass when you sign:
Expand Down
32 changes: 32 additions & 0 deletions python/kaspa/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -5147,6 +5147,38 @@ def calculate_transaction_mass(network_id: NetworkId, tx: Transaction, minimum_s
Exception: If mass calculation fails.
"""

def compute_sighash(tx: Transaction, input_index: builtins.int, sighash_type: str | SighashType | None = SighashType.All, ecdsa: builtins.bool = False) -> Hash:
r"""
Compute the signature hash (sighash) for a specific transaction input.

This mirrors the digest the node computes when validating a signature for
the input, without signing it. Useful for external/HSM signers, multisig
assembly, or verifying a signature against a precomputed digest. With the
defaults (Schnorr, `SighashType.All`) the resulting hash can be signed
with `sign_script_hash`. For any other `sighash_type`, or with
`ecdsa=True`, sign the digest externally and assemble the signature
script yourself — the signature followed by the hashtype byte matching
the digest — because `sign_script_hash` always signs Schnorr and appends
the All hashtype byte.

Every transaction input must have an attached UTXO entry, as the sighash
commits to each input's script public key and amount.

Args:
tx: The transaction containing the input.
input_index: The index of the input to compute the sighash for.
sighash_type: The signature hash type (default: All).
ecdsa: Compute the ECDSA variant of the sighash instead of Schnorr
(an additional hash round over the Schnorr digest).

Returns:
Hash: The signature hash for the input.

Raises:
Exception: If the input index is out of bounds or the transaction's
inputs are missing UTXO entries.
"""

def covenant_id(outpoint: TransactionOutpoint, auth_outputs: typing.Sequence[TransactionOutput]) -> Hash:
r"""
Compute the covenant id for a set of authorizing outputs.
Expand Down
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ fn kaspa(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
wallet::core::tx::signer::py_create_input_signature,
m
)?)?;
m.add_function(wrap_pyfunction!(
wallet::core::tx::signer::py_compute_sighash,
m
)?)?;
m.add_function(wrap_pyfunction!(
wallet::core::tx::signer::py_sign_script_hash,
m
Expand Down
80 changes: 79 additions & 1 deletion src/wallet/core/tx/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ use crate::{
};
use kaspa_consensus_client::{Transaction, sign_with_multiple_v3};
use kaspa_consensus_core::{
hashing::{sighash_type::SIG_HASH_ALL, wasm::SighashType},
hashing::{
sighash::{
SigHashReusedValuesUnsync, calc_ecdsa_signature_hash, calc_schnorr_signature_hash,
},
sighash_type::SIG_HASH_ALL,
wasm::SighashType,
},
sign::{sign_input, verify},
tx::PopulatedTransaction,
};
Expand Down Expand Up @@ -92,6 +98,78 @@ pub fn py_create_input_signature(
Ok(signature.to_hex())
}

/// Compute the signature hash (sighash) for a specific transaction input.
///
/// This mirrors the digest the node computes when validating a signature for
/// the input, without signing it. Useful for external/HSM signers, multisig
/// assembly, or verifying a signature against a precomputed digest. With the
/// defaults (Schnorr, `SighashType.All`) the resulting hash can be signed
/// with `sign_script_hash`. For any other `sighash_type`, or with
/// `ecdsa=True`, sign the digest externally and assemble the signature
/// script yourself — the signature followed by the hashtype byte matching
/// the digest — because `sign_script_hash` always signs Schnorr and appends
/// the All hashtype byte.
///
/// Every transaction input must have an attached UTXO entry, as the sighash
/// commits to each input's script public key and amount.
///
/// Args:
/// tx: The transaction containing the input.
/// input_index: The index of the input to compute the sighash for.
/// sighash_type: The signature hash type (default: All).
/// ecdsa: Compute the ECDSA variant of the sighash instead of Schnorr
/// (an additional hash round over the Schnorr digest).
///
/// Returns:
/// Hash: The signature hash for the input.
///
/// Raises:
/// Exception: If the input index is out of bounds or the transaction's
/// inputs are missing UTXO entries.
#[gen_stub_pyfunction]
#[pyfunction]
#[pyo3(name = "compute_sighash")]
#[pyo3(signature = (tx, input_index, sighash_type=None, ecdsa=false))]
pub fn py_compute_sighash(
tx: &PyTransaction,
input_index: usize,
#[gen_stub(override_type(type_repr = "str | SighashType | None = SighashType.All"))]
sighash_type: Option<PySighashType>,
ecdsa: bool,
) -> PyResult<PyHash> {
let (cctx, utxos) = tx
.inner()
.tx_and_utxos()
.map_err(|err| PyException::new_err(err.to_string()))?;
if input_index >= cctx.inputs.len() {
return Err(PyException::new_err(format!(
"Input index {input_index} out of bounds for transaction with {} inputs",
cctx.inputs.len()
)));
}
let populated_transaction = PopulatedTransaction::new(&cctx, utxos);

let sighash_type: SighashType = sighash_type.unwrap_or(PySighashType::All).into();
let reused_values = SigHashReusedValuesUnsync::new();

let hash = if ecdsa {
calc_ecdsa_signature_hash(
&populated_transaction,
input_index,
sighash_type.into(),
&reused_values,
)
} else {
calc_schnorr_signature_hash(
&populated_transaction,
input_index,
sighash_type.into(),
&reused_values,
)
};
Ok(hash.into())
}

/// Sign a script hash with a private key.
///
/// Args:
Expand Down
115 changes: 115 additions & 0 deletions tests/unit/test_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@
Generator,
PaymentOutput,
Hash,
pay_to_address_script,
sign_transaction,
compute_sighash,
create_input_signature,
sign_script_hash,
create_transaction,
create_transactions,
estimate_transactions,
Expand Down Expand Up @@ -236,6 +239,118 @@ def test_sighash_type_exists(self):
assert SighashType is not None


class TestComputeSighash:
"""Tests for compute_sighash."""

PRIVATE_KEY_HEX = "b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef"
PREV_TX_ID = "880eb9819a31821d9d2399e2f35e2433b72637e393d71ecc9b8d0250f49153c3"

def _build_tx(self, signature_script=b"", with_utxo=True, amount=100_000_000):
"""Build a single-input P2PK transaction spending a synthetic UTXO."""
private_key = PrivateKey(self.PRIVATE_KEY_HEX)
address = private_key.to_address("mainnet")
spk = pay_to_address_script(address)

outpoint = TransactionOutpoint(Hash(self.PREV_TX_ID), 0)
if with_utxo:
from kaspa import UtxoEntryReference

utxo_ref = UtxoEntryReference.from_dict({
"address": address.to_string(),
"outpoint": {"transactionId": self.PREV_TX_ID, "index": 0},
"utxoEntry": {
"amount": amount,
"scriptPublicKey": {"version": 0, "script": spk.script},
"blockDaaScore": 0,
"isCoinbase": False,
"covenantId": None,
},
})
input = TransactionInput(outpoint, signature_script, 0, 1, utxo=utxo_ref)
else:
input = TransactionInput(outpoint, signature_script, 0, 1)
output = TransactionOutput(amount - 10_000, spk)
return Transaction(0, [input], [output], 0, "0" * 40, 0, "", 0)

def test_compute_sighash_deterministic(self):
"""Test compute_sighash returns a deterministic 32-byte Hash."""
tx = self._build_tx()
sighash = compute_sighash(tx, 0)

assert isinstance(sighash, Hash)
assert len(sighash.to_hex()) == 64
assert sighash.to_hex() == compute_sighash(tx, 0).to_hex()

def test_compute_sighash_default_type_is_all(self):
"""Test the default sighash type is All, accepting enum or string."""
tx = self._build_tx()
default = compute_sighash(tx, 0).to_hex()

assert compute_sighash(tx, 0, SighashType.All).to_hex() == default
assert compute_sighash(tx, 0, "all").to_hex() == default

def test_compute_sighash_types_differ(self):
"""Test different sighash types produce different digests."""
tx = self._build_tx()
digests = {
compute_sighash(tx, 0, sighash_type).to_hex()
for sighash_type in ["all", "none", "single"]
}
assert len(digests) == 3

def test_compute_sighash_ecdsa_differs(self):
"""Test the ECDSA digest differs from the Schnorr digest."""
tx = self._build_tx()
schnorr = compute_sighash(tx, 0).to_hex()
ecdsa = compute_sighash(tx, 0, ecdsa=True).to_hex()
assert schnorr != ecdsa

def test_compute_sighash_input_index_out_of_bounds(self):
"""Test out-of-bounds input index raises."""
tx = self._build_tx()
with pytest.raises(Exception, match="out of bounds"):
compute_sighash(tx, 1)

def test_compute_sighash_missing_utxo_entry(self):
"""Test a transaction without UTXO entries raises."""
tx = self._build_tx(with_utxo=False)
with pytest.raises(Exception):
compute_sighash(tx, 0)

def test_compute_sighash_matches_node_verification(self):
"""Test the digest is the one the node verifies signatures against.

Sign the computed sighash externally with sign_script_hash, splice the
resulting signature blob into the input's signature script, and let
sign_transaction(verify_sig=True) run consensus-side verification
(which recomputes the sighash and checks the Schnorr signature).
"""
private_key = PrivateKey(self.PRIVATE_KEY_HEX)
tx_unsigned = self._build_tx()

sighash = compute_sighash(tx_unsigned, 0)
sig_blob = sign_script_hash(sighash.to_hex(), private_key)

tx_signed = self._build_tx(signature_script=bytes.fromhex(sig_blob))
# Raises if consensus-side signature verification fails
sign_transaction(tx_signed, [], True)

def test_compute_sighash_commits_to_amount(self):
"""Test a signature over a digest from different tx data fails verification."""
private_key = PrivateKey(self.PRIVATE_KEY_HEX)
tx_unsigned = self._build_tx()

sighash = compute_sighash(tx_unsigned, 0)
sig_blob = sign_script_hash(sighash.to_hex(), private_key)

# Same signature spliced into a tx with a different amount must not verify
tx_tampered = self._build_tx(
signature_script=bytes.fromhex(sig_blob), amount=200_000_000
)
with pytest.raises(Exception):
sign_transaction(tx_tampered, [], True)


class TestCreateTransaction:
"""Tests for create_transaction helper function."""
# TODO
Expand Down