HPP Account Abstraction SDK. Kernel v3.3 smart accounts on HPP (EIP-7702 or factory), Smart Sessions for agents, and the HPP bundler — with the chain-specific pitfalls handled inside the library.
Status: HPP Sepolia only (chain 181228). Gas is paid by the account until the HPP paymaster is live.
npm i @hpp-io/aa-sdk viemSource: github.com/hpp-io/hpp-aa-sdk · Infra & guide: hpp-io/hpp-bundler (docs/DEVELOPER_GUIDE.md) · Example app: hpp-io/hpp-aa-examples
import { createHppAccount, hppSepolia, TOKENS } from "@hpp-io/aa-sdk";
import { privateKeyToAccount } from "viem/accounts";
import { encodeFunctionData, parseAbi } from "viem";
// 1. an account. `owner` is any viem LocalAccount (Privy/Dynamic embedded signer, server key)
// or a WalletClient from a browser wallet (MetaMask, Rabby …).
const user = await createHppAccount({ owner: privateKeyToAccount(KEY), chain: hppSepolia, bundlerUrl: BUNDLER_URL });
await user.ensureReady({ sponsor }); // once. 7702: delegation tx · factory: createAccount tx. `sponsor` pays.
// 2. send calls — one UserOperation, atomic. approve + pay in a single prompt.
const erc20 = parseAbi(["function transfer(address,uint256) returns (bool)"]);
await user.sendCalls([{ to: TOKENS[181228].USDCe, data: encodeFunctionData({ abi: erc20, functionName: "transfer", args: [to, 1_000_000n] }) }]);
// 3. delegate to an agent — one signature, then the agent acts within the limits, no prompts.
const { permissionId } = await user.grantSession({
signer: agent.address,
actions: [{ target: TOKENS[181228].USDCe, signature: "transfer(address,uint256)" }],
spend: [{ token: TOKENS[181228].USDCe, limit: 5_000_000n }], // 5 USDC.e total
validUntil: Math.floor(Date.now() / 1000) + 30 * 86400,
});Agent side:
import { createHppSessionClient } from "@hpp-io/aa-sdk";
const agentClient = await createHppSessionClient({ account: user.address, permissionId, signer: agentKey, bundlerUrl: BUNDLER_URL });
await agentClient.sendCalls([{ to: USDCe, data: transferCalldata }]); // over the cap → rejected at validation, no gas spentRevoke: await user.revokeSession(permissionId).
Result handling: sendCalls, grantSession, revokeSession return { success, txHash, userOpHash, receipt } and do not throw when the
UserOperation was mined but its execution reverted (success: false). They do throw when the bundler or paymaster rejects the
request before inclusion (validation revert, policy denial, budget exhausted). Check success after every call.
The HPP paymaster shares the bundler URL and takes { policyId, anchorId? } as the 7677 context. policyId is your
app's policy (issued by HPP ops); anchorId is the per-user entitlement your backend attaches for the logged-in
user — never something the end user types.
const user = await createHppAccount({ owner, chain: hppSepolia, bundlerUrl, paymaster: { policyId, anchorId } });
await user.sendCalls([...]); // account holds 0 ETH; the paymaster's deposit paysA policy is one of three kinds (HPP ops set it):
fee_mode |
who pays | what the user needs |
|---|---|---|
sponsored |
HPP (VerifyingPaymaster) | nothing |
token |
the user, in USDC.e, charged after execution (SingletonPaymaster postOp) | USDC.e balance + a one-time approve |
sponsored_then_token |
free for the first N ops per anchor, then USDC.e | approve while ops are still free |
The SDK handles the switch: the same sendCalls call goes to the free paymaster while free ops remain and to the
ERC-20 paymaster afterwards. Every SendResult carries fee:
const r = await user.sendCalls([...]);
r.fee // { mode: "sponsored", freeRemaining: 2 }
// { mode: "token", token, symbol: "USDC.e", decimals: 6, exchangeRate, maxToken, charged } ← charged = actual USDC.e takenShow the fee before signing — quoteFee runs the stub + gas estimate and returns the upper bound the user will be asked to hold:
const q = await user.quoteFee(calls);
if (q.mode === "token") ui.show(`up to ${formatUnits(q.maxToken, q.decimals)} ${q.symbol} (actual cost is charged)`);Approve once, while it is free — the ERC-20 paymaster pulls USDC.e with transferFrom, so the account must approve it.
Fold the approve into an onboarding op (it costs the user nothing under sponsored_then_token):
await user.ensureFeeAllowance({ calls: [enableSessionsCall(...)] }); // approve + your calls in ONE free op; null if not neededRejections arrive as JSON-RPC -32000 with data.reason. Two of them mean "start over from the stub" and the SDK retries
them for you (free_exhausted: another op took the last free slot; fee_quote_stale: the exchange rate moved > 3% since the quote).
The rest need the user or the app: fee_balance_insufficient, fee_allowance_missing, would_revert (the op would leave the
account unable to pay — e.g. it moves the USDC.e out), sender_blocked / anchor_blocked (repeated failed ops), plus the policy
ones (policy_required, anchor_unknown, budget_exceeded, …). Signatures are short-lived (policy default 5 min): send right away.
paymaster also accepts true (same URL, no context), a separate URL, or a viem PaymasterClient — then there is no fee
detail and no re-quote. createHppSessionClient takes the same option, so agent UserOps are charged under the same policy
(gas comes from the account's USDC.e, independent of the session's spend limit).
ensureReady({ sponsor }) accepts either a funded viem WalletClient (scripts, servers) or a RemoteSponsor
for browser apps — the page signs, your backend broadcasts:
const sponsor = {
submitDelegation: ({ account, authorization }) => post("/api/sponsor", { type: "delegate", account, authorization }),
submitDeploy: ({ account, initData, salt }) => post("/api/sponsor", { type: "deploy", account, initData, salt }),
};
await user.ensureReady({ sponsor }); // owner never needs ETH for setupThe SDK never touches a private key. Give it the two primitives every embedded-wallet SDK exposes and it takes the 7702 path (same address, no new deposit):
import { toEmbeddedOwner } from "@hpp-io/aa-sdk";
// Privy (react)
const { signMessage } = useSignMessage();
const { signAuthorization } = useSignAuthorization();
const owner = toEmbeddedOwner({
address: wallet.address,
signMessage: (hash) => signMessage({ message: { raw: hash } }, { address: wallet.address }).then((r) => r.signature),
signAuthorization: (a) => signAuthorization({ contractAddress: a.contractAddress, chainId: a.chainId, nonce: a.nonce }, { address: wallet.address }),
});
const user = await createHppAccount({ owner, chain: hppSepolia, bundlerUrl });Leave out signAuthorization for a wallet that cannot sign type-4 authorizations and the account becomes a
factory account automatically. HPP is a custom chain for these vendors: register it with viem's defineChain
(hppSepolia is exactly that) — no vendor-side approval is needed, since signing is chain-agnostic.
| owner | mode | address | how it becomes a smart account |
|---|---|---|---|
LocalAccount that can signAuthorization (embedded wallets, server keys) |
7702 (default) |
same as the EOA | ensureDelegated() sends one type-4 tx |
| WalletClient from a browser wallet (no 7702 signing exposed to dapps) | factory (auto) |
new counterfactual address | ensureReady({ sponsor }) calls KernelFactory.createAccount (initCode deployment needs a staked factory — not yet on HPP) |
Both modes share everything after that: batching, sessions, revocation. Force a mode with mode: "factory".
Pass the account itself as the signer — it produces ERC-1271 signatures:
import { x402Client, x402HTTPClient } from "@x402/core/client";
import { ExactEvmScheme } from "@x402/evm/exact/client";
const user = await createHppAccount({ owner, bundlerUrl: HPP_AA_ENDPOINTS[181228] });
const client = new x402HTTPClient(new x402Client().register("eip155:181228", new ExactEvmScheme(user.account)));
const payload = await client.createPaymentPayload(paymentRequired); // account signs, no UserOp, no gas
const res = await fetch(url, { method: "POST", headers: { ...client.encodePaymentSignatureHeader(payload) }, body });A smart account cannot sign these payloads with a plain owner signature: the token sees code at the
address and calls isValidSignature instead of ecrecover. This includes 7702-delegated EOAs —
once delegated, the address has code and a raw owner signature is rejected (FiatTokenV2: invalid signature). user.account.signTypedData() wraps the hash in the account's Kernel domain and prefixes
the root-validator selector; signErc1271TypedData / signErc1271Message expose the same thing
directly. Both account modes work — see examples/x402-exact.mjs (MODE=7702|factory).
A session lets an agent send UserOps; it cannot sign an x402 (EIP-3009) payment, because that signature is checked through the account's ERC-1271 and never passes the session's policies. For payments use a payment delegation instead: the account signs a delegation to the agent's key with on-chain caps (MetaMask delegation-framework enforcers, redeployed on HPP), and the HPP facilitator redeems it per payment through the account — the agent's key never holds USDC.e or ETH.
// wallet side — one UserOp once (installs the DelegationManager executor), then signatures only
const grant = await user.grantPaymentDelegation({
agent: agentKey.address, token: USDCe,
limit: parseUnits("5", 6), // or period: { amount, seconds } for a rolling cap
validUntil: BigInt(now + 30 * 86400), maxCalls: 100n,
});
// give the agent grant.permissionContext; keep grant.delegation to revoke:
await user.revokePaymentDelegation(grant.delegation); // one UserOp, effective immediately
// agent side — per 402 whose extra.assetTransferMethod === "erc7710"
const leaf = buildPaymentRedelegation(chain.id, agentKey.address, {
parentPermissionContext: grant.permissionContext,
facilitatorAddresses: requirements.extra.facilitatorAddresses, token, amount, payTo,
});
const payload = await signPaymentRedelegation({ chainId: chain.id, agent: agentKey, leaf, parentPermissionContext: grant.permissionContext });
// → x402 payload { delegationManager, permissionContext, delegator } for the exact scheme@metamask/x402 + @metamask/smart-accounts-kit produce the same bytes: register HPP's contracts
with overrideDeployedEnvironment(chain.id, "1.3.0", erc7710Environment(chain.id)) and use
x402Erc7710Client({ delegationProvider: createx402DelegationProvider({ account: agentKey, parentPermissionContext: grant.permissionContext, ... }) }).
What the caps enforce, on-chain: total or per-period USDC.e, expiry, max redemptions (grant); exact
amount, exact recipient, only HPP facilitator keys may redeem, short expiry (each payment). Full
example: examples/x402-erc7710.mjs against a facilitator that advertises erc7710 on /supported.
Only works with sellers settled by the HPP facilitator (other facilitators do not implement ERC-7710).
- Fees —
rundler_getUserOperationGasPrice, not viem's estimator (priority fee is 0 on HPP). - preVerificationGas — +15 % buffer by default (
pvgBufferPercent); HPP gas is ~98 % L1 data. - 7702 factory marker — delegation is a separate type-4 tx, so UserOps never carry
factory: "0x7702". - 7702 UserOp hash / signature — root signs EIP-191(userOpHash), verified by Kernel against
address(this). - Kernel validator install — Smart Sessions
initDataincludes theexecuteselector; installed inside the first UserOp. - Session nonce — nonce key type
0x01(module-sdk's helper emits0x00, which routes to the root key). - ERC-1271 signatures — payload hash wrapped in the account's Kernel domain + root-validator byte, so EIP-3009 / Permit / x402 verify against the account (a raw owner signature does not).
| ⑧ | ERC-20 paymaster ops need a real
paymasterPostOpGasLimit(postOp does thetransferFrom); viem leaves it 0 without a stub | the HPP paymaster stub returns 120k and the SDK keeps it | | ⑨ | A nonce lane's first use costs more verification gas (cold nonce slot) — reusing another lane's estimate failsAA26in the bundler's pre-sign simulation |sendCallsestimates per op; if you build ops by hand, estimate each lane |
chains.ts—hppSepolia,hppMainnetaddresses.ts— canonical addresses,TOKENSbundler.ts—createHppBundlerClient(fees, pVG buffer, ERC-7677 paymaster hook)account.ts—toKernelAccount(viemSmartAccount, 7702 / factory)sessions.ts—buildSession,enableSessionsCall,removeSessionCall,toSessionAccounterc1271.ts—signErc1271TypedData,signErc1271Message,kernelWrappedHasherc7710.ts— payment delegations:buildPaymentDelegation,signDelegationAsAccount,buildPaymentRedelegation,signPaymentRedelegation,encodeDelegations,erc7710Environmentindex.ts—HppAccount/createHppAccount,createHppSessionClient
npm test # encoding unit tests (byte layouts proven by test/cases/*)
PAYER_KEY=0x… npm run e2e # Sepolia e2e, 7702 path (sponsor = examples/.sdk-test-sponsor.json; never the refill funder)
RELAYER_KEY=0x… PAYER_KEY=0x… MODE=factory node examples/e2e-sepolia.mjs
RELAYER_KEY=0x… PAYER_KEY=0x… OWNER=embedded node examples/e2e-sepolia.mjs # 7702 through toEmbeddedOwner
RELAYER_KEY=0x… PAYER_KEY=0x… PAYMASTER_URL=… POLICY_ID=… ANCHOR_ID=… node examples/e2e-sepolia.mjs # sponsored: account ETH stays 0
PAYMASTER_URL=… DELEGATED_ACCOUNT_KEY=0x… node examples/paymaster-stub-check.mjs # stub+estimate only, reserves nothing