Problem
The send/publish pipeline in tapfreighter and proof bakes in two assumptions that break lightweight, custom-anchor integrations that own their BTC anchor transaction and build deep transaction graphs (chained checkpoints) before anything confirms:
-
Only the last transition in a proof file may be unconfirmed. Everything else in the chain must have a confirmed anchor (valid block header + tx merkle proof). An integration can legitimately construct and verify a compact unconfirmed proof path (N chained transitions, none or all-but-one confirmed) inside the SDK, but tapd will not publish/log a package backed by such a path.
-
Every selected input proof must already live in the publishing tapd's local proof archive/asset store. When the anchor is externally owned and the intermediate checkpoints were never imported (they can't be — import requires confirmation), the publish path fails closed at input-proof lookup.
Separately, when the BTC anchor txid of a checkpoint changes (e.g. the graph is re-materialized/re-bound during construction), the downstream V1 asset witnesses need to be re-associated with the new virtual transaction. There is no stable, exported helper for this, so integrations are forced to mutate protocol structs (asset.Witness / asset.PrevID / PrevWitnesses[i].TxWitness) by hand.
Both are upstream gaps: the SDK (see Context) can build and verify the path, but cannot get a stock tapd to accept it, so it fails closed rather than produce an invalid or partially-anchored transfer.
Current behaviour
The relevant publish surface is RPCServer.PublishAndLogTransfer (rpcserver/rpcserver.go:3396), which wraps the caller's fully-signed anchor tx and vPSBTs into a PreAnchoredParcel (rpcserver/rpcserver.go:3484, type at tapfreighter/parcel.go:415) and ships it through the chain porter. The parcel enters at SendStateVerifyPreBroadcast (tapfreighter/chain_porter.go:1993) and later reaches SendStateStorePostAnchorTxConf (tapfreighter/chain_porter.go:2203). Both stages assume confirmed, archived inputs:
-
Pre-broadcast verify requires archived, confirmed inputs. verifyVPacketsPreBroadcast (tapfreighter/chain_porter.go:1508) calls verifyOutputProofPreBroadcast (tapfreighter/chain_porter.go:1555), which reconstructs a full proof File by fetching the input proof from the archive via p.fetchInputProof (tapfreighter/chain_porter.go:1636 and :1621) and then verifies it with proof.WithSkipChainVerificationForFinalProof() (tapfreighter/chain_porter.go:1670). That option only relaxes chain checks for the last proof: in File.Verify, WithSkipChainVerification is appended solely when idx == len(f.proofs)-1 (proof/verifier.go:1436-:1449). Every earlier transition still runs the full header + merkle verification in VerifyProofIntegrity (proof/verifier.go:1114-:1132). So a compact path whose non-final hops are unconfirmed fails here.
-
Input proofs must already be in the archive. fetchInputProof (tapfreighter/chain_porter.go:998) resolves the input via p.cfg.ProofReader.FetchProof (tapfreighter/chain_porter.go:1010; ProofReader proof.Exporter at tapfreighter/chain_porter.go:106). The funding side has the same dependency: createFundedPacketWithInputs → fetchInputProof → exporter.FetchProof (tapfreighter/fund.go:26, :470, :483). There is no way to feed a verified compact proof source that lives only in the caller/SDK.
-
You can't pre-load an unconfirmed input to satisfy the lookup. MultiArchiver.ImportProofs (proof/archive.go:1004) runs a full Verify on every proof before storing (proof/archive.go:1012), which requires confirmation. The confirmation-free ImportVerifiedProofs (interface VerifiedProofImporter at tapfreighter/chain_porter.go:53; impl proof/archive.go:775) is only invoked internally by the porter after the anchor confirms (tapfreighter/chain_porter.go:630, :723) and is not exposed as a path-aware ingest.
-
Post-confirmation store re-fetches inputs from the archive too. storeProofs (tapfreighter/chain_porter.go:565) → updateAssetProofFile (tapfreighter/chain_porter.go:1028) again calls fetchInputProof (tapfreighter/chain_porter.go:1049) to append the confirmed suffix onto the input proof file, so the archive dependency persists after confirmation.
-
Input assets must exist in the DB. PublishAndLogTransfer first runs validateInputAssets (rpcserver/rpcserver.go:3230), which calls AssetStore.FetchCommitment per input (rpcserver/rpcserver.go:3302); missing inputs degrade validation and can trip the downstream anchor-equality checks.
-
The building blocks for unconfirmed suffixes already exist. CommitVirtualPsbts (rpcserver/rpcserver.go:3011) produces per-output suffixes via tapsend.CreateProofSuffix (rpcserver/rpcserver.go:3174). CreateProofSuffixCustom explicitly supports a non-final or nil anchor tx and documents that "it must be set on the proof later manually to make the proof valid" (tapsend/proof.go:144-:150). So tapd can already emit an unconfirmed transition; what's missing is a path-aware consumer that verifies/stores/finalizes a multi-hop unconfirmed path instead of a single trailing suffix.
-
No stable witness-rebind helper. For V1 assets, the committed leaf excludes the raw witness — Asset.Leaf() uses EncodeNoWitness for V1 (asset/asset.go:2169, :2180), and Witness.EncodeNoWitness keeps prevID/split but drops the raw witness (asset/asset.go:923). The low-level primitive that binds a witness to its virtual input is asset.VirtualTxWithInput (asset/tx.go:57, documented as "used to further bind a given witness to the 'true' input it spends"). But the only public flow, SignVirtualTransaction, always re-derives and re-signs (newAsset.PrevWitnesses[idx].TxWitness = newWitness at tapsend/send.go:728). There is no exported helper that takes a complete V1 witness plus a new virtual transaction/anchor and returns a correctly rebound witness+asset, so integrations must hand-edit protocol structs when an anchor txid changes.
Proposed change
Pick one of the two ingest strategies (a is more general; b is smaller), and add the witness helper regardless:
(a) Path-aware verify/store/finalize. Introduce operations in the chain porter / proof package that distinguish an unconfirmed transition from a confirmed proof import, and that accept a caller-supplied verified compact proof source instead of always resolving inputs through ProofReader.FetchProof. Concretely: let verifyOutputProofPreBroadcast/fetchInputProof take an optional in-memory proof provider (the SDK's compact path), and relax File.Verify so a contiguous unconfirmed tail (not just the single last proof) can be skipped for chain verification while still validating asset-level state transitions. Persist such packages with ImportVerifiedProofs semantics and finalize (add block header/merkle proof, re-verify, watch for reorg) once each hop confirms.
(b) Post-confirmation rebind + reseal API. Add an RPC/method that, after the anchor confirms, rebinds the caller's materialized input proofs to the now-known anchor outpoints and reseals the package (recompute suffix chain data, append onto the confirmed input proof file, verify, import). This keeps the pre-broadcast path unchanged but removes the "inputs must already be archived" requirement for externally-anchored graphs.
Witness helper (both options). Export a stable helper — e.g. asset.RebindWitness / tapsend.RebindV1Witness — that takes a complete V1 witness and a new virtual transaction (new anchor PrevID.OutPoint) and returns the rebound witness/asset without re-signing, building on asset.VirtualTxWithInput and the EncodeNoWitness commitment semantics, so integrations never touch asset.Witness/PrevWitnesses directly. The exact home (asset vs tapsend) needs maintainer confirmation.
Context
Needed by lightninglabs/tap-sdk#158, which adds an advanced custom-anchor Taproot Assets transaction builder (used by SwapDK) where the caller owns the BTC anchor tx, commits assets via tapd, signs externally, and publishes/logs. The SDK can already construct and verify compact unconfirmed proof paths locally, but because tapd only tolerates a single unconfirmed (final) transition and insists that every input proof already be archived/confirmed (tapfreighter/chain_porter.go:1636, :1010; proof/verifier.go:1436), the SDK fails closed rather than hand tapd a package it would mis-handle — and it currently has to hand-edit protocol structs to re-anchor witnesses. This issue asks tapd to provide the path-aware ingest (or rebind/reseal) plus the stable witness-rebind helper so the SDK can stop failing closed.
Problem
The send/publish pipeline in
tapfreighterandproofbakes in two assumptions that break lightweight, custom-anchor integrations that own their BTC anchor transaction and build deep transaction graphs (chained checkpoints) before anything confirms:Only the last transition in a proof file may be unconfirmed. Everything else in the chain must have a confirmed anchor (valid block header + tx merkle proof). An integration can legitimately construct and verify a compact unconfirmed proof path (N chained transitions, none or all-but-one confirmed) inside the SDK, but tapd will not publish/log a package backed by such a path.
Every selected input proof must already live in the publishing tapd's local proof archive/asset store. When the anchor is externally owned and the intermediate checkpoints were never imported (they can't be — import requires confirmation), the publish path fails closed at input-proof lookup.
Separately, when the BTC anchor txid of a checkpoint changes (e.g. the graph is re-materialized/re-bound during construction), the downstream V1 asset witnesses need to be re-associated with the new virtual transaction. There is no stable, exported helper for this, so integrations are forced to mutate protocol structs (
asset.Witness/asset.PrevID/PrevWitnesses[i].TxWitness) by hand.Both are upstream gaps: the SDK (see Context) can build and verify the path, but cannot get a stock tapd to accept it, so it fails closed rather than produce an invalid or partially-anchored transfer.
Current behaviour
The relevant publish surface is
RPCServer.PublishAndLogTransfer(rpcserver/rpcserver.go:3396), which wraps the caller's fully-signed anchor tx and vPSBTs into aPreAnchoredParcel(rpcserver/rpcserver.go:3484, type attapfreighter/parcel.go:415) and ships it through the chain porter. The parcel enters atSendStateVerifyPreBroadcast(tapfreighter/chain_porter.go:1993) and later reachesSendStateStorePostAnchorTxConf(tapfreighter/chain_porter.go:2203). Both stages assume confirmed, archived inputs:Pre-broadcast verify requires archived, confirmed inputs.
verifyVPacketsPreBroadcast(tapfreighter/chain_porter.go:1508) callsverifyOutputProofPreBroadcast(tapfreighter/chain_porter.go:1555), which reconstructs a full proofFileby fetching the input proof from the archive viap.fetchInputProof(tapfreighter/chain_porter.go:1636and:1621) and then verifies it withproof.WithSkipChainVerificationForFinalProof()(tapfreighter/chain_porter.go:1670). That option only relaxes chain checks for the last proof: inFile.Verify,WithSkipChainVerificationis appended solely whenidx == len(f.proofs)-1(proof/verifier.go:1436-:1449). Every earlier transition still runs the full header + merkle verification inVerifyProofIntegrity(proof/verifier.go:1114-:1132). So a compact path whose non-final hops are unconfirmed fails here.Input proofs must already be in the archive.
fetchInputProof(tapfreighter/chain_porter.go:998) resolves the input viap.cfg.ProofReader.FetchProof(tapfreighter/chain_porter.go:1010;ProofReader proof.Exporterattapfreighter/chain_porter.go:106). The funding side has the same dependency:createFundedPacketWithInputs→fetchInputProof→exporter.FetchProof(tapfreighter/fund.go:26,:470,:483). There is no way to feed a verified compact proof source that lives only in the caller/SDK.You can't pre-load an unconfirmed input to satisfy the lookup.
MultiArchiver.ImportProofs(proof/archive.go:1004) runs a fullVerifyon every proof before storing (proof/archive.go:1012), which requires confirmation. The confirmation-freeImportVerifiedProofs(interfaceVerifiedProofImporterattapfreighter/chain_porter.go:53; implproof/archive.go:775) is only invoked internally by the porter after the anchor confirms (tapfreighter/chain_porter.go:630,:723) and is not exposed as a path-aware ingest.Post-confirmation store re-fetches inputs from the archive too.
storeProofs(tapfreighter/chain_porter.go:565) →updateAssetProofFile(tapfreighter/chain_porter.go:1028) again callsfetchInputProof(tapfreighter/chain_porter.go:1049) to append the confirmed suffix onto the input proof file, so the archive dependency persists after confirmation.Input assets must exist in the DB.
PublishAndLogTransferfirst runsvalidateInputAssets(rpcserver/rpcserver.go:3230), which callsAssetStore.FetchCommitmentper input (rpcserver/rpcserver.go:3302); missing inputs degrade validation and can trip the downstream anchor-equality checks.The building blocks for unconfirmed suffixes already exist.
CommitVirtualPsbts(rpcserver/rpcserver.go:3011) produces per-output suffixes viatapsend.CreateProofSuffix(rpcserver/rpcserver.go:3174).CreateProofSuffixCustomexplicitly supports a non-final ornilanchor tx and documents that "it must be set on the proof later manually to make the proof valid" (tapsend/proof.go:144-:150). So tapd can already emit an unconfirmed transition; what's missing is a path-aware consumer that verifies/stores/finalizes a multi-hop unconfirmed path instead of a single trailing suffix.No stable witness-rebind helper. For V1 assets, the committed leaf excludes the raw witness —
Asset.Leaf()usesEncodeNoWitnessforV1(asset/asset.go:2169,:2180), andWitness.EncodeNoWitnesskeepsprevID/split but drops the raw witness (asset/asset.go:923). The low-level primitive that binds a witness to its virtual input isasset.VirtualTxWithInput(asset/tx.go:57, documented as "used to further bind a given witness to the 'true' input it spends"). But the only public flow,SignVirtualTransaction, always re-derives and re-signs (newAsset.PrevWitnesses[idx].TxWitness = newWitnessattapsend/send.go:728). There is no exported helper that takes a complete V1 witness plus a new virtual transaction/anchor and returns a correctly rebound witness+asset, so integrations must hand-edit protocol structs when an anchor txid changes.Proposed change
Pick one of the two ingest strategies (a is more general; b is smaller), and add the witness helper regardless:
(a) Path-aware verify/store/finalize. Introduce operations in the chain porter / proof package that distinguish an unconfirmed transition from a confirmed proof import, and that accept a caller-supplied verified compact proof source instead of always resolving inputs through
ProofReader.FetchProof. Concretely: letverifyOutputProofPreBroadcast/fetchInputProoftake an optional in-memory proof provider (the SDK's compact path), and relaxFile.Verifyso a contiguous unconfirmed tail (not just the single last proof) can be skipped for chain verification while still validating asset-level state transitions. Persist such packages withImportVerifiedProofssemantics and finalize (add block header/merkle proof, re-verify, watch for reorg) once each hop confirms.(b) Post-confirmation rebind + reseal API. Add an RPC/method that, after the anchor confirms, rebinds the caller's materialized input proofs to the now-known anchor outpoints and reseals the package (recompute suffix chain data, append onto the confirmed input proof file, verify, import). This keeps the pre-broadcast path unchanged but removes the "inputs must already be archived" requirement for externally-anchored graphs.
Witness helper (both options). Export a stable helper — e.g.
asset.RebindWitness/tapsend.RebindV1Witness— that takes a complete V1 witness and a new virtual transaction (new anchorPrevID.OutPoint) and returns the rebound witness/asset without re-signing, building onasset.VirtualTxWithInputand theEncodeNoWitnesscommitment semantics, so integrations never touchasset.Witness/PrevWitnessesdirectly. The exact home (asset vs tapsend) needs maintainer confirmation.Context
Needed by lightninglabs/tap-sdk#158, which adds an advanced custom-anchor Taproot Assets transaction builder (used by SwapDK) where the caller owns the BTC anchor tx, commits assets via tapd, signs externally, and publishes/logs. The SDK can already construct and verify compact unconfirmed proof paths locally, but because tapd only tolerates a single unconfirmed (final) transition and insists that every input proof already be archived/confirmed (
tapfreighter/chain_porter.go:1636,:1010;proof/verifier.go:1436), the SDK fails closed rather than hand tapd a package it would mis-handle — and it currently has to hand-edit protocol structs to re-anchor witnesses. This issue asks tapd to provide the path-aware ingest (or rebind/reseal) plus the stable witness-rebind helper so the SDK can stop failing closed.