How to Work with Smart Accounts

Predict account addresses, inspect on-chain state, build and send ERC-4337 UserOps, and wait for receipts.

How to Work with Smart Accounts

This guide shows you how to work with ERC-4337 smart accounts linked to a Hyperauth identity — from predicting the account address before deployment through signing, sponsoring, and confirming a UserOperation.

Predict the smart account address

Before the account is deployed on-chain you can predict its deterministic address from the passkey's public key coordinates.

import { getSmartAccountAddress } from '@hyperauth/sdk';

const address = await getSmartAccountAddress({
  pubKeyX: '0xabc123...', // P-256 public key X coordinate (hex)
  pubKeyY: '0xdef456...', // P-256 public key Y coordinate (hex)
});

The pubKeyX and pubKeyY values come from the identity's verification method, available after calling client.query(). Pass a salt to derive alternative account addresses from the same key pair:

const address = await getSmartAccountAddress({ pubKeyX, pubKeyY, salt: 1 });

If you run a self-hosted vault, pass vaultUrl to point at your own deployment:

const address = await getSmartAccountAddress({
  pubKeyX,
  pubKeyY,
  vaultUrl: 'https://vault.example.com/api',
});

Inspect on-chain account state

getAccountState reads the deployed account's on-chain state — nonce, associated DID, active status, and public key.

import { getAccountState } from '@hyperauth/sdk';

const state = await getAccountState({ account: '0xabc...' });

console.log(state.nonce);    // string — current nonce
console.log(state.did);      // string — on-chain DID hash
console.log(state.isActive); // boolean
console.log(state.pubKeyX);  // string
console.log(state.pubKeyY);  // string

If you need to know whether an account has been deployed yet, check state.isActive. A newly predicted address that has not been used returns isActive: false.

Sign a UserOperation

signUserOp reads the account's encrypted key material from the vault and produces a WebAuthn-compatible ERC-4337 signature. The vault must be unlocked.

import type { UserOp } from '@hyperauth/sdk';

const userOp: UserOp = {
  sender: '0xabc...',
  nonce: '0x0',
  callData: '0x...',
  callGasLimit: '0x0',
  verificationGasLimit: '0x0',
  preVerificationGas: '0x0',
  maxFeePerGas: '0x0',
  maxPriorityFeePerGas: '0x0',
  signature: '0x',
};

const { signature, user_op_hash } = await client.signUserOp(userOp);
const signedOp = { ...userOp, signature };

Estimate gas

Before submitting, get gas estimates from the bundler:

import { estimateUserOpGas } from '@hyperauth/sdk';

const gas = await estimateUserOpGas(userOp);

const opWithGas: UserOp = {
  ...userOp,
  preVerificationGas: gas.preVerificationGas,
  verificationGasLimit: gas.verificationGasLimit,
  callGasLimit: gas.callGasLimit,
};

To make the transaction gasless for the user, call sponsorUserOp. This calls the paymaster's pm_sponsorUserOperation method and returns the UserOp with paymaster fields populated. It also fills in gas estimates internally, so there is no need to call estimateUserOpGas first.

import { sponsorUserOp } from '@hyperauth/sdk';

const sponsoredOp = await sponsorUserOp(userOp);

Sign after sponsoring:

const { signature } = await client.signUserOp(sponsoredOp);
const readyOp = { ...sponsoredOp, signature };

Send the UserOperation

import { sendUserOp } from '@hyperauth/sdk';

const userOpHash = await sendUserOp(readyOp);

All bundler functions use /api/bundler by default. To use a different bundler URL or a non-default entry point:

const userOpHash = await sendUserOp(
  readyOp,
  '0xEntryPointAddress',
  'https://bundler.example.com',
);

Wait for confirmation

waitForReceipt polls the bundler until the UserOperation is confirmed or the timeout expires (default 120 seconds).

import { waitForReceipt } from '@hyperauth/sdk';

const receipt = await waitForReceipt(userOpHash);

console.log(receipt.success);                    // boolean
console.log(receipt.receipt.transactionHash);    // on-chain tx hash
console.log(receipt.actualGasUsed);              // string

Adjust polling behaviour:

const receipt = await waitForReceipt(userOpHash, {
  timeout: 60_000,   // 60 seconds
  interval: 2_000,   // poll every 2 seconds
});

Fetch a receipt directly

If you already have the hash and want a one-shot check without polling:

import { getUserOpReceipt } from '@hyperauth/sdk';

const receipt = await getUserOpReceipt(userOpHash);
if (!receipt) {
  // Not yet confirmed
}

Full UserOp lifecycle example

import {
  getSmartAccountAddress,
  sponsorUserOp,
  sendUserOp,
  waitForReceipt,
} from '@hyperauth/sdk';

// 1. Predict address
const sender = await getSmartAccountAddress({ pubKeyX, pubKeyY });

// 2. Build a minimal UserOp
const userOp: UserOp = {
  sender,
  nonce: '0x0',
  callData: encodedCallData,
  callGasLimit: '0x0',
  verificationGasLimit: '0x0',
  preVerificationGas: '0x0',
  maxFeePerGas: '0x0',
  maxPriorityFeePerGas: '0x0',
  signature: '0x',
};

// 3. Sponsor (fills gas + paymaster fields)
const sponsored = await sponsorUserOp(userOp);

// 4. Sign
const { signature } = await client.signUserOp(sponsored);

// 5. Send
const hash = await sendUserOp({ ...sponsored, signature });

// 6. Confirm
const receipt = await waitForReceipt(hash);
console.log('confirmed:', receipt.success);

On this page