Skip to content

proof+tapfreighter: support unconfirmed multi-hop proof paths end to end #2211

Description

@darioAnongba

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:

  1. 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.

  2. 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: createFundedPacketWithInputsfetchInputProofexporter.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    Status
    🆕 New

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions