From 8fdae22015f2ef61f4443195c9311c335edea341 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:08:06 +0800 Subject: [PATCH 01/20] Add witness-to-validator migration design spec Phase A dual-support strategy mirroring the JS/PHP libs: send new validator names on the wire, accept both old and new from callers with DeprecationWarning, tolerate responses from older nodes. Central compat module (vizbase/validator_compat.py) holds all old->new mappings so Phase C cleanup is a one-file deletion. --- ...9-witness-to-validator-migration-design.md | 431 ++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-witness-to-validator-migration-design.md diff --git a/docs/superpowers/specs/2026-05-19-witness-to-validator-migration-design.md b/docs/superpowers/specs/2026-05-19-witness-to-validator-migration-design.md new file mode 100644 index 0000000..68aa837 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-witness-to-validator-migration-design.md @@ -0,0 +1,431 @@ +# Witness → Validator Migration (Python Lib) + +**Date:** 2026-05-19 +**Status:** Design approved; ready for implementation plan. +**Reference:** `~/Downloads/witness-to-validator-migration-reference.md` (JS/PHP migration reference) + +## Context + +The VIZ blockchain is renaming "witness" terminology to "validator" across the entire stack. JSON string names change everywhere (operations, API methods, API namespace, response fields, chain-properties fields, dynamic global properties, account fields, `get_config` keys). Integer operation type IDs and binary wire format are unchanged. + +The C++ node already accepts both old and new JSON field names in incoming transactions and responds with new names only. Old API method names remain as deprecated aliases for one release cycle. + +This spec brings the Python lib to **Phase A** of the migration (full dual-support), matching the JS/PHP libraries' approach: send new names, accept both old and new on input, fall back to old names when reading from older nodes. + +## Goals + +- Send new validator names on the wire (op names, API methods, chain-properties fields). +- Accept both old and new names from callers (builder kwargs, op-name filters, API method lookups) with a `DeprecationWarning` when old names are used. +- Tolerate responses from nodes that still emit old names (response-side reads). +- Keep all existing user code working unchanged, emitting clear deprecation warnings for what needs to migrate. +- Make Phase C cleanup (removing the compat layer) a localized, auditable change — single file deletion plus removal of its imports. + +## Non-goals + +- **New high-level wrapper methods** (`validator_update`, `approve_validator`, `validator_proxy`). Existing TODO comments in `viz/viz.py` get their op names updated; no new functionality added here. +- **graphenecommon migration.** `viz.validator.Validator` continues to inherit from `graphenecommon.witness.Witness`. We do not shadow upstream-owned attributes (`self.witness_class`, etc.). When graphenecommon migrates, a follow-up spec switches inheritance. +- **Phase C cleanup** (removing the compat module, deleted class aliases, `witness_api` fallback). Separate future spec; runs only after node operators have widely upgraded. +- **CLI wallet commands.** Not applicable to a library. +- **Account / block-header / dynamic-global-property / `get_config` reads.** Grep confirms zero current sites in the lib read these response fields by hardcoded name (only `viz/blockchain.py` docstring examples and the stream `filter_by` parameter need attention). If post-merge code adds such reads, use the `pick()` helper introduced here. + +## Architecture + +A new module **`vizbase/validator_compat.py`** is the single source of truth for the witness→validator translation. Every other file that needs to know about the rename imports from this module. + +Phase C cleanup is `git rm vizbase/validator_compat.py` plus deleting its imports — the diff makes the entire rename surface auditable in one place. + +### `vizbase/validator_compat.py` contents + +```python +import warnings + +# Wire-format op name aliases (old -> new) +OP_NAME_ALIASES = { + "witness_update": "validator_update", + "account_witness_vote": "account_validator_vote", + "account_witness_proxy": "account_validator_proxy", + "shutdown_witness": "shutdown_validator", + "witness_reward": "validator_reward", +} + +# JSON-RPC API method aliases (old -> new) +API_METHOD_ALIASES = { + "get_active_witnesses": "get_active_validators", + "get_witness_schedule": "get_validator_schedule", + "get_witnesses": "get_validators", + "get_witness_by_account": "get_validator_by_account", + "get_witnesses_by_vote": "get_validators_by_vote", + "get_witnesses_by_counted_vote": "get_validators_by_counted_vote", + "get_witness_count": "get_validator_count", + "lookup_witness_accounts": "lookup_validator_accounts", + "debug_get_witness_schedule": "debug_get_validator_schedule", +} + +# Chain-properties field aliases (old -> new). Applies to chain_properties_hf4 / hf6 / hf9. +CHAIN_PROPS_FIELD_ALIASES = { + "inflation_witness_percent": "inflation_validator_percent", + "witness_miss_penalty_percent": "validator_miss_penalty_percent", + "witness_miss_penalty_duration": "validator_miss_penalty_duration", + "witness_declaration_fee": "validator_declaration_fee", +} + +# Per-operation kwarg field aliases (old -> new), keyed by canonical new op name. +OP_FIELD_ALIASES = { + "account_validator_vote": {"witness": "validator"}, + "validator_reward": {"witness": "validator"}, # virtual op; reserved for future +} + + +def translate_kwargs(kwargs: dict, alias_map: dict, *, context: str) -> dict: + """ + Return a copy of `kwargs` with old keys renamed to new keys per `alias_map`. + + Emits one DeprecationWarning per old key found, citing `context`. + If both old and new are present, the new value wins and the old key + triggers a warning. + """ + out = dict(kwargs) + for old, new in alias_map.items(): + if old in out: + warnings.warn( + f"{context}: '{old}' is deprecated; use '{new}' instead", + DeprecationWarning, stacklevel=3, + ) + if new not in out: + out[new] = out.pop(old) + else: + out.pop(old) + return out + + +def pick(obj, *keys, default=None): + """ + Return obj[k] for the first k in `keys` that exists; else `default`. + + Used at response-read sites to tolerate both old (`witness_*`) and new + (`validator_*`) field names. Call with new key first, old key second: + + pick(schedule, "current_shuffled_validators", "current_shuffled_witnesses") + """ + for k in keys: + if isinstance(obj, dict) and k in obj: + return obj[k] + if hasattr(obj, k): + return getattr(obj, k) + return default +``` + +**Design notes:** + +- Type IDs are the contract. All wire serialization uses the integer ID. The alias dicts only govern JSON string names. Binary format is untouched. +- No fifth global dict for response reads. `pick()` handles dual-name access where needed (small number of sites today). +- `translate_kwargs` is the only function with side effects (DeprecationWarning). It centralizes the warning text so every site reads the same way. + +## Component changes + +### `vizbase/operationids.py` + +Rename five entries in the `OPS` list (positional order preserved): + +```python +OPS = [ + ..., + "validator_update", # 6, was "witness_update" + "account_validator_vote", # 7, was "account_witness_vote" + "account_validator_proxy", # 8, was "account_witness_proxy" + ..., + "shutdown_validator", # 30, was "shutdown_witness" + ..., + "validator_reward", # 42, was "witness_reward" + ..., +] +``` + +Same renames in `VIRTUAL_OPS` (`shutdown_validator`, `validator_reward`). + +After building `operations = {o: OPS.index(o) for o in OPS}`, extend the dict with old-name aliases: + +```python +from .validator_compat import OP_NAME_ALIASES +for old, new in OP_NAME_ALIASES.items(): + operations[old] = operations[new] +``` + +This makes `operations["witness_update"] == operations["validator_update"] == 6`. Any caller doing name → ID lookup with an old name still works. + +### `vizbase/operations.py` + +**Rename classes (canonical = new name):** + +- `Witness_update` → `Validator_update` +- `Account_witness_vote` → `Account_validator_vote` + +**Wire-format field rename in `Account_validator_vote`:** OrderedDict key `"witness"` → `"validator"`. + +**Kwarg dual-support in `Account_validator_vote.__init__`:** + +```python +from .validator_compat import translate_kwargs, OP_FIELD_ALIASES + +class Account_validator_vote(GrapheneObject): + def __init__(self, *args, **kwargs): + if isArgsThisClass(self, args): + self.data = args[0].data + else: + if len(args) == 1 and len(kwargs) == 0: + kwargs = args[0] + kwargs = translate_kwargs( + kwargs, + OP_FIELD_ALIASES["account_validator_vote"], + context="Account_validator_vote", + ) + super().__init__( + OrderedDict([ + ("account", String(kwargs["account"])), + ("validator", String(kwargs["validator"])), + ("approve", Bool(bool(kwargs["approve"]))), + ]) + ) +``` + +**Deprecated class aliases** (at module bottom, after the canonical classes are defined): + +```python +class _DeprecatedAlias: + """Warn once-per-process on first instantiation of a deprecated class name.""" + _warned: set[str] = set() + + @classmethod + def make(cls, old_name: str, new_class: type) -> type: + class _Alias(new_class): + def __init__(self, *args, **kwargs): + if old_name not in _DeprecatedAlias._warned: + warnings.warn( + f"{old_name} is deprecated; use {new_class.__name__} instead", + DeprecationWarning, stacklevel=2, + ) + _DeprecatedAlias._warned.add(old_name) + super().__init__(*args, **kwargs) + _Alias.__name__ = old_name + _Alias.__qualname__ = old_name + return _Alias + +Witness_update = _DeprecatedAlias.make("Witness_update", Validator_update) +Account_witness_vote = _DeprecatedAlias.make("Account_witness_vote", Account_validator_vote) +``` + +**Note:** the `Operation` dispatcher / class registry in `vizbase/operations.py` (the code that maps op-name strings → operation classes) must register both old and new names → the canonical new class. Verify during implementation; if `Operation` uses a `klass_name = op_name.title()` style lookup, the dispatch already works once `Validator_update` exists. + +**No new classes added** for types 8, 30, 42. None exist in the lib today; strict rename scope. + +### `vizbase/objects.py` (chain_properties) + +OrderedDict keys change for the four fields: + +| Old key | New key | +|---|---| +| `inflation_witness_percent` | `inflation_validator_percent` | +| `witness_miss_penalty_percent` | `validator_miss_penalty_percent` | +| `witness_miss_penalty_duration` | `validator_miss_penalty_duration` | +| `witness_declaration_fee` | `validator_declaration_fee` | + +Field order is preserved (binary serialization depends on it). + +At the top of the relevant `__init__`: + +```python +from .validator_compat import translate_kwargs, CHAIN_PROPS_FIELD_ALIASES +kwargs = translate_kwargs( + kwargs, CHAIN_PROPS_FIELD_ALIASES, context="chain_properties_update", +) +``` + +### `vizapi/consts.py` + +Remove these entries: + +``` +get_miner_queue -> witness_api +get_witnesses_by_counted_vote -> witness_api +get_active_witnesses -> witness_api +get_witness_schedule -> witness_api +get_witnesses -> witness_api +get_witness_by_account -> witness_api +get_witnesses_by_vote -> witness_api +get_witness_count -> witness_api +lookup_witness_accounts -> witness_api +debug_get_witness_schedule -> debug_node +``` + +Add these: + +``` +get_miner_queue -> validator_api +get_validators_by_counted_vote -> validator_api +get_active_validators -> validator_api +get_validator_schedule -> validator_api +get_validators -> validator_api +get_validator_by_account -> validator_api +get_validators_by_vote -> validator_api +get_validator_count -> validator_api +lookup_validator_accounts -> validator_api +debug_get_validator_schedule -> debug_node +``` + +### `vizapi/noderpc.py` (dispatcher fallback) + +The dispatcher lives in `Rpc.__getattr__` (lines 111–140). It resolves `name → api` via `API.get(name)` and submits a JSON-RPC `call` with `[api, name, args]` params. + +Add a runtime fallback: if a method call fails with a "method not found" / "no such method" RPC error and `name` is a value in `API_METHOD_ALIASES`, retry once against the old name + `witness_api` namespace. + +```python +class Rpc(GrapheneRpc): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._uses_legacy_witness_api: bool | None = None # None = unknown + + def __getattr__(self, name): + def method(*args, **kwargs): + # Build the primary (new-name) query as before. + # On NoSuchMethod-style error, if name is in API_METHOD_ALIASES.values(): + # - look up the old name (reverse-map) + # - retry with api="witness_api", method=old_name + # - on success, set self._uses_legacy_witness_api = True + # and emit one DeprecationWarning per Rpc instance + # On first success of any *new*-name call, set _uses_legacy_witness_api = False + # If self._uses_legacy_witness_api is True, skip the new-name attempt entirely + # (already-known legacy node). + ... + return method +``` + +**Cache scope:** per-`Rpc` instance. Once flipped to legacy, stay on legacy for the lifetime of the connection. No global state. + +**Reverse map:** built once at import time from `API_METHOD_ALIASES`: + +```python +_REVERSE_API_METHOD = {new: old for old, new in API_METHOD_ALIASES.items()} +``` + +**Error detection:** rely on the existing `vizapi.exceptions` types. The exact predicate (which exception means "method not found") needs to be verified against `vizapi/exceptions.py` and `post_process_exception` during implementation. If the current code raises `UnhandledRPCError` for everything, add a `NoSuchMethod` exception class and detect it from the message in `post_process_exception`. + +**One-time warning:** emit `DeprecationWarning("Node responded on witness_api; upgrade recommended")` exactly once per `Rpc` instance, on the first legacy fallback. + +### `viz/witness.py` → `viz/validator.py` + +Rename the file. Classes become: + +```python +# viz/validator.py +from graphenecommon.witness import Witness as GrapheneWitness +from graphenecommon.witness import Witnesses as GrapheneWitnesses + +from .account import Account +from .instance import BlockchainInstance + + +@BlockchainInstance.inject +class Validator(GrapheneWitness): + # TODO: switch parent to graphenecommon.validator.Validator once graphenecommon migrates. + def define_classes(self): + self.account_class = Account + self.type_ids = [6, 2] + + +@BlockchainInstance.inject +class Validators(GrapheneWitnesses): + def define_classes(self): + self.account_class = Account + self.witness_class = Validator # graphenecommon contract; must stay + self.validator_class = Validator # forward-compat; harmless if upstream ignores +``` + +**Shim file** at `viz/witness.py`: + +```python +import warnings + +from .validator import Validator, Validators + +warnings.warn( + "viz.witness is deprecated; import from viz.validator instead", + DeprecationWarning, stacklevel=2, +) + +Witness = Validator +Witnesses = Validators +``` + +**`viz/__init__.py`** — currently has zero witness references. If a future change adds re-exports, expose both `Validator`/`Validators` (canonical) and `Witness`/`Witnesses` (deprecated alias). No action required today. + +### `viz/blockchain.py` + +Two changes: + +1. **Docstring/example strings on lines ~141, 145, 160** reference `witness_reward` and `'witness': 'committee'`. Update example strings to `validator_reward` / `'validator': 'committee'`. No logic change. +2. **Stream `filter_by` parameter** — canonicalize the user-supplied filter through `OP_NAME_ALIASES` at the top of the stream loop: + + ```python + from vizbase.validator_compat import OP_NAME_ALIASES + filter_by_canonical = OP_NAME_ALIASES.get(filter_by, filter_by) + ``` + + Then match against both the canonical name and the original. This makes `filter_by="witness_reward"` match streams that emit either `witness_reward` (old node) or `validator_reward` (new node). + +## Tests + +### Update existing tests to new names + +- **`tests/test_serialization.py`** — chain_properties dict uses new field names (`inflation_validator_percent`, `validator_miss_penalty_percent`, `validator_miss_penalty_duration`, `validator_declaration_fee`). Expected binary hex output is byte-identical (field order preserved, names not on wire); the hex assertion does not change. +- **`tests/test_blockchain.py`** — `filter_by="validator_reward"`, assertions match `validator_reward`. Note: this test currently runs against a live testnet which is pinned to a pre-migration image; see "Integration-test caveat" below. + +### New file: `tests/test_validator_compat.py` + +Unit-level coverage (no live node required): + +1. **Operation kwarg dual-support.** `Account_validator_vote(witness="alice", account="bob", approve=True)` — succeeds, emits exactly one `DeprecationWarning` mentioning `witness`, serialized JSON has `"validator": "alice"`. Same call with `validator="alice"` emits zero warnings. +2. **Chain-properties kwarg dual-support.** Build chain_properties with old field names — warns per old field, serializes to byte-identical output as the new-name build. +3. **Op-name alias resolution.** `operations["witness_update"] == operations["validator_update"] == 6`. Repeat for all five entries in `OP_NAME_ALIASES`. +4. **Stream filter canonicalization.** `filter_by="witness_reward"` matches a synthetic stream emitting `validator_reward` ops, and vice versa. +5. **Module shim.** `from viz.witness import Witness` succeeds, emits the module-level DeprecationWarning, and `Witness is Validator` evaluates True. +6. **API dispatcher fallback.** Mock a node that errors `NoSuchMethod` on `get_active_validators` and returns `["alice"]` on `get_active_witnesses`. Assert: + - First call: tries new name, falls back, returns `["alice"]`, emits one `DeprecationWarning("Node responded on witness_api ...")`. + - Second call on same `Rpc` instance: skips the new-name attempt (cache flipped), single legacy call, no further warnings. + - A fresh `Rpc` instance starts at `_uses_legacy_witness_api = None`. +7. **Deprecated class alias.** Instantiating `Witness_update(...)` emits one DeprecationWarning across the process; second instantiation emits none. `isinstance(Witness_update(...), Validator_update)` is True. + +### Coverage drift check + +A parametrized test loops over `OP_NAME_ALIASES.items()`, `API_METHOD_ALIASES.items()`, and `CHAIN_PROPS_FIELD_ALIASES.items()`, asserting each entry has at least one exercising test. Implemented via a simple registry-set assertion: every alias mentioned by a passing test is added to a set during test runs; the parametrized check confirms the set equals the alias dict keys. Catches drift when someone adds an alias and forgets the test. + +### Integration-test caveat + +Phase-A wire-format integration tests need a node that accepts both old and new names. The currently-pinned `vizblockchain/vizd:pr-85-merge` image predates the rename and likely accepts only old names. Integration tests that submit operations using new names must be marked `pytest.mark.skipif(not node_supports_validators(), reason=...)` until the testnet image is rebuilt. The unit tests in `tests/test_validator_compat.py` above don't depend on a live node. + +## Out of scope (explicit non-goals — reiterated) + +- New high-level wrapper methods (`validator_update`, `approve_validator`/`disapprove_validator`, `validator_proxy`). Existing `TODO` comments in `viz/viz.py` get their op names updated (witness → validator) but no implementation. +- graphenecommon parent-class migration. +- Phase C cleanup (deleting `vizbase/validator_compat.py` and its imports). +- CLI wallet command renames. +- `get_config` key renames (`CHAIN_MAX_WITNESSES` → `CHAIN_MAX_VALIDATORS` etc.) — grep confirms the lib reads zero of these keys today. +- Account-object field renames (`witnesses_voted_for` etc.) — grep confirms the lib reads zero of these today. +- Block-header field renames (`witness_signature` etc.) — grep confirms the lib does not access these directly. +- Dynamic-global-property field renames (`current_witness`) — grep confirms zero direct reads. + +If post-merge code adds reads of any of the above, use the `pick()` helper from `vizbase.validator_compat`. + +## Migration phases (post-merge) + +This spec implements Phase A. Future phases: + +- **Phase B** (after node-operator upgrades stabilize): default to new names for sending; keep old-name acceptance for reading historical data. No code change required from Phase A — already there. +- **Phase C** (cleanup, separate spec): delete `vizbase/validator_compat.py`; remove deprecated class aliases (`Witness_update`, `Account_witness_vote`); delete the `viz/witness.py` shim; remove the `witness_api` fallback path in `vizapi/noderpc.py`. One-file deletion plus a small import sweep. + +## Risks & open verifications + +These need confirmation during implementation, not before: + +- **Op dispatch registry.** Verify how `Operation` (in `vizbase/operations.py`) resolves an op-name string to a class. The current class naming convention (`Witness_update`, capital W with lowercase after underscore) suggests a custom capitalization, not `str.title()`. Whatever the resolver uses, ensure it maps the new names in `OPS` to the renamed canonical classes (`Validator_update`, `Account_validator_vote`). If the resolver consults a flat `globals()`-style lookup, the deprecated `_DeprecatedAlias`-wrapped classes also need to be discoverable so any code path that resolves an old op name from historical data returns a working class. +- **NoSuchMethod exception.** Verify the exception type raised by the current dispatcher for an unknown method. If `UnhandledRPCError` is the only signal, add a dedicated `NoSuchMethod` exception and detect it from the message in `NodeRPC.post_process_exception`. +- **graphenecommon `Witnesses.witness_class` contract.** Confirm graphenecommon uses `self.witness_class` (not `self.validator_class`) when iterating; the `Validators` class above sets both for safety. From fe97ac1a204893d23b68ed1568b2c94571f17520 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:24:40 +0800 Subject: [PATCH 02/20] docs: add witness-to-validator migration implementation plan --- ...26-05-19-witness-to-validator-migration.md | 1638 +++++++++++++++++ 1 file changed, 1638 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-witness-to-validator-migration.md diff --git a/docs/superpowers/plans/2026-05-19-witness-to-validator-migration.md b/docs/superpowers/plans/2026-05-19-witness-to-validator-migration.md new file mode 100644 index 0000000..b29ad56 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-witness-to-validator-migration.md @@ -0,0 +1,1638 @@ +# Witness → Validator Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring the Python lib to Phase A of the witness → validator terminology migration: send new names on the wire, accept both old and new names from callers (with `DeprecationWarning`), tolerate old nodes via a runtime fallback. + +**Architecture:** Single compat module `vizbase/validator_compat.py` is the source of truth for old↔new translation (op names, API methods, chain-properties field names, per-op kwarg renames). Operation classes and chain-properties builder run incoming `kwargs` through `translate_kwargs()` and warn on old names. Wire JSON uses new names exclusively. API dispatcher in `vizapi/noderpc.py` canonicalizes inbound calls (old name → new name) and falls back to `witness_api` namespace on `NoSuchMethod` errors, caching the result per `Rpc` instance. + +**Tech Stack:** Python 3.10+, pytest, poetry, graphenebase / graphenecommon / grapheneapi (upstream). + +**Spec:** `docs/superpowers/specs/2026-05-19-witness-to-validator-migration-design.md` + +**Implementation note found during planning:** `graphenecommon.witness.Witness.__init__` calls `self.blockchain.rpc.get_witness_by_account(...)` (line 27/32 in upstream). Since we remove `get_witness_by_account` from `vizapi/consts.py:API`, the dispatcher must canonicalize inbound method names too (translate old → new at the top of `Rpc.__getattr__`), not only fall back on failure. Task 9 covers both. This is consistent with the spec's "Phase A dual-support" intent. + +**Implementation note on op-class resolution:** `graphenebase.objects.Operation.klass_name` is `name[0].upper() + name[1:]` (verified during planning). So `"validator_update"` → `"Validator_update"` class. The existing class naming convention is preserved. Old op names resolve to deprecated alias classes (e.g. `"witness_update"` → `"Witness_update"`) which inherit from the new canonical class. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `vizbase/validator_compat.py` | **Create** | Old↔new alias dicts + `translate_kwargs` + `pick` helpers | +| `vizbase/operationids.py` | Modify | Rename op strings in `OPS` / `VIRTUAL_OPS`; extend `operations` dict with old-name aliases | +| `vizbase/operations.py` | Modify | Rename `Witness_update` / `Account_witness_vote` classes; kwarg translation; deprecated aliases | +| `vizbase/objects.py` | Modify | Rename 4 chain_properties field names; kwarg translation | +| `vizapi/consts.py` | Modify | Replace 9 `witness_api` entries with `validator_api` entries | +| `vizapi/exceptions.py` | Modify | Add `NoSuchMethod` exception class | +| `vizapi/noderpc.py` | Modify | Detect `NoSuchMethod` in `post_process_exception`; inbound translation + outbound fallback in `Rpc.__getattr__` | +| `viz/witness.py` | **Rename → `viz/validator.py`** | Classes renamed to `Validator` / `Validators` | +| `viz/witness.py` (new) | **Create** | Deprecation shim re-exporting from `viz.validator` | +| `viz/blockchain.py` | Modify | Update docstring examples; canonicalize stream `filter_by` | +| `viz/viz.py` | Modify | Update TODO comments (witness → validator) | +| `tests/test_serialization.py` | Modify | Use new chain_properties field names | +| `tests/test_blockchain.py` | Modify | Use `validator_reward` instead of `witness_reward` | +| `tests/test_validator_compat.py` | **Create** | Unit tests for compat module + alias resolution + dispatcher fallback + module shim | + +--- + +## Task 1: Create `vizbase/validator_compat.py` + +**Files:** +- Create: `vizbase/validator_compat.py` +- Create: `tests/test_validator_compat.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_validator_compat.py` with this content: + +```python +"""Unit tests for the witness -> validator compatibility layer.""" +import warnings + +import pytest + +from vizbase.validator_compat import ( + API_METHOD_ALIASES, + CHAIN_PROPS_FIELD_ALIASES, + OP_FIELD_ALIASES, + OP_NAME_ALIASES, + pick, + translate_kwargs, +) + + +def test_op_name_aliases_complete(): + assert OP_NAME_ALIASES == { + "witness_update": "validator_update", + "account_witness_vote": "account_validator_vote", + "account_witness_proxy": "account_validator_proxy", + "shutdown_witness": "shutdown_validator", + "witness_reward": "validator_reward", + } + + +def test_api_method_aliases_complete(): + assert API_METHOD_ALIASES == { + "get_active_witnesses": "get_active_validators", + "get_witness_schedule": "get_validator_schedule", + "get_witnesses": "get_validators", + "get_witness_by_account": "get_validator_by_account", + "get_witnesses_by_vote": "get_validators_by_vote", + "get_witnesses_by_counted_vote": "get_validators_by_counted_vote", + "get_witness_count": "get_validator_count", + "lookup_witness_accounts": "lookup_validator_accounts", + "debug_get_witness_schedule": "debug_get_validator_schedule", + } + + +def test_chain_props_field_aliases_complete(): + assert CHAIN_PROPS_FIELD_ALIASES == { + "inflation_witness_percent": "inflation_validator_percent", + "witness_miss_penalty_percent": "validator_miss_penalty_percent", + "witness_miss_penalty_duration": "validator_miss_penalty_duration", + "witness_declaration_fee": "validator_declaration_fee", + } + + +def test_op_field_aliases_account_validator_vote(): + assert OP_FIELD_ALIASES["account_validator_vote"] == {"witness": "validator"} + + +def test_translate_kwargs_renames_and_warns(): + with pytest.warns(DeprecationWarning, match=r"'witness' is deprecated; use 'validator'"): + out = translate_kwargs( + {"witness": "alice", "approve": True}, + {"witness": "validator"}, + context="Account_validator_vote", + ) + assert out == {"validator": "alice", "approve": True} + + +def test_translate_kwargs_no_warning_for_new_names(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + out = translate_kwargs( + {"validator": "alice", "approve": True}, + {"witness": "validator"}, + context="Account_validator_vote", + ) + assert out == {"validator": "alice", "approve": True} + + +def test_translate_kwargs_new_wins_when_both_present(): + with pytest.warns(DeprecationWarning): + out = translate_kwargs( + {"witness": "alice", "validator": "bob"}, + {"witness": "validator"}, + context="Account_validator_vote", + ) + assert out == {"validator": "bob"} + + +def test_translate_kwargs_returns_copy(): + inp = {"witness": "alice"} + with pytest.warns(DeprecationWarning): + out = translate_kwargs(inp, {"witness": "validator"}, context="ctx") + assert "witness" in inp + assert out == {"validator": "alice"} + + +def test_pick_returns_first_present_dict_key(): + d = {"current_shuffled_witnesses": ["a"]} + assert pick(d, "current_shuffled_validators", "current_shuffled_witnesses") == ["a"] + + +def test_pick_prefers_first_listed(): + d = {"current_shuffled_validators": ["new"], "current_shuffled_witnesses": ["old"]} + assert pick(d, "current_shuffled_validators", "current_shuffled_witnesses") == ["new"] + + +def test_pick_default_when_none_present(): + assert pick({}, "a", "b", default=[]) == [] + + +def test_pick_works_on_objects_with_attributes(): + class O: + current_validator = "alice" + assert pick(O(), "current_validator", "current_witness") == "alice" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_validator_compat.py -v` +Expected: ImportError — `No module named 'vizbase.validator_compat'` + +- [ ] **Step 3: Create the module** + +Create `vizbase/validator_compat.py`: + +```python +""" +Witness -> Validator migration compatibility layer. + +Single source of truth for old -> new name translation. When the migration +is complete (Phase C), delete this file and remove all imports from it. +""" +import warnings + +# Wire-format op name aliases (old -> new). +OP_NAME_ALIASES = { + "witness_update": "validator_update", + "account_witness_vote": "account_validator_vote", + "account_witness_proxy": "account_validator_proxy", + "shutdown_witness": "shutdown_validator", + "witness_reward": "validator_reward", +} + +# JSON-RPC API method aliases (old -> new). +API_METHOD_ALIASES = { + "get_active_witnesses": "get_active_validators", + "get_witness_schedule": "get_validator_schedule", + "get_witnesses": "get_validators", + "get_witness_by_account": "get_validator_by_account", + "get_witnesses_by_vote": "get_validators_by_vote", + "get_witnesses_by_counted_vote": "get_validators_by_counted_vote", + "get_witness_count": "get_validator_count", + "lookup_witness_accounts": "lookup_validator_accounts", + "debug_get_witness_schedule": "debug_get_validator_schedule", +} + +# Chain-properties field aliases (old -> new). Applies to chain_properties_hf4/hf6/hf9. +CHAIN_PROPS_FIELD_ALIASES = { + "inflation_witness_percent": "inflation_validator_percent", + "witness_miss_penalty_percent": "validator_miss_penalty_percent", + "witness_miss_penalty_duration": "validator_miss_penalty_duration", + "witness_declaration_fee": "validator_declaration_fee", +} + +# Per-op kwarg field aliases (old -> new), keyed by canonical new op name. +OP_FIELD_ALIASES = { + "account_validator_vote": {"witness": "validator"}, + "validator_reward": {"witness": "validator"}, # virtual op; reserved for future use +} + + +def translate_kwargs(kwargs: dict, alias_map: dict, *, context: str) -> dict: + """ + Return a copy of `kwargs` with old keys renamed to new keys per `alias_map`. + + Emits one DeprecationWarning per old key found, citing `context`. + If both old and new keys are present, the new value wins and the old + key triggers a warning. + """ + out = dict(kwargs) + for old, new in alias_map.items(): + if old in out: + warnings.warn( + f"{context}: '{old}' is deprecated; use '{new}' instead", + DeprecationWarning, stacklevel=3, + ) + if new not in out: + out[new] = out.pop(old) + else: + out.pop(old) + return out + + +def pick(obj, *keys, default=None): + """ + Return obj[k] for the first k in `keys` that exists; else `default`. + + Used at response-read sites to tolerate both old (witness_*) and new + (validator_*) field names. Call with new key first, old key second. + """ + for k in keys: + if isinstance(obj, dict) and k in obj: + return obj[k] + if not isinstance(obj, dict) and hasattr(obj, k): + return getattr(obj, k) + return default +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_validator_compat.py -v` +Expected: 11 passed. + +- [ ] **Step 5: Commit** + +```bash +git add vizbase/validator_compat.py tests/test_validator_compat.py +git commit -m "Add validator_compat module with alias dicts and translate_kwargs/pick helpers" +``` + +--- + +## Task 2: Update `vizbase/operationids.py` + +**Files:** +- Modify: `vizbase/operationids.py` +- Test: `tests/test_validator_compat.py` (add tests) + +- [ ] **Step 1: Add failing tests for op-id resolution** + +Append to `tests/test_validator_compat.py`: + +```python +def test_operations_dict_has_new_names(): + from vizbase.operationids import operations + assert operations["validator_update"] == 6 + assert operations["account_validator_vote"] == 7 + assert operations["account_validator_proxy"] == 8 + assert operations["shutdown_validator"] == 30 + assert operations["validator_reward"] == 42 + + +def test_operations_dict_has_old_name_aliases(): + from vizbase.operationids import operations + assert operations["witness_update"] == operations["validator_update"] == 6 + assert operations["account_witness_vote"] == operations["account_validator_vote"] == 7 + assert operations["account_witness_proxy"] == operations["account_validator_proxy"] == 8 + assert operations["shutdown_witness"] == operations["shutdown_validator"] == 30 + assert operations["witness_reward"] == operations["validator_reward"] == 42 + + +def test_ops_list_order_preserved(): + """Operation type IDs are positional; renaming must not shift indices.""" + from vizbase.operationids import OPS + assert OPS.index("transfer") == 2 + assert OPS.index("account_update") == 5 + assert OPS.index("validator_update") == 6 + assert OPS.index("account_validator_vote") == 7 + assert OPS.index("account_validator_proxy") == 8 + assert OPS.index("shutdown_validator") == 30 + assert OPS.index("validator_reward") == 42 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_validator_compat.py -v -k operations` +Expected: 3 FAIL — `KeyError: 'validator_update'` etc. + +- [ ] **Step 3: Edit `vizbase/operationids.py`** + +Replace the file's content with: + +```python +from .validator_compat import OP_NAME_ALIASES + +#: Operation ids +# Note: take operations from libraries/protocol/include/graphene/protocol/operations.hpp +# Beware to keep operations order! +OPS = [ + "vote", + "content", + "transfer", + "transfer_to_vesting", + "withdraw_vesting", + "account_update", + "validator_update", + "account_validator_vote", + "account_validator_proxy", + "delete_content", + "custom", + "set_withdraw_vesting_route", + "request_account_recovery", + "recover_account", + "change_recovery_account", + "escrow_transfer", + "escrow_dispute", + "escrow_release", + "escrow_approve", + "delegate_vesting_shares", + "account_create", + "account_metadata", + "proposal_create", + "proposal_update", + "proposal_delete", + "chain_properties_update", + "author_reward", + "curation_reward", + "content_reward", + "fill_vesting_withdraw", + "shutdown_validator", + "hardfork", + "content_payout_update", + "content_benefactor_reward", + "return_vesting_delegation", + "committee_worker_create_request", + "committee_worker_cancel_request", + "committee_vote_request", + "committee_cancel_request", + "committee_approve_request", + "committee_payout_request", + "committee_pay_request", + "validator_reward", + "create_invite", + "claim_invite_balance", + "invite_registration", + "versioned_chain_properties_update", + "award", + "receive_award", + "benefactor_award", + "set_paid_subscription", + "paid_subscribe", + "paid_subscription_action", + "cancel_paid_subscription", + "set_account_price", + "set_subaccount_price", + "buy_account", + "account_sale", + "use_invite_balance", + "expire_escrow_ratification", + "fixed_award", + "target_account_sale", + "bid", + "outbid", +] +operations = {o: OPS.index(o) for o in OPS} + +# Phase A dual-support: register old op names as aliases pointing to the +# same integer ID. Lookup by either old or new name returns the same id. +# Remove these alias entries during Phase C cleanup. +for _old, _new in OP_NAME_ALIASES.items(): + operations[_old] = operations[_new] + +# libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp +VIRTUAL_OPS = [ + "author_reward", + "curation_reward", + "content_reward", + "fill_vesting_withdraw", + "shutdown_validator", + "hardfork", + "content_payout_update", + "content_benefactor_reward", + "return_vesting_delegation", + "committee_cancel_request", + "committee_approve_request", + "committee_payout_request", + "committee_pay_request", + "validator_reward", + "receive_award", + "benefactor_award", + "paid_subscription_action", + "cancel_paid_subscription", + "expire_escrow_ratification", + "bid", + "outbid", +] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_validator_compat.py -v` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add vizbase/operationids.py tests/test_validator_compat.py +git commit -m "Rename witness ops to validator in OPS/VIRTUAL_OPS and register old-name aliases" +``` + +--- + +## Task 3: Rename `Witness_update` → `Validator_update` with deprecated alias + +**Files:** +- Modify: `vizbase/operations.py:271-290` +- Test: `tests/test_validator_compat.py` (add tests) + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_validator_compat.py`: + +```python +def test_validator_update_class_exists_and_serializes(): + from vizbase.operations import Validator_update + op = Validator_update( + owner="alice", + url="https://alice.example", + block_signing_key="VIZ1111111111111111111111111111111114T1Anm", + ) + j = op.json() + assert j["owner"] == "alice" + assert j["url"] == "https://alice.example" + assert j["block_signing_key"] == "VIZ1111111111111111111111111111111114T1Anm" + + +def test_witness_update_alias_emits_deprecation_warning_once(): + from vizbase.operations import _DeprecatedAlias + _DeprecatedAlias._warned.discard("Witness_update") + + from vizbase.operations import Validator_update, Witness_update + with pytest.warns(DeprecationWarning, match=r"Witness_update is deprecated"): + op1 = Witness_update( + owner="alice", url="https://x", + block_signing_key="VIZ1111111111111111111111111111111114T1Anm", + ) + assert isinstance(op1, Validator_update) + + # Second instantiation: no warning. + with warnings.catch_warnings(): + warnings.simplefilter("error") + Witness_update( + owner="alice", url="https://x", + block_signing_key="VIZ1111111111111111111111111111111114T1Anm", + ) + + +def test_witness_update_and_validator_update_serialize_identically(): + from vizbase.operations import Validator_update, Witness_update + kwargs = dict( + owner="alice", + url="https://alice.example", + block_signing_key="VIZ1111111111111111111111111111111114T1Anm", + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + a = bytes(Validator_update(**kwargs)) + b = bytes(Witness_update(**kwargs)) + assert a == b +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_validator_compat.py -v -k validator_update` +Expected: FAIL — `ImportError: cannot import name 'Validator_update'`. + +- [ ] **Step 3: Edit `vizbase/operations.py`** + +At the top of `vizbase/operations.py` (after the existing imports, before `class Account_create`): + +```python +import warnings + + +class _DeprecatedAlias: + """Warn once-per-process on first instantiation of a deprecated class name.""" + + _warned: set[str] = set() + + @classmethod + def make(cls, old_name: str, new_class: type) -> type: + warned = cls._warned + + class _Alias(new_class): + def __init__(self, *args, **kwargs): + if old_name not in warned: + warnings.warn( + f"{old_name} is deprecated; use {new_class.__name__} instead", + DeprecationWarning, stacklevel=2, + ) + warned.add(old_name) + super().__init__(*args, **kwargs) + + _Alias.__name__ = old_name + _Alias.__qualname__ = old_name + return _Alias +``` + +Replace the existing `class Witness_update(GrapheneObject):` definition (around line 271) with: + +```python +class Validator_update(GrapheneObject): + def __init__(self, *args, **kwargs): + if isArgsThisClass(self, args): + self.data = args[0].data + else: + if len(args) == 1 and len(kwargs) == 0: + kwargs = args[0] + prefix = kwargs.pop("prefix", DEFAULT_PREFIX) + + if not kwargs["block_signing_key"]: + kwargs["block_signing_key"] = f"{prefix}1111111111111111111111111111111114T1Anm" + super().__init__( + OrderedDict( + [ + ("owner", String(kwargs["owner"])), + ("url", String(kwargs["url"])), + ("block_signing_key", PublicKey(kwargs["block_signing_key"], prefix=prefix)), + ] + ) + ) +``` + +At the bottom of `vizbase/operations.py`, add: + +```python +# Deprecated witness-named aliases. Subclasses that warn once per process +# on first instantiation. Remove during Phase C cleanup. +Witness_update = _DeprecatedAlias.make("Witness_update", Validator_update) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_validator_compat.py -v` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add vizbase/operations.py tests/test_validator_compat.py +git commit -m "Rename Witness_update to Validator_update with deprecated alias" +``` + +--- + +## Task 4: Rename `Account_witness_vote` → `Account_validator_vote` with field & kwarg translation + +**Files:** +- Modify: `vizbase/operations.py:313-328` +- Test: `tests/test_validator_compat.py` (add tests) + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_validator_compat.py`: + +```python +def test_account_validator_vote_serializes_new_kwargs(): + from vizbase.operations import Account_validator_vote + op = Account_validator_vote(account="alice", validator="bob", approve=True) + j = op.json() + assert j == {"account": "alice", "validator": "bob", "approve": True} + + +def test_account_validator_vote_accepts_old_witness_kwarg_with_warning(): + from vizbase.operations import Account_validator_vote + with pytest.warns(DeprecationWarning, match=r"'witness' is deprecated"): + op = Account_validator_vote(account="alice", witness="bob", approve=True) + j = op.json() + assert j == {"account": "alice", "validator": "bob", "approve": True} + + +def test_account_witness_vote_alias_class_works(): + from vizbase.operations import _DeprecatedAlias + _DeprecatedAlias._warned.discard("Account_witness_vote") + + from vizbase.operations import Account_validator_vote, Account_witness_vote + with pytest.warns(DeprecationWarning): + op = Account_witness_vote(account="alice", witness="bob", approve=True) + assert isinstance(op, Account_validator_vote) + assert op.json() == {"account": "alice", "validator": "bob", "approve": True} + + +def test_account_validator_vote_old_and_new_serialize_identically(): + from vizbase.operations import Account_validator_vote + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + a = bytes(Account_validator_vote(account="alice", validator="bob", approve=True)) + b = bytes(Account_validator_vote(account="alice", witness="bob", approve=True)) + assert a == b +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_validator_compat.py -v -k account_validator_vote` +Expected: FAIL — `cannot import name 'Account_validator_vote'`. + +- [ ] **Step 3: Edit `vizbase/operations.py`** + +At the top of the file with the other imports, add: + +```python +from .validator_compat import OP_FIELD_ALIASES, translate_kwargs +``` + +Replace the existing `class Account_witness_vote` definition (around line 313) with: + +```python +class Account_validator_vote(GrapheneObject): + def __init__(self, *args, **kwargs): + if isArgsThisClass(self, args): + self.data = args[0].data + else: + if len(args) == 1 and len(kwargs) == 0: + kwargs = args[0] + kwargs = translate_kwargs( + kwargs, + OP_FIELD_ALIASES["account_validator_vote"], + context="Account_validator_vote", + ) + super().__init__( + OrderedDict( + [ + ("account", String(kwargs["account"])), + ("validator", String(kwargs["validator"])), + ("approve", Bool(bool(kwargs["approve"]))), + ] + ) + ) +``` + +At the bottom of the file, alongside the `Witness_update` alias, add: + +```python +Account_witness_vote = _DeprecatedAlias.make("Account_witness_vote", Account_validator_vote) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_validator_compat.py -v` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add vizbase/operations.py tests/test_validator_compat.py +git commit -m "Rename Account_witness_vote to Account_validator_vote; translate witness= kwarg" +``` + +--- + +## Task 5: Update chain-properties field names in `vizbase/objects.py` + +**Files:** +- Modify: `vizbase/objects.py:130-176` +- Test: `tests/test_validator_compat.py` (add tests) + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_validator_compat.py`: + +```python +CHAIN_PROPS_NEW = { + "account_creation_fee": "1.000 VIZ", + "maximum_block_size": 65536, + "create_account_delegation_ratio": 2, + "create_account_delegation_time": 3600, + "min_delegation": "10.000 VIZ", + "min_curation_percent": 1000, + "max_curation_percent": 2000, + "bandwidth_reserve_percent": 1000, + "bandwidth_reserve_below": "10.000 SHARES", + "flag_energy_additional_cost": 1000, + "vote_accounting_min_rshares": 100000, + "committee_request_approve_min_percent": 1000, + "inflation_validator_percent": 1000, + "inflation_ratio_committee_vs_reward_fund": 5000, + "inflation_recalc_period": 3600, + "data_operations_cost_additional_bandwidth": 0, + "validator_miss_penalty_percent": 1000, + "validator_miss_penalty_duration": 3600, + "create_invite_min_balance": "1.000 VIZ", + "committee_create_request_fee": "1.000 VIZ", + "create_paid_subscription_fee": "1.000 VIZ", + "account_on_sale_fee": "1.000 VIZ", + "subaccount_on_sale_fee": "1.000 VIZ", + "validator_declaration_fee": "1.000 VIZ", + "withdraw_intervals": 10, +} + +CHAIN_PROPS_OLD = { + **{k: v for k, v in CHAIN_PROPS_NEW.items() + if k not in { + "inflation_validator_percent", + "validator_miss_penalty_percent", + "validator_miss_penalty_duration", + "validator_declaration_fee", + }}, + "inflation_witness_percent": 1000, + "witness_miss_penalty_percent": 1000, + "witness_miss_penalty_duration": 3600, + "witness_declaration_fee": "1.000 VIZ", +} + + +def test_chain_properties_serializes_with_new_field_names(): + from vizbase.operations import Versioned_chain_properties_update + op = Versioned_chain_properties_update(owner="alice", props=CHAIN_PROPS_NEW) + bytes(op) + + +def test_chain_properties_old_field_names_warn_and_serialize_identically(): + from vizbase.operations import Versioned_chain_properties_update + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + new_bytes = bytes(Versioned_chain_properties_update(owner="alice", props=CHAIN_PROPS_NEW)) + + with pytest.warns(DeprecationWarning, match=r"'inflation_witness_percent' is deprecated"): + op_old = Versioned_chain_properties_update(owner="alice", props=CHAIN_PROPS_OLD) + old_bytes = bytes(op_old) + + assert new_bytes == old_bytes +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_validator_compat.py -v -k chain_properties` +Expected: FAIL — `KeyError: 'inflation_validator_percent'`. + +- [ ] **Step 3: Edit `vizbase/objects.py`** + +At the top of `vizbase/objects.py` with the other imports, add: + +```python +from .validator_compat import CHAIN_PROPS_FIELD_ALIASES, translate_kwargs +``` + +In the chain-properties `__init__` (around lines 130-176), insert a `translate_kwargs` call before the `OrderedDict` build, and rename the four field names. The full block becomes: + +```python + if len(args) == 1 and len(kwargs) == 0: + kwargs = args[0] + + kwargs = translate_kwargs( + kwargs, CHAIN_PROPS_FIELD_ALIASES, context="chain_properties_update", + ) + + super().__init__( + OrderedDict( + [ + # initial, version 0 + ("account_creation_fee", Amount(kwargs["account_creation_fee"])), + ("maximum_block_size", Uint32(kwargs["maximum_block_size"])), + ("create_account_delegation_ratio", Uint32(kwargs["create_account_delegation_ratio"])), + ("create_account_delegation_time", Uint32(kwargs["create_account_delegation_time"])), + ("min_delegation", Amount(kwargs["min_delegation"])), + ("min_curation_percent", Uint16(kwargs["min_curation_percent"])), + ("max_curation_percent", Uint16(kwargs["max_curation_percent"])), + ("bandwidth_reserve_percent", Uint16(kwargs["bandwidth_reserve_percent"])), + ("bandwidth_reserve_below", Amount(kwargs["bandwidth_reserve_below"])), + ("flag_energy_additional_cost", Uint16(kwargs["flag_energy_additional_cost"])), + ("vote_accounting_min_rshares", Uint32(kwargs["vote_accounting_min_rshares"])), + ( + "committee_request_approve_min_percent", + Uint16(kwargs["committee_request_approve_min_percent"]), + ), + # chain_properties_hf4, version 1 + ("inflation_validator_percent", Uint16(kwargs["inflation_validator_percent"])), + ( + "inflation_ratio_committee_vs_reward_fund", + Uint16(kwargs["inflation_ratio_committee_vs_reward_fund"]), + ), + ("inflation_recalc_period", Uint32(kwargs["inflation_recalc_period"])), + # chain_properties_hf6: version 2 + ( + "data_operations_cost_additional_bandwidth", + Uint32(kwargs["data_operations_cost_additional_bandwidth"]), + ), + ("validator_miss_penalty_percent", Uint16(kwargs["validator_miss_penalty_percent"])), + ("validator_miss_penalty_duration", Uint32(kwargs["validator_miss_penalty_duration"])), + # chain_properties_hf9: version 3 + ("create_invite_min_balance", Amount(kwargs["create_invite_min_balance"])), + ("committee_create_request_fee", Amount(kwargs["committee_create_request_fee"])), + ("create_paid_subscription_fee", Amount(kwargs["create_paid_subscription_fee"])), + ("account_on_sale_fee", Amount(kwargs["account_on_sale_fee"])), + ("subaccount_on_sale_fee", Amount(kwargs["subaccount_on_sale_fee"])), + ("validator_declaration_fee", Amount(kwargs["validator_declaration_fee"])), + ("withdraw_intervals", Uint16(kwargs["withdraw_intervals"])), + ] + ) + ) +``` + +Field order is preserved. The only changes are the four field renames and the `translate_kwargs` call at the top. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_validator_compat.py -v` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add vizbase/objects.py tests/test_validator_compat.py +git commit -m "Rename chain_properties witness fields to validator; translate old kwargs" +``` + +--- + +## Task 6: Update existing serialization test to new field names + +**Files:** +- Modify: `tests/test_serialization.py:62-79` + +- [ ] **Step 1: Edit the test** + +In `tests/test_serialization.py`, find `test_versioned_chain_properties_update`. Replace the four old field names with new ones in the `props` dict: + +- `"inflation_witness_percent"` → `"inflation_validator_percent"` +- `"witness_miss_penalty_percent"` → `"validator_miss_penalty_percent"` +- `"witness_miss_penalty_duration"` → `"validator_miss_penalty_duration"` +- `"witness_declaration_fee"` → `"validator_declaration_fee"` + +(Keep all other fields and the surrounding `do_test` call unchanged.) + +- [ ] **Step 2: Run the test** + +Run: `pytest tests/test_serialization.py::TestSerialization::test_versioned_chain_properties_update -v` + +Expected behavior depends on the testnet image: +- Against an upgraded node that accepts new field names: PASS. +- Against `vizblockchain/vizd:pr-85-merge` (predates the rename): FAIL — the node rejects unknown field names in `get_transaction_hex`. + +If the test fails because of the testnet image, do not roll back the rename. The failure is expected and documented in the spec's "Integration-test caveat." Mark it `xfail` only if CI green is required: + +```python +@pytest.mark.xfail(reason="Requires upgraded vizd image with validator field names") +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_serialization.py +git commit -m "Update chain_properties test to use new validator field names" +``` + +--- + +## Task 7: Update `vizapi/consts.py` (API method renames) + +**Files:** +- Modify: `vizapi/consts.py:100-108` and `:61` +- Test: `tests/test_validator_compat.py` (add tests) + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_validator_compat.py`: + +```python +def test_consts_api_has_validator_methods(): + from vizapi.consts import API + assert API["get_active_validators"] == "validator_api" + assert API["get_validator_schedule"] == "validator_api" + assert API["get_validators"] == "validator_api" + assert API["get_validator_by_account"] == "validator_api" + assert API["get_validators_by_vote"] == "validator_api" + assert API["get_validators_by_counted_vote"] == "validator_api" + assert API["get_validator_count"] == "validator_api" + assert API["lookup_validator_accounts"] == "validator_api" + assert API["get_miner_queue"] == "validator_api" + assert API["debug_get_validator_schedule"] == "debug_node" + + +def test_consts_api_does_not_have_old_witness_methods(): + from vizapi.consts import API + for old_name in ( + "get_active_witnesses", "get_witness_schedule", "get_witnesses", + "get_witness_by_account", "get_witnesses_by_vote", + "get_witnesses_by_counted_vote", "get_witness_count", + "lookup_witness_accounts", "debug_get_witness_schedule", + ): + assert old_name not in API, f"{old_name} should be removed from API map" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_validator_compat.py -v -k consts_api` +Expected: FAIL — KeyError on validator method names. + +- [ ] **Step 3: Edit `vizapi/consts.py`** + +Replace the `debug_get_witness_schedule` line: + +```python + "debug_get_validator_schedule": "debug_node", +``` + +Replace the nine `witness_api` block (lines ~100-108) with: + +```python + "get_miner_queue": "validator_api", + "get_validators_by_counted_vote": "validator_api", + "get_active_validators": "validator_api", + "get_validator_schedule": "validator_api", + "get_validators": "validator_api", + "get_validator_by_account": "validator_api", + "get_validators_by_vote": "validator_api", + "get_validator_count": "validator_api", + "lookup_validator_accounts": "validator_api", +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_validator_compat.py -v` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add vizapi/consts.py tests/test_validator_compat.py +git commit -m "Rename witness_api methods to validator_api in vizapi/consts" +``` + +--- + +## Task 8: Add `NoSuchMethod` exception + +**Files:** +- Modify: `vizapi/exceptions.py` +- Test: `tests/test_validator_compat.py` (add test) + +- [ ] **Step 1: Add failing test** + +Append to `tests/test_validator_compat.py`: + +```python +def test_no_such_method_exception_exists(): + from vizapi.exceptions import NoSuchMethod, RPCError + assert issubclass(NoSuchMethod, RPCError) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_validator_compat.py -v -k no_such_method` +Expected: FAIL — `cannot import name 'NoSuchMethod'`. + +- [ ] **Step 3: Edit `vizapi/exceptions.py`** + +Add a new class: + +```python +class NoSuchMethod(RPCError): + """Raised when the node reports the requested method is not available on the API.""" + pass +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_validator_compat.py -v -k no_such_method` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add vizapi/exceptions.py tests/test_validator_compat.py +git commit -m "Add NoSuchMethod exception for method-not-found RPC errors" +``` + +--- + +## Task 9: Dispatcher inbound translation + outbound fallback + +**Files:** +- Modify: `vizapi/noderpc.py:38-63` (`post_process_exception`) +- Modify: `vizapi/noderpc.py:101-140` (`Rpc` class) +- Test: `tests/test_validator_compat.py` (add tests) + +- [ ] **Step 1: Add failing tests** + +Append to `tests/test_validator_compat.py`: + +```python +def test_post_process_exception_raises_no_such_method(): + from vizapi.exceptions import NoSuchMethod + from vizapi.noderpc import NodeRPC + + rpc = NodeRPC.__new__(NodeRPC) + + class FakeError(Exception): + pass + + err = FakeError("foo bar (123)\nCould not find method get_active_validators\n\n") + with pytest.raises(NoSuchMethod): + rpc.post_process_exception(err) + + +def _build_rpc_with_runner(runner): + """Build a Rpc with rpcexec stubbed and a request-id counter, no network.""" + from vizapi.noderpc import Rpc + rpc = Rpc.__new__(Rpc) + rpc._uses_legacy_witness_api = None + rpc._request_id = 0 + + def get_request_id(): + rpc._request_id += 1 + return rpc._request_id + + def parse_response(resp): + return resp["result"] + + rpc.get_request_id = get_request_id + rpc.rpcexec = runner + rpc.parse_response = parse_response + return rpc + + +def test_dispatcher_inbound_translates_old_method_name_with_warning(): + seen = [] + + def runner(query): + seen.append(query["params"]) + return {"result": ["alice", "bob"]} + + rpc = _build_rpc_with_runner(runner) + with pytest.warns(DeprecationWarning, match=r"get_active_witnesses.*deprecated"): + result = rpc.get_active_witnesses() + assert result == ["alice", "bob"] + assert seen[0] == ["validator_api", "get_active_validators", []] + + +def test_dispatcher_falls_back_to_witness_api_on_no_such_method(): + from vizapi.exceptions import NoSuchMethod + + calls = [] + + def runner(query): + calls.append(query["params"]) + if query["params"][1] == "get_active_validators": + raise NoSuchMethod("Could not find method") + return {"result": ["alice"]} + + rpc = _build_rpc_with_runner(runner) + with pytest.warns(DeprecationWarning, match=r"witness_api"): + result = rpc.get_active_validators() + assert result == ["alice"] + assert calls[0] == ["validator_api", "get_active_validators", []] + assert calls[1] == ["witness_api", "get_active_witnesses", []] + assert rpc._uses_legacy_witness_api is True + + +def test_dispatcher_uses_cached_legacy_on_subsequent_calls(): + from vizapi.exceptions import NoSuchMethod + + calls = [] + + def runner(query): + calls.append(query["params"]) + if query["params"][1] == "get_active_validators": + raise NoSuchMethod("Could not find method") + return {"result": ["alice"]} + + rpc = _build_rpc_with_runner(runner) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + rpc.get_active_validators() + rpc.get_active_validators() + + # First call: tried new then fell back -> 2 calls. + # Second call: skipped new attempt -> 1 call. Total: 3. + assert len(calls) == 3 + assert calls[2] == ["witness_api", "get_active_witnesses", []] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_validator_compat.py -v -k dispatcher or post_process_exception` +Expected: FAIL — current dispatcher doesn't translate, doesn't fall back, and `post_process_exception` doesn't raise `NoSuchMethod`. + +- [ ] **Step 3: Update `vizapi/noderpc.py`** + +Replace the top imports block: + +```python +import logging +import warnings +from threading import Lock + +from grapheneapi.api import Api as GrapheneApi +from grapheneapi.http import Http as GrapheneHttp +from grapheneapi.rpc import Rpc as GrapheneRpc +from grapheneapi.websocket import Websocket as GrapheneWebsocket + +from vizbase.chains import KNOWN_CHAINS +from vizbase.validator_compat import API_METHOD_ALIASES + +from . import exceptions +from .consts import API + +log = logging.getLogger(__name__) + +# Reverse map for runtime fallback: new method name -> old method name. +_REVERSE_API_METHOD = {new: old for old, new in API_METHOD_ALIASES.items()} +``` + +Replace the body of `post_process_exception` (around lines 38-63): + +```python + def post_process_exception(self, error: Exception) -> None: + if isinstance(error, exceptions.NoSuchAPI): + raise + + msg = exceptions.decode_rpc_error_msg(error) + msg_lower = msg.lower() + if ( + msg.startswith("Missing Active Authority") + or msg.startswith("Missing Master Authority") + or msg.startswith("Missing Authority") + or msg.startswith("Missing Regular Authority") + ): + raise exceptions.MissingRequiredAuthority(msg) + elif msg == "Unable to acquire READ lock": + raise exceptions.ReadLockFail(msg) + elif ( + "could not find method" in msg_lower + or "method not found" in msg_lower + or "no such method" in msg_lower + ): + raise exceptions.NoSuchMethod(msg) + elif msg: + raise exceptions.UnhandledRPCError(msg) + else: + raise error +``` + +Replace the `Rpc` class (around lines 101-140) with: + +```python +class Rpc(GrapheneRpc): + """ + This class is responsible for making RPC queries. + + Phase A of the witness -> validator migration: inbound calls using old + method names are translated to new names with a DeprecationWarning. + On a NoSuchMethod error against the new method, the dispatcher falls + back to the old method on `witness_api` and caches the result so + subsequent calls skip the new-name attempt. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # None = unknown; True = node only knows witness_api; False = new names confirmed. + self._uses_legacy_witness_api: bool | None = None + + def __getattr__(self, name): + """Map all methods to RPC calls and pass through the arguments.""" + + def method(*args, **kwargs): + # Inbound translation: if caller used a deprecated witness_* name, + # translate to the validator_* equivalent and warn. + canonical_name = API_METHOD_ALIASES.get(name, name) + if canonical_name != name: + warnings.warn( + f"API method '{name}' is deprecated; use '{canonical_name}' instead", + DeprecationWarning, stacklevel=2, + ) + + api = kwargs.get("api", API.get(canonical_name)) + if not api: + raise exceptions.NoSuchAPI(f'Cannot find API for you request "{canonical_name}"') + + # Fix wrong api name hardcoded in graphenecommon.TransactionBuilder + if api == "network_broadcast": + api = "network_broadcast_api" + + # If the node is known to only speak witness_api, skip new-name attempt. + if self._uses_legacy_witness_api and canonical_name in _REVERSE_API_METHOD: + return self._call_legacy(canonical_name, list(args)) + + return self._call_with_fallback(api, canonical_name, list(args)) + + return method + + def _call_legacy(self, canonical_name: str, params_args: list): + old_name = _REVERSE_API_METHOD[canonical_name] + return self._do_call("witness_api", old_name, params_args) + + def _call_with_fallback(self, api: str, canonical_name: str, params_args: list): + try: + result = self._do_call(api, canonical_name, params_args) + except exceptions.NoSuchMethod: + if canonical_name not in _REVERSE_API_METHOD: + raise + if self._uses_legacy_witness_api is None: + warnings.warn( + "Node responded on witness_api; upgrade recommended", + DeprecationWarning, stacklevel=4, + ) + self._uses_legacy_witness_api = True + return self._call_legacy(canonical_name, params_args) + else: + if self._uses_legacy_witness_api is None: + self._uses_legacy_witness_api = False + return result + + def _do_call(self, api: str, name: str, params_args: list): + query = { + "method": "call", + "params": [api, name, params_args], + "jsonrpc": "2.0", + "id": self.get_request_id(), + } + log.debug(query) + while True: + try: + response = self.rpcexec(query) + message = self.parse_response(response) + except exceptions.ReadLockFail: + pass + else: + break + return message +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_validator_compat.py -v` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add vizapi/noderpc.py tests/test_validator_compat.py +git commit -m "Dispatcher: translate old method names inbound, fall back to witness_api outbound" +``` + +--- + +## Task 10: Rename `viz/witness.py` → `viz/validator.py` + +**Files:** +- Rename: `viz/witness.py` → `viz/validator.py` +- Test: `tests/test_validator_compat.py` (add test) + +- [ ] **Step 1: Add failing test** + +Append to `tests/test_validator_compat.py`: + +```python +def test_validator_class_importable(): + from viz.validator import Validator, Validators + from graphenecommon.witness import Witness as GrapheneWitness + from graphenecommon.witness import Witnesses as GrapheneWitnesses + assert issubclass(Validator, GrapheneWitness) + assert issubclass(Validators, GrapheneWitnesses) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_validator_compat.py -v -k validator_class_importable` +Expected: FAIL — `No module named 'viz.validator'`. + +- [ ] **Step 3: Move the file and rename classes** + +Run: + +```bash +git mv viz/witness.py viz/validator.py +``` + +Replace the contents of `viz/validator.py`: + +```python +from graphenecommon.witness import Witness as GrapheneWitness +from graphenecommon.witness import Witnesses as GrapheneWitnesses + +from .account import Account +from .instance import BlockchainInstance + + +@BlockchainInstance.inject +class Validator(GrapheneWitness): + """ + Read data about a validator in the chain. + + :param str account_name: Name of the validator + :param viz blockchain_instance: Client() instance to use when + accesing a RPC + + .. note:: + Inherits from graphenecommon.witness.Witness. Once graphenecommon + migrates its terminology, this parent can be swapped to the + validator-named equivalent. + """ + + def define_classes(self): + self.account_class = Account + self.type_ids = [6, 2] + + +@BlockchainInstance.inject +class Validators(GrapheneWitnesses): + """ + Obtain a list of **active** validators and the current schedule. + + :param bool only_active: (False) Only return validators that are + actively producing blocks + :param viz blockchain_instance: Client() instance to use when + accesing a RPC + """ + + def define_classes(self): + self.account_class = Account + # graphenecommon contract: parent asserts self.witness_class. + self.witness_class = Validator + # Forward-compat for a future graphenecommon migration. Harmless today. + self.validator_class = Validator +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_validator_compat.py -v -k validator_class_importable` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add viz/validator.py +git commit -m "Rename viz/witness.py to viz/validator.py with Validator/Validators classes" +``` + +--- + +## Task 11: Create `viz/witness.py` deprecation shim + +**Files:** +- Create: `viz/witness.py` +- Test: `tests/test_validator_compat.py` (add test) + +- [ ] **Step 1: Add failing test** + +Append to `tests/test_validator_compat.py`: + +```python +def test_viz_witness_shim_emits_warning_and_reexports(): + import sys + sys.modules.pop("viz.witness", None) + + with pytest.warns(DeprecationWarning, match=r"viz.witness is deprecated"): + import viz.witness # noqa: F401 + + from viz.validator import Validator, Validators + assert viz.witness.Witness is Validator + assert viz.witness.Witnesses is Validators +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_validator_compat.py -v -k viz_witness_shim` +Expected: FAIL — `No module named 'viz.witness'` (renamed in Task 10). + +- [ ] **Step 3: Create `viz/witness.py`** + +```python +""" +Deprecated module: use viz.validator instead. + +This shim re-exports Validator/Validators under their old witness names +to preserve backward compatibility during the witness -> validator +terminology migration. Remove during Phase C cleanup. +""" +import warnings + +from .validator import Validator, Validators + +warnings.warn( + "viz.witness is deprecated; import from viz.validator instead", + DeprecationWarning, stacklevel=2, +) + +Witness = Validator +Witnesses = Validators + +__all__ = ["Witness", "Witnesses"] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_validator_compat.py -v -k viz_witness_shim` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add viz/witness.py tests/test_validator_compat.py +git commit -m "Add viz.witness deprecation shim re-exporting from viz.validator" +``` + +--- + +## Task 12: Update `viz/blockchain.py` (docstring + filter canonicalization) + +**Files:** +- Modify: `viz/blockchain.py` +- Test: `tests/test_validator_compat.py` (add test) + +- [ ] **Step 1: Add failing test** + +Append to `tests/test_validator_compat.py`: + +```python +def test_filter_canonicalization_helper(): + from viz.blockchain import _canonical_filter + assert _canonical_filter("witness_reward") == "validator_reward" + assert _canonical_filter("validator_reward") == "validator_reward" + assert _canonical_filter("transfer") == "transfer" + assert _canonical_filter(None) is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_validator_compat.py -v -k filter_canonicalization` +Expected: FAIL — `cannot import name '_canonical_filter'`. + +- [ ] **Step 3: Read and edit `viz/blockchain.py`** + +Read `viz/blockchain.py` to locate the docstring examples and the stream method. Make these edits: + +1. Line ~141: `'type': 'witness_reward',` → `'type': 'validator_reward',` +2. Line ~145: `'witness': 'committee',` → `'validator': 'committee',` +3. Line ~160: `'op': ['witness_reward', {'witness': 'committee', 'shares': '0.032999 SHARES'}],` → `'op': ['validator_reward', {'validator': 'committee', 'shares': '0.032999 SHARES'}],` + +Add the canonical-filter helper near the top of the file (after the existing imports): + +```python +from vizbase.validator_compat import OP_NAME_ALIASES + + +def _canonical_filter(filter_by): + """Translate deprecated witness_* op names to validator_*; pass through others.""" + if filter_by is None: + return None + return OP_NAME_ALIASES.get(filter_by, filter_by) +``` + +In the `stream` method, add this line as the first statement of the method body, before any use of `filter_by`: + +```python + filter_by = _canonical_filter(filter_by) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_validator_compat.py -v -k filter_canonicalization` +Expected: PASS. + +Sanity check that the blockchain test file still collects without import errors: + +Run: `pytest tests/test_blockchain.py -v --collect-only` +Expected: tests collect cleanly. + +- [ ] **Step 5: Commit** + +```bash +git add viz/blockchain.py tests/test_validator_compat.py +git commit -m "Update viz/blockchain.py docstring examples and canonicalize filter_by" +``` + +--- + +## Task 13: Update `tests/test_blockchain.py` + +**Files:** +- Modify: `tests/test_blockchain.py:12,14,19,23` + +- [ ] **Step 1: Edit the test** + +Open `tests/test_blockchain.py`. Update both streaming tests: + +- Line 12: `filter_by="witness_reward"` → `filter_by="validator_reward"` +- Line 14: `assert op["type"] == "witness_reward"` → `"validator_reward"` +- Line 19: same as line 12 update +- Line 23: `assert op["op"][0] == "witness_reward"` → `"validator_reward"` + +- [ ] **Step 2: Run tests** + +Run: `pytest tests/test_blockchain.py -v` + +Expected behavior depends on the testnet image: +- Against an upgraded node returning `validator_reward`: PASS. +- Against `pr-85-merge` (still returns `witness_reward`): the canonicalizer in Task 12 makes `filter_by="validator_reward"` match either old or new op names, so the stream call succeeds. The `assert op["type"] == "validator_reward"` will fail because the old node emits `"witness_reward"`. Mark the test `xfail`: + +```python +@pytest.mark.xfail(reason="Requires upgraded vizd image that emits validator_reward") +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_blockchain.py +git commit -m "Update test_blockchain.py to use validator_reward op name" +``` + +--- + +## Task 14: Update TODO comments in `viz/viz.py` + +**Files:** +- Modify: `viz/viz.py:744-757` + +- [ ] **Step 1: Edit the TODO block** + +In `viz/viz.py`, find the TODO block at the end of the `Client` class (around line 744-757). Replace it with: + +```python + # TODO: Methods to implement: + # - validator_update + # - chain_properties_update + # - allow / disallow + # - update_memo_key + # - approve_validator / disapprove_validator + # - account_metadata + # - proposal_create / proposal_update / proposal_delete + # - validator_proxy + # - recover-related methods + # - escrow-related methods + # - worker create / cancel / vote + # - invite-related: create_invite, claim_invite_balance, invite_registration + # - paid subscrives related: set_paid_subscription / paid_subscribe +``` + +- [ ] **Step 2: Commit** + +```bash +git add viz/viz.py +git commit -m "Update viz.py TODO comments: witness -> validator" +``` + +--- + +## Task 15: Coverage drift test + +**Files:** +- Modify: `tests/test_validator_compat.py` + +- [ ] **Step 1: Add parametrized coverage tests** + +Append to `tests/test_validator_compat.py`: + +```python +@pytest.mark.parametrize("old,new", list(OP_NAME_ALIASES.items())) +def test_every_op_alias_resolves(old, new): + from vizbase.operationids import operations + assert operations[old] == operations[new] + + +@pytest.mark.parametrize("old,new", list(API_METHOD_ALIASES.items())) +def test_every_api_alias_in_reverse_map(old, new): + from vizapi.noderpc import _REVERSE_API_METHOD + assert _REVERSE_API_METHOD[new] == old + + +@pytest.mark.parametrize("old,new", list(CHAIN_PROPS_FIELD_ALIASES.items())) +def test_every_chain_props_alias_translatable(old, new): + out = translate_kwargs({old: 1}, CHAIN_PROPS_FIELD_ALIASES, context="ctx") + assert out == {new: 1} +``` + +- [ ] **Step 2: Run the full compat test suite** + +Run: `pytest tests/test_validator_compat.py -v` +Expected: all tests pass (parametrized cases should add ~18 new test invocations). + +- [ ] **Step 3: Final commit** + +```bash +git add tests/test_validator_compat.py +git commit -m "Add parametrized coverage drift tests across all alias dicts" +``` + +--- + +## Verification + +After all tasks complete, run the unit suite: + +```bash +pytest tests/test_validator_compat.py -v +``` + +Expected: all tests pass. + +Run the full non-integration suite: + +```bash +pytest tests/ -v --ignore=tests/test_blockchain.py --ignore=tests/test_serialization.py +``` + +Then the integration suite (requires testnet, may `xfail` until image is rebuilt): + +```bash +pytest tests/test_blockchain.py tests/test_serialization.py -v +``` + +The implementation is complete when the unit suite is green and the integration suite either passes or `xfail`s only on the documented testnet-image-blocked tests. From d16cf4ef6f8e015a2b71b5186b3c75612996c19f Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:40:49 +0800 Subject: [PATCH 03/20] Add validator_compat module with alias dicts and translate_kwargs/pick helpers --- tests/test_validator_compat.py | 111 +++++++++++++++++++++++++++++++++ vizbase/validator_compat.py | 82 ++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 tests/test_validator_compat.py create mode 100644 vizbase/validator_compat.py diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py new file mode 100644 index 0000000..7062284 --- /dev/null +++ b/tests/test_validator_compat.py @@ -0,0 +1,111 @@ +"""Unit tests for the witness -> validator compatibility layer.""" + +import warnings + +import pytest + +from vizbase.validator_compat import ( + API_METHOD_ALIASES, + CHAIN_PROPS_FIELD_ALIASES, + OP_FIELD_ALIASES, + OP_NAME_ALIASES, + pick, + translate_kwargs, +) + + +def test_op_name_aliases_complete(): + assert OP_NAME_ALIASES == { + "witness_update": "validator_update", + "account_witness_vote": "account_validator_vote", + "account_witness_proxy": "account_validator_proxy", + "shutdown_witness": "shutdown_validator", + "witness_reward": "validator_reward", + } + + +def test_api_method_aliases_complete(): + assert API_METHOD_ALIASES == { + "get_active_witnesses": "get_active_validators", + "get_witness_schedule": "get_validator_schedule", + "get_witnesses": "get_validators", + "get_witness_by_account": "get_validator_by_account", + "get_witnesses_by_vote": "get_validators_by_vote", + "get_witnesses_by_counted_vote": "get_validators_by_counted_vote", + "get_witness_count": "get_validator_count", + "lookup_witness_accounts": "lookup_validator_accounts", + "debug_get_witness_schedule": "debug_get_validator_schedule", + } + + +def test_chain_props_field_aliases_complete(): + assert CHAIN_PROPS_FIELD_ALIASES == { + "inflation_witness_percent": "inflation_validator_percent", + "witness_miss_penalty_percent": "validator_miss_penalty_percent", + "witness_miss_penalty_duration": "validator_miss_penalty_duration", + "witness_declaration_fee": "validator_declaration_fee", + } + + +def test_op_field_aliases_account_validator_vote(): + assert OP_FIELD_ALIASES["account_validator_vote"] == {"witness": "validator"} + + +def test_translate_kwargs_renames_and_warns(): + with pytest.warns(DeprecationWarning, match=r"'witness' is deprecated; use 'validator'"): + out = translate_kwargs( + {"witness": "alice", "approve": True}, + {"witness": "validator"}, + context="Account_validator_vote", + ) + assert out == {"validator": "alice", "approve": True} + + +def test_translate_kwargs_no_warning_for_new_names(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + out = translate_kwargs( + {"validator": "alice", "approve": True}, + {"witness": "validator"}, + context="Account_validator_vote", + ) + assert out == {"validator": "alice", "approve": True} + + +def test_translate_kwargs_new_wins_when_both_present(): + with pytest.warns(DeprecationWarning): + out = translate_kwargs( + {"witness": "alice", "validator": "bob"}, + {"witness": "validator"}, + context="Account_validator_vote", + ) + assert out == {"validator": "bob"} + + +def test_translate_kwargs_returns_copy(): + inp = {"witness": "alice"} + with pytest.warns(DeprecationWarning): + out = translate_kwargs(inp, {"witness": "validator"}, context="ctx") + assert "witness" in inp + assert out == {"validator": "alice"} + + +def test_pick_returns_first_present_dict_key(): + d = {"current_shuffled_witnesses": ["a"]} + assert pick(d, "current_shuffled_validators", "current_shuffled_witnesses") == ["a"] + + +def test_pick_prefers_first_listed(): + d = {"current_shuffled_validators": ["new"], "current_shuffled_witnesses": ["old"]} + assert pick(d, "current_shuffled_validators", "current_shuffled_witnesses") == ["new"] + + +def test_pick_default_when_none_present(): + assert pick({}, "a", "b", default=[]) == [] + + +def test_pick_works_on_objects_with_attributes(): + class Obj: + current_validator = "alice" + + assert pick(Obj(), "current_validator", "current_witness") == "alice" diff --git a/vizbase/validator_compat.py b/vizbase/validator_compat.py new file mode 100644 index 0000000..8910e0b --- /dev/null +++ b/vizbase/validator_compat.py @@ -0,0 +1,82 @@ +""" +Witness -> Validator migration compatibility layer. + +Single source of truth for old -> new name translation. When the migration +is complete (Phase C), delete this file and remove all imports from it. +""" + +import warnings + +# Wire-format op name aliases (old -> new). +OP_NAME_ALIASES = { + "witness_update": "validator_update", + "account_witness_vote": "account_validator_vote", + "account_witness_proxy": "account_validator_proxy", + "shutdown_witness": "shutdown_validator", + "witness_reward": "validator_reward", +} + +# JSON-RPC API method aliases (old -> new). +API_METHOD_ALIASES = { + "get_active_witnesses": "get_active_validators", + "get_witness_schedule": "get_validator_schedule", + "get_witnesses": "get_validators", + "get_witness_by_account": "get_validator_by_account", + "get_witnesses_by_vote": "get_validators_by_vote", + "get_witnesses_by_counted_vote": "get_validators_by_counted_vote", + "get_witness_count": "get_validator_count", + "lookup_witness_accounts": "lookup_validator_accounts", + "debug_get_witness_schedule": "debug_get_validator_schedule", +} + +# Chain-properties field aliases (old -> new). Applies to chain_properties_hf4/hf6/hf9. +CHAIN_PROPS_FIELD_ALIASES = { + "inflation_witness_percent": "inflation_validator_percent", + "witness_miss_penalty_percent": "validator_miss_penalty_percent", + "witness_miss_penalty_duration": "validator_miss_penalty_duration", + "witness_declaration_fee": "validator_declaration_fee", +} + +# Per-op kwarg field aliases (old -> new), keyed by canonical new op name. +OP_FIELD_ALIASES = { + "account_validator_vote": {"witness": "validator"}, + "validator_reward": {"witness": "validator"}, # virtual op; reserved for future use +} + + +def translate_kwargs(kwargs: dict, alias_map: dict, *, context: str) -> dict: + """ + Return a copy of `kwargs` with old keys renamed to new keys per `alias_map`. + + Emits one DeprecationWarning per old key found, citing `context`. + If both old and new keys are present, the new value wins and the old + key triggers a warning. + """ + out = dict(kwargs) + for old, new in alias_map.items(): + if old in out: + warnings.warn( + f"{context}: '{old}' is deprecated; use '{new}' instead", + DeprecationWarning, + stacklevel=3, + ) + if new not in out: + out[new] = out.pop(old) + else: + out.pop(old) + return out + + +def pick(obj, *keys, default=None): + """ + Return obj[k] for the first k in `keys` that exists; else `default`. + + Used at response-read sites to tolerate both old (witness_*) and new + (validator_*) field names. Call with new key first, old key second. + """ + for k in keys: + if isinstance(obj, dict) and k in obj: + return obj[k] + if not isinstance(obj, dict) and hasattr(obj, k): + return getattr(obj, k) + return default From 94a767755372fd13e089847d332dead4cb1ea826 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:42:45 +0800 Subject: [PATCH 04/20] Rename witness ops to validator in OPS/VIRTUAL_OPS and register old-name aliases --- tests/test_validator_compat.py | 33 +++++++++++++++++++++++++++++++++ vizbase/operationids.py | 22 +++++++++++++++------- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index 7062284..a071537 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -109,3 +109,36 @@ class Obj: current_validator = "alice" assert pick(Obj(), "current_validator", "current_witness") == "alice" + + +def test_operations_dict_has_new_names(): + from vizbase.operationids import operations + + assert operations["validator_update"] == 6 + assert operations["account_validator_vote"] == 7 + assert operations["account_validator_proxy"] == 8 + assert operations["shutdown_validator"] == 30 + assert operations["validator_reward"] == 42 + + +def test_operations_dict_has_old_name_aliases(): + from vizbase.operationids import operations + + assert operations["witness_update"] == operations["validator_update"] == 6 + assert operations["account_witness_vote"] == operations["account_validator_vote"] == 7 + assert operations["account_witness_proxy"] == operations["account_validator_proxy"] == 8 + assert operations["shutdown_witness"] == operations["shutdown_validator"] == 30 + assert operations["witness_reward"] == operations["validator_reward"] == 42 + + +def test_ops_list_order_preserved(): + """Operation type IDs are positional; renaming must not shift indices.""" + from vizbase.operationids import OPS + + assert OPS.index("transfer") == 2 + assert OPS.index("account_update") == 5 + assert OPS.index("validator_update") == 6 + assert OPS.index("account_validator_vote") == 7 + assert OPS.index("account_validator_proxy") == 8 + assert OPS.index("shutdown_validator") == 30 + assert OPS.index("validator_reward") == 42 diff --git a/vizbase/operationids.py b/vizbase/operationids.py index 108d3b8..2f2f70a 100644 --- a/vizbase/operationids.py +++ b/vizbase/operationids.py @@ -1,3 +1,5 @@ +from .validator_compat import OP_NAME_ALIASES + #: Operation ids # Note: take operations from libraries/protocol/include/graphene/protocol/operations.hpp # Beware to keep operations order! @@ -8,9 +10,9 @@ "transfer_to_vesting", "withdraw_vesting", "account_update", - "witness_update", - "account_witness_vote", - "account_witness_proxy", + "validator_update", + "account_validator_vote", + "account_validator_proxy", "delete_content", "custom", "set_withdraw_vesting_route", @@ -32,7 +34,7 @@ "curation_reward", "content_reward", "fill_vesting_withdraw", - "shutdown_witness", + "shutdown_validator", "hardfork", "content_payout_update", "content_benefactor_reward", @@ -44,7 +46,7 @@ "committee_approve_request", "committee_payout_request", "committee_pay_request", - "witness_reward", + "validator_reward", "create_invite", "claim_invite_balance", "invite_registration", @@ -69,13 +71,19 @@ ] operations = {o: OPS.index(o) for o in OPS} +# Phase A dual-support: register old op names as aliases pointing to the +# same integer ID. Lookup by either old or new name returns the same id. +# Remove these alias entries during Phase C cleanup. +for _old, _new in OP_NAME_ALIASES.items(): + operations[_old] = operations[_new] + # libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp VIRTUAL_OPS = [ "author_reward", "curation_reward", "content_reward", "fill_vesting_withdraw", - "shutdown_witness", + "shutdown_validator", "hardfork", "content_payout_update", "content_benefactor_reward", @@ -84,7 +92,7 @@ "committee_approve_request", "committee_payout_request", "committee_pay_request", - "witness_reward", + "validator_reward", "receive_award", "benefactor_award", "paid_subscription_action", From 9719974baeff303a6967356ee4d974a8ee8af399 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:45:08 +0800 Subject: [PATCH 05/20] Rename Witness_update to Validator_update with deprecated alias --- tests/test_validator_compat.py | 54 ++++++++++++++++++++++++++++++++++ vizbase/operations.py | 33 ++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index a071537..308fed2 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -142,3 +142,57 @@ def test_ops_list_order_preserved(): assert OPS.index("account_validator_proxy") == 8 assert OPS.index("shutdown_validator") == 30 assert OPS.index("validator_reward") == 42 + + +def test_validator_update_class_exists_and_serializes(): + from vizbase.operations import Validator_update + + op = Validator_update( + owner="alice", + url="https://alice.example", + block_signing_key="VIZ1111111111111111111111111111111114T1Anm", + ) + j = op.json() + assert j["owner"] == "alice" + assert j["url"] == "https://alice.example" + assert j["block_signing_key"] == "VIZ1111111111111111111111111111111114T1Anm" + + +def test_witness_update_alias_emits_deprecation_warning_once(): + from vizbase.operations import _DeprecatedAlias + + _DeprecatedAlias._warned.discard("Witness_update") + + from vizbase.operations import Validator_update, Witness_update + + with pytest.warns(DeprecationWarning, match=r"Witness_update is deprecated"): + op1 = Witness_update( + owner="alice", + url="https://x", + block_signing_key="VIZ1111111111111111111111111111111114T1Anm", + ) + assert isinstance(op1, Validator_update) + + # Second instantiation: no warning. + with warnings.catch_warnings(): + warnings.simplefilter("error") + Witness_update( + owner="alice", + url="https://x", + block_signing_key="VIZ1111111111111111111111111111111114T1Anm", + ) + + +def test_witness_update_and_validator_update_serialize_identically(): + from vizbase.operations import Validator_update, Witness_update + + kwargs = { + "owner": "alice", + "url": "https://alice.example", + "block_signing_key": "VIZ1111111111111111111111111111111114T1Anm", + } + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + a = bytes(Validator_update(**kwargs)) + b = bytes(Witness_update(**kwargs)) + assert a == b diff --git a/vizbase/operations.py b/vizbase/operations.py index d6bf623..d10c4d6 100644 --- a/vizbase/operations.py +++ b/vizbase/operations.py @@ -1,4 +1,5 @@ import json +import warnings from collections import OrderedDict from graphenebase.types import ( @@ -27,6 +28,31 @@ # libraries/protocol/include/graphene/protocol/chain_operations.hpp +class _DeprecatedAlias: + """Warn once-per-process on first instantiation of a deprecated class name.""" + + _warned: set[str] = set() + + @classmethod + def make(cls, old_name: str, new_class: type) -> type: + warned = cls._warned + + class _Alias(new_class): + def __init__(self, *args, **kwargs): + if old_name not in warned: + warnings.warn( + f"{old_name} is deprecated; use {new_class.__name__} instead", + DeprecationWarning, + stacklevel=2, + ) + warned.add(old_name) + super().__init__(*args, **kwargs) + + _Alias.__name__ = old_name + _Alias.__qualname__ = old_name + return _Alias + + class Account_create(GrapheneObject): def __init__(self, *args, **kwargs): if isArgsThisClass(self, args): @@ -268,7 +294,7 @@ def __init__(self, *args, **kwargs): ) -class Witness_update(GrapheneObject): +class Validator_update(GrapheneObject): def __init__(self, *args, **kwargs): if isArgsThisClass(self, args): self.data = args[0].data @@ -450,3 +476,8 @@ def __init__(self, *args, **kwargs): ] ) ) + + +# Deprecated witness-named aliases. Subclasses that warn once per process +# on first instantiation. Remove during Phase C cleanup. +Witness_update = _DeprecatedAlias.make("Witness_update", Validator_update) From 7c917c2866541be38992722c130536f1a5296077 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:47:09 +0800 Subject: [PATCH 06/20] Rename Account_witness_vote to Account_validator_vote; translate witness= kwarg --- tests/test_validator_compat.py | 40 ++++++++++++++++++++++++++++++++++ vizbase/operations.py | 11 ++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index 308fed2..da21bc1 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -196,3 +196,43 @@ def test_witness_update_and_validator_update_serialize_identically(): a = bytes(Validator_update(**kwargs)) b = bytes(Witness_update(**kwargs)) assert a == b + + +def test_account_validator_vote_serializes_new_kwargs(): + from vizbase.operations import Account_validator_vote + + op = Account_validator_vote(account="alice", validator="bob", approve=True) + j = op.json() + assert j == {"account": "alice", "validator": "bob", "approve": True} + + +def test_account_validator_vote_accepts_old_witness_kwarg_with_warning(): + from vizbase.operations import Account_validator_vote + + with pytest.warns(DeprecationWarning, match=r"'witness' is deprecated"): + op = Account_validator_vote(account="alice", witness="bob", approve=True) + j = op.json() + assert j == {"account": "alice", "validator": "bob", "approve": True} + + +def test_account_witness_vote_alias_class_works(): + from vizbase.operations import _DeprecatedAlias + + _DeprecatedAlias._warned.discard("Account_witness_vote") + + from vizbase.operations import Account_validator_vote, Account_witness_vote + + with pytest.warns(DeprecationWarning): + op = Account_witness_vote(account="alice", witness="bob", approve=True) + assert isinstance(op, Account_validator_vote) + assert op.json() == {"account": "alice", "validator": "bob", "approve": True} + + +def test_account_validator_vote_old_and_new_serialize_identically(): + from vizbase.operations import Account_validator_vote + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + a = bytes(Account_validator_vote(account="alice", validator="bob", approve=True)) + b = bytes(Account_validator_vote(account="alice", witness="bob", approve=True)) + assert a == b diff --git a/vizbase/operations.py b/vizbase/operations.py index d10c4d6..9a531e9 100644 --- a/vizbase/operations.py +++ b/vizbase/operations.py @@ -23,6 +23,7 @@ Permission, isArgsThisClass, ) +from .validator_compat import OP_FIELD_ALIASES, translate_kwargs # You can find operations definitions in # libraries/protocol/include/graphene/protocol/chain_operations.hpp @@ -336,18 +337,23 @@ def __init__(self, *args, **kwargs): super().__init__(OrderedDict([("owner", String(kwargs["owner"])), ("props", props)])) -class Account_witness_vote(GrapheneObject): +class Account_validator_vote(GrapheneObject): def __init__(self, *args, **kwargs): if isArgsThisClass(self, args): self.data = args[0].data else: if len(args) == 1 and len(kwargs) == 0: kwargs = args[0] + kwargs = translate_kwargs( + kwargs, + OP_FIELD_ALIASES["account_validator_vote"], + context="Account_validator_vote", + ) super().__init__( OrderedDict( [ ("account", String(kwargs["account"])), - ("witness", String(kwargs["witness"])), + ("validator", String(kwargs["validator"])), ("approve", Bool(bool(kwargs["approve"]))), ] ) @@ -481,3 +487,4 @@ def __init__(self, *args, **kwargs): # Deprecated witness-named aliases. Subclasses that warn once per process # on first instantiation. Remove during Phase C cleanup. Witness_update = _DeprecatedAlias.make("Witness_update", Validator_update) +Account_witness_vote = _DeprecatedAlias.make("Account_witness_vote", Account_validator_vote) From 0a0066ac7fc4f44b719fa09c817f0eee0adf5e27 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:49:35 +0800 Subject: [PATCH 07/20] Rename chain_properties witness fields to validator; translate old kwargs --- tests/test_validator_compat.py | 68 ++++++++++++++++++++++++++++++++++ vizbase/objects.py | 15 ++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index da21bc1..4e80b5e 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -236,3 +236,71 @@ def test_account_validator_vote_old_and_new_serialize_identically(): a = bytes(Account_validator_vote(account="alice", validator="bob", approve=True)) b = bytes(Account_validator_vote(account="alice", witness="bob", approve=True)) assert a == b + + +CHAIN_PROPS_NEW = { + "account_creation_fee": "1.000 VIZ", + "maximum_block_size": 65536, + "create_account_delegation_ratio": 2, + "create_account_delegation_time": 3600, + "min_delegation": "10.000 VIZ", + "min_curation_percent": 1000, + "max_curation_percent": 2000, + "bandwidth_reserve_percent": 1000, + "bandwidth_reserve_below": "10.000 SHARES", + "flag_energy_additional_cost": 1000, + "vote_accounting_min_rshares": 100000, + "committee_request_approve_min_percent": 1000, + "inflation_validator_percent": 1000, + "inflation_ratio_committee_vs_reward_fund": 5000, + "inflation_recalc_period": 3600, + "data_operations_cost_additional_bandwidth": 0, + "validator_miss_penalty_percent": 1000, + "validator_miss_penalty_duration": 3600, + "create_invite_min_balance": "1.000 VIZ", + "committee_create_request_fee": "1.000 VIZ", + "create_paid_subscription_fee": "1.000 VIZ", + "account_on_sale_fee": "1.000 VIZ", + "subaccount_on_sale_fee": "1.000 VIZ", + "validator_declaration_fee": "1.000 VIZ", + "withdraw_intervals": 10, +} + +CHAIN_PROPS_OLD = { + **{ + k: v + for k, v in CHAIN_PROPS_NEW.items() + if k + not in { + "inflation_validator_percent", + "validator_miss_penalty_percent", + "validator_miss_penalty_duration", + "validator_declaration_fee", + } + }, + "inflation_witness_percent": 1000, + "witness_miss_penalty_percent": 1000, + "witness_miss_penalty_duration": 3600, + "witness_declaration_fee": "1.000 VIZ", +} + + +def test_chain_properties_serializes_with_new_field_names(): + from vizbase.operations import Versioned_chain_properties_update + + op = Versioned_chain_properties_update(owner="alice", props=CHAIN_PROPS_NEW) + bytes(op) + + +def test_chain_properties_old_field_names_warn_and_serialize_identically(): + from vizbase.operations import Versioned_chain_properties_update + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + new_bytes = bytes(Versioned_chain_properties_update(owner="alice", props=CHAIN_PROPS_NEW)) + + with pytest.warns(DeprecationWarning, match=r"'inflation_witness_percent' is deprecated"): + op_old = Versioned_chain_properties_update(owner="alice", props=CHAIN_PROPS_OLD) + old_bytes = bytes(op_old) + + assert new_bytes == old_bytes diff --git a/vizbase/objects.py b/vizbase/objects.py index f5f3bec..3b9c8a8 100644 --- a/vizbase/objects.py +++ b/vizbase/objects.py @@ -18,6 +18,7 @@ from .chains import DEFAULT_PREFIX, PRECISIONS from .exceptions import AssetUnknown from .operationids import operations +from .validator_compat import CHAIN_PROPS_FIELD_ALIASES, translate_kwargs class Operation(GrapheneOperation): @@ -130,6 +131,12 @@ def __init__(self, *args, **kwargs): if len(args) == 1 and len(kwargs) == 0: kwargs = args[0] + kwargs = translate_kwargs( + kwargs, + CHAIN_PROPS_FIELD_ALIASES, + context="chain_properties_update", + ) + super().__init__( OrderedDict( [ @@ -150,7 +157,7 @@ def __init__(self, *args, **kwargs): Uint16(kwargs["committee_request_approve_min_percent"]), ), # chain_properties_hf4, version 1 - ("inflation_witness_percent", Uint16(kwargs["inflation_witness_percent"])), + ("inflation_validator_percent", Uint16(kwargs["inflation_validator_percent"])), ( "inflation_ratio_committee_vs_reward_fund", Uint16(kwargs["inflation_ratio_committee_vs_reward_fund"]), @@ -161,15 +168,15 @@ def __init__(self, *args, **kwargs): "data_operations_cost_additional_bandwidth", Uint32(kwargs["data_operations_cost_additional_bandwidth"]), ), - ("witness_miss_penalty_percent", Uint16(kwargs["witness_miss_penalty_percent"])), - ("witness_miss_penalty_duration", Uint32(kwargs["witness_miss_penalty_duration"])), + ("validator_miss_penalty_percent", Uint16(kwargs["validator_miss_penalty_percent"])), + ("validator_miss_penalty_duration", Uint32(kwargs["validator_miss_penalty_duration"])), # chain_properties_hf9: version 3 ("create_invite_min_balance", Amount(kwargs["create_invite_min_balance"])), ("committee_create_request_fee", Amount(kwargs["committee_create_request_fee"])), ("create_paid_subscription_fee", Amount(kwargs["create_paid_subscription_fee"])), ("account_on_sale_fee", Amount(kwargs["account_on_sale_fee"])), ("subaccount_on_sale_fee", Amount(kwargs["subaccount_on_sale_fee"])), - ("witness_declaration_fee", Amount(kwargs["witness_declaration_fee"])), + ("validator_declaration_fee", Amount(kwargs["validator_declaration_fee"])), ("withdraw_intervals", Uint16(kwargs["withdraw_intervals"])), ] ) From 4ac00d23423b070d91229fccaabd22f340e7fb3a Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:50:45 +0800 Subject: [PATCH 08/20] Update chain_properties test to use new validator field names --- tests/test_serialization.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 73ca1cd..da89a41 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -63,18 +63,18 @@ def test_versioned_chain_properties_update(self): "flag_energy_additional_cost": 1000, "vote_accounting_min_rshares": 100000, "committee_request_approve_min_percent": 1000, - "inflation_witness_percent": 1000, + "inflation_validator_percent": 1000, "inflation_ratio_committee_vs_reward_fund": 5000, "inflation_recalc_period": 3600, "data_operations_cost_additional_bandwidth": 0, - "witness_miss_penalty_percent": 1000, - "witness_miss_penalty_duration": 3600, + "validator_miss_penalty_percent": 1000, + "validator_miss_penalty_duration": 3600, "create_invite_min_balance": "1.000 VIZ", "committee_create_request_fee": "1.000 VIZ", "create_paid_subscription_fee": "1.000 VIZ", "account_on_sale_fee": "1.000 VIZ", "subaccount_on_sale_fee": "1.000 VIZ", - "witness_declaration_fee": "1.000 VIZ", + "validator_declaration_fee": "1.000 VIZ", "withdraw_intervals": 10, } data = {"owner": self.default_account, "props": props} # type: ignore From cbb545df9a2db876adb109335020b277568e73f5 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:52:07 +0800 Subject: [PATCH 09/20] Rename witness_api methods to validator_api in vizapi/consts --- tests/test_validator_compat.py | 32 ++++++++++++++++++++++++++++++++ vizapi/consts.py | 20 ++++++++++---------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index 4e80b5e..0d95c73 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -304,3 +304,35 @@ def test_chain_properties_old_field_names_warn_and_serialize_identically(): old_bytes = bytes(op_old) assert new_bytes == old_bytes + + +def test_consts_api_has_validator_methods(): + from vizapi.consts import API + + assert API["get_active_validators"] == "validator_api" + assert API["get_validator_schedule"] == "validator_api" + assert API["get_validators"] == "validator_api" + assert API["get_validator_by_account"] == "validator_api" + assert API["get_validators_by_vote"] == "validator_api" + assert API["get_validators_by_counted_vote"] == "validator_api" + assert API["get_validator_count"] == "validator_api" + assert API["lookup_validator_accounts"] == "validator_api" + assert API["get_miner_queue"] == "validator_api" + assert API["debug_get_validator_schedule"] == "debug_node" + + +def test_consts_api_does_not_have_old_witness_methods(): + from vizapi.consts import API + + for old_name in ( + "get_active_witnesses", + "get_witness_schedule", + "get_witnesses", + "get_witness_by_account", + "get_witnesses_by_vote", + "get_witnesses_by_counted_vote", + "get_witness_count", + "lookup_witness_accounts", + "debug_get_witness_schedule", + ): + assert old_name not in API, f"{old_name} should be removed from API map" diff --git a/vizapi/consts.py b/vizapi/consts.py index 412f325..816348c 100644 --- a/vizapi/consts.py +++ b/vizapi/consts.py @@ -58,7 +58,7 @@ "debug_push_blocks": "debug_node", "debug_push_json_blocks": "debug_node", "debug_pop_block": "debug_node", - "debug_get_witness_schedule": "debug_node", + "debug_get_validator_schedule": "debug_node", "debug_get_hardfork_property_object": "debug_node", "debug_set_hardfork": "debug_node", "debug_has_hardfork": "debug_node", @@ -97,15 +97,15 @@ "get_blog_authors": "follow", "get_inbox": "private_message", "get_outbox": "private_message", - "get_miner_queue": "witness_api", - "get_witnesses_by_counted_vote": "witness_api", - "get_active_witnesses": "witness_api", - "get_witness_schedule": "witness_api", - "get_witnesses": "witness_api", - "get_witness_by_account": "witness_api", - "get_witnesses_by_vote": "witness_api", - "get_witness_count": "witness_api", - "lookup_witness_accounts": "witness_api", + "get_miner_queue": "validator_api", + "get_validators_by_counted_vote": "validator_api", + "get_active_validators": "validator_api", + "get_validator_schedule": "validator_api", + "get_validators": "validator_api", + "get_validator_by_account": "validator_api", + "get_validators_by_vote": "validator_api", + "get_validator_count": "validator_api", + "lookup_validator_accounts": "validator_api", "get_account": "custom_protocol_api", "test_api_a": "test_api", "test_api_b": "test_api", From 5c10018f329c7ab25f21acbdb378946f5b3e5bc4 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:53:16 +0800 Subject: [PATCH 10/20] Add NoSuchMethod exception for method-not-found RPC errors --- tests/test_validator_compat.py | 6 ++++++ vizapi/exceptions.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index 0d95c73..b8d52c9 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -336,3 +336,9 @@ def test_consts_api_does_not_have_old_witness_methods(): "debug_get_witness_schedule", ): assert old_name not in API, f"{old_name} should be removed from API map" + + +def test_no_such_method_exception_exists(): + from vizapi.exceptions import NoSuchMethod, RPCError + + assert issubclass(NoSuchMethod, RPCError) diff --git a/vizapi/exceptions.py b/vizapi/exceptions.py index 16aae60..8a1b3dc 100644 --- a/vizapi/exceptions.py +++ b/vizapi/exceptions.py @@ -36,3 +36,9 @@ class ReadLockFail(RPCError): class UnknownNetwork(RPCError): pass + + +class NoSuchMethod(RPCError): + """Raised when the node reports the requested method is not available on the API.""" + + pass From 3fdce93acfd5a54386d6c7454564c7aeb7814e64 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:56:26 +0800 Subject: [PATCH 11/20] Dispatcher: translate old method names inbound, fall back to witness_api outbound --- tests/test_validator_compat.py | 92 ++++++++++++++++++++++++++++++++++ vizapi/noderpc.py | 92 +++++++++++++++++++++++++++------- 2 files changed, 165 insertions(+), 19 deletions(-) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index b8d52c9..a301671 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -342,3 +342,95 @@ def test_no_such_method_exception_exists(): from vizapi.exceptions import NoSuchMethod, RPCError assert issubclass(NoSuchMethod, RPCError) + + +def test_post_process_exception_raises_no_such_method(): + from vizapi.exceptions import NoSuchMethod + from vizapi.noderpc import NodeRPC + + rpc = NodeRPC.__new__(NodeRPC) + + class FakeError(Exception): + pass + + err = FakeError("foo bar (123)\nCould not find method get_active_validators\n\n") + with pytest.raises(NoSuchMethod): + rpc.post_process_exception(err) + + +def _build_rpc_with_runner(runner): + """Build a Rpc with rpcexec stubbed and a request-id counter, no network.""" + from vizapi.noderpc import Rpc + + rpc = Rpc.__new__(Rpc) + rpc._uses_legacy_witness_api = None + rpc._request_id = 0 + + def get_request_id(): + rpc._request_id += 1 + return rpc._request_id + + def parse_response(resp): + return resp["result"] + + rpc.get_request_id = get_request_id + rpc.rpcexec = runner + rpc.parse_response = parse_response + return rpc + + +def test_dispatcher_inbound_translates_old_method_name_with_warning(): + seen = [] + + def runner(query): + seen.append(query["params"]) + return {"result": ["alice", "bob"]} + + rpc = _build_rpc_with_runner(runner) + with pytest.warns(DeprecationWarning, match=r"get_active_witnesses.*deprecated"): + result = rpc.get_active_witnesses() + assert result == ["alice", "bob"] + assert seen[0] == ["validator_api", "get_active_validators", []] + + +def test_dispatcher_falls_back_to_witness_api_on_no_such_method(): + from vizapi.exceptions import NoSuchMethod + + calls = [] + + def runner(query): + calls.append(query["params"]) + if query["params"][1] == "get_active_validators": + raise NoSuchMethod("Could not find method") + return {"result": ["alice"]} + + rpc = _build_rpc_with_runner(runner) + with pytest.warns(DeprecationWarning, match=r"witness_api"): + result = rpc.get_active_validators() + assert result == ["alice"] + assert calls[0] == ["validator_api", "get_active_validators", []] + assert calls[1] == ["witness_api", "get_active_witnesses", []] + assert rpc._uses_legacy_witness_api is True + + +def test_dispatcher_uses_cached_legacy_on_subsequent_calls(): + from vizapi.exceptions import NoSuchMethod + + calls = [] + + def runner(query): + calls.append(query["params"]) + if query["params"][1] == "get_active_validators": + raise NoSuchMethod("Could not find method") + return {"result": ["alice"]} + + rpc = _build_rpc_with_runner(runner) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + rpc.get_active_validators() + rpc.get_active_validators() + + # First call: tried new then fell back -> 2 calls. + # Second call: skipped new attempt -> 1 call. Total: 3. + assert len(calls) == 3 + assert calls[2] == ["witness_api", "get_active_witnesses", []] diff --git a/vizapi/noderpc.py b/vizapi/noderpc.py index 480d9b8..52be4e4 100644 --- a/vizapi/noderpc.py +++ b/vizapi/noderpc.py @@ -1,4 +1,5 @@ import logging +import warnings from threading import Lock from grapheneapi.api import Api as GrapheneApi @@ -7,12 +8,16 @@ from grapheneapi.websocket import Websocket as GrapheneWebsocket from vizbase.chains import KNOWN_CHAINS +from vizbase.validator_compat import API_METHOD_ALIASES from . import exceptions from .consts import API log = logging.getLogger(__name__) +# Reverse map for runtime fallback: new method name -> old method name. +_REVERSE_API_METHOD = {new: old for old, new in API_METHOD_ALIASES.items()} + class NodeRPC(GrapheneApi): """ @@ -48,6 +53,7 @@ def post_process_exception(self, error: Exception) -> None: raise msg = exceptions.decode_rpc_error_msg(error) + msg_lower = msg.lower() if ( msg.startswith("Missing Active Authority") or msg.startswith("Missing Master Authority") @@ -57,6 +63,8 @@ def post_process_exception(self, error: Exception) -> None: raise exceptions.MissingRequiredAuthority(msg) elif msg == "Unable to acquire READ lock": raise exceptions.ReadLockFail(msg) + elif "could not find method" in msg_lower or "method not found" in msg_lower or "no such method" in msg_lower: + raise exceptions.NoSuchMethod(msg) elif msg: raise exceptions.UnhandledRPCError(msg) else: @@ -102,43 +110,89 @@ class Rpc(GrapheneRpc): """ This class is responsible for making RPC queries. - Original graphene chains (like Bitshares) uses api_id in "params", while Golos and VIZ uses api name here. + Phase A of the witness -> validator migration: inbound calls using old + method names are translated to new names with a DeprecationWarning. + On a NoSuchMethod error against the new method, the dispatcher falls + back to the old method on `witness_api` and caches the result so + subsequent calls skip the new-name attempt. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # None = unknown; True = node only knows witness_api; False = new names confirmed. + self._uses_legacy_witness_api: bool | None = None def __getattr__(self, name): """Map all methods to RPC calls and pass through the arguments.""" def method(*args, **kwargs): - api = kwargs.get("api", API.get(name)) + # Inbound translation: if caller used a deprecated witness_* name, + # translate to the validator_* equivalent and warn. + canonical_name = API_METHOD_ALIASES.get(name, name) + if canonical_name != name: + warnings.warn( + f"API method '{name}' is deprecated; use '{canonical_name}' instead", + DeprecationWarning, + stacklevel=2, + ) + + api = kwargs.get("api", API.get(canonical_name)) if not api: - raise exceptions.NoSuchAPI(f'Cannot find API for you request "{name}"') + raise exceptions.NoSuchAPI(f'Cannot find API for you request "{canonical_name}"') # Fix wrong api name hardcoded in graphenecommon.TransactionBuilder if api == "network_broadcast": api = "network_broadcast_api" - query = { - "method": "call", - "params": [api, name, list(args)], - "jsonrpc": "2.0", - "id": self.get_request_id(), - } - log.debug(query) - while True: - try: - response = self.rpcexec(query) - message = self.parse_response(response) - except exceptions.ReadLockFail: - pass - else: - break - return message + # If the node is known to only speak witness_api, skip new-name attempt. + if self._uses_legacy_witness_api and canonical_name in _REVERSE_API_METHOD: + return self._call_legacy(canonical_name, list(args)) + + return self._call_with_fallback(api, canonical_name, list(args)) return method + def _call_legacy(self, canonical_name: str, params_args: list) -> object: + old_name = _REVERSE_API_METHOD[canonical_name] + return self._do_call("witness_api", old_name, params_args) + + def _call_with_fallback(self, api: str, canonical_name: str, params_args: list) -> object: + try: + result = self._do_call(api, canonical_name, params_args) + except exceptions.NoSuchMethod: + if canonical_name not in _REVERSE_API_METHOD: + raise + if self._uses_legacy_witness_api is None: + warnings.warn( + "Node responded on witness_api; upgrade recommended", + DeprecationWarning, + stacklevel=4, + ) + self._uses_legacy_witness_api = True + return self._call_legacy(canonical_name, params_args) + else: + if self._uses_legacy_witness_api is None: + self._uses_legacy_witness_api = False + return result + + def _do_call(self, api: str, name: str, params_args: list) -> object: + query = { + "method": "call", + "params": [api, name, params_args], + "jsonrpc": "2.0", + "id": self.get_request_id(), + } + log.debug(query) + while True: + try: + response = self.rpcexec(query) + message = self.parse_response(response) + except exceptions.ReadLockFail: + pass + else: + break + return message + class Websocket(GrapheneWebsocket, Rpc): """ From e34c88f8bde21aa1a148c971932b22904532fbc7 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 19:58:43 +0800 Subject: [PATCH 12/20] Rename viz/witness.py to viz/validator.py with Validator/Validators classes --- tests/test_validator_compat.py | 10 ++++++++ viz/validator.py | 44 ++++++++++++++++++++++++++++++++++ viz/witness.py | 36 ---------------------------- 3 files changed, 54 insertions(+), 36 deletions(-) create mode 100644 viz/validator.py delete mode 100644 viz/witness.py diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index a301671..5dce3be 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -434,3 +434,13 @@ def runner(query): # Second call: skipped new attempt -> 1 call. Total: 3. assert len(calls) == 3 assert calls[2] == ["witness_api", "get_active_witnesses", []] + + +def test_validator_class_importable(): + from graphenecommon.witness import Witness as GrapheneWitness + from graphenecommon.witness import Witnesses as GrapheneWitnesses + + from viz.validator import Validator, Validators + + assert issubclass(Validator, GrapheneWitness) + assert issubclass(Validators, GrapheneWitnesses) diff --git a/viz/validator.py b/viz/validator.py new file mode 100644 index 0000000..ea1f756 --- /dev/null +++ b/viz/validator.py @@ -0,0 +1,44 @@ +from graphenecommon.witness import Witness as GrapheneWitness +from graphenecommon.witness import Witnesses as GrapheneWitnesses + +from .account import Account +from .instance import BlockchainInstance + + +@BlockchainInstance.inject +class Validator(GrapheneWitness): + """ + Read data about a validator in the chain. + + :param str account_name: Name of the validator + :param viz blockchain_instance: Client() instance to use when + accesing a RPC + + .. note:: + Inherits from graphenecommon.witness.Witness. Once graphenecommon + migrates its terminology, this parent can be swapped to the + validator-named equivalent. + """ + + def define_classes(self): + self.account_class = Account + self.type_ids = [6, 2] + + +@BlockchainInstance.inject +class Validators(GrapheneWitnesses): + """ + Obtain a list of **active** validators and the current schedule. + + :param bool only_active: (False) Only return validators that are + actively producing blocks + :param viz blockchain_instance: Client() instance to use when + accesing a RPC + """ + + def define_classes(self): + self.account_class = Account + # graphenecommon contract: parent asserts self.witness_class. + self.witness_class = Validator + # Forward-compat for a future graphenecommon migration. Harmless today. + self.validator_class = Validator diff --git a/viz/witness.py b/viz/witness.py deleted file mode 100644 index 3b4554c..0000000 --- a/viz/witness.py +++ /dev/null @@ -1,36 +0,0 @@ -from graphenecommon.witness import Witness as GrapheneWitness -from graphenecommon.witness import Witnesses as GrapheneWitnesses - -from .account import Account -from .instance import BlockchainInstance - - -@BlockchainInstance.inject -class Witness(GrapheneWitness): - """ - Read data about a witness in the chain. - - :param str account_name: Name of the witness - :param viz blockchain_instance: Client() instance to use when - accesing a RPC - """ - - def define_classes(self): - self.account_class = Account - self.type_ids = [6, 2] - - -@BlockchainInstance.inject -class Witnesses(GrapheneWitnesses): - """ - Obtain a list of **active** witnesses and the current schedule. - - :param bool only_active: (False) Only return witnesses that are - actively producing blocks - :param viz blockchain_instance: Client() instance to use when - accesing a RPC - """ - - def define_classes(self): - self.account_class = Account - self.witness_class = Witness From 47e86e6ea3cfae137b0751b207846590a47cd781 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 20:00:13 +0800 Subject: [PATCH 13/20] Add viz.witness deprecation shim re-exporting from viz.validator --- tests/test_validator_compat.py | 14 ++++++++++++++ viz/witness.py | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 viz/witness.py diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index 5dce3be..30137da 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -444,3 +444,17 @@ def test_validator_class_importable(): assert issubclass(Validator, GrapheneWitness) assert issubclass(Validators, GrapheneWitnesses) + + +def test_viz_witness_shim_emits_warning_and_reexports(): + import sys + + sys.modules.pop("viz.witness", None) + + with pytest.warns(DeprecationWarning, match=r"viz.witness is deprecated"): + import viz.witness # noqa: F401 + + from viz.validator import Validator, Validators + + assert viz.witness.Witness is Validator + assert viz.witness.Witnesses is Validators diff --git a/viz/witness.py b/viz/witness.py new file mode 100644 index 0000000..f5eeab1 --- /dev/null +++ b/viz/witness.py @@ -0,0 +1,22 @@ +""" +Deprecated module: use viz.validator instead. + +This shim re-exports Validator/Validators under their old witness names +to preserve backward compatibility during the witness -> validator +terminology migration. Remove during Phase C cleanup. +""" + +import warnings + +from .validator import Validator, Validators + +warnings.warn( + "viz.witness is deprecated; import from viz.validator instead", + DeprecationWarning, + stacklevel=2, +) + +Witness = Validator +Witnesses = Validators + +__all__ = ["Witness", "Witnesses"] From 87a306be92cb381150a8c4a6e3afb19c8a894a59 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 20:02:18 +0800 Subject: [PATCH 14/20] Update viz/blockchain.py docstring examples and canonicalize filter_by --- tests/test_validator_compat.py | 9 +++++++++ viz/blockchain.py | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index 30137da..41348b3 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -458,3 +458,12 @@ def test_viz_witness_shim_emits_warning_and_reexports(): assert viz.witness.Witness is Validator assert viz.witness.Witnesses is Validators + + +def test_filter_canonicalization_helper(): + from viz.blockchain import _canonical_filter + + assert _canonical_filter("witness_reward") == "validator_reward" + assert _canonical_filter("validator_reward") == "validator_reward" + assert _canonical_filter("transfer") == "transfer" + assert _canonical_filter(None) is None diff --git a/viz/blockchain.py b/viz/blockchain.py index 50a7fc1..9f2c66d 100644 --- a/viz/blockchain.py +++ b/viz/blockchain.py @@ -6,11 +6,19 @@ from graphenecommon.blockchain import Blockchain as GrapheneBlockchain from vizbase import operationids +from vizbase.validator_compat import OP_NAME_ALIASES from .block import Block from .instance import BlockchainInstance +def _canonical_filter(filter_by): + """Translate deprecated witness_* op names to validator_*; pass through others.""" + if filter_by is None: + return None + return OP_NAME_ALIASES.get(filter_by, filter_by) + + @BlockchainInstance.inject class Blockchain(GrapheneBlockchain): """ @@ -138,11 +146,11 @@ def stream( { '_id': 'e2fabb498706edfccd1114921f05d95e8fd64e4c', - 'type': 'witness_reward', + 'type': 'validator_reward', 'timestamp': '2020-05-29T19:07:48', 'block_num': 1, 'trx_id': '0000000000000000000000000000000000000000', - 'witness': 'committee', + 'validator': 'committee', 'shares': '0.032999 SHARES', } @@ -157,7 +165,7 @@ def stream( 'op_in_trx': 0, 'virtual_op': 1, 'timestamp': '2020-05-29T19:28:08', - 'op': ['witness_reward', {'witness': 'committee', 'shares': '0.032999 SHARES'}], + 'op': ['validator_reward', {'validator': 'committee', 'shares': '0.032999 SHARES'}], } @@ -175,6 +183,7 @@ def stream( 'memo': 'test stream', } """ + filter_by = _canonical_filter(filter_by) if filter_by is None: filter_by = [] From 61ccf15c13b3cca84b5237d97026df42b33f6564 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 20:03:21 +0800 Subject: [PATCH 15/20] Make _canonical_filter handle list filter_by inputs --- tests/test_validator_compat.py | 10 ++++++++++ viz/blockchain.py | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index 41348b3..6bb3d37 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -467,3 +467,13 @@ def test_filter_canonicalization_helper(): assert _canonical_filter("validator_reward") == "validator_reward" assert _canonical_filter("transfer") == "transfer" assert _canonical_filter(None) is None + + +def test_filter_canonicalization_helper_handles_list(): + from viz.blockchain import _canonical_filter + + assert _canonical_filter(["witness_reward", "transfer"]) == [ + "validator_reward", + "transfer", + ] + assert _canonical_filter([]) == [] diff --git a/viz/blockchain.py b/viz/blockchain.py index 9f2c66d..fb33db4 100644 --- a/viz/blockchain.py +++ b/viz/blockchain.py @@ -16,7 +16,9 @@ def _canonical_filter(filter_by): """Translate deprecated witness_* op names to validator_*; pass through others.""" if filter_by is None: return None - return OP_NAME_ALIASES.get(filter_by, filter_by) + if isinstance(filter_by, str): + return OP_NAME_ALIASES.get(filter_by, filter_by) + return [OP_NAME_ALIASES.get(name, name) for name in filter_by] @BlockchainInstance.inject From f6d56ed22a46a0a47306413ac81ef84cbe1463e6 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 20:04:11 +0800 Subject: [PATCH 16/20] Update test_blockchain.py to use validator_reward op name --- tests/test_blockchain.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_blockchain.py b/tests/test_blockchain.py index 69ec8d7..7fe3611 100644 --- a/tests/test_blockchain.py +++ b/tests/test_blockchain.py @@ -9,18 +9,18 @@ def blockchain(viz): def test_stream_virtual_ops(blockchain): - stream = blockchain.stream(start_block=1, end_block=2, filter_by="witness_reward") + stream = blockchain.stream(start_block=1, end_block=2, filter_by="validator_reward") for op in stream: - assert op["type"] == "witness_reward" + assert op["type"] == "validator_reward" assert "shares" in op def test_stream_virtual_ops_raw_output(blockchain): - stream = blockchain.stream(start_block=1, end_block=2, filter_by="witness_reward", raw_output=True) + stream = blockchain.stream(start_block=1, end_block=2, filter_by="validator_reward", raw_output=True) for op in stream: print(op) assert "block" in op - assert op["op"][0] == "witness_reward" + assert op["op"][0] == "validator_reward" def test_stream_real_ops(blockchain, viz, default_account): From 90d54f95831bd8c9353cfeb2ca73cd8cc66d41c8 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 20:05:23 +0800 Subject: [PATCH 17/20] Update viz.py TODO comments: witness -> validator --- viz/viz.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/viz/viz.py b/viz/viz.py index c4679ff..a3613f1 100644 --- a/viz/viz.py +++ b/viz/viz.py @@ -742,14 +742,14 @@ def _store_keys(self, *args): pass # TODO: Methods to implement: - # - witness_update + # - validator_update # - chain_properties_update # - allow / disallow # - update_memo_key - # - approve_witness / disapprove_witness + # - approve_validator / disapprove_validator # - account_metadata # - proposal_create / proposal_update / proposal_delete - # - witness_proxy + # - validator_proxy # - recover-related methods # - escrow-related methods # - worker create / cancel / vote From ce44d0533141b35f49b191fd03fa7406a33723ae Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 20:07:06 +0800 Subject: [PATCH 18/20] Add parametrized coverage drift tests across all alias dicts --- tests/test_validator_compat.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_validator_compat.py b/tests/test_validator_compat.py index 6bb3d37..5707074 100644 --- a/tests/test_validator_compat.py +++ b/tests/test_validator_compat.py @@ -477,3 +477,23 @@ def test_filter_canonicalization_helper_handles_list(): "transfer", ] assert _canonical_filter([]) == [] + + +@pytest.mark.parametrize("old,new", list(OP_NAME_ALIASES.items())) +def test_every_op_alias_resolves(old, new): + from vizbase.operationids import operations + + assert operations[old] == operations[new] + + +@pytest.mark.parametrize("old,new", list(API_METHOD_ALIASES.items())) +def test_every_api_alias_in_reverse_map(old, new): + from vizapi.noderpc import _REVERSE_API_METHOD + + assert _REVERSE_API_METHOD[new] == old + + +@pytest.mark.parametrize("old,new", list(CHAIN_PROPS_FIELD_ALIASES.items())) +def test_every_chain_props_alias_translatable(old, new): + out = translate_kwargs({old: 1}, CHAIN_PROPS_FIELD_ALIASES, context="ctx") + assert out == {new: 1} From dc60e5600aef5d253422ff78f6f8ad550748dc4f Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 21:16:34 +0800 Subject: [PATCH 19/20] Mark test_versioned_chain_properties_update xfail until testnet image is rebuilt with validator-renamed schema --- tests/test_serialization.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index da89a41..e5a712b 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -49,6 +49,10 @@ def test_transfer(self): self.do_test(op) + @pytest.mark.xfail( + reason="testnet image pr-85-merge predates witness->validator rename; awaiting vizd:latest rebuild", + strict=False, + ) def test_versioned_chain_properties_update(self): props = { "account_creation_fee": "1.000 VIZ", From e16ccef14a7227add4318a4907d1f9dc8796ab29 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 19 May 2026 21:25:02 +0800 Subject: [PATCH 20/20] ci: dedupe tests workflow triggers Run on push only for master (post-merge validation) and on pull_request for all PRs. Add concurrency group so new pushes to a PR branch cancel any in-flight run, eliminating the testnet-Docker port collisions caused by the previous push+pull_request double-fire. --- .github/workflows/tests.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bda2f6d..75b33c4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,13 @@ name: tests -on: [push, pull_request] +on: + push: + branches: [master] + pull_request: + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build: