From 2730eaf9c3a87f162392fb91f56bb2ac4c245709 Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Thu, 22 Oct 2026 12:41:56 -0400 Subject: [PATCH 1/4] authex: test the SVM createlink sweep; document import-before-link CertiK WNS-63 (WIRE-413) is fixed on master by #594 (links sweep unmapped credits), #639 (credits never expire) and #670 (onreward retired). The SVM createlink sweep had no unit test; the new one also checks that a credit sharing the key's 8-byte bychainad prefix stays put. A link sweeps once and createlink does not check that the DClaim import is sealed, so the upgrade-order doc now says to finish the import before the first link. Change-Id: I50e69a120633181dd3d9e0dd100c3e5a663af844 --- contracts/tests/sysio.authex_tests.cpp | 31 ++++++++++++++++++++++++++ docs/contract-upgrade-order.md | 7 ++++++ 2 files changed, 38 insertions(+) diff --git a/contracts/tests/sysio.authex_tests.cpp b/contracts/tests/sysio.authex_tests.cpp index 84df19eac5..55b8569f3a 100644 --- a/contracts/tests/sysio.authex_tests.cpp +++ b/contracts/tests/sysio.authex_tests.cpp @@ -30,6 +30,7 @@ using mvo = fc::mutable_variant_object; using ChainKind = sysio::opp::types::ChainKind; using sysio_system::test_support::build_link_message; +using sysio_system::test_support::sign_createlink; // ——— Tester class ——— class sysio_authex_tester : public tester { @@ -342,6 +343,36 @@ BOOST_FIXTURE_TEST_CASE( createlink_preserves_old_prelink_dclaim_rewards, BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 5000); } FC_LOG_AND_RETHROW() +BOOST_FIXTURE_TEST_CASE( createlink_svm_sweeps_prelink_dclaim_rewards, sysio_authex_tester ) try { + deploy_dclaim(); + const auto private_key = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::ed); + const auto public_key = private_key.get_public_key(); + const auto raw_key = public_key.get().serialize(); + const std::vector native_address(raw_key.begin(), raw_key.end()); + // Same first 8 bytes, hence the same `bychainad` key: the sweep must match the full address. + std::vector same_prefix_address = native_address; + same_prefix_address.back() ^= char{0x01}; + + BOOST_REQUIRE_EQUAL(success(), import_credit( + same_prefix_address, 3000, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE_EQUAL(success(), import_credit( + native_address, 8000, ChainKind::CHAIN_KIND_SVM)); + + const auto nonce = now_ms(); + const auto sig = sign_createlink(private_key, "carol", ChainKind::CHAIN_KIND_SVM, nonce); + BOOST_REQUIRE_EQUAL(success(), createlink( + "carol"_n, ChainKind::CHAIN_KIND_SVM, "carol", sig, public_key, nonce)); + produce_blocks(); + + BOOST_REQUIRE(get_dclaim_row("unmapped"_n, "unmapped_token", 2).is_null()); + const auto same_prefix = get_dclaim_row("unmapped"_n, "unmapped_token", 1); + BOOST_REQUIRE(!same_prefix.is_null()); + BOOST_REQUIRE_EQUAL(same_prefix["balance"].as().get_amount(), 3000); + const auto pending = get_dclaim_row("pclaims"_n, "pending_claim", "carol"_n.to_uint64_t()); + BOOST_REQUIRE(!pending.is_null()); + BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 8000); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( recordlink_records_link_when_dclaim_is_missing, sysio_authex_tester ) try { const auto public_key = fc::crypto::private_key::generate( fc::crypto::private_key::key_type::em).get_public_key(); diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 3e5555a818..4bfa23267f 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -155,6 +155,13 @@ Production deployment through `sysio.roa::setsyscode` privileges `sysio.dclaim` as part of the deploy, so there is no separate privilege step. The durable precondition is ordering: deploy DClaim before any link action can execute. +Finish the pre-launch import (`importseed`, then `importdone`) before the first +`createlink` or `recordlink` as well. A link sweeps only the credits already in +`unmapped`, and its holder cannot trigger the sweep again: `createlink` accepts +one link per account and chain, and only `sysio.authex` may send `linkswept`. A +credit imported after its key is linked stays in `unmapped`, still owed, until +an operator sends `linkswept` for it as `sysio.authex`. + The two added address fields are intentionally not a staged compatibility protocol. Activate WIRE-352 in a quiesced maintenance window: From bb79c424f621afa8f02fc818b6c986538b9806b0 Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Thu, 22 Oct 2026 19:45:55 -0400 Subject: [PATCH 2/4] dclaim: remove the stale import converter; document governance-only link recovery convert_import.py emitted CHAIN_KIND_ETHEREUM/SOLANA, which the DClaim ABI rejects, scaled Solana pretokens as 9-decimal (they are 8), and produced an empty import from the current Solana export. The LCO import in wire-tools-ts replaces it. The upgrade-order doc now orders fund, import, reconcile and seal before the first link. A credit imported after its key was linked is recovered by governance through sysio.msig, because linkswept does not check the account against the link; the recordlink row and the authex comments say the same. Change-Id: I0616c8d8f35928ac2acd95c92ad1f411b9d14281 --- .../include/sysio.authex/sysio.authex.hpp | 4 +- contracts/sysio.authex/src/sysio.authex.cpp | 2 +- .../sysio.dclaim/tools/convert_import.py | 200 ------------------ docs/contract-upgrade-order.md | 21 +- 4 files changed, 17 insertions(+), 210 deletions(-) delete mode 100755 contracts/sysio.dclaim/tools/convert_import.py diff --git a/contracts/sysio.authex/include/sysio.authex/sysio.authex.hpp b/contracts/sysio.authex/include/sysio.authex/sysio.authex.hpp index 5df12b118f..264f75a638 100644 --- a/contracts/sysio.authex/include/sysio.authex/sysio.authex.hpp +++ b/contracts/sysio.authex/include/sysio.authex/sysio.authex.hpp @@ -285,8 +285,8 @@ namespace sysio { * A malformed or mismatched address skips only the sweep, not link insertion. A missing or * non-privileged sysio.dclaim deployment likewise skips the sweep. An identical recordlink can * retry before node-owner registration completes; after registration, nodeownreg's duplicate - * gate returns before this action, so remediation requires an operator-pushed recordlink or - * linkswept action. + * gate returns before this action, so remediation requires governance to push recordlink or + * linkswept as sysio.authex@active. * * Unlike createlink, this does NOT enforce a unique `pub_key`: one external wallet may hold * several WireNodes NFTs and back several Wire accounts, so one ETH key -> many accounts is diff --git a/contracts/sysio.authex/src/sysio.authex.cpp b/contracts/sysio.authex/src/sysio.authex.cpp index d20d12411f..5c2a26072a 100644 --- a/contracts/sysio.authex/src/sysio.authex.cpp +++ b/contracts/sysio.authex/src/sysio.authex.cpp @@ -59,7 +59,7 @@ void send_linked_rewards_sweep(const name self, const name account, * Best-effort sweep for the trusted inbound path. * * A missing or non-privileged DClaim deployment cannot safely bill new rows to sysio. Preserve the - * recorded link so an identical operator-authorized recordlink can retry after bootstrap. + * recorded link so governance can retry with an identical recordlink after bootstrap. */ void try_send_linked_rewards_sweep(const name self, const name account, const opp::types::ChainKind chain_kind, diff --git a/contracts/sysio.dclaim/tools/convert_import.py b/contracts/sysio.dclaim/tools/convert_import.py deleted file mode 100755 index 6e8ef910a1..0000000000 --- a/contracts/sysio.dclaim/tools/convert_import.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -"""Convert the indexer JSON dump into sysio.dclaim::importseed action batches. - -Live sources: - ETH: curl -H 'x-api-key: ' https://index.wire.foundation/opp/balances - SOL: curl -H 'x-api-key: ' https://index.wire.foundation/opp/solana/balances - -Schema (verified 2026-05-13): - metadata bookkeeping; not consumed by the contract. Notable fields: - generatedAt, totalMessages, yieldDust (ETH; not present on SOL). - purchasers[] {address, totalPretokens, yieldClaimed?, ...} - owed = totalPretokens (already net of yieldClaimed; field - absent on SOL). - stakers[] {address, pretokenYield, yieldClaimed?, ...} - owed = pretokenYield - yieldClaimed (yieldClaimed absent - on SOL -> defaults to 0). - -A given address may appear in both arrays (~9 such addresses in current -SOL snapshot); contributions sum per address. - -Per-chain conventions: - CHAIN_KIND_ETHEREUM - address 0x-prefixed lowercase hex, 20 raw bytes - source 18 decimals (wei-style) - divisor 10^9 (source 1e18 -> WIRE atomic 1e9) - CHAIN_KIND_SOLANA - address base58 (case-sensitive), 32 raw bytes - source 9 decimals (lamport-style; same as WIRE atomic) - divisor 1 (no scaling needed) - -Per-address conversion: - total = sum(purchaser.totalPretokens) - + sum(staker.pretokenYield - staker.yieldClaimed) - wire_atomic = total // divisor (floor; drop sub-atomic dust) - -Rows with wire_atomic == 0 are filtered. Output is a JSON array of -importseed action arg objects, each batched up to --batch-size credits -per call. `native_address` is emitted as the hex spelling of the raw -bytes (no 0x prefix), which the sysio.dclaim ABI consumes as `bytes`. - -Batching: default is 10,000 credits per trx, which fits well inside -the 150ms execution / 500KB transaction-size envelope. At launch -scale (under ~50K users per chain) this typically produces a single -batch; importseed accrues into `unmapped_tokens` with zero inline -transfers, so the only cost driver is the per-row table write. -Operators only need to lower --batch-size if a trx hits the size or -execution limit at very high user counts. - -Usage: - ./convert_import.py eth_balances.json --chain CHAIN_KIND_ETHEREUM > eth.json - ./convert_import.py sol_balances.json --chain CHAIN_KIND_SOLANA > sol.json -""" - -import argparse -import json -import sys -from collections import defaultdict -from pathlib import Path -from typing import Callable - - -# --------------------------------------------------------------------------- -# Base58 decoder (Bitcoin / Solana alphabet). Stdlib has no base58. -# --------------------------------------------------------------------------- -B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" -B58_INDEX = {c: i for i, c in enumerate(B58_ALPHABET)} - - -def base58_decode(s: str) -> bytes: - n = 0 - for c in s: - if c not in B58_INDEX: - raise ValueError(f"invalid base58 character: {c!r}") - n = n * 58 + B58_INDEX[c] - leading_ones = len(s) - len(s.lstrip("1")) - payload = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b"" - return b"\x00" * leading_ones + payload - - -def eth_decode(addr: str) -> bytes: - return bytes.fromhex(addr.lower().removeprefix("0x")) - - -# --------------------------------------------------------------------------- -# Chain configuration -# --------------------------------------------------------------------------- -CHAIN_CONFIG = { - "CHAIN_KIND_ETHEREUM": { - "decoder": eth_decode, - "addr_len": 20, - "divisor": 10**9, # source 1e18 -> WIRE atomic 1e9 - }, - "CHAIN_KIND_SOLANA": { - "decoder": base58_decode, - "addr_len": 32, - "divisor": 1, # source 1e9 already matches WIRE atomic - }, -} - - -# --------------------------------------------------------------------------- -# Pipeline -# --------------------------------------------------------------------------- -def parse_args() -> argparse.Namespace: - ap = argparse.ArgumentParser( - description="Emit sysio.dclaim::importseed batches from the indexer JSON.", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__, - ) - ap.add_argument("input", type=Path, - help="Indexer JSON file (ETH or SOL /opp/[solana/]balances shape)") - ap.add_argument("--batch-size", type=int, default=10_000, - help="Credits per importseed call (default: 10000; " - "fits the 150ms/500KB trx envelope with headroom). " - "Lower this only if you hit a trx size or execution limit.") - ap.add_argument("--chain", default="CHAIN_KIND_ETHEREUM", - choices=sorted(CHAIN_CONFIG.keys()), - help="ChainKind enum name (default: CHAIN_KIND_ETHEREUM)") - return ap.parse_args() - - -def accumulate(data: dict, decoder: Callable[[str], bytes], addr_len: int) -> dict[bytes, int]: - """Return {raw_address_bytes: total_pretokens_owed}. - - Addresses appearing in both `purchasers` and `stakers` sum together. - The accumulator is keyed by decoded raw bytes so that case / format - normalization happens exactly once at decode time. - """ - acc: dict[bytes, int] = defaultdict(int) - - def addr_bytes(s: str) -> bytes: - b = decoder(s) - if len(b) != addr_len: - raise ValueError(f"address {s!r} decoded to {len(b)} bytes, expected {addr_len}") - return b - - for row in data.get("purchasers") or []: - acc[addr_bytes(row["address"])] += int(row["totalPretokens"]) - for row in data.get("stakers") or []: - owed = int(row["pretokenYield"]) - int(row.get("yieldClaimed", "0")) - if owed > 0: - acc[addr_bytes(row["address"])] += owed - return acc - - -def to_credits(accumulator: dict[bytes, int], divisor: int) -> tuple[list[dict], int]: - """Floor each total at `divisor`; return (credits, dropped_dust_total).""" - credits = [] - dropped_dust = 0 - for addr_bytes, total in sorted(accumulator.items()): - atomic, dust = divmod(total, divisor) - dropped_dust += dust - if atomic <= 0: - continue - credits.append({"native_address": addr_bytes.hex(), "wire_atomic": atomic}) - return credits, dropped_dust - - -def batched(credits: list[dict], chain: str, batch_size: int) -> list[dict]: - return [ - {"chain": chain, "credits": credits[i:i + batch_size]} - for i in range(0, len(credits), batch_size) - ] - - -def main() -> int: - args = parse_args() - cfg = CHAIN_CONFIG[args.chain] - - data = json.loads(args.input.read_text()) - accumulator = accumulate(data, cfg["decoder"], cfg["addr_len"]) - credits, dropped_dust = to_credits(accumulator, cfg["divisor"]) - batches = batched(credits, args.chain, args.batch_size) - - json.dump(batches, sys.stdout, indent=2) - sys.stdout.write("\n") - - total_atomic = sum(c["wire_atomic"] for c in credits) - meta = data.get("metadata") or {} - lines = [ - f"input: {args.input}", - f"chain: {args.chain}", - f"generatedAt: {meta.get('generatedAt', '')}", - f"totalMessages: {meta.get('totalMessages', '')}", - f"indexer yieldDust: {meta.get('yieldDust', '')}" - " (indexer-side ledger; ETH only)", - f"unique addresses: {len(accumulator)}", - f"non-zero credits: {len(credits)}", - f"batches: {len(batches)} (size {args.batch_size})", - f"total credited: {total_atomic} atomic WIRE" - f" ({total_atomic / 10**9:.6f} WIRE)", - f"dropped dust: {dropped_dust} sub-atomic units" - f" (divisor {cfg['divisor']})", - ] - print(*lines, sep="\n", file=sys.stderr) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 4bfa23267f..547be44d90 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -138,7 +138,7 @@ WIRE-352 adds separate inline edges whose failure behavior depends on the caller | `sysio.msgch` node-owner dispatch | `sysio.roa::nodeownreg` | Deploy `sysio.msgch` and `sysio.roa` as one compatibility-coupled set. | WIRE-352 adds the required `eth_address` field. An old caller underflows the new action decoder; the inverse pairing is also unsupported. Node-owner delivery can abort. | | `sysio.roa::nodeownreg` | `sysio.authex::recordlink` | Deploy `sysio.roa` and `sysio.authex` as the same compatibility-coupled set. | WIRE-352 adds the required `native_address` field. An old caller underflows the new action decoder; the inverse pairing is also unsupported. Node-owner delivery can abort. | | `sysio.authex::createlink` | `sysio.dclaim::linkswept` | Deploy `sysio.dclaim` before the first user-created external-key link. | A missing or non-privileged callee aborts before link insertion. Any inline failure rolls the transaction back, so the user can submit a fresh retry. | -| `sysio.authex::recordlink` | `sysio.dclaim::linkswept` | Prefer deploying `sysio.dclaim` before trusted node-owner dispatch begins. | A missing or non-privileged callee skips the sweep but preserves the trusted link; an identical operator-authorized `recordlink` can retry it after bootstrap. | +| `sysio.authex::recordlink` | `sysio.dclaim::linkswept` | Prefer deploying `sysio.dclaim` before trusted node-owner dispatch begins. | A missing or non-privileged callee skips the sweep but preserves the trusted link; governance can retry it after bootstrap with an identical `recordlink` submitted as `sysio.authex@active`. | | `sysio.msgch` inbound envelope dispatch | `sysio.synd::onsynd`, `sysio.synd::onyield`, `sysio.synd::closeenv` | Deploy `sysio.msgch`, `sysio.synd`, `sysio.liq`, `sysio.bond` and `sysio.authex` as one compatibility-coupled set, and deploy `sysio.synd` before any liq token is activated. | The actions run inside the consensus transaction that accepts the envelope. A missing `sysio.synd`, or one without these actions, asserts in its dispatcher and aborts that transaction, so no envelope of the outpost is accepted until the set is whole. | | `sysio.synd` intake and queue | `sysio.liq::mint`, `sysio.liq::burn`, `sysio.liq::mintyield` | The same set. | `sysio.synd` verifies every condition `mint` checks before sending it, so the current `sysio.liq` never refuses it. A `sysio.liq` without these actions, or one that does not accept `sysio.synd` as their only signer, aborts the intake and with it the inbound envelope. | | `sysio.authex::createlink` | `sysio.synd::linkswept` | None. Nothing is parked before `sysio.synd` is deployed, and it deploys privileged through `setsyscode`. | A missing or non-privileged callee skips the sweep and keeps the link; the permissionless `sysio.synd::sweep` delivers the parked shadow later. The delivery's `sysio.liq::transfer` notifies the linking account, so a contract account whose transfer handler throws aborts its own `createlink` and cannot link while shadow is parked for its key. | @@ -155,12 +155,19 @@ Production deployment through `sysio.roa::setsyscode` privileges `sysio.dclaim` as part of the deploy, so there is no separate privilege step. The durable precondition is ordering: deploy DClaim before any link action can execute. -Finish the pre-launch import (`importseed`, then `importdone`) before the first -`createlink` or `recordlink` as well. A link sweeps only the credits already in -`unmapped`, and its holder cannot trigger the sweep again: `createlink` accepts -one link per account and chain, and only `sysio.authex` may send `linkswept`. A -credit imported after its key is linked stays in `unmapped`, still owed, until -an operator sends `linkswept` for it as `sysio.authex`. +On a fresh deployment, finish the DClaim import before the first `createlink` or +`recordlink`: fund `sysio.dclaim` with the total of the credits, push every +`importseed` batch, reconcile the `unmapped` rows against the export, and only +then seal the import with `importdone`. The LCO import in the cluster bootstrap +(`LcoImportSteps` in wire-tools-ts) runs these steps in the epoch-0 window and +stops before the seal when a row differs. A link sweeps only the credits already +in `unmapped`, and its holder cannot trigger the sweep again: `createlink` +accepts one link per account and chain, and only `sysio.authex` may send +`linkswept`. `linkswept` does not check the account against the link, so +recovering a credit imported after its key was linked is for governance: it +submits `linkswept` as `sysio.authex@active` through `sysio.msig`, with the +account, chain and address taken from the existing link. For a node-owner link, +governance can instead replay the identical `recordlink`. The two added address fields are intentionally not a staged compatibility protocol. Activate WIRE-352 in a quiesced maintenance window: From 2a36e122ec5f035d043bfd70c2d3a2dff2e55b5e Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Fri, 23 Oct 2026 10:49:57 -0400 Subject: [PATCH 3/4] docs: derive and check the native address before a governance link recovery The links row keeps only the account, chain and public key, so the recovery paragraph now has governance derive the address from the stored key (SVM: the raw ED key; EVM: the 20-byte address of the EM key) and check the link and the exact unmapped row before proposing linkswept or replaying recordlink. Master is merged in. Change-Id: I23ce4dd13217de54b7864b3b5cb1a0677be3eeda --- docs/contract-upgrade-order.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 547be44d90..00b3b67669 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -163,11 +163,16 @@ then seal the import with `importdone`. The LCO import in the cluster bootstrap stops before the seal when a row differs. A link sweeps only the credits already in `unmapped`, and its holder cannot trigger the sweep again: `createlink` accepts one link per account and chain, and only `sysio.authex` may send -`linkswept`. `linkswept` does not check the account against the link, so -recovering a credit imported after its key was linked is for governance: it -submits `linkswept` as `sysio.authex@active` through `sysio.msig`, with the -account, chain and address taken from the existing link. For a node-owner link, -governance can instead replay the identical `recordlink`. +`linkswept`. `linkswept` moves the `unmapped` row matching the supplied chain and +address to the supplied account without consulting the link, so recovering a +credit imported after its key was linked is for governance, through `sysio.msig` +as `sysio.authex@active`. The `links` row keeps only the account, chain and +public key, not the native address, so governance derives the address from the +stored key (SVM: the raw 32-byte ED key; EVM: the canonical 20-byte address of +the stored EM key, the last 20 bytes of keccak256 of its uncompressed form) and +checks the link and the exact target `unmapped` row before proposing +`linkswept`. The same derivation and check come first when governance replays +the identical `recordlink` for a node-owner link. The two added address fields are intentionally not a staged compatibility protocol. Activate WIRE-352 in a quiesced maintenance window: From 2944e9027d0c8ac26616ef22b15a40836143a276 Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Fri, 23 Oct 2026 11:15:34 -0400 Subject: [PATCH 4/4] docs: hash X || Y, not the SEC1-prefixed key, for the EVM recovery address The EVM address is the trailing 20 bytes of keccak256 over the 64-byte coordinates; hashing the 65-byte key with its SEC1 0x04 prefix gives another address, and the recovery would miss its unmapped row. The doc now says so and names opp::evm_address_from_uncompressed_key. Master is merged in. Change-Id: Icbea2d71f4e36f141a074228ed259e535a6225ae --- docs/contract-upgrade-order.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 00b3b67669..99034ac4ca 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -169,10 +169,12 @@ credit imported after its key was linked is for governance, through `sysio.msig` as `sysio.authex@active`. The `links` row keeps only the account, chain and public key, not the native address, so governance derives the address from the stored key (SVM: the raw 32-byte ED key; EVM: the canonical 20-byte address of -the stored EM key, the last 20 bytes of keccak256 of its uncompressed form) and -checks the link and the exact target `unmapped` row before proposing -`linkswept`. The same derivation and check come first when governance replays -the identical `recordlink` for a node-owner link. +the stored EM key, the trailing 20 bytes of `keccak256(X || Y)` over the 64-byte +coordinates, without the leading SEC1 `0x04` byte, as +`opp::evm_address_from_uncompressed_key` computes it) and checks the link and the +exact target `unmapped` row before proposing `linkswept`. The same derivation and +check come first when governance replays the identical `recordlink` for a +node-owner link. The two added address fields are intentionally not a staged compatibility protocol. Activate WIRE-352 in a quiesced maintenance window: