Skip to content

feat(agent-003): real MsgRetire tx-stream for WF-MM-03#103

Open
brawlaphant wants to merge 4 commits intoregen-network:mainfrom
brawlaphant:feat/agent-003-real-retire-tx-stream
Open

feat(agent-003): real MsgRetire tx-stream for WF-MM-03#103
brawlaphant wants to merge 4 commits intoregen-network:mainfrom
brawlaphant:feat/agent-003-real-retire-tx-stream

Conversation

@brawlaphant
Copy link
Copy Markdown
Contributor

Summary

Replaces the batch-supply-delta MVP proxy in WF-MM-03 with a real tx-search client that reads recent `MsgRetire` events from the Cosmos LCD. Closes the follow-up documented in #80's design-decision #3.

  • Lands in: `agent-003-market-monitor/`
  • Changes: new tx-search client methods + rewritten WF-MM-03 observe+orient phases + 16 new tests
  • Validate: `cd agent-003-market-monitor && npm test && npx tsc --noEmit`

The change

Before (MVP) After (real tx-stream)
Walk batches → supplies, derive retirement by `retired_amount` delta Query LCD `tx-search` for `/regen.ecocredit.v1.MsgRetire`, parse per-tx
`topRetiree` / `topRetireeQuantity` / `pctWithJurisdiction` all empty All fields populated from real `EventRetire` attributes
No tx hash, no retirement timestamp `txHash` and `retiredAt` carried through from the tx response
Synthetic "unique retirees" proxied by batch count Real unique retirees via Set of owner addresses

Ledger client additions

  • `getRecentRetirementTxs(limit)` — public, queries the LCD tx-search endpoint with events filter, reverse-ordered, limited. Returns `[]` on any error.
  • `parseRetirementsFromTx(tx)` — public pure function. Walks `logs[].events[]` AND top-level `events[]` for cross-SDK compatibility. Accepts both the Regen v1 and v1beta1 `EventRetire` type URLs. Skips malformed events (missing batch_denom, non-finite amount, non-positive amount).

Workflow refactor

  • `observe()` replaced with `ledger.getRecentRetirementTxs(200)` call.
  • `orient()` now calls a new pure function `aggregateRetirementsByClass` that groups retirements by class id, identifies the top retiree by cumulative quantity (not count — pinned by a unit test), counts unique retirees via a Set, and measures jurisdiction coverage.
  • `act()` unchanged — still persists, outputs, and alerts on demand-index jumps.

