Skip to content

assets: Add shared asset tooling - #1225

Open
hieblmi wants to merge 5 commits into
lightninglabs:masterfrom
hieblmi:codex/shared-asset-swapkit
Open

assets: Add shared asset tooling#1225
hieblmi wants to merge 5 commits into
lightninglabs:masterfrom
hieblmi:codex/shared-asset-swapkit

Conversation

@hieblmi

@hieblmi hieblmi commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • make Loop the single home for the shared asset HTLC scripts, immutable
    SwapKit, legacy deposit kit, and generic OP_TRUE virtual-packet sweep
    helper
  • preserve the existing deposit contract as the explicit
    LegacyDepositV0 policy, pinned by golden script, key, anchor, witness, and
    virtual-packet vectors
  • expose narrow boundaries that both the client and server can import
  • use Taproot Assets v0.8.3, taprpc v1.3.3, and LND v0.21.3-beta,
    with Go 1.25.13 and classic btcd; this prototype does not add tap-sdk

Security and correctness boundaries

The shared kit verifies proofs before using them and binds every Bitcoin
spend to the proof's exact anchor outpoint, output value, script, commitment
root, and unique PSBT input. It supplies every prevout to the signer, applies
the required CSV sequence to the matched input without mutating the caller's
PSBT on failure, rejects malformed signer responses, verifies the returned
Schnorr signature, and returns the matched input index to the caller.

The OP_TRUE helper rejects empty, nil, invalid, non-OP_TRUE, mixed-asset,
overflowing, and amount-mismatched proof sets. It derives the network from the
destination address and validates the prepared output and split-root witness
before attaching the asset witness.

Network validation distinguishes shared testnet HRPs by Bitcoin network
magic. Simnet explicitly accepts both btcd's native BIP-0044 coin type 115 and
lnd's testnet-compatible coin type 1 without relying on mutable global state.

Feature state machines remain responsible for trusted proof import,
canonical-chain and confirmation tracking, reorg handling, destination
validation, quote and fee limits, and durable recovery. This PR adds no Asset
Loop Out RPC, funding flow, persistence, or state machine, and does not alter
conventional Loop In or Asset Loop In.

This is prototype infrastructure, not a release or rollout change.

Verification

  • GOTOOLCHAIN=go1.25.13 go test ./... -count=1 -timeout=10m
  • GOTOOLCHAIN=go1.25.13 go test -race ./assets/... -count=1
  • GOTOOLCHAIN=go1.25.13 go vet ./assets/...
  • GOTOOLCHAIN=go1.25.13 CGO_ENABLED=0 go build -tags=dev ./cmd/loop ./cmd/loopd
  • GOTOOLCHAIN=go1.25.13 go mod verify in the root and looprpc modules
  • GOTOOLCHAIN=go1.25.13 go mod tidy -diff in the root, looprpc, and
    swapserverrpc modules
  • GOTOOLCHAIN=go1.25.13 go test ./... -count=1 in the looprpc and
    swapserverrpc modules
  • make commitmsg-lint range=origin/master..HEAD
  • git diff --check origin/master..HEAD

@hieblmi hieblmi added the no-changelog Skip the release notes CI check label Sep 2, 2026
@hieblmi
hieblmi force-pushed the codex/shared-asset-swapkit branch 3 times, most recently from f805e70 to c206f0d Compare September 3, 2026 05:32
Move the existing server asset HTLC contract into Loop so both
sides can derive and spend the same commitment. Freeze the legacy
vectors and bind witness construction to verified proofs, prevouts,
and input indices.

Reserve future policies so Loop Asset Out can choose its contract
explicitly instead of inheriting the deposit key path.
@hieblmi
hieblmi force-pushed the codex/shared-asset-swapkit branch from c206f0d to 2814bf6 Compare September 3, 2026 05:54
@hieblmi hieblmi added asset loop out Taproot Asset Loop Out work and removed no-changelog Skip the release notes CI check labels Sep 3, 2026
@hieblmi hieblmi changed the title assets: Add shared asset HTLC kit assets: Add shared asset tooling Sep 3, 2026
@hieblmi hieblmi added the no-changelog Skip the release notes CI check label Sep 3, 2026
@hieblmi
hieblmi force-pushed the codex/shared-asset-swapkit branch from a33d139 to 4283082 Compare September 3, 2026 08:08
Consolidate the remaining deposit and OP_TRUE virtual-packet helpers
behind Loop-owned packages. Replace positional sweep assumptions with
proof-bound input selection, complete prevout validation, and explicit
signature verification.
@hieblmi
hieblmi force-pushed the codex/shared-asset-swapkit branch from 4283082 to 50595de Compare September 3, 2026 08:12
@hieblmi hieblmi self-assigned this Sep 3, 2026
Reject non-block deposit expiries. This prevents BIP68 flags from
changing or disabling the intended block delay.

Canonicalize OP_TRUE keys, populate and validate every virtual input
witness, and reject unsupported addresses and duplicate anchor inputs.
Return the complete anchor Merkle root from proof verification.

