diff --git a/Makefile b/Makefile index 422377601..74591c247 100644 --- a/Makefile +++ b/Makefile @@ -296,7 +296,7 @@ local-image: ifeq (,$(shell which heighliner)) echo 'heighliner' binary not found. Consider running `make get-heighliner` else - heighliner build -c layer --local --dockerfile cosmos --go-version "1.24.13-alpine3.20" --build-target "make install" --binaries "/go/bin/layerd" + heighliner build -c layer --local --dockerfile cosmos --go-version 1.24.13 --alpine-version 3.22 --build-target "make install" --binaries "/go/bin/layerd" endif get-localic: diff --git a/adr/adr1012 - reporter power cap.md b/adr/adr1012 - reporter power cap.md new file mode 100644 index 000000000..f597900fa --- /dev/null +++ b/adr/adr1012 - reporter power cap.md @@ -0,0 +1,81 @@ +# ADR 1012: Reporter power cap + +## Authors + +@danflo27 + +## Changelog + +- 2026-06-12: initial version +- 2026-06-12: disable interchain accounts (host and controller) in the same upgrade after finding mainnet's ICA host allowlist set to `["*"]`; only interchain queries remain supported +- 2026-06-12: documented the decision to leave the delegator 30% cap hardcoded while the reporter cap is a param + +## Context + +Layer already limits stake concentration on the validator/delegator side. The `TrackStakeChangesDecorator` ante handler rejects any transaction that would: + +- move total bonded stake by more than 5% within a twelve-hour window, or +- give any single delegator more than 30% of total bonded stake (`ErrExceedsMaxStakeShare`). + +Reporting power has no equivalent limit. A reporter's power is the sum of the bonded tokens of up to `max_selectors` (default 100) selectors, so a reporter can aggregate the stake of many delegators who are each individually under the 30% delegator cap. Nothing today stops a single reporter from accumulating 30%, 50%, or more of total reporting power. A reporter that large can dominate medians on low-participation queries, carries outsized weight in dispute voting (reporter group), and concentrates the impact of a single bad submission. + +This ADR adds the same idea on the reporter side: **no single reporter may reach or exceed 30% of total reporting power on chain**, enforced by rejecting the transactions that would push a reporter over the line. The chain is assumed to be below the cap for every reporter at activation; the mechanism prevents crossings rather than remediating existing concentration. + +### What counts as a reporter's power for the cap + +For cap purposes we use a conservative "potential stake" for the reporter, not the exact stake a report would use at that instant: + +- the bonded tokens of every selector currently selecting the reporter, **including** dispute-locked selectors (their stake returns when the lock expires), **excluding** selectors with a pending switch away (their stake already stopped counting and is committed elsewhere), plus +- the bonded tokens of selectors with a **pending switch into** the reporter (that stake lands at finalization, so it must be booked against the cap when the switch is scheduled, otherwise two concurrent inflows could each pass the check and overshoot together). + +The denominator is total bonded tokens, the same quantity `TotalReporterPower()` already returns and the same denominator the delegator-side cap uses. Reporter stake is a subset of bonded tokens, so the ratio is well defined. + +### Enforcement points + +All enforcement lives in the existing `TrackStakeChangesDecorator` (x/reporter/ante), next to the delegator cap, so one decorator owns all stake-concentration limits and over-cap transactions are rejected at CheckTx before they enter the mempool. The decorator projects the final post-transaction state across all messages in the tx (consistent with how the 5% and delegator-30% checks already work), then runs the cap check once per affected reporter: + +1. **MsgSelectReporter** — the selector's projected bonded stake joins the target reporter. +2. **MsgSwitchReporter** — same, against the destination reporter (checked at scheduling time; the stake actually lands at finalization). Re-sends of an already-pending switch to the same destination are not double-counted. +3. **MsgCreateReporter** — the creator's own projected bonded stake becomes the new reporter's power (both the fresh-create and selector-conversion paths). +4. **Staking messages** (`MsgDelegate`, `MsgBeginRedelegate`, `MsgCancelUnbondingDelegation`, `MsgCreateValidator`) — the decorator already computes per-delegator bonded deltas, including deltas caused by validators entering/leaving the active set within the tx. Each positive delta is attributed to the delegator's selected reporter (honoring selections made earlier in the same tx) and the affected reporter is re-checked. + +A transaction is rejected with `ErrExceedsMaxReporterPower` if any affected reporter's projected potential stake would be **greater than or equal to** the cap fraction of projected total bonded stake. Note the boundary differs deliberately from the delegator cap (which rejects only strictly above 30%): the requirement here is that a reporter must never *reach* 30%. + +Decreases are never blocked: a reporter already at/over the cap (possible only through passive drift, see below) can always shed stake, and its selectors can always undelegate or switch away. + +### The cap is a module parameter + +`max_reporter_power_share` (Dec) is added to x/reporter params, default `0.30`. A value of `1` or greater disables the check. + +The delegator cap is hardcoded; this one cannot be, for a practical reason: the check fires on `CreateReporter`/`SelectReporter`, which every chain must execute during bootstrap. On a fresh chain with one validator, the validator holds ~100% of bonded stake and could never register a reporter — the oracle would be unusable on every devnet, local network, and young testnet. The same applies to the e2e suite, which routinely runs 2–3 validator chains where each validator holds 33–50% of bonded stake and registers itself as a reporter. Making the threshold a parameter keeps mainnet at a secure default while letting small networks raise or disable it explicitly in genesis (the standard e2e genesis disables it; the dedicated cap tests set it to 0.30 with a suitable validator distribution). Mainnet receives the default via the upgrade handler. + +## Alternative Approaches + +### Enforce in the x/reporter message handlers instead of ante + +Handler checks see authoritative state and are simpler to write, but they split the concentration limits across two mechanisms (staking messages can only be intercepted in ante), they burn the user's fee on failure instead of rejecting at the mempool, and they cannot see the combined effect of multiple messages in one transaction the way the existing projection tracker does. Keeping everything in the one decorator that already owns stake-concentration policy was judged clearer. + +Ante-only enforcement is sound only if no execution path runs messages without the ante chain. The one such path in the app was the ICA host, which executes ICA-relayed messages straight through the `MsgServiceRouter` — and mainnet's ICA host allowlist was found set to `["*"]` (verified against `mainnet.tellorlayer.com`), meaning ICA could bypass not just this cap but the pre-existing delegator cap and 5% tracker. Rather than duplicating every ante check into handlers, the v6.1.6 upgrade disables interchain accounts entirely (see Issues). + +### Maintain a materialized per-reporter power total in state + +A running tally updated by staking hooks would make the cap check O(1). It was rejected because reporter power is not an additive function of delegation events: it changes when validators enter or leave the bonded set, when selector locks expire, when switches finalize, and when reporters are jailed. The module already chose lazy recomputation with recalc flags (`ReporterStake`) instead of incremental maintenance for exactly this reason; a second, parallel incremental tally would be a standing source of consensus-risk bugs. The cap check instead recomputes the affected reporter's potential stake on demand, bounded by `max_selectors × max_num_of_delegations` (≤ ~1,000 store reads) and paid for by the transaction's gas. + +### Cap effective power at report time instead of blocking acquisition + +Clamping `ReporterStake` to 30% of total bonded at `SubmitValue` time would make the invariant unconditional (immune to all drift vectors below) without ever bricking a reporter. It was deferred, not rejected: it touches the oracle aggregation, reward-distribution period tracking, and dispute snapshot paths, and the power a report carries would diverge from the stake actually at risk behind it. It is the natural phase-2 defense-in-depth if drift past the cap is ever observed in practice. Rejecting reports outright from an over-cap reporter was rejected: a reporter can drift over the cap through no action of its own and must not lose the ability to operate. + +### Hardcode 30% like the delegator cap + +Breaks chain bootstrap and most of the existing test infrastructure, as described above. + +## Issues / Notes on Implementation + +- **The delegator cap stays hardcoded; the inconsistency is deliberate.** The reporter cap is a parameter and the delegator 30% cap remains hardcoded in the ante decorator. The two caps face different constraints: the delegator cap only blocks stake *increases*, so a chain whose genesis validators exceed 30% still bootstraps and operates; the reporter cap fires on `CreateReporter`/`SelectReporter`, which every chain must execute to have an oracle at all, so it cannot be hardcoded without killing small networks. Promoting the delegator cap to a matching `max_delegator_stake_share` param was considered (it would also let small devnets accept delegations to over-30% validators, which the hardcoded cap currently blocks) and deliberately deferred: it changes the mutability of an existing live limit from "chain upgrade required" to "governance vote", and that trade-off deserves its own decision rather than riding along here. Revisit if the devnet friction or the inconsistency becomes a problem. +- **Interchain accounts are disabled; interchain queries stay.** ICA-executed messages reach module handlers through the `MsgServiceRouter` without the ante chain, so an enabled ICA host with a permissive allowlist (mainnet had `allow_messages: ["*"]`) bypasses every ante-enforced limit: the 5% stake tracker, the 30% delegator cap, max delegations, and this reporter power cap. The v6.1.6 upgrade sets `host_enabled: false` with an empty allowlist and `controller_enabled: false`, and the app's default genesis ships both disabled, so new chains start safe. The async-ICQ module (used to serve oracle data to counterparty chains) does not execute messages and remains enabled. If ICA is ever wanted again, re-enabling via governance must come with a strict `allow_messages` list that excludes staking and reporter messages — or with these limits duplicated in message handlers. +- **Passive drift is not blocked.** A reporter's share can still reach 30% without any blockable transaction: total bonded stake shrinking (bounded by the existing 5%-per-12h tracker), validators entering/leaving the bonded set at end-block (jailing, slashing), dispute resolutions re-delegating returned stake, selectors' dispute locks expiring, and tip withdrawals delegating small amounts (`MsgWithdrawTip` performs a delegation outside the tracked staking messages — a pre-existing gap shared with the delegator cap, negligible in magnitude). Under the activation assumption (nobody at/over cap) plus the acquisition checks, drift past the cap requires the denominator to move against an already-near-cap reporter. If observed, phase 2 (report-time clamping) closes it. +- **Conservative overcounting is accepted.** Counting dispute-locked selectors and pending incoming switches means the check can reject a transaction even though the reporter's instantaneous reporting power is below the cap. This errs on the side of the invariant and avoids time-dependent loopholes (jail/lock windows as accumulation vehicles). Jailed reporters are checked the same as active ones for the same reason. +- **Gas cost.** Selecting to or delegating under a reporter with many selectors now performs a bounded selector scan in ante (comparable to what `SubmitValue` already does on every report). The scan consumes gas through normal store reads plus an explicit per-selector charge, mirroring the active-set scan precedent in the same decorator, so it cannot be used as a free-compute DoS vector. +- **Exact-boundary semantics.** `projected_reporter_stake * 1 >= max_reporter_power_share * projected_total_bonded` rejects. With the default, a reporter may hold at most one token-unit less than 30%. +- **Migration.** The new param deserializes as nil/zero for existing chains; the upgrade handler sets it to the 0.30 default. The ante check treats a nil/zero param (pre-upgrade state, or chains that never migrated) as disabled rather than as "cap everything at 0", which would halt all staking. +- **No retroactive remediation.** If a reporter is at/over the cap when the upgrade activates (contrary to the stated assumption), nothing forces divestment; the reporter simply cannot grow, and the existing paths (switch away, undelegate, RemoveSelector) remain available to shrink it. diff --git a/api/layer/reporter/params.pulsar.go b/api/layer/reporter/params.pulsar.go index b894742ff..3d1a2532d 100644 --- a/api/layer/reporter/params.pulsar.go +++ b/api/layer/reporter/params.pulsar.go @@ -23,6 +23,7 @@ var ( fd_Params_max_selectors protoreflect.FieldDescriptor fd_Params_max_num_of_delegations protoreflect.FieldDescriptor fd_Params_max_pending_switches_per_reporter protoreflect.FieldDescriptor + fd_Params_max_reporter_power_share protoreflect.FieldDescriptor ) func init() { @@ -33,6 +34,7 @@ func init() { fd_Params_max_selectors = md_Params.Fields().ByName("max_selectors") fd_Params_max_num_of_delegations = md_Params.Fields().ByName("max_num_of_delegations") fd_Params_max_pending_switches_per_reporter = md_Params.Fields().ByName("max_pending_switches_per_reporter") + fd_Params_max_reporter_power_share = md_Params.Fields().ByName("max_reporter_power_share") } var _ protoreflect.Message = (*fastReflection_Params)(nil) @@ -130,6 +132,12 @@ func (x *fastReflection_Params) Range(f func(protoreflect.FieldDescriptor, proto return } } + if x.MaxReporterPowerShare != "" { + value := protoreflect.ValueOfString(x.MaxReporterPowerShare) + if !f(fd_Params_max_reporter_power_share, value) { + return + } + } } // Has reports whether a field is populated. @@ -155,6 +163,8 @@ func (x *fastReflection_Params) Has(fd protoreflect.FieldDescriptor) bool { return x.MaxNumOfDelegations != uint64(0) case "layer.reporter.Params.max_pending_switches_per_reporter": return x.MaxPendingSwitchesPerReporter != uint64(0) + case "layer.reporter.Params.max_reporter_power_share": + return x.MaxReporterPowerShare != "" default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: layer.reporter.Params")) @@ -181,6 +191,8 @@ func (x *fastReflection_Params) Clear(fd protoreflect.FieldDescriptor) { x.MaxNumOfDelegations = uint64(0) case "layer.reporter.Params.max_pending_switches_per_reporter": x.MaxPendingSwitchesPerReporter = uint64(0) + case "layer.reporter.Params.max_reporter_power_share": + x.MaxReporterPowerShare = "" default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: layer.reporter.Params")) @@ -212,6 +224,9 @@ func (x *fastReflection_Params) Get(descriptor protoreflect.FieldDescriptor) pro case "layer.reporter.Params.max_pending_switches_per_reporter": value := x.MaxPendingSwitchesPerReporter return protoreflect.ValueOfUint64(value) + case "layer.reporter.Params.max_reporter_power_share": + value := x.MaxReporterPowerShare + return protoreflect.ValueOfString(value) default: if descriptor.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: layer.reporter.Params")) @@ -242,6 +257,8 @@ func (x *fastReflection_Params) Set(fd protoreflect.FieldDescriptor, value proto x.MaxNumOfDelegations = value.Uint() case "layer.reporter.Params.max_pending_switches_per_reporter": x.MaxPendingSwitchesPerReporter = value.Uint() + case "layer.reporter.Params.max_reporter_power_share": + x.MaxReporterPowerShare = value.Interface().(string) default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: layer.reporter.Params")) @@ -272,6 +289,8 @@ func (x *fastReflection_Params) Mutable(fd protoreflect.FieldDescriptor) protore panic(fmt.Errorf("field max_num_of_delegations of message layer.reporter.Params is not mutable")) case "layer.reporter.Params.max_pending_switches_per_reporter": panic(fmt.Errorf("field max_pending_switches_per_reporter of message layer.reporter.Params is not mutable")) + case "layer.reporter.Params.max_reporter_power_share": + panic(fmt.Errorf("field max_reporter_power_share of message layer.reporter.Params is not mutable")) default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: layer.reporter.Params")) @@ -295,6 +314,8 @@ func (x *fastReflection_Params) NewField(fd protoreflect.FieldDescriptor) protor return protoreflect.ValueOfUint64(uint64(0)) case "layer.reporter.Params.max_pending_switches_per_reporter": return protoreflect.ValueOfUint64(uint64(0)) + case "layer.reporter.Params.max_reporter_power_share": + return protoreflect.ValueOfString("") default: if fd.IsExtension() { panic(fmt.Errorf("proto3 declared messages do not support extensions: layer.reporter.Params")) @@ -381,6 +402,10 @@ func (x *fastReflection_Params) ProtoMethods() *protoiface.Methods { if x.MaxPendingSwitchesPerReporter != 0 { n += 1 + runtime.Sov(uint64(x.MaxPendingSwitchesPerReporter)) } + l = len(x.MaxReporterPowerShare) + if l > 0 { + n += 1 + l + runtime.Sov(uint64(l)) + } if x.unknownFields != nil { n += len(x.unknownFields) } @@ -410,6 +435,13 @@ func (x *fastReflection_Params) ProtoMethods() *protoiface.Methods { i -= len(x.unknownFields) copy(dAtA[i:], x.unknownFields) } + if len(x.MaxReporterPowerShare) > 0 { + i -= len(x.MaxReporterPowerShare) + copy(dAtA[i:], x.MaxReporterPowerShare) + i = runtime.EncodeVarint(dAtA, i, uint64(len(x.MaxReporterPowerShare))) + i-- + dAtA[i] = 0x32 + } if x.MaxPendingSwitchesPerReporter != 0 { i = runtime.EncodeVarint(dAtA, i, uint64(x.MaxPendingSwitchesPerReporter)) i-- @@ -609,6 +641,38 @@ func (x *fastReflection_Params) ProtoMethods() *protoiface.Methods { break } } + case 6: + if wireType != 2 { + return protoiface.UnmarshalOutput{NoUnkeyedLiterals: input.NoUnkeyedLiterals, Flags: input.Flags}, fmt.Errorf("proto: wrong wireType = %d for field MaxReporterPowerShare", 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.MaxReporterPowerShare = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := runtime.Skip(dAtA[iNdEx:]) @@ -1173,6 +1237,10 @@ type Params struct { // max pending reporter switches involving a reporter as outgoing or incoming // (each side capped separately when scheduling a switch). MaxPendingSwitchesPerReporter uint64 `protobuf:"varint,5,opt,name=max_pending_switches_per_reporter,json=maxPendingSwitchesPerReporter,proto3" json:"max_pending_switches_per_reporter,omitempty"` + // max share of total bonded tokens a single reporter's potential stake may + // hold; transactions that would put a reporter at or above this share are + // rejected. Values >= 1 (or unset) disable the check. + MaxReporterPowerShare string `protobuf:"bytes,6,opt,name=max_reporter_power_share,json=maxReporterPowerShare,proto3" json:"max_reporter_power_share,omitempty"` } func (x *Params) Reset() { @@ -1230,6 +1298,13 @@ func (x *Params) GetMaxPendingSwitchesPerReporter() uint64 { return 0 } +func (x *Params) GetMaxReporterPowerShare() string { + if x != nil { + return x.MaxReporterPowerShare + } + return "" +} + type StakeTracker struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1285,7 +1360,7 @@ var file_layer_reporter_params_proto_rawDesc = []byte{ 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, 0xaa, 0x03, 0x0a, 0x06, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x12, 0x7f, 0x0a, + 0x74, 0x6f, 0x22, 0xba, 0x04, 0x0a, 0x06, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x12, 0x7f, 0x0a, 0x13, 0x6d, 0x69, 0x6e, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x4f, 0xc8, 0xde, 0x1f, 0x00, 0xda, 0xde, 0x1f, 0x1b, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, 0x73, 0x64, 0x6b, 0x2e, 0x69, 0x6f, @@ -1309,7 +1384,16 @@ var file_layer_reporter_params_proto_rawDesc = []byte{ 0x6e, 0x67, 0x5f, 0x73, 0x77, 0x69, 0x74, 0x63, 0x68, 0x65, 0x73, 0x5f, 0x70, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x1d, 0x6d, 0x61, 0x78, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x53, 0x77, 0x69, 0x74, 0x63, 0x68, - 0x65, 0x73, 0x50, 0x65, 0x72, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x72, 0x3a, 0x20, 0xe8, + 0x65, 0x73, 0x50, 0x65, 0x72, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x72, 0x12, 0x8d, 0x01, + 0x0a, 0x18, 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x72, 0x5f, 0x70, + 0x6f, 0x77, 0x65, 0x72, 0x5f, 0x73, 0x68, 0x61, 0x72, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x54, 0xc8, 0xde, 0x1f, 0x00, 0xda, 0xde, 0x1f, 0x1b, 0x63, 0x6f, 0x73, 0x6d, 0x6f, 0x73, + 0x73, 0x64, 0x6b, 0x2e, 0x69, 0x6f, 0x2f, 0x6d, 0x61, 0x74, 0x68, 0x2e, 0x4c, 0x65, 0x67, 0x61, + 0x63, 0x79, 0x44, 0x65, 0x63, 0xf2, 0xde, 0x1f, 0x1f, 0x79, 0x61, 0x6d, 0x6c, 0x3a, 0x22, 0x6d, + 0x61, 0x78, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x6f, 0x77, 0x65, + 0x72, 0x5f, 0x73, 0x68, 0x61, 0x72, 0x65, 0x22, 0xd2, 0xb4, 0x2d, 0x0a, 0x63, 0x6f, 0x73, 0x6d, + 0x6f, 0x73, 0x2e, 0x44, 0x65, 0x63, 0x52, 0x15, 0x6d, 0x61, 0x78, 0x52, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x65, 0x72, 0x50, 0x6f, 0x77, 0x65, 0x72, 0x53, 0x68, 0x61, 0x72, 0x65, 0x3a, 0x20, 0xe8, 0xa0, 0x1f, 0x01, 0x8a, 0xe7, 0xb0, 0x2a, 0x17, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x2f, 0x78, 0x2f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x72, 0x2f, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x22, 0xa6, 0x01, 0x0a, 0x0c, 0x53, 0x74, 0x61, 0x6b, 0x65, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x72, diff --git a/app/app.go b/app/app.go index e98736c94..c4975fd2d 100644 --- a/app/app.go +++ b/app/app.go @@ -219,6 +219,7 @@ type App struct { EvidenceKeeper evidencekeeper.Keeper TransferKeeper ibctransferkeeper.Keeper ICAHostKeeper icahostkeeper.Keeper + ICAControllerKeeper icacontrollerkeeper.Keeper FeeGrantKeeper feegrantkeeper.Keeper GroupKeeper groupkeeper.Keeper ConsensusParamsKeeper consensusparamkeeper.Keeper @@ -502,7 +503,7 @@ func New( // keeper's gRPC query router must be set explicitly or NewMsgServerImpl panics // with "query router must not be nil". app.ICAHostKeeper.WithQueryRouter(bApp.GRPCQueryRouter()) - icaControllerKeeper := icacontrollerkeeper.NewKeeper( + app.ICAControllerKeeper = icacontrollerkeeper.NewKeeper( appCodec, keys[icacontrollertypes.StoreKey], nil, app.IBCKeeper.ChannelKeeper, // may be replaced with middleware such as ics29 fee @@ -689,7 +690,7 @@ func New( ibctm.AppModule{}, ibc.NewAppModule(app.IBCKeeper), transfer.NewAppModule(app.TransferKeeper), - ica.NewAppModule(&icaControllerKeeper, &app.ICAHostKeeper), + icaModule{ica.NewAppModule(&app.ICAControllerKeeper, &app.ICAHostKeeper)}, icqcustomModule{icq.NewAppModule(app.ICQKeeper, nil)}, // this line is used by starport scaffolding # stargate/app/appModule ) diff --git a/app/default_overrides.go b/app/default_overrides.go index 943dca436..167e6d0a6 100644 --- a/app/default_overrides.go +++ b/app/default_overrides.go @@ -6,6 +6,10 @@ import ( icq "github.com/cosmos/ibc-apps/modules/async-icq/v8" icqtypes "github.com/cosmos/ibc-apps/modules/async-icq/v8/types" + ica "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts" + icacontrollertypes "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts/controller/types" + icagenesistypes "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts/genesis/types" + icahosttypes "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts/host/types" "github.com/strangelove-ventures/globalfee/x/globalfee" globalfeetypes "github.com/strangelove-ventures/globalfee/x/globalfee/types" @@ -119,6 +123,22 @@ func (icqcustomModule) DefaultGenesis(cdc codec.JSONCodec) json.RawMessage { return cdc.MustMarshalJSON(genState) } +type icaModule struct { + ica.AppModule +} + +// DefaultGenesis disables interchain accounts entirely (host and controller). +// ICA-executed messages go through the MsgServiceRouter without the ante chain, +// bypassing the stake and reporter power limits; only interchain queries are +// supported on Layer. +func (icaModule) DefaultGenesis(cdc codec.JSONCodec) json.RawMessage { + genState := icagenesistypes.DefaultGenesis() + genState.HostGenesisState.Params = icahosttypes.Params{HostEnabled: false, AllowMessages: []string{}} + genState.ControllerGenesisState.Params = icacontrollertypes.Params{ControllerEnabled: false} + + return cdc.MustMarshalJSON(genState) +} + func CustomMessageValidator(msgs []sdk.Msg) error { if len(msgs) != 1 { return fmt.Errorf("unexpected number of GenTx messages; got: %d, expected: 1", len(msgs)) diff --git a/app/default_overrides_test.go b/app/default_overrides_test.go new file mode 100644 index 000000000..8abef5313 --- /dev/null +++ b/app/default_overrides_test.go @@ -0,0 +1,25 @@ +package app + +import ( + "testing" + + icagenesistypes "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts/genesis/types" + "github.com/stretchr/testify/require" + + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" +) + +// Interchain accounts must ship disabled: ICA-executed messages skip the ante +// chain and would bypass the stake and reporter power limits. +func TestICADefaultGenesisDisabled(t *testing.T) { + cdc := codec.NewProtoCodec(codectypes.NewInterfaceRegistry()) + + raw := icaModule{}.DefaultGenesis(cdc) + + var genState icagenesistypes.GenesisState + require.NoError(t, cdc.UnmarshalJSON(raw, &genState)) + require.False(t, genState.HostGenesisState.Params.HostEnabled) + require.Empty(t, genState.HostGenesisState.Params.AllowMessages) + require.False(t, genState.ControllerGenesisState.Params.ControllerEnabled) +} diff --git a/app/upgrades.go b/app/upgrades.go index 7ebe3f07a..b14f47221 100644 --- a/app/upgrades.go +++ b/app/upgrades.go @@ -30,6 +30,8 @@ func (app *App) setupUpgradeHandlers() { app.ModuleManager(), app.configurator, app.ReporterKeeper, + app.ICAControllerKeeper, + app.ICAHostKeeper, ), ) } diff --git a/app/upgrades/v6.1.6/upgrade.go b/app/upgrades/v6.1.6/upgrade.go index b4cdb32d1..d789b91a3 100644 --- a/app/upgrades/v6.1.6/upgrade.go +++ b/app/upgrades/v6.1.6/upgrade.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + icacontrollerkeeper "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts/controller/keeper" + icacontrollertypes "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts/controller/types" + icahostkeeper "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts/host/keeper" + icahosttypes "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts/host/types" reporterkeeper "github.com/tellor-io/layer/x/reporter/keeper" reportertypes "github.com/tellor-io/layer/x/reporter/types" @@ -21,6 +25,17 @@ Upgrade to v6.1.6: not in BeginBlock. - Pending switch targets live only in keeper collections (not on Selection). Max pending switches per reporter is a module param (default 10). +- Reporter power cap (ADR 1012): new reporter module param max_reporter_power_share + caps a single reporter's potential stake below a share of total bonded tokens + (default 30%). Enforcement happens in the TrackStakeChangesDecorator ante handler + on CreateReporter/SelectReporter/SwitchReporter and on staking messages that + increase a selector's bonded stake. The param deserializes as nil for existing + chains, which the ante treats as disabled; this handler sets the 0.30 default so + the cap activates at upgrade. +- Interchain accounts are disabled entirely (host and controller). Mainnet's + ICA host allowed all messages, and ICA-executed messages go through the + MsgServiceRouter without the ante chain, bypassing the stake and reporter + power limits. Only interchain queries remain supported. No custom state migration is required beyond RunMigrations: new collections and proto fields deserialize to empty / zero for existing chains. @@ -30,6 +45,8 @@ func CreateUpgradeHandler( mm *module.Manager, configurator module.Configurator, rk reporterkeeper.Keeper, + ick icacontrollerkeeper.Keeper, + ihk icahostkeeper.Keeper, ) upgradetypes.UpgradeHandler { return func(ctx context.Context, _ upgradetypes.Plan, vm module.VersionMap) (module.VersionMap, error) { sdkCtx := sdk.UnwrapSDKContext(ctx) @@ -44,16 +61,33 @@ func CreateUpgradeHandler( if err != nil { return vm, fmt.Errorf("reporter params: %w", err) } + changed := false if params.MaxPendingSwitchesPerReporter == 0 { params.MaxPendingSwitchesPerReporter = reportertypes.DefaultMaxPendingSwitchesPerReporter - if err := rk.Params.Set(ctx, params); err != nil { - return vm, fmt.Errorf("set max_pending_switches_per_reporter: %w", err) - } + changed = true sdkCtx.Logger().Info( "set reporter max_pending_switches_per_reporter", "value", params.MaxPendingSwitchesPerReporter, ) } + if params.MaxReporterPowerShare.IsNil() || params.MaxReporterPowerShare.IsZero() { + params.MaxReporterPowerShare = reportertypes.DefaultMaxReporterPowerShare + changed = true + sdkCtx.Logger().Info( + "set reporter max_reporter_power_share", + "value", params.MaxReporterPowerShare.String(), + ) + } + if changed { + if err := rk.Params.Set(ctx, params); err != nil { + return vm, fmt.Errorf("set reporter params: %w", err) + } + } + + ihk.SetParams(sdkCtx, icahosttypes.Params{HostEnabled: false, AllowMessages: []string{}}) + sdkCtx.Logger().Info("disabled interchain accounts host") + ick.SetParams(sdkCtx, icacontrollertypes.Params{ControllerEnabled: false}) + sdkCtx.Logger().Info("disabled interchain accounts controller") return vm, nil } diff --git a/e2e/attestation_test.go b/e2e/attestation_test.go index cb244bd8f..51703c120 100644 --- a/e2e/attestation_test.go +++ b/e2e/attestation_test.go @@ -12,7 +12,6 @@ import ( "github.com/stretchr/testify/require" "github.com/tellor-io/layer/e2e" - sdk "github.com/cosmos/cosmos-sdk/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" ) @@ -22,18 +21,7 @@ func TestConsensusAttestation(t *testing.T) { cosmos.SetSDKConfig("tellor") - modifyGenesis := []cosmos.GenesisKV{ - cosmos.NewGenesisKV("app_state.dispute.params.team_address", sdk.MustAccAddressFromBech32("tellor14ncp4jg0d087l54pwnp8p036s0dc580xy4gavf").Bytes()), - cosmos.NewGenesisKV("consensus.params.abci.vote_extensions_enable_height", "1"), - cosmos.NewGenesisKV("app_state.gov.params.voting_period", "15s"), - cosmos.NewGenesisKV("app_state.gov.params.max_deposit_period", "10s"), - cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.denom", "loya"), - cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.amount", "1"), - cosmos.NewGenesisKV("app_state.globalfee.params.minimum_gas_prices.0.amount", "0.000025000000000000"), - } - config := e2e.DefaultSetupConfig() - config.ModifyGenesis = modifyGenesis - chain, ic, ctx := e2e.SetupChainWithCustomConfig(t, config) + chain, ic, ctx := e2e.SetupChainWithCustomConfig(t, e2e.DefaultSetupConfig()) defer ic.Close() validators, err := e2e.GetValidators(ctx, chain) diff --git a/e2e/icq_test.go b/e2e/icq_test.go index ebb57c299..a137f144c 100644 --- a/e2e/icq_test.go +++ b/e2e/icq_test.go @@ -21,7 +21,6 @@ import ( "cosmossdk.io/math" "github.com/cosmos/cosmos-sdk/crypto/keyring" - sdk "github.com/cosmos/cosmos-sdk/types" ) func TestIbcInterchainQuery(t *testing.T) { @@ -38,14 +37,16 @@ func TestIbcInterchainQuery(t *testing.T) { client, network := interchaintest.DockerSetup(t) - modifyGenesis := []cosmos.GenesisKV{ - cosmos.NewGenesisKV("app_state.dispute.params.team_address", sdk.MustAccAddressFromBech32("tellor14ncp4jg0d087l54pwnp8p036s0dc580xy4gavf").Bytes()), - cosmos.NewGenesisKV("consensus.params.abci.vote_extensions_enable_height", "1"), - cosmos.NewGenesisKV("app_state.gov.params.voting_period", "15s"), - cosmos.NewGenesisKV("app_state.gov.params.max_deposit_period", "10s"), - cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.denom", "loya"), - cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.amount", "1"), + modifyGenesis := append(e2e.CreateStandardGenesis(), cosmos.NewGenesisKV("app_state.globalfee.params.minimum_gas_prices.0.amount", "0.0"), + ) + // the layer-icq image is built from the ibc branch, whose layerd predates + // max_reporter_power_share and panics on unknown genesis fields at InitGenesis; + icqGenesis := make([]cosmos.GenesisKV, 0, len(modifyGenesis)) + for _, kv := range modifyGenesis { + if kv.Key != e2e.MaxReporterPowerShareGenesisKey { + icqGenesis = append(icqGenesis, kv) + } } nv := 1 nf := 0 @@ -73,7 +74,7 @@ func TestIbcInterchainQuery(t *testing.T) { }, }, EncodingConfig: e2e.LayerEncoding(), - ModifyGenesis: cosmos.ModifyGenesis(modifyGenesis), + ModifyGenesis: cosmos.ModifyGenesis(icqGenesis), AdditionalStartArgs: []string{"--key-name", "validator"}, }, }, diff --git a/e2e/reporter_power_cap_test.go b/e2e/reporter_power_cap_test.go new file mode 100644 index 000000000..000823575 --- /dev/null +++ b/e2e/reporter_power_cap_test.go @@ -0,0 +1,82 @@ +package e2e_test + +import ( + "context" + "testing" + + interchaintest "github.com/strangelove-ventures/interchaintest/v8" + "github.com/strangelove-ventures/interchaintest/v8/chain/cosmos" + "github.com/strangelove-ventures/interchaintest/v8/testutil" + "github.com/stretchr/testify/require" + "github.com/tellor-io/layer/e2e" + + "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// setupPowerCapChain runs four equal genesis validators (25% of bonded stake +// each, under the 30% cap) and re-enables the reporter power cap that the +// standard e2e genesis disables. +func setupPowerCapChain(t *testing.T) (*cosmos.CosmosChain, *interchaintest.Interchain, context.Context) { + t.Helper() + + config := e2e.DefaultSetupConfig() + config.NumValidators = 4 + config.ModifyGenesis = append(e2e.CreateStandardGenesis(), + cosmos.NewGenesisKV(e2e.MaxReporterPowerShareGenesisKey, "0.300000000000000000"), + ) + return e2e.SetupChainWithCustomConfig(t, config) +} + +func TestReporterPowerCap(t *testing.T) { + require := require.New(t) + + cosmos.SetSDKConfig("tellor") + + chain, ic, ctx := setupPowerCapChain(t) + defer ic.Close() + + validators, err := e2e.GetValidators(ctx, chain) + require.NoError(err) + require.Len(validators, 4) + + // validator 0 holds 25% of bonded stake, under the cap, so registering as + // a reporter is allowed + _, err = validators[0].Node.ExecTx(ctx, validators[0].AccAddr, + "reporter", "create-reporter", "0.1", "1000000", "val0_moniker", + "--keyring-dir", validators[0].Node.HomeDir(), + "--gas", "500000", "--fees", "20loya", + ) + require.NoError(err) + require.NoError(testutil.WaitForBlocks(ctx, 1, validators[0].Node)) + + // validator 1's account also holds 25% of bonded stake; selecting validator + // 0's reporter would put that reporter at 50%, so the tx must be rejected + _, err = validators[1].Node.ExecTx(ctx, validators[1].AccAddr, + "reporter", "select-reporter", validators[0].AccAddr, + "--keyring-dir", validators[1].Node.HomeDir(), + "--gas", "500000", "--fees", "20loya", + ) + require.Error(err) + require.ErrorContains(err, "reporter power would reach or exceed the max share of total bonded stake") + + // a fresh account with a tiny bonded delegation keeps the reporter far + // below the cap, so selecting is allowed + user := interchaintest.GetAndFundTestUsers(t, ctx, "power-cap-user", math.NewInt(10_000_000), chain)[0] + delegateAmt := sdk.NewCoin("loya", math.NewInt(1_000_000)) + _, err = validators[0].Node.ExecTx(ctx, user.FormattedAddress(), + "staking", "delegate", validators[0].ValAddr, delegateAmt.String(), + "--keyring-dir", validators[0].Node.HomeDir(), + "--gas", "500000", "--fees", "20loya", + ) + require.NoError(err) + require.NoError(testutil.WaitForBlocks(ctx, 1, validators[0].Node)) + + _, err = validators[0].Node.ExecTx(ctx, user.FormattedAddress(), + "reporter", "select-reporter", validators[0].AccAddr, + "--keyring-dir", validators[0].Node.HomeDir(), + "--gas", "500000", "--fees", "20loya", + ) + require.NoError(err) +} diff --git a/e2e/rewards_test.go b/e2e/rewards_test.go index 753c59979..0d3196015 100644 --- a/e2e/rewards_test.go +++ b/e2e/rewards_test.go @@ -32,19 +32,12 @@ func TestRewards(t *testing.T) { cosmos.SetSDKConfig("tellor") - // Create custom config with 3 validators and realistic gas fees + // Use the standard e2e genesis with 3 validators and a shorter voting period. config := e2e.DefaultSetupConfig() config.NumValidators = 3 - config.GasPrices = e2e.DefaultGasPrice - config.ModifyGenesis = []cosmos.GenesisKV{ - cosmos.NewGenesisKV("app_state.dispute.params.team_address", sdk.MustAccAddressFromBech32("tellor14ncp4jg0d087l54pwnp8p036s0dc580xy4gavf").Bytes()), - cosmos.NewGenesisKV("consensus.params.abci.vote_extensions_enable_height", "1"), + config.ModifyGenesis = append(config.ModifyGenesis, cosmos.NewGenesisKV("app_state.gov.params.voting_period", "30s"), - cosmos.NewGenesisKV("app_state.gov.params.max_deposit_period", "10s"), - cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.denom", "loya"), - cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.amount", "1"), - cosmos.NewGenesisKV("app_state.globalfee.params.minimum_gas_prices.0.amount", "0.000025000000000000"), - } + ) chain, ic, ctx := e2e.SetupChainWithCustomConfig(t, config) defer ic.Close() diff --git a/e2e/utils.go b/e2e/utils.go index 0f6b5e17d..4d93250cc 100644 --- a/e2e/utils.go +++ b/e2e/utils.go @@ -516,6 +516,11 @@ func DefaultSetupConfig() SetupConfig { } } +// MaxReporterPowerShareGenesisKey is the genesis path of the reporter power cap +// param (ADR 1012). Exported so tests that run pre-cap binaries (the ibc-branch +// layer-icq image) can strip it +const MaxReporterPowerShareGenesisKey = "app_state.reporter.params.max_reporter_power_share" + // CreateStandardGenesis creates a standard genesis configuration func CreateStandardGenesis() []cosmos.GenesisKV { teamAddressBytes := sdk.MustAccAddressFromBech32("tellor14ncp4jg0d087l54pwnp8p036s0dc580xy4gavf").Bytes() @@ -528,6 +533,10 @@ func CreateStandardGenesis() []cosmos.GenesisKV { cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.denom", "loya"), cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.amount", "1"), cosmos.NewGenesisKV("app_state.globalfee.params.minimum_gas_prices.0.amount", "0.000025000000000000"), + // most fixtures run 2-3 validators whose accounts hold well over 30% of + // bonded stake, so the reporter power cap (ADR 1012) is disabled here; + // dedicated cap tests opt back in with an explicit 0.30 override + cosmos.NewGenesisKV(MaxReporterPowerShareGenesisKey, "1.000000000000000000"), } } @@ -807,6 +816,8 @@ func LayerChainSpec(nv, nf int, chainId string) *interchaintest.ChainSpec { cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.denom", "loya"), cosmos.NewGenesisKV("app_state.gov.params.min_deposit.0.amount", "1"), cosmos.NewGenesisKV("app_state.globalfee.params.minimum_gas_prices.0.amount", "0.0"), + // reporter power cap disabled for the same reason as CreateStandardGenesis + cosmos.NewGenesisKV(MaxReporterPowerShareGenesisKey, "1.000000000000000000"), } return &interchaintest.ChainSpec{ NumValidators: &nv, diff --git a/proto/layer/reporter/params.proto b/proto/layer/reporter/params.proto index a3d02936b..38c4a6111 100644 --- a/proto/layer/reporter/params.proto +++ b/proto/layer/reporter/params.proto @@ -34,6 +34,15 @@ message Params { // max pending reporter switches involving a reporter as outgoing or incoming // (each side capped separately when scheduling a switch). uint64 max_pending_switches_per_reporter = 5; + // max share of total bonded tokens a single reporter's potential stake may + // hold; transactions that would put a reporter at or above this share are + // rejected. Values >= 1 (or unset) disable the check. + string max_reporter_power_share = 6 [ + (gogoproto.moretags) = "yaml:\"max_reporter_power_share\"", + (gogoproto.customtype) = "cosmossdk.io/math.LegacyDec", + (gogoproto.nullable) = false, + (cosmos_proto.scalar) = "cosmos.Dec" + ]; } message StakeTracker { diff --git a/start_scripts/start_a_chain.sh b/start_scripts/start_a_chain.sh index 8fa9d4c5d..98fc02d53 100755 --- a/start_scripts/start_a_chain.sh +++ b/start_scripts/start_a_chain.sh @@ -79,6 +79,8 @@ jq '.app_state.gov.params.max_deposit_period = "45s"' $HOME_DIR/config/genesis.j jq '.app_state.gov.params.min_deposit[0].denom = "loya"' $HOME_DIR/config/genesis.json > temp.json && mv temp.json $HOME_DIR/config/genesis.json jq '.app_state.gov.params.min_deposit[0].amount = "100"' $HOME_DIR/config/genesis.json > temp.json && mv temp.json $HOME_DIR/config/genesis.json jq '.app_state.gov.params.expedited_voting_period = "30s"' $HOME_DIR/config/genesis.json > temp.json && mv temp.json $HOME_DIR/config/genesis.json +# single/dual validator devnets exceed the 30% reporter power cap (ADR 1012), so disable it +jq '.app_state.reporter.params.max_reporter_power_share = "1.000000000000000000"' $HOME_DIR/config/genesis.json > temp.json && mv temp.json $HOME_DIR/config/genesis.json echo "$KEY_NAME..." jq '.app_state.gov.params.voting_period = "1m"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json @@ -86,6 +88,8 @@ jq '.app_state.gov.params.max_deposit_period = "45s"' $HOME_DIR/$KEY_NAME/config jq '.app_state.gov.params.min_deposit[0].denom = "loya"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json jq '.app_state.gov.params.min_deposit[0].amount = "100"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json jq '.app_state.gov.params.expedited_voting_period = "30s"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json +# single/dual validator devnets exceed the 30% reporter power cap (ADR 1012), so disable it +jq '.app_state.reporter.params.max_reporter_power_share = "1.000000000000000000"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json echo "bill..." jq '.app_state.gov.params.voting_period = "1m"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json @@ -93,6 +97,8 @@ jq '.app_state.gov.params.max_deposit_period = "45s"' $HOME_DIR/bill/config/gene jq '.app_state.gov.params.min_deposit[0].denom = "loya"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json jq '.app_state.gov.params.min_deposit[0].amount = "100"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json jq '.app_state.gov.params.expedited_voting_period = "30s"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json +# single/dual validator devnets exceed the 30% reporter power cap (ADR 1012), so disable it +jq '.app_state.reporter.params.max_reporter_power_share = "1.000000000000000000"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json # Add TRBBridgeV2 data spec to genesis so local bridge tests work without runtime registration echo "Adding TRBBridgeV2 data spec to genesis..." diff --git a/start_scripts/start_from_tag.sh b/start_scripts/start_from_tag.sh index 15752fbb0..007750e48 100755 --- a/start_scripts/start_from_tag.sh +++ b/start_scripts/start_from_tag.sh @@ -90,6 +90,8 @@ jq '.app_state.gov.params.voting_period = "5m"' $HOME_DIR/config/genesis.json > jq '.app_state.gov.params.max_deposit_period = "1m"' $HOME_DIR/config/genesis.json > temp.json && mv temp.json $HOME_DIR/config/genesis.json jq '.app_state.gov.params.min_deposit[0].denom = "loya"' $HOME_DIR/config/genesis.json > temp.json && mv temp.json $HOME_DIR/config/genesis.json jq '.app_state.gov.params.min_deposit[0].amount = "100"' $HOME_DIR/config/genesis.json > temp.json && mv temp.json $HOME_DIR/config/genesis.json +# single/dual validator devnets exceed the 30% reporter power cap (ADR 1012), so disable it +jq '.app_state.reporter.params.max_reporter_power_share = "1.000000000000000000"' $HOME_DIR/config/genesis.json > temp.json && mv temp.json $HOME_DIR/config/genesis.json jq '.app_state.gov.params.expedited_voting_period = "3m"' $HOME_DIR/config/genesis.json > temp.json && mv temp.json $HOME_DIR/config/genesis.json echo "$KEY_NAME..." @@ -97,6 +99,8 @@ jq '.app_state.gov.params.voting_period = "5m"' $HOME_DIR/$KEY_NAME/config/genes jq '.app_state.gov.params.max_deposit_period = "1m"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json jq '.app_state.gov.params.min_deposit[0].denom = "loya"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json jq '.app_state.gov.params.min_deposit[0].amount = "100"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json +# single/dual validator devnets exceed the 30% reporter power cap (ADR 1012), so disable it +jq '.app_state.reporter.params.max_reporter_power_share = "1.000000000000000000"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json jq '.app_state.gov.params.expedited_voting_period = "3m"' $HOME_DIR/$KEY_NAME/config/genesis.json > temp.json && mv temp.json $HOME_DIR/$KEY_NAME/config/genesis.json echo "bill..." @@ -104,6 +108,8 @@ jq '.app_state.gov.params.voting_period = "5m"' $HOME_DIR/bill/config/genesis.js jq '.app_state.gov.params.max_deposit_period = "1m"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json jq '.app_state.gov.params.min_deposit[0].denom = "loya"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json jq '.app_state.gov.params.min_deposit[0].amount = "100"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json +# single/dual validator devnets exceed the 30% reporter power cap (ADR 1012), so disable it +jq '.app_state.reporter.params.max_reporter_power_share = "1.000000000000000000"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json jq '.app_state.gov.params.expedited_voting_period = "3m"' $HOME_DIR/bill/config/genesis.json > temp.json && mv temp.json $HOME_DIR/bill/config/genesis.json # Create a tx to give alice loyas to stake diff --git a/start_scripts/start_two_chains.sh b/start_scripts/start_two_chains.sh index 4809aa751..8a07327f9 100755 --- a/start_scripts/start_two_chains.sh +++ b/start_scripts/start_two_chains.sh @@ -79,6 +79,8 @@ jq '.app_state.gov.params.voting_period = "5m"' ~/.layer/config/genesis.json > t jq '.app_state.gov.params.max_deposit_period = "1m"' ~/.layer/config/genesis.json > temp.json && mv temp.json ~/.layer/config/genesis.json jq '.app_state.gov.params.min_deposit[0].denom = "loya"' ~/.layer/config/genesis.json > temp.json && mv temp.json ~/.layer/config/genesis.json jq '.app_state.gov.params.min_deposit[0].amount = "100"' ~/.layer/config/genesis.json > temp.json && mv temp.json ~/.layer/config/genesis.json +# single/dual validator devnets exceed the 30% reporter power cap (ADR 1012), so disable it +jq '.app_state.reporter.params.max_reporter_power_share = "1.000000000000000000"' ~/.layer/config/genesis.json > temp.json && mv temp.json ~/.layer/config/genesis.json jq '.app_state.gov.params.expedited_voting_period = "3m"' ~/.layer/config/genesis.json > temp.json && mv temp.json ~/.layer/config/genesis.json echo "$KEY_NAME..." @@ -86,6 +88,8 @@ jq '.app_state.gov.params.voting_period = "5m"' ~/.layer/$KEY_NAME/config/genesi jq '.app_state.gov.params.max_deposit_period = "1m"' ~/.layer/$KEY_NAME/config/genesis.json > temp.json && mv temp.json ~/.layer/$KEY_NAME/config/genesis.json jq '.app_state.gov.params.min_deposit[0].denom = "loya"' ~/.layer/$KEY_NAME/config/genesis.json > temp.json && mv temp.json ~/.layer/$KEY_NAME/config/genesis.json jq '.app_state.gov.params.min_deposit[0].amount = "100"' ~/.layer/$KEY_NAME/config/genesis.json > temp.json && mv temp.json ~/.layer/$KEY_NAME/config/genesis.json +# single/dual validator devnets exceed the 30% reporter power cap (ADR 1012), so disable it +jq '.app_state.reporter.params.max_reporter_power_share = "1.000000000000000000"' ~/.layer/$KEY_NAME/config/genesis.json > temp.json && mv temp.json ~/.layer/$KEY_NAME/config/genesis.json jq '.app_state.gov.params.expedited_voting_period = "3m"' ~/.layer/$KEY_NAME/config/genesis.json > temp.json && mv temp.json ~/.layer/$KEY_NAME/config/genesis.json echo "bill..." @@ -93,6 +97,8 @@ jq '.app_state.gov.params.voting_period = "5m"' ~/.layer/bill/config/genesis.jso jq '.app_state.gov.params.max_deposit_period = "1m"' ~/.layer/bill/config/genesis.json > temp.json && mv temp.json ~/.layer/bill/config/genesis.json jq '.app_state.gov.params.min_deposit[0].denom = "loya"' ~/.layer/bill/config/genesis.json > temp.json && mv temp.json ~/.layer/bill/config/genesis.json jq '.app_state.gov.params.min_deposit[0].amount = "100"' ~/.layer/bill/config/genesis.json > temp.json && mv temp.json ~/.layer/bill/config/genesis.json +# single/dual validator devnets exceed the 30% reporter power cap (ADR 1012), so disable it +jq '.app_state.reporter.params.max_reporter_power_share = "1.000000000000000000"' ~/.layer/bill/config/genesis.json > temp.json && mv temp.json ~/.layer/bill/config/genesis.json jq '.app_state.gov.params.expedited_voting_period = "3m"' ~/.layer/bill/config/genesis.json > temp.json && mv temp.json ~/.layer/bill/config/genesis.json # Create a tx to give alice loyas to stake diff --git a/x/reporter/ante/ante.go b/x/reporter/ante/ante.go index c5ed32391..c49f94e99 100644 --- a/x/reporter/ante/ante.go +++ b/x/reporter/ante/ante.go @@ -11,6 +11,7 @@ import ( "github.com/tellor-io/layer/x/reporter/types" "cosmossdk.io/collections" + errorsmod "cosmossdk.io/errors" "cosmossdk.io/math" storetypes "cosmossdk.io/store/types" @@ -48,6 +49,16 @@ func newValidatorAddressKey(addr sdk.ValAddress) validatorAddressKey { return validatorAddressKey(addr.String()) } +type reporterAddressKey string + +func newReporterAddressKey(addr sdk.AccAddress) reporterAddressKey { + return reporterAddressKey(addr.String()) +} + +func (k reporterAddressKey) address() (sdk.AccAddress, error) { + return sdk.AccAddressFromBech32(string(k)) +} + type stakeChangeTracker struct { totalBondedDelta math.Int delegatorBondedDelta map[delegatorAddressKey]math.LegacyDec @@ -58,6 +69,11 @@ type stakeChangeTracker struct { pendingValidators map[validatorAddressKey]prospectiveValidator // activeSetDelta is true when a tx can change which validators are bonded. activeSetDelta bool + // selectionChanges records selectors whose selected reporter changes within + // this tx (CreateReporter/SelectReporter/SwitchReporter), so the reporter + // power cap books their full stake against the new reporter and later + // staking deltas in the same tx attribute to the right reporter. + selectionChanges map[delegatorAddressKey]reporterAddressKey } type prospectiveValidator struct { @@ -87,6 +103,7 @@ func newStakeChangeTracker() *stakeChangeTracker { delegationShareDelta: make(map[validatorAddressKey]map[delegatorAddressKey]math.LegacyDec), validatorProjections: make(map[validatorAddressKey]prospectiveValidator), pendingValidators: make(map[validatorAddressKey]prospectiveValidator), + selectionChanges: make(map[delegatorAddressKey]reporterAddressKey), } } @@ -149,6 +166,13 @@ func (t *stakeChangeTracker) markActiveSetDelta(activeSetDelta bool) { } } +func (t *stakeChangeTracker) setSelection(selector, reporter sdk.AccAddress) { + if selector == nil || reporter == nil { + return + } + t.selectionChanges[newDelegatorAddressKey(selector)] = newReporterAddressKey(reporter) +} + func (t *stakeChangeTracker) addDelegationShareDelta(validator sdk.ValAddress, delegator sdk.AccAddress, shares math.LegacyDec) { if shares.IsZero() { return @@ -255,7 +279,10 @@ func (t TrackStakeChangesDecorator) finalizeStakeChanges(ctx sdk.Context, stakeC return err } } - return t.checkDelegatorStakeShares(ctx, stakeChanges) + if err := t.checkDelegatorStakeShares(ctx, stakeChanges); err != nil { + return err + } + return t.checkReporterPowerShares(ctx, stakeChanges) } func (t TrackStakeChangesDecorator) processMessage(ctx sdk.Context, msg sdk.Msg, nestedMsgCount int64, stakeChanges *stakeChangeTracker) error { @@ -286,6 +313,43 @@ func (t TrackStakeChangesDecorator) processMessage(ctx sdk.Context, msg sdk.Msg, func (t TrackStakeChangesDecorator) checkStakeChange(ctx sdk.Context, msg sdk.Msg, stakeChanges *stakeChangeTracker) error { switch msg := msg.(type) { + case *types.MsgCreateReporter: + addr, err := sdk.AccAddressFromBech32(msg.ReporterAddress) + if err != nil { + return err + } + // the creator's own bonded stake becomes the new reporter's power (both + // the fresh-create and selector-conversion paths) + stakeChanges.setSelection(addr, addr) + case *types.MsgSelectReporter: + selectorAddr, err := sdk.AccAddressFromBech32(msg.SelectorAddress) + if err != nil { + return err + } + reporterAddr, err := sdk.AccAddressFromBech32(msg.ReporterAddress) + if err != nil { + return err + } + stakeChanges.setSelection(selectorAddr, reporterAddr) + case *types.MsgSwitchReporter: + selectorAddr, err := sdk.AccAddressFromBech32(msg.SelectorAddress) + if err != nil { + return err + } + reporterAddr, err := sdk.AccAddressFromBech32(msg.ReporterAddress) + if err != nil { + return err + } + // a switch already pending to this reporter is a handler no-op and its + // stake is already booked against the destination's potential stake + pending, pendingTo, err := t.reporterKeeper.PendingSwitchTarget(ctx, selectorAddr) + if err != nil { + return err + } + if pending && bytes.Equal(pendingTo, reporterAddr.Bytes()) { + return nil + } + stakeChanges.setSelection(selectorAddr, reporterAddr) case *stakingtypes.MsgCreateValidator: valAddr, err := sdk.ValAddressFromBech32(msg.ValidatorAddress) if err != nil { @@ -736,6 +800,118 @@ func (t TrackStakeChangesDecorator) delegatorBondedTokens(ctx sdk.Context, deleg return tokens, iterError } +// checkReporterPowerShares enforces the reporter power cap: no reporter's +// projected potential stake may reach the max_reporter_power_share fraction of +// projected total bonded stake. Only reporters gaining stake in this tx are +// checked; decreases are never blocked, so an over-cap reporter can always +// shed stake. +func (t TrackStakeChangesDecorator) checkReporterPowerShares(ctx sdk.Context, stakeChanges *stakeChangeTracker) error { + if stakeChanges == nil || (len(stakeChanges.selectionChanges) == 0 && len(stakeChanges.delegatorBondedDelta) == 0) { + return nil + } + params, err := t.reporterKeeper.Params.Get(ctx) + if err != nil { + if errors.Is(err, collections.ErrNotFound) { + return nil + } + return err + } + maxShare := params.MaxReporterPowerShare + // nil/zero is pre-migration state and shares >= 1 are explicitly disabled; + // both must not be read as "cap everything at zero" + if maxShare.IsNil() || !maxShare.IsPositive() || maxShare.GTE(math.LegacyOneDec()) { + return nil + } + + reporterAdditions := make(map[reporterAddressKey]math.LegacyDec) + // selectors changing reporter bring their whole projected bonded stake to + // the destination reporter + for _, selectorKey := range sortedKeys(stakeChanges.selectionChanges) { + selector, err := selectorKey.address() + if err != nil { + return err + } + bonded, err := t.delegatorBondedTokens(ctx, selector) + if err != nil { + return err + } + contribution := bonded.Add(decFromMap(stakeChanges.delegatorBondedDelta, selectorKey)) + if contribution.IsPositive() { + addDec(reporterAdditions, stakeChanges.selectionChanges[selectorKey], contribution) + } + } + // stake increases by existing selectors attribute to their selected reporter + for _, delegatorKey := range sortedKeys(stakeChanges.delegatorBondedDelta) { + if _, changed := stakeChanges.selectionChanges[delegatorKey]; changed { + continue // already counted above with the selector's full stake + } + delta := stakeChanges.delegatorBondedDelta[delegatorKey] + if !delta.IsPositive() { + continue + } + delegator, err := delegatorKey.address() + if err != nil { + return err + } + reporter, found, err := t.selectedReporter(ctx, delegator) + if err != nil { + return err + } + if !found { + continue + } + addDec(reporterAdditions, newReporterAddressKey(reporter), delta) + } + if len(reporterAdditions) == 0 { + return nil + } + + currentTotalBonded, err := t.stakingKeeper.TotalBondedTokens(ctx) + if err != nil { + return err + } + totalBondedAfter := currentTotalBonded.Add(stakeChanges.totalBondedDelta) + if !totalBondedAfter.IsPositive() { + return nil + } + maxAllowed := maxShare.MulInt(totalBondedAfter) + for _, reporterKey := range sortedKeys(reporterAdditions) { + reporter, err := reporterKey.address() + if err != nil { + return err + } + potential, err := t.reporterKeeper.ReporterPotentialStake(ctx, reporter) + if err != nil { + return err + } + if potential.ToLegacyDec().Add(reporterAdditions[reporterKey]).GTE(maxAllowed) { + return errorsmod.Wrapf(types.ErrExceedsMaxReporterPower, "reporter %s", reporter.String()) + } + } + return nil +} + +// selectedReporter resolves the reporter a delegator's stake counts toward: the +// pending switch destination when one is scheduled, otherwise the stored +// selection. Returns found=false for delegators who are not selectors. +func (t TrackStakeChangesDecorator) selectedReporter(ctx sdk.Context, delegator sdk.AccAddress) (sdk.AccAddress, bool, error) { + selection, err := t.reporterKeeper.GetSelector(ctx, delegator) + if err != nil { + if errors.Is(err, collections.ErrNotFound) { + return nil, false, nil + } + return nil, false, err + } + pending, to, err := t.reporterKeeper.PendingSwitchTarget(ctx, delegator) + if err != nil { + return nil, false, err + } + if pending { + return sdk.AccAddress(to), true, nil + } + return sdk.AccAddress(selection.Reporter), true, nil +} + func (t TrackStakeChangesDecorator) checkAmountOfDelegationsByAddressDoesNotExceedMax(ctx sdk.Context, msg sdk.Msg) (bool, error) { params, err := t.reporterKeeper.Params.Get(ctx) if err != nil { diff --git a/x/reporter/ante/reporter_power_cap_test.go b/x/reporter/ante/reporter_power_cap_test.go new file mode 100644 index 000000000..394d2cd8d --- /dev/null +++ b/x/reporter/ante/reporter_power_cap_test.go @@ -0,0 +1,359 @@ +package ante + +import ( + "testing" + + "github.com/stretchr/testify/require" + keepertest "github.com/tellor-io/layer/testutil/keeper" + "github.com/tellor-io/layer/testutil/sample" + "github.com/tellor-io/layer/x/reporter/types" + + "cosmossdk.io/collections" + "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" +) + +// noopNext lets tests run the decorator in isolation. +func noopNext(ctx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + return ctx, nil +} + +func TestReporterPowerCapSelect(t *testing.T) { + testCases := []struct { + name string + selectorStake math.Int + err error + }{ + { + // reporter 20 + selector 9 = 29 < 30% of 100 + name: "allows select below the cap", + selectorStake: math.NewInt(9), + err: nil, + }, + { + // reporter 20 + selector 10 = 30, reaching 30% of 100 is rejected + name: "blocks select reaching the cap", + selectorStake: math.NewInt(10), + err: types.ErrExceedsMaxReporterPower, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + k, sk, _, _, _, ctx, _ := keepertest.ReporterKeeper(t) + ctx = ctx.WithBlockHeight(1) + decorator := NewTrackStakeChangesDecorator(k, sk) + + valAddr := sdk.ValAddress(sample.AccAddressBytes()) + val := validator(valAddr, stakingtypes.Bonded, math.NewInt(1000)) + reporterAddr := sample.AccAddressBytes() + selectorAddr := sample.AccAddressBytes() + require.NoError(t, k.Selectors.Set(ctx, reporterAddr.Bytes(), types.NewSelection(reporterAddr.Bytes(), 1))) + + mockValidator(sk, ctx, val) + sk.On("TotalBondedTokens", ctx).Return(math.NewInt(100), nil) + mockIterateDelegations(sk, ctx, reporterAddr, []stakingtypes.Delegation{delegation(reporterAddr, valAddr, math.NewInt(20))}) + mockIterateDelegations(sk, ctx, selectorAddr, []stakingtypes.Delegation{delegation(selectorAddr, valAddr, tc.selectorStake)}) + + tx := buildTx(t, &types.MsgSelectReporter{ + SelectorAddress: selectorAddr.String(), + ReporterAddress: reporterAddr.String(), + }) + + _, err := decorator.AnteHandle(ctx, tx, false, noopNext) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestReporterPowerCapCreateReporter(t *testing.T) { + testCases := []struct { + name string + creatorStake math.Int + err error + }{ + { + name: "allows create below the cap", + creatorStake: math.NewInt(29), + err: nil, + }, + { + name: "blocks create reaching the cap", + creatorStake: math.NewInt(30), + err: types.ErrExceedsMaxReporterPower, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + k, sk, _, _, _, ctx, _ := keepertest.ReporterKeeper(t) + ctx = ctx.WithBlockHeight(1) + decorator := NewTrackStakeChangesDecorator(k, sk) + + valAddr := sdk.ValAddress(sample.AccAddressBytes()) + val := validator(valAddr, stakingtypes.Bonded, math.NewInt(1000)) + creatorAddr := sample.AccAddressBytes() + + mockValidator(sk, ctx, val) + sk.On("TotalBondedTokens", ctx).Return(math.NewInt(100), nil) + mockIterateDelegations(sk, ctx, creatorAddr, []stakingtypes.Delegation{delegation(creatorAddr, valAddr, tc.creatorStake)}) + + tx := buildTx(t, &types.MsgCreateReporter{ + ReporterAddress: creatorAddr.String(), + CommissionRate: math.LegacyZeroDec(), + MinTokensRequired: math.OneInt(), + Moniker: "moniker", + }) + + _, err := decorator.AnteHandle(ctx, tx, false, noopNext) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestReporterPowerCapSwitch(t *testing.T) { + k, sk, _, _, _, ctx, _ := keepertest.ReporterKeeper(t) + ctx = ctx.WithBlockHeight(1) + decorator := NewTrackStakeChangesDecorator(k, sk) + + valAddr := sdk.ValAddress(sample.AccAddressBytes()) + val := validator(valAddr, stakingtypes.Bonded, math.NewInt(1000)) + fromReporter := sample.AccAddressBytes() + toReporter := sample.AccAddressBytes() + selectorAddr := sample.AccAddressBytes() + require.NoError(t, k.Selectors.Set(ctx, toReporter.Bytes(), types.NewSelection(toReporter.Bytes(), 1))) + require.NoError(t, k.Selectors.Set(ctx, selectorAddr.Bytes(), types.NewSelection(fromReporter.Bytes(), 1))) + + mockValidator(sk, ctx, val) + sk.On("TotalBondedTokens", ctx).Return(math.NewInt(100), nil) + mockIterateDelegations(sk, ctx, toReporter, []stakingtypes.Delegation{delegation(toReporter, valAddr, math.NewInt(20))}) + mockIterateDelegations(sk, ctx, selectorAddr, []stakingtypes.Delegation{delegation(selectorAddr, valAddr, math.NewInt(10))}) + + // destination reporter 20 + switching selector 10 reaches 30% of 100 + tx := buildTx(t, &types.MsgSwitchReporter{ + SelectorAddress: selectorAddr.String(), + ReporterAddress: toReporter.String(), + }) + + _, err := decorator.AnteHandle(ctx, tx, false, noopNext) + require.ErrorIs(t, err, types.ErrExceedsMaxReporterPower) +} + +func TestReporterPowerCapSwitchAlreadyPending(t *testing.T) { + k, sk, _, _, _, ctx, _ := keepertest.ReporterKeeper(t) + ctx = ctx.WithBlockHeight(1) + decorator := NewTrackStakeChangesDecorator(k, sk) + + valAddr := sdk.ValAddress(sample.AccAddressBytes()) + val := validator(valAddr, stakingtypes.Bonded, math.NewInt(1000)) + fromReporter := sample.AccAddressBytes() + toReporter := sample.AccAddressBytes() + selectorAddr := sample.AccAddressBytes() + require.NoError(t, k.Selectors.Set(ctx, toReporter.Bytes(), types.NewSelection(toReporter.Bytes(), 1))) + require.NoError(t, k.Selectors.Set(ctx, selectorAddr.Bytes(), types.NewSelection(fromReporter.Bytes(), 1))) + // the switch is already scheduled, so its stake is already booked against + // the destination's potential stake and a re-send must not double count + require.NoError(t, k.OutgoingPendingSwitches.Set(ctx, collections.Join(fromReporter.Bytes(), selectorAddr.Bytes()), types.PendingSwitchEntry{ + ToReporter: toReporter.Bytes(), + UnlockBlock: 100, + })) + require.NoError(t, k.IncomingPendingSwitchIdx.Set(ctx, collections.Join(toReporter.Bytes(), selectorAddr.Bytes()), fromReporter.Bytes())) + + mockValidator(sk, ctx, val) + sk.On("TotalBondedTokens", ctx).Return(math.NewInt(100), nil) + mockIterateDelegations(sk, ctx, toReporter, []stakingtypes.Delegation{delegation(toReporter, valAddr, math.NewInt(20))}) + mockIterateDelegations(sk, ctx, selectorAddr, []stakingtypes.Delegation{delegation(selectorAddr, valAddr, math.NewInt(10))}) + + // the handler treats this as a no-op, so the ante must not block it even + // though destination potential stake (20 + 10 pending) is at the cap + tx := buildTx(t, &types.MsgSwitchReporter{ + SelectorAddress: selectorAddr.String(), + ReporterAddress: toReporter.String(), + }) + + _, err := decorator.AnteHandle(ctx, tx, false, noopNext) + require.NoError(t, err) +} + +func TestReporterPowerCapDelegateBySelector(t *testing.T) { + testCases := []struct { + name string + delegateAmt math.Int + err error + }{ + { + // reporter potential 29 + 1 = 30 vs 30% of 101 = 30.3 + name: "allows delegate keeping reporter below cap", + delegateAmt: math.OneInt(), + err: nil, + }, + { + // reporter potential 29 + 2 = 31 vs 30% of 102 = 30.6 + name: "blocks delegate pushing reporter to cap", + delegateAmt: math.NewInt(2), + err: types.ErrExceedsMaxReporterPower, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + k, sk, _, _, _, ctx, _ := keepertest.ReporterKeeper(t) + ctx = ctx.WithBlockHeight(1) + decorator := NewTrackStakeChangesDecorator(k, sk) + + valAddr := sdk.ValAddress(sample.AccAddressBytes()) + val := validator(valAddr, stakingtypes.Bonded, math.NewInt(1000)) + reporterAddr := sample.AccAddressBytes() + selectorAddr := sample.AccAddressBytes() + require.NoError(t, k.Selectors.Set(ctx, reporterAddr.Bytes(), types.NewSelection(reporterAddr.Bytes(), 1))) + require.NoError(t, k.Selectors.Set(ctx, selectorAddr.Bytes(), types.NewSelection(reporterAddr.Bytes(), 1))) + + selectorDelegations := []stakingtypes.Delegation{delegation(selectorAddr, valAddr, math.NewInt(9))} + mockValidator(sk, ctx, val) + sk.On("TotalBondedTokens", ctx).Return(math.NewInt(100), nil) + sk.On("GetAllDelegatorDelegations", ctx, selectorAddr).Return(selectorDelegations, nil) + mockIterateDelegations(sk, ctx, reporterAddr, []stakingtypes.Delegation{delegation(reporterAddr, valAddr, math.NewInt(20))}) + mockIterateDelegations(sk, ctx, selectorAddr, selectorDelegations) + + tx := buildTx(t, &stakingtypes.MsgDelegate{ + DelegatorAddress: selectorAddr.String(), + ValidatorAddress: valAddr.String(), + Amount: sdk.Coin{Denom: "loya", Amount: tc.delegateAmt}, + }) + + _, err := decorator.AnteHandle(ctx, tx, false, noopNext) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestReporterPowerCapSelectPlusDelegate(t *testing.T) { + k, sk, _, _, _, ctx, _ := keepertest.ReporterKeeper(t) + ctx = ctx.WithBlockHeight(1) + decorator := NewTrackStakeChangesDecorator(k, sk) + + valAddr := sdk.ValAddress(sample.AccAddressBytes()) + val := validator(valAddr, stakingtypes.Bonded, math.NewInt(1000)) + reporterAddr := sample.AccAddressBytes() + selectorAddr := sample.AccAddressBytes() + require.NoError(t, k.Selectors.Set(ctx, reporterAddr.Bytes(), types.NewSelection(reporterAddr.Bytes(), 1))) + + selectorDelegations := []stakingtypes.Delegation{delegation(selectorAddr, valAddr, math.NewInt(5))} + mockValidator(sk, ctx, val) + sk.On("TotalBondedTokens", ctx).Return(math.NewInt(100), nil) + sk.On("GetAllDelegatorDelegations", ctx, selectorAddr).Return(selectorDelegations, nil) + mockIterateDelegations(sk, ctx, reporterAddr, []stakingtypes.Delegation{delegation(reporterAddr, valAddr, math.NewInt(20))}) + mockIterateDelegations(sk, ctx, selectorAddr, selectorDelegations) + + // The selector joins with 5 bonded and delegates 10 more in the same tx. + // The reporter's projected stake is 20 + (5 + 10) = 35, which reaches 30% + // of the projected total of 110. Without folding the same-tx delegation + // into the joiner's contribution this would pass at 25 / 110. + tx := buildTx(t, + &types.MsgSelectReporter{ + SelectorAddress: selectorAddr.String(), + ReporterAddress: reporterAddr.String(), + }, + &stakingtypes.MsgDelegate{ + DelegatorAddress: selectorAddr.String(), + ValidatorAddress: valAddr.String(), + Amount: sdk.Coin{Denom: "loya", Amount: math.NewInt(10)}, + }, + ) + + _, err := decorator.AnteHandle(ctx, tx, false, noopNext) + require.ErrorIs(t, err, types.ErrExceedsMaxReporterPower) +} + +func TestReporterPowerCapDisabled(t *testing.T) { + testCases := []struct { + name string + share math.LegacyDec + }{ + { + name: "share of one disables the check", + share: math.LegacyOneDec(), + }, + { + name: "nil share (pre-migration) disables the check", + share: math.LegacyDec{}, + }, + { + name: "zero share disables the check", + share: math.LegacyZeroDec(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + k, sk, _, _, _, ctx, _ := keepertest.ReporterKeeper(t) + ctx = ctx.WithBlockHeight(1) + decorator := NewTrackStakeChangesDecorator(k, sk) + params := types.DefaultParams() + params.MaxReporterPowerShare = tc.share + require.NoError(t, k.Params.Set(ctx, params)) + + valAddr := sdk.ValAddress(sample.AccAddressBytes()) + val := validator(valAddr, stakingtypes.Bonded, math.NewInt(1000)) + reporterAddr := sample.AccAddressBytes() + selectorAddr := sample.AccAddressBytes() + require.NoError(t, k.Selectors.Set(ctx, reporterAddr.Bytes(), types.NewSelection(reporterAddr.Bytes(), 1))) + + mockValidator(sk, ctx, val) + sk.On("TotalBondedTokens", ctx).Return(math.NewInt(100), nil) + mockIterateDelegations(sk, ctx, reporterAddr, []stakingtypes.Delegation{delegation(reporterAddr, valAddr, math.NewInt(40))}) + mockIterateDelegations(sk, ctx, selectorAddr, []stakingtypes.Delegation{delegation(selectorAddr, valAddr, math.NewInt(20))}) + + // 40 + 20 = 60% of total bonded would be far over an active cap + tx := buildTx(t, &types.MsgSelectReporter{ + SelectorAddress: selectorAddr.String(), + ReporterAddress: reporterAddr.String(), + }) + + _, err := decorator.AnteHandle(ctx, tx, false, noopNext) + require.NoError(t, err) + }) + } +} + +func TestReporterPowerCapUndelegateNotBlocked(t *testing.T) { + k, sk, _, _, _, ctx, _ := keepertest.ReporterKeeper(t) + ctx = ctx.WithBlockHeight(1) + decorator := NewTrackStakeChangesDecorator(k, sk) + + valAddr := sdk.ValAddress(sample.AccAddressBytes()) + val := validator(valAddr, stakingtypes.Bonded, math.NewInt(1000)) + reporterAddr := sample.AccAddressBytes() + // the reporter is already over the cap (40% of total bonded) but only sheds + // stake, which must always be allowed + require.NoError(t, k.Selectors.Set(ctx, reporterAddr.Bytes(), types.NewSelection(reporterAddr.Bytes(), 1))) + + mockValidator(sk, ctx, val) + sk.On("TotalBondedTokens", ctx).Return(math.NewInt(100), nil) + mockDelegation(sk, ctx, reporterAddr, valAddr, math.NewInt(40)) + mockPowerStore(sk, ctx, 1, valAddr) + mockIterateDelegations(sk, ctx, reporterAddr, []stakingtypes.Delegation{delegation(reporterAddr, valAddr, math.NewInt(40))}) + + tx := buildTx(t, &stakingtypes.MsgUndelegate{ + DelegatorAddress: reporterAddr.String(), + ValidatorAddress: valAddr.String(), + Amount: sdk.Coin{Denom: "loya", Amount: math.OneInt()}, + }) + + _, err := decorator.AnteHandle(ctx, tx, false, noopNext) + require.NoError(t, err) +} diff --git a/x/reporter/keeper/reporter.go b/x/reporter/keeper/reporter.go index 5ef69b15b..b5943f7d8 100644 --- a/x/reporter/keeper/reporter.go +++ b/x/reporter/keeper/reporter.go @@ -11,6 +11,7 @@ import ( "cosmossdk.io/collections" errorsmod "cosmossdk.io/errors" "cosmossdk.io/math" + storetypes "cosmossdk.io/store/types" sdk "github.com/cosmos/cosmos-sdk/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" @@ -176,6 +177,85 @@ func (k Keeper) TotalReporterPower(ctx context.Context) (math.Int, error) { return valSet.TotalBondedTokens(ctx) } +// PotentialStakeSelectorGas makes the power-cap selector expansion visible to gas +// accounting on top of normal store-read costs, mirroring the active-set scan +// precedent in the ante decorator. +const PotentialStakeSelectorGas = storetypes.Gas(10_000) + +const potentialStakeSelectorGasMessage = "reporter power cap selector check" + +// ReporterPotentialStake returns a conservative upper bound on a reporter's +// reporting power, used by the power-cap check: the bonded tokens of every +// selector currently selecting the reporter — including dispute-locked selectors +// (their stake returns when the lock expires) and regardless of reporter jail +// status — excluding selectors with a pending switch away (that stake already +// stopped counting and is committed elsewhere), plus the bonded tokens of +// selectors with a pending switch into the reporter (booked against the cap as +// soon as the switch is scheduled). Read-only: never mutates state. +func (k Keeper) ReporterPotentialStake(ctx context.Context, repAddr sdk.AccAddress) (math.Int, error) { + gasMeter := sdk.UnwrapSDKContext(ctx).GasMeter() + total := math.ZeroInt() + iter, err := k.Selectors.Indexes.Reporter.MatchExact(ctx, repAddr.Bytes()) + if err != nil { + return math.Int{}, err + } + defer iter.Close() + for ; iter.Valid(); iter.Next() { + selectorAddr, err := iter.PrimaryKey() + if err != nil { + return math.Int{}, err + } + hasPending, err := k.hasOutgoingPendingSwitch(ctx, repAddr.Bytes(), selectorAddr) + if err != nil { + return math.Int{}, err + } + if hasPending { + continue + } + gasMeter.ConsumeGas(PotentialStakeSelectorGas, potentialStakeSelectorGasMessage) + bonded, _, err := k.CheckSelectorsDelegations(ctx, sdk.AccAddress(selectorAddr)) + if err != nil { + return math.Int{}, err + } + total = total.Add(bonded) + } + + inRange := collections.NewPrefixedPairRange[[]byte, []byte](repAddr.Bytes()) + inIter, err := k.IncomingPendingSwitchIdx.Iterate(ctx, inRange) + if err != nil { + return math.Int{}, err + } + defer inIter.Close() + for ; inIter.Valid(); inIter.Next() { + pk, err := inIter.Key() + if err != nil { + return math.Int{}, err + } + gasMeter.ConsumeGas(PotentialStakeSelectorGas, potentialStakeSelectorGasMessage) + bonded, _, err := k.CheckSelectorsDelegations(ctx, sdk.AccAddress(pk.K2())) + if err != nil { + return math.Int{}, err + } + total = total.Add(bonded) + } + return total, nil +} + +// PendingSwitchTarget returns the reporter a selector's scheduled pending switch +// is headed to, if any. Callers use it to attribute the selector's stake to the +// reporter that will actually receive it and to avoid double-booking re-sent +// switches against the power cap. +func (k Keeper) PendingSwitchTarget(ctx context.Context, selectorAddr sdk.AccAddress) (bool, []byte, error) { + selection, err := k.Selectors.Get(ctx, selectorAddr.Bytes()) + if err != nil { + if errors.Is(err, collections.ErrNotFound) { + return false, nil, nil + } + return false, nil, err + } + return k.pendingSwitchToReporter(ctx, sdk.AccAddress(selection.Reporter), selectorAddr) +} + // Delegation returns a selector's reporter, delegations count, and locked time information func (k Keeper) Delegation(ctx context.Context, delegator sdk.AccAddress) (types.Selection, error) { return k.Selectors.Get(ctx, delegator) diff --git a/x/reporter/keeper/reporter_potential_stake_test.go b/x/reporter/keeper/reporter_potential_stake_test.go new file mode 100644 index 000000000..6c2f9987c --- /dev/null +++ b/x/reporter/keeper/reporter_potential_stake_test.go @@ -0,0 +1,102 @@ +package keeper_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/tellor-io/layer/testutil/sample" + "github.com/tellor-io/layer/x/reporter/mocks" + "github.com/tellor-io/layer/x/reporter/types" + + "cosmossdk.io/collections" + "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" +) + +func mockSelectorBondedStake(sk *mocks.StakingKeeper, ctx sdk.Context, selector sdk.AccAddress, valAddr sdk.ValAddress, tokens math.Int) { + delegations := []stakingtypes.Delegation{ + { + DelegatorAddress: selector.String(), + ValidatorAddress: valAddr.String(), + Shares: tokens.ToLegacyDec(), + }, + } + sk.On("IterateDelegatorDelegations", ctx, selector, mock.AnythingOfType("func(types.Delegation) bool")).Return(nil).Run(func(args mock.Arguments) { + fn := args.Get(2).(func(stakingtypes.Delegation) bool) + for _, delegation := range delegations { + if fn(delegation) { + return + } + } + }) +} + +func TestReporterPotentialStake(t *testing.T) { + k, sk, _, _, _, ctx, _ := setupKeeper(t) + ctx = ctx.WithBlockHeight(10).WithBlockTime(time.Now()) + + valAddr := sdk.ValAddress(sample.AccAddressBytes()) + val := stakingtypes.Validator{ + OperatorAddress: valAddr.String(), + Status: stakingtypes.Bonded, + Tokens: math.NewInt(1000), + DelegatorShares: math.NewInt(1000).ToLegacyDec(), + MinSelfDelegation: math.OneInt(), + } + sk.On("GetValidator", ctx, valAddr).Return(val, nil) + + reporterAddr := sample.AccAddressBytes() + otherReporter := sample.AccAddressBytes() + selfSelector := reporterAddr + lockedSelector := sample.AccAddressBytes() + leavingSelector := sample.AccAddressBytes() + incomingSelector := sample.AccAddressBytes() + + // active self stake counts + require.NoError(t, k.Selectors.Set(ctx, selfSelector.Bytes(), types.NewSelection(reporterAddr.Bytes(), 1))) + mockSelectorBondedStake(sk, ctx, selfSelector, valAddr, math.NewInt(20)) + + // dispute-locked selectors count: their stake returns when the lock expires + locked := types.NewSelection(reporterAddr.Bytes(), 1) + locked.LockedUntilTime = ctx.BlockTime().Add(time.Hour) + require.NoError(t, k.Selectors.Set(ctx, lockedSelector.Bytes(), locked)) + mockSelectorBondedStake(sk, ctx, lockedSelector, valAddr, math.NewInt(7)) + + // selectors with a pending switch away are excluded + require.NoError(t, k.Selectors.Set(ctx, leavingSelector.Bytes(), types.NewSelection(reporterAddr.Bytes(), 1))) + require.NoError(t, k.OutgoingPendingSwitches.Set(ctx, collections.Join(reporterAddr.Bytes(), leavingSelector.Bytes()), types.PendingSwitchEntry{ + ToReporter: otherReporter.Bytes(), + UnlockBlock: 100, + })) + + // selectors with a pending switch into the reporter count + require.NoError(t, k.Selectors.Set(ctx, incomingSelector.Bytes(), types.NewSelection(otherReporter.Bytes(), 1))) + require.NoError(t, k.OutgoingPendingSwitches.Set(ctx, collections.Join(otherReporter.Bytes(), incomingSelector.Bytes()), types.PendingSwitchEntry{ + ToReporter: reporterAddr.Bytes(), + UnlockBlock: 100, + })) + require.NoError(t, k.IncomingPendingSwitchIdx.Set(ctx, collections.Join(reporterAddr.Bytes(), incomingSelector.Bytes()), otherReporter.Bytes())) + mockSelectorBondedStake(sk, ctx, incomingSelector, valAddr, math.NewInt(5)) + + total, err := k.ReporterPotentialStake(ctx, reporterAddr) + require.NoError(t, err) + // 20 (self) + 7 (locked) + 5 (pending incoming); the leaving selector's + // stake is excluded + require.Equal(t, math.NewInt(32), total) + + // state must not be mutated by the read + sel, err := k.Selectors.Get(ctx, lockedSelector.Bytes()) + require.NoError(t, err) + require.Equal(t, locked.LockedUntilTime.Unix(), sel.LockedUntilTime.Unix()) +} + +func TestReporterPotentialStakeNoSelectors(t *testing.T) { + k, _, _, _, _, ctx, _ := setupKeeper(t) + total, err := k.ReporterPotentialStake(ctx, sample.AccAddressBytes()) + require.NoError(t, err) + require.True(t, total.IsZero()) +} diff --git a/x/reporter/types/errors.go b/x/reporter/types/errors.go index bfabf3fbe..888867981 100644 --- a/x/reporter/types/errors.go +++ b/x/reporter/types/errors.go @@ -26,4 +26,5 @@ var ( ErrNoUnbondingDelegationEntries = sdkerrors.Register(ModuleName, 1114, "no unbonding delegation entries") ErrExceedsMaxDelegations = sdkerrors.Register(ModuleName, 1115, "exceeds max number of delegations") ErrExceedsMaxStakeShare = sdkerrors.Register(ModuleName, 1117, "delegator bonded stake exceeds 30% of total bonded stake") + ErrExceedsMaxReporterPower = sdkerrors.Register(ModuleName, 1118, "reporter power would reach or exceed the max share of total bonded stake") ) diff --git a/x/reporter/types/params.go b/x/reporter/types/params.go index 78734d6ac..ad9c4e5fe 100644 --- a/x/reporter/types/params.go +++ b/x/reporter/types/params.go @@ -22,6 +22,10 @@ var ( DefaultMaxNumOfDelegations = uint64(10) KeyMaxPendingSwitchesPerReporter = []byte("MaxPendingSwitchesPerReporter") DefaultMaxPendingSwitchesPerReporter = uint64(10) + KeyMaxReporterPowerShare = []byte("MaxReporterPowerShare") + // DefaultMaxReporterPowerShare caps a single reporter's potential stake below + // 30% of total bonded tokens; values >= 1 disable the check (small networks). + DefaultMaxReporterPowerShare = math.LegacyNewDecWithPrec(30, 2) ) // ParamKeyTable the param key table for launch module @@ -36,6 +40,7 @@ func NewParams( maxSelectors uint64, maxNumOfDelegations uint64, maxPendingSwitchesPerReporter uint64, + maxReporterPowerShare math.LegacyDec, ) Params { return Params{ MinCommissionRate: minCommissionRate, @@ -43,6 +48,7 @@ func NewParams( MaxSelectors: maxSelectors, MaxNumOfDelegations: maxNumOfDelegations, MaxPendingSwitchesPerReporter: maxPendingSwitchesPerReporter, + MaxReporterPowerShare: maxReporterPowerShare, } } @@ -54,6 +60,7 @@ func DefaultParams() Params { DefaultMaxSelectors, DefaultMaxNumOfDelegations, DefaultMaxPendingSwitchesPerReporter, + DefaultMaxReporterPowerShare, ) } @@ -65,6 +72,7 @@ func (p *Params) ParamSetPairs() paramtypes.ParamSetPairs { paramtypes.NewParamSetPair(KeyMaxSelectors, &p.MaxSelectors, validateMaxSelectors), paramtypes.NewParamSetPair(KeyMaxNumOfDelegations, &p.MaxNumOfDelegations, validateMaxNumOfDelegations), paramtypes.NewParamSetPair(KeyMaxPendingSwitchesPerReporter, &p.MaxPendingSwitchesPerReporter, validateMaxPendingSwitchesPerReporter), + paramtypes.NewParamSetPair(KeyMaxReporterPowerShare, &p.MaxReporterPowerShare, validateMaxReporterPowerShare), } } @@ -85,6 +93,9 @@ func (p Params) Validate() error { if err := validateMaxPendingSwitchesPerReporter(p.MaxPendingSwitchesPerReporter); err != nil { return err } + if err := validateMaxReporterPowerShare(p.MaxReporterPowerShare); err != nil { + return err + } return nil } @@ -136,3 +147,19 @@ func validateMaxPendingSwitchesPerReporter(v interface{}) error { } return nil } + +// validateMaxReporterPowerShare allows nil (pre-migration state, check disabled) +// and any positive share; shares >= 1 disable the check. +func validateMaxReporterPowerShare(v interface{}) error { + share, ok := v.(math.LegacyDec) + if !ok { + return fmt.Errorf("invalid parameter type: %T", v) + } + if share.IsNil() { + return nil + } + if share.IsNegative() { + return fmt.Errorf("max reporter power share cannot be negative") + } + return nil +} diff --git a/x/reporter/types/params.pb.go b/x/reporter/types/params.pb.go index 0ff4fdf0f..c48446efe 100644 --- a/x/reporter/types/params.pb.go +++ b/x/reporter/types/params.pb.go @@ -43,6 +43,10 @@ type Params struct { // max pending reporter switches involving a reporter as outgoing or incoming // (each side capped separately when scheduling a switch). MaxPendingSwitchesPerReporter uint64 `protobuf:"varint,5,opt,name=max_pending_switches_per_reporter,json=maxPendingSwitchesPerReporter,proto3" json:"max_pending_switches_per_reporter,omitempty"` + // max share of total bonded tokens a single reporter's potential stake may + // hold; transactions that would put a reporter at or above this share are + // rejected. Values >= 1 (or unset) disable the check. + MaxReporterPowerShare cosmossdk_io_math.LegacyDec `protobuf:"bytes,6,opt,name=max_reporter_power_share,json=maxReporterPowerShare,proto3,customtype=cosmossdk.io/math.LegacyDec" json:"max_reporter_power_share" yaml:"max_reporter_power_share"` } func (m *Params) Reset() { *m = Params{} } @@ -152,41 +156,43 @@ func init() { func init() { proto.RegisterFile("layer/reporter/params.proto", fileDescriptor_2b46dabd827272cb) } var fileDescriptor_2b46dabd827272cb = []byte{ - // 529 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x84, 0x52, 0xbf, 0x6f, 0xd3, 0x40, - 0x14, 0x8e, 0x69, 0x29, 0x70, 0xb4, 0xa0, 0xba, 0xfc, 0x08, 0xa9, 0xb0, 0x4b, 0x58, 0x2a, 0x50, - 0x6d, 0x89, 0x6e, 0x15, 0x42, 0x28, 0x04, 0x89, 0x4a, 0x15, 0x8d, 0x9c, 0x2c, 0xb0, 0x58, 0x17, - 0xe7, 0xc5, 0x39, 0xc5, 0x77, 0xcf, 0xba, 0xbb, 0x08, 0x7b, 0x62, 0x67, 0xea, 0x9f, 0xc0, 0xc4, - 0xc0, 0xc4, 0xc0, 0x1f, 0xd1, 0xb1, 0x62, 0x42, 0x0c, 0x01, 0x25, 0x03, 0xcc, 0xfd, 0x0b, 0x50, - 0x7c, 0x0e, 0xa9, 0x28, 0x12, 0x8b, 0xe5, 0xf7, 0xde, 0xf7, 0xdd, 0xf7, 0xee, 0xfb, 0x8e, 0x6c, - 0x26, 0x34, 0x07, 0xe9, 0x4b, 0x48, 0x51, 0x6a, 0x90, 0x7e, 0x4a, 0x25, 0xe5, 0xca, 0x4b, 0x25, - 0x6a, 0xb4, 0xaf, 0x15, 0x43, 0x6f, 0x3e, 0xac, 0xad, 0x53, 0xce, 0x04, 0xfa, 0xc5, 0xd7, 0x40, - 0x6a, 0x77, 0x22, 0x54, 0x1c, 0x55, 0x58, 0x54, 0xbe, 0x29, 0xca, 0xd1, 0x8d, 0x18, 0x63, 0x34, - 0xfd, 0xd9, 0x5f, 0xd9, 0x75, 0x63, 0xc4, 0x38, 0x01, 0xbf, 0xa8, 0xba, 0xa3, 0xbe, 0xaf, 0x19, - 0x07, 0xa5, 0x29, 0x4f, 0x0d, 0xa0, 0xfe, 0x71, 0x89, 0xac, 0xb4, 0x8a, 0x2d, 0xec, 0xb7, 0x64, - 0x83, 0x33, 0x11, 0x46, 0xc8, 0x39, 0x53, 0x8a, 0xa1, 0x08, 0x25, 0xd5, 0x50, 0xb5, 0xb6, 0xac, - 0xed, 0x2b, 0x8d, 0xc3, 0xe3, 0xb1, 0x5b, 0xf9, 0x36, 0x76, 0x37, 0x8d, 0xa8, 0xea, 0x0d, 0x3d, - 0x86, 0x3e, 0xa7, 0x7a, 0xe0, 0x1d, 0x40, 0x4c, 0xa3, 0xbc, 0x09, 0xd1, 0xe9, 0xd8, 0xad, 0xe5, - 0x94, 0x27, 0x7b, 0xf5, 0x7f, 0x9c, 0x53, 0xff, 0xf2, 0x79, 0x87, 0x94, 0x1b, 0x37, 0x21, 0x0a, - 0xd6, 0x39, 0x13, 0xcf, 0xfe, 0x40, 0x02, 0xaa, 0xc1, 0x7e, 0x45, 0x2e, 0xcf, 0x88, 0x09, 0xe6, - 0xb4, 0x7a, 0xa1, 0x50, 0x7d, 0x52, 0xaa, 0xde, 0x3c, 0xaf, 0xba, 0x2f, 0xf4, 0xe9, 0xd8, 0xbd, - 0xbe, 0xd0, 0x9b, 0xd1, 0xce, 0x8a, 0xec, 0x0b, 0x1d, 0x5c, 0xe2, 0x4c, 0x1c, 0x60, 0x4e, 0xed, - 0xfb, 0x64, 0x8d, 0xd3, 0x2c, 0x54, 0x90, 0x40, 0xa4, 0x51, 0xaa, 0xea, 0xd2, 0x96, 0xb5, 0xbd, - 0x1c, 0xac, 0x72, 0x9a, 0xb5, 0xe7, 0x3d, 0x7b, 0x97, 0xdc, 0x9a, 0x81, 0xc4, 0x88, 0x87, 0xd8, - 0x0f, 0x7b, 0x90, 0x40, 0x4c, 0x35, 0x43, 0xa1, 0xaa, 0xcb, 0x05, 0x7a, 0x83, 0xd3, 0xec, 0xe5, - 0x88, 0x1f, 0xf6, 0x9b, 0x8b, 0x91, 0xfd, 0x82, 0xdc, 0x9b, 0x91, 0x52, 0x10, 0x3d, 0x26, 0xe2, - 0x50, 0xbd, 0x61, 0x3a, 0x1a, 0x80, 0x0a, 0x53, 0x90, 0xe1, 0x3c, 0xca, 0xea, 0xc5, 0x82, 0x7f, - 0x97, 0xd3, 0xac, 0x65, 0x70, 0xed, 0x12, 0xd6, 0x02, 0x19, 0x94, 0xa0, 0xbd, 0xad, 0x5f, 0xef, - 0x5d, 0xeb, 0xdd, 0xcf, 0x4f, 0x0f, 0x6e, 0x9b, 0x57, 0x92, 0x2d, 0xde, 0x89, 0x49, 0xa8, 0xfe, - 0xc1, 0x22, 0xab, 0x6d, 0x4d, 0x87, 0xd0, 0x91, 0x34, 0x1a, 0x82, 0xb4, 0x9f, 0x12, 0x02, 0x59, - 0xca, 0x64, 0xb1, 0x4b, 0x91, 0xd4, 0xd5, 0x47, 0x35, 0xcf, 0x64, 0xee, 0xcd, 0x33, 0xf7, 0x3a, - 0xf3, 0xcc, 0x1b, 0xcb, 0x47, 0xdf, 0x5d, 0x2b, 0x38, 0xc3, 0xb1, 0x3b, 0x64, 0x85, 0x72, 0x1c, - 0x09, 0x5d, 0x3a, 0xfe, 0xf8, 0x7f, 0x8e, 0xaf, 0x19, 0xc7, 0x0d, 0xe9, 0x6f, 0xbf, 0xcb, 0xb3, - 0x1a, 0xcf, 0x8f, 0x27, 0x8e, 0x75, 0x32, 0x71, 0xac, 0x1f, 0x13, 0xc7, 0x3a, 0x9a, 0x3a, 0x95, - 0x93, 0xa9, 0x53, 0xf9, 0x3a, 0x75, 0x2a, 0xaf, 0x1f, 0xc6, 0x4c, 0x0f, 0x46, 0x5d, 0x2f, 0x42, - 0xee, 0x6b, 0x48, 0x12, 0x94, 0x3b, 0x0c, 0xfd, 0x73, 0x17, 0xd6, 0x79, 0x0a, 0xaa, 0xbb, 0x52, - 0x5c, 0x61, 0xf7, 0x77, 0x00, 0x00, 0x00, 0xff, 0xff, 0x36, 0xfd, 0x6b, 0xce, 0x37, 0x03, 0x00, - 0x00, + // 573 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x52, 0x3f, 0x6f, 0xd3, 0x4e, + 0x18, 0x8e, 0x7f, 0xbf, 0x10, 0xe0, 0x68, 0x41, 0x75, 0x29, 0x98, 0x54, 0xd8, 0x25, 0x2c, 0x15, + 0xa8, 0xb6, 0x44, 0xb7, 0x0a, 0x21, 0x54, 0x8a, 0x44, 0xa5, 0x8a, 0x46, 0x4e, 0x16, 0x58, 0xac, + 0x8b, 0xf3, 0xc6, 0x39, 0xc5, 0x77, 0x67, 0xdd, 0x5d, 0x54, 0x7b, 0x62, 0x47, 0x42, 0xea, 0x47, + 0x60, 0x62, 0x66, 0x60, 0xe2, 0x13, 0x74, 0xac, 0x98, 0x10, 0x43, 0x40, 0xc9, 0x00, 0x73, 0x3f, + 0x01, 0xf2, 0xd9, 0x26, 0x55, 0x0b, 0x82, 0xc5, 0xf2, 0xbd, 0xef, 0xf3, 0xbc, 0xcf, 0xfb, 0xe7, + 0x41, 0xab, 0x31, 0xce, 0x40, 0x78, 0x02, 0x12, 0x2e, 0x14, 0x08, 0x2f, 0xc1, 0x02, 0x53, 0xe9, + 0x26, 0x82, 0x2b, 0x6e, 0x5e, 0xd5, 0x49, 0xb7, 0x4a, 0x36, 0x97, 0x30, 0x25, 0x8c, 0x7b, 0xfa, + 0x5b, 0x40, 0x9a, 0xb7, 0x42, 0x2e, 0x29, 0x97, 0x81, 0x7e, 0x79, 0xc5, 0xa3, 0x4c, 0x5d, 0x8f, + 0x78, 0xc4, 0x8b, 0x78, 0xfe, 0x57, 0x46, 0x9d, 0x88, 0xf3, 0x28, 0x06, 0x4f, 0xbf, 0x7a, 0xe3, + 0x81, 0xa7, 0x08, 0x05, 0xa9, 0x30, 0x4d, 0x0a, 0x40, 0xeb, 0x63, 0x1d, 0x35, 0xda, 0xba, 0x0b, + 0xf3, 0x15, 0x5a, 0xa6, 0x84, 0x05, 0x21, 0xa7, 0x94, 0x48, 0x49, 0x38, 0x0b, 0x04, 0x56, 0x60, + 0x19, 0x6b, 0xc6, 0xfa, 0xe5, 0xed, 0xfd, 0xa3, 0x89, 0x53, 0xfb, 0x32, 0x71, 0x56, 0x0b, 0x51, + 0xd9, 0x1f, 0xb9, 0x84, 0x7b, 0x14, 0xab, 0xa1, 0xbb, 0x07, 0x11, 0x0e, 0xb3, 0x1d, 0x08, 0x4f, + 0x26, 0x4e, 0x33, 0xc3, 0x34, 0xde, 0x6a, 0xfd, 0xa6, 0x4e, 0xeb, 0xd3, 0x87, 0x0d, 0x54, 0x76, + 0xbc, 0x03, 0xa1, 0xbf, 0x44, 0x09, 0x7b, 0xf2, 0x0b, 0xe2, 0x63, 0x05, 0xe6, 0x0b, 0x74, 0x29, + 0x27, 0xc6, 0x3c, 0xc3, 0xd6, 0x7f, 0x5a, 0xf5, 0x51, 0xa9, 0xba, 0x72, 0x5e, 0x75, 0x97, 0xa9, + 0x93, 0x89, 0x73, 0x6d, 0xae, 0x97, 0xd3, 0x4e, 0x8b, 0xec, 0x32, 0xe5, 0x5f, 0xa4, 0x84, 0xed, + 0xf1, 0x0c, 0x9b, 0x77, 0xd1, 0x22, 0xc5, 0x69, 0x20, 0x21, 0x86, 0x50, 0x71, 0x21, 0xad, 0xff, + 0xd7, 0x8c, 0xf5, 0xba, 0xbf, 0x40, 0x71, 0xda, 0xa9, 0x62, 0xe6, 0x26, 0xba, 0x91, 0x83, 0xd8, + 0x98, 0x06, 0x7c, 0x10, 0xf4, 0x21, 0x86, 0x08, 0x2b, 0xc2, 0x99, 0xb4, 0xea, 0x1a, 0xbd, 0x4c, + 0x71, 0xfa, 0x7c, 0x4c, 0xf7, 0x07, 0x3b, 0xf3, 0x94, 0xf9, 0x0c, 0xdd, 0xc9, 0x49, 0x09, 0xb0, + 0x3e, 0x61, 0x51, 0x20, 0x0f, 0x88, 0x0a, 0x87, 0x20, 0x83, 0x04, 0x44, 0x50, 0x9d, 0xd2, 0xba, + 0xa0, 0xf9, 0xb7, 0x29, 0x4e, 0xdb, 0x05, 0xae, 0x53, 0xc2, 0xda, 0x20, 0xfc, 0x12, 0x64, 0xbe, + 0x31, 0x90, 0x95, 0x97, 0xaa, 0x58, 0x41, 0xc2, 0x0f, 0x40, 0x04, 0x72, 0x88, 0x05, 0x58, 0x0d, + 0xbd, 0x8f, 0xee, 0xbf, 0x5d, 0xc1, 0x29, 0xb7, 0xf2, 0x87, 0x62, 0x67, 0x4f, 0xb1, 0x42, 0x71, + 0x5a, 0x75, 0xd1, 0xce, 0x61, 0x9d, 0x1c, 0xb5, 0xb5, 0xf6, 0xe3, 0xad, 0x63, 0xbc, 0xfe, 0xfe, + 0xfe, 0xde, 0xcd, 0xc2, 0xb5, 0xe9, 0xdc, 0xb7, 0x85, 0x63, 0x5a, 0xef, 0x0c, 0xb4, 0xd0, 0x51, + 0x78, 0x04, 0x5d, 0x81, 0xc3, 0x11, 0x08, 0xf3, 0x31, 0x42, 0x90, 0x26, 0x44, 0xe8, 0xdd, 0x68, + 0xe7, 0x5c, 0x79, 0xd0, 0x74, 0x0b, 0x0f, 0xba, 0x95, 0x07, 0xdd, 0x6e, 0xe5, 0xc1, 0xed, 0xfa, + 0xe1, 0x57, 0xc7, 0xf0, 0x4f, 0x71, 0xcc, 0x2e, 0x6a, 0x60, 0xca, 0xc7, 0x4c, 0x95, 0x0e, 0x78, + 0xf8, 0x37, 0x07, 0x2c, 0x16, 0xb3, 0x16, 0xa4, 0xb3, 0xf7, 0x2f, 0x6b, 0x6d, 0x3f, 0x3d, 0x9a, + 0xda, 0xc6, 0xf1, 0xd4, 0x36, 0xbe, 0x4d, 0x6d, 0xe3, 0x70, 0x66, 0xd7, 0x8e, 0x67, 0x76, 0xed, + 0xf3, 0xcc, 0xae, 0xbd, 0xbc, 0x1f, 0x11, 0x35, 0x1c, 0xf7, 0xdc, 0x90, 0x53, 0x4f, 0x41, 0x1c, + 0x73, 0xb1, 0x41, 0xb8, 0x77, 0x6e, 0x60, 0x95, 0x25, 0x20, 0x7b, 0x0d, 0x3d, 0xc2, 0xe6, 0xcf, + 0x00, 0x00, 0x00, 0xff, 0xff, 0x43, 0x9c, 0x47, 0x71, 0xc7, 0x03, 0x00, 0x00, } func (this *Params) Equal(that interface{}) bool { @@ -223,6 +229,9 @@ func (this *Params) Equal(that interface{}) bool { if this.MaxPendingSwitchesPerReporter != that1.MaxPendingSwitchesPerReporter { return false } + if !this.MaxReporterPowerShare.Equal(that1.MaxReporterPowerShare) { + return false + } return true } func (m *Params) Marshal() (dAtA []byte, err error) { @@ -245,6 +254,16 @@ func (m *Params) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l + { + size := m.MaxReporterPowerShare.Size() + i -= size + if _, err := m.MaxReporterPowerShare.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintParams(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x32 if m.MaxPendingSwitchesPerReporter != 0 { i = encodeVarintParams(dAtA, i, uint64(m.MaxPendingSwitchesPerReporter)) i-- @@ -356,6 +375,8 @@ func (m *Params) Size() (n int) { if m.MaxPendingSwitchesPerReporter != 0 { n += 1 + sovParams(uint64(m.MaxPendingSwitchesPerReporter)) } + l = m.MaxReporterPowerShare.Size() + n += 1 + l + sovParams(uint64(l)) return n } @@ -534,6 +555,40 @@ func (m *Params) Unmarshal(dAtA []byte) error { break } } + case 6: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field MaxReporterPowerShare", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowParams + } + 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 ErrInvalidLengthParams + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthParams + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.MaxReporterPowerShare.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex default: iNdEx = preIndex skippy, err := skipParams(dAtA[iNdEx:]) diff --git a/x/reporter/types/params_test.go b/x/reporter/types/params_test.go index 43e979b82..3267f1f8a 100644 --- a/x/reporter/types/params_test.go +++ b/x/reporter/types/params_test.go @@ -14,29 +14,30 @@ import ( func TestParams_NewParams(t *testing.T) { require := require.New(t) - params := NewParams(math.LegacyNewDec(5), math.NewInt(1), 100, 10, 10) + params := NewParams(math.LegacyNewDec(5), math.NewInt(1), 100, 10, 10, math.LegacyNewDecWithPrec(30, 2)) require.NoError(params.Validate()) require.Equal(params.MinCommissionRate, math.LegacyNewDec(5)) require.Equal(params.MinLoya, math.NewInt(1)) require.Equal(params.MaxSelectors, uint64(100)) require.Equal(params.MaxNumOfDelegations, uint64(10)) require.Equal(params.MaxPendingSwitchesPerReporter, uint64(10)) + require.Equal(params.MaxReporterPowerShare, math.LegacyNewDecWithPrec(30, 2)) - params = NewParams(math.LegacyZeroDec(), math.NewInt(0), 0, 0, 1) + params = NewParams(math.LegacyZeroDec(), math.NewInt(0), 0, 0, 1, math.LegacyZeroDec()) require.NoError(params.Validate()) require.Equal(params.MinCommissionRate, math.LegacyZeroDec()) require.Equal(params.MinLoya, math.NewInt(0)) require.Equal(params.MaxSelectors, uint64(0)) require.Equal(params.MaxNumOfDelegations, uint64(0)) - params = NewParams(math.LegacyNewDec(100), math.NewInt(100), 100, 100, 10) + params = NewParams(math.LegacyNewDec(100), math.NewInt(100), 100, 100, 10, math.LegacyOneDec()) require.NoError(params.Validate()) require.Equal(params.MinCommissionRate, math.LegacyNewDec(100)) require.Equal(params.MinLoya, math.NewInt(100)) require.Equal(params.MaxSelectors, uint64(100)) require.Equal(params.MaxNumOfDelegations, uint64(100)) - params = NewParams(math.LegacyNewDec(100), math.NewInt(1000), 1000, 1000, 10) + params = NewParams(math.LegacyNewDec(100), math.NewInt(1000), 1000, 1000, 10, math.LegacyDec{}) require.NoError(params.Validate()) require.Equal(params.MinCommissionRate, math.LegacyNewDec(100)) require.Equal(params.MinLoya, math.NewInt(1000)) @@ -54,6 +55,7 @@ func TestParams_DefaultParams(t *testing.T) { require.Equal(params.MaxSelectors, DefaultMaxSelectors) require.Equal(params.MaxNumOfDelegations, DefaultMaxNumOfDelegations) require.Equal(params.MaxPendingSwitchesPerReporter, DefaultMaxPendingSwitchesPerReporter) + require.Equal(params.MaxReporterPowerShare, DefaultMaxReporterPowerShare) } func TestParams_ParamSetPairs(t *testing.T) { @@ -68,6 +70,7 @@ func TestParams_ParamSetPairs(t *testing.T) { {Key: KeyMaxSelectors, Value: ¶ms.MaxSelectors, ValidatorFn: validateMaxSelectors}, {Key: KeyMaxNumOfDelegations, Value: ¶ms.MaxNumOfDelegations, ValidatorFn: validateMaxNumOfDelegations}, {Key: KeyMaxPendingSwitchesPerReporter, Value: ¶ms.MaxPendingSwitchesPerReporter, ValidatorFn: validateMaxPendingSwitchesPerReporter}, + {Key: KeyMaxReporterPowerShare, Value: ¶ms.MaxReporterPowerShare, ValidatorFn: validateMaxReporterPowerShare}, } for i := range expected { @@ -82,4 +85,13 @@ func TestParams_Validate(t *testing.T) { params := DefaultParams() require.NoError(params.Validate()) + + params.MaxReporterPowerShare = math.LegacyNewDec(-1) + require.Error(params.Validate()) + + // nil (pre-migration) and >= 1 (disabled) are both valid + params.MaxReporterPowerShare = math.LegacyDec{} + require.NoError(params.Validate()) + params.MaxReporterPowerShare = math.LegacyNewDec(2) + require.NoError(params.Validate()) }