Test additions — 16 new tests (68 total, up from 52 in #99)

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

Case Pins
empty tx Parser returns [] cleanly
single EventRetire All 5 Retirement fields populated correctly
v1beta1 fallback Both the v1 and v1beta1 EventRetire type URLs accepted
non-retirement events Ignored
missing batch_denom Skipped (not thrown)
non-finite / zero / negative amounts All skipped
multiple retirements in one tx Batched retirements all extracted
events in tx.events[] Flat-event LCD shapes work alongside nested
tx hash + timestamp Carried through to Retirement record

`src/workflows/retirement-tracking.test.ts` (7 new tests)

Case Pins
empty retirements Empty aggregator result
single retirement One-entry summary with all fields
multiple by class Correct grouping by class id
top retiree by quantity not count 3 small + 1 big → big is top
jurisdiction percentage 2 of 4 → 50%
zero-quantity classes skipped Degenerate case
empty retiree string Handled without crash

What this unlocks

Downstream consumers now get:

  • Retiree identity in the narrative layer (replacing the `(none)` placeholder)
  • Jurisdiction coverage that can surface compliance-driven demand (per the SPEC: ">50% jurisdiction coverage typically implies compliance-driven demand")
  • Concrete tx hash + timestamp for every retirement, enabling downstream auditing and cross-referencing with the LCD
  • Real unique retiree counts for the demand-breadth component of the index

PR relationship

Based on #99 (AGENT-003 unit tests), which is based on #80 (AGENT-003 initial implementation). If both land first, this PR rebases cleanly.

Refs `phase-2/2.2-agentic-workflows.md` §WF-MM-03
Refs #80's design decision #3 (MVP proxy → real tx-stream follow-up)

Mirrors the AGENT-002 Governance Analyst structure to give AGENT-003
Market Monitor the same full standalone TypeScript implementation that
AGENT-002 has:

- `agent-003-market-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` — ecocredit marketplace types + per-workflow shapes
- `src/ledger.ts` — LCD client for ecocredit + marketplace endpoints
- `src/store.ts` — SQLite state (trade obs, anomaly dedupe, liquidity
  snapshots, retirement summaries, workflow executions)
- `src/ooda.ts` — generic OODA executor (same shape as agent-002)
- `src/monitor.ts` — Claude narrative layer, one function per workflow
- `src/output.ts` — console + optional Discord webhook dispatcher
- `src/workflows/price-anomaly-detection.ts` — WF-MM-01
- `src/workflows/liquidity-monitor.ts` — WF-MM-02
- `src/workflows/retirement-tracking.ts` — WF-MM-03

Design decisions documented in the agent README:
1. Deterministic numbers, narrative-only LLM calls. All severity
   classification, z-score computation, liquidity health scoring, and
   demand index derivation happen locally. Claude only writes the
   report from those numbers. Keeps the agent cheap, reproducible,
   and auditable.
2. Sell-order-as-trade proxy for the MVP until Ledger MCP exposes
   filled-trade events. The code path swaps cleanly once real trade
   events are available.
3. Batch-supply delta as the retirement source for the MVP, capped
   at 100 most recent batches per cycle to protect the LCD. A
   follow-up PR will plug in a real MsgRetire tx-stream client.
4. Alert dedupe by (trade_id, severity) — a trade that escalates from
   WARNING to CRITICAL still fires a new alert; a same-severity
   re-alert does not.

Thresholds (`config.market.*`) mirror the character definition at
`agents/packages/agents/src/characters/market-monitor.ts` so downstream
tooling has a single source of truth. Anomaly severity boundaries
(WARNING >= 2.0, CRITICAL >= 3.5 z-score) and the liquidity depth
floor live in `config.ts` and are referenced from the system prompt.

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

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

Refs phase-2/2.2-agentic-workflows.md §WF-MM-01, §WF-MM-02, §WF-MM-03
Refs agents/packages/agents/src/characters/market-monitor.ts (regen-network#64)
Follow-up to PR regen-network#80 — promised in the PR description as "Unit
tests for median, stddev, classifyAnomaly, scoreHealth,
computeDemandIndex, etc., planned as a separate test-only PR so
the original PR stays a single-concern 'add the agent' change."

Adds 52 unit tests across 3 test files covering every
deterministic helper function in the AGENT-003 workflows. The
helpers compute severity, liquidity health, and demand signals
— they're the parts most likely to silently drift in a future
refactor, so pinning their exact behavior prevents regressions
that a typecheck cannot catch.

## Changes

### Helper exports

Eight previously module-private helpers are now exported so the
test file can import them:

  price-anomaly-detection.ts:
    median, stddev, classifyAnomaly, classIdFromBatchDenom,
    isUsdStableDenom

  liquidity-monitor.ts:
    askUsd, scoreHealth

  retirement-tracking.ts:
    computeDemandIndex

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

### Test files

  src/workflows/price-anomaly-detection.test.ts   (30 tests)
    median — empty, one-element, odd length, even length,
      non-mutation, negatives, floats
    stddev — empty, single, two-element, known five-element,
      n-1 denominator (sample, not population)
    classifyAnomaly — INFO / WARNING / CRITICAL boundaries,
      max of two inputs, symmetry for negative z-scores
    classIdFromBatchDenom — canonical regen format, single-letter,
      no dash, empty, degenerate leading dash
    isUsdStableDenom — USDC/USDT/DAI case-insensitive, ibc/*
      wrappers, non-stable rejects, empty string

  src/workflows/liquidity-monitor.test.ts          (13 tests)
    askUsd — normal, zero quantity, negative quantity, non-finite
      ask, non-finite quantity, fractional
    scoreHealth — CRITICAL at sub-half depth, DEGRADED between
      half and full, HEALTHY at 3x floor with strong count,
      score-cap when depth saturates, count-cap at 20, zero case

  src/workflows/retirement-tracking.test.ts        (9 tests)
    computeDemandIndex — zero, volume cap (60), count cap (20 at
      10 retirements), breadth cap (20 at 5 retirees), total cap
      (100), moderate activity, log10 scaling, sub-1 floor,
      rounding

### Vitest setup

  vitest.config.ts (8 lines) — standard config, node env
  package.json — adds "test" and "test:watch" scripts + vitest
    ^2.1.0 devDep
  tsconfig.json — excludes *.test.ts from the production
    typecheck path (tests still typecheck via vitest itself)
  .gitignore — adds *.db-shm and *.db-wal (SQLite WAL side files
    from running tests locally; the base *.db pattern doesn't
    catch them)

## Validation

  $ cd agent-003-market-monitor && npm test
  Test Files  3 passed (3)
       Tests  52 passed (52)

  $ cd agent-003-market-monitor && npx tsc --noEmit
  (exit 0)

Both CI checks continue to pass after this PR.

## Scope

Does NOT touch the workflow OODA loops themselves, the Claude
narrative layer, the LCD client, the SQLite store, or any output
formatting. The tests cover the pure functions only — the
surrounding integration machinery is tested implicitly when
AGENT-003 runs a live cycle against the Regen LCD.

- Lands in: `agent-003-market-monitor/`
- Changes: 52 unit tests + vitest setup + 8 helper exports
- Validate: `cd agent-003-market-monitor && npm test`

## PR relationship

This PR is based on PR regen-network#80's branch (the AGENT-003 implementation)
because the helpers don't exist on upstream/main. If regen-network#80 merges
first, this PR rebases cleanly.
Replaces the batch-supply-delta MVP proxy in WF-MM-03 with a real
tx-search client that reads recent MsgRetire events from the
Cosmos LCD. This closes the follow-up documented in PR regen-network#80's
design-decision regen-network#3: "Batch supply delta as retirement source
(MVP). A follow-up PR will plug in a real tx-stream client for
MsgRetire once the LCD event endpoint is available."

## What changes in the workflow

The observe phase no longer walks batches → supplies. Instead:

  const retirements = await ledger.getRecentRetirementTxs(200);

The orient phase no longer synthesizes retirement records from
supply deltas. Instead it calls a new pure function
`aggregateRetirementsByClass` that groups a list of real
Retirement records by class id, computes the top retiree by
cumulative quantity (not count), counts unique retirees via a
Set, and measures the jurisdiction-metadata coverage percentage.

The act phase is unchanged — it still persists, outputs, and
alerts on demand-index jumps.

## What changes in the ledger client

Adds two new methods to `LedgerClient`:

  - `getRecentRetirementTxs(limit)` — queries the LCD tx-search
    endpoint with `events=message.action='/regen.ecocredit.v1.MsgRetire'`,
    reverse-ordered, limited. Parses each tx response into zero
    or more Retirement records. Returns [] on error (transient
    LCD failures degrade to "no recent retirements" rather than
    crashing the cycle).

  - `parseRetirementsFromTx(tx)` — public parser exposed so unit
    tests can feed synthetic tx responses. Walks `logs[].events[]`
    AND top-level `events[]` for cross-SDK compatibility, filters
    on the Regen v1 and v1beta1 EventRetire type URLs, and
    extracts the owner / batch_denom / amount / jurisdiction /
    reason attributes. Skips malformed events (missing
    batch_denom, non-finite amount, non-positive amount).

The parser is a pure function — no side effects, no LCD calls —
so it is fully unit-testable with synthetic inputs.

## New tests (16 total across 2 files)

**src/ledger.test.ts** (9 tests)
- empty tx
- single EventRetire with all attributes
- v1beta1 fallback event type
- non-retirement events ignored
- missing batch_denom ignored
- non-finite, zero, and negative amounts ignored
- multiple retirements in one tx (batched)
- events read from tx.events[] as well as logs[].events[]
- tx hash and timestamp carried through to Retirement

**src/workflows/retirement-tracking.test.ts** (7 new tests for
aggregateRetirementsByClass, on top of the existing 9 for
computeDemandIndex)
- empty retirements → empty map
- single retirement → one-entry summary
- multiple retirements grouped by class id
- top retiree identified by cumulative quantity, not count
- jurisdiction metadata percentage
- classes with zero total quantity skipped
- empty retiree string handled without crashing

Full test suite: 68 passed across 4 files (up from 52 in PR regen-network#99).

## What this unlocks

The old MVP proxy produced RetirementSummary records with empty
`topRetiree` / `topRetireeQuantity` / `pctWithJurisdiction`
fields — the supply-delta source couldn't carry retiree identity
or compliance metadata. The new implementation populates every
field on the Retirement type. Downstream:

- Demand index calculations now distinguish "5 unique retirees
  each retiring 200" from "1 whale retiring 1000".
- Jurisdiction-metadata coverage surfaces compliance-driven demand
  (per the SPEC note: ">50% jurisdiction coverage typically implies
  compliance-driven demand").
- Top-retiree identification feeds the narrative layer with
  concrete context instead of a placeholder.
- Each Retirement carries its real tx hash and timestamp, so
  downstream auditing can link back to the exact on-chain event.

## Scope

Does NOT touch WF-MM-01 (price anomaly detection) or WF-MM-02
(liquidity monitor). The price oracle integration noted elsewhere
in the README is still future work. The LCD-level error handling
returns empty arrays on failure — a future follow-up might
distinguish transient failures from genuine "no recent retirements".

- Lands in: `agent-003-market-monitor/`
- Changes: new tx-search client methods + rewritten WF-MM-03 + 16 new tests
- Validate: `cd agent-003-market-monitor && npm test && npx tsc --noEmit`

## PR relationship

Based on PR regen-network#99 (AGENT-003 unit tests) which is based on PR regen-network#80
(AGENT-003 initial implementation). If both land first, this PR
rebases cleanly. Sibling PR to a forthcoming AGENT-004 real
delegation tx-stream follow-up.

Refs phase-2/2.2-agentic-workflows.md §WF-MM-03
Refs PR regen-network#80's design decision regen-network#3 (MVP proxy → real tx-stream)
Copy link
Copy Markdown
Contributor

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

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-003, the Regen Market Monitor, a standalone Node.js agent that tracks ecocredit price anomalies, liquidity health, and retirement demand using OODA-based workflows and SQLite persistence. The review feedback highlights several opportunities for improving data accuracy and statistical consistency. Specifically, the ledger client should be updated to avoid duplicate event processing and to broaden transaction search filters. Additionally, the implementation should use BigInt for blockchain amounts to prevent precision loss, correct the Z-score logic to use means instead of medians for standard deviation consistency, and utilize median ask prices rather than hardcoded values for retirement summaries.

Comment thread agent-003-market-monitor/src/ledger.ts Outdated
Comment on lines +176 to +186
const events: Array<Record<string, unknown>> = [];

// Cosmos LCD responses can carry events at two levels: per-msg
// inside `logs[i].events[]`, and flattened at `tx.events[]`. We
// harvest both for maximum compatibility across SDK versions.
for (const log of logs) {
const logEvents = (log.events || []) as Array<Record<string, unknown>>;
events.push(...logEvents);
}
const flatEvents = (tx.events || []) as Array<Record<string, unknown>>;
events.push(...flatEvents);
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 current implementation duplicates events when both logs[].events and the top-level tx.events are present in the LCD response. In most Cosmos SDK versions, tx.events is a flattened superset of all events found in the logs. This will result in every retirement being counted twice, leading to incorrect volume and count metrics in downstream workflows. It is safer to prefer the flattened list if available and fallback to logs otherwise.

    // Cosmos LCD responses can carry events at two levels: per-msg
    // inside `logs[i].events[]`, and flattened at `tx.events[]`. We
    // prefer the flattened list if available to avoid duplication.
    const events = (Array.isArray(tx.events) && tx.events.length > 0)
      ? (tx.events as Array<Record<string, unknown>>)
      : logs.flatMap(log => (log.events || []) as Array<Record<string, unknown>>);

Comment thread agent-003-market-monitor/src/ledger.ts Outdated
async getRecentRetirementTxs(limit = 100): Promise<Retirement[]> {
try {
const params = new URLSearchParams();
params.set("events", "message.action='/regen.ecocredit.v1.MsgRetire'");
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 tx-search query filter is restricted to the v1 MsgRetire type. While the parser (lines 190-197) correctly handles both v1 and v1beta1 event types, transactions using the v1beta1 message type will never be returned by this search. If the intention is to support both, you may need to perform separate queries or use a broader filter if supported by the specific LCD endpoint.

Comment thread agent-003-market-monitor/src/ledger.ts Outdated
if (!batchDenom) continue;

const quantityRaw = attr("amount") ?? attr("quantity") ?? "0";
const quantity = Number(quantityRaw);
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

Using Number() to parse blockchain amounts can lead to precision loss for very large values (above Number.MAX_SAFE_INTEGER). While ecocredits typically use 6 decimals, it is safer to use BigInt for the raw integer units to ensure accuracy across all possible credit types, especially if credits with higher precision (e.g., 18 decimals) are supported in the future.

const classMedian = median(classSamples);
const classMean = classSamples.reduce((a, b) => a + b, 0) / classSamples.length;
const classStd = stddev(classSamples, classMean);
const zClass = classStd > 0 ? (trade.pricePerCredit - classMedian) / classStd : 0;
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 Z-score calculation is statistically inconsistent because it uses the classMedian in the numerator but the classStd (standard deviation) in the denominator. Standard deviation is calculated relative to the mean. To compute a standard Z-score, you should use classMean. If you prefer robustness to outliers, consider using a Robust Z-score formula using the median and Median Absolute Deviation (MAD).

Suggested change
const zClass = classStd > 0 ? (trade.pricePerCredit - classMedian) / classStd : 0;
const zClass = classStd > 0 ? (trade.pricePerCredit - classMean) / classStd : 0;

: classMean;
const batchStd = batchSamples.length > 1 ? stddev(batchSamples, batchMean) : classStd;
const zBatch = batchStd > 0 ? (trade.pricePerCredit - batchMedian) / batchStd : 0;

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

Similar to the class Z-score, the batch Z-score should use batchMean in the numerator to remain statistically consistent with the standard deviation used in the denominator.

Suggested change
const zBatch = batchStd > 0 ? (trade.pricePerCredit - batchMean) / batchStd : 0;

// Treat USD value as 1:1 with quantity for now. Price oracle
// integration is future work — documented as an open question
// in the workflow spec.
const totalValueUsd = totalQuantity;
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

Hardcoding the USD value as 1:1 with the credit quantity can lead to significantly inaccurate reports in the narrative layer, as credit prices often deviate from $1.00. Since this agent already monitors sell orders in WF-MM-02, consider using the most recent median ask price for the credit class as a more accurate proxy for value until a full price oracle is integrated.

brawlaphant added a commit to brawlaphant/agentic-tokenomics that referenced this pull request Apr 11, 2026
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)
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)
Addresses Gemini review feedback on PR regen-network#103:

ledger.getRecentRetirementTxs:
* Query both v1 and v1beta1 MsgRetire type URLs instead of only v1.
  Older forwarders still emit v1beta1 and the parser already handles
  both event shapes — the tx-search filter was the only thing
  restricting the stream. Dedupe by tx hash so a tx appearing under
  both queries is only processed once.
* Replace the empty catch with console.error so network issues are
  visible instead of silently degrading to "no recent retirements".

parseRetirementsFromTx:
* Prefer the flattened `tx.events[]` list over walking
  `tx.logs[i].events[]`. In modern Cosmos SDK versions the flat list
  is a superset, so walking both double-counted every event. Fall
  back to the per-log list only when the flat list is empty (very
  old LCD builds).
* Parse the raw integer amount via BigInt first so higher-precision
  credit types (18-decimal biodiversity tokens on the roadmap) do
  not lose precision at the boundary. The downstream Retirement
  record still uses `number` for ergonomics — a safe downcast at
  current 6-decimal precision — with a comment noting the boundary.

aggregateRetirementsByClass / WF-MM-03 orient:
* USD value for retirement volume now uses the most recent WF-MM-02
  median ask price per class (via a new store.getLatestMedianAsk
  helper and a `classPriceUsd` map threaded into the aggregator).
  Falls back to 1:1 USD when no snapshot exists yet. The
  aggregator's new optional `classPriceUsd` parameter keeps the
  unit tests' 1:1 assumption backwards-compatible.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

1 participant