Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions db/round_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
23 changes: 22 additions & 1 deletion round/join_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -424,7 +445,7 @@ func buildJoinRoundAuthRequest(ctx context.Context, env *ClientEnvironment,
vtxo.Expiry, forfeitReq,
),
LockTime: forfeitAuthLockTime(forfeitReq),
AuthSpend: forfeitReq.AuthSpend,
AuthSpend: authSpend,
})
}

Expand Down
9 changes: 9 additions & 0 deletions wallet/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +64 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the asset-root discriminator in wallet descriptors

For upgraded wallets containing the explicitly supported historical root-only asset rows, TaprootAssetRef is empty even though TaprootAssetRoot is non-nil. This descriptor therefore misclassifies such a VTXO as Bitcoin-only, and handleRefreshVTXOs emits a replacement request without AssetRef, AssetAmount, or FixedAmount; the refresh consequently cannot reissue the asset and may submit a destructive transition to an operator lacking conservation checks. Carry the root into this adapter and reject or recover metadata for root-only assets rather than using an empty reference as the Bitcoin discriminator.

Useful? React with 👍 / 👎.


// TaprootAssetAmount is the number of asset units riding on this
// VTXO.
TaprootAssetAmount uint64
}

// VTXOReader provides read-only access to VTXO descriptors. The wallet uses
Expand Down
15 changes: 13 additions & 2 deletions wallet/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Comment on lines +1735 to +1738

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add a change leg for asset-only refresh batches

When the selected batch contains only asset VTXOs, every replacement is marked FixedAmount and none can become the fee-bearing change output. A single asset refresh therefore has input sats exactly equal to its fixed output and is rejected whenever the quoted operator fee is positive; two or more asset replacements are rejected even with a zero fee because designateChangeMarker skips every fixed output, leaving the multi-output intent with no IsChange marker. Add an ordinary Bitcoin change/funding leg or reject these unsupported selections before reserving them.

AGENTS.md reference: wallet/AGENTS.md:L40-L42

Useful? React with 👍 / 👎.

}
vtxos = append(vtxos, request)
}

// Reserve the forfeit inputs through the VTXO manager before
Expand Down
51 changes: 51 additions & 0 deletions wallet/wallet_admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
15 changes: 5 additions & 10 deletions waved/rpc_refresh_estimate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down
14 changes: 8 additions & 6 deletions waved/rpc_taproot_asset_guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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{
Expand All @@ -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
Expand Down
16 changes: 9 additions & 7 deletions waved/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down