From da68ff70925d22fd7d8e477f3b5f30230f5e4ee5 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 10 Aug 2026 17:43:12 +0200 Subject: [PATCH 1/3] multi: reissue asset identity through a refresh Refreshing an asset-bearing VTXO forfeits the leaf and asks the same units back as a fixed-amount asset VTXO request, so the round reissues them through its asset transition. The wallet descriptor and the RPC preview learn the asset identity; the blanket refresh rejection is lifted for explicitly named targets while the catch-all selection still skips them. --- wallet/interfaces.go | 9 +++++ wallet/wallet.go | 15 ++++++-- wallet/wallet_admission_test.go | 51 +++++++++++++++++++++++++++ waved/rpc_refresh_estimate.go | 15 +++----- waved/rpc_taproot_asset_guard_test.go | 14 ++++---- waved/server.go | 16 +++++---- 6 files changed, 95 insertions(+), 25 deletions(-) diff --git a/wallet/interfaces.go b/wallet/interfaces.go index 7fe7a3caf..0ad2bc550 100644 --- a/wallet/interfaces.go +++ b/wallet/interfaces.go @@ -60,6 +60,15 @@ type VTXODescriptor struct { // OperatorKey is the operator's public key for collaborative spends. OperatorKey *btcec.PublicKey + + // TaprootAssetRef is the canonical reference of the Taproot Asset + // this VTXO carries, empty for a Bitcoin-only VTXO. A refresh must + // reissue the same asset through the round's asset transition. + TaprootAssetRef string + + // TaprootAssetAmount is the number of asset units riding on this + // VTXO. + TaprootAssetAmount uint64 } // VTXOReader provides read-only access to VTXO descriptors. The wallet uses diff --git a/wallet/wallet.go b/wallet/wallet.go index d9758950a..bc223f224 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1713,7 +1713,7 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, VTXOOutpoint: &op, Amount: vtxo.Amount, }) - vtxos = append(vtxos, types.VTXORequest{ + request := types.VTXORequest{ PolicyTemplate: policyTemplate, Amount: vtxo.Amount, OwnerKey: vtxo.ClientKey, @@ -1726,7 +1726,18 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, // debit on transfers_out, leaving only the // operator fee as the net vtxo_balance change. Origin: types.VTXOOriginRoundRefresh, - }) + } + + // An asset leaf reissues its units through the round's + // asset transition. The carrier value is fixed so the seal + // quote can never shrink it, and the request never acts as + // the change output. + if vtxo.TaprootAssetRef != "" { + request.AssetRef = vtxo.TaprootAssetRef + request.AssetAmount = vtxo.TaprootAssetAmount + request.FixedAmount = true + } + vtxos = append(vtxos, request) } // Reserve the forfeit inputs through the VTXO manager before diff --git a/wallet/wallet_admission_test.go b/wallet/wallet_admission_test.go index b8a5b59c2..05dede735 100644 --- a/wallet/wallet_admission_test.go +++ b/wallet/wallet_admission_test.go @@ -2001,3 +2001,54 @@ func TestSendVTXOsIntentPackageContents(t *testing.T) { "or 2+ markers)", ) } + +// TestRefreshCarriesAssetIdentity verifies that refreshing an +// asset-bearing VTXO stamps the asset onto the new VTXO request: the +// round must reissue the units through its asset transition, and the +// carrier value is fixed so the seal quote can never shrink it. +func TestRefreshCarriesAssetIdentity(t *testing.T) { + t.Parallel() + + op := testOutpoint(7) + vtxoDescs := map[wire.OutPoint]*VTXODescriptor{ + op: { + Outpoint: op, + Amount: 10_000, + PkScript: []byte{ + 0x51, + 0x20, + 0x01, + }, + PolicyTemplate: []byte{ + 0xde, + 0xad, + 0xbe, + 0xef, + }, + Expiry: 100, + TaprootAssetRef: "test-asset-ref", + TaprootAssetAmount: 900, + }, + } + + mgr := &mockVTXOManagerBehavior{ + forfeitReserveResp: &actormsg.ReserveForfeitResponse{}, + } + roundActor := &mockRoundActorBehavior{} + w := newTestWalletWithManagerAndRound( + t, mgr, roundActor, testVTXOReader(vtxoDescs), + ) + + result := w.Receive(t.Context(), &RefreshVTXOsRequest{ + TargetOutpoints: []wire.OutPoint{op}, + }) + require.True(t, result.IsOk(), "expected ok, got: %v", result.Err()) + + require.NotNil(t, roundActor.capturedIntent) + require.Len(t, roundActor.capturedIntent.VTXOs, 1) + request := roundActor.capturedIntent.VTXOs[0] + require.Equal(t, "test-asset-ref", request.AssetRef) + require.EqualValues(t, 900, request.AssetAmount) + require.True(t, request.FixedAmount) + require.False(t, request.IsChange) +} diff --git a/waved/rpc_refresh_estimate.go b/waved/rpc_refresh_estimate.go index 889834b1a..f5aa17f19 100644 --- a/waved/rpc_refresh_estimate.go +++ b/waved/rpc_refresh_estimate.go @@ -115,9 +115,11 @@ func (r *RPCServer) resolveRefreshPreviewTargets(ctx context.Context, // Keep only VTXOs actually in LiveState, matching the // real refresh path's filter: anything already on its // way through a round must not be double-counted in the - // preview either. Asset-bearing VTXOs are excluded: a - // round consumes the input without an asset transition, - // destroying the asset commitment. + // preview either. Asset-bearing VTXOs are excluded from + // the catch-all selection: refreshing one commits its + // units through the round's asset transition, so it takes + // an explicit outpoint rather than riding along with a + // bulk Bitcoin refresh. descs := make([]*vtxo.Descriptor, 0, len(liveVTXOs)) for _, desc := range liveVTXOs { if desc.Status != vtxo.VTXOStatusLive { @@ -158,13 +160,6 @@ func (r *RPCServer) resolveRefreshPreviewTargets(ctx context.Context, op.Hash, op.Index, desc.Status) } - // A round consumes the input without an asset transition, so - // refreshing an asset-bearing VTXO would destroy the asset - // commitment while preserving only its carrier sats. - if desc.TaprootAssetRoot != nil { - return nil, errAssetBearingVTXO(op, "refreshed") - } - descs = append(descs, desc) } diff --git a/waved/rpc_taproot_asset_guard_test.go b/waved/rpc_taproot_asset_guard_test.go index dbbc45a2e..8b0a66ce3 100644 --- a/waved/rpc_taproot_asset_guard_test.go +++ b/waved/rpc_taproot_asset_guard_test.go @@ -57,9 +57,11 @@ func TestRefreshAllSkipsAssetVTXOs(t *testing.T) { ) } -// TestRefreshExplicitRejectsAssetVTXO ensures a directly named -// asset-bearing VTXO is rejected instead of being destroyed by a round. -func TestRefreshExplicitRejectsAssetVTXO(t *testing.T) { +// TestRefreshExplicitAcceptsAssetVTXO ensures a directly named +// asset-bearing VTXO enters the refresh preview: the wallet reissues +// its units through the round's asset transition, so the old blanket +// rejection no longer applies. +func TestRefreshExplicitAcceptsAssetVTXO(t *testing.T) { t.Parallel() const height = int32(900) @@ -70,7 +72,7 @@ func TestRefreshExplicitRejectsAssetVTXO(t *testing.T) { assetDesc := newAssetGuardVTXO(t, 0x03) require.NoError(t, vtxoStore.SaveVTXO(t.Context(), assetDesc)) - _, err := r.RefreshVTXOs( + resp, err := r.RefreshVTXOs( t.Context(), &waverpc.RefreshVTXOsRequest{ Selection: &waverpc.RefreshVTXOsRequest_Outpoints{ Outpoints: &waverpc.OutpointSelection{ @@ -82,8 +84,8 @@ func TestRefreshExplicitRejectsAssetVTXO(t *testing.T) { DryRun: true, }, ) - require.Equal(t, codes.InvalidArgument, status.Code(err)) - require.ErrorContains(t, err, "cannot be refreshed") + require.NoError(t, err) + require.NotNil(t, resp) } // TestLeaveAllSkipsAssetVTXOs ensures selection=all never routes an diff --git a/waved/server.go b/waved/server.go index f42ecb731..f996977df 100644 --- a/waved/server.go +++ b/waved/server.go @@ -4013,13 +4013,15 @@ func (s *Server) initWalletActor(ctx context.Context, } return &wallet.VTXODescriptor{ - Outpoint: desc.Outpoint, - Amount: desc.Amount, - PolicyTemplate: desc.PolicyTemplate, - PkScript: desc.PkScript, - Expiry: desc.RelativeExpiry, - ClientKey: desc.ClientKey, - OperatorKey: desc.OperatorKey, + Outpoint: desc.Outpoint, + Amount: desc.Amount, + PolicyTemplate: desc.PolicyTemplate, + PkScript: desc.PkScript, + Expiry: desc.RelativeExpiry, + ClientKey: desc.ClientKey, + OperatorKey: desc.OperatorKey, + TaprootAssetRef: desc.TaprootAssetRef, + TaprootAssetAmount: desc.TaprootAssetAmount, }, nil }) From 355af14983b466d55658bbd85f86978f74a4180d Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 10 Aug 2026 17:57:23 +0200 Subject: [PATCH 2/3] round: compose the forfeit auth path for asset leaves The proof-of-funds input for a forfeited VTXO derived the default timeout path, whose witness program is the bare policy output. An asset leaf pays to the policy branched with its asset root, so the proof needs the composed control block, the same way boarding disclosures already build theirs. --- round/join_auth.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/round/join_auth.go b/round/join_auth.go index 8ad6fcb93..ebb046bec 100644 --- a/round/join_auth.go +++ b/round/join_auth.go @@ -412,6 +412,27 @@ func buildJoinRoundAuthRequest(ctx context.Context, env *ClientEnvironment, "missing operator key", outpoint) } + // An asset leaf pays to its policy tree branched with the + // asset commitment, so the default timeout proof path would + // derive the wrong witness program. Compose the timeout path + // with the leaf's asset root instead. + authSpend := forfeitReq.AuthSpend + if authSpend == nil && vtxo.TaprootAssetRoot != nil { + authSpend, err = arkscript.ComposedBoardingAuthSpend( + [32]byte( + vtxo.TaprootAssetRoot.CloneBytes(), + ), + vtxo.OwnerKey.PubKey, + vtxo.OperatorKey, + vtxo.Expiry, + ) + if err != nil { + return nil, nil, fmt.Errorf("forfeit auth "+ + "input %s composed spend: %w", outpoint, + err) + } + } + signingInputs = append(signingInputs, joinAuthInput{ OutPoint: outpoint, PrevOut: &wire.TxOut{ @@ -424,7 +445,7 @@ func buildJoinRoundAuthRequest(ctx context.Context, env *ClientEnvironment, vtxo.Expiry, forfeitReq, ), LockTime: forfeitAuthLockTime(forfeitReq), - AuthSpend: forfeitReq.AuthSpend, + AuthSpend: authSpend, }) } From 4efe00666f1787f3a2209e21a3af683c781eab8c Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 10 Aug 2026 18:04:23 +0200 Subject: [PATCH 3/3] db: rehydrate asset identity on round-store VTXO loads The round store's ClientVTXO loader dropped the Taproot Asset columns, so a forfeited asset leaf looked like plain sats to the join-auth builder and its proof spent the wrong witness program. --- db/round_store.go | 52 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/db/round_store.go b/db/round_store.go index dbddf0110..fa0e18692 100644 --- a/db/round_store.go +++ b/db/round_store.go @@ -1820,19 +1820,47 @@ func (s *RoundPersistenceStore) dbVTXOToDomainVTXO(ctx context.Context, copy(commitmentTxID[:], dbVTXO.CommitmentTxid) } + // Rehydrate the leaf's asset identity: a forfeit of an asset leaf + // must build its proof and forfeit spends against the composed + // output, which is only recognizable through these fields. + var taprootAssetRoot *chainhash.Hash + if len(dbVTXO.TaprootAssetRoot) > 0 { + if len(dbVTXO.TaprootAssetRoot) != chainhash.HashSize { + return nil, fmt.Errorf("invalid Taproot Asset root "+ + "length: %d", len(dbVTXO.TaprootAssetRoot)) + } + + root := &chainhash.Hash{} + copy(root[:], dbVTXO.TaprootAssetRoot) + taprootAssetRoot = root + } + taprootAssetRef, taprootAssetAmount, err := decodeTaprootAssetMetadata( + taprootAssetRoot, dbVTXO.TaprootAssetRef, + dbVTXO.TaprootAssetAmount, + ) + if err != nil { + return nil, err + } + return &round.ClientVTXO{ - Outpoint: outpoint, - Amount: btcutil.Amount(dbVTXO.Amount), - PolicyTemplate: policyTemplate, - PkScript: dbVTXO.PkScript, - Expiry: expiry, - OwnerKey: ownerKey, - OperatorKey: operatorPubkey, - Ancestry: ancestry, - RoundID: roundIDOpt, - CommitmentTxID: commitmentTxID, - BatchExpiry: dbVTXO.BatchExpiry, - CreatedHeight: dbVTXO.CreatedHeight, + Outpoint: outpoint, + Amount: btcutil.Amount(dbVTXO.Amount), + PolicyTemplate: policyTemplate, + PkScript: dbVTXO.PkScript, + Expiry: expiry, + OwnerKey: ownerKey, + OperatorKey: operatorPubkey, + Ancestry: ancestry, + RoundID: roundIDOpt, + CommitmentTxID: commitmentTxID, + BatchExpiry: dbVTXO.BatchExpiry, + CreatedHeight: dbVTXO.CreatedHeight, + TaprootAssetRoot: taprootAssetRoot, + TaprootAssetRef: taprootAssetRef, + TaprootAssetAmount: taprootAssetAmount, + TaprootAssetSealedPackage: bytes.Clone( + dbVTXO.TaprootAssetSealedPackage, + ), }, nil }