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 } 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, }) } 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 })