feat(agent-003): real MsgRetire tx-stream for WF-MM-03#103
feat(agent-003): real MsgRetire tx-stream for WF-MM-03#103brawlaphant wants to merge 4 commits intoregen-network:mainfrom
Conversation
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)
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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>>);| async getRecentRetirementTxs(limit = 100): Promise<Retirement[]> { | ||
| try { | ||
| const params = new URLSearchParams(); | ||
| params.set("events", "message.action='/regen.ecocredit.v1.MsgRetire'"); |
There was a problem hiding this comment.
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.
| if (!batchDenom) continue; | ||
|
|
||
| const quantityRaw = attr("amount") ?? attr("quantity") ?? "0"; | ||
| const quantity = Number(quantityRaw); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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).
| 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; | ||
|
|
There was a problem hiding this comment.
| // 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; |
There was a problem hiding this comment.
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.
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)
…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>
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.
The change
Ledger client additions
Workflow refactor
Test additions — 16 new tests (68 total, up from 52 in #99)
`src/ledger.test.ts` (9 new tests)
`src/workflows/retirement-tracking.test.ts` (7 new tests)
What this unlocks
Downstream consumers now get:
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)