Skip to content

feat(universal-accounts): add UniversalStateInit action and handler - #16103

Open
Wiezzel wants to merge 2 commits into
masterfrom
wiezzel/uaid/state-init-action
Open

feat(universal-accounts): add UniversalStateInit action and handler#16103
Wiezzel wants to merge 2 commits into
masterfrom
wiezzel/uaid/state-init-action

Conversation

@Wiezzel

@Wiezzel Wiezzel commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Adds the UniversalStateInit action, which creates a 0u universal account on-chain from its state init: optional contract code, initial storage, and full-access keys. It mirrors the existing DeterministicStateInit action but additionally installs access keys and supports key-only (code-less) accounts. The action is gated behind a new nightly ProtocolFeature::UniversalAccounts.

  • Runtime handler that creates the account, installs code/data/keys, and settles the attached deposit against storage staking, reusing shared helpers with the deterministic handler.
  • Action validation: feature gate, a validity rule rejecting a state init with neither code nor keys, a derived-id check against the receiver, and per-entry key/value length limits.
  • Dedicated universal_state_init_* fee parameters; each installed key is charged as an add_full_access_key. Values currently mirror the deterministic action and are not yet estimator-calibrated (config-store fallback), left to a follow-up PR.
  • New ActionView variant plus conversions; protocol schema and OpenAPI/OpenRPC regenerated.
  • Validation unit test and test-loop tests covering a key-only account, a contract account, and repeated initialization.

Note: because universal accounts closely parallel deterministic accounts, several pieces here are very similar to or outright copied from the deterministic state-init code (validation, the runtime handler flow, the fee shape). Some shared logic is already deduplicated in this PR; the remaining overlap (e.g. folding the install/deploy handlers and consolidating the validators) is intentionally left for a follow-up clean-up PR, to keep this change's blast radius off the stable deterministic path.

Comment thread core/primitives/src/errors.rs Outdated
Comment on lines +475 to +491
InvalidUniversalStateInitReceiver {
receiver_id: AccountId,
derived_id: AccountId,
} = 22,
/// A `UniversalStateInit` state init defines neither contract code nor an
/// access key, so the resulting account could never be used.
UnusableUniversalStateInit = 23,
/// A storage key in a `UniversalStateInit` state init exceeds the limit.
UniversalStateInitKeyLengthExceeded {
length: u64,
limit: u64,
} = 24,
/// A storage value in a `UniversalStateInit` state init exceeds the limit.
UniversalStateInitValueLengthExceeded {
length: u64,
limit: u64,
} = 25,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered re-using the existing DeterministicStateInit error variants here, but that would be a backwards-incompatible RPC change: ActionsValidationError serializes the variant name as the JSON tag, and the deterministic variants are live (stable since v82), so sharing or renaming them would change the error strings clients match on. Borsh is unaffected (it's keyed on the discriminant). Hence the parallel Universal* variants; a neutral-name consolidation can be done deliberately in a follow-up PR.

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.20238% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.71%. Comparing base (e65c1fe) to head (d42d2a6).

Files with missing lines Patch % Lines
runtime/runtime/src/universal_account_id.rs 87.20% 7 Missing and 4 partials ⚠️
core/primitives/src/errors.rs 0.00% 10 Missing ⚠️
core/primitives/src/views.rs 35.71% 9 Missing ⚠️
core/primitives/src/action/mod.rs 20.00% 4 Missing ⚠️
...me-params-estimator/src/costs_to_runtime_config.rs 0.00% 3 Missing ⚠️
runtime/runtime/src/action_validation.rs 96.84% 0 Missing and 3 partials ⚠️
chain/rosetta-rpc/src/adapters/mod.rs 0.00% 1 Missing ⚠️
runtime/runtime/src/lib.rs 90.90% 1 Missing ⚠️
tools/state-viewer/src/contract_accounts.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #16103      +/-   ##
==========================================
+ Coverage   73.69%   73.71%   +0.02%     
==========================================
  Files         864      865       +1     
  Lines      192209   192529     +320     
  Branches   192209   192529     +320     
==========================================
+ Hits       141642   141917     +275     
- Misses      46085    46119      +34     
- Partials     4482     4493      +11     
Flag Coverage Δ
pytests-nightly 1.20% <0.00%> (-0.01%) ⬇️
unittests 69.99% <38.98%> (-0.06%) ⬇️
unittests-nightly 70.14% <87.20%> (+0.03%) ⬆️
unittests-spice 66.07% <87.20%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Base automatically changed from wiezzel/uaid/state-init to master July 23, 2026 10:44
@Wiezzel
Wiezzel force-pushed the wiezzel/uaid/state-init-action branch from 61c23ff to c884b35 Compare July 23, 2026 12:19
@Wiezzel
Wiezzel requested review from Trisfald and staffik July 23, 2026 12:30
@Wiezzel
Wiezzel marked this pull request as ready for review July 23, 2026 12:30
@Wiezzel
Wiezzel requested review from a team and frol as code owners July 23, 2026 12:30
@Wiezzel

Wiezzel commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

cc @mitinarseny

@github-actions

Copy link
Copy Markdown

Pull request overview

Adds a new UniversalStateInit action that creates a 0u universal account whose id is the SHA3-256 of its canonical borsh-encoded state init (contract code + storage + access keys). It closely mirrors DeterministicStateInit, adding access-key installation and a code-less (key-only) account mode, and is gated behind a new nightly ProtocolFeature::UniversalAccounts. The change is largely mechanical protocol wiring: action variant, fees, validation, runtime handler, view/borsh schemas, and RPC/tooling touch-ups.

Changes:

  • New Action::UniversalStateInit(Box<UniversalStateInitAction>) = 15 variant with borsh/serde schemas, ActionView, and OpenAPI/OpenRPC regeneration.
  • Runtime handler action_universal_state_init that creates the account, installs code via use_global_contract, writes storage entries, installs full-access keys via set_access_key_by_handle, and settles the deposit; the deposit-settlement helper is factored out and shared with the deterministic handler.
  • Action validation: feature gate, unusable-state rejection (no code + no keys), derived-id check against the receiver, per-entry key/value length limits, and post-quantum gating when any handle is ML-DSA-65.
  • New fee parameters action_universal_state_init{,_per_entry,_per_byte}, plus a per-key charge equivalent to add_full_access_key folded into both send and exec fees.
  • New ProtocolFeature::UniversalAccounts at protocol version 153 (nightly).
  • Wiring: check_actor_permissions, check_account_existence, is_deploy_like_action, pipelining, rosetta/state-viewer adapters.
  • Unit tests for validation and test-loop tests for key-only creation, contract creation, and idempotent repeat.

Reviewed changes

Per-file summary
File Description
core/primitives/src/action/mod.rs New UniversalStateInitAction struct and Action::UniversalStateInit variant; deposit accessor; post-quantum gate scans installed key handles.
core/primitives/src/universal_state_init.rs Adds len_bytes() sharing with deterministic, and take() for cloneless field extraction.
core/primitives/src/errors.rs Four new ActionsValidationError variants for universal state init.
core/primitives/src/views.rs New ActionView::UniversalStateInit = 17; From/TryFrom conversions.
core/primitives-core/src/deterministic_account_id.rs Extracts state_init_data_len_bytes shared helper.
core/primitives-core/src/version.rs New ProtocolFeature::UniversalAccounts at 153.
core/parameters/{res,src}/... New ActionUniversalStateInit* parameters and ActionCosts variants; base fees copied from deterministic (uncalibrated).
runtime/runtime/src/universal_account_id.rs New handler that creates the account and installs code/data/keys.
runtime/runtime/src/deterministic_account_id.rs Extracts settle_state_init_deposit.
runtime/runtime/src/action_validation.rs Feature gate, receiver-derivation check, unusable-init rejection, per-entry length limits; one happy-path + three negative unit tests.
runtime/runtime/src/config.rs total_send_fees + exec_fee for universal, via a shared universal_state_init_fee helper that includes a per-key add_full_access_key charge.
runtime/runtime/src/{lib,metrics,pipelining,actions}.rs Handler dispatch, metric counter, pipelining exclusion, actor/existence checks.
chain/{client,rosetta-rpc,jsonrpc/openapi}, tools/state-viewer Non-runtime wiring: pending-tx classification, rosetta TODO, OpenAPI bump, state-viewer action-type map.
test-loop-tests/src/tests/universal_account_id.rs Three integration tests: key-only, contract-backed, repeated init.
tools/protocol-schema-check/res/protocol_schema.toml Regenerated protocol schema hashes.

Findings

Non-blocking (suggestions / follow-ups):

  • runtime/runtime/src/action_validation.rs:496 — the validation unit test covers InvalidUniversalStateInitReceiver, UnusableUniversalStateInit, and pre-feature rejection, but the two length-limit branches (UniversalStateInitKeyLengthExceeded / UniversalStateInitValueLengthExceeded) have no coverage. The deterministic counterpart has them (see test_validate_universal_state_init neighbors around lines 1303/1317). Worth adding two short cases to prevent silent regressions in the limit wiring.

  • runtime/runtime/src/universal_account_id.rs:113-127 — inside the access-keys loop, borsh::object_length(&access_key) is recomputed every iteration for a value that only depends on AccessKey::full_access() with the loop-invariant nonce. Hoisting it out of the loop is a minor cleanup and matches the intent of the surrounding access_key_storage_usage code path in access_keys.rs:17.

  • runtime/runtime/src/universal_account_id.rs:35-45 vs deterministic_account_id.rs:38-48 — the handlers use different retry semantics: universal skips install whenever maybe_account.is_some(), while deterministic re-attempts deploy when account.contract().is_none(). Since failed actions trigger state_update.rollback() (see runtime/runtime/src/lib.rs:978), a partially-created universal account cannot persist, so this is safe. Worth a one-line comment tying the invariant to the rollback guarantee, or aligning with the deterministic pattern in the planned consolidation follow-up.

  • runtime/runtime/src/action_validation.rs:465-512 — no explicit upper bound on state_init.access_keys().len() or state_init.data().len(). Overall size is bounded transitively by the receipt size limit, so .checked_mul(num_keys).unwrap() in universal_state_init_fee (config.rs:235-244) can't realistically overflow, but the invariant is implicit. Not a defect — same shape as the deterministic path — just flagging for the eventual estimator-calibration pass mentioned in the PR description.

  • core/parameters/res/runtime_configs/parameters.yaml:135-152 — fees are copied from DeterministicStateInit with a TODO(universal-accounts) note. The PR description already flags calibration as a follow-up; leaving here so it isn't lost.

Existing thread on core/primitives/src/errors.rs:491 (author explains why the Universal* variants are parallel to Deterministic* rather than shared) is addressed and not re-raised.

✅ Approved

@Trisfald

Copy link
Copy Markdown
Contributor

Do we need a changelog entry or will it be done with the stabilization PR?

@Trisfald Trisfald left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double checking: in this PR 0u has no AccountType variant, and as such CreateAccount isn't guarded and Transfer can't pre-fund.

Do you plan to enable implicit UA in a follow up PR?

Comment thread runtime/runtime/src/universal_account_id.rs
Ok(())
}

fn validate_universal_state_init(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should have a limit on the number of data entries or access keys? Maybe many short ones can be an attack angle

@mitinarseny mitinarseny Aug 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There MUST be no limit on the number of entries, at least, as long as protocol itself doesn't have one. If no, then it MUST succeed as long as the StateInit's deposit + account's balance is enough to cover for storage staking.

Comment thread core/primitives/src/action/mod.rs Outdated
Comment thread core/primitives/src/errors.rs Outdated
Comment on lines +1549 to +1556
UniversalStateInit {
code: Option<GlobalContractIdentifierView>,
#[serde_as(as = "BTreeMap<Base64, Base64>")]
#[cfg_attr(feature = "schemars", schemars(with = "BTreeMap<String, String>"))]
data: BTreeMap<Vec<u8>, Vec<u8>>,
access_keys: Vec<PublicKeyHandle>,
deposit: Balance,
} = 17,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not versioned and creates inconsistency with the Action::UniversalStateInit layout. Can we reuse UniversalStateInit here?

Suggested change
UniversalStateInit {
code: Option<GlobalContractIdentifierView>,
#[serde_as(as = "BTreeMap<Base64, Base64>")]
#[cfg_attr(feature = "schemars", schemars(with = "BTreeMap<String, String>"))]
data: BTreeMap<Vec<u8>, Vec<u8>>,
access_keys: Vec<PublicKeyHandle>,
deposit: Balance,
} = 17,
UniversalStateInit {
state_init: UniversalStateInit,
deposit: Balance,
} = 17,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I somewhat blindly followed the precedent of DeterministicStateInit which is also a versioned struct, but also does this kind of flattening. Let me check if there was any good reason deterministic account were done that way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jakmeier Could explain why was ActionView::DeterministicStateInit implemented as it was, instead of re-suing the versioned struct?

Comment on lines +235 to +244
let all_entries_fee = entry_fee.checked_mul(num_entries).unwrap();
let all_bytes_fee = byte_fee.checked_mul(num_bytes).unwrap();
let all_keys_fee = key_fee.checked_mul(num_keys).unwrap();
base_fee
.checked_add(all_bytes_fee)
.unwrap()
.checked_add(all_entries_fee)
.unwrap()
.checked_add(all_keys_fee)
.unwrap()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure it's ok to panic on overflows here?

Comment on lines +31 to +60
// A `0u` account can only come into existence through this action, so
// an account that already exists here is the one this action initialized.
// Initialize on first sight; on repeat, skip straight to the deposit
// handling without touching the installed state.
let needs_init = maybe_account.is_none();
let account = match maybe_account {
Some(account) => account,
// Create without changing actor_id, so a same-receipt follow-up can't hijack the account.
None => maybe_account.insert(Account::new(
Balance::ZERO,
Balance::ZERO,
AccountContract::None,
storage_usage_config.num_bytes_account,
)),
};

if needs_init {
install_universal_account(
state_update,
account,
account_id,
&action.state_init,
result,
fees,
apply_state.block_height,
)?;
if result.result.is_err() {
return Ok(());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not true: the account can be "created" by receiving an incoming transfer first. StateInit should only be applied (i.e. "installed") if and only if the account didn't exist yet or if its initialized flag is unset.

Suggested change
// A `0u` account can only come into existence through this action, so
// an account that already exists here is the one this action initialized.
// Initialize on first sight; on repeat, skip straight to the deposit
// handling without touching the installed state.
let needs_init = maybe_account.is_none();
let account = match maybe_account {
Some(account) => account,
// Create without changing actor_id, so a same-receipt follow-up can't hijack the account.
None => maybe_account.insert(Account::new(
Balance::ZERO,
Balance::ZERO,
AccountContract::None,
storage_usage_config.num_bytes_account,
)),
};
if needs_init {
install_universal_account(
state_update,
account,
account_id,
&action.state_init,
result,
fees,
apply_state.block_height,
)?;
if result.result.is_err() {
return Ok(());
}
}
let account = match maybe_account {
Some(account) => account,
// Create without changing actor_id, so a same-receipt follow-up can't hijack the account.
None => maybe_account.insert(Account::new(
Balance::ZERO,
Balance::ZERO,
AccountContract::None,
storage_usage_config.num_bytes_account,
)),
};
if !account.is_initialized() {
install_universal_account(
state_update,
account,
account_id,
&action.state_init,
result,
fees,
apply_state.block_height,
)?;
if result.result.is_err() {
return Ok(());
}
}

Comment thread runtime/runtime/src/universal_account_id.rs Outdated
Comment on lines +119 to +121
// Mirror `access_key_storage_usage`: on-trie handle length + the access
// key's borsh length + the per-record overhead.
let key_bytes = (handle.trie_id_len() as u64)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it mean that smart-contracts will have to hardcode this trie_id_len value as well if they want to estimate deposit required for given StateInit?

Add the `UniversalStateInit` action (discriminant 15), which creates a
`0u` universal account on-chain from its state init: optional contract
code, storage entries, and full-access keys. It mirrors
`DeterministicStateInit` but also installs access keys and supports
key-only (code-less) accounts.

- Gate behind a new nightly `ProtocolFeature::UniversalAccounts`.
- Runtime handler in `universal_account_id.rs`, sharing the deposit
  settlement and data-length helpers with the deterministic handler.
- Action validation: feature gate, validity rule, derived-id check, and
  per-entry key/value limits, plus new `ActionsValidationError` variants.
- Dedicated `universal_state_init_*` fee params (values mirror the
  deterministic action; each access key is charged as an
  `add_full_access_key`). No estimator yet; the config-store fallback is
  used and calibration is deferred to a follow-up PR.
- `ActionView` variant and conversions; protocol schema and OpenAPI
  regenerated.
- Validation unit test plus test-loop tests (key-only, contract, and
  repeated init).
@Wiezzel
Wiezzel force-pushed the wiezzel/uaid/state-init-action branch from c884b35 to d42d2a6 Compare August 10, 2026 13:07
@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown

Greptile Summary

Adds the protocol-gated UniversalStateInit action and its end-to-end runtime, fee, validation, view, schema, and test integration.

  • Creates derived 0u accounts from optional global contract code, initial storage, and full-access key handles.
  • Charges state-init and per-key fees, settles deposits against storage staking, and blocks stale contract preparation.
  • Adds protocol/API representations and test-loop coverage for key-only, contract, and repeated initialization.
  • The generated RPC schemas contain one validation error that is absent from and contradicted by the implementation.

Confidence Score: 4/5

The PR appears safe to merge after correcting the non-blocking RPC schema mismatch for empty universal state initialization.

Runtime execution, charging, storage settlement, feature gating, and pipeline integration are internally consistent, but the generated API specifications expose a validation error that the implementation intentionally never returns.

Files Needing Attention: chain/jsonrpc/openapi/openapi.json, chain/jsonrpc/openapi/openrpc.json

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[UniversalStateInit action] --> B[Validate feature and derived receiver]
  B --> C{Account exists?}
  C -->|No| D[Create empty account]
  D --> E[Install optional global contract]
  E --> F[Write initial storage]
  F --> G[Install full-access keys]
  C -->|Yes| H[Keep installed state]
  G --> I[Settle deposit against storage staking]
  H --> I
  I --> J[Refund excess deposit]
Loading

Fix All in Claude Code Fix All in Codex

Reviews (1): Last reviewed commit: "refactor(universal-accounts): address re..." | Re-trigger Greptile

Comment on lines +3327 to +3334
},
{
"description": "A `UniversalStateInit` state init defines neither contract code nor an\naccess key, so the resulting account could never be used.",
"enum": [
"UnusableUniversalStateInit"
],
"type": "string"
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Impossible validation error in schema

The generated OpenAPI and OpenRPC schemas advertise UnusableUniversalStateInit, but the runtime has no corresponding error variant and explicitly accepts an initialization containing neither code nor access keys. Generated clients therefore expose and may handle an error that the API can never return.

Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants