diff --git a/CLAUDE.md b/CLAUDE.md index 8b86547bf5..716eeec67c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -399,6 +399,8 @@ cd $BUILD_DIR && python3 tests/.py ``` Do NOT run from the source root — that would use stale or missing binaries. +Review [contract upgrade order](docs/contract-upgrade-order.md) when changing inline calls between system contracts; unchanged ABIs do not guarantee mixed-version compatibility. + ## Smart Contract Compilation (System Contracts specifically) The system contracts are compiled via the `contracts_project` CMake target. diff --git a/README.md b/README.md index 3221f6c1fd..d3f64ae03e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Wire Sysio is a fork of Spring, a C++ implementation of the [AntelopeIO](https://github.com/AntelopeIO) protocol. It contains blockchain node software and supporting tools for developers and node operators. +Resource allocation and node-owner registration are described in [the ROA overview](docs/roa-overview.md). + ## Branches The `master` branch is the latest stable branch. diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index 5a08492e37..3d706a9456 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -585,16 +585,13 @@ std::optional em_identity_from_uncompressed_key(const std::vector& data, uint64_t chain_code) auto eth_identity = em_identity_from_uncompressed_key(reg.actor_pub_key); if (!eth_identity) return; // unusable depositor ETH key; drop - // 1) Create the account (idempotent; soft-skips a name that breaks the tier rule). - action(permission_level{ROA_ACCOUNT, "active"_n}, ROA_ACCOUNT, "newnameduser"_n, - std::make_tuple(*owner, *wire_pk, tier)).send(); - - // 2) Register the owner + record the ETH link, with claim-payload soft-fail + audit recording. + // ROA owns the nonexistence check and account creation within the same claim. action(permission_level{ROA_ACCOUNT, "active"_n}, ROA_ACCOUNT, "nodeownreg"_n, std::make_tuple(*owner, tier, eth_identity->key, *wire_pk, eth_identity->address)).send(); } diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index fbf2d55f79..85f86d6533 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ diff --git a/contracts/sysio.roa/sysio.roa.abi b/contracts/sysio.roa/sysio.roa.abi index 578068bd26..01ddb61d17 100644 --- a/contracts/sysio.roa/sysio.roa.abi +++ b/contracts/sysio.roa/sysio.roa.abi @@ -253,6 +253,20 @@ } ] }, + { + "name": "nodeownfin", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "tier", + "type": "uint8" + } + ] + }, { "name": "nodeownreg", "base": "", @@ -575,6 +589,11 @@ "type": "newuser", "ricardian_contract": "" }, + { + "name": "nodeownfin", + "type": "nodeownfin", + "ricardian_contract": "" + }, { "name": "nodeownreg", "type": "nodeownreg", diff --git a/contracts/sysio.roa/sysio.roa.cpp b/contracts/sysio.roa/sysio.roa.cpp index 3212711c1b..ed470b3dff 100644 --- a/contracts/sysio.roa/sysio.roa.cpp +++ b/contracts/sysio.roa/sysio.roa.cpp @@ -6,7 +6,6 @@ #include #include #include // add_sat_u64 / add_sat_i64 -- never-throw saturating accumulators -#include // get_permission -- read an account's active authority in nodeownreg #include #include @@ -19,6 +18,8 @@ namespace sysio { // literals -- a contract rename is one change here, not scattered across call sites). constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; constexpr name AUTHEX_RECORDLINK = "recordlink"_n; + /// Claim allocation continuation, queued after native account creation. + constexpr name ROA_NODEOWNFIN = "nodeownfin"_n; /// Names under this prefix belong to system accounts. The chain refuses them only to non-privileged /// creators, and sysio.roa is privileged, so node-owner claims must refuse them here. @@ -751,8 +752,8 @@ namespace sysio { void roa::forcereg(const name& owner, const uint8_t& tier) { // Privileged bootstrap/test registration -- see the header doc. Governance/system-only - // (require_auth self); bypasses the OPP claim path (nodeownreg). Beyond the tier-range guard - // it does no name-rule / active-key / authex validation and writes no nodeownerreg audit row + // (require_auth self); separate from the OPP claim path. Beyond the tier-range guard + // it does no name-rule / new-account / authex validation and writes no nodeownerreg audit row // -- it just runs the shared tier allocator. require_auth(get_self()); @@ -764,13 +765,8 @@ namespace sysio { void roa::nodeownreg(const name& owner, const uint8_t& tier, const public_key& eth_pub_key, const public_key& wire_pub_key, const bytes& eth_address) { - // Dispatched by the OPP depot (sysio.msgch) when it processes an inbound - // ATTESTATION_TYPE_NODE_OWNER_REG attestation. msgch inline-sends newnameduser (account - // create) and then this action, both declaring permission_level{sysio.roa, active}. - // Privileged sysio.msgch may declare that target permission without a cross-contract active - // grant, so deployment must preserve msgch's privileged status. Inline actions run - // depth-first, so any newaccount has executed before this runs. Invalid names and full - // tiers skip creation; this action records their rejection instead. + // One authenticated depot claim owns creation and registration. Existing accounts are + // never eligible, even when the payload copies their public active key. require_auth(get_self()); // ---- Envelope / system invariants (depot misuse) ---- @@ -798,48 +794,28 @@ namespace sysio { // ---- Claim-payload checks (trust-OPP soft-fail) ---- // The OPP envelope can be well-formed while the claim inside it is bad (the name the user - // chose is invalid, names an account they do not control, or is a replay). Aborting the + // chose is invalid, names an existing account, or is a replay). Aborting the // dispatching transaction would be bad UX, leave no audit row, and give the batch operator // undefined retry semantics. Instead record a REJECTED row with a reason and return, so the // transaction commits and the failure is queryable on Wire (no outbound error attestation). - // (1) the chosen name must be valid for the tier (newnameduser skipped creation otherwise). if (!valid_name_for_tier(owner, tier)) { record_nodereg(owner, tier, REJECTED, NAME_INVALID, gen); return; } - // (2) newnameduser skips creation at capacity to avoid spending sysio's RAM on a rejected - // claim. Otherwise a missing account means the creation step did not occur. - if (!is_account(owner)) { - const auto reason = nodeowner_count(get_self(), gen, tier) >= nodeowner_cap(tier) - ? TIER_CAP_REACHED : OWNER_NOT_ACCOUNT; - record_nodereg(owner, tier, REJECTED, reason, gen); + // Preserve the duplicate outcome for an already registered owner. + nodeowners_t nodeowners(get_self(), gen); + if (nodeowners.contains(nodeowner_key{owner.value})) { + record_nodereg(owner, tier, REJECTED, DUPLICATE, gen); return; } - - // (3) the account must be controlled by exactly the claimed wire key. For a just-created - // account this is the key newnameduser set; for a pre-existing account it proves the claimant - // controls it -- an NFT cannot be claimed into an account someone else holds. - if (!active_key_matches(owner, wire_pub_key)) { - record_nodereg(owner, tier, REJECTED, ACCOUNT_KEY_MISMATCH, gen); + if (is_account(owner)) { + record_nodereg(owner, tier, REJECTED, ACCOUNT_EXISTS, gen); return; } - - // (4) must not already be a registered node owner -- a replay / double-claim. Record - // DUPLICATE and return so the claim is idempotent and auditable. - { - nodeowners_t nodeowners(get_self(), gen); - if (nodeowners.contains(nodeowner_key{owner.value})) { - record_nodereg(owner, tier, REJECTED, DUPLICATE, gen); - return; - } - } - - // (5) the account must not already carry a *different* EVM link than this claim. recordlink is - // idempotent per (account, chain) and would silently keep the existing key, so a claim with a - // new depositor key would otherwise record CONFIRMED against a stale link. Detect that here and - // soft-fail rather than overwrite or mis-record. + // A trusted import may have recorded a link before account creation. Never confirm + // against a different depositor identity; recordlink intentionally keeps an existing link. { authex::links_t links(AUTHEX_ACCOUNT); auto by_namechain = links.get_index<"bynamechain"_n>(); @@ -849,22 +825,16 @@ namespace sysio { return; } } - - // (6) capacity exhaustion is a terminal claim outcome, not a malformed envelope. Soft-fail - // so another claim in the same consensus envelope cannot roll back every registration and - // leave batch operators retrying the deterministically failing envelope forever. if (nodeowner_count(get_self(), gen, tier) >= nodeowner_cap(tier)) { record_nodereg(owner, tier, REJECTED, TIER_CAP_REACHED, gen); return; } - // (7) a pre-existing reslimit row no longer blocks registration. An attacker can plant one on - // any account via addpolicy (no target consent), which previously forced this claim into a - // permanent OWNER_HAS_RESLIMIT soft-fail -- a valid registration could be griefed indefinitely. - // regnodeowner now reconciles instead: it stacks the node-owner allocation onto the existing - // row via increase_reslimit rather than creating from scratch. The planted policy stays backed - // by its issuer's reserve and remains independently reclaimable via reducepolicy; no value is - // stolen and the claim reaches CONFIRMED. (SEC-087) + // Allocation must run after the queued native newaccount. All effects, including the + // terminal audit below, commit together or roll back if an inline action fails. + newnameduser(owner, wire_pub_key, tier); + action(permission_level{get_self(), "active"_n}, get_self(), ROA_NODEOWNFIN, + std::make_tuple(owner, tier)).send(); // Record the depositor's ETH key as a sysio.authex link via the trusted depot-only path. // recordlink requires sysio.authex.active; privileged sysio.roa may declare that permission @@ -875,10 +845,16 @@ namespace sysio { std::make_tuple(owner, opp::types::ChainKind::CHAIN_KIND_EVM, eth_pub_key, eth_address)).send(); - regnodeowner(owner, tier); record_nodereg(owner, tier, CONFIRMED, NONE, gen); }; + void roa::nodeownfin(const name& owner, const uint8_t& tier) { + require_auth(get_self()); + check(get_sender() == get_self(), "nodeownfin must be sent inline by sysio.roa"); + check(is_account(owner), "claim account was not created"); + regnodeowner(owner, tier); + } + // ---- Private Helper Function ---- void roa::record_nodereg(const name& owner, uint8_t tier, uint8_t status, uint8_t reason, @@ -911,21 +887,6 @@ namespace sysio { return len >= 1 && len <= 12; } - bool roa::active_key_matches(const name& account, const public_key& key) { - // The claim is valid iff `key` can satisfy the account's `active` authority by itself: it - // appears among the keys with weight >= threshold. We deliberately do NOT require a single-key - // authority or an empty `accounts` list -- a standard account also carries an - // @sysio.code entry (benign: it grants the account's own contract inline-action - // rights, not external control), and newnameduser-created accounts have neither. A claim into - // an account a different key controls still fails: that key won't be among `auth.keys`. - auto perm = get_permission(account, "active"_n); - if (!perm.has_value() || perm->auth.threshold == 0) return false; - for (const auto& kw : perm->auth.keys) { - if (kw.key == key && kw.weight >= perm->auth.threshold) return true; - } - return false; - } - uint32_t roa::nodeowner_cap(uint8_t tier) { switch (tier) { case 1: @@ -1019,7 +980,7 @@ namespace sysio { // aborting, and never double-counting the gift. Then sync the on-chain quota to the resulting // row totals so the reslimit row and the resource limits stay equal on BOTH the fresh and // reconcile paths. - // Conservation: production (newnameduser -> nodeownreg) funds newaccount_ram at creation, so + // Conservation: nodeownreg's newnameduser call funds newaccount_ram before nodeownfin, so // the create path stays conservation-exact; the reconcile path adds only the node-owner delta. // forcereg over a pre-existing row now reconciles too (previously threw in set_reslimit) -- // acceptable for the governance/bootstrap path. @@ -1267,29 +1228,25 @@ namespace sysio { } void roa::newnameduser(const name& account, const public_key& pubkey, uint8_t tier) { - // Dispatched by the OPP depot (sysio.msgch) in the NFT node-owner claim flow, the same way - // as nodeownreg: privileged sysio.msgch sends this inline declaring {sysio.roa, active}, - // without requiring a cross-contract active grant. + // Called directly by nodeownreg, or as a standalone privileged account-creation action. require_auth(get_self()); roastate_t roastate(get_self()); auto state = roastate.get(); check(state.is_active, "ROA is not active yet"); - // Idempotent: if the account already exists, the create step is already done. A key or tier - // mismatch on a pre-existing account is caught downstream by nodeownreg's soft-fail. + // Standalone privileged creation remains idempotent. nodeownreg checks nonexistence + // before calling this helper; a separately created account is not eligible for a claim. if (is_account(account)) return; // Tier is a depot/system invariant -- a bad value means the dispatch is malformed, so abort // (the name charset [a-z1-5.] is already enforced by the name type). check(tier >= 1 && tier <= 3, "Tier level must be between 1 and 3"); - // The chosen name is claim-payload: if it violates the tier's length rule, skip creation - // (non-throwing) and let nodeownreg soft-fail with NAME_INVALID. Without this guard a bad - // name would either abort the depot dispatch or create an account the claim then rejects. + // Standalone creation silently skips invalid names. nodeownreg validates and records + // claim rejections before calling this helper. if (!valid_name_for_tier(account, tier)) return; - // A full tier is another soft rejection. Leave the account and sysio's RAM pool untouched; - // nodeownreg records TIER_CAP_REACHED even though no account was created. + // Standalone creation also skips full tiers without spending sysio's RAM. if (nodeowner_count(get_self(), state.network_gen, tier) >= nodeowner_cap(tier)) return; // Create the account with the holder's key as both owner and active. diff --git a/contracts/sysio.roa/sysio.roa.hpp b/contracts/sysio.roa/sysio.roa.hpp index b6748696cf..0f5958e36a 100644 --- a/contracts/sysio.roa/sysio.roa.hpp +++ b/contracts/sysio.roa/sysio.roa.hpp @@ -115,19 +115,17 @@ namespace sysio { /** - * @brief Directly register `owner` as a node owner at `tier`, bypassing the OPP claim - * flow. Privileged bootstrap/test path -- NOT the production registration route. + * @brief Directly register an existing account for governance/bootstrap and tests. * * Requires `sysio.roa` active authority, so it is governance/system-only and never * user-callable. It exists to seed node owners where the full OPP machinery is absent or * unnecessary: the C++ unit-test base tester (registers `nodedaddy` as the default policy * issuer for the suite), emissions tests, the TestHarness bios bootstrap, and dev clusters. * - * Production node-owner registration goes through `nodeownreg` instead -- dispatched by - * the OPP depot (sysio.msgch) on an inbound NodeOwnerRegistration attestation, or - * governance-signed during bootstrap. Beyond the tier-range check, `forcereg` performs - * none of `nodeownreg`'s claim-payload validation (no tier name-rule check, no active-key - * match, no sysio.authex link) and writes no `nodeownerreg` audit row -- it simply + * Production NFT registration enters through `nodeownreg`, dispatched by sysio.msgch, + * and allocates through the self-inline `nodeownfin` action. Beyond the tier-range check, + * `forcereg` performs none of `nodeownreg`'s claim-payload validation (no tier name-rule or new-account + * check, no sysio.authex link) and writes no `nodeownerreg` audit row -- it simply * allocates the tier's SYS via the shared `regnodeowner` helper. * * @param owner The account to register as a node owner. @@ -137,33 +135,34 @@ namespace sysio { void forcereg(const name& owner, const uint8_t& tier); /** - * @brief Registers a node owner when the depot (sysio.msgch) processes an inbound OPP - * NodeOwnerRegistration attestation. The register step of the NFT claim flow; the - * depot inline-sends newnameduser immediately before this. It creates a missing - * account only when the name is valid and the tier has registration capacity. - * - * Trust-OPP: the OPP envelope is the deposit proof, so this RECORDS the depositor's ETH - * key as a sysio.authex link (inline recordlink) rather than verifying a pre-existing - * createlink. Claim-payload problems (bad name, account controlled by a different key, - * already registered, tier full) are soft-failed -- recorded in `nodeownerreg` with a reject_reason - * and returned -- never thrown, so the dispatching transaction commits. Depot/system - * invariants (tier range, ETH key type and curve membership, ROA active) stay hard checks: - * sysio.msgch drops a depositor key that is not a secp256k1 point before dispatch, and - * one that still arrived would leave recordlink skipping the link this action confirms. - * - * @param owner The Wire account to register as node owner (created in-flow if absent). - * @param tier Node owner tier: 1, 2, or 3. - * @param eth_pub_key The depositor's ETH public key (Wire PUB_EM format); recorded as the link. - * @param wire_pub_key The Wire account owner/active key the claim specified; an existing - * account must be controlled by exactly this key or the claim is rejected. - * @param eth_address Depositor 20-byte ETH address used to sweep DClaim rewards - * deposited before the link existed. + * @brief Create and register a new node-owner account from an authenticated OPP claim. + * + * Requires sysio.roa authority. Existing accounts are rejected even if wire_pub_key + * matches a public active key. Valid claims queue native account creation, tier allocation + * through nodeownfin, and the depositor's AuthEx link in one atomic transaction. + * Claim-payload errors record a terminal rejection without aborting depot dispatch. + * Tier range, depositor key/address validity and ROA activation remain hard invariants. + * + * @param owner New Wire account name, valid for the claimed tier. + * @param tier Node-owner tier 1, 2 or 3. + * @param eth_pub_key Depositor EM public key, recorded as the account's Ethereum link. + * @param wire_pub_key Owner and active key for the newly created account. + * @param eth_address Depositor's 20-byte address, used to sweep pre-link DClaim rewards. */ [[sysio::action]] void nodeownreg(const name& owner, const uint8_t& tier, const public_key& eth_pub_key, const public_key& wire_pub_key, const bytes& eth_address); + /** + * @brief Allocate a validated claim after its queued native account creation. + * Requires sysio.roa authority and an inline sender of sysio.roa; top-level calls fail. + * @param owner Newly created claim account. + * @param tier Node-owner tier already validated by nodeownreg. + */ + [[sysio::action]] + void nodeownfin(const name& owner, const uint8_t& tier); + /** * @brief Creates a new user account on the network and records the sponsor mapping. * @@ -236,14 +235,14 @@ namespace sysio { /** * @brief Create a node-owner account with a user-chosen (vanity) name and the holder's * K1 key as owner/active, funded with the fixed newaccount_ram from sysio's pool. The - * create step of the OPP NFT claim flow (newnameduser -> nodeownreg, whose inline - * recordlink records the external-chain link and sweeps pre-link rewards). + * creation helper called internally by nodeownreg before its queued allocation and AuthEx link. * - * Dispatched by privileged sysio.msgch as {sysio.roa, active}, without a cross-contract - * active grant, like nodeownreg. Idempotent: a no-op if the account already exists. + * Standalone creation is privileged and idempotent. An account created separately is + * not eligible for a later NFT claim. * Tier-based name rules: tier-1 = 2-6 char prefix; tier 2/3 = up to 12 chars. - * Invalid names and full tiers skip creation without spending RAM; nodeownreg records - * the rejection. Capacity comes from the live generation's authoritative owner rows. + * Standalone invalid names and full tiers skip creation without an audit or RAM spend. + * nodeownreg records those rejections before calling this helper. Capacity comes from + * the live generation's authoritative owner rows. * * @param account The user-chosen account name. * @param pubkey The holder's K1 public key (becomes owner and active). @@ -439,20 +438,15 @@ namespace sysio { // Rejection reason for `nodeownerreg::reason` (only meaningful when status == REJECTED). // Under trust-OPP, nodeownreg soft-fails claim-payload errors by recording one of these // instead of aborting the dispatching transaction, so the failure is queryable on Wire. - // (Values renumbered for the create-in-flow model: the ETH link is now *recorded* via - // sysio.authex::recordlink rather than verified against a pre-existing createlink, so the - // former NO_AUTHEX_LINK / KEY_MISMATCH reasons no longer apply. Pre-launch: no stored rows - // to migrate.) enum reject_reason : uint8_t { NONE = 0, // not rejected NAME_INVALID = 1, // chosen name violates the tier's length rule or is a reserved sysio. name - OWNER_NOT_ACCOUNT = 2, // account does not exist (creation did not occur) - ACCOUNT_KEY_MISMATCH = 3, // existing account's active authority != the single claimed wire key + OWNER_NOT_ACCOUNT = 2, // historical: creation did not occur + ACCOUNT_KEY_MISMATCH = 3, // historical: submitted key did not match active authority DUPLICATE = 4, // owner is already a registered node owner LINK_KEY_MISMATCH = 5, // account already carries a different external-chain link key - TIER_CAP_REACHED = 6 // the claimed tier has no remaining registration capacity - // (OWNER_HAS_RESLIMIT removed: a pre-existing reslimit row no longer rejects registration -- - // regnodeowner reconciles it via increase_reslimit. Pre-launch: no stored rows to migrate. SEC-087.) + TIER_CAP_REACHED = 6, // the claimed tier has no remaining registration capacity + ACCOUNT_EXISTS = 7 // the target existed before this claim; registration requires a new account }; // Under trust-OPP the OPP envelope is the deposit proof, so this audit row records only @@ -606,20 +600,6 @@ namespace sysio { */ static bool valid_name_for_tier(const name& account, uint8_t tier); - /** - * @brief Whether `key` can satisfy `account`'s `active` authority by itself -- i.e. it - * appears among the active keys with weight >= threshold. Passes for a - * newnameduser-created account ({key}) and for a standard account that also carries - * the benign @sysio.code entry; rejects an account a different key controls - * (that key won't be present) and a multi-sig where `key` alone is insufficient. - * - * @param account The account whose active permission to inspect. - * @param key The claimed owner/active public key. - * @return true if `key` alone controls `account`'s active permission. - */ - static bool active_key_matches(const name& account, const public_key& key); - - /** * @brief A node owner's SYS budget for tier 1, 2, or 3: the tier allocation, net of sysio's * carve-out for tier 1. Matches the rounding activateroa uses to size the reserve. @@ -674,7 +654,7 @@ namespace sysio { * one-time `newaccount_ram` gift is folded into its ram_bytes; when present, only the passed * deltas are added (so the gift is never double-counted). Accumulation saturates rather than * throwing on overflow (see safe_ops): this helper sits on the OPP never-throw dispatch path - * (nodeownreg -> regnodeowner, newnameduser). + * (nodeownreg -> nodeownfin -> regnodeowner, and newnameduser). * * @param require_to_exist assert the row already exists (no create); used by callers that * must only ever top up a known account (e.g. sysio.acct funding). diff --git a/contracts/sysio.roa/sysio.roa.wasm b/contracts/sysio.roa/sysio.roa.wasm index 74a1a742da..896599dd05 100755 Binary files a/contracts/sysio.roa/sysio.roa.wasm and b/contracts/sysio.roa/sysio.roa.wasm differ diff --git a/contracts/tests/contract_test_support.hpp b/contracts/tests/contract_test_support.hpp index ff3051d40e..80182afe7c 100644 --- a/contracts/tests/contract_test_support.hpp +++ b/contracts/tests/contract_test_support.hpp @@ -213,6 +213,7 @@ inline constexpr uint64_t reason_account_key_mismatch = 3; inline constexpr uint64_t reason_duplicate = 4; inline constexpr uint64_t reason_link_key_mismatch = 5; inline constexpr uint64_t reason_tier_cap_reached = 6; +inline constexpr uint64_t reason_account_exists = 7; } // namespace nodeownerreg namespace nodeowners { diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 51e282586f..c3ed52198a 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -324,9 +324,7 @@ class sysio_dispatch_tester : public tester { static constexpr auto ROA_ACCOUNT = "sysio.roa"_n; static constexpr auto BATCHOP = "batchop.a"_n; static constexpr auto UWRIT_OP = "uwrit.alice"_n; - // Pre-created claim account for the NodeOwnerRegistration test: a fresh single-key account, so - // nodeownreg's active_key_matches succeeds when the claim carries that same key (existing-account - // path -- exercises the dispatch decode + routing without the account-creation machinery). + /// Node-owner claim target, deliberately absent until depot dispatch. static constexpr auto CLAIM_ACCOUNT = "claimacct"_n; static constexpr uint64_t ROA_NETWORK_GEN = 0; /// Termination prune delay for the suite's opreg config: ten minutes, far beyond any test's wall clock. @@ -340,15 +338,6 @@ class sysio_dispatch_tester : public tester { CHALG_ACCOUNT, TOKEN_ACCOUNT, CHAINS_ACCOUNT, DCLAIM_ACCOUNT, BATCHOP, UWRIT_OP }); - // CLAIM_ACCOUNT with NO roa policy (include_roa_policy=false) so regnodeowner exercises the - // fresh create-branch of increase_reslimit. (A pre-existing reslimit row would now be reconciled, - // not rejected -- SEC-087 -- but this dispatch test keeps the clean create path.) include_code=true - // leaves the standard @sysio.code on active, which exercises active_key_matches against a - // real (non-single-entry) authority. - create_account(CLAIM_ACCOUNT, config::system_account_name, - /*multisig=*/false, /*include_code=*/true, /*include_roa_policy=*/false); - produce_blocks(2); - deploy(MSGCH_ACCOUNT, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_abi); deploy(OPREG_ACCOUNT, contracts::opreg_wasm(), contracts::opreg_abi(), opreg_abi); deploy(EPOCH_ACCOUNT, contracts::epoch_wasm(), contracts::epoch_abi(), epoch_abi); @@ -935,11 +924,7 @@ BOOST_FIXTURE_TEST_CASE(deliver_envelope_at_size_cap_succeeds, sysio_dispatch_te BOOST_REQUIRE_EQUAL(current_epoch(), row["epoch_index"].as()); } FC_LOG_AND_RETHROW() } -// NodeOwnerRegistration: msgch decodes the attestation and inline-sends sysio.roa::newnameduser then -// nodeownreg. CLAIM_ACCOUNT pre-exists with a single-key active, so newnameduser no-ops and the -// claim's matching wire key drives nodeownreg's existing-account path to CONFIRMED (registers the -// owner and inline-records the depositor's ETH link in sysio.authex). Exercises the full dispatch: -// proto decode (account name + WireKey + ETH key) -> routing -> both roa actions -> recordlink. +/// Full depot dispatch creates the account, registers it and sweeps pre-link DClaim rewards. BOOST_FIXTURE_TEST_CASE(dispatch_routes_node_owner_reg_to_roa, sysio_dispatch_tester) { try { // Node-owner NFT deposits originate on the Ethereum outpost (code "ETHEREUM", matching the launch // bootstrap config and msgch's NODE_OWNER_SRC_CHAIN). Bootstrap it as the scheduled source outpost @@ -947,7 +932,7 @@ BOOST_FIXTURE_TEST_CASE(dispatch_routes_node_owner_reg_to_roa, sysio_dispatch_te bootstrap_for_dispatch("ETHEREUM"); const auto eth_code = fc::slug_name{"ETHEREUM"}.value; - // The claim must carry CLAIM_ACCOUNT's own active key so nodeownreg's active_key_matches passes. + // The supplied key becomes the new account's owner and active authority. auto wire_key = k1_pubkey_bytes(get_public_key(CLAIM_ACCOUNT, "active")); // BAR supplies the depositor's ETH key as an uncompressed 65-byte point. auto eth_pub = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em).get_public_key(); @@ -969,7 +954,16 @@ BOOST_FIXTURE_TEST_CASE(dispatch_routes_node_owner_reg_to_roa, sysio_dispatch_te auto envelope = encode_envelope_with_one_attestation( current_epoch(), sysio::opp::types::ATTESTATION_TYPE_NODE_OWNER_REG, payload); - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, envelope)); + const auto trace = deliver_trace(eth_code, envelope); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + const auto allocation = std::find_if(trace->action_traces.begin(), trace->action_traces.end(), + [](const auto& at) { return at.receiver == ROA_ACCOUNT && at.act.name == "nodeownfin"_n; }); + BOOST_REQUIRE(allocation != trace->action_traces.end()); + BOOST_CHECK(std::none_of(trace->action_traces.begin(), trace->action_traces.end(), + [](const auto& at) { + return at.receiver == ROA_ACCOUNT && (at.act.name == "forcereg"_n || at.act.name == "newnameduser"_n); + })); // Registered at the claimed tier, audited CONFIRMED. auto reg = get_nodeowner(CLAIM_ACCOUNT); @@ -985,6 +979,51 @@ BOOST_FIXTURE_TEST_CASE(dispatch_routes_node_owner_reg_to_roa, sysio_dispatch_te BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 4321); } FC_LOG_AND_RETHROW() } +/// A copied public key cannot authorize an existing name, and rejection must not block a fresh claim. +BOOST_FIXTURE_TEST_CASE(dispatch_existing_node_owner_name_rejects_without_blocking_fresh_claim, sysio_dispatch_tester) { try { + namespace audit = sysio_system::test_support::nodeownerreg; + bootstrap_for_dispatch("ETHEREUM"); + create_accounts({CLAIM_ACCOUNT}); + constexpr auto fresh = "freshclaim"_n; + const auto victim_key = get_public_key(CLAIM_ACCOUNT, "active"); + const auto operator_link = get_row_by_id(AUTHEX_ACCOUNT, AUTHEX_ACCOUNT, "links"_n, 0); + BOOST_REQUIRE(!operator_link.empty()); + BOOST_REQUIRE(get_row_by_id(AUTHEX_ACCOUNT, AUTHEX_ACCOUNT, "links"_n, 1).empty()); + const auto eth_pub = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em).get_public_key(); + const auto eth_address = fc::crypto::ethereum::address_to_bytes(eth_pub); + const std::vector native_address(eth_address.begin(), eth_address.end()); + BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, DCLAIM_ACCOUNT, "importseed"_n, mvo() + ("chain", ChainKind::CHAIN_KIND_EVM)("credits", fc::variants{ + mvo()("native_address", native_address)("wire_atomic", int64_t{4321})}))); + std::vector claims; + for (const auto owner : {CLAIM_ACCOUNT, fresh}) { + claims.push_back(encode_node_owner_registration( + owner.to_string(), 2, sysio::opp::types::WIRE_KEY_TYPE_K1, + k1_pubkey_bytes(get_public_key(owner, "active")), em_uncompressed_pubkey_bytes(eth_pub), eth_address)); + } + BOOST_REQUIRE_EQUAL(success(), deliver(fc::slug_name{"ETHEREUM"}.value, + encode_envelope_with_attestations(current_epoch(), sysio::opp::types::ATTESTATION_TYPE_NODE_OWNER_REG, claims))); + const auto rejected = get_nodeownerreg(CLAIM_ACCOUNT); + BOOST_REQUIRE(!rejected.is_null()); + BOOST_CHECK_EQUAL(rejected["status"].as(), audit::status_rejected); + BOOST_CHECK_EQUAL(rejected["reason"].as(), audit::reason_account_exists); + BOOST_CHECK(get_nodeowner(CLAIM_ACCOUNT).is_null()); + BOOST_CHECK(get_dclaim_row("pclaims"_n, "pending_claim", CLAIM_ACCOUNT.to_uint64_t()).is_null()); + // Only the fresh account's link may consume the depositor's imported credit. + // Bootstrap already linked the underwriter; preserve it and add only the fresh owner's link. + BOOST_CHECK(get_row_by_id(AUTHEX_ACCOUNT, AUTHEX_ACCOUNT, "links"_n, 0) == operator_link); + const auto linked = get_row_by_id(AUTHEX_ACCOUNT, AUTHEX_ACCOUNT, "links"_n, 1); + BOOST_REQUIRE(!linked.empty()); + BOOST_CHECK_EQUAL(authex_abi.binary_to_variant("links_s", linked, fixture_abi_yield_function())["username"].as(), fresh); + BOOST_CHECK(get_row_by_id(AUTHEX_ACCOUNT, AUTHEX_ACCOUNT, "links"_n, 2).empty()); + BOOST_REQUIRE(!get_nodeowner(fresh).is_null()); + BOOST_CHECK_EQUAL(get_nodeownerreg(fresh)["status"].as(), audit::status_confirmed); + BOOST_CHECK_EQUAL(get_dclaim_row("pclaims"_n, "pending_claim", fresh.to_uint64_t())["balance"].as().get_amount(), 4321); + const auto& active = control->get_authorization_manager().get_permission({CLAIM_ACCOUNT, config::active_name}); + BOOST_REQUIRE_EQUAL(active.auth.keys.size(), 1u); + BOOST_CHECK(active.auth.keys[0].key == victim_key); +} FC_LOG_AND_RETHROW() } + // Only ATTESTATION_TYPE_NODE_OWNER_REG reaches the node-owner handler. The outbound-only types // (OPERATORS, BATCH_OPERATOR_GROUPS) are dropped on receipt even when // the payload is a well-formed NodeOwnerRegistration from the node-owner source outpost: a payload @@ -1321,6 +1360,7 @@ class node_owner_claim_tester : public sysio_dispatch_tester { // attacker-controlled. It must reach sysio as an ordinary notification and change nothing: not sysio's owner, active, // auth.ext or any other permission, and not a user's. BOOST_FIXTURE_TEST_CASE(claimed_auth_msg_notification_changes_no_permission, node_owner_claim_tester) { try { + create_account(CLAIM_ACCOUNT); bootstrap_for_dispatch(NODE_OWNER_SOURCE_CHAIN); create_account(PAYER_ACCOUNT); deploy_system_contract(); @@ -1336,6 +1376,7 @@ BOOST_FIXTURE_TEST_CASE(claimed_auth_msg_notification_changes_no_permission, nod const auto replacement_key = get_public_key(REPLACEMENT_KEY_NAME, "active"); const auto sysio_before = permissions_of(config::system_account_name); const auto user_before = permissions_of(CLAIM_ACCOUNT); + BOOST_REQUIRE(!user_before.empty()); for (const auto target : {config::system_account_name, CLAIM_ACCOUNT}) { for (const auto permission : {config::owner_name, config::active_name, AUTH_EXT_PERMISSION, OTHER_PERMISSION}) { BOOST_CHECK_MESSAGE(notify_onlinkauth(target, permission, replacement_key), @@ -2372,6 +2413,7 @@ BOOST_AUTO_TEST_CASE(generic_external_simulator_drives_production_dispatch) { // Funded pre-launch imports link and settle the exact token balance. BOOST_FIXTURE_TEST_CASE(funded_import_claims_link_and_settle, sysio_dispatch_tester) { using namespace sysio::testing::external; + create_account(CLAIM_ACCOUNT); bootstrap_for_dispatch(First.chain, First.kind); deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi); BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, TOKEN_ACCOUNT, "create"_n, diff --git a/contracts/tests/sysio.roa_tests.cpp b/contracts/tests/sysio.roa_tests.cpp index b15746bc1c..6fe5eb5cac 100644 --- a/contracts/tests/sysio.roa_tests.cpp +++ b/contracts/tests/sysio.roa_tests.cpp @@ -2684,11 +2684,9 @@ BOOST_FIXTURE_TEST_CASE( newnameduser_tier_name_rules, sysio_roa_tester ) try { // --------------------------------------------------------------------------- // nodeownreg tests (OPP Node Owner NFT claim: create-in-flow + register + record ETH link) // -// Under the create-in-flow model the depot (sysio.msgch) inline-sends newnameduser (creates the -// account with the claimed wire key) then nodeownreg (registers + records the depositor's ETH key -// via an inline sysio.authex::recordlink). These unit tests drive the two sysio.roa actions -// directly, signed by privileged ROA, the same way the depot would. No cross-contract active -// permission is installed: privilege authorizes the declared sysio.authex.active permission. +// The depot dispatches nodeownreg once. It creates a fresh account, allocates the owner and +// records its ETH link atomically. Existing names must soft-reject even with a matching public +// key. Privileged standalone account creation and allocation remain separate administrative paths. // --------------------------------------------------------------------------- /// Shared mirrors of sysio.roa's node-owner registration audit values. @@ -2754,7 +2752,7 @@ class sysio_roa_nodeownreg_tester : public sysio_roa_tester { "links_s", data, fixture_abi_yield_function()); } - // Create the claim account in-flow (depot path) with `wire_pub_key` as owner/active. + // Create a pre-existing account fixture with `wire_pub_key` as owner/active. action_result newnameduser(const name& account, const fc::crypto::public_key& wire_pub_key, uint8_t tier) { return push_action(ROA, "newnameduser"_n, mvo() ("account", account)("pubkey", wire_pub_key)("tier", tier)); @@ -2787,8 +2785,7 @@ class sysio_roa_nodeownreg_tester : public sysio_roa_tester { static constexpr uint64_t CONFIRMED = nodeownerreg_audit::status_confirmed; static constexpr uint64_t REJECTED = nodeownerreg_audit::status_rejected; static constexpr uint64_t R_NAME_INVALID = nodeownerreg_audit::reason_name_invalid; - static constexpr uint64_t R_OWNER_NOT_ACCOUNT = nodeownerreg_audit::reason_owner_not_account; - static constexpr uint64_t R_ACCOUNT_KEY_MISMATCH = nodeownerreg_audit::reason_account_key_mismatch; + static constexpr uint64_t R_ACCOUNT_EXISTS = nodeownerreg_audit::reason_account_exists; static constexpr uint64_t R_DUPLICATE = nodeownerreg_audit::reason_duplicate; static constexpr uint64_t R_LINK_KEY_MISMATCH = nodeownerreg_audit::reason_link_key_mismatch; static constexpr uint64_t R_TIER_CAP_REACHED = nodeownerreg_audit::reason_tier_cap_reached; @@ -2796,31 +2793,64 @@ class sysio_roa_nodeownreg_tester : public sysio_roa_tester { abi_serializer authex_abi_ser; }; -// Happy path: depot creates the account with the claimed wire key, then nodeownreg registers and -// records the depositor's ETH link. +/// Fresh claims create the chosen authority, membership and Ethereum link for all tiers. BOOST_FIXTURE_TEST_CASE( nodeownreg_happy_path, sysio_roa_nodeownreg_tester ) try { - const auto owner = "claimacct"_n; const auto wire_pub = gen_k1_key(); - const auto eth_pub = gen_em_key(); + const auto eth_pub = gen_em_key(); + for (const auto& [owner, tier] : {std::pair{"freshone"_n, uint8_t{2}}, + std::pair{"fresh"_n, uint8_t{1}}, + std::pair{"freshthree"_n, uint8_t{3}}}) { + BOOST_REQUIRE((control->db().find(owner) == nullptr)); + BOOST_REQUIRE_EQUAL(success(), nodeownreg(owner, tier, eth_pub, wire_pub)); + produce_blocks(); + const auto reg = get_nodeowner(owner); + BOOST_REQUIRE(!reg.is_null()); + BOOST_CHECK_EQUAL(reg["tier"].as(), tier); + BOOST_CHECK_EQUAL(get_nodeownerreg(owner)["status"].as(), CONFIRMED); + const auto& active = control->get_authorization_manager().get_permission({owner, config::active_name}); + BOOST_REQUIRE_EQUAL(active.auth.keys.size(), 1u); + BOOST_CHECK(active.auth.keys[0].key == wire_pub); + } +} FC_LOG_AND_RETHROW() +/// ROA authority alone cannot invoke the claim-only allocation continuation. +BOOST_FIXTURE_TEST_CASE( nodeownfin_rejects_top_level_call, sysio_roa_nodeownreg_tester ) try { + constexpr auto owner = "alice"_n; + BOOST_REQUIRE((control->db().find(owner) != nullptr)); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: nodeownfin must be sent inline by sysio.roa"), + push_action(ROA, "nodeownfin"_n, mvo()("owner", owner)("tier", 2))); + BOOST_CHECK(get_nodeowner(owner).is_null()); + BOOST_CHECK(get_nodeownerreg(owner).is_null()); +} FC_LOG_AND_RETHROW() + +/// Public-key equality never authorizes registration into an existing account. +BOOST_FIXTURE_TEST_CASE( nodeownreg_rejects_existing_account_with_matching_key, sysio_roa_nodeownreg_tester ) try { + const auto owner = "claimacct"_n; + const auto wire_pub = gen_k1_key(); BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, wire_pub, 2)); produce_blocks(); - - BOOST_REQUIRE_EQUAL(success(), nodeownreg(owner, 2, eth_pub, wire_pub)); + const auto pool_before = get_reslimit(config::system_account_name); + int64_t ram_before = 0, net_before = 0, cpu_before = 0; + auto& limits = control->get_resource_limits_manager(); + limits.get_account_limits(owner, ram_before, net_before, cpu_before); + BOOST_REQUIRE_EQUAL(success(), nodeownreg(owner, 2, gen_em_key(), wire_pub)); produce_blocks(); - - // A CONFIRMED registration proves the account was created with wire_pub: nodeownreg's - // active_key_matches requires the account to exist and be controlled by exactly that key, so - // reaching CONFIRMED (rather than OWNER_NOT_ACCOUNT / ACCOUNT_KEY_MISMATCH) is the existence proof. - auto reg = get_nodeowner(owner); - BOOST_REQUIRE_EQUAL(reg.is_null(), false); - BOOST_REQUIRE_EQUAL(reg["tier"].as(), 2); - auto audit = get_nodeownerreg(owner); - BOOST_REQUIRE_EQUAL(audit.is_null(), false); - BOOST_REQUIRE_EQUAL(audit["status"].as(), CONFIRMED); - // nodeownreg returning success implies the inline recordlink ({sysio.authex, active}) was - // authorized and ran -- an unauthorized inline send would have aborted the whole transaction. - // recordlink's own table effects are covered by the sysio.authex unit tests. + BOOST_CHECK(get_nodeowner(owner).is_null()); + BOOST_CHECK(get_authex_link(0).is_null()); + const auto audit = get_nodeownerreg(owner); + BOOST_CHECK_EQUAL(audit["status"].as(), REJECTED); + BOOST_CHECK_EQUAL(audit["reason"].as(), R_ACCOUNT_EXISTS); + const auto& active = control->get_authorization_manager().get_permission({owner, config::active_name}); + BOOST_REQUIRE_EQUAL(active.auth.keys.size(), 1u); + BOOST_CHECK(active.auth.keys[0].key == wire_pub); + int64_t ram_after = 0, net_after = 0, cpu_after = 0; + limits.get_account_limits(owner, ram_after, net_after, cpu_after); + BOOST_CHECK_EQUAL(ram_after, ram_before); + BOOST_CHECK_EQUAL(net_after, net_before); + BOOST_CHECK_EQUAL(cpu_after, cpu_before); + BOOST_CHECK_EQUAL(get_reslimit(config::system_account_name)["ram_bytes"].as_int64(), + pool_before["ram_bytes"].as_int64()); } FC_LOG_AND_RETHROW() // The depot envelope carries a raw EVM address. Anything other than exactly 20 bytes is a hard @@ -2831,8 +2861,6 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_rejects_malformed_eth_address_lengths, const auto wire_pub = gen_k1_key(); const auto eth_pub = gen_em_key(); - BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, wire_pub, 2)); - produce_blocks(); for (const auto size : {size_t{19}, size_t{21}}) { const std::vector malformed_address(size, char{0x01}); @@ -2845,8 +2873,8 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_rejects_malformed_eth_address_lengths, } } FC_LOG_AND_RETHROW() -// Existing account controlled by a different key than the claim -> REJECTED/ACCOUNT_KEY_MISMATCH. -BOOST_FIXTURE_TEST_CASE( nodeownreg_account_key_mismatch, sysio_roa_nodeownreg_tester ) try { +// A different submitted key cannot bypass the existing-account rejection either. +BOOST_FIXTURE_TEST_CASE( nodeownreg_rejects_existing_account_with_different_key, sysio_roa_nodeownreg_tester ) try { const auto owner = "claimacct"_n; const auto real_k = gen_k1_key(); // key the account is actually created with const auto claimed = gen_k1_key(); // a different key in the claim @@ -2861,22 +2889,17 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_account_key_mismatch, sysio_roa_nodeownreg_t BOOST_REQUIRE(get_nodeowner(owner).is_null()); auto audit = get_nodeownerreg(owner); BOOST_REQUIRE_EQUAL(audit["status"].as(), REJECTED); - BOOST_REQUIRE_EQUAL(audit["reason"].as(), R_ACCOUNT_KEY_MISMATCH); + BOOST_REQUIRE_EQUAL(audit["reason"].as(), R_ACCOUNT_EXISTS); } FC_LOG_AND_RETHROW() -// SEC-087: an account that already carries a reslimit row must NOT be blocked from registering. Such a -// row can be planted on any account by a node owner via addpolicy (no target consent), which previously -// forced the claim into a permanent OWNER_HAS_RESLIMIT soft-fail -- a denial-of-registration grief. -// nodeownreg now RECONCILES: regnodeowner stacks the node-owner allocation onto the existing row via -// increase_reslimit, the claim reaches CONFIRMED, and the planted policy is preserved (still the -// issuer's, reclaimable via reducepolicy). No value is stolen and the gift is not double-counted. -BOOST_FIXTURE_TEST_CASE( nodeownreg_reconciles_existing_reslimit, sysio_roa_nodeownreg_tester ) try { +// SEC-087 resource reconciliation remains supported through privileged forcereg. +// Public NFT claims reject existing accounts. +BOOST_FIXTURE_TEST_CASE( forcereg_reconciles_existing_reslimit, sysio_roa_nodeownreg_tester ) try { const auto owner = "claimacct"_n; const auto wire_pub = gen_k1_key(); - const auto eth_pub = gen_em_key(); - // Create the claim account in-flow, then have NODE_DADDY plant a RAM-only policy on it so it carries - // a reslimit row before the claim is resolved (NODE_DADDY is the tier-1 issuer seeded by the fixture). + // Create an account and add a policy before privileged registration (NODE_DADDY is the + // tier-1 issuer seeded by the fixture). BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, wire_pub, 2)); produce_blocks(); add_roa_policy(NODE_DADDY, owner, "0.0000 SYS", "0.0000 SYS", "1.0000 SYS", 0, 0); @@ -2897,17 +2920,15 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_reconciles_existing_reslimit, sysio_roa_node BOOST_REQUIRE_EQUAL(r["ram_bytes"].as_int64(), planted_ram_bytes + newaccount_ram); const auto daddy_before = get_nodeowner(NODE_DADDY); - // The claim must commit AND register -- not soft-fail, not throw. - BOOST_REQUIRE_EQUAL(success(), nodeownreg(owner, 2, eth_pub, wire_pub)); + // Privileged allocation must preserve the existing policy. + BOOST_REQUIRE_EQUAL(success(), push_action(ROA, "forcereg"_n, mvo()("owner", owner)("tier", 2))); produce_blocks(); // Registration completed (the SEC-087 outcome). auto reg = get_nodeowner(owner); BOOST_REQUIRE_EQUAL(reg.is_null(), false); BOOST_REQUIRE_EQUAL(reg["tier"].as(), 2); - auto audit = get_nodeownerreg(owner); - BOOST_REQUIRE_EQUAL(audit["status"].as(), CONFIRMED); - BOOST_REQUIRE_EQUAL(audit["reason"].as(), 0); // NONE + BOOST_REQUIRE(get_nodeownerreg(owner).is_null()); // reslimit reconciled: the planted row is preserved untouched and the gift is NOT re-added. A // tier-2 registration adds no personal weight, so the row is exactly what addpolicy left. @@ -2931,7 +2952,7 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_reconciles_existing_reslimit, sysio_roa_node BOOST_REQUIRE_EQUAL(pol["cpu_weight"].as_string(), "0.0000 SYS"); BOOST_REQUIRE_EQUAL(pol["ram_weight"].as_string(), "1.0000 SYS"); - // Conservation: nodeownreg does not touch the planted-policy issuer's allocations (the planted + // Conservation: forcereg does not touch the planted-policy issuer's allocations (the planted // resources stay backed by NODE_DADDY's reserve; the node-owner allocation is independently backed). auto daddy_after = get_nodeowner(NODE_DADDY); BOOST_REQUIRE_EQUAL(daddy_after["allocated_sys"].as_string(), daddy_before["allocated_sys"].as_string()); @@ -2940,11 +2961,10 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_reconciles_existing_reslimit, sysio_roa_node } FC_LOG_AND_RETHROW() // SEC-087 companion: a planted policy can carry NET/CPU as well as RAM. Verify the reconcile leaves -// them intact (the increase_reslimit MODIFY branch) and registration still reaches CONFIRMED. -BOOST_FIXTURE_TEST_CASE( nodeownreg_reconciles_existing_reslimit_net_cpu, sysio_roa_nodeownreg_tester ) try { +// them intact (the increase_reslimit MODIFY branch) through privileged registration. +BOOST_FIXTURE_TEST_CASE( forcereg_reconciles_existing_reslimit_net_cpu, sysio_roa_nodeownreg_tester ) try { const auto owner = "claimacct"_n; const auto wire_pub = gen_k1_key(); - const auto eth_pub = gen_em_key(); BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, wire_pub, 2)); produce_blocks(); @@ -2954,11 +2974,11 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_reconciles_existing_reslimit_net_cpu, sysio_ const int64_t bytes_per_unit = 104; const int64_t planted_ram_bytes = 10000 * bytes_per_unit; - BOOST_REQUIRE_EQUAL(success(), nodeownreg(owner, 2, eth_pub, wire_pub)); + BOOST_REQUIRE_EQUAL(success(), push_action(ROA, "forcereg"_n, mvo()("owner", owner)("tier", 2))); produce_blocks(); BOOST_REQUIRE_EQUAL(get_nodeowner(owner).is_null(), false); - BOOST_REQUIRE_EQUAL(get_nodeownerreg(owner)["status"].as(), CONFIRMED); + BOOST_REQUIRE(get_nodeownerreg(owner).is_null()); // A tier-2 registration adds no personal weight, so the planted 1.0000 SYS of net/cpu and the // planted RAM (plus the one-time gift) survive the reconcile unchanged. @@ -2981,10 +3001,9 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_reconciles_existing_reslimit_net_cpu, sysio_ // node-owner personal weights must stack, and the one-time newaccount_ram gift must be counted once // rather than re-added. Without this the arithmetic that reconcile depends on would go untested on // the registration path. -BOOST_FIXTURE_TEST_CASE( nodeownreg_tier1_reconcile_stacks_personal_weights, sysio_roa_nodeownreg_tester ) try { +BOOST_FIXTURE_TEST_CASE( forcereg_tier1_reconcile_stacks_personal_weights, sysio_roa_nodeownreg_tester ) try { const auto owner = "t1own"_n; // tier-1 name rule: 2-6 characters const auto wire_pub = gen_k1_key(); - const auto eth_pub = gen_em_key(); BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, wire_pub, 1)); produce_blocks(); @@ -3001,11 +3020,11 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_tier1_reconcile_stacks_personal_weights, sys BOOST_REQUIRE_EQUAL(r["net_weight"].as_string(), "1.0000 SYS"); BOOST_REQUIRE_EQUAL(r["ram_bytes"].as_int64(), planted_ram_bytes + newaccount_ram); - BOOST_REQUIRE_EQUAL(success(), nodeownreg(owner, 1, eth_pub, wire_pub)); + BOOST_REQUIRE_EQUAL(success(), push_action(ROA, "forcereg"_n, mvo()("owner", owner)("tier", 1))); produce_blocks(); BOOST_REQUIRE_EQUAL(get_nodeowner(owner)["tier"].as(), 1); - BOOST_REQUIRE_EQUAL(get_nodeownerreg(owner)["status"].as(), CONFIRMED); + BOOST_REQUIRE(get_nodeownerreg(owner).is_null()); // MODIFY branch with non-zero deltas: planted 1.0000 + personal 0.0500 = 1.0500 SYS of net/cpu, // ram = planted + gift + personal, with the gift NOT re-added. @@ -3087,8 +3106,6 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_link_key_mismatch, sysio_roa_nodeownreg_test const auto eth_a = gen_em_key(); // key already linked to the account const auto eth_b = gen_em_key(); // depositor key in the new claim - BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, wire_pub, 2)); - produce_blocks(); // Pre-existing EVM link with eth_a; the account is not yet a node owner. BOOST_REQUIRE_EQUAL(success(), recordlink(eth_a, owner)); produce_blocks(); @@ -3142,26 +3159,12 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_rejects_reserved_system_names, sysio_roa_nod } } FC_LOG_AND_RETHROW() -// Valid-for-tier name that is not an account -> REJECTED/OWNER_NOT_ACCOUNT. -BOOST_FIXTURE_TEST_CASE( nodeownreg_owner_not_account, sysio_roa_nodeownreg_tester ) try { - const auto owner = "ghost"_n; // 5 chars: valid for tier 1, but no account was created - BOOST_REQUIRE_EQUAL(success(), nodeownreg(owner, 1, gen_em_key(), gen_k1_key())); - produce_blocks(); - - BOOST_REQUIRE(get_nodeowner(owner).is_null()); - auto audit = get_nodeownerreg(owner); - BOOST_REQUIRE_EQUAL(audit["status"].as(), REJECTED); - BOOST_REQUIRE_EQUAL(audit["reason"].as(), R_OWNER_NOT_ACCOUNT); -} FC_LOG_AND_RETHROW() - // Replay after a successful claim -> REJECTED/DUPLICATE, no abort. BOOST_FIXTURE_TEST_CASE( nodeownreg_already_registered, sysio_roa_nodeownreg_tester ) try { const auto owner = "claimacct"_n; const auto wire_pub = gen_k1_key(); const auto eth_pub = gen_em_key(); - BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, wire_pub, 2)); - produce_blocks(); BOOST_REQUIRE_EQUAL(success(), nodeownreg(owner, 2, eth_pub, wire_pub)); produce_blocks(); @@ -3194,7 +3197,7 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_full_tier_has_no_registration_side_effects, const auto audit = get_nodeownerreg(owner); BOOST_REQUIRE(!audit.is_null()); BOOST_CHECK_EQUAL(audit["status"].as(), REJECTED); - BOOST_CHECK_EQUAL(audit["reason"].as(), R_TIER_CAP_REACHED); + BOOST_CHECK_EQUAL(audit["reason"].as(), owner == fresh ? R_TIER_CAP_REACHED : R_ACCOUNT_EXISTS); BOOST_CHECK(get_nodeowner(owner).is_null()); BOOST_CHECK(get_reslimit(owner).is_null()); BOOST_CHECK(get_authex_link(0).is_null()); @@ -3214,18 +3217,19 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_full_tier_preserves_rejection_precedence, sysio_roa_nodeownreg_tester ) try { namespace owners = sysio_system::test_support::nodeowners; const auto eth_pub = gen_em_key(); - BOOST_REQUIRE_EQUAL(success(), nodeownreg("alice"_n, owners::tier1, eth_pub, - get_public_key("alice"_n, "active"))); + BOOST_REQUIRE_EQUAL(success(), push_action(ROA, "forcereg"_n, mvo()("owner", "alice")("tier", owners::tier1))); produce_block(); const auto linked_key = gen_em_key(); - BOOST_REQUIRE_EQUAL(success(), recordlink(linked_key, "bob"_n)); + constexpr auto linked_name = "linked"_n; + BOOST_REQUIRE((control->db().find(linked_name) == nullptr)); + BOOST_REQUIRE_EQUAL(success(), recordlink(linked_key, linked_name)); produce_block(); owners::fill_tier1(*this, abi_ser); for (const auto& [owner, key, reason] : { std::tuple{"alice"_n, get_public_key("alice"_n, "active"), R_DUPLICATE}, - std::tuple{"carol"_n, gen_k1_key(), R_ACCOUNT_KEY_MISMATCH}, - std::tuple{"bob"_n, get_public_key("bob"_n, "active"), R_LINK_KEY_MISMATCH}, + std::tuple{"carol"_n, gen_k1_key(), R_ACCOUNT_EXISTS}, + std::tuple{linked_name, gen_k1_key(), R_LINK_KEY_MISMATCH}, std::tuple{"toolong"_n, gen_k1_key(), R_NAME_INVALID}}) { BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, key, owners::tier1)); produce_block(); @@ -3238,11 +3242,12 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_full_tier_preserves_rejection_precedence, BOOST_CHECK_EQUAL(owners::count(*this, abi_ser, owners::tier1), owners::tier1_cap); } BOOST_CHECK(!get_nodeowner("alice"_n).is_null()); - BOOST_CHECK(get_nodeowner("bob"_n).is_null()); + BOOST_CHECK(get_nodeowner(linked_name).is_null()); + BOOST_CHECK((control->db().find(linked_name) == nullptr)); BOOST_CHECK(get_nodeowner("carol"_n).is_null()); BOOST_CHECK((control->db().find("toolong"_n) == nullptr)); - BOOST_CHECK_EQUAL(get_authex_link(1)["pub_key"].as(), linked_key); - BOOST_CHECK(get_authex_link(2).is_null()); + BOOST_CHECK_EQUAL(get_authex_link(0)["pub_key"].as(), linked_key); + BOOST_CHECK(get_authex_link(1).is_null()); } FC_LOG_AND_RETHROW() // Invalid tier is a depot/system invariant -> hard abort (not a soft-fail). @@ -3263,8 +3268,6 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_invalid_tier, sysio_roa_nodeownreg_tester ) BOOST_FIXTURE_TEST_CASE( nodeownreg_rejects_em_key_off_the_curve, sysio_roa_nodeownreg_tester ) try { const auto owner = "claimacct"_n; const auto wire_pub = gen_k1_key(); - BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, wire_pub, 2)); - produce_blocks(); // x = 0 has no point (7 is not a square mod p); the all-zero bytes have no valid prefix either. fc::em::public_key_data zero_x{}; diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 64d183f9ad..067ba1a5df 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -383,3 +383,22 @@ Deploy this `sysio.msgch` on fresh state and regenerate the SDK contract types w written before the change has no `byqueue` entry, so `buildenv` never finds it: a queued `DESYNDICATE_LIQ` would never ship, and an inbound row would never be erased. There is no migration path. + +## WIRE-439 node-owner registration + +Deploy the matching `sysio.roa` and `sysio.msgch` WASMs together in one transaction, +or quiesce inbound dispatch until both are installed. ROA's ABI adds the self-inline +`nodeownfin(owner, tier)` allocation action; stored-row layouts are unchanged. +Mixed revisions are not compatible: old MSGCH creates +the account before new ROA rejects it, while new MSGCH leaves creation to ROA. +Do not process node-owner claims between separate deployments. Preserve both +contracts' privileged status and the default inline-depth limit. + +Claim traces contain `nodeownreg`, native `newaccount`, `nodeownfin` and +`recordlink`; `newnameduser` is called internally and emits no separate action trace. +Index registration outcomes from `nodeowners`/`nodeownerreg`, not the presence +of a `newnameduser` trace. The external `nodeownreg` payload is unchanged. + +Deploy the new-account-only Hub and Tools companions with this pair. Existing +memberships and historical audit reason values remain readable; this update does +not remediate previously bound links or release escrowed NFTs. diff --git a/docs/roa-overview.md b/docs/roa-overview.md index b568332157..b9148b376a 100644 --- a/docs/roa-overview.md +++ b/docs/roa-overview.md @@ -875,8 +875,9 @@ and are omitted. | `newuser` | tier-1 node owner | Create a sponsored sub-account | | `raiseacctlim` | tier-1 node owner | Raise its own account limit, at most by the configured multiplier | | `setacctlim` | `sysio.roa` | Set the default account limit and the raise multiplier | -| `newnameduser` | `sysio.roa` | Create a vanity account (NFT-claim flow) | -| `nodeownreg` | `sysio.roa` | Register a node owner from an OPP attestation | +| `newnameduser` | `sysio.roa` | Privileged vanity-account creation; also called internally by `nodeownreg` | +| `nodeownreg` | `sysio.roa` | Create and register a fresh node-owner account from an OPP attestation | +| `nodeownfin` | `sysio.roa`, self-inline only | Allocate a validated claim after native account creation | | `forcereg` | `sysio.roa` | Register a node owner directly (governance/bootstrap) | | `activateroa` | `sysio.roa` | Activate ROA and set the initial byte price | | `setbyteprice` | `sysio.roa` | Change bytes-per-unit on network expansion | @@ -914,3 +915,22 @@ and are omitted. above — contract-as-payer billing, the failure mode of an unprovisioned contract, RAM-free token transfers, and multi-issuer policy stacking — were each verified against the code and confirmed with executable tests. The transcript blocks reproduce actual test output.* + +## NFT registration names + +Node-owner NFT claims create **new accounts only**. +`sysio.msgch` dispatches `nodeownreg`, which rejects an existing name with +`ACCOUNT_EXISTS` (7), including when the claim copies its public active key. +Already registered owners retain the `DUPLICATE` outcome. Rejection records an +audit row without changing the account authority, AuthEx link or owner allocation. +A fresh accepted claim creates the requested authority, allocates the owner and +records the depositor's Ethereum link in one atomic Wire transaction. +Allocation uses the self-inline `nodeownfin` continuation; `forcereg` remains +the separate privileged governance/bootstrap path. AuthEx `recordlink` remains +a sibling action of `nodeownfin`, so its DClaim sweep stays within the inline-depth limit. + +The Hub checks name availability before BAR commitment; this cannot reserve the +name against a competing claim. A name taken before depot execution is rejected +and the NFT remains subject to BAR's existing escrow/recovery rules. This change +adds no automatic refund or recovery action. Deploy the coupled contracts as +specified in [contract upgrade order](contract-upgrade-order.md#wire-439-node-owner-registration). diff --git a/tests/nodeownreg_test.py b/tests/nodeownreg_test.py index 56a8498833..8faf4a7ec9 100755 --- a/tests/nodeownreg_test.py +++ b/tests/nodeownreg_test.py @@ -4,10 +4,9 @@ nodeownreg_test.py — OPP Node Owner NFT Registration integration test (create-in-flow). Drives sysio.roa the way the depot (sysio.msgch) does for an inbound NodeOwnerRegistration, -pushing the two inline actions directly as sysio.roa: - 1. newnameduser(account, wire_key, tier) -- create the claim account - 2. nodeownreg(account, tier, eth_key, wire_key, eth_address) - -- register + inline-record the ETH link +pushing nodeownreg(account, tier, eth_key, wire_key, eth_address) as sysio.roa. +The claim creates the account, registers it and records its ETH link atomically. +Existing names are rejected even when the submitted key matches their active authority. Verifies the nodeowners + nodeownerreg tables, plus the soft-fail (audit row) and hard-fail paths. The bios bootstrap marks sysio.roa privileged, so its inline recordlink may declare @@ -23,7 +22,7 @@ # nodeownerreg reg_status + reject_reason values (mirror sysio.roa.hpp). CONFIRMED, REJECTED = 0, 1 -R_NAME_INVALID, R_OWNER_NOT_ACCOUNT, R_ACCOUNT_KEY_MISMATCH, R_DUPLICATE = 1, 2, 3, 4 +R_NAME_INVALID, R_DUPLICATE, R_ACCOUNT_EXISTS = 1, 4, 7 # --------------------------------------------------------------------------- @@ -120,10 +119,9 @@ def get_audit(node, owner): Utils.Print(f" eth (EM) key: {eth_key}") Utils.Print(f" wire (K1) key: {wire_key}") - # ---- Test 1: happy path -- create the account then register it ---- - Utils.Print("=== Test 1: newnameduser + nodeownreg happy path ===") + # ---- Test 1: happy path -- one claim creates and registers the account ---- + Utils.Print("=== Test 1: nodeownreg create-and-register happy path ===") owner = "claimacct1a" - assert push_newnameduser(node, owner, wire_key, 2)[0], "newnameduser failed" assert push_nodeownreg(node, owner, 2, eth_address, eth_key, wire_key)[0], "nodeownreg failed" reg = get_nodeowner(node, owner) @@ -133,20 +131,19 @@ def get_audit(node, owner): assert audit is not None and int(audit["status"]) == CONFIRMED, f"expected CONFIRMED, got {audit}" Utils.Print(f" Verified: {owner} registered tier-2, audit CONFIRMED") - # ---- Test 2: account controlled by a different key -> soft-fail ACCOUNT_KEY_MISMATCH ---- - Utils.Print("=== Test 2: nodeownreg with a non-matching wire key (soft-fail) ===") + # ---- Test 2: existing account with a matching public key -> soft-fail ACCOUNT_EXISTS ---- + Utils.Print("=== Test 2: nodeownreg with a copied matching wire key (soft-fail) ===") owner2 = "claimacct2a" real_key = wallet_create_key(walletMgr.host, walletMgr.port, "K1") - other_key = wallet_create_key(walletMgr.host, walletMgr.port, "K1") assert push_newnameduser(node, owner2, real_key, 2)[0], "newnameduser (owner2) failed" - # Claim owner2 with a different wire key than it was created with. - assert push_nodeownreg(node, owner2, 2, eth_address, eth_key, other_key)[0], \ + # Public active-key equality is not authorization. + assert push_nodeownreg(node, owner2, 2, eth_address, eth_key, real_key)[0], \ "nodeownreg should soft-fail, not abort" assert get_nodeowner(node, owner2) is None, "owner2 must not be registered" audit2 = get_audit(node, owner2) assert audit2 is not None and int(audit2["status"]) == REJECTED \ - and int(audit2["reason"]) == R_ACCOUNT_KEY_MISMATCH, f"expected ACCOUNT_KEY_MISMATCH, got {audit2}" - Utils.Print(" Correctly soft-failed ACCOUNT_KEY_MISMATCH") + and int(audit2["reason"]) == R_ACCOUNT_EXISTS, f"expected ACCOUNT_EXISTS, got {audit2}" + Utils.Print(" Correctly soft-failed ACCOUNT_EXISTS") # ---- Test 3: re-register the owner -> soft-fail DUPLICATE ---- # Use a fresh ETH key so the replay is a *distinct* transaction: a byte-identical resubmission of diff --git a/unittests/intrinsic_probe_tests.cpp b/unittests/intrinsic_probe_tests.cpp index 0b731cedb5..27157ba0c7 100644 --- a/unittests/intrinsic_probe_tests.cpp +++ b/unittests/intrinsic_probe_tests.cpp @@ -546,7 +546,7 @@ BOOST_FIXTURE_TEST_CASE(set_action_return_value_empty, intrinsic_probe_fixture) // I. get_permission_lower_bound (P2) // // Wire-specific intrinsic (libraries/chain/webassembly/permission.cpp) backing CDT's sysio::get_permission -- -// the only host path a contract has to read another account's authority on-chain (sysio.roa::active_key_matches). +// the host path a contract uses to read another account's authority on-chain. // Each probe queries get_self()'s owner/active permissions (created with a single K1 key at // threshold 1) and self-verifies the record via in-contract check(); the driver asserts the host stays silent, // so any layout / size-query / sentinel / lower-bound-semantics regression surfaces as a diff --git a/unittests/test-contracts/intrinsic_probe/intrinsic_probe.cpp b/unittests/test-contracts/intrinsic_probe/intrinsic_probe.cpp index 0012504cfb..bca00b3dd3 100644 --- a/unittests/test-contracts/intrinsic_probe/intrinsic_probe.cpp +++ b/unittests/test-contracts/intrinsic_probe/intrinsic_probe.cpp @@ -88,8 +88,8 @@ int32_t check_transaction_authorization( const char* trx_data, uint32_t trx_len const char* perms_data, uint32_t perms_len ); // get_permission_lower_bound: aligned_span out-param (buffer). Wire-specific intrinsic -// (libraries/chain/webassembly/permission.cpp) backing CDT's sysio::get_permission wrapper, used on-chain by -// sysio.roa::active_key_matches to read an account's authority. by_owner lower_bound on +// (libraries/chain/webassembly/permission.cpp) backing CDT's sysio::get_permission wrapper +// for reading an account's authority. by_owner lower_bound on // (account, permission); returns -1 when no permission for `account` exists at/after the key, else the full // serialized record size (which may exceed buffer_size -- the size-query-then-fetch contract). The serialized // payload is pack(perm_name) ++ pack(parent_name) ++ pack(last_updated) ++ pack(authority). @@ -324,8 +324,8 @@ constexpr uint64_t U64_MAX = 0xFFFFFFFFFFFFFFFFULL; // ----------------------------------------------------------------------------- // get_permission_lower_bound record layout. A byte-for-byte mirror of CDT's // sysio::permission_record / perm_authority (libraries/sysiolib/contracts/sysio/permission.hpp): the exact -// shape on-chain consumers (sysio.roa::active_key_matches) unpack from this intrinsic's output. -// Defined locally so the probe verifies the raw host serialization against the real consumer contract without +// shape the CDT wrapper unpacks from this intrinsic's output. +// Defined locally so the probe verifies the raw host serialization against the CDT layout without // routing the CALL through the CDT wrapper. Note the host also packs a trailing `waits` vector that the // consumer struct intentionally omits -- unpack stops after `accounts` and tolerates the trailing bytes. // ----------------------------------------------------------------------------- @@ -1355,7 +1355,7 @@ class [[sysio::contract("intrinsic_probe")]] intrinsic_probe : public contract { // P2 -- get_permission_lower_bound // // Wire-specific intrinsic backing CDT's sysio::get_permission -- the only host path a contract has to read - // another account's authority on-chain (sysio.roa::active_key_matches). These probes pin the + // another account's authority on-chain. These probes pin the // pieces the CDT wrapper and its callers depend on: the serialized record layout, the size-query / // small-buffer contract, the not-found -1 sentinel, and the lower-bound (NOT exact-match) lookup semantics. // All queries target get_self(), whose owner/active permissions the Boost driver creates with a single K1