diff --git a/.gitignore b/.gitignore index 53489bdf6..df3166191 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ DS_Store go.work.sum /tools/custom-gcl +.reviews/ diff --git a/arkrpc/indexer.pb.go b/arkrpc/indexer.pb.go index ba0d37969..406526a10 100644 --- a/arkrpc/indexer.pb.go +++ b/arkrpc/indexer.pb.go @@ -155,6 +155,62 @@ func (VTXOEventType) EnumDescriptor() ([]byte, []int) { return file_indexer_proto_rawDescGZIP(), []int{1} } +// VTXOOrigin distinguishes how a VTXO was created so the receiving +// client can apply the correct materialization logic. +type VTXOOrigin int32 + +const ( + // VTXO_ORIGIN_UNSPECIFIED is the default zero value. + VTXOOrigin_VTXO_ORIGIN_UNSPECIFIED VTXOOrigin = 0 + // VTXO_ORIGIN_IN_ROUND indicates the VTXO was created as part of + // a confirmed round (directed in-round send). + VTXOOrigin_VTXO_ORIGIN_IN_ROUND VTXOOrigin = 1 + // VTXO_ORIGIN_OOR indicates the VTXO was created via an + // out-of-round transfer. + VTXOOrigin_VTXO_ORIGIN_OOR VTXOOrigin = 2 +) + +// Enum value maps for VTXOOrigin. +var ( + VTXOOrigin_name = map[int32]string{ + 0: "VTXO_ORIGIN_UNSPECIFIED", + 1: "VTXO_ORIGIN_IN_ROUND", + 2: "VTXO_ORIGIN_OOR", + } + VTXOOrigin_value = map[string]int32{ + "VTXO_ORIGIN_UNSPECIFIED": 0, + "VTXO_ORIGIN_IN_ROUND": 1, + "VTXO_ORIGIN_OOR": 2, + } +) + +func (x VTXOOrigin) Enum() *VTXOOrigin { + p := new(VTXOOrigin) + *p = x + return p +} + +func (x VTXOOrigin) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (VTXOOrigin) Descriptor() protoreflect.EnumDescriptor { + return file_indexer_proto_enumTypes[2].Descriptor() +} + +func (VTXOOrigin) Type() protoreflect.EnumType { + return &file_indexer_proto_enumTypes[2] +} + +func (x VTXOOrigin) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use VTXOOrigin.Descriptor instead. +func (VTXOOrigin) EnumDescriptor() ([]byte, []int) { + return file_indexer_proto_rawDescGZIP(), []int{2} +} + type RegisterReceiveScriptRequest struct { state protoimpl.MessageState `protogen:"open.v1"` PkScript []byte `protobuf:"bytes,1,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` @@ -2188,16 +2244,39 @@ func (x *ListVTXOEventsByScriptsResponse) GetNextCursor() uint64 { return 0 } -// IncomingVTXOEvent is delivered as a mailbox EVENT envelope. It is a durable -// hint that a wallet should reconcile state by polling event feeds. +// IncomingVTXOEvent is delivered as a mailbox EVENT envelope. For +// VTXO_CREATED events from confirmed rounds, the pk_script and +// value_sat fields carry enough data for the client to materialize +// the VTXO without a follow-up indexer query. type IncomingVTXOEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - EventId uint64 `protobuf:"varint,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - Type VTXOEventType `protobuf:"varint,2,opt,name=type,proto3,enum=arkrpc.VTXOEventType" json:"type,omitempty"` - Outpoint *OutPoint `protobuf:"bytes,3,opt,name=outpoint,proto3" json:"outpoint,omitempty"` - Status VTXOStatus `protobuf:"varint,4,opt,name=status,proto3,enum=arkrpc.VTXOStatus" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + EventId uint64 `protobuf:"varint,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + Type VTXOEventType `protobuf:"varint,2,opt,name=type,proto3,enum=arkrpc.VTXOEventType" json:"type,omitempty"` + Outpoint *OutPoint `protobuf:"bytes,3,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + Status VTXOStatus `protobuf:"varint,4,opt,name=status,proto3,enum=arkrpc.VTXOStatus" json:"status,omitempty"` + // pk_script is the VTXO output script. Present for CREATED events + // so the client can match against registered receive scripts. + PkScript []byte `protobuf:"bytes,5,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` + // value_sat is the VTXO amount in satoshis. + ValueSat uint64 `protobuf:"varint,6,opt,name=value_sat,json=valueSat,proto3" json:"value_sat,omitempty"` + // round_id identifies the round that created this VTXO. + RoundId string `protobuf:"bytes,7,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"` + // batch_expiry_height is the absolute block height at which the + // batch sweep path becomes spendable. The server MUST compute + // this as confirmation_height + sweep_delay before publishing + // the event. + BatchExpiryHeight int32 `protobuf:"varint,8,opt,name=batch_expiry_height,json=batchExpiryHeight,proto3" json:"batch_expiry_height,omitempty"` + // relative_expiry is the CSV delay for the unilateral exit path. + RelativeExpiry uint32 `protobuf:"varint,9,opt,name=relative_expiry,json=relativeExpiry,proto3" json:"relative_expiry,omitempty"` + // origin indicates how the VTXO was created (in-round send vs + // out-of-round transfer). + Origin VTXOOrigin `protobuf:"varint,10,opt,name=origin,proto3,enum=arkrpc.VTXOOrigin" json:"origin,omitempty"` + // commitment_txid is the transaction ID of the round's + // commitment transaction. This is distinct from the leaf txid + // carried in the outpoint field. + CommitmentTxid []byte `protobuf:"bytes,11,opt,name=commitment_txid,json=commitmentTxid,proto3" json:"commitment_txid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *IncomingVTXOEvent) Reset() { @@ -2258,6 +2337,55 @@ func (x *IncomingVTXOEvent) GetStatus() VTXOStatus { return VTXOStatus_VTXO_STATUS_UNSPECIFIED } +func (x *IncomingVTXOEvent) GetPkScript() []byte { + if x != nil { + return x.PkScript + } + return nil +} + +func (x *IncomingVTXOEvent) GetValueSat() uint64 { + if x != nil { + return x.ValueSat + } + return 0 +} + +func (x *IncomingVTXOEvent) GetRoundId() string { + if x != nil { + return x.RoundId + } + return "" +} + +func (x *IncomingVTXOEvent) GetBatchExpiryHeight() int32 { + if x != nil { + return x.BatchExpiryHeight + } + return 0 +} + +func (x *IncomingVTXOEvent) GetRelativeExpiry() uint32 { + if x != nil { + return x.RelativeExpiry + } + return 0 +} + +func (x *IncomingVTXOEvent) GetOrigin() VTXOOrigin { + if x != nil { + return x.Origin + } + return VTXOOrigin_VTXO_ORIGIN_UNSPECIFIED +} + +func (x *IncomingVTXOEvent) GetCommitmentTxid() []byte { + if x != nil { + return x.CommitmentTxid + } + return nil +} + var File_indexer_proto protoreflect.FileDescriptor const file_indexer_proto_rawDesc = "" + @@ -2410,12 +2538,20 @@ const file_indexer_proto_rawDesc = "" + "\x1fListVTXOEventsByScriptsResponse\x12)\n" + "\x06events\x18\x01 \x03(\v2\x11.arkrpc.VTXOEventR\x06events\x12\x1f\n" + "\vnext_cursor\x18\x02 \x01(\x04R\n" + - "nextCursor\"\xb3\x01\n" + + "nextCursor\"\xb6\x03\n" + "\x11IncomingVTXOEvent\x12\x19\n" + "\bevent_id\x18\x01 \x01(\x04R\aeventId\x12)\n" + "\x04type\x18\x02 \x01(\x0e2\x15.arkrpc.VTXOEventTypeR\x04type\x12,\n" + "\boutpoint\x18\x03 \x01(\v2\x10.arkrpc.OutPointR\boutpoint\x12*\n" + - "\x06status\x18\x04 \x01(\x0e2\x12.arkrpc.VTXOStatusR\x06status*\x84\x02\n" + + "\x06status\x18\x04 \x01(\x0e2\x12.arkrpc.VTXOStatusR\x06status\x12\x1b\n" + + "\tpk_script\x18\x05 \x01(\fR\bpkScript\x12\x1b\n" + + "\tvalue_sat\x18\x06 \x01(\x04R\bvalueSat\x12\x19\n" + + "\bround_id\x18\a \x01(\tR\aroundId\x12.\n" + + "\x13batch_expiry_height\x18\b \x01(\x05R\x11batchExpiryHeight\x12'\n" + + "\x0frelative_expiry\x18\t \x01(\rR\x0erelativeExpiry\x12*\n" + + "\x06origin\x18\n" + + " \x01(\x0e2\x12.arkrpc.VTXOOriginR\x06origin\x12'\n" + + "\x0fcommitment_txid\x18\v \x01(\fR\x0ecommitmentTxid*\x84\x02\n" + "\n" + "VTXOStatus\x12\x1b\n" + "\x17VTXO_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + @@ -2431,7 +2567,12 @@ const file_indexer_proto_rawDesc = "" + "\x1bVTXO_EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17VTXO_EVENT_TYPE_CREATED\x10\x01\x12\"\n" + "\x1eVTXO_EVENT_TYPE_STATUS_CHANGED\x10\x02\x12\x1e\n" + - "\x1aVTXO_EVENT_TYPE_TERMINATED\x10\x032\xef\x05\n" + + "\x1aVTXO_EVENT_TYPE_TERMINATED\x10\x03*X\n" + + "\n" + + "VTXOOrigin\x12\x1b\n" + + "\x17VTXO_ORIGIN_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14VTXO_ORIGIN_IN_ROUND\x10\x01\x12\x13\n" + + "\x0fVTXO_ORIGIN_OOR\x10\x022\xef\x05\n" + "\x0eIndexerService\x12d\n" + "\x15RegisterReceiveScript\x12$.arkrpc.RegisterReceiveScriptRequest\x1a%.arkrpc.RegisterReceiveScriptResponse\x12a\n" + "\x14ListMyReceiveScripts\x12#.arkrpc.ListMyReceiveScriptsRequest\x1a$.arkrpc.ListMyReceiveScriptsResponse\x12j\n" + @@ -2453,97 +2594,99 @@ func file_indexer_proto_rawDescGZIP() []byte { return file_indexer_proto_rawDescData } -var file_indexer_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_indexer_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_indexer_proto_msgTypes = make([]protoimpl.MessageInfo, 30) var file_indexer_proto_goTypes = []any{ (VTXOStatus)(0), // 0: arkrpc.VTXOStatus (VTXOEventType)(0), // 1: arkrpc.VTXOEventType - (*RegisterReceiveScriptRequest)(nil), // 2: arkrpc.RegisterReceiveScriptRequest - (*TaprootSchnorrProof)(nil), // 3: arkrpc.TaprootSchnorrProof - (*BIP322Proof)(nil), // 4: arkrpc.BIP322Proof - (*RegisterReceiveScriptResponse)(nil), // 5: arkrpc.RegisterReceiveScriptResponse - (*UnregisterReceiveScriptRequest)(nil), // 6: arkrpc.UnregisterReceiveScriptRequest - (*ListMyReceiveScriptsRequest)(nil), // 7: arkrpc.ListMyReceiveScriptsRequest - (*ListMyReceiveScriptsResponse)(nil), // 8: arkrpc.ListMyReceiveScriptsResponse - (*RegisteredReceiveScript)(nil), // 9: arkrpc.RegisteredReceiveScript - (*UnregisterReceiveScriptResponse)(nil), // 10: arkrpc.UnregisterReceiveScriptResponse - (*ListOORRecipientEventsByScriptRequest)(nil), // 11: arkrpc.ListOORRecipientEventsByScriptRequest - (*ListOORRecipientEventsByScriptResponse)(nil), // 12: arkrpc.ListOORRecipientEventsByScriptResponse - (*OORRecipientEvent)(nil), // 13: arkrpc.OORRecipientEvent - (*IncomingOOREvent)(nil), // 14: arkrpc.IncomingOOREvent - (*OutPoint)(nil), // 15: arkrpc.OutPoint - (*TxOut)(nil), // 16: arkrpc.TxOut - (*TreePathNode)(nil), // 17: arkrpc.TreePathNode - (*TreePath)(nil), // 18: arkrpc.TreePath - (*ScriptScope)(nil), // 19: arkrpc.ScriptScope - (*VTXO)(nil), // 20: arkrpc.VTXO - (*ListVTXOsByScriptsRequest)(nil), // 21: arkrpc.ListVTXOsByScriptsRequest - (*ListVTXOsByScriptsResponse)(nil), // 22: arkrpc.ListVTXOsByScriptsResponse - (*TreeNode)(nil), // 23: arkrpc.TreeNode - (*TreeEdge)(nil), // 24: arkrpc.TreeEdge - (*GetSubtreeByScriptsRequest)(nil), // 25: arkrpc.GetSubtreeByScriptsRequest - (*GetSubtreeByScriptsResponse)(nil), // 26: arkrpc.GetSubtreeByScriptsResponse - (*VTXOEvent)(nil), // 27: arkrpc.VTXOEvent - (*ListVTXOEventsByScriptsRequest)(nil), // 28: arkrpc.ListVTXOEventsByScriptsRequest - (*ListVTXOEventsByScriptsResponse)(nil), // 29: arkrpc.ListVTXOEventsByScriptsResponse - (*IncomingVTXOEvent)(nil), // 30: arkrpc.IncomingVTXOEvent - nil, // 31: arkrpc.TreePathNode.ChildrenEntry + (VTXOOrigin)(0), // 2: arkrpc.VTXOOrigin + (*RegisterReceiveScriptRequest)(nil), // 3: arkrpc.RegisterReceiveScriptRequest + (*TaprootSchnorrProof)(nil), // 4: arkrpc.TaprootSchnorrProof + (*BIP322Proof)(nil), // 5: arkrpc.BIP322Proof + (*RegisterReceiveScriptResponse)(nil), // 6: arkrpc.RegisterReceiveScriptResponse + (*UnregisterReceiveScriptRequest)(nil), // 7: arkrpc.UnregisterReceiveScriptRequest + (*ListMyReceiveScriptsRequest)(nil), // 8: arkrpc.ListMyReceiveScriptsRequest + (*ListMyReceiveScriptsResponse)(nil), // 9: arkrpc.ListMyReceiveScriptsResponse + (*RegisteredReceiveScript)(nil), // 10: arkrpc.RegisteredReceiveScript + (*UnregisterReceiveScriptResponse)(nil), // 11: arkrpc.UnregisterReceiveScriptResponse + (*ListOORRecipientEventsByScriptRequest)(nil), // 12: arkrpc.ListOORRecipientEventsByScriptRequest + (*ListOORRecipientEventsByScriptResponse)(nil), // 13: arkrpc.ListOORRecipientEventsByScriptResponse + (*OORRecipientEvent)(nil), // 14: arkrpc.OORRecipientEvent + (*IncomingOOREvent)(nil), // 15: arkrpc.IncomingOOREvent + (*OutPoint)(nil), // 16: arkrpc.OutPoint + (*TxOut)(nil), // 17: arkrpc.TxOut + (*TreePathNode)(nil), // 18: arkrpc.TreePathNode + (*TreePath)(nil), // 19: arkrpc.TreePath + (*ScriptScope)(nil), // 20: arkrpc.ScriptScope + (*VTXO)(nil), // 21: arkrpc.VTXO + (*ListVTXOsByScriptsRequest)(nil), // 22: arkrpc.ListVTXOsByScriptsRequest + (*ListVTXOsByScriptsResponse)(nil), // 23: arkrpc.ListVTXOsByScriptsResponse + (*TreeNode)(nil), // 24: arkrpc.TreeNode + (*TreeEdge)(nil), // 25: arkrpc.TreeEdge + (*GetSubtreeByScriptsRequest)(nil), // 26: arkrpc.GetSubtreeByScriptsRequest + (*GetSubtreeByScriptsResponse)(nil), // 27: arkrpc.GetSubtreeByScriptsResponse + (*VTXOEvent)(nil), // 28: arkrpc.VTXOEvent + (*ListVTXOEventsByScriptsRequest)(nil), // 29: arkrpc.ListVTXOEventsByScriptsRequest + (*ListVTXOEventsByScriptsResponse)(nil), // 30: arkrpc.ListVTXOEventsByScriptsResponse + (*IncomingVTXOEvent)(nil), // 31: arkrpc.IncomingVTXOEvent + nil, // 32: arkrpc.TreePathNode.ChildrenEntry } var file_indexer_proto_depIdxs = []int32{ - 3, // 0: arkrpc.RegisterReceiveScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof - 4, // 1: arkrpc.RegisterReceiveScriptRequest.bip322:type_name -> arkrpc.BIP322Proof - 3, // 2: arkrpc.UnregisterReceiveScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof - 4, // 3: arkrpc.UnregisterReceiveScriptRequest.bip322:type_name -> arkrpc.BIP322Proof - 9, // 4: arkrpc.ListMyReceiveScriptsResponse.scripts:type_name -> arkrpc.RegisteredReceiveScript - 3, // 5: arkrpc.ListOORRecipientEventsByScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof - 4, // 6: arkrpc.ListOORRecipientEventsByScriptRequest.bip322:type_name -> arkrpc.BIP322Proof - 13, // 7: arkrpc.ListOORRecipientEventsByScriptResponse.events:type_name -> arkrpc.OORRecipientEvent - 15, // 8: arkrpc.TreePathNode.input:type_name -> arkrpc.OutPoint - 16, // 9: arkrpc.TreePathNode.outputs:type_name -> arkrpc.TxOut - 31, // 10: arkrpc.TreePathNode.children:type_name -> arkrpc.TreePathNode.ChildrenEntry - 17, // 11: arkrpc.TreePath.nodes:type_name -> arkrpc.TreePathNode - 15, // 12: arkrpc.TreePath.batch_outpoint:type_name -> arkrpc.OutPoint - 16, // 13: arkrpc.TreePath.batch_output:type_name -> arkrpc.TxOut - 3, // 14: arkrpc.ScriptScope.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof - 4, // 15: arkrpc.ScriptScope.bip322:type_name -> arkrpc.BIP322Proof - 15, // 16: arkrpc.VTXO.outpoint:type_name -> arkrpc.OutPoint + 4, // 0: arkrpc.RegisterReceiveScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof + 5, // 1: arkrpc.RegisterReceiveScriptRequest.bip322:type_name -> arkrpc.BIP322Proof + 4, // 2: arkrpc.UnregisterReceiveScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof + 5, // 3: arkrpc.UnregisterReceiveScriptRequest.bip322:type_name -> arkrpc.BIP322Proof + 10, // 4: arkrpc.ListMyReceiveScriptsResponse.scripts:type_name -> arkrpc.RegisteredReceiveScript + 4, // 5: arkrpc.ListOORRecipientEventsByScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof + 5, // 6: arkrpc.ListOORRecipientEventsByScriptRequest.bip322:type_name -> arkrpc.BIP322Proof + 14, // 7: arkrpc.ListOORRecipientEventsByScriptResponse.events:type_name -> arkrpc.OORRecipientEvent + 16, // 8: arkrpc.TreePathNode.input:type_name -> arkrpc.OutPoint + 17, // 9: arkrpc.TreePathNode.outputs:type_name -> arkrpc.TxOut + 32, // 10: arkrpc.TreePathNode.children:type_name -> arkrpc.TreePathNode.ChildrenEntry + 18, // 11: arkrpc.TreePath.nodes:type_name -> arkrpc.TreePathNode + 16, // 12: arkrpc.TreePath.batch_outpoint:type_name -> arkrpc.OutPoint + 17, // 13: arkrpc.TreePath.batch_output:type_name -> arkrpc.TxOut + 4, // 14: arkrpc.ScriptScope.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof + 5, // 15: arkrpc.ScriptScope.bip322:type_name -> arkrpc.BIP322Proof + 16, // 16: arkrpc.VTXO.outpoint:type_name -> arkrpc.OutPoint 0, // 17: arkrpc.VTXO.status:type_name -> arkrpc.VTXOStatus - 18, // 18: arkrpc.VTXO.tree_path:type_name -> arkrpc.TreePath - 19, // 19: arkrpc.ListVTXOsByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope + 19, // 18: arkrpc.VTXO.tree_path:type_name -> arkrpc.TreePath + 20, // 19: arkrpc.ListVTXOsByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope 0, // 20: arkrpc.ListVTXOsByScriptsRequest.status_filter:type_name -> arkrpc.VTXOStatus - 20, // 21: arkrpc.ListVTXOsByScriptsResponse.vtxos:type_name -> arkrpc.VTXO - 15, // 22: arkrpc.TreeNode.input:type_name -> arkrpc.OutPoint - 19, // 23: arkrpc.GetSubtreeByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope - 20, // 24: arkrpc.GetSubtreeByScriptsResponse.vtxos:type_name -> arkrpc.VTXO - 23, // 25: arkrpc.GetSubtreeByScriptsResponse.nodes:type_name -> arkrpc.TreeNode - 24, // 26: arkrpc.GetSubtreeByScriptsResponse.edges:type_name -> arkrpc.TreeEdge + 21, // 21: arkrpc.ListVTXOsByScriptsResponse.vtxos:type_name -> arkrpc.VTXO + 16, // 22: arkrpc.TreeNode.input:type_name -> arkrpc.OutPoint + 20, // 23: arkrpc.GetSubtreeByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope + 21, // 24: arkrpc.GetSubtreeByScriptsResponse.vtxos:type_name -> arkrpc.VTXO + 24, // 25: arkrpc.GetSubtreeByScriptsResponse.nodes:type_name -> arkrpc.TreeNode + 25, // 26: arkrpc.GetSubtreeByScriptsResponse.edges:type_name -> arkrpc.TreeEdge 1, // 27: arkrpc.VTXOEvent.type:type_name -> arkrpc.VTXOEventType - 15, // 28: arkrpc.VTXOEvent.outpoint:type_name -> arkrpc.OutPoint + 16, // 28: arkrpc.VTXOEvent.outpoint:type_name -> arkrpc.OutPoint 0, // 29: arkrpc.VTXOEvent.status:type_name -> arkrpc.VTXOStatus - 19, // 30: arkrpc.ListVTXOEventsByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope - 27, // 31: arkrpc.ListVTXOEventsByScriptsResponse.events:type_name -> arkrpc.VTXOEvent + 20, // 30: arkrpc.ListVTXOEventsByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope + 28, // 31: arkrpc.ListVTXOEventsByScriptsResponse.events:type_name -> arkrpc.VTXOEvent 1, // 32: arkrpc.IncomingVTXOEvent.type:type_name -> arkrpc.VTXOEventType - 15, // 33: arkrpc.IncomingVTXOEvent.outpoint:type_name -> arkrpc.OutPoint + 16, // 33: arkrpc.IncomingVTXOEvent.outpoint:type_name -> arkrpc.OutPoint 0, // 34: arkrpc.IncomingVTXOEvent.status:type_name -> arkrpc.VTXOStatus - 2, // 35: arkrpc.IndexerService.RegisterReceiveScript:input_type -> arkrpc.RegisterReceiveScriptRequest - 7, // 36: arkrpc.IndexerService.ListMyReceiveScripts:input_type -> arkrpc.ListMyReceiveScriptsRequest - 6, // 37: arkrpc.IndexerService.UnregisterReceiveScript:input_type -> arkrpc.UnregisterReceiveScriptRequest - 11, // 38: arkrpc.IndexerService.ListOORRecipientEventsByScript:input_type -> arkrpc.ListOORRecipientEventsByScriptRequest - 21, // 39: arkrpc.IndexerService.ListVTXOsByScripts:input_type -> arkrpc.ListVTXOsByScriptsRequest - 25, // 40: arkrpc.IndexerService.GetSubtreeByScripts:input_type -> arkrpc.GetSubtreeByScriptsRequest - 28, // 41: arkrpc.IndexerService.ListVTXOEventsByScripts:input_type -> arkrpc.ListVTXOEventsByScriptsRequest - 5, // 42: arkrpc.IndexerService.RegisterReceiveScript:output_type -> arkrpc.RegisterReceiveScriptResponse - 8, // 43: arkrpc.IndexerService.ListMyReceiveScripts:output_type -> arkrpc.ListMyReceiveScriptsResponse - 10, // 44: arkrpc.IndexerService.UnregisterReceiveScript:output_type -> arkrpc.UnregisterReceiveScriptResponse - 12, // 45: arkrpc.IndexerService.ListOORRecipientEventsByScript:output_type -> arkrpc.ListOORRecipientEventsByScriptResponse - 22, // 46: arkrpc.IndexerService.ListVTXOsByScripts:output_type -> arkrpc.ListVTXOsByScriptsResponse - 26, // 47: arkrpc.IndexerService.GetSubtreeByScripts:output_type -> arkrpc.GetSubtreeByScriptsResponse - 29, // 48: arkrpc.IndexerService.ListVTXOEventsByScripts:output_type -> arkrpc.ListVTXOEventsByScriptsResponse - 42, // [42:49] is the sub-list for method output_type - 35, // [35:42] is the sub-list for method input_type - 35, // [35:35] is the sub-list for extension type_name - 35, // [35:35] is the sub-list for extension extendee - 0, // [0:35] is the sub-list for field type_name + 2, // 35: arkrpc.IncomingVTXOEvent.origin:type_name -> arkrpc.VTXOOrigin + 3, // 36: arkrpc.IndexerService.RegisterReceiveScript:input_type -> arkrpc.RegisterReceiveScriptRequest + 8, // 37: arkrpc.IndexerService.ListMyReceiveScripts:input_type -> arkrpc.ListMyReceiveScriptsRequest + 7, // 38: arkrpc.IndexerService.UnregisterReceiveScript:input_type -> arkrpc.UnregisterReceiveScriptRequest + 12, // 39: arkrpc.IndexerService.ListOORRecipientEventsByScript:input_type -> arkrpc.ListOORRecipientEventsByScriptRequest + 22, // 40: arkrpc.IndexerService.ListVTXOsByScripts:input_type -> arkrpc.ListVTXOsByScriptsRequest + 26, // 41: arkrpc.IndexerService.GetSubtreeByScripts:input_type -> arkrpc.GetSubtreeByScriptsRequest + 29, // 42: arkrpc.IndexerService.ListVTXOEventsByScripts:input_type -> arkrpc.ListVTXOEventsByScriptsRequest + 6, // 43: arkrpc.IndexerService.RegisterReceiveScript:output_type -> arkrpc.RegisterReceiveScriptResponse + 9, // 44: arkrpc.IndexerService.ListMyReceiveScripts:output_type -> arkrpc.ListMyReceiveScriptsResponse + 11, // 45: arkrpc.IndexerService.UnregisterReceiveScript:output_type -> arkrpc.UnregisterReceiveScriptResponse + 13, // 46: arkrpc.IndexerService.ListOORRecipientEventsByScript:output_type -> arkrpc.ListOORRecipientEventsByScriptResponse + 23, // 47: arkrpc.IndexerService.ListVTXOsByScripts:output_type -> arkrpc.ListVTXOsByScriptsResponse + 27, // 48: arkrpc.IndexerService.GetSubtreeByScripts:output_type -> arkrpc.GetSubtreeByScriptsResponse + 30, // 49: arkrpc.IndexerService.ListVTXOEventsByScripts:output_type -> arkrpc.ListVTXOEventsByScriptsResponse + 43, // [43:50] is the sub-list for method output_type + 36, // [36:43] is the sub-list for method input_type + 36, // [36:36] is the sub-list for extension type_name + 36, // [36:36] is the sub-list for extension extendee + 0, // [0:36] is the sub-list for field type_name } func init() { file_indexer_proto_init() } @@ -2572,7 +2715,7 @@ func file_indexer_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_indexer_proto_rawDesc), len(file_indexer_proto_rawDesc)), - NumEnums: 2, + NumEnums: 3, NumMessages: 30, NumExtensions: 0, NumServices: 1, diff --git a/arkrpc/indexer.proto b/arkrpc/indexer.proto index 3d8ce01e6..0e93405e3 100644 --- a/arkrpc/indexer.proto +++ b/arkrpc/indexer.proto @@ -472,11 +472,56 @@ message ListVTXOEventsByScriptsResponse { uint64 next_cursor = 2; } -// IncomingVTXOEvent is delivered as a mailbox EVENT envelope. It is a durable -// hint that a wallet should reconcile state by polling event feeds. +// VTXOOrigin distinguishes how a VTXO was created so the receiving +// client can apply the correct materialization logic. +enum VTXOOrigin { + // VTXO_ORIGIN_UNSPECIFIED is the default zero value. + VTXO_ORIGIN_UNSPECIFIED = 0; + + // VTXO_ORIGIN_IN_ROUND indicates the VTXO was created as part of + // a confirmed round (directed in-round send). + VTXO_ORIGIN_IN_ROUND = 1; + + // VTXO_ORIGIN_OOR indicates the VTXO was created via an + // out-of-round transfer. + VTXO_ORIGIN_OOR = 2; +} + +// IncomingVTXOEvent is delivered as a mailbox EVENT envelope. For +// VTXO_CREATED events from confirmed rounds, the pk_script and +// value_sat fields carry enough data for the client to materialize +// the VTXO without a follow-up indexer query. message IncomingVTXOEvent { uint64 event_id = 1; VTXOEventType type = 2; OutPoint outpoint = 3; VTXOStatus status = 4; + + // pk_script is the VTXO output script. Present for CREATED events + // so the client can match against registered receive scripts. + bytes pk_script = 5; + + // value_sat is the VTXO amount in satoshis. + uint64 value_sat = 6; + + // round_id identifies the round that created this VTXO. + string round_id = 7; + + // batch_expiry_height is the absolute block height at which the + // batch sweep path becomes spendable. The server MUST compute + // this as confirmation_height + sweep_delay before publishing + // the event. + int32 batch_expiry_height = 8; + + // relative_expiry is the CSV delay for the unilateral exit path. + uint32 relative_expiry = 9; + + // origin indicates how the VTXO was created (in-round send vs + // out-of-round transfer). + VTXOOrigin origin = 10; + + // commitment_txid is the transaction ID of the round's + // commitment transaction. This is distinct from the leaf txid + // carried in the outpoint field. + bytes commitment_txid = 11; } diff --git a/darepod/incoming_vtxo_handler.go b/darepod/incoming_vtxo_handler.go new file mode 100644 index 000000000..b4faf3d19 --- /dev/null +++ b/darepod/incoming_vtxo_handler.go @@ -0,0 +1,35 @@ +package darepod + +import ( + "context" + + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/vtxo" +) + +// ownedScriptLookupAdapter wraps db.OORArtifactPersistenceStore to +// satisfy the vtxo.OwnedScriptLookup interface. It converts the +// db-specific record type to the vtxo-level OwnedReceiveScript. +type ownedScriptLookupAdapter struct { + store *db.OORArtifactPersistenceStore +} + +// LookupOwnedReceiveScript delegates to the underlying store and +// converts the result to a vtxo.OwnedReceiveScript. +func (a *ownedScriptLookupAdapter) LookupOwnedReceiveScript( + ctx context.Context, + pkScript []byte) (*vtxo.OwnedReceiveScript, error) { + + rec, err := a.store.LookupOwnedReceiveScript(ctx, pkScript) + if err != nil { + return nil, err + } + + return &vtxo.OwnedReceiveScript{ + ClientKey: rec.ClientKey, + OperatorPubKey: rec.OperatorPubKey, + ExitDelay: rec.ExitDelay, + }, nil +} + +var _ vtxo.OwnedScriptLookup = (*ownedScriptLookupAdapter)(nil) diff --git a/darepod/owned_script_checker.go b/darepod/owned_script_checker.go new file mode 100644 index 000000000..3d4b8fbb4 --- /dev/null +++ b/darepod/owned_script_checker.go @@ -0,0 +1,86 @@ +package darepod + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/round" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" +) + +// ownedScriptCheckerAdapter implements round.OwnedScriptChecker by +// looking up pkScripts in the owned_receive_scripts persistence store. +type ownedScriptCheckerAdapter struct { + store *db.OORArtifactPersistenceStore +} + +var _ round.OwnedScriptChecker = (*ownedScriptCheckerAdapter)(nil) + +// IsOwnedScript returns whether the pkScript is registered as an owned +// receive script in the OOR artifact store. Returns an error for real +// store failures; a not-found result returns false with no error. +func (a *ownedScriptCheckerAdapter) IsOwnedScript(ctx context.Context, + pkScript []byte) fn.Result[bool] { + + if a.store == nil { + return fn.Ok(false) + } + + // Use a context that survives cancellation so the DB lookup + // completes even if the caller's context is being torn down + // (e.g., during round confirmation in a shutting-down FSM). + lookupCtx := context.WithoutCancel(ctx) + + _, err := a.store.LookupOwnedReceiveScript(lookupCtx, pkScript) + if err != nil { + // Not-found means the script isn't ours. + if errors.Is(err, sql.ErrNoRows) { + return fn.Ok(false) + } + + return fn.Err[bool](fmt.Errorf( + "lookup owned receive script: %w", err, + )) + } + + return fn.Ok(true) +} + +// ownedScriptRegistrarAdapter implements round.OwnedScriptRegistrar by +// persisting pkScripts in the owned_receive_scripts table. +type ownedScriptRegistrarAdapter struct { + store *db.OORArtifactPersistenceStore + operatorKey *btcec.PublicKey + exitDelay uint32 +} + +var _ round.OwnedScriptRegistrar = (*ownedScriptRegistrarAdapter)(nil) + +// RegisterOwnedScript persists the pkScript as a locally owned receive +// script in the OOR artifact store. +func (a *ownedScriptRegistrarAdapter) RegisterOwnedScript( + ctx context.Context, pkScript []byte, + ownerKey keychain.KeyDescriptor) error { + + if a.store == nil { + return fmt.Errorf("store is nil") + } + + return a.store.UpsertOwnedReceiveScript( + ctx, db.OwnedReceiveScriptRecord{ + PkScript: pkScript, + ClientKey: ownerKey, + OperatorPubKey: a.operatorKey, + ExitDelay: int64(a.exitDelay), + Source: db.OwnedReceiveScriptSourceWallet, + CreatedAt: time.Now(), + LastUsedAt: fn.None[time.Time](), + }, + ) +} diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index db30be95f..c3582b2d7 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -635,11 +635,21 @@ func (r *RPCServer) SendVTXO(ctx context.Context, return nil, err } + // TODO(#241): Tune this cap based on round tree constraints + // and consider making it configurable. + const maxRecipients = 256 + if len(req.Recipients) == 0 { return nil, status.Errorf(codes.InvalidArgument, "at least one recipient is required") } + if len(req.Recipients) > maxRecipients { + return nil, status.Errorf(codes.InvalidArgument, + "too many recipients: %d (max %d)", + len(req.Recipients), maxRecipients) + } + // Resolve each recipient's pkScript and client pubkey from // the proto Output destination. recipients := make( @@ -656,14 +666,24 @@ func (r *RPCServer) SendVTXO(ctx context.Context, ) } - if out.AmountSat <= 0 { + if out.AmountSat <= 0 || + out.AmountSat > int64(btcutil.MaxSatoshi) { + return nil, status.Errorf( codes.InvalidArgument, "recipient %d: amount must be "+ - "positive", i, + "between 1 and %d", + i, int64(btcutil.MaxSatoshi), ) } + // Overflow-safe addition. + if totalAmount > int64(btcutil.MaxSatoshi)-out.AmountSat { + return nil, status.Errorf( + codes.InvalidArgument, + "total amount overflows max supply") + } + pkScript, clientKey, err := r.resolveRecipientOutput( out, ) diff --git a/darepod/server.go b/darepod/server.go index 68c23260a..8a83c46b2 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -99,6 +99,10 @@ const ( // MethodIncomingOOR is the routing method name for incoming // OOR transfer notifications pushed by the server indexer. MethodIncomingOOR = "IncomingOOR" + + // MethodIncomingVTXO is the routing method name for incoming + // VTXO lifecycle events pushed by the server indexer. + MethodIncomingVTXO = "IncomingVTXO" ) // Main is the true entry point for the daemon. It is called after CLI flag @@ -117,6 +121,7 @@ func Main(cfg *Config, interceptor signal.Interceptor) error { // indexer client, and the daemon's own gRPC server. type Server struct { cfg *Config + clk clock.Clock logManager *lndbuild.SubLoggerManager loggers SubLoggers @@ -204,6 +209,7 @@ type Server struct { func NewServer(cfg *Config) (*Server, error) { return &Server{ cfg: cfg, + clk: clock.NewDefaultClock(), walletReady: make(chan struct{}), }, nil } @@ -473,12 +479,11 @@ func (s *Server) run(ctx context.Context, } // Create the VTXO store for RPC queries (ListVTXOs, GetBalance). - clk := clock.NewDefaultClock() dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - s.vtxoStore = dbStore.NewVTXOStore(clk) + s.vtxoStore = dbStore.NewVTXOStore(s.clk) // ------------------------------------------------------- // 6. Start the daemon's own gRPC server and mailbox mux. @@ -1450,10 +1455,47 @@ func (s *Server) buildEventRoutes() *serverconn.EventRouter { s.registerOOREventRoutes(router) s.registerRoundEventRoutes(router) + s.registerIncomingVTXOEventRoute(router) return router } +// registerIncomingVTXOEventRoute registers the IncomingVTXO push event +// route. When the server publishes a VTXO_CREATED event for a round +// leaf matching a registered receive script, this route dispatches it +// to the incoming VTXO handler actor for materialization. +func (s *Server) registerIncomingVTXOEventRoute( + router *serverconn.EventRouter) { + + vtxoKey := vtxo.IncomingVTXOServiceKey() + + serverconn.AddRoute(router, serverconn.EventRouteConfig[ + vtxo.IncomingVTXOMsg, vtxo.IncomingVTXOResp, + ]{ + Service: arkServiceName, + Method: MethodIncomingVTXO, + NewEvent: func() proto.Message { + return &arkrpc.IncomingVTXOEvent{} + }, + Key: vtxoKey, + Adapt: func(p proto.Message) ( + vtxo.IncomingVTXOMsg, error) { + + evt, ok := p.(*arkrpc.IncomingVTXOEvent) + if !ok { + return vtxo.IncomingVTXOMsg{}, + fmt.Errorf( + "expected "+ + "IncomingVTXOEvent"+ + ", got %T", p, + ) + } + + return vtxo.IncomingVTXOMsg{Event: evt}, nil + }, + }) +} + // registerOOREventRoutes registers OOR mailbox service event routes with the // EventRouter. When the server pushes SubmitPackage or FinalizePackage // response events, the router decodes the oorpb proto, adapts it into a @@ -1941,7 +1983,7 @@ func (s *Server) initDatabase(ctx context.Context) error { } s.deliveryStore, err = actordelivery.NewTxAwareDeliveryStoreFromDB( - s.db.DB, s.db.Backend(), clock.NewDefaultClock(), + s.db.DB, s.db.Backend(), s.clk, s.subLogger(actor.Subsystem), ) if err != nil { @@ -1965,7 +2007,7 @@ func (s *Server) initRPCClients(ctx context.Context) { s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - packageStore := dbStore.NewOORArtifactStore(clock.NewDefaultClock()) + packageStore := dbStore.NewOORArtifactStore(s.clk) // Determine the node identity pubkey for indexer registration. // In lnd mode this comes from the lnd connection. In lwwallet @@ -2071,13 +2113,11 @@ func (s *Server) initWalletActor(ctx context.Context, chainsource.ChainSourceMsg, chainsource.ChainSourceResp, ]) (actor.ActorRef[wallet.WalletMsg, wallet.WalletResp], error) { - clk := clock.NewDefaultClock() - dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - boardingStore := dbStore.NewBoardingStore(s.chainParams, clk) + boardingStore := dbStore.NewBoardingStore(s.chainParams, s.clk) // Select the boarding backend based on wallet type. var boardingBackend wallet.BoardingBackend @@ -2186,13 +2226,11 @@ func (s *Server) initRoundActor(ctx context.Context, clientWallet = s.btcwWallet.UnsafeFromSome() } - clk := clock.NewDefaultClock() - dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - roundStore := dbStore.NewRoundStore(s.chainParams, clk) + roundStore := dbStore.NewRoundStore(s.chainParams, s.clk) s.roundStore = roundStore // Fetch the operator's terms from the server. These include @@ -2214,21 +2252,44 @@ func (s *Server) initRoundActor(ctx context.Context, // is generous for regtest/testnet usage. const defaultMaxOperatorFee = btcutil.Amount(1_000_000) + // Build the owned-script checker from the OOR artifact store. + // This allows the round FSM to determine which VTXOs belong + // to the local wallet by looking up registered receive scripts. + var scriptChecker round.OwnedScriptChecker + var scriptRegistrar round.OwnedScriptRegistrar + if s.db != nil { + oorStore := db.NewStore( + s.db.DB, s.db.Queries, s.db.Backend(), + s.log, + ).NewOORArtifactStore(s.clk) + + scriptChecker = &ownedScriptCheckerAdapter{ + store: oorStore, + } + scriptRegistrar = &ownedScriptRegistrarAdapter{ + store: oorStore, + operatorKey: operatorTerms.PubKey, + exitDelay: operatorTerms.VTXOExitDelay, + } + } + roundCfg := &round.RoundClientConfig{ - Name: "round-client", - Logger: s.subLogger(round.Subsystem), - Wallet: clientWallet, - RoundStore: roundStore, - VTXOStore: roundStore, - OperatorTerms: operatorTerms, - ServerConn: s.runtime.TellRef(), - ChainSource: chainSourceRef, - WalletActor: walletRef, - ChainParams: s.chainParams, - ActorSystem: s.actorSystem, - TimeoutActor: timeoutRef, - MaxOperatorFee: defaultMaxOperatorFee, - VTXOManager: vtxoManager, + Name: "round-client", + Logger: s.subLogger(round.Subsystem), + Wallet: clientWallet, + RoundStore: roundStore, + VTXOStore: roundStore, + OperatorTerms: operatorTerms, + ServerConn: s.runtime.TellRef(), + ChainSource: chainSourceRef, + WalletActor: walletRef, + ChainParams: s.chainParams, + ActorSystem: s.actorSystem, + TimeoutActor: timeoutRef, + MaxOperatorFee: defaultMaxOperatorFee, + VTXOManager: vtxoManager, + OwnedScriptChecker: scriptChecker, + OwnedScriptRegistrar: scriptRegistrar, ForfeitCollectionTimeout: s.cfg. ForfeitCollectionTimeout, } @@ -2288,12 +2349,11 @@ func (s *Server) initVTXOManager(ctx context.Context, vtxoWallet = s.btcwWallet.UnsafeFromSome() } - clk := clock.NewDefaultClock() dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - vtxoStore := dbStore.NewVTXOStore(clk) + vtxoStore := dbStore.NewVTXOStore(s.clk) manager := vtxo.NewManager(&vtxo.ManagerConfig{ Store: vtxoStore, @@ -2342,7 +2402,6 @@ func (s *Server) initVTXOManager(ctx context.Context, func (s *Server) initOORActor(ctx context.Context, vtxoManagerRef actor.TellOnlyRef[vtxo.ManagerMsg]) error { - clk := clock.NewDefaultClock() dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), @@ -2368,8 +2427,8 @@ func (s *Server) initOORActor(ctx context.Context, oorSigner = s.btcwWallet.UnsafeFromSome() } - vtxoStore := dbStore.NewVTXOStore(clk) - packageStore := dbStore.NewOORArtifactStore(clk) + vtxoStore := dbStore.NewVTXOStore(s.clk) + packageStore := dbStore.NewOORArtifactStore(s.clk) // Create the timeout actor for scheduling retry timers. When a // retry timer fires, the callback ref transforms the expiry into @@ -2467,6 +2526,28 @@ func (s *Server) initOORActor(ctx context.Context, s.log.InfoS(ctx, "OOR client actor started") + // Register the incoming VTXO handler actor. This handles + // IncomingVTXOEvent push notifications from the indexer and + // materializes VTXOs for registered receive scripts. + incomingVTXOStore := dbStore.NewVTXOStore(s.clk) + incomingHandler := vtxo.NewIncomingVTXOHandler( + vtxo.IncomingVTXOHandlerConfig{ + Log: fn.Some(s.subLogger(Subsystem)), + ScriptStore: &ownedScriptLookupAdapter{ + store: packageStore, + }, + VTXOStore: incomingVTXOStore, + VTXOManager: vtxoManagerRef, + }, + ) + incomingKey := vtxo.IncomingVTXOServiceKey() + actor.RegisterWithSystem( + s.actorSystem, "incoming-vtxo-handler", + incomingKey, incomingHandler, + ) + + s.log.InfoS(ctx, "Incoming VTXO handler started") + return nil } diff --git a/db/round_store.go b/db/round_store.go index 4a2458a31..15db09f10 100644 --- a/db/round_store.go +++ b/db/round_store.go @@ -1102,7 +1102,6 @@ func vtxoRequestToRoundParams(roundID string, requestIndex int, ClientPubkey: clientPubkey, ClientKeyFamily: int32(req.OwnerKey.KeyLocator.Family), ClientKeyIndex: int32(req.OwnerKey.KeyLocator.Index), - OwnsClientKey: req.IsOwner, OperatorPubkey: operatorPubkey, SigningKeyFamily: int32(req.SigningKey.KeyLocator.Family), SigningKeyIndex: int32(req.SigningKey.KeyLocator.Index), @@ -1151,7 +1150,6 @@ func dbVtxoRequestRowToRoundVTXORequest( Index: uint32(t.ClientKeyIndex), }, }, - IsOwner: t.OwnsClientKey, OperatorKey: operatorKey, }, SigningKey: keychain.KeyDescriptor{ diff --git a/db/round_store_test.go b/db/round_store_test.go index f66a80e5a..346e33470 100644 --- a/db/round_store_test.go +++ b/db/round_store_test.go @@ -614,7 +614,6 @@ func createBoardingIntentFixture( Index: uint32(idx * 10), }, }, - IsOwner: true, OperatorKey: operatorPubKey, }, SigningKey: signingKey, @@ -767,7 +766,6 @@ func TestRoundStoreDecoupledVTXOStorage(t *testing.T) { Index: uint32(i), }, }, - IsOwner: true, OperatorKey: operatorKey.PubKey(), }, SigningKey: signingKey, diff --git a/db/vtxo_store.go b/db/vtxo_store.go index 9661f8c51..7d694574c 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -366,8 +366,9 @@ func (s *VTXOPersistenceStore) descriptorToInsertParams( desc *vtxo.Descriptor, ) (InsertVTXOParams, error) { - // Serialize tree path. - var treePathBytes []byte + // Serialize tree path. Use empty blob if no path is available + // (e.g., incoming VTXOs from round notifications). + treePathBytes := []byte{} if desc.TreePath != nil { data, err := SerializeTree(desc.TreePath) if err != nil { diff --git a/lib/types/boarding.go b/lib/types/boarding.go index 215cfb76c..5c676223f 100644 --- a/lib/types/boarding.go +++ b/lib/types/boarding.go @@ -130,12 +130,6 @@ type VTXORequest struct { // preserved locally when the client owns the resulting VTXO. OwnerKey keychain.KeyDescriptor - // IsOwner reports whether this client controls the VTXO owner - // key and should persist the created VTXO locally once the - // round confirms. This is local-only metadata and is not sent - // on the wire. - IsOwner bool - // OperatorKey is the public key of the operator used in the // construction of the collaborative spend path of the VTXO. OperatorKey *btcec.PublicKey diff --git a/round/AGENTS.md b/round/AGENTS.md index cfac427b6..5073dc8a5 100644 --- a/round/AGENTS.md +++ b/round/AGENTS.md @@ -17,7 +17,7 @@ protocols with MuSig2 signing ceremonies. - `Intents` — Pools of boarding, VTXO, forfeit, and leave requests accumulated before registration. - `IntentPackage` — FSM event wrapping `Intents` for atomic delivery to the round FSM. - `RegisterIntentRequest` — Actor message carrying a pre-composed `IntentPackage` from the wallet. -- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`, `IsOwner` flag. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). +- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). Ownership is determined at confirmation time via `OwnedScriptChecker`. - `RoundVTXORequest` — Pairs a `VTXOIntent` with an ephemeral `SigningKey` derived at registration time for MuSig2 tree construction. - `ForfeitSignaturesCollectingState` — State entered after VTXO tree signing when round includes refresh/leave VTXOs. Waits for all expected forfeit signatures before submitting to server. - `ForfeitSignatureResponse` — Carries a VTXO's forfeit signature back from the VTXO actor. diff --git a/round/CLAUDE.md b/round/CLAUDE.md index cfac427b6..5073dc8a5 100644 --- a/round/CLAUDE.md +++ b/round/CLAUDE.md @@ -17,7 +17,7 @@ protocols with MuSig2 signing ceremonies. - `Intents` — Pools of boarding, VTXO, forfeit, and leave requests accumulated before registration. - `IntentPackage` — FSM event wrapping `Intents` for atomic delivery to the round FSM. - `RegisterIntentRequest` — Actor message carrying a pre-composed `IntentPackage` from the wallet. -- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`, `IsOwner` flag. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). +- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). Ownership is determined at confirmation time via `OwnedScriptChecker`. - `RoundVTXORequest` — Pairs a `VTXOIntent` with an ephemeral `SigningKey` derived at registration time for MuSig2 tree construction. - `ForfeitSignaturesCollectingState` — State entered after VTXO tree signing when round includes refresh/leave VTXOs. Waits for all expected forfeit signatures before submitting to server. - `ForfeitSignatureResponse` — Carries a VTXO's forfeit signature back from the VTXO actor. diff --git a/round/actor.go b/round/actor.go index 6313160bb..064aab034 100644 --- a/round/actor.go +++ b/round/actor.go @@ -113,7 +113,6 @@ func (a *RoundClientActor) buildVTXOIntentFromRefresh( PkScript: req.PkScript, Expiry: req.Expiry, OwnerKey: req.NewVTXOKey, - IsOwner: true, OperatorKey: req.OperatorKey, }, nil } @@ -267,6 +266,17 @@ type RoundClientConfig struct { // forfeit signatures after entering ForfeitSignaturesCollectingState. // If zero, a conservative default is used. ForfeitCollectionTimeout time.Duration + + // OwnedScriptChecker determines whether a VTXO pkScript belongs + // to the local wallet. When nil, all VTXOs pass the ownership + // check (backward-compatible default for tests). + OwnedScriptChecker OwnedScriptChecker + + // OwnedScriptRegistrar registers pkScripts as locally owned. + // Called when building VTXO intents so the checker can + // recognize them at confirmation time. When nil, registration + // is skipped (tests). + OwnedScriptRegistrar OwnedScriptRegistrar } // NewRoundClientActor creates a new client actor with the provided @@ -295,6 +305,7 @@ func NewRoundClientActor(cfg *RoundClientConfig) fn.Result[*RoundClientActor] { MaxOperatorFee: cfg.MaxOperatorFee, Log: actorLog, DisableJoinRequestAuth: cfg.DisableJoinRequestAuth, + OwnedScriptChecker: cfg.OwnedScriptChecker, } if err := ValidateDelayParameters( @@ -387,6 +398,7 @@ func (a *RoundClientActor) createRoundFSMFromDB(ctx context.Context, DisableJoinRequestAuth: a.cfg.DisableJoinRequestAuth, ForfeitCollectionTimeout: a. env.ForfeitCollectionTimeout, + OwnedScriptChecker: a.cfg.OwnedScriptChecker, } fsmCfg := ClientStateMachineCfg{ Logger: fsmLogger, @@ -446,6 +458,7 @@ func (a *RoundClientActor) createNewRound(ctx context.Context) (*RoundFSM, error DisableJoinRequestAuth: a.cfg.DisableJoinRequestAuth, ForfeitCollectionTimeout: a. env.ForfeitCollectionTimeout, + OwnedScriptChecker: a.cfg.OwnedScriptChecker, } fsmCfg := ClientStateMachineCfg{ Logger: fsmLogger, @@ -1080,7 +1093,6 @@ func vtxoRequestToIntent(req types.VTXORequest) VTXOIntent { PkScript: req.PkScript, Expiry: req.Expiry, OwnerKey: req.OwnerKey, - IsOwner: req.IsOwner, OperatorKey: req.OperatorKey, } } @@ -1113,12 +1125,24 @@ func (a *RoundClientActor) buildVTXOIntent(ctx context.Context, ) } + // Register the pkScript as locally owned so the + // OwnedScriptChecker recognizes it at confirmation time. + if a.cfg.OwnedScriptRegistrar != nil { + regErr := a.cfg.OwnedScriptRegistrar.RegisterOwnedScript( + ctx, desc.PkScript, *ownerKeyDesc, + ) + if regErr != nil { + return nil, fmt.Errorf( + "register owned script: %w", regErr, + ) + } + } + return &VTXOIntent{ Amount: amount, PkScript: desc.PkScript, Expiry: expiry, OwnerKey: *ownerKeyDesc, - IsOwner: true, OperatorKey: operatorKey, }, nil } @@ -1961,6 +1985,36 @@ func (a *RoundClientActor) handleRegisterIntent(ctx context.Context, } } + // Register locally-owned VTXO pkScripts from the intent so + // the OwnedScriptChecker recognizes them at confirmation + // time. VTXOs with a populated OwnerKey.KeyLocator are + // locally derived (e.g. change outputs in directed sends). + // A zero-value KeyLocator (Family=0, Index=0) signals a + // remote recipient whose key was not derived by this + // wallet — Family 0 is not used for VTXO owner keys + // (VTXOOwnerKeyFamily starts at 44). + if a.cfg.OwnedScriptRegistrar != nil { + for _, vtxo := range req.Package.VTXOs { + if vtxo.OwnerKey.PubKey == nil { + continue + } + + if vtxo.OwnerKey.KeyLocator == (keychain.KeyLocator{}) { //nolint:ll + continue + } + + regErr := a.cfg.OwnedScriptRegistrar.RegisterOwnedScript( //nolint:ll + ctx, vtxo.PkScript, vtxo.OwnerKey, + ) + if regErr != nil { + return fn.Err[actormsg.RoundActorResp]( + fmt.Errorf("register owned "+ + "script: %w", regErr), + ) + } + } + } + // Feed the pre-composed package to the FSM. err := a.askEventAndProcessOutbox(ctx, roundFSM, req.Package) if err != nil { diff --git a/round/actor_test.go b/round/actor_test.go index d192bb6aa..c3541f4d0 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -222,7 +222,6 @@ func TestActorStart(t *testing.T) { require.Equal(t, expectedDesc.PkScript, req.PkScript) require.Equal(t, h.operatorTerms.VTXOExitDelay, req.Expiry) require.True(t, req.OwnerKey.PubKey.IsEqual(ownerKey.PubKey)) - require.True(t, req.IsOwner) require.True(t, req.OperatorKey.IsEqual(h.operatorPubKey)) // SigningKey is not on VTXORequest — it lives on diff --git a/round/fsm_environment.go b/round/fsm_environment.go index a32745ba7..4bad6918d 100644 --- a/round/fsm_environment.go +++ b/round/fsm_environment.go @@ -62,6 +62,13 @@ type ClientEnvironment struct { // ForfeitCollectionTimeout is the timeout used while waiting for // forfeit signatures from VTXO actors. ForfeitCollectionTimeout time.Duration + + // OwnedScriptChecker determines whether a pkScript belongs to + // the local wallet. Used by buildOwnedClientVTXOs to filter + // VTXOs that should be persisted locally. This replaces the + // IsOwner flag with a data-driven ownership check backed by + // the owned receive scripts store. + OwnedScriptChecker OwnedScriptChecker } // Name returns the unique identifier for this FSM instance. diff --git a/round/harness_test.go b/round/harness_test.go index b4f9f2b16..aa42fec65 100644 --- a/round/harness_test.go +++ b/round/harness_test.go @@ -22,6 +22,7 @@ import ( "github.com/lightninglabs/darepo-client/lib/tree" "github.com/lightninglabs/darepo-client/lib/types" "github.com/lightninglabs/darepo-client/wallet" + fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/mock" @@ -145,6 +146,31 @@ func (m *MockVTXOStore) MarkVTXOSpent(ctx context.Context, // Compile-time check that MockVTXOStore implements VTXOStore. var _ VTXOStore = (*MockVTXOStore)(nil) +// mockOwnedScriptChecker is an OwnedScriptChecker backed by a set of +// known owned pkScripts. +type mockOwnedScriptChecker struct { + owned map[string]bool +} + +func newMockOwnedScriptChecker( + ownedScripts ...[]byte) *mockOwnedScriptChecker { + + m := &mockOwnedScriptChecker{owned: make(map[string]bool)} + for _, s := range ownedScripts { + m.owned[string(s)] = true + } + + return m +} + +func (m *mockOwnedScriptChecker) IsOwnedScript(_ context.Context, + pkScript []byte) fn.Result[bool] { + + return fn.Ok(m.owned[string(pkScript)]) +} + +var _ OwnedScriptChecker = (*mockOwnedScriptChecker)(nil) + // MockClientWallet implements ClientWallet (input.MuSig2Signer + input.Signer) // using mock.Mock for testing. type MockClientWallet struct { @@ -562,7 +588,6 @@ func (h *boardingTestHarness) newTestVTXORequestForIntent( PkScript: pkScript, Expiry: testExitDelay, OwnerKey: clientKey, - IsOwner: true, OperatorKey: h.operatorPubKey, } } diff --git a/round/interfaces.go b/round/interfaces.go index c41f28b9a..bfb3229d9 100644 --- a/round/interfaces.go +++ b/round/interfaces.go @@ -232,10 +232,6 @@ type VTXOIntent struct { // OwnerKey is the owner's key descriptor for the VTXO. OwnerKey keychain.KeyDescriptor - // IsOwner reports whether this client controls the VTXO owner - // key and should persist the VTXO locally once confirmed. - IsOwner bool - // OperatorKey is the operator's public key for collaborative // spends. OperatorKey *btcec.PublicKey @@ -260,7 +256,6 @@ func (r RoundVTXORequest) ToVTXORequest() types.VTXORequest { PkScript: r.PkScript, Expiry: r.Expiry, OwnerKey: r.OwnerKey, - IsOwner: r.IsOwner, OperatorKey: r.OperatorKey, SigningKey: r.SigningKey, } @@ -446,6 +441,29 @@ type ClientVTXO struct { RoundID fn.Option[RoundID] } +// OwnedScriptChecker determines whether a pkScript belongs to the local +// wallet. Implementations typically check against the persisted set of +// registered receive scripts (the same store used for OOR receives). +type OwnedScriptChecker interface { + // IsOwnedScript returns whether the pkScript is registered as + // an owned receive script in the local wallet. Returns an + // error if the store lookup fails for reasons other than the + // script not being found. + IsOwnedScript(ctx context.Context, + pkScript []byte) fn.Result[bool] +} + +// OwnedScriptRegistrar registers a pkScript as locally owned. The +// round actor calls this when building VTXO intents (boarding, +// refresh, change) so the OwnedScriptChecker can recognize them +// when the round confirms. +type OwnedScriptRegistrar interface { + // RegisterOwnedScript persists a pkScript + owner key as + // locally owned. + RegisterOwnedScript(ctx context.Context, pkScript []byte, + ownerKey keychain.KeyDescriptor) error +} + // VTXOStore defines the storage interface for off-chain balance management. // VTXOs (Virtual Transaction Outputs) are created when rounds complete // successfully and represent the client's off-chain balance. diff --git a/round/transitions.go b/round/transitions.go index e987d5f35..3e65107f0 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -1638,16 +1638,27 @@ func (s *PartialSigsSentState) transitionToForfeitCollection( } // buildOwnedClientVTXOs constructs locally owned ClientVTXO instances from -// the intents and client trees. Requests for foreign-owned VTXOs are skipped: -// the client still co-signs their tree path, but it must not persist them as -// spendable local balance. -func buildOwnedClientVTXOs(intents Intents, trees map[SignerKey]*tree.Tree, +// the intents and client trees. VTXOs whose pkScript is not recognized by +// the OwnedScriptChecker are skipped: the client still co-signs their tree +// path, but it must not persist them as spendable local balance. +func buildOwnedClientVTXOs(ctx context.Context, checker OwnedScriptChecker, + intents Intents, trees map[SignerKey]*tree.Tree, roundID RoundID) ([]*ClientVTXO, error) { vtxos := make([]*ClientVTXO, 0) for _, req := range intents.VTXOs { - if !req.IsOwner { - continue + if checker != nil { + owned, err := checker.IsOwnedScript( + ctx, req.PkScript, + ).Unpack() + if err != nil { + return nil, fmt.Errorf("check owned "+ + "script: %w", err) + } + + if !owned { + continue + } } signerKey := NewSignerKey(req.SigningKey.PubKey) @@ -1657,37 +1668,35 @@ func buildOwnedClientVTXOs(intents Intents, trees map[SignerKey]*tree.Tree, "for signing key") } - // Each signing key maps to exactly one leaf - // (enforced by ValidatePath during tree validation). + // Each signing key maps to exactly one leaf in the + // commitment tree. The server assigns one sub-tree + // per signing key, and each sub-tree contains a + // single VTXO output leaf (plus an anchor). This is + // enforced by ValidatePath during tree validation. leaves := clientTree.Root.GetLeafNodes() - - for _, leaf := range leaves { - outpoint, err := leaf.GetNonAnchorOutpoint() - if err != nil { - return nil, fmt.Errorf("failed to "+ - "derive VTXO outpoint: %w", err) - } - - // Use the VTXO's declared OwnerKey rather - // than the SigningKey (MuSig2 co-signer). For - // self-refresh these are the same key, but for - // directed sends the recipient's OwnerKey - // differs from the sender's SigningKey. - ownerKeyDesc := keychain.KeyDescriptor{ - PubKey: req.OwnerKey.PubKey, - } - - vtxos = append(vtxos, &ClientVTXO{ - Outpoint: *outpoint, - Amount: req.Amount, - PkScript: req.PkScript, - Expiry: req.Expiry, - OwnerKey: ownerKeyDesc, - OperatorKey: req.OperatorKey, - TreePath: clientTree, - RoundID: fn.Some(roundID), - }) + if len(leaves) != 1 { + return nil, fmt.Errorf("expected exactly "+ + "1 leaf for signing key, got %d", + len(leaves)) } + leaf := leaves[0] + + outpoint, err := leaf.GetNonAnchorOutpoint() + if err != nil { + return nil, fmt.Errorf("failed to "+ + "derive VTXO outpoint: %w", err) + } + + vtxos = append(vtxos, &ClientVTXO{ + Outpoint: *outpoint, + Amount: req.Amount, + PkScript: req.PkScript, + Expiry: req.Expiry, + OwnerKey: req.OwnerKey, + OperatorKey: req.OperatorKey, + TreePath: clientTree, + RoundID: fn.Some(roundID), + }) } return vtxos, nil @@ -1720,6 +1729,7 @@ func (s *InputSigSentState) ProcessEvent( slog.Int("confirmations", int(evt.Confirmations))) vtxos, err := buildOwnedClientVTXOs( + ctx, env.OwnedScriptChecker, s.Intents, s.ClientTrees, s.RoundID, ) if err != nil { diff --git a/round/transitions_test.go b/round/transitions_test.go index 2385ec095..08f6d09aa 100644 --- a/round/transitions_test.go +++ b/round/transitions_test.go @@ -12,6 +12,7 @@ import ( "github.com/lightninglabs/darepo-client/lib/scripts" "github.com/lightninglabs/darepo-client/lib/tree" "github.com/lightninglabs/darepo-client/lib/types" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -1520,10 +1521,13 @@ func TestInputSigSentState(t *testing.T) { []BoardingIntent{intent}, ) - // Mark the VTXO request as not locally owned. The - // client co-signed the tree path but does not own the - // resulting VTXO, so it must not be persisted. - state.Intents.VTXOs[0].IsOwner = false + // Simulate a foreign-owned VTXO: clear the owner key's + // key locator family so it doesn't match the local + // VTXO owner key family, and install a checker that + // also rejects the pkScript. + state.Intents.VTXOs[0].OwnerKey.KeyLocator = + keychain.KeyLocator{} + h.env.OwnedScriptChecker = newMockOwnedScriptChecker() h.withState(state) diff --git a/vtxo/incoming_handler.go b/vtxo/incoming_handler.go new file mode 100644 index 000000000..45d3b547a --- /dev/null +++ b/vtxo/incoming_handler.go @@ -0,0 +1,270 @@ +package vtxo + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "math" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/arkrpc" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/lib/scripts" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" +) + +// OwnedReceiveScript holds the metadata returned when looking up a +// pkScript in the owned receive scripts store. +type OwnedReceiveScript struct { + // ClientKey is the local wallet key descriptor for this script. + ClientKey keychain.KeyDescriptor + + // OperatorPubKey is the operator pubkey used for this script. + OperatorPubKey *btcec.PublicKey + + // ExitDelay is the relative CSV delay for the exit path. + ExitDelay int64 +} + +// OwnedScriptLookup provides read access to the owned receive scripts +// store. Implementations return sql.ErrNoRows when the script is not +// found. +type OwnedScriptLookup interface { + // LookupOwnedReceiveScript returns the metadata for a + // registered owned receive script, or sql.ErrNoRows if the + // script is not tracked. + LookupOwnedReceiveScript(ctx context.Context, + pkScript []byte) (*OwnedReceiveScript, error) +} + +// VTXOSaver persists materialized VTXO descriptors. +type VTXOSaver interface { + // SaveVTXO persists the given VTXO descriptor. + SaveVTXO(ctx context.Context, desc *Descriptor) error +} + +// IncomingVTXOMsg wraps an IncomingVTXOEvent for the handler actor. +type IncomingVTXOMsg struct { + actor.BaseMessage + Event *arkrpc.IncomingVTXOEvent +} + +// MessageType returns a human-readable message identifier. +func (m IncomingVTXOMsg) MessageType() string { + return fmt.Sprintf("IncomingVTXOMsg(event_id=%d)", + m.Event.GetEventId()) +} + +// IncomingVTXOResp is the handler's response type. +type IncomingVTXOResp = any + +const incomingVTXOServiceKeyName = "incoming-vtxo-handler" + +// IncomingVTXOServiceKey returns the well-known service key for the +// incoming VTXO handler actor. +func IncomingVTXOServiceKey() actor.ServiceKey[ + IncomingVTXOMsg, IncomingVTXOResp] { + + return actor.NewServiceKey[IncomingVTXOMsg, IncomingVTXOResp]( + incomingVTXOServiceKeyName, + ) +} + +// IncomingVTXOHandlerConfig holds the handler's dependencies. +type IncomingVTXOHandlerConfig struct { + // Log is the optional logger for the handler. + Log fn.Option[btclog.Logger] + + // ScriptStore is the persistence store used to look up owned + // receive scripts by pkScript. + ScriptStore OwnedScriptLookup + + // VTXOStore is the persistence store used to save materialized + // VTXO descriptors. + VTXOStore VTXOSaver + + // VTXOManager is a tell-only reference to the VTXO manager + // actor, used to notify it of newly materialized VTXOs. + VTXOManager actor.TellOnlyRef[ManagerMsg] +} + +// IncomingVTXOHandler materializes VTXOs from IncomingVTXOEvent +// notifications pushed by the server's indexer after round +// confirmation. +type IncomingVTXOHandler struct { + cfg IncomingVTXOHandlerConfig + log btclog.Logger +} + +// NewIncomingVTXOHandler creates a new handler. +func NewIncomingVTXOHandler( + cfg IncomingVTXOHandlerConfig) *IncomingVTXOHandler { + + return &IncomingVTXOHandler{ + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + } +} + +// Receive processes IncomingVTXOEvent messages. +func (h *IncomingVTXOHandler) Receive(ctx context.Context, + msg IncomingVTXOMsg) fn.Result[IncomingVTXOResp] { + + evt := msg.Event + if evt == nil { + return fn.Ok[IncomingVTXOResp](nil) + } + + // We only handle VTXO_CREATED events. Log unexpected types + // so we notice if the server starts sending new event kinds. + if evt.Type != arkrpc.VTXOEventType_VTXO_EVENT_TYPE_CREATED { + h.log.DebugS(ctx, "Ignoring non-CREATED VTXO event", + slog.Int("type", int(evt.Type))) + + return fn.Ok[IncomingVTXOResp](nil) + } + + op := evt.GetOutpoint() + if op == nil || len(op.Txid) != 32 { + h.log.WarnS(ctx, "IncomingVTXOEvent has invalid "+ + "or missing outpoint", nil) + + return fn.Ok[IncomingVTXOResp](nil) + } + + pkScript := evt.GetPkScript() + if len(pkScript) == 0 { + h.log.WarnS(ctx, "IncomingVTXOEvent has empty "+ + "pkScript", nil) + + return fn.Ok[IncomingVTXOResp](nil) + } + + var outpoint wire.OutPoint + copy(outpoint.Hash[:], op.Txid) + outpoint.Index = op.Vout + + h.log.InfoS(ctx, "Received IncomingVTXOEvent", + slog.String("outpoint", outpoint.String()), + slog.Uint64("value_sat", evt.ValueSat), + slog.String("round_id", evt.RoundId)) + + if h.cfg.ScriptStore == nil { + return fn.Ok[IncomingVTXOResp](nil) + } + + // Look up the pkScript in owned receive scripts. + rec, err := h.cfg.ScriptStore.LookupOwnedReceiveScript( + ctx, pkScript, + ) + if err != nil { + // Not-found means the script isn't ours — ignore. + // Any other error is a real store failure that + // should be surfaced. + if errors.Is(err, sql.ErrNoRows) { + return fn.Ok[IncomingVTXOResp](nil) + } + + return fn.Err[IncomingVTXOResp](fmt.Errorf( + "lookup owned receive script: %w", err, + )) + } + + if rec.ClientKey.PubKey == nil { + h.log.WarnS(ctx, "Owned receive script has nil "+ + "client pubkey", nil, + slog.String("outpoint", outpoint.String())) + + return fn.Ok[IncomingVTXOResp](nil) + } + + // Reject server-provided values that would overflow int64 + // when cast to btcutil.Amount. + if evt.ValueSat > uint64(math.MaxInt64) || + evt.ValueSat > uint64(btcutil.MaxSatoshi) { + + h.log.WarnS(ctx, "Incoming VTXO value exceeds "+ + "maximum", nil, + slog.String("outpoint", outpoint.String()), + slog.Uint64("value_sat", evt.ValueSat)) + + return fn.Ok[IncomingVTXOResp](nil) + } + + // Build the tapscript for the descriptor. + operatorKey := rec.OperatorPubKey + exitDelay := uint32(rec.ExitDelay) + + tapscript, err := scripts.VTXOTapScript( + rec.ClientKey.PubKey, operatorKey, exitDelay, + ) + if err != nil { + h.log.WarnS(ctx, "Failed to derive tapscript "+ + "for incoming VTXO", err, + slog.String("outpoint", outpoint.String())) + + return fn.Ok[IncomingVTXOResp](nil) + } + + // Use the commitment tx ID from the event, which references + // the round's commitment transaction. This is distinct from + // the leaf txid in the outpoint. + var commitTxID chainhash.Hash + if len(evt.CommitmentTxid) == chainhash.HashSize { + copy(commitTxID[:], evt.CommitmentTxid) + } + + desc := &Descriptor{ + Outpoint: outpoint, + Amount: btcutil.Amount(evt.ValueSat), + PkScript: pkScript, + OwnerKey: rec.ClientKey, + OperatorKey: operatorKey, + TapScript: tapscript, + RoundID: evt.RoundId, + CommitmentTxID: commitTxID, + BatchExpiry: evt.BatchExpiryHeight, + RelativeExpiry: evt.RelativeExpiry, + Status: VTXOStatusLive, + } + + // Persist the VTXO. A save failure signals a database or + // schema inconsistency that must be surfaced. + if h.cfg.VTXOStore != nil { + saveErr := h.cfg.VTXOStore.SaveVTXO(ctx, desc) + if saveErr != nil { + return fn.Err[IncomingVTXOResp](fmt.Errorf( + "save incoming VTXO %s: %w", + outpoint.String(), saveErr, + )) + } + } + + // Notify the VTXO manager to spawn an actor. + if h.cfg.VTXOManager != nil { + tellErr := h.cfg.VTXOManager.Tell( + ctx, + &VTXOsMaterializedNotification{ + VTXOs: []*Descriptor{desc}, + }, + ) + if tellErr != nil { + h.log.WarnS(ctx, "Failed to notify "+ + "VTXO manager", tellErr) + } + } + + h.log.InfoS(ctx, "Materialized incoming VTXO", + slog.String("outpoint", outpoint.String()), + slog.Int64("amount", int64(desc.Amount)), + slog.String("round_id", evt.RoundId)) + + return fn.Ok[IncomingVTXOResp](nil) +} diff --git a/vtxo/incoming_handler_test.go b/vtxo/incoming_handler_test.go new file mode 100644 index 000000000..6b77fddf3 --- /dev/null +++ b/vtxo/incoming_handler_test.go @@ -0,0 +1,188 @@ +package vtxo + +import ( + "context" + "database/sql" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/arkrpc" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// mockScriptLookup implements OwnedScriptLookup for testing. +type mockScriptLookup struct { + scripts map[string]*OwnedReceiveScript +} + +func (m *mockScriptLookup) LookupOwnedReceiveScript( + _ context.Context, + pkScript []byte) (*OwnedReceiveScript, error) { + + rec, ok := m.scripts[string(pkScript)] + if !ok { + return nil, sql.ErrNoRows + } + + return rec, nil +} + +// mockVTXOSaver implements VTXOSaver for testing. +type mockVTXOSaver struct { + saved []*Descriptor +} + +func (m *mockVTXOSaver) SaveVTXO( + _ context.Context, desc *Descriptor) error { + + m.saved = append(m.saved, desc) + + return nil +} + +// newTestEvent creates an IncomingVTXOEvent with the given parameters. +func newTestEvent(txid chainhash.Hash, vout uint32, + pkScript []byte, valueSat uint64, + roundID string) *arkrpc.IncomingVTXOEvent { + + return &arkrpc.IncomingVTXOEvent{ + EventId: 1, + Type: arkrpc.VTXOEventType_VTXO_EVENT_TYPE_CREATED, + Outpoint: &arkrpc.OutPoint{ + Txid: txid[:], + Vout: vout, + }, + PkScript: pkScript, + ValueSat: valueSat, + RoundId: roundID, + BatchExpiryHeight: 800_000, + RelativeExpiry: 144, + CommitmentTxid: txid[:], + } +} + +// TestIncomingVTXOHandlerOwnedScript verifies that a VTXO_CREATED +// event for an owned script results in a persisted VTXO and +// manager notification. +func TestIncomingVTXOHandlerOwnedScript(t *testing.T) { + t.Parallel() + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pkScript := []byte{0x51, 0x20, 0xaa, 0xbb} + + lookup := &mockScriptLookup{ + scripts: map[string]*OwnedReceiveScript{ + string(pkScript): { + ClientKey: keychain.KeyDescriptor{ + PubKey: privKey.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: 44, + Index: 0, + }, + }, + OperatorPubKey: operatorPriv.PubKey(), + ExitDelay: 144, + }, + }, + } + saver := &mockVTXOSaver{} + + handler := NewIncomingVTXOHandler(IncomingVTXOHandlerConfig{ + ScriptStore: lookup, + VTXOStore: saver, + }) + + var txid chainhash.Hash + txid[0] = 0x01 + + evt := newTestEvent(txid, 0, pkScript, 50_000, "round-1") + msg := IncomingVTXOMsg{Event: evt} + + result := handler.Receive(t.Context(), msg) + _, resultErr := result.Unpack() + require.NoError(t, resultErr) + + require.Len(t, saver.saved, 1) + + desc := saver.saved[0] + require.Equal(t, txid, desc.Outpoint.Hash) + require.Equal(t, uint32(0), desc.Outpoint.Index) + require.Equal(t, int64(50_000), int64(desc.Amount)) + require.Equal(t, pkScript, desc.PkScript) + require.Equal(t, "round-1", desc.RoundID) + require.Equal(t, VTXOStatusLive, desc.Status) +} + +// TestIncomingVTXOHandlerUnownedScript verifies that a VTXO_CREATED +// event for an unowned script is silently ignored. +func TestIncomingVTXOHandlerUnownedScript(t *testing.T) { + t.Parallel() + + lookup := &mockScriptLookup{ + scripts: map[string]*OwnedReceiveScript{}, + } + saver := &mockVTXOSaver{} + + handler := NewIncomingVTXOHandler(IncomingVTXOHandlerConfig{ + ScriptStore: lookup, + VTXOStore: saver, + }) + + var txid chainhash.Hash + txid[0] = 0x02 + + evt := newTestEvent( + txid, 0, []byte{0x51, 0x20, 0xff}, 10_000, "round-2", + ) + msg := IncomingVTXOMsg{Event: evt} + + result := handler.Receive(t.Context(), msg) + _, resultErr := result.Unpack() + require.NoError(t, resultErr) + + require.Empty(t, saver.saved) +} + +// TestIncomingVTXOHandlerNonCreatedEvent verifies that non-CREATED +// event types are ignored. +func TestIncomingVTXOHandlerNonCreatedEvent(t *testing.T) { + t.Parallel() + + saver := &mockVTXOSaver{} + handler := NewIncomingVTXOHandler(IncomingVTXOHandlerConfig{ + VTXOStore: saver, + }) + + evt := &arkrpc.IncomingVTXOEvent{ + EventId: 2, + Type: arkrpc.VTXOEventType_VTXO_EVENT_TYPE_STATUS_CHANGED, + } + msg := IncomingVTXOMsg{Event: evt} + + result := handler.Receive(t.Context(), msg) + _, resultErr := result.Unpack() + require.NoError(t, resultErr) + + require.Empty(t, saver.saved) +} + +// TestIncomingVTXOHandlerNilEvent verifies that a nil event is +// handled gracefully. +func TestIncomingVTXOHandlerNilEvent(t *testing.T) { + t.Parallel() + + handler := NewIncomingVTXOHandler(IncomingVTXOHandlerConfig{}) + + msg := IncomingVTXOMsg{Event: nil} + + result := handler.Receive(t.Context(), msg) + _, resultErr := result.Unpack() + require.NoError(t, resultErr) +} diff --git a/wallet/wallet.go b/wallet/wallet.go index 491b3266d..88593f918 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -785,7 +785,6 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, PkScript: vtxo.PkScript, Expiry: vtxo.Expiry, OwnerKey: vtxo.OwnerKey, - IsOwner: true, OperatorKey: vtxo.OperatorKey, }) } @@ -1361,10 +1360,17 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, )) } - if r.Amount <= 0 { + if r.Amount <= 0 || r.Amount > btcutil.MaxSatoshi { return fn.Err[WalletResp](fmt.Errorf( - "recipient %d: amount must be positive", - i, + "recipient %d: amount must be "+ + "between 1 and %d", + i, int64(btcutil.MaxSatoshi), + )) + } + + if totalRecipientAmount+r.Amount < 0 { + return fn.Err[WalletResp](fmt.Errorf( + "total recipient amount overflows", )) } @@ -1407,37 +1413,41 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, ) } - // releaseAndFail attempts a strict forfeit release and - // returns the primary error, augmented with release failure - // info if the release itself fails. - releaseAndFail := func(primary error) fn.Result[WalletResp] { + // Ensure reserved VTXOs are released if we don't reach the + // successful registration at the end. Use a background + // context so cleanup survives client disconnection. + committed := false + defer func() { + if committed { + return + } + + releaseCtx := context.WithoutCancel(ctx) releaseErr := a.releaseManagerForfeitStrict( - ctx, reservedOutpoints, + releaseCtx, reservedOutpoints, ) if releaseErr != nil { - return fn.Err[WalletResp](fmt.Errorf( - "%w; additionally, forfeit "+ - "release failed: %v", - primary, releaseErr, - )) + a.logger(releaseCtx).WarnS( + releaseCtx, + "Failed to release reserved "+ + "VTXOs", releaseErr, + ) } - - return fn.Err[WalletResp](primary) - } + }() // Compute change. change := mgrResp.TotalSelected - totalNeeded if change < 0 { // Should not happen since coin selection covers the // target, but be defensive. - return releaseAndFail(fmt.Errorf( + return fn.Err[WalletResp](fmt.Errorf( "selection shortfall: selected %d, need %d", mgrResp.TotalSelected, totalNeeded, )) } if change > 0 && change <= req.DustLimit { - return releaseAndFail(fmt.Errorf( + return fn.Err[WalletResp](fmt.Errorf( "change %d is below dust limit %d; "+ "adjust send amount", change, req.DustLimit, @@ -1446,17 +1456,7 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, // Dry-run: validate coin selection then release immediately. if req.DryRun { - releaseErr := a.releaseManagerForfeitStrict( - ctx, reservedOutpoints, - ) - if releaseErr != nil { - return fn.Err[WalletResp](fmt.Errorf( - "dry-run release failed, funds may "+ - "be temporarily unavailable: "+ - "%w", releaseErr, - )) - } - + // The deferred cleanup releases the reservation. return fn.Ok[WalletResp](&SendVTXOsResponse{ Status: "preview", SelectedCount: len(mgrResp.SelectedVTXOs), @@ -1483,7 +1483,7 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, ctx, req, change, ) if buildErr != nil { - return releaseAndFail(buildErr) + return fn.Err[WalletResp](buildErr) } // Register the intent with the round actor. @@ -1500,12 +1500,14 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, a.logger(ctx).WarnS(ctx, "Round rejected send intent", result.Err()) - return releaseAndFail(fmt.Errorf( + return fn.Err[WalletResp](fmt.Errorf( "round rejected send intent: %w", result.Err(), )) } + committed = true + a.logger(ctx).InfoS(ctx, "Directed send intent registered", slog.Int("forfeits", len(forfeits)), slog.Int("recipient_vtxos", len(req.Recipients)), @@ -1555,7 +1557,6 @@ func (a *Ark) buildSendVTXORequests(ctx context.Context, OwnerKey: keychain.KeyDescriptor{ PubKey: r.ClientKey, }, - IsOwner: false, OperatorKey: req.OperatorKey, }) } @@ -1590,7 +1591,6 @@ func (a *Ark) buildSendVTXORequests(ctx context.Context, PkScript: changeDesc.PkScript, Expiry: req.VTXOExitDelay, OwnerKey: *changeOwnerKey, - IsOwner: true, OperatorKey: req.OperatorKey, }, ) diff --git a/wallet/wallet_admission_test.go b/wallet/wallet_admission_test.go index db7e4d035..9c9cefa26 100644 --- a/wallet/wallet_admission_test.go +++ b/wallet/wallet_admission_test.go @@ -906,9 +906,9 @@ func TestSendVTXOsDryRun(t *testing.T) { require.Equal(t, 1, mgr.forfeitReleaseCalls) } -// TestSendVTXOsDryRunReleaseFails verifies that a dry-run where -// release fails returns an explicit error about lingering -// reservations. +// TestSendVTXOsDryRunReleaseFails verifies that a dry-run succeeds +// even when the deferred forfeit release fails. The release is +// best-effort — errors are logged but don't propagate. func TestSendVTXOsDryRunReleaseFails(t *testing.T) { t.Parallel() @@ -937,9 +937,12 @@ func TestSendVTXOsDryRunReleaseFails(t *testing.T) { VTXOExitDelay: 144, DryRun: true, }) - _, err := result.Unpack() - require.Error(t, err) - require.Contains(t, err.Error(), "temporarily unavailable") + resp, err := result.Unpack() + require.NoError(t, err) + + sendResp, ok := resp.(*SendVTXOsResponse) + require.True(t, ok) + require.Equal(t, "preview", sendResp.Status) } // TestSendVTXOsRoundRejectsAndReleases verifies that when the round @@ -1139,13 +1142,4 @@ func TestSendVTXOsIntentPackageContents(t *testing.T) { "(FSM derives it)", i, ) } - - // The change VTXO should have IsOwner=true with the VTXO - // owner key family. Recipient VTXOs have IsOwner=false. - require.True(t, vtxoChange.IsOwner, - "change VTXO should be owned") - for _, vtxo := range intent.VTXOs[:2] { - require.False(t, vtxo.IsOwner, - "recipient VTXO should not be owned") - } }