UniversalStateInit action carrying that state — the code, storage, and access keys the account will start with. What differs between the three flows below is who signs the transaction that carries it, and when the account starts existing.
This page assumes you’re familiar with Universal Address and Access Keys.
This is a protocol-level walkthrough using raw transactions and RPC calls. Dedicated SDK helpers for building a
UniversalStateInit action are expected to follow in near-api-js and near-kit — check NEAR API for the latest tooling before hand-rolling this in production.Choosing a flow
The uninitialized account
Before any state init runs, a0u address can already receive NEAR: a plain Transfer to an address that doesn’t exist yet silently creates it as uninitialized — it holds a balance, but has no code, access keys, or storage.
Querying an uninitialized account looks like this:
- JSON
- JavaScript
- HTTPie
Example response (uninitialized)
Example response (uninitialized)
bootstrap_nonce is seeded as (block_height - 1) * 1,000,000 — the block the account was created at, using the exact same formula and multiplier NEAR uses for a freshly added access key’s starting nonce (see the nonce values note on the Transaction Anatomy page).state and bootstrap_nonce disappear from the response entirely, rather than flipping to "initialized" / null.
Example response (initialized)
Example response (initialized)
Why
bootstrap_nonce mattersAn uninitialized account has no access key yet, so there’s no view_access_key query to ask for a nonce. bootstrap_nonce — seeded from the block height the account was created at — is the only place to learn the value a self-signed bootstrap transaction’s nonce is based on: the transaction itself must use bootstrap_nonce + 1, the same “+1” rule as any other nonce.Relayer-assisted creation
An existing, funded account creates and pays for someone else’s universal account in a single transaction. This is the pattern for onboarding: the new owner doesn’t need to hold any NEAR up front.1
Build the state init and derive the address
PublicKeyHandle is the compact form an access key takes in a state init: the full public key for ed25519/secp256k1, or the 32-byte SHA3-256 hash for ml-dsa-65 — PublicKeyHandle::from(public_key) picks the right variant for you.Control over the account is entirely decided by whichever keys end up in access_keys at this step — there’s no separate “owner” concept. A relayer building this state init could just as well name its own key alongside (or instead of) the future owner’s; whoever holds any key committed here controls the account from the moment it initializes.2
Sign one transaction with UniversalStateInit and Transfer
The relayer signs a transaction from its own account, targeting the derived id, bundling the state init with a funding transfer:Either
UniversalStateInit or Transfer can run first. The one that runs first creates the account, and the other one then applies to it. The order matters for storage stake. If UniversalStateInit runs first, the account starts with a zero balance, so the state init’s own deposit must cover the storage stake of everything it installs. This is not needed when the account uses 770 bytes or less, as in this example. A FunctionCall can follow in the same batch too, to call into the contract the state init just installed.Why is
deposit zero here, and Transfer used instead? UniversalStateInitAction.deposit isn’t a general funding mechanism. It only tops up the account’s balance to cover its storage stake. This happens only the first time a state init initializes the account. A UniversalStateInit that lands on an already-initialized account always refunds the whole deposit, because there is nothing left for it to cover. A zero deposit works here because the new account uses 770 bytes or less, so it needs no storage stake. For a larger account, put the Transfer first, or set deposit to cover the storage stake. The Transfer sets the account’s spendable balance.3
Broadcast it
Self-signed bootstrap
The future owner already holds a key pair and just needs somewhere to send funds to before that key exists on-chain as an access key. No relayer, no second party — the account initializes itself.1
Fund the address
Anyone — typically the owner themselves, from another account or an exchange withdrawal — sends an ordinary transfer to the derived
0u address. This is the exact mechanic described in The uninitialized account: the transfer alone brings the account into existence, uninitialized.2
Read the bootstrap nonce
Query
view_account (see the example above) to confirm state is "uninitialized" and read bootstrap_nonce.3
Sign the state init with the account's own key
The transaction’s
signer_id and receiver_id are both the new account itself, signed with the key that’s inside state_init.access_keys — the same key pair the owner generated in step 1 of the relayer flow above:4
Broadcast it
Same
send_tx call as the relayer flow. Once it executes, the key that signed the bootstrap is a real full-access key, and the account behaves like any other.Contract-triggered creation
A deployed contract creates a universal account as part of its own execution — the pattern for factories that spin up new accounts on demand, or for airdrop/linkdrop-style contracts that pre-fund an address before its owner claims it.Unlike
app.bob.near, a universal account created this way is not a namespace child of the creating contract — its 0u address is just a hash of its own state, unrelated to the contract’s account id. “Spinning up an account” here means creating an independently-addressed account, not extending the contract’s own domain the way named sub-accounts do.1
Derive the account id on-chain
The contract asks the host to derive the id from the exact bytes it’s about to use, so the promise it creates in the next step is guaranteed to target the right receiver. The call also validates the bytes and reports the result, instead of silently deriving an id for anything:These are the raw host-function imports — there’s no
near-sdk-rs ergonomic wrapper for them yet, so this is what you write directly, the same low-level way you’d import any other host function it doesn’t wrap.universal_state_init_to_account_id returns a status instead of always succeeding. 0 means the id was derived and written to the register. 1 means the bytes don’t decode into a UniversalStateInit, which includes bytes with extra data at the end. 2 means they decode but aren’t the one canonical encoding: unsorted or duplicate data keys or access_keys. On anything but 0, the register is left unwritten. Check the status before moving on. Otherwise this is a pure computation: no side effects, and it can run inside a view call.2
Append the UniversalStateInit action to a promise
If step 1 returned a status other than Like in the relayer flow, this
0, do not create the promise.amount_ptr deposit only tops up the account’s storage stake, and only on the state init that actually initializes the account, refunding the rest back to the calling contract. A state init that lands on an account that’s already initialized — a repeat, idempotent call — always refunds the whole deposit, since there’s no storage left for it to cover. It doesn’t unconditionally add to the account’s balance the way a Transfer action does; if you want the new account to end up with a specific spendable balance, batch a Transfer action onto the same promise as well.There’s no host function to build up a
UniversalStateInit’s data map incrementally the way set_state_init_data_entry does for a deterministic account. If your contract needs to populate initial storage, it has to construct the whole Borsh-encoded state init itself before calling promise_batch_action_universal_state_init — the two host functions here only derive an id from bytes and attach an action, they don’t help build the bytes.3
Invalid input fails your function call
If the contract creates the promise with input that fails validation, the contract’s own function call fails with
NewReceiptValidationError. The failed call’s state changes are rolled back. None of the receipts it created run, including callbacks.promise_batch_action_universal_state_init does not check the bytes or the receiver when the contract calls it. The runtime checks them when the contract’s function call finishes, together with every other receipt that the call created. The inner ActionsValidation error says which check failed:The receiver check runs before the decoding check. A status of
0 from step 1 does not check the size limits in the last four rows.A promise that passes all checks is sent. The state init can still fail when it runs, for example if the account balance and the deposit together do not cover the storage stake. If your contract’s logic depends on the account having been created, check the outcome of the cross-contract call.Both host functions are gated by the
UniversalAccounts protocol feature, stable from protocol version 88. Calling them on a network that hasn’t reached that version fails to link, the same way any other protocol-gated host function does.