diff --git a/proto/pocket/application/event.proto b/proto/pocket/application/event.proto index 2d37177..045c076 100644 --- a/proto/pocket/application/event.proto +++ b/proto/pocket/application/event.proto @@ -100,6 +100,41 @@ message EventApplicationUnbondingEnd { int64 unbonding_end_height = 4 [(gogoproto.jsontag) = "unbonding_height"]; } +// EventApplicationStakeStuckInModulePool is emitted when EndBlockerUnbondApplications +// (via UnbondApplication) could NOT return the application's escrowed stake to its +// owner account (e.g., a blocked module account, the bank module rejected the send). +// The application is removed from state anyway to keep the chain making progress, +// but the coins remain stranded in the application module pool. Indexers should +// track these events so governance can propose a reclaim transfer; without this +// event the loss would be invisible to off-chain observers. +// +// Mirror of EventSupplierStakeStuckInModulePool (see pocket/supplier/event.proto). +// Before v0.1.34 the application path returned the bank-send error from +// EndBlockerUnbondApplications, which halts the chain when a legacy +// module-account-owned application exists. This event replaces that halt vector +// with an indexer-visible signal + continue, matching the supplier-side fix. +// +// Expected occurrences on a healthy mainnet: zero. Non-zero count post-upgrade +// indicates pre-existing legacy state (module-account-owned applications); the +// new stake-time module-account-owner check blocks NEW occurrences. +message EventApplicationStakeStuckInModulePool { + // application_address identifies the removed application. + string application_address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // stuck_coin is the coin that remains in the application module pool with no + // owner to claim it. + cosmos.base.v1beta1.Coin stuck_coin = 2 [(gogoproto.jsontag) = "stuck_coin"]; + + // reason is the bank-module error string captured at the time of the failed + // send. Free-form, intended for human triage. + string reason = 3 [(gogoproto.jsontag) = "reason"]; + + // session_end_height is the session-end height of the EndBlocker that + // attempted (and failed) the return-of-stake. Useful for correlating with + // settlement events in the same block. + int64 session_end_height = 4 [(gogoproto.jsontag) = "session_end_height"]; +} + // EventApplicationUnbondingCanceled is emitted when an application which was unbonding // successfully (re-)stakes before the unbonding period has elapsed. An EventApplicationStaked // event will also be emitted immediately after this event. diff --git a/proto/pocket/application/tx.proto b/proto/pocket/application/tx.proto index 0555998..1d1a916 100644 --- a/proto/pocket/application/tx.proto +++ b/proto/pocket/application/tx.proto @@ -55,6 +55,10 @@ message MsgStakeApplication { cosmos.base.v1beta1.Coin stake = 2; // The total amount of uPOKT the application has staked. Must be ≥ to the current amount that the application has staked (if any) repeated pocket.shared.ApplicationServiceConfig services = 3; // The list of services this application is staked to request service for + // Optional per-session spend limit in uPOKT. + // Three-way semantics: nil/omitted = preserve existing limit, zero = clear limit, positive = set new limit. + cosmos.base.v1beta1.Coin per_session_spend_limit = 4; + // TODO_POST_MAINNET: Consider allowing applications to delegate // to gateways at time of staking for a better developer experience. // repeated string gateway_address diff --git a/proto/pocket/application/types.proto b/proto/pocket/application/types.proto index fd62de4..53f5ff4 100644 --- a/proto/pocket/application/types.proto +++ b/proto/pocket/application/types.proto @@ -44,6 +44,41 @@ message Application { // Information about pending application transfers PendingApplicationTransfer pending_transfer = 7; + + // Optional per-session spend limit in uPOKT. When set, caps the maximum + // amount of stake consumed per session. Nil = no limit (default). + cosmos.base.v1beta1.Coin per_session_spend_limit = 8; + + // List of historical service configuration updates, tracking the application's + // service config changes and their corresponding activation heights. + // + // Mirrors the supplier `service_config_history` pattern to support + // deterministic historical session queries (GetSession at a past height must + // return the service config that was active at that height, not the latest). + // + // This history is NOT pruned: applications are orders of magnitude fewer than + // supplier service rows, so the storage cost is negligible, and keeping it + // forever avoids the pruning-induced historical-query non-determinism observed + // on the supplier side (see session_mutation_analysis). + repeated ApplicationServiceConfigUpdate service_config_history = 9; +} + +// ApplicationServiceConfigUpdate tracks a change in an application's service +// configuration at a specific block height, enabling deterministic +// reconstruction of which service an application was staked for at any height. +message ApplicationServiceConfigUpdate { + // Address of the application corresponding to the service configuration change + string application_address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // The specific service configuration that was added or scheduled for removal + pocket.shared.ApplicationServiceConfig service = 2; + + // Block height at which this service configuration became active in the network + int64 activation_height = 3; + + // Block height at which this service configuration was deactivated (0 if still active). + // For configs scheduled for deactivation, this stores the height when deactivation occurs. + int64 deactivation_height = 4; } // UndelegatingGatewayList is used as the Value of `pending_undelegations`. diff --git a/proto/pocket/proof/event.proto b/proto/pocket/proof/event.proto index c9f9937..74487ed 100644 --- a/proto/pocket/proof/event.proto +++ b/proto/pocket/proof/event.proto @@ -9,7 +9,7 @@ import "gogoproto/gogo.proto"; import "pocket/proof/types.proto"; message EventClaimCreated { - // Next index: 13 + // Next index: 14 // pocket.proof.Claim claim = 1 [(gogoproto.jsontag) = "claim"]; // cosmos.base.v1beta1.Coin claimed_upokt = 6 [(gogoproto.jsontag) = "claimed_upokt"]; @@ -42,11 +42,18 @@ message EventClaimCreated { // The operator address of the supplier which submitted the claim. string supplier_operator_address = 12 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // The probabilistic estimate of the total number of relays served this session. + // num_estimated_relays = num_estimated_compute_units / compute_units_per_relay. + // Mirrors EventClaimSettled.num_estimated_relays so indexers do not have to + // re-derive it; the off-chain derivation divides by num_relays and breaks when + // num_relays == 0 (e.g. empty claims), which this field avoids. + uint64 num_estimated_relays = 13 [(gogoproto.jsontag) = "num_estimated_relays"]; } // TODO_TEST: Add coverage for claim updates. message EventClaimUpdated { - // Next index: 13 + // Next index: 14 // pocket.proof.Claim claim = 1 [(gogoproto.jsontag) = "claim"]; // cosmos.base.v1beta1.Coin claimed_upokt = 6 [(gogoproto.jsontag) = "claimed_upokt"]; @@ -79,10 +86,17 @@ message EventClaimUpdated { // The operator address of the supplier which updated the claim. string supplier_operator_address = 12 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // The probabilistic estimate of the total number of relays served this session. + // num_estimated_relays = num_estimated_compute_units / compute_units_per_relay. + // Mirrors EventClaimSettled.num_estimated_relays so indexers do not have to + // re-derive it; the off-chain derivation divides by num_relays and breaks when + // num_relays == 0 (e.g. empty claims), which this field avoids. + uint64 num_estimated_relays = 13 [(gogoproto.jsontag) = "num_estimated_relays"]; } message EventProofSubmitted { - // Next index: 13 + // Next index: 14 // pocket.proof.Claim claim = 1 [(gogoproto.jsontag) = "claim"]; // cosmos.base.v1beta1.Coin claimed_upokt = 6 [(gogoproto.jsontag) = "claimed_upokt"]; @@ -115,11 +129,18 @@ message EventProofSubmitted { // The operator address of the supplier which submitted the proof. string supplier_operator_address = 12 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // The probabilistic estimate of the total number of relays served this session. + // num_estimated_relays = num_estimated_compute_units / compute_units_per_relay. + // Mirrors EventClaimSettled.num_estimated_relays so indexers do not have to + // re-derive it; the off-chain derivation divides by num_relays and breaks when + // num_relays == 0 (e.g. empty claims), which this field avoids. + uint64 num_estimated_relays = 13 [(gogoproto.jsontag) = "num_estimated_relays"]; } // TODO_TEST: Add coverage for proof updates. message EventProofUpdated { - // Next index: 13 + // Next index: 14 // pocket.proof.Claim claim = 1 [(gogoproto.jsontag) = "claim"]; // cosmos.base.v1beta1.Coin claimed_upokt = 6 [(gogoproto.jsontag) = "claimed_upokt"]; @@ -152,6 +173,13 @@ message EventProofUpdated { // The operator address of the supplier which updated the proof. string supplier_operator_address = 12 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // The probabilistic estimate of the total number of relays served this session. + // num_estimated_relays = num_estimated_compute_units / compute_units_per_relay. + // Mirrors EventClaimSettled.num_estimated_relays so indexers do not have to + // re-derive it; the off-chain derivation divides by num_relays and breaks when + // num_relays == 0 (e.g. empty claims), which this field avoids. + uint64 num_estimated_relays = 13 [(gogoproto.jsontag) = "num_estimated_relays"]; } // Event emitted after a proof has been checked for validity in the proof module's diff --git a/proto/pocket/service/tx.proto b/proto/pocket/service/tx.proto index 86b23f5..7e5823b 100644 --- a/proto/pocket/service/tx.proto +++ b/proto/pocket/service/tx.proto @@ -22,7 +22,8 @@ service Msg { // parameters. The authority defaults to the x/gov module account. rpc UpdateParams (MsgUpdateParams) returns (MsgUpdateParamsResponse); rpc UpdateParam (MsgUpdateParam ) returns (MsgUpdateParamResponse ); - rpc AddService (MsgAddService ) returns (MsgAddServiceResponse ); + rpc AddService (MsgAddService ) returns (MsgAddServiceResponse ); + rpc TransferService (MsgTransferService ) returns (MsgTransferServiceResponse ); } // MsgUpdateParams is the Msg/UpdateParams request type. message MsgUpdateParams { @@ -78,3 +79,15 @@ message MsgAddServiceResponse { // pocket.shared.Service service = 1; reserved 1; } + +// MsgTransferService transfers ownership of a service to a new address. +// Only the current owner can initiate the transfer. +message MsgTransferService { + option (cosmos.msg.v1.signer) = "owner_address"; + string owner_address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; // The Bech32 address of the current service owner (signer). + string service_id = 2; // The unique identifier of the service to transfer. + string new_owner_address = 3 [(cosmos_proto.scalar) = "cosmos.AddressString"]; // The Bech32 address of the new service owner. +} + +// MsgTransferServiceResponse is the response to a MsgTransferService message. +message MsgTransferServiceResponse {} diff --git a/proto/pocket/session/genesis.proto b/proto/pocket/session/genesis.proto index 28ad871..b44bbf7 100644 --- a/proto/pocket/session/genesis.proto +++ b/proto/pocket/session/genesis.proto @@ -13,4 +13,6 @@ import "pocket/session/params.proto"; message GenesisState { // params defines all the parameters of the module. Params params = 1 [(gogoproto.nullable) = false, (amino.dont_omitempty) = true]; + // params_history contains historical params snapshots for height-based lookups. + repeated ParamsUpdate params_history = 2 [(gogoproto.nullable) = false]; } diff --git a/proto/pocket/shared/genesis.proto b/proto/pocket/shared/genesis.proto index e241450..f6d7220 100644 --- a/proto/pocket/shared/genesis.proto +++ b/proto/pocket/shared/genesis.proto @@ -15,4 +15,6 @@ message GenesisState { (gogoproto.nullable) = false, (amino.dont_omitempty) = true ]; + // params_history contains historical params snapshots for height-based lookups. + repeated ParamsUpdate params_history = 2 [(gogoproto.nullable) = false]; } diff --git a/proto/pocket/shared/params.proto b/proto/pocket/shared/params.proto index ac43541..9e91669 100644 --- a/proto/pocket/shared/params.proto +++ b/proto/pocket/shared/params.proto @@ -83,6 +83,20 @@ message Params { // ongoing sessions. This prevents sessions from settling using parameters that // were not in effect during their creation. uint64 compute_unit_cost_granularity = 11 [(gogoproto.jsontag) = "compute_unit_cost_granularity", (gogoproto.moretags) = "yaml:\"compute_unit_cost_granularity\""]; + + // session_grid_anchor_height is the block height at which this params epoch's session + // grid begins. Boundary math is computed relative to this anchor instead of block 1, so + // that changing num_blocks_per_session does not misalign in-flight sessions (#543). + // DERIVED metadata, NOT governable: set by the param-update handler to the next session + // boundary; any value supplied via governance is ignored/overwritten. A zero value falls + // back to the genesis block-1 grid (legacy behavior). + uint64 session_grid_anchor_height = 12 [(gogoproto.jsontag) = "session_grid_anchor_height"]; + + // session_number_at_anchor is the session number of the session starting at + // session_grid_anchor_height. Used to keep GetSessionNumber monotonic across epochs + // (epoch-relative numbering would otherwise reset at each change). + // DERIVED metadata, NOT governable. + uint64 session_number_at_anchor = 13 [(gogoproto.jsontag) = "session_number_at_anchor"]; } // ParamsUpdate stores a snapshot of shared parameters @@ -95,4 +109,4 @@ message ParamsUpdate { // params is the snapshot of shared params that were effective starting at effective_height. Params params = 2 [(gogoproto.jsontag) = "params"]; -} +} \ No newline at end of file diff --git a/proto/pocket/shared/query.proto b/proto/pocket/shared/query.proto index 5fdae19..3051177 100644 --- a/proto/pocket/shared/query.proto +++ b/proto/pocket/shared/query.proto @@ -16,6 +16,14 @@ service Query { rpc Params(QueryParamsRequest) returns (QueryParamsResponse) { option (google.api.http).get = "/pokt-network/poktroll/shared/params"; } + + // ParamsAtHeight queries the shared parameters that were effective at a given block + // height. Used by off-chain clients (e.g. the RelayMiner) to compute a session's + // claim/proof windows using the num_blocks_per_session that was in effect when that + // session started, rather than the live value — see the anchored session grid (#543). + rpc ParamsAtHeight(QueryParamsAtHeightRequest) returns (QueryParamsAtHeightResponse) { + option (google.api.http).get = "/pokt-network/poktroll/shared/params/{height}"; + } } // QueryParamsRequest is request type for the Query/Params RPC method. @@ -29,3 +37,18 @@ message QueryParamsResponse { (amino.dont_omitempty) = true ]; } + +// QueryParamsAtHeightRequest is request type for the Query/ParamsAtHeight RPC method. +message QueryParamsAtHeightRequest { + // height is the block height at which to look up the effective shared params. + int64 height = 1; +} + +// QueryParamsAtHeightResponse is response type for the Query/ParamsAtHeight RPC method. +message QueryParamsAtHeightResponse { + // params holds the shared parameters that were effective at the requested height. + Params params = 1 [ + (gogoproto.nullable) = false, + (amino.dont_omitempty) = true + ]; +} diff --git a/proto/pocket/supplier/event.proto b/proto/pocket/supplier/event.proto index 07f8f1a..84e510f 100644 --- a/proto/pocket/supplier/event.proto +++ b/proto/pocket/supplier/event.proto @@ -64,6 +64,39 @@ message EventSupplierUnbondingCanceled { int64 session_end_height = 2 [(gogoproto.jsontag) = "session_end_height"]; } +// EventSupplierStakeStuckInModulePool is emitted when EndBlockerUnbondSuppliers +// could NOT return the supplier's bonded stake to its owner account (e.g., the +// owner is a blocked module account, the bank module rejected the send). The +// supplier is removed from state anyway to keep the chain making progress, but +// the coins remain stranded in the supplier module pool. Indexers should track +// these events so governance can propose a reclaim transfer; without this event +// the loss would be invisible to off-chain observers. +// +// Pre-v0.1.34 the same scenario only produced a Logger().Error line — easy to +// miss in operator workflows. The new stake-time module-account-owner check +// prevents NEW occurrences, so this event should fire only for legacy state. +message EventSupplierStakeStuckInModulePool { + // operator_address identifies the removed supplier. + string operator_address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // owner_address is the address the bank module refused to send to. Either a + // blocked module account or an otherwise rejected recipient. + string owner_address = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // stuck_coin is the coin that remains in the supplier module pool with no + // owner to claim it. + cosmos.base.v1beta1.Coin stuck_coin = 3 [(gogoproto.jsontag) = "stuck_coin"]; + + // reason is the bank-module error string captured at the time of the failed + // send. Free-form, intended for human triage. + string reason = 4 [(gogoproto.jsontag) = "reason"]; + + // session_end_height is the session-end height of the EndBlocker that + // attempted (and failed) the return-of-stake. Useful for correlating with + // settlement events in the same block. + int64 session_end_height = 5 [(gogoproto.jsontag) = "session_end_height"]; +} + // EventSupplierServiceConfigActivated is emitted when a supplier service configuration // becomes effective at a specific block height. message EventSupplierServiceConfigActivated { diff --git a/proto/pocket/tokenomics/event.proto b/proto/pocket/tokenomics/event.proto index 3612ef6..9ed834e 100644 --- a/proto/pocket/tokenomics/event.proto +++ b/proto/pocket/tokenomics/event.proto @@ -72,6 +72,13 @@ message EventClaimExpired { // The operator address of the supplier whose claim expired. string supplier_operator_address = 12 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // The probabilistic estimate of the total number of relays served this session. + // num_estimated_relays = num_estimated_compute_units / compute_units_per_relay. + // Mirrors EventClaimSettled.num_estimated_relays so indexers do not have to + // re-derive it; the off-chain derivation divides by num_relays and breaks when + // num_relays == 0 (e.g. empty claims), which this field avoids. + uint64 num_estimated_relays = 13 [(gogoproto.jsontag) = "num_estimated_relays"]; } // EventClaimSettled is emitted during settlement whenever a claim is successfully settled. @@ -221,7 +228,7 @@ message EventApplicationOverserviced { // EventSupplierSlashed is emitted when a supplier is slashed. // This can happen for in cases such as missing or invalid proofs for submitted claims. message EventSupplierSlashed { - // Next index: 9 + // Next index: 10 // pocket.proof.Claim claim = 1; // cosmos.base.v1beta1.Coin proof_missing_penalty = 2; @@ -253,6 +260,12 @@ message EventSupplierSlashed { // The operator address of the supplier that was slashed. string supplier_operator_address = 8 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // The supplier's remaining stake AFTER the slash was applied, in uPOKT + // (capped at 0 when the penalty exceeds the prior stake). Lets indexers read + // the post-slash stake directly instead of subtracting proof_missing_penalty + // from a cached prior stake, removing dependence on indexer cache accuracy. + string supplier_stake_after_slash = 9 [(gogoproto.jsontag) = "supplier_stake_after_slash"]; } // EventClaimDiscarded is emitted when a claim is discarded due to unexpected situations. @@ -329,6 +342,146 @@ message EventSettlementBatch { string op_type = 7; } +// EventValidatorRewardDistribution is emitted once per bonded validator per settlement +// op_reason per settlement block. It summarizes how that validator's slice of the proposer +// reward pool was split into commission and per-delegator rewards. +// +// It does NOT multiply with the number of delegators or claims (it is bounded by the +// validator-set size), preserving the settlement event-count reduction from #1758. +// +// A delegator's reward from this validator can be reconstructed (off-chain / indexer side) +// without per-delegator events: +// +// delegatorReward = (self_delegation_reward_upokt + delegators_reward_upokt) +// × (delegatorStake / total_delegated_stake_upokt) +// +// where `(self_delegation_reward_upokt + delegators_reward_upokt)` is the post-commission +// remainder, distributed across all delegations (including the validator's self-delegation) +// proportional to stake. +// +// CROSS-DELEGATION ACCOUNTING NOTE (for indexers): +// When the SAME pokt address is both a validator's account AND a delegator on a +// different validator, its income from BOTH sources is bucketed under the +// validator-side op_reason in EventSettlementBatch (because the bank-batch +// accumulator keys on (recipient, op_reason), not source). Per-validator +// breakdown reported BY THIS EVENT stays correct — commission_upokt is +// unambiguously validator income and delegators_reward_upokt / +// self_delegation_reward_upokt are unambiguously delegator-side. Indexers +// building "VALIDATOR vs DELEGATOR" totals across the bank-batch stream +// should sum from this event (per-validator) rather than from +// EventSettlementBatch alone, otherwise cross-delegation accounts will +// over-count under VALIDATOR and under-count under DELEGATOR. +message EventValidatorRewardDistribution { + // Next index: 12 + + // The session end block height for the batch of settlements. + // + // Caveat (cross-session batches): when settlement processes claims that + // belong to DIFFERENT session_end_block_heights in the same settlement + // block (the O2 cross-session candidate-scan path, exercised after a + // window-offset change), this field reports the session_end_block_height + // of the FIRST claim in the batched results — it does NOT identify every + // session contributing to the pool. Indexers wanting a canonical timestamp + // for the validator-reward summary should use the settlement block height + // from the SDK header (the height at which this event was emitted) rather + // than treating session_end_block_height as definitive. On the common + // path (single-session settlement, by far the typical case on mainnet), + // this field and the settlement block height differ by exactly the + // claim/proof-window offsets, and there is no ambiguity. + int64 session_end_block_height = 1; + + // The settlement operation reason (distinguishes Mint=Burn from Global Mint pools). + SettlementOpReason op_reason = 2; + + // The validator operator (valoper...) address. + string validator_operator_address = 3; + + // The validator account (pokt...) address; commission and the self-delegation slice + // are paid here. + string validator_account_address = 4; + + // The validator's commission rate as a decimal string (e.g. "0.100000000000000000"). + string commission_rate = 5; + + // The validator's slice of the total proposer reward pool, as upokt. + string pool_share_upokt = 6; + + // The commission carved out of pool_share_upokt and paid to the validator account, as upokt. + string commission_upokt = 7; + + // The validator's own self-delegation slice of the post-commission remainder, as upokt. + // For sole-stakeholder validators (no external delegations), the entire remainder is here. + string self_delegation_reward_upokt = 8; + + // The portion of the post-commission remainder paid to external (non-self) delegators, as upokt. + string delegators_reward_upokt = 9; + + // The total delegated stake backing this validator (includes the self-delegation), as upokt. + // This is the denominator for per-delegator reward attribution. + string total_delegated_stake_upokt = 10; + + // The number of external (non-self) delegators that received a reward from this validator. + uint32 num_delegators = 11; +} + +// EventSupplierRevShareFallbackDistribution is emitted during settlement when a +// supplier's per-service RevShare list cannot be used as-is to distribute the +// supplier's slice of the reward. The known triggers are: +// +// 1. Sum of revshare percentages != 100 (validation perimeter hole: the +// stake-time ValidateServiceRevShare guarantees sum == 100 for NEW stakes, +// but pre-v0.1.34 state migrated via the duplicate-revshare merge +// (`x/supplier/keeper/migrate_duplicate_revshare.go`) can produce a merged +// list with sum > 100 — that supplier survives in store until it restakes). +// 2. Duplicate recipient addresses observed AFTER migration (defensive — the +// migration is expected to have removed them, but settlement validates +// independently to avoid silent map-overwrite data loss). +// 3. A negative remainder computed during proportional distribution (defensive +// overflow check; should be unreachable when sum == 100 holds). +// +// On any of the above, settlement pays the full supplier slice to the supplier's +// `owner_address` (a proto-level field guaranteed populated at stake time, +// independent of any revshare config). This rescues the chain from a `NewCoin` +// panic on negative amount AND keeps supplier revenue flowing to the rightful +// owner, while signalling clearly that the operator's revshare config is broken +// and needs to be re-staked. +// +// Indexers should treat this event as a "supplier config is broken" beacon: +// payouts via this event will NOT appear in the revshare-configured recipients' +// balances. Operator follow-up = restake supplier with clean revshare. +// +// Expected occurrences on a healthy mainnet: zero. Non-zero count post-upgrade +// indicates pre-existing duplicate-revshare suppliers that the migration could +// not normalize back to sum == 100; combine with B2 pre-upgrade enumeration to +// reach out to affected operators. +message EventSupplierRevShareFallbackDistribution { + // The operator address of the supplier whose revshare config was rejected. + string supplier_operator_address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // The owner address that received the full supplier slice as fallback. + string supplier_owner_address = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + + // The Service ID for the claim whose distribution triggered the fallback. + string service_id = 3; + + // The session end block height of the settled claim that triggered the fallback. + int64 session_end_block_height = 4; + + // The full amount paid to the owner as fallback, as a coin string (e.g. "1000upokt"). + string amount = 5; + + // The settlement operation reason (TLM source) that produced the supplier slice. + SettlementOpReason op_reason = 6; + + // Observed sum of revshare percentages, 0-255+ in theory but practically a + // small positive integer different from 100. Reported for operator triage. + uint64 observed_sum_percentage = 7; + + // Human-readable reason describing why the fallback engaged + // (e.g. "sum 110 != 100", "duplicate address ...", "negative remainder"). + string reason = 8; +} + // EventApplicationReimbursementRequest is emitted when an application requests a reimbursement from the DAO. // It is intended to prevent self dealing attacks when global inflation is enabled. // TODO_DISTANT_FUTURE: Remove this once global inflation is disabled in perpetuity. @@ -355,4 +508,4 @@ message EventApplicationReimbursementRequest { // The amount of uPOKT to be reimbursed to the application. string amount = 7; -} +} \ No newline at end of file diff --git a/proto/pocket/tokenomics/params.proto b/proto/pocket/tokenomics/params.proto index a11507a..d00007a 100644 --- a/proto/pocket/tokenomics/params.proto +++ b/proto/pocket/tokenomics/params.proto @@ -82,4 +82,4 @@ message MintEqualsBurnClaimDistribution { // application - % of claimable tokens sent to the application account address. double application = 5 [(gogoproto.jsontag) = "application", (gogoproto.moretags) = "yaml:\"application\""]; -} +} \ No newline at end of file diff --git a/schema.graphql b/schema.graphql index 4025a62..824aa42 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1131,6 +1131,52 @@ type EventProofValidityChecked @entity { event: Event! } +# EventValidatorRewardDistribution is emitted once per bonded validator per +# settlement op_reason per settlement block (v0.1.34+). It summarizes how that +# validator's slice of the proposer reward pool was split into commission and +# (self / external) delegator rewards. It is bounded by the validator-set size, +# so it does NOT scale with delegators or claims (preserves the #1758 event-count +# reduction). +# +# ACCOUNTING NOTE: build "VALIDATOR vs DELEGATOR" reward totals from THIS event +# (per-validator) rather than from EventSettlementBatch / ModToAcctTransfer. When +# the same pokt address is both a validator account AND a delegator on another +# validator, the bank-batch stream buckets both incomes under the validator-side +# op_reason, over-counting VALIDATOR and under-counting DELEGATOR. The split here +# is unambiguous: commissionAmount is validator income; self/external delegator +# amounts are delegator-side. +# +# All *Amount fields are denominated in upokt (chain emits bare integer strings). +type EventValidatorRewardDistribution @entity { + id: ID! + # session_end_block_height of the FIRST claim in the batch. On the common + # single-session path this differs from the settlement block height only by the + # claim/proof window offsets; for a canonical timestamp use block. + sessionEndBlockHeight: BigInt! + # Distinguishes the Mint=Burn pool from the Global Mint pool. + opReason: SettlementOpReason! + # valoper... operator address. + validatorOperatorAddress: String! @index + # pokt... account address; commission + self-delegation slice are paid here. + validatorAccountAddress: String! @index + # Commission rate as a decimal string, e.g. "0.100000000000000000". + commissionRate: String! + # This validator's slice of the total proposer reward pool (upokt). + poolShareAmount: BigInt! + # Commission carved out of poolShareAmount, paid to the validator account (upokt). + commissionAmount: BigInt! + # Validator self-delegation slice of the post-commission remainder (upokt). + selfDelegationRewardAmount: BigInt! + # Portion of the post-commission remainder paid to external delegators (upokt). + delegatorsRewardAmount: BigInt! + # Total delegated stake backing this validator incl. self-delegation (upokt); + # the denominator for per-delegator reward attribution. + totalDelegatedStakeAmount: BigInt! + # Number of external (non-self) delegators that received a reward. + numDelegators: Int! + block: Block! +} + type Supply @entity { id: ID! denom: String! @index diff --git a/src/mappings/authz/grants.ts b/src/mappings/authz/grants.ts index 10c8d6e..7ac066b 100644 --- a/src/mappings/authz/grants.ts +++ b/src/mappings/authz/grants.ts @@ -2,12 +2,9 @@ import { CosmosEvent, CosmosMessage } from "@subql/types-cosmos"; import { MsgGrant } from "cosmjs-types/cosmos/authz/v1beta1/tx"; import { AuthzProps } from "../../types/models/Authz"; import { getAuthzId, getBlockId, getEventId } from "../utils/ids"; +import { parseAttribute } from "../utils/json"; import { isEventOfFinalizedBlockKind, isEventOfMessageKind } from "../utils/primitives"; -function parseAttribute(attribute: unknown): string { - return (attribute as string).replaceAll("\"", ""); -} - function _handleEventGrant(event: CosmosEvent): AuthzProps { let granter: string | null = null; let grantee: string | null = null; diff --git a/src/mappings/handlers.ts b/src/mappings/handlers.ts index d76fc21..6f83d81 100644 --- a/src/mappings/handlers.ts +++ b/src/mappings/handlers.ts @@ -31,6 +31,7 @@ import { handleEventProofUpdated, handleEventProofValidityChecked, handleEventSettlementBatch, + handleEventValidatorRewardDistribution, handleMsgCreateClaim, handleMsgSubmitProof, } from "./pocket/relays"; @@ -126,6 +127,7 @@ export const EventHandlers: Record) => Promi "pocket.tokenomics.EventApplicationOverserviced": handleEventApplicationOverserviced, "pocket.tokenomics.EventApplicationReimbursementRequest": handleEventApplicationReimbursementRequest, "pocket.tokenomics.EventSettlementBatch": handleEventSettlementBatch, + "pocket.tokenomics.EventValidatorRewardDistribution": handleEventValidatorRewardDistribution, "pocket.proof.EventClaimUpdated": handleEventClaimUpdated, "pocket.proof.EventProofUpdated": handleEventProofUpdated, "pocket.proof.EventProofValidityChecked": handleEventProofValidityChecked, diff --git a/src/mappings/indexer.manager.ts b/src/mappings/indexer.manager.ts index 2128ce3..d77673a 100644 --- a/src/mappings/indexer.manager.ts +++ b/src/mappings/indexer.manager.ts @@ -174,6 +174,7 @@ async function indexRelays(msgByType: MessageByType, eventByType: EventByType): "pocket.tokenomics.EventApplicationOverserviced", "pocket.tokenomics.EventApplicationReimbursementRequest", "pocket.tokenomics.EventSettlementBatch", + "pocket.tokenomics.EventValidatorRewardDistribution", ]; await Promise.all([ diff --git a/src/mappings/pocket/relays.ts b/src/mappings/pocket/relays.ts index 29bdad8..7940818 100644 --- a/src/mappings/pocket/relays.ts +++ b/src/mappings/pocket/relays.ts @@ -20,6 +20,7 @@ import { EventClaimSettledProps } from "../../types/models/EventClaimSettled"; import { EventClaimUpdatedProps } from "../../types/models/EventClaimUpdated"; import { EventProofUpdatedProps } from "../../types/models/EventProofUpdated"; import { EventProofValidityCheckedProps } from "../../types/models/EventProofValidityChecked"; +import { EventValidatorRewardDistributionProps } from "../../types/models/EventValidatorRewardDistribution"; import { MsgCreateClaimProps } from "../../types/models/MsgCreateClaim"; import { MsgSubmitProofProps } from "../../types/models/MsgSubmitProof"; import { CoinSDKType } from "../../types/proto-interfaces/cosmos/base/v1beta1/coin"; @@ -49,6 +50,7 @@ import { messageId, } from "../utils/ids"; import { + parseAttribute, parseJson, stringify, } from "../utils/json"; @@ -197,10 +199,6 @@ function getSettlementOpReasonFromSDK(item: typeof SettlementOpReasonSDKType | n } } -function parseAttribute(attribute: unknown = ""): string { - return (attribute as string).replaceAll("\"", ""); -} - // eslint-disable-next-line complexity export function getAttributes(attributes: CosmosEvent["event"]["attributes"]) { @@ -232,7 +230,8 @@ export function getAttributes(attributes: CosmosEvent["event"]["attributes"]) { chainNumEstimatedRelays: bigint | undefined, mintedUpokt: CoinSDKType | undefined, overservicingLossUpokt: CoinSDKType | undefined, - deflationLossUpokt: CoinSDKType | undefined; + deflationLossUpokt: CoinSDKType | undefined, + supplierStakeAfterSlash: CoinSDKType | undefined; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -341,6 +340,11 @@ export function getAttributes(attributes: CosmosEvent["event"]["attributes"]) { proofMissingPenalty = getDenomAndAmount(attribute.value as string); } + // EventSupplierSlashed field 9 (v0.1.34+): post-slash stake as a coin string. + if (attribute.key === "supplier_stake_after_slash") { + supplierStakeAfterSlash = getDenomAndAmount(attribute.value as string); + } + if (attribute.key === "failure_reason") { failureReason = parseAttribute(attribute.value as string); } @@ -414,6 +418,7 @@ export function getAttributes(attributes: CosmosEvent["event"]["attributes"]) { mintedUpokt, overservicingLossUpokt, deflationLossUpokt, + supplierStakeAfterSlash, }; } @@ -520,14 +525,18 @@ function _handleMsgCreateClaim(msg: CosmosMessage): MsgCreateCla */ const { + chainNumEstimatedRelays, claimed, numClaimedComputedUnits, numEstimatedComputedUnits, numRelays, } = getAttributes(eventClaimCreated.attributes); - const CUPR = Math.floor(Number(numClaimedComputedUnits) / Number(numRelays)) - const numEstimatedRelays = BigInt(Math.round(Number(numEstimatedComputedUnits) / CUPR)) + // Prefer the chain-emitted num_estimated_relays (EventClaimCreated field 13, + // v0.1.34+); the helper derives it from compute units for pre-upgrade blocks. + const numEstimatedRelays = _computeNumEstimatedRelays( + chainNumEstimatedRelays, numEstimatedComputedUnits, numClaimedComputedUnits, numRelays, + ); return { id: messageId(msg), @@ -845,7 +854,17 @@ function _computeNumEstimatedRelays( if (chainNumEstimatedRelays !== undefined) { return chainNumEstimatedRelays; } - return BigInt(Math.round(Number(numEstimatedComputedUnits) / Math.floor(Number(numClaimedComputedUnits) / Number(numRelays)))); + // Pre-upgrade fallback: derive from compute units. Guard against numRelays == 0 + // and a 0 compute-units-per-relay (CUPR) divisor, both of which would otherwise + // produce Infinity/NaN and make BigInt() throw. + if (numRelays === BigInt(0)) { + return BigInt(0); + } + const cupr = Math.floor(Number(numClaimedComputedUnits) / Number(numRelays)); + if (cupr === 0) { + return BigInt(0); + } + return BigInt(Math.round(Number(numEstimatedComputedUnits) / cupr)); } function _computeDeflationLoss( @@ -963,6 +982,7 @@ function _handleEventClaimSettled( function _handleEventClaimExpired(event: CosmosEvent): EventClaimExpiredProps { const { + chainNumEstimatedRelays, claim, claimed, expirationReason, @@ -973,8 +993,11 @@ function _handleEventClaimExpired(event: CosmosEvent): EventClaimExpiredProps { const { proof_validation_status, session_header, supplier_operator_address } = claim; - const CUPR = Math.floor(Number(numClaimedComputedUnits) / Number(numRelays)) - const numEstimatedRelays = BigInt(Math.round(Number(numEstimatedComputedUnits) / CUPR)) + // Prefer the chain-emitted num_estimated_relays (EventClaimExpired field 13, + // v0.1.34+); the helper derives it from compute units for pre-upgrade blocks. + const numEstimatedRelays = _computeNumEstimatedRelays( + chainNumEstimatedRelays, numEstimatedComputedUnits, numClaimedComputedUnits, numRelays, + ); return { supplierId: supplier_operator_address, @@ -1576,3 +1599,116 @@ export async function handleEventSettlementBatch(events: Array): Pr ]); } } + +// EventValidatorRewardDistribution (v0.1.34+) is emitted once per bonded +// validator per settlement op_reason per settlement block. It carries the +// canonical per-validator commission/delegator split, which is the correct +// source for "VALIDATOR vs DELEGATOR" reward totals — see the entity comment in +// schema.graphql for the cross-delegation accounting caveat that makes summing +// from EventSettlementBatch alone over-count the validator side. +function _handleEventValidatorRewardDistribution( + event: CosmosEvent, + blockId: bigint, +): EventValidatorRewardDistributionProps { + let sessionEndBlockHeight = BigInt(0), + opReason = "", + validatorOperatorAddress = "", + validatorAccountAddress = "", + commissionRate = "", + poolShareAmount = BigInt(0), + commissionAmount = BigInt(0), + selfDelegationRewardAmount = BigInt(0), + delegatorsRewardAmount = BigInt(0), + totalDelegatedStakeAmount = BigInt(0), + numDelegators = 0; + + for (const attribute of event.event.attributes) { + switch (attribute.key) { + case "session_end_block_height": + sessionEndBlockHeight = BigInt(parseAttribute(attribute.value)); + break; + case "op_reason": + opReason = parseAttribute(attribute.value); + break; + case "validator_operator_address": + validatorOperatorAddress = parseAttribute(attribute.value); + break; + case "validator_account_address": + validatorAccountAddress = parseAttribute(attribute.value); + break; + case "commission_rate": + commissionRate = parseAttribute(attribute.value); + break; + // *_upokt fields are emitted as bare integer strings (no denom). + case "pool_share_upokt": + poolShareAmount = BigInt(parseAttribute(attribute.value)); + break; + case "commission_upokt": + commissionAmount = BigInt(parseAttribute(attribute.value)); + break; + case "self_delegation_reward_upokt": + selfDelegationRewardAmount = BigInt(parseAttribute(attribute.value)); + break; + case "delegators_reward_upokt": + delegatorsRewardAmount = BigInt(parseAttribute(attribute.value)); + break; + case "total_delegated_stake_upokt": + totalDelegatedStakeAmount = BigInt(parseAttribute(attribute.value)); + break; + case "num_delegators": + numDelegators = Number(parseAttribute(attribute.value)); + break; + default: + break; + } + } + + // Fail fast on malformed events instead of silently persisting empty/zero rows + // (mirrors the guard clauses in the sibling event handlers). + if (!validatorOperatorAddress) { + throw new Error(`[handleEventValidatorRewardDistribution] validator_operator_address not found in event ${getEventId(event)}`); + } + if (!validatorAccountAddress) { + throw new Error(`[handleEventValidatorRewardDistribution] validator_account_address not found in event ${getEventId(event)}`); + } + if (Number.isNaN(numDelegators)) { + throw new Error(`[handleEventValidatorRewardDistribution] num_delegators is not a number in event ${getEventId(event)}`); + } + + // op_reason should always resolve to a known reward-distribution reason; surface + // protocol drift (a new/renamed reason) rather than silently bucketing as UNSPECIFIED. + const opReasonEnum = getSettlementOpReasonFromSDK(opReason); + if (opReasonEnum === SettlementOpReason.UNSPECIFIED) { + logger.warn(`[handleEventValidatorRewardDistribution] unknown op_reason='${opReason}' resolved to UNSPECIFIED in event ${getEventId(event)}`); + } + + return { + // getEventId includes the event index, so per-validator/op_reason emissions + // within the same settlement block get distinct ids. + id: getEventId(event), + blockId, + sessionEndBlockHeight, + opReason: opReasonEnum, + validatorOperatorAddress, + validatorAccountAddress, + commissionRate, + poolShareAmount, + commissionAmount, + selfDelegationRewardAmount, + delegatorsRewardAmount, + totalDelegatedStakeAmount, + numDelegators, + }; +} + +export async function handleEventValidatorRewardDistribution(events: Array): Promise { + if (events.length === 0) return; + + // All events in the batch belong to the same block; compute blockId once. + const blockId = getBlockId(events[0].block); + await optimizedBulkCreate( + "EventValidatorRewardDistribution", + events.map((event) => _handleEventValidatorRewardDistribution(event, blockId)), + "block_id", + ); +} diff --git a/src/mappings/pocket/suppliers.ts b/src/mappings/pocket/suppliers.ts index 250e95f..9dc11f5 100644 --- a/src/mappings/pocket/suppliers.ts +++ b/src/mappings/pocket/suppliers.ts @@ -633,6 +633,8 @@ function _getValuesOldEventSupplierSlashed(event: CosmosEvent) { session: "", sessionStartHeight: BigInt(0), sessionEndHeight: BigInt(0), + // Not emitted before v0.1.34; afterStakeAmount is derived from stake - penalty. + supplierStakeAfterSlash: undefined as CoinSDKType | undefined, } } @@ -640,6 +642,7 @@ function _getValuesEventSupplierSlashed(event: CosmosEvent) { const { claim, proofMissingPenalty, + supplierStakeAfterSlash, } = getAttributes(event.event.attributes); if (!claim || !claim.session_header || Object.keys(claim).length === 0) { @@ -656,6 +659,7 @@ function _getValuesEventSupplierSlashed(event: CosmosEvent) { sessionStartHeight: BigInt(claim.session_header.session_start_block_height || "0"), proofMissingPenalty, proofValidationStatus: getClaimProofStatusFromSDK(claim.proof_validation_status), + supplierStakeAfterSlash, } } @@ -678,6 +682,7 @@ export function _handleEventSupplierSlashed( session, sessionEndHeight, sessionStartHeight, + supplierStakeAfterSlash, } = _getValuesEventSupplierSlashed(event); if (!operatorAddress) { @@ -694,8 +699,21 @@ export function _handleEventSupplierSlashed( throw new Error(`[handleEventSupplierSlashed] supplier not found for address: ${operatorAddress}`); } - const previousStakeAmount = currentSupplier.stakeAmount.valueOf(); - const afterStakeAmount = currentSupplier.stakeAmount - BigInt(proofMissingPenalty.amount); + // Prefer the chain-emitted post-slash stake (supplier_stake_after_slash, v0.1.34+); + // fall back to deriving it from the indexer-tracked stake minus the penalty for + // pre-upgrade events (also robust when tracked stake drifts on pruned nodes). + const afterStakeAmount = supplierStakeAfterSlash + ? BigInt(supplierStakeAfterSlash.amount) + : currentSupplier.stakeAmount - BigInt(proofMissingPenalty.amount); + + // When the chain emits the post-slash stake, derive the pre-slash stake from it + // (after + penalty, both chain-sourced) so that + // previousStakeAmount - afterStakeAmount === proofMissingPenalty holds by + // construction and does not drift with the indexer-tracked stake. Pre-upgrade + // events fall back to the indexer-tracked stake. + const previousStakeAmount = supplierStakeAfterSlash + ? afterStakeAmount + BigInt(proofMissingPenalty.amount) + : currentSupplier.stakeAmount.valueOf(); return { supplier: {