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..e36095ca --- /dev/null +++ b/.changelog/unreleased/improvements/224-aum-fee-dust.md @@ -0,0 +1,4 @@ +* 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). +* 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 a809f41d..5521266c 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,14 @@ 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 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"` } func (x *VaultAccount) Reset() { @@ -5037,6 +5111,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 +5392,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 +5493,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/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 } 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.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/migrations_test.go b/keeper/migrations_test.go index 543fddd6..b99f41d4 100644 --- a/keeper/migrations_test.go +++ b/keeper/migrations_test.go @@ -77,6 +77,100 @@ 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() { + 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 + } + + 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 + } + + zero := sdkmath.LegacyZeroDec().String() + + 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", + }, + } + + 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 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 // 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/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/keeper/reconcile.go b/keeper/reconcile.go index 4a3e3875..bf5a6ac1 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) @@ -389,35 +389,117 @@ 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 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) { + _, collectibleUnderlying, _, err := k.accruedCollectibleFee(ctx, vault, totalAssets) + if err != nil { + return sdkmath.Int{}, err + } + return collectibleUnderlying, nil +} + +// 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 FeeRemainder carried from prior periods, and returns the +// collectible portion converted to the vault's PaymentDenom. +// +// 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 || ctx.BlockTime().Unix()-vault.FeePeriodStart <= 0 { + return zero, nil + } + + feePayment, _, remainder, err := k.accruedCollectibleFee(ctx, *vault, totalAssets) + if err != nil { + 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{}, sdkmath.Int{}, sdkmath.LegacyDec{}, fmt.Errorf("failed to parse fee remainder %q: %w", vault.FeeRemainder, err) + } + if vault.FeePeriodStart == 0 { - return sdkmath.ZeroInt(), nil + return zeroPayment, sdkmath.ZeroInt(), carried, nil } duration := ctx.BlockTime().Unix() - vault.FeePeriodStart if duration <= 0 { - return sdkmath.ZeroInt(), nil + return zeroPayment, sdkmath.ZeroInt(), carried, nil } - 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) + feeDec, err := interest.CalculateAUMFeeDec(totalAssets, vault.AumFeeBips, duration) if err != nil { - return sdk.Coin{}, fmt.Errorf("failed to calculate accrued AUM fee: %w", err) + 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() { - return sdk.NewCoin(vault.PaymentDenom, sdkmath.ZeroInt()), nil + return zeroPayment, sdkmath.ZeroInt(), combined, nil } + 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) + } + + collectibleUnderlying := sdkmath.ZeroInt() + if feePayment.IsPositive() { + collectibleUnderlying, err = k.ToUnderlyingAssetAmount(ctx, vault, sdk.NewCoin(vault.PaymentDenom, feePayment)) + if err != nil { + return sdk.Coin{}, sdkmath.Int{}, sdkmath.LegacyDec{}, fmt.Errorf("failed to convert collectible fee back to underlying: %w", err) + } } - 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 23bbe335..e4b406f5 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" @@ -401,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") }) } @@ -2132,10 +2213,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 +2237,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) @@ -2213,7 +2310,7 @@ func (s *TestSuite) TestKeeper_AccrualCalculations() { } }) - s.Run("CalculateAccruedAUMFeePayment", func() { + s.Run("AccrueAUMFeePayment", func() { s.SetupTest() vault := setup() @@ -2249,6 +2346,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,13 +2356,98 @@ 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) }) } }) + 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(100) + 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("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/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 ( diff --git a/proto/provlabs/vault/v1/vault.proto b/proto/provlabs/vault/v1/vault.proto index 94812e1e..af222380 100644 --- a/proto/provlabs/vault/v1/vault.proto +++ b/proto/provlabs/vault/v1/vault.proto @@ -149,6 +149,15 @@ 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 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"]; } // VaultNAV is a single internal net asset value entry recording the price of one diff --git a/spec/02_state.md b/spec/02_state.md index 25ccc40c..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`, and `outstanding_aum_fee`. +- **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 1839a75e..915a8e01 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 `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 9e82a410..70622263 100644 --- a/types/vault.go +++ b/types/vault.go @@ -295,6 +295,19 @@ 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 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 { return fmt.Errorf("failed to validate swap-in limits: %w", err) } diff --git a/types/vault.pb.go b/types/vault.pb.go index a0432719..533f9f0d 100644 --- a/types/vault.pb.go +++ b/types/vault.pb.go @@ -144,6 +144,14 @@ 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 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"` } func (m *VaultAccount) Reset() { *m = VaultAccount{} } @@ -375,6 +383,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 +668,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 +766,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 +1358,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 +2294,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:]) diff --git a/types/vault_test.go b/types/vault_test.go index 8fca2805..44781fb3 100644 --- a/types/vault_test.go +++ b/types/vault_test.go @@ -711,6 +711,96 @@ 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", + }, + { + 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, + TotalShares: sdk.NewInt64Coin(validDenom, 0), + UnderlyingAsset: "uusd", + PaymentDenom: "uusd", + CurrentInterestRate: validInterest, + DesiredInterestRate: validInterest, + OutstandingAumFee: sdk.NewInt64Coin("uusd", 0), + FeeRemainder: "1.500000000000000000", + }, + expectedErr: "fee remainder must carry less than one whole unit", + }, } for _, tc := range tests {