Skip to content

feat(agent-004): real delegation tx-stream for WF-VM-02#104

Closed
brawlaphant wants to merge 5 commits into
regen-network:mainfrom
brawlaphant:feat/agent-004-real-delegation-tx-stream
Closed

feat(agent-004): real delegation tx-stream for WF-VM-02#104
brawlaphant wants to merge 5 commits into
regen-network:mainfrom
brawlaphant:feat/agent-004-real-delegation-tx-stream

Conversation

@brawlaphant

Copy link
Copy Markdown
Contributor

Summary

Replaces the token-delta MVP proxy in WF-VM-02 with a real staking tx-search client that reads recent `MsgDelegate`, `MsgUndelegate`, and `MsgBeginRedelegate` events from the Cosmos LCD. Closes the follow-up documented in #81's design-decision #2. Sibling PR to #103 (AGENT-003 MsgRetire tx-stream).

  • Lands in: `agent-004-validator-monitor/`
  • Changes: new tx-search client + `DelegationEvent` type + rewritten WF-VM-02 observe+orient + 22 new tests
  • Validate: `cd agent-004-validator-monitor && npm test && npx tsc --noEmit`

The change

Before (MVP) After (real tx-stream)
Snapshot `validator.tokens` per cycle, diff against previous snapshot Query LCD `tx-search` for three staking message type URLs
Delegate / undelegate / redelegate all collapsed into a single signed delta Three distinct event types, each carrying real attributes
No delegator identity Delegator extracted from the positional `message` event sender
Intra-cycle netting invisible (A delegates, B undelegates same amount → "no change") Every event is recorded, net is derived in the aggregator
No audit trail to tx hash Each DelegationEvent carries `txHash` and `occurredAt`

Ledger client additions

  • `getRecentDelegationTxs(limit)` — queries the LCD tx-search endpoint three times, once per staking message type URL (`MsgDelegate`, `MsgUndelegate`, `MsgBeginRedelegate`). Per-type failures are isolated — a dropped query doesn't take the others down.
  • `parseDelegationEventsFromTx(tx)` — public pure function. Walks `logs[].events[]` and `tx.events[]` for cross-SDK compatibility. Matches three Cosmos SDK event types (`delegate`, `unbond`, `redelegate`) and extracts the delegator from positional `message` event senders.
  • `parseCoinAmount(raw)` — helper that strips the denom suffix from Cosmos coin-amount strings like `"1000uregen"`, returning the numeric part as a BigInt-safe string.

Workflow refactor

  • `observe()` replaced with `ledger.getRecentDelegationTxs(200)`.
  • `orient()` uses two new exported pure functions:
    • `aggregateEventsToFlows(events)` — groups events per-validator, handling the three event types correctly (redelegate produces two flows: source outflow + destination inflow).
    • `summarizeFlows(flows)` — derives totals, whale count, top inflow, top outflow.
  • Monikers are still backfilled from `ledger.getValidators()` inside the orient phase so the narrative layer can show operator names.
  • `act()` unchanged — still persists and alerts on whale activity.

Test additions — 22 new tests (55 total across 3 files, up from 33 in #100)

`src/ledger.test.ts` (9 new tests)

Covers MsgDelegate, MsgUndelegate, MsgBeginRedelegate extraction; batched txs with positional sender matching; missing-validator / malformed-amount guards; coin-amount format parsing; and the flat-vs-nested event layout.

`src/workflows/delegation-flow-analysis.test.ts` (13 new tests)

Covers the aggregator (empty, single event of each type, redelegate splits, netting, zero-delta skip, whale tagging, bad amounts) and the summarizer (zero totals, sum correctness, top inflow/outflow, whale count separation).

What this unlocks

  • Delegate vs undelegate vs redelegate distinction. A validator losing 100K stake could be a pure outflow OR a redelegate; the old proxy collapsed both. The narrative layer can now distinguish them.
  • Delegator attribution. Downstream analytics can see WHO delegated, not just WHERE the stake landed.
  • Intra-cycle visibility. A delegates 1000, B undelegates 1000 within the same cycle: the old proxy reported "no change" and missed both events. The new implementation records them both.
  • Tx hash audit trail. Every flow can be traced back to a real tx on the LCD.

Scope boundary

Does NOT touch WF-VM-01 (performance tracking) or WF-VM-03 (decentralization monitor). The bech32 operator→delegator conversion for governance participation scoring is the next follow-up PR — m014 governance score is still MVP-zero after this PR.

PR relationship

Based on #100 (AGENT-004 unit tests), which is based on #81 (AGENT-004 initial implementation). Sibling to #103 (AGENT-003 real MsgRetire tx-stream). The two real-tx-stream PRs close the MVP-proxy column for both market-monitor and validator-monitor in the same session.

Refs `phase-2/2.2-agentic-workflows.md` §WF-VM-02
Refs #81's design decision #2 (MVP token-delta proxy → real tx-stream follow-up)

Mirrors the AGENT-002 / AGENT-003 structure to give AGENT-004 Validator
Monitor the same standalone TypeScript implementation:

- `agent-004-validator-monitor/` — standalone Node.js process
- `src/index.ts` — banner, cycle runner, polling vs single-run mode
- `src/config.ts` — config + thresholds mirrored from the character
- `src/types.ts` — staking / slashing / gov types + per-workflow shapes
- `src/ledger.ts` — LCD client for staking, slashing, gov endpoints
- `src/store.ts` — SQLite state (token snapshots, commission history,
  scorecards, decentralization snapshots, workflow executions)
- `src/ooda.ts` — generic OODA executor (same shape as agent-002/003)
- `src/monitor.ts` — Claude narrative layer, one function per workflow
- `src/output.ts` — console + optional Discord webhook dispatcher
- `src/workflows/performance-tracking.ts` — WF-VM-01
- `src/workflows/delegation-flow-analysis.ts` — WF-VM-02
- `src/workflows/decentralization-monitor.ts` — WF-VM-03

Scoring (M014):
- Uptime component: signed_blocks_window − missed_blocks_counter,
  weighted at config.validator.scoreWeightUptime (default 400)
- Governance component: votes cast / recent finalized proposals,
  weighted at config.validator.scoreWeightGovernance (default 350)
- Stability component: full weight minus penalties for jailing
  (100) and commission changes in the trailing window (40 each),
  floored at 0, weighted at config.validator.scoreWeightStability
  (default 250)
- Composite = sum, 0..1000, PoA eligible when >= 800

Decentralization (WF-VM-03):
- Nakamoto coefficient uses the 33.4% halt-threshold convention
- Gini index uses the textbook formula on token amounts normalized
  to uregen
- Health classification from Nakamoto floor (<=5 CRITICAL, <=8
  WARNING), Gini ceiling (>=0.65 WARNING), and single-validator
  concentration (>=33% CRITICAL, >=20% WARNING)

Delegation flows (WF-VM-02):
- Snapshot each validator's `tokens` field every cycle
- Derive flows from the delta against the previous snapshot
- Whale-sized movements (>= 100,000 REGEN by default) tagged in
  the summary and raise the alert level

Determinism: all scoring, Nakamoto, Gini, health, and whale
detection are computed in plain TypeScript. Claude is only used
for the narrative layer. Keeps the agent cheap, reproducible, and
auditable — and makes the hard parts trivially unit-testable in a
follow-up PR.

Deliberate MVP proxies (documented in the agent README):
1. Token-delta as the delegation source for WF-VM-02 until a real
   MsgDelegate / MsgUndelegate / MsgRedelegate tx-stream client
   lands.
2. Governance participation currently scores 0 for every validator
   because the operator→delegator bech32 conversion is deferred to
   a follow-up PR. Relative composites still surface real
   differences in uptime and stability without asymmetric penalty.
3. MVP signing-info join on the raw consensus key — falls back to
   assuming 100% uptime when no match is found, which under-counts
   real issues rather than smearing a healthy validator.

CI: the new agent is added to the `agents` job so `npx tsc --noEmit`
runs against it on every PR, matching the existing
agent-002-governance-analyst wiring.

- Lands in: `agent-004-validator-monitor/`, `.github/workflows/ci.yml`
- Changes: new standalone AGENT-004 process with 3 workflows (WF-VM-01/02/03)
- Validate: `cd agent-004-validator-monitor && npm ci && npx tsc --noEmit`

Refs phase-2/2.2-agentic-workflows.md §WF-VM-01, §WF-VM-02, §WF-VM-03
Refs agents/packages/agents/src/characters/validator-monitor.ts (regen-network#64)
Sibling PR to the AGENT-003 unit tests (regen-network#99) and a follow-up to
PR regen-network#81, which promised the unit tests as a "separate test-only PR
so this PR stays a single-concern 'add the agent' change."

Adds 33 unit tests across 2 test files covering every
deterministic helper in the AGENT-004 workflows. The helpers are
the core of the decentralization analysis surface — if they drift
silently, the validator monitor produces misleading alerts or
misses real concentration attacks.

## Changes

### Helper exports

Five previously module-private helpers are now exported so the
test files can import them:

  decentralization-monitor.ts:
    nakamotoCoefficient, giniIndex, topNSharePct, classifyHealth

  delegation-flow-analysis.ts:
    absBig

The export is the only production-code change — no behavior
change, no API rename. Module consumers are unchanged.

### Test files

  src/workflows/decentralization-monitor.test.ts   (28 tests)

    nakamotoCoefficient — 8 tests
      - empty input / zero total returns 0
      - single validator with entire stake → 1
      - top validator > 33.4% → 1
      - top validator exactly 33.4% (334/1000) → 2
        (pins the STRICT `> threshold` predicate — a refactor that
         changes > to >= would silently produce Nakamoto = 1 here)
      - top two combined clear threshold → 2
      - ten equal validators → 4
      - degenerate case when total > sum of list

    giniIndex — 7 tests
      - empty / single-element → 0
      - perfect equality → 0
      - unequal distribution > 0
      - maximally unequal (one holds everything) → approaches 1
      - all-zeros → 0 (cumulative guard)
      - monotonicity: more inequality → higher Gini

    topNSharePct — 6 tests
      - zero total → 0
      - top 1, top 3 cumulative share
      - n > array length → 100%
      - n = array length → 100%
      - two-decimal precision

    classifyHealth — 7 tests
      - HEALTHY baseline
      - CRITICAL on Nakamoto floor
      - CRITICAL on single-validator concentration
      - WARNING on Nakamoto warning floor
      - WARNING on Gini ceiling
      - WARNING on single-validator warning concentration
      - CRITICAL wins over WARNING when thresholds overlap

  src/workflows/delegation-flow-analysis.test.ts    (5 tests)

    absBig — 5 tests
      - zero
      - positive inputs
      - negative inputs
      - values beyond Number.MAX_SAFE_INTEGER (2^53 + 1)
        — critical because AGENT-004 works in uregen and 221M REGEN
        is 2.21e14 uregen, close to the unsafe-integer boundary
      - idempotence

### Vitest setup

Same structure as AGENT-003's test PR (regen-network#99):

  vitest.config.ts              — standard config, node env
  package.json                  — adds "test" / "test:watch" + vitest ^2.1.0
  tsconfig.json                 — excludes *.test.ts from prod typecheck
  .gitignore                    — adds *.db-shm and *.db-wal

## Validation

  $ cd agent-004-validator-monitor && npm test
  Test Files  2 passed (2)
       Tests  33 passed (33)

  $ cd agent-004-validator-monitor && npx tsc --noEmit
  (exit 0)

Note: the tests import decentralization-monitor.ts, which in turn
imports store.ts at the top level. The store constructor opens a
SQLite database on import. If a prior test run left stale WAL
lock files on disk, the next run fails with "database is locked".
The .gitignore update prevents those lock files from landing in
a PR; developers running tests locally may need to rm -f
agent-004.db-shm agent-004.db-wal once if they hit the lock.

## Scope

Does NOT touch the OODA loops, the Claude narrative layer, the
LCD client, the SQLite store, or any output formatting. Tests
cover pure functions only.

- Lands in: `agent-004-validator-monitor/`
- Changes: 33 unit tests + vitest setup + 5 helper exports
- Validate: `cd agent-004-validator-monitor && npm test`

## PR relationship

Based on PR regen-network#81's branch. If regen-network#81 merges first, this PR rebases
cleanly. Sibling PR to regen-network#99 (AGENT-003 unit tests) — the two
follow an identical structure and review together better than
separately.
Replaces the token-delta MVP proxy with a real staking tx-search
client that reads recent MsgDelegate, MsgUndelegate, and
MsgBeginRedelegate events from the Cosmos LCD. Closes the
follow-up documented in PR regen-network#81's design-decision regen-network#2.

## What changes in the workflow

The observe phase no longer snapshots `validator.tokens` or
consults the previous snapshot. Instead:

  const events = await ledger.getRecentDelegationTxs(200);

The orient phase aggregates events per-validator via a new pure
function `aggregateEventsToFlows`, which handles three rules:

  - delegate   → inflow to event.validator
  - undelegate → outflow from event.validator
  - redelegate → outflow from event.sourceValidator,
                 inflow to event.validator (destination)

`summarizeFlows` (also new and exported for tests) derives the
totals, whale count, top inflow, and top outflow from the flow
list. Neither function touches the store — the old per-cycle
token snapshot is no longer needed.

The monikers for the narrative layer are still backfilled from
a single `ledger.getValidators()` call inside the orient phase.

## What changes in the ledger client

New methods on LedgerClient:

  - `getRecentDelegationTxs(limit)` — queries the LCD tx-search
    endpoint once per staking message type (three type URLs
    total) and flattens the results into a single
    DelegationEvent list. Per-type failures are isolated: if the
    MsgUndelegate query fails for any reason, the MsgDelegate
    and MsgBeginRedelegate results still come through.

  - `parseDelegationEventsFromTx(tx)` — public pure function.
    Walks events at both `logs[].events[]` and top-level
    `tx.events[]` positions for cross-SDK compatibility. Matches
    three Cosmos SDK event types: `delegate`, `unbond`, and
    `redelegate`. Extracts the delegator address from the
    positionally-corresponding `message` event sender.

  - New helper `parseCoinAmount(raw)` extracts the numeric
    prefix from Cosmos coin-amount strings like "1000uregen",
    returning the numeric part as a string for BigInt-safe
    downstream consumption.

## New types

  - `DelegationEvent` — a single on-chain staking event with
    txHash, eventType, delegator, validator,
    sourceValidator (only set for redelegate), amountUregen,
    and occurredAt. The DelegationFlow type is preserved for
    backward compatibility with the narrative layer.

## New tests — 22 total (55 total across 3 files, up from 33 in regen-network#100)

### src/ledger.test.ts (9 new tests)
- empty tx
- MsgDelegate extraction with sender from message event
- MsgUndelegate extraction via the `unbond` event type
- MsgBeginRedelegate with source + destination validators
- batched: 3 staking events in one tx with positional sender matching
- missing validator attribute ignored
- malformed amount attribute ignored
- coin-amount format "<uint>uregen" parsed correctly
- events read from tx.events[] alongside logs[].events[]

### src/workflows/delegation-flow-analysis.test.ts (13 new tests,
on top of the existing 5 for absBig)
- aggregateEventsToFlows:
  - empty
  - single delegate → inflow
  - single undelegate → outflow
  - redelegate → two flows (source + destination)
  - net delegate + undelegate on same validator
  - zero net delta skipped
  - whale threshold tagging
  - zero/negative amount skipped
  - non-numeric amount skipped
- summarizeFlows:
  - zero totals on empty
  - inflow / outflow / net sum correctness
  - top inflow + top outflow identification
  - whale count separate from total

## What this unlocks

The old MVP proxy couldn't:
  - Distinguish delegate from undelegate from redelegate.
    A validator losing 100K stake could be a pure outflow
    (undelegate) or a redelegate to a different validator,
    but the proxy saw the same delta number either way.
  - Attribute flows to a specific delegator address.
  - Capture intra-cycle movements that net to zero (A delegates,
    B undelegates same amount within a minute — the old proxy
    reported "no change" and missed both events).
  - Produce a reliable audit trail linking back to real tx
    hashes.

The new implementation fixes all four.

## Scope

Does NOT touch WF-VM-01 (performance tracking) or WF-VM-03
(decentralization monitor). The bech32 operator→delegator
conversion for governance participation scoring is a separate
follow-up (the m014 governance score is still MVP-zero after
this PR).

- Lands in: `agent-004-validator-monitor/`
- Changes: new tx-search client + new DelegationEvent type +
  rewritten WF-VM-02 observe+orient phases + 22 new tests
- Validate: `cd agent-004-validator-monitor && npm test && npx tsc --noEmit`

## PR relationship

Based on PR regen-network#100 (AGENT-004 unit tests) which is based on PR regen-network#81
(AGENT-004 initial implementation). Sibling to PR regen-network#103 (AGENT-003
MsgRetire tx-stream). The two real-tx-stream PRs close the
MVP-proxy column for both market-monitor and validator-monitor
in the same session.

Refs `phase-2/2.2-agentic-workflows.md` §WF-VM-02
Refs PR regen-network#81's design decision regen-network#2 (MVP token-delta proxy → real tx-stream follow-up)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces AGENT-004, a standalone Node.js agent designed to monitor Regen Network validator performance, delegation flows, and network decentralization. The implementation utilizes an OODA loop architecture with SQLite for state management and Claude for generating narrative reports. Feedback highlights several critical logic errors, including unreliable delegator attribution due to positional matching, incompatible format handling in uptime scoring, and event duplication from redundant data sources. Additionally, improvements are needed to correct database query offsets for trend analysis and to prevent precision loss in decentralization metrics during token unit conversions.

Comment on lines +213 to +229
if (sender) senders.push(sender.value);
}
}

const results: DelegationEvent[] = [];
let senderIdx = 0;

const nextSender = (): string => {
const s = senders[senderIdx] ?? "";
senderIdx++;
return s;
};

for (const ev of collected) {
const type = typeof ev.type === "string" ? ev.type : "";
const attributes = (ev.attributes || []) as Array<{ key: string; value: string }>;
const attr = (k: string): string | null => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The positional matching logic for attributing delegators is unreliable. It assumes that the $N$-th staking event in the transaction corresponds to the $N$-th message event's sender. However, if a transaction contains a mix of staking and non-staking messages (e.g., [MsgDelegate, MsgSend, MsgUndelegate]), the senders array will include the sender of the MsgSend, causing the undelegate event to be attributed to the wrong address. It is recommended to iterate through tx.logs and process events message-by-message to ensure correct attribution.

Comment on lines +146 to +148
const signing = signingByConsAddrLike.get(
v.consensus_pubkey?.key || ""
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The uptime scoring logic will fail to find signing information because it attempts to match a base64-encoded public key (v.consensus_pubkey.key) against a bech32-encoded consensus address (info.address, which starts with regenvalcons). These formats are incompatible. To correctly link these, you must derive the consensus address from the public key (SHA256 hash of the pubkey, truncated to 20 bytes, then bech32 encoded). Without this conversion, the uptime score will incorrectly default to 100% for all validators, missing real performance issues.

Comment on lines +200 to +205
const collected: Array<Record<string, unknown>> = [];
const logs = (tx.logs || []) as Array<Record<string, unknown>>;
for (const log of logs) {
collected.push(...((log.events || []) as Array<Record<string, unknown>>));
}
collected.push(...((tx.events || []) as Array<Record<string, unknown>>));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation duplicates events by merging both tx.logs[].events and tx.events. In most Cosmos SDK versions, tx.events is a flattened version of all events already present in the logs. Processing both will lead to double-counting of all delegation, undelegation, and redelegation events. You should check if logs are present and non-empty, and only fallback to tx.events if necessary.

const row = this.db
.prepare(
`SELECT nakamoto, gini, top10_pct, health, snapshot, captured_at
FROM decentralization_snapshots ORDER BY id DESC LIMIT 1 OFFSET 1`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The OFFSET 1 in this query is incorrect for the intended use case. Since getLatestDecentralizationSnapshot is called in the orient phase before the current cycle's snapshot is saved, the most recent record in the database is the actual "previous" snapshot needed for trend analysis. Using OFFSET 1 skips this and returns an older record (or null if only one exists), skewing the trend comparison.

Suggested change
FROM decentralization_snapshots ORDER BY id DESC LIMIT 1 OFFSET 1`
FROM decentralization_snapshots ORDER BY id DESC LIMIT 1`

const total = tokensBig.reduce((acc, t) => acc + t, 0n);
const sortedDesc = [...tokensBig].sort((a, b) => (a > b ? -1 : a < b ? 1 : 0));

const tokensNum = active.map((v) => Number(BigInt(v.tokens || "0") / 1_000_000n));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Dividing by 1_000_000n before converting to Number causes significant precision loss for the Gini index calculation. For example, validators with 1.1 REGEN and 1.9 REGEN would both be treated as having exactly 1 REGEN. Since the total supply of REGEN in uregen fits well within the safe integer range of a JavaScript number (up to $\approx 9 \times 10^{15}$), you should convert the full uregen value to Number to maintain calculation accuracy.

Suggested change
const tokensNum = active.map((v) => Number(BigInt(v.tokens || "0") / 1_000_000n));
const tokensNum = active.map((v) => Number(BigInt(v.tokens || "0")));

brawlaphant added a commit to brawlaphant/agentic-tokenomics that referenced this pull request Apr 11, 2026
…e scoring

Closes the last MVP proxy in AGENT-004 — the governance score in
WF-VM-01 was hardcoded to zero because the operator→delegator
bech32 conversion required to query per-validator votes was
deferred to this follow-up. This PR delivers it.

## What changes

### Bech32 converter

New exported pure function `operatorToAccountBech32`:

  regenvaloper1abc... → regen1abc...

It decodes the operator bech32, verifies the HRP ends in
"valoper", strips that suffix to get the delegator HRP, and
re-encodes the same word payload. Returns null on any invalid
input — not a bech32, HRP not ending in valoper, empty delegator
prefix, or bad checksum. The null-instead-of-throw shape lets
the observe phase skip broken validators without crashing the
whole cycle.

The underlying bech32 library is `bech32@^2.0.0` — a zero-
runtime-dependency npm package that implements the reference
encoding from BIP-0173. Added to `package.json` as the only new
runtime dependency.

### Observe phase rewired

For each validator in the set, the observe phase now:
  1. Converts operator → delegator bech32.
  2. For each recent finalized proposal, calls
     `ledger.getVoteForVoter(proposalId, delegatorAddress)`.
  3. Counts the number of successful vote lookups.

Validators whose operator address does not convert cleanly get
a zero count — no crash, no asymmetric penalty relative to
validators that DID vote zero times on actual proposals.

The fan-out is O(validators × proposals) per cycle — up to
~75 × 20 = 1500 LCD requests on mainnet. Both loops run in
parallel with Promise.all so wall-clock time is bounded by the
slowest request, not the total count. A future optimization can
batch the queries by fetching `/proposals/{id}/votes` once per
proposal and indexing locally.

### Orient phase uses real numbers

No math changes — the existing scoring formula already read
`obs.votesCastByOperator`. Previously the map was empty; now
it's populated. The resulting composite score for validators
that DID vote reflects real governance participation, and the
PoA eligibility calculation (`composite >= 800`) can now
meaningfully distinguish validators that participate in
governance from validators that only sign blocks.

## New tests — 7 total

**src/workflows/performance-tracking.test.ts** (new test file)

All seven tests use real `bech32.encode` to build test inputs
rather than hand-rolled fixture strings. This way, any change
in the bech32 library's output format would be caught
automatically instead of silently drifting past the tests.

- converts regenvaloper → regen
- converts cosmosvaloper → cosmos (chain-agnostic)
- round-trip decode produces identical 20-byte payload
- returns null for non-bech32 input
- returns null for delegator bech32 (no valoper suffix)
- returns null when HRP is exactly "valoper" (empty prefix after strip)
- returns null for a bech32 address with a bad checksum

Full test suite: 62 passed across 4 test files (up from 55 in regen-network#104).

## What this unlocks

With real governance scores in the composite, the M014
eligibility decision now reflects the full SPEC methodology:

- Uptime (400/1000): signed blocks in trailing window
- Governance (350/1000): % of recent finalized proposals voted on
- Stability (250/1000): jailing and commission penalty

A validator that perfectly signs blocks but never votes now
scores ~650 and does NOT clear the 800 PoA threshold — as the
SPEC intends. Previously both "perfect signer, zero governance"
and "perfect signer, perfect governance" produced the same
score of 650 because governance was hardcoded zero. The MVP
hid a real distinction; this PR surfaces it.

## Scope

Does NOT touch WF-VM-02 (delegation flow — addressed in regen-network#104)
or WF-VM-03 (decentralization). Does NOT implement per-proposal
vote batching (noted as a future optimization). Does NOT fix
the signing-info join which is still MVP-positional — a future
PR can address that by computing the valcons bech32 from the
consensus pubkey.

- Lands in: `agent-004-validator-monitor/`
- Changes: new bech32 converter + real vote fetching + 7 new tests
- Validate: `cd agent-004-validator-monitor && npm test && npx tsc --noEmit`

## PR relationship

Based on PR regen-network#104 (AGENT-004 real delegation tx-stream). Together
with regen-network#103 (AGENT-003 real MsgRetire tx-stream) and regen-network#104, this
PR closes every MVP-proxy column in both agent implementations.
The only remaining known limitation in the AGENT-004 WF-VM-01
path is the signing-info join, which is an orthogonal fix.

Refs `mechanisms/m014-authority-validator-governance/SPEC.md` §5.3
Refs `phase-2/2.2-agentic-workflows.md` §WF-VM-01
Refs PR regen-network#81's design decision regen-network#3 (MVP-zero governance → real scoring follow-up)
brawlaphant added a commit to brawlaphant/agentic-tokenomics that referenced this pull request Apr 11, 2026
…T, loop, commission

Addresses Gemini review feedback on PR regen-network#81:

WF-VM-01 (performance-tracking):
* Derive the validator's regenvalcons1… consensus address from its
  consensus pubkey (SHA256 of pubkey bytes → first 20 bytes → bech32
  under the consensus HRP) and use that as the signingByConsAddrLike
  lookup key. The old code was joining a base64 pubkey string against
  a bech32 address, so the lookup always missed and every validator
  reported 0 missed blocks — uptime scored 100% across the board.
* Hoist the trailing-window `sinceIso` out of the per-validator loop
  so we do not recompute the same Date arithmetic N times per cycle.
* Drop the dead `operatorToAccountBech32` stub and use the real
  `operatorToDelegator` helper from the new bech32 module.

WF-VM-03 (decentralization-monitor):
* Gini index now converts the full uregen value to Number without
  pre-dividing by 1_000_000n. Integer division floored every
  validator with less than 1 REGEN to zero and discarded fractional
  REGEN for larger stakes, both of which distort the Gini. uregen
  fits inside Number.MAX_SAFE_INTEGER safely, so no precision is
  lost by keeping the full value.

Store:
* countCommissionChangesSince now checks whether a baseline row
  exists *before* the window and counts the in-window rows
  accordingly. The old `cnt - 1` path dropped a real commission
  change whenever the baseline read fell outside the window.
* getLatestDecentralizationSnapshot uses `LIMIT 1` (no OFFSET). The
  caller runs in the `orient` phase before the current cycle's
  snapshot is written, so the newest row is the actual previous
  cycle's snapshot. `OFFSET 1` was skipping it and either comparing
  against the cycle before last or returning null on the second run.
* Store constructor accepts an optional dbPath (defaulting to the
  on-disk DB file) so unit tests can pass `:memory:` without
  clobbering the shared DB or hitting the "database is locked"
  failure mode.

New src/bech32.ts module:
* consensusPubkeyToConsAddress, operatorToDelegator, delegatorToOperator
  built on the `bech32` npm package. Centralized so WF-VM-01 and the
  forthcoming WF-VM-02 real tx-stream (PR regen-network#104) share one
  implementation.
* Added `bech32: ^2.0.0` dependency to agent-004/package.json.

Main loop:
* setInterval → recursive setTimeout so a slow cycle cannot overlap
  with the next tick.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
brawlaphant added a commit to brawlaphant/agentic-tokenomics that referenced this pull request Apr 11, 2026
…T, loop, commission

Addresses Gemini review feedback on PR regen-network#81:

WF-VM-01 (performance-tracking):
* Derive the validator's regenvalcons1… consensus address from its
  consensus pubkey (SHA256 of pubkey bytes → first 20 bytes → bech32
  under the consensus HRP) and use that as the signingByConsAddrLike
  lookup key. The old code was joining a base64 pubkey string against
  a bech32 address, so the lookup always missed and every validator
  reported 0 missed blocks — uptime scored 100% across the board.
* Hoist the trailing-window `sinceIso` out of the per-validator loop
  so we do not recompute the same Date arithmetic N times per cycle.
* Drop the dead `operatorToAccountBech32` stub and use the real
  `operatorToDelegator` helper from the new bech32 module.

WF-VM-03 (decentralization-monitor):
* Gini index now converts the full uregen value to Number without
  pre-dividing by 1_000_000n. Integer division floored every
  validator with less than 1 REGEN to zero and discarded fractional
  REGEN for larger stakes, both of which distort the Gini. uregen
  fits inside Number.MAX_SAFE_INTEGER safely, so no precision is
  lost by keeping the full value.

Store:
* countCommissionChangesSince now checks whether a baseline row
  exists *before* the window and counts the in-window rows
  accordingly. The old `cnt - 1` path dropped a real commission
  change whenever the baseline read fell outside the window.
* getLatestDecentralizationSnapshot uses `LIMIT 1` (no OFFSET). The
  caller runs in the `orient` phase before the current cycle's
  snapshot is written, so the newest row is the actual previous
  cycle's snapshot. `OFFSET 1` was skipping it and either comparing
  against the cycle before last or returning null on the second run.
* Store constructor accepts an optional dbPath (defaulting to the
  on-disk DB file) so unit tests can pass `:memory:` without
  clobbering the shared DB or hitting the "database is locked"
  failure mode.

New src/bech32.ts module:
* consensusPubkeyToConsAddress, operatorToDelegator, delegatorToOperator
  built on the `bech32` npm package. Centralized so WF-VM-01 and the
  forthcoming WF-VM-02 real tx-stream (PR regen-network#104) share one
  implementation.
* Added `bech32: ^2.0.0` dependency to agent-004/package.json.

Main loop:
* setInterval → recursive setTimeout so a slow cycle cannot overlap
  with the next tick.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
brawlaphant and others added 2 commits April 11, 2026 14:59
…T, loop, commission

Addresses Gemini review feedback on PR regen-network#81:

WF-VM-01 (performance-tracking):
* Derive the validator's regenvalcons1… consensus address from its
  consensus pubkey (SHA256 of pubkey bytes → first 20 bytes → bech32
  under the consensus HRP) and use that as the signingByConsAddrLike
  lookup key. The old code was joining a base64 pubkey string against
  a bech32 address, so the lookup always missed and every validator
  reported 0 missed blocks — uptime scored 100% across the board.
* Hoist the trailing-window `sinceIso` out of the per-validator loop
  so we do not recompute the same Date arithmetic N times per cycle.
* Drop the dead `operatorToAccountBech32` stub and use the real
  `operatorToDelegator` helper from the new bech32 module.

WF-VM-03 (decentralization-monitor):
* Gini index now converts the full uregen value to Number without
  pre-dividing by 1_000_000n. Integer division floored every
  validator with less than 1 REGEN to zero and discarded fractional
  REGEN for larger stakes, both of which distort the Gini. uregen
  fits inside Number.MAX_SAFE_INTEGER safely, so no precision is
  lost by keeping the full value.

Store:
* countCommissionChangesSince now checks whether a baseline row
  exists *before* the window and counts the in-window rows
  accordingly. The old `cnt - 1` path dropped a real commission
  change whenever the baseline read fell outside the window.
* getLatestDecentralizationSnapshot uses `LIMIT 1` (no OFFSET). The
  caller runs in the `orient` phase before the current cycle's
  snapshot is written, so the newest row is the actual previous
  cycle's snapshot. `OFFSET 1` was skipping it and either comparing
  against the cycle before last or returning null on the second run.
* Store constructor accepts an optional dbPath (defaulting to the
  on-disk DB file) so unit tests can pass `:memory:` without
  clobbering the shared DB or hitting the "database is locked"
  failure mode.

New src/bech32.ts module:
* consensusPubkeyToConsAddress, operatorToDelegator, delegatorToOperator
  built on the `bech32` npm package. Centralized so WF-VM-01 and the
  forthcoming WF-VM-02 real tx-stream (PR regen-network#104) share one
  implementation.
* Added `bech32: ^2.0.0` dependency to agent-004/package.json.

Main loop:
* setInterval → recursive setTimeout so a slow cycle cannot overlap
  with the next tick.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses Gemini review feedback on PR regen-network#104:

LedgerClient.parseDelegationEventsFromTx:
* Walk `tx.logs` message-by-message so each staking event is
  attributed to the delegator (`sender`) of its own message. The
  previous implementation flattened all events, collected every
  `message.sender` into a positional array, and mapped the Nth
  staking event to the Nth sender. That mis-attributed staking
  events whenever a tx mixed staking with non-staking messages
  (e.g. `[MsgDelegate, MsgSend, MsgUndelegate]` would attribute the
  undelegate to the MsgSend sender).
* Prefer `tx.logs[i].events[]` over a mixed `tx.logs + tx.events`
  merge. In modern Cosmos SDK versions `tx.events` is a flattened
  superset of everything in the logs, so walking both double-counts
  every staking event (delegation deltas ended up 2× reality). We
  only fall back to `tx.events` when `tx.logs` is empty (very old
  LCD builds).

LedgerClient.getRecentDelegationTxs:
* Dedupe transactions by hash across the three per-type-URL queries
  so a tx containing more than one of the three message types
  (e.g. atomic `MsgDelegate` + `MsgUndelegate`) is only parsed once.
  Previously every event in such a tx was aggregated multiple times,
  inflating the per-cycle delegation deltas.
* Replace the silent `catch {}` with `console.error` so network
  failures are visible.

Also cherry-picks the PR regen-network#81 Gemini-review fixes so this branch is
self-consistent (bech32 consensus address derivation, Gini precision,
OFFSET 1 → 0, commission baseline, recursive setTimeout main loop).

Tests: updated the "multiple staking events in a single batched tx"
case to use the realistic one-log-per-message shape, and added a new
"attributes staking events through interleaved non-staking messages"
case that directly pins the bug the previous positional-match code
was broken by. Full vitest suite: 56/56 passing. Typecheck clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
brawlaphant added a commit to brawlaphant/agentic-tokenomics that referenced this pull request Apr 11, 2026
…T, loop, commission

Addresses Gemini review feedback on PR regen-network#81:

WF-VM-01 (performance-tracking):
* Derive the validator's regenvalcons1… consensus address from its
  consensus pubkey (SHA256 of pubkey bytes → first 20 bytes → bech32
  under the consensus HRP) and use that as the signingByConsAddrLike
  lookup key. The old code was joining a base64 pubkey string against
  a bech32 address, so the lookup always missed and every validator
  reported 0 missed blocks — uptime scored 100% across the board.
* Hoist the trailing-window `sinceIso` out of the per-validator loop
  so we do not recompute the same Date arithmetic N times per cycle.
* Drop the dead `operatorToAccountBech32` stub and use the real
  `operatorToDelegator` helper from the new bech32 module.

WF-VM-03 (decentralization-monitor):
* Gini index now converts the full uregen value to Number without
  pre-dividing by 1_000_000n. Integer division floored every
  validator with less than 1 REGEN to zero and discarded fractional
  REGEN for larger stakes, both of which distort the Gini. uregen
  fits inside Number.MAX_SAFE_INTEGER safely, so no precision is
  lost by keeping the full value.

Store:
* countCommissionChangesSince now checks whether a baseline row
  exists *before* the window and counts the in-window rows
  accordingly. The old `cnt - 1` path dropped a real commission
  change whenever the baseline read fell outside the window.
* getLatestDecentralizationSnapshot uses `LIMIT 1` (no OFFSET). The
  caller runs in the `orient` phase before the current cycle's
  snapshot is written, so the newest row is the actual previous
  cycle's snapshot. `OFFSET 1` was skipping it and either comparing
  against the cycle before last or returning null on the second run.
* Store constructor accepts an optional dbPath (defaulting to the
  on-disk DB file) so unit tests can pass `:memory:` without
  clobbering the shared DB or hitting the "database is locked"
  failure mode.

New src/bech32.ts module:
* consensusPubkeyToConsAddress, operatorToDelegator, delegatorToOperator
  built on the `bech32` npm package. Centralized so WF-VM-01 and the
  forthcoming WF-VM-02 real tx-stream (PR regen-network#104) share one
  implementation.
* Added `bech32: ^2.0.0` dependency to agent-004/package.json.

Main loop:
* setInterval → recursive setTimeout so a slow cycle cannot overlap
  with the next tick.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
brawlaphant added a commit to brawlaphant/agentic-tokenomics that referenced this pull request Apr 11, 2026
Addresses Gemini review feedback on PR regen-network#104:

LedgerClient.parseDelegationEventsFromTx:
* Walk `tx.logs` message-by-message so each staking event is
  attributed to the delegator (`sender`) of its own message. The
  previous implementation flattened all events, collected every
  `message.sender` into a positional array, and mapped the Nth
  staking event to the Nth sender. That mis-attributed staking
  events whenever a tx mixed staking with non-staking messages
  (e.g. `[MsgDelegate, MsgSend, MsgUndelegate]` would attribute the
  undelegate to the MsgSend sender).
* Prefer `tx.logs[i].events[]` over a mixed `tx.logs + tx.events`
  merge. In modern Cosmos SDK versions `tx.events` is a flattened
  superset of everything in the logs, so walking both double-counts
  every staking event (delegation deltas ended up 2× reality). We
  only fall back to `tx.events` when `tx.logs` is empty (very old
  LCD builds).

LedgerClient.getRecentDelegationTxs:
* Dedupe transactions by hash across the three per-type-URL queries
  so a tx containing more than one of the three message types
  (e.g. atomic `MsgDelegate` + `MsgUndelegate`) is only parsed once.
  Previously every event in such a tx was aggregated multiple times,
  inflating the per-cycle delegation deltas.
* Replace the silent `catch {}` with `console.error` so network
  failures are visible.

Also cherry-picks the PR regen-network#81 Gemini-review fixes so this branch is
self-consistent (bech32 consensus address derivation, Gini precision,
OFFSET 1 → 0, commission baseline, recursive setTimeout main loop).

Tests: updated the "multiple staking events in a single batched tx"
case to use the realistic one-log-per-message shape, and added a new
"attributes staking events through interleaved non-staking messages"
case that directly pins the bug the previous positional-match code
was broken by. Full vitest suite: 56/56 passing. Typecheck clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
brawlaphant added a commit to brawlaphant/agentic-tokenomics that referenced this pull request Apr 11, 2026
Addresses Gemini review feedback on PR regen-network#105:

Governance scoring:
* Replace the O(validators × proposals) per-voter fan-out with an
  O(proposals) per-proposal batch fetch. The old code was firing up
  to ~1500 LCD requests per cycle (75 validators × 20 proposals) and
  would reliably trip rate limits on public endpoints. We now call
  `/cosmos/gov/v1beta1/proposals/{id}/votes` once per proposal,
  paginate through the result, and index the voters locally — cost
  is 20 requests instead of 1500.
* Add a paginated `ledger.getVotesForProposal` with a 25-page safety
  cap (500 votes × 25 = 12.5k voter headroom).

Bech32 consolidation:
* The inline `operatorToAccountBech32` helper was moved into
  `src/bech32.ts` in the PR regen-network#81 fix (this branch cherry-picks that
  fix) and renamed `operatorToDelegator`. Both the workflow and the
  existing unit tests now import from the shared module.
* `operatorToDelegator` preserves the same guarantees the inline
  version had (strict HRP suffix check, empty-prefix guard, chain-
  agnostic HRP handling) so the PR regen-network#105 test suite still passes
  against the centralized implementation.
* Also cherry-picks the PR regen-network#81 review fixes (bech32 consensus
  address derivation for signing info lookup, Gini precision fix,
  OFFSET 1 → 0, commission baseline logic, recursive setTimeout
  main loop) and the PR regen-network#104 review fixes (log-scoped delegator
  attribution, tx.events vs logs dedup, tx-hash dedup across
  staking type queries) so this branch is self-consistent when its
  ancestors rebase.

Full vitest suite: 63/63 passing. Typecheck clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@glandua glandua closed this Apr 25, 2026
@glandua glandua reopened this Apr 25, 2026
@glandua

glandua commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

See review comment on #100 — the agent-004 stack (#100 + #104 + #105) branches in parallel and needs consolidation or an explicit merge order before we can land any of them.

@brawlaphant

Copy link
Copy Markdown
Contributor Author

Consolidated into #105 per @glandua's audit comment.

#105 already includes the real delegation tx-stream commit 962e0ce (the same commit that's at the head of this branch), plus the bech32 op→delegator + M014 governance scoring on top. #100's lazy store singleton has also been cherry-picked onto #105 (c76b6de), so #105 is the single branch carrying all agent-004 work.

Closing this in favor of #105.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants