diff --git a/cmd/wavecli/waveclicommands/cmd_taproot_assets.go b/cmd/wavecli/waveclicommands/cmd_taproot_assets.go index 90e382449..cab3a092f 100644 --- a/cmd/wavecli/waveclicommands/cmd_taproot_assets.go +++ b/cmd/wavecli/waveclicommands/cmd_taproot_assets.go @@ -41,7 +41,10 @@ func newTaprootAssetOnboardCmd() *cobra.Command { Long: "Move one complete, isolated, confirmed Taproot " + "Asset proof into a standard Wavelength VTXO policy. " + "The current prototype requires tapd and waved to " + - "use the same LND wallet. Pass --wait, or preserve " + + "use the same LND wallet, which supplies any " + + "additional Bitcoin needed for the visible carrier " + + "value, miner fee, and wallet change. Pass --wait, " + + "or preserve " + "every flag and rerun this command after " + "confirmation when the response state is pending.", Args: cobra.NoArgs, @@ -64,8 +67,19 @@ func newTaprootAssetOnboardCmd() *cobra.Command { "path to the complete confirmed Taproot Asset proof file", ) flags.Uint64( - "max-fee-sat", 0, - "exact Bitcoin fee subtracted from the current anchor", + "carrier-value-sat", 0, "Bitcoin value carried by the "+ + "asset VTXO (zero uses the operator minimum)", + ) + flags.Uint64( + "sat-per-vbyte", 0, "explicit on-chain fee rate (mutually "+ + "exclusive with --target-conf)", + ) + flags.Uint32( + "target-conf", 0, "on-chain confirmation target (mutually "+ + "exclusive with --sat-per-vbyte)", + ) + flags.Uint64( + "max-fee-sat", 0, "hard upper bound for the on-chain miner fee", ) flags.Bool( "wait", false, @@ -153,6 +167,9 @@ func taprootAssetOnboardingRequest(cmd *cobra.Command) ( assetRef, _ := cmd.Flags().GetString("asset-ref") assetAmount, _ := cmd.Flags().GetUint64("asset-amount") proofPath, _ := cmd.Flags().GetString("proof-file") + carrierValueSat, _ := cmd.Flags().GetUint64("carrier-value-sat") + feeRateSatPerVByte, _ := cmd.Flags().GetUint64("sat-per-vbyte") + targetConf, _ := cmd.Flags().GetUint32("target-conf") maxFeeSat, _ := cmd.Flags().GetUint64("max-fee-sat") switch { @@ -170,6 +187,14 @@ func taprootAssetOnboardingRequest(cmd *cobra.Command) ( case maxFeeSat == 0: return nil, fmt.Errorf("--max-fee-sat must be positive") + + case feeRateSatPerVByte == 0 && targetConf == 0: + return nil, fmt.Errorf("exactly one of --sat-per-vbyte and " + + "--target-conf is required") + + case feeRateSatPerVByte != 0 && targetConf != 0: + return nil, fmt.Errorf("--sat-per-vbyte and --target-conf " + + "are mutually exclusive") } if err := validateFreeText( @@ -195,10 +220,13 @@ func taprootAssetOnboardingRequest(cmd *cobra.Command) ( } return &waverpc.OnboardTaprootAssetRequest{ - IdempotencyKey: idempotencyKey, - AssetRef: assetRef, - AssetAmount: assetAmount, - InputProofFile: proofFile, - MaxFeeSat: maxFeeSat, + IdempotencyKey: idempotencyKey, + AssetRef: assetRef, + AssetAmount: assetAmount, + InputProofFile: proofFile, + MaxFeeSat: maxFeeSat, + CarrierValueSat: carrierValueSat, + FeeRateSatPerVbyte: feeRateSatPerVByte, + TargetConf: targetConf, }, nil } diff --git a/cmd/wavecli/waveclicommands/cmd_taproot_assets_test.go b/cmd/wavecli/waveclicommands/cmd_taproot_assets_test.go index 8b3bfeede..4acab2455 100644 --- a/cmd/wavecli/waveclicommands/cmd_taproot_assets_test.go +++ b/cmd/wavecli/waveclicommands/cmd_taproot_assets_test.go @@ -26,6 +26,8 @@ func TestTaprootAssetOnboardingRequest(t *testing.T) { require.NoError(t, cmd.Flags().Set("asset-ref", "asset-id")) require.NoError(t, cmd.Flags().Set("asset-amount", "42")) require.NoError(t, cmd.Flags().Set("proof-file", proofPath)) + require.NoError(t, cmd.Flags().Set("carrier-value-sat", "1000")) + require.NoError(t, cmd.Flags().Set("sat-per-vbyte", "2")) require.NoError(t, cmd.Flags().Set("max-fee-sat", "500")) request, err := taprootAssetOnboardingRequest(cmd) @@ -34,9 +36,31 @@ func TestTaprootAssetOnboardingRequest(t *testing.T) { require.Equal(t, "asset-id", request.AssetRef) require.Equal(t, uint64(42), request.AssetAmount) require.Equal(t, proof, request.InputProofFile) + require.Equal(t, uint64(1_000), request.CarrierValueSat) + require.Equal(t, uint64(2), request.FeeRateSatPerVbyte) + require.Zero(t, request.TargetConf) require.Equal(t, uint64(500), request.MaxFeeSat) } +// TestTaprootAssetOnboardingRequestTargetConf verifies the estimator mode and +// the daemon-side operator-minimum carrier default remain explicit on the +// wire. +func TestTaprootAssetOnboardingRequestTargetConf(t *testing.T) { + t.Parallel() + + proofPath := filepath.Join(t.TempDir(), "asset-proof.tasset") + require.NoError(t, os.WriteFile(proofPath, []byte("proof"), 0o600)) + cmd := validTaprootAssetOnboardCmd(t, proofPath) + require.NoError(t, cmd.Flags().Set("sat-per-vbyte", "0")) + require.NoError(t, cmd.Flags().Set("target-conf", "6")) + + request, err := taprootAssetOnboardingRequest(cmd) + require.NoError(t, err) + require.Zero(t, request.CarrierValueSat) + require.Zero(t, request.FeeRateSatPerVbyte) + require.Equal(t, uint32(6), request.TargetConf) +} + // TestWaitForTaprootAssetOnboarding verifies --wait reuses one byte-identical // request until the daemon reports that registration is ready. func TestWaitForTaprootAssetOnboarding(t *testing.T) { @@ -116,6 +140,19 @@ func TestTaprootAssetOnboardingRequestRejectsInvalidInput(t *testing.T) { value: "0", wantErr: "--max-fee-sat must be positive", }, + { + name: "fee selector", + flag: "sat-per-vbyte", + value: "0", + wantErr: "exactly one of --sat-per-vbyte and " + + "--target-conf is required", + }, + { + name: "two fee selectors", + flag: "target-conf", + value: "6", + wantErr: "mutually exclusive", + }, } for _, test := range tests { @@ -143,6 +180,7 @@ func validTaprootAssetOnboardCmd(t *testing.T, require.NoError(t, cmd.Flags().Set("asset-ref", "asset-id")) require.NoError(t, cmd.Flags().Set("asset-amount", "42")) require.NoError(t, cmd.Flags().Set("proof-file", proofPath)) + require.NoError(t, cmd.Flags().Set("sat-per-vbyte", "2")) require.NoError(t, cmd.Flags().Set("max-fee-sat", "500")) return cmd diff --git a/docs/taproot-assets-carrier-onboarding-execplan.md b/docs/taproot-assets-carrier-onboarding-execplan.md new file mode 100644 index 000000000..2e8b4747b --- /dev/null +++ b/docs/taproot-assets-carrier-onboarding-execplan.md @@ -0,0 +1,309 @@ +# Fund Taproot Asset onboarding carriers from the shared Bitcoin wallet + +This ExecPlan is a living document. The sections `Progress`, `Surprises & +Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to +date as work proceeds. This document is maintained in accordance with +`PLANS.md` at the repository root. + +## Purpose / Big Picture + +After this change, a Wavelength user can move a complete, confirmed Taproot +Asset balance into Wavelength without relying on the asset's existing on-chain +anchor to contain enough bitcoin for both the new virtual transaction output +(VTXO) and the miner fee. The user chooses, or accepts the operator minimum for, +the number of satoshis carried by the asset VTXO. The Taproot Asset daemon +(`tapd`) asks the same LND wallet used by Wavelength to add ordinary bitcoin +inputs, pay the miner fee, and return any bitcoin change. The CLI and RPC expose +the carrier amount, fee policy, fee ceiling, and actual fee instead of hiding +them. + +The behavior is observable with focused tests that start from a 1,000-satoshi +asset anchor, request a 1,000-satoshi carrier, and still produce a valid +wallet-funded transfer with a positive fee and optional bitcoin change. A later +Lumos plan will prove the same behavior with real LND and tapd daemons; this +plan owns the Wavelength workflow and its deterministic tests only. + +## Progress + +- [x] (2026-07-21 16:54Z) Replayed and signed the existing Taproot Asset + onboarding/OOR substrate onto current `origin/main`; focused Wavelength + package tests pass. +- [x] (2026-07-21 16:54Z) Audited the pinned tap-sdk custom-anchor funding API + and confirmed wallet-funded inputs, added bitcoin change, fee selection, + fee ceiling, and deterministic lock IDs are available without an upstream + change. +- [x] (2026-07-21 17:02Z) Changed the durable onboarding request and tap-sdk + plan from exact caller-funded subtraction to an exact carrier output plus + wallet funding, deterministic lock identity, and sealed actual-fee checks. +- [x] (2026-07-21 17:04Z) Exposed carrier and fee policy in the daemon RPC and + `wavecli`, including the effective carrier and actual fee in the response. +- [x] (2026-07-21 17:11Z) Added workflow, RPC, CLI, restart, change-output, + funding-summary, and malformed-request tests; formatting, focused tests, + build, generated-code reproducibility, and changed-code lint pass. +- [x] (2026-07-21 17:12Z) Committed the carrier-onboarding implementation as a + signed milestone stacked on the integration-refresh branch. + +## Surprises & Discoveries + +- Observation: the original prototype subtracts an exact fee from the current + asset anchor, so a normal 1,000-satoshi tapd anchor cannot satisfy an + operator minimum of 1,000 satoshis once any miner fee is paid. + Evidence: `tapassets/onboarding.go` computes `outputValue := anchor.AmtSat - + int64(request.AnchorFeeSat)` and rejects values below the dust floor. +- Observation: tap-sdk wallet funding preserves all caller-declared asset + output values and indices, may append ordinary bitcoin inputs, and may append + at most one ordinary bitcoin change output. + Evidence: the pinned tap-sdk validates `CustomAnchorFundingWalletFunded` with + `AnchorChangeOutputAdd`, `AnchorFee`, `MaxFeeSat`, and a 32-byte + `CustomLockID`; its committed-shape validator rejects mutation of asset + outputs. +- Observation: an added bitcoin change output does not need a Wavelength key or + policy. It is an ordinary LND on-chain wallet output and is not registered as + a VTXO by the operator. + Evidence: tap-sdk delegates `AnchorChangeOutputAdd` to WalletKit and records + the backend-selected change index separately from asset outputs. +- Observation: the repository RPC target could not run because the local + Docker daemon was unavailable, while the exact compiler and plugin versions + pinned by the repository produced byte-identical output on a second run. + Evidence: local protoc 3.21.12 generation printed `waverpc regeneration + clean`; `make build` and `make lint-changed-local` subsequently passed. + +## Decision Log + +- Decision: wallet-fund onboarding through tap-sdk rather than manually + selecting LND UTXOs in Wavelength. + Rationale: tap-sdk already seals the exact funding policy, locked outpoints, + actual fee, and backend-managed input indices into the transfer package. It + also verifies that the wallet did not mutate the declared asset output. + Date/Author: 2026-07-21 / Codex. +- Decision: zero `carrier_value_sat` means the current operator-advertised + minimum VTXO amount; a positive value must be at least that minimum. + Rationale: this gives a useful default while keeping carrier satoshis visible + in both request documentation and response data. + Date/Author: 2026-07-21 / Codex. +- Decision: require exactly one of `fee_rate_sat_per_vbyte` and `target_conf`, + plus a non-zero `max_fee_sat` hard ceiling. + Rationale: the fee strategy and the maximum possible spend are distinct. An + estimator can choose a fee while the user retains an absolute loss bound. + Date/Author: 2026-07-21 / Codex. +- Decision: derive tap-sdk's 32-byte custom lock ID by hashing a domain label + and the durable onboarding request digest. + Rationale: retries of the same request reuse the same WalletKit lease + identity, while a different request cannot collide accidentally or reuse a + caller-controlled raw lock ID. + Date/Author: 2026-07-21 / Codex. +- Decision: retain the current isolated-asset and complete-amount onboarding + restrictions. + Rationale: carrier funding solves the bitcoin shortfall without expanding + passive-asset or partial-asset semantics. Partial sends are a separate + off-chain OOR milestone. + Date/Author: 2026-07-21 / Codex. + +## Outcomes & Retrospective + +The Wavelength implementation is complete on +`feat/taproot-assets-carrier-onboarding`. It preserves an exact carrier value, +authorizes tapd/LND to add Bitcoin inputs and one ordinary change output, +binds the request economics to a deterministic lock identity, and restores the +actual fee from the sealed package after restart. A 1,000-satoshi source anchor +now produces a 1,000-satoshi asset VTXO while paying a positive fee from added +wallet value. Focused tests, `make build`, and changed-code lint pass. The +remaining proof is the live paired LND/tapd flow in the stacked Lumos harness; +that is intentionally not represented as complete by these deterministic +tests. + +## Context and Orientation + +Wavelength is the wallet-facing daemon. A Taproot Asset is committed inside a +Bitcoin Taproot output called an anchor. An Ark VTXO is an off-chain spendable +output whose value is denominated in satoshis. When an asset is held by a VTXO, +those satoshis are its carrier: they make the Bitcoin output valid and later +fund any additional asset-change outputs. Asset units and carrier satoshis are +accounted independently. + +`tapassets/onboarding.go` implements an idempotent state machine. It verifies a +confirmed proof with tapd, derives the Wavelength owner policy, builds a +tap-sdk `CustomAnchorRequest`, commits it, asks Wavelength's WalletKit signer to +finalize the Bitcoin PSBT, publishes through tap-sdk, waits for confirmation, +and registers the asset-bearing output with the operator. The sealed package +and final PSBT are stored so retrying the same idempotency key does not rebuild +or repay anything. + +`tapassets/driver.go` projects a sealed tap-sdk package into Wavelength's narrow +internal representation. It must carry the package's actual miner fee so the +result can report it after restart without recalculation or another external +call. + +`waved/rpc_taproot_asset_onboarding.go` converts the public daemon request into +the internal request. It already has the negotiated `OperatorTerms`; the +effective floor is `terms.MinVTXOAmountFloor()`. `waverpc/daemon.proto` defines +the public request and response, while `cmd/wavecli/waveclicommands/ +cmd_taproot_assets.go` provides the user command. Generated protobuf files must +be regenerated from the proto source, never edited by hand. + +Tap-sdk and Wavelength must use the same LND wallet. Tapd's WalletKit client +adds and locks ordinary bitcoin inputs during the commit. The later Wavelength +WalletKit signing pass signs both the original tapd-managed asset anchor and +the backend-added ordinary inputs. Tap-sdk records which inputs are backend +managed, preventing an input from being left unclassified. + +## Plan of Work + +First, replace `OnboardingRequest.AnchorFeeSat` with explicit +`CarrierValueSat`, `FeeRateSatPerVByte`, `TargetConf`, and `MaxFeeSat` fields. +Validation must accept exactly one fee selector. The request digest must bind +all four values. A helper should convert the two public fee selector fields to +tap-sdk's `AnchorFee`, returning a clear error before any journal write. + +In `Onboarder.commit`, stop subtracting the fee from `ManagedUtxo.AmtSat`. +Build the initial PSBT with the asset input and one asset output whose value is +exactly `CarrierValueSat`. Select `CustomAnchorFundingWalletFunded`, +`AnchorChangeOutputAdd`, the converted fee policy, `MaxFeeSat`, and a +deterministic 32-byte custom lock ID. Keep input zero's anchor signing plan; +tap-sdk classifies any appended wallet inputs as backend managed. Do not treat +the appended ordinary bitcoin change output as an asset output. + +Extend `commitResult` and `OnboardingResult` with `ActualFeeSat`, sourced from +`CustomAnchorTransferPackage.Funding.ActualFeeSat`. Update package restoration +and fake drivers so a pending-confirmation restart returns the same effective +carrier and actual fee. Validate that the sealed package is wallet-funded, its +actual fee does not exceed the requested maximum, and its asset output retains +the requested value even when the anchor PSBT contains a second bitcoin-only +change output. + +Then revise `OnboardTaprootAssetRequest` in `waverpc/daemon.proto`. Preserve +existing field numbers and reinterpret field 5 only as the hard `max_fee_sat` +ceiling; add `carrier_value_sat`, `fee_rate_sat_per_vbyte`, and `target_conf` at +new field numbers. Add `actual_fee_sat` to the response; existing `value_sat` +remains the effective carrier value for compatibility. The RPC resolves zero +carrier to the operator floor and rejects a positive carrier below that floor. +Regenerate all `waverpc` outputs and the low-level CLI development registry. + +Finally, update `wavecli taproot-assets onboard`. Add `--carrier-value-sat` +with a zero/default meaning operator minimum, `--sat-per-vbyte`, and +`--target-conf`. Require exactly one fee selector and keep `--max-fee-sat` as +the hard cap. Update command help so users understand that carrier satoshis are +Bitcoin value belonging to the asset VTXO and may require ordinary wallet +funds. Extend CLI and RPC tests for defaults, mutual exclusion, floors, +idempotency rewrites, actual-fee persistence, and wallet-funded request shape. + +## Concrete Steps + +Work from `/Users/dario/dev/lightninglabs/.worktrees/ +wavelength-carrier-funding` on branch +`feat/taproot-assets-carrier-onboarding`. + +After each implementation slice, format only changed Go files: + + make fmt-changed + +Regenerate RPC files using the repository target when Docker is available: + + make rpc + +If the local Docker daemon is unavailable, use the exact protobuf compiler and +Go plugin versions pinned by `scripts/rpc.Dockerfile` and `go.mod`, generate the +`waverpc` package from `waverpc/daemon.proto`, then verify a second generation +is clean. Record that environment limitation in this plan. + +Run the focused tests during development: + + go test ./tapassets ./waved ./cmd/wavecli/waveclicommands + +Before the milestone commit, run: + + go test ./tapassets ./waved ./cmd/waved \ + ./cmd/wavecli/waveclicommands ./waverpc + make build + make lint-changed-local + +Commit the completed milestone with an SSH signature and the repository's +package-prefix convention: + + git commit -S -m 'tapassets: wallet fund onboarding carriers' + +## Validation and Acceptance + +`TestOnboarderResumesPendingConfirmation` must prove that the request passed to +tap-sdk is wallet funded, has `AnchorChangeOutputAdd`, carries the selected fee +strategy and maximum, uses a deterministic 32-byte lock ID, and preserves the +exact requested carrier value. It must also prove a restart returns the same +actual fee without a second commit, signature, or publication. + +A dedicated test must set the managed asset anchor to 1,000 satoshis, request a +1,000-satoshi carrier, and use a positive fee ceiling. It passes because wallet +funding is authorized; the pre-change implementation fails because it can only +produce an output smaller than 1,000 satoshis. Negative tests must reject no +fee selector, two fee selectors, a zero maximum fee, a zero internal carrier, +and a sealed package whose actual fee exceeds the maximum. + +RPC tests must show that carrier zero becomes the operator floor, a carrier +below the floor returns `InvalidArgument`, and the response contains both the +effective carrier and actual fee. CLI tests must show that exactly one of +`--sat-per-vbyte` and `--target-conf` is required, while omitting +`--carrier-value-sat` leaves zero for daemon-side defaulting. + +The final diff must not add tapd credentials to Lumos or swapd. Bitcoin-only +Wavelength behavior and the existing exact caller-funded OOR builder must +remain unchanged. The full live acceptance test is intentionally owned by the +stacked Lumos integration branch because that repository starts the operator, +two Wavelength clients, paired LND/tapd daemons, and bitcoind. + +## Idempotence and Recovery + +The request digest binds the carrier and fee policy, so reusing an idempotency +key with different economics fails before another tapd call. The custom lock ID +is derived from that digest, so retries use the same backend lock identity. +Once the sealed package is stored, retries decode it and reuse the exact PSBT. +If tapd reports a known failure before committing, clear the attempt marker and +allow a safe retry. If the outcome may have committed, preserve the marker and +return `ErrReconciliationRequired`; do not build a competing transition. + +The implementation must never unlock or publish a different PSBT merely +because an RPC retry timed out. A future upstream status-by-custom-lock-ID API +can reconcile an ambiguous tapd outcome; until then the safe PoC behavior is a +quarantine with an explicit error. + +## Artifacts and Notes + +The refreshed substrate's focused validation completed successfully before +this plan was created: + + ok github.com/lightninglabs/wavelength/oor + ok github.com/lightninglabs/wavelength/vtxo + ok github.com/lightninglabs/wavelength/unroll + ok github.com/lightninglabs/wavelength/tapassets + ok github.com/lightninglabs/wavelength/waved + ok github.com/lightninglabs/wavelength/cmd/waved + ok github.com/lightninglabs/wavelength/cmd/wavecli/waveclicommands + ok github.com/lightninglabs/wavelength/db + ok github.com/lightninglabs/wavelength/lib/tx/oor + +## Interfaces and Dependencies + +At the end of this plan, `tapassets.OnboardingRequest` must expose: + + CarrierValueSat uint64 + FeeRateSatPerVByte uint64 + TargetConf uint32 + MaxFeeSat uint64 + +`tapassets.OnboardingResult` and the daemon response must expose +`ActualFeeSat uint64`. `tapassets.commitResult` must preserve the same value +from `tapsdk.CustomAnchorTransferPackage.Funding.ActualFeeSat`. + +The generated tap-sdk request must use +`tapsdk.CustomAnchorFundingWalletFunded`, +`tapsdk.AnchorChangeOutputAdd`, either `tapsdk.AnchorFeeSatPerVByte` or +`tapsdk.AnchorFeeTargetConf`, the caller's maximum fee, and a deterministic +32-byte custom lock ID. No new tap-sdk or taproot-assets dependency is expected +for this milestone. + +Revision note (2026-07-21): created this plan after rebasing the existing +integration and auditing tap-sdk's wallet-funded custom-anchor contract. The +scope deliberately ends at onboarding; mixed off-chain asset sends and the +live Lumos topology remain separate reviewable milestones. + +Revision note (2026-07-21 17:11Z): recorded the completed wallet-funding, +RPC/CLI, restart, change-output, fee-bound validation, and repository checks. +The live daemon proof remains assigned to the Lumos integration milestone. diff --git a/tapassets/driver.go b/tapassets/driver.go index c95b1298a..58493e61a 100644 --- a/tapassets/driver.go +++ b/tapassets/driver.go @@ -29,6 +29,9 @@ type commitInput struct { type commitResult struct { packageBytes []byte anchorPSBT []byte + fundingMode tapsdk.CustomAnchorFundingMode + actualFeeSat uint64 + maxFeeSat uint64 inputs []commitInput outputs []commitOutput } @@ -189,6 +192,9 @@ func commitResultFromPackage(transfer *tapsdk.CustomAnchorTransferPackage) ( result := &commitResult{ packageBytes: encoded, anchorPSBT: append([]byte(nil), transfer.AnchorPsbt...), + fundingMode: transfer.Funding.Mode, + actualFeeSat: transfer.Funding.ActualFeeSat, + maxFeeSat: transfer.Funding.MaxFeeSat, inputs: make([]commitInput, len(transfer.Inputs)), outputs: make([]commitOutput, len(transfer.Outputs)), } diff --git a/tapassets/onboarding.go b/tapassets/onboarding.go index 9d279da80..18d49f11e 100644 --- a/tapassets/onboarding.go +++ b/tapassets/onboarding.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "sync" "github.com/btcsuite/btcd/btcec/v2" @@ -26,6 +27,8 @@ const ( onboardingStateVersion = uint16(0) onboardingAttemptCommit = "commit" onboardingStorePrefix = "onboarding/" + onboardingLockIDDomain = "wavelength/taproot-assets/onboarding-lock/v1" + onboardingDustFloorSat = uint64(330) ) // ErrOnboardingPendingConfirmation means the exact published asset anchor is @@ -37,13 +40,16 @@ var ErrOnboardingPendingConfirmation = errors.New("taproot asset onboarding " + // OnboardingRequest selects one complete Taproot Asset proof and the standard // Wavelength policy that will own its new on-chain anchor. type OnboardingRequest struct { - RequestID string - AssetRef string - AssetAmount uint64 - ProofFile []byte - AnchorFeeSat uint64 - OperatorKey *btcec.PublicKey - ExitDelay uint32 + RequestID string + AssetRef string + AssetAmount uint64 + ProofFile []byte + CarrierValueSat uint64 + FeeRateSatPerVByte uint64 + TargetConf uint32 + MaxFeeSat uint64 + OperatorKey *btcec.PublicKey + ExitDelay uint32 } // OnboardingKeyDeriver returns the next wallet-owned standard VTXO key. @@ -83,6 +89,7 @@ type OnboardingResult struct { Status OnboardingStatus Outpoint wire.OutPoint ValueSat int64 + ActualFeeSat uint64 PolicyTemplate []byte PkScript []byte TaprootAssetRoot chainhash.Hash @@ -354,12 +361,12 @@ func (o *Onboarder) commit(ctx context.Context, request *OnboardingRequest, if err != nil { return nil, err } - if anchor.AmtSat <= int64(request.AnchorFeeSat) { - return nil, fmt.Errorf("asset anchor value %d does not "+ - "cover fee %d", anchor.AmtSat, request.AnchorFeeSat) + fee, err := onboardingAnchorFee(request) + if err != nil { + return nil, err } - outputValue := anchor.AmtSat - int64(request.AnchorFeeSat) - if outputValue < 330 { + outputValue := int64(request.CarrierValueSat) + if outputValue < int64(onboardingDustFloorSat) { return nil, fmt.Errorf("onboarding output value %d is below "+ "the Taproot dust floor", outputValue) } @@ -381,6 +388,7 @@ func (o *Onboarder) commit(ctx context.Context, request *OnboardingRequest, err) } + requestDigest := onboardingRequestDigest(request) requestDTO := &tapsdk.CustomAnchorRequest{ Inputs: []tapsdk.CustomAssetInput{{ ID: "wavelength-onboarding-input-0", @@ -406,7 +414,19 @@ func (o *Onboarder) commit(ctx context.Context, request *OnboardingRequest, ), }}, AnchorPSBT: anchorPSBT, - Funding: callerFundedExact(), + Funding: tapsdk.CustomAnchorFundingPlan{ + Mode: tapsdk.CustomAnchorFundingWalletFunded, + WalletFunded: &tapsdk.CustomAnchorWalletFunding{ + ChangeOutput: tapsdk.AnchorChangeOutput{ + Mode: tapsdk.AnchorChangeOutputAdd, + }, + Fee: fee, + MaxFeeSat: request.MaxFeeSat, + CustomLockID: onboardingCustomLockID( + requestDigest, + ), + }, + }, PassiveAssets: tapsdk.CustomAnchorPassiveAssets{ Policy: tapsdk.CustomAnchorPassiveReject, }, @@ -534,6 +554,20 @@ func onboardingResultFromCommit(request *OnboardingRequest, return nil, fmt.Errorf("onboarding package must contain one " + "input and one output") } + if committed.fundingMode != tapsdk.CustomAnchorFundingWalletFunded { + return nil, fmt.Errorf("onboarding package is not wallet " + + "funded") + } + if committed.maxFeeSat != request.MaxFeeSat { + return nil, fmt.Errorf("onboarding package maximum fee %d "+ + "does not match request %d", committed.maxFeeSat, + request.MaxFeeSat) + } + if committed.actualFeeSat > committed.maxFeeSat { + return nil, fmt.Errorf("onboarding package actual fee %d "+ + "exceeds maximum %d", committed.actualFeeSat, + committed.maxFeeSat) + } assetRef, err := tapsdk.ParseAssetRef(request.AssetRef) if err != nil { return nil, err @@ -547,10 +581,15 @@ func onboardingResultFromCommit(request *OnboardingRequest, return nil, fmt.Errorf("onboarding package asset selection " + "mismatch") } - if output.anchorOutputIndex != 0 || output.anchorValueSat <= 0 { + if output.anchorOutputIndex != 0 || output.anchorValueSat != + int64(request.CarrierValueSat) { return nil, fmt.Errorf("onboarding package output shape " + "mismatch") } + if output.anchorOutpoint.Index != output.anchorOutputIndex { + return nil, fmt.Errorf("onboarding package output index " + + "mismatch") + } if output.taprootAssetRoot == (tapsdk.Hash{}) || output.taprootMerkleRoot == (tapsdk.Hash{}) { return nil, fmt.Errorf("onboarding package root hints are " + @@ -576,18 +615,21 @@ func onboardingResultFromCommit(request *OnboardingRequest, if err != nil { return nil, err } - if len(packet.UnsignedTx.TxOut) != 1 || - packet.UnsignedTx.TxOut[0].Value != output.anchorValueSat { + if output.anchorOutputIndex >= uint32(len(packet.UnsignedTx.TxOut)) { + return nil, fmt.Errorf("onboarding package output index is " + + "out of range") + } + anchorOutput := packet.UnsignedTx.TxOut[output.anchorOutputIndex] + if anchorOutput.Value != output.anchorValueSat { return nil, fmt.Errorf("committed onboarding anchor does not " + "match VTXO policy and root") } if err := validateOutputCommitment( - packet.UnsignedTx.TxOut[0], policy.InternalKey, policy.RootHash, - output, + anchorOutput, policy.InternalKey, policy.RootHash, output, ); err != nil { return nil, fmt.Errorf("committed onboarding output: %w", err) } - if !bytes.Equal(packet.UnsignedTx.TxOut[0].PkScript, pkScript) { + if !bytes.Equal(anchorOutput.PkScript, pkScript) { return nil, fmt.Errorf("committed onboarding output policy " + "mismatch") } @@ -602,6 +644,7 @@ func onboardingResultFromCommit(request *OnboardingRequest, return &OnboardingResult{ Outpoint: outpoint, ValueSat: output.anchorValueSat, + ActualFeeSat: committed.actualFeeSat, PolicyTemplate: append([]byte(nil), state.PolicyTemplate...), PkScript: pkScript, TaprootAssetRoot: root, @@ -686,10 +729,26 @@ func validateOnboardingRequest(request *OnboardingRequest) error { return fmt.Errorf("taproot asset ref, amount, and proof are " + "required") } - if request.AnchorFeeSat == 0 { - return fmt.Errorf("taproot asset onboarding anchor fee is " + + if request.CarrierValueSat == 0 { + return fmt.Errorf("taproot asset onboarding carrier value is " + + "required") + } + if request.CarrierValueSat < onboardingDustFloorSat { + return fmt.Errorf("taproot asset onboarding carrier value %d "+ + "is below the Taproot dust floor", + request.CarrierValueSat) + } + if request.CarrierValueSat > math.MaxInt64 { + return fmt.Errorf("taproot asset onboarding carrier value %d "+ + "is too large", request.CarrierValueSat) + } + if request.MaxFeeSat == 0 { + return fmt.Errorf("taproot asset onboarding maximum fee is " + "required") } + if _, err := onboardingAnchorFee(request); err != nil { + return err + } if request.OperatorKey == nil || request.ExitDelay == 0 { return fmt.Errorf("taproot asset onboarding operator policy " + "is required") @@ -704,7 +763,12 @@ func onboardingRequestDigest(request *OnboardingRequest) tapsdk.Hash { writeDigestBytes(&value, []byte(request.AssetRef)) _ = binary.Write(&value, binary.BigEndian, request.AssetAmount) writeDigestBytes(&value, request.ProofFile) - _ = binary.Write(&value, binary.BigEndian, request.AnchorFeeSat) + _ = binary.Write(&value, binary.BigEndian, request.CarrierValueSat) + _ = binary.Write( + &value, binary.BigEndian, request.FeeRateSatPerVByte, + ) + _ = binary.Write(&value, binary.BigEndian, request.TargetConf) + _ = binary.Write(&value, binary.BigEndian, request.MaxFeeSat) writeDigestBytes(&value, request.OperatorKey.SerializeCompressed()) _ = binary.Write(&value, binary.BigEndian, request.ExitDelay) digest := sha256.Sum256(value.Bytes()) @@ -712,6 +776,48 @@ func onboardingRequestDigest(request *OnboardingRequest) tapsdk.Hash { return tapsdk.Hash(digest) } +func onboardingAnchorFee(request *OnboardingRequest) (tapsdk.AnchorFee, error) { + if request == nil { + return tapsdk.AnchorFee{}, fmt.Errorf("taproot asset " + + "onboarding request is required") + } + hasFeeRate := request.FeeRateSatPerVByte != 0 + hasTargetConf := request.TargetConf != 0 + if hasFeeRate == hasTargetConf { + return tapsdk.AnchorFee{}, fmt.Errorf("taproot asset " + + "onboarding requires exactly one of fee rate and " + + "target confirmation") + } + if hasTargetConf { + return tapsdk.AnchorFee{ + Mode: tapsdk.AnchorFeeTargetConf, + TargetConf: request.TargetConf, + }, nil + } + + feeRate, err := tapsdk.NewFeeRateSatPerVByte( + request.FeeRateSatPerVByte, + ) + if err != nil { + return tapsdk.AnchorFee{}, fmt.Errorf("taproot asset "+ + "onboarding fee rate: %w", err) + } + + return tapsdk.AnchorFee{ + Mode: tapsdk.AnchorFeeSatPerVByte, + FeeRate: feeRate, + }, nil +} + +func onboardingCustomLockID(requestDigest tapsdk.Hash) []byte { + var value bytes.Buffer + writeDigestBytes(&value, []byte(onboardingLockIDDomain)) + writeDigestBytes(&value, requestDigest[:]) + lockID := sha256.Sum256(value.Bytes()) + + return lockID[:] +} + func ownerKeyFromState(state *onboardingState) (keychain.KeyDescriptor, error) { if state == nil { return keychain.KeyDescriptor{}, fmt.Errorf("onboarding " + diff --git a/tapassets/onboarding_test.go b/tapassets/onboarding_test.go index c07dad856..7a4c9b1ba 100644 --- a/tapassets/onboarding_test.go +++ b/tapassets/onboarding_test.go @@ -3,12 +3,17 @@ package tapassets import ( "bytes" "context" + "crypto/sha256" "errors" + "math" "sync" "testing" + "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/tx/psbtutil" "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) @@ -50,6 +55,9 @@ func TestOnboarderResumesPendingConfirmation(t *testing.T) { error) { signCalls++ + packet, err := psbtutil.Parse(anchor) + require.NoError(t, err) + require.Len(t, packet.UnsignedTx.TxIn, 2) return append([]byte(nil), anchor...), nil }, @@ -74,7 +82,8 @@ func TestOnboarderResumesPendingConfirmation(t *testing.T) { require.Equal(t, 1, driver.verifications) require.Equal(t, 1, driver.publishes) require.Len(t, registrations, 1) - require.Equal(t, int64(4_750), result.ValueSat) + require.Equal(t, int64(1_000), result.ValueSat) + require.Equal(t, uint64(250), result.ActualFeeSat) require.NotZero(t, result.TaprootAssetRoot) require.NotEmpty(t, result.PolicyTemplate) require.NotEmpty(t, result.PkScript) @@ -85,6 +94,7 @@ func TestOnboarderResumesPendingConfirmation(t *testing.T) { require.NoError(t, err) require.Equal(t, OnboardingStatusReady, result.Status) require.Equal(t, int32(321), result.ConfirmationHeight) + require.Equal(t, uint64(250), result.ActualFeeSat) require.Equal(t, 1, deriveCalls) require.Equal(t, 1, signCalls) require.Equal(t, 1, driver.commits) @@ -97,6 +107,7 @@ func TestOnboarderResumesPendingConfirmation(t *testing.T) { result, err = newOnboarder().Onboard(t.Context(), request) require.NoError(t, err) require.Equal(t, OnboardingStatusReady, result.Status) + require.Equal(t, uint64(250), result.ActualFeeSat) require.Equal(t, 1, deriveCalls) require.Equal(t, 1, signCalls) require.Equal(t, 1, driver.commits) @@ -106,9 +117,30 @@ func TestOnboarderResumesPendingConfirmation(t *testing.T) { dto := driver.requests[0] require.Equal( - t, tapsdk.CustomAnchorFundingCallerFundedExact, - dto.Funding.Mode, + t, tapsdk.CustomAnchorFundingWalletFunded, dto.Funding.Mode, ) + require.NotNil(t, dto.Funding.WalletFunded) + require.Equal( + t, tapsdk.AnchorChangeOutputAdd, + dto.Funding.WalletFunded.ChangeOutput.Mode, + ) + require.Equal( + t, tapsdk.AnchorFeeSatPerVByte, + dto.Funding.WalletFunded.Fee.Mode, + ) + require.Equal( + t, request.FeeRateSatPerVByte, + dto.Funding.WalletFunded.Fee.FeeRate.SatPerVByteFloor(), + ) + require.Equal(t, request.MaxFeeSat, dto.Funding.WalletFunded.MaxFeeSat) + require.Equal( + t, + onboardingCustomLockID( + onboardingRequestDigest(request), + ), + dto.Funding.WalletFunded.CustomLockID, + ) + require.Len(t, dto.Funding.WalletFunded.CustomLockID, sha256.Size) require.Equal( t, tapsdk.CustomAnchorPassiveReject, dto.PassiveAssets.Policy, ) @@ -120,8 +152,224 @@ func TestOnboarderResumesPendingConfirmation(t *testing.T) { require.Equal( t, tapsdk.CustomAssetScriptWallet, dto.Outputs[0].Script.Mode, ) - require.Equal(t, uint64(4_750), dto.Outputs[0].AnchorValueSat) + require.Equal(t, uint64(1_000), dto.Outputs[0].AnchorValueSat) require.Len(t, dto.Outputs[0].Anchor.Tapscript.TapLeaves, 2) + committed, err := psbtutil.Parse(driver.result.anchorPSBT) + require.NoError(t, err) + require.Len(t, committed.UnsignedTx.TxOut, 2) + assetOutputIndex := driver.result.outputs[0].anchorOutputIndex + require.Equal( + t, int64(request.CarrierValueSat), + committed.UnsignedTx.TxOut[assetOutputIndex].Value, + ) +} + +// TestOnboardingFeeSelection binds the public whole-sat fee selectors to the +// tagged tap-sdk fee policy without silently preferring one selector. +func TestOnboardingFeeSelection(t *testing.T) { + t.Parallel() + + request, _, _ := testOnboardingRequest(t) + fee, err := onboardingAnchorFee(request) + require.NoError(t, err) + require.Equal(t, tapsdk.AnchorFeeSatPerVByte, fee.Mode) + require.Equal( + t, request.FeeRateSatPerVByte, fee.FeeRate.SatPerVByteFloor(), + ) + + request.FeeRateSatPerVByte = 0 + request.TargetConf = 6 + fee, err = onboardingAnchorFee(request) + require.NoError(t, err) + require.Equal(t, tapsdk.AnchorFeeTargetConf, fee.Mode) + require.Equal(t, uint32(6), fee.TargetConf) +} + +// TestOnboardingRejectsInvalidEconomics verifies malformed wallet-funding +// authority is rejected before the durable workflow derives or stores state. +func TestOnboardingRejectsInvalidEconomics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*OnboardingRequest) + errContains string + }{ + { + name: "carrier missing", + mutate: func(request *OnboardingRequest) { + request.CarrierValueSat = 0 + }, + errContains: "carrier value is required", + }, + { + name: "carrier overflows signed output", + mutate: func(request *OnboardingRequest) { + request.CarrierValueSat = math.MaxUint64 + }, + errContains: "carrier value", + }, + { + name: "carrier below dust", + mutate: func(request *OnboardingRequest) { + request.CarrierValueSat = + onboardingDustFloorSat - 1 + }, + errContains: "below the Taproot dust floor", + }, + { + name: "fee selector missing", + mutate: func(request *OnboardingRequest) { + request.FeeRateSatPerVByte = 0 + }, + errContains: "exactly one", + }, + { + name: "fee selectors conflict", + mutate: func(request *OnboardingRequest) { + request.TargetConf = 6 + }, + errContains: "exactly one", + }, + { + name: "fee rate overflows", + mutate: func(request *OnboardingRequest) { + request.FeeRateSatPerVByte = math.MaxUint64 + }, + errContains: "fee rate", + }, + { + name: "maximum fee missing", + mutate: func(request *OnboardingRequest) { + request.MaxFeeSat = 0 + }, + errContains: "maximum fee is required", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + request, _, _ := testOnboardingRequest(t) + test.mutate(request) + err := validateOnboardingRequest(request) + require.ErrorContains(t, err, test.errContains) + }) + } +} + +// TestOnboardingCustomLockID proves retry identity is stable while every +// carrier-funding economic choice remains part of that identity. +func TestOnboardingCustomLockID(t *testing.T) { + t.Parallel() + + request, _, _ := testOnboardingRequest(t) + lockID := onboardingCustomLockID(onboardingRequestDigest(request)) + require.Len(t, lockID, sha256.Size) + require.Equal( + t, lockID, + onboardingCustomLockID( + onboardingRequestDigest(request), + ), + ) + + mutations := []func(*OnboardingRequest){ + func(request *OnboardingRequest) { + request.CarrierValueSat++ + }, + func(request *OnboardingRequest) { + request.FeeRateSatPerVByte++ + }, + func(request *OnboardingRequest) { + request.FeeRateSatPerVByte = 0 + request.TargetConf = 6 + }, + func(request *OnboardingRequest) { + request.MaxFeeSat++ + }, + } + for _, mutate := range mutations { + changed := *request + mutate(&changed) + require.NotEqual( + t, lockID, + onboardingCustomLockID( + onboardingRequestDigest(&changed), + ), + ) + } +} + +// TestOnboarderRejectsInvalidFundingSummary ensures restored SDK packages +// cannot broaden the wallet-funding authority declared by the durable request. +func TestOnboarderRejectsInvalidFundingSummary(t *testing.T) { + t.Parallel() + + callerFunded := tapsdk.CustomAnchorFundingCallerFundedExact + wrongMaximum := uint64(999) + tests := []struct { + name string + configure func(*fakeOnboardingDriver, *OnboardingRequest) + errContains string + }{ + { + name: "wrong funding mode", + configure: func(driver *fakeOnboardingDriver, + _ *OnboardingRequest) { + + driver.fundingMode = &callerFunded + }, + errContains: "not wallet funded", + }, + { + name: "different maximum", + configure: func(driver *fakeOnboardingDriver, + _ *OnboardingRequest) { + + driver.maxFeeSat = &wrongMaximum + }, + errContains: "does not match request", + }, + { + name: "actual fee exceeds maximum", + configure: func(driver *fakeOnboardingDriver, + request *OnboardingRequest) { + + driver.actualFeeSat = request.MaxFeeSat + 1 + }, + errContains: "exceeds maximum", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + request, inventory, owner := testOnboardingRequest(t) + driver := newFakeOnboardingDriver() + test.configure(driver, request) + store, err := NewFileStore(t.TempDir()) + require.NoError(t, err) + onboarder := testOnboarder( + driver, inventory, store, owner, + func(context.Context, OnboardingRegistration) ( + *OnboardingRegistrationResult, error) { + + return nil, + ErrOnboardingPendingConfirmation + }, + ) + + _, err = onboarder.Onboard(t.Context(), request) + require.ErrorContains(t, err, test.errContains) + require.Equal(t, 1, driver.commits) + require.Zero(t, driver.verifications) + require.Zero(t, driver.publishes) + }) + } } // TestOnboarderRejectsIdempotencyRewrite binds the durable request identity @@ -213,13 +461,21 @@ type fakeOnboardingDriver struct { requests []*tapsdk.CustomAnchorRequest result *commitResult commitErr error + actualFeeSat uint64 + appendChange bool + fundingMode *tapsdk.CustomAnchorFundingMode + maxFeeSat *uint64 commits int verifications int publishes int } func newFakeOnboardingDriver() *fakeOnboardingDriver { - return &fakeOnboardingDriver{base: newFakeDriver()} + return &fakeOnboardingDriver{ + base: newFakeDriver(), + actualFeeSat: 250, + appendChange: true, + } } func (d *fakeOnboardingDriver) CommitOnboarding(ctx context.Context, @@ -239,6 +495,63 @@ func (d *fakeOnboardingDriver) CommitOnboarding(ctx context.Context, if err != nil { return nil, err } + result.fundingMode = request.Funding.Mode + result.actualFeeSat = d.actualFeeSat + if request.Funding.WalletFunded != nil { + result.maxFeeSat = request.Funding.WalletFunded.MaxFeeSat + } + if d.fundingMode != nil { + result.fundingMode = *d.fundingMode + } + if d.maxFeeSat != nil { + result.maxFeeSat = *d.maxFeeSat + } + if d.appendChange { + packet, parseErr := psbtutil.Parse(result.anchorPSBT) + if parseErr != nil { + return nil, parseErr + } + walletInputValue := int64(2_000) + packet.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: int64(request.Outputs[0].AnchorValueSat), + PkScript: []byte{ + txscript.OP_TRUE, + }, + } + walletInputHash := sha256Bytes( + []byte("onboarding-wallet-input"), + ) + packet.UnsignedTx.AddTxIn( + wire.NewTxIn( + &wire.OutPoint{ + Hash: walletInputHash, + Index: 1, + }, + nil, + nil, + ), + ) + packet.Inputs = append(packet.Inputs, psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: walletInputValue, + PkScript: []byte{txscript.OP_TRUE}, + }, + }) + changeValue := walletInputValue - int64(result.actualFeeSat) + packet.UnsignedTx.AddTxOut(&wire.TxOut{ + Value: changeValue, + PkScript: []byte{txscript.OP_TRUE}, + }) + packet.Outputs = append(packet.Outputs, psbt.POutput{}) + result.anchorPSBT, err = psbtutil.Serialize(packet) + if err != nil { + return nil, err + } + anchorTxid := packet.UnsignedTx.TxHash() + for idx := range result.outputs { + result.outputs[idx].anchorOutpoint.Txid = anchorTxid + } + } d.mu.Lock() d.result = cloneCommitResult(result) d.mu.Unlock() @@ -249,7 +562,13 @@ func (d *fakeOnboardingDriver) CommitOnboarding(ctx context.Context, func (d *fakeOnboardingDriver) DecodePackage(encoded []byte) (*commitResult, error) { - return d.base.DecodePackage(encoded) + d.mu.Lock() + defer d.mu.Unlock() + if d.result == nil || !bytes.Equal(encoded, d.result.packageBytes) { + return nil, errors.New("unknown fake onboarding package") + } + + return cloneCommitResult(d.result), nil } func (d *fakeOnboardingDriver) VerifyFinalOnboarding(packageBytes, @@ -291,7 +610,7 @@ func testOnboardingRequest(t *testing.T) (*OnboardingRequest, *fakeInventory, operator := testPrivateKey(t, 21) anchorKey := testPrivateKey(t, 22) anchor := inventory.onlyAnchor() - anchor.AmtSat = 5_000 + anchor.AmtSat = 1_000 anchor.InternalKey, _ = tapsdk.ParsePubKey( anchorKey.PubKey().SerializeCompressed(), ) @@ -310,9 +629,11 @@ func testOnboardingRequest(t *testing.T) (*OnboardingRequest, *fakeInventory, ProofFile: append( []byte(nil), preparation.Intent.ProofFile..., ), - AnchorFeeSat: 250, - OperatorKey: operator.PubKey(), - ExitDelay: 144, + CarrierValueSat: 1_000, + FeeRateSatPerVByte: 2, + MaxFeeSat: 1_000, + OperatorKey: operator.PubKey(), + ExitDelay: 144, }, inventory, ownerDescriptor } diff --git a/waved/rpc_taproot_asset_onboarding.go b/waved/rpc_taproot_asset_onboarding.go index b507050f8..d800e82cb 100644 --- a/waved/rpc_taproot_asset_onboarding.go +++ b/waved/rpc_taproot_asset_onboarding.go @@ -89,7 +89,15 @@ func (r *RPCServer) OnboardTaprootAsset(ctx context.Context, len(req.GetInputProofFile()) == 0 || req.GetMaxFeeSat() == 0 { return nil, status.Error( codes.InvalidArgument, "idempotency key, asset "+ - "ref, amount, proof, and fee are required", + "ref, amount, proof, and maximum fee are "+ + "required", + ) + } + if (req.GetFeeRateSatPerVbyte() == 0) == + (req.GetTargetConf() == 0) { + return nil, status.Error( + codes.InvalidArgument, "exactly one of fee rate "+ + "and confirmation target is required", ) } if r.server.WalletLifecycleState() != WalletStateReady { @@ -111,15 +119,42 @@ func (r *RPCServer) OnboardTaprootAsset(ctx context.Context, "operator terms are not ready", ) } + minimumCarrier := terms.MinVTXOAmountFloor() + if minimumCarrier <= 0 { + return nil, status.Error( + codes.FailedPrecondition, + "operator returned an invalid minimum VTXO amount", + ) + } + carrierValue := req.GetCarrierValueSat() + if carrierValue == 0 { + carrierValue = uint64(minimumCarrier) + } + if carrierValue < uint64(minimumCarrier) { + return nil, status.Errorf(codes.InvalidArgument, "carrier "+ + "value %d is below operator minimum %d", carrierValue, + minimumCarrier) + } + if carrierValue > math.MaxInt64 { + return nil, status.Error( + codes.InvalidArgument, + "carrier value exceeds the supported Bitcoin range", + ) + } result, err := onboarder.Onboard(ctx, &tapassets.OnboardingRequest{ - RequestID: req.GetIdempotencyKey(), - AssetRef: req.GetAssetRef(), - AssetAmount: req.GetAssetAmount(), - ProofFile: append([]byte(nil), req.GetInputProofFile()...), - AnchorFeeSat: req.GetMaxFeeSat(), - OperatorKey: terms.PubKey, - ExitDelay: terms.VTXOExitDelay, + RequestID: req.GetIdempotencyKey(), + AssetRef: req.GetAssetRef(), + AssetAmount: req.GetAssetAmount(), + ProofFile: append( + []byte(nil), req.GetInputProofFile()..., + ), + CarrierValueSat: carrierValue, + FeeRateSatPerVByte: req.GetFeeRateSatPerVbyte(), + TargetConf: req.GetTargetConf(), + MaxFeeSat: req.GetMaxFeeSat(), + OperatorKey: terms.PubKey, + ExitDelay: terms.VTXOExitDelay, }) if errors.Is(err, tapassets.ErrReconciliationRequired) { return nil, status.Error(codes.FailedPrecondition, err.Error()) @@ -135,9 +170,10 @@ func (r *RPCServer) OnboardTaprootAsset(ctx context.Context, } response := &waverpc.OnboardTaprootAssetResponse{ - Outpoint: result.Outpoint.String(), - ValueSat: result.ValueSat, - PkScript: append([]byte(nil), result.PkScript...), + Outpoint: result.Outpoint.String(), + ValueSat: result.ValueSat, + PkScript: append([]byte(nil), result.PkScript...), + ActualFeeSat: result.ActualFeeSat, TaprootAssetRoot: append( []byte(nil), result.TaprootAssetRoot[:]..., ), diff --git a/waved/rpc_taproot_asset_onboarding_test.go b/waved/rpc_taproot_asset_onboarding_test.go index 92dfe0d49..2642ac2a6 100644 --- a/waved/rpc_taproot_asset_onboarding_test.go +++ b/waved/rpc_taproot_asset_onboarding_test.go @@ -60,7 +60,8 @@ func TestOnboardTaprootAssetPendingThenReady(t *testing.T) { ready := &tapassets.OnboardingResult{ Status: tapassets.OnboardingStatusReady, Outpoint: outpoint, - ValueSat: 4_750, + ValueSat: 1_000, + ActualFeeSat: 125, PolicyTemplate: policyBytes, PkScript: pkScript, TaprootAssetRoot: root, @@ -124,15 +125,17 @@ func TestOnboardTaprootAssetPendingThenReady(t *testing.T) { server.operatorTerms.Store(&types.OperatorTerms{ PubKey: operator.PubKey(), VTXOExitDelay: 144, + MinVTXOAmount: 1_000, }) rpcServer := &RPCServer{server: server} request := &waverpc.OnboardTaprootAssetRequest{ IdempotencyKey: "onboarding-id", AssetRef: "asset:00000000000000000000000000000000" + "00000000000000000000000000000001", - AssetAmount: 21, - InputProofFile: []byte("proof"), - MaxFeeSat: 250, + AssetAmount: 21, + InputProofFile: []byte("proof"), + MaxFeeSat: 250, + FeeRateSatPerVbyte: 2, } response, err := rpcServer.OnboardTaprootAsset(t.Context(), request) @@ -146,6 +149,8 @@ func TestOnboardTaprootAssetPendingThenReady(t *testing.T) { require.Equal(t, assetOnboardingReady, response.State) require.Equal(t, outpoint.String(), response.Outpoint) require.Equal(t, int32(321), response.ConfirmationHeight) + require.Equal(t, int64(1_000), response.ValueSat) + require.Equal(t, uint64(125), response.ActualFeeSat) stored, err := vtxoStore.GetVTXO(t.Context(), outpoint) require.NoError(t, err) require.True(t, sameOnboardedVTXO(stored, <-materialized)) @@ -160,7 +165,52 @@ func TestOnboardTaprootAssetPendingThenReady(t *testing.T) { for _, captured := range onboarder.requests { require.Equal(t, operator.PubKey(), captured.OperatorKey) require.Equal(t, uint32(144), captured.ExitDelay) + require.Equal(t, uint64(1_000), captured.CarrierValueSat) + require.Equal(t, uint64(2), captured.FeeRateSatPerVByte) + require.Zero(t, captured.TargetConf) + require.Equal(t, uint64(250), captured.MaxFeeSat) + } +} + +// TestOnboardTaprootAssetValidatesCarrierAndFeePolicy rejects economic +// parameters before an onboarding service can reserve or commit anything. +func TestOnboardTaprootAssetValidatesCarrierAndFeePolicy(t *testing.T) { + t.Parallel() + + operator, err := btcec.NewPrivateKey() + require.NoError(t, err) + onboarder := &testTaprootAssetOnboarder{} + cfg := DefaultConfig() + cfg.TaprootAssetOnboarder = onboarder + server := &Server{cfg: cfg, walletReady: make(chan struct{})} + server.walletState.Store(int32(WalletStateReady)) + server.operatorTerms.Store(&types.OperatorTerms{ + PubKey: operator.PubKey(), + VTXOExitDelay: 144, + MinVTXOAmount: 1_000, + }) + rpcServer := &RPCServer{server: server} + request := &waverpc.OnboardTaprootAssetRequest{ + IdempotencyKey: "onboarding-id", + AssetRef: "asset-ref", + AssetAmount: 21, + InputProofFile: []byte("proof"), + MaxFeeSat: 250, + CarrierValueSat: 999, + FeeRateSatPerVbyte: 2, } + + _, err = rpcServer.OnboardTaprootAsset(t.Context(), request) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + require.ErrorContains(t, err, "below operator minimum") + require.Empty(t, onboarder.requests) + + request.CarrierValueSat = 1_000 + request.TargetConf = 6 + _, err = rpcServer.OnboardTaprootAsset(t.Context(), request) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + require.ErrorContains(t, err, "exactly one") + require.Empty(t, onboarder.requests) } // TestOnboardTaprootAssetRequiresReadyFeature covers the public fail-closed @@ -174,6 +224,7 @@ func TestOnboardTaprootAssetRequiresReadyFeature(t *testing.T) { AssetAmount: 1, InputProofFile: []byte("proof"), MaxFeeSat: 1, + TargetConf: 6, } cfg := DefaultConfig() server := &Server{cfg: cfg, walletReady: make(chan struct{})} diff --git a/waverpc/daemon.pb.go b/waverpc/daemon.pb.go index fb4d1825d..f4cc9f016 100644 --- a/waverpc/daemon.pb.go +++ b/waverpc/daemon.pb.go @@ -883,8 +883,9 @@ func (VHTLCRecoveryState) EnumDescriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{11} } -// OnboardTaprootAssetRequest selects the complete confirmed asset proof and -// caps the Bitcoin fee paid by its current anchor. +// OnboardTaprootAssetRequest selects the complete confirmed asset proof, the +// visible Bitcoin value carried by its Wavelength VTXO, and a bounded fee +// policy for wallet funding. type OnboardTaprootAssetRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // idempotency_key is a stable caller-generated retry key. @@ -896,9 +897,19 @@ type OnboardTaprootAssetRequest struct { AssetAmount uint64 `protobuf:"varint,3,opt,name=asset_amount,json=assetAmount,proto3" json:"asset_amount,omitempty"` // input_proof_file is the complete confirmed Taproot Asset proof file. InputProofFile []byte `protobuf:"bytes,4,opt,name=input_proof_file,json=inputProofFile,proto3" json:"input_proof_file,omitempty"` - // max_fee_sat is the exact fee subtracted from the current anchor value. - // The name is a cap for forward compatibility; this PoC pays exactly it. - MaxFeeSat uint64 `protobuf:"varint,5,opt,name=max_fee_sat,json=maxFeeSat,proto3" json:"max_fee_sat,omitempty"` + // max_fee_sat is the hard upper bound for the on-chain miner fee. It is + // independent of the fee-rate or confirmation-target estimator. + MaxFeeSat uint64 `protobuf:"varint,5,opt,name=max_fee_sat,json=maxFeeSat,proto3" json:"max_fee_sat,omitempty"` + // carrier_value_sat is the Bitcoin value assigned to the asset-bearing + // VTXO. Zero uses the operator's current minimum VTXO value. + CarrierValueSat uint64 `protobuf:"varint,6,opt,name=carrier_value_sat,json=carrierValueSat,proto3" json:"carrier_value_sat,omitempty"` + // fee_rate_sat_per_vbyte selects an explicit on-chain fee rate. Exactly + // one of this field and target_conf must be non-zero. + FeeRateSatPerVbyte uint64 `protobuf:"varint,7,opt,name=fee_rate_sat_per_vbyte,json=feeRateSatPerVbyte,proto3" json:"fee_rate_sat_per_vbyte,omitempty"` + // target_conf asks the shared LND wallet to estimate a fee for this many + // blocks. Exactly one of this field and fee_rate_sat_per_vbyte must be + // non-zero. + TargetConf uint32 `protobuf:"varint,8,opt,name=target_conf,json=targetConf,proto3" json:"target_conf,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -968,6 +979,27 @@ func (x *OnboardTaprootAssetRequest) GetMaxFeeSat() uint64 { return 0 } +func (x *OnboardTaprootAssetRequest) GetCarrierValueSat() uint64 { + if x != nil { + return x.CarrierValueSat + } + return 0 +} + +func (x *OnboardTaprootAssetRequest) GetFeeRateSatPerVbyte() uint64 { + if x != nil { + return x.FeeRateSatPerVbyte + } + return 0 +} + +func (x *OnboardTaprootAssetRequest) GetTargetConf() uint32 { + if x != nil { + return x.TargetConf + } + return 0 +} + type OnboardTaprootAssetResponse struct { state protoimpl.MessageState `protogen:"open.v1"` State TaprootAssetOnboardingState `protobuf:"varint,1,opt,name=state,proto3,enum=waverpc.TaprootAssetOnboardingState" json:"state,omitempty"` @@ -976,8 +1008,11 @@ type OnboardTaprootAssetResponse struct { PkScript []byte `protobuf:"bytes,4,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` TaprootAssetRoot []byte `protobuf:"bytes,5,opt,name=taproot_asset_root,json=taprootAssetRoot,proto3" json:"taproot_asset_root,omitempty"` ConfirmationHeight int32 `protobuf:"varint,6,opt,name=confirmation_height,json=confirmationHeight,proto3" json:"confirmation_height,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // actual_fee_sat is the exact on-chain miner fee recorded in the sealed + // tap-sdk package. It remains stable across retries and restarts. + ActualFeeSat uint64 `protobuf:"varint,7,opt,name=actual_fee_sat,json=actualFeeSat,proto3" json:"actual_fee_sat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *OnboardTaprootAssetResponse) Reset() { @@ -1052,6 +1087,13 @@ func (x *OnboardTaprootAssetResponse) GetConfirmationHeight() int32 { return 0 } +func (x *OnboardTaprootAssetResponse) GetActualFeeSat() uint64 { + if x != nil { + return x.ActualFeeSat + } + return 0 +} + type GetInfoRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -10499,20 +10541,25 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + - "\fdaemon.proto\x12\awaverpc\"\xcf\x01\n" + + "\fdaemon.proto\x12\awaverpc\"\xd0\x02\n" + "\x1aOnboardTaprootAssetRequest\x12'\n" + "\x0fidempotency_key\x18\x01 \x01(\tR\x0eidempotencyKey\x12\x1b\n" + "\tasset_ref\x18\x02 \x01(\tR\bassetRef\x12!\n" + "\fasset_amount\x18\x03 \x01(\x04R\vassetAmount\x12(\n" + "\x10input_proof_file\x18\x04 \x01(\fR\x0einputProofFile\x12\x1e\n" + - "\vmax_fee_sat\x18\x05 \x01(\x04R\tmaxFeeSat\"\x8e\x02\n" + + "\vmax_fee_sat\x18\x05 \x01(\x04R\tmaxFeeSat\x12*\n" + + "\x11carrier_value_sat\x18\x06 \x01(\x04R\x0fcarrierValueSat\x122\n" + + "\x16fee_rate_sat_per_vbyte\x18\a \x01(\x04R\x12feeRateSatPerVbyte\x12\x1f\n" + + "\vtarget_conf\x18\b \x01(\rR\n" + + "targetConf\"\xb4\x02\n" + "\x1bOnboardTaprootAssetResponse\x12:\n" + "\x05state\x18\x01 \x01(\x0e2$.waverpc.TaprootAssetOnboardingStateR\x05state\x12\x1a\n" + "\boutpoint\x18\x02 \x01(\tR\boutpoint\x12\x1b\n" + "\tvalue_sat\x18\x03 \x01(\x03R\bvalueSat\x12\x1b\n" + "\tpk_script\x18\x04 \x01(\fR\bpkScript\x12,\n" + "\x12taproot_asset_root\x18\x05 \x01(\fR\x10taprootAssetRoot\x12/\n" + - "\x13confirmation_height\x18\x06 \x01(\x05R\x12confirmationHeight\"\x10\n" + + "\x13confirmation_height\x18\x06 \x01(\x05R\x12confirmationHeight\x12$\n" + + "\x0eactual_fee_sat\x18\a \x01(\x04R\factualFeeSat\"\x10\n" + "\x0eGetInfoRequest\"\xb1\x03\n" + "\x0fGetInfoResponse\x12\x18\n" + "\aversion\x18\x01 \x01(\tR\aversion\x12\x16\n" + diff --git a/waverpc/daemon.proto b/waverpc/daemon.proto index 4eed34781..1508c032a 100644 --- a/waverpc/daemon.proto +++ b/waverpc/daemon.proto @@ -270,8 +270,9 @@ service DaemonService { returns (ListVHTLCRecoveriesResponse); } -// OnboardTaprootAssetRequest selects the complete confirmed asset proof and -// caps the Bitcoin fee paid by its current anchor. +// OnboardTaprootAssetRequest selects the complete confirmed asset proof, the +// visible Bitcoin value carried by its Wavelength VTXO, and a bounded fee +// policy for wallet funding. message OnboardTaprootAssetRequest { // idempotency_key is a stable caller-generated retry key. string idempotency_key = 1; @@ -286,9 +287,22 @@ message OnboardTaprootAssetRequest { // input_proof_file is the complete confirmed Taproot Asset proof file. bytes input_proof_file = 4; - // max_fee_sat is the exact fee subtracted from the current anchor value. - // The name is a cap for forward compatibility; this PoC pays exactly it. + // max_fee_sat is the hard upper bound for the on-chain miner fee. It is + // independent of the fee-rate or confirmation-target estimator. uint64 max_fee_sat = 5; + + // carrier_value_sat is the Bitcoin value assigned to the asset-bearing + // VTXO. Zero uses the operator's current minimum VTXO value. + uint64 carrier_value_sat = 6; + + // fee_rate_sat_per_vbyte selects an explicit on-chain fee rate. Exactly + // one of this field and target_conf must be non-zero. + uint64 fee_rate_sat_per_vbyte = 7; + + // target_conf asks the shared LND wallet to estimate a fee for this many + // blocks. Exactly one of this field and fee_rate_sat_per_vbyte must be + // non-zero. + uint32 target_conf = 8; } // TaprootAssetOnboardingState reports whether the transaction still needs a @@ -306,6 +320,10 @@ message OnboardTaprootAssetResponse { bytes pk_script = 4; bytes taproot_asset_root = 5; int32 confirmation_height = 6; + + // actual_fee_sat is the exact on-chain miner fee recorded in the sealed + // tap-sdk package. It remains stable across retries and restarts. + uint64 actual_fee_sat = 7; } // WalletState mirrors the daemon's in-process wallet lifecycle enum: