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); // stringIf 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,
};Sponsor gas with a paymaster
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); // stringAdjust 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);