diff --git a/api-spec/openapi/swagger/ark/v1/admin.openapi.json b/api-spec/openapi/swagger/ark/v1/admin.openapi.json index a641d191a..d1fb29634 100644 --- a/api-spec/openapi/swagger/ark/v1/admin.openapi.json +++ b/api-spec/openapi/swagger/ark/v1/admin.openapi.json @@ -311,6 +311,56 @@ } } }, + "/v1/admin/fees/collected": { + "get": { + "tags": [ + "AdminService" + ], + "operationId": "AdminService_GetCollectedFees", + "parameters": [ + { + "name": "after", + "in": "query", + "description": "Unix timestamp (UTC, exclusive). Only include rounds starting after this time. 0 means no lower bound.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "before", + "in": "query", + "description": "Unix timestamp (UTC, exclusive). Only include rounds starting before this time. 0 means no upper bound.", + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "a successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetCollectedFeesResponse" + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Status" + } + } + } + } + } + } + }, "/v1/admin/intentFees": { "get": { "tags": [ @@ -1169,6 +1219,33 @@ } } }, + "GetCollectedFeesRequest": { + "title": "GetCollectedFeesRequest", + "type": "object", + "properties": { + "after": { + "type": "integer", + "description": "Unix timestamp (UTC, exclusive). Only include rounds starting after this time. 0 means no lower bound.", + "format": "int64" + }, + "before": { + "type": "integer", + "description": "Unix timestamp (UTC, exclusive). Only include rounds starting before this time. 0 means no upper bound.", + "format": "int64" + } + } + }, + "GetCollectedFeesResponse": { + "title": "GetCollectedFeesResponse", + "type": "object", + "properties": { + "collectedFees": { + "type": "integer", + "description": "Total collected fees in satoshis.", + "format": "uint64" + } + } + }, "GetConvictionsByRoundRequest": { "title": "GetConvictionsByRoundRequest", "type": "object", diff --git a/api-spec/protobuf/ark/v1/admin.proto b/api-spec/protobuf/ark/v1/admin.proto index ba0bbf4fc..9f1b1f73f 100644 --- a/api-spec/protobuf/ark/v1/admin.proto +++ b/api-spec/protobuf/ark/v1/admin.proto @@ -132,6 +132,11 @@ service AdminService { get: "/v1/admin/liquidity/recoverable" }; } + rpc GetCollectedFees(GetCollectedFeesRequest) returns (GetCollectedFeesResponse) { + option (meshapi.gateway.http) = { + get: "/v1/admin/fees/collected" + }; + } rpc Sweep(SweepRequest) returns (SweepResponse) { option (meshapi.gateway.http) = { post: "/v1/admin/sweep" @@ -364,6 +369,14 @@ message GetRecoverableLiquidityResponse { uint64 amount = 1; } +message GetCollectedFeesRequest { + int64 after = 1; // Unix timestamp (UTC, exclusive). Only include rounds starting after this time. 0 means no lower bound. + int64 before = 2; // Unix timestamp (UTC, exclusive). Only include rounds starting before this time. 0 means no upper bound. +} +message GetCollectedFeesResponse { + uint64 collected_fees = 1; // Total collected fees in satoshis. +} + message SweepRequest { bool connectors = 1; repeated string commitment_txids = 2; diff --git a/api-spec/protobuf/gen/ark/v1/admin.pb.go b/api-spec/protobuf/gen/ark/v1/admin.pb.go index bab2263b8..f3f3f7a1d 100644 --- a/api-spec/protobuf/gen/ark/v1/admin.pb.go +++ b/api-spec/protobuf/gen/ark/v1/admin.pb.go @@ -2658,6 +2658,102 @@ func (x *GetRecoverableLiquidityResponse) GetAmount() uint64 { return 0 } +type GetCollectedFeesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + After int64 `protobuf:"varint,1,opt,name=after,proto3" json:"after,omitempty"` // Unix timestamp (UTC, exclusive). Only include rounds starting after this time. 0 means no lower bound. + Before int64 `protobuf:"varint,2,opt,name=before,proto3" json:"before,omitempty"` // Unix timestamp (UTC, exclusive). Only include rounds starting before this time. 0 means no upper bound. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCollectedFeesRequest) Reset() { + *x = GetCollectedFeesRequest{} + mi := &file_ark_v1_admin_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCollectedFeesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCollectedFeesRequest) ProtoMessage() {} + +func (x *GetCollectedFeesRequest) ProtoReflect() protoreflect.Message { + mi := &file_ark_v1_admin_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCollectedFeesRequest.ProtoReflect.Descriptor instead. +func (*GetCollectedFeesRequest) Descriptor() ([]byte, []int) { + return file_ark_v1_admin_proto_rawDescGZIP(), []int{50} +} + +func (x *GetCollectedFeesRequest) GetAfter() int64 { + if x != nil { + return x.After + } + return 0 +} + +func (x *GetCollectedFeesRequest) GetBefore() int64 { + if x != nil { + return x.Before + } + return 0 +} + +type GetCollectedFeesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + CollectedFees uint64 `protobuf:"varint,1,opt,name=collected_fees,json=collectedFees,proto3" json:"collected_fees,omitempty"` // Total collected fees in satoshis. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCollectedFeesResponse) Reset() { + *x = GetCollectedFeesResponse{} + mi := &file_ark_v1_admin_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCollectedFeesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCollectedFeesResponse) ProtoMessage() {} + +func (x *GetCollectedFeesResponse) ProtoReflect() protoreflect.Message { + mi := &file_ark_v1_admin_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCollectedFeesResponse.ProtoReflect.Descriptor instead. +func (*GetCollectedFeesResponse) Descriptor() ([]byte, []int) { + return file_ark_v1_admin_proto_rawDescGZIP(), []int{51} +} + +func (x *GetCollectedFeesResponse) GetCollectedFees() uint64 { + if x != nil { + return x.CollectedFees + } + return 0 +} + type SweepRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Connectors bool `protobuf:"varint,1,opt,name=connectors,proto3" json:"connectors,omitempty"` @@ -2668,7 +2764,7 @@ type SweepRequest struct { func (x *SweepRequest) Reset() { *x = SweepRequest{} - mi := &file_ark_v1_admin_proto_msgTypes[50] + mi := &file_ark_v1_admin_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2680,7 +2776,7 @@ func (x *SweepRequest) String() string { func (*SweepRequest) ProtoMessage() {} func (x *SweepRequest) ProtoReflect() protoreflect.Message { - mi := &file_ark_v1_admin_proto_msgTypes[50] + mi := &file_ark_v1_admin_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2693,7 +2789,7 @@ func (x *SweepRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SweepRequest.ProtoReflect.Descriptor instead. func (*SweepRequest) Descriptor() ([]byte, []int) { - return file_ark_v1_admin_proto_rawDescGZIP(), []int{50} + return file_ark_v1_admin_proto_rawDescGZIP(), []int{52} } func (x *SweepRequest) GetConnectors() bool { @@ -2720,7 +2816,7 @@ type SweepResponse struct { func (x *SweepResponse) Reset() { *x = SweepResponse{} - mi := &file_ark_v1_admin_proto_msgTypes[51] + mi := &file_ark_v1_admin_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2732,7 +2828,7 @@ func (x *SweepResponse) String() string { func (*SweepResponse) ProtoMessage() {} func (x *SweepResponse) ProtoReflect() protoreflect.Message { - mi := &file_ark_v1_admin_proto_msgTypes[51] + mi := &file_ark_v1_admin_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2745,7 +2841,7 @@ func (x *SweepResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SweepResponse.ProtoReflect.Descriptor instead. func (*SweepResponse) Descriptor() ([]byte, []int) { - return file_ark_v1_admin_proto_rawDescGZIP(), []int{51} + return file_ark_v1_admin_proto_rawDescGZIP(), []int{53} } func (x *SweepResponse) GetTxid() string { @@ -2774,7 +2870,7 @@ type ListTokensRequest struct { func (x *ListTokensRequest) Reset() { *x = ListTokensRequest{} - mi := &file_ark_v1_admin_proto_msgTypes[52] + mi := &file_ark_v1_admin_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2786,7 +2882,7 @@ func (x *ListTokensRequest) String() string { func (*ListTokensRequest) ProtoMessage() {} func (x *ListTokensRequest) ProtoReflect() protoreflect.Message { - mi := &file_ark_v1_admin_proto_msgTypes[52] + mi := &file_ark_v1_admin_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2799,7 +2895,7 @@ func (x *ListTokensRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTokensRequest.ProtoReflect.Descriptor instead. func (*ListTokensRequest) Descriptor() ([]byte, []int) { - return file_ark_v1_admin_proto_rawDescGZIP(), []int{52} + return file_ark_v1_admin_proto_rawDescGZIP(), []int{54} } func (x *ListTokensRequest) GetToken() string { @@ -2839,7 +2935,7 @@ type ListTokensResponse struct { func (x *ListTokensResponse) Reset() { *x = ListTokensResponse{} - mi := &file_ark_v1_admin_proto_msgTypes[53] + mi := &file_ark_v1_admin_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2851,7 +2947,7 @@ func (x *ListTokensResponse) String() string { func (*ListTokensResponse) ProtoMessage() {} func (x *ListTokensResponse) ProtoReflect() protoreflect.Message { - mi := &file_ark_v1_admin_proto_msgTypes[53] + mi := &file_ark_v1_admin_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2864,7 +2960,7 @@ func (x *ListTokensResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTokensResponse.ProtoReflect.Descriptor instead. func (*ListTokensResponse) Descriptor() ([]byte, []int) { - return file_ark_v1_admin_proto_rawDescGZIP(), []int{53} + return file_ark_v1_admin_proto_rawDescGZIP(), []int{55} } func (x *ListTokensResponse) GetTokens() []*TokenInfo { @@ -2885,7 +2981,7 @@ type TokenInfo struct { func (x *TokenInfo) Reset() { *x = TokenInfo{} - mi := &file_ark_v1_admin_proto_msgTypes[54] + mi := &file_ark_v1_admin_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2897,7 +2993,7 @@ func (x *TokenInfo) String() string { func (*TokenInfo) ProtoMessage() {} func (x *TokenInfo) ProtoReflect() protoreflect.Message { - mi := &file_ark_v1_admin_proto_msgTypes[54] + mi := &file_ark_v1_admin_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2910,7 +3006,7 @@ func (x *TokenInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenInfo.ProtoReflect.Descriptor instead. func (*TokenInfo) Descriptor() ([]byte, []int) { - return file_ark_v1_admin_proto_rawDescGZIP(), []int{54} + return file_ark_v1_admin_proto_rawDescGZIP(), []int{56} } func (x *TokenInfo) GetHash() string { @@ -2946,7 +3042,7 @@ type RevokeTokensRequest struct { func (x *RevokeTokensRequest) Reset() { *x = RevokeTokensRequest{} - mi := &file_ark_v1_admin_proto_msgTypes[55] + mi := &file_ark_v1_admin_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2958,7 +3054,7 @@ func (x *RevokeTokensRequest) String() string { func (*RevokeTokensRequest) ProtoMessage() {} func (x *RevokeTokensRequest) ProtoReflect() protoreflect.Message { - mi := &file_ark_v1_admin_proto_msgTypes[55] + mi := &file_ark_v1_admin_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2971,7 +3067,7 @@ func (x *RevokeTokensRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeTokensRequest.ProtoReflect.Descriptor instead. func (*RevokeTokensRequest) Descriptor() ([]byte, []int) { - return file_ark_v1_admin_proto_rawDescGZIP(), []int{55} + return file_ark_v1_admin_proto_rawDescGZIP(), []int{57} } func (x *RevokeTokensRequest) GetToken() string { @@ -3011,7 +3107,7 @@ type RevokeTokensResponse struct { func (x *RevokeTokensResponse) Reset() { *x = RevokeTokensResponse{} - mi := &file_ark_v1_admin_proto_msgTypes[56] + mi := &file_ark_v1_admin_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3023,7 +3119,7 @@ func (x *RevokeTokensResponse) String() string { func (*RevokeTokensResponse) ProtoMessage() {} func (x *RevokeTokensResponse) ProtoReflect() protoreflect.Message { - mi := &file_ark_v1_admin_proto_msgTypes[56] + mi := &file_ark_v1_admin_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3036,7 +3132,7 @@ func (x *RevokeTokensResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeTokensResponse.ProtoReflect.Descriptor instead. func (*RevokeTokensResponse) Descriptor() ([]byte, []int) { - return file_ark_v1_admin_proto_rawDescGZIP(), []int{56} + return file_ark_v1_admin_proto_rawDescGZIP(), []int{58} } func (x *RevokeTokensResponse) GetRevokedCount() int32 { @@ -3202,7 +3298,12 @@ const file_ark_v1_admin_proto_rawDesc = "" + "\x06amount\x18\x01 \x01(\x04R\x06amount\" \n" + "\x1eGetRecoverableLiquidityRequest\"9\n" + "\x1fGetRecoverableLiquidityResponse\x12\x16\n" + - "\x06amount\x18\x01 \x01(\x04R\x06amount\"Y\n" + + "\x06amount\x18\x01 \x01(\x04R\x06amount\"G\n" + + "\x17GetCollectedFeesRequest\x12\x14\n" + + "\x05after\x18\x01 \x01(\x03R\x05after\x12\x16\n" + + "\x06before\x18\x02 \x01(\x03R\x06before\"A\n" + + "\x18GetCollectedFeesResponse\x12%\n" + + "\x0ecollected_fees\x18\x01 \x01(\x04R\rcollectedFees\"Y\n" + "\fSweepRequest\x12\x1e\n" + "\n" + "connectors\x18\x01 \x01(\bR\n" + @@ -3241,7 +3342,7 @@ const file_ark_v1_admin_proto_rawDesc = "" + "\x15CRIME_TYPE_MANUAL_BAN\x10\a*M\n" + "\x0eConvictionType\x12\x1f\n" + "\x1bCONVICTION_TYPE_UNSPECIFIED\x10\x00\x12\x1a\n" + - "\x16CONVICTION_TYPE_SCRIPT\x10\x012\xdb\x16\n" + + "\x16CONVICTION_TYPE_SCRIPT\x10\x012\xd1\x17\n" + "\fAdminService\x12o\n" + "\x11GetScheduledSweep\x12 .ark.v1.GetScheduledSweepRequest\x1a!.ark.v1.GetScheduledSweepResponse\"\x15\xb2J\x12\x12\x10/v1/admin/sweeps\x12s\n" + "\x0fGetRoundDetails\x12\x1e.ark.v1.GetRoundDetailsRequest\x1a\x1f.ark.v1.GetRoundDetailsResponse\"\x1f\xb2J\x1c\x12\x1a/v1/admin/round/{round_id}\x12W\n" + @@ -3268,7 +3369,8 @@ const file_ark_v1_admin_proto_rawDesc = "" + "ListTokens\x12\x19.ark.v1.ListTokensRequest\x1a\x1a.ark.v1.ListTokensResponse\"\x18\xb2J\x15B\x01*\"\x10/v1/admin/tokens\x12j\n" + "\fRevokeTokens\x12\x1b.ark.v1.RevokeTokensRequest\x1a\x1c.ark.v1.RevokeTokensResponse\"\x1f\xb2J\x1cB\x01*\"\x17/v1/admin/tokens/revoke\x12\x84\x01\n" + "\x14GetExpiringLiquidity\x12#.ark.v1.GetExpiringLiquidityRequest\x1a$.ark.v1.GetExpiringLiquidityResponse\"!\xb2J\x1e\x12\x1c/v1/admin/liquidity/expiring\x12\x90\x01\n" + - "\x17GetRecoverableLiquidity\x12&.ark.v1.GetRecoverableLiquidityRequest\x1a'.ark.v1.GetRecoverableLiquidityResponse\"$\xb2J!\x12\x1f/v1/admin/liquidity/recoverable\x12M\n" + + "\x17GetRecoverableLiquidity\x12&.ark.v1.GetRecoverableLiquidityRequest\x1a'.ark.v1.GetRecoverableLiquidityResponse\"$\xb2J!\x12\x1f/v1/admin/liquidity/recoverable\x12t\n" + + "\x10GetCollectedFees\x12\x1f.ark.v1.GetCollectedFeesRequest\x1a .ark.v1.GetCollectedFeesResponse\"\x1d\xb2J\x1a\x12\x18/v1/admin/fees/collected\x12M\n" + "\x05Sweep\x12\x14.ark.v1.SweepRequest\x1a\x15.ark.v1.SweepResponse\"\x17\xb2J\x14B\x01*\"\x0f/v1/admin/sweepBy\n" + "\n" + "com.ark.v1B\n" + @@ -3287,7 +3389,7 @@ func file_ark_v1_admin_proto_rawDescGZIP() []byte { } var file_ark_v1_admin_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_ark_v1_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 57) +var file_ark_v1_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 59) var file_ark_v1_admin_proto_goTypes = []any{ (CrimeType)(0), // 0: ark.v1.CrimeType (ConvictionType)(0), // 1: ark.v1.ConvictionType @@ -3341,15 +3443,17 @@ var file_ark_v1_admin_proto_goTypes = []any{ (*GetExpiringLiquidityResponse)(nil), // 49: ark.v1.GetExpiringLiquidityResponse (*GetRecoverableLiquidityRequest)(nil), // 50: ark.v1.GetRecoverableLiquidityRequest (*GetRecoverableLiquidityResponse)(nil), // 51: ark.v1.GetRecoverableLiquidityResponse - (*SweepRequest)(nil), // 52: ark.v1.SweepRequest - (*SweepResponse)(nil), // 53: ark.v1.SweepResponse - (*ListTokensRequest)(nil), // 54: ark.v1.ListTokensRequest - (*ListTokensResponse)(nil), // 55: ark.v1.ListTokensResponse - (*TokenInfo)(nil), // 56: ark.v1.TokenInfo - (*RevokeTokensRequest)(nil), // 57: ark.v1.RevokeTokensRequest - (*RevokeTokensResponse)(nil), // 58: ark.v1.RevokeTokensResponse - (*FeeInfo)(nil), // 59: ark.v1.FeeInfo - (*Intent)(nil), // 60: ark.v1.Intent + (*GetCollectedFeesRequest)(nil), // 52: ark.v1.GetCollectedFeesRequest + (*GetCollectedFeesResponse)(nil), // 53: ark.v1.GetCollectedFeesResponse + (*SweepRequest)(nil), // 54: ark.v1.SweepRequest + (*SweepResponse)(nil), // 55: ark.v1.SweepResponse + (*ListTokensRequest)(nil), // 56: ark.v1.ListTokensRequest + (*ListTokensResponse)(nil), // 57: ark.v1.ListTokensResponse + (*TokenInfo)(nil), // 58: ark.v1.TokenInfo + (*RevokeTokensRequest)(nil), // 59: ark.v1.RevokeTokensRequest + (*RevokeTokensResponse)(nil), // 60: ark.v1.RevokeTokensResponse + (*FeeInfo)(nil), // 61: ark.v1.FeeInfo + (*Intent)(nil), // 62: ark.v1.Intent } var file_ark_v1_admin_proto_depIdxs = []int32{ 41, // 0: ark.v1.GetScheduledSweepResponse.sweeps:type_name -> ark.v1.ScheduledSweep @@ -3363,14 +3467,14 @@ var file_ark_v1_admin_proto_depIdxs = []int32{ 47, // 8: ark.v1.GetConvictionsByRoundResponse.convictions:type_name -> ark.v1.Conviction 47, // 9: ark.v1.GetActiveScriptConvictionsResponse.convictions:type_name -> ark.v1.Conviction 40, // 10: ark.v1.ScheduledSweep.outputs:type_name -> ark.v1.SweepableOutput - 59, // 11: ark.v1.ScheduledSessionConfig.fees:type_name -> ark.v1.FeeInfo + 61, // 11: ark.v1.ScheduledSessionConfig.fees:type_name -> ark.v1.FeeInfo 46, // 12: ark.v1.IntentInfo.receivers:type_name -> ark.v1.Output 43, // 13: ark.v1.IntentInfo.inputs:type_name -> ark.v1.IntentInput 43, // 14: ark.v1.IntentInfo.boarding_inputs:type_name -> ark.v1.IntentInput - 60, // 15: ark.v1.IntentInfo.intent:type_name -> ark.v1.Intent + 62, // 15: ark.v1.IntentInfo.intent:type_name -> ark.v1.Intent 1, // 16: ark.v1.Conviction.type:type_name -> ark.v1.ConvictionType 0, // 17: ark.v1.Conviction.crime_type:type_name -> ark.v1.CrimeType - 56, // 18: ark.v1.ListTokensResponse.tokens:type_name -> ark.v1.TokenInfo + 58, // 18: ark.v1.ListTokensResponse.tokens:type_name -> ark.v1.TokenInfo 2, // 19: ark.v1.AdminService.GetScheduledSweep:input_type -> ark.v1.GetScheduledSweepRequest 4, // 20: ark.v1.AdminService.GetRoundDetails:input_type -> ark.v1.GetRoundDetailsRequest 6, // 21: ark.v1.AdminService.GetRounds:input_type -> ark.v1.GetRoundsRequest @@ -3390,37 +3494,39 @@ var file_ark_v1_admin_proto_depIdxs = []int32{ 34, // 35: ark.v1.AdminService.PardonConviction:input_type -> ark.v1.PardonConvictionRequest 36, // 36: ark.v1.AdminService.BanScript:input_type -> ark.v1.BanScriptRequest 38, // 37: ark.v1.AdminService.RevokeAuth:input_type -> ark.v1.RevokeAuthRequest - 54, // 38: ark.v1.AdminService.ListTokens:input_type -> ark.v1.ListTokensRequest - 57, // 39: ark.v1.AdminService.RevokeTokens:input_type -> ark.v1.RevokeTokensRequest + 56, // 38: ark.v1.AdminService.ListTokens:input_type -> ark.v1.ListTokensRequest + 59, // 39: ark.v1.AdminService.RevokeTokens:input_type -> ark.v1.RevokeTokensRequest 48, // 40: ark.v1.AdminService.GetExpiringLiquidity:input_type -> ark.v1.GetExpiringLiquidityRequest 50, // 41: ark.v1.AdminService.GetRecoverableLiquidity:input_type -> ark.v1.GetRecoverableLiquidityRequest - 52, // 42: ark.v1.AdminService.Sweep:input_type -> ark.v1.SweepRequest - 3, // 43: ark.v1.AdminService.GetScheduledSweep:output_type -> ark.v1.GetScheduledSweepResponse - 5, // 44: ark.v1.AdminService.GetRoundDetails:output_type -> ark.v1.GetRoundDetailsResponse - 7, // 45: ark.v1.AdminService.GetRounds:output_type -> ark.v1.GetRoundsResponse - 9, // 46: ark.v1.AdminService.CreateNote:output_type -> ark.v1.CreateNoteResponse - 11, // 47: ark.v1.AdminService.GetScheduledSessionConfig:output_type -> ark.v1.GetScheduledSessionConfigResponse - 13, // 48: ark.v1.AdminService.UpdateScheduledSessionConfig:output_type -> ark.v1.UpdateScheduledSessionConfigResponse - 15, // 49: ark.v1.AdminService.ClearScheduledSessionConfig:output_type -> ark.v1.ClearScheduledSessionConfigResponse - 17, // 50: ark.v1.AdminService.ListIntents:output_type -> ark.v1.ListIntentsResponse - 19, // 51: ark.v1.AdminService.DeleteIntents:output_type -> ark.v1.DeleteIntentsResponse - 21, // 52: ark.v1.AdminService.GetIntentFees:output_type -> ark.v1.GetIntentFeesResponse - 23, // 53: ark.v1.AdminService.UpdateIntentFees:output_type -> ark.v1.UpdateIntentFeesResponse - 25, // 54: ark.v1.AdminService.ClearIntentFees:output_type -> ark.v1.ClearIntentFeesResponse - 27, // 55: ark.v1.AdminService.GetConvictions:output_type -> ark.v1.GetConvictionsResponse - 29, // 56: ark.v1.AdminService.GetConvictionsInRange:output_type -> ark.v1.GetConvictionsInRangeResponse - 31, // 57: ark.v1.AdminService.GetConvictionsByRound:output_type -> ark.v1.GetConvictionsByRoundResponse - 33, // 58: ark.v1.AdminService.GetActiveScriptConvictions:output_type -> ark.v1.GetActiveScriptConvictionsResponse - 35, // 59: ark.v1.AdminService.PardonConviction:output_type -> ark.v1.PardonConvictionResponse - 37, // 60: ark.v1.AdminService.BanScript:output_type -> ark.v1.BanScriptResponse - 39, // 61: ark.v1.AdminService.RevokeAuth:output_type -> ark.v1.RevokeAuthResponse - 55, // 62: ark.v1.AdminService.ListTokens:output_type -> ark.v1.ListTokensResponse - 58, // 63: ark.v1.AdminService.RevokeTokens:output_type -> ark.v1.RevokeTokensResponse - 49, // 64: ark.v1.AdminService.GetExpiringLiquidity:output_type -> ark.v1.GetExpiringLiquidityResponse - 51, // 65: ark.v1.AdminService.GetRecoverableLiquidity:output_type -> ark.v1.GetRecoverableLiquidityResponse - 53, // 66: ark.v1.AdminService.Sweep:output_type -> ark.v1.SweepResponse - 43, // [43:67] is the sub-list for method output_type - 19, // [19:43] is the sub-list for method input_type + 52, // 42: ark.v1.AdminService.GetCollectedFees:input_type -> ark.v1.GetCollectedFeesRequest + 54, // 43: ark.v1.AdminService.Sweep:input_type -> ark.v1.SweepRequest + 3, // 44: ark.v1.AdminService.GetScheduledSweep:output_type -> ark.v1.GetScheduledSweepResponse + 5, // 45: ark.v1.AdminService.GetRoundDetails:output_type -> ark.v1.GetRoundDetailsResponse + 7, // 46: ark.v1.AdminService.GetRounds:output_type -> ark.v1.GetRoundsResponse + 9, // 47: ark.v1.AdminService.CreateNote:output_type -> ark.v1.CreateNoteResponse + 11, // 48: ark.v1.AdminService.GetScheduledSessionConfig:output_type -> ark.v1.GetScheduledSessionConfigResponse + 13, // 49: ark.v1.AdminService.UpdateScheduledSessionConfig:output_type -> ark.v1.UpdateScheduledSessionConfigResponse + 15, // 50: ark.v1.AdminService.ClearScheduledSessionConfig:output_type -> ark.v1.ClearScheduledSessionConfigResponse + 17, // 51: ark.v1.AdminService.ListIntents:output_type -> ark.v1.ListIntentsResponse + 19, // 52: ark.v1.AdminService.DeleteIntents:output_type -> ark.v1.DeleteIntentsResponse + 21, // 53: ark.v1.AdminService.GetIntentFees:output_type -> ark.v1.GetIntentFeesResponse + 23, // 54: ark.v1.AdminService.UpdateIntentFees:output_type -> ark.v1.UpdateIntentFeesResponse + 25, // 55: ark.v1.AdminService.ClearIntentFees:output_type -> ark.v1.ClearIntentFeesResponse + 27, // 56: ark.v1.AdminService.GetConvictions:output_type -> ark.v1.GetConvictionsResponse + 29, // 57: ark.v1.AdminService.GetConvictionsInRange:output_type -> ark.v1.GetConvictionsInRangeResponse + 31, // 58: ark.v1.AdminService.GetConvictionsByRound:output_type -> ark.v1.GetConvictionsByRoundResponse + 33, // 59: ark.v1.AdminService.GetActiveScriptConvictions:output_type -> ark.v1.GetActiveScriptConvictionsResponse + 35, // 60: ark.v1.AdminService.PardonConviction:output_type -> ark.v1.PardonConvictionResponse + 37, // 61: ark.v1.AdminService.BanScript:output_type -> ark.v1.BanScriptResponse + 39, // 62: ark.v1.AdminService.RevokeAuth:output_type -> ark.v1.RevokeAuthResponse + 57, // 63: ark.v1.AdminService.ListTokens:output_type -> ark.v1.ListTokensResponse + 60, // 64: ark.v1.AdminService.RevokeTokens:output_type -> ark.v1.RevokeTokensResponse + 49, // 65: ark.v1.AdminService.GetExpiringLiquidity:output_type -> ark.v1.GetExpiringLiquidityResponse + 51, // 66: ark.v1.AdminService.GetRecoverableLiquidity:output_type -> ark.v1.GetRecoverableLiquidityResponse + 53, // 67: ark.v1.AdminService.GetCollectedFees:output_type -> ark.v1.GetCollectedFeesResponse + 55, // 68: ark.v1.AdminService.Sweep:output_type -> ark.v1.SweepResponse + 44, // [44:69] is the sub-list for method output_type + 19, // [19:44] is the sub-list for method input_type 19, // [19:19] is the sub-list for extension type_name 19, // [19:19] is the sub-list for extension extendee 0, // [0:19] is the sub-list for field type_name @@ -3442,7 +3548,7 @@ func file_ark_v1_admin_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ark_v1_admin_proto_rawDesc), len(file_ark_v1_admin_proto_rawDesc)), NumEnums: 2, - NumMessages: 57, + NumMessages: 59, NumExtensions: 0, NumServices: 1, }, diff --git a/api-spec/protobuf/gen/ark/v1/admin.pb.rgw.go b/api-spec/protobuf/gen/ark/v1/admin.pb.rgw.go index 4fe729b14..7c3d364d1 100644 --- a/api-spec/protobuf/gen/ark/v1/admin.pb.rgw.go +++ b/api-spec/protobuf/gen/ark/v1/admin.pb.rgw.go @@ -404,6 +404,28 @@ func request_AdminService_GetRecoverableLiquidity_0(ctx context.Context, marshal } +var ( + query_params_AdminService_GetCollectedFees_0 = gateway.QueryParameterParseOptions{ + Filter: trie.New(), + } +) + +func request_AdminService_GetCollectedFees_0(ctx context.Context, marshaler gateway.Marshaler, mux *gateway.ServeMux, client AdminServiceClient, req *http.Request, pathParams gateway.Params) (proto.Message, gateway.ServerMetadata, error) { + var protoReq GetCollectedFeesRequest + var metadata gateway.ServerMetadata + + if err := req.ParseForm(); err != nil { + return nil, metadata, gateway.ErrInvalidQueryParameters{Err: err} + } + if err := mux.PopulateQueryParameters(&protoReq, req.Form, query_params_AdminService_GetCollectedFees_0); err != nil { + return nil, metadata, gateway.ErrInvalidQueryParameters{Err: err} + } + + msg, err := client.GetCollectedFees(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + func request_AdminService_Sweep_0(ctx context.Context, marshaler gateway.Marshaler, mux *gateway.ServeMux, client AdminServiceClient, req *http.Request, pathParams gateway.Params) (proto.Message, gateway.ServerMetadata, error) { var protoReq SweepRequest var metadata gateway.ServerMetadata @@ -962,6 +984,28 @@ func RegisterAdminServiceHandlerClient(ctx context.Context, mux *gateway.ServeMu mux.ForwardResponseMessage(annotatedContext, outboundMarshaler, w, req, resp) }) + mux.HandleWithParams("GET", "/v1/admin/fees/collected", func(w http.ResponseWriter, req *http.Request, pathParams gateway.Params) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := mux.MarshalerForRequest(req) + var err error + var annotatedContext context.Context + annotatedContext, err = gateway.AnnotateContext(ctx, mux, req, "/ark.v1.AdminService/GetCollectedFees", gateway.WithHTTPPathPattern("/v1/admin/fees/collected")) + if err != nil { + mux.HTTPError(ctx, outboundMarshaler, w, req, err) + return + } + + resp, md, err := request_AdminService_GetCollectedFees_0(annotatedContext, inboundMarshaler, mux, client, req, pathParams) + annotatedContext = gateway.NewServerMetadataContext(annotatedContext, md) + if err != nil { + mux.HTTPError(annotatedContext, outboundMarshaler, w, req, err) + return + } + + mux.ForwardResponseMessage(annotatedContext, outboundMarshaler, w, req, resp) + }) + mux.HandleWithParams("POST", "/v1/admin/sweep", func(w http.ResponseWriter, req *http.Request, pathParams gateway.Params) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() diff --git a/api-spec/protobuf/gen/ark/v1/admin_grpc.pb.go b/api-spec/protobuf/gen/ark/v1/admin_grpc.pb.go index b2c1001f9..625d28d2a 100644 --- a/api-spec/protobuf/gen/ark/v1/admin_grpc.pb.go +++ b/api-spec/protobuf/gen/ark/v1/admin_grpc.pb.go @@ -42,6 +42,7 @@ const ( AdminService_RevokeTokens_FullMethodName = "/ark.v1.AdminService/RevokeTokens" AdminService_GetExpiringLiquidity_FullMethodName = "/ark.v1.AdminService/GetExpiringLiquidity" AdminService_GetRecoverableLiquidity_FullMethodName = "/ark.v1.AdminService/GetRecoverableLiquidity" + AdminService_GetCollectedFees_FullMethodName = "/ark.v1.AdminService/GetCollectedFees" AdminService_Sweep_FullMethodName = "/ark.v1.AdminService/Sweep" ) @@ -72,6 +73,7 @@ type AdminServiceClient interface { RevokeTokens(ctx context.Context, in *RevokeTokensRequest, opts ...grpc.CallOption) (*RevokeTokensResponse, error) GetExpiringLiquidity(ctx context.Context, in *GetExpiringLiquidityRequest, opts ...grpc.CallOption) (*GetExpiringLiquidityResponse, error) GetRecoverableLiquidity(ctx context.Context, in *GetRecoverableLiquidityRequest, opts ...grpc.CallOption) (*GetRecoverableLiquidityResponse, error) + GetCollectedFees(ctx context.Context, in *GetCollectedFeesRequest, opts ...grpc.CallOption) (*GetCollectedFeesResponse, error) Sweep(ctx context.Context, in *SweepRequest, opts ...grpc.CallOption) (*SweepResponse, error) } @@ -313,6 +315,16 @@ func (c *adminServiceClient) GetRecoverableLiquidity(ctx context.Context, in *Ge return out, nil } +func (c *adminServiceClient) GetCollectedFees(ctx context.Context, in *GetCollectedFeesRequest, opts ...grpc.CallOption) (*GetCollectedFeesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCollectedFeesResponse) + err := c.cc.Invoke(ctx, AdminService_GetCollectedFees_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *adminServiceClient) Sweep(ctx context.Context, in *SweepRequest, opts ...grpc.CallOption) (*SweepResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SweepResponse) @@ -350,6 +362,7 @@ type AdminServiceServer interface { RevokeTokens(context.Context, *RevokeTokensRequest) (*RevokeTokensResponse, error) GetExpiringLiquidity(context.Context, *GetExpiringLiquidityRequest) (*GetExpiringLiquidityResponse, error) GetRecoverableLiquidity(context.Context, *GetRecoverableLiquidityRequest) (*GetRecoverableLiquidityResponse, error) + GetCollectedFees(context.Context, *GetCollectedFeesRequest) (*GetCollectedFeesResponse, error) Sweep(context.Context, *SweepRequest) (*SweepResponse, error) } @@ -429,6 +442,9 @@ func (UnimplementedAdminServiceServer) GetExpiringLiquidity(context.Context, *Ge func (UnimplementedAdminServiceServer) GetRecoverableLiquidity(context.Context, *GetRecoverableLiquidityRequest) (*GetRecoverableLiquidityResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetRecoverableLiquidity not implemented") } +func (UnimplementedAdminServiceServer) GetCollectedFees(context.Context, *GetCollectedFeesRequest) (*GetCollectedFeesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCollectedFees not implemented") +} func (UnimplementedAdminServiceServer) Sweep(context.Context, *SweepRequest) (*SweepResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Sweep not implemented") } @@ -866,6 +882,24 @@ func _AdminService_GetRecoverableLiquidity_Handler(srv interface{}, ctx context. return interceptor(ctx, in, info, handler) } +func _AdminService_GetCollectedFees_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCollectedFeesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminServiceServer).GetCollectedFees(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminService_GetCollectedFees_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminServiceServer).GetCollectedFees(ctx, req.(*GetCollectedFeesRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AdminService_Sweep_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(SweepRequest) if err := dec(in); err != nil { @@ -983,6 +1017,10 @@ var AdminService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetRecoverableLiquidity", Handler: _AdminService_GetRecoverableLiquidity_Handler, }, + { + MethodName: "GetCollectedFees", + Handler: _AdminService_GetCollectedFees_Handler, + }, { MethodName: "Sweep", Handler: _AdminService_Sweep_Handler, diff --git a/internal/core/application/admin.go b/internal/core/application/admin.go index 604b12516..414e33077 100644 --- a/internal/core/application/admin.go +++ b/internal/core/application/admin.go @@ -49,6 +49,7 @@ type AdminService interface { ) (string, string, error) GetExpiringLiquidity(ctx context.Context, after, before int64) (uint64, error) GetRecoverableLiquidity(ctx context.Context) (uint64, error) + GetCollectedFees(ctx context.Context, after, before int64) (int64, error) } type adminService struct { @@ -101,7 +102,6 @@ func (a *adminService) GetRoundDetails( inputVtxos := make([]string, 0) outputVtxos := make([]string, 0) for _, intent := range round.Intents { - // TODO: Add fees amount totalForfeitAmount += intent.TotalInputAmount() for _, receiver := range intent.Receivers { @@ -135,7 +135,7 @@ func (a *adminService) GetRoundDetails( TotalVtxosAmount: totalVtxosAmount, TotalExitAmount: totalExitAmount, ExitAddresses: exitAddresses, - FeesAmount: 0, + FeesAmount: round.CollectedFees, InputVtxos: inputVtxos, OutputVtxos: outputVtxos, StartedAt: round.StartingTimestamp, @@ -607,6 +607,12 @@ func (a *adminService) GetRecoverableLiquidity(ctx context.Context) (uint64, err return a.repoManager.Vtxos().GetRecoverableLiquidity(ctx) } +func (a *adminService) GetCollectedFees( + ctx context.Context, after, before int64, +) (int64, error) { + return a.repoManager.Rounds().GetCollectedFees(ctx, after, before) +} + func (a *adminService) getScheduledSweep( ctx context.Context, commitmentTxid string, ) (*ScheduledSweep, error) { @@ -778,7 +784,7 @@ type RoundDetails struct { ForfeitedAmount uint64 TotalVtxosAmount uint64 TotalExitAmount uint64 - FeesAmount uint64 + FeesAmount int64 InputVtxos []string OutputVtxos []string ExitAddresses []string diff --git a/internal/core/application/alert.go b/internal/core/application/alert.go index 6f765abd5..94e0a760c 100644 --- a/internal/core/application/alert.go +++ b/internal/core/application/alert.go @@ -74,8 +74,8 @@ func (s *service) getBatchStats( a.OnchainFees = totalIn - totalOut } + a.CollectedFees = calculateCollectedFees(round, a.BoardingInputAmount) for _, intent := range round.Intents { - a.CollectedFees += intent.TotalInputAmount() + a.BoardingInputAmount - intent.TotalOutputAmount() a.ForfeitCount += len(intent.Inputs) a.ForfeitAmount += intent.TotalInputAmount() for _, receiver := range intent.Receivers { diff --git a/internal/core/application/alert_test.go b/internal/core/application/alert_test.go new file mode 100644 index 000000000..1ffdcc4bb --- /dev/null +++ b/internal/core/application/alert_test.go @@ -0,0 +1,85 @@ +package application + +import ( + "testing" + + "github.com/arkade-os/arkd/internal/core/domain" + "github.com/stretchr/testify/require" +) + +func TestCalculateCollectedFees(t *testing.T) { + makeRound := func(intents map[string]domain.Intent) *domain.Round { + return &domain.Round{Intents: intents} + } + + makeIntent := func(inputs []uint64, outputs []uint64) domain.Intent { + vtxos := make([]domain.Vtxo, len(inputs)) + for i, a := range inputs { + vtxos[i] = domain.Vtxo{Amount: a} + } + receivers := make([]domain.Receiver, len(outputs)) + for i, a := range outputs { + receivers[i] = domain.Receiver{Amount: a} + } + return domain.Intent{Inputs: vtxos, Receivers: receivers} + } + + t.Run("no_intents_no_boarding", func(t *testing.T) { + round := makeRound(nil) + require.Equal(t, 0, int(calculateCollectedFees(round, 0))) + }) + + t.Run("no_intents_with_boarding", func(t *testing.T) { + // boarding input with no intents means all boarding goes to fees + round := makeRound(nil) + require.Equal(t, 5000, int(calculateCollectedFees(round, 5000))) + }) + + t.Run("single_intent_no_fee", func(t *testing.T) { + round := makeRound(map[string]domain.Intent{ + "a": makeIntent([]uint64{10000}, []uint64{10000}), + }) + require.Equal(t, 0, int(calculateCollectedFees(round, 0))) + }) + + t.Run("single_intent_with_fee", func(t *testing.T) { + // input 10000, output 9800 → fee = 200 + round := makeRound(map[string]domain.Intent{ + "a": makeIntent([]uint64{10000}, []uint64{9800}), + }) + require.Equal(t, 200, int(calculateCollectedFees(round, 0))) + }) + + t.Run("boarding_counted_once_not_per_intent", func(t *testing.T) { + // Two intents, each with input 10000 and output 9900. + // Boarding input = 5000. + // Correct: totalIn = 5000 + 10000 + 10000 = 25000 + // totalOut = 9900 + 9900 = 19800 + // fee = 5200 + // Bug (boarding * N): totalIn would be 5000*2 + 20000 = 30000 → fee = 10200 + round := makeRound(map[string]domain.Intent{ + "a": makeIntent([]uint64{10000}, []uint64{9900}), + "b": makeIntent([]uint64{10000}, []uint64{9900}), + }) + require.Equal(t, 5200, int(calculateCollectedFees(round, 5000))) + }) + + t.Run("multiple_inputs_and_outputs", func(t *testing.T) { + // Intent with two inputs and two outputs. + // inputs: 3000 + 7000 = 10000, outputs: 4000 + 5000 = 9000 + // boarding: 1000 + // fee = (1000 + 10000) - 9000 = 2000 + round := makeRound(map[string]domain.Intent{ + "a": makeIntent([]uint64{3000, 7000}, []uint64{4000, 5000}), + }) + require.Equal(t, 2000, int(calculateCollectedFees(round, 1000))) + }) + + t.Run("output_exceeds_input_returns_zero", func(t *testing.T) { + // This shouldn't happen in practice, but the function guards against underflow. + round := makeRound(map[string]domain.Intent{ + "a": makeIntent([]uint64{1000}, []uint64{2000}), + }) + require.Equal(t, 0, int(calculateCollectedFees(round, 0))) + }) +} diff --git a/internal/core/application/service.go b/internal/core/application/service.go index 1733999a8..267726ce6 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -3223,7 +3223,10 @@ func (s *service) finalizeRound(roundId string, roundTiming roundTiming) { s.roundReportSvc.OpEnded(PublishCommitmentTxOp) - changes, err = round.EndFinalization(forfeitTxs, signedCommitmentTx) + boardingAmount := calculateBoardingInputAmount(commitmentTx) + // fees in sats + collectedFees := calculateCollectedFees(round, boardingAmount) + changes, err = round.EndFinalization(forfeitTxs, signedCommitmentTx, collectedFees) if err != nil { changes = round.Fail(errors.INTERNAL_ERROR.New("failed to finalize round: %s", err)) return diff --git a/internal/core/application/utils.go b/internal/core/application/utils.go index 3ad38f6d9..d5a8fd19b 100644 --- a/internal/core/application/utils.go +++ b/internal/core/application/utils.go @@ -607,3 +607,32 @@ func maxAssetsPerVtxo(maxTxWeight uint64, spendingWeightThreshold float64) int { availableWU := maxPacketWU - assetPacketOverheadWU return int(availableWU / refGroupWeight) } + +// calculateCollectedFees computes the total fees (sats) collected by the coordinator for a given round. +func calculateCollectedFees(round *domain.Round, boardingInputAmount uint64) int64 { + totalIn := boardingInputAmount + totalOut := uint64(0) + for _, intent := range round.Intents { + totalIn += intent.TotalInputAmount() + totalOut += intent.TotalOutputAmount() + } + if totalOut >= totalIn { + return 0 + } + return int64(totalIn - totalOut) +} + +// calculateBoardingInputAmount computes the total amount (sats) of boarding inputs in a PSBT. +func calculateBoardingInputAmount(ptx *psbt.Packet) uint64 { + boardingInputAmount := uint64(0) + for _, input := range ptx.Inputs { + if input.WitnessUtxo == nil { + continue + } + // TODO fragile, it may fail if arkd-wallet uses TaprootLeafScript in the future + if len(input.TaprootLeafScript) > 0 { + boardingInputAmount += uint64(input.WitnessUtxo.Value) + } + } + return boardingInputAmount +} diff --git a/internal/core/domain/round.go b/internal/core/domain/round.go index c7e8dda40..0ec30943d 100644 --- a/internal/core/domain/round.go +++ b/internal/core/domain/round.go @@ -53,6 +53,7 @@ type Round struct { Version uint Swept bool VtxoTreeExpiration int64 + CollectedFees int64 SweepTxs map[string]string FailReason string Changes []Event @@ -162,7 +163,11 @@ func (r *Round) StartFinalization( return []Event{event}, nil } -func (r *Round) EndFinalization(forfeitTxs []ForfeitTx, finalCommitmentTx string) ([]Event, error) { +func (r *Round) EndFinalization( + forfeitTxs []ForfeitTx, + finalCommitmentTx string, + collectedFees int64, +) ([]Event, error) { if len(forfeitTxs) <= 0 { for _, intent := range r.Intents { for _, in := range intent.Inputs { @@ -191,6 +196,7 @@ func (r *Round) EndFinalization(forfeitTxs []ForfeitTx, finalCommitmentTx string }, ForfeitTxs: forfeitTxs, FinalCommitmentTx: finalCommitmentTx, + Fees: collectedFees, Timestamp: time.Now().Unix(), } r.raise(event) @@ -287,6 +293,7 @@ func (r *Round) on(event Event, replayed bool) { r.ForfeitTxs = append([]ForfeitTx{}, e.ForfeitTxs...) r.EndingTimestamp = e.Timestamp r.CommitmentTx = e.FinalCommitmentTx + r.CollectedFees = e.Fees case RoundFailed: r.Stage.Failed = true r.FailReason = e.Reason diff --git a/internal/core/domain/round_event.go b/internal/core/domain/round_event.go index 350392363..7fc2ac7e5 100644 --- a/internal/core/domain/round_event.go +++ b/internal/core/domain/round_event.go @@ -33,6 +33,7 @@ type RoundFinalized struct { RoundEvent ForfeitTxs []ForfeitTx FinalCommitmentTx string + Fees int64 Timestamp int64 } diff --git a/internal/core/domain/round_repo.go b/internal/core/domain/round_repo.go index c75c2f3ea..857aed1a8 100644 --- a/internal/core/domain/round_repo.go +++ b/internal/core/domain/round_repo.go @@ -25,6 +25,7 @@ type RoundRepository interface { GetTxsWithTxids(ctx context.Context, txids []string) ([]string, error) GetRoundsWithCommitmentTxids(ctx context.Context, txids []string) (map[string]any, error) GetIntentByTxid(ctx context.Context, txid string) (*Intent, error) + GetCollectedFees(ctx context.Context, after, before int64) (int64, error) Close() } diff --git a/internal/core/domain/round_test.go b/internal/core/domain/round_test.go index ed4207e12..9f0a66e42 100644 --- a/internal/core/domain/round_test.go +++ b/internal/core/domain/round_test.go @@ -476,12 +476,13 @@ func testEndFinalization(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, events) - events, err = round.EndFinalization(forfeitTxs, finalCommitmentTx) + events, err = round.EndFinalization(forfeitTxs, finalCommitmentTx, 42000) require.NoError(t, err) require.Len(t, events, 1) require.False(t, round.IsStarted()) require.True(t, round.IsEnded()) require.False(t, round.IsFailed()) + require.Equal(t, 42000, int(round.CollectedFees)) event, ok := events[0].(domain.RoundFinalized) require.True(t, ok) @@ -489,6 +490,7 @@ func testEndFinalization(t *testing.T) { require.Equal(t, round.Id, event.Id) require.Exactly(t, forfeitTxs, event.ForfeitTxs) require.Exactly(t, round.EndingTimestamp, event.Timestamp) + require.Equal(t, 42000, int(event.Fees)) }) t.Run("invalid", func(t *testing.T) { @@ -571,7 +573,7 @@ func testEndFinalization(t *testing.T) { for _, f := range fixtures { t.Run(f.name, func(t *testing.T) { - events, err := f.round.EndFinalization(f.forfeitTxs, finalCommitmentTx) + events, err := f.round.EndFinalization(f.forfeitTxs, finalCommitmentTx, 0) require.EqualError(t, err, f.expectedErr) require.Empty(t, events) }) @@ -603,7 +605,7 @@ func testSweep(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, events) - events, err = round.EndFinalization(forfeitTxs, finalCommitmentTx) + events, err = round.EndFinalization(forfeitTxs, finalCommitmentTx, 0) require.NoError(t, err) require.Len(t, events, 1) require.False(t, round.IsStarted()) diff --git a/internal/core/ports/alerts.go b/internal/core/ports/alerts.go index 5e99526b9..38a7aaa0c 100644 --- a/internal/core/ports/alerts.go +++ b/internal/core/ports/alerts.go @@ -32,7 +32,7 @@ type BatchFinalizedAlert struct { ForfeitCount int ForfeitAmount uint64 OnchainFees uint64 - CollectedFees uint64 + CollectedFees int64 } type Alerts interface { diff --git a/internal/infrastructure/db/badger/ark_repo.go b/internal/infrastructure/db/badger/ark_repo.go index 3bb6c34ef..11d6a19c3 100644 --- a/internal/infrastructure/db/badger/ark_repo.go +++ b/internal/infrastructure/db/badger/ark_repo.go @@ -124,6 +124,28 @@ func (r *arkRepository) GetRoundStats( return nil, nil } +func (r *arkRepository) GetCollectedFees( + ctx context.Context, after, before int64, +) (int64, error) { + query := badgerhold.Where("Stage.Ended").Eq(true). + And("Stage.Failed").Eq(false) + if after > 0 { + query = query.And("StartingTimestamp").Gt(after) + } + if before > 0 { + query = query.And("StartingTimestamp").Lt(before) + } + rounds, err := r.findRound(ctx, query) + if err != nil { + return 0, err + } + var total int64 + for _, round := range rounds { + total += round.CollectedFees + } + return total, nil +} + func (r *arkRepository) GetRoundForfeitTxs( ctx context.Context, commitmentTxid string, ) ([]domain.ForfeitTx, error) { @@ -274,6 +296,7 @@ func (r *arkRepository) addOrUpdateRound( Swept: round.Swept, VtxoTreeExpiration: round.VtxoTreeExpiration, SweepTxs: round.SweepTxs, + CollectedFees: round.CollectedFees, } var upsertFn func() error if ctx.Value("tx") != nil { diff --git a/internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.down.sql b/internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.down.sql new file mode 100644 index 000000000..20a80c49a --- /dev/null +++ b/internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.down.sql @@ -0,0 +1 @@ +ALTER TABLE round DROP COLUMN fees; diff --git a/internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql b/internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql new file mode 100644 index 000000000..9e09ea8bf --- /dev/null +++ b/internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql @@ -0,0 +1 @@ +ALTER TABLE round ADD COLUMN fees BIGINT NOT NULL DEFAULT 0 CHECK (fees >= 0); diff --git a/internal/infrastructure/db/postgres/round_repo.go b/internal/infrastructure/db/postgres/round_repo.go index ed9dd2abd..b6d161ef8 100644 --- a/internal/infrastructure/db/postgres/round_repo.go +++ b/internal/infrastructure/db/postgres/round_repo.go @@ -91,6 +91,7 @@ func (r *roundRepository) AddOrUpdateRound(ctx context.Context, round domain.Rou ConnectorAddress: round.ConnectorAddress, Version: int32(round.Version), Swept: round.Swept, + Fees: round.CollectedFees, FailReason: sql.NullString{ String: round.FailReason, Valid: len(round.FailReason) > 0, }, @@ -445,6 +446,18 @@ func (r *roundRepository) GetIntentByTxid( Message: intent.Message.String, }, nil } +func (r *roundRepository) GetCollectedFees( + ctx context.Context, after, before int64, +) (int64, error) { + fees, err := r.querier.SelectCollectedFees(ctx, queries.SelectCollectedFeesParams{ + After: after, + Before: before, + }) + if err != nil { + return 0, err + } + return fees, nil +} func rowToReceiver(row queries.IntentWithReceiversVw) domain.Receiver { return domain.Receiver{ @@ -485,6 +498,7 @@ func rowsToRounds(rows []combinedRow) ([]*domain.Round, error) { Swept: v.round.Swept, Intents: make(map[string]domain.Intent), VtxoTreeExpiration: v.round.VtxoTreeExpiration, + CollectedFees: v.round.Fees, FailReason: v.round.FailReason.String, } } diff --git a/internal/infrastructure/db/postgres/sqlc/queries/models.go b/internal/infrastructure/db/postgres/sqlc/queries/models.go index d8dd5c036..10794ab6e 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/models.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/models.go @@ -156,6 +156,7 @@ type Round struct { Swept bool VtxoTreeExpiration int64 FailReason sql.NullString + Fees int64 } type RoundIntentsVw struct { diff --git a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go index e06532008..fb58be0ab 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go @@ -330,6 +330,27 @@ func (q *Queries) SelectAssetsByIds(ctx context.Context, dollar_1 []string) ([]A return items, nil } +const selectCollectedFees = `-- name: SelectCollectedFees :one +SELECT COALESCE(SUM(fees), 0)::bigint AS fees +FROM round +WHERE ended = true + AND failed = false + AND ($1::bigint <= 0 OR starting_timestamp > $1) + AND ($2::bigint <= 0 OR starting_timestamp < $2) +` + +type SelectCollectedFeesParams struct { + After int64 + Before int64 +} + +func (q *Queries) SelectCollectedFees(ctx context.Context, arg SelectCollectedFeesParams) (int64, error) { + row := q.db.QueryRowContext(ctx, selectCollectedFees, arg.After, arg.Before) + var fees int64 + err := row.Scan(&fees) + return fees, err +} + const selectControlAssetByID = `-- name: SelectControlAssetByID :one SELECT control_asset_id FROM asset WHERE id = $1 ` @@ -1129,7 +1150,7 @@ func (q *Queries) SelectRoundVtxoTreeLeaves(ctx context.Context, commitmentTxid } const selectRoundWithId = `-- name: SelectRoundWithId :many -SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round.failed, round.stage_code, round.connector_address, round.version, round.swept, round.vtxo_tree_expiration, round.fail_reason, +SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round.failed, round.stage_code, round.connector_address, round.version, round.swept, round.vtxo_tree_expiration, round.fail_reason, round.fees, round_intents_vw.id, round_intents_vw.round_id, round_intents_vw.proof, round_intents_vw.message, round_intents_vw.txid, round_txs_vw.txid, round_txs_vw.tx, round_txs_vw.round_id, round_txs_vw.type, round_txs_vw.position, round_txs_vw.children, intent_with_receivers_vw.intent_id, intent_with_receivers_vw.pubkey, intent_with_receivers_vw.onchain_address, intent_with_receivers_vw.amount, intent_with_receivers_vw.id, intent_with_receivers_vw.round_id, intent_with_receivers_vw.proof, intent_with_receivers_vw.message, intent_with_receivers_vw.txid, @@ -1171,6 +1192,7 @@ func (q *Queries) SelectRoundWithId(ctx context.Context, id string) ([]SelectRou &i.Round.Swept, &i.Round.VtxoTreeExpiration, &i.Round.FailReason, + &i.Round.Fees, &i.RoundIntentsVw.ID, &i.RoundIntentsVw.RoundID, &i.RoundIntentsVw.Proof, @@ -1230,7 +1252,7 @@ func (q *Queries) SelectRoundWithId(ctx context.Context, id string) ([]SelectRou } const selectRoundWithTxid = `-- name: SelectRoundWithTxid :many -SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round.failed, round.stage_code, round.connector_address, round.version, round.swept, round.vtxo_tree_expiration, round.fail_reason, +SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round.failed, round.stage_code, round.connector_address, round.version, round.swept, round.vtxo_tree_expiration, round.fail_reason, round.fees, round_intents_vw.id, round_intents_vw.round_id, round_intents_vw.proof, round_intents_vw.message, round_intents_vw.txid, round_txs_vw.txid, round_txs_vw.tx, round_txs_vw.round_id, round_txs_vw.type, round_txs_vw.position, round_txs_vw.children, intent_with_receivers_vw.intent_id, intent_with_receivers_vw.pubkey, intent_with_receivers_vw.onchain_address, intent_with_receivers_vw.amount, intent_with_receivers_vw.id, intent_with_receivers_vw.round_id, intent_with_receivers_vw.proof, intent_with_receivers_vw.message, intent_with_receivers_vw.txid, @@ -1274,6 +1296,7 @@ func (q *Queries) SelectRoundWithTxid(ctx context.Context, txid string) ([]Selec &i.Round.Swept, &i.Round.VtxoTreeExpiration, &i.Round.FailReason, + &i.Round.Fees, &i.RoundIntentsVw.ID, &i.RoundIntentsVw.RoundID, &i.RoundIntentsVw.Proof, @@ -2027,10 +2050,10 @@ func (q *Queries) UpsertReceiver(ctx context.Context, arg UpsertReceiverParams) const upsertRound = `-- name: UpsertRound :exec INSERT INTO round ( id, starting_timestamp, ending_timestamp, ended, failed, fail_reason, - stage_code, connector_address, version, swept, vtxo_tree_expiration + stage_code, connector_address, version, swept, vtxo_tree_expiration, fees ) VALUES ( $1, $2, $3, $4, $5, $6, - $7, $8, $9, $10, $11 + $7, $8, $9, $10, $11, $12 ) ON CONFLICT(id) DO UPDATE SET starting_timestamp = EXCLUDED.starting_timestamp, @@ -2042,7 +2065,8 @@ ON CONFLICT(id) DO UPDATE SET connector_address = EXCLUDED.connector_address, version = EXCLUDED.version, swept = EXCLUDED.swept, - vtxo_tree_expiration = EXCLUDED.vtxo_tree_expiration + vtxo_tree_expiration = EXCLUDED.vtxo_tree_expiration, + fees = EXCLUDED.fees ` type UpsertRoundParams struct { @@ -2057,6 +2081,7 @@ type UpsertRoundParams struct { Version int32 Swept bool VtxoTreeExpiration int64 + Fees int64 } func (q *Queries) UpsertRound(ctx context.Context, arg UpsertRoundParams) error { @@ -2072,6 +2097,7 @@ func (q *Queries) UpsertRound(ctx context.Context, arg UpsertRoundParams) error arg.Version, arg.Swept, arg.VtxoTreeExpiration, + arg.Fees, ) return err } diff --git a/internal/infrastructure/db/postgres/sqlc/query.sql b/internal/infrastructure/db/postgres/sqlc/query.sql index 79518e977..3044b966a 100644 --- a/internal/infrastructure/db/postgres/sqlc/query.sql +++ b/internal/infrastructure/db/postgres/sqlc/query.sql @@ -1,10 +1,10 @@ -- name: UpsertRound :exec INSERT INTO round ( id, starting_timestamp, ending_timestamp, ended, failed, fail_reason, - stage_code, connector_address, version, swept, vtxo_tree_expiration + stage_code, connector_address, version, swept, vtxo_tree_expiration, fees ) VALUES ( @id, @starting_timestamp, @ending_timestamp, @ended, @failed, @fail_reason, - @stage_code, @connector_address, @version, @swept, @vtxo_tree_expiration + @stage_code, @connector_address, @version, @swept, @vtxo_tree_expiration, @fees ) ON CONFLICT(id) DO UPDATE SET starting_timestamp = EXCLUDED.starting_timestamp, @@ -16,7 +16,8 @@ ON CONFLICT(id) DO UPDATE SET connector_address = EXCLUDED.connector_address, version = EXCLUDED.version, swept = EXCLUDED.swept, - vtxo_tree_expiration = EXCLUDED.vtxo_tree_expiration; + vtxo_tree_expiration = EXCLUDED.vtxo_tree_expiration, + fees = EXCLUDED.fees; -- name: UpsertTx :exec INSERT INTO tx (tx, round_id, type, position, txid, children) @@ -436,6 +437,14 @@ VALUES (@asset_id, @txid, @vout, @amount); -- name: SelectAssetsByIds :many SELECT * FROM asset WHERE asset.id = ANY($1::varchar[]); +-- name: SelectCollectedFees :one +SELECT COALESCE(SUM(fees), 0)::bigint AS fees +FROM round +WHERE ended = true + AND failed = false + AND (@after::bigint <= 0 OR starting_timestamp > @after) + AND (@before::bigint <= 0 OR starting_timestamp < @before); + -- name: SelectAssetSupply :one SELECT (COALESCE(SUM(ap.amount), 0))::TEXT AS supply FROM asset_projection ap diff --git a/internal/infrastructure/db/service_test.go b/internal/infrastructure/db/service_test.go index 06a44ed47..35b08b8b1 100644 --- a/internal/infrastructure/db/service_test.go +++ b/internal/infrastructure/db/service_test.go @@ -654,6 +654,152 @@ func testRoundRepository(t *testing.T, svc ports.RepoManager) { // - second round has no vtxo tree require.Empty(t, sweepableRounds) }) + + t.Run("test_collected_fees", func(t *testing.T) { + ctx := context.Background() + repo := svc.Rounds() + + // No fees collected yet for a fresh time range. + fees, err := repo.GetCollectedFees(ctx, 0, 0) + require.NoError(t, err) + require.Equal(t, 0, int(fees)) + + // Create three completed rounds at timestamps 100, 200, 300 with + // collected fees 1000, 2000, 3000 respectively. + for _, tc := range []struct { + ts int64 + fee int64 + }{ + {100, 1000}, + {200, 2000}, + {300, 3000}, + } { + id := uuid.New().String() + round := domain.NewRoundFromEvents([]domain.Event{ + domain.RoundStarted{ + RoundEvent: domain.RoundEvent{ + Id: id, + Type: domain.EventTypeRoundStarted, + }, + Timestamp: tc.ts, + }, + domain.RoundFinalizationStarted{ + RoundEvent: domain.RoundEvent{ + Id: id, + Type: domain.EventTypeRoundFinalizationStarted, + }, + CommitmentTxid: randomString(32), + CommitmentTx: emptyTx, + }, + domain.RoundFinalized{ + RoundEvent: domain.RoundEvent{ + Id: id, + Type: domain.EventTypeRoundFinalized, + }, + FinalCommitmentTx: emptyTx, + Fees: tc.fee, + Timestamp: tc.ts + 10, + }, + }) + err := repo.AddOrUpdateRound(ctx, *round) + require.NoError(t, err) + } + + // Create a failed round at timestamp 250 with fees 9999 — should be excluded. + failedId := uuid.New().String() + failedRound := domain.NewRoundFromEvents([]domain.Event{ + domain.RoundStarted{ + RoundEvent: domain.RoundEvent{ + Id: failedId, + Type: domain.EventTypeRoundStarted, + }, + Timestamp: 250, + }, + domain.RoundFailed{ + RoundEvent: domain.RoundEvent{ + Id: failedId, + Type: domain.EventTypeRoundFailed, + }, + Reason: "test failure", + Timestamp: 260, + }, + }) + // Manually set CollectedFees on the failed round to verify it's excluded. + failedRound.CollectedFees = 9999 + err = repo.AddOrUpdateRound(ctx, *failedRound) + require.NoError(t, err) + + // before=0 means no upper bound: all three completed rounds (after > 0). + // Rounds at ts 100, 200, 300 all have starting_timestamp > 0. + fees, err = repo.GetCollectedFees(ctx, 0, 0) + require.NoError(t, err) + require.Equal(t, 6000, int(fees)) + + // Only rounds starting after 100 (exclusive): ts 200, 300. + fees, err = repo.GetCollectedFees(ctx, 100, 0) + require.NoError(t, err) + require.Equal(t, 5000, int(fees)) + + // Rounds starting after 100 and before 300 (both exclusive): ts 200 only. + fees, err = repo.GetCollectedFees(ctx, 100, 300) + require.NoError(t, err) + require.Equal(t, 2000, int(fees)) + + // Range that includes all three: after 0, before 999. + fees, err = repo.GetCollectedFees(ctx, 0, 999) + require.NoError(t, err) + require.Equal(t, 6000, int(fees)) + + // Range that includes none: after 300 with no upper bound. + fees, err = repo.GetCollectedFees(ctx, 300, 0) + require.NoError(t, err) + require.Equal(t, 0, int(fees)) + + // Create a round that was finalized then failed (ended+failed). + // Its collected fees should NOT be included in totals. + endedFailedId := uuid.New().String() + endedFailedRound := domain.NewRoundFromEvents([]domain.Event{ + domain.RoundStarted{ + RoundEvent: domain.RoundEvent{ + Id: endedFailedId, + Type: domain.EventTypeRoundStarted, + }, + Timestamp: 400, + }, + domain.RoundFinalizationStarted{ + RoundEvent: domain.RoundEvent{ + Id: endedFailedId, + Type: domain.EventTypeRoundFinalizationStarted, + }, + CommitmentTxid: randomString(32), + CommitmentTx: emptyTx, + }, + domain.RoundFinalized{ + RoundEvent: domain.RoundEvent{ + Id: endedFailedId, + Type: domain.EventTypeRoundFinalized, + }, + FinalCommitmentTx: emptyTx, + Fees: 8888, + Timestamp: 410, + }, + domain.RoundFailed{ + RoundEvent: domain.RoundEvent{ + Id: endedFailedId, + Type: domain.EventTypeRoundFailed, + }, + Reason: "double-spend detected", + Timestamp: 420, + }, + }) + err = repo.AddOrUpdateRound(ctx, *endedFailedRound) + require.NoError(t, err) + + // Totals should still be 6000, not 6000+8888. + fees, err = repo.GetCollectedFees(ctx, 0, 0) + require.NoError(t, err) + require.Equal(t, 6000, int(fees)) + }) } func testVtxoRepository(t *testing.T, svc ports.RepoManager) { diff --git a/internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.down.sql b/internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.down.sql new file mode 100644 index 000000000..20a80c49a --- /dev/null +++ b/internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.down.sql @@ -0,0 +1 @@ +ALTER TABLE round DROP COLUMN fees; diff --git a/internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql b/internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql new file mode 100644 index 000000000..a374dcd93 --- /dev/null +++ b/internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql @@ -0,0 +1 @@ +ALTER TABLE round ADD COLUMN fees REAL NOT NULL DEFAULT 0 CHECK (fees >= 0); diff --git a/internal/infrastructure/db/sqlite/round_repo.go b/internal/infrastructure/db/sqlite/round_repo.go index 8d734bc3b..2e6745a5a 100644 --- a/internal/infrastructure/db/sqlite/round_repo.go +++ b/internal/infrastructure/db/sqlite/round_repo.go @@ -91,6 +91,7 @@ func (r *roundRepository) AddOrUpdateRound(ctx context.Context, round domain.Rou ConnectorAddress: round.ConnectorAddress, Version: int64(round.Version), Swept: round.Swept, + Fees: round.CollectedFees, FailReason: sql.NullString{ String: round.FailReason, Valid: len(round.FailReason) > 0, }, @@ -511,6 +512,19 @@ func (r *roundRepository) GetIntentByTxid( }, nil } +func (r *roundRepository) GetCollectedFees( + ctx context.Context, after, before int64, +) (int64, error) { + fees, err := r.querier.SelectCollectedFees(ctx, queries.SelectCollectedFeesParams{ + After: after, + Before: before, + }) + if err != nil { + return 0, err + } + return fees, nil +} + func rowToReceiver(row queries.IntentWithReceiversVw) domain.Receiver { return domain.Receiver{ Amount: uint64(row.Amount.Int64), @@ -550,6 +564,7 @@ func rowsToRounds(rows []combinedRow) ([]*domain.Round, error) { Swept: v.round.Swept, Intents: make(map[string]domain.Intent), VtxoTreeExpiration: v.round.VtxoTreeExpiration, + CollectedFees: v.round.Fees, FailReason: v.round.FailReason.String, } } diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/models.go b/internal/infrastructure/db/sqlite/sqlc/queries/models.go index f5ded82b3..0ea2e65f3 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/models.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/models.go @@ -143,6 +143,7 @@ type Round struct { Swept bool VtxoTreeExpiration int64 FailReason sql.NullString + Fees int64 } type RoundIntentsVw struct { diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go index 5e9245a14..50c7e0a0b 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go @@ -352,6 +352,27 @@ func (q *Queries) SelectAssetsByIds(ctx context.Context, ids []string) ([]Asset, return items, nil } +const selectCollectedFees = `-- name: SelectCollectedFees :one +SELECT CAST(COALESCE(SUM(fees), 0) AS INTEGER) AS fees +FROM round +WHERE ended = true + AND failed = false + AND (CAST(?1 AS INTEGER) <= 0 OR starting_timestamp > ?1) + AND (CAST(?2 AS INTEGER) <= 0 OR starting_timestamp < ?2) +` + +type SelectCollectedFeesParams struct { + After int64 + Before int64 +} + +func (q *Queries) SelectCollectedFees(ctx context.Context, arg SelectCollectedFeesParams) (int64, error) { + row := q.db.QueryRowContext(ctx, selectCollectedFees, arg.After, arg.Before) + var fees int64 + err := row.Scan(&fees) + return fees, err +} + const selectControlAssetByID = `-- name: SelectControlAssetByID :one SELECT control_asset_id FROM asset WHERE id = ? ` @@ -1211,7 +1232,7 @@ func (q *Queries) SelectRoundVtxoTreeLeaves(ctx context.Context, commitmentTxid } const selectRoundWithId = `-- name: SelectRoundWithId :many -SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round.failed, round.stage_code, round.connector_address, round.version, round.swept, round.vtxo_tree_expiration, round.fail_reason, +SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round.failed, round.stage_code, round.connector_address, round.version, round.swept, round.vtxo_tree_expiration, round.fail_reason, round.fees, round_intents_vw.id, round_intents_vw.round_id, round_intents_vw.proof, round_intents_vw.message, round_intents_vw.txid, round_txs_vw.txid, round_txs_vw.tx, round_txs_vw.round_id, round_txs_vw.type, round_txs_vw.position, round_txs_vw.children FROM round @@ -1247,6 +1268,7 @@ func (q *Queries) SelectRoundWithId(ctx context.Context, id string) ([]SelectRou &i.Round.Swept, &i.Round.VtxoTreeExpiration, &i.Round.FailReason, + &i.Round.Fees, &i.RoundIntentsVw.ID, &i.RoundIntentsVw.RoundID, &i.RoundIntentsVw.Proof, @@ -1273,7 +1295,7 @@ func (q *Queries) SelectRoundWithId(ctx context.Context, id string) ([]SelectRou } const selectRoundWithTxid = `-- name: SelectRoundWithTxid :many -SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round.failed, round.stage_code, round.connector_address, round.version, round.swept, round.vtxo_tree_expiration, round.fail_reason, +SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round.failed, round.stage_code, round.connector_address, round.version, round.swept, round.vtxo_tree_expiration, round.fail_reason, round.fees, round_intents_vw.id, round_intents_vw.round_id, round_intents_vw.proof, round_intents_vw.message, round_intents_vw.txid, round_txs_vw.txid, round_txs_vw.tx, round_txs_vw.round_id, round_txs_vw.type, round_txs_vw.position, round_txs_vw.children FROM round @@ -1311,6 +1333,7 @@ func (q *Queries) SelectRoundWithTxid(ctx context.Context, txid string) ([]Selec &i.Round.Swept, &i.Round.VtxoTreeExpiration, &i.Round.FailReason, + &i.Round.Fees, &i.RoundIntentsVw.ID, &i.RoundIntentsVw.RoundID, &i.RoundIntentsVw.Proof, @@ -2141,10 +2164,10 @@ func (q *Queries) UpsertReceiver(ctx context.Context, arg UpsertReceiverParams) const upsertRound = `-- name: UpsertRound :exec INSERT INTO round ( id, starting_timestamp, ending_timestamp, ended, failed, fail_reason, - stage_code, connector_address, version, swept, vtxo_tree_expiration + stage_code, connector_address, version, swept, vtxo_tree_expiration, fees ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, - ?7, ?8, ?9, ?10, ?11 + ?7, ?8, ?9, ?10, ?11, ?12 ) ON CONFLICT(id) DO UPDATE SET starting_timestamp = EXCLUDED.starting_timestamp, @@ -2156,7 +2179,8 @@ ON CONFLICT(id) DO UPDATE SET connector_address = EXCLUDED.connector_address, version = EXCLUDED.version, swept = EXCLUDED.swept, - vtxo_tree_expiration = EXCLUDED.vtxo_tree_expiration + vtxo_tree_expiration = EXCLUDED.vtxo_tree_expiration, + fees = EXCLUDED.fees ` type UpsertRoundParams struct { @@ -2171,6 +2195,7 @@ type UpsertRoundParams struct { Version int64 Swept bool VtxoTreeExpiration int64 + Fees int64 } func (q *Queries) UpsertRound(ctx context.Context, arg UpsertRoundParams) error { @@ -2186,6 +2211,7 @@ func (q *Queries) UpsertRound(ctx context.Context, arg UpsertRoundParams) error arg.Version, arg.Swept, arg.VtxoTreeExpiration, + arg.Fees, ) return err } diff --git a/internal/infrastructure/db/sqlite/sqlc/query.sql b/internal/infrastructure/db/sqlite/sqlc/query.sql index 47f0a7691..faa9324c7 100644 --- a/internal/infrastructure/db/sqlite/sqlc/query.sql +++ b/internal/infrastructure/db/sqlite/sqlc/query.sql @@ -1,10 +1,10 @@ -- name: UpsertRound :exec INSERT INTO round ( id, starting_timestamp, ending_timestamp, ended, failed, fail_reason, - stage_code, connector_address, version, swept, vtxo_tree_expiration + stage_code, connector_address, version, swept, vtxo_tree_expiration, fees ) VALUES ( @id, @starting_timestamp, @ending_timestamp, @ended, @failed, @fail_reason, - @stage_code, @connector_address, @version, @swept, @vtxo_tree_expiration + @stage_code, @connector_address, @version, @swept, @vtxo_tree_expiration, @fees ) ON CONFLICT(id) DO UPDATE SET starting_timestamp = EXCLUDED.starting_timestamp, @@ -16,7 +16,8 @@ ON CONFLICT(id) DO UPDATE SET connector_address = EXCLUDED.connector_address, version = EXCLUDED.version, swept = EXCLUDED.swept, - vtxo_tree_expiration = EXCLUDED.vtxo_tree_expiration; + vtxo_tree_expiration = EXCLUDED.vtxo_tree_expiration, + fees = EXCLUDED.fees; -- name: UpsertTx :exec INSERT INTO tx (tx, round_id, type, position, txid, children) @@ -438,6 +439,14 @@ VALUES (@id, @is_immutable, @metadata_hash, @metadata, @control_asset_id); INSERT INTO asset_projection (asset_id, txid, vout, amount) VALUES (@asset_id, @txid, @vout, @amount); +-- name: SelectCollectedFees :one +SELECT CAST(COALESCE(SUM(fees), 0) AS INTEGER) AS fees +FROM round +WHERE ended = true + AND failed = false + AND (CAST(sqlc.arg('after') AS INTEGER) <= 0 OR starting_timestamp > sqlc.arg('after')) + AND (CAST(sqlc.arg('before') AS INTEGER) <= 0 OR starting_timestamp < sqlc.arg('before')); + -- name: SelectAssetsByIds :many SELECT * FROM asset WHERE asset.id IN (sqlc.slice('ids')); diff --git a/internal/interface/grpc/handlers/adminservice.go b/internal/interface/grpc/handlers/adminservice.go index c65f29b13..294035867 100644 --- a/internal/interface/grpc/handlers/adminservice.go +++ b/internal/interface/grpc/handlers/adminservice.go @@ -75,7 +75,7 @@ func (a *adminHandler) GetRoundDetails( ForfeitedAmount: convertSatsToBTCStr(details.ForfeitedAmount), TotalVtxosAmount: convertSatsToBTCStr(details.TotalVtxosAmount), TotalExitAmount: convertSatsToBTCStr(details.TotalExitAmount), - TotalFeeAmount: convertSatsToBTCStr(details.FeesAmount), + TotalFeeAmount: convertSatsToBTCStr(uint64(details.FeesAmount)), InputsVtxos: details.InputVtxos, OutputsVtxos: details.OutputVtxos, ExitAddresses: details.ExitAddresses, @@ -457,6 +457,30 @@ func (a *adminHandler) BanScript( return &arkv1.BanScriptResponse{}, nil } +func (a *adminHandler) GetCollectedFees( + ctx context.Context, req *arkv1.GetCollectedFeesRequest, +) (*arkv1.GetCollectedFeesResponse, error) { + after := req.GetAfter() + before := req.GetBefore() + + if after < 0 { + return nil, status.Error(codes.InvalidArgument, "invalid after (must be >= 0)") + } + if before < 0 { + return nil, status.Error(codes.InvalidArgument, "invalid before (must be >= 0)") + } + if before > 0 && after >= before { + return nil, status.Error(codes.InvalidArgument, "invalid range") + } + + fees, err := a.adminService.GetCollectedFees(ctx, after, before) + if err != nil { + return nil, status.Errorf(codes.Internal, "%s", err.Error()) + } + + return &arkv1.GetCollectedFeesResponse{CollectedFees: uint64(fees)}, nil +} + func (a *adminHandler) Sweep( ctx context.Context, req *arkv1.SweepRequest, ) (*arkv1.SweepResponse, error) { diff --git a/internal/interface/grpc/permissions/permissions.go b/internal/interface/grpc/permissions/permissions.go index 95625971d..9ae9c6265 100644 --- a/internal/interface/grpc/permissions/permissions.go +++ b/internal/interface/grpc/permissions/permissions.go @@ -306,6 +306,10 @@ func AllPermissionsByMethod() map[string][]bakery.Op { Entity: EntityManager, Action: "read", }}, + fmt.Sprintf("/%s/GetCollectedFees", arkv1.AdminService_ServiceDesc.ServiceName): {{ + Entity: EntityManager, + Action: "read", + }}, fmt.Sprintf("/%s/CreateNote", arkv1.AdminService_ServiceDesc.ServiceName): {{ Entity: EntityNote, Action: "write", diff --git a/internal/test/e2e/e2e_test.go b/internal/test/e2e/e2e_test.go index a7ddb421d..59a6d7602 100644 --- a/internal/test/e2e/e2e_test.go +++ b/internal/test/e2e/e2e_test.go @@ -4762,6 +4762,103 @@ func TestFee(t *testing.T) { require.Empty(t, bobBalance.OnchainBalance.LockedAmount) } +func TestCollectedFees(t *testing.T) { + // Record timestamp before any rounds so every round in this test falls + // inside the query window. + startTime := time.Now().Unix() + + // Save and clear fees so the funding round (faucetOffchain) doesn't + // collect fees — only the final settle round should. + originalFees, err := getIntentFees() + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, clearIntentFees()) + if !isEmptyIntentFees(*originalFees) { + require.NoError(t, updateIntentFees(*originalFees)) + } + }) + + require.NoError(t, clearIntentFees()) + + ctx := t.Context() + alice := setupArkSDK(t) + bob := setupArkSDK(t) + + _, aliceOffchainAddr, aliceBoardingAddr, err := alice.Receive(ctx) + require.NoError(t, err) + _, bobOffchainAddr, _, err := bob.Receive(ctx) + require.NoError(t, err) + + // Fund Alice onchain (no round triggered) and Bob offchain (round triggered, + // but no fees configured yet so collected fees stay zero). + faucetOnchain(t, aliceBoardingAddr.Address, 0.001) + faucetOffchain(t, bob, 0.001) + time.Sleep(6 * time.Second) + + // Configure 1% input fees so the next round generates non-zero collected fees. + fees := intentFees{ + IntentOffchainInputFeeProgram: "0.01 * amount", + IntentOnchainInputFeeProgram: "0.01 * amount", + IntentOffchainOutputFeeProgram: "0.0", + IntentOnchainOutputFeeProgram: "0.0", + } + err = updateIntentFees(fees) + require.NoError(t, err) + + // Alice (boarding / onchain input) and Bob (renewal / offchain input) settle together. + wg := &sync.WaitGroup{} + wg.Add(4) + + var aliceIncomingErr error + go func() { + _, aliceIncomingErr = alice.NotifyIncomingFunds(ctx, aliceOffchainAddr.Address) + wg.Done() + }() + + var bobIncomingErr error + go func() { + _, bobIncomingErr = bob.NotifyIncomingFunds(ctx, bobOffchainAddr.Address) + wg.Done() + }() + + var aliceSettleErr error + go func() { + _, aliceSettleErr = alice.Settle(ctx) + wg.Done() + }() + + var bobSettleErr error + go func() { + _, bobSettleErr = bob.Settle(ctx) + wg.Done() + }() + + wg.Wait() + require.NoError(t, aliceIncomingErr) + require.NoError(t, bobIncomingErr) + require.NoError(t, aliceSettleErr) + require.NoError(t, bobSettleErr) + + time.Sleep(time.Second) + + // Query collected fees for the window that includes our round. + endTime := time.Now().Unix() + 10 + collectedFees, err := getCollectedFees(startTime-1, endTime) + require.NoError(t, err) + + // Alice's boarding input: 100,000 sats × 1% = 1,000 sats + // Bob's offchain input: 100,000 sats × 1% = 1,000 sats + // Total expected: 2,000 sats + require.Equal(t, 2000, int(collectedFees), + "collected fees should equal sum of onchain and offchain input fees") + + // Query with a future window — should return zero. + futureFees, err := getCollectedFees(endTime, 0) + require.NoError(t, err) + require.Zero(t, futureFees, "expected zero collected fees for future time range") +} + func TestAsset(t *testing.T) { // This test ensures that an asset vtxo can be issued, transfered and then refreshed t.Run("transfer and renew", func(t *testing.T) { @@ -5389,9 +5486,24 @@ func TestTxListenerChurn(t *testing.T) { // Wait for the stress window to expire, then drain all goroutines and // close the sentinel stream. <-stressCtx.Done() - wg.Wait() closeSentinelStream() - <-sentinelDone + + wgDone := make(chan struct{}) + go func() { + wg.Wait() + close(wgDone) + }() + select { + case <-wgDone: + case <-time.After(10 * time.Second): + t.Log("churn/producer goroutines did not exit within 10s") + } + + select { + case <-sentinelDone: + case <-time.After(5 * time.Second): + t.Log("sentinel goroutine did not exit within 5s") + } // Drain the error channel — any non-retryable error from a churn // worker or the tx producer is a test failure. @@ -5522,6 +5634,7 @@ func TestEventListenerChurn(t *testing.T) { for { select { case <-stressCtx.Done(): + closeStream() return case ev, ok := <-sentinelStream: if !ok { @@ -5681,6 +5794,9 @@ func TestEventListenerChurn(t *testing.T) { select { case <-roundDone: + case <-stressCtx.Done(): + cancelRound() + return case <-time.After(roundTimeout + 2*time.Second): cancelRound() continue @@ -5737,9 +5853,24 @@ func TestEventListenerChurn(t *testing.T) { // Wait for the stress window to expire, then drain all goroutines and // close the sentinel stream. <-stressCtx.Done() - wg.Wait() closeSentinelStream() - <-sentinelDone + + wgDone := make(chan struct{}) + go func() { + wg.Wait() + close(wgDone) + }() + select { + case <-wgDone: + case <-time.After(10 * time.Second): + t.Log("churn/producer goroutines did not exit within 10s") + } + + select { + case <-sentinelDone: + case <-time.After(5 * time.Second): + t.Log("sentinel goroutine did not exit within 5s") + } // At least one round must have completed and the sentinel must have // observed events. Transient sentinel errors are tolerated (the diff --git a/internal/test/e2e/utils_test.go b/internal/test/e2e/utils_test.go index 394ce07ef..a59916a82 100644 --- a/internal/test/e2e/utils_test.go +++ b/internal/test/e2e/utils_test.go @@ -560,6 +560,24 @@ func updateIntentFees(intentFees intentFees) error { return nil } +type collectedFeesResponse struct { + CollectedFees uint64 `json:"collectedFees,string"` +} + +func getCollectedFees(after, before int64) (uint64, error) { + adminHttpClient := &http.Client{ + Timeout: 15 * time.Second, + } + + url := fmt.Sprintf("%s/v1/admin/fees/collected?after=%d&before=%d", adminUrl, after, before) + resp, err := get[collectedFeesResponse](adminHttpClient, url, "collected fees") + if err != nil { + return 0, fmt.Errorf("failed to get collected fees: %w", err) + } + + return resp.CollectedFees, nil +} + func clearIntentFees() error { adminHttpClient := &http.Client{ Timeout: 15 * time.Second,