This lets MuSig2 spends reproduce the output's Taproot tweak.
@hieblmi
hieblmi marked this pull request as ready for review September 3, 2026 12:22
@hieblmi

hieblmi commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

/gateway review

@lightninglabs-gateway

Copy link
Copy Markdown

👀 gateway review starting…

@starius starius left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Style — ~18 unexported funcs and ~14 test helpers have no godoc, against the convention. Non-test: canonicalAddressParams, cloneAddressParams (both copies), validateScripts, genBtcControlBlock, validateAsset (both), findUniqueInput (both), validateSweep (both), validateSequence, verifyTapscriptSignature (both), timeoutPathSibling, encodedTimeoutPathSibling, newHtlcSwapKit, validateProof.

Comment thread assets/tapkit.go
}

sweepVpkt, err := tappsbt.FromProofs(
proofs, addr.ChainParams, tappsbt.V1,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Above we accept both V0 and V1, but map both to V1. V0 addresses accepted, but built as a V1/interactive send.

  1. Upstream's own mapping (tappsbt/create.go:39-45) is V0→V0, V1→V1, and tappsbt.CommitmentVersion (tappsbt/interface.go:198-210) turns V1 into a mandatory TapCommitmentV2 while V0 leaves it open. A V0 address therefore gets an output commitment it doesn't describe.
  2. The other half is worse: upstream builds V0/V1 address sends as non-interactive with a split-root tombstone, with an explicit comment that V0/V1 receivers need that to predict the anchor output key. Loop builds a single Interactive: true full-value output. So for V0 and V1 addresses the interactivity is wrong too.
    address.V0 == 0, so an unset Version silently takes this path — and that's exactly what the fixtures do (assets/tapkit_test.go:64,113 construct address.Tap with no Version, paired with TapCommitmentV2 proofs). The only success-path test that sets a version uses address.V1. The V0 path has no coverage and fails silently.

Fix: reject non-V1.

Comment thread assets/tapkit.go
if assetProof == nil {
return nil, fmt.Errorf("asset proof %d is nil", idx)
}
if _, err := assetProof.VerifyProofs(); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

malformed proofs panic inside VerifyProofs

Loop's exposure by call ordering: assets/tapkit.go:66 calls VerifyProofs before its script-key nil check at :73 (exposed to both); exported GenTaprootAssetRootFromProof (swapkit.go:489) validates nothing (exposed to both); deposit/kit.go:353 and swapkit.go:614 guard the script key first but check the internal key only afterwards at :357 — or never (exposed to the internal-key panic).

Comment thread assets/deposit/kit.go
if !bytes.Equal(d.assetID[:], proofID[:]) {
return fmt.Errorf("asset proof ID does not match deposit")
}
if depositAsset.Amount == 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

validateAsset only rejects Amount == 0. Kit stores no expected amount even though NewAddr takes one, so VerifyProof succeeds for any positive amount of the right asset at the right script key. A caller reading it as "the deposit is funded" would accept an underfunded deposit. (GroupKey is also unchecked, unlike the swapkit path.)

Comment thread assets/deposit/kit.go
return nil, err
}

tapCommitment, err := depositProof.VerifyProofs()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

VerifyProofs() is a commitment-structure check, not a proof-validity check, and VerifyProof exposes its result as a standalone verdict.

Proof.VerifyProofs() verifies only the inclusion proof, the split-root proof (when the asset has a split commitment witness), the exclusion proofs, and that the inclusion/exclusion commitment versions are consistent. It deliberately stops there — compare it with VerifyProofIntegrity, which additionally checks the proof version, Asset.Validate(), TxSpendsPrevOut, and runs HeaderVerifier + MerkleVerifier plus the genesis/group reveals; and with Proof.Verify, which on top of that runs verifyAssetStateTransition (previous witnesses and amount conservation through the asset VM).

So what we establish here is: "some Taproot Asset commitment containing this asset leaf is committed to by this anchor output, and the sibling and internal key are the ones this deposit expects." What we do not establish:

  • that the anchor transaction is in a block (no header or merkle verification);
  • that the asset has valid provenance back to genesis, or a valid group key;
  • amount conservation from the previous witnesses;
  • that the anchor outpoint is confirmed, or still unspent.

A single *proof.Proof cannot give us provenance at all — the chain back to genesis lives in the proof.File.

Why this call site specifically

The other VerifyProofs() callers in this branch are fine as-is. validateSweep (kit.go:467) and htlc.SwapKit.validateSweep / GetPkScriptFromProof use the returned commitment to reconstruct a pkScript that is then compared against the prevout being spent (kit.go:514-536). A forged proof there just yields a script that does not exist on chain, and the sweep fails — no trust decision is taken.

VerifyProof (kit.go:389) is different: it returns the anchor root and nothing else, and its name and doc invite a caller to read it as "this deposit is real." It is the one place where a commitment-only result escapes as a verdict someone can act on. Once Nautilus or Asset Loop In consumes this package, that is the line where a counterfeit or unconfirmed asset state would be accepted.

Suggested fix

Split the two concerns so the trust decision cannot be reached by accident:

  1. Keep today's cheap reconstruction, renamed to say what it does (e.g. AnchorRootFromProofCommitment), with a doc note that it establishes nothing about the asset's validity or on-chain existence.
  2. Add one explicitly-named verification entry point that takes a *proof.File plus an expected amount. Options, roughly in order of fit for a client:
    • Delegate to tapd. taprpc.TaprootAssets.VerifyProof(ProofFile) → {Valid, DecodedProof} already exists in our pinned taprpc v1.2.0, and tapd has a fully wired VerifierCtx against its own chain backend. AddressProofClient (kit.go:33-38) already narrows tapd to NewAddr/ExportProof; adding VerifyProof is a small extension in the existing style and needs no dependency bump.
    • Verify in-process via proof.Verify with a real VerifierCtx, backing HeaderVerifier/MerkleVerifier with lndclient's ChainKit.GetBlockHeader / GetBlock (available in our pinned lndclient; loop does not use ChainKit today, so this is new plumbing).
    • For the unconfirmed case — which is what Asset Loop In actually needs, since HTLC funding has to be validated before the anchor confirms — proof.VerifyProofSuffix. It runs proof integrity, inclusion/exclusion, anchor input spending checks and the asset VM against the supplied input files, skipping only the suffix's own header, merkle proof and timelock checks. Note it does not exist in v0.8.1; it is new on taproot-assets main, so it would require bumping the pin.

Whichever we pick, these remain our job at the kit level, because no verifier does them for us:

  • proof outpoint equals the outpoint we expect, and its pkScript equals the one we derived ourselves from this Kit (we already do this in validateSweep; VerifyProof does not);
  • amount equals the expected amount, and asset ID / group key match;
  • confirmation depth, from our own chain source;
  • the anchor outpoint is still unspent — no proof can ever tell us this.

Given the package has no callers yet, this does not have to block landing the foundation, but it should be settled before anything treats these helpers as a validation boundary.

Comment thread assets/deposit/kit.go
muSig2Key, err := input.MuSig2CombineKeys(
input.MuSig2Version100RC2,
[]*btcec.PublicKey{funderKeyCopy, coSignerKeyCopy}, sortKeys,
&input.MuSig2Tweaks{TaprootBIP0086Tweak: true},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

TaprootBIP0086Tweak: true here is inert, and it is a footgun to leave in place.

Only PreTweakedKey is ever read from muSig2Key (kit.go:198, :359, :419, :429, :529), and btcd snapshots that value before applying any tweak (schnorr/musig2/keys.go:395-399; the taproot branch at :403 normalizes a local copy and mutates only finalKeyJFinalKey). So this flag cannot change any key this kit produces, parity included.

I think this is residue from adapting staticaddr/script/script.go:62-99, which is the same shape — two keys plus a CSV timeout leaf — but computes rootHash from the script tree up front and keeps both PreTweakedKey and FinalKey. That does not work for an asset anchor: the root is tapCommitment.TapscriptRoot(siblingHash) and is unknown until the deposit lands and we hold a proof, which is exactly why VerifyProof returns it. TaprootBIP0086Tweak is the only tweak variant that needs no root hash, so it satisfies the struct without data we do not have yet.

Worth removing rather than just tidying, because MuSig2Tweaks is also the signing-time config (ToContextOptions / MuSig2CreateContext) and TaprootBIP0086Tweak takes precedence over TaprootTweak — lnd ignores the script root when the BIP86 flag is set (input/musig2.go:220-229, :358-363). If someone later stores this struct on the Kit and adds the real anchor root for the MuSig2 key-path spend without spotting the flag, the root is silently discarded and they get BIP86 key-spend-only signatures for an output that has both a CSV leaf and an asset commitment.

Suggest matching htlc.GetAggregateKey (swapkit.go:289), which passes an empty tweak set and documents the intent:

	&input.MuSig2Tweaks{},

plus a comment noting that the BIP341 tweak is applied later at signing time, using the anchor root from VerifyProof.

Comment thread assets/deposit/kit.go
return nil, err
}
sweepTx := sweepPacket.UnsignedTx.Copy()
sweepTx.TxIn[sweep.assetInputIndex].Sequence = d.csvExpiry

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Divergent contracts: deposit.CreateTimeoutWitness sets the CSV sequence itself and writes it back into the caller's PSBT (kit.go:591,631); htlc.CreateTimeoutWitness requires the caller to have set it and rejects any mismatch. Easy to get wrong.

Comment thread assets/htlc/swapkit.go
func cloneAddressParams(params address.ChainParams) *address.ChainParams {
paramsCopy := params
if params.Params != nil {
bitcoinParams := *params.Params

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This derefs params.Params unguarded; the htlc twin guards it. Safe via NewKit today, a landmine if reused.

Align taprpc and LND with the Taproot Assets v0.8.3 dependency graph.
Raise the minimum Go build version to 1.25.13 and refresh module sums
for both the main and client RPC modules.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

asset loop out Taproot Asset Loop Out work no-changelog Skip the release notes CI check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants