Skip to content

Conversation

guggero
Copy link
Contributor

@guggero guggero commented Aug 18, 2025

Fixes #1740.

Fixes two bugs that have been in the code base for a long time but only have been triggered recently by the test mentioned here: #1740

  1. Use correct input asset: When an asset is a split output and then in the next transfer becomes a passive asset, the input asset used when signing was not the correct one (missing the split commitment), which lead to the invalid transfer asset witness error. The fix is using the correct input asset from the input proof instead of the asset from the trimmed input commitment.
  2. Store the split commitment root in the DB when importing asset from proof: This is super old and shows that so far we've only ever kept the split root of a transfer on our local node (as change or tombstone) and have never sent it out to another node as part of an interactive transfer. The fix is storing the split commitment root hash and value when importing an asset from a proof file. This fixes the following error: unable to fund address send: error funding packet: unable to list eligible coins: unable to query commitments: mismatch of managed utxo and constructed tap commitment root.

I've also included the integration test from ZZiigguurraatt@d6ef2ac which reproduced the first bug reliably (thanks a lot, @ZZiigguurraatt, that was super helpful!).

cc @bhandras, not sure if you perhaps ran into either of these bugs in your work?

@coveralls
Copy link

coveralls commented Aug 18, 2025

Pull Request Test Coverage Report for Build 17062493703

Details

  • 70 of 76 (92.11%) changed or added relevant lines in 5 files are covered.
  • 8942 unchanged lines in 124 files lost coverage.
  • Overall coverage decreased (-0.02%) to 56.645%

Changes Missing Coverage Covered Lines Changed/Added Lines %
tappsbt/create.go 33 35 94.29%
tapdb/assets_store.go 6 10 60.0%
Files with Coverage Reduction New Missed Lines %
authmailbox/client.go 2 69.84%
commitment/proof.go 2 87.29%
fn/retry.go 2 92.5%
tapdb/sqlc/transfers.sql.go 2 83.33%
tapsend/proof.go 2 85.99%
universe/syncer.go 2 82.73%
fn/recv.go 3 65.12%
itest/multisig.go 3 97.91%
universe/interface.go 3 60.63%
commitment/encoding.go 4 68.75%
Totals Coverage Status
Change from base Build 17057360543: -0.02%
Covered Lines: 60721
Relevant Lines: 107195

💛 - Coveralls

@guggero guggero force-pushed the passive-asset-split-output-fix branch from 40acfa2 to af5f93f Compare August 18, 2025 15:00
@levmi levmi moved this from 🆕 New to 👀 In review in Taproot-Assets Project Board Aug 18, 2025
@levmi levmi requested a review from GeorgeTsagk August 18, 2025 15:08
@ZZiigguurraatt
Copy link

2. Store the split commitment root in the DB when importing asset from proof: This is super old and shows that so far we've only ever kept the split root of a transfer on our local node (as change or tombstone) and have never sent it out to another node as part of an interactive transfer. The fix is storing the split commitment root hash and value when importing an asset from a proof file. This fixes the following error: unable to fund address send: error funding packet: unable to list eligible coins: unable to query commitments: mismatch of managed utxo and constructed tap commitment root.

Is this the cause of step 10 (the second problem) in #1736 ?

Copy link
Member

@Roasbeef Roasbeef left a comment

Choose a reason for hiding this comment

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

An initial pass, still trying to grok the exact gaps here which led to this bug. I also plan to add some additional documentation here for posterity.

@Roasbeef
Copy link
Member

Roasbeef commented Aug 19, 2025

Here's an extra unit test that we can add (at the `tapdb` level, covers the issue where import proof didn't write the split commit info):
// createTestProof creates an annotated proof for testing.
func createTestProof(t *testing.T,
	testAsset *asset.Asset) *proof.AnnotatedProof {

	return createTestProofWithAnchor(t, testAsset, 0)
}

// createTestProofWithAnchor creates an annotated proof with specific anchor
// index.
func createTestProofWithAnchor(t *testing.T, testAsset *asset.Asset,
	anchorIndex uint32) *proof.AnnotatedProof {

	var blockHash chainhash.Hash
	_, err := rand.Read(blockHash[:])
	require.NoError(t, err)

	anchorTx := wire.NewMsgTx(2)
	anchorTx.AddTxIn(&wire.TxIn{})

	// Add enough outputs to cover the requested index
	for i := uint32(0); i <= anchorIndex; i++ {
		anchorTx.AddTxOut(&wire.TxOut{
			PkScript: bytes.Repeat([]byte{0x01}, 34),
			Value:    10,
		})
	}

	assetRoot, err := commitment.NewAssetCommitment(testAsset)
	require.NoError(t, err)

	commitVersion := test.RandFlip(nil, fn.Ptr(commitment.TapCommitmentV2))
	taprootAssetRoot, err := commitment.NewTapCommitment(
		commitVersion, assetRoot,
	)
	require.NoError(t, err)

	testProof := randProof(t, testAsset)
	proofBlob, err := testProof.Bytes()
	require.NoError(t, err)

	assetID := testAsset.ID()
	anchorPoint := wire.OutPoint{
		Hash:  anchorTx.TxHash(),
		Index: anchorIndex,
	}

	return &proof.AnnotatedProof{
		Locator: proof.Locator{
			AssetID:   &assetID,
			ScriptKey: *testAsset.ScriptKey.PubKey,
		},
		Blob: proofBlob,
		AssetSnapshot: &proof.AssetSnapshot{
			Asset:             testAsset,
			OutPoint:          anchorPoint,
			AnchorBlockHash:   blockHash,
			AnchorBlockHeight: uint32(test.RandIntn(1000) + 1),
			AnchorTxIndex:     test.RandInt[uint32](),
			AnchorTx:          anchorTx,
			OutputIndex:       anchorIndex,
			InternalKey:       test.RandPubKey(t),
			ScriptRoot:        taprootAssetRoot,
		},
	}
}

// TestUpsertAssetsWithSplitCommitments tests that split commitment data is
// properly stored and retrieved during asset upsert operations.
func TestUpsertAssetsWithSplitCommitments(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name           string
		hasSplitCommit bool
		splitValue     uint64
	}{
		{
			name:           "asset_with_split_commitment",
			hasSplitCommit: true,
			splitValue:     12345,
		},
		{
			name:           "asset_without_split_commitment",
			hasSplitCommit: false,
		},
	}

	for _, tc := range testCases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			ctx := context.Background()
			dbHandle := NewDbHandle(t)

			testAsset := randAsset(t)

			var expectedHash mssmt.NodeHash
			if tc.hasSplitCommit {
				splitHash := test.RandBytes(32)
				copy(expectedHash[:], splitHash)

				testAsset.SplitCommitmentRoot = mssmt.NewComputedNode( //nolint:lll
					expectedHash, tc.splitValue,
				)
			}

			annotatedProof := createTestProof(
				t, testAsset,
			)

			err := dbHandle.AssetStore.ImportProofs(
				ctx, proof.MockVerifierCtx, false,
				annotatedProof,
			)
			require.NoError(t, err)

			assets, err := dbHandle.AssetStore.FetchAllAssets(ctx, false, false, nil)
			require.NoError(t, err)
			require.Len(t, assets, 1)

			dbAsset := assets[0].Asset

			if tc.hasSplitCommit {
				require.NotNil(
					t, dbAsset.SplitCommitmentRoot,
				)
				gotHash := dbAsset.SplitCommitmentRoot.NodeHash()
				require.Equal(t, expectedHash, gotHash)
				require.Equal(
					t, tc.splitValue,
					dbAsset.SplitCommitmentRoot.NodeSum(),
				)
			} else {
				require.Nil(
					t, dbAsset.SplitCommitmentRoot,
					"Split commitment root should be nil",
				)
			}
		})
	}
}

@guggero
Copy link
Contributor Author

guggero commented Aug 19, 2025

Is this the cause of step 10 (the second problem) in #1736 ?

Yes, I'm pretty sure that's the bug you ran into. At least the error message is identical (but different cases could potentially lead to that error message, but hoping this is the one fixed here, otherwise please make sure to report again).

guggero and others added 3 commits August 19, 2025 09:12
Fixes #1740.

We haven't ever tested creating a split asset that then becomes a
passive asset in a subsequent transfer.
And that logic did have a bug in it: Because we used the asset from the
trimmed input commitment (meaning we called
commitment.TrimSplitWitnesses) because we wanted to make sure it matches
the output script where we always trim, it never had a split commitment in the
witness.
So when signing we signed without the split commitment.
But when verifying, we properly used the input proof's asset which did
have the split commitment, so the signature ended up being incorrect.

This commit also contains a minimal reproduction case that failed before
this fix.
This is the integration test that initially discovered the issue with
split outputs that become passive assets in a subsequent transfer.
We add it here to make sure the bug is properly fixed.
Because we don't need to be able to fully predict the asset leaf as it
is put on chain (which was the case for V0 and V1 addresses), we can
optimize chain usage by avoiding the creation of a tombstone output on
full value sends (meaning we use an interactive transfer scheme for V2
addresses, since the receiver will learn about the proofs from the auth
mailbox and doesn't need to predict the leave(s) themselves).
That means we no longer have to create a zero-value change output that
locks 1k sats until we implement garbage collection if the user wants to
send all assets. So fewer outputs created on-chain and fewer sats locked
for the user.
@guggero guggero force-pushed the passive-asset-split-output-fix branch from af5f93f to ae38d8a Compare August 19, 2025 07:14
Fixes the following error that occurred sometimes:
unable to fund address send: error funding packet: unable to list eligible coins: unable to query commitments: mismatch of managed utxo and constructed tap commitment root

This would happen when we inserted the split _ROOT_ output as a proof
file (instead of going through the transfer flow which didn't have this
issue), because the split commitment root wasn't properly written to
the database in that case.
Which means, not a single itest so far did send out the split root
output to the counterparty as part of an interactive transfer.

This was only discovered after turning the address v2 send flow into an
interactive send, which does allow the split root output to be sent to
the counterparty instead of only using it as the local change output.
@guggero guggero force-pushed the passive-asset-split-output-fix branch from ae38d8a to 5488ad7 Compare August 19, 2025 07:19
@guggero
Copy link
Contributor Author

guggero commented Aug 19, 2025

Here's an extra unit test that we can add (at the tapdb level, covers the issue where import proof didn't write the split commit info):

Awesome, thanks you! Added.

@ZZiigguurraatt
Copy link

I've also included the integration test from ZZiigguurraatt@d6ef2ac which reproduced the first bug reliably (thanks a lot, @ZZiigguurraatt, that was super helpful!).

If this issue has been around for a while, should we re-write the itest to do a non-group key send? I was able to reproduce the issue sending the individual asset tranches using a non-group key V1 address, but I did not try to do the multi asset id send with V1 address. Perhaps that could be done by creating 3 V1 addresses and then paying to them all in the same invocation of SendAsset since we can pass multiple addresses to it at once (

// The list of TAP addresses to send assets to. The amount to send to each
// address is determined by the amount specified in the address itself. For
// V2 addresses that are allowed to not specify an amount, use the
// addresses_with_amounts list to specify the amount to send to each
// address. The tap_addrs and addresses_with_amounts lists are mutually
// exclusive, meaning that if addresses_with_amounts is set, then tap_addrs
// must be empty, and vice versa.
repeated string tap_addrs = 1;
)?

@guggero
Copy link
Contributor Author

guggero commented Aug 19, 2025

I've also included the integration test from ZZiigguurraatt@d6ef2ac which reproduced the first bug reliably (thanks a lot, @ZZiigguurraatt, that was super helpful!).

If this issue has been around for a while, should we re-write the itest to do a non-group key send? I was able to reproduce the issue sending the individual asset tranches using a non-group key V1 address, but I did not try to do the multi asset id send with V1 address. Perhaps that could be done by creating 3 V1 addresses and then paying to them all in the same invocation of SendAsset since we can pass multiple addresses to it at once (

// The list of TAP addresses to send assets to. The amount to send to each
// address is determined by the amount specified in the address itself. For
// V2 addresses that are allowed to not specify an amount, use the
// addresses_with_amounts list to specify the amount to send to each
// address. The tap_addrs and addresses_with_amounts lists are mutually
// exclusive, meaning that if addresses_with_amounts is set, then tap_addrs
// must be empty, and vice versa.
repeated string tap_addrs = 1;

)?

No, I don't think that will work. Because you need multiple split outputs being in the same on-chain output. And you can't do that with V0/V1 addresses, each address will get its own on-chain output.
We can do it with the vPSBT API and I did add a reproduction itest case for that, in addition to your V2 itest case. So I think this case is now covered at least twice, which is much better than before.

@ZZiigguurraatt
Copy link

We can do it with the vPSBT API

So this was the only way that this bug could have been previously exposed? You say this PR fixes two long standing edge cases, but before V2 addresses actually getting them to happen would be very hard for the average user and only a really advanced user could have even found them?

@guggero
Copy link
Contributor Author

guggero commented Aug 20, 2025

So this was the only way that this bug could have been previously exposed? You say this PR fixes two long standing edge cases, but before V2 addresses actually getting them to happen would be very hard for the average user and only a really advanced user could have even found them?

Correct, so we're lucky nobody ran into these two cases yet.

Copy link
Member

@Roasbeef Roasbeef left a comment

Choose a reason for hiding this comment

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

LGTM 🧬

Copy link
Member

@GeorgeTsagk GeorgeTsagk left a comment

Choose a reason for hiding this comment

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

LGTM, ty for the fixes! 💯

@GeorgeTsagk GeorgeTsagk added this pull request to the merge queue Aug 21, 2025
Merged via the queue into main with commit dd85d50 Aug 21, 2025
37 of 38 checks passed
@github-project-automation github-project-automation bot moved this from 👀 In review to ✅ Done in Taproot-Assets Project Board Aug 21, 2025
@guggero guggero deleted the passive-asset-split-output-fix branch August 21, 2025 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: ✅ Done
Development

Successfully merging this pull request may close these issues.

[bug]: SendAsset: assets get stuck unconfirmed with group key send
5 participants