From e8f7aead49620015e2499263fe03ff19f765365e Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 06:54:44 -0600 Subject: [PATCH 01/13] add fee remainder field --- api/provlabs/vault/v1/vault.pulsar.go | 216 ++++++++++++++++++-------- proto/provlabs/vault/v1/vault.proto | 6 + types/vault.pb.go | 208 ++++++++++++++++--------- 3 files changed, 288 insertions(+), 142 deletions(-) diff --git a/api/provlabs/vault/v1/vault.pulsar.go b/api/provlabs/vault/v1/vault.pulsar.go index a809f41d..c62023e9 100644 --- a/api/provlabs/vault/v1/vault.pulsar.go +++ b/api/provlabs/vault/v1/vault.pulsar.go @@ -48,6 +48,7 @@ var ( fd_VaultAccount_max_swap_in_value protoreflect.FieldDescriptor fd_VaultAccount_max_swap_out_value protoreflect.FieldDescriptor fd_VaultAccount_nav_authority protoreflect.FieldDescriptor + fd_VaultAccount_fee_remainder protoreflect.FieldDescriptor ) func init() { @@ -82,6 +83,7 @@ func init() { fd_VaultAccount_max_swap_in_value = md_VaultAccount.Fields().ByName("max_swap_in_value") fd_VaultAccount_max_swap_out_value = md_VaultAccount.Fields().ByName("max_swap_out_value") fd_VaultAccount_nav_authority = md_VaultAccount.Fields().ByName("nav_authority") + fd_VaultAccount_fee_remainder = md_VaultAccount.Fields().ByName("fee_remainder") } var _ protoreflect.Message = (*fastReflection_VaultAccount)(nil) @@ -323,6 +325,12 @@ func (x *fastReflection_VaultAccount) Range(f func(protoreflect.FieldDescriptor, return } } + if x.FeeRemainder != "" { + value := protoreflect.ValueOfString(x.FeeRemainder) + if !f(fd_VaultAccount_fee_remainder, value) { + return + } + } } // Has reports whether a field is populated. @@ -396,6 +404,8 @@ func (x *fastReflection_VaultAccount) Has(fd protoreflect.FieldDescriptor) bool return x.MaxSwapOutValue != "" case "provlabs.vault.v1.VaultAccount.nav_authority": return x.NavAuthority != "" + case "provlabs.vault.v1.VaultAccount.fee_remainder": + return x.FeeRemainder != "" default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: provlabs.vault.v1.VaultAccount")) @@ -470,6 +480,8 @@ func (x *fastReflection_VaultAccount) Clear(fd protoreflect.FieldDescriptor) { x.MaxSwapOutValue = "" case "provlabs.vault.v1.VaultAccount.nav_authority": x.NavAuthority = "" + case "provlabs.vault.v1.VaultAccount.fee_remainder": + x.FeeRemainder = "" default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: provlabs.vault.v1.VaultAccount")) @@ -573,6 +585,9 @@ func (x *fastReflection_VaultAccount) Get(descriptor protoreflect.FieldDescripto case "provlabs.vault.v1.VaultAccount.nav_authority": value := x.NavAuthority return protoreflect.ValueOfString(value) + case "provlabs.vault.v1.VaultAccount.fee_remainder": + value := x.FeeRemainder + return protoreflect.ValueOfString(value) default: if descriptor.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: provlabs.vault.v1.VaultAccount")) @@ -651,6 +666,8 @@ func (x *fastReflection_VaultAccount) Set(fd protoreflect.FieldDescriptor, value x.MaxSwapOutValue = value.Interface().(string) case "provlabs.vault.v1.VaultAccount.nav_authority": x.NavAuthority = value.Interface().(string) + case "provlabs.vault.v1.VaultAccount.fee_remainder": + x.FeeRemainder = value.Interface().(string) default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: provlabs.vault.v1.VaultAccount")) @@ -741,6 +758,8 @@ func (x *fastReflection_VaultAccount) Mutable(fd protoreflect.FieldDescriptor) p panic(fmt.Errorf("field max_swap_out_value of message provlabs.vault.v1.VaultAccount is not mutable")) case "provlabs.vault.v1.VaultAccount.nav_authority": panic(fmt.Errorf("field nav_authority of message provlabs.vault.v1.VaultAccount is not mutable")) + case "provlabs.vault.v1.VaultAccount.fee_remainder": + panic(fmt.Errorf("field fee_remainder of message provlabs.vault.v1.VaultAccount is not mutable")) default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: provlabs.vault.v1.VaultAccount")) @@ -816,6 +835,8 @@ func (x *fastReflection_VaultAccount) NewField(fd protoreflect.FieldDescriptor) return protoreflect.ValueOfString("") case "provlabs.vault.v1.VaultAccount.nav_authority": return protoreflect.ValueOfString("") + case "provlabs.vault.v1.VaultAccount.fee_remainder": + return protoreflect.ValueOfString("") default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: provlabs.vault.v1.VaultAccount")) @@ -991,6 +1012,10 @@ func (x *fastReflection_VaultAccount) ProtoMethods() *protoiface.Methods { if l > 0 { n += 2 + l + runtime.Sov(uint64(l)) } + l = len(x.FeeRemainder) + if l > 0 { + n += 2 + l + runtime.Sov(uint64(l)) + } if x.unknownFields != nil { n += len(x.unknownFields) } @@ -1020,6 +1045,15 @@ func (x *fastReflection_VaultAccount) ProtoMethods() *protoiface.Methods { i -= len(x.unknownFields) copy(dAtA[i:], x.unknownFields) } + if len(x.FeeRemainder) > 0 { + i -= len(x.FeeRemainder) + copy(dAtA[i:], x.FeeRemainder) + i = runtime.EncodeVarint(dAtA, i, uint64(len(x.FeeRemainder))) + i-- + dAtA[i] = 0x1 + i-- + dAtA[i] = 0xf2 + } if len(x.NavAuthority) > 0 { i -= len(x.NavAuthority) copy(dAtA[i:], x.NavAuthority) @@ -2146,6 +2180,38 @@ func (x *fastReflection_VaultAccount) ProtoMethods() *protoiface.Methods { } x.NavAuthority = string(dAtA[iNdEx:postIndex]) iNdEx = postIndex + case 30: + if wireType != 2 { + return protoiface.UnmarshalOutput{NoUnkeyedLiterals: input.NoUnkeyedLiterals, Flags: input.Flags}, fmt.Errorf("proto: wrong wireType = %d for field FeeRemainder", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protoiface.UnmarshalOutput{NoUnkeyedLiterals: input.NoUnkeyedLiterals, Flags: input.Flags}, runtime.ErrIntOverflow + } + if iNdEx >= l { + return protoiface.UnmarshalOutput{NoUnkeyedLiterals: input.NoUnkeyedLiterals, Flags: input.Flags}, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protoiface.UnmarshalOutput{NoUnkeyedLiterals: input.NoUnkeyedLiterals, Flags: input.Flags}, runtime.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protoiface.UnmarshalOutput{NoUnkeyedLiterals: input.NoUnkeyedLiterals, Flags: input.Flags}, runtime.ErrInvalidLength + } + if postIndex > l { + return protoiface.UnmarshalOutput{NoUnkeyedLiterals: input.NoUnkeyedLiterals, Flags: input.Flags}, io.ErrUnexpectedEOF + } + x.FeeRemainder = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := runtime.Skip(dAtA[iNdEx:]) @@ -4812,6 +4878,11 @@ type VaultAccount struct { // table via MsgUpdateVaultNAV. If empty, the vault admin is treated as the NAV // authority. It is rotated only via MsgUpdateNAVAuthority. NavAuthority string `protobuf:"bytes,29,opt,name=nav_authority,json=navAuthority,proto3" json:"nav_authority,omitempty"` + // fee_remainder is the sub-unit AUM fee, denominated in the underlying_asset, + // carried across periods. Each reconciliation adds it to the precise fee before + // truncation, collects the whole-unit portion, and stores the new fraction so + // truncation does not discard protocol revenue. A decimal string; empty is zero. + FeeRemainder string `protobuf:"bytes,30,opt,name=fee_remainder,json=feeRemainder,proto3" json:"fee_remainder,omitempty"` } func (x *VaultAccount) Reset() { @@ -5037,6 +5108,13 @@ func (x *VaultAccount) GetNavAuthority() string { return "" } +func (x *VaultAccount) GetFeeRemainder() string { + if x != nil { + return x.FeeRemainder + } + return "" +} + // VaultNAV is a single internal net asset value entry recording the price of one // asset denom held by a vault. The vault module is the sole source of truth for // these values; the NAV authority maintains them via MsgUpdateVaultNAV. @@ -5311,7 +5389,7 @@ var file_provlabs_vault_v1_vault_proto_rawDesc = []byte{ 0x6f, 0x67, 0x6f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x67, 0x6f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc9, 0x0c, 0x0a, 0x0c, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x41, 0x63, + 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x84, 0x0d, 0x0a, 0x0c, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x49, 0x0a, 0x0c, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, @@ -5412,72 +5490,76 @@ var file_provlabs_vault_v1_vault_proto_rawDesc = []byte{ 0x74, 0x79, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x09, 0x42, 0x18, 0xd2, 0xb4, 0x2d, 0x14, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x52, 0x0c, 0x6e, 0x61, 0x76, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, - 0x22, 0xaf, 0x02, 0x0a, 0x08, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x4e, 0x41, 0x56, 0x12, 0x14, 0x0a, - 0x05, 0x64, 0x65, 0x6e, 0x6f, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x64, 0x65, - 0x6e, 0x6f, 0x6d, 0x12, 0x35, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x62, 0x61, 0x73, 0x65, - 0x2e, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x69, 0x6e, 0x42, 0x04, 0xc8, - 0xde, 0x1f, 0x00, 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x76, 0x6f, - 0x6c, 0x75, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x2b, 0xc8, 0xde, 0x1f, 0x00, - 0xda, 0xde, 0x1f, 0x15, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x73, 0x64, 0x6b, 0x2e, 0x69, 0x6f, - 0x2f, 0x6d, 0x61, 0x74, 0x68, 0x2e, 0x49, 0x6e, 0x74, 0xd2, 0xb4, 0x2d, 0x0a, 0x63, 0x6f, 0x73, - 0x6d, 0x6f, 0x73, 0x2e, 0x49, 0x6e, 0x74, 0x52, 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, - 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x30, 0x0a, 0x14, 0x75, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x64, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x6c, - 0x6f, 0x63, 0x6b, 0x48, 0x65, 0x69, 0x67, 0x68, 0x74, 0x12, 0x47, 0x0a, 0x0c, 0x75, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, - 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x08, 0xc8, 0xde, 0x1f, - 0x00, 0x90, 0xdf, 0x1f, 0x01, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x54, 0x69, - 0x6d, 0x65, 0x22, 0xa5, 0x01, 0x0a, 0x0f, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x56, 0x61, - 0x75, 0x6c, 0x74, 0x4e, 0x41, 0x56, 0x12, 0x35, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x62, - 0x61, 0x73, 0x65, 0x2e, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x69, 0x6e, - 0x42, 0x04, 0xc8, 0xde, 0x1f, 0x00, 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x43, 0x0a, - 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x2b, 0xc8, - 0xde, 0x1f, 0x00, 0xda, 0xde, 0x1f, 0x15, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x73, 0x64, 0x6b, - 0x2e, 0x69, 0x6f, 0x2f, 0x6d, 0x61, 0x74, 0x68, 0x2e, 0x49, 0x6e, 0x74, 0xd2, 0xb4, 0x2d, 0x0a, - 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x49, 0x6e, 0x74, 0x52, 0x06, 0x76, 0x6f, 0x6c, 0x75, - 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x97, 0x01, 0x0a, 0x0e, 0x41, - 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x18, 0x0a, - 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x61, 0x0a, 0x05, 0x63, 0x6f, 0x69, 0x6e, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, - 0x62, 0x61, 0x73, 0x65, 0x2e, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x69, - 0x6e, 0x42, 0x30, 0xc8, 0xde, 0x1f, 0x00, 0xaa, 0xdf, 0x1f, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2f, 0x63, 0x6f, 0x73, - 0x6d, 0x6f, 0x73, 0x2d, 0x73, 0x64, 0x6b, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x43, 0x6f, - 0x69, 0x6e, 0x73, 0x52, 0x05, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x3a, 0x08, 0x88, 0xa0, 0x1f, 0x00, - 0xe8, 0xa0, 0x1f, 0x00, 0x22, 0xdb, 0x01, 0x0a, 0x0e, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, - 0x53, 0x77, 0x61, 0x70, 0x4f, 0x75, 0x74, 0x12, 0x2e, 0x0a, 0x05, 0x6f, 0x77, 0x6e, 0x65, 0x72, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x18, 0xd2, 0xb4, 0x2d, 0x14, 0x63, 0x6f, 0x73, 0x6d, - 0x6f, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, - 0x52, 0x05, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x3d, 0x0a, 0x0d, 0x76, 0x61, 0x75, 0x6c, 0x74, - 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x18, - 0xd2, 0xb4, 0x2d, 0x14, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x52, 0x0c, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x41, - 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x37, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x73, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, - 0x62, 0x61, 0x73, 0x65, 0x2e, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x69, - 0x6e, 0x42, 0x04, 0xc8, 0xde, 0x1f, 0x00, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x73, 0x12, - 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x65, 0x65, 0x6d, 0x5f, 0x64, 0x65, 0x6e, 0x6f, 0x6d, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x65, 0x65, 0x6d, 0x44, 0x65, 0x6e, - 0x6f, 0x6d, 0x42, 0xc2, 0x01, 0x0a, 0x15, 0x63, 0x6f, 0x6d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x6c, - 0x61, 0x62, 0x73, 0x2e, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x2e, 0x76, 0x31, 0x42, 0x0a, 0x56, 0x61, - 0x75, 0x6c, 0x74, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x2f, - 0x76, 0x61, 0x75, 0x6c, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x6c, 0x61, - 0x62, 0x73, 0x2f, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x76, 0x61, 0x75, 0x6c, - 0x74, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x50, 0x56, 0x58, 0xaa, 0x02, 0x11, 0x50, 0x72, 0x6f, 0x76, - 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x11, - 0x50, 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x5c, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x5c, 0x56, - 0x31, 0xe2, 0x02, 0x1d, 0x50, 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x5c, 0x56, 0x61, 0x75, - 0x6c, 0x74, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0xea, 0x02, 0x13, 0x50, 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x3a, 0x3a, 0x56, 0x61, - 0x75, 0x6c, 0x74, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x12, 0x39, 0x0a, 0x0d, 0x66, 0x65, 0x65, 0x5f, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x64, 0x65, + 0x72, 0x18, 0x1e, 0x20, 0x01, 0x28, 0x09, 0x42, 0x14, 0xd2, 0xb4, 0x2d, 0x10, 0x63, 0x6f, 0x73, + 0x6d, 0x6f, 0x73, 0x2e, 0x44, 0x65, 0x63, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x52, 0x0c, 0x66, + 0x65, 0x65, 0x52, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x64, 0x65, 0x72, 0x22, 0xaf, 0x02, 0x0a, 0x08, + 0x56, 0x61, 0x75, 0x6c, 0x74, 0x4e, 0x41, 0x56, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6e, 0x6f, + 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x64, 0x65, 0x6e, 0x6f, 0x6d, 0x12, 0x35, + 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, + 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x62, 0x61, 0x73, 0x65, 0x2e, 0x76, 0x31, 0x62, 0x65, + 0x74, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x69, 0x6e, 0x42, 0x04, 0xc8, 0xde, 0x1f, 0x00, 0x52, 0x05, + 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x2b, 0xc8, 0xde, 0x1f, 0x00, 0xda, 0xde, 0x1f, 0x15, 0x63, + 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x73, 0x64, 0x6b, 0x2e, 0x69, 0x6f, 0x2f, 0x6d, 0x61, 0x74, 0x68, + 0x2e, 0x49, 0x6e, 0x74, 0xd2, 0xb4, 0x2d, 0x0a, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x49, + 0x6e, 0x74, 0x52, 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x12, 0x30, 0x0a, 0x14, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x6c, + 0x6f, 0x63, 0x6b, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x12, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x48, 0x65, + 0x69, 0x67, 0x68, 0x74, 0x12, 0x47, 0x0a, 0x0c, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, + 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x08, 0xc8, 0xde, 0x1f, 0x00, 0x90, 0xdf, 0x1f, 0x01, + 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0xa5, 0x01, + 0x0a, 0x0f, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x4e, 0x41, + 0x56, 0x12, 0x35, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x62, 0x61, 0x73, 0x65, 0x2e, 0x76, + 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x69, 0x6e, 0x42, 0x04, 0xc8, 0xde, 0x1f, + 0x00, 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x76, 0x6f, 0x6c, 0x75, + 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x2b, 0xc8, 0xde, 0x1f, 0x00, 0xda, 0xde, + 0x1f, 0x15, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x73, 0x64, 0x6b, 0x2e, 0x69, 0x6f, 0x2f, 0x6d, + 0x61, 0x74, 0x68, 0x2e, 0x49, 0x6e, 0x74, 0xd2, 0xb4, 0x2d, 0x0a, 0x63, 0x6f, 0x73, 0x6d, 0x6f, + 0x73, 0x2e, 0x49, 0x6e, 0x74, 0x52, 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x16, 0x0a, + 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x97, 0x01, 0x0a, 0x0e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x12, 0x61, 0x0a, 0x05, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x62, 0x61, 0x73, 0x65, 0x2e, + 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x69, 0x6e, 0x42, 0x30, 0xc8, 0xde, + 0x1f, 0x00, 0xaa, 0xdf, 0x1f, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2f, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2d, 0x73, + 0x64, 0x6b, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x52, 0x05, + 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x3a, 0x08, 0x88, 0xa0, 0x1f, 0x00, 0xe8, 0xa0, 0x1f, 0x00, 0x22, + 0xdb, 0x01, 0x0a, 0x0e, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x53, 0x77, 0x61, 0x70, 0x4f, + 0x75, 0x74, 0x12, 0x2e, 0x0a, 0x05, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x18, 0xd2, 0xb4, 0x2d, 0x14, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x41, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x52, 0x05, 0x6f, 0x77, 0x6e, + 0x65, 0x72, 0x12, 0x3d, 0x0a, 0x0d, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x61, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x18, 0xd2, 0xb4, 0x2d, 0x14, 0x63, + 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x53, 0x74, 0x72, + 0x69, 0x6e, 0x67, 0x52, 0x0c, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x12, 0x37, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x2e, 0x62, 0x61, 0x73, 0x65, 0x2e, + 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x69, 0x6e, 0x42, 0x04, 0xc8, 0xde, + 0x1f, 0x00, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, + 0x64, 0x65, 0x65, 0x6d, 0x5f, 0x64, 0x65, 0x6e, 0x6f, 0x6d, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x72, 0x65, 0x64, 0x65, 0x65, 0x6d, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x42, 0xc2, 0x01, + 0x0a, 0x15, 0x63, 0x6f, 0x6d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x76, + 0x61, 0x75, 0x6c, 0x74, 0x2e, 0x76, 0x31, 0x42, 0x0a, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x76, 0x61, 0x75, 0x6c, 0x74, + 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x76, 0x61, + 0x75, 0x6c, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x76, 0x31, 0xa2, 0x02, + 0x03, 0x50, 0x56, 0x58, 0xaa, 0x02, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x2e, + 0x56, 0x61, 0x75, 0x6c, 0x74, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x6c, + 0x61, 0x62, 0x73, 0x5c, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1d, 0x50, + 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x5c, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x5c, 0x56, 0x31, + 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x13, 0x50, + 0x72, 0x6f, 0x76, 0x6c, 0x61, 0x62, 0x73, 0x3a, 0x3a, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x3a, 0x3a, + 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/proto/provlabs/vault/v1/vault.proto b/proto/provlabs/vault/v1/vault.proto index 94812e1e..c3c014c4 100644 --- a/proto/provlabs/vault/v1/vault.proto +++ b/proto/provlabs/vault/v1/vault.proto @@ -149,6 +149,12 @@ message VaultAccount { // table via MsgUpdateVaultNAV. If empty, the vault admin is treated as the NAV // authority. It is rotated only via MsgUpdateNAVAuthority. string nav_authority = 29 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // fee_remainder is the sub-unit AUM fee, denominated in the underlying_asset, + // carried across periods. Each reconciliation adds it to the precise fee before + // truncation, collects the whole-unit portion, and stores the new fraction so + // truncation does not discard protocol revenue. A decimal string; empty is zero. + string fee_remainder = 30 [(cosmos_proto.scalar) = "cosmos.DecString"]; } // VaultNAV is a single internal net asset value entry recording the price of one diff --git a/types/vault.pb.go b/types/vault.pb.go index a0432719..12b18cec 100644 --- a/types/vault.pb.go +++ b/types/vault.pb.go @@ -144,6 +144,11 @@ type VaultAccount struct { // table via MsgUpdateVaultNAV. If empty, the vault admin is treated as the NAV // authority. It is rotated only via MsgUpdateNAVAuthority. NavAuthority string `protobuf:"bytes,29,opt,name=nav_authority,json=navAuthority,proto3" json:"nav_authority,omitempty"` + // fee_remainder is the sub-unit AUM fee, denominated in the underlying_asset, + // carried across periods. Each reconciliation adds it to the precise fee before + // truncation, collects the whole-unit portion, and stores the new fraction so + // truncation does not discard protocol revenue. A decimal string; empty is zero. + FeeRemainder string `protobuf:"bytes,30,opt,name=fee_remainder,json=feeRemainder,proto3" json:"fee_remainder,omitempty"` } func (m *VaultAccount) Reset() { *m = VaultAccount{} } @@ -375,6 +380,13 @@ func (m *VaultAccount) GetNavAuthority() string { return "" } +func (m *VaultAccount) GetFeeRemainder() string { + if m != nil { + return m.FeeRemainder + } + return "" +} + // VaultNAV is a single internal net asset value entry recording the price of one // asset denom held by a vault. The vault module is the sole source of truth for // these values; the NAV authority maintains them via MsgUpdateVaultNAV. @@ -653,81 +665,82 @@ func init() { func init() { proto.RegisterFile("provlabs/vault/v1/vault.proto", fileDescriptor_2e0d78aae3177bea) } var fileDescriptor_2e0d78aae3177bea = []byte{ - // 1169 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xac, 0x56, 0x4d, 0x6f, 0x1b, 0x45, - 0x18, 0xf6, 0xc6, 0x49, 0x9a, 0x8e, 0xbf, 0x92, 0xa9, 0x5b, 0xb6, 0x85, 0xda, 0x26, 0x08, 0x64, - 0x04, 0xb1, 0x9b, 0x22, 0xc4, 0x87, 0x54, 0x15, 0xbb, 0xa1, 0x34, 0x07, 0x48, 0xb5, 0xa9, 0x72, - 0xe0, 0xb2, 0x1a, 0xef, 0xbe, 0xb1, 0x47, 0xd9, 0x9d, 0x59, 0xed, 0xcc, 0x3a, 0xc9, 0x3f, 0xe0, - 0xd8, 0x1b, 0xd7, 0x5e, 0xb8, 0x70, 0xe1, 0x92, 0x1f, 0x51, 0x6e, 0x51, 0x4e, 0x08, 0xa4, 0x14, - 0x25, 0x17, 0x7e, 0x06, 0x9a, 0x8f, 0x75, 0x3e, 0x2a, 0x39, 0x11, 0xe2, 0x64, 0xcf, 0xf3, 0x3e, - 0xef, 0xb3, 0xef, 0xbe, 0xfb, 0xcc, 0xbc, 0x83, 0xee, 0x27, 0x29, 0x1f, 0x47, 0x64, 0x20, 0xba, - 0x63, 0x92, 0x45, 0xb2, 0x3b, 0x5e, 0x35, 0x7f, 0x3a, 0x49, 0xca, 0x25, 0xc7, 0x4b, 0x79, 0xb8, - 0x63, 0xd0, 0xf1, 0xea, 0xbd, 0x46, 0xc0, 0x45, 0xcc, 0x45, 0x97, 0x64, 0x72, 0xd4, 0x1d, 0xaf, - 0x0e, 0x40, 0x92, 0x55, 0xbd, 0x30, 0x29, 0x93, 0xf8, 0x80, 0x08, 0x98, 0xc4, 0x03, 0x4e, 0x99, - 0x8d, 0xdf, 0x35, 0x71, 0x5f, 0xaf, 0xba, 0x66, 0x61, 0x43, 0xf5, 0x21, 0x1f, 0x72, 0x83, 0xab, - 0x7f, 0x16, 0x6d, 0x0e, 0x39, 0x1f, 0x46, 0xd0, 0xd5, 0xab, 0x41, 0xb6, 0xdd, 0x95, 0x34, 0x06, - 0x21, 0x49, 0x9c, 0x18, 0xc2, 0xf2, 0xef, 0x65, 0x54, 0xde, 0x52, 0xe5, 0xf5, 0x82, 0x80, 0x67, - 0x4c, 0xe2, 0x75, 0x54, 0x56, 0x4f, 0xf7, 0x89, 0x59, 0xbb, 0x4e, 0xcb, 0x69, 0x97, 0x1e, 0xb6, - 0x3a, 0xf6, 0x61, 0xba, 0x58, 0x5b, 0x59, 0xa7, 0x4f, 0x04, 0xd8, 0xbc, 0xfe, 0xec, 0xe1, 0x71, - 0xd3, 0xf1, 0x4a, 0x83, 0x33, 0x08, 0xf7, 0x51, 0x59, 0x72, 0x49, 0x22, 0x5f, 0x8c, 0x48, 0x0a, - 0xc2, 0x9d, 0xd1, 0x52, 0x77, 0x73, 0x29, 0x45, 0x9d, 0x48, 0x3d, 0xe1, 0x94, 0xf5, 0x67, 0x5f, - 0x1f, 0x37, 0x0b, 0x5e, 0x49, 0x27, 0x6d, 0xea, 0x1c, 0xfc, 0x31, 0x5a, 0xcc, 0x58, 0x08, 0x69, - 0xb4, 0x4f, 0xd9, 0xd0, 0x27, 0x42, 0x80, 0x74, 0x8b, 0x2d, 0xa7, 0x7d, 0xd3, 0xab, 0x9d, 0xe1, - 0x3d, 0x05, 0xe3, 0x0f, 0x50, 0x25, 0x21, 0xfb, 0x31, 0x30, 0xe9, 0x87, 0xc0, 0x78, 0xec, 0xce, - 0x6a, 0x5e, 0xd9, 0x82, 0x6b, 0x0a, 0xc3, 0x1d, 0x34, 0x47, 0xc2, 0x98, 0x32, 0x77, 0x4e, 0x05, - 0xfb, 0xee, 0xd1, 0xc1, 0x4a, 0xdd, 0xd6, 0xd3, 0x0b, 0xc3, 0x14, 0x84, 0xd8, 0x94, 0x29, 0x65, - 0x43, 0xcf, 0xd0, 0xf0, 0x33, 0x74, 0x3b, 0xc8, 0xd2, 0x54, 0x89, 0x52, 0x26, 0x21, 0x05, 0x21, - 0xfd, 0x94, 0x48, 0x70, 0xe7, 0x75, 0x7e, 0xfd, 0xe8, 0x60, 0x65, 0xd1, 0xe6, 0xaf, 0x41, 0x60, - 0x73, 0x6f, 0xd9, 0x94, 0x75, 0x9b, 0xe1, 0x11, 0x09, 0x4a, 0x29, 0x04, 0x41, 0x53, 0x08, 0x2f, - 0x29, 0xdd, 0x98, 0xa6, 0x64, 0x53, 0x2e, 0x28, 0x7d, 0x83, 0x96, 0x62, 0xca, 0x2e, 0xa9, 0x2c, - 0x4c, 0x51, 0xa9, 0xc5, 0x94, 0xbd, 0xa5, 0x40, 0xf6, 0x2e, 0x29, 0xdc, 0x9c, 0xaa, 0x40, 0xf6, - 0x2e, 0x28, 0xbc, 0x8f, 0xca, 0x09, 0xa4, 0x94, 0x87, 0xbe, 0x90, 0x24, 0x95, 0x2e, 0x6a, 0x39, - 0xed, 0xa2, 0x57, 0x32, 0xd8, 0xa6, 0x82, 0xf0, 0x87, 0xa8, 0x6a, 0x29, 0xca, 0x74, 0x3c, 0x93, - 0x6e, 0x49, 0x93, 0x2a, 0x06, 0x7d, 0x61, 0x40, 0xfc, 0x11, 0xaa, 0x89, 0x5d, 0x92, 0xf8, 0x94, - 0xf9, 0xc0, 0xc8, 0x20, 0x82, 0xd0, 0x2d, 0xb7, 0x9c, 0xf6, 0x82, 0x57, 0x51, 0xf0, 0x3a, 0xfb, - 0xd6, 0x80, 0xb8, 0x8d, 0x16, 0x35, 0x8f, 0x67, 0x72, 0x42, 0xac, 0x68, 0x62, 0x55, 0xe1, 0x1b, - 0x99, 0xcc, 0x99, 0x5f, 0x22, 0x77, 0x97, 0xca, 0x51, 0x98, 0x92, 0x5d, 0x12, 0xf9, 0x21, 0x44, - 0x64, 0xdf, 0x17, 0x10, 0x70, 0x16, 0x0a, 0xb7, 0xda, 0x72, 0xda, 0xb3, 0xde, 0x9d, 0xb3, 0xf8, - 0x9a, 0x0a, 0x6f, 0x9a, 0x28, 0xbe, 0x83, 0xe6, 0x13, 0x92, 0x09, 0x08, 0xdd, 0x9a, 0x56, 0xb6, - 0x2b, 0xfc, 0x14, 0x55, 0xcd, 0x3f, 0x7f, 0x40, 0x22, 0xc2, 0x02, 0x70, 0x17, 0xaf, 0xe7, 0xe5, - 0x8a, 0x49, 0xeb, 0x9b, 0x2c, 0x63, 0x51, 0xad, 0x93, 0x02, 0x11, 0x9c, 0xb9, 0x4b, 0xb9, 0x45, - 0x15, 0xe8, 0x69, 0x0c, 0x3f, 0x46, 0xd5, 0x41, 0x4a, 0xc3, 0x21, 0xf8, 0xc4, 0x38, 0xd2, 0xc5, - 0x57, 0x78, 0xb5, 0x62, 0xf8, 0x16, 0x54, 0x8d, 0xb7, 0x02, 0x79, 0x9f, 0x6e, 0x99, 0x86, 0x1a, - 0x34, 0x6f, 0xd3, 0x23, 0x54, 0xd1, 0xfb, 0xc9, 0x8f, 0x09, 0x23, 0x43, 0x48, 0xdd, 0xfa, 0x15, - 0x8f, 0x29, 0x6b, 0xfa, 0xf7, 0x86, 0xad, 0xbe, 0xc7, 0x36, 0x80, 0x7f, 0xc1, 0x05, 0xb7, 0xf5, - 0x07, 0xae, 0x6e, 0x03, 0x3c, 0x3f, 0x67, 0x84, 0x4f, 0x11, 0x3e, 0xc7, 0xcc, 0xcd, 0x70, 0x47, - 0x73, 0x17, 0x27, 0xdc, 0xdc, 0x0f, 0x1b, 0xe8, 0x16, 0xcf, 0xa4, 0x90, 0x84, 0x85, 0x7a, 0xcb, - 0x67, 0xb1, 0xbf, 0x0d, 0xe0, 0xbe, 0x73, 0xbd, 0x86, 0x2f, 0x9d, 0xcb, 0xed, 0x65, 0xf1, 0x53, - 0x00, 0xdc, 0x42, 0x65, 0x2b, 0xe2, 0x0f, 0x68, 0x22, 0x5c, 0xb7, 0xe5, 0xb4, 0x2b, 0x1e, 0x22, - 0x3a, 0xda, 0xa7, 0x89, 0xc0, 0x8f, 0xcd, 0x86, 0xca, 0x6d, 0x38, 0x26, 0x51, 0x06, 0xee, 0xdd, - 0xb7, 0xb6, 0xc3, 0x3a, 0x93, 0xb6, 0x13, 0xd5, 0x98, 0xb2, 0x4d, 0xed, 0xce, 0x2d, 0xc5, 0xc5, - 0x3d, 0x84, 0x27, 0x02, 0xca, 0x9f, 0x46, 0xe1, 0xde, 0x14, 0x85, 0x9a, 0x55, 0xd8, 0xc8, 0xa4, - 0x91, 0x78, 0x6c, 0xb6, 0xe4, 0xc5, 0x1a, 0xde, 0x9d, 0x5a, 0x03, 0xd9, 0xbb, 0x5c, 0x43, 0x2e, - 0x70, 0x56, 0xc3, 0x7b, 0x53, 0x6b, 0x30, 0x0a, 0x93, 0x1a, 0x1e, 0xa1, 0x0a, 0x23, 0x63, 0x5f, - 0x9d, 0xf1, 0x3c, 0xa5, 0x72, 0xdf, 0xbd, 0x7f, 0x95, 0x23, 0x18, 0x19, 0xf7, 0x72, 0xf6, 0xf2, - 0x6f, 0x33, 0x68, 0x41, 0xcf, 0x92, 0x1f, 0x7a, 0x5b, 0xb8, 0x8e, 0xe6, 0xcc, 0x29, 0xec, 0x68, - 0x8b, 0x9b, 0x05, 0xfe, 0x1c, 0xcd, 0x25, 0x29, 0x0d, 0xe0, 0xba, 0xb3, 0xc0, 0xb0, 0xf1, 0x13, - 0x34, 0x3f, 0xe6, 0x51, 0x16, 0x83, 0x39, 0xfb, 0xfb, 0x9f, 0xa8, 0xe0, 0x9f, 0xc7, 0xcd, 0xdb, - 0x26, 0x5d, 0x84, 0x3b, 0x1d, 0xca, 0xbb, 0x31, 0x91, 0x23, 0xf5, 0x7a, 0x47, 0x07, 0x2b, 0xe8, - 0xec, 0x65, 0x3d, 0x9b, 0xaa, 0x36, 0xb7, 0xe0, 0x59, 0x1a, 0x80, 0x1d, 0x0c, 0x76, 0x85, 0x1f, - 0xa0, 0x7a, 0x96, 0x84, 0x44, 0xaa, 0xdd, 0x1d, 0xf1, 0x60, 0xc7, 0x1f, 0x01, 0x1d, 0x8e, 0xa4, - 0x9e, 0x10, 0x45, 0x0f, 0xdb, 0x58, 0x5f, 0x85, 0x9e, 0xe9, 0x08, 0xfe, 0x0e, 0x95, 0xf3, 0x0c, - 0xe5, 0x66, 0x3d, 0x0b, 0x4a, 0x0f, 0xef, 0x75, 0xcc, 0xb0, 0xed, 0xe4, 0xc3, 0xb6, 0xf3, 0x22, - 0x1f, 0xb6, 0xfd, 0x05, 0x55, 0xf0, 0xcb, 0x37, 0x6a, 0x42, 0xda, 0x4c, 0x15, 0x5b, 0xfe, 0xc5, - 0x41, 0xb5, 0x75, 0x46, 0x25, 0x25, 0xd1, 0xa4, 0x71, 0x93, 0x16, 0x39, 0xff, 0xb1, 0x45, 0x33, - 0xff, 0x47, 0x8b, 0x8a, 0xe7, 0x5b, 0xb4, 0xfc, 0xb3, 0x83, 0xaa, 0xf9, 0xa0, 0xb7, 0x47, 0x99, - 0x8b, 0x6e, 0xe4, 0xc7, 0x93, 0xf9, 0xc2, 0xf9, 0x12, 0x13, 0x34, 0xa7, 0xae, 0x2c, 0x6a, 0xde, - 0x17, 0xa7, 0xbf, 0xc0, 0x03, 0x55, 0xe3, 0xaf, 0x6f, 0x9a, 0xed, 0x21, 0x95, 0xa3, 0x6c, 0xd0, - 0x09, 0x78, 0x6c, 0x2f, 0x35, 0xf6, 0x67, 0x45, 0x84, 0x3b, 0x5d, 0xb9, 0x9f, 0x80, 0xd0, 0x09, - 0xc2, 0x33, 0xca, 0x5f, 0x2f, 0xfc, 0xf4, 0xaa, 0x59, 0xf8, 0xe7, 0x55, 0xb3, 0xb0, 0xfc, 0x97, - 0x83, 0xaa, 0xcf, 0x41, 0x6f, 0x77, 0x6b, 0x65, 0x35, 0xe2, 0xf9, 0x2e, 0x83, 0xd4, 0xd4, 0x35, - 0x6d, 0xc4, 0x6b, 0x9a, 0x72, 0xbd, 0xbe, 0xa0, 0x4d, 0x8e, 0xdb, 0x99, 0xab, 0x5c, 0xaf, 0xe9, - 0xf9, 0x69, 0xfb, 0x05, 0x9a, 0xb7, 0xf7, 0x9b, 0xe2, 0xf5, 0x3e, 0x98, 0xa5, 0xab, 0x11, 0x9a, - 0x42, 0x08, 0x10, 0x5f, 0xb8, 0xae, 0x94, 0x0c, 0xa6, 0x6f, 0x2b, 0xfd, 0xaf, 0x5e, 0x9f, 0x34, - 0x9c, 0xc3, 0x93, 0x86, 0xf3, 0xf7, 0x49, 0xc3, 0x79, 0x79, 0xda, 0x28, 0x1c, 0x9e, 0x36, 0x0a, - 0x7f, 0x9c, 0x36, 0x0a, 0x3f, 0x36, 0xcf, 0xb5, 0xec, 0xd2, 0x35, 0x54, 0xf7, 0x6b, 0x30, 0xaf, - 0x5d, 0xf8, 0xd9, 0xbf, 0x01, 0x00, 0x00, 0xff, 0xff, 0x04, 0x49, 0xa7, 0x78, 0xa5, 0x0a, 0x00, - 0x00, + // 1192 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xac, 0x56, 0x3d, 0x6f, 0x1b, 0x47, + 0x13, 0xe6, 0x89, 0x92, 0x2c, 0x2f, 0xbf, 0xa4, 0x35, 0xed, 0x77, 0xed, 0x37, 0x26, 0x19, 0x05, + 0x09, 0x18, 0x24, 0x22, 0x2d, 0x07, 0x41, 0xe2, 0x00, 0x86, 0x43, 0xda, 0x71, 0xac, 0x22, 0xb1, + 0x71, 0x32, 0x5c, 0xa4, 0x39, 0x2c, 0xef, 0x46, 0xe4, 0xc2, 0x77, 0xbb, 0x87, 0xdb, 0x3d, 0xca, + 0xea, 0x53, 0xa4, 0x74, 0x97, 0xd6, 0x4d, 0x9a, 0x34, 0x69, 0xfc, 0x23, 0x5c, 0x1a, 0xae, 0x82, + 0x04, 0x90, 0x03, 0xa9, 0xc9, 0xcf, 0x08, 0xf6, 0xe3, 0xa8, 0x0f, 0x03, 0x94, 0x10, 0xa4, 0xe2, + 0xed, 0x33, 0xcf, 0x3c, 0x37, 0x9c, 0x9b, 0x9d, 0x19, 0x74, 0x3d, 0xcd, 0xc4, 0x34, 0xa6, 0x23, + 0xd9, 0x9f, 0xd2, 0x3c, 0x56, 0xfd, 0xe9, 0xa6, 0x7d, 0xe8, 0xa5, 0x99, 0x50, 0x02, 0xaf, 0x15, + 0xe6, 0x9e, 0x45, 0xa7, 0x9b, 0xd7, 0x5a, 0xa1, 0x90, 0x89, 0x90, 0x7d, 0x9a, 0xab, 0x49, 0x7f, + 0xba, 0x39, 0x02, 0x45, 0x37, 0xcd, 0xc1, 0xba, 0xcc, 0xec, 0x23, 0x2a, 0x61, 0x66, 0x0f, 0x05, + 0xe3, 0xce, 0x7e, 0xd5, 0xda, 0x03, 0x73, 0xea, 0xdb, 0x83, 0x33, 0x35, 0xc7, 0x62, 0x2c, 0x2c, + 0xae, 0x9f, 0x1c, 0xda, 0x1e, 0x0b, 0x31, 0x8e, 0xa1, 0x6f, 0x4e, 0xa3, 0x7c, 0xa7, 0xaf, 0x58, + 0x02, 0x52, 0xd1, 0x24, 0xb5, 0x84, 0xf5, 0x1f, 0x6b, 0xa8, 0xfa, 0x44, 0x87, 0x37, 0x08, 0x43, + 0x91, 0x73, 0x85, 0xb7, 0x50, 0x55, 0xbf, 0x3d, 0xa0, 0xf6, 0x4c, 0xbc, 0x8e, 0xd7, 0xad, 0xdc, + 0xec, 0xf4, 0xdc, 0xcb, 0x4c, 0xb0, 0x2e, 0xb2, 0xde, 0x90, 0x4a, 0x70, 0x7e, 0xc3, 0xc5, 0xd7, + 0xfb, 0x6d, 0xcf, 0xaf, 0x8c, 0x8e, 0x20, 0x3c, 0x44, 0x55, 0x25, 0x14, 0x8d, 0x03, 0x39, 0xa1, + 0x19, 0x48, 0xb2, 0x60, 0xa4, 0xae, 0x16, 0x52, 0x9a, 0x3a, 0x93, 0xba, 0x2b, 0x18, 0x1f, 0x2e, + 0xbe, 0xda, 0x6f, 0x97, 0xfc, 0x8a, 0x71, 0xda, 0x36, 0x3e, 0xf8, 0x63, 0xb4, 0x9a, 0xf3, 0x08, + 0xb2, 0x78, 0x8f, 0xf1, 0x71, 0x40, 0xa5, 0x04, 0x45, 0xca, 0x1d, 0xaf, 0x7b, 0xd1, 0x6f, 0x1c, + 0xe1, 0x03, 0x0d, 0xe3, 0x0f, 0x50, 0x2d, 0xa5, 0x7b, 0x09, 0x70, 0x15, 0x44, 0xc0, 0x45, 0x42, + 0x16, 0x0d, 0xaf, 0xea, 0xc0, 0x7b, 0x1a, 0xc3, 0x3d, 0xb4, 0x44, 0xa3, 0x84, 0x71, 0xb2, 0xa4, + 0x8d, 0x43, 0xf2, 0xe6, 0xe5, 0x46, 0xd3, 0xc5, 0x33, 0x88, 0xa2, 0x0c, 0xa4, 0xdc, 0x56, 0x19, + 0xe3, 0x63, 0xdf, 0xd2, 0xf0, 0x03, 0x74, 0x39, 0xcc, 0xb3, 0x4c, 0x8b, 0x32, 0xae, 0x20, 0x03, + 0xa9, 0x82, 0x8c, 0x2a, 0x20, 0xcb, 0xc6, 0xbf, 0xf9, 0xe6, 0xe5, 0xc6, 0xaa, 0xf3, 0xbf, 0x07, + 0xa1, 0xf3, 0xbd, 0xe4, 0x5c, 0xb6, 0x9c, 0x87, 0x4f, 0x15, 0x68, 0xa5, 0x08, 0x24, 0xcb, 0x20, + 0x3a, 0xa5, 0x74, 0x61, 0x9e, 0x92, 0x73, 0x39, 0xa1, 0xf4, 0x35, 0x5a, 0x4b, 0x18, 0x3f, 0xa5, + 0xb2, 0x32, 0x47, 0xa5, 0x91, 0x30, 0xfe, 0x8e, 0x02, 0x7d, 0x76, 0x4a, 0xe1, 0xe2, 0x5c, 0x05, + 0xfa, 0xec, 0x84, 0xc2, 0xfb, 0xa8, 0x9a, 0x42, 0xc6, 0x44, 0x14, 0x48, 0x45, 0x33, 0x45, 0x50, + 0xc7, 0xeb, 0x96, 0xfd, 0x8a, 0xc5, 0xb6, 0x35, 0x84, 0x3f, 0x44, 0x75, 0x47, 0xd1, 0x45, 0x27, + 0x72, 0x45, 0x2a, 0x86, 0x54, 0xb3, 0xe8, 0x63, 0x0b, 0xe2, 0x8f, 0x50, 0x43, 0xee, 0xd2, 0x34, + 0x60, 0x3c, 0x00, 0x4e, 0x47, 0x31, 0x44, 0xa4, 0xda, 0xf1, 0xba, 0x2b, 0x7e, 0x4d, 0xc3, 0x5b, + 0xfc, 0x1b, 0x0b, 0xe2, 0x2e, 0x5a, 0x35, 0x3c, 0x91, 0xab, 0x19, 0xb1, 0x66, 0x88, 0x75, 0x8d, + 0x3f, 0xcc, 0x55, 0xc1, 0xfc, 0x12, 0x91, 0x5d, 0xa6, 0x26, 0x51, 0x46, 0x77, 0x69, 0x1c, 0x44, + 0x10, 0xd3, 0xbd, 0x40, 0x42, 0x28, 0x78, 0x24, 0x49, 0xbd, 0xe3, 0x75, 0x17, 0xfd, 0x2b, 0x47, + 0xf6, 0x7b, 0xda, 0xbc, 0x6d, 0xad, 0xf8, 0x0a, 0x5a, 0x4e, 0x69, 0x2e, 0x21, 0x22, 0x0d, 0xa3, + 0xec, 0x4e, 0xf8, 0x3e, 0xaa, 0xdb, 0xa7, 0x60, 0x44, 0x63, 0xca, 0x43, 0x20, 0xab, 0xe7, 0xab, + 0xe5, 0x9a, 0x75, 0x1b, 0x5a, 0x2f, 0x5b, 0xa2, 0x46, 0x27, 0x03, 0x2a, 0x05, 0x27, 0x6b, 0x45, + 0x89, 0x6a, 0xd0, 0x37, 0x18, 0xbe, 0x83, 0xea, 0xa3, 0x8c, 0x45, 0x63, 0x08, 0xa8, 0xad, 0x48, + 0x82, 0xcf, 0xa8, 0xd5, 0x9a, 0xe5, 0x3b, 0x50, 0x27, 0xde, 0x09, 0x14, 0x79, 0xba, 0x64, 0x13, + 0x6a, 0xd1, 0x22, 0x4d, 0xb7, 0x51, 0xcd, 0xdc, 0xa7, 0x20, 0xa1, 0x9c, 0x8e, 0x21, 0x23, 0xcd, + 0x33, 0x5e, 0x53, 0x35, 0xf4, 0xef, 0x2c, 0x5b, 0x7f, 0x8f, 0x1d, 0x80, 0xe0, 0x44, 0x15, 0x5c, + 0x36, 0x1f, 0xb8, 0xbe, 0x03, 0xf0, 0xe8, 0x58, 0x21, 0x7c, 0x8a, 0xf0, 0x31, 0x66, 0x51, 0x0c, + 0x57, 0x0c, 0x77, 0x75, 0xc6, 0x2d, 0xea, 0xe1, 0x21, 0xba, 0x24, 0x72, 0x25, 0x15, 0xe5, 0x91, + 0xb9, 0xf2, 0x79, 0x12, 0xec, 0x00, 0x90, 0xff, 0x9d, 0x2f, 0xe1, 0x6b, 0xc7, 0x7c, 0x07, 0x79, + 0x72, 0x1f, 0x00, 0x77, 0x50, 0xd5, 0x89, 0x04, 0x23, 0x96, 0x4a, 0x42, 0x3a, 0x5e, 0xb7, 0xe6, + 0x23, 0x6a, 0xac, 0x43, 0x96, 0x4a, 0x7c, 0xc7, 0x5e, 0xa8, 0xa2, 0x0c, 0xa7, 0x34, 0xce, 0x81, + 0x5c, 0x7d, 0xe7, 0x3a, 0x6c, 0x71, 0xe5, 0x32, 0x51, 0x4f, 0x18, 0xdf, 0x36, 0xd5, 0xf9, 0x44, + 0x73, 0xf1, 0x00, 0xe1, 0x99, 0x80, 0xae, 0x4f, 0xab, 0x70, 0x6d, 0x8e, 0x42, 0xc3, 0x29, 0x3c, + 0xcc, 0x95, 0x95, 0xb8, 0x63, 0xaf, 0xe4, 0xc9, 0x18, 0xfe, 0x3f, 0x37, 0x06, 0xfa, 0xec, 0x74, + 0x0c, 0x85, 0xc0, 0x51, 0x0c, 0xef, 0xcd, 0x8d, 0xc1, 0x2a, 0xcc, 0x62, 0xb8, 0x8d, 0x6a, 0x9c, + 0x4e, 0x03, 0xdd, 0xe3, 0x45, 0xc6, 0xd4, 0x1e, 0xb9, 0x7e, 0x56, 0x45, 0x70, 0x3a, 0x1d, 0x14, + 0x6c, 0x7c, 0x0b, 0xd5, 0x74, 0x92, 0x33, 0x48, 0x28, 0xd3, 0xbd, 0x99, 0xb4, 0xe6, 0x74, 0x94, + 0xea, 0x0e, 0x80, 0x5f, 0x30, 0xd7, 0x7f, 0x5b, 0x40, 0x2b, 0x66, 0x0c, 0x7d, 0x3f, 0x78, 0x82, + 0x9b, 0x68, 0xc9, 0x36, 0x70, 0xcf, 0xdc, 0x0e, 0x7b, 0xc0, 0x9f, 0xa3, 0xa5, 0x34, 0x63, 0x21, + 0x9c, 0x77, 0x8c, 0x58, 0x36, 0xbe, 0x8b, 0x96, 0xa7, 0x22, 0xce, 0x13, 0xb0, 0x63, 0x63, 0xf8, + 0x89, 0x36, 0xfe, 0xb1, 0xdf, 0xbe, 0x6c, 0xdd, 0x65, 0xf4, 0xb4, 0xc7, 0x44, 0x3f, 0xa1, 0x6a, + 0xa2, 0x33, 0xf3, 0xe6, 0xe5, 0x06, 0x3a, 0xca, 0x93, 0xef, 0x5c, 0x75, 0x5f, 0x90, 0x22, 0xcf, + 0x42, 0x70, 0x33, 0xc5, 0x9d, 0xf0, 0x0d, 0xd4, 0xcc, 0xd3, 0x88, 0x2a, 0xdd, 0x18, 0x62, 0x11, + 0x3e, 0x0d, 0x26, 0xc0, 0xc6, 0x13, 0x65, 0x86, 0x4b, 0xd9, 0xc7, 0xce, 0x36, 0xd4, 0xa6, 0x07, + 0xc6, 0x82, 0xbf, 0x45, 0xd5, 0xc2, 0x43, 0x5f, 0x04, 0x33, 0x46, 0x2a, 0x37, 0xaf, 0xf5, 0xec, + 0x9c, 0xee, 0x15, 0x73, 0xba, 0xf7, 0xb8, 0x98, 0xd3, 0xc3, 0x15, 0x1d, 0xf0, 0xf3, 0xb7, 0x7a, + 0xb8, 0x3a, 0x4f, 0x6d, 0x5b, 0xff, 0xc5, 0x43, 0x8d, 0x2d, 0xce, 0x14, 0xa3, 0xf1, 0x2c, 0x71, + 0xb3, 0x14, 0x79, 0xff, 0x32, 0x45, 0x0b, 0xff, 0x45, 0x8a, 0xca, 0xc7, 0x53, 0xb4, 0xfe, 0xb3, + 0x87, 0xea, 0xc5, 0x8e, 0xe0, 0xba, 0x20, 0x41, 0x17, 0x8a, 0xce, 0x66, 0xbf, 0x70, 0x71, 0xc4, + 0x14, 0x2d, 0xe9, 0x6d, 0x47, 0xaf, 0x0a, 0xe5, 0xf9, 0x7f, 0xe0, 0x86, 0x8e, 0xf1, 0xd7, 0xb7, + 0xed, 0xee, 0x98, 0xa9, 0x49, 0x3e, 0xea, 0x85, 0x22, 0x71, 0xfb, 0x90, 0xfb, 0xd9, 0x90, 0xd1, + 0xd3, 0xbe, 0xda, 0x4b, 0x41, 0x1a, 0x07, 0xe9, 0x5b, 0xe5, 0xaf, 0x56, 0x7e, 0x7a, 0xd1, 0x2e, + 0xfd, 0xfd, 0xa2, 0x5d, 0x5a, 0xff, 0xd3, 0x43, 0xf5, 0x47, 0x60, 0x3a, 0x85, 0xbb, 0x05, 0x7a, + 0x3b, 0x10, 0xbb, 0x1c, 0x32, 0x1b, 0xd7, 0xbc, 0xed, 0xc0, 0xd0, 0xf4, 0x85, 0x31, 0xbb, 0xdd, + 0xac, 0x53, 0x2f, 0x9c, 0x75, 0x61, 0x0c, 0xbd, 0x68, 0xd4, 0x5f, 0xa0, 0x65, 0xb7, 0x1a, 0x95, + 0xcf, 0xf7, 0xc1, 0x1c, 0x5d, 0x4f, 0xdf, 0x0c, 0x22, 0x80, 0xe4, 0xc4, 0xa6, 0x53, 0xb1, 0x98, + 0x59, 0x74, 0x86, 0xb7, 0x5e, 0x1d, 0xb4, 0xbc, 0xd7, 0x07, 0x2d, 0xef, 0xaf, 0x83, 0x96, 0xf7, + 0xfc, 0xb0, 0x55, 0x7a, 0x7d, 0xd8, 0x2a, 0xfd, 0x7e, 0xd8, 0x2a, 0xfd, 0xd0, 0x3e, 0x96, 0xb2, + 0x53, 0x1b, 0xac, 0xc9, 0xd7, 0x68, 0xd9, 0x54, 0xe1, 0x67, 0xff, 0x04, 0x00, 0x00, 0xff, 0xff, + 0x78, 0x26, 0xc1, 0xa5, 0xe0, 0x0a, 0x00, 0x00, } func (m *VaultAccount) Marshal() (dAtA []byte, err error) { @@ -750,6 +763,15 @@ func (m *VaultAccount) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l + if len(m.FeeRemainder) > 0 { + i -= len(m.FeeRemainder) + copy(dAtA[i:], m.FeeRemainder) + i = encodeVarintVault(dAtA, i, uint64(len(m.FeeRemainder))) + i-- + dAtA[i] = 0x1 + i-- + dAtA[i] = 0xf2 + } if len(m.NavAuthority) > 0 { i -= len(m.NavAuthority) copy(dAtA[i:], m.NavAuthority) @@ -1333,6 +1355,10 @@ func (m *VaultAccount) Size() (n int) { if l > 0 { n += 2 + l + sovVault(uint64(l)) } + l = len(m.FeeRemainder) + if l > 0 { + n += 2 + l + sovVault(uint64(l)) + } return n } @@ -2265,6 +2291,38 @@ func (m *VaultAccount) Unmarshal(dAtA []byte) error { } m.NavAuthority = string(dAtA[iNdEx:postIndex]) iNdEx = postIndex + case 30: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field FeeRemainder", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowVault + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthVault + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthVault + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.FeeRemainder = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := skipVault(dAtA[iNdEx:]) From 49ef01a5dd8a276770ffcdf5ea4a4fb41df741d8 Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 06:57:43 -0600 Subject: [PATCH 02/13] make calculation decimal --- interest/interest.go | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/interest/interest.go b/interest/interest.go index 574c3b3d..704ac73e 100644 --- a/interest/interest.go +++ b/interest/interest.go @@ -79,40 +79,54 @@ func CalculateInterestEarned(principal sdk.Coin, rate string, periodSeconds int6 return interestAmountDec.TruncateInt(), nil } -// CalculateAUMFee computes the technology fee based on the vault's AUM and configured basis points. +// CalculateAUMFeeDec computes the technology fee based on the vault's AUM and configured +// basis points, returning the precise decimal result without truncation. // // Formula: // // Fee = (AUM * (bips / 10000) * duration) / 31536000 (SecondsPerYear) // -// Returns the fee as a truncated sdkmath.Int. -func CalculateAUMFee(aum sdkmath.Int, bips uint32, duration int64) (fee sdkmath.Int, err error) { +// Callers that collect the fee should carry the fractional part across periods (see the +// vault's fee_remainder) so repeated truncation does not discard protocol revenue. +func CalculateAUMFeeDec(aum sdkmath.Int, bips uint32, duration int64) (fee sdkmath.LegacyDec, err error) { defer func() { if rec := recover(); rec != nil { - fee = sdkmath.Int{} + fee = sdkmath.LegacyDec{} err = fmt.Errorf("aum fee calculation overflow (aum=%s, bips=%d, duration=%d): %v", aum, bips, duration, rec) } }() if aum.IsNegative() { - return sdkmath.Int{}, errors.New("aum cannot be negative") + return sdkmath.LegacyDec{}, errors.New("aum cannot be negative") } if duration < 0 { - return sdkmath.Int{}, errors.New("duration cannot be negative") + return sdkmath.LegacyDec{}, errors.New("duration cannot be negative") } if duration == 0 || aum.IsZero() || bips == 0 { - return sdkmath.ZeroInt(), nil + return sdkmath.LegacyZeroDec(), nil } rate := sdkmath.LegacyNewDec(int64(bips)).Quo(sdkmath.LegacyNewDec(10_000)) - - // Fee = (AUM * rate * duration) / SecondsPerYear aumDec := sdkmath.LegacyNewDecFromInt(aum) - durationDec := sdkmath.LegacyNewDec(duration) yearDec := sdkmath.LegacyNewDec(SecondsPerYear) - feeDec := aumDec.Mul(rate).Mul(durationDec).Quo(yearDec) + return aumDec.Mul(rate).Mul(durationDec).Quo(yearDec), nil +} + +// CalculateAUMFee computes the technology fee based on the vault's AUM and configured basis points. +// +// Formula: +// +// Fee = (AUM * (bips / 10000) * duration) / 31536000 (SecondsPerYear) +// +// Returns the fee as a truncated sdkmath.Int. Use CalculateAUMFeeDec when the fractional +// part must be preserved across reconciliation periods. +func CalculateAUMFee(aum sdkmath.Int, bips uint32, duration int64) (sdkmath.Int, error) { + feeDec, err := CalculateAUMFeeDec(aum, bips, duration) + if err != nil { + return sdkmath.Int{}, err + } return feeDec.TruncateInt(), nil } From e67bfe225b4ea119c97eaedfdc9dc2b727bcb90d Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 07:33:28 -0600 Subject: [PATCH 03/13] add calculation and validation, update tests --- keeper/reconcile.go | 53 +++++++++++++++++++++++++++++------ keeper/reconcile_test.go | 5 ++-- types/vault.go | 10 +++++++ types/vault_test.go | 60 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/keeper/reconcile.go b/keeper/reconcile.go index 4a3e3875..fd12749e 100644 --- a/keeper/reconcile.go +++ b/keeper/reconcile.go @@ -255,9 +255,9 @@ func (k Keeper) PerformVaultFeeTransfer(ctx sdk.Context, vault *types.VaultAccou return fmt.Errorf("failed to get TVV: %w", err) } - newFeePayment, err := k.CalculateAccruedAUMFeePayment(ctx, *vault, tvv) + newFeePayment, err := k.AccrueAUMFeePayment(ctx, vault, tvv) if err != nil { - return fmt.Errorf("failed to calculate accrued AUM fee payment: %w", err) + return fmt.Errorf("failed to accrue AUM fee: %w", err) } totalOutstandingAmount, err := vault.OutstandingAumFee.Amount.SafeAdd(newFeePayment.Amount) @@ -403,17 +403,54 @@ func (k Keeper) CalculateAccruedAUMFee(ctx sdk.Context, vault types.VaultAccount return interest.CalculateAUMFee(totalAssets, vault.AumFeeBips, duration) } -// CalculateAccruedAUMFeePayment calculates the AUM fees that would have accrued for the vault -// from its FeePeriodStart to the current block time, converted to the vault's PaymentDenom. -func (k Keeper) CalculateAccruedAUMFeePayment(ctx sdk.Context, vault types.VaultAccount, totalAssets sdkmath.Int) (sdk.Coin, error) { - feeUnderlying, err := k.CalculateAccruedAUMFee(ctx, vault, totalAssets) +// parseFeeRemainder interprets a vault's persisted FeeRemainder string, treating an empty +// value as zero. Legacy vaults created before the field existed carry an empty remainder. +func parseFeeRemainder(remainder string) (sdkmath.LegacyDec, error) { + if remainder == "" { + return sdkmath.LegacyZeroDec(), nil + } + return sdkmath.LegacyNewDecFromStr(remainder) +} + +// AccrueAUMFeePayment computes the AUM fee accrued from the vault's FeePeriodStart to the +// current block time, folds in the fractional FeeRemainder carried from prior periods, and +// returns the collectible whole-unit portion converted to the vault's PaymentDenom. The +// leftover sub-unit fraction is written back to vault.FeeRemainder so that repeated truncation +// over short accrual windows does not discard protocol revenue. +// +// This method mutates vault.FeeRemainder but does not persist the vault; the caller is +// responsible for persistence. It returns a zero PaymentDenom coin when accrual has not +// started, no time has elapsed, or no whole unit is yet due. +func (k Keeper) AccrueAUMFeePayment(ctx sdk.Context, vault *types.VaultAccount, totalAssets sdkmath.Int) (sdk.Coin, error) { + zero := sdk.NewCoin(vault.PaymentDenom, sdkmath.ZeroInt()) + + if vault.FeePeriodStart == 0 { + return zero, nil + } + duration := ctx.BlockTime().Unix() - vault.FeePeriodStart + if duration <= 0 { + return zero, nil + } + + feeDec, err := interest.CalculateAUMFeeDec(totalAssets, vault.AumFeeBips, duration) if err != nil { return sdk.Coin{}, fmt.Errorf("failed to calculate accrued AUM fee: %w", err) } + + carried, err := parseFeeRemainder(vault.FeeRemainder) + if err != nil { + return sdk.Coin{}, fmt.Errorf("failed to parse fee remainder %q: %w", vault.FeeRemainder, err) + } + + combined := feeDec.Add(carried) + feeUnderlying := combined.TruncateInt() + vault.FeeRemainder = combined.Sub(sdkmath.LegacyNewDecFromInt(feeUnderlying)).String() + if feeUnderlying.IsZero() { - return sdk.NewCoin(vault.PaymentDenom, sdkmath.ZeroInt()), nil + return zero, nil } - feePayment, err := k.FromUnderlyingAssetAmount(ctx, vault, feeUnderlying, vault.PaymentDenom) + + feePayment, err := k.FromUnderlyingAssetAmount(ctx, *vault, feeUnderlying, vault.PaymentDenom) if err != nil { return sdk.Coin{}, fmt.Errorf("failed to convert accrued fee to payment denom: %w", err) } diff --git a/keeper/reconcile_test.go b/keeper/reconcile_test.go index 23bbe335..3871c095 100644 --- a/keeper/reconcile_test.go +++ b/keeper/reconcile_test.go @@ -2213,7 +2213,7 @@ func (s *TestSuite) TestKeeper_AccrualCalculations() { } }) - s.Run("CalculateAccruedAUMFeePayment", func() { + s.Run("AccrueAUMFeePayment", func() { s.SetupTest() vault := setup() @@ -2249,6 +2249,7 @@ func (s *TestSuite) TestKeeper_AccrualCalculations() { s.Run(tc.name, func() { vault.FeePeriodStart = tc.start vault.PaymentDenom = tc.paymentDenom + vault.FeeRemainder = "" s.k.AuthKeeper.SetAccount(s.ctx, vault) if tc.setupNav { @@ -2258,7 +2259,7 @@ func (s *TestSuite) TestKeeper_AccrualCalculations() { s.setVaultNAV(vault, tc.paymentDenom, sdk.NewInt64Coin(underlyingDenom, 1), 2) } - amt, err := s.k.CalculateAccruedAUMFeePayment(s.ctx, *vault, underlying.Amount) + amt, err := s.k.AccrueAUMFeePayment(s.ctx, vault, underlying.Amount) s.Require().NoError(err, "accrued AUM fee payment calculation failed for case: %s", tc.name) s.Require().Equal(tc.expected, amt, "accrued AUM fee payment coin mismatch for case: %s", tc.name) }) diff --git a/types/vault.go b/types/vault.go index 9e82a410..1682e9af 100644 --- a/types/vault.go +++ b/types/vault.go @@ -295,6 +295,16 @@ func (v VaultAccount) Validate() error { return fmt.Errorf("outstanding AUM fee denom %s does not match payment denom %s", v.OutstandingAumFee.Denom, v.PaymentDenom) } + if v.FeeRemainder != "" { + remainder, err := sdkmath.LegacyNewDecFromStr(v.FeeRemainder) + if err != nil { + return fmt.Errorf("invalid fee remainder: %s", v.FeeRemainder) + } + if remainder.IsNegative() { + return fmt.Errorf("fee remainder cannot be negative: %s", v.FeeRemainder) + } + } + if err := ValidateSwapLimits(v.MinSwapInValue, v.MaxSwapInValue); err != nil { return fmt.Errorf("failed to validate swap-in limits: %w", err) } diff --git a/types/vault_test.go b/types/vault_test.go index 8fca2805..a9539add 100644 --- a/types/vault_test.go +++ b/types/vault_test.go @@ -711,6 +711,66 @@ func TestVaultAccount_Validate(t *testing.T) { }, expectedErr: "", }, + { + name: "empty fee remainder is valid (treated as zero)", + vaultAccount: types.VaultAccount{ + BaseAccount: baseAcc, + Admin: validAdmin, + TotalShares: sdk.NewInt64Coin(validDenom, 0), + UnderlyingAsset: "uusd", + PaymentDenom: "uusd", + CurrentInterestRate: validInterest, + DesiredInterestRate: validInterest, + OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), + FeeRemainder: "", + }, + expectedErr: "", + }, + { + name: "valid fractional fee remainder", + vaultAccount: types.VaultAccount{ + BaseAccount: baseAcc, + Admin: validAdmin, + TotalShares: sdk.NewInt64Coin(validDenom, 0), + UnderlyingAsset: "uusd", + PaymentDenom: "uusd", + CurrentInterestRate: validInterest, + DesiredInterestRate: validInterest, + OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), + FeeRemainder: "0.500000000000000000", + }, + expectedErr: "", + }, + { + name: "malformed fee remainder string", + vaultAccount: types.VaultAccount{ + BaseAccount: baseAcc, + Admin: validAdmin, + TotalShares: sdk.NewInt64Coin(validDenom, 0), + UnderlyingAsset: "uusd", + PaymentDenom: "uusd", + CurrentInterestRate: validInterest, + DesiredInterestRate: validInterest, + OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), + FeeRemainder: invalidInterest, + }, + expectedErr: "invalid fee remainder", + }, + { + name: "negative fee remainder", + vaultAccount: types.VaultAccount{ + BaseAccount: baseAcc, + Admin: validAdmin, + TotalShares: sdk.NewInt64Coin(validDenom, 0), + UnderlyingAsset: "uusd", + PaymentDenom: "uusd", + CurrentInterestRate: validInterest, + DesiredInterestRate: validInterest, + OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), + FeeRemainder: "-0.5", + }, + expectedErr: "fee remainder cannot be negative", + }, } for _, tc := range tests { From 1a5d99ed74e0957f24b19eb2f9b4a55a7bd6ab1f Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 07:41:47 -0600 Subject: [PATCH 04/13] add migrations --- keeper/migrations.go | 30 ++++++++++++++++++++++++++++++ keeper/migrator.go | 14 +++++++++----- module.go | 10 ++++++---- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/keeper/migrations.go b/keeper/migrations.go index 58ac7ed5..d07141ca 100644 --- a/keeper/migrations.go +++ b/keeper/migrations.go @@ -59,6 +59,36 @@ func (k Keeper) MigrateVaultAccountPaymentDenomDefaults(ctx sdk.Context) error { return nil } +// migrateVaultFeeRemainderDefaults normalizes the FeeRemainder field on legacy +// VaultAccount state created before the field existed. +// +// The AUM fee dust accumulator (see https://github.com/ProvLabs/vault/issues/224) +// carries the sub-unit fractional fee in FeeRemainder across reconciliation periods. +// Vaults persisted before this field existed decode it as an empty string. Runtime +// code already treats empty as zero, so this migration is a cosmetic normalization +// that gives every vault an explicit, parseable value. +// +// It runs as part of the Migrator.Migrate1to2 handler and is idempotent; vaults +// that already carry a value are left unchanged. +func (k Keeper) migrateVaultFeeRemainderDefaults(ctx sdk.Context) error { + zero := math.LegacyZeroDec().String() + + for _, acc := range k.AuthKeeper.GetAllAccounts(ctx) { + v, ok := acc.(*types.VaultAccount) + if !ok { + continue + } + if v.FeeRemainder == "" { + v.FeeRemainder = zero + if err := k.SetVaultAccount(ctx, v); err != nil { + return fmt.Errorf("failed to update vault account %s during fee remainder migration: %w", v.Address, err) + } + } + } + + return nil +} + // migrateInternalNAVSeedFromMarker seeds the Internal NAV table for every vault // that will need an Internal NAV entry once the pricing engine switches to // Internal-NAV-only reads. diff --git a/keeper/migrator.go b/keeper/migrator.go index 113fbbb4..0975eef5 100644 --- a/keeper/migrator.go +++ b/keeper/migrator.go @@ -18,10 +18,14 @@ func NewMigrator(k *Keeper) Migrator { return Migrator{keeper: k} } -// Migrate1to2 advances the vault module from ConsensusVersion 1 to 2 by -// seeding the Internal NAV table from Marker NAVs and defaulting nav_authority -// to the vault admin when unset. The work is delegated to the unexported -// migrateInternalNAVSeedFromMarker, which is idempotent across retries. +// Migrate1to2 advances the vault module from ConsensusVersion 1 to 2 by seeding +// the Internal NAV table from Marker NAVs, defaulting nav_authority to the vault +// admin when unset, and normalizing the FeeRemainder field on legacy VaultAccount +// state to an explicit zero. The work is delegated to unexported helpers, each of +// which is idempotent across retries. func (m Migrator) Migrate1to2(ctx sdk.Context) error { - return m.keeper.migrateInternalNAVSeedFromMarker(ctx) + if err := m.keeper.migrateInternalNAVSeedFromMarker(ctx); err != nil { + return err + } + return m.keeper.migrateVaultFeeRemainderDefaults(ctx) } diff --git a/module.go b/module.go index c1160c8b..02dbdc44 100644 --- a/module.go +++ b/module.go @@ -33,10 +33,12 @@ import ( // ConsensusVersion defines the current x/vault module consensus version. // // Bumped from 1 to 2 to accompany Migrator.Migrate1to2, which seeds the -// Internal NAV table from the Marker module's NAV store and defaults -// nav_authority to the vault admin. A v1->v2 migration handler is registered -// in RegisterServices so the SDK module manager can drive the migration via -// RunMigrations when an upstream upgrade handler advances the chain. +// Internal NAV table from the Marker module's NAV store, defaults nav_authority +// to the vault admin, and normalizes the FeeRemainder field on legacy +// VaultAccount state to an explicit zero for the AUM fee dust accumulator. A +// v1->v2 migration handler is registered in RegisterServices so the SDK module +// manager can drive the migration via RunMigrations when an upstream upgrade +// handler advances the chain. const ConsensusVersion = 2 var ( From 1e081df64fb2a36f4ac7360c756902cf2f294196 Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 07:43:40 -0600 Subject: [PATCH 05/13] add change log --- .changelog/unreleased/improvements/224-aum-fee-dust.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/unreleased/improvements/224-aum-fee-dust.md diff --git a/.changelog/unreleased/improvements/224-aum-fee-dust.md b/.changelog/unreleased/improvements/224-aum-fee-dust.md new file mode 100644 index 00000000..4c58f605 --- /dev/null +++ b/.changelog/unreleased/improvements/224-aum-fee-dust.md @@ -0,0 +1 @@ +* Accumulate sub-unit AUM fee remainder across reconciliation periods so frequent, short accrual windows no longer discard protocol revenue to truncation [#224](https://github.com/provlabs/vault/issues/224). From c39164eb9987e9ba7e3cb9027ddca7fa708fb730 Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 07:46:46 -0600 Subject: [PATCH 06/13] update specs --- spec/02_state.md | 2 +- spec/06_blocker.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/02_state.md b/spec/02_state.md index 25ccc40c..65830526 100644 --- a/spec/02_state.md +++ b/spec/02_state.md @@ -34,7 +34,7 @@ Each vault is an `x/auth` account implementing `VaultAccountI`. The canonical re - **Total supply-of-record:** `total_shares` (authoritative across chains; includes locally and externally held shares) - **Bridging controls:** `bridge_address` (the sole authorized external address) and `bridge_enabled` (feature gate) - **Asset Management:** optional `asset_manager` address with delegated authority; it is also the sole authority for P2P settlement (`AcceptAsset`/`RejectAsset`). -- **AUM Fee State:** `fee_period_start`, `fee_period_timeout`, and `outstanding_aum_fee`. +- **AUM Fee State:** `fee_period_start`, `fee_period_timeout`, `outstanding_aum_fee`, and `fee_remainder` (the sub-unit fractional fee, in underlying units, carried across periods so truncation does not discard revenue). - **NAV Authority:** optional `nav_authority` address authorized to mutate the vault's internal NAV table via `MsgUpdateVaultNAV`; the admin acts as NAV authority when unset. `VaultAccount` enforces invariants (e.g., payment denom cannot equal underlying, rate bounds, etc.) and provides helpers like `AcceptedDenoms()` and `ValidateAcceptedDenom`. :contentReference[oaicite:1]{index=1} diff --git a/spec/06_blocker.md b/spec/06_blocker.md index 1839a75e..b24e6b5a 100644 --- a/spec/06_blocker.md +++ b/spec/06_blocker.md @@ -85,6 +85,7 @@ Reconciles the 15 bps AUM technology fee for vaults whose fee timeout has elapse 3. **Attempt Atomic Reconciliation** (via `atomicallyReconcileFee` using `CacheContext`): - **PerformVaultFeeTransfer**: - Computes fee based on **Gross TVV**. + - Adds the fractional `fee_remainder` carried from prior periods, collects only the whole-unit portion, and stores the new sub-unit fraction back to `fee_remainder` so frequent short accrual windows do not discard revenue to truncation. - Collects from principal marker into the configured ProvLabs collection address. - **Success (Partial/Full Collection)**: If the marker lacks liquidity, the uncollected remainder is recorded in `outstanding_aum_fee`. This is considered a successful transfer. - **Schedules next fee timeout** and commits state changes. From 6c748cf78624f7b0522cb0f9e9497634a58aaf67 Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 07:57:41 -0600 Subject: [PATCH 07/13] add tests --- interest/interest_test.go | 120 ++++++++++++++++++++++++++++++++++++++ keeper/migrations_test.go | 55 +++++++++++++++++ keeper/reconcile_test.go | 41 +++++++++++++ 3 files changed, 216 insertions(+) diff --git a/interest/interest_test.go b/interest/interest_test.go index 0d91cd5b..e03e841a 100644 --- a/interest/interest_test.go +++ b/interest/interest_test.go @@ -210,6 +210,126 @@ func TestCalculateAUMFee(t *testing.T) { } } +func TestCalculateAUMFeeDec(t *testing.T) { + tests := []struct { + name string + aum sdkmath.Int + bips uint32 + duration int64 + expectedFee sdkmath.LegacyDec + expectErr bool + expectedErrContains string + }{ + { + name: "zero AUM", + aum: sdkmath.ZeroInt(), + bips: 15, + duration: interest.SecondsPerYear, + expectedFee: sdkmath.LegacyZeroDec(), + }, + { + name: "zero duration", + aum: sdkmath.NewInt(1_000_000), + bips: 15, + duration: 0, + expectedFee: sdkmath.LegacyZeroDec(), + }, + { + name: "zero bips", + aum: sdkmath.NewInt(1_000_000), + bips: 0, + duration: interest.SecondsPerYear, + expectedFee: sdkmath.LegacyZeroDec(), + }, + { + name: "1 year at 15 bps (1,000,000 AUM)", + aum: sdkmath.NewInt(1_000_000), + bips: 15, + duration: interest.SecondsPerYear, + expectedFee: sdkmath.LegacyNewDec(1_500), + }, + { + name: "sub-unit fee is preserved as a fraction, not truncated to zero", + aum: sdkmath.NewInt(100), + bips: 15, + duration: interest.SecondsPerYear, + expectedFee: sdkmath.LegacyMustNewDecFromStr("0.15"), + }, + { + name: "negative duration errors", + aum: sdkmath.NewInt(1_000_000), + bips: 15, + duration: -1, + expectErr: true, + expectedErrContains: "duration cannot be negative", + }, + { + name: "negative aum errors", + aum: sdkmath.NewInt(-1_000_000), + bips: 15, + duration: interest.SecondsPerYear, + expectErr: true, + expectedErrContains: "aum cannot be negative", + }, + { + name: "near-max AUM over a one year period overflows the decimal multiply", + aum: sdkmath.NewIntFromBigInt(new(big.Int).Lsh(big.NewInt(1), 255)), + bips: 10_000, + duration: interest.SecondsPerYear, + expectErr: true, + expectedErrContains: "overflow", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fee, err := interest.CalculateAUMFeeDec(tc.aum, tc.bips, tc.duration) + if tc.expectErr { + require.Errorf(t, err, "test case %q: expected an error but got none", tc.name) + require.Containsf(t, err.Error(), tc.expectedErrContains, "test case %q: error %q should contain %q", tc.name, err, tc.expectedErrContains) + require.Truef(t, fee.IsNil(), "test case %q: fee should be the zero-value LegacyDec on error, got %s", tc.name, fee) + } else { + require.NoErrorf(t, err, "test case %q: unexpected error during AUM fee decimal calculation", tc.name) + require.Truef(t, tc.expectedFee.Equal(fee), "test case %q: fee amount mismatch; expected %s, got %s", tc.name, tc.expectedFee, fee) + } + }) + } +} + +func TestCumulativeAUMFeeTruncationLoss(t *testing.T) { + aum := sdkmath.NewInt(100_000_000) + bips := uint32(50) + blockTime := int64(6) + iterations := 10_000 + + sumIterativeInt := sdkmath.ZeroInt() + sumIterativeDec := sdkmath.LegacyZeroDec() + for i := range iterations { + feeInt, err := interest.CalculateAUMFee(aum, bips, blockTime) + require.NoErrorf(t, err, "iteration %d: truncated fee calculation failed", i) + sumIterativeInt = sumIterativeInt.Add(feeInt) + + feeDec, err := interest.CalculateAUMFeeDec(aum, bips, blockTime) + require.NoErrorf(t, err, "iteration %d: decimal fee calculation failed", i) + sumIterativeDec = sumIterativeDec.Add(feeDec) + } + + totalTime := blockTime * int64(iterations) + bulkDec, err := interest.CalculateAUMFeeDec(aum, bips, totalTime) + require.NoError(t, err, "bulk decimal fee calculation failed") + + require.Truef(t, sumIterativeInt.IsZero(), + "per-period integer truncation should collect nothing over short windows, got %s", sumIterativeInt) + require.Truef(t, bulkDec.TruncateInt().IsPositive(), + "bulk calculation should accrue a positive whole fee, got %s", bulkDec) + require.Truef(t, sumIterativeDec.GT(sumIterativeInt.ToLegacyDec()), + "decimal accumulation should retain the fee that integer truncation discards (dec=%s, int=%s)", sumIterativeDec, sumIterativeInt) + + drift := bulkDec.Sub(sumIterativeDec).Abs() + require.Truef(t, drift.LT(sdkmath.LegacyMustNewDecFromStr("0.000001")), + "decimal accumulation should match the bulk calculation within rounding, drift=%s", drift) +} + func TestCalculateExpiration(t *testing.T) { startTime := int64(1752764321) denom := "vault" diff --git a/keeper/migrations_test.go b/keeper/migrations_test.go index 543fddd6..2f7749d8 100644 --- a/keeper/migrations_test.go +++ b/keeper/migrations_test.go @@ -77,6 +77,61 @@ func (s *TestSuite) TestKeeper_MigrateVaultAccountPaymentDenomDefaults() { s.Require().Equal("uylds.fcc", gotVaultA2.PaymentDenom, "vault A PaymentDenom should remain set to UnderlyingAsset after second migration run") } +func (s *TestSuite) TestKeeper_MigrateVaultFeeRemainderDefaults() { + s.SetupTest() + + createVault := func(shareDenom, denom, feeRemainder string) sdk.AccAddress { + addr := types.GetVaultAddress(shareDenom) + vault := &types.VaultAccount{ + BaseAccount: authtypes.NewBaseAccountWithAddress(addr), + Admin: s.adminAddr.String(), + TotalShares: sdk.NewCoin(shareDenom, sdkmath.ZeroInt()), + UnderlyingAsset: denom, + PaymentDenom: denom, + CurrentInterestRate: types.ZeroInterestRate, + DesiredInterestRate: types.ZeroInterestRate, + FeeRemainder: feeRemainder, + } + acct := s.simApp.AccountKeeper.NewAccount(s.ctx, vault) + vaultAcct, ok := acct.(*types.VaultAccount) + s.Require().True(ok, "new account should return a *types.VaultAccount for a VaultAccount prototype") + s.simApp.AccountKeeper.SetAccount(s.ctx, vaultAcct) + return addr + } + + zero := sdkmath.LegacyZeroDec().String() + emptyAddr := createVault("feeremshareEmpty", "uatom", "") + existingAddr := createVault("feeremshareSet", "uusdc", "0.250000000000000000") + + nonVaultAddr := sdk.AccAddress([]byte("non-vault-account-addr____")) + nonVault := s.simApp.AccountKeeper.NewAccountWithAddress(s.ctx, nonVaultAddr) + s.simApp.AccountKeeper.SetAccount(s.ctx, nonVault) + + getVault := func(addr sdk.AccAddress) *types.VaultAccount { + acc := s.simApp.AccountKeeper.GetAccount(s.ctx, addr) + s.Require().NotNil(acc, "vault account %s should exist", addr) + v, ok := acc.(*types.VaultAccount) + s.Require().True(ok, "account %s should be a VaultAccount", addr) + return v + } + + err := keeper.NewMigrator(s.simApp.VaultKeeper).Migrate1to2(s.ctx) + s.Require().NoError(err, "migration should not return an error") + + s.Require().Equal(zero, getVault(emptyAddr).FeeRemainder, "empty fee remainder should be normalized to an explicit zero") + s.Require().Equal("0.250000000000000000", getVault(existingAddr).FeeRemainder, "existing fee remainder should be left unchanged") + + gotNonVault := s.simApp.AccountKeeper.GetAccount(s.ctx, nonVaultAddr) + s.Require().NotNil(gotNonVault, "non-vault account should still exist after migration") + _, ok := gotNonVault.(*types.VaultAccount) + s.Require().False(ok, "non-vault account should not be converted to a VaultAccount during migration") + + err = keeper.NewMigrator(s.simApp.VaultKeeper).Migrate1to2(s.ctx) + s.Require().NoError(err, "migration should be idempotent and not error when run a second time") + s.Require().Equal(zero, getVault(emptyAddr).FeeRemainder, "normalized fee remainder should remain zero after a second migration run") + s.Require().Equal("0.250000000000000000", getVault(existingAddr).FeeRemainder, "existing fee remainder should remain unchanged after a second migration run") +} + // TestKeeper_MigrateInternalNAVSeedFromMarker exercises the migration that seeds // the Internal NAV table from Marker NAVs for vaults whose payment_denom differs // from underlying_asset. Each subtest sets up a fresh fleet so cross-test state diff --git a/keeper/reconcile_test.go b/keeper/reconcile_test.go index 3871c095..ace098cb 100644 --- a/keeper/reconcile_test.go +++ b/keeper/reconcile_test.go @@ -2266,6 +2266,47 @@ func (s *TestSuite) TestKeeper_AccrualCalculations() { } }) + s.Run("AccrueAUMFeePayment dust accumulation preserves revenue across short windows", func() { + s.SetupTest() + vault := setup() + vault.AumFeeBips = 15 + + aum := underlying.Amount + blockTime := int64(6) + iterations := int64(10_000) + base := pastTime.Unix() + + naiveTruncatedSum := sdkmath.ZeroInt() + accumulatedSum := sdkmath.ZeroInt() + for i := range iterations { + naive, err := interest.CalculateAUMFee(aum, vault.AumFeeBips, blockTime) + s.Require().NoError(err, "naive per-window fee calculation failed at iteration %d", i) + naiveTruncatedSum = naiveTruncatedSum.Add(naive) + + vault.FeePeriodStart = base + i*blockTime + s.ctx = s.ctx.WithBlockTime(time.Unix(base+(i+1)*blockTime, 0).UTC()) + collected, err := s.k.AccrueAUMFeePayment(s.ctx, vault, aum) + s.Require().NoError(err, "AccrueAUMFeePayment failed at iteration %d", i) + accumulatedSum = accumulatedSum.Add(collected.Amount) + } + + perWindowDec, err := interest.CalculateAUMFeeDec(aum, vault.AumFeeBips, blockTime) + s.Require().NoError(err, "per-window decimal fee calculation failed") + expectedWhole := perWindowDec.MulInt64(iterations).TruncateInt() + + s.Require().True(naiveTruncatedSum.IsZero(), + "per-window integer truncation should collect nothing over short windows, got %s", naiveTruncatedSum) + s.Require().True(expectedWhole.IsPositive(), + "the accumulated fee should recover a positive whole amount, got %s", expectedWhole) + s.Require().Equal(expectedWhole, accumulatedSum, + "accumulated fee should equal the truncated cumulative decimal fee (recovered=%s, expected=%s)", accumulatedSum, expectedWhole) + + remainder, err := sdkmath.LegacyNewDecFromStr(vault.FeeRemainder) + s.Require().NoError(err, "final fee remainder %q should be parseable", vault.FeeRemainder) + s.Require().True(!remainder.IsNegative() && remainder.LT(sdkmath.LegacyOneDec()), + "fee remainder must be a fraction in [0,1), got %s", remainder) + }) + s.Run("CalculateVaultTotalAssets", func() { s.SetupTest() vault := setup() From 491356bbf5f1671257bfd96b52253d40e61d4237 Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 08:41:57 -0600 Subject: [PATCH 08/13] carried fee into valuation --- .../improvements/224-aum-fee-dust.md | 2 ++ keeper/reconcile.go | 23 ++++++++++++-- keeper/reconcile_test.go | 24 ++++++++++++--- types/vault.go | 3 ++ types/vault_test.go | 30 +++++++++++++++++++ 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/.changelog/unreleased/improvements/224-aum-fee-dust.md b/.changelog/unreleased/improvements/224-aum-fee-dust.md index 4c58f605..3fe1b1fb 100644 --- a/.changelog/unreleased/improvements/224-aum-fee-dust.md +++ b/.changelog/unreleased/improvements/224-aum-fee-dust.md @@ -1 +1,3 @@ * Accumulate sub-unit AUM fee remainder across reconciliation periods so frequent, short accrual windows no longer discard protocol revenue to truncation [#224](https://github.com/provlabs/vault/issues/224). +* Fold the carried fee remainder into the non-mutating valuation path so share NAV, mint, and redeem calculations recognize the same whole-unit fee liability the collection path will realize [#224](https://github.com/provlabs/vault/issues/224). +* Enforce that a vault's persisted fee remainder is a sub-unit fraction in `[0, 1)` during validation, rejecting imported state that would otherwise collect an extra whole-unit AUM fee [#224](https://github.com/provlabs/vault/issues/224). diff --git a/keeper/reconcile.go b/keeper/reconcile.go index fd12749e..0def5954 100644 --- a/keeper/reconcile.go +++ b/keeper/reconcile.go @@ -389,8 +389,16 @@ func (k Keeper) CalculateAccruedInterest(ctx sdk.Context, vault types.VaultAccou return interest.CalculateInterestEarned(principal, vault.CurrentInterestRate, duration) } -// CalculateAccruedAUMFee calculates the AUM fees that would have accrued for the vault +// CalculateAccruedAUMFee calculates the AUM fees that would be collected for the vault // from its FeePeriodStart to the current block time, based on the provided total assets. +// +// It mirrors AccrueAUMFeePayment by folding the carried FeeRemainder into the precise +// accrual before truncation. Without this, the non-mutating valuation path would subtract +// only the integer-truncated current fee and OutstandingAumFee, understating the liability +// whenever the carried fraction plus the current accrual crosses a whole underlying unit. +// Keeping the two paths symmetric ensures share NAV, mint, and redeem calculations recognize +// the same whole-unit fee that the collection path would realize. +// // It returns the fee amount in the underlying asset and does not mutate state. func (k Keeper) CalculateAccruedAUMFee(ctx sdk.Context, vault types.VaultAccount, totalAssets sdkmath.Int) (sdkmath.Int, error) { if vault.FeePeriodStart == 0 { @@ -400,7 +408,18 @@ func (k Keeper) CalculateAccruedAUMFee(ctx sdk.Context, vault types.VaultAccount if duration <= 0 { return sdkmath.ZeroInt(), nil } - return interest.CalculateAUMFee(totalAssets, vault.AumFeeBips, duration) + + feeDec, err := interest.CalculateAUMFeeDec(totalAssets, vault.AumFeeBips, duration) + if err != nil { + return sdkmath.Int{}, fmt.Errorf("failed to calculate accrued AUM fee: %w", err) + } + + carried, err := parseFeeRemainder(vault.FeeRemainder) + if err != nil { + return sdkmath.Int{}, fmt.Errorf("failed to parse fee remainder %q: %w", vault.FeeRemainder, err) + } + + return feeDec.Add(carried).TruncateInt(), nil } // parseFeeRemainder interprets a vault's persisted FeeRemainder string, treating an empty diff --git a/keeper/reconcile_test.go b/keeper/reconcile_test.go index ace098cb..1a6eea1e 100644 --- a/keeper/reconcile_test.go +++ b/keeper/reconcile_test.go @@ -2132,10 +2132,11 @@ func (s *TestSuite) TestKeeper_AccrualCalculations() { vault := setup() tests := []struct { - name string - start int64 - assets sdkmath.Int - expected sdkmath.Int + name string + start int64 + assets sdkmath.Int + remainder string + expected sdkmath.Int }{ { name: "no fee period start, should return zero fee", @@ -2155,11 +2156,26 @@ func (s *TestSuite) TestKeeper_AccrualCalculations() { assets: sdkmath.ZeroInt(), expected: sdkmath.ZeroInt(), }, + { + name: "carried remainder crosses a whole unit, should recognize the extra unit", + start: pastTime.Unix(), + assets: underlying.Amount, + remainder: "0.7", + expected: sdkmath.NewInt(246_576), + }, + { + name: "carried remainder stays sub-unit, should not add a whole unit", + start: pastTime.Unix(), + assets: underlying.Amount, + remainder: "0.5", + expected: sdkmath.NewInt(246_575), + }, } for _, tc := range tests { s.Run(tc.name, func() { vault.FeePeriodStart = tc.start + vault.FeeRemainder = tc.remainder amt, err := s.k.CalculateAccruedAUMFee(s.ctx, *vault, tc.assets) s.Require().NoError(err, "accrued AUM fee calculation failed for case: %s", tc.name) s.Require().Equal(tc.expected, amt, "AUM fee mismatch for case: %s", tc.name) diff --git a/types/vault.go b/types/vault.go index 1682e9af..fffc953b 100644 --- a/types/vault.go +++ b/types/vault.go @@ -303,6 +303,9 @@ func (v VaultAccount) Validate() error { if remainder.IsNegative() { return fmt.Errorf("fee remainder cannot be negative: %s", v.FeeRemainder) } + if remainder.GTE(sdkmath.LegacyOneDec()) { + return fmt.Errorf("fee remainder must be a sub-unit fraction less than one: %s", v.FeeRemainder) + } } if err := ValidateSwapLimits(v.MinSwapInValue, v.MaxSwapInValue); err != nil { diff --git a/types/vault_test.go b/types/vault_test.go index a9539add..d96bc24b 100644 --- a/types/vault_test.go +++ b/types/vault_test.go @@ -771,6 +771,36 @@ func TestVaultAccount_Validate(t *testing.T) { }, expectedErr: "fee remainder cannot be negative", }, + { + name: "fee remainder equal to one whole unit is invalid", + vaultAccount: types.VaultAccount{ + BaseAccount: baseAcc, + Admin: validAdmin, + TotalShares: sdk.NewInt64Coin(validDenom, 0), + UnderlyingAsset: "uusd", + PaymentDenom: "uusd", + CurrentInterestRate: validInterest, + DesiredInterestRate: validInterest, + OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), + FeeRemainder: "1.000000000000000000", + }, + expectedErr: "fee remainder must be a sub-unit fraction less than one", + }, + { + name: "fee remainder exceeding one whole unit is invalid", + vaultAccount: types.VaultAccount{ + BaseAccount: baseAcc, + Admin: validAdmin, + TotalShares: sdk.NewInt64Coin(validDenom, 0), + UnderlyingAsset: "uusd", + PaymentDenom: "uusd", + CurrentInterestRate: validInterest, + DesiredInterestRate: validInterest, + OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), + FeeRemainder: "1.5", + }, + expectedErr: "fee remainder must be a sub-unit fraction less than one", + }, } for _, tc := range tests { From 461b23028dbccf802b3f55f9a021d47973b3bbae Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 09:15:28 -0600 Subject: [PATCH 09/13] update migration tests --- .../plans/2026-06-28-force-pause-flag.md | 505 ++++++++++++++++++ keeper/migrations_test.go | 83 ++- 2 files changed, 566 insertions(+), 22 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-28-force-pause-flag.md diff --git a/docs/superpowers/plans/2026-06-28-force-pause-flag.md b/docs/superpowers/plans/2026-06-28-force-pause-flag.md new file mode 100644 index 00000000..7f2132b1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-force-pause-flag.md @@ -0,0 +1,505 @@ +# Force-Pause Flag Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `PauseVault` strict by default (a failed pre-pause reconcile or valuation aborts the tx and leaves the vault unpaused) and gate the existing best-effort behavior behind an explicit `force` flag, with the tolerated error recorded on `EventVaultPaused`. + +**Architecture:** Add `bool force` to `MsgPauseVaultRequest`. The handler returns the wrapped error on any reconcile/valuation failure when `force=false`; when `force=true` it logs, tolerates the failure, snapshots a best-effort `PausedBalance` (zero on valuation failure), and persists via the validation-skipping `AuthKeeper.SetAccount` path that `autoPauseVault` already uses. `EventVaultPaused` gains `forced` (force path used) and `forced_error` (the tolerated reconcile/valuation error, joined when both fail). + +**Tech Stack:** Go, Cosmos SDK, gogoproto (`make proto-all`), testify suite tests. + +## Global Constraints + +- Error wrapping: always `fmt.Errorf("failed to [action]: %w", err)`. +- Logging in `keeper/`: use `k.getLogger(ctx)`, never `ctx.Logger()`. Preserve existing key-value fields. +- Number literals: underscore separators (e.g. `100_000`). +- Tests: table-driven, descriptive case names, high-context `Require`/`Assert` messages, no godocs on `TestXxx`. +- Do NOT run `make format` (its gci config conflicts with lint). Use `make lint` + `gofmt` instead. +- Proto is the source of truth; never hand-edit generated `*.pb.go` / `api/`. Run `make proto-all` after proto edits. +- Event error strings land in consensus events, so they must be deterministic. `reconcileVault` and `GetNetTVVInUnderlyingAsset` errors are plain `fmt.Errorf` wraps (no map ordering / randomness) and are safe. + +--- + +### Task 1: Add proto fields and regenerate + +**Files:** +- Modify: `proto/provlabs/vault/v1/tx.proto:474` (MsgPauseVaultRequest) +- Modify: `proto/provlabs/vault/v1/events.proto:268` (EventVaultPaused) +- Regenerated (do not hand-edit): `types/tx.pb.go`, `types/events.pb.go`, `api/...` + +**Interfaces:** +- Produces: `MsgPauseVaultRequest.Force bool`; `EventVaultPaused.Forced bool`, `EventVaultPaused.ForcedError string`. + +- [ ] **Step 1: Add the `force` field to `MsgPauseVaultRequest`** + +In `proto/provlabs/vault/v1/tx.proto`, inside `message MsgPauseVaultRequest`, after the `reason` field: + +```proto + // force, when true, allows the pause to proceed even if the pre-pause + // reconcile or vault valuation fails. The pause is applied best-effort: + // tolerated failures are logged and surfaced on EventVaultPaused, and + // PausedBalance may be the net TVV, zero (when valuation itself failed), or + // otherwise approximate. When false (default), any reconcile or valuation + // failure aborts the pause and the vault remains unpaused. + bool force = 4; +``` + +- [ ] **Step 2: Add `forced` and `forced_error` to `EventVaultPaused`** + +In `proto/provlabs/vault/v1/events.proto`, inside `message EventVaultPaused`, after `total_vault_value`: + +```proto + // forced indicates the pause used the force path, waiving the strict + // reconcile/valuation gate. False for a normal strict pause; true for a + // forced manual pause and for automated auto-pauses. + bool forced = 5; + // forced_error records the reconcile and/or valuation error that was + // tolerated when forced is true. Empty when nothing failed. When non-empty, + // total_vault_value may be stale or zero. + string forced_error = 6; +``` + +- [ ] **Step 3: Regenerate Go code** + +Run: `make proto-all` +Expected: completes without error; `git status` shows changes in `types/tx.pb.go`, `types/events.pb.go`, `api/`. + +- [ ] **Step 4: Verify the build still compiles** + +Run: `go build ./...` +Expected: success. New fields are additive and not yet referenced, so the build stays green. + +- [ ] **Step 5: Commit** + +```bash +git add proto/ types/tx.pb.go types/events.pb.go api/ +git commit -m "feat: add force flag to MsgPauseVaultRequest and forced/forced_error to EventVaultPaused" +``` + +--- + +### Task 2: Extend the event constructor and update existing call sites + +**Files:** +- Modify: `types/events.go:202` (`NewEventVaultPaused`) +- Modify: `keeper/vault.go:526-543` (`autoPauseVault`) +- Modify: `keeper/msg_server.go:586` (`PauseVault` emit, interim) + +**Interfaces:** +- Consumes: `EventVaultPaused.Forced`, `EventVaultPaused.ForcedError` (Task 1). +- Produces: `NewEventVaultPaused(vaultAddress, authority, reason string, totalVaultValue sdk.Coin, forced bool, forcedError string) *EventVaultPaused`. + +- [ ] **Step 1: Update the constructor signature and body** + +In `types/events.go`, replace `NewEventVaultPaused`: + +```go +// NewEventVaultPaused creates a new EventVaultPaused event. forced reports +// whether the pause waived the strict reconcile/valuation gate, and forcedError +// carries the tolerated error (empty when nothing failed). +func NewEventVaultPaused(vaultAddress, authority, reason string, totalVaultValue sdk.Coin, forced bool, forcedError string) *EventVaultPaused { + return &EventVaultPaused{ + VaultAddress: vaultAddress, + Authority: authority, + Reason: reason, + TotalVaultValue: totalVaultValue.String(), + Forced: forced, + ForcedError: forcedError, + } +} +``` + +- [ ] **Step 2: Update `autoPauseVault` to record the tolerated valuation error** + +In `keeper/vault.go`, in `autoPauseVault`, capture the TVV error and pass `forced=true`: + +```go + var forcedError string + tvv, err := k.GetNetTVVInUnderlyingAsset(ctx, *vault) + if err != nil { + forcedError = fmt.Sprintf("valuation failed: %v", err) + k.getLogger(ctx).Error("Failed to get net TVV in underlying asset", "vault_address", vault.GetAddress().String(), "error", err) + } + + vault.Paused = true + vault.PausedReason = reason + vault.PausedBalance = sdk.Coin{Denom: vault.UnderlyingAsset, Amount: tvv} + k.AuthKeeper.SetAccount(ctx, vault) // Updating via SetAccount to skip validation since auto-pausing is triggered by invalid state + + k.emitEvent(ctx, types.NewEventVaultPaused(vault.GetAddress().String(), vault.GetAddress().String(), reason, vault.PausedBalance, true, forcedError)) +``` + +Confirm `fmt` is imported in `keeper/vault.go` (it is used elsewhere in the file); if not, add it. + +- [ ] **Step 3: Update the `PauseVault` emit call (interim, replaced in Task 3)** + +In `keeper/msg_server.go:586`, change the emit so the file compiles with the new signature. Pass `false, ""` for now; Task 3 wires the real values: + +```go + k.emitEvent(ctx, types.NewEventVaultPaused(msg.VaultAddress, msg.Authority, msg.Reason, vault.PausedBalance, false, "")) +``` + +- [ ] **Step 4: Build and run the affected package tests** + +Run: `go build ./... && go test ./types/... ./keeper/... -run 'Pause|AutoPause' -count=1` +Expected: PASS. Existing tests assert the event *type* string only, so the new fields do not break them. + +- [ ] **Step 5: Commit** + +```bash +git add types/events.go keeper/vault.go keeper/msg_server.go +git commit -m "feat: thread forced/forced_error through EventVaultPaused constructor and auto-pause" +``` + +--- + +### Task 3: Gate best-effort pause behind `force` in the handler + +This task carries its own TDD cycle. The existing branch test +`TestMsgServer_PauseVault_ReconcileFailureStillPauses` +(`keeper/msg_server_test.go:4416`) currently pauses through a failed reconcile +*without* `force`; it must be updated to set `Force: true`, and strict-mode +failure cases must be added. + +**Files:** +- Modify: `keeper/msg_server.go:540-589` (`PauseVault`) +- Modify: `keeper/msg_server_test.go:4416` (`TestMsgServer_PauseVault_ReconcileFailureStillPauses`) + +**Interfaces:** +- Consumes: `MsgPauseVaultRequest.Force` (Task 1); `NewEventVaultPaused(..., forced bool, forcedError string)` (Task 2); `types.ZeroInterestRate` (`types/vault.go:21`); `types.NewEventVaultInterestChange(addr, currentRate, desiredRate string)` (`types/events.go:80`). + +- [ ] **Step 1: Rewrite the failing tests first** + +Replace `TestMsgServer_PauseVault_ReconcileFailureStillPauses` in `keeper/msg_server_test.go` with a version that covers strict failure AND forced success. The two `setup` closures (insufficient reserves; broken TVV) are unchanged from the current branch version; only the request `Force` value, the expected error, and the new assertions differ. + +```go +func (s *TestSuite) TestMsgServer_PauseVault_ForceVsStrict() { + reason := "emergency" + + insufficientReservesSetup := func() (sdk.AccAddress, string) { + underlying := "under" + share := "vaultshares" + s.requireAddFinalizeAndActivateMarker(sdk.NewCoin(underlying, math.NewInt(10_000)), s.adminAddr) + _, err := s.k.CreateVault(s.ctx, &types.MsgCreateVaultRequest{ + Admin: s.adminAddr.String(), + ShareDenom: share, + UnderlyingAsset: underlying, + }) + s.Require().NoError(err, "vault creation should succeed for share denom %s", share) + vaultAddr := types.GetVaultAddress(share) + vault, err := s.k.GetVault(s.ctx, vaultAddr) + s.Require().NoError(err, "loading the vault for setup should succeed") + + oneDay := int64(24 * time.Hour / time.Second) + vault.CurrentInterestRate = "1.0" + vault.DesiredInterestRate = "1.0" + vault.PeriodStart = s.ctx.BlockTime().Unix() - oneDay + s.k.AuthKeeper.SetAccount(s.ctx, vault) + + s.Require().NoError(FundAccount(s.ctx, s.simApp.BankKeeper, vault.PrincipalMarkerAddress(), + sdk.NewCoins(sdk.NewInt64Coin(underlying, 100_000))), + "funding the principal marker should succeed so reconcile owes unpayable interest") + s.ctx = s.ctx.WithEventManager(sdk.NewEventManager()) + return vaultAddr, underlying + } + + brokenTVVSetup := func() (sdk.AccAddress, string) { + underlying := "ylds" + share := "vshare" + payment := "usdc" + s.requireAddFinalizeAndActivateMarker(sdk.NewInt64Coin(payment, 1_000_000), s.adminAddr) + vault := s.setupBaseVault(underlying, share, payment) + s.Require().NoError(s.k.NAVs.Remove(s.ctx, collections.Join(vault.GetAddress(), payment)), + "removing the bootstrap payment NAV should surface the conversion failure") + s.Require().NoError(FundAccount(s.ctx, s.simApp.BankKeeper, vault.PrincipalMarkerAddress(), + sdk.NewCoins(sdk.NewInt64Coin(payment, 10))), + "funding the principal with an unpriced accepted denom should make TVV conversion error") + s.ctx = s.ctx.WithEventManager(sdk.NewEventManager()) + return vault.GetAddress(), underlying + } + + tests := []struct { + name string + setup func() (sdk.AccAddress, string) + force bool + expectErr bool + expectedPauseAmount int64 + expectForcedError bool + }{ + { + name: "strict pause aborts when reconcile fails on insufficient reserves", + setup: insufficientReservesSetup, + force: false, + expectErr: true, + }, + { + name: "strict pause aborts when TVV conversion fails", + setup: brokenTVVSetup, + force: false, + expectErr: true, + }, + { + name: "force pause proceeds through insufficient-reserves reconcile failure", + setup: insufficientReservesSetup, + force: true, + expectErr: false, + expectedPauseAmount: 100_000, + expectForcedError: true, + }, + { + name: "force pause proceeds through broken TVV with zero snapshot", + setup: brokenTVVSetup, + force: true, + expectErr: false, + expectedPauseAmount: 0, + expectForcedError: true, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + s.SetupTest() + vaultAddr, underlying := tc.setup() + + _, err := keeper.NewMsgServer(s.simApp.VaultKeeper).PauseVault(s.ctx, &types.MsgPauseVaultRequest{ + Authority: s.adminAddr.String(), + VaultAddress: vaultAddr.String(), + Reason: reason, + Force: tc.force, + }) + + v, getErr := s.k.GetVault(s.ctx, vaultAddr) + s.Require().NoError(getErr, "loading the vault should succeed for case %q", tc.name) + + if tc.expectErr { + s.Require().Error(err, "strict pause must fail when pre-pause bookkeeping fails for case %q", tc.name) + s.Assert().False(v.Paused, "vault must remain unpaused after a failed strict pause for case %q", tc.name) + return + } + + s.Require().NoError(err, "force pause must succeed even when the pre-pause reconcile fails for case %q", tc.name) + s.Assert().True(v.Paused, "vault should be paused after a force pause for case %q", tc.name) + s.Assert().Equal(reason, v.PausedReason, "paused reason should be recorded for case %q", tc.name) + s.Assert().Equal(types.ZeroInterestRate, v.CurrentInterestRate, "pausing should zero the current interest rate for case %q", tc.name) + s.Assert().Equal(underlying, v.PausedBalance.Denom, "paused balance should be denominated in the underlying asset for case %q", tc.name) + s.Assert().Equal(tc.expectedPauseAmount, v.PausedBalance.Amount.Int64(), "paused balance should be the best-effort TVV snapshot for case %q", tc.name) + + pausedEvent := s.findLastEventVaultPaused() + s.Require().NotNil(pausedEvent, "an EventVaultPaused should have been emitted for case %q", tc.name) + s.Assert().True(pausedEvent.Forced, "the forced flag should be set on a force pause for case %q", tc.name) + if tc.expectForcedError { + s.Assert().NotEmpty(pausedEvent.ForcedError, "forced_error should record the tolerated failure for case %q", tc.name) + } + }) + } +} +``` + +- [ ] **Step 2: Add the `findLastEventVaultPaused` helper to `suite_test.go`** + +Per the DRY mandate, add a typed-event lookup helper in `keeper/suite_test.go`: + +```go +// findLastEventVaultPaused returns the most recent EventVaultPaused decoded from +// the context event manager, or nil when none has been emitted. +func (s *TestSuite) findLastEventVaultPaused() *types.EventVaultPaused { + events := s.ctx.EventManager().Events() + for i := len(events) - 1; i >= 0; i-- { + if events[i].Type != "provlabs.vault.v1.EventVaultPaused" { + continue + } + msg, err := sdk.ParseTypedEvent(abci.Event(events[i])) + s.Require().NoError(err, "parsing the EventVaultPaused typed event should succeed") + paused, ok := msg.(*types.EventVaultPaused) + s.Require().True(ok, "parsed event should be *EventVaultPaused") + return paused + } + return nil +} +``` + +Ensure `keeper/suite_test.go` imports `abci "github.com/cometbft/cometbft/abci/types"` (add it if missing). + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test ./keeper/... -run 'TestMsgServer_PauseVault_ForceVsStrict|TestMsgServer_PauseVault_ReconcileFailureStillPauses' -count=1` +Expected: FAIL. Strict cases currently pass best-effort (no error), and the old test name no longer exists. This confirms the new behavior is not yet implemented. + +- [ ] **Step 4: Implement the strict/force branch in `PauseVault`** + +Replace the body of `PauseVault` in `keeper/msg_server.go` (from the `if vault.Paused` check through the final emit) with: + +```go + if vault.Paused { + return nil, fmt.Errorf("vault %s is already paused", msg.VaultAddress) + } + + var forcedErrors []string + + if err = k.reconcileVault(ctx, vault); err != nil { + if !msg.Force { + return nil, fmt.Errorf("failed to reconcile before pausing: %w", err) + } + forcedErrors = append(forcedErrors, fmt.Sprintf("reconcile failed: %v", err)) + k.getLogger(ctx).Error( + "reconcile failed before forced pause; pausing best-effort", + "vault", msg.VaultAddress, + "err", err, + ) + } + + tvv, err := k.GetNetTVVInUnderlyingAsset(ctx, *vault) + if err != nil { + if !msg.Force { + return nil, fmt.Errorf("failed to get net TVV before pausing: %w", err) + } + forcedErrors = append(forcedErrors, fmt.Sprintf("valuation failed: %v", err)) + k.getLogger(ctx).Error( + "failed to value vault before forced pause; snapshotting zero paused balance", + "vault", msg.VaultAddress, + "err", err, + ) + tvv = sdkmath.ZeroInt() + } + + vault.PausedBalance = sdk.NewCoin(vault.UnderlyingAsset, tvv) + vault.Paused = true + vault.PausedReason = msg.Reason + vault.CurrentInterestRate = types.ZeroInterestRate + k.emitEvent(ctx, types.NewEventVaultInterestChange(vault.GetAddress().String(), types.ZeroInterestRate, vault.DesiredInterestRate)) + + if msg.Force { + k.AuthKeeper.SetAccount(ctx, vault) + } else if err := k.SetVaultAccount(ctx, vault); err != nil { + return nil, fmt.Errorf("failed to set vault account: %w", err) + } + + forcedError := strings.Join(forcedErrors, "; ") + k.emitEvent(ctx, types.NewEventVaultPaused(msg.VaultAddress, msg.Authority, msg.Reason, vault.PausedBalance, msg.Force, forcedError)) + + return &types.MsgPauseVaultResponse{}, nil +``` + +Notes for the implementer: +- This removes the `UpdateInterestRates` call. That helper persisted via the validating `SetVaultAccount` internally, which (a) double-persisted with the existing trailing `SetVaultAccount` and (b) made force-mode validation-skipping impossible. Zeroing `CurrentInterestRate` inline plus emitting `NewEventVaultInterestChange` preserves the prior observable behavior (rate set to `0.0`, interest-change event emitted) while persisting exactly once under mode control. `DesiredInterestRate` is intentionally left unchanged, matching the prior `UpdateInterestRates(ctx, vault, ZeroInterestRate, vault.DesiredInterestRate)` call. +- Add `"strings"` to the import block in `keeper/msg_server.go`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./keeper/... -run 'TestMsgServer_PauseVault' -count=1` +Expected: PASS, including the existing `TestMsgServer_PauseVault` and `TestMsgServer_PauseVault_Failures`. + +- [ ] **Step 6: Update the godoc on `PauseVault`** + +Replace the doc comment above `PauseVault` so it describes the gated behavior rather than unconditional best-effort: + +```go +// PauseVault pauses a vault, disabling all user-facing operations. +// +// By default the pause is strict: it reconciles outstanding interest and fees +// and snapshots the net TVV first, and any failure (e.g. insufficient reserves +// to settle positive interest, or a broken TVV/NAV conversion) aborts the pause +// and leaves the vault unpaused. Setting Force on the request makes the pause an +// emergency control that mirrors autoPauseVault: tolerated reconcile/valuation +// failures are logged and surfaced via EventVaultPaused.forced_error, the frozen +// PausedBalance is the net TVV when valuable or zero when valuation itself failed, +// and the account is persisted without validation so an invalid-state vault can +// still be frozen. +``` + +- [ ] **Step 7: Commit** + +```bash +git add keeper/msg_server.go keeper/msg_server_test.go keeper/suite_test.go +git commit -m "feat: make PauseVault strict by default, gate best-effort pause behind force flag" +``` + +--- + +### Task 4: Update the spec + +**Files:** +- Modify: `spec/03_messages.md` (PauseVault row at line 70; PauseVault section at line 276) +- Modify: `spec/04_events.md` (EventVaultPaused section ~line 67) +- Modify: `spec/06_blocker.md` (auto-pause notes ~line 176) + +- [ ] **Step 1: Update the message reference** + +In `spec/03_messages.md`, replace the PauseVault summary-table row (line 70) Notes cell: + +``` +| `PauseVault` | Admin or Asset Manager | ✅ | ❌ | Strict by default: reconciles, snapshots `PausedBalance`, sets paused; aborts if reconcile/valuation fails. `force=true` pauses best-effort, tolerating failures and recording them on `EventVaultPaused`. | +``` + +Then replace the `## PauseVault` section body (lines 276-281): + +```markdown +## PauseVault + +Admin or Asset Manager. Pauses a vault, disabling swap-ins and swap-outs, and recording reason + balance snapshot. + +By default the pause is **strict**: it reconciles outstanding interest and fees and values the vault first, and any failure (insufficient reserves to settle positive interest, or a broken TVV/NAV conversion) aborts the request and leaves the vault unpaused. The failed transaction is the operator's signal that the vault is in an unexpected state. + +Setting `force = true` makes the pause an **emergency control**: a reconcile or valuation failure is logged and tolerated rather than blocking the freeze. The frozen `PausedBalance` is the net TVV when the vault can be valued, or zero when the valuation itself is what failed, so it may be approximate. The tolerated error is recorded on `EventVaultPaused.forced_error`. + +* **Request:** `MsgPauseVaultRequest { authority, vault_address, reason, force }` +* **Response:** `MsgPauseVaultResponse {}` +``` + +- [ ] **Step 2: Document the new event fields** + +In `spec/04_events.md`, in the `### EventVaultPaused` field list, add: + +```markdown +* `forced` — true when the pause used the force path and waived the strict reconcile/valuation gate (also true for automated auto-pauses) +* `forced_error` — the reconcile and/or valuation error tolerated during a forced pause; empty when nothing failed. When non-empty, `total_vault_value` may be stale or zero. +``` + +- [ ] **Step 3: Note the auto-pause event semantics** + +In `spec/06_blocker.md`, near the auto-pause description (~line 176), add a sentence: + +```markdown +Auto-pause emits `EventVaultPaused` with `forced = true` and, when valuation failed, the tolerated error in `forced_error`; the manual `PauseVault` path produces the same signal only when called with `force = true`. +``` + +- [ ] **Step 4: Commit** + +```bash +git add spec/ +git commit -m "docs: document strict/force PauseVault semantics and EventVaultPaused forced fields" +``` + +--- + +### Task 5: Full local verification + +**Files:** none (verification only). + +- [ ] **Step 1: Lint** + +Run: `make lint` +Expected: no findings. If `gofmt` differences appear, run `gofmt -w` on the changed files (do NOT run `make format`). + +- [ ] **Step 2: Full unit test suite** + +Run: `go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 3: Simplest sims** + +Run: `make test-sim-simple && make test-sim-nondeterminism` +Expected: PASS. (Equivalent to invoking the `vault-lint-test` skill, which wraps format/lint/test/sims.) + +- [ ] **Step 4: Final review of the diff** + +Run: `git diff main...HEAD --stat` +Expected: changes limited to `proto/`, regenerated `types/*.pb.go` + `api/`, `types/events.go`, `keeper/vault.go`, `keeper/msg_server.go`, `keeper/msg_server_test.go`, `keeper/suite_test.go`, and `spec/`. + +--- + +## Self-Review Notes + +- **Spec coverage:** strict-default behavior (Task 3), force flag (Tasks 1, 3), event audit fields (Tasks 1-3), determinism of error strings (Global Constraints), spec sync (Task 4) — all covered. +- **Type consistency:** `NewEventVaultPaused(..., forced bool, forcedError string)` defined in Task 2 and called identically in Tasks 2-3; `MsgPauseVaultRequest.Force` defined in Task 1 and consumed in Task 3; `findLastEventVaultPaused` defined and used in Task 3. +- **Carried-over decision:** `forced_error` joins reconcile-then-valuation failures with `"; "` rather than keeping only the first error (per the design discussion). If only the primary error is preferred, drop the `forcedErrors` slice and assign the single error string at each failure site instead. diff --git a/keeper/migrations_test.go b/keeper/migrations_test.go index 2f7749d8..b99f41d4 100644 --- a/keeper/migrations_test.go +++ b/keeper/migrations_test.go @@ -78,8 +78,6 @@ func (s *TestSuite) TestKeeper_MigrateVaultAccountPaymentDenomDefaults() { } func (s *TestSuite) TestKeeper_MigrateVaultFeeRemainderDefaults() { - s.SetupTest() - createVault := func(shareDenom, denom, feeRemainder string) sdk.AccAddress { addr := types.GetVaultAddress(shareDenom) vault := &types.VaultAccount{ @@ -99,14 +97,6 @@ func (s *TestSuite) TestKeeper_MigrateVaultFeeRemainderDefaults() { return addr } - zero := sdkmath.LegacyZeroDec().String() - emptyAddr := createVault("feeremshareEmpty", "uatom", "") - existingAddr := createVault("feeremshareSet", "uusdc", "0.250000000000000000") - - nonVaultAddr := sdk.AccAddress([]byte("non-vault-account-addr____")) - nonVault := s.simApp.AccountKeeper.NewAccountWithAddress(s.ctx, nonVaultAddr) - s.simApp.AccountKeeper.SetAccount(s.ctx, nonVault) - getVault := func(addr sdk.AccAddress) *types.VaultAccount { acc := s.simApp.AccountKeeper.GetAccount(s.ctx, addr) s.Require().NotNil(acc, "vault account %s should exist", addr) @@ -115,21 +105,70 @@ func (s *TestSuite) TestKeeper_MigrateVaultFeeRemainderDefaults() { return v } - err := keeper.NewMigrator(s.simApp.VaultKeeper).Migrate1to2(s.ctx) - s.Require().NoError(err, "migration should not return an error") + zero := sdkmath.LegacyZeroDec().String() - s.Require().Equal(zero, getVault(emptyAddr).FeeRemainder, "empty fee remainder should be normalized to an explicit zero") - s.Require().Equal("0.250000000000000000", getVault(existingAddr).FeeRemainder, "existing fee remainder should be left unchanged") + tests := []struct { + name string + shareDenom string + denom string + initialRemainder string + expectedRemainder string + }{ + { + name: "empty remainder is normalized to explicit zero", + shareDenom: "feeremshareEmpty", + denom: "uatom", + initialRemainder: "", + expectedRemainder: zero, + }, + { + name: "existing remainder is preserved", + shareDenom: "feeremshareSet", + denom: "uusdc", + initialRemainder: "0.250000000000000000", + expectedRemainder: "0.250000000000000000", + }, + } - gotNonVault := s.simApp.AccountKeeper.GetAccount(s.ctx, nonVaultAddr) - s.Require().NotNil(gotNonVault, "non-vault account should still exist after migration") - _, ok := gotNonVault.(*types.VaultAccount) - s.Require().False(ok, "non-vault account should not be converted to a VaultAccount during migration") + for _, tc := range tests { + s.Run(tc.name, func() { + s.SetupTest() + addr := createVault(tc.shareDenom, tc.denom, tc.initialRemainder) - err = keeper.NewMigrator(s.simApp.VaultKeeper).Migrate1to2(s.ctx) - s.Require().NoError(err, "migration should be idempotent and not error when run a second time") - s.Require().Equal(zero, getVault(emptyAddr).FeeRemainder, "normalized fee remainder should remain zero after a second migration run") - s.Require().Equal("0.250000000000000000", getVault(existingAddr).FeeRemainder, "existing fee remainder should remain unchanged after a second migration run") + err := keeper.NewMigrator(s.simApp.VaultKeeper).Migrate1to2(s.ctx) + s.Require().NoError(err, "migration should not return an error for case %s", tc.name) + + s.Require().Equal(tc.expectedRemainder, getVault(addr).FeeRemainder, + "fee remainder mismatch after migration for case %s", tc.name) + }) + } + + s.Run("non-vault accounts are left untouched", func() { + s.SetupTest() + nonVaultAddr := sdk.AccAddress([]byte("non-vault-account-addr____")) + s.simApp.AccountKeeper.SetAccount(s.ctx, s.simApp.AccountKeeper.NewAccountWithAddress(s.ctx, nonVaultAddr)) + + err := keeper.NewMigrator(s.simApp.VaultKeeper).Migrate1to2(s.ctx) + s.Require().NoError(err, "migration should not error when a non-vault account exists") + + got := s.simApp.AccountKeeper.GetAccount(s.ctx, nonVaultAddr) + s.Require().NotNil(got, "non-vault account should still exist after migration") + _, ok := got.(*types.VaultAccount) + s.Require().False(ok, "non-vault account should not be converted to a VaultAccount during migration") + }) + + s.Run("migration is idempotent across consecutive runs", func() { + s.SetupTest() + emptyAddr := createVault("feeremshareIdempotentEmpty", "uatom", "") + existingAddr := createVault("feeremshareIdempotentSet", "uusdc", "0.250000000000000000") + + migrator := keeper.NewMigrator(s.simApp.VaultKeeper) + s.Require().NoError(migrator.Migrate1to2(s.ctx), "first migration run should not error") + s.Require().NoError(migrator.Migrate1to2(s.ctx), "second migration run should be idempotent and not error") + + s.Require().Equal(zero, getVault(emptyAddr).FeeRemainder, "normalized fee remainder should remain zero after a second migration run") + s.Require().Equal("0.250000000000000000", getVault(existingAddr).FeeRemainder, "existing fee remainder should remain unchanged after a second migration run") + }) } // TestKeeper_MigrateInternalNAVSeedFromMarker exercises the migration that seeds From 00429c8245c581ba7d54f88d4a2a0f601024a226 Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 09:26:16 -0600 Subject: [PATCH 10/13] lower test iteration count --- keeper/reconcile_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keeper/reconcile_test.go b/keeper/reconcile_test.go index 1a6eea1e..db0babea 100644 --- a/keeper/reconcile_test.go +++ b/keeper/reconcile_test.go @@ -2289,7 +2289,7 @@ func (s *TestSuite) TestKeeper_AccrualCalculations() { aum := underlying.Amount blockTime := int64(6) - iterations := int64(10_000) + iterations := int64(100) base := pastTime.Unix() naiveTruncatedSum := sdkmath.ZeroInt() From 2c67666d5c835b63aeca0a40f518a7fe696d13cc Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 10:11:07 -0600 Subject: [PATCH 11/13] update dust collection --- .../improvements/224-aum-fee-dust.md | 5 +- api/provlabs/vault/v1/vault.pulsar.go | 11 +- .../plans/2026-06-28-force-pause-flag.md | 505 ------------------ keeper/reconcile.go | 30 +- keeper/reconcile_test.go | 44 ++ proto/provlabs/vault/v1/vault.proto | 11 +- spec/02_state.md | 2 +- spec/06_blocker.md | 2 +- types/vault.go | 3 - types/vault.pb.go | 11 +- types/vault_test.go | 21 +- 11 files changed, 97 insertions(+), 548 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-28-force-pause-flag.md diff --git a/.changelog/unreleased/improvements/224-aum-fee-dust.md b/.changelog/unreleased/improvements/224-aum-fee-dust.md index 3fe1b1fb..e36095ca 100644 --- a/.changelog/unreleased/improvements/224-aum-fee-dust.md +++ b/.changelog/unreleased/improvements/224-aum-fee-dust.md @@ -1,3 +1,4 @@ -* Accumulate sub-unit AUM fee remainder across reconciliation periods so frequent, short accrual windows no longer discard protocol revenue to truncation [#224](https://github.com/provlabs/vault/issues/224). +* Accumulate the uncollected AUM fee remainder across reconciliation periods so frequent, short accrual windows no longer discard protocol revenue to truncation [#224](https://github.com/provlabs/vault/issues/224). +* Carry the underlying that floors to zero payment-denom units forward in `fee_remainder` instead of dropping it, so a coarse payment denom no longer loses whole underlying units between collections [#224](https://github.com/provlabs/vault/issues/224). * Fold the carried fee remainder into the non-mutating valuation path so share NAV, mint, and redeem calculations recognize the same whole-unit fee liability the collection path will realize [#224](https://github.com/provlabs/vault/issues/224). -* Enforce that a vault's persisted fee remainder is a sub-unit fraction in `[0, 1)` during validation, rejecting imported state that would otherwise collect an extra whole-unit AUM fee [#224](https://github.com/provlabs/vault/issues/224). +* Validate that a vault's persisted fee remainder is a non-negative decimal, allowing it to exceed one whole underlying unit when the payment denom is coarser than the underlying asset [#224](https://github.com/provlabs/vault/issues/224). diff --git a/api/provlabs/vault/v1/vault.pulsar.go b/api/provlabs/vault/v1/vault.pulsar.go index c62023e9..5521266c 100644 --- a/api/provlabs/vault/v1/vault.pulsar.go +++ b/api/provlabs/vault/v1/vault.pulsar.go @@ -4878,10 +4878,13 @@ type VaultAccount struct { // table via MsgUpdateVaultNAV. If empty, the vault admin is treated as the NAV // authority. It is rotated only via MsgUpdateNAVAuthority. NavAuthority string `protobuf:"bytes,29,opt,name=nav_authority,json=navAuthority,proto3" json:"nav_authority,omitempty"` - // fee_remainder is the sub-unit AUM fee, denominated in the underlying_asset, - // carried across periods. Each reconciliation adds it to the precise fee before - // truncation, collects the whole-unit portion, and stores the new fraction so - // truncation does not discard protocol revenue. A decimal string; empty is zero. + // fee_remainder is accrued AUM fee value, denominated in the underlying_asset, + // that has not yet been collected, carried across periods. Each reconciliation + // folds it into the precise fee, collects only the portion representable as whole + // payment_denom units, and stores the uncollected remainder so truncation does not + // discard protocol revenue. When payment_denom is coarser than underlying_asset, + // this can exceed one whole underlying unit until enough value accrues to convert + // to a whole payment_denom unit. A decimal string; empty is zero. FeeRemainder string `protobuf:"bytes,30,opt,name=fee_remainder,json=feeRemainder,proto3" json:"fee_remainder,omitempty"` } diff --git a/docs/superpowers/plans/2026-06-28-force-pause-flag.md b/docs/superpowers/plans/2026-06-28-force-pause-flag.md deleted file mode 100644 index 7f2132b1..00000000 --- a/docs/superpowers/plans/2026-06-28-force-pause-flag.md +++ /dev/null @@ -1,505 +0,0 @@ -# Force-Pause Flag Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `PauseVault` strict by default (a failed pre-pause reconcile or valuation aborts the tx and leaves the vault unpaused) and gate the existing best-effort behavior behind an explicit `force` flag, with the tolerated error recorded on `EventVaultPaused`. - -**Architecture:** Add `bool force` to `MsgPauseVaultRequest`. The handler returns the wrapped error on any reconcile/valuation failure when `force=false`; when `force=true` it logs, tolerates the failure, snapshots a best-effort `PausedBalance` (zero on valuation failure), and persists via the validation-skipping `AuthKeeper.SetAccount` path that `autoPauseVault` already uses. `EventVaultPaused` gains `forced` (force path used) and `forced_error` (the tolerated reconcile/valuation error, joined when both fail). - -**Tech Stack:** Go, Cosmos SDK, gogoproto (`make proto-all`), testify suite tests. - -## Global Constraints - -- Error wrapping: always `fmt.Errorf("failed to [action]: %w", err)`. -- Logging in `keeper/`: use `k.getLogger(ctx)`, never `ctx.Logger()`. Preserve existing key-value fields. -- Number literals: underscore separators (e.g. `100_000`). -- Tests: table-driven, descriptive case names, high-context `Require`/`Assert` messages, no godocs on `TestXxx`. -- Do NOT run `make format` (its gci config conflicts with lint). Use `make lint` + `gofmt` instead. -- Proto is the source of truth; never hand-edit generated `*.pb.go` / `api/`. Run `make proto-all` after proto edits. -- Event error strings land in consensus events, so they must be deterministic. `reconcileVault` and `GetNetTVVInUnderlyingAsset` errors are plain `fmt.Errorf` wraps (no map ordering / randomness) and are safe. - ---- - -### Task 1: Add proto fields and regenerate - -**Files:** -- Modify: `proto/provlabs/vault/v1/tx.proto:474` (MsgPauseVaultRequest) -- Modify: `proto/provlabs/vault/v1/events.proto:268` (EventVaultPaused) -- Regenerated (do not hand-edit): `types/tx.pb.go`, `types/events.pb.go`, `api/...` - -**Interfaces:** -- Produces: `MsgPauseVaultRequest.Force bool`; `EventVaultPaused.Forced bool`, `EventVaultPaused.ForcedError string`. - -- [ ] **Step 1: Add the `force` field to `MsgPauseVaultRequest`** - -In `proto/provlabs/vault/v1/tx.proto`, inside `message MsgPauseVaultRequest`, after the `reason` field: - -```proto - // force, when true, allows the pause to proceed even if the pre-pause - // reconcile or vault valuation fails. The pause is applied best-effort: - // tolerated failures are logged and surfaced on EventVaultPaused, and - // PausedBalance may be the net TVV, zero (when valuation itself failed), or - // otherwise approximate. When false (default), any reconcile or valuation - // failure aborts the pause and the vault remains unpaused. - bool force = 4; -``` - -- [ ] **Step 2: Add `forced` and `forced_error` to `EventVaultPaused`** - -In `proto/provlabs/vault/v1/events.proto`, inside `message EventVaultPaused`, after `total_vault_value`: - -```proto - // forced indicates the pause used the force path, waiving the strict - // reconcile/valuation gate. False for a normal strict pause; true for a - // forced manual pause and for automated auto-pauses. - bool forced = 5; - // forced_error records the reconcile and/or valuation error that was - // tolerated when forced is true. Empty when nothing failed. When non-empty, - // total_vault_value may be stale or zero. - string forced_error = 6; -``` - -- [ ] **Step 3: Regenerate Go code** - -Run: `make proto-all` -Expected: completes without error; `git status` shows changes in `types/tx.pb.go`, `types/events.pb.go`, `api/`. - -- [ ] **Step 4: Verify the build still compiles** - -Run: `go build ./...` -Expected: success. New fields are additive and not yet referenced, so the build stays green. - -- [ ] **Step 5: Commit** - -```bash -git add proto/ types/tx.pb.go types/events.pb.go api/ -git commit -m "feat: add force flag to MsgPauseVaultRequest and forced/forced_error to EventVaultPaused" -``` - ---- - -### Task 2: Extend the event constructor and update existing call sites - -**Files:** -- Modify: `types/events.go:202` (`NewEventVaultPaused`) -- Modify: `keeper/vault.go:526-543` (`autoPauseVault`) -- Modify: `keeper/msg_server.go:586` (`PauseVault` emit, interim) - -**Interfaces:** -- Consumes: `EventVaultPaused.Forced`, `EventVaultPaused.ForcedError` (Task 1). -- Produces: `NewEventVaultPaused(vaultAddress, authority, reason string, totalVaultValue sdk.Coin, forced bool, forcedError string) *EventVaultPaused`. - -- [ ] **Step 1: Update the constructor signature and body** - -In `types/events.go`, replace `NewEventVaultPaused`: - -```go -// NewEventVaultPaused creates a new EventVaultPaused event. forced reports -// whether the pause waived the strict reconcile/valuation gate, and forcedError -// carries the tolerated error (empty when nothing failed). -func NewEventVaultPaused(vaultAddress, authority, reason string, totalVaultValue sdk.Coin, forced bool, forcedError string) *EventVaultPaused { - return &EventVaultPaused{ - VaultAddress: vaultAddress, - Authority: authority, - Reason: reason, - TotalVaultValue: totalVaultValue.String(), - Forced: forced, - ForcedError: forcedError, - } -} -``` - -- [ ] **Step 2: Update `autoPauseVault` to record the tolerated valuation error** - -In `keeper/vault.go`, in `autoPauseVault`, capture the TVV error and pass `forced=true`: - -```go - var forcedError string - tvv, err := k.GetNetTVVInUnderlyingAsset(ctx, *vault) - if err != nil { - forcedError = fmt.Sprintf("valuation failed: %v", err) - k.getLogger(ctx).Error("Failed to get net TVV in underlying asset", "vault_address", vault.GetAddress().String(), "error", err) - } - - vault.Paused = true - vault.PausedReason = reason - vault.PausedBalance = sdk.Coin{Denom: vault.UnderlyingAsset, Amount: tvv} - k.AuthKeeper.SetAccount(ctx, vault) // Updating via SetAccount to skip validation since auto-pausing is triggered by invalid state - - k.emitEvent(ctx, types.NewEventVaultPaused(vault.GetAddress().String(), vault.GetAddress().String(), reason, vault.PausedBalance, true, forcedError)) -``` - -Confirm `fmt` is imported in `keeper/vault.go` (it is used elsewhere in the file); if not, add it. - -- [ ] **Step 3: Update the `PauseVault` emit call (interim, replaced in Task 3)** - -In `keeper/msg_server.go:586`, change the emit so the file compiles with the new signature. Pass `false, ""` for now; Task 3 wires the real values: - -```go - k.emitEvent(ctx, types.NewEventVaultPaused(msg.VaultAddress, msg.Authority, msg.Reason, vault.PausedBalance, false, "")) -``` - -- [ ] **Step 4: Build and run the affected package tests** - -Run: `go build ./... && go test ./types/... ./keeper/... -run 'Pause|AutoPause' -count=1` -Expected: PASS. Existing tests assert the event *type* string only, so the new fields do not break them. - -- [ ] **Step 5: Commit** - -```bash -git add types/events.go keeper/vault.go keeper/msg_server.go -git commit -m "feat: thread forced/forced_error through EventVaultPaused constructor and auto-pause" -``` - ---- - -### Task 3: Gate best-effort pause behind `force` in the handler - -This task carries its own TDD cycle. The existing branch test -`TestMsgServer_PauseVault_ReconcileFailureStillPauses` -(`keeper/msg_server_test.go:4416`) currently pauses through a failed reconcile -*without* `force`; it must be updated to set `Force: true`, and strict-mode -failure cases must be added. - -**Files:** -- Modify: `keeper/msg_server.go:540-589` (`PauseVault`) -- Modify: `keeper/msg_server_test.go:4416` (`TestMsgServer_PauseVault_ReconcileFailureStillPauses`) - -**Interfaces:** -- Consumes: `MsgPauseVaultRequest.Force` (Task 1); `NewEventVaultPaused(..., forced bool, forcedError string)` (Task 2); `types.ZeroInterestRate` (`types/vault.go:21`); `types.NewEventVaultInterestChange(addr, currentRate, desiredRate string)` (`types/events.go:80`). - -- [ ] **Step 1: Rewrite the failing tests first** - -Replace `TestMsgServer_PauseVault_ReconcileFailureStillPauses` in `keeper/msg_server_test.go` with a version that covers strict failure AND forced success. The two `setup` closures (insufficient reserves; broken TVV) are unchanged from the current branch version; only the request `Force` value, the expected error, and the new assertions differ. - -```go -func (s *TestSuite) TestMsgServer_PauseVault_ForceVsStrict() { - reason := "emergency" - - insufficientReservesSetup := func() (sdk.AccAddress, string) { - underlying := "under" - share := "vaultshares" - s.requireAddFinalizeAndActivateMarker(sdk.NewCoin(underlying, math.NewInt(10_000)), s.adminAddr) - _, err := s.k.CreateVault(s.ctx, &types.MsgCreateVaultRequest{ - Admin: s.adminAddr.String(), - ShareDenom: share, - UnderlyingAsset: underlying, - }) - s.Require().NoError(err, "vault creation should succeed for share denom %s", share) - vaultAddr := types.GetVaultAddress(share) - vault, err := s.k.GetVault(s.ctx, vaultAddr) - s.Require().NoError(err, "loading the vault for setup should succeed") - - oneDay := int64(24 * time.Hour / time.Second) - vault.CurrentInterestRate = "1.0" - vault.DesiredInterestRate = "1.0" - vault.PeriodStart = s.ctx.BlockTime().Unix() - oneDay - s.k.AuthKeeper.SetAccount(s.ctx, vault) - - s.Require().NoError(FundAccount(s.ctx, s.simApp.BankKeeper, vault.PrincipalMarkerAddress(), - sdk.NewCoins(sdk.NewInt64Coin(underlying, 100_000))), - "funding the principal marker should succeed so reconcile owes unpayable interest") - s.ctx = s.ctx.WithEventManager(sdk.NewEventManager()) - return vaultAddr, underlying - } - - brokenTVVSetup := func() (sdk.AccAddress, string) { - underlying := "ylds" - share := "vshare" - payment := "usdc" - s.requireAddFinalizeAndActivateMarker(sdk.NewInt64Coin(payment, 1_000_000), s.adminAddr) - vault := s.setupBaseVault(underlying, share, payment) - s.Require().NoError(s.k.NAVs.Remove(s.ctx, collections.Join(vault.GetAddress(), payment)), - "removing the bootstrap payment NAV should surface the conversion failure") - s.Require().NoError(FundAccount(s.ctx, s.simApp.BankKeeper, vault.PrincipalMarkerAddress(), - sdk.NewCoins(sdk.NewInt64Coin(payment, 10))), - "funding the principal with an unpriced accepted denom should make TVV conversion error") - s.ctx = s.ctx.WithEventManager(sdk.NewEventManager()) - return vault.GetAddress(), underlying - } - - tests := []struct { - name string - setup func() (sdk.AccAddress, string) - force bool - expectErr bool - expectedPauseAmount int64 - expectForcedError bool - }{ - { - name: "strict pause aborts when reconcile fails on insufficient reserves", - setup: insufficientReservesSetup, - force: false, - expectErr: true, - }, - { - name: "strict pause aborts when TVV conversion fails", - setup: brokenTVVSetup, - force: false, - expectErr: true, - }, - { - name: "force pause proceeds through insufficient-reserves reconcile failure", - setup: insufficientReservesSetup, - force: true, - expectErr: false, - expectedPauseAmount: 100_000, - expectForcedError: true, - }, - { - name: "force pause proceeds through broken TVV with zero snapshot", - setup: brokenTVVSetup, - force: true, - expectErr: false, - expectedPauseAmount: 0, - expectForcedError: true, - }, - } - - for _, tc := range tests { - s.Run(tc.name, func() { - s.SetupTest() - vaultAddr, underlying := tc.setup() - - _, err := keeper.NewMsgServer(s.simApp.VaultKeeper).PauseVault(s.ctx, &types.MsgPauseVaultRequest{ - Authority: s.adminAddr.String(), - VaultAddress: vaultAddr.String(), - Reason: reason, - Force: tc.force, - }) - - v, getErr := s.k.GetVault(s.ctx, vaultAddr) - s.Require().NoError(getErr, "loading the vault should succeed for case %q", tc.name) - - if tc.expectErr { - s.Require().Error(err, "strict pause must fail when pre-pause bookkeeping fails for case %q", tc.name) - s.Assert().False(v.Paused, "vault must remain unpaused after a failed strict pause for case %q", tc.name) - return - } - - s.Require().NoError(err, "force pause must succeed even when the pre-pause reconcile fails for case %q", tc.name) - s.Assert().True(v.Paused, "vault should be paused after a force pause for case %q", tc.name) - s.Assert().Equal(reason, v.PausedReason, "paused reason should be recorded for case %q", tc.name) - s.Assert().Equal(types.ZeroInterestRate, v.CurrentInterestRate, "pausing should zero the current interest rate for case %q", tc.name) - s.Assert().Equal(underlying, v.PausedBalance.Denom, "paused balance should be denominated in the underlying asset for case %q", tc.name) - s.Assert().Equal(tc.expectedPauseAmount, v.PausedBalance.Amount.Int64(), "paused balance should be the best-effort TVV snapshot for case %q", tc.name) - - pausedEvent := s.findLastEventVaultPaused() - s.Require().NotNil(pausedEvent, "an EventVaultPaused should have been emitted for case %q", tc.name) - s.Assert().True(pausedEvent.Forced, "the forced flag should be set on a force pause for case %q", tc.name) - if tc.expectForcedError { - s.Assert().NotEmpty(pausedEvent.ForcedError, "forced_error should record the tolerated failure for case %q", tc.name) - } - }) - } -} -``` - -- [ ] **Step 2: Add the `findLastEventVaultPaused` helper to `suite_test.go`** - -Per the DRY mandate, add a typed-event lookup helper in `keeper/suite_test.go`: - -```go -// findLastEventVaultPaused returns the most recent EventVaultPaused decoded from -// the context event manager, or nil when none has been emitted. -func (s *TestSuite) findLastEventVaultPaused() *types.EventVaultPaused { - events := s.ctx.EventManager().Events() - for i := len(events) - 1; i >= 0; i-- { - if events[i].Type != "provlabs.vault.v1.EventVaultPaused" { - continue - } - msg, err := sdk.ParseTypedEvent(abci.Event(events[i])) - s.Require().NoError(err, "parsing the EventVaultPaused typed event should succeed") - paused, ok := msg.(*types.EventVaultPaused) - s.Require().True(ok, "parsed event should be *EventVaultPaused") - return paused - } - return nil -} -``` - -Ensure `keeper/suite_test.go` imports `abci "github.com/cometbft/cometbft/abci/types"` (add it if missing). - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `go test ./keeper/... -run 'TestMsgServer_PauseVault_ForceVsStrict|TestMsgServer_PauseVault_ReconcileFailureStillPauses' -count=1` -Expected: FAIL. Strict cases currently pass best-effort (no error), and the old test name no longer exists. This confirms the new behavior is not yet implemented. - -- [ ] **Step 4: Implement the strict/force branch in `PauseVault`** - -Replace the body of `PauseVault` in `keeper/msg_server.go` (from the `if vault.Paused` check through the final emit) with: - -```go - if vault.Paused { - return nil, fmt.Errorf("vault %s is already paused", msg.VaultAddress) - } - - var forcedErrors []string - - if err = k.reconcileVault(ctx, vault); err != nil { - if !msg.Force { - return nil, fmt.Errorf("failed to reconcile before pausing: %w", err) - } - forcedErrors = append(forcedErrors, fmt.Sprintf("reconcile failed: %v", err)) - k.getLogger(ctx).Error( - "reconcile failed before forced pause; pausing best-effort", - "vault", msg.VaultAddress, - "err", err, - ) - } - - tvv, err := k.GetNetTVVInUnderlyingAsset(ctx, *vault) - if err != nil { - if !msg.Force { - return nil, fmt.Errorf("failed to get net TVV before pausing: %w", err) - } - forcedErrors = append(forcedErrors, fmt.Sprintf("valuation failed: %v", err)) - k.getLogger(ctx).Error( - "failed to value vault before forced pause; snapshotting zero paused balance", - "vault", msg.VaultAddress, - "err", err, - ) - tvv = sdkmath.ZeroInt() - } - - vault.PausedBalance = sdk.NewCoin(vault.UnderlyingAsset, tvv) - vault.Paused = true - vault.PausedReason = msg.Reason - vault.CurrentInterestRate = types.ZeroInterestRate - k.emitEvent(ctx, types.NewEventVaultInterestChange(vault.GetAddress().String(), types.ZeroInterestRate, vault.DesiredInterestRate)) - - if msg.Force { - k.AuthKeeper.SetAccount(ctx, vault) - } else if err := k.SetVaultAccount(ctx, vault); err != nil { - return nil, fmt.Errorf("failed to set vault account: %w", err) - } - - forcedError := strings.Join(forcedErrors, "; ") - k.emitEvent(ctx, types.NewEventVaultPaused(msg.VaultAddress, msg.Authority, msg.Reason, vault.PausedBalance, msg.Force, forcedError)) - - return &types.MsgPauseVaultResponse{}, nil -``` - -Notes for the implementer: -- This removes the `UpdateInterestRates` call. That helper persisted via the validating `SetVaultAccount` internally, which (a) double-persisted with the existing trailing `SetVaultAccount` and (b) made force-mode validation-skipping impossible. Zeroing `CurrentInterestRate` inline plus emitting `NewEventVaultInterestChange` preserves the prior observable behavior (rate set to `0.0`, interest-change event emitted) while persisting exactly once under mode control. `DesiredInterestRate` is intentionally left unchanged, matching the prior `UpdateInterestRates(ctx, vault, ZeroInterestRate, vault.DesiredInterestRate)` call. -- Add `"strings"` to the import block in `keeper/msg_server.go`. - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `go test ./keeper/... -run 'TestMsgServer_PauseVault' -count=1` -Expected: PASS, including the existing `TestMsgServer_PauseVault` and `TestMsgServer_PauseVault_Failures`. - -- [ ] **Step 6: Update the godoc on `PauseVault`** - -Replace the doc comment above `PauseVault` so it describes the gated behavior rather than unconditional best-effort: - -```go -// PauseVault pauses a vault, disabling all user-facing operations. -// -// By default the pause is strict: it reconciles outstanding interest and fees -// and snapshots the net TVV first, and any failure (e.g. insufficient reserves -// to settle positive interest, or a broken TVV/NAV conversion) aborts the pause -// and leaves the vault unpaused. Setting Force on the request makes the pause an -// emergency control that mirrors autoPauseVault: tolerated reconcile/valuation -// failures are logged and surfaced via EventVaultPaused.forced_error, the frozen -// PausedBalance is the net TVV when valuable or zero when valuation itself failed, -// and the account is persisted without validation so an invalid-state vault can -// still be frozen. -``` - -- [ ] **Step 7: Commit** - -```bash -git add keeper/msg_server.go keeper/msg_server_test.go keeper/suite_test.go -git commit -m "feat: make PauseVault strict by default, gate best-effort pause behind force flag" -``` - ---- - -### Task 4: Update the spec - -**Files:** -- Modify: `spec/03_messages.md` (PauseVault row at line 70; PauseVault section at line 276) -- Modify: `spec/04_events.md` (EventVaultPaused section ~line 67) -- Modify: `spec/06_blocker.md` (auto-pause notes ~line 176) - -- [ ] **Step 1: Update the message reference** - -In `spec/03_messages.md`, replace the PauseVault summary-table row (line 70) Notes cell: - -``` -| `PauseVault` | Admin or Asset Manager | ✅ | ❌ | Strict by default: reconciles, snapshots `PausedBalance`, sets paused; aborts if reconcile/valuation fails. `force=true` pauses best-effort, tolerating failures and recording them on `EventVaultPaused`. | -``` - -Then replace the `## PauseVault` section body (lines 276-281): - -```markdown -## PauseVault - -Admin or Asset Manager. Pauses a vault, disabling swap-ins and swap-outs, and recording reason + balance snapshot. - -By default the pause is **strict**: it reconciles outstanding interest and fees and values the vault first, and any failure (insufficient reserves to settle positive interest, or a broken TVV/NAV conversion) aborts the request and leaves the vault unpaused. The failed transaction is the operator's signal that the vault is in an unexpected state. - -Setting `force = true` makes the pause an **emergency control**: a reconcile or valuation failure is logged and tolerated rather than blocking the freeze. The frozen `PausedBalance` is the net TVV when the vault can be valued, or zero when the valuation itself is what failed, so it may be approximate. The tolerated error is recorded on `EventVaultPaused.forced_error`. - -* **Request:** `MsgPauseVaultRequest { authority, vault_address, reason, force }` -* **Response:** `MsgPauseVaultResponse {}` -``` - -- [ ] **Step 2: Document the new event fields** - -In `spec/04_events.md`, in the `### EventVaultPaused` field list, add: - -```markdown -* `forced` — true when the pause used the force path and waived the strict reconcile/valuation gate (also true for automated auto-pauses) -* `forced_error` — the reconcile and/or valuation error tolerated during a forced pause; empty when nothing failed. When non-empty, `total_vault_value` may be stale or zero. -``` - -- [ ] **Step 3: Note the auto-pause event semantics** - -In `spec/06_blocker.md`, near the auto-pause description (~line 176), add a sentence: - -```markdown -Auto-pause emits `EventVaultPaused` with `forced = true` and, when valuation failed, the tolerated error in `forced_error`; the manual `PauseVault` path produces the same signal only when called with `force = true`. -``` - -- [ ] **Step 4: Commit** - -```bash -git add spec/ -git commit -m "docs: document strict/force PauseVault semantics and EventVaultPaused forced fields" -``` - ---- - -### Task 5: Full local verification - -**Files:** none (verification only). - -- [ ] **Step 1: Lint** - -Run: `make lint` -Expected: no findings. If `gofmt` differences appear, run `gofmt -w` on the changed files (do NOT run `make format`). - -- [ ] **Step 2: Full unit test suite** - -Run: `go test ./... -count=1` -Expected: PASS. - -- [ ] **Step 3: Simplest sims** - -Run: `make test-sim-simple && make test-sim-nondeterminism` -Expected: PASS. (Equivalent to invoking the `vault-lint-test` skill, which wraps format/lint/test/sims.) - -- [ ] **Step 4: Final review of the diff** - -Run: `git diff main...HEAD --stat` -Expected: changes limited to `proto/`, regenerated `types/*.pb.go` + `api/`, `types/events.go`, `keeper/vault.go`, `keeper/msg_server.go`, `keeper/msg_server_test.go`, `keeper/suite_test.go`, and `spec/`. - ---- - -## Self-Review Notes - -- **Spec coverage:** strict-default behavior (Task 3), force flag (Tasks 1, 3), event audit fields (Tasks 1-3), determinism of error strings (Global Constraints), spec sync (Task 4) — all covered. -- **Type consistency:** `NewEventVaultPaused(..., forced bool, forcedError string)` defined in Task 2 and called identically in Tasks 2-3; `MsgPauseVaultRequest.Force` defined in Task 1 and consumed in Task 3; `findLastEventVaultPaused` defined and used in Task 3. -- **Carried-over decision:** `forced_error` joins reconcile-then-valuation failures with `"; "` rather than keeping only the first error (per the design discussion). If only the primary error is preferred, drop the `forcedErrors` slice and assign the single error string at each failure site instead. diff --git a/keeper/reconcile.go b/keeper/reconcile.go index 0def5954..d5410248 100644 --- a/keeper/reconcile.go +++ b/keeper/reconcile.go @@ -432,14 +432,21 @@ func parseFeeRemainder(remainder string) (sdkmath.LegacyDec, error) { } // AccrueAUMFeePayment computes the AUM fee accrued from the vault's FeePeriodStart to the -// current block time, folds in the fractional FeeRemainder carried from prior periods, and -// returns the collectible whole-unit portion converted to the vault's PaymentDenom. The -// leftover sub-unit fraction is written back to vault.FeeRemainder so that repeated truncation -// over short accrual windows does not discard protocol revenue. +// current block time, folds in the FeeRemainder carried from prior periods, and returns the +// collectible portion converted to the vault's PaymentDenom. +// +// FeeRemainder tracks accrued fee value, denominated in the underlying asset, that has not +// yet been collected. Only the underlying that maps to a whole, non-zero PaymentDenom amount +// is realized here; the rest is written back to vault.FeeRemainder so it carries forward. +// This matters when PaymentDenom is coarser than the underlying asset: converting a small +// whole-underlying amount can floor to zero PaymentDenom units, and deducting that underlying +// before the conversion would silently discard protocol revenue. Because a coarse PaymentDenom +// can require several underlying units to accrue before a single PaymentDenom unit is due, the +// carried remainder may exceed one whole underlying unit until enough value accumulates. // // This method mutates vault.FeeRemainder but does not persist the vault; the caller is // responsible for persistence. It returns a zero PaymentDenom coin when accrual has not -// started, no time has elapsed, or no whole unit is yet due. +// started, no time has elapsed, or nothing is yet collectible. func (k Keeper) AccrueAUMFeePayment(ctx sdk.Context, vault *types.VaultAccount, totalAssets sdkmath.Int) (sdk.Coin, error) { zero := sdk.NewCoin(vault.PaymentDenom, sdkmath.ZeroInt()) @@ -463,9 +470,9 @@ func (k Keeper) AccrueAUMFeePayment(ctx sdk.Context, vault *types.VaultAccount, combined := feeDec.Add(carried) feeUnderlying := combined.TruncateInt() - vault.FeeRemainder = combined.Sub(sdkmath.LegacyNewDecFromInt(feeUnderlying)).String() if feeUnderlying.IsZero() { + vault.FeeRemainder = combined.String() return zero, nil } @@ -473,6 +480,17 @@ func (k Keeper) AccrueAUMFeePayment(ctx sdk.Context, vault *types.VaultAccount, if err != nil { return sdk.Coin{}, fmt.Errorf("failed to convert accrued fee to payment denom: %w", err) } + + collectedUnderlying := sdkmath.ZeroInt() + if feePayment.IsPositive() { + collectedUnderlying, err = k.ToUnderlyingAssetAmount(ctx, *vault, sdk.NewCoin(vault.PaymentDenom, feePayment)) + if err != nil { + return sdk.Coin{}, fmt.Errorf("failed to convert collectible fee back to underlying: %w", err) + } + } + + vault.FeeRemainder = combined.Sub(sdkmath.LegacyNewDecFromInt(collectedUnderlying)).String() + return sdk.NewCoin(vault.PaymentDenom, feePayment), nil } diff --git a/keeper/reconcile_test.go b/keeper/reconcile_test.go index db0babea..66c51832 100644 --- a/keeper/reconcile_test.go +++ b/keeper/reconcile_test.go @@ -2323,6 +2323,50 @@ func (s *TestSuite) TestKeeper_AccrualCalculations() { "fee remainder must be a fraction in [0,1), got %s", remainder) }) + s.Run("AccrueAUMFeePayment carries uncollected underlying under a coarse payment denom", func() { + tests := []struct { + name string + initialRemainder string + expectedPayment int64 + expectedRemainder string + }{ + { + name: "whole underlying unit that floors to zero payment is carried, not dropped", + initialRemainder: "1.5", + expectedPayment: 0, + expectedRemainder: "1.500000000000000000", + }, + { + name: "carry is collected once it represents a whole payment unit", + initialRemainder: "2.3", + expectedPayment: 1, + expectedRemainder: "0.300000000000000000", + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + s.SetupTest() + vault := setup() + vault.AumFeeBips = 0 + + payment := "coarsepay" + s.requireAddFinalizeAndActivateMarker(sdk.NewInt64Coin(payment, 1_000_000), s.adminAddr) + s.setVaultPaymentDenomWithNAV(vault, payment, sdk.NewInt64Coin(underlyingDenom, 2), 1) + + vault.FeePeriodStart = pastTime.Unix() + vault.FeeRemainder = tc.initialRemainder + + collected, err := s.k.AccrueAUMFeePayment(s.ctx, vault, underlying.Amount) + s.Require().NoError(err, "AccrueAUMFeePayment should not error for case %s", tc.name) + s.Require().Equal(sdk.NewInt64Coin(payment, tc.expectedPayment).String(), collected.String(), + "collected payment mismatch for case %s", tc.name) + s.Require().Equal(tc.expectedRemainder, vault.FeeRemainder, + "fee remainder should retain uncollected underlying for case %s", tc.name) + }) + } + }) + s.Run("CalculateVaultTotalAssets", func() { s.SetupTest() vault := setup() diff --git a/proto/provlabs/vault/v1/vault.proto b/proto/provlabs/vault/v1/vault.proto index c3c014c4..af222380 100644 --- a/proto/provlabs/vault/v1/vault.proto +++ b/proto/provlabs/vault/v1/vault.proto @@ -150,10 +150,13 @@ message VaultAccount { // authority. It is rotated only via MsgUpdateNAVAuthority. string nav_authority = 29 [(cosmos_proto.scalar) = "cosmos.AddressString"]; - // fee_remainder is the sub-unit AUM fee, denominated in the underlying_asset, - // carried across periods. Each reconciliation adds it to the precise fee before - // truncation, collects the whole-unit portion, and stores the new fraction so - // truncation does not discard protocol revenue. A decimal string; empty is zero. + // fee_remainder is accrued AUM fee value, denominated in the underlying_asset, + // that has not yet been collected, carried across periods. Each reconciliation + // folds it into the precise fee, collects only the portion representable as whole + // payment_denom units, and stores the uncollected remainder so truncation does not + // discard protocol revenue. When payment_denom is coarser than underlying_asset, + // this can exceed one whole underlying unit until enough value accrues to convert + // to a whole payment_denom unit. A decimal string; empty is zero. string fee_remainder = 30 [(cosmos_proto.scalar) = "cosmos.DecString"]; } diff --git a/spec/02_state.md b/spec/02_state.md index 65830526..051baeab 100644 --- a/spec/02_state.md +++ b/spec/02_state.md @@ -34,7 +34,7 @@ Each vault is an `x/auth` account implementing `VaultAccountI`. The canonical re - **Total supply-of-record:** `total_shares` (authoritative across chains; includes locally and externally held shares) - **Bridging controls:** `bridge_address` (the sole authorized external address) and `bridge_enabled` (feature gate) - **Asset Management:** optional `asset_manager` address with delegated authority; it is also the sole authority for P2P settlement (`AcceptAsset`/`RejectAsset`). -- **AUM Fee State:** `fee_period_start`, `fee_period_timeout`, `outstanding_aum_fee`, and `fee_remainder` (the sub-unit fractional fee, in underlying units, carried across periods so truncation does not discard revenue). +- **AUM Fee State:** `fee_period_start`, `fee_period_timeout`, `outstanding_aum_fee`, and `fee_remainder` (accrued-but-uncollected fee, in underlying units, carried across periods so truncation does not discard revenue; it can exceed one whole underlying unit when `payment_denom` is coarser than `underlying_asset`). - **NAV Authority:** optional `nav_authority` address authorized to mutate the vault's internal NAV table via `MsgUpdateVaultNAV`; the admin acts as NAV authority when unset. `VaultAccount` enforces invariants (e.g., payment denom cannot equal underlying, rate bounds, etc.) and provides helpers like `AcceptedDenoms()` and `ValidateAcceptedDenom`. :contentReference[oaicite:1]{index=1} diff --git a/spec/06_blocker.md b/spec/06_blocker.md index b24e6b5a..915a8e01 100644 --- a/spec/06_blocker.md +++ b/spec/06_blocker.md @@ -85,7 +85,7 @@ Reconciles the 15 bps AUM technology fee for vaults whose fee timeout has elapse 3. **Attempt Atomic Reconciliation** (via `atomicallyReconcileFee` using `CacheContext`): - **PerformVaultFeeTransfer**: - Computes fee based on **Gross TVV**. - - Adds the fractional `fee_remainder` carried from prior periods, collects only the whole-unit portion, and stores the new sub-unit fraction back to `fee_remainder` so frequent short accrual windows do not discard revenue to truncation. + - Adds the `fee_remainder` carried from prior periods, collects only the portion representable as whole `payment_denom` units, and stores the uncollected remainder back to `fee_remainder` so frequent short accrual windows and coarse payment denoms do not discard revenue to truncation. When `payment_denom` is coarser than `underlying_asset`, the carried remainder can exceed one whole underlying unit until enough value accrues to convert to a whole `payment_denom` unit. - Collects from principal marker into the configured ProvLabs collection address. - **Success (Partial/Full Collection)**: If the marker lacks liquidity, the uncollected remainder is recorded in `outstanding_aum_fee`. This is considered a successful transfer. - **Schedules next fee timeout** and commits state changes. diff --git a/types/vault.go b/types/vault.go index fffc953b..1682e9af 100644 --- a/types/vault.go +++ b/types/vault.go @@ -303,9 +303,6 @@ func (v VaultAccount) Validate() error { if remainder.IsNegative() { return fmt.Errorf("fee remainder cannot be negative: %s", v.FeeRemainder) } - if remainder.GTE(sdkmath.LegacyOneDec()) { - return fmt.Errorf("fee remainder must be a sub-unit fraction less than one: %s", v.FeeRemainder) - } } if err := ValidateSwapLimits(v.MinSwapInValue, v.MaxSwapInValue); err != nil { diff --git a/types/vault.pb.go b/types/vault.pb.go index 12b18cec..533f9f0d 100644 --- a/types/vault.pb.go +++ b/types/vault.pb.go @@ -144,10 +144,13 @@ type VaultAccount struct { // table via MsgUpdateVaultNAV. If empty, the vault admin is treated as the NAV // authority. It is rotated only via MsgUpdateNAVAuthority. NavAuthority string `protobuf:"bytes,29,opt,name=nav_authority,json=navAuthority,proto3" json:"nav_authority,omitempty"` - // fee_remainder is the sub-unit AUM fee, denominated in the underlying_asset, - // carried across periods. Each reconciliation adds it to the precise fee before - // truncation, collects the whole-unit portion, and stores the new fraction so - // truncation does not discard protocol revenue. A decimal string; empty is zero. + // fee_remainder is accrued AUM fee value, denominated in the underlying_asset, + // that has not yet been collected, carried across periods. Each reconciliation + // folds it into the precise fee, collects only the portion representable as whole + // payment_denom units, and stores the uncollected remainder so truncation does not + // discard protocol revenue. When payment_denom is coarser than underlying_asset, + // this can exceed one whole underlying unit until enough value accrues to convert + // to a whole payment_denom unit. A decimal string; empty is zero. FeeRemainder string `protobuf:"bytes,30,opt,name=fee_remainder,json=feeRemainder,proto3" json:"fee_remainder,omitempty"` } diff --git a/types/vault_test.go b/types/vault_test.go index d96bc24b..d79467c7 100644 --- a/types/vault_test.go +++ b/types/vault_test.go @@ -772,7 +772,7 @@ func TestVaultAccount_Validate(t *testing.T) { expectedErr: "fee remainder cannot be negative", }, { - name: "fee remainder equal to one whole unit is invalid", + name: "fee remainder at or above one whole unit is valid for coarse payment denoms", vaultAccount: types.VaultAccount{ BaseAccount: baseAcc, Admin: validAdmin, @@ -782,24 +782,9 @@ func TestVaultAccount_Validate(t *testing.T) { CurrentInterestRate: validInterest, DesiredInterestRate: validInterest, OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), - FeeRemainder: "1.000000000000000000", + FeeRemainder: "1.500000000000000000", }, - expectedErr: "fee remainder must be a sub-unit fraction less than one", - }, - { - name: "fee remainder exceeding one whole unit is invalid", - vaultAccount: types.VaultAccount{ - BaseAccount: baseAcc, - Admin: validAdmin, - TotalShares: sdk.NewInt64Coin(validDenom, 0), - UnderlyingAsset: "uusd", - PaymentDenom: "uusd", - CurrentInterestRate: validInterest, - DesiredInterestRate: validInterest, - OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), - FeeRemainder: "1.5", - }, - expectedErr: "fee remainder must be a sub-unit fraction less than one", + expectedErr: "", }, } From 2b6483ddaa6b40e4ffcb331c2b04dbc98281d36a Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 10:38:02 -0600 Subject: [PATCH 12/13] update reconcile test --- keeper/reconcile_test.go | 78 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/keeper/reconcile_test.go b/keeper/reconcile_test.go index 66c51832..f7689837 100644 --- a/keeper/reconcile_test.go +++ b/keeper/reconcile_test.go @@ -198,6 +198,84 @@ func (s *TestSuite) TestKeeper_ReconcileVault() { } } +func (s *TestSuite) TestKeeper_ReconcileVault_PersistsDustFeeRemainder() { + s.SetupTest() + + shareDenom := "dust.persist.shares" + underlyingDenom := "underlying" + vaultAddress := types.GetVaultAddress(shareDenom) + testBlockTime := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) + + s.requireAddFinalizeAndActivateMarker(sdk.NewInt64Coin(underlyingDenom, 10_000_000_000), s.adminAddr) + vault := s.CreateVaultWithParams(shareDenom, underlyingDenom, underlyingDenom) + vault.AumFeeBips = 15 + vault.CurrentInterestRate = types.ZeroInterestRate + vault.DesiredInterestRate = types.ZeroInterestRate + vault.PeriodStart = 0 + vault.FeePeriodStart = testBlockTime.Add(-6 * time.Second).Unix() + vault.FeeRemainder = "" + s.k.AuthKeeper.SetAccount(s.ctx, vault) + + s.FundMarker(shareDenom, sdk.NewCoins(sdk.NewInt64Coin(underlyingDenom, 1_000_000_000))) + s.ctx = s.ctx.WithBlockTime(testBlockTime) + + err := s.k.TestAccessor_reconcileVault(s.T(), s.ctx, vault) + s.Require().NoError(err, "reconcileVault should not error on a short dust-only accrual window") + + persisted, err := s.k.GetVault(s.ctx, vaultAddress) + s.Require().NoError(err, "should read the vault back from the store after reconcile") + + remainder, err := sdkmath.LegacyNewDecFromStr(persisted.FeeRemainder) + s.Require().NoError(err, "persisted fee remainder %q should parse", persisted.FeeRemainder) + s.Require().True(remainder.IsPositive() && remainder.LT(sdkmath.LegacyOneDec()), + "a dust-only accrual should persist a positive sub-unit fee remainder, got %s", persisted.FeeRemainder) + s.Require().True(persisted.OutstandingAumFee.IsZero(), + "dust below one whole unit should not be recorded as an outstanding fee, got %s", persisted.OutstandingAumFee) +} + +func (s *TestSuite) TestKeeper_HandleVaultFeeTimeouts_PersistsDustFeeRemainder() { + s.SetupTest() + + shareDenom := "dust.timeout.shares" + underlyingDenom := "underlying" + vaultAddress := types.GetVaultAddress(shareDenom) + testBlockTime := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) + feeStart := testBlockTime.Add(-6 * time.Second).Unix() + + s.requireAddFinalizeAndActivateMarker(sdk.NewInt64Coin(underlyingDenom, 10_000_000_000), s.adminAddr) + vault := s.CreateVaultWithParams(shareDenom, underlyingDenom, underlyingDenom) + s.Require().NoError(s.k.FeeTimeoutQueue.Dequeue(s.ctx, vault.FeePeriodTimeout, vaultAddress), + "clearing the bootstrap fee timeout should succeed") + + vault.AumFeeBips = 15 + vault.CurrentInterestRate = types.ZeroInterestRate + vault.DesiredInterestRate = types.ZeroInterestRate + vault.PeriodStart = 0 + vault.FeePeriodStart = feeStart + vault.FeePeriodTimeout = feeStart + vault.FeeRemainder = "" + s.k.AuthKeeper.SetAccount(s.ctx, vault) + + s.FundMarker(shareDenom, sdk.NewCoins(sdk.NewInt64Coin(underlyingDenom, 1_000_000_000))) + s.Require().NoError(s.k.FeeTimeoutQueue.Enqueue(s.ctx, feeStart, vaultAddress), + "enqueueing a due fee timeout should succeed") + + s.ctx = s.ctx.WithBlockTime(testBlockTime) + + err := s.k.TestAccessor_handleVaultFeeTimeouts(s.T(), s.ctx) + s.Require().NoError(err, "handleVaultFeeTimeouts should not error on a short dust-only accrual window") + + persisted, err := s.k.GetVault(s.ctx, vaultAddress) + s.Require().NoError(err, "should read the vault back from the store after the fee timeout runs") + + remainder, err := sdkmath.LegacyNewDecFromStr(persisted.FeeRemainder) + s.Require().NoError(err, "persisted fee remainder %q should parse", persisted.FeeRemainder) + s.Require().True(remainder.IsPositive() && remainder.LT(sdkmath.LegacyOneDec()), + "the fee timeout path should persist a positive sub-unit fee remainder, got %s", persisted.FeeRemainder) + s.Require().True(persisted.OutstandingAumFee.IsZero(), + "dust below one whole unit should not be recorded as an outstanding fee, got %s", persisted.OutstandingAumFee) +} + func (s *TestSuite) TestKeeper_PerformVaultReconcile_CompositeWithOutstandingFee() { shareDenom := "composite.shares" underlyingDenom := "underlying" From 110da2ef1ce8fc439e62dacc75ebbcc7cdf1a208 Mon Sep 17 00:00:00 2001 From: Carlton N Hanna Date: Wed, 1 Jul 2026 11:05:25 -0600 Subject: [PATCH 13/13] fix AUM fee dust valuation and bound imported fee remainder --- keeper/reconcile.go | 116 +++++++++++++++++++++------------------ keeper/reconcile_test.go | 9 ++- types/vault.go | 3 + types/vault_test.go | 17 +++++- 4 files changed, 87 insertions(+), 58 deletions(-) diff --git a/keeper/reconcile.go b/keeper/reconcile.go index d5410248..bf5a6ac1 100644 --- a/keeper/reconcile.go +++ b/keeper/reconcile.go @@ -392,34 +392,21 @@ func (k Keeper) CalculateAccruedInterest(ctx sdk.Context, vault types.VaultAccou // CalculateAccruedAUMFee calculates the AUM fees that would be collected for the vault // from its FeePeriodStart to the current block time, based on the provided total assets. // -// It mirrors AccrueAUMFeePayment by folding the carried FeeRemainder into the precise -// accrual before truncation. Without this, the non-mutating valuation path would subtract -// only the integer-truncated current fee and OutstandingAumFee, understating the liability -// whenever the carried fraction plus the current accrual crosses a whole underlying unit. -// Keeping the two paths symmetric ensures share NAV, mint, and redeem calculations recognize -// the same whole-unit fee that the collection path would realize. +// It returns the underlying-asset value of the portion that AccrueAUMFeePayment would +// actually realize as whole PaymentDenom units, not the raw truncated accrual. When the +// PaymentDenom is coarser than the underlying asset, part of the accrued fee floors to zero +// collectible PaymentDenom units and is carried forward rather than collected; subtracting +// that uncollectible part from share NAV would understate mint and redeem pricing before the +// fee can be collected. Sharing accruedCollectibleFee with the collection path keeps the two +// symmetric so valuation recognizes exactly the liability the vault will realize. // // It returns the fee amount in the underlying asset and does not mutate state. func (k Keeper) CalculateAccruedAUMFee(ctx sdk.Context, vault types.VaultAccount, totalAssets sdkmath.Int) (sdkmath.Int, error) { - if vault.FeePeriodStart == 0 { - return sdkmath.ZeroInt(), nil - } - duration := ctx.BlockTime().Unix() - vault.FeePeriodStart - if duration <= 0 { - return sdkmath.ZeroInt(), nil - } - - feeDec, err := interest.CalculateAUMFeeDec(totalAssets, vault.AumFeeBips, duration) - if err != nil { - return sdkmath.Int{}, fmt.Errorf("failed to calculate accrued AUM fee: %w", err) - } - - carried, err := parseFeeRemainder(vault.FeeRemainder) + _, collectibleUnderlying, _, err := k.accruedCollectibleFee(ctx, vault, totalAssets) if err != nil { - return sdkmath.Int{}, fmt.Errorf("failed to parse fee remainder %q: %w", vault.FeeRemainder, err) + return sdkmath.Int{}, err } - - return feeDec.Add(carried).TruncateInt(), nil + return collectibleUnderlying, nil } // parseFeeRemainder interprets a vault's persisted FeeRemainder string, treating an empty @@ -435,63 +422,84 @@ func parseFeeRemainder(remainder string) (sdkmath.LegacyDec, error) { // current block time, folds in the FeeRemainder carried from prior periods, and returns the // collectible portion converted to the vault's PaymentDenom. // -// FeeRemainder tracks accrued fee value, denominated in the underlying asset, that has not -// yet been collected. Only the underlying that maps to a whole, non-zero PaymentDenom amount -// is realized here; the rest is written back to vault.FeeRemainder so it carries forward. -// This matters when PaymentDenom is coarser than the underlying asset: converting a small -// whole-underlying amount can floor to zero PaymentDenom units, and deducting that underlying -// before the conversion would silently discard protocol revenue. Because a coarse PaymentDenom -// can require several underlying units to accrue before a single PaymentDenom unit is due, the -// carried remainder may exceed one whole underlying unit until enough value accumulates. -// -// This method mutates vault.FeeRemainder but does not persist the vault; the caller is -// responsible for persistence. It returns a zero PaymentDenom coin when accrual has not -// started, no time has elapsed, or nothing is yet collectible. +// This method mutates vault.FeeRemainder with the carry produced by accruedCollectibleFee but +// does not persist the vault; the caller is responsible for persistence. It returns a zero +// PaymentDenom coin when accrual has not started, no time has elapsed, or nothing is yet +// collectible. func (k Keeper) AccrueAUMFeePayment(ctx sdk.Context, vault *types.VaultAccount, totalAssets sdkmath.Int) (sdk.Coin, error) { zero := sdk.NewCoin(vault.PaymentDenom, sdkmath.ZeroInt()) - if vault.FeePeriodStart == 0 { - return zero, nil - } - duration := ctx.BlockTime().Unix() - vault.FeePeriodStart - if duration <= 0 { + if vault.FeePeriodStart == 0 || ctx.BlockTime().Unix()-vault.FeePeriodStart <= 0 { return zero, nil } - feeDec, err := interest.CalculateAUMFeeDec(totalAssets, vault.AumFeeBips, duration) + feePayment, _, remainder, err := k.accruedCollectibleFee(ctx, *vault, totalAssets) if err != nil { - return sdk.Coin{}, fmt.Errorf("failed to calculate accrued AUM fee: %w", err) + return sdk.Coin{}, err } + vault.FeeRemainder = remainder.String() + return feePayment, nil +} + +// accruedCollectibleFee computes the AUM fee accrued from the vault's FeePeriodStart to the +// current block time and folds in the FeeRemainder carried from prior periods. It splits the +// combined accrual into the portion collectible as whole PaymentDenom units and the underlying +// remainder that carries forward. +// +// FeeRemainder tracks accrued fee value, denominated in the underlying asset, that has not yet +// been collected. Only the underlying that maps to a whole, non-zero PaymentDenom amount is +// collectible; the rest is returned as the carry so it accumulates across periods. This matters +// when PaymentDenom is coarser than the underlying asset: converting a small whole-underlying +// amount can floor to zero PaymentDenom units, and deducting that underlying before the +// conversion would silently discard protocol revenue. Because a coarse PaymentDenom can require +// several underlying units to accrue before a single PaymentDenom unit is due, the carried +// remainder may exceed one whole underlying unit until enough value accumulates. +// +// It returns the collectible fee in PaymentDenom, the underlying-asset value of that collectible +// portion, and the carry to persist as the new FeeRemainder. It does not mutate state. +func (k Keeper) accruedCollectibleFee(ctx sdk.Context, vault types.VaultAccount, totalAssets sdkmath.Int) (sdk.Coin, sdkmath.Int, sdkmath.LegacyDec, error) { + zeroPayment := sdk.NewCoin(vault.PaymentDenom, sdkmath.ZeroInt()) + carried, err := parseFeeRemainder(vault.FeeRemainder) if err != nil { - return sdk.Coin{}, fmt.Errorf("failed to parse fee remainder %q: %w", vault.FeeRemainder, err) + return sdk.Coin{}, sdkmath.Int{}, sdkmath.LegacyDec{}, fmt.Errorf("failed to parse fee remainder %q: %w", vault.FeeRemainder, err) + } + + if vault.FeePeriodStart == 0 { + return zeroPayment, sdkmath.ZeroInt(), carried, nil + } + duration := ctx.BlockTime().Unix() - vault.FeePeriodStart + if duration <= 0 { + return zeroPayment, sdkmath.ZeroInt(), carried, nil + } + + feeDec, err := interest.CalculateAUMFeeDec(totalAssets, vault.AumFeeBips, duration) + if err != nil { + return sdk.Coin{}, sdkmath.Int{}, sdkmath.LegacyDec{}, fmt.Errorf("failed to calculate accrued AUM fee: %w", err) } combined := feeDec.Add(carried) feeUnderlying := combined.TruncateInt() - if feeUnderlying.IsZero() { - vault.FeeRemainder = combined.String() - return zero, nil + return zeroPayment, sdkmath.ZeroInt(), combined, nil } - feePayment, err := k.FromUnderlyingAssetAmount(ctx, *vault, feeUnderlying, vault.PaymentDenom) + feePayment, err := k.FromUnderlyingAssetAmount(ctx, vault, feeUnderlying, vault.PaymentDenom) if err != nil { - return sdk.Coin{}, fmt.Errorf("failed to convert accrued fee to payment denom: %w", err) + return sdk.Coin{}, sdkmath.Int{}, sdkmath.LegacyDec{}, fmt.Errorf("failed to convert accrued fee to payment denom: %w", err) } - collectedUnderlying := sdkmath.ZeroInt() + collectibleUnderlying := sdkmath.ZeroInt() if feePayment.IsPositive() { - collectedUnderlying, err = k.ToUnderlyingAssetAmount(ctx, *vault, sdk.NewCoin(vault.PaymentDenom, feePayment)) + collectibleUnderlying, err = k.ToUnderlyingAssetAmount(ctx, vault, sdk.NewCoin(vault.PaymentDenom, feePayment)) if err != nil { - return sdk.Coin{}, fmt.Errorf("failed to convert collectible fee back to underlying: %w", err) + return sdk.Coin{}, sdkmath.Int{}, sdkmath.LegacyDec{}, fmt.Errorf("failed to convert collectible fee back to underlying: %w", err) } } - vault.FeeRemainder = combined.Sub(sdkmath.LegacyNewDecFromInt(collectedUnderlying)).String() - - return sdk.NewCoin(vault.PaymentDenom, feePayment), nil + remainder := combined.Sub(sdkmath.LegacyNewDecFromInt(collectibleUnderlying)) + return sdk.NewCoin(vault.PaymentDenom, feePayment), collectibleUnderlying, remainder, nil } // CalculateOutstandingFeeUnderlying converts the vault's outstanding AUM fees into diff --git a/keeper/reconcile_test.go b/keeper/reconcile_test.go index f7689837..e4b406f5 100644 --- a/keeper/reconcile_test.go +++ b/keeper/reconcile_test.go @@ -479,13 +479,16 @@ func (s *TestSuite) TestKeeper_PerformVaultReconcile_CompositeWithOutstandingFee // Accruals on Gross (1b) // Interest = 41,952,013 - // Fee = 256,919 + // Fee accrued = 256,919 underlying, but with NAV 1 payment = 2 underlying only + // floor(256,919 / 2) = 128,459 payment units are collectible, worth 256,918 underlying. + // The leftover 1 underlying floors to zero payment units and carries forward, so it must + // not reduce share NAV until it becomes collectible. // Debt (Net of NAV) = 50,000,000 * 2 = 100,000,000 - // Net Valuation = (1,000,000,000 + 41,952,013) - 256,919 - 100,000,000 = 941,695,094 + // Net Valuation = (1,000,000,000 + 41,952,013) - 256,918 - 100,000,000 = 941,695,095 val, err := s.k.CalculateVaultTotalAssets(s.ctx, vault, sdk.NewInt64Coin(underlyingDenom, 1_000_000_000)) s.Require().NoError(err, "CalculateVaultTotalAssets should not error for case: Non-1:1 NAV Composite Conversion") - s.Require().Equal(sdkmath.NewInt(941_695_094), val, "valuation should correctly convert secondary debt via NAV") + s.Require().Equal(sdkmath.NewInt(941_695_095), val, "valuation should exclude uncollectible fee dust under coarse payment denom") }) } diff --git a/types/vault.go b/types/vault.go index 1682e9af..70622263 100644 --- a/types/vault.go +++ b/types/vault.go @@ -303,6 +303,9 @@ func (v VaultAccount) Validate() error { if remainder.IsNegative() { return fmt.Errorf("fee remainder cannot be negative: %s", v.FeeRemainder) } + if v.PaymentDenom == v.UnderlyingAsset && !remainder.TruncateInt().IsZero() { + return fmt.Errorf("fee remainder must carry less than one whole unit when payment denom equals underlying asset: %s", v.FeeRemainder) + } } if err := ValidateSwapLimits(v.MinSwapInValue, v.MaxSwapInValue); err != nil { diff --git a/types/vault_test.go b/types/vault_test.go index d79467c7..44781fb3 100644 --- a/types/vault_test.go +++ b/types/vault_test.go @@ -773,6 +773,21 @@ func TestVaultAccount_Validate(t *testing.T) { }, { name: "fee remainder at or above one whole unit is valid for coarse payment denoms", + vaultAccount: types.VaultAccount{ + BaseAccount: baseAcc, + Admin: validAdmin, + TotalShares: sdk.NewInt64Coin(validDenom, 0), + UnderlyingAsset: "uusd", + PaymentDenom: "musd", + CurrentInterestRate: validInterest, + DesiredInterestRate: validInterest, + OutstandingAumFee: sdk.NewInt64Coin("musd", 0), + FeeRemainder: "1.500000000000000000", + }, + expectedErr: "", + }, + { + name: "fee remainder at or above one whole unit is invalid when payment denom equals underlying asset", vaultAccount: types.VaultAccount{ BaseAccount: baseAcc, Admin: validAdmin, @@ -784,7 +799,7 @@ func TestVaultAccount_Validate(t *testing.T) { OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), FeeRemainder: "1.500000000000000000", }, - expectedErr: "", + expectedErr: "fee remainder must carry less than one whole unit", }, }