Skip to content

Commit c1e9b99

Browse files
committed
tapfreighter: carry purged assets through pre-anchored flow
Return the pruned leaves discovered in rpcServer.validateInputAssets and store them in the pre-anchored parcel so ChainPorter can include them when re-running ValidateAnchorInputs. PublishAndLogTransfer now forwards that map, sendPackage/ChainPorter merge it with any locally derived commitments, and other callers keep passing nil when they still have the full taproot input state.
1 parent f6120d5 commit c1e9b99

File tree

7 files changed

+94
-7
lines changed

7 files changed

+94
-7
lines changed

itest/psbt_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3663,7 +3663,7 @@ func testPsbtRelativeLockTimeSendProofFail(t *harnessTest) {
36633663

36643664
AssertSendEvents(
36653665
t.t, aliceScriptKeyBytes, sendEvents,
3666-
tapfreighter.SendStateStorePreBroadcast,
3666+
tapfreighter.SendStateVerifyPreBroadcast,
36673667
tapfreighter.SendStateWaitTxConf,
36683668
)
36693669

rpcserver.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3138,7 +3138,7 @@ func (r *rpcServer) PublishAndLogTransfer(ctx context.Context,
31383138
resp, err := r.cfg.ChainPorter.RequestShipment(
31393139
tapfreighter.NewPreAnchoredParcel(
31403140
activePackets, passivePackets, anchorTx,
3141-
req.SkipAnchorTxBroadcast, req.Label,
3141+
req.SkipAnchorTxBroadcast, req.Label, purgedAssets,
31423142
),
31433143
)
31443144
if err != nil {

tapchannel/aux_closer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ func shipChannelTxn(txSender tapfreighter.Porter, chanTx *wire.MsgTx,
667667
FinalTx: chanTx,
668668
}
669669
preSignedParcel := tapfreighter.NewPreAnchoredParcel(
670-
vPkts, nil, closeAnchor, false, "",
670+
vPkts, nil, closeAnchor, false, "", nil,
671671
)
672672
_, err = txSender.RequestShipment(preSignedParcel)
673673
if err != nil {

tapchannel/aux_funding_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1457,7 +1457,7 @@ func (f *FundingController) completeChannelFunding(ctx context.Context,
14571457
FinalTx: signedFundingTx,
14581458
}
14591459
preSignedParcel := tapfreighter.NewPreAnchoredParcel(
1460-
activePkts, passivePkts, anchorTx, false, "",
1460+
activePkts, passivePkts, anchorTx, false, "", nil,
14611461
)
14621462
_, err = f.cfg.TxSender.RequestShipment(preSignedParcel)
14631463
if err != nil {

tapfreighter/chain_porter.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1619,8 +1619,14 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) {
16191619

16201620
// For the final validation, we need to also supply the assets
16211621
// that were committed to the input tree but pruned because they
1622-
// were burns or tombstones.
1622+
// were burns or tombstones. Some parcels (like the pre-anchored
1623+
// flow) already provide those pruned assets up-front.
16231624
prunedAssets := make(map[wire.OutPoint][]*asset.Asset)
1625+
for outpoint, assets := range currentPkg.PrunedAssets {
1626+
prunedAssets[outpoint] = append(
1627+
prunedAssets[outpoint], assets...,
1628+
)
1629+
}
16241630
for prevID := range currentPkg.InputCommitments {
16251631
c := currentPkg.InputCommitments[prevID]
16261632
prunedAssets[prevID.OutPoint] = append(

tapfreighter/parcel.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,10 @@ type PreAnchoredParcel struct {
390390

391391
anchorTx *tapsend.AnchorTransaction
392392

393+
// prunedAssets holds any assets that were part of the input commitment
394+
// but are not recreated by the virtual packets (e.g. tombstones).
395+
prunedAssets map[wire.OutPoint][]*asset.Asset
396+
393397
// skipAnchorTxBroadcast bool is a flag that indicates whether the
394398
// anchor transaction broadcast should be skipped. This is useful when
395399
// an external system handles broadcasting, such as in custom
@@ -407,7 +411,8 @@ var _ Parcel = (*PreAnchoredParcel)(nil)
407411
// NewPreAnchoredParcel creates a new PreAnchoredParcel.
408412
func NewPreAnchoredParcel(vPackets []*tappsbt.VPacket,
409413
passiveAssets []*tappsbt.VPacket, anchorTx *tapsend.AnchorTransaction,
410-
skipAnchorTxBroadcast bool, label string) *PreAnchoredParcel {
414+
skipAnchorTxBroadcast bool, label string,
415+
prunedAssets map[wire.OutPoint][]*asset.Asset) *PreAnchoredParcel {
411416

412417
return &PreAnchoredParcel{
413418
parcelKit: &parcelKit{
@@ -417,6 +422,7 @@ func NewPreAnchoredParcel(vPackets []*tappsbt.VPacket,
417422
virtualPackets: vPackets,
418423
passiveAssets: passiveAssets,
419424
anchorTx: anchorTx,
425+
prunedAssets: prunedAssets,
420426
skipAnchorTxBroadcast: skipAnchorTxBroadcast,
421427
label: label,
422428
}
@@ -431,10 +437,11 @@ func (p *PreAnchoredParcel) pkg() *sendPackage {
431437
// commitment.
432438
return &sendPackage{
433439
Parcel: p,
434-
SendState: SendStateStorePreBroadcast,
440+
SendState: SendStateVerifyPreBroadcast,
435441
VirtualPackets: p.virtualPackets,
436442
PassiveAssets: p.passiveAssets,
437443
AnchorTx: p.anchorTx,
444+
PrunedAssets: p.prunedAssets,
438445
Label: p.label,
439446
SkipAnchorTxBroadcast: p.skipAnchorTxBroadcast,
440447
}
@@ -493,6 +500,10 @@ type sendPackage struct {
493500
// associated Taproot Asset commitment.
494501
InputCommitments tappsbt.InputCommitments
495502

503+
// PrunedAssets holds any assets that were part of the input commitment
504+
// but are not recreated by the virtual packets (e.g. tombstones).
505+
PrunedAssets map[wire.OutPoint][]*asset.Asset
506+
496507
// SendManifests is a map of send manifests that need to be sent to the
497508
// auth mailbox server to complete an address V2 transfer. It is keyed
498509
// by the anchor output index.

tmp_change_dsc.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
TestTaprootAssetsDaemon/tranche00/83-of-98/anchor_multiple_virtual_transactions was failing inside ValidateAnchorInputs
2+
with “anchor input script mismatch”. The mismatch appeared whenever we executed the PublishAndLogTransfer (pre‑anchored)
3+
path: the RPC would validate the user-supplied PSBT/vpkts,
4+
but once ChainPorter picked up the resulting parcel it no longer had the full tap trees needed to recreate each anchor
5+
input, so the final pre-broadcast check failed.
6+
7+
Root cause
8+
9+
ValidateAnchorInputs needs all assets that were committed in the original anchor input:
10+
11+
1. active + passive assets (present in the virtual packets), and
12+
2. “purged assets” (tombstones/burns) that were part of the commitment but are not recreated.
13+
14+
When ChainPorter orchestrates a send from scratch it keeps the original InputCommitments in memory, so in
15+
SendStateVerifyPreBroadcast we can call tapsend.ExtractUnSpendable on those commitments and hand the pruned leaves to
16+
ValidateAnchorInputs. However, in the pre‑anchored RPC flow we never stored
17+
those commitments—InputCommitments is empty—and although rpcServer.validateInputAssets fetched and used the purged
18+
leaves to check the user’s PSBT, it didn’t persist them anywhere. By the time ChainPorter ran, the only data left were
19+
the active/passive assets; the tombstones/burns were gone, so the
20+
reconstructed tap tree didn’t match the on-chain script.
21+
22+
What changed
23+
24+
1. rpcServer.validateInputAssets now returns the pruned assets it discovers while validating a PSBT’s inputs. The helper
25+
still performs all prior checks (version validation, local key derivation, commitment lookups, supply conservation),
26+
but it also hands the tombstones/burns back to the caller.
27+
2. PublishAndLogTransfer captures that map and passes it down to ChainPorter by storing it in the PreAnchoredParcel.
28+
3. sendPackage and ChainPorter gain a PrunedAssets field. When we reach SendStateVerifyPreBroadcast, we merge any
29+
pre-supplied pruned leaves with the ones we can still derive from InputCommitments and feed the combined set into
30+
ValidateAnchorInputs.
31+
4. Existing internal callers of NewPreAnchoredParcel (aux funding controller, aux closer) pass nil because they still
32+
have full commitments in memory; nothing changes for those flows.
33+
34+
This ensures that every code path which validated a PSBT against the full tap tree can supply the same tombstones/burns
35+
later, so the final pre-broadcast check reconstructs the exact script that is committed on chain.
36+
37+
Why not just populate InputCommitments?
38+
39+
For pre-anchored flows we often cannot build the full InputCommitments map:
40+
41+
- Inputs might belong to another party; we can’t fetch or persist their entire tap commitments.
42+
- Even for local inputs the full commitment can be large, and we’d only be storing it to re-derive a small subset (the
43+
unspendable leaves). That’s heavyweight and redundant.
44+
45+
Passing just the pruned leaves is the minimal data we need to satisfy ValidateAnchorInputs, and it works even when the
46+
full commitments aren’t available.
47+
48+
Is rpcServer.validateInputAssets redundant now?
49+
50+
No. It serves as the early validation barrier at the RPC boundary:
51+
52+
- It decorates the PSBT with local derivation info (so later signing works).
53+
- It fetches whatever commitments the node knows about to enforce supply conservation and collect pruned leaves.
54+
- It ensures malformed PSBTs are rejected before we ask the wallet to fund/sign or modify any state.
55+
56+
ChainPorter’s validateReadyForPublish is the final gate after coin selection and passive re-anchoring. Both stages call
57+
into ValidateAnchorInputs/Outputs, but at different points in the lifecycle and with different responsibilities.
58+
Removing either would either expose us to malformed user input (if
59+
we removed the RPC check) or allow inconsistencies to slip through right before broadcast (if we removed the ChainPorter
60+
check). The new change simply lets the early-stage information flow to the late stage so both checks see the same
61+
complete data.
62+
63+
Summary
64+
65+
- Returning pruned assets from the RPC-layer validation and carrying them through the parcel keeps tombstones/burns
66+
alive for the final validation step.
67+
- ChainPorter still gathers unspendables from InputCommitments when it has them; the new field only matters for
68+
pre‑anchored workflows that previously had no way to reproduce the missing leaves.
69+
- validateInputAssets remains necessary as the RPC boundary guard; the additional return value just makes its work
70+
reusable later.

0 commit comments

Comments
 (0)