diff --git a/go.mod b/go.mod index 23a11cf32..3ef306063 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,7 @@ require ( github.com/btcsuite/btcd/txscript/v2 v2.0.0 github.com/btcsuite/btcd/wire/v2 v2.0.0 github.com/lightninglabs/go-wasmsqlite v0.0.0-20260627090804-0dce68fc5287 - github.com/lightninglabs/tap-sdk v0.1.1-0.20260810103129-7c691597f424 + github.com/lightninglabs/tap-sdk v0.1.1-0.20260811140317-fcd41ffe5729 ) require ( diff --git a/go.sum b/go.sum index 72ef5070e..eef14cdcf 100644 --- a/go.sum +++ b/go.sum @@ -1078,16 +1078,8 @@ github.com/lightninglabs/neutrino/cache v1.1.4 h1:KVtvUmBwYr7eKtjfjHG/q6/cQlcrjH github.com/lightninglabs/neutrino/cache v1.1.4/go.mod h1:ZqLzxghPggIRfNXeAZN1VtTKofMyK/ALI6niYsPH6OE= github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display h1:Y2WiPkBS/00EiEg0qp0FhehxnQfk3vv8U6Xt3nN+rTY= github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260807130524-b65dc1fab1e2 h1:HKv71GoDQ5cCZWqAKbG4fptPc++lvmies/6YPpBQUKY= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260807130524-b65dc1fab1e2/go.mod h1:RI0Z+A0ySP5itemPcUcSe8czEjczOzRk9IK6zaF9rnc= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260808180102-4e89a693a257 h1:vE6BB2ZrcSlc+VUlvE0TdcnsVeGTAhCeUfm35AtSzYk= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260808180102-4e89a693a257/go.mod h1:RI0Z+A0ySP5itemPcUcSe8czEjczOzRk9IK6zaF9rnc= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260808182145-9d7785c9e99d h1:YQrqp5QFPFkb6ezhmFUh/vNCX/vlhOlZ7CiCzLfvz/o= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260808182145-9d7785c9e99d/go.mod h1:RI0Z+A0ySP5itemPcUcSe8czEjczOzRk9IK6zaF9rnc= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260808183551-5be3d28f91b5 h1:VHSStSmn9LunEyyOI5szaN2Pnj3gKoa9QqXxOL1meVo= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260808183551-5be3d28f91b5/go.mod h1:RI0Z+A0ySP5itemPcUcSe8czEjczOzRk9IK6zaF9rnc= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260810103129-7c691597f424 h1:vcyFzQRO8bSPH8B5KEhIupjR0s8ZA3viuLTjoSHSJhs= -github.com/lightninglabs/tap-sdk v0.1.1-0.20260810103129-7c691597f424/go.mod h1:RI0Z+A0ySP5itemPcUcSe8czEjczOzRk9IK6zaF9rnc= +github.com/lightninglabs/tap-sdk v0.1.1-0.20260811140317-fcd41ffe5729 h1:Nwhv76nu4l62CKFzfMmwgAntrUrGVXGFDAXyDTr6oRo= +github.com/lightninglabs/tap-sdk v0.1.1-0.20260811140317-fcd41ffe5729/go.mod h1:RI0Z+A0ySP5itemPcUcSe8czEjczOzRk9IK6zaF9rnc= github.com/lightninglabs/taproot-assets v0.7.1-0.20260804093026-95bd8d412bef h1:4vHoQMzUkpkPoNKHT+QdTUbWoWaz0T5CDx6CWsforXM= github.com/lightninglabs/taproot-assets v0.7.1-0.20260804093026-95bd8d412bef/go.mod h1:fm0WaNsDpP7jEgR9zMap3hpum7lXVDtjsi783MW9BxY= github.com/lightninglabs/taproot-assets/taprpc v1.1.1-0.20260804093026-95bd8d412bef h1:3uhbpFZUp3JwLxSlhdelA56g0dULFfcYlC6Q/wQICk0= diff --git a/tapassets/claim.go b/tapassets/claim.go new file mode 100644 index 000000000..aeb123001 --- /dev/null +++ b/tapassets/claim.go @@ -0,0 +1,366 @@ +package tapassets + +import ( + "bytes" + "context" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/psbt/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + tapsdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tx/psbtutil" +) + +// ClaimRequest describes one confirmed asset VTXO being claimed into the +// owner's tapd wallet after a unilateral exit put its anchor on chain. +type ClaimRequest struct { + // Outpoint is the claimed VTXO's anchor outpoint, confirmed on + // chain by the exit. + Outpoint wire.OutPoint + + // CarrierValueSat is the anchor output's Bitcoin value. + CarrierValueSat int64 + + // AssetRef and AssetAmount identify the claimed units. + AssetRef string + AssetAmount uint64 + + // TaprootAssetRoot is the anchor's asset commitment root. + TaprootAssetRoot chainhash.Hash + + // SealedPackage is the sealed transition package behind the VTXO, + // the leaf's only lineage. + SealedPackage []byte + + // Confirmations carries the raw block and height for every anchor + // transaction in the leaf's unconfirmed lineage, keyed by txid. + Confirmations map[chainhash.Hash]tapsdk.AnchorConfirmation + + // ExitDelay is the CSV delay of the exit leaf being spent; it + // becomes the claim input's sequence. + ExitDelay uint32 + + // FeeSat is the miner fee, paid out of the carrier value. + FeeSat int64 + + // SignExit signs the claim transaction's single input, the composed + // leaf's exit path, and returns the finalized PSBT. + SignExit tapsdk.AnchorSigner +} + +// ClaimResult reports the published claim. +type ClaimResult struct { + // Txid is the claim transaction ID. + Txid chainhash.Hash + + // AnchorOutpoint is the new tapd-owned anchor holding the claimed + // units. + AnchorOutpoint wire.OutPoint + + // OutputValueSat is the new anchor's Bitcoin value. + OutputValueSat int64 +} + +// ClaimAssetVTXO moves an exited asset VTXO's units into the owner's tapd +// wallet. The VTXO's compact lineage is completed into a confirmed proof +// file, and one custom-anchor transition spends the composed leaf output +// through its exit path into a fresh wallet-owned anchor: the script key +// and internal key both come from tapd, so the units become ordinary, +// spendable wallet balance once the claim confirms. The workflow is +// stateless; a failed attempt is retried from scratch. +func ClaimAssetVTXO(ctx context.Context, wallet *tapsdk.Wallet, + req *ClaimRequest) (*ClaimResult, error) { + + if wallet == nil { + return nil, fmt.Errorf("tap-sdk wallet is required") + } + if req == nil || req.SignExit == nil { + return nil, fmt.Errorf("claim request and exit signer are " + + "required") + } + if req.ExitDelay == 0 { + return nil, fmt.Errorf("exit delay is required") + } + outputValue := req.CarrierValueSat - req.FeeSat + if req.FeeSat <= 0 || outputValue < int64(onboardingDustFloorSat) { + return nil, fmt.Errorf("claim fee %d leaves output %d below "+ + "the Taproot dust floor", req.FeeSat, outputValue) + } + + // The sealed package is the leaf's lineage: resolve its compact + // proof path and OP_TRUE witness, then complete the path into a + // confirmed proof file now that the exit put every hop on chain. + source, err := ResolveCreatedAssetProofSource( + req.SealedPackage, req.Outpoint, req.CarrierValueSat, + req.AssetRef, req.AssetAmount, req.TaprootAssetRoot, + ) + if err != nil { + return nil, fmt.Errorf("resolve claim lineage: %w", err) + } + + var path tapsdk.AssetProofPath + if err := path.UnmarshalBinary(source.CompactProofPath); err != nil { + return nil, fmt.Errorf("decode claim proof path: %w", err) + } + confirmedFile, err := path.ConfirmProofFile( + claimConfirmations(req.Confirmations), + ) + if err != nil { + return nil, fmt.Errorf("confirm claim proof file: %w", err) + } + + // The publish step verifies the transition against tapd's own proof + // archive, so the leaf's lineage must be imported first. Importing + // needs the leaf's OP_TRUE script key declared; its spec is fully + // recoverable from the witness stack's script and control block. + if err := importClaimLineage( + ctx, wallet, source, confirmedFile, + ); err != nil { + return nil, err + } + + assetRef, err := tapsdk.ParseAssetRef(req.AssetRef) + if err != nil { + return nil, fmt.Errorf("parse claim asset ref: %w", err) + } + internalKey, err := wallet.Client().DeriveInternalKey(ctx) + if err != nil { + return nil, fmt.Errorf("derive claim anchor internal key: %w", + err) + } + internalPub, err := btcec.ParsePubKey(internalKey.PubKey[:]) + if err != nil { + return nil, fmt.Errorf("parse claim anchor internal key: %w", + err) + } + + buildRequest := func(anchorPSBT []byte) *tapsdk.CustomAnchorRequest { + return &tapsdk.CustomAnchorRequest{ + Inputs: []tapsdk.CustomAssetInput{{ + ID: "wavelength-claim-input-0", + AssetRef: assetRef, + Amount: req.AssetAmount, + ProofFile: confirmedFile, + Witness: tapsdk.CustomAssetWitnessPlan{ + Mode: witnessCallerProvided, + Stack: cloneByteSlices( + source.OPTrueWitness, + ), + }, + }}, + Outputs: []tapsdk.CustomAssetOutput{{ + ID: "wavelength-claim-output-0", + AssetRef: assetRef, + Amount: req.AssetAmount, + AnchorOutputIndex: 0, + AnchorValueSat: uint64(outputValue), + Script: tapsdk.CustomAssetScriptPlan{ + Mode: tapsdk.CustomAssetScriptWallet, + Wallet: &tapsdk. + CustomAssetWalletScriptPlan{}, + }, + Anchor: anchorPlan(internalPub, nil), + }}, + AnchorPSBT: anchorPSBT, + Funding: tapsdk.CustomAnchorFundingPlan{ + Mode: tapsdk. + CustomAnchorFundingCallerFundedExact, + CallerFundedExact: &tapsdk. + CustomAnchorCallerFundedExact{}, + }, + PassiveAssets: tapsdk.CustomAnchorPassiveAssets{ + Policy: tapsdk.CustomAnchorPassiveReject, + }, + LossPolicy: tapsdk.CustomAnchorLossPolicy{ + Mode: tapsdk.CustomAnchorLossReject, + }, + SigningPlans: []tapsdk.CustomAnchorInputSigningPlan{{ + InputIndex: 0, + CallerSigned: &tapsdk. + CustomAnchorCallerSignedPlan{}, + }}, + } + } + + // The wallet script plan derives a fresh asset script key at commit + // time, so the anchor output script is only knowable once tapd + // commits: the template ships a placeholder and the committed + // transaction carries the authoritative script. + driver := &sdkDriver{wallet: wallet} + anchorPSBT, err := claimAnchorPSBT(req, nil) + if err != nil { + return nil, err + } + committed, err := driver.CommitClaim( + ctx, buildRequest(anchorPSBT), nil, + ) + if err != nil { + return nil, fmt.Errorf("commit claim transition: %w", err) + } + if len(committed.outputs) != 1 || + committed.outputs[0].anchorOutputIndex != 0 || + committed.outputs[0].anchorValueSat != outputValue || + committed.outputs[0].scriptMode != + tapsdk.CustomAssetScriptWallet || + committed.outputs[0].amount != req.AssetAmount { + return nil, fmt.Errorf("committed claim diverges from the " + + "request") + } + committedPacket, err := psbtutil.Parse(committed.anchorPSBT) + if err != nil { + return nil, fmt.Errorf("parse committed claim PSBT: %w", err) + } + if len(committedPacket.UnsignedTx.TxOut) != 1 || + len(committedPacket.UnsignedTx.TxIn) != 1 || + committedPacket.UnsignedTx.TxIn[0].Sequence != req.ExitDelay { + return nil, fmt.Errorf("committed claim transaction shape " + + "diverges from the template") + } + claimScript := committedPacket.UnsignedTx.TxOut[0].PkScript + + finalPSBT, err := req.SignExit(ctx, committed.anchorPSBT) + if err != nil { + return nil, fmt.Errorf("sign claim exit spend: %w", err) + } + if err := driver.VerifyFinalOnboarding( + committed.packageBytes, finalPSBT, + ); err != nil { + return nil, fmt.Errorf("verify final claim PSBT: %w", err) + } + if err := driver.PublishOnboarding( + ctx, committed.packageBytes, finalPSBT, + ); err != nil { + return nil, fmt.Errorf("publish claim transfer: %w", err) + } + + packet, err := psbtutil.Parse(finalPSBT) + if err != nil { + return nil, fmt.Errorf("parse final claim PSBT: %w", err) + } + txid := packet.UnsignedTx.TxHash() + if len(packet.UnsignedTx.TxOut) == 0 || !bytes.Equal( + packet.UnsignedTx.TxOut[0].PkScript, claimScript, + ) { + return nil, fmt.Errorf("final claim output script diverges " + + "from its derivation") + } + + return &ClaimResult{ + Txid: txid, + AnchorOutpoint: wire.OutPoint{ + Hash: txid, + Index: 0, + }, + OutputValueSat: outputValue, + }, nil +} + +// importClaimLineage declares the exited leaf's OP_TRUE script key and +// imports its confirmed proof file into the owner's tapd, which archives +// the lineage the publish step verifies against. Declaring is correct at +// claim time: the anchor is on chain under the owner's exclusive control, +// and the claim spends it in the same flow. +func importClaimLineage(ctx context.Context, wallet *tapsdk.Wallet, + source *CreatedAssetProofSource, confirmedFile []byte) error { + + if len(source.OPTrueWitness) != 2 { + return fmt.Errorf("claim OP_TRUE witness has %d items, "+ + "expected script and control block", + len(source.OPTrueWitness)) + } + leafScript := source.OPTrueWitness[0] + controlBlock, err := txscript.ParseControlBlock( + source.OPTrueWitness[1], + ) + if err != nil { + return fmt.Errorf("parse claim OP_TRUE control block: %w", err) + } + tapTweak := controlBlock.RootHash(leafScript) + + scriptKey, err := tapsdk.ParsePubKey(source.ScriptKey[:]) + if err != nil { + return fmt.Errorf("parse claim script key: %w", err) + } + internalKey, err := tapsdk.ParsePubKey( + controlBlock.InternalKey.SerializeCompressed(), + ) + if err != nil { + return fmt.Errorf("parse claim script internal key: %w", err) + } + + _, err = wallet.Client().DeclareScriptKey( + ctx, &tapsdk.DeclareScriptKeyRequest{ + ScriptKey: tapsdk.ScriptKey{ + PubKey: scriptKey, + KeyDesc: tapsdk.KeyDescriptor{ + RawKeyBytes: internalKey, + }, + TapTweak: append([]byte(nil), tapTweak...), + }, + }, + ) + if err != nil { + return fmt.Errorf("declare claim script key: %w", err) + } + + if _, err := wallet.ImportProofFile(ctx, &tapsdk.ProofFile{ + RawProofFile: confirmedFile, + }); err != nil { + return fmt.Errorf("import claim proof file: %w", err) + } + + return nil +} + +// claimConfirmations converts the caller's chainhash-keyed block data into +// the SDK's hash-keyed shape. +func claimConfirmations( + confirmations map[chainhash.Hash]tapsdk.AnchorConfirmation, +) map[tapsdk.Hash]tapsdk.AnchorConfirmation { + + converted := make( + map[tapsdk.Hash]tapsdk.AnchorConfirmation, len(confirmations), + ) + for txid, confirmation := range confirmations { + converted[tapsdk.Hash(txid)] = confirmation + } + + return converted +} + +// claimAnchorPSBT builds the claim's caller-funded anchor template: one +// input spending the exited leaf with the exit leaf's CSV sequence, and +// one output paying the claimed carrier value minus fees. A nil script +// selects a placeholder for the preview pass. +func claimAnchorPSBT(req *ClaimRequest, script []byte) ([]byte, error) { + if script == nil { + placeholderKey := txscript.ComputeTaprootKeyNoScript( + &arkscript.ARKNUMSKey, + ) + placeholder, err := txscript.PayToTaprootScript(placeholderKey) + if err != nil { + return nil, err + } + script = placeholder + } + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: req.Outpoint, + Sequence: req.ExitDelay, + }) + tx.AddTxOut(&wire.TxOut{ + Value: req.CarrierValueSat - req.FeeSat, + PkScript: script, + }) + packet, err := psbt.NewFromUnsignedTx(tx) + if err != nil { + return nil, fmt.Errorf("build claim anchor PSBT: %w", err) + } + + return psbtutil.Serialize(packet) +} diff --git a/tapassets/driver.go b/tapassets/driver.go index 8a15ac4a3..7e5c0eace 100644 --- a/tapassets/driver.go +++ b/tapassets/driver.go @@ -365,3 +365,39 @@ func (e *commitResponseError) Error() string { func (e *commitResponseError) Unwrap() error { return e.err } + +// CommitClaim commits a claim transition whose anchor transaction the +// caller finalizes and tap-sdk publishes with the claim label. +func (d *sdkDriver) CommitClaim(ctx context.Context, + request *tapsdk.CustomAnchorRequest, + verifier tapsdk.ConfirmedProofVerifier) (*commitResult, error) { + + if d == nil || d.wallet == nil { + return nil, fmt.Errorf("tap-sdk wallet is required") + } + + builder := d.wallet.NewCustomAnchorTxBuilder() + if verifier != nil { + builder.SetConfirmedProofVerifier(verifier) + } + plan, err := builder.Build(ctx, request) + if err != nil { + return nil, err + } + + result, err := plan.Commit(ctx, tapsdk.CustomAnchorCommitOptions{ + Publish: tapsdk.CustomAnchorPublishMetadata{ + Label: "wavelength-claim", + }, + }) + if err != nil { + return nil, err + } + + converted, err := commitResultFromPackage(result) + if err != nil { + return nil, &commitResponseError{err: err} + } + + return converted, nil +} diff --git a/tapassets/proof_source.go b/tapassets/proof_source.go index 349ff0a4d..cc57dfe9d 100644 --- a/tapassets/proof_source.go +++ b/tapassets/proof_source.go @@ -49,6 +49,7 @@ type CreatedAssetProofSource struct { CarrierValueSat int64 AssetRef string AssetAmount uint64 + ScriptKey [33]byte TaprootAssetRoot chainhash.Hash ProofSourceKind ProofSourceKind ProofSourceID [32]byte @@ -199,6 +200,7 @@ func resolveCreatedAssetProofSource(committed *commitResult, CarrierValueSat: selected.anchorValueSat, AssetRef: selected.assetRef.String(), AssetAmount: selected.amount, + ScriptKey: selected.scriptKey, TaprootAssetRoot: chainhash.Hash(selected.taprootAssetRoot), ProofSourceKind: sourceKind, ProofSourceID: [32]byte(input.proofSource.contentID), diff --git a/waved/rpc_server.go b/waved/rpc_server.go index e43bfde2b..47f381e4c 100644 --- a/waved/rpc_server.go +++ b/waved/rpc_server.go @@ -5009,6 +5009,15 @@ func (r *RPCServer) preflightUnrollFeasibility(ctx context.Context, return nil } + // An asset-bearing VTXO's worth is the assets riding on it, not its + // carrier sats, so the sats-denominated feasibility verdict would + // refuse every asset exit as uneconomical. The value judgment is + // the caller's; an underfunded wallet still surfaces through the + // exit's own broadcast failures. + if desc.TaprootAssetRoot != nil { + return nil + } + walletSnapshot, err := r.walletExitFundingSnapshot(ctx) if err != nil { return status.Errorf(codes.Internal, "preflight wallet "+ diff --git a/waved/rpc_taproot_asset_onboarding.go b/waved/rpc_taproot_asset_onboarding.go index 1e13b836c..6b7f1395e 100644 --- a/waved/rpc_taproot_asset_onboarding.go +++ b/waved/rpc_taproot_asset_onboarding.go @@ -60,6 +60,7 @@ func (r *RPCServer) ConfigureTaprootAssetOnboarding(wallet *tapsdk.Wallet, return err } r.server.cfg.TaprootAssetOnboarder = onboarder + r.server.taprootAssetWallet = wallet return nil } diff --git a/waved/server.go b/waved/server.go index f996977df..180fe7947 100644 --- a/waved/server.go +++ b/waved/server.go @@ -27,6 +27,7 @@ import ( btcwalletpkg "github.com/btcsuite/btcwallet/wallet" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/lndclient" + tapsdk "github.com/lightninglabs/tap-sdk" "github.com/lightninglabs/wavelength/arkrpc" "github.com/lightninglabs/wavelength/baselib/actor" "github.com/lightninglabs/wavelength/btcwbackend" @@ -306,6 +307,11 @@ type Server struct { // signers for daemon-owned receive scripts and indexer identity. proofKeyBackend proofkeys.Backend + // taprootAssetWallet is the daemon's tapd wallet, present when the + // optional Taproot Asset integration is configured. Onboarding and + // the exit claim build their transitions through it. + taprootAssetWallet *tapsdk.Wallet + // operatorTerms caches the operator policy fetched during daemon // bootstrap so local RPC callers can inspect the current server // terms. It is stored atomically because startup writes race with diff --git a/waved/taproot_asset_claim.go b/waved/taproot_asset_claim.go new file mode 100644 index 000000000..1e96d3b3e --- /dev/null +++ b/waved/taproot_asset_claim.go @@ -0,0 +1,147 @@ +package waved + +import ( + "bytes" + "context" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + tapsdk "github.com/lightninglabs/tap-sdk" + "github.com/lightninglabs/wavelength/lib/tx/psbtutil" + "github.com/lightninglabs/wavelength/tapassets" +) + +// ClaimAssetVTXO claims an exited asset VTXO's units into the daemon's tapd +// wallet. The exit already put the composed leaf output on chain; once its +// CSV delay matures, the claim spends it through the exit leaf into a fresh +// tapd-owned anchor and publishes the transition, making the units ordinary +// wallet balance. The caller supplies the raw block and height for every +// anchor transaction in the leaf's lineage, keyed by txid. +func (s *Server) ClaimAssetVTXO(ctx context.Context, outpoint wire.OutPoint, + feeSat int64, + confirmations map[chainhash.Hash]tapsdk.AnchorConfirmation) ( + *tapassets.ClaimResult, error) { + + if s.taprootAssetWallet == nil { + return nil, fmt.Errorf("taproot asset integration is not " + + "configured") + } + if s.vtxoStore == nil { + return nil, fmt.Errorf("VTXO store is not initialized") + } + + desc, err := s.vtxoStore.GetVTXO(ctx, outpoint) + if err != nil { + return nil, fmt.Errorf("load claim VTXO %s: %w", outpoint, err) + } + if desc.TaprootAssetRoot == nil || desc.TaprootAssetRef == "" { + return nil, fmt.Errorf("VTXO %s carries no Taproot Asset", + outpoint) + } + if len(desc.TaprootAssetSealedPackage) == 0 { + return nil, fmt.Errorf("VTXO %s has no sealed package to "+ + "claim from", outpoint) + } + + params, err := desc.DecodeStandardPolicyTemplate() + if err != nil { + return nil, fmt.Errorf("decode claim VTXO policy: %w", err) + } + + // The exit leaf's spend info carries the composed control block: the + // policy tree branched with the asset commitment root. + spendInfo, err := desc.EffectiveStandardSpendInfo(1) + if err != nil { + return nil, fmt.Errorf("derive claim exit spend: %w", err) + } + + signer, err := s.newSweepWallet() + if err != nil { + return nil, fmt.Errorf("claim exit signer: %w", err) + } + + prevOut := &wire.TxOut{ + Value: int64(desc.Amount), + PkScript: bytes.Clone(desc.PkScript), + } + signExit := func(_ context.Context, anchorPSBT []byte) ([]byte, error) { + packet, err := psbtutil.Parse(anchorPSBT) + if err != nil { + return nil, fmt.Errorf("parse claim anchor PSBT: %w", + err) + } + if len(packet.Inputs) != 1 || + packet.UnsignedTx.TxIn[0].PreviousOutPoint != + outpoint { + return nil, fmt.Errorf("claim anchor PSBT does not " + + "spend the exited leaf") + } + + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes( + packet.UnsignedTx, fetcher, + ) + signDesc := spendInfo.BuildSignDescriptor( + desc.ClientKey, prevOut, sigHashes, fetcher, 0, + ) + sig, err := signer.SignOutputRaw(packet.UnsignedTx, signDesc) + if err != nil { + return nil, fmt.Errorf("sign claim exit spend: %w", err) + } + witness, err := spendInfo.TimeoutWitness(sig) + if err != nil { + return nil, fmt.Errorf("assemble claim exit "+ + "witness: %w", err) + } + + packet.Inputs[0].WitnessUtxo = prevOut + packet.Inputs[0].FinalScriptWitness, err = + serializeClaimWitness(witness) + if err != nil { + return nil, err + } + + return psbtutil.Serialize(packet) + } + + return tapassets.ClaimAssetVTXO( + ctx, s.taprootAssetWallet, &tapassets.ClaimRequest{ + Outpoint: outpoint, + CarrierValueSat: int64(desc.Amount), + AssetRef: desc.TaprootAssetRef, + AssetAmount: desc.TaprootAssetAmount, + TaprootAssetRoot: *desc.TaprootAssetRoot, + SealedPackage: bytes.Clone( + desc.TaprootAssetSealedPackage, + ), + Confirmations: confirmations, + ExitDelay: params.ExitDelay, + FeeSat: feeSat, + SignExit: signExit, + }, + ) +} + +// serializeClaimWitness encodes a witness stack into BIP-174 +// FinalScriptWitness format: a CompactSize item count followed by +// length-prefixed items. +func serializeClaimWitness(witness wire.TxWitness) ([]byte, error) { + var buf bytes.Buffer + if err := wire.WriteVarInt( + &buf, 0, uint64(len(witness)), + ); err != nil { + return nil, fmt.Errorf("serialize claim witness: %w", err) + } + for _, item := range witness { + if err := wire.WriteVarBytes(&buf, 0, item); err != nil { + return nil, fmt.Errorf("serialize claim witness: %w", + err) + } + } + + return buf.Bytes(), nil +}