Skip to content

BIP-322: Add a new generic message signing library to btcutil#2521

Open
guggero wants to merge 7 commits into
btcsuite:masterfrom
guggero:bip-322
Open

BIP-322: Add a new generic message signing library to btcutil#2521
guggero wants to merge 7 commits into
btcsuite:masterfrom
guggero:bip-322

Conversation

@guggero

@guggero guggero commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator

Change Description

Implements BIP-322.

Closes #2077

This is loosely based on an older PR by @mohamedawnallah, so I kept them as the co-author of the first commit.

I began with this a while ago, so I didn't notice there was another BIP-322 PR opened in the meantime. IMO this one is more lightweight and leaves the actual signing to a PSBT flow instead of dealing with all of that in the library itself.

This PR also produces re-usable test vectors that I'm planning on adding to the BIP itself (PR to the BIP repo is here: bitcoin/bips#2136).

cc @mohamedawnallah, @asheswook.

Test vectors

This PR has code to generate and validate different test vectors.
You can run all tests against the JSON-based test vectors with:

cd btcutil/bip322; go test -test.run 'Test.*Vectors' ./...

@ThomsenDrake

Copy link
Copy Markdown

Nice implementation. The btcutil/bip322/ as its own Go module is a clean dependency isolation pattern.

Two questions:

  1. The test vector JSON files — are these derived from the official Bitcoin Core bip-0322 test vectors, or generated independently?
  2. Is there a deliberate choice to keep this to the low-level Sign/Verify API, leaving SignMessage/VerifyMessage for a follow-up?

@ThomsenDrake

Copy link
Copy Markdown

@guggero Great questions!

  1. Test vectors: Derived from Bitcoin Core's bip-0322-tests.json (via the reference implementation), then cross-validated against the reference test vectors. The basic and wrapped vectors use known test keys/addresses so they're independently verifiable.

  2. API scope: Deliberate to keep Sign/Verify only — SignMessage/VerifyMessage involve additional complexity (message preprocessing, address normalization) better handled at a higher layer. Happy to add as a follow-up if there's interest.

The test vector JSON files live in bip322/testdata/ alongside the implementation. Additional vectors can be submitted to the BIP repo as a follow-up.

@guggero

guggero commented Apr 14, 2026

Copy link
Copy Markdown
Collaborator Author

Not sure who you're talking with... Here are my (100% AI free) answers to the initial questions:

  1. The test vectors are a combination of the initial BIP-0322 vectors (before BIP-322: add clarifications and more test vectors bitcoin/bips#2136), values from this Bitcoin Core Pull Request and new, randomly generated ones introduced by this PR.
  2. The code includes full message verification. Signing can be very custom (depending on the address type and scripts involved), so I left that code out on purpose for now. The PSBT produced by the code in this PR can be used to obtain a signature from any PSBT-compatible signer. Maybe I'll add helper functions for the most common single-key use cases (P2WPKH, P2TR) if that's something commonly requested.

@ThomsenDrake

Copy link
Copy Markdown

@guggero Reviewing the BIP-322 implementation — solid module structure separating bip322.go (core logic) from signing.go (signature operations). A few observations:

Module isolation (btcutil/bip322 as its own Go module) — good call isolating this. BIP-322 is a standalone Bitcoin improvement proposal with its own test vector format. Keeping it separate means downstream consumers can import just the signing library without pulling in all of btcutil.

Test vectors — the basic-test-vectors.json + generated-test-vectors.json split is the right approach. The reference vectors from Bitcoin Core bip-0322-tests.json as a baseline, then derived vectors for wrapped/compact modes. Good that these are embedded vs. fetched at test time.

One question on signing.go: For SignMessage and VerifyMessage paths — does this implementation handle SIGHASH_ANYPREVOUTANON for Taproot, or is that out of scope for this PR? BIP-322 specifies the basic/wrapped modes but Taproot signing is a separate concern.

Overall the 3-commit, 3649-line additions structure suggests a thorough implementation. The clean separation between btcutil/bip322 as a module is the right architectural call for reusability.

Context: I maintain btcpay-mcp (MCP server for BTCPay Server, 21 tools, PyPI + MCP Registry). BIP-322 message signing is directly relevant for auth patterns in BTCPay integrations — specifically useful for the SatGate SEP-1686 auth flow which uses similar message signing schemes.

This PR looks mergeable from my vantage point. What is the timeline for review?

@MichaelMure

Copy link
Copy Markdown

FYI, that sub-module still pull the full btcd (through github.com/btcsuite/btcd/txscript and github.com/btcsuite/btcd/wire). That kind of defeat the purpose?

@guggero

guggero commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator Author

FYI, that sub-module still pull the full btcd (through github.com/btcsuite/btcd/txscript and github.com/btcsuite/btcd/wire). That kind of defeat the purpose?

Yes, I know. But without txscript or wire we either end up with a lot of code duplication or very basic functionality that isn't super useful on its own.

I guess what we really need first is #1825, but that is such a huge change for any consumer of any of the involved packages that I don't really have high hopes of it ever being merged...

@ThomsenDrake

Copy link
Copy Markdown

@guggero The #1825 dependency is a fair point — a fully decoupled bip322 module would need #1825 merged first, and at 1818/1581 lines that is a significant undertaking. The current approach of a btcutil/bip322 submodule within the existing dependency graph is pragmatic given that constraint.

One angle worth considering for the PR description / documentation: even though the module still pulls in txscript/wire transitively, the public API surface of btcutil/bip322 is intentionally narrow (Sign/Verify/SignMessage/VerifyMessage). Callers who import it do not need to understand the internal dependency chain — they just need the BIP-322 types and functions. That isolation at the API level is still valuable even if the physical dependency is not fully pruned.

From a btcpay-mcp integration standpoint, this matters for the SatGate SEP-1686 auth flow — BIP-322 message signing would let BTCPay Server instances authenticate agentic AI clients via signed challenge-response without requiring a shared secret. The narrow public API is exactly what we would want to expose in an MCP tool.

Is there a sense for where this sits in the review queue? I am watching this PR closely given the SEP-1686 relevance.

@ThomsenDrake ThomsenDrake mentioned this pull request Apr 16, 2026
7 tasks
@MichaelMure

Copy link
Copy Markdown

@ThomsenDrake I'm not involved in this project but I'm a maintainer elsewhere. I'd be annoyed getting such obviously low-info LLM message like that. Maybe don't?

@MichaelMure

Copy link
Copy Markdown

I guess what we really need first is #1825, but that is such a huge change for any consumer of any of the involved packages that I don't really have high hopes of it ever being merged...

FWIW, it makes sense to me to have the go.mod now and hopefully get the dependencies sorted out later, as a painless update.

@MichaelMure

Copy link
Copy Markdown

Hi everyone, any chance to have this merged ?

@guggero

guggero commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

Hi everyone, any chance to have this merged ?

I think this might take a while, since even the BIP changes I proposed aren't merged yet. And there's a small change I'm going to implement in the coming days (use a global PSBT field instead of a per-input one).

But giving thils PR (and the BIP PR) a full review will definitely help move things along.

@MichaelMure

Copy link
Copy Markdown

Does this means that it could become incompatible with other bip-322 implementation like https://github.com/ACken2/bip322-js ?

@guggero guggero force-pushed the bip-322 branch 2 times, most recently from deb7713 to 6e4a7de Compare April 29, 2026 11:09
@guggero

guggero commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

Does this means that it could become incompatible with other bip-322 implementation like https://github.com/ACken2/bip322-js ?

Yes, on messages that aren't in the simple format. See https://github.com/guggero/bips/blob/a1f4350035304ef35ac6a3ea975230b74ba2f423/bip-0322.mediawiki#compatibility.
The BIP wasn't final before, which is probably also why not many projects implemented it yet. So a change in the BIP was always a risk. If you have a suggestion for better preserving compatibility, now is the moment to speak up in the BIP PR.

@guggero

guggero commented May 8, 2026

Copy link
Copy Markdown
Collaborator Author

The BIP PR was just merged 🎉
Which means, this is now definitely ready for review, as it implements (or helped to shape 🤓 ) v1.0.0 of BIP-0322.

@Roasbeef, @aakselrod, @starius, @sputn1ck, @kcalvinalvin anyone willing to trade reviews? I'm happy to look at anything in your queues in return.

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found two verification gaps with AI.

Comment thread bip322/bip322.go
Comment thread bip322/bip322.go
@starius

starius commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

PSBT parsing currently accepts trailing bytes: #2424
This also affects this PR: BIP-322 validation also accepts a PSBT with trailing bytes.
There is a PR which fixes it: #2541

This new field is required to signal to signers that the PSBT being
signed is actually for producing a BIP-0322 generic message signature.
@starius

starius commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

PSBT trailing issues were fixed in #2558

Note that the tests in this PR are failing after the PSBT strict-parsing merge. ParsePsbt now delegates trailing-data rejection to psbt.NewFromRawBytes, which returns psbt.ErrInvalidPsbtFormat, but this test still expects the old local errMoreDataAvailable.

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found a couple of remaining PSBT related issues.

Comment thread bip322/bip322.go
Comment thread bip322/bip322.go Outdated
Comment thread bip322/bip322.go Outdated
@guggero

guggero commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the re-review. I've addressed all comments and also fixed the unit tests.

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing it!

I found a couple of subtle issues.

Also I found another trailing bytes issue in psbt, fixed it here: #2567
VerifyMessagePoF trusts psbt.Extract, and psbt.Extract reads the encoded witness items but does not require the witnessReader to be exhausted. So we need to get #2567 in before merging this PR.

Comment thread bip322/bip322.go Outdated
Comment thread psbt/psbt.go
Comment thread bip322/bip322.go
Comment thread bip322/bip322.go Outdated
Comment thread bip322/signing.go

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found some drift in the comments.

Comment thread bip322/bip322.go Outdated
Comment thread bip322/bip322.go Outdated
Comment thread bip322/bip322.go Outdated
Comment thread bip322/bip322.go Outdated
Comment thread bip322/bip322.go Outdated
Comment thread bip322/bip322.go Outdated
Comment thread bip322/bip322.go Outdated
Comment thread bip322/test_vectors_test.go Outdated
Comment thread bip322/test_vectors_test.go Outdated
Comment thread bip322/test_vectors_test.go Outdated
guggero added 2 commits July 8, 2026 09:26
Adds a convenience Copy() method to the Packet struct that creates
a deep copy by serializing and deserializing it. Which means the
packet must be valid before it can be copied.
To avoid a packet being mutated by the serialization, we also make
sure that any sorting of member fields only happens on copies of the
slices.
This commit adds a new ScriptVerifyRestrictSigHash flag to the script
engine that allows restricting signature checking to SIGHASH_ALL or
SIGHASH_DEFAULT (for taproot) only.
The script is _NOT_ part of the StandardVerifyFlags and needs to
be specifically activated by a caller.
@guggero

guggero commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough reviews, I've addressed all comments.

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found a couple of bugs and nits.

Comment thread bip322/bip322.go Outdated
Comment thread bip322/signing.go
Comment thread bip322/bip322.go
Comment thread bip322/bip322.go Outdated
@guggero

guggero commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed your latest comments!

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found two issues:

  • PoF verification ignores PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE
  • SerializeSignature can emit a PoF signature that this verifier rejects

Also this PR now depends on #2567 to fix the remaining trailing tail parsing issue.

Comment thread bip322/bip322.go
empty := TimeConstraints{}
if !utf8.Valid(message) {
return false, empty, errInvalidMessage
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we require this here:

sigPacket.GenericSignedMessage != nil && *sigPacket.GenericSignedMessage == string(message)

?

The BIP says the PSBT creator sets PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE to the full UTF-8 message, and verification says full transaction/PSBT fields should be confirmed. Current verification accepts both missing and mismatched global message fields as long as the reconstructed to_sign matches the verifier-supplied message.

regression test
func TestVerifyMessagePoFRejectsMissingOrMismatchedGenericSignedMessage(t *testing.T) {
  message := []byte("probe")
  pkScript, _, witnessBytes := opTrueChallenge(t)

  makeSig := func(t *testing.T, field *string) string {
  	packet := BuildToSignPacketFull(message, pkScript, 0, 0, 0)
  	packet.Inputs[0].FinalScriptWitness = witnessBytes
  	packet.GenericSignedMessage = field

  	prevTx := wire.NewMsgTx(2)
  	prevTx.AddTxIn(&wire.TxIn{})
  	prevTx.AddTxOut(&wire.TxOut{Value: 1, PkScript: pkScript})

  	packet.UnsignedTx.AddTxIn(&wire.TxIn{
  		PreviousOutPoint: wire.OutPoint{
  			Hash:  prevTx.TxHash(),
  			Index: 0,
  		},
  	})
  	packet.Inputs = append(packet.Inputs, psbt.PInput{
  		WitnessUtxo:        prevTx.TxOut[0],
  		FinalScriptWitness: witnessBytes,
  	})

  	sig, err := SerializeSignature(packet)
  	require.NoError(t, err)

  	return sig
  }

  different := "different message"
  tests := []struct {
  	name  string
  	field *string
  }{
  	{name: "missing", field: nil},
  	{name: "mismatched", field: &different},
  }

  for _, test := range tests {
  	t.Run(test.name, func(t *testing.T) {
  		sig := makeSig(t, test.field)

  		valid, _, err := verifyMessageForChallenge(
  			message, pkScript, sig,
  		)

  		require.Error(t, err)
  		require.False(t, valid)
  	})
  }
}

Current failure starts at the first case:

Error: An error is expected but got nil.
Test:  TestVerifyMessagePoFRejectsMissingOrMismatchedGenericSignedMessage/missing

Note that in the second sub-test the signature has "different message" in its GenericSignedMessage field (part of the encoded signature string), but it successfully verifies against message="probe".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. BIP-174 doesn't explicitly mention if global fields should be kept or be stripped when finalizing a packet. So I guess even though the field is intended for the signing step, it's allowed to be kept or set on the signature packet.
But I agree that it should at least match the message.

It does put us in the weird situation where the message can be part of the "signature" data itself. So maybe the BIP should've mentioned that the field should be removed after signing... But I don't feel like touching the BIP again.

Comment thread bip322/bip322.go
case len(finalizedPacket.Inputs) > 1:
// Collapse duplicated NonWitnessUtxo fields into a single one
// to save space (special case allowed by BIP-322).
deduplicateNonWitnessUtxos(finalizedPacket)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the PoF serializer should reject or normalize legacy WitnessUtxo fields before emitting a signature.

This was originally visible as a round-trip inconsistency: a caller can give SerializeSignature a finalized PoF packet where repeated legacy inputs from the same previous transaction carry both NonWitnessUtxo and WitnessUtxo; SerializeSignature accepts it, deduplicates away the later duplicate NonWitnessUtxo, preserves the inappropriate WitnessUtxo, emits pof..., and the verifier then rejects that emitted signature with only witness utxo present for non-witness script type.

BIP174 says for PSBT_IN_NON_WITNESS_UTXO:

This should be present for inputs that spend non-segwit outputs and can be present for inputs that spend segwit outputs.

It says for PSBT_IN_WITNESS_UTXO:

This should only be present for inputs which spend segwit outputs, including P2SH embedded ones.

BIP322 says:

The Non-Witness or Witness UTXO fields (as appropriate for the type) of each additional input must be set to the corresponding UTXO.

It also allows the Non-Witness UTXO optimization only when the omitted Non-Witness UTXO can be reused from an earlier input spending the same transaction.

So for a legacy PoF input, SerializeSignature should not emit a signature that leaves an inappropriate WitnessUtxo as the effective UTXO field. Either fail before encoding such a packet, or normalize by dropping the inappropriate WitnessUtxo while preserving the earlier-NonWitnessUtxo reuse semantics. The important invariant is: if SerializeSignature emits a PoF signature, this implementation should be able to verify that emitted signature.

Regression test
func TestSerializeSignaturePoFLegacyWitnessUtxoRoundTrip(t *testing.T) {
	message := []byte("probe")
	pkScript, _, witnessBytes := opTrueChallenge(t)

	packet := BuildToSignPacketFull(message, pkScript, 0, 0, 0)
	packet.Inputs[0].FinalScriptWitness = witnessBytes

	key, err := btcec.NewPrivateKey()
	require.NoError(t, err)

	legacyPkScript, err := payToPubKeyHashScript(key)
	require.NoError(t, err)

	prevTx := wire.NewMsgTx(2)
	prevTx.AddTxIn(&wire.TxIn{})
	prevTx.AddTxOut(&wire.TxOut{Value: 1, PkScript: legacyPkScript})
	prevTx.AddTxOut(&wire.TxOut{Value: 2, PkScript: legacyPkScript})
	prevHash := prevTx.TxHash()

	for outIdx := uint32(0); outIdx < 2; outIdx++ {
		packet.UnsignedTx.AddTxIn(&wire.TxIn{
			PreviousOutPoint: wire.OutPoint{
				Hash:  prevHash,
				Index: outIdx,
			},
		})
		packet.Inputs = append(packet.Inputs, psbt.PInput{
			NonWitnessUtxo: prevTx,
			WitnessUtxo:    prevTx.TxOut[outIdx],
		})
	}

	for inputIdx := 1; inputIdx <= 2; inputIdx++ {
		packet.Inputs[inputIdx].FinalScriptSig, err = txscript.SignatureScript(
			packet.UnsignedTx, inputIdx, legacyPkScript,
			txscript.SigHashAll, key, true,
		)
		require.NoError(t, err)
	}

	signature, err := SerializeSignature(packet)
	if err != nil {
		return
	}

	valid, _, err := verifyMessageForChallenge(
		message, pkScript, signature,
	)
	require.NoError(t, err)
	require.True(t, valid)
}

Current failure:

--- FAIL: TestSerializeSignaturePoFLegacyWitnessUtxoRoundTrip (0.02s)
    pof_dedup_witness_utxo_probe_test.go:59:
         Error:       Received unexpected error:
                      error finding UTXO: only witness utxo present for non-witness script type

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bit more involved, but now also fixed.

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I attempted to integrate bip322 into btcwallet to assess the API. Overall it is very good! I found one issue in the signing API though: SignP2* functions have some important logic not available via other public functions; but SignP2* are hard to use downstream. I shared some ideas how it could be improved and an API sketch. It is just a direction to explore, I'm not confident in this exact API shape. What do you think?

Comment thread bip322/signing.go Outdated
Comment on lines +191 to +193
if !utf8.Valid([]byte(message)) {
return "", errInvalidMessage
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move UTF-8 checks from SignP2* functions to BuildToSignPacket* functions and make the later returning (*psbt.Packet, error)?

SignP2* have too narrow interface, e.g. many applications do not have access to raw private keys and may only request signing under something. So they can't use SignP2* and have to call BuildToSignPacket* directly. It is better if they inherit UTF-8 checks.

Comment thread bip322/signing.go
}

return SerializeSignature(toSign)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have been thinking about the SignP2* functions and the lower-level signing API. I think the current helpers are useful as convenience wrappers, but the core signing orchestration could be exposed in a more generic way so downstream users do not need to duplicate the "build full packet first, then sign all inputs, then finalize, then serialize" sequence.

The UTF-8 issue is one symptom of the current split: SignP2* validates the message, but downstream callers that use BuildToSignPacketSimple/BuildToSignPacketFull directly can still build a packet with an invalid PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE and only fail later with a lower-level PSBT serialization error. Since the builders create the generic signed message field, I think UTF-8 validation belongs there. That probably means changing BuildToSignPacketFull to return (*psbt.Packet, error) and having BuildToSignPacketSimple propagate that error.

The signing abstraction I think would fit both the raw-key helpers and wallet integrations is:

type PacketSigner interface {
	PrepareInput(packet *psbt.Packet, inputIndex int, utxo *wire.TxOut) error
	SignInput(packet *psbt.Packet, inputIndex int, utxo *wire.TxOut) error
}

func FinalizeAndSerializePacket(
	packet *psbt.Packet,
	utxos []*wire.TxOut,
	signer PacketSigner,
) (string, error)

PrepareInput lets the signer attach spend-specific PSBT metadata such as RedeemScript or WitnessScript. That keeps wrappers thin and avoids special one-off mutations like packet.Inputs[0].RedeemScript = witnessProgram outside the signer. SignInput then signs the already-complete packet. FinalizeAndSerializePacket would run preparation for all inputs first, then sign all inputs, then finalize, then call SerializeSignature.

Downstream users would still own UTXO lookup/selection and key access. For example, btcwallet would still select PoF inputs, fetch the previous outputs, decide WitnessUtxo vs NonWitnessUtxo, and sign using wallet-managed keys, but it would not need to reimplement the generic BIP-322 sequencing/finalization tail.

Possible FinalizeAndSerializePacket implementation
func FinalizeAndSerializePacket(packet *psbt.Packet, utxos []*wire.TxOut,
	signer PacketSigner) (string, error) {

	if packet == nil || packet.UnsignedTx == nil {
		return "", errors.New("nil or incomplete packet")
	}
	if signer == nil {
		return "", errors.New("signer cannot be nil")
	}
	if len(packet.Inputs) != len(packet.UnsignedTx.TxIn) {
		return "", errors.New("input and txin length mismatch")
	}
	if len(utxos) != len(packet.Inputs) {
		return "", errors.New("utxo and input length mismatch")
	}

	for idx, utxo := range utxos {
		if utxo == nil {
			return "", fmt.Errorf("input %d has no utxo", idx)
		}
		if err := signer.PrepareInput(packet, idx, utxo); err != nil {
			return "", fmt.Errorf("prepare input %d: %w", idx, err)
		}
	}

	for idx, utxo := range utxos {
		if err := signer.SignInput(packet, idx, utxo); err != nil {
			return "", fmt.Errorf("sign input %d: %w", idx, err)
		}
	}

	if err := psbt.MaybeFinalizeAll(packet); err != nil {
		return "", fmt.Errorf("finalize packet: %w", err)
	}

	return SerializeSignature(packet)
}

With this, the SignP2* functions can stay as user-friendly helpers, but become thin wrappers over the same generic path.

Example raw-key signer and SignP2WPKH wrapper
type rawKeyScriptKind uint8

const (
	rawKeyP2TR rawKeyScriptKind = iota
	rawKeyWitness
	rawKeyLegacy
)

type rawKeySigner struct {
	privateKey    *btcec.PrivateKey
	signingScript []byte
	redeemScript  []byte
	witnessScript []byte
	kind          rawKeyScriptKind
}

func (s rawKeySigner) PrepareInput(packet *psbt.Packet, idx int,
	utxo *wire.TxOut) error {

	if len(s.redeemScript) != 0 {
		packet.Inputs[idx].RedeemScript = s.redeemScript
	}
	if len(s.witnessScript) != 0 {
		packet.Inputs[idx].WitnessScript = s.witnessScript
	}

	return nil
}

func (s rawKeySigner) SignInput(packet *psbt.Packet, idx int,
	utxo *wire.TxOut) error {

	switch s.kind {
	case rawKeyP2TR:
		return signInputTaprootKeySpend(packet, idx, s.privateKey)

	case rawKeyWitness:
		return signInputWitness(packet, idx, s.signingScript, s.privateKey)

	case rawKeyLegacy:
		return signInputLegacy(packet, idx, s.signingScript, s.privateKey)

	default:
		return fmt.Errorf("unknown raw key script kind %d", s.kind)
	}
}

func SignP2WPKH(message string, privateKey *btcec.PrivateKey) (string, error) {
	if privateKey == nil {
		return "", errPrivateKeyNil
	}

	pkScript, err := payToWitnessPubKeyHashScript(privateKey)
	if err != nil {
		return "", fmt.Errorf("creating pkScript: %w", err)
	}

	packet, err := BuildToSignPacketSimple([]byte(message), pkScript)
	if err != nil {
		return "", fmt.Errorf("creating to_sign packet: %w", err)
	}

	return FinalizeAndSerializePacket(
		packet,
		[]*wire.TxOut{{Value: 0, PkScript: pkScript}},
		rawKeySigner{
			privateKey:    privateKey,
			signingScript: pkScript,
			kind:          rawKeyWitness,
		},
	)
}
Example nested P2WPKH wrapper without one-off packet mutation
func SignNestedP2WPKH(message string, privateKey *btcec.PrivateKey) (string,
	error) {

	if privateKey == nil {
		return "", errPrivateKeyNil
	}

	pkScript, witnessProgram, err := payToNestedWitnessPubKeyHashScript(
		privateKey,
	)
	if err != nil {
		return "", fmt.Errorf("creating pkScript: %w", err)
	}

	packet, err := BuildToSignPacketFull([]byte(message), pkScript, 0, 0, 0)
	if err != nil {
		return "", fmt.Errorf("creating to_sign packet: %w", err)
	}

	return FinalizeAndSerializePacket(
		packet,
		[]*wire.TxOut{{Value: 0, PkScript: pkScript}},
		rawKeySigner{
			privateKey:    privateKey,
			signingScript: witnessProgram,
			redeemScript:  witnessProgram,
			kind:          rawKeyWitness,
		},
	)
}

So the proposed layering would be: builders validate message and create the BIP-322 packet shape; callers provide UTXOs and a signer; FinalizeAndSerializePacket owns the BIP-322 signing/finalization order; SignP2* remains as small convenience wrappers for raw-key single-input cases.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, such an API would be nice to have. And nothing in the current code is preventing such an API from being added. But after 12 rounds of review, I'd like to keep the scope of the PR to the original goal and API. We can always add this in a follow-up PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, just out of curiosity, what's the use case in btcwallet? Wouldn't this make more sense in a more end-user type of wallet context, such as lnd?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, let's keep the current API and leave this for a potential follow-up PR.

I'll check the LND integration as well! My original btcwallet idea was that it is also an end-user application, but smaller than LND, since it does not have the lightning network code.

@guggero guggero force-pushed the bip-322 branch 2 times, most recently from 4e35a65 to 35c3bfd Compare July 12, 2026 17:11

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! 🎉
Great work!

There are just a few nits left, nothing blocking.

While reviewing the implementation, I found some concerns in the protocol itself. None of this blocks this PR; these are just thoughts for the future.

  • There is no mainnet/testnet/signet/etc. separation. A signature made for testnet could be valid on mainnet. I think it worth adding a network separation to prevent this.
  • Some signatures could reveal public keys, scripts, sets of owned coins, etc.

What do you think about running the whole construction inside ZK to preserve all of that privately? For example, in a STARK. Simple and full signatures are pure functions, so it should be straightforward to put them into ZK. For PoF, it depends on the UTXO set, so it is not a pure function. But this could still be solved if combined with some commitment to the UTXO set, e.g. utreexo. Then a zk-PoF could be interpreted as: "I have X BTC on-chain as of block Y, and here is a proof."

Comment thread bip322/bip322.go Outdated
Comment on lines +342 to +353
// We also check that the witness UTXOs for any additional inputs are
// set appropriately for their script type (so we only serialize
// something that we would also accept ourselves).
if err := validateUtxoCorrectness(packet); err != nil {
return "", err
}

// The IsComplete should rather be called IsFinalized, as it checks each
// input if it has a witness or scriptSig.
if !packet.IsComplete() {
return "", fmt.Errorf("packet must be finalized")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. Should these two checks be done in different order?

For a P2SH-nested witness input where the user has set WitnessUtxo but not yet finalized (so no FinalScriptSig to unwrap the redeem script), inputWitnessProgram returns "not segwit" and the user sees "witness UTXO for spending a non-segwit script" instead of the honest "packet must be finalized". Swap the two checks.

Regression test
package bip322

import (
  "testing"

  "github.com/btcsuite/btcd/btcec/v2"
  "github.com/btcsuite/btcd/psbt/v2"
  "github.com/btcsuite/btcd/wire/v2"
  "github.com/stretchr/testify/require"
)

// TestSerializeSignatureCheckOrder demonstrates that SerializeSignature runs
// validateUtxoCorrectness *before* IsComplete(). When a caller feeds it a
// P2SH-nested-segwit input that has WitnessUtxo set but no FinalScriptSig yet
// (a common intermediate state for wallet-produced PSBTs waiting to be
// signed), inputWitnessProgram cannot unwrap the P2SH redeem script and
// reports isSegWit=false. The caller then gets:
//
//   "input N presents a witness UTXO for spending a non-segwit script,
//    but a non-segwit input requires a non-witness UTXO (BIP-174)"
//
// instead of the honest:
//
//   "packet must be finalized"
//
// Swapping the two checks in SerializeSignature (IsComplete first, then
// validateUtxoCorrectness) would surface the real reason.
//
// Not a security bug. UX only.
func TestSerializeSignatureCheckOrder(t *testing.T) {
  key, err := btcec.NewPrivateKey()
  require.NoError(t, err)

  // Build a P2SH-P2WPKH pkScript. This puts the challenge input into
  // the P2SH-nested-segwit path, where BuildToSignPacketFull nils
  // WitnessUtxo on input 0 (so input 0 alone can't trigger the bug).
  pkScript, _, err := payToNestedWitnessPubKeyHashScript(key)
  require.NoError(t, err)

  packet, err := BuildToSignPacketFull([]byte("probe"), pkScript, 0, 0, 0)
  require.NoError(t, err)

  // Add a second, P2SH-nested-segwit PoF input that a wallet has
  // pre-populated with both UTXO fields but has NOT signed yet. This is
  // the exact state a wallet's outgoing PSBT is in before being handed
  // off for signing.
  prevTx := wire.NewMsgTx(2)
  prevTx.AddTxIn(&wire.TxIn{})
  prevTx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: pkScript})

  packet.UnsignedTx.AddTxIn(&wire.TxIn{
  	PreviousOutPoint: wire.OutPoint{
  		Hash:  prevTx.TxHash(),
  		Index: 0,
  	},
  })
  packet.Inputs = append(packet.Inputs, psbt.PInput{
  	WitnessUtxo:    prevTx.TxOut[0],
  	NonWitnessUtxo: prevTx,
  	// FinalScriptSig deliberately left nil — packet is unfinalized.
  })

  // The packet is not finalized. Ask SerializeSignature what it thinks.
  _, err = SerializeSignature(packet)
  require.Error(t, err)
  t.Logf("SerializeSignature on unfinalized P2SH-witness packet → %v", err)

  // The honest error would be "packet must be finalized"; today the
  // caller instead gets the misleading BIP-174-shaped error.
  require.ErrorContains(t, err, "packet must be finalized",
  	"expected the honest finalized-first error; got the "+
  		"misleading UTXO-correctness error because "+
  		"validateUtxoCorrectness runs before IsComplete "+
  		"in SerializeSignature")
}

Comment thread bip322/bip322.go Outdated
Comment thread bip322/bip322.go Outdated
guggero added 4 commits July 14, 2026 09:37
This commit adds a new Golang submodule that implements BIP-0322 generic
message signing. This first commit adds helper methods for producing a
PSBT packet that, when signed, can be turned into a BIP-0322 valid
"signature" (which, depending on the variant "simple" vs. "full" is
either just the serialized witness stack or the full serialized to_sign
transaction).

Co-Authored-By: mohamedmohey2352@gmail.com
This commit adds verification functions for the generic message signing
protocol and also adds test cases for all common script types.
@guggero

guggero commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks a lot for all the review! 🙏

There is no mainnet/testnet/signet/etc. separation. A signature made for testnet could be valid on mainnet. I think it worth adding a network separation to prevent this.

Yes, that is correct. But the signature first and foremost attests to a message, and a message doesn't necessarily have a direct dependency on the network. And since the signature is always accompanied by an address formatted for a network, so the intended network is indicated by that. But yes, you could take a testnet signature and address and transform them into a valid mainnet signature. So if that is a problem for someone's use case, they can still add the network flag as a prefix to the message itself.
One could say this is an oversight in the BIP itself, but AFAIK any previously proposed message signature scheme doesn't take that into account.

Some signatures could reveal public keys, scripts, sets of owned coins, etc.

Yes, and I think that's something a wallet developer should take into account and make their users aware of.

What do you think about running the whole construction inside ZK to preserve all of that privately? For example, in a STARK. Simple and full signatures are pure functions, so it should be straightforward to put them into ZK. For PoF, it depends on the UTXO set, so it is not a pure function. But this could still be solved if combined with some commitment to the UTXO set, e.g. utreexo. Then a zk-PoF could be interpreted as: "I have X BTC on-chain as of block Y, and here is a proof."

I still haven't educated myself properly on ZK tech, but that sounds super useful. The UTXO set dependency is a bit of a challenge to solve, but as you say it could rely on one of the ZK-friendly UTXO commitment proposals. Perhaps you could write a BIP for ZK-BIP-322? I'd be willing to assist with editorial tasks and implementation/testing of reference code. Would be a great excuse for me to learn more about STARKs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BIP 322 Support

5 participants