Shell Chain Native Account Abstraction Guide

Shell Chain implements account abstraction at the protocol layer. Every user account is treated as a smart account from the start: the chain validates post-quantum signatures natively, uses 32-byte native addresses end-to-end inside the PQVM, and exposes each address as 0x + 64 lowercase hex.

See also: Quickstart Guide · JSON-RPC API Reference · Post-Quantum Cryptography Guide


1. What "native AA" means on Shell Chain

Shell Chain does not rely on ERC-4337's EntryPoint / Bundler architecture. Instead, transaction validation is part of the base protocol:

  • Default path: built-in post-quantum signature validation
  • Upgradeable path: account-specific validation contract logic
  • Stable account identity: address stays the same across key rotation
  • PQVM execution: the PQVM operates on Shell's full 32-byte addresses (no truncation to 20 bytes)

In practice, this means the chain can support:

  • first-use account creation from a PQ public key
  • key rotation without changing account identity
  • custom validation logic such as multisig or social recovery

2. Address format

2.1 Native 32-byte address derivation

Shell Chain derives account addresses from the signing algorithm and public key:

preimage = algo_id(1 byte) || pubkey(n bytes)
address  = blake3(preimage)
  • algo_id = SignatureType::as_u8() is one byte
  • the final address is the full 32-byte BLAKE3 output
  • do not prepend a version byte; it would produce a different address from the node and SDK

There is no internal 20-byte address model. The PQVM, RPC layer, genesis format, and storage all operate on the same native 32-byte address.

2.2 External address encoding

Shell Chain exposes one valid representation of each 32-byte address:

0x<64 lowercase hex characters>

Example:

  • canonical form: 0x0b871be37a4af6348d28a90dbecc96820aa50d5178b7cbd7ced3d710e5a7eda9

2.3 Why Shell Chain keeps one canonical form

The 0x + 64 lowercase hex format is canonical for JSON-RPC, genesis, CLI, SDK APIs, and human-facing displays. It refers to the full 32-byte native address.


3. Validation model

Shell Chain uses a three-layer validation flow.

Layer Trigger Validation rule Purpose
Layer 1 First transaction from an account with no state entry Re-derive tx.from from (algo_id, pubkey) and verify signature Account creation / first-use safety
Layer 2 Existing account with validation_code_hash = None Verify pubkey_hash and PQ signature Normal operation with key rotation support
Layer 3 Existing account with validation_code_hash = Some(hash) Call account-specific validation logic in the PQVM Multisig / recovery / custom policies

3.1 Layer 1 — first-use validation

When the account does not yet exist in world state:

  1. the node requires sender_pubkey
  2. it derives the expected address from (algo_id, pubkey)
  3. it checks that the derived address matches tx.from
  4. it verifies the PQ signature

This is the only stage where address derivation itself is re-checked.

3.2 Layer 2 — default existing-account validation

Once an account exists and uses the built-in validator path:

  1. the node resolves the sender public key
  2. it compares blake3(pubkey) with account.pq_pubkey_hash
  3. it verifies the PQ signature

At this stage the chain no longer needs to re-derive the address from the new public key, which is what makes key rotation without address changes possible.

3.3 Layer 3 — custom validator path

If account.validation_code_hash is set, the chain delegates validation to account-specific PQVM logic instead of the built-in PQ verifier.

This is the hook for advanced account policies such as:

  • multisig
  • social recovery
  • time locks
  • contract-defined signature / authorization gates

4. Custom validator contract interface

Shell Chain's native AA path calls a validation function with the transaction hash and signature material:

interface IAccountValidator {
    function validateTransaction(
        bytes32 txHash,
        bytes calldata sig,
        bytes calldata pubkey
    ) external returns (bytes1);
}

Validation call behavior

  • target: the account address being validated
  • gas cap: 500_000
  • input: validateTransaction(bytes32,bytes,bytes)
  • execution model: isolated validation dry-run against a world-state snapshot
  • replay guard: protocol nonce equality is still enforced before execution

Current limitation

Custom validators currently receive only txHash, sig, and pubkey. That is enough for custom signature / policy checks, but not enough to safely replace canonical nonce handling. Because of that, Shell Chain keeps the protocol nonce check as the baseline replay guard even when validation_code_hash is set. Richer custom nonce schemes are deferred until the validator ABI carries more transaction context.

Validation succeeds when the return value is interpreted as true / valid. Current node logic accepts the common "magic valid" encodings:

  • raw 0x01
  • ABI-encoded bool(true)
  • ABI-encoded bytes1(0x01)

This call path is implemented in:

  • crates/pqvm/src/aa_validation.rs
  • crates/pqvm/src/tx_validation.rs
  • contracts/DefaultPQValidator.sol

5. Key rotation and validator upgrades

The long-term AA model includes a protocol-managed account controller for:

  • rotateKey(pubkey, algo_id)
  • setValidationCode(code_hash)
  • clearValidationCode()

Why address rotation is not required

Shell Chain checks address derivation only when the account is first created. After that, validation depends on the account's stored pq_pubkey_hash or custom validator configuration.

That means a user can:

  1. keep the same account address
  2. rotate to a new keypair
  3. even move to a different supported PQ algorithm

without changing the account's on-chain identity.

Current status

The validation dispatcher, AccountManager system-contract flow, and reference validator contract are all landed. The remaining AA work is focused on wider workspace regression and final rollout validation.


6. Session Keys

Shell Chain AA Phase 2 supports session keys: short-lived delegated signing keys derived from a PQ-HD wallet seed.

Authorization model

A root account signs a SessionAuth authorization over:

PQTX_SESSION_V2 || session_pubkey || session_algo || target_present || target_or_zero || value_cap || expiry_block || chain_id

The authorization binds the session key and its declared signature algorithm to one chain, an expiry block, a value cap, and optionally one target address. This prevents a session authorization created for one dApp or chain from being replayed elsewhere.

target_present is one byte: 0x00 for an unrestricted session or 0x01 when a target is present. target_or_zero is always 32 bytes. The explicit presence byte keeps an unrestricted authorization distinct from one restricted to the zero address.

Wallet flow

PQ-HD wallets derive session keys from:

m/1'/1'/session_index'

The Shella wallet exposes a privileged background operation that:

  1. decrypts the local HD seed after password verification
  2. derives the requested root account and session key
  3. signs the session authorization with the root key
  4. optionally signs a 32-byte AA bundle signing hash with the session key
  5. zeroizes root/session secret material after the operation

The returned SessionAuth has an empty session_signature until a concrete AA bundle signing hash is supplied.

Client rules

  • Use SDK session helpers instead of constructing SessionAuth by hand.
  • Do not persist session secret keys.
  • Prefer short expiry blocks and per-dApp target restrictions.
  • Treat missing session_signature as an unsigned draft, not as a submit-ready AA bundle.

7. How this differs from ERC-4337

Topic Shell Chain native AA ERC-4337
Validation location Protocol-level EntryPoint contract
Bundler required No Yes
Separate alt-mempool No Usually yes
Default validator Built into the chain Wallet contract-defined
Address format 0x + 64 lowercase hex (32-byte native address) 0x... (20-byte)

Shell Chain's model is closer to a native smart-account chain than to an Ethereum add-on AA layer.


8. Rollout boundary for testnet

Native AA + 32-byte native addressing is a release-boundary change.

For the first public M9-compatible testnet:

  • a one-time testnet reset is required
  • genesis must be rebuilt with the new 32-byte address space
  • old world-state / RocksDB / mempool data is not migrated in place

The same private keys may be reused, but their exported Shell Chain addresses must be re-derived under the new address scheme.


9. Implementation status

Area Status Notes
PQ address derivation (`blake3(algo_id pubkey)`)
RPC / CLI / genesis address migration ✅ Implemented User-facing outputs now use 32-byte 0x hex
AA validation dispatcher core ✅ Implemented Layer 1 / Layer 2 / Layer 3 routing exists
Custom validator dry-run path ✅ Implemented Snapshot-based EVM validation with gas cap
Mempool / production ingress integration ✅ Implemented Revalidation and block-production paths are wired
AccountManager (rotateKey, setValidationCode) ✅ Implemented Native system-contract flow is live and tested
Reference validator contract ✅ Implemented contracts/DefaultPQValidator.sol + compiled runtime fixture
Session-key authorization ✅ Implemented SDK helpers and Shella wallet background authorization flow

10. Developer pointers

If you want to trace the implementation in code:

  • crates/primitives/src/address.rs — address derivation and 32-byte hex encoding
  • crates/pqvm/src/aa_validation.rs — native AA dispatcher and custom-validator path
  • crates/pqvm/src/tx_validation.rs — transaction validation entry points
  • crates/mempool/src/pool.rs — mempool-side validation integration

11. Summary

Shell Chain's AA model combines:

  • protocol-native smart-account validation
  • post-quantum key material
  • 32-byte 0x + 64 hex chain-specific addresses
  • future-safe key rotation and validator upgrades
  • batch transactions and native paymaster (v0.18.0 Phase 1)

The goal is to make account abstraction the default account model, not an optional overlay.


12. Batch Transactions (v0.18.0 Phase 1)

Shell Chain v0.18.0 introduces AA batch transactions — a single PQ-signed transaction that executes multiple inner calls atomically.

11.1 Transaction type 0x7E

Batch transactions use tx type 0x7E (126). The payload carries:

pub struct InnerCall {
    pub to: Option<Address>,   // None = contract creation
    pub value: U256,
    pub data: Bytes,
    pub gas_limit: u64,        // per-inner advisory cap; sum ≤ outer gas_limit
}

pub struct AaBundle {
    pub inner_calls: Vec<InnerCall>,
    pub paymaster: Option<Address>,
    pub paymaster_signature: Option<Bytes>,
}

AaBundle is an optional trailing field on SignedTransaction. The base Transaction struct is unchanged, preserving backward compatibility.

11.2 Execution semantics

  • All inner calls share one nonce (the outer transaction nonce)
  • All inner calls share one gas budget (the outer gas_limit)
  • Execution is atomic: if any inner call reverts, the entire batch reverts
  • Each inner call produces its own receipt logs
  • Per-inner gas_limit is an advisory cap; the node still enforces the sum

Maximum inner calls: 16 per batch.

11.3 Building a batch transaction with the SDK

First complete the Counter deployment tutorial, including its matching node/SDK source versions and local project setup. The npm release 0.13.0 uses V1 transaction signing and cannot submit to the current V2 node source; use the documented source pair until a compatible package is published. Keep the local node running and retain artifacts/Counter.json and your funded keystore. Run only this example from that account while checking its nonce.

Save the following as scripts/batch-counter.ts. It submits two Counter increments as one batch, waits for a successful receipt, then verifies that the count increased by two and the account nonce increased by one:

import { readFile } from "node:fs/promises";
import { buildBatchTransaction, createShellProvider, decryptKeystore, type AaInnerCall } from "shell-sdk";
import { readContract } from "shell-sdk/contracts";

const password = process.env.SHELL_KEYSTORE_PASSWORD;
const counter = process.env.SHELL_COUNTER_ADDRESS;
if (!password || !counter) throw new Error("Set SHELL_KEYSTORE_PASSWORD and SHELL_COUNTER_ADDRESS");
const provider = createShellProvider({ rpcHttpUrl: process.env.SHELL_RPC_URL ?? "http://127.0.0.1:8545" });
const chainId = Number(process.env.SHELL_CHAIN_ID ?? 1337);
const signer = await decryptKeystore(
  JSON.parse(await readFile(process.env.SHELL_KEYSTORE_PATH ?? "my-key.json", "utf8")), password,
);
const artifact = JSON.parse(await readFile("artifacts/Counter.json", "utf8"));
try {
  const from = signer.getAddress() as `0x${string}`;
  const before = await readContract({ provider, address: counter, abi: artifact.abi, functionName: "get" }) as bigint;
  const nonce = Number(BigInt(await provider.client.request({ method: "eth_getTransactionCount", params: [from, "pending"] })));
  const gasPrice = Number(BigInt(await provider.client.request({ method: "eth_gasPrice", params: [] })));
  const innerCalls: AaInnerCall[] = [
    { to: counter, value: "0x0", data: "0xd09de08a", gas_limit: "0x186a0" },
    { to: counter, value: "0x0", data: "0xd09de08a", gas_limit: "0x186a0" },
  ];
  const { tx, aa_bundle } = buildBatchTransaction({
    chainId, nonce, innerCalls, gasLimit: 300_000,
    maxFeePerGas: gasPrice * 2, maxPriorityFeePerGas: 0,
  });
  const signed = await signer.buildSignedTransaction({ tx, aaBundle: aa_bundle, includePublicKey: true });
  const hash = await provider.sendTransaction(signed);
  console.log("batch:", hash);
  let receipt: { status: string } | null = null;
  for (let attempt = 0; attempt < 60; attempt++) {
    receipt = await provider.client.request({ method: "eth_getTransactionReceipt", params: [hash as `0x${string}`] });
    if (receipt) break;
    await new Promise(resolve => setTimeout(resolve, 1_000));
  }
  if (!receipt) throw new Error(`Batch still pending: ${hash}; check this hash before retrying`);
  if (receipt.status !== "0x1") throw new Error(`Batch reverted: ${hash}`);
  const after = await readContract({ provider, address: counter, abi: artifact.abi, functionName: "get" }) as bigint;
  if (after !== before + 2n) throw new Error("Expected both increments to commit");
  const nextNonce = Number(BigInt(await provider.client.request({ method: "eth_getTransactionCount", params: [from, "latest"] })));
  if (nextNonce !== nonce + 1) throw new Error("Expected one nonce for the batch");
  console.log("count:", before, "->", after, "nonce:", nonce, "->", nextNonce);
} finally {
  signer.dispose();
}

Run it in the Counter project, replacing the address with the complete 32-byte address printed by deployment and using your keystore password:

SHELL_COUNTER_ADDRESS=0xYOUR_64_HEX_CHARACTER_CONTRACT_ADDRESS \
SHELL_KEYSTORE_PATH=my-key.json \
SHELL_KEYSTORE_PASSWORD=dev-password \
npx tsx scripts/batch-counter.ts

After the Counter tutorial's initial increment, the output should show count: 1n -> 3n and one nonce consumed. A submitted hash is not proof of success: a pending receipt must be queried again, and status: "0x0" means the batch reverted. Earlier inner-call state changes and logs are rolled back if a later call reverts; the outer nonce and gas accounting still apply.

11.4 Estimating batch gas

Inside the try block above, before signing, you can call shell_estimateBatch:

const estimate = await provider.estimateBatch({
  from,
  inner_calls: innerCalls,
});
console.log(estimate.total_gas); // hex string

The builder option is innerCalls, while the RPC request uses inner_calls. Individual calls use hex quantities and gas_limit. A supplied inner gas limit is a budget, not evidence that the call succeeds; always check the actual batch receipt. The example's outer budget includes room for the AA envelope and per-call overhead in addition to the inner budgets.


12. Sponsored Gas / Paymaster (v0.18.0 Phase 1)

Shell Chain v0.18.0 introduces native paymaster accounts. A paymaster is an ordinary chain account that authorizes paying gas on behalf of another account.

12.1 How paymaster authorization works

A batch transaction (or regular transaction) includes:

  • paymaster: Address — the account that will pay gas
  • paymaster_signature: Bytes — ECDSA/PQ signature by the paymaster over the canonical transaction hash

The node validates the paymaster signature during mempool admission:

  1. Checks that the paymaster address has sufficient balance
  2. Verifies the paymaster signature against the canonical hash
  3. On execution, deducts gas cost from the paymaster account instead of the sender

Scope: EOA/native paymasters are submit-ready when the paymaster signature is present and valid. Contract paymasters expose a versioned capability probe through shell_estimatePaymasterGas, but current nodes return simulation_status: "cap_only" rather than a full validatePaymasterOp staticcall simulation.

12.2 Checking paymaster policy

const policy = await provider.getPaymasterPolicy("0xpaymasterAddress");
// { address, hasPqPubkey, balance, policy: "eoa-open", maxGasSponsorship: null }

12.3 Checking contract-paymaster capability

Updated node and SDK source builds support actual validation simulation; these changes are not in the currently published node v0.27.4 or SDK 0.13.0 packages. Supply the actual outer bundle gas limit and fee. Here paymaster is your contract address, innerCallsData is the canonical RLP inner-call list, and paymasterContext is the context required by that policy:

const gas = await provider.estimatePaymasterGas({
  paymaster,
  sender: address,
  inner_calls_data: innerCallsData,
  gas_limit: "0x493e0", // use this bundle's actual outer gas limit
  max_fee_per_gas: "0x7", // use this bundle's actual maximum fee
  paymaster_context: paymasterContext,
});

if (gas.simulation_status !== "simulated") {
  // Cap-only responses have no measured validation gas.
  disableContractPaymasterSubmission();
}

With gas_limit, updated source executes admission's bounded STATICCALL and returns version 2, capability paymaster_staticcall, measured validation_gas, max_gas_cost (gas limit times maximum fee), and within_cap: true on acceptance. Measured gas includes intrinsic and wrapper costs under the existing 50,000 cap; it is not a transaction fee receipt. Rejection, revert, attempted state writes or budget exhaustion fail the RPC with -32000.

Without gas_limit, or on older servers, simulation_status: "cap_only" retains null validation_gas and within_cap. Wallets and dApps must not treat this as successful validation. Check the response even when supplying the new field.

Simulation uses current state and admission's next-candidate block context, commits no writes, and preserves existing paymaster ABI/activation rules. It does not validate bundle structure, signatures, nonce or funding, and cannot guarantee sponsorship at a later state or height.

12.4 Checking sponsorship after execution

const info = await provider.isSponsored("0xtxhash");
// { found: true, location: "chain", is_aa_bundle: true, sponsored: true,
//   paymaster: "0x...", sender: "0x...", inner_call_count: 2 }

12.5 Security and hash domains

Batch and sponsored transactions use a distinct hash domain from regular transactions. This prevents any v0.17 or earlier signature from being replayed as a v0.18.0 AA bundle. The hash domain is defined in crates/primitives/src/transaction.rs.


13. Implementation pointers (v0.18.0)

File Purpose
crates/primitives/src/transaction.rs AaBundle, InnerCall wire structs; hash domain
crates/pqvm/src/executor.rs Batch execution loop; inner-call receipts
crates/pqvm/src/tx_validation.rs Paymaster balance + signature pre-check
crates/rpc/src/api.rs shell_estimateBatch, shell_getPaymasterPolicy, shell_isSponsored
tests/e2e/aa_batch_test.rs E2E batch execution tests
tests/e2e/aa_sponsored_test.rs E2E paymaster tests