Skip to content

fix: canonical validator ids and safe reconcile close-out#82

Merged
jorgecuesta merged 1 commit into
mainfrom
fix/reconcile-canonical-ids-closeout
Jun 12, 2026
Merged

fix: canonical validator ids and safe reconcile close-out#82
jorgecuesta merged 1 commit into
mainfrom
fix/reconcile-canonical-ids-closeout

Conversation

@jorgecuesta

Copy link
Copy Markdown
Collaborator

Follow-up to #81, hardening two review findings with verified fixes.

Validator id from validatorAddress (review point 3)

Validator.id now comes straight from MsgCreateValidator.validatorAddress — the canonical staking module key that reconcileValidators (keyed by cv.operatorAddress) and the reward/commission FKs already assume — instead of being re-derived from the tx signer pubkey. The derivation is kept as a cross-check that throws on mismatch, in both the tx handler and the genesis gentx path. Side effect: the Unsupported Signer: ... fallback can no longer leak into the entity id.

Verified on localnet with a clean reindex:

  • genesis gentx validator: same id as before the change, cross-check silent
  • live MsgCreateValidator tx: row created with id == validator_address, status correctly mirrors BOND_STATUS_UNBONDED
  • 0 orphan validator_rewards FKs

Close-out restricted to Unstaking → Unstaked (review point 2)

An entity cannot leave the chain store without unbonding first, and the unbonding-begin triggers already move rows to Unstaking when that block is processed. So the reconcile close-out now only transitions Unstaking → Unstaked; a Staked row missing from the chain read is an impossible state and gets logged as an error instead of being wiped. This bounds the blast radius of a hypothetical empty/partial chain read to rows already unbonding.

Note: the empty-read scenario itself cannot happen silently through this stack — cosmjs throws at every layer (HTTP ≥400, JSON-RPC error object, ABCI code != 0), so an empty list requires a genuinely successful response. This change is defense in depth plus indexer-bug detection.

Verified on localnet: live app unstake → Unstaking on the begin event → chain dropped the app at unbonding end → row transitioned to Unstaked/stake 0 within two blocks, zero false-positive error logs.

🤖 Generated with Claude Code

Validator entity id now comes from MsgCreateValidator.validatorAddress (the
canonical staking module key) instead of being derived from the tx signer
pubkey. The derivation is kept as a cross-check that fails loudly on mismatch,
in both the tx and genesis gentx paths. This removes the implicit
id == operatorAddress coupling the reconciler and reward/commission FKs rely
on, and fixes the latent 'Unsupported Signer' id corruption.

Reconcile close-out (entity tracked locally but absent from the chain read) is
now restricted to Unstaking -> Unstaked. An entity cannot leave the chain store
without unbonding first, so a Staked row missing from the read is an impossible
state: it is logged as an error and never wiped. This bounds the blast radius
of a hypothetical empty/partial chain read to rows already unbonding.

Verified on localnet: genesis and tx validator creation produce identical ids
with zero cross-check failures, and a live app unstake transitioned
Unstaking -> Unstaked within two blocks of the chain dropping it.
@jorgecuesta jorgecuesta merged commit f641d42 into main Jun 12, 2026
3 checks passed
@jorgecuesta jorgecuesta deleted the fix/reconcile-canonical-ids-closeout branch June 12, 2026 02:22
jorgecuesta added a commit that referenced this pull request Jun 16, 2026
## Problem

The genesis handler only creates validators from `genutil.gen_txs`. A
migration/restart (state-export) genesis instead carries the live
validator set under `app_state.staking.validators` with `gen_txs` empty,
so those validators are **never indexed**.

Observed on **beta** (`pocket-lego-testnet`): genesis had
`staking.validators = 5`, `gen_txs = 0`, and the DB was missing all 5
(only the 3 post-genesis `MsgCreateValidator` tx validators were
present). The ingested genesis was correct — the handler simply skipped
that section (it reads `staking.params` but not `staking.validators`).
Mainnet (`gen_txs = 1`, `staking.validators = 0`) is unaffected.

## Change

`_handleGenesisGenTxs` now also processes `app_state.staking.validators`
in the **same persistence pass** as the gen_tx validators. Keeping it in
one handler is deliberate: splitting it into a parallel handler would
race on `optimizedBulkCreate`'s `destroy`-by-`block_id` of the shared
`MsgCreateValidator` model (both would wipe each other's rows at the
genesis block).

Per staking validator, a `MsgCreateValidator` is synthesized (mirroring
how genesis apps synthesize a `MsgStakeApplication` so relations
resolve), with:
- `id` = `operator_address` (canonical staking key, consistent with #82)
- `ed25519_id` from the consensus pubkey (`sha256(pubkey)[:20]`, hex)
- `signer_id` = operator; `signer_pokt_prefix_id` = operator re-encoded
to the `pokt` prefix (no signer pubkey exists in a state export)
- `commission` converted from the genesis decimal (`"0.1"`) to the
protobuf-scaled integer (`"100000000000000000"`) that the live path and
`reconcileValidators` persist
- `stake_status` mapped from the export's bond status
- `transaction_id` = a genesis fake tx hash, index space continued past
the gen_tx validators so hashes never collide

If a genesis ever carries both sources, operators already created from a
gen_tx are skipped to avoid a duplicate id.

## Verification

- Replayed the handler's derivation against the real beta genesis: it
reproduces the **exact** rows (`id`, `ed25519_id`,
`signer_pokt_prefix_id`, `commission.rate`, `stake_status`, tx hash)
that were confirmed earlier against the live cometbft validator set and
the existing DB row shape.
- `tsc` (changed files) and `eslint` clean.

## Self-review (Otto out on leave)

Ran a high-effort self-review (parallel finders + verify). Findings
addressed:
- **Race** on the shared `MsgCreateValidator` `destroy`-by-`block_id` →
unified into one handler/one write pass.
- **Wrong comment** claiming the gen_tx path stores the integer
commission form (it stores raw decimal) → corrected; the conversion
matches the live/reconcile representation and existing rows.
- **Message.json shape** → now stores a `MsgCreateValidator`-shaped
payload matching the declared `typeUrl`, not the raw `staking.Validator`
shape.
- **Double ed25519 computation** → computed once in the synthesized msg;
the Validator reads it back.
- **`min_self_delegation` NaN** on an empty/missing field → guarded.
- `securityContact` retained (camelCase) to match the live path's actual
stored data; the schema's `securityContract` is a pre-existing
never-populated typo, out of scope.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants