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/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..99034ac4ca 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,6 +155,27 @@ 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. +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` 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 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: