← Back to Blog

Native Account Abstraction Phase 1: How It Works Under the Hood

·Shell Chain Team
account-abstractionarchitecturepost-quantumtechnical

Where we left off

The previous AA post explained why Shell Chain implements account abstraction at the protocol layer rather than as an overlay. This post explains how Phase 1 is actually built.


The wire format

A batch transaction is identified by tx_type = 0x7E (decimal 126). The full structure, in Rust notation:

pub struct BatchTransaction {
    pub nonce:               u64,
    pub gas:                 u64,
    pub inner_calls:         Vec<InnerCall>,     // max AA_MAX_INNER_CALLS = 16
    pub paymaster:           Option<Address>,
    pub paymaster_signature: Option<Bytes>,
    pub validity_window:     Option<(u64, u64)>, // (not_before, not_after) Unix secs
    pub signature:           PqSignature,
}

pub struct InnerCall {
    pub to:        Address,
    pub value:     U256,
    pub data:      Bytes,
    pub gas_limit: u64,
}

The encoding is RLP, prepended with the 0x7E type byte — the same envelope used by EIP-2718 typed transactions on Ethereum. This means block body parsing code needs only a single branch on the first byte to distinguish batch from legacy transactions.


Signing domain isolation

A subtle but critical design decision: the hash that the sender signs over a batch transaction must not be forgeable from a v0.17.0 single-transaction signature (and vice versa).

We achieve this with a domain byte:

// v0.17.0 single tx
tx_hash = keccak256(0x01 || RLP(tx_fields))

// v0.18.0 batch tx
bundle_hash = keccak256(0x42 || RLP(tx) || RLP(bundle_signing_fields))

bundle_signing_fields is the ordered tuple of inner call hashes, paymaster address, and validity window. This means a valid batch signature is computationally useless for signing a legacy tx, and a valid legacy signature cannot be reused for a batch.


Paymaster admission

When a batch tx with a paymaster field arrives at the mempool:

  1. Balance check: the paymaster address must hold at least gas * gas_price at the current state root.
  2. Signature check: verify_ml_dsa65(paymaster_pubkey, paymaster_sig, keccak256("paymaster" || bundle_hash || validity_window)) must pass.
  3. Validity window: not_before ≤ now ≤ not_after (nodes allow ±30s clock skew).

If any of these fail, the tx is rejected at the gate — it never enters the mempool. This is intentionally stricter than Ethereum's eth_sendRawTransaction, which defers validation to block inclusion time.

At execution time, gas is debited from the paymaster account, not the sender. The sender's nonce is still incremented (preventing replay), but the sender's balance is untouched as long as the paymaster has sufficient funds.


Execution semantics

The executor processes inner calls in order:

for (i, call) in inner_calls.iter().enumerate() {
    let outcome = evm.execute(call);
    if outcome.is_revert() {
        // Roll back all state changes from calls 0..=i
        // Deduct gas consumed up to the revert point from paymaster (or sender)
        return BatchOutcome::Revert { inner_index: i, reason: outcome.revert_reason() };
    }
    logs.extend(outcome.logs);
}

The atomicity guarantee is straightforward: if call 3 of 5 reverts, the journal is rewound to the pre-batch snapshot. Gas consumed up to the revert is still charged (to prevent DoS via cheap reverts).

This is different from Ethereum's ERC-4337 model, where inner ops each get their own gas accounting. Shell Chain's model is simpler: one nonce, one gas budget, one signature, all-or-nothing.


SDK usage

import { buildBatchTransaction, signTransaction } from 'shell-sdk';

const batch = buildBatchTransaction([
  { to: tokenAddr, value: 0n, data: encodeTransfer(alice, 100n), gasLimit: 60_000n },
  { to: dexAddr,   value: 0n, data: encodeSwap(tokenAddr, 50n),  gasLimit: 80_000n },
]);

// Optional: attach a paymaster
const sponsored = buildSponsoredTransaction(batch, paymasterAddr, paymasterSig);

const signed = await signer.signTransaction(sponsored);
await provider.sendTransaction(signed);

The SDK normalizes all ML-DSA-65 naming variants ("Dilithium3", "MlDsa65", "ML-DSA-65") to the canonical NIST name internally. Existing code that used "Dilithium3" continues to work.


What Phase 2 will add

Phase 2 (v0.19.0 target) will extend the paymaster model to support contract-based paymasters — smart contracts that implement a validation interface, enabling programmable gas sponsorship policies. This is the equivalent of ERC-4337's IPaymaster, but integrated at the protocol layer with PQ-native signatures.

Session keys and guardian recovery will also land in Phase 2.


Resources