Quickstart: Smart Accounts
Three steps: create an account, make it ready, send calls. @hpp-io/aa-sdk
talks to HPP's bundler and paymaster and handles the chain-specific details for you.
npm i @hpp-io/aa-sdk viem
Requirements: Node.js 20+, ESM ("type": "module" or .mjs), viem 2.56+. The SDK runs in Node and
in the browser.
Test funds (Sepolia): test ETH from the HPP Sepolia Faucet. For test USDC.e or sandbox sponsorship, see Gas sponsorship → Apply.
Pick the network once and reuse it:
- HPP Sepolia
- HPP Mainnet
import { hppSepolia, TOKENS } from "@hpp-io/aa-sdk";
const chain = hppSepolia;
const bundlerUrl = "https://aa-sepolia.hpp.io/rpc";
const USDCe = TOKENS[181228].USDCe;
import { hppMainnet, TOKENS } from "@hpp-io/aa-sdk";
const chain = hppMainnet;
const bundlerUrl = "https://aa.hpp.io/rpc";
const USDCe = TOKENS[190415].USDCe;
1. Create an account
The owner is whatever signs for the user. Pick the tab that matches your app.
- Server or local key
- Embedded wallet (Privy, …)
- Browser wallet (MetaMask, …)
import { createHppAccount } from "@hpp-io/aa-sdk";
import { privateKeyToAccount } from "viem/accounts";
const user = await createHppAccount({ owner: privateKeyToAccount(KEY), chain, bundlerUrl });
console.log(user.address, user.mode); // mode "7702": the key's own address becomes the smart account
import { createHppAccount, toEmbeddedOwner } from "@hpp-io/aa-sdk";
import { useWallets, useSign7702Authorization } from "@privy-io/react-auth";
const { wallets } = useWallets();
const { signAuthorization } = useSign7702Authorization();
const w = wallets.find((x) => x.walletClientType === "privy")!;
const provider = await w.getEthereumProvider();
const owner = toEmbeddedOwner({
address: w.address as `0x${string}`,
signMessage: (hash) => provider.request({ method: "personal_sign", params: [hash, w.address] }),
signTypedData: (td) => provider.request({ method: "eth_signTypedData_v4", params: [w.address, JSON.stringify(td, (_, v) => (typeof v === "bigint" ? v.toString() : v))] }),
signAuthorization: (a) =>
signAuthorization({ contractAddress: a.contractAddress, chainId: a.chainId, nonce: a.nonce }, { address: w.address })
.then((s) => ({ address: a.contractAddress, chainId: a.chainId, nonce: a.nonce, r: s.r, s: s.s, yParity: s.yParity })),
});
const user = await createHppAccount({ owner, chain, bundlerUrl });
signTypedData is needed for x402 payments and payment delegations; without it those calls fail.
Register HPP with viem's defineChain in your embedded-wallet provider (hppSepolia / hppMainnet are
exactly that) — signing is chain-agnostic, so no vendor approval is needed. Sign 32-byte hashes with the
wallet's EIP-1193 personal_sign; some SDKs' signMessage() treats the input as text.
import { createHppAccount } from "@hpp-io/aa-sdk";
import { createWalletClient, custom } from "viem";
const wallet = createWalletClient({ account: address, chain, transport: custom(window.ethereum) });
const user = await createHppAccount({ owner: wallet, chain, bundlerUrl }); // mode "factory"
console.log(user.address); // a NEW address — show the user where to deposit
Browser wallets do not let dapps sign EIP-7702 authorizations, so the account is a separate Kernel contract with its own address. Everything after this step is identical.
2. Make it ready (once per account)
await user.ensureReady({ sponsor });
This lands one transaction: the EIP-7702 delegation (same address) or the account deployment (new address). Who pays for it:
- a funded viem
WalletClientpassed assponsor(scripts, servers), or - a
RemoteSponsorfrom the browser — two callbacks that hand the signed request to your backend, which sends the transaction and returns its hash, or - the owner itself, if you omit
sponsor— only for a plain private-key owner holding a little ETH. Embedded-wallet owners cannot send transactions themselves, so they need a sponsor.
import type { RemoteSponsor } from "@hpp-io/aa-sdk";
// browser → your backend sends the setup transaction; the user never needs ETH
const sponsor: RemoteSponsor = {
submitDelegation: ({ account, authorization }) => post("/api/sponsor", { type: "delegate", account, authorization }),
submitDeploy: ({ account, initData, salt }) => post("/api/sponsor", { type: "deploy", account, initData, salt }),
}; // each callback resolves to the transaction hash
On the backend: for delegate, send a type-4 transaction with authorizationList: [authorization] (set gas
explicitly, e.g. 3M — estimates on HPP can be low); for deploy, call KernelFactory.createAccount(initData, salt). Check that the authorization targets Kernel v3.3 on the right chain, and rate-limit the endpoint —
it spends your ETH. When posting JSON, serialize bigint fields (such as v) as strings.
3. Send calls
import { encodeFunctionData, parseAbi } from "viem";
const erc20 = parseAbi(["function transfer(address,uint256) returns (bool)"]);
const r = await user.sendCalls([
{ to: USDCe, data: encodeFunctionData({ abi: erc20, functionName: "transfer", args: [to, 1_000_000n] }) },
// add more calls — they run together in ONE UserOperation, or not at all
]);
if (!r.success) throw new Error(`reverted: ${r.txHash}`);
sendCalls returns { success, txHash, userOpHash, receipt, fee }. The first sendCalls on a new account
also installs the session module, so it is slightly heavier than later ones.
- It does not throw when the operation was included but reverted — check
success. - It throws when the bundler or paymaster rejects the request before inclusion (for example, the account cannot pay its gas). The error carries a reason — see Reference.
Without sponsorship the account pays its own gas in ETH, so it needs a little ETH at its address.
Check it worked: open the transaction on the explorer —
https://explorer.hpp.io/tx/${r.txHash} (Mainnet) or https://sepolia-explorer.hpp.io/tx/${r.txHash}
(Sepolia). The transaction is the bundler's; your calls appear inside it, from your account's address.
4. Turn on gas sponsorship (optional)
To let users transact with no ETH at all, add the paymaster option with the policy HPP issued to your app:
const user = await createHppAccount({
owner, chain, bundlerUrl,
paymaster: { policyId: "<your policy>", anchorId: "<this user>" },
});
const r = await user.sendCalls([...]);
console.log(r.fee); // { mode: "sponsored", freeRemaining: 2 } or { mode: "token", charged, symbol: "USDC.e", … }
On HPP Sepolia you can try this today with sandbox access — Gas sponsorship explains policies, anchors, paying gas in USDC.e, and how to request access.
What the SDK handles for you
HPP is an Arbitrum Orbit chain and Kernel is a modular account. Hand-written ERC-4337 code hits a handful of
traps here — a priority fee of 0, preVerificationGas that is mostly L1 data cost, EIP-7702 delegation
details, validator install data, and nonce keys for sessions. The SDK takes care of all of them. If you
use viem or permissionless directly, read Reference → If you bypass the SDK.
Next
- Gas sponsorship — free operations for your users, then gas in USDC.e
- Agent permissions — let an agent or backend act within limits
- Reference — endpoints, contract addresses, limits